Skip to main content

Concealed Balances

The Concealed 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 register their BPK in the contract on-chain by calling the registerBPK(...) method. This way their EVM address addr is bound to their BPK. The registration is immutable -- once set, the BPK for an address cannot be changed.

Balances

Suppose a user has registered their BPK key in the 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 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.

Conceal (Public to Concealed)

TODO

Transfer (Concealed to Concealed)

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.

Reveal (Concealed 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 reveal amount (public, unlike in transfer)
  • recipient -- the destination EVM address

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

The recipient address is bound as a public input to the circuit. This ensures the proof cannot be front-run and redirected in the mempool — an attacker who copies the proof and submits it with a different recipient will fail verification.

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.

Autoconceal

A user who has registered their BPK can call setAutoconceal(true) on the contract. This sets a per-address boolean flag autoconceal[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 conceal 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.