Skip to main content

Overview

ERC20Confidential combines a fully transparent ERC-20 with a confidential layer. That dual nature is powerful but introduces its own footguns: value straddles a public/private boundary, confidential mutations never revert, and encrypted handles change on every update. This page collects the practices that matter most for dual-mode tokens. The confidential-transfer, operator, and callback guidance from FHERC20 Best Practices applies here too.

Mind the Privacy Boundary

The single most important thing to understand about dual-mode: the public layer reveals everything, the confidential layer reveals nothing. Value only becomes private once it crosses into the confidential layer via shield.

Shielding and unshielding are public events

shield() emits TokensShielded(account, amount) with a plaintext amount, and moves a visible public balance into CONFIDENTIAL_POOL. Anyone can see that you shielded and how much.
Privacy begins after shielding — confidential transfers between holders are opaque. But the act of entering and exiting the pool is on the public record.Recommendation: if the entry/exit amount itself is sensitive, shield in standardized denominations, or shield more than you need and keep a confidential buffer, so the public shield amount doesn’t map 1:1 to a later private action.

The pool balance is a public aggregate

CONFIDENTIAL_POOL’s public balance equals the total backing of all confidential balances. confidentialTotalSupply() derives directly from it. This is fine — it’s an aggregate — but remember it’s fully public and cannot be hidden.

Design where users cross the boundary

A DEX that only ever sees the public side of your token learns nothing about confidential holdings. Route sensitive flows through confidential transfers, and only unshield when the recipient genuinely needs public liquidity.

Backing and Minting

Every confidential unit must stay backed

Confidential balances are only redeemable because CONFIDENTIAL_POOL holds the corresponding public tokens. There are two correct ways to create confidential balances:
Never credit a confidential balance without a matching increase in the pool’s public balance. If you write a custom mint that calls _confidentialUpdate(address(0), to, ...) without minting the backing into CONFIDENTIAL_POOL, unshield claims will drain the pool and eventually fail.

Guard your mint functions

ERC20Confidential is abstract and ships no minting policy. Add access control:

Handle Zero-Replacement Correctly

Confidential transfers, unshields, and burns never revert on insufficient balance — they move encrypted zero instead. This preserves privacy but breaks any code that assumes the requested amount moved.
The same applies to unshield — unshielding more than your confidential balance creates a claim for zero. Verify balances before unshielding when the exact amount matters.

Access Control on Encrypted Handles

Handles change on every update — re-grant access

Each confidential mutation stores a fresh euint64 handle. FHE.allow grants on an old handle don’t carry over. _confidentialUpdate re-grants allowThis + allow(..., holder) automatically for balances and transferred amounts, but if your contract caches or re-exposes a handle, re-grant after every change.

A handle of zero means 'never held', not 'zero balance'

confidentialBalanceOf returns 0 for an account that has never had a confidential balance. Always check before decrypting:

confidentialTotalSupply is not decryptable

confidentialTotalSupply() returns a synthetic handle wrapping a plaintext number — it is not a registered ciphertext. Never attempt to decrypt it or feed it into FHE operations; read it as informational only.

Operators

The operator guidance is identical to FHERC20 — operators get full, all-or-nothing access to a holder’s confidential balance until expiry.

Prefer short windows

Grant setOperator(spender, block.timestamp + 10 minutes) for a single interaction rather than type(uint48).max.

Revoke by expiring now

setOperator(spender, uint48(block.timestamp)) revokes immediately — no separate revoke function needed.

Use seconds, not milliseconds

until is a Unix timestamp in seconds. Use Math.floor(Date.now()/1000), never Date.now().

Trust is total

An operator can move the holder’s entire confidential balance. Only authorize trusted contracts/addresses.
See Confidential Operations for signatures and FHERC20 Best Practices for the full operator playbook.

Choosing the Contract Variant

Constructor vs Upgradeable

  • ERC20Confidential — immutable, constructor-based. Simpler, cheaper to deploy, no proxy. Use it when you don’t need upgradeability.
  • ERC20ConfidentialUpgradeable — proxy-safe. No constructor; call __ERC20Confidential_init(name, symbol, decimals) from your initializer. State lives in an ERC-7201 namespaced struct to avoid storage-layout collisions across upgrades.
For the upgradeable variant, initialize once behind a proxy and follow OpenZeppelin’s upgrade-safety rules (no constructors for logic contracts, no immutables for versioned state, protect the initializer).

Pick your decimals deliberately

The public token can be 18 decimals to match mainstream ERC-20s, but the confidential layer caps at 6. That means sub-rate() dust (e.g. sub-10^12 for an 18-decimal token) can never be shielded. If precise small amounts matter for your use case, consider a lower public decimals so rate() is smaller (or 1).