Demonstrate on how to generate a zk proof to transmit a authorized message from Computors to outside of qubic ecosystem
On this demo, we prove on Ethereum that a quorum of Qubic computors (≥ 451 of 676) signed the message "Hello ZK, this is QUBIC." — with one Groth16 proof (~300 bytes, ~295k gas, about ~$1) instead of 451 FourQ signature checks on-chain, which is nearly impossible.
Qubic side this repo Ethereum
────────── ───────── ────────
arbitrator-signed computor list ─┐ guest program (RISC Zero zkVM) QubicQuorumVerifier
+ ≥451 computor signatures ──────┼─▶ checks every signature in C, ──▶ STARK ──▶ Groth16 ──▶ attest(imageId, journal, seal)
+ the message ┘ commits journal = epoch | digest (GPU farm) → isAttested(digest, epoch)
The zkVM program's hash (IMAGE_ID) is the only thing the contract trusts. The arbitrator's public
key is baked into that program, so the proof is bound to the real Qubic computor set. No trusted
setup of our own: RISC Zero's universal Groth16 circuit + its on-chain RiscZeroVerifierRouter.
Four real components + support. Data flows crypto → guest → host → contract.
| Folder | Language | What it is | Why it exists |
|---|---|---|---|
crypto/ |
C (+ C++ host shim) | FourQ SchnorrQ verify and KangarooTwelve (Qubic's hash), portable rv32im port src/fourq_verify.c + tables.c; src/stock.c = untouched Qubic-core reference used only as test oracle; tools/derive_keys (seed → pubkey/identity); tests/ (vectors: 701 K12, 16 SchnorrQ, 677 keygen, 34 sign) |
The zkVM is 32-bit RISC-V; Qubic's crypto had to be ported bit-exactly. Differentially tested against stock.c (0 diffs over ~90k adversarial cases). |
methods/ |
Rust + the C above | The guest = the program being proven (guest/src/main.rs): read packet + message + votes, verify the arbitrator signature, count ≥ 451 distinct valid computor signatures, commit the 36-byte journal. guest/src/bigint2.rs moves field arithmetic onto risc0's bigint2 precompile (597 M → 203 M cycles). guest/build.rs compiles crypto/ into it and bakes the arbitrator from config/deploy.env. src/lib.rs exports ZKQ_QUORUM_ELF / ZKQ_QUORUM_ID (= IMAGE_ID). |
Everything here changes IMAGE_ID. |
host/ |
Rust | Native tools: zkq-prove (prove via a bento GPU farm; verify a receipt), gen_fixture (build a test input from seeds), run_fixture (execute the guest without proving), image_id, zkq_identity. src/fixture.rs = the ZKQFIX01 input format and the vote digest. |
Relayer/operator tooling; outputs journal_hex + seal_hex for the contract. |
contracts/ |
Solidity (Foundry) | QubicQuorumVerifier.sol: attest(imageId, journal, seal) → router verifies → attestations[digest][epoch] = imageId. Image allowlist with rotation delay, two-step ownership. Deploy.s.sol, Attest.s.sol, 26 tests. lib/ = vendored forge-std + risc0 interfaces. |
The on-chain consumer entry point. |
config/deploy.env |
— | The one config: profile, arbitrator identity, chain/router/verifier, signer, prover farm. Read by both build scripts and every shell script. | One place = no mismatch between guest, scripts and chain. |
scripts/ |
bash | build.sh (locked build), check_config.sh, deploy_verifier.sh, demo_quorum_ok.sh, demo_quorum_fail.sh, e2e.sh, lib/config.sh, bento/ (GPU farm: start server / GPU workers / status / stop / cancel) |
Operator entry points. |
seeds/ |
text | 676 devnet computor seeds (public core-lite defaults) + arbitrator seed z×55 + derived pubkeys |
Only so gen_fixture can sign test inputs. Never for production. |
fixtures/ |
generated | quorum_ok.bin, quorum_fail.bin, receipts (*.json) — gitignored, see fixtures/README.md |
Test inputs / evidence. |
docker/ |
— | Pinned dev image (gcc, Rust, rzup, Foundry) + an untested compose variant of the prover farm | Reproducible toolchain. |
docs/ |
— | DEPLOY.md (runbook), BENTO.md (farm), ARCHITECTURE.md, RUST_TO_C.md, GROTH16_NO_CEREMONY.md, E2E_REPORT.md |
|
SPEC.md, SECURITY.md, NOTICE |
— | Byte formats/constants; what is and is not proved; third-party licenses |
If you only ever touch three things: config/deploy.env (deployment), methods/guest/src/main.rs
(the statement), contracts/src/QubicQuorumVerifier.sol (what the chain does with it).
Inputs to the guest: Computors packet (epoch u16 | 676 × pubkey 32 B | arbitrator sig 64 B,
21 698 B), the message bytes, and N votes (computorIndex u16 | SchnorrQ sig 64 B).
- The packet's signature verifies under the arbitrator pubkey baked into the guest.
- ≥ 451 distinct indices of that list have a valid signature over
voteDigest = K12(VOTE_DOMAIN | epoch | K12(message))— bound to this protocol and this epoch, so a vote can never double as a Qubic transaction/tick signature nor be replayed under another list. - Journal (public output, 36 B):
epoch u32 LE | K12(message).
The contract recomputes sha256(journal), asks the RISC Zero router to verify the Groth16 seal
against imageId, then stores attestations[digest][epoch] = imageId. Consumers call
isAttested(digest, epoch).
Everything runs from config/deploy.env (ships with devnet defaults: Sepolia, arbitrator = seed
z×55, prover farm on localhost).
Step 0 — toolchain. Either the dev image or local: Rust 1.96 (rust-toolchain.toml), rzup
with rust 1.94.1 + cpp 2024.1.5 + r0vm 3.0.4, cmake/gcc, Foundry.
docker build -f docker/Dockerfile.dev -t zkq-dev . && docker run --rm -it -v "$PWD:/work" zkq-devStep 1 — crypto self-test. Port vs Qubic reference.
crypto/tests/run_tests.sh # K12 701/701, SchnorrQ 16/16, keygen 687/687, sign/verify 272/272Step 2 — build (locked) and read the program identity.
scripts/build.sh
target/release/image_id # IMAGE_ID + the arbitrator identity it was built withStep 3 — make the inputs. 676 computors, epoch 999, message Hello ZK, this is QUBIC.;
500 of them sign the vote digest (gen_fixture signs with the devnet seeds):
target/release/gen_fixture --seeds seeds/computor_seeds.txt --arbitrator-seed seeds/arbitrator_seed.txt \
--epoch 999 --message "Hello ZK, this is QUBIC." --votes 500 --out fixtures/quorum_ok.bin
target/release/gen_fixture ... --votes 500 --bad-votes 200 --out fixtures/quorum_fail.bin # 300 good + 200 corruptedStep 4 — run the guest without proving (fast, dev mode).
RISC0_DEV_MODE=1 target/release/run_fixture --fixture fixtures/quorum_ok.bin
# PASS quorum_ok.bin: journal e7030000 c40f7721…e405e1 (203 M cycles) ← epoch 999 | K12(message)
RISC0_DEV_MODE=1 target/release/run_fixture --fixture fixtures/quorum_fail.bin --expect-fail
# Guest panicked: quorum not reached: 300 valid distinct votes, need 451 ← no proof possibleStep 5 — contract. Validate the config, then deploy (or sync the image on an existing verifier):
scripts/check_config.sh # chain id, router, signer, IMAGE_ID vs on-chain, farm version lock → "== config OK"
scripts/deploy_verifier.sh # deploys QubicQuorumVerifier(router, IMAGE_ID, owner, rotationDelay); writes VERIFIER= backStep 6 — real proof on GPUs and on-chain attestation. Needs a bento farm (docs/BENTO.md).
RISC0_DEV_MODE=0 scripts/demo_quorum_ok.sh
# prove … receipt_kind: groth16, seal_hex 0x73c457ba… (~5 min on 4 × RTX 4090)
# local verify OK under IMAGE_ID
# attest tx 0x… isAttested(0xc40f…e405e1, 999) = true attestedImageId = IMAGE_ID
scripts/demo_quorum_fail.sh # prover rejects the fail fixture: "quorum not reached: proof impossible"scripts/e2e.sh runs steps 1–4 plus a replay check (votes signed for epoch 998 under the
epoch-999 list → 0 valid votes).
The demo proves a quorum over an arbitrary message. In production the computors do not sign
loose messages — they sign transactions, and the thing to prove is "≥ 451 computors broadcast a
transaction carrying X". Concretely, Qubic's oracle machine makes every computor send an
OracleReplyCommit transaction (type 6) whose input contains queryId | K12(reply) | knowledge proof.
That transaction already carries everything the demo's vote digest had to add by hand:
Transaction (Qubic core network_messages/transactions.h)
sourcePublicKey 32 B ← the computor (must be in the epoch's list) = the "vote index"
destinationPublicKey 32 B
amount 8 B
tick 4 B ← when (epoch = f(tick))
inputType 2 B ← 6 = OracleReplyCommit = domain separation
inputSize 2 B
input n B ← queryId u64 | replyDigest 32 B | knowledgeProof 32 B
signature 64 B ← SchnorrQ over K12(everything above)
Goal: a user burns 100 wUSDT on Qubic and receives 100 USDT on Ethereum, with no trusted relayer.
- Qubic: the user calls the bridge contract's
Withdraw(amount, ethRecipient). The contract burns the tokens and emits a logWithdrawn(withdrawId, amount, ethRecipient). - Oracle round: anyone sends an oracle query for that log (
readQubicLog, interface 4:tick | txHash | logId). Every computor's oracle machine reads its own bob node, builds the 288-byte reply (the raw log), and broadcasts anOracleReplyCommittransaction withK12(reply). ≥ 451 identical commits ⇒ the reply is revealed on Qubic. - Relayer (permissionless): collects the epoch's
BroadcastComputorspacket + 451 commit transactions for thatqueryId, writes aZKQFIX01file, runszkq-prove --fixture … --mode groth16on a GPU farm (~5 min), getsjournal+seal. - Ethereum: the relayer calls
QubicQuorumVerifier.attest(imageId, journal, seal)(~295k gas). The router verifies the Groth16 proof; the contract recordsattestations[replyDigest][epoch]. - Release: the user (or relayer) calls
Bridge.release(reply bytes, epoch). The bridge contract computesK12(reply), checksverifier.isAttested(digest, epoch), decodes the reply (emitter == Qubic bridge contract,logType == Withdrawn,withdrawIdunused), and transfers 100 USDT toethRecipient.
Why it is safe: releasing needs a valid proof that 451 of the 676 current computors — the same set
that runs Qubic consensus — committed to that exact log. A single relayer, a single RPC provider,
or the bridge operator cannot forge it; a wrong or missing quorum simply cannot be proven. Cost to
the user: one Groth16 verification ($1 at 1 gwei / $3000 ETH) instead of 451 on-chain signature
checks ($300+).
The same pipeline attests anything computors commit to: price feeds (oracle readEVMLog of a
Chainlink AnswerUpdated event, attested back to another chain), cross-chain messages, or proofs
of Qubic state for any EVM consumer.