Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-7812: ZK Identity Registry

Singleton registry system for storing abstract private provable statements.

Authors Artem Chystiakov (@arvolear) <artem@rarilabs.com>, Oleksandr Kurbatov <oleksandr@rarilabs.com>, Yaroslav Panasenko <yaroslav@rarilabs.com>, Michael Elliot (@michaelelliot) <mike@zkpassport.id>, Vitalik Buterin (@vbuterin)
Created 2024-11-08
Discussion Link https://ethereum-magicians.org/t/erc-7812-zk-identity-registry/21624

Abstract

This EIP introduces an on-chain registry system for storing and proving abstract statements. Users may utilize the system to store commitments to their private data to later prove its validity and authenticity via zero knowledge, without disclosing anything about the data itself. Moreover, developers may use the singleton EvidenceRegistry contract available at 0x<TODO> to integrate custom business-specific registrars for managing and processing particular statements.

Motivation

This EIP stemmed from the need to localize and unravel the storage and issuance of provable statements so that future protocols can anchor to the standardized singleton on-chain registry and benefit from cross-reuse.

The aggregation of provable statements significantly improves reusability, portability, and security of the abundance of zero knowledge privacy-oriented solutions. The abstract specification of the registry allows custom indentity-based, reputation-based, proof-of-attendance-based, etc., protocols to be implemented with little to minimal constraints.

The given proposal lays the important foundation for specific solution to build upon. The more concrete specifications of statements and commitments structures are expected to emerge as separate, standalone EIPs.

Specification

The keywords “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.

Definitions

  • A “Sparse Merkle Tree (SMT)” is a special Merkle tree that works by deterministically and idempotently storing key/value pairs in the given locations leveraging a hash function. The Poseidon hash function is often used to optimize the compatibility with ZK.
  • A “statement” is an accepted structured representation of some abstract evidence. A statement can range from a simple string to a Merkle root of some SMT.
  • A “commitment” is a special public value resulting from blinding a statement to conceal it. Commitments allow the authenticity of a statement to be proven in ZK without disclosing the statement itself.
  • A “commitment key” is a private salt mixed with the statement to obtain a commitment to that statement. The commitment key must be kept private to maintain the confidentiality of statements.

General

The on-chain registry system consists of two subsystems: the EvidenceRegistry with EvidenceDB and Registrar components. This EIP will focus on describing and standardizing the former, while the Registrar specification may be amended as the separate proposals.

The on-chain evidence registry system entities diagram.

The on-chain evidence registry system entities diagram.

The EvidenceRegistry acts as the entrypoint to a protocol-wide provable database EvidenceDB where arbitrary 32-byte data can be written to and later proven on demand. The Registrar entities implement specific business use cases, structure the provable data, and utilize EvidenceRegistry to put this data in the EvidenceDB.

In order to prove that certain data is or is not present in the EvidenceDB Merkle proofs may be used. Understanding how a specific Registrar has structured and put data into the EvidenceDB, one may implement an on-chain ZK verifier (using Circom or any other stack) and prove the inclusion (or exclusion) of the data in the database.

The Circom implementation of a general-purpose SMT-driven EvidenceDB verifier circuit together with the Solidity implementation of EvidenceRegistry and EvidenceDB smart contracts may be found in the “Reference Implementation” section.

Evidence DB

The EvidenceDB smart contract MAY implement an arbitrary provable key/value data structure, however it MUST support the addition, update, and removal of elements. All of the supported write operations MUST maintain the property of idempotence (e.i. addition followed by removal should not change the state of the database). The data structure of choice MUST be capable of providing both element inclusion and exclusion proofs. The functions that modify the EvidenceDB state MUST be callable only by the EvidenceRegistry.

For reference, the EvidenceDB smart contract MAY implement the following interface:

pragma solidity ^0.8.0;

/**
 * @notice Evidence DB interface for Sparse Merkle Tree based statements database.
 */
interface IEvidenceDB {
    /**
     * @notice Represents the proof of a node's inclusion/exclusion in the tree.
     * @param root The root hash of the Merkle tree.
     * @param siblings An array of sibling hashes can be used to get the Merkle Root.
     * @param existence Indicates the presence (true) or absence (false) of the node.
     * @param key The key associated with the node.
     * @param value The value associated with the node.
     * @param auxExistence Indicates the presence (true) or absence (false) of an auxiliary node.
     * @param auxKey The key of the auxiliary node.
     * @param auxValue The value of the auxiliary node.
     */
    struct Proof {
        bytes32 root;
        bytes32[] siblings;
        bool existence;
        bytes32 key;
        bytes32 value;
        bool auxExistence;
        bytes32 auxKey;
        bytes32 auxValue;
    }
    
    /**
     * @notice Adds the new element to the tree.
     */
    function add(bytes32 key, bytes32 value) external;

    /**
     * @notice Removes the element from the tree.
     */
    function remove(bytes32 key) external;

    /**
     * @notice Updates the element in the tree.
     */
    function update(bytes32 key, bytes32 newValue) external;

    /**
     * @notice Gets the SMT root.
     * SHOULD NOT be used on-chain due to roots frontrunning.
     */
    function getRoot() external view returns (bytes32);

    /**
     * @notice Gets the number of nodes in the tree.
     */
    function getSize() external view returns (uint256);
    
    /**
     * @notice Gets the max tree height (number of branches in the Merkle proof)
     */
    function getMaxHeight() external view returns (uint256);

    /**
     * @notice Gets Merkle inclusion/exclusion proof of the element.
     */
    function getProof(bytes32 key) external view returns (Proof memory);

    /**
     * @notice Gets the element value by its key.
     */
    function getValue(bytes32 key) external view returns (bytes32);
}

Evidence Registry

The EvidenceRegistry smart contract is the central piece of this EIP. The EvidenceRegistry MUST implement the following interface, however, it MAY be extended:

pragma solidity ^0.8.0;

/**
 * @notice Common Evidence Registry interface.
 */
interface IEvidenceRegistry {
    /**
     * @notice MUST be emitted whenever the Merkle root is updated.
     */
    event RootUpdated(bytes32 indexed prev, bytes32 indexed curr);

    /**
     * @notice Adds the new statement to the DB.
     */
    function addStatement(bytes32 key, bytes32 value) external;

    /**
     * @notice Removes the statement from the DB.
     */
    function removeStatement(bytes32 key) external;

    /**
     * @notice Updates the statement in the DB.
     */
    function updateStatement(bytes32 key, bytes32 newValue) external;

    /**
     * @notice Retrieves historical DB roots creation timestamps.
     * Latest root MUST return `block.timestamp`.
     * Non-existent root MUST return `0`.
     */
    function getRootTimestamp(bytes32 root) external view returns (uint256);
}

The addStatement, removeStatement, and updateStatement methods MUST isolate the statement key in order for the database to allocate a specific namespace for a caller. These methods MUST revert in case the isolated key being added already exists in the EvidenceDB or the isolated key being removed or updated does not.

The EvidenceRegistry MUST maintain the linear history of EvidenceDB roots. The getRootTimestamp method MUST NOT revert. Instead, it MUST return 0 in case the queried root does not exist. The method MUST return block.timestamp in case the latest root is requested.

Before communicating with the EvidenceDB, the key MUST be isolated in the following way:

bytes32 isolatedKey = hash(msg.sender, key)

Where the hash is secure protocol-wide hash function of choice.

Hash Function

The same secure hash function MUST be employed in both EvidenceRegistry and EvidenceDB. It is RECOMMENDED to use ZK-friendly hash function such as poseidon to streamline the database proving.

In case ZK-friendly hash function is chosen, EvidenceRegistry MUST NOT accept keys or values beyond the underlying elliptic curve prime field size (21888242871839275222246405745257275088548364400416034343698204186575808495617 for BN128).

Rationale

During the EIP specification we have considered two approaches: where every protocol has its own registry and where all protocols are united under a singleton registry. We have decided to go with the latter as this approach provides the following benefits:

  1. Cross-chain portability. Only a single bytes32 value (the SMT root) has to be sent cross-chain to be able to prove the state of the registry.
  2. Centralization of trust. Users only need to trust a single, permissionaless, immutable smart contract.
  3. Integration streamline. The singleton design formalizes the system interface, the hash function, and the overall proofs structure to simplify the integration.

The proposal is deliberately written as abstract as possible to not constrain the possible business use cases and allow Registrars to implement arbitrary provable solutions.

It is expected that based on this work future EIPs will describe concrete registrars with the exact procedures of generation of commitments, management of commitment keys, and proving of operated statements. For instance, there may be a registrar for on-chain accounting of national passports, a registrar with EIP-4337 confidential account identity management, a registrar for POAPs, etc.

The EvidenceDB namespacing is chosen to segregate the write access to the database cells, ensuring that no entity but issuer can alter their content. However, this decision delegates the access control management responsibility solely to registrars, an important aspect to be considered during their development.

The EvidenceRegistry maintains the minimal viable (gas-wise) history of roots on-chain for smooth registrars integration. In case more elaborate history is required, it is RECOMMENDED to implement off-chain services for parsing of RootUpdated events.

Backwards Compatibility

This EIP is fully backwards compatible.

Deployment Method

The EvidenceRegistry is a singleton contract available at 0x<TODO> deployed via the “deterministic deployment proxy” from 0x4e59b44847b379578588920ca78fbf26c0b4956c with the salt 0x<TODO so that the ER address starts with the EIP number> .

Reference Implementation

The reference implementation of EvidenceRegistry and EvidenceDB Solidity smart contracts together with the evidence registry state verifier Circom circuit is provided in the proposal.

The low-level Solidity and Circom implementations of SMT can be found here and here.

The height of the SMT is set to 80.

Please note that the reference implementation depends on the @openzeppelin/contracts v5.1.0 and circomlib v2.0.5.

EvidenceDB Implementation

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.21;

import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

import {IEvidenceDB} from "./interfaces/IEvidenceDB.sol";

import {SparseMerkleTree} from "./libraries/SparseMerkleTree.sol";
import {PoseidonUnit2L, PoseidonUnit3L} from "./libraries/Poseidon.sol";

contract EvidenceDB is IEvidenceDB, Initializable {
    using SparseMerkleTree for SparseMerkleTree.SMT;

    address private _evidenceRegistry;

    SparseMerkleTree.SMT private _tree;

    modifier onlyEvidenceRegistry() {
        _requireEvidenceRegistry();
        _;
    }

    function __EvidenceDB_init(address evidenceRegistry_, uint32 maxDepth_) external initializer {
        _evidenceRegistry = evidenceRegistry_;

        _tree.initialize(maxDepth_);

        _tree.setHashers(_hash2, _hash3);
    }

    /**
     * @inheritdoc IEvidenceDB
     */
    function add(bytes32 key_, bytes32 value_) external onlyEvidenceRegistry {
        _tree.add(key_, value_);
    }

    /**
     * @inheritdoc IEvidenceDB
     */
    function remove(bytes32 key_) external onlyEvidenceRegistry {
        _tree.remove(key_);
    }

    /**
     * @inheritdoc IEvidenceDB
     */
    function update(bytes32 key_, bytes32 newValue_) external onlyEvidenceRegistry {
        _tree.update(key_, newValue_);
    }

    /**
     * @inheritdoc IEvidenceDB
     */
    function getRoot() external view returns (bytes32) {
        return _tree.getRoot();
    }

    /**
     * @inheritdoc IEvidenceDB
     */
    function getSize() external view returns (uint256) {
        return _tree.getNodesCount();
    }

    /**
     * @inheritdoc IEvidenceDB
     */
    function getMaxHeight() external view returns (uint256) {
        return _tree.getMaxDepth();
    }

    /**
     * @inheritdoc IEvidenceDB
     */
    function getProof(bytes32 key_) external view returns (Proof memory) {
        return _tree.getProof(key_);
    }

    /**
     * @inheritdoc IEvidenceDB
     */
    function getValue(bytes32 key_) external view returns (bytes32) {
        return _tree.getNodeByKey(key_).value;
    }

    /**
     * @notice Returns the address of the Evidence Registry.
     */
    function getEvidenceRegistry() external view returns (address) {
        return _evidenceRegistry;
    }

    function _requireEvidenceRegistry() private view {
        if (_evidenceRegistry != msg.sender) {
            revert NotFromEvidenceRegistry(msg.sender);
        }
    }

    function _hash2(bytes32 element1_, bytes32 element2_) private pure returns (bytes32) {
        return PoseidonUnit2L.poseidon([element1_, element2_]);
    }

    function _hash3(
        bytes32 element1_,
        bytes32 element2_,
        bytes32 element3_
    ) private pure returns (bytes32) {
        return PoseidonUnit3L.poseidon([element1_, element2_, element3_]);
    }
}

EvidenceRegistry Implementation

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.21;

import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

import {IEvidenceDB} from "./interfaces/IEvidenceDB.sol";
import {IEvidenceRegistry} from "./interfaces/IEvidenceRegistry.sol";

import {PoseidonUnit2L} from "./libraries/Poseidon.sol";

contract EvidenceRegistry is IEvidenceRegistry, Initializable {
    uint256 public constant BABY_JUB_JUB_PRIME_FIELD =
        21888242871839275222246405745257275088548364400416034343698204186575808495617;

    IEvidenceDB private _evidenceDB;

    mapping(bytes32 => uint256) private _rootTimestamps;

    modifier onlyInPrimeField(bytes32 key) {
        _requireInPrimeField(key);
        _;
    }

    modifier onRootUpdate() {
        bytes32 prevRoot_ = _evidenceDB.getRoot();
        _rootTimestamps[prevRoot_] = block.timestamp;
        _;
        emit RootUpdated(prevRoot_, _evidenceDB.getRoot());
    }

    function __EvidenceRegistry_init(address evidenceDB_) external initializer {
        _evidenceDB = IEvidenceDB(evidenceDB_);
    }

    /**
     * @inheritdoc IEvidenceRegistry
     */
    function addStatement(
        bytes32 key_,
        bytes32 value_
    ) external onlyInPrimeField(key_) onlyInPrimeField(value_) onRootUpdate {
        bytes32 isolatedKey_ = _getIsolatedKey(key_);

        if (_evidenceDB.getValue(isolatedKey_) != bytes32(0)) {
            revert KeyAlreadyExists(key_);
        }

        _evidenceDB.add(isolatedKey_, value_);
    }

    /**
     * @inheritdoc IEvidenceRegistry
     */
    function removeStatement(bytes32 key_) external onlyInPrimeField(key_) onRootUpdate {
        bytes32 isolatedKey_ = _getIsolatedKey(key_);

        if (_evidenceDB.getValue(isolatedKey_) == bytes32(0)) {
            revert KeyDoesNotExist(key_);
        }

        _evidenceDB.remove(isolatedKey_);
    }

    /**
     * @inheritdoc IEvidenceRegistry
     */
    function updateStatement(
        bytes32 key_,
        bytes32 newValue_
    ) external onlyInPrimeField(key_) onlyInPrimeField(newValue_) onRootUpdate {
        bytes32 isolatedKey_ = _getIsolatedKey(key_);

        if (_evidenceDB.getValue(isolatedKey_) == bytes32(0)) {
            revert KeyDoesNotExist(key_);
        }

        _evidenceDB.update(isolatedKey_, newValue_);
    }

    /**
     * @inheritdoc IEvidenceRegistry
     */
    function getRootTimestamp(bytes32 root_) external view returns (uint256) {
        if (root_ == bytes32(0)) {
            return 0;
        }

        if (root_ == _evidenceDB.getRoot()) {
            return block.timestamp;
        }

        return _rootTimestamps[root_];
    }

    function getEvidenceDB() external view returns (address) {
        return address(_evidenceDB);
    }

    function _getIsolatedKey(bytes32 key_) internal view returns (bytes32) {
        return PoseidonUnit2L.poseidon([bytes32(uint256(uint160(msg.sender))), key_]);
    }

    function _requireInPrimeField(bytes32 key_) private pure {
        if (uint256(key_) >= BABY_JUB_JUB_PRIME_FIELD) {
            revert NumberNotInPrimeField(key_);
        }
    }
}

EvidenceRegistry Verifier Implementation

// LICENSE: CC0-1.0
pragma circom 2.1.9;

include "SparseMerkleTree.circom";

template BuildIsolatedKey() {
    signal output isolatedKey;

    signal input address;
    signal input key;

    component hasher = Poseidon(2);
    hasher.inputs[0] <== address;
    hasher.inputs[1] <== key;

    hasher.out ==> isolatedKey;
}

template EvidenceRegistrySMT(levels) {
    // Public Inputs
    signal input root;

    // Private Inputs
    signal input address;
    signal input key;

    signal input value;

    signal input siblings[levels];

    signal input auxKey;
    signal input auxValue;
    signal input auxIsEmpty;

    signal input isExclusion;

    // Build isolated key
    component isolatedKey = BuildIsolatedKey();
    isolatedKey.address <== address;
    isolatedKey.key <== key;

    // Verify Sparse Merkle Tree Proof
    component smtVerifier = SparseMerkleTree(levels);
    smtVerifier.siblings <== siblings;

    smtVerifier.key <== isolatedKey.isolatedKey;
    smtVerifier.value <== value;

    smtVerifier.auxKey <== auxKey;
    smtVerifier.auxValue <== auxValue;
    smtVerifier.auxIsEmpty <== auxIsEmpty;

    smtVerifier.isExclusion <== isExclusion;

    smtVerifier.root <== root;
}

component main {public [root]} = EvidenceRegistrySMT(80);

Security Considerations

From security standpoint there are several important aspects that must be highlighted.

The individual registrars are expected to provide the functionality for both management and proving of statements. The proving will often be carried out by ZK proofs, which require trusted setup. Improperly setup ZK verifiers can be exploited to verify forged proofs.

The getRoot method of EvidenceDB SHOULD NOT be used on-chain by the integrating registrars to check the validity of the database state. Instead, the required root SHOULD be passed as a function parameter and checked via getRootTimestamp method to avoid being frontrun.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Artem Chystiakov (@arvolear) <artem@rarilabs.com>, Oleksandr Kurbatov <oleksandr@rarilabs.com>, Yaroslav Panasenko <yaroslav@rarilabs.com>, Michael Elliot (@michaelelliot) <mike@zkpassport.id>, Vitalik Buterin (@vbuterin), "ERC-7812: ZK Identity Registry [DRAFT]," Ethereum Improvement Proposals, no. 7812, November 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7812.