// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; /** * @title AereSanctionsRegistry — read-only OFAC SDN sanctions registry * @notice Foundation publishes a Merkle root of OFAC Specially Designated * Nationals (SDN) addresses each epoch (24h cron). Any consumer * contract can verify whether a specific address is sanctioned at * a given epoch by submitting a Merkle inclusion proof. * * Source of truth: the official OFAC SDN list at * https://www.treasury.gov/ofac/downloads/sdnlist.txt * An off-chain ingester running on a Hostinger VPS (#2) reads the * official list nightly, extracts crypto addresses where present, * builds the Merkle tree, and publishes the root via the * Foundation 2-of-3 multisig (Ledger-signed). * * Designed as a PUBLIC GOOD. Anyone — institutional integrator, * dApp, EOA — calls `isSanctioned(...)` to gate or screen a * counterparty. There is no fee. No discretionary interception. * * @dev Lifecycle: Proposed → (Challenged?) → Attested (mirror of * AereNavOracle pattern). Optimistic 24h window. * * Leaf format: keccak256(abi.encode(address evmAddress, uint16 listId)) * where listId distinguishes the source list (1 = OFAC SDN, * 2 = OFAC SSI, 3 = EU consolidated, etc.). Phase 1 ships with * OFAC SDN only. * * IMMUTABILITY: * - CHALLENGE_WINDOW constant * - History append-only; attested epochs cannot be rewritten * - Owner can only propose / dismiss / attest * - There is NO "deSanction" path on chain — that would suggest * AERE Foundation is making a sanctions decision, which it is * NOT. The Foundation only mirrors the canonical OFAC list. */ contract AereSanctionsRegistry is Ownable { uint256 public constant CHALLENGE_WINDOW = 24 hours; /// @notice OFAC SDN list id, used as the prefix in the leaf hash. uint16 public constant LIST_OFAC_SDN = 1; uint16 public constant LIST_OFAC_SSI = 2; uint16 public constant LIST_EU_CONSOLIDATED = 3; uint16 public constant LIST_UK_HMT = 4; uint16 public constant LIST_UN_CONSOLIDATED = 5; enum State { Proposed, Challenged, Attested } struct Snapshot { bytes32 root; uint64 proposedAt; uint64 attestedAt; State state; string sourceUrl; // canonical OFAC URL the root was built from string ipfsCid; // optional: pin the full list } /// @notice epoch → Snapshot mapping(uint256 => Snapshot) public snapshots; /// @notice epoch → open-challenge count mapping(uint256 => uint256) public openChallenges; mapping(uint256 => Challenge[]) public epochChallenges; struct Challenge { address challenger; uint64 timestamp; string reason; bool dismissed; } /// @notice monotonic latest epoch counter. uint256 public latestEpoch; event SnapshotProposed(uint256 indexed epoch, bytes32 root, string sourceUrl, string ipfsCid); event SnapshotChallenged(uint256 indexed epoch, uint256 challengeId, address challenger, string reason); event ChallengeDismissed(uint256 indexed epoch, uint256 challengeId, string response); event SnapshotAttested(uint256 indexed epoch, bytes32 root); error WrongState(State got, State want); error UnknownEpoch(); error UnknownChallenge(); error EpochAlreadyExists(); error TimelockNotElapsed(uint256 nowTs, uint256 earliest); /* -------------------------------- propose -------------------------------- */ function proposeSnapshot( uint256 epoch, bytes32 root, string calldata sourceUrl, string calldata ipfsCid ) external onlyOwner { if (snapshots[epoch].proposedAt != 0) revert EpochAlreadyExists(); snapshots[epoch] = Snapshot({ root: root, proposedAt: uint64(block.timestamp), attestedAt: 0, state: State.Proposed, sourceUrl: sourceUrl, ipfsCid: ipfsCid }); if (epoch > latestEpoch) latestEpoch = epoch; emit SnapshotProposed(epoch, root, sourceUrl, ipfsCid); } /* ------------------------------- challenge ------------------------------ */ function challenge(uint256 epoch, string calldata reason) external returns (uint256 challengeId) { Snapshot storage s = snapshots[epoch]; if (s.proposedAt == 0) revert UnknownEpoch(); if (s.state != State.Proposed && s.state != State.Challenged) revert WrongState(s.state, State.Proposed); if (block.timestamp >= uint256(s.proposedAt) + CHALLENGE_WINDOW) { revert TimelockNotElapsed(block.timestamp, uint256(s.proposedAt) + CHALLENGE_WINDOW); } challengeId = epochChallenges[epoch].length; epochChallenges[epoch].push(Challenge({ challenger: msg.sender, timestamp: uint64(block.timestamp), reason: reason, dismissed: false })); openChallenges[epoch]++; s.state = State.Challenged; emit SnapshotChallenged(epoch, challengeId, msg.sender, reason); } function dismissChallenge(uint256 epoch, uint256 challengeId, string calldata response) external onlyOwner { Challenge[] storage list = epochChallenges[epoch]; if (challengeId >= list.length) revert UnknownChallenge(); Challenge storage c = list[challengeId]; if (c.dismissed) revert UnknownChallenge(); c.dismissed = true; openChallenges[epoch]--; if (openChallenges[epoch] == 0) snapshots[epoch].state = State.Proposed; emit ChallengeDismissed(epoch, challengeId, response); } /* ------------------------------- attestation ---------------------------- */ function attest(uint256 epoch) external { Snapshot storage s = snapshots[epoch]; if (s.proposedAt == 0) revert UnknownEpoch(); if (s.state != State.Proposed) revert WrongState(s.state, State.Proposed); uint256 earliest = uint256(s.proposedAt) + CHALLENGE_WINDOW; if (block.timestamp < earliest) revert TimelockNotElapsed(block.timestamp, earliest); if (openChallenges[epoch] != 0) revert WrongState(State.Challenged, State.Proposed); s.state = State.Attested; s.attestedAt = uint64(block.timestamp); emit SnapshotAttested(epoch, s.root); } /* ------------------------------- verify -------------------------------- */ /// @notice Verify that `evmAddress` was on `listId` at `epoch`. /// @return sanctioned True if the proof is valid AND the snapshot is attested. function isSanctioned( uint256 epoch, address evmAddress, uint16 listId, bytes32[] calldata proof ) external view returns (bool sanctioned) { Snapshot storage s = snapshots[epoch]; if (s.state != State.Attested) return false; bytes32 leaf = keccak256(abi.encode(evmAddress, listId)); return MerkleProof.verify(proof, s.root, leaf); } /// @notice ROUND-3 FIX: cap iteration depth to prevent griefable DoS. /// If Foundation proposes 1000 challenged-but-never-attested /// snapshots, dApps calling this in-tx would have been bricked. /// Now capped at 64 — practically: the latest 64 epochs. uint256 public constant MAX_EPOCH_SCAN = 64; /// @notice Same shape as isSanctioned but takes only the latest attested epoch. function isSanctionedLatest( address evmAddress, uint16 listId, bytes32[] calldata proof ) external view returns (bool sanctioned, uint256 attestedEpoch) { uint256 scanned = 0; for (uint256 e = latestEpoch; e > 0 && scanned < MAX_EPOCH_SCAN; e--) { Snapshot storage s = snapshots[e]; if (s.state == State.Attested) { bytes32 leaf = keccak256(abi.encode(evmAddress, listId)); return (MerkleProof.verify(proof, s.root, leaf), e); } unchecked { scanned++; } } return (false, 0); } function snapshotStatus(uint256 epoch) external view returns ( bytes32 root, uint64 proposedAt, uint64 attestedAt, State state, uint256 openChallengeCount, string memory sourceUrl, string memory ipfsCid ) { Snapshot storage s = snapshots[epoch]; return (s.root, s.proposedAt, s.attestedAt, s.state, openChallenges[epoch], s.sourceUrl, s.ipfsCid); } }