Next.js dApp for the BlocPaie confidential payroll platform. Two dashboards — one for companies managing payroll, one for contractors collecting salary — connected to on-chain vaults via Porto passkeys and gas-sponsored transactions.
BlocPaie is an invoice-to-payment platform where companies create on-chain payroll vaults and pay contractors on Ethereum — while every salary amount, payee identity, and payment status stays fully encrypted on-chain via Zama's Fully Homomorphic Encryption. Nobody on-chain, including the contract itself, can read salary values in plaintext.
The blockchain acts as a verifiability layer — every payment registration, execution, and cancellation is permanently recorded on-chain with a cryptographic commitment, without exposing the underlying amounts or identities. This makes payroll auditable by regulatory authorities without compromising employee privacy.
The backend stores plaintext invoice metadata — amounts, contractor names, transaction history — so dashboards load instantly without requiring on-chain decryption on every page view. Decryption via Zama's KMS involves on-chain ACL grants and KMS round-trips; caching the results off-chain keeps the UX responsive.
The on-chain contracts never see plaintext. All sensitive values are FHE-encrypted client-side in the browser before any transaction is submitted. The backend is a UX layer — the vault contracts are the source of truth for all payments.
Ephemeral decrypt keypair — Zama's KMS validates userDecrypt requests via secp256k1 ECDSA signatures. Porto wallets use WebAuthn P-256 passkeys, which the KMS cannot verify directly. As a workaround, BlocPaie generates a short-lived secp256k1 keypair (decryptViewer) in the browser on every decrypt, grants it ACL access on-chain, uses it to sign the KMS request, then discards it. This costs an extra on-chain transaction and briefly materialises a secp256k1 private key in browser memory. This workaround is eliminated if Zama's KMS adds EIP-1271 support — allowing decryption to be authorised via isValidSignature on any smart contract wallet directly.
- Framework: Next.js 16 (App Router), React 19
- Blockchain: Wagmi 3, viem 2, Porto SDK (EIP-7702)
- FHE:
@zama-fhe/relayer-sdkfor client-side encryption and decryption - Wallet: Porto passkeys (WebAuthn) — no MetaMask required
- Gas: Ithaca Relay merchant route — all transactions sponsored
- Styling: Inline CSS (no Tailwind), Lucide icons
Porto requires HTTPS. Local dev must run over https://localhost:3000. The dev script uses next dev --experimental-https which self-signs a certificate.
npm installcp .env.example .env.local| Variable | Description |
|---|---|
NEXT_PUBLIC_API_URL |
Backend API base URL (e.g. https://blocpaie.onrender.com) |
NEXT_PUBLIC_RPC_URL |
Ethereum Sepolia RPC URL |
NEXT_PUBLIC_MERCHANT_URL |
Full URL to the merchant route (e.g. https://your-ngrok-url/api/porto/merchant). Must be publicly accessible — Porto SDK calls this from the browser. |
MERCHANT_ADDRESS |
Porto merchant account address (used server-side in /api/porto/merchant) |
MERCHANT_PRIVATE_KEY |
Porto merchant private key (server-side only) |
JWT_SECRET |
Same secret as backend — used to verify JWTs in API routes |
NEXT_PUBLIC_MERCHANT_URLmust be a public HTTPS URL. Porto's iframe calls this endpoint fromid.porto.sh. In local dev, expose it via ngrok:ngrok http 3000.
npm run devApp runs at https://localhost:3000.
app/
├── page.tsx # Landing / role selector
├── register/
│ ├── company/page.tsx # Company registration
│ └── contractor/page.tsx # Contractor registration
├── company/
│ ├── dashboard/page.tsx # Main company view — vault stats, deposit, withdraw, invoices
│ ├── vault-setup/page.tsx # Create vault (ERC-20 or Confidential)
│ ├── invoices/page.tsx # Invoice management — approve, register, cancel
│ └── transactions/page.tsx # Full transaction history (all 5 tx types)
└── contractor/
└── dashboard/page.tsx # Contractor view — invoices, cheque execution, balances, unwrap
api/
└── porto/
└── merchant/route.ts # Gas sponsorship endpoint (server-side)
All on-chain interactions are encapsulated in hooks under hooks/. Each hook wraps useSendCalls + waitForCallsStatus and posts confirmation to the backend.
| Hook | Vault type | Action |
|---|---|---|
useCreateVault |
Both | Deploy vault via VaultFactory |
useVaultDeposit |
ERC-20 | Deposit USDC into vault |
useVaultWithdraw |
ERC-20 | Withdraw USDC from vault, optional forward |
useVaultRegisterInvoice |
ERC-20 | Register cheque on-chain |
useVaultExecuteCheque |
ERC-20 | Execute cheque (payee collects) |
useVaultCancelCheque |
ERC-20 | Cancel cheque |
useVaultBalance |
ERC-20 | Read vault USDC balance |
useConfidentialVaultDeposit |
Confidential | Encrypt + deposit cUSDC |
useConfidentialVaultWithdraw |
Confidential | Withdraw cUSDC + full two-step unwrap |
useConfidentialVaultRegisterInvoice |
Confidential | Encrypt payee + amount, register cheque |
useConfidentialVaultExecuteCheque |
Confidential | Execute encrypted cheque |
useConfidentialVaultCancelCheque |
Confidential | Cancel encrypted cheque |
useConfidentialVaultBalance |
Confidential | Read + decrypt vault cUSDC balance |
useContractorCusdcBalance |
Confidential | Read + decrypt contractor's wallet cUSDC balance |
Company: deposit USDC → approve invoice → register cheque (on-chain)
Contractor: execute cheque → USDC lands in Porto wallet
Company: withdraw remaining USDC (optional: forward to another address)
Company: wrap USDC→cUSDC → encrypt + deposit → register encrypted cheque
Contractor: execute cheque → cUSDC lands in Porto wallet (encrypted)
↓ optional
decrypt balance → unwrap cUSDC → publicDecrypt (Zama) → finalizeUnwrap → USDC
Company: withdraw cUSDC (auto-unwraps to USDC)
Unwrapping cUSDC to USDC requires two Porto confirmations:
unwrap(from, to, encryptedAmount, proof)— registers request on-chain, emitsUnwrapRequested- Parse
UnwrapRequestedlog →instance.publicDecrypt([handle])via Zama relayer HTTP finalizeUnwrap(handle, cleartextAmount, decryptionProof)— transfers underlying USDC
This is handled automatically inside useConfidentialVaultWithdraw and the contractor's Receive Funds modal.
app/api/porto/merchant/route.ts is the gas sponsorship endpoint. Porto's iframe posts unsigned calls here; the server signs and returns a merchant signature.
The current implementation sponsors all transactions unconditionally. Post-MVP: add an allowlist of contract addresses to prevent misuse.
All FHE operations run client-side via lib/fhevm.ts (singleton FhevmInstance).
- Encryption:
instance.createEncryptedInput(contractAddr, userAddr)— proof is bound to both addresses - Balance decryption: ephemeral keypair via
generateKeypair()+ EIP-712 signature →userDecrypt() - Unwrap decryption:
instance.publicDecrypt([handle])— no user signature required, returns{ clearValues, decryptionProof } - Error checking:
lib/confidentialError.ts— decrypts the vault'sgetLastError()after each confidential operation
| Contract | Address |
|---|---|
| VaultFactory | 0x619B322e1D722F86294B4d7dF92B42c89B3456aB |
| MockUSDC | 0xe89D1caF047aEc9F7f0F3623F799F3bc321fFc9c |
| ConfidentialUSDC (cUSDC) | 0x8a486Fa9c123ADc482d383f9fe8A48adaD7fBc17 |
Constants live in lib/constants.ts.
frontend/
├── app/ # Next.js App Router pages + API routes
├── hooks/ # Wagmi-based on-chain interaction hooks
├── lib/
│ ├── abis/ # Contract ABIs (exported from Hardhat)
│ ├── auth.ts # JWT storage + getToken()
│ ├── constants.ts # Contract addresses, chain config
│ ├── fhevm.ts # FhevmInstance singleton
│ └── confidentialError.ts # Soft-error decryption helper
├── components/
│ └── Navbar.tsx
└── public/