Private Transfer
A private transfer moves shielded tokens from one vault to another without revealing the sender, recipient, amount, or asset type on-chain. It consumes one or two input notes owned by the sender and creates two new output notes — one for the recipient and one for the sender's change.
What the Circuit Proves
The transfer circuit (transfer.circom) generates a Groth16 zero-knowledge proof that attests, without revealing private data:
- Each real input note exists in a commitment tree of the Merkle forest.
- The sender knows the
spending_keycorresponding to theownerPkembedded in each input note (via BabyJubJub key derivation — see below). - Output commitments are computed correctly.
- Total input value equals total output value plus the gasless fee.
- All notes use the same asset.
- No value exceeds the u128 range.
- When two real inputs are used, their nullifiers are distinct (no self-double-spend in a single transaction). The pallet enforces this independently of the proof: two equal non-dummy nullifiers are rejected on-chain.
- Dummy input nullifiers are forced to zero.
Key Design: BabyJubJub Key Derivation
transfer.circom replaced EdDSA signature verification with BabyJubJub (BabyPbk) key derivation, reducing constraint count by ~6,000 and eliminating 10 private input signals.
Earlier revisions proved ownership with an EdDSA signature over the note. The circuit now derives the owner public key directly from the spending key inside the R1CS system:
ownerPk.Ax = BabyPbk(spending_key).Ax
The prover must know spending_key such that scalar multiplication of the BabyJubJub base point Base8 by spending_key produces the Ax coordinate embedded in the note commitment. This is the discrete logarithm relation on the BabyJubJub curve — it cannot be faked.
Why this is stronger than EdDSA:
- BabyPbk proves knowledge of the private key directly. EdDSA only proves knowledge of a valid signature, which is a weaker statement.
- The derived
Axis bound to the note commitment, so an attacker cannot substitute a different public key even if they can forge a signature-style check. - The approach matches the Tornado Cash Nova key derivation model.
API impact: Callers no longer provide input_owner_Ax, input_owner_Ay, input_sig_R8x, input_sig_R8y, or input_sig_S. Only spending_keys[2] is needed — the circuit derives ownership internally.
Key Design: Dummy Note Support
Private transfers always consume exactly two input slots and produce exactly two output slots. When a user has only one note to spend, the second slot is filled with a dummy note — a placeholder with value = 0.
is_dummy[i] = IsZero(input_values[i])
IsZero is deterministic in R1CS: a prover cannot claim is_dummy = 1 for a note with value > 0. This is the same technique used in Zcash Sapling.
Dummy slots skip:
- Merkle membership verification
- Nullifier derivation and correctness check
- Ownership (BabyPbk) check
Dummy slots are still bound by:
nullifiers[i] * is_dummy[i].out === 0— the nullifier for a dummy slot must be zero. A prover cannot insert a real nullifier in the dummy slot while bypassing membership checks.- The anti-spam check in the pallet: a transaction where all nullifiers are zero (both inputs dummy) is rejected at the transaction pool level.
Public Inputs (On-Chain)
| Field | Type | Description |
|---|---|---|
merkle_root | Field | Commitment tree root at proof generation time |
nullifiers[2] | Field[2] | Nullifiers of the consumed input notes |
commitments[2] | Field[2] | Commitments of the newly created output notes |
asset_id | Field | Asset being transferred (must match all note asset IDs) |
fee | Field | Gasless fee deducted from input sum; credited per the dispatch origin — see Who receives the fee |
Private Inputs (Prover Only)
Input Notes (Consumed)
| Field | Type | Description |
|---|---|---|
input_values[2] | u128[2] | Note values (set second to 0 for a dummy slot) |
input_asset_ids[2] | Field[2] | Asset IDs of input notes |
input_blindings[2] | Field[2] | Random blinding factors used when the notes were created |
spending_keys[2] | Field[2] | Secret keys — derive ownerPk via BabyPbk and compute nullifiers |
Merkle Proofs
| Field | Type | Description |
|---|---|---|
input_path_elements[2][20] | Field[2][20] | Sibling hashes for each Merkle proof |
input_path_indices[2][20] | u8[2][20] | Path directions per level (0=left, 1=right) |
Output Notes (Created)
| Field | Type | Description |
|---|---|---|
output_values[2] | u128[2] | Values of the new output notes |
output_asset_ids[2] | Field[2] | Asset IDs of the output notes |
output_owner_pubkeys[2] | Field[2] | Ax coordinate of each recipient's key |
output_blindings[2] | Field[2] | Random blinding factors for output notes |
Usage
Two-note Transfer
Alice sends 100 tokens to Bob using two of her notes (60 + 41), paying a 1-unit fee:
import { generateTransferProof, WebArtifactProvider } from '@orbinum/sdk';
const { proof, publicSignals } = await generateTransferProof(
{
merkleRoot: currentRoot,
fee: 1n,
inputs: [
{
nullifier: nullifier0,
value: 60n,
assetId: 0n,
ownerPk: alicePubkeyAx,
blinding: blinding0,
spendingKey: aliceSpendingKey,
pathSiblings: proof0.pathSiblings,
leafIndex: proof0.leafIndex,
},
{
nullifier: nullifier1,
value: 41n,
assetId: 0n,
ownerPk: alicePubkeyAx,
blinding: blinding1,
spendingKey: aliceSpendingKey,
pathSiblings: proof1.pathSiblings,
leafIndex: proof1.leafIndex,
},
],
outputs: [
{
commitment: outputCommitmentForBob,
value: 100n,
assetId: 0n,
ownerPk: bobPubkeyAx,
blinding: outputBlinding0,
},
{
commitment: changeCommitmentForAlice,
value: 0n,
assetId: 0n,
ownerPk: alicePubkeyAx,
blinding: outputBlinding1,
},
],
},
{ provider: new WebArtifactProvider() }
);
Single-note Transfer (Dummy Slot)
Alice has one note with 101 tokens and transfers 100 to Bob, keeping 0 change (or using a dummy second output):
The circuit always takes exactly two input slots. When you only have one note to spend, fill the
second with buildDummyTransferInput — a zero-valued slot the circuit recognises and skips:
import {
generateTransferProof,
buildDummyTransferInput,
WebArtifactProvider,
} from '@orbinum/sdk';
const { proof, publicSignals } = await generateTransferProof(
{
merkleRoot: currentRoot,
fee: 1n,
inputs: [
{
nullifier: nullifier0,
value: 101n,
assetId: 0n,
ownerPk: alicePubkeyAx,
blinding: blinding0,
spendingKey: aliceSpendingKey,
pathSiblings: proof0.pathSiblings,
leafIndex: proof0.leafIndex,
},
buildDummyTransferInput(0n), // assetId must match the real note
],
outputs: [
{ commitment: outputCommitmentForBob, value: 100n, assetId: 0n, ownerPk: bobPubkeyAx, blinding: outputBlinding0 },
{ commitment: changeCommitmentForAlice, value: 0n, assetId: 0n, ownerPk: alicePubkeyAx, blinding: outputBlinding1 },
],
},
{ provider: new WebArtifactProvider() }
);
A dummy slot has value: 0n and nullifier: 0n. The circuit enforces that a zero-valued input
contributes nothing and that its nullifier is zero, so it cannot be used to spend anything. The
pallet separately rejects a transfer whose nullifiers are all zero.
The extrinsic takes one argument beyond the circuit's public inputs: circuit_version, the
version the spent notes were created under. The proof is verified against that version's
verification key, which is what keeps older notes spendable after a key rotation. Resolve it with
the SDK's CircuitVersionResolver rather than assuming the active version.
Who receives the fee
Nothing in the calldata names the fee recipient. The chain reads it from the dispatch origin — how the call arrived, which the caller cannot forge:
| Submitted via | Credited to |
|---|---|
| ShieldedPool precompile | whoever signed that EVM transaction and paid its gas |
| Signed extrinsic | the signer's registered EVM address |
| Unsigned extrinsic | the block author |
The recipient is read from the origin rather than the calldata because a calldata field would be an unauthenticated claim. A private transfer is broadcast before it is included, so anyone could take a propagated proof, resubmit it naming themselves, and collect a fee they never paid for. An origin cannot be rewritten that way: the party credited is the one who actually bore the cost of submitting.
The fee is a public input to the proof, so it cannot be altered without regenerating the proof.
See Fee Lifecycle.
Choosing a submit route
Which origin a transfer arrives under is the sender's choice, and it is the difference between leaving a public trace and leaving none.
An unsigned submit needs no wallet at all: the proof authorises the spend, so there is no signature to check and nothing to charge gas to. What lands on chain is a nullifier and two commitments — the same as any other transfer, with nothing tying them to a submitter.
Signing through the precompile publishes an Ethereum transaction alongside it. That transaction
is public and permanent, and it names the sender's address, the precompile it called, and the
privateTransfer selector. Anyone reading the chain learns this address made a private transfer in
this block. The amount, the recipient and the spent note stay hidden — but participation does not.
| Unsigned | Precompile | |
|---|---|---|
| Wallet signature | none | required |
| Gas | none | paid by the signer |
| Relay fee | to the block author | back to the signer |
| Public trace | none | an EVM transaction naming the sender |
| Time to inclusion | ~4.5 s | ~8.2 s |
Measured against a development node; treat the timings as relative, not absolute.
For a wallet spending its own notes, unsigned is better on every axis a user cares about. The one thing signing buys back is the relay fee — and the gas it costs to do so exceeds it, so a sender who signs to recover their own fee ends up behind.
Signing is for the case it was built for: relaying somebody else's transfer. There the submitter pays the gas and is credited the fee, which is the trade that makes third-party relaying worth doing.
The same choice applies to unshield, but the stakes differ: an unshield publishes its recipient and amount by design. Submitting it unsigned withholds who withdrew, not what was withdrawn.
Security Properties
BabyPbk(spending_key) derives ownerPk inside the circuit. The prover must know the private scalar whose multiplication by Base8 equals the ownerPk in the note.
Nullifiers are inserted into the pallet's nullifier set after each transaction. When both inputs are real, the circuit enforces that their nullifiers are distinct — a note cannot be spent twice in the same transaction.
IsZero(value) is deterministic in R1CS. A prover cannot claim is_dummy = 1 for a note with value > 0. Dummy nullifiers are forced to zero and cannot be used to spend a real note while bypassing Merkle checks.
The pallet rejects any private_transfer where all nullifiers are zero (both inputs dummy). This prevents free Merkle tree inflation. Enforced in both validate_unsigned (tx pool) and execute (extrinsic).
Circuit Parameters
| Parameter | Value |
|---|---|
| Constraints | 33,687 |
| Tree depth | 20 (up to 1,048,576 notes per tree) |
| Public inputs | 7 (merkle_root, nullifiers[2], commitments[2], asset_id, fee) |
| Private inputs | 9 scalars + 40 Merkle path elements |
| Proving scheme | Groth16 / BN254 |
| Proving time | ~2–3 s (client machine) |
| Verification time | ~15 ms |
The proving key distributed with this release uses a single-party trusted setup. It is not secure for production use. A multi-party ceremony with 50+ participants is required before mainnet.
Known Limitations
- The circuit processes exactly 2 input notes and 2 output notes. Single-note spends require a dummy slot.
- There is no range check on output note values individually — only the conservation constraint and u128 range on inputs enforce correctness. Incorrect output splits would still satisfy the circuit but produce an unspendable change note.
- Recipient anonymity depends on the viewing key encryption scheme, not on this circuit directly.