Introduction
Shade is a financial privacy layer built on Zama's fhEVM. It lets you send, receive, lock funds in escrow, and run payroll — while keeping every amount permanently encrypted on-chain. No plaintext figure ever appears in any transaction, log, or state variable. Not even validators can see it.
The trust model
Shade doesn't rely on trusted operators, off-chain relayers, or separate privacy infrastructure. Privacy is enforced by Fully Homomorphic Encryption running natively inside the EVM. The math is the trust — there is no operator key that could be compromised, no bridge custody, no sequencer privilege.
What stays private
| Public on-chain | Encrypted on-chain |
|---|---|
| Sender / receiver address (standard send) | Transaction amount (euint64) |
| That a transfer occurred | All balances and allowances |
| Timestamp, transaction hash | Total supply |
| Gas paid | Receiver address (stealth send only) |
| Freeze / allowlist status | — |
How FHE Works
What is a euint64?
euint64 is a Solidity type from Zama's fhEVM library. Under the hood it is a 32-byte handle — a reference to a ciphertext managed by the Zama coprocessor. When you write balances[alice] = FHE.add(balances[alice], amount) in Solidity, you are not computing in the EVM. You are emitting a request. The coprocessor picks it up, performs the FHE addition on the ciphertexts, and posts the resulting handle back. The EVM stores the handle; the ciphertext lives with Zama.
Why amounts stay encrypted end-to-end
Fully Homomorphic Encryption allows arbitrary computation on ciphertexts without decrypting them. The EVM can add, subtract, and compare encrypted balances — and the result is always another ciphertext. No plaintext ever appears in the execution trace, logs, or state. Even node operators and validators see only 32-byte handles.
The ACL — who can decrypt what
Each ciphertext handle has an Access Control List managed by Zama's on-chain ACL contract. Only addresses explicitly permitted — via FHE.allow(handle, address) — can request decryption of that value. When Shade calls FHE.allow(amount, recipient) during a transfer, it grants only the recipient the right to see the incoming amount. All other addresses, including the sender, retain their original grant — no address loses access when another is added.
User decryption (balance reveal)
When you click reveal on your balance, the app generates an ephemeral keypair and signs an EIP-712 permit authorising the Zama gateway to decrypt your specific handle. The gateway — a threshold network of KMS nodes — co-signs the decryption result and returns the plaintext. No single node can decrypt alone; threshold consensus is required. The keypair is stored in sessionStorage and discarded when you close the tab.
Public decryption (unshield, balance proof)
Some operations require a verified plaintext to be posted back on-chain. For these, Shade calls FHE.makePubliclyDecryptable(handle) which registers the handle for public decryption. The KMS nodes produce a result and a BLS signature aggregated from all threshold participants. The contract verifies the signature with FHE.checkSignatures before acting on the plaintext.
Input proofs
When you encrypt an amount client-side before sending it to a contract, the fhEVM SDK produces both a ciphertext handle and a zero-knowledge input proof. The proof demonstrates that you correctly encrypted a valid 64-bit value intended for the target contract — without revealing the value itself. The contract verifies the proof on-chain via FHE.fromExternal(ext, proof) before accepting the handle.
Getting Started
1. Get test USDC and Sepolia ETH
Shade runs on Sepolia testnet. You need two things: test USDC from the Circle faucet, and Sepolia ETH for gas from any Sepolia faucet (e.g. Chainlink). Connect MetaMask to Sepolia and you're ready.
2. Shield USDC
Go to Shield. Enter an amount and confirm two transactions: a standard ERC-20 approval on USDC, then the shield call which moves your USDC into the contract and mints an encrypted euint64 balance for your address.
Your cUSDC balance is never visible as plaintext on-chain. To view it in the app, click the reveal icon — the app generates a short-lived EIP-712 decryption permit which the Zama gateway uses to decrypt and return only your balance, only to you.
3. Send privately
Go to Send, enter a recipient address and an amount. Choose a mode:
Standard send — calls transfer(to, handle, proof) on ConfidentialUSDC. The recipient address is public; the amount is not.
Stealth send — additionally encrypts the recipient address. Neither the amount nor the destination is readable by observers. Only the intended recipient can claim.
4. View transfer history
The Transfers page shows a unified, chronological list of all your standard sends and stealth sends. Each row links to a detail page where you can grant a viewer access to decrypt a specific transfer.
5. Unshield back to USDC
Go to the Unshield tab under Shield. Enter an amount, submit the request, then wait ~30–120 seconds for the Zama KMS to produce a public decryption proof. Once available, finalise the unshield — your USDC is returned as plain ERC-20.
Escrow
Private escrow between a depositor and a recipient. The locked amount is an encrypted euint64 throughout its entire lifecycle — it is never decrypted on-chain during normal operation, not even for the arbiter.
State machine
An escrow moves through six states. Each transition requires a specific action from a specific role, enforced by the contract.
CREATED → FUNDED Depositor funds the escrow FUNDED → COMPLETED Recipient marks delivery complete COMPLETED → RELEASED Depositor approves and releases payment Dispute path (requires an arbiter to be set): FUNDED → DISPUTED Recipient raises dispute after the window passes DISPUTED → RELEASED Arbiter resolves in recipient's favour DISPUTED → REFUNDED Arbiter resolves in depositor's favour No-arbiter path: FUNDED → RELEASED Recipient calls claimAfterWindow() if depositor stalls FUNDED → REFUNDED Depositor calls timeout() if no delivery
Creating and funding
Call createEscrow(recipient, arbiter, timeoutSeconds) to open the escrow. The arbiter address is optional — pass the zero address to create a two-party escrow where timeout and claim-after-window are the only dispute resolution paths.
Fund by calling fund(id, encryptedAmount, inputProof). The amount is encrypted client-side before it ever leaves your browser. The contract pulls the cUSDC from your balance via an encrypted transferFrom. At no point does the plaintext figure appear on-chain.
Delivery and release
Once the work or goods are delivered, the recipient calls markCompleted(id, proofURI), attaching an optional IPFS or HTTPS URI that evidences delivery. The depositor reviews and calls release(id) to send the funds to the recipient.
Disputes and timeouts
If the depositor does not release after completion, the recipient can open a dispute (if an arbiter was set) or claim directly after the configured window. If the recipient never delivers, the depositor can reclaim their funds after the timeout expires. Both paths preserve the amount's encryption — the contract resolves ownership without ever knowing the plaintext figure.
The arbiter's role
The arbiter is ACL-permitted to decrypt the locked amount handle via the Zama gateway — giving them the ability to make an informed judgement if needed. In practice, the arbiter often needs only to verify delivery evidence (the proofURI) without inspecting the amount. The contract never reveals the amount to the arbiter automatically; decryption is always an explicit, optional step.
createEscrow(recipient, arbiter, timeoutSeconds) fund(id, encryptedAmount, inputProof) markCompleted(id, proofURI) release(id) disputeWithProof(id, proofURI) claimAfterWindow(id) timeout(id) resolveToRecipient(id) resolveToDepositor(id)
Payroll
Run fully encrypted payroll cycles. Each employee's salary is an individual euint64 ciphertext — no employee, no validator, and no contract admin can read another employee's figure.
Templates
A template is a reusable employee roster — just a list of wallet addresses. Create it once with createTemplate(address[]) and reuse it for every pay cycle. You can have multiple templates for different teams or departments.
Payroll runs — four steps
Each pay cycle is a separate run that goes through four sequential steps:
1. Create run — call createRun(templateId, handles[], proofs[]), passing one encrypted salary per employee. Each salary is encrypted individually client-side, so the handle array contains ciphertexts that only the intended employee (and the vault) can decrypt. Salaries are cached in localStorage so the run can be funded in a later browser session.
2. Fund run — approve the PayrollVault for the total salary amount, then call fundRun(id). The vault pulls cUSDC from your balance via an encrypted transferFrom. The total transfer amount is the sum of all encrypted salaries, computed inside the FHE coprocessor without ever producing a plaintext total on-chain.
3. Execute run — call executeRun(id). This credits each employee's euint64 salary handle to their pending claim on-chain. The vault can be executed at any time after funding — employees do not receive funds until they claim.
4. Employees claim — each employee calls claim() to move their pending salary into their own cUSDC balance. Only the employee's own claim is moved; the contract uses ACL-controlled handles so no other account can touch it.
Privacy guarantees
Salary handles are encrypted per-employee before submission. Each handle's ACL permits only the employer (to fund / execute) and the employee (to claim and decrypt their own amount). No employee can read another's salary — not even by inspecting the transaction data, because all figures are ciphertexts.
createTemplate(address[] employees) → templateId createRun(templateId, bytes32[] handles, bytes[] proofs) → runId fundRun(runId) // employer funds total executeRun(runId) // credits all claims claim() // employee pulls their salary
Balance Proof
Prove that your cUSDC balance meets or exceeds a threshold — without revealing either the balance or the threshold. The result is a publicly readable boolean stored on-chain.
Use cases: proof of funds for a counterparty, KYC-lite credit eligibility checks, DeFi collateral requirements, or any context where “I have at least X” is what matters — not the exact figure.
How it works — five steps
1. Authorise the prover — call authorizeBalanceRead(proverAddress) on ConfidentialUSDC. This grants the BalanceProver contract ACL access to compare your encrypted balance. Without this, the prover cannot touch your handle.
2. Submit the proof request — encrypt your threshold client-side and call proveAbove(encryptedThreshold, inputProof). The contract runs FHE.ge(balance, threshold) inside the FHE coprocessor. The result is a new ebool handle stored as pendingHandle — neither the balance nor the threshold appears in plaintext.
3. Request public decryption — the app reads pendingHandle and submits it to the Zama KMS for public decryption. This takes 30–60 seconds; the app retries automatically up to 12 times. The KMS produces a plaintext boolean and a BLS threshold signature over the result.
4. Publish the proof — call publishProof(user, abiEncodedResult, kmsSignature). The contract verifies the KMS signature with FHE.checkSignatures before storing the boolean. If verification fails, the transaction reverts. Only a genuine KMS-signed result can be published.
5. Anyone can verify — the published result is public. Navigate to /prove/[address] — no wallet required — to see whether a given address has proved their balance exceeds a threshold, when the proof was published, and whether it has been cleared.
Proof lifecycle
A proof exists until the account holder calls clearProof() to remove it. Only one proof per address is stored at a time — publishing a new proof overwrites the old one. The threshold used to generate the proof is never stored; only the boolean outcome is.
// On ConfidentialUSDC authorizeBalanceRead(address prover) // On BalanceProver proveAbove(bytes32 encryptedThreshold, bytes inputProof) → stores pendingHandle publishProof(address user, bytes result, bytes kmsSignature) clearProof() getProof(address user) → (bool result, uint256 timestamp)
Compliance
ConfidentialUSDC includes a lightweight compliance layer that coexists cleanly with the encryption model. All compliance checks happen on plaintext state before any FHE computation, so no ciphertext is ever processed for a non-compliant account.
Freeze and unfreeze
A frozen account cannot send or receive cUSDC. Freeze state is a plaintext mapping(address => bool) checked before any transfer operation. The admin panel shows a live list of all currently frozen accounts with inline one-click unfreeze buttons. The list refreshes automatically after each action.
Allowlist
When the allowlist is active, only listed addresses can participate in transfers. Toggle the allowlist on or off with the pill switch — the change takes effect immediately on-chain. Add and remove individual addresses at any time. The panel shows the complete current list with inline remove buttons.
Compliance admin delegation
The contract owner can assign a separate complianceAdmin address — a dedicated compliance officer wallet that can freeze accounts and manage the allowlist without holding full contract ownership. The current admin address is displayed prominently with a copy button and an Etherscan link.
// Solidity interface function setComplianceAdmin(address admin) external onlyOwner function freeze(address account) external onlyCompliance function unfreeze(address account) external onlyCompliance function setAllowlist(address, bool) external onlyCompliance function setAllowlistEnabled(bool) external onlyCompliance
Your compliance status
The Audit → Status tab and the Profile page both show your personal compliance standing: whether your account is frozen, and whether the allowlist is active and you are on it.
Selective Disclosure
Every transfer and stealth send has an on-chain ID. As the sender, you can grant any address (an auditor, counterparty, or regulator) the right to decrypt a specific transfer — without exposing any of your other activity.
How it works
Granting access calls FHE.allow(handle, viewer) for the relevant encrypted fields (amount, and recipient address for stealth sends), which adds the viewer to the ciphertext ACL inside Zama's ACL contract. The viewer can then request decryption via the Zama gateway using their own wallet — no further action needed from you.
// StealthSend function grantViewAccess(uint256 id, address viewer) external → FHE.allow(amount, viewer) → FHE.allow(recipient, viewer) → emit ViewAccessGranted(id, viewer) // ConfidentialUSDC function grantTransferViewAccess(uint256 id, address viewer) external → FHE.allow(amount, viewer) → emit TransferViewAccessGranted(id, viewer)
Viewing your grants
The Audit → Status → Selective Disclosure section shows every grant you've issued, grouped by transfer ID, with the viewer addresses and a link to the on-chain grant transaction.
How to grant from the UI
On the Transfers page, each row has a key icon. Click it to open the grant modal, enter the viewer's address, acknowledge the permanence warning, and confirm. For stealth sends, navigate to the detail page at /stealth/[id] — the same flow is available there.
Contracts
All contracts are deployed on Sepolia testnet as UUPS upgradeable proxies. On Etherscan, use “Read as Proxy” / “Write as Proxy” to interact with the correct ABI.
Underlying USDC (Circle Sepolia test token): 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
ConfidentialUSDC
Encrypted ERC-20 wrapping Circle's Sepolia USDC. Balances, allowances, and total supply are stored as euint64 ciphertexts. Supports shield, requestUnshield / finalizeUnshield, transfer, approve, and transferFrom — all operating on encrypted values. Includes compliance controls (freeze, allowlist, complianceAdmin) checked before any FHE computation.
StealthSend
Sends where the recipient address is encrypted as an eaddress. The sender encrypts both the amount and recipient in a single client-side proof. Only the intended recipient can claim by proving address equality under FHE. Senders can grant auditors selective view access via grantViewAccess(id, viewer), which opens ACL access to both encrypted fields.
PrivateEscrow
Holds cUSDC in a six-state machine (Created → Funded → Completed → Released / Disputed → Resolved / Refunded). The locked amount is an euint64 throughout — even the arbiter never sees the plaintext unless they actively request decryption via the gateway.
createEscrow(recipient, arbiter, timeoutSeconds) fund(id, encryptedAmount, inputProof) markCompleted(id, proofURI) // recipient signals delivery release(id) // depositor releases to recipient disputeWithProof(id, proofURI) // recipient opens dispute after window claimAfterWindow(id) // recipient auto-claims (no-arbiter path) timeout(id) // depositor reclaims if no delivery resolveToRecipient(id) // arbiter: pay recipient resolveToDepositor(id) // arbiter: refund depositor
PayrollVault
Employer creates a template (employee roster) then a run (encrypted salary per employee). Once funded and executed, each employee calls claim() to pull their encrypted salary into their cUSDC balance. Salaries are individual euint64ciphertexts — no employee can read another's.
BalanceProver
Generates an on-chain proof that a cUSDC balance exceeds a threshold without revealing either number. The result is a public bool readable by anyone at /prove/[address]. Powered by FHE.makePubliclyDecryptable and async KMS signature verification.
FAQ
Can anyone see my balance?
No. Your cUSDC balance is an encrypted handle on-chain. To obtain the plaintext, an address must be ACL-permitted for that handle and request decryption through the Zama gateway. By default only your own address is permitted. You can grant additional addresses read access via selective disclosure.
Can I see how much someone sent me?
Yes — you can decrypt amounts sent to you. When a transfer is made, the sender calls FHE.allow(amount, recipient), granting you ACL access. The app will offer a reveal button on any received transfer in the Transfers page (coming soon — currently the sender's reveal flow is implemented).
What does the arbiter see in an escrow?
The arbiter address is ACL-permitted to decrypt the locked amount handle, so it can request decryption via the gateway if needed to make a fair judgement. However, the amount is never surfaced automatically — the arbiter must actively initiate the decryption. The contract only emits the arbiter address and escrow ID in events, not the amount.
Can I revoke a view access grant?
No. fhEVM ACL grants in v0.11 are permanent — once FHE.allow(handle, viewer) is called, it cannot be undone. This is why the UI surfaces an explicit permanence warning before you confirm any grant.
What happens if my account is frozen?
A frozen account cannot send or receive cUSDC. Your existing balance remains intact and encrypted — no funds are moved or seized. Only the contract owner or the compliance admin can freeze or unfreeze accounts. If you believe your account was frozen in error, contact the protocol administrator.
Do I need to do anything to receive a stealth send?
Yes. Stealth sends encrypt the recipient address on-chain, so they don't appear in your wallet automatically. The sender should share the stealth send ID with you (or you can scan for sends addressed to you). Navigate to /stealth/[id] and click “Claim” to pull the funds into your cUSDC balance.
Is Shade audited?
Shade is on Sepolia testnet and has not been audited. Do not use it for mainnet funds. Zama's underlying fhEVM library is under continuous review by Zama's security team.
Can I use Shade on mainnet?
Not yet. Shade is deployed on Sepolia testnet only. A mainnet deployment will follow once Zama's fhEVM is production-ready on Ethereum mainnet and the contracts have been audited.
Where is the source code?
The contracts and frontend are open source on GitHub. Contracts are verified on Sepolia Etherscan — click any address in the Contracts section above to inspect the source.