// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; /// @dev Matches Succinct's canonical SP1 verifier / SP1VerifierGateway ABI: /// verifyProof RETURNS NOTHING and REVERTS on an invalid proof. (An /// earlier version of this interface declared `returns (bool)`, which is /// wrong for the real gateway — decoding a bool from the gateway's empty /// returndata reverts even for VALID proofs, so submitProof could never /// succeed on-chain. Fixed to void + try/catch below.) interface ISp1Verifier { function verifyProof( bytes32 programVKey, bytes calldata publicValues, bytes calldata proof ) external view; } /** * @title AereZKScreen — Privacy-Pools-style ZK compliance anchor * @notice Anchors zero-knowledge proofs that a user passed an off-chain * compliance screen WITHOUT revealing the source data. Pattern: * user runs a screening program (sanctions check, residency * attestation, accredited-investor proof, etc.) inside an SP1 * zkVM; the resulting proof is verified on-chain and the user's * address is marked as "screened" against `programVKey`. * * dApps then gate access by reading * `isCleared(user, programVKey, minTimestamp)` — no PII ever * touches the chain. * * GOVERNANCE: * - Foundation registers `programVKey` values (each represents a * distinct compliance program: OFAC screen, accredited-investor * check, EU MiCA-eligible jurisdiction proof, etc.). * - Each program binds a Foundation-set `authorizedRoot` — see * ROOT BINDING below. * - Foundation can set a per-program `expirySeconds`, rotate the * authorizedRoot (`setAuthorizedRoot`), and revoke an individual * clearance (`revoke`). * * ROOT BINDING (critical soundness property): * The proof commits an `extraDataHash` interpreted as the allowlist * Merkle root the screen was checked against. Because the PROVER * supplies that root inside the zkVM, a naive design would let anyone * prove membership in a root of THEIR OWN making (an "allowlist" * containing only themselves) — a total soundness break for the * compliance claim. This contract closes that: each program binds a * Foundation-set `authorizedRoot`, and `submitProof` REQUIRES the * proof's `extraDataHash == authorizedRoot`. The Foundation rotates * the root via `setAuthorizedRoot` as the real allowlist changes and * can void an individual clearance via `revoke`. * * CONSUMER CONTRACT: dApps MUST call `isCleared` with a real * `minTimestamp` (e.g. `block.timestamp - freshnessWindow`), never * 0 — otherwise they accept arbitrarily old attestations. Treat * clearance as NECESSARY-not-sufficient and AND it with a live * sanctions check for sanctions-sensitive flows. * * IMMUTABILITY: * - SP1_VERIFIER is fixed at deploy * - programVKey + expirySeconds are immutable PER-PROGRAM after * registration. authorizedRoot is rotatable by the owner. * * PUBLIC-VALUES CONVENTION: * bytes representing `abi.encode(uint256 chainId, address user, uint256 attestedAt, bytes32 extraDataHash)`. */ contract AereZKScreen is Ownable { address public immutable SP1_VERIFIER; struct Program { bool registered; uint32 expirySeconds; // 0 = no expiry bytes32 programVKey; bytes32 authorizedRoot; // the ONLY allowlist root proofs may attest to string description; } /// @notice programVKey → Program mapping(bytes32 => Program) public programs; /// @notice user → programVKey → last cleared timestamp mapping(address => mapping(bytes32 => uint256)) public clearedAt; /// @notice user → programVKey → revocation watermark; a clearance whose /// attestation timestamp is <= this value is void. mapping(address => mapping(bytes32 => uint256)) public revokedAt; /* --------------------------------- events ------------------------------- */ event ProgramRegistered(bytes32 indexed programVKey, uint32 expirySeconds, bytes32 authorizedRoot, string description); event AuthorizedRootUpdated(bytes32 indexed programVKey, bytes32 oldRoot, bytes32 newRoot); event Screened(address indexed user, bytes32 indexed programVKey, uint256 attestedAt, bytes32 extraDataHash); event Revoked(address indexed user, bytes32 indexed programVKey, uint256 at); /* --------------------------------- errors ------------------------------- */ error UnknownProgram(); error InvalidProof(); error WrongChain(uint256 inProof, uint256 onChain); error UserMismatch(address inProof, address msgSender); error AttestationInFuture(); error AlreadyRegistered(); error ZeroAddress(); error ZeroRoot(); error BadPublicValuesLength(uint256 got); error StaleProof(uint256 newAttestedAt, uint256 currentClearedAt); error UnauthorizedRoot(bytes32 inProof, bytes32 authorized); /* ----------------------------- constructor ------------------------------ */ constructor(address sp1Verifier) { if (sp1Verifier == address(0)) revert ZeroAddress(); SP1_VERIFIER = sp1Verifier; } /* ---------------------------- governance -------------------------------- */ function registerProgram( bytes32 programVKey, uint32 expirySeconds, bytes32 authorizedRoot, string calldata description ) external onlyOwner { if (programs[programVKey].registered) revert AlreadyRegistered(); if (authorizedRoot == bytes32(0)) revert ZeroRoot(); programs[programVKey] = Program({ registered: true, expirySeconds: expirySeconds, programVKey: programVKey, authorizedRoot: authorizedRoot, description: description }); emit ProgramRegistered(programVKey, expirySeconds, authorizedRoot, description); } /// @notice Rotate the allowlist root a program accepts (e.g. when the /// approved set changes). Does NOT retroactively void existing /// clearances — use `revoke` for that. function setAuthorizedRoot(bytes32 programVKey, bytes32 newRoot) external onlyOwner { Program storage p = programs[programVKey]; if (!p.registered) revert UnknownProgram(); if (newRoot == bytes32(0)) revert ZeroRoot(); bytes32 old = p.authorizedRoot; p.authorizedRoot = newRoot; emit AuthorizedRootUpdated(programVKey, old, newRoot); } /// @notice Void a user's clearance for a program (de-listed, compromised, /// or newly sanctioned). `isCleared` returns false until the user /// submits a fresh proof with `attestedAt` strictly after this call. function revoke(address user, bytes32 programVKey) external onlyOwner { revokedAt[user][programVKey] = block.timestamp; emit Revoked(user, programVKey, block.timestamp); } /* ------------------------------ proof submit ---------------------------- */ /// @notice Submit a fresh SP1 proof attesting that `msg.sender` passed /// the program identified by `programVKey`. publicValues MUST /// decode to (chainId, user, attestedAt, extraDataHash) per spec, /// and extraDataHash MUST equal the program's authorizedRoot. function submitProof( bytes32 programVKey, bytes calldata publicValues, bytes calldata proof ) external { Program memory p = programs[programVKey]; if (!p.registered) revert UnknownProgram(); // Exact length check prevents trailing-bytes decode spoof. // 4 × 32 = 128 bytes for (uint256, address, uint256, bytes32). if (publicValues.length != 128) revert BadPublicValuesLength(publicValues.length); // Real SP1 gateway reverts on an invalid proof and returns nothing on // success. Convert its revert into our typed InvalidProof error. try ISp1Verifier(SP1_VERIFIER).verifyProof(programVKey, publicValues, proof) { // proof verified — did not revert } catch { revert InvalidProof(); } (uint256 chainId, address user, uint256 attestedAt, bytes32 extraDataHash) = abi.decode(publicValues, (uint256, address, uint256, bytes32)); if (chainId != block.chainid) revert WrongChain(chainId, block.chainid); if (user != msg.sender) revert UserMismatch(user, msg.sender); // CRITICAL soundness gate: the proof must attest to the // Foundation-authorized allowlist root, not one the prover chose. // Without this, anyone can self-clear by proving membership in an // allowlist of their own making. if (extraDataHash != p.authorizedRoot) revert UnauthorizedRoot(extraDataHash, p.authorizedRoot); if (attestedAt > block.timestamp) revert AttestationInFuture(); // Prevent stale-proof replay from DOWNGRADING a fresher clearance. uint256 currentTs = clearedAt[user][programVKey]; if (attestedAt <= currentTs) revert StaleProof(attestedAt, currentTs); clearedAt[user][programVKey] = attestedAt; emit Screened(user, programVKey, attestedAt, extraDataHash); } /* ----------------------------------- views ------------------------------ */ /// @notice True iff `user` has a valid, non-revoked anchor for `programVKey` /// whose attestation timestamp is at least `minTimestamp` AND (if /// expirySeconds > 0) not older than expirySeconds. function isCleared(address user, bytes32 programVKey, uint256 minTimestamp) external view returns (bool) { Program memory p = programs[programVKey]; if (!p.registered) return false; uint256 ts = clearedAt[user][programVKey]; if (ts == 0) return false; if (ts < minTimestamp) return false; if (ts <= revokedAt[user][programVKey]) return false; // revoked if (p.expirySeconds > 0 && block.timestamp > ts + uint256(p.expirySeconds)) return false; return true; } function clearanceAge(address user, bytes32 programVKey) external view returns (uint256) { uint256 ts = clearedAt[user][programVKey]; if (ts == 0) return type(uint256).max; return block.timestamp - ts; } }