"Payroll where the public can't see salaries, but the auditor can."
CipherPay is a confidential payroll protocol built on Solana that uses Token-2022's Confidential Transfer extension to encrypt salary amounts end-to-end. An employer funds a treasury with USDC, approves a payroll run, and every employee is paid in a single batched flow. Amounts are encrypted on-chain β the public sees that payroll happened, never how much.
Built for the Colosseum Solana Hackathon (devnet only).
- π Amount privacy β salary amounts are ZK-encrypted on-chain via Token-2022 Confidential Transfer
- ποΈ Compliance-first β a designated auditor can decrypt all payments; mixer-style unlinkability is deliberately out of scope
- β‘ Batch payroll β pay an entire team in one UI flow (client-orchestrated, proof-chunked pipeline)
- π Split payouts β rule-based splits (e.g. 79% employee / 20% tax reserve / 1% platform fee) for contractor payments
- π‘οΈ Simulation before execution β balance checks, recipient activation checks, and totals before anything moves
- βΈοΈ Pause / resume β admin can halt new payouts without freezing employee withdrawals
- π Operator key β admin authorizes a scoped operator key so a full payroll run doesn't require a wallet popup per transaction
- π Auditor view β auditor decrypts all payment ciphertexts emitted in on-chain events, no chain indexer needed
CipherPay is split into two independent but tightly coupled halves: an on-chain half (Rust / Anchor programs deployed to Solana) and an off-chain half (Next.js app that runs ZK proof generation in the browser). Neither half can do payroll alone.
vault/ β Anchor workspace root
βββ programs/
β βββ vault/ β Anchor program (14 instructions)
β βββ tests/ β litesvm in-process tests
βββ native_programs/
β βββ token_wrap_compliance/ β Raw native program (fork of Anza spl-token-wrap)
β βββ DEVNET_DEMO_AUDITOR_KEY.md β Auditor keypair reference
βββ libs/
β βββ confidential_token_helpers/ β Shared Rust library (not deployed)
βββ client/ β Standalone TS round-trip client (reference / testing)
β βββ src/roundTrip.ts
βββ web/ β Next.js 16 app β product UI + proof orchestration
βββ src/
β βββ app/ β Next.js App Router pages
β βββ lib/ β Crypto, chain, payroll, wallet, auth modules
βββ CLAUDE.md β Web-half engineering conventions
graph TB
subgraph Browser["π Browser (Off-Chain)"]
UI["Next.js App\n(Admin / Employee / Auditor views)"]
WASM["@solana/zk-sdk\n(WASM proof generation)"]
Crypto["lib/crypto\nElGamal Β· AES key derivation"]
Pipeline["lib/payroll\nBatch pipeline Β· Simulation"]
Chain["lib/chain\nRPC helpers Β· Tx building"]
DB_Client["lib/auth + lib/wallet\nWallet session Β· Auth"]
end
subgraph Cloudflare["βοΈ Cloudflare (Off-Chain)"]
Worker["Cloudflare Workers\n(OpenNext)"]
D1["Cloudflare D1\n(Employees Β· Run history Β· Splits)"]
end
subgraph Solana["βοΈ Solana (On-Chain)"]
VaultProgram["programs/vault\n(Anchor β 14 instructions)\nELChxVFA..."]
TokenWrap["native_programs/token_wrap_compliance\n(WUSDC mint creation + auditor key)"]
ZKProg["ZK ElGamal Proof Program\n(system β verifies proofs)"]
Token2022["Token-2022 Program\n(confidential transfer extension)"]
CtxAccounts["Proof Context Accounts\n(ephemeral β equality Β· validity Β· range)"]
end
UI --> Pipeline
UI --> Crypto
UI --> DB_Client
Pipeline --> WASM
Pipeline --> Chain
Chain -->|"RPC / tx"| Solana
Worker --> D1
UI -->|"API"| Worker
Pipeline -->|"create + verify proofs"| ZKProg
Pipeline -->|"pay_employee"| VaultProgram
VaultProgram -->|"CPI invoke_signed"| Token2022
TokenWrap -->|"creates mint with auditor key"| Token2022
ZKProg --> CtxAccounts
VaultProgram --> CtxAccounts
| Half | What it does |
|---|---|
On-chain (programs/, native_programs/) |
Anchor instructions, ZK proof verification, vault state, payroll guards |
Off-chain (web/) |
Proof generation (WASM), batch pipeline orchestration, employee list, run history, Next.js UI |
β οΈ A change to any instruction, account, event, or error in the on-chain half is a breaking change forweb/until the IDL and generated client are regenerated (bun run gen:vault).
These two identities are intentionally separate and must never be conflated:
| Identity | Holder | What it controls |
|---|---|---|
| Spend authority | Vault PDA | Calls confidential Transfer via invoke_signed |
| Decryption authority | Admin's derived ElGamal / AES keys | Reads encrypted treasury balances |
The PDA cannot decrypt. Only the admin (and the auditor) can read treasury amounts. This means a compromised server that holds the PDA seeds cannot drain funds in plaintext.
Keys are never randomly generated and stored. They are re-derived each session from the wallet:
ElGamalKeypair = derive(Ed25519_sign(fixed_message, owner_pubkey, mint_pubkey))
AeKey = derive(Ed25519_sign(fixed_message, owner_pubkey, mint_pubkey))
Ed25519 signatures are deterministic, so this is fully reproducible from the wallet alone β no key backup, no server storage. Keys live in browser memory only and are cleared on disconnect. Implemented via deriveElGamalKeypairForOwnerMint / deriveAeKeyForOwnerMint from @solana-program/token-2022/confidential.
Hardware wallets that do not support
signMessagecannot use the app.
A confidential Transfer requires three ZK proofs:
| Proof | Proves |
|---|---|
| Equality | The same amount is committed in sender and receiver ciphertexts |
| Ciphertext-validity | Each ciphertext is well-formed under the recipient's key |
| Range | The transferred amount is non-negative and does not overdraft |
These proofs do not fit alongside the transfer instruction in a single transaction (Solana's 1,232-byte wire limit). The solution β two transactions per proof before the transfer:
ββ Tx 1 βββββββββββββββββββββββββββββββββββ
β create_account (context-state PDA) β
β VerifyProof β write proof into account β
βββββββββββββββββββββββββββββββββββββββββββ
β (repeated for all 3 proofs)
ββ Tx 2 βββββββββββββββββββββββββββββββββββ
β pay_employee β
β β reads + closes context accounts β
βββββββββββββββββββββββββββββββββββββββββββ
The batch pipeline orchestrates this chunked, parallelizable flow per recipient. litesvm does not enforce the 1,232-byte limit β always test proof context flows against a real validator (surfpool / devnet).
Every confidential transfer is dual-encrypted: once for the recipient and once for the auditor's ElGamal pubkey, which is baked into the WUSDC mint at creation time by token_wrap_compliance.
pay_employee tx
βββ recipient ciphertext (only recipient can decrypt)
βββ auditor ciphertext (only auditor can decrypt β emitted in event)
WithdrawandUnwrapmake a specific amount plaintext β partial disclosure is supported- There is no anti-correlation infrastructure β no jitter, batching-as-obfuscation, or mixing. This is a deliberate design choice to avoid the shielded-pool / Tornado Cash legal exposure
Paying N employees means N Γ (3 proof tx pairs + 1 pay_employee tx). To avoid a wallet popup per transaction and to fit within devnet's ~40s transaction window, the admin can authorize a scoped operator key:
- Registered via
set_operatorβ stored in its own PDA so existing vaults are unaffected - Operator may sign only
pay_employee, with an optional expiry and revocation - Exposure is bounded by expiry; the program cannot cap amounts (amounts are encrypted)
Employee data, payroll runs, and split-payout rules live off-chain in Cloudflare D1 (SQLite via Drizzle ORM). Nothing salary-related is stored on-chain in plaintext.
| Store | Contents |
|---|---|
| On-chain | Vault PDA state, confidential token account balances (encrypted), proof context accounts (ephemeral) |
| Off-chain (D1) | Employee list (name, wallet, salary), payroll run history, tx signatures per recipient, split-payout rules |
Program ID (devnet): ELChxVFAiE5jv1SV3v3gprWeWxmbgX7ATWingW5VcB48
The core payroll vault program. All 14 instructions are implemented and tested on litesvm + local surfpool.
| Instruction | Description |
|---|---|
initialize_vault |
Create a new payroll vault PDA |
configure_treasury_account |
Set up the vault's confidential treasury token account |
fund_treasury |
Transfer WUSDC into the treasury |
deposit_confidential |
Deposit plaintext balance into the confidential balance |
apply_treasury_pending |
Apply pending confidential balance for the treasury |
pay_employee |
Transfer encrypted funds from treasury to one employee (3 ZK proofs) |
pause_payroll |
Halt pay_employee (withdrawals remain open) |
resume_payroll |
Re-enable pay_employee |
set_operator |
Register a scoped operator key (optional expiry) |
authorize_operator |
Operator delegation for signing pay_employee |
create_confidential_account |
Set up a wallet-owned confidential token account |
wrap_confidential |
1:1 wrap real USDC β WUSDC (confidential-capable) |
withdraw_confidential |
Employee withdraws encrypted pay (2 ZK proofs) |
unwrap_confidential |
Unwrap WUSDC β real USDC |
A fork of Anza's spl-token-wrap. Creates the canonical WUSDC mint with:
- Confidential Transfer extension
- Designated auditor key
- Permanent delegate
- Pausable
A Next.js 16 app (App Router) deployed to Cloudflare Workers via OpenNext.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router), React 19, TypeScript |
| Package Manager | Bun 1.3.14 |
| Styling | Tailwind CSS v4, shadcn/ui |
| Solana Client | @solana/kit v8, @solana-program/token-2022 |
| ZK Proofs | @solana/zk-sdk (WASM, browser), @noble/curves |
| Database | Cloudflare D1 (SQLite) via Drizzle ORM |
| Deployment | Cloudflare Workers (OpenNext) |
| Route | View | Who uses it |
|---|---|---|
/ |
Landing / home | Everyone |
/app |
Dashboard overview | Admin |
/admin |
Payroll management, employee list, vault setup | Employer admin |
/pay |
Execute a payroll run, approve payouts | Employer admin |
/auditor |
Decrypt and verify all payment ciphertexts | Auditor |
| Module | Description |
|---|---|
crypto/ |
ElGamal / AES key derivation, encryption, decryption |
chain/ |
Solana RPC helpers, transaction building, proof context accounts |
payroll/ |
Batch pipeline orchestration, simulation, run tracking |
wallet/ |
Wallet connection and session key management |
auth/ |
Authentication helpers |
mock/ |
Stub implementations for offline / UI development |
- Rust +
cargo - Solana CLI β₯ 1.18
- Anchor CLI β₯ 0.30
- Bun β₯ 1.3.14
- Surfpool (for local validator testing)
spl-token-cliβ₯ 5.6.1 (install with--locked)
git clone <repo-url>
cd vault
β οΈ Build with--arch v1β the current surfpool version requires the older SBPF target. A default-arch deploy fails withELF error: Detected sbpf_version required by the executable which are not enabled.
cargo build-sbf --arch v1 --manifest-path programs/vault/Cargo.toml
cargo build-sbf --arch v1 --manifest-path native_programs/token_wrap_compliance/Cargo.toml# Runs cargo build-sbf --arch v1 then cargo test (litesvm + integration)
anchor test# Start local validator (offline mode)
surfpool start --offline --no-deploy --no-tui --no-studio &
# Point CLI at local validator
solana config set --url http://127.0.0.1:8899
# Deploy both programs
solana program deploy target/deploy/vault.so \
--program-id target/deploy/vault-keypair.json
solana program deploy target/deploy/token_wrap_compliance.so \
--program-id target/deploy/token_wrap_compliance-keypair.jsonRemember to switch back:
solana config set --url <cluster>before doing anything against devnet/mainnet.
The client/ directory contains a standalone TypeScript script that proves the entire payroll flow end-to-end against a real validator.
cd client
npm install
# Against local surfpool
npm run round-trip:surfpool
# Against Solana devnet (needs ~0.65 SOL in FUNDER_KEYPAIR = ~/.config/solana/id.json)
npm run round-trip:devnetThe script logs each step with addresses and amounts, and exits non-zero if any balance check fails.
cd web
bun install
# Copy environment config
cp .env.example .env.local
# Edit .env.local β set NEXT_PUBLIC_SOLANA_RPC_URL to a dedicated devnet endpoint
# Apply local DB migrations
bun run db:migrate:local
# Start dev server
bun run devOpen http://localhost:3000.
| Variable | Description | Default |
|---|---|---|
NEXT_PUBLIC_SOLANA_RPC_URL |
Solana RPC endpoint (browser-visible) | https://api.devnet.solana.com |
The public devnet RPC rate-limits the batch payout pipeline. Use a dedicated endpoint (e.g., Helius, Triton) for payroll runs.
Used for local Cloudflare Workers secrets. Copy from .dev.vars.example and fill in values before running bun run preview.
The app uses Cloudflare D1 (SQLite-compatible) via Drizzle ORM for off-chain data:
- Employee list (name, wallet address, salary)
- Payroll run history (status, tx signatures per recipient)
- Split payout rules and approvals
# Generate migrations after schema changes
bun run db:generate
# Apply to local D1
bun run db:migrate:local
# Apply to remote D1 (Cloudflare)
bun run db:migrate:remote
# Open a SQL shell (local)
bun run db:shell:local "SELECT * FROM employees"
# Reset local DB
bun run db:reset:local| Test suite | Location | Command |
|---|---|---|
| On-chain (litesvm, in-process) | programs/vault/tests/ |
anchor test |
| Round-trip (surfpool / devnet) | client/src/roundTrip.ts |
npm run round-trip:surfpool |
| Web unit tests | web/ |
bun test |
Important: litesvm does not enforce Solana's 1232-byte transaction limit. Real-validator tests (surfpool / devnet) are required to validate the two-transaction proof context flow.
Admin App (Browser) Solana Chain
β β β
ββ Connect wallet βββββββΊβ β
β ββ derive ElGamal/AES keys β
β β (signMessage, in-memory) β
β β β
ββ Fund treasury ββββββββΊβ β
β ββ wrap USDC β WUSDC ββββββββΊβ token_wrap_compliance
β ββ fund_treasury βββββββββββββΊβ vault
β ββ deposit_confidential ββββββΊβ vault
β ββ apply_treasury_pending ββββΊβ vault
β β β
ββ Approve payroll ββββββΊβ β
β ββ Simulate (balance check, recipients activated)
β β β
β β Per employee: β
β ββ create equality ctx βββββββΊβ ZK ElGamal Proof Program
β ββ verify equality proof βββββΊβ
β ββ create validity ctx βββββββΊβ
β ββ verify validity proof βββββΊβ
β ββ create range ctx ββββββββββΊβ
β ββ verify range proof ββββββββΊβ
β ββ pay_employee ββββββββββββββΊβ vault (emits auditor ciphertext event)
β β β
Employee β β
ββ withdraw βββββββββββββΊβ β
β ββ withdraw_confidential βββββΊβ vault
β ββ unwrap_confidential βββββββΊβ vault
βββ USDC in wallet βββββββ β
pay_employeeemits an event containing the payment amount encrypted for the auditor (ciphertext taken from the proof context account β guaranteed to match what was proven on-chain)- The client stores the transaction signature per recipient in the off-chain run history
- The auditor opens the
/auditorview, fetches each transaction by signature, and decrypts the ciphertext with their secret key - Decrypted amounts β verified, tamper-evident payment record
The auditor key is currently a platform-wide key on the canonical WUSDC mint. Per-employer auditors are a Phase 2 feature.
| Phase | Description | Status |
|---|---|---|
| Phase 0 | Token-2022 primitives, token_wrap_compliance, all 14 vault instructions, litesvm + surfpool tests |
β Complete |
| Phase 1 | Next.js app on devnet, batch pipeline, admin/employee/auditor views, simulation, split payouts | β Complete |
| Phase 2 | TypeScript SDK (custody-safe, customer-held keys), hosted API (simulation, webhooks, reconciliation), per-employer auditor | π Planned |
| Phase 3 | Recurring / milestone / event-triggered payments, multi-party approvals, spending limits | π Planned |
| Phase 4 | Mainnet deployment, authority role hardening, security review, real auditor key management | π Planned |
| Property | How it's achieved |
|---|---|
| Salary amounts hidden from public | Token-2022 Confidential Transfer β amounts are ElGamal-encrypted on-chain |
| Auditor can always verify | Every transfer is dual-encrypted (recipient + auditor pubkey) at the program level |
| Treasury keys never leave the browser | Keys derived from signMessage β in-memory only β cleared on disconnect |
| No server custody | All ZK proof generation runs in the browser (WASM). Our servers never hold private keys |
| Spend authority β decrypt authority | Vault PDA controls spending; admin's derived keys control decryption |
| Operator key is scoped | set_operator allows signing only pay_employee, with optional expiry and revocation |
- Devnet only β not audited for mainnet
- All four WUSDC mint authority roles point to one deployer wallet (split before real funds)
- Auditor key is platform-wide on the canonical WUSDC mint (per-employer auditors are Phase 2)
- Employees need their own SOL for withdrawal fees (fee sponsorship is roadmap)
| File | Purpose |
|---|---|
PRODUCT.md |
Product context, decisions log, architecture deep-dive |
CLAUDE.md |
On-chain half engineering conventions |
web/CLAUDE.md |
Web half engineering conventions |
web/DESIGN.md |
UI/UX design system and component guidelines |
Anchor.toml |
Anchor workspace config and program IDs |
client/README.md |
Round-trip TS client usage guide |
# Regenerate Anchor IDL and vault TypeScript client
cd web && bun run gen:vault
# Typecheck the web app
cd web && bun run typecheck
# Generate Cloudflare env types
cd web && bun run cf-typegen
# Deploy to Cloudflare Workers
cd web && bun run deploy
# Preview locally with Cloudflare runtime
cd web && bun run previewThis project was built for the Colosseum Solana Hackathon. License TBD.
Token-2022 Confidential Transfer Β· ZK ElGamal Proofs Β· Anchor Β· Next.js Β· Cloudflare Workers


