Encrypted Balances
The Encrypted Layer. Balances are stored as ElGamal ciphertexts on-chain. Transfer amounts are hidden from public observers, but the transaction graph (who sends to whom) remains visible.
User Accounts
Each user has an EVM account addr and an off-chain Grumpkin Curve key pair (ESK, EPK) — the encryption secret key and the encryption public key. The key pair is generated locally and is the user's privacy identity within the system.
Before an EPK can be used at all — for outgoing operations or as the recipient of any deposit — it must be registered with a controller (an EVM address) via registerEpk. Registration is the on-chain event that first binds an EPK to a custodian; the contract refuses to credit any balance for an EPK that does not yet have a controller.
Why incoming credits also require registration. The two-factor design splits trust between ESK (computation, held client-side) and controller (custody, held in a wallet). A natural deployment is to delegate ESK to a custodian that is trusted only for anonymity, not for custody — e.g. an off-chain service that holds the ESK so the user's own address never touches the encrypted layer. If deposits could land at an unregistered EPK, that ESK-only custodian could front-run registerEpk with a controller they control and withdraw everything sitting there. Requiring registration before any credit — encrypted transfer, public-to-encrypted deposit, or autoEncrypt routing — closes that window. In the contract, this is enforced as a single invariant at the one point where credits happen (_creditSlot), not per entry point, so new deposit paths inherit the check automatically. The error is EpkNotRegistered.
The same chokepoint also enforces the AML freeze check on inbound credits: _creditSlot reverts with EpkIsFrozen if the target EPK is in the freeze registry. See Freeze and Seize for the full enforcement model and outbound checks.
Outgoing operations (encryptedTransfer, encryptedToPublicTransfer) and pending-balance management (activatePending) require two forms of authorization:
- A ZK proof proving knowledge of the ESK — this handles the balance computation (proving the sender can afford the transfer, computing new ciphertexts, etc.).
- An EIP-712 signature from the controller — this provides custody authorization, binding the operation parameters and preventing replay.
This separation decouples computation (ESK, held client-side) from custody (controller, held in any EVM wallet — MetaMask, Ledger, Fireblocks, Safe multisig). The controller never transacts on-chain — it only signs EIP-712 messages. All operations go through SharedAccount. The EVM msg.sender remains irrelevant — authorization comes from the controller signature, not from msg.sender.
Two on-chain mechanisms link an EVM address to an EPK, both opt-in:
- AutoEncrypt (see AutoEncrypt) — calling
setAutoEncrypt(true, epk)bindsmsg.sender → epkso that any subsequent ERC-20transfertomsg.senderis automatically encrypted toepkinstead of crediting the public balance. The binding is one-shot — the enabled flag can be flipped afterwards viatoggleAutoEncrypt, but the bound EPK cannot be changed. - Pending Balance Protection —
activatePending(epk, ...)flips a flag on the EPK itself; it does not link the EPK to any EVM address (deniability is preserved because the controller address is a fresh unused key).
Contract layout
Each deployment splits into two UUPS contracts:
EBHub— one per deployment. The issuer-facing surface: identity (EPK→controller registry), compliance and auditor keys (trcPk,totalSupplyPk), mint/burn entry points and their verifiers, governance roles.EBEMT— one per token (zkUSD, zkEUR, …). The encrypted ERC-20 itself: balances, pending balances, autoEncrypt, encrypted total-supply counter, user-facing transfer entry points.
Controller-signed operations split across two EIP-712 domains: controller rotation against EBHub's domain, every other controller signature against the token's domain.
Balances
Each EBEMT contract is itself an ERC-20 token (one EBEMT deployment = one token). Multi-token support is achieved by deploying multiple EBEMT instances; they all share a single Hub.
For each EPK, the contract maintains:
encryptedBalances[EPK]-- the main balance, used as a public input in all balance-decreasing operations (encryptedTransfer,encryptedToPublicTransfer).pendingBalances[EPK]-- a secondary balance that receives incoming transfers when pending-balance protection is active for this EPK (see Pending Balance).controllers[EPK]-- the EVM address authorized to sign operations for this EPK. Lives on the Hub (deployment-wide). Set once viaHub.registerEpk, changeable viaHub.changeController. EBEMTs read it via STATICCALL on every credit and every controller-authenticated operation.authNonces[controllerAddress]-- bitmap-based replay protection for controller-authorized operations. Non-sequential: the caller picks any unuseduint256nonce, the contract checks it hasn't been used and marks it consumed. Uses Permit2-style bitmap packing (256 bits per storage slot). The Hub maintains its ownauthNoncesbitmap (consumed only bychangeController); each token maintains its own (consumed by transfer / encrypted-to-public / activatePending). The two spaces are independent — domain-separated by EIP-712 verifyingContract.
A per-EPK boolean flag pendingActive[EPK] controls whether incoming funds are routed to pendingBalances or directly to encryptedBalances.
Both balances start at the zero ciphertext (the default value of the storage mapping) and are first updated when the EPK first appears as a sender or recipient. Any credit to an EPK — i.e. an incoming amount routed into either encryptedBalances[EPK] or pendingBalances[EPK] — requires controllers[EPK] to be non-zero; see User Accounts for why.
Each balance is an ElGamal encryption of a value amount: u64. More specifically, the ciphertext is a pair of curve points (R, C) where:
R = r * G
C = amount * G + r * EPK
Here r is a random scalar (the randomizing element) and G is the Grumpkin generator. The on-chain ElGamalCiphertext struct holds R and C as nested Point { uint256 x; uint256 y; } fields — four field elements in storage, with R and C named so the type system can't accidentally swap them.
An important thing to note is that the balance of user A is then encrypted by this user's public key, meaning that we expect only this particular user to be able to learn their balance.
The second thing to note is that an exponential encoding is used in the encryption scheme. This means that when decrypting the user obtains a point M = m*G but recovering m is not immediate. In fact the user must solve a discrete logarithm problem to get it. See Discrete Log Solver.
Operations
Gas Abstraction
The system is designed so that a user without access to gas tokens of the underlying chain can still perform each operation. Gas sponsorship is provided by an ERC-4337 Paymaster. Users construct their transactions as ERC-4337 UserOperations, which are submitted by a bundler and have their gas paid by the Paymaster. The user never needs to hold ETH or interact with the chain directly.
In the descriptions below we focus on the cryptographic and contract-level logic of each operation, and omit the ERC-4337 wrapping for clarity. Every operation described can be submitted as a UserOperation sponsored by the Paymaster.
Mint and Burn
See Minting and Burning for the entry points
(publicMint, publicBurn, encryptedMint, encryptedBurn) and their role model.
Controller Registration
Both entry points live on EBHub. One register / one change once enrolls an EPK across every token in the deployment.
Hub.registerEpk(epk, controller, proof)
One-time operation binding an EPK to a controller address. The ZK proof verifies ESK · G == EPK with a domain-separating commitment as an unconstrained public input. The Hub computes that commitment as
auxCommitment = uint256(keccak256(abi.encode(controller, block.chainid, address(this)))) % BN254_FR
where address(this) is the Hub address, so the proof is bound simultaneously to the chosen controller, the chain it was generated for, and the specific deployment's Hub. No msg.sender check: compatible with SharedAccount + Paymaster flow.
Three replay attacks are prevented by the three components of the commitment:
- Controller substitution. Without
controllerin the binding, a MEV bot could lift a valid proof from the mempool and submit it with a different controller, hijacking the EPK. - Cross-chain replay (DoS). Without
block.chainid, an attacker who scrapes aregisterEpkcall from chain A can replay it verbatim on chain B and squat(epk, controller)there. Ifcontrolleris a smart-contract address that only exists on chain A (e.g. a Safe whose deployment salt was never executed on chain B), the legitimate owner cannot produce a controller signature on chain B and their EPK is permanently bricked there. - Cross-deployment replay. The Hub's
address(this)domain-separates across distinct deployments on the same chain — staging vs. prod, redeploys, or chains that share a chainid via a fork. A proof generated for one Hub cannot be lifted into another. Hub's CREATE3 deterministic address makes this binding stable across chains.
Once registered, the controller can only be changed via Hub.changeController.
Hub.changeController(epk, newController, nonce, deadline, signature)
Allows the current controller to delegate control to a new address. Authorized by an EIP-712 signature (ChangeControllerAuth) from the current controller, signed against Hub's EIP-712 domain (name = "EBHub", verifyingContract = hub address). Uses a bitmap nonce from Hub's own authNonces for replay protection (independent of the per-token transfer nonces). Even with knowledge of ESK, an attacker cannot override the controller — this is the only way to change it.
Security properties
| Threat | Mitigation |
|---|---|
| ESK compromised | Attacker can generate valid ZK proofs but cannot sign — controller signature is required. Funds are safe as long as the controller key is secure. |
| Controller key compromised | Attacker can sign authorizations but cannot generate valid ZK proofs — proof requires correct balance decryption. Funds are safe as long as ESK is secure. |
| Both compromised | Attacker has full control. Same risk as the old ESK-only model. |
Frontrunning registerEpk | Controller address is folded into the ZK proof's _aux_commitment — a MEV bot cannot reuse the proof with a different controller. |
Cross-chain replay of registerEpk (EPK squatting / DoS) | block.chainid and the Hub's address(this) are folded into _aux_commitment — a proof generated for one (chain, Hub deployment) tuple is rejected by every other Hub, so an attacker cannot replay a proof from chain A on chain B to brick (epk, controller). |
| ESK-only custodian steals deposits to an unregistered EPK | The contract refuses to credit any EPK whose controllers[EPK] is still zero — enforced at the single credit chokepoint (_creditSlot), so it applies uniformly to encryptedTransfer, publicToEncryptedTransfer, and autoEncrypt routing. A party holding only the ESK (trusted for anonymity, not custody) cannot receive funds until the owner has bound a controller they choose. |
Transfer (Encrypted to Encrypted)
User A sends amount tokens to user B. The operation requires both a ZK proof and a controller EIP-712 signature.
Controller authorization. The sender's controller signs an EncryptedTransferAuth EIP-712 message binding the sender EPK, recipient EPK, a hash of all operation parameters (paramsHash), a nonce, and a deadline. The contract validates the signature via SignatureChecker (supporting both EOA and ERC-1271 smart contract controllers) and consumes the nonce via the bitmap.
Recipient must be registered. As with every other inbound credit path (see User Accounts), the transfer is rejected with EpkNotRegistered if controllers[EPK_B] == address(0).
ZK proof. A circuit is run with the following public inputs:
c_A_old-- the initial encrypted balance of userAc_A_new-- the new encrypted balance of userAc_amount-- the encrypted value ofamountunder keyB
The transfer amount itself is a private input — it never appears on-chain.
The following constraints are verified in the circuit:
- The prover knows the secret key corresponding to
EPK_A - There is a value
balance_oldthat is the decryption ofc_A_oldandbalance_old >= amount balance_old = balance_new + amountc_A_newis the encryption ofbalance_newunder keyAc_amountis the encryption ofamountunder keyB
The transaction payload includes the proof and the controller signature. The contract verifies both, then updates balances:
encryptedBalances[A] = c_A_new- The encrypted transfer amount
c_amountis added to the recipient'sencryptedBalances[B]— or topendingBalances[B]if the recipient has pending-balance protection active (see Pending Balance).
The function also takes two required boolean parameters, clearPending and deactivatePending, that control pending-balance state after the transfer (see Pending Balance). These are packed into a ControllerAuth struct alongside the nonce, deadline, and signature.
Encrypted to Public Transfer
User A withdraws amount tokens to a public EVM address recipient. Like encrypted transfers, this requires both a ZK proof and a controller EIP-712 signature.
Controller authorization. The sender's controller signs an EncryptedToPublicAuth EIP-712 message binding the sender EPK, recipient address, amount, a hash of operation parameters (paramsHash), a nonce, and a deadline.
ZK proof. A circuit is run with the following public inputs:
c_A_old-- the initial encrypted balance of userAc_A_new-- the new encrypted balance of userAamount-- the withdrawn amount (public, unlike in transfer)
The following constraints are verified in the circuit:
- The prover knows the secret key corresponding to
EPK_A - There is a value
balance_oldthat is the decryption ofc_A_oldandbalance_old >= amount balance_old = balance_new + amountc_A_newis the encryption ofbalance_newunder keyA
The recipient address and amount are bound to the operation via the controller signature (not via the circuit's _aux_commitment — see ZK circuit changes for what _aux_commitment carries).
The contract updates state:
encryptedBalances[A] = c_A_newamountpublic tokens are minted torecipient
Since amount is public, there is no need to encrypt it under a recipient key -- the circuit is simpler than the transfer circuit.
The function also takes two required boolean parameters, clearPending and deactivatePending, that control pending-balance state after the transfer (see Pending Balance).
AutoEncrypt
A user who has registered their EPK can call setAutoEncrypt(enabled, epk) on the contract to bind their EVM address to that EPK. This sets autoEncryptEpk[addr] = epk and autoEncrypt[addr] = enabled in storage. Once enabled, the contract overrides the standard ERC-20 transfer and transferFrom functions: when anyone sends public tokens to that address, instead of crediting the recipient's public balance, the contract burns the tokens and adds the encrypted amount to balance[EPK] -- the same operation as a public to encrypted transfer with r = 0. Subject to the same pending-balance routing as transfers.
This is transparent to the sender -- they call a normal ERC-20 transfer and the contract intercepts it. No ZK proof is involved; the encryption is done on-chain.
setAutoEncrypt(enabled, epk) requires the target EPK to already be registered — the call reverts with EpkNotRegistered otherwise. This is an early check; the same invariant is still enforced at the credit chokepoint (_creditSlot) for all incoming-credit paths, but rejecting misconfiguration at bind time prevents users from silently creating an address that DoSes its own inbound ERC-20 transfers.
Bind is one-shot. setAutoEncrypt reverts with AutoEncryptEpkAlreadySet if autoEncryptEpk[msg.sender] is non-zero — the bound EPK cannot be changed or cleared. To flip the enabled flag after the initial bind, use toggleAutoEncrypt(enabled), which reverts with AutoEncryptEpkNotSet if no EPK has been bound. Both entry points have WithAuth variants for the bundler path; the toggleAutoEncryptWithAuth digest folds the stored autoEncryptEpk[msg.sender] into the typed data (not a caller-supplied value), so the SDK reads it from chain when constructing the permit.
The bind-only design closes a takeover window: if an EVM address could rebind to a different EPK, an attacker who briefly compromises the controller (or the EVM account) could redirect future inbound transfers to an EPK they control. Pinning the binding for the lifetime of the address means inbound routing matches what was set at registration time.
Pending Balance
Every balance-decreasing operation (encryptedTransfer, encryptedToPublicTransfer) includes the sender's current encryptedBalances value as a public input to the ZK proof. If a third party sends a transfer to a user between proof generation and proof submission, the stored balance changes and the proof becomes invalid. This is a griefing vector: an attacker can frontrun any outgoing operation with a dust transfer, invalidating the proof at near-zero cost.
The pending balance mechanism lets users control when incoming funds are merged into their main balance. When pendingActive[EPK] is true, incoming transfers and autoEncrypt amounts are added to pendingBalances instead of encryptedBalances. The user's encryptedBalances then changes only as a result of their own operations.
Activation: activatePending(epk, nonce, deadline, signature)
Sets pendingActive[EPK] = true. The function:
- Reverts if
pendingActive[EPK]is already true. - Validates the
deadlinehas not passed. - Consumes the
noncevia the bitmap (authNonces) — reverts if already used. - Verifies an EIP-712 signature (
ActivatePendingAuth) from the EPK's registered controller. - On success, sets
pendingActive[EPK] = true.
No ZK proof is required — the controller signature is sufficient authorization. This eliminates ~200k+ gas for on-chain proof verification and the latency of proof generation. The EIP-712 type is ActivatePendingAuth(bytes32 epk, uint256 nonce, uint256 deadline).
Deniability. activatePending does not check msg.sender. The controller address is expected to be a fresh, previously unused key with zero balance — it never appears as msg.sender or tx.origin (all operations go through SharedAccount). An observer sees a transaction from SharedAccount calling activatePending with a signature, but cannot link the controller to any real-world identity.
There is no standalone deactivatePending or clearPending function — both operations are folded into the parameters of balance-decreasing operations.
Operation parameters: clearPending and deactivatePending
Both encryptedTransfer and encryptedToPublicTransfer take two required boolean parameters:
clearPending— when true, after the main operation the contract mergespendingBalances[EPK]intoencryptedBalances[EPK]via on-chain EC point addition and resetspendingBalances[EPK]to zero. Most callers should passtrue.deactivatePending— when true, after the main operation the contract setspendingActive[EPK] = false. Most callers should passtrue.
Both are no-ops when pendingActive[EPK] is already false. Additionally, clearPending short-circuits when the pending balance is the zero ciphertext, avoiding unnecessary EC additions.
In both authorization modes (Controller Signature and msg.sender == controller), the proof is bound to the controller via the aux_commitment public input.
aux_commitment = uint256(uint160(controllerAddress))
The 160-bit controller address always fits in BN254's 254-bit scalar field, so the commitment is the address as a uint256 — no hashing or reduction. The contract recomputes aux_commitment from the controller resolved on-chain and uses it as the last public input to the verifier. The circuit exposes _aux_commitment as a public input but does not constrain it — the binding is enforced by the contract matching the recomputed value against what the prover committed to.
clearPending / deactivatePending, the recipient address (for encryptedToPublicTransfer), and other operation parameters are not bound to the proof. They are bound to the call by the controller's EIP-712 signature (paramsHash) in the auth path, and by msg.sender == controller in the no-auth path.
Usage patterns
Default (no protection). Never call activatePending. Identical to pre-griefing-prevention behavior. The griefing vector remains, but no workflow changes are required.
Reactive. Leave pendingActive = false by default. If a transfer or reveal gets griefed, call activatePending(epk, nonce, deadline, signature) once to freeze incoming funds, then retry the operation with clearPending=true, deactivatePending=true. The retry's flags auto-reset state on success. This is the expected common case — most users will never be griefed, and those who are pay the activation cost only once per incident. Since activatePending uses a controller signature (not a ZK proof), activation is fast and cheap (~10k gas).
Always-on. Call activatePending once. On every subsequent outgoing operation, pass deactivatePending=false. The pending balance is cleared on each operation but protection stays active. Incoming transfers are never spendable until the next outgoing operation. Suited for high-value accounts that want permanent protection.
Batch. When submitting multiple outgoing operations in sequence, pass clearPending=false on all but the last to amortize the merge — pending funds are absorbed only at the end of the batch.
ZK circuit changes
All three EB circuits use a public input named _aux_commitment to bind contract-only data to the proof. The field name is deliberately reused, but its meaning differs per circuit — the circuit never constrains the value, so the contract is free to encode whatever replay-protection payload makes sense for that operation. The replay-protection trick is the same in every case: any change to the contract-supplied value at verification time produces different public inputs and any cached proof fails to verify.
Per-circuit usage:
| Circuit | Used by | What _aux_commitment carries |
|---|---|---|
eb-epk-ownership | Hub.registerEpk | keccak256(abi.encode(controller, block.chainid, hubAddress)) — binds the proof to the controller, chain, and Hub deployment (prevents controller substitution, cross-chain EPK squatting, and cross-deployment replay) |
eb-encrypted-to-public | encryptedToPublicTransfer (both auth modes) | uint256(uint160(controllerAddress)) — binds the proof to the authorizing controller |
eb-transfer | encryptedTransfer (both auth modes) | uint256(uint160(controllerAddress)) — binds the proof to the authorizing controller |
The eb-epk-ownership circuit verifies ESK · G == EPK and exposes (epk.x, epk.y, _aux_commitment) as public inputs. It is used only by registerEpk (not by activatePending, which uses a controller signature instead of a ZK proof).
The eb-transfer and eb-encrypted-to-public circuits are unchanged from the pending-balance feature — they include _aux_commitment as an unconstrained public input. Only the value the contract passes into _aux_commitment has changed.