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