Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
214 lines
8.8 KiB
Solidity
214 lines
8.8 KiB
Solidity
// 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 owner account 0x0243A4f4 (measured on chain 2800: an
|
|
* externally owned account today, not a multisig contract).
|
|
* Hardware-wallet storage of that key is NOT asserted here;
|
|
* the chain cannot attest to it.
|
|
*
|
|
* 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);
|
|
}
|
|
}
|