MeritPay is a zero-knowledge payroll system built on Stellar Soroban. Employees prove their KPI metrics (hours worked, sales targets, etc.) in zero-knowledge, an employer submits a single batch Groth16 proof to release a payroll pool on-chain, and each employee then individually claims their payout with a second Groth16 proof — without ever revealing raw KPI data or individual salaries on-chain.
This repository contains the ZK circuits (Circom) and the Soroban smart contracts (Rust) that make up the on-chain half of the system. The Next.js frontend that drives proof generation and wallet interaction lives in a sibling repository.
- How it works
- Repository layout
- Prerequisites
- Getting started
- Circuits
- Soroban contracts
- Two-step payroll flow, in detail
- The unit system
- Testing
- Deploying to testnet
- Key management
- Security notes
- Contributing
- License
- KPI proof — each employee (or the employer on their behalf) proves, in zero-knowledge, that they met their hours/sales thresholds without revealing the underlying numbers.
- Payroll aggregation — the employer batches up to 5 employees' KPI results into one
PayrollAggregatorGroth16 proof and submits it to thepayrollSoroban contract. This verifies the batch, burns 5 nullifiers (preventing replay), and moves the total payroll amount from the pool into theclaimcontract's escrow. - Individual claim — each employee generates their own
ClaimPayoutGroth16 proof and calls theclaimcontract directly. The contract checks the employee's nullifier was included in an executed payroll batch, verifies the proof, and transfers their exact stroop amount to their wallet.
All proof verification happens on-chain, using Soroban's native BN254 pairing host functions — there is no trusted off-chain verifier.
circuits/ Circom 2.0 circuit sources + package.json (circomlib, snarkjs)
kpi.circom Per-employee KPI proof
payroll_aggregator.circom Batches 5 employees into one payroll execution proof
claim.circom Individual payout claim proof
auditor_disclosure.circom Auditor-facing budget disclosure proof
contracts/ Rust workspace, compiled to wasm32v1-none
groth16_verifier/ Generic BN254 Groth16 verifier (deployed twice — one per VK)
payroll/ Payroll pool: batch execute, nullifier tracking, escrow
claim/ Employee claim: individual payout, anti-double-claim
scripts/
setup.sh Compiles circuits, runs the Groth16 trusted setup, syncs wasm/zkey to web/
deploy.sh Builds contracts, deploys to testnet, wires them together, funds the pool
gen_proof.js Generates and locally verifies a test proof for each circuit
upload_vk.js Uploads a vkey.json to a deployed groth16_verifier contract
build/ Generated artefacts (r1cs, zkey, wasm, vkey.json, solidity_ref/) — gitignored
Cargo.toml Rust workspace manifest (groth16_verifier, payroll, claim)
.env.contracts Contract IDs written by deploy.sh
build/is regenerated byscripts/setup.shand is not committed — it includes two Powers-of-Tau files (~54 MB and ~220 MB) that would otherwise bloat the repository.
| Tool | Why | Install |
|---|---|---|
| Node.js 18+ / npm | circomlib, snarkjs, deploy scripts | https://nodejs.org |
| circom 2.x | Circuit compilation | cargo install circom or https://docs.circom.io/getting-started/installation/ |
Rust + wasm32v1-none target |
Compiling Soroban contracts | rustup target add wasm32v1-none |
Stellar CLI (stellar) |
Key management, contract deploy | https://developers.stellar.org/docs/tools/developer-tools |
| curl or wget | Downloading Powers-of-Tau files | usually preinstalled |
# 1. Install circuit dependencies (circomlib, snarkjs)
cd circuits && npm install && cd ..
# 2. Compile all circuits, run the Groth16 trusted setup, and sync
# wasm/zkey artefacts into web/public/circuits/
./scripts/setup.sh
# subsequent runs can skip re-downloading the (large) ptau files:
SKIP_PTAU=1 ./scripts/setup.sh
# 3. Build the Soroban contracts
rustup target add wasm32v1-none # one-time
cargo build --target wasm32v1-none --release
# 4. Run the contract test suites
cargo test -p meritpay-payroll
cargo test -p claim
# 5. Generate a deployer key and deploy everything to testnet
stellar keys generate deployer --network testnet # one-time
stellar keys fund deployer --network testnet
./scripts/deploy.shsetup.sh runs in 8 stages: prerequisite checks → npm install → build dirs → circuit compilation → Powers-of-Tau download → Groth16 Phase 2 setup per circuit → Solidity verifier export (reference only) → sync wasm/zkey into web/public/circuits/.
All circuits are Circom 2.0, compiled with --r1cs --wasm --sym, and use Groth16 over BN254.
| Circuit | File | Public signals (in order) |
|---|---|---|
| KPI | kpi.circom |
[kpiCommitment, hoursMet, salesMet, employeeId, hoursThreshold] — circuit outputs first, then the two declared-public inputs |
| PayrollAggregator | payroll_aggregator.circom |
employeeIds[5] (0–4), hoursThresholds[5] (5–9), totalPayroll (10), payrollEpoch (11), nullifiers[5] (12–16) — 17 signals total, batches 5 employees per execution |
| ClaimPayout | claim.circom |
[nullifier, payrollEpoch, amount] — indices 0, 1, 2. amount is denominated in stroops |
| AuditorDisclosure | auditor_disclosure.circom |
[totalPayroll, budget] — lets an auditor confirm payroll stayed within budget without seeing individual salaries |
Powers-of-Tau (Phase 1, universal, downloaded once):
- pot12 (~54 MB, 2¹² = 4096 constraints) — covers
kpi,auditor_disclosure,claim - pot14 (~220 MB, 2¹⁴ = 16384 constraints) — required by
payroll_aggregator, which has 7065 constraints (exceeds pot12's ceiling)
Each circuit gets its own Phase 2 contribution (<name>_final.zkey) and exported verification key (<name>_vkey.json) under build/<name>/.
Rust workspace with three contracts, compiled to wasm32v1-none:
Generic BN254 Groth16 verifier using Soroban's native pairing host functions (soroban_sdk::crypto::bn254). Deployed twice in production — once per verification key, since a single instance only holds one VK.
set_vk(admin, vk_bytes)— stores the verification key as raw bytes (alpha/beta/gamma/delta G1/G2 points + IC array); the first caller becomes the admin for this verifier instance, and only that admin may callset_vkagain to rotate the keyverify(proof_bytes, public_signals) -> bool— checks the Groth16 pairing equatione(A,B) · e(-alpha,beta) · e(-vk_x,gamma) · e(-C,delta) == 1, wherevk_x = ic[0] + MSM(ic[1..], public_signals)
Wire format: proof is 256 bytes (pi_a: G1/64B, pi_b: G2/128B, pi_c: G1/64B); all field/curve coordinates are big-endian, 32 bytes per limb.
Holds the payroll pool and gates batch execution.
| Function | Access | Description |
|---|---|---|
initialize(admin, verifier_contract, token_address) |
once | Sets admin, verifier, and pool token (XLM or any SEP-41 asset) |
fund_pool(funder, amount) |
anyone | Deposits amount into the pool |
execute_payroll(caller, proof, public_signals, nullifiers, total_payroll) |
admin-only | Verifies the batch proof, rejects any already-spent nullifier, deducts the pool, transfers total_payroll to the linked claim contract, increments the payroll epoch |
verify_auditor(proof, public_signals) |
anyone | Read-only proof check; does not touch state |
set_verifier_vk_hash(admin, vk_hash) |
admin-only | Stores a SHA-256 digest of the expected VK so the frontend can detect a swapped VK cheaply |
set_claim_contract(admin, claim_contract) |
admin-only | Links the claim contract that receives escrow |
get_pool_balance(), is_nullifier_spent(nullifier), get_epoch() |
anyone | Read-only views |
execute_payroll errors: NotInitialized, Unauthorized, InvalidAmount, InsufficientFunds, NullifierSpent, InvalidProof, ClaimNotConfigured.
Holds escrow released by payroll.execute_payroll and pays out individual employees.
initialize(admin, payroll_contract, verifier_contract, token_address)—admin.require_auth()gates the one-time setupclaim_payout(recipient, proof, public_signals, nullifier, amount)—recipient.require_auth()(the employee's Freighter wallet must sign), checkspublic_signals[0] == nullifier, cross-callspayroll.is_nullifier_spent(nullifier)to confirm the batch executed, decodespublic_signals[1]as the payroll epoch and requires it's<=the on-chain epoch, decodespublic_signals[2]as the claimed amount and requires it exactly equals theamountargument (both in stroops), verifies the Groth16 proof, transfersamounttorecipient, and marks the nullifier claimedis_claimed(nullifier)— read-only
claim_payout errors: NotInitialized, InvalidAmount, SignalMismatch, NullifierNotAuthorized, AlreadyClaimed, PayrollEpochNotExecuted, InvalidProof. SignalMismatch (#9) is almost always a unit-conversion bug — see The unit system below.
Step 1 — Employer
generatePayrollProof() → executePayroll() → payroll contract
• verifies the PayrollAggregator Groth16 proof
• marks up to 5 batch nullifiers spent
• moves total_payroll (stroops) from pool → claim contract escrow
• saves a ClaimBundle for employees to consume in step 2
Step 2 — Employee
generateClaimProof() → claimPayout() → claim contract
• verifies the ClaimPayout Groth16 proof
• checks the nullifier against payroll.is_nullifier_spent
• compares public_signals[2] (amount, stroops) == amount argument
• token.transfer → employee wallet
• marks the nullifier claimed
Getting units wrong is the #1 cause of ClaimError::SignalMismatch (#9). There are three unit systems layered on top of each other:
| Layer | Unit | Value for 30 XLM |
|---|---|---|
| Frontend display | XLM | 30 |
| Circuit / claim-entry inputs | circuit units | 30,000 (= XLM × 1,000) |
Claim circuit public signal / Soroban token.transfer |
stroops | 300,000,000 (= circuit units × 10,000) |
- XLM → circuit units happens in the frontend when building an employee record (multiply by 1,000).
- circuit units → stroops happens right before proof generation for the claim circuit (multiply by
STROOPS_PER_UNIT = 10_000), sopublicSignals[2]is already in stroops and matches theamountStroopsargument sent on-chain.
cargo test -p meritpay-payroll # payroll contract — 10 tests (init, fund, execute, auditor, nullifiers, admin gating)
cargo test -p claim # claim contract — happy-path payout testContract tests use soroban_sdk::testutils with mock verifier contracts (MockOk always passes, MockFail always fails) and a register_stellar_asset_contract_v2 mock XLM token, so they run entirely offline without a real Groth16 proof or testnet connection.
node scripts/gen_proof.js # generate + locally verify a sample proof per circuit, using snarkjs./scripts/deploy.sh runs in stages:
- Build all contracts (
cargo build --target wasm32v1-none --release) and optimise the wasm - Fund the deployer account via the testnet friendbot
- Deploy
groth16_verifier(payroll VK) →VERIFIER_CONTRACT_ID - Deploy
payroll→PAYROLL_CONTRACT_ID - Deploy a second
groth16_verifierinstance (claim VK) →CLAIM_VERIFIER_CONTRACT_ID - Deploy
claim→CLAIM_CONTRACT_ID - Initialize
payrollandclaim, callpayroll.set_claim_contract(claim)to link them payroll.fund_pool(...)to seed the pool, and write all contract IDs to.env.contracts
Copy the result into the frontend:
cp .env.contracts ../MeritPay-Frontend/.env.local # adjust path to your frontend checkoutIf a deploy run skips VK upload, do it manually:
node scripts/upload_vk.js build/payroll/payroll_aggregator_vkey.json <VERIFIER_CONTRACT_ID>
node scripts/upload_vk.js build/claim/claim_vkey.json <CLAIM_VERIFIER_CONTRACT_ID>stellar keys generate deployer --network testnet # one-time
stellar keys ls
stellar keys address deployer
stellar keys fund deployer --network testnet # faucet- The Groth16 trusted setup performed by
setup.shuses a single, scripted Phase 2 contribution — fine for a hackathon/demo, not a production-grade multi-party ceremony. Do not treat these zkeys as trustworthy for real funds. execute_payrolland administrative functions are gated byrequire_auth()on the stored admin address;claim_payoutrequires the recipient's own signature, so a payout can only be pulled by the employee it belongs to.- Nullifiers are the sole replay defense on both contracts — reusing a nullifier value across unrelated employees or epochs will cause either a false
NullifierSpentrejection or (if you deliberately construct a collision) a valid payout for the wrong signals, since the contracts trust the proof to bind the nullifier to the right inputs. Circuit correctness is the actual security boundary; the on-chain contracts assume the proof is sound.
To report a vulnerability, follow SECURITY.md — do not open a public issue. A record of a prior internal audit is in SECURITY_AUDIT_REPORT.md.
Contributions are welcome. Start with CONTRIBUTING.md for the
development setup, coding standards, and PR process, and see the
issues/ directory for a curated, pre-scoped backlog (security
hardening, test coverage, circuit fixes, tooling) — each file has full context
and acceptance criteria. All participants are expected to follow the
Code of Conduct.
Licensed under the Apache License 2.0. See NOTICE for
attribution and third-party components — note that the snarkjs-generated
build/solidity_ref/*.sol reference files are GPL-3.0 and are not part of the
deployed system.