Three-layer architecture
Layer 1: Ethereum mainnet
Ethereum stores balances aseuint64 ciphertexts — FHE-encrypted 64-bit unsigned integers. Every HideMeToken maps address → euint64, not address → uint256. The EVM performs addition, subtraction, and comparison directly on these ciphertexts without ever decrypting them.
The ACL (Access Control List) contract tracks which addresses are permitted to request decryption of each ciphertext handle. When a balance is updated, the contract calls FHE.allow(newBalance, holder) to grant decrypt permission to the holder, and FHE.allow(newBalance, observer) for each registered compliance observer.
Layer 2: Zama KMS network
The Zama Key Management Service runs a distributed threshold network that holds the FHE master private key. No single node can decrypt a ciphertext alone — a threshold of nodes must cooperate. When you request to see your balance, the KMS network:- Verifies your EIP-712 signature to confirm you are the account owner
- Checks the on-chain ACL to confirm the ciphertext handle grants you decrypt access
- Returns the plaintext value encrypted under your temporary session public key
- Your browser decrypts locally using the corresponding temporary private key
Layer 3: Gateway Chain
The Gateway Chain coordinates public decryption requests — operations where a plaintext result needs to be verified and used on-chain. This is required for the unwrap flow (converting cTokens back to plain ERC-20) and for the confidential payment router. WhenConfidentialWrapper calls FHE.makePubliclyDecryptable(canUnwrap), the Gateway Chain collects threshold signatures from the KMS network and submits a proof. The relayer then calls finalizeUnwrap(requestId, proof) with this proof to complete the operation.
Token flows
Mint flow
When the owner mints tokens, the plaintext amount is encrypted on-chain:HideMeToken.sol:
Transfer flow
Encrypted transfers use a client-side TFHE proof to move tokens without revealing the amount:_transfer:
FHE primitives
HideMeToken uses these fhEVM operations, all from theFHE library in @fhevm/solidity:
| Primitive | Purpose |
|---|---|
FHE.asEuint64(x) | Encrypt a plaintext uint64 on-chain into a ciphertext |
FHE.fromExternal(handle, proof) | Verify and import a client-side encrypted value |
FHE.add(a, b) | Add two encrypted values, return encrypted result |
FHE.sub(a, b) | Subtract two encrypted values, return encrypted result |
FHE.le(a, b) | Encrypted comparison — returns ebool (encrypted boolean) |
FHE.select(cond, a, b) | Encrypted conditional — returns a if cond is true, else b |
FHE.allow(ct, addr) | Grant addr permission to decrypt ciphertext ct |
FHE.allowThis(ct) | Grant the current contract permission to use ct |
FHE.isSenderAllowed(ct) | Check that msg.sender is permitted to use ct |
FHE.makePubliclyDecryptable(ct) | Request a public (on-chain) decryption via Gateway Chain |
Privacy properties
Why transfer events emit amount = 0
The Transfer(from, to, uint256 amount) event is required by ERC-20 tooling (block explorers, wallets, indexers). Emitting the real encrypted amount would leak the ciphertext handle publicly, and emitting a plaintext amount would obviously destroy privacy. Instead, HideMe always emits 0 for the amount field. The event still records the sender, recipient, and block timestamp, but reveals nothing about the value transferred.
Silent failure on insufficient balance
In a standard ERC-20, transferring more than your balance reverts the transaction. A revert is observable on-chain — it tells anyone watching that your balance is less than the attempted amount. This is an information leak in a confidential token system. HideMe uses an FHE-native pattern instead:FHE.le() performs an encrypted comparison. FHE.select() picks amount if the balance is sufficient, or 0 if it is not — entirely on encrypted values. The transaction succeeds either way, but a transfer of 0 is sent when the sender has insufficient funds. An observer cannot distinguish a successful transfer from a failed one by watching the chain.
Frequently asked questions
Can anyone see my balance?
Can anyone see my balance?
No. Your balance is stored on-chain as a
euint64 ciphertext — an FHE-encrypted 64-bit integer. Only you (and any observer addresses configured on the token) can request a decryption from the Zama KMS network. The KMS verifies your EIP-712 signature and checks the on-chain ACL before returning anything. Block explorers, indexers, and other wallet addresses see only an opaque ciphertext handle.What is an observer?
What is an observer?
An observer is a compliance or audit address that the token owner designates at creation time (or later via The token owner can add or remove observers at any time with
addObserver()). Observers are granted decrypt access to every balance ciphertext via FHE.allow(ct, observer) on every mint, transfer, and burn. This allows a regulator or auditor to verify holdings without exposing them to the public. Observers do not have any transfer or mint authority — they can only read balances.addObserver() and removeObserver(). Renouncing ownership permanently disables both functions.How does client-side encryption work?
How does client-side encryption work?
When you initiate a transfer, the HideMe frontend loads Zama’s TFHE WASM library (
relayer-sdk.js) in your browser. The encryptAmount() function calls createEncryptedInput(contractAddress, userAddress), adds the uint64 value, and calls .encrypt(). This produces two outputs:- handle: a reference to the ciphertext, submitted as the
externalEuint64argument totransfer() - inputProof: a zero-knowledge proof that the encrypted value was formed correctly for the specified contract and caller
FHE.fromExternal(encryptedAmount, inputProof) verifies the proof against the InputVerifier contract before accepting the ciphertext. This prevents a malicious caller from submitting an arbitrary ciphertext they didn’t create.The encryption happens entirely in your browser. The plaintext amount is never sent to any server.What happens during an unwrap?
What happens during an unwrap?
Unwrapping a cToken back to a plain ERC-20 requires a two-step process because the plaintext amount needs to be verified on-chain before the ERC-20 is released.Step 1: You call
unwrap(amount). The contract performs FHE.le(amount, balance) to check sufficiency (encrypted), then calls FHE.makePubliclyDecryptable(canUnwrap). This queues a decryption request on the Gateway Chain. Your account is locked from further transfers until finalization.Step 2: The relayer monitors the Gateway Chain for completed decryption proofs. When the Zama KMS network produces a threshold signature, the relayer calls finalizeUnwrap(requestId, proof). The contract verifies the proof, and if canUnwrap is true, burns the cToken balance and transfers the underlying ERC-20 back to you.If finalization does not happen within 1 day, you can cancel the unwrap to unlock your account.Do I need to run any infrastructure?
Do I need to run any infrastructure?
No. The Zama KMS network and Gateway Chain are operated by Zama. The relayer for finalizing unwraps and payments is run by HideMe. If you want to run your own relayer, you can — finalization transactions are permissionless and anyone can submit them with a valid KMS proof.For local development or environments without direct Zama relayer access, set
NEXT_PUBLIC_USE_MINI_RELAYER=true in your environment. This routes user decrypt requests through the app’s own /api/decrypt/user-decrypt endpoint, which signs and submits the decryption request to the Zama Gateway Chain using the server-side relayer key.