Skip to content

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

MeritPay Contracts

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.

Contents

How it works

  1. 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.
  2. Payroll aggregation — the employer batches up to 5 employees' KPI results into one PayrollAggregator Groth16 proof and submits it to the payroll Soroban contract. This verifies the batch, burns 5 nullifiers (preventing replay), and moves the total payroll amount from the pool into the claim contract's escrow.
  3. Individual claim — each employee generates their own ClaimPayout Groth16 proof and calls the claim contract 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.

Repository layout

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 by scripts/setup.sh and is not committed — it includes two Powers-of-Tau files (~54 MB and ~220 MB) that would otherwise bloat the repository.

Prerequisites

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

Getting started

# 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.sh

setup.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/.

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>/.

Soroban contracts

Rust workspace with three contracts, compiled to wasm32v1-none:

groth16_verifier

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 call set_vk again to rotate the key
  • verify(proof_bytes, public_signals) -> bool — checks the Groth16 pairing equation e(A,B) · e(-alpha,beta) · e(-vk_x,gamma) · e(-C,delta) == 1, where vk_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.

payroll

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.

claim

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 setup
  • claim_payout(recipient, proof, public_signals, nullifier, amount) — recipient.require_auth() (the employee's Freighter wallet must sign), checks public_signals[0] == nullifier, cross-calls payroll.is_nullifier_spent(nullifier) to confirm the batch executed, decodes public_signals[1] as the payroll epoch and requires it's <= the on-chain epoch, decodes public_signals[2] as the claimed amount and requires it exactly equals the amount argument (both in stroops), verifies the Groth16 proof, transfers amount to recipient, and marks the nullifier claimed
  • is_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.

Two-step payroll flow, in detail

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

The unit system — read this before touching amounts

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), so publicSignals[2] is already in stroops and matches the amountStroops argument sent on-chain.

Testing

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 test

Contract 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

Deploying to testnet

./scripts/deploy.sh runs in stages:

  1. Build all contracts (cargo build --target wasm32v1-none --release) and optimise the wasm
  2. Fund the deployer account via the testnet friendbot
  3. Deploy groth16_verifier (payroll VK) → VERIFIER_CONTRACT_ID
  4. Deploy payroll → PAYROLL_CONTRACT_ID
  5. Deploy a second groth16_verifier instance (claim VK) → CLAIM_VERIFIER_CONTRACT_ID
  6. Deploy claim → CLAIM_CONTRACT_ID
  7. Initialize payroll and claim, call payroll.set_claim_contract(claim) to link them
  8. 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 checkout

If 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>

Key management

stellar keys generate deployer --network testnet   # one-time
stellar keys ls
stellar keys address deployer
stellar keys fund deployer --network testnet        # faucet

Security notes

  • The Groth16 trusted setup performed by setup.sh uses 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_payroll and administrative functions are gated by require_auth() on the stored admin address; claim_payout requires 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 NullifierSpent rejection 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.

Contributing

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.

License

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.

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages