// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter, IERC20Balance} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; interface IRentShare { /// Harvests the CALLER's own rent. Reads as though `receiver` chooses whose /// rent is collected and it does not: called by anyone without /// RENT_DELEGATOR_ROLE, it sets `owner = msg.sender` and harvests theirs. /// So an account with no property tokens gets nothing from it, which is the /// normal arrangement here. function harvestRent(string[] calldata symbols, address receiver) external returns (uint256); /// Assigns this caller's whole rent stream to somebody else. Moves no /// tokens now and gives away income later, which is why the delegatee is not /// the agent's to choose. /// /// ONE delegatee per user, across every property — the deployed model, and /// coarser than the source's per-property one. Worth stating because it /// means a delegation is not scoped: authorising it for one building /// authorises it for all of them. function delegateRent(address delegatee) external; function resetDelegate() external; function getUserDelegatee(address user) external view returns (address); /// The property TOKEN behind a rent symbol. /// /// How a rent op turns the symbol it already carries into the address /// RentDelegatee wants, without a curated table of our own and without a new /// signed field. The venue is the authority on which token a symbol means, /// and a second answer here is a second thing to get wrong. function getSymbolToPropertyAddress(string memory symbol) external view returns (address); } /// 0xequity's rent automation. Harvests an owner's rent, redeems it, and splits /// it across that owner's RentPositions positions. /// /// `harvestRent` is a bare public function — no modifier, msg.sender never read. /// Routing it through the account buys the one-shot taskId, the allow-list /// permission and the audit event, NOT access control. Anyone can call it /// directly, and it would do the same thing. /// /// Note this is NOT the forwarder, and their safety arguments do not transfer. /// RentDelegatee is upgradeable, owned, and briefly HOLDS the redeemed USDC /// before splitting it. Mapping it as a venue is a decision to trust an /// upgradeable 0xequity contract with the harvest in flight — the same class of /// trust as rent_share and rent_distributor, and worth stating rather than /// inheriting by analogy. interface IRentDelegatee { /// Returns nothing. Do not write code that decodes a return value. function harvestRent(address propertyAddress, address delegator) external; /// The two places `harvestRent` pays, and the token it pays in — the /// venue's own public state, read so the adapter can MEASURE a harvest /// rather than take a void success for one (#22 of the 2026-10-08 audit). /// `harvestRent` redeems the delegator's rent in `USDC()`, sends the /// positions' share to `rentPositions()` and the undelegated remainder to /// the delegator. function USDC() external view returns (address); function rentPositions() external view returns (address); } /// Our own forwarder. See contracts/src/RentPositionForwarder.sol — it is /// whitelisted once as an eligible delegatee, holds no role, never custodies, /// and can only pay the recipient the position's own owner nominated. interface IRentPositionForwarder { function claim(uint256 tokenId, uint256 index) external returns (uint256 rent); function recipientFor(uint256 tokenId) external view returns (address); } interface ILockNFT { function approve(address to, uint256 tokenId) external; function ownerOf(uint256 tokenId) external view returns (address); /// What the lock is FOR. Used to work out where the proceeds may land, /// because whether an address may be paid is answered per property. function nftToRentShareLockDetails(uint256 tokenId) external view returns (LockDetails memory); struct LockDetails { string[] propertySymbols; uint256[] amounts; uint256 lockedAt; uint256 status; } } interface IRentDistributor { /// `recipient` again. Same rule. function redeem(uint256 lockNftTokenId, address recipient) external returns (uint256); } interface ISBTCheck { function getApprovedSBTCommunities(string memory symbol) external view returns (string[] memory); function getBalanceOf(address user, string memory community) external view returns (uint256); } interface IAllowList { function isAddressOnAllowList(address addressToCheck) external view returns (bool); } /// @title RentAdapter — collect the owner's rent, and give none of it away. /// /// The two arrangements this exists for, and there are exactly two. /// /// PROPERTY WITH THE OWNER. The owner points their rent stream at 0xequity's /// RentDelegatee tool, creates a RentPositions position naming how many of their /// tokens it covers, and makes our forwarder that position's index delegatee. /// `harvest_delegated` drives the harvest and split; `claim_position` collects /// this account's share. The routing lives in the position, which is what lets an /// owner send half a building's rent here and keep the rest. /// /// PROPERTY WITH THE ACCOUNT, which requires it enrolled on 0xequity. /// `claim_own_rent` collects rent it earned itself, and no delegation is /// involved at all. An account that is not enrolled collects nothing from it, /// and a claim that quietly collects nothing while appearing to work is worse /// than one that is not authorised. /// /// What is deliberately ABSENT is a third arrangement: the owner making this /// ACCOUNT their global delegatee and the account harvesting against it. The /// venue keeps one delegatee per user across every property, so that means /// signing over an entire portfolio's income with no way to scope it — and it /// claims the very slot the position machinery needs pointed at RentDelegatee. /// It is not a narrower thing an owner might want; it is the same thing without /// the routing. There is no `claim_rent`. /// /// A claim does not pay out. It mints a lock NFT, which becomes rent tokens /// later through `redeem` — which is why the account needs to receive ERC-721s, /// and why Kernel implementing that already matters here. /// /// Three of these calls carry an address deciding who ends up with money: /// `harvestRent(symbols, receiver)`, `redeem(lockNftTokenId, recipient)`, and /// `delegateRent(delegatee)`. The first two are /// written here and are not in any signed message. The third cannot be, because /// delegating to yourself is refused by the venue — its whole purpose is to pay /// somebody else. /// /// The first two resolve to owner A, not to the account, and the venue is what /// decides that: every one of these paths calls `_isKycdAndNotSanctioned` on the /// address it is about to pay. The owner's wallet carries the KYC record and the /// soul-bound token; the smart account does not. Paying the account would revert /// inside RentShare — and if it ever stopped reverting, it would be sending /// regulated income to an address that is not permitted to receive it. /// /// A is READ from the account rather than configured, so it follows an ownership /// change with nothing to update and there is no stored address to mistype. /// /// So the delegatee is handled the way every other unavoidable third party in /// this system is: as a NAME from a set owner A curated. The agent says "delegate /// to the property manager"; A decided what that means. An agent that could pass /// an address could assign the account's rent to itself, quietly, in a call that /// moves nothing at the time and looks like nothing in a balance diff. /// /// Delegation is also the one operation here a cap cannot bound. No tokens move, /// so there is no amount to charge — what it costs is future income, and this /// contract has no way to price that. The bound is the allow-list and the /// operation permission, and saying so is better than implying a cap covers it. contract RentAdapter is BaseAdapter { /// The venue name A maps to 0xequity's allow-list. Unmapped means "assume /// the account is not cleared", which is the safe direction: it delivers to /// the owner, who certainly is. bytes32 internal constant WHITELIST_VENUE = "whitelist"; /// The SBT contract, which is where the real credential lives. The /// allow-list above is a bypass 0xequity can grant; this is what a KYC'd /// user actually holds. bytes32 internal constant SBT_VENUE = "sbt"; /// The lock NFT a claim mints. Needed because redeeming does not burn it — /// the distributor does `safeTransferFrom(msg.sender, propertyAddress, id)`, /// so the account has to approve the distributor first. bytes32 internal constant LOCK_NFT_VENUE = "lock_nft"; /// 0xequity's rent automation contract, which is what the OWNER points /// RentShare at when they delegate. bytes32 internal constant RENT_DELEGATEE_VENUE = "rent_delegatee"; /// Our forwarder, the eligible delegatee that pays a position's nominated /// recipient. These two strings must match internal/adapters/registry.go /// byte for byte: a disagreement reverts UnknownVenue at execution, on a /// signature that verified perfectly. bytes32 internal constant RENT_FORWARDER_VENUE = "rent_position_forwarder"; /// Where a regulated asset may actually land: the account if the venue would /// accept it, otherwise the owner. /// /// It asks the question the VENUE asks, in the venue's own terms — on the /// allow-list, OR holding at least one of the communities approved for this /// property. Checking only the allow-list, which an earlier version did, gets /// the answer wrong in the direction that matters: an account enrolled /// through AccountKycForwarder holds the SBT and is NOT on the list, so /// deliveries kept going to the owner and the account still could not sell /// what it paid for. The allow-list is the bypass; the SBT is the credential. /// /// Both answers are OURS and neither is configured. Nothing signed reaches /// this and there is no third possibility, so the agent's inability to /// redirect is unchanged whichever branch is taken. /// /// It prefers the ACCOUNT because that is where the position belongs, and /// because only an account that HOLDS property tokens can sell them: the /// router pulls from msg.sender. /// /// With no allow-list and no SBT contract mapped it falls back to the owner, /// which is the safe direction — the owner is certainly credentialed, and /// delivering to an uncredentialed account reverts inside the venue. function holderFor(address account, string memory symbol) public view returns (address) { address list = _venueOrZero(WHITELIST_VENUE); if (list != address(0) && IAllowList(list).isAddressOnAllowList(account)) { return account; } address sbt = _venueOrZero(SBT_VENUE); if (sbt != address(0) && bytes(symbol).length != 0) { string[] memory approved = ISBTCheck(sbt).getApprovedSBTCommunities(symbol); for (uint256 i = 0; i < approved.length; i++) { if (ISBTCheck(sbt).getBalanceOf(account, approved[i]) != 0) { return account; } } } return ownerOf(account); } /// Collect rent on property tokens the ACCOUNT itself holds. Only meaningful /// once the account is enrolled and has bought property into itself; before /// that it collects nothing, because the account holds nothing. bytes32 public constant OP_CLAIM_OWN = "claim_own_rent"; bytes32 public constant OP_REDEEM = "redeem_rent"; bytes32 public constant OP_DELEGATE = "delegate_rent"; bytes32 public constant OP_UNDELEGATE = "undelegate_rent"; /// Drive 0xequity's rent automation for the OWNER'S holding. /// /// The owner delegated to the RentDelegatee TOOL, created positions naming /// how many tokens each covers, and made our forwarder the delegatee of /// those positions. This harvests and splits; OP_CLAIM_POSITION collects /// this account's share. /// /// The owner's one global delegatee slot has to point at that tool for this /// to do anything. Checked before signing rather than discovered as a revert /// — see CheckGroupClearance. bytes32 public constant OP_HARVEST_DELEGATED = "harvest_delegated"; /// Collect this account's share of a position's harvested rent. bytes32 public constant OP_CLAIM_POSITION = "claim_position"; bytes32 internal constant RENT_TYPEHASH = keccak256( "Rent(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 operation," "string symbol,bytes32 delegatee,uint256 amount)" ); struct Act { address account; address owner; bytes32 taskId; uint256 deadline; bytes32 operation; /// The property symbol the venue keys rent on. A string because that is /// what RentShare takes; it names a property, not an address. string symbol; /// WHO the delegation pays, by name: `rent_delegatee` (the protocol's /// venue) or one from the set A curated. Meaningful for delegate only, /// and never an address. bytes32 delegatee; /// Token count for a delegation, or the lock NFT id for a redemption. uint256 amount; } /// Addresses this account is willing to delegate rent to. Wiped on /// uninstall; stamped with `cfg.gen` so a leftover name is also inert if /// the current generation does not match. mapping(address account => mapping(bytes32 name => address)) internal _delegatees; mapping(address account => mapping(bytes32 name => uint64 gen)) internal _delegateeGen; mapping(address account => bytes32[] names) internal _delegateeKeys; event DelegateeAllowed(address indexed account, bytes32 indexed name, address who); event RentActed( address indexed account, bytes32 indexed operation, string symbol, uint256 amount ); event PositionClaimed( address indexed account, uint256 indexed tokenId, uint256 index, address indexed to, uint256 rent ); error UnknownOperation(bytes32 operation); error UnknownDelegatee(address account, bytes32 name); /// `rent_delegatee` is the protocol's venue, resolved from the registry by /// `delegateeOf`. An owner-set row under that name would never be read, and /// accepting one would let the owner believe they had changed it. error ReservedDelegatee(bytes32 name); error NoRentArrived(string symbol); error NoSymbol(); error LockNotRedeemed(uint256 lockNftId); /// The position pays somebody who is neither this account nor its owner. /// Distinct from UnknownDelegatee, which is about OUR curated list rather /// than about whose rent this is. error NotOurPosition(uint256 tokenId, address recipient); error UnknownPropertySymbol(string symbol); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsRentAdapter", "1"); } /// Allow an address to be delegated to, by name. Callable only by the /// account. Removing a name is `resetCuration` or reinstall, which wipes /// the table. /// /// NOT `rent_delegatee`. That name is 0xequity's automation contract, and it /// is the protocol's: `delegateeOf` resolves it from the registry's venue /// table for every account, so a v2 account — which installs nothing and so /// was never curated one — can delegate to it. An owner-set row under that /// name would never be read. function allowDelegatee(bytes32 name, address who) external { if (name == RENT_DELEGATEE_VENUE) revert ReservedDelegatee(name); // THE CALLER IS THE ACCOUNT, which is the whole authority — the // install was a proxy for it and a v2 account never installs. // See `BaseAdapter._accountConfigGen`. _accountConfigGen(); uint64 gen = _config[msg.sender].gen; if (_delegateeGen[msg.sender][name] != gen) { _delegateeKeys[msg.sender].push(name); } _delegatees[msg.sender][name] = who; _delegateeGen[msg.sender][name] = gen; // WHOSE ROW THIS IS — M-3 of round 2, #18 of 2026-10-08. Written // inside an installer sync it is the registry's, and the next sync // may drop it; written any other time it is the owner's, and only the // owner's own `resetCuration` (or an uninstall) removes it. _curatedBySync[msg.sender][name] = _inSync(); emit DelegateeAllowed(msg.sender, name, who); } /// Who `name` delegates to for `account`. `rent_delegatee` is the /// protocol's venue, the same for every account; any other name is the /// owner's own row. function delegateeOf(address account, bytes32 name) public view returns (address who) { if (name == RENT_DELEGATEE_VENUE) return _venueOf(account, RENT_DELEGATEE_VENUE); who = _delegatees[account][name]; if (who == address(0) || _delegateeGen[account][name] != _config[account].gen) { revert UnknownDelegatee(account, name); } } /// Drop EVERY delegatee name the owner set for the caller. The account /// clearing its own table. `rent_delegatee` is not in it and is unaffected. function resetCuration() external { // THE CALLER IS THE ACCOUNT, which is the whole authority — the // install was a proxy for it and a v2 account never installs. // See `BaseAdapter._accountConfigGen`. _accountConfigGen(); _clearDelegatees(msg.sender); } function _wipeAuthority(address account) internal override { super._wipeAuthority(account); _clearDelegatees(account); } /// AN INSTALLER SYNC DROPS THE NAMES AN EARLIER SYNC WROTE, AND NO OTHERS. /// See `BaseAdapter.resetSyncedCuration`. Swap-and-pop, so the key list /// stays the set of live names and a name written again is listed once. function _resetSyncedCuration(address account) internal override { bytes32[] storage names = _delegateeKeys[account]; uint256 i = 0; while (i < names.length) { bytes32 name = names[i]; if (!_curatedBySync[account][name]) { i++; continue; } delete _delegatees[account][name]; delete _delegateeGen[account][name]; delete _curatedBySync[account][name]; names[i] = names[names.length - 1]; names.pop(); } } /// UNINSTALL clears everything. See MorphoAdapter._clearMarkets. function _clearDelegatees(address account) internal { bytes32[] storage names = _delegateeKeys[account]; for (uint256 i = 0; i < names.length; i++) { delete _delegatees[account][names[i]]; delete _delegateeGen[account][names[i]]; delete _curatedBySync[account][names[i]]; } delete _delegateeKeys[account]; } function digest(Act calldata a) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( RENT_TYPEHASH, a.account, a.owner, a.taskId, a.deadline, a.operation, // A string member is hashed, per EIP-712. keccak256(bytes(a.symbol)), a.delegatee, a.amount ) ) ); } function act(Act calldata a, bytes calldata signature) external { _consume(a.account, a.owner, a.taskId, a.deadline, digest(a), signature); _requireOperation(a.account, a.operation); if (a.operation == OP_REDEEM) { _redeem(a); } else if (a.operation == OP_CLAIM_OWN) { _claimOwn(a); } else if (a.operation == OP_DELEGATE) { // No symbol and no count: the deployed model is one delegatee for // everything this account earns. Which makes the curated list matter // MORE than it would under a per-property model — there is no // narrower thing to authorise. address who = delegateeOf(a.account, a.delegatee); _exec( a.account, _venueOf(a.account, "rent_share"), 0, abi.encodeCall(IRentShare.delegateRent, (who)) ); } else if (a.operation == OP_UNDELEGATE) { _exec( a.account, _venueOf(a.account, "rent_share"), 0, abi.encodeCall(IRentShare.resetDelegate, ()) ); } else if (a.operation == OP_HARVEST_DELEGATED) { _harvestDelegated(a); } else if (a.operation == OP_CLAIM_POSITION) { _claimPosition(a); } else { revert UnknownOperation(a.operation); } emit RentActed(a.account, a.operation, a.symbol, a.amount); } /// Drive the owner's rent through 0xequity's automation. /// /// The delegator is READ from the account rather than signed: an agent that /// could name a delegator would be naming whose rent gets harvested. The /// venue keys the delegation on (delegator, pool, msg.sender) and would /// refuse a stranger anyway; pinning it removes the field rather than /// relying on that. /// /// The property ADDRESS comes from the venue, resolved from the symbol this /// act already carries. RentDelegatee wants an address and every other rent /// op speaks in symbols, so the translation happens here rather than by /// widening the signed struct or curating a second address table. /// /// Nothing leaves the ACCOUNT here, so there is no cap to charge and no /// residual to check — the money moves from the venue to the owner's /// positions and to the owner's own wallet. Note that last part: the /// UN-delegated remainder goes to A, not here. This op delivers the account /// nothing, which is why its catalogue entry declares Produces:false and why /// no later step may spend its output. function _harvestDelegated(Act calldata a) internal { if (bytes(a.symbol).length == 0) revert NoSymbol(); address property = IRentShare(_venueOf(a.account, "rent_share")).getSymbolToPropertyAddress(a.symbol); // A symbol the venue does not know resolves to zero, and a CALL to a // codeless address SUCCEEDS with empty returndata — so without this the // harvest would report success having done nothing at all. if (property == address(0)) revert UnknownPropertySymbol(a.symbol); // A HARVEST THAT MOVED NO RENT DID NOT WORK — #22 of the 2026-10-08 // audit. `harvestRent` returns nothing, so a call that succeeded told // this adapter nothing at all: a property with no accrued rent, or an // owner whose delegation points elsewhere, completed, emitted // RentActed and burned the task id. `_claimOwn` and `_claimPosition` // already refuse `NoRentArrived` on zero; this is the same rule. // // MEASURED WHERE THE VENUE PAYS. The rent lands in two places neither // of which is the account — the positions' share in RentPositions, the // undelegated rest at the owner — so the measure is their SUM, in the // venue's own rent token. Nothing else moves either balance inside // this call. address tool = _venueOf(a.account, RENT_DELEGATEE_VENUE); address owner = ownerOf(a.account); uint256 before = _rentHeld(tool, owner); _exec(a.account, tool, 0, abi.encodeCall(IRentDelegatee.harvestRent, (property, owner))); if (_rentHeld(tool, owner) <= before) revert NoRentArrived(a.symbol); } /// What `harvestRent` pays into, summed: RentPositions plus the owner. function _rentHeld(address tool, address owner) internal view returns (uint256) { IERC20Balance token = IERC20Balance(IRentDelegatee(tool).USDC()); return token.balanceOf(IRentDelegatee(tool).rentPositions()) + token.balanceOf(owner); } /// Collect this account's share of a position's rent. /// /// `amount` is the POSITION token id, the same way it is the lock NFT id for /// a redemption. The index is pinned to 0 and that is the venue's decision, /// not a simplification: RentDelegatee credits positions through /// accumulateRent, which writes tokenIdToNFTDetails[id][0] and says "0 is /// default index" in as many words. createPosition pushes exactly one entry. /// A signed index field would therefore be a field with one legal value. /// /// The recipient is NOT ours to choose and is not read from anything signed. /// The forwarder takes no receiver at all — it reads what the position's /// owner nominated. What is checked here is only that the nomination points /// at this account or its owner, so an agent cannot burn a taskId moving a /// stranger's rent to a stranger. /// /// Deliberately NOT gated on holderFor: that returns the account only if it /// is enrolled, and this arrangement does not require enrolment. Rent payout /// has no KYC gate — RentPositions transfers plain USDC — so a holderFor /// check would pass here by luck and refuse a correctly configured account. function _claimPosition(Act calldata a) internal { address fwd = _venueOf(a.account, RENT_FORWARDER_VENUE); address to = IRentPositionForwarder(fwd).recipientFor(a.amount); if (to != a.account && to != ownerOf(a.account)) revert NotOurPosition(a.amount, to); bytes memory ret = _exec(a.account, fwd, 0, abi.encodeCall(IRentPositionForwarder.claim, (a.amount, 0))); uint256 rent = ret.length >= 32 ? abi.decode(ret, (uint256)) : 0; // An empty position withdraws cleanly and moves nothing: RentPositions // only transfers when the amount is non-zero. A claim that quietly // collects nothing while appearing to work is the failure this file's // header calls worse than one that is refused, so it is made loud here. if (rent == 0) revert NoRentArrived(a.symbol); emit PositionClaimed(a.account, a.amount, 0, to, rent); } /// Collect rent on property tokens the ACCOUNT holds itself. /// /// Separate from the delegated claim because it is a different arrangement, /// not a different encoding: this one needs the account to own property, /// which needs the account to be enrolled. Kept apart so an owner can /// authorise one without the other, and so neither silently collects nothing /// while looking like it worked. function _claimOwn(Act calldata a) internal { if (bytes(a.symbol).length == 0) revert NoSymbol(); string[] memory symbols = new string[](1); symbols[0] = a.symbol; address to = holderFor(a.account, a.symbol); bytes memory ret = _exec( a.account, _venueOf(a.account, "rent_share"), 0, abi.encodeCall(IRentShare.harvestRent, (symbols, to)) ); uint256 rent = ret.length >= 32 ? abi.decode(ret, (uint256)) : 0; // A HARVEST THAT COLLECTED NOTHING DID NOT WORK, and this said it had. // // `harvestRent` returns an amount and it was thrown away, so an account // holding no property tokens — the ordinary consequence of a plan that // ran a step early, or of a property whose rent has not accrued — // completed, emitted RentActed, and burned the task id. The intent // cannot be retried afterwards, because a spent task id is spent. // // Its sibling `_claimPosition` already refuses `NoRentArrived` on zero, // and the header of this file calls a claim that quietly collects // nothing the worse failure. // // THE VENUE'S OWN NUMBER, read the same way `_claimPosition` reads its // forwarder's. A balance delta would be better and there is nothing to // measure it on: the rent token is not a venue this adapter curates, and // `harvestRent` pays whoever `holderFor` named — which is the OWNER for // an unenrolled account, so the account's balance would not move on a // harvest that worked. if (rent == 0) revert NoRentArrived(a.symbol); } /// Redeem a matured rent lock. /// /// `amount` is the lock NFT id rather than a quantity, which is unusual /// enough to be worth saying twice: a claim does not pay out, it mints one of /// these, and this is what turns it into tokens. /// /// The distributor moves the NFT rather than burning it — /// `safeTransferFrom(msg.sender, propertyAddress, id)`, where msg.sender is /// the account — so the account approves the distributor first. Without it /// the venue reverts "caller is not token owner or approved", which reads as /// though the ACCOUNT does not own the lock when in fact it does. /// /// No residual to clear: an ERC-721 approval is consumed by the transfer it /// authorises. Checked rather than assumed, because "the transfer happened" /// and "the approval is gone" are the same fact only if the transfer /// actually happened. function _redeem(Act calldata a) internal { address distributor = _venueOf(a.account, "rent_distributor"); address lock = _venueOf(a.account, LOCK_NFT_VENUE); // The symbol comes from the LOCK, not from the intent. // // A redemption names a lock NFT id and nothing else, so `symbol` is // empty on this path — and an empty symbol makes holderFor unable to ask // whether the account may be paid, so it fell back to the owner. The // effect was a claim that credited the account and a redemption that // paid the owner, for the same rent, with nothing saying why. // // Reading it from the lock also means the plan cannot get it wrong: the // lock knows what it is for, and a symbol repeated in the intent would // be a second copy that could disagree. ILockNFT.LockDetails memory d = ILockNFT(lock).nftToRentShareLockDetails(a.amount); string memory symbol = d.propertySymbols.length == 0 ? "" : d.propertySymbols[0]; _exec(a.account, lock, 0, abi.encodeCall(ILockNFT.approve, (distributor, a.amount))); _exec( a.account, distributor, 0, abi.encodeCall(IRentDistributor.redeem, (a.amount, holderFor(a.account, symbol))) ); if (ILockNFT(lock).ownerOf(a.amount) == a.account) revert LockNotRedeemed(a.amount); } }