ZK Shielded Privacy Layer
Transparent Plonky3/FRI zero-knowledge proofs for private value transfers on Lichen.
Architecture
Lichen's privacy layer uses a shielded UTXO pool model (similar to Zcash Sapling). Value enters the pool via shield operations, moves privately via transfers, and exits via unshield operations. The live runtime validates scheme-versioned proof envelopes with a native Plonky3 FRI verifier over Goldilocks while commitments, nullifiers, and Merkle nodes use canonical Poseidon2-derived 32-byte values.
Pool model, not address model. There are no "shielded addresses." Notes live in a global Merkle tree. Only the holder of the note's spending key can spend it.
| Component | Detail |
|---|---|
| Field / hash domain | Goldilocks words over canonical 32-byte values |
| Proof system | Plonky3 FRI (plonky3-fri-poseidon2) |
| Hash function | Poseidon2 over canonical Goldilocks limbs |
| Merkle tree | Binary, depth 20 (1,048,576 leaf capacity) |
| Note format | commitment = Poseidon2(value, blinding) |
| Nullifier | nullifier = Poseidon2(serial, spending_key) |
| Key directory | None required in current releases; validators and zk-prove
operate directly on native Plonky3 proof envelopes |
| Proof size | Variable-length bincode-serialized Plonky3 proof payload |
Current runtime. Validators and the zk-prove CLI no longer require
bundled pk_*/vk_* files or a shared ~/.lichen/zk/
cache. Proofs are generated and verified as native Plonky3 envelopes.
Key Concepts
Notes
A note represents value in the shielded pool. Each note has:
- value — amount in spores (1 LICN = 1,000,000,000 spores)
- blinding — random canonical 32-byte scalar-compatible value for hiding the note amount
- serial — random unique identifier for nullifier derivation
- spending_key — secret key that authorizes spending
The commitment Poseidon2(value, blinding) is stored on-chain in the
Merkle tree.
The preimage (value, blinding, serial,
spending_key) must be kept secret and persisted.
Nullifiers
To spend a note, the owner reveals the nullifier =
Poseidon2(serial, spending_key).
The chain records spent nullifiers to prevent double-spending. Since the nullifier is derived
deterministically
from the note's secrets, only the owner can produce it, and each note can only be spent once.
Merkle Tree
All commitments are inserted into a binary Merkle tree of depth 20. The Merkle root is stored in the pool state and updated with each shield or transfer operation. The root is a canonical 32-byte Poseidon2 digest displayed as a 64-character hex string (not base58, since it's a hash, not an address).
Fee Schedule
| Operation | Opcode | Compute Units | Approx. Fee |
|---|---|---|---|
| Shield | 23 | 100,000 | ~0.0001 LICN |
| Unshield | 24 | 150,000 | ~0.00015 LICN |
| Transfer | 25 | 200,000 | ~0.0002 LICN |
The fee payer is always instruction.accounts[0] — a transparent account that pays
the transaction fee. The shielded value is separate from the fee.
Shield (Deposit into Pool)
Shields move value from a transparent account into the shielded pool. The sender's on-chain balance is debited, and a new commitment is inserted into the Merkle tree.
What happens on-chain
- Sender balance is debited by
amount - ZK proof is verified (proves commitment corresponds to declared amount)
- Commitment is inserted as a new Merkle leaf
- Merkle root is recalculated
- Pool state counters are updated (
shieldCount,totalShielded,commitmentCount)
Save your note secrets! After shielding, you must persist blinding,
serial, and spending_key. Without them, your shielded value is
permanently locked.
Unshield (Withdraw from Pool)
Unshields move value from the pool back to a transparent account. The owner proves knowledge of a note in the Merkle tree and reveals the nullifier to prevent double-spending.
What happens on-chain
- Nullifier is checked — must not be already spent
- Merkle root is verified against current pool state
- ZK proof is verified (proves: note exists in tree, nullifier is correct, recipient matches)
- Recipient balance is credited by
amount - Nullifier is marked as spent
- Pool state is updated (
unshieldCount,totalShieldeddecreases)
Transfer (Private Send)
Transfers move value entirely within the shielded pool. The circuit is 2-in-2-out: it consumes 2 input notes and creates 2 output notes. Value conservation is enforced by the ZK circuit (sum of inputs = sum of outputs).
What happens on-chain
- Both nullifiers are checked — neither can be already spent, and they must be distinct
- Merkle root is verified against current pool state
- ZK proof is verified with public inputs:
[merkle_root, null_a, null_b, comm_c, comm_d] - Both nullifiers are marked as spent
- Two new commitments are inserted into the Merkle tree
- Merkle root is recalculated
- Pool state is updated (
transferCount,commitmentCount+2,nullifierCount+2;totalShieldedunchanged)
No on-chain balance change. Transfers don't credit or debit any transparent account. The fee payer's account is included only for transaction fee deduction.
Self-transfers and splitting
If you only need to spend one note, you still need 2 inputs. Shield a second note (even a small one) to pair with it. Output amounts can be split arbitrarily as long as the total is conserved.
zk-prove shield
./target/release/zk-prove shield --amount <spores>
Generates a native Plonky3 proof envelope for a shield operation. No shared key directory or compatibility bundle is required.
Output (JSON)
{
"type": "shield",
"commitment": "912f53dd...", // 32-byte hex — store on chain
"blinding": "a1b2c3d4...", // 32-byte hex — SAVE LOCALLY
"serial": "e5f6a7b8...", // 32-byte hex — SAVE LOCALLY
"spending_key": "1234abcd...", // 32-byte hex — SAVE LOCALLY
"proof": "deadbeef..." // variable-length hex — Plonky3 proof payload
}
zk-prove unshield
./target/release/zk-prove unshield \
--amount <spores> \
--blinding <hex> --serial <hex> --spending-key <hex> \
--recipient <hex_32_recipient> \
--merkle-root <hex> --merkle-path <hex,hex,...> --path-bits 0,1,0,...
Generates a native Plonky3 proof envelope for an unshield (withdrawal) operation.
Prerequisites
- The note secrets (
blinding,serial,spending_key) from the original shield - The current Merkle root and path for the note's leaf index (from
getShieldedMerklePath)
Output (JSON)
{
"type": "unshield",
"nullifier": "f08d00a6...", // 32-byte hex
"recipient_hash": "c1d2e3f4...", // 32-byte hex
"proof": "abcdef01..." // variable-length hex — Plonky3 proof payload
}
zk-prove transfer
./target/release/zk-prove transfer \
--transfer-json witness.json
Generates a native Plonky3 proof envelope for a 2-in-2-out shielded transfer.
Witness file format (witness.json)
{
"merkle_root": "<hex 32-byte>",
"inputs": [
{
"amount": 300000000,
"blinding": "<hex>",
"serial": "<hex>",
"spending_key": "<hex>",
"merkle_path": ["<hex>", "..."],
"path_bits": [false, true, "..."]
},
{ "...second input note..." }
],
"outputs": [
{ "amount": 350000000 },
{ "amount": 150000000 }
]
}
merkle_path— 20 sibling hashes fromgetShieldedMerklePathpath_bits— 20 booleans (left/right at each tree level)- Output amounts must sum to input amounts (value conservation)
- Output blindings are generated randomly if not provided
Output (JSON)
{
"type": "transfer",
"merkle_root": "7047a713...",
"nullifier_a": "8580318a...",
"nullifier_b": "ade8e6f4...",
"commitment_c": "a1b2c3d4...",
"commitment_d": "e5f6a7b8...",
"proof": "cafebabe...",
"outputs": [
{
"amount": 350000000,
"blinding": "...", "serial": "...", "commitment": "..."
},
{
"amount": 150000000,
"blinding": "...", "serial": "...", "commitment": "..."
}
]
}
The outputs array contains the secrets for each new note. The recipient
needs these values to spend the note later.
Python SDK
The lichen Python package provides instruction builders for all three operations:
from lichen import (
Connection, Keypair, TransactionBuilder,
shield_instruction, unshield_instruction, transfer_instruction,
)
conn = Connection("http://127.0.0.1:8899")
kp = Keypair.load("keypairs/deployer.json")
# ── Shield ──
ix = shield_instruction(
sender=kp.public_key(),
amount=500_000_000, # 0.5 LICN in spores
commitment=bytes.fromhex(shield_json["commitment"]),
proof=bytes.fromhex(shield_json["proof"]),
)
bh = await conn.get_recent_blockhash()
tx = TransactionBuilder().add(ix).set_recent_blockhash(bh).build_and_sign(kp)
sig = await conn.send_transaction(tx)
# ── Unshield ──
ix = unshield_instruction(
recipient=kp.public_key(),
amount=500_000_000,
nullifier=bytes.fromhex(unshield_json["nullifier"]),
merkle_root=bytes.fromhex(root_hex),
recipient_hash=bytes.fromhex(unshield_json["recipient_hash"]),
proof=bytes.fromhex(unshield_json["proof"]),
)
# ── Transfer ──
ix = transfer_instruction(
fee_payer=kp.public_key(),
nullifiers=[bytes.fromhex(t["nullifier_a"]), bytes.fromhex(t["nullifier_b"])],
output_commitments=[bytes.fromhex(t["commitment_c"]), bytes.fromhex(t["commitment_d"])],
merkle_root=bytes.fromhex(t["merkle_root"]),
proof=bytes.fromhex(t["proof"]),
)
End-to-End Workflow
# 1. Shield — deposit 0.5 LICN into the pool
zk-prove shield --amount 500000000 > shield.json
# → Save blinding, serial, spending_key from shield.json
# 2. Build & send the shield transaction (Python)
ix = shield_instruction(sender, 500000000, commitment, proof)
# → Wait for confirmation; note leaf index = commitmentCount - 1
# 3. Query Merkle path for later spending
path = await conn._rpc("getShieldedMerklePath", [leaf_index])
# 4. (Optional) Transfer within pool
# Build witness.json with 2 input notes + 2 output amounts
zk-prove transfer --transfer-json witness.json > transfer.json
# → New output note secrets are in transfer.json["outputs"]
# 5. Unshield — withdraw back to transparent account
zk-prove unshield --amount 500000000 ... > unshield.json
ix = unshield_instruction(recipient, 500000000, nullifier, root, hash, proof)
# → Spores appear in recipient's transparent balance
JSON-RPC Methods
All methods are available on the main RPC endpoint (port 8899).
getShieldedPoolState
Returns the full shielded pool state.
// Request
{"jsonrpc":"2.0","id":1,"method":"getShieldedPoolState"}
// Response
{
"merkleRoot": "43b19b67fc7151e1...",
"commitmentCount": 4,
"totalShielded": 500000000,
"totalShieldedLicn": "0.500000000",
"nullifierCount": 2,
"shieldCount": 2,
"unshieldCount": 0,
"transferCount": 1,
"zkScheme": "plonky3-fri-poseidon2"
}
getShieldedMerkleRoot
Returns just the current Merkle root.
// Response
{ "merkleRoot": "43b19b67fc7151e1..." }
getShieldedMerklePath
Returns the authentication path for a leaf at the given index.
// Request
{"jsonrpc":"2.0","id":1,"method":"getShieldedMerklePath","params":[0]}
// Response
{
"siblings": ["a1b2c3...", "d4e5f6...", ...], // 20 hex strings
"pathBits": [false, true, false, ...] // 20 booleans
}
isNullifierSpent
Checks whether a nullifier has been revealed (note spent).
// Request
{"jsonrpc":"2.0","id":1,"method":"isNullifierSpent","params":["8580318ad00a9c2a..."]}
// Response
{ "spent": true }
getShieldedCommitments
Paginated list of all commitments in the Merkle tree.
// Request (optional: from, limit)
{"jsonrpc":"2.0","id":1,"method":"getShieldedCommitments","params":[0, 100]}
// Response
{
"commitments": ["912f53dd...", "c716601c...", ...],
"total": 4,
"from": 0,
"limit": 100
}
REST Endpoints
Base URLs: https://testnet-rpc.lichen.network/api/v1/shielded (testnet) ·
https://rpc.lichen.network/api/v1/shielded (mainnet)
| Method | Path | Body / Params | Description |
|---|---|---|---|
| GET | /pool |
— | Pool state |
| GET | /merkle-root |
— | Current Merkle root |
| GET | /merkle-path/:index |
— | Siblings + path bits |
| GET | /nullifier/:hex |
— | { spent: bool } |
| GET | /commitments?from=&limit= |
— | Paginated commitments |
| POST | /shield |
{ "transaction": "<base64>" } |
Submit shield TX |
| POST | /unshield |
{ "transaction": "<base64>" } |
Submit unshield TX |
| POST | /transfer |
{ "transaction": "<base64>" } |
Submit transfer TX |
All responses are wrapped in { "success": true, "data": { ... } }.
Instruction Data Layouts
Shield (opcode 23) — 41-byte fixed prefix + proof payload
| Offset | Length | Field |
|---|---|---|
| 0 | 1 | Type tag (23) |
| 1 | 8 | Amount (u64 LE) |
| 9 | 32 | Commitment |
| 41 | variable | Plonky3 proof payload |
Accounts: [sender]
Unshield (opcode 24) — 105-byte fixed prefix + proof payload
| Offset | Length | Field |
|---|---|---|
| 0 | 1 | Type tag (24) |
| 1 | 8 | Amount (u64 LE) |
| 9 | 32 | Nullifier |
| 41 | 32 | Merkle root |
| 73 | 32 | Recipient hash |
| 105 | variable | Plonky3 proof payload |
Accounts: [recipient]
Transfer (opcode 25) — 161-byte fixed prefix + proof payload
| Offset | Length | Field |
|---|---|---|
| 0 | 1 | Type tag (25) |
| 1 | 32 | Nullifier A |
| 33 | 32 | Nullifier B |
| 65 | 32 | Output commitment C |
| 97 | 32 | Output commitment D |
| 129 | 32 | Merkle root |
| 161 | variable | Plonky3 proof payload |
Accounts: [fee_payer]
Public inputs order: [merkle_root, null_a, null_b, comm_c, comm_d]
Security Model
- Double-spend prevention: Nullifiers are stored on-chain. Re-using a nullifier is rejected.
- Value conservation: The transfer circuit constrains
sum(inputs) = sum(outputs); the shield circuit constrainscommitment = Poseidon2(amount, blinding)with the declared amount. - Merkle root binding: Proofs are bound to the current Merkle root. Stale roots are rejected.
- 64-bit range checks: All values are range-checked to 64 bits within the circuit to prevent overflow attacks.
- Compatibility artifact hashes: The pool state still exposes hashes of the
bundled
vk_*files so operators can detect artifact drift while the proving helpers transition, but the live verifier itself checks the scheme-versioned Plonky3 envelope. - Proof non-malleability: Plonky3 proof envelopes are verified against canonical public inputs reconstructed directly from the instruction fields.