Introduction – Confidentiality with FHEVM

Zama’s FHEVM integrates Fully Homomorphic Encryption (FHE) into EVM‑compatible chains so smart contracts can compute on encrypted values (for example, euint64) while keeping function arguments and state confidential end-to-end. Off-chain coprocessors handle the heavy FHE math, while on‑chain contracts orchestrate state updates, access control, and (when needed) asynchronous decryption callbacks.

This guide uses an illustrative case study: a confidential Wrapped Ether token (cWETH) that calls a separate FeeHandler to compute transfer fees as well as an auction contract that uses cWETH for bidding. We will demonstrate multiple pitfalls using these examples. However, note that the code snippets are incomplete for brevity.

You will find a practical checklist at the end to review your confidential contracts before you have them audited.

How the Access Control List (ACL) Works

In FHEVM, every ciphertext is identified by a bytes32 handle and carries its own access control that is managed by a centralized ACL contract. A contract that produces or has access to a ciphertext handle can grant another address the ability to use it, either persistently with FHE.allow or only for the current transaction with FHE.allowTransient. When a function receives a handle, it can verify that the caller is actually authorized to act on that ciphertext using FHE.isSenderAllowed. These permissions determine who may perform encrypted operations over a value and are fundamental to preventing misuse or indirect disclosure. In practice, developers should default to granting only the minimum permission necessary for the operation at hand and keep authorization checks close to where the ciphertext is consumed.

Security Deep Dive 1 – Arithmetic and Access Control

Scenario: In the following code, the cWETH contract implements its internal transfer routine by passing the encrypted amount to a FeeHandler that returns the encrypted fee handle. To make the interaction work across calls, cWETH grants FeeHandler permission on the amount ciphertext using a persistent allowance, and the fee is then used to update balances while preserving confidentiality. Inside FeeHandler, the fee is computed as basis points over the amount, and the resulting encrypted fee is made available to the caller so it can be consumed by the token logic.

// cWETH.sol
import { FHE, euint64 } from "@fhevm/solidity/lib/FHE.sol";
import { ZamaEthereumConfig } from "@fhevm/solidity/config/ZamaConfig.sol";

contract cWETH is ZamaEthereumConfig {
    FeeHandler public feeHandler;

    function _transfer(address from, address to, euint64 amount)
        internal returns (euint64 transferred)
    {
        // Grants persistent permission on amount to the FeeHandler
        FHE.allow(amount, address(feeHandler));

        // External call to compute the fee on the encrypted amount
        euint64 fee = feeHandler.calculateFee(amount);

        // ... proceed with transfer logic using fee
    }
}

// FeeHandler.sol
import { FHE, euint64 } from "@fhevm/solidity/lib/FHE.sol";
import { ZamaEthereumConfig } from "@fhevm/solidity/config/ZamaConfig.sol";

contract FeeHandler is ZamaEthereumConfig {
    uint64 public constant FEE_BASIS_POINTS = 10; // 0.10%
    uint64 public constant FEE_DENOMINATOR  = 10_000;

    function calculateFee(euint64 amount) external returns (euint64) {
        // Computes fee as amount * bps / denominator under encryption
        euint64 feeNumerator = FHE.mul(
            amount, FEE_BASIS_POINTS
        );
        euint64 fee = FHE.div(feeNumerator, FEE_DENOMINATOR);

        // Makes the resulting ciphertext handle available to the caller
        FHE.allow(fee, msg.sender);
        return fee;
    }
}

Vulnerability A – Unchecked Arithmetic Overflow Leading to Under-Charging

Arithmetic on encrypted integers is intentionally unchecked to avoid leaking information through reverts, such that operations wrap on overflow and underflow. If a sufficiently large amount is provided, the intermediate multiplication during fee calculation can wrap to a small value and yield a negligible or zero fee.

Mitigation: Detect overflow under encryption using FHE comparisons and then use FHE.select to continue with a valid fallback value. This is an intentional silent failure pattern for confidentiality: the function proceeds without revealing the condition while enforcing a safe outcome. In this fee example, the selection should clamp to a maximum fee when overflow is detected, ensuring the transfer cannot under‑charge.

uint64 public constant MAX_SAFE_AMOUNT = type(uint64).max / FEE_BASIS_POINTS;

function calculateFee(euint64 amount) external returns (euint64) {
    ebool overflow = FHE.gt(amount, MAX_SAFE_AMOUNT);

    // cap amount at MAX_SAFE_AMOUNT to avoid overflow
    euint64 cappedAmount = FHE.select(
        overflow,
        FHE.asEuint64(MAX_SAFE_AMOUNT),
        amount
    );

    euint64 feeNumerator = FHE.mul(cappedAmount, FEE_BASIS_POINTS);
    // ...
}

Vulnerability B – Information Leakage via Missing Authorization and Over-Broad Permission

Granting the FeeHandler persistent access to amount lets it compute fees across calls, but without verifying that the caller is authorized on that ciphertext handle, an attacker can route the call through an intermediary contract. This attacker contract can invoke calculateFee(amount) with a copied handle, which the FeeHandler accepts, computes the fee, and (by design) grants access to msg.sender (the attacker contract). That contract now retains ACL rights on fee and can forward them (e.g., by granting access to the attacker’s address or making it publicly decryptable). Thus, the helper is turned into a disclosure oracle even though the fee itself did not carry permission to the attacker initially.

Mitigation: Verify the caller’s permission on the provided ciphertext handle before any computation, and scope allowances to one-shot interactions wherever possible. Keep persistent grants rare and tightly scoped so ciphertext handles do not propagate to untrusted contexts.

// in cWETH.sol
function _transfer(address from, address to, euint64 amount)
    internal returns (euint64 transferred)
{
    // Grants transient permission on amount to the FeeHandler
    FHE.allowTransient(amount, address(feeHandler));

    // ...
}

// in FeeHandler.sol
error AccessProhibited();

function calculateFee(euint64 amount) external returns (euint64) {
    require(FHE.isSenderAllowed(amount), AccessProhibited());

    // ...
}

Security Deep Dive 2 – Preventing Replay in Asynchronous Decryption Callbacks

Scenario: Some operations require the decrypted value of a ciphertext handle. This decryption is fulfilled asynchronously by an off-chain relayer. In the following code, the cWETH contract implements two functions (withdraw and withdrawCallback) to decrypt and unwrap cWETH to ETH, while the _withdrawRequests mapping is used as a link between both steps. This mapping is keyed by the user address requesting the withdrawal. This means that a two-step withdrawal has to be completed before another can be initiated.

In the first transaction calling withdraw, the requested amount is burnt and the burnt amount is tracked in the mapping. The contract also marks that burnt amount to be publicly decryptable, which is effectively calling the ACL.

In a second transaction, the relayer calls withdrawCallback, providing the withdrawer’s address, cleartext burnt amount, and a proof of correctness. The contract looks up the ciphertext handle from the mapping to see whether a withdrawal is pending and uses this value to verify the proof. The operation is completed by releasing the locked ETH to the recipient. Note that the amount is scaled by a _rate, due to fewer decimals in the confidential token to better accommodate the euint64 type for balances.

// cWETH.sol

contract cWETH is ZamaEthereumConfig {
    mapping(address to => euint64 amount) _withdrawRequests;

    function withdraw(
	      externalEuint64 encryptedAmount,
	      bytes calldata inputProof
    ) public {
        euint64 amount = FHE.fromExternal(encryptedAmount, inputProof);
        _withdraw(amount);
    }

    function _withdraw(euint64 amount) private {
        euint64 burntAmount = _update(msg.sender, address(0), amount);

        require(
            !FHE.isInitialized(_withdrawRequests[msg.sender]),
            WithdrawalAlreadyPending()
        );
        _withdrawRequests[msg.sender] = burntAmount;
        FHE.makePubliclyDecryptable(burntAmount);

        emit WithdrawalInitialized(msg.sender, burntAmount);
    }

    function withdrawCallback(
        address to,
        uint64 amountCleartext,
        bytes calldata proof
    ) public {
        euint64 amount = _withdrawRequests[to];
        require(FHE.isInitialized(amount), NoWithdrawalToFinalize());

        bytes32[] memory cts = new bytes32[](1);
        cts[0] = euint64.unwrap(amount);
        FHE.checkSignatures(cts, abi.encode(amountCleartext), proof);

        uint256 scaled = _rate * amountCleartext;
        (bool success, ) = to.call{value: scaled}("");
        require(success, EthTransferFailed());

        emit WithdrawalFinalized(to, scaled);
    }
}

Vulnerability – Stateless Callback Replay

While the callback verifies the relayer’s proof, a design that leaves _withdrawRequests entries valid after the first successful fulfillment is subject to replay attacks. An attacker can initiate a withdrawal for any amount, observe the calldata of the relayer when calling the callback, and replay the second transaction with this copied calldata themselves. By repeating this attack, an attacker can drain the locked ETH, as long as the mapping remains intact.

Mitigation: Ensure decryption callbacks both verify the relayer response and enforce one-time use of the asynchronous link (_withdrawRequests). Invalidate the recorded mapping entry as an effect that happens before interacting with external addresses, and keep the callback’s logic minimal to limit external value transfers or calls unless they are explicitly intended and audited. This turns the decryption flow into a consumable-once pipeline and closes off replay-based drains.

function withdrawCallback(...) public {
    euint64 amount = _withdrawRequests[to];
    require(FHE.isInitialized(amount), NoWithdrawalToFinalize());
    delete _withdrawRequests[to]; // fix
    // ...
}

The asynchronous link has to be carefully considered. The tracking of async calls should not collide between users and should be handled between requests. For instance, ciphertext handles are not a safe identifier. Instead, apply the strategy of sanitization, e.g. by introducing a dedicated async request counter, or validation, as seen with the WithdrawalAlreadyPending check above.

Security Deep Dive 3 – Silent Failures and Block Reorgs

Scenario: In this example, a SealedTreasureAuction contract implements a confidential, sealed-bid auction where participants bid in cWETH. Bids are submitted as externalEuint64 values and pulled in using a confidential transfer from cWETH. The contract tracks the current leader in encrypted state (euint64 _highestBid and eaddress _highestBidder) and only reveals the winner after the auction ends. The winner can then claim access to a confidential treasure location (latitude and longitude) stored as encrypted coordinates.

The following snippet highlights another FHE implementation detail in the cWETH internal _update function. Because all balances and amounts are encrypted, the EVM cannot branch or revert based on intermediate FHE results. The off-chain coprocessor learns whether balance >= amount, but on-chain execution must proceed linearly without seeing that bit in the clear. Instead of reverting on insufficient balance, the contract always continues and proceeds with a zero transfer no-op, leaving it to higher-level logic to inspect the effective transferred value under encryption. This pattern is what we refer to as a silent failure.

// cWETH.sol

contract cWETH is ZamaEthereumConfig {
    function _update(address from, address to, euint64 amount)
        internal returns (euint64 transferred)
    {
        ebool success = FHE.asEbool(true);
        euint64 fromBalance;

        if (from != address(0)) {
            fromBalance = _balances[from];
            success = FHE.ge(fromBalance, amount);
        }

        transferred = FHE.select(success, amount, FHE.asEuint64(0));
        FHE.allowThis(transferred);

        if (from != address(0)) {
            euint64 newFromBalance = FHE.sub(fromBalance, transferred);
            _balances[from] = newFromBalance;
            FHE.allowThis(newFromBalance);
            FHE.allow(newFromBalance, from);
            FHE.allow(transferred, from);
        }
        if (to != address(0)) {
            euint64 newToBalance = FHE.add(_balances[to], transferred);
            _balances[to] = newToBalance;
            FHE.allowThis(newToBalance);
            FHE.allow(newToBalance, to);
            FHE.allow(transferred, to);
        }

        emit ConfidentialTransfer(from, to, transferred);
    }

}

// SealedTreasureAuction.sol

contract SealedTreasureAuction is ZamaEthereumConfig {
    euint64  _highestBid;
    eaddress _highestBidder;

    function bid(externalEuint64 encryptedAmount, bytes calldata inputProof)
        external
    {
        require(block.timestamp <= AUCTION_END, AuctionAlreadyEnded());

        // Decode encrypted bid amount
        euint64 amount = FHE.fromExternal(encryptedAmount, inputProof);

        cWETH.confidentialTransferFrom(msg.sender, address(this), amount);

        ebool isHigher = FHE.gt(amount, _highestBid);

        newHighestBid    = FHE.select(isHigher, amount, _highestBid);
        newHighestBidder = FHE.select(
            isHigher,
            FHE.asEaddress(msg.sender),
            _highestBidder
        );

        FHE.allowThis(newHighestBid);
        FHE.allowThis(newHighestBidder);

        _highestBid    = newHighestBid;
        _highestBidder = newHighestBidder;

        emit BidPlaced(msg.sender);
    }

    function discloseWinner() external {
        require(block.timestamp > AUCTION_END, AuctionNotEnded());
        FHE.makePubliclyDecryptable(_highestBid);
        FHE.makePubliclyDecryptable(_highestBidder);
        emit WinnerDisclosed(_highestBidder, _highestBid);
    }

    function claimTreasure(address winner, bytes calldata proof) external {
        // verify that winner matches highestBidder using proof...
        FHE.allow(TREASURE_LAT, winner);
        FHE.allow(TREASURE_LONG, winner);
    }
}

Vulnerability A – Silently Failing Bids Winning the Auction

In the event of insufficient balance the contract does not revert and the internal _update logic silently transfers and returns a zero amount. The auction ignores the effective transferred amount and compares the requested amount against highestBid, so any bidder can submit an arbitrarily large encrypted bid, trigger a zero-value transfer, and still become highestBidder without transferring any cWETH tokens.

Mitigation: When integrating with confidential tokens that may silently clamp or zero-out values, the auction must base its winner selection on the effectively received (transferred) amount, i.e. minus the fee, and reject bids that did not move value. In this case, the contract should compute a guard on transferred > 0 and fold it into the encrypted comparison before updating _highestBid and _highestBidder.

function bid(...) external {
    // ...
    euint64 transferred = cWETH.confidentialTransferFrom(
        msg.sender, address(this), amount
    );

    ebool hasTransferred = FHE.gt(transferred, FHE.asEuint64(0));
    ebool isHigher = FHE.gt(transferred, _highestBid);
    ebool isBestBid = FHE.and(hasTransferred, isHigher);
    // use isBestBid in FHE.select to assign new state...
}

Vulnerability B – Information Disclosure Racing Chain Finality

In this auction, the value being sold is the treasure’s coordinates, and revealing them is irreversible once the winner can decrypt them. If discloseWinner immediately makes _highestBid and _highestBidder publicly decryptable as soon as auctionEnd has passed, a user can learn the treasure location and then retroactively lose the auction if a block reorg replaces their winning bid with a later one. The on-chain state would show a different winner, but the original bidder already knows the coordinates for free.

Mitigation: When information is the product, the contract should decouple deciding the winner from granting decryption access and align disclosure with a finality delay. A first call schedules disclosure by recording the current block time after the auction ends. A second call, enforcing a delay that guarantees the finality for the block when the auction has ended, then grants decryption rights and treasure access.

function discloseWinner() external {
    require(_disclosureScheduledAt == 0, DisclosureAlreadyScheduled())
    _disclosureScheduledAt = block.timestamp;
    // ...
}

function claimTreasure(address winner, bytes calldata proof) external {
    require(
        block.timestamp > _disclosureScheduledAt + FINALITY_DELAY_SECONDS,
        BlockFinalityNotReached()
    );
    // ...
}

Further Considerations

The deep dives focused on specific contract-level bugs, but there are a few broader FHEVM pitfalls that are easy to miss because they sit “between” contracts: in transient storage, in how encrypted inputs are produced off-chain, and in how arbitrary external calls could interact with the ACL. These are not full bugs on their own, but they are easy to get wrong and can silently break confidentiality.

Transient Allowances and Account Abstraction

Transient allowances and handles (for example via allowTransient, fromExternal, or intermediate encrypted values) are meant to be short-lived. A single call grants access, uses it, and then that access effectively disappears at the end of the transaction. With Account Abstraction, multiple user operations share the same transaction and therefore the same transient storage. If Alice’s operation gives the contract transient access to handle x, and Mallory’s operation runs in the same transaction, Mallory can reuse x and benefit from Alice’s transient permission. This can be abused as a data-leak channel between otherwise independent user operations.

There is a countermeasure available to wipe the affected transient storage, which is the FHE.cleanTransientStorage()function. However, in practice it is unrealistic to expect every FHEVM contract to call this at the end of every call path. A better pattern is for the AA wallet to detect an FHEVM contract by querying confidentialProtocolId() from ZamaEthereumConfig, and then following up each FHE-sensitive user operation with its own “cleanup” user operation. This way, each user operation gets a fresh transient context without forcing that responsibility onto every protocol.

Externally Encrypted Values for Third-Party Callers

Externally encrypted values, i.e. (externalEuint64 value, bytes proof) tuples used with FHE.fromExternal, are created off-chain for a specific contract address and a specific caller address (msg.sender). In the typical cWETH flow, the user encrypts an amount for cWETH with themselves as the caller and then directly calls cWETH.confidentialTransferFrom. In that case, the tuple is effectively bound to that user. Another account cannot replay it, because FHE.fromExternal will fail when msg.sender does not match the user input’s context.

The problem appears when a value is encrypted for a third-party caller instead of the user, e.g. a timelock contract that will later call cWETH.confidentialTransferFrom on the user’s behalf. The tuple is then valid for whoever can make this timelock call cWETH with this (value, proof), not just for the original user. An attacker who can trigger the timelock to call cWETH.confidentialTransferFrom again, can copy the tuple from calldata and learn the user’s confidential amount by observing their own balance change.

There are three possible mitigations. One is to simply avoid encrypting values for third-party callers. Another is to have the intermediate contract (e.g. the timelock) validate the tuple using FHE.fromExternal, thereby validating the user context, and then using the ciphertext handle for further calls. Lastly, the timelock can cryptographically bind the tuple to the intended user context so that other accounts cannot reuse it. However, note that the proof component is malleable (for example, by reordering off-chain signatures), so just hashing the (value, proof) bytes is not a reliable way to detect reuse. Thus, non-replayability must come from how the tuple is bound to identities and context, not from its raw byte representation.

Arbitrary External Calls

Arbitrary external calls are always dangerous, but in an FHEVM context they can bypass confidentiality altogether. Any execute(address target, bytes data)-type function that runs from a privileged context can be used to call into the ACL contract and grant access to encrypted state, effectively turning that executor into a general-purpose data oracle. When a contract holds or is granted confidential data, exposing arbitrary external calls is a direct confidentiality risk. Such contracts should avoid unconstrained calls and carefully be audited with the ACL in mind, not only with traditional EVM safety assumptions.

Conclusion – From Confidentiality to Integrity

This article walked through how FHEVM’s per-ciphertext ACL underpins confidentiality while shifting much of the security burden toward integrity and state discipline. In the fee-calculation case study, we saw how unchecked encrypted arithmetic can silently under-charge without careful guard patterns, and how overlooking authorization in helpers, especially when paired with persistent allowances, can turn benign integrations into data-leak channels. In the asynchronous decryption flow, we highlighted the importance of treating relayer callbacks as one-time events by consuming records before any external interaction. The sealed-bid auction example showed how silent failures can break economic assumptions around escrow, and why chain finality matters when the asset you sell is information. Finally, we explored three cross-cutting considerations around transient allowances under Account Abstraction, externally encrypted values for third-party callers, and arbitrary external calls into ACL contracts. Across all examples, the common theme is to minimize permissions, verify at the point of use, check the arithmetic, handle fallback cases explicitly, and prevent replayability.

These safeguards are not free: encrypted guard patterns, explicit authorization checks, and tighter control flow increase gas. Whether to enable each check should follow your threat model and be weighed on a case-by-case basis.


Checklist for Secure FHEVM Development

  • For every encrypted arithmetic step, analyze wrap‑around risk and apply an encrypted guard and a safe fallback value selection.
  • When a contract receives a ciphertext handle, verify that the caller is authorized to act on it using the appropriate authorization check.
  • Grant only the minimum permission necessary. Prefer transient, transaction‑scoped allowances for helper calls. If a persistent allowance is unavoidable, constrain where ciphertext handles flow and enforce checks in every consumer. Make sure that accounts that must have access to a handle are explicitly granted access.
  • Ensure that async transaction records are invalidated before any external calls or value transfers.
  • Verify the relayer’s proof in the callback and keep the callback surface as narrow as possible to reduce re‑entrancy and replay risks.
  • For any FHEVM call that can silently clamp or zero out effects, inspect the effective result before updating state that depends on it, or design a recovery path.
  • If the disclosure of information is the product being sold, and block reorgs are a concern, use a two-step disclosure. First, record the current block height when scheduling the disclosure. Second, grant ACL access only after the chain’s finality/confirmation threshold has passed (not in the same transaction).
  • In the context of Account Abstraction, when multiple user operations run in one transaction, ensure that transient storage is wiped between operations.
  • When externally encrypting input for a third-party contract caller context, make sure it prevents replayability of this input value-proof tuple by other unauthorized users.
  • Ensure that arbitrary external calls are properly access controlled to prevent leaking data through the ACL.
  • Be aware that the callee in a delegate call can access and decrypt the caller’s ciphertext handles.

 

Useful Links