diff --git a/certora/Terms.spec b/certora/Terms.spec index 446a6ee20..e91c6e3de 100644 --- a/certora/Terms.spec +++ b/certora/Terms.spec @@ -42,8 +42,8 @@ invariant sanitySumBondShares(bytes32 id) invariant sanitySumDebt(bytes32 id) sumDebtOf[id] >= 0; -rule satisfyMatch(env e, calldataarg args) { - MATCH(e, args); +rule satisfyTake(env e, calldataarg args) { + take(e, args); satisfy true; } diff --git a/src/Terms.sol b/src/Terms.sol index d1affdbd6..24c5c8d74 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -3,7 +3,6 @@ pragma solidity 0.8.28; import "./libraries/UtilsLib.sol"; import {MathLib, WAD} from "./libraries/MathLib.sol"; -import {SharesMathLib} from "./libraries/SharesMathLib.sol"; import "./interfaces/IERC20.sol"; import "./interfaces/IOracle.sol"; import "./interfaces/ITerms.sol"; @@ -11,7 +10,6 @@ import "./interfaces/IMorphoLiquidationCallback.sol"; contract Terms is ITerms { using MathLib for uint256; - using SharesMathLib for uint256; /// CONSTANTS /// @@ -19,7 +17,6 @@ contract Terms is ITerms { bytes32 public constant OFFER_TYPEHASH = keccak256( "Offer(bool lend,address offering,uint256 assets,address loanToken,Collateral[] collaterals,uint256 maturity,uint256 price)" ); - uint256 public constant ORACLE_PRICE_SCALE = 1e36; /// STORAGE /// @@ -36,42 +33,31 @@ contract Terms is ITerms { /// ENTRY-POINTS /// - /// @dev This function is used for both primary and secondary markets. - function MATCH(Offer memory buyOffer, Signature memory buySig, Offer memory sellOffer, Signature memory sellSig) + /// @dev Same function used to buy and sell. + /// @dev If one wants to make to offers without taking a position, they can batch take them and not have a position at the end. + function take(Term memory term, uint256 amount, address onBehalf, Offer memory offer, Signature memory sig) public { - _checkOffers(buyOffer, buySig, sellOffer, sellSig); + require(term.maturity >= block.timestamp, "maturity"); + _checkSignature(offer, sig); + _checkOffer(term, offer); - uint256 amount = UtilsLib.min( - buyOffer.assets - consumed[abi.encode(buyOffer)], sellOffer.assets - consumed[abi.encode(sellOffer)] - ); - require(amount > 0, "No assets to match"); - address buyer = buyOffer.offering; - address seller = sellOffer.offering; + (address buyer, address seller) = offer.buy ? (offer.offering, onBehalf) : (onBehalf, offer.offering); - consumed[abi.encode(buyOffer)] += amount; - consumed[abi.encode(sellOffer)] += amount; + consumed[abi.encode(offer)] += amount; + require(consumed[abi.encode(offer)] <= offer.assets, "consumed"); - Term memory term = Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity); bytes32 id = _id(term); uint256 repaid = UtilsLib.min(debtOf[buyer][id], amount); uint256 bought = amount - repaid; - uint256 boughtShares = bought.toSharesDown(totalAssets[id], totalShares[id]); + uint256 boughtShares = bought.mulDivDown(totalShares[id] + 1, totalAssets[id] + 1); + uint256 withdrawn = + UtilsLib.min(bondSharesOf[seller][id].mulDivDown(totalAssets[id] + 1, totalShares[id] + 1), amount); + uint256 withdrawnShares = withdrawn.mulDivUp(totalShares[id] + 1, totalAssets[id] + 1); + debtOf[buyer][id] -= repaid; bondSharesOf[buyer][id] += boughtShares; - - uint256 withdrawn; - uint256 withdrawnShares; - - if (bondSharesOf[seller][id].toAssetsDown(totalAssets[id], totalShares[id]) < amount) { - withdrawn = bondSharesOf[seller][id].toAssetsDown(totalAssets[id], totalShares[id]); - withdrawnShares = bondSharesOf[seller][id]; - } else { - withdrawn = amount; - withdrawnShares = amount.toSharesUp(totalAssets[id], totalShares[id]); - } - bondSharesOf[seller][id] -= withdrawnShares; debtOf[seller][id] += amount - withdrawn; @@ -83,11 +69,8 @@ contract Terms is ITerms { require(debtOf[buyer][id] == 0 || _isHealthy(term, buyer), "Buyer is unhealthy"); require(debtOf[seller][id] == 0 || _isHealthy(term, seller), "Seller is unhealthy"); - uint256 sellerScaledPrice = sellOffer.price * amount / sellOffer.assets; - uint256 buyerScaledPrice = buyOffer.price * amount / buyOffer.assets; - - IERC20(buyOffer.loanToken).transferFrom(buyer, seller, sellerScaledPrice); - IERC20(buyOffer.loanToken).transferFrom(buyer, msg.sender, buyerScaledPrice - sellerScaledPrice); + uint256 scaledPrice = offer.price * amount / offer.assets; + IERC20(offer.loanToken).transferFrom(buyer, seller, scaledPrice); } /// @dev Will revert if there is no withdrawable funds. @@ -95,8 +78,8 @@ contract Terms is ITerms { require(UtilsLib.exactlyOneZero(amount, shares), "INCONSISTENT_INPUT"); bytes32 id = _id(term); - if (amount > 0) shares = amount.toSharesUp(totalAssets[id], totalShares[id]); - else amount = shares.toAssetsDown(totalAssets[id], totalShares[id]); + if (amount > 0) shares = amount.mulDivUp(totalShares[id] + 1, totalAssets[id] + 1); + else amount = shares.mulDivDown(totalAssets[id] + 1, totalShares[id] + 1); bondSharesOf[onBehalf][id] -= shares; withdrawable[id] -= amount; @@ -204,7 +187,7 @@ contract Terms is ITerms { } function bondOf(address owner, bytes32 id) public view returns (uint256) { - return bondSharesOf[owner][id].toAssetsDown(totalAssets[id], totalShares[id]); + return bondSharesOf[owner][id].mulDivDown(totalAssets[id] + 1, totalShares[id] + 1); } /// INTERNAL /// @@ -213,39 +196,23 @@ contract Terms is ITerms { return keccak256(abi.encode(term)); } - function _checkOffers( - Offer memory buyOffer, - Signature memory buySig, - Offer memory sellOffer, - Signature memory sellSig - ) internal view { - // Check consistency. - - require(buyOffer.buy && !sellOffer.buy, "Inconsistent lend flags"); - require(buyOffer.maturity > block.timestamp, "Buy offer has expired"); - _checkSignature(buyOffer, buySig); - _checkSignature(sellOffer, sellSig); + function _checkOffer(Term memory term, Offer memory offer) internal pure { + require(offer.loanToken == term.loanToken, "Loan tokens do not match"); + require(offer.maturity == term.maturity, "Maturities do not match"); - // Check compatibility. + Collateral[] memory subset = offer.buy ? term.collaterals : offer.collaterals; + Collateral[] memory superset = offer.buy ? offer.collaterals : term.collaterals; - require(buyOffer.offering != sellOffer.offering, "Same offering"); - require(buyOffer.loanToken == sellOffer.loanToken, "Loan tokens do not match"); uint256 j = 0; - for (uint256 i = 0; i < sellOffer.collaterals.length; i++) { + for (uint256 i = 0; i < subset.length; i++) { // Relies on the fact that the collaterals are sorted. // Note that we actually never check that. - // If they are not, the match could fail. - while ( - bytes20(sellOffer.collaterals[i].token) < bytes20(buyOffer.collaterals[j].token) - && j++ < buyOffer.collaterals.length - ) {} - require(sellOffer.collaterals[i].token == buyOffer.collaterals[j].token, "Collaterals tokens do not match"); - require(sellOffer.collaterals[i].lltv <= buyOffer.collaterals[j].lltv, "LLTVs do not match"); - require(sellOffer.collaterals[i].oracle == buyOffer.collaterals[j].oracle, "Oracles do not match"); + // If they are not, the matching could fail. + while (superset[j].token != subset[i].token) j++; + require(superset[j].lltv >= subset[i].lltv, "LLTVs do not match"); + require(subset[i].oracle == superset[j].oracle, "Oracles do not match"); j++; } - require(buyOffer.maturity == sellOffer.maturity, "Maturities do not match"); - require(buyOffer.price >= sellOffer.price, "Buy offer price is less than sell offer price"); } function _checkSignature(Offer memory offer, Signature memory signature) internal view { diff --git a/src/libraries/SharesMathLib.sol b/src/libraries/SharesMathLib.sol deleted file mode 100644 index 7e252db2f..000000000 --- a/src/libraries/SharesMathLib.sol +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.8.0; - -import {MathLib} from "./MathLib.sol"; - -/// @title SharesMathLib -/// @author Morpho Labs -/// @custom:contact security@morpho.org -/// @notice Shares management library. -/// @dev This implementation mitigates share price manipulations, using OpenZeppelin's method of virtual shares: -/// https://docs.openzeppelin.com/contracts/4.x/erc4626#inflation-attack. -library SharesMathLib { - using MathLib for uint256; - - /// @dev The number of virtual shares has been chosen low enough to prevent overflows, and high enough to ensure - /// high precision computations. - /// @dev Virtual shares can never be redeemed for the assets they are entitled to, but it is assumed the share price - /// stays low enough not to inflate these assets to a significant value. - /// @dev Warning: The assets to which virtual borrow shares are entitled behave like unrealizable bad debt. - uint256 internal constant VIRTUAL_SHARES = 1e6; - - /// @dev A number of virtual assets of 1 enforces a conversion rate between shares and assets when a market is - /// empty. - uint256 internal constant VIRTUAL_ASSETS = 1; - - /// @dev Calculates the value of `assets` quoted in shares, rounding down. - function toSharesDown(uint256 assets, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { - return assets.mulDivDown(totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS); - } - - /// @dev Calculates the value of `shares` quoted in assets, rounding down. - function toAssetsDown(uint256 shares, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { - return shares.mulDivDown(totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES); - } - - /// @dev Calculates the value of `assets` quoted in shares, rounding up. - function toSharesUp(uint256 assets, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { - return assets.mulDivUp(totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS); - } -} diff --git a/test/BaseTest.sol b/test/BaseTest.sol index b9b886933..f3d728815 100644 --- a/test/BaseTest.sol +++ b/test/BaseTest.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; -import {Test} from "../lib/forge-std/src/Test.sol"; +import "../lib/forge-std/src/Test.sol"; import {ERC20} from "./helpers/ERC20.sol"; import "../src/Terms.sol"; diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index 136bc5b18..b8131f91e 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -23,7 +23,8 @@ contract LiquidationTest is BaseTest { ERC20[] memory tokens = new ERC20[](n); for (uint256 i = 0; i < n; i++) { - tokens[i] = new ERC20("collat", "c", 1 ether); + tokens[i] = new ERC20("collat", "c"); + deal(address(tokens[i]), address(this), 1 ether); } tokens = sortTokens(tokens); @@ -57,16 +58,7 @@ contract LiquidationTest is BaseTest { } function mintBond(Collateral[] memory cs) internal { - Offer memory lendOffer = Offer({ - buy: true, - offering: lender, - assets: 1000, - loanToken: address(loanToken), - collaterals: cs, - maturity: block.timestamp + 100, - price: 990 - }); - + Term memory term = Term(address(loanToken), cs, block.timestamp + 100); Offer memory borrowOffer = Offer({ buy: false, offering: borrower, @@ -77,10 +69,9 @@ contract LiquidationTest is BaseTest { price: 990 }); - Signature memory lendSig = _signOffer(lendOffer, lenderSK); Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); - terms.MATCH(lendOffer, lendSig, borrowOffer, borrowSig); + terms.take(term, 1000, lender, borrowOffer, borrowSig); } function setUp() public override { @@ -89,9 +80,10 @@ contract LiquidationTest is BaseTest { (lender, lenderSK) = makeAddrAndKey("lender"); liquidator = makeAddr("liquidator"); - loanToken = new ERC20("loan", "loan", 1 ether); - loanToken.transfer(lender, 99); - loanToken.transfer(borrower, 1); + loanToken = new ERC20("loan", "loan"); + deal(address(loanToken), address(this), type(uint256).max); + deal(address(loanToken), address(lender), 99); + deal(address(loanToken), address(borrower), 1); vm.prank(lender); loanToken.approve(address(terms), type(uint256).max); diff --git a/test/TermsTest.sol b/test/TermsTest.sol index d2f6d4c65..d28a7f78f 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -13,7 +13,7 @@ contract TermsTest is BaseTest { address private borrower; uint256 private lenderSK; address private lender; - address private liquidator; + address private liquidator = makeAddr("liquidator"); Term private term; bytes32 private id; @@ -24,12 +24,14 @@ contract TermsTest is BaseTest { super.setUp(); (borrower, borrowerSK) = makeAddrAndKey("borrower"); (lender, lenderSK) = makeAddrAndKey("lender"); - liquidator = makeAddr("liquidator"); - loanToken = new ERC20("loan", "loan", 1 ether); - loanToken.transfer(lender, 99); - loanToken.transfer(borrower, 1); - collateralToken = new ERC20("collat", "collat", 1 ether); + loanToken = new ERC20("loan", "loan"); + collateralToken = new ERC20("collat", "collat"); + + deal(address(loanToken), address(this), 100); + deal(address(loanToken), address(lender), 99); + deal(address(loanToken), address(borrower), 1); + deal(address(collateralToken), address(this), 134); oracle = new Oracle(); collaterals = new Collateral[](1); @@ -45,13 +47,65 @@ contract TermsTest is BaseTest { loanToken.approve(address(terms), type(uint256).max); vm.prank(borrower); loanToken.approve(address(terms), type(uint256).max); - collateralToken.approve(address(terms), type(uint256).max); - terms.supplyCollateral(term, address(collateralToken), 134, borrower); vm.prank(liquidator); loanToken.approve(address(terms), type(uint256).max); + vm.prank(address(this)); + loanToken.approve(address(terms), type(uint256).max); + + collateralToken.approve(address(terms), type(uint256).max); + terms.supplyCollateral(term, address(collateralToken), 134, borrower); + } + + function testTakePostMaturity(uint256 maturity) public { + maturity = bound(maturity, 0, block.timestamp - 1); + Term memory _term = Term(address(loanToken), collaterals, maturity); + Offer memory offer; + Signature memory sig; + vm.expectRevert("maturity"); + terms.take(_term, 100, lender, offer, sig); + } + + function testLend() public { + Offer memory borrowOffer = Offer({ + buy: false, + offering: borrower, + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 99 + }); + Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); + terms.take(term, 100, lender, borrowOffer, borrowSig); + + assertEq(terms.bondSharesOf(lender, id), 100, "lender bond shares"); + assertEq(terms.debtOf(borrower, id), 100, "borrower debt"); + + assertEq(loanToken.balanceOf(borrower), 100, "borrower balance"); + assertEq(loanToken.balanceOf(lender), 0, "lender balance"); + } + + function testBorrow() public { + Offer memory lendOffer = Offer({ + buy: true, + offering: lender, + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 99 + }); + Signature memory lendSig = _signOffer(lendOffer, lenderSK); + terms.take(term, 100, borrower, lendOffer, lendSig); + + assertEq(terms.bondSharesOf(lender, id), 100, "bond shares"); + assertEq(terms.debtOf(borrower, id), 100, "lender debt"); + + assertEq(loanToken.balanceOf(borrower), 100, "borrower balance"); + assertEq(loanToken.balanceOf(lender), 0, "lender balance"); } - function testMint() public { + function testMatch() public { Offer memory lendOffer = Offer({ buy: true, offering: lender, @@ -61,6 +115,7 @@ contract TermsTest is BaseTest { maturity: block.timestamp + 100, price: 99 }); + Signature memory lendSig = _signOffer(lendOffer, lenderSK); Offer memory borrowOffer = Offer({ buy: false, offering: borrower, @@ -70,21 +125,18 @@ contract TermsTest is BaseTest { maturity: block.timestamp + 100, price: 99 }); - - Signature memory lendSig = _signOffer(lendOffer, lenderSK); Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); - terms.MATCH(lendOffer, lendSig, borrowOffer, borrowSig); + terms.take(term, 100, address(this), borrowOffer, borrowSig); + terms.take(term, 100, address(this), lendOffer, lendSig); - assertEq(terms.bondOf(lender, id), 100); - assertEq(terms.debtOf(borrower, id), 100); - - assertEq(loanToken.balanceOf(borrower), 100); - assertEq(loanToken.balanceOf(lender), 0); + assertEq(terms.bondSharesOf(address(this), id), 0, "bond shares"); + assertEq(terms.debtOf(address(this), id), 0, "debt"); + assertEq(loanToken.balanceOf(address(this)), 100, "balance"); } function testRepay() public { - testMint(); + testLend(); vm.warp(block.timestamp + 99); @@ -104,7 +156,7 @@ contract TermsTest is BaseTest { vm.prank(lender); terms.withdrawBond(term, 100, 0, lender); - assertEq(terms.bondOf(lender, id), 0); + assertEq(terms.bondSharesOf(lender, id), 0); assertEq(terms.withdrawable(id), 0); assertEq(loanToken.balanceOf(address(terms)), 0); @@ -124,9 +176,9 @@ contract TermsTest is BaseTest { } function testBadDebt() public { - testMint(); + testLend(); - loanToken.transfer(liquidator, 1000); + deal(address(loanToken), address(liquidator), 1000); Oracle(collaterals[0].oracle).setPrice(0.75e36); vm.prank(liquidator); @@ -137,4 +189,130 @@ contract TermsTest is BaseTest { assertEq(terms.bondOf(lender, id), 87); assertEq(terms.totalAssets(id), 87); } + + function testConsumed() public { + Offer memory lendOffer = Offer({ + buy: true, + offering: lender, + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 99 + }); + Signature memory lendSig = _signOffer(lendOffer, lenderSK); + + terms.take(term, 100, borrower, lendOffer, lendSig); + + vm.expectRevert("consumed"); + terms.take(term, 100, borrower, lendOffer, lendSig); + } + + function testTakeLendOfferCollateralMissing() public { + collaterals[0].token = address(0); + + Offer memory lendOffer = Offer({ + buy: true, + offering: lender, + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 99 + }); + Signature memory lendSig = _signOffer(lendOffer, lenderSK); + + vm.expectRevert(stdError.indexOOBError); + terms.take(term, 100, borrower, lendOffer, lendSig); + } + + function testTakeLendOfferLLTVMismatch() public { + collaterals[0].lltv = 0.5e18; + + Offer memory lendOffer = Offer({ + buy: true, + offering: lender, + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 99 + }); + Signature memory lendSig = _signOffer(lendOffer, lenderSK); + + vm.expectRevert("LLTVs do not match"); + terms.take(term, 100, borrower, lendOffer, lendSig); + } + + function testTakeLendOfferOraclesMismatch() public { + collaterals[0].oracle = address(0); + + Offer memory lendOffer = Offer({ + buy: true, + offering: lender, + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 99 + }); + Signature memory lendSig = _signOffer(lendOffer, lenderSK); + + vm.expectRevert("Oracles do not match"); + terms.take(term, 100, borrower, lendOffer, lendSig); + } + + function testTakeBorrowOfferTooMuchCollaterals() public { + collaterals[0].token = address(0); + + Offer memory borrowOffer = Offer({ + buy: false, + offering: borrower, + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 99 + }); + Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); + + vm.expectRevert(stdError.indexOOBError); + terms.take(term, 100, lender, borrowOffer, borrowSig); + } + + function testTakeBorrowOfferLLTVMismatch() public { + collaterals[0].lltv = 0.99e18; + + Offer memory borrowOffer = Offer({ + buy: false, + offering: borrower, + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 99 + }); + Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); + + vm.expectRevert("LLTVs do not match"); + terms.take(term, 100, lender, borrowOffer, borrowSig); + } + + function testTakeBorrowOfferOraclesMismatch() public { + collaterals[0].oracle = address(0); + + Offer memory borrowOffer = Offer({ + buy: false, + offering: borrower, + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 99 + }); + Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); + + vm.expectRevert("Oracles do not match"); + terms.take(term, 100, lender, borrowOffer, borrowSig); + } } diff --git a/test/helpers/ERC20.sol b/test/helpers/ERC20.sol index 72825edd2..19d3651b9 100644 --- a/test/helpers/ERC20.sol +++ b/test/helpers/ERC20.sol @@ -8,11 +8,9 @@ contract ERC20 { mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; - constructor(string memory _name, string memory _symbol, uint256 _totalSupply) { + constructor(string memory _name, string memory _symbol) { name = _name; symbol = _symbol; - totalSupply = _totalSupply; - balanceOf[msg.sender] = _totalSupply; } function transfer(address recipient, uint256 amount) public returns (bool) {