ERC-7962 - Key Hash Based Tokens

Created 2025-05-16
Status Draft
Category ERC
Type Standards Track
Authors
Requires

Abstract

This EIP proposes two token interfaces: ERC-KeyHash721 for non-fungible tokens (NFTs) and ERC-KeyHash20 for fungible tokens (similar to ERC-20). Both of them utilize cryptographic key hashes (“keyHash”, or keccak256(key)) instead of Ethereum addresses to manage ownership. This enhances privacy by authorizing by the public key’s ECDSA signature (address derived from keccak256(key[1:])) and matching keyHash = keccak256(key), without storing addresses on‑chain. Consequently, it empowers users to conduct transactions using any address they choose. By separating ownership from transaction initiation, these standards allow gas fees to be paid by third parties without relinquishing token control, making them suitable for batch transactions and gas sponsorship. Security is ensured by implementing robust ECDSA signature verification on key functions (transfer, destroy) to prevent message tampering.

Motivation

Traditional ERC-721 and ERC-20 tokens bind ownership to Ethereum addresses, which are publicly visible and may be linked to identities, compromising privacy. The key hash-based ownership model allows owners to prove control without exposing addresses, ideal for anonymous collectibles, private transactions, or decentralized identity use cases. Additionally, separating ownership from gas fee payment enables third-party gas sponsorship, improving user experience in high-gas or batch transaction scenarios. This proposal aligns with the privacy principles of ERC-5564 (Stealth Addresses) and extends them to token ownership.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.

Overview

This proposal defines two token interfaces: - IERCKeyHash721: For non-fungible tokens (NFTs), each identified by a unique tokenId, with ownership managed via keyHash (keccak256(key)). - IERCKeyHash20: For fungible tokens, with balances associated with keyHash.

Token operations (transfer, destroy) require the owner's key(an uncompressed secp256k1 public key) and an ECDSA signature produced by the private key corresponding to the key (i.e., the address derived from keccak256(key[1:]) excluding the 0x04 prefix) to prove ownership, ensuring only legitimate owners can execute actions. The mint function's access control is implementation-defined, typically restricted to the contract owner. Signatures follow EIP-712 structured data hashing to prevent message tampering, with per-keyHash nonces and deadlines to prevent replay attacks.

Notably, the approve function is intentionally omitted. The key is designed for one-time use and is revealed only during token transfer or destruction transactions. Once revealed, holdings are typically migrated to fresh keyHashes; implementations MAY disallow reuse of previously revealed keyHash. Since transactions can be submitted by any address, the signature must be generated by the address derived from the key. This binds authorization to the key while allowing any relayer address to submit and pay gas.

ERC-KeyHash721: Non-Fungible Token Interface

Interface

interface IERCKeyHash721 {
    // Events
    event KeyHashTransfer721(uint256 indexed tokenId, bytes32 indexed fromKeyHash, bytes32 indexed toKeyHash);
    event KeyHashBurn721(uint256 indexed tokenId, bytes32 indexed ownerKeyHash);

    // View functions (aligned with ERC-721)
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function tokenURI(uint256 tokenId) external view returns (string memory);
    function totalSupply() external view returns (uint256);
    function ownerOf(uint256 tokenId) external view returns (bytes32);

    // State-changing functions
    function mint(uint256 tokenId, bytes32 keyHash) external;
    function transfer(uint256 tokenId, bytes32 toKeyHash, bytes memory key, bytes memory signature, uint256 deadline) external;
    function destroy(uint256 tokenId, bytes memory key, bytes memory signature, uint256 deadline) external;
}

Function Descriptions

transfer
    transfer(uint256 tokenId, bytes32 toKeyHash, bytes memory key, bytes memory signature, uint256 deadline) external;

Description: Transfers the specified token from the current owner's keyHash to toKeyHash. The caller provides the owner's key to prove ownership. The signature is verified using EIP-712 structured data.
Parameters: - tokenId: uint256 - The token ID to transfer. - toKeyHash: bytes32 - The new owner's key hash. - key: bytes - MUST be a 65-byte uncompressed secp256k1 public key with prefix 0x04. - signature: bytes - ECDSA signature produced by the private key corresponding to the key, verifying ownership and preventing malicious relay attacks. - deadline: uint256 - Signature expiration timestamp (Unix seconds).
Signature Message: EIP-712 structured data:
solidity struct Transfer { uint256 tokenId; bytes32 toKeyHash; uint256 nonce; uint256 deadline; }
Events: Emits KeyHashTransfer721(tokenId, fromKeyHash, toKeyHash).
Requirements: - Token MUST exist (non-zero fromKeyHash, not destroyed). - keccak256(key) MUST equal the current fromKeyHash. - Signature MUST be valid. - block.timestamp MUST be <= deadline. - toKeyHash MUST NOT be zero. - Updates ownership to toKeyHash.

destroy
    destroy(uint256 tokenId, bytes memory key, bytes memory signature, uint256 deadline) 

Description: Destroys the specified token, removing it from circulation. Requires the owner's hash key and signature.
Parameters: - tokenId: uint256 - The token ID to destroy. - key: bytes - MUST be a 65-byte uncompressed secp256k1 public key with prefix 0x04. - signature: bytes - ECDSA signature produced by the private key corresponding to the key, verifying ownership and preventing malicious relay attacks. - deadline: uint256 - Signature expiration timestamp.
Signature Message: EIP-712 structured data:
solidity struct Destroy { uint256 tokenId; uint256 nonce; uint256 deadline; }
Events: - KeyHashBurn721(tokenId, ownerKeyHash). - KeyHashTransfer721(tokenId, ownerKeyHash, bytes32(0)).
Requirements: - Token MUST exist. - keccak256(key) MUST equal the current ownerKeyHash. - Signature MUST be valid. - block.timestamp MUST be <= deadline. - Marks token as destroyed and decrements totalSupply.

mint
mint(uint256 tokenId, bytes32 keyHash)

Description: Mints a new token and assigns it to keyHash. Access control is implementation-defined (e.g., restricted to contract owner). Re-minting a previously destroyed tokenId is prohibited.
Parameters: - tokenId: uint256 - The new token ID. - keyHash: bytes32 - The owner's key hash.
Events: Emits KeyHashTransfer721(tokenId, bytes32(0), keyHash).
Requirements: - tokenId MUST NOT exist and MUST NOT have been previously destroyed. - keyHash MUST NOT be zero. - Increments totalSupply.

Key Concepts

require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");

ERC-KeyHash20: Fungible Token Interface

Interface

interface IERCKeyHash20 {
    // Events
    event KeyHashTransfer20(bytes32 indexed fromKeyHash, bytes32 indexed toKeyHash, uint256 amount);

    // View functions (aligned with ERC-20)
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function decimals() external view returns (uint8);
    function totalSupply() external view returns (uint256);
    function balanceOf(bytes32 keyHash) external view returns (uint256);

    // State-changing functions
    function mint(bytes32 keyHash, uint256 amount) external;
    function transfer(bytes32 fromKeyHash, bytes32 toKeyHash, uint256 amount, bytes memory key, bytes memory signature, uint256 deadline, bytes32 leftKeyHash) external;
}

Function Descriptions

transfer
    transfer(bytes32 fromKeyHash, bytes32 toKeyHash, uint256 amount, bytes memory key, bytes memory signature, uint256 deadline, bytes32 leftKeyHash)

Description: Transfers amount tokens from fromKeyHash to toKeyHash, with remaining balance assigned to leftKeyHash (controlled by the sender). The caller provides the owner's key to prove ownership. The signature is verified using EIP-712 structured data. Mimics Bitcoin's UTXO model for partial transfers.
Parameters: - fromKeyHash: bytes32 - Token owner's key hash. - toKeyHash: bytes32 - Recipient's key hash. - amount: uint256 - Amount to transfer. - key: bytes - MUST be a 65-byte uncompressed secp256k1 public key with prefix 0x04. - signature: bytes - ECDSA signature. - deadline: uint256 - Signature expiration timestamp. - leftKeyHash: bytes32 - Key hash for remaining balance (balance - amount). MUST NOT equal toKeyHash or fromKeyHash (strict mode to enforce key rotation and unlinkability).
Signature Message: EIP-712 structured data:
solidity struct Transfer { bytes32 fromKeyHash; bytes32 toKeyHash; uint256 amount; uint256 nonce; uint256 deadline; bytes32 leftKeyHash; }
Events: Emits KeyHashTransfer20(fromKeyHash, toKeyHash, amount).
Requirements: - fromKeyHash MUST have sufficient balance (balanceOf[fromKeyHash] >= amount). - keccak256(key) MUST equal fromKeyHash. - Signature MUST be valid. - block.timestamp MUST be <= deadline. - toKeyHash and leftKeyHash MUST NOT be zero. - Updates balances: balanceOf[fromKeyHash] = 0, balanceOf[toKeyHash] += amount, balanceOf[leftKeyHash] += (original balance - amount).

mint
    mint(bytes32 keyHash, uint256 amount)

Description: Mints amount tokens to keyHash. Access control is implementation-defined.
Parameters: - keyHash: bytes32 - Recipient's key hash,. - amount: uint256 - Amount to mint.
Events: Emits KeyHashTransfer20(bytes32(0), keyHash, amount).
Requirements: - Increases totalSupply and balanceOf[keyHash] by amount. - keyHash MUST NOT be zero.

Signature Verification

For transfer and destroy: 1. Verify keccak256(key) == current keyHash. 2. Compute EIP-712 message hash: solidity bytes32 digest = keccak256(abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR, keccak256(abi.encode( TYPE_HASH, // Struct-specific type hash params // Struct fields (e.g., tokenId, toKeyHash, nonce, deadline) )) )); 3. Recover signer address using ecrecover(digest, signature). 4. REQUIRE signer == address(uint160(uint256(keccak256(key[1:])))), where key is a 65‑byte uncompressed secp256k1 public key (0x04 || X || Y) and key[1:] denotes the 64‑byte XY payload (prefix removed) 5. On successful verification, increment _keyNonces[currentOwnerKeyHash] (i.e., _keyNonces[ownerKeyHash] for ERC‑KeyHash721 and _keyNonces[fromKeyHash] for ERC‑KeyHash20) to prevent replay. 6. Verify block.timestamp <= deadline.

Requirements

Rationale

Advantages of Key Hash

Transfer and Destroy Design

Mint Flexibility

Backwards Compatibility

This proposal is not compatible with ERC-721 or ERC-20 due to bytes32 key hashes instead of addresses. Adapters can bridge to existing systems for privacy-focused use cases.

Reference Implementation

ERC-KeyHash721 Implementation

pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract KeyHashERC721 is EIP712 {
    using ECDSA for bytes32;

    string public name;
    string public symbol;
    mapping(uint256 => bytes32) private _tokenKeyHashes;
    mapping(uint256 => bool) private _destroyedTokens;
    uint256 public totalSupply;
    mapping(bytes32 => uint256) private _keyNonces;

    event KeyHashTransfer721(uint256 indexed tokenId, bytes32 indexed fromKeyHash, bytes32 indexed toKeyHash);
    event KeyHashBurn721(uint256 indexed tokenId, bytes32 indexed ownerKeyHash);

    bytes32 private constant TRANSFER_TYPEHASH = keccak256(
        "Transfer(uint256 tokenId,bytes32 toKeyHash,uint256 nonce,uint256 deadline)"
    );
    bytes32 private constant DESTROY_TYPEHASH = keccak256(
        "Destroy(uint256 tokenId,uint256 nonce,uint256 deadline)"
    );

    constructor(string memory _name, string memory _symbol)
        EIP712("KeyHashERC721", "1")
    {
        name = _name;
        symbol = _symbol;
    }

    function ownerOf(uint256 tokenId) external view returns (bytes32) {
        require(_tokenKeyHashes[tokenId] != 0 && !_destroyedTokens[tokenId], "Token does not exist");
        return _tokenKeyHashes[tokenId];
    }

    function mint(uint256 tokenId, bytes32 keyHash) external {
        require(_tokenKeyHashes[tokenId] == 0, "EXISTS");
        require(!_destroyedTokens[tokenId], "BURNED");
        require(keyHash != bytes32(0), "ZERO_KEYHASH");
        _tokenKeyHashes[tokenId] = keyHash;
        totalSupply++;
        emit KeyHashTransfer721(tokenId, bytes32(0), keyHash);
    }

    function tokenURI(uint256) external pure returns (string memory) { return ""; }

    function transfer(
        uint256 tokenId,
        bytes32 toKeyHash,
        bytes memory key,
        bytes memory signature,
        uint256 deadline
    ) external {
        require(toKeyHash != bytes32(0), "Invalid recipient hash");
        require(_tokenKeyHashes[tokenId] != 0 && !_destroyedTokens[tokenId], "Token does not exist");
        require(block.timestamp <= deadline, "Signature expired");
        require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");
        bytes32 currentKeyHash = _tokenKeyHashes[tokenId];
        require(keccak256(key) == currentKeyHash, "BAD_KEYHASH");

        uint256 nonce = _keyNonces[currentKeyHash];
        bytes32 structHash = keccak256(abi.encode(
            TRANSFER_TYPEHASH,
            tokenId,
            toKeyHash,
            nonce,
            deadline
        ));
        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = digest.recover(signature);
        address expectedAddress = _addressFromUncompressedKey(key);
        require(signer == expectedAddress, "Invalid signature");
        _keyNonces[currentKeyHash] = nonce + 1; // 验签通过后再自增

        _tokenKeyHashes[tokenId] = toKeyHash;
        emit KeyHashTransfer721(tokenId, currentKeyHash, toKeyHash);
    }

    function destroy(
        uint256 tokenId,
        bytes memory key,
        bytes memory signature,
        uint256 deadline
    ) external {
        require(_tokenKeyHashes[tokenId] != 0 && !_destroyedTokens[tokenId], "Token does not exist");
        require(block.timestamp <= deadline, "Signature expired");
        require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");
        bytes32 currentKeyHash = _tokenKeyHashes[tokenId];
        require(keccak256(key) == currentKeyHash, "BAD_KEYHASH");

        uint256 nonce = _keyNonces[currentKeyHash];
        bytes32 structHash = keccak256(abi.encode(
            DESTROY_TYPEHASH,
            tokenId,
            nonce,
            deadline
        ));
        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = digest.recover(signature);
        address expectedAddress = _addressFromUncompressedKey(key);
        require(signer == expectedAddress, "Invalid signature");
        _keyNonces[currentKeyHash] = nonce + 1; // 验签通过后再自增

        _destroyedTokens[tokenId] = true;
        totalSupply--;
        emit KeyHashBurn721(tokenId, currentKeyHash);
        emit KeyHashTransfer721(tokenId, currentKeyHash, bytes32(0));
    }

    function getNonce(bytes32 keyHash) external view returns (uint256) {
        return _keyNonces[keyHash];
    }

    function _addressFromUncompressedKey(bytes memory key) internal pure returns (address) {
        // key: 65 bytes, [0] = 0x04, [1..32] = X, [33..64] = Y 
        require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");
        bytes32 x;
        bytes32 y;
        assembly {
            x := mload(add(key, 0x21)) // key[1..32] 
            y := mload(add(key, 0x41)) // key[33..64] 
        }
        bytes32 h = keccak256(abi.encodePacked(x, y)); // 64-byte XY 
        return address(uint160(uint256(h)));
    }
}

ERC-KeyHash20 Implementation

pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract KeyHashERC20 is EIP712 {
    using ECDSA for bytes32;

    string public name;
    string public symbol;
    uint8 public decimals;
    mapping(bytes32 => uint256) public balanceOf;
    uint256 public totalSupply;
    mapping(bytes32 => uint256) private _keyNonces;

    event KeyHashTransfer20(bytes32 indexed fromKeyHash, bytes32 indexed toKeyHash, uint256 amount);

    bytes32 private constant TRANSFER_TYPEHASH = keccak256(
        "Transfer(bytes32 fromKeyHash,bytes32 toKeyHash,uint256 amount,uint256 nonce,uint256 deadline,bytes32 leftKeyHash)"
    );

    constructor(string memory _name, string memory _symbol)
        EIP712("KeyHashERC20", "1")
    {
        name = _name;
        symbol = _symbol;
        decimals = 18;
    }

    function mint(bytes32 keyHash, uint256 amount) external {
        require(keyHash != bytes32(0), "ZERO_KEYHASH");
        balanceOf[keyHash] += amount;
        totalSupply += amount;
        emit KeyHashTransfer20(bytes32(0), keyHash, amount);
    }

    function transfer(
        bytes32 fromKeyHash,
        bytes32 toKeyHash,
        uint256 amount,
        bytes memory key,
        bytes memory signature,
        uint256 deadline,
        bytes32 leftKeyHash
    ) external {
        require(balanceOf[fromKeyHash] >= amount, "Insufficient balance");
        require(toKeyHash != bytes32(0), "Invalid recipient hash");
        require(leftKeyHash != bytes32(0), "Invalid leftKeyHash");
        require(leftKeyHash != toKeyHash, "LEFT_EQ_TO");
        require(leftKeyHash != fromKeyHash, "LEFT_EQ_FROM");
        require(block.timestamp <= deadline, "Signature expired");
        require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");
        require(keccak256(key) == fromKeyHash, "BAD_KEYHASH");

        uint256 nonce = _keyNonces[fromKeyHash];
        bytes32 structHash = keccak256(abi.encode(
            TRANSFER_TYPEHASH,
            fromKeyHash,
            toKeyHash,
            amount,
            nonce,
            deadline,
            leftKeyHash
        ));
        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = digest.recover(signature);
        address expectedAddress = _addressFromUncompressedKey(key);
        require(signer == expectedAddress, "Invalid signature");
        _keyNonces[fromKeyHash] = nonce + 1; // 验签通过后再自增

        uint256 remaining = balanceOf[fromKeyHash] - amount;
        balanceOf[fromKeyHash] = 0;
        balanceOf[toKeyHash] += amount;
        balanceOf[leftKeyHash] += remaining;
        emit KeyHashTransfer20(fromKeyHash, toKeyHash, amount);
    }

    function getNonce(bytes32 keyHash) external view returns (uint256) {
        return _keyNonces[keyHash];
    }

    function _addressFromUncompressedKey(bytes memory key) internal pure returns (address) {
        // key: 65 bytes, [0] = 0x04, [1..32] = X, [33..64] = Y 
        require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");
        bytes32 x;
        bytes32 y;
        assembly {
            x := mload(add(key, 0x21)) // key[1..32] 
            y := mload(add(key, 0x41)) // key[33..64] 
        }
        bytes32 h = keccak256(abi.encodePacked(x, y)); // 64-byte XY 
        return address(uint160(uint256(h)));
    }
}

Security Considerations

Copyright

Copyright and related rights waived via CC0.