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:
- The sender knows the
spending_keywhose BabyJubJub public key (Ax) is embedded in the note commitment. - The note exists in the Merkle tree of commitments.
- The nullifier is computed correctly from the commitment and spending key.
- The note value equals the net withdrawal amount plus the gasless fee.
- The asset ID in the note matches the public asset ID.
- The note value and fee are within u128 range.
Key Design: BabyJubJub Key Derivation
unshield.circom removed the note_owner private input. The owner public key is now derived inside the circuit from spending_key via BabyPbk.
In previous versions, 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.
In v0.6.0, 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.
Gasless Fee
The unshield circuit supports a gasless fee model: the block author collects a fee from the note value directly inside the proof.
note_value === amount + fee
amount— net tokens received by the public recipient address.fee— credited to the block author inside the shielded pool without a separate signed transaction.
The fee is a public input, visible on-chain. Both note_value and fee are range-checked to u128.
Public Inputs (On-Chain)
| Field | Type | Description |
|---|---|---|
merkle_root | Field | Commitment tree root at proof generation time |
nullifier | Field | Nullifier to prevent double-spend of this note |
amount | Field | Net withdrawal — what the recipient receives |
recipient | Field | Recipient public address (validated non-zero by the pallet) |
asset_id | Field | Asset being unshielded (publicly revealed) |
fee | Field | Gasless fee deducted from note value; paid to block author |
Private Inputs (Prover Only)
| Field | Type | Description |
|---|---|---|
note_value | Field | Total note value (must equal amount + fee) |
note_asset_id | Field | Asset ID in the note (must match public asset_id) |
note_blinding | Field | Random blinding factor used when the note was created |
spending_key | Field | Secret key — derives ownerPk via BabyPbk and computes the nullifier |
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) |
Usage
Basic Unshield
Alice withdraws 99 tokens from her private note to her public address, paying a 1-unit fee:
import { buildUnshieldInput } from '@orbinum/sdk/shielded-pool';
const input = await buildUnshieldInput({
// Public — visible on-chain
merkle_root: currentRoot,
nullifier: computedNullifier,
amount: 99n, // net withdrawal (note_value - fee)
recipient: alicePublicAddress,
asset_id: 0n, // native token
fee: 1n,
// Private — only Alice knows
note_value: 100n, // amount + fee
note_asset_id: 0n,
note_blinding: randomBlinding,
spending_key: aliceSpendingKey, // ownerPk derived inside circuit
// Merkle proof
path_elements: merkleProof.pathElements,
path_indices: merkleProof.pathIndices,
});
Partial Unshield (Split First)
The unshield circuit consumes the entire note value. To withdraw only part of a note, use the Transfer circuit first to split it:
// Step 1: Split note via private transfer
// 100 token note → 60 (new note for withdrawal) + 40 (change note)
Transfer: [100n] → [60n, 40n]
// Step 2: Unshield the 60-token note
Unshield: note_value=60n → amount=59n, fee=1n
Multi-Asset Unshield
Alice withdraws 500 of asset #42:
const input = await buildUnshieldInput({
merkle_root: currentRoot,
nullifier: computedNullifier,
amount: 498n,
recipient: alicePublicAddress,
asset_id: 42n,
fee: 2n,
note_value: 500n,
note_asset_id: 42n,
// ...
});
Security Properties
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.
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.
The circuit enforces note_value === amount + fee. The prover cannot inflate the withdrawal amount or understate the fee without producing an invalid proof.
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
| Parameter | Value |
|---|---|
| Constraints | 16,033 |
| Tree depth | 20 (up to 1,048,576 notes) |
| Public inputs | 6 (merkle_root, nullifier, amount, recipient, asset_id, fee) |
| Private inputs | 6 scalars + 40 Merkle path elements |
| Proving scheme | Groth16 / BN254 |
| Proving time | ~750 ms (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 unshields the entire note. To withdraw a partial amount, split the note first via a private transfer.
recipientis 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 be a historic root accepted by the pallet. If the tree advances between proof generation and submission, the transaction will be rejected.