diff --git a/src/LoanManager.sol b/src/LoanManager.sol index 98566cdd..9fa8261a 100644 --- a/src/LoanManager.sol +++ b/src/LoanManager.sol @@ -72,7 +72,7 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 address public feeTo; uint96 public defaultFeeRake; //contract to token //fee rake - mapping(address => Fee) public exoticFee; + mapping(address => Fee) public feeOverride; enum FieldFlags { INITIALIZED, @@ -117,9 +117,8 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 } struct Fee { - ItemType itemType; - address token; - uint88 rake; + bool enabled; + uint96 amount; } event Close(uint256 loanId); @@ -130,14 +129,15 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 error ConduitTransferError(); error InvalidConduit(); error InvalidRefinance(); - error NotSeaport(); - error NotLoanCustodian(); - error InvalidAction(); - error InvalidLoan(uint256); + error InvalidCustodian(); + error InvalidLoan(); error InvalidMaximumSpentEmpty(); error InvalidDebt(); error InvalidOrigination(); error InvalidNoRefinanceConsideration(); + error NotLoanCustodian(); + error NotPayingFees(); + error NotSeaport(); constructor(ConsiderationInterface seaport_) { seaport = seaport_; @@ -198,8 +198,8 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 } function tokenURI(uint256 tokenId) public view override returns (string memory) { - if (!_exists(tokenId)) { - revert InvalidLoan(tokenId); + if (!_issued(tokenId)) { + revert InvalidLoan(); } return string(""); } @@ -212,12 +212,6 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 return _issued(tokenId); } - //break the revert of the ownerOf method, so we can ensure anyone calling it in the settlement pipeline wont halt - function ownerOf(uint256 loanId) public view override returns (address) { - //not hasn't been issued but exists if we own it - return _issued(loanId) && !_exists(loanId) ? address(this) : _ownerOf(loanId); - } - function settle(Loan memory loan) external { if (msg.sender != loan.custodian) { revert NotLoanCustodian(); @@ -228,7 +222,7 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 function _settle(Loan memory loan) internal { uint256 tokenId = loan.getId(); if (!_issued(tokenId)) { - revert InvalidLoan(tokenId); + revert InvalidLoan(); } if (_exists(tokenId)) { _burn(tokenId); @@ -262,7 +256,7 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 Custodian(payable(custodian)).custody(consideration, orderHashes, contractNonce, context) != Custodian.custody.selector ) { - revert InvalidAction(); + revert InvalidCustodian(); } } } @@ -287,6 +281,7 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 ) public view returns (SpentItem[] memory offer, ReceivedItem[] memory consideration) { LoanManager.Obligation memory obligation = abi.decode(context, (LoanManager.Obligation)); + bool feeOn; if (obligation.debt.length == 0) { revert InvalidDebt(); } @@ -295,7 +290,7 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 } consideration = maximumSpentFromBorrower.toReceivedItems(obligation.custodian); if (feeTo != address(0)) { - consideration = _mergeFees(consideration, _feeRake(obligation.debt)); + feeOn = true; } address receiver = obligation.borrower; @@ -308,9 +303,13 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 ); SpentItem[] memory debt = obligation.debt; offer = new SpentItem[](debt.length + 1); + SpentItem[] memory feeItems = !feeOn ? new SpentItem[](0) : _feeRake(debt); for (uint256 i; i < debt.length;) { offer[i] = debt[i]; + if (feeOn && feeItems[i].amount > 0) { + offer[i].amount = debt[i].amount - feeItems[i].amount; + } unchecked { ++i; } @@ -318,6 +317,19 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 offer[debt.length] = SpentItem({itemType: ItemType.ERC721, token: address(this), identifier: uint256(caveatHash), amount: 1}); + } else if (feeOn) { + SpentItem[] memory debt = obligation.debt; + offer = new SpentItem[](debt.length); + + SpentItem[] memory feeItems = !feeOn ? new SpentItem[](0) : _feeRake(debt); + + for (uint256 i; i < debt.length;) { + offer[i] = debt[i]; + offer[i].amount = debt[i].amount - feeItems[i].amount; + unchecked { + ++i; + } + } } } @@ -338,53 +350,26 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 defaultFeeRake = defaultFeeRake_; } - function setExoticFee(address exotic, Fee memory fee) external onlyOwner { - exoticFee[exotic] = fee; - } - - function getExoticFee(SpentItem memory exotic) public view returns (Fee memory fee) { - return exoticFee[exotic.token]; + function setFeeOverride(address token, uint96 overrideValue) external onlyOwner { + feeOverride[token].enabled = true; + feeOverride[token].amount = overrideValue; } - function _feeRake(SpentItem[] memory debt) internal view returns (ReceivedItem[] memory feeConsideration) { - uint256 i = 0; - feeConsideration = new ReceivedItem[](debt.length); - for (; i < debt.length;) { - feeConsideration[i].identifier = 0; //fees are native or erc20 - feeConsideration[i].recipient = payable(feeTo); + function _feeRake(SpentItem[] memory debt) internal view returns (SpentItem[] memory feeItems) { + feeItems = new SpentItem[](debt.length); + uint256 totalDebtItems; + for (uint256 i = 0; i < debt.length;) { + Fee memory feeOverride = feeOverride[debt[i].token]; + feeItems[i].identifier = 0; //fees are native or erc20 if (debt[i].itemType == ItemType.NATIVE || debt[i].itemType == ItemType.ERC20) { - feeConsideration[i].amount = debt[i].amount.mulDiv( - defaultFeeRake, debt[i].itemType == ItemType.NATIVE ? 1e18 : 10 ** ERC20(debt[i].token).decimals() + feeItems[i].amount = debt[i].amount.mulDiv( + !feeOverride.enabled ? defaultFeeRake : feeOverride.amount, + debt[i].itemType == ItemType.NATIVE ? 1e18 : 10 ** ERC20(debt[i].token).decimals() ); - feeConsideration[i].token = debt[i].token; - feeConsideration[i].itemType = debt[i].itemType; - } else { - Fee memory fee = getExoticFee(debt[i]); - feeConsideration[i].itemType = fee.itemType; - feeConsideration[i].token = fee.token; - feeConsideration[i].amount = fee.rake; //flat fee - } - unchecked { - ++i; - } - } - } - - function _mergeFees(ReceivedItem[] memory first, ReceivedItem[] memory second) - internal - pure - returns (ReceivedItem[] memory consideration) - { - consideration = new ReceivedItem[](first.length + second.length); - uint256 i = 0; - for (; i < first.length;) { - consideration[i] = first[i]; - unchecked { - ++i; + feeItems[i].token = debt[i].token; + feeItems[i].itemType = debt[i].itemType; + ++totalDebtItems; } - } - for (i = first.length; i < second.length;) { - consideration[i] = second[i]; unchecked { ++i; } @@ -396,6 +381,10 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 SpentItem[] calldata maximumSpentFromBorrower, bytes calldata context ) internal returns (SpentItem[] memory offer, ReceivedItem[] memory consideration) { + bool feesOn = false; + if (feeTo != address(0)) { + feesOn = true; + } LoanManager.Obligation memory obligation = abi.decode(context, (LoanManager.Obligation)); if (obligation.debt.length == 0) { @@ -405,12 +394,10 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 revert InvalidMaximumSpentEmpty(); } consideration = maximumSpentFromBorrower.toReceivedItems(obligation.custodian); - if (feeTo != address(0)) { - consideration = _mergeFees(consideration, _feeRake(obligation.debt)); - } + address receiver = obligation.borrower; bool enforceCaveats = fulfiller != receiver || obligation.caveats.length > 0; - if (enforceCaveats) { + if (enforceCaveats || feesOn) { receiver = address(this); } Originator.Response memory response = Originator(obligation.originator).execute( @@ -452,7 +439,9 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 ++i; } } - offer = _setOffer(loan.debt, caveatHash); + offer = _setOffer(loan.debt, caveatHash, feesOn); + } else if (feesOn) { + offer = _setOffer(loan.debt, bytes32(0), feesOn); } _issueLoanManager(loan, response.issuer.code.length > 0); } @@ -487,6 +476,14 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 (offer, consideration) = _fillObligationAndVerify(fulfiller, maximumSpent, context); } + function _moveFeesToReceived(SpentItem memory feeItem) internal { + if (feeItem.itemType == ItemType.NATIVE) { + payable(feeTo).call{value: feeItem.amount}(""); + } else if (feeItem.itemType == ItemType.ERC20) { + ERC20(feeItem.token).transfer(feeTo, feeItem.amount); + } + } + function _enableDebtWithSeaport(SpentItem memory debt) internal { //approve consideration based on item type if (debt.itemType == ItemType.NATIVE) { @@ -502,28 +499,32 @@ contract LoanManager is ContractOffererInterface, ConduitHelper, Ownable, ERC721 } } - function _setOffer(SpentItem[] memory debt, bytes32 caveatHash) internal returns (SpentItem[] memory offer) { - offer = new SpentItem[](debt.length + 1); - + function _setOffer(SpentItem[] memory debt, bytes32 caveatHash, bool feesOn) + internal + returns (SpentItem[] memory offer) + { + uint256 caveatLength = (caveatHash == bytes32(0)) ? 0 : 1; + offer = new SpentItem[](debt.length + caveatLength); + SpentItem[] memory feeItems = !feesOn ? new SpentItem[](0) : _feeRake(debt); for (uint256 i; i < debt.length;) { offer[i] = debt[i]; - _enableDebtWithSeaport(debt[i]); + if (feesOn) { + offer[i].amount = debt[i].amount - feeItems[i].amount; + _moveFeesToReceived(feeItems[i]); + } + _enableDebtWithSeaport(offer[i]); unchecked { ++i; } } - - offer[debt.length] = - SpentItem({itemType: ItemType.ERC721, token: address(this), identifier: uint256(caveatHash), amount: 1}); - } - - function transferFrom(address from, address to, uint256 tokenId) public payable override { - //active loans do nothing - if (from != address(this)) revert CannotTransferLoans(); + if (caveatHash != bytes32(0)) { + offer[debt.length] = + SpentItem({itemType: ItemType.ERC721, token: address(this), identifier: uint256(caveatHash), amount: 1}); + } } - function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) public payable override { - if (from != address(this)) revert CannotTransferLoans(); + function transferFrom(address from, address to, uint256 tokenId) public payable override onlySeaport { + if (address(this) != from) revert CannotTransferLoans(); } /** diff --git a/test/StarPortTest.sol b/test/StarPortTest.sol index 8e3ab641..c904b9f7 100644 --- a/test/StarPortTest.sol +++ b/test/StarPortTest.sol @@ -694,7 +694,16 @@ contract StarPortTest is BaseOrderTest { balanceAfter = ERC20(debt[0].token).balanceOf(borrower.addr); } - assertEq(balanceAfter - balanceBefore, debt[0].amount); + uint256 feeReceiverBalance; + if (LM.feeTo() != address(0)) { + if (debt[0].token == address(0)) { + feeReceiverBalance = LM.feeTo().balance; + } else { + feeReceiverBalance = ERC20(debt[0].token).balanceOf(LM.feeTo()); + } + } + + assertEq(balanceAfter - balanceBefore + feeReceiverBalance, debt[0].amount); vm.stopPrank(); } @@ -787,6 +796,15 @@ contract StarPortTest is BaseOrderTest { ConsiderationItem memory collateral, SpentItem memory debtRequested, address incomingIssuer + ) internal returns (Originator.Details memory details) { + return _generateOriginationDetails(collateral, debtRequested, incomingIssuer, address(LM.defaultCustodian())); + } + + function _generateOriginationDetails( + ConsiderationItem memory collateral, + SpentItem memory debtRequested, + address incomingIssuer, + address incomingCustodian ) internal returns (Originator.Details memory details) { delete selectedCollateral; delete debt; @@ -802,7 +820,7 @@ contract StarPortTest is BaseOrderTest { }); details = Originator.Details({ conduit: address(lenderConduit), - custodian: address(custodian), + custodian: address(incomingCustodian), issuer: incomingIssuer, deadline: block.timestamp + 100, offer: Originator.Offer({ @@ -890,6 +908,21 @@ contract StarPortTest is BaseOrderTest { }); } + function _getERC721Consideration(TestERC721 token, uint256 tokenId) + internal + view + returns (ConsiderationItem memory) + { + return ConsiderationItem({ + token: address(token), + startAmount: 1, + endAmount: 1, + identifierOrCriteria: tokenId, + itemType: ItemType.ERC721, + recipient: payable(address(custodian)) + }); + } + function _getERC1155Consideration(TestERC1155 token) internal view returns (ConsiderationItem memory) { return ConsiderationItem({ token: address(token), diff --git a/test/TestLoanManager.sol b/test/TestLoanManager.sol index ba072e1a..2be7d78a 100644 --- a/test/TestLoanManager.sol +++ b/test/TestLoanManager.sol @@ -48,6 +48,17 @@ contract MockOriginator is Originator, TokenReceiverInterface { } } +contract MockCustodian is Custodian { + constructor(LoanManager LM_, address seaport) Custodian(LM_, seaport) {} + + function custody( + ReceivedItem[] calldata consideration, + bytes32[] calldata orderHashes, + uint256 contractNonce, + bytes calldata context + ) external virtual override onlyLoanManager returns (bytes4 selector) {} +} + contract TestLoanManager is StarPortTest { using Cast for *; @@ -55,8 +66,27 @@ contract TestLoanManager is StarPortTest { using {StarPortLib.getId} for LoanManager.Loan; + uint256 public borrowAmount = 100; + MockCustodian mockCustodian = new MockCustodian(LM, address(seaport)); + function setUp() public virtual override { super.setUp(); + + erc20s[0].approve(address(lenderConduit), 100000); + + mockCustodian = new MockCustodian(LM, address(seaport)); + Originator.Details memory defaultLoanDetails = _generateOriginationDetails( + _getERC721Consideration(erc721s[0]), _getERC20SpentItem(erc20s[0], borrowAmount), lender.addr + ); + + LoanManager.Loan memory loan = newLoan( + NewLoanData(address(custodian), new LoanManager.Caveat[](0), abi.encode(defaultLoanDetails)), + Originator(UO), + selectedCollateral + ); + Custodian(custodian).mint(loan); + + loan.toStorage(activeLoan); } function testName() public { @@ -106,4 +136,201 @@ contract TestLoanManager is StarPortTest { //TODO:: validate return data matches request // assertEq(keccak256(abi.encode(consideration)), keccak256(abi.encode(maxSpent))); } + + function testCannotSettleUnlessValidCustodian() public { + vm.expectRevert(abi.encodeWithSelector(LoanManager.NotLoanCustodian.selector)); + LM.settle(activeLoan); + } + + function testCannotSettleInvalidLoan() public { + activeLoan.borrower = address(0); + vm.prank(activeLoan.custodian); + vm.expectRevert(abi.encodeWithSelector(LoanManager.InvalidLoan.selector)); + LM.settle(activeLoan); + } + + function testIssued() public { + assert(LM.issued(activeLoan.getId())); + } + + function testInitializedFlagSetProperly() public { + activeLoan.borrower = address(0); + assert(LM.initialized(activeLoan.getId())); + } + + function testTokenURI() public { + assertEq(LM.tokenURI(uint256(keccak256(abi.encode(activeLoan)))), ""); + } + + function testTokenURIInvalidLoan() public { + vm.expectRevert(abi.encodeWithSelector(LoanManager.InvalidLoan.selector)); + LM.tokenURI(uint256(0)); + } + + function testTransferFromFailFromSeaport() public { + vm.startPrank(address(LM.seaport())); + vm.expectRevert(abi.encodeWithSelector(LoanManager.CannotTransferLoans.selector)); + LM.transferFrom(address(this), address(this), uint256(keccak256(abi.encode(activeLoan)))); + vm.expectRevert(abi.encodeWithSelector(LoanManager.CannotTransferLoans.selector)); + LM.safeTransferFrom(address(this), address(this), uint256(keccak256(abi.encode(activeLoan)))); + vm.expectRevert(abi.encodeWithSelector(LoanManager.CannotTransferLoans.selector)); + LM.safeTransferFrom(address(this), address(this), uint256(keccak256(abi.encode(activeLoan))), ""); + vm.stopPrank(); + } + + function testNonDefaultCustodianCustodyCallFails() public { + LoanManager.Obligation memory obligation = LoanManager.Obligation({ + custodian: address(mockCustodian), + borrower: address(0), + debt: new SpentItem[](0), + salt: bytes32(0), + details: "", + approval: "", + caveats: new LoanManager.Caveat[](0), + originator: address(UO) + }); + vm.prank(address(LM.seaport())); + vm.expectRevert(abi.encodeWithSelector(LoanManager.InvalidCustodian.selector)); + LM.ratifyOrder(new SpentItem[](0), new ReceivedItem[](0), abi.encode(obligation), new bytes32[](0), uint256(0)); + } + + function testNonDefaultCustodianCustodyCallSuccess() public { + LoanManager.Obligation memory obligation = LoanManager.Obligation({ + custodian: address(mockCustodian), + borrower: address(0), + debt: new SpentItem[](0), + salt: bytes32(0), + details: "", + approval: "", + caveats: new LoanManager.Caveat[](0), + originator: address(UO) + }); + + vm.mockCall( + address(mockCustodian), + abi.encodeWithSelector( + Custodian.custody.selector, new ReceivedItem[](0), new bytes32[](0), uint256(0), abi.encode(obligation) + ), + abi.encode(bytes4(Custodian.custody.selector)) + ); + vm.prank(address(LM.seaport())); + LM.ratifyOrder(new SpentItem[](0), new ReceivedItem[](0), abi.encode(obligation), new bytes32[](0), uint256(0)); + } + + function testInvalidDebt() public { + LoanManager.Obligation memory obligation = LoanManager.Obligation({ + custodian: address(mockCustodian), + borrower: address(0), + debt: new SpentItem[](0), + salt: bytes32(0), + details: "", + approval: "", + caveats: new LoanManager.Caveat[](0), + originator: address(UO) + }); + vm.prank(address(LM.seaport())); + vm.expectRevert(abi.encodeWithSelector(LoanManager.InvalidDebt.selector)); + LM.generateOrder(address(this), new SpentItem[](0), new SpentItem[](0), abi.encode(obligation)); + } + + function testInvalidMaximumSpentEmpty() public { + LoanManager.Obligation memory obligation = LoanManager.Obligation({ + custodian: address(mockCustodian), + borrower: address(0), + debt: debt, + salt: bytes32(0), + details: "", + approval: "", + caveats: new LoanManager.Caveat[](0), + originator: address(UO) + }); + vm.prank(address(LM.seaport())); + vm.expectRevert(abi.encodeWithSelector(LoanManager.InvalidMaximumSpentEmpty.selector)); + LM.generateOrder(address(this), new SpentItem[](0), new SpentItem[](0), abi.encode(obligation)); + } + + function testDefaultFeeRake() public { + assertEq(LM.defaultFeeRake(), 0); + address feeReceiver = address(20); + LM.setFeeData(feeReceiver, 1e17); //10% fees + + Originator.Details memory defaultLoanDetails = _generateOriginationDetails( + _getERC721Consideration(erc721s[0], uint256(2)), _getERC20SpentItem(erc20s[0], borrowAmount), lender.addr + ); + + LoanManager.Loan memory loan = newLoan( + NewLoanData(address(custodian), new LoanManager.Caveat[](0), abi.encode(defaultLoanDetails)), + Originator(UO), + selectedCollateral + ); + assertEq(erc20s[0].balanceOf(feeReceiver), debt[0].amount * 1e17 / 1e18, "fee receiver not paid properly"); + } + + function testOverrideFeeRake() public { + assertEq(LM.defaultFeeRake(), 0); + address feeReceiver = address(20); + LM.setFeeData(feeReceiver, 1e17); //10% fees + LM.setFeeOverride(debt[0].token, 0); //0% fees + + Originator.Details memory defaultLoanDetails = _generateOriginationDetails( + _getERC721Consideration(erc721s[0], uint256(2)), _getERC20SpentItem(erc20s[0], borrowAmount), lender.addr + ); + + LoanManager.Loan memory loan = newLoan( + NewLoanData(address(custodian), new LoanManager.Caveat[](0), abi.encode(defaultLoanDetails)), + Originator(UO), + selectedCollateral + ); + assertEq(erc20s[0].balanceOf(feeReceiver), 0, "fee receiver not paid properly"); + } + + function testCaveatEnforcerInvalidOrigination() public { + Originator originator = new MockOriginator(LM, address(0), 0); + address seaport = address(LM.seaport()); + debt.push(SpentItem({itemType: ItemType.ERC20, token: address(erc20s[0]), amount: 100, identifier: 0})); + + SpentItem[] memory maxSpent = new SpentItem[](1); + maxSpent[0] = SpentItem({token: address(erc721s[0]), amount: 1, identifier: 1, itemType: ItemType.ERC721}); + + TermEnforcer TE = new TermEnforcer(); + + LoanManager.Caveat[] memory caveats = new LoanManager.Caveat[](1); + caveats[0] = LoanManager.Caveat({enforcer: address(TE), terms: ""}); + LoanManager.Obligation memory O = LoanManager.Obligation({ + custodian: address(custodian), + borrower: borrower.addr, + debt: debt, + salt: bytes32(0), + details: "", + approval: "", + caveats: caveats, + originator: address(originator) + }); + + LoanManager.Loan memory mockLoan = LoanManager.Loan({ + start: block.timestamp, + borrower: O.borrower, + collateral: maxSpent, + issuer: O.originator, + custodian: O.custodian, + debt: debt, + originator: O.originator, + terms: LoanManager.Terms({ + hook: address(0), + hookData: new bytes(0), + pricing: address(0), + pricingData: new bytes(0), + handler: address(0), + handlerData: new bytes(0) + }) + }); + vm.mockCall( + address(TE), + abi.encodeWithSelector(TermEnforcer.enforceCaveat.selector, bytes(""), mockLoan), + abi.encode(false) + ); + vm.startPrank(seaport); + vm.expectRevert(abi.encodeWithSelector(LoanManager.InvalidOrigination.selector)); + LM.generateOrder(address(this), new SpentItem[](0), maxSpent, abi.encode(O)); + } }