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) 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) 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. Signatures follow EIP-712 structured data hashing to prevent message tampering, with per-keyHash nonces and deadlines to prevent replay attacks.
Implementers MAY optionally add administrative functions such as mint (create new tokens) and destroy (remove tokens) based on application requirements. These functions are not part of the core interface of this ERC; if provided, they SHOULD enforce strict access control, correct supply accounting, and preserve the key‑hash privacy model without exposing addresses. Regardless of how tokens are created or destroyed, implementations MUST emit events as specified in Events and Indexing.
Notably, the approve function is intentionally omitted. The key is designed for one-time use and is revealed only during token transfer 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.
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).
- 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.
Other Functions: name, symbol, tokenURI, and totalSupply align with ERC-721 . ownerOf returns bytes32 (keyHash) instead of an address. tokenURI is part of the core interface and MAY return an empty string if metadata is not provided. nonces(keyHash) returns the current nonce for keyHash (see Signature Domain and Nonces).
Key Concepts
Key (key): An uncompressed secp256k1 public key (65 bytes, starting with 0x04), used to prove ownership. Implementations MUST validate the key format:
Key Hash (keyHash): A bytes32 value representing keccak256(key), identifying ownership without exposing addresses.
Token Existence: A token exists if its keyHash is non-zero.
Nonce: Nonces are tracked per keyHash and per contract. Each ERC-KeyHash contract maintains its own mapping(bytes32 => uint256) of nonces, exposed through nonces(bytes32). Cross-contract replay is already prevented by the EIP-712 domain (verifyingContract, chainId). Signers MUST serialize operations for the same keyHash within the same contract.
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; see Security Considerations). Signature Message: EIP-712 structured data: solidity
struct Transfer {
bytes32 fromKeyHash;
bytes32 toKeyHash;
uint256 amount;
uint256 nonce;
uint256 deadline;
bytes32 leftKeyHash;
}
Events: MUST emit exactly one KeyHashTransfer20(fromKeyHash, toKeyHash, leftKeyHash, amount, balance - amount), including when balance - amount is zero. 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).
Other Functions: name, symbol, decimals, and totalSupply align with ERC-20. balanceOf uses bytes32 parameter. nonces(keyHash) returns the current nonce for keyHash.
Events and Indexing
Wallets and indexers discover holdings from logs. Every change to ownership or balance MUST therefore be observable through the events defined in this specification.
ERC-KeyHash721: every change of ownerOf(tokenId) MUST emit KeyHashTransfer721. Any mechanism that creates a token MUST emit it with fromKeyHash = bytes32(0); any mechanism that destroys a token MUST emit it with toKeyHash = bytes32(0).
ERC-KeyHash20: every change of balanceOf MUST be covered by KeyHashTransfer20. Any mechanism that creates tokens MUST emit KeyHashTransfer20(bytes32(0), keyHash, bytes32(0), amount, 0); any mechanism that destroys tokens MUST emit KeyHashTransfer20(keyHash, bytes32(0), bytes32(0), amount, 0).
For each KeyHashTransfer20, an indexer applies:
balanceOf(fromKeyHash) -= amount + leftAmount
balanceOf(toKeyHash) += amount
balanceOf(leftKeyHash) += leftAmount
where updates to bytes32(0) are ignored. For ERC-KeyHash721, ownerOf(tokenId) is the toKeyHash of the latest KeyHashTransfer721 for tokenId.
A wallet that knows the set of keyHashes it controls can recover all holdings by filtering on the indexed toKeyHash and leftKeyHash topics.
Signature Domain and Nonces
Contracts MUST implement ERC-5267 (eip712Domain()), so that wallets can obtain the EIP-712 domain without out-of-band configuration.
nonces(keyHash) MUST return the nonce that the next valid signature for keyHash commits to.
Recover signer address using ecrecover(digest, signature).
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)
On successful verification, increment _keyNonces[currentOwnerKeyHash] (i.e., _keyNonces[ownerKeyHash] for ERC‑KeyHash721 and _keyNonces[fromKeyHash] for ERC‑KeyHash20) to prevent replay.
Verify block.timestamp <= deadline.
Requirements
Contracts MUST maintain mappings:
ERC-KeyHash721: tokenId to keyHash.
ERC-KeyHash20: keyHash to balance.
MUST use per-keyHash nonces (mapping(bytes32 => uint256) _keyNonces) for replay protection, exposed through nonces(bytes32).
MUST implement EIP-712 for signature hashing and ERC-5267 for domain discovery.
MUST enforce deadline to limit signature validity.
MUST verify signatures and hash keys in transfer.
For ERC-KeyHash20, MUST enforce strict mode for leftKeyHash by requiring it to be different from both toKeyHash and fromKeyHash. This prevents change consolidation with the recipient or the original account. Because key is revealed in calldata when spending, leaving the change under fromKeyHash would keep funds under a keyHash whose public key is already known; requiring a distinct leftKeyHash ensures that any keyHash currently holding a balance has a public key that has not yet been revealed.
Rationale
Advantages of Key Hash
Privacy: ownerOf and balanceOf return keyHash, not addresses. Users can use unique key pairs per token or balance, reducing linkability.
Gas Fee Separation: Anyone can call transfer with a valid signature, paying gas fees, enabling batch transactions or gas sponsorship.
Flexibility: Aligns with ERC-5564 stealth addresses, extending privacy to tokens.
Transfer Design
Open to any caller with valid signatures, ensuring only owners operate while allowing gas sponsorship.
EIP-712 signatures prevent message tampering by including all critical parameters.
Per-keyHash nonces and deadlines prevent replay attacks.
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
pragmasolidity^0.8.0;import"@openzeppelin/contracts/utils/cryptography/ECDSA.sol";import"@openzeppelin/contracts/utils/cryptography/EIP712.sol";// OpenZeppelin >= 4.9 also provides ERC-5267 eip712Domain()
// Token creation and destruction are implementation-specific and omitted here; see Events and Indexing.
contractKeyHashERC721isEIP712{usingECDSAforbytes32;stringpublicname;stringpublicsymbol;mapping(uint256=>bytes32)private_tokenKeyHashes;uint256publictotalSupply;mapping(bytes32=>uint256)private_keyNonces;eventKeyHashTransfer721(uint256indexedtokenId,bytes32indexedfromKeyHash,bytes32indexedtoKeyHash);bytes32privateconstantTRANSFER_TYPEHASH=keccak256("Transfer(uint256 tokenId,bytes32 toKeyHash,uint256 nonce,uint256 deadline)");constructor(stringmemory_name,stringmemory_symbol)EIP712("KeyHashERC721","1"){name=_name;symbol=_symbol;}functionownerOf(uint256tokenId)externalviewreturns(bytes32){require(_tokenKeyHashes[tokenId]!=0,"Token does not exist");return_tokenKeyHashes[tokenId];}functiontokenURI(uint256)externalpurereturns(stringmemory){return"";}functiontransfer(uint256tokenId,bytes32toKeyHash,bytesmemorykey,bytesmemorysignature,uint256deadline)external{require(toKeyHash!=bytes32(0),"Invalid recipient hash");require(_tokenKeyHashes[tokenId]!=0,"Token does not exist");require(block.timestamp<=deadline,"Signature expired");require(key.length==65&&key[0]==0x04,"BAD_KEY_FMT");bytes32currentKeyHash=_tokenKeyHashes[tokenId];require(keccak256(key)==currentKeyHash,"BAD_KEYHASH");uint256nonce=_keyNonces[currentKeyHash];bytes32structHash=keccak256(abi.encode(TRANSFER_TYPEHASH,tokenId,toKeyHash,nonce,deadline));bytes32digest=_hashTypedDataV4(structHash);addresssigner=digest.recover(signature);addressexpectedAddress=_addressFromUncompressedKey(key);require(signer==expectedAddress,"Invalid signature");_keyNonces[currentKeyHash]=nonce+1;// Increment after the signature verification passes.
_tokenKeyHashes[tokenId]=toKeyHash;emitKeyHashTransfer721(tokenId,currentKeyHash,toKeyHash);}functionnonces(bytes32keyHash)externalviewreturns(uint256){return_keyNonces[keyHash];}function_addressFromUncompressedKey(bytesmemorykey)internalpurereturns(address){// key: 65 bytes, [0] = 0x04, [1..32] = X, [33..64] = Y
require(key.length==65&&key[0]==0x04,"BAD_KEY_FMT");bytes32x;bytes32y;assembly{x:=mload(add(key,0x21))// key[1..32]
y:=mload(add(key,0x41))// key[33..64]
}bytes32h=keccak256(abi.encodePacked(x,y));// 64-byte XY
returnaddress(uint160(uint256(h)));}}
ERC-KeyHash20 Implementation
pragmasolidity^0.8.0;import"@openzeppelin/contracts/utils/cryptography/ECDSA.sol";import"@openzeppelin/contracts/utils/cryptography/EIP712.sol";// OpenZeppelin >= 4.9 also provides ERC-5267 eip712Domain()
// Token creation and destruction are implementation-specific and omitted here; see Events and Indexing.
contractKeyHashERC20isEIP712{usingECDSAforbytes32;stringpublicname;stringpublicsymbol;uint8publicdecimals;mapping(bytes32=>uint256)publicbalanceOf;uint256publictotalSupply;mapping(bytes32=>uint256)private_keyNonces;eventKeyHashTransfer20(bytes32indexedfromKeyHash,bytes32indexedtoKeyHash,bytes32indexedleftKeyHash,uint256amount,uint256leftAmount);bytes32privateconstantTRANSFER_TYPEHASH=keccak256("Transfer(bytes32 fromKeyHash,bytes32 toKeyHash,uint256 amount,uint256 nonce,uint256 deadline,bytes32 leftKeyHash)");constructor(stringmemory_name,stringmemory_symbol)EIP712("KeyHashERC20","1"){name=_name;symbol=_symbol;decimals=18;}functiontransfer(bytes32fromKeyHash,bytes32toKeyHash,uint256amount,bytesmemorykey,bytesmemorysignature,uint256deadline,bytes32leftKeyHash)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");uint256nonce=_keyNonces[fromKeyHash];bytes32structHash=keccak256(abi.encode(TRANSFER_TYPEHASH,fromKeyHash,toKeyHash,amount,nonce,deadline,leftKeyHash));bytes32digest=_hashTypedDataV4(structHash);addresssigner=digest.recover(signature);addressexpectedAddress=_addressFromUncompressedKey(key);require(signer==expectedAddress,"Invalid signature");_keyNonces[fromKeyHash]=nonce+1;// Increment after the signature verification passes.
uint256remaining=balanceOf[fromKeyHash]-amount;balanceOf[fromKeyHash]=0;balanceOf[toKeyHash]+=amount;balanceOf[leftKeyHash]+=remaining;emitKeyHashTransfer20(fromKeyHash,toKeyHash,leftKeyHash,amount,remaining);}functionnonces(bytes32keyHash)externalviewreturns(uint256){return_keyNonces[keyHash];}function_addressFromUncompressedKey(bytesmemorykey)internalpurereturns(address){// key: 65 bytes, [0] = 0x04, [1..32] = X, [33..64] = Y
require(key.length==65&&key[0]==0x04,"BAD_KEY_FMT");bytes32x;bytes32y;assembly{x:=mload(add(key,0x21))// key[1..32]
y:=mload(add(key,0x41))// key[33..64]
}bytes32h=keccak256(abi.encodePacked(x,y));// 64-byte XY
returnaddress(uint160(uint256(h)));}}
Security Considerations
Replay Attacks:
Mitigation: Per-keyHash nonces (_keyNonces[keyHash]) increment after each operation, invalidating old signatures.
Design: Similar to ERC-2612 permit mechanism, ensuring owner-controlled nonce sequences.
Message Tampering:
Mitigation: EIP-712 structured signatures include all critical parameters (tokenId, toKeyHash, amount, leftKeyHash, etc.), preventing relayer tampering. Signatures are function-specific (TRANSFER_TYPEHASH).
Audit: Contracts MUST be audited to ensure no parameter omissions or hash collisions.
Issue: Public keys (key) are revealed in calldata; the corresponding Ethereum addresses can be derived off‑chain from keccak256(key[1:]). Use fresh keys (toKeyHash / leftKeyHash) to reduce linkability.
Recommendation: Use new key pairs per token or balance to minimize linkability. Store the private key securely; the keyHash itself is public on-chain and does not require protection.
Transaction Graph Visibility:
Issue: fromKeyHash, toKeyHash, and leftKeyHash all appear in the same transfer calldata, so the flow of funds within a contract is publicly traceable: an observer can follow a chain of change keyHashes indefinitely. Because balanceOf is public and leftKeyHash is required to be fresh, the sender’s balance prior to the transfer is computable as amount + balanceOf(leftKeyHash).
Scope: This proposal does not hide the intra-contract transaction graph or transferred amounts. What it provides is (a) ownership identifiers that are not addresses and therefore cannot be joined against the ecosystem-wide address graph maintained by explorers, indexers, other contracts, and other chains; and (b) the guarantee that any keyHash currently holding a balance has a public key that has not yet been revealed.
Recommendation: Applications requiring amount or graph privacy need to layer additional mechanisms such as zero-knowledge proofs or mixing; this ERC alone is not sufficient.
Key Management:
Risk: Loss or compromise of the private key corresponding to key results in loss of control. Store private keys securely.
Recommendation: Use safe systems to save key.
Gas Costs:
Issue: Signature verification and EIP-712 hashing increase gas costs.
Recommendation: Optimize implementations and consider gas sponsorship to offset costs.
Signature Malleability: Implementations MUST reject malleable signatures: s MUST be in the lower half of the curve order, and v MUST be 27 or 28. OpenZeppelin’s ECDSA helpers enforce these checks by default.