diff --git a/.gitmodules b/.gitmodules index 9acd364e..5ba86499 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,6 +18,9 @@ [submodule "lib/murky"] path = lib/murky url = https://github.com/dmfxyz/murky +[submodule "lib/lista-v3"] + path = lib/lista-v3 + url = https://github.com/lista-dao/lista-v3 [submodule "lib/lista-dao-contracts.git"] path = lib/lista-dao-contracts.git url = https://github.com/lista-dao/lista-dao-contracts.git diff --git a/foundry.toml b/foundry.toml index 1d33165b..3812848e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,6 +3,9 @@ src = "src" out = "out" libs = ["lib"] +remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/", +] solc = "0.8.34" optimizer = true optimizer_runs = 20 diff --git a/lib/lista-v3 b/lib/lista-v3 new file mode 160000 index 00000000..54cc4b25 --- /dev/null +++ b/lib/lista-v3 @@ -0,0 +1 @@ +Subproject commit 54cc4b2522d45d26e902f25767981a5784bed02c diff --git a/remappings.txt b/remappings.txt index ea98bb20..96d18410 100644 --- a/remappings.txt +++ b/remappings.txt @@ -11,8 +11,10 @@ forge-std=lib/forge-std/src timelock=src/timelock revenue=src/revenue murky=lib/murky +lista-v3/=lib/lista-v3/src/ +lista-dao-contracts/=lib/lista-dao-contracts.git/contracts/ @openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol=lib/lista-dao-contracts.git/node_modules/@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol @openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol=lib/lista-dao-contracts.git/node_modules/@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol @openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol=lib/lista-dao-contracts.git/node_modules/@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol -lib/lista-dao-contracts.git/:@openzeppelin/contracts-upgradeable/=lib/lista-dao-contracts.git/node_modules/@openzeppelin/contracts-upgradeable/ \ No newline at end of file +lib/lista-dao-contracts.git/:@openzeppelin/contracts-upgradeable/=lib/lista-dao-contracts.git/node_modules/@openzeppelin/contracts-upgradeable/ diff --git a/src/liquidator/V3Liquidator.sol b/src/liquidator/V3Liquidator.sol new file mode 100644 index 00000000..4a38ef1d --- /dev/null +++ b/src/liquidator/V3Liquidator.sol @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.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 { IWBNB } from "../provider/interfaces/IWBNB.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, AccessControlEnumerableUpgradeable { + 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 the native coin in token whitelists. + address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /* ──────────────────────────── 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 coin → 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 / native coin 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 / native coin 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 / native coin → 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 / native coin 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 (the wrapped-native leg arrives as the native coin). + * 2. Swaps TOKEN0 → loanToken. + * 3. Swaps TOKEN1 → 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. + * The wrapped-native leg arrives as the native coin (the provider unwraps it on exit). + * @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 to receive (slippage guard). + * @param receiver Recipient of TOKEN0 and TOKEN1 (native coin for the wrapped-native leg). + */ + 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(); + // The provider unwraps whichever leg equals its wrapped-native token to the native coin on exit + // (WBNB→BNB on BSC, WETH→ETH on Ethereum), so that leg must be sold via call{value}; every other + // leg is an ERC-20 sold via approve + call. Read it from the provider — never hardcode a chain. + address wrappedNative = IV3Provider(d.v3Provider).WRAPPED_NATIVE(); + + // Redeem V3 shares → TOKEN0 + TOKEN1; the wrapped-native leg arrives as the native coin. + (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) { + _swapRedeemedLeg(token0 == wrappedNative, d.token0Pair, d.token0Spender, token0, amount0, d.swapToken0Data); + } + + // Swap TOKEN1 → loanToken. + if (d.swapToken1 && amount1 > 0 && token1 != d.loanToken) { + _swapRedeemedLeg(token1 == wrappedNative, d.token1Pair, d.token1Spender, token1, amount1, d.swapToken1Data); + } + + // If the loan token IS the wrapped-native, its leg was redeemed as the native coin and its swap + // was skipped (loanToken→loanToken is a no-op). Wrap the native balance back so the ERC-20 + // repayment check below — and Moolah's subsequent transferFrom — see the loanToken balance. + if (d.loanToken == wrappedNative) { + uint256 nativeBalance = address(this).balance; + if (nativeBalance > 0) IWBNB(wrappedNative).deposit{ value: nativeBalance }(); + } + + 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 ──────────────────────────── */ + + /// @dev Sell a redeemed collateral leg into loanToken inside the liquidation callback. The wrapped- + /// native leg arrives as the native coin (the provider unwraps it on exit), so it is sold via + /// call{value}; every other leg is an ERC-20 sold via approve + call. `isNativeLeg` is decided + /// by the provider's WRAPPED_NATIVE(), so this works on any chain and for either token position. + function _swapRedeemedLeg( + bool isNativeLeg, + address pair, + address spender, + address token, + uint256 amount, + bytes memory swapData + ) private { + if (isNativeLeg) { + (bool ok, ) = pair.call{ value: amount }(swapData); + require(ok, SwapFailed()); + } else { + token.safeApprove(spender, amount); + (bool ok, ) = pair.call(swapData); + require(ok, SwapFailed()); + token.safeApprove(spender, 0); + } + } + + 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/interfaces/INonfungiblePositionManager.sol b/src/provider/interfaces/INonfungiblePositionManager.sol new file mode 100644 index 00000000..efa34249 --- /dev/null +++ b/src/provider/interfaces/INonfungiblePositionManager.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +/// @title Minimal Lista V3 / Uniswap V3 NonfungiblePositionManager interface +/// @notice Self-contained subset consumed by {V3Provider}. The full interface in the +/// lista-v3 dependency (lib/lista-v3) is Solidity 0.7.6 and inherits OpenZeppelin +/// ERC721 upgradeable interfaces whose paths/names differ from this repo's OZ v5.2, +/// so it cannot be imported into a 0.8.34 contract. Only the methods V3Provider +/// actually calls are declared here. +interface INonfungiblePositionManager { + /// @notice The address of the factory that created the position manager's pools. + function factory() external view returns (address); + + /// @notice Returns the position information associated with a given token ID. + 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 + ); + + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + /// @notice Creates a new position wrapped in a NFT. + function mint( + MintParams calldata params + ) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + + struct IncreaseLiquidityParams { + uint256 tokenId; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + /// @notice Increases the amount of liquidity in a position, with tokens paid by `msg.sender`. + function increaseLiquidity( + IncreaseLiquidityParams calldata params + ) external payable returns (uint128 liquidity, uint256 amount0, uint256 amount1); + + struct DecreaseLiquidityParams { + uint256 tokenId; + uint128 liquidity; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + /// @notice Decreases the amount of liquidity in a position and accounts it to the position. + function decreaseLiquidity( + DecreaseLiquidityParams calldata params + ) external payable returns (uint256 amount0, uint256 amount1); + + struct CollectParams { + uint256 tokenId; + address recipient; + uint128 amount0Max; + uint128 amount1Max; + } + + /// @notice Collects up to a maximum amount of fees owed to a specific position to the recipient. + function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1); + + /// @notice Burns a token ID, which deletes it from the NFT contract. The token must have 0 liquidity + /// and all tokens must be collected first. + function burn(uint256 tokenId) external payable; +} diff --git a/src/provider/interfaces/ISlisBNBV3DexAdapter.sol b/src/provider/interfaces/ISlisBNBV3DexAdapter.sol new file mode 100644 index 00000000..0b8a53fc --- /dev/null +++ b/src/provider/interfaces/ISlisBNBV3DexAdapter.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { IV3DexAdapter } from "./IV3DexAdapter.sol"; + +/** + * @title ISlisBNBV3DexAdapter + * @notice slisBNB/BNB adapter surface consumed by SlisBNBV3Provider. The rate-centered `rebalance` + * and rate-drift config are now generic (promoted to {IV3DexAdapter}); this alias is retained + * so existing imports / casts keep compiling unchanged. + */ +interface ISlisBNBV3DexAdapter is IV3DexAdapter {} diff --git a/src/provider/interfaces/IStakeManager.sol b/src/provider/interfaces/IStakeManager.sol index 59a40ce7..8303062a 100644 --- a/src/provider/interfaces/IStakeManager.sol +++ b/src/provider/interfaces/IStakeManager.sol @@ -5,4 +5,12 @@ interface IStakeManager { function convertBnbToSnBnb(uint256 _amount) external view returns (uint256); function convertSnBnbToBnb(uint256 _amountInSlisBnb) external view returns (uint256); + + /// @notice Stake native BNB and mint slisBNB to the caller. + function deposit() external payable; + + /// @notice Instantly redeem slisBNB for native BNB (no unbonding cooldown), minus an + /// instant-withdraw fee. The caller must approve `_amountInSlisBnb` to this contract. + /// @return bnbAmount The native BNB sent to the caller. + function instantWithdraw(uint256 _amountInSlisBnb) external returns (uint256 bnbAmount); } diff --git a/src/provider/interfaces/IV3DexAdapter.sol b/src/provider/interfaces/IV3DexAdapter.sol new file mode 100644 index 00000000..5f46566d --- /dev/null +++ b/src/provider/interfaces/IV3DexAdapter.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +/** + * @title IV3DexAdapter + * @author Lista DAO + * @notice Seam between the vault (V3Provider) / oracle (SlisBNBV3ProviderOracle) and the DEX custodian + * (V3DexAdapter). The adapter is the SOLE holder of the V3 NFT, the idle inventory and all + * NPM/pool interaction. The vault and oracle never touch NPM/pool directly — they call the + * adapter's `onlyProvider` write functions and read its raw-NAV/composition views (staticcall). + * + * @dev Conventions: + * - Raw NAV: all views return amounts WITHOUT any oracle haircut. The vault uses them for + * share accounting; the oracle applies its own haircut on top for Moolah pricing. + * - Token custody: before `addLiquidity` the provider transfers the input tokens to the adapter; + * the adapter refunds unused tokens to `refundTo` and, on `removeLiquidity`, sends the + * withdrawn underlying directly to `receiver` (avoids a provider→user double hop). + * - "amounts" are token0/token1 raw units; sqrt prices are X96. + */ +interface IV3DexAdapter { + /* ───────────────────── pool / position accessors ────────────── */ + + function TOKEN0() external view returns (address); + + function TOKEN1() external view returns (address); + + function DECIMALS0() external view returns (uint8); + + function DECIMALS1() external view returns (uint8); + + function POOL() external view returns (address); + + /// @notice Wrapped-native token of the chain (WBNB on BSC, WETH on Ethereum). + function WRAPPED_NATIVE() external view returns (address); + + /// @notice The vault (V3Provider) authorized to drive this adapter. + function provider() external view returns (address); + + function tokenId() external view returns (uint256); + + function tickLower() external view returns (int24); + + function tickUpper() external view returns (int24); + + function idleToken0() external view returns (uint256); + + function idleToken1() external view returns (uint256); + + /* ───────────────── raw-NAV / composition views ──────────────── */ + + /// @notice token0/token1 represented by the whole position at `sqrtPriceX96`, INCLUDING uncollected + /// fees (tokensOwed) and idle inventory. Raw (no haircut). Single source of truth that both + /// the vault (share accounting) and oracle (Moolah pricing) read. + function positionAmountsAt(uint160 sqrtPriceX96) external view returns (uint256 total0, uint256 total1); + + /// @notice token0/token1 for a given `liquidity` at `sqrtPriceX96` (position math only, no fees/idle). + /// Used by the vault's value-based share mint to value freshly added liquidity at the fair price. + function amountsForLiquidity( + uint128 liquidity, + uint160 sqrtPriceX96 + ) external view returns (uint256 amount0, uint256 amount1); + + /// @notice Current liquidity of the managed position (0 if none). + function totalLiquidity() external view returns (uint128); + + /// @notice Fair valuation price: exchange-rate-implied for slisBNB/WBNB, pool TWAP otherwise. + function fairSqrtPriceX96() external view returns (uint160); + + /// @notice Current pool spot price (slot0). + function spotSqrtPriceX96() external view returns (uint160); + + /// @notice Simulate adding `amount0Desired/amount1Desired` at the current spot price. + function previewAddLiquidity( + uint256 amount0Desired, + uint256 amount1Desired + ) external view returns (uint128 liquidity, uint256 amount0, uint256 amount1); + + /// @notice Simulate removing `shares/totalShares` of the position (liquidity + idle) at spot. + function previewRemoveLiquidity( + uint256 shares, + uint256 totalShares + ) external view returns (uint256 amount0, uint256 amount1); + + /* ─────────────────────── writes (onlyProvider) ──────────────── */ + + /// @notice Add liquidity from tokens already transferred to the adapter; mint a fresh NFT if none + /// exists, otherwise increase. Unused input is refunded to `refundTo`. + /// @return liquidityAdded Liquidity units added. + /// @return amount0Used token0 actually consumed by the pool. + /// @return amount1Used token1 actually consumed by the pool. + function addLiquidity( + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min, + address refundTo + ) external returns (uint128 liquidityAdded, uint256 amount0Used, uint256 amount1Used); + + /// @notice Remove the `shares/totalShares` pro-rata slice of liquidity AND idle inventory, sending + /// the underlying directly to `receiver` (WBNB unwrapped to native BNB). Used by the vault's + /// withdraw / redeemShares. No protocol value floor — the caller's minAmount0/1 is the guard + /// (keeps liquidation live; see finding C4). + function removeLiquidity( + uint256 shares, + uint256 totalShares, + uint256 minAmount0, + uint256 minAmount1, + address receiver + ) external returns (uint256 amount0, uint256 amount1); + + /// @notice Collect accrued fees and re-add them plus idle inventory as liquidity (compound). + function collectAndCompound() external; + + /* ─────────────────────── rebalance / rate config ────────────────── */ + + /// @notice Exchange rate at the last successful center/init (rate-implied pairs; 0 for TWAP pairs). + function lastCenterRate() external view returns (uint256); + + /// @notice Min relative exchange-rate drift before rebalance is allowed (BPS; 0 = off). + function centerRateThresholdBps() external view returns (uint256); + + /// @notice Set the rate-drift threshold required for rebalance (onlyRole MANAGER). + function setCenterRateThresholdBps(uint256 centerRateThresholdBps) external; + + /// @notice Whether `swapPair` is a whitelisted venue for the rebalance inventory-conversion swap. + function swapPairWhitelist(address swapPair) external view returns (bool); + + /// @notice Whitelist (or remove) a swap venue for the rebalance inventory conversion (onlyRole + /// MANAGER). A venue may never be TOKEN0 / TOKEN1 / POOL / POSITION_MANAGER. + function setSwapPairWhitelist(address swapPair, bool status) external; + + /// @notice Recenter the position to its range and convert inventory to the optimal ratio. + /// onlyProvider — the provider gates the caller with the BOT role. + function rebalance( + uint256 minAmount0, + uint256 minAmount1, + uint256 minLiquidity, + uint256 deadline, + bytes calldata swapData + ) external; +} diff --git a/src/provider/interfaces/IV3PoolMinimal.sol b/src/provider/interfaces/IV3PoolMinimal.sol new file mode 100644 index 00000000..b04a0ac4 --- /dev/null +++ b/src/provider/interfaces/IV3PoolMinimal.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +/** + * @title IV3PoolMinimal + * @author Lista DAO + * @notice Minimal Uniswap/PancakeSwap V3 pool reader that decodes only the slot0 fields the adapter + * actually consumes (sqrtPriceX96, tick). The full slot0 tuple ends with a `feeProtocol` field + * whose width differs across forks — Uniswap V3 / lista-v3 pack it as uint8, PancakeSwap V3 as + * uint32. Decoding the whole tuple through a uint8-typed interface reverts against a Pancake + * pool (dirty high bits). Stopping the decode at `tick` makes the read width-agnostic, so the + * adapter works against any V3 flavor (and the integration tests can fork a live Pancake pool). + */ +interface IV3PoolMinimal { + function slot0() external view returns (uint160 sqrtPriceX96, int24 tick); +} diff --git a/src/provider/interfaces/IV3Provider.sol b/src/provider/interfaces/IV3Provider.sol new file mode 100644 index 00000000..b143bcb0 --- /dev/null +++ b/src/provider/interfaces/IV3Provider.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { IProvider } from "./IProvider.sol"; + +/** + * @title IV3Provider + * @notice Vault surface for the V3 LP collateral provider. Position internals (tokenId, ticks, pool, + * TWAP) live on the DEX adapter (IV3DexAdapter); share pricing lives on the oracle + * (IV3ProviderOracle). The vault is no longer an IOracle. + */ +interface IV3Provider is IProvider { + function TOKEN0() external view returns (address); + + function TOKEN1() external view returns (address); + + /// @notice Wrapped-native token of the pool's chain (WBNB on BSC, WETH on Ethereum). On exit the + /// provider unwraps whichever leg equals this to the native coin, so consumers (e.g. the + /// liquidator) must treat that leg as native rather than ERC-20. + function WRAPPED_NATIVE() external view returns (address); + + /// @notice The DEX adapter holding the V3 NFT / idle inventory. + function ADAPTER() external view returns (address); + + /// @notice Total token0/token1 backing the vault at the current pool spot (display/bots). + function getTotalAmounts() external view returns (uint256 total0, uint256 total1); + + /// @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 Withdraw provider shares from Moolah collateral without redeeming the underlying position. + function withdrawShares( + MarketParams calldata marketParams, + uint256 shares, + address onBehalf, + address receiver + ) external; + + /// @notice Supply wallet-held provider shares as Moolah collateral. + function supplyShares(MarketParams calldata marketParams, uint256 shares, address onBehalf) external; + + /// @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/src/provider/interfaces/IV3ProviderOracle.sol b/src/provider/interfaces/IV3ProviderOracle.sol new file mode 100644 index 00000000..d30a2f0c --- /dev/null +++ b/src/provider/interfaces/IV3ProviderOracle.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { IOracle } from "moolah/interfaces/IOracle.sol"; + +/** + * @title IV3ProviderOracle + * @author Lista DAO + * @notice Standalone oracle for the vLP share token. Moolah's `market.oracle` points here. It prices + * the share by staticcalling the adapter's fair composition view (no double-hop through the + * vault), pricing each leg via the resilient oracle, then applying a conservative haircut. + * Separating the IOracle implementation from the vault isolates the estimation-bug radius from + * vault state and upgrades (Codex adv #5). + * + * @dev `peek(share)` reverts on a zero underlying price / zero total value when supply > 0 (finding D), + * so Moolah never prices collateral off a broken feed; `supply == 0` returns 0 (pre-market). + * `getTokenConfig(share)` self-registers this oracle for the share token; other tokens delegate to + * the resilient oracle. + */ +interface IV3ProviderOracle is IOracle { + /// @notice The DEX adapter whose fair composition view backs share pricing. + function ADAPTER() external view returns (address); + + /// @notice The vLP share token (the V3Provider) this oracle prices. + function PROVIDER_SHARE() external view returns (address); + + /// @notice Conservative haircut applied to the share price, in basis points (e.g. 50 = 0.5%). + function haircutBps() external view returns (uint256); + + /// @notice Set the share-price haircut (MANAGER). Bounded; reverts above the configured cap. + function setHaircutBps(uint256 haircutBps) external; +} diff --git a/src/provider/interfaces/IWbETH.sol b/src/provider/interfaces/IWbETH.sol new file mode 100644 index 00000000..be63bff1 --- /dev/null +++ b/src/provider/interfaces/IWbETH.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +interface IWbETH { + /// @notice ETH per 1 wbETH (1e18). Binance operator-reported exchange rate — monotonic, not market + /// driven. This is directly the WETH-per-wbETH rate (no intermediate peg). + function exchangeRate() external view returns (uint256); +} diff --git a/src/provider/interfaces/IWstETH.sol b/src/provider/interfaces/IWstETH.sol new file mode 100644 index 00000000..b9b2cbca --- /dev/null +++ b/src/provider/interfaces/IWstETH.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +interface IWstETH { + /// @notice stETH per 1 wstETH (1e18). Lido's on-chain accounting rate — monotonic, not market + /// driven. stETH is treated 1:1 with ETH, so this equals the WETH-per-wstETH rate. + function stEthPerToken() external view returns (uint256); +} diff --git a/src/provider/libraries/SwapInventoryLib.sol b/src/provider/libraries/SwapInventoryLib.sol new file mode 100644 index 00000000..f053a233 --- /dev/null +++ b/src/provider/libraries/SwapInventoryLib.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IWBNB } from "../interfaces/IWBNB.sol"; + +/** + * @title SwapInventoryLib + * @author Lista DAO + * @notice DEX-agnostic inventory-conversion swap shared by the V3 LP adapters during rebalance. Mirrors + * {Liquidator}'s aggregator pattern: the (BOT) backend builds `swapData` for ANY whitelisted venue + * (1inch / 0x / Uniswap / a StakeManager instant-redeem or stake / …); the adapter just forwards + * it via a low-level call and enforces the result with MEASURED balance deltas — `spent <= amountIn` + * and `received >= amountOutMin`. No on-chain routing or price math. + * + * The wrapped-native leg may be settled in the NATIVE coin on either side, so the venue's calling + * convention is symmetric: + * - native OUT (e.g. instantWithdraw → BNB): any native the call delivers is wrapped back into + * the wrapped-native ERC20 before `received` is measured; + * - native IN (e.g. StakeManager.deposit{value}): set `nativeIn` and the wrapped-native input is + * unwrapped to the native coin and forwarded as `msg.value` (instead of an ERC20 allowance). + * Any native delivered is ALWAYS wrapped (never stranded); if neither leg is the wrapped-native it + * reverts {UnexpectedNative}. + * + * @dev Invoked via DELEGATECALL, so `address(this)` is the adapter: token custody, allowances and any + * native received resolve to the adapter, and the swap output must land in the adapter (otherwise + * `received` is 0 and the swap reverts). The adapter whitelists `swapPair`; `amountIn`/`amountOutMin`/ + * `nativeIn`/`swapData` come from the backend. `amountIn` is capped to the available balance; the ERC-20 + * allowance to `swapPair` is set to `amountIn` then reset to 0 after the call. + */ +library SwapInventoryLib { + using SafeERC20 for IERC20; + + error SwapFailed(); + error ExceedAmountIn(); + error InsufficientOutput(); + error UnexpectedNative(); + + /// @notice Execute one backend-built swap. `sellToken0` ⇒ sell token0 for token1, else token1 for + /// token0. `nativeIn` ⇒ the venue takes the native coin for the wrapped-native input leg + /// (unwrap + call{value}); otherwise the input is an ERC-20 (approve + call). `wrappedNative` is + /// the adapter's wrapped-native token. Returns (total0, total1) adjusted by MEASURED deltas. + function swap( + address swapPair, + address token0, + address token1, + bool sellToken0, + uint256 amountIn, + uint256 amountOutMin, + bytes memory swapData, + uint256 total0, + uint256 total1, + address wrappedNative, + bool nativeIn + ) external returns (uint256, uint256) { + if (amountIn == 0) return (total0, total1); + + address tokenIn = sellToken0 ? token0 : token1; + address tokenOut = sellToken0 ? token1 : token0; + + uint256 avail = sellToken0 ? total0 : total1; + if (amountIn > avail) amountIn = avail; // never spend more than the position holds + if (amountIn == 0) return (total0, total1); + + uint256 beforeIn = IERC20(tokenIn).balanceOf(address(this)); + uint256 beforeOut = IERC20(tokenOut).balanceOf(address(this)); + uint256 beforeNative = address(this).balance; + + if (nativeIn) { + // Native-input venue (e.g. StakeManager.deposit{value}): only the wrapped-native leg can be paid + // as the native coin. Unwrap the wrapped-native ERC-20 and forward it as msg.value. + if (tokenIn != wrappedNative) revert UnexpectedNative(); + IWBNB(wrappedNative).withdraw(amountIn); + (bool ok, ) = swapPair.call{ value: amountIn }(swapData); + if (!ok) revert SwapFailed(); + } else { + IERC20(tokenIn).forceApprove(swapPair, amountIn); + (bool ok, ) = swapPair.call(swapData); + if (!ok) revert SwapFailed(); + IERC20(tokenIn).forceApprove(swapPair, 0); // clear any residual allowance + } + + // Wrap any native this call delivered — a native-out venue's proceeds (instantWithdraw → BNB) or a + // native-in venue's unspent refund — back into the wrapped-native ERC-20, so it is booked into the + // totals via the spent/received deltas and never stranded. Native can only belong to the + // wrapped-native leg; if neither leg is it, there is nowhere to book the native ⇒ revert. + if (address(this).balance > beforeNative) { + if (tokenIn != wrappedNative && tokenOut != wrappedNative) revert UnexpectedNative(); + IWBNB(wrappedNative).deposit{ value: address(this).balance - beforeNative }(); + } + + uint256 spent = beforeIn - IERC20(tokenIn).balanceOf(address(this)); + uint256 received = IERC20(tokenOut).balanceOf(address(this)) - beforeOut; + if (spent > amountIn) revert ExceedAmountIn(); + if (received < amountOutMin) revert InsufficientOutput(); + + if (sellToken0) { + total0 -= spent; + total1 += received; + } else { + total1 -= spent; + total0 += received; + } + return (total0, total1); + } +} diff --git a/src/provider/libraries/V3PositionLib.sol b/src/provider/libraries/V3PositionLib.sol new file mode 100644 index 00000000..741794e6 --- /dev/null +++ b/src/provider/libraries/V3PositionLib.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { INonfungiblePositionManager } from "../interfaces/INonfungiblePositionManager.sol"; + +/** + * @title V3PositionLib + * @author Lista DAO + * @notice External library holding the (user-invisible) NonfungiblePositionManager interaction + * primitives — mint / increaseLiquidity / decreaseLiquidity / collect / burn — that + * {V3Provider} repeats across deposit, withdraw, compound and rebalance. + * + * These functions are `external`, so the library is deployed once and linked into the + * provider; the heavy NPM struct-encoding bytecode lives here instead of being duplicated + * in every provider call site, which keeps the provider implementation under EIP-170. + * + * @dev The library is invoked via DELEGATECALL, so inside every function `address(this)` is the + * provider: token custody, allowances and the collect `recipient` all resolve to the + * provider, exactly as the inline code did. The provider's immutables (NPM address, token + * addresses, fee) are not readable from library code, so they are passed in as arguments. + * `recipient` is always `address(this)` and `deadline` always `block.timestamp`, matching + * the previous inline behaviour. + */ +library V3PositionLib { + using SafeERC20 for IERC20; + + /// @dev Approve `npm` for both tokens and mint a fresh position to the provider. + function mint( + INonfungiblePositionManager npm, + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min + ) external returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) { + IERC20(token0).safeIncreaseAllowance(address(npm), amount0Desired); + IERC20(token1).safeIncreaseAllowance(address(npm), amount1Desired); + return + npm.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 + }) + ); + } + + /// @dev Approve `npm` for both tokens and add liquidity to an existing position. + function increaseLiquidity( + INonfungiblePositionManager npm, + address token0, + address token1, + uint256 tokenId, + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min + ) external returns (uint128 liquidity, uint256 amount0, uint256 amount1) { + IERC20(token0).safeIncreaseAllowance(address(npm), amount0Desired); + IERC20(token1).safeIncreaseAllowance(address(npm), amount1Desired); + return + npm.increaseLiquidity( + INonfungiblePositionManager.IncreaseLiquidityParams({ + tokenId: tokenId, + amount0Desired: amount0Desired, + amount1Desired: amount1Desired, + amount0Min: amount0Min, + amount1Min: amount1Min, + deadline: block.timestamp + }) + ); + } + + /// @dev Remove `liquidity` from the position (tokens are accounted to tokensOwed; collect separately). + function decreaseLiquidity( + INonfungiblePositionManager npm, + uint256 tokenId, + uint128 liquidity, + uint256 amount0Min, + uint256 amount1Min + ) external { + npm.decreaseLiquidity( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: tokenId, + liquidity: liquidity, + amount0Min: amount0Min, + amount1Min: amount1Min, + deadline: block.timestamp + }) + ); + } + + /// @dev Collect all owed tokens (fees + decreased liquidity) to the provider. + function collectAll( + INonfungiblePositionManager npm, + uint256 tokenId + ) external returns (uint256 amount0, uint256 amount1) { + return + npm.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + } + + /// @dev Burn an empty position NFT. + function burn(INonfungiblePositionManager npm, uint256 tokenId) external { + npm.burn(tokenId); + } +} diff --git a/src/provider/v3/SlisBNBV3DexAdapter.sol b/src/provider/v3/SlisBNBV3DexAdapter.sol new file mode 100644 index 00000000..32389388 --- /dev/null +++ b/src/provider/v3/SlisBNBV3DexAdapter.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { V3DexAdapter } from "./V3DexAdapter.sol"; +import { IStakeManager } from "../interfaces/IStakeManager.sol"; +import { ISlisBNBV3DexAdapter } from "../interfaces/ISlisBNBV3DexAdapter.sol"; + +/** + * @title SlisBNBV3DexAdapter + * @author Lista DAO + * @notice slisBNB/BNB specialization of {V3DexAdapter}. The base carries the rate-implied fair price, + * ±1% rate-centered tick range, the rebalance skeleton and the DEX-agnostic, backend-built swap + * conversion + swap-pair whitelist (shared with the wstETH/wbETH families). This subclass supplies + * only the slisBNB-specific hook: + * - _lstNativeRate(): StakeManager slisBNB↔BNB rate (not pool spot/TWAP). + * The rebalance inventory conversion is a backend-built swap against a whitelisted venue — the + * StakeManager instant-redeem is just one possible such venue and is no longer special-cased on + * chain. `receive()` is inherited: it accepts native BNB only from the WBNB unwrap. + */ +contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { + /* ─────────────────────────── constants ──────────────────────────── */ + + address public constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; + IStakeManager public constant STAKE_MANAGER = IStakeManager(0x1adB950d8bB3dA4bE104211D5AB038628e477fE6); + + /// @dev BSC wrapped native token (forwarded to the base as WRAPPED_NATIVE). + address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + + /* ───────────────────────────── errors ───────────────────────────── */ + + error NotSlisBnbWbnbPair(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address _positionManager, + address _token0, + address _token1, + uint24 _fee, + uint32 _twapPeriod + ) V3DexAdapter(_positionManager, _token0, _token1, _fee, _twapPeriod, WBNB) { + // slisBNB/BNB-ONLY: the rate-implied fair price and ±1% tick centering assume token0 == slisBNB and + // token1 == WBNB. The base already enforces token0 < token1, and slisBNB < WBNB, so this is the only + // valid ordering — reject anything else. + if (!(_token0 == SLISBNB && _token1 == WBNB)) revert NotSlisBnbWbnbPair(); + } + + /** + * @param _admin Default admin (upgrade / roles). + * @param _manager Manager role (sets centerRateThresholdBps + the swap-pair whitelist). + */ + function initialize(address _admin, address _manager) external initializer { + uint256 initialCenterRate = _lstNativeRate(); + (int24 initialTickLower, int24 initialTickUpper) = _initialTickRange(initialCenterRate); + __V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper); + lastCenterRate = initialCenterRate; + centerRateThresholdBps = INITIAL_RANGE_BPS; + } + + /* ───────────────────────── hook overrides ───────────────────────── */ + + /// @dev slisBNB↔BNB rate from the StakeManager (1e18). 0 for any non-slisBNB/WBNB pair → base TWAP. + function _lstNativeRate() internal view override returns (uint256) { + return _isSlisBnbWbnbPool() ? _poolPriceRate() : 0; + } + + /* ─────────────────────────── internals ──────────────────────────── */ + + function _isSlisBnbWbnbPool() internal view returns (bool) { + return (TOKEN0 == SLISBNB && TOKEN1 == WBNB) || (TOKEN0 == WBNB && TOKEN1 == SLISBNB); + } + + function _poolPriceRate() internal view returns (uint256) { + return TOKEN0 == SLISBNB ? STAKE_MANAGER.convertSnBnbToBnb(1e18) : STAKE_MANAGER.convertBnbToSnBnb(1e18); + } +} diff --git a/src/provider/v3/SlisBNBV3Provider.sol b/src/provider/v3/SlisBNBV3Provider.sol new file mode 100644 index 00000000..e2318a64 --- /dev/null +++ b/src/provider/v3/SlisBNBV3Provider.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { IMoolah, Id } from "moolah/interfaces/IMoolah.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; + +import { V3Provider } from "./V3Provider.sol"; +import { IV3DexAdapter } from "../interfaces/IV3DexAdapter.sol"; +import { ISlisBNBV3DexAdapter } from "../interfaces/ISlisBNBV3DexAdapter.sol"; +import { ISlisBNBxMinter } from "../../utils/interfaces/ISlisBNBx.sol"; + +/** + * @title SlisBNBV3Provider + * @author Lista DAO + * @notice slisBNB/BNB vault: thin slisBNB specialization of {V3Provider}. The DEX / rate / rebalance + * logic lives in the SlisBNBV3DexAdapter; this vault adds slisBNBx reward mirroring and the + * BOT-gated rebalance entry that forwards to the adapter. + */ +contract SlisBNBV3Provider is V3Provider { + /// @dev Virtual address used by the resilient oracle to price native BNB. + address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /* ──────────────────────────── storage ───────────────────────────── */ + + mapping(address => mapping(Id => uint256)) public userMarketDeposit; + mapping(address => uint256) public userTotalDeposit; + address public slisBNBxMinter; + + /* ───────────────────────────── events ───────────────────────────── */ + + event SlisBNBxMinterChanged(address indexed minter); + + /* ───────────────────────────── errors ───────────────────────────── */ + + error LengthMismatch(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _moolah, address _adapter) V3Provider(_moolah, _adapter) {} + + function initialize( + address _admin, + address _manager, + address _bot, + address _resilientOracle, + address _accountingAsset, + string calldata _name, + string calldata _symbol + ) external initializer { + __V3Provider_init(_admin, _manager, _bot, _resilientOracle, _accountingAsset, _name, _symbol); + } + + /* ─────────────────────────── rebalance ──────────────────────────── */ + + /// @notice Recenter the managed position to the exchange-rate-derived range (adapter does the work). + /// BOT-gated here; the adapter's rebalance is onlyProvider. `swapData` is built by the BOT + /// backend and encodes (swapPair, sellToken0, amountIn, amountOutMin, innerSwapData) for the + /// inventory-conversion swap (empty ⇒ recenter only). + function rebalance( + uint256 minAmount0, + uint256 minAmount1, + uint256 minLiquidity, + uint256 deadline, + bytes calldata swapData + ) external onlyRole(BOT) nonReentrant { + ISlisBNBV3DexAdapter(ADAPTER).rebalance(minAmount0, minAmount1, minLiquidity, deadline, swapData); + } + + /* ─────────────────── slisBNBx: sync / view ──────────────────────── */ + + /// @notice User's deposited collateral value in BNB (18 decimals). ISlisBNBxModule callback. + /// Valued at the adapter's exchange-rate fair price (manipulation-resistant). + 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) = IV3DexAdapter(ADAPTER).positionAmountsAt( + IV3DexAdapter(ADAPTER).fairSqrtPriceX96() + ); + + 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 + + uint256 value0 = (user0 * price0 * 1e18) / (10 ** DECIMALS0); + uint256 value1 = (user1 * price1 * 1e18) / (10 ** DECIMALS1); + return (value0 + value1) / bnbPrice; + } + + function syncUserBalance(Id id, address account) external { + if (MOOLAH.idToMarketParams(id).collateralToken != address(this)) revert InvalidMarket(); + _syncPosition(id, account); + } + + function bulkSyncUserBalance(Id[] calldata ids, address[] calldata accounts) external { + if (ids.length != accounts.length) revert LengthMismatch(); + for (uint256 i = 0; i < accounts.length; i++) { + if (MOOLAH.idToMarketParams(ids[i]).collateralToken != address(this)) revert InvalidMarket(); + _syncPosition(ids[i], accounts[i]); + } + } + + /* ──────────────────── manager: slisBNBxMinter ───────────────────── */ + + function setSlisBNBxMinter(address _slisBNBxMinter) external onlyRole(MANAGER) { + slisBNBxMinter = _slisBNBxMinter; + emit SlisBNBxMinterChanged(_slisBNBxMinter); + } + + /* ────────────────────────── hook override ───────────────────────── */ + + function _afterCollateralChange(Id id, address account) internal override { + _syncPosition(id, account); + } + + 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); + } + } +} diff --git a/src/provider/v3/SlisBNBV3ProviderOracle.sol b/src/provider/v3/SlisBNBV3ProviderOracle.sol new file mode 100644 index 00000000..6756e10e --- /dev/null +++ b/src/provider/v3/SlisBNBV3ProviderOracle.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { V3ProviderOracle } from "./V3ProviderOracle.sol"; + +/** + * @title SlisBNBV3ProviderOracle + * @author Lista DAO + * @notice slisBNB/BNB specialization of {V3ProviderOracle}: identical pricing logic, with a + * constructor guard pinning the pair to slisBNB/WBNB. Retained as a distinct type so the + * audited slisBNB deployment and its tests stay byte-stable; can be collapsed into the + * generic V3ProviderOracle once the slisBNB audit PR has merged. + */ +contract SlisBNBV3ProviderOracle is V3ProviderOracle { + /// @dev slisBNB/BNB-only pair (token0 < token1; slisBNB < WBNB). + address public constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; + address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + + error NotSlisBnbWbnbPair(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address _adapter, + address _providerShare, + address _token0, + address _token1 + ) V3ProviderOracle(_adapter, _providerShare, _token0, _token1) { + // slisBNB/BNB-ONLY: reject any other pair. The base already verifies the pair matches the adapter. + if (!(_token0 == SLISBNB && _token1 == WBNB)) revert NotSlisBnbWbnbPair(); + } +} diff --git a/src/provider/v3/V3DexAdapter.sol b/src/provider/v3/V3DexAdapter.sol new file mode 100644 index 00000000..91bca6b5 --- /dev/null +++ b/src/provider/v3/V3DexAdapter.sol @@ -0,0 +1,684 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +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 "lista-dao-contracts/libraries/TickMath.sol"; +import { LiquidityAmounts } from "lista-dao-contracts/libraries/LiquidityAmounts.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { FullMath } from "lista-dao-contracts/oracle/libraries/FullMath.sol"; + +import { INonfungiblePositionManager } from "../interfaces/INonfungiblePositionManager.sol"; +import { V3PositionLib } from "../libraries/V3PositionLib.sol"; +import { SwapInventoryLib } from "../libraries/SwapInventoryLib.sol"; +import { IListaV3Factory } from "lista-v3/core/interfaces/IListaV3Factory.sol"; +import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; +import { IWBNB } from "../interfaces/IWBNB.sol"; +import { IV3DexAdapter } from "../interfaces/IV3DexAdapter.sol"; +import { IV3Provider } from "../interfaces/IV3Provider.sol"; +import { IV3PoolMinimal } from "../interfaces/IV3PoolMinimal.sol"; + +/** + * @title V3DexAdapter + * @author Lista DAO + * @notice Generic, abstract DEX-custodian for a single Uniswap V3 / PancakeSwap V3 concentrated + * liquidity NFT. Sole holder of the position (tokenId), the idle inventory and all NPM/pool + * interaction. The vault (V3Provider) drives it through `onlyProvider` writes; the vault and + * the oracle (SlisBNBV3ProviderOracle) read its raw-NAV/composition views via staticcall. + * + * Splitting NFT custody + DEX math out of the vault keeps each runtime under EIP-170 and + * isolates the position state from the share-accounting / pricing logic. + * + * The rebalance (rate-centered recenter) and the DEX-agnostic, backend-built inventory-conversion + * swap (+ swap-pair whitelist) live here and are shared by every rate-implied pair. + * + * Extension points (rate-implied subclasses override): + * - _lstNativeRate(): the LST↔native exchange rate — the range center and the fair-price anchor. + * - fairSqrtPriceX96(): the valuation price (rate-implied by default; wstETH/wbETH clamp the pool TWAP + * to the rate). receive() may also be overridden to widen accepted native senders. + */ +abstract contract V3DexAdapter is + UUPSUpgradeable, + AccessControlEnumerableUpgradeable, + ReentrancyGuardUpgradeable, + IV3DexAdapter +{ + using SafeERC20 for IERC20; + + /* ─────────────────────────── immutables ─────────────────────────── */ + + INonfungiblePositionManager public immutable POSITION_MANAGER; + address public immutable POOL; + address public immutable TOKEN0; + address public immutable TOKEN1; + uint24 public immutable FEE; + uint32 public immutable TWAP_PERIOD; + uint8 public immutable DECIMALS0; + uint8 public immutable DECIMALS1; + + /// @dev Wrapped-native token of the chain (WBNB on BSC, WETH on Ethereum). Native sent on deposit + /// is wrapped to this before forwarding; refunds/withdrawals unwrap it back to native. + address public immutable WRAPPED_NATIVE; + + bytes32 public constant MANAGER = keccak256("MANAGER"); + + uint256 internal constant BPS = 10_000; + /// @dev Half-width of the rate-centered range for rate-implied pairs (±1%). + uint256 internal constant INITIAL_RANGE_BPS = 100; + /// @dev Fallback half-range (ticks) around spot for non-rate (TWAP) pairs. + int24 internal constant FALLBACK_HALF_RANGE_TICKS = 500; + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev The vault (V3Provider) authorized to drive this adapter. Set once via setProvider. + address public provider; + + /// @dev tokenId of the V3 NFT held by this adapter; 0 means no position yet. + uint256 public tokenId; + + int24 public tickLower; + int24 public tickUpper; + + /// @dev Idle inventory from ratio mismatch during compound/rebalance. Tracked in storage (not + /// balanceOf) so donations cannot inflate the reported NAV. + uint256 public idleToken0; + uint256 public idleToken1; + + /// @dev Exchange rate at the last successful center/init; used as the range center. Rate-implied + /// pairs only (0 for pure-TWAP pairs). + uint256 public lastCenterRate; + + /// @dev Min relative exchange-rate drift from lastCenterRate before rebalance is allowed (BPS; 0 = off). + uint256 public centerRateThresholdBps; + + /// @dev Whitelisted swap venues the rebalance inventory conversion may call. The BOT backend builds the + /// swap calldata; the adapter only allows whitelisted targets (à la {Liquidator}'s pairWhitelist). + /// Chain/venue-agnostic: a DEX pool, an aggregator, or any router that converts TOKEN0<->TOKEN1. + mapping(address => bool) public swapPairWhitelist; + + /// @dev Reserved storage for future base variables (keep subclass storage stable on upgrade). + uint256[47] private __gap; + + /* ───────────────────────────── events ───────────────────────────── */ + + event ProviderSet(address indexed provider); + event Compounded(uint256 amount0, uint256 amount1, uint128 liquidityAdded); + event LiquidityAdded(uint128 liquidityAdded, uint256 amount0Used, uint256 amount1Used); + event LiquidityRemoved(uint256 shares, uint256 totalShares, uint256 amount0, uint256 amount1, address receiver); + event CenterRateThresholdChanged(uint256 centerRateThresholdBps); + event LastCenterRateUpdated(uint256 oldCenterRate, uint256 newCenterRate); + event Rebalanced(int24 oldTickLower, int24 oldTickUpper, int24 newTickLower, int24 newTickUpper, uint256 newTokenId); + event SwapPairWhitelistSet(address indexed swapPair, bool status); + + /* ───────────────────────────── errors ───────────────────────────── */ + + error ZeroAddress(); + error TokenOrderInvalid(); + error ZeroFee(); + error ZeroTwapPeriod(); + error PoolDoesNotExist(); + error InvalidTickRange(); + error OnlyProvider(); + error ProviderAlreadySet(); + error ProviderAdapterMismatch(); + error BnbTransferFailed(); + error NotWrappedNative(); + error DeadlineExpired(); + error InsufficientLiquidityMinted(); + error RateDeviationBelowThreshold(); + error InvalidThreshold(); + error NotWhitelistedPair(); + error InvalidSwapPair(); + + /* ─────────────────────────── constructor ────────────────────────── */ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address _positionManager, + address _token0, + address _token1, + uint24 _fee, + uint32 _twapPeriod, + address _wrappedNative + ) { + if (_positionManager == address(0)) revert ZeroAddress(); + if (_token0 == address(0) || _token1 == address(0)) revert ZeroAddress(); + if (_wrappedNative == address(0)) revert ZeroAddress(); + if (_token0 >= _token1) revert TokenOrderInvalid(); + if (_fee == 0) revert ZeroFee(); + if (_twapPeriod == 0) revert ZeroTwapPeriod(); + + address _pool = IListaV3Factory(INonfungiblePositionManager(_positionManager).factory()).getPool( + _token0, + _token1, + _fee + ); + if (_pool == address(0)) revert PoolDoesNotExist(); + + POSITION_MANAGER = INonfungiblePositionManager(_positionManager); + TOKEN0 = _token0; + TOKEN1 = _token1; + FEE = _fee; + POOL = _pool; + TWAP_PERIOD = _twapPeriod; + WRAPPED_NATIVE = _wrappedNative; + DECIMALS0 = IERC20Metadata(_token0).decimals(); + DECIMALS1 = IERC20Metadata(_token1).decimals(); + + _disableInitializers(); + } + + /* ─────────────────────────── initializer ────────────────────────── */ + + function __V3DexAdapter_init( + address _admin, + address _manager, + int24 _tickLower, + int24 _tickUpper + ) internal onlyInitializing { + if (_admin == address(0) || _manager == address(0)) revert ZeroAddress(); + if (_tickLower >= _tickUpper) revert InvalidTickRange(); + + __AccessControl_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(MANAGER, _manager); + + tickLower = _tickLower; + tickUpper = _tickUpper; + } + + /// @notice Wire the vault that may drive this adapter. One-time, admin-only. + /// @dev Cross-validates the wiring: the vault's immutable ADAPTER (set in its constructor) must point + /// back to THIS adapter. Guards against a silent mis-wire — especially across same-pair adapters — + /// that would permanently brick the adapter (setProvider is one-time) or misprice collateral. + function setProvider(address _provider) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_provider == address(0)) revert ZeroAddress(); + if (provider != address(0)) revert ProviderAlreadySet(); + if (IV3Provider(_provider).ADAPTER() != address(this)) revert ProviderAdapterMismatch(); + provider = _provider; + emit ProviderSet(_provider); + } + + modifier onlyProvider() { + if (msg.sender != provider) revert OnlyProvider(); + _; + } + + /* ─────────────────────── writes (onlyProvider) ──────────────────── */ + + /// @inheritdoc IV3DexAdapter + function addLiquidity( + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min, + address refundTo + ) external onlyProvider nonReentrant returns (uint128 liquidityAdded, uint256 amount0Used, uint256 amount1Used) { + if (tokenId == 0) { + (tokenId, liquidityAdded, amount0Used, amount1Used) = V3PositionLib.mint( + POSITION_MANAGER, + TOKEN0, + TOKEN1, + FEE, + tickLower, + tickUpper, + amount0Desired, + amount1Desired, + amount0Min, + amount1Min + ); + } else { + (liquidityAdded, amount0Used, amount1Used) = V3PositionLib.increaseLiquidity( + POSITION_MANAGER, + TOKEN0, + TOKEN1, + tokenId, + amount0Desired, + amount1Desired, + amount0Min, + amount1Min + ); + } + + // Refund unused input (ratio mismatch) to the depositor. The wrapped-native token is unwrapped to native coin. + uint256 refund0 = amount0Desired - amount0Used; + uint256 refund1 = amount1Desired - amount1Used; + if (refund0 > 0) _sendToken(TOKEN0, refund0, payable(refundTo)); + if (refund1 > 0) _sendToken(TOKEN1, refund1, payable(refundTo)); + + emit LiquidityAdded(liquidityAdded, amount0Used, amount1Used); + } + + /// @inheritdoc IV3DexAdapter + function removeLiquidity( + uint256 shares, + uint256 totalShares, + uint256 minAmount0, + uint256 minAmount1, + address receiver + ) external onlyProvider nonReentrant returns (uint256 amount0, uint256 amount1) { + uint128 totalLiq = _getPositionLiquidity(); + uint128 liquidityToRemove = totalShares == 0 ? 0 : uint128((uint256(totalLiq) * shares) / totalShares); + + if (liquidityToRemove > 0) { + V3PositionLib.decreaseLiquidity(POSITION_MANAGER, tokenId, liquidityToRemove, minAmount0, minAmount1); + (amount0, amount1) = V3PositionLib.collectAll(POSITION_MANAGER, tokenId); + } + + // Pro-rata idle inventory (finding C): redeem the same fraction of idle as of liquidity. + if (totalShares > 0) { + uint256 idleOut0 = (idleToken0 * shares) / totalShares; + uint256 idleOut1 = (idleToken1 * shares) / totalShares; + if (idleOut0 > 0) { + idleToken0 -= idleOut0; + amount0 += idleOut0; + } + if (idleOut1 > 0) { + idleToken1 -= idleOut1; + amount1 += idleOut1; + } + } + + if (amount0 > 0) _sendToken(TOKEN0, amount0, payable(receiver)); + if (amount1 > 0) _sendToken(TOKEN1, amount1, payable(receiver)); + + emit LiquidityRemoved(shares, totalShares, amount0, amount1, receiver); + } + + /// @inheritdoc IV3DexAdapter + function collectAndCompound() external onlyProvider nonReentrant { + _collectAndCompound(); + } + + /* ─────────────────────── manager / rebalance ────────────────────── */ + + /// @inheritdoc IV3DexAdapter + function setCenterRateThresholdBps(uint256 _centerRateThresholdBps) external onlyRole(MANAGER) { + if (_centerRateThresholdBps > BPS) revert InvalidThreshold(); + centerRateThresholdBps = _centerRateThresholdBps; + emit CenterRateThresholdChanged(_centerRateThresholdBps); + } + + /// @notice Whitelist (or remove) a swap venue the rebalance inventory conversion may call. Backend-built + /// calldata can only target whitelisted venues. + /// @dev Defense-in-depth: a swap venue must never be a token / pool / NPM the adapter holds or trusts, + /// else crafted swapData could move the adapter's own inventory (e.g. TOKEN0.transfer) or position. + function setSwapPairWhitelist(address swapPair, bool status) external onlyRole(MANAGER) { + if (swapPair == address(0)) revert ZeroAddress(); + if ( + status && (swapPair == TOKEN0 || swapPair == TOKEN1 || swapPair == POOL || swapPair == address(POSITION_MANAGER)) + ) revert InvalidSwapPair(); + swapPairWhitelist[swapPair] = status; + emit SwapPairWhitelistSet(swapPair, status); + } + + /// @inheritdoc IV3DexAdapter + function rebalance( + uint256 minAmount0, + uint256 minAmount1, + uint256 minLiquidity, + uint256 deadline, + bytes calldata swapData + ) external onlyProvider nonReentrant { + if (block.timestamp > deadline) revert DeadlineExpired(); + + // Rate-implied pairs recenter around the LST↔native rate; pure-TWAP pairs (rate == 0) recenter + // around the spot tick and skip the rate-drift guard / inventory conversion. + uint256 centerRate = _lstNativeRate(); + bool rateImplied = centerRate != 0; + if (rateImplied) _requireCenterRateDeviation(centerRate); + + (int24 newTickLower, int24 newTickUpper) = _initialTickRange(centerRate); + int24 oldTickLower = tickLower; + int24 oldTickUpper = tickUpper; + + uint256 total0; + uint256 total1; + if (tokenId != 0) { + (total0, total1) = V3PositionLib.collectAll(POSITION_MANAGER, tokenId); + } + total0 += idleToken0; + total1 += idleToken1; + idleToken0 = 0; + idleToken1 = 0; + + if (tokenId != 0) { + uint128 liquidity = _getPositionLiquidity(); + if (liquidity > 0) { + V3PositionLib.decreaseLiquidity(POSITION_MANAGER, tokenId, liquidity, minAmount0, minAmount1); + } + (uint256 removed0, uint256 removed1) = V3PositionLib.collectAll(POSITION_MANAGER, tokenId); + total0 += removed0; + total1 += removed1; + V3PositionLib.burn(POSITION_MANAGER, tokenId); + tokenId = 0; + } + + (total0, total1) = _convertToOptimalRatio(total0, total1, newTickLower, newTickUpper, centerRate, swapData); + + tickLower = newTickLower; + tickUpper = newTickUpper; + + uint128 mintedLiquidity; + if (total0 > 0 || total1 > 0) { + (uint256 newTokenId, uint128 liquidity, uint256 used0, uint256 used1) = V3PositionLib.mint( + POSITION_MANAGER, + TOKEN0, + TOKEN1, + FEE, + newTickLower, + newTickUpper, + total0, + total1, + 0, + 0 + ); + tokenId = newTokenId; + mintedLiquidity = liquidity; + idleToken0 = total0 - used0; + idleToken1 = total1 - used1; + } else { + idleToken0 = total0; + idleToken1 = total1; + } + + if (uint256(mintedLiquidity) < minLiquidity) revert InsufficientLiquidityMinted(); + + if (rateImplied) { + uint256 oldCenterRate = lastCenterRate; + lastCenterRate = centerRate; + emit LastCenterRateUpdated(oldCenterRate, centerRate); + } + + emit Rebalanced(oldTickLower, oldTickUpper, newTickLower, newTickUpper, tokenId); + } + + /* ───────────────────────── views (staticcall) ───────────────────── */ + + /// @inheritdoc IV3DexAdapter + function positionAmountsAt(uint160 sqrtPriceX96) public view returns (uint256 total0, uint256 total1) { + if (tokenId == 0) return (idleToken0, idleToken1); + + (, , , , , , , uint128 liquidity, , , uint128 tokensOwed0, uint128 tokensOwed1) = POSITION_MANAGER.positions( + tokenId + ); + + (uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + liquidity + ); + + total0 = amount0 + uint256(tokensOwed0) + idleToken0; + total1 = amount1 + uint256(tokensOwed1) + idleToken1; + } + + /// @inheritdoc IV3DexAdapter + function amountsForLiquidity( + uint128 liquidity, + uint160 sqrtPriceX96 + ) external view returns (uint256 amount0, uint256 amount1) { + return + LiquidityAmounts.getAmountsForLiquidity( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + liquidity + ); + } + + /// @inheritdoc IV3DexAdapter + function totalLiquidity() external view returns (uint128) { + return _getPositionLiquidity(); + } + + /// @inheritdoc IV3DexAdapter + /// @dev Rate-implied (manipulation-resistant) when the subclass supplies a non-zero LST↔native + /// rate via _lstNativeRate(); otherwise falls back to the pool TWAP. + function fairSqrtPriceX96() public view virtual returns (uint160) { + uint256 rate = _lstNativeRate(); + if (rate == 0) return TickMath.getSqrtRatioAtTick(_twapTick()); + return _sqrtPriceX96FromRate(rate); + } + + /// @inheritdoc IV3DexAdapter + function spotSqrtPriceX96() public view returns (uint160 sqrtPriceX96) { + // Decode only sqrtPriceX96/tick (width-agnostic to feeProtocol uint8/uint32; see IV3PoolMinimal). + (sqrtPriceX96, ) = IV3PoolMinimal(POOL).slot0(); + } + + /// @inheritdoc IV3DexAdapter + function previewAddLiquidity( + uint256 amount0Desired, + uint256 amount1Desired + ) external view returns (uint128 liquidity, uint256 amount0, uint256 amount1) { + uint160 sqrtPriceX96 = spotSqrtPriceX96(); + uint160 sqrtLower = TickMath.getSqrtRatioAtTick(tickLower); + uint160 sqrtUpper = TickMath.getSqrtRatioAtTick(tickUpper); + liquidity = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + sqrtLower, + sqrtUpper, + amount0Desired, + amount1Desired + ); + (amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtLower, sqrtUpper, liquidity); + } + + /// @inheritdoc IV3DexAdapter + function previewRemoveLiquidity( + uint256 shares, + uint256 totalShares + ) external view returns (uint256 amount0, uint256 amount1) { + if (totalShares == 0 || shares == 0) return (0, 0); + uint128 liquidityToRemove = uint128((uint256(_getPositionLiquidity()) * shares) / totalShares); + (amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity( + spotSqrtPriceX96(), + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + liquidityToRemove + ); + amount0 += (idleToken0 * shares) / totalShares; + amount1 += (idleToken1 * shares) / totalShares; + } + + /// @notice TWAP tick over TWAP_PERIOD seconds. + function getTwapTick() external view returns (int24) { + return _twapTick(); + } + + /* ─────────────────────────── internals ──────────────────────────── */ + + function _collectAndCompound() internal { + if (tokenId == 0) return; + + (uint256 fees0, uint256 fees1) = V3PositionLib.collectAll(POSITION_MANAGER, tokenId); + + uint256 toCompound0 = fees0 + idleToken0; + uint256 toCompound1 = fees1 + idleToken1; + if (toCompound0 == 0 && toCompound1 == 0) return; + + (uint128 liquidityAdded, uint256 used0, uint256 used1) = V3PositionLib.increaseLiquidity( + POSITION_MANAGER, + TOKEN0, + TOKEN1, + tokenId, + toCompound0, + toCompound1, + 0, + 0 + ); + + idleToken0 = toCompound0 - used0; + idleToken1 = toCompound1 - used1; + + emit Compounded(toCompound0, toCompound1, liquidityAdded); + } + + function _getPositionLiquidity() internal view returns (uint128 liquidity) { + if (tokenId == 0) return 0; + (, , , , , , , liquidity, , , , ) = POSITION_MANAGER.positions(tokenId); + } + + function _twapTick() internal view returns (int24 twapTick) { + uint32[] memory secondsAgos = new uint32[](2); + secondsAgos[0] = TWAP_PERIOD; + secondsAgos[1] = 0; + (int56[] memory tickCumulatives, ) = IListaV3Pool(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--; + } + + /// @dev Send `token` to `to`, unwrapping the wrapped-native token to native coin. + function _sendToken(address token, uint256 amount, address payable to) internal { + if (token == WRAPPED_NATIVE) { + IWBNB(WRAPPED_NATIVE).withdraw(amount); + (bool ok, ) = to.call{ value: amount }(""); + if (!ok) revert BnbTransferFailed(); + } else { + IERC20(token).safeTransfer(to, amount); + } + } + + /// @dev Accepts native coin from the wrapped-native unwrap, or from a whitelisted swap venue that + /// settles the wrapped-native leg as the native coin (e.g. a StakeManager instant-redeem). The + /// rebalance swap wraps that native back into the wrapped-native token (see {SwapInventoryLib}). + receive() external payable virtual { + if (msg.sender != WRAPPED_NATIVE && !swapPairWhitelist[msg.sender]) revert NotWrappedNative(); + } + + /* ─────────────────── rate-centering math (shared) ────────────────── */ + + /// @dev Tick range for the position. Rate-implied (centerRate != 0): ±INITIAL_RANGE_BPS around the + /// rate-derived price. Pure-TWAP (centerRate == 0): ±FALLBACK_HALF_RANGE_TICKS around spot. + function _initialTickRange( + uint256 centerRate + ) internal view returns (int24 initialTickLower, int24 initialTickUpper) { + int24 tickSpacing = IListaV3Pool(POOL).tickSpacing(); + + if (centerRate != 0) { + (initialTickLower, initialTickUpper) = _tickRangeForRate(centerRate, tickSpacing); + } else { + (, int24 currentTick) = IV3PoolMinimal(POOL).slot0(); + initialTickLower = _floorTick(currentTick - FALLBACK_HALF_RANGE_TICKS, tickSpacing); + initialTickUpper = _ceilTick(currentTick + FALLBACK_HALF_RANGE_TICKS, tickSpacing); + } + + if (initialTickLower >= initialTickUpper) { + initialTickUpper = initialTickLower + tickSpacing; + } + } + + function _tickRangeForRate( + uint256 centerRate, + int24 tickSpacing + ) internal view returns (int24 initialTickLower, int24 initialTickUpper) { + uint256 lowerRate = (centerRate * (BPS - INITIAL_RANGE_BPS)) / BPS; + uint256 upperRate = (centerRate * (BPS + INITIAL_RANGE_BPS)) / BPS; + initialTickLower = _floorTick(_tickAtSqrtRatio(_sqrtPriceX96FromRate(lowerRate)), tickSpacing); + initialTickUpper = _ceilTick(_tickAtSqrtRatio(_sqrtPriceX96FromRate(upperRate)), tickSpacing); + } + + function _requireCenterRateDeviation(uint256 centerRate) internal view { + uint256 thresholdBps = centerRateThresholdBps; + uint256 previousCenterRate = lastCenterRate; + if (thresholdBps == 0 || previousCenterRate == 0) return; + uint256 delta = centerRate > previousCenterRate ? centerRate - previousCenterRate : previousCenterRate - centerRate; + if ((delta * BPS) / previousCenterRate < thresholdBps) revert RateDeviationBelowThreshold(); + } + + /// @dev Convert an exchange `rate` — token1-per-token0 scaled by 1e18 (1e18 ⇒ 1 WHOLE token0 is worth + /// 1 WHOLE token1; subclasses return it in these human/whole-token terms) — into the pool + /// sqrtPriceX96, which encodes the RAW (smallest-unit) price token1/token0. The two tokens may have + /// different decimals (the wrapped-native is 18; the paired token need not be), so adjust by the + /// decimal difference: token1_raw/token0_raw = (rate / 1e18) · 10^(DECIMALS1 − DECIMALS0). + function _sqrtPriceX96FromRate(uint256 rate) internal view returns (uint160) { + uint256 priceX192; // (token1_raw / token0_raw) · 2^192 + if (DECIMALS1 >= DECIMALS0) { + priceX192 = FullMath.mulDiv(rate * (uint256(10) ** (DECIMALS1 - DECIMALS0)), 1 << 192, 1e18); + } else { + priceX192 = FullMath.mulDiv(rate, 1 << 192, 1e18 * (uint256(10) ** (DECIMALS0 - DECIMALS1))); + } + return uint160(Math.sqrt(priceX192)); + } + + function _tickAtSqrtRatio(uint160 sqrtPriceX96) internal pure returns (int24) { + int24 low = TickMath.MIN_TICK; + int24 high = TickMath.MAX_TICK; + while (low < high) { + int24 mid = int24((int256(low) + int256(high) + 1) / 2); + if (TickMath.getSqrtRatioAtTick(mid) <= sqrtPriceX96) { + low = mid; + } else { + high = mid - 1; + } + } + return low; + } + + function _floorTick(int24 tick, int24 tickSpacing) internal pure returns (int24) { + int24 compressed = tick / tickSpacing; + if (tick < 0 && tick % tickSpacing != 0) compressed--; + return compressed * tickSpacing; + } + + function _ceilTick(int24 tick, int24 tickSpacing) internal pure returns (int24) { + int24 compressed = tick / tickSpacing; + if (tick > 0 && tick % tickSpacing != 0) compressed++; + return compressed * tickSpacing; + } + + /* ────────────────────────── extension hooks ─────────────────────── */ + + /// @dev LST↔native exchange rate (native per LST, 1e18). 0 ⇒ no rate (pure-TWAP pair): the base + /// uses pool TWAP for the fair price and a spot-centered range. Rate-implied subclasses + /// (slisBNB via StakeManager, wstETH via stEthPerToken) override this. + function _lstNativeRate() internal view virtual returns (uint256) { + return 0; + } + + /// @dev DEX-agnostic, backend-built rebalance inventory conversion, shared by all rate-implied pairs + /// (slisBNB/WBNB, wstETH/WETH, wbETH/WETH). `swapData` (when non-empty) ABI-encodes + /// (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bool nativeIn, + /// bytes innerSwapData): the adapter requires `swapPair` whitelisted and forwards `innerSwapData` + /// via a low-level call, bounding the swap by the backend's `amountOutMin` (see {SwapInventoryLib}). + /// `nativeIn` ⇒ a native-input venue (the wrapped-native leg is unwrapped + sent as msg.value, e.g. + /// StakeManager.deposit). Empty swapData ⇒ recenter without converting (also the TWAP-pair default). + function _convertToOptimalRatio( + uint256 total0, + uint256 total1, + int24 /* targetTickLower */, + int24 /* targetTickUpper */, + uint256 /* rate */, + bytes calldata swapData + ) internal virtual returns (uint256, uint256) { + if (swapData.length == 0) return (total0, total1); + (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bool nativeIn, bytes memory inner) = abi + .decode(swapData, (address, bool, uint256, uint256, bool, bytes)); + if (!swapPairWhitelist[swapPair]) revert NotWhitelistedPair(); + return + SwapInventoryLib.swap( + swapPair, + TOKEN0, + TOKEN1, + sellToken0, + amountIn, + amountOutMin, + inner, + total0, + total1, + WRAPPED_NATIVE, + nativeIn + ); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/src/provider/v3/V3Provider.sol b/src/provider/v3/V3Provider.sol new file mode 100644 index 00000000..6a916ea2 --- /dev/null +++ b/src/provider/v3/V3Provider.sol @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.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 { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; + +import { IWBNB } from "../interfaces/IWBNB.sol"; +import { IV3Provider } from "../interfaces/IV3Provider.sol"; +import { IV3DexAdapter } from "../interfaces/IV3DexAdapter.sol"; + +/** + * @title V3Provider + * @author Lista DAO + * @notice Generic, abstract VAULT for a V3 LP collateral position. Issues ERC-4626 vLP shares that + * are the Moolah collateral token, wires deposit/withdraw to Moolah, and delegates ALL DEX + * interaction (NFT custody, NPM/pool math, rebalance) to a V3DexAdapter. Share pricing for + * Moolah lives in a separate SlisBNBV3ProviderOracle (this contract is NOT an IOracle). + * + * Architecture (3-contract split): + * - V3Provider (this) : ERC-4626 shares + Moolah wiring + share accounting. Holds no NFT. + * - V3DexAdapter : sole NFT custodian + all NPM/pool writes + raw-NAV/composition views. + * - SlisBNBV3ProviderOracle : Moolah `market.oracle`; prices the share off the adapter's fair view. + * + * Token flow: + * - deposit: user → vault (pull/wrap) → adapter (transfer) → addLiquidity (adapter refunds unused to user) + * - withdraw: adapter.removeLiquidity sends underlying directly to receiver (no double hop) + * + * Extension point: + * - _afterCollateralChange(id, account): hook after deposit / withdraw / liquidation. + */ +abstract contract V3Provider is + ERC4626Upgradeable, + UUPSUpgradeable, + AccessControlEnumerableUpgradeable, + ReentrancyGuardUpgradeable, + IV3Provider +{ + using SafeERC20 for IERC20; + using MarketParamsLib for MarketParams; + + /* ─────────────────────────── immutables ─────────────────────────── */ + + /// @dev Moolah lending core. + IMoolah public immutable MOOLAH; + + /// @dev DEX adapter that custodies the V3 NFT and performs all pool interaction. + address public immutable ADAPTER; + + /// @dev Pool tokens (mirrored from the adapter for deposit/pricing). token0 < token1. + address public immutable TOKEN0; + address public immutable TOKEN1; + uint8 public immutable DECIMALS0; + uint8 public immutable DECIMALS1; + + /// @dev Wrapped-native token (WBNB on BSC, WETH on Ethereum), mirrored from the adapter. Native sent + /// on deposit is wrapped to this before forwarding. + address public immutable WRAPPED_NATIVE; + + bytes32 public constant MANAGER = keccak256("MANAGER"); + bytes32 public constant BOT = keccak256("BOT"); + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev Resilient oracle pricing TOKEN0/TOKEN1 (8-decimal USD), used for totalAssets(). + address public resilientOracle; + + /// @dev Decimal precision of the ERC-4626 accounting asset. + uint8 public accountingAssetDecimals; + + /// @dev Reserved storage for future base variables (keep subclass storage stable on upgrade). + uint256[50] private __gap; + + /* ───────────────────────────── events ───────────────────────────── */ + + 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 SharesWithdrawn(address indexed onBehalf, uint256 shares, address receiver, Id indexed marketId); + event SharesSupplied(address indexed supplier, address indexed onBehalf, uint256 shares, Id indexed marketId); + event SharesRedeemed(address indexed redeemer, uint256 shares, uint256 amount0, uint256 amount1, address receiver); + + /* ───────────────────────────── errors ───────────────────────────── */ + + error ZeroAddress(); + error InvalidCollateralToken(); + error PoolHasNoWrappedNative(); + error ZeroAmounts(); + error ZeroShares(); + error Unauthorized(); + error InsufficientShares(); + error OnlyMoolah(); + error InvalidMarket(); + error StandardEntryDisabled(); + error BnbTransferFailed(); + error NotAdapter(); + + /* ─────────────────────────── constructor ────────────────────────── */ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _moolah, address _adapter) { + if (_moolah == address(0) || _adapter == address(0)) revert ZeroAddress(); + MOOLAH = IMoolah(_moolah); + ADAPTER = _adapter; + TOKEN0 = IV3DexAdapter(_adapter).TOKEN0(); + TOKEN1 = IV3DexAdapter(_adapter).TOKEN1(); + DECIMALS0 = IV3DexAdapter(_adapter).DECIMALS0(); + DECIMALS1 = IV3DexAdapter(_adapter).DECIMALS1(); + WRAPPED_NATIVE = IV3DexAdapter(_adapter).WRAPPED_NATIVE(); + _disableInitializers(); + } + + /* ─────────────────────────── initializer ────────────────────────── */ + + function __V3Provider_init( + address _admin, + address _manager, + address _bot, + address _resilientOracle, + address _accountingAsset, + string calldata _name, + string calldata _symbol + ) internal onlyInitializing { + if ( + _admin == address(0) || + _manager == address(0) || + _bot == address(0) || + _resilientOracle == address(0) || + _accountingAsset == address(0) + ) { + revert ZeroAddress(); + } + + __ERC20_init(_name, _symbol); + __ERC4626_init(IERC20(_accountingAsset)); + __AccessControl_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(MANAGER, _manager); + _setRoleAdmin(BOT, MANAGER); + _grantRole(BOT, _bot); + + resilientOracle = _resilientOracle; + accountingAssetDecimals = IERC20Metadata(_accountingAsset).decimals(); + } + + /* ──────────────────── ERC20 transfer restrictions ───────────────── */ + + /// @dev Only Moolah may transfer shares (prevents orphaning the position by moving collateral out). + function transfer(address to, uint256 value) public override(ERC20Upgradeable, IERC20) returns (bool) { + if (msg.sender != address(MOOLAH)) revert OnlyMoolah(); + _transfer(msg.sender, to, value); + return true; + } + + function transferFrom( + address from, + address to, + uint256 value + ) public override(ERC20Upgradeable, IERC20) returns (bool) { + if (msg.sender != address(MOOLAH)) revert OnlyMoolah(); + _transfer(from, to, value); + return true; + } + + /* ─────────────────────── core user functions ────────────────────── */ + + /// @inheritdoc IV3Provider + 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) { + if (marketParams.collateralToken != address(this)) revert InvalidCollateralToken(); + if (onBehalf == address(0)) revert ZeroAddress(); + + uint256 _amount0Desired = amount0Desired; + uint256 _amount1Desired = amount1Desired; + + // Wrap any native coin into the wrapped-native token and use it for that leg. + if (msg.value > 0) { + if (!(TOKEN0 == WRAPPED_NATIVE || TOKEN1 == WRAPPED_NATIVE)) revert PoolHasNoWrappedNative(); + if (TOKEN0 == WRAPPED_NATIVE) { + _amount0Desired = msg.value; + } else { + _amount1Desired = msg.value; + } + IWBNB(WRAPPED_NATIVE).deposit{ value: msg.value }(); + } + + if (_amount0Desired == 0 && _amount1Desired == 0) revert ZeroAmounts(); + + // Pull ERC-20 input (skip the side funded by msg.value, already wrapped here). + if (_amount0Desired > 0 && !(TOKEN0 == WRAPPED_NATIVE && msg.value > 0)) { + IERC20(TOKEN0).safeTransferFrom(msg.sender, address(this), _amount0Desired); + } + if (_amount1Desired > 0 && !(TOKEN1 == WRAPPED_NATIVE && msg.value > 0)) { + IERC20(TOKEN1).safeTransferFrom(msg.sender, address(this), _amount1Desired); + } + + // Compound accrued fees first so existing holders capture them before new shares dilute. + IV3DexAdapter(ADAPTER).collectAndCompound(); + + uint256 totalValueBefore; + uint256 supplyBefore = totalSupply(); + uint160 fairSqrtPriceX96 = IV3DexAdapter(ADAPTER).fairSqrtPriceX96(); + if (supplyBefore > 0) { + (uint256 total0Before, uint256 total1Before) = IV3DexAdapter(ADAPTER).positionAmountsAt(fairSqrtPriceX96); + totalValueBefore = _amountsValueUsd(total0Before, total1Before); + if (totalValueBefore == 0) revert ZeroShares(); + } + + // Forward the input to the adapter, which adds liquidity and refunds unused back to THIS vault. + // The refund is deliberately NOT sent to the depositor here: doing so (a native-BNB call) before + // shares are minted would expose a window where adapter NAV already includes the new liquidity + // but totalSupply() is still the old value, letting a malicious depositor reenter and read an + // inflated share price from the oracle (C-1). We forward the refund to the depositor only after + // _mint + supplyCollateral below, when NAV and totalSupply are consistent. + if (_amount0Desired > 0) IERC20(TOKEN0).safeTransfer(ADAPTER, _amount0Desired); + if (_amount1Desired > 0) IERC20(TOKEN1).safeTransfer(ADAPTER, _amount1Desired); + + uint128 liquidityAdded; + (liquidityAdded, amount0Used, amount1Used) = IV3DexAdapter(ADAPTER).addLiquidity( + _amount0Desired, + _amount1Desired, + amount0Min, + amount1Min, + address(this) + ); + + (uint256 added0, uint256 added1) = IV3DexAdapter(ADAPTER).amountsForLiquidity(liquidityAdded, fairSqrtPriceX96); + uint256 addedValue = _amountsValueUsd(added0, added1); + if (supplyBefore == 0) { + uint256 assetPrice = IOracle(resilientOracle).peek(asset()); // 8 decimals + if (assetPrice > 0) shares = (addedValue * (10 ** uint256(accountingAssetDecimals))) / assetPrice; + } else { + shares = (addedValue * supplyBefore) / totalValueBefore; + } + if (shares == 0) revert ZeroShares(); + + _mint(address(this), shares); + _approve(address(this), address(MOOLAH), shares); + MOOLAH.supplyCollateral(marketParams, shares, onBehalf, ""); + + _afterCollateralChange(marketParams.id(), onBehalf); + + emit Deposit(onBehalf, amount0Used, amount1Used, shares, marketParams.id()); + + // Refund unused input to the depositor now that shares are minted and supplied (CEI): NAV and + // totalSupply are consistent, so the (native-BNB) refund callback cannot inflate the share price. + uint256 refund0 = _amount0Desired - amount0Used; + uint256 refund1 = _amount1Desired - amount1Used; + if (refund0 > 0) _refund(TOKEN0, refund0, msg.sender); + if (refund1 > 0) _refund(TOKEN1, refund1, msg.sender); + } + + /// @inheritdoc IV3Provider + function withdraw( + MarketParams calldata marketParams, + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address onBehalf, + address receiver + ) external nonReentrant returns (uint256 amount0, uint256 amount1) { + if (marketParams.collateralToken != address(this)) revert InvalidCollateralToken(); + if (shares == 0) revert ZeroShares(); + if (receiver == address(0)) revert ZeroAddress(); + if (!_isSenderAuthorized(onBehalf)) revert Unauthorized(); + + MOOLAH.withdrawCollateral(marketParams, shares, onBehalf, address(this)); + _afterCollateralChange(marketParams.id(), onBehalf); + + IV3DexAdapter(ADAPTER).collectAndCompound(); + + // CEI: burn before the adapter removes liquidity and pushes underlying to `receiver`, so + // totalSupply stays consistent with the reduced position during the (native-BNB) callback — + // otherwise the oracle would briefly under-price the share. Pass the pre-burn supply so the + // liquidity-fraction math (shares/supply) is unchanged. + uint256 supply = totalSupply(); + _burn(address(this), shares); + (amount0, amount1) = IV3DexAdapter(ADAPTER).removeLiquidity(shares, supply, minAmount0, minAmount1, receiver); + + emit Withdraw(onBehalf, shares, amount0, amount1, receiver, marketParams.id()); + } + + /// @inheritdoc IV3Provider + function withdrawShares( + MarketParams calldata marketParams, + uint256 shares, + address onBehalf, + address receiver + ) external nonReentrant { + if (marketParams.collateralToken != address(this)) revert InvalidCollateralToken(); + if (shares == 0) revert ZeroShares(); + if (receiver == address(0)) revert ZeroAddress(); + if (!_isSenderAuthorized(onBehalf)) revert Unauthorized(); + + MOOLAH.withdrawCollateral(marketParams, shares, onBehalf, address(this)); + _afterCollateralChange(marketParams.id(), onBehalf); + + _transfer(address(this), receiver, shares); + emit SharesWithdrawn(onBehalf, shares, receiver, marketParams.id()); + } + + /// @inheritdoc IV3Provider + function supplyShares(MarketParams calldata marketParams, uint256 shares, address onBehalf) external nonReentrant { + if (marketParams.collateralToken != address(this)) revert InvalidCollateralToken(); + if (shares == 0) revert ZeroShares(); + if (onBehalf == address(0)) revert ZeroAddress(); + if (balanceOf(msg.sender) < shares) revert InsufficientShares(); + + _transfer(msg.sender, address(this), shares); + _approve(address(this), address(MOOLAH), shares); + MOOLAH.supplyCollateral(marketParams, shares, onBehalf, ""); + + _afterCollateralChange(marketParams.id(), onBehalf); + emit SharesSupplied(msg.sender, onBehalf, shares, marketParams.id()); + } + + /// @inheritdoc IV3Provider + /// @dev Liquidation-critical path: no protocol value floor — caller's minAmount0/1 is the only + /// guard (a hard floor here would brick atomic liquidation; see finding C4). + function redeemShares( + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address receiver + ) external nonReentrant returns (uint256 amount0, uint256 amount1) { + if (shares == 0) revert ZeroShares(); + if (receiver == address(0)) revert ZeroAddress(); + if (balanceOf(msg.sender) < shares) revert InsufficientShares(); + + IV3DexAdapter(ADAPTER).collectAndCompound(); + + // CEI: burn the caller's shares before the adapter sends underlying to `receiver` (see withdraw). + uint256 supply = totalSupply(); + _burn(msg.sender, shares); + (amount0, amount1) = IV3DexAdapter(ADAPTER).removeLiquidity(shares, supply, minAmount0, minAmount1, receiver); + + emit SharesRedeemed(msg.sender, shares, amount0, amount1, receiver); + } + + /* ──────────────────── Moolah provider callback ──────────────────── */ + + function liquidate(Id id, address borrower) external { + if (msg.sender != address(MOOLAH)) revert OnlyMoolah(); + if (MOOLAH.idToMarketParams(id).collateralToken != address(this)) revert InvalidMarket(); + _afterCollateralChange(id, borrower); + } + + /* ───────────────────────── view functions ───────────────────────── */ + + /// @inheritdoc IV3Provider + function getTotalAmounts() public view returns (uint256 total0, uint256 total1) { + return IV3DexAdapter(ADAPTER).positionAmountsAt(IV3DexAdapter(ADAPTER).spotSqrtPriceX96()); + } + + /// @notice Simulate a redemption of `shares` at the current pool price (for tight minAmount0/1). + function previewRedeemUnderlying(uint256 shares) external view returns (uint256 amount0, uint256 amount1) { + return IV3DexAdapter(ADAPTER).previewRemoveLiquidity(shares, totalSupply()); + } + + /// @notice Simulate a deposit at the current pool price (for tight amount0Min/amount1Min). + function previewDepositAmounts( + uint256 amount0Desired, + uint256 amount1Desired + ) external view returns (uint128 liquidity, uint256 amount0, uint256 amount1) { + return IV3DexAdapter(ADAPTER).previewAddLiquidity(amount0Desired, amount1Desired); + } + + /// @notice IProvider hook — the "token" is this shares contract itself. + function TOKEN() external view returns (address) { + return address(this); + } + + /* ─────────────────────── ERC-4626 shell ─────────────────────────── */ + + /// @notice ERC-4626 total managed assets, in the accounting asset's units. + /// @dev Reads the adapter's FAIR composition (manipulation-resistant) priced via the resilient + /// oracle, divided by the accounting asset's USD price. + function totalAssets() public view override returns (uint256) { + uint256 assetPrice = IOracle(resilientOracle).peek(asset()); // 8 decimals + if (assetPrice == 0) return 0; + return (_positionValueUsd() * (10 ** uint256(accountingAssetDecimals))) / assetPrice; + } + + /// @dev Total position value in 8-decimal USD at the adapter's fair price (raw, no haircut). + function _positionValueUsd() internal view returns (uint256) { + (uint256 total0, uint256 total1) = IV3DexAdapter(ADAPTER).positionAmountsAt( + IV3DexAdapter(ADAPTER).fairSqrtPriceX96() + ); + return _amountsValueUsd(total0, total1); + } + + /// @dev Value token0/token1 amounts as 8-decimal USD through the resilient oracle. + function _amountsValueUsd(uint256 amount0, uint256 amount1) internal view returns (uint256) { + uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8 decimals + uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8 decimals + return (amount0 * price0) / (10 ** DECIMALS0) + (amount1 * price1) / (10 ** DECIMALS1); + } + + /// @dev Single-asset ERC-4626 entry is disabled — this is a two-token LP vault. Use the two-token + /// deposit(marketParams,…) / withdraw(marketParams,…) / redeemShares(). + function deposit(uint256, address) public pure override returns (uint256) { + revert StandardEntryDisabled(); + } + + function mint(uint256, address) public pure override returns (uint256) { + revert StandardEntryDisabled(); + } + + function withdraw(uint256, address, address) public pure override returns (uint256) { + revert StandardEntryDisabled(); + } + + function redeem(uint256, address, address) public pure override returns (uint256) { + revert StandardEntryDisabled(); + } + + /* ────────────────────────── extension hooks ─────────────────────── */ + + /// @dev Hook invoked after deposit / withdraw / liquidation with the affected (market, account). + function _afterCollateralChange(Id id, address account) internal virtual {} + + /* ─────────────────────────── internals ──────────────────────────── */ + + function _isSenderAuthorized(address onBehalf) internal view returns (bool) { + return msg.sender == onBehalf || MOOLAH.isAuthorized(onBehalf, msg.sender); + } + + /// @dev Forward a deposit refund to the depositor. The wrapped-native leg is already unwrapped to + /// native coin by the adapter when it refunds to this vault, so it is paid out as native coin. + function _refund(address token, uint256 amount, address to) internal { + if (token == WRAPPED_NATIVE) { + (bool ok, ) = payable(to).call{ value: amount }(""); + if (!ok) revert BnbTransferFailed(); + } else { + IERC20(token).safeTransfer(to, amount); + } + } + + /// @dev Accept native BNB only from the adapter (WBNB refund unwrapped during deposit). + receive() external payable { + if (msg.sender != ADAPTER) revert NotAdapter(); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/src/provider/v3/V3ProviderOracle.sol b/src/provider/v3/V3ProviderOracle.sol new file mode 100644 index 00000000..41fd4da9 --- /dev/null +++ b/src/provider/v3/V3ProviderOracle.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import { IOracle, TokenConfig } from "moolah/interfaces/IOracle.sol"; +import { IV3DexAdapter } from "../interfaces/IV3DexAdapter.sol"; +import { IV3Provider } from "../interfaces/IV3Provider.sol"; +import { IV3ProviderOracle } from "../interfaces/IV3ProviderOracle.sol"; + +/** + * @title V3ProviderOracle + * @author Lista DAO + * @notice Standalone IOracle for a V3 LP vLP share token (Moolah `market.oracle` points here). Prices + * the share off the DEX adapter's manipulation-resistant FAIR composition view (staticcall, no + * double-hop through the vault) — that fair price is exchange-rate-implied (slisBNB) or pool + * TWAP clamped to the rate (wstETH/wbETH), never raw pool spot — then values each leg via the + * resilient oracle and applies a conservative haircut. The resilient oracle prices the LST leg + * RATE-DERIVED (peek(LST) == peek(underlying) × exchangeRate / 1e18), so the leg valuation is + * consistent with the rate-anchored composition — see peek()'s AUDIT NOTE. Chain/pair-agnostic: + * the pair is taken from the constructor and validated against the adapter. + * + * @dev finding D — when supply > 0, peek(share) reverts on a zero leg price or zero total value so + * Moolah never prices collateral off a broken feed; supply == 0 returns 0 (pre-market). + */ +contract V3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgradeable, IV3ProviderOracle { + /* ─────────────────────────── immutables ─────────────────────────── */ + + /// @inheritdoc IV3ProviderOracle + address public immutable ADAPTER; + /// @inheritdoc IV3ProviderOracle + address public immutable PROVIDER_SHARE; + + address public immutable TOKEN0; + address public immutable TOKEN1; + uint8 public immutable DECIMALS0; + uint8 public immutable DECIMALS1; + + /// @dev Decimals of the priced share token (ERC4626 ⇒ == the accounting asset's decimals). Moolah reads + /// `collateralToken.decimals()` to interpret peek(), so the share price must be quoted per ONE WHOLE + /// share (10 ** SHARE_DECIMALS share-wei), not hardcoded to 1e18. + uint8 public immutable SHARE_DECIMALS; + + bytes32 public constant MANAGER = keccak256("MANAGER"); + uint256 internal constant BPS = 10_000; + /// @dev Hard cap on the configurable haircut (10%). + uint256 public constant MAX_HAIRCUT_BPS = 1_000; + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev Resilient oracle pricing TOKEN0/TOKEN1 (and any non-share token, delegated). + address public resilientOracle; + + /// @inheritdoc IV3ProviderOracle + uint256 public haircutBps; + + uint256[50] private __gap; + + /* ─────────────────────────── events/errors ──────────────────────── */ + + event HaircutChanged(uint256 haircutBps); + + error ZeroAddress(); + error InvalidHaircut(); + error ZeroPrice(); + error AdapterPairMismatch(); + error ShareAdapterMismatch(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _adapter, address _providerShare, address _token0, address _token1) { + if (_adapter == address(0) || _providerShare == address(0)) revert ZeroAddress(); + if (_token0 == address(0) || _token1 == address(0)) revert ZeroAddress(); + // The oracle's tokens (and their order) must match the adapter's, so peek() prices exactly the + // composition the adapter reports. + if (_token0 != IV3DexAdapter(_adapter).TOKEN0() || _token1 != IV3DexAdapter(_adapter).TOKEN1()) + revert AdapterPairMismatch(); + // Cross-validate the wiring: the priced share must be the vault bound to THIS adapter, so peek() + // reads composition from the same adapter that issued the share. Guards against a silent mis-wire. + if (IV3Provider(_providerShare).ADAPTER() != _adapter) revert ShareAdapterMismatch(); + ADAPTER = _adapter; + PROVIDER_SHARE = _providerShare; + TOKEN0 = _token0; + TOKEN1 = _token1; + DECIMALS0 = IERC20Metadata(_token0).decimals(); + DECIMALS1 = IERC20Metadata(_token1).decimals(); + SHARE_DECIMALS = IERC20Metadata(_providerShare).decimals(); + _disableInitializers(); + } + + function initialize( + address _admin, + address _manager, + address _resilientOracle, + uint256 _haircutBps + ) external initializer { + if (_admin == address(0) || _manager == address(0) || _resilientOracle == address(0)) revert ZeroAddress(); + if (_haircutBps > MAX_HAIRCUT_BPS) revert InvalidHaircut(); + + __AccessControl_init(); + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(MANAGER, _manager); + + resilientOracle = _resilientOracle; + haircutBps = _haircutBps; + } + + /* ─────────────────────── IOracle implementation ─────────────────── */ + + /// @inheritdoc IOracle + function peek(address token) external view returns (uint256) { + if (token != PROVIDER_SHARE) { + return IOracle(resilientOracle).peek(token); + } + + uint256 supply = IERC20(PROVIDER_SHARE).totalSupply(); + if (supply == 0) return 0; // pre-market + + // Fair composition from the adapter, taken at its manipulation-resistant fair price + // (exchange-rate-implied for slisBNB; pool TWAP clamped to the rate for wstETH/wbETH; never raw + // pool spot/slot0). + (uint256 total0, uint256 total1) = IV3DexAdapter(ADAPTER).positionAmountsAt( + IV3DexAdapter(ADAPTER).fairSqrtPriceX96() + ); + + // AUDIT NOTE — leg prices are RATE-CONSISTENT with the composition above, NOT an independent + // second market price. By deployment invariant, the resilient oracle prices the LST leg (TOKEN0 = + // slisBNB / wstETH / wbETH) rate-derived from the SAME on-chain exchange rate used for the + // composition: + // peek(LST) == peek(underlying WBNB/WETH) × exchangeRate / 1e18 + // (slisBNB → StakeManager.convertSnBnbToBnb; wstETH → stEthPerToken; wbETH → exchangeRate) + // i.e. NOT a secondary-market price. So there is no market-vs-rate divergence between the two and + // the stETH/ETH (or wbETH/ETH) depeg is excluded from the price by construction (carried by LLTV). + // Verified on-chain to the wei (e.g. peek(wstETH) == peek(WETH) × wstETH.stEthPerToken() / 1e18). + uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8 decimals (rate-derived for the LST leg) + uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8 decimals + if (price0 == 0 || price1 == 0) revert ZeroPrice(); // finding D + + uint256 totalValue = (total0 * price0) / (10 ** DECIMALS0) + (total1 * price1) / (10 ** DECIMALS1); + if (totalValue == 0) revert ZeroPrice(); // finding D + + // 8-decimal USD price per ONE WHOLE share (10 ** SHARE_DECIMALS share-wei) — Moolah interprets peek() + // using collateralToken.decimals() — minus the conservative haircut. + uint256 raw = (totalValue * (10 ** SHARE_DECIMALS)) / supply; + return (raw * (BPS - haircutBps)) / BPS; + } + + /// @inheritdoc IOracle + function getTokenConfig(address token) external view returns (TokenConfig memory) { + if (token != PROVIDER_SHARE) { + return IOracle(resilientOracle).getTokenConfig(token); + } + return + TokenConfig({ + asset: PROVIDER_SHARE, + oracles: [address(this), address(0), address(0)], + enableFlagsForOracles: [true, false, false], + timeDeltaTolerance: 0 + }); + } + + /* ──────────────────────────── manager ───────────────────────────── */ + + /// @inheritdoc IV3ProviderOracle + function setHaircutBps(uint256 _haircutBps) external onlyRole(MANAGER) { + if (_haircutBps > MAX_HAIRCUT_BPS) revert InvalidHaircut(); + haircutBps = _haircutBps; + emit HaircutChanged(_haircutBps); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/src/provider/v3/WbETHV3DexAdapter.sol b/src/provider/v3/WbETHV3DexAdapter.sol new file mode 100644 index 00000000..122d76f5 --- /dev/null +++ b/src/provider/v3/WbETHV3DexAdapter.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; + +import { V3DexAdapter } from "./V3DexAdapter.sol"; +import { IWbETH } from "../interfaces/IWbETH.sol"; + +/** + * @title WbETHV3DexAdapter + * @author Lista DAO + * @notice wbETH/WETH specialization of {V3DexAdapter} for Ethereum — mechanism identical to + * {WstETHV3DexAdapter}, only the rate source + pair differ. The base carries the rate-centered + * range, the rebalance skeleton and the DEX-agnostic, backend-built swap conversion + swap-pair + * whitelist. This subclass supplies only: + * - _lstNativeRate(): Binance `wbETH.exchangeRate()` (ETH per wbETH ⇒ WETH-per-wbETH); + * - fairSqrtPriceX96(): valuation price = pool TWAP CLAMPED to the rate. + * `receive()` is inherited: it accepts native ETH only from the WETH unwrap. + */ +contract WbETHV3DexAdapter is V3DexAdapter { + /* ─────────────────────────── constants ──────────────────────────── */ + + address public constant WBETH = 0xa2E3356610840701BDf5611a53974510Ae27E2e1; + address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + /// @dev Hard cap on the configurable TWAP-vs-rate valuation clamp band (10%). + uint256 public constant MAX_TWAP_DEVIATION_BPS = 1_000; + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev Max |TWAP − rate| band (BPS) for the valuation price: the LP composition is priced at the + /// pool TWAP, CLAMPED into [rate·(1−dev), rate·(1+dev)] so a manipulated TWAP cannot move the + /// valuation beyond this guardrail. Defaults to the ±range width. 0 ⇒ pure rate-implied. + uint256 public maxTwapDeviationBps; + + /* ─────────────────────────── events/errors ──────────────────────── */ + + event MaxTwapDeviationChanged(uint256 maxTwapDeviationBps); + + error NotWbEthWethPair(); + error InvalidDeviation(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address _positionManager, + address _token0, + address _token1, + uint24 _fee, + uint32 _twapPeriod + ) V3DexAdapter(_positionManager, _token0, _token1, _fee, _twapPeriod, WETH) { + // wbETH/WETH-ONLY: the rate-implied valuation and ±1% tick centering assume token0 == wbETH and + // token1 == WETH. The base enforces token0 < token1, and wbETH < WETH, so this is the only valid + // ordering — reject anything else. + if (!(_token0 == WBETH && _token1 == WETH)) revert NotWbEthWethPair(); + } + + /** + * @param _admin Default admin (upgrade / roles). + * @param _manager Manager role (sets the clamp band + swap-pair whitelist). + */ + function initialize(address _admin, address _manager) external initializer { + uint256 initialCenterRate = _lstNativeRate(); + (int24 initialTickLower, int24 initialTickUpper) = _initialTickRange(initialCenterRate); + __V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper); + + lastCenterRate = initialCenterRate; + centerRateThresholdBps = INITIAL_RANGE_BPS; + maxTwapDeviationBps = INITIAL_RANGE_BPS; // default valuation clamp band = ±range width (±1%) + emit MaxTwapDeviationChanged(INITIAL_RANGE_BPS); + } + + /* ───────────────────────── manager config ───────────────────────── */ + + /// @notice Set the TWAP-vs-rate clamp band (BPS) for the valuation price. 0 ⇒ pure rate-implied. + function setMaxTwapDeviationBps(uint256 _maxTwapDeviationBps) external onlyRole(MANAGER) { + if (_maxTwapDeviationBps > MAX_TWAP_DEVIATION_BPS) revert InvalidDeviation(); + maxTwapDeviationBps = _maxTwapDeviationBps; + emit MaxTwapDeviationChanged(_maxTwapDeviationBps); + } + + /* ───────────────────────── hook overrides ───────────────────────── */ + + /// @dev WETH-per-wbETH (1e18) from Binance's operator-reported exchange rate. Monotonic, not + /// market-driven — manipulation-resistant. + function _lstNativeRate() internal view override returns (uint256) { + return IWbETH(WBETH).exchangeRate(); + } + + /// @notice Valuation price for the LP composition: the pool TWAP, CLAMPED into + /// [rate·(1−dev), rate·(1+dev)] — see {WstETHV3DexAdapter}. dev == 0 ⇒ pure rate-implied. + function fairSqrtPriceX96() public view override returns (uint160) { + uint256 rate = _lstNativeRate(); + uint256 dev = maxTwapDeviationBps; + + if (dev == 0) return _sqrtPriceX96FromRate(rate); + + uint160 sqrtLow = _sqrtPriceX96FromRate((rate * (BPS - dev)) / BPS); + uint160 sqrtHigh = _sqrtPriceX96FromRate((rate * (BPS + dev)) / BPS); + uint160 twapSqrt = TickMath.getSqrtRatioAtTick(_twapTick()); + if (twapSqrt < sqrtLow) return sqrtLow; + if (twapSqrt > sqrtHigh) return sqrtHigh; + return twapSqrt; + } +} diff --git a/src/provider/v3/WbETHV3Provider.sol b/src/provider/v3/WbETHV3Provider.sol new file mode 100644 index 00000000..665d2569 --- /dev/null +++ b/src/provider/v3/WbETHV3Provider.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { V3Provider } from "./V3Provider.sol"; +import { IV3DexAdapter } from "../interfaces/IV3DexAdapter.sol"; + +/** + * @title WbETHV3Provider + * @author Lista DAO + * @notice wbETH/WETH V3 LP vault (Ethereum) — identical to {WstETHV3Provider}, paired with a + * {WbETHV3DexAdapter}. A lean {V3Provider}: no reward-token mirroring and no per-user deposit + * tracking — it inherits the base deposit / withdraw / redeem flow unchanged and only adds the + * BOT-gated rebalance forwarder. + */ +contract WbETHV3Provider is V3Provider { + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _moolah, address _adapter) V3Provider(_moolah, _adapter) {} + + function initialize( + address _admin, + address _manager, + address _bot, + address _resilientOracle, + address _accountingAsset, + string calldata _name, + string calldata _symbol + ) external initializer { + __V3Provider_init(_admin, _manager, _bot, _resilientOracle, _accountingAsset, _name, _symbol); + } + + /// @notice Recenter the position and convert inventory to the optimal ratio. BOT-gated; forwards to + /// the adapter (which is `onlyProvider`). `swapData` is built by the BOT backend and encodes + /// (swapPair, sellToken0, amountIn, amountOutMin, innerSwapData) for the rebalance swap. + function rebalance( + uint256 minAmount0, + uint256 minAmount1, + uint256 minLiquidity, + uint256 deadline, + bytes calldata swapData + ) external onlyRole(BOT) nonReentrant { + IV3DexAdapter(ADAPTER).rebalance(minAmount0, minAmount1, minLiquidity, deadline, swapData); + } +} diff --git a/src/provider/v3/WstETHV3DexAdapter.sol b/src/provider/v3/WstETHV3DexAdapter.sol new file mode 100644 index 00000000..7769ee9e --- /dev/null +++ b/src/provider/v3/WstETHV3DexAdapter.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; + +import { V3DexAdapter } from "./V3DexAdapter.sol"; +import { IWstETH } from "../interfaces/IWstETH.sol"; + +/** + * @title WstETHV3DexAdapter + * @author Lista DAO + * @notice wstETH/WETH specialization of {V3DexAdapter} for Ethereum. The base carries the rate-centered + * range, the rebalance skeleton and the DEX-agnostic, backend-built swap conversion + swap-pair + * whitelist (shared by all rate-implied pairs). This subclass supplies only: + * - _lstNativeRate(): Lido `wstETH.stEthPerToken()` (stETH≈ETH 1:1 ⇒ WETH-per-wstETH); + * - fairSqrtPriceX96(): valuation price = pool TWAP CLAMPED to the rate, so the oracle/vault + * price the LP composition at the (manipulation-bounded) market price. + * `receive()` is inherited: it accepts native ETH only from the WETH unwrap. + */ +contract WstETHV3DexAdapter is V3DexAdapter { + /* ─────────────────────────── constants ──────────────────────────── */ + + address public constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + /// @dev Hard cap on the configurable TWAP-vs-rate valuation clamp band (10%). + uint256 public constant MAX_TWAP_DEVIATION_BPS = 1_000; + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev Max |TWAP − rate| band (BPS) for the valuation price: the LP composition is priced at the + /// pool TWAP, CLAMPED into [rate·(1−dev), rate·(1+dev)] so a manipulated TWAP cannot move the + /// valuation beyond this guardrail. Defaults to the ±range width. 0 ⇒ pure rate-implied. + uint256 public maxTwapDeviationBps; + + /* ─────────────────────────── events/errors ──────────────────────── */ + + event MaxTwapDeviationChanged(uint256 maxTwapDeviationBps); + + error NotWstEthWethPair(); + error InvalidDeviation(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address _positionManager, + address _token0, + address _token1, + uint24 _fee, + uint32 _twapPeriod + ) V3DexAdapter(_positionManager, _token0, _token1, _fee, _twapPeriod, WETH) { + // wstETH/WETH-ONLY: the rate-implied valuation and ±1% tick centering assume token0 == wstETH and + // token1 == WETH. The base enforces token0 < token1, and wstETH < WETH, so this is the only valid + // ordering — reject anything else. + if (!(_token0 == WSTETH && _token1 == WETH)) revert NotWstEthWethPair(); + } + + /** + * @param _admin Default admin (upgrade / roles). + * @param _manager Manager role (sets the clamp band + swap-pair whitelist). + */ + function initialize(address _admin, address _manager) external initializer { + uint256 initialCenterRate = _lstNativeRate(); + (int24 initialTickLower, int24 initialTickUpper) = _initialTickRange(initialCenterRate); + __V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper); + + lastCenterRate = initialCenterRate; + centerRateThresholdBps = INITIAL_RANGE_BPS; + maxTwapDeviationBps = INITIAL_RANGE_BPS; // default valuation clamp band = ±range width (±1%) + emit MaxTwapDeviationChanged(INITIAL_RANGE_BPS); + } + + /* ───────────────────────── manager config ───────────────────────── */ + + /// @notice Set the TWAP-vs-rate clamp band (BPS) for the valuation price. 0 ⇒ pure rate-implied. + function setMaxTwapDeviationBps(uint256 _maxTwapDeviationBps) external onlyRole(MANAGER) { + if (_maxTwapDeviationBps > MAX_TWAP_DEVIATION_BPS) revert InvalidDeviation(); + maxTwapDeviationBps = _maxTwapDeviationBps; + emit MaxTwapDeviationChanged(_maxTwapDeviationBps); + } + + /* ───────────────────────── hook overrides ───────────────────────── */ + + /// @dev WETH-per-wstETH (1e18) from Lido's on-chain accounting (stETH≈ETH 1:1). Monotonic, not + /// market-driven — manipulation-resistant. + function _lstNativeRate() internal view override returns (uint256) { + return IWstETH(WSTETH).stEthPerToken(); + } + + /// @notice Valuation price for the LP composition: the pool TWAP, CLAMPED into + /// [rate·(1−dev), rate·(1+dev)]. The oracle and the vault both read this, so they price the + /// position at the same (manipulation-bounded) market price; the token split tracks real + /// in-range drift between rebalances while the rate clamp bounds any TWAP manipulation. + /// Components are valued at the resilient oracle's rate-derived prices (WETH=ETH, wstETH=rate); + /// the stETH/ETH depeg is intentionally NOT priced here (carried by a lower LLTV). dev == 0 ⇒ + /// pure rate-implied (no pool observe()/cardinality dependency). + function fairSqrtPriceX96() public view override returns (uint160) { + uint256 rate = _lstNativeRate(); + uint256 dev = maxTwapDeviationBps; + + // dev == 0 ⇒ pure rate-implied: skip the TWAP read entirely, so the valuation has no pool + // observe()/observation-cardinality dependency. + if (dev == 0) return _sqrtPriceX96FromRate(rate); + + uint160 sqrtLow = _sqrtPriceX96FromRate((rate * (BPS - dev)) / BPS); + uint160 sqrtHigh = _sqrtPriceX96FromRate((rate * (BPS + dev)) / BPS); + uint160 twapSqrt = TickMath.getSqrtRatioAtTick(_twapTick()); + if (twapSqrt < sqrtLow) return sqrtLow; + if (twapSqrt > sqrtHigh) return sqrtHigh; + return twapSqrt; + } +} diff --git a/src/provider/v3/WstETHV3Provider.sol b/src/provider/v3/WstETHV3Provider.sol new file mode 100644 index 00000000..ff62169a --- /dev/null +++ b/src/provider/v3/WstETHV3Provider.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { V3Provider } from "./V3Provider.sol"; +import { IV3DexAdapter } from "../interfaces/IV3DexAdapter.sol"; + +/** + * @title WstETHV3Provider + * @author Lista DAO + * @notice wstETH/WETH V3 LP vault (Ethereum). A lean {V3Provider}: no reward-token mirroring and no + * per-user deposit tracking (there is no slisBNBx analogue) — it inherits the base deposit / + * withdraw / redeem flow unchanged and only adds the BOT-gated rebalance forwarder. + */ +contract WstETHV3Provider is V3Provider { + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _moolah, address _adapter) V3Provider(_moolah, _adapter) {} + + function initialize( + address _admin, + address _manager, + address _bot, + address _resilientOracle, + address _accountingAsset, + string calldata _name, + string calldata _symbol + ) external initializer { + __V3Provider_init(_admin, _manager, _bot, _resilientOracle, _accountingAsset, _name, _symbol); + } + + /// @notice Recenter the position and convert inventory to the optimal ratio. BOT-gated; forwards to + /// the adapter (which is `onlyProvider`). `swapData` is built by the BOT backend and encodes + /// (swapPair, sellToken0, amountIn, amountOutMin, innerSwapData) for the rebalance swap. + function rebalance( + uint256 minAmount0, + uint256 minAmount1, + uint256 minLiquidity, + uint256 deadline, + bytes calldata swapData + ) external onlyRole(BOT) nonReentrant { + IV3DexAdapter(ADAPTER).rebalance(minAmount0, minAmount1, minLiquidity, deadline, swapData); + } +} diff --git a/test/liquidator/V3Liquidator.t.sol b/test/liquidator/V3Liquidator.t.sol new file mode 100644 index 00000000..61513ecb --- /dev/null +++ b/test/liquidator/V3Liquidator.t.sol @@ -0,0 +1,606 @@ +// 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 { SlisBNBV3Provider } from "../../src/provider/v3/SlisBNBV3Provider.sol"; +import { SlisBNBV3DexAdapter } from "../../src/provider/v3/SlisBNBV3DexAdapter.sol"; +import { SlisBNBV3ProviderOracle } from "../../src/provider/v3/SlisBNBV3ProviderOracle.sol"; +import { V3ProviderOracle } from "../../src/provider/v3/V3ProviderOracle.sol"; +import { V3Liquidator } from "../../src/liquidator/V3Liquidator.sol"; +import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.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, TokenConfig } from "moolah/interfaces/IOracle.sol"; +import { IStakeManager } from "../../src/provider/interfaces/IStakeManager.sol"; + +import { MockOneInch } from "./mocks/MockOneInch.sol"; + +/// @dev Minimal resilient-oracle mock: 8-decimal USD prices, settable per token. +contract MockOracle is IOracle { + mapping(address => uint256) public price; + + function setPrice(address token, uint256 value) external { + price[token] = value; + } + + function peek(address token) external view returns (uint256) { + return price[token]; + } + + function getTokenConfig(address) external pure returns (TokenConfig memory c) { + return c; + } +} + +contract V3LiquidatorTest is Test { + using MarketParamsLib for MarketParams; + + /* ─────────────────── PancakeSwap V3 BSC mainnet ─────────────────── */ + address constant POOL = 0xe1B404Aaf60eEc5c8A1FEDE7dcDC0EAb9C69662F; // SLISBNB/WBNB + address constant NPM = 0x46A15B0b27311cedF172AB29E4f4766fbE7F4364; + uint24 constant FEE = 100; + + /* ───────────────────────────── tokens ───────────────────────────── */ + address constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; // 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 STAKE_MANAGER = 0x1adB950d8bB3dA4bE104211D5AB038628e477fE6; + address constant IRM = 0xFe7dAe87Ebb11a7BEB9F534BB23267992d9cDe7c; + + uint32 constant TWAP_PERIOD = 1800; + uint256 constant LLTV = 70 * 1e16; + uint256 constant BNB_USD = 600e8; // 8-dec mock BNB/USD + + /* ───────────────────────── test contracts ───────────────────────── */ + Moolah moolah; + SlisBNBV3DexAdapter adapter; + SlisBNBV3Provider provider; + SlisBNBV3ProviderOracle providerOracle; + V3Liquidator liquidator; + MockOneInch mockSwap; + MockOracle oracle; + 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); + + // Resilient-oracle mock: WBNB = BNB price; slisBNB = BNB price × StakeManager rate; lisUSD ≈ $1. + oracle = new MockOracle(); + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + oracle.setPrice(WBNB, BNB_USD); + oracle.setPrice(BNB_ADDRESS, BNB_USD); + oracle.setPrice(SLISBNB, (BNB_USD * rate) / 1e18); + oracle.setPrice(LISUSD, 1e8); + + // Deploy the heavy 3-contract topology EARLY (adapter → provider → oracle) to avoid + // forge setUp gas-forwarding issues with large code deposits. + + // 1) DEX adapter: sole NFT custodian + all NPM/pool writes. + SlisBNBV3DexAdapter adapterImpl = new SlisBNBV3DexAdapter(NPM, SLISBNB, WBNB, FEE, TWAP_PERIOD); + adapter = SlisBNBV3DexAdapter( + payable(new ERC1967Proxy(address(adapterImpl), abi.encodeCall(SlisBNBV3DexAdapter.initialize, (admin, manager)))) + ); + + // 2) Provider / vault: ERC-4626 shares = Moolah collateral. accountingAsset = WBNB. + SlisBNBV3Provider implP = new SlisBNBV3Provider(MOOLAH_PROXY, address(adapter)); + provider = SlisBNBV3Provider( + payable( + new ERC1967Proxy( + address(implP), + abi.encodeCall( + SlisBNBV3Provider.initialize, + (admin, manager, bot, address(oracle), WBNB, "V3LP SLISBNB/WBNB", "v3LP") + ) + ) + ) + ); + + // 3) Wire the adapter to the vault (one-time, admin). + vm.prank(admin); + adapter.setProvider(address(provider)); + + // 4) Oracle: Moolah market.oracle; prices the share off the adapter's fair view. + SlisBNBV3ProviderOracle oracleImpl = new SlisBNBV3ProviderOracle( + address(adapter), + address(provider), + SLISBNB, + WBNB + ); + providerOracle = SlisBNBV3ProviderOracle( + payable( + new ERC1967Proxy( + address(oracleImpl), + abi.encodeCall(V3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + ) + ) + ); + + // 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 = providerOracle. + marketParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(providerOracle), + 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(SLISBNB, 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(SLISBNB, _user, amount0); + deal(WBNB, _user, amount1); + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); + vm.startPrank(_user); + IERC20(SLISBNB).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 = providerOracle.peek(address(provider)); + uint256 loanPrice = providerOracle.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(providerOracle), + 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), SLISBNB, LISUSD, 1, 0, ""); + } + + /* ─────────────────── liquidate (pre-funded) ─────────────────────── */ + + function test_liquidate_prefunded_receivesShares() public { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 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, 10 ether, 10 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, 10 ether, 10 ether); + uint256 borrowed = _borrowAgainstCollateral(user); + _makeUnhealthy(); + + // token0 (SLISBNB) 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, + SLISBNB, // tokenIn + LISUSD, // tokenOut + uint256(0), // amountIn (mock pulls nothing; residual SLISBNB stays in liquidator) + borrowed * 2 // amountOutMin — enough to cover repayment + ); + + // token1 (WBNB) swap: SlisBNBV3Provider 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, 10 ether, 10 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.previewRedeemUnderlying(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(SLISBNB).balanceOf(address(liquidator)), out0, "SLISBNB 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(SLISBNB, address(liquidator), amountIn); + + bytes memory swapData = abi.encodeWithSelector(mockSwap.swap.selector, SLISBNB, LISUSD, amountIn, amountOut); + + vm.prank(bot); + liquidator.sellToken(address(mockSwap), SLISBNB, LISUSD, amountIn, amountOut, swapData); + + assertEq(IERC20(LISUSD).balanceOf(address(liquidator)), amountOut, "received lisUSD"); + assertEq(IERC20(SLISBNB).balanceOf(address(liquidator)), 0, "SLISBNB consumed"); + assertEq(IERC20(SLISBNB).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(SLISBNB, address(liquidator), 1 ether); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.sellToken(fakePair, SLISBNB, LISUSD, 1 ether, 0, ""); + } + + function test_sellToken_revertsIfAmountExceedsBalance() public { + deal(SLISBNB, address(liquidator), 50 ether); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.ExceedAmount.selector); + liquidator.sellToken(address(mockSwap), SLISBNB, 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/liquidator/V3LiquidatorEth.t.sol b/test/liquidator/V3LiquidatorEth.t.sol new file mode 100644 index 00000000..bc38589d --- /dev/null +++ b/test/liquidator/V3LiquidatorEth.t.sol @@ -0,0 +1,405 @@ +// 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 { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; + +import { WstETHV3Provider } from "../../src/provider/v3/WstETHV3Provider.sol"; +import { WstETHV3DexAdapter } from "../../src/provider/v3/WstETHV3DexAdapter.sol"; +import { V3ProviderOracle } from "../../src/provider/v3/V3ProviderOracle.sol"; +import { IWstETH } from "../../src/provider/interfaces/IWstETH.sol"; +import { V3Liquidator } from "../../src/liquidator/V3Liquidator.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, TokenConfig } from "moolah/interfaces/IOracle.sol"; + +import { MockOneInch } from "./mocks/MockOneInch.sol"; + +/// @dev Minimal resilient-oracle mock: 8-decimal USD prices, settable per token. +contract MockOracle is IOracle { + mapping(address => uint256) public price; + + function setPrice(address token, uint256 value) external { + price[token] = value; + } + + function peek(address token) external view returns (uint256) { + return price[token]; + } + + function getTokenConfig(address) external pure returns (TokenConfig memory c) { + return c; + } +} + +/// @dev A whitelisted "swap venue" that always reverts — used to exercise the SwapFailed() path. +contract RevertingPair { + fallback() external payable { + revert("nope"); + } +} + +/// @notice Ethereum fork tests for V3Liquidator against a wstETH/WETH V3 LP market. The wrapped-native +/// leg on Ethereum is WETH (not BSC's WBNB); the provider unwraps it to native ETH on redeem, so +/// the liquidator must sell that leg via call{value}. This suite is the regression coverage for +/// H2: a chain-hardcoded WBNB check would route the WETH leg down the ERC-20 path and revert. +contract V3LiquidatorEthTest is Test { + using MarketParamsLib for MarketParams; + using SafeERC20 for IERC20; + + /* live Uniswap V3 wstETH/WETH 0.01% pool */ + address constant POOL = 0x109830a1AAaD605BbF02a9dFA7B0B92EC2FB7dAa; + address constant NPM = 0xC36442b4a4522E871399CD717aBDD847Ab11FE88; + uint24 constant FEE = 100; + + address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; // token0 + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // token1 = wrapped-native + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // loan token (6 decimals) + address constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; // native sentinel (MockOneInch) + + address constant MOOLAH_PROXY = 0xf820fB4680712CD7263a0D3D024D5b5aEA82Fd70; + address constant MOOLAH_ADMIN = 0xa18ae79AEDA3e711E0CD64cfe1Cd06402d400D61; // DEFAULT_ADMIN timelock + address constant IRM = 0x8b7d334d243b74D63C4b963893267A0F5240F990; + + bytes32 constant OPERATOR = keccak256("OPERATOR"); + bytes32 constant MOOLAH_MANAGER = keccak256("MANAGER"); + + uint32 constant TWAP_PERIOD = 1800; + uint256 constant LLTV = 86 * 1e16; + uint256 constant ETH_USD = 3000e8; // 8-dec mock ETH/USD + + Moolah moolah; + WstETHV3DexAdapter adapter; + WstETHV3Provider provider; + V3ProviderOracle providerOracle; + V3Liquidator liquidator; + MockOneInch mockSwap; + MockOracle oracle; + MarketParams marketParams; + Id marketId; + + address admin = makeAddr("admin"); + address manager = makeAddr("manager"); + address bot = makeAddr("bot"); + address user = makeAddr("user"); + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC"), 23566432); + + address newMoolahImpl = address(new Moolah()); + vm.prank(MOOLAH_ADMIN); + UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newMoolahImpl, bytes("")); + moolah = Moolah(MOOLAH_PROXY); + + // Resilient-oracle mock: WETH = ETH; wstETH = ETH × stEthPerToken (rate-derived); USDC = $1. + oracle = new MockOracle(); + uint256 rate = IWstETH(WSTETH).stEthPerToken(); + oracle.setPrice(WETH, ETH_USD); + oracle.setPrice(WSTETH, (ETH_USD * rate) / 1e18); + oracle.setPrice(USDC, 1e8); + oracle.setPrice(BNB_ADDRESS, ETH_USD); + + // 1) DEX adapter. + WstETHV3DexAdapter adapterImpl = new WstETHV3DexAdapter(NPM, WSTETH, WETH, FEE, TWAP_PERIOD); + adapter = WstETHV3DexAdapter( + payable(new ERC1967Proxy(address(adapterImpl), abi.encodeCall(WstETHV3DexAdapter.initialize, (admin, manager)))) + ); + + // 2) Vault. accountingAsset = WETH. + WstETHV3Provider provImpl = new WstETHV3Provider(MOOLAH_PROXY, address(adapter)); + provider = WstETHV3Provider( + payable( + new ERC1967Proxy( + address(provImpl), + abi.encodeCall( + WstETHV3Provider.initialize, + (admin, manager, bot, address(oracle), WETH, "wstETH/WETH vLP", "vLP-wstETH-WETH") + ) + ) + ) + ); + + vm.prank(admin); + adapter.setProvider(address(provider)); + + // 3) Share oracle (Moolah market.oracle). + V3ProviderOracle oracleImpl = new V3ProviderOracle(address(adapter), address(provider), WSTETH, WETH); + providerOracle = V3ProviderOracle( + payable( + new ERC1967Proxy( + address(oracleImpl), + abi.encodeCall(V3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + ) + ) + ); + + // 4) V3Liquidator. + V3Liquidator implL = new V3Liquidator(MOOLAH_PROXY); + liquidator = V3Liquidator( + payable(new ERC1967Proxy(address(implL), abi.encodeCall(V3Liquidator.initialize, (admin, manager, bot)))) + ); + + mockSwap = new MockOneInch(); + + // Grant ourselves OPERATOR (createMarket) + MANAGER (setProvider) on the forked Moolah. + vm.startPrank(MOOLAH_ADMIN); + IAccessControl(MOOLAH_PROXY).grantRole(OPERATOR, address(this)); + IAccessControl(MOOLAH_PROXY).grantRole(MOOLAH_MANAGER, address(this)); + vm.stopPrank(); + + marketParams = MarketParams({ + loanToken: USDC, + collateralToken: address(provider), + oracle: address(providerOracle), + irm: IRM, + lltv: LLTV + }); + marketId = marketParams.id(); + + moolah.createMarket(marketParams); + moolah.setProvider(marketId, address(provider), true); + + // Seed USDC liquidity so the borrow can draw from the market. + deal(USDC, address(this), 10_000_000e6); + IERC20(USDC).forceApprove(MOOLAH_PROXY, 10_000_000e6); + moolah.supply(marketParams, 10_000_000e6, 0, address(this), ""); + + // Liquidator whitelists. + vm.startPrank(manager); + liquidator.setTokenWhitelist(WSTETH, true); + liquidator.setTokenWhitelist(WETH, true); + liquidator.setTokenWhitelist(USDC, true); + liquidator.setTokenWhitelist(BNB_ADDRESS, true); + liquidator.setMarketWhitelist(Id.unwrap(marketId), true); + liquidator.setPairWhitelist(address(mockSwap), true); + liquidator.setV3ProviderWhitelist(address(provider), true); + vm.stopPrank(); + } + + /* ──────────────────────── helpers ───────────────────────────────── */ + + function _deposit(uint256 amtWst, uint256 amtWeth) internal returns (uint256 shares) { + return _depositTo(marketParams, amtWst, amtWeth); + } + + function _depositTo(MarketParams memory mp, uint256 amtWst, uint256 amtWeth) internal returns (uint256 shares) { + deal(WSTETH, user, amtWst); + deal(WETH, user, amtWeth); + (, uint256 e0, uint256 e1) = provider.previewDepositAmounts(amtWst, amtWeth); + vm.startPrank(user); + IERC20(WSTETH).approve(address(provider), amtWst); + IERC20(WETH).approve(address(provider), amtWeth); + (shares, , ) = provider.deposit(mp, amtWst, amtWeth, (e0 * 99) / 100, (e1 * 99) / 100, user); + vm.stopPrank(); + } + + function _collateral(address _user) internal view returns (uint256) { + return _collateralIn(marketId, _user); + } + + function _collateralIn(Id id, address _user) internal view returns (uint256) { + (, , uint256 col) = moolah.position(id, _user); + return col; + } + + /// @dev Borrow 60% of the user's collateral value in USDC (6-dec loan token). The BSC liquidator test + /// sizes an 18-dec loan the same way; the extra /1e12 rescales to USDC's 6 decimals. + function _borrowAgainstCollateral(address _user) internal returns (uint256 borrowed) { + (, , uint128 col) = moolah.position(marketId, _user); + uint256 sharePrice = providerOracle.peek(address(provider)); // 8-dec USD / 1e18 shares + uint256 loanPrice = providerOracle.peek(USDC); // 1e8 + borrowed = (uint256(col) * sharePrice * 60) / (loanPrice * 100 * 1e12); + vm.prank(_user); + moolah.borrow(marketParams, borrowed, 0, _user, _user); + } + + /// @dev Drop the collateral oracle to zero, making any indebted position liquidatable. + function _makeUnhealthy() internal { + vm.mockCall( + address(providerOracle), + abi.encodeWithSelector(IOracle.peek.selector, address(provider)), + abi.encode(uint256(0)) + ); + } + + /* ──────────────────────── tests ─────────────────────────────────── */ + + /// @notice On Ethereum the WETH leg is unwrapped to native ETH on redeem (token1 == WRAPPED_NATIVE), + /// so redeemV3Shares pays it as native ETH — the wstETH leg arrives as an ERC-20. + function test_redeemV3Shares_wethLegPaidAsNativeEth() public { + uint256 shares = _deposit(10 ether, 10 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + + // Seize the shares into the liquidator via a pre-funded liquidation. + deal(USDC, address(liquidator), 1_000_000e6); + vm.prank(bot); + liquidator.liquidate(Id.unwrap(marketId), user, shares, 0); + + uint256 held = provider.balanceOf(address(liquidator)); + assertGt(held, 0, "setup: liquidator holds seized shares"); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(held); + uint256 wstBefore = IERC20(WSTETH).balanceOf(address(liquidator)); + uint256 ethBefore = address(liquidator).balance; + + vm.prank(bot); + (uint256 out0, uint256 out1) = liquidator.redeemV3Shares( + address(provider), + held, + (exp0 * 99) / 100, + (exp1 * 99) / 100, + address(liquidator) + ); + + assertEq(provider.balanceOf(address(liquidator)), 0, "shares burned"); + assertEq(IERC20(WSTETH).balanceOf(address(liquidator)) - wstBefore, out0, "wstETH leg paid as ERC-20"); + assertEq(address(liquidator).balance - ethBefore, out1, "WETH leg paid as native ETH"); + assertGt(out1, 0, "native ETH leg non-zero"); + } + + /// @notice H2 regression: flashLiquidate redeems shares and sells the WETH leg. Because the provider + /// hands that leg over as native ETH, the liquidator must sell it via call{value}. The mock + /// venue REQUIRES msg.value (native `amountIn` > 0), so the buggy ERC-20 path (which would + /// approve WETH and call with no value) reverts, while the WRAPPED_NATIVE()-driven path passes. + function test_flashLiquidate_sellsNativeWethLeg() public { + uint256 shares = _deposit(10 ether, 10 ether); + uint256 borrowed = _borrowAgainstCollateral(user); + _makeUnhealthy(); + + // Expected native WETH-leg amount; require the venue to be paid this as msg.value. + (, uint256 exp1) = provider.previewRedeemUnderlying(shares); + uint256 nativeAmountIn = (exp1 * 99) / 100; + + // WETH leg (native): MockOneInch requires msg.value >= nativeAmountIn, then mints borrowed*2 USDC. + bytes memory swap1Data = abi.encodeWithSelector( + mockSwap.swap.selector, + BNB_ADDRESS, // native-coin path + USDC, + nativeAmountIn, + borrowed * 2 // produce enough USDC to cover repayment + ); + + // token0 (wstETH) leg: no swap — left in the liquidator as residue. + V3Liquidator.FlashLiquidateParams memory params = V3Liquidator.FlashLiquidateParams({ + v3Provider: address(provider), + minToken0Amt: 0, + minToken1Amt: 0, + redeemShares: true, + token0Pair: address(0), + token0Spender: address(0), + token1Pair: address(mockSwap), + token1Spender: address(0), + swapToken0Data: "", + swapToken1Data: swap1Data + }); + + vm.prank(bot); + liquidator.flashLiquidate(Id.unwrap(marketId), user, shares, params); + + assertEq(_collateral(user), 0, "borrower collateral seized"); + assertEq(provider.balanceOf(address(liquidator)), 0, "shares redeemed in callback"); + assertGt(IERC20(USDC).balanceOf(address(liquidator)), 0, "USDC produced by native WETH-leg swap"); + assertGt(IERC20(WSTETH).balanceOf(address(liquidator)), 0, "wstETH residue retained (leg not swapped)"); + } + + /// @notice When the loan token IS the wrapped-native (a market that borrows WETH against the LP), the + /// redeemed WETH leg comes back as native ETH and its swap is skipped (loanToken→loanToken). + /// The liquidator must wrap that native ETH back to WETH so the ERC-20 repayment works — else + /// the WETH-leg value is stranded as native and the loanToken balance check / Moolah pull fail. + function test_flashLiquidate_loanTokenIsWrappedNative_wrapsNativeLeg() public { + // A WETH-loan market against the same wstETH/WETH LP collateral. + MarketParams memory wethMarket = MarketParams({ + loanToken: WETH, + collateralToken: address(provider), + oracle: address(providerOracle), + irm: IRM, + lltv: LLTV + }); + Id wethId = wethMarket.id(); + moolah.createMarket(wethMarket); + moolah.setProvider(wethId, address(provider), true); + + deal(WETH, address(this), 1_000 ether); + IERC20(WETH).approve(MOOLAH_PROXY, 1_000 ether); + moolah.supply(wethMarket, 1_000 ether, 0, address(this), ""); + + vm.prank(manager); + liquidator.setMarketWhitelist(Id.unwrap(wethId), true); + + uint256 shares = _depositTo(wethMarket, 10 ether, 10 ether); + { + (, , uint128 col) = moolah.position(wethId, user); + uint256 sharePrice = providerOracle.peek(address(provider)); + uint256 loanPrice = providerOracle.peek(WETH); // 8-dec USD; WETH is 18-dec + uint256 borrowed = (uint256(col) * sharePrice * 60) / (loanPrice * 100); + vm.prank(user); + moolah.borrow(wethMarket, borrowed, 0, user, user); + } + _makeUnhealthy(); + + (, uint256 exp1) = provider.previewRedeemUnderlying(shares); + + // No swaps: token0 (wstETH) is held as residue; token1 (WETH) returns native and must be wrapped. + V3Liquidator.FlashLiquidateParams memory params = V3Liquidator.FlashLiquidateParams({ + v3Provider: address(provider), + minToken0Amt: 0, + minToken1Amt: 0, + redeemShares: true, + token0Pair: address(0), + token0Spender: address(0), + token1Pair: address(0), + token1Spender: address(0), + swapToken0Data: "", + swapToken1Data: "" + }); + + uint256 wethBefore = IERC20(WETH).balanceOf(address(liquidator)); + vm.prank(bot); + liquidator.flashLiquidate(Id.unwrap(wethId), user, shares, params); + + assertEq(_collateralIn(wethId, user), 0, "collateral seized"); + assertEq(provider.balanceOf(address(liquidator)), 0, "shares redeemed"); + assertEq(address(liquidator).balance, 0, "native WETH leg fully wrapped, not stranded"); + assertGe( + IERC20(WETH).balanceOf(address(liquidator)) - wethBefore, + (exp1 * 99) / 100, + "WETH leg wrapped back to ERC-20 for repayment" + ); + } + + /// @notice The native-leg swap path propagates venue failure as SwapFailed() (no silent success). + function test_flashLiquidate_nativeSwapReverts_revertsSwapFailed() public { + uint256 shares = _deposit(10 ether, 10 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + + RevertingPair badPair = new RevertingPair(); + vm.prank(manager); + liquidator.setPairWhitelist(address(badPair), true); + + // token1 (WETH, native) routed to a venue that reverts → SwapFailed must bubble up. + bytes memory swap1Data = abi.encodeWithSignature("doSwap()"); + V3Liquidator.FlashLiquidateParams memory params = V3Liquidator.FlashLiquidateParams({ + v3Provider: address(provider), + minToken0Amt: 0, + minToken1Amt: 0, + redeemShares: true, + token0Pair: address(0), + token0Spender: address(0), + token1Pair: address(badPair), + token1Spender: address(0), + swapToken0Data: "", + swapToken1Data: swap1Data + }); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.SwapFailed.selector); + liquidator.flashLiquidate(Id.unwrap(marketId), user, shares, params); + } +} diff --git a/test/moolah/mocks/MockStakeManager.sol b/test/moolah/mocks/MockStakeManager.sol index 8d4ff81a..c3713570 100644 --- a/test/moolah/mocks/MockStakeManager.sol +++ b/test/moolah/mocks/MockStakeManager.sol @@ -17,4 +17,10 @@ contract MockStakeManager is IStakeManager { function convertSnBnbToBnb(uint256 _amountInSlisBnb) external view returns (uint256) { return (_amountInSlisBnb * 1e18) / exchangeRate; } + + function deposit() external payable {} + + function instantWithdraw(uint256) external pure returns (uint256) { + return 0; + } } diff --git a/test/provider/SlisBNBV3Provider.t.sol b/test/provider/SlisBNBV3Provider.t.sol new file mode 100644 index 00000000..def5c70c --- /dev/null +++ b/test/provider/SlisBNBV3Provider.t.sol @@ -0,0 +1,2002 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import { StdStorage, stdStorage } from "forge-std/StdStorage.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 { SlisBNBV3Provider } from "../../src/provider/v3/SlisBNBV3Provider.sol"; +import { SlisBNBV3DexAdapter } from "../../src/provider/v3/SlisBNBV3DexAdapter.sol"; +import { SlisBNBV3ProviderOracle } from "../../src/provider/v3/SlisBNBV3ProviderOracle.sol"; +import { V3ProviderOracle } from "../../src/provider/v3/V3ProviderOracle.sol"; +import { IStakeManager } from "../../src/provider/interfaces/IStakeManager.sol"; +import { V3Provider } from "../../src/provider/v3/V3Provider.sol"; +import { V3DexAdapter } from "../../src/provider/v3/V3DexAdapter.sol"; +import { SwapInventoryLib } from "../../src/provider/libraries/SwapInventoryLib.sol"; +import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; +import { IV3PoolMinimal } from "../../src/provider/interfaces/IV3PoolMinimal.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 Minimal resilient-oracle mock: 8-decimal USD prices, settable per token. +contract MockOracle is IOracle { + mapping(address => uint256) public price; + + function setPrice(address token, uint256 value) external { + price[token] = value; + } + + function peek(address token) external view returns (uint256) { + return price[token]; + } + + function getTokenConfig(address) external pure returns (TokenConfig memory c) { + return c; + } +} + +/// @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; + IListaV3Pool(pool).swap(address(this), zeroForOne, int256(amountIn), limit, abi.encode(pool)); + } + + /// @dev V3 swap callback — pay whatever the pool pulled. PancakeSwap pools call the `pancake…` + /// name, Uniswap pools the `uniswap…` name; support both. + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + _pay(amount0Delta, amount1Delta, data); + } + + function pancakeV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + _pay(amount0Delta, amount1Delta, data); + } + + function _pay(int256 amount0Delta, int256 amount1Delta, bytes calldata data) internal { + address pool = abi.decode(data, (address)); + if (amount0Delta > 0) IERC20(IListaV3Pool(pool).token0()).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) IERC20(IListaV3Pool(pool).token1()).transfer(msg.sender, uint256(amount1Delta)); + } +} + +/// @dev Stand-in StakeManager. The live implementation at this fork block predates `instantWithdraw` +/// (the real-time slisBNB→BNB redeem the rebalance inventory conversion relies on), so we etch this +/// faithful mock at the StakeManager address. It mirrors deposit()/instantWithdraw()/convert* at a +/// fixed rate (seeded from the live rate) and performs real BNB↔slisBNB transfers, so the +/// balance-delta accounting in SlisBnbInventoryLib is exercised exactly as it will be in prod. +contract MockStakeManager { + uint256 public immutable rate; // BNB per slisBNB, 1e18 + address public immutable slisBnb; + + constructor(uint256 _rate, address _slisBnb) { + rate = _rate; + slisBnb = _slisBnb; + } + + function convertSnBnbToBnb(uint256 amount) external view returns (uint256) { + return (amount * rate) / 1e18; + } + + function convertBnbToSnBnb(uint256 amount) external view returns (uint256) { + return (amount * 1e18) / rate; + } + + /// @notice Stake BNB → slisBNB (mint emulated by transferring from this mock's pre-funded balance). + function deposit() external payable { + uint256 out = (msg.value * 1e18) / rate; + IERC20(slisBnb).transfer(msg.sender, out); + } + + /// @notice Real-time redeem slisBNB → BNB at the on-chain rate. Matches IStakeManager (returns BNB out). + function instantWithdraw(uint256 amount) external returns (uint256 bnbAmount) { + IERC20(slisBnb).transferFrom(msg.sender, address(this), amount); + bnbAmount = (amount * rate) / 1e18; + (bool ok, ) = msg.sender.call{ value: bnbAmount }(""); + require(ok, "bnb send failed"); + } + + receive() external payable {} +} + +/// @dev Minimal DEX-agnostic swap venue the rebalance backend routes through (the slisBNB conversion is +/// now a backend-built swap, not a StakeManager special case): pulls `amountIn` of tokenIn from the +/// caller (the adapter, which forceApproved it) and sends a fixed `amountOut` of tokenOut to `to`. +contract MockSwap { + function swap(address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, address to) external { + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + IERC20(tokenOut).transfer(to, amountOut); + } +} + +/// @dev A venue that pulls an ERC-20 input but (incorrectly) pays the output in the NATIVE coin — used to +/// prove the adapter wraps stray native (never strands it) and then reverts on the missing ERC-20 out. +contract NativeSwap { + function swap(address tokenIn, uint256 amountIn, uint256 nativeOut, address to) external { + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + (bool ok, ) = to.call{ value: nativeOut }(""); + require(ok, "native send failed"); + } + + receive() external payable {} +} + +/// @notice Functional integration tests for the slisBNB/BNB V3 LP topology (3-contract split: +/// SlisBNBV3DexAdapter + SlisBNBV3Provider vault + SlisBNBV3ProviderOracle), forked against the +/// live PancakeSwap V3 slisBNB/WBNB 1bp pool + a faithful slisBNB StakeManager stand-in. +contract SlisBNBV3ProviderTest is Test { + using MarketParamsLib for MarketParams; + using stdStorage for StdStorage; + + /* ─────────────────── PancakeSwap V3 slisBNB/WBNB 1bp ─────────────────── */ + address constant POOL = 0xe1B404Aaf60eEc5c8A1FEDE7dcDC0EAb9C69662F; // slisBNB/WBNB + address constant NPM = 0x46A15B0b27311cedF172AB29E4f4766fbE7F4364; + uint24 constant FEE = 100; + + /* ───────────────────────────── tokens ───────────────────────────── */ + address constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; // token0 + address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; // token1 + address constant STAKE_MANAGER = 0x1adB950d8bB3dA4bE104211D5AB038628e477fE6; + 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 LISUSD = 0x0782b6d8c4551B9760e74c0545a9bCD90bdc41E5; + address constant IRM = 0xFe7dAe87Ebb11a7BEB9F534BB23267992d9cDe7c; + + uint32 constant TWAP_PERIOD = 1800; // 30 minutes + uint256 constant LLTV = 70 * 1e16; + uint256 constant LLTV_SECOND = 71 * 1e16; + uint256 constant BNB_USD = 600e8; // mock BNB price, 8 decimals + + /* ───────────────────────── test contracts ───────────────────────── */ + Moolah moolah; + SlisBNBV3Provider provider; + SlisBNBV3DexAdapter adapter; + SlisBNBV3ProviderOracle providerOracle; + MockOracle oracle; + MockSwap mockSwap; + MarketParams marketParams; + Id marketId; + + /// @dev slisBNB USD price (= BNB_USD × StakeManager rate); WBNB priced at BNB_USD. Both set in setUp. + uint256 slisPrice; + uint256 constant wbnbPrice = BNB_USD; + + /* ───────────────────────── 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); + + // Read the live exchange rate BEFORE etching the StakeManager mock. + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + + // Mock resilient oracle: WBNB = BNB price; slisBNB = BNB price × exchange rate (OracleAdaptor-style). + oracle = new MockOracle(); + slisPrice = (BNB_USD * rate) / 1e18; + oracle.setPrice(WBNB, BNB_USD); + oracle.setPrice(BNB_ADDRESS, BNB_USD); + oracle.setPrice(SLISBNB, slisPrice); + oracle.setPrice(LISUSD, 1e8); // lisUSD ≈ $1 — needed for Moolah's loan-token health check / borrow math + + // Etch a faithful StakeManager stand-in (same rate) so `instantWithdraw` — absent on the live + // impl at this block — exists for the rebalance inventory conversion. Fund it on both legs. + MockStakeManager mockSm = new MockStakeManager(rate, SLISBNB); + vm.etch(STAKE_MANAGER, address(mockSm).code); + vm.deal(STAKE_MANAGER, 1_000_000 ether); + deal(SLISBNB, STAKE_MANAGER, 1_000_000 ether); + + // Deploy the heavy contracts (adapter → provider → oracle) EARLY in setUp, + // before unrelated deploys, to avoid forge setUp gas-forwarding issues with + // large code deposits. + + // 1) DEX adapter (NFT custodian + all NPM/pool interaction). + SlisBNBV3DexAdapter adapterImpl = new SlisBNBV3DexAdapter(NPM, SLISBNB, WBNB, FEE, TWAP_PERIOD); + adapter = SlisBNBV3DexAdapter( + payable(new ERC1967Proxy(address(adapterImpl), abi.encodeCall(SlisBNBV3DexAdapter.initialize, (admin, manager)))) + ); + + // 2) Provider / vault (ERC-4626 vLP shares + Moolah wiring). accountingAsset = WBNB. + SlisBNBV3Provider provImpl = new SlisBNBV3Provider(MOOLAH_PROXY, address(adapter)); + provider = SlisBNBV3Provider( + payable( + new ERC1967Proxy( + address(provImpl), + abi.encodeCall( + SlisBNBV3Provider.initialize, + (admin, manager, bot, address(oracle), WBNB, "SlisBNBV3Provider slisBNB/WBNB", "v3LP-slisBNB-WBNB") + ) + ) + ) + ); + + // 3) Wire adapter → provider (one-time, admin). + vm.prank(admin); + adapter.setProvider(address(provider)); + + // DEX-agnostic swap venue stand-in: whitelist it so backend-built rebalance swapData may target it. + mockSwap = new MockSwap(); + vm.prank(manager); + adapter.setSwapPairWhitelist(address(mockSwap), true); + + // 4) Share oracle (Moolah market.oracle points here). + SlisBNBV3ProviderOracle oracleImpl = new SlisBNBV3ProviderOracle( + address(adapter), + address(provider), + SLISBNB, + WBNB + ); + providerOracle = SlisBNBV3ProviderOracle( + payable( + new ERC1967Proxy( + address(oracleImpl), + abi.encodeCall(V3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + ) + ) + ); + + // 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); + + // Build Moolah market: collateral = provider shares, oracle = providerOracle. + marketParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(providerOracle), + irm: IRM, + lltv: LLTV + }); + marketId = marketParams.id(); + + // Create market and register SlisBNBV3Provider 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(SLISBNB, _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.previewDepositAmounts(amount0, amount1); + uint256 min0 = (exp0 * 999) / 1000; + uint256 min1 = (exp1 * 999) / 1000; + vm.startPrank(_user); + IERC20(SLISBNB).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; + } + + function _createSecondMarket() internal returns (MarketParams memory secondParams, Id secondId) { + secondParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(providerOracle), + irm: IRM, + lltv: LLTV_SECOND + }); + secondId = secondParams.id(); + + if (!moolah.isLltvEnabled(LLTV_SECOND)) { + vm.prank(MANAGER_ADDR); + moolah.enableLltv(LLTV_SECOND); + } + + vm.prank(OPERATOR); + moolah.createMarket(secondParams); + + vm.prank(MANAGER_ADDR); + moolah.setProvider(secondId, address(provider), true); + } + + /* ────────────────────────── test cases ─────────────────────────── */ + + function test_initialize() public view { + assertEq(provider.TOKEN0(), SLISBNB); + assertEq(provider.TOKEN1(), WBNB); + assertEq(adapter.FEE(), FEE); + assertEq(adapter.POOL(), POOL); + assertEq(address(provider.MOOLAH()), MOOLAH_PROXY); + assertEq(address(adapter.POSITION_MANAGER()), NPM); + assertEq(provider.resilientOracle(), address(oracle)); + assertEq(provider.asset(), WBNB); + assertEq(provider.accountingAssetDecimals(), 18); + assertEq(adapter.TWAP_PERIOD(), TWAP_PERIOD); + assertLt(adapter.tickLower(), adapter.tickUpper()); + 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()); + // adapter is wired to the provider/vault + assertEq(adapter.provider(), address(provider)); + } + + function test_deposit_firstDeposit() public { + uint256 amount0 = 10 ether; // slisBNB + uint256 amount1 = 10 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. + // slisBNB refunded as ERC-20; WBNB (TOKEN1 = WRAPPED_NATIVE) refunded as native BNB. + assertEq(IERC20(SLISBNB).balanceOf(user), amount0 - used0); + assertEq(user.balance, amount1 - used1); + } + + function test_deposit_secondDeposit_sharesProportional() public { + _deposit(user, 10 ether, 10 ether); + uint256 sharesAfterFirst = _collateral(user); + + (uint256 shares2, , ) = _deposit(user2, 20 ether, 20 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, 10 ether, 10 ether); + + uint256 slisBefore = IERC20(SLISBNB).balanceOf(user); + uint256 bnbBefore = user.balance; // WBNB (TOKEN1) is unwrapped to native BNB on withdrawal + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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(SLISBNB).balanceOf(user), slisBefore + out0); + assertEq(user.balance, bnbBefore + out1); // WBNB unwrapped to BNB + } + + function test_withdraw_partialWithdrawal() public { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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, 10 ether, 10 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(V3Provider.Unauthorized.selector); + provider.withdraw(marketParams, shares, 1, 1, user, user2); + } + + function test_withdrawShares_toWallet_doesNotRedeemUnderlying() public { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + uint256 supplyBefore = provider.totalSupply(); + uint256 tokenIdBefore = adapter.tokenId(); + + vm.prank(user); + provider.withdrawShares(marketParams, shares, user, user); + + assertEq(_collateral(user), 0, "collateral should be withdrawn from market"); + assertEq(provider.balanceOf(user), shares, "user should hold vLP shares"); + assertEq(provider.balanceOf(MOOLAH_PROXY), 0, "Moolah should hold no shares"); + assertEq(provider.totalSupply(), supplyBefore, "shares should not be burned"); + assertEq(adapter.tokenId(), tokenIdBefore, "V3 position should remain intact"); + assertEq(provider.userMarketDeposit(user, marketId), 0, "market tracking should clear"); + assertEq(provider.userTotalDeposit(user), 0, "total tracking should clear"); + } + + function test_withdrawShares_revertsIfUnauthorized() public { + _deposit(user, 10 ether, 10 ether); + uint256 shares = _collateral(user); + + vm.prank(user2); + vm.expectRevert(V3Provider.Unauthorized.selector); + provider.withdrawShares(marketParams, shares, user, user2); + } + + function test_supplyShares_fromWallet_suppliesCollateral() public { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + + vm.startPrank(user); + provider.withdrawShares(marketParams, shares, user, user); + provider.supplyShares(marketParams, shares, user); + vm.stopPrank(); + + assertEq(_collateral(user), shares, "collateral should be restored"); + assertEq(provider.balanceOf(user), 0, "user should no longer hold shares"); + assertEq(provider.balanceOf(MOOLAH_PROXY), shares, "Moolah should hold shares"); + assertEq(provider.userMarketDeposit(user, marketId), shares, "market tracking should restore"); + assertEq(provider.userTotalDeposit(user), shares, "total tracking should restore"); + } + + function test_supplyShares_revertsIfSenderDoesNotHoldShares() public { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + + vm.prank(user2); + vm.expectRevert(V3Provider.InsufficientShares.selector); + provider.supplyShares(marketParams, shares, user2); + } + + function test_withdrawShares_supplyShares_movesCollateralBetweenMarkets() public { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + (MarketParams memory secondParams, Id secondId) = _createSecondMarket(); + + vm.startPrank(user); + provider.withdrawShares(marketParams, shares, user, user); + provider.supplyShares(secondParams, shares, user); + vm.stopPrank(); + + (, , uint256 secondCollateral) = moolah.position(secondId, user); + assertEq(_collateral(user), 0, "first market collateral should be empty"); + assertEq(secondCollateral, shares, "second market collateral should receive shares"); + assertEq(provider.balanceOf(user), 0, "wallet should not retain shares"); + assertEq(provider.balanceOf(MOOLAH_PROXY), shares, "Moolah should custody shares"); + assertEq(provider.userMarketDeposit(user, marketId), 0, "first market tracking should clear"); + assertEq(provider.userMarketDeposit(user, secondId), shares, "second market tracking should update"); + assertEq(provider.userTotalDeposit(user), shares, "total tracking should remain one deposit"); + } + + function test_redeemShares_byLiquidator() public { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 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 slisBefore = IERC20(SLISBNB).balanceOf(liquidator); + uint256 bnbBefore = liquidator.balance; // WBNB (TOKEN1) is unwrapped to native BNB + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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(SLISBNB).balanceOf(liquidator), slisBefore + out0); + assertEq(liquidator.balance, bnbBefore + out1); // WBNB unwrapped to BNB + } + + function test_transferRestriction_directTransferReverts() public { + _deposit(user, 10 ether, 10 ether); + + vm.prank(user); + vm.expectRevert(V3Provider.OnlyMoolah.selector); + provider.transfer(user2, 1); + } + + function test_transferRestriction_transferFromReverts() public { + _deposit(user, 10 ether, 10 ether); + + vm.prank(user); + vm.expectRevert(V3Provider.OnlyMoolah.selector); + provider.transferFrom(MOOLAH_PROXY, user2, 1); + } + + function test_rebalance_onlyBot() public { + _deposit(user, 10 ether, 10 ether); + + // manager cannot rebalance — revert fires on role check before amounts matter. + vm.prank(manager); + vm.expectRevert(); + provider.rebalance(1, 1, 1, block.timestamp, ""); + + // Disable the rate-drift guard — a pool swap does NOT move the StakeManager rate, so the + // default 1% center-rate threshold would block this rebalance with RateDeviationBelowThreshold. + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // bot can rebalance — range is derived internally by the provider/adapter. + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + uint256 min0 = (total0 * 999) / 1000; + uint256 min1 = (total1 * 999) / 1000; + uint256 oldTokenId = adapter.tokenId(); + vm.prank(bot); + provider.rebalance(min0, min1, 0, block.timestamp, ""); + + assertGt(adapter.tokenId(), oldTokenId, "position NFT should be re-minted"); + assertLt(adapter.tickLower(), adapter.tickUpper()); + } + + function test_rebalance_liquidity_preserved() public { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + + (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); + + // Disable the rate-drift guard (the rate is unchanged by deposits / pool activity). + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + uint256 min0 = (total0 * 999) / 1000; + uint256 min1 = (total1 * 999) / 1000; + vm.prank(bot); + provider.rebalance(min0, min1, 0, block.timestamp, ""); + + // 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(providerOracle.peek(address(provider)), 0, "price should be 0 with no deposits"); + } + + function test_peek_nonZeroAfterDeposit() public { + _deposit(user, 10 ether, 10 ether); + + uint256 price = providerOracle.peek(address(provider)); + assertGt(price, 0, "share price should be non-zero after deposit"); + } + + function test_getTotalAmounts_nonZeroAfterDeposit() public { + _deposit(user, 10 ether, 10 ether); + + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + assertGt(total0 + total1, 0, "total amounts should be non-zero after deposit"); + } + + function test_compoundFees_shareValueIncreasesOverTime() public { + // slisBNB pricing uses the StakeManager rate (set on the MockOracle in setUp), not pool TWAP, + // so no RESILIENT_ORACLE price mocks and no pool.observe() mock are needed across the warp. + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + + uint256 priceBefore = providerOracle.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, 10 ether, 10 ether); + + uint256 priceAfter = providerOracle.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); + } + + function test_deposit_afterIdle_mintsByNav_doesNotDiluteExistingShares() public { + _deposit(user, 10 ether, 10 ether); + + uint256 idle0 = 1 ether; + uint256 idle1 = 50 ether; + deal(SLISBNB, address(adapter), IERC20(SLISBNB).balanceOf(address(adapter)) + idle0); + deal(WBNB, address(adapter), IERC20(WBNB).balanceOf(address(adapter)) + idle1); + stdstore.target(address(adapter)).sig("idleToken0()").checked_write(adapter.idleToken0() + idle0); + stdstore.target(address(adapter)).sig("idleToken1()").checked_write(adapter.idleToken1() + idle1); + + uint256 idleValue = _valueUSD(adapter.idleToken0(), adapter.idleToken1()); + assertGt(idleValue, 0, "test setup should include tracked idle value"); + + uint256 snapshot = vm.snapshotState(); + vm.prank(address(provider)); + adapter.collectAndCompound(); + + uint256 priceBefore = providerOracle.peek(address(provider)); + uint256 supplyBefore = provider.totalSupply(); + uint160 fairSqrtPriceX96 = adapter.fairSqrtPriceX96(); + (uint256 total0Before, uint256 total1Before) = adapter.positionAmountsAt(fairSqrtPriceX96); + uint256 totalValueBefore = _valueUSD(total0Before, total1Before); + (uint128 liquidityPreview, , ) = adapter.previewAddLiquidity(10 ether, 10 ether); + (uint256 added0, uint256 added1) = adapter.amountsForLiquidity(liquidityPreview, fairSqrtPriceX96); + uint256 expectedNavShares = (_valueUSD(added0, added1) * supplyBefore) / totalValueBefore; + uint256 liquidityOnlyShares = (uint256(liquidityPreview) * supplyBefore) / uint256(adapter.totalLiquidity()); + assertLt(expectedNavShares, liquidityOnlyShares, "tracked idle should reduce new depositor shares"); + assertTrue(vm.revertToState(snapshot), "snapshot revert failed"); + + (uint256 shares2, , ) = _deposit(user2, 10 ether, 10 ether); + assertApproxEqAbs(shares2, expectedNavShares, 1, "second depositor should receive NAV-priced shares"); + + uint256 priceAfter = providerOracle.peek(address(provider)); + assertGe(priceAfter, priceBefore, "NAV-based mint must not dilute existing share value"); + } + + /// @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(SLISBNB, _user, amount0); + deal(WBNB, _user, amount1); + vm.startPrank(_user); + IERC20(SLISBNB).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 = 10 ether; + uint256 amount1 = 10 ether; + + (uint128 liquidity, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(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 = 10 ether; + uint256 amount1 = 10 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(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 ether, 10 ether); + _pushPriceBelowRange(); + + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); + + // Position is fully slisBNB — 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 ether, 10 ether); + _pushPriceAboveRange(); + + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(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, 10 ether, 10 ether); + + uint256 amount0 = 20 ether; + uint256 amount1 = 20 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(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.previewRedeemUnderlying(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 ether, 10 ether); + + (, int24 currentTick) = IV3PoolMinimal(POOL).slot0(); + assertGt(currentTick, adapter.tickLower(), "price should be above tickLower"); + assertLt(currentTick, adapter.tickUpper(), "price should be below tickUpper"); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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 ether, 10 ether); + + vm.prank(MOOLAH_PROXY); + provider.transfer(user2, shares); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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 ether, 10 ether); + + (uint256 fullExp0, uint256 fullExp1) = provider.previewRedeemUnderlying(shares); + (uint256 halfExp0, uint256 halfExp1) = provider.previewRedeemUnderlying(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 ether, 10 ether); + _pushPriceBelowRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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 ether, 10 ether); + _pushPriceAboveRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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 ether, 10 ether); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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 = 10 ether; + uint256 amount1 = 10 ether; + + // min0 far exceeds what NPM can place — should revert from NPM slippage check. + deal(SLISBNB, user, amount0); + deal(WBNB, user, amount1); + vm.startPrank(user); + IERC20(SLISBNB).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 = 10 ether; + uint256 amount1 = 10 ether; + + deal(SLISBNB, user, amount0); + deal(WBNB, user, amount1); + vm.startPrank(user); + IERC20(SLISBNB).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, 10 ether, 10 ether); + + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; + + deal(SLISBNB, user2, amount0); + deal(WBNB, user2, amount1); + vm.startPrank(user2); + IERC20(SLISBNB).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, 10 ether, 10 ether); + + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; + + deal(SLISBNB, user2, amount0); + deal(WBNB, user2, amount1); + vm.startPrank(user2); + IERC20(SLISBNB).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 → the deposit must revert. + // NOTE: V3Provider.ZeroLiquidity was REMOVED in the 3-contract split. In the new + // topology the adapter forwards a one-sided in-range mint straight to the V3 + // NPM/pool, which reverts with EMPTY data (the pool's own zero-amount guard) + // BEFORE the vault's ZeroShares check can fire. The test intent (one-sided + // in-range deposit must revert) is preserved; only the revert source/selector + // changed (bare vm.expectRevert() instead of V3Provider.ZeroLiquidity). + + function test_deposit_oneSided_token0Only_inRange_reverts() public { + // Price is in-range: token0 alone yields 0 liquidity → pool mint reverts (no data). + // Pass min=0 so the failure comes from the zero-liquidity mint, not an NPM slippage check. + deal(SLISBNB, user, 10 ether); + vm.startPrank(user); + IERC20(SLISBNB).approve(address(provider), 10 ether); + vm.expectRevert(); + provider.deposit(marketParams, 10 ether, 0, 0, 0, user); + vm.stopPrank(); + } + + function test_deposit_oneSided_token1Only_inRange_reverts() public { + // Price is in-range: token1 alone yields 0 liquidity → pool mint reverts (no data). + deal(WBNB, user, 10 ether); + vm.startPrank(user); + IERC20(WBNB).approve(address(provider), 10 ether); + vm.expectRevert(); + provider.deposit(marketParams, 0, 10 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 ether, 10 ether); + _pushPriceBelowRange(); + + // Price below tickLower: only token0 (slisBNB) is accepted. + uint256 amount0 = 10 ether; + deal(SLISBNB, user2, amount0); + vm.startPrank(user2); + IERC20(SLISBNB).approve(address(provider), amount0); + (, uint256 exp0, ) = provider.previewDepositAmounts(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 ether, 10 ether); + _pushPriceBelowRange(); + + // Price below range: token1 alone yields 0 liquidity → pool mint reverts (no data). + // (See note above test_deposit_oneSided_token0Only_inRange_reverts: ZeroLiquidity removed.) + deal(WBNB, user2, 10 ether); + vm.startPrank(user2); + IERC20(WBNB).approve(address(provider), 10 ether); + vm.expectRevert(); + provider.deposit(marketParams, 0, 10 ether, 0, 0, user2); + vm.stopPrank(); + } + + function test_deposit_oneSided_token1Only_aboveRange_succeeds() public { + _deposit(user, 10 ether, 10 ether); + _pushPriceAboveRange(); + + // Price above tickUpper: only token1 (WBNB) is accepted. + uint256 amount1 = 10 ether; + deal(WBNB, user2, amount1); + vm.startPrank(user2); + IERC20(WBNB).approve(address(provider), amount1); + (, , uint256 exp1) = provider.previewDepositAmounts(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 ether, 10 ether); + _pushPriceAboveRange(); + + // Price above range: token0 alone yields 0 liquidity → pool mint reverts (no data). + // (See note above test_deposit_oneSided_token0Only_inRange_reverts: ZeroLiquidity removed.) + deal(SLISBNB, user2, 10 ether); + vm.startPrank(user2); + IERC20(SLISBNB).approve(address(provider), 10 ether); + vm.expectRevert(); + provider.deposit(marketParams, 10 ether, 0, 0, 0, user2); + vm.stopPrank(); + } + + function test_deposit_revertsWithInvalidCollateralToken() public { + MarketParams memory badParams = marketParams; + badParams.collateralToken = SLISBNB; + + deal(SLISBNB, user, 10 ether); + deal(WBNB, user, 10 ether); + vm.startPrank(user); + IERC20(SLISBNB).approve(address(provider), 10 ether); + IERC20(WBNB).approve(address(provider), 10 ether); + vm.expectRevert(V3Provider.InvalidCollateralToken.selector); + // The revert fires before min amounts are evaluated; use 1,1 for consistency. + provider.deposit(badParams, 10 ether, 10 ether, 1, 1, user); + vm.stopPrank(); + } + + function test_getTokenConfig() public view { + TokenConfig memory config = providerOracle.getTokenConfig(address(provider)); + assertEq(config.asset, address(provider)); + assertEq(config.oracles[0], address(providerOracle)); + assertTrue(config.enableFlagsForOracles[0]); + assertEq(config.oracles[1], address(0)); + assertEq(config.oracles[2], address(0)); + } + + /* ─────────── rebalance after price leaves range (fully slisBNB) ─────── */ + + /// @dev Compute USD value (8-decimal) from raw token amounts. + /// token0 = slisBNB (priced at slisPrice = BNB_USD × rate), token1 = WBNB (priced at BNB_USD). + function _valueUSD(uint256 amount0, uint256 amount1) internal view returns (uint256) { + return (amount0 * slisPrice) / 1e18 + (amount1 * wbnbPrice) / 1e18; + } + + /// @dev Push pool price below tickLower by swapping a large amount of slisBNB → WBNB. + /// zeroForOne = true (token0 → token1) drives the tick downward. + /// When tick < tickLower the V3 position converts entirely to token0 (slisBNB). + /// The ±1% range is narrow; 20k slisBNB comfortably exits it. + function _pushPriceBelowRange() internal { + PoolSwapper swapper = new PoolSwapper(); + uint256 slisIn = 20_000 ether; + deal(SLISBNB, address(swapper), slisIn); + swapper.swapExactIn(POOL, true, slisIn); + } + + function test_rebalance_priceBelowRange_positionFullyslisBNB() public { + _deposit(user, 10 ether, 10 ether); + + // Push price below tickLower — position should convert entirely to slisBNB (token0). + _pushPriceBelowRange(); + + (, int24 tickAfterSwap) = IV3PoolMinimal(POOL).slot0(); + assertLt(tickAfterSwap, adapter.tickLower(), "tick should be below tickLower after swap"); + + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + assertGt(total0, 0, "should hold slisBNB"); + assertEq(total1, 0, "position should be fully slisBNB (token1 == 0) when price is below range"); + } + + function test_rebalance_priceBelowRange_totalValuePreserved() public { + _deposit(user, 10 ether, 10 ether); + + _pushPriceBelowRange(); + + // Snapshot USD value before rebalance (position is 100% slisBNB). + (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); + uint256 valueBefore = _valueUSD(total0Before, total1Before); + assertGt(valueBefore, 0, "should have non-zero value before rebalance"); + + // The rate-derived recenter target is unaffected by a pool swap, so disable the rate-drift guard. + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // Rebalance uses an internally derived range; caller only supplies execution guards. + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, ""); + + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); + + (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); + uint256 valueAfter = _valueUSD(total0After, total1After); + + // Recenter-only (empty swapData ⇒ no inventory conversion): the all-slisBNB inventory is re-minted + // into the rate-derived range (excess held as idle), so total VALUE is preserved within ~2%. The + // actual slisBNB↔WBNB conversion is exercised by the swap-venue tests below. + assertApproxEqRel(valueAfter, valueBefore, 0.02e18, "recenter-only preserves total value within 2%"); + } + + /* ─────── rebalance inventory conversion via a whitelisted swap venue (DEX-agnostic) ─────── */ + + /// @notice The rebalance converts inventory through a backend-built swap against a whitelisted venue + /// (the slisBNB conversion is now a swap, not a StakeManager special case). A fair-rate + /// slisBNB→WBNB swap is value-neutral and the position is re-minted. + function test_rebalance_swapExecutesThroughWhitelistedVenue() public { + _deposit(user, 10 ether, 10 ether); + uint256 peekBefore = providerOracle.peek(address(provider)); + uint256 oldTokenId = adapter.tokenId(); + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // Sell 0.5 slisBNB → WBNB at the StakeManager rate; fund the venue with the WBNB it pays out. + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + uint256 amountIn = 0.5 ether; + uint256 fairOut = (amountIn * rate) / 1e18; + deal(WBNB, address(mockSwap), fairOut); + + bytes memory inner = abi.encodeCall(MockSwap.swap, (SLISBNB, WBNB, amountIn, fairOut, address(adapter))); + bytes memory data = abi.encode(address(mockSwap), true, amountIn, (fairOut * 99) / 100, false, inner); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, data); + + assertGt(adapter.tokenId(), oldTokenId, "position re-minted after swap"); + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 2e16, "fair swap ~value-neutral"); + } + + /// @notice The adapter only allows whitelisted swap venues; a non-whitelisted target reverts. + function test_rebalance_revertsNotWhitelistedPair() public { + _deposit(user, 10 ether, 10 ether); + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + MockSwap rogue = new MockSwap(); // never whitelisted + bytes memory inner = abi.encodeCall(MockSwap.swap, (SLISBNB, WBNB, 0.5 ether, 0.5 ether, address(adapter))); + bytes memory data = abi.encode(address(rogue), true, uint256(0.5 ether), uint256(0), false, inner); + + vm.prank(bot); + vm.expectRevert(V3DexAdapter.NotWhitelistedPair.selector); + provider.rebalance(0, 0, 0, block.timestamp, data); + } + + /// @notice Defense-in-depth: a swap venue may never be the position's own tokens / pool / NPM. + function test_setSwapPairWhitelist_rejectsSensitiveAddresses() public { + address npm = address(adapter.POSITION_MANAGER()); + vm.startPrank(manager); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(SLISBNB, true); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(WBNB, true); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(POOL, true); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(npm, true); + vm.stopPrank(); + } + + /// @notice instantWithdraw is just another whitelisted swap venue: the StakeManager settles slisBNB→ + /// native BNB, which the adapter wraps back to WBNB. Confirms the simplified, swap-pair-agnostic + /// path still supports instant-redeem (no StakeManager special-casing on chain). + function test_rebalance_instantWithdrawAsSwapVenue() public { + vm.prank(manager); + adapter.setSwapPairWhitelist(STAKE_MANAGER, true); + + _deposit(user, 10 ether, 10 ether); + uint256 peekBefore = providerOracle.peek(address(provider)); + uint256 oldTokenId = adapter.tokenId(); + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // Sell 0.5 slisBNB via instantWithdraw → native BNB → wrapped to WBNB by the adapter. + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + uint256 amountIn = 0.5 ether; + uint256 fairOut = (amountIn * rate) / 1e18; + + bytes memory inner = abi.encodeCall(IStakeManager.instantWithdraw, (amountIn)); + bytes memory data = abi.encode(STAKE_MANAGER, true, amountIn, (fairOut * 99) / 100, false, inner); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, data); + + assertGt(adapter.tokenId(), oldTokenId, "position re-minted after instantWithdraw conversion"); + assertApproxEqRel( + providerOracle.peek(address(provider)), + peekBefore, + 2e16, + "instantWithdraw conversion ~value-neutral" + ); + assertEq(address(adapter).balance, 0, "native fully wrapped, none stranded"); + } + + /// @notice The backend amountOutMin is enforced on the measured output: a venue under-delivering + /// vs amountOutMin reverts the whole rebalance (replaces the old instant-withdraw slippage guard). + function test_rebalance_swap_revertsBelowAmountOutMin() public { + _deposit(user, 10 ether, 10 ether); + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + uint256 amountIn = 0.5 ether; + uint256 fairOut = (amountIn * rate) / 1e18; + deal(WBNB, address(mockSwap), fairOut); + + // Venue pays only half, but the backend demanded the full fair output as amountOutMin. + bytes memory inner = abi.encodeCall(MockSwap.swap, (SLISBNB, WBNB, amountIn, fairOut / 2, address(adapter))); + bytes memory data = abi.encode(address(mockSwap), true, amountIn, fairOut, false, inner); + + vm.prank(bot); + vm.expectRevert(SwapInventoryLib.InsufficientOutput.selector); + provider.rebalance(0, 0, 0, block.timestamp, data); + } + + /// @notice The other swap direction (sellToken0 = false): sell WBNB → slisBNB, value-neutral. + function test_rebalance_swapToken1ToToken0() public { + _deposit(user, 10 ether, 10 ether); + uint256 peekBefore = providerOracle.peek(address(provider)); + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + uint256 amountIn = 0.5 ether; // WBNB in + uint256 fairOut = (amountIn * 1e18) / rate; // slisBNB out + deal(SLISBNB, address(mockSwap), fairOut); + + bytes memory inner = abi.encodeCall(MockSwap.swap, (WBNB, SLISBNB, amountIn, fairOut, address(adapter))); + bytes memory data = abi.encode(address(mockSwap), false, amountIn, (fairOut * 99) / 100, false, inner); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, data); + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 2e16, "WBNB->slisBNB swap ~value-neutral"); + } + + /// @notice Native-input venue (nativeIn=true): StakeManager.deposit{value} takes BNB. The adapter + /// unwraps the WBNB input → native, forwards it as msg.value, and books the minted slisBNB. + /// Confirms the WBNB→slisBNB-via-deposit path works (the second native direction). + function test_rebalance_depositAsSwapVenue() public { + vm.prank(manager); + adapter.setSwapPairWhitelist(STAKE_MANAGER, true); + + _deposit(user, 10 ether, 10 ether); + uint256 peekBefore = providerOracle.peek(address(provider)); + uint256 oldTokenId = adapter.tokenId(); + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // Sell 0.5 WBNB → slisBNB via deposit{value}: WBNB unwrapped to BNB, deposit mints slisBNB. + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + uint256 amountIn = 0.5 ether; // WBNB in + uint256 fairOut = (amountIn * 1e18) / rate; // slisBNB minted + + bytes memory inner = abi.encodeCall(IStakeManager.deposit, ()); + bytes memory data = abi.encode(STAKE_MANAGER, false, amountIn, (fairOut * 99) / 100, true, inner); // nativeIn=true + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, data); + + assertGt(adapter.tokenId(), oldTokenId, "position re-minted after deposit conversion"); + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 2e16, "deposit conversion ~value-neutral"); + assertEq(address(adapter).balance, 0, "native fully consumed/wrapped, none stranded"); + } + + /// @notice A venue that delivers the NATIVE coin for the non-wrapped-native output leg cannot strand + /// BNB: the adapter wraps it (so it isn't lost), but the expected ERC-20 output is then 0 and + /// the backend amountOutMin guard reverts the rebalance. + function test_rebalance_wrongNativeOutput_reverts() public { + NativeSwap bad = new NativeSwap(); + vm.deal(address(bad), 10 ether); + vm.prank(manager); + adapter.setSwapPairWhitelist(address(bad), true); + + _deposit(user, 10 ether, 10 ether); + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // sellToken0=false ⇒ tokenOut = slisBNB. The venue takes WBNB but pays native BNB (wrong leg). + uint256 amountIn = 0.5 ether; + bytes memory inner = abi.encodeCall(NativeSwap.swap, (WBNB, amountIn, 0.4 ether, address(adapter))); + bytes memory data = abi.encode(address(bad), false, amountIn, uint256(0.4 ether), false, inner); + + vm.prank(bot); + vm.expectRevert(SwapInventoryLib.InsufficientOutput.selector); // slisBNB received = 0 < amountOutMin + provider.rebalance(0, 0, 0, block.timestamp, data); + } + + /* ─────────── rebalance after price leaves range (fully WBNB) ──────── */ + + /// @dev Push pool price above tickUpper by swapping a large amount of WBNB → slisBNB. + /// 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 = 20_000 ether; + deal(WBNB, address(swapper), wbnbIn); + swapper.swapExactIn(POOL, false, wbnbIn); + } + + function test_rebalance_priceAboveRange_positionFullyWBNB() public { + _deposit(user, 10 ether, 10 ether); + + // Push price above tickUpper — position should convert entirely to WBNB (token1). + _pushPriceAboveRange(); + + (, int24 tickAfterSwap) = IV3PoolMinimal(POOL).slot0(); + assertGt(tickAfterSwap, adapter.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 { + _deposit(user, 10 ether, 10 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"); + + // The rate-derived recenter target is unaffected by a pool swap, so disable the rate-drift guard. + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // Rebalance uses an internally derived range; caller only supplies execution guards. + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, ""); + + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); + + (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); + uint256 valueAfter = _valueUSD(total0After, total1After); + + // Recenter-only (empty swapData ⇒ no inventory conversion): the all-WBNB inventory is re-minted into + // the rate-derived range (excess held as idle), so total VALUE is preserved within ~2%. + assertApproxEqRel(valueAfter, valueBefore, 0.02e18, "recenter-only preserves total value within 2%"); + } + + /* ──────────── minAmount slippage guard tests ────────────────────── */ + + /// @dev When price is below range the position is 100% slisBNB (token0). + /// rebalance with minAmount0 = actual slisBNB held passes; minAmount0 > actual reverts. + function test_rebalance_priceBelowRange_minAmount0_passes() public { + _deposit(user, 10 ether, 10 ether); + _pushPriceBelowRange(); + + (uint256 total0, ) = provider.getTotalAmounts(); + assertGt(total0, 0, "should hold slisBNB before rebalance"); + + // The rate-derived recenter target is unaffected by a pool swap, so disable the rate-drift guard. + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // minAmount0 = total0 (exact), minAmount1 = 0 (position has no WBNB). + vm.prank(bot); + provider.rebalance(total0, 0, 0, block.timestamp, ""); + + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); + } + + function test_rebalance_priceBelowRange_minAmount0_tooHigh_reverts() public { + _deposit(user, 10 ether, 10 ether); + _pushPriceBelowRange(); + + (uint256 total0, ) = provider.getTotalAmounts(); + + // Get past the rate-drift guard so the revert is the intended NPM slippage check. + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // minAmount0 one unit above actual → should revert with NPM slippage check. + vm.prank(bot); + vm.expectRevert(); + provider.rebalance(total0 + 1, 0, 0, block.timestamp, ""); + } + + /// @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 ether, 10 ether); + _pushPriceAboveRange(); + + (, uint256 total1) = provider.getTotalAmounts(); + assertGt(total1, 0, "should hold WBNB before rebalance"); + + // The rate-derived recenter target is unaffected by a pool swap, so disable the rate-drift guard. + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // minAmount0 = 0 (no slisBNB), minAmount1 = total1 (exact). + vm.prank(bot); + provider.rebalance(0, total1, 0, block.timestamp, ""); + + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); + } + + function test_rebalance_priceAboveRange_minAmount1_tooHigh_reverts() public { + _deposit(user, 10 ether, 10 ether); + _pushPriceAboveRange(); + + (, uint256 total1) = provider.getTotalAmounts(); + + // Get past the rate-drift guard so the revert is the intended NPM slippage check. + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // minAmount1 one unit above actual → should revert with NPM slippage check. + vm.prank(bot); + vm.expectRevert(); + provider.rebalance(0, total1 + 1, 0, block.timestamp, ""); + } + + function test_withdraw_minAmount_tooHigh_reverts() public { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + + (uint256 exp0, ) = provider.previewRedeemUnderlying(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 ether, 10 ether); + + vm.prank(MOOLAH_PROXY); + provider.transfer(user2, shares); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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 ether, 10 ether); + _pushPriceBelowRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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 ether, 10 ether); + _pushPriceAboveRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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 ether, 10 ether); + + (uint256 exp0, ) = provider.previewRedeemUnderlying(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, 10 ether, 10 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, 10 ether, 10 ether); + (uint256 shares2, , ) = _deposit(user, 10 ether, 10 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, 10 ether, 10 ether); + (uint256 shares2, , ) = _deposit(user2, 20 ether, 20 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, 10 ether, 10 ether); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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, 10 ether, 10 ether); + uint256 half = shares / 2; + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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, 10 ether, 10 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 ─────────────── */ + + function test_getUserBalanceInBnb_zeroBeforeDeposit() public view { + assertEq(provider.getUserBalanceInBnb(user), 0); + } + + function test_getUserBalanceInBnb_nonzeroAfterDeposit() public { + _deposit(user, 10 ether, 10 ether); + + uint256 bnbValue = provider.getUserBalanceInBnb(user); + assertGt(bnbValue, 0, "should return positive BNB value after deposit"); + } + + function test_getUserBalanceInBnb_proportionalToShares() public { + _deposit(user, 10 ether, 10 ether); + _deposit(user2, 20 ether, 20 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 { + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + + // peek() returns (totalValue * 1e18 / supply) where totalValue is 8-dec USD. + // getUserBalanceInBnb returns (shares * 1e18 * totalValue / supply / bnbPrice) + // = shares * sharePrice / bnbPrice + uint256 sharePrice = providerOracle.peek(address(provider)); // 8-dec USD * 1e18 / liquidity-unit + uint256 expectedBnbValue = (shares * sharePrice) / BNB_USD; + + 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, 10 ether, 10 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, 10 ether, 10 ether); + _deposit(user2, 20 ether, 20 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(SlisBNBV3Provider.LengthMismatch.selector); + 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 SlisBNBV3Provider. + MarketParams memory foreign = MarketParams({ + loanToken: LISUSD, + collateralToken: 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B, // slisBNB + oracle: 0xf3afD82A4071f272F403dC176916141f44E6c750, // multiOracle + irm: 0x5F9f9173B405C6CEAfa7f98d09e4B8447e9797E6, + lltv: 90 * 1e16 + }); + return foreign.id(); + } + + function test_syncUserBalance_foreignMarket_reverts() public { + _deposit(user, 10 ether, 10 ether); + uint256 totalBefore = provider.userTotalDeposit(user); + + vm.expectRevert(V3Provider.InvalidMarket.selector); + provider.syncUserBalance(_foreignMarketId(), user); + + // Deposit tracking must be unchanged. + assertEq(provider.userTotalDeposit(user), totalBefore); + } + + function test_bulkSyncUserBalance_foreignMarket_reverts() public { + _deposit(user, 10 ether, 10 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(V3Provider.InvalidMarket.selector); + 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)); + + (uint256 shares, , ) = _deposit(user, 10 ether, 10 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)); + + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + assertGt(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "setup: slisBNBx minted after deposit"); + + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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)); + + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); + uint256 slisBNBxAfterDeposit = ISlisBNBx(SLISBNBX).balanceOf(user); + assertGt(slisBNBxAfterDeposit, 0); + + uint256 half = shares / 2; + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(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)); + + (uint256 shares, , ) = _deposit(user, 10 ether, 10 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 = providerOracle.peek(address(provider)); // 8-dec USD per share + uint256 loanPrice = providerOracle.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(providerOracle), + abi.encodeWithSelector(IOracle.peek.selector, address(provider)), + abi.encode(uint256(0)) + ); + } + + function test_borrow_afterDeposit_receivesLisUSD() public { + _deposit(user, 10 ether, 10 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, 10 ether, 10 ether); + _deposit(user2, 20 ether, 20 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, 10 ether, 10 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, 10 ether, 10 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, 10 ether, 10 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, 10 ether, 10 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.previewRedeemUnderlying(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(SLISBNB).balanceOf(liquidator), out0); + assertEq(liquidator.balance, out1); // WBNB unwrapped to BNB + } + + /// @notice The V3 provider no longer blocks rebalance based on spot/TWAP tick deviation. + function test_rebalance_noLongerUsesTwapDeviationGuard() public { + _deposit(user, 10 ether, 10 ether); + + // A pool swap does not move the StakeManager rate, so disable the rate-drift guard. + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, ""); + + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); + } + + /* ───── rebalance without TWAP deviation guard ───── */ + + function test_rebalance_succeeds_without_twap_deviation_config() public { + _deposit(user, 10 ether, 10 ether); + + // A pool swap does not move the StakeManager rate, so disable the rate-drift guard. + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, ""); + + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); + } +} diff --git a/test/provider/SlisBNBV3ProviderRate.t.sol b/test/provider/SlisBNBV3ProviderRate.t.sol new file mode 100644 index 00000000..4addd88e --- /dev/null +++ b/test/provider/SlisBNBV3ProviderRate.t.sol @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { SlisBNBV3Provider } from "../../src/provider/v3/SlisBNBV3Provider.sol"; +import { SlisBNBV3DexAdapter } from "../../src/provider/v3/SlisBNBV3DexAdapter.sol"; +import { V3DexAdapter } from "../../src/provider/v3/V3DexAdapter.sol"; +import { SlisBNBV3ProviderOracle } from "../../src/provider/v3/SlisBNBV3ProviderOracle.sol"; +import { V3ProviderOracle } from "../../src/provider/v3/V3ProviderOracle.sol"; +import { IStakeManager } from "../../src/provider/interfaces/IStakeManager.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, TokenConfig } from "moolah/interfaces/IOracle.sol"; +import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; +import { IV3PoolMinimal } from "../../src/provider/interfaces/IV3PoolMinimal.sol"; + +/// @dev Minimal resilient-oracle mock: 8-decimal USD prices, settable per token. +contract MockOracle is IOracle { + mapping(address => uint256) public price; + + function setPrice(address token, uint256 value) external { + price[token] = value; + } + + function peek(address token) external view returns (uint256) { + return price[token]; + } + + function getTokenConfig(address) external pure returns (TokenConfig memory c) { + return c; + } +} + +/// @dev Executes a direct pool swap and satisfies the PancakeSwap V3 callback (to manipulate price). +contract PoolSwapper { + uint160 internal constant MIN_SQRT_RATIO = 4295128739; + uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; + + function swapExactIn(address pool, bool zeroForOne, uint256 amountIn) external { + uint160 limit = zeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1; + IListaV3Pool(pool).swap(address(this), zeroForOne, int256(amountIn), limit, abi.encode(pool)); + } + + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + _pay(amount0Delta, amount1Delta, data); + } + + /// @dev PancakeSwap V3 pools invoke this callback name (not the Uniswap one); support both. + function pancakeV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + _pay(amount0Delta, amount1Delta, data); + } + + function _pay(int256 amount0Delta, int256 amount1Delta, bytes calldata data) internal { + address pool = abi.decode(data, (address)); + if (amount0Delta > 0) IERC20(IListaV3Pool(pool).token0()).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) IERC20(IListaV3Pool(pool).token1()).transfer(msg.sender, uint256(amount1Delta)); + } +} + +/// @dev Stand-in StakeManager. The live implementation at this fork block predates `instantWithdraw` +/// (the real-time slisBNB→BNB redeem the rebalance inventory conversion relies on), so we etch this +/// faithful mock at the StakeManager address. It mirrors deposit()/instantWithdraw()/convert* at a +/// fixed rate (seeded from the live rate) and performs real BNB↔slisBNB transfers, so the +/// balance-delta accounting in the rebalance inventory conversion is exercised as it will be in prod. +contract MockStakeManager { + uint256 public immutable rate; // BNB per slisBNB, 1e18 + address public immutable slisBnb; + + constructor(uint256 _rate, address _slisBnb) { + rate = _rate; + slisBnb = _slisBnb; + } + + function convertSnBnbToBnb(uint256 amount) external view returns (uint256) { + return (amount * rate) / 1e18; + } + + function convertBnbToSnBnb(uint256 amount) external view returns (uint256) { + return (amount * 1e18) / rate; + } + + /// @notice Stake BNB → slisBNB (mint emulated by transferring from this mock's pre-funded balance). + function deposit() external payable { + uint256 out = (msg.value * 1e18) / rate; + IERC20(slisBnb).transfer(msg.sender, out); + } + + /// @notice Real-time redeem slisBNB → BNB at the on-chain rate. Matches IStakeManager (returns BNB out). + function instantWithdraw(uint256 amount) external returns (uint256 bnbAmount) { + IERC20(slisBnb).transferFrom(msg.sender, address(this), amount); + bnbAmount = (amount * rate) / 1e18; + (bool ok, ) = msg.sender.call{ value: bnbAmount }(""); + require(ok, "bnb send failed"); + } + + receive() external payable {} +} + +/// @notice Rate-path integration tests for the slisBNB/BNB V3 LP topology (3-contract split: +/// SlisBNBV3DexAdapter + SlisBNBV3Provider vault + SlisBNBV3ProviderOracle), forked against the +/// live PancakeSwap V3 slisBNB/WBNB 1bp pool + the real slisBNB StakeManager. Verifies the +/// exchange-rate oracle (providerOracle.peek / vault.totalAssets / vault.getUserBalanceInBnb) is +/// invariant to pool-price manipulation, and that the custom slisBNB/BNB rebalance entry point +/// (vault → adapter) runs end-to-end. +/// +/// @dev Pancake-stand-in caveats handled by this harness (the production target is a real Lista V3 +/// slisBNB/BNB pool): (1) the adapter reads slot0 via {IV3PoolMinimal} so Pancake's uint32 +/// `feeProtocol` packing doesn't break the uint8-typed full-tuple decode; (2) the {PoolSwapper} +/// implements `pancakeV3SwapCallback`; (3) a {MockStakeManager} is etched at the StakeManager +/// address because the live impl at this block predates `instantWithdraw`. +contract SlisBNBV3ProviderRateTest is Test { + using MarketParamsLib for MarketParams; + + /* live slisBNB/WBNB 1bp PancakeSwap V3 pool (stand-in for the not-yet-created Lista V3 pool) */ + address constant POOL = 0xe1B404Aaf60eEc5c8A1FEDE7dcDC0EAb9C69662F; + address constant NPM = 0x46A15B0b27311cedF172AB29E4f4766fbE7F4364; // canonical Pancake V3 NPM (factory 0x0BFbCF) + uint24 constant FEE = 100; + + address constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; // token0 + address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; // token1 + address constant STAKE_MANAGER = 0x1adB950d8bB3dA4bE104211D5AB038628e477fE6; + address constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + address constant MOOLAH_PROXY = 0x8F73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C; + address constant TIMELOCK = 0x07D274a68393E8b8a2CCf19A2ce4Ba3518735253; + address constant OPERATOR = 0xd7e38800201D6a42C408Bf79d8723740C4E7f631; + address constant MANAGER_ADDR = 0x8d388136d578dCD791D081c6042284CED6d9B0c6; + address constant LISUSD = 0x0782b6d8c4551B9760e74c0545a9bCD90bdc41E5; + address constant IRM = 0xFe7dAe87Ebb11a7BEB9F534BB23267992d9cDe7c; + + uint32 constant TWAP_PERIOD = 1800; + uint256 constant LLTV = 70 * 1e16; + uint256 constant BNB_USD = 600e8; // mock BNB price, 8 decimals + + Moolah moolah; + SlisBNBV3DexAdapter adapter; + SlisBNBV3Provider provider; + SlisBNBV3ProviderOracle providerOracle; + MockOracle oracle; + PoolSwapper swapper; + MarketParams marketParams; + Id marketId; + + address admin = makeAddr("admin"); + address manager = makeAddr("manager"); + address bot = makeAddr("bot"); + address user = makeAddr("user"); + + function setUp() public { + vm.createSelectFork(vm.envString("BSC_RPC"), 60541406); + emit log_named_uint("gas_at_start", gasleft()); + + // Upgrade Moolah to the local implementation (the deployed impl at this block predates the + // current setProvider/provider wiring used by the split topology). + address newMoolahImpl = address(new Moolah()); + vm.prank(TIMELOCK); + UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newMoolahImpl, bytes("")); + + // Mock resilient oracle: WBNB = BNB price; slisBNB = BNB price × exchange rate (OracleAdaptor-style). + oracle = new MockOracle(); + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + oracle.setPrice(WBNB, BNB_USD); + oracle.setPrice(BNB_ADDRESS, BNB_USD); + oracle.setPrice(SLISBNB, (BNB_USD * rate) / 1e18); + + // Etch a faithful StakeManager stand-in (same rate) so `instantWithdraw` — absent on the live + // impl at this block — exists for the rebalance inventory conversion. Fund it on both legs. + MockStakeManager mockSm = new MockStakeManager(rate, SLISBNB); + vm.etch(STAKE_MANAGER, address(mockSm).code); + vm.deal(STAKE_MANAGER, 1_000_000 ether); + deal(SLISBNB, STAKE_MANAGER, 1_000_000 ether); + + // Deploy the (large) topology contracts FIRST, while setUp gas is untouched — forge's setUp gas + // forwarding chokes on the big code deposits if other deploys run before them. Order: adapter, + // then vault (depends on adapter), then oracle (depends on adapter + vault). + + // 1) DEX adapter (NFT custodian + rate/rebalance logic). + SlisBNBV3DexAdapter adapterImpl = new SlisBNBV3DexAdapter(NPM, SLISBNB, WBNB, FEE, TWAP_PERIOD); + adapter = SlisBNBV3DexAdapter( + payable(new ERC1967Proxy(address(adapterImpl), abi.encodeCall(SlisBNBV3DexAdapter.initialize, (admin, manager)))) + ); + + // 2) Vault (ERC-4626 shares + Moolah wiring). accountingAsset = WBNB for these pools. + SlisBNBV3Provider provImpl = new SlisBNBV3Provider(MOOLAH_PROXY, address(adapter)); + provider = SlisBNBV3Provider( + payable( + new ERC1967Proxy( + address(provImpl), + abi.encodeCall( + SlisBNBV3Provider.initialize, + (admin, manager, bot, address(oracle), WBNB, "slisBNB/BNB vLP", "vLP-slisBNB-BNB") + ) + ) + ) + ); + + // 3) Wire adapter -> vault (one-time, admin). + vm.prank(admin); + adapter.setProvider(address(provider)); + + // 4) Oracle (Moolah market.oracle; prices the share off the adapter's fair view). + SlisBNBV3ProviderOracle oracleImpl = new SlisBNBV3ProviderOracle( + address(adapter), + address(provider), + SLISBNB, + WBNB + ); + providerOracle = SlisBNBV3ProviderOracle( + payable( + new ERC1967Proxy( + address(oracleImpl), + abi.encodeCall(V3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + ) + ) + ); + + moolah = Moolah(MOOLAH_PROXY); + + swapper = new PoolSwapper(); + emit log_named_uint("gas_after_swapper", gasleft()); + + assertEq(adapter.lastCenterRate(), rate, "lastCenterRate initialized from StakeManager"); + assertEq(adapter.centerRateThresholdBps(), 100, "default center-rate threshold is 1%"); + assertEq(provider.asset(), WBNB, "accounting asset"); + assertEq(provider.accountingAssetDecimals(), 18, "accounting asset decimals"); + + marketParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(providerOracle), + irm: IRM, + lltv: LLTV + }); + marketId = marketParams.id(); + + vm.prank(OPERATOR); + moolah.createMarket(marketParams); + vm.prank(MANAGER_ADDR); + moolah.setProvider(marketId, address(provider), true); + } + + function _deposit(uint256 amtSlis, uint256 amtWbnb) internal returns (uint256 shares) { + deal(SLISBNB, user, amtSlis); + deal(WBNB, user, amtWbnb); + (, uint256 e0, uint256 e1) = provider.previewDepositAmounts(amtSlis, amtWbnb); + vm.startPrank(user); + IERC20(SLISBNB).approve(address(provider), amtSlis); + IERC20(WBNB).approve(address(provider), amtWbnb); + (shares, , ) = provider.deposit(marketParams, amtSlis, amtWbnb, (e0 * 99) / 100, (e1 * 99) / 100, user); + vm.stopPrank(); + } + + /// @dev Big WBNB->slisBNB swap to push pool price far, then warp time (so a TWAP would also move). + function _manipulatePoolUp(uint256 amountIn) internal { + deal(WBNB, address(swapper), amountIn); + swapper.swapExactIn(POOL, false, amountIn); // token1 (WBNB) in → price up + vm.warp(block.timestamp + 3600); + } + + /* ─────────────────── exchange-rate oracle: invariance ─────────────────── */ + + function test_peek_usesRate_invariantToPoolManipulation() public { + _deposit(10 ether, 10 ether); + + uint256 peekBefore = providerOracle.peek(address(provider)); + (uint256 s0Before, uint256 s1Before) = provider.getTotalAmounts(); // slot0-based, for contrast + + int24 tickBefore = _tick(); + _manipulatePoolUp(20_000 ether); + int24 tickAfter = _tick(); + + uint256 peekAfter = providerOracle.peek(address(provider)); + (uint256 s0After, uint256 s1After) = provider.getTotalAmounts(); + + // sanity: the pool price actually moved a lot + assertGt(tickAfter - tickBefore, 100, "pool tick should move materially"); + // contrast: the slot0-based composition shifted materially... + assertTrue(s0After != s0Before || s1After != s1Before, "slot0 composition should shift"); + // ...but the rate-based collateral price is invariant (only tiny fee accrual on our position). + assertApproxEqRel(peekAfter, peekBefore, 1e16, "peek must be invariant to pool price (<=1%)"); + assertGt(peekBefore, 0, "peek should be non-zero"); + } + + function test_getUserBalanceInBnb_invariantToPoolManipulation() public { + _deposit(10 ether, 10 ether); + provider.syncUserBalance(marketId, user); // record deposit tracking + + uint256 bnbBefore = provider.getUserBalanceInBnb(user); + _manipulatePoolUp(20_000 ether); + uint256 bnbAfter = provider.getUserBalanceInBnb(user); + + assertGt(bnbBefore, 0, "should have a BNB-denominated balance"); + assertApproxEqRel(bnbAfter, bnbBefore, 1e16, "getUserBalanceInBnb must track rate, not pool"); + } + + function test_totalAssets_invariantToPoolManipulation() public { + _deposit(10 ether, 10 ether); + + uint256 taBefore = provider.totalAssets(); + _manipulatePoolUp(20_000 ether); + uint256 taAfter = provider.totalAssets(); + + assertGt(taBefore, 0, "totalAssets should be non-zero"); + assertApproxEqRel(taAfter, taBefore, 1e16, "totalAssets (WBNB) must track rate, not pool"); + } + + function test_peek_doesNotRevert_withoutTwapHistory() public { + // The pool has observationCardinality == 1, so the base TWAP path would revert on observe(). + // The rate path must not depend on it. + _deposit(10 ether, 10 ether); + uint256 p = providerOracle.peek(address(provider)); + assertGt(p, 0, "rate-based peek works even without TWAP history"); + } + + /* ───────────────────── custom slisBNB/BNB rebalance ───────────────────── */ + + function test_rebalance_recentersToRateDerivedRange() public { + _deposit(10 ether, 10 ether); + uint256 peekBefore = providerOracle.peek(address(provider)); + uint256 oldTokenId = adapter.tokenId(); + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, ""); + + assertGt(adapter.tokenId(), oldTokenId, "position should be re-minted"); + assertLt(adapter.tickLower(), adapter.tickUpper(), "rate-derived range should be valid"); + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 2e16, "rebalance is ~value-neutral"); + assertEq(adapter.lastCenterRate(), IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18), "center rate updated"); + } + + function test_rebalance_revertsWhenCenterRateDeviationBelowThreshold() public { + _deposit(10 ether, 10 ether); + + vm.prank(bot); + vm.expectRevert(V3DexAdapter.RateDeviationBelowThreshold.selector); + provider.rebalance(0, 0, 0, block.timestamp, ""); + } + + function test_rebalance_revertsAfterDeadline() public { + _deposit(10 ether, 10 ether); + + vm.prank(bot); + vm.expectRevert(V3DexAdapter.DeadlineExpired.selector); + provider.rebalance(0, 0, 0, block.timestamp - 1, ""); + } + + function test_rebalance_revertsWhenMinLiquidityTooHigh() public { + _deposit(10 ether, 10 ether); + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + vm.prank(bot); + vm.expectRevert(V3DexAdapter.InsufficientLiquidityMinted.selector); + provider.rebalance(0, 0, type(uint256).max, block.timestamp, ""); + } + + function _tick() internal view returns (int24 tick) { + (, tick) = IV3PoolMinimal(POOL).slot0(); + } +} diff --git a/test/provider/V3DexAdapterDecimals.t.sol b/test/provider/V3DexAdapterDecimals.t.sol new file mode 100644 index 00000000..110880ce --- /dev/null +++ b/test/provider/V3DexAdapterDecimals.t.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import { FullMath } from "lista-dao-contracts/oracle/libraries/FullMath.sol"; +import { V3DexAdapter } from "../../src/provider/v3/V3DexAdapter.sol"; + +/// @dev Test-only concrete adapter exposing the internal rate→sqrtPrice math. The wrapped-native is the +/// 18-dec token1; the paired token0 may have any decimals — this verifies the conversion accounts +/// for the decimal difference (not just 18/18 pairs). +contract DecimalProbeAdapter is V3DexAdapter { + constructor(address npm, address t0, address t1, uint24 fee, uint32 twap) V3DexAdapter(npm, t0, t1, fee, twap, t1) {} + + function sqrtPriceFromRate(uint256 rate) external view returns (uint160) { + return _sqrtPriceX96FromRate(rate); + } +} + +/// @notice Unit tests for V3DexAdapter._sqrtPriceX96FromRate's decimal handling, against real Uniswap V3 +/// pools so the constructor's pool/decimals reads are genuine. Proves a non-18-decimal paired +/// token (USDC, 6-dec) is priced correctly, and that an 18/18 pair is unchanged. +contract V3DexAdapterDecimalsTest is Test { + address constant NPM = 0xC36442b4a4522E871399CD717aBDD847Ab11FE88; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // 18-dec (token1 / wrapped-native) + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // 6-dec (token0) + address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; // 18-dec (token0) + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC"), 23566432); + } + + /// @dev Implied RAW price token1/token0 scaled by 1e18 = (sqrtP/2^96)^2 · 1e18 (two-step to avoid overflow). + function _impliedPriceX18(uint160 sqrtP) internal pure returns (uint256) { + uint256 priceX96 = FullMath.mulDiv(uint256(sqrtP), uint256(sqrtP), 1 << 96); // rawPrice · 2^96 + return FullMath.mulDiv(priceX96, 1e18, 1 << 96); // rawPrice · 1e18 + } + + /// @notice USDC(6)/WETH(18): the raw price must be scaled by 10^(18-6) vs the human rate. + function test_sqrtPriceFromRate_nonEqualDecimals() public { + DecimalProbeAdapter a = new DecimalProbeAdapter(NPM, USDC, WETH, 500, 1800); + assertEq(a.DECIMALS0(), 6, "USDC 6 dec"); + assertEq(a.DECIMALS1(), 18, "WETH 18 dec"); + + uint256 rate = 1e18; // 1 whole USDC priced at 1 whole WETH (arbitrary; just exercising the math) + // RAW price token1/token0 = (rate/1e18)·10^(18-6) ⇒ priceX18 = rate·10^12. + assertApproxEqRel(_impliedPriceX18(a.sqrtPriceFromRate(rate)), rate * (10 ** 12), 1e12, "scaled by 10^12"); + + // Linear in the rate. + assertApproxEqRel(_impliedPriceX18(a.sqrtPriceFromRate(3e18)), 3e18 * (10 ** 12), 1e12, "tracks rate"); + } + + /// @notice wstETH(18)/WETH(18): equal decimals ⇒ factor 10^0 = 1 ⇒ raw price == rate/1e18 (no change). + function test_sqrtPriceFromRate_equalDecimals_unchanged() public { + DecimalProbeAdapter a = new DecimalProbeAdapter(NPM, WSTETH, WETH, 100, 1800); + assertEq(a.DECIMALS0(), 18); + assertEq(a.DECIMALS1(), 18); + + uint256 rate = 12e17; // 1.2 + assertApproxEqRel(_impliedPriceX18(a.sqrtPriceFromRate(rate)), rate, 1e12, "18/18: priceX18 == rate"); + } +} diff --git a/test/provider/V3ProviderReentrancyPoC.t.sol b/test/provider/V3ProviderReentrancyPoC.t.sol new file mode 100644 index 00000000..b0aa7b83 --- /dev/null +++ b/test/provider/V3ProviderReentrancyPoC.t.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IMoolah, MarketParams, Id, Position } from "moolah/interfaces/IMoolah.sol"; +import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; +import { IV3Provider } from "../../src/provider/interfaces/IV3Provider.sol"; + +import { SlisBNBV3ProviderTest } from "./SlisBNBV3Provider.t.sol"; + +/** + * @title V3Provider C-1 regression — deposit-refund reentrancy must NOT inflate the share oracle + * @notice C-1 (Critical): V3Provider.deposit forwarded tokens to the adapter and called addLiquidity + * — which refunded unused WBNB via a native-BNB call to the DEPOSITOR — BEFORE minting and + * supplying the new shares. During that refund callback: + * - adapter NAV already included the freshly-added liquidity, but + * - totalSupply() was still the OLD supply (shares minted later), + * so SlisBNBV3ProviderOracle.peek(share) reported a transiently inflated price. Moolah was NOT + * locked at that point (supplyCollateral ran later), so the attacker reentered Moolah.borrow + * and over-borrowed against its pre-existing collateral, leaving bad debt once price normalized. + * + * Fix: the adapter now refunds to the VAULT, and the vault forwards the refund to the depositor + * only AFTER _mint + supplyCollateral. No external (native-BNB) call happens while NAV and + * totalSupply are inconsistent, so the oracle can never be transiently inflated. + * + * This test still drives the full attack (deposit -> refund reentry -> borrow), but now asserts + * the attack is NEUTRALIZED: the price read during the refund callback equals the normal price, + * and the attacker's position stays healthy (no bad debt). It FAILS against the pre-fix code + * (26x inflation, insolvent attacker) and PASSES against the fixed code. + */ +contract V3ProviderReentrancyPoC is SlisBNBV3ProviderTest { + Attacker attacker; + address constant victimB = address(0xB0B); // receives the big deposit's collateral (recoverable) + + function test_C1_depositRefundReentrancy_neutralized() public { + // 1) Bootstrap a small shared position so supply > 0. + _deposit(user, 1 ether, 1 ether); + + // 2) Deploy the attacker and give it a small pre-existing collateral position in the market. + attacker = new Attacker(address(provider), MOOLAH_PROXY, address(providerOracle), SLISBNB, WBNB, LISUSD); + attacker.setMarket(marketParams); + + deal(SLISBNB, address(attacker), 1 ether); + deal(WBNB, address(attacker), 1 ether); + attacker.predeposit(1 ether, 1 ether); // attacker now holds collateral, priced at the normal rate + + uint256 colA = _collateral(address(attacker)); + assertGt(colA, 0, "attacker has pre-existing collateral"); + + uint256 peekNormal = providerOracle.peek(address(provider)); + + // 3) Fund the big imbalanced deposit: lots of WBNB so a large WBNB refund fires the native + // callback, while still adding large liquidity (so NAV jumps). + uint256 bigSlis = 50 ether; + uint256 bigWbnb = 150 ether; // ~100 used + ~50 refunded + deal(SLISBNB, address(attacker), bigSlis); + deal(WBNB, address(attacker), bigWbnb); + + // 4) Execute the attack: deposit (onBehalf = victimB) -> refund reentry -> borrow. + attacker.attack(bigSlis, bigWbnb, victimB); + + uint256 peekDuring = attacker.peekDuring(); + uint256 peekAfter = providerOracle.peek(address(provider)); + + emit log_named_uint("peek normal (pre-attack)", peekNormal); + emit log_named_uint("peek DURING refund reentry", peekDuring); + emit log_named_uint("peek after deposit settles", peekAfter); + + // ── Proofs the attack is neutralized ──────────────────────────────────── + // (a) The refund callback fires AFTER mint+supply, so NAV and totalSupply are consistent: the + // price observed mid-deposit must match the settled/normal price (no transient inflation). + assertApproxEqRel(peekDuring, peekNormal, 1e16, "peek during refund must equal normal price (<=1%)"); + assertApproxEqRel(peekDuring, peekAfter, 1e16, "peek during refund must equal settled price (<=1%)"); + + // (b) Because the price was never inflated, any reentrant borrow was fairly priced: the attacker's + // position is solvent — no bad debt is left to the protocol. + assertTrue( + moolah.isHealthy(marketParams, marketId, address(attacker)), + "attacker position must remain solvent (no bad debt)" + ); + + // (c) The deposit itself still works: the big collateral landed on the onBehalf account. + assertGt(_collateral(victimB), colA * 10, "deposit still credits onBehalf collateral"); + } +} + +/// @dev Malicious depositor: reenters Moolah.borrow from its receive() during the WBNB refund. +contract Attacker { + using MarketParamsLib for MarketParams; + + IV3Provider public immutable provider; + IMoolah public immutable moolah; + IOracle public immutable oracle; + address public immutable slis; + address public immutable wbnb; + address public immutable lisusd; + + MarketParams public mp; + bool armed; + uint256 public peekDuring; + + constructor(address _provider, address _moolah, address _oracle, address _slis, address _wbnb, address _lisusd) { + provider = IV3Provider(_provider); + moolah = IMoolah(_moolah); + oracle = IOracle(_oracle); + slis = _slis; + wbnb = _wbnb; + lisusd = _lisusd; + } + + function setMarket(MarketParams calldata _mp) external { + mp = _mp; + } + + /// @dev Build a normally-priced collateral position (armed == false, so receive() is inert). + function predeposit(uint256 a0, uint256 a1) external { + IERC20(slis).approve(address(provider), type(uint256).max); + IERC20(wbnb).approve(address(provider), type(uint256).max); + provider.deposit(mp, a0, a1, 0, 0, address(this)); + } + + /// @dev The malicious deposit. onBehalf = a separate (recoverable) account. + function attack(uint256 a0, uint256 a1, address onBehalf) external { + armed = true; + provider.deposit(mp, a0, a1, 0, 0, onBehalf); + armed = false; + } + + /// @dev WBNB refund is unwrapped and delivered here as native BNB mid-deposit -> attempt reentry. + receive() external payable { + if (!armed) return; + armed = false; // single shot + + peekDuring = oracle.peek(address(provider)); // post-fix: equals the normal (un-inflated) price + + // Attempt to borrow ~60% of this account's collateral value at the observed price. Wrapped in + // try/catch so the deposit completes regardless of whether the borrow succeeds (it will, but at a + // fair price post-fix, leaving a healthy position) or reverts. + Position memory p = moolah.position(mp.id(), address(this)); + uint256 col = p.collateral; + uint256 sharePrice = peekDuring; // 8-dec USD per 1e18 share + uint256 loanPrice = oracle.peek(lisusd); // ~1e8 + uint256 borrowAssets = (col * sharePrice * 60) / (loanPrice * 100); + + try moolah.borrow(mp, borrowAssets, 0, address(this), address(this)) {} catch {} + } +} diff --git a/test/provider/WbETHV3Provider.t.sol b/test/provider/WbETHV3Provider.t.sol new file mode 100644 index 00000000..6519e470 --- /dev/null +++ b/test/provider/WbETHV3Provider.t.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { FullMath } from "lista-dao-contracts/oracle/libraries/FullMath.sol"; + +import { WbETHV3Provider } from "../../src/provider/v3/WbETHV3Provider.sol"; +import { WbETHV3DexAdapter } from "../../src/provider/v3/WbETHV3DexAdapter.sol"; +import { V3DexAdapter } from "../../src/provider/v3/V3DexAdapter.sol"; +import { V3ProviderOracle } from "../../src/provider/v3/V3ProviderOracle.sol"; +import { IWbETH } from "../../src/provider/interfaces/IWbETH.sol"; +import { IOracle, TokenConfig } from "moolah/interfaces/IOracle.sol"; + +/// @dev Minimal resilient-oracle mock: 8-decimal USD prices, settable per token. +contract MockOracle is IOracle { + mapping(address => uint256) public price; + + function setPrice(address token, uint256 value) external { + price[token] = value; + } + + function peek(address token) external view returns (uint256) { + return price[token]; + } + + function getTokenConfig(address) external pure returns (TokenConfig memory c) { + return c; + } +} + +/// @notice Ethereum fork tests for the wbETH/WETH V3 LP topology (WbETHV3DexAdapter + WbETHV3Provider +/// + generic V3ProviderOracle). The mechanism is identical to wstETH/WETH (shared base + +/// SwapInventoryLib), so the deposit / withdraw / redeem / swap-rebalance PATH is validated by +/// WstETHV3Provider.t.sol; these tests cover the wbETH-specific wiring that does NOT need a deep +/// pool — rate source (exchangeRate), pair guard, pure-rate valuation, oracle, and config. +/// +/// @dev No deep wbETH/WETH AMM exists on Ethereum (the only Uniswap V3 pool, 0.3%, is empty), so +/// functional deposit/rebalance fork tests await a seeded Lista pool; the rebalance swap venue is +/// backend-built calldata against a whitelisted pair, validated end-to-end by WstETHV3Provider.t.sol. +contract WbETHV3ProviderTest is Test { + /* the (empty) Uniswap V3 wbETH/WETH 0.3% pool — used only to satisfy the adapter's pool wiring */ + address constant POOL = 0xFEBf58c2E1bBaBE298A9E5EC099385a4B641AE18; + address constant NPM = 0xC36442b4a4522E871399CD717aBDD847Ab11FE88; + uint24 constant FEE = 3000; + + address constant WBETH = 0xa2E3356610840701BDf5611a53974510Ae27E2e1; // token0 + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // token1 + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + address constant MOOLAH_PROXY = 0xf820fB4680712CD7263a0D3D024D5b5aEA82Fd70; + + uint32 constant TWAP_PERIOD = 1800; + uint256 constant ETH_USD = 3000e8; // mock ETH price, 8 decimals + + WbETHV3DexAdapter adapter; + WbETHV3Provider provider; + V3ProviderOracle providerOracle; + MockOracle oracle; + + address admin = makeAddr("admin"); + address manager = makeAddr("manager"); + address bot = makeAddr("bot"); + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC"), 23566432); + + // Mock resilient oracle: WETH = ETH price; wbETH = ETH price × exchangeRate (rate-derived). USDC = $1. + oracle = new MockOracle(); + uint256 rate = IWbETH(WBETH).exchangeRate(); + oracle.setPrice(WETH, ETH_USD); + oracle.setPrice(WBETH, (ETH_USD * rate) / 1e18); + oracle.setPrice(USDC, 1e8); + + WbETHV3DexAdapter adapterImpl = new WbETHV3DexAdapter(NPM, WBETH, WETH, FEE, TWAP_PERIOD); + adapter = WbETHV3DexAdapter( + payable(new ERC1967Proxy(address(adapterImpl), abi.encodeCall(WbETHV3DexAdapter.initialize, (admin, manager)))) + ); + + WbETHV3Provider provImpl = new WbETHV3Provider(MOOLAH_PROXY, address(adapter)); + provider = WbETHV3Provider( + payable( + new ERC1967Proxy( + address(provImpl), + abi.encodeCall( + WbETHV3Provider.initialize, + (admin, manager, bot, address(oracle), WETH, "wbETH/WETH vLP", "vLP-wbETH-WETH") + ) + ) + ) + ); + + vm.prank(admin); + adapter.setProvider(address(provider)); + + V3ProviderOracle oracleImpl = new V3ProviderOracle(address(adapter), address(provider), WBETH, WETH); + providerOracle = V3ProviderOracle( + payable( + new ERC1967Proxy( + address(oracleImpl), + abi.encodeCall(V3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + ) + ) + ); + } + + /* ───────────────────────────── tests ────────────────────────────── */ + + function test_initialize() public view { + assertEq(adapter.TOKEN0(), WBETH); + assertEq(adapter.TOKEN1(), WETH); + assertEq(adapter.WRAPPED_NATIVE(), WETH); + assertEq(adapter.FEE(), FEE); + assertEq(adapter.POOL(), POOL); + assertEq(adapter.maxTwapDeviationBps(), 100, "TWAP clamp band defaults to range width"); + assertEq(adapter.centerRateThresholdBps(), 100, "default threshold 1%"); + // rate wiring: the center rate is wbETH.exchangeRate(), not stEthPerToken or pool price. + assertEq(adapter.lastCenterRate(), IWbETH(WBETH).exchangeRate(), "center rate from exchangeRate"); + assertEq(adapter.provider(), address(provider)); + assertEq(provider.asset(), WETH, "accounting asset"); + assertEq(providerOracle.TOKEN0(), WBETH); + assertEq(providerOracle.TOKEN1(), WETH); + } + + function test_constructor_revertsWrongPair() public { + // USDC/WETH 0.3% pool exists and is correctly ordered, so the base ordering + pool-existence + // checks pass; only the wbETH/WETH pair guard rejects it. + vm.expectRevert(WbETHV3DexAdapter.NotWbEthWethPair.selector); + new WbETHV3DexAdapter(NPM, USDC, WETH, FEE, TWAP_PERIOD); + } + + /// @notice Pure-rate valuation (band = 0) reflects wbETH.exchangeRate() and needs no pool TWAP — so it + /// works even against the empty wbETH/WETH pool (the bootstrap mode for a fresh Lista pool). + function test_fairSqrtPrice_pureRate_matchesExchangeRate() public { + vm.prank(manager); + adapter.setMaxTwapDeviationBps(0); + + uint160 sp = adapter.fairSqrtPriceX96(); + assertGt(sp, 0, "pure-rate fair price non-zero (no observe dependency)"); + + // (sqrtP / 2^96)^2 ≈ WETH-per-wbETH ≈ exchangeRate. + uint256 impliedRate = FullMath.mulDiv(uint256(sp) * uint256(sp), 1e18, 1 << 192); + assertApproxEqRel(impliedRate, IWbETH(WBETH).exchangeRate(), 1e15, "fair price tracks exchangeRate"); + } + + /// @notice The oracle delegates any non-share token to the resilient oracle (wbETH priced rate-derived). + function test_oracle_delegatesNonShareToken() public view { + assertEq(providerOracle.peek(WBETH), (ETH_USD * IWbETH(WBETH).exchangeRate()) / 1e18, "wbETH price delegated"); + assertEq(providerOracle.peek(WETH), ETH_USD, "WETH price delegated"); + } + + /* ─────────────────────── access control / config ─────────────────────── */ + + function test_setSwapPairWhitelist_onlyManager() public { + vm.expectRevert(); + adapter.setSwapPairWhitelist(address(0xBEEF), true); + + vm.prank(manager); + adapter.setSwapPairWhitelist(address(0xBEEF), true); + assertTrue(adapter.swapPairWhitelist(address(0xBEEF))); + + vm.prank(manager); + adapter.setSwapPairWhitelist(address(0xBEEF), false); + assertFalse(adapter.swapPairWhitelist(address(0xBEEF))); + } + + function test_setSwapPairWhitelist_zeroReverts() public { + vm.prank(manager); + vm.expectRevert(V3DexAdapter.ZeroAddress.selector); + adapter.setSwapPairWhitelist(address(0), true); + } + + function test_setMaxTwapDeviationBps_capEnforced() public { + uint256 overCap = adapter.MAX_TWAP_DEVIATION_BPS() + 1; + vm.prank(manager); + vm.expectRevert(WbETHV3DexAdapter.InvalidDeviation.selector); + adapter.setMaxTwapDeviationBps(overCap); + + vm.prank(manager); + adapter.setMaxTwapDeviationBps(0); + assertEq(adapter.maxTwapDeviationBps(), 0, "clamp band settable to 0 (pure rate)"); + } +} diff --git a/test/provider/WstETHV3Provider.t.sol b/test/provider/WstETHV3Provider.t.sol new file mode 100644 index 00000000..d76e8bb4 --- /dev/null +++ b/test/provider/WstETHV3Provider.t.sol @@ -0,0 +1,543 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; + +import { WstETHV3Provider } from "../../src/provider/v3/WstETHV3Provider.sol"; +import { WstETHV3DexAdapter } from "../../src/provider/v3/WstETHV3DexAdapter.sol"; +import { V3DexAdapter } from "../../src/provider/v3/V3DexAdapter.sol"; +import { V3ProviderOracle } from "../../src/provider/v3/V3ProviderOracle.sol"; +import { IWstETH } from "../../src/provider/interfaces/IWstETH.sol"; +import { SwapInventoryLib } from "../../src/provider/libraries/SwapInventoryLib.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, TokenConfig } from "moolah/interfaces/IOracle.sol"; +import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; + +/// @dev Minimal resilient-oracle mock: 8-decimal USD prices, settable per token. +contract MockOracle is IOracle { + mapping(address => uint256) public price; + + function setPrice(address token, uint256 value) external { + price[token] = value; + } + + function peek(address token) external view returns (uint256) { + return price[token]; + } + + function getTokenConfig(address) external pure returns (TokenConfig memory c) { + return c; + } +} + +/// @dev Executes a direct Uniswap V3 pool swap (to manipulate the pool price) and pays the callback. +contract PoolSwapper { + uint160 internal constant MIN_SQRT_RATIO = 4295128739; + uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; + + function swapExactIn(address pool, bool zeroForOne, uint256 amountIn) external { + uint160 limit = zeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1; + IListaV3Pool(pool).swap(address(this), zeroForOne, int256(amountIn), limit, abi.encode(pool)); + } + + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + address pool = abi.decode(data, (address)); + if (amount0Delta > 0) IERC20(IListaV3Pool(pool).token0()).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) IERC20(IListaV3Pool(pool).token1()).transfer(msg.sender, uint256(amount1Delta)); + } +} + +/// @dev Minimal DEX-agnostic swap target standing in for the venue the BOT backend would route to: +/// pulls `amountIn` of tokenIn from the caller (the adapter, which has forceApproved it) and +/// sends a fixed `amountOut` of tokenOut to `to`. Pre-fund it with tokenOut via `deal`. +contract MockSwap { + function swap(address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, address to) external { + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + IERC20(tokenOut).transfer(to, amountOut); + } +} + +/// @notice Ethereum fork tests for the wstETH/WETH V3 LP topology (WstETHV3DexAdapter + WstETHV3Provider +/// + generic V3ProviderOracle), against the live Uniswap V3 wstETH/WETH 0.01% pool. Verifies: +/// the rate-implied oracle is invariant to pool-price manipulation; the backend-built rebalance +/// swap recenters value-neutrally through a whitelisted venue; and (the linchpin) the swap is +/// bounded by the backend-supplied amountOutMin and only allowed against a whitelisted pair. +contract WstETHV3ProviderTest is Test { + using MarketParamsLib for MarketParams; + + /* live Uniswap V3 wstETH/WETH 0.01% pool */ + address constant POOL = 0x109830a1AAaD605BbF02a9dFA7B0B92EC2FB7dAa; + address constant NPM = 0xC36442b4a4522E871399CD717aBDD847Ab11FE88; + uint24 constant FEE = 100; + + address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; // token0 + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // token1 + + address constant MOOLAH_PROXY = 0xf820fB4680712CD7263a0D3D024D5b5aEA82Fd70; + address constant MOOLAH_ADMIN = 0xa18ae79AEDA3e711E0CD64cfe1Cd06402d400D61; // admin timelock (DEFAULT_ADMIN) + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant IRM = 0x8b7d334d243b74D63C4b963893267A0F5240F990; + + bytes32 constant OPERATOR = keccak256("OPERATOR"); + bytes32 constant MOOLAH_MANAGER = keccak256("MANAGER"); + + uint32 constant TWAP_PERIOD = 1800; + uint256 constant LLTV = 86 * 1e16; + uint256 constant ETH_USD = 3000e8; // mock ETH price, 8 decimals + + Moolah moolah; + WstETHV3DexAdapter adapter; + WstETHV3Provider provider; + V3ProviderOracle providerOracle; + MockOracle oracle; + PoolSwapper swapper; + MockSwap mockSwap; + MarketParams marketParams; + Id marketId; + + address admin = makeAddr("admin"); + address manager = makeAddr("manager"); + address bot = makeAddr("bot"); + address user = makeAddr("user"); + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC"), 23566432); + + // Upgrade Moolah to the local implementation (keeps the split-topology wiring consistent with the + // current source regardless of the deployed impl at this block). + address newMoolahImpl = address(new Moolah()); + vm.prank(MOOLAH_ADMIN); + UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newMoolahImpl, bytes("")); + moolah = Moolah(MOOLAH_PROXY); + + // Mock resilient oracle: WETH = ETH price; wstETH = ETH price × stEthPerToken (rate-derived, like + // the live ResilientOracle). USDT = $1. + oracle = new MockOracle(); + uint256 rate = IWstETH(WSTETH).stEthPerToken(); + oracle.setPrice(WETH, ETH_USD); + oracle.setPrice(WSTETH, (ETH_USD * rate) / 1e18); + oracle.setPrice(USDT, 1e8); + + // 1) DEX adapter (NFT custodian + rate/rebalance logic). + WstETHV3DexAdapter adapterImpl = new WstETHV3DexAdapter(NPM, WSTETH, WETH, FEE, TWAP_PERIOD); + adapter = WstETHV3DexAdapter( + payable(new ERC1967Proxy(address(adapterImpl), abi.encodeCall(WstETHV3DexAdapter.initialize, (admin, manager)))) + ); + + // 2) Vault (ERC-4626 shares + Moolah wiring). accountingAsset = WETH. + WstETHV3Provider provImpl = new WstETHV3Provider(MOOLAH_PROXY, address(adapter)); + provider = WstETHV3Provider( + payable( + new ERC1967Proxy( + address(provImpl), + abi.encodeCall( + WstETHV3Provider.initialize, + (admin, manager, bot, address(oracle), WETH, "wstETH/WETH vLP", "vLP-wstETH-WETH") + ) + ) + ) + ); + + // 3) Wire adapter -> vault (one-time, admin). + vm.prank(admin); + adapter.setProvider(address(provider)); + + // 4) Oracle (Moolah market.oracle; prices the share off the adapter's rate-implied fair view). + V3ProviderOracle oracleImpl = new V3ProviderOracle(address(adapter), address(provider), WSTETH, WETH); + providerOracle = V3ProviderOracle( + payable( + new ERC1967Proxy( + address(oracleImpl), + abi.encodeCall(V3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + ) + ) + ); + + swapper = new PoolSwapper(); + + // DEX-agnostic swap stand-in: whitelist it so the backend-built rebalance swapData may target it. + mockSwap = new MockSwap(); + vm.prank(manager); + adapter.setSwapPairWhitelist(address(mockSwap), true); + + // Grant ourselves OPERATOR (createMarket) + MANAGER (setProvider) on the forked Moolah. + vm.startPrank(MOOLAH_ADMIN); + IAccessControl(MOOLAH_PROXY).grantRole(OPERATOR, address(this)); + IAccessControl(MOOLAH_PROXY).grantRole(MOOLAH_MANAGER, address(this)); + vm.stopPrank(); + + marketParams = MarketParams({ + loanToken: USDT, + collateralToken: address(provider), + oracle: address(providerOracle), + irm: IRM, + lltv: LLTV + }); + marketId = marketParams.id(); + + moolah.createMarket(marketParams); + moolah.setProvider(marketId, address(provider), true); + } + + /* ───────────────────────────── helpers ──────────────────────────── */ + + function _deposit(uint256 amtWst, uint256 amtWeth) internal returns (uint256 shares) { + deal(WSTETH, user, amtWst); + deal(WETH, user, amtWeth); + (, uint256 e0, uint256 e1) = provider.previewDepositAmounts(amtWst, amtWeth); + vm.startPrank(user); + IERC20(WSTETH).approve(address(provider), amtWst); + IERC20(WETH).approve(address(provider), amtWeth); + (shares, , ) = provider.deposit(marketParams, amtWst, amtWeth, (e0 * 99) / 100, (e1 * 99) / 100, user); + vm.stopPrank(); + } + + /// @dev Encode the backend rebalance blob the adapter decodes: (swapPair, sellToken0, amountIn, + /// amountOutMin, nativeIn, innerSwapData). nativeIn=false here (DEX venues; no native-in on ETH). + /// Empty blob ⇒ recenter without converting inventory. + function _swapData( + address swapPair, + bool sellToken0, + uint256 amountIn, + uint256 amountOutMin, + bytes memory inner + ) internal pure returns (bytes memory) { + return abi.encode(swapPair, sellToken0, amountIn, amountOutMin, false, inner); + } + + /// @dev Inner calldata the adapter low-level-calls on the whitelisted MockSwap: pull `amountIn` of + /// tokenIn from the adapter, send `amountOut` of tokenOut back to it. + function _mockInner( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOut + ) internal view returns (bytes memory) { + return abi.encodeCall(MockSwap.swap, (tokenIn, tokenOut, amountIn, amountOut, address(adapter))); + } + + /// @dev WETH->wstETH swap to push the pool price up (wstETH expensive in-pool). INSTANT — no time + /// passes, so the TWAP is (almost) unmoved and only slot0 shifts. + function _swapPoolUp(uint256 amountIn) internal { + deal(WETH, address(swapper), amountIn); + swapper.swapExactIn(POOL, false, amountIn); // token1 (WETH) in → price up + } + + /// @dev Sustained manipulation: swap, then warp past the TWAP window so the TWAP reflects it. + function _manipulatePoolUp(uint256 amountIn) internal { + _swapPoolUp(amountIn); + vm.warp(block.timestamp + 3600); + } + + /* ───────────────────────────── tests ────────────────────────────── */ + + function test_initialize() public view { + assertEq(adapter.TOKEN0(), WSTETH); + assertEq(adapter.TOKEN1(), WETH); + assertEq(adapter.WRAPPED_NATIVE(), WETH); + assertEq(adapter.FEE(), FEE); + assertEq(adapter.POOL(), POOL); + assertTrue(adapter.swapPairWhitelist(address(mockSwap)), "swap venue whitelisted in setUp"); + assertEq(adapter.maxTwapDeviationBps(), 100, "TWAP clamp band defaults to range width"); + assertEq(adapter.lastCenterRate(), IWstETH(WSTETH).stEthPerToken(), "center rate from stEthPerToken"); + assertEq(adapter.centerRateThresholdBps(), 100, "default threshold 1%"); + assertEq(adapter.provider(), address(provider)); + assertEq(provider.asset(), WETH, "accounting asset"); + assertEq(provider.WRAPPED_NATIVE(), WETH); + assertEq(providerOracle.TOKEN0(), WSTETH); + assertEq(providerOracle.TOKEN1(), WETH); + } + + function test_deposit_firstDeposit() public { + uint256 shares = _deposit(10 ether, 10 ether); + assertGt(shares, 0, "shares minted"); + (, , uint128 collateral) = moolah.position(marketId, user); + assertEq(collateral, shares, "collateral == shares supplied"); + assertGt(adapter.tokenId(), 0, "position minted"); + } + + function test_deposit_secondDeposit_sharesProportional() public { + _deposit(10 ether, 10 ether); + uint256 supplyBefore = provider.totalSupply(); + uint256 peekBefore = providerOracle.peek(address(provider)); + + uint256 shares2 = _deposit(10 ether, 10 ether); + // second equal deposit roughly doubles supply; per-share price stays ~constant + assertApproxEqRel(provider.totalSupply(), supplyBefore + shares2, 1e16); + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 2e16, "share price ~stable"); + } + + function test_withdraw() public { + uint256 shares = _deposit(10 ether, 10 ether); + uint256 wstBefore = IERC20(WSTETH).balanceOf(user); + uint256 wethBefore = IERC20(WETH).balanceOf(user); + + vm.prank(user); + (uint256 a0, uint256 a1) = provider.withdraw(marketParams, shares, 0, 0, user, user); + + assertGt(a0 + a1, 0, "received underlying"); + assertEq(IERC20(WSTETH).balanceOf(user), wstBefore + a0); + // WETH leg is unwrapped to native ETH by the adapter; user gets ETH, WETH balance unchanged. + assertEq(IERC20(WETH).balanceOf(user), wethBefore, "WETH leg paid as native ETH"); + (, , uint128 collateral) = moolah.position(marketId, user); + assertEq(collateral, 0, "collateral fully withdrawn"); + } + + function test_redeemShares_byHolder() public { + uint256 shares = _deposit(10 ether, 10 ether); + // move shares out of Moolah to the user so they hold them directly + vm.prank(user); + provider.withdrawShares(marketParams, shares, user, user); + assertEq(provider.balanceOf(user), shares); + + vm.prank(user); + (uint256 a0, uint256 a1) = provider.redeemShares(shares, 0, 0, user); + assertGt(a0 + a1, 0, "redeemed underlying"); + assertEq(provider.balanceOf(user), 0); + } + + /* ─────── LP oracle: TWAP clamped to rate — slot0-resistant + clamp-bounded ─────── */ + + /// @notice Priced off TWAP (not slot0): an INSTANT swap (no elapsed time) barely moves the TWAP, so + /// peek is unchanged even though the slot0/spot composition shifts hard. + function test_peek_resistsInstantManipulation() public { + _deposit(10 ether, 10 ether); + + uint256 peekBefore = providerOracle.peek(address(provider)); + (uint256 s0Before, ) = provider.getTotalAmounts(); // spot/slot0-based, for contrast + + _swapPoolUp(2000 ether); // instant, no warp + + (uint256 s0After, ) = provider.getTotalAmounts(); + assertTrue(s0After != s0Before, "spot composition shifts with slot0"); + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 1e16, "peek resists instant manipulation"); + assertGt(peekBefore, 0, "peek non-zero"); + } + + /// @notice The rate clamp bounds the valuation: with the band set to 0 the price is pinned to the + /// rate, so even a SUSTAINED (TWAP-moving) manipulation cannot move peek. + function test_peek_clampPinsToRateWhenBandZero() public { + _deposit(10 ether, 10 ether); + vm.prank(manager); + adapter.setMaxTwapDeviationBps(0); + + uint256 peekBefore = providerOracle.peek(address(provider)); + _manipulatePoolUp(2000 ether); // sustained: TWAP moves, but clamp=0 pins valuation to the rate + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 5e15, "clamp=0 pins valuation to rate"); + } + + /// @notice totalAssets (same valuation price) likewise resists instant manipulation. + function test_totalAssets_resistsInstantManipulation() public { + _deposit(10 ether, 10 ether); + uint256 taBefore = provider.totalAssets(); + _swapPoolUp(2000 ether); // instant + assertApproxEqRel(provider.totalAssets(), taBefore, 1e16, "totalAssets resists instant manipulation"); + } + + /// @notice Pure-rate mode (band = 0) skips the TWAP entirely — NO pool observe()/cardinality + /// dependency. peek works even when the pool cannot serve observations (e.g. a freshly + /// built Lista pool before cardinality is seeded), whereas the default band needs observe(). + function test_peek_pureRate_noObserveDependency() public { + _deposit(10 ether, 10 ether); + + // Simulate a pool with no TWAP history: every observe() call reverts. + vm.mockCallRevert(POOL, abi.encodeWithSignature("observe(uint32[])"), bytes("no observations")); + + // Default band (>0) reads the TWAP → peek reverts when observe() is unavailable. + vm.expectRevert(); + providerOracle.peek(address(provider)); + + // Pure-rate mode (band = 0) must not touch observe() → peek still works. + vm.prank(manager); + adapter.setMaxTwapDeviationBps(0); + assertGt(providerOracle.peek(address(provider)), 0, "pure-rate peek works without pool TWAP observations"); + } + + /* ───────────────────── swap-based rebalance ───────────────────── */ + + /// @notice Empty swapData ⇒ recenter only (no inventory conversion). Value-neutral. + function test_rebalance_recentersValueNeutral() public { + _deposit(10 ether, 10 ether); + uint256 peekBefore = providerOracle.peek(address(provider)); + uint256 oldTokenId = adapter.tokenId(); + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, ""); + + assertGt(adapter.tokenId(), oldTokenId, "position re-minted"); + assertLt(adapter.tickLower(), adapter.tickUpper(), "valid range"); + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 2e16, "rebalance ~value-neutral"); + assertEq(adapter.lastCenterRate(), IWstETH(WSTETH).stEthPerToken(), "center rate updated"); + } + + /// @notice Backend-built swapData routes the conversion through a whitelisted venue. A fair-rate swap + /// (wstETH→WETH at the LST rate) is value-neutral and the position is re-minted. + function test_rebalance_swapExecutesThroughWhitelistedVenue() public { + _deposit(10 ether, 10 ether); + uint256 peekBefore = providerOracle.peek(address(provider)); + uint256 oldTokenId = adapter.tokenId(); + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + // Sell 0.5 wstETH for WETH at the fair LST rate; fund the venue with the WETH it must pay out. + uint256 rate = IWstETH(WSTETH).stEthPerToken(); + uint256 amountIn = 0.5 ether; + uint256 fairOut = (amountIn * rate) / 1e18; + deal(WETH, address(mockSwap), fairOut); + + bytes memory inner = _mockInner(WSTETH, WETH, amountIn, fairOut); + bytes memory data = _swapData(address(mockSwap), true, amountIn, (fairOut * 99) / 100, inner); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, data); + + assertGt(adapter.tokenId(), oldTokenId, "position re-minted after swap"); + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 2e16, "fair swap ~value-neutral"); + } + + /// @notice Linchpin: the backend-supplied `amountOutMin` is enforced on the measured output. A venue + /// that under-delivers (returns less than amountOutMin) reverts the whole rebalance. + function test_rebalance_revertsBelowAmountOutMin() public { + _deposit(10 ether, 10 ether); + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + uint256 rate = IWstETH(WSTETH).stEthPerToken(); + uint256 amountIn = 0.5 ether; + uint256 fairOut = (amountIn * rate) / 1e18; + deal(WETH, address(mockSwap), fairOut); + + // Venue pays only half of fairOut, but the backend demanded the full fairOut as amountOutMin. + bytes memory inner = _mockInner(WSTETH, WETH, amountIn, fairOut / 2); + bytes memory data = _swapData(address(mockSwap), true, amountIn, fairOut, inner); + + vm.prank(bot); + vm.expectRevert(SwapInventoryLib.InsufficientOutput.selector); + provider.rebalance(0, 0, 0, block.timestamp, data); + } + + /// @notice The adapter only allows whitelisted swap venues; a non-whitelisted target reverts before + /// any call is made. + function test_rebalance_revertsNotWhitelistedPair() public { + _deposit(10 ether, 10 ether); + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + MockSwap rogue = new MockSwap(); // never whitelisted + bytes memory inner = _mockInner(WSTETH, WETH, 0.5 ether, 0.5 ether); + bytes memory data = _swapData(address(rogue), true, 0.5 ether, 0, inner); + + vm.prank(bot); + vm.expectRevert(V3DexAdapter.NotWhitelistedPair.selector); + provider.rebalance(0, 0, 0, block.timestamp, data); + } + + /* ─────────────────────── access control / config ─────────────────────── */ + + function test_rebalance_onlyBot() public { + _deposit(10 ether, 10 ether); + vm.expectRevert(); + provider.rebalance(0, 0, 0, block.timestamp, ""); + } + + function test_rebalance_revertsAfterDeadline() public { + _deposit(10 ether, 10 ether); + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + vm.prank(bot); + vm.expectRevert(V3DexAdapter.DeadlineExpired.selector); + provider.rebalance(0, 0, 0, block.timestamp - 1, ""); + } + + function test_setSwapPairWhitelist_onlyManager() public { + vm.expectRevert(); + adapter.setSwapPairWhitelist(address(0xBEEF), true); + + vm.prank(manager); + adapter.setSwapPairWhitelist(address(0xBEEF), true); + assertTrue(adapter.swapPairWhitelist(address(0xBEEF))); + + vm.prank(manager); + adapter.setSwapPairWhitelist(address(0xBEEF), false); + assertFalse(adapter.swapPairWhitelist(address(0xBEEF))); + } + + function test_setSwapPairWhitelist_zeroReverts() public { + vm.prank(manager); + vm.expectRevert(V3DexAdapter.ZeroAddress.selector); + adapter.setSwapPairWhitelist(address(0), true); + } + + /// @notice Defense-in-depth: a swap venue must never be the position's own tokens / pool / NPM, else + /// crafted swapData could move the adapter's inventory. Whitelisting them reverts. + function test_setSwapPairWhitelist_rejectsSensitiveAddresses() public { + address npm = address(adapter.POSITION_MANAGER()); + vm.startPrank(manager); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(WSTETH, true); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(WETH, true); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(POOL, true); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(npm, true); + vm.stopPrank(); + } + + function test_setMaxTwapDeviationBps_capEnforced() public { + uint256 overCap = adapter.MAX_TWAP_DEVIATION_BPS() + 1; + vm.prank(manager); + vm.expectRevert(WstETHV3DexAdapter.InvalidDeviation.selector); + adapter.setMaxTwapDeviationBps(overCap); + + vm.prank(manager); + adapter.setMaxTwapDeviationBps(0); + assertEq(adapter.maxTwapDeviationBps(), 0, "clamp band settable to 0 (pure rate)"); + } + + function test_constructor_revertsWrongPair() public { + // USDC/USDT 0.01% pool exists and is correctly ordered, so the base ordering + pool-existence + // checks pass; only the wstETH/WETH pair guard rejects it. + vm.expectRevert(WstETHV3DexAdapter.NotWstEthWethPair.selector); + new WstETHV3DexAdapter(NPM, USDC, USDT, FEE, TWAP_PERIOD); + } + + /* ─────────────────── wiring cross-validation (M4) ─────────────────── */ + + /// @dev A second, independent adapter for the same pair — `provider` is wired to `adapter`, not this. + function _freshAdapter() internal returns (WstETHV3DexAdapter) { + WstETHV3DexAdapter impl2 = new WstETHV3DexAdapter(NPM, WSTETH, WETH, FEE, TWAP_PERIOD); + return + WstETHV3DexAdapter( + payable(new ERC1967Proxy(address(impl2), abi.encodeCall(WstETHV3DexAdapter.initialize, (admin, manager)))) + ); + } + + /// @notice setProvider rejects a vault that doesn't point back to this adapter (mis-wire guard). + function test_setProvider_revertsOnAdapterMismatch() public { + WstETHV3DexAdapter adapter2 = _freshAdapter(); + vm.prank(admin); + vm.expectRevert(V3DexAdapter.ProviderAdapterMismatch.selector); + adapter2.setProvider(address(provider)); // provider.ADAPTER() == adapter, not adapter2 + } + + /// @notice The oracle constructor rejects a share whose adapter isn't the one the oracle reads. + function test_oracleConstructor_revertsOnShareAdapterMismatch() public { + WstETHV3DexAdapter adapter2 = _freshAdapter(); // same pair ⇒ token check passes, share check fails + vm.expectRevert(V3ProviderOracle.ShareAdapterMismatch.selector); + new V3ProviderOracle(address(adapter2), address(provider), WSTETH, WETH); + } +}