// SPDX-License-Identifier: MIT pragma solidity 0.8.24; /** * @title SuperWalletTrustRegistry * @notice Public, append-mostly record that lets anyone check a Super Wallet credential without * trusting the Super Wallet servers: who the issuer is (and which key it signs with), that a * credential was anchored and has not changed since (its canonical digest), whether it has * been revoked, which zero-knowledge commitments are bound to it, and which verifier contract * checks each kind of proof. * @dev Nothing personal is ever stored here. The only values are hashes (issuer ids, key * fingerprints, credential ids, credential digests, verification-key hashes), ZK commitments * (field elements that reveal nothing without their salt), timestamps, small reason codes and * public DIDs / URIs of issuers and circuits. * * Roles: * - owner (governance): registers and suspends issuers, manages each issuer's operators, * registers circuits. Two-step ownership transfer so a mistyped address cannot take control. * - operator of an issuer: an address allowed to anchor, revoke and commit for that issuer * (for example the issuer's backend wallet). An operator acts only for issuers it was added to. * * Identifiers (conventions the off-chain tooling follows; the contract treats them as opaque): * - issuerId = keccak256(utf8(issuer DID)) * - credentialId = keccak256(utf8(vc.id)) * - digest = sha256(JCS(vc without its proof)) (RFC 8785 JSON Canonicalization Scheme) * - circuitId = keccak256(utf8(circuit name)), e.g. keccak256("tier-threshold-v1") */ contract SuperWalletTrustRegistry { // --------------------------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------------------------- /// @notice Issuer status values returned by {issuer}. uint8 public constant ISSUER_NONE = 0; uint8 public constant ISSUER_ACTIVE = 1; uint8 public constant ISSUER_SUSPENDED = 2; /// @notice Largest number of credentials {anchorBatch} accepts in one call. uint256 public constant MAX_BATCH = 100; struct Issuer { bytes32 keyFingerprint; uint8 status; uint64 since; string did; } struct Credential { bytes32 digest; bytes32 issuerId; uint64 anchoredAt; uint64 revokedAt; uint8 reason; } struct Circuit { address verifier; uint16 contributions; bytes32 vkHash; string uri; } // --------------------------------------------------------------------------------------------- // Storage // --------------------------------------------------------------------------------------------- /// @notice Current governance address. address public owner; /// @notice Address that may accept ownership; zero when no transfer is pending. address public pendingOwner; mapping(bytes32 => Issuer) private _issuers; mapping(bytes32 => mapping(address => bool)) private _operators; mapping(bytes32 => Credential) private _credentials; mapping(bytes32 => mapping(bytes32 => uint256)) private _commitments; mapping(bytes32 => Circuit) private _circuits; // --------------------------------------------------------------------------------------------- // Events // --------------------------------------------------------------------------------------------- event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); event IssuerRegistered(bytes32 indexed issuerId, bytes32 keyFingerprint, string did); event IssuerSuspended(bytes32 indexed issuerId); event OperatorAdded(bytes32 indexed issuerId, address indexed operator); event OperatorRemoved(bytes32 indexed issuerId, address indexed operator); event Anchored(bytes32 indexed credentialId, bytes32 indexed issuerId, bytes32 digest); event Revoked(bytes32 indexed credentialId, bytes32 indexed issuerId, uint8 reason); event Committed(bytes32 indexed credentialId, bytes32 indexed circuitId, uint256 commitment); event CircuitRegistered( bytes32 indexed circuitId, address verifier, bytes32 vkHash, uint16 ceremonyContributions, string uri ); // --------------------------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------------------------- error NotOwner(); error NotPendingOwner(); error ZeroValue(); error IssuerNotActive(bytes32 issuerId); error IssuerUnknown(bytes32 issuerId); error NotIssuerOperator(bytes32 issuerId, address caller); error OperatorAlreadyAdded(bytes32 issuerId, address operator); error OperatorNotFound(bytes32 issuerId, address operator); error AlreadyAnchored(bytes32 credentialId); error NotAnchored(bytes32 credentialId); error AlreadyRevoked(bytes32 credentialId); error AlreadyCommitted(bytes32 credentialId, bytes32 circuitId); error CircuitUnknown(bytes32 circuitId); error CircuitAlreadyRegistered(bytes32 circuitId); error BatchLengthMismatch(); error BatchSize(uint256 size); // --------------------------------------------------------------------------------------------- // Modifiers // --------------------------------------------------------------------------------------------- modifier onlyGovernance() { if (msg.sender != owner) revert NotOwner(); _; } modifier onlyActiveIssuerOperator(bytes32 issuerId) { _requireActiveOperator(issuerId); _; } // --------------------------------------------------------------------------------------------- // Construction and governance // --------------------------------------------------------------------------------------------- /// @notice Deploys the registry with the deployer as governance. constructor() { owner = msg.sender; emit OwnershipTransferred(address(0), msg.sender); } /** * @notice Starts a two-step ownership transfer. The new owner must call {acceptOwnership}. * @dev Passing the zero address cancels a pending transfer. * @param newOwner Address that will be allowed to accept ownership. */ function transferOwnership(address newOwner) external onlyGovernance { pendingOwner = newOwner; emit OwnershipTransferStarted(owner, newOwner); } /// @notice Completes a transfer started by {transferOwnership}; callable only by the pending owner. function acceptOwnership() external { if (msg.sender != pendingOwner) revert NotPendingOwner(); address previous = owner; owner = msg.sender; pendingOwner = address(0); emit OwnershipTransferred(previous, msg.sender); } // --------------------------------------------------------------------------------------------- // Issuer registry // --------------------------------------------------------------------------------------------- /** * @notice Registers an issuer, or re-registers one to rotate its key or lift a suspension. * @dev Every registration sets status ACTIVE and `since` to the current block time, so `since` * always answers "since when does this key and status hold". * @param issuerId keccak256(utf8(issuer DID)). * @param keyFingerprint Hash of the issuer's current assertion public key * (Super Wallet uses the RFC 7638 SHA-256 JWK thumbprint). * @param did The issuer DID, stored so a reader can match it to the credential's `issuer`. */ function registerIssuer(bytes32 issuerId, bytes32 keyFingerprint, string calldata did) external onlyGovernance { if (issuerId == bytes32(0) || keyFingerprint == bytes32(0) || bytes(did).length == 0) { revert ZeroValue(); } Issuer storage i = _issuers[issuerId]; i.keyFingerprint = keyFingerprint; i.status = ISSUER_ACTIVE; i.since = uint64(block.timestamp); i.did = did; emit IssuerRegistered(issuerId, keyFingerprint, did); } /** * @notice Suspends an active issuer: its operators can no longer anchor or commit. * @dev Revocation of credentials it already anchored stays possible, so a suspended issuer can * still withdraw what it issued. * @param issuerId The issuer to suspend. */ function suspendIssuer(bytes32 issuerId) external onlyGovernance { Issuer storage i = _issuers[issuerId]; if (i.status != ISSUER_ACTIVE) revert IssuerNotActive(issuerId); i.status = ISSUER_SUSPENDED; i.since = uint64(block.timestamp); emit IssuerSuspended(issuerId); } /** * @notice Allows `operator` to anchor, revoke and commit on behalf of `issuerId`. * @param issuerId A registered issuer (active or suspended). * @param operator The address to authorise, typically the issuer's backend wallet. */ function addOperator(bytes32 issuerId, address operator) external onlyGovernance { if (operator == address(0)) revert ZeroValue(); if (_issuers[issuerId].status == ISSUER_NONE) revert IssuerUnknown(issuerId); if (_operators[issuerId][operator]) revert OperatorAlreadyAdded(issuerId, operator); _operators[issuerId][operator] = true; emit OperatorAdded(issuerId, operator); } /** * @notice Withdraws an operator's authority for one issuer. * @param issuerId The issuer the operator acted for. * @param operator The address to remove. */ function removeOperator(bytes32 issuerId, address operator) external onlyGovernance { if (!_operators[issuerId][operator]) revert OperatorNotFound(issuerId, operator); _operators[issuerId][operator] = false; emit OperatorRemoved(issuerId, operator); } /** * @notice Reads an issuer record. * @param issuerId keccak256(utf8(issuer DID)). * @return keyFingerprint Hash of the issuer's assertion public key. * @return did The issuer DID. * @return status 0 unknown, 1 active, 2 suspended. * @return since Block time of the last registration or suspension. */ function issuer(bytes32 issuerId) external view returns (bytes32 keyFingerprint, string memory did, uint8 status, uint64 since) { Issuer storage i = _issuers[issuerId]; return (i.keyFingerprint, i.did, i.status, i.since); } /** * @notice Whether `operator` may act for `issuerId`. * @param issuerId The issuer. * @param operator The address to check. * @return True when the operator was added and not removed. */ function isOperator(bytes32 issuerId, address operator) external view returns (bool) { return _operators[issuerId][operator]; } // --------------------------------------------------------------------------------------------- // Credential anchors and revocation // --------------------------------------------------------------------------------------------- /** * @notice Anchors one credential's canonical digest under an active issuer. * @dev Reverts when the id was already anchored: an anchor is written once and never replaced. * @param credentialId keccak256(utf8(vc.id)). * @param digest sha256(JCS(vc without proof)). * @param issuerId The issuer the caller operates for. */ function anchor(bytes32 credentialId, bytes32 digest, bytes32 issuerId) external onlyActiveIssuerOperator(issuerId) { _anchor(credentialId, digest, issuerId); } /** * @notice Anchors up to {MAX_BATCH} credentials in one transaction. * @dev All-or-nothing: if any id is zero or already anchored the whole batch reverts. * @param ids Credential ids. * @param digests Digests, same length and order as `ids`. * @param issuerId The issuer the caller operates for. */ function anchorBatch(bytes32[] calldata ids, bytes32[] calldata digests, bytes32 issuerId) external onlyActiveIssuerOperator(issuerId) { uint256 n = ids.length; if (n != digests.length) revert BatchLengthMismatch(); if (n == 0 || n > MAX_BATCH) revert BatchSize(n); for (uint256 k = 0; k < n; ) { _anchor(ids[k], digests[k], issuerId); unchecked { ++k; } } } /** * @notice Records that an anchored credential is revoked. * @dev Only an operator of the issuer that anchored the credential may revoke it, even while that * issuer is suspended. A revocation is final. * @param credentialId The credential to revoke. * @param reason Reason code (see the README for the values Super Wallet uses). */ function revoke(bytes32 credentialId, uint8 reason) external { Credential storage c = _credentials[credentialId]; if (c.anchoredAt == 0) revert NotAnchored(credentialId); if (!_operators[c.issuerId][msg.sender]) revert NotIssuerOperator(c.issuerId, msg.sender); if (c.revokedAt != 0) revert AlreadyRevoked(credentialId); c.revokedAt = uint64(block.timestamp); c.reason = reason; emit Revoked(credentialId, c.issuerId, reason); } /** * @notice Reads a credential anchor. * @param credentialId keccak256(utf8(vc.id)). * @return digest The anchored digest, zero when never anchored. * @return issuerId The issuer that anchored it. * @return anchoredAt Block time of the anchor, zero when never anchored. * @return revokedAt Block time of the revocation, zero when not revoked. * @return reason Revocation reason code, zero when not revoked. */ function credential(bytes32 credentialId) external view returns (bytes32 digest, bytes32 issuerId, uint64 anchoredAt, uint64 revokedAt, uint8 reason) { Credential storage c = _credentials[credentialId]; return (c.digest, c.issuerId, c.anchoredAt, c.revokedAt, c.reason); } // --------------------------------------------------------------------------------------------- // Zero-knowledge commitments // --------------------------------------------------------------------------------------------- /** * @notice Binds a ZK commitment to an anchored, unrevoked credential for one circuit. * @dev Only the anchoring issuer's operator, only while that issuer is active, only for a * registered circuit, and only once per (credential, circuit). * @param credentialId The credential the commitment belongs to. * @param circuitId The circuit whose proofs open this commitment. * @param commitmentValue The commitment (for example Poseidon(value, salt)); must be non-zero. */ function commit(bytes32 credentialId, bytes32 circuitId, uint256 commitmentValue) external { Credential storage c = _credentials[credentialId]; if (c.anchoredAt == 0) revert NotAnchored(credentialId); _requireActiveOperator(c.issuerId); if (c.revokedAt != 0) revert AlreadyRevoked(credentialId); if (_circuits[circuitId].verifier == address(0)) revert CircuitUnknown(circuitId); if (commitmentValue == 0) revert ZeroValue(); if (_commitments[credentialId][circuitId] != 0) revert AlreadyCommitted(credentialId, circuitId); _commitments[credentialId][circuitId] = commitmentValue; emit Committed(credentialId, circuitId, commitmentValue); } /** * @notice Reads the commitment bound to a credential for a circuit. * @dev A commitment of a revoked credential is still returned; check {credential} for revocation. * @param credentialId The credential. * @param circuitId The circuit. * @return The commitment, zero when none was bound. */ function commitment(bytes32 credentialId, bytes32 circuitId) external view returns (uint256) { return _commitments[credentialId][circuitId]; } // --------------------------------------------------------------------------------------------- // Circuit registry // --------------------------------------------------------------------------------------------- /** * @notice Registers a circuit: the contract that verifies its proofs and the key it was built with. * @dev A circuit id is registered once; a new key means a new circuit id, so commitments made for * one key are never silently reinterpreted under another. * @param circuitId keccak256(utf8(circuit name)). * @param verifier The verifier contract address. * @param vkHash Hash of the verification key the verifier was generated from. * @param ceremonyContributions Number of independent contributions to the key ceremony. * @param uri Where the circuit source, key and ceremony transcript are published. */ function registerCircuit( bytes32 circuitId, address verifier, bytes32 vkHash, uint16 ceremonyContributions, string calldata uri ) external onlyGovernance { if (circuitId == bytes32(0) || verifier == address(0) || vkHash == bytes32(0)) revert ZeroValue(); if (_circuits[circuitId].verifier != address(0)) revert CircuitAlreadyRegistered(circuitId); _circuits[circuitId] = Circuit(verifier, ceremonyContributions, vkHash, uri); emit CircuitRegistered(circuitId, verifier, vkHash, ceremonyContributions, uri); } /** * @notice Reads a circuit record. * @param circuitId keccak256(utf8(circuit name)). * @return verifier The verifier contract, zero when unknown. * @return vkHash Hash of the verification key. * @return contributions Number of key-ceremony contributions. * @return uri Where the circuit artefacts are published. */ function circuit(bytes32 circuitId) external view returns (address verifier, bytes32 vkHash, uint16 contributions, string memory uri) { Circuit storage c = _circuits[circuitId]; return (c.verifier, c.vkHash, c.contributions, c.uri); } // --------------------------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------------------------- function _requireActiveOperator(bytes32 issuerId) private view { if (_issuers[issuerId].status != ISSUER_ACTIVE) revert IssuerNotActive(issuerId); if (!_operators[issuerId][msg.sender]) revert NotIssuerOperator(issuerId, msg.sender); } function _anchor(bytes32 credentialId, bytes32 digest, bytes32 issuerId) private { if (credentialId == bytes32(0) || digest == bytes32(0)) revert ZeroValue(); Credential storage c = _credentials[credentialId]; if (c.anchoredAt != 0) revert AlreadyAnchored(credentialId); c.digest = digest; c.issuerId = issuerId; c.anchoredAt = uint64(block.timestamp); emit Anchored(credentialId, issuerId, digest); } }