Gm,
A few weeks ago, I published:
as part of my CKBuilder journey. Near the end of the article, I described a
direction I was calling a Proof-Bound Capsule: a typed CKB Cell transition
whose validity depends on a zero-knowledge proof about its old and new state.
@ArthurZhang wrote this
Where Is the ABI? Also, Why Is My Cell Dead? - #5 by ArthurZhang),
which gave this direction a sharper security and UI/UX question:
“A valid proof can still be bound to the wrong transition.”
The project did not begin with that comment, but it helped me separate three
statements that can easily be mistaken for one:
the proof is mathematically valid
the proof exposes the intended public statement
the public statement is bound to this exact CKB Cell transition
Over Weeks 7–9 of my CKBuilder journey, I have been exploring these boundaries
in an experimental project called
noir-ckb-verifier.
What I am trying to build
The long-term goal is a version-pinned developer toolchain that can turn a
Noir-authored circuit into artifacts consumable by a Groth16 verifier on CKB,
then provide a reference pattern for binding the verified public inputs to a
typed Cell transition.
Noir source
-> ACIR artifact and execution witness
-> BN254 Groth16 proof, VK, and ordered public inputs
-> validated arkworks objects
-> canonical compressed serialization
-> groth16-ckb v1 Molecule VK and witness payloads
-> CKB-VM verification
-> proof-bound old Cell -> new Cell transition
This work builds on Cecilia Mulandi’s
groth16-ckb, which already
provides a general arkworks-based BN254 Groth16 verifier for CKB-VM.
Her
describes the endpoint: reusable verifier code, a Molecule-encoded verification
key in a CellDep, and proof plus public inputs in WitnessArgs.input_type.
My project is focused on producing and validating the artifacts that cross into
that endpoint.
Week 7: making both ends concrete
I started with a minimal Noir circuit:
fn main(x: Field, y: pub Field) {
assert(x * x == y);
}
The development values are private x = 7 and public y = 49. Nargo/noirc
1.0.0-beta.18 reported the intended ACIR interface:
private parameters: [w0]
public parameters: [w1]
ASSERT w1 = w0*w0
I ran nargo check, compiled the ACIR, and solved the execution witness. I also
generated and explicitly verified a Barretenberg UltraHonk control proof. That
control established that the Noir circuit worked in its common proving path,
but it was not a Groth16 artifact for CKB.
On the other end, I reproduced groth16-ckb at commit
d64c769ffe2d2edb5eb308dc59058efda77c2f83. The production RISC-V verifier
binary was 98,464 bytes. The retained Rust host and integration tests passed,
the TypeScript SDK and square-root transaction example passed, and the CKB-VM
benchmark measured 99,843,490 cycles for one public input.
Week 7 therefore established:
- Noir can produce the source artifact and witness.
- CKB-VM can verify the target Groth16/Molecule format.
- The conversion and semantic checks between them were still missing.
The complete evidence and architecture discussion are in my
CKBuilder Week 7 report.
Week 8: a proof that verified the wrong public interface
For the first ACIR-to-Groth16 experiment, I pinned Noir-Groth16 at commit
4b7caace1f2128e454c8d0fe50cac1ec46b1e272. Its legacy beta.18 parser could
consume the unchanged Week 7 artifact.
The backend parsed the circuit, solved the expected witnesses, and emitted a
structurally valid BN254 R1CS and WTNS. snarkjs reported that the witness was
correct. However, the exported R1CS witness vector was:
["1", "7", "49", "49"]
The R1CS declared one public input, so its leading non-constant wire was public.
That wire contained 7, even though Noir declared x = 7 private and y = 49
public.
The resulting development-only Groth16 proof confirmed the mismatch:
same proof + public [7] -> snarkjs accepts
same proof + public [49] -> snarkjs rejects
The equation remained correct, but the public meaning changed. This showed that
the following sequence is not sufficient evidence of compatibility:
artifact parses
witness satisfies R1CS
proof verifies
Public/private counts do not preserve semantics unless the correct witnesses
also occupy the target format’s required public-wire positions.
I retained this private-first circuit as a negative regression case. To isolate
the ordering issue, I created a public-first control:
fn main(y: pub Field, x: Field) {
assert(x * x == y);
}
Its witness vector became:
["1", "49", "7", "49"]
For this constrained control, the generated proof accepted [49] and rejected
[7].
This is not a general fix. Reordering source parameters only made the control
match the backend’s identity wire mapping. Until a sound remapper exists, the
toolchain should fail closed unless it can prove that the Noir public witnesses
already occupy the required target positions in deterministic semantic order.
The full diagnosis, R1CS inspection, setup limitations, and artifact evidence
are in my
CKBuilder Week 8 report.
Week 9: crossing the typed host boundary
Week 9 started from the retained public-first control. I preserved a small,
non-secret fixture containing the snarkjs proof, verification key, intended
public vector [49], wrong public vector [7], and a provenance manifest. The
proving key, private witness, R1CS, WTNS, and Powers of Tau files are excluded.
I then implemented a Rust crate and CLI called noir-ckb-adapter. It pins
arkworks 0.5 and imports groth16-schema, wire-decode, and verifier-core
from the exact groth16-ckb commit reproduced in Week 7.
The adapter fails closed on:
- unsupported protocol or curve identifiers;
- non-canonical or out-of-range field integers;
- unexpected affine markers or infinity points;
- off-curve or wrong-subgroup points;
- public-input count mismatches;
- wrong Molecule versions and malformed payloads.
An important detail is that arkworks field parsing can reduce integers modulo
the field modulus. The adapter therefore checks the integer range before
constructing the field element. It also maps snarkjs Fq2 [c0,c1] directly to
arkworks coefficient order rather than copying the EVM-specific reversal seen
in some Solidity calldata examples.
The same retained proof now has matching positive and negative behavior across
implementations.
Cross-verification results
| Implementation | Intended public input [49] |
Wrong public input [7] |
|---|---|---|
| snarkjs 0.7.5 | Accepted | Rejected as required |
| arkworks 0.5 | Accepted | Rejected as required |
Pinned groth16-ckb host path |
Accepted | Rejected as required |
After verification, the adapter uses arkworks canonical compressed
serialization and constructs the endpoint’s actual version-1 Molecule schema.
Generated artifact sizes
| Generated artifact | Exact size |
|---|---|
| Canonical verification key | 296 bytes |
| Canonical Groth16 proof | 128 bytes |
| One-input public buffer | 36 bytes |
| Molecule VK payload | 334 bytes |
| Molecule witness payload | 194 bytes |
The CKB Blake2b data hash of the exact Molecule VK payload is:
1fa6f0c18ff7b0d32abcd01ddf2ddcc3e4190be99add55bbf2418f045eb32715
The adapter decodes its generated Molecule objects through the pinned endpoint,
compares the recovered canonical buffers byte for byte, and calls the pinned
host verifier. The locked Rust suite executed 11 tests with no failures. It
covers positive and negative proof semantics, strict numeric and point parsing,
exact wire round trips, wrong public input rejection, wrong wire-version
rejection, and truncated-witness rejection.
This reaches the groth16-ckb host boundary for one provenance-recorded fixture.
It does not yet claim execution of the Noir-derived proof inside CKB-VM.
The three boundaries exposed across Weeks 7–9
The last three weeks exposed the same security idea at three separate layers:
-
Compiler/backend semantics: Week 8 showed that a proof can verify while
the backend exposes the wrong Noir witness as public. The target wire order
must preserve the Noir ABI, not only the equation and visibility counts. -
Cross-library serialization: Week 9 required snarkjs, arkworks, and the
pinned CKB endpoint to agree on the same proof, key, field values, point
ordering, public-input order, canonical bytes, and Molecule version. “Groth16
over BN254” does not automatically imply byte compatibility. -
Application authorization: the next stage must derive the expected public
inputs from the consumed and created Capsule Cells. A generic verifier proves
onlyverify(vk, public_inputs, proof); it does not prove that the public
inputs describe the transaction currently being validated.
These checks are cumulative:
correct source-to-proof semantics
AND correct cross-library/wire semantics
AND correct transaction-binding semantics
-> meaningful proof-carrying Cell transition
A later verifier cannot repair an earlier semantic mismatch.
From proof verification to proof-bound authorization
For the eventual Capsule reference pattern, the public statement may need to
cover:
- old and new Capsule commitments;
- stable Capsule identity;
- intended action;
- a nullifier or equivalent one-time authorization value;
- a replay domain, possibly including script identity, network, or consumed
OutPoint.
The intended application rule is:
the transaction resolves the committed VK Cell
AND the Molecule payload is canonical and version-correct
AND the Groth16 proof verifies
AND public inputs equal values derived from this transaction
AND Capsule transition and replay rules hold
-> accept
The required test pair remains:
valid proof + correct Capsule transition -> accept
valid proof + wrong Capsule transition -> reject
Arthur’s diagram also suggests a UI/UX requirement. A useful transaction tool
should eventually display more than “proof verified.” It should show the old
typed state, proposed new state, decoded public commitments, action, replay
domain, and exact transaction that will be signed. Cryptographic verification
and understandable user intent are related, but they are not the same result.
Current limits and next milestone
The current result does not establish arbitrary Noir compatibility, a general
ACIR public-wire remapper, a production trusted setup, CKB-VM execution of this
Noir-derived proof, VK Cell deployment, Capsule transition binding, or audit
readiness.
The next narrow milestone is to place the exact Week 9 vk.mol.bin and
witness.mol.bin payloads into a groth16-ckb integration transaction and
retain CKB-VM evidence for:
same proof + public [49] -> accept
same proof + public [7] -> reject
wrong VK commitment -> reject
malformed witness -> reject
After that, I want to define a canonical transaction-derived public-input
manifest and test correct-transition acceptance against wrong-transition
rejection.
Feedback I would value
- Would a version-pinned Noir-to-CKB artifact adapter be useful as standalone
CKB developer infrastructure if it fails closed on unsupported Noir layouts? - Should the first public release focus on a compatibility checker and reviewed
fixtures, or also produce deployment manifests and CCC/TypeScript bindings? - Which fields should be mandatory in a minimal proof-bound Cell public
statement and replay domain? - Should I complete the narrow CKB-VM path first, or prioritize the general
ACIR-to-R1CS public-wire remapper? - What should a wallet or transaction inspector display so that “proof
verified” cannot be mistaken for “the transition I intended was verified”?
References
noir-ckb-verifiergroth16-ckb- CKBuilder Week 7 report: Mapping the Noir-to-CKB Proof Pipeline
- CKBuilder Week 8 report: ACIR-to-Groth16 and public-wire semantics
- CKBuilder Week 9 report: Arkworks conversion and the CKB Molecule boundary
- Where Is the ABI? Also, Why Is My Cell Dead?
- Groth16-ckb: an on-chain Groth16 verifier for CKB-VM
Thanks for reading. I would appreciate direct technical feedback from people
working with CKB-VM, Molecule, Noir/ACIR, Groth16 interoperability, transaction
tooling, or proof-bound Cell protocols.