// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AereCompliancePoolV2 — Privacy Pools + Travel Rule hybrid (bug-fixed challenge bond) * @notice Bug-fix redeploy of AereCompliancePool * (0x79735c31F289F7A4d6Be3E02aaB70B544796D41d, 0 deposits). The * Privacy-Pools construction, on-chain append-only Merkle tree, rolling * root history, SP1 verifier reference, and Travel-Rule deposit path are * BYTE-FOR-BYTE identical to V1. The SAME deployed SP1 verifier is * reused, so the existing off-chain circuit still matches this pool's * tree (TREE_DEPTH, ZERO_VALUE and keccak hashing are unchanged). * * ─────────────────────────── WHY V2 ─────────────────────────── * F8 (challenge bond unrecoverable). V1's challengeAssociationRoot pulled * a 100-token CHALLENGE_BOND but there was NO path to return it to an * HONEST challenger: * - if the Foundation dismissed the challenge, the bond was BURNED; * - if the Foundation simply never dismissed, the root stayed * "challenged" forever, the bond sat locked in the contract, and no * refund function existed. * So an honest challenger who was RIGHT always lost 100 tokens. * * V2 fix. A challenge now resolves one of two ways: * - DISMISSED (provably-bad challenge): the Foundation calls * dismissAssociationRootChallenge WITHIN the dismissal window * (== ASSOCIATION_ROOT_CHALLENGE_WINDOW after the challenge). The * bond is BURNED to dEaD, exactly as V1. This preserves V1's * anti-grief property: griefing a valid root still costs the bond. * - UPHELD (Foundation failed to dismiss in time): after the dismissal * window passes with the challenge still standing, anyone can call * reclaimUpheldChallenge — the bond is REFUNDED to the challenger and * the root is PERMANENTLY REJECTED (it can never publish). An honest * challenger is made whole; a questionable association set is not * published (the safe default for a compliance pool). * * The dismissal window is bounded by ASSOCIATION_ROOT_CHALLENGE_WINDOW, * which is now a constructor IMMUTABLE (was a constant) so a deployer- * owned demo twin can exercise the full lifecycle quickly while the * canonical pool keeps the production 24h value. No other behaviour * changes. */ 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. /// IDENTICAL to AereCompliancePool V1 (circuit compatibility). abstract contract MerkleTreeWithHistoryV2 { uint32 public constant TREE_DEPTH = 20; 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); constructor() { 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; uint32 cap = uint32(1 << TREE_DEPTH); uint32 leaves = nextLeafIndex; if (leaves == (cap * 90) / 100 || leaves == (cap * 99) / 100) { emit TreeNearCapacity(leaves, cap); } } 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 AereCompliancePoolV2 is MerkleTreeWithHistoryV2, ReentrancyGuard { /* ================================ config ================================ */ IERC20 public immutable TOKEN; uint256 public immutable DENOMINATION; address public immutable FOUNDATION; IPrivacyPoolVerifier public immutable VERIFIER; bytes32 public immutable POOL_ID; /* ================================= state ================================= */ /// association roots go through a challenge window. mapping(bytes32 => uint64) public associationRootPublishedAt; mapping(bytes32 => uint64) public associationRootProposedAt; mapping(bytes32 => bool) public associationRootChallenged; /// F8: timestamp of the CURRENT active challenge (dismissal-window anchor). mapping(bytes32 => uint64) public associationRootChallengedAt; /// F8: a root permanently killed by an UPHELD challenge — can never publish. mapping(bytes32 => bool) public associationRootRejected; 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 tokens address public constant CHALLENGE_BOND_BURN = 0x000000000000000000000000000000000000dEaD; IERC20 public immutable BOND_TOKEN; // bond paid in same TOKEN as DENOMINATION /// V2: immutable (was a 24h constant) so a demo twin can use a short window. uint64 public immutable ASSOCIATION_ROOT_CHALLENGE_WINDOW; 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); /// F8: bond refunded to an honest challenger; root permanently rejected. event AssociationRootChallengeUpheld(bytes32 indexed root, address indexed challenger, uint256 bondRefunded); event AssociationRootRejected(bytes32 indexed root); 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(); error MaxDismissalsReached(); error DismissalWindowClosed(); // F8: Foundation dismissed too late (challenge upheld) error DismissalWindowOpen(); // F8: reclaim attempted before the window elapsed error NotChallenged(); // F8 modifier onlyFoundation() { if (msg.sender != FOUNDATION) revert NotFoundation(); _; } constructor( IERC20 token, uint256 denomination, address foundation, IPrivacyPoolVerifier verifier, uint64 challengeWindow ) MerkleTreeWithHistoryV2() { if (address(token) == address(0) || foundation == address(0) || address(verifier) == address(0)) { revert ZeroAddr(); } require(denomination > 0, "zero-denom"); require(challengeWindow > 0, "zero-window"); TOKEN = token; BOND_TOKEN = token; DENOMINATION = denomination; FOUNDATION = foundation; VERIFIER = verifier; ASSOCIATION_ROOT_CHALLENGE_WINDOW = challengeWindow; 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 ============================= */ /* IDENTICAL to V1. */ 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(); 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 (associationRootRejected[root]) return; // permanently killed by an upheld challenge 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 (associationRootRejected[root]) revert UnknownAssociationRoot(); if (associationRootChallenged[root]) revert UnknownAssociationRoot(); // already challenged if (associationRootDismissCount[root] >= MAX_DISMISSALS_PER_ROOT) revert MaxDismissalsReached(); if (!BOND_TOKEN.transferFrom(msg.sender, address(this), CHALLENGE_BOND)) revert ChallengeBondTransferFailed(); associationRootChallenged[root] = true; associationRootChallenger[root] = msg.sender; associationRootChallengedAt[root] = uint64(block.timestamp); emit AssociationRootChallenged(root, msg.sender, reason); } /// @notice Foundation dismisses a PROVABLY-BAD challenge. Must be called /// within ASSOCIATION_ROOT_CHALLENGE_WINDOW of the challenge; after /// that the challenge is deemed UPHELD and can only be reclaimed. /// Burns the bond (unchanged from V1) and restarts the publish /// window so an honest Foundation can still publish a valid root. function dismissAssociationRootChallenge(bytes32 root) external onlyFoundation { if (!associationRootChallenged[root]) revert UnknownAssociationRoot(); if (block.timestamp >= uint256(associationRootChallengedAt[root]) + ASSOCIATION_ROOT_CHALLENGE_WINDOW) { revert DismissalWindowClosed(); } associationRootChallenged[root] = false; associationRootDismissCount[root] += 1; associationRootProposedAt[root] = uint64(block.timestamp); // reset publish window address challenger = associationRootChallenger[root]; delete associationRootChallenger[root]; delete associationRootChallengedAt[root]; if (challenger != address(0)) { // burn the provably-bad challenger's bond (best-effort) BOND_TOKEN.transfer(CHALLENGE_BOND_BURN, CHALLENGE_BOND); } emit AssociationRootChallengeDismissed(root, challenger, associationRootDismissCount[root]); emit AssociationRootProposed(root, uint64(block.timestamp) + ASSOCIATION_ROOT_CHALLENGE_WINDOW); } /// @notice F8: if the Foundation did NOT dismiss within the window, the /// challenge stands (UPHELD). Anyone can finalise it: the honest /// challenger's bond is REFUNDED and the root is permanently /// rejected (safe default — a questioned set is not published). function reclaimUpheldChallenge(bytes32 root) external nonReentrant { if (!associationRootChallenged[root]) revert NotChallenged(); if (block.timestamp < uint256(associationRootChallengedAt[root]) + ASSOCIATION_ROOT_CHALLENGE_WINDOW) { revert DismissalWindowOpen(); } address challenger = associationRootChallenger[root]; associationRootChallenged[root] = false; associationRootRejected[root] = true; delete associationRootChallenger[root]; delete associationRootChallengedAt[root]; if (challenger != address(0)) { if (!BOND_TOKEN.transfer(challenger, CHALLENGE_BOND)) revert TransferFailed(); } emit AssociationRootChallengeUpheld(root, challenger, CHALLENGE_BOND); emit AssociationRootRejected(root); } function publishAssociationRoot(bytes32 root) external { if (associationRootPublishedAt[root] != 0) return; if (associationRootRejected[root]) revert UnknownAssociationRoot(); 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 ============================ */ /* IDENTICAL to V1. */ 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(); 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; 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; } function latestDepositRoot() external view returns (bytes32) { return roots[currentRootIndex]; } }