Skip to main content

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 amount is subtracted from the user's public balance,
  • The amount is trivially encrypted to the destination_bpk with randomizing element r = 0, giving R = O (the identity point) and C = amount · G.
  • The encrypted amount is added to balance[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:

  • amount
  • c_A_old -- the initial encrypted balance of user A
  • c_A_new -- the new encrypted balance of user A
  • c_amount -- the encrypted value of amount The following constraints are verified in the circuit:
  • There is a value balance_old that is the decryption of c_A_old and balance_old >= amount
  • balance_old = balance_new + amount
  • c_A_new is the encryption of balance_new under key A
  • c_amount is the encryption of amount under key B

Part of the transaction payload is the proof for the above statement and the contract updates balances accordingly:

  • balance[A] = c_A_new
  • balance[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 user A
  • c_A_new -- the new encrypted balance of user A
  • amount -- 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_old that is the decryption of c_A_old and balance_old >= amount
  • balance_old = balance_new + amount
  • c_A_new is the encryption of balance_new under key A

Part of the transaction payload is the proof, the amount, and the recipient address. The contract updates state accordingly:

  • balance[A] = c_A_new
  • amount public tokens are minted to recipient

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.