ZK & Shielded Pool Guide¶
Overview¶
The Dilithia shielded pool is an on-chain privacy layer that lets users deposit, transfer, and withdraw tokens without revealing amounts or participants on the public ledger. Privacy is enforced through zero-knowledge proofs -- specifically STARKs (Scalable Transparent ARguments of Knowledge).
Why STARKs instead of SNARKs?
- No trusted setup. STARKs are transparent: there is no ceremony that could be compromised.
- Post-quantum secure. STARKs rely on hash functions (Poseidon) rather than elliptic-curve pairings, making them resistant to quantum attacks. This aligns with Dilithia's broader post-quantum cryptographic design (ML-DSA-65 signatures, lattice-based key exchange).
- Scalable verification. Proof verification is polylogarithmic in the computation size.
The ZK adapter wraps the low-level proof system (winterfell) and exposes seven
methods for hashing, commitments, nullifiers, and proof generation/verification.
The DilithiaClient then provides five high-level methods for interacting with
the on-chain shielded contract.
For the full API reference, see ZK Adapter API.
Installation¶
The ZK bridge is packaged separately from the core SDK to keep the base install lightweight (STARK proof generation pulls in native code).
Loading the Adapter¶
import { loadZkAdapter, loadSyncZkAdapter } from "@dilithia/sdk";
// Async adapter (recommended for servers / long-running processes)
const zk = await loadZkAdapter();
if (!zk) throw new Error("@dilithia/sdk-zk not installed");
// Sync adapter (useful for scripts and CLI tools)
const zkSync = loadSyncZkAdapter();
if (!zkSync) throw new Error("@dilithia/sdk-zk not installed");
from dilithia_sdk.zk import load_zk_adapter, load_async_zk_adapter
# Synchronous adapter
zk = load_zk_adapter()
assert zk is not None, "dilithia-sdk-zk not installed"
# Async adapter (wraps sync via asyncio.to_thread)
async_zk = load_async_zk_adapter()
assert async_zk is not None, "dilithia-sdk-zk not installed"
use dilithia_sdk::DilithiaZkAdapter;
// Implement the trait for your type. With the `stark` feature enabled,
// dilithia-core provides a native implementation.
struct MyZkAdapter;
impl DilithiaZkAdapter for MyZkAdapter {
fn poseidon_hash(&self, inputs: &[u64]) -> Result<String, String> {
// ... native winterfell call
todo!()
}
// ... remaining methods
}
Use Case 1: Private Transfer¶
This end-to-end example demonstrates depositing tokens into the shielded pool and later withdrawing them to a different address, all without revealing the amount on chain.
Step-by-step¶
- Generate a random secret and nonce (32 bytes each).
- Compute a commitment --
Poseidon(value || secret || nonce). - Deposit the commitment into the shielded pool.
- When ready to withdraw: compute the nullifier --
Poseidon(secret || nonce). - Generate a preimage proof proving knowledge of the commitment's preimage.
- Withdraw from the shielded pool using the nullifier and proof.
TypeScript¶
import { randomBytes } from "node:crypto";
import { DilithiaClient, loadZkAdapter } from "@dilithia/sdk";
const client = new DilithiaClient({ rpcUrl: "http://localhost:8000/rpc" });
const zk = await loadZkAdapter();
if (!zk) throw new Error("ZK bridge not available");
// 1. Generate secret and nonce
const secret = randomBytes(32).toString("hex");
const nonce = randomBytes(32).toString("hex");
const depositAmount = 1000;
// 2. Compute commitment
const commitment = await zk.computeCommitment(depositAmount, secret, nonce);
console.log("Commitment hash:", commitment.hash);
// 3. Generate a preimage proof for the deposit
const depositProof = await zk.generatePreimageProof([depositAmount]);
// 4. Deposit into the shielded pool
const depositTx = await client.shieldedDeposit(
commitment.hash,
depositAmount,
depositProof.proof,
);
console.log("Deposit submitted:", depositTx);
// --- later, when you want to withdraw ---
// 5. Compute nullifier
const nullifier = await zk.computeNullifier(secret, nonce);
// 6. Generate withdrawal proof (preimage proof over the commitment inputs)
const withdrawProof = await zk.generatePreimageProof([depositAmount]);
// 7. Get the current commitment root
const rootResult = await client.getCommitmentRoot();
const commitmentRoot = rootResult.root as string;
// 8. Withdraw
const withdrawTx = await client.shieldedWithdraw(
nullifier.hash,
depositAmount,
"dili1_recipient_address_here",
withdrawProof.proof,
commitmentRoot,
);
console.log("Withdrawal submitted:", withdrawTx);
Python¶
import os
from dilithia_sdk.client import DilithiaClient
from dilithia_sdk.zk import load_zk_adapter
client = DilithiaClient("http://localhost:8000/rpc")
zk = load_zk_adapter()
assert zk is not None, "ZK bridge not available"
# 1. Generate secret and nonce
secret = os.urandom(32).hex()
nonce = os.urandom(32).hex()
deposit_amount = 1000
# 2. Compute commitment
commitment = zk.compute_commitment(deposit_amount, secret, nonce)
print("Commitment hash:", commitment.hash)
# 3. Generate a preimage proof for the deposit
deposit_proof = zk.generate_preimage_proof([deposit_amount])
# 4. Deposit into the shielded pool
deposit_tx = client.shielded_deposit(
commitment.hash,
deposit_amount,
deposit_proof.proof,
)
print("Deposit submitted:", deposit_tx)
# --- later, when you want to withdraw ---
# 5. Compute nullifier
nullifier = zk.compute_nullifier(secret, nonce)
# 6. Generate withdrawal proof
withdraw_proof = zk.generate_preimage_proof([deposit_amount])
# 7. Get the current commitment root
root_result = client.get_commitment_root()
commitment_root = root_result["root"]
# 8. Withdraw
withdraw_tx = client.shielded_withdraw(
nullifier.hash,
deposit_amount,
"dili1_recipient_address_here",
withdraw_proof.proof,
commitment_root,
)
print("Withdrawal submitted:", withdraw_tx)
Store your secret and nonce safely
The secret and nonce are the only way to derive the nullifier and
reclaim your funds. If you lose them, the deposited tokens are
permanently locked in the shielded pool.
Use Case 2: Compliance Proof (Tax Paid)¶
Regulatory bodies may require proof that taxes were paid on shielded transactions. With a range proof, you can prove that the total tax paid falls within an expected range without revealing the exact amounts of any individual transaction.
TypeScript¶
import { DilithiaClient, loadZkAdapter } from "@dilithia/sdk";
const client = new DilithiaClient({ rpcUrl: "http://localhost:8000/rpc" });
const zk = await loadZkAdapter();
if (!zk) throw new Error("ZK bridge not available");
// Suppose you owe between 500 and 2000 in tax for the period, and you
// actually paid 1200. Prove it without revealing the exact figure.
const taxPaid = 1200;
const minExpected = 500;
const maxExpected = 2000;
// 1. Generate a range proof: taxPaid in [500, 2000]
const rangeProof = await zk.generateRangeProof(taxPaid, minExpected, maxExpected);
// 2. Submit compliance proof to the shielded contract
const call = client.buildContractCall("shielded", "compliance_proof", {
proof_type: "tax_paid",
proof: rangeProof.proof,
inputs: rangeProof.inputs,
});
const result = await client.sendCall(call);
console.log("Compliance proof accepted:", result);
Python¶
from dilithia_sdk.client import DilithiaClient
from dilithia_sdk.zk import load_zk_adapter
client = DilithiaClient("http://localhost:8000/rpc")
zk = load_zk_adapter()
assert zk is not None
tax_paid = 1200
min_expected = 500
max_expected = 2000
# 1. Generate range proof
range_proof = zk.generate_range_proof(tax_paid, min_expected, max_expected)
# 2. Submit compliance proof
result = client.call_contract("shielded", "compliance_proof", {
"proof_type": "tax_paid",
"proof": range_proof.proof,
"inputs": range_proof.inputs,
})
print("Compliance proof accepted:", result)
Use Case 3: Sanctions Screening¶
Prove that your address is not on a sanctions list, without revealing which addresses are on the list or any details about your transaction history.
This uses a preimage proof: the verifier publishes a Poseidon hash of the sanctions list, and you prove that your address hashes to a value that is not in the committed set.
TypeScript¶
import { DilithiaClient, loadZkAdapter } from "@dilithia/sdk";
const client = new DilithiaClient({ rpcUrl: "http://localhost:8000/rpc" });
const zk = await loadZkAdapter();
if (!zk) throw new Error("ZK bridge not available");
// Your address as a field element (simplified -- real implementation
// would encode the address into field elements).
const myAddressField = 0x1234abcd;
// 1. Generate a preimage proof demonstrating non-membership
const proof = await zk.generatePreimageProof([myAddressField]);
// 2. Submit a "not_on_sanctions" compliance proof
const call = client.buildContractCall("shielded", "compliance_proof", {
proof_type: "not_on_sanctions",
proof: proof.proof,
inputs: proof.inputs,
});
const result = await client.sendCall(call);
console.log("Sanctions screening passed:", result);
Sync vs Async¶
When to use the async adapter¶
Use the async adapter when running inside an event loop or a server that handles concurrent requests. STARK proof generation is CPU-intensive and can take hundreds of milliseconds or more. The async adapter ensures this work does not block other tasks.
- TypeScript:
loadZkAdapter()returns aDilithiaZkAdapterwhose methods returnPromise<T>. The underlying native call is still synchronous, but wrapping it inasyncallows the event loop to schedule other microtasks. - Python:
load_async_zk_adapter()returns anAsyncNativeZkAdapterthat delegates every call toasyncio.to_thread, running the CPU work on a thread-pool executor so the event loop stays responsive.
When to use the sync adapter¶
Use the sync adapter for scripts, CLI tools, tests, or any context where
blocking is acceptable and you want simpler code without await.
- TypeScript:
loadSyncZkAdapter()returns aSyncDilithiaZkAdapterwhose methods returnTdirectly. - Python:
load_zk_adapter()returns aDilithiaZkAdapter(sync protocol) with plain synchronous methods.
Rust, Go, and Java¶
- Rust: The
DilithiaZkAdaptertrait is synchronous. Wrap calls intokio::task::spawn_blocking(or equivalent) if you need async behavior. - Go: The
ZkAdapterinterface accepts acontext.Contextfor cancellation. Run proof generation in a goroutine if needed. - Java: The
DilithiaZkAdapterinterface is synchronous. UseCompletableFuture.supplyAsync()or virtual threads (Java 21+) for non-blocking execution. - C#: The
IDilithiaZkAdapterinterface is synchronous. UseTask.Run()to offload CPU-intensive proof generation to a thread-pool thread, keepingasync/awaitpipelines responsive.
Security Considerations¶
- Secret management. The 32-byte
secretandnonceare the keys to your shielded funds. Store them with the same care as private keys. - Nullifier uniqueness. Each commitment can only be spent once. The chain rejects any withdrawal whose nullifier has already been recorded.
- Commitment root freshness. Always query
getCommitmentRootimmediately before generating a withdrawal proof. A stale root will cause the on-chain verification to fail. - Proof size. STARK proofs are larger than SNARK proofs (tens of KB vs. hundreds of bytes). Plan for this in transaction size budgets.