aere-contracts/contracts/compliance/AereZKScreen.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

225 lines
10 KiB
Solidity
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}
}