ZK Shielded Privacy Layer

Groth16/BN254 zero-knowledge proofs for private value transfers on MoltChain.

Architecture

MoltChain'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. All state transitions are validated by Groth16 zero-knowledge proofs over the BN254 elliptic curve.

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.

ComponentDetail
CurveBN254 (alt_bn128)
Proof systemGroth16 (ark-groth16)
Hash functionPoseidon (2-input, BN254 scalar field)
Merkle treeBinary, depth 20 (1,048,576 leaf capacity)
Note formatcommitment = Poseidon(value, blinding)
NullifierPoseidon(serial, spending_key)
Key directory~/.moltchain/zk/ (pk + vk for each circuit)
Proof size128 bytes (compressed BN254 Groth16)

Key Concepts

Notes

A note represents value in the shielded pool. Each note has:

  • value — amount in shells (1 MOLT = 1,000,000,000 shells)
  • blinding — random BN254 field element for hiding the value
  • serial — random unique identifier for nullifier derivation
  • spending_key — secret key that authorizes spending

The commitment Poseidon(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 = Poseidon(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 BN254 field element displayed as a 64-character hex string (not base58, since it's a hash, not an address).

Fee Schedule

OperationOpcodeCompute UnitsApprox. Fee
Shield23100,000~0.0001 MOLT
Unshield24150,000~0.00015 MOLT
Transfer25200,000~0.0002 MOLT

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 <shells> --pk-dir ~/.moltchain/zk

Generates a Groth16 proof for a shield operation.

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..."            // 128-byte hex — Groth16 proof
}

zk-prove unshield

./target/release/zk-prove unshield \
  --amount <shells> \
  --blinding <hex> --serial <hex> --spending-key <hex> \
  --recipient <base58_pubkey> \
  --merkle-root <hex> --merkle-path <hex,hex,...> --path-bits 0,1,0,... \
  --pk-dir ~/.moltchain/zk

Generates a Groth16 proof 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..."            // 128-byte hex
}

zk-prove transfer

./target/release/zk-prove transfer \
  --transfer-json witness.json \
  --pk-dir ~/.moltchain/zk

Generates a Groth16 proof 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 moltchain Python package provides instruction builders for all three operations:

from moltchain 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 MOLT in shells
    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 MOLT into the pool
zk-prove shield --amount 500000000 --pk-dir ~/.moltchain/zk > 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 --pk-dir ~/.moltchain/zk > transfer.json
# → New output note secrets are in transfer.json["outputs"]

# 5. Unshield — withdraw back to transparent account
zk-prove unshield --amount 500000000 ... --pk-dir ~/.moltchain/zk > unshield.json
ix = unshield_instruction(recipient, 500000000, nullifier, root, hash, proof)
# → Shells 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,
  "totalShieldedMolt": "0.500000000",
  "nullifierCount": 2,
  "shieldCount": 2,
  "unshieldCount": 0,
  "transferCount": 1,
  "vkShieldHash": "c0381284...",
  "vkUnshieldHash": "9d015c5b...",
  "vkTransferHash": "b705c04e..."
}

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 URL: http://localhost:8899/api/v1/shielded

MethodPathBody / ParamsDescription
GET/poolPool state
GET/merkle-rootCurrent Merkle root
GET/merkle-path/:indexSiblings + 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) — 169 bytes

OffsetLengthField
01Type tag (23)
18Amount (u64 LE)
932Commitment
41128Groth16 proof

Accounts: [sender]

Unshield (opcode 24) — 233 bytes

OffsetLengthField
01Type tag (24)
18Amount (u64 LE)
932Nullifier
4132Merkle root
7332Recipient hash
105128Groth16 proof

Accounts: [recipient]

Transfer (opcode 25) — 289 bytes

OffsetLengthField
01Type tag (25)
132Nullifier A
3332Nullifier B
6532Output commitment C
9732Output commitment D
12932Merkle root
161128Groth16 proof

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 = Poseidon(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.
  • Verification key hashes: The pool state stores hashes of all three verification keys. If keys are changed, the hashes update and old proofs become invalid.
  • Proof non-malleability: Groth16 proofs are verified with the exact public inputs declared in the instruction data.