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 a commitment tree of the Merkle forest.
- The nullifier is computed correctly from the commitment and spending key.
- The note value equals the net withdrawal amount, plus the gasless fee, plus any change kept private.
- 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
Ownership is proven by key derivation, not a signature. The circuit derives the owner public key from the spending key inside the R1CS system:
ownerPk.Ax = BabyPbk(spending_key).Ax
The prover must know the spending_key whose scalar multiplication of the
BabyJubJub base point Base8 produces the Ax coordinate embedded in the note
commitment. That is the discrete logarithm relation on the curve — it cannot be
faked, and the prover cannot substitute an arbitrary ownerPk.
This is a stronger statement than a signature check: BabyPbk proves knowledge of
the private key directly, where EdDSA only proves knowledge of a valid signature.
The derived Ax is bound to the note commitment, so an attacker cannot swap in a
different public key even if they could forge a signature-style check.
See Keys & Identity for where spending_key comes from.
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 Gasless Fees and choosing a submit route.change_value— the remainder, returned to the pool as a new private note.0for a total unshield.
All values 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 |
change_commitment | Field | Commitment of the change note; 0 for a total unshield |
Private Inputs (Prover Only)
| Field | Type | Description |
|---|---|---|
note_value | Field | Total note value (must equal amount + fee + change_value) |
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 |
change_value | Field | Value of the change note; 0 for a total unshield |
change_blinding | Field | Blinding factor for the change note commitment |
change_owner_pubkey | Field | BabyJubJub 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.
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 + change_value. The prover cannot inflate the withdrawal amount, understate the fee, or mint value into the change note 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,903 |
| Tree depth | 20 (up to 1,048,576 notes per tree) |
| Public inputs | 7 (merkle_root, nullifier, amount, recipient, asset_id, fee, change_commitment) |
| Private inputs | 7 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
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 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.