Abstract
This EIP defines a minimal interface for fungible tokens with encrypted balances. Token balances are stored as Fully Homomorphic Encryption (FHE) ciphertexts. Transfers are verified via zero-knowledge proofs without revealing amounts, sender balances, or recipient balances to any on-chain observer.
Unlike ERC-20 where balanceOf returns a plaintext uint256, this interface returns an opaque ciphertext that only the balance owner can decrypt. Arithmetic operations on balances (credit, debit, comparison) are performed homomorphically by the execution environment without any party observing plaintext values.
This interface is backwards-compatible with ERC-20 for public aggregate values (totalSupply, name, symbol, decimals) while introducing encrypted counterparts for all privacy-sensitive operations.
Motivation
ERC-20 tokens expose all balances and transfer amounts publicly. This creates several critical problems:
-
Portfolio exposure: Anyone can see how much of any token an address holds, enabling targeted attacks, social engineering, and front-running.
-
Transfer surveillance: Every transfer amount is public, enabling transaction graph analysis, de-anonymization, and MEV extraction.
-
Governance coercion: In DAO voting with token-weighted governance, visible balances enable vote buying, whale tracking, and political coercion.
-
Regulatory conflict: Financial privacy regulations (GDPR Article 17 "right to be forgotten", MiCA, HIPAA for health tokens) conflict with permanent public ledger storage.
-
MEV extraction: Visible pending transactions enable sandwich attacks, front-running, and other MEV strategies that extract value from users.
Existing privacy solutions (mixers, L2 privacy chains) operate outside the ERC ecosystem, breaking composability with DeFi protocols, wallets, and indexers. This EIP brings privacy into the token interface itself, allowing any compliant wallet or protocol to interact with encrypted tokens through a well-defined interface.
Why not just add a privacy layer on top?
Privacy layers (Tornado Cash, Aztec Connect) are external wrappers. They break composability: a "shielded" token cannot be used in Uniswap, Aave, or Compound without unshielding first — defeating the purpose. This EIP makes privacy native to the token interface. A compliant lending protocol can accept encrypted collateral, compute liquidation thresholds homomorphically, and never see the actual collateral amount.
Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Definitions
- Ciphertext: An FHE-encrypted value. Represented on-chain as
bytes. Only the owner of the corresponding decryption key can recover the plaintext. - Proof: A zero-knowledge proof (SNARK/STARK) attesting to the validity of an operation without revealing the operands.
- Blind Transfer: A transfer where the amount is encrypted. The proof guarantees: (a) sender has sufficient balance, (b) amount is non-negative, (c) no overflow occurs, (d) balances are correctly updated.
- Selective Disclosure: The ability for a balance owner to prove a property of their balance (e.g., "≥ 1000") without revealing the exact value.
- Encryption Scheme: The FHE scheme used. This EIP is scheme-agnostic but RECOMMENDS TFHE (Torus FHE) for its boolean circuit efficiency.
Interface
Every compliant contract MUST implement the following interface:
// SPDX-License-Identifier: CC0-1.0
pragma solidity >=0.8.0;
/// @title ERC-8227 Encrypted Token Interface
/// @notice An interface for tokens with FHE-encrypted balances
/// and zero-knowledge transfer verification.
interface IERC8227 {
// ──────────────────────────────────────────────
// Custom Errors
// ──────────────────────────────────────────────
error ProofVerificationFailed(bytes32 proofHash);
error NonceAlreadyUsed(uint256 nonce);
error InvalidCiphertext();
error InsufficientEncryptedBalance();
error KeyVersionMismatch(uint256 expected, uint256 provided);
// ──────────────────────────────────────────────
// Events
// ──────────────────────────────────────────────
/// @notice Emitted on every blind transfer.
/// @dev The `amount` field is intentionally absent.
/// The `proofHash` allows anyone to verify the transfer
/// was valid without learning the amount.
/// @param from The sender address.
/// @param to The recipient address.
/// @param proofHash Keccak256 hash of the ZK proof.
/// @param keyVersion The FHE key version used for this transfer.
event BlindTransfer(
address indexed from,
address indexed to,
bytes32 proofHash,
uint256 keyVersion
);
/// @notice Emitted when an encrypted approval is set.
/// @param owner The token owner.
/// @param spender The approved spender.
event BlindApproval(
address indexed owner,
address indexed spender
);
/// @notice Emitted when tokens are minted (encrypted).
/// @param to The recipient.
/// @param proofHash Proof that mint is authorized and valid.
event BlindMint(
address indexed to,
bytes32 indexed proofHash
);
/// @notice Emitted when tokens are burned (encrypted).
/// @param from The address burning tokens.
/// @param proofHash Proof that burn is valid.
event BlindBurn(
address indexed from,
bytes32 indexed proofHash
);
/// @notice Emitted when the FHE key is rotated.
/// @param oldVersion The previous key version.
/// @param newVersion The new key version.
event KeyRotated(
uint256 indexed oldVersion,
uint256 indexed newVersion
);
// ──────────────────────────────────────────────
// ERC-20 Compatible (Public Metadata)
// ──────────────────────────────────────────────
/// @notice Returns the token name.
function name() external view returns (string memory);
/// @notice Returns the token symbol.
function symbol() external view returns (string memory);
/// @notice Returns the number of decimals.
function decimals() external view returns (uint8);
/// @notice Returns the total supply.
/// @dev If `isTotalSupplyConfidential()` returns `false`, this MUST
/// return the actual total supply. If it returns `true`, this
/// MUST return 0 and the contract MUST implement
/// `IERC8227_ConfidentialSupply`.
function totalSupply() external view returns (uint256);
/// @notice Returns whether the total supply is confidential.
/// @dev This value is fixed at deployment and MUST NOT change over the
/// lifetime of the contract. It exists so that a consumer can tell
/// a hidden supply from a genuine supply of 0 without probing
/// [ERC-165](./eip-165.md) for an extension.
/// @return `true` if the total supply is hidden, `false` if it is public.
function isTotalSupplyConfidential() external view returns (bool);
// ──────────────────────────────────────────────
// Encrypted Balance Queries
// ──────────────────────────────────────────────
/// @notice Returns the encrypted balance of `account`.
/// @dev The returned bytes are an FHE ciphertext. Only `account`
/// (or a party holding the decryption key) can decrypt it.
/// External observers see opaque bytes.
/// @param account The address to query.
/// @return The FHE ciphertext of the balance.
function encryptedBalanceOf(address account)
external
view
returns (bytes memory);
/// @notice Returns the FHE scheme identifier used by this token.
/// @dev Allows wallets and protocols to select the correct
/// decryption/re-encryption logic.
/// Example return values: "TFHE-v0.3", "OpenFHE-BFV", "CONCRETE-v1"
/// @return A string identifying the FHE scheme and version.
function encryptionScheme() external view returns (string memory);
/// @notice Returns the public encryption key of the token contract.
/// @dev Users encrypt their inputs with this key before submitting.
/// The contract (or its FHE co-processor) holds the
/// corresponding secret key.
/// @return The public key bytes.
function publicKey() external view returns (bytes memory);
/// @notice Returns the current FHE key version.
/// @dev Incremented on each key rotation. Proofs MUST reference
/// the active key version to be accepted.
/// @return The current key version number.
function keyVersion() external view returns (uint256);
/// @notice Returns whether a given key version is currently active.
/// @param version The key version to query.
/// @return True if the version is the current active key.
function isKeyActive(uint256 version) external view returns (bool);
// ──────────────────────────────────────────────
// Blind Transfers
// ──────────────────────────────────────────────
/// @notice Transfer an encrypted amount to `to`.
/// @dev The `encryptedAmount` is an FHE ciphertext of the amount.
/// The `proof` is a ZK proof attesting:
/// 1. sender balance >= amount (non-negative remainder)
/// 2. amount >= 0
/// 3. new sender balance = old sender balance - amount
/// 4. new recipient balance = old recipient balance + amount
/// The contract MUST verify the proof on-chain before applying
/// the homomorphic balance update.
/// @param to The recipient address.
/// @param encryptedAmount FHE ciphertext of the transfer amount.
/// @param proof ZK proof of transfer validity.
/// @return True if the transfer was verified and applied.
function blindTransfer(
address to,
bytes calldata encryptedAmount,
bytes calldata proof
) external returns (bool);
/// @notice Transfer from `from` to `to` using an encrypted approval.
/// @dev Requires prior `blindApprove` from `from` to `msg.sender`.
/// The proof MUST additionally attest that the transfer
/// does not exceed the encrypted allowance.
/// @param from The sender address.
/// @param to The recipient address.
/// @param encryptedAmount FHE ciphertext of the transfer amount.
/// @param proof ZK proof including allowance verification.
/// @return True if the transfer was verified and applied.
function blindTransferFrom(
address from,
address to,
bytes calldata encryptedAmount,
bytes calldata proof
) external returns (bool);
// ──────────────────────────────────────────────
// Encrypted Approvals
// ──────────────────────────────────────────────
/// @notice Approve `spender` to transfer up to `encryptedAmount`.
/// @dev The allowance is stored as an FHE ciphertext.
/// Subsequent `blindTransferFrom` calls deduct from this
/// allowance homomorphically.
/// The proof MUST attest that the ciphertext is well-formed
/// and non-negative. It MUST NOT require a balance check at
/// approval time — balance sufficiency is checked at transfer time.
/// @param spender The approved spender.
/// @param encryptedAmount FHE ciphertext of the max allowance.
/// @param proof Proof that the ciphertext is valid and non-negative.
/// @return True if the approval was set.
function blindApprove(
address spender,
bytes calldata encryptedAmount,
bytes calldata proof
) external returns (bool);
/// @notice Returns the encrypted allowance of `spender` for `owner`.
/// @param owner The token owner.
/// @param spender The approved spender.
/// @return The FHE ciphertext of the remaining allowance.
function encryptedAllowance(address owner, address spender)
external
view
returns (bytes memory);
// ──────────────────────────────────────────────
// Selective Disclosure (OPTIONAL)
// ──────────────────────────────────────────────
/// @notice Verify a property proof about an account's balance.
/// @dev Allows an account to prove a predicate about their balance
/// (e.g., "my balance >= 1000") without revealing the balance.
/// The `predicate` encodes the condition type and threshold.
/// The `proof` is a ZK proof binding the predicate to the
/// account's actual encrypted balance.
/// The proof MUST be generated by `account` or an authorized
/// delegate. Third-party probing is not possible — proof
/// generation requires knowledge of the plaintext balance.
/// @param account The address whose balance is being proven.
/// @param predicate Encoded predicate (condition + threshold).
/// @param proof ZK proof of the predicate's truth.
/// @return True if the predicate is verified.
function verifyBalancePredicate(
address account,
bytes calldata predicate,
bytes calldata proof
) external view returns (bool);
// ──────────────────────────────────────────────
// Mint / Burn (OPTIONAL)
// ──────────────────────────────────────────────
/// @notice Mint encrypted tokens to `to`.
/// @dev Only callable by authorized minter.
/// The `encryptedAmount` is added homomorphically to
/// the recipient's encrypted balance.
/// @param to The recipient.
/// @param encryptedAmount FHE ciphertext of the mint amount.
/// @param proof Proof of authorized minting.
function blindMint(
address to,
bytes calldata encryptedAmount,
bytes calldata proof
) external;
/// @notice Burn encrypted tokens from `msg.sender`.
/// @param encryptedAmount FHE ciphertext of the burn amount.
/// @param proof Proof that balance >= amount.
function blindBurn(
bytes calldata encryptedAmount,
bytes calldata proof
) external;
}Interface Detection
Compliant contracts MUST implement ERC-165 and return true for the interface ID of IERC8227.
bytes4 constant IERC8227_ID = type(IERC8227).interfaceId;Optional Extensions
Implementations MAY additionally implement one or more of the following extension interfaces:
IERC8227_Shielded — Bridge between the public pool (compatible with ERC-20) and the encrypted pool:
interface IERC8227_Shielded is IERC8227 {
event Shielded(address indexed account, uint256 amount);
event Unshielded(address indexed account, uint256 amount);
/// @notice Move tokens from public ERC-20 balance to encrypted balance.
function shield(uint256 amount) external;
/// @notice Move tokens from encrypted balance to public ERC-20 balance.
/// @param proof Proof of sufficient encrypted balance.
function unshield(uint256 amount, bytes calldata proof) external;
}IERC8227_ConfidentialSupply — For tokens that also hide total supply. A contract whose isTotalSupplyConfidential() returns true MUST implement this extension; a contract whose isTotalSupplyConfidential() returns false MUST NOT:
interface IERC8227_ConfidentialSupply is IERC8227 {
/// @dev MUST revert if `isTotalSupplyConfidential()` returns `false`.
function totalSupplyEncrypted() external view returns (bytes memory);
}IERC8227_Fees — FHE computation fee interface:
interface IERC8227_Fees is IERC8227 {
event FeeCollected(address indexed payer, address indexed recipient, uint256 amount);
function feeConfig() external view returns (
address feeRecipient,
uint256 feeBasisPoints,
uint256 pGasPerGate
);
function estimateBlindTransferCost() external view returns (uint256 pGas);
}IERC8227_Batch — Aggregated batch blind transfers:
interface IERC8227_Batch is IERC8227 {
function blindBatchTransfer(
address[] calldata recipients,
bytes[] calldata encryptedAmounts,
bytes calldata batchProof
) external returns (bool);
}Predicate Encoding
The predicate parameter in verifyBalancePredicate MUST use the following encoding:
// Predicate = abi.encode(PredicateType, threshold)
enum PredicateType {
GTE, // balance >= threshold
LTE, // balance <= threshold
EQ, // balance == threshold
NEQ, // balance != threshold
RANGE // lowerBound <= balance <= upperBound
}
// For RANGE type:
// predicate = abi.encode(PredicateType.RANGE, lowerBound, upperBound)Proof Format
This EIP does not mandate a specific ZK proof system. Implementations MAY use Groth16, Halo2, Nova, Plonk, or any system that provides:
- Succinctness: Proof size SHOULD be ≤ 1 KB.
- Non-interactivity: Proof generation requires no interaction with the verifier.
- Soundness: Negligible probability of false proof acceptance.
The proof MUST encode the following public inputs (available to the verifier):
publicInputs = {
from: address, // sender
to: address, // recipient
encryptedAmountHash: bytes32, // hash of the encrypted amount
oldBalanceHashFrom: bytes32, // hash of sender's pre-transfer encrypted balance
newBalanceHashFrom: bytes32, // hash of sender's post-transfer encrypted balance
oldBalanceHashTo: bytes32, // hash of recipient's pre-transfer encrypted balance
newBalanceHashTo: bytes32, // hash of recipient's post-transfer encrypted balance
nonce: uint256 // replay protection
}Re-encryption
When a user needs to read their own balance, they SHOULD NOT request decryption on-chain. Instead, the FHE co-processor or execution environment MUST support re-encryption: transforming the ciphertext from the contract's public key to the user's personal public key, off-chain, so that only the user can decrypt the result on their device.
Rationale
Why encrypted balances instead of commitments?
Commitment schemes (Pedersen, Poseidon) hide values but don't support computation. You can't add two Pedersen commitments and get a valid commitment to the sum without the sender's help. FHE ciphertexts support homomorphic addition and comparison natively, enabling the contract to update balances without any party revealing plaintext values.
Why separate events without amounts?
ERC-20's Transfer(from, to, amount) leaks the amount. BlindTransfer(from, to, proofHash, keyVersion) preserves the minimum information needed for indexing (who transferred to whom) without revealing how much. The proofHash enables off-chain auditors (with appropriate keys) to verify transfer validity.
Why is totalSupply visibility a deployment-time flag?
Supply visibility is a property of the deployment, not of the standard. Public-supply tokens (stablecoins, governance tokens, listed assets) need a readable totalSupply for oracles, exchange listings and circulating-supply audits. Confidential-supply tokens (private payroll, internal corporate units, redeemable loyalty points) leak meaningful business signal through the supply alone, even when every balance is encrypted. Neither model can be made the default without breaking the other, so the choice is made once, at deployment, and published.
An earlier draft signalled confidentiality by returning 0 from totalSupply(). That is ambiguous: 0 is also the correct answer for a token that has not minted yet, so a consumer could not distinguish a hidden supply from an empty one. isTotalSupplyConfidential() makes the distinction explicit and cheap to read.
The flag is fixed rather than mutable because flipping it would retroactively change the meaning of every historical totalSupply() read, and a token that can reveal its supply later does not offer supply confidentiality at all.
Why verifyBalancePredicate instead of balanceOf?
In DeFi, you rarely need the exact balance. You need to know "does this user have enough collateral?" (predicate: balance >= threshold). Selective disclosure via predicate proofs gives protocols exactly the information they need — and nothing more.
Why is balance check absent from blindApprove?
ERC-20's approve does not check that the owner currently holds the approved amount — the check occurs at transferFrom time. This EIP follows the same pattern: blindApprove verifies only that the ciphertext is well-formed and non-negative. Balance sufficiency is proven at blindTransferFrom time via the ZK proof.
Why a key version field?
FHE key rotation (e.g., due to key compromise or scheme upgrade) invalidates all ciphertexts encrypted under the old key. The keyVersion field on events and the keyVersion() / isKeyActive() query functions allow clients and indexers to detect key rotations and re-encrypt balances under the new key.
Backwards Compatibility with ERC-20
The public metadata functions (name, symbol, decimals, totalSupply) are identical to ERC-20. This allows block explorers and basic wallet UIs to display token identity. The encrypted functions (blindTransfer, encryptedBalanceOf) are additive — they don't override ERC-20 functions but extend them.
A token MAY implement both ERC-20 and this interface simultaneously, where ERC-20 functions operate on a separate "public balance" pool and encrypted functions operate on the "shielded balance" pool. The optional IERC8227_Shielded extension provides shield/unshield bridge functions to move tokens between pools.
Backwards Compatibility
This interface is fully backwards-compatible with ERC-20 for metadata queries. Tokens implementing this interface alongside ERC-20 can participate in both the public and encrypted ecosystems.
Wallets that do not support this interface will display the token name and symbol but will show a balance of 0 (since balanceOf returns the public pool balance, which may be empty if all tokens are shielded).
This EIP REQUIRES ERC-165 for interface detection, allowing protocols to query whether a token supports encrypted operations before attempting them.
Reference Implementation
The normative interface is published at Valisthea/styx-erc-encrypted-token, in contracts/interfaces/IERC8227.sol.
A working implementation is in development and is not yet available. The outstanding work is the binding between an FHE co-processor and the on-chain proof verification loop. This section will be updated when that implementation exists and is tested.
Security Considerations
FHE Ciphertext Malleability
FHE ciphertexts are malleable by design (homomorphic operations modify them). The ZK proof binds the ciphertext to a specific operation, preventing unauthorized modifications. Implementations MUST verify proofs before applying any ciphertext to state.
Proof Replay
Each proof includes a nonce. Implementations MUST maintain a nonce registry and reject proofs with previously-used nonces. The nonce SHOULD be derived from the sender's transaction count to prevent cross-chain replay.
Side-Channel Leakage
While balances and amounts are encrypted, the following metadata remains public:
- Transaction timing: When transfers occur.
- Gas consumption: Different proof sizes may leak information about amount magnitude.
- Access patterns: Who transacts with whom.
Implementations SHOULD use constant-size proofs and constant gas consumption for all transfer amounts. Uniform circuit compilation (all operations compile to the same gate count) achieves this property.
Quantum Threat
Lattice-based FHE schemes (TFHE, BFV, BGV) are believed to be quantum-resistant. However, the ZK proof system may not be. Implementations SHOULD use quantum-resistant proof systems or plan migration paths.
Key Management
The contract's FHE secret key is the most sensitive asset. If compromised, all balances are exposed. Implementations MUST use threshold key management (Shamir Secret Sharing with ≥ 5 custodians) or hardware security modules (HSMs).
Share Inflation (ERC-4626 Analog)
For vault-like implementations that wrap encrypted tokens, the share inflation attack analog exists: an attacker could manipulate the encrypted share/asset ratio. Implementations MUST initialize vaults with a minimum deposit or use virtual shares to prevent first-depositor attacks on encrypted vaults.
Copyright
Copyright and related rights waived via CC0.
