From 8d1d33fef90d36d3fa24a29f2dc4c37ba5556a63 Mon Sep 17 00:00:00 2001 From: MathisGD Date: Tue, 21 Jan 2025 13:48:10 +0100 Subject: [PATCH 01/41] chore: init --- .gitignore | 3 + .gitmodules | 3 + certora/Terms.conf | 13 +++ certora/Terms.spec | 61 +++++++++++++ lib/forge-std | 1 + src/Terms.sol | 178 +++++++++++++++++++++++++++++++++++++ src/interfaces/IERC20.sol | 7 ++ src/interfaces/IOracle.sol | 6 ++ src/interfaces/ITerms.sol | 33 +++++++ src/libraries/Math.sol | 8 ++ test/TermsTest.sol | 55 ++++++++++++ test/helpers/ERC20.sol | 34 +++++++ test/helpers/Oracle.sol | 6 ++ 13 files changed, 408 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 certora/Terms.conf create mode 100644 certora/Terms.spec create mode 160000 lib/forge-std create mode 100644 src/Terms.sol create mode 100644 src/interfaces/IERC20.sol create mode 100644 src/interfaces/IOracle.sol create mode 100644 src/interfaces/ITerms.sol create mode 100644 src/libraries/Math.sol create mode 100644 test/TermsTest.sol create mode 100644 test/helpers/ERC20.sol create mode 100644 test/helpers/Oracle.sol diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..5280472af --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +cache +out +.certora_internal diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..888d42dcd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/certora/Terms.conf b/certora/Terms.conf new file mode 100644 index 000000000..dbc8619e2 --- /dev/null +++ b/certora/Terms.conf @@ -0,0 +1,13 @@ +{ + "files": [ + "src/Terms.sol" + ], + "solc": "solc-0.8.28", + "verify": "Terms:certora/Terms.spec", + "rule_sanity": "basic", + "server": "production", + "loop_iter": "2", + "optimistic_loop": true, + "optimistic_hashing": true, + "msg": "Terms" +} diff --git a/certora/Terms.spec b/certora/Terms.spec new file mode 100644 index 000000000..1884158ec --- /dev/null +++ b/certora/Terms.spec @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +/// METHODS /// + +methods { + function withdrawable(bytes32 id) external returns uint256 envfree; + + function _.transferFrom(address, address, uint256) external => HAVOC_ECF; + function _.transfer(address, uint256) external => HAVOC_ECF; +} + +/// HOOKS /// + +persistent ghost mapping(bytes32 => mathint) sumBondOf { + init_state axiom (forall bytes32 id. sumBondOf[id] == 0); +} +hook Sload uint256 bondOfOwner bondOf[KEY address owner][KEY bytes32 id] { + require sumBondOf[id] >= to_mathint(bondOfOwner); +} +hook Sstore bondOf[KEY address owner][KEY bytes32 id] uint256 newBond (uint256 oldBond) { + sumBondOf[id] = sumBondOf[id] - oldBond + newBond; +} + +persistent ghost mapping(bytes32 => mathint) sumDebtOf { + init_state axiom (forall bytes32 id. sumDebtOf[id] == 0); +} +hook Sload uint256 debtOfOwner debtOf[KEY address owner][KEY bytes32 id] { + require sumDebtOf[id] >= to_mathint(debtOfOwner); +} +hook Sstore debtOf[KEY address owner][KEY bytes32 id] uint256 newDebt (uint256 oldDebt) { + sumDebtOf[id] = sumDebtOf[id] - oldDebt + newDebt; +} + +/// SANITY /// + +invariant sanitySumBond(bytes32 id) + sumBondOf[id] >= 0; + +invariant sanitySumDebt(bytes32 id) + sumDebtOf[id] >= 0; + + +rule satisfyMint(env e, calldataarg args) { + mint(e, args); + satisfy true; +} + +rule satisfyTransferDebt(env e, calldataarg args) { + transferDebt(e, args); + satisfy true; +} + +rule satisfyTransferBond(env e, calldataarg args) { + transferBond(e, args); + satisfy true; +} + +/// INVARIANTS /// + +invariant sums(bytes32 id) + sumBondOf[id] == sumDebtOf[id] + withdrawable(id); diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 000000000..b93cf4bc3 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit b93cf4bc34ff214c099dc970b153f85ade8c9f66 diff --git a/src/Terms.sol b/src/Terms.sol new file mode 100644 index 000000000..fbcce1c8a --- /dev/null +++ b/src/Terms.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.28; + +import "./libraries/Math.sol"; +import "./interfaces/IERC20.sol"; +import "./interfaces/IOracle.sol"; +import "./interfaces/ITerms.sol"; + +contract Terms is ITerms { + /// CONSTANTS /// + + bytes32 constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); + bytes32 constant OFFER_TYPEHASH = keccak256( + "Offer(bool lend,address offering,uint256 assets,address loanToken,Collateral[] collaterals,uint256 maturity,uint256 price)" + ); + uint256 constant WAD = 1 ether; + + /// STORAGE /// + + // Terms. + mapping(address => mapping(bytes32 => uint256)) public bondOf; + mapping(address => mapping(bytes32 => uint256)) public debtOf; + mapping(address => mapping(bytes32 => mapping(address => uint256))) public collateralOf; + mapping(bytes32 => uint256) public withdrawable; + // Offers. + mapping(bytes32 => uint256) public consumed; + + /// ENTRY-POINTS /// + + function mint( + Offer memory lendOffer, + Signature memory lendSig, + Offer memory borrowOffer, + Signature memory borrowSig + ) public { + _checkOffers(lendOffer, lendSig, borrowOffer, borrowSig); + + uint256 amount = Math.min(lendOffer.assets, borrowOffer.assets); + // Commented because it makes invariants "not vacuous". + // consumed[keccak256(abi.encode(lendOffer))] += amount; + // consumed[keccak256(abi.encode(borrowOffer))] += amount; + + bytes32 id = id(Term(borrowOffer.loanToken, borrowOffer.collaterals, borrowOffer.maturity)); + bondOf[borrowOffer.offering][id] += amount; + debtOf[borrowOffer.offering][id] += amount; + + IERC20(lendOffer.loanToken).transferFrom(lendOffer.offering, borrowOffer.offering, amount); + } + + function transferBond( + Offer memory buyOffer, + Signature memory buySig, + Offer memory sellOffer, + Signature memory sellSig + ) external { + _checkOffers(buyOffer, buySig, sellOffer, sellSig); + + uint256 amount = Math.min(buyOffer.assets, sellOffer.assets); + // consumed[keccak256(abi.encode(buyOffer))] += amount; + // consumed[keccak256(abi.encode(sellOffer))] += amount; + + bytes32 id = id(Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity)); + bondOf[sellOffer.offering][id] -= amount; + bondOf[buyOffer.offering][id] += amount; + + IERC20(buyOffer.loanToken).transferFrom(buyOffer.offering, sellOffer.offering, amount); + } + + function transferDebt( + Offer memory buyOffer, + Signature memory buySig, + Offer memory sellOffer, + Signature memory sellSig + ) external { + _checkOffers(buyOffer, buySig, sellOffer, sellSig); + + uint256 amount = Math.min(buyOffer.assets, sellOffer.assets); + // consumed[keccak256(abi.encode(buyOffer))] += amount; + // consumed[keccak256(abi.encode(sellOffer))] += amount; + + bytes32 id = id(Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity)); + debtOf[sellOffer.offering][id] -= amount; + debtOf[buyOffer.offering][id] += amount; + + IERC20(buyOffer.loanToken).transferFrom(buyOffer.offering, sellOffer.offering, amount); + } + + /// @dev Will revert if there is no withdrawable funds. + function withdrawBond(Term memory term, uint256 amount, address onBehalf) external { + bytes32 id = id(term); + + bondOf[onBehalf][id] -= amount; + withdrawable[id] -= amount; + + IERC20(term.loanToken).transfer(msg.sender, amount); + } + + function repayDebt(Term memory term, uint256 amount, address onBehalf) external { + bytes32 id = id(term); + + debtOf[onBehalf][id] -= amount; + withdrawable[id] += amount; + + IERC20(term.loanToken).transferFrom(msg.sender, address(this), amount); + } + + function supplyCollateral(Term memory term, address collateral, uint256 amount, address onBehalf) external { + collateralOf[onBehalf][id(term)][collateral] += amount; + IERC20(collateral).transferFrom(msg.sender, address(this), amount); + } + + function withdrawCollateral(Term memory term, address collateral, uint256 amount, address onBehalf) external { + collateralOf[onBehalf][id(term)][collateral] -= amount; + IERC20(collateral).transfer(msg.sender, amount); + } + + /// VIEW /// + + function id(Term memory term) public pure returns (bytes32) { + return keccak256(abi.encode(term)); + } + + /// INTERNAL /// + + function _checkOffers( + Offer memory buyOffer, + Signature memory buySig, + Offer memory sellOffer, + Signature memory sellSig + ) internal view { + // Check consistency. + + require(buyOffer.lend && !sellOffer.lend, "Inconsistent lend flags"); + require(buyOffer.maturity > block.timestamp, "Buy offer has expired"); + // Commented because it makes verification fail. + // _checkSignature(buyOffer, buySig); + // _checkSignature(sellOffer, sellSig); + + // Check compatibility. + + require(buyOffer.loanToken == sellOffer.loanToken, "Loan tokens do not match"); + for (uint256 i = 0; i < sellOffer.collaterals.length; i++) { + uint256 j; + while ( + bytes20(sellOffer.collaterals[i].token) < bytes20(buyOffer.collaterals[j].token) + && j++ < buyOffer.collaterals.length + ) {} + require(sellOffer.collaterals[i].token == buyOffer.collaterals[j].token, "Collateral tokens do not match"); + require(sellOffer.collaterals[i].lltv <= buyOffer.collaterals[j].lltv, "LLTV exceeds limit"); + require(sellOffer.collaterals[i].oracle == buyOffer.collaterals[j].oracle, "Oracles do not match"); + } + 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 { + bytes32 hashStruct = keccak256(abi.encode(OFFER_TYPEHASH, offer)); + bytes32 domainSeparator = keccak256(abi.encode(DOMAIN_TYPEHASH, block.chainid, address(this))); + bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, hashStruct)); + address signatory = ecrecover(digest, signature.v, signature.r, signature.s); + + require(signatory != address(0) && offer.offering == signatory, "Invalid signature"); + } + + function _checkCollateralisation(Offer memory borrowOffer) internal view { + bytes32 id = id(Term(borrowOffer.loanToken, borrowOffer.collaterals, borrowOffer.maturity)); + + uint256 maxDebt; + for (uint256 i = 0; i < borrowOffer.collaterals.length; i++) { + uint256 price = IOracle(borrowOffer.collaterals[i].oracle).price(); + uint256 collateralQuoted = + collateralOf[borrowOffer.offering][id][borrowOffer.collaterals[i].token] * price / WAD; + maxDebt += collateralQuoted * borrowOffer.collaterals[i].lltv / WAD; + } + + require(debtOf[borrowOffer.offering][id] <= maxDebt); + } +} diff --git a/src/interfaces/IERC20.sol b/src/interfaces/IERC20.sol new file mode 100644 index 000000000..ee9fc9b3a --- /dev/null +++ b/src/interfaces/IERC20.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +interface IERC20 { + function transfer(address recipient, uint256 amount) external returns (bool); + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); +} diff --git a/src/interfaces/IOracle.sol b/src/interfaces/IOracle.sol new file mode 100644 index 000000000..ffa31cff2 --- /dev/null +++ b/src/interfaces/IOracle.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +interface IOracle { + function price() external view returns (uint256); +} diff --git a/src/interfaces/ITerms.sol b/src/interfaces/ITerms.sol new file mode 100644 index 000000000..70979a26e --- /dev/null +++ b/src/interfaces/ITerms.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +struct Term { + address loanToken; + // Must be sorted by address. + Collateral[] collaterals; + uint256 maturity; +} + +struct Collateral { + address token; + uint256 lltv; + address oracle; +} + +struct Offer { + bool lend; + address offering; + uint256 assets; + address loanToken; + Collateral[] collaterals; + uint256 maturity; + uint256 price; +} + +struct Signature { + uint8 v; + bytes32 r; + bytes32 s; +} + +interface ITerms {} diff --git a/src/libraries/Math.sol b/src/libraries/Math.sol new file mode 100644 index 000000000..8b25a58e9 --- /dev/null +++ b/src/libraries/Math.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +library Math { + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } +} diff --git a/test/TermsTest.sol b/test/TermsTest.sol new file mode 100644 index 000000000..9bc5490af --- /dev/null +++ b/test/TermsTest.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {Test} from "../lib/forge-std/src/Test.sol"; +import "../src/Terms.sol"; +import {ERC20} from "./helpers/ERC20.sol"; +import {Oracle} from "./helpers/Oracle.sol"; + +contract TermsTest is Test { + Terms private terms; + ERC20 private loanToken; + ERC20 private collateralToken; + Oracle private oracle; + + function setUp() external { + terms = new Terms(); + loanToken = new ERC20("loan", "loan", type(uint256).max); + collateralToken = new ERC20("collat", "collat", type(uint256).max); + oracle = new Oracle(); + } + + function testMint() external { + Collateral[] memory collaterals = new Collateral[](1); + collaterals[0] = Collateral({token: address(collateralToken), lltv: 1e18, oracle: address(oracle)}); + Offer memory lendOffer = Offer({ + lend: true, + offering: address(this), + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 1 + }); + Offer memory borrowOffer = Offer({ + lend: false, + offering: address(this), + assets: 100, + loanToken: address(loanToken), + collaterals: collaterals, + maturity: block.timestamp + 100, + price: 1 + }); + + Signature memory lendSig = Signature(0, 0, 0); + Signature memory borrowSig = Signature(0, 0, 0); + + terms.mint(lendOffer, lendSig, borrowOffer, borrowSig); + + Term memory term = Term(address(loanToken), collaterals, block.timestamp + 100); + bytes32 id = terms.id(term); + + assertEq(terms.bondOf(address(this), id), 100); + assertEq(terms.debtOf(address(this), id), 100); + } +} diff --git a/test/helpers/ERC20.sol b/test/helpers/ERC20.sol new file mode 100644 index 000000000..912705d12 --- /dev/null +++ b/test/helpers/ERC20.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +contract ERC20 { + string public name; + string public symbol; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + + constructor(string memory _name, string memory _symbol, uint256 _totalSupply) { + name = _name; + symbol = _symbol; + totalSupply = _totalSupply; + balanceOf[msg.sender] = _totalSupply; + } + + function transfer(address recipient, uint256 amount) public returns (bool) { + require(amount <= balanceOf[msg.sender], "Insufficient balance"); + + balanceOf[msg.sender] -= amount; + balanceOf[recipient] += amount; + + return true; + } + + function transferFrom(address sender, address recipient, uint256 amount) public returns (bool) { + require(amount <= balanceOf[sender], "Insufficient balance"); + + balanceOf[sender] -= amount; + balanceOf[recipient] += amount; + + return true; + } +} diff --git a/test/helpers/Oracle.sol b/test/helpers/Oracle.sol new file mode 100644 index 000000000..a0a7722bb --- /dev/null +++ b/test/helpers/Oracle.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +contract Oracle { + uint256 public price = 1e18; +} From c539a4613bba682c71cd5ffe00120af38df95f37 Mon Sep 17 00:00:00 2001 From: MathisGD Date: Tue, 21 Jan 2025 17:17:57 +0100 Subject: [PATCH 02/41] feat: merge matching functions --- certora/Terms.conf | 5 +- certora/Terms.spec | 29 +++---- certora/dispatch/ERC20NoRevert.sol | 48 +++++++++++ certora/dispatch/ERC20Standard.sol | 42 +++++++++ certora/dispatch/ERC20USDT.sol | 45 ++++++++++ certora/helpers/TermsHelpers.sol | 14 +++ foundry.toml | 2 + src/Terms.sol | 133 +++++++++++++---------------- src/interfaces/IERC20.sol | 1 + src/interfaces/ITerms.sol | 2 +- test/TermsTest.sol | 37 +++++--- test/helpers/ERC20.sol | 8 ++ 12 files changed, 259 insertions(+), 107 deletions(-) create mode 100644 certora/dispatch/ERC20NoRevert.sol create mode 100644 certora/dispatch/ERC20Standard.sol create mode 100644 certora/dispatch/ERC20USDT.sol create mode 100644 certora/helpers/TermsHelpers.sol create mode 100644 foundry.toml diff --git a/certora/Terms.conf b/certora/Terms.conf index dbc8619e2..fe2570914 100644 --- a/certora/Terms.conf +++ b/certora/Terms.conf @@ -1,9 +1,10 @@ { "files": [ - "src/Terms.sol" + "certora/helpers/TermsHelpers.sol", + "certora/dispatch/ERC20Standard.sol" ], "solc": "solc-0.8.28", - "verify": "Terms:certora/Terms.spec", + "verify": "TermsHelpers:certora/Terms.spec", "rule_sanity": "basic", "server": "production", "loop_iter": "2", diff --git a/certora/Terms.spec b/certora/Terms.spec index 1884158ec..2e9b9ef13 100644 --- a/certora/Terms.spec +++ b/certora/Terms.spec @@ -4,9 +4,12 @@ methods { function withdrawable(bytes32 id) external returns uint256 envfree; - - function _.transferFrom(address, address, uint256) external => HAVOC_ECF; - function _.transfer(address, uint256) external => HAVOC_ECF; + function balanceOf(address, address) external returns uint256 envfree; + function id(Terms.Term) external returns bytes32 envfree; + + function _.transfer(address, uint256) external => DISPATCHER(true); + function _.transferFrom(address, address, uint256) external => DISPATCHER(true); + function _.balanceOf(address) external => DISPATCHER(true); } /// HOOKS /// @@ -38,24 +41,16 @@ invariant sanitySumBond(bytes32 id) invariant sanitySumDebt(bytes32 id) sumDebtOf[id] >= 0; - - -rule satisfyMint(env e, calldataarg args) { - mint(e, args); - satisfy true; -} -rule satisfyTransferDebt(env e, calldataarg args) { - transferDebt(e, args); - satisfy true; -} - -rule satisfyTransferBond(env e, calldataarg args) { - transferBond(e, args); +rule satisfyMatch(env e, calldataarg args) { + MATCH(e, args); satisfy true; } /// INVARIANTS /// -invariant sums(bytes32 id) +strong invariant sums(bytes32 id) sumBondOf[id] == sumDebtOf[id] + withdrawable(id); + +// invariant balances(TermsHelpers.Term term) +// balanceOf(term.loanToken, currentContract) >= withdrawable(id(term)); diff --git a/certora/dispatch/ERC20NoRevert.sol b/certora/dispatch/ERC20NoRevert.sol new file mode 100644 index 000000000..b8dfccd1e --- /dev/null +++ b/certora/dispatch/ERC20NoRevert.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +contract ERC20NoRevert { + address public owner; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner); + _; + } + + function _transfer(address _from, address _to, uint256 _amount) internal returns (bool) { + if (balanceOf[_from] < _amount) { + return false; + } + balanceOf[_from] -= _amount; + balanceOf[_to] += _amount; + return true; + } + + function transfer(address _to, uint256 _amount) public returns (bool) { + return _transfer(msg.sender, _to, _amount); + } + + function transferFrom(address _from, address _to, uint256 _amount) public returns (bool) { + if (allowance[_from][msg.sender] < _amount) { + return false; + } + allowance[_from][msg.sender] -= _amount; + return _transfer(_from, _to, _amount); + } + + function approve(address _spender, uint256 _amount) public { + allowance[msg.sender][_spender] = _amount; + } + + function mint(address _receiver, uint256 _amount) public onlyOwner { + balanceOf[_receiver] += _amount; + totalSupply += _amount; + } +} diff --git a/certora/dispatch/ERC20Standard.sol b/certora/dispatch/ERC20Standard.sol new file mode 100644 index 000000000..71af6d9ca --- /dev/null +++ b/certora/dispatch/ERC20Standard.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +contract ERC20Standard { + address public owner; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner); + _; + } + + function _transfer(address _from, address _to, uint256 _amount) internal returns (bool) { + balanceOf[_from] -= _amount; + balanceOf[_to] += _amount; + return true; + } + + function transfer(address _to, uint256 _amount) public returns (bool) { + return _transfer(msg.sender, _to, _amount); + } + + function transferFrom(address _from, address _to, uint256 _amount) public returns (bool) { + allowance[_from][msg.sender] -= _amount; + return _transfer(_from, _to, _amount); + } + + function approve(address _spender, uint256 _amount) public { + allowance[msg.sender][_spender] = _amount; + } + + function mint(address _receiver, uint256 _amount) public onlyOwner { + balanceOf[_receiver] += _amount; + totalSupply += _amount; + } +} diff --git a/certora/dispatch/ERC20USDT.sol b/certora/dispatch/ERC20USDT.sol new file mode 100644 index 000000000..cce99353a --- /dev/null +++ b/certora/dispatch/ERC20USDT.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +contract ERC20USDT { + address public owner; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner); + _; + } + + function _transfer(address _from, address _to, uint256 _amount) internal { + balanceOf[_from] -= _amount; + balanceOf[_to] += _amount; + } + + function transfer(address _to, uint256 _amount) public { + _transfer(msg.sender, _to, _amount); + } + + function transferFrom(address _from, address _to, uint256 _amount) public { + if (allowance[_from][msg.sender] < type(uint256).max) { + allowance[_from][msg.sender] -= _amount; + } + _transfer(_from, _to, _amount); + } + + function approve(address _spender, uint256 _amount) public { + require(!((_amount != 0) && (allowance[msg.sender][_spender] != 0))); + + allowance[msg.sender][_spender] = _amount; + } + + function mint(address _receiver, uint256 _amount) public onlyOwner { + balanceOf[_receiver] += _amount; + totalSupply += _amount; + } +} diff --git a/certora/helpers/TermsHelpers.sol b/certora/helpers/TermsHelpers.sol new file mode 100644 index 000000000..221b4287d --- /dev/null +++ b/certora/helpers/TermsHelpers.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {Terms, Term, IERC20} from "../../src/Terms.sol"; + +contract TermsHelpers is Terms { + function balanceOf(address token, address account) external view returns (uint256) { + return IERC20(token).balanceOf(account); + } + + function id(Term memory term) external pure returns (bytes32) { + return _id(term); + } +} diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 000000000..e9c314cdc --- /dev/null +++ b/foundry.toml @@ -0,0 +1,2 @@ +[profile.default] +via_ir = true diff --git a/src/Terms.sol b/src/Terms.sol index fbcce1c8a..ced8c4087 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -20,74 +20,53 @@ contract Terms is ITerms { // Terms. mapping(address => mapping(bytes32 => uint256)) public bondOf; mapping(address => mapping(bytes32 => uint256)) public debtOf; - mapping(address => mapping(bytes32 => mapping(address => uint256))) public collateralOf; mapping(bytes32 => uint256) public withdrawable; + mapping(address => mapping(bytes32 => mapping(address => uint256))) public collateralOf; // Offers. mapping(bytes32 => uint256) public consumed; /// ENTRY-POINTS /// - function mint( - Offer memory lendOffer, - Signature memory lendSig, - Offer memory borrowOffer, - Signature memory borrowSig - ) public { - _checkOffers(lendOffer, lendSig, borrowOffer, borrowSig); - - uint256 amount = Math.min(lendOffer.assets, borrowOffer.assets); - // Commented because it makes invariants "not vacuous". - // consumed[keccak256(abi.encode(lendOffer))] += amount; - // consumed[keccak256(abi.encode(borrowOffer))] += amount; - - bytes32 id = id(Term(borrowOffer.loanToken, borrowOffer.collaterals, borrowOffer.maturity)); - bondOf[borrowOffer.offering][id] += amount; - debtOf[borrowOffer.offering][id] += amount; - - IERC20(lendOffer.loanToken).transferFrom(lendOffer.offering, borrowOffer.offering, amount); - } - - function transferBond( - Offer memory buyOffer, - Signature memory buySig, - Offer memory sellOffer, - Signature memory sellSig - ) external { + function MATCH(Offer memory buyOffer, Signature memory buySig, Offer memory sellOffer, Signature memory sellSig) + public + { _checkOffers(buyOffer, buySig, sellOffer, sellSig); + // Commented because it makes invariants "not vacuous". + // uint256 amount = Math.min(buyOffer.assets - consumed[keccak256(abi.encode(buyOffer))], sellOffer.assets - consumed[keccak256(abi.encode(sellOffer))]); uint256 amount = Math.min(buyOffer.assets, sellOffer.assets); + require(amount > 0, "No assets to match"); + address buyer = buyOffer.offering; + address seller = sellOffer.offering; + + // Commented because it makes invariants "not vacuous". // consumed[keccak256(abi.encode(buyOffer))] += amount; // consumed[keccak256(abi.encode(sellOffer))] += amount; - bytes32 id = id(Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity)); - bondOf[sellOffer.offering][id] -= amount; - bondOf[buyOffer.offering][id] += amount; + Term memory term = Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity); + bytes32 id = _id(term); - IERC20(buyOffer.loanToken).transferFrom(buyOffer.offering, sellOffer.offering, amount); - } + uint256 repaid = Math.min(debtOf[buyer][id], amount); + debtOf[buyer][id] -= repaid; + bondOf[buyer][id] += amount - repaid; - function transferDebt( - Offer memory buyOffer, - Signature memory buySig, - Offer memory sellOffer, - Signature memory sellSig - ) external { - _checkOffers(buyOffer, buySig, sellOffer, sellSig); + uint256 withdrawn = Math.min(bondOf[seller][id], amount); + bondOf[seller][id] -= withdrawn; + debtOf[seller][id] += amount - withdrawn; - uint256 amount = Math.min(buyOffer.assets, sellOffer.assets); - // consumed[keccak256(abi.encode(buyOffer))] += amount; - // consumed[keccak256(abi.encode(sellOffer))] += amount; + require(debtOf[buyer][id] == 0 || _isHealthy(term, buyer), "Buyer is unhealthy"); + require(debtOf[seller][id] == 0 || _isHealthy(term, seller), "Seller is unhealthy"); - bytes32 id = id(Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity)); - debtOf[sellOffer.offering][id] -= amount; - debtOf[buyOffer.offering][id] += amount; + uint256 sellerScaledPrice = sellOffer.price * amount / sellOffer.assets; + uint256 buyerScaledPrice = buyOffer.price * amount / buyOffer.assets; - IERC20(buyOffer.loanToken).transferFrom(buyOffer.offering, sellOffer.offering, amount); + IERC20(buyOffer.loanToken).transferFrom(buyer, seller, sellerScaledPrice); + IERC20(buyOffer.loanToken).transferFrom(buyer, msg.sender, buyerScaledPrice - sellerScaledPrice); } /// @dev Will revert if there is no withdrawable funds. function withdrawBond(Term memory term, uint256 amount, address onBehalf) external { - bytes32 id = id(term); + bytes32 id = _id(term); bondOf[onBehalf][id] -= amount; withdrawable[id] -= amount; @@ -96,7 +75,7 @@ contract Terms is ITerms { } function repayDebt(Term memory term, uint256 amount, address onBehalf) external { - bytes32 id = id(term); + bytes32 id = _id(term); debtOf[onBehalf][id] -= amount; withdrawable[id] += amount; @@ -105,32 +84,31 @@ contract Terms is ITerms { } function supplyCollateral(Term memory term, address collateral, uint256 amount, address onBehalf) external { - collateralOf[onBehalf][id(term)][collateral] += amount; + collateralOf[onBehalf][_id(term)][collateral] += amount; IERC20(collateral).transferFrom(msg.sender, address(this), amount); } function withdrawCollateral(Term memory term, address collateral, uint256 amount, address onBehalf) external { - collateralOf[onBehalf][id(term)][collateral] -= amount; + collateralOf[onBehalf][_id(term)][collateral] -= amount; + + require(_isHealthy(term, onBehalf), "Unhealthy borrower"); + IERC20(collateral).transfer(msg.sender, amount); } - /// VIEW /// + /// INTERNAL /// - function id(Term memory term) public pure returns (bytes32) { + function _id(Term memory term) public pure returns (bytes32) { return keccak256(abi.encode(term)); } - /// INTERNAL /// - - function _checkOffers( - Offer memory buyOffer, - Signature memory buySig, - Offer memory sellOffer, - Signature memory sellSig - ) internal view { + function _checkOffers(Offer memory buyOffer, Signature memory, Offer memory sellOffer, Signature memory) + internal + view + { // Check consistency. - require(buyOffer.lend && !sellOffer.lend, "Inconsistent lend flags"); + require(buyOffer.buy && !sellOffer.buy, "Inconsistent lend flags"); require(buyOffer.maturity > block.timestamp, "Buy offer has expired"); // Commented because it makes verification fail. // _checkSignature(buyOffer, buySig); @@ -138,15 +116,19 @@ contract Terms is ITerms { // Check compatibility. + require(buyOffer.offering != sellOffer.offering, "Same offering"); require(buyOffer.loanToken == sellOffer.loanToken, "Loan tokens do not match"); for (uint256 i = 0; i < sellOffer.collaterals.length; i++) { uint256 j; + // 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, "Collateral tokens do not match"); - require(sellOffer.collaterals[i].lltv <= buyOffer.collaterals[j].lltv, "LLTV exceeds limit"); + 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"); } require(buyOffer.maturity == sellOffer.maturity, "Maturities do not match"); @@ -162,17 +144,20 @@ contract Terms is ITerms { require(signatory != address(0) && offer.offering == signatory, "Invalid signature"); } - function _checkCollateralisation(Offer memory borrowOffer) internal view { - bytes32 id = id(Term(borrowOffer.loanToken, borrowOffer.collaterals, borrowOffer.maturity)); - - uint256 maxDebt; - for (uint256 i = 0; i < borrowOffer.collaterals.length; i++) { - uint256 price = IOracle(borrowOffer.collaterals[i].oracle).price(); - uint256 collateralQuoted = - collateralOf[borrowOffer.offering][id][borrowOffer.collaterals[i].token] * price / WAD; - maxDebt += collateralQuoted * borrowOffer.collaterals[i].lltv / WAD; - } + function _isHealthy(Term memory term, address borrower) internal view returns (bool) { + if (term.maturity < block.timestamp) { + return false; + } else { + bytes32 id = _id(Term(term.loanToken, term.collaterals, term.maturity)); + + uint256 maxDebt; + for (uint256 i = 0; i < term.collaterals.length; i++) { + uint256 price = IOracle(term.collaterals[i].oracle).price(); + uint256 collateralQuoted = collateralOf[borrower][id][term.collaterals[i].token] * price / WAD; + maxDebt += collateralQuoted * term.collaterals[i].lltv / WAD; + } - require(debtOf[borrowOffer.offering][id] <= maxDebt); + return debtOf[borrower][id] <= maxDebt; + } } } diff --git a/src/interfaces/IERC20.sol b/src/interfaces/IERC20.sol index ee9fc9b3a..444767548 100644 --- a/src/interfaces/IERC20.sol +++ b/src/interfaces/IERC20.sol @@ -4,4 +4,5 @@ pragma solidity ^0.8.0; interface IERC20 { function transfer(address recipient, uint256 amount) external returns (bool); function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); } diff --git a/src/interfaces/ITerms.sol b/src/interfaces/ITerms.sol index 70979a26e..c6d13602a 100644 --- a/src/interfaces/ITerms.sol +++ b/src/interfaces/ITerms.sol @@ -15,7 +15,7 @@ struct Collateral { } struct Offer { - bool lend; + bool buy; address offering; uint256 assets; address loanToken; diff --git a/test/TermsTest.sol b/test/TermsTest.sol index 9bc5490af..6dd6a17c8 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; -import {Test} from "../lib/forge-std/src/Test.sol"; +import {Test, console} from "../lib/forge-std/src/Test.sol"; import "../src/Terms.sol"; import {ERC20} from "./helpers/ERC20.sol"; import {Oracle} from "./helpers/Oracle.sol"; @@ -11,19 +11,31 @@ contract TermsTest is Test { ERC20 private loanToken; ERC20 private collateralToken; Oracle private oracle; + address private borrower = makeAddr("borrower"); + Term private term; + bytes32 private id; + Collateral[] private collaterals; function setUp() external { terms = new Terms(); - loanToken = new ERC20("loan", "loan", type(uint256).max); - collateralToken = new ERC20("collat", "collat", type(uint256).max); + loanToken = new ERC20("loan", "loan", 1 ether); + collateralToken = new ERC20("collat", "collat", 1 ether); oracle = new Oracle(); + + collaterals = new Collateral[](1); + collaterals[0] = Collateral({token: address(collateralToken), lltv: 1e18, oracle: address(oracle)}); + + term = Term(address(loanToken), collaterals, block.timestamp + 100); + id = keccak256(abi.encode(term)); + + loanToken.approve(address(terms), type(uint256).max); + collateralToken.approve(address(terms), type(uint256).max); + terms.supplyCollateral(term, address(collateralToken), 1 ether, borrower); } function testMint() external { - Collateral[] memory collaterals = new Collateral[](1); - collaterals[0] = Collateral({token: address(collateralToken), lltv: 1e18, oracle: address(oracle)}); Offer memory lendOffer = Offer({ - lend: true, + buy: true, offering: address(this), assets: 100, loanToken: address(loanToken), @@ -32,8 +44,8 @@ contract TermsTest is Test { price: 1 }); Offer memory borrowOffer = Offer({ - lend: false, - offering: address(this), + buy: false, + offering: borrower, assets: 100, loanToken: address(loanToken), collaterals: collaterals, @@ -44,12 +56,11 @@ contract TermsTest is Test { Signature memory lendSig = Signature(0, 0, 0); Signature memory borrowSig = Signature(0, 0, 0); - terms.mint(lendOffer, lendSig, borrowOffer, borrowSig); - - Term memory term = Term(address(loanToken), collaterals, block.timestamp + 100); - bytes32 id = terms.id(term); + terms.MATCH(lendOffer, lendSig, borrowOffer, borrowSig); assertEq(terms.bondOf(address(this), id), 100); - assertEq(terms.debtOf(address(this), id), 100); + assertEq(terms.debtOf(borrower, id), 100); + + assertEq(loanToken.balanceOf(borrower), 1); } } diff --git a/test/helpers/ERC20.sol b/test/helpers/ERC20.sol index 912705d12..72825edd2 100644 --- a/test/helpers/ERC20.sol +++ b/test/helpers/ERC20.sol @@ -6,6 +6,7 @@ contract ERC20 { string public symbol; uint256 public totalSupply; mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; constructor(string memory _name, string memory _symbol, uint256 _totalSupply) { name = _name; @@ -25,10 +26,17 @@ contract ERC20 { function transferFrom(address sender, address recipient, uint256 amount) public returns (bool) { require(amount <= balanceOf[sender], "Insufficient balance"); + require(amount <= allowance[sender][msg.sender], "Insufficient allowance"); balanceOf[sender] -= amount; balanceOf[recipient] += amount; + allowance[sender][msg.sender] -= amount; return true; } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } } From 4c4349f06b8cd309646de9eac16e6a909ccdc36f Mon Sep 17 00:00:00 2001 From: MathisGD Date: Wed, 22 Jan 2025 17:17:52 +0100 Subject: [PATCH 03/41] test: test signatures --- certora/Terms.spec | 6 ++-- src/Terms.sol | 25 +++++++-------- test/TermsTest.sol | 80 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 85 insertions(+), 26 deletions(-) diff --git a/certora/Terms.spec b/certora/Terms.spec index 2e9b9ef13..003aa1ab3 100644 --- a/certora/Terms.spec +++ b/certora/Terms.spec @@ -7,9 +7,9 @@ methods { function balanceOf(address, address) external returns uint256 envfree; function id(Terms.Term) external returns bytes32 envfree; - function _.transfer(address, uint256) external => DISPATCHER(true); - function _.transferFrom(address, address, uint256) external => DISPATCHER(true); - function _.balanceOf(address) external => DISPATCHER(true); + // function _.transfer(address, uint256) external => DISPATCHER(true); + // function _.transferFrom(address, address, uint256) external => DISPATCHER(true); + // function _.balanceOf(address) external => DISPATCHER(true); } /// HOOKS /// diff --git a/src/Terms.sol b/src/Terms.sol index ced8c4087..9ce717d1f 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -9,11 +9,11 @@ import "./interfaces/ITerms.sol"; contract Terms is ITerms { /// CONSTANTS /// - bytes32 constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); - bytes32 constant OFFER_TYPEHASH = keccak256( + bytes32 constant public DOMAIN_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); + bytes32 constant public OFFER_TYPEHASH = keccak256( "Offer(bool lend,address offering,uint256 assets,address loanToken,Collateral[] collaterals,uint256 maturity,uint256 price)" ); - uint256 constant WAD = 1 ether; + uint256 constant public WAD = 1 ether; /// STORAGE /// @@ -23,25 +23,23 @@ contract Terms is ITerms { mapping(bytes32 => uint256) public withdrawable; mapping(address => mapping(bytes32 => mapping(address => uint256))) public collateralOf; // Offers. - mapping(bytes32 => uint256) public consumed; + mapping(bytes => uint256) public consumed; /// 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) public { _checkOffers(buyOffer, buySig, sellOffer, sellSig); - // Commented because it makes invariants "not vacuous". - // uint256 amount = Math.min(buyOffer.assets - consumed[keccak256(abi.encode(buyOffer))], sellOffer.assets - consumed[keccak256(abi.encode(sellOffer))]); - uint256 amount = Math.min(buyOffer.assets, sellOffer.assets); + uint256 amount = Math.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; - // Commented because it makes invariants "not vacuous". - // consumed[keccak256(abi.encode(buyOffer))] += amount; - // consumed[keccak256(abi.encode(sellOffer))] += amount; + consumed[abi.encode(buyOffer)] += amount; + consumed[abi.encode(sellOffer)] += amount; Term memory term = Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity); bytes32 id = _id(term); @@ -102,7 +100,7 @@ contract Terms is ITerms { return keccak256(abi.encode(term)); } - function _checkOffers(Offer memory buyOffer, Signature memory, Offer memory sellOffer, Signature memory) + function _checkOffers(Offer memory buyOffer, Signature memory buySig, Offer memory sellOffer, Signature memory sellSig) internal view { @@ -110,9 +108,8 @@ contract Terms is ITerms { require(buyOffer.buy && !sellOffer.buy, "Inconsistent lend flags"); require(buyOffer.maturity > block.timestamp, "Buy offer has expired"); - // Commented because it makes verification fail. - // _checkSignature(buyOffer, buySig); - // _checkSignature(sellOffer, sellSig); + _checkSignature(buyOffer, buySig); + _checkSignature(sellOffer, sellSig); // Check compatibility. diff --git a/test/TermsTest.sol b/test/TermsTest.sol index 6dd6a17c8..74b197470 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -11,14 +11,22 @@ contract TermsTest is Test { ERC20 private loanToken; ERC20 private collateralToken; Oracle private oracle; - address private borrower = makeAddr("borrower"); + uint256 private borrowerSK; + address private borrower; + uint256 private lenderSK; + address private lender; Term private term; bytes32 private id; Collateral[] private collaterals; function setUp() external { + (borrower, borrowerSK) = makeAddrAndKey("borrower"); + (lender, lenderSK) = makeAddrAndKey("lender"); + terms = new Terms(); loanToken = new ERC20("loan", "loan", 1 ether); + loanToken.transfer(lender, 99); + loanToken.transfer(borrower, 1); collateralToken = new ERC20("collat", "collat", 1 ether); oracle = new Oracle(); @@ -28,20 +36,23 @@ contract TermsTest is Test { term = Term(address(loanToken), collaterals, block.timestamp + 100); id = keccak256(abi.encode(term)); + vm.prank(lender); + 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), 1 ether, borrower); } - function testMint() external { + function testMint() public { Offer memory lendOffer = Offer({ buy: true, - offering: address(this), + offering: lender, assets: 100, loanToken: address(loanToken), collaterals: collaterals, maturity: block.timestamp + 100, - price: 1 + price: 99 }); Offer memory borrowOffer = Offer({ buy: false, @@ -50,17 +61,68 @@ contract TermsTest is Test { loanToken: address(loanToken), collaterals: collaterals, maturity: block.timestamp + 100, - price: 1 + price: 99 }); - Signature memory lendSig = Signature(0, 0, 0); - Signature memory borrowSig = Signature(0, 0, 0); + Signature memory lendSig = _signOffer(lendOffer, lenderSK); + Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); terms.MATCH(lendOffer, lendSig, borrowOffer, borrowSig); - assertEq(terms.bondOf(address(this), id), 100); + assertEq(terms.bondOf(lender, id), 100); assertEq(terms.debtOf(borrower, id), 100); - assertEq(loanToken.balanceOf(borrower), 1); + assertEq(loanToken.balanceOf(borrower), 100); + assertEq(loanToken.balanceOf(lender), 0); + } + + function testRepay() public { + testMint(); + + vm.warp(block.timestamp + 99); + + vm.prank(borrower); + terms.repayDebt(term, 100, borrower); + + assertEq(terms.debtOf(borrower, id), 0); + assertEq(terms.withdrawable(id), 100); + + assertEq(loanToken.balanceOf(address(terms)), 100); + assertEq(loanToken.balanceOf(borrower), 0); + } + + function testWithdraw() public { + testRepay(); + + vm.prank(lender); + terms.withdrawBond(term, 100, lender); + + assertEq(terms.bondOf(lender, id), 0); + assertEq(terms.withdrawable(id), 0); + + assertEq(loanToken.balanceOf(address(terms)), 0); + assertEq(loanToken.balanceOf(lender), 100); + } + + function testWithdrawCollateral() public { + testRepay(); + + vm.prank(borrower); + terms.withdrawCollateral(term, address(collateralToken), 1 ether, borrower); + + assertEq(terms.collateralOf(borrower, id, address(collateralToken)), 0); + + assertEq(collateralToken.balanceOf(address(terms)), 0); + assertEq(collateralToken.balanceOf(borrower), 1 ether); + } + + function _signOffer(Offer memory offer, uint256 sk) internal view returns (Signature memory) { + bytes32 hashStruct = keccak256(abi.encode(terms.OFFER_TYPEHASH(), offer)); + bytes32 domainSeparator = keccak256(abi.encode(terms.DOMAIN_TYPEHASH(), block.chainid, address(terms))); + bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, hashStruct)); + + Signature memory sig; + (sig.v, sig.r, sig.s) = vm.sign(sk, digest); + return sig; } } From b5be327f7192d98f2faac4551d37c69d44e65d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Thu, 13 Mar 2025 14:05:41 +0100 Subject: [PATCH 04/41] feat: liquidation poc --- src/Terms.sol | 113 +++++++++++++++++++++++++++++++++++--- src/interfaces/ITerms.sol | 8 +++ 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 9ce717d1f..675069f1b 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -9,11 +9,11 @@ import "./interfaces/ITerms.sol"; contract Terms is ITerms { /// CONSTANTS /// - bytes32 constant public DOMAIN_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); - bytes32 constant public OFFER_TYPEHASH = keccak256( + bytes32 public constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); + bytes32 public constant OFFER_TYPEHASH = keccak256( "Offer(bool lend,address offering,uint256 assets,address loanToken,Collateral[] collaterals,uint256 maturity,uint256 price)" ); - uint256 constant public WAD = 1 ether; + uint256 public constant WAD = 1 ether; /// STORAGE /// @@ -33,7 +33,9 @@ contract Terms is ITerms { { _checkOffers(buyOffer, buySig, sellOffer, sellSig); - uint256 amount = Math.min(buyOffer.assets - consumed[abi.encode(buyOffer)], sellOffer.assets - consumed[abi.encode(sellOffer)]); + uint256 amount = Math.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; @@ -58,8 +60,17 @@ contract Terms is ITerms { uint256 sellerScaledPrice = sellOffer.price * amount / sellOffer.assets; uint256 buyerScaledPrice = buyOffer.price * amount / buyOffer.assets; + uint256 rest; + if (sellerScaledPrice < buyerScaledPrice) { + rest = buyerScaledPrice - sellerScaledPrice; + } else { + rest = 0; + } + IERC20(buyOffer.loanToken).transferFrom(buyer, seller, sellerScaledPrice); - IERC20(buyOffer.loanToken).transferFrom(buyer, msg.sender, buyerScaledPrice - sellerScaledPrice); + if (rest > 0) { + IERC20(buyOffer.loanToken).transferFrom(buyer, msg.sender, rest); + } } /// @dev Will revert if there is no withdrawable funds. @@ -94,16 +105,100 @@ contract Terms is ITerms { IERC20(collateral).transfer(msg.sender, amount); } + /// @notice Liquidates the given collections `repaidAmounts` of debt asset + /// by colleral or seize the given `seizedAssets` of collateral on the given + /// `term` of the given `borrower`, + /// @dev Either `seizedAssets` or `repaidAmounts` should be empty. + /// @param term The term of the bond. + /// @param borrower The debtor of the loan. + /// @param seizedAssets A collection of amounts of collateral to seize and the collateral index to seize.. + /// @param repaidShares A collection of amounts of loan asset to repay and the collateral index to seize. + /// @param data Arbitrary data to pass to the `onMorphoLiquidate` callback. Pass empty data if not needed. + /// @return The list of amounts of assets seized by collateral index. + /// @return The list of amounts of assets repaid by collateral index. + function liquidate(Term memory term, Limit[] memory seizedAssets, Limit[] memory repaidAmounts, address borrower) + external + returns (Limit[] memory, Limit[] memory) + { + require( + seizedAssets.length <= term.collaterals.length, "Cannot seize more assets than the the supplied collaterals" + ); + // TODO check that either the user is either seized or repaying amounts. + require(seizedAssets.length == repaidAmounts.length, "Incoherent limit arrays"); + require(!_isHealthy(term, borrower), "Healthy borrower"); + + bytes32 id = _id(term); + + // Over approximation + uint256 liquidationIncentiveFactor = 1.15e18; + + uint256 totalRepaid; + for (uint256 i = 0; i < repaidAmounts.length; i++) { + totalRepaid += repaidAmounts[i].amount; + } + + // Compute the repaid and seized amounts by collateral index. + if (totalRepaid > 0) { + for (uint256 i = 0; i < repaidAmounts.length; i++) { + Limit memory l; + uint256 collateralPrice = IOracle(term.collaterals[repaidAmounts[i].collateralIndex].oracle).price(); + l.collateralIndex = repaidAmounts[i].collateralIndex; + l.amount = (repaidAmounts[i].amount * liquidationIncentiveFactor / WAD) / collateralPrice; + seizedAssets[i] = l; + } + } else { + for (uint256 i = 0; i < seizedAssets.length; i++) { + Limit memory l; + uint256 collateralPrice = IOracle(term.collaterals[seizedAssets[i].collateralIndex].oracle).price(); + uint256 seizedAssetsQuoted = seizedAssets[i].amount * collateralPrice; + l.collateralIndex = repaidAmounts[i].collateralIndex; + l.amount = seizedAssetsQuoted * WAD / liquidationIncentiveFactor; + totalRepaid += l.amount; + repaidAmounts[i] = l; + } + } + + debtOf[borrower][id] -= totalRepaid; + withdrawable[id] += totalRepaid; + + // Transfer the repaid amount. + IERC20(term.loanToken).transferFrom(msg.sender, address(this), totalRepaid); + + // Transfer the seized collaterals. + for (uint256 i = 0; i < seizedAssets.length; i++) { + Limit memory l = seizedAssets[i]; + address collateral = term.collaterals[l.collateralIndex].token; + collateralOf[borrower][_id(term)][collateral] -= l.amount; + IERC20(collateral).transfer(msg.sender, l.amount); + } + + // Realize bad debt. + uint256 totalCollateralQuoted; + for (uint256 i = 0; i < term.collaterals.length; i++) { + uint256 price = IOracle(term.collaterals[i].oracle).price(); + uint256 collateralQuoted = collateralOf[borrower][id][term.collaterals[i].token] * price / WAD; + totalCollateralQuoted += collateralQuoted; + } + if (totalCollateralQuoted == 0) { + uint256 badDebt = debtOf[borrower][id]; + withdrawable[id] -= badDebt; + debtOf[borrower][id] = 0; + } + return (seizedAssets, repaidAmounts); + } + /// INTERNAL /// function _id(Term memory term) public pure returns (bytes32) { return keccak256(abi.encode(term)); } - function _checkOffers(Offer memory buyOffer, Signature memory buySig, Offer memory sellOffer, Signature memory sellSig) - internal - view - { + 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"); diff --git a/src/interfaces/ITerms.sol b/src/interfaces/ITerms.sol index c6d13602a..301e3bccf 100644 --- a/src/interfaces/ITerms.sol +++ b/src/interfaces/ITerms.sol @@ -30,4 +30,12 @@ struct Signature { bytes32 s; } +struct Limit { + // Index in the collateral list of the term. + // TODO use something more robust than indexes. + uint256 collateralIndex; + // Amount in either loan or collateral asset. + uint256 amount; +} + interface ITerms {} From a3a97250bd9a74d694aa5ff0d78155649b8390e6 Mon Sep 17 00:00:00 2001 From: MathisGD Date: Tue, 18 Mar 2025 09:41:32 +0100 Subject: [PATCH 05/41] chore: fmt --- src/Terms.sol | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 9ce717d1f..dd9821fc5 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -9,11 +9,11 @@ import "./interfaces/ITerms.sol"; contract Terms is ITerms { /// CONSTANTS /// - bytes32 constant public DOMAIN_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); - bytes32 constant public OFFER_TYPEHASH = keccak256( + bytes32 public constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); + bytes32 public constant OFFER_TYPEHASH = keccak256( "Offer(bool lend,address offering,uint256 assets,address loanToken,Collateral[] collaterals,uint256 maturity,uint256 price)" ); - uint256 constant public WAD = 1 ether; + uint256 public constant WAD = 1 ether; /// STORAGE /// @@ -33,7 +33,9 @@ contract Terms is ITerms { { _checkOffers(buyOffer, buySig, sellOffer, sellSig); - uint256 amount = Math.min(buyOffer.assets - consumed[abi.encode(buyOffer)], sellOffer.assets - consumed[abi.encode(sellOffer)]); + uint256 amount = Math.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; @@ -100,10 +102,12 @@ 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 - { + 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"); From 3f694857478a9d1cba68bb2510670b265c1d7b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 14:39:56 +0100 Subject: [PATCH 06/41] fix: implement review suggestions --- src/Terms.sol | 135 ++++++++++++++++++++++---------------- src/interfaces/ITerms.sol | 8 ++- 2 files changed, 85 insertions(+), 58 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 675069f1b..35026088f 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -15,6 +15,8 @@ contract Terms is ITerms { ); uint256 public constant WAD = 1 ether; + uint256 public constant ORACLE_PRICE_SCALE = 1e36; + /// STORAGE /// // Terms. @@ -105,27 +107,21 @@ contract Terms is ITerms { IERC20(collateral).transfer(msg.sender, amount); } - /// @notice Liquidates the given collections `repaidAmounts` of debt asset - /// by colleral or seize the given `seizedAssets` of collateral on the given - /// `term` of the given `borrower`, - /// @dev Either `seizedAssets` or `repaidAmounts` should be empty. + /// @notice Execute the given collection `seizures` on the given `term` of the given `borrower`. + /// @dev On each seizure either `seizedAssets` or `repaidAmounts` should be equal to zero. /// @param term The term of the bond. + /// @param A collection of amounts of debt to repay or asset to seize with the index of the collateral in the term collaterals. /// @param borrower The debtor of the loan. - /// @param seizedAssets A collection of amounts of collateral to seize and the collateral index to seize.. - /// @param repaidShares A collection of amounts of loan asset to repay and the collateral index to seize. - /// @param data Arbitrary data to pass to the `onMorphoLiquidate` callback. Pass empty data if not needed. - /// @return The list of amounts of assets seized by collateral index. - /// @return The list of amounts of assets repaid by collateral index. - function liquidate(Term memory term, Limit[] memory seizedAssets, Limit[] memory repaidAmounts, address borrower) + /// @param data Arbitrary data to pass to the callback. Pass empty data if not needed. + /// @return A collection of the actual amounts of debt repaid or asset seized with the collateral index. + function liquidate(Term memory term, Seizure[] memory seizures, address borrower, bytes calldata data) external - returns (Limit[] memory, Limit[] memory) + returns (Seizure[] memory) { require( - seizedAssets.length <= term.collaterals.length, "Cannot seize more assets than the the supplied collaterals" + seizures.length <= term.collaterals.length && seizures.length > 0, + "Cannot seize more assets than the the supplied collaterals" ); - // TODO check that either the user is either seized or repaying amounts. - require(seizedAssets.length == repaidAmounts.length, "Incoherent limit arrays"); - require(!_isHealthy(term, borrower), "Healthy borrower"); bytes32 id = _id(term); @@ -133,62 +129,90 @@ contract Terms is ITerms { uint256 liquidationIncentiveFactor = 1.15e18; uint256 totalRepaid; - for (uint256 i = 0; i < repaidAmounts.length; i++) { - totalRepaid += repaidAmounts[i].amount; + uint256 totalCollateralQuoted; + uint256 maxDebt; + + // Compute the total collateral quoted and borrow capacity. + for (uint256 i = 0; i < term.collaterals.length; i++) { + uint256 price = IOracle(term.collaterals[i].oracle).price(); + uint256 collateralQuoted = + collateralOf[borrower][id][term.collaterals[i].token] * price / ORACLE_PRICE_SCALE; + totalCollateralQuoted += collateralQuoted; + maxDebt += collateralQuoted * term.collaterals[i].lltv / WAD; } - // Compute the repaid and seized amounts by collateral index. - if (totalRepaid > 0) { - for (uint256 i = 0; i < repaidAmounts.length; i++) { - Limit memory l; - uint256 collateralPrice = IOracle(term.collaterals[repaidAmounts[i].collateralIndex].oracle).price(); - l.collateralIndex = repaidAmounts[i].collateralIndex; - l.amount = (repaidAmounts[i].amount * liquidationIncentiveFactor / WAD) / collateralPrice; - seizedAssets[i] = l; - } - } else { - for (uint256 i = 0; i < seizedAssets.length; i++) { - Limit memory l; - uint256 collateralPrice = IOracle(term.collaterals[seizedAssets[i].collateralIndex].oracle).price(); - uint256 seizedAssetsQuoted = seizedAssets[i].amount * collateralPrice; - l.collateralIndex = repaidAmounts[i].collateralIndex; - l.amount = seizedAssetsQuoted * WAD / liquidationIncentiveFactor; - totalRepaid += l.amount; - repaidAmounts[i] = l; - } + // Check that position not healthy. + require(debtOf[borrower][id] > maxDebt, "Healthy borrower"); + + // Compute the repaid and seized amounts by collateral index, remaining collateral and total repaid. + for (uint256 i = 0; i < seizures.length; i++) { + require(seizures[i].collateralIndex < term.collaterals.length, "INCONSISTENT_INPUT"); + (uint256 repaidAmount, uint256 seizedAssets, uint256 seizedAssetsQuoted) = _seizeCollateral( + term.collaterals[seizures[i].collateralIndex], seizures[i], liquidationIncentiveFactor, borrower + ); + seizures[i].repaidAmount = repaidAmount; + seizures[i].seizedAssets = seizedAssets; + collateralOf[borrower][_id(term)][term.collaterals[seizures[i].collateralIndex].token] -= + seizures[i].seizedAssets; + totalRepaid += seizures[i].repaidAmount; + totalCollateralQuoted -= seizedAssetsQuoted; } debtOf[borrower][id] -= totalRepaid; withdrawable[id] += totalRepaid; - // Transfer the repaid amount. - IERC20(term.loanToken).transferFrom(msg.sender, address(this), totalRepaid); - - // Transfer the seized collaterals. - for (uint256 i = 0; i < seizedAssets.length; i++) { - Limit memory l = seizedAssets[i]; - address collateral = term.collaterals[l.collateralIndex].token; - collateralOf[borrower][_id(term)][collateral] -= l.amount; - IERC20(collateral).transfer(msg.sender, l.amount); - } - // Realize bad debt. - uint256 totalCollateralQuoted; - for (uint256 i = 0; i < term.collaterals.length; i++) { - uint256 price = IOracle(term.collaterals[i].oracle).price(); - uint256 collateralQuoted = collateralOf[borrower][id][term.collaterals[i].token] * price / WAD; - totalCollateralQuoted += collateralQuoted; - } if (totalCollateralQuoted == 0) { uint256 badDebt = debtOf[borrower][id]; withdrawable[id] -= badDebt; debtOf[borrower][id] = 0; } - return (seizedAssets, repaidAmounts); + + // Perform the callback. + // TODO: simplify with dedicated signature for callback + if (data.length > 0) { + bytes memory callbackData = abi.encode(seizures, borrower, msg.sender, data); + (bool success, bytes memory returnData) = msg.sender.call(callbackData); + if (!success) lowLevelRevert(returnData); + } + + IERC20(term.loanToken).transferFrom(msg.sender, address(this), totalRepaid); + + return seizures; + } + + function _seizeCollateral(Collateral memory c, Seizure memory s, uint256 lif, address borrower) + internal + returns (uint256, uint256, uint256) + { + require(exactlyOneZero(s.seizedAssets, s.repaidAmount), "INCONSISTENT_INPUT"); + uint256 collateralPrice = IOracle(c.oracle).price(); + uint256 seizedAssetsQuoted = s.seizedAssets * collateralPrice / ORACLE_PRICE_SCALE; + if (s.repaidAmount > 0) { + s.seizedAssets = (s.repaidAmount * lif / WAD) * ORACLE_PRICE_SCALE / collateralPrice; + seizedAssetsQuoted = s.seizedAssets * collateralPrice / ORACLE_PRICE_SCALE; + } else { + s.repaidAmount = seizedAssetsQuoted * WAD * ORACLE_PRICE_SCALE / lif; + } + IERC20(c.token).transfer(msg.sender, s.seizedAssets); + return (s.repaidAmount, s.seizedAssets, seizedAssetsQuoted); } /// INTERNAL /// + // TODO: move to a dedicatedd library + function exactlyOneZero(uint256 x, uint256 y) internal pure returns (bool z) { + assembly { + z := xor(iszero(x), iszero(y)) + } + } + + function lowLevelRevert(bytes memory returnData) internal pure { + assembly ("memory-safe") { + revert(add(32, returnData), mload(returnData)) + } + } + function _id(Term memory term) public pure returns (bytes32) { return keccak256(abi.encode(term)); } @@ -245,7 +269,8 @@ contract Terms is ITerms { uint256 maxDebt; for (uint256 i = 0; i < term.collaterals.length; i++) { uint256 price = IOracle(term.collaterals[i].oracle).price(); - uint256 collateralQuoted = collateralOf[borrower][id][term.collaterals[i].token] * price / WAD; + uint256 collateralQuoted = + collateralOf[borrower][id][term.collaterals[i].token] * price / ORACLE_PRICE_SCALE; maxDebt += collateralQuoted * term.collaterals[i].lltv / WAD; } diff --git a/src/interfaces/ITerms.sol b/src/interfaces/ITerms.sol index 301e3bccf..22ab374a4 100644 --- a/src/interfaces/ITerms.sol +++ b/src/interfaces/ITerms.sol @@ -30,12 +30,14 @@ struct Signature { bytes32 s; } -struct Limit { +struct Seizure { // Index in the collateral list of the term. // TODO use something more robust than indexes. uint256 collateralIndex; - // Amount in either loan or collateral asset. - uint256 amount; + // Amount of loan asset to repay. + uint256 repaidAmount; + // Amount of collater asset to seize. + uint256 seizedAssets; } interface ITerms {} From 80c02fdd7226eac557047bf5c178d6fad4f21a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Thu, 20 Mar 2025 10:18:43 +0100 Subject: [PATCH 07/41] feat: test liquidation --- src/Terms.sol | 17 ++++---- test/TermsTest.sol | 93 ++++++++++++++++++++++++++++++++++++++++- test/helpers/Oracle.sol | 4 ++ 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 35026088f..0a3dec16e 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -15,7 +15,7 @@ contract Terms is ITerms { ); uint256 public constant WAD = 1 ether; - uint256 public constant ORACLE_PRICE_SCALE = 1e36; + uint256 public constant ORACLE_PRICE_SCALE = 1 ether; /// STORAGE /// @@ -108,9 +108,9 @@ contract Terms is ITerms { } /// @notice Execute the given collection `seizures` on the given `term` of the given `borrower`. - /// @dev On each seizure either `seizedAssets` or `repaidAmounts` should be equal to zero. + /// @dev On each seizure either `repaidAmounts` or `seizedAssets` should be equal to zero. /// @param term The term of the bond. - /// @param A collection of amounts of debt to repay or asset to seize with the index of the collateral in the term collaterals. + /// @param seizures An array of amounts of debt to repay or assetd to seize with the index of the collateral in the term collaterals. /// @param borrower The debtor of the loan. /// @param data Arbitrary data to pass to the callback. Pass empty data if not needed. /// @return A collection of the actual amounts of debt repaid or asset seized with the collateral index. @@ -120,7 +120,7 @@ contract Terms is ITerms { { require( seizures.length <= term.collaterals.length && seizures.length > 0, - "Cannot seize more assets than the the supplied collaterals" + "Cannot seize more assets than the supplied collaterals" ); bytes32 id = _id(term); @@ -148,7 +148,7 @@ contract Terms is ITerms { for (uint256 i = 0; i < seizures.length; i++) { require(seizures[i].collateralIndex < term.collaterals.length, "INCONSISTENT_INPUT"); (uint256 repaidAmount, uint256 seizedAssets, uint256 seizedAssetsQuoted) = _seizeCollateral( - term.collaterals[seizures[i].collateralIndex], seizures[i], liquidationIncentiveFactor, borrower + term.collaterals[seizures[i].collateralIndex], seizures[i], liquidationIncentiveFactor, msg.sender ); seizures[i].repaidAmount = repaidAmount; seizures[i].seizedAssets = seizedAssets; @@ -181,7 +181,7 @@ contract Terms is ITerms { return seizures; } - function _seizeCollateral(Collateral memory c, Seizure memory s, uint256 lif, address borrower) + function _seizeCollateral(Collateral memory c, Seizure memory s, uint256 lif, address liquidator) internal returns (uint256, uint256, uint256) { @@ -192,15 +192,16 @@ contract Terms is ITerms { s.seizedAssets = (s.repaidAmount * lif / WAD) * ORACLE_PRICE_SCALE / collateralPrice; seizedAssetsQuoted = s.seizedAssets * collateralPrice / ORACLE_PRICE_SCALE; } else { + // TODO: fix rouding s.repaidAmount = seizedAssetsQuoted * WAD * ORACLE_PRICE_SCALE / lif; } - IERC20(c.token).transfer(msg.sender, s.seizedAssets); + IERC20(c.token).transfer(liquidator, s.seizedAssets); return (s.repaidAmount, s.seizedAssets, seizedAssetsQuoted); } /// INTERNAL /// - // TODO: move to a dedicatedd library + // TODO: move to a dedicated library function exactlyOneZero(uint256 x, uint256 y) internal pure returns (bool z) { assembly { z := xor(iszero(x), iszero(y)) diff --git a/test/TermsTest.sol b/test/TermsTest.sol index 74b197470..8821c687a 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -22,7 +22,7 @@ contract TermsTest is Test { function setUp() external { (borrower, borrowerSK) = makeAddrAndKey("borrower"); (lender, lenderSK) = makeAddrAndKey("lender"); - + terms = new Terms(); loanToken = new ERC20("loan", "loan", 1 ether); loanToken.transfer(lender, 99); @@ -116,6 +116,97 @@ contract TermsTest is Test { assertEq(collateralToken.balanceOf(borrower), 1 ether); } + function genTerm() internal returns (Term memory) { + // instantiate tokens and oracle + ERC20 c1 = new ERC20("collat1", "c1", 1 ether); + ERC20 c2 = new ERC20("collat1", "c2", 1 ether); + Oracle o1 = new Oracle(); + Oracle o2 = new Oracle(); + Collateral[] memory cs = new Collateral[](1); + cs[0] = Collateral({token: address(c1), lltv: 0.75e18, oracle: address(o1)}); + cs[1] = Collateral({token: address(c2), lltv: 1e18, oracle: address(o2)}); + Term memory t = Term(address(loanToken), cs, block.timestamp + 100); + return t; + } + + function genSeizures() internal pure returns (Seizure[] memory) { + Seizure[] memory s = new Seizure[](1); + s[0] = Seizure({collateralIndex: 0, repaidAmount: 43, seizedAssets: 0}); + + return s; + } + + function mintBond(Collateral[] memory cs) internal { + Offer memory lendOffer = Offer({ + buy: true, + offering: lender, + assets: 100, + loanToken: address(loanToken), + collaterals: cs, + maturity: block.timestamp + 100, + price: 99 + }); + Offer memory borrowOffer = Offer({ + buy: false, + offering: borrower, + assets: 100, + loanToken: address(loanToken), + collaterals: cs, + maturity: block.timestamp + 100, + price: 99 + }); + + Signature memory lendSig = _signOffer(lendOffer, lenderSK); + Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); + + terms.MATCH(lendOffer, lendSig, borrowOffer, borrowSig); + } + + function testLiquidation() public { + address liquidator; + uint256 liquidatorSK; + (liquidator, liquidatorSK) = makeAddrAndKey("liquidator"); + + Term memory t = genTerm(); + + deal(t.collaterals[0].token, borrower, 1 ether); + deal(t.collaterals[1].token, borrower, 1 ether); + + vm.startPrank(borrower); + ERC20(t.collaterals[0].token).approve(address(terms), type(uint256).max); + ERC20(t.collaterals[1].token).approve(address(terms), type(uint256).max); + terms.supplyCollateral(t, t.collaterals[0].token, 134, borrower); + terms.supplyCollateral(t, t.collaterals[1].token, 34, borrower); + vm.stopPrank(); + + mintBond(t.collaterals); + + Seizure[] memory s = genSeizures(); + loanToken.transfer(liquidator, 50); + + vm.prank(liquidator); + loanToken.approve(address(terms), type(uint256).max); + + vm.warp(block.timestamp + 50); + Oracle(t.collaterals[0].oracle).setPrice(0.5e18); + + vm.prank(liquidator); + uint256 gasBefore = gasleft(); + terms.liquidate(t, s, borrower, "0x0"); + uint256 gasUsed = gasBefore - gasleft(); + + oracle.setPrice(1e18); + emit log_named_uint("Gas used", gasUsed); + + bytes32 idT = keccak256(abi.encode(t)); + assertEq(terms.debtOf(borrower, idT), 57); + assertEq(terms.withdrawable(idT), 43); + + assertEq(loanToken.balanceOf(address(terms)), 43); + assertEq(loanToken.balanceOf(liquidator), 7); + assertEq(ERC20(t.collaterals[0].token).balanceOf(liquidator), 98); + } + function _signOffer(Offer memory offer, uint256 sk) internal view returns (Signature memory) { bytes32 hashStruct = keccak256(abi.encode(terms.OFFER_TYPEHASH(), offer)); bytes32 domainSeparator = keccak256(abi.encode(terms.DOMAIN_TYPEHASH(), block.chainid, address(terms))); diff --git a/test/helpers/Oracle.sol b/test/helpers/Oracle.sol index a0a7722bb..c500dcc97 100644 --- a/test/helpers/Oracle.sol +++ b/test/helpers/Oracle.sol @@ -3,4 +3,8 @@ pragma solidity ^0.8.0; contract Oracle { uint256 public price = 1e18; + + function setPrice(uint256 newPrice) external { + price = newPrice; + } } From e406a6a388edec77de285133f804181cb2301944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 12:47:37 +0100 Subject: [PATCH 08/41] feat: fix rounding --- src/Terms.sol | 27 ++++++++++++----------- src/libraries/MathLib.sol | 45 +++++++++++++++++++++++++++++++++++++++ test/helpers/Oracle.sol | 2 +- 3 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 src/libraries/MathLib.sol diff --git a/src/Terms.sol b/src/Terms.sol index 0a3dec16e..d014fbc33 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -2,20 +2,22 @@ pragma solidity 0.8.28; import "./libraries/Math.sol"; +import {MathLib, WAD} from "./libraries/MathLib.sol"; import "./interfaces/IERC20.sol"; import "./interfaces/IOracle.sol"; import "./interfaces/ITerms.sol"; contract Terms is ITerms { + using MathLib for uint256; + /// CONSTANTS /// bytes32 public constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); bytes32 public constant OFFER_TYPEHASH = keccak256( "Offer(bool lend,address offering,uint256 assets,address loanToken,Collateral[] collaterals,uint256 maturity,uint256 price)" ); - uint256 public constant WAD = 1 ether; - uint256 public constant ORACLE_PRICE_SCALE = 1 ether; + uint256 public constant ORACLE_PRICE_SCALE = 1e36; /// STORAGE /// @@ -136,9 +138,9 @@ contract Terms is ITerms { for (uint256 i = 0; i < term.collaterals.length; i++) { uint256 price = IOracle(term.collaterals[i].oracle).price(); uint256 collateralQuoted = - collateralOf[borrower][id][term.collaterals[i].token] * price / ORACLE_PRICE_SCALE; + collateralOf[borrower][id][term.collaterals[i].token].mulDivDown(price, ORACLE_PRICE_SCALE); totalCollateralQuoted += collateralQuoted; - maxDebt += collateralQuoted * term.collaterals[i].lltv / WAD; + maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); } // Check that position not healthy. @@ -187,13 +189,12 @@ contract Terms is ITerms { { require(exactlyOneZero(s.seizedAssets, s.repaidAmount), "INCONSISTENT_INPUT"); uint256 collateralPrice = IOracle(c.oracle).price(); - uint256 seizedAssetsQuoted = s.seizedAssets * collateralPrice / ORACLE_PRICE_SCALE; - if (s.repaidAmount > 0) { - s.seizedAssets = (s.repaidAmount * lif / WAD) * ORACLE_PRICE_SCALE / collateralPrice; - seizedAssetsQuoted = s.seizedAssets * collateralPrice / ORACLE_PRICE_SCALE; + uint256 seizedAssetsQuoted = s.seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); + if (s.seizedAssets > 0) { + s.repaidAmount = seizedAssetsQuoted.wDivUp(lif); } else { - // TODO: fix rouding - s.repaidAmount = seizedAssetsQuoted * WAD * ORACLE_PRICE_SCALE / lif; + s.seizedAssets = s.repaidAmount.wMulDown(lif).mulDivUp(ORACLE_PRICE_SCALE, collateralPrice); + seizedAssetsQuoted = s.seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); } IERC20(c.token).transfer(liquidator, s.seizedAssets); return (s.repaidAmount, s.seizedAssets, seizedAssetsQuoted); @@ -237,7 +238,7 @@ contract Terms is ITerms { require(buyOffer.loanToken == sellOffer.loanToken, "Loan tokens do not match"); for (uint256 i = 0; i < sellOffer.collaterals.length; i++) { uint256 j; - // Relies on the fact that the collaterals are sorted. + // 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 ( @@ -271,8 +272,8 @@ contract Terms is ITerms { for (uint256 i = 0; i < term.collaterals.length; i++) { uint256 price = IOracle(term.collaterals[i].oracle).price(); uint256 collateralQuoted = - collateralOf[borrower][id][term.collaterals[i].token] * price / ORACLE_PRICE_SCALE; - maxDebt += collateralQuoted * term.collaterals[i].lltv / WAD; + collateralOf[borrower][id][term.collaterals[i].token].mulDivDown(price, ORACLE_PRICE_SCALE); + maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); } return debtOf[borrower][id] <= maxDebt; diff --git a/src/libraries/MathLib.sol b/src/libraries/MathLib.sol new file mode 100644 index 000000000..653db4f87 --- /dev/null +++ b/src/libraries/MathLib.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +uint256 constant WAD = 1e18; + +/// @title MathLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Library to manage fixed-point arithmetic. +library MathLib { + /// @dev Returns (`x` * `y`) / `WAD` rounded down. + function wMulDown(uint256 x, uint256 y) internal pure returns (uint256) { + return mulDivDown(x, y, WAD); + } + + /// @dev Returns (`x` * `WAD`) / `y` rounded down. + function wDivDown(uint256 x, uint256 y) internal pure returns (uint256) { + return mulDivDown(x, WAD, y); + } + + /// @dev Returns (`x` * `WAD`) / `y` rounded up. + function wDivUp(uint256 x, uint256 y) internal pure returns (uint256) { + return mulDivUp(x, WAD, y); + } + + /// @dev Returns (`x` * `y`) / `d` rounded down. + function mulDivDown(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) { + return (x * y) / d; + } + + /// @dev Returns (`x` * `y`) / `d` rounded up. + function mulDivUp(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) { + return (x * y + (d - 1)) / d; + } + + /// @dev Returns the sum of the first three non-zero terms of a Taylor expansion of e^(nx) - 1, to approximate a + /// continuous compound interest rate. + function wTaylorCompounded(uint256 x, uint256 n) internal pure returns (uint256) { + uint256 firstTerm = x * n; + uint256 secondTerm = mulDivDown(firstTerm, firstTerm, 2 * WAD); + uint256 thirdTerm = mulDivDown(secondTerm, firstTerm, 3 * WAD); + + return firstTerm + secondTerm + thirdTerm; + } +} diff --git a/test/helpers/Oracle.sol b/test/helpers/Oracle.sol index c500dcc97..1db5ff986 100644 --- a/test/helpers/Oracle.sol +++ b/test/helpers/Oracle.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; contract Oracle { - uint256 public price = 1e18; + uint256 public price = 1e36; function setPrice(uint256 newPrice) external { price = newPrice; From e18bf19a4a74c3208375a9ba9e695538766776d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 13:12:13 +0100 Subject: [PATCH 09/41] refactor: modularize tests --- test/BaseTest.sol | 24 ++++++++++ test/TermsTest.sol | 114 +++------------------------------------------ 2 files changed, 31 insertions(+), 107 deletions(-) create mode 100644 test/BaseTest.sol diff --git a/test/BaseTest.sol b/test/BaseTest.sol new file mode 100644 index 000000000..d34d2cd2c --- /dev/null +++ b/test/BaseTest.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {Test} from "../lib/forge-std/src/Test.sol"; + +import "../src/Terms.sol"; + +abstract contract BaseTest is Test { + Terms internal terms; + + function setUp() public virtual { + terms = new Terms(); + } + + function _signOffer(Offer memory offer, uint256 sk) internal view returns (Signature memory) { + bytes32 hashStruct = keccak256(abi.encode(terms.OFFER_TYPEHASH(), offer)); + bytes32 domainSeparator = keccak256(abi.encode(terms.DOMAIN_TYPEHASH(), block.chainid, address(terms))); + bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, hashStruct)); + + Signature memory sig; + (sig.v, sig.r, sig.s) = vm.sign(sk, digest); + return sig; + } +} diff --git a/test/TermsTest.sol b/test/TermsTest.sol index 8821c687a..2af6d62f3 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; -import {Test, console} from "../lib/forge-std/src/Test.sol"; -import "../src/Terms.sol"; +import "./BaseTest.sol"; +import {console} from "../lib/forge-std/src/Test.sol"; + import {ERC20} from "./helpers/ERC20.sol"; import {Oracle} from "./helpers/Oracle.sol"; -contract TermsTest is Test { - Terms private terms; +contract TermsTest is BaseTest { ERC20 private loanToken; ERC20 private collateralToken; Oracle private oracle; @@ -16,14 +16,15 @@ contract TermsTest is Test { uint256 private lenderSK; address private lender; Term private term; + bytes32 private id; Collateral[] private collaterals; - function setUp() external { + function setUp() public override { + super.setUp(); (borrower, borrowerSK) = makeAddrAndKey("borrower"); (lender, lenderSK) = makeAddrAndKey("lender"); - terms = new Terms(); loanToken = new ERC20("loan", "loan", 1 ether); loanToken.transfer(lender, 99); loanToken.transfer(borrower, 1); @@ -115,105 +116,4 @@ contract TermsTest is Test { assertEq(collateralToken.balanceOf(address(terms)), 0); assertEq(collateralToken.balanceOf(borrower), 1 ether); } - - function genTerm() internal returns (Term memory) { - // instantiate tokens and oracle - ERC20 c1 = new ERC20("collat1", "c1", 1 ether); - ERC20 c2 = new ERC20("collat1", "c2", 1 ether); - Oracle o1 = new Oracle(); - Oracle o2 = new Oracle(); - Collateral[] memory cs = new Collateral[](1); - cs[0] = Collateral({token: address(c1), lltv: 0.75e18, oracle: address(o1)}); - cs[1] = Collateral({token: address(c2), lltv: 1e18, oracle: address(o2)}); - Term memory t = Term(address(loanToken), cs, block.timestamp + 100); - return t; - } - - function genSeizures() internal pure returns (Seizure[] memory) { - Seizure[] memory s = new Seizure[](1); - s[0] = Seizure({collateralIndex: 0, repaidAmount: 43, seizedAssets: 0}); - - return s; - } - - function mintBond(Collateral[] memory cs) internal { - Offer memory lendOffer = Offer({ - buy: true, - offering: lender, - assets: 100, - loanToken: address(loanToken), - collaterals: cs, - maturity: block.timestamp + 100, - price: 99 - }); - Offer memory borrowOffer = Offer({ - buy: false, - offering: borrower, - assets: 100, - loanToken: address(loanToken), - collaterals: cs, - maturity: block.timestamp + 100, - price: 99 - }); - - Signature memory lendSig = _signOffer(lendOffer, lenderSK); - Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); - - terms.MATCH(lendOffer, lendSig, borrowOffer, borrowSig); - } - - function testLiquidation() public { - address liquidator; - uint256 liquidatorSK; - (liquidator, liquidatorSK) = makeAddrAndKey("liquidator"); - - Term memory t = genTerm(); - - deal(t.collaterals[0].token, borrower, 1 ether); - deal(t.collaterals[1].token, borrower, 1 ether); - - vm.startPrank(borrower); - ERC20(t.collaterals[0].token).approve(address(terms), type(uint256).max); - ERC20(t.collaterals[1].token).approve(address(terms), type(uint256).max); - terms.supplyCollateral(t, t.collaterals[0].token, 134, borrower); - terms.supplyCollateral(t, t.collaterals[1].token, 34, borrower); - vm.stopPrank(); - - mintBond(t.collaterals); - - Seizure[] memory s = genSeizures(); - loanToken.transfer(liquidator, 50); - - vm.prank(liquidator); - loanToken.approve(address(terms), type(uint256).max); - - vm.warp(block.timestamp + 50); - Oracle(t.collaterals[0].oracle).setPrice(0.5e18); - - vm.prank(liquidator); - uint256 gasBefore = gasleft(); - terms.liquidate(t, s, borrower, "0x0"); - uint256 gasUsed = gasBefore - gasleft(); - - oracle.setPrice(1e18); - emit log_named_uint("Gas used", gasUsed); - - bytes32 idT = keccak256(abi.encode(t)); - assertEq(terms.debtOf(borrower, idT), 57); - assertEq(terms.withdrawable(idT), 43); - - assertEq(loanToken.balanceOf(address(terms)), 43); - assertEq(loanToken.balanceOf(liquidator), 7); - assertEq(ERC20(t.collaterals[0].token).balanceOf(liquidator), 98); - } - - function _signOffer(Offer memory offer, uint256 sk) internal view returns (Signature memory) { - bytes32 hashStruct = keccak256(abi.encode(terms.OFFER_TYPEHASH(), offer)); - bytes32 domainSeparator = keccak256(abi.encode(terms.DOMAIN_TYPEHASH(), block.chainid, address(terms))); - bytes32 digest = keccak256(bytes.concat("\x19\x01", domainSeparator, hashStruct)); - - Signature memory sig; - (sig.v, sig.r, sig.s) = vm.sign(sk, digest); - return sig; - } } From 37f2ef3c0886259f58038ad2246fe7e93035e0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 13:12:35 +0100 Subject: [PATCH 10/41] refactor: externalize liquidation tests --- test/LiquidationTest.sol | 184 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 test/LiquidationTest.sol diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol new file mode 100644 index 000000000..c062103f8 --- /dev/null +++ b/test/LiquidationTest.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "./BaseTest.sol"; + +import {console} from "../lib/forge-std/src/Test.sol"; +import {ERC20} from "./helpers/ERC20.sol"; +import {Oracle} from "./helpers/Oracle.sol"; + +contract LiquidationTest is BaseTest { + ERC20 private loanToken; + ERC20 private collateralToken; + Oracle private oracle; + uint256 private borrowerSK; + address private borrower; + uint256 private lenderSK; + address private lender; + address liquidator; + uint256 liquidatorSK; + Term[] private liquidationTerms; + Seizure[] private s; + + function genTerm(uint256 n) internal returns (Term memory) { + // instantiate tokens and oracle + vm.stopPrank(); + Collateral[] memory cs = new Collateral[](n); + for (uint256 i = 0; i < n; i++) { + ERC20 c = new ERC20("collat", "c", 1 ether); + Oracle o = new Oracle(); + ERC20(c).transfer(borrower, 1 ether); + cs[i] = Collateral({token: address(c), lltv: 0.75e18, oracle: address(o)}); + vm.startPrank(borrower); + ERC20(c).approve(address(terms), type(uint256).max); + vm.stopPrank(); + } + + Term memory t = Term(address(loanToken), cs, block.timestamp + 100); + + loanToken.transfer(lender, 1000); + + vm.startPrank(borrower); + + uint256 remaining = 840; + uint256 dealt; + for (uint256 i = 1; i < n; i++) { + dealt += remaining / (n - 1); + terms.supplyCollateral(t, cs[i].token, remaining / (n - 1), borrower); + } + terms.supplyCollateral(t, cs[0].token, remaining + 500, borrower); + vm.stopPrank(); + + return t; + } + + function genSeizures() internal { + s[0] = Seizure({collateralIndex: 0, repaidAmount: 100, seizedAssets: 0}); + } + + 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 + }); + Offer memory borrowOffer = Offer({ + buy: false, + offering: borrower, + assets: 1000, + loanToken: address(loanToken), + collaterals: cs, + maturity: block.timestamp + 100, + price: 990 + }); + + Signature memory lendSig = _signOffer(lendOffer, lenderSK); + Signature memory borrowSig = _signOffer(borrowOffer, borrowerSK); + + terms.MATCH(lendOffer, lendSig, borrowOffer, borrowSig); + } + + function setUp() public override { + super.setUp(); + (borrower, borrowerSK) = makeAddrAndKey("borrower"); + (lender, lenderSK) = makeAddrAndKey("lender"); + (liquidator, liquidatorSK) = makeAddrAndKey("liquidator"); + + loanToken = new ERC20("loan", "loan", 1 ether); + loanToken.transfer(lender, 99); + loanToken.transfer(borrower, 1); + collateralToken = new ERC20("collat", "collat", 1 ether); + oracle = new Oracle(); + + vm.prank(lender); + loanToken.approve(address(terms), type(uint256).max); + vm.prank(borrower); + loanToken.approve(address(terms), type(uint256).max); + vm.prank(liquidator); + loanToken.approve(address(terms), type(uint256).max); + vm.stopPrank(); + + liquidationTerms = new Term[](10); + s = new Seizure[](1); + + for (uint256 i = 0; i < 10; i++) { + liquidationTerms[i] = genTerm(i + 1); + mintBond(liquidationTerms[i].collaterals); + } + + genSeizures(); + } + + function runLiquidation(uint256 n) public { + loanToken.transfer(liquidator, 500); + Term memory t = liquidationTerms[n - 1]; + vm.warp(block.timestamp + 50); + Oracle(t.collaterals[0].oracle).setPrice(0.25e36); + + vm.prank(liquidator); + uint256 gasBefore = gasleft(); + terms.liquidate(t, s, borrower, "0x0"); + uint256 gasUsed = gasBefore - gasleft(); + + oracle.setPrice(1e36); + emit log_named_uint("Gas used", gasUsed); + + bytes32 idT = keccak256(abi.encode(t)); + assertEq(terms.debtOf(borrower, idT), 900); + assertEq(terms.withdrawable(idT), 100); + + assertEq(loanToken.balanceOf(address(terms)), 100); + assertEq(loanToken.balanceOf(liquidator), 400); + assertEq(ERC20(t.collaterals[0].token).balanceOf(liquidator), 460); + vm.prank(borrower); + loanToken.transfer(address(0), loanToken.balanceOf(borrower)); + vm.stopPrank(); + vm.prank(liquidator); + loanToken.transfer(address(0), loanToken.balanceOf(liquidator)); + vm.stopPrank(); + } + + function testLiquidation1() public { + runLiquidation(1); + } + + function testLiquidation2() public { + runLiquidation(2); + } + + function testLiquidation3() public { + runLiquidation(3); + } + + function testLiquidation4() public { + runLiquidation(4); + } + + function testLiquidation5() public { + runLiquidation(5); + } + + function testLiquidation6() public { + runLiquidation(6); + } + + function testLiquidation7() public { + runLiquidation(7); + } + + function testLiquidation8() public { + runLiquidation(8); + } + + function testLiquidation9() public { + runLiquidation(9); + } + + function testLiquidation10() public { + runLiquidation(10); + } +} From 93a6785c40c654d7a5f3f17abc372342c99d65f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 16:00:46 +0100 Subject: [PATCH 11/41] fix: check offers --- src/Terms.sol | 2 +- test/LiquidationTest.sol | 64 +++++++++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index d014fbc33..ea881eac4 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -242,7 +242,7 @@ contract Terms is ITerms { // 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) + bytes20(sellOffer.collaterals[j].token) < bytes20(buyOffer.collaterals[i].token) && j++ < buyOffer.collaterals.length ) {} require(sellOffer.collaterals[i].token == buyOffer.collaterals[j].token, "Collaterals tokens do not match"); diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index c062103f8..fc902e31d 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -20,17 +20,43 @@ contract LiquidationTest is BaseTest { Term[] private liquidationTerms; Seizure[] private s; + function sortTokens(ERC20[] memory arr) internal pure returns (ERC20[] memory) { + uint256 length = arr.length; + for (uint256 i = 1; i < length; i++) { + bytes20 key = bytes20((address(arr[i]))); + uint256 j = i - 1; + while ((int256(j) >= 0) && (bytes20(address(arr[j])) > key)) { + arr[j + 1] = arr[j]; + if (j == 0) { + break; + } + j--; + } + arr[j + (bytes20(address(arr[j])) > key ? 0 : 1)] = ERC20(address(key)); + } + return arr; + } + function genTerm(uint256 n) internal returns (Term memory) { - // instantiate tokens and oracle - vm.stopPrank(); Collateral[] memory cs = new Collateral[](n); + ERC20[] memory tokens = new ERC20[](n); + + for (uint256 i = 0; i < n; i++) { + tokens[i] = new ERC20("collat", "c", 1 ether); + } + + tokens = sortTokens(tokens); + for (uint256 i = 0; i < n; i++) { - ERC20 c = new ERC20("collat", "c", 1 ether); + ERC20 c = tokens[i]; + if (i < n - 1) { + require(bytes20(address(tokens[i])) < bytes20(address(tokens[i + 1]))); + } Oracle o = new Oracle(); - ERC20(c).transfer(borrower, 1 ether); - cs[i] = Collateral({token: address(c), lltv: 0.75e18, oracle: address(o)}); + c.transfer(borrower, 1 ether); + cs[i] = Collateral({token: address(tokens[i]), lltv: 0.75e18, oracle: address(o)}); vm.startPrank(borrower); - ERC20(c).approve(address(terms), type(uint256).max); + c.approve(address(terms), type(uint256).max); vm.stopPrank(); } @@ -91,8 +117,6 @@ contract LiquidationTest is BaseTest { loanToken = new ERC20("loan", "loan", 1 ether); loanToken.transfer(lender, 99); loanToken.transfer(borrower, 1); - collateralToken = new ERC20("collat", "collat", 1 ether); - oracle = new Oracle(); vm.prank(lender); loanToken.approve(address(terms), type(uint256).max); @@ -113,7 +137,7 @@ contract LiquidationTest is BaseTest { genSeizures(); } - function runLiquidation(uint256 n) public { + function execLiquidation(uint256 n) public { loanToken.transfer(liquidator, 500); Term memory t = liquidationTerms[n - 1]; vm.warp(block.timestamp + 50); @@ -124,7 +148,7 @@ contract LiquidationTest is BaseTest { terms.liquidate(t, s, borrower, "0x0"); uint256 gasUsed = gasBefore - gasleft(); - oracle.setPrice(1e36); + Oracle(t.collaterals[0].oracle).setPrice(1e36); emit log_named_uint("Gas used", gasUsed); bytes32 idT = keccak256(abi.encode(t)); @@ -143,42 +167,42 @@ contract LiquidationTest is BaseTest { } function testLiquidation1() public { - runLiquidation(1); + execLiquidation(1); } function testLiquidation2() public { - runLiquidation(2); + execLiquidation(2); } function testLiquidation3() public { - runLiquidation(3); + execLiquidation(3); } function testLiquidation4() public { - runLiquidation(4); + execLiquidation(4); } function testLiquidation5() public { - runLiquidation(5); + execLiquidation(5); } function testLiquidation6() public { - runLiquidation(6); + execLiquidation(6); } function testLiquidation7() public { - runLiquidation(7); + execLiquidation(7); } function testLiquidation8() public { - runLiquidation(8); + execLiquidation(8); } function testLiquidation9() public { - runLiquidation(9); + execLiquidation(9); } function testLiquidation10() public { - runLiquidation(10); + execLiquidation(10); } } From ac647cdcaf046842ea9f426ad6276f8f1c40b67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 16:10:35 +0100 Subject: [PATCH 12/41] refactor: minor refacotring --- test/BaseTest.sol | 19 ++++++++++++++- test/LiquidationTest.sol | 51 ++++++++++++---------------------------- test/TermsTest.sol | 1 - 3 files changed, 33 insertions(+), 38 deletions(-) diff --git a/test/BaseTest.sol b/test/BaseTest.sol index d34d2cd2c..b9b886933 100644 --- a/test/BaseTest.sol +++ b/test/BaseTest.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import {Test} from "../lib/forge-std/src/Test.sol"; - +import {ERC20} from "./helpers/ERC20.sol"; import "../src/Terms.sol"; abstract contract BaseTest is Test { @@ -21,4 +21,21 @@ abstract contract BaseTest is Test { (sig.v, sig.r, sig.s) = vm.sign(sk, digest); return sig; } + + function sortTokens(ERC20[] memory arr) internal pure returns (ERC20[] memory) { + uint256 length = arr.length; + for (uint256 i = 1; i < length; i++) { + bytes20 key = bytes20((address(arr[i]))); + uint256 j = i - 1; + while ((int256(j) >= 0) && (bytes20(address(arr[j])) > key)) { + arr[j + 1] = arr[j]; + if (j == 0) { + break; + } + j--; + } + arr[j + (bytes20(address(arr[j])) > key ? 0 : 1)] = ERC20(address(key)); + } + return arr; + } } diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index fc902e31d..ca9cfbc81 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -4,39 +4,19 @@ pragma solidity ^0.8.0; import "./BaseTest.sol"; import {console} from "../lib/forge-std/src/Test.sol"; -import {ERC20} from "./helpers/ERC20.sol"; + import {Oracle} from "./helpers/Oracle.sol"; contract LiquidationTest is BaseTest { ERC20 private loanToken; - ERC20 private collateralToken; - Oracle private oracle; uint256 private borrowerSK; address private borrower; uint256 private lenderSK; address private lender; address liquidator; - uint256 liquidatorSK; Term[] private liquidationTerms; Seizure[] private s; - function sortTokens(ERC20[] memory arr) internal pure returns (ERC20[] memory) { - uint256 length = arr.length; - for (uint256 i = 1; i < length; i++) { - bytes20 key = bytes20((address(arr[i]))); - uint256 j = i - 1; - while ((int256(j) >= 0) && (bytes20(address(arr[j])) > key)) { - arr[j + 1] = arr[j]; - if (j == 0) { - break; - } - j--; - } - arr[j + (bytes20(address(arr[j])) > key ? 0 : 1)] = ERC20(address(key)); - } - return arr; - } - function genTerm(uint256 n) internal returns (Term memory) { Collateral[] memory cs = new Collateral[](n); ERC20[] memory tokens = new ERC20[](n); @@ -49,12 +29,10 @@ contract LiquidationTest is BaseTest { for (uint256 i = 0; i < n; i++) { ERC20 c = tokens[i]; - if (i < n - 1) { - require(bytes20(address(tokens[i])) < bytes20(address(tokens[i + 1]))); - } Oracle o = new Oracle(); c.transfer(borrower, 1 ether); cs[i] = Collateral({token: address(tokens[i]), lltv: 0.75e18, oracle: address(o)}); + vm.startPrank(borrower); c.approve(address(terms), type(uint256).max); vm.stopPrank(); @@ -65,13 +43,13 @@ contract LiquidationTest is BaseTest { loanToken.transfer(lender, 1000); vm.startPrank(borrower); - uint256 remaining = 840; uint256 dealt; for (uint256 i = 1; i < n; i++) { dealt += remaining / (n - 1); terms.supplyCollateral(t, cs[i].token, remaining / (n - 1), borrower); } + // The collateral in position 0 is used to make the position liquidatable. terms.supplyCollateral(t, cs[0].token, remaining + 500, borrower); vm.stopPrank(); @@ -92,6 +70,7 @@ contract LiquidationTest is BaseTest { maturity: block.timestamp + 100, price: 990 }); + Offer memory borrowOffer = Offer({ buy: false, offering: borrower, @@ -112,7 +91,7 @@ contract LiquidationTest is BaseTest { super.setUp(); (borrower, borrowerSK) = makeAddrAndKey("borrower"); (lender, lenderSK) = makeAddrAndKey("lender"); - (liquidator, liquidatorSK) = makeAddrAndKey("liquidator"); + liquidator = makeAddr("liquidator"); loanToken = new ERC20("loan", "loan", 1 ether); loanToken.transfer(lender, 99); @@ -166,43 +145,43 @@ contract LiquidationTest is BaseTest { vm.stopPrank(); } - function testLiquidation1() public { + function testLiquidation1Collat() public { execLiquidation(1); } - function testLiquidation2() public { + function testLiquidation2Collats() public { execLiquidation(2); } - function testLiquidation3() public { + function testLiquidation3Collats() public { execLiquidation(3); } - function testLiquidation4() public { + function testLiquidation4Collats() public { execLiquidation(4); } - function testLiquidation5() public { + function testLiquidation5Collats() public { execLiquidation(5); } - function testLiquidation6() public { + function testLiquidation6Collats() public { execLiquidation(6); } - function testLiquidation7() public { + function testLiquidation7Collats() public { execLiquidation(7); } - function testLiquidation8() public { + function testLiquidation8Collats() public { execLiquidation(8); } - function testLiquidation9() public { + function testLiquidation9Collats() public { execLiquidation(9); } - function testLiquidation10() public { + function testLiquidation10Collats() public { execLiquidation(10); } } diff --git a/test/TermsTest.sol b/test/TermsTest.sol index 2af6d62f3..03b7e6b51 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.0; import "./BaseTest.sol"; import {console} from "../lib/forge-std/src/Test.sol"; -import {ERC20} from "./helpers/ERC20.sol"; import {Oracle} from "./helpers/Oracle.sol"; contract TermsTest is BaseTest { From d4bd40cef2d5b719255d8fd978718c68440ece7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 16:46:10 +0100 Subject: [PATCH 13/41] fix: minor typo --- src/Terms.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.sol b/src/Terms.sol index ea881eac4..7ebb49b37 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -238,7 +238,7 @@ contract Terms is ITerms { require(buyOffer.loanToken == sellOffer.loanToken, "Loan tokens do not match"); for (uint256 i = 0; i < sellOffer.collaterals.length; i++) { uint256 j; - // relies on the fact that the collaterals are sorted. + // 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 ( From 9168aef20986a844661acaed9cdf38d33ee5a45a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 11:15:32 +0100 Subject: [PATCH 14/41] test: measure variable k --- test/LiquidationTest.sol | 91 ++++++++++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index ca9cfbc81..baefca6af 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -15,7 +15,7 @@ contract LiquidationTest is BaseTest { address private lender; address liquidator; Term[] private liquidationTerms; - Seizure[] private s; + Seizure[][] private s; function genTerm(uint256 n) internal returns (Term memory) { Collateral[] memory cs = new Collateral[](n); @@ -56,10 +56,6 @@ contract LiquidationTest is BaseTest { return t; } - function genSeizures() internal { - s[0] = Seizure({collateralIndex: 0, repaidAmount: 100, seizedAssets: 0}); - } - function mintBond(Collateral[] memory cs) internal { Offer memory lendOffer = Offer({ buy: true, @@ -106,36 +102,43 @@ contract LiquidationTest is BaseTest { vm.stopPrank(); liquidationTerms = new Term[](10); - s = new Seizure[](1); + s = new Seizure[][](10); for (uint256 i = 0; i < 10; i++) { liquidationTerms[i] = genTerm(i + 1); mintBond(liquidationTerms[i].collaterals); + s[i] = new Seizure[](i + 1); + s[i][0] = Seizure({collateralIndex: 0, repaidAmount: 100, seizedAssets: 0}); + for (uint256 k = 1; k < i + 1; k++) { + s[i][k] = Seizure({collateralIndex: k, repaidAmount: 0, seizedAssets: 93}); + } } - - genSeizures(); } - function execLiquidation(uint256 n) public { - loanToken.transfer(liquidator, 500); + function execLiquidation(uint256 k, uint256 n) public { + loanToken.transfer(liquidator, 1000); Term memory t = liquidationTerms[n - 1]; vm.warp(block.timestamp + 50); Oracle(t.collaterals[0].oracle).setPrice(0.25e36); vm.prank(liquidator); uint256 gasBefore = gasleft(); - terms.liquidate(t, s, borrower, "0x0"); + if (n < 10) { + terms.liquidate(t, s[0], borrower, "0x0"); + } else { + terms.liquidate(t, s[k - 1], borrower, "0x0"); + } uint256 gasUsed = gasBefore - gasleft(); Oracle(t.collaterals[0].oracle).setPrice(1e36); emit log_named_uint("Gas used", gasUsed); bytes32 idT = keccak256(abi.encode(t)); - assertEq(terms.debtOf(borrower, idT), 900); - assertEq(terms.withdrawable(idT), 100); + // assertEq(terms.debtOf(borrower, idT), 900); + //assertEq(terms.withdrawable(idT), 100); - assertEq(loanToken.balanceOf(address(terms)), 100); - assertEq(loanToken.balanceOf(liquidator), 400); + //assertEq(loanToken.balanceOf(address(terms)), 100); + //assertEq(loanToken.balanceOf(liquidator), 400); assertEq(ERC20(t.collaterals[0].token).balanceOf(liquidator), 460); vm.prank(borrower); loanToken.transfer(address(0), loanToken.balanceOf(borrower)); @@ -146,42 +149,78 @@ contract LiquidationTest is BaseTest { } function testLiquidation1Collat() public { - execLiquidation(1); + execLiquidation(1, 1); } function testLiquidation2Collats() public { - execLiquidation(2); + execLiquidation(1, 2); } function testLiquidation3Collats() public { - execLiquidation(3); + execLiquidation(1, 3); } function testLiquidation4Collats() public { - execLiquidation(4); + execLiquidation(1, 4); } function testLiquidation5Collats() public { - execLiquidation(5); + execLiquidation(1, 5); } function testLiquidation6Collats() public { - execLiquidation(6); + execLiquidation(1, 6); } function testLiquidation7Collats() public { - execLiquidation(7); + execLiquidation(1, 7); } function testLiquidation8Collats() public { - execLiquidation(8); + execLiquidation(1, 8); } function testLiquidation9Collats() public { - execLiquidation(9); + execLiquidation(1, 9); + } + + function testLiquidation10Collats1() public { + execLiquidation(1, 10); + } + + function testLiquidation10Collats2() public { + execLiquidation(2, 10); + } + + function testLiquidation10Collats3() public { + execLiquidation(3, 10); + } + + function testLiquidation10Collats4() public { + execLiquidation(4, 10); + } + + function testLiquidation10Collats5() public { + execLiquidation(5, 10); + } + + function testLiquidation10Collats6() public { + execLiquidation(6, 10); + } + + function testLiquidation10Collats7() public { + execLiquidation(7, 10); + } + + function testLiquidation10Collats8() public { + execLiquidation(8, 10); + } + + function testLiquidation10Collats9() public { + execLiquidation(9, 10); } - function testLiquidation10Collats() public { - execLiquidation(10); + function testLiquidation10Collats10() public { + execLiquidation(10, 10); } } From 7de613b64b15542033b1800e0722de28ad62a66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 11:20:47 +0100 Subject: [PATCH 15/41] refactor: improve test names --- test/LiquidationTest.sol | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index baefca6af..32bf3b7bb 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -148,79 +148,79 @@ contract LiquidationTest is BaseTest { vm.stopPrank(); } - function testLiquidation1Collat() public { + function testLiquidationN1K1() public { execLiquidation(1, 1); } - function testLiquidation2Collats() public { + function testLiquidationN2K1() public { execLiquidation(1, 2); } - function testLiquidation3Collats() public { + function testLiquidationN3K1() public { execLiquidation(1, 3); } - function testLiquidation4Collats() public { + function testLiquidationN4K1() public { execLiquidation(1, 4); } - function testLiquidation5Collats() public { + function testLiquidationN5K1() public { execLiquidation(1, 5); } - function testLiquidation6Collats() public { + function testLiquidationN6K1() public { execLiquidation(1, 6); } - function testLiquidation7Collats() public { + function testLiquidationN7K1() public { execLiquidation(1, 7); } - function testLiquidation8Collats() public { + function testLiquidationN8K1() public { execLiquidation(1, 8); } - function testLiquidation9Collats() public { + function testLiquidationN9K1() public { execLiquidation(1, 9); } - function testLiquidation10Collats1() public { + function testLiquidationN10K1() public { execLiquidation(1, 10); } - function testLiquidation10Collats2() public { + function testLiquidationN10K2() public { execLiquidation(2, 10); } - function testLiquidation10Collats3() public { + function testLiquidationN10K3() public { execLiquidation(3, 10); } - function testLiquidation10Collats4() public { + function testLiquidationN10K4() public { execLiquidation(4, 10); } - function testLiquidation10Collats5() public { + function testLiquidationN10K5() public { execLiquidation(5, 10); } - function testLiquidation10Collats6() public { + function testLiquidationN10K6() public { execLiquidation(6, 10); } - function testLiquidation10Collats7() public { + function testLiquidationN10K7() public { execLiquidation(7, 10); } - function testLiquidation10Collats8() public { + function testLiquidationN10K8() public { execLiquidation(8, 10); } - function testLiquidation10Collats9() public { + function testLiquidationN10K9() public { execLiquidation(9, 10); } - function testLiquidation10Collats10() public { + function testLiquidationN10K10() public { execLiquidation(10, 10); } } From 55fec38257f88d902a4c8ec335c902a59b8dfa8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 14:24:21 +0100 Subject: [PATCH 16/41] fix: bug in check offers --- src/Terms.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 7ebb49b37..298ac8d32 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -236,18 +236,19 @@ contract Terms is ITerms { 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++) { - uint256 j; // 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[j].token) < bytes20(buyOffer.collaterals[i].token) + 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"); + j++; } require(buyOffer.maturity == sellOffer.maturity, "Maturities do not match"); require(buyOffer.price >= sellOffer.price, "Buy offer price is less than sell offer price"); From b7433df21bc0c2854f7a363a6320cb4ae1556dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Mon, 31 Mar 2025 22:14:51 +0200 Subject: [PATCH 17/41] feat: implement bad debt realization --- src/Terms.sol | 88 +++++++++++++++++++++++---------- src/interfaces/ITerms.sol | 5 ++ src/libraries/SharesMathLib.sol | 45 +++++++++++++++++ test/LiquidationTest.sol | 20 ++++---- test/TermsTest.sol | 31 ++++++++++-- 5 files changed, 148 insertions(+), 41 deletions(-) create mode 100644 src/libraries/SharesMathLib.sol diff --git a/src/Terms.sol b/src/Terms.sol index 298ac8d32..ee8301a3f 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -3,12 +3,14 @@ pragma solidity 0.8.28; import "./libraries/Math.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"; contract Terms is ITerms { using MathLib for uint256; + using SharesMathLib for uint256; /// CONSTANTS /// @@ -22,9 +24,10 @@ contract Terms is ITerms { /// STORAGE /// // Terms. - mapping(address => mapping(bytes32 => uint256)) public bondOf; - mapping(address => mapping(bytes32 => uint256)) public debtOf; - mapping(bytes32 => uint256) public withdrawable; + mapping(address => mapping(bytes32 => uint256)) public bondSharesOf; + mapping(address => mapping(bytes32 => uint256)) public debtSharesOf; + mapping(bytes32 => uint256) public withdrawableShares; + mapping(bytes32 => Market) public market; mapping(address => mapping(bytes32 => mapping(address => uint256))) public collateralOf; // Offers. mapping(bytes => uint256) public consumed; @@ -50,16 +53,24 @@ contract Terms is ITerms { Term memory term = Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity); bytes32 id = _id(term); - uint256 repaid = Math.min(debtOf[buyer][id], amount); - debtOf[buyer][id] -= repaid; - bondOf[buyer][id] += amount - repaid; + uint256 amountShares = amount.toSharesDown(market[id].totalAssets, market[id].totalShares); - uint256 withdrawn = Math.min(bondOf[seller][id], amount); - bondOf[seller][id] -= withdrawn; - debtOf[seller][id] += amount - withdrawn; + uint256 repaidShares = Math.min(debtSharesOf[buyer][id], amountShares); + uint256 boughtShares = amountShares - repaidShares; + debtSharesOf[buyer][id] -= repaidShares; + bondSharesOf[buyer][id] += boughtShares; - require(debtOf[buyer][id] == 0 || _isHealthy(term, buyer), "Buyer is unhealthy"); - require(debtOf[seller][id] == 0 || _isHealthy(term, seller), "Seller is unhealthy"); + uint256 withdrawnShares = Math.min(bondSharesOf[seller][id], amountShares); + bondSharesOf[seller][id] -= withdrawnShares; + debtSharesOf[seller][id] += amountShares - withdrawnShares; + + uint256 boughtAmount = + (boughtShares - withdrawnShares).toAssetsDown(market[id].totalAssets, market[id].totalShares); + market[id].totalShares += boughtShares - withdrawnShares; + market[id].totalAssets += boughtAmount; + + require(debtSharesOf[buyer][id] == 0 || _isHealthy(term, buyer), "Buyer is unhealthy"); + require(debtSharesOf[seller][id] == 0 || _isHealthy(term, seller), "Seller is unhealthy"); uint256 sellerScaledPrice = sellOffer.price * amount / sellOffer.assets; uint256 buyerScaledPrice = buyOffer.price * amount / buyOffer.assets; @@ -81,8 +92,13 @@ contract Terms is ITerms { function withdrawBond(Term memory term, uint256 amount, address onBehalf) external { bytes32 id = _id(term); - bondOf[onBehalf][id] -= amount; - withdrawable[id] -= amount; + uint256 shares = amount.toSharesUp(market[id].totalAssets, market[id].totalShares); + + bondSharesOf[onBehalf][id] -= shares; + withdrawableShares[id] -= shares; + + market[id].totalShares -= shares; + market[id].totalAssets -= amount; IERC20(term.loanToken).transfer(msg.sender, amount); } @@ -90,8 +106,9 @@ contract Terms is ITerms { function repayDebt(Term memory term, uint256 amount, address onBehalf) external { bytes32 id = _id(term); - debtOf[onBehalf][id] -= amount; - withdrawable[id] += amount; + uint256 shares = amount.toSharesDown(market[id].totalAssets, market[id].totalShares); + debtSharesOf[onBehalf][id] -= shares; + withdrawableShares[id] += shares; IERC20(term.loanToken).transferFrom(msg.sender, address(this), amount); } @@ -143,8 +160,11 @@ contract Terms is ITerms { maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); } - // Check that position not healthy. - require(debtOf[borrower][id] > maxDebt, "Healthy borrower"); + // Check that position is not healthy. + require( + debtSharesOf[borrower][id].toAssetsUp(market[id].totalAssets, market[id].totalShares) > maxDebt, + "Healthy borrower" + ); // Compute the repaid and seized amounts by collateral index, remaining collateral and total repaid. for (uint256 i = 0; i < seizures.length; i++) { @@ -160,16 +180,20 @@ contract Terms is ITerms { totalCollateralQuoted -= seizedAssetsQuoted; } - debtOf[borrower][id] -= totalRepaid; - withdrawable[id] += totalRepaid; - // Realize bad debt. if (totalCollateralQuoted == 0) { - uint256 badDebt = debtOf[borrower][id]; - withdrawable[id] -= badDebt; - debtOf[borrower][id] = 0; + uint256 debtShares = debtSharesOf[borrower][id]; + market[id].totalAssets -= + (debtShares.toAssetsUp(market[id].totalAssets, market[id].totalShares) - totalRepaid); + debtSharesOf[borrower][id] = 0; } + uint256 repaidShares = totalRepaid.toSharesUp(market[id].totalAssets, market[id].totalShares); + if (totalCollateralQuoted != 0) { + debtSharesOf[borrower][id] -= repaidShares; + } + withdrawableShares[id] += repaidShares; + // Perform the callback. // TODO: simplify with dedicated signature for callback if (data.length > 0) { @@ -183,6 +207,20 @@ contract Terms is ITerms { return seizures; } + function bondOf(address owner, bytes32 id) public view returns (uint256) { + return bondSharesOf[owner][id].toAssetsDown(market[id].totalAssets, market[id].totalShares); + } + + function debtOf(address owner, bytes32 id) public view returns (uint256) { + return debtSharesOf[owner][id].toAssetsDown(market[id].totalAssets, market[id].totalShares); + } + + function withdrawable(bytes32 id) public view returns (uint256) { + return withdrawableShares[id].toAssetsDown(market[id].totalAssets, market[id].totalShares); + } + + /// INTERNAL /// + function _seizeCollateral(Collateral memory c, Seizure memory s, uint256 lif, address liquidator) internal returns (uint256, uint256, uint256) @@ -200,8 +238,6 @@ contract Terms is ITerms { return (s.repaidAmount, s.seizedAssets, seizedAssetsQuoted); } - /// INTERNAL /// - // TODO: move to a dedicated library function exactlyOneZero(uint256 x, uint256 y) internal pure returns (bool z) { assembly { @@ -277,7 +313,7 @@ contract Terms is ITerms { maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); } - return debtOf[borrower][id] <= maxDebt; + return debtSharesOf[borrower][id].toAssetsUp(market[id].totalAssets, market[id].totalShares) <= maxDebt; } } } diff --git a/src/interfaces/ITerms.sol b/src/interfaces/ITerms.sol index 22ab374a4..13488d715 100644 --- a/src/interfaces/ITerms.sol +++ b/src/interfaces/ITerms.sol @@ -24,6 +24,11 @@ struct Offer { uint256 price; } +struct Market { + uint256 totalAssets; + uint256 totalShares; +} + struct Signature { uint8 v; bytes32 r; diff --git a/src/libraries/SharesMathLib.sol b/src/libraries/SharesMathLib.sol new file mode 100644 index 000000000..3ed7115b5 --- /dev/null +++ b/src/libraries/SharesMathLib.sol @@ -0,0 +1,45 @@ +// 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); + } + + /// @dev Calculates the value of `shares` quoted in assets, rounding up. + function toAssetsUp(uint256 shares, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { + return shares.mulDivUp(totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES); + } +} diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index 32bf3b7bb..56a42446b 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -13,7 +13,7 @@ contract LiquidationTest is BaseTest { address private borrower; uint256 private lenderSK; address private lender; - address liquidator; + address private liquidator; Term[] private liquidationTerms; Seizure[][] private s; @@ -32,7 +32,6 @@ contract LiquidationTest is BaseTest { Oracle o = new Oracle(); c.transfer(borrower, 1 ether); cs[i] = Collateral({token: address(tokens[i]), lltv: 0.75e18, oracle: address(o)}); - vm.startPrank(borrower); c.approve(address(terms), type(uint256).max); vm.stopPrank(); @@ -113,32 +112,31 @@ contract LiquidationTest is BaseTest { s[i][k] = Seizure({collateralIndex: k, repaidAmount: 0, seizedAssets: 93}); } } + + vm.warp(block.timestamp + 50); } function execLiquidation(uint256 k, uint256 n) public { loanToken.transfer(liquidator, 1000); Term memory t = liquidationTerms[n - 1]; - vm.warp(block.timestamp + 50); Oracle(t.collaterals[0].oracle).setPrice(0.25e36); vm.prank(liquidator); - uint256 gasBefore = gasleft(); + uint256 gasBefore; + uint256 gasUsed; if (n < 10) { + gasBefore = gasleft(); terms.liquidate(t, s[0], borrower, "0x0"); + gasUsed = gasBefore - gasleft(); } else { + gasBefore = gasleft(); terms.liquidate(t, s[k - 1], borrower, "0x0"); + gasUsed = gasBefore - gasleft(); } - uint256 gasUsed = gasBefore - gasleft(); Oracle(t.collaterals[0].oracle).setPrice(1e36); emit log_named_uint("Gas used", gasUsed); - bytes32 idT = keccak256(abi.encode(t)); - // assertEq(terms.debtOf(borrower, idT), 900); - //assertEq(terms.withdrawable(idT), 100); - - //assertEq(loanToken.balanceOf(address(terms)), 100); - //assertEq(loanToken.balanceOf(liquidator), 400); assertEq(ERC20(t.collaterals[0].token).balanceOf(liquidator), 460); vm.prank(borrower); loanToken.transfer(address(0), loanToken.balanceOf(borrower)); diff --git a/test/TermsTest.sol b/test/TermsTest.sol index 03b7e6b51..d4ddae96c 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -14,15 +14,18 @@ contract TermsTest is BaseTest { address private borrower; uint256 private lenderSK; address private lender; + address private liquidator; Term private term; bytes32 private id; Collateral[] private collaterals; + Seizure[] private seizures; function setUp() public override { super.setUp(); (borrower, borrowerSK) = makeAddrAndKey("borrower"); (lender, lenderSK) = makeAddrAndKey("lender"); + liquidator = makeAddr("liquidator"); loanToken = new ERC20("loan", "loan", 1 ether); loanToken.transfer(lender, 99); @@ -31,7 +34,10 @@ contract TermsTest is BaseTest { oracle = new Oracle(); collaterals = new Collateral[](1); - collaterals[0] = Collateral({token: address(collateralToken), lltv: 1e18, oracle: address(oracle)}); + collaterals[0] = Collateral({token: address(collateralToken), lltv: 0.75e18, oracle: address(oracle)}); + + seizures = new Seizure[](1); + seizures[0] = Seizure({collateralIndex: 0, repaidAmount: 0, seizedAssets: 134}); term = Term(address(loanToken), collaterals, block.timestamp + 100); id = keccak256(abi.encode(term)); @@ -41,7 +47,9 @@ contract TermsTest is BaseTest { vm.prank(borrower); loanToken.approve(address(terms), type(uint256).max); collateralToken.approve(address(terms), type(uint256).max); - terms.supplyCollateral(term, address(collateralToken), 1 ether, borrower); + terms.supplyCollateral(term, address(collateralToken), 134, borrower); + vm.prank(liquidator); + loanToken.approve(address(terms), type(uint256).max); } function testMint() public { @@ -108,11 +116,26 @@ contract TermsTest is BaseTest { testRepay(); vm.prank(borrower); - terms.withdrawCollateral(term, address(collateralToken), 1 ether, borrower); + terms.withdrawCollateral(term, address(collateralToken), 134, borrower); assertEq(terms.collateralOf(borrower, id, address(collateralToken)), 0); assertEq(collateralToken.balanceOf(address(terms)), 0); - assertEq(collateralToken.balanceOf(borrower), 1 ether); + assertEq(collateralToken.balanceOf(borrower), 134); + } + + function testBadDebt() public { + testMint(); + + loanToken.transfer(liquidator, 1000); + Oracle(collaterals[0].oracle).setPrice(0.75e36); + + vm.prank(liquidator); + terms.liquidate(term, seizures, borrower, "0x0"); + assertEq(terms.debtOf(borrower, id), 0); + assertEq(terms.withdrawable(id), 87); + assertEq(terms.bondOf(lender, id), 87); + (uint256 totalAssets,) = terms.market(id); + assertEq(totalAssets, 87); } } From 1a44dd711f714fc265f892a937800d5134c8e2d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20=7C=20Morpho=20=F0=9F=A6=8B?= Date: Mon, 31 Mar 2025 22:38:41 +0200 Subject: [PATCH 18/41] fix: apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Colin | Morpho 🦋 --- src/Terms.sol | 4 ++-- test/TermsTest.sol | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index ee8301a3f..a655d00f3 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -126,10 +126,10 @@ contract Terms is ITerms { IERC20(collateral).transfer(msg.sender, amount); } - /// @notice Execute the given collection `seizures` on the given `term` of the given `borrower`. + /// @notice Execute the given collection of `seizures` on the given `term` of the given `borrower`. /// @dev On each seizure either `repaidAmounts` or `seizedAssets` should be equal to zero. /// @param term The term of the bond. - /// @param seizures An array of amounts of debt to repay or assetd to seize with the index of the collateral in the term collaterals. + /// @param seizures An array of amounts of debt to repay or assets to seize with the index of the collateral in the term's collateral assets. /// @param borrower The debtor of the loan. /// @param data Arbitrary data to pass to the callback. Pass empty data if not needed. /// @return A collection of the actual amounts of debt repaid or asset seized with the collateral index. diff --git a/test/TermsTest.sol b/test/TermsTest.sol index d4ddae96c..9cd679eb6 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.0; import "./BaseTest.sol"; -import {console} from "../lib/forge-std/src/Test.sol"; import {Oracle} from "./helpers/Oracle.sol"; From fe1115915f0bcdb3040b5bc76209923d03f623f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Thu, 3 Apr 2025 13:44:44 +0200 Subject: [PATCH 19/41] refactor: expect a list of N seizures for N collaterals --- src/Terms.sol | 72 ++++++++++++++++++--------------------- src/interfaces/ITerms.sol | 8 ----- 2 files changed, 33 insertions(+), 47 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index a655d00f3..a80a5d659 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -27,7 +27,8 @@ contract Terms is ITerms { mapping(address => mapping(bytes32 => uint256)) public bondSharesOf; mapping(address => mapping(bytes32 => uint256)) public debtSharesOf; mapping(bytes32 => uint256) public withdrawableShares; - mapping(bytes32 => Market) public market; + mapping(bytes32 => uint256) public totalAssets; + mapping(bytes32 => uint256) public totalShares; mapping(address => mapping(bytes32 => mapping(address => uint256))) public collateralOf; // Offers. mapping(bytes => uint256) public consumed; @@ -53,7 +54,7 @@ contract Terms is ITerms { Term memory term = Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity); bytes32 id = _id(term); - uint256 amountShares = amount.toSharesDown(market[id].totalAssets, market[id].totalShares); + uint256 amountShares = amount.toSharesDown(totalAssets[id], totalShares[id]); uint256 repaidShares = Math.min(debtSharesOf[buyer][id], amountShares); uint256 boughtShares = amountShares - repaidShares; @@ -64,10 +65,9 @@ contract Terms is ITerms { bondSharesOf[seller][id] -= withdrawnShares; debtSharesOf[seller][id] += amountShares - withdrawnShares; - uint256 boughtAmount = - (boughtShares - withdrawnShares).toAssetsDown(market[id].totalAssets, market[id].totalShares); - market[id].totalShares += boughtShares - withdrawnShares; - market[id].totalAssets += boughtAmount; + uint256 boughtAmount = (boughtShares - withdrawnShares).toAssetsDown(totalAssets[id], totalShares[id]); + totalShares[id] += boughtShares - withdrawnShares; + totalAssets[id] += boughtAmount; require(debtSharesOf[buyer][id] == 0 || _isHealthy(term, buyer), "Buyer is unhealthy"); require(debtSharesOf[seller][id] == 0 || _isHealthy(term, seller), "Seller is unhealthy"); @@ -92,13 +92,13 @@ contract Terms is ITerms { function withdrawBond(Term memory term, uint256 amount, address onBehalf) external { bytes32 id = _id(term); - uint256 shares = amount.toSharesUp(market[id].totalAssets, market[id].totalShares); + uint256 shares = amount.toSharesUp(totalAssets[id], totalShares[id]); bondSharesOf[onBehalf][id] -= shares; withdrawableShares[id] -= shares; - market[id].totalShares -= shares; - market[id].totalAssets -= amount; + totalShares[id] -= shares; + totalAssets[id] -= amount; IERC20(term.loanToken).transfer(msg.sender, amount); } @@ -106,7 +106,7 @@ contract Terms is ITerms { function repayDebt(Term memory term, uint256 amount, address onBehalf) external { bytes32 id = _id(term); - uint256 shares = amount.toSharesDown(market[id].totalAssets, market[id].totalShares); + uint256 shares = amount.toSharesDown(totalAssets[id], totalShares[id]); debtSharesOf[onBehalf][id] -= shares; withdrawableShares[id] += shares; @@ -137,10 +137,7 @@ contract Terms is ITerms { external returns (Seizure[] memory) { - require( - seizures.length <= term.collaterals.length && seizures.length > 0, - "Cannot seize more assets than the supplied collaterals" - ); + require(seizures.length == term.collaterals.length, "Cannot seize more assets than the supplied collaterals"); bytes32 id = _id(term); @@ -161,34 +158,31 @@ contract Terms is ITerms { } // Check that position is not healthy. - require( - debtSharesOf[borrower][id].toAssetsUp(market[id].totalAssets, market[id].totalShares) > maxDebt, - "Healthy borrower" - ); + require(debtSharesOf[borrower][id].toAssetsUp(totalAssets[id], totalShares[id]) > maxDebt, "Healthy borrower"); // Compute the repaid and seized amounts by collateral index, remaining collateral and total repaid. - for (uint256 i = 0; i < seizures.length; i++) { - require(seizures[i].collateralIndex < term.collaterals.length, "INCONSISTENT_INPUT"); - (uint256 repaidAmount, uint256 seizedAssets, uint256 seizedAssetsQuoted) = _seizeCollateral( - term.collaterals[seizures[i].collateralIndex], seizures[i], liquidationIncentiveFactor, msg.sender - ); - seizures[i].repaidAmount = repaidAmount; - seizures[i].seizedAssets = seizedAssets; - collateralOf[borrower][_id(term)][term.collaterals[seizures[i].collateralIndex].token] -= - seizures[i].seizedAssets; - totalRepaid += seizures[i].repaidAmount; - totalCollateralQuoted -= seizedAssetsQuoted; + for (uint256 i = 0; i < term.collaterals.length; i++) { + uint256 collateralPrice = IOracle(term.collaterals[i].oracle).price(); + if ((seizures[i].repaidAmount + seizures[i].seizedAssets) != 0) { + (uint256 repaidAmount, uint256 seizedAssets, uint256 seizedAssetsQuoted) = _seizeCollateral( + term.collaterals[i].token, collateralPrice, seizures[i], liquidationIncentiveFactor, msg.sender + ); + seizures[i].repaidAmount = repaidAmount; + seizures[i].seizedAssets = seizedAssets; + collateralOf[borrower][_id(term)][term.collaterals[i].token] -= seizures[i].seizedAssets; + totalRepaid += seizures[i].repaidAmount; + totalCollateralQuoted -= seizedAssetsQuoted; + } } // Realize bad debt. if (totalCollateralQuoted == 0) { uint256 debtShares = debtSharesOf[borrower][id]; - market[id].totalAssets -= - (debtShares.toAssetsUp(market[id].totalAssets, market[id].totalShares) - totalRepaid); + totalAssets[id] -= (debtShares.toAssetsUp(totalAssets[id], totalShares[id]) - totalRepaid); debtSharesOf[borrower][id] = 0; } - uint256 repaidShares = totalRepaid.toSharesUp(market[id].totalAssets, market[id].totalShares); + uint256 repaidShares = totalRepaid.toSharesUp(totalAssets[id], totalShares[id]); if (totalCollateralQuoted != 0) { debtSharesOf[borrower][id] -= repaidShares; } @@ -208,25 +202,25 @@ contract Terms is ITerms { } function bondOf(address owner, bytes32 id) public view returns (uint256) { - return bondSharesOf[owner][id].toAssetsDown(market[id].totalAssets, market[id].totalShares); + return bondSharesOf[owner][id].toAssetsDown(totalAssets[id], totalShares[id]); } function debtOf(address owner, bytes32 id) public view returns (uint256) { - return debtSharesOf[owner][id].toAssetsDown(market[id].totalAssets, market[id].totalShares); + return debtSharesOf[owner][id].toAssetsDown(totalAssets[id], totalShares[id]); } function withdrawable(bytes32 id) public view returns (uint256) { - return withdrawableShares[id].toAssetsDown(market[id].totalAssets, market[id].totalShares); + return withdrawableShares[id].toAssetsDown(totalAssets[id], totalShares[id]); } /// INTERNAL /// - function _seizeCollateral(Collateral memory c, Seizure memory s, uint256 lif, address liquidator) + function _seizeCollateral(address token, uint256 collateralPrice, Seizure memory s, uint256 lif, address liquidator) internal returns (uint256, uint256, uint256) { require(exactlyOneZero(s.seizedAssets, s.repaidAmount), "INCONSISTENT_INPUT"); - uint256 collateralPrice = IOracle(c.oracle).price(); + uint256 seizedAssetsQuoted = s.seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); if (s.seizedAssets > 0) { s.repaidAmount = seizedAssetsQuoted.wDivUp(lif); @@ -234,7 +228,7 @@ contract Terms is ITerms { s.seizedAssets = s.repaidAmount.wMulDown(lif).mulDivUp(ORACLE_PRICE_SCALE, collateralPrice); seizedAssetsQuoted = s.seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); } - IERC20(c.token).transfer(liquidator, s.seizedAssets); + IERC20(token).transfer(liquidator, s.seizedAssets); return (s.repaidAmount, s.seizedAssets, seizedAssetsQuoted); } @@ -313,7 +307,7 @@ contract Terms is ITerms { maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); } - return debtSharesOf[borrower][id].toAssetsUp(market[id].totalAssets, market[id].totalShares) <= maxDebt; + return debtSharesOf[borrower][id].toAssetsUp(totalAssets[id], totalShares[id]) <= maxDebt; } } } diff --git a/src/interfaces/ITerms.sol b/src/interfaces/ITerms.sol index 13488d715..9b12845ca 100644 --- a/src/interfaces/ITerms.sol +++ b/src/interfaces/ITerms.sol @@ -24,11 +24,6 @@ struct Offer { uint256 price; } -struct Market { - uint256 totalAssets; - uint256 totalShares; -} - struct Signature { uint8 v; bytes32 r; @@ -36,9 +31,6 @@ struct Signature { } struct Seizure { - // Index in the collateral list of the term. - // TODO use something more robust than indexes. - uint256 collateralIndex; // Amount of loan asset to repay. uint256 repaidAmount; // Amount of collater asset to seize. From cd892c32e4718fb3d98be5226221218e0d4e8da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Thu, 3 Apr 2025 13:49:54 +0200 Subject: [PATCH 20/41] fix: update tests --- test/LiquidationTest.sol | 28 ++++++++++++++++++++-------- test/TermsTest.sol | 8 +++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index 56a42446b..cac2e35b1 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -15,7 +15,8 @@ contract LiquidationTest is BaseTest { address private lender; address private liquidator; Term[] private liquidationTerms; - Seizure[][] private s; + Seizure[][] private sN; + Seizure[][] private sK; function genTerm(uint256 n) internal returns (Term memory) { Collateral[] memory cs = new Collateral[](n); @@ -101,15 +102,26 @@ contract LiquidationTest is BaseTest { vm.stopPrank(); liquidationTerms = new Term[](10); - s = new Seizure[][](10); + sN = new Seizure[][](10); + sK = new Seizure[][](10); for (uint256 i = 0; i < 10; i++) { liquidationTerms[i] = genTerm(i + 1); mintBond(liquidationTerms[i].collaterals); - s[i] = new Seizure[](i + 1); - s[i][0] = Seizure({collateralIndex: 0, repaidAmount: 100, seizedAssets: 0}); + sK[i] = new Seizure[](10); + sK[i][0] = Seizure({repaidAmount: 100, seizedAssets: 0}); for (uint256 k = 1; k < i + 1; k++) { - s[i][k] = Seizure({collateralIndex: k, repaidAmount: 0, seizedAssets: 93}); + sK[i][k] = Seizure({repaidAmount: 0, seizedAssets: 93}); + } + } + + for (uint256 i = 0; i < 10; i++) { + liquidationTerms[i] = genTerm(i + 1); + mintBond(liquidationTerms[i].collaterals); + sN[i] = new Seizure[](i + 1); + sN[i][0] = Seizure({repaidAmount: 100, seizedAssets: 0}); + for (uint256 k = 1; k < i + 1; k++) { + sN[i][k] = Seizure({repaidAmount: 0, seizedAssets: 0}); } } @@ -124,13 +136,13 @@ contract LiquidationTest is BaseTest { vm.prank(liquidator); uint256 gasBefore; uint256 gasUsed; - if (n < 10) { + if (n == 10) { gasBefore = gasleft(); - terms.liquidate(t, s[0], borrower, "0x0"); + terms.liquidate(t, sK[k - 1], borrower, "0x0"); gasUsed = gasBefore - gasleft(); } else { gasBefore = gasleft(); - terms.liquidate(t, s[k - 1], borrower, "0x0"); + terms.liquidate(t, sN[n - 1], borrower, "0x0"); gasUsed = gasBefore - gasleft(); } diff --git a/test/TermsTest.sol b/test/TermsTest.sol index 9cd679eb6..adcf4669a 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -5,6 +5,8 @@ import "./BaseTest.sol"; import {Oracle} from "./helpers/Oracle.sol"; +import "../lib/forge-std/src/console.sol"; + contract TermsTest is BaseTest { ERC20 private loanToken; ERC20 private collateralToken; @@ -36,7 +38,7 @@ contract TermsTest is BaseTest { collaterals[0] = Collateral({token: address(collateralToken), lltv: 0.75e18, oracle: address(oracle)}); seizures = new Seizure[](1); - seizures[0] = Seizure({collateralIndex: 0, repaidAmount: 0, seizedAssets: 134}); + seizures[0] = Seizure({repaidAmount: 0, seizedAssets: 134}); term = Term(address(loanToken), collaterals, block.timestamp + 100); id = keccak256(abi.encode(term)); @@ -131,10 +133,10 @@ contract TermsTest is BaseTest { vm.prank(liquidator); terms.liquidate(term, seizures, borrower, "0x0"); + console.log("withdrawable shares", terms.withdrawableShares(id)); assertEq(terms.debtOf(borrower, id), 0); assertEq(terms.withdrawable(id), 87); assertEq(terms.bondOf(lender, id), 87); - (uint256 totalAssets,) = terms.market(id); - assertEq(totalAssets, 87); + assertEq(terms.totalAssets(id), 87); } } From f967432bf9f2e5a3fe4f11484cb63f760107667e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Thu, 3 Apr 2025 13:52:39 +0200 Subject: [PATCH 21/41] refactor: implement review suggestions --- src/Terms.sol | 62 ++++++++++++++------------------------- src/libraries/MathLib.sol | 15 ---------- 2 files changed, 22 insertions(+), 55 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index a80a5d659..9d709516a 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -75,17 +75,8 @@ contract Terms is ITerms { uint256 sellerScaledPrice = sellOffer.price * amount / sellOffer.assets; uint256 buyerScaledPrice = buyOffer.price * amount / buyOffer.assets; - uint256 rest; - if (sellerScaledPrice < buyerScaledPrice) { - rest = buyerScaledPrice - sellerScaledPrice; - } else { - rest = 0; - } - IERC20(buyOffer.loanToken).transferFrom(buyer, seller, sellerScaledPrice); - if (rest > 0) { - IERC20(buyOffer.loanToken).transferFrom(buyer, msg.sender, rest); - } + IERC20(buyOffer.loanToken).transferFrom(buyer, msg.sender, buyerScaledPrice - sellerScaledPrice); } /// @dev Will revert if there is no withdrawable funds. @@ -146,32 +137,35 @@ contract Terms is ITerms { uint256 totalRepaid; uint256 totalCollateralQuoted; - uint256 maxDebt; - - // Compute the total collateral quoted and borrow capacity. - for (uint256 i = 0; i < term.collaterals.length; i++) { - uint256 price = IOracle(term.collaterals[i].oracle).price(); - uint256 collateralQuoted = - collateralOf[borrower][id][term.collaterals[i].token].mulDivDown(price, ORACLE_PRICE_SCALE); - totalCollateralQuoted += collateralQuoted; - maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); - } // Check that position is not healthy. - require(debtSharesOf[borrower][id].toAssetsUp(totalAssets[id], totalShares[id]) > maxDebt, "Healthy borrower"); + require(!_isHealthy(term, borrower), "Healthy borrower"); // Compute the repaid and seized amounts by collateral index, remaining collateral and total repaid. for (uint256 i = 0; i < term.collaterals.length; i++) { uint256 collateralPrice = IOracle(term.collaterals[i].oracle).price(); + uint256 collateralQuoted = + collateralOf[borrower][id][term.collaterals[i].token].mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); if ((seizures[i].repaidAmount + seizures[i].seizedAssets) != 0) { - (uint256 repaidAmount, uint256 seizedAssets, uint256 seizedAssetsQuoted) = _seizeCollateral( - term.collaterals[i].token, collateralPrice, seizures[i], liquidationIncentiveFactor, msg.sender - ); - seizures[i].repaidAmount = repaidAmount; - seizures[i].seizedAssets = seizedAssets; + require(exactlyOneZero(seizures[i].seizedAssets, seizures[i].repaidAmount), "INCONSISTENT_INPUT"); + + // Perform the seizure + uint256 seizedAssetsQuoted = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); + if (seizures[i].seizedAssets > 0) { + seizures[i].repaidAmount = seizedAssetsQuoted.wDivUp(liquidationIncentiveFactor); + } else { + seizures[i].seizedAssets = seizures[i].repaidAmount.wMulDown(liquidationIncentiveFactor).mulDivUp( + ORACLE_PRICE_SCALE, collateralPrice + ); + seizedAssetsQuoted = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); + } + IERC20(term.collaterals[i].token).transfer(msg.sender, seizures[i].seizedAssets); + collateralOf[borrower][_id(term)][term.collaterals[i].token] -= seizures[i].seizedAssets; totalRepaid += seizures[i].repaidAmount; - totalCollateralQuoted -= seizedAssetsQuoted; + totalCollateralQuoted -= collateralQuoted - seizedAssetsQuoted; + } else { + totalCollateralQuoted += collateralQuoted; } } @@ -218,19 +212,7 @@ contract Terms is ITerms { function _seizeCollateral(address token, uint256 collateralPrice, Seizure memory s, uint256 lif, address liquidator) internal returns (uint256, uint256, uint256) - { - require(exactlyOneZero(s.seizedAssets, s.repaidAmount), "INCONSISTENT_INPUT"); - - uint256 seizedAssetsQuoted = s.seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); - if (s.seizedAssets > 0) { - s.repaidAmount = seizedAssetsQuoted.wDivUp(lif); - } else { - s.seizedAssets = s.repaidAmount.wMulDown(lif).mulDivUp(ORACLE_PRICE_SCALE, collateralPrice); - seizedAssetsQuoted = s.seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); - } - IERC20(token).transfer(liquidator, s.seizedAssets); - return (s.repaidAmount, s.seizedAssets, seizedAssetsQuoted); - } + {} // TODO: move to a dedicated library function exactlyOneZero(uint256 x, uint256 y) internal pure returns (bool z) { diff --git a/src/libraries/MathLib.sol b/src/libraries/MathLib.sol index 653db4f87..60bedfff3 100644 --- a/src/libraries/MathLib.sol +++ b/src/libraries/MathLib.sol @@ -13,11 +13,6 @@ library MathLib { return mulDivDown(x, y, WAD); } - /// @dev Returns (`x` * `WAD`) / `y` rounded down. - function wDivDown(uint256 x, uint256 y) internal pure returns (uint256) { - return mulDivDown(x, WAD, y); - } - /// @dev Returns (`x` * `WAD`) / `y` rounded up. function wDivUp(uint256 x, uint256 y) internal pure returns (uint256) { return mulDivUp(x, WAD, y); @@ -32,14 +27,4 @@ library MathLib { function mulDivUp(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) { return (x * y + (d - 1)) / d; } - - /// @dev Returns the sum of the first three non-zero terms of a Taylor expansion of e^(nx) - 1, to approximate a - /// continuous compound interest rate. - function wTaylorCompounded(uint256 x, uint256 n) internal pure returns (uint256) { - uint256 firstTerm = x * n; - uint256 secondTerm = mulDivDown(firstTerm, firstTerm, 2 * WAD); - uint256 thirdTerm = mulDivDown(secondTerm, firstTerm, 3 * WAD); - - return firstTerm + secondTerm + thirdTerm; - } } From 29d9e7d4faff0556760efd3856d8da7ffa7e8448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Thu, 3 Apr 2025 15:04:01 +0200 Subject: [PATCH 22/41] fix: withdrawable with bad debt --- src/Terms.sol | 26 ++++++++++---------------- test/TermsTest.sol | 3 --- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 9d709516a..7f180e444 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -26,7 +26,7 @@ contract Terms is ITerms { // Terms. mapping(address => mapping(bytes32 => uint256)) public bondSharesOf; mapping(address => mapping(bytes32 => uint256)) public debtSharesOf; - mapping(bytes32 => uint256) public withdrawableShares; + mapping(bytes32 => uint256) public withdrawable; mapping(bytes32 => uint256) public totalAssets; mapping(bytes32 => uint256) public totalShares; mapping(address => mapping(bytes32 => mapping(address => uint256))) public collateralOf; @@ -86,7 +86,7 @@ contract Terms is ITerms { uint256 shares = amount.toSharesUp(totalAssets[id], totalShares[id]); bondSharesOf[onBehalf][id] -= shares; - withdrawableShares[id] -= shares; + withdrawable[id] -= amount; totalShares[id] -= shares; totalAssets[id] -= amount; @@ -99,7 +99,7 @@ contract Terms is ITerms { uint256 shares = amount.toSharesDown(totalAssets[id], totalShares[id]); debtSharesOf[onBehalf][id] -= shares; - withdrawableShares[id] += shares; + withdrawable[id] += amount; IERC20(term.loanToken).transferFrom(msg.sender, address(this), amount); } @@ -163,25 +163,23 @@ contract Terms is ITerms { collateralOf[borrower][_id(term)][term.collaterals[i].token] -= seizures[i].seizedAssets; totalRepaid += seizures[i].repaidAmount; - totalCollateralQuoted -= collateralQuoted - seizedAssetsQuoted; + totalCollateralQuoted += collateralQuoted - seizedAssetsQuoted; } else { totalCollateralQuoted += collateralQuoted; } } + uint256 repaidShares = totalRepaid.toSharesUp(totalAssets[id], totalShares[id]); + debtSharesOf[borrower][id] -= repaidShares; + withdrawable[id] += totalRepaid; + // Realize bad debt. if (totalCollateralQuoted == 0) { - uint256 debtShares = debtSharesOf[borrower][id]; - totalAssets[id] -= (debtShares.toAssetsUp(totalAssets[id], totalShares[id]) - totalRepaid); + uint256 badDebtShares = debtSharesOf[borrower][id]; + totalAssets[id] -= badDebtShares.toAssetsUp(totalAssets[id], totalShares[id]); debtSharesOf[borrower][id] = 0; } - uint256 repaidShares = totalRepaid.toSharesUp(totalAssets[id], totalShares[id]); - if (totalCollateralQuoted != 0) { - debtSharesOf[borrower][id] -= repaidShares; - } - withdrawableShares[id] += repaidShares; - // Perform the callback. // TODO: simplify with dedicated signature for callback if (data.length > 0) { @@ -203,10 +201,6 @@ contract Terms is ITerms { return debtSharesOf[owner][id].toAssetsDown(totalAssets[id], totalShares[id]); } - function withdrawable(bytes32 id) public view returns (uint256) { - return withdrawableShares[id].toAssetsDown(totalAssets[id], totalShares[id]); - } - /// INTERNAL /// function _seizeCollateral(address token, uint256 collateralPrice, Seizure memory s, uint256 lif, address liquidator) diff --git a/test/TermsTest.sol b/test/TermsTest.sol index adcf4669a..b8f25ef40 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -5,8 +5,6 @@ import "./BaseTest.sol"; import {Oracle} from "./helpers/Oracle.sol"; -import "../lib/forge-std/src/console.sol"; - contract TermsTest is BaseTest { ERC20 private loanToken; ERC20 private collateralToken; @@ -133,7 +131,6 @@ contract TermsTest is BaseTest { vm.prank(liquidator); terms.liquidate(term, seizures, borrower, "0x0"); - console.log("withdrawable shares", terms.withdrawableShares(id)); assertEq(terms.debtOf(borrower, id), 0); assertEq(terms.withdrawable(id), 87); assertEq(terms.bondOf(lender, id), 87); From f81640181e223c3db37e86146116736f3d35181c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Thu, 3 Apr 2025 15:17:36 +0200 Subject: [PATCH 23/41] fix: debt accounting --- src/Terms.sol | 49 ++++++++++++++++++------------------------------- 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 7f180e444..37a8a1252 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -25,7 +25,7 @@ contract Terms is ITerms { // Terms. mapping(address => mapping(bytes32 => uint256)) public bondSharesOf; - mapping(address => mapping(bytes32 => uint256)) public debtSharesOf; + mapping(address => mapping(bytes32 => uint256)) public debtOf; mapping(bytes32 => uint256) public withdrawable; mapping(bytes32 => uint256) public totalAssets; mapping(bytes32 => uint256) public totalShares; @@ -54,23 +54,21 @@ contract Terms is ITerms { Term memory term = Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity); bytes32 id = _id(term); - uint256 amountShares = amount.toSharesDown(totalAssets[id], totalShares[id]); + uint256 repaid = Math.min(debtOf[buyer][id], amount); + uint256 bought = amount - repaid; + debtOf[buyer][id] -= repaid; + bondSharesOf[buyer][id] += bought.toSharesDown(totalAssets[id], totalShares[id]); - uint256 repaidShares = Math.min(debtSharesOf[buyer][id], amountShares); - uint256 boughtShares = amountShares - repaidShares; - debtSharesOf[buyer][id] -= repaidShares; - bondSharesOf[buyer][id] += boughtShares; + uint256 withdrawn = Math.min(bondSharesOf[seller][id].toAssetsDown(totalAssets[id], totalShares[id]), amount); + bondSharesOf[seller][id] -= withdrawn; + debtOf[seller][id] += amount - withdrawn; - uint256 withdrawnShares = Math.min(bondSharesOf[seller][id], amountShares); - bondSharesOf[seller][id] -= withdrawnShares; - debtSharesOf[seller][id] += amountShares - withdrawnShares; - - uint256 boughtAmount = (boughtShares - withdrawnShares).toAssetsDown(totalAssets[id], totalShares[id]); - totalShares[id] += boughtShares - withdrawnShares; + uint256 boughtAmount = (bought - withdrawn); + totalShares[id] += boughtAmount.toSharesDown(totalAssets[id], totalShares[id]); totalAssets[id] += boughtAmount; - require(debtSharesOf[buyer][id] == 0 || _isHealthy(term, buyer), "Buyer is unhealthy"); - require(debtSharesOf[seller][id] == 0 || _isHealthy(term, seller), "Seller is unhealthy"); + 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; @@ -97,8 +95,7 @@ contract Terms is ITerms { function repayDebt(Term memory term, uint256 amount, address onBehalf) external { bytes32 id = _id(term); - uint256 shares = amount.toSharesDown(totalAssets[id], totalShares[id]); - debtSharesOf[onBehalf][id] -= shares; + debtOf[onBehalf][id] -= amount; withdrawable[id] += amount; IERC20(term.loanToken).transferFrom(msg.sender, address(this), amount); @@ -169,15 +166,14 @@ contract Terms is ITerms { } } - uint256 repaidShares = totalRepaid.toSharesUp(totalAssets[id], totalShares[id]); - debtSharesOf[borrower][id] -= repaidShares; + debtOf[borrower][id] -= totalRepaid; withdrawable[id] += totalRepaid; // Realize bad debt. if (totalCollateralQuoted == 0) { - uint256 badDebtShares = debtSharesOf[borrower][id]; - totalAssets[id] -= badDebtShares.toAssetsUp(totalAssets[id], totalShares[id]); - debtSharesOf[borrower][id] = 0; + uint256 badDebt = debtOf[borrower][id]; + totalAssets[id] -= badDebt; + debtOf[borrower][id] = 0; } // Perform the callback. @@ -197,17 +193,8 @@ contract Terms is ITerms { return bondSharesOf[owner][id].toAssetsDown(totalAssets[id], totalShares[id]); } - function debtOf(address owner, bytes32 id) public view returns (uint256) { - return debtSharesOf[owner][id].toAssetsDown(totalAssets[id], totalShares[id]); - } - /// INTERNAL /// - function _seizeCollateral(address token, uint256 collateralPrice, Seizure memory s, uint256 lif, address liquidator) - internal - returns (uint256, uint256, uint256) - {} - // TODO: move to a dedicated library function exactlyOneZero(uint256 x, uint256 y) internal pure returns (bool z) { assembly { @@ -283,7 +270,7 @@ contract Terms is ITerms { maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); } - return debtSharesOf[borrower][id].toAssetsUp(totalAssets[id], totalShares[id]) <= maxDebt; + return debtOf[borrower][id] <= maxDebt; } } } From f57cfdca3590f0dba15f7e8977167834e5609e0d Mon Sep 17 00:00:00 2001 From: MathisGD Date: Thu, 3 Apr 2025 18:47:10 +0200 Subject: [PATCH 24/41] fix: various things on liquidations --- src/Terms.sol | 66 ++++++------------- src/interfaces/IMorphoLiquidationCallback.sol | 8 +++ test/LiquidationTest.sol | 4 +- test/TermsTest.sol | 2 +- 4 files changed, 32 insertions(+), 48 deletions(-) create mode 100644 src/interfaces/IMorphoLiquidationCallback.sol diff --git a/src/Terms.sol b/src/Terms.sol index 37a8a1252..052f77fce 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -7,6 +7,7 @@ import {SharesMathLib} from "./libraries/SharesMathLib.sol"; import "./interfaces/IERC20.sol"; import "./interfaces/IOracle.sol"; import "./interfaces/ITerms.sol"; +import "./interfaces/IMorphoLiquidationCallback.sol"; contract Terms is ITerms { using MathLib for uint256; @@ -126,63 +127,45 @@ contract Terms is ITerms { returns (Seizure[] memory) { require(seizures.length == term.collaterals.length, "Cannot seize more assets than the supplied collaterals"); + require(!_isHealthy(term, borrower), "Healthy borrower"); bytes32 id = _id(term); - // Over approximation uint256 liquidationIncentiveFactor = 1.15e18; uint256 totalRepaid; - uint256 totalCollateralQuoted; - - // Check that position is not healthy. - require(!_isHealthy(term, borrower), "Healthy borrower"); - - // Compute the repaid and seized amounts by collateral index, remaining collateral and total repaid. + bool allCollateralsAtZero = true; for (uint256 i = 0; i < term.collaterals.length; i++) { uint256 collateralPrice = IOracle(term.collaterals[i].oracle).price(); - uint256 collateralQuoted = - collateralOf[borrower][id][term.collaterals[i].token].mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); - if ((seizures[i].repaidAmount + seizures[i].seizedAssets) != 0) { - require(exactlyOneZero(seizures[i].seizedAssets, seizures[i].repaidAmount), "INCONSISTENT_INPUT"); - - // Perform the seizure - uint256 seizedAssetsQuoted = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); - if (seizures[i].seizedAssets > 0) { - seizures[i].repaidAmount = seizedAssetsQuoted.wDivUp(liquidationIncentiveFactor); - } else { - seizures[i].seizedAssets = seizures[i].repaidAmount.wMulDown(liquidationIncentiveFactor).mulDivUp( - ORACLE_PRICE_SCALE, collateralPrice - ); - seizedAssetsQuoted = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE); - } - IERC20(term.collaterals[i].token).transfer(msg.sender, seizures[i].seizedAssets); - - collateralOf[borrower][_id(term)][term.collaterals[i].token] -= seizures[i].seizedAssets; - totalRepaid += seizures[i].repaidAmount; - totalCollateralQuoted += collateralQuoted - seizedAssetsQuoted; + + require(seizures[i].repaidAmount * seizures[i].seizedAssets == 0, "INCONSISTENT_INPUT"); + + if (seizures[i].seizedAssets > 0) { + seizures[i].repaidAmount = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE) + .wDivUp(liquidationIncentiveFactor); } else { - totalCollateralQuoted += collateralQuoted; + seizures[i].seizedAssets = seizures[i].repaidAmount.wMulDown(liquidationIncentiveFactor).mulDivUp( + ORACLE_PRICE_SCALE, collateralPrice + ); } + + totalRepaid += seizures[i].repaidAmount; + collateralOf[borrower][id][term.collaterals[i].token] -= seizures[i].seizedAssets; + allCollateralsAtZero = allCollateralsAtZero && collateralOf[borrower][id][term.collaterals[i].token] == 0; + + IERC20(term.collaterals[i].token).transfer(msg.sender, seizures[i].seizedAssets); } debtOf[borrower][id] -= totalRepaid; withdrawable[id] += totalRepaid; // Realize bad debt. - if (totalCollateralQuoted == 0) { - uint256 badDebt = debtOf[borrower][id]; - totalAssets[id] -= badDebt; + if (allCollateralsAtZero) { + totalAssets[id] -= debtOf[borrower][id]; debtOf[borrower][id] = 0; } - // Perform the callback. - // TODO: simplify with dedicated signature for callback - if (data.length > 0) { - bytes memory callbackData = abi.encode(seizures, borrower, msg.sender, data); - (bool success, bytes memory returnData) = msg.sender.call(callbackData); - if (!success) lowLevelRevert(returnData); - } + if (data.length > 0) IMorphoLiquidationCallback(msg.sender).onLiquidate(seizures, borrower, msg.sender, data); IERC20(term.loanToken).transferFrom(msg.sender, address(this), totalRepaid); @@ -195,13 +178,6 @@ contract Terms is ITerms { /// INTERNAL /// - // TODO: move to a dedicated library - function exactlyOneZero(uint256 x, uint256 y) internal pure returns (bool z) { - assembly { - z := xor(iszero(x), iszero(y)) - } - } - function lowLevelRevert(bytes memory returnData) internal pure { assembly ("memory-safe") { revert(add(32, returnData), mload(returnData)) diff --git a/src/interfaces/IMorphoLiquidationCallback.sol b/src/interfaces/IMorphoLiquidationCallback.sol new file mode 100644 index 000000000..b7a24bbda --- /dev/null +++ b/src/interfaces/IMorphoLiquidationCallback.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.28; + +import {Seizure} from "./ITerms.sol"; + +interface IMorphoLiquidationCallback { + function onLiquidate(Seizure[] memory seizures, address borrower, address liquidator, bytes memory data) external; +} diff --git a/test/LiquidationTest.sol b/test/LiquidationTest.sol index cac2e35b1..136bc5b18 100644 --- a/test/LiquidationTest.sol +++ b/test/LiquidationTest.sol @@ -138,11 +138,11 @@ contract LiquidationTest is BaseTest { uint256 gasUsed; if (n == 10) { gasBefore = gasleft(); - terms.liquidate(t, sK[k - 1], borrower, "0x0"); + terms.liquidate(t, sK[k - 1], borrower, hex""); gasUsed = gasBefore - gasleft(); } else { gasBefore = gasleft(); - terms.liquidate(t, sN[n - 1], borrower, "0x0"); + terms.liquidate(t, sN[n - 1], borrower, hex""); gasUsed = gasBefore - gasleft(); } diff --git a/test/TermsTest.sol b/test/TermsTest.sol index b8f25ef40..61c5d8405 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -130,7 +130,7 @@ contract TermsTest is BaseTest { Oracle(collaterals[0].oracle).setPrice(0.75e36); vm.prank(liquidator); - terms.liquidate(term, seizures, borrower, "0x0"); + terms.liquidate(term, seizures, borrower, hex""); assertEq(terms.debtOf(borrower, id), 0); assertEq(terms.withdrawable(id), 87); assertEq(terms.bondOf(lender, id), 87); From 53c397359a403180bcd252441b769b4609ef7796 Mon Sep 17 00:00:00 2001 From: MathisGD Date: Thu, 3 Apr 2025 18:54:22 +0200 Subject: [PATCH 25/41] perf: don't query the oracle price if you don't seize this asset --- src/Terms.sol | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 052f77fce..10c38539a 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -136,24 +136,26 @@ contract Terms is ITerms { uint256 totalRepaid; bool allCollateralsAtZero = true; for (uint256 i = 0; i < term.collaterals.length; i++) { - uint256 collateralPrice = IOracle(term.collaterals[i].oracle).price(); - - require(seizures[i].repaidAmount * seizures[i].seizedAssets == 0, "INCONSISTENT_INPUT"); - - if (seizures[i].seizedAssets > 0) { - seizures[i].repaidAmount = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE) - .wDivUp(liquidationIncentiveFactor); - } else { - seizures[i].seizedAssets = seizures[i].repaidAmount.wMulDown(liquidationIncentiveFactor).mulDivUp( - ORACLE_PRICE_SCALE, collateralPrice - ); + if (seizures[i].seizedAssets + seizures[i].repaidAmount > 0) { + require(seizures[i].repaidAmount * seizures[i].seizedAssets == 0, "INCONSISTENT_INPUT"); + + uint256 collateralPrice = IOracle(term.collaterals[i].oracle).price(); + if (seizures[i].seizedAssets > 0) { + seizures[i].repaidAmount = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE) + .wDivUp(liquidationIncentiveFactor); + } else if (seizures[i].repaidAmount > 0) { + seizures[i].seizedAssets = seizures[i].repaidAmount.wMulDown(liquidationIncentiveFactor).mulDivUp( + ORACLE_PRICE_SCALE, collateralPrice + ); + } + + totalRepaid += seizures[i].repaidAmount; + collateralOf[borrower][id][term.collaterals[i].token] -= seizures[i].seizedAssets; + + IERC20(term.collaterals[i].token).transfer(msg.sender, seizures[i].seizedAssets); } - totalRepaid += seizures[i].repaidAmount; - collateralOf[borrower][id][term.collaterals[i].token] -= seizures[i].seizedAssets; allCollateralsAtZero = allCollateralsAtZero && collateralOf[borrower][id][term.collaterals[i].token] == 0; - - IERC20(term.collaterals[i].token).transfer(msg.sender, seizures[i].seizedAssets); } debtOf[borrower][id] -= totalRepaid; From 5dc481c3733f0296b5a2bea995412f2e08ce9474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20=7C=20Morpho=20=F0=9F=A6=8B?= Date: Mon, 7 Apr 2025 10:02:15 +0200 Subject: [PATCH 26/41] docs: apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: MathisGD <74971347+MathisGD@users.noreply.github.com> Signed-off-by: Colin | Morpho 🦋 --- src/Terms.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.sol b/src/Terms.sol index 10c38539a..6e2cce4b3 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -126,7 +126,7 @@ contract Terms is ITerms { external returns (Seizure[] memory) { - require(seizures.length == term.collaterals.length, "Cannot seize more assets than the supplied collaterals"); + require(seizures.length == term.collaterals.length, "Cannot seize more nor less assets than the bond's collaterals"); require(!_isHealthy(term, borrower), "Healthy borrower"); bytes32 id = _id(term); From 7e2405a650e631945c6943505d151a1e2d16851f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20=7C=20Morpho=20=F0=9F=A6=8B?= Date: Thu, 10 Apr 2025 11:33:30 +0200 Subject: [PATCH 27/41] refacotr: apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: MathisGD <74971347+MathisGD@users.noreply.github.com> Signed-off-by: Colin | Morpho 🦋 --- src/Terms.sol | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 6e2cce4b3..4ddf001b1 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -180,12 +180,6 @@ contract Terms is ITerms { /// INTERNAL /// - function lowLevelRevert(bytes memory returnData) internal pure { - assembly ("memory-safe") { - revert(add(32, returnData), mload(returnData)) - } - } - function _id(Term memory term) public pure returns (bytes32) { return keccak256(abi.encode(term)); } From e6b74602ebff8f7b431453f4ed26e7c749ebfc4b Mon Sep 17 00:00:00 2001 From: MathisGD Date: Thu, 10 Apr 2025 16:14:59 +0200 Subject: [PATCH 28/41] feat: new bad debt realisation --- src/Terms.sol | 43 ++++++++++++++++++++------------------- src/libraries/Math.sol | 4 ++++ src/libraries/MathLib.sol | 5 +++++ 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 6e2cce4b3..6329c4356 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -126,15 +126,30 @@ contract Terms is ITerms { external returns (Seizure[] memory) { - require(seizures.length == term.collaterals.length, "Cannot seize more nor less assets than the bond's collaterals"); - require(!_isHealthy(term, borrower), "Healthy borrower"); + require(seizures.length == term.collaterals.length, "should have all collats"); bytes32 id = _id(term); - // Over approximation uint256 liquidationIncentiveFactor = 1.15e18; + uint256 maxDebt; + uint256 liquidatableDebt; + for (uint256 i = 0; i < term.collaterals.length; i++) { + uint256 price = IOracle(term.collaterals[i].oracle).price(); + uint256 collateralQuoted = + collateralOf[borrower][id][term.collaterals[i].token].mulDivDown(price, ORACLE_PRICE_SCALE); + maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); + liquidatableDebt += collateralQuoted.wDivDown(liquidationIncentiveFactor); + } + require(debtOf[borrower][id] >= maxDebt, "position is healthy"); + + uint256 badDebt; + if (liquidatableDebt < debtOf[borrower][id]) { + badDebt = debtOf[borrower][id] - liquidatableDebt; + debtOf[borrower][id] -= badDebt; + totalAssets[id] -= badDebt; + } + uint256 totalRepaid; - bool allCollateralsAtZero = true; for (uint256 i = 0; i < term.collaterals.length; i++) { if (seizures[i].seizedAssets + seizures[i].repaidAmount > 0) { require(seizures[i].repaidAmount * seizures[i].seizedAssets == 0, "INCONSISTENT_INPUT"); @@ -143,7 +158,7 @@ contract Terms is ITerms { if (seizures[i].seizedAssets > 0) { seizures[i].repaidAmount = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE) .wDivUp(liquidationIncentiveFactor); - } else if (seizures[i].repaidAmount > 0) { + } else { seizures[i].seizedAssets = seizures[i].repaidAmount.wMulDown(liquidationIncentiveFactor).mulDivUp( ORACLE_PRICE_SCALE, collateralPrice ); @@ -154,19 +169,11 @@ contract Terms is ITerms { IERC20(term.collaterals[i].token).transfer(msg.sender, seizures[i].seizedAssets); } - - allCollateralsAtZero = allCollateralsAtZero && collateralOf[borrower][id][term.collaterals[i].token] == 0; } - debtOf[borrower][id] -= totalRepaid; + debtOf[borrower][id] -= totalRepaid; // should it be zero floor sub withdrawable[id] += totalRepaid; - // Realize bad debt. - if (allCollateralsAtZero) { - totalAssets[id] -= debtOf[borrower][id]; - debtOf[borrower][id] = 0; - } - if (data.length > 0) IMorphoLiquidationCallback(msg.sender).onLiquidate(seizures, borrower, msg.sender, data); IERC20(term.loanToken).transferFrom(msg.sender, address(this), totalRepaid); @@ -180,12 +187,6 @@ contract Terms is ITerms { /// INTERNAL /// - function lowLevelRevert(bytes memory returnData) internal pure { - assembly ("memory-safe") { - revert(add(32, returnData), mload(returnData)) - } - } - function _id(Term memory term) public pure returns (bytes32) { return keccak256(abi.encode(term)); } @@ -238,7 +239,7 @@ contract Terms is ITerms { if (term.maturity < block.timestamp) { return false; } else { - bytes32 id = _id(Term(term.loanToken, term.collaterals, term.maturity)); + bytes32 id = _id(term); uint256 maxDebt; for (uint256 i = 0; i < term.collaterals.length; i++) { diff --git a/src/libraries/Math.sol b/src/libraries/Math.sol index 8b25a58e9..378df9bc2 100644 --- a/src/libraries/Math.sol +++ b/src/libraries/Math.sol @@ -5,4 +5,8 @@ library Math { function min(uint256 a, uint256 b) internal pure returns (uint256) { return a < b ? a : b; } + + function exactlyOneZero(uint256 a, uint256 b) internal pure returns (bool) { + return (a == 0 && b != 0) || (a != 0 && b == 0); + } } diff --git a/src/libraries/MathLib.sol b/src/libraries/MathLib.sol index 60bedfff3..0c9b99a66 100644 --- a/src/libraries/MathLib.sol +++ b/src/libraries/MathLib.sol @@ -18,6 +18,11 @@ library MathLib { return mulDivUp(x, WAD, y); } + /// @dev Returns (`x` * `WAD`) / `y` rounded up. + function wDivDown(uint256 x, uint256 y) internal pure returns (uint256) { + return mulDivDown(x, WAD, y); + } + /// @dev Returns (`x` * `y`) / `d` rounded down. function mulDivDown(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) { return (x * y) / d; From 220bc9d0589236eca7ba48bfd375b6011454172c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Thu, 10 Apr 2025 17:01:34 +0200 Subject: [PATCH 29/41] fix: rounding --- src/Terms.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 6329c4356..81dde9c16 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -132,19 +132,19 @@ contract Terms is ITerms { uint256 liquidationIncentiveFactor = 1.15e18; uint256 maxDebt; - uint256 liquidatableDebt; + uint256 repayableDebt; for (uint256 i = 0; i < term.collaterals.length; i++) { uint256 price = IOracle(term.collaterals[i].oracle).price(); uint256 collateralQuoted = collateralOf[borrower][id][term.collaterals[i].token].mulDivDown(price, ORACLE_PRICE_SCALE); maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); - liquidatableDebt += collateralQuoted.wDivDown(liquidationIncentiveFactor); + repayableDebt += collateralQuoted.wDivUp(liquidationIncentiveFactor); } require(debtOf[borrower][id] >= maxDebt, "position is healthy"); uint256 badDebt; - if (liquidatableDebt < debtOf[borrower][id]) { - badDebt = debtOf[borrower][id] - liquidatableDebt; + if (repayableDebt < debtOf[borrower][id]) { + badDebt = debtOf[borrower][id] - repayableDebt; debtOf[borrower][id] -= badDebt; totalAssets[id] -= badDebt; } From ab78e592b3bb8822f6e8eded960266806428a917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Fri, 11 Apr 2025 11:22:35 +0200 Subject: [PATCH 30/41] feat: withdraw with shares --- src/Terms.sol | 33 ++++++++++++++++++++++----------- src/libraries/Math.sol | 12 ------------ src/libraries/SharesMathLib.sol | 5 ----- src/libraries/UtilsLib.sol | 23 +++++++++++++++++++++++ test/TermsTest.sol | 2 +- 5 files changed, 46 insertions(+), 29 deletions(-) delete mode 100644 src/libraries/Math.sol create mode 100644 src/libraries/UtilsLib.sol diff --git a/src/Terms.sol b/src/Terms.sol index 81dde9c16..e49c42ef9 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.28; -import "./libraries/Math.sol"; +import "./libraries/UtilsLib.sol"; import {MathLib, WAD} from "./libraries/MathLib.sol"; import {SharesMathLib} from "./libraries/SharesMathLib.sol"; import "./interfaces/IERC20.sol"; @@ -9,6 +9,8 @@ import "./interfaces/IOracle.sol"; import "./interfaces/ITerms.sol"; import "./interfaces/IMorphoLiquidationCallback.sol"; +import "../lib/forge-std/src/console.sol"; + contract Terms is ITerms { using MathLib for uint256; using SharesMathLib for uint256; @@ -42,7 +44,7 @@ contract Terms is ITerms { { _checkOffers(buyOffer, buySig, sellOffer, sellSig); - uint256 amount = Math.min( + uint256 amount = UtilsLib.min( buyOffer.assets - consumed[abi.encode(buyOffer)], sellOffer.assets - consumed[abi.encode(sellOffer)] ); require(amount > 0, "No assets to match"); @@ -55,12 +57,13 @@ contract Terms is ITerms { Term memory term = Term(sellOffer.loanToken, sellOffer.collaterals, sellOffer.maturity); bytes32 id = _id(term); - uint256 repaid = Math.min(debtOf[buyer][id], amount); + uint256 repaid = UtilsLib.min(debtOf[buyer][id], amount); uint256 bought = amount - repaid; debtOf[buyer][id] -= repaid; bondSharesOf[buyer][id] += bought.toSharesDown(totalAssets[id], totalShares[id]); - uint256 withdrawn = Math.min(bondSharesOf[seller][id].toAssetsDown(totalAssets[id], totalShares[id]), amount); + uint256 withdrawn = + UtilsLib.min(bondSharesOf[seller][id].toAssetsDown(totalAssets[id], totalShares[id]), amount); bondSharesOf[seller][id] -= withdrawn; debtOf[seller][id] += amount - withdrawn; @@ -79,10 +82,12 @@ contract Terms is ITerms { } /// @dev Will revert if there is no withdrawable funds. - function withdrawBond(Term memory term, uint256 amount, address onBehalf) external { + function withdrawBond(Term memory term, uint256 amount, uint256 shares, address onBehalf) external { + require(UtilsLib.exactlyOneZero(amount, shares), "INCONSISTENT_INPUT"); bytes32 id = _id(term); - uint256 shares = amount.toSharesUp(totalAssets[id], totalShares[id]); + if (amount > 0) shares = amount.toSharesUp(totalAssets[id], totalShares[id]); + else amount = shares.toAssetsDown(totalAssets[id], totalShares[id]); bondSharesOf[onBehalf][id] -= shares; withdrawable[id] -= amount; @@ -133,6 +138,7 @@ contract Terms is ITerms { uint256 maxDebt; uint256 repayableDebt; + for (uint256 i = 0; i < term.collaterals.length; i++) { uint256 price = IOracle(term.collaterals[i].oracle).price(); uint256 collateralQuoted = @@ -140,21 +146,26 @@ contract Terms is ITerms { maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); repayableDebt += collateralQuoted.wDivUp(liquidationIncentiveFactor); } + require(debtOf[borrower][id] >= maxDebt, "position is healthy"); - uint256 badDebt; + // Realize bad debt if (repayableDebt < debtOf[borrower][id]) { - badDebt = debtOf[borrower][id] - repayableDebt; + uint256 badDebt = debtOf[borrower][id] - repayableDebt; debtOf[borrower][id] -= badDebt; totalAssets[id] -= badDebt; } uint256 totalRepaid; + for (uint256 i = 0; i < term.collaterals.length; i++) { - if (seizures[i].seizedAssets + seizures[i].repaidAmount > 0) { - require(seizures[i].repaidAmount * seizures[i].seizedAssets == 0, "INCONSISTENT_INPUT"); + if (seizures[i].repaidAmount + seizures[i].seizedAssets > 0) { + require( + UtilsLib.exactlyOneZero(seizures[i].repaidAmount, seizures[i].seizedAssets), "INCONSISTENT_INPUT" + ); uint256 collateralPrice = IOracle(term.collaterals[i].oracle).price(); + if (seizures[i].seizedAssets > 0) { seizures[i].repaidAmount = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE) .wDivUp(liquidationIncentiveFactor); @@ -171,7 +182,7 @@ contract Terms is ITerms { } } - debtOf[borrower][id] -= totalRepaid; // should it be zero floor sub + debtOf[borrower][id] -= totalRepaid; withdrawable[id] += totalRepaid; if (data.length > 0) IMorphoLiquidationCallback(msg.sender).onLiquidate(seizures, borrower, msg.sender, data); diff --git a/src/libraries/Math.sol b/src/libraries/Math.sol deleted file mode 100644 index 378df9bc2..000000000 --- a/src/libraries/Math.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.0; - -library Math { - function min(uint256 a, uint256 b) internal pure returns (uint256) { - return a < b ? a : b; - } - - function exactlyOneZero(uint256 a, uint256 b) internal pure returns (bool) { - return (a == 0 && b != 0) || (a != 0 && b == 0); - } -} diff --git a/src/libraries/SharesMathLib.sol b/src/libraries/SharesMathLib.sol index 3ed7115b5..7e252db2f 100644 --- a/src/libraries/SharesMathLib.sol +++ b/src/libraries/SharesMathLib.sol @@ -37,9 +37,4 @@ library SharesMathLib { function toSharesUp(uint256 assets, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { return assets.mulDivUp(totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS); } - - /// @dev Calculates the value of `shares` quoted in assets, rounding up. - function toAssetsUp(uint256 shares, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { - return shares.mulDivUp(totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES); - } } diff --git a/src/libraries/UtilsLib.sol b/src/libraries/UtilsLib.sol new file mode 100644 index 000000000..57f43ae6d --- /dev/null +++ b/src/libraries/UtilsLib.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +/// @title UtilsLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Library exposing helpers. +/// @dev Inspired by https://github.com/morpho-org/morpho-utils. +library UtilsLib { + /// @dev Returns true if there is exactly one zero among `x` and `y`. + function exactlyOneZero(uint256 x, uint256 y) internal pure returns (bool z) { + assembly { + z := xor(iszero(x), iszero(y)) + } + } + + /// @dev Returns the min of `x` and `y`. + function min(uint256 x, uint256 y) internal pure returns (uint256 z) { + assembly { + z := xor(x, mul(xor(x, y), lt(y, x))) + } + } +} diff --git a/test/TermsTest.sol b/test/TermsTest.sol index 61c5d8405..715fd9559 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -102,7 +102,7 @@ contract TermsTest is BaseTest { testRepay(); vm.prank(lender); - terms.withdrawBond(term, 100, lender); + terms.withdrawBond(term, 100, 0, lender); assertEq(terms.bondOf(lender, id), 0); assertEq(terms.withdrawable(id), 0); From b5b6cbffb3330246c4c1316c606a867f93a2381d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20=7C=20Morpho=20=F0=9F=A6=8B?= Date: Wed, 16 Apr 2025 11:18:19 +0200 Subject: [PATCH 31/41] fix: apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Quentin Garchery Signed-off-by: Colin | Morpho 🦋 --- src/Terms.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index e49c42ef9..6c83ef60b 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -9,8 +9,6 @@ import "./interfaces/IOracle.sol"; import "./interfaces/ITerms.sol"; import "./interfaces/IMorphoLiquidationCallback.sol"; -import "../lib/forge-std/src/console.sol"; - contract Terms is ITerms { using MathLib for uint256; using SharesMathLib for uint256; @@ -64,7 +62,7 @@ contract Terms is ITerms { uint256 withdrawn = UtilsLib.min(bondSharesOf[seller][id].toAssetsDown(totalAssets[id], totalShares[id]), amount); - bondSharesOf[seller][id] -= withdrawn; + bondSharesOf[seller][id] -= withdrawn.toSharesUp(totalAssets[id], totalShares[id]); debtOf[seller][id] += amount - withdrawn; uint256 boughtAmount = (bought - withdrawn); From d9ac321ddccabb1a8c51e7863163b5bbf8b1e0df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Fri, 18 Apr 2025 09:53:54 +0200 Subject: [PATCH 32/41] fix: exact computation to update assets and shares --- src/Terms.sol | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 6c83ef60b..743ef8032 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -65,9 +65,12 @@ contract Terms is ITerms { bondSharesOf[seller][id] -= withdrawn.toSharesUp(totalAssets[id], totalShares[id]); debtOf[seller][id] += amount - withdrawn; - uint256 boughtAmount = (bought - withdrawn); - totalShares[id] += boughtAmount.toSharesDown(totalAssets[id], totalShares[id]); - totalAssets[id] += boughtAmount; + uint256 boughtShares = bought.toSharesDown(totalAssets[id], totalShares[id]); + uint256 withdrawnShares = withdrawn.toSharesDown(totalAssets[id], totalShares[id]); + totalShares[id] += boughtShares; + totalShares[id] -= withdrawnShares; + totalAssets[id] += bought; + totalAssets[id] -= withdrawn; require(debtOf[buyer][id] == 0 || _isHealthy(term, buyer), "Buyer is unhealthy"); require(debtOf[seller][id] == 0 || _isHealthy(term, seller), "Seller is unhealthy"); From 925feff66c7957b4a32dd0c1fe76b8b0bd419726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Fri, 18 Apr 2025 11:16:32 +0200 Subject: [PATCH 33/41] fix: shares rounding --- src/Terms.sol | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index 743ef8032..e0aea751d 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -57,16 +57,24 @@ contract Terms is ITerms { uint256 repaid = UtilsLib.min(debtOf[buyer][id], amount); uint256 bought = amount - repaid; + uint256 boughtShares = bought.toSharesDown(totalAssets[id], totalShares[id]); debtOf[buyer][id] -= repaid; - bondSharesOf[buyer][id] += bought.toSharesDown(totalAssets[id], totalShares[id]); + 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]); + } - uint256 withdrawn = - UtilsLib.min(bondSharesOf[seller][id].toAssetsDown(totalAssets[id], totalShares[id]), amount); - bondSharesOf[seller][id] -= withdrawn.toSharesUp(totalAssets[id], totalShares[id]); + bondSharesOf[seller][id] -= withdrawnShares; debtOf[seller][id] += amount - withdrawn; - uint256 boughtShares = bought.toSharesDown(totalAssets[id], totalShares[id]); - uint256 withdrawnShares = withdrawn.toSharesDown(totalAssets[id], totalShares[id]); totalShares[id] += boughtShares; totalShares[id] -= withdrawnShares; totalAssets[id] += bought; From f440d9180ff1ce2df1aad48c7990dfa6256f92a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Wed, 21 May 2025 14:13:13 +0200 Subject: [PATCH 34/41] fix: liquidation roundings --- src/Terms.sol | 22 +++++++++++++--------- test/TermsTest.sol | 3 ++- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Terms.sol b/src/Terms.sol index e0aea751d..6814481d2 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -155,16 +155,8 @@ contract Terms is ITerms { maxDebt += collateralQuoted.wMulDown(term.collaterals[i].lltv); repayableDebt += collateralQuoted.wDivUp(liquidationIncentiveFactor); } - require(debtOf[borrower][id] >= maxDebt, "position is healthy"); - // Realize bad debt - if (repayableDebt < debtOf[borrower][id]) { - uint256 badDebt = debtOf[borrower][id] - repayableDebt; - debtOf[borrower][id] -= badDebt; - totalAssets[id] -= badDebt; - } - uint256 totalRepaid; for (uint256 i = 0; i < term.collaterals.length; i++) { @@ -179,19 +171,31 @@ contract Terms is ITerms { seizures[i].repaidAmount = seizures[i].seizedAssets.mulDivDown(collateralPrice, ORACLE_PRICE_SCALE) .wDivUp(liquidationIncentiveFactor); } else { - seizures[i].seizedAssets = seizures[i].repaidAmount.wMulDown(liquidationIncentiveFactor).mulDivUp( + seizures[i].seizedAssets = seizures[i].repaidAmount.wMulDown(liquidationIncentiveFactor).mulDivDown( ORACLE_PRICE_SCALE, collateralPrice ); } totalRepaid += seizures[i].repaidAmount; collateralOf[borrower][id][term.collaterals[i].token] -= seizures[i].seizedAssets; + console.log("remaining collat", collateralOf[borrower][id][term.collaterals[i].token]); IERC20(term.collaterals[i].token).transfer(msg.sender, seizures[i].seizedAssets); } } + uint256 originalDebt = debtOf[borrower][id]; debtOf[borrower][id] -= totalRepaid; + + // Realize bad debt + if (repayableDebt < originalDebt) { + // Because roundings are not aligned the effective bad debt is either the remaining debt or the original debt minus the theoretical repayable debt. + uint256 badDebt = UtilsLib.min(debtOf[borrower][id], originalDebt - repayableDebt); + debtOf[borrower][id] -= badDebt; + totalAssets[id] -= badDebt; + console.log("debt after bad debt realization ", debtOf[borrower][id]); + } + withdrawable[id] += totalRepaid; if (data.length > 0) IMorphoLiquidationCallback(msg.sender).onLiquidate(seizures, borrower, msg.sender, data); diff --git a/test/TermsTest.sol b/test/TermsTest.sol index 715fd9559..d2f6d4c65 100644 --- a/test/TermsTest.sol +++ b/test/TermsTest.sol @@ -130,8 +130,9 @@ contract TermsTest is BaseTest { Oracle(collaterals[0].oracle).setPrice(0.75e36); vm.prank(liquidator); - terms.liquidate(term, seizures, borrower, hex""); + Seizure[] memory ret = terms.liquidate(term, seizures, borrower, hex""); assertEq(terms.debtOf(borrower, id), 0); + assertEq(ret[0].repaidAmount, 87); assertEq(terms.withdrawable(id), 87); assertEq(terms.bondOf(lender, id), 87); assertEq(terms.totalAssets(id), 87); From cf26a4553144099d935385bcf8bcd532df14d0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Wed, 21 May 2025 14:21:10 +0200 Subject: [PATCH 35/41] fix: remove console log --- src/Terms.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Terms.sol b/src/Terms.sol index 6814481d2..877e8644c 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -178,7 +178,6 @@ contract Terms is ITerms { totalRepaid += seizures[i].repaidAmount; collateralOf[borrower][id][term.collaterals[i].token] -= seizures[i].seizedAssets; - console.log("remaining collat", collateralOf[borrower][id][term.collaterals[i].token]); IERC20(term.collaterals[i].token).transfer(msg.sender, seizures[i].seizedAssets); } From ad980f33ac4b31dc5857977369d57f81208fde60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Colin=20Gonz=C3=A1lez?= Date: Wed, 21 May 2025 14:21:43 +0200 Subject: [PATCH 36/41] fix: remove console log --- src/Terms.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Terms.sol b/src/Terms.sol index 877e8644c..d1affdbd6 100644 --- a/src/Terms.sol +++ b/src/Terms.sol @@ -192,7 +192,6 @@ contract Terms is ITerms { uint256 badDebt = UtilsLib.min(debtOf[borrower][id], originalDebt - repayableDebt); debtOf[borrower][id] -= badDebt; totalAssets[id] -= badDebt; - console.log("debt after bad debt realization ", debtOf[borrower][id]); } withdrawable[id] += totalRepaid; From a7f36f34b5715b5223004d76557741bda7f1efab Mon Sep 17 00:00:00 2001 From: MathisGD Date: Mon, 30 Jun 2025 10:41:10 +0200 Subject: [PATCH 37/41] fix verif --- certora/Terms.conf | 4 +++- certora/Terms.spec | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/certora/Terms.conf b/certora/Terms.conf index fe2570914..672d93437 100644 --- a/certora/Terms.conf +++ b/certora/Terms.conf @@ -10,5 +10,7 @@ "loop_iter": "2", "optimistic_loop": true, "optimistic_hashing": true, - "msg": "Terms" + "solc_via_ir": true, + "hashing_length_bound": "320", + "msg": "Terms" } diff --git a/certora/Terms.spec b/certora/Terms.spec index 003aa1ab3..446a6ee20 100644 --- a/certora/Terms.spec +++ b/certora/Terms.spec @@ -14,14 +14,14 @@ methods { /// HOOKS /// -persistent ghost mapping(bytes32 => mathint) sumBondOf { - init_state axiom (forall bytes32 id. sumBondOf[id] == 0); +persistent ghost mapping(bytes32 => mathint) sumBondSharesOf { + init_state axiom (forall bytes32 id. sumBondSharesOf[id] == 0); } -hook Sload uint256 bondOfOwner bondOf[KEY address owner][KEY bytes32 id] { - require sumBondOf[id] >= to_mathint(bondOfOwner); +hook Sload uint256 bondSharesOfOwner bondSharesOf[KEY address owner][KEY bytes32 id] { + require sumBondSharesOf[id] >= to_mathint(bondSharesOfOwner); } -hook Sstore bondOf[KEY address owner][KEY bytes32 id] uint256 newBond (uint256 oldBond) { - sumBondOf[id] = sumBondOf[id] - oldBond + newBond; +hook Sstore bondSharesOf[KEY address owner][KEY bytes32 id] uint256 newBondShares (uint256 oldBondShares) { + sumBondSharesOf[id] = sumBondSharesOf[id] - oldBondShares + newBondShares; } persistent ghost mapping(bytes32 => mathint) sumDebtOf { @@ -36,8 +36,8 @@ hook Sstore debtOf[KEY address owner][KEY bytes32 id] uint256 newDebt (uint256 o /// SANITY /// -invariant sanitySumBond(bytes32 id) - sumBondOf[id] >= 0; +invariant sanitySumBondShares(bytes32 id) + sumBondSharesOf[id] >= 0; invariant sanitySumDebt(bytes32 id) sumDebtOf[id] >= 0; @@ -49,8 +49,8 @@ rule satisfyMatch(env e, calldataarg args) { /// INVARIANTS /// -strong invariant sums(bytes32 id) - sumBondOf[id] == sumDebtOf[id] + withdrawable(id); +// strong invariant sums(bytes32 id) +// sumBondOf[id] == sumDebtOf[id] + withdrawable(id); // invariant balances(TermsHelpers.Term term) // balanceOf(term.loanToken, currentContract) >= withdrawable(id(term)); From 5dfa1d52152a910316e339b05162de4e66a741c7 Mon Sep 17 00:00:00 2001 From: MathisGD Date: Mon, 30 Jun 2025 10:51:59 +0200 Subject: [PATCH 38/41] ci --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..9893c01b0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + foundry: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + - name: Check formatting + run: forge fmt --check + - name: Run Forge tests + run: forge test + + certora: + runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Submit verification jobs + uses: Certora/certora-run-action@v1 + with: + configurations: |- + certora/Terms.conf + job-name: "Terms spec" + certora-key: ${{ secrets.CERTORAKEY }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 56583fc4efcd726c87bf4cefb5cab07ef328aaf0 Mon Sep 17 00:00:00 2001 From: MathisGD Date: Mon, 30 Jun 2025 10:53:34 +0200 Subject: [PATCH 39/41] ci fixes --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9893c01b0..6c0ab1897 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: ci on: push: + branches: + - main pull_request: workflow_dispatch: @@ -32,6 +34,7 @@ jobs: with: configurations: |- certora/Terms.conf + solc-versions: 0.8.28 job-name: "Terms spec" certora-key: ${{ secrets.CERTORAKEY }} env: From 6160d5bf0c4232354433efc9d4c99bbf8023665c Mon Sep 17 00:00:00 2001 From: MathisGD Date: Mon, 30 Jun 2025 16:46:25 +0200 Subject: [PATCH 40/41] ci --- .github/workflows/certora.yml | 32 +++++++++++++++++++++++++++ .github/workflows/ci.yml | 41 ----------------------------------- .github/workflows/forge.yml | 21 ++++++++++++++++++ 3 files changed, 53 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/certora.yml delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/forge.yml diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml new file mode 100644 index 000000000..8107ea1c7 --- /dev/null +++ b/.github/workflows/certora.yml @@ -0,0 +1,32 @@ +name: certora + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + verify: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + conf: + - Terms + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - name: Install certora + run: pip install certora-cli + - name: Install solc + run: | + wget https://github.com/ethereum/solidity/releases/download/v0.8.28/solc-static-linux + chmod +x solc-static-linux + sudo mv solc-static-linux /usr/local/bin/solc-0.8.28 + - name: Verify ${{ matrix.conf }} + run: certoraRun certora/confs/${{ matrix.conf }}.conf + env: + CERTORAKEY: ${{ secrets.CERTORAKEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 6c0ab1897..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: ci - -on: - push: - branches: - - main - pull_request: - workflow_dispatch: - -jobs: - foundry: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - name: Check formatting - run: forge fmt --check - - name: Run Forge tests - run: forge test - - certora: - runs-on: ubuntu-latest - permissions: - contents: read - statuses: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Submit verification jobs - uses: Certora/certora-run-action@v1 - with: - configurations: |- - certora/Terms.conf - solc-versions: 0.8.28 - job-name: "Terms spec" - certora-key: ${{ secrets.CERTORAKEY }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/forge.yml b/.github/workflows/forge.yml new file mode 100644 index 000000000..487799b5a --- /dev/null +++ b/.github/workflows/forge.yml @@ -0,0 +1,21 @@ +name: forge + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + foundry: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + - name: Check formatting + run: forge fmt --check + - name: Run Forge tests + run: forge test From 0ac3818e88d44e3501afa834c9e12179afa9fba1 Mon Sep 17 00:00:00 2001 From: MathisGD Date: Mon, 30 Jun 2025 16:47:20 +0200 Subject: [PATCH 41/41] ci fix --- .github/workflows/certora.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index 8107ea1c7..07be11780 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -27,6 +27,6 @@ jobs: chmod +x solc-static-linux sudo mv solc-static-linux /usr/local/bin/solc-0.8.28 - name: Verify ${{ matrix.conf }} - run: certoraRun certora/confs/${{ matrix.conf }}.conf + run: certoraRun certora/${{ matrix.conf }}.conf env: CERTORAKEY: ${{ secrets.CERTORAKEY }}