diff --git a/.gitmodules b/.gitmodules index acda2ad9..50fd7241 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,3 +18,9 @@ [submodule "lib/murky"] path = lib/murky url = https://github.com/dmfxyz/murky +[submodule "lib/v3-core"] + path = lib/v3-core + url = https://github.com/Uniswap/v3-core +[submodule "lib/v3-periphery"] + path = lib/v3-periphery + url = https://github.com/uniswap/v3-periphery diff --git a/foundry.lock b/foundry.lock index 168778b8..35f30da2 100644 --- a/foundry.lock +++ b/foundry.lock @@ -10,5 +10,17 @@ "name": "v0.1.0", "rev": "5feccd1253d7da820f7cccccdedf64471025455d" } + }, + "lib/v3-core": { + "tag": { + "name": "v1.0.0", + "rev": "e3589b192d0be27e100cd0daaf6c97204fdb1899" + } + }, + "lib/v3-periphery": { + "tag": { + "name": "v1.3.0", + "rev": "80f26c86c57b8a5e4b913f42844d4c8bd274d058" + } } } \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index b3bc00ed..e82b5fc9 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,6 +3,10 @@ src = "src" out = "out" libs = ["lib"] +remappings = [ + "@uniswap/v3-core/=lib/v3-core/", + "@uniswap/v3-periphery/=lib/v3-periphery/", +] solc = "0.8.34" optimizer = true optimizer_runs = 20 diff --git a/lib/v3-core b/lib/v3-core new file mode 160000 index 00000000..6562c52e --- /dev/null +++ b/lib/v3-core @@ -0,0 +1 @@ +Subproject commit 6562c52e8f75f0c10f9deaf44861847585fc8129 diff --git a/lib/v3-periphery b/lib/v3-periphery new file mode 160000 index 00000000..b325bb09 --- /dev/null +++ b/lib/v3-periphery @@ -0,0 +1 @@ +Subproject commit b325bb0905d922ae61fcc7df85ee802e8df5e96c diff --git a/src/liquidator/V3Liquidator.sol b/src/liquidator/V3Liquidator.sol new file mode 100644 index 00000000..7b3b08ce --- /dev/null +++ b/src/liquidator/V3Liquidator.sol @@ -0,0 +1,482 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; + +import { IV3Provider } from "../provider/interfaces/IV3Provider.sol"; +import "./Interface.sol"; + +/** + * @title V3Liquidator + * @notice Liquidator for Moolah markets whose collateral is a V3Provider LP share token. + * + * Liquidation flows: + * 1. liquidate() — pre-funded: caller holds loanToken, receives V3 shares. + * 2. flashLiquidate() — callback-based: in onMoolahLiquidate, optionally redeem + * V3 shares → TOKEN0 / TOKEN1, swap to loanToken, repay. + * 3. redeemV3Shares() — standalone: redeem shares held by this contract. + * 4. sellToken/sellBNB() — swap any token/BNB held by this contract (e.g. post-redeem). + */ +contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessControlUpgradeable { + using SafeTransferLib for address; + + /* ──────────────────────────── errors ────────────────────────────── */ + + error NoProfit(); + error OnlyMoolah(); + error ExceedAmount(); + error WhitelistSameStatus(); + error NotWhitelisted(); + error SwapFailed(); + + /* ──────────────────────────── constants ─────────────────────────── */ + + bytes32 public constant MANAGER = keccak256("MANAGER"); + bytes32 public constant BOT = keccak256("BOT"); + + /// @dev Virtual address used to represent native BNB in token whitelists. + address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /// @dev BSC wrapped native token — V3Provider unwraps it to native BNB on exit. + address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + + /* ──────────────────────────── immutables ─────────────────────────── */ + + address public immutable MOOLAH; + + /* ──────────────────────────── storage ───────────────────────────── */ + + mapping(address => bool) public tokenWhitelist; + mapping(bytes32 => bool) public marketWhitelist; + mapping(address => bool) public pairWhitelist; + /// @dev Whitelisted V3Provider contracts (collateral token = the provider itself). + mapping(address => bool) public v3Providers; + + /* ──────────────────────────── events ────────────────────────────── */ + + event TokenWhitelistChanged(address indexed token, bool status); + event MarketWhitelistChanged(bytes32 indexed id, bool status); + event PairWhitelistChanged(address indexed pair, bool status); + event V3ProviderWhitelistChanged(address indexed provider, bool status); + event SellToken( + address indexed pair, + address spender, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 actualAmountOut + ); + event V3Liquidation( + bytes32 indexed id, + address indexed v3Provider, + address indexed borrower, + uint256 seized, + uint256 repaid, + uint256 amount0, + uint256 amount1 + ); + + /* ──────────────────────── callback struct ───────────────────────── */ + + /** + * @dev Passed through Moolah's liquidate callback mechanism. + * @param v3Provider V3Provider that issued the seized shares. + * @param loanToken Loan token to repay to Moolah. + * @param seized Number of V3 shares seized by Moolah. + * @param redeemShares If true, redeem V3 shares in callback; else hold as ERC-20. + * @param minToken0Amt Slippage guard passed to V3Provider.redeemShares. + * @param minToken1Amt Slippage guard passed to V3Provider.redeemShares. + * @param swapToken0 Swap TOKEN0 → loanToken after redemption. + * @param swapToken1 Swap TOKEN1 / native BNB → loanToken after redemption. + * @param token0Pair DEX router / pair for TOKEN0 swap. + * @param token0Spender Token0 approval target (set to token0Pair if same). + * @param token1Pair DEX router / pair for TOKEN1 / BNB swap. + * @param token1Spender Token1 approval target (set to token1Pair if same). + * @param swapToken0Data Calldata for TOKEN0 swap (e.g. from 1inch aggregator). + * @param swapToken1Data Calldata for TOKEN1 / BNB swap. + */ + struct V3LiquidateData { + address v3Provider; + address loanToken; + uint256 seized; + bool redeemShares; + uint256 minToken0Amt; + uint256 minToken1Amt; + bool swapToken0; + bool swapToken1; + address token0Pair; + address token0Spender; + address token1Pair; + address token1Spender; + bytes swapToken0Data; + bytes swapToken1Data; + } + + /* ──────────────────── flashLiquidate params ─────────────────────── */ + + /** + * @dev Parameters for flashLiquidate, bundled into a struct to avoid stack-too-deep. + * @param v3Provider Whitelisted V3Provider contract. + * @param minToken0Amt Min TOKEN0 from redeemShares. + * @param minToken1Amt Min TOKEN1 from redeemShares. + * @param redeemShares Redeem V3 shares in callback? If false, contract holds shares. + * @param token0Pair DEX pair for TOKEN0 → loanToken swap. address(0) = no swap. + * @param token0Spender Approval target for TOKEN0; if address(0), uses token0Pair. + * @param token1Pair DEX pair for TOKEN1 / BNB → loanToken swap. address(0) = no swap. + * @param token1Spender Approval target for TOKEN1; if address(0), uses token1Pair. + * @param swapToken0Data Aggregator calldata for TOKEN0 swap. + * @param swapToken1Data Aggregator calldata for TOKEN1 / BNB swap. + */ + struct FlashLiquidateParams { + address v3Provider; + uint256 minToken0Amt; + uint256 minToken1Amt; + bool redeemShares; + address token0Pair; + address token0Spender; + address token1Pair; + address token1Spender; + bytes swapToken0Data; + bytes swapToken1Data; + } + + /* ────────────────────── constructor / init ──────────────────────── */ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address moolah) { + require(moolah != address(0), "zero address"); + MOOLAH = moolah; + _disableInitializers(); + } + + function initialize(address admin, address manager, address bot) external initializer { + require(admin != address(0) && manager != address(0) && bot != address(0), "zero address"); + __AccessControl_init(); + __ReentrancyGuard_init(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(MANAGER, manager); + _grantRole(BOT, bot); + } + + receive() external payable {} + + /* ─────────────────────── withdrawals ────────────────────────────── */ + + function withdrawERC20(address token, uint256 amount) external onlyRole(MANAGER) { + token.safeTransfer(msg.sender, amount); + } + + function withdrawETH(uint256 amount) external onlyRole(MANAGER) { + msg.sender.safeTransferETH(amount); + } + + /* ─────────────────────── whitelists ─────────────────────────────── */ + + function setTokenWhitelist(address token, bool status) external onlyRole(MANAGER) { + require(tokenWhitelist[token] != status, WhitelistSameStatus()); + tokenWhitelist[token] = status; + emit TokenWhitelistChanged(token, status); + } + + function setMarketWhitelist(bytes32 id, bool status) external onlyRole(MANAGER) { + _setMarketWhitelist(id, status); + } + + function batchSetMarketWhitelist(bytes32[] calldata ids, bool status) external onlyRole(MANAGER) { + for (uint256 i = 0; i < ids.length; i++) { + _setMarketWhitelist(ids[i], status); + } + } + + function setPairWhitelist(address pair, bool status) external onlyRole(MANAGER) { + require(pair != address(0), "zero address"); + require(pairWhitelist[pair] != status, WhitelistSameStatus()); + pairWhitelist[pair] = status; + emit PairWhitelistChanged(pair, status); + } + + function setV3ProviderWhitelist(address provider, bool status) external onlyRole(MANAGER) { + require(provider != address(0), "zero address"); + require(v3Providers[provider] != status, WhitelistSameStatus()); + v3Providers[provider] = status; + emit V3ProviderWhitelistChanged(provider, status); + } + + function batchSetV3Providers(address[] calldata providers, bool status) external onlyRole(MANAGER) { + for (uint256 i = 0; i < providers.length; i++) { + require(providers[i] != address(0), "zero address"); + v3Providers[providers[i]] = status; + emit V3ProviderWhitelistChanged(providers[i], status); + } + } + + function _setMarketWhitelist(bytes32 id, bool status) internal { + require(IMoolah(MOOLAH).idToMarketParams(id).loanToken != address(0), "Invalid market"); + require(marketWhitelist[id] != status, WhitelistSameStatus()); + marketWhitelist[id] = status; + emit MarketWhitelistChanged(id, status); + } + + /* ───────────────────── core liquidation ─────────────────────────── */ + + /** + * @notice Basic liquidation. This contract must hold enough loanToken to cover repayment. + * Seized V3 shares are held by this contract; bot may later call redeemV3Shares. + * @param id Market id. + * @param borrower Position to liquidate. + * @param seizedAssets Collateral shares to seize (pass 0 to use repaidShares instead). + * @param repaidShares Debt shares to repay (pass 0 to use seizedAssets instead). + */ + function liquidate( + bytes32 id, + address borrower, + uint256 seizedAssets, + uint256 repaidShares + ) external nonReentrant onlyRole(BOT) { + require(marketWhitelist[id], NotWhitelisted()); + IMoolah.MarketParams memory params = IMoolah(MOOLAH).idToMarketParams(id); + + // Pre-approve Moolah to pull the repayment; cleared after the call. + params.loanToken.safeApprove(MOOLAH, type(uint256).max); + IMoolah(MOOLAH).liquidate(params, borrower, seizedAssets, repaidShares, ""); + params.loanToken.safeApprove(MOOLAH, 0); + } + + /** + * @notice Flash liquidation: Moolah delivers seized V3 shares to this contract inside + * the onMoolahLiquidate callback. The callback optionally: + * 1. Redeems V3 shares → TOKEN0 + TOKEN1 (TOKEN1 arrives as native BNB if WBNB). + * 2. Swaps TOKEN0 → loanToken. + * 3. Swaps TOKEN1 / BNB → loanToken. + * 4. Approves loanToken to Moolah to satisfy repayment. + * + * If `params.redeemShares == false`, shares are held as ERC-20 and the contract + * must already hold enough loanToken to cover repayment. + * @param id Market id. + * @param borrower Position to liquidate. + * @param seizedAssets Collateral shares to seize (exactlyOneZero with repaidShares). + * @param params Flash liquidation parameters (see FlashLiquidateParams). + */ + function flashLiquidate( + bytes32 id, + address borrower, + uint256 seizedAssets, + FlashLiquidateParams calldata params + ) external nonReentrant onlyRole(BOT) { + require(marketWhitelist[id], NotWhitelisted()); + require(v3Providers[params.v3Provider], NotWhitelisted()); + _requirePairWhitelisted(params.token0Pair, params.token0Spender); + _requirePairWhitelisted(params.token1Pair, params.token1Spender); + + IMoolah.MarketParams memory mp = IMoolah(MOOLAH).idToMarketParams(id); + require(mp.collateralToken == params.v3Provider, "provider/market mismatch"); + + address effectiveToken0Spender = params.token0Spender == address(0) ? params.token0Pair : params.token0Spender; + address effectiveToken1Spender = params.token1Spender == address(0) ? params.token1Pair : params.token1Spender; + + (uint256 _seized, uint256 _repaid) = IMoolah(MOOLAH).liquidate( + mp, + borrower, + seizedAssets, + 0, + abi.encode( + V3LiquidateData({ + v3Provider: params.v3Provider, + loanToken: mp.loanToken, + seized: seizedAssets, + redeemShares: params.redeemShares, + minToken0Amt: params.minToken0Amt, + minToken1Amt: params.minToken1Amt, + swapToken0: params.token0Pair != address(0) && params.swapToken0Data.length > 0, + swapToken1: params.token1Pair != address(0) && params.swapToken1Data.length > 0, + token0Pair: params.token0Pair, + token0Spender: effectiveToken0Spender, + token1Pair: params.token1Pair, + token1Spender: effectiveToken1Spender, + swapToken0Data: params.swapToken0Data, + swapToken1Data: params.swapToken1Data + }) + ) + ); + + emit V3Liquidation(id, params.v3Provider, borrower, _seized, _repaid, 0, 0); + } + + /** + * @notice Redeem V3 shares held by this contract. + * TOKEN1 arrives as native BNB if the V3Provider pool contains WBNB. + * @param v3Provider V3Provider whose shares to redeem. + * @param shares Number of shares to redeem. + * @param minAmt0 Min TOKEN0 to receive (slippage guard). + * @param minAmt1 Min TOKEN1 / BNB to receive (slippage guard). + * @param receiver Recipient of TOKEN0 and TOKEN1 / BNB. + */ + function redeemV3Shares( + address v3Provider, + uint256 shares, + uint256 minAmt0, + uint256 minAmt1, + address receiver + ) external nonReentrant onlyRole(BOT) returns (uint256 amount0, uint256 amount1) { + require(v3Providers[v3Provider], NotWhitelisted()); + (amount0, amount1) = IV3Provider(v3Provider).redeemShares(shares, minAmt0, minAmt1, receiver); + } + + /* ─────────────────────── sell tokens ────────────────────────────── */ + + /// @notice Sell an ERC-20 token (pair == spender). + function sellToken( + address pair, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) external nonReentrant onlyRole(BOT) { + _sellToken(pair, pair, tokenIn, tokenOut, amountIn, amountOutMin, swapData); + } + + /// @notice Sell an ERC-20 token with separate pair and spender (e.g. DEX aggregator). + function sellToken( + address pair, + address spender, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) external nonReentrant onlyRole(BOT) { + require(pair != spender, "pair and spender cannot be same address"); + require(pairWhitelist[spender], NotWhitelisted()); + _sellToken(pair, spender, tokenIn, tokenOut, amountIn, amountOutMin, swapData); + } + + /// @notice Sell native BNB held by this contract. + function sellBNB( + address pair, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) external nonReentrant 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 beforeIn = address(this).balance; + uint256 beforeOut = tokenOut.balanceOf(address(this)); + + (bool success, ) = pair.call{ value: amountIn }(swapData); + require(success, SwapFailed()); + + uint256 actualIn = beforeIn - address(this).balance; + uint256 actualOut = tokenOut.balanceOf(address(this)) - beforeOut; + + require(actualIn <= amountIn, ExceedAmount()); + require(actualOut >= amountOutMin, NoProfit()); + + emit SellToken(pair, pair, BNB_ADDRESS, tokenOut, amountIn, actualOut); + } + + /* ──────────────────── Moolah callback ───────────────────────────── */ + + /** + * @dev Called by Moolah immediately before it pulls repaidAssets of loanToken from + * this contract. At this point Moolah has already transferred the seized V3 + * shares to address(this). + */ + function onMoolahLiquidate(uint256 repaidAssets, bytes calldata data) external { + require(msg.sender == MOOLAH, OnlyMoolah()); + V3LiquidateData memory d = abi.decode(data, (V3LiquidateData)); + + if (d.redeemShares) { + address token0 = IV3Provider(d.v3Provider).TOKEN0(); + address token1 = IV3Provider(d.v3Provider).TOKEN1(); + + // Redeem V3 shares → TOKEN0 as ERC-20, TOKEN1 as ERC-20 or native BNB (if WBNB). + (uint256 amount0, uint256 amount1) = IV3Provider(d.v3Provider).redeemShares( + d.seized, + d.minToken0Amt, + d.minToken1Amt, + address(this) + ); + + // Swap TOKEN0 → loanToken (skip if already loanToken or no swap requested). + if (d.swapToken0 && amount0 > 0 && token0 != d.loanToken) { + token0.safeApprove(d.token0Spender, amount0); + (bool ok, ) = d.token0Pair.call(d.swapToken0Data); + require(ok, SwapFailed()); + token0.safeApprove(d.token0Spender, 0); + } + + // Swap TOKEN1 / native BNB → loanToken. + // V3Provider always unwraps WBNB to native BNB, so use call{value} for WBNB pools. + if (d.swapToken1 && amount1 > 0 && token1 != d.loanToken) { + if (token1 == WBNB) { + (bool ok, ) = d.token1Pair.call{ value: amount1 }(d.swapToken1Data); + require(ok, SwapFailed()); + } else { + token1.safeApprove(d.token1Spender, amount1); + (bool ok, ) = d.token1Pair.call(d.swapToken1Data); + require(ok, SwapFailed()); + token1.safeApprove(d.token1Spender, 0); + } + } + + if (d.loanToken.balanceOf(address(this)) < repaidAssets) revert NoProfit(); + } + + // Approve Moolah to pull the repayment (always done, flash or pre-funded). + d.loanToken.safeApprove(MOOLAH, repaidAssets); + } + + /* ─────────────────────────── internals ──────────────────────────── */ + + function _sellToken( + address pair, + address spender, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) private { + require(tokenWhitelist[tokenIn], NotWhitelisted()); + require(tokenWhitelist[tokenOut], NotWhitelisted()); + require(pairWhitelist[pair], NotWhitelisted()); + require(amountIn > 0, "amountIn zero"); + require(tokenIn.balanceOf(address(this)) >= amountIn, ExceedAmount()); + + uint256 beforeIn = tokenIn.balanceOf(address(this)); + uint256 beforeOut = tokenOut.balanceOf(address(this)); + + tokenIn.safeApprove(spender, amountIn); + (bool success, ) = pair.call(swapData); + require(success, SwapFailed()); + + uint256 actualIn = beforeIn - tokenIn.balanceOf(address(this)); + uint256 actualOut = tokenOut.balanceOf(address(this)) - beforeOut; + + require(actualIn <= amountIn, ExceedAmount()); + require(actualOut >= amountOutMin, NoProfit()); + + tokenIn.safeApprove(spender, 0); + + emit SellToken(pair, spender, tokenIn, tokenOut, actualIn, actualOut); + } + + /// @dev Validates that both pair and spender (when non-zero) are in the pair whitelist. + function _requirePairWhitelisted(address pair, address spender) internal view { + if (pair == address(0)) return; + require(pairWhitelist[pair], NotWhitelisted()); + if (spender != address(0) && spender != pair) require(pairWhitelist[spender], NotWhitelisted()); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/src/provider/SmartProvider.sol b/src/provider/SmartProvider.sol index fcf6c56d..e202765e 100644 --- a/src/provider/SmartProvider.sol +++ b/src/provider/SmartProvider.sol @@ -579,7 +579,7 @@ contract SmartProvider is /// @dev Sets the slisBNBxMinter address. function setSlisBNBxMinter(address _slisBNBxMinter) external onlyRole(MANAGER) { - require(_slisBNBxMinter != address(0), "zero address provided"); + require(_slisBNBxMinter != slisBNBxMinter, "same minter"); slisBNBxMinter = _slisBNBxMinter; emit SlisBNBxMinterChanged(_slisBNBxMinter); diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol new file mode 100644 index 00000000..870695bf --- /dev/null +++ b/src/provider/V3Provider.sol @@ -0,0 +1,1005 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { TickMath } from "@uniswap/v3-core/contracts/libraries/TickMath.sol"; +import { SqrtPriceMath } from "@uniswap/v3-core/contracts/libraries/SqrtPriceMath.sol"; +import { LiquidityAmounts } from "@uniswap/v3-periphery/contracts/libraries/LiquidityAmounts.sol"; + +import { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; +import { IOracle, TokenConfig } from "moolah/interfaces/IOracle.sol"; + +import { INonfungiblePositionManager } from "./interfaces/INonfungiblePositionManager.sol"; +import { IUniswapV3Factory } from "./interfaces/IUniswapV3Factory.sol"; +import { IUniswapV3Pool } from "./interfaces/IUniswapV3Pool.sol"; +import { IWBNB } from "./interfaces/IWBNB.sol"; +import { IV3Provider } from "./interfaces/IV3Provider.sol"; +import { ISlisBNBxMinter } from "../utils/interfaces/ISlisBNBx.sol"; + +/** + * @title V3Provider + * @author Lista DAO + * @notice Manages a single Uniswap V3 / PancakeSwap V3 concentrated liquidity position. + * Issues ERC20 shares representing pro-rata ownership of the position. + * Registered as a Moolah provider so it can supply and withdraw collateral + * on behalf of users without requiring per-user Moolah authorization. + * + * Architecture: + * - Shares (this contract's ERC20 token) are the Moolah collateral token for the market. + * - On deposit: tokens → V3 liquidity → mint shares → Moolah.supplyCollateral(onBehalf) + * - On withdraw: Moolah.withdrawCollateral → burn shares → remove V3 liquidity → tokens to receiver + * - On liquidation: Moolah sends shares to liquidator; liquidator calls redeemShares() + * - Fees are compounded into the position before every deposit/withdraw/rebalance. + * - Only Moolah may transfer shares (prevents bypassing the vault on withdrawal). + * + * Dependencies (add to lib/ or remappings): + * uniswap/v3-core - TickMath + */ +contract V3Provider is + ERC20Upgradeable, + UUPSUpgradeable, + AccessControlEnumerableUpgradeable, + ReentrancyGuardUpgradeable, + IOracle, + IV3Provider +{ + using SafeERC20 for IERC20; + using MarketParamsLib for MarketParams; + + /* ─────────────────────────── immutables ─────────────────────────── */ + + /// @dev Moolah lending core + IMoolah public immutable MOOLAH; + + /// @dev Uniswap V3 / PancakeSwap V3 NonfungiblePositionManager + INonfungiblePositionManager public immutable POSITION_MANAGER; + + /// @dev V3 pool address for TOKEN0/TOKEN1/FEE, derived from NPM factory in constructor + address public immutable POOL; + + /// @dev token0 of the V3 pool + address public immutable TOKEN0; + + /// @dev token1 of the V3 pool + address public immutable TOKEN1; + + /// @dev V3 pool fee tier (e.g. 500, 3000, 10000) + uint24 public immutable FEE; + + /// @dev TWAP window in seconds for manipulation-resistant tick queries + uint32 public immutable TWAP_PERIOD; + + /// @dev Decimal precision of TOKEN0 and TOKEN1, cached to avoid repeated external calls. + uint8 public immutable DECIMALS0; + uint8 public immutable DECIMALS1; + + /// @dev BSC wrapped native token. Users may send BNB directly; it is wrapped on entry + /// and unwrapped on exit when one of the pool tokens is WBNB. + address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev Resilient oracle used to price TOKEN0 and TOKEN1 individually (8-decimal USD) + address public resilientOracle; + + /// @dev tokenId of the V3 NFT position held by this contract; 0 means no position yet + uint256 public tokenId; + + /// @dev Lower tick of the current position range + int24 public tickLower; + + /// @dev Upper tick of the current position range + int24 public tickUpper; + + /// @dev Idle TOKEN0 balance that arose from internal ratio mismatch during compounding. + /// Tracked separately to avoid sweeping arbitrary token donations. + uint256 public idleToken0; + + /// @dev Idle TOKEN1 balance that arose from internal ratio mismatch during compounding. + /// Tracked separately to avoid sweeping arbitrary token donations. + uint256 public idleToken1; + + /// @dev user account > market id > amount of collateral(shares) deposited + mapping(address => mapping(Id => uint256)) public userMarketDeposit; + + /// @dev user account > total amount of collateral(shares) deposited + mapping(address => uint256) public userTotalDeposit; + + /// @dev slisBNBxMinter address + address public slisBNBxMinter; + + /// @dev Virtual address used by the resilient oracle to price native BNB. + address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + bytes32 public constant MANAGER = keccak256("MANAGER"); + bytes32 public constant BOT = keccak256("BOT"); + + /* ───────────────────────────── events ───────────────────────────── */ + + event SlisBNBxMinterChanged(address indexed minter); + + event Deposit( + address indexed onBehalf, + uint256 amount0Used, + uint256 amount1Used, + uint256 shares, + Id indexed marketId + ); + event Withdraw( + address indexed onBehalf, + uint256 shares, + uint256 amount0, + uint256 amount1, + address receiver, + Id indexed marketId + ); + event SharesRedeemed(address indexed redeemer, uint256 shares, uint256 amount0, uint256 amount1, address receiver); + event Compounded(uint256 fees0, uint256 fees1, uint128 liquidityAdded); + event Rebalanced(int24 oldTickLower, int24 oldTickUpper, int24 newTickLower, int24 newTickUpper, uint256 newTokenId); + + /* ─────────────────────────── constructor ────────────────────────── */ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address _moolah, + address _positionManager, + address _token0, + address _token1, + uint24 _fee, + uint32 _twapPeriod + ) { + require(_moolah != address(0), "zero address"); + require(_positionManager != address(0), "zero address"); + require(_token0 != address(0) && _token1 != address(0), "zero address"); + require(_token0 < _token1, "token0 must be < token1"); + require(_fee > 0, "zero fee"); + require(_twapPeriod > 0, "zero twap period"); + + address _pool = IUniswapV3Factory(INonfungiblePositionManager(_positionManager).factory()).getPool( + _token0, + _token1, + _fee + ); + require(_pool != address(0), "pool does not exist"); + + MOOLAH = IMoolah(_moolah); + POSITION_MANAGER = INonfungiblePositionManager(_positionManager); + TOKEN0 = _token0; + TOKEN1 = _token1; + FEE = _fee; + POOL = _pool; + TWAP_PERIOD = _twapPeriod; + DECIMALS0 = IERC20Metadata(_token0).decimals(); + DECIMALS1 = IERC20Metadata(_token1).decimals(); + + _disableInitializers(); + } + + /* ─────────────────────────── initializer ────────────────────────── */ + + /** + * @param _admin Default admin (can upgrade, grant roles) + * @param _manager Manager role (can rebalance position range) + * @param _bot Bot address granted BOT role (can trigger rebalance) + * @param _resilientOracle Resilient oracle for pricing TOKEN0 and TOKEN1 + * @param _tickLower Initial position lower tick + * @param _tickUpper Initial position upper tick + * @param _name ERC20 name for shares token + * @param _symbol ERC20 symbol for shares token + */ + function initialize( + address _admin, + address _manager, + address _bot, + address _resilientOracle, + int24 _tickLower, + int24 _tickUpper, + string calldata _name, + string calldata _symbol + ) external initializer { + require( + _admin != address(0) && _manager != address(0) && _bot != address(0) && _resilientOracle != address(0), + "zero address" + ); + require(_tickLower < _tickUpper, "invalid tick range"); + + __ERC20_init(_name, _symbol); + __AccessControl_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(MANAGER, _manager); + _setRoleAdmin(BOT, MANAGER); + _grantRole(BOT, _bot); + + resilientOracle = _resilientOracle; + tickLower = _tickLower; + tickUpper = _tickUpper; + } + + /* ──────────────────── ERC20 transfer restrictions ───────────────── */ + + /// @dev Only Moolah may transfer shares. This prevents users from transferring + /// shares directly without going through withdraw(), which would orphan V3 liquidity. + function transfer(address to, uint256 value) public override returns (bool) { + require(msg.sender == address(MOOLAH), "only moolah"); + _transfer(msg.sender, to, value); + return true; + } + + /// @dev Only Moolah may call transferFrom (e.g. when pulling collateral on supplyCollateral). + function transferFrom(address from, address to, uint256 value) public override returns (bool) { + require(msg.sender == address(MOOLAH), "only moolah"); + _transfer(from, to, value); + return true; + } + + /* ─────────────────────── core user functions ────────────────────── */ + + /** + * @notice Deposit TOKEN0 and TOKEN1, add them to the V3 position, mint shares, + * and supply those shares as Moolah collateral on behalf of `onBehalf`. + * @param marketParams Moolah market (collateralToken must equal address(this)) + * @param amount0Desired Max TOKEN0 to deposit + * @param amount1Desired Max TOKEN1 to deposit + * @param amount0Min Min TOKEN0 accepted after slippage (for V3 mint/increase) + * @param amount1Min Min TOKEN1 accepted after slippage (for V3 mint/increase) + * @param onBehalf Moolah position owner to credit collateral to + * @return shares Shares minted to represent this deposit + * @return amount0Used Actual TOKEN0 consumed by the V3 pool + * @return amount1Used Actual TOKEN1 consumed by the V3 pool + */ + function deposit( + MarketParams calldata marketParams, + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min, + address onBehalf + ) external payable nonReentrant returns (uint256 shares, uint256 amount0Used, uint256 amount1Used) { + require(marketParams.collateralToken == address(this), "invalid collateral token"); + require(onBehalf != address(0), "zero address"); + + // ── Native token handling ────────────────────────────────────────── + // If the caller sends BNB, wrap it and use it in place of the pool token + // that equals WBNB. Pull the other token via transferFrom as usual. + // Idle always stays in wrapped (ERC-20) form; only the entry boundary wraps. + uint256 _amount0Desired = amount0Desired; + uint256 _amount1Desired = amount1Desired; + + if (msg.value > 0) { + require(TOKEN0 == WBNB || TOKEN1 == WBNB, "pool has no WBNB"); + if (TOKEN0 == WBNB) { + _amount0Desired = msg.value; + } else { + _amount1Desired = msg.value; + } + IWBNB(WBNB).deposit{ value: msg.value }(); + } + + require(_amount0Desired > 0 || _amount1Desired > 0, "zero amounts"); + + // Reject upfront if the supplied amounts yield zero liquidity at the current price. + // This catches one-sided deposits in the wrong direction (e.g. token0-only when price + // is above tickUpper) before any tokens are pulled from the caller. + { + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + require( + LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + _amount0Desired, + _amount1Desired + ) > 0, + "zero liquidity" + ); + } + + // Pull ERC-20 tokens from caller. + // Skip whichever side was funded by msg.value (already wrapped and held by this contract). + if (_amount0Desired > 0 && !(TOKEN0 == WBNB && msg.value > 0)) { + IERC20(TOKEN0).safeTransferFrom(msg.sender, address(this), _amount0Desired); + } + if (_amount1Desired > 0 && !(TOKEN1 == WBNB && msg.value > 0)) { + IERC20(TOKEN1).safeTransferFrom(msg.sender, address(this), _amount1Desired); + } + + // Compound pending fees before computing share ratio so existing holders + // capture accrued fees before new shares dilute them. + _collectAndCompound(); + + uint128 liquidityBefore = _getPositionLiquidity(); + uint256 supplyBefore = totalSupply(); + + uint128 liquidityAdded; + if (tokenId == 0) { + // No position exists yet — mint a fresh V3 NFT. + IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), _amount0Desired); + IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), _amount1Desired); + + (tokenId, liquidityAdded, amount0Used, amount1Used) = POSITION_MANAGER.mint( + INonfungiblePositionManager.MintParams({ + token0: TOKEN0, + token1: TOKEN1, + fee: FEE, + tickLower: tickLower, + tickUpper: tickUpper, + amount0Desired: _amount0Desired, + amount1Desired: _amount1Desired, + amount0Min: amount0Min, + amount1Min: amount1Min, + recipient: address(this), + deadline: block.timestamp + }) + ); + + // First depositor: shares 1:1 with liquidity units. + shares = uint256(liquidityAdded); + } else { + // Existing position — increase liquidity. + IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), _amount0Desired); + IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), _amount1Desired); + + (liquidityAdded, amount0Used, amount1Used) = POSITION_MANAGER.increaseLiquidity( + INonfungiblePositionManager.IncreaseLiquidityParams({ + tokenId: tokenId, + amount0Desired: _amount0Desired, + amount1Desired: _amount1Desired, + amount0Min: amount0Min, + amount1Min: amount1Min, + deadline: block.timestamp + }) + ); + + // Subsequent depositors: proportional to liquidity contributed vs pre-deposit total. + if (supplyBefore == 0 || liquidityBefore == 0) { + shares = uint256(liquidityAdded); + } else { + shares = (uint256(liquidityAdded) * supplyBefore) / uint256(liquidityBefore); + } + } + + require(shares > 0, "zero shares"); + + // Refund any tokens not consumed by the V3 pool (ratio mismatch). + // WBNB refunds are unwrapped back to BNB before sending. + uint256 refund0 = _amount0Desired - amount0Used; + uint256 refund1 = _amount1Desired - amount1Used; + if (refund0 > 0) _sendToken(TOKEN0, refund0, payable(msg.sender)); + if (refund1 > 0) _sendToken(TOKEN1, refund1, payable(msg.sender)); + + // Mint shares to this contract, then grant Moolah a one-time allowance so + // supplyCollateral can pull them. Our transferFrom restricts the caller to + // Moolah, so _approve is used internally to set the allowance. + _mint(address(this), shares); + _approve(address(this), address(MOOLAH), shares); + MOOLAH.supplyCollateral(marketParams, shares, onBehalf, ""); + + _syncPosition(marketParams.id(), onBehalf); + + emit Deposit(onBehalf, amount0Used, amount1Used, shares, marketParams.id()); + } + + /** + * @notice Withdraw shares from Moolah, remove the proportional V3 liquidity, + * and return TOKEN0/TOKEN1 to `receiver`. + * @dev Caller must be `onBehalf` or authorized via MOOLAH.isAuthorized(). + * @param marketParams Moolah market (collateralToken must equal address(this)) + * @param shares Number of shares to redeem + * @param minAmount0 Min TOKEN0 to receive (slippage guard) + * @param minAmount1 Min TOKEN1 to receive (slippage guard) + * @param onBehalf Owner of the Moolah collateral position + * @param receiver Address to send TOKEN0/TOKEN1 to + */ + function withdraw( + MarketParams calldata marketParams, + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address onBehalf, + address receiver + ) external nonReentrant returns (uint256 amount0, uint256 amount1) { + require(marketParams.collateralToken == address(this), "invalid collateral token"); + require(shares > 0, "zero shares"); + require(receiver != address(0), "zero address"); + require(_isSenderAuthorized(onBehalf), "unauthorized"); + + // Moolah decrements position.collateral and transfers shares to address(this). + // Our transfer() allows msg.sender == MOOLAH, so this succeeds. + MOOLAH.withdrawCollateral(marketParams, shares, onBehalf, address(this)); + + _syncPosition(marketParams.id(), onBehalf); + + _collectAndCompound(); + + (amount0, amount1) = _burnSharesAndRemoveLiquidity(shares, minAmount0, minAmount1, receiver); + + emit Withdraw(onBehalf, shares, amount0, amount1, receiver, marketParams.id()); + } + + /** + * @notice Redeem shares already held by the caller (typically a liquidator that + * received shares from Moolah during liquidation) for TOKEN0/TOKEN1. + * @param shares Number of shares to redeem + * @param minAmount0 Min TOKEN0 to receive (slippage guard) + * @param minAmount1 Min TOKEN1 to receive (slippage guard) + * @param receiver Address to send TOKEN0/TOKEN1 to + */ + function redeemShares( + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address receiver + ) external nonReentrant returns (uint256 amount0, uint256 amount1) { + require(shares > 0, "zero shares"); + require(receiver != address(0), "zero address"); + require(balanceOf(msg.sender) >= shares, "insufficient shares"); + + _collectAndCompound(); + + // Transfer shares from caller to this contract so _burnSharesAndRemoveLiquidity + // can burn from address(this). We use the internal _transfer to bypass the + // Moolah-only restriction (caller holds their own shares). + _transfer(msg.sender, address(this), shares); + + (amount0, amount1) = _burnSharesAndRemoveLiquidity(shares, minAmount0, minAmount1, receiver); + + emit SharesRedeemed(msg.sender, shares, amount0, amount1, receiver); + } + + /* ──────────────────── Moolah provider callback ──────────────────── */ + + /** + * @dev Called by Moolah after a liquidation event. + * Syncs the borrower's deposit tracking and triggers slisBNBx rebalance if configured. + * Moolah already transferred the seized shares to the liquidator via transfer(). + */ + function liquidate(Id id, address borrower) external { + require(msg.sender == address(MOOLAH), "only moolah"); + require(MOOLAH.idToMarketParams(id).collateralToken == address(this), "invalid market"); + _syncPosition(id, borrower); + } + + /* ───────────────────── manager: rebalance range ─────────────────── */ + + /** + * @notice Move the position to a new tick range. Collects all fees, removes all + * liquidity, burns the old NFT, and mints a new position at the new ticks. + * Share count is unchanged — each share now represents the new range. + * @dev Caller must hold MANAGER role. A price movement between decreaseLiquidity + * and the new mint is the primary slippage risk; minAmount0/minAmount1 guard against it. + * @param _tickLower New lower tick + * @param _tickUpper New upper tick + * @param minAmount0 Min TOKEN0 to receive when removing old liquidity + * @param minAmount1 Min TOKEN1 to receive when removing old liquidity + * @param amount0Desired TOKEN0 to reinvest into the new position. Must not exceed + * the total internally collected (fees + idle + removed liquidity). + * Pass type(uint256).max to reinvest everything. + * @param amount1Desired TOKEN1 to reinvest into the new position. Same semantics. + */ + function rebalance( + int24 _tickLower, + int24 _tickUpper, + uint256 minAmount0, + uint256 minAmount1, + uint256 amount0Desired, + uint256 amount1Desired + ) external onlyRole(BOT) nonReentrant { + require(_tickLower < _tickUpper, "invalid tick range"); + + int24 oldTickLower = tickLower; + int24 oldTickUpper = tickUpper; + + // 1. Collect all fees; track amounts explicitly to avoid balanceOf donation surface. + (uint256 total0, uint256 total1) = _collectAll(); + + // Add previously idle tokens from compound ratio mismatches. + total0 += idleToken0; + total1 += idleToken1; + idleToken0 = 0; + idleToken1 = 0; + + // 2. Remove all existing liquidity. + if (tokenId != 0) { + uint128 liquidity = _getPositionLiquidity(); + if (liquidity > 0) { + POSITION_MANAGER.decreaseLiquidity( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: tokenId, + liquidity: liquidity, + amount0Min: minAmount0, + amount1Min: minAmount1, + deadline: block.timestamp + }) + ); + } + // Collect removed liquidity back to this contract; accumulate into tracked totals. + (uint256 removed0, uint256 removed1) = POSITION_MANAGER.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + total0 += removed0; + total1 += removed1; + + POSITION_MANAGER.burn(tokenId); + tokenId = 0; + } + + // 3. Update range. + tickLower = _tickLower; + tickUpper = _tickUpper; + + // 4. Re-mint with caller-specified amounts (capped to internally available). + // This lets the BOT pre-compute the optimal ratio for the new tick range, + // minimising idle remainder. Excess stays in idleToken0/1 for next compound. + uint256 toMint0 = amount0Desired > total0 ? total0 : amount0Desired; + uint256 toMint1 = amount1Desired > total1 ? total1 : amount1Desired; + + if (toMint0 > 0 || toMint1 > 0) { + IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), toMint0); + IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), toMint1); + + (uint256 newTokenId, , uint256 used0, uint256 used1) = POSITION_MANAGER.mint( + INonfungiblePositionManager.MintParams({ + token0: TOKEN0, + token1: TOKEN1, + fee: FEE, + tickLower: _tickLower, + tickUpper: _tickUpper, + amount0Desired: toMint0, + amount1Desired: toMint1, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + }) + ); + tokenId = newTokenId; + + // Any leftover (caller under-specified or ratio mismatch) tracked for next compound. + idleToken0 = total0 - used0; + idleToken1 = total1 - used1; + } else { + // Nothing to mint; park everything as idle. + idleToken0 = total0; + idleToken1 = total1; + } + + emit Rebalanced(oldTickLower, oldTickUpper, _tickLower, _tickUpper, tokenId); + } + + /* ───────────────────────── view functions ───────────────────────── */ + + /** + * @notice Total TOKEN0 and TOKEN1 represented by the vault at the current spot price. + * Includes amounts locked in the V3 position, uncollected fees (tokensOwed), + * and any idle token balances held by this contract. + * @dev Uses slot0 — suitable for display and bot decisions, NOT for the lending oracle. + * peek() uses the TWAP price to resist manipulation; see _getTotalAmountsAt. + */ + function getTotalAmounts() public view returns (uint256 total0, uint256 total1) { + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + return _getTotalAmountsAt(sqrtPriceX96); + } + + /** + * @notice Simulates a redemption and returns the token amounts a holder would receive + * for burning `shares` at the current pool price. + * Use this to compute tight `minAmount0`/`minAmount1` before calling + * `withdraw` or `redeemShares`: + * + * (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + * uint256 min0 = exp0 * 995 / 1000; // 0.5 % slippage tolerance + * uint256 min1 = exp1 * 995 / 1000; + * provider.withdraw(marketParams, shares, min0, min1, onBehalf, receiver); + * + * @param shares Number of provider shares to redeem. + * @return amount0 TOKEN0 the caller would receive (≥ minAmount0 to pass slippage guard). + * @return amount1 TOKEN1 the caller would receive (≥ minAmount1 to pass slippage guard). + */ + function previewRedeem(uint256 shares) external view returns (uint256 amount0, uint256 amount1) { + uint256 supply = totalSupply(); + if (supply == 0 || shares == 0) return (0, 0); + + uint128 totalLiquidity = _getPositionLiquidity(); + uint128 liquidityToRemove = uint128((uint256(totalLiquidity) * shares) / supply); + + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + (amount0, amount1) = _getAmountsForLiquidity( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + liquidityToRemove + ); + } + + /** + * @notice Simulates a deposit and returns the token amounts that would actually be consumed + * plus the liquidity that would be minted, given desired input amounts. + * Use this to compute tight `amount0Min`/`amount1Min` before calling `deposit`: + * + * (uint128 liq, uint256 exp0, uint256 exp1) = provider.previewDeposit(des0, des1); + * uint256 min0 = exp0 * 995 / 1000; // 0.5 % slippage tolerance + * uint256 min1 = exp1 * 995 / 1000; + * provider.deposit(marketParams, des0, des1, min0, min1, onBehalf); + * + * @param amount0Desired Amount of TOKEN0 the caller intends to supply. + * @param amount1Desired Amount of TOKEN1 the caller intends to supply. + * @return liquidity Liquidity units that would be added to the position. + * @return amount0 TOKEN0 that would actually be consumed (≤ amount0Desired). + * @return amount1 TOKEN1 that would actually be consumed (≤ amount1Desired). + */ + function previewDeposit( + uint256 amount0Desired, + uint256 amount1Desired + ) external view returns (uint128 liquidity, uint256 amount0, uint256 amount1) { + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + uint160 sqrtRatioLower = TickMath.getSqrtRatioAtTick(tickLower); + uint160 sqrtRatioUpper = TickMath.getSqrtRatioAtTick(tickUpper); + + liquidity = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + sqrtRatioLower, + sqrtRatioUpper, + amount0Desired, + amount1Desired + ); + (amount0, amount1) = _getAmountsForLiquidity(sqrtPriceX96, sqrtRatioLower, sqrtRatioUpper, liquidity); + } + + /// @dev Returns the TOKEN field required by the IProvider interface. + /// For V3Provider, the "token" is this contract itself (the shares ERC20). + function TOKEN() external view returns (address) { + return address(this); + } + + /* ─────────────────────── IOracle implementation ─────────────────── */ + + /** + * @notice Returns the USD price (8 decimals) for a given token. + * - If token == address(this): prices V3Provider shares as + * (total0 × price0 + total1 × price1) / totalSupply. + * - Otherwise: delegates directly to the resilient oracle. + * + * @dev Token composition is derived from the TWAP tick (not slot0) so a single-block + * AMM price manipulation cannot inflate the reported collateral value. + * pool.observe() reverts when the pool lacks TWAP_PERIOD seconds of history, + * which in turn reverts peek() — intentionally blocking borrows until the market + * has seasoned. Do NOT add a slot0 fallback here. + */ + function peek(address token) external view override returns (uint256) { + if (token != address(this)) { + return IOracle(resilientOracle).peek(token); + } + + uint256 supply = totalSupply(); + if (supply == 0) return 0; + + uint160 sqrtTwapX96 = TickMath.getSqrtRatioAtTick(getTwapTick()); + (uint256 total0, uint256 total1) = _getTotalAmountsAt(sqrtTwapX96); + + uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8 decimals + uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8 decimals + + uint256 totalValue = (total0 * price0) / (10 ** DECIMALS0) + (total1 * price1) / (10 ** DECIMALS1); + + // shares are 18-decimal; return 8-decimal price per share + return (totalValue * 1e18) / supply; + } + + /** + * @notice Returns the TokenConfig for a given token. + * - If token == address(this): registers this contract as the primary oracle + * so the resilient oracle can delegate share pricing back to us. + * - Otherwise: delegates to the resilient oracle. + */ + function getTokenConfig(address token) external view override returns (TokenConfig memory) { + if (token != address(this)) { + return IOracle(resilientOracle).getTokenConfig(token); + } + return + TokenConfig({ + asset: token, + oracles: [address(this), address(0), address(0)], + enableFlagsForOracles: [true, false, false], + timeDeltaTolerance: 0 + }); + } + + /** + * @notice Returns the TWAP tick for POOL over TWAP_PERIOD seconds. + * Useful for bots to cross-check whether the current slot0 tick deviates + * significantly from the TWAP before triggering a rebalance. + * Public (not external) so peek() can call it directly. + */ + function getTwapTick() public view returns (int24 twapTick) { + uint32[] memory secondsAgos = new uint32[](2); + secondsAgos[0] = TWAP_PERIOD; + secondsAgos[1] = 0; + + (int56[] memory tickCumulatives, ) = IUniswapV3Pool(POOL).observe(secondsAgos); + + int56 delta = tickCumulatives[1] - tickCumulatives[0]; + twapTick = int24(delta / int56(uint56(TWAP_PERIOD))); + if (delta < 0 && (delta % int56(uint56(TWAP_PERIOD)) != 0)) twapTick--; + } + + /* ─────────────────── slisBNBx: sync / view ──────────────────────── */ + + /** + * @notice Returns the user's total deposited collateral value expressed in BNB (18 decimals). + * Called by SlisBNBxMinter as the ISlisBNBxModule callback to compute how much + * slisBNBx the user is entitled to. + * @param account The user whose position is being priced. + */ + function getUserBalanceInBnb(address account) external view returns (uint256) { + uint256 shares = userTotalDeposit[account]; + if (shares == 0) return 0; + + uint256 supply = totalSupply(); + if (supply == 0) return 0; + + (uint256 total0, uint256 total1) = getTotalAmounts(); + + uint256 user0 = (total0 * shares) / supply; + uint256 user1 = (total1 * shares) / supply; + + uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8-decimal USD + uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8-decimal USD + uint256 bnbPrice = IOracle(resilientOracle).peek(BNB_ADDRESS); // 8-decimal USD + + // Scale up by 1e18 before dividing by bnbPrice so the result is 18-decimal BNB. + uint256 value0 = (user0 * price0 * 1e18) / (10 ** DECIMALS0); + uint256 value1 = (user1 * price1 * 1e18) / (10 ** DECIMALS1); + + return (value0 + value1) / bnbPrice; + } + + /** + * @notice Manually sync one user's deposit tracking and slisBNBx balance for a market. + * @param id Moolah market Id (collateralToken must equal address(this)). + * @param account User to sync. + */ + function syncUserBalance(Id id, address account) external { + require(MOOLAH.idToMarketParams(id).collateralToken == address(this), "invalid market"); + _syncPosition(id, account); + } + + /** + * @notice Batch sync multiple users across multiple markets. + * @param ids Array of market Ids. + * @param accounts Array of user addresses (parallel to ids). + */ + function bulkSyncUserBalance(Id[] calldata ids, address[] calldata accounts) external { + require(ids.length == accounts.length, "length mismatch"); + for (uint256 i = 0; i < accounts.length; i++) { + require(MOOLAH.idToMarketParams(ids[i]).collateralToken == address(this), "invalid market"); + _syncPosition(ids[i], accounts[i]); + } + } + + /* ──────────────────── manager: slisBNBxMinter ───────────────────── */ + + /// @notice Set (or unset) the SlisBNBxMinter plugin. Pass address(0) to disable. + /// When set, deposit/withdraw/liquidate call minter.rebalance(account). + function setSlisBNBxMinter(address _slisBNBxMinter) external onlyRole(MANAGER) { + slisBNBxMinter = _slisBNBxMinter; + emit SlisBNBxMinterChanged(_slisBNBxMinter); + } + + /* ─────────────────────────── internals ──────────────────────────── */ + + /// @dev Reads the user's current Moolah collateral for `id`, diffs against the last + /// recorded snapshot in `userMarketDeposit`, updates `userTotalDeposit`, then + /// calls `slisBNBxMinter.rebalance(account)` if a minter is configured. + /// Callers that have already validated the market (deposit, withdraw) skip the + /// idToMarketParams check; liquidate() validates before calling this. + function _syncPosition(Id id, address account) internal { + uint256 current = MOOLAH.position(id, account).collateral; + + if (current >= userMarketDeposit[account][id]) { + userTotalDeposit[account] += current - userMarketDeposit[account][id]; + } else { + userTotalDeposit[account] -= userMarketDeposit[account][id] - current; + } + userMarketDeposit[account][id] = current; + + if (slisBNBxMinter != address(0)) { + ISlisBNBxMinter(slisBNBxMinter).rebalance(account); + } + } + + /// @dev Collect accrued fees from the position and re-add them plus any previously + /// idle tokens (from prior ratio mismatches) as liquidity. + /// Idle amounts are tracked in storage rather than read from balanceOf() to + /// avoid sweeping arbitrary token donations into the position. + function _collectAndCompound() internal { + if (tokenId == 0) return; + + (uint256 fees0, uint256 fees1) = POSITION_MANAGER.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + + uint256 toCompound0 = fees0 + idleToken0; + uint256 toCompound1 = fees1 + idleToken1; + + if (toCompound0 == 0 && toCompound1 == 0) return; + + IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), toCompound0); + IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), toCompound1); + + (uint128 liquidityAdded, uint256 used0, uint256 used1) = POSITION_MANAGER.increaseLiquidity( + INonfungiblePositionManager.IncreaseLiquidityParams({ + tokenId: tokenId, + amount0Desired: toCompound0, + amount1Desired: toCompound1, + amount0Min: 0, + amount1Min: 0, + deadline: block.timestamp + }) + ); + + // Track leftover from ratio mismatch so it's swept on the next compound. + idleToken0 = toCompound0 - used0; + idleToken1 = toCompound1 - used1; + + emit Compounded(toCompound0, toCompound1, liquidityAdded); + } + + /// @dev Collect all pending fees without compounding (used before rebalance). + /// Returns the amounts collected so callers can track totals without balanceOf. + function _collectAll() internal returns (uint256 collected0, uint256 collected1) { + if (tokenId == 0) return (0, 0); + (collected0, collected1) = POSITION_MANAGER.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + } + + /// @dev Burn `shares` held by address(this), remove proportional V3 liquidity, + /// collect the resulting tokens to this contract, then forward to `receiver` + /// — unwrapping WBNB to native BNB along the way. + function _burnSharesAndRemoveLiquidity( + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address receiver + ) internal returns (uint256 amount0, uint256 amount1) { + uint256 supply = totalSupply(); + uint128 totalLiquidity = _getPositionLiquidity(); + + // Compute liquidity to remove proportionally to shares being redeemed. + uint128 liquidityToRemove = uint128((uint256(totalLiquidity) * shares) / supply); + + _burn(address(this), shares); + + if (liquidityToRemove > 0) { + POSITION_MANAGER.decreaseLiquidity( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: tokenId, + liquidity: liquidityToRemove, + amount0Min: minAmount0, + amount1Min: minAmount1, + deadline: block.timestamp + }) + ); + + // Collect to address(this) so we can unwrap WBNB before forwarding. + (amount0, amount1) = POSITION_MANAGER.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + + if (amount0 > 0) _sendToken(TOKEN0, amount0, payable(receiver)); + if (amount1 > 0) _sendToken(TOKEN1, amount1, payable(receiver)); + } + } + + /// @dev Transfer `token` to `to`. If `token == WBNB`, unwrap first and send native BNB; + /// otherwise send as ERC-20. + /// Idle tokens (idleToken0/1) always stay in wrapped ERC-20 form; this helper + /// is only called at the exit boundary (withdraw / redeemShares / deposit refund). + function _sendToken(address token, uint256 amount, address payable to) internal { + if (token == WBNB) { + IWBNB(WBNB).withdraw(amount); + (bool ok, ) = to.call{ value: amount }(""); + require(ok, "BNB transfer failed"); + } else { + IERC20(token).safeTransfer(to, amount); + } + } + + /// @dev Accepts native BNB sent by WBNB during unwrap. + receive() external payable { + require(msg.sender == WBNB, "not WBNB"); + } + + /// @dev Returns the current liquidity of the managed V3 position. + function _getPositionLiquidity() internal view returns (uint128 liquidity) { + if (tokenId == 0) return 0; + (, , , , , , , liquidity, , , , ) = POSITION_MANAGER.positions(tokenId); + } + + /// @dev True if the sender may act on behalf of `onBehalf`. + function _isSenderAuthorized(address onBehalf) internal view returns (bool) { + return msg.sender == onBehalf || MOOLAH.isAuthorized(onBehalf, msg.sender); + } + + /* ──────── Uniswap V3 liquidity math (via v3-core libraries) ──────── */ + + /// @dev Shared implementation for getTotalAmounts() and peek(). Callers supply the + /// sqrtPriceX96 so each can use the price appropriate for its purpose: + /// slot0 for display/bots, TWAP for the lending oracle. + function _getTotalAmountsAt(uint160 sqrtPriceX96) private view returns (uint256 total0, uint256 total1) { + if (tokenId == 0) return (0, 0); + + (, , , , , , , uint128 liquidity, , , uint128 tokensOwed0, uint128 tokensOwed1) = POSITION_MANAGER.positions( + tokenId + ); + + (uint256 amount0, uint256 amount1) = _getAmountsForLiquidity( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + liquidity + ); + + // Add uncollected fees and internally-tracked idle tokens (ratio mismatch leftovers). + // Using idleToken0/1 instead of balanceOf() prevents donated tokens from inflating + // the share price reported by peek(). + total0 = amount0 + uint256(tokensOwed0) + idleToken0; + total1 = amount1 + uint256(tokensOwed1) + idleToken1; + } + + /// @dev Computes token amounts for a given liquidity position at sqrtPriceX96. + /// Delegates to SqrtPriceMath from uniswap/v3-core for overflow-safe arithmetic. + function _getAmountsForLiquidity( + uint160 sqrtPriceX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity + ) internal pure returns (uint256 amount0, uint256 amount1) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + if (sqrtPriceX96 <= sqrtRatioAX96) { + // Current price below range: position is fully TOKEN0. + amount0 = SqrtPriceMath.getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, false); + } else if (sqrtPriceX96 < sqrtRatioBX96) { + // Current price inside range. + amount0 = SqrtPriceMath.getAmount0Delta(sqrtPriceX96, sqrtRatioBX96, liquidity, false); + amount1 = SqrtPriceMath.getAmount1Delta(sqrtRatioAX96, sqrtPriceX96, liquidity, false); + } else { + // Current price above range: position is fully TOKEN1. + amount1 = SqrtPriceMath.getAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, false); + } + } + + /* ──────────────────────── upgrade guard ─────────────────────────── */ + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/src/provider/interfaces/INonfungiblePositionManager.sol b/src/provider/interfaces/INonfungiblePositionManager.sol new file mode 100644 index 00000000..6c0ddb81 --- /dev/null +++ b/src/provider/interfaces/INonfungiblePositionManager.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +/// @title Minimal interface for Uniswap V3 / PancakeSwap V3 NonfungiblePositionManager +interface INonfungiblePositionManager { + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + struct IncreaseLiquidityParams { + uint256 tokenId; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + struct DecreaseLiquidityParams { + uint256 tokenId; + uint128 liquidity; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + struct CollectParams { + uint256 tokenId; + address recipient; + uint128 amount0Max; + uint128 amount1Max; + } + + function mint( + MintParams calldata params + ) external returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + + function increaseLiquidity( + IncreaseLiquidityParams calldata params + ) external returns (uint128 liquidity, uint256 amount0, uint256 amount1); + + function decreaseLiquidity( + DecreaseLiquidityParams calldata params + ) external returns (uint256 amount0, uint256 amount1); + + function collect(CollectParams calldata params) external returns (uint256 amount0, uint256 amount1); + + function burn(uint256 tokenId) external; + + function factory() external view returns (address); + + function positions( + uint256 tokenId + ) + external + view + returns ( + uint96 nonce, + address operator, + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1 + ); +} diff --git a/src/provider/interfaces/IUniswapV3Factory.sol b/src/provider/interfaces/IUniswapV3Factory.sol new file mode 100644 index 00000000..38127cd0 --- /dev/null +++ b/src/provider/interfaces/IUniswapV3Factory.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +/// @title Minimal interface for Uniswap V3 / PancakeSwap V3 factory +interface IUniswapV3Factory { + /// @notice Returns the pool address for a given token pair and fee tier, or address(0) if none. + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool); +} diff --git a/src/provider/interfaces/IUniswapV3Pool.sol b/src/provider/interfaces/IUniswapV3Pool.sol new file mode 100644 index 00000000..6d660a3a --- /dev/null +++ b/src/provider/interfaces/IUniswapV3Pool.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +/// @title Minimal interface for Uniswap V3 / PancakeSwap V3 pool +interface IUniswapV3Pool { + function token0() external view returns (address); + + function token1() external view returns (address); + + function fee() external view returns (uint24); + + /// @return sqrtPriceX96 Current sqrt price as Q64.96 + /// @return tick Current tick + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint32 feeProtocol, + bool unlocked + ); + + /// @param secondsAgos Array of seconds in the past to query + /// @return tickCumulatives Cumulative tick values for each secondsAgo + /// @return secondsPerLiquidityCumulativeX128s Cumulative seconds-per-liquidity for each secondsAgo + function observe( + uint32[] calldata secondsAgos + ) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); + + /// @notice Swap token0 for token1, or token1 for token0 + /// @param recipient Address to receive the output tokens + /// @param zeroForOne True if swapping token0 → token1, false if token1 → token0 + /// @param amountSpecified Exact input (positive) or exact output (negative) + /// @param sqrtPriceLimitX96 Price limit; use MIN_SQRT_RATIO+1 for zeroForOne, MAX_SQRT_RATIO-1 otherwise + /// @param data Arbitrary data forwarded to the swap callback + function swap( + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 sqrtPriceLimitX96, + bytes calldata data + ) external returns (int256 amount0, int256 amount1); +} diff --git a/src/provider/interfaces/IV3Provider.sol b/src/provider/interfaces/IV3Provider.sol new file mode 100644 index 00000000..2f5ce1fc --- /dev/null +++ b/src/provider/interfaces/IV3Provider.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; +import { IProvider } from "./IProvider.sol"; + +interface IV3Provider is IProvider, IOracle { + function TOKEN0() external view returns (address); + + function TOKEN1() external view returns (address); + + function FEE() external view returns (uint24); + + function POOL() external view returns (address); + + function tokenId() external view returns (uint256); + + function tickLower() external view returns (int24); + + function tickUpper() external view returns (int24); + + /// @notice Returns total token0 and token1 amounts held by the vault, + /// including liquidity-equivalent amounts and uncollected fees. + function getTotalAmounts() external view returns (uint256 total0, uint256 total1); + + /// @notice Returns the TWAP tick for the pool over the configured TWAP_PERIOD. + function getTwapTick() external view returns (int24 twapTick); + + /// @notice Deposit token0/token1 into the V3 position and supply resulting + /// shares as Moolah collateral on behalf of `onBehalf`. + function deposit( + MarketParams calldata marketParams, + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min, + address onBehalf + ) external payable returns (uint256 shares, uint256 amount0Used, uint256 amount1Used); + + /// @notice Withdraw shares from Moolah, remove liquidity, and return + /// token0/token1 to `receiver`. + function withdraw( + MarketParams calldata marketParams, + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address onBehalf, + address receiver + ) external returns (uint256 amount0, uint256 amount1); + + /// @notice Redeem shares already held by the caller (e.g. a liquidator) + /// for the underlying token0/token1. + function redeemShares( + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address receiver + ) external returns (uint256 amount0, uint256 amount1); +} diff --git a/test/liquidator/V3Liquidator.t.sol b/test/liquidator/V3Liquidator.t.sol new file mode 100644 index 00000000..4daf0128 --- /dev/null +++ b/test/liquidator/V3Liquidator.t.sol @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { V3Provider } from "../../src/provider/V3Provider.sol"; +import { V3Liquidator } from "../../src/liquidator/V3Liquidator.sol"; +import { IUniswapV3Pool } from "../../src/provider/interfaces/IUniswapV3Pool.sol"; +import { Moolah } from "../../src/moolah/Moolah.sol"; +import { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; + +import { MockOneInch } from "./mocks/MockOneInch.sol"; + +contract V3LiquidatorTest is Test { + using MarketParamsLib for MarketParams; + + /* ─────────────────── PancakeSwap V3 BSC mainnet ─────────────────── */ + address constant POOL = 0x4141325bAc36aFFe9Db165e854982230a14e6d48; // USDC/WBNB + address constant NPM = 0x7b8A01B39D58278b5DE7e48c8449c9f4F5170613; + uint24 constant FEE = 100; + + /* ───────────────────────────── tokens ───────────────────────────── */ + address constant USDC = 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d; // token0 + address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; // token1 + address constant LISUSD = 0x0782b6d8c4551B9760e74c0545a9bCD90bdc41E5; + address constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /* ──────────────────────── Moolah ecosystem ──────────────────────── */ + address constant MOOLAH_PROXY = 0x8F73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C; + address constant TIMELOCK = 0x07D274a68393E8b8a2CCf19A2ce4Ba3518735253; + address constant OPERATOR = 0xd7e38800201D6a42C408Bf79d8723740C4E7f631; + address constant MANAGER_ADDR = 0x8d388136d578dCD791D081c6042284CED6d9B0c6; + address constant RESILIENT_ORACLE = 0xf3afD82A4071f272F403dC176916141f44E6c750; + address constant IRM = 0xFe7dAe87Ebb11a7BEB9F534BB23267992d9cDe7c; + + uint32 constant TWAP_PERIOD = 1800; + uint256 constant LLTV = 70 * 1e16; + + /* ───────────────────────── test contracts ───────────────────────── */ + Moolah moolah; + V3Provider provider; + V3Liquidator liquidator; + MockOneInch mockSwap; + MarketParams marketParams; + Id marketId; + + /* ───────────────────────── test accounts ────────────────────────── */ + address admin = makeAddr("admin"); + address manager = makeAddr("manager"); + address bot = makeAddr("bot"); + address user = makeAddr("user"); + + /* ────────────────────────────── setUp ───────────────────────────── */ + + function setUp() public { + vm.createSelectFork(vm.envString("BSC_RPC"), 60541406); + + // Upgrade Moolah to the latest local implementation. + address newImpl = address(new Moolah()); + vm.prank(TIMELOCK); + UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newImpl, bytes("")); + moolah = Moolah(MOOLAH_PROXY); + + // Deploy V3Provider. + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + V3Provider implP = new V3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); + provider = V3Provider( + payable( + new ERC1967Proxy( + address(implP), + abi.encodeCall( + V3Provider.initialize, + (admin, manager, bot, RESILIENT_ORACLE, currentTick - 500, currentTick + 500, "V3LP USDC/WBNB", "v3LP") + ) + ) + ) + ); + + // Deploy V3Liquidator. + V3Liquidator implL = new V3Liquidator(MOOLAH_PROXY); + liquidator = V3Liquidator( + payable(new ERC1967Proxy(address(implL), abi.encodeCall(V3Liquidator.initialize, (admin, manager, bot)))) + ); + + mockSwap = new MockOneInch(); + + // Build Moolah market: collateral = provider shares, oracle = provider. + marketParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(provider), + irm: IRM, + lltv: LLTV + }); + marketId = marketParams.id(); + + vm.prank(OPERATOR); + moolah.createMarket(marketParams); + + vm.prank(MANAGER_ADDR); + moolah.setProvider(marketId, address(provider), true); + + // Seed lisUSD liquidity so borrows can succeed. + deal(LISUSD, address(this), 1_000_000 ether); + IERC20(LISUSD).approve(MOOLAH_PROXY, 1_000_000 ether); + moolah.supply(marketParams, 1_000_000 ether, 0, address(this), ""); + + // Configure liquidator whitelists. + vm.startPrank(manager); + liquidator.setTokenWhitelist(USDC, true); + liquidator.setTokenWhitelist(LISUSD, true); + liquidator.setTokenWhitelist(BNB_ADDRESS, true); + liquidator.setMarketWhitelist(Id.unwrap(marketId), true); + liquidator.setPairWhitelist(address(mockSwap), true); + liquidator.setV3ProviderWhitelist(address(provider), true); + vm.stopPrank(); + } + + /* ──────────────────────── helper fns ───────────────────────────── */ + + function _deposit( + address _user, + uint256 amount0, + uint256 amount1 + ) internal returns (uint256 shares, uint256 used0, uint256 used1) { + deal(USDC, _user, amount0); + deal(WBNB, _user, amount1); + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + vm.startPrank(_user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + (shares, used0, used1) = provider.deposit( + marketParams, + amount0, + amount1, + (exp0 * 999) / 1000, + (exp1 * 999) / 1000, + _user + ); + vm.stopPrank(); + } + + function _collateral(address _user) internal view returns (uint256) { + (, , uint256 col) = moolah.position(marketId, _user); + return col; + } + + /// @dev Borrow 60% of user's collateral value — healthy, but mocking oracle to 0 makes it unhealthy. + function _borrowAgainstCollateral(address _user) internal returns (uint256 borrowed) { + (, , uint128 col) = moolah.position(marketId, _user); + uint256 sharePrice = provider.peek(address(provider)); + uint256 loanPrice = provider.peek(LISUSD); + borrowed = (uint256(col) * sharePrice * 60) / (loanPrice * 100); + vm.prank(_user); + moolah.borrow(marketParams, borrowed, 0, _user, _user); + } + + /// @dev Mock collateral oracle to zero, making any indebted position liquidatable. + function _makeUnhealthy() internal { + vm.mockCall( + address(provider), + abi.encodeWithSelector(IOracle.peek.selector, address(provider)), + abi.encode(uint256(0)) + ); + } + + /* ─────────────────── whitelist management ───────────────────────── */ + + function test_setTokenWhitelist_togglesAndReverts() public { + vm.prank(manager); + liquidator.setTokenWhitelist(WBNB, true); + assertTrue(liquidator.tokenWhitelist(WBNB)); + + vm.prank(manager); + vm.expectRevert(V3Liquidator.WhitelistSameStatus.selector); + liquidator.setTokenWhitelist(WBNB, true); + + vm.prank(user); + vm.expectRevert(); + liquidator.setTokenWhitelist(WBNB, false); + } + + function test_setMarketWhitelist_toggles() public { + bytes32 id = Id.unwrap(marketId); + + vm.prank(manager); + liquidator.setMarketWhitelist(id, false); + assertFalse(liquidator.marketWhitelist(id)); + + vm.prank(manager); + liquidator.setMarketWhitelist(id, true); + assertTrue(liquidator.marketWhitelist(id)); + } + + function test_batchSetMarketWhitelist_updatesAll() public { + bytes32[] memory ids = new bytes32[](1); + ids[0] = Id.unwrap(marketId); + + vm.prank(manager); + liquidator.batchSetMarketWhitelist(ids, false); + assertFalse(liquidator.marketWhitelist(ids[0])); + + vm.prank(manager); + liquidator.batchSetMarketWhitelist(ids, true); + assertTrue(liquidator.marketWhitelist(ids[0])); + } + + function test_setPairWhitelist_togglesAndReverts() public { + address pair = makeAddr("pair"); + + vm.prank(manager); + liquidator.setPairWhitelist(pair, true); + assertTrue(liquidator.pairWhitelist(pair)); + + vm.prank(manager); + vm.expectRevert(V3Liquidator.WhitelistSameStatus.selector); + liquidator.setPairWhitelist(pair, true); + } + + function test_setV3ProviderWhitelist_togglesAndReverts() public { + address prov = makeAddr("prov"); + + vm.prank(manager); + liquidator.setV3ProviderWhitelist(prov, true); + assertTrue(liquidator.v3Providers(prov)); + + vm.prank(manager); + vm.expectRevert(V3Liquidator.WhitelistSameStatus.selector); + liquidator.setV3ProviderWhitelist(prov, true); + } + + function test_batchSetV3Providers_updatesAll() public { + address prov1 = makeAddr("prov1"); + address prov2 = makeAddr("prov2"); + address[] memory provs = new address[](2); + provs[0] = prov1; + provs[1] = prov2; + + vm.prank(manager); + liquidator.batchSetV3Providers(provs, true); + assertTrue(liquidator.v3Providers(prov1)); + assertTrue(liquidator.v3Providers(prov2)); + } + + /* ─────────────────── access control ─────────────────────────────── */ + + function test_liquidate_revertsIfNotBot() public { + vm.prank(user); + vm.expectRevert(); + liquidator.liquidate(Id.unwrap(marketId), user, 1, 0); + } + + function test_flashLiquidate_revertsIfNotBot() public { + V3Liquidator.FlashLiquidateParams memory params; + params.v3Provider = address(provider); + + vm.prank(user); + vm.expectRevert(); + liquidator.flashLiquidate(Id.unwrap(marketId), user, 1, params); + } + + function test_redeemV3Shares_revertsIfNotBot() public { + vm.prank(user); + vm.expectRevert(); + liquidator.redeemV3Shares(address(provider), 1, 0, 0, user); + } + + function test_sellToken_revertsIfNotBot() public { + vm.prank(user); + vm.expectRevert(); + liquidator.sellToken(address(mockSwap), USDC, LISUSD, 1, 0, ""); + } + + /* ─────────────────── liquidate (pre-funded) ─────────────────────── */ + + function test_liquidate_prefunded_receivesShares() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + + deal(LISUSD, address(liquidator), 1_000 ether); + + vm.prank(bot); + liquidator.liquidate(Id.unwrap(marketId), user, shares, 0); + + assertGt(provider.balanceOf(address(liquidator)), 0, "liquidator received shares"); + assertEq(_collateral(user), 0, "borrower collateral seized"); + assertEq(IERC20(LISUSD).allowance(address(liquidator), MOOLAH_PROXY), 0, "loanToken allowance cleared"); + } + + function test_liquidate_revertsIfMarketNotWhitelisted() public { + vm.prank(manager); + liquidator.setMarketWhitelist(Id.unwrap(marketId), false); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.liquidate(Id.unwrap(marketId), user, 1, 0); + } + + /* ─────────────────── flashLiquidate ─────────────────────────────── */ + + function test_flashLiquidate_holdShares_noRedeem() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + uint256 borrowed = _borrowAgainstCollateral(user); + _makeUnhealthy(); + + // Pre-fund with enough lisUSD: onMoolahLiquidate approves Moolah even when not redeeming. + deal(LISUSD, address(liquidator), borrowed * 2); + + V3Liquidator.FlashLiquidateParams memory params = V3Liquidator.FlashLiquidateParams({ + v3Provider: address(provider), + minToken0Amt: 0, + minToken1Amt: 0, + redeemShares: false, + token0Pair: address(0), + token0Spender: address(0), + token1Pair: address(0), + token1Spender: address(0), + swapToken0Data: "", + swapToken1Data: "" + }); + + vm.prank(bot); + liquidator.flashLiquidate(Id.unwrap(marketId), user, shares, params); + + assertGt(provider.balanceOf(address(liquidator)), 0, "liquidator holds seized shares"); + assertEq(_collateral(user), 0, "borrower collateral cleared"); + } + + function test_flashLiquidate_redeemAndSwap_coveredBySwapProfit() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + uint256 borrowed = _borrowAgainstCollateral(user); + _makeUnhealthy(); + + // token0 (USDC) swap: amountIn=0 so mock accepts any approval; produces borrowed*2 lisUSD. + // This ensures the NoProfit check passes without knowing the exact repaidAssets upfront. + bytes memory swap0Data = abi.encodeWithSelector( + mockSwap.swap.selector, + USDC, // tokenIn + LISUSD, // tokenOut + uint256(0), // amountIn (mock pulls nothing; residual USDC stays in liquidator) + borrowed * 2 // amountOutMin — enough to cover repayment + ); + + // token1 (WBNB) swap: V3Provider unwraps WBNB → native BNB, V3Liquidator sends it via call{value}. + // amountIn=0 so msg.value >= 0 always passes; MockOneInch refunds BNB to liquidator, gives 0 lisUSD. + bytes memory swap1Data = abi.encodeWithSelector( + mockSwap.swap.selector, + BNB_ADDRESS, // tokenIn (native BNB path) + LISUSD, + uint256(0), // amountIn + uint256(0) // no extra lisUSD needed from this leg + ); + + V3Liquidator.FlashLiquidateParams memory params = V3Liquidator.FlashLiquidateParams({ + v3Provider: address(provider), + minToken0Amt: 0, + minToken1Amt: 0, + redeemShares: true, + token0Pair: address(mockSwap), + token0Spender: address(0), + token1Pair: address(mockSwap), + token1Spender: address(0), + swapToken0Data: swap0Data, + swapToken1Data: swap1Data + }); + + vm.prank(bot); + liquidator.flashLiquidate(Id.unwrap(marketId), user, shares, params); + + assertEq(provider.balanceOf(address(liquidator)), 0, "shares redeemed in callback"); + assertEq(_collateral(user), 0, "borrower collateral seized"); + // Excess lisUSD (borrowed*2 - repaidAssets ≈ borrowed) remains in liquidator. + assertGt(IERC20(LISUSD).balanceOf(address(liquidator)), 0, "excess lisUSD in liquidator"); + } + + function test_flashLiquidate_revertsIfMarketNotWhitelisted() public { + vm.prank(manager); + liquidator.setMarketWhitelist(Id.unwrap(marketId), false); + + V3Liquidator.FlashLiquidateParams memory params; + params.v3Provider = address(provider); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.flashLiquidate(Id.unwrap(marketId), user, 1, params); + } + + function test_flashLiquidate_revertsIfProviderNotWhitelisted() public { + vm.prank(manager); + liquidator.setV3ProviderWhitelist(address(provider), false); + + V3Liquidator.FlashLiquidateParams memory params; + params.v3Provider = address(provider); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.flashLiquidate(Id.unwrap(marketId), user, 1, params); + } + + function test_flashLiquidate_revertsIfProviderMarketMismatch() public { + // Register a second provider that is not the collateral for this market. + address fakeProvider = makeAddr("fakeProvider"); + vm.prank(manager); + liquidator.setV3ProviderWhitelist(fakeProvider, true); + + V3Liquidator.FlashLiquidateParams memory params; + params.v3Provider = fakeProvider; + + vm.prank(bot); + vm.expectRevert("provider/market mismatch"); + liquidator.flashLiquidate(Id.unwrap(marketId), user, 1, params); + } + + /* ─────────────────── redeemV3Shares ─────────────────────────────── */ + + function test_redeemV3Shares_redemeesSharesToTokens() public { + // Acquire shares via pre-funded liquidation. + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + deal(LISUSD, address(liquidator), 1_000 ether); + vm.prank(bot); + liquidator.liquidate(Id.unwrap(marketId), user, shares, 0); + + uint256 heldShares = provider.balanceOf(address(liquidator)); + assertGt(heldShares, 0, "setup: liquidator holds shares"); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(heldShares); + + vm.prank(bot); + (uint256 out0, uint256 out1) = liquidator.redeemV3Shares( + address(provider), + heldShares, + (exp0 * 999) / 1000, + (exp1 * 999) / 1000, + address(liquidator) + ); + + assertEq(provider.balanceOf(address(liquidator)), 0, "shares burned after redeem"); + assertGt(out0 + out1, 0, "tokens received"); + assertEq(IERC20(USDC).balanceOf(address(liquidator)), out0, "USDC received"); + assertEq(address(liquidator).balance, out1, "BNB received (WBNB unwrapped)"); + } + + function test_redeemV3Shares_revertsIfProviderNotWhitelisted() public { + vm.prank(manager); + liquidator.setV3ProviderWhitelist(address(provider), false); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.redeemV3Shares(address(provider), 1, 0, 0, address(liquidator)); + } + + /* ─────────────────── sell token ─────────────────────────────────── */ + + function test_sellToken_erc20_swapsAndClearsAllowance() public { + uint256 amountIn = 100 ether; + uint256 amountOut = 50 ether; + deal(USDC, address(liquidator), amountIn); + + bytes memory swapData = abi.encodeWithSelector(mockSwap.swap.selector, USDC, LISUSD, amountIn, amountOut); + + vm.prank(bot); + liquidator.sellToken(address(mockSwap), USDC, LISUSD, amountIn, amountOut, swapData); + + assertEq(IERC20(LISUSD).balanceOf(address(liquidator)), amountOut, "received lisUSD"); + assertEq(IERC20(USDC).balanceOf(address(liquidator)), 0, "USDC consumed"); + assertEq(IERC20(USDC).allowance(address(liquidator), address(mockSwap)), 0, "allowance cleared"); + } + + function test_sellToken_revertsIfTokenNotWhitelisted() public { + deal(WBNB, address(liquidator), 1 ether); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.sellToken(address(mockSwap), WBNB, LISUSD, 1 ether, 0, ""); + } + + function test_sellToken_revertsIfPairNotWhitelisted() public { + address fakePair = makeAddr("fakePair"); + deal(USDC, address(liquidator), 1 ether); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.sellToken(fakePair, USDC, LISUSD, 1 ether, 0, ""); + } + + function test_sellToken_revertsIfAmountExceedsBalance() public { + deal(USDC, address(liquidator), 50 ether); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.ExceedAmount.selector); + liquidator.sellToken(address(mockSwap), USDC, LISUSD, 100 ether, 0, ""); + } + + function test_sellBNB_swapsNativeBNB() public { + uint256 amountIn = 1 ether; + uint256 amountOut = 500 ether; + deal(address(liquidator), amountIn); + + bytes memory swapData = abi.encodeWithSelector(mockSwap.swap.selector, BNB_ADDRESS, LISUSD, amountIn, amountOut); + + vm.prank(bot); + liquidator.sellBNB(address(mockSwap), LISUSD, amountIn, amountOut, swapData); + + assertEq(IERC20(LISUSD).balanceOf(address(liquidator)), amountOut, "received lisUSD"); + assertEq(address(liquidator).balance, 0, "BNB consumed"); + } + + /* ─────────────────── withdrawals ────────────────────────────────── */ + + function test_withdrawERC20_sendsToManager() public { + uint256 amount = 100 ether; + deal(LISUSD, address(liquidator), amount); + + vm.prank(manager); + liquidator.withdrawERC20(LISUSD, amount); + + assertEq(IERC20(LISUSD).balanceOf(manager), amount); + assertEq(IERC20(LISUSD).balanceOf(address(liquidator)), 0); + } + + function test_withdrawETH_sendsToManager() public { + uint256 amount = 1 ether; + deal(address(liquidator), amount); + + vm.prank(manager); + liquidator.withdrawETH(amount); + + assertEq(manager.balance, amount); + assertEq(address(liquidator).balance, 0); + } + + function test_withdrawERC20_revertsIfNotManager() public { + vm.prank(user); + vm.expectRevert(); + liquidator.withdrawERC20(LISUSD, 1); + } +} diff --git a/test/provider/V3Provider.t.sol b/test/provider/V3Provider.t.sol new file mode 100644 index 00000000..c73f1dfc --- /dev/null +++ b/test/provider/V3Provider.t.sol @@ -0,0 +1,1544 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { V3Provider } from "../../src/provider/V3Provider.sol"; +import { IUniswapV3Pool } from "../../src/provider/interfaces/IUniswapV3Pool.sol"; +import { Moolah } from "../../src/moolah/Moolah.sol"; +import { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; +import { TokenConfig, IOracle } from "moolah/interfaces/IOracle.sol"; +import { SlisBNBxMinter, ISlisBNBx } from "../../src/utils/SlisBNBxMinter.sol"; + +/// @dev Helper that executes a direct pool swap and satisfies the PancakeSwap V3 callback. +contract PoolSwapper { + // MIN / MAX sqrt ratios from TickMath (ticks ±887272) + uint160 internal constant MIN_SQRT_RATIO = 4295128739; + uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; + + /// @notice Swap tokenIn → tokenOut by selling `amountIn` worth of tokenIn. + /// zeroForOne = true → token0 in, token1 out (price moves down) + /// zeroForOne = false → token1 in, token0 out (price moves up) + function swapExactIn(address pool, bool zeroForOne, uint256 amountIn) external { + uint160 limit = zeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1; + IUniswapV3Pool(pool).swap(address(this), zeroForOne, int256(amountIn), limit, abi.encode(pool)); + } + + /// @dev PancakeSwap V3 swap callback — pay whatever the pool pulled. + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + address pool = abi.decode(data, (address)); + if (amount0Delta > 0) IERC20(IUniswapV3Pool(pool).token0()).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) IERC20(IUniswapV3Pool(pool).token1()).transfer(msg.sender, uint256(amount1Delta)); + } +} + +contract V3ProviderTest is Test { + using MarketParamsLib for MarketParams; + + /* ─────────────────── PancakeSwap V3 BSC mainnet ─────────────────── */ + address constant POOL = 0x4141325bAc36aFFe9Db165e854982230a14e6d48; // USDC/WBNB + address constant NPM = 0x7b8A01B39D58278b5DE7e48c8449c9f4F5170613; + uint24 constant FEE = 100; + + /* ───────────────────────────── tokens ───────────────────────────── */ + address constant USDC = 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d; // token0 + address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; // token1 + + /* ──────────────────────── Moolah ecosystem ──────────────────────── */ + address constant MOOLAH_PROXY = 0x8F73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C; + address constant TIMELOCK = 0x07D274a68393E8b8a2CCf19A2ce4Ba3518735253; + address constant OPERATOR = 0xd7e38800201D6a42C408Bf79d8723740C4E7f631; + address constant MANAGER_ADDR = 0x8d388136d578dCD791D081c6042284CED6d9B0c6; + address constant LISUSD = 0x0782b6d8c4551B9760e74c0545a9bCD90bdc41E5; + address constant RESILIENT_ORACLE = 0xf3afD82A4071f272F403dC176916141f44E6c750; + address constant IRM = 0xFe7dAe87Ebb11a7BEB9F534BB23267992d9cDe7c; + + uint32 constant TWAP_PERIOD = 1800; // 30 minutes + uint256 constant LLTV = 70 * 1e16; + + /* ───────────────────────── test contracts ───────────────────────── */ + Moolah moolah; + V3Provider provider; + MarketParams marketParams; + Id marketId; + + /* ───────────────────────── test accounts ────────────────────────── */ + address admin = makeAddr("admin"); + address manager = makeAddr("manager"); + address bot = makeAddr("bot"); + address user = makeAddr("user"); + address user2 = makeAddr("user2"); + + /* ────────────────────────────── setUp ───────────────────────────── */ + + function setUp() public { + vm.createSelectFork(vm.envString("BSC_RPC"), 60541406); + + // Upgrade Moolah to the latest local implementation. + address newImpl = address(new Moolah()); + vm.prank(TIMELOCK); + UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newImpl, bytes("")); + moolah = Moolah(MOOLAH_PROXY); + + // Derive initial tick range from the live pool. + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 tickLower = currentTick - 500; + int24 tickUpper = currentTick + 500; + + // Deploy V3Provider (implementation + UUPS proxy). + V3Provider impl = new V3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); + bytes memory initData = abi.encodeCall( + V3Provider.initialize, + (admin, manager, bot, RESILIENT_ORACLE, tickLower, tickUpper, "V3Provider USDC/WBNB", "v3LP-USDC-WBNB") + ); + provider = V3Provider(payable(new ERC1967Proxy(address(impl), initData))); + + // Build Moolah market: collateral = provider shares, oracle = provider. + marketParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(provider), + irm: IRM, + lltv: LLTV + }); + marketId = marketParams.id(); + + // Create market and register V3Provider as the Moolah provider. + vm.prank(OPERATOR); + moolah.createMarket(marketParams); + + vm.prank(MANAGER_ADDR); + moolah.setProvider(marketId, address(provider), true); + + // Seed market with lisUSD so borrow tests can succeed. + deal(LISUSD, address(this), 1_000_000 ether); + IERC20(LISUSD).approve(MOOLAH_PROXY, 1_000_000 ether); + moolah.supply(marketParams, 1_000_000 ether, 0, address(this), ""); + } + + /* ────────────────────────── helper fns ─────────────────────────── */ + + function _deposit( + address _user, + uint256 amount0, + uint256 amount1 + ) internal returns (uint256 shares, uint256 used0, uint256 used1) { + deal(USDC, _user, amount0); + deal(WBNB, _user, amount1); + // Derive tight min amounts (0.1% slippage) from previewDeposit so that we + // never bypass the slippage guard with zeros. + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + uint256 min0 = (exp0 * 999) / 1000; + uint256 min1 = (exp1 * 999) / 1000; + vm.startPrank(_user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + (shares, used0, used1) = provider.deposit(marketParams, amount0, amount1, min0, min1, _user); + vm.stopPrank(); + } + + function _collateral(address _user) internal view returns (uint256) { + (, , uint256 col) = moolah.position(marketId, _user); + return col; + } + + /* ────────────────────────── test cases ─────────────────────────── */ + + function test_initialize() public view { + assertEq(provider.TOKEN0(), USDC); + assertEq(provider.TOKEN1(), WBNB); + assertEq(provider.FEE(), FEE); + assertEq(provider.POOL(), POOL); + assertEq(address(provider.MOOLAH()), MOOLAH_PROXY); + assertEq(address(provider.POSITION_MANAGER()), NPM); + assertEq(provider.resilientOracle(), RESILIENT_ORACLE); + assertEq(provider.TWAP_PERIOD(), TWAP_PERIOD); + assertTrue(provider.hasRole(provider.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(provider.hasRole(provider.MANAGER(), manager)); + assertTrue(provider.hasRole(provider.BOT(), bot)); + // BOT role admin is MANAGER + assertEq(provider.getRoleAdmin(provider.BOT()), provider.MANAGER()); + } + + function test_deposit_firstDeposit() public { + uint256 amount0 = 1_000 ether; // USDC + uint256 amount1 = 3 ether; // WBNB + + (uint256 shares, uint256 used0, uint256 used1) = _deposit(user, amount0, amount1); + + assertGt(shares, 0, "should mint shares"); + assertGt(used0 + used1, 0, "should consume tokens"); + + // Collateral position in Moolah equals shares minted. + assertEq(_collateral(user), shares, "Moolah collateral should equal shares"); + + // Shares are held by Moolah, not user. + assertEq(provider.balanceOf(user), 0, "user should hold no shares directly"); + assertEq(provider.balanceOf(MOOLAH_PROXY), shares, "Moolah should hold shares"); + + // Unused tokens refunded to caller. + // USDC refunded as ERC-20; WBNB (TOKEN1 = WRAPPED_NATIVE) refunded as native BNB. + assertEq(IERC20(USDC).balanceOf(user), amount0 - used0); + assertEq(user.balance, amount1 - used1); + } + + function test_deposit_secondDeposit_sharesProportional() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 sharesAfterFirst = _collateral(user); + + (uint256 shares2, , ) = _deposit(user2, 2_000 ether, 6 ether); + + // Second depositor contributes roughly twice as much — shares should be ~2x. + assertApproxEqRel(shares2, sharesAfterFirst * 2, 0.01e18, "second deposit shares should be ~2x"); + } + + function test_withdraw_fullWithdrawal() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + uint256 usdcBefore = IERC20(USDC).balanceOf(user); + uint256 bnbBefore = user.balance; // WBNB (TOKEN1) is unwrapped to native BNB on withdrawal + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + uint256 min0 = (exp0 * 999) / 1000; + uint256 min1 = (exp1 * 999) / 1000; + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, min0, min1, user, user); + + // Collateral cleared. + assertEq(_collateral(user), 0, "collateral should be zero after full withdrawal"); + + // Tokens returned. + assertGt(out0 + out1, 0, "should receive tokens back"); + assertEq(IERC20(USDC).balanceOf(user), usdcBefore + out0); + assertEq(user.balance, bnbBefore + out1); // WBNB unwrapped to BNB + } + + function test_withdraw_partialWithdrawal() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares / 2); + uint256 min0 = (exp0 * 999) / 1000; + uint256 min1 = (exp1 * 999) / 1000; + + vm.prank(user); + provider.withdraw(marketParams, shares / 2, min0, min1, user, user); + + assertApproxEqAbs(_collateral(user), shares / 2, 1, "half collateral should remain"); + } + + function test_withdraw_revertsIfUnauthorized() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 shares = _collateral(user); + + // user2 cannot withdraw on behalf of user without authorization. + // The revert fires on the auth check before min amounts are evaluated; use 1,1. + vm.prank(user2); + vm.expectRevert("unauthorized"); + provider.withdraw(marketParams, shares, 1, 1, user, user2); + } + + function test_redeemShares_byLiquidator() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + address liquidator = makeAddr("liquidator"); + + // Simulate Moolah transferring shares to liquidator during liquidation. + // (transfer is restricted to Moolah — prank as Moolah to move shares) + vm.prank(MOOLAH_PROXY); + provider.transfer(liquidator, shares); + + assertEq(provider.balanceOf(liquidator), shares); + + uint256 usdcBefore = IERC20(USDC).balanceOf(liquidator); + uint256 bnbBefore = liquidator.balance; // WBNB (TOKEN1) is unwrapped to native BNB + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + uint256 min0 = (exp0 * 999) / 1000; + uint256 min1 = (exp1 * 999) / 1000; + + vm.prank(liquidator); + (uint256 out0, uint256 out1) = provider.redeemShares(shares, min0, min1, liquidator); + + assertEq(provider.balanceOf(liquidator), 0, "shares should be burned"); + assertGt(out0 + out1, 0, "liquidator should receive tokens"); + assertEq(IERC20(USDC).balanceOf(liquidator), usdcBefore + out0); + assertEq(liquidator.balance, bnbBefore + out1); // WBNB unwrapped to BNB + } + + function test_transferRestriction_directTransferReverts() public { + _deposit(user, 1_000 ether, 3 ether); + + vm.prank(user); + vm.expectRevert("only moolah"); + provider.transfer(user2, 1); + } + + function test_transferRestriction_transferFromReverts() public { + _deposit(user, 1_000 ether, 3 ether); + + vm.prank(user); + vm.expectRevert("only moolah"); + provider.transferFrom(MOOLAH_PROXY, user2, 1); + } + + function test_rebalance_onlyBot() public { + _deposit(user, 1_000 ether, 3 ether); + + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 newLower = currentTick - 1000; + int24 newUpper = currentTick + 1000; + + // manager cannot rebalance — revert fires on role check before amounts matter. + vm.prank(manager); + vm.expectRevert(); + provider.rebalance(newLower, newUpper, 1, 1, 1, 1); + + // bot can rebalance — pass full available amounts so pool picks optimal ratio. + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + uint256 min0 = (total0 * 999) / 1000; + uint256 min1 = (total1 * 999) / 1000; + vm.prank(bot); + provider.rebalance(newLower, newUpper, min0, min1, total0, total1); + + assertEq(provider.tickLower(), newLower); + assertEq(provider.tickUpper(), newUpper); + } + + function test_rebalance_liquidity_preserved() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); + + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + uint256 min0 = (total0 * 999) / 1000; + uint256 min1 = (total1 * 999) / 1000; + vm.prank(bot); + provider.rebalance(currentTick - 1000, currentTick + 1000, min0, min1, total0, total1); + + // Share count is unchanged after rebalance. + assertEq(_collateral(user), shares, "shares should be unchanged after rebalance"); + + // Total amounts should be roughly preserved (small dust from ratio mismatch is acceptable). + (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); + uint256 valueBefore = total0Before + total1Before; + uint256 valueAfter = total0After + total1After; + assertApproxEqRel(valueAfter, valueBefore, 0.02e18, "total value should be preserved within 2%"); + } + + function test_peek_zeroBeforeDeposit() public view { + assertEq(provider.peek(address(provider)), 0, "price should be 0 with no deposits"); + } + + function test_peek_nonZeroAfterDeposit() public { + _deposit(user, 1_000 ether, 3 ether); + + uint256 price = provider.peek(address(provider)); + assertGt(price, 0, "share price should be non-zero after deposit"); + } + + function test_getTwapTick_nearCurrentTick() public view { + int24 twapTick = provider.getTwapTick(); + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // TWAP tick should be within a reasonable distance of the current tick. + int256 diff = int256(currentTick) - int256(twapTick); + if (diff < 0) diff = -diff; + assertLt(diff, 500, "TWAP tick should be near current tick"); + } + + function test_getTotalAmounts_nonZeroAfterDeposit() public { + _deposit(user, 1_000 ether, 3 ether); + + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + assertGt(total0 + total1, 0, "total amounts should be non-zero after deposit"); + } + + function test_compoundFees_shareValueIncreasesOverTime() public { + // mock USDC price + vm.mockCall( + RESILIENT_ORACLE, + abi.encodeWithSelector(IOracle.peek.selector, USDC), + abi.encode(1e8) // $1 with 8 decimals + ); + + // mock WBNB price; $700 + vm.mockCall( + RESILIENT_ORACLE, + abi.encodeWithSelector(IOracle.peek.selector, WBNB), + abi.encode(700 * 1e8) // $700 with 8 decimals + ); + + // Stabilise the TWAP tick across the vm.warp by mocking pool.observe to always + // return tick cumulatives consistent with the current slot0 tick. Without this, + // the 7-day warp shifts the TWAP window from real BSC history to pure extrapolation, + // producing a spurious ~0.3% price delta that has nothing to do with fee compounding. + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int56[] memory tickCumulatives = new int56[](2); + tickCumulatives[0] = 0; + tickCumulatives[1] = int56(currentTick) * int56(uint56(TWAP_PERIOD)); + uint160[] memory secondsPerLiq = new uint160[](2); + vm.mockCall( + POOL, + abi.encodeWithSelector(IUniswapV3Pool.observe.selector), + abi.encode(tickCumulatives, secondsPerLiq) + ); + + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + uint256 priceBefore = provider.peek(address(provider)); + + // Simulate time passing and swap activity accumulating fees by warping forward. + vm.warp(block.timestamp + 7 days); + + // A second deposit triggers _collectAndCompound internally. + _deposit(user2, 1_000 ether, 3 ether); + + uint256 priceAfter = provider.peek(address(provider)); + + // Share price should be >= before (fees compounded, no value destroyed). + assertGe(priceAfter, priceBefore, "share price should not decrease after compounding"); + + // user's collateral share count is unchanged. + assertEq(_collateral(user), shares); + } + + /// @dev Helper: deposit with explicit min amounts (bypasses _deposit which passes zeros). + function _depositWithMin( + address _user, + uint256 amount0, + uint256 amount1, + uint256 min0, + uint256 min1 + ) internal returns (uint256 shares, uint256 used0, uint256 used1) { + deal(USDC, _user, amount0); + deal(WBNB, _user, amount1); + vm.startPrank(_user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + (shares, used0, used1) = provider.deposit(marketParams, amount0, amount1, min0, min1, _user); + vm.stopPrank(); + } + + /* ──────────────── previewDeposit tests ─────────────────────────── */ + + function test_previewDeposit_amountsMatchActual() public { + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + (uint128 liquidity, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + assertGt(liquidity, 0, "liquidity should be non-zero"); + // Both preview amounts must be within the desired amounts. + assertLe(exp0, amount0, "exp0 must not exceed desired"); + assertLe(exp1, amount1, "exp1 must not exceed desired"); + assertGt(exp0 + exp1, 0, "at least one token must be consumed"); + + // Actual deposit should consume within 1 wei of what previewDeposit predicted + // (NPM uses the same math with possible ±1 rounding differences). + uint256 min0 = exp0 > 0 ? exp0 - 1 : 0; + uint256 min1 = exp1 > 0 ? exp1 - 1 : 0; + (, uint256 used0, uint256 used1) = _depositWithMin(user, amount0, amount1, min0, min1); + + assertApproxEqAbs(used0, exp0, 1, "used0 should match preview within 1 wei"); + assertApproxEqAbs(used1, exp1, 1, "used1 should match preview within 1 wei"); + } + + function test_previewDeposit_derivedMinAmounts_succeed() public { + uint256 amount0 = 5_000 ether; + uint256 amount1 = 15 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + // Apply 0.5% slippage tolerance. + uint256 min0 = (exp0 * 995) / 1000; + uint256 min1 = (exp1 * 995) / 1000; + + (uint256 shares, uint256 used0, uint256 used1) = _depositWithMin(user, amount0, amount1, min0, min1); + + assertGt(shares, 0, "should mint shares"); + assertGe(used0, min0, "used0 >= min0"); + assertGe(used1, min1, "used1 >= min1"); + } + + function test_previewDeposit_priceBelowRange_onlyToken0() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + // Position is fully USDC — only token0 consumed, token1 = 0. + assertGt(exp0, 0, "expected token0 consumed when price below range"); + assertEq(exp1, 0, "expected no token1 consumed when price below range"); + } + + function test_previewDeposit_priceAboveRange_onlyToken1() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + // Position is fully WBNB — only token1 consumed, token0 = 0. + assertEq(exp0, 0, "expected no token0 consumed when price above range"); + assertGt(exp1, 0, "expected token1 consumed when price above range"); + } + + function test_previewDeposit_secondDeposit_matchesActual() public { + // Seed an initial position so the second deposit goes through increaseLiquidity. + _deposit(user, 1_000 ether, 3 ether); + + uint256 amount0 = 2_000 ether; + uint256 amount1 = 6 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + uint256 min0 = exp0 > 0 ? exp0 - 1 : 0; + uint256 min1 = exp1 > 0 ? exp1 - 1 : 0; + (, uint256 used0, uint256 used1) = _depositWithMin(user2, amount0, amount1, min0, min1); + + assertApproxEqAbs(used0, exp0, 1, "used0 should match preview within 1 wei on second deposit"); + assertApproxEqAbs(used1, exp1, 1, "used1 should match preview within 1 wei on second deposit"); + } + + /* ──────────────── previewRedeem tests ──────────────────────────── */ + + function test_previewRedeem_zeroBeforeDeposit() public view { + (uint256 amount0, uint256 amount1) = provider.previewRedeem(1 ether); + assertEq(amount0, 0, "should return 0 when no position exists"); + assertEq(amount1, 0, "should return 0 when no position exists"); + } + + function test_previewRedeem_matchesActualWithdraw() public { + // Price is inside the tick range: preview predicts both tokens, withdraw returns both. + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + assertGt(currentTick, provider.tickLower(), "price should be above tickLower"); + assertLt(currentTick, provider.tickUpper(), "price should be below tickUpper"); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertGt(exp0, 0, "previewRedeem should predict token0 in-range"); + assertGt(exp1, 0, "previewRedeem should predict token1 in-range"); + + uint256 min0 = exp0 - 1; + uint256 min1 = exp1 - 1; + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, min0, min1, user, user); + + assertApproxEqAbs(out0, exp0, 1, "out0 should match preview within 1 wei"); + assertApproxEqAbs(out1, exp1, 1, "out1 should match preview within 1 wei"); + assertGt(out0, 0, "should receive token0 when withdrawing in-range"); + assertGt(out1, 0, "should receive token1 when withdrawing in-range"); + } + + function test_previewRedeem_matchesActualRedeemShares() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + vm.prank(MOOLAH_PROXY); + provider.transfer(user2, shares); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + + uint256 min0 = exp0 > 0 ? exp0 - 1 : 0; + uint256 min1 = exp1 > 0 ? exp1 - 1 : 0; + + vm.prank(user2); + (uint256 out0, uint256 out1) = provider.redeemShares(shares, min0, min1, user2); + + assertApproxEqAbs(out0, exp0, 1, "out0 should match preview within 1 wei"); + assertApproxEqAbs(out1, exp1, 1, "out1 should match preview within 1 wei"); + } + + function test_previewRedeem_partialShares_proportional() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (uint256 fullExp0, uint256 fullExp1) = provider.previewRedeem(shares); + (uint256 halfExp0, uint256 halfExp1) = provider.previewRedeem(shares / 2); + + // Half the shares should yield approximately half the tokens. + assertApproxEqRel(halfExp0, fullExp0 / 2, 0.001e18, "half shares ~half token0"); + assertApproxEqRel(halfExp1, fullExp1 / 2, 0.001e18, "half shares ~half token1"); + } + + function test_previewRedeem_priceBelowRange_onlyToken0() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertGt(exp0, 0, "should return token0 when price below range"); + assertEq(exp1, 0, "should return no token1 when price below range"); + } + + function test_previewRedeem_priceAboveRange_onlyToken1() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertEq(exp0, 0, "should return no token0 when price above range"); + assertGt(exp1, 0, "should return token1 when price above range"); + } + + function test_previewRedeem_derivedMinAmounts_succeed() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + + // Apply 0.5% slippage tolerance. + uint256 min0 = (exp0 * 995) / 1000; + uint256 min1 = (exp1 * 995) / 1000; + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, min0, min1, user, user); + + assertGe(out0, min0, "out0 >= min0"); + assertGe(out1, min1, "out1 >= min1"); + } + + function test_deposit_minAmount0_tooHigh_reverts_firstDeposit() public { + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + // min0 far exceeds what NPM can place — should revert from NPM slippage check. + deal(USDC, user, amount0); + deal(WBNB, user, amount1); + vm.startPrank(user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + vm.expectRevert(); + provider.deposit(marketParams, amount0, amount1, amount0 * 2, 0, user); + vm.stopPrank(); + } + + function test_deposit_minAmount1_tooHigh_reverts_firstDeposit() public { + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + deal(USDC, user, amount0); + deal(WBNB, user, amount1); + vm.startPrank(user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + vm.expectRevert(); + provider.deposit(marketParams, amount0, amount1, 0, amount1 * 2, user); + vm.stopPrank(); + } + + function test_deposit_minAmount0_tooHigh_reverts_secondDeposit() public { + _deposit(user, 1_000 ether, 3 ether); + + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + deal(USDC, user2, amount0); + deal(WBNB, user2, amount1); + vm.startPrank(user2); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + vm.expectRevert(); + provider.deposit(marketParams, amount0, amount1, amount0 * 2, 0, user2); + vm.stopPrank(); + } + + function test_deposit_minAmount1_tooHigh_reverts_secondDeposit() public { + _deposit(user, 1_000 ether, 3 ether); + + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + deal(USDC, user2, amount0); + deal(WBNB, user2, amount1); + vm.startPrank(user2); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + vm.expectRevert(); + provider.deposit(marketParams, amount0, amount1, 0, amount1 * 2, user2); + vm.stopPrank(); + } + + /* ──────────── one-sided deposit tests ──────────────────────────── */ + + // When the price is in-range both tokens are required to add liquidity. + // Supplying only one token yields 0 liquidity → "zero shares" revert. + + function test_deposit_oneSided_token0Only_inRange_reverts() public { + // Price is in-range: token0 alone yields 0 liquidity → "zero shares". + // Pass min=0 so NPM doesn't revert first; our guard fires instead. + deal(USDC, user, 10_000 ether); + vm.startPrank(user); + IERC20(USDC).approve(address(provider), 10_000 ether); + vm.expectRevert("zero liquidity"); + provider.deposit(marketParams, 10_000 ether, 0, 0, 0, user); + vm.stopPrank(); + } + + function test_deposit_oneSided_token1Only_inRange_reverts() public { + // Price is in-range: token1 alone yields 0 liquidity → "zero shares". + deal(WBNB, user, 30 ether); + vm.startPrank(user); + IERC20(WBNB).approve(address(provider), 30 ether); + vm.expectRevert("zero liquidity"); + provider.deposit(marketParams, 0, 30 ether, 0, 0, user); + vm.stopPrank(); + } + + // When the price is outside the range only one token is valid. + // Supplying the correct token succeeds; supplying the wrong token reverts. + + function test_deposit_oneSided_token0Only_belowRange_succeeds() public { + // Seed a position first so rebalance can move ticks. + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + // Price below tickLower: only token0 (USDC) is accepted. + uint256 amount0 = 5_000 ether; + deal(USDC, user2, amount0); + vm.startPrank(user2); + IERC20(USDC).approve(address(provider), amount0); + (, uint256 exp0, ) = provider.previewDeposit(amount0, 0); + uint256 min0 = (exp0 * 999) / 1000; + (uint256 shares, uint256 used0, uint256 used1) = provider.deposit(marketParams, amount0, 0, min0, 0, user2); + vm.stopPrank(); + + assertGt(shares, 0, "should mint shares with token0 only below range"); + assertGt(used0, 0, "should consume token0"); + assertEq(used1, 0, "should not consume token1"); + } + + function test_deposit_oneSided_token1Only_belowRange_reverts() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + // Price below range: token1 alone yields 0 liquidity → "zero shares". + deal(WBNB, user2, 30 ether); + vm.startPrank(user2); + IERC20(WBNB).approve(address(provider), 30 ether); + vm.expectRevert("zero liquidity"); + provider.deposit(marketParams, 0, 30 ether, 0, 0, user2); + vm.stopPrank(); + } + + function test_deposit_oneSided_token1Only_aboveRange_succeeds() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + // Price above tickUpper: only token1 (WBNB) is accepted. + uint256 amount1 = 15 ether; + deal(WBNB, user2, amount1); + vm.startPrank(user2); + IERC20(WBNB).approve(address(provider), amount1); + (, , uint256 exp1) = provider.previewDeposit(0, amount1); + uint256 min1 = (exp1 * 999) / 1000; + (uint256 shares, uint256 used0, uint256 used1) = provider.deposit(marketParams, 0, amount1, 0, min1, user2); + vm.stopPrank(); + + assertGt(shares, 0, "should mint shares with token1 only above range"); + assertEq(used0, 0, "should not consume token0"); + assertGt(used1, 0, "should consume token1"); + } + + function test_deposit_oneSided_token0Only_aboveRange_reverts() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + // Price above range: token0 alone yields 0 liquidity → "zero shares". + deal(USDC, user2, 10_000 ether); + vm.startPrank(user2); + IERC20(USDC).approve(address(provider), 10_000 ether); + vm.expectRevert("zero liquidity"); + provider.deposit(marketParams, 10_000 ether, 0, 0, 0, user2); + vm.stopPrank(); + } + + function test_deposit_revertsWithInvalidCollateralToken() public { + MarketParams memory badParams = marketParams; + badParams.collateralToken = USDC; + + deal(USDC, user, 1_000 ether); + deal(WBNB, user, 3 ether); + vm.startPrank(user); + IERC20(USDC).approve(address(provider), 1_000 ether); + IERC20(WBNB).approve(address(provider), 3 ether); + vm.expectRevert("invalid collateral token"); + // The revert fires before min amounts are evaluated; use 1,1 for consistency. + provider.deposit(badParams, 1_000 ether, 3 ether, 1, 1, user); + vm.stopPrank(); + } + + function test_getTokenConfig() public view { + TokenConfig memory config = provider.getTokenConfig(address(provider)); + assertEq(config.asset, address(provider)); + assertEq(config.oracles[0], address(provider)); + assertTrue(config.enableFlagsForOracles[0]); + assertEq(config.oracles[1], address(0)); + assertEq(config.oracles[2], address(0)); + } + + /* ─────────── rebalance after price leaves range (fully USDC) ─────── */ + + // Prices: USDC = $1, WBNB = $700 (8-decimal USD) + uint256 constant USDC_PRICE = 1e8; + uint256 constant WBNB_PRICE = 700e8; + // USDC and WBNB are both 18-decimal on BSC. + uint256 constant TOKEN_DECIMALS = 1e18; + + function _mockOraclePrices() internal { + vm.mockCall(RESILIENT_ORACLE, abi.encodeWithSelector(IOracle.peek.selector, USDC), abi.encode(USDC_PRICE)); + vm.mockCall(RESILIENT_ORACLE, abi.encodeWithSelector(IOracle.peek.selector, WBNB), abi.encode(WBNB_PRICE)); + } + + /// @dev Compute USD value (8-decimal) from raw token amounts. + function _valueUSD(uint256 amount0, uint256 amount1) internal pure returns (uint256) { + return (amount0 * USDC_PRICE) / TOKEN_DECIMALS + (amount1 * WBNB_PRICE) / TOKEN_DECIMALS; + } + + /// @dev Push pool price below tickLower by swapping a large amount of USDC → WBNB. + /// zeroForOne = true (token0 → token1) drives the tick downward. + /// When tick < tickLower the V3 position converts entirely to token0 (USDC). + function _pushPriceBelowRange() internal { + PoolSwapper swapper = new PoolSwapper(); + uint256 usdcIn = 5_000_000_000 ether; // 5 billion USDC — enough to blow past ±500 ticks + deal(USDC, address(swapper), usdcIn); + swapper.swapExactIn(POOL, true, usdcIn); + } + + function test_rebalance_priceBelowRange_positionFullyUSDC() public { + _mockOraclePrices(); + _deposit(user, 10_000 ether, 30 ether); + + // Push price below tickLower — position should convert entirely to USDC (token0). + _pushPriceBelowRange(); + + (, int24 tickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + assertLt(tickAfterSwap, provider.tickLower(), "tick should be below tickLower after swap"); + + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + assertGt(total0, 0, "should hold USDC"); + assertEq(total1, 0, "position should be fully USDC (token1 == 0) when price is below range"); + } + + function test_rebalance_priceBelowRange_totalValuePreserved() public { + _mockOraclePrices(); + _deposit(user, 10_000 ether, 30 ether); + + _pushPriceBelowRange(); + + // Snapshot USD value before rebalance (position is 100% USDC). + (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); + uint256 valueBefore = _valueUSD(total0Before, total1Before); + assertGt(valueBefore, 0, "should have non-zero value before rebalance"); + + // Rebalance to a range entirely ABOVE the current (very low) tick so that + // the entire range is below current price → only token0 (USDC) is needed to mint. + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 newLower = newTick + 100; + int24 newUpper = newTick + 600; + + // Position is 100% USDC — only token0 needed for new range (price below it). + uint256 min0 = (total0Before * 999) / 1000; + vm.prank(bot); + provider.rebalance(newLower, newUpper, min0, 0, total0Before, 0); + + assertEq(provider.tickLower(), newLower, "tickLower updated"); + assertEq(provider.tickUpper(), newUpper, "tickUpper updated"); + + // Position is still fully USDC (price below new range). All USDC was deployed + // into the new position; getTotalAmounts captures it via position amounts. + (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); + uint256 valueAfter = _valueUSD(total0After, total1After); + + assertApproxEqRel(valueAfter, valueBefore, 0.01e16, "total value should be preserved within 0.01% after rebalance"); + } + + /* ─────────── rebalance after price leaves range (fully WBNB) ──────── */ + + /// @dev Push pool price above tickUpper by swapping a large amount of WBNB → USDC. + /// zeroForOne = false (token1 → token0) drives the tick upward. + /// When tick > tickUpper the V3 position converts entirely to token1 (WBNB). + function _pushPriceAboveRange() internal { + PoolSwapper swapper = new PoolSwapper(); + uint256 wbnbIn = 10_000_000 ether; // 10 million WBNB — enough to blow past ±500 ticks + deal(WBNB, address(swapper), wbnbIn); + swapper.swapExactIn(POOL, false, wbnbIn); + } + + function test_rebalance_priceAboveRange_positionFullyWBNB() public { + _mockOraclePrices(); + _deposit(user, 10_000 ether, 30 ether); + + // Push price above tickUpper — position should convert entirely to WBNB (token1). + _pushPriceAboveRange(); + + (, int24 tickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + assertGt(tickAfterSwap, provider.tickUpper(), "tick should be above tickUpper after swap"); + + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + assertEq(total0, 0, "position should be fully WBNB (token0 == 0) when price is above range"); + assertGt(total1, 0, "should hold WBNB"); + } + + function test_rebalance_priceAboveRange_totalValuePreserved() public { + _mockOraclePrices(); + _deposit(user, 10_000 ether, 30 ether); + + _pushPriceAboveRange(); + + // Snapshot USD value before rebalance (position is 100% WBNB). + (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); + uint256 valueBefore = _valueUSD(total0Before, total1Before); + assertGt(valueBefore, 0, "should have non-zero value before rebalance"); + + // Rebalance to a range entirely BELOW the current (very high) tick so that + // the entire range is above current price → only token1 (WBNB) is needed to mint. + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 newLower = newTick - 600; + int24 newUpper = newTick - 100; + + // Position is 100% WBNB — only token1 needed for new range (price above it). + uint256 min1 = (total1Before * 999) / 1000; + vm.prank(bot); + provider.rebalance(newLower, newUpper, 0, min1, 0, total1Before); + + assertEq(provider.tickLower(), newLower, "tickLower updated"); + assertEq(provider.tickUpper(), newUpper, "tickUpper updated"); + + // Position is still fully WBNB (price above new range). All WBNB was deployed + // into the new position; getTotalAmounts captures it via position amounts. + (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); + uint256 valueAfter = _valueUSD(total0After, total1After); + + assertApproxEqRel(valueAfter, valueBefore, 0.01e16, "total value should be preserved within 0.01% after rebalance"); + } + + /* ──────────── minAmount slippage guard tests ────────────────────── */ + + /// @dev When price is below range the position is 100% USDC (token0). + /// rebalance with minAmount0 = actual USDC held passes; minAmount0 > actual reverts. + function test_rebalance_priceBelowRange_minAmount0_passes() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + (uint256 total0, ) = provider.getTotalAmounts(); + assertGt(total0, 0, "should hold USDC before rebalance"); + + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // minAmount0 = total0 (exact), minAmount1 = 0 (position has no WBNB). + // amount0Desired = total0, amount1Desired = 0 (reinvest all USDC, no WBNB available). + vm.prank(bot); + provider.rebalance(newTick + 100, newTick + 600, total0, 0, total0, 0); + + assertEq(provider.tickLower(), newTick + 100, "tickLower updated"); + } + + function test_rebalance_priceBelowRange_minAmount0_tooHigh_reverts() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + (uint256 total0, ) = provider.getTotalAmounts(); + + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // minAmount0 one unit above actual → should revert with NPM slippage check. + // amount0Desired = total0 (correct available), minAmount0 = total0 + 1 (too tight). + vm.prank(bot); + vm.expectRevert(); + provider.rebalance(newTick + 100, newTick + 600, total0 + 1, 0, total0, 0); + } + + /// @dev When price is above range the position is 100% WBNB (token1). + /// rebalance with minAmount1 = actual WBNB held passes; minAmount1 > actual reverts. + function test_rebalance_priceAboveRange_minAmount1_passes() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + (, uint256 total1) = provider.getTotalAmounts(); + assertGt(total1, 0, "should hold WBNB before rebalance"); + + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // minAmount0 = 0 (no USDC), minAmount1 = total1 (exact). + // amount0Desired = 0, amount1Desired = total1 (reinvest all WBNB). + vm.prank(bot); + provider.rebalance(newTick - 600, newTick - 100, 0, total1, 0, total1); + + assertEq(provider.tickUpper(), newTick - 100, "tickUpper updated"); + } + + function test_rebalance_priceAboveRange_minAmount1_tooHigh_reverts() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + (, uint256 total1) = provider.getTotalAmounts(); + + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // minAmount1 one unit above actual → should revert with NPM slippage check. + // amount1Desired = total1 (correct available), minAmount1 = total1 + 1 (too tight). + vm.prank(bot); + vm.expectRevert(); + provider.rebalance(newTick - 600, newTick - 100, 0, total1 + 1, 0, total1); + } + + function test_withdraw_minAmount_tooHigh_reverts() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (uint256 exp0, ) = provider.previewRedeem(shares); + + vm.prank(user); + vm.expectRevert(); + provider.withdraw(marketParams, shares, exp0 * 2, 1, user, user); + } + + function test_redeemShares_minAmount_tooHigh_reverts() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + vm.prank(MOOLAH_PROXY); + provider.transfer(user2, shares); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + uint256 min0 = (exp0 * 999) / 1000; + + vm.prank(user2); + vm.expectRevert(); + provider.redeemShares(shares, min0, exp1 * 2, user2); + } + + /* ──────────── withdraw token composition by price position ─────── */ + + function test_withdraw_belowRange_returnsToken0Only() public { + // When price is below tickLower the entire position is token0. + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertGt(exp0, 0, "previewRedeem should predict token0 below range"); + assertEq(exp1, 0, "previewRedeem should predict zero token1 below range"); + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, (exp0 * 999) / 1000, 0, user, user); + + assertGt(out0, 0, "should receive token0 when price below range"); + assertEq(out1, 0, "should receive no token1 when price below range"); + } + + function test_withdraw_aboveRange_returnsToken1Only() public { + // When price is above tickUpper the entire position is token1. + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertEq(exp0, 0, "previewRedeem should predict zero token0 above range"); + assertGt(exp1, 0, "previewRedeem should predict token1 above range"); + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, 0, (exp1 * 999) / 1000, user, user); + + assertEq(out0, 0, "should receive no token0 when price above range"); + assertGt(out1, 0, "should receive token1 when price above range"); + } + + function test_withdraw_inRange_cannotForceOneSided_alwaysBoth() public { + // Even with minAmount1=0, an in-range withdrawal still returns token1. + // Setting min to 0 disables the floor but does not change what is received. + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (uint256 exp0, ) = provider.previewRedeem(shares); + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, (exp0 * 999) / 1000, 0, user, user); + + assertGt(out0, 0, "token0 returned even with minAmount1=0"); + assertGt(out1, 0, "token1 still returned in-range regardless of minAmount1=0"); + } + + /* ─────────────────── slisBNBx: setSlisBNBxMinter ───────────────── */ + + address constant SLISBNBX = 0x4b30fcAA7945fE9fDEFD2895aae539ba102Ed6F6; + address constant SLISBNBX_ADMIN = 0x702115D6d3Bbb37F407aae4dEcf9d09980e28ebc; + + function _deployMinter() internal returns (SlisBNBxMinter minter) { + address[] memory modules = new address[](1); + modules[0] = address(provider); + + SlisBNBxMinter.ModuleConfig[] memory configs = new SlisBNBxMinter.ModuleConfig[](1); + configs[0] = SlisBNBxMinter.ModuleConfig({ + discount: 2e4, // 2 % + feeRate: 3e4, // 3 % + moduleAddress: address(provider) + }); + + SlisBNBxMinter impl = new SlisBNBxMinter(SLISBNBX); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeWithSelector(SlisBNBxMinter.initialize.selector, admin, manager, modules, configs) + ); + minter = SlisBNBxMinter(address(proxy)); + + // Give minter an MPC wallet with a large cap so minting never hits the cap. + address mpc = makeAddr("mpc"); + vm.prank(manager); + minter.addMPCWallet(mpc, 1_000_000_000 ether); + + // Authorise the minter contract to mint slisBNBx. + vm.prank(SLISBNBX_ADMIN); + ISlisBNBx(SLISBNBX).addMinter(address(minter)); + } + + function test_setSlisBNBxMinter_manager_succeeds() public { + SlisBNBxMinter minter = _deployMinter(); + + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + assertEq(provider.slisBNBxMinter(), address(minter)); + } + + function test_setSlisBNBxMinter_zeroAddress_disablesMinter() public { + SlisBNBxMinter minter = _deployMinter(); + vm.startPrank(manager); + provider.setSlisBNBxMinter(address(minter)); + assertEq(provider.slisBNBxMinter(), address(minter)); + provider.setSlisBNBxMinter(address(0)); + assertEq(provider.slisBNBxMinter(), address(0)); + vm.stopPrank(); + } + + function test_setSlisBNBxMinter_notManager_reverts() public { + SlisBNBxMinter minter = _deployMinter(); + + vm.prank(user); + vm.expectRevert(); + provider.setSlisBNBxMinter(address(minter)); + } + + /* ─────────────────── slisBNBx: deposit tracking ────────────────── */ + + function test_deposit_updatesUserMarketDeposit() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + assertEq(provider.userMarketDeposit(user, marketId), shares, "userMarketDeposit should match shares"); + assertEq(provider.userTotalDeposit(user), shares, "userTotalDeposit should match shares"); + } + + function test_deposit_twoDeposits_accumulatesTotal() public { + (uint256 shares1, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares2, , ) = _deposit(user, 1_000 ether, 3 ether); + + assertEq(provider.userMarketDeposit(user, marketId), shares1 + shares2, "market deposit should accumulate"); + assertEq(provider.userTotalDeposit(user), shares1 + shares2, "total deposit should accumulate"); + } + + function test_deposit_twoUsers_trackingIsIndependent() public { + (uint256 shares1, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares2, , ) = _deposit(user2, 2_000 ether, 6 ether); + + assertEq(provider.userMarketDeposit(user, marketId), shares1); + assertEq(provider.userTotalDeposit(user), shares1); + assertEq(provider.userMarketDeposit(user2, marketId), shares2); + assertEq(provider.userTotalDeposit(user2), shares2); + } + + /* ─────────────────── slisBNBx: withdraw tracking ───────────────── */ + + function test_withdraw_updatesUserMarketDeposit() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + vm.prank(user); + provider.withdraw(marketParams, shares, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); + + assertEq(provider.userMarketDeposit(user, marketId), 0, "market deposit should be 0 after full withdraw"); + assertEq(provider.userTotalDeposit(user), 0, "total deposit should be 0 after full withdraw"); + } + + function test_withdraw_partial_updatesTracking() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + uint256 half = shares / 2; + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(half); + vm.prank(user); + provider.withdraw(marketParams, half, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); + + uint256 remaining = provider.userMarketDeposit(user, marketId); + assertApproxEqAbs(remaining, shares - half, 1, "market deposit should halve"); + assertEq(provider.userTotalDeposit(user), remaining, "total deposit matches market deposit"); + } + + /* ─────────────────── slisBNBx: liquidate tracking ──────────────── */ + + function test_liquidate_syncsBorrowerToZero() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + assertEq(provider.userMarketDeposit(user, marketId), shares); + + // Simulate post-liquidation: Moolah reports 0 collateral for the borrower. + vm.mockCall( + MOOLAH_PROXY, + abi.encodeWithSelector(IMoolah.position.selector, marketId, user), + abi.encode(0, uint128(0), uint128(0)) + ); + + vm.prank(MOOLAH_PROXY); + provider.liquidate(marketId, user); + + assertEq(provider.userMarketDeposit(user, marketId), 0, "market deposit should clear after liquidation"); + assertEq(provider.userTotalDeposit(user), 0, "total deposit should clear after liquidation"); + } + + /* ─────────────────── slisBNBx: getUserBalanceInBnb ─────────────── */ + + address constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + uint256 constant BNB_PRICE = 700e8; + + function _mockAllPrices() internal { + _mockOraclePrices(); // mocks USDC and WBNB prices + vm.mockCall(RESILIENT_ORACLE, abi.encodeWithSelector(IOracle.peek.selector, BNB_ADDRESS), abi.encode(BNB_PRICE)); + } + + function test_getUserBalanceInBnb_zeroBeforeDeposit() public view { + assertEq(provider.getUserBalanceInBnb(user), 0); + } + + function test_getUserBalanceInBnb_nonzeroAfterDeposit() public { + _mockAllPrices(); + _deposit(user, 1_000 ether, 3 ether); + + uint256 bnbValue = provider.getUserBalanceInBnb(user); + assertGt(bnbValue, 0, "should return positive BNB value after deposit"); + } + + function test_getUserBalanceInBnb_proportionalToShares() public { + _mockAllPrices(); + _deposit(user, 1_000 ether, 3 ether); + _deposit(user2, 2_000 ether, 6 ether); + + uint256 value1 = provider.getUserBalanceInBnb(user); + uint256 value2 = provider.getUserBalanceInBnb(user2); + + // user2 deposited ~2x; allow 2% tolerance for compounding and rounding. + assertApproxEqRel(value2, value1 * 2, 0.02e18, "user2 BNB value should be ~2x user"); + } + + function test_getUserBalanceInBnb_matchesShareValueInBnb() public { + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + // peek() returns (totalValue * 1e18 / supply) where totalValue is 8-dec USD. + // getUserBalanceInBnb returns (shares * 1e18 * totalValue / supply / bnbPrice) + // = shares * sharePrice / bnbPrice + uint256 sharePrice = provider.peek(address(provider)); // 8-dec USD * 1e18 / liquidity-unit + uint256 expectedBnbValue = (shares * sharePrice) / BNB_PRICE; + + uint256 actualBnbValue = provider.getUserBalanceInBnb(user); + // Allow 1% for rounding between slot0-based amounts and oracle math. + assertApproxEqRel(actualBnbValue, expectedBnbValue, 0.01e18, "BNB value should match share oracle price"); + } + + /* ─────────────────── slisBNBx: manual sync ─────────────────────── */ + + function test_syncUserBalance_noOpWhenAlreadySynced() public { + _deposit(user, 1_000 ether, 3 ether); + + uint256 depositBefore = provider.userMarketDeposit(user, marketId); + provider.syncUserBalance(marketId, user); + assertEq(provider.userMarketDeposit(user, marketId), depositBefore, "no change when already synced"); + } + + function test_bulkSyncUserBalance_syncsMultipleUsers() public { + _deposit(user, 1_000 ether, 3 ether); + _deposit(user2, 2_000 ether, 6 ether); + + uint256 d1 = provider.userMarketDeposit(user, marketId); + uint256 d2 = provider.userMarketDeposit(user2, marketId); + + Id[] memory ids = new Id[](2); + ids[0] = marketId; + ids[1] = marketId; + address[] memory accounts = new address[](2); + accounts[0] = user; + accounts[1] = user2; + + provider.bulkSyncUserBalance(ids, accounts); + + assertEq(provider.userMarketDeposit(user, marketId), d1, "user1 unchanged"); + assertEq(provider.userMarketDeposit(user2, marketId), d2, "user2 unchanged"); + } + + function test_bulkSyncUserBalance_lengthMismatch_reverts() public { + Id[] memory ids = new Id[](2); + ids[0] = marketId; + ids[1] = marketId; + address[] memory accounts = new address[](1); + accounts[0] = user; + + vm.expectRevert("length mismatch"); + provider.bulkSyncUserBalance(ids, accounts); + } + + // ── H-1 regression: foreign market ID must be rejected ──────────── + + /// @dev Returns the Id of a live Moolah market whose collateralToken != address(provider). + function _foreignMarketId() internal pure returns (Id) { + // Use the first market in the live Moolah deployment (slisBNB / lisUSD). + // Its collateralToken is slisBNB, not this V3Provider. + MarketParams memory foreign = MarketParams({ + loanToken: LISUSD, + collateralToken: 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B, // slisBNB + oracle: RESILIENT_ORACLE, + irm: 0x5F9f9173B405C6CEAfa7f98d09e4B8447e9797E6, + lltv: 90 * 1e16 + }); + return foreign.id(); + } + + function test_syncUserBalance_foreignMarket_reverts() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 totalBefore = provider.userTotalDeposit(user); + + vm.expectRevert("invalid market"); + provider.syncUserBalance(_foreignMarketId(), user); + + // Deposit tracking must be unchanged. + assertEq(provider.userTotalDeposit(user), totalBefore); + } + + function test_bulkSyncUserBalance_foreignMarket_reverts() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 totalBefore = provider.userTotalDeposit(user); + + Id[] memory ids = new Id[](1); + ids[0] = _foreignMarketId(); + address[] memory accounts = new address[](1); + accounts[0] = user; + + vm.expectRevert("invalid market"); + provider.bulkSyncUserBalance(ids, accounts); + + assertEq(provider.userTotalDeposit(user), totalBefore); + } + + /* ─────────────────── slisBNBx: minter integration ──────────────── */ + + function test_withMinter_deposit_mintsSlisBNBx() public { + SlisBNBxMinter minter = _deployMinter(); + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + // Deposit tracking + assertEq(provider.userMarketDeposit(user, marketId), shares, "userMarketDeposit should equal shares"); + assertEq(provider.userTotalDeposit(user), shares, "userTotalDeposit should equal shares"); + // slisBNBx minted to user + assertGt(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "slisBNBx should be minted to user after deposit"); + } + + function test_withMinter_withdraw_burnsSlisBNBx() public { + SlisBNBxMinter minter = _deployMinter(); + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + assertGt(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "setup: slisBNBx minted after deposit"); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + vm.prank(user); + provider.withdraw(marketParams, shares, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); + + // Deposit tracking zeroed + assertEq(provider.userMarketDeposit(user, marketId), 0, "userMarketDeposit should be 0 after full withdraw"); + assertEq(provider.userTotalDeposit(user), 0, "userTotalDeposit should be 0 after full withdraw"); + // slisBNBx burned + assertEq(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "slisBNBx should be burned after full withdraw"); + } + + function test_withMinter_partialWithdraw_reducesSlisBNBx() public { + SlisBNBxMinter minter = _deployMinter(); + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + uint256 slisBNBxAfterDeposit = ISlisBNBx(SLISBNBX).balanceOf(user); + assertGt(slisBNBxAfterDeposit, 0); + + uint256 half = shares / 2; + (uint256 exp0, uint256 exp1) = provider.previewRedeem(half); + vm.prank(user); + provider.withdraw(marketParams, half, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); + + uint256 remainingDeposit = provider.userMarketDeposit(user, marketId); + // Deposit tracking reduced by half + assertApproxEqAbs(remainingDeposit, shares - half, 1, "userMarketDeposit should halve"); + assertEq(provider.userTotalDeposit(user), remainingDeposit, "userTotalDeposit matches userMarketDeposit"); + // slisBNBx partially burned + uint256 slisBNBxAfterWithdraw = ISlisBNBx(SLISBNBX).balanceOf(user); + assertLt(slisBNBxAfterWithdraw, slisBNBxAfterDeposit, "slisBNBx should decrease after partial withdraw"); + assertGt(slisBNBxAfterWithdraw, 0, "some slisBNBx should remain after partial withdraw"); + } + + function test_withMinter_liquidate_burnsSlisBNBx() public { + SlisBNBxMinter minter = _deployMinter(); + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + assertEq(provider.userMarketDeposit(user, marketId), shares, "setup: deposit tracked"); + assertGt(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "setup: slisBNBx minted after deposit"); + + // Simulate full liquidation: Moolah reports 0 collateral for the borrower. + vm.mockCall( + MOOLAH_PROXY, + abi.encodeWithSelector(IMoolah.position.selector, marketId, user), + abi.encode(0, uint128(0), uint128(0)) + ); + + vm.prank(MOOLAH_PROXY); + provider.liquidate(marketId, user); + + // Deposit tracking zeroed + assertEq(provider.userMarketDeposit(user, marketId), 0, "userMarketDeposit cleared after liquidation"); + assertEq(provider.userTotalDeposit(user), 0, "userTotalDeposit cleared after liquidation"); + // slisBNBx burned + assertEq(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "slisBNBx burned after liquidation sync"); + } + + /* ─────────────────── borrow / repay / liquidate ─────────────────── */ + + function _borrow(address _user, uint256 assets) internal { + vm.prank(_user); + moolah.borrow(marketParams, assets, 0, _user, _user); + } + + function _debtOf(address _user) internal view returns (uint128 borrowShares) { + (, borrowShares, ) = moolah.position(marketId, _user); + } + + /// @dev Borrow 60% of the user's current collateral value. Safe to borrow (< LLTV) + /// but large enough that mocking the price to zero makes the position unhealthy. + function _borrowAgainstCollateral(address _user) internal returns (uint256 borrowed) { + (, , uint128 col) = moolah.position(marketId, _user); + uint256 sharePrice = provider.peek(address(provider)); // 8-dec USD per share + uint256 loanPrice = provider.peek(LISUSD); // 8-dec USD per lisUSD (~1e8) + // 60% of collateral value in lisUSD units + borrowed = (uint256(col) * sharePrice * 60) / (loanPrice * 100); + _borrow(_user, borrowed); + } + + /// @dev Set collateral oracle price to zero, making any position with debt unhealthy. + function _makeUnhealthy() internal { + vm.mockCall( + address(provider), + abi.encodeWithSelector(IOracle.peek.selector, address(provider)), + abi.encode(uint256(0)) + ); + } + + function test_borrow_afterDeposit_receivesLisUSD() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 balBefore = IERC20(LISUSD).balanceOf(user); + _borrow(user, 100 ether); + assertEq(IERC20(LISUSD).balanceOf(user), balBefore + 100 ether); + assertGt(_debtOf(user), 0, "borrow shares recorded"); + } + + function test_borrow_twoUsers_independentDebt() public { + _deposit(user, 1_000 ether, 3 ether); + _deposit(user2, 2_000 ether, 6 ether); + _borrow(user, 100 ether); + _borrow(user2, 200 ether); + assertGt(_debtOf(user), 0); + assertGt(_debtOf(user2), _debtOf(user), "user2 has more debt"); + assertEq(IERC20(LISUSD).balanceOf(user), 100 ether); + assertEq(IERC20(LISUSD).balanceOf(user2), 200 ether); + } + + function test_repay_full_clearsDebt() public { + _deposit(user, 1_000 ether, 3 ether); + _borrow(user, 100 ether); + assertGt(_debtOf(user), 0); + + deal(LISUSD, user, 200 ether); // extra buffer for accrued interest + vm.startPrank(user); + IERC20(LISUSD).approve(MOOLAH_PROXY, type(uint256).max); + moolah.repay(marketParams, 0, _debtOf(user), user, ""); // repay by shares → exact + vm.stopPrank(); + + assertEq(_debtOf(user), 0, "debt cleared after full repay"); + } + + function test_repay_partial_reducesDebt() public { + _deposit(user, 1_000 ether, 3 ether); + _borrow(user, 100 ether); + uint128 sharesBefore = _debtOf(user); + + deal(LISUSD, user, 50 ether); + vm.startPrank(user); + IERC20(LISUSD).approve(MOOLAH_PROXY, type(uint256).max); + moolah.repay(marketParams, 50 ether, 0, user, ""); + vm.stopPrank(); + + uint128 sharesAfter = _debtOf(user); + assertLt(sharesAfter, sharesBefore, "debt decreased"); + assertGt(sharesAfter, 0, "some debt remains"); + } + + function test_liquidate_seizedSharesSentToLiquidator() public { + address liquidator = makeAddr("liquidator"); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + + deal(LISUSD, liquidator, 1_000 ether); + vm.startPrank(liquidator); + IERC20(LISUSD).approve(MOOLAH_PROXY, type(uint256).max); + moolah.liquidate(marketParams, user, shares, 0, ""); + vm.stopPrank(); + + assertGt(provider.balanceOf(liquidator), 0, "liquidator received shares"); + (, , uint128 colAfter) = moolah.position(marketId, user); + assertEq(colAfter, 0, "borrower collateral seized"); + assertEq(provider.userMarketDeposit(user, marketId), 0, "deposit tracking cleared"); + assertEq(provider.userTotalDeposit(user), 0, "total deposit cleared"); + } + + function test_liquidate_liquidatorRedeemsSharesToTokens() public { + address liquidator = makeAddr("liquidator"); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + + deal(LISUSD, liquidator, 1_000 ether); + vm.startPrank(liquidator); + IERC20(LISUSD).approve(MOOLAH_PROXY, type(uint256).max); + moolah.liquidate(marketParams, user, shares, 0, ""); + + uint256 seizedShares = provider.balanceOf(liquidator); + (uint256 exp0, uint256 exp1) = provider.previewRedeem(seizedShares); + (uint256 out0, uint256 out1) = provider.redeemShares( + seizedShares, + (exp0 * 99) / 100, + (exp1 * 99) / 100, + liquidator + ); + vm.stopPrank(); + + assertEq(provider.balanceOf(liquidator), 0, "shares burned after redeem"); + assertGt(out0 + out1, 0, "liquidator received tokens"); + assertEq(IERC20(USDC).balanceOf(liquidator), out0); + assertEq(liquidator.balance, out1); // WBNB unwrapped to BNB + } +}