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