Search projects, hackathons, sponsors and tech

    ← All projects

    Penguin Protocol

    prevent your coworkers from stalking your paycheck

    About

    The problem it solves # Penguin Protocol Private payroll on Ethereum. Companies pay employees without exposing salary amounts or wallet addresses on-chain. --- ## The Problem Standard on-chain payroll leaks everything — sender, receiver, amount. Employees can be targeted, salaries compared, treasury movements tracked. --- ## How It Works **Identity via ENS** Companies own `company.eth`. When they onboard an employee, they mint a subdomain (`alice.company.eth`) directly to the employee's wallet using `NameWrapper.setSubnodeRecord`. No custom registry — ENS does the work. **Authentication via SIWE** Both parties sign in with their wallet (EIP-4361). Backend verifies the signature, checks ENS subdomain ownership against the database, issues a JWT. **Contracts stored on Fileverse** Salary terms are encrypted with both the company's and employee's public keys (ECIES), stored as a JSON blob inside a markdown file on Fileverse (pinned to IPFS). Only the two parties can decrypt. The employee decrypts by signing a deterministic message — no private key input needed. **Private payroll via ShieldVault** The vault holds USDC and maintains a MiMC Merkle tree. The company deposits a batch of commitments — each one encodes a salary note against a stealth address. The employee submits a ZK proof (Noir + Barretenberg UltraHonk) proving membership in the tree and receives USDC at a stealth address with no on-chain link to their identity. --- --- ## Why This Is Better | Without this | With this | |---|---| | Salary visible on-chain | Encrypted in IPFS, ZK proof to claim | | Employee wallet linked to payment | Stealth address, no on-chain link | | Custom employee registry | ENS subdomains | | Private key for decryption | Wallet signature derives key locally | | Single point of failure | BitGo treasury + private vault separated | --- ## Deployed (Base Sepolia) | Contract | Address | |---|---| | MockUSDC | `0x231E63e5E40E208D7570aaD33eF8a045d8EA4A3d` | | HonkVerifier | `0xDA559F68d4D001E34a6ccDD55B2975E3eaD8d79B` | | ShieldVault | `0x367707c3710514B196Bcf6bafE11977e264aa223` | ENS on Sepolia. Vault + ZK verification on Base Sepolia. --- ## Stack Next.js · RainbowKit/Wagmi · SIWE · ENS NameWrapper · Fileverse · Pinata/IPFS · Noir · Barretenberg · Solidity/Foundry · BitGo · Supabase

    How it's made

    Challenges we ran into # Bugs & Hurdles --- ## 1. ENS NameWrapper: `owner()` never returns your wallet Every `.eth` name registered via the ENS app since 2023 is "wrapped". The ENS Registry's `owner(node)` returns the NameWrapper contract, not your wallet. Our login check was calling `registry.owner(namehash(name))` — it always failed. Fix: check `NameWrapper.ownerOf(uint256(namehash(name)))` for wrapped names. Same issue hit subdomain creation — `registry.setSubnodeOwner` is a no-op on wrapped names. Switched to `NameWrapper.setSubnodeRecord(parentNode, stringLabel, owner, resolver, fuses, expiry)`. Also had to add an explicit `gas: BigInt(300_000)` because viem's estimator returned 21M gas (network cap is 25M, but the estimate was wrong). --- ## 2. MiMC constants identical in code, different outputs on-chain The ZK circuit (Noir) and the Solidity contract both implement MiMC hash. Constants were copy-pasted — byte identical. Proofs passed locally but `verifier.verify()` always returned false on-chain. Root cause: the Noir `h([a, b, 0, 0])` function runs **3 rounds per input** (12 rounds total). An early Solidity version ran **1 round per input** (4 rounds total). The loop body looked similar, just missing two lines. Merkle roots computed by the contract and by the circuit diverged completely. Fix: wrote a Foundry test that printed intermediate values, ran `nargo test --show-output` to get the Noir equivalents, diffed step by step until both matched. --- ## 3. SIWE chainId locked auth to one chain, txns needed the other ENS is on Sepolia. ShieldVault is on Base Sepolia. SIWE embeds `chainId` in the signed message. When the wallet was on Base Sepolia, auth rejected because the backend expected Sepolia's chain ID. When it was on Sepolia, vault transactions failed because the wrong network was active. Fix: removed `chainId` enforcement from SIWE verification — the domain, nonce, and timestamp are sufficient for replay protection. Auth is about wallet identity, not network. Both chains are always in the Wagmi config; the frontend calls `switchChainAsync({ chainId: baseSepolia.id })` before any vault transaction without triggering re-auth. --- ## 4. Contract decryption: can't ask users to paste private keys Employment contracts are encrypted with ECIES. The obvious path is "user pastes their private key to decrypt". That's not acceptable. Fix: deterministic key derivation from a wallet signature. The employee signs a fixed domain-scoped message (`penguin-protocol:decrypt:<address>`), the signature bytes are hashed with keccak256, and that 32-byte value is used as key material for AES-256-GCM via Web Crypto API. Same wallet, same key, every time. Nothing stored, nothing transmitted. --- ## 5. `depositBatch` gas estimate: 131M, block limit: 25M `ShieldVault.depositBatch` inserts leaves into a MiMC Merkle tree. Viem estimated 131,250,000 gas for 6 leaves. Base Sepolia's limit is 25M. Transaction rejected before hitting the mempool. Fix: `gasLimit: 8_000_000` explicitly on the call. Actual cost on-chain was ~4.2M. Viem's simulation diverged from actual execution because the tree state at simulation time didn't match the real state. --- ## 6. Fileverse `fileId` is a `BigInt`, `JSON.stringify` throws `agent.create()` returns `fileId` as a JavaScript `BigInt` (from an on-chain event). Passing it to `JSON.stringify` or Supabase directly throws `TypeError: Do not know how to serialize a BigInt`. Contracts were being stored with `fileverse_file_id: null`. Fix: `String(file.fileId)` before any serialization or DB write. One line, half a day lost.