Build a ZK counter on CKB with CellScript

I am actually a bit excited to say, now CellScript supports Groth16 proofs over BN254 for CKB state transitions. We have a working counter application, Rust and TypeScript clients, and tests that exercise the verifier in CKB-VM and confirm transactions on a disposable local CKB node.

This post walks through the example and explains what you would need to change for your own application. The commands use a fixed commit so you can reproduce the same version.

What the example proves

The application stores an owner commitment and a public counter in a Cell. To increment it, the caller proves knowledge of the owner’s secret. The circuit checks that the owner stays the same and that the counter increases by exactly one without overflowing.

The secret is the private input. The counter, its changes, and the transaction remain public. This demonstrates private authorization; it does not hide transaction history or amounts. The tutorial deliberately uses a public test secret and public test setup so anyone can reproduce it.

The proof is also bound to the input Cell, the old and new state, the executing Script, the action, and the raw transaction hash. Copying a proof into another transition must fail.

Run it locally

Start with Git, Bash, rustup, a native Rust build environment, and Node.js 22.18 or later with npm. I tested the example on Linux with Rust 1.97.1 and Node.js 22.23.1. The repository pins its Rust toolchain.

Use a fresh directory. The current workspace expects ckb-sdk-rust beside CellScript:

mkdir cellscript-zk-demo
cd cellscript-zk-demo

git clone --branch v5.1.0 https://github.com/nervosnetwork/ckb-sdk-rust.git
git clone --branch 0.32 https://github.com/CellScript-Labs/CellScript.git
cd CellScript
git checkout 35bf983db30aae80281f97e30bbce08a878d7c58

rustup target add riscv64imac-unknown-none-elf
bash examples/zk/run.sh target/my-zk-app

The first run compiles the compiler, verifier, lifecycle script, and native prover dependencies. The runner also installs the example’s pinned npm dependencies.

It creates a counter at zero, proves and verifies 0 → 1 and 1 → 2, checks rejection of a corrupted proof and a replayed proof, then generates the TypeScript SDK and runs the client checks. You do not need a wallet or a running node for this step: it executes real scripts in the ckb-testtool CKB-VM environment, using a fixture Lock.

On success, the script ends with:

Examples passed. Read target/my-zk-app/walkthrough-report.json and typescript-report.json

Inspect the results:

cat target/my-zk-app/walkthrough-report.json
cat target/my-zk-app/typescript-report.json
cat target/my-zk-app/parent.cell

The walkthrough report should have "status": "passed", successful creation and increment rows, and expected rejection rows for corrupt-proof and replay-old-proof. It also records cycles and proof size. The proof is 128 bytes; the two increments in our fixture run took about 110 million CKB-VM cycles each. Your report gives the measurements for your run.

The output directory contains the compiled parent, compiler metadata, exact verifier handle, proof, and generated SDK. Use a new output directory when rerunning, for example target/my-zk-app-2; the runner refuses to overwrite an existing run.

Follow the transaction through the code

The Rust walkthrough is the complete executable example. Its main sequence is:

  1. Build the transaction, including dependencies and outputs.
  2. Resolve its inputs and derive the statement that the proof will cover.
  3. Generate the proof with the owner secret.
  4. Verify it locally and attach it to the witness.
  5. Execute the transaction in CKB-VM.

The generated parent.cell contains the zk::require_valid(...) call. It binds the verifier identity and verification key. The lifecycle script enforces creation and state continuity, calls the CellScript parent, and preserves the counter Cell’s capacity and Lock; the parent invokes the proof verifier.

For the circuit itself, start with the counter crate. The profile specification defines the statement and wire formats.

Connect an application and wallet

The TypeScript counter adapter exposes this flow:

const prepared = await prepareIncrement(signer, counterOutPoint, deployment, sdk);
const proved = await prepared.prove(prover);
const { hash } = await prepared.signAndSend(signer, proved);

This is the integration shape, not a standalone script: signer is your CCC signer, counterOutPoint identifies the live counter, deployment contains the actual code and verification-key Cell identities, sdk is the generated SDK, and prover produces a proof for the prepared statement. The shipped increment.ts wires these together with a native prover and a local secp256k1 wallet. The developer guide includes its configuration and parent-export commands.

The order matters. Fee inputs, change outputs, and dependencies must be finalized before proving, because the proof covers the raw transaction hash. The adapter reserves space for the proof before calculating fees. After proving, the wallet signs the Lock witness; changes to the raw transaction require a new proof.

CCC supplies transaction and wallet plumbing for the TypeScript example. CellScript supplies the contract metadata and encoding, and the counter client derives the statement and places the proof. The Rust client is available separately, so CCC is optional.

A live deployment needs the verifier, verification key, parent, and lifecycle Cells, a created counter instance, and separate fee Cells. The local walkthrough’s deployment coordinates are synthetic. For a custom chain, the CCC client also needs that chain’s known-script configuration; the shipped node client demonstrates this for the test chain.

I tested that path separately against a disposable local CKB node: two counter updates were confirmed with real secp256k1 fee signatures, a corrupt proof was rejected during dry-run, and an already-spent input was rejected before submission. The first command above runs the VM walkthrough; the guide documents the additional prerequisites for the node acceptance test.

Build another application

The verifier integration and transaction-binding profile are reusable. The counter’s business rule is defined in its circuit. A membership proof, payment, or other relation needs an appropriate circuit, its own setup and verification key, and lifecycle rules for the Cells it consumes and creates. Editing the CellScript parent alone does not change the proven relation.

For a first experiment, run the counter unchanged, inspect parent.cell and the statement derivation, then follow the circuit alongside the Rust walkthrough. That makes it easier to see which checks belong in the proof and which belong in the transaction’s scripts.

The current setup and deployment work still takes several steps. The example’s default setup is a public test fixture. The separate local setup workflow trusts its operator and host; it has no MPC ceremony or independent audit. No public-network deployment is included here.

If you try the tutorial, please share the command that failed and the relevant error, or the application you want to build. Feedback on circuit integration, deployment configuration, and wallet handling would help us decide what to simplify next.

3 Likes

Welcome! Do you think the project will require an external security audit, and if so, when do you expect that to happen?

There is no audit timeline to announce at this stage. If and when we move a particular ZK application towards production, the appropriate review scope can be defined then.

Great work. Thank you.