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.
376 lines
16 KiB
Solidity
376 lines
16 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.23;
|
|
|
|
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
|
|
|
|
/**
|
|
* @title AereCompliancePool — Privacy Pools + Travel Rule hybrid
|
|
* @notice Implements the Privacy Pools construction of Buterin, Illum,
|
|
* Nadler, Schar & Soleimani (eprint 2023/1366) — zk-mixer with
|
|
* association-set proofs that let honest users prove their deposit
|
|
* belongs to a curated "clean" subset, while sanctioned addresses
|
|
* cannot exit via the pool because their commitment never enters
|
|
* the association set.
|
|
*
|
|
* The deposit root is computed on-chain from a deterministic
|
|
* append-only Merkle tree of committed leaves (audit fix HIGH #28).
|
|
* The Foundation cannot publish a fake tree to drain the pool.
|
|
*
|
|
* Hashing: keccak256 (cheap on EVM, no trusted setup, no MiMC).
|
|
* Depth: 20 (1,048,576 leaves per pool).
|
|
* History: last 64 roots remain valid simultaneously so a user with
|
|
* a proof built against a slightly-old root can still withdraw.
|
|
*
|
|
* The Foundation still publishes ASSOCIATION roots through the 24h
|
|
* challenge process, since the association set is an off-chain
|
|
* classification.
|
|
*/
|
|
interface IPrivacyPoolVerifier {
|
|
/// @param publicInputs [poolId, depositRoot, associationRoot, nullifierHash,
|
|
/// recipientWord, feeRecipientWord, refundWord, feeWord]
|
|
function verify(bytes calldata proof, uint256[] calldata publicInputs) external view returns (bool);
|
|
}
|
|
|
|
/// @dev Append-only zk-mixer Merkle tree with rolling-history root cache.
|
|
/// Construction follows the Privacy Pools paper (Buterin et al. 2023).
|
|
abstract contract MerkleTreeWithHistory {
|
|
uint32 public constant TREE_DEPTH = 20;
|
|
/// AUDIT FIX R5 (MED-10): raised from 64 → 256. At 0.5s blocks, 64 deposits
|
|
/// fit in ~32s; honest provers building proofs against a 30s-old root
|
|
/// were getting DoS'd by deposit bursts. 256 gives ~2min headroom which
|
|
/// matches expected prover latency on AERE.
|
|
uint32 public constant ROOT_HISTORY_SIZE = 256;
|
|
|
|
bytes32 public constant ZERO_VALUE = keccak256("aere.compliance-pool.v1.empty-leaf");
|
|
|
|
bytes32[TREE_DEPTH] public filledSubtrees;
|
|
bytes32[TREE_DEPTH] public zeros;
|
|
bytes32[ROOT_HISTORY_SIZE] public roots;
|
|
uint32 public currentRootIndex;
|
|
uint32 public nextLeafIndex;
|
|
|
|
error TreeFull();
|
|
|
|
event TreeNearCapacity(uint32 indexed leaves, uint32 capacity); // AUDIT FIX R4 (MED)
|
|
|
|
constructor() {
|
|
// AUDIT FIX R4 (LOW): do NOT seed the empty-tree root into the rolling
|
|
// history — that would let withdrawals against an empty-tree proof
|
|
// succeed for the first ROOT_HISTORY_SIZE deposits. Instead, leave all
|
|
// history slots at bytes32(0); isKnownRoot already returns false for
|
|
// zero. The first real _insert writes the first valid root.
|
|
bytes32 z = ZERO_VALUE;
|
|
for (uint32 i = 0; i < TREE_DEPTH; i++) {
|
|
zeros[i] = z;
|
|
filledSubtrees[i] = z;
|
|
z = keccak256(abi.encodePacked(z, z));
|
|
}
|
|
// history intentionally left as zeros.
|
|
}
|
|
|
|
function _insert(bytes32 leaf) internal returns (uint32 idx) {
|
|
idx = nextLeafIndex;
|
|
if (idx >= uint32(1 << TREE_DEPTH)) revert TreeFull();
|
|
nextLeafIndex = idx + 1;
|
|
|
|
uint32 cur = idx;
|
|
bytes32 left;
|
|
bytes32 right;
|
|
bytes32 node = leaf;
|
|
for (uint32 i = 0; i < TREE_DEPTH; i++) {
|
|
if (cur & 1 == 0) {
|
|
left = node;
|
|
right = zeros[i];
|
|
filledSubtrees[i] = node;
|
|
} else {
|
|
left = filledSubtrees[i];
|
|
right = node;
|
|
}
|
|
node = keccak256(abi.encodePacked(left, right));
|
|
cur >>= 1;
|
|
}
|
|
|
|
uint32 newRootIdx = (currentRootIndex + 1) % ROOT_HISTORY_SIZE;
|
|
currentRootIndex = newRootIdx;
|
|
roots[newRootIdx] = node;
|
|
|
|
// AUDIT FIX R4 (MED): warn at 90% and 99% capacity so operators can
|
|
// schedule a sibling-pool deployment before depositors hit TreeFull.
|
|
uint32 cap = uint32(1 << TREE_DEPTH);
|
|
uint32 leaves = nextLeafIndex;
|
|
if (leaves == (cap * 90) / 100 || leaves == (cap * 99) / 100) {
|
|
emit TreeNearCapacity(leaves, cap);
|
|
}
|
|
}
|
|
|
|
/// @notice Returns true if `root` is in the recent ROOT_HISTORY_SIZE roots.
|
|
function isKnownRoot(bytes32 root) public view returns (bool) {
|
|
if (root == bytes32(0)) return false;
|
|
uint32 i = currentRootIndex;
|
|
do {
|
|
if (roots[i] == root) return true;
|
|
if (i == 0) i = ROOT_HISTORY_SIZE;
|
|
i--;
|
|
} while (i != currentRootIndex);
|
|
return false;
|
|
}
|
|
|
|
function getLastRoot() external view returns (bytes32) {
|
|
return roots[currentRootIndex];
|
|
}
|
|
}
|
|
|
|
contract AereCompliancePool is MerkleTreeWithHistory, ReentrancyGuard {
|
|
|
|
/* ================================ config ================================ */
|
|
|
|
IERC20 public immutable TOKEN;
|
|
uint256 public immutable DENOMINATION;
|
|
address public immutable FOUNDATION;
|
|
IPrivacyPoolVerifier public immutable VERIFIER;
|
|
bytes32 public immutable POOL_ID;
|
|
|
|
/* ================================= state ================================= */
|
|
|
|
/// AUDIT FIX (MED #28): association roots still go through 24h challenge.
|
|
mapping(bytes32 => uint64) public associationRootPublishedAt;
|
|
mapping(bytes32 => uint64) public associationRootProposedAt;
|
|
mapping(bytes32 => bool) public associationRootChallenged;
|
|
/// AUDIT FIX R5 (HIGH-2): cap dismissals + slashable challenge bond.
|
|
mapping(bytes32 => uint8) public associationRootDismissCount;
|
|
mapping(bytes32 => address) public associationRootChallenger;
|
|
uint8 public constant MAX_DISMISSALS_PER_ROOT = 2;
|
|
uint256 public constant CHALLENGE_BOND = 100 ether; // 100 AERE — burnt on dismissal
|
|
address public constant CHALLENGE_BOND_BURN = 0x000000000000000000000000000000000000dEaD;
|
|
IERC20 public immutable BOND_TOKEN; // bond paid in same TOKEN as DENOMINATION
|
|
uint64 public constant ASSOCIATION_ROOT_CHALLENGE_WINDOW = 24 hours;
|
|
|
|
mapping(bytes32 => bool) public spentNullifiers;
|
|
mapping(bytes32 => bool) public commitmentSeen; // prevents duplicate commitment deposits
|
|
uint256 public depositCount;
|
|
|
|
mapping(address => bool) public isComplianceProvider;
|
|
address[] public complianceProviderList;
|
|
|
|
/* ================================= events ================================= */
|
|
|
|
event Deposited(
|
|
uint256 indexed depositId,
|
|
address indexed sender,
|
|
bytes32 commitment,
|
|
bytes32 travelRuleHash,
|
|
address indexed complianceProvider,
|
|
string travelRuleUri,
|
|
uint32 leafIndex,
|
|
bytes32 newRoot
|
|
);
|
|
event AssociationRootProposed(bytes32 indexed root, uint64 challengeEndsAt);
|
|
event AssociationRootChallenged(bytes32 indexed root, address indexed challenger, string reason);
|
|
event AssociationRootChallengeDismissed(bytes32 indexed root, address indexed challenger, uint8 dismissCount); // R5 HIGH-2
|
|
event AssociationRootPublished(bytes32 indexed root);
|
|
event Withdrawn(
|
|
bytes32 indexed nullifierHash,
|
|
address indexed recipient,
|
|
address indexed feeRecipient,
|
|
uint256 fee,
|
|
uint256 refund,
|
|
bytes32 depositRoot,
|
|
bytes32 associationRoot
|
|
);
|
|
event ComplianceProviderSet(address indexed provider, bool allowed);
|
|
|
|
/* ================================= errors ================================= */
|
|
|
|
error NotFoundation();
|
|
error ZeroAddr();
|
|
error NotAComplianceProvider();
|
|
error AlreadySpent();
|
|
error UnknownAssociationRoot();
|
|
error UnknownDepositRoot();
|
|
error InvalidProof();
|
|
error WrongDenomination();
|
|
error TransferFailed();
|
|
error FeeTooLarge();
|
|
error CommitmentReused();
|
|
error ChallengeBondTransferFailed(); // R5 HIGH-2
|
|
error MaxDismissalsReached(); // R5 HIGH-2
|
|
|
|
modifier onlyFoundation() {
|
|
if (msg.sender != FOUNDATION) revert NotFoundation();
|
|
_;
|
|
}
|
|
|
|
constructor(
|
|
IERC20 token,
|
|
uint256 denomination,
|
|
address foundation,
|
|
IPrivacyPoolVerifier verifier
|
|
) MerkleTreeWithHistory() {
|
|
if (address(token) == address(0) || foundation == address(0) || address(verifier) == address(0)) {
|
|
revert ZeroAddr();
|
|
}
|
|
require(denomination > 0, "zero-denom");
|
|
TOKEN = token;
|
|
BOND_TOKEN = token; // R5 HIGH-2: same asset as DENOMINATION
|
|
DENOMINATION = denomination;
|
|
FOUNDATION = foundation;
|
|
VERIFIER = verifier;
|
|
POOL_ID = keccak256(abi.encodePacked(address(this), block.chainid));
|
|
}
|
|
|
|
/* ============================ compliance provider ========================= */
|
|
|
|
function setComplianceProvider(address provider, bool allowed) external onlyFoundation {
|
|
if (provider == address(0)) revert ZeroAddr();
|
|
bool prev = isComplianceProvider[provider];
|
|
isComplianceProvider[provider] = allowed;
|
|
if (!prev && allowed) complianceProviderList.push(provider);
|
|
emit ComplianceProviderSet(provider, allowed);
|
|
}
|
|
|
|
/* ============================== deposit path ============================= */
|
|
|
|
function deposit(
|
|
bytes32 commitment,
|
|
bytes32 travelRuleHash,
|
|
address complianceProvider,
|
|
string calldata travelRuleUri
|
|
) external nonReentrant {
|
|
if (!isComplianceProvider[complianceProvider]) revert NotAComplianceProvider();
|
|
if (commitment == bytes32(0) || commitment == ZERO_VALUE) revert CommitmentReused();
|
|
// AUDIT FIX R5 (MED-11): bind commitment to msg.sender. Mempool
|
|
// front-run by another address now produces a DIFFERENT actualLeaf so
|
|
// the victim's deposit still succeeds. The ZK circuit will need to
|
|
// constrain that the witnessed leaf = keccak256(commitment, depositor).
|
|
bytes32 actualLeaf = keccak256(abi.encodePacked(commitment, msg.sender));
|
|
if (commitmentSeen[actualLeaf]) revert CommitmentReused();
|
|
commitmentSeen[actualLeaf] = true;
|
|
|
|
if (!TOKEN.transferFrom(msg.sender, address(this), DENOMINATION)) revert TransferFailed();
|
|
|
|
uint32 leafIndex = _insert(actualLeaf);
|
|
bytes32 newRoot = roots[currentRootIndex];
|
|
|
|
uint256 id = depositCount++;
|
|
emit Deposited(id, msg.sender, commitment, travelRuleHash, complianceProvider, travelRuleUri, leafIndex, newRoot);
|
|
}
|
|
|
|
/* ========================== association set publish ====================== */
|
|
|
|
function proposeAssociationRoot(bytes32 root) external onlyFoundation {
|
|
if (associationRootPublishedAt[root] != 0) return;
|
|
if (associationRootProposedAt[root] == 0) {
|
|
associationRootProposedAt[root] = uint64(block.timestamp);
|
|
associationRootChallenged[root] = false;
|
|
emit AssociationRootProposed(root, uint64(block.timestamp) + ASSOCIATION_ROOT_CHALLENGE_WINDOW);
|
|
}
|
|
}
|
|
|
|
function challengeAssociationRoot(bytes32 root, string calldata reason) external {
|
|
if (associationRootProposedAt[root] == 0) revert UnknownAssociationRoot();
|
|
if (associationRootPublishedAt[root] != 0) revert UnknownAssociationRoot();
|
|
if (associationRootChallenged[root]) revert UnknownAssociationRoot(); // already challenged — wait for dismissal
|
|
if (associationRootDismissCount[root] >= MAX_DISMISSALS_PER_ROOT) revert MaxDismissalsReached();
|
|
// AUDIT FIX R5 (HIGH-2): require slashable CHALLENGE_BOND so a griefer
|
|
// can no longer freeze withdrawals at ~30k gas per 24h cycle.
|
|
if (!BOND_TOKEN.transferFrom(msg.sender, address(this), CHALLENGE_BOND)) revert ChallengeBondTransferFailed();
|
|
associationRootChallenged[root] = true;
|
|
associationRootChallenger[root] = msg.sender;
|
|
emit AssociationRootChallenged(root, msg.sender, reason);
|
|
}
|
|
|
|
function dismissAssociationRootChallenge(bytes32 root) external onlyFoundation {
|
|
if (!associationRootChallenged[root]) revert UnknownAssociationRoot();
|
|
associationRootChallenged[root] = false;
|
|
associationRootDismissCount[root] += 1;
|
|
// AUDIT FIX R6 (CRITICAL): reset window on dismiss. Combined with
|
|
// MAX_DISMISSALS_PER_ROOT=2 this caps the worst-case Foundation+
|
|
// sockpuppet bypass at 48h (vs R5's 0h instant publish). Each
|
|
// dismissal genuinely costs Foundation 24h delay AND burns the
|
|
// challenger's bond.
|
|
associationRootProposedAt[root] = uint64(block.timestamp);
|
|
address challenger = associationRootChallenger[root];
|
|
delete associationRootChallenger[root];
|
|
if (challenger != address(0)) {
|
|
// best-effort burn
|
|
BOND_TOKEN.transfer(CHALLENGE_BOND_BURN, CHALLENGE_BOND);
|
|
}
|
|
emit AssociationRootChallengeDismissed(root, challenger, associationRootDismissCount[root]);
|
|
emit AssociationRootProposed(root, uint64(block.timestamp) + ASSOCIATION_ROOT_CHALLENGE_WINDOW);
|
|
}
|
|
|
|
function publishAssociationRoot(bytes32 root) external {
|
|
if (associationRootPublishedAt[root] != 0) return;
|
|
uint64 proposed = associationRootProposedAt[root];
|
|
if (proposed == 0) revert UnknownAssociationRoot();
|
|
if (associationRootChallenged[root]) revert UnknownAssociationRoot();
|
|
if (block.timestamp < uint256(proposed) + ASSOCIATION_ROOT_CHALLENGE_WINDOW) {
|
|
revert UnknownAssociationRoot();
|
|
}
|
|
associationRootPublishedAt[root] = uint64(block.timestamp);
|
|
emit AssociationRootPublished(root);
|
|
}
|
|
|
|
/* ============================== withdraw path ============================ */
|
|
|
|
function withdraw(
|
|
bytes calldata proof,
|
|
bytes32 depositRoot,
|
|
bytes32 associationRoot,
|
|
bytes32 nullifierHash,
|
|
address recipient,
|
|
address feeRecipient,
|
|
uint256 fee,
|
|
uint256 refund
|
|
) external nonReentrant {
|
|
if (spentNullifiers[nullifierHash]) revert AlreadySpent();
|
|
if (associationRootPublishedAt[associationRoot] == 0) revert UnknownAssociationRoot();
|
|
// AUDIT FIX (HIGH #28): the depositRoot must be one of the last 64
|
|
// on-chain-computed roots — there is no Foundation override.
|
|
if (!isKnownRoot(depositRoot)) revert UnknownDepositRoot();
|
|
if (fee + refund > DENOMINATION) revert FeeTooLarge();
|
|
|
|
uint256[] memory pubs = new uint256[](8);
|
|
pubs[0] = uint256(POOL_ID);
|
|
pubs[1] = uint256(depositRoot);
|
|
pubs[2] = uint256(associationRoot);
|
|
pubs[3] = uint256(nullifierHash);
|
|
pubs[4] = uint256(uint160(recipient));
|
|
pubs[5] = uint256(uint160(feeRecipient));
|
|
pubs[6] = refund;
|
|
pubs[7] = fee;
|
|
|
|
if (!VERIFIER.verify(proof, pubs)) revert InvalidProof();
|
|
|
|
spentNullifiers[nullifierHash] = true;
|
|
|
|
// AUDIT FIX R6 (HIGH): refund is paid out to msg.sender (the relayer
|
|
// or sponsor) as a gas-reimbursement kickback — the standard
|
|
// zk-mixer relayer pattern. Previously refund was subtracted from
|
|
// recipient amount but NEVER paid out — pool accounting was unbalanced.
|
|
uint256 toRecipient = DENOMINATION - fee - refund;
|
|
if (toRecipient > 0 && !TOKEN.transfer(recipient, toRecipient)) revert TransferFailed();
|
|
if (fee > 0 && !TOKEN.transfer(feeRecipient, fee)) revert TransferFailed();
|
|
if (refund > 0 && !TOKEN.transfer(msg.sender, refund)) revert TransferFailed();
|
|
|
|
emit Withdrawn(nullifierHash, recipient, feeRecipient, fee, refund, depositRoot, associationRoot);
|
|
}
|
|
|
|
/* ================================= views ================================= */
|
|
|
|
function complianceProviders() external view returns (address[] memory) {
|
|
return complianceProviderList;
|
|
}
|
|
|
|
function isAssociationRootValid(bytes32 root) external view returns (bool) {
|
|
return associationRootPublishedAt[root] != 0;
|
|
}
|
|
|
|
/// @notice Compatibility alias for older indexers/clients that expect a
|
|
/// `latestDepositRoot()` getter. Returns the current on-chain Merkle root.
|
|
function latestDepositRoot() external view returns (bytes32) {
|
|
return roots[currentRootIndex];
|
|
}
|
|
}
|