// SPDX-License-Identifier: MIT pragma solidity 0.8.28; /// 0xequity's rent position NFT. One position is "N of my tokens in this /// property, and here is where its rent should end up". interface IRentPositions { struct RewardConfig { uint8 rewardBehavior; address harvestTokenOut; /// Where the rent goes. Set by the POSITION OWNER at createPosition and /// changeable only through updateNFTSettings, which is onlyNftOwner. /// Validated non-zero by RentPositions and read by nothing else there — /// it is a declared destination waiting for something to honour it. address rewardrecipient; } struct PositionSettings { address propertyTokenAddress; uint256 noOfPropertyTokens; bool automateRewards; RewardConfig rewardBehavior; } function tokenIdToPositionSettings(uint256 tokenId) external view returns (PositionSettings memory); /// Pays `receiver` directly. The caller never holds the funds. /// Gated onlyNftOwnerOrIndexDelegatee. function withdrawRentOfIndex(uint256 tokenId, uint256 index, address receiver) external returns (uint256 rent); function ownerOf(uint256 tokenId) external view returns (address); } /// @title RentPositionForwarder — one whitelisted delegatee for everybody, /// which can only ever pay the address the position's own owner nominated. /// /// THE PROBLEM. A smart account collecting rent an owner delegated to it has to /// be an index delegatee on that owner's rent position, and becoming one /// requires an entry in 0xequity's DelegateeRegistry. That entry is written by /// `whitelistDelegatee`, behind RENT_MANAGER_ROLE — a role that also carries /// `updateRewardPerMonth` (set any property's rent rate) and /// `resetMaliciousStakerAmount` (delete a staker's position). Asking 0xequity to /// grant that per account is not on, and holding it in a contract of ours means /// the whole safety argument rests on that contract having no other door. /// /// WHAT THIS DOES INSTEAD. It is whitelisted ONCE, as a single address, and /// holds no role at all. Being a whitelisted delegatee is eligibility, not /// capability: it lets an address BE NAMED by a position owner and confers /// nothing until one does. So one entry covers every account, forever, and the /// thing 0xequity is asked to approve is a contract that cannot do anything on /// its own. /// /// Why it cannot misdirect anybody's rent: /// /// It reads the destination, it does not choose it. `rewardrecipient` lives in /// the position and is writable only by the position's owner — createPosition /// and updateNFTSettings are both onlyNftOwner. This contract has no setter /// for it and no mapping of its own. /// /// It can only touch positions that named it. RentPositions gates /// `withdrawRentOfIndex` on onlyNftOwnerOrIndexDelegatee, so a position whose /// owner has not made this contract the delegatee of that index is untouchable /// — being whitelisted does not reach into anybody's position by itself. /// /// It never holds the money. `withdrawRentOfIndex` transfers straight to the /// receiver, so there is no balance here to sweep and no approval to abuse. /// /// It cannot be repointed. No admin, no owner, no upgrade path, no setter. /// The one address it knows is immutable and set at construction. /// /// And `claim` is deliberately open to anyone. It moves money only to an address /// the position owner nominated, so there is nothing to gain by calling it and /// something to lose by requiring permission: a relayer running an owner's /// pipeline should not need a signature to collect rent that owner already /// directed here. contract RentPositionForwarder { IRentPositions public immutable positions; event Forwarded(uint256 indexed tokenId, uint256 indexed index, address indexed to, uint256 amount); error NoRecipient(uint256 tokenId); constructor(IRentPositions positions_) { positions = positions_; } /// Where this position's rent is nominated to go. /// /// Exposed because it is the whole of the safety argument, and anyone /// checking what this contract would do with a position should be able to /// ask without sending a transaction. function recipientFor(uint256 tokenId) public view returns (address) { return positions.tokenIdToPositionSettings(tokenId).rewardBehavior.rewardrecipient; } /// Move one index's accumulated rent to the position's nominated recipient. function claim(uint256 tokenId, uint256 index) public returns (uint256 rent) { address to = recipientFor(tokenId); // RentPositions already refuses a zero recipient at write time. Checked // again because a zero here would send rent to address(0) rather than // revert, and a silent burn is the worst possible failure for a contract // whose entire job is delivery. if (to == address(0)) revert NoRecipient(tokenId); rent = positions.withdrawRentOfIndex(tokenId, index, to); emit Forwarded(tokenId, index, to, rent); } /// The same for several positions. /// /// Not a loop over withdrawRentOfIndexBatch: that one takes a single /// receiver for the whole batch, which would mean one owner's nominated /// address collecting another owner's rent. Each position is claimed to its /// own recipient. function claimMany(uint256[] calldata tokenIds, uint256[] calldata indexes) external returns (uint256 total) { if (tokenIds.length != indexes.length) revert(); for (uint256 i = 0; i < tokenIds.length; i++) { total += claim(tokenIds[i], indexes[i]); } } }