Skip to main content

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:

  1. Each real input note exists in the Merkle tree of commitments.
  2. The sender knows the spending_key corresponding to the ownerPk embedded in each input note (via BabyJubJub key derivation — see below).
  3. Output commitments are computed correctly.
  4. Total input value equals total output value plus the gasless fee.
  5. All notes use the same asset.
  6. No value exceeds the u128 range.
  7. When two real inputs are used, their nullifiers are distinct (no self-double-spend in a single transaction).
  8. Dummy input nullifiers are forced to zero.

Key Design: BabyJubJub Key Derivation

Changed in v0.6.0

transfer.circom replaced EdDSA signature verification with BabyJubJub (BabyPbk) key derivation, reducing constraint count by ~6,000 and eliminating 10 private input signals.

In previous versions, ownership was proved by providing an EdDSA signature over the note. In v0.6.0, the circuit 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 Ax is 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)

FieldTypeDescription
merkle_rootFieldCommitment 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_idFieldAsset being transferred (must match all note asset IDs)
feeFieldGasless fee deducted from input sum; credited to block author

Private Inputs (Prover Only)

Input Notes (Consumed)

FieldTypeDescription
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

FieldTypeDescription
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)

FieldTypeDescription
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 { buildTransferInput } from '@orbinum/sdk/shielded-pool';

const input = await buildTransferInput({
// Public
merkle_root: currentRoot,
nullifiers: [nullifier0, nullifier1],
commitments: [outputCommitmentForBob, changeCommitmentForAlice],
asset_id: 0n,
fee: 1n,

// Private — input notes
input_values: [60n, 41n],
input_asset_ids: [0n, 0n],
input_blindings: [blinding0, blinding1],
spending_keys: [aliceSpendingKey, aliceSpendingKey],

// Merkle proofs
input_path_elements: [pathElements0, pathElements1],
input_path_indices: [pathIndices0, pathIndices1],

// Output notes
output_values: [100n, 0n],
output_asset_ids: [0n, 0n],
output_owner_pubkeys: [bobPubkeyAx, alicePubkeyAx],
output_blindings: [outputBlinding0, outputBlinding1],
});

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):

import { buildTransferInput, buildDummySlot } from '@orbinum/sdk/shielded-pool';

const input = await buildTransferInput({
// Public
merkle_root: currentRoot,
nullifiers: [nullifier0, 0n], // second is dummy: forced to zero
commitments: [outputCommitmentForBob, changeCommitmentForAlice],
asset_id: 0n,
fee: 1n,

// Private — first note real, second is dummy (value = 0)
input_values: [101n, 0n],
input_asset_ids: [0n, 0n],
input_blindings: [blinding0, 0n],
spending_keys: [aliceSpendingKey, 0n], // dummy spending_key irrelevant

// Merkle proof for dummy slot: all-zero path (ignored by circuit)
input_path_elements: [pathElements0, dummyPath],
input_path_indices: [pathIndices0, dummyIndices],

// Output notes: 100 to Bob, 0 change
output_values: [100n, 0n],
output_owner_pubkeys: [bobPubkeyAx, alicePubkeyAx],
// ...
});

Security Properties

Ownership via discrete log

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.

Double-spend prevention

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.

Dummy slot soundness

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.

Anti-spam (pallet level)

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

ParameterValue
Constraints33,687
Tree depth20 (up to 1,048,576 notes)
Public inputs7 (merkle_root, nullifiers[2], commitments[2], asset_id, fee)
Private inputs9 scalars + 40 Merkle path elements
Proving schemeGroth16 / BN254
Proving time~2–3 s (client machine)
Verification time~15 ms
Development trusted setup

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.