ERC-8065 - Zero Knowledge Token Wrapper

Created 2025-10-18
Status Draft
Category ERC
Type Standards Track
Authors
Requires

Abstract

This ERC defines a standard for the Zero Knowledge Token Wrapper, a wrapper that adds privacy to tokens — including ERC-20, ERC-721, ERC-1155 and ERC-6909 — while preserving all of the tokens' original properties, such as transferability, tradability, and composability. It specifies EIP-7503-style provable burn-and-remint flows, enabling users to break on-chain traceability and making privacy a native feature of all tokens on Ethereum.

Motivation

Most existing tokens lack native privacy due to regulatory, technical, and issuer-side neglect. Users seeking privacy must rely on dedicated privacy blockchains or privacy-focused dApps, which restrict token usability, reduce composability, limit supported token types, impose whitelists, and constrain privacy schemes.

This ERC takes a different approach by introducing a zero knowledge token wrapper that preserves the underlying token’s properties while adding privacy. Its primary goals are:

Specification

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Overview

A Zero Knowledge Wrapped Token (ZWToken) is a wrapped token minted by a Zero Knowledge Token Wrapper. It adds a commitment-based privacy layer to existing tokens, including ERC-20, ERC-721, ERC-1155, ERC-6909. This privacy layer allows private transfers without modifying the underlying token standard, while preserving full composability with existing Ethereum infrastructure.

The commitment mechanism underlying this privacy layer may be implemented using Merkle trees, cryptographic accumulators, or any other verifiable cryptographic structure.

A Zero Knowledge Wrapped Token (ZWToken) provides the following core functionalities:

The ZWToken recipient can be a provable burn address, from which the tokens can later be reminted.

Privacy Features by Token Type

For fungible tokens (FTs), e.g., ERC-20:

For non-fungible tokens (NFTs), e.g., ERC-721:

ZWToken-aware Workflow

In the ZWToken-aware workflow, both the user and the system explicitly recognize and interact with ZWToken. ZWToken inherits all functional properties of the underlying token.

For example, if the underlying token is ERC-20, ZWToken can be traded on DEXs, used for swaps, liquidity provision, or standard transfers. Similar to how holding WETH provides additional benefits over holding ETH directly, users may prefer to hold ZWToken rather than the underlying token.

ZWToken-unaware Workflow

This ERC also supports a ZWToken-unaware workflow. In this mode, all transfers are internally handled through ZWToken, but users remain unaware of its existence.

ZWToken functions transparently beneath the user interface, reducing the number of required contract interactions and improving overall user experience for those who prefer not to hold ZWToken directly.

Alternative Workflows

The two workflows described above represent only a subset of the interaction patterns supported by this ERC. Additional workflows are also possible, including:

The interface:

interface IERC8065 is IERC165 {
    struct RemintData {
        bytes32 commitment;
        bytes32[] nullifiers;
        bytes proverData;
        bytes relayerData;
        bool redeem;
        bytes proof;
    }

    // Optional
    event CommitmentUpdated(uint256 indexed id, bytes32 indexed commitment, address indexed to, uint256 amount);

    event Deposited(address indexed from, address indexed to, uint256 indexed id, uint256 amount);

    event Withdrawn(address indexed from, address indexed to, uint256 indexed id, uint256 amount);

    event Reminted(address indexed from, address indexed to, uint256 indexed id, uint256 amount, bool redeem);

    function deposit(address to, uint256 id, uint256 amount, bytes calldata data) external payable;

    function withdraw(address to, uint256 id, uint256 amount, bytes calldata data) external;

    function remint(
        address to,
        uint256 id,
        uint256 amount,
        RemintData calldata data
    ) external;

    // Optional
    function previewDeposit(address to, uint256 id, uint256 amount, bytes calldata data) external view returns (uint256);

    // Optional
    function previewWithdraw(address to, uint256 id, uint256 amount, bytes calldata data) external view returns (uint256);

    // Optional
    function previewRemint(address to, uint256 id, uint256 amount, RemintData calldata data) external view returns (uint256);

    function getLatestCommitment(uint256 id) external view returns (bytes32);

    function hasCommitment(uint256 id, bytes32 commitment) external view returns (bool);

    // Optional
    function getCommitLeafCount(uint256 id) external view returns (uint256);

    // Optional
    function getCommitLeaves(uint256 id, uint256 startIndex, uint256 length)
    external view returns (bytes32[] memory commitHashes, address[] memory recipients, uint256[] memory amounts);

    function getUnderlying() external view returns (address);
}

Deposit / Wrap

/// @notice Deposits a specified amount of the underlying asset and mints the corresponding amount of ZWToken to the given address.
/// @dev
/// If the underlying asset is an ERC-20/ERC-721/ERC-1155/ERC-6909 token, the caller must approve this contract to transfer the specified `amount` beforehand.
/// If the underlying asset is ETH, the caller should send the deposit value along with the transaction (`msg.value`), and `msg.value` MUST be equal to `amount`.
/// @param to The address that will receive the minted ZWTokens.
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param amount The amount of the underlying asset to deposit.
/// @param data Additional data for extensibility, such as fee information, callback data, or metadata.
function deposit(address to, uint256 id, uint256 amount, bytes calldata data) external payable;

Withdraw / Unwrap

/// @notice Withdraw underlying tokens by burning ZWToken
/// @param to The recipient address that will receive the underlying token
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param amount The amount of ZWToken to burn and redeem for the underlying token
/// @param data Additional data for extensibility, such as fee information, callback data, or metadata.
function withdraw(address to, uint256 id, uint256 amount, bytes calldata data) external;

Transfer and Update Commitment

Remint

/// @notice Encapsulates all data required for remint operations
/// @param commitment The commitment (Merkle root) corresponding to the provided proof
/// @param nullifiers Array of unique nullifiers used to prevent double-remint
/// @param proverData Generic data for the prover. The meaning and encoding are implementation-specific (e.g., a circuit identifier/version).
/// @param relayerData Generic data for the relayer. The meaning and encoding are implementation-specific (e.g., fee information).
/// @param redeem If true, withdraws the equivalent underlying token instead of reminting ZWToken
/// @param proof Zero-knowledge proof bytes verifying ownership of the provable burn address
struct RemintData {
    bytes32 commitment;
    bytes32[] nullifiers;
    bytes proverData;
    bytes relayerData;
    bool redeem;
    bytes proof;
}

/// @notice Remint ZWToken using a zero-knowledge proof to unlink the source of funds
/// @param to Recipient address that will receive the reminted ZWToken or the underlying token
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param amount Amount of ZWToken burned from the provable burn address for reminting
/// @param data Encapsulated remint data including commitment, nullifiers, redeem flag, proof, and relayer information
function remint(
    address to,
    uint256 id,
    uint256 amount,
    RemintData calldata data
) external;

Preview Functions (Optional)

Since the actual token amounts received may differ from the input amounts due to implementation-specific factors (e.g., fees), users need a standardized way to determine the exact amounts they will receive. Following the design pattern established by ERC-4626, the preview functions allow users to simulate the effects of their operations at the current block, returning values as close to and no more than the exact amounts that would result from the corresponding mutable operations if called in the same transaction.

/// @notice OPTIONAL: Allows an on-chain or off-chain user to simulate the effects of their deposit at the current block.
/// @dev MUST return as close to and no more than the exact amount of ZWToken that would be minted in a `deposit` call in the same transaction.
/// @param to The address that will receive the minted ZWTokens.
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param amount The amount of underlying tokens to deposit.
/// @param data Additional data for extensibility, such as fee information.
/// @return The amount of ZWToken that would be minted to the recipient after deducting applicable fees.
function previewDeposit(address to, uint256 id, uint256 amount, bytes calldata data) external view returns (uint256);
/// @notice OPTIONAL: Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the current block.
/// @dev MUST return as close to and no more than the exact amount of underlying tokens that would be received in a `withdraw` call in the same transaction.
/// @param to The recipient address that will receive the underlying token.
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param amount The amount of ZWToken to burn.
/// @param data Additional data for extensibility, such as fee information.
/// @return The amount of underlying tokens that would be received by the recipient after deducting applicable fees.
function previewWithdraw(address to, uint256 id, uint256 amount, bytes calldata data) external view returns (uint256);
/// @notice OPTIONAL: Allows an on-chain or off-chain user to simulate the effects of their remint at the current block.
/// @dev MUST return as close to and no more than the exact amount of ZWToken or underlying tokens that would be received in a `remint` call in the same transaction.
/// @param to Recipient address that will receive the reminted ZWToken or the underlying token.
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param amount The amount of ZWToken burned from the provable burn address for reminting.
/// @param data Encapsulated remint data including commitment, nullifiers, redeem flag, proof, and relayer information.
/// @return The amount of ZWToken or underlying tokens that would be received by the recipient after all applicable fees have been deducted.
function previewRemint(address to, uint256 id, uint256 amount, RemintData calldata data) external view returns (uint256);

Query Interfaces

/// @notice Returns the current top-level commitment representing the privacy state
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @return The latest root hash of the commitment tree
function getLatestCommitment(uint256 id) external view returns (bytes32);

/// @notice Checks if a specific top-level commitment exists
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param commitment The root hash to verify
/// @return True if the commitment exists, false otherwise
function hasCommitment(uint256 id, bytes32 commitment) external view returns (bool);

/// @notice OPTIONAL: Returns the total number of commitment leaves stored
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @return The total count of commitment leaves
function getCommitLeafCount(uint256 id) external view returns (uint256);

/// @notice OPTIONAL: Retrieves leaf-level commit data and their hashes
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param startIndex Index of the first leaf to fetch
/// @param length Number of leaves to fetch
/// @return commitHashes Hashes of the leaf data
/// @return recipients Recipient addresses of each leaf
/// @return amounts Token amounts of each leaf
function getCommitLeaves(uint256 id, uint256 startIndex, uint256 length)
    external view returns (bytes32[] memory commitHashes, address[] memory recipients, uint256[] memory amounts);

/// @notice Returns the address of the underlying token wrapped by this ZWToken
/// @return The underlying token contract address, or address(0) if the underlying asset is ETH.
function getUnderlying() external view returns (address);

ERC-165 Support

Events

// Optional: Emitted when a contract-maintained commitment is updated
/// @notice OPTIONAL event emitted when a commitment is updated in the contract
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param commitment The new top-level commitment hash
/// @param to The recipient address associated with the commitment
/// @param amount The amount related to this commitment update
event CommitmentUpdated(uint256 indexed id, bytes32 indexed commitment, address indexed to, uint256 amount);

/// @notice Emitted when underlying tokens are deposited and ZWToken is minted to the recipient
/// @param from The address sending the underlying tokens
/// @param to The address receiving the minted ZWToken (after fees)
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param amount The net amount of ZWToken minted to `to` after deducting applicable fees
event Deposited(address indexed from, address indexed to, uint256 indexed id, uint256 amount);

/// @notice Emitted when ZWToken is burned to redeem underlying tokens to the recipient
/// @param from The address burning the ZWToken
/// @param to The address receiving the redeemed underlying tokens (after fees)
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param amount The net amount of underlying tokens received by `to` after deducting applicable fees
event Withdrawn(address indexed from, address indexed to, uint256 indexed id, uint256 amount);

/// @notice Emitted upon successful reminting of ZWToken or withdrawal of underlying tokens via a zero-knowledge proof
/// @param from The address initiating the remint operation
/// @param to The address receiving the reminted ZWToken or withdrawn underlying tokens (after fees)
/// @param id The token identifier. For fungible tokens that do not have `id`, such as ERC-20, this value MUST be set to `0`.
/// @param amount The net amount of ZWToken or underlying tokens received by `to` after all applicable fees have been deducted
/// @param redeem If true, withdraws the equivalent underlying tokens instead of reminting ZWToken
event Reminted(address indexed from, address indexed to, uint256 indexed id, uint256 amount, bool redeem);

Rationale

Backwards Compatibility

This ERC introduces no breaking changes. It extends the functionality of the underlying token without modifying or overriding its base interfaces.

Reference Implementation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";

interface IVerifier {
    function verifyProof(
        bytes calldata proof,
        uint256[] calldata input
    ) external view returns (bool);
}

contract ZWToken is IERC8065, ERC20, ERC165 {
    using SafeERC20 for IERC20;

    IERC20 public immutable underlying;
    IVerifier public immutable verifier;

    mapping(bytes32 => bool) public usedNullifier;

    event Deposited(address indexed from, address indexed to, uint256 indexed id, uint256 amount);
    event Withdrawn(address indexed from, address indexed to, uint256 indexed id, uint256 amount);
    event Reminted(address indexed from, address indexed to, uint256 indexed id, uint256 amount, bool redeem);

    constructor(
        string memory name_,
        string memory symbol_,
        address underlying_,
        address verifier_
    ) ERC20(name_, symbol_) {
        require(underlying_ != address(0), "Invalid underlying");
        require(verifier_ != address(0), "Invalid verifier");
        underlying = IERC20(underlying_);
        verifier = IVerifier(verifier_);
    }

    function deposit(address to, uint256 id, uint256 amount, bytes calldata /*data*/) external payable override {
        require(amount > 0, "amount must > 0");
        require(id == 0, "id must be 0 for ERC20");
        underlying.safeTransferFrom(msg.sender, address(this), amount);
        _mint(to, amount);
        emit Deposited(msg.sender, to, id, amount);
    }

    function withdraw(address to, uint256 id, uint256 amount, bytes calldata /*data*/) external override {
        require(amount > 0, "amount must > 0");
        require(id == 0, "id must be 0 for ERC20");
        _burn(msg.sender, amount);
        underlying.safeTransfer(to, amount);
        emit Withdrawn(msg.sender, to, id, amount);
    }

    function remint(
        address to,
        uint256 id,
        uint256 amount,
        IERC8065.RemintData calldata data
    ) external override {
        require(id == 0, "id must be 0 for ERC20");
        // NOTE: This reference implementation uses a verifier circuit that consumes a single nullifier as a public input.
        // The ERC-8065 specification allows `data.nullifiers` to contain multiple nullifiers so implementations can
        // support batch reminting (consuming multiple nullifiers atomically within one proof).
        require(data.nullifiers.length == 1, "Only single nullifier supported");
        bytes32 nullifier = data.nullifiers[0];
        require(!usedNullifier[nullifier], "nullifier used");

        bytes32 headerHash = blockhash(uint256(data.commitment));
        require(headerHash != bytes32(0), "commitment not found");

        // Example encoding (implementation-specific):
        // if relayerData.length >= 32, first 32 bytes are interpreted as relayerFee (uint256)
        uint256 relayerFee = 0;
        if (data.relayerData.length >= 32) {
            assembly {
                relayerFee := calldataload(data.relayerData.offset)
            }
        }

        // Replay protection by chain id and contract address is handled externally––
        // they MUST be included as parameters when generating the provable burn address and proof.
        // (For example, the Poseidon hash that defines the burn address should incorporate these values.)
        // No contract-side enforcement is implemented here.
        uint256[] memory input = new uint256[](7);
        input[0] = uint256(headerHash);
        input[1] = uint256(nullifier);
        input[2] = uint256(uint160(to));
        input[3] = uint256(id);
        input[4] = uint256(amount);
        input[5] = uint256(data.redeem ? 1 : 0);
        input[6] = uint256(relayerFee);

        require(verifier.verifyProof(data.proof, input), "bad proof");

        usedNullifier[nullifier] = true;

        // Fee handling is implementation-specific
        // This example implementation applies only relayer fees (parsed from relayerData above)
        // In this example, relayerFee is interpreted as a percentage with denominator 10000
        uint256 remain = amount;
        if (relayerFee > 0) {
            uint256 feeDenominator = 10000;
            require(relayerFee < feeDenominator, "invalid relayer fee");
            remain = amount - amount * relayerFee / feeDenominator;
            require(remain > 0, "invalid remain");
        }

        if (data.redeem) {
            underlying.safeTransfer(to, remain);
            if (relayerFee > 0) {
                underlying.safeTransfer(msg.sender, amount - remain);
            }
        } else {
            _mint(to, remain);
            if (relayerFee > 0) {
                _mint(msg.sender, amount - remain);
            }
        }
        emit Reminted(msg.sender, to, id, remain, data.redeem);
    }

    function getLatestCommitment(uint256 id) external view override returns (bytes32) {
        require(id == 0, "id must be 0 for ERC20");
        return bytes32(block.number);
    }

    function hasCommitment(uint256 id, bytes32 commitment) external view override returns (bool) {
        require(id == 0, "id must be 0 for ERC20");
        bytes32 headerHash = blockhash(uint256(commitment));
        return headerHash != bytes32(0);
    }

    function getUnderlying() external view override returns (address) {
        return address(underlying);
    }

    // Optional preview functions
    function previewDeposit(address /*to*/, uint256 id, uint256 amount, bytes calldata /*data*/) external view returns (uint256) {
        require(id == 0, "id must be 0 for ERC20");
        // This example implementation has no deposit fees, so the output equals the input.
        // Implementations with fees SHOULD deduct them here.
        return amount;
    }

    function previewWithdraw(address /*to*/, uint256 id, uint256 amount, bytes calldata /*data*/) external view returns (uint256) {
        require(id == 0, "id must be 0 for ERC20");
        // This example implementation has no withdrawal fees, so the output equals the input.
        // Implementations with fees SHOULD deduct them here.
        return amount;
    }

    function previewRemint(address /*to*/, uint256 id, uint256 amount, IERC8065.RemintData calldata data) external view returns (uint256) {
        require(id == 0, "id must be 0 for ERC20");
        // Example encoding (implementation-specific):
        // Parse relayerFee from relayerData (if provided)
        uint256 relayerFee = 0;
        if (data.relayerData.length >= 32) {
            relayerFee = abi.decode(data.relayerData[:32], (uint256));
        }
        // Apply relayer fee (percentage with denominator 10000)
        uint256 remain = amount;
        if (relayerFee > 0) {
            uint256 feeDenominator = 10000;
            require(relayerFee < feeDenominator, "invalid relayer fee");
            remain = amount - amount * relayerFee / feeDenominator;
        }
        return remain;
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        virtual
        override(ERC165, IERC165)
        returns (bool)
    {
        return interfaceId == type(IERC8065).interfaceId || super.supportsInterface(interfaceId);
    }
}

Security Considerations

Copyright

Copyright and related rights waived via CC0.