Source file
ZkRepRegistry.sol
On-chain registry that verifies zkrep proofs against the published reputation root.
// 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);
}
}