aere-contracts/contracts/compliance/AereCompliancePoolV2.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

411 lines
18 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 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];
}
}