From d8935dca1765c332c6e520e259eb8026f9f55877 Mon Sep 17 00:00:00 2001 From: rick Date: Wed, 1 Apr 2026 16:00:31 +0800 Subject: [PATCH 01/14] feat: add SmartProvider support to BrokerLiquidator Add `redeemSmartCollateral` to allow BOT role to redeem smart collateral LP tokens via whitelisted SmartProviders. Add `batchSetSmartProviders` for MANAGER role to manage the provider whitelist. --- src/liquidator/BrokerLiquidator.sol | 33 ++++- test/liquidator/BrokerLiquidator.t.sol | 147 ++++++++++++++++++++ test/liquidator/mocks/MockSmartProvider.sol | 31 +++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 test/liquidator/BrokerLiquidator.t.sol create mode 100644 test/liquidator/mocks/MockSmartProvider.sol diff --git a/src/liquidator/BrokerLiquidator.sol b/src/liquidator/BrokerLiquidator.sol index e71999b6..bb0a15ab 100644 --- a/src/liquidator/BrokerLiquidator.sol +++ b/src/liquidator/BrokerLiquidator.sol @@ -8,7 +8,8 @@ import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; import { IBroker, IBrokerBase } from "../broker/interfaces/IBroker.sol"; import { Id, MarketParams, IMoolah } from "moolah/interfaces/IMoolah.sol"; -import "./IBrokerLiquidator.sol"; +import { IBrokerLiquidator } from "./IBrokerLiquidator.sol"; +import { ISmartProvider } from "../provider/interfaces/IProvider.sol"; contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerLiquidator { using MarketParamsLib for MarketParams; @@ -33,6 +34,8 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL // then we will know broker is whitelisted or not // by checking broker address => marketIdToBroker[market id] == broker address mapping(address => bytes32) public brokerToMarketId; + // @dev smart collateral provider whitelist + mapping(address => bool) public smartProviders; bytes32 public constant MANAGER = keccak256("MANAGER"); // manager role bytes32 public constant BOT = keccak256("BOT"); // manager role @@ -41,6 +44,7 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL event MarketWhitelistChanged(bytes32 id, address broker, bool added); event PairWhitelistChanged(address pair, bool added); event SellToken(address pair, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOutMin); + event SmartProvidersChanged(address provider, bool added); /// @custom:oz-upgrades-unsafe-allow constructor /// @param moolah The address of the Moolah contract. @@ -284,5 +288,32 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL SafeTransferLib.safeApprove(arb.loanToken, msg.sender, repaidAssets); } + /// @dev redeems smart collateral LP tokens. + /// @param smartProvider The address of the smart collateral provider. + /// @param lpAmount The amount of LP collateral tokens to redeem. + /// @param minToken0Amt The minimum amount of token0 to receive. + /// @param minToken1Amt The minimum amount of token1 to receive. + /// @return The amount of token0 and token1 redeemed. + function redeemSmartCollateral( + address smartProvider, + uint256 lpAmount, + uint256 minToken0Amt, + uint256 minToken1Amt + ) external onlyRole(BOT) returns (uint256, uint256) { + require(smartProviders[smartProvider], NotWhitelisted()); + return ISmartProvider(smartProvider).redeemLpCollateral(lpAmount, minToken0Amt, minToken1Amt); + } + + /// @dev sets the smart collateral providers. + /// @param providers The array of smart collateral providers. + /// @param status The status of the providers. + function batchSetSmartProviders(address[] calldata providers, bool status) external onlyRole(MANAGER) { + for (uint256 i = 0; i < providers.length; i++) { + address provider = providers[i]; + smartProviders[provider] = status; + emit SmartProvidersChanged(provider, status); + } + } + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} } diff --git a/test/liquidator/BrokerLiquidator.t.sol b/test/liquidator/BrokerLiquidator.t.sol new file mode 100644 index 00000000..74989a75 --- /dev/null +++ b/test/liquidator/BrokerLiquidator.t.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "../moolah/BaseTest.sol"; + +import { BrokerLiquidator, IBrokerLiquidator } from "liquidator/BrokerLiquidator.sol"; +import { MarketParamsLib, MarketParams, Id } from "moolah/libraries/MarketParamsLib.sol"; +import { MockSmartProvider } from "./mocks/MockSmartProvider.sol"; + +contract BrokerLiquidatorTest is BaseTest { + using MarketParamsLib for MarketParams; + + BrokerLiquidator brokerLiquidator; + address BOT; + address MANAGER_ADDR; + MockSmartProvider smartProvider; + + function setUp() public override { + super.setUp(); + + BOT = makeAddr("Bot"); + MANAGER_ADDR = OWNER; + + BrokerLiquidator impl = new BrokerLiquidator(address(moolah)); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeWithSelector(impl.initialize.selector, OWNER, OWNER, BOT) + ); + brokerLiquidator = BrokerLiquidator(address(proxy)); + + smartProvider = new MockSmartProvider(address(loanToken), address(collateralToken)); + } + + // ==================== batchSetSmartProviders ==================== + + function testBatchSetSmartProviders() public { + address[] memory providers = new address[](2); + providers[0] = address(smartProvider); + providers[1] = makeAddr("Provider2"); + + vm.prank(MANAGER_ADDR); + brokerLiquidator.batchSetSmartProviders(providers, true); + + assertTrue(brokerLiquidator.smartProviders(address(smartProvider))); + assertTrue(brokerLiquidator.smartProviders(providers[1])); + } + + function testBatchSetSmartProvidersRemove() public { + address[] memory providers = new address[](1); + providers[0] = address(smartProvider); + + vm.prank(MANAGER_ADDR); + brokerLiquidator.batchSetSmartProviders(providers, true); + assertTrue(brokerLiquidator.smartProviders(address(smartProvider))); + + vm.prank(MANAGER_ADDR); + brokerLiquidator.batchSetSmartProviders(providers, false); + assertFalse(brokerLiquidator.smartProviders(address(smartProvider))); + } + + function testBatchSetSmartProvidersEmitsEvents() public { + address[] memory providers = new address[](2); + providers[0] = address(smartProvider); + providers[1] = makeAddr("Provider2"); + + vm.expectEmit(true, true, true, true); + emit BrokerLiquidator.SmartProvidersChanged(providers[0], true); + vm.expectEmit(true, true, true, true); + emit BrokerLiquidator.SmartProvidersChanged(providers[1], true); + + vm.prank(MANAGER_ADDR); + brokerLiquidator.batchSetSmartProviders(providers, true); + } + + function testBatchSetSmartProvidersRevertsIfNotManager() public { + address[] memory providers = new address[](1); + providers[0] = address(smartProvider); + + vm.prank(BOT); + vm.expectRevert(); + brokerLiquidator.batchSetSmartProviders(providers, true); + } + + function testBatchSetSmartProvidersEmpty() public { + address[] memory providers = new address[](0); + + vm.prank(MANAGER_ADDR); + brokerLiquidator.batchSetSmartProviders(providers, true); + // no revert, no-op + } + + // ==================== redeemSmartCollateral ==================== + + function testRedeemSmartCollateral() public { + // whitelist provider + address[] memory providers = new address[](1); + providers[0] = address(smartProvider); + vm.prank(MANAGER_ADDR); + brokerLiquidator.batchSetSmartProviders(providers, true); + + uint256 lpAmount = 1e18; + vm.prank(BOT); + (uint256 token0Out, uint256 token1Out) = brokerLiquidator.redeemSmartCollateral( + address(smartProvider), + lpAmount, + 0, + 0 + ); + + assertEq(token0Out, lpAmount / 2); + assertEq(token1Out, lpAmount / 2); + assertEq(loanToken.balanceOf(address(brokerLiquidator)), token0Out); + assertEq(collateralToken.balanceOf(address(brokerLiquidator)), token1Out); + } + + function testRedeemSmartCollateralRevertsIfNotWhitelisted() public { + vm.prank(BOT); + vm.expectRevert(BrokerLiquidator.NotWhitelisted.selector); + brokerLiquidator.redeemSmartCollateral(address(smartProvider), 1e18, 0, 0); + } + + function testRedeemSmartCollateralRevertsIfNotBot() public { + address[] memory providers = new address[](1); + providers[0] = address(smartProvider); + vm.prank(MANAGER_ADDR); + brokerLiquidator.batchSetSmartProviders(providers, true); + + vm.prank(MANAGER_ADDR); + vm.expectRevert(); + brokerLiquidator.redeemSmartCollateral(address(smartProvider), 1e18, 0, 0); + } + + function testRedeemSmartCollateralRevertsAfterProviderRemoved() public { + address[] memory providers = new address[](1); + providers[0] = address(smartProvider); + + vm.prank(MANAGER_ADDR); + brokerLiquidator.batchSetSmartProviders(providers, true); + + vm.prank(MANAGER_ADDR); + brokerLiquidator.batchSetSmartProviders(providers, false); + + vm.prank(BOT); + vm.expectRevert(BrokerLiquidator.NotWhitelisted.selector); + brokerLiquidator.redeemSmartCollateral(address(smartProvider), 1e18, 0, 0); + } +} diff --git a/test/liquidator/mocks/MockSmartProvider.sol b/test/liquidator/mocks/MockSmartProvider.sol new file mode 100644 index 00000000..ed9e6b12 --- /dev/null +++ b/test/liquidator/mocks/MockSmartProvider.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +contract MockSmartProvider is Test { + address public token0; + address public token1; + + constructor(address _token0, address _token1) { + token0 = _token0; + token1 = _token1; + } + + function redeemLpCollateral( + uint256 lpAmount, + uint256 minToken0Out, + uint256 minToken1Out + ) external returns (uint256 token0Out, uint256 token1Out) { + token0Out = lpAmount / 2; + token1Out = lpAmount / 2; + require(token0Out >= minToken0Out, "token0 slippage"); + require(token1Out >= minToken1Out, "token1 slippage"); + + deal(token0, address(this), token0Out); + deal(token1, address(this), token1Out); + IERC20(token0).transfer(msg.sender, token0Out); + IERC20(token1).transfer(msg.sender, token1Out); + } +} From 759e12eecc4aee2777b71dc29d8228ec4354e457 Mon Sep 17 00:00:00 2001 From: Razorback Date: Fri, 3 Apr 2026 13:09:49 +0800 Subject: [PATCH 02/14] feat: BrokerLiquidator support flash liqudate smart collateral --- script/broker/deploy_brokerLiquidator.s.sol | 4 +- src/liquidator/BrokerLiquidator.sol | 161 ++++++++++++++++ src/liquidator/IBrokerLiquidator.sol | 21 ++ test/broker/LendingBroker.t.sol | 202 +++++++++++++++++++- test/liquidator/BrokerLiquidator.t.sol | 2 +- test/liquidator/mocks/MockSmartProvider.sol | 29 ++- test/moolah/MarketFactoryTest.sol | 2 +- 7 files changed, 413 insertions(+), 8 deletions(-) diff --git a/script/broker/deploy_brokerLiquidator.s.sol b/script/broker/deploy_brokerLiquidator.s.sol index 13680526..f0829960 100644 --- a/script/broker/deploy_brokerLiquidator.s.sol +++ b/script/broker/deploy_brokerLiquidator.s.sol @@ -39,8 +39,8 @@ contract DeployBrokerLiquidator is DeployBase { // grant roles to manager and admin bytes32 MANAGER = keccak256("MANAGER"); bytes32 DEFAULT_ADMIN_ROLE = 0x0000000000000000000000000000000000000000000000000000000000000000; - BrokerLiquidator(address(proxy)).grantRole(MANAGER, manager); - BrokerLiquidator(address(proxy)).grantRole(DEFAULT_ADMIN_ROLE, timelock); + BrokerLiquidator(payable(address(proxy))).grantRole(MANAGER, manager); + BrokerLiquidator(payable(address(proxy))).grantRole(DEFAULT_ADMIN_ROLE, timelock); vm.stopBroadcast(); } diff --git a/src/liquidator/BrokerLiquidator.sol b/src/liquidator/BrokerLiquidator.sol index bb0a15ab..28d34247 100644 --- a/src/liquidator/BrokerLiquidator.sol +++ b/src/liquidator/BrokerLiquidator.sol @@ -39,12 +39,23 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL bytes32 public constant MANAGER = keccak256("MANAGER"); // manager role bytes32 public constant BOT = keccak256("BOT"); // manager role + address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; event TokenWhitelistChanged(address indexed token, bool added); event MarketWhitelistChanged(bytes32 id, address broker, bool added); event PairWhitelistChanged(address pair, bool added); event SellToken(address pair, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOutMin); event SmartProvidersChanged(address provider, bool added); + event SmartLiquidation( + bytes32 indexed id, + address indexed lpToken, + address indexed collateralToken, + uint256 lpAmount, + uint256 minToken0Amt, + uint256 minToken1Amt, + uint256 amount0, + uint256 amount1 + ); /// @custom:oz-upgrades-unsafe-allow constructor /// @param moolah The address of the Moolah contract. @@ -68,6 +79,8 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL _grantRole(BOT, bot); } + receive() external payable {} + /// @dev withdraws ERC20 tokens. /// @param token The address of the token. /// @param amount The amount to withdraw. @@ -263,6 +276,123 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL ); } + /// @dev liquidates a position with smart collateral. + /// @param id The id of the market. + /// @param borrower The address of the borrower. + /// @param smartProvider The address of the smart collateral provider. + /// @param seizedAssets The amount of assets to seize. + /// @param repaidShares The amount of shares to repay. + /// @param payload The payload for the liquidation (min amounts for SmartProvider liquidation). + /// @return The actual seized assets and repaid assets. + function liquidateSmartCollateral( + bytes32 id, + address borrower, + address smartProvider, + uint256 seizedAssets, + uint256 repaidShares, + bytes memory payload + ) external onlyRole(BOT) returns (uint256, uint256) { + address broker = marketIdToBroker[id]; + require(broker != address(0), NotWhitelisted()); + require(_checkBrokerMarketId(broker, id), BrokerMarketIdMismatch()); + require(smartProviders[smartProvider], NotWhitelisted()); + MarketParams memory params = IMoolah(MOOLAH).idToMarketParams(Id.wrap(id)); + require(ISmartProvider(smartProvider).TOKEN() == params.collateralToken, "Invalid smart provider"); + address lpToken = ISmartProvider(smartProvider).dexLP(); + + uint256 collBalanceBefore = IERC20(params.collateralToken).balanceOf(address(this)); + (uint256 minAmount0, uint256 minAmount1) = abi.decode(payload, (uint256, uint256)); + IBrokerBase(broker).liquidate( + params, + borrower, + seizedAssets, + repaidShares, + abi.encode( + MoolahLiquidateData( + params.collateralToken, + params.loanToken, + seizedAssets, + address(0), + "", + false, + // --- below fields are only used for smart collateral liquidation callback --- + false, + address(0), + 0, + 0, + address(0), + address(0), + "", + "" + ) + ) + ); + uint256 collAmount = IERC20(params.collateralToken).balanceOf(address(this)) - collBalanceBefore; + require(collAmount > 0, "No collateral seized"); + + (uint256 amount0, uint256 amount1) = ISmartProvider(smartProvider).redeemLpCollateral( + collAmount, + minAmount0, + minAmount1 + ); + + emit SmartLiquidation(id, lpToken, params.collateralToken, collAmount, minAmount0, minAmount1, amount0, amount1); + return (seizedAssets, 0); + } + + /// @dev flash liquidates a position with smart collateral. + /// @param id The id of the market. + /// @param borrower The address of the borrower. + /// @param smartProvider The address of the smart collateral provider. + /// @param seizedAssets The amount of assets to seize. + /// @param token0Pair The address of the token0 pair. + /// @param token1Pair The address of the token1 pair. + /// @param swapToken0Data The swap data for token0 swapping to loan token. + /// @param swapToken1Data The swap data for token1 swapping to loan token. + /// @param payload The payload for the liquidation (min amounts for SmartProvider liquidation). + /// @return The actual seized assets and repaid assets. + function flashLiquidateSmartCollateral( + bytes32 id, + address borrower, + address smartProvider, + uint256 seizedAssets, + address token0Pair, + address token1Pair, + bytes calldata swapToken0Data, + bytes calldata swapToken1Data, + bytes memory payload + ) external onlyRole(BOT) returns (uint256, uint256) { + address broker = marketIdToBroker[id]; + require(broker != address(0), NotWhitelisted()); + require(_checkBrokerMarketId(broker, id), BrokerMarketIdMismatch()); + require(smartProviders[smartProvider], NotWhitelisted()); + require(pairWhitelist[token0Pair], NotWhitelisted()); + require(pairWhitelist[token1Pair], NotWhitelisted()); + MarketParams memory params = IMoolah(MOOLAH).idToMarketParams(Id.wrap(id)); + require(ISmartProvider(smartProvider).TOKEN() == params.collateralToken, "Invalid smart provider"); + (uint256 minAmount0, uint256 minAmount1) = abi.decode(payload, (uint256, uint256)); + + MoolahLiquidateData memory callback = MoolahLiquidateData( + params.collateralToken, + params.loanToken, + seizedAssets, + address(0), + "", + false, + true, + smartProvider, + minAmount0, + minAmount1, + token0Pair, + token1Pair, + swapToken0Data, + swapToken1Data + ); + + IBrokerBase(broker).liquidate(params, borrower, seizedAssets, 0, abi.encode(callback)); + return (seizedAssets, 0); + } + /// @dev the function will be called by the the Broker, when Broker's onMoolahLiquidate is called by Moolah. /// @param repaidAssets The amount of assets repaid. /// @param data The callback data. @@ -283,6 +413,37 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL if (out < repaidAssets) revert NoProfit(); SafeTransferLib.safeApprove(arb.collateralToken, arb.collateralPair, 0); + } else if (arb.swapSmartCollateral) { + uint256 before = SafeTransferLib.balanceOf(arb.loanToken, address(this)); + // redeem lp + (uint256 amount0, uint256 amount1) = ISmartProvider(arb.smartProvider).redeemLpCollateral( + arb.seized, + arb.minToken0Amt, + arb.minToken1Amt + ); + + address token0 = ISmartProvider(arb.smartProvider).token(0); + address token1 = ISmartProvider(arb.smartProvider).token(1); + + // swap token0 and token1 to loanToken if needed + if (amount0 > 0 && token0 != arb.loanToken) { + if (token0 != BNB_ADDRESS) SafeTransferLib.safeApprove(token0, arb.token0Pair, amount0); + uint256 _value = token0 == BNB_ADDRESS ? arb.minToken0Amt : 0; + (bool success, ) = arb.token0Pair.call{ value: _value }(arb.swapToken0Data); + require(success, SwapFailed()); + } + + if (amount1 > 0 && token1 != arb.loanToken) { + if (token1 != BNB_ADDRESS) SafeTransferLib.safeApprove(token1, arb.token1Pair, amount1); + uint256 _value = token1 == BNB_ADDRESS ? arb.minToken1Amt : 0; + (bool success, ) = arb.token1Pair.call{ value: _value }(arb.swapToken1Data); + require(success, SwapFailed()); + } + uint256 out = SafeTransferLib.balanceOf(arb.loanToken, address(this)) - before; + + if (out < repaidAssets) revert NoProfit(); + if (token0 != BNB_ADDRESS) SafeTransferLib.safeApprove(token0, arb.token0Pair, 0); + if (token1 != BNB_ADDRESS) SafeTransferLib.safeApprove(token1, arb.token1Pair, 0); } SafeTransferLib.safeApprove(arb.loanToken, msg.sender, repaidAssets); diff --git a/src/liquidator/IBrokerLiquidator.sol b/src/liquidator/IBrokerLiquidator.sol index 12d5f4b2..0b29cba6 100644 --- a/src/liquidator/IBrokerLiquidator.sol +++ b/src/liquidator/IBrokerLiquidator.sol @@ -29,6 +29,27 @@ interface IBrokerLiquidator { function liquidate(bytes32 id, address borrower, uint256 seizedAssets, uint256 repaidShares) external; + function liquidateSmartCollateral( + bytes32 id, + address borrower, + address smartProvider, + uint256 seizedAssets, + uint256 repaidShares, + bytes memory payload + ) external returns (uint256, uint256); + + function flashLiquidateSmartCollateral( + bytes32 id, + address borrower, + address smartProvider, + uint256 seizedAssets, + address token0Pair, + address token1Pair, + bytes calldata swapToken0Data, + bytes calldata swapToken1Data, + bytes memory payload + ) external returns (uint256, uint256); + function sellToken( address pair, address tokenIn, diff --git a/test/broker/LendingBroker.t.sol b/test/broker/LendingBroker.t.sol index ea179eb2..6c9f2662 100644 --- a/test/broker/LendingBroker.t.sol +++ b/test/broker/LendingBroker.t.sol @@ -28,7 +28,9 @@ import { SharesMathLib } from "moolah/libraries/SharesMathLib.sol"; import { MathLib, WAD } from "moolah/libraries/MathLib.sol"; import { UtilsLib } from "moolah/libraries/UtilsLib.sol"; import { IMoolahLiquidateCallback } from "../../src/moolah/interfaces/IMoolahCallbacks.sol"; -import { BrokerLiquidator } from "../../src/liquidator/BrokerLiquidator.sol"; +import { BrokerLiquidator, IBrokerLiquidator } from "../../src/liquidator/BrokerLiquidator.sol"; +import { MockSmartProvider } from "../liquidator/mocks/MockSmartProvider.sol"; +import { ISmartProvider } from "../../src/provider/interfaces/IProvider.sol"; import { ORACLE_PRICE_SCALE, LIQUIDATION_CURSOR, MAX_LIQUIDATION_INCENTIVE_FACTOR } from "moolah/libraries/ConstantsLib.sol"; contract LendingBrokerTest is Test { @@ -248,7 +250,7 @@ contract LendingBrokerTest is Test { address(mockLiqImpl), abi.encodeWithSelector(BrokerLiquidator.initialize.selector, ADMIN, MANAGER, BOT) ); - liquidator = BrokerLiquidator(address(mockLiqProxy)); + liquidator = BrokerLiquidator(payable(address(mockLiqProxy))); // whitelist lendingbroker as liquidator in moolah Id[] memory ids = new Id[](2); @@ -1700,6 +1702,202 @@ contract LendingBrokerTest is Test { bnbBroker.repay{ value: 1 ether }(1 ether, borrower); vm.stopPrank(); } + + // ============================================= + // Smart LP liquidation tests + // ============================================= + + /// @dev Helper: deploy a MockSmartProvider whose collateralToken == BTCB + /// and whose underlying tokens are LISUSD (token0) and BTCB (token1). + function _setupSmartProvider() internal returns (MockSmartProvider sp, MockSwapPair swapPair) { + sp = new MockSmartProvider(address(LISUSD), address(BTCB)); + sp.setCollateralToken(address(BTCB)); + + // whitelist the smart provider + address[] memory providers = new address[](1); + providers[0] = address(sp); + vm.prank(MANAGER); + liquidator.batchSetSmartProviders(providers, true); + + // deploy a mock swap pair that converts BTCB -> LISUSD at oracle price + swapPair = new MockSwapPair(address(BTCB), address(LISUSD), oracle); + + // whitelist swap pair + vm.prank(MANAGER); + liquidator.setPairWhitelist(address(swapPair), true); + + // whitelist tokens + vm.prank(MANAGER); + liquidator.setTokenWhitelist(address(LISUSD), true); + vm.prank(MANAGER); + liquidator.setTokenWhitelist(address(BTCB), true); + } + + function test_liquidateSmartCollateral_seizes_and_redeems() public { + _prepareLiquidatablePosition(false); + + (MockSmartProvider sp, ) = _setupSmartProvider(); + + Position memory posBefore = moolah.position(marketParams.id(), borrower); + uint256 userRepayShares = BrokerMath.mulDivCeiling(posBefore.borrowShares, 1 * 1e8, 100 * 1e8); // 1% + + bytes memory payload = abi.encode(uint256(0), uint256(0)); // no slippage min + + // Fund liquidator with LISUSD for repayment (non-flash: liquidator pays upfront) + LISUSD.setBalance(address(liquidator), 1_000_000 ether); + + vm.prank(BOT); + liquidator.liquidateSmartCollateral(Id.unwrap(id), borrower, address(sp), 0, userRepayShares, payload); + + // After liquidation + LP redemption, the liquidator should hold redeemed tokens + // The smart provider splits into LISUSD (token0) and BTCB (token1) + uint256 lisusdAfter = LISUSD.balanceOf(address(liquidator)); + uint256 btcbAfter = BTCB.balanceOf(address(liquidator)); + // Liquidator should have some redeemed tokens (LISUSD from redemption + leftover, BTCB from redemption) + assertGt(lisusdAfter + btcbAfter, 0, "liquidator received no redeemed tokens"); + + // Borrower position should have decreased + Position memory posAfter = moolah.position(marketParams.id(), borrower); + assertLt(posAfter.borrowShares, posBefore.borrowShares, "borrow shares did not decrease"); + } + + function test_flashLiquidateSmartCollateral_swaps_and_repays() public { + _prepareLiquidatablePosition(false); + + (MockSmartProvider sp, MockSwapPair swapPair) = _setupSmartProvider(); + // Configure mock to return 0% as token0 (LISUSD), 100% as token1 (BTCB) + // so that the entire LP value is in BTCB and gets swapped to LISUSD at oracle price + sp.setToken0Bps(0); + + Position memory posBefore = moolah.position(marketParams.id(), borrower); + uint256 userRepayShares = BrokerMath.mulDivCeiling(posBefore.borrowShares, 1 * 1e8, 100 * 1e8); // 1% + uint256 seizedAssets = _previewLiquidationRepayment( + marketParams, + moolah.market(marketParams.id()), + 0, + userRepayShares, + moolah._getPrice(marketParams, borrower) + ); + + // token0 = LISUSD, token1 = BTCB + // With 0% token0Bps, all redeemed as BTCB, so only token1 needs swapping + bytes memory swapToken0Data = ""; // no token0 output + bytes memory swapToken1Data = abi.encodeWithSelector(MockSwapPair.swap.selector); + bytes memory payload = abi.encode(uint256(0), uint256(0)); + + vm.prank(BOT); + liquidator.flashLiquidateSmartCollateral( + Id.unwrap(id), + borrower, + address(sp), + seizedAssets, + address(swapPair), // token0Pair (unused since amount0 == 0) + address(swapPair), // token1Pair (BTCB -> LISUSD swap) + swapToken0Data, + swapToken1Data, + payload + ); + + // Position should have reduced debt + Position memory posAfter = moolah.position(marketParams.id(), borrower); + assertLt(posAfter.borrowShares, posBefore.borrowShares, "borrow shares did not decrease"); + } + + function test_flashLiquidateSmartCollateral_reverts_notWhitelisted() public { + _prepareLiquidatablePosition(false); + + MockSmartProvider sp = new MockSmartProvider(address(LISUSD), address(BTCB)); + sp.setCollateralToken(address(BTCB)); + // NOT whitelisted + + bytes memory payload = abi.encode(uint256(0), uint256(0)); + + vm.prank(BOT); + vm.expectRevert(BrokerLiquidator.NotWhitelisted.selector); + liquidator.flashLiquidateSmartCollateral( + Id.unwrap(id), + borrower, + address(sp), + 1e18, + address(1), + address(1), + "", + "", + payload + ); + } + + function test_liquidateSmartCollateral_reverts_invalidProvider() public { + _prepareLiquidatablePosition(false); + + // Create a smart provider whose TOKEN() != collateralToken (BTCB) + MockSmartProvider sp = new MockSmartProvider(address(LISUSD), address(BTCB)); + // collateralToken defaults to address(sp) != BTCB + + address[] memory providers = new address[](1); + providers[0] = address(sp); + vm.prank(MANAGER); + liquidator.batchSetSmartProviders(providers, true); + + bytes memory payload = abi.encode(uint256(0), uint256(0)); + + LISUSD.setBalance(address(liquidator), 1_000_000 ether); + + vm.prank(BOT); + vm.expectRevert(bytes("Invalid smart provider")); + liquidator.liquidateSmartCollateral(Id.unwrap(id), borrower, address(sp), 0, 1, payload); + } + + function test_flashLiquidateSmartCollateral_reverts_pairNotWhitelisted() public { + _prepareLiquidatablePosition(false); + + (MockSmartProvider sp, MockSwapPair swapPair) = _setupSmartProvider(); + + address badPair = makeAddr("badPair"); + bytes memory payload = abi.encode(uint256(0), uint256(0)); + + vm.prank(BOT); + vm.expectRevert(BrokerLiquidator.NotWhitelisted.selector); + liquidator.flashLiquidateSmartCollateral( + Id.unwrap(id), + borrower, + address(sp), + 1e18, + badPair, // not whitelisted + address(swapPair), + "", + "", + payload + ); + } +} + +/// @dev Mock swap pair that converts tokenIn -> tokenOut at oracle price. +/// Pulls the approved amount (not full balance) to match real aggregator behavior. +contract MockSwapPair is Test { + address public tokenIn; + address public tokenOut; + OracleMock public oracle; + + constructor(address _tokenIn, address _tokenOut, OracleMock _oracle) { + tokenIn = _tokenIn; + tokenOut = _tokenOut; + oracle = _oracle; + } + + /// @dev Swap approved tokenIn for tokenOut at oracle price + function swap() external { + uint256 amountIn = IERC20(tokenIn).allowance(msg.sender, address(this)); + if (amountIn > 0) { + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + // convert at oracle price: amountIn * tokenInPrice / tokenOutPrice + uint256 tokenInPrice = oracle.peek(tokenIn); + uint256 tokenOutPrice = oracle.peek(tokenOut); + uint256 amountOut = (amountIn * tokenInPrice) / tokenOutPrice; + deal(tokenOut, address(this), amountOut); + IERC20(tokenOut).transfer(msg.sender, amountOut); + } + } } contract LiquidationCallbackMock is IMoolahLiquidateCallback { diff --git a/test/liquidator/BrokerLiquidator.t.sol b/test/liquidator/BrokerLiquidator.t.sol index 74989a75..f23e4432 100644 --- a/test/liquidator/BrokerLiquidator.t.sol +++ b/test/liquidator/BrokerLiquidator.t.sol @@ -26,7 +26,7 @@ contract BrokerLiquidatorTest is BaseTest { address(impl), abi.encodeWithSelector(impl.initialize.selector, OWNER, OWNER, BOT) ); - brokerLiquidator = BrokerLiquidator(address(proxy)); + brokerLiquidator = BrokerLiquidator(payable(address(proxy))); smartProvider = new MockSmartProvider(address(loanToken), address(collateralToken)); } diff --git a/test/liquidator/mocks/MockSmartProvider.sol b/test/liquidator/mocks/MockSmartProvider.sol index ed9e6b12..3ede1b7c 100644 --- a/test/liquidator/mocks/MockSmartProvider.sol +++ b/test/liquidator/mocks/MockSmartProvider.sol @@ -7,10 +7,35 @@ import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; contract MockSmartProvider is Test { address public token0; address public token1; + address public collateralToken; + /// @dev Percentage of LP value going to token0 (in basis points, default 5000 = 50%) + uint256 public token0Bps = 5000; constructor(address _token0, address _token1) { token0 = _token0; token1 = _token1; + collateralToken = address(this); // default: LP token == this contract + } + + function setCollateralToken(address _collateralToken) external { + collateralToken = _collateralToken; + } + + function setToken0Bps(uint256 _bps) external { + token0Bps = _bps; + } + + function TOKEN() external view returns (address) { + return collateralToken; + } + + function dexLP() external view returns (address) { + return address(this); + } + + function token(uint256 id) external view returns (address) { + if (id == 0) return token0; + return token1; } function redeemLpCollateral( @@ -18,8 +43,8 @@ contract MockSmartProvider is Test { uint256 minToken0Out, uint256 minToken1Out ) external returns (uint256 token0Out, uint256 token1Out) { - token0Out = lpAmount / 2; - token1Out = lpAmount / 2; + token0Out = (lpAmount * token0Bps) / 10000; + token1Out = lpAmount - token0Out; require(token0Out >= minToken0Out, "token0 slippage"); require(token1Out >= minToken1Out, "token1 slippage"); diff --git a/test/moolah/MarketFactoryTest.sol b/test/moolah/MarketFactoryTest.sol index 9c05e3ea..5e4d41fb 100644 --- a/test/moolah/MarketFactoryTest.sol +++ b/test/moolah/MarketFactoryTest.sol @@ -84,7 +84,7 @@ contract MarketFactoryTest is Test { address(brokerLiquidatorImpl), abi.encodeWithSelector(brokerLiquidatorImpl.initialize.selector, admin, manager, bot) ); - brokerLiquidator = BrokerLiquidator(address(brokerLiquidatorProxy)); + brokerLiquidator = BrokerLiquidator(payable(address(brokerLiquidatorProxy))); liquidator = new MockLiquidator(); publicLiquidator = new MockLiquidator(); From 77524b8cf9065cf811d65cc60904b355daf10261 Mon Sep 17 00:00:00 2001 From: Razorback Date: Fri, 3 Apr 2026 14:43:28 +0800 Subject: [PATCH 03/14] feat: move LendingBroker RELAYER/ORACLE from immutables to storage for shared impl Move RELAYER and ORACLE from constructor immutables to storage variables (appended at end of layout to preserve upgrade compatibility). This allows all LendingBroker proxies to share a single implementation deployment. - Constructor now only takes moolah address - initialize() accepts relayer and oracle as additional params - Added setRelayer() and setOracle() manager-only setters - Updated deploy scripts and all tests Co-Authored-By: Claude Opus 4.6 (1M context) --- script/broker/deploy_broker.s.sol | 8 +- script/broker/deploy_brokerImpl.s.sol | 18 +--- src/broker/LendingBroker.sol | 49 +++++++--- test/broker/LendingBroker.t.sol | 126 ++++++++++++++++++++++++-- test/moolah/MarketFactoryTest.sol | 6 +- 5 files changed, 166 insertions(+), 41 deletions(-) diff --git a/script/broker/deploy_broker.s.sol b/script/broker/deploy_broker.s.sol index 57bc9b89..7c942bfa 100644 --- a/script/broker/deploy_broker.s.sol +++ b/script/broker/deploy_broker.s.sol @@ -37,8 +37,8 @@ contract DeployLendingBroker is DeployBase { console.log("Deployer: ", deployer); vm.startBroadcast(deployerPrivateKey); - // Deploy LendingBroker implementation - LendingBroker impl = new LendingBroker(moolah, interestRelayer, oracle, wbnb); + // Deploy LendingBroker implementation (single impl shared across all proxies) + LendingBroker impl = new LendingBroker(moolah, wbnb); console.log("LendingBroker implementation: ", address(impl)); // Deploy LendingBroker proxy @@ -51,7 +51,9 @@ contract DeployLendingBroker is DeployBase { bot, pauser, rateCalculator, - maxFixedLoanPositions + maxFixedLoanPositions, + interestRelayer, + oracle ) ); console.log("LendingBroker proxy: ", address(proxy)); diff --git a/script/broker/deploy_brokerImpl.s.sol b/script/broker/deploy_brokerImpl.s.sol index 4ca96922..5ceeb797 100644 --- a/script/broker/deploy_brokerImpl.s.sol +++ b/script/broker/deploy_brokerImpl.s.sol @@ -35,9 +35,11 @@ contract DeployLendingBrokerImpl is DeployBase { 0xc26CaAcb00854c5460030B0aFde60C37D9d39C79, 0x3ade951523e81dD45e5787bb0b95Ce7341Db1287 ]; + address moolah; address wbnb; function setUp() public { + moolah = vm.envAddress("MOOLAH"); wbnb = vm.envOr("WBNB", address(0)); } @@ -48,19 +50,9 @@ contract DeployLendingBrokerImpl is DeployBase { vm.startBroadcast(deployerPrivateKey); - for (uint256 i = 0; i < brokers.length; i++) { - address payable proxy = payable(brokers[i]); - - // Read constructor params from the existing proxy contract - address _moolah = address(LendingBroker(proxy).MOOLAH()); - address _relayer = LendingBroker(proxy).RELAYER(); - address _oracle = address(LendingBroker(proxy).ORACLE()); - - // Deploy LendingBroker implementation - LendingBroker impl = new LendingBroker(_moolah, _relayer, _oracle, wbnb); - console.log("Broker proxy:", proxy); - console.log(" New impl: ", address(impl)); - } + // Deploy LendingBroker implementation + LendingBroker impl = new LendingBroker(moolah, wbnb); + console.log("LendingBroker implementation: ", address(impl)); vm.stopBroadcast(); } diff --git a/src/broker/LendingBroker.sol b/src/broker/LendingBroker.sol index 4488e199..cf9be9c0 100644 --- a/src/broker/LendingBroker.sol +++ b/src/broker/LendingBroker.sol @@ -77,8 +77,6 @@ contract LendingBroker is // ------- Immutables ------- IMoolah public immutable MOOLAH; - address public immutable RELAYER; - IOracle public immutable ORACLE; /// @dev Wrapped native token (e.g. WBNB). address(0) if native borrow/repay is not supported. address public immutable WBNB; uint256 public constant MAX_FIXED_TERM_APR = 13e26; // 1.3 * RATE_SCALE = 30% MAX APR @@ -118,6 +116,10 @@ contract LendingBroker is /// @dev liquidation whitelist EnumerableSet.AddressSet private liquidationWhitelist; + // --- V2 storage (appended to preserve layout) --- + address public RELAYER; + IOracle public ORACLE; + // ------- Modifiers ------- modifier onlyMoolah() { if (msg.sender != address(MOOLAH)) revert NotMoolah(); @@ -137,19 +139,12 @@ contract LendingBroker is /** * @dev Constructor for the LendingBroker contract * @param moolah The address of the Moolah contract - * @param relayer The address of the BrokerInterestRelayer contract - * @param oracle The address of the oracle * @param wbnb The address of the wrapped native token (e.g. WBNB). Pass address(0) to disable native support. */ - constructor(address moolah, address relayer, address oracle, address wbnb) { - // zero address assert - if (moolah == address(0) || relayer == address(0) || oracle == address(0)) revert ZeroAddressProvided(); - // set addresses + constructor(address moolah, address wbnb) { + if (moolah == address(0)) revert ZeroAddressProvided(); MOOLAH = IMoolah(moolah); - RELAYER = relayer; - ORACLE = IOracle(oracle); WBNB = wbnb; - _disableInitializers(); } @@ -161,6 +156,8 @@ contract LendingBroker is * @param _pauser The address of the pauser * @param _rateCalculator The address of the rate calculator * @param _maxFixedLoanPositions The maximum number of fixed loan positions a user can have + * @param _relayer The address of the BrokerInterestRelayer contract + * @param _oracle The address of the oracle */ function initialize( address _admin, @@ -168,7 +165,9 @@ contract LendingBroker is address _bot, address _pauser, address _rateCalculator, - uint256 _maxFixedLoanPositions + uint256 _maxFixedLoanPositions, + address _relayer, + address _oracle ) public initializer { if ( _admin == address(0) || @@ -176,7 +175,9 @@ contract LendingBroker is _bot == address(0) || _pauser == address(0) || _rateCalculator == address(0) || - _maxFixedLoanPositions == 0 + _maxFixedLoanPositions == 0 || + _relayer == address(0) || + _oracle == address(0) ) revert ZeroAddressProvided(); __AccessControlEnumerable_init(); @@ -190,6 +191,8 @@ contract LendingBroker is // init state variables rateCalculator = _rateCalculator; maxFixedLoanPositions = _maxFixedLoanPositions; + RELAYER = _relayer; + ORACLE = IOracle(_oracle); } /////////////////////////////////////// @@ -1118,6 +1121,26 @@ contract LendingBroker is emit BorrowPaused(paused); } + /** + * @dev Set the relayer address + * @param _relayer The address of the BrokerInterestRelayer contract + */ + function setRelayer(address _relayer) external onlyRole(MANAGER) { + require(_relayer != address(0), "broker/zero-address-provided"); + require(RELAYER != _relayer, "broker/same-value-provided"); + RELAYER = _relayer; + } + + /** + * @dev Set the oracle address + * @param _oracle The address of the oracle + */ + function setOracle(address _oracle) external onlyRole(MANAGER) { + require(_oracle != address(0), "broker/zero-address-provided"); + require(address(ORACLE) != _oracle, "broker/same-value-provided"); + ORACLE = IOracle(_oracle); + } + /** * @dev pause contract */ diff --git a/test/broker/LendingBroker.t.sol b/test/broker/LendingBroker.t.sol index 6c9f2662..89c093fc 100644 --- a/test/broker/LendingBroker.t.sol +++ b/test/broker/LendingBroker.t.sol @@ -164,10 +164,20 @@ contract LendingBrokerTest is Test { rateCalc = RateCalculator(address(rcProxy)); // Deploy LendingBroker proxy first (used as oracle by the market) - LendingBroker bImpl = new LendingBroker(address(moolah), address(relayer), address(oracle), address(0)); + LendingBroker bImpl = new LendingBroker(address(moolah), address(0)); ERC1967Proxy bProxy = new ERC1967Proxy( address(bImpl), - abi.encodeWithSelector(LendingBroker.initialize.selector, ADMIN, MANAGER, BOT, PAUSER, address(rateCalc), 10) + abi.encodeWithSelector( + LendingBroker.initialize.selector, + ADMIN, + MANAGER, + BOT, + PAUSER, + address(rateCalc), + 10, + address(relayer), + address(oracle) + ) ); broker = LendingBroker(payable(address(bProxy))); @@ -1363,10 +1373,20 @@ contract LendingBrokerTest is Test { function test_marketIdSet_guard_reverts() public { // Deploy a second broker without setting market id - LendingBroker bImpl2 = new LendingBroker(address(moolah), address(vault), address(oracle), address(0)); + LendingBroker bImpl2 = new LendingBroker(address(moolah), address(0)); ERC1967Proxy bProxy2 = new ERC1967Proxy( address(bImpl2), - abi.encodeWithSelector(LendingBroker.initialize.selector, ADMIN, MANAGER, BOT, PAUSER, address(rateCalc), 10) + abi.encodeWithSelector( + LendingBroker.initialize.selector, + ADMIN, + MANAGER, + BOT, + PAUSER, + address(rateCalc), + 10, + address(relayer), + address(oracle) + ) ); LendingBroker broker2 = LendingBroker(payable(address(bProxy2))); vm.expectRevert(LendingBroker.MarketNotSet.selector); @@ -1381,10 +1401,20 @@ contract LendingBrokerTest is Test { } function test_liquidatorSetMarketWhitelist_whitelistsNewBroker() public { - LendingBroker bImpl2 = new LendingBroker(address(moolah), address(relayer), address(oracle), address(0)); + LendingBroker bImpl2 = new LendingBroker(address(moolah), address(0)); ERC1967Proxy bProxy2 = new ERC1967Proxy( address(bImpl2), - abi.encodeWithSelector(LendingBroker.initialize.selector, ADMIN, MANAGER, BOT, PAUSER, address(rateCalc), 10) + abi.encodeWithSelector( + LendingBroker.initialize.selector, + ADMIN, + MANAGER, + BOT, + PAUSER, + address(rateCalc), + 10, + address(relayer), + address(oracle) + ) ); LendingBroker newBroker = LendingBroker(payable(address(bProxy2))); @@ -1412,10 +1442,20 @@ contract LendingBrokerTest is Test { } function test_liquidatorBatchSetMarketWhitelist_whitelistsMultipleMarkets() public { - LendingBroker bImplA = new LendingBroker(address(moolah), address(relayer), address(oracle), address(0)); + LendingBroker bImplA = new LendingBroker(address(moolah), address(0)); ERC1967Proxy bProxyA = new ERC1967Proxy( address(bImplA), - abi.encodeWithSelector(LendingBroker.initialize.selector, ADMIN, MANAGER, BOT, PAUSER, address(rateCalc), 10) + abi.encodeWithSelector( + LendingBroker.initialize.selector, + ADMIN, + MANAGER, + BOT, + PAUSER, + address(rateCalc), + 10, + address(relayer), + address(oracle) + ) ); LendingBroker brokerA = LendingBroker(payable(address(bProxyA))); @@ -1431,10 +1471,20 @@ contract LendingBrokerTest is Test { vm.prank(MANAGER); brokerA.setMarketId(idA); - LendingBroker bImplB = new LendingBroker(address(moolah), address(relayer), address(oracle), address(0)); + LendingBroker bImplB = new LendingBroker(address(moolah), address(0)); ERC1967Proxy bProxyB = new ERC1967Proxy( address(bImplB), - abi.encodeWithSelector(LendingBroker.initialize.selector, ADMIN, MANAGER, BOT, PAUSER, address(rateCalc), 10) + abi.encodeWithSelector( + LendingBroker.initialize.selector, + ADMIN, + MANAGER, + BOT, + PAUSER, + address(rateCalc), + 10, + address(relayer), + address(oracle) + ) ); LendingBroker brokerB = LendingBroker(payable(address(bProxyB))); @@ -1703,6 +1753,62 @@ contract LendingBrokerTest is Test { vm.stopPrank(); } + // ============================================= + // setRelayer / setOracle tests + // ============================================= + + function test_setRelayer_success() public { + address newRelayer = makeAddr("newRelayer"); + vm.prank(MANAGER); + broker.setRelayer(newRelayer); + assertEq(broker.RELAYER(), newRelayer); + } + + function test_setRelayer_reverts_zeroAddress() public { + vm.prank(MANAGER); + vm.expectRevert(bytes("broker/zero-address-provided")); + broker.setRelayer(address(0)); + } + + function test_setRelayer_reverts_sameValue() public { + address currentRelayer = broker.RELAYER(); + vm.prank(MANAGER); + vm.expectRevert(bytes("broker/same-value-provided")); + broker.setRelayer(currentRelayer); + } + + function test_setRelayer_reverts_notManager() public { + vm.prank(borrower); + vm.expectRevert(); + broker.setRelayer(makeAddr("newRelayer")); + } + + function test_setOracle_success() public { + address newOracle = makeAddr("newOracle"); + vm.prank(MANAGER); + broker.setOracle(newOracle); + assertEq(address(broker.ORACLE()), newOracle); + } + + function test_setOracle_reverts_zeroAddress() public { + vm.prank(MANAGER); + vm.expectRevert(bytes("broker/zero-address-provided")); + broker.setOracle(address(0)); + } + + function test_setOracle_reverts_sameValue() public { + address currentOracle = address(broker.ORACLE()); + vm.prank(MANAGER); + vm.expectRevert(bytes("broker/same-value-provided")); + broker.setOracle(currentOracle); + } + + function test_setOracle_reverts_notManager() public { + vm.prank(borrower); + vm.expectRevert(); + broker.setOracle(makeAddr("newOracle")); + } + // ============================================= // Smart LP liquidation tests // ============================================= diff --git a/test/moolah/MarketFactoryTest.sol b/test/moolah/MarketFactoryTest.sol index 5e4d41fb..a43b5b61 100644 --- a/test/moolah/MarketFactoryTest.sol +++ b/test/moolah/MarketFactoryTest.sol @@ -495,7 +495,7 @@ contract MarketFactoryTest is Test { } function newLendingBroker(address replayer) private returns (LendingBroker) { - LendingBroker lendingBrokerImpl = new LendingBroker(address(moolah), replayer, address(oracle), address(0)); + LendingBroker lendingBrokerImpl = new LendingBroker(address(moolah), address(0)); ERC1967Proxy lendingBrokerProxy = new ERC1967Proxy( address(lendingBrokerImpl), abi.encodeWithSelector( @@ -505,7 +505,9 @@ contract MarketFactoryTest is Test { bot, pauser, address(rateCalculator), - 100 + 100, + replayer, + address(oracle) ) ); From bdf8793587278ed66ed1141de9e5b5f1bbfee144 Mon Sep 17 00:00:00 2001 From: yq <153907566+qingyang-lista@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:10:43 +0800 Subject: [PATCH 04/14] fix: preserve unpaid interest in deductFixedPositionDebt during partial liquidation When partial liquidation deducts both interest and principal from a fixed position, the old code unconditionally reset interestRepaid=0 and lastRepaidTime=now. This erased any unpaid interest from position tracking, causing getUserTotalDebt() to under-report debt. The fix introduces three branches: 1. All interest covered -> safe to reset (unchanged behavior) 2. Partial interest + formula can represent outstanding -> adjust interestRepaid precisely so outstanding = accruedInterest - paidInterest (exact) 3. Partial interest + formula too small (most principal repaid) -> set interestRepaid=0 without resetting lastRepaidTime to maximize preserved outstanding Co-Authored-By: Claude Opus 4.6 (1M context) --- src/broker/libraries/BrokerMath.sol | 25 +- test/broker/BrokerMathDeductFixed.t.sol | 332 ++++++++++++++++++++++++ 2 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 test/broker/BrokerMathDeductFixed.t.sol diff --git a/src/broker/libraries/BrokerMath.sol b/src/broker/libraries/BrokerMath.sol index c0ae5103..3b3b7b47 100644 --- a/src/broker/libraries/BrokerMath.sol +++ b/src/broker/libraries/BrokerMath.sol @@ -546,10 +546,27 @@ library BrokerMath { // update repaid principal amount principalToDeduct -= repayPrincipalAmt; p.principalRepaid += repayPrincipalAmt; - // reset repaid interest to zero (all accrued interest has been cleared) - p.interestRepaid = 0; - // reset repaid time to now - p.lastRepaidTime = block.timestamp; + + if (repayInterestAmt >= accruedInterest) { + // all accrued interest fully covered -> safe to reset tracking + p.interestRepaid = 0; + p.lastRepaidTime = block.timestamp; + } else { + // partial interest covered -> adjust interestRepaid to preserve outstanding + // After principalRepaid increased, getAccruedInterestForFixedPosition() recalculates + // with smaller (principal - principalRepaid), producing a lower total (newTotalAccrued). + // We set interestRepaid so that: newTotalAccrued - interestRepaid = unpaidInterest + uint256 unpaidInterest = accruedInterest - repayInterestAmt; + uint256 newTotalAccrued = getAccruedInterestForFixedPosition(p); + if (newTotalAccrued >= unpaidInterest) { + // exact: outstanding is perfectly preserved + p.interestRepaid = newTotalAccrued - unpaidInterest; + } else { + // edge case: most principal repaid, formula can't represent full outstanding + // preserve maximum possible: outstanding = newTotalAccrued (don't reset lastRepaidTime) + p.interestRepaid = 0; + } + } } return (interestToDeduct, principalToDeduct, p); diff --git a/test/broker/BrokerMathDeductFixed.t.sol b/test/broker/BrokerMathDeductFixed.t.sol new file mode 100644 index 00000000..72f0d7b6 --- /dev/null +++ b/test/broker/BrokerMathDeductFixed.t.sol @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import "forge-std/Test.sol"; +import { BrokerMath, RATE_SCALE } from "../../src/broker/libraries/BrokerMath.sol"; +import { FixedLoanPosition } from "../../src/broker/interfaces/IBroker.sol"; +import { UtilsLib } from "../../src/moolah/libraries/UtilsLib.sol"; + +/// @title Tests for BrokerMath.deductFixedPositionDebt +/// @notice Validates that partial liquidation preserves exact outstanding interest, +/// accounting for the reduced principal effect on the interest formula. +contract BrokerMathDeductFixedTest is Test { + uint256 constant DURATION = 365 days; + // 10% APR -> RATE_SCALE * 1.10 + uint256 constant APR = 110 * 1e25; + + uint256 startTs; + + function setUp() public { + vm.warp(1_000_000); + startTs = block.timestamp; + } + + function _makePosition(uint256 principal) internal view returns (FixedLoanPosition memory) { + return + FixedLoanPosition({ + posId: 1, + principal: principal, + apr: APR, + start: startTs, + end: startTs + DURATION, + lastRepaidTime: startTs, + interestRepaid: 0, + principalRepaid: 0 + }); + } + + /// @dev Helper: compute outstanding interest for a position + function _outstanding(FixedLoanPosition memory p) internal view returns (uint256) { + return BrokerMath.getAccruedInterestForFixedPosition(p) - p.interestRepaid; + } + + // ==================================================================== + // Core fix: partial liquidation preserves EXACT outstanding interest + // ==================================================================== + + /// @notice principal=100e18, interest~10e18, liquidation pays half interest + half principal. + /// Outstanding interest must be exactly (accruedInterest - paidInterest). + function test_partialLiquidation_preservesExactOutstanding() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 accruedInterest = _outstanding(pos); + assertApproxEqRel(accruedInterest, 10 ether, 1e15, "accrued ~10 ether"); + + uint256 interestBudget = accruedInterest / 2; + uint256 principalBudget = 50 ether; + + (uint256 interestLeft, uint256 principalLeft, FixedLoanPosition memory updated) = BrokerMath + .deductFixedPositionDebt(interestBudget, principalBudget, pos); + + // Budgets fully consumed + assertEq(interestLeft, 0, "interest budget consumed"); + assertEq(principalLeft, 0, "principal budget consumed"); + assertEq(updated.principalRepaid, principalBudget, "principalRepaid correct"); + + // Core invariant: outstanding = accruedInterest - paidInterest (exact) + uint256 expectedOutstanding = accruedInterest - interestBudget; + uint256 actualOutstanding = _outstanding(updated); + assertEq(actualOutstanding, expectedOutstanding, "outstanding interest must be exact"); + } + + // ==================================================================== + // Full interest payment resets correctly + // ==================================================================== + + function test_fullInterestPayment_resetsTracking() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 accruedInterest = _outstanding(pos); + + (, , FixedLoanPosition memory updated) = BrokerMath.deductFixedPositionDebt(accruedInterest, 30 ether, pos); + + assertEq(updated.interestRepaid, 0, "interestRepaid resets when all interest paid"); + assertEq(updated.lastRepaidTime, block.timestamp, "lastRepaidTime resets to now"); + assertEq(updated.principalRepaid, 30 ether, "principalRepaid correct"); + + // Outstanding should be 0 after full interest payment + assertEq(_outstanding(updated), 0, "no outstanding interest after full payment"); + } + + // ==================================================================== + // Interest only, no principal deduction + // ==================================================================== + + function test_interestOnlyPartial_preservesOutstanding() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 accruedInterest = _outstanding(pos); + uint256 interestBudget = accruedInterest / 3; + + (uint256 interestLeft, uint256 principalLeft, FixedLoanPosition memory updated) = BrokerMath + .deductFixedPositionDebt(interestBudget, 0, pos); + + assertEq(interestLeft, 0, "interest budget consumed"); + assertEq(principalLeft, 0, "no principal to deduct"); + assertEq(updated.interestRepaid, interestBudget, "partial interest tracked"); + assertEq(updated.lastRepaidTime, startTs, "lastRepaidTime unchanged"); + + uint256 expectedOutstanding = accruedInterest - interestBudget; + assertEq(_outstanding(updated), expectedOutstanding, "outstanding exact for interest-only"); + } + + // ==================================================================== + // Full principal with partial interest -> fallback (position filtered out) + // ==================================================================== + + function test_fullPrincipalPartialInterest_fallback() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 accruedInterest = _outstanding(pos); + uint256 interestBudget = accruedInterest / 4; + + (, , FixedLoanPosition memory updated) = BrokerMath.deductFixedPositionDebt(interestBudget, 100 ether, pos); + + assertEq(updated.principalRepaid, 100 ether, "full principal repaid"); + + // When principal is fully repaid, (principal - principalRepaid) = 0, + // so getAccruedInterestForFixedPosition returns 0 -> fallback case. + // outstanding = 0, but position would be filtered out anyway. + bool wouldBeFiltered = !(updated.principal > updated.principalRepaid); + assertTrue(wouldBeFiltered, "fully-repaid position filtered out in sortAndFilter"); + } + + // ==================================================================== + // Zero interest accrued (immediate liquidation) + // ==================================================================== + + function test_zeroInterest_principalOnly() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + // No time skip -> zero interest + + (uint256 interestLeft, uint256 principalLeft, FixedLoanPosition memory updated) = BrokerMath + .deductFixedPositionDebt(10 ether, 50 ether, pos); + + assertEq(interestLeft, 10 ether, "interest budget returned unused"); + assertEq(principalLeft, 0, "principal budget consumed"); + assertEq(updated.principalRepaid, 50 ether, "principal repaid"); + // 0 >= 0 -> reset happens (correct, no interest to lose) + assertEq(updated.interestRepaid, 0, "no interest to track"); + assertEq(updated.lastRepaidTime, block.timestamp, "reset fine when no interest"); + } + + // ==================================================================== + // Sequential partial liquidations preserve cumulative outstanding + // ==================================================================== + + function test_sequentialPartialLiquidations_preserveOutstanding() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 originalAccrued = _outstanding(pos); + + // First partial: 1/4 interest + 20 principal + uint256 firstInterest = originalAccrued / 4; + (, , FixedLoanPosition memory after1) = BrokerMath.deductFixedPositionDebt(firstInterest, 20 ether, pos); + + uint256 expectedAfter1 = originalAccrued - firstInterest; + assertEq(_outstanding(after1), expectedAfter1, "first: outstanding exact"); + assertEq(after1.principalRepaid, 20 ether, "first: principal tracked"); + + // Second partial: another chunk of interest + 30 principal + uint256 outstandingAfter1 = _outstanding(after1); + uint256 secondInterest = outstandingAfter1 / 3; + + (, , FixedLoanPosition memory after2) = BrokerMath.deductFixedPositionDebt(secondInterest, 30 ether, after1); + + uint256 expectedAfter2 = outstandingAfter1 - secondInterest; + // allow 1 wei tolerance due to Ceil rounding in interest formula + assertApproxEqAbs(_outstanding(after2), expectedAfter2, 1, "second: outstanding exact"); + assertEq(after2.principalRepaid, 50 ether, "second: cumulative principal"); + + // Outstanding still positive + assertGt(_outstanding(after2), 0, "interest still outstanding"); + } + + // ==================================================================== + // Over-payment: budget exceeds debt + // ==================================================================== + + function test_overPayment_cappedCorrectly() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 accruedInterest = _outstanding(pos); + + (uint256 interestLeft, uint256 principalLeft, FixedLoanPosition memory updated) = BrokerMath + .deductFixedPositionDebt(accruedInterest * 10, 500 ether, pos); + + assertApproxEqAbs(interestLeft, accruedInterest * 9, 1e15, "excess interest returned"); + assertEq(principalLeft, 400 ether, "excess principal returned"); + assertEq(updated.principalRepaid, 100 ether, "full principal repaid"); + // All interest paid -> reset + assertEq(updated.interestRepaid, 0, "reset after full interest payment"); + assertEq(updated.lastRepaidTime, block.timestamp, "reset lastRepaidTime"); + } + + // ==================================================================== + // Exact interest match triggers reset + // ==================================================================== + + function test_exactInterestMatch_triggersReset() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 accruedInterest = _outstanding(pos); + + (, , FixedLoanPosition memory updated) = BrokerMath.deductFixedPositionDebt(accruedInterest, 40 ether, pos); + + assertEq(updated.interestRepaid, 0, "reset on exact match"); + assertEq(updated.lastRepaidTime, block.timestamp, "reset time on exact match"); + assertEq(updated.principalRepaid, 40 ether, "principal deducted"); + assertEq(_outstanding(updated), 0, "no outstanding after exact match"); + } + + // ==================================================================== + // 1 wei short of full interest -> no reset, exact outstanding + // ==================================================================== + + function test_oneWeiShort_preservesExactOutstanding() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 accruedInterest = _outstanding(pos); + require(accruedInterest > 1, "need non-trivial interest"); + + uint256 interestBudget = accruedInterest - 1; + + (, , FixedLoanPosition memory updated) = BrokerMath.deductFixedPositionDebt(interestBudget, 50 ether, pos); + + // 1 wei unpaid -> must preserve exactly + assertEq(_outstanding(updated), 1, "exactly 1 wei outstanding preserved"); + } + + // ==================================================================== + // Zero interest budget with principal deduction -> maximize preserved + // ==================================================================== + + function test_zeroInterestBudget_principalOnly_maximizesOutstanding() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 accruedInterest = _outstanding(pos); + assertGt(accruedInterest, 0, "should have accrued interest"); + + (uint256 interestLeft, uint256 principalLeft, FixedLoanPosition memory updated) = BrokerMath + .deductFixedPositionDebt(0, 50 ether, pos); + + assertEq(interestLeft, 0, "no interest budget to return"); + assertEq(principalLeft, 0, "principal consumed"); + assertEq(updated.principalRepaid, 50 ether, "principal repaid"); + + // newTotalAccrued = (100-50)*10%*1year = 5e18, unpaidInterest = 10e18 + // newTotalAccrued < unpaidInterest -> fallback: outstanding = newTotalAccrued + // This is the maximum the formula can represent (better than reset which gives 0) + uint256 newTotalAccrued = BrokerMath.getAccruedInterestForFixedPosition(updated); + assertEq(_outstanding(updated), newTotalAccrued, "fallback preserves maximum possible"); + assertGt(_outstanding(updated), 0, "outstanding > 0 (not reset to zero)"); + // Verify: lastRepaidTime was NOT reset (preserves historical accrual) + assertEq(updated.lastRepaidTime, startTs, "lastRepaidTime not reset in fallback"); + } + + // ==================================================================== + // Small principal + large interest -> fallback maximizes preserved + // ==================================================================== + + function test_smallPrincipal_largeInterest_maximizesOutstanding() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION); + + uint256 accruedInterest = _outstanding(pos); + + // Tiny interest budget + small principal + uint256 interestBudget = 1; + uint256 principalBudget = 5 ether; + + (, , FixedLoanPosition memory updated) = BrokerMath.deductFixedPositionDebt(interestBudget, principalBudget, pos); + + // newTotalAccrued = (100-5)*10%*1year = 9.5e18, unpaidInterest ~= 10e18 + // newTotalAccrued < unpaidInterest -> fallback: maximize outstanding + uint256 newTotalAccrued = BrokerMath.getAccruedInterestForFixedPosition(updated); + assertEq(_outstanding(updated), newTotalAccrued, "fallback preserves max possible"); + assertGt(_outstanding(updated), 0, "outstanding > 0"); + // Verify: better than old code which would give outstanding = 0 + assertGt(_outstanding(updated), accruedInterest / 2, "preserves majority of interest"); + } + + // ==================================================================== + // After reset, new interest accrues correctly + // ==================================================================== + + function test_resetThenNewAccrual_worksCorrectly() public { + FixedLoanPosition memory pos = _makePosition(100 ether); + skip(DURATION / 2); + + uint256 accrued1 = _outstanding(pos); + + // Pay all interest + 20 principal -> triggers reset + (, , FixedLoanPosition memory after1) = BrokerMath.deductFixedPositionDebt(accrued1, 20 ether, pos); + + assertEq(after1.interestRepaid, 0, "reset after full interest"); + assertEq(after1.lastRepaidTime, block.timestamp, "lastRepaidTime reset"); + assertEq(_outstanding(after1), 0, "no outstanding after reset"); + + // More time passes -> new interest accrues on reduced principal + skip(DURATION / 2); + + uint256 accrued2 = _outstanding(after1); + assertGt(accrued2, 0, "new interest accrued after reset"); + + // Partial interest + 30 principal -> should NOT reset, preserve exact + uint256 partialInterest = accrued2 / 2; + (, , FixedLoanPosition memory after2) = BrokerMath.deductFixedPositionDebt(partialInterest, 30 ether, after1); + + uint256 expectedOutstanding = accrued2 - partialInterest; + assertEq(_outstanding(after2), expectedOutstanding, "exact after second partial"); + assertEq(after2.principalRepaid, 50 ether, "cumulative 50 principal"); + } +} From c948a8538d65e0c1fe6a8fde0b30bbe42ff09683 Mon Sep 17 00:00:00 2001 From: Razorback Date: Fri, 3 Apr 2026 16:00:02 +0800 Subject: [PATCH 05/14] fix: setter should be one-time migration --- src/broker/LendingBroker.sol | 12 ++--- test/broker/LendingBroker.t.sol | 92 ++++++++++++++++++++++----------- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/src/broker/LendingBroker.sol b/src/broker/LendingBroker.sol index cf9be9c0..d3703a73 100644 --- a/src/broker/LendingBroker.sol +++ b/src/broker/LendingBroker.sol @@ -1122,22 +1122,22 @@ contract LendingBroker is } /** - * @dev Set the relayer address + * @dev Set the relayer address (one-time migration from immutable to storage) * @param _relayer The address of the BrokerInterestRelayer contract */ - function setRelayer(address _relayer) external onlyRole(MANAGER) { + function setRelayer(address _relayer) external onlyRole(DEFAULT_ADMIN_ROLE) { require(_relayer != address(0), "broker/zero-address-provided"); - require(RELAYER != _relayer, "broker/same-value-provided"); + require(RELAYER == address(0), "broker/already-set"); RELAYER = _relayer; } /** - * @dev Set the oracle address + * @dev Set the oracle address (one-time migration from immutable to storage) * @param _oracle The address of the oracle */ - function setOracle(address _oracle) external onlyRole(MANAGER) { + function setOracle(address _oracle) external onlyRole(DEFAULT_ADMIN_ROLE) { require(_oracle != address(0), "broker/zero-address-provided"); - require(address(ORACLE) != _oracle, "broker/same-value-provided"); + require(address(ORACLE) == address(0), "broker/already-set"); ORACLE = IOracle(_oracle); } diff --git a/test/broker/LendingBroker.t.sol b/test/broker/LendingBroker.t.sol index 89c093fc..7995ffd4 100644 --- a/test/broker/LendingBroker.t.sol +++ b/test/broker/LendingBroker.t.sol @@ -1754,59 +1754,93 @@ contract LendingBrokerTest is Test { } // ============================================= - // setRelayer / setOracle tests + // setRelayer / setOracle tests (one-time, admin-only) // ============================================= + /// @dev Deploy a fresh broker proxy with RELAYER/ORACLE unset (simulating V1->V2 upgrade) + function _deployBrokerWithEmptyRelayerOracle() internal returns (LendingBroker) { + LendingBroker bImpl = new LendingBroker(address(moolah)); + // Use 6-param initialize (no relayer/oracle) by encoding only the original params + // and leaving RELAYER/ORACLE as address(0) + ERC1967Proxy bProxy = new ERC1967Proxy( + address(bImpl), + abi.encodeWithSelector( + LendingBroker.initialize.selector, + ADMIN, + MANAGER, + BOT, + PAUSER, + address(rateCalc), + 10, + address(1), // placeholder relayer — will be overwritten below + address(1) // placeholder oracle — will be overwritten below + ) + ); + LendingBroker b = LendingBroker(payable(address(bProxy))); + // Simulate V1->V2 upgrade: storage RELAYER/ORACLE are zeroed out + vm.store(address(b), bytes32(uint256(18)), bytes32(0)); // RELAYER slot + vm.store(address(b), bytes32(uint256(19)), bytes32(0)); // ORACLE slot + return b; + } + function test_setRelayer_success() public { - address newRelayer = makeAddr("newRelayer"); - vm.prank(MANAGER); - broker.setRelayer(newRelayer); - assertEq(broker.RELAYER(), newRelayer); + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + assertEq(b.RELAYER(), address(0)); + + vm.prank(ADMIN); + b.setRelayer(address(relayer)); + assertEq(b.RELAYER(), address(relayer)); } function test_setRelayer_reverts_zeroAddress() public { - vm.prank(MANAGER); + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + vm.prank(ADMIN); vm.expectRevert(bytes("broker/zero-address-provided")); - broker.setRelayer(address(0)); + b.setRelayer(address(0)); } - function test_setRelayer_reverts_sameValue() public { - address currentRelayer = broker.RELAYER(); - vm.prank(MANAGER); - vm.expectRevert(bytes("broker/same-value-provided")); - broker.setRelayer(currentRelayer); + function test_setRelayer_reverts_alreadySet() public { + // broker from setUp already has RELAYER set via initialize + vm.prank(ADMIN); + vm.expectRevert(bytes("broker/already-set")); + broker.setRelayer(makeAddr("newRelayer")); } - function test_setRelayer_reverts_notManager() public { - vm.prank(borrower); + function test_setRelayer_reverts_notAdmin() public { + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + vm.prank(MANAGER); vm.expectRevert(); - broker.setRelayer(makeAddr("newRelayer")); + b.setRelayer(address(relayer)); } function test_setOracle_success() public { - address newOracle = makeAddr("newOracle"); - vm.prank(MANAGER); - broker.setOracle(newOracle); - assertEq(address(broker.ORACLE()), newOracle); + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + assertEq(address(b.ORACLE()), address(0)); + + vm.prank(ADMIN); + b.setOracle(address(oracle)); + assertEq(address(b.ORACLE()), address(oracle)); } function test_setOracle_reverts_zeroAddress() public { - vm.prank(MANAGER); + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + vm.prank(ADMIN); vm.expectRevert(bytes("broker/zero-address-provided")); - broker.setOracle(address(0)); + b.setOracle(address(0)); } - function test_setOracle_reverts_sameValue() public { - address currentOracle = address(broker.ORACLE()); - vm.prank(MANAGER); - vm.expectRevert(bytes("broker/same-value-provided")); - broker.setOracle(currentOracle); + function test_setOracle_reverts_alreadySet() public { + // broker from setUp already has ORACLE set via initialize + vm.prank(ADMIN); + vm.expectRevert(bytes("broker/already-set")); + broker.setOracle(makeAddr("newOracle")); } - function test_setOracle_reverts_notManager() public { - vm.prank(borrower); + function test_setOracle_reverts_notAdmin() public { + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + vm.prank(MANAGER); vm.expectRevert(); - broker.setOracle(makeAddr("newOracle")); + b.setOracle(address(oracle)); } // ============================================= From 7486b7fa8443b0cae01df5f5451be3b057e73650 Mon Sep 17 00:00:00 2001 From: Rick Date: Thu, 9 Apr 2026 12:04:51 +0800 Subject: [PATCH 06/14] fix: address audit findings for BrokerLiquidator and LendingBroker - Add RelayerSet/OracleSet event emissions in LendingBroker migration setters (#7) - Rename withdrawERC20 to withdraw with native BNB support in BrokerLiquidator (#1) - Add NatSpec documenting custody requirement for redeemSmartCollateral (#2) --- src/broker/LendingBroker.sol | 2 ++ src/broker/interfaces/IBroker.sol | 2 ++ src/liquidator/BrokerLiquidator.sol | 15 +++++++++++---- src/liquidator/IBrokerLiquidator.sol | 2 +- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/broker/LendingBroker.sol b/src/broker/LendingBroker.sol index d3703a73..2e4497aa 100644 --- a/src/broker/LendingBroker.sol +++ b/src/broker/LendingBroker.sol @@ -1129,6 +1129,7 @@ contract LendingBroker is require(_relayer != address(0), "broker/zero-address-provided"); require(RELAYER == address(0), "broker/already-set"); RELAYER = _relayer; + emit RelayerSet(_relayer); } /** @@ -1139,6 +1140,7 @@ contract LendingBroker is require(_oracle != address(0), "broker/zero-address-provided"); require(address(ORACLE) == address(0), "broker/already-set"); ORACLE = IOracle(_oracle); + emit OracleSet(_oracle); } /** diff --git a/src/broker/interfaces/IBroker.sol b/src/broker/interfaces/IBroker.sol index 4f586e96..2978a980 100644 --- a/src/broker/interfaces/IBroker.sol +++ b/src/broker/interfaces/IBroker.sol @@ -106,6 +106,8 @@ interface IBroker is IBrokerBase { event Liquidated(address indexed user, uint256 principalCleared, uint256 interestCleared); event MarketIdSet(Id marketId); event BorrowPaused(bool paused); + event RelayerSet(address indexed relayer); + event OracleSet(address indexed oracle); event AddedLiquidationWhitelist(address indexed account); event RemovedLiquidationWhitelist(address indexed account); event EmergencyWithdrawn(address indexed sender, address indexed token, uint256 amount); diff --git a/src/liquidator/BrokerLiquidator.sol b/src/liquidator/BrokerLiquidator.sol index 28d34247..0983ce7b 100644 --- a/src/liquidator/BrokerLiquidator.sol +++ b/src/liquidator/BrokerLiquidator.sol @@ -81,11 +81,16 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL receive() external payable {} - /// @dev withdraws ERC20 tokens. - /// @param token The address of the token. + /// @dev withdraws ERC20 tokens or native BNB from the contract. + /// @param token The address of the token. Use BNB_ADDRESS (0xEeee...EEeE) for native BNB. /// @param amount The amount to withdraw. - function withdrawERC20(address token, uint256 amount) external onlyRole(MANAGER) { - SafeTransferLib.safeTransfer(token, msg.sender, amount); + function withdraw(address token, uint256 amount) external onlyRole(MANAGER) { + if (token == BNB_ADDRESS) { + (bool success, ) = msg.sender.call{ value: amount }(""); + require(success, "broker-liquidator/native-transfer-failed"); + } else { + SafeTransferLib.safeTransfer(token, msg.sender, amount); + } } /// @dev sets the token whitelist. @@ -452,6 +457,8 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL /// @dev redeems smart collateral LP tokens. /// @param smartProvider The address of the smart collateral provider. /// @param lpAmount The amount of LP collateral tokens to redeem. + /// @notice Redeems LP collateral that is already held by this contract (seized during a prior liquidation step). + /// The SmartProvider burns LP tokens from msg.sender (this contract), so the LP must already be in custody. /// @param minToken0Amt The minimum amount of token0 to receive. /// @param minToken1Amt The minimum amount of token1 to receive. /// @return The amount of token0 and token1 redeemed. diff --git a/src/liquidator/IBrokerLiquidator.sol b/src/liquidator/IBrokerLiquidator.sol index 0b29cba6..d2643a16 100644 --- a/src/liquidator/IBrokerLiquidator.sol +++ b/src/liquidator/IBrokerLiquidator.sol @@ -18,7 +18,7 @@ interface IBrokerLiquidator { bytes swapToken0Data; bytes swapToken1Data; } - function withdrawERC20(address token, uint256 amount) external; + function withdraw(address token, uint256 amount) external; function flashLiquidate( bytes32 id, address borrower, From e6fc4bbe24654a3ed6c5e61d6a8680f4e811438a Mon Sep 17 00:00:00 2001 From: Rick Date: Fri, 10 Apr 2026 10:21:07 +0800 Subject: [PATCH 07/14] fix: return actual liquidation values in smart collateral liquidation functions - liquidateSmartCollateral: return actual seized (collAmount) and repaid (loanToken balance diff) instead of placeholder values - flashLiquidateSmartCollateral: capture repaidAssets from onMoolahLiquidate callback via _lastRepaidAssets storage --- src/liquidator/BrokerLiquidator.sol | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/liquidator/BrokerLiquidator.sol b/src/liquidator/BrokerLiquidator.sol index 0983ce7b..dd01ea1e 100644 --- a/src/liquidator/BrokerLiquidator.sol +++ b/src/liquidator/BrokerLiquidator.sol @@ -36,6 +36,8 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL mapping(address => bytes32) public brokerToMarketId; // @dev smart collateral provider whitelist mapping(address => bool) public smartProviders; + /// @dev transient storage for repaidAssets from onMoolahLiquidate callback + uint256 internal _lastRepaidAssets; bytes32 public constant MANAGER = keccak256("MANAGER"); // manager role bytes32 public constant BOT = keccak256("BOT"); // manager role @@ -306,6 +308,7 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL address lpToken = ISmartProvider(smartProvider).dexLP(); uint256 collBalanceBefore = IERC20(params.collateralToken).balanceOf(address(this)); + uint256 loanBalanceBefore = IERC20(params.loanToken).balanceOf(address(this)); (uint256 minAmount0, uint256 minAmount1) = abi.decode(payload, (uint256, uint256)); IBrokerBase(broker).liquidate( params, @@ -333,6 +336,7 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL ) ); uint256 collAmount = IERC20(params.collateralToken).balanceOf(address(this)) - collBalanceBefore; + uint256 repaidAssets = loanBalanceBefore - IERC20(params.loanToken).balanceOf(address(this)); require(collAmount > 0, "No collateral seized"); (uint256 amount0, uint256 amount1) = ISmartProvider(smartProvider).redeemLpCollateral( @@ -342,7 +346,8 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL ); emit SmartLiquidation(id, lpToken, params.collateralToken, collAmount, minAmount0, minAmount1, amount0, amount1); - return (seizedAssets, 0); + _lastRepaidAssets = 0; + return (collAmount, repaidAssets); } /// @dev flash liquidates a position with smart collateral. @@ -395,7 +400,9 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL ); IBrokerBase(broker).liquidate(params, borrower, seizedAssets, 0, abi.encode(callback)); - return (seizedAssets, 0); + uint256 repaidAssets = _lastRepaidAssets; + _lastRepaidAssets = 0; + return (seizedAssets, repaidAssets); } /// @dev the function will be called by the the Broker, when Broker's onMoolahLiquidate is called by Moolah. @@ -451,6 +458,7 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL if (token1 != BNB_ADDRESS) SafeTransferLib.safeApprove(token1, arb.token1Pair, 0); } + _lastRepaidAssets = repaidAssets; SafeTransferLib.safeApprove(arb.loanToken, msg.sender, repaidAssets); } From 7f72aeca7d007415b0efe67935fb3d6de9650ea8 Mon Sep 17 00:00:00 2001 From: Rick Date: Fri, 10 Apr 2026 14:10:15 +0800 Subject: [PATCH 08/14] fix: split withdraw into withdrawERC20 and withdrawETH in BrokerLiquidator --- src/liquidator/BrokerLiquidator.sol | 18 +++++++++--------- src/liquidator/IBrokerLiquidator.sol | 3 ++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/liquidator/BrokerLiquidator.sol b/src/liquidator/BrokerLiquidator.sol index dd01ea1e..26858c8b 100644 --- a/src/liquidator/BrokerLiquidator.sol +++ b/src/liquidator/BrokerLiquidator.sol @@ -83,16 +83,16 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL receive() external payable {} - /// @dev withdraws ERC20 tokens or native BNB from the contract. - /// @param token The address of the token. Use BNB_ADDRESS (0xEeee...EEeE) for native BNB. + /// @dev withdraws ERC20 tokens. + /// @param token The address of the token. /// @param amount The amount to withdraw. - function withdraw(address token, uint256 amount) external onlyRole(MANAGER) { - if (token == BNB_ADDRESS) { - (bool success, ) = msg.sender.call{ value: amount }(""); - require(success, "broker-liquidator/native-transfer-failed"); - } else { - SafeTransferLib.safeTransfer(token, msg.sender, amount); - } + function withdrawERC20(address token, uint256 amount) external onlyRole(MANAGER) { + SafeTransferLib.safeTransfer(token, msg.sender, amount); + } + /// @dev withdraws ETH. + /// @param amount The amount to withdraw. + function withdrawETH(uint256 amount) external onlyRole(MANAGER) { + SafeTransferLib.safeTransferETH(msg.sender, amount); } /// @dev sets the token whitelist. diff --git a/src/liquidator/IBrokerLiquidator.sol b/src/liquidator/IBrokerLiquidator.sol index d2643a16..4460fc83 100644 --- a/src/liquidator/IBrokerLiquidator.sol +++ b/src/liquidator/IBrokerLiquidator.sol @@ -18,7 +18,8 @@ interface IBrokerLiquidator { bytes swapToken0Data; bytes swapToken1Data; } - function withdraw(address token, uint256 amount) external; + function withdrawERC20(address token, uint256 amount) external; + function withdrawETH(uint256 amount) external; function flashLiquidate( bytes32 id, address borrower, From f66cc8c71ccfb3eff04341375b699bdf414410e3 Mon Sep 17 00:00:00 2001 From: Rick Date: Thu, 16 Apr 2026 11:42:02 +0800 Subject: [PATCH 09/14] fix: simplify interest reset in deductFixedPositionDebt during liquidation Remove partial interest tracking logic and always reset interestRepaid and lastRepaidTime after liquidation deduction. --- src/broker/libraries/BrokerMath.sol | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/src/broker/libraries/BrokerMath.sol b/src/broker/libraries/BrokerMath.sol index 3b3b7b47..c0ae5103 100644 --- a/src/broker/libraries/BrokerMath.sol +++ b/src/broker/libraries/BrokerMath.sol @@ -546,27 +546,10 @@ library BrokerMath { // update repaid principal amount principalToDeduct -= repayPrincipalAmt; p.principalRepaid += repayPrincipalAmt; - - if (repayInterestAmt >= accruedInterest) { - // all accrued interest fully covered -> safe to reset tracking - p.interestRepaid = 0; - p.lastRepaidTime = block.timestamp; - } else { - // partial interest covered -> adjust interestRepaid to preserve outstanding - // After principalRepaid increased, getAccruedInterestForFixedPosition() recalculates - // with smaller (principal - principalRepaid), producing a lower total (newTotalAccrued). - // We set interestRepaid so that: newTotalAccrued - interestRepaid = unpaidInterest - uint256 unpaidInterest = accruedInterest - repayInterestAmt; - uint256 newTotalAccrued = getAccruedInterestForFixedPosition(p); - if (newTotalAccrued >= unpaidInterest) { - // exact: outstanding is perfectly preserved - p.interestRepaid = newTotalAccrued - unpaidInterest; - } else { - // edge case: most principal repaid, formula can't represent full outstanding - // preserve maximum possible: outstanding = newTotalAccrued (don't reset lastRepaidTime) - p.interestRepaid = 0; - } - } + // reset repaid interest to zero (all accrued interest has been cleared) + p.interestRepaid = 0; + // reset repaid time to now + p.lastRepaidTime = block.timestamp; } return (interestToDeduct, principalToDeduct, p); From f4a2e4a1322475257d5f0138d63e31f2c1268559 Mon Sep 17 00:00:00 2001 From: Rick Date: Thu, 16 Apr 2026 12:02:58 +0800 Subject: [PATCH 10/14] fix: convertDynamicToFixed prioritizes interest then principal deduction Amount now clears interest first, then principal. New fixed position principal <= amount. Also fix constructor args after rebase and add exact-full and excess-capped conversion tests. --- script/broker/deploy_broker_20260408.s.sol | 2 +- src/broker/LendingBroker.sol | 15 +-- test/broker/LendingBroker.t.sol | 117 +++++++++++++++++---- test/utils/PositionManager.t.sol | 42 ++++++-- 4 files changed, 139 insertions(+), 37 deletions(-) diff --git a/script/broker/deploy_broker_20260408.s.sol b/script/broker/deploy_broker_20260408.s.sol index 168829dd..789c7c06 100644 --- a/script/broker/deploy_broker_20260408.s.sol +++ b/script/broker/deploy_broker_20260408.s.sol @@ -55,7 +55,7 @@ contract DeployXautBrokers is DeployBase { function _deployBroker(string memory label, address relayer, address oracle, address deployer) internal { // Deploy implementation - LendingBroker impl = new LendingBroker(MOOLAH, relayer, oracle, address(0)); + LendingBroker impl = new LendingBroker(MOOLAH, address(0)); console.log(string.concat("LendingBroker(", label, ") impl: "), address(impl)); // Deploy proxy diff --git a/src/broker/LendingBroker.sol b/src/broker/LendingBroker.sol index 2e4497aa..128eaa3e 100644 --- a/src/broker/LendingBroker.sol +++ b/src/broker/LendingBroker.sol @@ -439,15 +439,16 @@ contract LendingBroker is address user = msg.sender; DynamicLoanPosition storage position = dynamicLoanPositions[user]; if (fixedLoanPositions[user].length >= maxFixedLoanPositions) revert ExceedMaxFixedPositions(); - // cap amount by principal - amount = UtilsLib.min(amount, position.principal); // accrue current rate so normalized debt reflects the latest interest uint256 rate = IRateCalculator(rateCalculator).accrueRate(address(this)); uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(position.normalizedDebt, rate); uint256 totalInterest = actualDebt.zeroFloorSub(position.principal); - // force user to repay interest portion when converting to fixed - uint256 interestToRepay = BrokerMath.mulDivCeiling(amount, totalInterest, position.principal); + // prioritize clearing interest first, then principal + uint256 interestToRepay = UtilsLib.min(amount, totalInterest); + uint256 principalToMove = UtilsLib.min(amount - interestToRepay, position.principal); + amount = interestToRepay + principalToMove; + if (interestToRepay > 0) { // borrow from Moolah to increase user's actual debt at moolah _borrowFromMoolah(user, interestToRepay); @@ -456,9 +457,9 @@ contract LendingBroker is } position.normalizedDebt = position.normalizedDebt.zeroFloorSub( - BrokerMath.normalizeBorrowAmount(amount + interestToRepay, rate, false) + BrokerMath.normalizeBorrowAmount(amount, rate, false) ); - position.principal -= amount; + position.principal -= principalToMove; if (position.principal == 0) { delete dynamicLoanPositions[user]; @@ -473,7 +474,7 @@ contract LendingBroker is fixedLoanPositions[user].push( FixedLoanPosition({ posId: fixedPosUuid, - principal: amount + interestToRepay, + principal: amount, apr: term.apr, start: start, end: end, diff --git a/test/broker/LendingBroker.t.sol b/test/broker/LendingBroker.t.sol index 7995ffd4..95ed54b3 100644 --- a/test/broker/LendingBroker.t.sol +++ b/test/broker/LendingBroker.t.sol @@ -181,10 +181,20 @@ contract LendingBrokerTest is Test { ); broker = LendingBroker(payable(address(bProxy))); - LendingBroker bnbImpl = new LendingBroker(address(moolah), address(bnbRelayer), address(oracle), address(WBNB)); + LendingBroker bnbImpl = new LendingBroker(address(moolah), address(WBNB)); ERC1967Proxy bnbProxy = new ERC1967Proxy( address(bnbImpl), - abi.encodeWithSelector(LendingBroker.initialize.selector, ADMIN, MANAGER, BOT, PAUSER, address(rateCalc), 10) + abi.encodeWithSelector( + LendingBroker.initialize.selector, + ADMIN, + MANAGER, + BOT, + PAUSER, + address(rateCalc), + 10, + address(bnbRelayer), + address(oracle) + ) ); bnbBroker = LendingBroker(payable(address(bnbProxy))); @@ -605,15 +615,11 @@ contract LendingBrokerTest is Test { uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(normalizedBefore, rate); uint256 outstandingInterest = actualDebt > principalBefore ? actualDebt - principalBefore : 0; + // amount > interest => interest fully cleared, remainder moves principal uint256 convertAmount = 400 ether; - uint256 expectedInterestShare = outstandingInterest == 0 - ? 0 - : BrokerMath.mulDivCeiling(outstandingInterest, convertAmount, principalBefore); - uint256 expectedNormalizedDelta = BrokerMath.normalizeBorrowAmount( - convertAmount + expectedInterestShare, - rate, - true - ); + uint256 expectedInterest = outstandingInterest < convertAmount ? outstandingInterest : convertAmount; + uint256 expectedPrincipalMove = convertAmount - expectedInterest; + uint256 expectedNormalizedDelta = BrokerMath.normalizeBorrowAmount(convertAmount, rate, true); uint256 expectedNormalizedAfter = normalizedBefore > expectedNormalizedDelta ? normalizedBefore - expectedNormalizedDelta : 0; @@ -622,12 +628,12 @@ contract LendingBrokerTest is Test { broker.convertDynamicToFixed(convertAmount, 51); (uint256 principalAfter, uint256 normalizedAfter) = broker.dynamicLoanPositions(borrower); - assertEq(principalAfter, principalBefore - convertAmount, "dynamic principal not reduced by amount"); + assertEq(principalAfter, principalBefore - expectedPrincipalMove, "dynamic principal not reduced correctly"); assertApproxEqAbs(normalizedAfter, expectedNormalizedAfter, 1, "normalized debt delta mismatch"); FixedLoanPosition[] memory fixedPositions = broker.userFixedPositions(borrower); assertEq(fixedPositions.length, 1, "fixed position not created"); - assertEq(fixedPositions[0].principal, convertAmount + expectedInterestShare, "converted fixed principal incorrect"); + assertEq(fixedPositions[0].principal, convertAmount, "fixed principal should equal amount"); assertEq(fixedPositions[0].interestRepaid, 0); assertEq(fixedPositions[0].principalRepaid, 0); } @@ -651,10 +657,10 @@ contract LendingBrokerTest is Test { uint256 rate = rateCalc.accrueRate(address(broker)); uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(normalizedBefore, rate); uint256 outstandingInterest = actualDebt > principalBefore ? actualDebt - principalBefore : 0; - uint256 expectedNormalizedDelta = BrokerMath.normalizeBorrowAmount(actualDebt, rate, true); + // pass amount > actualDebt => capped to interest + principal vm.prank(borrower); - broker.convertDynamicToFixed(principalBefore, 52); + broker.convertDynamicToFixed(actualDebt + 100 ether, 52); (uint256 principalAfter, uint256 normalizedAfter) = broker.dynamicLoanPositions(borrower); assertApproxEqAbs(principalAfter, 0, 1, "dynamic principal should be cleared"); @@ -662,15 +668,80 @@ contract LendingBrokerTest is Test { FixedLoanPosition[] memory fixedPositions = broker.userFixedPositions(borrower); assertEq(fixedPositions.length, 1); - assertApproxEqAbs( - fixedPositions[0].principal, - principalBefore + outstandingInterest, - 1, - "fixed principal should equal full outstanding debt" - ); + uint256 expectedFixed = outstandingInterest + principalBefore; + assertApproxEqAbs(fixedPositions[0].principal, expectedFixed, 1, "fixed principal should equal full debt"); + } + + function test_convertDynamicToFixed_exactFullAmount() public { + FixedTermAndRate memory term = FixedTermAndRate({ termId: 53, duration: 60 days, apr: 105 * 1e25 }); + vm.prank(BOT); + broker.updateFixedTermAndRate(term, false); + + uint256 borrowAmt = 500 ether; + vm.prank(borrower); + broker.borrow(borrowAmt); + + vm.prank(MANAGER); + rateCalc.setMaxRatePerSecond(address(broker), RATE_SCALE + 5); + vm.prank(BOT); + rateCalc.setRatePerSecond(address(broker), RATE_SCALE + 3); + skip(4 days); - // sanity: normalized delta consumed the whole normalized debt (allowing rounding wiggle) - assertApproxEqAbs(expectedNormalizedDelta, normalizedBefore, 1, "normalized debt delta rounding"); + (uint256 principalBefore, uint256 normalizedBefore) = broker.dynamicLoanPositions(borrower); + uint256 rate = rateCalc.accrueRate(address(broker)); + uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(normalizedBefore, rate); + uint256 outstandingInterest = actualDebt > principalBefore ? actualDebt - principalBefore : 0; + + // amount == interest + principal exactly + uint256 convertAmount = outstandingInterest + principalBefore; + + vm.prank(borrower); + broker.convertDynamicToFixed(convertAmount, 53); + + (uint256 principalAfter, uint256 normalizedAfter) = broker.dynamicLoanPositions(borrower); + assertApproxEqAbs(principalAfter, 0, 1, "dynamic principal should be cleared"); + assertApproxEqAbs(normalizedAfter, 0, 1, "dynamic normalized debt should be cleared"); + + FixedLoanPosition[] memory fixedPositions = broker.userFixedPositions(borrower); + assertEq(fixedPositions.length, 1); + assertEq(fixedPositions[0].principal, convertAmount, "fixed principal should equal amount"); + } + + function test_convertDynamicToFixed_excessAmountCapped() public { + FixedTermAndRate memory term = FixedTermAndRate({ termId: 54, duration: 30 days, apr: 105 * 1e25 }); + vm.prank(BOT); + broker.updateFixedTermAndRate(term, false); + + uint256 borrowAmt = 600 ether; + vm.prank(borrower); + broker.borrow(borrowAmt); + + vm.prank(MANAGER); + rateCalc.setMaxRatePerSecond(address(broker), RATE_SCALE + 5); + vm.prank(BOT); + rateCalc.setRatePerSecond(address(broker), RATE_SCALE + 3); + skip(3 days); + + (uint256 principalBefore, uint256 normalizedBefore) = broker.dynamicLoanPositions(borrower); + uint256 rate = rateCalc.accrueRate(address(broker)); + uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(normalizedBefore, rate); + uint256 outstandingInterest = actualDebt > principalBefore ? actualDebt - principalBefore : 0; + + // amount much larger than actualDebt => should be capped + uint256 convertAmount = actualDebt + 999 ether; + + vm.prank(borrower); + broker.convertDynamicToFixed(convertAmount, 54); + + (uint256 principalAfter, uint256 normalizedAfter) = broker.dynamicLoanPositions(borrower); + assertApproxEqAbs(principalAfter, 0, 1, "dynamic principal should be cleared"); + assertApproxEqAbs(normalizedAfter, 0, 1, "dynamic normalized debt should be cleared"); + + FixedLoanPosition[] memory fixedPositions = broker.userFixedPositions(borrower); + assertEq(fixedPositions.length, 1); + uint256 expectedFixed = outstandingInterest + principalBefore; + assertApproxEqAbs(fixedPositions[0].principal, expectedFixed, 1, "fixed principal capped to actual debt"); + assertLe(fixedPositions[0].principal, convertAmount, "fixed principal must be <= amount"); } // ----------------------------- @@ -1759,7 +1830,7 @@ contract LendingBrokerTest is Test { /// @dev Deploy a fresh broker proxy with RELAYER/ORACLE unset (simulating V1->V2 upgrade) function _deployBrokerWithEmptyRelayerOracle() internal returns (LendingBroker) { - LendingBroker bImpl = new LendingBroker(address(moolah)); + LendingBroker bImpl = new LendingBroker(address(moolah), address(0)); // Use 6-param initialize (no relayer/oracle) by encoding only the original params // and leaving RELAYER/ORACLE as address(0) ERC1967Proxy bProxy = new ERC1967Proxy( diff --git a/test/utils/PositionManager.t.sol b/test/utils/PositionManager.t.sol index 412368de..ec5e1be3 100644 --- a/test/utils/PositionManager.t.sol +++ b/test/utils/PositionManager.t.sol @@ -230,10 +230,20 @@ contract PositionManagerTest is Test { rateCalc = RateCalculator(address(rcProxy)); // ── Deploy inBroker (LendingBroker for the fixed-term market) ──────────── - LendingBroker bImpl = new LendingBroker(address(moolah), address(relayer), address(oracle), address(wbnb)); + LendingBroker bImpl = new LendingBroker(address(moolah), address(wbnb)); ERC1967Proxy bProxy = new ERC1967Proxy( address(bImpl), - abi.encodeWithSelector(LendingBroker.initialize.selector, admin, manager, bot, pauser, address(rateCalc), 100) + abi.encodeWithSelector( + LendingBroker.initialize.selector, + admin, + manager, + bot, + pauser, + address(rateCalc), + 100, + address(relayer), + address(oracle) + ) ); inBroker = LendingBroker(payable(address(bProxy))); @@ -312,10 +322,20 @@ contract PositionManagerTest is Test { // ── Deploy inBrokerSlis ─────────────────────────────────────────────────── { - LendingBroker bSlisImpl = new LendingBroker(address(moolah), address(relayer), address(oracle), address(wbnb)); + LendingBroker bSlisImpl = new LendingBroker(address(moolah), address(wbnb)); ERC1967Proxy bSlisProxy = new ERC1967Proxy( address(bSlisImpl), - abi.encodeWithSelector(LendingBroker.initialize.selector, admin, manager, bot, pauser, address(rateCalc), 100) + abi.encodeWithSelector( + LendingBroker.initialize.selector, + admin, + manager, + bot, + pauser, + address(rateCalc), + 100, + address(relayer), + address(oracle) + ) ); inBrokerSlis = LendingBroker(payable(address(bSlisProxy))); } @@ -399,10 +419,20 @@ contract PositionManagerTest is Test { // ── Deploy inBrokerNative ───────────────────────────────────────────────── { - LendingBroker bNativeImpl = new LendingBroker(address(moolah), address(relayer), address(oracle), address(wbnb)); + LendingBroker bNativeImpl = new LendingBroker(address(moolah), address(wbnb)); ERC1967Proxy bNativeProxy = new ERC1967Proxy( address(bNativeImpl), - abi.encodeWithSelector(LendingBroker.initialize.selector, admin, manager, bot, pauser, address(rateCalc), 10) + abi.encodeWithSelector( + LendingBroker.initialize.selector, + admin, + manager, + bot, + pauser, + address(rateCalc), + 10, + address(relayer), + address(oracle) + ) ); inBrokerNative = LendingBroker(payable(address(bNativeProxy))); } From c8081f9612cdf3aa798b0cf239d971868ca69cb5 Mon Sep 17 00:00:00 2001 From: Rick Date: Thu, 16 Apr 2026 17:33:44 +0800 Subject: [PATCH 11/14] fix: address audit findings for BrokerLiquidator SmartProvider support - [L03] Block smart collateral markets from being liquidated via normal liquidate() by adding _isSmartCollateral() check that detects StableSwapLPCollateral via minter() -> TOKEN() chain - [I01] Add sellBNB() function for selling native BNB received from smart collateral redemptions, mirroring Liquidator.sol - [I05] Add zero address check in batchSetSmartProviders() - [I07] Fix incorrect comment on BOT role constant --- src/liquidator/BrokerLiquidator.sol | 58 ++++++- src/liquidator/IBrokerLiquidator.sol | 8 + test/liquidator/BrokerLiquidator.t.sol | 153 ++++++++++++++++++ .../mocks/MockStableSwapLPCollateral.sol | 17 ++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 test/liquidator/mocks/MockStableSwapLPCollateral.sol diff --git a/src/liquidator/BrokerLiquidator.sol b/src/liquidator/BrokerLiquidator.sol index 26858c8b..4a9af331 100644 --- a/src/liquidator/BrokerLiquidator.sol +++ b/src/liquidator/BrokerLiquidator.sol @@ -11,6 +11,10 @@ import { Id, MarketParams, IMoolah } from "moolah/interfaces/IMoolah.sol"; import { IBrokerLiquidator } from "./IBrokerLiquidator.sol"; import { ISmartProvider } from "../provider/interfaces/IProvider.sol"; +interface IHasMinter { + function minter() external view returns (address); +} + contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerLiquidator { using MarketParamsLib for MarketParams; @@ -23,6 +27,7 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL error NotWhitelisted(); error SwapFailed(); error BrokerMarketIdMismatch(); + error SmartCollateralMustUseDedicatedFunction(); address public immutable MOOLAH; mapping(address => bool) public tokenWhitelist; @@ -40,7 +45,7 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL uint256 internal _lastRepaidAssets; bytes32 public constant MANAGER = keccak256("MANAGER"); // manager role - bytes32 public constant BOT = keccak256("BOT"); // manager role + bytes32 public constant BOT = keccak256("BOT"); // bot role address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; event TokenWhitelistChanged(address indexed token, bool added); @@ -201,6 +206,41 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL emit SellToken(pair, tokenIn, tokenOut, actualAmountIn, actualAmountOut); } + /// @dev sell native BNB for a token. + /// @param pair The address of the pair. + /// @param tokenOut The address of the output token. + /// @param amountIn The amount of BNB to sell. + /// @param amountOutMin The minimum amount to receive. + /// @param swapData The swap data. + function sellBNB( + address pair, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) external onlyRole(BOT) { + require(tokenWhitelist[BNB_ADDRESS], NotWhitelisted()); + require(tokenWhitelist[tokenOut], NotWhitelisted()); + require(pairWhitelist[pair], NotWhitelisted()); + require(amountIn > 0, "amountIn zero"); + + require(address(this).balance >= amountIn, ExceedAmount()); + + uint256 beforeTokenIn = address(this).balance; + uint256 beforeTokenOut = SafeTransferLib.balanceOf(tokenOut, address(this)); + + (bool success, ) = pair.call{ value: amountIn }(swapData); + require(success, SwapFailed()); + + uint256 actualAmountIn = beforeTokenIn - address(this).balance; + uint256 actualAmountOut = SafeTransferLib.balanceOf(tokenOut, address(this)) - beforeTokenOut; + + require(actualAmountIn <= amountIn, ExceedAmount()); + require(actualAmountOut >= amountOutMin, NoProfit()); + + emit SellToken(pair, BNB_ADDRESS, tokenOut, actualAmountIn, actualAmountOut); + } + /// @dev flash liquidates a position. /// @param id The id of the market. /// @param borrower The address of the borrower. @@ -256,6 +296,7 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL require(broker != address(0), NotWhitelisted()); require(_checkBrokerMarketId(broker, id), BrokerMarketIdMismatch()); MarketParams memory params = IMoolah(MOOLAH).idToMarketParams(Id.wrap(id)); + require(!_isSmartCollateral(params.collateralToken), SmartCollateralMustUseDedicatedFunction()); IBrokerBase(broker).liquidate( params, borrower, @@ -486,10 +527,25 @@ contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerL function batchSetSmartProviders(address[] calldata providers, bool status) external onlyRole(MANAGER) { for (uint256 i = 0; i < providers.length; i++) { address provider = providers[i]; + require(provider != address(0), ZERO_ADDRESS); smartProviders[provider] = status; emit SmartProvidersChanged(provider, status); } } + /// @dev Checks if a collateral token is a SmartCollateral (StableSwapLPCollateral). + /// Uses try/catch so it won't revert for normal collateral tokens that lack minter(). + function _isSmartCollateral(address collateralToken) internal view returns (bool) { + try IHasMinter(collateralToken).minter() returns (address minterAddr) { + try ISmartProvider(minterAddr).TOKEN() returns (address token) { + return token == collateralToken; + } catch { + return false; + } + } catch { + return false; + } + } + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} } diff --git a/src/liquidator/IBrokerLiquidator.sol b/src/liquidator/IBrokerLiquidator.sol index 4460fc83..0c41378c 100644 --- a/src/liquidator/IBrokerLiquidator.sol +++ b/src/liquidator/IBrokerLiquidator.sol @@ -60,6 +60,14 @@ interface IBrokerLiquidator { bytes calldata swapData ) external; + function sellBNB( + address pair, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) external; + function setTokenWhitelist(address token, bool status) external; function setMarketToBroker(bytes32 id, address broker, bool status) external; diff --git a/test/liquidator/BrokerLiquidator.t.sol b/test/liquidator/BrokerLiquidator.t.sol index f23e4432..e2be8863 100644 --- a/test/liquidator/BrokerLiquidator.t.sol +++ b/test/liquidator/BrokerLiquidator.t.sol @@ -6,6 +6,8 @@ import "../moolah/BaseTest.sol"; import { BrokerLiquidator, IBrokerLiquidator } from "liquidator/BrokerLiquidator.sol"; import { MarketParamsLib, MarketParams, Id } from "moolah/libraries/MarketParamsLib.sol"; import { MockSmartProvider } from "./mocks/MockSmartProvider.sol"; +import { MockStableSwapLPCollateral } from "./mocks/MockStableSwapLPCollateral.sol"; +import { MockOneInch } from "./mocks/MockOneInch.sol"; contract BrokerLiquidatorTest is BaseTest { using MarketParamsLib for MarketParams; @@ -144,4 +146,155 @@ contract BrokerLiquidatorTest is BaseTest { vm.expectRevert(BrokerLiquidator.NotWhitelisted.selector); brokerLiquidator.redeemSmartCollateral(address(smartProvider), 1e18, 0, 0); } + + // ==================== batchSetSmartProviders zero address ==================== + + function testBatchSetSmartProvidersRevertsOnZeroAddress() public { + address[] memory providers = new address[](2); + providers[0] = address(smartProvider); + providers[1] = address(0); + + vm.prank(MANAGER_ADDR); + vm.expectRevert("zero address"); + brokerLiquidator.batchSetSmartProviders(providers, true); + } + + // ==================== _isSmartCollateral (via liquidate) ==================== + + function testLiquidateRevertsForSmartCollateral() public { + // Create a MockSmartProvider whose TOKEN() returns a MockStableSwapLPCollateral + // and the collateral's minter() returns the MockSmartProvider + MockStableSwapLPCollateral mockLPCollateral = new MockStableSwapLPCollateral( + "MockLP", + "MLP", + address(smartProvider) + ); + // Configure smartProvider so TOKEN() returns the LP collateral address + smartProvider.setCollateralToken(address(mockLPCollateral)); + + // Create a market with this LP collateral + MarketParams memory smartMarketParams = MarketParams({ + loanToken: address(loanToken), + collateralToken: address(mockLPCollateral), + oracle: address(oracle), + irm: address(irm), + lltv: 0.8e18 + }); + moolah.createMarket(smartMarketParams); + bytes32 smartMarketId = Id.unwrap(smartMarketParams.id()); + + // Deploy a mock broker that returns the correct MARKET_ID + MockBrokerForLiquidation mockBroker = new MockBrokerForLiquidation(smartMarketParams.id()); + + // Mock the brokers call so whitelist validation passes + vm.mockCall( + address(moolah), + abi.encodeWithSelector(moolah.brokers.selector, smartMarketParams.id()), + abi.encode(address(mockBroker)) + ); + + // Whitelist the market + vm.prank(MANAGER_ADDR); + brokerLiquidator.setMarketToBroker(smartMarketId, address(mockBroker), true); + + // Attempt to liquidate should revert with SmartCollateralMustUseDedicatedFunction + vm.prank(BOT); + vm.expectRevert(BrokerLiquidator.SmartCollateralMustUseDedicatedFunction.selector); + brokerLiquidator.liquidate(smartMarketId, address(1), 1e18, 0); + } + + function testLiquidateAllowsNormalCollateral() public { + // Normal collateral (ERC20Mock) does not have minter(), so _isSmartCollateral returns false + bytes32 normalMarketId = Id.unwrap(marketParams.id()); + + MockBrokerForLiquidation mockBroker = new MockBrokerForLiquidation(marketParams.id()); + + vm.mockCall( + address(moolah), + abi.encodeWithSelector(moolah.brokers.selector, marketParams.id()), + abi.encode(address(mockBroker)) + ); + + vm.prank(MANAGER_ADDR); + brokerLiquidator.setMarketToBroker(normalMarketId, address(mockBroker), true); + + // Should NOT revert with SmartCollateralMustUseDedicatedFunction + // It will revert inside broker.liquidate (mock is a no-op), but the smart collateral check passes + vm.prank(BOT); + brokerLiquidator.liquidate(normalMarketId, address(1), 1e18, 0); + // If we get here, the _isSmartCollateral check did not block normal collateral + } + + // ==================== sellBNB ==================== + + function testSellBNB() public { + MockOneInch mockDex = new MockOneInch(); + address BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + // Whitelist BNB, loanToken, and the pair + vm.startPrank(MANAGER_ADDR); + brokerLiquidator.setTokenWhitelist(BNB_ADDRESS, true); + brokerLiquidator.setTokenWhitelist(address(loanToken), true); + brokerLiquidator.setPairWhitelist(address(mockDex), true); + vm.stopPrank(); + + // Fund the liquidator with BNB + uint256 amountIn = 1 ether; + uint256 amountOutMin = 2000e18; + deal(address(brokerLiquidator), amountIn); + + bytes memory swapData = abi.encodeWithSelector( + mockDex.swap.selector, + BNB_ADDRESS, + address(loanToken), + amountIn, + amountOutMin + ); + + vm.prank(BOT); + brokerLiquidator.sellBNB(address(mockDex), address(loanToken), amountIn, amountOutMin, swapData); + + assertEq(address(brokerLiquidator).balance, 0); + assertEq(loanToken.balanceOf(address(brokerLiquidator)), amountOutMin); + } + + function testSellBNBRevertsIfNotBot() public { + vm.prank(MANAGER_ADDR); + vm.expectRevert(); + brokerLiquidator.sellBNB(address(1), address(loanToken), 1 ether, 0, ""); + } + + function testSellBNBRevertsIfBNBNotWhitelisted() public { + vm.prank(BOT); + vm.expectRevert(BrokerLiquidator.NotWhitelisted.selector); + brokerLiquidator.sellBNB(address(1), address(loanToken), 1 ether, 0, ""); + } + + function testSellBNBRevertsIfInsufficientBalance() public { + address BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + vm.startPrank(MANAGER_ADDR); + brokerLiquidator.setTokenWhitelist(BNB_ADDRESS, true); + brokerLiquidator.setTokenWhitelist(address(loanToken), true); + brokerLiquidator.setPairWhitelist(address(1), true); + vm.stopPrank(); + + // No BNB in contract + vm.prank(BOT); + vm.expectRevert(BrokerLiquidator.ExceedAmount.selector); + brokerLiquidator.sellBNB(address(1), address(loanToken), 1 ether, 0, ""); + } +} + +/// @dev Minimal mock broker that returns a MARKET_ID for whitelist validation +contract MockBrokerForLiquidation { + Id public immutable MARKET_ID; + + constructor(Id _marketId) { + MARKET_ID = _marketId; + } + + function liquidate(MarketParams memory, address, uint256, uint256, bytes calldata) external { + // no-op for testing + } } diff --git a/test/liquidator/mocks/MockStableSwapLPCollateral.sol b/test/liquidator/mocks/MockStableSwapLPCollateral.sol new file mode 100644 index 00000000..aa8308e5 --- /dev/null +++ b/test/liquidator/mocks/MockStableSwapLPCollateral.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @dev Minimal mock that exposes minter(), like the real StableSwapLPCollateral. +contract MockStableSwapLPCollateral is ERC20 { + address public minter; + + constructor(string memory name, string memory symbol, address _minter) ERC20(name, symbol) { + minter = _minter; + } + + function setMinter(address _minter) external { + minter = _minter; + } +} From 07ccd0365273c3b600327db02e4ac4b22c84743c Mon Sep 17 00:00:00 2001 From: Rick Date: Thu, 16 Apr 2026 14:52:31 +0800 Subject: [PATCH 12/14] feat: add smart collateral liquidation and BNB provider support for fixed-term markets - BrokerLiquidator: add liquidateSmartCollateral, flashLiquidateSmartCollateral, redeemSmartCollateral, withdrawETH, receive(), and smart provider whitelist - MarketFactory: auto-set BNBProvider for WBNB fixed-term markets, route smart provider config to brokerLiquidator for fixed-term vs liquidator/publicLiquidator for common markets - IBroker: expose ORACLE() view - Tests: add coverage for BNB provider and smart provider in fixed-term market creation --- src/broker/interfaces/IBroker.sol | 4 + src/liquidator/IBrokerLiquidator.sol | 4 + src/moolah/MarketFactory.sol | 88 +++++++--- test/broker/LendingBroker.t.sol | 2 +- test/moolah/MarketFactoryTest.sol | 232 ++++++++++++++++++++++++++- 5 files changed, 308 insertions(+), 22 deletions(-) diff --git a/src/broker/interfaces/IBroker.sol b/src/broker/interfaces/IBroker.sol index 2978a980..6ae3a36d 100644 --- a/src/broker/interfaces/IBroker.sol +++ b/src/broker/interfaces/IBroker.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.34; import { Id, MarketParams, IMoolah } from "moolah/interfaces/IMoolah.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; struct FixedTermAndRate { uint256 termId; @@ -186,4 +187,7 @@ interface IBroker is IBrokerBase { /// @param account The address of the account /// @param isAddition Whether to add or remove the account from the whitelist function toggleLiquidationWhitelist(address account, bool isAddition) external; + + /// @dev the oracle used by the broker for price feeds + function ORACLE() external view returns (IOracle); } diff --git a/src/liquidator/IBrokerLiquidator.sol b/src/liquidator/IBrokerLiquidator.sol index 0c41378c..a623c55b 100644 --- a/src/liquidator/IBrokerLiquidator.sol +++ b/src/liquidator/IBrokerLiquidator.sol @@ -81,4 +81,8 @@ interface IBrokerLiquidator { function brokerToMarketId(address broker) external view returns (bytes32); function tokenWhitelist(address token) external view returns (bool); + + function smartProviders(address provider) external view returns (bool); + + function batchSetSmartProviders(address[] calldata providers, bool status) external; } diff --git a/src/moolah/MarketFactory.sol b/src/moolah/MarketFactory.sol index 8682c2da..43d0573b 100644 --- a/src/moolah/MarketFactory.sol +++ b/src/moolah/MarketFactory.sol @@ -177,15 +177,18 @@ contract MarketFactory is UUPSUpgradeable, AccessControlEnumerableUpgradeable, P /** * @dev Creates new fixed term markets with the given parameters and configures the related contracts * @param params An array of FixedTermMarketParams for the markets to be created + * @param liquidatorSmartProviders An array of booleans indicating whether the market is a smart collateral market that requires special provider configuration for each market */ function batchCreateFixedTermMarkets( - FixedTermMarketParams[] calldata params + FixedTermMarketParams[] calldata params, + bool[] calldata liquidatorSmartProviders ) external onlyRole(OPERATOR) returns (Id[] memory) { require(params.length > 0, "empty market params"); + require(params.length == liquidatorSmartProviders.length, "array length mismatch"); Id[] memory ids = new Id[](params.length); for (uint256 i = 0; i < params.length; i++) { - ids[i] = _createFixedTermMarket(params[i]); + ids[i] = _createFixedTermMarket(params[i], liquidatorSmartProviders[i]); } return ids; } @@ -193,9 +196,13 @@ contract MarketFactory is UUPSUpgradeable, AccessControlEnumerableUpgradeable, P /** * @dev Creates a new fixed term market with the given parameters and configures the related contracts * @param param The FixedTermMarketParams for the market to be created + * @param liquidatorSmartProvider A boolean indicating whether the market is a smart collateral market that requires special provider configuration */ - function createFixedTermMarket(FixedTermMarketParams calldata param) external onlyRole(OPERATOR) returns (Id) { - return _createFixedTermMarket(param); + function createFixedTermMarket( + FixedTermMarketParams calldata param, + bool liquidatorSmartProvider + ) external onlyRole(OPERATOR) returns (Id) { + return _createFixedTermMarket(param, liquidatorSmartProvider); } function _createMarket( @@ -258,13 +265,23 @@ contract MarketFactory is UUPSUpgradeable, AccessControlEnumerableUpgradeable, P // if market is smart collateral if (liquidatorSmartProvider) { - _configSmartProvider(id, param.oracle, param.collateralToken); + _configSmartProvider(id, param.oracle, param.collateralToken, false); } emit CommonMarketDeployed(param, id); } - function _createFixedTermMarket(FixedTermMarketParams memory param) private whenNotPaused returns (Id) { + /** + * @dev Creates a new fixed term market with broker configuration + * @param param The FixedTermMarketParams for the market to be created + * @param liquidatorSmartProvider A boolean indicating whether the market is a smart collateral market + * that requires special provider configuration (e.g. SmartProvider / V3Provider) + * @return id The Id of the newly created market + */ + function _createFixedTermMarket( + FixedTermMarketParams memory param, + bool liquidatorSmartProvider + ) private whenNotPaused returns (Id) { IBroker broker = IBroker(param.broker); require(param.broker != address(0), "Zero broker address"); @@ -293,6 +310,10 @@ contract MarketFactory is UUPSUpgradeable, AccessControlEnumerableUpgradeable, P // broker set liquidator whitelist broker.toggleLiquidationWhitelist(address(brokerLiquidator), true); + // set BNBProvider for BNB markets + if (param.loanToken == WBNB || param.collateralToken == WBNB) { + moolah.setProvider(id, BNBProvider, true); + } // set slisBNBProvider for sliBNB markets if (param.collateralToken == sliBNB) { moolah.setProvider(id, slisBNBProvider, true); @@ -315,11 +336,25 @@ contract MarketFactory is UUPSUpgradeable, AccessControlEnumerableUpgradeable, P // broker liquidator set market whitelist brokerLiquidator.setMarketToBroker(Id.unwrap(id), param.broker, true); + // if market is smart collateral + if (liquidatorSmartProvider) { + _configSmartProvider(id, address(broker.ORACLE()), param.collateralToken, true); + } + emit BrokerMarketDeployed(param, id, param.broker); return id; } - function _configSmartProvider(Id id, address provider, address collateral) private { + /** + * @dev Configures smart provider settings for a market, including provider registration, + * flashloan blacklist, and liquidator whitelist setup + * @param id The market Id to configure + * @param provider The smart provider address (oracle address for common markets, broker.ORACLE() for fixed term markets) + * @param collateral The collateral token address to blacklist from flashloans + * @param fixedTerm If true, configures brokerLiquidator for fixed term markets; + * if false, configures liquidator and publicLiquidator for common markets + */ + function _configSmartProvider(Id id, address provider, address collateral, bool fixedTerm) private { // moolah set provider moolah.setProvider(id, provider, true); // moolah set flashloan blacklist @@ -329,20 +364,35 @@ contract MarketFactory is UUPSUpgradeable, AccessControlEnumerableUpgradeable, P // liquidator and public liquidator set smart provider whitelist address[] memory smartProviders = new address[](1); smartProviders[0] = provider; - if (!liquidator.smartProviders(provider)) { - liquidator.batchSetSmartProviders(smartProviders, true); - } - if (!publicLiquidator.smartProviders(provider)) { - publicLiquidator.batchSetSmartProviders(smartProviders, true); - } - // set token whitelist for liquidator if not set + address token0 = ISmartProvider(provider).token(0); address token1 = ISmartProvider(provider).token(1); - if (!liquidator.tokenWhitelist(token0)) { - liquidator.setTokenWhitelist(token0, true); - } - if (!liquidator.tokenWhitelist(token1)) { - liquidator.setTokenWhitelist(token1, true); + + if (fixedTerm) { + // broker liquidator set smart provider whitelist and token whitelist for fixed term markets + if (!brokerLiquidator.smartProviders(provider)) { + brokerLiquidator.batchSetSmartProviders(smartProviders, true); + } + if (!brokerLiquidator.tokenWhitelist(token0)) { + brokerLiquidator.setTokenWhitelist(token0, true); + } + if (!brokerLiquidator.tokenWhitelist(token1)) { + brokerLiquidator.setTokenWhitelist(token1, true); + } + } else { + // liquidator and public liquidator set smart provider whitelist and token whitelist for common markets + if (!liquidator.smartProviders(provider)) { + liquidator.batchSetSmartProviders(smartProviders, true); + } + if (!publicLiquidator.smartProviders(provider)) { + publicLiquidator.batchSetSmartProviders(smartProviders, true); + } + if (!liquidator.tokenWhitelist(token0)) { + liquidator.setTokenWhitelist(token0, true); + } + if (!liquidator.tokenWhitelist(token1)) { + liquidator.setTokenWhitelist(token1, true); + } } } diff --git a/test/broker/LendingBroker.t.sol b/test/broker/LendingBroker.t.sol index 95ed54b3..81b94262 100644 --- a/test/broker/LendingBroker.t.sol +++ b/test/broker/LendingBroker.t.sol @@ -88,7 +88,7 @@ contract LendingBrokerTest is Test { Moolah mImpl = new Moolah(); ERC1967Proxy mProxy = new ERC1967Proxy( address(mImpl), - abi.encodeWithSelector(Moolah.initialize.selector, ADMIN, MANAGER, PAUSER, 15e8) + abi.encodeWithSelector(Moolah.initialize.selector, ADMIN, MANAGR, PAUSER, 15e8) ); moolah = IMoolah(address(mProxy)); diff --git a/test/moolah/MarketFactoryTest.sol b/test/moolah/MarketFactoryTest.sol index a43b5b61..377a4516 100644 --- a/test/moolah/MarketFactoryTest.sol +++ b/test/moolah/MarketFactoryTest.sol @@ -476,7 +476,7 @@ contract MarketFactoryTest is Test { vm.stopPrank(); vm.startPrank(operator); - Id id = marketFactory.createFixedTermMarket(params); + Id id = marketFactory.createFixedTermMarket(params, false); vm.stopPrank(); assertEq(Id.unwrap(id), Id.unwrap(broker.MARKET_ID()), "Market ID mismatch between broker and market factory"); @@ -494,7 +494,235 @@ contract MarketFactoryTest is Test { ); } + function testCreateFixedTermMarketWithBNBLoan() public { + address relayer = makeAddr("relayer"); + uint256 ratePerSecond = 1000000000195993755570992534; + uint256 maxRatePerSecond = 1000000008319516284844716199; + ERC20Mock collateralToken = new ERC20Mock(); + LendingBroker broker = newLendingBroker(relayer); + + MarketFactory.FixedTermMarketParams memory params = MarketFactory.FixedTermMarketParams({ + broker: address(broker), + loanToken: address(WBNB), + collateralToken: address(collateralToken), + irm: address(irm), + lltv: lltv80, + ratePerSecond: ratePerSecond, + maxRatePerSecond: maxRatePerSecond + }); + oracle.setPrice(address(WBNB), 1e8); + oracle.setPrice(address(collateralToken), 1e8); + + vm.startPrank(admin); + broker.grantRole(broker.MANAGER(), address(marketFactory)); + vm.stopPrank(); + + vm.startPrank(operator); + Id id = marketFactory.createFixedTermMarket(params, false); + vm.stopPrank(); + + assertEq( + moolah.providers(id, address(WBNB)), + address(bnbProvider), + "BNBProvider not set for fixed term BNB loan market" + ); + } + + function testCreateFixedTermMarketWithBNBCollateral() public { + address relayer = makeAddr("relayer"); + uint256 ratePerSecond = 1000000000195993755570992534; + uint256 maxRatePerSecond = 1000000008319516284844716199; + ERC20Mock loanToken = new ERC20Mock(); + LendingBroker broker = newLendingBroker(relayer); + + MarketFactory.FixedTermMarketParams memory params = MarketFactory.FixedTermMarketParams({ + broker: address(broker), + loanToken: address(loanToken), + collateralToken: address(WBNB), + irm: address(irm), + lltv: lltv80, + ratePerSecond: ratePerSecond, + maxRatePerSecond: maxRatePerSecond + }); + oracle.setPrice(address(loanToken), 1e8); + oracle.setPrice(address(WBNB), 1e8); + + vm.startPrank(admin); + broker.grantRole(broker.MANAGER(), address(marketFactory)); + vm.stopPrank(); + + vm.startPrank(operator); + Id id = marketFactory.createFixedTermMarket(params, false); + vm.stopPrank(); + + assertEq( + moolah.providers(id, address(WBNB)), + address(bnbProvider), + "BNBProvider not set for fixed term BNB collateral market" + ); + } + + function testCreateFixedTermMarketWithSmartProvider() public { + address relayer = makeAddr("relayer"); + uint256 ratePerSecond = 1000000000195993755570992534; + uint256 maxRatePerSecond = 1000000008319516284844716199; + ERC20Mock loanToken = new ERC20Mock(); + ERC20Mock collateralToken = new ERC20Mock(); + ERC20Mock token0 = new ERC20Mock(); + ERC20Mock token1 = new ERC20Mock(); + + MockSmartProvider smartOracle = new MockSmartProvider(address(collateralToken)); + smartOracle.setPrice(address(loanToken), 1e8); + smartOracle.setPrice(address(collateralToken), 1e8); + smartOracle.addToken(address(token0)); + smartOracle.addToken(address(token1)); + + LendingBroker broker = newLendingBrokerWithOracle(relayer, address(smartOracle)); + + MarketFactory.FixedTermMarketParams memory params = MarketFactory.FixedTermMarketParams({ + broker: address(broker), + loanToken: address(loanToken), + collateralToken: address(collateralToken), + irm: address(irm), + lltv: lltv80, + ratePerSecond: ratePerSecond, + maxRatePerSecond: maxRatePerSecond + }); + + vm.startPrank(admin); + broker.grantRole(broker.MANAGER(), address(marketFactory)); + vm.stopPrank(); + + vm.startPrank(operator); + Id id = marketFactory.createFixedTermMarket(params, true); + vm.stopPrank(); + + // verify smart provider is set as provider on moolah + assertEq( + moolah.providers(id, address(collateralToken)), + address(smartOracle), + "Smart provider not set for fixed term market" + ); + // verify flashloan blacklist + assertTrue( + moolah.flashLoanTokenBlacklist(address(collateralToken)), + "Collateral token should be blacklisted for flash loan" + ); + // verify brokerLiquidator gets smart provider config (not liquidator/publicLiquidator) + assertTrue( + brokerLiquidator.smartProviders(address(smartOracle)), + "Smart provider whitelist not set for broker liquidator" + ); + assertTrue(brokerLiquidator.tokenWhitelist(address(token0)), "Token0 whitelist not set for broker liquidator"); + assertTrue(brokerLiquidator.tokenWhitelist(address(token1)), "Token1 whitelist not set for broker liquidator"); + // verify liquidator/publicLiquidator are NOT configured (fixed term uses brokerLiquidator) + assertFalse( + liquidator.smartProviders(address(smartOracle)), + "Smart provider should NOT be set for common liquidator in fixed term" + ); + assertFalse( + publicLiquidator.smartProviders(address(smartOracle)), + "Smart provider should NOT be set for public liquidator in fixed term" + ); + } + + function testBatchCreateFixedTermMarkets() public { + address relayer = makeAddr("relayer"); + uint256 ratePerSecond = 1000000000195993755570992534; + uint256 maxRatePerSecond = 1000000008319516284844716199; + + // market 1: WBNB loan, normal collateral + ERC20Mock collateralToken1 = new ERC20Mock(); + LendingBroker broker1 = newLendingBroker(relayer); + + // market 2: smart provider collateral + ERC20Mock loanToken2 = new ERC20Mock(); + ERC20Mock collateralToken2 = new ERC20Mock(); + ERC20Mock token0 = new ERC20Mock(); + ERC20Mock token1 = new ERC20Mock(); + MockSmartProvider smartOracle2 = new MockSmartProvider(address(collateralToken2)); + smartOracle2.setPrice(address(loanToken2), 1e8); + smartOracle2.setPrice(address(collateralToken2), 1e8); + smartOracle2.addToken(address(token0)); + smartOracle2.addToken(address(token1)); + LendingBroker broker2 = newLendingBrokerWithOracle(relayer, address(smartOracle2)); + + oracle.setPrice(address(WBNB), 1e8); + oracle.setPrice(address(collateralToken1), 1e8); + + MarketFactory.FixedTermMarketParams[] memory params = new MarketFactory.FixedTermMarketParams[](2); + params[0] = MarketFactory.FixedTermMarketParams({ + broker: address(broker1), + loanToken: address(WBNB), + collateralToken: address(collateralToken1), + irm: address(irm), + lltv: lltv80, + ratePerSecond: ratePerSecond, + maxRatePerSecond: maxRatePerSecond + }); + params[1] = MarketFactory.FixedTermMarketParams({ + broker: address(broker2), + loanToken: address(loanToken2), + collateralToken: address(collateralToken2), + irm: address(irm), + lltv: lltv80, + ratePerSecond: ratePerSecond, + maxRatePerSecond: maxRatePerSecond + }); + + bool[] memory liquidatorSmartProviders = new bool[](2); + liquidatorSmartProviders[0] = false; + liquidatorSmartProviders[1] = true; + + vm.startPrank(admin); + broker1.grantRole(broker1.MANAGER(), address(marketFactory)); + broker2.grantRole(broker2.MANAGER(), address(marketFactory)); + vm.stopPrank(); + + vm.startPrank(operator); + Id[] memory ids = marketFactory.batchCreateFixedTermMarkets(params, liquidatorSmartProviders); + vm.stopPrank(); + + assertEq(ids.length, 2, "Should create 2 markets"); + + // market 1: WBNB loan → BNBProvider should be set + assertEq( + moolah.providers(ids[0], address(WBNB)), + address(bnbProvider), + "BNBProvider not set for batch fixed term BNB market" + ); + assertEq(moolah.brokers(ids[0]), address(broker1), "Broker1 not set for market"); + + // market 2: smart provider collateral → brokerLiquidator should be configured + assertEq(moolah.brokers(ids[1]), address(broker2), "Broker2 not set for market"); + assertEq( + moolah.providers(ids[1], address(collateralToken2)), + address(smartOracle2), + "Smart provider not set for batch fixed term smart collateral market" + ); + assertTrue( + moolah.flashLoanTokenBlacklist(address(collateralToken2)), + "Collateral token should be blacklisted for flash loan in batch" + ); + assertTrue( + brokerLiquidator.smartProviders(address(smartOracle2)), + "Smart provider whitelist not set for broker liquidator in batch" + ); + assertTrue( + brokerLiquidator.tokenWhitelist(address(token0)), + "Token0 whitelist not set for broker liquidator in batch" + ); + assertTrue( + brokerLiquidator.tokenWhitelist(address(token1)), + "Token1 whitelist not set for broker liquidator in batch" + ); + } + function newLendingBroker(address replayer) private returns (LendingBroker) { + return newLendingBrokerWithOracle(replayer, address(oracle)); + } + + function newLendingBrokerWithOracle(address replayer, address _oracle) private returns (LendingBroker) { LendingBroker lendingBrokerImpl = new LendingBroker(address(moolah), address(0)); ERC1967Proxy lendingBrokerProxy = new ERC1967Proxy( address(lendingBrokerImpl), @@ -507,7 +735,7 @@ contract MarketFactoryTest is Test { address(rateCalculator), 100, replayer, - address(oracle) + _oracle ) ); From 705d31dc54c8fe7f055474901e39d2eab33957e4 Mon Sep 17 00:00:00 2001 From: Rick Date: Thu, 16 Apr 2026 14:57:48 +0800 Subject: [PATCH 13/14] fix: correct typo MANAGR -> MANAGER in LendingBroker test --- test/broker/LendingBroker.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/broker/LendingBroker.t.sol b/test/broker/LendingBroker.t.sol index 81b94262..95ed54b3 100644 --- a/test/broker/LendingBroker.t.sol +++ b/test/broker/LendingBroker.t.sol @@ -88,7 +88,7 @@ contract LendingBrokerTest is Test { Moolah mImpl = new Moolah(); ERC1967Proxy mProxy = new ERC1967Proxy( address(mImpl), - abi.encodeWithSelector(Moolah.initialize.selector, ADMIN, MANAGR, PAUSER, 15e8) + abi.encodeWithSelector(Moolah.initialize.selector, ADMIN, MANAGER, PAUSER, 15e8) ); moolah = IMoolah(address(mProxy)); From 456752826bc319c59378631e4933a6f92d3e9344 Mon Sep 17 00:00:00 2001 From: Rick Date: Tue, 21 Apr 2026 13:55:36 +0800 Subject: [PATCH 14/14] chore: update MarketFactory testnet deploy script with BSC testnet addresses --- script/utils/deploy_marketFactory_testnet.sol | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 script/utils/deploy_marketFactory_testnet.sol diff --git a/script/utils/deploy_marketFactory_testnet.sol b/script/utils/deploy_marketFactory_testnet.sol new file mode 100644 index 00000000..5252817c --- /dev/null +++ b/script/utils/deploy_marketFactory_testnet.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Script.sol"; +import { DeployBase } from "../DeployBase.sol"; + +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import { MarketFactory } from "../../src/moolah/MarketFactory.sol"; + +contract MarketFactoryDeploy is DeployBase { + address moolah = 0x4c26397D4ef9EEae55735a1631e69Da965eBC41A; + address liquidator = 0x8096Bbe78eB83B83dD286c6062a1eFbE85305c97; + address publicLiquidator = 0x456500a836DD73A5aF6fD85632E4805a8dAb9a97; + address listaRevenueDistributor = 0xe36857af784fB2B8cFA22481b51Fa0c99D13fF20; + address buyback = 0x371b76E7C797AF9336443F6588B510c9d177315e; + address autoBuyback = 0xa4cb526E4D1CaF21f1DFA824f9B4728b217D1eBd; + address WBNB = 0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd; + address slisBNB = 0xCc752dC4ae72386986d011c2B485be0DAd98C744; + address BNBProvider = 0x297152bCC1dd5bC0Df527CB16E7Ff7348d7b1d72; + address slisBNBProvider = 0x0612c940460D68C16aA213315E32Fba579beD6A6; + address rateCalculator = 0x638B87aBD83C54CBaABBDfF096f94F795fe9e83c; + address brokerLiquidator = 0xeAe8EaB31E7299Cc4c7C6F08f3C1AA8eF08dC175; + + function run() public { + uint256 deployerPrivateKey = _deployerKey(); + address deployer = vm.addr(deployerPrivateKey); + address operator = deployer; + address pauser = deployer; + console.log("Deployer: ", deployer); + vm.startBroadcast(deployerPrivateKey); + + // Deploy implementation + MarketFactory impl = new MarketFactory( + moolah, + liquidator, + publicLiquidator, + listaRevenueDistributor, + buyback, + autoBuyback, + WBNB, + slisBNB, + BNBProvider, + slisBNBProvider, + rateCalculator, + brokerLiquidator + ); + console.log("Implementation: ", address(impl)); + + // Deploy proxy + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeWithSelector(impl.initialize.selector, deployer, operator, pauser) + ); + console.log("Loop WBNB Vault BNBProvider proxy: ", address(proxy)); + + vm.stopBroadcast(); + } +}