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

  1. Sender balance is debited by amount
  2. ZK proof is verified (proves commitment corresponds to declared amount)
  3. Commitment is inserted as a new Merkle leaf
  4. Merkle root is recalculated
  5. 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

  1. Nullifier is checked — must not be already spent
  2. Merkle root is verified against current pool state
  3. ZK proof is verified (proves: note exists in tree, nullifier is correct, recipient matches)
  4. Recipient balance is credited by amount
  5. Nullifier is marked as spent
  6. Pool state is updated (unshieldCount, totalShielded decreases)

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

  1. Both nullifiers are checked — neither can be already spent, and they must be distinct
  2. Merkle root is verified against current pool state
  3. ZK proof is verified with public inputs: [merkle_root, null_a, null_b, comm_c, comm_d]
  4. Both nullifiers are marked as spent
  5. Two new commitments are inserted into the Merkle tree
  6. Merkle root is recalculated
  7. Pool state is updated (transferCount, commitmentCount +2, nullifierCount +2; totalShielded unchanged)

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 from getShieldedMerklePath
  • path_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 constrains commitment = 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.