Zero-knowledge proofs (ZKPs)
此内容尚不支持你的语言。
Introduction
This guide shows how to create, compile, and test Circom circuits and verify ZK-proofs on TON Blockchain using the Tact language and the zk-SNARK Groth16 protocol.
It demonstrates how to use the zkJetton repository to create a Jetton token in the TON blockchain, where user balances are hidden using homomorphic encryption and zero-knowledge proofs.
The zkJetton project combines the Jetton standard with ZK-proof verification inside Tact smart contracts. zkJetton
is based on the pipeline Circom → snarkjs → export-ton-verifier → Tact, similar to the examples from zk-ton-example.
Disclaimer
This repository uses a simplified version of Jetton written in Tact. The code has not been audited, contains potential vulnerabilities, and requires significant improvements. It is implemented solely for educational purposes.
What this guide covers
- Setting up the environment.
- How zkJetton is designed.
- How to work with Circom.
- Exporting zk-verifiers for Tact.
- Testing zkJetton.
Prerequisites
- Node.js LTS version 18 or later installed.
- circom and snarkjs packages installed.
- Basic familiarity with TON, Tact, and the Blueprint toolkit.
- Basic knowledge of Jettons.
Project setup
-
Create a new project with Blueprint:
Terminal window npm create ton@latest -- zk-proofs-tact --type tact-empty --contractName ZkProofs -
Install libraries for ZK-proof handling:
Terminal window npm install snarkjs @types/snarkjs -
Install the verifier export utility for TON:
Terminal window npm install export-ton-verifier@latestThis tool exports verifier contracts for Tact, FunC, and Tolk.
Homomorphic encryption
This project uses additively homomorphic Paillier encryption for private balances. It allows performing addition operations directly on encrypted data without decryption:
This aligns perfectly with Jetton logic: deposit/withdrawal = adding/subtracting to the hidden balance.
zkJetton architecture
The token consists of several contracts:
ZkJettonMinter
— similar toJettonMinter
:
- The main token contract.
- Allows minting tokens via the
Mint
message. - Inherits from
trait MintVerifier
.
ZkJettonWallet
— similar toJettonWallet
:
- Created for each user upon registration. Allows transferring tokens (
ZkJettonTransfer
) and receiving them (ZkJettonTransferInternal
). - Inherits from
trait RegistrationVerifier
andtrait TransferVerifier
.
Circom
In the circuits
directory, you will find circom
circuits that can be compiled as follows:
cd circuits
circom registration.circom --r1cs --wasm --sym --prime bls12381circom mint.circom --r1cs --wasm --sym --prime bls12381circom transfer.circom --r1cs --wasm --sym --prime bls12381
Compilation produces:
.r1cs
— circuit constraints (R1CS).sym
— signal mapping.wasm
— artifact for proof generation
Circuits
Registration circuit
The first step is registering in the token contract and assigning the user keys that will be used for encrypting balances.
The template for fast modular exponentiation is imported first:
include "binpower.circom";
This template is then used to encrypt an initial balance of zero, required for proving that the encrypted balance indeed equals zero.
Mint circuit
The second step is minting tokens to the user.
After registration, the user’s public key and encrypted balance are stored in the token contract.
The circuit checks that the minted amount is encrypted with the recipient’s public key. This is important because otherwise, when adding the encrypted balance and transfer amount, the decrypted result could be invalid (e.g., instead of 10 tokens, the user might get 10,000).
Transfer circuit
The third step is transferring tokens from one user to another.
The circuit checks that:
- The transfer amount does not exceed the decrypted user balance.
- The encrypted transfer amounts for sender and recipient are correctly encrypted with their respective public keys and are valid.
Trusted setup (Groth16)
After writing and compiling circuits, the next step is running a simplified trusted setup ceremony. Example for the registration circuit (similar for others):
snarkjs powersoftau new bls12-381 10 pot10_0000.ptau -vsnarkjs powersoftau contribute pot10_0000.ptau pot10_0001.ptau --name="First contribution" -v -e="some random text"snarkjs powersoftau prepare phase2 pot10_0001.ptau pot10_final.ptau -vsnarkjs groth16 setup registration.r1cs pot10_final.ptau registration_0000.zkeysnarkjs zkey contribute registration_0000.zkey registration_final.zkey --name="1st Contributor Name" -v -e="some random text"
# export verification keysnarkjs zkey export verificationkey registration_final.zkey verification_key.json
The parameter (10
) affects execution time — larger circuits require higher values.
Exporting verifier contracts
After the trusted setup ceremony, verifier contracts can be exported for Tact:
npx export-ton-verifier --tact \ ./circuits/registration/registration_final.zkey \ ./contracts/verifiers/verifier_registration.tact
This generates a contract template that accepts a proof and verifies it:
contract Verifier() { receive(msg: Verify) { let res = self.groth16Verify(msg.piA, msg.piB, msg.piC, msg.pubInputs); }
fun groth16Verify( piA: Slice, piB: Slice, piC: Slice, pubInputs: map<Int as uint32, Int>, ): Bool { // Body of this utility function was hidden for simplicity purposes return true; }
get fun verify( piA: Slice, piB: Slice, piC: Slice, pubInputs: map<Int as uint32, Int>, ): Bool { return self.groth16Verify(piA, piB, piC, pubInputs); }}
message Verify { piA: Slice; piB: Slice; piC: Slice; pubInputs: map<Int as uint32, Int>;}
For quick checks, you can use the verify
get-method.
Possible integration approaches:
- Turn the contract into a
trait
and inherit from it. The most convenient option. - Extend the generated contract with business logic.
- Use a two-step flow:
- User → Verifier (proof check)
- Verifier → Main contract (execute logic if verified)
Testing and proof verification
Testing is split into two stages:
- Verifier testing (
Verifiers.spec.ts
) - Token testing (
zkJetton.spec.ts
)
Helper functions in the common
directory simplify proof generation.
Preparing for testing
For example, here is how to prepare the registration:
// Implementation of the homomorphic cryptosystemimport paillierBigint from 'paillier-bigint';import * as snarkjs from 'snarkjs';import path from 'path';
// dictFromInputList — converts input array into a `Dictionary`.// groth16CompressProof — prepares the proof for sending to the contract.import { dictFromInputList, groth16CompressProof } from 'export-ton-verifier';
const wasmPath = path.join(__dirname, '../../circuits/registration/registration_js', 'registration.wasm');const zkeyPath = path.join(__dirname, '../../circuits/registration', 'registration_final.zkey');
Generating a proof
Proofs can be generated in one line:
await snarkjs.groth16.fullProve(getRegistrationData(keys), wasmPath, zkeyPath);
The function getRegistrationData(keys)
generates input values for proof creation:
export function getRegistrationData(keys: paillierBigint.KeyPair) { const balance = initBalance; // 0 const rand_r = getRandomBigInt(keys.publicKey.n); const encryptedBalance = keys.publicKey.encrypt(balance, rand_r); const pubKey = [keys.publicKey.g, rand_r, keys.publicKey.n];
return { encryptedBalance, balance, pubKey };}
Which corresponds to this circom
circuit:
signal input encryptedBalance;signal input balance;// public key: g, rand r, nsignal input pubKey[3];
The generated proof is then prepared for contract submission:
const { proof, publicSignals } = await createRegistrationProof(keys);
const { pi_a, pi_b, pi_c, pubInputs } = await groth16CompressProof(proof, publicSignals);
Proof verification
For testing, proofs can be verified locally:
const verificationKey = require('../circuits/verification_key.json');
const ok = await snarkjs.groth16.verify(verificationKey, publicSignals, proof);
Or sent to the contract:
await zkJettonWallet.getVerifyRegistration( beginCell().storeBuffer(pi_a).endCell().asSlice(), beginCell().storeBuffer(pi_b).endCell().asSlice(), beginCell().storeBuffer(pi_c).endCell().asSlice(), dictFromInputList(pubInputs),)
Conclusion
zkJetton
is a working example of a minimal Jetton token with private balances. It demonstrates:
- how to integrate zk-proofs into TON,
- how to protect user data,
- how to use Circom circuits with Tact contracts.
This can serve as a foundation for building private DeFi protocols, DAOs, or payment systems in TON.
Credits
This article was initially written by mysteryon88.
Useful links
- Token repository with hidden balances: zkJetton
- Example repository: zk-ton-example
- Verifier export library: export-ton-verifier
- Circom: docs.circom.io
- SnarkJS: iden3/snarkjs