diff --git a/src/enforcers/AstariaV1RatioLenderEnforcer.sol b/src/enforcers/AstariaV1RatioLenderEnforcer.sol index 96f74fe..b581038 100644 --- a/src/enforcers/AstariaV1RatioLenderEnforcer.sol +++ b/src/enforcers/AstariaV1RatioLenderEnforcer.sol @@ -16,15 +16,12 @@ import {Starport} from "starport-core/Starport.sol"; import {CaveatEnforcer} from "starport-core/enforcers/CaveatEnforcer.sol"; import {BasePricing} from "v1-core/pricing/BasePricing.sol"; import {AdditionalTransfer} from "starport-core/lib/StarportLib.sol"; - import {AstariaV1Lib} from "v1-core/lib/AstariaV1Lib.sol"; - -import {ItemType} from "seaport-types/src/lib/ConsiderationEnums.sol"; -import {SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; import {FixedPointMathLib} from "solady/src/utils/FixedPointMathLib.sol"; contract AstariaV1RatioLenderEnforcer is CaveatEnforcer { using FixedPointMathLib for uint256; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* CUSTOM ERRORS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -34,16 +31,10 @@ contract AstariaV1RatioLenderEnforcer is CaveatEnforcer { error LoanRateLessThanCaveatRate(); error DebtBundlesNotSupported(); error CollateralBundlesNotSupported(); - error DebtAmountExceedsDebtMax(uint256 maxDebt, uint256 loanAmount); + error DebtAmountExceedsDebtMax(uint256 maxDebt, uint256 debtAmount); error BelowMinCollateralAmount(); error MaxDebtOrCollateralToDebtRatioZero(); - /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ - /* CONSTANTS AND IMMUTABLES */ - /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - - uint256 constant MAX_DURATION = uint256(3 * 365 days); // 3 years - /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* STRUCTS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -59,8 +50,10 @@ contract AstariaV1RatioLenderEnforcer is CaveatEnforcer { /* PUBLIC FUNCTIONS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ - /// @notice Validates a loan against a caveat, w/ a minimum rate and a maximum amount - /// @dev Bundle support is not implemented, and will revert + /// @notice Validates a loan against a caveat, w/ a minCollateralAmount, collateralToDebtRatio, and a matchIdentifier + /// @dev collateralToDebtRatio is a 1e18 value allowing a ratio conversion from collateral to debt, 1e18 was used to allow flexibility on the collateral units lower bound + /// @dev Collateral bundle support is not implemented, and will revert + /// @dev Debt bundle support is not implemented, and will revert /// @dev matchIdentifier = false will allow the loan to have a different identifier than the caveat /// @dev Only viable for use w/ AstariaV1Pricing and AstariaV1Status modules function validate( @@ -78,9 +71,9 @@ contract AstariaV1RatioLenderEnforcer is CaveatEnforcer { Starport.Terms calldata loanTerms = loan.terms; uint256 loanRate = abi.decode(loanTerms.pricingData, (BasePricing.Details)).rate; - uint256 loanAmount = loan.debt[0].amount; + uint256 debtAmount = loan.debt[0].amount; AstariaV1Lib.validateCompoundInterest( - loanAmount, + debtAmount, loanRate, AstariaV1Lib.getBaseRecallMax(loanTerms.statusData), AstariaV1Lib.getBasePricingDecimals(loanTerms.pricingData) @@ -93,9 +86,9 @@ contract AstariaV1RatioLenderEnforcer is CaveatEnforcer { revert BelowMinCollateralAmount(); } - uint256 maxDebt = (collateralAmount * details.collateralToDebtRatio) / AstariaV1Lib.WAD; - if (loanAmount > maxDebt) { - revert DebtAmountExceedsDebtMax(maxDebt, loanAmount); + uint256 maxDebt = collateralAmount.mulWad(details.collateralToDebtRatio); + if (debtAmount > maxDebt) { + revert DebtAmountExceedsDebtMax(maxDebt, debtAmount); } if (maxDebt == 0) { @@ -112,21 +105,16 @@ contract AstariaV1RatioLenderEnforcer is CaveatEnforcer { AstariaV1Lib.setBasePricingRate(caveatPricingData, loanRate); Starport.Loan memory caveatLoan = details.loan; - // Update the caveat loan amount - caveatLoan.debt[0].amount = loanAmount; - if (!details.matchIdentifier) { // Update the caveat loan identifier - uint256 i = 0; - for (; i < caveatLoan.collateral.length;) { - caveatLoan.collateral[i].identifier = loan.collateral[i].identifier; - unchecked { - ++i; - } - } + caveatLoan.collateral[0].identifier = loan.collateral[0].identifier; } - // Hash and match w/ expected borrower + // Update the caveat debt and collateral amounts + caveatLoan.debt[0].amount = debtAmount; + caveatLoan.collateral[0].amount = collateralAmount; + + // Hash and match w/ expected borrower and originator _validate(additionalTransfers, loan, caveatLoan); selector = CaveatEnforcer.validate.selector; } diff --git a/test/TestV1RatioLenderEnforcer.sol b/test/TestV1RatioLenderEnforcer.sol new file mode 100644 index 0000000..f41e45b --- /dev/null +++ b/test/TestV1RatioLenderEnforcer.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 +// █████╗ ███████╗████████╗ █████╗ ██████╗ ██╗ █████╗ ██╗ ██╗ ██╗ +// ██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██║██╔══██╗ ██║ ██║███║ +// ███████║███████╗ ██║ ███████║██████╔╝██║███████║ ██║ ██║╚██║ +// ██╔══██║╚════██║ ██║ ██╔══██║██╔══██╗██║██╔══██║ ╚██╗ ██╔╝ ██║ +// ██║ ██║███████║ ██║ ██║ ██║██║ ██║██║██║ ██║ ╚████╔╝ ██║ +// ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ +// +// Astaria v1 Lending +// Built on Starport https://github.com/astariaXYZ/starport +// Designed with love by Astaria Labs, Inc + +pragma solidity ^0.8.17; + +import "test/AstariaV1Test.sol"; + +import {Starport} from "starport-core/Starport.sol"; +import {StarportLib, AdditionalTransfer} from "starport-core/lib/StarportLib.sol"; + +import {AstariaV1RatioLenderEnforcer} from "v1-core/enforcers/AstariaV1RatioLenderEnforcer.sol"; +import {AstariaV1Lib} from "v1-core/lib/AstariaV1Lib.sol"; + +import {FixedPointMathLib} from "solady/src/utils/FixedPointMathLib.sol"; +import {BasePricing} from "v1-core/pricing/BasePricing.sol"; + +import {console2} from "forge-std/console2.sol"; + +contract TestV1RatioLenderEnforcer is AstariaV1Test, AstariaV1RatioLenderEnforcer { + using FixedPointMathLib for uint256; + + function setUp() public virtual override { + super.setUp(); + + lenderEnforcer = new AstariaV1RatioLenderEnforcer(); + } + + function getDefaultV1RatioLenderDetails(Starport.Loan memory loan) + public + pure + returns (AstariaV1RatioLenderEnforcer.Details memory details) + { + details = AstariaV1RatioLenderEnforcer.Details({ + matchIdentifier: false, + minCollateralAmount: loan.collateral[0].amount, + collateralToDebtRatio: loan.debt[0].amount.divWadUp(loan.collateral[0].amount), + loan: loanCopy(loan) + }); + } + + function testV1RatioLenderDefault() public { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + // Test general passing case + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + function testV1RatioLenderDebtBundle() public { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + // Test Debt Bundle + loan.debt = new SpentItem[](2); + vm.expectRevert(DebtBundlesNotSupported.selector); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + function testV1RatioLenderCollateralBundle() public { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + // Test Collateral Bundle + loan.collateral = new SpentItem[](2); + vm.expectRevert(CollateralBundlesNotSupported.selector); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + function testV1RatioLenderMinCollateralAmount() public { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + // Test below min collateral amount + loan.collateral[0].amount--; + vm.expectRevert(BelowMinCollateralAmount.selector); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + + // Test below min above collateral amount + loan.collateral[0].amount += 2; + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + function testV1RatioLenderDebtAmountExceedsDebtMax() public { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + // + loan.debt[0].amount++; + vm.expectRevert( + abi.encodeWithSelector( + DebtAmountExceedsDebtMax.selector, + loan.collateral[0].amount.mulWad(details.collateralToDebtRatio), + loan.debt[0].amount + ) + ); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + function testV1RatioLenderMaxDebtOrCollateralToDebtRatioZero() public { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + loan.debt[0].amount = 0; + loan.collateral[0].amount = 0; + details.collateralToDebtRatio = 0; + details.minCollateralAmount = 0; + vm.expectRevert(abi.encodeWithSelector(MaxDebtOrCollateralToDebtRatioZero.selector)); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + function testV1RatioLenderLoanRateLessThanCaveatRate() public { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + BasePricing.Details memory pricingDetails = abi.decode(loan.terms.pricingData, (BasePricing.Details)); + pricingDetails.rate--; + loan.terms.pricingData = abi.encode(pricingDetails); + vm.expectRevert(LoanRateLessThanCaveatRate.selector); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + function testV1RatioLenderEnforcerRate() public { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + // Test malleable rate + AstariaV1Lib.setBasePricingRate( + loan.terms.pricingData, AstariaV1Lib.getBasePricingRate(details.loan.terms.pricingData) + 1 + ); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + + // Test insufficient rate + AstariaV1Lib.setBasePricingRate( + loan.terms.pricingData, AstariaV1Lib.getBasePricingRate(details.loan.terms.pricingData) - 1 + ); + vm.expectRevert(LoanRateLessThanCaveatRate.selector); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + // Test matchIdentifier + function testV1RatioLenderEnforcerMatchIdentifier() public { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + loan.collateral[0].identifier += 1; + + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + details.matchIdentifier = true; + + vm.expectRevert(LenderEnforcer.InvalidLoanTerms.selector); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + function testV1RatioLenderEnforcerAdditionalTransfers() external { + Starport.Loan memory loan = generateDefaultLoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + // Test invalid additional transfer from lender + AdditionalTransfer[] memory additionalTransfers = new AdditionalTransfer[](1); + additionalTransfers[0] = AdditionalTransfer({ + token: address(0), + amount: 0, + to: address(0), + from: lender.addr, + identifier: 0, + itemType: ItemType.ERC20 + }); + + vm.expectRevert(LenderEnforcer.InvalidAdditionalTransfer.selector); + lenderEnforcer.validate(additionalTransfers, loan, abi.encode(details)); + + // Test valid additional transfer from other party + additionalTransfers[0].from = borrower.addr; + lenderEnforcer.validate(additionalTransfers, loan, abi.encode(details)); + } + + // ensures that a debt copy occurs at the end of validate + function testV1LenderEnforcerCopyDebtAmount() external { + Starport.Loan memory loan = generateDefaultERC20LoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = AstariaV1RatioLenderEnforcer.Details({ + matchIdentifier: false, + minCollateralAmount: 1, + collateralToDebtRatio: loan.debt[0].amount.divWadUp(loan.collateral[0].amount), + loan: loanCopy(loan) + }); + + loan.debt[0].amount /= 2; + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + // ensures that a collateral copy occurs at the end of validate + function testV1LenderEnforcerCopyCollateralAmount() external { + Starport.Loan memory loan = generateDefaultERC20LoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = AstariaV1RatioLenderEnforcer.Details({ + matchIdentifier: false, + minCollateralAmount: 1, + collateralToDebtRatio: loan.debt[0].amount.divWadUp(loan.collateral[0].amount), + loan: loanCopy(loan) + }); + + loan.collateral[0].amount = 10; + loan.debt[0].amount = loan.collateral[0].amount.mulWad(details.collateralToDebtRatio); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + // ensures that a borrower copy occurs at the beginning of _validate + function testV1LenderEnforcerCopyBorrower() external { + Starport.Loan memory loan = generateDefaultERC20LoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + // ensuring that the details.loan.borrower does not match the loan.borrower + details.loan.borrower = address(uint160(details.loan.borrower) << 1); + assert(details.loan.borrower != loan.borrower); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } + + // ensures that a originator copy occurs at the beginning of _validate + function testV1LenderEnforcerCopyOriginator() external { + Starport.Loan memory loan = generateDefaultERC20LoanTerms(); + AstariaV1RatioLenderEnforcer.Details memory details = getDefaultV1RatioLenderDetails(loan); + + // ensuring that the details.loan.originator does not match the loan.originator + details.loan.originator = address(uint160(fulfiller.addr) << 1); + assert(details.loan.originator != loan.originator); + lenderEnforcer.validate(new AdditionalTransfer[](0), loan, abi.encode(details)); + } +}