From 30f50a69805bb7faf1c8f11fd3c066b53c66a94e Mon Sep 17 00:00:00 2001 From: 08xmt Date: Thu, 3 Jul 2025 10:37:47 +0200 Subject: [PATCH 1/2] Add simplified PrivateBorrowController and tests --- src/PrivateBorrowController.sol | 136 ++++++++++ test/PrivateBorrowController.t.sol | 393 +++++++++++++++++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 src/PrivateBorrowController.sol create mode 100644 test/PrivateBorrowController.t.sol diff --git a/src/PrivateBorrowController.sol b/src/PrivateBorrowController.sol new file mode 100644 index 0000000..318e1cd --- /dev/null +++ b/src/PrivateBorrowController.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; +import {DolaBorrowingRights} from "src/DBR.sol"; + +interface IChainlinkFeed { + function decimals() external view returns (uint8); + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80); +} + +interface IOracle { + function feeds(address token) external view returns(IChainlinkFeed, uint8); +} + +interface IMarket { + function collateral() external view returns(address); + function oracle() external view returns(IOracle); + function debts(address) external view returns(uint); +} + +/** + * @title Borrow Controller + * @notice Contract for limiting the contracts that are allowed to interact with markets + */ +contract PrivateBorrowController { + + address public operator; + DolaBorrowingRights public immutable DBR; + mapping(address => uint) public minDebts; + mapping(address => uint) public stalenessThreshold; + mapping(address => mapping(address => bool)) public allowedBorrowers; + + constructor(address _operator, address _DBR) { + operator = _operator; + DBR = DolaBorrowingRights(_DBR); + } + + modifier onlyOperator { + require(msg.sender == operator, "Only operator"); + _; + } + + /** + * @notice Sets the operator of the borrow controller. Only callable by the operator. + * @param _operator The address of the new operator. + */ + function setOperator(address _operator) public onlyOperator { operator = _operator; } + + /** + * @notice Allows a borrower to interact with a private FiRM market + * @param market The private FiRM market + * @param allowedBorrower The borrower to be allowed for the market + * @param isAllowed whether or not the borrower is allowed to borrow from market + */ + function allowBorrower(address market, address allowedBorrower, bool isAllowed) public onlyOperator { allowedBorrowers[market][allowedBorrower] = isAllowed; } + + /** + * @notice Sets the staleness threshold for Chainlink feeds + * @param newStalenessThreshold The new staleness threshold denominated in seconds + * @dev Only callable by operator + */ + function setStalenessThreshold(address market, uint newStalenessThreshold) public onlyOperator { stalenessThreshold[market] = newStalenessThreshold; } + + /** + * @notice sets the market specific minimum amount a debt a borrower needs to take on. + * @param market The market to set the minimum debt for. + * @param newMinDebt The new minimum amount of debt. + * @dev This is to mitigate the creation of positions which are uneconomical to liquidate. Only callable by operator. + */ + function setMinDebt(address market, uint newMinDebt) public onlyOperator {minDebts[market] = newMinDebt; } + + /** + * @notice Checks if a borrow is allowed + * @dev Currently the borrowController checks if contracts are part of an allow list and enforces a daily limit + * @param msgSender The message sender trying to borrow + * @param borrower The address being borrowed on behalf of + * @param amount The amount to be borrowed + * @return A boolean that is true if borrowing is allowed and false if not. + */ + function borrowAllowed(address msgSender, address borrower, uint amount) public returns (bool) { + uint lastUpdated = DBR.lastUpdated(borrower); + uint debts = DBR.debts(borrower); + //Check to prevent effects of edge case bug + uint timeElapsed = block.timestamp - lastUpdated; + if(lastUpdated > 0 && debts * timeElapsed < 365 days && lastUpdated != block.timestamp){ + //Important check, otherwise a user could repeatedly mint themsevles DBR + require(DBR.markets(msg.sender), "Message sender is not a market"); + uint deficit = (block.timestamp - lastUpdated) * amount / 365 days; + //If the contract is not a DBR minter, it should disallow borrowing for edgecase users + if(!DBR.minters(address(this))) return false; + //Mint user deficit caused by edge case bug + DBR.mint(borrower, deficit); + } + //If the debt is below the minimum debt threshold, deny borrow + if(isBelowMinDebt(msg.sender, borrower, amount)) return false; + //If the chainlink oracle price feed is stale, deny borrow + if(isPriceStale(msg.sender)) return false; + //Check if borrower is an allowed borrower + return allowedBorrowers[msg.sender][msgSender]; + } + + /** + * @notice Unused function + * @param amount Amount repaid in the market + */ + function onRepay(uint amount) public { + } + + /** + * @notice Checks if the price for the given market is stale. + * @param market The address of the market for which the price staleness is to be checked. + * @return bool Returns true if the price is stale, false otherwise. + */ + function isPriceStale(address market) public view returns(bool){ + uint marketStalenessThreshold = stalenessThreshold[market]; + if(marketStalenessThreshold == 0) return false; + IOracle oracle = IMarket(market).oracle(); + (IChainlinkFeed feed,) = oracle.feeds(IMarket(market).collateral()); + (,,,uint updatedAt,) = feed.latestRoundData(); + return block.timestamp - updatedAt > marketStalenessThreshold; + } + + /** + * @notice Checks if the borrower's debt in the given market is below the minimum debt after adding the specified amount. + * @param market The address of the market for which the borrower's debt is to be checked. + * @param borrower The address of the borrower whose debt is to be checked. + * @param amount The amount to be added to the borrower's current debt before checking against the minimum debt. + * @return bool Returns true if the borrower's debt after adding the amount is below the minimum debt, false otherwise. + */ + function isBelowMinDebt(address market, address borrower, uint amount) public view returns(bool){ + //Optimization to check if borrow amount itself is higher than the minimum + //This avoids an expensive lookup in the market + uint minDebt = minDebts[market]; + if(amount >= minDebt) return false; + return IMarket(market).debts(borrower) + amount < minDebt; + } +} diff --git a/test/PrivateBorrowController.t.sol b/test/PrivateBorrowController.t.sol new file mode 100644 index 0000000..a4e46d1 --- /dev/null +++ b/test/PrivateBorrowController.t.sol @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "test/mocks/BorrowContract.sol"; +import "test/FiRMBaseTest.sol"; +import {PrivateBorrowController} from "src/PrivateBorrowController.sol"; + +contract BorrowContractTxOrigin { + uint256 constant AMOUNT = 1 ether; + uint256 constant PRICE = 1000; + uint256 constant COLLATERAL_FACTOR_BPS = 8500; + uint256 constant BPS_BASIS = 10_000; + + constructor(Market market, WETH9 weth) payable { + weth.approve(address(market), type(uint).max); + weth.deposit{value: msg.value}(); + market.deposit(address(this), AMOUNT); + market.borrow((AMOUNT * COLLATERAL_FACTOR_BPS * PRICE) / BPS_BASIS); + } +} +contract BatchApprove { + IDBR immutable dbr; + + constructor(address _dbr){ + dbr = IDBR(_dbr); + } + + function approveDepositAndBorrow(address _market, uint depositAmount, uint borrowAmount) external { + require(msg.sender == address(this), "Invalid authority"); + require(tx.origin == address(this), "Invalid origin"); + require(dbr.markets(_market), "Invalid market"); + IMarket market = IMarket(_market); + IERC20(market.collateral()).approve(_market, depositAmount); + market.depositAndBorrow(depositAmount, borrowAmount); + } +} + +contract PrivateBorrowControllerTest is FiRMBaseTest { + BorrowContract borrowContract; + PrivateBorrowController privateBorrowController; + bytes onlyOperatorLowercase = "Only operator"; + function setUp() public { + initialize( + replenishmentPriceBps, + collateralFactorBps, + replenishmentIncentiveBps, + liquidationBonusBps, + callOnDepositCallback + ); + vm.startPrank(gov); + privateBorrowController = new PrivateBorrowController(gov, address(dbr)); + market.setBorrowController(IBorrowController(address(privateBorrowController))); + + gibWeth(address(borrowContract), 1 ether); + vm.prank(gov); + privateBorrowController.setStalenessThreshold(address(market), 10); + require( + address(market.borrowController()) != address(0), + "Borrow controller not set" + ); + //Let daily limit recover fully + vm.warp(block.timestamp + 1 days); + ethFeed.changeUpdatedAt(block.timestamp); + } + + function test_BorrowAllowed_True_Where_UserIsAllowedForMarket() public { + vm.prank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + vm.startPrank(address(market), user); + assertEq( + privateBorrowController.borrowAllowed(user, address(0), 0), + true, + "EOA not allowed to borrow" + ); + } + + function test_BorrowAllowed_False_Where_UserIsUnallowedForMarket() public { + vm.prank(address(market), user); + assertEq( + privateBorrowController.borrowAllowed( + address(borrowContract), + address(0), + 0 + ), + false, + "Unallowed contract allowed to borrow" + ); + } + + function test_BorrowAllowed_False_Where_EdgeCaseBugTriggeredAndNotAMinter() + public + { + vm.prank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + uint testAmount = 1e18; + gibWeth(user, testAmount); + uint maxBorrow = 1e18-1; + gibDOLA(address(market), maxBorrow); + vm.startPrank(user, user); + deposit(testAmount); + market.borrow(maxBorrow); + market.repay(user, maxBorrow); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + vm.prank(address(market), user); + assertFalse( + privateBorrowController.borrowAllowed(user, user, 1), + "User was allowed to borrow" + ); + } + + function test_BorrowAllowed_True_Where_EdgeCaseBugDebtNonZero() + public + { + vm.prank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + + uint testAmount = 1e18; + gibWeth(user, testAmount); + uint halfBorrow = getMaxBorrowAmount(testAmount) / 2; + gibDOLA(address(market), halfBorrow * 2); + vm.startPrank(user, user); + deposit(testAmount); + market.borrow(halfBorrow); + market.repay(user, halfBorrow-1); + vm.stopPrank(); + + assertEq(market.debts(user), 1, "User debt not 1"); + assertEq(dbr.balanceOf(user), 0, "DBR balance of user is not 0 before time skip"); + vm.warp(block.timestamp + 30 days); + ethFeed.changeUpdatedAt(block.timestamp); + vm.prank(gov); + dbr.addMinter(address(privateBorrowController)); + vm.prank(address(market), user); + assertTrue( + privateBorrowController.borrowAllowed(user, user, 1) + ); + vm.startPrank(user, user); + assertGt(market.getCreditLimit(user), 0, "User has no credit limit"); + market.borrow(market.getCreditLimit(user) / 100); + assertLe(dbr.deficitOf(user), 30 days * 1, "Deficit of user more than expected"); + assertEq(dbr.balanceOf(user), 0, "DBR balance of user is not 0"); + } + + function test_BorrowAllowed_True_Where_EdgeCaseBugDebtNonZero365Days() + public + { + vm.prank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + + uint testAmount = 1e18; + gibWeth(user, testAmount); + uint halfBorrow = getMaxBorrowAmount(testAmount) / 2; + gibDOLA(address(market), halfBorrow * 2); + vm.startPrank(user, user); + deposit(testAmount); + market.borrow(halfBorrow); + market.repay(user, halfBorrow-1); + vm.stopPrank(); + + assertEq(market.debts(user), 1, "User debt not 1"); + assertEq(dbr.balanceOf(user), 0, "DBR balance of user is not 0 before time skip"); + vm.warp(block.timestamp + 365 days); + ethFeed.changeUpdatedAt(block.timestamp); + vm.prank(gov); + dbr.addMinter(address(privateBorrowController)); + vm.prank(address(market), user); + assertTrue( + privateBorrowController.borrowAllowed(user, user, 1) + ); + vm.startPrank(user, user); + assertGt(market.getCreditLimit(user), 0, "User has no credit limit"); + uint creditLimit = market.getCreditLimit(user); + vm.expectRevert("DBR Deficit"); + market.borrow(creditLimit / 100); + dbr.accrueDueTokens(user); + assertEq(dbr.deficitOf(user), 1); + } + + + function test_BorrowAllowed_True_Where_EdgeCaseBugDebtNonZeroFuzz(uint timeElapsed) + public + { + vm.prank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + + timeElapsed = timeElapsed % 365 days; + uint testAmount = 1e18; + gibWeth(user, testAmount); + uint halfBorrow = getMaxBorrowAmount(testAmount) / 2; + gibDOLA(address(market), halfBorrow * 2); + vm.startPrank(user, user); + deposit(testAmount); + market.borrow(halfBorrow); + market.repay(user, halfBorrow-1); + vm.stopPrank(); + + assertEq(market.debts(user), 1, "User debt not 1"); + assertEq(dbr.balanceOf(user), 0, "DBR balance of user is not 0 before time skip"); + vm.warp(block.timestamp + timeElapsed); + ethFeed.changeUpdatedAt(block.timestamp); + vm.prank(gov); + dbr.addMinter(address(privateBorrowController)); + vm.prank(address(market), user); + assertTrue( + privateBorrowController.borrowAllowed(user, user, 1) + ); + vm.startPrank(user, user); + assertGt(market.getCreditLimit(user), 0, "User has no credit limit"); + market.borrow(market.getCreditLimit(user) / 100); + assertLe(dbr.deficitOf(user), timeElapsed * 1, "Deficit of user more than expected"); + assertEq(dbr.balanceOf(user), 0, "DBR balance of user is not 0"); + } + + function test_BorrowAllowed_False_Where_EdgeCaseBugTriggeredWithMinimalDebt() + public + { + vm.prank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + + + uint testAmount = 1e18; + gibWeth(user, testAmount); + uint maxBorrow = getMaxBorrowAmount(testAmount); + gibDOLA(address(market), maxBorrow); + vm.startPrank(user, user); + deposit(testAmount); + market.borrow(maxBorrow); + market.repay(user, maxBorrow-1); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + vm.prank(address(market), user); + assertFalse( + privateBorrowController.borrowAllowed(user, user, 1), + "User was allowed to borrow" + ); + } + + + function test_BorrowAllowed_True_Where_EdgeCaseBugTriggeredAndAMinter() + public + { + vm.prank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + + + uint testAmount = 1e18; + gibWeth(user, testAmount); + uint maxBorrow = 1e18-1; + gibDOLA(address(market), maxBorrow); + vm.startPrank(user, user); + deposit(testAmount); + market.borrow(maxBorrow); + market.repay(user, maxBorrow); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + vm.prank(gov); + dbr.addMinter(address(privateBorrowController)); + vm.prank(address(market), user); + assertEq( + privateBorrowController.borrowAllowed(user, user, 1), + true, + "User was not allowed to borrow" + ); + } + + function test_BorrowAllowed_Revert_When_EdgeCaseBugTriggeredAndCalledByNonApprovedMarket() + public + { + vm.prank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + + uint testAmount = 1e18; + gibWeth(user, testAmount); + uint maxBorrow = 1e18-1; + gibDOLA(address(market), maxBorrow); + vm.startPrank(user, user); + deposit(testAmount); + market.borrow(maxBorrow); + market.repay(user, maxBorrow); + vm.stopPrank(); + + vm.warp(block.timestamp + 1); + vm.prank(gov); + dbr.addMinter(address(privateBorrowController)); + vm.prank(address(0xdeadbeef), address(0xdeadbeef)); + vm.expectRevert("Message sender is not a market"); + privateBorrowController.borrowAllowed(user, user, 0); + } + + function test_BorrowAllowed_False_Where_PriceIsStale() public { + vm.prank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + + vm.warp(block.timestamp + 1000); + + vm.startPrank(address(market), user); + assertEq(privateBorrowController.isPriceStale(address(market)), true); + assertEq( + privateBorrowController.borrowAllowed(user, address(0), 0), + false, + "Allowed contract not allowed to borrow" + ); + vm.stopPrank(); + } + + function test_BorrowAllowed_False_Where_DebtIsBelowMininimum() public { + vm.startPrank(gov); + privateBorrowController.setMinDebt(address(market), 1 ether); + privateBorrowController.allowBorrower(address(market), user, true); + vm.stopPrank(); + + vm.startPrank(address(market), user); + assertEq( + privateBorrowController.isBelowMinDebt(address(market), user, 0.5 ether), + true + ); + assertEq( + privateBorrowController.borrowAllowed(user, address(0), 0.5 ether), + false, + "Allowed contract not allowed to borrow" + ); + vm.stopPrank(); + } + + function test_addAddressToAllowlist() public { + bool allowed = privateBorrowController.allowedBorrowers( + address(market), + user + ); + assertEq(allowed, false, "User was allowed before call to allow"); + + vm.startPrank(gov); + privateBorrowController.allowBorrower(address(market), user, true); + vm.stopPrank(); + + assertEq( + privateBorrowController.allowedBorrowers(address(market), user), + true, + "Contract was not added to allowlist successfully" + ); + } + + function test_removesAddressFromAllowlist() public { + test_addAddressToAllowlist(); + + vm.startPrank(gov); + privateBorrowController.allowBorrower(address(market), user, false); + + assertEq( + privateBorrowController.allowedBorrowers(address(market), user), + false, + "Contract was not removed from allowlist successfully" + ); + } + //Access Control + function test_accessControl_setOperator() public { + vm.prank(gov); + privateBorrowController.setOperator(address(0)); + + vm.expectRevert(onlyOperatorLowercase); + privateBorrowController.setOperator(address(0)); + } + + function test_accessControl_setStalenessThresshold() public { + vm.prank(gov); + privateBorrowController.setStalenessThreshold(address(market), 1); + assertEq(privateBorrowController.stalenessThreshold(address(market)), 1); + + vm.expectRevert(onlyOperatorLowercase); + privateBorrowController.setStalenessThreshold(address(market), 2); + } + + function test_accessControl_setMinDebtThresshold() public { + vm.prank(gov); + privateBorrowController.setMinDebt(address(market), 500 ether); + assertEq(privateBorrowController.minDebts(address(market)), 500 ether); + + vm.expectRevert(onlyOperatorLowercase); + privateBorrowController.setMinDebt(address(market), 200 ether); + } + + function test_accessControl_allowBorrower() public { + vm.prank(gov); + privateBorrowController.allowBorrower(address(0), address(0), true); + + vm.expectRevert(onlyOperatorLowercase); + privateBorrowController.allowBorrower(address(0), address(0), true); + } +} From c9a06ff8e54c835c64a9590e5d2b075cc08a43d5 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Thu, 3 Jul 2025 10:45:09 +0200 Subject: [PATCH 2/2] Add allow/disallow event --- src/PrivateBorrowController.sol | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/PrivateBorrowController.sol b/src/PrivateBorrowController.sol index 318e1cd..e5e7673 100644 --- a/src/PrivateBorrowController.sol +++ b/src/PrivateBorrowController.sol @@ -29,6 +29,8 @@ contract PrivateBorrowController { mapping(address => uint) public stalenessThreshold; mapping(address => mapping(address => bool)) public allowedBorrowers; + event IsAllowed(address indexed market, address indexed borrower, bool isAllowed); + constructor(address _operator, address _DBR) { operator = _operator; DBR = DolaBorrowingRights(_DBR); @@ -51,7 +53,10 @@ contract PrivateBorrowController { * @param allowedBorrower The borrower to be allowed for the market * @param isAllowed whether or not the borrower is allowed to borrow from market */ - function allowBorrower(address market, address allowedBorrower, bool isAllowed) public onlyOperator { allowedBorrowers[market][allowedBorrower] = isAllowed; } + function allowBorrower(address market, address allowedBorrower, bool isAllowed) public onlyOperator { + allowedBorrowers[market][allowedBorrower] = isAllowed; + emit IsAllowed(market, allowedBorrower, isAllowed); + } /** * @notice Sets the staleness threshold for Chainlink feeds