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.
225 lines
10 KiB
Solidity
225 lines
10 KiB
Solidity
// 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;
|
||
}
|
||
}
|