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/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/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/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(); + } +} diff --git a/src/broker/LendingBroker.sol b/src/broker/LendingBroker.sol index 4488e199..128eaa3e 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); } /////////////////////////////////////// @@ -436,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); @@ -453,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]; @@ -470,7 +474,7 @@ contract LendingBroker is fixedLoanPositions[user].push( FixedLoanPosition({ posId: fixedPosUuid, - principal: amount + interestToRepay, + principal: amount, apr: term.apr, start: start, end: end, @@ -1118,6 +1122,28 @@ contract LendingBroker is emit BorrowPaused(paused); } + /** + * @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(DEFAULT_ADMIN_ROLE) { + require(_relayer != address(0), "broker/zero-address-provided"); + require(RELAYER == address(0), "broker/already-set"); + RELAYER = _relayer; + emit RelayerSet(_relayer); + } + + /** + * @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(DEFAULT_ADMIN_ROLE) { + require(_oracle != address(0), "broker/zero-address-provided"); + require(address(ORACLE) == address(0), "broker/already-set"); + ORACLE = IOracle(_oracle); + emit OracleSet(_oracle); + } + /** * @dev pause contract */ diff --git a/src/broker/interfaces/IBroker.sol b/src/broker/interfaces/IBroker.sol index 4f586e96..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; @@ -106,6 +107,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); @@ -184,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/BrokerLiquidator.sol b/src/liquidator/BrokerLiquidator.sol index e71999b6..4a9af331 100644 --- a/src/liquidator/BrokerLiquidator.sol +++ b/src/liquidator/BrokerLiquidator.sol @@ -8,7 +8,12 @@ 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"; + +interface IHasMinter { + function minter() external view returns (address); +} contract BrokerLiquidator is UUPSUpgradeable, AccessControlUpgradeable, IBrokerLiquidator { using MarketParamsLib for MarketParams; @@ -22,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; @@ -33,14 +39,30 @@ 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; + /// @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 + bytes32 public constant BOT = keccak256("BOT"); // bot 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. @@ -64,12 +86,19 @@ 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. 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. /// @param token The address of the token. @@ -177,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. @@ -232,6 +296,61 @@ 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, + 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), + "", + "" + ) + ) + ); + } + + /// @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 loanBalanceBefore = IERC20(params.loanToken).balanceOf(address(this)); + (uint256 minAmount0, uint256 minAmount1) = abi.decode(payload, (uint256, uint256)); IBrokerBase(broker).liquidate( params, borrower, @@ -257,6 +376,74 @@ 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( + collAmount, + minAmount0, + minAmount1 + ); + + emit SmartLiquidation(id, lpToken, params.collateralToken, collAmount, minAmount0, minAmount1, amount0, amount1); + _lastRepaidAssets = 0; + return (collAmount, repaidAssets); + } + + /// @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)); + 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. @@ -279,10 +466,86 @@ 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); } + _lastRepaidAssets = repaidAssets; 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. + /// @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. + 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]; + 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 12d5f4b2..a623c55b 100644 --- a/src/liquidator/IBrokerLiquidator.sol +++ b/src/liquidator/IBrokerLiquidator.sol @@ -19,6 +19,7 @@ interface IBrokerLiquidator { bytes swapToken1Data; } function withdrawERC20(address token, uint256 amount) external; + function withdrawETH(uint256 amount) external; function flashLiquidate( bytes32 id, address borrower, @@ -29,6 +30,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, @@ -38,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; @@ -51,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/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"); + } +} diff --git a/test/broker/LendingBroker.t.sol b/test/broker/LendingBroker.t.sol index ea179eb2..95ed54b3 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 { @@ -162,17 +164,37 @@ 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))); - 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))); @@ -248,7 +270,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); @@ -593,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; @@ -610,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); } @@ -639,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"); @@ -650,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); + + (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); - // sanity: normalized delta consumed the whole normalized debt (allowing rounding wiggle) - assertApproxEqAbs(expectedNormalizedDelta, normalizedBefore, 1, "normalized debt delta rounding"); + 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"); } // ----------------------------- @@ -1361,10 +1444,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); @@ -1379,10 +1472,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))); @@ -1410,10 +1513,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))); @@ -1429,10 +1542,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))); @@ -1700,6 +1823,292 @@ contract LendingBrokerTest is Test { bnbBroker.repay{ value: 1 ether }(1 ether, borrower); vm.stopPrank(); } + + // ============================================= + // 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), 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( + 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 { + 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 { + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + vm.prank(ADMIN); + vm.expectRevert(bytes("broker/zero-address-provided")); + b.setRelayer(address(0)); + } + + 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_notAdmin() public { + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + vm.prank(MANAGER); + vm.expectRevert(); + b.setRelayer(address(relayer)); + } + + function test_setOracle_success() public { + 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 { + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + vm.prank(ADMIN); + vm.expectRevert(bytes("broker/zero-address-provided")); + b.setOracle(address(0)); + } + + 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_notAdmin() public { + LendingBroker b = _deployBrokerWithEmptyRelayerOracle(); + vm.prank(MANAGER); + vm.expectRevert(); + b.setOracle(address(oracle)); + } + + // ============================================= + // 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 new file mode 100644 index 00000000..e2be8863 --- /dev/null +++ b/test/liquidator/BrokerLiquidator.t.sol @@ -0,0 +1,300 @@ +// 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"; +import { MockStableSwapLPCollateral } from "./mocks/MockStableSwapLPCollateral.sol"; +import { MockOneInch } from "./mocks/MockOneInch.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(payable(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); + } + + // ==================== 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/MockSmartProvider.sol b/test/liquidator/mocks/MockSmartProvider.sol new file mode 100644 index 00000000..3ede1b7c --- /dev/null +++ b/test/liquidator/mocks/MockSmartProvider.sol @@ -0,0 +1,56 @@ +// 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; + 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( + uint256 lpAmount, + uint256 minToken0Out, + uint256 minToken1Out + ) external returns (uint256 token0Out, uint256 token1Out) { + token0Out = (lpAmount * token0Bps) / 10000; + token1Out = lpAmount - token0Out; + 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); + } +} 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; + } +} diff --git a/test/moolah/MarketFactoryTest.sol b/test/moolah/MarketFactoryTest.sol index 9c05e3ea..377a4516 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(); @@ -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,8 +494,236 @@ 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) { - LendingBroker lendingBrokerImpl = new LendingBroker(address(moolah), replayer, address(oracle), address(0)); + 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), abi.encodeWithSelector( @@ -505,7 +733,9 @@ contract MarketFactoryTest is Test { bot, pauser, address(rateCalculator), - 100 + 100, + replayer, + _oracle ) ); 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))); }