aere-contracts/contracts/AereFeeMonetizationV2.sol
Aere Network acac2f00a6
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 unpublished line of work joins the sanitized public line
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.
2026-08-15 13:59:30 +03:00

290 lines
14 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
/// Minimal Ownable-style interface used to prove the caller controls a contract.
interface IOwnableProbe {
function owner() external view returns (address);
}
/**
* @title AereFeeMonetizationV2
* @notice Bug-fix redeploy of AereFeeMonetization (V1 at
* 0x6b62DC6cC974F779354c953F41b64a7aB994dd98).
*
* Pay developers (and the Foundation treasury) automatically when their
* contracts get used. A developer registers a contract and receives an
* ERC-721 representing the right to collect that contract's dev-share of
* gas fees; a Foundation distributor credits per-tokenId amounts; the NFT
* owner claims accumulated AERE.
*
* ────────────────────────── WHY V2 (finding #1, HIGH) ──────────────────────
* V1.register(contractAddr, payout) performed NO check that msg.sender
* controls contractAddr. Anyone could "squat" a victim contract's fee stream
* by registering the victim's address and minting the NFT to themselves; the
* real developer could then NEVER register (permanent DoS, "already
* registered"), and the squatter collected every fee the distributor
* attributed to that contract. There was also no unregister / reclaim path.
*
* V2 fixes:
* (1) PERMISSIONLESS-but-AUTHENTICATED register: the caller must prove it
* controls contractAddr — either
* * msg.sender == contractAddr (the contract registers itself), OR
* * msg.sender == Ownable(contractAddr).owner() (try/catch; the
* on-chain owner registers it).
* A contract that has no owner() and is not self-registering cannot be
* registered by a third party. The permissionless-monetization intent is
* preserved: NO Foundation gatekeeping of who may register their own
* contract, it is not onlyOwner.
* (2) unregister(contractAddr): the current NFT owner (registrant) OR the
* FeeMonetization owner (Foundation, for corrections) can clear a
* mistaken/stale registration. Any pending rewards are paid to the NFT
* owner first (nothing is lost), the NFT is burned, and the contract can
* be registered again cleanly.
* (3) reassignPayout(contractAddr, newPayout): convenience wrapper that moves
* the fee-stream NFT to a new payout address (equivalent to an ERC-721
* transfer, exposed by contract address for UX).
*
* Fee-distribution, claim, treasury and policy logic are byte-for-byte
* identical to V1.
*/
contract AereFeeMonetizationV2 is ERC721, Ownable, ReentrancyGuard {
/// Treasury address. All `treasuryBps` revenue flows here.
address payable public treasury;
/// Dev-share rate in basis points (10000 = 100%). Hard cap at 3000 (30%).
uint256 public devShareBps = 2000;
/// Treasury slice in basis points. Hard cap at 1500 (15%).
uint256 public treasuryBps = 500;
uint256 public constant BPS = 10_000;
/// Authorised distributors (Foundation runs these — same trust model as Tier 1.8).
mapping(address => bool) public isDistributor;
/// Auto-incrementing tokenId.
uint256 public nextTokenId = 1;
/// tokenId → contract that earns this share.
mapping(uint256 => address) public earningContract;
/// contractAddr → tokenId (0 if not registered).
mapping(address => uint256) public tokenIdOf;
/// tokenId → unclaimed AERE balance.
mapping(uint256 => uint256) public pendingRewards;
/// Lifetime cumulative paid to each NFT and to treasury.
mapping(uint256 => uint256) public totalPaidToToken;
uint256 public totalPaidToTreasury;
event Registered(address indexed contractAddr, uint256 indexed tokenId, address indexed payout);
event Unregistered(address indexed contractAddr, uint256 indexed tokenId, address indexed to, uint256 pendingPaid);
event PayoutReassigned(address indexed contractAddr, uint256 indexed tokenId, address indexed newPayout);
event Distributed(uint256 indexed tokenId, uint256 amount, uint256 newPending);
event TreasuryFunded(uint256 amount, uint256 newTotal);
event Claimed(uint256 indexed tokenId, address indexed to, uint256 amount);
event RateChanged(string what, uint256 oldValue, uint256 newValue);
event DistributorChanged(address indexed distributor, bool authorised);
event TreasuryChanged(address indexed previous, address indexed next);
modifier onlyDistributor() {
require(isDistributor[msg.sender], "FeeMon: not a distributor");
_;
}
constructor(address payable _treasury) ERC721("AERE Fee Monetization", "AERE-FM") {
require(_treasury != address(0), "FeeMon: zero treasury");
treasury = _treasury;
}
// ───────────────────── Admin ─────────────────────
function setTreasury(address payable _treasury) external onlyOwner {
require(_treasury != address(0), "FeeMon: zero treasury");
emit TreasuryChanged(treasury, _treasury);
treasury = _treasury;
}
function setDevShareBps(uint256 _bps) external onlyOwner {
require(_bps <= 3000, "FeeMon: devShare too high");
emit RateChanged("devShareBps", devShareBps, _bps);
devShareBps = _bps;
}
function setTreasuryBps(uint256 _bps) external onlyOwner {
require(_bps <= 1500, "FeeMon: treasuryBps too high");
emit RateChanged("treasuryBps", treasuryBps, _bps);
treasuryBps = _bps;
}
function setDistributor(address dist, bool ok) external onlyOwner {
isDistributor[dist] = ok;
emit DistributorChanged(dist, ok);
}
// ───────────────────── Registration ─────────────────────
/// @notice Returns true iff `who` provably controls `target`: either it IS
/// `target` (the contract self-registering) or it is `target`'s
/// on-chain Ownable owner. try/catch so a non-Ownable target simply
/// yields false rather than reverting.
function controlsContract(address who, address target) public view returns (bool) {
if (who == target) return true;
// No-code target (EOA): only self-registration is allowed. Guard the
// owner() probe so a call to a codeless address returns false gracefully
// instead of tripping Solidity's extcodesize check (a reasonless revert).
if (target.code.length == 0) return false;
try IOwnableProbe(target).owner() returns (address o) {
return (o != address(0) && o == who);
} catch {
return false;
}
}
/**
* @notice Register a contract for fee-share. Permissionless, but the caller
* MUST control contractAddr (self-registration or its Ownable owner).
* The NFT is minted to payoutAddr (the NFT owner at claim time is who
* receives the AERE). This closes the V1 fee-stream squat + DoS.
*/
function register(address contractAddr, address payoutAddr) external returns (uint256 tokenId) {
require(contractAddr != address(0), "FeeMon: zero contract");
require(tokenIdOf[contractAddr] == 0, "FeeMon: already registered");
require(payoutAddr != address(0), "FeeMon: zero payout");
require(controlsContract(msg.sender, contractAddr), "FeeMon: not controller");
tokenId = nextTokenId++;
earningContract[tokenId] = contractAddr;
tokenIdOf[contractAddr] = tokenId;
_mint(payoutAddr, tokenId);
emit Registered(contractAddr, tokenId, payoutAddr);
}
/// @notice Clear a mistaken/stale registration. Callable by the current NFT
/// owner (registrant) or the FeeMonetization owner. Any pending
/// rewards are paid to the current NFT owner first (nothing lost),
/// the NFT is burned, and the contract can be registered again.
function unregister(address contractAddr) external nonReentrant {
uint256 tokenId = tokenIdOf[contractAddr];
require(tokenId != 0, "FeeMon: not registered");
address holder = ownerOf(tokenId); // reverts if already burned
require(msg.sender == holder || msg.sender == owner(), "FeeMon: not authorized");
uint256 amount = pendingRewards[tokenId];
pendingRewards[tokenId] = 0;
earningContract[tokenId] = address(0);
tokenIdOf[contractAddr] = 0;
_burn(tokenId);
if (amount > 0) {
(bool ok, ) = payable(holder).call{ value: amount }("");
require(ok, "FeeMon: payout failed");
}
emit Unregistered(contractAddr, tokenId, holder, amount);
}
/// @notice Move a fee-stream NFT to a new payout address. Convenience wrapper
/// over a plain ERC-721 transfer, addressed by the earning contract.
function reassignPayout(address contractAddr, address newPayout) external {
require(newPayout != address(0), "FeeMon: zero payout");
uint256 tokenId = tokenIdOf[contractAddr];
require(tokenId != 0, "FeeMon: not registered");
require(ownerOf(tokenId) == msg.sender, "FeeMon: not NFT owner");
_transfer(msg.sender, newPayout, tokenId);
emit PayoutReassigned(contractAddr, tokenId, newPayout);
}
/// @notice Convenience: Foundation registers its OWN protocol contracts in
/// bulk. This is an onlyOwner ADMIN bootstrap (the FeeMonetization
/// owner is the Foundation account, a single-key EOA today, not a multisig) — it is NOT the permissionless
/// register() path and is NOT a squat vector: only the Foundation can
/// call it, and only for the contracts it operates.
function registerBatch(address[] calldata contractAddrs, address payoutAddr)
external onlyOwner returns (uint256[] memory tokenIds)
{
require(payoutAddr != address(0), "FeeMon: zero payout");
tokenIds = new uint256[](contractAddrs.length);
for (uint256 i = 0; i < contractAddrs.length; i++) {
require(contractAddrs[i] != address(0), "FeeMon: zero contract");
require(tokenIdOf[contractAddrs[i]] == 0, "FeeMon: already registered");
uint256 tid = nextTokenId++;
earningContract[tid] = contractAddrs[i];
tokenIdOf[contractAddrs[i]] = tid;
_mint(payoutAddr, tid);
emit Registered(contractAddrs[i], tid, payoutAddr);
tokenIds[i] = tid;
}
}
// ───────────────────── Distribution (called by Foundation distributor) ─────────────────────
/**
* @notice Credit per-tokenId fee shares. The distributor sends `totalAmount`
* as msg.value. Sum of `amounts` plus the treasury slice = totalAmount.
*/
function distribute(
uint256[] calldata tokenIds,
uint256[] calldata amounts,
uint256 treasuryAmount
) external payable onlyDistributor nonReentrant {
require(tokenIds.length == amounts.length, "FeeMon: length mismatch");
uint256 sum = treasuryAmount;
for (uint256 i = 0; i < amounts.length; i++) {
sum += amounts[i];
}
require(sum == msg.value, "FeeMon: amount mismatch");
// Treasury slice
if (treasuryAmount > 0) {
(bool ok, ) = treasury.call{ value: treasuryAmount }("");
require(ok, "FeeMon: treasury transfer failed");
totalPaidToTreasury += treasuryAmount;
emit TreasuryFunded(treasuryAmount, totalPaidToTreasury);
}
// Per-token shares (held in contract until claim)
for (uint256 i = 0; i < tokenIds.length; i++) {
require(earningContract[tokenIds[i]] != address(0), "FeeMon: bad tokenId");
pendingRewards[tokenIds[i]] += amounts[i];
totalPaidToToken[tokenIds[i]] += amounts[i];
emit Distributed(tokenIds[i], amounts[i], pendingRewards[tokenIds[i]]);
}
}
// ───────────────────── Claim ─────────────────────
function claim(uint256 tokenId) external nonReentrant returns (uint256 amount) {
require(ownerOf(tokenId) == msg.sender, "FeeMon: not NFT owner");
amount = pendingRewards[tokenId];
require(amount > 0, "FeeMon: nothing to claim");
pendingRewards[tokenId] = 0;
(bool ok, ) = payable(msg.sender).call{ value: amount }("");
require(ok, "FeeMon: claim transfer failed");
emit Claimed(tokenId, msg.sender, amount);
}
/// View: how much would the current NFT owner withdraw if they called claim() now?
function claimableFor(address holder) external view returns (uint256 total) {
uint256 bal = balanceOf(holder);
if (bal == 0) return 0;
for (uint256 t = 1; t < nextTokenId; t++) {
if (_ownerOf(t) == holder) {
total += pendingRewards[t];
}
}
}
// ───────────────────── Stats ─────────────────────
function policy() external view returns (uint256 burnBps_, uint256 devShareBps_, uint256 treasuryBps_, uint256 validatorBps_) {
burnBps_ = 3750; // Tier 1.8 - documented here for clarity only; actual burn lives in AereCoinbaseSplitter
devShareBps_ = devShareBps;
treasuryBps_ = treasuryBps;
validatorBps_ = BPS - 3750 - devShareBps - treasuryBps;
}
receive() external payable {}
}