Encrypted Balances (L2)
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.
Below we present the details of how the system is designed.
User Accounts
Each user has an EVM account addr, and to enter the system they generate a fresh Grumpkin Curve key pair (BSK, BPK) (balance secret key, and, balance public key) and registers their BPK in the EB contract on-chain by calling the registerBPK(...) method. This way their EVM address addr is now bounded to their BPK.
Balances
Suppose a user has registered their BPK key in the EB contract. Now, for each supported token (assume for the sake of this description there is just one), a balance map holds a value balance[BPK] being the El-Gamal encryption of a value amount: u64 (the current balance of the user). More specifically, the ciphertext is a pair of curve points (R, C) where:
R = r · G
C = amount · G + r · BPK
Here r is a random scalar (the randomizing element) and G is the Grumpkin generator. The storage uses four field elements: (R_x, R_y, C_x, C_y).
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 expontential 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
Relayers
The system is designed in a way so that a user without access to gas tokens of the underlying chain should be able to still peform each operation. This boils down to details of the contract interface and allowing offchain signed messages to authorize a user. In the description below we often skip over these details for clarity.
Shield (Public to Encrypted)
A user specifies a public amount of ERC20 tokens and a destination destination_bpk. What happens is:
- The
amountis subtracted from the user's public balance, - The
amountis trivially encrypted to thedestination_bpkwith randomizing elementr = 0, givingR = O(the identity point) andC = amount · G. - The encrypted
amountis added tobalance[destination_bpk]using the additive homomorphism property of ElGamal.
Transfer (Encrypted to Encrypted)
Suppose user A wants to send amount of tokens to user B. A zk-circuit is run with the following public inputs:
amountc_A_old-- the initial encrypted balance of userAc_A_new-- the new encrypted balance of userAc_amount-- the encrypted value ofamountThe following constraints are verified in the circuit:- 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
Part of the transaction payload is the proof for the above statement and the contract updates balances accordingly:
balance[A] = c_A_newbalance[B] = balance[B] + c_amount
There is a tradeoff on how much gas the call costs, vs how expensive is the proof computation client-side. For instance, one could move the computation of c_amount from the circuit to the contract. This will make the proof faster to compute at the cost of increased gas expenditure.
The current implementation of transfer has a vulnerability: c_A_old is a public input, and the contract checks it against the stored balance before accepting the proof. This means any change to the sender's stored balance — including an incoming transfer from another user — invalidates the proof. An attacker can grief a user by sending them a small transfer while they have a proof in flight. See flaw #5. TODO: decide on a solution to this.
Unshield (Encrypted to Public)
Suppose user A wants to withdraw amount tokens back to a public EVM address recipient. A zk-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 unshield amount (public, unlike in transfer)
The following constraints are verified in the circuit:
- The prover knows the secret key corresponding to
BPK_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
Part of the transaction payload is the proof, the amount, and the recipient address. The contract updates state accordingly:
balance[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 same concurrency drawback applies: c_A_old is checked against storage and can be invalidated by incoming transfers.
The recipient must be bound by the proof — either as a public input in the circuit, or by verifying a signature over a tuple that includes it. Otherwise an attacker can frontrun the transaction, reuse the proof with a different recipient, and steal the minted tokens. The reference implementation does not bind it — see flaw #10.
Autoshield
A user who has registered their BPK can call setAutoshield(true) on the contract. This sets a per-address boolean flag autoshield[addr] = true 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[BPK] — the same operation as a shield with r = 0.
This is transparent to the sender — they call a normal ERC-20 transfer and the interception happens inside the contract. No ZK proof is involved; the encryption is done on-chain.
Compliance (UKRC)
For each private transfer, the circuit also encrypts the transfer amount under a compliance key so that a designated quorum can later decrypt it for regulatory purposes. The sender and recipient BPKs are already visible as public inputs, so only the amount needs to be encrypted.
The compliance key is derived per-transfer. A transfer_id is computed deterministically from the ciphertext components of the transfer:
transfer_id = Poseidon2Hash(old_balance_C_x, new_balance_C_x, transfer_C_x)
A per-transfer public key is then derived:
h = Poseidon2Hash(DOMAIN_UKRC, transfer_id)
P_transfer = quorumPK + h · G
The circuit encrypts the amount under P_transfer using ElGamal:
R_ukrc = r_ukrc · G
C_ukrc = amount · G + r_ukrc · P_transfer
The contract verifies that the quorumPK in the proof matches the one stored on-chain, then emits a PrivateTransfer event containing (senderBpkHash, recipientBpkHash, transferId, R_ukrc, C_ukrc). To decrypt, the quorum computes sk' = quorumSK + h and recovers amount · G = C_ukrc - sk' · R_ukrc, then solves the discrete log.
There are several known issues with this design. The per-transfer key derivation adds complexity without providing forward secrecy or selective disclosure, since transfer_id is computed from public inputs and the quorum can derive every per-transfer key — see flaw #1. The circuit does not constrain the UKRC-encrypted amount to equal the actual transfer amount, so a malicious prover can encrypt a false value — see flaw #11. For further details see Compliance.