// 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 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 {} }