Source file

ZkRepRegistry.sol

On-chain registry that verifies zkrep proofs against the published reputation root.

contracts/src/zkrep/ZkRepRegistry.sol422 linessolidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {IKaratumCertRegistry} from "../guard/interfaces/IKaratumCertRegistry.sol";

/// @notice The Groth16 verifier generated by snarkjs for circuits/karatum_rep.circom (8 public signals).
interface IKaratumRepVerifier {
    function verifyProof(
        uint256[2] calldata pA,
        uint256[2][2] calldata pB,
        uint256[2] calldata pC,
        uint256[8] calldata pubSignals
    ) external view returns (bool);
}

/// @title ZkRepRegistry
/// @notice Zero-knowledge reputation for AI agents. Karatum publishes, daily and on every recall, the Poseidon root of
///         its reputation tree: one leaf per certified agent and task class,
///             leaf = Poseidon(idCommitment, taskClass, tier, K, expiresAt, VALID)
///         where idCommitment = Poseidon(secret) and only the agent knows `secret`. An agent then proves with Groth16
///         "I hold a VALID Karatum certificate for task class X with tier >= t and K >= k that expires after now",
///         without revealing which agent it is, its exact tier, K or expiry. Each proof carries a nullifier
///         Poseidon(domain, secret, scope): the same agent always gets the same nullifier in one scope (one proof per
///         agent per scope: anti-sybil) and unlinkable nullifiers across scopes.
///
///         Freshness: only the CURRENT root is accepted, plus the previous one for `graceSeconds` after a routine
///         publication. A recall publishes with `immediate = true`, which ends the grace at once: from that block a
///         recalled agent (whose leaf the new root no longer contains) cannot produce an acceptable proof.
///
///         Guard: the contract also implements IKaratumCertRegistry for PSEUDONYMOUS executors. An agent proves once
///         per root, binding the proof to a fresh executor address (`messageHash = executor`); a KaratumSafeGuard (or
///         KaratumGuard7579) deployed with this contract as its registry then gates the Safe on that executor exactly as
///         it gates a public agent on KaratumCertRegistry, with the PROVEN lower bounds as tier and K. One executor per
///         agent at a time (re-proving with a new executor retires the old one).
/// @dev No upgradeability, no ETH. The owner (Ownable2Step) rotates the attester and tunes freshness; it cannot add
///      roots or accept proofs. Public signal order (snarkjs: outputs first):
///      [nullifier, root, taskClass, minTier, minKarats, nowTs, scope, messageHash].
contract ZkRepRegistry is IKaratumCertRegistry, Ownable2Step {
    /// @notice BN254 scalar field. Every public signal must be below it.
    uint256 public constant SNARK_FIELD =
        21_888_242_871_839_275_222_246_405_745_257_275_088_548_364_400_416_034_343_698_204_186_575_808_495_617;
    uint64 public constant MAX_ROOT_TTL_CAP = 30 days;
    uint64 public constant MAX_GRACE_CAP = 1 days;
    uint64 public constant MAX_PROOF_AGE_CAP = 1 days;
    /// @notice A proof may claim a `nowTs` at most this far ahead of the block (clock skew of the prover).
    uint64 public constant MAX_FUTURE_SKEW = 5 minutes;
    /// @notice App scope of the Guard path: scopeOf(address(this), GUARD_APP_SCOPE).
    bytes32 public constant GUARD_APP_SCOPE = keccak256("karatum.zkrep.guard.v1");
    /// @notice Gas forwarded to the Groth16 verifier.
    uint256 public constant VERIFY_GAS = 400_000;

    enum RootState {
        UNKNOWN,
        CURRENT,
        GRACE,
        SUPERSEDED,
        REVOKED,
        EXPIRED
    }

    /// @notice Why `check` refuses a proof. OK = acceptable.
    enum Check {
        OK,
        ROOT_NOT_ACCEPTED,
        PROOF_STALE,
        PROOF_FROM_FUTURE,
        BAD_SIGNAL,
        INVALID_PROOF
    }

    struct RootInfo {
        uint64 epoch;
        uint64 publishedAt;
        uint64 validUntil;
        uint32 leafCount;
        bool immediate;
        bool revoked;
    }

    struct Proof {
        uint256[2] a;
        uint256[2][2] b;
        uint256[2] c;
    }

    /// @notice The public signals of one proof, in circuit order.
    struct Statement {
        uint256 nullifier;
        uint256 root;
        uint256 taskClass;
        uint256 minTier;
        uint256 minKarats;
        uint256 nowTs;
        uint256 scope;
        uint256 messageHash;
    }

    /// @dev One slot: what a pseudonymous executor proved, and against which root.
    struct ZkCert {
        uint8 tier;
        uint8 karats;
        uint64 epoch;
        uint64 provenAt;
    }

    // forge-lint: disable-next-line(screaming-snake-case-immutable)
    IKaratumRepVerifier public immutable verifier;

    address public attester;
    uint64 public currentEpoch;
    /// @notice The previous root stays acceptable this long after a routine (non-immediate) publication.
    uint64 public graceSeconds;
    uint64 public maxRootTtl;
    /// @notice A proof's `nowTs` may lag the block by at most this much.
    uint64 public maxProofAge;
    /// @notice End of the previous root's grace window (0 after an immediate publication).
    uint64 public graceEndsAt;

    mapping(uint64 epoch => bytes32) public rootOf;
    mapping(bytes32 root => RootInfo) internal _roots;
    mapping(uint256 scope => mapping(uint256 nullifier => bool)) public nullifierUsed;

    mapping(address executor => mapping(bytes32 taskType => ZkCert)) internal _zk;
    /// @notice Guard path: the executor each agent (nullifier in the Guard scope) currently uses.
    mapping(uint256 nullifier => address) public executorOf;
    mapping(address executor => uint256) public nullifierOfExecutor;
    /// @notice The agent (nullifier in the Guard scope) that proved `_zk[executor][taskType]`. A certificate counts only
    ///         while that agent still holds the executor, so a retired executor's old certificates never revive when
    ///         another agent claims the address.
    mapping(address executor => mapping(bytes32 taskType => uint256 nullifier)) public zkCertNullifier;

    event AttesterSet(address indexed attester);
    event FreshnessSet(uint64 graceSeconds, uint64 maxRootTtl, uint64 maxProofAge);
    event RootPublished(
        uint64 indexed epoch, bytes32 indexed root, uint64 validUntil, uint32 leafCount, bool immediate, string uri
    );
    event RootRevoked(uint64 indexed epoch, bytes32 indexed root);
    event ProofAccepted(
        uint256 indexed scope,
        uint256 indexed nullifier,
        uint64 indexed epoch,
        address consumer,
        bytes32 appScope,
        uint256 taskClass,
        uint8 minTier,
        uint8 minKarats,
        uint256 messageHash
    );
    event GuardProven(
        address indexed executor,
        bytes32 indexed taskType,
        uint64 indexed epoch,
        uint8 tier,
        uint8 karats,
        uint256 nullifier
    );
    event GuardExecutorRetired(address indexed executor, uint256 indexed nullifier);

    error RenounceDisabled();
    error NotAttester();
    error ZeroAddress();
    error BadFreshness();
    error BadRoot();
    error RootExists(bytes32 root);
    error BadValidity();
    error UnknownEpoch(uint64 epoch);
    error ProofRejected(Check reason);
    error ScopeMismatch(uint256 expected, uint256 got);
    error NullifierUsed(uint256 scope, uint256 nullifier);
    error TaskTypeMismatch();
    error ExecutorMismatch();

    modifier onlyAttester() {
        if (msg.sender != attester) revert NotAttester();
        _;
    }

    constructor(
        address owner_,
        address attester_,
        IKaratumRepVerifier verifier_,
        uint64 graceSeconds_,
        uint64 maxRootTtl_,
        uint64 maxProofAge_
    ) Ownable(owner_) {
        if (address(verifier_) == address(0)) revert ZeroAddress();
        verifier = verifier_;
        _setAttester(attester_);
        _setFreshness(graceSeconds_, maxRootTtl_, maxProofAge_);
    }

    // ------------------------------------------------------------------ owner

    function setAttester(address attester_) external onlyOwner {
        _setAttester(attester_);
    }

    /// @notice Disabled: the registry must always have an owner able to rotate the attester.
    function renounceOwnership() public view override onlyOwner {
        revert RenounceDisabled();
    }

    function setFreshness(uint64 graceSeconds_, uint64 maxRootTtl_, uint64 maxProofAge_) external onlyOwner {
        _setFreshness(graceSeconds_, maxRootTtl_, maxProofAge_);
    }

    // ------------------------------------------------------------------ attester

    /// @notice Publish the root of epoch `currentEpoch + 1`. `immediate` (recall): the previous root stops counting now;
    ///         otherwise it keeps counting for `graceSeconds` so in-flight proofs survive a routine daily rotation.
    function publishRoot(bytes32 root, uint64 validUntil, uint32 leafCount, bool immediate, string calldata uri)
        external
        onlyAttester
        returns (uint64 epoch)
    {
        if (root == bytes32(0) || uint256(root) >= SNARK_FIELD) revert BadRoot();
        if (_roots[root].epoch != 0) revert RootExists(root);
        if (validUntil <= block.timestamp || validUntil > block.timestamp + maxRootTtl) revert BadValidity();
        epoch = ++currentEpoch;
        rootOf[epoch] = root;
        _roots[root] = RootInfo({
            epoch: epoch,
            publishedAt: uint64(block.timestamp),
            validUntil: validUntil,
            leafCount: leafCount,
            immediate: immediate,
            revoked: false
        });
        graceEndsAt = immediate ? 0 : uint64(block.timestamp) + graceSeconds;
        emit RootPublished(epoch, root, validUntil, leafCount, immediate, uri);
    }

    /// @notice Kill a bad root: no proof against it is accepted any more, and Guard executors proven on it lapse.
    function revokeRoot(uint64 epoch) external onlyAttester {
        bytes32 root = rootOf[epoch];
        if (root == bytes32(0)) revert UnknownEpoch(epoch);
        _roots[root].revoked = true;
        emit RootRevoked(epoch, root);
    }

    // ------------------------------------------------------------------ views

    function rootInfo(bytes32 root) external view returns (RootInfo memory) {
        return _roots[root];
    }

    function latestRoot() external view returns (bytes32 root, RootInfo memory info) {
        root = rootOf[currentEpoch];
        info = _roots[root];
    }

    /// @notice CURRENT and GRACE roots are acceptable; everything else is not.
    function rootState(bytes32 root) public view returns (RootState) {
        RootInfo storage r = _roots[root];
        if (r.epoch == 0) return RootState.UNKNOWN;
        if (r.revoked) return RootState.REVOKED;
        if (block.timestamp > _roots[rootOf[currentEpoch]].validUntil) return RootState.EXPIRED;
        if (r.epoch == currentEpoch) return RootState.CURRENT;
        if (r.epoch + 1 == currentEpoch && block.timestamp <= graceEndsAt) return RootState.GRACE;
        return RootState.SUPERSEDED;
    }

    function isAcceptableRoot(bytes32 root) public view returns (bool) {
        RootState s = rootState(root);
        return s == RootState.CURRENT || s == RootState.GRACE;
    }

    /// @notice The nullifier scope of `consumer` (the caller of `accept`) for its own `appScope`. Bound to this chain
    ///         and registry, so a proof made for one application can never be replayed into another.
    function scopeOf(address consumer, bytes32 appScope) public view returns (uint256) {
        return uint256(keccak256(abi.encode(block.chainid, address(this), consumer, appScope))) >> 8;
    }

    /// @notice Circuit encoding of a Guard task type keccak256("karatum.task.<class>"): the top 252 bits.
    function taskClassField(bytes32 taskType) public pure returns (uint256) {
        return uint256(taskType) >> 4;
    }

    /// @notice Full check of a proof without state: root freshness, proof time, signal ranges, Groth16 validity.
    function check(Proof calldata p, Statement calldata s) public view returns (Check) {
        if (!isAcceptableRoot(bytes32(s.root))) return Check.ROOT_NOT_ACCEPTED;
        if (s.nowTs > block.timestamp + MAX_FUTURE_SKEW) return Check.PROOF_FROM_FUTURE;
        if (s.nowTs + maxProofAge < block.timestamp) return Check.PROOF_STALE;
        if (s.minTier > 3 || s.minKarats > 24) return Check.BAD_SIGNAL;
        uint256[8] memory pub =
            [s.nullifier, s.root, s.taskClass, s.minTier, s.minKarats, s.nowTs, s.scope, s.messageHash];
        for (uint256 i; i < 8; ++i) {
            if (pub[i] >= SNARK_FIELD) return Check.BAD_SIGNAL;
        }
        // The generated verifier returns false on an invalid proof, but an off-curve point makes the pairing precompile
        // burn all gas it is given: cap it (a valid proof needs ~210k) so a junk proof cannot drain the caller.
        try verifier.verifyProof{gas: VERIFY_GAS}(p.a, p.b, p.c, pub) returns (bool ok) {
            return ok ? Check.OK : Check.INVALID_PROOF;
        } catch {
            return Check.INVALID_PROOF;
        }
    }

    function verify(Proof calldata p, Statement calldata s) external view returns (bool) {
        return check(p, s) == Check.OK;
    }

    // ------------------------------------------------------------------ anyone: accept a proof in a scope

    /// @notice Accept a proof in the caller's scope `scopeOf(msg.sender, appScope)`: valid, fresh, and the first proof
    ///         of this agent in this scope (anti-sybil). Applications call this from their own contract; an EOA call
    ///         gets the EOA's own scope.
    function accept(Proof calldata p, Statement calldata s, bytes32 appScope) external returns (uint64 epoch) {
        uint256 scope = scopeOf(msg.sender, appScope);
        if (s.scope != scope) revert ScopeMismatch(scope, s.scope);
        Check c = check(p, s);
        if (c != Check.OK) revert ProofRejected(c);
        if (nullifierUsed[scope][s.nullifier]) revert NullifierUsed(scope, s.nullifier);
        nullifierUsed[scope][s.nullifier] = true;
        epoch = _roots[bytes32(s.root)].epoch;
        emit ProofAccepted(
            scope,
            s.nullifier,
            epoch,
            msg.sender,
            appScope,
            s.taskClass,
            uint8(s.minTier),
            uint8(s.minKarats),
            s.messageHash
        );
    }

    // ------------------------------------------------------------------ anyone: Guard path (pseudonymous executor)

    /// @notice Register `executor` as a pseudonymous certified agent for `taskType`, at the proven lower bounds. The
    ///         proof must be bound to the executor (`messageHash == uint160(executor)`) and made for the Guard scope,
    ///         so a relayer can submit it but cannot redirect it. Permissionless.
    function proveForGuard(Proof calldata p, Statement calldata s, bytes32 taskType, address executor) external {
        if (executor == address(0)) revert ZeroAddress();
        if (s.messageHash != uint256(uint160(executor))) revert ExecutorMismatch();
        if (s.taskClass != taskClassField(taskType)) revert TaskTypeMismatch();
        uint256 scope = scopeOf(address(this), GUARD_APP_SCOPE);
        if (s.scope != scope) revert ScopeMismatch(scope, s.scope);
        Check c = check(p, s);
        if (c != Check.OK) revert ProofRejected(c);

        // One executor per agent: a new executor retires the previous one (all of its task types lapse).
        address prev = executorOf[s.nullifier];
        if (prev != address(0) && prev != executor) {
            nullifierOfExecutor[prev] = 0;
            emit GuardExecutorRetired(prev, s.nullifier);
        }
        // An executor belongs to one agent: it cannot be claimed by a second nullifier while the first holds it.
        uint256 holder = nullifierOfExecutor[executor];
        if (holder != 0 && holder != s.nullifier && executorOf[holder] == executor) revert ExecutorMismatch();
        executorOf[s.nullifier] = executor;
        nullifierOfExecutor[executor] = s.nullifier;

        uint64 epoch = _roots[bytes32(s.root)].epoch;
        _zk[executor][taskType] = ZkCert({
            tier: uint8(s.minTier), karats: uint8(s.minKarats), epoch: epoch, provenAt: uint64(block.timestamp)
        });
        zkCertNullifier[executor][taskType] = s.nullifier;
        emit GuardProven(executor, taskType, epoch, uint8(s.minTier), uint8(s.minKarats), s.nullifier);
    }

    function zkCertOf(address executor, bytes32 taskType) external view returns (ZkCert memory) {
        return _zk[executor][taskType];
    }

    /// @inheritdoc IKaratumCertRegistry
    /// @dev VALID while the root the executor proved against is still acceptable (CURRENT, or GRACE after a routine
    ///      publication), the executor is still its agent's active one, and that agent is the one that proved this task
    ///      type (a certificate proven by a previous holder of the address is REVOKED). `expiresAt` is the latest root's validUntil
    ///      (the certificate's own expiry stays hidden; the proof showed it is later than the proof time).
    function status(address agent, bytes32 taskType)
        public
        view
        returns (Status s, uint8 tier, uint8 karats, uint64 expiresAt, uint64 epoch)
    {
        ZkCert memory c = _zk[agent][taskType];
        tier = c.tier;
        karats = c.karats;
        epoch = c.epoch;
        expiresAt = _roots[rootOf[currentEpoch]].validUntil;
        if (c.epoch == 0) return (Status.NONE, tier, karats, expiresAt, epoch);
        uint256 nul = nullifierOfExecutor[agent];
        if (nul == 0 || executorOf[nul] != agent || zkCertNullifier[agent][taskType] != nul) {
            return (Status.REVOKED, tier, karats, expiresAt, epoch);
        }
        RootState rs = rootState(rootOf[c.epoch]);
        if (rs == RootState.REVOKED) s = Status.REVOKED;
        else if (rs == RootState.EXPIRED) s = Status.ROOT_EXPIRED;
        else if (rs == RootState.SUPERSEDED) s = Status.SUPERSEDED;
        else s = Status.VALID;
    }

    /// @inheritdoc IKaratumCertRegistry
    function isCertified(address agent, bytes32 taskType, uint8 minTier) external view returns (bool) {
        (Status s, uint8 tier,,,) = status(agent, taskType);
        return s == Status.VALID && tier >= minTier;
    }

    // ------------------------------------------------------------------ internal

    function _setAttester(address a) internal {
        if (a == address(0)) revert ZeroAddress();
        attester = a;
        emit AttesterSet(a);
    }

    function _setFreshness(uint64 grace, uint64 ttl, uint64 age) internal {
        if (
            grace > MAX_GRACE_CAP || ttl < 1 hours || ttl > MAX_ROOT_TTL_CAP || age < 1 minutes
                || age > MAX_PROOF_AGE_CAP
        ) {
            revert BadFreshness();
        }
        graceSeconds = grace;
        maxRootTtl = ttl;
        maxProofAge = age;
        emit FreshnessSet(grace, ttl, age);
    }
}

Read the zkrep methodology · Live zkrep demo