diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c35d251 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +out/ +cache/ +node_modules/ +.env \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..558b49a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/rari-capital/solmate diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..78bc78a --- /dev/null +++ b/foundry.toml @@ -0,0 +1,9 @@ +[default] +src = 'src' +out = 'out' +libs = ['lib'] +remappings = [ + '@openzeppelin/=lib/backed-protocol/node_modules/@openzeppelin/' +] + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config \ No newline at end of file diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..1680d7f --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 1680d7fb3e00b7b197a7336e7c88e838c7e6a3ec diff --git a/lib/solmate b/lib/solmate new file mode 160000 index 0000000..851ea3b --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit 851ea3baa4327f453da723df75b1093b58b964dc diff --git a/src/Contract.sol b/src/Contract.sol new file mode 100644 index 0000000..655b543 --- /dev/null +++ b/src/Contract.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract Contract {} diff --git a/src/LendController.sol b/src/LendController.sol new file mode 100644 index 0000000..5a1e33d --- /dev/null +++ b/src/LendController.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.13; + +import {ERC721, ERC721TokenReceiver} from 'solmate/tokens/ERC721.sol'; +import {SafeTransferLib, ERC20} from "solmate/utils/SafeTransferLib.sol"; + +import {INFTLoanFacilitator} from './interfaces/INFTLoanFacilitator.sol'; + +struct LoanTerms { + bool allowBuyouts; + bool allowLoanAmountIncrease; + uint16 interestRate; + uint128 amount; + uint32 durationSeconds; + address loanAsset; +} + +interface NFTLoanTermsSource { + function terms(uint256 tokenId, address nft, address loanAsset, bytes calldata data) external returns (LoanTerms memory); +} + +contract LendController is ERC721TokenReceiver { + using SafeTransferLib for ERC20; + + struct LenderSpec { + address lender; + uint256 amount; + bool pull; + } + + struct BorrowRequest { + LenderSpec[] lendersSpecs; + address termsSource; + LoanTerms terms; + } + + uint256 ONE = 1e18; + mapping(address => mapping(address => bool)) public termsApprovals; + mapping(address => mapping(address => uint256)) public lenderBalance; + mapping(uint256 => mapping(address => uint256)) public lenderLoanShares; + + INFTLoanFacilitator public loanFacilitator; + + constructor(INFTLoanFacilitator _facilitator) { + loanFacilitator = _facilitator; + } + + event SetTermsApproval(address indexed from, address indexed termsContract, bool approved); + + function setTermsApproval(address termsContract, bool approved) external { + termsApprovals[msg.sender][termsContract] = approved; + + emit SetTermsApproval(msg.sender, termsContract, approved); + } + + function purchaseNFT() external { + // allows anyone to call in and seize a seize NFT that + // the controller holds the lend ticket for and pays the lenders + // purchase price must be >= totalOwed on loan + } + + function liquidateNFT(uint256 loanId) external { + // TBD if controller will have one liquidation mechanism + // or will call out to the terms contract for implement + // update lenderBalance based on lenderLoanShares * sale value + } + + // used to lend to an exisiting loan + function lend( + uint256 loanId, + uint256 tokenId, + address nft, + BorrowRequest memory request, + bytes calldata data + ) external { + _lend(loanId, tokenId, nft, request, false, data); + } + + function _lend( + uint256 loanId, + uint256 tokenId, + address nft, + BorrowRequest memory request, + bool skipIsBuyoutCheck, + bytes calldata data + ) internal { + LoanTerms memory terms = NFTLoanTermsSource(request.termsSource).terms(tokenId, msg.sender, request.terms.loanAsset, ''); + + ERC20 loanAsset = ERC20(request.terms.loanAsset); + uint256 lenderTotal; + + for(uint i = 0; i < request.lendersSpecs.length; i++) { + LenderSpec memory info = request.lendersSpecs[i]; + require(termsApprovals[info.lender][request.termsSource], 'lender has not approved terms source'); + + if(info.pull) { + ERC20(request.terms.loanAsset).safeTransferFrom(info.lender, address(this), info.amount); + } else { + lenderBalance[info.lender][address(loanAsset)] -= info.amount; + } + lenderTotal += info.amount; + + lenderLoanShares[loanId][info.lender] = info.amount * ONE / terms.amount; + } + + loanAsset.approve(address(loanFacilitator), type(uint256).max); + + if(skipIsBuyoutCheck){ + require(lenderTotal == terms.amount); + } else { + (,,, uint40 lastAccumulatedTimestamp,,,,,,,) = loanFacilitator.loanInfo(loanId); + + if(lastAccumulatedTimestamp != 0) { + require(terms.allowBuyouts); + require(lenderTotal == terms.amount + loanFacilitator.interestOwed(loanId)); + } else { + require(lenderTotal == terms.amount); + } + } + + loanFacilitator.lend( + loanId, + terms.interestRate, + terms.amount, + terms.durationSeconds, + address(this) + ); + } + + // used to create loan and lend to it in one transaction + function onERC721Received( + address, + address from, + uint256 tokenId, + bytes calldata data + ) external override returns (bytes4) { + BorrowRequest memory request = abi.decode(data, (BorrowRequest)); + + ERC721(msg.sender).setApprovalForAll(address(loanFacilitator), true); + + uint256 loanId = loanFacilitator.createLoan( + tokenId, + msg.sender, + request.terms.interestRate, + request.terms.allowLoanAmountIncrease, + request.terms.amount, + request.terms.loanAsset, + request.terms.durationSeconds, + from + ); + + _lend(loanId, tokenId, msg.sender, request, true, data); + + return ERC721TokenReceiver.onERC721Received.selector; + } +} diff --git a/src/Terms.sol b/src/Terms.sol new file mode 100644 index 0000000..9c10a54 --- /dev/null +++ b/src/Terms.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract LendController { + +} diff --git a/src/interfaces/INFTLoanFacilitator.sol b/src/interfaces/INFTLoanFacilitator.sol new file mode 100644 index 0000000..d829b03 --- /dev/null +++ b/src/interfaces/INFTLoanFacilitator.sol @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.12; + +interface INFTLoanFacilitator { + /// @notice See loanInfo + struct Loan { + bool closed; + uint16 perAnnumInterestRate; + uint32 durationSeconds; + uint40 lastAccumulatedTimestamp; + address collateralContractAddress; + bool allowLoanAmountIncrease; + uint88 originationFeeRate; + address loanAssetContractAddress; + uint128 accumulatedInterest; + uint128 loanAmount; + uint256 collateralTokenId; + } + + /** + * @notice The magnitude of SCALAR + * @dev 10^INTEREST_RATE_DECIMALS = 1 = 100% + */ + function INTEREST_RATE_DECIMALS() external view returns (uint8); + + /** + * @notice The SCALAR for all percentages in the loan facilitator contract + * @dev Any interest rate passed to a function should already been multiplied by SCALAR + */ + function SCALAR() external view returns (uint256); + + /** + * @notice The percent of the loan amount that the facilitator will take as a fee, scaled by SCALAR + * @dev Starts set to 1%. Can only be set to 0 - 5%. + */ + function originationFeeRate() external view returns (uint256); + + /** + * @notice The lend ticket contract associated with this loan facilitator + * @dev Once set, cannot be modified + */ + function lendTicketContract() external view returns (address); + + /** + * @notice The borrow ticket contract associated with this loan facilitator + * @dev Once set, cannot be modified + */ + function borrowTicketContract() external view returns (address); + + /** + * @notice The percent improvement required of at least one loan term when buying out current lender + * a loan that already has a lender, scaled by SCALAR. + * E.g. setting this value to 100 (10%) means, when replacing a lender, the new loan terms must have + * at least 10% greater duration or loan amount or at least 10% lower interest rate. + * @dev Starts at 100 = 10%. Only owner can set. Cannot be set to 0. + */ + function requiredImprovementRate() external view returns (uint256); + + /** + * @notice Emitted when the loan is created + * @param id The id of the new loan, matches the token id of the borrow ticket minted in the same transaction + * @param minter msg.sender + * @param collateralTokenId The token id of the collateral NFT + * @param collateralContract The contract address of the collateral NFT + * @param maxInterestRate The max per anum interest rate, scaled by SCALAR + * @param loanAssetContract The contract address of the loan asset + * @param minLoanAmount mimimum loan amount + * @param minDurationSeconds minimum loan duration in seconds + */ + event CreateLoan( + uint256 indexed id, + address indexed minter, + uint256 collateralTokenId, + address collateralContract, + uint256 maxInterestRate, + address loanAssetContract, + bool allowLoanAmountIncrease, + uint256 minLoanAmount, + uint256 minDurationSeconds + ); + + /** + * @notice Emitted when ticket is closed + * @param id The id of the ticket which has been closed + */ + event Close(uint256 indexed id); + + /** + * @notice Emitted when the loan is lent to + * @param id The id of the loan which is being lent to + * @param lender msg.sender + * @param interestRate The per anum interest rate, scaled by SCALAR, for the loan + * @param loanAmount The loan amount + * @param durationSeconds The loan duration in seconds + */ + event Lend( + uint256 indexed id, + address indexed lender, + uint256 interestRate, + uint256 loanAmount, + uint256 durationSeconds + ); + + /** + * @notice Emitted when a lender is being bought out: + * the current loan ticket holder is being replaced by a new lender offering better terms + * @param lender msg.sender + * @param replacedLoanOwner The current loan ticket holder + * @param interestEarned The amount of interest the loan has accrued from first lender to this buyout + * @param replacedAmount The loan amount prior to buyout + */ + event BuyoutLender( + uint256 indexed id, + address indexed lender, + address indexed replacedLoanOwner, + uint256 interestEarned, + uint256 replacedAmount + ); + + /** + * @notice Emitted when loan is repaid + * @param id The loan id + * @param repayer msg.sender + * @param loanOwner The current holder of the lend ticket for this loan, token id matching the loan id + * @param interestEarned The total interest accumulated on the loan + * @param loanAmount The loan amount + */ + event Repay( + uint256 indexed id, + address indexed repayer, + address indexed loanOwner, + uint256 interestEarned, + uint256 loanAmount + ); + + /** + * @notice Emitted when loan NFT collateral is seized + * @param id The ticket id + */ + event SeizeCollateral(uint256 indexed id); + + /** + * @notice Emitted when origination fees are withdrawn + * @dev only owner can call + * @param asset the ERC20 asset withdrawn + * @param amount the amount withdrawn + * @param to the address the withdrawn amount was sent to + */ + event WithdrawOriginationFees(address asset, uint256 amount, address to); + + /** + * @notice Emitted when originationFeeRate is updated + * @dev only owner can call, value is scaled by SCALAR, 100% = SCALAR + * @param feeRate the new origination fee rate + */ + event UpdateOriginationFeeRate(uint32 feeRate); + + /** + * @notice Emitted when requiredImprovementRate is updated + * @dev only owner can call, value is scaled by SCALAR, 100% = SCALAR + * @param improvementRate the new required improvementRate + */ + event UpdateRequiredImprovementRate(uint256 improvementRate); + + /** + * @notice (1) transfers the collateral NFT to the loan facilitator contract + * (2) creates the loan, populating loanInfo in the facilitator contract, + * and (3) mints a Borrow Ticket to mintBorrowTicketTo + * @dev loan duration or loan amount cannot be 0, + * this is done to protect borrowers from accidentally passing a default value + * and also because it creates odd lending and buyout behavior: possible to lend + * for 0 value or 0 duration, and possible to buyout with no improvement because, for example + * previousDurationSeconds + (previousDurationSeconds * requiredImprovementRate / SCALAR) <= durationSeconds + * evaluates to true if previousDurationSeconds is 0 and durationSeconds is 0. + * loanAssetContractAddress cannot be address(0), we check this because Solmate SafeTransferLib + * does not revert with address(0) and this could cause odd behavior. + * collateralContractAddress cannot be address(borrowTicket) or address(lendTicket). + * @param collateralTokenId The token id of the collateral NFT + * @param collateralContractAddress The contract address of the collateral NFT + * @param maxPerAnnumInterest The maximum per anum interest rate for this loan, scaled by SCALAR + * @param allowLoanAmountIncrease Whether the borrower is open to lenders offerring greater than minLoanAmount + * @param minLoanAmount The minimum acceptable loan amount for this loan + * @param loanAssetContractAddress The address of the loan asset + * @param minDurationSeconds The minimum duration for this loan + * @param mintBorrowTicketTo An address to mint the Borrow Ticket corresponding to this loan to + * @return id of the created loan + */ + function createLoan( + uint256 collateralTokenId, + address collateralContractAddress, + uint16 maxPerAnnumInterest, + bool allowLoanAmountIncrease, + uint128 minLoanAmount, + address loanAssetContractAddress, + uint32 minDurationSeconds, + address mintBorrowTicketTo + ) external returns (uint256 id); + + /** + * @notice Closes the loan, sends the NFT collateral to sendCollateralTo + * @dev Can only be called by the holder of the Borrow Ticket with tokenId + * matching the loanId. Can only be called if loan has no lender, + * i.e. lastAccumulatedInterestTimestamp = 0 + * @param loanId The loan id + * @param sendCollateralTo The address to send the collateral NFT to + */ + function closeLoan(uint256 loanId, address sendCollateralTo) external; + + /** + * @notice Lends, meeting or beating the proposed loan terms, + * transferring `amount` of the loan asset + * to the facilitator contract. If the loan has not yet been lent to, + * a Lend Ticket is minted to `sendLendTicketTo`. If the loan has already been + * lent to, then this is a buyout, and the Lend Ticket will be transferred + * from the current holder to `sendLendTicketTo`. Also in the case of a buyout, interestOwed() + * is transferred from the caller to the facilitator contract, in addition to `amount`, and + * totalOwed() is paid to the current Lend Ticket holder. + * @dev Loan terms must meet or beat loan terms. If a buyout, at least one loan term + * must be improved by at least 10%. E.g. 10% longer duration, 10% lower interest, + * 10% higher amount + * @param loanId The loan id + * @param interestRate The per anum interest rate, scaled by SCALAR + * @param amount The loan amount + * @param durationSeconds The loan duration in seconds + * @param sendLendTicketTo The address to send the Lend Ticket to + */ + function lend( + uint256 loanId, + uint16 interestRate, + uint128 amount, + uint32 durationSeconds, + address sendLendTicketTo + ) external; + + /** + * @notice repays and closes the loan, transferring totalOwed() to the current Lend Ticket holder + * and transferring the collateral NFT to the Borrow Ticket holder. + * @param loanId The loan id + */ + function repayAndCloseLoan(uint256 loanId) external; + + /** + * @notice Transfers the collateral NFT to `sendCollateralTo` and closes the loan. + * @dev Can only be called by Lend Ticket holder. Can only be called + * if block.timestamp > loanEndSeconds() + * @param loanId The loan id + * @param sendCollateralTo The address to send the collateral NFT to + */ + function seizeCollateral(uint256 loanId, address sendCollateralTo) external; + + /** + * @notice returns the info for this loan + * @param loanId The id of the loan + * @return closed Whether or not the ticket is closed + * @return perAnnumInterestRate The per anum interest rate, scaled by SCALAR + * @return durationSeconds The loan duration in seconds + + * @return lastAccumulatedTimestamp The timestamp (in seconds) when interest was last accumulated, + * i.e. the timestamp of the most recent lend + * @return collateralContractAddress The contract address of the NFT collateral + * @return allowLoanAmountIncrease + * @return originationFeeRate + * @return loanAssetContractAddress The contract address of the loan asset. + * @return accumulatedInterest The amount of interest accumulated on the loan prior to the current lender + * @return loanAmount The loan amount + * @return collateralTokenId The token ID of the NFT collateral + */ + function loanInfo(uint256 loanId) + external + view + returns ( + bool closed, + uint16 perAnnumInterestRate, + uint32 durationSeconds, + uint40 lastAccumulatedTimestamp, + address collateralContractAddress, + bool allowLoanAmountIncrease, + uint88 originationFeeRate, + address loanAssetContractAddress, + uint128 accumulatedInterest, + uint128 loanAmount, + uint256 collateralTokenId + ); + + /** + * @notice returns the info for this loan + * @dev this is a convenience method for other contracts that would prefer to have the + * Loan object not decomposed. + * @param loanId The id of the loan + * @return Loan struct corresponding to loanId + */ + function loanInfoStruct(uint256 loanId) external view returns (Loan memory); + + /** + * @notice returns the total amount owed for the loan, i.e. principal + interest + * @param loanId The loan id + * @return amount required to repay and close the loan corresponding to loanId + */ + function totalOwed(uint256 loanId) view external returns (uint256); + + /** + * @notice returns the interest owed on the loan, in loan asset units + * @param loanId The loan id + * @return amount of interest owed on loan corresonding to loanId + */ + function interestOwed(uint256 loanId) view external returns (uint256); + + /** + * @notice returns the unix timestamp (seconds) of the loan end + * @param loanId The loan id + * @return timestamp at which loan payment is due, after which lend ticket holder + * can seize collateral + */ + function loanEndSeconds(uint256 loanId) view external returns (uint256); +} \ No newline at end of file diff --git a/test/Contract.t.sol b/test/Contract.t.sol new file mode 100644 index 0000000..bfaaad9 --- /dev/null +++ b/test/Contract.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +contract ContractTest is Test { + function setUp() public {} + + function testExample() public { + assertTrue(true); + } +}