Skip to main content

Unshield

Unshield is the exit mechanism from the Orbinum privacy pool. It converts one private note into publicly visible tokens at a recipient address. After unshielding, the funds are no longer private — the amount and recipient address are visible on-chain.


What the Circuit Proves

The unshield circuit (unshield.circom) generates a Groth16 zero-knowledge proof that attests, without revealing the spending key or the Merkle path:

  1. The sender knows the spending_key whose BabyJubJub public key (Ax) is embedded in the note commitment.
  2. The note exists in a commitment tree of the Merkle forest.
  3. The nullifier is computed correctly from the commitment and spending key.
  4. The note value equals the net withdrawal amount, plus the gasless fee, plus any change kept private.
  5. The asset ID in the note matches the public asset ID.
  6. The note value and fee are within u128 range.

Key Design: BabyJubJub Key Derivation

Ownership is proven by key derivation, not a signature

unshield.circom removed the note_owner private input. The owner public key is now derived inside the circuit from spending_key via BabyPbk.

Earlier revisions of the circuit accepted note_owner as an explicit private input — the raw Ax coordinate of the owner's key. This created a formal soundness gap: a prover could supply any ownerPk value as long as the resulting commitment was in the Merkle tree.

ownerPk is derived deterministically inside the circuit:

ownerPk.Ax = BabyPbk(spending_key).Ax

The circuit uses key_derivation.Ax in the note commitment computation. The prover cannot supply an arbitrary ownerPk — they must know the spending_key whose scalar multiplication by the BabyJubJub Base8 point yields the correct Ax. This closes the formal soundness gap.

API impact: note_owner is no longer a private input. Only spending_key is needed — ownership is fully determined inside the circuit.


Value Conservation and the Gasless Fee

The circuit enforces that the consumed note's value is fully accounted for:

note_value === amount + fee + change_value
  • amount — net tokens received by the public recipient address.
  • fee — the gasless relay fee, credited inside the shielded pool without a separate signed transaction. Who receives it is decided by the dispatch origin, never by calldata: the EVM signer when submitted through the precompile, the signer's registered EVM address for a signed extrinsic, and the block author when unsigned. Submitting unsigned needs no wallet and publishes no transaction naming the withdrawer — an unshield reveals its recipient and amount either way, but not who submitted it. See Fee Lifecycle and choosing a submit route.
  • change_value — the remainder, returned to the pool as a new private note. 0 for a total unshield.

All values are range-checked to u128.


Public Inputs (On-Chain)

FieldTypeDescription
merkle_rootFieldCommitment tree root at proof generation time
nullifierFieldNullifier to prevent double-spend of this note
amountFieldNet withdrawal — what the recipient receives
recipientFieldRecipient public address (validated non-zero by the pallet)
asset_idFieldAsset being unshielded (publicly revealed)
feeFieldGasless fee deducted from note value
change_commitmentFieldCommitment of the change note; 0 for a total unshield

Private Inputs (Prover Only)

FieldTypeDescription
note_valueFieldTotal note value (must equal amount + fee + change_value)
note_asset_idFieldAsset ID in the note (must match public asset_id)
note_blindingFieldRandom blinding factor used when the note was created
spending_keyFieldSecret key — derives ownerPk via BabyPbk and computes the nullifier
change_valueFieldValue of the change note; 0 for a total unshield
change_blindingFieldBlinding factor for the change note commitment
change_owner_pubkeyFieldBabyJubJub Ax of the change note owner
path_elements[20]Field[20]Sibling hashes for the Merkle membership proof
path_indices[20]u8[20]Path directions per level (0 = left, 1 = right)

When change_value > 0, the circuit constrains change_commitment to equal Poseidon4(change_value, note_asset_id, change_owner_pubkey, change_blinding). A tampered commitment, wrong blinding, wrong owner, or wrong asset is rejected at the R1CS level.


Usage

Total Unshield

Alice withdraws her entire 100-token note: 99 to her public address, 1 as the relay fee.

import { generateUnshieldProof, WebArtifactProvider } from '@orbinum/sdk';

const { proof, publicSignals } = await generateUnshieldProof(
{
merkleRoot: currentRoot,
nullifier: computedNullifier,
amount: 99n, // net withdrawal
assetId: 0n, // native token
recipient: recipientFieldElement,
blinding: noteBlinding,
spendingKey: aliceSpendingKey,
pathSiblings: merkleProof.pathSiblings,
leafIndex: merkleProof.leafIndex,
fee: 1n,
// changeValue omitted → 0n → total unshield
},
{ provider: new WebArtifactProvider() }
);

recipient is the address encoded as a BN254 field element — for a Substrate account that is Poseidon(le32(accountId32)).

Partial Unshield

Withdraw part of a note and keep the rest private, in a single transaction. Set changeValue so that note_value == amount + fee + changeValue:

// 100-token note → 59 withdrawn, 1 fee, 40 kept as a new private note
const { proof, publicSignals } = await generateUnshieldProof(
{
merkleRoot: currentRoot,
nullifier: computedNullifier,
amount: 59n,
assetId: 0n,
recipient: recipientFieldElement,
blinding: noteBlinding,
spendingKey: aliceSpendingKey,
pathSiblings: merkleProof.pathSiblings,
leafIndex: merkleProof.leafIndex,
fee: 1n,
changeValue: 40n, // remainder returns to the pool as a change note
// changeBlinding auto-generated with a CSPRNG when omitted
// changeOwnerPubkey defaults to the same owner
},
{ provider: new WebArtifactProvider() }
);

The change note belongs to the same owner by default. Pass changeOwnerPubkey to direct it elsewhere.

No split step needed

Earlier versions of the circuit consumed the whole note, so withdrawing part of one meant first splitting it with a private transfer. That is no longer necessary — the change note is produced by the unshield itself.

Multi-Asset Unshield

The same call with a different assetId — 498 of asset #42, 2 as fee, from a 500-token note:

{
amount: 498n,
assetId: 42n,
fee: 2n,
// note_value 500 == 498 + 2 + 0
}

Security Properties

Ownership via discrete log

BabyPbk(spending_key) derives ownerPk inside the circuit. A prover cannot substitute an arbitrary ownerPk — they must know the scalar whose product with Base8 equals the Ax in the note commitment.

Double-spend prevention

The nullifier is computed from the note commitment and spending key inside the circuit. The pallet rejects any transaction whose nullifier is already in the nullifier set.

Amount integrity

The circuit enforces note_value === amount + fee + change_value. The prover cannot inflate the withdrawal amount, understate the fee, or mint value into the change note without producing an invalid proof.

Limitation: amount is public

Unshield makes the amount and recipient address visible on-chain. The note's history inside the pool remains private, but the exit transaction is fully public.


Circuit Parameters

ParameterValue
Constraints16,903
Tree depth20 (up to 1,048,576 notes per tree)
Public inputs7 (merkle_root, nullifier, amount, recipient, asset_id, fee, change_commitment)
Private inputs7 scalars + 40 Merkle path elements
Proving schemeGroth16 / BN254
Proving time~750 ms (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

  • recipient is validated as non-zero by the pallet but is not constrained by the circuit itself — any field element is accepted at the proof level.
  • The Merkle root used at proof generation time must still be accepted by the pallet at submission time. Roots of sealed trees are permanent anchors, but a root from the currently-filling tree ages out of the historic ring — regenerate the proof if submission is delayed.