From 7d940c488a8799b8705d569ae1968c15c57bbaa4 Mon Sep 17 00:00:00 2001 From: Razorback Date: Mon, 23 Mar 2026 21:18:24 +0800 Subject: [PATCH 01/17] feat: V3Provider --- .gitmodules | 6 + foundry.lock | 12 + foundry.toml | 4 + lib/v3-core | 1 + lib/v3-periphery | 1 + src/liquidator/V3Liquidator.sol | 482 +++++ src/provider/SmartProvider.sol | 2 +- src/provider/V3Provider.sol | 1005 +++++++++++ .../INonfungiblePositionManager.sol | 81 + src/provider/interfaces/IUniswapV3Factory.sol | 8 + src/provider/interfaces/IUniswapV3Pool.sol | 47 + src/provider/interfaces/IV3Provider.sol | 60 + test/liquidator/V3Liquidator.t.sol | 545 ++++++ test/provider/V3Provider.t.sol | 1544 +++++++++++++++++ 14 files changed, 3797 insertions(+), 1 deletion(-) create mode 160000 lib/v3-core create mode 160000 lib/v3-periphery create mode 100644 src/liquidator/V3Liquidator.sol create mode 100644 src/provider/V3Provider.sol create mode 100644 src/provider/interfaces/INonfungiblePositionManager.sol create mode 100644 src/provider/interfaces/IUniswapV3Factory.sol create mode 100644 src/provider/interfaces/IUniswapV3Pool.sol create mode 100644 src/provider/interfaces/IV3Provider.sol create mode 100644 test/liquidator/V3Liquidator.t.sol create mode 100644 test/provider/V3Provider.t.sol diff --git a/.gitmodules b/.gitmodules index acda2ad9..50fd7241 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,3 +18,9 @@ [submodule "lib/murky"] path = lib/murky url = https://github.com/dmfxyz/murky +[submodule "lib/v3-core"] + path = lib/v3-core + url = https://github.com/Uniswap/v3-core +[submodule "lib/v3-periphery"] + path = lib/v3-periphery + url = https://github.com/uniswap/v3-periphery diff --git a/foundry.lock b/foundry.lock index 168778b8..35f30da2 100644 --- a/foundry.lock +++ b/foundry.lock @@ -10,5 +10,17 @@ "name": "v0.1.0", "rev": "5feccd1253d7da820f7cccccdedf64471025455d" } + }, + "lib/v3-core": { + "tag": { + "name": "v1.0.0", + "rev": "e3589b192d0be27e100cd0daaf6c97204fdb1899" + } + }, + "lib/v3-periphery": { + "tag": { + "name": "v1.3.0", + "rev": "80f26c86c57b8a5e4b913f42844d4c8bd274d058" + } } } \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index b3bc00ed..e82b5fc9 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,6 +3,10 @@ src = "src" out = "out" libs = ["lib"] +remappings = [ + "@uniswap/v3-core/=lib/v3-core/", + "@uniswap/v3-periphery/=lib/v3-periphery/", +] solc = "0.8.34" optimizer = true optimizer_runs = 20 diff --git a/lib/v3-core b/lib/v3-core new file mode 160000 index 00000000..6562c52e --- /dev/null +++ b/lib/v3-core @@ -0,0 +1 @@ +Subproject commit 6562c52e8f75f0c10f9deaf44861847585fc8129 diff --git a/lib/v3-periphery b/lib/v3-periphery new file mode 160000 index 00000000..b325bb09 --- /dev/null +++ b/lib/v3-periphery @@ -0,0 +1 @@ +Subproject commit b325bb0905d922ae61fcc7df85ee802e8df5e96c diff --git a/src/liquidator/V3Liquidator.sol b/src/liquidator/V3Liquidator.sol new file mode 100644 index 00000000..7b3b08ce --- /dev/null +++ b/src/liquidator/V3Liquidator.sol @@ -0,0 +1,482 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; + +import { IV3Provider } from "../provider/interfaces/IV3Provider.sol"; +import "./Interface.sol"; + +/** + * @title V3Liquidator + * @notice Liquidator for Moolah markets whose collateral is a V3Provider LP share token. + * + * Liquidation flows: + * 1. liquidate() — pre-funded: caller holds loanToken, receives V3 shares. + * 2. flashLiquidate() — callback-based: in onMoolahLiquidate, optionally redeem + * V3 shares → TOKEN0 / TOKEN1, swap to loanToken, repay. + * 3. redeemV3Shares() — standalone: redeem shares held by this contract. + * 4. sellToken/sellBNB() — swap any token/BNB held by this contract (e.g. post-redeem). + */ +contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessControlUpgradeable { + using SafeTransferLib for address; + + /* ──────────────────────────── errors ────────────────────────────── */ + + error NoProfit(); + error OnlyMoolah(); + error ExceedAmount(); + error WhitelistSameStatus(); + error NotWhitelisted(); + error SwapFailed(); + + /* ──────────────────────────── constants ─────────────────────────── */ + + bytes32 public constant MANAGER = keccak256("MANAGER"); + bytes32 public constant BOT = keccak256("BOT"); + + /// @dev Virtual address used to represent native BNB in token whitelists. + address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /// @dev BSC wrapped native token — V3Provider unwraps it to native BNB on exit. + address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + + /* ──────────────────────────── immutables ─────────────────────────── */ + + address public immutable MOOLAH; + + /* ──────────────────────────── storage ───────────────────────────── */ + + mapping(address => bool) public tokenWhitelist; + mapping(bytes32 => bool) public marketWhitelist; + mapping(address => bool) public pairWhitelist; + /// @dev Whitelisted V3Provider contracts (collateral token = the provider itself). + mapping(address => bool) public v3Providers; + + /* ──────────────────────────── events ────────────────────────────── */ + + event TokenWhitelistChanged(address indexed token, bool status); + event MarketWhitelistChanged(bytes32 indexed id, bool status); + event PairWhitelistChanged(address indexed pair, bool status); + event V3ProviderWhitelistChanged(address indexed provider, bool status); + event SellToken( + address indexed pair, + address spender, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 actualAmountOut + ); + event V3Liquidation( + bytes32 indexed id, + address indexed v3Provider, + address indexed borrower, + uint256 seized, + uint256 repaid, + uint256 amount0, + uint256 amount1 + ); + + /* ──────────────────────── callback struct ───────────────────────── */ + + /** + * @dev Passed through Moolah's liquidate callback mechanism. + * @param v3Provider V3Provider that issued the seized shares. + * @param loanToken Loan token to repay to Moolah. + * @param seized Number of V3 shares seized by Moolah. + * @param redeemShares If true, redeem V3 shares in callback; else hold as ERC-20. + * @param minToken0Amt Slippage guard passed to V3Provider.redeemShares. + * @param minToken1Amt Slippage guard passed to V3Provider.redeemShares. + * @param swapToken0 Swap TOKEN0 → loanToken after redemption. + * @param swapToken1 Swap TOKEN1 / native BNB → loanToken after redemption. + * @param token0Pair DEX router / pair for TOKEN0 swap. + * @param token0Spender Token0 approval target (set to token0Pair if same). + * @param token1Pair DEX router / pair for TOKEN1 / BNB swap. + * @param token1Spender Token1 approval target (set to token1Pair if same). + * @param swapToken0Data Calldata for TOKEN0 swap (e.g. from 1inch aggregator). + * @param swapToken1Data Calldata for TOKEN1 / BNB swap. + */ + struct V3LiquidateData { + address v3Provider; + address loanToken; + uint256 seized; + bool redeemShares; + uint256 minToken0Amt; + uint256 minToken1Amt; + bool swapToken0; + bool swapToken1; + address token0Pair; + address token0Spender; + address token1Pair; + address token1Spender; + bytes swapToken0Data; + bytes swapToken1Data; + } + + /* ──────────────────── flashLiquidate params ─────────────────────── */ + + /** + * @dev Parameters for flashLiquidate, bundled into a struct to avoid stack-too-deep. + * @param v3Provider Whitelisted V3Provider contract. + * @param minToken0Amt Min TOKEN0 from redeemShares. + * @param minToken1Amt Min TOKEN1 from redeemShares. + * @param redeemShares Redeem V3 shares in callback? If false, contract holds shares. + * @param token0Pair DEX pair for TOKEN0 → loanToken swap. address(0) = no swap. + * @param token0Spender Approval target for TOKEN0; if address(0), uses token0Pair. + * @param token1Pair DEX pair for TOKEN1 / BNB → loanToken swap. address(0) = no swap. + * @param token1Spender Approval target for TOKEN1; if address(0), uses token1Pair. + * @param swapToken0Data Aggregator calldata for TOKEN0 swap. + * @param swapToken1Data Aggregator calldata for TOKEN1 / BNB swap. + */ + struct FlashLiquidateParams { + address v3Provider; + uint256 minToken0Amt; + uint256 minToken1Amt; + bool redeemShares; + address token0Pair; + address token0Spender; + address token1Pair; + address token1Spender; + bytes swapToken0Data; + bytes swapToken1Data; + } + + /* ────────────────────── constructor / init ──────────────────────── */ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address moolah) { + require(moolah != address(0), "zero address"); + MOOLAH = moolah; + _disableInitializers(); + } + + function initialize(address admin, address manager, address bot) external initializer { + require(admin != address(0) && manager != address(0) && bot != address(0), "zero address"); + __AccessControl_init(); + __ReentrancyGuard_init(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(MANAGER, manager); + _grantRole(BOT, bot); + } + + receive() external payable {} + + /* ─────────────────────── withdrawals ────────────────────────────── */ + + function withdrawERC20(address token, uint256 amount) external onlyRole(MANAGER) { + token.safeTransfer(msg.sender, amount); + } + + function withdrawETH(uint256 amount) external onlyRole(MANAGER) { + msg.sender.safeTransferETH(amount); + } + + /* ─────────────────────── whitelists ─────────────────────────────── */ + + function setTokenWhitelist(address token, bool status) external onlyRole(MANAGER) { + require(tokenWhitelist[token] != status, WhitelistSameStatus()); + tokenWhitelist[token] = status; + emit TokenWhitelistChanged(token, status); + } + + function setMarketWhitelist(bytes32 id, bool status) external onlyRole(MANAGER) { + _setMarketWhitelist(id, status); + } + + function batchSetMarketWhitelist(bytes32[] calldata ids, bool status) external onlyRole(MANAGER) { + for (uint256 i = 0; i < ids.length; i++) { + _setMarketWhitelist(ids[i], status); + } + } + + function setPairWhitelist(address pair, bool status) external onlyRole(MANAGER) { + require(pair != address(0), "zero address"); + require(pairWhitelist[pair] != status, WhitelistSameStatus()); + pairWhitelist[pair] = status; + emit PairWhitelistChanged(pair, status); + } + + function setV3ProviderWhitelist(address provider, bool status) external onlyRole(MANAGER) { + require(provider != address(0), "zero address"); + require(v3Providers[provider] != status, WhitelistSameStatus()); + v3Providers[provider] = status; + emit V3ProviderWhitelistChanged(provider, status); + } + + function batchSetV3Providers(address[] calldata providers, bool status) external onlyRole(MANAGER) { + for (uint256 i = 0; i < providers.length; i++) { + require(providers[i] != address(0), "zero address"); + v3Providers[providers[i]] = status; + emit V3ProviderWhitelistChanged(providers[i], status); + } + } + + function _setMarketWhitelist(bytes32 id, bool status) internal { + require(IMoolah(MOOLAH).idToMarketParams(id).loanToken != address(0), "Invalid market"); + require(marketWhitelist[id] != status, WhitelistSameStatus()); + marketWhitelist[id] = status; + emit MarketWhitelistChanged(id, status); + } + + /* ───────────────────── core liquidation ─────────────────────────── */ + + /** + * @notice Basic liquidation. This contract must hold enough loanToken to cover repayment. + * Seized V3 shares are held by this contract; bot may later call redeemV3Shares. + * @param id Market id. + * @param borrower Position to liquidate. + * @param seizedAssets Collateral shares to seize (pass 0 to use repaidShares instead). + * @param repaidShares Debt shares to repay (pass 0 to use seizedAssets instead). + */ + function liquidate( + bytes32 id, + address borrower, + uint256 seizedAssets, + uint256 repaidShares + ) external nonReentrant onlyRole(BOT) { + require(marketWhitelist[id], NotWhitelisted()); + IMoolah.MarketParams memory params = IMoolah(MOOLAH).idToMarketParams(id); + + // Pre-approve Moolah to pull the repayment; cleared after the call. + params.loanToken.safeApprove(MOOLAH, type(uint256).max); + IMoolah(MOOLAH).liquidate(params, borrower, seizedAssets, repaidShares, ""); + params.loanToken.safeApprove(MOOLAH, 0); + } + + /** + * @notice Flash liquidation: Moolah delivers seized V3 shares to this contract inside + * the onMoolahLiquidate callback. The callback optionally: + * 1. Redeems V3 shares → TOKEN0 + TOKEN1 (TOKEN1 arrives as native BNB if WBNB). + * 2. Swaps TOKEN0 → loanToken. + * 3. Swaps TOKEN1 / BNB → loanToken. + * 4. Approves loanToken to Moolah to satisfy repayment. + * + * If `params.redeemShares == false`, shares are held as ERC-20 and the contract + * must already hold enough loanToken to cover repayment. + * @param id Market id. + * @param borrower Position to liquidate. + * @param seizedAssets Collateral shares to seize (exactlyOneZero with repaidShares). + * @param params Flash liquidation parameters (see FlashLiquidateParams). + */ + function flashLiquidate( + bytes32 id, + address borrower, + uint256 seizedAssets, + FlashLiquidateParams calldata params + ) external nonReentrant onlyRole(BOT) { + require(marketWhitelist[id], NotWhitelisted()); + require(v3Providers[params.v3Provider], NotWhitelisted()); + _requirePairWhitelisted(params.token0Pair, params.token0Spender); + _requirePairWhitelisted(params.token1Pair, params.token1Spender); + + IMoolah.MarketParams memory mp = IMoolah(MOOLAH).idToMarketParams(id); + require(mp.collateralToken == params.v3Provider, "provider/market mismatch"); + + address effectiveToken0Spender = params.token0Spender == address(0) ? params.token0Pair : params.token0Spender; + address effectiveToken1Spender = params.token1Spender == address(0) ? params.token1Pair : params.token1Spender; + + (uint256 _seized, uint256 _repaid) = IMoolah(MOOLAH).liquidate( + mp, + borrower, + seizedAssets, + 0, + abi.encode( + V3LiquidateData({ + v3Provider: params.v3Provider, + loanToken: mp.loanToken, + seized: seizedAssets, + redeemShares: params.redeemShares, + minToken0Amt: params.minToken0Amt, + minToken1Amt: params.minToken1Amt, + swapToken0: params.token0Pair != address(0) && params.swapToken0Data.length > 0, + swapToken1: params.token1Pair != address(0) && params.swapToken1Data.length > 0, + token0Pair: params.token0Pair, + token0Spender: effectiveToken0Spender, + token1Pair: params.token1Pair, + token1Spender: effectiveToken1Spender, + swapToken0Data: params.swapToken0Data, + swapToken1Data: params.swapToken1Data + }) + ) + ); + + emit V3Liquidation(id, params.v3Provider, borrower, _seized, _repaid, 0, 0); + } + + /** + * @notice Redeem V3 shares held by this contract. + * TOKEN1 arrives as native BNB if the V3Provider pool contains WBNB. + * @param v3Provider V3Provider whose shares to redeem. + * @param shares Number of shares to redeem. + * @param minAmt0 Min TOKEN0 to receive (slippage guard). + * @param minAmt1 Min TOKEN1 / BNB to receive (slippage guard). + * @param receiver Recipient of TOKEN0 and TOKEN1 / BNB. + */ + function redeemV3Shares( + address v3Provider, + uint256 shares, + uint256 minAmt0, + uint256 minAmt1, + address receiver + ) external nonReentrant onlyRole(BOT) returns (uint256 amount0, uint256 amount1) { + require(v3Providers[v3Provider], NotWhitelisted()); + (amount0, amount1) = IV3Provider(v3Provider).redeemShares(shares, minAmt0, minAmt1, receiver); + } + + /* ─────────────────────── sell tokens ────────────────────────────── */ + + /// @notice Sell an ERC-20 token (pair == spender). + function sellToken( + address pair, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) external nonReentrant onlyRole(BOT) { + _sellToken(pair, pair, tokenIn, tokenOut, amountIn, amountOutMin, swapData); + } + + /// @notice Sell an ERC-20 token with separate pair and spender (e.g. DEX aggregator). + function sellToken( + address pair, + address spender, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) external nonReentrant onlyRole(BOT) { + require(pair != spender, "pair and spender cannot be same address"); + require(pairWhitelist[spender], NotWhitelisted()); + _sellToken(pair, spender, tokenIn, tokenOut, amountIn, amountOutMin, swapData); + } + + /// @notice Sell native BNB held by this contract. + function sellBNB( + address pair, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) external nonReentrant onlyRole(BOT) { + require(tokenWhitelist[BNB_ADDRESS], NotWhitelisted()); + require(tokenWhitelist[tokenOut], NotWhitelisted()); + require(pairWhitelist[pair], NotWhitelisted()); + require(amountIn > 0, "amountIn zero"); + require(address(this).balance >= amountIn, ExceedAmount()); + + uint256 beforeIn = address(this).balance; + uint256 beforeOut = tokenOut.balanceOf(address(this)); + + (bool success, ) = pair.call{ value: amountIn }(swapData); + require(success, SwapFailed()); + + uint256 actualIn = beforeIn - address(this).balance; + uint256 actualOut = tokenOut.balanceOf(address(this)) - beforeOut; + + require(actualIn <= amountIn, ExceedAmount()); + require(actualOut >= amountOutMin, NoProfit()); + + emit SellToken(pair, pair, BNB_ADDRESS, tokenOut, amountIn, actualOut); + } + + /* ──────────────────── Moolah callback ───────────────────────────── */ + + /** + * @dev Called by Moolah immediately before it pulls repaidAssets of loanToken from + * this contract. At this point Moolah has already transferred the seized V3 + * shares to address(this). + */ + function onMoolahLiquidate(uint256 repaidAssets, bytes calldata data) external { + require(msg.sender == MOOLAH, OnlyMoolah()); + V3LiquidateData memory d = abi.decode(data, (V3LiquidateData)); + + if (d.redeemShares) { + address token0 = IV3Provider(d.v3Provider).TOKEN0(); + address token1 = IV3Provider(d.v3Provider).TOKEN1(); + + // Redeem V3 shares → TOKEN0 as ERC-20, TOKEN1 as ERC-20 or native BNB (if WBNB). + (uint256 amount0, uint256 amount1) = IV3Provider(d.v3Provider).redeemShares( + d.seized, + d.minToken0Amt, + d.minToken1Amt, + address(this) + ); + + // Swap TOKEN0 → loanToken (skip if already loanToken or no swap requested). + if (d.swapToken0 && amount0 > 0 && token0 != d.loanToken) { + token0.safeApprove(d.token0Spender, amount0); + (bool ok, ) = d.token0Pair.call(d.swapToken0Data); + require(ok, SwapFailed()); + token0.safeApprove(d.token0Spender, 0); + } + + // Swap TOKEN1 / native BNB → loanToken. + // V3Provider always unwraps WBNB to native BNB, so use call{value} for WBNB pools. + if (d.swapToken1 && amount1 > 0 && token1 != d.loanToken) { + if (token1 == WBNB) { + (bool ok, ) = d.token1Pair.call{ value: amount1 }(d.swapToken1Data); + require(ok, SwapFailed()); + } else { + token1.safeApprove(d.token1Spender, amount1); + (bool ok, ) = d.token1Pair.call(d.swapToken1Data); + require(ok, SwapFailed()); + token1.safeApprove(d.token1Spender, 0); + } + } + + if (d.loanToken.balanceOf(address(this)) < repaidAssets) revert NoProfit(); + } + + // Approve Moolah to pull the repayment (always done, flash or pre-funded). + d.loanToken.safeApprove(MOOLAH, repaidAssets); + } + + /* ─────────────────────────── internals ──────────────────────────── */ + + function _sellToken( + address pair, + address spender, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOutMin, + bytes calldata swapData + ) private { + require(tokenWhitelist[tokenIn], NotWhitelisted()); + require(tokenWhitelist[tokenOut], NotWhitelisted()); + require(pairWhitelist[pair], NotWhitelisted()); + require(amountIn > 0, "amountIn zero"); + require(tokenIn.balanceOf(address(this)) >= amountIn, ExceedAmount()); + + uint256 beforeIn = tokenIn.balanceOf(address(this)); + uint256 beforeOut = tokenOut.balanceOf(address(this)); + + tokenIn.safeApprove(spender, amountIn); + (bool success, ) = pair.call(swapData); + require(success, SwapFailed()); + + uint256 actualIn = beforeIn - tokenIn.balanceOf(address(this)); + uint256 actualOut = tokenOut.balanceOf(address(this)) - beforeOut; + + require(actualIn <= amountIn, ExceedAmount()); + require(actualOut >= amountOutMin, NoProfit()); + + tokenIn.safeApprove(spender, 0); + + emit SellToken(pair, spender, tokenIn, tokenOut, actualIn, actualOut); + } + + /// @dev Validates that both pair and spender (when non-zero) are in the pair whitelist. + function _requirePairWhitelisted(address pair, address spender) internal view { + if (pair == address(0)) return; + require(pairWhitelist[pair], NotWhitelisted()); + if (spender != address(0) && spender != pair) require(pairWhitelist[spender], NotWhitelisted()); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/src/provider/SmartProvider.sol b/src/provider/SmartProvider.sol index fcf6c56d..e202765e 100644 --- a/src/provider/SmartProvider.sol +++ b/src/provider/SmartProvider.sol @@ -579,7 +579,7 @@ contract SmartProvider is /// @dev Sets the slisBNBxMinter address. function setSlisBNBxMinter(address _slisBNBxMinter) external onlyRole(MANAGER) { - require(_slisBNBxMinter != address(0), "zero address provided"); + require(_slisBNBxMinter != slisBNBxMinter, "same minter"); slisBNBxMinter = _slisBNBxMinter; emit SlisBNBxMinterChanged(_slisBNBxMinter); diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol new file mode 100644 index 00000000..870695bf --- /dev/null +++ b/src/provider/V3Provider.sol @@ -0,0 +1,1005 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { TickMath } from "@uniswap/v3-core/contracts/libraries/TickMath.sol"; +import { SqrtPriceMath } from "@uniswap/v3-core/contracts/libraries/SqrtPriceMath.sol"; +import { LiquidityAmounts } from "@uniswap/v3-periphery/contracts/libraries/LiquidityAmounts.sol"; + +import { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; +import { IOracle, TokenConfig } from "moolah/interfaces/IOracle.sol"; + +import { INonfungiblePositionManager } from "./interfaces/INonfungiblePositionManager.sol"; +import { IUniswapV3Factory } from "./interfaces/IUniswapV3Factory.sol"; +import { IUniswapV3Pool } from "./interfaces/IUniswapV3Pool.sol"; +import { IWBNB } from "./interfaces/IWBNB.sol"; +import { IV3Provider } from "./interfaces/IV3Provider.sol"; +import { ISlisBNBxMinter } from "../utils/interfaces/ISlisBNBx.sol"; + +/** + * @title V3Provider + * @author Lista DAO + * @notice Manages a single Uniswap V3 / PancakeSwap V3 concentrated liquidity position. + * Issues ERC20 shares representing pro-rata ownership of the position. + * Registered as a Moolah provider so it can supply and withdraw collateral + * on behalf of users without requiring per-user Moolah authorization. + * + * Architecture: + * - Shares (this contract's ERC20 token) are the Moolah collateral token for the market. + * - On deposit: tokens → V3 liquidity → mint shares → Moolah.supplyCollateral(onBehalf) + * - On withdraw: Moolah.withdrawCollateral → burn shares → remove V3 liquidity → tokens to receiver + * - On liquidation: Moolah sends shares to liquidator; liquidator calls redeemShares() + * - Fees are compounded into the position before every deposit/withdraw/rebalance. + * - Only Moolah may transfer shares (prevents bypassing the vault on withdrawal). + * + * Dependencies (add to lib/ or remappings): + * uniswap/v3-core - TickMath + */ +contract V3Provider is + ERC20Upgradeable, + UUPSUpgradeable, + AccessControlEnumerableUpgradeable, + ReentrancyGuardUpgradeable, + IOracle, + IV3Provider +{ + using SafeERC20 for IERC20; + using MarketParamsLib for MarketParams; + + /* ─────────────────────────── immutables ─────────────────────────── */ + + /// @dev Moolah lending core + IMoolah public immutable MOOLAH; + + /// @dev Uniswap V3 / PancakeSwap V3 NonfungiblePositionManager + INonfungiblePositionManager public immutable POSITION_MANAGER; + + /// @dev V3 pool address for TOKEN0/TOKEN1/FEE, derived from NPM factory in constructor + address public immutable POOL; + + /// @dev token0 of the V3 pool + address public immutable TOKEN0; + + /// @dev token1 of the V3 pool + address public immutable TOKEN1; + + /// @dev V3 pool fee tier (e.g. 500, 3000, 10000) + uint24 public immutable FEE; + + /// @dev TWAP window in seconds for manipulation-resistant tick queries + uint32 public immutable TWAP_PERIOD; + + /// @dev Decimal precision of TOKEN0 and TOKEN1, cached to avoid repeated external calls. + uint8 public immutable DECIMALS0; + uint8 public immutable DECIMALS1; + + /// @dev BSC wrapped native token. Users may send BNB directly; it is wrapped on entry + /// and unwrapped on exit when one of the pool tokens is WBNB. + address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev Resilient oracle used to price TOKEN0 and TOKEN1 individually (8-decimal USD) + address public resilientOracle; + + /// @dev tokenId of the V3 NFT position held by this contract; 0 means no position yet + uint256 public tokenId; + + /// @dev Lower tick of the current position range + int24 public tickLower; + + /// @dev Upper tick of the current position range + int24 public tickUpper; + + /// @dev Idle TOKEN0 balance that arose from internal ratio mismatch during compounding. + /// Tracked separately to avoid sweeping arbitrary token donations. + uint256 public idleToken0; + + /// @dev Idle TOKEN1 balance that arose from internal ratio mismatch during compounding. + /// Tracked separately to avoid sweeping arbitrary token donations. + uint256 public idleToken1; + + /// @dev user account > market id > amount of collateral(shares) deposited + mapping(address => mapping(Id => uint256)) public userMarketDeposit; + + /// @dev user account > total amount of collateral(shares) deposited + mapping(address => uint256) public userTotalDeposit; + + /// @dev slisBNBxMinter address + address public slisBNBxMinter; + + /// @dev Virtual address used by the resilient oracle to price native BNB. + address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + bytes32 public constant MANAGER = keccak256("MANAGER"); + bytes32 public constant BOT = keccak256("BOT"); + + /* ───────────────────────────── events ───────────────────────────── */ + + event SlisBNBxMinterChanged(address indexed minter); + + event Deposit( + address indexed onBehalf, + uint256 amount0Used, + uint256 amount1Used, + uint256 shares, + Id indexed marketId + ); + event Withdraw( + address indexed onBehalf, + uint256 shares, + uint256 amount0, + uint256 amount1, + address receiver, + Id indexed marketId + ); + event SharesRedeemed(address indexed redeemer, uint256 shares, uint256 amount0, uint256 amount1, address receiver); + event Compounded(uint256 fees0, uint256 fees1, uint128 liquidityAdded); + event Rebalanced(int24 oldTickLower, int24 oldTickUpper, int24 newTickLower, int24 newTickUpper, uint256 newTokenId); + + /* ─────────────────────────── constructor ────────────────────────── */ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address _moolah, + address _positionManager, + address _token0, + address _token1, + uint24 _fee, + uint32 _twapPeriod + ) { + require(_moolah != address(0), "zero address"); + require(_positionManager != address(0), "zero address"); + require(_token0 != address(0) && _token1 != address(0), "zero address"); + require(_token0 < _token1, "token0 must be < token1"); + require(_fee > 0, "zero fee"); + require(_twapPeriod > 0, "zero twap period"); + + address _pool = IUniswapV3Factory(INonfungiblePositionManager(_positionManager).factory()).getPool( + _token0, + _token1, + _fee + ); + require(_pool != address(0), "pool does not exist"); + + MOOLAH = IMoolah(_moolah); + POSITION_MANAGER = INonfungiblePositionManager(_positionManager); + TOKEN0 = _token0; + TOKEN1 = _token1; + FEE = _fee; + POOL = _pool; + TWAP_PERIOD = _twapPeriod; + DECIMALS0 = IERC20Metadata(_token0).decimals(); + DECIMALS1 = IERC20Metadata(_token1).decimals(); + + _disableInitializers(); + } + + /* ─────────────────────────── initializer ────────────────────────── */ + + /** + * @param _admin Default admin (can upgrade, grant roles) + * @param _manager Manager role (can rebalance position range) + * @param _bot Bot address granted BOT role (can trigger rebalance) + * @param _resilientOracle Resilient oracle for pricing TOKEN0 and TOKEN1 + * @param _tickLower Initial position lower tick + * @param _tickUpper Initial position upper tick + * @param _name ERC20 name for shares token + * @param _symbol ERC20 symbol for shares token + */ + function initialize( + address _admin, + address _manager, + address _bot, + address _resilientOracle, + int24 _tickLower, + int24 _tickUpper, + string calldata _name, + string calldata _symbol + ) external initializer { + require( + _admin != address(0) && _manager != address(0) && _bot != address(0) && _resilientOracle != address(0), + "zero address" + ); + require(_tickLower < _tickUpper, "invalid tick range"); + + __ERC20_init(_name, _symbol); + __AccessControl_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(MANAGER, _manager); + _setRoleAdmin(BOT, MANAGER); + _grantRole(BOT, _bot); + + resilientOracle = _resilientOracle; + tickLower = _tickLower; + tickUpper = _tickUpper; + } + + /* ──────────────────── ERC20 transfer restrictions ───────────────── */ + + /// @dev Only Moolah may transfer shares. This prevents users from transferring + /// shares directly without going through withdraw(), which would orphan V3 liquidity. + function transfer(address to, uint256 value) public override returns (bool) { + require(msg.sender == address(MOOLAH), "only moolah"); + _transfer(msg.sender, to, value); + return true; + } + + /// @dev Only Moolah may call transferFrom (e.g. when pulling collateral on supplyCollateral). + function transferFrom(address from, address to, uint256 value) public override returns (bool) { + require(msg.sender == address(MOOLAH), "only moolah"); + _transfer(from, to, value); + return true; + } + + /* ─────────────────────── core user functions ────────────────────── */ + + /** + * @notice Deposit TOKEN0 and TOKEN1, add them to the V3 position, mint shares, + * and supply those shares as Moolah collateral on behalf of `onBehalf`. + * @param marketParams Moolah market (collateralToken must equal address(this)) + * @param amount0Desired Max TOKEN0 to deposit + * @param amount1Desired Max TOKEN1 to deposit + * @param amount0Min Min TOKEN0 accepted after slippage (for V3 mint/increase) + * @param amount1Min Min TOKEN1 accepted after slippage (for V3 mint/increase) + * @param onBehalf Moolah position owner to credit collateral to + * @return shares Shares minted to represent this deposit + * @return amount0Used Actual TOKEN0 consumed by the V3 pool + * @return amount1Used Actual TOKEN1 consumed by the V3 pool + */ + function deposit( + MarketParams calldata marketParams, + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min, + address onBehalf + ) external payable nonReentrant returns (uint256 shares, uint256 amount0Used, uint256 amount1Used) { + require(marketParams.collateralToken == address(this), "invalid collateral token"); + require(onBehalf != address(0), "zero address"); + + // ── Native token handling ────────────────────────────────────────── + // If the caller sends BNB, wrap it and use it in place of the pool token + // that equals WBNB. Pull the other token via transferFrom as usual. + // Idle always stays in wrapped (ERC-20) form; only the entry boundary wraps. + uint256 _amount0Desired = amount0Desired; + uint256 _amount1Desired = amount1Desired; + + if (msg.value > 0) { + require(TOKEN0 == WBNB || TOKEN1 == WBNB, "pool has no WBNB"); + if (TOKEN0 == WBNB) { + _amount0Desired = msg.value; + } else { + _amount1Desired = msg.value; + } + IWBNB(WBNB).deposit{ value: msg.value }(); + } + + require(_amount0Desired > 0 || _amount1Desired > 0, "zero amounts"); + + // Reject upfront if the supplied amounts yield zero liquidity at the current price. + // This catches one-sided deposits in the wrong direction (e.g. token0-only when price + // is above tickUpper) before any tokens are pulled from the caller. + { + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + require( + LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + _amount0Desired, + _amount1Desired + ) > 0, + "zero liquidity" + ); + } + + // Pull ERC-20 tokens from caller. + // Skip whichever side was funded by msg.value (already wrapped and held by this contract). + if (_amount0Desired > 0 && !(TOKEN0 == WBNB && msg.value > 0)) { + IERC20(TOKEN0).safeTransferFrom(msg.sender, address(this), _amount0Desired); + } + if (_amount1Desired > 0 && !(TOKEN1 == WBNB && msg.value > 0)) { + IERC20(TOKEN1).safeTransferFrom(msg.sender, address(this), _amount1Desired); + } + + // Compound pending fees before computing share ratio so existing holders + // capture accrued fees before new shares dilute them. + _collectAndCompound(); + + uint128 liquidityBefore = _getPositionLiquidity(); + uint256 supplyBefore = totalSupply(); + + uint128 liquidityAdded; + if (tokenId == 0) { + // No position exists yet — mint a fresh V3 NFT. + IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), _amount0Desired); + IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), _amount1Desired); + + (tokenId, liquidityAdded, amount0Used, amount1Used) = POSITION_MANAGER.mint( + INonfungiblePositionManager.MintParams({ + token0: TOKEN0, + token1: TOKEN1, + fee: FEE, + tickLower: tickLower, + tickUpper: tickUpper, + amount0Desired: _amount0Desired, + amount1Desired: _amount1Desired, + amount0Min: amount0Min, + amount1Min: amount1Min, + recipient: address(this), + deadline: block.timestamp + }) + ); + + // First depositor: shares 1:1 with liquidity units. + shares = uint256(liquidityAdded); + } else { + // Existing position — increase liquidity. + IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), _amount0Desired); + IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), _amount1Desired); + + (liquidityAdded, amount0Used, amount1Used) = POSITION_MANAGER.increaseLiquidity( + INonfungiblePositionManager.IncreaseLiquidityParams({ + tokenId: tokenId, + amount0Desired: _amount0Desired, + amount1Desired: _amount1Desired, + amount0Min: amount0Min, + amount1Min: amount1Min, + deadline: block.timestamp + }) + ); + + // Subsequent depositors: proportional to liquidity contributed vs pre-deposit total. + if (supplyBefore == 0 || liquidityBefore == 0) { + shares = uint256(liquidityAdded); + } else { + shares = (uint256(liquidityAdded) * supplyBefore) / uint256(liquidityBefore); + } + } + + require(shares > 0, "zero shares"); + + // Refund any tokens not consumed by the V3 pool (ratio mismatch). + // WBNB refunds are unwrapped back to BNB before sending. + uint256 refund0 = _amount0Desired - amount0Used; + uint256 refund1 = _amount1Desired - amount1Used; + if (refund0 > 0) _sendToken(TOKEN0, refund0, payable(msg.sender)); + if (refund1 > 0) _sendToken(TOKEN1, refund1, payable(msg.sender)); + + // Mint shares to this contract, then grant Moolah a one-time allowance so + // supplyCollateral can pull them. Our transferFrom restricts the caller to + // Moolah, so _approve is used internally to set the allowance. + _mint(address(this), shares); + _approve(address(this), address(MOOLAH), shares); + MOOLAH.supplyCollateral(marketParams, shares, onBehalf, ""); + + _syncPosition(marketParams.id(), onBehalf); + + emit Deposit(onBehalf, amount0Used, amount1Used, shares, marketParams.id()); + } + + /** + * @notice Withdraw shares from Moolah, remove the proportional V3 liquidity, + * and return TOKEN0/TOKEN1 to `receiver`. + * @dev Caller must be `onBehalf` or authorized via MOOLAH.isAuthorized(). + * @param marketParams Moolah market (collateralToken must equal address(this)) + * @param shares Number of shares to redeem + * @param minAmount0 Min TOKEN0 to receive (slippage guard) + * @param minAmount1 Min TOKEN1 to receive (slippage guard) + * @param onBehalf Owner of the Moolah collateral position + * @param receiver Address to send TOKEN0/TOKEN1 to + */ + function withdraw( + MarketParams calldata marketParams, + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address onBehalf, + address receiver + ) external nonReentrant returns (uint256 amount0, uint256 amount1) { + require(marketParams.collateralToken == address(this), "invalid collateral token"); + require(shares > 0, "zero shares"); + require(receiver != address(0), "zero address"); + require(_isSenderAuthorized(onBehalf), "unauthorized"); + + // Moolah decrements position.collateral and transfers shares to address(this). + // Our transfer() allows msg.sender == MOOLAH, so this succeeds. + MOOLAH.withdrawCollateral(marketParams, shares, onBehalf, address(this)); + + _syncPosition(marketParams.id(), onBehalf); + + _collectAndCompound(); + + (amount0, amount1) = _burnSharesAndRemoveLiquidity(shares, minAmount0, minAmount1, receiver); + + emit Withdraw(onBehalf, shares, amount0, amount1, receiver, marketParams.id()); + } + + /** + * @notice Redeem shares already held by the caller (typically a liquidator that + * received shares from Moolah during liquidation) for TOKEN0/TOKEN1. + * @param shares Number of shares to redeem + * @param minAmount0 Min TOKEN0 to receive (slippage guard) + * @param minAmount1 Min TOKEN1 to receive (slippage guard) + * @param receiver Address to send TOKEN0/TOKEN1 to + */ + function redeemShares( + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address receiver + ) external nonReentrant returns (uint256 amount0, uint256 amount1) { + require(shares > 0, "zero shares"); + require(receiver != address(0), "zero address"); + require(balanceOf(msg.sender) >= shares, "insufficient shares"); + + _collectAndCompound(); + + // Transfer shares from caller to this contract so _burnSharesAndRemoveLiquidity + // can burn from address(this). We use the internal _transfer to bypass the + // Moolah-only restriction (caller holds their own shares). + _transfer(msg.sender, address(this), shares); + + (amount0, amount1) = _burnSharesAndRemoveLiquidity(shares, minAmount0, minAmount1, receiver); + + emit SharesRedeemed(msg.sender, shares, amount0, amount1, receiver); + } + + /* ──────────────────── Moolah provider callback ──────────────────── */ + + /** + * @dev Called by Moolah after a liquidation event. + * Syncs the borrower's deposit tracking and triggers slisBNBx rebalance if configured. + * Moolah already transferred the seized shares to the liquidator via transfer(). + */ + function liquidate(Id id, address borrower) external { + require(msg.sender == address(MOOLAH), "only moolah"); + require(MOOLAH.idToMarketParams(id).collateralToken == address(this), "invalid market"); + _syncPosition(id, borrower); + } + + /* ───────────────────── manager: rebalance range ─────────────────── */ + + /** + * @notice Move the position to a new tick range. Collects all fees, removes all + * liquidity, burns the old NFT, and mints a new position at the new ticks. + * Share count is unchanged — each share now represents the new range. + * @dev Caller must hold MANAGER role. A price movement between decreaseLiquidity + * and the new mint is the primary slippage risk; minAmount0/minAmount1 guard against it. + * @param _tickLower New lower tick + * @param _tickUpper New upper tick + * @param minAmount0 Min TOKEN0 to receive when removing old liquidity + * @param minAmount1 Min TOKEN1 to receive when removing old liquidity + * @param amount0Desired TOKEN0 to reinvest into the new position. Must not exceed + * the total internally collected (fees + idle + removed liquidity). + * Pass type(uint256).max to reinvest everything. + * @param amount1Desired TOKEN1 to reinvest into the new position. Same semantics. + */ + function rebalance( + int24 _tickLower, + int24 _tickUpper, + uint256 minAmount0, + uint256 minAmount1, + uint256 amount0Desired, + uint256 amount1Desired + ) external onlyRole(BOT) nonReentrant { + require(_tickLower < _tickUpper, "invalid tick range"); + + int24 oldTickLower = tickLower; + int24 oldTickUpper = tickUpper; + + // 1. Collect all fees; track amounts explicitly to avoid balanceOf donation surface. + (uint256 total0, uint256 total1) = _collectAll(); + + // Add previously idle tokens from compound ratio mismatches. + total0 += idleToken0; + total1 += idleToken1; + idleToken0 = 0; + idleToken1 = 0; + + // 2. Remove all existing liquidity. + if (tokenId != 0) { + uint128 liquidity = _getPositionLiquidity(); + if (liquidity > 0) { + POSITION_MANAGER.decreaseLiquidity( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: tokenId, + liquidity: liquidity, + amount0Min: minAmount0, + amount1Min: minAmount1, + deadline: block.timestamp + }) + ); + } + // Collect removed liquidity back to this contract; accumulate into tracked totals. + (uint256 removed0, uint256 removed1) = POSITION_MANAGER.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + total0 += removed0; + total1 += removed1; + + POSITION_MANAGER.burn(tokenId); + tokenId = 0; + } + + // 3. Update range. + tickLower = _tickLower; + tickUpper = _tickUpper; + + // 4. Re-mint with caller-specified amounts (capped to internally available). + // This lets the BOT pre-compute the optimal ratio for the new tick range, + // minimising idle remainder. Excess stays in idleToken0/1 for next compound. + uint256 toMint0 = amount0Desired > total0 ? total0 : amount0Desired; + uint256 toMint1 = amount1Desired > total1 ? total1 : amount1Desired; + + if (toMint0 > 0 || toMint1 > 0) { + IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), toMint0); + IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), toMint1); + + (uint256 newTokenId, , uint256 used0, uint256 used1) = POSITION_MANAGER.mint( + INonfungiblePositionManager.MintParams({ + token0: TOKEN0, + token1: TOKEN1, + fee: FEE, + tickLower: _tickLower, + tickUpper: _tickUpper, + amount0Desired: toMint0, + amount1Desired: toMint1, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + }) + ); + tokenId = newTokenId; + + // Any leftover (caller under-specified or ratio mismatch) tracked for next compound. + idleToken0 = total0 - used0; + idleToken1 = total1 - used1; + } else { + // Nothing to mint; park everything as idle. + idleToken0 = total0; + idleToken1 = total1; + } + + emit Rebalanced(oldTickLower, oldTickUpper, _tickLower, _tickUpper, tokenId); + } + + /* ───────────────────────── view functions ───────────────────────── */ + + /** + * @notice Total TOKEN0 and TOKEN1 represented by the vault at the current spot price. + * Includes amounts locked in the V3 position, uncollected fees (tokensOwed), + * and any idle token balances held by this contract. + * @dev Uses slot0 — suitable for display and bot decisions, NOT for the lending oracle. + * peek() uses the TWAP price to resist manipulation; see _getTotalAmountsAt. + */ + function getTotalAmounts() public view returns (uint256 total0, uint256 total1) { + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + return _getTotalAmountsAt(sqrtPriceX96); + } + + /** + * @notice Simulates a redemption and returns the token amounts a holder would receive + * for burning `shares` at the current pool price. + * Use this to compute tight `minAmount0`/`minAmount1` before calling + * `withdraw` or `redeemShares`: + * + * (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + * uint256 min0 = exp0 * 995 / 1000; // 0.5 % slippage tolerance + * uint256 min1 = exp1 * 995 / 1000; + * provider.withdraw(marketParams, shares, min0, min1, onBehalf, receiver); + * + * @param shares Number of provider shares to redeem. + * @return amount0 TOKEN0 the caller would receive (≥ minAmount0 to pass slippage guard). + * @return amount1 TOKEN1 the caller would receive (≥ minAmount1 to pass slippage guard). + */ + function previewRedeem(uint256 shares) external view returns (uint256 amount0, uint256 amount1) { + uint256 supply = totalSupply(); + if (supply == 0 || shares == 0) return (0, 0); + + uint128 totalLiquidity = _getPositionLiquidity(); + uint128 liquidityToRemove = uint128((uint256(totalLiquidity) * shares) / supply); + + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + (amount0, amount1) = _getAmountsForLiquidity( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + liquidityToRemove + ); + } + + /** + * @notice Simulates a deposit and returns the token amounts that would actually be consumed + * plus the liquidity that would be minted, given desired input amounts. + * Use this to compute tight `amount0Min`/`amount1Min` before calling `deposit`: + * + * (uint128 liq, uint256 exp0, uint256 exp1) = provider.previewDeposit(des0, des1); + * uint256 min0 = exp0 * 995 / 1000; // 0.5 % slippage tolerance + * uint256 min1 = exp1 * 995 / 1000; + * provider.deposit(marketParams, des0, des1, min0, min1, onBehalf); + * + * @param amount0Desired Amount of TOKEN0 the caller intends to supply. + * @param amount1Desired Amount of TOKEN1 the caller intends to supply. + * @return liquidity Liquidity units that would be added to the position. + * @return amount0 TOKEN0 that would actually be consumed (≤ amount0Desired). + * @return amount1 TOKEN1 that would actually be consumed (≤ amount1Desired). + */ + function previewDeposit( + uint256 amount0Desired, + uint256 amount1Desired + ) external view returns (uint128 liquidity, uint256 amount0, uint256 amount1) { + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + uint160 sqrtRatioLower = TickMath.getSqrtRatioAtTick(tickLower); + uint160 sqrtRatioUpper = TickMath.getSqrtRatioAtTick(tickUpper); + + liquidity = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + sqrtRatioLower, + sqrtRatioUpper, + amount0Desired, + amount1Desired + ); + (amount0, amount1) = _getAmountsForLiquidity(sqrtPriceX96, sqrtRatioLower, sqrtRatioUpper, liquidity); + } + + /// @dev Returns the TOKEN field required by the IProvider interface. + /// For V3Provider, the "token" is this contract itself (the shares ERC20). + function TOKEN() external view returns (address) { + return address(this); + } + + /* ─────────────────────── IOracle implementation ─────────────────── */ + + /** + * @notice Returns the USD price (8 decimals) for a given token. + * - If token == address(this): prices V3Provider shares as + * (total0 × price0 + total1 × price1) / totalSupply. + * - Otherwise: delegates directly to the resilient oracle. + * + * @dev Token composition is derived from the TWAP tick (not slot0) so a single-block + * AMM price manipulation cannot inflate the reported collateral value. + * pool.observe() reverts when the pool lacks TWAP_PERIOD seconds of history, + * which in turn reverts peek() — intentionally blocking borrows until the market + * has seasoned. Do NOT add a slot0 fallback here. + */ + function peek(address token) external view override returns (uint256) { + if (token != address(this)) { + return IOracle(resilientOracle).peek(token); + } + + uint256 supply = totalSupply(); + if (supply == 0) return 0; + + uint160 sqrtTwapX96 = TickMath.getSqrtRatioAtTick(getTwapTick()); + (uint256 total0, uint256 total1) = _getTotalAmountsAt(sqrtTwapX96); + + uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8 decimals + uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8 decimals + + uint256 totalValue = (total0 * price0) / (10 ** DECIMALS0) + (total1 * price1) / (10 ** DECIMALS1); + + // shares are 18-decimal; return 8-decimal price per share + return (totalValue * 1e18) / supply; + } + + /** + * @notice Returns the TokenConfig for a given token. + * - If token == address(this): registers this contract as the primary oracle + * so the resilient oracle can delegate share pricing back to us. + * - Otherwise: delegates to the resilient oracle. + */ + function getTokenConfig(address token) external view override returns (TokenConfig memory) { + if (token != address(this)) { + return IOracle(resilientOracle).getTokenConfig(token); + } + return + TokenConfig({ + asset: token, + oracles: [address(this), address(0), address(0)], + enableFlagsForOracles: [true, false, false], + timeDeltaTolerance: 0 + }); + } + + /** + * @notice Returns the TWAP tick for POOL over TWAP_PERIOD seconds. + * Useful for bots to cross-check whether the current slot0 tick deviates + * significantly from the TWAP before triggering a rebalance. + * Public (not external) so peek() can call it directly. + */ + function getTwapTick() public view returns (int24 twapTick) { + uint32[] memory secondsAgos = new uint32[](2); + secondsAgos[0] = TWAP_PERIOD; + secondsAgos[1] = 0; + + (int56[] memory tickCumulatives, ) = IUniswapV3Pool(POOL).observe(secondsAgos); + + int56 delta = tickCumulatives[1] - tickCumulatives[0]; + twapTick = int24(delta / int56(uint56(TWAP_PERIOD))); + if (delta < 0 && (delta % int56(uint56(TWAP_PERIOD)) != 0)) twapTick--; + } + + /* ─────────────────── slisBNBx: sync / view ──────────────────────── */ + + /** + * @notice Returns the user's total deposited collateral value expressed in BNB (18 decimals). + * Called by SlisBNBxMinter as the ISlisBNBxModule callback to compute how much + * slisBNBx the user is entitled to. + * @param account The user whose position is being priced. + */ + function getUserBalanceInBnb(address account) external view returns (uint256) { + uint256 shares = userTotalDeposit[account]; + if (shares == 0) return 0; + + uint256 supply = totalSupply(); + if (supply == 0) return 0; + + (uint256 total0, uint256 total1) = getTotalAmounts(); + + uint256 user0 = (total0 * shares) / supply; + uint256 user1 = (total1 * shares) / supply; + + uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8-decimal USD + uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8-decimal USD + uint256 bnbPrice = IOracle(resilientOracle).peek(BNB_ADDRESS); // 8-decimal USD + + // Scale up by 1e18 before dividing by bnbPrice so the result is 18-decimal BNB. + uint256 value0 = (user0 * price0 * 1e18) / (10 ** DECIMALS0); + uint256 value1 = (user1 * price1 * 1e18) / (10 ** DECIMALS1); + + return (value0 + value1) / bnbPrice; + } + + /** + * @notice Manually sync one user's deposit tracking and slisBNBx balance for a market. + * @param id Moolah market Id (collateralToken must equal address(this)). + * @param account User to sync. + */ + function syncUserBalance(Id id, address account) external { + require(MOOLAH.idToMarketParams(id).collateralToken == address(this), "invalid market"); + _syncPosition(id, account); + } + + /** + * @notice Batch sync multiple users across multiple markets. + * @param ids Array of market Ids. + * @param accounts Array of user addresses (parallel to ids). + */ + function bulkSyncUserBalance(Id[] calldata ids, address[] calldata accounts) external { + require(ids.length == accounts.length, "length mismatch"); + for (uint256 i = 0; i < accounts.length; i++) { + require(MOOLAH.idToMarketParams(ids[i]).collateralToken == address(this), "invalid market"); + _syncPosition(ids[i], accounts[i]); + } + } + + /* ──────────────────── manager: slisBNBxMinter ───────────────────── */ + + /// @notice Set (or unset) the SlisBNBxMinter plugin. Pass address(0) to disable. + /// When set, deposit/withdraw/liquidate call minter.rebalance(account). + function setSlisBNBxMinter(address _slisBNBxMinter) external onlyRole(MANAGER) { + slisBNBxMinter = _slisBNBxMinter; + emit SlisBNBxMinterChanged(_slisBNBxMinter); + } + + /* ─────────────────────────── internals ──────────────────────────── */ + + /// @dev Reads the user's current Moolah collateral for `id`, diffs against the last + /// recorded snapshot in `userMarketDeposit`, updates `userTotalDeposit`, then + /// calls `slisBNBxMinter.rebalance(account)` if a minter is configured. + /// Callers that have already validated the market (deposit, withdraw) skip the + /// idToMarketParams check; liquidate() validates before calling this. + function _syncPosition(Id id, address account) internal { + uint256 current = MOOLAH.position(id, account).collateral; + + if (current >= userMarketDeposit[account][id]) { + userTotalDeposit[account] += current - userMarketDeposit[account][id]; + } else { + userTotalDeposit[account] -= userMarketDeposit[account][id] - current; + } + userMarketDeposit[account][id] = current; + + if (slisBNBxMinter != address(0)) { + ISlisBNBxMinter(slisBNBxMinter).rebalance(account); + } + } + + /// @dev Collect accrued fees from the position and re-add them plus any previously + /// idle tokens (from prior ratio mismatches) as liquidity. + /// Idle amounts are tracked in storage rather than read from balanceOf() to + /// avoid sweeping arbitrary token donations into the position. + function _collectAndCompound() internal { + if (tokenId == 0) return; + + (uint256 fees0, uint256 fees1) = POSITION_MANAGER.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + + uint256 toCompound0 = fees0 + idleToken0; + uint256 toCompound1 = fees1 + idleToken1; + + if (toCompound0 == 0 && toCompound1 == 0) return; + + IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), toCompound0); + IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), toCompound1); + + (uint128 liquidityAdded, uint256 used0, uint256 used1) = POSITION_MANAGER.increaseLiquidity( + INonfungiblePositionManager.IncreaseLiquidityParams({ + tokenId: tokenId, + amount0Desired: toCompound0, + amount1Desired: toCompound1, + amount0Min: 0, + amount1Min: 0, + deadline: block.timestamp + }) + ); + + // Track leftover from ratio mismatch so it's swept on the next compound. + idleToken0 = toCompound0 - used0; + idleToken1 = toCompound1 - used1; + + emit Compounded(toCompound0, toCompound1, liquidityAdded); + } + + /// @dev Collect all pending fees without compounding (used before rebalance). + /// Returns the amounts collected so callers can track totals without balanceOf. + function _collectAll() internal returns (uint256 collected0, uint256 collected1) { + if (tokenId == 0) return (0, 0); + (collected0, collected1) = POSITION_MANAGER.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + } + + /// @dev Burn `shares` held by address(this), remove proportional V3 liquidity, + /// collect the resulting tokens to this contract, then forward to `receiver` + /// — unwrapping WBNB to native BNB along the way. + function _burnSharesAndRemoveLiquidity( + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address receiver + ) internal returns (uint256 amount0, uint256 amount1) { + uint256 supply = totalSupply(); + uint128 totalLiquidity = _getPositionLiquidity(); + + // Compute liquidity to remove proportionally to shares being redeemed. + uint128 liquidityToRemove = uint128((uint256(totalLiquidity) * shares) / supply); + + _burn(address(this), shares); + + if (liquidityToRemove > 0) { + POSITION_MANAGER.decreaseLiquidity( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: tokenId, + liquidity: liquidityToRemove, + amount0Min: minAmount0, + amount1Min: minAmount1, + deadline: block.timestamp + }) + ); + + // Collect to address(this) so we can unwrap WBNB before forwarding. + (amount0, amount1) = POSITION_MANAGER.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + + if (amount0 > 0) _sendToken(TOKEN0, amount0, payable(receiver)); + if (amount1 > 0) _sendToken(TOKEN1, amount1, payable(receiver)); + } + } + + /// @dev Transfer `token` to `to`. If `token == WBNB`, unwrap first and send native BNB; + /// otherwise send as ERC-20. + /// Idle tokens (idleToken0/1) always stay in wrapped ERC-20 form; this helper + /// is only called at the exit boundary (withdraw / redeemShares / deposit refund). + function _sendToken(address token, uint256 amount, address payable to) internal { + if (token == WBNB) { + IWBNB(WBNB).withdraw(amount); + (bool ok, ) = to.call{ value: amount }(""); + require(ok, "BNB transfer failed"); + } else { + IERC20(token).safeTransfer(to, amount); + } + } + + /// @dev Accepts native BNB sent by WBNB during unwrap. + receive() external payable { + require(msg.sender == WBNB, "not WBNB"); + } + + /// @dev Returns the current liquidity of the managed V3 position. + function _getPositionLiquidity() internal view returns (uint128 liquidity) { + if (tokenId == 0) return 0; + (, , , , , , , liquidity, , , , ) = POSITION_MANAGER.positions(tokenId); + } + + /// @dev True if the sender may act on behalf of `onBehalf`. + function _isSenderAuthorized(address onBehalf) internal view returns (bool) { + return msg.sender == onBehalf || MOOLAH.isAuthorized(onBehalf, msg.sender); + } + + /* ──────── Uniswap V3 liquidity math (via v3-core libraries) ──────── */ + + /// @dev Shared implementation for getTotalAmounts() and peek(). Callers supply the + /// sqrtPriceX96 so each can use the price appropriate for its purpose: + /// slot0 for display/bots, TWAP for the lending oracle. + function _getTotalAmountsAt(uint160 sqrtPriceX96) private view returns (uint256 total0, uint256 total1) { + if (tokenId == 0) return (0, 0); + + (, , , , , , , uint128 liquidity, , , uint128 tokensOwed0, uint128 tokensOwed1) = POSITION_MANAGER.positions( + tokenId + ); + + (uint256 amount0, uint256 amount1) = _getAmountsForLiquidity( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + liquidity + ); + + // Add uncollected fees and internally-tracked idle tokens (ratio mismatch leftovers). + // Using idleToken0/1 instead of balanceOf() prevents donated tokens from inflating + // the share price reported by peek(). + total0 = amount0 + uint256(tokensOwed0) + idleToken0; + total1 = amount1 + uint256(tokensOwed1) + idleToken1; + } + + /// @dev Computes token amounts for a given liquidity position at sqrtPriceX96. + /// Delegates to SqrtPriceMath from uniswap/v3-core for overflow-safe arithmetic. + function _getAmountsForLiquidity( + uint160 sqrtPriceX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity + ) internal pure returns (uint256 amount0, uint256 amount1) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + if (sqrtPriceX96 <= sqrtRatioAX96) { + // Current price below range: position is fully TOKEN0. + amount0 = SqrtPriceMath.getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, false); + } else if (sqrtPriceX96 < sqrtRatioBX96) { + // Current price inside range. + amount0 = SqrtPriceMath.getAmount0Delta(sqrtPriceX96, sqrtRatioBX96, liquidity, false); + amount1 = SqrtPriceMath.getAmount1Delta(sqrtRatioAX96, sqrtPriceX96, liquidity, false); + } else { + // Current price above range: position is fully TOKEN1. + amount1 = SqrtPriceMath.getAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, false); + } + } + + /* ──────────────────────── upgrade guard ─────────────────────────── */ + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/src/provider/interfaces/INonfungiblePositionManager.sol b/src/provider/interfaces/INonfungiblePositionManager.sol new file mode 100644 index 00000000..6c0ddb81 --- /dev/null +++ b/src/provider/interfaces/INonfungiblePositionManager.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +/// @title Minimal interface for Uniswap V3 / PancakeSwap V3 NonfungiblePositionManager +interface INonfungiblePositionManager { + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + struct IncreaseLiquidityParams { + uint256 tokenId; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + struct DecreaseLiquidityParams { + uint256 tokenId; + uint128 liquidity; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + struct CollectParams { + uint256 tokenId; + address recipient; + uint128 amount0Max; + uint128 amount1Max; + } + + function mint( + MintParams calldata params + ) external returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + + function increaseLiquidity( + IncreaseLiquidityParams calldata params + ) external returns (uint128 liquidity, uint256 amount0, uint256 amount1); + + function decreaseLiquidity( + DecreaseLiquidityParams calldata params + ) external returns (uint256 amount0, uint256 amount1); + + function collect(CollectParams calldata params) external returns (uint256 amount0, uint256 amount1); + + function burn(uint256 tokenId) external; + + function factory() external view returns (address); + + function positions( + uint256 tokenId + ) + external + view + returns ( + uint96 nonce, + address operator, + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1 + ); +} diff --git a/src/provider/interfaces/IUniswapV3Factory.sol b/src/provider/interfaces/IUniswapV3Factory.sol new file mode 100644 index 00000000..38127cd0 --- /dev/null +++ b/src/provider/interfaces/IUniswapV3Factory.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +/// @title Minimal interface for Uniswap V3 / PancakeSwap V3 factory +interface IUniswapV3Factory { + /// @notice Returns the pool address for a given token pair and fee tier, or address(0) if none. + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool); +} diff --git a/src/provider/interfaces/IUniswapV3Pool.sol b/src/provider/interfaces/IUniswapV3Pool.sol new file mode 100644 index 00000000..6d660a3a --- /dev/null +++ b/src/provider/interfaces/IUniswapV3Pool.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +/// @title Minimal interface for Uniswap V3 / PancakeSwap V3 pool +interface IUniswapV3Pool { + function token0() external view returns (address); + + function token1() external view returns (address); + + function fee() external view returns (uint24); + + /// @return sqrtPriceX96 Current sqrt price as Q64.96 + /// @return tick Current tick + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint32 feeProtocol, + bool unlocked + ); + + /// @param secondsAgos Array of seconds in the past to query + /// @return tickCumulatives Cumulative tick values for each secondsAgo + /// @return secondsPerLiquidityCumulativeX128s Cumulative seconds-per-liquidity for each secondsAgo + function observe( + uint32[] calldata secondsAgos + ) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); + + /// @notice Swap token0 for token1, or token1 for token0 + /// @param recipient Address to receive the output tokens + /// @param zeroForOne True if swapping token0 → token1, false if token1 → token0 + /// @param amountSpecified Exact input (positive) or exact output (negative) + /// @param sqrtPriceLimitX96 Price limit; use MIN_SQRT_RATIO+1 for zeroForOne, MAX_SQRT_RATIO-1 otherwise + /// @param data Arbitrary data forwarded to the swap callback + function swap( + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 sqrtPriceLimitX96, + bytes calldata data + ) external returns (int256 amount0, int256 amount1); +} diff --git a/src/provider/interfaces/IV3Provider.sol b/src/provider/interfaces/IV3Provider.sol new file mode 100644 index 00000000..2f5ce1fc --- /dev/null +++ b/src/provider/interfaces/IV3Provider.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; +import { IProvider } from "./IProvider.sol"; + +interface IV3Provider is IProvider, IOracle { + function TOKEN0() external view returns (address); + + function TOKEN1() external view returns (address); + + function FEE() external view returns (uint24); + + function POOL() external view returns (address); + + function tokenId() external view returns (uint256); + + function tickLower() external view returns (int24); + + function tickUpper() external view returns (int24); + + /// @notice Returns total token0 and token1 amounts held by the vault, + /// including liquidity-equivalent amounts and uncollected fees. + function getTotalAmounts() external view returns (uint256 total0, uint256 total1); + + /// @notice Returns the TWAP tick for the pool over the configured TWAP_PERIOD. + function getTwapTick() external view returns (int24 twapTick); + + /// @notice Deposit token0/token1 into the V3 position and supply resulting + /// shares as Moolah collateral on behalf of `onBehalf`. + function deposit( + MarketParams calldata marketParams, + uint256 amount0Desired, + uint256 amount1Desired, + uint256 amount0Min, + uint256 amount1Min, + address onBehalf + ) external payable returns (uint256 shares, uint256 amount0Used, uint256 amount1Used); + + /// @notice Withdraw shares from Moolah, remove liquidity, and return + /// token0/token1 to `receiver`. + function withdraw( + MarketParams calldata marketParams, + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address onBehalf, + address receiver + ) external returns (uint256 amount0, uint256 amount1); + + /// @notice Redeem shares already held by the caller (e.g. a liquidator) + /// for the underlying token0/token1. + function redeemShares( + uint256 shares, + uint256 minAmount0, + uint256 minAmount1, + address receiver + ) external returns (uint256 amount0, uint256 amount1); +} diff --git a/test/liquidator/V3Liquidator.t.sol b/test/liquidator/V3Liquidator.t.sol new file mode 100644 index 00000000..4daf0128 --- /dev/null +++ b/test/liquidator/V3Liquidator.t.sol @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { V3Provider } from "../../src/provider/V3Provider.sol"; +import { V3Liquidator } from "../../src/liquidator/V3Liquidator.sol"; +import { IUniswapV3Pool } from "../../src/provider/interfaces/IUniswapV3Pool.sol"; +import { Moolah } from "../../src/moolah/Moolah.sol"; +import { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; + +import { MockOneInch } from "./mocks/MockOneInch.sol"; + +contract V3LiquidatorTest is Test { + using MarketParamsLib for MarketParams; + + /* ─────────────────── PancakeSwap V3 BSC mainnet ─────────────────── */ + address constant POOL = 0x4141325bAc36aFFe9Db165e854982230a14e6d48; // USDC/WBNB + address constant NPM = 0x7b8A01B39D58278b5DE7e48c8449c9f4F5170613; + uint24 constant FEE = 100; + + /* ───────────────────────────── tokens ───────────────────────────── */ + address constant USDC = 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d; // token0 + address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; // token1 + address constant LISUSD = 0x0782b6d8c4551B9760e74c0545a9bCD90bdc41E5; + address constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /* ──────────────────────── Moolah ecosystem ──────────────────────── */ + address constant MOOLAH_PROXY = 0x8F73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C; + address constant TIMELOCK = 0x07D274a68393E8b8a2CCf19A2ce4Ba3518735253; + address constant OPERATOR = 0xd7e38800201D6a42C408Bf79d8723740C4E7f631; + address constant MANAGER_ADDR = 0x8d388136d578dCD791D081c6042284CED6d9B0c6; + address constant RESILIENT_ORACLE = 0xf3afD82A4071f272F403dC176916141f44E6c750; + address constant IRM = 0xFe7dAe87Ebb11a7BEB9F534BB23267992d9cDe7c; + + uint32 constant TWAP_PERIOD = 1800; + uint256 constant LLTV = 70 * 1e16; + + /* ───────────────────────── test contracts ───────────────────────── */ + Moolah moolah; + V3Provider provider; + V3Liquidator liquidator; + MockOneInch mockSwap; + MarketParams marketParams; + Id marketId; + + /* ───────────────────────── test accounts ────────────────────────── */ + address admin = makeAddr("admin"); + address manager = makeAddr("manager"); + address bot = makeAddr("bot"); + address user = makeAddr("user"); + + /* ────────────────────────────── setUp ───────────────────────────── */ + + function setUp() public { + vm.createSelectFork(vm.envString("BSC_RPC"), 60541406); + + // Upgrade Moolah to the latest local implementation. + address newImpl = address(new Moolah()); + vm.prank(TIMELOCK); + UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newImpl, bytes("")); + moolah = Moolah(MOOLAH_PROXY); + + // Deploy V3Provider. + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + V3Provider implP = new V3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); + provider = V3Provider( + payable( + new ERC1967Proxy( + address(implP), + abi.encodeCall( + V3Provider.initialize, + (admin, manager, bot, RESILIENT_ORACLE, currentTick - 500, currentTick + 500, "V3LP USDC/WBNB", "v3LP") + ) + ) + ) + ); + + // Deploy V3Liquidator. + V3Liquidator implL = new V3Liquidator(MOOLAH_PROXY); + liquidator = V3Liquidator( + payable(new ERC1967Proxy(address(implL), abi.encodeCall(V3Liquidator.initialize, (admin, manager, bot)))) + ); + + mockSwap = new MockOneInch(); + + // Build Moolah market: collateral = provider shares, oracle = provider. + marketParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(provider), + irm: IRM, + lltv: LLTV + }); + marketId = marketParams.id(); + + vm.prank(OPERATOR); + moolah.createMarket(marketParams); + + vm.prank(MANAGER_ADDR); + moolah.setProvider(marketId, address(provider), true); + + // Seed lisUSD liquidity so borrows can succeed. + deal(LISUSD, address(this), 1_000_000 ether); + IERC20(LISUSD).approve(MOOLAH_PROXY, 1_000_000 ether); + moolah.supply(marketParams, 1_000_000 ether, 0, address(this), ""); + + // Configure liquidator whitelists. + vm.startPrank(manager); + liquidator.setTokenWhitelist(USDC, true); + liquidator.setTokenWhitelist(LISUSD, true); + liquidator.setTokenWhitelist(BNB_ADDRESS, true); + liquidator.setMarketWhitelist(Id.unwrap(marketId), true); + liquidator.setPairWhitelist(address(mockSwap), true); + liquidator.setV3ProviderWhitelist(address(provider), true); + vm.stopPrank(); + } + + /* ──────────────────────── helper fns ───────────────────────────── */ + + function _deposit( + address _user, + uint256 amount0, + uint256 amount1 + ) internal returns (uint256 shares, uint256 used0, uint256 used1) { + deal(USDC, _user, amount0); + deal(WBNB, _user, amount1); + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + vm.startPrank(_user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + (shares, used0, used1) = provider.deposit( + marketParams, + amount0, + amount1, + (exp0 * 999) / 1000, + (exp1 * 999) / 1000, + _user + ); + vm.stopPrank(); + } + + function _collateral(address _user) internal view returns (uint256) { + (, , uint256 col) = moolah.position(marketId, _user); + return col; + } + + /// @dev Borrow 60% of user's collateral value — healthy, but mocking oracle to 0 makes it unhealthy. + function _borrowAgainstCollateral(address _user) internal returns (uint256 borrowed) { + (, , uint128 col) = moolah.position(marketId, _user); + uint256 sharePrice = provider.peek(address(provider)); + uint256 loanPrice = provider.peek(LISUSD); + borrowed = (uint256(col) * sharePrice * 60) / (loanPrice * 100); + vm.prank(_user); + moolah.borrow(marketParams, borrowed, 0, _user, _user); + } + + /// @dev Mock collateral oracle to zero, making any indebted position liquidatable. + function _makeUnhealthy() internal { + vm.mockCall( + address(provider), + abi.encodeWithSelector(IOracle.peek.selector, address(provider)), + abi.encode(uint256(0)) + ); + } + + /* ─────────────────── whitelist management ───────────────────────── */ + + function test_setTokenWhitelist_togglesAndReverts() public { + vm.prank(manager); + liquidator.setTokenWhitelist(WBNB, true); + assertTrue(liquidator.tokenWhitelist(WBNB)); + + vm.prank(manager); + vm.expectRevert(V3Liquidator.WhitelistSameStatus.selector); + liquidator.setTokenWhitelist(WBNB, true); + + vm.prank(user); + vm.expectRevert(); + liquidator.setTokenWhitelist(WBNB, false); + } + + function test_setMarketWhitelist_toggles() public { + bytes32 id = Id.unwrap(marketId); + + vm.prank(manager); + liquidator.setMarketWhitelist(id, false); + assertFalse(liquidator.marketWhitelist(id)); + + vm.prank(manager); + liquidator.setMarketWhitelist(id, true); + assertTrue(liquidator.marketWhitelist(id)); + } + + function test_batchSetMarketWhitelist_updatesAll() public { + bytes32[] memory ids = new bytes32[](1); + ids[0] = Id.unwrap(marketId); + + vm.prank(manager); + liquidator.batchSetMarketWhitelist(ids, false); + assertFalse(liquidator.marketWhitelist(ids[0])); + + vm.prank(manager); + liquidator.batchSetMarketWhitelist(ids, true); + assertTrue(liquidator.marketWhitelist(ids[0])); + } + + function test_setPairWhitelist_togglesAndReverts() public { + address pair = makeAddr("pair"); + + vm.prank(manager); + liquidator.setPairWhitelist(pair, true); + assertTrue(liquidator.pairWhitelist(pair)); + + vm.prank(manager); + vm.expectRevert(V3Liquidator.WhitelistSameStatus.selector); + liquidator.setPairWhitelist(pair, true); + } + + function test_setV3ProviderWhitelist_togglesAndReverts() public { + address prov = makeAddr("prov"); + + vm.prank(manager); + liquidator.setV3ProviderWhitelist(prov, true); + assertTrue(liquidator.v3Providers(prov)); + + vm.prank(manager); + vm.expectRevert(V3Liquidator.WhitelistSameStatus.selector); + liquidator.setV3ProviderWhitelist(prov, true); + } + + function test_batchSetV3Providers_updatesAll() public { + address prov1 = makeAddr("prov1"); + address prov2 = makeAddr("prov2"); + address[] memory provs = new address[](2); + provs[0] = prov1; + provs[1] = prov2; + + vm.prank(manager); + liquidator.batchSetV3Providers(provs, true); + assertTrue(liquidator.v3Providers(prov1)); + assertTrue(liquidator.v3Providers(prov2)); + } + + /* ─────────────────── access control ─────────────────────────────── */ + + function test_liquidate_revertsIfNotBot() public { + vm.prank(user); + vm.expectRevert(); + liquidator.liquidate(Id.unwrap(marketId), user, 1, 0); + } + + function test_flashLiquidate_revertsIfNotBot() public { + V3Liquidator.FlashLiquidateParams memory params; + params.v3Provider = address(provider); + + vm.prank(user); + vm.expectRevert(); + liquidator.flashLiquidate(Id.unwrap(marketId), user, 1, params); + } + + function test_redeemV3Shares_revertsIfNotBot() public { + vm.prank(user); + vm.expectRevert(); + liquidator.redeemV3Shares(address(provider), 1, 0, 0, user); + } + + function test_sellToken_revertsIfNotBot() public { + vm.prank(user); + vm.expectRevert(); + liquidator.sellToken(address(mockSwap), USDC, LISUSD, 1, 0, ""); + } + + /* ─────────────────── liquidate (pre-funded) ─────────────────────── */ + + function test_liquidate_prefunded_receivesShares() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + + deal(LISUSD, address(liquidator), 1_000 ether); + + vm.prank(bot); + liquidator.liquidate(Id.unwrap(marketId), user, shares, 0); + + assertGt(provider.balanceOf(address(liquidator)), 0, "liquidator received shares"); + assertEq(_collateral(user), 0, "borrower collateral seized"); + assertEq(IERC20(LISUSD).allowance(address(liquidator), MOOLAH_PROXY), 0, "loanToken allowance cleared"); + } + + function test_liquidate_revertsIfMarketNotWhitelisted() public { + vm.prank(manager); + liquidator.setMarketWhitelist(Id.unwrap(marketId), false); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.liquidate(Id.unwrap(marketId), user, 1, 0); + } + + /* ─────────────────── flashLiquidate ─────────────────────────────── */ + + function test_flashLiquidate_holdShares_noRedeem() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + uint256 borrowed = _borrowAgainstCollateral(user); + _makeUnhealthy(); + + // Pre-fund with enough lisUSD: onMoolahLiquidate approves Moolah even when not redeeming. + deal(LISUSD, address(liquidator), borrowed * 2); + + V3Liquidator.FlashLiquidateParams memory params = V3Liquidator.FlashLiquidateParams({ + v3Provider: address(provider), + minToken0Amt: 0, + minToken1Amt: 0, + redeemShares: false, + token0Pair: address(0), + token0Spender: address(0), + token1Pair: address(0), + token1Spender: address(0), + swapToken0Data: "", + swapToken1Data: "" + }); + + vm.prank(bot); + liquidator.flashLiquidate(Id.unwrap(marketId), user, shares, params); + + assertGt(provider.balanceOf(address(liquidator)), 0, "liquidator holds seized shares"); + assertEq(_collateral(user), 0, "borrower collateral cleared"); + } + + function test_flashLiquidate_redeemAndSwap_coveredBySwapProfit() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + uint256 borrowed = _borrowAgainstCollateral(user); + _makeUnhealthy(); + + // token0 (USDC) swap: amountIn=0 so mock accepts any approval; produces borrowed*2 lisUSD. + // This ensures the NoProfit check passes without knowing the exact repaidAssets upfront. + bytes memory swap0Data = abi.encodeWithSelector( + mockSwap.swap.selector, + USDC, // tokenIn + LISUSD, // tokenOut + uint256(0), // amountIn (mock pulls nothing; residual USDC stays in liquidator) + borrowed * 2 // amountOutMin — enough to cover repayment + ); + + // token1 (WBNB) swap: V3Provider unwraps WBNB → native BNB, V3Liquidator sends it via call{value}. + // amountIn=0 so msg.value >= 0 always passes; MockOneInch refunds BNB to liquidator, gives 0 lisUSD. + bytes memory swap1Data = abi.encodeWithSelector( + mockSwap.swap.selector, + BNB_ADDRESS, // tokenIn (native BNB path) + LISUSD, + uint256(0), // amountIn + uint256(0) // no extra lisUSD needed from this leg + ); + + V3Liquidator.FlashLiquidateParams memory params = V3Liquidator.FlashLiquidateParams({ + v3Provider: address(provider), + minToken0Amt: 0, + minToken1Amt: 0, + redeemShares: true, + token0Pair: address(mockSwap), + token0Spender: address(0), + token1Pair: address(mockSwap), + token1Spender: address(0), + swapToken0Data: swap0Data, + swapToken1Data: swap1Data + }); + + vm.prank(bot); + liquidator.flashLiquidate(Id.unwrap(marketId), user, shares, params); + + assertEq(provider.balanceOf(address(liquidator)), 0, "shares redeemed in callback"); + assertEq(_collateral(user), 0, "borrower collateral seized"); + // Excess lisUSD (borrowed*2 - repaidAssets ≈ borrowed) remains in liquidator. + assertGt(IERC20(LISUSD).balanceOf(address(liquidator)), 0, "excess lisUSD in liquidator"); + } + + function test_flashLiquidate_revertsIfMarketNotWhitelisted() public { + vm.prank(manager); + liquidator.setMarketWhitelist(Id.unwrap(marketId), false); + + V3Liquidator.FlashLiquidateParams memory params; + params.v3Provider = address(provider); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.flashLiquidate(Id.unwrap(marketId), user, 1, params); + } + + function test_flashLiquidate_revertsIfProviderNotWhitelisted() public { + vm.prank(manager); + liquidator.setV3ProviderWhitelist(address(provider), false); + + V3Liquidator.FlashLiquidateParams memory params; + params.v3Provider = address(provider); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.flashLiquidate(Id.unwrap(marketId), user, 1, params); + } + + function test_flashLiquidate_revertsIfProviderMarketMismatch() public { + // Register a second provider that is not the collateral for this market. + address fakeProvider = makeAddr("fakeProvider"); + vm.prank(manager); + liquidator.setV3ProviderWhitelist(fakeProvider, true); + + V3Liquidator.FlashLiquidateParams memory params; + params.v3Provider = fakeProvider; + + vm.prank(bot); + vm.expectRevert("provider/market mismatch"); + liquidator.flashLiquidate(Id.unwrap(marketId), user, 1, params); + } + + /* ─────────────────── redeemV3Shares ─────────────────────────────── */ + + function test_redeemV3Shares_redemeesSharesToTokens() public { + // Acquire shares via pre-funded liquidation. + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + deal(LISUSD, address(liquidator), 1_000 ether); + vm.prank(bot); + liquidator.liquidate(Id.unwrap(marketId), user, shares, 0); + + uint256 heldShares = provider.balanceOf(address(liquidator)); + assertGt(heldShares, 0, "setup: liquidator holds shares"); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(heldShares); + + vm.prank(bot); + (uint256 out0, uint256 out1) = liquidator.redeemV3Shares( + address(provider), + heldShares, + (exp0 * 999) / 1000, + (exp1 * 999) / 1000, + address(liquidator) + ); + + assertEq(provider.balanceOf(address(liquidator)), 0, "shares burned after redeem"); + assertGt(out0 + out1, 0, "tokens received"); + assertEq(IERC20(USDC).balanceOf(address(liquidator)), out0, "USDC received"); + assertEq(address(liquidator).balance, out1, "BNB received (WBNB unwrapped)"); + } + + function test_redeemV3Shares_revertsIfProviderNotWhitelisted() public { + vm.prank(manager); + liquidator.setV3ProviderWhitelist(address(provider), false); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.redeemV3Shares(address(provider), 1, 0, 0, address(liquidator)); + } + + /* ─────────────────── sell token ─────────────────────────────────── */ + + function test_sellToken_erc20_swapsAndClearsAllowance() public { + uint256 amountIn = 100 ether; + uint256 amountOut = 50 ether; + deal(USDC, address(liquidator), amountIn); + + bytes memory swapData = abi.encodeWithSelector(mockSwap.swap.selector, USDC, LISUSD, amountIn, amountOut); + + vm.prank(bot); + liquidator.sellToken(address(mockSwap), USDC, LISUSD, amountIn, amountOut, swapData); + + assertEq(IERC20(LISUSD).balanceOf(address(liquidator)), amountOut, "received lisUSD"); + assertEq(IERC20(USDC).balanceOf(address(liquidator)), 0, "USDC consumed"); + assertEq(IERC20(USDC).allowance(address(liquidator), address(mockSwap)), 0, "allowance cleared"); + } + + function test_sellToken_revertsIfTokenNotWhitelisted() public { + deal(WBNB, address(liquidator), 1 ether); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.sellToken(address(mockSwap), WBNB, LISUSD, 1 ether, 0, ""); + } + + function test_sellToken_revertsIfPairNotWhitelisted() public { + address fakePair = makeAddr("fakePair"); + deal(USDC, address(liquidator), 1 ether); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.NotWhitelisted.selector); + liquidator.sellToken(fakePair, USDC, LISUSD, 1 ether, 0, ""); + } + + function test_sellToken_revertsIfAmountExceedsBalance() public { + deal(USDC, address(liquidator), 50 ether); + + vm.prank(bot); + vm.expectRevert(V3Liquidator.ExceedAmount.selector); + liquidator.sellToken(address(mockSwap), USDC, LISUSD, 100 ether, 0, ""); + } + + function test_sellBNB_swapsNativeBNB() public { + uint256 amountIn = 1 ether; + uint256 amountOut = 500 ether; + deal(address(liquidator), amountIn); + + bytes memory swapData = abi.encodeWithSelector(mockSwap.swap.selector, BNB_ADDRESS, LISUSD, amountIn, amountOut); + + vm.prank(bot); + liquidator.sellBNB(address(mockSwap), LISUSD, amountIn, amountOut, swapData); + + assertEq(IERC20(LISUSD).balanceOf(address(liquidator)), amountOut, "received lisUSD"); + assertEq(address(liquidator).balance, 0, "BNB consumed"); + } + + /* ─────────────────── withdrawals ────────────────────────────────── */ + + function test_withdrawERC20_sendsToManager() public { + uint256 amount = 100 ether; + deal(LISUSD, address(liquidator), amount); + + vm.prank(manager); + liquidator.withdrawERC20(LISUSD, amount); + + assertEq(IERC20(LISUSD).balanceOf(manager), amount); + assertEq(IERC20(LISUSD).balanceOf(address(liquidator)), 0); + } + + function test_withdrawETH_sendsToManager() public { + uint256 amount = 1 ether; + deal(address(liquidator), amount); + + vm.prank(manager); + liquidator.withdrawETH(amount); + + assertEq(manager.balance, amount); + assertEq(address(liquidator).balance, 0); + } + + function test_withdrawERC20_revertsIfNotManager() public { + vm.prank(user); + vm.expectRevert(); + liquidator.withdrawERC20(LISUSD, 1); + } +} diff --git a/test/provider/V3Provider.t.sol b/test/provider/V3Provider.t.sol new file mode 100644 index 00000000..c73f1dfc --- /dev/null +++ b/test/provider/V3Provider.t.sol @@ -0,0 +1,1544 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { V3Provider } from "../../src/provider/V3Provider.sol"; +import { IUniswapV3Pool } from "../../src/provider/interfaces/IUniswapV3Pool.sol"; +import { Moolah } from "../../src/moolah/Moolah.sol"; +import { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; +import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; +import { TokenConfig, IOracle } from "moolah/interfaces/IOracle.sol"; +import { SlisBNBxMinter, ISlisBNBx } from "../../src/utils/SlisBNBxMinter.sol"; + +/// @dev Helper that executes a direct pool swap and satisfies the PancakeSwap V3 callback. +contract PoolSwapper { + // MIN / MAX sqrt ratios from TickMath (ticks ±887272) + uint160 internal constant MIN_SQRT_RATIO = 4295128739; + uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; + + /// @notice Swap tokenIn → tokenOut by selling `amountIn` worth of tokenIn. + /// zeroForOne = true → token0 in, token1 out (price moves down) + /// zeroForOne = false → token1 in, token0 out (price moves up) + function swapExactIn(address pool, bool zeroForOne, uint256 amountIn) external { + uint160 limit = zeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1; + IUniswapV3Pool(pool).swap(address(this), zeroForOne, int256(amountIn), limit, abi.encode(pool)); + } + + /// @dev PancakeSwap V3 swap callback — pay whatever the pool pulled. + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { + address pool = abi.decode(data, (address)); + if (amount0Delta > 0) IERC20(IUniswapV3Pool(pool).token0()).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) IERC20(IUniswapV3Pool(pool).token1()).transfer(msg.sender, uint256(amount1Delta)); + } +} + +contract V3ProviderTest is Test { + using MarketParamsLib for MarketParams; + + /* ─────────────────── PancakeSwap V3 BSC mainnet ─────────────────── */ + address constant POOL = 0x4141325bAc36aFFe9Db165e854982230a14e6d48; // USDC/WBNB + address constant NPM = 0x7b8A01B39D58278b5DE7e48c8449c9f4F5170613; + uint24 constant FEE = 100; + + /* ───────────────────────────── tokens ───────────────────────────── */ + address constant USDC = 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d; // token0 + address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; // token1 + + /* ──────────────────────── Moolah ecosystem ──────────────────────── */ + address constant MOOLAH_PROXY = 0x8F73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C; + address constant TIMELOCK = 0x07D274a68393E8b8a2CCf19A2ce4Ba3518735253; + address constant OPERATOR = 0xd7e38800201D6a42C408Bf79d8723740C4E7f631; + address constant MANAGER_ADDR = 0x8d388136d578dCD791D081c6042284CED6d9B0c6; + address constant LISUSD = 0x0782b6d8c4551B9760e74c0545a9bCD90bdc41E5; + address constant RESILIENT_ORACLE = 0xf3afD82A4071f272F403dC176916141f44E6c750; + address constant IRM = 0xFe7dAe87Ebb11a7BEB9F534BB23267992d9cDe7c; + + uint32 constant TWAP_PERIOD = 1800; // 30 minutes + uint256 constant LLTV = 70 * 1e16; + + /* ───────────────────────── test contracts ───────────────────────── */ + Moolah moolah; + V3Provider provider; + MarketParams marketParams; + Id marketId; + + /* ───────────────────────── test accounts ────────────────────────── */ + address admin = makeAddr("admin"); + address manager = makeAddr("manager"); + address bot = makeAddr("bot"); + address user = makeAddr("user"); + address user2 = makeAddr("user2"); + + /* ────────────────────────────── setUp ───────────────────────────── */ + + function setUp() public { + vm.createSelectFork(vm.envString("BSC_RPC"), 60541406); + + // Upgrade Moolah to the latest local implementation. + address newImpl = address(new Moolah()); + vm.prank(TIMELOCK); + UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newImpl, bytes("")); + moolah = Moolah(MOOLAH_PROXY); + + // Derive initial tick range from the live pool. + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 tickLower = currentTick - 500; + int24 tickUpper = currentTick + 500; + + // Deploy V3Provider (implementation + UUPS proxy). + V3Provider impl = new V3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); + bytes memory initData = abi.encodeCall( + V3Provider.initialize, + (admin, manager, bot, RESILIENT_ORACLE, tickLower, tickUpper, "V3Provider USDC/WBNB", "v3LP-USDC-WBNB") + ); + provider = V3Provider(payable(new ERC1967Proxy(address(impl), initData))); + + // Build Moolah market: collateral = provider shares, oracle = provider. + marketParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(provider), + irm: IRM, + lltv: LLTV + }); + marketId = marketParams.id(); + + // Create market and register V3Provider as the Moolah provider. + vm.prank(OPERATOR); + moolah.createMarket(marketParams); + + vm.prank(MANAGER_ADDR); + moolah.setProvider(marketId, address(provider), true); + + // Seed market with lisUSD so borrow tests can succeed. + deal(LISUSD, address(this), 1_000_000 ether); + IERC20(LISUSD).approve(MOOLAH_PROXY, 1_000_000 ether); + moolah.supply(marketParams, 1_000_000 ether, 0, address(this), ""); + } + + /* ────────────────────────── helper fns ─────────────────────────── */ + + function _deposit( + address _user, + uint256 amount0, + uint256 amount1 + ) internal returns (uint256 shares, uint256 used0, uint256 used1) { + deal(USDC, _user, amount0); + deal(WBNB, _user, amount1); + // Derive tight min amounts (0.1% slippage) from previewDeposit so that we + // never bypass the slippage guard with zeros. + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + uint256 min0 = (exp0 * 999) / 1000; + uint256 min1 = (exp1 * 999) / 1000; + vm.startPrank(_user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + (shares, used0, used1) = provider.deposit(marketParams, amount0, amount1, min0, min1, _user); + vm.stopPrank(); + } + + function _collateral(address _user) internal view returns (uint256) { + (, , uint256 col) = moolah.position(marketId, _user); + return col; + } + + /* ────────────────────────── test cases ─────────────────────────── */ + + function test_initialize() public view { + assertEq(provider.TOKEN0(), USDC); + assertEq(provider.TOKEN1(), WBNB); + assertEq(provider.FEE(), FEE); + assertEq(provider.POOL(), POOL); + assertEq(address(provider.MOOLAH()), MOOLAH_PROXY); + assertEq(address(provider.POSITION_MANAGER()), NPM); + assertEq(provider.resilientOracle(), RESILIENT_ORACLE); + assertEq(provider.TWAP_PERIOD(), TWAP_PERIOD); + assertTrue(provider.hasRole(provider.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(provider.hasRole(provider.MANAGER(), manager)); + assertTrue(provider.hasRole(provider.BOT(), bot)); + // BOT role admin is MANAGER + assertEq(provider.getRoleAdmin(provider.BOT()), provider.MANAGER()); + } + + function test_deposit_firstDeposit() public { + uint256 amount0 = 1_000 ether; // USDC + uint256 amount1 = 3 ether; // WBNB + + (uint256 shares, uint256 used0, uint256 used1) = _deposit(user, amount0, amount1); + + assertGt(shares, 0, "should mint shares"); + assertGt(used0 + used1, 0, "should consume tokens"); + + // Collateral position in Moolah equals shares minted. + assertEq(_collateral(user), shares, "Moolah collateral should equal shares"); + + // Shares are held by Moolah, not user. + assertEq(provider.balanceOf(user), 0, "user should hold no shares directly"); + assertEq(provider.balanceOf(MOOLAH_PROXY), shares, "Moolah should hold shares"); + + // Unused tokens refunded to caller. + // USDC refunded as ERC-20; WBNB (TOKEN1 = WRAPPED_NATIVE) refunded as native BNB. + assertEq(IERC20(USDC).balanceOf(user), amount0 - used0); + assertEq(user.balance, amount1 - used1); + } + + function test_deposit_secondDeposit_sharesProportional() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 sharesAfterFirst = _collateral(user); + + (uint256 shares2, , ) = _deposit(user2, 2_000 ether, 6 ether); + + // Second depositor contributes roughly twice as much — shares should be ~2x. + assertApproxEqRel(shares2, sharesAfterFirst * 2, 0.01e18, "second deposit shares should be ~2x"); + } + + function test_withdraw_fullWithdrawal() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + uint256 usdcBefore = IERC20(USDC).balanceOf(user); + uint256 bnbBefore = user.balance; // WBNB (TOKEN1) is unwrapped to native BNB on withdrawal + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + uint256 min0 = (exp0 * 999) / 1000; + uint256 min1 = (exp1 * 999) / 1000; + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, min0, min1, user, user); + + // Collateral cleared. + assertEq(_collateral(user), 0, "collateral should be zero after full withdrawal"); + + // Tokens returned. + assertGt(out0 + out1, 0, "should receive tokens back"); + assertEq(IERC20(USDC).balanceOf(user), usdcBefore + out0); + assertEq(user.balance, bnbBefore + out1); // WBNB unwrapped to BNB + } + + function test_withdraw_partialWithdrawal() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares / 2); + uint256 min0 = (exp0 * 999) / 1000; + uint256 min1 = (exp1 * 999) / 1000; + + vm.prank(user); + provider.withdraw(marketParams, shares / 2, min0, min1, user, user); + + assertApproxEqAbs(_collateral(user), shares / 2, 1, "half collateral should remain"); + } + + function test_withdraw_revertsIfUnauthorized() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 shares = _collateral(user); + + // user2 cannot withdraw on behalf of user without authorization. + // The revert fires on the auth check before min amounts are evaluated; use 1,1. + vm.prank(user2); + vm.expectRevert("unauthorized"); + provider.withdraw(marketParams, shares, 1, 1, user, user2); + } + + function test_redeemShares_byLiquidator() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + address liquidator = makeAddr("liquidator"); + + // Simulate Moolah transferring shares to liquidator during liquidation. + // (transfer is restricted to Moolah — prank as Moolah to move shares) + vm.prank(MOOLAH_PROXY); + provider.transfer(liquidator, shares); + + assertEq(provider.balanceOf(liquidator), shares); + + uint256 usdcBefore = IERC20(USDC).balanceOf(liquidator); + uint256 bnbBefore = liquidator.balance; // WBNB (TOKEN1) is unwrapped to native BNB + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + uint256 min0 = (exp0 * 999) / 1000; + uint256 min1 = (exp1 * 999) / 1000; + + vm.prank(liquidator); + (uint256 out0, uint256 out1) = provider.redeemShares(shares, min0, min1, liquidator); + + assertEq(provider.balanceOf(liquidator), 0, "shares should be burned"); + assertGt(out0 + out1, 0, "liquidator should receive tokens"); + assertEq(IERC20(USDC).balanceOf(liquidator), usdcBefore + out0); + assertEq(liquidator.balance, bnbBefore + out1); // WBNB unwrapped to BNB + } + + function test_transferRestriction_directTransferReverts() public { + _deposit(user, 1_000 ether, 3 ether); + + vm.prank(user); + vm.expectRevert("only moolah"); + provider.transfer(user2, 1); + } + + function test_transferRestriction_transferFromReverts() public { + _deposit(user, 1_000 ether, 3 ether); + + vm.prank(user); + vm.expectRevert("only moolah"); + provider.transferFrom(MOOLAH_PROXY, user2, 1); + } + + function test_rebalance_onlyBot() public { + _deposit(user, 1_000 ether, 3 ether); + + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 newLower = currentTick - 1000; + int24 newUpper = currentTick + 1000; + + // manager cannot rebalance — revert fires on role check before amounts matter. + vm.prank(manager); + vm.expectRevert(); + provider.rebalance(newLower, newUpper, 1, 1, 1, 1); + + // bot can rebalance — pass full available amounts so pool picks optimal ratio. + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + uint256 min0 = (total0 * 999) / 1000; + uint256 min1 = (total1 * 999) / 1000; + vm.prank(bot); + provider.rebalance(newLower, newUpper, min0, min1, total0, total1); + + assertEq(provider.tickLower(), newLower); + assertEq(provider.tickUpper(), newUpper); + } + + function test_rebalance_liquidity_preserved() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); + + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + uint256 min0 = (total0 * 999) / 1000; + uint256 min1 = (total1 * 999) / 1000; + vm.prank(bot); + provider.rebalance(currentTick - 1000, currentTick + 1000, min0, min1, total0, total1); + + // Share count is unchanged after rebalance. + assertEq(_collateral(user), shares, "shares should be unchanged after rebalance"); + + // Total amounts should be roughly preserved (small dust from ratio mismatch is acceptable). + (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); + uint256 valueBefore = total0Before + total1Before; + uint256 valueAfter = total0After + total1After; + assertApproxEqRel(valueAfter, valueBefore, 0.02e18, "total value should be preserved within 2%"); + } + + function test_peek_zeroBeforeDeposit() public view { + assertEq(provider.peek(address(provider)), 0, "price should be 0 with no deposits"); + } + + function test_peek_nonZeroAfterDeposit() public { + _deposit(user, 1_000 ether, 3 ether); + + uint256 price = provider.peek(address(provider)); + assertGt(price, 0, "share price should be non-zero after deposit"); + } + + function test_getTwapTick_nearCurrentTick() public view { + int24 twapTick = provider.getTwapTick(); + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // TWAP tick should be within a reasonable distance of the current tick. + int256 diff = int256(currentTick) - int256(twapTick); + if (diff < 0) diff = -diff; + assertLt(diff, 500, "TWAP tick should be near current tick"); + } + + function test_getTotalAmounts_nonZeroAfterDeposit() public { + _deposit(user, 1_000 ether, 3 ether); + + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + assertGt(total0 + total1, 0, "total amounts should be non-zero after deposit"); + } + + function test_compoundFees_shareValueIncreasesOverTime() public { + // mock USDC price + vm.mockCall( + RESILIENT_ORACLE, + abi.encodeWithSelector(IOracle.peek.selector, USDC), + abi.encode(1e8) // $1 with 8 decimals + ); + + // mock WBNB price; $700 + vm.mockCall( + RESILIENT_ORACLE, + abi.encodeWithSelector(IOracle.peek.selector, WBNB), + abi.encode(700 * 1e8) // $700 with 8 decimals + ); + + // Stabilise the TWAP tick across the vm.warp by mocking pool.observe to always + // return tick cumulatives consistent with the current slot0 tick. Without this, + // the 7-day warp shifts the TWAP window from real BSC history to pure extrapolation, + // producing a spurious ~0.3% price delta that has nothing to do with fee compounding. + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int56[] memory tickCumulatives = new int56[](2); + tickCumulatives[0] = 0; + tickCumulatives[1] = int56(currentTick) * int56(uint56(TWAP_PERIOD)); + uint160[] memory secondsPerLiq = new uint160[](2); + vm.mockCall( + POOL, + abi.encodeWithSelector(IUniswapV3Pool.observe.selector), + abi.encode(tickCumulatives, secondsPerLiq) + ); + + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + uint256 priceBefore = provider.peek(address(provider)); + + // Simulate time passing and swap activity accumulating fees by warping forward. + vm.warp(block.timestamp + 7 days); + + // A second deposit triggers _collectAndCompound internally. + _deposit(user2, 1_000 ether, 3 ether); + + uint256 priceAfter = provider.peek(address(provider)); + + // Share price should be >= before (fees compounded, no value destroyed). + assertGe(priceAfter, priceBefore, "share price should not decrease after compounding"); + + // user's collateral share count is unchanged. + assertEq(_collateral(user), shares); + } + + /// @dev Helper: deposit with explicit min amounts (bypasses _deposit which passes zeros). + function _depositWithMin( + address _user, + uint256 amount0, + uint256 amount1, + uint256 min0, + uint256 min1 + ) internal returns (uint256 shares, uint256 used0, uint256 used1) { + deal(USDC, _user, amount0); + deal(WBNB, _user, amount1); + vm.startPrank(_user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + (shares, used0, used1) = provider.deposit(marketParams, amount0, amount1, min0, min1, _user); + vm.stopPrank(); + } + + /* ──────────────── previewDeposit tests ─────────────────────────── */ + + function test_previewDeposit_amountsMatchActual() public { + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + (uint128 liquidity, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + assertGt(liquidity, 0, "liquidity should be non-zero"); + // Both preview amounts must be within the desired amounts. + assertLe(exp0, amount0, "exp0 must not exceed desired"); + assertLe(exp1, amount1, "exp1 must not exceed desired"); + assertGt(exp0 + exp1, 0, "at least one token must be consumed"); + + // Actual deposit should consume within 1 wei of what previewDeposit predicted + // (NPM uses the same math with possible ±1 rounding differences). + uint256 min0 = exp0 > 0 ? exp0 - 1 : 0; + uint256 min1 = exp1 > 0 ? exp1 - 1 : 0; + (, uint256 used0, uint256 used1) = _depositWithMin(user, amount0, amount1, min0, min1); + + assertApproxEqAbs(used0, exp0, 1, "used0 should match preview within 1 wei"); + assertApproxEqAbs(used1, exp1, 1, "used1 should match preview within 1 wei"); + } + + function test_previewDeposit_derivedMinAmounts_succeed() public { + uint256 amount0 = 5_000 ether; + uint256 amount1 = 15 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + // Apply 0.5% slippage tolerance. + uint256 min0 = (exp0 * 995) / 1000; + uint256 min1 = (exp1 * 995) / 1000; + + (uint256 shares, uint256 used0, uint256 used1) = _depositWithMin(user, amount0, amount1, min0, min1); + + assertGt(shares, 0, "should mint shares"); + assertGe(used0, min0, "used0 >= min0"); + assertGe(used1, min1, "used1 >= min1"); + } + + function test_previewDeposit_priceBelowRange_onlyToken0() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + // Position is fully USDC — only token0 consumed, token1 = 0. + assertGt(exp0, 0, "expected token0 consumed when price below range"); + assertEq(exp1, 0, "expected no token1 consumed when price below range"); + } + + function test_previewDeposit_priceAboveRange_onlyToken1() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + // Position is fully WBNB — only token1 consumed, token0 = 0. + assertEq(exp0, 0, "expected no token0 consumed when price above range"); + assertGt(exp1, 0, "expected token1 consumed when price above range"); + } + + function test_previewDeposit_secondDeposit_matchesActual() public { + // Seed an initial position so the second deposit goes through increaseLiquidity. + _deposit(user, 1_000 ether, 3 ether); + + uint256 amount0 = 2_000 ether; + uint256 amount1 = 6 ether; + + (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + + uint256 min0 = exp0 > 0 ? exp0 - 1 : 0; + uint256 min1 = exp1 > 0 ? exp1 - 1 : 0; + (, uint256 used0, uint256 used1) = _depositWithMin(user2, amount0, amount1, min0, min1); + + assertApproxEqAbs(used0, exp0, 1, "used0 should match preview within 1 wei on second deposit"); + assertApproxEqAbs(used1, exp1, 1, "used1 should match preview within 1 wei on second deposit"); + } + + /* ──────────────── previewRedeem tests ──────────────────────────── */ + + function test_previewRedeem_zeroBeforeDeposit() public view { + (uint256 amount0, uint256 amount1) = provider.previewRedeem(1 ether); + assertEq(amount0, 0, "should return 0 when no position exists"); + assertEq(amount1, 0, "should return 0 when no position exists"); + } + + function test_previewRedeem_matchesActualWithdraw() public { + // Price is inside the tick range: preview predicts both tokens, withdraw returns both. + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + assertGt(currentTick, provider.tickLower(), "price should be above tickLower"); + assertLt(currentTick, provider.tickUpper(), "price should be below tickUpper"); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertGt(exp0, 0, "previewRedeem should predict token0 in-range"); + assertGt(exp1, 0, "previewRedeem should predict token1 in-range"); + + uint256 min0 = exp0 - 1; + uint256 min1 = exp1 - 1; + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, min0, min1, user, user); + + assertApproxEqAbs(out0, exp0, 1, "out0 should match preview within 1 wei"); + assertApproxEqAbs(out1, exp1, 1, "out1 should match preview within 1 wei"); + assertGt(out0, 0, "should receive token0 when withdrawing in-range"); + assertGt(out1, 0, "should receive token1 when withdrawing in-range"); + } + + function test_previewRedeem_matchesActualRedeemShares() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + vm.prank(MOOLAH_PROXY); + provider.transfer(user2, shares); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + + uint256 min0 = exp0 > 0 ? exp0 - 1 : 0; + uint256 min1 = exp1 > 0 ? exp1 - 1 : 0; + + vm.prank(user2); + (uint256 out0, uint256 out1) = provider.redeemShares(shares, min0, min1, user2); + + assertApproxEqAbs(out0, exp0, 1, "out0 should match preview within 1 wei"); + assertApproxEqAbs(out1, exp1, 1, "out1 should match preview within 1 wei"); + } + + function test_previewRedeem_partialShares_proportional() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (uint256 fullExp0, uint256 fullExp1) = provider.previewRedeem(shares); + (uint256 halfExp0, uint256 halfExp1) = provider.previewRedeem(shares / 2); + + // Half the shares should yield approximately half the tokens. + assertApproxEqRel(halfExp0, fullExp0 / 2, 0.001e18, "half shares ~half token0"); + assertApproxEqRel(halfExp1, fullExp1 / 2, 0.001e18, "half shares ~half token1"); + } + + function test_previewRedeem_priceBelowRange_onlyToken0() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertGt(exp0, 0, "should return token0 when price below range"); + assertEq(exp1, 0, "should return no token1 when price below range"); + } + + function test_previewRedeem_priceAboveRange_onlyToken1() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertEq(exp0, 0, "should return no token0 when price above range"); + assertGt(exp1, 0, "should return token1 when price above range"); + } + + function test_previewRedeem_derivedMinAmounts_succeed() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + + // Apply 0.5% slippage tolerance. + uint256 min0 = (exp0 * 995) / 1000; + uint256 min1 = (exp1 * 995) / 1000; + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, min0, min1, user, user); + + assertGe(out0, min0, "out0 >= min0"); + assertGe(out1, min1, "out1 >= min1"); + } + + function test_deposit_minAmount0_tooHigh_reverts_firstDeposit() public { + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + // min0 far exceeds what NPM can place — should revert from NPM slippage check. + deal(USDC, user, amount0); + deal(WBNB, user, amount1); + vm.startPrank(user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + vm.expectRevert(); + provider.deposit(marketParams, amount0, amount1, amount0 * 2, 0, user); + vm.stopPrank(); + } + + function test_deposit_minAmount1_tooHigh_reverts_firstDeposit() public { + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + deal(USDC, user, amount0); + deal(WBNB, user, amount1); + vm.startPrank(user); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + vm.expectRevert(); + provider.deposit(marketParams, amount0, amount1, 0, amount1 * 2, user); + vm.stopPrank(); + } + + function test_deposit_minAmount0_tooHigh_reverts_secondDeposit() public { + _deposit(user, 1_000 ether, 3 ether); + + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + deal(USDC, user2, amount0); + deal(WBNB, user2, amount1); + vm.startPrank(user2); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + vm.expectRevert(); + provider.deposit(marketParams, amount0, amount1, amount0 * 2, 0, user2); + vm.stopPrank(); + } + + function test_deposit_minAmount1_tooHigh_reverts_secondDeposit() public { + _deposit(user, 1_000 ether, 3 ether); + + uint256 amount0 = 1_000 ether; + uint256 amount1 = 3 ether; + + deal(USDC, user2, amount0); + deal(WBNB, user2, amount1); + vm.startPrank(user2); + IERC20(USDC).approve(address(provider), amount0); + IERC20(WBNB).approve(address(provider), amount1); + vm.expectRevert(); + provider.deposit(marketParams, amount0, amount1, 0, amount1 * 2, user2); + vm.stopPrank(); + } + + /* ──────────── one-sided deposit tests ──────────────────────────── */ + + // When the price is in-range both tokens are required to add liquidity. + // Supplying only one token yields 0 liquidity → "zero shares" revert. + + function test_deposit_oneSided_token0Only_inRange_reverts() public { + // Price is in-range: token0 alone yields 0 liquidity → "zero shares". + // Pass min=0 so NPM doesn't revert first; our guard fires instead. + deal(USDC, user, 10_000 ether); + vm.startPrank(user); + IERC20(USDC).approve(address(provider), 10_000 ether); + vm.expectRevert("zero liquidity"); + provider.deposit(marketParams, 10_000 ether, 0, 0, 0, user); + vm.stopPrank(); + } + + function test_deposit_oneSided_token1Only_inRange_reverts() public { + // Price is in-range: token1 alone yields 0 liquidity → "zero shares". + deal(WBNB, user, 30 ether); + vm.startPrank(user); + IERC20(WBNB).approve(address(provider), 30 ether); + vm.expectRevert("zero liquidity"); + provider.deposit(marketParams, 0, 30 ether, 0, 0, user); + vm.stopPrank(); + } + + // When the price is outside the range only one token is valid. + // Supplying the correct token succeeds; supplying the wrong token reverts. + + function test_deposit_oneSided_token0Only_belowRange_succeeds() public { + // Seed a position first so rebalance can move ticks. + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + // Price below tickLower: only token0 (USDC) is accepted. + uint256 amount0 = 5_000 ether; + deal(USDC, user2, amount0); + vm.startPrank(user2); + IERC20(USDC).approve(address(provider), amount0); + (, uint256 exp0, ) = provider.previewDeposit(amount0, 0); + uint256 min0 = (exp0 * 999) / 1000; + (uint256 shares, uint256 used0, uint256 used1) = provider.deposit(marketParams, amount0, 0, min0, 0, user2); + vm.stopPrank(); + + assertGt(shares, 0, "should mint shares with token0 only below range"); + assertGt(used0, 0, "should consume token0"); + assertEq(used1, 0, "should not consume token1"); + } + + function test_deposit_oneSided_token1Only_belowRange_reverts() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + // Price below range: token1 alone yields 0 liquidity → "zero shares". + deal(WBNB, user2, 30 ether); + vm.startPrank(user2); + IERC20(WBNB).approve(address(provider), 30 ether); + vm.expectRevert("zero liquidity"); + provider.deposit(marketParams, 0, 30 ether, 0, 0, user2); + vm.stopPrank(); + } + + function test_deposit_oneSided_token1Only_aboveRange_succeeds() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + // Price above tickUpper: only token1 (WBNB) is accepted. + uint256 amount1 = 15 ether; + deal(WBNB, user2, amount1); + vm.startPrank(user2); + IERC20(WBNB).approve(address(provider), amount1); + (, , uint256 exp1) = provider.previewDeposit(0, amount1); + uint256 min1 = (exp1 * 999) / 1000; + (uint256 shares, uint256 used0, uint256 used1) = provider.deposit(marketParams, 0, amount1, 0, min1, user2); + vm.stopPrank(); + + assertGt(shares, 0, "should mint shares with token1 only above range"); + assertEq(used0, 0, "should not consume token0"); + assertGt(used1, 0, "should consume token1"); + } + + function test_deposit_oneSided_token0Only_aboveRange_reverts() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + // Price above range: token0 alone yields 0 liquidity → "zero shares". + deal(USDC, user2, 10_000 ether); + vm.startPrank(user2); + IERC20(USDC).approve(address(provider), 10_000 ether); + vm.expectRevert("zero liquidity"); + provider.deposit(marketParams, 10_000 ether, 0, 0, 0, user2); + vm.stopPrank(); + } + + function test_deposit_revertsWithInvalidCollateralToken() public { + MarketParams memory badParams = marketParams; + badParams.collateralToken = USDC; + + deal(USDC, user, 1_000 ether); + deal(WBNB, user, 3 ether); + vm.startPrank(user); + IERC20(USDC).approve(address(provider), 1_000 ether); + IERC20(WBNB).approve(address(provider), 3 ether); + vm.expectRevert("invalid collateral token"); + // The revert fires before min amounts are evaluated; use 1,1 for consistency. + provider.deposit(badParams, 1_000 ether, 3 ether, 1, 1, user); + vm.stopPrank(); + } + + function test_getTokenConfig() public view { + TokenConfig memory config = provider.getTokenConfig(address(provider)); + assertEq(config.asset, address(provider)); + assertEq(config.oracles[0], address(provider)); + assertTrue(config.enableFlagsForOracles[0]); + assertEq(config.oracles[1], address(0)); + assertEq(config.oracles[2], address(0)); + } + + /* ─────────── rebalance after price leaves range (fully USDC) ─────── */ + + // Prices: USDC = $1, WBNB = $700 (8-decimal USD) + uint256 constant USDC_PRICE = 1e8; + uint256 constant WBNB_PRICE = 700e8; + // USDC and WBNB are both 18-decimal on BSC. + uint256 constant TOKEN_DECIMALS = 1e18; + + function _mockOraclePrices() internal { + vm.mockCall(RESILIENT_ORACLE, abi.encodeWithSelector(IOracle.peek.selector, USDC), abi.encode(USDC_PRICE)); + vm.mockCall(RESILIENT_ORACLE, abi.encodeWithSelector(IOracle.peek.selector, WBNB), abi.encode(WBNB_PRICE)); + } + + /// @dev Compute USD value (8-decimal) from raw token amounts. + function _valueUSD(uint256 amount0, uint256 amount1) internal pure returns (uint256) { + return (amount0 * USDC_PRICE) / TOKEN_DECIMALS + (amount1 * WBNB_PRICE) / TOKEN_DECIMALS; + } + + /// @dev Push pool price below tickLower by swapping a large amount of USDC → WBNB. + /// zeroForOne = true (token0 → token1) drives the tick downward. + /// When tick < tickLower the V3 position converts entirely to token0 (USDC). + function _pushPriceBelowRange() internal { + PoolSwapper swapper = new PoolSwapper(); + uint256 usdcIn = 5_000_000_000 ether; // 5 billion USDC — enough to blow past ±500 ticks + deal(USDC, address(swapper), usdcIn); + swapper.swapExactIn(POOL, true, usdcIn); + } + + function test_rebalance_priceBelowRange_positionFullyUSDC() public { + _mockOraclePrices(); + _deposit(user, 10_000 ether, 30 ether); + + // Push price below tickLower — position should convert entirely to USDC (token0). + _pushPriceBelowRange(); + + (, int24 tickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + assertLt(tickAfterSwap, provider.tickLower(), "tick should be below tickLower after swap"); + + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + assertGt(total0, 0, "should hold USDC"); + assertEq(total1, 0, "position should be fully USDC (token1 == 0) when price is below range"); + } + + function test_rebalance_priceBelowRange_totalValuePreserved() public { + _mockOraclePrices(); + _deposit(user, 10_000 ether, 30 ether); + + _pushPriceBelowRange(); + + // Snapshot USD value before rebalance (position is 100% USDC). + (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); + uint256 valueBefore = _valueUSD(total0Before, total1Before); + assertGt(valueBefore, 0, "should have non-zero value before rebalance"); + + // Rebalance to a range entirely ABOVE the current (very low) tick so that + // the entire range is below current price → only token0 (USDC) is needed to mint. + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 newLower = newTick + 100; + int24 newUpper = newTick + 600; + + // Position is 100% USDC — only token0 needed for new range (price below it). + uint256 min0 = (total0Before * 999) / 1000; + vm.prank(bot); + provider.rebalance(newLower, newUpper, min0, 0, total0Before, 0); + + assertEq(provider.tickLower(), newLower, "tickLower updated"); + assertEq(provider.tickUpper(), newUpper, "tickUpper updated"); + + // Position is still fully USDC (price below new range). All USDC was deployed + // into the new position; getTotalAmounts captures it via position amounts. + (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); + uint256 valueAfter = _valueUSD(total0After, total1After); + + assertApproxEqRel(valueAfter, valueBefore, 0.01e16, "total value should be preserved within 0.01% after rebalance"); + } + + /* ─────────── rebalance after price leaves range (fully WBNB) ──────── */ + + /// @dev Push pool price above tickUpper by swapping a large amount of WBNB → USDC. + /// zeroForOne = false (token1 → token0) drives the tick upward. + /// When tick > tickUpper the V3 position converts entirely to token1 (WBNB). + function _pushPriceAboveRange() internal { + PoolSwapper swapper = new PoolSwapper(); + uint256 wbnbIn = 10_000_000 ether; // 10 million WBNB — enough to blow past ±500 ticks + deal(WBNB, address(swapper), wbnbIn); + swapper.swapExactIn(POOL, false, wbnbIn); + } + + function test_rebalance_priceAboveRange_positionFullyWBNB() public { + _mockOraclePrices(); + _deposit(user, 10_000 ether, 30 ether); + + // Push price above tickUpper — position should convert entirely to WBNB (token1). + _pushPriceAboveRange(); + + (, int24 tickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + assertGt(tickAfterSwap, provider.tickUpper(), "tick should be above tickUpper after swap"); + + (uint256 total0, uint256 total1) = provider.getTotalAmounts(); + assertEq(total0, 0, "position should be fully WBNB (token0 == 0) when price is above range"); + assertGt(total1, 0, "should hold WBNB"); + } + + function test_rebalance_priceAboveRange_totalValuePreserved() public { + _mockOraclePrices(); + _deposit(user, 10_000 ether, 30 ether); + + _pushPriceAboveRange(); + + // Snapshot USD value before rebalance (position is 100% WBNB). + (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); + uint256 valueBefore = _valueUSD(total0Before, total1Before); + assertGt(valueBefore, 0, "should have non-zero value before rebalance"); + + // Rebalance to a range entirely BELOW the current (very high) tick so that + // the entire range is above current price → only token1 (WBNB) is needed to mint. + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 newLower = newTick - 600; + int24 newUpper = newTick - 100; + + // Position is 100% WBNB — only token1 needed for new range (price above it). + uint256 min1 = (total1Before * 999) / 1000; + vm.prank(bot); + provider.rebalance(newLower, newUpper, 0, min1, 0, total1Before); + + assertEq(provider.tickLower(), newLower, "tickLower updated"); + assertEq(provider.tickUpper(), newUpper, "tickUpper updated"); + + // Position is still fully WBNB (price above new range). All WBNB was deployed + // into the new position; getTotalAmounts captures it via position amounts. + (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); + uint256 valueAfter = _valueUSD(total0After, total1After); + + assertApproxEqRel(valueAfter, valueBefore, 0.01e16, "total value should be preserved within 0.01% after rebalance"); + } + + /* ──────────── minAmount slippage guard tests ────────────────────── */ + + /// @dev When price is below range the position is 100% USDC (token0). + /// rebalance with minAmount0 = actual USDC held passes; minAmount0 > actual reverts. + function test_rebalance_priceBelowRange_minAmount0_passes() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + (uint256 total0, ) = provider.getTotalAmounts(); + assertGt(total0, 0, "should hold USDC before rebalance"); + + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // minAmount0 = total0 (exact), minAmount1 = 0 (position has no WBNB). + // amount0Desired = total0, amount1Desired = 0 (reinvest all USDC, no WBNB available). + vm.prank(bot); + provider.rebalance(newTick + 100, newTick + 600, total0, 0, total0, 0); + + assertEq(provider.tickLower(), newTick + 100, "tickLower updated"); + } + + function test_rebalance_priceBelowRange_minAmount0_tooHigh_reverts() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + (uint256 total0, ) = provider.getTotalAmounts(); + + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // minAmount0 one unit above actual → should revert with NPM slippage check. + // amount0Desired = total0 (correct available), minAmount0 = total0 + 1 (too tight). + vm.prank(bot); + vm.expectRevert(); + provider.rebalance(newTick + 100, newTick + 600, total0 + 1, 0, total0, 0); + } + + /// @dev When price is above range the position is 100% WBNB (token1). + /// rebalance with minAmount1 = actual WBNB held passes; minAmount1 > actual reverts. + function test_rebalance_priceAboveRange_minAmount1_passes() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + (, uint256 total1) = provider.getTotalAmounts(); + assertGt(total1, 0, "should hold WBNB before rebalance"); + + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // minAmount0 = 0 (no USDC), minAmount1 = total1 (exact). + // amount0Desired = 0, amount1Desired = total1 (reinvest all WBNB). + vm.prank(bot); + provider.rebalance(newTick - 600, newTick - 100, 0, total1, 0, total1); + + assertEq(provider.tickUpper(), newTick - 100, "tickUpper updated"); + } + + function test_rebalance_priceAboveRange_minAmount1_tooHigh_reverts() public { + _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + (, uint256 total1) = provider.getTotalAmounts(); + + (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + + // minAmount1 one unit above actual → should revert with NPM slippage check. + // amount1Desired = total1 (correct available), minAmount1 = total1 + 1 (too tight). + vm.prank(bot); + vm.expectRevert(); + provider.rebalance(newTick - 600, newTick - 100, 0, total1 + 1, 0, total1); + } + + function test_withdraw_minAmount_tooHigh_reverts() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (uint256 exp0, ) = provider.previewRedeem(shares); + + vm.prank(user); + vm.expectRevert(); + provider.withdraw(marketParams, shares, exp0 * 2, 1, user, user); + } + + function test_redeemShares_minAmount_tooHigh_reverts() public { + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + vm.prank(MOOLAH_PROXY); + provider.transfer(user2, shares); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + uint256 min0 = (exp0 * 999) / 1000; + + vm.prank(user2); + vm.expectRevert(); + provider.redeemShares(shares, min0, exp1 * 2, user2); + } + + /* ──────────── withdraw token composition by price position ─────── */ + + function test_withdraw_belowRange_returnsToken0Only() public { + // When price is below tickLower the entire position is token0. + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + _pushPriceBelowRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertGt(exp0, 0, "previewRedeem should predict token0 below range"); + assertEq(exp1, 0, "previewRedeem should predict zero token1 below range"); + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, (exp0 * 999) / 1000, 0, user, user); + + assertGt(out0, 0, "should receive token0 when price below range"); + assertEq(out1, 0, "should receive no token1 when price below range"); + } + + function test_withdraw_aboveRange_returnsToken1Only() public { + // When price is above tickUpper the entire position is token1. + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + _pushPriceAboveRange(); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + assertEq(exp0, 0, "previewRedeem should predict zero token0 above range"); + assertGt(exp1, 0, "previewRedeem should predict token1 above range"); + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, 0, (exp1 * 999) / 1000, user, user); + + assertEq(out0, 0, "should receive no token0 when price above range"); + assertGt(out1, 0, "should receive token1 when price above range"); + } + + function test_withdraw_inRange_cannotForceOneSided_alwaysBoth() public { + // Even with minAmount1=0, an in-range withdrawal still returns token1. + // Setting min to 0 disables the floor but does not change what is received. + (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + + (uint256 exp0, ) = provider.previewRedeem(shares); + + vm.prank(user); + (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, (exp0 * 999) / 1000, 0, user, user); + + assertGt(out0, 0, "token0 returned even with minAmount1=0"); + assertGt(out1, 0, "token1 still returned in-range regardless of minAmount1=0"); + } + + /* ─────────────────── slisBNBx: setSlisBNBxMinter ───────────────── */ + + address constant SLISBNBX = 0x4b30fcAA7945fE9fDEFD2895aae539ba102Ed6F6; + address constant SLISBNBX_ADMIN = 0x702115D6d3Bbb37F407aae4dEcf9d09980e28ebc; + + function _deployMinter() internal returns (SlisBNBxMinter minter) { + address[] memory modules = new address[](1); + modules[0] = address(provider); + + SlisBNBxMinter.ModuleConfig[] memory configs = new SlisBNBxMinter.ModuleConfig[](1); + configs[0] = SlisBNBxMinter.ModuleConfig({ + discount: 2e4, // 2 % + feeRate: 3e4, // 3 % + moduleAddress: address(provider) + }); + + SlisBNBxMinter impl = new SlisBNBxMinter(SLISBNBX); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeWithSelector(SlisBNBxMinter.initialize.selector, admin, manager, modules, configs) + ); + minter = SlisBNBxMinter(address(proxy)); + + // Give minter an MPC wallet with a large cap so minting never hits the cap. + address mpc = makeAddr("mpc"); + vm.prank(manager); + minter.addMPCWallet(mpc, 1_000_000_000 ether); + + // Authorise the minter contract to mint slisBNBx. + vm.prank(SLISBNBX_ADMIN); + ISlisBNBx(SLISBNBX).addMinter(address(minter)); + } + + function test_setSlisBNBxMinter_manager_succeeds() public { + SlisBNBxMinter minter = _deployMinter(); + + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + assertEq(provider.slisBNBxMinter(), address(minter)); + } + + function test_setSlisBNBxMinter_zeroAddress_disablesMinter() public { + SlisBNBxMinter minter = _deployMinter(); + vm.startPrank(manager); + provider.setSlisBNBxMinter(address(minter)); + assertEq(provider.slisBNBxMinter(), address(minter)); + provider.setSlisBNBxMinter(address(0)); + assertEq(provider.slisBNBxMinter(), address(0)); + vm.stopPrank(); + } + + function test_setSlisBNBxMinter_notManager_reverts() public { + SlisBNBxMinter minter = _deployMinter(); + + vm.prank(user); + vm.expectRevert(); + provider.setSlisBNBxMinter(address(minter)); + } + + /* ─────────────────── slisBNBx: deposit tracking ────────────────── */ + + function test_deposit_updatesUserMarketDeposit() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + assertEq(provider.userMarketDeposit(user, marketId), shares, "userMarketDeposit should match shares"); + assertEq(provider.userTotalDeposit(user), shares, "userTotalDeposit should match shares"); + } + + function test_deposit_twoDeposits_accumulatesTotal() public { + (uint256 shares1, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares2, , ) = _deposit(user, 1_000 ether, 3 ether); + + assertEq(provider.userMarketDeposit(user, marketId), shares1 + shares2, "market deposit should accumulate"); + assertEq(provider.userTotalDeposit(user), shares1 + shares2, "total deposit should accumulate"); + } + + function test_deposit_twoUsers_trackingIsIndependent() public { + (uint256 shares1, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares2, , ) = _deposit(user2, 2_000 ether, 6 ether); + + assertEq(provider.userMarketDeposit(user, marketId), shares1); + assertEq(provider.userTotalDeposit(user), shares1); + assertEq(provider.userMarketDeposit(user2, marketId), shares2); + assertEq(provider.userTotalDeposit(user2), shares2); + } + + /* ─────────────────── slisBNBx: withdraw tracking ───────────────── */ + + function test_withdraw_updatesUserMarketDeposit() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + vm.prank(user); + provider.withdraw(marketParams, shares, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); + + assertEq(provider.userMarketDeposit(user, marketId), 0, "market deposit should be 0 after full withdraw"); + assertEq(provider.userTotalDeposit(user), 0, "total deposit should be 0 after full withdraw"); + } + + function test_withdraw_partial_updatesTracking() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + uint256 half = shares / 2; + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(half); + vm.prank(user); + provider.withdraw(marketParams, half, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); + + uint256 remaining = provider.userMarketDeposit(user, marketId); + assertApproxEqAbs(remaining, shares - half, 1, "market deposit should halve"); + assertEq(provider.userTotalDeposit(user), remaining, "total deposit matches market deposit"); + } + + /* ─────────────────── slisBNBx: liquidate tracking ──────────────── */ + + function test_liquidate_syncsBorrowerToZero() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + assertEq(provider.userMarketDeposit(user, marketId), shares); + + // Simulate post-liquidation: Moolah reports 0 collateral for the borrower. + vm.mockCall( + MOOLAH_PROXY, + abi.encodeWithSelector(IMoolah.position.selector, marketId, user), + abi.encode(0, uint128(0), uint128(0)) + ); + + vm.prank(MOOLAH_PROXY); + provider.liquidate(marketId, user); + + assertEq(provider.userMarketDeposit(user, marketId), 0, "market deposit should clear after liquidation"); + assertEq(provider.userTotalDeposit(user), 0, "total deposit should clear after liquidation"); + } + + /* ─────────────────── slisBNBx: getUserBalanceInBnb ─────────────── */ + + address constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + uint256 constant BNB_PRICE = 700e8; + + function _mockAllPrices() internal { + _mockOraclePrices(); // mocks USDC and WBNB prices + vm.mockCall(RESILIENT_ORACLE, abi.encodeWithSelector(IOracle.peek.selector, BNB_ADDRESS), abi.encode(BNB_PRICE)); + } + + function test_getUserBalanceInBnb_zeroBeforeDeposit() public view { + assertEq(provider.getUserBalanceInBnb(user), 0); + } + + function test_getUserBalanceInBnb_nonzeroAfterDeposit() public { + _mockAllPrices(); + _deposit(user, 1_000 ether, 3 ether); + + uint256 bnbValue = provider.getUserBalanceInBnb(user); + assertGt(bnbValue, 0, "should return positive BNB value after deposit"); + } + + function test_getUserBalanceInBnb_proportionalToShares() public { + _mockAllPrices(); + _deposit(user, 1_000 ether, 3 ether); + _deposit(user2, 2_000 ether, 6 ether); + + uint256 value1 = provider.getUserBalanceInBnb(user); + uint256 value2 = provider.getUserBalanceInBnb(user2); + + // user2 deposited ~2x; allow 2% tolerance for compounding and rounding. + assertApproxEqRel(value2, value1 * 2, 0.02e18, "user2 BNB value should be ~2x user"); + } + + function test_getUserBalanceInBnb_matchesShareValueInBnb() public { + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + // peek() returns (totalValue * 1e18 / supply) where totalValue is 8-dec USD. + // getUserBalanceInBnb returns (shares * 1e18 * totalValue / supply / bnbPrice) + // = shares * sharePrice / bnbPrice + uint256 sharePrice = provider.peek(address(provider)); // 8-dec USD * 1e18 / liquidity-unit + uint256 expectedBnbValue = (shares * sharePrice) / BNB_PRICE; + + uint256 actualBnbValue = provider.getUserBalanceInBnb(user); + // Allow 1% for rounding between slot0-based amounts and oracle math. + assertApproxEqRel(actualBnbValue, expectedBnbValue, 0.01e18, "BNB value should match share oracle price"); + } + + /* ─────────────────── slisBNBx: manual sync ─────────────────────── */ + + function test_syncUserBalance_noOpWhenAlreadySynced() public { + _deposit(user, 1_000 ether, 3 ether); + + uint256 depositBefore = provider.userMarketDeposit(user, marketId); + provider.syncUserBalance(marketId, user); + assertEq(provider.userMarketDeposit(user, marketId), depositBefore, "no change when already synced"); + } + + function test_bulkSyncUserBalance_syncsMultipleUsers() public { + _deposit(user, 1_000 ether, 3 ether); + _deposit(user2, 2_000 ether, 6 ether); + + uint256 d1 = provider.userMarketDeposit(user, marketId); + uint256 d2 = provider.userMarketDeposit(user2, marketId); + + Id[] memory ids = new Id[](2); + ids[0] = marketId; + ids[1] = marketId; + address[] memory accounts = new address[](2); + accounts[0] = user; + accounts[1] = user2; + + provider.bulkSyncUserBalance(ids, accounts); + + assertEq(provider.userMarketDeposit(user, marketId), d1, "user1 unchanged"); + assertEq(provider.userMarketDeposit(user2, marketId), d2, "user2 unchanged"); + } + + function test_bulkSyncUserBalance_lengthMismatch_reverts() public { + Id[] memory ids = new Id[](2); + ids[0] = marketId; + ids[1] = marketId; + address[] memory accounts = new address[](1); + accounts[0] = user; + + vm.expectRevert("length mismatch"); + provider.bulkSyncUserBalance(ids, accounts); + } + + // ── H-1 regression: foreign market ID must be rejected ──────────── + + /// @dev Returns the Id of a live Moolah market whose collateralToken != address(provider). + function _foreignMarketId() internal pure returns (Id) { + // Use the first market in the live Moolah deployment (slisBNB / lisUSD). + // Its collateralToken is slisBNB, not this V3Provider. + MarketParams memory foreign = MarketParams({ + loanToken: LISUSD, + collateralToken: 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B, // slisBNB + oracle: RESILIENT_ORACLE, + irm: 0x5F9f9173B405C6CEAfa7f98d09e4B8447e9797E6, + lltv: 90 * 1e16 + }); + return foreign.id(); + } + + function test_syncUserBalance_foreignMarket_reverts() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 totalBefore = provider.userTotalDeposit(user); + + vm.expectRevert("invalid market"); + provider.syncUserBalance(_foreignMarketId(), user); + + // Deposit tracking must be unchanged. + assertEq(provider.userTotalDeposit(user), totalBefore); + } + + function test_bulkSyncUserBalance_foreignMarket_reverts() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 totalBefore = provider.userTotalDeposit(user); + + Id[] memory ids = new Id[](1); + ids[0] = _foreignMarketId(); + address[] memory accounts = new address[](1); + accounts[0] = user; + + vm.expectRevert("invalid market"); + provider.bulkSyncUserBalance(ids, accounts); + + assertEq(provider.userTotalDeposit(user), totalBefore); + } + + /* ─────────────────── slisBNBx: minter integration ──────────────── */ + + function test_withMinter_deposit_mintsSlisBNBx() public { + SlisBNBxMinter minter = _deployMinter(); + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + + // Deposit tracking + assertEq(provider.userMarketDeposit(user, marketId), shares, "userMarketDeposit should equal shares"); + assertEq(provider.userTotalDeposit(user), shares, "userTotalDeposit should equal shares"); + // slisBNBx minted to user + assertGt(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "slisBNBx should be minted to user after deposit"); + } + + function test_withMinter_withdraw_burnsSlisBNBx() public { + SlisBNBxMinter minter = _deployMinter(); + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + assertGt(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "setup: slisBNBx minted after deposit"); + + (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + vm.prank(user); + provider.withdraw(marketParams, shares, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); + + // Deposit tracking zeroed + assertEq(provider.userMarketDeposit(user, marketId), 0, "userMarketDeposit should be 0 after full withdraw"); + assertEq(provider.userTotalDeposit(user), 0, "userTotalDeposit should be 0 after full withdraw"); + // slisBNBx burned + assertEq(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "slisBNBx should be burned after full withdraw"); + } + + function test_withMinter_partialWithdraw_reducesSlisBNBx() public { + SlisBNBxMinter minter = _deployMinter(); + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + uint256 slisBNBxAfterDeposit = ISlisBNBx(SLISBNBX).balanceOf(user); + assertGt(slisBNBxAfterDeposit, 0); + + uint256 half = shares / 2; + (uint256 exp0, uint256 exp1) = provider.previewRedeem(half); + vm.prank(user); + provider.withdraw(marketParams, half, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); + + uint256 remainingDeposit = provider.userMarketDeposit(user, marketId); + // Deposit tracking reduced by half + assertApproxEqAbs(remainingDeposit, shares - half, 1, "userMarketDeposit should halve"); + assertEq(provider.userTotalDeposit(user), remainingDeposit, "userTotalDeposit matches userMarketDeposit"); + // slisBNBx partially burned + uint256 slisBNBxAfterWithdraw = ISlisBNBx(SLISBNBX).balanceOf(user); + assertLt(slisBNBxAfterWithdraw, slisBNBxAfterDeposit, "slisBNBx should decrease after partial withdraw"); + assertGt(slisBNBxAfterWithdraw, 0, "some slisBNBx should remain after partial withdraw"); + } + + function test_withMinter_liquidate_burnsSlisBNBx() public { + SlisBNBxMinter minter = _deployMinter(); + vm.prank(manager); + provider.setSlisBNBxMinter(address(minter)); + + _mockAllPrices(); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + assertEq(provider.userMarketDeposit(user, marketId), shares, "setup: deposit tracked"); + assertGt(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "setup: slisBNBx minted after deposit"); + + // Simulate full liquidation: Moolah reports 0 collateral for the borrower. + vm.mockCall( + MOOLAH_PROXY, + abi.encodeWithSelector(IMoolah.position.selector, marketId, user), + abi.encode(0, uint128(0), uint128(0)) + ); + + vm.prank(MOOLAH_PROXY); + provider.liquidate(marketId, user); + + // Deposit tracking zeroed + assertEq(provider.userMarketDeposit(user, marketId), 0, "userMarketDeposit cleared after liquidation"); + assertEq(provider.userTotalDeposit(user), 0, "userTotalDeposit cleared after liquidation"); + // slisBNBx burned + assertEq(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "slisBNBx burned after liquidation sync"); + } + + /* ─────────────────── borrow / repay / liquidate ─────────────────── */ + + function _borrow(address _user, uint256 assets) internal { + vm.prank(_user); + moolah.borrow(marketParams, assets, 0, _user, _user); + } + + function _debtOf(address _user) internal view returns (uint128 borrowShares) { + (, borrowShares, ) = moolah.position(marketId, _user); + } + + /// @dev Borrow 60% of the user's current collateral value. Safe to borrow (< LLTV) + /// but large enough that mocking the price to zero makes the position unhealthy. + function _borrowAgainstCollateral(address _user) internal returns (uint256 borrowed) { + (, , uint128 col) = moolah.position(marketId, _user); + uint256 sharePrice = provider.peek(address(provider)); // 8-dec USD per share + uint256 loanPrice = provider.peek(LISUSD); // 8-dec USD per lisUSD (~1e8) + // 60% of collateral value in lisUSD units + borrowed = (uint256(col) * sharePrice * 60) / (loanPrice * 100); + _borrow(_user, borrowed); + } + + /// @dev Set collateral oracle price to zero, making any position with debt unhealthy. + function _makeUnhealthy() internal { + vm.mockCall( + address(provider), + abi.encodeWithSelector(IOracle.peek.selector, address(provider)), + abi.encode(uint256(0)) + ); + } + + function test_borrow_afterDeposit_receivesLisUSD() public { + _deposit(user, 1_000 ether, 3 ether); + uint256 balBefore = IERC20(LISUSD).balanceOf(user); + _borrow(user, 100 ether); + assertEq(IERC20(LISUSD).balanceOf(user), balBefore + 100 ether); + assertGt(_debtOf(user), 0, "borrow shares recorded"); + } + + function test_borrow_twoUsers_independentDebt() public { + _deposit(user, 1_000 ether, 3 ether); + _deposit(user2, 2_000 ether, 6 ether); + _borrow(user, 100 ether); + _borrow(user2, 200 ether); + assertGt(_debtOf(user), 0); + assertGt(_debtOf(user2), _debtOf(user), "user2 has more debt"); + assertEq(IERC20(LISUSD).balanceOf(user), 100 ether); + assertEq(IERC20(LISUSD).balanceOf(user2), 200 ether); + } + + function test_repay_full_clearsDebt() public { + _deposit(user, 1_000 ether, 3 ether); + _borrow(user, 100 ether); + assertGt(_debtOf(user), 0); + + deal(LISUSD, user, 200 ether); // extra buffer for accrued interest + vm.startPrank(user); + IERC20(LISUSD).approve(MOOLAH_PROXY, type(uint256).max); + moolah.repay(marketParams, 0, _debtOf(user), user, ""); // repay by shares → exact + vm.stopPrank(); + + assertEq(_debtOf(user), 0, "debt cleared after full repay"); + } + + function test_repay_partial_reducesDebt() public { + _deposit(user, 1_000 ether, 3 ether); + _borrow(user, 100 ether); + uint128 sharesBefore = _debtOf(user); + + deal(LISUSD, user, 50 ether); + vm.startPrank(user); + IERC20(LISUSD).approve(MOOLAH_PROXY, type(uint256).max); + moolah.repay(marketParams, 50 ether, 0, user, ""); + vm.stopPrank(); + + uint128 sharesAfter = _debtOf(user); + assertLt(sharesAfter, sharesBefore, "debt decreased"); + assertGt(sharesAfter, 0, "some debt remains"); + } + + function test_liquidate_seizedSharesSentToLiquidator() public { + address liquidator = makeAddr("liquidator"); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + + deal(LISUSD, liquidator, 1_000 ether); + vm.startPrank(liquidator); + IERC20(LISUSD).approve(MOOLAH_PROXY, type(uint256).max); + moolah.liquidate(marketParams, user, shares, 0, ""); + vm.stopPrank(); + + assertGt(provider.balanceOf(liquidator), 0, "liquidator received shares"); + (, , uint128 colAfter) = moolah.position(marketId, user); + assertEq(colAfter, 0, "borrower collateral seized"); + assertEq(provider.userMarketDeposit(user, marketId), 0, "deposit tracking cleared"); + assertEq(provider.userTotalDeposit(user), 0, "total deposit cleared"); + } + + function test_liquidate_liquidatorRedeemsSharesToTokens() public { + address liquidator = makeAddr("liquidator"); + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + _borrowAgainstCollateral(user); + _makeUnhealthy(); + + deal(LISUSD, liquidator, 1_000 ether); + vm.startPrank(liquidator); + IERC20(LISUSD).approve(MOOLAH_PROXY, type(uint256).max); + moolah.liquidate(marketParams, user, shares, 0, ""); + + uint256 seizedShares = provider.balanceOf(liquidator); + (uint256 exp0, uint256 exp1) = provider.previewRedeem(seizedShares); + (uint256 out0, uint256 out1) = provider.redeemShares( + seizedShares, + (exp0 * 99) / 100, + (exp1 * 99) / 100, + liquidator + ); + vm.stopPrank(); + + assertEq(provider.balanceOf(liquidator), 0, "shares burned after redeem"); + assertGt(out0 + out1, 0, "liquidator received tokens"); + assertEq(IERC20(USDC).balanceOf(liquidator), out0); + assertEq(liquidator.balance, out1); // WBNB unwrapped to BNB + } +} From 532a7b3e89d80fdd6eb2832645a4626b871a1c57 Mon Sep 17 00:00:00 2001 From: Razorback Date: Fri, 27 Mar 2026 14:14:45 +0800 Subject: [PATCH 02/17] feat: add maxTickDeviation --- src/provider/V3Provider.sol | 25 +++++- test/provider/V3Provider.t.sol | 151 +++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol index 870695bf..a0d2db3d 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/V3Provider.sol @@ -115,6 +115,11 @@ contract V3Provider is /// @dev slisBNBxMinter address address public slisBNBxMinter; + /// @dev Maximum allowed absolute tick deviation between slot0 and TWAP. + /// When non-zero, rebalance() reverts if |spotTick - twapTick| exceeds this value. + /// Default 0 = no guard (backwards compatible). + uint24 public maxTickDeviation; + /// @dev Virtual address used by the resilient oracle to price native BNB. address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; @@ -124,6 +129,7 @@ contract V3Provider is /* ───────────────────────────── events ───────────────────────────── */ event SlisBNBxMinterChanged(address indexed minter); + event MaxTickDeviationChanged(uint24 maxTickDeviation); event Deposit( address indexed onBehalf, @@ -495,6 +501,14 @@ contract V3Provider is ) external onlyRole(BOT) nonReentrant { require(_tickLower < _tickUpper, "invalid tick range"); + // Guard: prevent rebalance when spot diverges too far from TWAP. + if (maxTickDeviation > 0) { + (, int24 spotTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 twapTick = getTwapTick(); + int24 delta = spotTick > twapTick ? spotTick - twapTick : twapTick - spotTick; + require(uint24(delta) <= maxTickDeviation, "twap deviation too high"); + } + int24 oldTickLower = tickLower; int24 oldTickUpper = tickUpper; @@ -675,9 +689,11 @@ contract V3Provider is * * @dev Token composition is derived from the TWAP tick (not slot0) so a single-block * AMM price manipulation cannot inflate the reported collateral value. + * The maxTickDeviation guard on rebalance() prevents rebalancing while spot + * diverges far from TWAP, which would cause a phantom share-price discontinuity. * pool.observe() reverts when the pool lacks TWAP_PERIOD seconds of history, * which in turn reverts peek() — intentionally blocking borrows until the market - * has seasoned. Do NOT add a slot0 fallback here. + * has seasoned. */ function peek(address token) external view override returns (uint256) { if (token != address(this)) { @@ -799,6 +815,13 @@ contract V3Provider is emit SlisBNBxMinterChanged(_slisBNBxMinter); } + /// @notice Set the maximum allowed tick deviation between slot0 and TWAP for rebalance(). + /// Pass 0 to disable the guard. + function setMaxTickDeviation(uint24 _maxTickDeviation) external onlyRole(MANAGER) { + maxTickDeviation = _maxTickDeviation; + emit MaxTickDeviationChanged(_maxTickDeviation); + } + /* ─────────────────────────── internals ──────────────────────────── */ /// @dev Reads the user's current Moolah collateral for `id`, diffs against the last diff --git a/test/provider/V3Provider.t.sol b/test/provider/V3Provider.t.sol index c73f1dfc..9844898d 100644 --- a/test/provider/V3Provider.t.sol +++ b/test/provider/V3Provider.t.sol @@ -1541,4 +1541,155 @@ contract V3ProviderTest is Test { assertEq(IERC20(USDC).balanceOf(liquidator), out0); assertEq(liquidator.balance, out1); // WBNB unwrapped to BNB } + + /* ───── peek() discontinuity when rebalance happens while TWAP lags ───── */ + + /// @notice Demonstrates that rebalancing while spot has diverged far from TWAP + /// causes a peek() discontinuity — the oracle-reported share price jumps + /// even though no real value was created or destroyed. + /// + /// Scenario: + /// 1. User deposits into an in-range position. + /// 2. A large swap pushes spot price far below tickLower (position → 100% USDC). + /// 3. TWAP still reflects the old price (lagging behind spot). + /// 4. peek() is called before and after rebalance — the share price jumps because + /// the TWAP tick lands in a completely different region of the new range vs the old range. + /// + /// Before rebalance: + /// old range [tickLower, tickUpper], TWAP tick < old tickLower + /// → _getTotalAmountsAt(twap) = 100% token0 + /// → peek = total0 × price0 / supply + /// + /// After rebalance (new range centered below TWAP): + /// new range is ABOVE the current spot tick but BELOW the TWAP tick + /// → _getTotalAmountsAt(twap) evaluates position as if price is above new tickUpper + /// → 100% token1 (WBNB) at TWAP-implied amounts — different composition and value + /// + /// This is the TWAP-stale-window risk: the oracle's view of token0/token1 split + /// doesn't match reality, and rebalancing changes which "wrong view" is computed. + function test_peek_discontinuity_on_rebalance_with_stale_twap() public { + _mockOraclePrices(); + + // 1. User deposits and borrows against collateral. + _deposit(user, 10_000 ether, 30 ether); + uint256 shares = _collateral(user); + assertGt(shares, 0); + + // Record peek() at the healthy state. + uint256 peekHealthy = provider.peek(address(provider)); + assertGt(peekHealthy, 0, "peek should be non-zero after deposit"); + + // 2. Push spot price far below tickLower. + // TWAP (30-min average) barely moves — it still reflects the old price range. + _pushPriceBelowRange(); + + (, int24 spotTickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 twapTickAfterSwap = provider.getTwapTick(); + + // Confirm TWAP is still well above spot — the stale window. + assertLt(spotTickAfterSwap, provider.tickLower(), "spot should be below old tickLower"); + assertGt(twapTickAfterSwap, spotTickAfterSwap + 200, "TWAP should lag significantly behind spot"); + + // 3. peek() before rebalance — TWAP evaluates old range. + uint256 peekBeforeRebalance = provider.peek(address(provider)); + + // 4. Rebalance: create new range centered around the new spot tick. + // Choose a range that is entirely BELOW the TWAP tick so that + // _getTotalAmountsAt(twapSqrtPrice) sees the new range as "price above tickUpper" + // → interprets the position as 100% token1 (WBNB). + // + // Before rebalance, TWAP was below old tickLower → 100% token0 (USDC). + // After rebalance, TWAP is above new tickUpper → 100% token1 (WBNB). + // Same liquidity, but peek() reports a completely different token composition. + // Place new range ABOVE spot (so only token0/USDC is needed to mint, + // matching the 100%-USDC holdings) but BELOW the TWAP tick (so peek() + // evaluates the new position as "price above tickUpper" → 100% token1). + int24 newLower = spotTickAfterSwap + 100; + int24 newUpper = spotTickAfterSwap + 500; + + // Ensure new range is entirely below the TWAP tick. + assertLt(newUpper, twapTickAfterSwap, "new tickUpper should be below TWAP tick"); + // Ensure new range is entirely above the spot tick. + assertGt(newLower, spotTickAfterSwap, "new tickLower should be above spot tick"); + + // Collect total amounts for slippage params. + (uint256 t0, uint256 t1) = provider.getTotalAmounts(); + + vm.prank(bot); + provider.rebalance(newLower, newUpper, 0, 0, t0, t1); + + // 5. peek() after rebalance — TWAP evaluates NEW range. + uint256 peekAfterRebalance = provider.peek(address(provider)); + + // The share price SHOULD be approximately the same (no real value change), + // but due to TWAP staleness it can jump significantly. + uint256 priceDelta; + if (peekAfterRebalance > peekBeforeRebalance) { + priceDelta = peekAfterRebalance - peekBeforeRebalance; + } else { + priceDelta = peekBeforeRebalance - peekAfterRebalance; + } + uint256 pctChange = (priceDelta * 1e18) / peekBeforeRebalance; + + // Log for visibility. + emit log_named_uint("peek before rebalance (8 dec)", peekBeforeRebalance); + emit log_named_uint("peek after rebalance (8 dec)", peekAfterRebalance); + emit log_named_uint("change % (18 dec = 100%)", pctChange); + emit log_named_int("spot tick after swap", spotTickAfterSwap); + emit log_named_int("TWAP tick after swap", twapTickAfterSwap); + emit log_named_int("new tickLower", newLower); + emit log_named_int("new tickUpper", newUpper); + + // Without maxTickDeviation guard, the rebalance succeeds and causes a large + // peek() discontinuity. This proves the TWAP-stale-window risk is real. + assertGt(pctChange, 0.01e18, "peek() should show a >1% discontinuity due to stale TWAP"); + } + + /// @notice With maxTickDeviation set, the same rebalance is blocked — preventing + /// the peek() discontinuity from ever occurring. + function test_peek_discontinuity_blocked_by_maxTickDeviation() public { + _mockOraclePrices(); + _deposit(user, 10_000 ether, 30 ether); + + // Set the guard — only allow rebalance when spot ≈ TWAP. + vm.prank(manager); + provider.setMaxTickDeviation(100); + + _pushPriceBelowRange(); + + (, int24 spotTickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 newLower = spotTickAfterSwap + 100; + int24 newUpper = spotTickAfterSwap + 500; + (uint256 t0, uint256 t1) = provider.getTotalAmounts(); + + vm.prank(bot); + vm.expectRevert("twap deviation too high"); + provider.rebalance(newLower, newUpper, 0, 0, t0, t1); + } + + /* ───── rebalance TWAP deviation guard ───── */ + + function test_rebalance_succeeds_when_twap_deviation_within_limit() public { + _deposit(user, 10_000 ether, 30 ether); + + // Set a generous deviation limit — slot0 and TWAP should be close after deposit. + vm.prank(manager); + provider.setMaxTickDeviation(5000); + + (, int24 spotTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + int24 twapTick = provider.getTwapTick(); + int24 delta = twapTick > spotTick ? twapTick - spotTick : spotTick - twapTick; + assertLt(uint24(delta), 5000, "deviation should be within limit"); + + // Rebalance to a slightly shifted range — should succeed. + int24 newLower = provider.tickLower() - 100; + int24 newUpper = provider.tickUpper() - 100; + (uint256 t0, uint256 t1) = provider.getTotalAmounts(); + + vm.prank(bot); + provider.rebalance(newLower, newUpper, 0, 0, t0, t1); + + assertEq(provider.tickLower(), newLower, "tickLower should be updated"); + assertEq(provider.tickUpper(), newUpper, "tickUpper should be updated"); + } } From 4b94d4a613d61b7740d1daf71dfcef32d186a61a Mon Sep 17 00:00:00 2001 From: Razorback Date: Sun, 29 Mar 2026 13:39:19 +0800 Subject: [PATCH 03/17] feat: fork V3 contracts --- foundry.toml | 1 + src/dex/v3/core/ListaV3Factory.sol | 73 ++ src/dex/v3/core/ListaV3Pool.sol | 885 ++++++++++++++++++ src/dex/v3/core/ListaV3PoolDeployer.sol | 38 + src/dex/v3/core/NoDelegateCall.sol | 27 + src/dex/v3/core/interfaces/IERC20Minimal.sol | 48 + .../v3/core/interfaces/IListaV3Factory.sol | 70 ++ src/dex/v3/core/interfaces/IListaV3Pool.sol | 24 + .../core/interfaces/IListaV3PoolDeployer.sol | 20 + src/dex/v3/core/interfaces/LICENSE | 445 +++++++++ .../callback/IListaV3FlashCallback.sol | 14 + .../callback/IListaV3MintCallback.sol | 14 + .../callback/IListaV3SwapCallback.sol | 17 + .../interfaces/pool/IListaV3PoolActions.sol | 94 ++ .../pool/IListaV3PoolDerivedState.sol | 35 + .../interfaces/pool/IListaV3PoolErrors.sol | 19 + .../interfaces/pool/IListaV3PoolEvents.sol | 118 +++ .../pool/IListaV3PoolImmutables.sol | 35 + .../pool/IListaV3PoolOwnerActions.sol | 23 + .../interfaces/pool/IListaV3PoolState.sol | 118 +++ src/dex/v3/core/libraries/BitMath.sol | 98 ++ src/dex/v3/core/libraries/FixedPoint128.sol | 8 + src/dex/v3/core/libraries/FixedPoint96.sol | 10 + src/dex/v3/core/libraries/FullMath.sol | 120 +++ src/dex/v3/core/libraries/LICENSE | 445 +++++++++ src/dex/v3/core/libraries/LICENSE_MIT | 20 + src/dex/v3/core/libraries/Oracle.sol | 342 +++++++ src/dex/v3/core/libraries/Position.sol | 85 ++ src/dex/v3/core/libraries/SafeCast.sol | 28 + src/dex/v3/core/libraries/SqrtPriceMath.sol | 234 +++++ src/dex/v3/core/libraries/SwapMath.sol | 91 ++ src/dex/v3/core/libraries/Tick.sol | 190 ++++ src/dex/v3/core/libraries/TickBitmap.sol | 80 ++ src/dex/v3/core/libraries/TickMath.sol | 216 +++++ src/dex/v3/core/libraries/TransferHelper.sol | 20 + src/dex/v3/core/libraries/UnsafeMath.sol | 17 + .../periphery/NonfungiblePositionManager.sol | 397 ++++++++ .../NonfungibleTokenPositionDescriptor.sol | 117 +++ src/dex/v3/periphery/SwapRouter.sol | 217 +++++ src/dex/v3/periphery/base/BlockTimestamp.sol | 12 + src/dex/v3/periphery/base/ERC721Permit.sol | 79 ++ .../v3/periphery/base/LiquidityManagement.sol | 83 ++ src/dex/v3/periphery/base/Multicall.sol | 28 + .../base/PeripheryImmutableState.sol | 18 + .../v3/periphery/base/PeripheryPayments.sol | 61 ++ .../base/PeripheryPaymentsWithFee.sol | 52 + .../v3/periphery/base/PeripheryValidation.sol | 11 + src/dex/v3/periphery/base/PoolInitializer.sol | 32 + src/dex/v3/periphery/base/SelfPermit.sol | 63 ++ .../periphery/interfaces/IERC20Metadata.sol | 18 + .../v3/periphery/interfaces/IERC721Permit.sol | 25 + .../v3/periphery/interfaces/IMulticall.sol | 13 + .../INonfungiblePositionManager.sol | 170 ++++ .../INonfungibleTokenPositionDescriptor.sol | 14 + .../interfaces/IPeripheryImmutableState.sol | 12 + .../interfaces/IPeripheryPayments.sol | 24 + .../interfaces/IPeripheryPaymentsWithFee.sol | 29 + .../periphery/interfaces/IPoolInitializer.sol | 22 + src/dex/v3/periphery/interfaces/IQuoter.sol | 51 + src/dex/v3/periphery/interfaces/IQuoterV2.sol | 96 ++ .../v3/periphery/interfaces/ISelfPermit.sol | 69 ++ .../v3/periphery/interfaces/ISwapRouter.sol | 67 ++ src/dex/v3/periphery/interfaces/ITickLens.sol | 25 + .../interfaces/external/IERC1271.sol | 16 + .../external/IERC20PermitAllowed.sol | 27 + .../periphery/interfaces/external/IWETH9.sol | 13 + .../lens/ListaInterfaceMulticall.sol | 42 + src/dex/v3/periphery/lens/Quoter.sol | 161 ++++ src/dex/v3/periphery/lens/QuoterV2.sol | 257 +++++ src/dex/v3/periphery/lens/README.md | 4 + src/dex/v3/periphery/lens/TickLens.sol | 41 + .../periphery/libraries/AddressStringUtil.sol | 37 + src/dex/v3/periphery/libraries/BytesLib.sol | 95 ++ .../libraries/CallbackValidation.sol | 35 + src/dex/v3/periphery/libraries/ChainId.sol | 13 + src/dex/v3/periphery/libraries/HexStrings.sol | 29 + .../periphery/libraries/LiquidityAmounts.sol | 142 +++ .../v3/periphery/libraries/NFTDescriptor.sol | 475 ++++++++++ src/dex/v3/periphery/libraries/NFTSVG.sol | 403 ++++++++ .../v3/periphery/libraries/OracleLibrary.sol | 177 ++++ src/dex/v3/periphery/libraries/Path.sol | 61 ++ .../v3/periphery/libraries/PoolAddress.sol | 46 + .../periphery/libraries/PoolTicksCounter.sol | 96 ++ .../v3/periphery/libraries/PositionKey.sol | 9 + .../v3/periphery/libraries/PositionValue.sol | 164 ++++ .../v3/periphery/libraries/SafeERC20Namer.sol | 96 ++ .../libraries/SqrtPriceMathPartial.sol | 59 ++ .../libraries/TokenRatioSortOrder.sol | 12 + .../v3/periphery/libraries/TransferHelper.sol | 48 + src/provider/V3Provider.sol | 26 +- .../INonfungiblePositionManager.sol | 81 -- src/provider/interfaces/IUniswapV3Factory.sol | 8 - src/provider/interfaces/IUniswapV3Pool.sol | 47 - test/liquidator/V3Liquidator.t.sol | 4 +- test/provider/V3Provider.t.sol | 44 +- 95 files changed, 8522 insertions(+), 173 deletions(-) create mode 100644 src/dex/v3/core/ListaV3Factory.sol create mode 100644 src/dex/v3/core/ListaV3Pool.sol create mode 100644 src/dex/v3/core/ListaV3PoolDeployer.sol create mode 100644 src/dex/v3/core/NoDelegateCall.sol create mode 100644 src/dex/v3/core/interfaces/IERC20Minimal.sol create mode 100644 src/dex/v3/core/interfaces/IListaV3Factory.sol create mode 100644 src/dex/v3/core/interfaces/IListaV3Pool.sol create mode 100644 src/dex/v3/core/interfaces/IListaV3PoolDeployer.sol create mode 100644 src/dex/v3/core/interfaces/LICENSE create mode 100644 src/dex/v3/core/interfaces/callback/IListaV3FlashCallback.sol create mode 100644 src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol create mode 100644 src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol create mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolActions.sol create mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolDerivedState.sol create mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolErrors.sol create mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolEvents.sol create mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolImmutables.sol create mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolOwnerActions.sol create mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolState.sol create mode 100644 src/dex/v3/core/libraries/BitMath.sol create mode 100644 src/dex/v3/core/libraries/FixedPoint128.sol create mode 100644 src/dex/v3/core/libraries/FixedPoint96.sol create mode 100644 src/dex/v3/core/libraries/FullMath.sol create mode 100644 src/dex/v3/core/libraries/LICENSE create mode 100644 src/dex/v3/core/libraries/LICENSE_MIT create mode 100644 src/dex/v3/core/libraries/Oracle.sol create mode 100644 src/dex/v3/core/libraries/Position.sol create mode 100644 src/dex/v3/core/libraries/SafeCast.sol create mode 100644 src/dex/v3/core/libraries/SqrtPriceMath.sol create mode 100644 src/dex/v3/core/libraries/SwapMath.sol create mode 100644 src/dex/v3/core/libraries/Tick.sol create mode 100644 src/dex/v3/core/libraries/TickBitmap.sol create mode 100644 src/dex/v3/core/libraries/TickMath.sol create mode 100644 src/dex/v3/core/libraries/TransferHelper.sol create mode 100644 src/dex/v3/core/libraries/UnsafeMath.sol create mode 100644 src/dex/v3/periphery/NonfungiblePositionManager.sol create mode 100644 src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol create mode 100644 src/dex/v3/periphery/SwapRouter.sol create mode 100644 src/dex/v3/periphery/base/BlockTimestamp.sol create mode 100644 src/dex/v3/periphery/base/ERC721Permit.sol create mode 100644 src/dex/v3/periphery/base/LiquidityManagement.sol create mode 100644 src/dex/v3/periphery/base/Multicall.sol create mode 100644 src/dex/v3/periphery/base/PeripheryImmutableState.sol create mode 100644 src/dex/v3/periphery/base/PeripheryPayments.sol create mode 100644 src/dex/v3/periphery/base/PeripheryPaymentsWithFee.sol create mode 100644 src/dex/v3/periphery/base/PeripheryValidation.sol create mode 100644 src/dex/v3/periphery/base/PoolInitializer.sol create mode 100644 src/dex/v3/periphery/base/SelfPermit.sol create mode 100644 src/dex/v3/periphery/interfaces/IERC20Metadata.sol create mode 100644 src/dex/v3/periphery/interfaces/IERC721Permit.sol create mode 100644 src/dex/v3/periphery/interfaces/IMulticall.sol create mode 100644 src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol create mode 100644 src/dex/v3/periphery/interfaces/INonfungibleTokenPositionDescriptor.sol create mode 100644 src/dex/v3/periphery/interfaces/IPeripheryImmutableState.sol create mode 100644 src/dex/v3/periphery/interfaces/IPeripheryPayments.sol create mode 100644 src/dex/v3/periphery/interfaces/IPeripheryPaymentsWithFee.sol create mode 100644 src/dex/v3/periphery/interfaces/IPoolInitializer.sol create mode 100644 src/dex/v3/periphery/interfaces/IQuoter.sol create mode 100644 src/dex/v3/periphery/interfaces/IQuoterV2.sol create mode 100644 src/dex/v3/periphery/interfaces/ISelfPermit.sol create mode 100644 src/dex/v3/periphery/interfaces/ISwapRouter.sol create mode 100644 src/dex/v3/periphery/interfaces/ITickLens.sol create mode 100644 src/dex/v3/periphery/interfaces/external/IERC1271.sol create mode 100644 src/dex/v3/periphery/interfaces/external/IERC20PermitAllowed.sol create mode 100644 src/dex/v3/periphery/interfaces/external/IWETH9.sol create mode 100644 src/dex/v3/periphery/lens/ListaInterfaceMulticall.sol create mode 100644 src/dex/v3/periphery/lens/Quoter.sol create mode 100644 src/dex/v3/periphery/lens/QuoterV2.sol create mode 100644 src/dex/v3/periphery/lens/README.md create mode 100644 src/dex/v3/periphery/lens/TickLens.sol create mode 100644 src/dex/v3/periphery/libraries/AddressStringUtil.sol create mode 100644 src/dex/v3/periphery/libraries/BytesLib.sol create mode 100644 src/dex/v3/periphery/libraries/CallbackValidation.sol create mode 100644 src/dex/v3/periphery/libraries/ChainId.sol create mode 100644 src/dex/v3/periphery/libraries/HexStrings.sol create mode 100644 src/dex/v3/periphery/libraries/LiquidityAmounts.sol create mode 100644 src/dex/v3/periphery/libraries/NFTDescriptor.sol create mode 100644 src/dex/v3/periphery/libraries/NFTSVG.sol create mode 100644 src/dex/v3/periphery/libraries/OracleLibrary.sol create mode 100644 src/dex/v3/periphery/libraries/Path.sol create mode 100644 src/dex/v3/periphery/libraries/PoolAddress.sol create mode 100644 src/dex/v3/periphery/libraries/PoolTicksCounter.sol create mode 100644 src/dex/v3/periphery/libraries/PositionKey.sol create mode 100644 src/dex/v3/periphery/libraries/PositionValue.sol create mode 100644 src/dex/v3/periphery/libraries/SafeERC20Namer.sol create mode 100644 src/dex/v3/periphery/libraries/SqrtPriceMathPartial.sol create mode 100644 src/dex/v3/periphery/libraries/TokenRatioSortOrder.sol create mode 100644 src/dex/v3/periphery/libraries/TransferHelper.sol delete mode 100644 src/provider/interfaces/INonfungiblePositionManager.sol delete mode 100644 src/provider/interfaces/IUniswapV3Factory.sol delete mode 100644 src/provider/interfaces/IUniswapV3Pool.sol diff --git a/foundry.toml b/foundry.toml index e82b5fc9..8bcada73 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,6 +6,7 @@ libs = ["lib"] remappings = [ "@uniswap/v3-core/=lib/v3-core/", "@uniswap/v3-periphery/=lib/v3-periphery/", + "@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/", ] solc = "0.8.34" optimizer = true diff --git a/src/dex/v3/core/ListaV3Factory.sol b/src/dex/v3/core/ListaV3Factory.sol new file mode 100644 index 00000000..39fb30b8 --- /dev/null +++ b/src/dex/v3/core/ListaV3Factory.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +import { IListaV3Factory } from "./interfaces/IListaV3Factory.sol"; + +import { ListaV3PoolDeployer } from "./ListaV3PoolDeployer.sol"; +import { NoDelegateCall } from "./NoDelegateCall.sol"; + +import { ListaV3Pool } from "./ListaV3Pool.sol"; + +/// @title Canonical Lista V3 factory +/// @notice Deploys Lista V3 pools and manages ownership and control over pool protocol fees +contract ListaV3Factory is IListaV3Factory, ListaV3PoolDeployer, NoDelegateCall { + /// @inheritdoc IListaV3Factory + address public override owner; + + /// @inheritdoc IListaV3Factory + mapping(uint24 => int24) public override feeAmountTickSpacing; + /// @inheritdoc IListaV3Factory + mapping(address => mapping(address => mapping(uint24 => address))) public override getPool; + + constructor() { + owner = msg.sender; + emit OwnerChanged(address(0), msg.sender); + + feeAmountTickSpacing[500] = 10; + emit FeeAmountEnabled(500, 10); + feeAmountTickSpacing[3000] = 60; + emit FeeAmountEnabled(3000, 60); + feeAmountTickSpacing[10000] = 200; + emit FeeAmountEnabled(10000, 200); + } + + /// @inheritdoc IListaV3Factory + function createPool( + address tokenA, + address tokenB, + uint24 fee + ) external override noDelegateCall returns (address pool) { + require(tokenA != tokenB); + (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); + require(token0 != address(0)); + int24 tickSpacing = feeAmountTickSpacing[fee]; + require(tickSpacing != 0); + require(getPool[token0][token1][fee] == address(0)); + pool = deploy(address(this), token0, token1, fee, tickSpacing); + getPool[token0][token1][fee] = pool; + // populate mapping in the reverse direction, deliberate choice to avoid the cost of comparing addresses + getPool[token1][token0][fee] = pool; + emit PoolCreated(token0, token1, fee, tickSpacing, pool); + } + + /// @inheritdoc IListaV3Factory + function setOwner(address _owner) external override { + require(msg.sender == owner); + emit OwnerChanged(owner, _owner); + owner = _owner; + } + + /// @inheritdoc IListaV3Factory + function enableFeeAmount(uint24 fee, int24 tickSpacing) public override { + require(msg.sender == owner); + require(fee < 1000000); + // tick spacing is capped at 16384 to prevent the situation where tickSpacing is so large that + // TickBitmap#nextInitializedTickWithinOneWord overflows int24 container from a valid tick + // 16384 ticks represents a >5x price change with ticks of 1 bips + require(tickSpacing > 0 && tickSpacing < 16384); + require(feeAmountTickSpacing[fee] == 0); + + feeAmountTickSpacing[fee] = tickSpacing; + emit FeeAmountEnabled(fee, tickSpacing); + } +} diff --git a/src/dex/v3/core/ListaV3Pool.sol b/src/dex/v3/core/ListaV3Pool.sol new file mode 100644 index 00000000..4e91aebe --- /dev/null +++ b/src/dex/v3/core/ListaV3Pool.sol @@ -0,0 +1,885 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +import { IListaV3PoolImmutables, IListaV3PoolState, IListaV3PoolActions, IListaV3PoolDerivedState, IListaV3PoolOwnerActions, IListaV3Pool } from "./interfaces/IListaV3Pool.sol"; + +import { NoDelegateCall } from "./NoDelegateCall.sol"; + +import { SafeCast } from "./libraries/SafeCast.sol"; +import { Tick } from "./libraries/Tick.sol"; +import { TickBitmap } from "./libraries/TickBitmap.sol"; +import { Position } from "./libraries/Position.sol"; +import { Oracle } from "./libraries/Oracle.sol"; + +import { FullMath } from "./libraries/FullMath.sol"; +import { FixedPoint128 } from "./libraries/FixedPoint128.sol"; +import { TransferHelper } from "./libraries/TransferHelper.sol"; +import { TickMath } from "./libraries/TickMath.sol"; +import { SqrtPriceMath } from "./libraries/SqrtPriceMath.sol"; +import { SwapMath } from "./libraries/SwapMath.sol"; + +import { IListaV3PoolDeployer } from "./interfaces/IListaV3PoolDeployer.sol"; +import { IListaV3Factory } from "./interfaces/IListaV3Factory.sol"; +import { IERC20Minimal } from "./interfaces/IERC20Minimal.sol"; +import { IListaV3MintCallback } from "./interfaces/callback/IListaV3MintCallback.sol"; +import { IListaV3SwapCallback } from "./interfaces/callback/IListaV3SwapCallback.sol"; +import { IListaV3FlashCallback } from "./interfaces/callback/IListaV3FlashCallback.sol"; + +contract ListaV3Pool is IListaV3Pool, NoDelegateCall { + using SafeCast for uint256; + using SafeCast for int256; + using Tick for mapping(int24 => Tick.Info); + using TickBitmap for mapping(int16 => uint256); + using Position for mapping(bytes32 => Position.Info); + using Position for Position.Info; + using Oracle for Oracle.Observation[65535]; + + /// @inheritdoc IListaV3PoolImmutables + address public immutable override factory; + /// @inheritdoc IListaV3PoolImmutables + address public immutable override token0; + /// @inheritdoc IListaV3PoolImmutables + address public immutable override token1; + /// @inheritdoc IListaV3PoolImmutables + uint24 public immutable override fee; + + /// @inheritdoc IListaV3PoolImmutables + int24 public immutable override tickSpacing; + + /// @inheritdoc IListaV3PoolImmutables + uint128 public immutable override maxLiquidityPerTick; + + struct Slot0 { + // the current price + uint160 sqrtPriceX96; + // the current tick + int24 tick; + // the most-recently updated index of the observations array + uint16 observationIndex; + // the current maximum number of observations that are being stored + uint16 observationCardinality; + // the next maximum number of observations to store, triggered in observations.write + uint16 observationCardinalityNext; + // the current protocol fee as a percentage of the swap fee taken on withdrawal + // represented as an integer denominator (1/x)% + uint8 feeProtocol; + // whether the pool is locked + bool unlocked; + } + /// @inheritdoc IListaV3PoolState + Slot0 public override slot0; + + /// @inheritdoc IListaV3PoolState + uint256 public override feeGrowthGlobal0X128; + /// @inheritdoc IListaV3PoolState + uint256 public override feeGrowthGlobal1X128; + + // accumulated protocol fees in token0/token1 units + struct ProtocolFees { + uint128 token0; + uint128 token1; + } + /// @inheritdoc IListaV3PoolState + ProtocolFees public override protocolFees; + + /// @inheritdoc IListaV3PoolState + uint128 public override liquidity; + + /// @inheritdoc IListaV3PoolState + mapping(int24 => Tick.Info) public override ticks; + /// @inheritdoc IListaV3PoolState + mapping(int16 => uint256) public override tickBitmap; + /// @inheritdoc IListaV3PoolState + mapping(bytes32 => Position.Info) public override positions; + /// @inheritdoc IListaV3PoolState + Oracle.Observation[65535] public override observations; + + /// @dev Mutually exclusive reentrancy protection into the pool to/from a method. This method also prevents entrance + /// to a function before the pool is initialized. The reentrancy guard is required throughout the contract because + /// we use balance checks to determine the payment status of interactions such as mint, swap and flash. + modifier lock() { + if (!slot0.unlocked) revert LOK(); + slot0.unlocked = false; + _; + slot0.unlocked = true; + } + + /// @dev Prevents calling a function from anyone except the address returned by IListaV3Factory#owner() + modifier onlyFactoryOwner() { + require(msg.sender == IListaV3Factory(factory).owner()); + _; + } + + constructor() { + int24 _tickSpacing; + (factory, token0, token1, fee, _tickSpacing) = IListaV3PoolDeployer(msg.sender).parameters(); + tickSpacing = _tickSpacing; + + maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(_tickSpacing); + } + + /// @dev Common checks for valid tick inputs. + function checkTicks(int24 tickLower, int24 tickUpper) private pure { + if (tickLower >= tickUpper) revert TLU(); + if (tickLower < TickMath.MIN_TICK) revert TLM(); + if (tickUpper > TickMath.MAX_TICK) revert TUM(); + } + + /// @dev Returns the block timestamp truncated to 32 bits, i.e. mod 2**32. This method is overridden in tests. + function _blockTimestamp() internal view virtual returns (uint32) { + return uint32(block.timestamp); // truncation is desired + } + + /// @dev Get the pool's balance of token0 + /// @dev This function is gas optimized to avoid a redundant extcodesize check in addition to the returndatasize + /// check + function balance0() private view returns (uint256) { + (bool success, bytes memory data) = token0.staticcall( + abi.encodeWithSelector(IERC20Minimal.balanceOf.selector, address(this)) + ); + require(success && data.length >= 32); + return abi.decode(data, (uint256)); + } + + /// @dev Get the pool's balance of token1 + /// @dev This function is gas optimized to avoid a redundant extcodesize check in addition to the returndatasize + /// check + function balance1() private view returns (uint256) { + (bool success, bytes memory data) = token1.staticcall( + abi.encodeWithSelector(IERC20Minimal.balanceOf.selector, address(this)) + ); + require(success && data.length >= 32); + return abi.decode(data, (uint256)); + } + + /// @inheritdoc IListaV3PoolDerivedState + function snapshotCumulativesInside( + int24 tickLower, + int24 tickUpper + ) + external + view + override + noDelegateCall + returns (int56 tickCumulativeInside, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside) + { + checkTicks(tickLower, tickUpper); + + int56 tickCumulativeLower; + int56 tickCumulativeUpper; + uint160 secondsPerLiquidityOutsideLowerX128; + uint160 secondsPerLiquidityOutsideUpperX128; + uint32 secondsOutsideLower; + uint32 secondsOutsideUpper; + + { + Tick.Info storage lower = ticks[tickLower]; + Tick.Info storage upper = ticks[tickUpper]; + bool initializedLower; + (tickCumulativeLower, secondsPerLiquidityOutsideLowerX128, secondsOutsideLower, initializedLower) = ( + lower.tickCumulativeOutside, + lower.secondsPerLiquidityOutsideX128, + lower.secondsOutside, + lower.initialized + ); + require(initializedLower); + + bool initializedUpper; + (tickCumulativeUpper, secondsPerLiquidityOutsideUpperX128, secondsOutsideUpper, initializedUpper) = ( + upper.tickCumulativeOutside, + upper.secondsPerLiquidityOutsideX128, + upper.secondsOutside, + upper.initialized + ); + require(initializedUpper); + } + + Slot0 memory _slot0 = slot0; + + unchecked { + if (_slot0.tick < tickLower) { + return ( + tickCumulativeLower - tickCumulativeUpper, + secondsPerLiquidityOutsideLowerX128 - secondsPerLiquidityOutsideUpperX128, + secondsOutsideLower - secondsOutsideUpper + ); + } else if (_slot0.tick < tickUpper) { + uint32 time = _blockTimestamp(); + (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) = observations.observeSingle( + time, + 0, + _slot0.tick, + _slot0.observationIndex, + liquidity, + _slot0.observationCardinality + ); + return ( + tickCumulative - tickCumulativeLower - tickCumulativeUpper, + secondsPerLiquidityCumulativeX128 - secondsPerLiquidityOutsideLowerX128 - secondsPerLiquidityOutsideUpperX128, + time - secondsOutsideLower - secondsOutsideUpper + ); + } else { + return ( + tickCumulativeUpper - tickCumulativeLower, + secondsPerLiquidityOutsideUpperX128 - secondsPerLiquidityOutsideLowerX128, + secondsOutsideUpper - secondsOutsideLower + ); + } + } + } + + /// @inheritdoc IListaV3PoolDerivedState + function observe( + uint32[] calldata secondsAgos + ) + external + view + override + noDelegateCall + returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) + { + return + observations.observe( + _blockTimestamp(), + secondsAgos, + slot0.tick, + slot0.observationIndex, + liquidity, + slot0.observationCardinality + ); + } + + /// @inheritdoc IListaV3PoolActions + function increaseObservationCardinalityNext(uint16 observationCardinalityNext) external override lock noDelegateCall { + uint16 observationCardinalityNextOld = slot0.observationCardinalityNext; // for the event + uint16 observationCardinalityNextNew = observations.grow(observationCardinalityNextOld, observationCardinalityNext); + slot0.observationCardinalityNext = observationCardinalityNextNew; + if (observationCardinalityNextOld != observationCardinalityNextNew) + emit IncreaseObservationCardinalityNext(observationCardinalityNextOld, observationCardinalityNextNew); + } + + /// @inheritdoc IListaV3PoolActions + /// @dev not locked because it initializes unlocked + function initialize(uint160 sqrtPriceX96) external override { + if (slot0.sqrtPriceX96 != 0) revert AI(); + + int24 tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96); + + (uint16 cardinality, uint16 cardinalityNext) = observations.initialize(_blockTimestamp()); + + slot0 = Slot0({ + sqrtPriceX96: sqrtPriceX96, + tick: tick, + observationIndex: 0, + observationCardinality: cardinality, + observationCardinalityNext: cardinalityNext, + feeProtocol: 0, + unlocked: true + }); + + emit Initialize(sqrtPriceX96, tick); + } + + struct ModifyPositionParams { + // the address that owns the position + address owner; + // the lower and upper tick of the position + int24 tickLower; + int24 tickUpper; + // any change in liquidity + int128 liquidityDelta; + } + + /// @dev Effect some changes to a position + /// @param params the position details and the change to the position's liquidity to effect + /// @return position a storage pointer referencing the position with the given owner and tick range + /// @return amount0 the amount of token0 owed to the pool, negative if the pool should pay the recipient + /// @return amount1 the amount of token1 owed to the pool, negative if the pool should pay the recipient + function _modifyPosition( + ModifyPositionParams memory params + ) private noDelegateCall returns (Position.Info storage position, int256 amount0, int256 amount1) { + checkTicks(params.tickLower, params.tickUpper); + + Slot0 memory _slot0 = slot0; // SLOAD for gas optimization + + position = _updatePosition(params.owner, params.tickLower, params.tickUpper, params.liquidityDelta, _slot0.tick); + + if (params.liquidityDelta != 0) { + if (_slot0.tick < params.tickLower) { + // current tick is below the passed range; liquidity can only become in range by crossing from left to + // right, when we'll need _more_ token0 (it's becoming more valuable) so user must provide it + amount0 = SqrtPriceMath.getAmount0Delta( + TickMath.getSqrtRatioAtTick(params.tickLower), + TickMath.getSqrtRatioAtTick(params.tickUpper), + params.liquidityDelta + ); + } else if (_slot0.tick < params.tickUpper) { + // current tick is inside the passed range + uint128 liquidityBefore = liquidity; // SLOAD for gas optimization + + // write an oracle entry + (slot0.observationIndex, slot0.observationCardinality) = observations.write( + _slot0.observationIndex, + _blockTimestamp(), + _slot0.tick, + liquidityBefore, + _slot0.observationCardinality, + _slot0.observationCardinalityNext + ); + + amount0 = SqrtPriceMath.getAmount0Delta( + _slot0.sqrtPriceX96, + TickMath.getSqrtRatioAtTick(params.tickUpper), + params.liquidityDelta + ); + amount1 = SqrtPriceMath.getAmount1Delta( + TickMath.getSqrtRatioAtTick(params.tickLower), + _slot0.sqrtPriceX96, + params.liquidityDelta + ); + + liquidity = params.liquidityDelta < 0 + ? liquidityBefore - uint128(-params.liquidityDelta) + : liquidityBefore + uint128(params.liquidityDelta); + } else { + // current tick is above the passed range; liquidity can only become in range by crossing from right to + // left, when we'll need _more_ token1 (it's becoming more valuable) so user must provide it + amount1 = SqrtPriceMath.getAmount1Delta( + TickMath.getSqrtRatioAtTick(params.tickLower), + TickMath.getSqrtRatioAtTick(params.tickUpper), + params.liquidityDelta + ); + } + } + } + + /// @dev Gets and updates a position with the given liquidity delta + /// @param owner the owner of the position + /// @param tickLower the lower tick of the position's tick range + /// @param tickUpper the upper tick of the position's tick range + /// @param tick the current tick, passed to avoid sloads + function _updatePosition( + address owner, + int24 tickLower, + int24 tickUpper, + int128 liquidityDelta, + int24 tick + ) private returns (Position.Info storage position) { + position = positions.get(owner, tickLower, tickUpper); + + uint256 _feeGrowthGlobal0X128 = feeGrowthGlobal0X128; // SLOAD for gas optimization + uint256 _feeGrowthGlobal1X128 = feeGrowthGlobal1X128; // SLOAD for gas optimization + + // if we need to update the ticks, do it + bool flippedLower; + bool flippedUpper; + if (liquidityDelta != 0) { + uint32 time = _blockTimestamp(); + (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) = observations.observeSingle( + time, + 0, + slot0.tick, + slot0.observationIndex, + liquidity, + slot0.observationCardinality + ); + + flippedLower = ticks.update( + tickLower, + tick, + liquidityDelta, + _feeGrowthGlobal0X128, + _feeGrowthGlobal1X128, + secondsPerLiquidityCumulativeX128, + tickCumulative, + time, + false, + maxLiquidityPerTick + ); + flippedUpper = ticks.update( + tickUpper, + tick, + liquidityDelta, + _feeGrowthGlobal0X128, + _feeGrowthGlobal1X128, + secondsPerLiquidityCumulativeX128, + tickCumulative, + time, + true, + maxLiquidityPerTick + ); + + if (flippedLower) { + tickBitmap.flipTick(tickLower, tickSpacing); + } + if (flippedUpper) { + tickBitmap.flipTick(tickUpper, tickSpacing); + } + } + + (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) = ticks.getFeeGrowthInside( + tickLower, + tickUpper, + tick, + _feeGrowthGlobal0X128, + _feeGrowthGlobal1X128 + ); + + position.update(liquidityDelta, feeGrowthInside0X128, feeGrowthInside1X128); + + // clear any tick data that is no longer needed + if (liquidityDelta < 0) { + if (flippedLower) { + ticks.clear(tickLower); + } + if (flippedUpper) { + ticks.clear(tickUpper); + } + } + } + + /// @inheritdoc IListaV3PoolActions + /// @dev noDelegateCall is applied indirectly via _modifyPosition + function mint( + address recipient, + int24 tickLower, + int24 tickUpper, + uint128 amount, + bytes calldata data + ) external override lock returns (uint256 amount0, uint256 amount1) { + require(amount > 0); + (, int256 amount0Int, int256 amount1Int) = _modifyPosition( + ModifyPositionParams({ + owner: recipient, + tickLower: tickLower, + tickUpper: tickUpper, + liquidityDelta: int256(uint256(amount)).toInt128() + }) + ); + + amount0 = uint256(amount0Int); + amount1 = uint256(amount1Int); + + uint256 balance0Before; + uint256 balance1Before; + if (amount0 > 0) balance0Before = balance0(); + if (amount1 > 0) balance1Before = balance1(); + IListaV3MintCallback(msg.sender).listaV3MintCallback(amount0, amount1, data); + if (amount0 > 0 && balance0Before + amount0 > balance0()) revert M0(); + if (amount1 > 0 && balance1Before + amount1 > balance1()) revert M1(); + + emit Mint(msg.sender, recipient, tickLower, tickUpper, amount, amount0, amount1); + } + + /// @inheritdoc IListaV3PoolActions + function collect( + address recipient, + int24 tickLower, + int24 tickUpper, + uint128 amount0Requested, + uint128 amount1Requested + ) external override lock returns (uint128 amount0, uint128 amount1) { + // we don't need to checkTicks here, because invalid positions will never have non-zero tokensOwed{0,1} + Position.Info storage position = positions.get(msg.sender, tickLower, tickUpper); + + amount0 = amount0Requested > position.tokensOwed0 ? position.tokensOwed0 : amount0Requested; + amount1 = amount1Requested > position.tokensOwed1 ? position.tokensOwed1 : amount1Requested; + + unchecked { + if (amount0 > 0) { + position.tokensOwed0 -= amount0; + TransferHelper.safeTransfer(token0, recipient, amount0); + } + if (amount1 > 0) { + position.tokensOwed1 -= amount1; + TransferHelper.safeTransfer(token1, recipient, amount1); + } + } + + emit Collect(msg.sender, recipient, tickLower, tickUpper, amount0, amount1); + } + + /// @inheritdoc IListaV3PoolActions + /// @dev noDelegateCall is applied indirectly via _modifyPosition + function burn( + int24 tickLower, + int24 tickUpper, + uint128 amount + ) external override lock returns (uint256 amount0, uint256 amount1) { + unchecked { + (Position.Info storage position, int256 amount0Int, int256 amount1Int) = _modifyPosition( + ModifyPositionParams({ + owner: msg.sender, + tickLower: tickLower, + tickUpper: tickUpper, + liquidityDelta: -int256(uint256(amount)).toInt128() + }) + ); + + amount0 = uint256(-amount0Int); + amount1 = uint256(-amount1Int); + + if (amount0 > 0 || amount1 > 0) { + (position.tokensOwed0, position.tokensOwed1) = ( + position.tokensOwed0 + uint128(amount0), + position.tokensOwed1 + uint128(amount1) + ); + } + + emit Burn(msg.sender, tickLower, tickUpper, amount, amount0, amount1); + } + } + + struct SwapCache { + // the protocol fee for the input token + uint8 feeProtocol; + // liquidity at the beginning of the swap + uint128 liquidityStart; + // the timestamp of the current block + uint32 blockTimestamp; + // the current value of the tick accumulator, computed only if we cross an initialized tick + int56 tickCumulative; + // the current value of seconds per liquidity accumulator, computed only if we cross an initialized tick + uint160 secondsPerLiquidityCumulativeX128; + // whether we've computed and cached the above two accumulators + bool computedLatestObservation; + } + + // the top level state of the swap, the results of which are recorded in storage at the end + struct SwapState { + // the amount remaining to be swapped in/out of the input/output asset + int256 amountSpecifiedRemaining; + // the amount already swapped out/in of the output/input asset + int256 amountCalculated; + // current sqrt(price) + uint160 sqrtPriceX96; + // the tick associated with the current price + int24 tick; + // the global fee growth of the input token + uint256 feeGrowthGlobalX128; + // amount of input token paid as protocol fee + uint128 protocolFee; + // the current liquidity in range + uint128 liquidity; + } + + struct StepComputations { + // the price at the beginning of the step + uint160 sqrtPriceStartX96; + // the next tick to swap to from the current tick in the swap direction + int24 tickNext; + // whether tickNext is initialized or not + bool initialized; + // sqrt(price) for the next tick (1/0) + uint160 sqrtPriceNextX96; + // how much is being swapped in in this step + uint256 amountIn; + // how much is being swapped out + uint256 amountOut; + // how much fee is being paid in + uint256 feeAmount; + } + + /// @inheritdoc IListaV3PoolActions + function swap( + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 sqrtPriceLimitX96, + bytes calldata data + ) external override noDelegateCall returns (int256 amount0, int256 amount1) { + if (amountSpecified == 0) revert AS(); + + Slot0 memory slot0Start = slot0; + + if (!slot0Start.unlocked) revert LOK(); + require( + zeroForOne + ? sqrtPriceLimitX96 < slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 > TickMath.MIN_SQRT_RATIO + : sqrtPriceLimitX96 > slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 < TickMath.MAX_SQRT_RATIO, + "SPL" + ); + + slot0.unlocked = false; + + SwapCache memory cache = SwapCache({ + liquidityStart: liquidity, + blockTimestamp: _blockTimestamp(), + feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4), + secondsPerLiquidityCumulativeX128: 0, + tickCumulative: 0, + computedLatestObservation: false + }); + + bool exactInput = amountSpecified > 0; + + SwapState memory state = SwapState({ + amountSpecifiedRemaining: amountSpecified, + amountCalculated: 0, + sqrtPriceX96: slot0Start.sqrtPriceX96, + tick: slot0Start.tick, + feeGrowthGlobalX128: zeroForOne ? feeGrowthGlobal0X128 : feeGrowthGlobal1X128, + protocolFee: 0, + liquidity: cache.liquidityStart + }); + + // continue swapping as long as we haven't used the entire input/output and haven't reached the price limit + while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) { + StepComputations memory step; + + step.sqrtPriceStartX96 = state.sqrtPriceX96; + + (step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord( + state.tick, + tickSpacing, + zeroForOne + ); + + // ensure that we do not overshoot the min/max tick, as the tick bitmap is not aware of these bounds + if (step.tickNext < TickMath.MIN_TICK) { + step.tickNext = TickMath.MIN_TICK; + } else if (step.tickNext > TickMath.MAX_TICK) { + step.tickNext = TickMath.MAX_TICK; + } + + // get the price for the next tick + step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext); + + // compute values to swap to the target tick, price limit, or point where input/output amount is exhausted + (state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep( + state.sqrtPriceX96, + (zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96) + ? sqrtPriceLimitX96 + : step.sqrtPriceNextX96, + state.liquidity, + state.amountSpecifiedRemaining, + fee + ); + + if (exactInput) { + // safe because we test that amountSpecified > amountIn + feeAmount in SwapMath + unchecked { + state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256(); + } + state.amountCalculated -= step.amountOut.toInt256(); + } else { + unchecked { + state.amountSpecifiedRemaining += step.amountOut.toInt256(); + } + state.amountCalculated += (step.amountIn + step.feeAmount).toInt256(); + } + + // if the protocol fee is on, calculate how much is owed, decrement feeAmount, and increment protocolFee + if (cache.feeProtocol > 0) { + unchecked { + uint256 delta = step.feeAmount / cache.feeProtocol; + step.feeAmount -= delta; + state.protocolFee += uint128(delta); + } + } + + // update global fee tracker + if (state.liquidity > 0) { + unchecked { + state.feeGrowthGlobalX128 += FullMath.mulDiv(step.feeAmount, FixedPoint128.Q128, state.liquidity); + } + } + + // shift tick if we reached the next price + if (state.sqrtPriceX96 == step.sqrtPriceNextX96) { + // if the tick is initialized, run the tick transition + if (step.initialized) { + // check for the placeholder value, which we replace with the actual value the first time the swap + // crosses an initialized tick + if (!cache.computedLatestObservation) { + (cache.tickCumulative, cache.secondsPerLiquidityCumulativeX128) = observations.observeSingle( + cache.blockTimestamp, + 0, + slot0Start.tick, + slot0Start.observationIndex, + cache.liquidityStart, + slot0Start.observationCardinality + ); + cache.computedLatestObservation = true; + } + int128 liquidityNet = ticks.cross( + step.tickNext, + (zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128), + (zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128), + cache.secondsPerLiquidityCumulativeX128, + cache.tickCumulative, + cache.blockTimestamp + ); + // if we're moving leftward, we interpret liquidityNet as the opposite sign + // safe because liquidityNet cannot be type(int128).min + unchecked { + if (zeroForOne) liquidityNet = -liquidityNet; + } + + state.liquidity = liquidityNet < 0 + ? state.liquidity - uint128(-liquidityNet) + : state.liquidity + uint128(liquidityNet); + } + + unchecked { + state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext; + } + } else if (state.sqrtPriceX96 != step.sqrtPriceStartX96) { + // recompute unless we're on a lower tick boundary (i.e. already transitioned ticks), and haven't moved + state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96); + } + } + + // update tick and write an oracle entry if the tick change + if (state.tick != slot0Start.tick) { + (uint16 observationIndex, uint16 observationCardinality) = observations.write( + slot0Start.observationIndex, + cache.blockTimestamp, + slot0Start.tick, + cache.liquidityStart, + slot0Start.observationCardinality, + slot0Start.observationCardinalityNext + ); + (slot0.sqrtPriceX96, slot0.tick, slot0.observationIndex, slot0.observationCardinality) = ( + state.sqrtPriceX96, + state.tick, + observationIndex, + observationCardinality + ); + } else { + // otherwise just update the price + slot0.sqrtPriceX96 = state.sqrtPriceX96; + } + + // update liquidity if it changed + if (cache.liquidityStart != state.liquidity) liquidity = state.liquidity; + + // update fee growth global and, if necessary, protocol fees + // overflow is acceptable, protocol has to withdraw before it hits type(uint128).max fees + if (zeroForOne) { + feeGrowthGlobal0X128 = state.feeGrowthGlobalX128; + unchecked { + if (state.protocolFee > 0) protocolFees.token0 += state.protocolFee; + } + } else { + feeGrowthGlobal1X128 = state.feeGrowthGlobalX128; + unchecked { + if (state.protocolFee > 0) protocolFees.token1 += state.protocolFee; + } + } + + unchecked { + (amount0, amount1) = zeroForOne == exactInput + ? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated) + : (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining); + } + + // do the transfers and collect payment + if (zeroForOne) { + unchecked { + if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1)); + } + + uint256 balance0Before = balance0(); + IListaV3SwapCallback(msg.sender).listaV3SwapCallback(amount0, amount1, data); + if (balance0Before + uint256(amount0) > balance0()) revert IIA(); + } else { + unchecked { + if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0)); + } + + uint256 balance1Before = balance1(); + IListaV3SwapCallback(msg.sender).listaV3SwapCallback(amount0, amount1, data); + if (balance1Before + uint256(amount1) > balance1()) revert IIA(); + } + + emit Swap(msg.sender, recipient, amount0, amount1, state.sqrtPriceX96, state.liquidity, state.tick); + slot0.unlocked = true; + } + + /// @inheritdoc IListaV3PoolActions + function flash( + address recipient, + uint256 amount0, + uint256 amount1, + bytes calldata data + ) external override lock noDelegateCall { + uint128 _liquidity = liquidity; + if (_liquidity <= 0) revert L(); + + uint256 fee0 = FullMath.mulDivRoundingUp(amount0, fee, 1e6); + uint256 fee1 = FullMath.mulDivRoundingUp(amount1, fee, 1e6); + uint256 balance0Before = balance0(); + uint256 balance1Before = balance1(); + + if (amount0 > 0) TransferHelper.safeTransfer(token0, recipient, amount0); + if (amount1 > 0) TransferHelper.safeTransfer(token1, recipient, amount1); + + IListaV3FlashCallback(msg.sender).listaV3FlashCallback(fee0, fee1, data); + + uint256 balance0After = balance0(); + uint256 balance1After = balance1(); + + if (balance0Before + fee0 > balance0After) revert F0(); + if (balance1Before + fee1 > balance1After) revert F1(); + + unchecked { + // sub is safe because we know balanceAfter is gt balanceBefore by at least fee + uint256 paid0 = balance0After - balance0Before; + uint256 paid1 = balance1After - balance1Before; + + if (paid0 > 0) { + uint8 feeProtocol0 = slot0.feeProtocol % 16; + uint256 pFees0 = feeProtocol0 == 0 ? 0 : paid0 / feeProtocol0; + if (uint128(pFees0) > 0) protocolFees.token0 += uint128(pFees0); + feeGrowthGlobal0X128 += FullMath.mulDiv(paid0 - pFees0, FixedPoint128.Q128, _liquidity); + } + if (paid1 > 0) { + uint8 feeProtocol1 = slot0.feeProtocol >> 4; + uint256 pFees1 = feeProtocol1 == 0 ? 0 : paid1 / feeProtocol1; + if (uint128(pFees1) > 0) protocolFees.token1 += uint128(pFees1); + feeGrowthGlobal1X128 += FullMath.mulDiv(paid1 - pFees1, FixedPoint128.Q128, _liquidity); + } + + emit Flash(msg.sender, recipient, amount0, amount1, paid0, paid1); + } + } + + /// @inheritdoc IListaV3PoolOwnerActions + function setFeeProtocol(uint8 feeProtocol0, uint8 feeProtocol1) external override lock onlyFactoryOwner { + unchecked { + require( + (feeProtocol0 == 0 || (feeProtocol0 >= 4 && feeProtocol0 <= 10)) && + (feeProtocol1 == 0 || (feeProtocol1 >= 4 && feeProtocol1 <= 10)) + ); + uint8 feeProtocolOld = slot0.feeProtocol; + slot0.feeProtocol = feeProtocol0 + (feeProtocol1 << 4); + emit SetFeeProtocol(feeProtocolOld % 16, feeProtocolOld >> 4, feeProtocol0, feeProtocol1); + } + } + + /// @inheritdoc IListaV3PoolOwnerActions + function collectProtocol( + address recipient, + uint128 amount0Requested, + uint128 amount1Requested + ) external override lock onlyFactoryOwner returns (uint128 amount0, uint128 amount1) { + amount0 = amount0Requested > protocolFees.token0 ? protocolFees.token0 : amount0Requested; + amount1 = amount1Requested > protocolFees.token1 ? protocolFees.token1 : amount1Requested; + + unchecked { + if (amount0 > 0) { + if (amount0 == protocolFees.token0) amount0--; // ensure that the slot is not cleared, for gas savings + protocolFees.token0 -= amount0; + TransferHelper.safeTransfer(token0, recipient, amount0); + } + if (amount1 > 0) { + if (amount1 == protocolFees.token1) amount1--; // ensure that the slot is not cleared, for gas savings + protocolFees.token1 -= amount1; + TransferHelper.safeTransfer(token1, recipient, amount1); + } + } + + emit CollectProtocol(msg.sender, recipient, amount0, amount1); + } +} diff --git a/src/dex/v3/core/ListaV3PoolDeployer.sol b/src/dex/v3/core/ListaV3PoolDeployer.sol new file mode 100644 index 00000000..e9724f4e --- /dev/null +++ b/src/dex/v3/core/ListaV3PoolDeployer.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +import { IListaV3PoolDeployer } from "./interfaces/IListaV3PoolDeployer.sol"; + +import { ListaV3Pool } from "./ListaV3Pool.sol"; + +contract ListaV3PoolDeployer is IListaV3PoolDeployer { + struct Parameters { + address factory; + address token0; + address token1; + uint24 fee; + int24 tickSpacing; + } + + /// @inheritdoc IListaV3PoolDeployer + Parameters public override parameters; + + /// @dev Deploys a pool with the given parameters by transiently setting the parameters storage slot and then + /// clearing it after deploying the pool. + /// @param factory The contract address of the Lista V3 factory + /// @param token0 The first token of the pool by address sort order + /// @param token1 The second token of the pool by address sort order + /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip + /// @param tickSpacing The spacing between usable ticks + function deploy( + address factory, + address token0, + address token1, + uint24 fee, + int24 tickSpacing + ) internal returns (address pool) { + parameters = Parameters({ factory: factory, token0: token0, token1: token1, fee: fee, tickSpacing: tickSpacing }); + pool = address(new ListaV3Pool{ salt: keccak256(abi.encode(token0, token1, fee)) }()); + delete parameters; + } +} diff --git a/src/dex/v3/core/NoDelegateCall.sol b/src/dex/v3/core/NoDelegateCall.sol new file mode 100644 index 00000000..7355564b --- /dev/null +++ b/src/dex/v3/core/NoDelegateCall.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +/// @title Prevents delegatecall to a contract +/// @notice Base contract that provides a modifier for preventing delegatecall to methods in a child contract +abstract contract NoDelegateCall { + /// @dev The original address of this contract + address private immutable original; + + constructor() { + // Immutables are computed in the init code of the contract, and then inlined into the deployed bytecode. + // In other words, this variable won't change when it's checked at runtime. + original = address(this); + } + + /// @dev Private method is used instead of inlining into modifier because modifiers are copied into each method, + /// and the use of immutable means the address bytes are copied in every place the modifier is used. + function checkNotDelegateCall() private view { + require(address(this) == original); + } + + /// @notice Prevents delegatecall into the modified method + modifier noDelegateCall() { + checkNotDelegateCall(); + _; + } +} diff --git a/src/dex/v3/core/interfaces/IERC20Minimal.sol b/src/dex/v3/core/interfaces/IERC20Minimal.sol new file mode 100644 index 00000000..8ef8bfd4 --- /dev/null +++ b/src/dex/v3/core/interfaces/IERC20Minimal.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Minimal ERC20 interface for Lista +/// @notice Contains a subset of the full ERC20 interface that is used in Lista V3 +interface IERC20Minimal { + /// @notice Returns the balance of a token + /// @param account The account for which to look up the number of tokens it has, i.e. its balance + /// @return The number of tokens held by the account + function balanceOf(address account) external view returns (uint256); + + /// @notice Transfers the amount of token from the `msg.sender` to the recipient + /// @param recipient The account that will receive the amount transferred + /// @param amount The number of tokens to send from the sender to the recipient + /// @return Returns true for a successful transfer, false for an unsuccessful transfer + function transfer(address recipient, uint256 amount) external returns (bool); + + /// @notice Returns the current allowance given to a spender by an owner + /// @param owner The account of the token owner + /// @param spender The account of the token spender + /// @return The current allowance granted by `owner` to `spender` + function allowance(address owner, address spender) external view returns (uint256); + + /// @notice Sets the allowance of a spender from the `msg.sender` to the value `amount` + /// @param spender The account which will be allowed to spend a given amount of the owners tokens + /// @param amount The amount of tokens allowed to be used by `spender` + /// @return Returns true for a successful approval, false for unsuccessful + function approve(address spender, uint256 amount) external returns (bool); + + /// @notice Transfers `amount` tokens from `sender` to `recipient` up to the allowance given to the `msg.sender` + /// @param sender The account from which the transfer will be initiated + /// @param recipient The recipient of the transfer + /// @param amount The amount of the transfer + /// @return Returns true for a successful transfer, false for unsuccessful + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); + + /// @notice Event emitted when tokens are transferred from one address to another, either via `#transfer` or `#transferFrom`. + /// @param from The account from which the tokens were sent, i.e. the balance decreased + /// @param to The account to which the tokens were sent, i.e. the balance increased + /// @param value The amount of tokens that were transferred + event Transfer(address indexed from, address indexed to, uint256 value); + + /// @notice Event emitted when the approval amount for the spender of a given owner's tokens changes. + /// @param owner The account that approved spending of its tokens + /// @param spender The account for which the spending allowance was modified + /// @param value The new allowance from the owner to the spender + event Approval(address indexed owner, address indexed spender, uint256 value); +} diff --git a/src/dex/v3/core/interfaces/IListaV3Factory.sol b/src/dex/v3/core/interfaces/IListaV3Factory.sol new file mode 100644 index 00000000..d9e7eba4 --- /dev/null +++ b/src/dex/v3/core/interfaces/IListaV3Factory.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title The interface for the Lista V3 Factory +/// @notice The Lista V3 Factory facilitates creation of Lista V3 pools and control over the protocol fees +interface IListaV3Factory { + /// @notice Emitted when the owner of the factory is changed + /// @param oldOwner The owner before the owner was changed + /// @param newOwner The owner after the owner was changed + event OwnerChanged(address indexed oldOwner, address indexed newOwner); + + /// @notice Emitted when a pool is created + /// @param token0 The first token of the pool by address sort order + /// @param token1 The second token of the pool by address sort order + /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip + /// @param tickSpacing The minimum number of ticks between initialized ticks + /// @param pool The address of the created pool + event PoolCreated( + address indexed token0, + address indexed token1, + uint24 indexed fee, + int24 tickSpacing, + address pool + ); + + /// @notice Emitted when a new fee amount is enabled for pool creation via the factory + /// @param fee The enabled fee, denominated in hundredths of a bip + /// @param tickSpacing The minimum number of ticks between initialized ticks for pools created with the given fee + event FeeAmountEnabled(uint24 indexed fee, int24 indexed tickSpacing); + + /// @notice Returns the current owner of the factory + /// @dev Can be changed by the current owner via setOwner + /// @return The address of the factory owner + function owner() external view returns (address); + + /// @notice Returns the tick spacing for a given fee amount, if enabled, or 0 if not enabled + /// @dev A fee amount can never be removed, so this value should be hard coded or cached in the calling context + /// @param fee The enabled fee, denominated in hundredths of a bip. Returns 0 in case of unenabled fee + /// @return The tick spacing + function feeAmountTickSpacing(uint24 fee) external view returns (int24); + + /// @notice Returns the pool address for a given pair of tokens and a fee, or address 0 if it does not exist + /// @dev tokenA and tokenB may be passed in either token0/token1 or token1/token0 order + /// @param tokenA The contract address of either token0 or token1 + /// @param tokenB The contract address of the other token + /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip + /// @return pool The pool address + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool); + + /// @notice Creates a pool for the given two tokens and fee + /// @param tokenA One of the two tokens in the desired pool + /// @param tokenB The other of the two tokens in the desired pool + /// @param fee The desired fee for the pool + /// @dev tokenA and tokenB may be passed in either order: token0/token1 or token1/token0. tickSpacing is retrieved + /// from the fee. The call will revert if the pool already exists, the fee is invalid, or the token arguments + /// are invalid. + /// @return pool The address of the newly created pool + function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); + + /// @notice Updates the owner of the factory + /// @dev Must be called by the current owner + /// @param _owner The new owner of the factory + function setOwner(address _owner) external; + + /// @notice Enables a fee amount with the given tickSpacing + /// @dev Fee amounts may never be removed once enabled + /// @param fee The fee amount to enable, denominated in hundredths of a bip (i.e. 1e-6) + /// @param tickSpacing The spacing between ticks to be enforced for all pools created with the given fee amount + function enableFeeAmount(uint24 fee, int24 tickSpacing) external; +} diff --git a/src/dex/v3/core/interfaces/IListaV3Pool.sol b/src/dex/v3/core/interfaces/IListaV3Pool.sol new file mode 100644 index 00000000..bf06b2ed --- /dev/null +++ b/src/dex/v3/core/interfaces/IListaV3Pool.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +import { IListaV3PoolImmutables } from "./pool/IListaV3PoolImmutables.sol"; +import { IListaV3PoolState } from "./pool/IListaV3PoolState.sol"; +import { IListaV3PoolDerivedState } from "./pool/IListaV3PoolDerivedState.sol"; +import { IListaV3PoolActions } from "./pool/IListaV3PoolActions.sol"; +import { IListaV3PoolOwnerActions } from "./pool/IListaV3PoolOwnerActions.sol"; +import { IListaV3PoolErrors } from "./pool/IListaV3PoolErrors.sol"; +import { IListaV3PoolEvents } from "./pool/IListaV3PoolEvents.sol"; + +/// @title The interface for a Lista V3 Pool +/// @notice A Lista pool facilitates swapping and automated market making between any two assets that strictly conform +/// to the ERC20 specification +/// @dev The pool interface is broken up into many smaller pieces +interface IListaV3Pool is + IListaV3PoolImmutables, + IListaV3PoolState, + IListaV3PoolDerivedState, + IListaV3PoolActions, + IListaV3PoolOwnerActions, + IListaV3PoolErrors, + IListaV3PoolEvents +{} diff --git a/src/dex/v3/core/interfaces/IListaV3PoolDeployer.sol b/src/dex/v3/core/interfaces/IListaV3PoolDeployer.sol new file mode 100644 index 00000000..d49d20ca --- /dev/null +++ b/src/dex/v3/core/interfaces/IListaV3PoolDeployer.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title An interface for a contract that is capable of deploying Lista V3 Pools +/// @notice A contract that constructs a pool must implement this to pass arguments to the pool +/// @dev This is used to avoid having constructor arguments in the pool contract, which results in the init code hash +/// of the pool being constant allowing the CREATE2 address of the pool to be cheaply computed on-chain +interface IListaV3PoolDeployer { + /// @notice Get the parameters to be used in constructing the pool, set transiently during pool creation. + /// @dev Called by the pool constructor to fetch the parameters of the pool + /// Returns factory The factory address + /// Returns token0 The first token of the pool by address sort order + /// Returns token1 The second token of the pool by address sort order + /// Returns fee The fee collected upon every swap in the pool, denominated in hundredths of a bip + /// Returns tickSpacing The minimum number of ticks between initialized ticks + function parameters() + external + view + returns (address factory, address token0, address token1, uint24 fee, int24 tickSpacing); +} diff --git a/src/dex/v3/core/interfaces/LICENSE b/src/dex/v3/core/interfaces/LICENSE new file mode 100644 index 00000000..7f6aca78 --- /dev/null +++ b/src/dex/v3/core/interfaces/LICENSE @@ -0,0 +1,445 @@ +This software is available under your choice of the GNU General Public +License, version 2 or later, or the Business Source License, as set +forth below. + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. + + +Business Source License 1.1 + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Parameters + +Licensor: Uniswap Labs + +Licensed Work: Uniswap V3 Core + The Licensed Work is (c) 2021 Uniswap Labs + +Additional Use Grant: Any uses listed and defined at + v3-core-license-grants.uniswap.eth + +Change Date: The earlier of 2023-04-01 or a date specified at + v3-core-license-date.uniswap.eth + +Change License: GNU General Public License v2.0 or later + +----------------------------------------------------------------------------- + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark "Business Source License", +as long as you comply with the Covenants of Licensor below. + +----------------------------------------------------------------------------- + +Covenants of Licensor + +In consideration of the right to use this License’s text and the "Business +Source License" name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where "compatible" means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text "None". + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Notice + +The Business Source License (this document, or the "License") is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. diff --git a/src/dex/v3/core/interfaces/callback/IListaV3FlashCallback.sol b/src/dex/v3/core/interfaces/callback/IListaV3FlashCallback.sol new file mode 100644 index 00000000..fb29204f --- /dev/null +++ b/src/dex/v3/core/interfaces/callback/IListaV3FlashCallback.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Callback for IListaV3PoolActions#flash +/// @notice Any contract that calls IListaV3PoolActions#flash must implement this interface +interface IListaV3FlashCallback { + /// @notice Called to `msg.sender` after transferring to the recipient from IListaV3Pool#flash. + /// @dev In the implementation you must repay the pool the tokens sent by flash plus the computed fee amounts. + /// The caller of this method must be checked to be a ListaV3Pool deployed by the canonical ListaV3Factory. + /// @param fee0 The fee amount in token0 due to the pool by the end of the flash + /// @param fee1 The fee amount in token1 due to the pool by the end of the flash + /// @param data Any data passed through by the caller via the IListaV3PoolActions#flash call + function listaV3FlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external; +} diff --git a/src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol b/src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol new file mode 100644 index 00000000..67ec5c92 --- /dev/null +++ b/src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Callback for IListaV3PoolActions#mint +/// @notice Any contract that calls IListaV3PoolActions#mint must implement this interface +interface IListaV3MintCallback { + /// @notice Called to `msg.sender` after minting liquidity to a position from IListaV3Pool#mint. + /// @dev In the implementation you must pay the pool tokens owed for the minted liquidity. + /// The caller of this method must be checked to be a ListaV3Pool deployed by the canonical ListaV3Factory. + /// @param amount0Owed The amount of token0 due to the pool for the minted liquidity + /// @param amount1Owed The amount of token1 due to the pool for the minted liquidity + /// @param data Any data passed through by the caller via the IListaV3PoolActions#mint call + function listaV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata data) external; +} diff --git a/src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol b/src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol new file mode 100644 index 00000000..ebf4b830 --- /dev/null +++ b/src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Callback for IListaV3PoolActions#swap +/// @notice Any contract that calls IListaV3PoolActions#swap must implement this interface +interface IListaV3SwapCallback { + /// @notice Called to `msg.sender` after executing a swap via IListaV3Pool#swap. + /// @dev In the implementation you must pay the pool tokens owed for the swap. + /// The caller of this method must be checked to be a ListaV3Pool deployed by the canonical ListaV3Factory. + /// amount0Delta and amount1Delta can both be 0 if no tokens were swapped. + /// @param amount0Delta The amount of token0 that was sent (negative) or must be received (positive) by the pool by + /// the end of the swap. If positive, the callback must send that amount of token0 to the pool. + /// @param amount1Delta The amount of token1 that was sent (negative) or must be received (positive) by the pool by + /// the end of the swap. If positive, the callback must send that amount of token1 to the pool. + /// @param data Any data passed through by the caller via the IListaV3PoolActions#swap call + function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external; +} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolActions.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolActions.sol new file mode 100644 index 00000000..c778cfb4 --- /dev/null +++ b/src/dex/v3/core/interfaces/pool/IListaV3PoolActions.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Permissionless pool actions +/// @notice Contains pool methods that can be called by anyone +interface IListaV3PoolActions { + /// @notice Sets the initial price for the pool + /// @dev Price is represented as a sqrt(amountToken1/amountToken0) Q64.96 value + /// @param sqrtPriceX96 the initial sqrt price of the pool as a Q64.96 + function initialize(uint160 sqrtPriceX96) external; + + /// @notice Adds liquidity for the given recipient/tickLower/tickUpper position + /// @dev The caller of this method receives a callback in the form of IListaV3MintCallback#listaV3MintCallback + /// in which they must pay any token0 or token1 owed for the liquidity. The amount of token0/token1 due depends + /// on tickLower, tickUpper, the amount of liquidity, and the current price. + /// @param recipient The address for which the liquidity will be created + /// @param tickLower The lower tick of the position in which to add liquidity + /// @param tickUpper The upper tick of the position in which to add liquidity + /// @param amount The amount of liquidity to mint + /// @param data Any data that should be passed through to the callback + /// @return amount0 The amount of token0 that was paid to mint the given amount of liquidity. Matches the value in the callback + /// @return amount1 The amount of token1 that was paid to mint the given amount of liquidity. Matches the value in the callback + function mint( + address recipient, + int24 tickLower, + int24 tickUpper, + uint128 amount, + bytes calldata data + ) external returns (uint256 amount0, uint256 amount1); + + /// @notice Collects tokens owed to a position + /// @dev Does not recompute fees earned, which must be done either via mint or burn of any amount of liquidity. + /// Collect must be called by the position owner. To withdraw only token0 or only token1, amount0Requested or + /// amount1Requested may be set to zero. To withdraw all tokens owed, caller may pass any value greater than the + /// actual tokens owed, e.g. type(uint128).max. Tokens owed may be from accumulated swap fees or burned liquidity. + /// @param recipient The address which should receive the fees collected + /// @param tickLower The lower tick of the position for which to collect fees + /// @param tickUpper The upper tick of the position for which to collect fees + /// @param amount0Requested How much token0 should be withdrawn from the fees owed + /// @param amount1Requested How much token1 should be withdrawn from the fees owed + /// @return amount0 The amount of fees collected in token0 + /// @return amount1 The amount of fees collected in token1 + function collect( + address recipient, + int24 tickLower, + int24 tickUpper, + uint128 amount0Requested, + uint128 amount1Requested + ) external returns (uint128 amount0, uint128 amount1); + + /// @notice Burn liquidity from the sender and account tokens owed for the liquidity to the position + /// @dev Can be used to trigger a recalculation of fees owed to a position by calling with an amount of 0 + /// @dev Fees must be collected separately via a call to #collect + /// @param tickLower The lower tick of the position for which to burn liquidity + /// @param tickUpper The upper tick of the position for which to burn liquidity + /// @param amount How much liquidity to burn + /// @return amount0 The amount of token0 sent to the recipient + /// @return amount1 The amount of token1 sent to the recipient + function burn(int24 tickLower, int24 tickUpper, uint128 amount) external returns (uint256 amount0, uint256 amount1); + + /// @notice Swap token0 for token1, or token1 for token0 + /// @dev The caller of this method receives a callback in the form of IListaV3SwapCallback#listaV3SwapCallback + /// @param recipient The address to receive the output of the swap + /// @param zeroForOne The direction of the swap, true for token0 to token1, false for token1 to token0 + /// @param amountSpecified The amount of the swap, which implicitly configures the swap as exact input (positive), or exact output (negative) + /// @param sqrtPriceLimitX96 The Q64.96 sqrt price limit. If zero for one, the price cannot be less than this + /// value after the swap. If one for zero, the price cannot be greater than this value after the swap + /// @param data Any data to be passed through to the callback + /// @return amount0 The delta of the balance of token0 of the pool, exact when negative, minimum when positive + /// @return amount1 The delta of the balance of token1 of the pool, exact when negative, minimum when positive + function swap( + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 sqrtPriceLimitX96, + bytes calldata data + ) external returns (int256 amount0, int256 amount1); + + /// @notice Receive token0 and/or token1 and pay it back, plus a fee, in the callback + /// @dev The caller of this method receives a callback in the form of IListaV3FlashCallback#listaV3FlashCallback + /// @dev Can be used to donate underlying tokens pro-rata to currently in-range liquidity providers by calling + /// with 0 amount{0,1} and sending the donation amount(s) from the callback + /// @param recipient The address which will receive the token0 and token1 amounts + /// @param amount0 The amount of token0 to send + /// @param amount1 The amount of token1 to send + /// @param data Any data to be passed through to the callback + function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external; + + /// @notice Increase the maximum number of price and liquidity observations that this pool will store + /// @dev This method is no-op if the pool already has an observationCardinalityNext greater than or equal to + /// the input observationCardinalityNext. + /// @param observationCardinalityNext The desired minimum number of observations for the pool to store + function increaseObservationCardinalityNext(uint16 observationCardinalityNext) external; +} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolDerivedState.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolDerivedState.sol new file mode 100644 index 00000000..3974cf15 --- /dev/null +++ b/src/dex/v3/core/interfaces/pool/IListaV3PoolDerivedState.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Pool state that is not stored +/// @notice Contains view functions to provide information about the pool that is computed rather than stored on the +/// blockchain. The functions here may have variable gas costs. +interface IListaV3PoolDerivedState { + /// @notice Returns the cumulative tick and liquidity as of each timestamp `secondsAgo` from the current block timestamp + /// @dev To get a time weighted average tick or liquidity-in-range, you must call this with two values, one representing + /// the beginning of the period and another for the end of the period. E.g., to get the last hour time-weighted average tick, + /// you must call it with secondsAgos = [3600, 0]. + /// @dev The time weighted average tick represents the geometric time weighted average price of the pool, in + /// log base sqrt(1.0001) of token1 / token0. The TickMath library can be used to go from a tick value to a ratio. + /// @param secondsAgos From how long ago each cumulative tick and liquidity value should be returned + /// @return tickCumulatives Cumulative tick values as of each `secondsAgos` from the current block timestamp + /// @return secondsPerLiquidityCumulativeX128s Cumulative seconds per liquidity-in-range value as of each `secondsAgos` from the current block + /// timestamp + function observe( + uint32[] calldata secondsAgos + ) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); + + /// @notice Returns a snapshot of the tick cumulative, seconds per liquidity and seconds inside a tick range + /// @dev Snapshots must only be compared to other snapshots, taken over a period for which a position existed. + /// I.e., snapshots cannot be compared if a position is not held for the entire period between when the first + /// snapshot is taken and the second snapshot is taken. + /// @param tickLower The lower tick of the range + /// @param tickUpper The upper tick of the range + /// @return tickCumulativeInside The snapshot of the tick accumulator for the range + /// @return secondsPerLiquidityInsideX128 The snapshot of seconds per liquidity for the range + /// @return secondsInside The snapshot of seconds per liquidity for the range + function snapshotCumulativesInside( + int24 tickLower, + int24 tickUpper + ) external view returns (int56 tickCumulativeInside, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside); +} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolErrors.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolErrors.sol new file mode 100644 index 00000000..0322df98 --- /dev/null +++ b/src/dex/v3/core/interfaces/pool/IListaV3PoolErrors.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Errors emitted by a pool +/// @notice Contains all events emitted by the pool +interface IListaV3PoolErrors { + error LOK(); + error TLU(); + error TLM(); + error TUM(); + error AI(); + error M0(); + error M1(); + error AS(); + error IIA(); + error L(); + error F0(); + error F1(); +} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolEvents.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolEvents.sol new file mode 100644 index 00000000..33b81ae8 --- /dev/null +++ b/src/dex/v3/core/interfaces/pool/IListaV3PoolEvents.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Events emitted by a pool +/// @notice Contains all events emitted by the pool +interface IListaV3PoolEvents { + /// @notice Emitted exactly once by a pool when #initialize is first called on the pool + /// @dev Mint/Burn/Swap cannot be emitted by the pool before Initialize + /// @param sqrtPriceX96 The initial sqrt price of the pool, as a Q64.96 + /// @param tick The initial tick of the pool, i.e. log base 1.0001 of the starting price of the pool + event Initialize(uint160 sqrtPriceX96, int24 tick); + + /// @notice Emitted when liquidity is minted for a given position + /// @param sender The address that minted the liquidity + /// @param owner The owner of the position and recipient of any minted liquidity + /// @param tickLower The lower tick of the position + /// @param tickUpper The upper tick of the position + /// @param amount The amount of liquidity minted to the position range + /// @param amount0 How much token0 was required for the minted liquidity + /// @param amount1 How much token1 was required for the minted liquidity + event Mint( + address sender, + address indexed owner, + int24 indexed tickLower, + int24 indexed tickUpper, + uint128 amount, + uint256 amount0, + uint256 amount1 + ); + + /// @notice Emitted when fees are collected by the owner of a position + /// @dev Collect events may be emitted with zero amount0 and amount1 when the caller chooses not to collect fees + /// @param owner The owner of the position for which fees are collected + /// @param tickLower The lower tick of the position + /// @param tickUpper The upper tick of the position + /// @param amount0 The amount of token0 fees collected + /// @param amount1 The amount of token1 fees collected + event Collect( + address indexed owner, + address recipient, + int24 indexed tickLower, + int24 indexed tickUpper, + uint128 amount0, + uint128 amount1 + ); + + /// @notice Emitted when a position's liquidity is removed + /// @dev Does not withdraw any fees earned by the liquidity position, which must be withdrawn via #collect + /// @param owner The owner of the position for which liquidity is removed + /// @param tickLower The lower tick of the position + /// @param tickUpper The upper tick of the position + /// @param amount The amount of liquidity to remove + /// @param amount0 The amount of token0 withdrawn + /// @param amount1 The amount of token1 withdrawn + event Burn( + address indexed owner, + int24 indexed tickLower, + int24 indexed tickUpper, + uint128 amount, + uint256 amount0, + uint256 amount1 + ); + + /// @notice Emitted by the pool for any swaps between token0 and token1 + /// @param sender The address that initiated the swap call, and that received the callback + /// @param recipient The address that received the output of the swap + /// @param amount0 The delta of the token0 balance of the pool + /// @param amount1 The delta of the token1 balance of the pool + /// @param sqrtPriceX96 The sqrt(price) of the pool after the swap, as a Q64.96 + /// @param liquidity The liquidity of the pool after the swap + /// @param tick The log base 1.0001 of price of the pool after the swap + event Swap( + address indexed sender, + address indexed recipient, + int256 amount0, + int256 amount1, + uint160 sqrtPriceX96, + uint128 liquidity, + int24 tick + ); + + /// @notice Emitted by the pool for any flashes of token0/token1 + /// @param sender The address that initiated the swap call, and that received the callback + /// @param recipient The address that received the tokens from flash + /// @param amount0 The amount of token0 that was flashed + /// @param amount1 The amount of token1 that was flashed + /// @param paid0 The amount of token0 paid for the flash, which can exceed the amount0 plus the fee + /// @param paid1 The amount of token1 paid for the flash, which can exceed the amount1 plus the fee + event Flash( + address indexed sender, + address indexed recipient, + uint256 amount0, + uint256 amount1, + uint256 paid0, + uint256 paid1 + ); + + /// @notice Emitted by the pool for increases to the number of observations that can be stored + /// @dev observationCardinalityNext is not the observation cardinality until an observation is written at the index + /// just before a mint/swap/burn. + /// @param observationCardinalityNextOld The previous value of the next observation cardinality + /// @param observationCardinalityNextNew The updated value of the next observation cardinality + event IncreaseObservationCardinalityNext(uint16 observationCardinalityNextOld, uint16 observationCardinalityNextNew); + + /// @notice Emitted when the protocol fee is changed by the pool + /// @param feeProtocol0Old The previous value of the token0 protocol fee + /// @param feeProtocol1Old The previous value of the token1 protocol fee + /// @param feeProtocol0New The updated value of the token0 protocol fee + /// @param feeProtocol1New The updated value of the token1 protocol fee + event SetFeeProtocol(uint8 feeProtocol0Old, uint8 feeProtocol1Old, uint8 feeProtocol0New, uint8 feeProtocol1New); + + /// @notice Emitted when the collected protocol fees are withdrawn by the factory owner + /// @param sender The address that collects the protocol fees + /// @param recipient The address that receives the collected protocol fees + /// @param amount0 The amount of token0 protocol fees that is withdrawn + /// @param amount0 The amount of token1 protocol fees that is withdrawn + event CollectProtocol(address indexed sender, address indexed recipient, uint128 amount0, uint128 amount1); +} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolImmutables.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolImmutables.sol new file mode 100644 index 00000000..e5fb14a4 --- /dev/null +++ b/src/dex/v3/core/interfaces/pool/IListaV3PoolImmutables.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Pool state that never changes +/// @notice These parameters are fixed for a pool forever, i.e., the methods will always return the same values +interface IListaV3PoolImmutables { + /// @notice The contract that deployed the pool, which must adhere to the IListaV3Factory interface + /// @return The contract address + function factory() external view returns (address); + + /// @notice The first of the two tokens of the pool, sorted by address + /// @return The token contract address + function token0() external view returns (address); + + /// @notice The second of the two tokens of the pool, sorted by address + /// @return The token contract address + function token1() external view returns (address); + + /// @notice The pool's fee in hundredths of a bip, i.e. 1e-6 + /// @return The fee + function fee() external view returns (uint24); + + /// @notice The pool tick spacing + /// @dev Ticks can only be used at multiples of this value, minimum of 1 and always positive + /// e.g.: a tickSpacing of 3 means ticks can be initialized every 3rd tick, i.e., ..., -6, -3, 0, 3, 6, ... + /// This value is an int24 to avoid casting even though it is always positive. + /// @return The tick spacing + function tickSpacing() external view returns (int24); + + /// @notice The maximum amount of position liquidity that can use any tick in the range + /// @dev This parameter is enforced per tick to prevent liquidity from overflowing a uint128 at any point, and + /// also prevents out-of-range liquidity from being used to prevent adding in-range liquidity to a pool + /// @return The max amount of liquidity per tick + function maxLiquidityPerTick() external view returns (uint128); +} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolOwnerActions.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolOwnerActions.sol new file mode 100644 index 00000000..35f3b575 --- /dev/null +++ b/src/dex/v3/core/interfaces/pool/IListaV3PoolOwnerActions.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Permissioned pool actions +/// @notice Contains pool methods that may only be called by the factory owner +interface IListaV3PoolOwnerActions { + /// @notice Set the denominator of the protocol's % share of the fees + /// @param feeProtocol0 new protocol fee for token0 of the pool + /// @param feeProtocol1 new protocol fee for token1 of the pool + function setFeeProtocol(uint8 feeProtocol0, uint8 feeProtocol1) external; + + /// @notice Collect the protocol fee accrued to the pool + /// @param recipient The address to which collected protocol fees should be sent + /// @param amount0Requested The maximum amount of token0 to send, can be 0 to collect fees in only token1 + /// @param amount1Requested The maximum amount of token1 to send, can be 0 to collect fees in only token0 + /// @return amount0 The protocol fee collected in token0 + /// @return amount1 The protocol fee collected in token1 + function collectProtocol( + address recipient, + uint128 amount0Requested, + uint128 amount1Requested + ) external returns (uint128 amount0, uint128 amount1); +} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolState.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolState.sol new file mode 100644 index 00000000..979b96d1 --- /dev/null +++ b/src/dex/v3/core/interfaces/pool/IListaV3PoolState.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Pool state that can change +/// @notice These methods compose the pool's state, and can change with any frequency including multiple times +/// per transaction +interface IListaV3PoolState { + /// @notice The 0th storage slot in the pool stores many values, and is exposed as a single method to save gas + /// when accessed externally. + /// @return sqrtPriceX96 The current price of the pool as a sqrt(token1/token0) Q64.96 value + /// @return tick The current tick of the pool, i.e. according to the last tick transition that was run. + /// This value may not always be equal to SqrtTickMath.getTickAtSqrtRatio(sqrtPriceX96) if the price is on a tick + /// boundary. + /// @return observationIndex The index of the last oracle observation that was written, + /// @return observationCardinality The current maximum number of observations stored in the pool, + /// @return observationCardinalityNext The next maximum number of observations, to be updated when the observation. + /// @return feeProtocol The protocol fee for both tokens of the pool. + /// Encoded as two 4 bit values, where the protocol fee of token1 is shifted 4 bits and the protocol fee of token0 + /// is the lower 4 bits. Used as the denominator of a fraction of the swap fee, e.g. 4 means 1/4th of the swap fee. + /// unlocked Whether the pool is currently locked to reentrancy + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint8 feeProtocol, + bool unlocked + ); + + /// @notice The fee growth as a Q128.128 fees of token0 collected per unit of liquidity for the entire life of the pool + /// @dev This value can overflow the uint256 + function feeGrowthGlobal0X128() external view returns (uint256); + + /// @notice The fee growth as a Q128.128 fees of token1 collected per unit of liquidity for the entire life of the pool + /// @dev This value can overflow the uint256 + function feeGrowthGlobal1X128() external view returns (uint256); + + /// @notice The amounts of token0 and token1 that are owed to the protocol + /// @dev Protocol fees will never exceed uint128 max in either token + function protocolFees() external view returns (uint128 token0, uint128 token1); + + /// @notice The currently in range liquidity available to the pool + /// @dev This value has no relationship to the total liquidity across all ticks + /// @return The liquidity at the current price of the pool + function liquidity() external view returns (uint128); + + /// @notice Look up information about a specific tick in the pool + /// @param tick The tick to look up + /// @return liquidityGross the total amount of position liquidity that uses the pool either as tick lower or + /// tick upper + /// @return liquidityNet how much liquidity changes when the pool price crosses the tick, + /// @return feeGrowthOutside0X128 the fee growth on the other side of the tick from the current tick in token0, + /// @return feeGrowthOutside1X128 the fee growth on the other side of the tick from the current tick in token1, + /// @return tickCumulativeOutside the cumulative tick value on the other side of the tick from the current tick + /// @return secondsPerLiquidityOutsideX128 the seconds spent per liquidity on the other side of the tick from the current tick, + /// @return secondsOutside the seconds spent on the other side of the tick from the current tick, + /// @return initialized Set to true if the tick is initialized, i.e. liquidityGross is greater than 0, otherwise equal to false. + /// Outside values can only be used if the tick is initialized, i.e. if liquidityGross is greater than 0. + /// In addition, these values are only relative and must be used only in comparison to previous snapshots for + /// a specific position. + function ticks( + int24 tick + ) + external + view + returns ( + uint128 liquidityGross, + int128 liquidityNet, + uint256 feeGrowthOutside0X128, + uint256 feeGrowthOutside1X128, + int56 tickCumulativeOutside, + uint160 secondsPerLiquidityOutsideX128, + uint32 secondsOutside, + bool initialized + ); + + /// @notice Returns 256 packed tick initialized boolean values. See TickBitmap for more information + function tickBitmap(int16 wordPosition) external view returns (uint256); + + /// @notice Returns the information about a position by the position's key + /// @param key The position's key is a hash of a preimage composed by the owner, tickLower and tickUpper + /// @return liquidity The amount of liquidity in the position, + /// @return feeGrowthInside0LastX128 fee growth of token0 inside the tick range as of the last mint/burn/poke, + /// @return feeGrowthInside1LastX128 fee growth of token1 inside the tick range as of the last mint/burn/poke, + /// @return tokensOwed0 the computed amount of token0 owed to the position as of the last mint/burn/poke, + /// @return tokensOwed1 the computed amount of token1 owed to the position as of the last mint/burn/poke + function positions( + bytes32 key + ) + external + view + returns ( + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1 + ); + + /// @notice Returns data about a specific observation index + /// @param index The element of the observations array to fetch + /// @dev You most likely want to use #observe() instead of this method to get an observation as of some amount of time + /// ago, rather than at a specific index in the array. + /// @return blockTimestamp The timestamp of the observation, + /// @return tickCumulative the tick multiplied by seconds elapsed for the life of the pool as of the observation timestamp, + /// @return secondsPerLiquidityCumulativeX128 the seconds per in range liquidity for the life of the pool as of the observation timestamp, + /// @return initialized whether the observation has been initialized and the values are safe to use + function observations( + uint256 index + ) + external + view + returns (uint32 blockTimestamp, int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128, bool initialized); +} diff --git a/src/dex/v3/core/libraries/BitMath.sol b/src/dex/v3/core/libraries/BitMath.sol new file mode 100644 index 00000000..63910775 --- /dev/null +++ b/src/dex/v3/core/libraries/BitMath.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +/// @title BitMath +/// @dev This library provides functionality for computing bit properties of an unsigned integer +library BitMath { + /// @notice Returns the index of the most significant bit of the number, + /// where the least significant bit is at index 0 and the most significant bit is at index 255 + /// @dev The function satisfies the property: + /// x >= 2**mostSignificantBit(x) and x < 2**(mostSignificantBit(x)+1) + /// @param x the value for which to compute the most significant bit, must be greater than 0 + /// @return r the index of the most significant bit + function mostSignificantBit(uint256 x) internal pure returns (uint8 r) { + require(x > 0); + + unchecked { + if (x >= 0x100000000000000000000000000000000) { + x >>= 128; + r += 128; + } + if (x >= 0x10000000000000000) { + x >>= 64; + r += 64; + } + if (x >= 0x100000000) { + x >>= 32; + r += 32; + } + if (x >= 0x10000) { + x >>= 16; + r += 16; + } + if (x >= 0x100) { + x >>= 8; + r += 8; + } + if (x >= 0x10) { + x >>= 4; + r += 4; + } + if (x >= 0x4) { + x >>= 2; + r += 2; + } + if (x >= 0x2) r += 1; + } + } + + /// @notice Returns the index of the least significant bit of the number, + /// where the least significant bit is at index 0 and the most significant bit is at index 255 + /// @dev The function satisfies the property: + /// (x & 2**leastSignificantBit(x)) != 0 and (x & (2**(leastSignificantBit(x)) - 1)) == 0) + /// @param x the value for which to compute the least significant bit, must be greater than 0 + /// @return r the index of the least significant bit + function leastSignificantBit(uint256 x) internal pure returns (uint8 r) { + require(x > 0); + + unchecked { + r = 255; + if (x & type(uint128).max > 0) { + r -= 128; + } else { + x >>= 128; + } + if (x & type(uint64).max > 0) { + r -= 64; + } else { + x >>= 64; + } + if (x & type(uint32).max > 0) { + r -= 32; + } else { + x >>= 32; + } + if (x & type(uint16).max > 0) { + r -= 16; + } else { + x >>= 16; + } + if (x & type(uint8).max > 0) { + r -= 8; + } else { + x >>= 8; + } + if (x & 0xf > 0) { + r -= 4; + } else { + x >>= 4; + } + if (x & 0x3 > 0) { + r -= 2; + } else { + x >>= 2; + } + if (x & 0x1 > 0) r -= 1; + } + } +} diff --git a/src/dex/v3/core/libraries/FixedPoint128.sol b/src/dex/v3/core/libraries/FixedPoint128.sol new file mode 100644 index 00000000..10e265cc --- /dev/null +++ b/src/dex/v3/core/libraries/FixedPoint128.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.4.0; + +/// @title FixedPoint128 +/// @notice A library for handling binary fixed point numbers, see https://en.wikipedia.org/wiki/Q_(number_format) +library FixedPoint128 { + uint256 internal constant Q128 = 0x100000000000000000000000000000000; +} diff --git a/src/dex/v3/core/libraries/FixedPoint96.sol b/src/dex/v3/core/libraries/FixedPoint96.sol new file mode 100644 index 00000000..ba308d71 --- /dev/null +++ b/src/dex/v3/core/libraries/FixedPoint96.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.4.0; + +/// @title FixedPoint96 +/// @notice A library for handling binary fixed point numbers, see https://en.wikipedia.org/wiki/Q_(number_format) +/// @dev Used in SqrtPriceMath.sol +library FixedPoint96 { + uint8 internal constant RESOLUTION = 96; + uint256 internal constant Q96 = 0x1000000000000000000000000; +} diff --git a/src/dex/v3/core/libraries/FullMath.sol b/src/dex/v3/core/libraries/FullMath.sol new file mode 100644 index 00000000..c8660692 --- /dev/null +++ b/src/dex/v3/core/libraries/FullMath.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +/// @title Contains 512-bit math functions +/// @notice Facilitates multiplication and division that can have overflow of an intermediate value without any loss of precision +/// @dev Handles "phantom overflow" i.e., allows multiplication and division where an intermediate value overflows 256 bits +library FullMath { + /// @notice Calculates floor(a×b÷denominator) with full precision. Throws if result overflows a uint256 or denominator == 0 + /// @param a The multiplicand + /// @param b The multiplier + /// @param denominator The divisor + /// @return result The 256-bit result + /// @dev Credit to Remco Bloemen under MIT license https://xn--2-umb.com/21/muldiv + function mulDiv(uint256 a, uint256 b, uint256 denominator) internal pure returns (uint256 result) { + unchecked { + // 512-bit multiply [prod1 prod0] = a * b + // Compute the product mod 2**256 and mod 2**256 - 1 + // then use the Chinese Remainder Theorem to reconstruct + // the 512 bit result. The result is stored in two 256 + // variables such that product = prod1 * 2**256 + prod0 + uint256 prod0; // Least significant 256 bits of the product + uint256 prod1; // Most significant 256 bits of the product + assembly { + let mm := mulmod(a, b, not(0)) + prod0 := mul(a, b) + prod1 := sub(sub(mm, prod0), lt(mm, prod0)) + } + + // Handle non-overflow cases, 256 by 256 division + if (prod1 == 0) { + require(denominator > 0); + assembly { + result := div(prod0, denominator) + } + return result; + } + + // Make sure the result is less than 2**256. + // Also prevents denominator == 0 + require(denominator > prod1); + + /////////////////////////////////////////////// + // 512 by 256 division. + /////////////////////////////////////////////// + + // Make division exact by subtracting the remainder from [prod1 prod0] + // Compute remainder using mulmod + uint256 remainder; + assembly { + remainder := mulmod(a, b, denominator) + } + // Subtract 256 bit number from 512 bit number + assembly { + prod1 := sub(prod1, gt(remainder, prod0)) + prod0 := sub(prod0, remainder) + } + + // Factor powers of two out of denominator + // Compute largest power of two divisor of denominator. + // Always >= 1. + uint256 twos = (0 - denominator) & denominator; + // Divide denominator by power of two + assembly { + denominator := div(denominator, twos) + } + + // Divide [prod1 prod0] by the factors of two + assembly { + prod0 := div(prod0, twos) + } + // Shift in bits from prod1 into prod0. For this we need + // to flip `twos` such that it is 2**256 / twos. + // If twos is zero, then it becomes one + assembly { + twos := add(div(sub(0, twos), twos), 1) + } + prod0 |= prod1 * twos; + + // Invert denominator mod 2**256 + // Now that denominator is an odd number, it has an inverse + // modulo 2**256 such that denominator * inv = 1 mod 2**256. + // Compute the inverse by starting with a seed that is correct + // correct for four bits. That is, denominator * inv = 1 mod 2**4 + uint256 inv = (3 * denominator) ^ 2; + // Now use Newton-Raphson iteration to improve the precision. + // Thanks to Hensel's lifting lemma, this also works in modular + // arithmetic, doubling the correct bits in each step. + inv *= 2 - denominator * inv; // inverse mod 2**8 + inv *= 2 - denominator * inv; // inverse mod 2**16 + inv *= 2 - denominator * inv; // inverse mod 2**32 + inv *= 2 - denominator * inv; // inverse mod 2**64 + inv *= 2 - denominator * inv; // inverse mod 2**128 + inv *= 2 - denominator * inv; // inverse mod 2**256 + + // Because the division is now exact we can divide by multiplying + // with the modular inverse of denominator. This will give us the + // correct result modulo 2**256. Since the precoditions guarantee + // that the outcome is less than 2**256, this is the final result. + // We don't need to compute the high bits of the result and prod1 + // is no longer required. + result = prod0 * inv; + return result; + } + } + + /// @notice Calculates ceil(a×b÷denominator) with full precision. Throws if result overflows a uint256 or denominator == 0 + /// @param a The multiplicand + /// @param b The multiplier + /// @param denominator The divisor + /// @return result The 256-bit result + function mulDivRoundingUp(uint256 a, uint256 b, uint256 denominator) internal pure returns (uint256 result) { + unchecked { + result = mulDiv(a, b, denominator); + if (mulmod(a, b, denominator) > 0) { + require(result < type(uint256).max); + result++; + } + } + } +} diff --git a/src/dex/v3/core/libraries/LICENSE b/src/dex/v3/core/libraries/LICENSE new file mode 100644 index 00000000..7f6aca78 --- /dev/null +++ b/src/dex/v3/core/libraries/LICENSE @@ -0,0 +1,445 @@ +This software is available under your choice of the GNU General Public +License, version 2 or later, or the Business Source License, as set +forth below. + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. + + +Business Source License 1.1 + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Parameters + +Licensor: Uniswap Labs + +Licensed Work: Uniswap V3 Core + The Licensed Work is (c) 2021 Uniswap Labs + +Additional Use Grant: Any uses listed and defined at + v3-core-license-grants.uniswap.eth + +Change Date: The earlier of 2023-04-01 or a date specified at + v3-core-license-date.uniswap.eth + +Change License: GNU General Public License v2.0 or later + +----------------------------------------------------------------------------- + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark "Business Source License", +as long as you comply with the Covenants of Licensor below. + +----------------------------------------------------------------------------- + +Covenants of Licensor + +In consideration of the right to use this License’s text and the "Business +Source License" name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where "compatible" means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text "None". + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Notice + +The Business Source License (this document, or the "License") is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. diff --git a/src/dex/v3/core/libraries/LICENSE_MIT b/src/dex/v3/core/libraries/LICENSE_MIT new file mode 100644 index 00000000..bf4f90a2 --- /dev/null +++ b/src/dex/v3/core/libraries/LICENSE_MIT @@ -0,0 +1,20 @@ +Copyright (c) 2021 Remco Bloemen + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/src/dex/v3/core/libraries/Oracle.sol b/src/dex/v3/core/libraries/Oracle.sol new file mode 100644 index 00000000..7b833b93 --- /dev/null +++ b/src/dex/v3/core/libraries/Oracle.sol @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +/// @title Oracle +/// @notice Provides price and liquidity data useful for a wide variety of system designs +/// @dev Instances of stored oracle data, "observations", are collected in the oracle array +/// Every pool is initialized with an oracle array length of 1. Anyone can pay the SSTOREs to increase the +/// maximum length of the oracle array. New slots will be added when the array is fully populated. +/// Observations are overwritten when the full length of the oracle array is populated. +/// The most recent observation is available, independent of the length of the oracle array, by passing 0 to observe() +library Oracle { + error I(); + error OLD(); + + struct Observation { + // the block timestamp of the observation + uint32 blockTimestamp; + // the tick accumulator, i.e. tick * time elapsed since the pool was first initialized + int56 tickCumulative; + // the seconds per liquidity, i.e. seconds elapsed / max(1, liquidity) since the pool was first initialized + uint160 secondsPerLiquidityCumulativeX128; + // whether or not the observation is initialized + bool initialized; + } + + /// @notice Transforms a previous observation into a new observation, given the passage of time and the current tick and liquidity values + /// @dev blockTimestamp _must_ be chronologically equal to or greater than last.blockTimestamp, safe for 0 or 1 overflows + /// @param last The specified observation to be transformed + /// @param blockTimestamp The timestamp of the new observation + /// @param tick The active tick at the time of the new observation + /// @param liquidity The total in-range liquidity at the time of the new observation + /// @return Observation The newly populated observation + function transform( + Observation memory last, + uint32 blockTimestamp, + int24 tick, + uint128 liquidity + ) private pure returns (Observation memory) { + unchecked { + uint32 delta = blockTimestamp - last.blockTimestamp; + return + Observation({ + blockTimestamp: blockTimestamp, + tickCumulative: last.tickCumulative + int56(tick) * int56(uint56(delta)), + secondsPerLiquidityCumulativeX128: last.secondsPerLiquidityCumulativeX128 + + ((uint160(delta) << 128) / (liquidity > 0 ? liquidity : 1)), + initialized: true + }); + } + } + + /// @notice Initialize the oracle array by writing the first slot. Called once for the lifecycle of the observations array + /// @param self The stored oracle array + /// @param time The time of the oracle initialization, via block.timestamp truncated to uint32 + /// @return cardinality The number of populated elements in the oracle array + /// @return cardinalityNext The new length of the oracle array, independent of population + function initialize( + Observation[65535] storage self, + uint32 time + ) internal returns (uint16 cardinality, uint16 cardinalityNext) { + self[0] = Observation({ + blockTimestamp: time, + tickCumulative: 0, + secondsPerLiquidityCumulativeX128: 0, + initialized: true + }); + return (1, 1); + } + + /// @notice Writes an oracle observation to the array + /// @dev Writable at most once per block. Index represents the most recently written element. cardinality and index must be tracked externally. + /// If the index is at the end of the allowable array length (according to cardinality), and the next cardinality + /// is greater than the current one, cardinality may be increased. This restriction is created to preserve ordering. + /// @param self The stored oracle array + /// @param index The index of the observation that was most recently written to the observations array + /// @param blockTimestamp The timestamp of the new observation + /// @param tick The active tick at the time of the new observation + /// @param liquidity The total in-range liquidity at the time of the new observation + /// @param cardinality The number of populated elements in the oracle array + /// @param cardinalityNext The new length of the oracle array, independent of population + /// @return indexUpdated The new index of the most recently written element in the oracle array + /// @return cardinalityUpdated The new cardinality of the oracle array + function write( + Observation[65535] storage self, + uint16 index, + uint32 blockTimestamp, + int24 tick, + uint128 liquidity, + uint16 cardinality, + uint16 cardinalityNext + ) internal returns (uint16 indexUpdated, uint16 cardinalityUpdated) { + unchecked { + Observation memory last = self[index]; + + // early return if we've already written an observation this block + if (last.blockTimestamp == blockTimestamp) return (index, cardinality); + + // if the conditions are right, we can bump the cardinality + if (cardinalityNext > cardinality && index == (cardinality - 1)) { + cardinalityUpdated = cardinalityNext; + } else { + cardinalityUpdated = cardinality; + } + + indexUpdated = (index + 1) % cardinalityUpdated; + self[indexUpdated] = transform(last, blockTimestamp, tick, liquidity); + } + } + + /// @notice Prepares the oracle array to store up to `next` observations + /// @param self The stored oracle array + /// @param current The current next cardinality of the oracle array + /// @param next The proposed next cardinality which will be populated in the oracle array + /// @return next The next cardinality which will be populated in the oracle array + function grow(Observation[65535] storage self, uint16 current, uint16 next) internal returns (uint16) { + unchecked { + if (current <= 0) revert I(); + // no-op if the passed next value isn't greater than the current next value + if (next <= current) return current; + // store in each slot to prevent fresh SSTOREs in swaps + // this data will not be used because the initialized boolean is still false + for (uint16 i = current; i < next; i++) self[i].blockTimestamp = 1; + return next; + } + } + + /// @notice comparator for 32-bit timestamps + /// @dev safe for 0 or 1 overflows, a and b _must_ be chronologically before or equal to time + /// @param time A timestamp truncated to 32 bits + /// @param a A comparison timestamp from which to determine the relative position of `time` + /// @param b From which to determine the relative position of `time` + /// @return Whether `a` is chronologically <= `b` + function lte(uint32 time, uint32 a, uint32 b) private pure returns (bool) { + unchecked { + // if there hasn't been overflow, no need to adjust + if (a <= time && b <= time) return a <= b; + + uint256 aAdjusted = a > time ? a : a + 2 ** 32; + uint256 bAdjusted = b > time ? b : b + 2 ** 32; + + return aAdjusted <= bAdjusted; + } + } + + /// @notice Fetches the observations beforeOrAt and atOrAfter a target, i.e. where [beforeOrAt, atOrAfter] is satisfied. + /// The result may be the same observation, or adjacent observations. + /// @dev The answer must be contained in the array, used when the target is located within the stored observation + /// boundaries: older than the most recent observation and younger, or the same age as, the oldest observation + /// @param self The stored oracle array + /// @param time The current block.timestamp + /// @param target The timestamp at which the reserved observation should be for + /// @param index The index of the observation that was most recently written to the observations array + /// @param cardinality The number of populated elements in the oracle array + /// @return beforeOrAt The observation recorded before, or at, the target + /// @return atOrAfter The observation recorded at, or after, the target + function binarySearch( + Observation[65535] storage self, + uint32 time, + uint32 target, + uint16 index, + uint16 cardinality + ) private view returns (Observation memory beforeOrAt, Observation memory atOrAfter) { + unchecked { + uint256 l = (index + 1) % cardinality; // oldest observation + uint256 r = l + cardinality - 1; // newest observation + uint256 i; + while (true) { + i = (l + r) / 2; + + beforeOrAt = self[i % cardinality]; + + // we've landed on an uninitialized tick, keep searching higher (more recently) + if (!beforeOrAt.initialized) { + l = i + 1; + continue; + } + + atOrAfter = self[(i + 1) % cardinality]; + + bool targetAtOrAfter = lte(time, beforeOrAt.blockTimestamp, target); + + // check if we've found the answer! + if (targetAtOrAfter && lte(time, target, atOrAfter.blockTimestamp)) break; + + if (!targetAtOrAfter) r = i - 1; + else l = i + 1; + } + } + } + + /// @notice Fetches the observations beforeOrAt and atOrAfter a given target, i.e. where [beforeOrAt, atOrAfter] is satisfied + /// @dev Assumes there is at least 1 initialized observation. + /// Used by observeSingle() to compute the counterfactual accumulator values as of a given block timestamp. + /// @param self The stored oracle array + /// @param time The current block.timestamp + /// @param target The timestamp at which the reserved observation should be for + /// @param tick The active tick at the time of the returned or simulated observation + /// @param index The index of the observation that was most recently written to the observations array + /// @param liquidity The total pool liquidity at the time of the call + /// @param cardinality The number of populated elements in the oracle array + /// @return beforeOrAt The observation which occurred at, or before, the given timestamp + /// @return atOrAfter The observation which occurred at, or after, the given timestamp + function getSurroundingObservations( + Observation[65535] storage self, + uint32 time, + uint32 target, + int24 tick, + uint16 index, + uint128 liquidity, + uint16 cardinality + ) private view returns (Observation memory beforeOrAt, Observation memory atOrAfter) { + unchecked { + // optimistically set before to the newest observation + beforeOrAt = self[index]; + + // if the target is chronologically at or after the newest observation, we can early return + if (lte(time, beforeOrAt.blockTimestamp, target)) { + if (beforeOrAt.blockTimestamp == target) { + // if newest observation equals target, we're in the same block, so we can ignore atOrAfter + return (beforeOrAt, atOrAfter); + } else { + // otherwise, we need to transform + return (beforeOrAt, transform(beforeOrAt, target, tick, liquidity)); + } + } + + // now, set before to the oldest observation + beforeOrAt = self[(index + 1) % cardinality]; + if (!beforeOrAt.initialized) beforeOrAt = self[0]; + + // ensure that the target is chronologically at or after the oldest observation + if (!lte(time, beforeOrAt.blockTimestamp, target)) revert OLD(); + + // if we've reached this point, we have to binary search + return binarySearch(self, time, target, index, cardinality); + } + } + + /// @dev Reverts if an observation at or before the desired observation timestamp does not exist. + /// 0 may be passed as `secondsAgo' to return the current cumulative values. + /// If called with a timestamp falling between two observations, returns the counterfactual accumulator values + /// at exactly the timestamp between the two observations. + /// @param self The stored oracle array + /// @param time The current block timestamp + /// @param secondsAgo The amount of time to look back, in seconds, at which point to return an observation + /// @param tick The current tick + /// @param index The index of the observation that was most recently written to the observations array + /// @param liquidity The current in-range pool liquidity + /// @param cardinality The number of populated elements in the oracle array + /// @return tickCumulative The tick * time elapsed since the pool was first initialized, as of `secondsAgo` + /// @return secondsPerLiquidityCumulativeX128 The time elapsed / max(1, liquidity) since the pool was first initialized, as of `secondsAgo` + function observeSingle( + Observation[65535] storage self, + uint32 time, + uint32 secondsAgo, + int24 tick, + uint16 index, + uint128 liquidity, + uint16 cardinality + ) internal view returns (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) { + unchecked { + if (secondsAgo == 0) { + Observation memory last = self[index]; + if (last.blockTimestamp != time) last = transform(last, time, tick, liquidity); + return (last.tickCumulative, last.secondsPerLiquidityCumulativeX128); + } + + uint32 target = time - secondsAgo; + + (Observation memory beforeOrAt, Observation memory atOrAfter) = getSurroundingObservations( + self, + time, + target, + tick, + index, + liquidity, + cardinality + ); + + if (target == beforeOrAt.blockTimestamp) { + // we're at the left boundary + return (beforeOrAt.tickCumulative, beforeOrAt.secondsPerLiquidityCumulativeX128); + } else if (target == atOrAfter.blockTimestamp) { + // we're at the right boundary + return (atOrAfter.tickCumulative, atOrAfter.secondsPerLiquidityCumulativeX128); + } else { + // we're in the middle + uint32 observationTimeDelta = atOrAfter.blockTimestamp - beforeOrAt.blockTimestamp; + uint32 targetDelta = target - beforeOrAt.blockTimestamp; + return ( + beforeOrAt.tickCumulative + + ((atOrAfter.tickCumulative - beforeOrAt.tickCumulative) / int56(uint56(observationTimeDelta))) * + int56(uint56(targetDelta)), + beforeOrAt.secondsPerLiquidityCumulativeX128 + + uint160( + (uint256(atOrAfter.secondsPerLiquidityCumulativeX128 - beforeOrAt.secondsPerLiquidityCumulativeX128) * + targetDelta) / observationTimeDelta + ) + ); + } + } + } + + /// @notice Returns the accumulator values as of each time seconds ago from the given time in the array of `secondsAgos` + /// @dev Reverts if `secondsAgos` > oldest observation + /// @param self The stored oracle array + /// @param time The current block.timestamp + /// @param secondsAgos Each amount of time to look back, in seconds, at which point to return an observation + /// @param tick The current tick + /// @param index The index of the observation that was most recently written to the observations array + /// @param liquidity The current in-range pool liquidity + /// @param cardinality The number of populated elements in the oracle array + /// @return tickCumulatives The tick * time elapsed since the pool was first initialized, as of each `secondsAgo` + /// @return secondsPerLiquidityCumulativeX128s The cumulative seconds / max(1, liquidity) since the pool was first initialized, as of each `secondsAgo` + function observe( + Observation[65535] storage self, + uint32 time, + uint32[] memory secondsAgos, + int24 tick, + uint16 index, + uint128 liquidity, + uint16 cardinality + ) internal view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) { + unchecked { + if (cardinality <= 0) revert I(); + + tickCumulatives = new int56[](secondsAgos.length); + secondsPerLiquidityCumulativeX128s = new uint160[](secondsAgos.length); + for (uint256 i = 0; i < secondsAgos.length; i++) { + (tickCumulatives[i], secondsPerLiquidityCumulativeX128s[i]) = observeSingle( + self, + time, + secondsAgos[i], + tick, + index, + liquidity, + cardinality + ); + } + } + } +} diff --git a/src/dex/v3/core/libraries/Position.sol b/src/dex/v3/core/libraries/Position.sol new file mode 100644 index 00000000..ca42a8ae --- /dev/null +++ b/src/dex/v3/core/libraries/Position.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +import { FullMath } from "./FullMath.sol"; +import { FixedPoint128 } from "./FixedPoint128.sol"; + +/// @title Position +/// @notice Positions represent an owner address' liquidity between a lower and upper tick boundary +/// @dev Positions store additional state for tracking fees owed to the position +library Position { + error NP(); + + // info stored for each user's position + struct Info { + // the amount of liquidity owned by this position + uint128 liquidity; + // fee growth per unit of liquidity as of the last update to liquidity or fees owed + uint256 feeGrowthInside0LastX128; + uint256 feeGrowthInside1LastX128; + // the fees owed to the position owner in token0/token1 + uint128 tokensOwed0; + uint128 tokensOwed1; + } + + /// @notice Returns the Info struct of a position, given an owner and position boundaries + /// @param self The mapping containing all user positions + /// @param owner The address of the position owner + /// @param tickLower The lower tick boundary of the position + /// @param tickUpper The upper tick boundary of the position + /// @return position The position info struct of the given owners' position + function get( + mapping(bytes32 => Info) storage self, + address owner, + int24 tickLower, + int24 tickUpper + ) internal view returns (Position.Info storage position) { + position = self[keccak256(abi.encodePacked(owner, tickLower, tickUpper))]; + } + + /// @notice Credits accumulated fees to a user's position + /// @param self The individual position to update + /// @param liquidityDelta The change in pool liquidity as a result of the position update + /// @param feeGrowthInside0X128 The all-time fee growth in token0, per unit of liquidity, inside the position's tick boundaries + /// @param feeGrowthInside1X128 The all-time fee growth in token1, per unit of liquidity, inside the position's tick boundaries + function update( + Info storage self, + int128 liquidityDelta, + uint256 feeGrowthInside0X128, + uint256 feeGrowthInside1X128 + ) internal { + Info memory _self = self; + + uint128 liquidityNext; + if (liquidityDelta == 0) { + if (_self.liquidity <= 0) revert NP(); // disallow pokes for 0 liquidity positions + liquidityNext = _self.liquidity; + } else { + liquidityNext = liquidityDelta < 0 + ? _self.liquidity - uint128(-liquidityDelta) + : _self.liquidity + uint128(liquidityDelta); + } + + // calculate accumulated fees. overflow in the subtraction of fee growth is expected + uint128 tokensOwed0; + uint128 tokensOwed1; + unchecked { + tokensOwed0 = uint128( + FullMath.mulDiv(feeGrowthInside0X128 - _self.feeGrowthInside0LastX128, _self.liquidity, FixedPoint128.Q128) + ); + tokensOwed1 = uint128( + FullMath.mulDiv(feeGrowthInside1X128 - _self.feeGrowthInside1LastX128, _self.liquidity, FixedPoint128.Q128) + ); + + // update the position + if (liquidityDelta != 0) self.liquidity = liquidityNext; + self.feeGrowthInside0LastX128 = feeGrowthInside0X128; + self.feeGrowthInside1LastX128 = feeGrowthInside1X128; + if (tokensOwed0 > 0 || tokensOwed1 > 0) { + // overflow is acceptable, user must withdraw before they hit type(uint128).max fees + self.tokensOwed0 += tokensOwed0; + self.tokensOwed1 += tokensOwed1; + } + } + } +} diff --git a/src/dex/v3/core/libraries/SafeCast.sol b/src/dex/v3/core/libraries/SafeCast.sol new file mode 100644 index 00000000..51dc29cb --- /dev/null +++ b/src/dex/v3/core/libraries/SafeCast.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Safe casting methods +/// @notice Contains methods for safely casting between types +library SafeCast { + /// @notice Cast a uint256 to a uint160, revert on overflow + /// @param y The uint256 to be downcasted + /// @return z The downcasted integer, now type uint160 + function toUint160(uint256 y) internal pure returns (uint160 z) { + require((z = uint160(y)) == y); + } + + /// @notice Cast a int256 to a int128, revert on overflow or underflow + /// @param y The int256 to be downcasted + /// @return z The downcasted integer, now type int128 + function toInt128(int256 y) internal pure returns (int128 z) { + require((z = int128(y)) == y); + } + + /// @notice Cast a uint256 to a int256, revert on overflow + /// @param y The uint256 to be casted + /// @return z The casted integer, now type int256 + function toInt256(uint256 y) internal pure returns (int256 z) { + require(y < 2 ** 255); + z = int256(y); + } +} diff --git a/src/dex/v3/core/libraries/SqrtPriceMath.sol b/src/dex/v3/core/libraries/SqrtPriceMath.sol new file mode 100644 index 00000000..241bf505 --- /dev/null +++ b/src/dex/v3/core/libraries/SqrtPriceMath.sol @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +import { SafeCast } from "./SafeCast.sol"; + +import { FullMath } from "./FullMath.sol"; +import { UnsafeMath } from "./UnsafeMath.sol"; +import { FixedPoint96 } from "./FixedPoint96.sol"; + +/// @title Functions based on Q64.96 sqrt price and liquidity +/// @notice Contains the math that uses square root of price as a Q64.96 and liquidity to compute deltas +library SqrtPriceMath { + using SafeCast for uint256; + + /// @notice Gets the next sqrt price given a delta of token0 + /// @dev Always rounds up, because in the exact output case (increasing price) we need to move the price at least + /// far enough to get the desired output amount, and in the exact input case (decreasing price) we need to move the + /// price less in order to not send too much output. + /// The most precise formula for this is liquidity * sqrtPX96 / (liquidity +- amount * sqrtPX96), + /// if this is impossible because of overflow, we calculate liquidity / (liquidity / sqrtPX96 +- amount). + /// @param sqrtPX96 The starting price, i.e. before accounting for the token0 delta + /// @param liquidity The amount of usable liquidity + /// @param amount How much of token0 to add or remove from virtual reserves + /// @param add Whether to add or remove the amount of token0 + /// @return The price after adding or removing amount, depending on add + function getNextSqrtPriceFromAmount0RoundingUp( + uint160 sqrtPX96, + uint128 liquidity, + uint256 amount, + bool add + ) internal pure returns (uint160) { + // we short circuit amount == 0 because the result is otherwise not guaranteed to equal the input price + if (amount == 0) return sqrtPX96; + uint256 numerator1 = uint256(liquidity) << FixedPoint96.RESOLUTION; + + if (add) { + unchecked { + uint256 product; + if ((product = amount * sqrtPX96) / amount == sqrtPX96) { + uint256 denominator = numerator1 + product; + if (denominator >= numerator1) + // always fits in 160 bits + return uint160(FullMath.mulDivRoundingUp(numerator1, sqrtPX96, denominator)); + } + } + // denominator is checked for overflow + return uint160(UnsafeMath.divRoundingUp(numerator1, (numerator1 / sqrtPX96) + amount)); + } else { + unchecked { + uint256 product; + // if the product overflows, we know the denominator underflows + // in addition, we must check that the denominator does not underflow + require((product = amount * sqrtPX96) / amount == sqrtPX96 && numerator1 > product); + uint256 denominator = numerator1 - product; + return FullMath.mulDivRoundingUp(numerator1, sqrtPX96, denominator).toUint160(); + } + } + } + + /// @notice Gets the next sqrt price given a delta of token1 + /// @dev Always rounds down, because in the exact output case (decreasing price) we need to move the price at least + /// far enough to get the desired output amount, and in the exact input case (increasing price) we need to move the + /// price less in order to not send too much output. + /// The formula we compute is within <1 wei of the lossless version: sqrtPX96 +- amount / liquidity + /// @param sqrtPX96 The starting price, i.e., before accounting for the token1 delta + /// @param liquidity The amount of usable liquidity + /// @param amount How much of token1 to add, or remove, from virtual reserves + /// @param add Whether to add, or remove, the amount of token1 + /// @return The price after adding or removing `amount` + function getNextSqrtPriceFromAmount1RoundingDown( + uint160 sqrtPX96, + uint128 liquidity, + uint256 amount, + bool add + ) internal pure returns (uint160) { + // if we're adding (subtracting), rounding down requires rounding the quotient down (up) + // in both cases, avoid a mulDiv for most inputs + if (add) { + uint256 quotient = ( + amount <= type(uint160).max + ? (amount << FixedPoint96.RESOLUTION) / liquidity + : FullMath.mulDiv(amount, FixedPoint96.Q96, liquidity) + ); + + return (uint256(sqrtPX96) + quotient).toUint160(); + } else { + uint256 quotient = ( + amount <= type(uint160).max + ? UnsafeMath.divRoundingUp(amount << FixedPoint96.RESOLUTION, liquidity) + : FullMath.mulDivRoundingUp(amount, FixedPoint96.Q96, liquidity) + ); + + require(sqrtPX96 > quotient); + // always fits 160 bits + unchecked { + return uint160(sqrtPX96 - quotient); + } + } + } + + /// @notice Gets the next sqrt price given an input amount of token0 or token1 + /// @dev Throws if price or liquidity are 0, or if the next price is out of bounds + /// @param sqrtPX96 The starting price, i.e., before accounting for the input amount + /// @param liquidity The amount of usable liquidity + /// @param amountIn How much of token0, or token1, is being swapped in + /// @param zeroForOne Whether the amount in is token0 or token1 + /// @return sqrtQX96 The price after adding the input amount to token0 or token1 + function getNextSqrtPriceFromInput( + uint160 sqrtPX96, + uint128 liquidity, + uint256 amountIn, + bool zeroForOne + ) internal pure returns (uint160 sqrtQX96) { + require(sqrtPX96 > 0); + require(liquidity > 0); + + // round to make sure that we don't pass the target price + return + zeroForOne + ? getNextSqrtPriceFromAmount0RoundingUp(sqrtPX96, liquidity, amountIn, true) + : getNextSqrtPriceFromAmount1RoundingDown(sqrtPX96, liquidity, amountIn, true); + } + + /// @notice Gets the next sqrt price given an output amount of token0 or token1 + /// @dev Throws if price or liquidity are 0 or the next price is out of bounds + /// @param sqrtPX96 The starting price before accounting for the output amount + /// @param liquidity The amount of usable liquidity + /// @param amountOut How much of token0, or token1, is being swapped out + /// @param zeroForOne Whether the amount out is token0 or token1 + /// @return sqrtQX96 The price after removing the output amount of token0 or token1 + function getNextSqrtPriceFromOutput( + uint160 sqrtPX96, + uint128 liquidity, + uint256 amountOut, + bool zeroForOne + ) internal pure returns (uint160 sqrtQX96) { + require(sqrtPX96 > 0); + require(liquidity > 0); + + // round to make sure that we pass the target price + return + zeroForOne + ? getNextSqrtPriceFromAmount1RoundingDown(sqrtPX96, liquidity, amountOut, false) + : getNextSqrtPriceFromAmount0RoundingUp(sqrtPX96, liquidity, amountOut, false); + } + + /// @notice Gets the amount0 delta between two prices + /// @dev Calculates liquidity / sqrt(lower) - liquidity / sqrt(upper), + /// i.e. liquidity * (sqrt(upper) - sqrt(lower)) / (sqrt(upper) * sqrt(lower)) + /// @param sqrtRatioAX96 A sqrt price + /// @param sqrtRatioBX96 Another sqrt price + /// @param liquidity The amount of usable liquidity + /// @param roundUp Whether to round the amount up or down + /// @return amount0 Amount of token0 required to cover a position of size liquidity between the two passed prices + function getAmount0Delta( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity, + bool roundUp + ) internal pure returns (uint256 amount0) { + unchecked { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + uint256 numerator1 = uint256(liquidity) << FixedPoint96.RESOLUTION; + uint256 numerator2 = sqrtRatioBX96 - sqrtRatioAX96; + + require(sqrtRatioAX96 > 0); + + return + roundUp + ? UnsafeMath.divRoundingUp(FullMath.mulDivRoundingUp(numerator1, numerator2, sqrtRatioBX96), sqrtRatioAX96) + : FullMath.mulDiv(numerator1, numerator2, sqrtRatioBX96) / sqrtRatioAX96; + } + } + + /// @notice Gets the amount1 delta between two prices + /// @dev Calculates liquidity * (sqrt(upper) - sqrt(lower)) + /// @param sqrtRatioAX96 A sqrt price + /// @param sqrtRatioBX96 Another sqrt price + /// @param liquidity The amount of usable liquidity + /// @param roundUp Whether to round the amount up, or down + /// @return amount1 Amount of token1 required to cover a position of size liquidity between the two passed prices + function getAmount1Delta( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity, + bool roundUp + ) internal pure returns (uint256 amount1) { + unchecked { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + return + roundUp + ? FullMath.mulDivRoundingUp(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96) + : FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96); + } + } + + /// @notice Helper that gets signed token0 delta + /// @param sqrtRatioAX96 A sqrt price + /// @param sqrtRatioBX96 Another sqrt price + /// @param liquidity The change in liquidity for which to compute the amount0 delta + /// @return amount0 Amount of token0 corresponding to the passed liquidityDelta between the two prices + function getAmount0Delta( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + int128 liquidity + ) internal pure returns (int256 amount0) { + unchecked { + return + liquidity < 0 + ? -getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(-liquidity), false).toInt256() + : getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(liquidity), true).toInt256(); + } + } + + /// @notice Helper that gets signed token1 delta + /// @param sqrtRatioAX96 A sqrt price + /// @param sqrtRatioBX96 Another sqrt price + /// @param liquidity The change in liquidity for which to compute the amount1 delta + /// @return amount1 Amount of token1 corresponding to the passed liquidityDelta between the two prices + function getAmount1Delta( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + int128 liquidity + ) internal pure returns (int256 amount1) { + unchecked { + return + liquidity < 0 + ? -getAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(-liquidity), false).toInt256() + : getAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(liquidity), true).toInt256(); + } + } +} diff --git a/src/dex/v3/core/libraries/SwapMath.sol b/src/dex/v3/core/libraries/SwapMath.sol new file mode 100644 index 00000000..37996fff --- /dev/null +++ b/src/dex/v3/core/libraries/SwapMath.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +import { FullMath } from "./FullMath.sol"; +import { SqrtPriceMath } from "./SqrtPriceMath.sol"; + +/// @title Computes the result of a swap within ticks +/// @notice Contains methods for computing the result of a swap within a single tick price range, i.e., a single tick. +library SwapMath { + /// @notice Computes the result of swapping some amount in, or amount out, given the parameters of the swap + /// @dev The fee, plus the amount in, will never exceed the amount remaining if the swap's `amountSpecified` is positive + /// @param sqrtRatioCurrentX96 The current sqrt price of the pool + /// @param sqrtRatioTargetX96 The price that cannot be exceeded, from which the direction of the swap is inferred + /// @param liquidity The usable liquidity + /// @param amountRemaining How much input or output amount is remaining to be swapped in/out + /// @param feePips The fee taken from the input amount, expressed in hundredths of a bip + /// @return sqrtRatioNextX96 The price after swapping the amount in/out, not to exceed the price target + /// @return amountIn The amount to be swapped in, of either token0 or token1, based on the direction of the swap + /// @return amountOut The amount to be received, of either token0 or token1, based on the direction of the swap + /// @return feeAmount The amount of input that will be taken as a fee + function computeSwapStep( + uint160 sqrtRatioCurrentX96, + uint160 sqrtRatioTargetX96, + uint128 liquidity, + int256 amountRemaining, + uint24 feePips + ) internal pure returns (uint160 sqrtRatioNextX96, uint256 amountIn, uint256 amountOut, uint256 feeAmount) { + unchecked { + bool zeroForOne = sqrtRatioCurrentX96 >= sqrtRatioTargetX96; + bool exactIn = amountRemaining >= 0; + + if (exactIn) { + uint256 amountRemainingLessFee = FullMath.mulDiv(uint256(amountRemaining), 1e6 - feePips, 1e6); + amountIn = zeroForOne + ? SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true) + : SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true); + if (amountRemainingLessFee >= amountIn) sqrtRatioNextX96 = sqrtRatioTargetX96; + else + sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput( + sqrtRatioCurrentX96, + liquidity, + amountRemainingLessFee, + zeroForOne + ); + } else { + amountOut = zeroForOne + ? SqrtPriceMath.getAmount1Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, false) + : SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, false); + if (uint256(-amountRemaining) >= amountOut) sqrtRatioNextX96 = sqrtRatioTargetX96; + else + sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromOutput( + sqrtRatioCurrentX96, + liquidity, + uint256(-amountRemaining), + zeroForOne + ); + } + + bool max = sqrtRatioTargetX96 == sqrtRatioNextX96; + + // get the input/output amounts + if (zeroForOne) { + amountIn = max && exactIn + ? amountIn + : SqrtPriceMath.getAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, true); + amountOut = max && !exactIn + ? amountOut + : SqrtPriceMath.getAmount1Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false); + } else { + amountIn = max && exactIn + ? amountIn + : SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, true); + amountOut = max && !exactIn + ? amountOut + : SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, false); + } + + // cap the output amount to not exceed the remaining output amount + if (!exactIn && amountOut > uint256(-amountRemaining)) { + amountOut = uint256(-amountRemaining); + } + + if (exactIn && sqrtRatioNextX96 != sqrtRatioTargetX96) { + // we didn't reach the target, so take the remainder of the maximum input as fee + feeAmount = uint256(amountRemaining) - amountIn; + } else { + feeAmount = FullMath.mulDivRoundingUp(amountIn, feePips, 1e6 - feePips); + } + } + } +} diff --git a/src/dex/v3/core/libraries/Tick.sol b/src/dex/v3/core/libraries/Tick.sol new file mode 100644 index 00000000..8b73d2a1 --- /dev/null +++ b/src/dex/v3/core/libraries/Tick.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +import { SafeCast } from "./SafeCast.sol"; + +import { TickMath } from "./TickMath.sol"; + +/// @title Tick +/// @notice Contains functions for managing tick processes and relevant calculations +library Tick { + error LO(); + + using SafeCast for int256; + + // info stored for each initialized individual tick + struct Info { + // the total position liquidity that references this tick + uint128 liquidityGross; + // amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left), + int128 liquidityNet; + // fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) + // only has relative meaning, not absolute — the value depends on when the tick is initialized + uint256 feeGrowthOutside0X128; + uint256 feeGrowthOutside1X128; + // the cumulative tick value on the other side of the tick + int56 tickCumulativeOutside; + // the seconds per unit of liquidity on the _other_ side of this tick (relative to the current tick) + // only has relative meaning, not absolute — the value depends on when the tick is initialized + uint160 secondsPerLiquidityOutsideX128; + // the seconds spent on the other side of the tick (relative to the current tick) + // only has relative meaning, not absolute — the value depends on when the tick is initialized + uint32 secondsOutside; + // true iff the tick is initialized, i.e. the value is exactly equivalent to the expression liquidityGross != 0 + // these 8 bits are set to prevent fresh sstores when crossing newly initialized ticks + bool initialized; + } + + /// @notice Derives max liquidity per tick from given tick spacing + /// @dev Executed within the pool constructor + /// @param tickSpacing The amount of required tick separation, realized in multiples of `tickSpacing` + /// e.g., a tickSpacing of 3 requires ticks to be initialized every 3rd tick i.e., ..., -6, -3, 0, 3, 6, ... + /// @return The max liquidity per tick + function tickSpacingToMaxLiquidityPerTick(int24 tickSpacing) internal pure returns (uint128) { + unchecked { + int24 minTick = (TickMath.MIN_TICK / tickSpacing) * tickSpacing; + int24 maxTick = (TickMath.MAX_TICK / tickSpacing) * tickSpacing; + uint24 numTicks = uint24((maxTick - minTick) / tickSpacing) + 1; + return type(uint128).max / numTicks; + } + } + + /// @notice Retrieves fee growth data + /// @param self The mapping containing all tick information for initialized ticks + /// @param tickLower The lower tick boundary of the position + /// @param tickUpper The upper tick boundary of the position + /// @param tickCurrent The current tick + /// @param feeGrowthGlobal0X128 The all-time global fee growth, per unit of liquidity, in token0 + /// @param feeGrowthGlobal1X128 The all-time global fee growth, per unit of liquidity, in token1 + /// @return feeGrowthInside0X128 The all-time fee growth in token0, per unit of liquidity, inside the position's tick boundaries + /// @return feeGrowthInside1X128 The all-time fee growth in token1, per unit of liquidity, inside the position's tick boundaries + function getFeeGrowthInside( + mapping(int24 => Tick.Info) storage self, + int24 tickLower, + int24 tickUpper, + int24 tickCurrent, + uint256 feeGrowthGlobal0X128, + uint256 feeGrowthGlobal1X128 + ) internal view returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) { + unchecked { + Info storage lower = self[tickLower]; + Info storage upper = self[tickUpper]; + + // calculate fee growth below + uint256 feeGrowthBelow0X128; + uint256 feeGrowthBelow1X128; + if (tickCurrent >= tickLower) { + feeGrowthBelow0X128 = lower.feeGrowthOutside0X128; + feeGrowthBelow1X128 = lower.feeGrowthOutside1X128; + } else { + feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128; + feeGrowthBelow1X128 = feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128; + } + + // calculate fee growth above + uint256 feeGrowthAbove0X128; + uint256 feeGrowthAbove1X128; + if (tickCurrent < tickUpper) { + feeGrowthAbove0X128 = upper.feeGrowthOutside0X128; + feeGrowthAbove1X128 = upper.feeGrowthOutside1X128; + } else { + feeGrowthAbove0X128 = feeGrowthGlobal0X128 - upper.feeGrowthOutside0X128; + feeGrowthAbove1X128 = feeGrowthGlobal1X128 - upper.feeGrowthOutside1X128; + } + + feeGrowthInside0X128 = feeGrowthGlobal0X128 - feeGrowthBelow0X128 - feeGrowthAbove0X128; + feeGrowthInside1X128 = feeGrowthGlobal1X128 - feeGrowthBelow1X128 - feeGrowthAbove1X128; + } + } + + /// @notice Updates a tick and returns true if the tick was flipped from initialized to uninitialized, or vice versa + /// @param self The mapping containing all tick information for initialized ticks + /// @param tick The tick that will be updated + /// @param tickCurrent The current tick + /// @param liquidityDelta A new amount of liquidity to be added (subtracted) when tick is crossed from left to right (right to left) + /// @param feeGrowthGlobal0X128 The all-time global fee growth, per unit of liquidity, in token0 + /// @param feeGrowthGlobal1X128 The all-time global fee growth, per unit of liquidity, in token1 + /// @param secondsPerLiquidityCumulativeX128 The all-time seconds per max(1, liquidity) of the pool + /// @param tickCumulative The tick * time elapsed since the pool was first initialized + /// @param time The current block timestamp cast to a uint32 + /// @param upper true for updating a position's upper tick, or false for updating a position's lower tick + /// @param maxLiquidity The maximum liquidity allocation for a single tick + /// @return flipped Whether the tick was flipped from initialized to uninitialized, or vice versa + function update( + mapping(int24 => Tick.Info) storage self, + int24 tick, + int24 tickCurrent, + int128 liquidityDelta, + uint256 feeGrowthGlobal0X128, + uint256 feeGrowthGlobal1X128, + uint160 secondsPerLiquidityCumulativeX128, + int56 tickCumulative, + uint32 time, + bool upper, + uint128 maxLiquidity + ) internal returns (bool flipped) { + Tick.Info storage info = self[tick]; + + uint128 liquidityGrossBefore = info.liquidityGross; + uint128 liquidityGrossAfter = liquidityDelta < 0 + ? liquidityGrossBefore - uint128(-liquidityDelta) + : liquidityGrossBefore + uint128(liquidityDelta); + + if (liquidityGrossAfter > maxLiquidity) revert LO(); + + flipped = (liquidityGrossAfter == 0) != (liquidityGrossBefore == 0); + + if (liquidityGrossBefore == 0) { + // by convention, we assume that all growth before a tick was initialized happened _below_ the tick + if (tick <= tickCurrent) { + info.feeGrowthOutside0X128 = feeGrowthGlobal0X128; + info.feeGrowthOutside1X128 = feeGrowthGlobal1X128; + info.secondsPerLiquidityOutsideX128 = secondsPerLiquidityCumulativeX128; + info.tickCumulativeOutside = tickCumulative; + info.secondsOutside = time; + } + info.initialized = true; + } + + info.liquidityGross = liquidityGrossAfter; + + // when the lower (upper) tick is crossed left to right (right to left), liquidity must be added (removed) + info.liquidityNet = upper ? info.liquidityNet - liquidityDelta : info.liquidityNet + liquidityDelta; + } + + /// @notice Clears tick data + /// @param self The mapping containing all initialized tick information for initialized ticks + /// @param tick The tick that will be cleared + function clear(mapping(int24 => Tick.Info) storage self, int24 tick) internal { + delete self[tick]; + } + + /// @notice Transitions to next tick as needed by price movement + /// @param self The mapping containing all tick information for initialized ticks + /// @param tick The destination tick of the transition + /// @param feeGrowthGlobal0X128 The all-time global fee growth, per unit of liquidity, in token0 + /// @param feeGrowthGlobal1X128 The all-time global fee growth, per unit of liquidity, in token1 + /// @param secondsPerLiquidityCumulativeX128 The current seconds per liquidity + /// @param tickCumulative The tick * time elapsed since the pool was first initialized + /// @param time The current block.timestamp + /// @return liquidityNet The amount of liquidity added (subtracted) when tick is crossed from left to right (right to left) + function cross( + mapping(int24 => Tick.Info) storage self, + int24 tick, + uint256 feeGrowthGlobal0X128, + uint256 feeGrowthGlobal1X128, + uint160 secondsPerLiquidityCumulativeX128, + int56 tickCumulative, + uint32 time + ) internal returns (int128 liquidityNet) { + unchecked { + Tick.Info storage info = self[tick]; + info.feeGrowthOutside0X128 = feeGrowthGlobal0X128 - info.feeGrowthOutside0X128; + info.feeGrowthOutside1X128 = feeGrowthGlobal1X128 - info.feeGrowthOutside1X128; + info.secondsPerLiquidityOutsideX128 = secondsPerLiquidityCumulativeX128 - info.secondsPerLiquidityOutsideX128; + info.tickCumulativeOutside = tickCumulative - info.tickCumulativeOutside; + info.secondsOutside = time - info.secondsOutside; + liquidityNet = info.liquidityNet; + } + } +} diff --git a/src/dex/v3/core/libraries/TickBitmap.sol b/src/dex/v3/core/libraries/TickBitmap.sol new file mode 100644 index 00000000..98a3d4f5 --- /dev/null +++ b/src/dex/v3/core/libraries/TickBitmap.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.34; + +import { BitMath } from "./BitMath.sol"; + +/// @title Packed tick initialized state library +/// @notice Stores a packed mapping of tick index to its initialized state +/// @dev The mapping uses int16 for keys since ticks are represented as int24 and there are 256 (2^8) values per word. +library TickBitmap { + /// @notice Computes the position in the mapping where the initialized bit for a tick lives + /// @param tick The tick for which to compute the position + /// @return wordPos The key in the mapping containing the word in which the bit is stored + /// @return bitPos The bit position in the word where the flag is stored + function position(int24 tick) private pure returns (int16 wordPos, uint8 bitPos) { + unchecked { + wordPos = int16(tick >> 8); + bitPos = uint8(int8(tick % 256)); + } + } + + /// @notice Flips the initialized state for a given tick from false to true, or vice versa + /// @param self The mapping in which to flip the tick + /// @param tick The tick to flip + /// @param tickSpacing The spacing between usable ticks + function flipTick(mapping(int16 => uint256) storage self, int24 tick, int24 tickSpacing) internal { + unchecked { + require(tick % tickSpacing == 0); // ensure that the tick is spaced + (int16 wordPos, uint8 bitPos) = position(tick / tickSpacing); + uint256 mask = 1 << bitPos; + self[wordPos] ^= mask; + } + } + + /// @notice Returns the next initialized tick contained in the same word (or adjacent word) as the tick that is either + /// to the left (less than or equal to) or right (greater than) of the given tick + /// @param self The mapping in which to compute the next initialized tick + /// @param tick The starting tick + /// @param tickSpacing The spacing between usable ticks + /// @param lte Whether to search for the next initialized tick to the left (less than or equal to the starting tick) + /// @return next The next initialized or uninitialized tick up to 256 ticks away from the current tick + /// @return initialized Whether the next tick is initialized, as the function only searches within up to 256 ticks + function nextInitializedTickWithinOneWord( + mapping(int16 => uint256) storage self, + int24 tick, + int24 tickSpacing, + bool lte + ) internal view returns (int24 next, bool initialized) { + unchecked { + int24 compressed = tick / tickSpacing; + if (tick < 0 && tick % tickSpacing != 0) compressed--; // round towards negative infinity + + if (lte) { + (int16 wordPos, uint8 bitPos) = position(compressed); + // all the 1s at or to the right of the current bitPos + uint256 mask = (1 << bitPos) - 1 + (1 << bitPos); + uint256 masked = self[wordPos] & mask; + + // if there are no initialized ticks to the right of or at the current tick, return rightmost in the word + initialized = masked != 0; + // overflow/underflow is possible, but prevented externally by limiting both tickSpacing and tick + next = initialized + ? (compressed - int24(uint24(bitPos - BitMath.mostSignificantBit(masked)))) * tickSpacing + : (compressed - int24(uint24(bitPos))) * tickSpacing; + } else { + // start from the word of the next tick, since the current tick state doesn't matter + (int16 wordPos, uint8 bitPos) = position(compressed + 1); + // all the 1s at or to the left of the bitPos + uint256 mask = ~((1 << bitPos) - 1); + uint256 masked = self[wordPos] & mask; + + // if there are no initialized ticks to the left of the current tick, return leftmost in the word + initialized = masked != 0; + // overflow/underflow is possible, but prevented externally by limiting both tickSpacing and tick + next = initialized + ? (compressed + 1 + int24(uint24(BitMath.leastSignificantBit(masked) - bitPos))) * tickSpacing + : (compressed + 1 + int24(uint24(type(uint8).max - bitPos))) * tickSpacing; + } + } + } +} diff --git a/src/dex/v3/core/libraries/TickMath.sol b/src/dex/v3/core/libraries/TickMath.sol new file mode 100644 index 00000000..61e515cd --- /dev/null +++ b/src/dex/v3/core/libraries/TickMath.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +/// @title Math library for computing sqrt prices from ticks and vice versa +/// @notice Computes sqrt price for ticks of size 1.0001, i.e. sqrt(1.0001^tick) as fixed point Q64.96 numbers. Supports +/// prices between 2**-128 and 2**128 +library TickMath { + error T(); + error R(); + + /// @dev The minimum tick that may be passed to #getSqrtRatioAtTick computed from log base 1.0001 of 2**-128 + int24 internal constant MIN_TICK = -887272; + /// @dev The maximum tick that may be passed to #getSqrtRatioAtTick computed from log base 1.0001 of 2**128 + int24 internal constant MAX_TICK = -MIN_TICK; + + /// @dev The minimum value that can be returned from #getSqrtRatioAtTick. Equivalent to getSqrtRatioAtTick(MIN_TICK) + uint160 internal constant MIN_SQRT_RATIO = 4295128739; + /// @dev The maximum value that can be returned from #getSqrtRatioAtTick. Equivalent to getSqrtRatioAtTick(MAX_TICK) + uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; + + /// @notice Calculates sqrt(1.0001^tick) * 2^96 + /// @dev Throws if |tick| > max tick + /// @param tick The input tick for the above formula + /// @return sqrtPriceX96 A Fixed point Q64.96 number representing the sqrt of the ratio of the two assets (token1/token0) + /// at the given tick + function getSqrtRatioAtTick(int24 tick) internal pure returns (uint160 sqrtPriceX96) { + unchecked { + uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick)); + if (absTick > uint256(int256(MAX_TICK))) revert T(); + + uint256 ratio = absTick & 0x1 != 0 ? 0xfffcb933bd6fad37aa2d162d1a594001 : 0x100000000000000000000000000000000; + if (absTick & 0x2 != 0) ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128; + if (absTick & 0x4 != 0) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128; + if (absTick & 0x8 != 0) ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0) >> 128; + if (absTick & 0x10 != 0) ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644) >> 128; + if (absTick & 0x20 != 0) ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0) >> 128; + if (absTick & 0x40 != 0) ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861) >> 128; + if (absTick & 0x80 != 0) ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053) >> 128; + if (absTick & 0x100 != 0) ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4) >> 128; + if (absTick & 0x200 != 0) ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54) >> 128; + if (absTick & 0x400 != 0) ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3) >> 128; + if (absTick & 0x800 != 0) ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9) >> 128; + if (absTick & 0x1000 != 0) ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825) >> 128; + if (absTick & 0x2000 != 0) ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5) >> 128; + if (absTick & 0x4000 != 0) ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7) >> 128; + if (absTick & 0x8000 != 0) ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6) >> 128; + if (absTick & 0x10000 != 0) ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9) >> 128; + if (absTick & 0x20000 != 0) ratio = (ratio * 0x5d6af8dedb81196699c329225ee604) >> 128; + if (absTick & 0x40000 != 0) ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98) >> 128; + if (absTick & 0x80000 != 0) ratio = (ratio * 0x48a170391f7dc42444e8fa2) >> 128; + + if (tick > 0) ratio = type(uint256).max / ratio; + + // this divides by 1<<32 rounding up to go from a Q128.128 to a Q128.96. + // we then downcast because we know the result always fits within 160 bits due to our tick input constraint + // we round up in the division so getTickAtSqrtRatio of the output price is always consistent + sqrtPriceX96 = uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1)); + } + } + + /// @notice Calculates the greatest tick value such that getRatioAtTick(tick) <= ratio + /// @dev Throws in case sqrtPriceX96 < MIN_SQRT_RATIO, as MIN_SQRT_RATIO is the lowest value getRatioAtTick may + /// ever return. + /// @param sqrtPriceX96 The sqrt ratio for which to compute the tick as a Q64.96 + /// @return tick The greatest tick for which the ratio is less than or equal to the input ratio + function getTickAtSqrtRatio(uint160 sqrtPriceX96) internal pure returns (int24 tick) { + unchecked { + // second inequality must be < because the price can never reach the price at the max tick + if (!(sqrtPriceX96 >= MIN_SQRT_RATIO && sqrtPriceX96 < MAX_SQRT_RATIO)) revert R(); + uint256 ratio = uint256(sqrtPriceX96) << 32; + + uint256 r = ratio; + uint256 msb = 0; + + assembly { + let f := shl(7, gt(r, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)) + msb := or(msb, f) + r := shr(f, r) + } + assembly { + let f := shl(6, gt(r, 0xFFFFFFFFFFFFFFFF)) + msb := or(msb, f) + r := shr(f, r) + } + assembly { + let f := shl(5, gt(r, 0xFFFFFFFF)) + msb := or(msb, f) + r := shr(f, r) + } + assembly { + let f := shl(4, gt(r, 0xFFFF)) + msb := or(msb, f) + r := shr(f, r) + } + assembly { + let f := shl(3, gt(r, 0xFF)) + msb := or(msb, f) + r := shr(f, r) + } + assembly { + let f := shl(2, gt(r, 0xF)) + msb := or(msb, f) + r := shr(f, r) + } + assembly { + let f := shl(1, gt(r, 0x3)) + msb := or(msb, f) + r := shr(f, r) + } + assembly { + let f := gt(r, 0x1) + msb := or(msb, f) + } + + if (msb >= 128) r = ratio >> (msb - 127); + else r = ratio << (127 - msb); + + int256 log_2 = (int256(msb) - 128) << 64; + + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(63, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(62, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(61, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(60, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(59, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(58, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(57, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(56, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(55, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(54, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(53, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(52, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(51, f)) + r := shr(f, r) + } + assembly { + r := shr(127, mul(r, r)) + let f := shr(128, r) + log_2 := or(log_2, shl(50, f)) + } + + int256 log_sqrt10001 = log_2 * 255738958999603826347141; // 128.128 number + + int24 tickLow = int24((log_sqrt10001 - 3402992956809132418596140100660247210) >> 128); + int24 tickHi = int24((log_sqrt10001 + 291339464771989622907027621153398088495) >> 128); + + tick = tickLow == tickHi + ? tickLow + : getSqrtRatioAtTick(tickHi) <= sqrtPriceX96 + ? tickHi + : tickLow; + } + } +} diff --git a/src/dex/v3/core/libraries/TransferHelper.sol b/src/dex/v3/core/libraries/TransferHelper.sol new file mode 100644 index 00000000..cc89462d --- /dev/null +++ b/src/dex/v3/core/libraries/TransferHelper.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.6.0; + +import { IERC20Minimal } from "../interfaces/IERC20Minimal.sol"; + +/// @title TransferHelper +/// @notice Contains helper methods for interacting with ERC20 tokens that do not consistently return true/false +library TransferHelper { + error TF(); + + /// @notice Transfers tokens from msg.sender to a recipient + /// @dev Calls transfer on token contract, errors with TF if transfer fails + /// @param token The contract address of the token which will be transferred + /// @param to The recipient of the transfer + /// @param value The value of the transfer + function safeTransfer(address token, address to, uint256 value) internal { + (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Minimal.transfer.selector, to, value)); + if (!(success && (data.length == 0 || abi.decode(data, (bool))))) revert TF(); + } +} diff --git a/src/dex/v3/core/libraries/UnsafeMath.sol b/src/dex/v3/core/libraries/UnsafeMath.sol new file mode 100644 index 00000000..d043a1fb --- /dev/null +++ b/src/dex/v3/core/libraries/UnsafeMath.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Math functions that do not check inputs or outputs +/// @notice Contains methods that perform common math functions but do not do any overflow or underflow checks +library UnsafeMath { + /// @notice Returns ceil(x / y) + /// @dev division by 0 has unspecified behavior, and must be checked externally + /// @param x The dividend + /// @param y The divisor + /// @return z The quotient, ceil(x / y) + function divRoundingUp(uint256 x, uint256 y) internal pure returns (uint256 z) { + assembly { + z := add(div(x, y), gt(mod(x, y), 0)) + } + } +} diff --git a/src/dex/v3/periphery/NonfungiblePositionManager.sol b/src/dex/v3/periphery/NonfungiblePositionManager.sol new file mode 100644 index 00000000..e25e595c --- /dev/null +++ b/src/dex/v3/periphery/NonfungiblePositionManager.sol @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; +pragma abicoder v2; + +import "../core/interfaces/IListaV3Pool.sol"; +import "../core/libraries/FixedPoint128.sol"; +import "../core/libraries/FullMath.sol"; + +import "./interfaces/INonfungiblePositionManager.sol"; +import "./interfaces/INonfungibleTokenPositionDescriptor.sol"; +import "./libraries/PositionKey.sol"; +import "./libraries/PoolAddress.sol"; +import "./base/LiquidityManagement.sol"; +import "./base/PeripheryImmutableState.sol"; +import "./base/Multicall.sol"; +import "./base/ERC721Permit.sol"; +import "./base/PeripheryValidation.sol"; +import "./base/SelfPermit.sol"; +import "./base/PoolInitializer.sol"; + +/// @title NFT positions +/// @notice Wraps Lista V3 positions in the ERC721 non-fungible token interface +contract NonfungiblePositionManager is + INonfungiblePositionManager, + Multicall, + ERC721Permit, + PeripheryImmutableState, + PoolInitializer, + LiquidityManagement, + PeripheryValidation, + SelfPermit +{ + // details about the lista position + struct Position { + // the nonce for permits + uint96 nonce; + // the address that is approved for spending this token + address operator; + // the ID of the pool with which this token is connected + uint80 poolId; + // the tick range of the position + int24 tickLower; + int24 tickUpper; + // the liquidity of the position + uint128 liquidity; + // the fee growth of the aggregate position as of the last action on the individual position + uint256 feeGrowthInside0LastX128; + uint256 feeGrowthInside1LastX128; + // how many uncollected tokens are owed to the position, as of the last computation + uint128 tokensOwed0; + uint128 tokensOwed1; + } + + /// @dev IDs of pools assigned by this contract + mapping(address => uint80) private _poolIds; + + /// @dev Pool keys by pool ID, to save on SSTOREs for position data + mapping(uint80 => PoolAddress.PoolKey) private _poolIdToPoolKey; + + /// @dev The token ID position data + mapping(uint256 => Position) private _positions; + + /// @dev The ID of the next token that will be minted. Skips 0 + uint176 private _nextId = 1; + /// @dev The ID of the next pool that is used for the first time. Skips 0 + uint80 private _nextPoolId = 1; + + /// @dev The address of the token descriptor contract, which handles generating token URIs for position tokens + address private immutable _tokenDescriptor; + + constructor( + address _factory, + address _WETH9, + address _tokenDescriptor_ + ) ERC721Permit("Lista V3 Positions NFT-V1", "UNI-V3-POS", "1") PeripheryImmutableState(_factory, _WETH9) { + _tokenDescriptor = _tokenDescriptor_; + } + + /// @inheritdoc INonfungiblePositionManager + function positions( + uint256 tokenId + ) + external + view + override + 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 + ) + { + Position memory position = _positions[tokenId]; + require(position.poolId != 0, "Invalid token ID"); + PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; + return ( + position.nonce, + position.operator, + poolKey.token0, + poolKey.token1, + poolKey.fee, + position.tickLower, + position.tickUpper, + position.liquidity, + position.feeGrowthInside0LastX128, + position.feeGrowthInside1LastX128, + position.tokensOwed0, + position.tokensOwed1 + ); + } + + /// @dev Caches a pool key + function cachePoolKey(address pool, PoolAddress.PoolKey memory poolKey) private returns (uint80 poolId) { + poolId = _poolIds[pool]; + if (poolId == 0) { + _poolIds[pool] = (poolId = _nextPoolId++); + _poolIdToPoolKey[poolId] = poolKey; + } + } + + /// @inheritdoc INonfungiblePositionManager + function mint( + MintParams calldata params + ) + external + payable + override + checkDeadline(params.deadline) + returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) + { + IListaV3Pool pool; + (liquidity, amount0, amount1, pool) = addLiquidity( + AddLiquidityParams({ + token0: params.token0, + token1: params.token1, + fee: params.fee, + recipient: address(this), + tickLower: params.tickLower, + tickUpper: params.tickUpper, + amount0Desired: params.amount0Desired, + amount1Desired: params.amount1Desired, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min + }) + ); + + _mint(params.recipient, (tokenId = _nextId++)); + + bytes32 positionKey = PositionKey.compute(address(this), params.tickLower, params.tickUpper); + (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey); + + // idempotent set + uint80 poolId = cachePoolKey( + address(pool), + PoolAddress.PoolKey({ token0: params.token0, token1: params.token1, fee: params.fee }) + ); + + _positions[tokenId] = Position({ + nonce: 0, + operator: address(0), + poolId: poolId, + tickLower: params.tickLower, + tickUpper: params.tickUpper, + liquidity: liquidity, + feeGrowthInside0LastX128: feeGrowthInside0LastX128, + feeGrowthInside1LastX128: feeGrowthInside1LastX128, + tokensOwed0: 0, + tokensOwed1: 0 + }); + + emit IncreaseLiquidity(tokenId, liquidity, amount0, amount1); + } + + modifier isAuthorizedForToken(uint256 tokenId) { + address owner = _requireOwned(tokenId); + require(_isAuthorized(owner, msg.sender, tokenId), "Not approved"); + _; + } + + function tokenURI(uint256 tokenId) public view override(ERC721, IERC721Metadata) returns (string memory) { + _requireOwned(tokenId); + return INonfungibleTokenPositionDescriptor(_tokenDescriptor).tokenURI(this, tokenId); + } + + // save bytecode by removing implementation of unused method + function baseURI() public pure returns (string memory) {} + + /// @inheritdoc INonfungiblePositionManager + function increaseLiquidity( + IncreaseLiquidityParams calldata params + ) + external + payable + override + checkDeadline(params.deadline) + returns (uint128 liquidity, uint256 amount0, uint256 amount1) + { + Position storage position = _positions[params.tokenId]; + + PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; + + IListaV3Pool pool; + (liquidity, amount0, amount1, pool) = addLiquidity( + AddLiquidityParams({ + token0: poolKey.token0, + token1: poolKey.token1, + fee: poolKey.fee, + tickLower: position.tickLower, + tickUpper: position.tickUpper, + amount0Desired: params.amount0Desired, + amount1Desired: params.amount1Desired, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + recipient: address(this) + }) + ); + + bytes32 positionKey = PositionKey.compute(address(this), position.tickLower, position.tickUpper); + + // this is now updated to the current transaction + (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey); + + position.tokensOwed0 += uint128( + FullMath.mulDiv( + feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128, + position.liquidity, + FixedPoint128.Q128 + ) + ); + position.tokensOwed1 += uint128( + FullMath.mulDiv( + feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128, + position.liquidity, + FixedPoint128.Q128 + ) + ); + + position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128; + position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128; + position.liquidity += liquidity; + + emit IncreaseLiquidity(params.tokenId, liquidity, amount0, amount1); + } + + /// @inheritdoc INonfungiblePositionManager + function decreaseLiquidity( + DecreaseLiquidityParams calldata params + ) + external + payable + override + isAuthorizedForToken(params.tokenId) + checkDeadline(params.deadline) + returns (uint256 amount0, uint256 amount1) + { + require(params.liquidity > 0); + Position storage position = _positions[params.tokenId]; + + uint128 positionLiquidity = position.liquidity; + require(positionLiquidity >= params.liquidity); + + PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; + IListaV3Pool pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey)); + (amount0, amount1) = pool.burn(position.tickLower, position.tickUpper, params.liquidity); + + require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, "Price slippage check"); + + bytes32 positionKey = PositionKey.compute(address(this), position.tickLower, position.tickUpper); + // this is now updated to the current transaction + (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey); + + position.tokensOwed0 += + uint128(amount0) + + uint128( + FullMath.mulDiv( + feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128, + positionLiquidity, + FixedPoint128.Q128 + ) + ); + position.tokensOwed1 += + uint128(amount1) + + uint128( + FullMath.mulDiv( + feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128, + positionLiquidity, + FixedPoint128.Q128 + ) + ); + + position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128; + position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128; + // subtraction is safe because we checked positionLiquidity is gte params.liquidity + position.liquidity = positionLiquidity - params.liquidity; + + emit DecreaseLiquidity(params.tokenId, params.liquidity, amount0, amount1); + } + + /// @inheritdoc INonfungiblePositionManager + function collect( + CollectParams calldata params + ) external payable override isAuthorizedForToken(params.tokenId) returns (uint256 amount0, uint256 amount1) { + require(params.amount0Max > 0 || params.amount1Max > 0); + // allow collecting to the nft position manager address with address 0 + address recipient = params.recipient == address(0) ? address(this) : params.recipient; + + Position storage position = _positions[params.tokenId]; + + PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; + + IListaV3Pool pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey)); + + (uint128 tokensOwed0, uint128 tokensOwed1) = (position.tokensOwed0, position.tokensOwed1); + + // trigger an update of the position fees owed and fee growth snapshots if it has any liquidity + if (position.liquidity > 0) { + pool.burn(position.tickLower, position.tickUpper, 0); + (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions( + PositionKey.compute(address(this), position.tickLower, position.tickUpper) + ); + + tokensOwed0 += uint128( + FullMath.mulDiv( + feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128, + position.liquidity, + FixedPoint128.Q128 + ) + ); + tokensOwed1 += uint128( + FullMath.mulDiv( + feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128, + position.liquidity, + FixedPoint128.Q128 + ) + ); + + position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128; + position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128; + } + + // compute the arguments to give to the pool#collect method + (uint128 amount0Collect, uint128 amount1Collect) = ( + params.amount0Max > tokensOwed0 ? tokensOwed0 : params.amount0Max, + params.amount1Max > tokensOwed1 ? tokensOwed1 : params.amount1Max + ); + + // the actual amounts collected are returned + (amount0, amount1) = pool.collect( + recipient, + position.tickLower, + position.tickUpper, + amount0Collect, + amount1Collect + ); + + // sometimes there will be a few less wei than expected due to rounding down in core, but we just subtract the full amount expected + // instead of the actual amount so we can burn the token + (position.tokensOwed0, position.tokensOwed1) = (tokensOwed0 - amount0Collect, tokensOwed1 - amount1Collect); + + emit Collect(params.tokenId, recipient, amount0Collect, amount1Collect); + } + + /// @inheritdoc INonfungiblePositionManager + function burn(uint256 tokenId) external payable override isAuthorizedForToken(tokenId) { + Position storage position = _positions[tokenId]; + require(position.liquidity == 0 && position.tokensOwed0 == 0 && position.tokensOwed1 == 0, "Not cleared"); + delete _positions[tokenId]; + _burn(tokenId); + } + + function _getAndIncrementNonce(uint256 tokenId) internal override returns (uint256) { + return uint256(_positions[tokenId].nonce++); + } + + /// @inheritdoc IERC721 + function getApproved(uint256 tokenId) public view override(ERC721, IERC721) returns (address) { + _requireOwned(tokenId); + + return _positions[tokenId].operator; + } + + /// @dev Overrides _approve to use the operator in the position, which is packed with the position permit nonce + function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal override { + _positions[tokenId].operator = to; + if (emitEvent) { + emit Approval(ownerOf(tokenId), to, tokenId); + } + } +} diff --git a/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol b/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol new file mode 100644 index 00000000..0abcc022 --- /dev/null +++ b/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; +pragma abicoder v2; + +import "../core/interfaces/IListaV3Pool.sol"; + +import "./libraries/SafeERC20Namer.sol"; +import "./libraries/ChainId.sol"; +import "./interfaces/INonfungiblePositionManager.sol"; +import "./interfaces/INonfungibleTokenPositionDescriptor.sol"; +import "./interfaces/IERC20Metadata.sol"; +import "./libraries/PoolAddress.sol"; +import "./libraries/NFTDescriptor.sol"; +import "./libraries/TokenRatioSortOrder.sol"; + +/// @title Describes NFT token positions +/// @notice Produces a string containing the data URI for a JSON metadata string +contract NonfungibleTokenPositionDescriptor is INonfungibleTokenPositionDescriptor { + address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + address private constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address private constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address private constant TBTC = 0x8dAEBADE922dF735c38C80C7eBD708Af50815fAa; + address private constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; + + address public immutable WETH9; + /// @dev A null-terminated string + bytes32 public immutable nativeCurrencyLabelBytes; + + constructor(address _WETH9, bytes32 _nativeCurrencyLabelBytes) { + WETH9 = _WETH9; + nativeCurrencyLabelBytes = _nativeCurrencyLabelBytes; + } + + /// @notice Returns the native currency label as a string + function nativeCurrencyLabel() public view returns (string memory) { + uint256 len = 0; + while (len < 32 && nativeCurrencyLabelBytes[len] != 0) { + len++; + } + bytes memory b = new bytes(len); + for (uint256 i = 0; i < len; i++) { + b[i] = nativeCurrencyLabelBytes[i]; + } + return string(b); + } + + /// @inheritdoc INonfungibleTokenPositionDescriptor + function tokenURI( + INonfungiblePositionManager positionManager, + uint256 tokenId + ) external view override returns (string memory) { + (, , address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, , , , , ) = positionManager + .positions(tokenId); + + IListaV3Pool pool = IListaV3Pool( + PoolAddress.computeAddress( + positionManager.factory(), + PoolAddress.PoolKey({ token0: token0, token1: token1, fee: fee }) + ) + ); + + bool _flipRatio = flipRatio(token0, token1, ChainId.get()); + address quoteTokenAddress = !_flipRatio ? token1 : token0; + address baseTokenAddress = !_flipRatio ? token0 : token1; + (, int24 tick, , , , , ) = pool.slot0(); + + return + NFTDescriptor.constructTokenURI( + NFTDescriptor.ConstructTokenURIParams({ + tokenId: tokenId, + quoteTokenAddress: quoteTokenAddress, + baseTokenAddress: baseTokenAddress, + quoteTokenSymbol: quoteTokenAddress == WETH9 + ? nativeCurrencyLabel() + : SafeERC20Namer.tokenSymbol(quoteTokenAddress), + baseTokenSymbol: baseTokenAddress == WETH9 + ? nativeCurrencyLabel() + : SafeERC20Namer.tokenSymbol(baseTokenAddress), + quoteTokenDecimals: IERC20Metadata(quoteTokenAddress).decimals(), + baseTokenDecimals: IERC20Metadata(baseTokenAddress).decimals(), + flipRatio: _flipRatio, + tickLower: tickLower, + tickUpper: tickUpper, + tickCurrent: tick, + tickSpacing: pool.tickSpacing(), + fee: fee, + poolAddress: address(pool) + }) + ); + } + + function flipRatio(address token0, address token1, uint256 chainId) public view returns (bool) { + return tokenRatioPriority(token0, chainId) > tokenRatioPriority(token1, chainId); + } + + function tokenRatioPriority(address token, uint256 chainId) public view returns (int256) { + if (token == WETH9) { + return TokenRatioSortOrder.DENOMINATOR; + } + if (chainId == 1) { + if (token == USDC) { + return TokenRatioSortOrder.NUMERATOR_MOST; + } else if (token == USDT) { + return TokenRatioSortOrder.NUMERATOR_MORE; + } else if (token == DAI) { + return TokenRatioSortOrder.NUMERATOR; + } else if (token == TBTC) { + return TokenRatioSortOrder.DENOMINATOR_MORE; + } else if (token == WBTC) { + return TokenRatioSortOrder.DENOMINATOR_MOST; + } else { + return 0; + } + } + return 0; + } +} diff --git a/src/dex/v3/periphery/SwapRouter.sol b/src/dex/v3/periphery/SwapRouter.sol new file mode 100644 index 00000000..0d2e0d82 --- /dev/null +++ b/src/dex/v3/periphery/SwapRouter.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; +pragma abicoder v2; + +import "../core/libraries/SafeCast.sol"; +import "../core/libraries/TickMath.sol"; +import "../core/interfaces/IListaV3Pool.sol"; + +import "./interfaces/ISwapRouter.sol"; +import "./base/PeripheryImmutableState.sol"; +import "./base/PeripheryValidation.sol"; +import "./base/PeripheryPaymentsWithFee.sol"; +import "./base/Multicall.sol"; +import "./base/SelfPermit.sol"; +import "./libraries/Path.sol"; +import "./libraries/PoolAddress.sol"; +import "./libraries/CallbackValidation.sol"; +import "./interfaces/external/IWETH9.sol"; + +/// @title Lista V3 Swap Router +/// @notice Router for stateless execution of swaps against Lista V3 +contract SwapRouter is + ISwapRouter, + PeripheryImmutableState, + PeripheryValidation, + PeripheryPaymentsWithFee, + Multicall, + SelfPermit +{ + using Path for bytes; + using SafeCast for uint256; + + /// @dev Used as the placeholder value for amountInCached, because the computed amount in for an exact output swap + /// can never actually be this value + uint256 private constant DEFAULT_AMOUNT_IN_CACHED = type(uint256).max; + + /// @dev Transient storage variable used for returning the computed amount in for an exact output swap. + uint256 private amountInCached = DEFAULT_AMOUNT_IN_CACHED; + + constructor(address _factory, address _WETH9) PeripheryImmutableState(_factory, _WETH9) {} + + /// @dev Returns the pool for the given token pair and fee. The pool contract may or may not exist. + function getPool(address tokenA, address tokenB, uint24 fee) private view returns (IListaV3Pool) { + return IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee))); + } + + struct SwapCallbackData { + bytes path; + address payer; + } + + /// @inheritdoc IListaV3SwapCallback + function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata _data) external override { + require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported + SwapCallbackData memory data = abi.decode(_data, (SwapCallbackData)); + (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool(); + CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee); + + (bool isExactInput, uint256 amountToPay) = amount0Delta > 0 + ? (tokenIn < tokenOut, uint256(amount0Delta)) + : (tokenOut < tokenIn, uint256(amount1Delta)); + if (isExactInput) { + pay(tokenIn, data.payer, msg.sender, amountToPay); + } else { + // either initiate the next swap or pay + if (data.path.hasMultiplePools()) { + data.path = data.path.skipToken(); + exactOutputInternal(amountToPay, msg.sender, 0, data); + } else { + amountInCached = amountToPay; + tokenIn = tokenOut; // swap in/out because exact output swaps are reversed + pay(tokenIn, data.payer, msg.sender, amountToPay); + } + } + } + + /// @dev Performs a single exact input swap + function exactInputInternal( + uint256 amountIn, + address recipient, + uint160 sqrtPriceLimitX96, + SwapCallbackData memory data + ) private returns (uint256 amountOut) { + // allow swapping to the router address with address 0 + if (recipient == address(0)) recipient = address(this); + + (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool(); + + bool zeroForOne = tokenIn < tokenOut; + + (int256 amount0, int256 amount1) = getPool(tokenIn, tokenOut, fee).swap( + recipient, + zeroForOne, + amountIn.toInt256(), + sqrtPriceLimitX96 == 0 + ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) + : sqrtPriceLimitX96, + abi.encode(data) + ); + + return uint256(-(zeroForOne ? amount1 : amount0)); + } + + /// @inheritdoc ISwapRouter + function exactInputSingle( + ExactInputSingleParams calldata params + ) external payable override checkDeadline(params.deadline) returns (uint256 amountOut) { + amountOut = exactInputInternal( + params.amountIn, + params.recipient, + params.sqrtPriceLimitX96, + SwapCallbackData({ path: abi.encodePacked(params.tokenIn, params.fee, params.tokenOut), payer: msg.sender }) + ); + require(amountOut >= params.amountOutMinimum, "Too little received"); + } + + /// @inheritdoc ISwapRouter + function exactInput( + ExactInputParams memory params + ) external payable override checkDeadline(params.deadline) returns (uint256 amountOut) { + address payer = msg.sender; // msg.sender pays for the first hop + + while (true) { + bool hasMultiplePools = params.path.hasMultiplePools(); + + // the outputs of prior swaps become the inputs to subsequent ones + params.amountIn = exactInputInternal( + params.amountIn, + hasMultiplePools ? address(this) : params.recipient, // for intermediate swaps, this contract custodies + 0, + SwapCallbackData({ + path: params.path.getFirstPool(), // only the first pool in the path is necessary + payer: payer + }) + ); + + // decide whether to continue or terminate + if (hasMultiplePools) { + payer = address(this); // at this point, the caller has paid + params.path = params.path.skipToken(); + } else { + amountOut = params.amountIn; + break; + } + } + + require(amountOut >= params.amountOutMinimum, "Too little received"); + } + + /// @dev Performs a single exact output swap + function exactOutputInternal( + uint256 amountOut, + address recipient, + uint160 sqrtPriceLimitX96, + SwapCallbackData memory data + ) private returns (uint256 amountIn) { + // allow swapping to the router address with address 0 + if (recipient == address(0)) recipient = address(this); + + (address tokenOut, address tokenIn, uint24 fee) = data.path.decodeFirstPool(); + + bool zeroForOne = tokenIn < tokenOut; + + (int256 amount0Delta, int256 amount1Delta) = getPool(tokenIn, tokenOut, fee).swap( + recipient, + zeroForOne, + -amountOut.toInt256(), + sqrtPriceLimitX96 == 0 + ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) + : sqrtPriceLimitX96, + abi.encode(data) + ); + + uint256 amountOutReceived; + (amountIn, amountOutReceived) = zeroForOne + ? (uint256(amount0Delta), uint256(-amount1Delta)) + : (uint256(amount1Delta), uint256(-amount0Delta)); + // it's technically possible to not receive the full output amount, + // so if no price limit has been specified, require this possibility away + if (sqrtPriceLimitX96 == 0) require(amountOutReceived == amountOut); + } + + /// @inheritdoc ISwapRouter + function exactOutputSingle( + ExactOutputSingleParams calldata params + ) external payable override checkDeadline(params.deadline) returns (uint256 amountIn) { + // avoid an SLOAD by using the swap return data + amountIn = exactOutputInternal( + params.amountOut, + params.recipient, + params.sqrtPriceLimitX96, + SwapCallbackData({ path: abi.encodePacked(params.tokenOut, params.fee, params.tokenIn), payer: msg.sender }) + ); + + require(amountIn <= params.amountInMaximum, "Too much requested"); + // has to be reset even though we don't use it in the single hop case + amountInCached = DEFAULT_AMOUNT_IN_CACHED; + } + + /// @inheritdoc ISwapRouter + function exactOutput( + ExactOutputParams calldata params + ) external payable override checkDeadline(params.deadline) returns (uint256 amountIn) { + // it's okay that the payer is fixed to msg.sender here, as they're only paying for the "final" exact output + // swap, which happens first, and subsequent swaps are paid for within nested callback frames + exactOutputInternal( + params.amountOut, + params.recipient, + 0, + SwapCallbackData({ path: params.path, payer: msg.sender }) + ); + + amountIn = amountInCached; + require(amountIn <= params.amountInMaximum, "Too much requested"); + amountInCached = DEFAULT_AMOUNT_IN_CACHED; + } +} diff --git a/src/dex/v3/periphery/base/BlockTimestamp.sol b/src/dex/v3/periphery/base/BlockTimestamp.sol new file mode 100644 index 00000000..649cfd8a --- /dev/null +++ b/src/dex/v3/periphery/base/BlockTimestamp.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +/// @title Function for getting block timestamp +/// @dev Base contract that is overridden for tests +abstract contract BlockTimestamp { + /// @dev Method that exists purely to be overridden for tests + /// @return The current block timestamp + function _blockTimestamp() internal view virtual returns (uint256) { + return block.timestamp; + } +} diff --git a/src/dex/v3/periphery/base/ERC721Permit.sol b/src/dex/v3/periphery/base/ERC721Permit.sol new file mode 100644 index 00000000..f183f520 --- /dev/null +++ b/src/dex/v3/periphery/base/ERC721Permit.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; + +import "../libraries/ChainId.sol"; +import "../interfaces/external/IERC1271.sol"; +import "../interfaces/IERC721Permit.sol"; +import "./BlockTimestamp.sol"; + +/// @title ERC721 with permit +/// @notice Nonfungible tokens that support an approve via signature, i.e. permit +abstract contract ERC721Permit is BlockTimestamp, ERC721Enumerable, IERC721Permit { + /// @dev Gets the current nonce for a token ID and then increments it, returning the original value + function _getAndIncrementNonce(uint256 tokenId) internal virtual returns (uint256); + + /// @dev The hash of the name used in the permit signature verification + bytes32 private immutable nameHash; + + /// @dev The hash of the version string used in the permit signature verification + bytes32 private immutable versionHash; + + /// @notice Computes the nameHash and versionHash + constructor(string memory name_, string memory symbol_, string memory version_) ERC721(name_, symbol_) { + nameHash = keccak256(bytes(name_)); + versionHash = keccak256(bytes(version_)); + } + + /// @inheritdoc IERC721Permit + function DOMAIN_SEPARATOR() public view override returns (bytes32) { + return + keccak256( + abi.encode( + // keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)') + 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, + nameHash, + versionHash, + ChainId.get(), + address(this) + ) + ); + } + + /// @inheritdoc IERC721Permit + /// @dev Value is equal to keccak256("Permit(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)"); + bytes32 public constant override PERMIT_TYPEHASH = 0x49ecf333e5b8c95c40fdafc95c1ad136e8914a8fb55e9dc8bb01eaa83a2df9ad; + + /// @inheritdoc IERC721Permit + function permit( + address spender, + uint256 tokenId, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external payable override { + require(_blockTimestamp() <= deadline, "Permit expired"); + + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR(), + keccak256(abi.encode(PERMIT_TYPEHASH, spender, tokenId, _getAndIncrementNonce(tokenId), deadline)) + ) + ); + address owner = ownerOf(tokenId); + require(spender != owner, "ERC721Permit: approval to current owner"); + + if (owner.code.length > 0) { + require(IERC1271(owner).isValidSignature(digest, abi.encodePacked(r, s, v)) == 0x1626ba7e, "Unauthorized"); + } else { + address recoveredAddress = ecrecover(digest, v, r, s); + require(recoveredAddress != address(0), "Invalid signature"); + require(recoveredAddress == owner, "Unauthorized"); + } + + _approve(spender, tokenId, address(0)); + } +} diff --git a/src/dex/v3/periphery/base/LiquidityManagement.sol b/src/dex/v3/periphery/base/LiquidityManagement.sol new file mode 100644 index 00000000..dbd58795 --- /dev/null +++ b/src/dex/v3/periphery/base/LiquidityManagement.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; +pragma abicoder v2; + +import "../../core/interfaces/IListaV3Factory.sol"; +import "../../core/interfaces/callback/IListaV3MintCallback.sol"; +import "../../core/libraries/TickMath.sol"; + +import "../libraries/PoolAddress.sol"; +import "../libraries/CallbackValidation.sol"; +import "../libraries/LiquidityAmounts.sol"; + +import "./PeripheryPayments.sol"; +import "./PeripheryImmutableState.sol"; + +/// @title Liquidity management functions +/// @notice Internal functions for safely managing liquidity in Lista V3 +abstract contract LiquidityManagement is IListaV3MintCallback, PeripheryImmutableState, PeripheryPayments { + struct MintCallbackData { + PoolAddress.PoolKey poolKey; + address payer; + } + + /// @inheritdoc IListaV3MintCallback + function listaV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata data) external override { + MintCallbackData memory decoded = abi.decode(data, (MintCallbackData)); + CallbackValidation.verifyCallback(factory, decoded.poolKey); + + if (amount0Owed > 0) pay(decoded.poolKey.token0, decoded.payer, msg.sender, amount0Owed); + if (amount1Owed > 0) pay(decoded.poolKey.token1, decoded.payer, msg.sender, amount1Owed); + } + + struct AddLiquidityParams { + address token0; + address token1; + uint24 fee; + address recipient; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + } + + /// @notice Add liquidity to an initialized pool + function addLiquidity( + AddLiquidityParams memory params + ) internal returns (uint128 liquidity, uint256 amount0, uint256 amount1, IListaV3Pool pool) { + PoolAddress.PoolKey memory poolKey = PoolAddress.PoolKey({ + token0: params.token0, + token1: params.token1, + fee: params.fee + }); + + pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey)); + + // compute the liquidity amount + { + (uint160 sqrtPriceX96, , , , , , ) = pool.slot0(); + uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(params.tickLower); + uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(params.tickUpper); + + liquidity = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + sqrtRatioAX96, + sqrtRatioBX96, + params.amount0Desired, + params.amount1Desired + ); + } + + (amount0, amount1) = pool.mint( + params.recipient, + params.tickLower, + params.tickUpper, + liquidity, + abi.encode(MintCallbackData({ poolKey: poolKey, payer: msg.sender })) + ); + + require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, "Price slippage check"); + } +} diff --git a/src/dex/v3/periphery/base/Multicall.sol b/src/dex/v3/periphery/base/Multicall.sol new file mode 100644 index 00000000..7e4e7f85 --- /dev/null +++ b/src/dex/v3/periphery/base/Multicall.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; +pragma abicoder v2; + +import "../interfaces/IMulticall.sol"; + +/// @title Multicall +/// @notice Enables calling multiple methods in a single call to the contract +abstract contract Multicall is IMulticall { + /// @inheritdoc IMulticall + function multicall(bytes[] calldata data) public payable override returns (bytes[] memory results) { + results = new bytes[](data.length); + for (uint256 i = 0; i < data.length; i++) { + (bool success, bytes memory result) = address(this).delegatecall(data[i]); + + if (!success) { + // Next 5 lines from https://ethereum.stackexchange.com/a/83577 + if (result.length < 68) revert(); + assembly { + result := add(result, 0x04) + } + revert(abi.decode(result, (string))); + } + + results[i] = result; + } + } +} diff --git a/src/dex/v3/periphery/base/PeripheryImmutableState.sol b/src/dex/v3/periphery/base/PeripheryImmutableState.sol new file mode 100644 index 00000000..76944907 --- /dev/null +++ b/src/dex/v3/periphery/base/PeripheryImmutableState.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +import "../interfaces/IPeripheryImmutableState.sol"; + +/// @title Immutable state +/// @notice Immutable state used by periphery contracts +abstract contract PeripheryImmutableState is IPeripheryImmutableState { + /// @inheritdoc IPeripheryImmutableState + address public immutable override factory; + /// @inheritdoc IPeripheryImmutableState + address public immutable override WETH9; + + constructor(address _factory, address _WETH9) { + factory = _factory; + WETH9 = _WETH9; + } +} diff --git a/src/dex/v3/periphery/base/PeripheryPayments.sol b/src/dex/v3/periphery/base/PeripheryPayments.sol new file mode 100644 index 00000000..06c74c5f --- /dev/null +++ b/src/dex/v3/periphery/base/PeripheryPayments.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import "../interfaces/IPeripheryPayments.sol"; +import "../interfaces/external/IWETH9.sol"; + +import "../libraries/TransferHelper.sol"; + +import "./PeripheryImmutableState.sol"; + +abstract contract PeripheryPayments is IPeripheryPayments, PeripheryImmutableState { + receive() external payable { + require(msg.sender == WETH9, "Not WETH9"); + } + + /// @inheritdoc IPeripheryPayments + function unwrapWETH9(uint256 amountMinimum, address recipient) public payable override { + uint256 balanceWETH9 = IWETH9(WETH9).balanceOf(address(this)); + require(balanceWETH9 >= amountMinimum, "Insufficient WETH9"); + + if (balanceWETH9 > 0) { + IWETH9(WETH9).withdraw(balanceWETH9); + TransferHelper.safeTransferETH(recipient, balanceWETH9); + } + } + + /// @inheritdoc IPeripheryPayments + function sweepToken(address token, uint256 amountMinimum, address recipient) public payable override { + uint256 balanceToken = IERC20(token).balanceOf(address(this)); + require(balanceToken >= amountMinimum, "Insufficient token"); + + if (balanceToken > 0) { + TransferHelper.safeTransfer(token, recipient, balanceToken); + } + } + + /// @inheritdoc IPeripheryPayments + function refundETH() external payable override { + if (address(this).balance > 0) TransferHelper.safeTransferETH(msg.sender, address(this).balance); + } + + /// @param token The token to pay + /// @param payer The entity that must pay + /// @param recipient The entity that will receive payment + /// @param value The amount to pay + function pay(address token, address payer, address recipient, uint256 value) internal { + if (token == WETH9 && address(this).balance >= value) { + // pay with WETH9 + IWETH9(WETH9).deposit{ value: value }(); // wrap only what is needed to pay + IWETH9(WETH9).transfer(recipient, value); + } else if (payer == address(this)) { + // pay with tokens already in the contract (for the exact input multihop case) + TransferHelper.safeTransfer(token, recipient, value); + } else { + // pull payment + TransferHelper.safeTransferFrom(token, payer, recipient, value); + } + } +} diff --git a/src/dex/v3/periphery/base/PeripheryPaymentsWithFee.sol b/src/dex/v3/periphery/base/PeripheryPaymentsWithFee.sol new file mode 100644 index 00000000..0ca766ac --- /dev/null +++ b/src/dex/v3/periphery/base/PeripheryPaymentsWithFee.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import "./PeripheryPayments.sol"; +import "../interfaces/IPeripheryPaymentsWithFee.sol"; + +import "../interfaces/external/IWETH9.sol"; +import "../libraries/TransferHelper.sol"; + +abstract contract PeripheryPaymentsWithFee is PeripheryPayments, IPeripheryPaymentsWithFee { + /// @inheritdoc IPeripheryPaymentsWithFee + function unwrapWETH9WithFee( + uint256 amountMinimum, + address recipient, + uint256 feeBips, + address feeRecipient + ) public payable override { + require(feeBips > 0 && feeBips <= 100); + + uint256 balanceWETH9 = IWETH9(WETH9).balanceOf(address(this)); + require(balanceWETH9 >= amountMinimum, "Insufficient WETH9"); + + if (balanceWETH9 > 0) { + IWETH9(WETH9).withdraw(balanceWETH9); + uint256 feeAmount = (balanceWETH9 * feeBips) / 10_000; + if (feeAmount > 0) TransferHelper.safeTransferETH(feeRecipient, feeAmount); + TransferHelper.safeTransferETH(recipient, balanceWETH9 - feeAmount); + } + } + + /// @inheritdoc IPeripheryPaymentsWithFee + function sweepTokenWithFee( + address token, + uint256 amountMinimum, + address recipient, + uint256 feeBips, + address feeRecipient + ) public payable override { + require(feeBips > 0 && feeBips <= 100); + + uint256 balanceToken = IERC20(token).balanceOf(address(this)); + require(balanceToken >= amountMinimum, "Insufficient token"); + + if (balanceToken > 0) { + uint256 feeAmount = (balanceToken * feeBips) / 10_000; + if (feeAmount > 0) TransferHelper.safeTransfer(token, feeRecipient, feeAmount); + TransferHelper.safeTransfer(token, recipient, balanceToken - feeAmount); + } + } +} diff --git a/src/dex/v3/periphery/base/PeripheryValidation.sol b/src/dex/v3/periphery/base/PeripheryValidation.sol new file mode 100644 index 00000000..8b7f804c --- /dev/null +++ b/src/dex/v3/periphery/base/PeripheryValidation.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +import "./BlockTimestamp.sol"; + +abstract contract PeripheryValidation is BlockTimestamp { + modifier checkDeadline(uint256 deadline) { + require(_blockTimestamp() <= deadline, "Transaction too old"); + _; + } +} diff --git a/src/dex/v3/periphery/base/PoolInitializer.sol b/src/dex/v3/periphery/base/PoolInitializer.sol new file mode 100644 index 00000000..286e9111 --- /dev/null +++ b/src/dex/v3/periphery/base/PoolInitializer.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +import "../../core/interfaces/IListaV3Factory.sol"; +import "../../core/interfaces/IListaV3Pool.sol"; + +import "./PeripheryImmutableState.sol"; +import "../interfaces/IPoolInitializer.sol"; + +/// @title Creates and initializes V3 Pools +abstract contract PoolInitializer is IPoolInitializer, PeripheryImmutableState { + /// @inheritdoc IPoolInitializer + function createAndInitializePoolIfNecessary( + address token0, + address token1, + uint24 fee, + uint160 sqrtPriceX96 + ) external payable override returns (address pool) { + require(token0 < token1); + pool = IListaV3Factory(factory).getPool(token0, token1, fee); + + if (pool == address(0)) { + pool = IListaV3Factory(factory).createPool(token0, token1, fee); + IListaV3Pool(pool).initialize(sqrtPriceX96); + } else { + (uint160 sqrtPriceX96Existing, , , , , , ) = IListaV3Pool(pool).slot0(); + if (sqrtPriceX96Existing == 0) { + IListaV3Pool(pool).initialize(sqrtPriceX96); + } + } + } +} diff --git a/src/dex/v3/periphery/base/SelfPermit.sol b/src/dex/v3/periphery/base/SelfPermit.sol new file mode 100644 index 00000000..66a6f97d --- /dev/null +++ b/src/dex/v3/periphery/base/SelfPermit.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; + +import "../interfaces/ISelfPermit.sol"; +import "../interfaces/external/IERC20PermitAllowed.sol"; + +/// @title Self Permit +/// @notice Functionality to call permit on any EIP-2612-compliant token for use in the route +/// @dev These functions are expected to be embedded in multicalls to allow EOAs to approve a contract and call a function +/// that requires an approval in a single transaction. +abstract contract SelfPermit is ISelfPermit { + /// @inheritdoc ISelfPermit + function selfPermit( + address token, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public payable override { + IERC20Permit(token).permit(msg.sender, address(this), value, deadline, v, r, s); + } + + /// @inheritdoc ISelfPermit + function selfPermitIfNecessary( + address token, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external payable override { + if (IERC20(token).allowance(msg.sender, address(this)) < value) selfPermit(token, value, deadline, v, r, s); + } + + /// @inheritdoc ISelfPermit + function selfPermitAllowed( + address token, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) public payable override { + IERC20PermitAllowed(token).permit(msg.sender, address(this), nonce, expiry, true, v, r, s); + } + + /// @inheritdoc ISelfPermit + function selfPermitAllowedIfNecessary( + address token, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external payable override { + if (IERC20(token).allowance(msg.sender, address(this)) < type(uint256).max) + selfPermitAllowed(token, nonce, expiry, v, r, s); + } +} diff --git a/src/dex/v3/periphery/interfaces/IERC20Metadata.sol b/src/dex/v3/periphery/interfaces/IERC20Metadata.sol new file mode 100644 index 00000000..f1798a41 --- /dev/null +++ b/src/dex/v3/periphery/interfaces/IERC20Metadata.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title IERC20Metadata +/// @title Interface for ERC20 Metadata +/// @notice Extension to IERC20 that includes token metadata +interface IERC20Metadata is IERC20 { + /// @return The name of the token + function name() external view returns (string memory); + + /// @return The symbol of the token + function symbol() external view returns (string memory); + + /// @return The number of decimal places the token has + function decimals() external view returns (uint8); +} diff --git a/src/dex/v3/periphery/interfaces/IERC721Permit.sol b/src/dex/v3/periphery/interfaces/IERC721Permit.sol new file mode 100644 index 00000000..9cc0d5e0 --- /dev/null +++ b/src/dex/v3/periphery/interfaces/IERC721Permit.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; + +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +/// @title ERC721 with permit +/// @notice Extension to ERC721 that includes a permit function for signature based approvals +interface IERC721Permit is IERC721 { + /// @notice The permit typehash used in the permit signature + /// @return The typehash for the permit + function PERMIT_TYPEHASH() external pure returns (bytes32); + + /// @notice The domain separator used in the permit signature + /// @return The domain seperator used in encoding of permit signature + function DOMAIN_SEPARATOR() external view returns (bytes32); + + /// @notice Approve of a specific token ID for spending by spender via signature + /// @param spender The account that is being approved + /// @param tokenId The ID of the token that is being approved for spending + /// @param deadline The deadline timestamp by which the call must be mined for the approve to work + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function permit(address spender, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external payable; +} diff --git a/src/dex/v3/periphery/interfaces/IMulticall.sol b/src/dex/v3/periphery/interfaces/IMulticall.sol new file mode 100644 index 00000000..32ee7ba8 --- /dev/null +++ b/src/dex/v3/periphery/interfaces/IMulticall.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +/// @title Multicall interface +/// @notice Enables calling multiple methods in a single call to the contract +interface IMulticall { + /// @notice Call multiple functions in the current contract and return the data from all of them if they all succeed + /// @dev The `msg.value` should not be trusted for any method callable from multicall. + /// @param data The encoded function data for each of the calls to make to this contract + /// @return results The results from each of the calls passed in via data + function multicall(bytes[] calldata data) external payable returns (bytes[] memory results); +} diff --git a/src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol b/src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol new file mode 100644 index 00000000..5ebd4617 --- /dev/null +++ b/src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +import "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol"; + +import "./IPoolInitializer.sol"; +import "./IERC721Permit.sol"; +import "./IPeripheryPayments.sol"; +import "./IPeripheryImmutableState.sol"; +import "../libraries/PoolAddress.sol"; + +/// @title Non-fungible token for positions +/// @notice Wraps Lista V3 positions in a non-fungible token interface which allows for them to be transferred +/// and authorized. +interface INonfungiblePositionManager is + IPoolInitializer, + IPeripheryPayments, + IPeripheryImmutableState, + IERC721Metadata, + IERC721Enumerable, + IERC721Permit +{ + /// @notice Emitted when liquidity is increased for a position NFT + /// @dev Also emitted when a token is minted + /// @param tokenId The ID of the token for which liquidity was increased + /// @param liquidity The amount by which liquidity for the NFT position was increased + /// @param amount0 The amount of token0 that was paid for the increase in liquidity + /// @param amount1 The amount of token1 that was paid for the increase in liquidity + event IncreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + /// @notice Emitted when liquidity is decreased for a position NFT + /// @param tokenId The ID of the token for which liquidity was decreased + /// @param liquidity The amount by which liquidity for the NFT position was decreased + /// @param amount0 The amount of token0 that was accounted for the decrease in liquidity + /// @param amount1 The amount of token1 that was accounted for the decrease in liquidity + event DecreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + /// @notice Emitted when tokens are collected for a position NFT + /// @dev The amounts reported may not be exactly equivalent to the amounts transferred, due to rounding behavior + /// @param tokenId The ID of the token for which underlying tokens were collected + /// @param recipient The address of the account that received the collected tokens + /// @param amount0 The amount of token0 owed to the position that was collected + /// @param amount1 The amount of token1 owed to the position that was collected + event Collect(uint256 indexed tokenId, address recipient, uint256 amount0, uint256 amount1); + + /// @notice Returns the position information associated with a given token ID. + /// @dev Throws if the token ID is not valid. + /// @param tokenId The ID of the token that represents the position + /// @return nonce The nonce for permits + /// @return operator The address that is approved for spending + /// @return token0 The address of the token0 for a specific pool + /// @return token1 The address of the token1 for a specific pool + /// @return fee The fee associated with the pool + /// @return tickLower The lower end of the tick range for the position + /// @return tickUpper The higher end of the tick range for the position + /// @return liquidity The liquidity of the position + /// @return feeGrowthInside0LastX128 The fee growth of token0 as of the last action on the individual position + /// @return feeGrowthInside1LastX128 The fee growth of token1 as of the last action on the individual position + /// @return tokensOwed0 The uncollected amount of token0 owed to the position as of the last computation + /// @return tokensOwed1 The uncollected amount of token1 owed to the position as of the last computation + 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 + /// @dev Call this when the pool does exist and is initialized. Note that if the pool is created but not initialized + /// a method does not exist, i.e. the pool is assumed to be initialized. + /// @param params The params necessary to mint a position, encoded as `MintParams` in calldata + /// @return tokenId The ID of the token that represents the minted position + /// @return liquidity The amount of liquidity for this position + /// @return amount0 The amount of token0 + /// @return amount1 The amount of token1 + 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 the `msg.sender` + /// @param params tokenId The ID of the token for which liquidity is being increased, + /// amount0Desired The desired amount of token0 to be spent, + /// amount1Desired The desired amount of token1 to be spent, + /// amount0Min The minimum amount of token0 to spend, which serves as a slippage check, + /// amount1Min The minimum amount of token1 to spend, which serves as a slippage check, + /// deadline The time by which the transaction must be included to effect the change + /// @return liquidity The new liquidity amount as a result of the increase + /// @return amount0 The amount of token0 to acheive resulting liquidity + /// @return amount1 The amount of token1 to acheive resulting liquidity + 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 + /// @param params tokenId The ID of the token for which liquidity is being decreased, + /// amount The amount by which liquidity will be decreased, + /// amount0Min The minimum amount of token0 that should be accounted for the burned liquidity, + /// amount1Min The minimum amount of token1 that should be accounted for the burned liquidity, + /// deadline The time by which the transaction must be included to effect the change + /// @return amount0 The amount of token0 accounted to the position's tokens owed + /// @return amount1 The amount of token1 accounted to the position's tokens owed + 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 + /// @param params tokenId The ID of the NFT for which tokens are being collected, + /// recipient The account that should receive the tokens, + /// amount0Max The maximum amount of token0 to collect, + /// amount1Max The maximum amount of token1 to collect + /// @return amount0 The amount of fees collected in token0 + /// @return amount1 The amount of fees collected in token1 + 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. + /// @param tokenId The ID of the token that is being burned + function burn(uint256 tokenId) external payable; +} diff --git a/src/dex/v3/periphery/interfaces/INonfungibleTokenPositionDescriptor.sol b/src/dex/v3/periphery/interfaces/INonfungibleTokenPositionDescriptor.sol new file mode 100644 index 00000000..1abcaeb8 --- /dev/null +++ b/src/dex/v3/periphery/interfaces/INonfungibleTokenPositionDescriptor.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +import "./INonfungiblePositionManager.sol"; + +/// @title Describes position NFT tokens via URI +interface INonfungibleTokenPositionDescriptor { + /// @notice Produces the URI describing a particular token ID for a position manager + /// @dev Note this URI may be a data: URI with the JSON contents directly inlined + /// @param positionManager The position manager for which to describe the token + /// @param tokenId The ID of the token for which to produce a description, which may not be valid + /// @return The URI of the ERC721-compliant metadata + function tokenURI(INonfungiblePositionManager positionManager, uint256 tokenId) external view returns (string memory); +} diff --git a/src/dex/v3/periphery/interfaces/IPeripheryImmutableState.sol b/src/dex/v3/periphery/interfaces/IPeripheryImmutableState.sol new file mode 100644 index 00000000..94acc028 --- /dev/null +++ b/src/dex/v3/periphery/interfaces/IPeripheryImmutableState.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Immutable state +/// @notice Functions that return immutable state of the router +interface IPeripheryImmutableState { + /// @return Returns the address of the Lista V3 factory + function factory() external view returns (address); + + /// @return Returns the address of WETH9 + function WETH9() external view returns (address); +} diff --git a/src/dex/v3/periphery/interfaces/IPeripheryPayments.sol b/src/dex/v3/periphery/interfaces/IPeripheryPayments.sol new file mode 100644 index 00000000..fc424e52 --- /dev/null +++ b/src/dex/v3/periphery/interfaces/IPeripheryPayments.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; + +/// @title Periphery Payments +/// @notice Functions to ease deposits and withdrawals of ETH +interface IPeripheryPayments { + /// @notice Unwraps the contract's WETH9 balance and sends it to recipient as ETH. + /// @dev The amountMinimum parameter prevents malicious contracts from stealing WETH9 from users. + /// @param amountMinimum The minimum amount of WETH9 to unwrap + /// @param recipient The address receiving ETH + function unwrapWETH9(uint256 amountMinimum, address recipient) external payable; + + /// @notice Refunds any ETH balance held by this contract to the `msg.sender` + /// @dev Useful for bundling with mint or increase liquidity that uses ether, or exact output swaps + /// that use ether for the input amount + function refundETH() external payable; + + /// @notice Transfers the full amount of a token held by this contract to recipient + /// @dev The amountMinimum parameter prevents malicious contracts from stealing the token from users + /// @param token The contract address of the token which will be transferred to `recipient` + /// @param amountMinimum The minimum amount of token required for a transfer + /// @param recipient The destination address of the token + function sweepToken(address token, uint256 amountMinimum, address recipient) external payable; +} diff --git a/src/dex/v3/periphery/interfaces/IPeripheryPaymentsWithFee.sol b/src/dex/v3/periphery/interfaces/IPeripheryPaymentsWithFee.sol new file mode 100644 index 00000000..85a70d8e --- /dev/null +++ b/src/dex/v3/periphery/interfaces/IPeripheryPaymentsWithFee.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; + +import "./IPeripheryPayments.sol"; + +/// @title Periphery Payments +/// @notice Functions to ease deposits and withdrawals of ETH +interface IPeripheryPaymentsWithFee is IPeripheryPayments { + /// @notice Unwraps the contract's WETH9 balance and sends it to recipient as ETH, with a percentage between + /// 0 (exclusive), and 1 (inclusive) going to feeRecipient + /// @dev The amountMinimum parameter prevents malicious contracts from stealing WETH9 from users. + function unwrapWETH9WithFee( + uint256 amountMinimum, + address recipient, + uint256 feeBips, + address feeRecipient + ) external payable; + + /// @notice Transfers the full amount of a token held by this contract to recipient, with a percentage between + /// 0 (exclusive) and 1 (inclusive) going to feeRecipient + /// @dev The amountMinimum parameter prevents malicious contracts from stealing the token from users + function sweepTokenWithFee( + address token, + uint256 amountMinimum, + address recipient, + uint256 feeBips, + address feeRecipient + ) external payable; +} diff --git a/src/dex/v3/periphery/interfaces/IPoolInitializer.sol b/src/dex/v3/periphery/interfaces/IPoolInitializer.sol new file mode 100644 index 00000000..ba0ebace --- /dev/null +++ b/src/dex/v3/periphery/interfaces/IPoolInitializer.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +/// @title Creates and initializes V3 Pools +/// @notice Provides a method for creating and initializing a pool, if necessary, for bundling with other methods that +/// require the pool to exist. +interface IPoolInitializer { + /// @notice Creates a new pool if it does not exist, then initializes if not initialized + /// @dev This method can be bundled with others via IMulticall for the first action (e.g. mint) performed against a pool + /// @param token0 The contract address of token0 of the pool + /// @param token1 The contract address of token1 of the pool + /// @param fee The fee amount of the v3 pool for the specified token pair + /// @param sqrtPriceX96 The initial square root price of the pool as a Q64.96 value + /// @return pool Returns the pool address based on the pair of tokens and fee, will return the newly created pool address if necessary + function createAndInitializePoolIfNecessary( + address token0, + address token1, + uint24 fee, + uint160 sqrtPriceX96 + ) external payable returns (address pool); +} diff --git a/src/dex/v3/periphery/interfaces/IQuoter.sol b/src/dex/v3/periphery/interfaces/IQuoter.sol new file mode 100644 index 00000000..48b78e70 --- /dev/null +++ b/src/dex/v3/periphery/interfaces/IQuoter.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +/// @title Quoter Interface +/// @notice Supports quoting the calculated amounts from exact input or exact output swaps +/// @dev These functions are not marked view because they rely on calling non-view functions and reverting +/// to compute the result. They are also not gas efficient and should not be called on-chain. +interface IQuoter { + /// @notice Returns the amount out received for a given exact input swap without executing the swap + /// @param path The path of the swap, i.e. each token pair and the pool fee + /// @param amountIn The amount of the first token to swap + /// @return amountOut The amount of the last token that would be received + function quoteExactInput(bytes memory path, uint256 amountIn) external returns (uint256 amountOut); + + /// @notice Returns the amount out received for a given exact input but for a swap of a single pool + /// @param tokenIn The token being swapped in + /// @param tokenOut The token being swapped out + /// @param fee The fee of the token pool to consider for the pair + /// @param amountIn The desired input amount + /// @param sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + /// @return amountOut The amount of `tokenOut` that would be received + function quoteExactInputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint160 sqrtPriceLimitX96 + ) external returns (uint256 amountOut); + + /// @notice Returns the amount in required for a given exact output swap without executing the swap + /// @param path The path of the swap, i.e. each token pair and the pool fee. Path must be provided in reverse order + /// @param amountOut The amount of the last token to receive + /// @return amountIn The amount of first token required to be paid + function quoteExactOutput(bytes memory path, uint256 amountOut) external returns (uint256 amountIn); + + /// @notice Returns the amount in required to receive the given exact output amount but for a swap of a single pool + /// @param tokenIn The token being swapped in + /// @param tokenOut The token being swapped out + /// @param fee The fee of the token pool to consider for the pair + /// @param amountOut The desired output amount + /// @param sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + /// @return amountIn The amount required as the input for the swap in order to receive `amountOut` + function quoteExactOutputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountOut, + uint160 sqrtPriceLimitX96 + ) external returns (uint256 amountIn); +} diff --git a/src/dex/v3/periphery/interfaces/IQuoterV2.sol b/src/dex/v3/periphery/interfaces/IQuoterV2.sol new file mode 100644 index 00000000..b3e7ed7c --- /dev/null +++ b/src/dex/v3/periphery/interfaces/IQuoterV2.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +/// @title QuoterV2 Interface +/// @notice Supports quoting the calculated amounts from exact input or exact output swaps. +/// @notice For each pool also tells you the number of initialized ticks crossed and the sqrt price of the pool after the swap. +/// @dev These functions are not marked view because they rely on calling non-view functions and reverting +/// to compute the result. They are also not gas efficient and should not be called on-chain. +interface IQuoterV2 { + /// @notice Returns the amount out received for a given exact input swap without executing the swap + /// @param path The path of the swap, i.e. each token pair and the pool fee + /// @param amountIn The amount of the first token to swap + /// @return amountOut The amount of the last token that would be received + /// @return sqrtPriceX96AfterList List of the sqrt price after the swap for each pool in the path + /// @return initializedTicksCrossedList List of the initialized ticks that the swap crossed for each pool in the path + /// @return gasEstimate The estimate of the gas that the swap consumes + function quoteExactInput( + bytes memory path, + uint256 amountIn + ) + external + returns ( + uint256 amountOut, + uint160[] memory sqrtPriceX96AfterList, + uint32[] memory initializedTicksCrossedList, + uint256 gasEstimate + ); + + struct QuoteExactInputSingleParams { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint24 fee; + uint160 sqrtPriceLimitX96; + } + + /// @notice Returns the amount out received for a given exact input but for a swap of a single pool + /// @param params The params for the quote, encoded as `QuoteExactInputSingleParams` + /// tokenIn The token being swapped in + /// tokenOut The token being swapped out + /// fee The fee of the token pool to consider for the pair + /// amountIn The desired input amount + /// sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + /// @return amountOut The amount of `tokenOut` that would be received + /// @return sqrtPriceX96After The sqrt price of the pool after the swap + /// @return initializedTicksCrossed The number of initialized ticks that the swap crossed + /// @return gasEstimate The estimate of the gas that the swap consumes + function quoteExactInputSingle( + QuoteExactInputSingleParams memory params + ) + external + returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate); + + /// @notice Returns the amount in required for a given exact output swap without executing the swap + /// @param path The path of the swap, i.e. each token pair and the pool fee. Path must be provided in reverse order + /// @param amountOut The amount of the last token to receive + /// @return amountIn The amount of first token required to be paid + /// @return sqrtPriceX96AfterList List of the sqrt price after the swap for each pool in the path + /// @return initializedTicksCrossedList List of the initialized ticks that the swap crossed for each pool in the path + /// @return gasEstimate The estimate of the gas that the swap consumes + function quoteExactOutput( + bytes memory path, + uint256 amountOut + ) + external + returns ( + uint256 amountIn, + uint160[] memory sqrtPriceX96AfterList, + uint32[] memory initializedTicksCrossedList, + uint256 gasEstimate + ); + + struct QuoteExactOutputSingleParams { + address tokenIn; + address tokenOut; + uint256 amount; + uint24 fee; + uint160 sqrtPriceLimitX96; + } + + /// @notice Returns the amount in required to receive the given exact output amount but for a swap of a single pool + /// @param params The params for the quote, encoded as `QuoteExactOutputSingleParams` + /// tokenIn The token being swapped in + /// tokenOut The token being swapped out + /// fee The fee of the token pool to consider for the pair + /// amountOut The desired output amount + /// sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + /// @return amountIn The amount required as the input for the swap in order to receive `amountOut` + /// @return sqrtPriceX96After The sqrt price of the pool after the swap + /// @return initializedTicksCrossed The number of initialized ticks that the swap crossed + /// @return gasEstimate The estimate of the gas that the swap consumes + function quoteExactOutputSingle( + QuoteExactOutputSingleParams memory params + ) external returns (uint256 amountIn, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate); +} diff --git a/src/dex/v3/periphery/interfaces/ISelfPermit.sol b/src/dex/v3/periphery/interfaces/ISelfPermit.sol new file mode 100644 index 00000000..f7eee60b --- /dev/null +++ b/src/dex/v3/periphery/interfaces/ISelfPermit.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; + +/// @title Self Permit +/// @notice Functionality to call permit on any EIP-2612-compliant token for use in the route +interface ISelfPermit { + /// @notice Permits this contract to spend a given token from `msg.sender` + /// @dev The `owner` is always msg.sender and the `spender` is always address(this). + /// @param token The address of the token spent + /// @param value The amount that can be spent of token + /// @param deadline A timestamp, the current blocktime must be less than or equal to this timestamp + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function selfPermit(address token, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external payable; + + /// @notice Permits this contract to spend a given token from `msg.sender` + /// @dev The `owner` is always msg.sender and the `spender` is always address(this). + /// Can be used instead of #selfPermit to prevent calls from failing due to a frontrun of a call to #selfPermit + /// @param token The address of the token spent + /// @param value The amount that can be spent of token + /// @param deadline A timestamp, the current blocktime must be less than or equal to this timestamp + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function selfPermitIfNecessary( + address token, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external payable; + + /// @notice Permits this contract to spend the sender's tokens for permit signatures that have the `allowed` parameter + /// @dev The `owner` is always msg.sender and the `spender` is always address(this) + /// @param token The address of the token spent + /// @param nonce The current nonce of the owner + /// @param expiry The timestamp at which the permit is no longer valid + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function selfPermitAllowed( + address token, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external payable; + + /// @notice Permits this contract to spend the sender's tokens for permit signatures that have the `allowed` parameter + /// @dev The `owner` is always msg.sender and the `spender` is always address(this) + /// Can be used instead of #selfPermitAllowed to prevent calls from failing due to a frontrun of a call to #selfPermitAllowed. + /// @param token The address of the token spent + /// @param nonce The current nonce of the owner + /// @param expiry The timestamp at which the permit is no longer valid + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function selfPermitAllowedIfNecessary( + address token, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external payable; +} diff --git a/src/dex/v3/periphery/interfaces/ISwapRouter.sol b/src/dex/v3/periphery/interfaces/ISwapRouter.sol new file mode 100644 index 00000000..924fb95d --- /dev/null +++ b/src/dex/v3/periphery/interfaces/ISwapRouter.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +import "../../core/interfaces/callback/IListaV3SwapCallback.sol"; + +/// @title Router token swapping functionality +/// @notice Functions for swapping tokens via Lista V3 +interface ISwapRouter is IListaV3SwapCallback { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + /// @notice Swaps `amountIn` of one token for as much as possible of another token + /// @param params The parameters necessary for the swap, encoded as `ExactInputSingleParams` in calldata + /// @return amountOut The amount of the received token + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); + + struct ExactInputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + } + + /// @notice Swaps `amountIn` of one token for as much as possible of another along the specified path + /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactInputParams` in calldata + /// @return amountOut The amount of the received token + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); + + struct ExactOutputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + uint160 sqrtPriceLimitX96; + } + + /// @notice Swaps as little as possible of one token for `amountOut` of another token + /// @param params The parameters necessary for the swap, encoded as `ExactOutputSingleParams` in calldata + /// @return amountIn The amount of the input token + function exactOutputSingle(ExactOutputSingleParams calldata params) external payable returns (uint256 amountIn); + + struct ExactOutputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + } + + /// @notice Swaps as little as possible of one token for `amountOut` of another along the specified path (reversed) + /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactOutputParams` in calldata + /// @return amountIn The amount of the input token + function exactOutput(ExactOutputParams calldata params) external payable returns (uint256 amountIn); +} diff --git a/src/dex/v3/periphery/interfaces/ITickLens.sol b/src/dex/v3/periphery/interfaces/ITickLens.sol new file mode 100644 index 00000000..71eda991 --- /dev/null +++ b/src/dex/v3/periphery/interfaces/ITickLens.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +/// @title Tick Lens +/// @notice Provides functions for fetching chunks of tick data for a pool +/// @dev This avoids the waterfall of fetching the tick bitmap, parsing the bitmap to know which ticks to fetch, and +/// then sending additional multicalls to fetch the tick data +interface ITickLens { + struct PopulatedTick { + int24 tick; + int128 liquidityNet; + uint128 liquidityGross; + } + + /// @notice Get all the tick data for the populated ticks from a word of the tick bitmap of a pool + /// @param pool The address of the pool for which to fetch populated tick data + /// @param tickBitmapIndex The index of the word in the tick bitmap for which to parse the bitmap and + /// fetch all the populated ticks + /// @return populatedTicks An array of tick data for the given word in the tick bitmap + function getPopulatedTicksInWord( + address pool, + int16 tickBitmapIndex + ) external view returns (PopulatedTick[] memory populatedTicks); +} diff --git a/src/dex/v3/periphery/interfaces/external/IERC1271.sol b/src/dex/v3/periphery/interfaces/external/IERC1271.sol new file mode 100644 index 00000000..6cdc487b --- /dev/null +++ b/src/dex/v3/periphery/interfaces/external/IERC1271.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Interface for verifying contract-based account signatures +/// @notice Interface that verifies provided signature for the data +/// @dev Interface defined by EIP-1271 +interface IERC1271 { + /// @notice Returns whether the provided signature is valid for the provided data + /// @dev MUST return the bytes4 magic value 0x1626ba7e when function passes. + /// MUST NOT modify state (using STATICCALL for solc < 0.5, view modifier for solc > 0.5). + /// MUST allow external calls. + /// @param hash Hash of the data to be signed + /// @param signature Signature byte array associated with _data + /// @return magicValue The bytes4 magic value 0x1626ba7e + function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4 magicValue); +} diff --git a/src/dex/v3/periphery/interfaces/external/IERC20PermitAllowed.sol b/src/dex/v3/periphery/interfaces/external/IERC20PermitAllowed.sol new file mode 100644 index 00000000..0594596c --- /dev/null +++ b/src/dex/v3/periphery/interfaces/external/IERC20PermitAllowed.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Interface for permit +/// @notice Interface used by DAI/CHAI for permit +interface IERC20PermitAllowed { + /// @notice Approve the spender to spend some tokens via the holder signature + /// @dev This is the permit interface used by DAI and CHAI + /// @param holder The address of the token holder, the token owner + /// @param spender The address of the token spender + /// @param nonce The holder's nonce, increases at each call to permit + /// @param expiry The timestamp at which the permit is no longer valid + /// @param allowed Boolean that sets approval amount, true for type(uint256).max and false for 0 + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function permit( + address holder, + address spender, + uint256 nonce, + uint256 expiry, + bool allowed, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} diff --git a/src/dex/v3/periphery/interfaces/external/IWETH9.sol b/src/dex/v3/periphery/interfaces/external/IWETH9.sol new file mode 100644 index 00000000..f9db69bb --- /dev/null +++ b/src/dex/v3/periphery/interfaces/external/IWETH9.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title Interface for WETH9 +interface IWETH9 is IERC20 { + /// @notice Deposit ether to get wrapped ether + function deposit() external payable; + + /// @notice Withdraw wrapped ether to get ether + function withdraw(uint256) external; +} diff --git a/src/dex/v3/periphery/lens/ListaInterfaceMulticall.sol b/src/dex/v3/periphery/lens/ListaInterfaceMulticall.sol new file mode 100644 index 00000000..bd881f7f --- /dev/null +++ b/src/dex/v3/periphery/lens/ListaInterfaceMulticall.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; +pragma abicoder v2; + +/// @notice A fork of Multicall2 specifically tailored for the Lista Interface +contract ListaInterfaceMulticall { + struct Call { + address target; + uint256 gasLimit; + bytes callData; + } + + struct Result { + bool success; + uint256 gasUsed; + bytes returnData; + } + + function getCurrentBlockTimestamp() public view returns (uint256 timestamp) { + timestamp = block.timestamp; + } + + function getEthBalance(address addr) public view returns (uint256 balance) { + balance = addr.balance; + } + + function multicall(Call[] memory calls) public returns (uint256 blockNumber, Result[] memory returnData) { + blockNumber = block.number; + returnData = new Result[](calls.length); + for (uint256 i = 0; i < calls.length; i++) { + (address target, uint256 gasLimit, bytes memory callData) = ( + calls[i].target, + calls[i].gasLimit, + calls[i].callData + ); + uint256 gasLeftBefore = gasleft(); + (bool success, bytes memory ret) = target.call{ gas: gasLimit }(callData); + uint256 gasUsed = gasLeftBefore - gasleft(); + returnData[i] = Result(success, gasUsed, ret); + } + } +} diff --git a/src/dex/v3/periphery/lens/Quoter.sol b/src/dex/v3/periphery/lens/Quoter.sol new file mode 100644 index 00000000..9d3e0a4b --- /dev/null +++ b/src/dex/v3/periphery/lens/Quoter.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; +pragma abicoder v2; + +import "../../core/libraries/SafeCast.sol"; +import "../../core/libraries/TickMath.sol"; +import "../../core/interfaces/IListaV3Pool.sol"; +import "../../core/interfaces/callback/IListaV3SwapCallback.sol"; + +import "../interfaces/IQuoter.sol"; +import "../base/PeripheryImmutableState.sol"; +import "../libraries/Path.sol"; +import "../libraries/PoolAddress.sol"; +import "../libraries/CallbackValidation.sol"; + +/// @title Provides quotes for swaps +/// @notice Allows getting the expected amount out or amount in for a given swap without executing the swap +/// @dev These functions are not gas efficient and should _not_ be called on chain. Instead, optimistically execute +/// the swap and check the amounts in the callback. +contract Quoter is IQuoter, IListaV3SwapCallback, PeripheryImmutableState { + using Path for bytes; + using SafeCast for uint256; + + /// @dev Transient storage variable used to check a safety condition in exact output swaps. + uint256 private amountOutCached; + + constructor(address _factory, address _WETH9) PeripheryImmutableState(_factory, _WETH9) {} + + function getPool(address tokenA, address tokenB, uint24 fee) private view returns (IListaV3Pool) { + return IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee))); + } + + /// @inheritdoc IListaV3SwapCallback + function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes memory path) external view override { + require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported + (address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool(); + CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee); + + (bool isExactInput, uint256 amountToPay, uint256 amountReceived) = amount0Delta > 0 + ? (tokenIn < tokenOut, uint256(amount0Delta), uint256(-amount1Delta)) + : (tokenOut < tokenIn, uint256(amount1Delta), uint256(-amount0Delta)); + if (isExactInput) { + assembly { + let ptr := mload(0x40) + mstore(ptr, amountReceived) + revert(ptr, 32) + } + } else { + // if the cache has been populated, ensure that the full output amount has been received + if (amountOutCached != 0) require(amountReceived == amountOutCached); + assembly { + let ptr := mload(0x40) + mstore(ptr, amountToPay) + revert(ptr, 32) + } + } + } + + /// @dev Parses a revert reason that should contain the numeric quote + function parseRevertReason(bytes memory reason) private pure returns (uint256) { + if (reason.length != 32) { + if (reason.length < 68) revert("Unexpected error"); + assembly { + reason := add(reason, 0x04) + } + revert(abi.decode(reason, (string))); + } + return abi.decode(reason, (uint256)); + } + + /// @inheritdoc IQuoter + function quoteExactInputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint160 sqrtPriceLimitX96 + ) public override returns (uint256 amountOut) { + bool zeroForOne = tokenIn < tokenOut; + + try + getPool(tokenIn, tokenOut, fee).swap( + address(this), // address(0) might cause issues with some tokens + zeroForOne, + amountIn.toInt256(), + sqrtPriceLimitX96 == 0 + ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) + : sqrtPriceLimitX96, + abi.encodePacked(tokenIn, fee, tokenOut) + ) + {} catch (bytes memory reason) { + return parseRevertReason(reason); + } + } + + /// @inheritdoc IQuoter + function quoteExactInput(bytes memory path, uint256 amountIn) external override returns (uint256 amountOut) { + while (true) { + bool hasMultiplePools = path.hasMultiplePools(); + + (address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool(); + + // the outputs of prior swaps become the inputs to subsequent ones + amountIn = quoteExactInputSingle(tokenIn, tokenOut, fee, amountIn, 0); + + // decide whether to continue or terminate + if (hasMultiplePools) { + path = path.skipToken(); + } else { + return amountIn; + } + } + } + + /// @inheritdoc IQuoter + function quoteExactOutputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountOut, + uint160 sqrtPriceLimitX96 + ) public override returns (uint256 amountIn) { + bool zeroForOne = tokenIn < tokenOut; + + // if no price limit has been specified, cache the output amount for comparison in the swap callback + if (sqrtPriceLimitX96 == 0) amountOutCached = amountOut; + try + getPool(tokenIn, tokenOut, fee).swap( + address(this), // address(0) might cause issues with some tokens + zeroForOne, + -amountOut.toInt256(), + sqrtPriceLimitX96 == 0 + ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) + : sqrtPriceLimitX96, + abi.encodePacked(tokenOut, fee, tokenIn) + ) + {} catch (bytes memory reason) { + if (sqrtPriceLimitX96 == 0) delete amountOutCached; // clear cache + return parseRevertReason(reason); + } + } + + /// @inheritdoc IQuoter + function quoteExactOutput(bytes memory path, uint256 amountOut) external override returns (uint256 amountIn) { + while (true) { + bool hasMultiplePools = path.hasMultiplePools(); + + (address tokenOut, address tokenIn, uint24 fee) = path.decodeFirstPool(); + + // the inputs of prior swaps become the outputs of subsequent ones + amountOut = quoteExactOutputSingle(tokenIn, tokenOut, fee, amountOut, 0); + + // decide whether to continue or terminate + if (hasMultiplePools) { + path = path.skipToken(); + } else { + return amountOut; + } + } + } +} diff --git a/src/dex/v3/periphery/lens/QuoterV2.sol b/src/dex/v3/periphery/lens/QuoterV2.sol new file mode 100644 index 00000000..e7440f00 --- /dev/null +++ b/src/dex/v3/periphery/lens/QuoterV2.sol @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; +pragma abicoder v2; + +import "../../core/libraries/SafeCast.sol"; +import "../../core/libraries/TickMath.sol"; +import "../../core/libraries/TickBitmap.sol"; +import "../../core/interfaces/IListaV3Pool.sol"; +import "../../core/interfaces/callback/IListaV3SwapCallback.sol"; + +import "../interfaces/IQuoterV2.sol"; +import "../base/PeripheryImmutableState.sol"; +import "../libraries/Path.sol"; +import "../libraries/PoolAddress.sol"; +import "../libraries/CallbackValidation.sol"; +import "../libraries/PoolTicksCounter.sol"; + +/// @title Provides quotes for swaps +/// @notice Allows getting the expected amount out or amount in for a given swap without executing the swap +/// @dev These functions are not gas efficient and should _not_ be called on chain. Instead, optimistically execute +/// the swap and check the amounts in the callback. +contract QuoterV2 is IQuoterV2, IListaV3SwapCallback, PeripheryImmutableState { + using Path for bytes; + using SafeCast for uint256; + using PoolTicksCounter for IListaV3Pool; + + /// @dev Transient storage variable used to check a safety condition in exact output swaps. + uint256 private amountOutCached; + + constructor(address _factory, address _WETH9) PeripheryImmutableState(_factory, _WETH9) {} + + function getPool(address tokenA, address tokenB, uint24 fee) private view returns (IListaV3Pool) { + return IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee))); + } + + /// @inheritdoc IListaV3SwapCallback + function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes memory path) external view override { + require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported + (address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool(); + CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee); + + (bool isExactInput, uint256 amountToPay, uint256 amountReceived) = amount0Delta > 0 + ? (tokenIn < tokenOut, uint256(amount0Delta), uint256(-amount1Delta)) + : (tokenOut < tokenIn, uint256(amount1Delta), uint256(-amount0Delta)); + + IListaV3Pool pool = getPool(tokenIn, tokenOut, fee); + (uint160 sqrtPriceX96After, int24 tickAfter, , , , , ) = pool.slot0(); + + if (isExactInput) { + assembly { + let ptr := mload(0x40) + mstore(ptr, amountReceived) + mstore(add(ptr, 0x20), sqrtPriceX96After) + mstore(add(ptr, 0x40), tickAfter) + revert(ptr, 96) + } + } else { + // if the cache has been populated, ensure that the full output amount has been received + if (amountOutCached != 0) require(amountReceived == amountOutCached); + assembly { + let ptr := mload(0x40) + mstore(ptr, amountToPay) + mstore(add(ptr, 0x20), sqrtPriceX96After) + mstore(add(ptr, 0x40), tickAfter) + revert(ptr, 96) + } + } + } + + /// @dev Parses a revert reason that should contain the numeric quote + function parseRevertReason( + bytes memory reason + ) private pure returns (uint256 amount, uint160 sqrtPriceX96After, int24 tickAfter) { + if (reason.length != 96) { + if (reason.length < 68) revert("Unexpected error"); + assembly { + reason := add(reason, 0x04) + } + revert(abi.decode(reason, (string))); + } + return abi.decode(reason, (uint256, uint160, int24)); + } + + function handleRevert( + bytes memory reason, + IListaV3Pool pool, + uint256 gasEstimate + ) private view returns (uint256 amount, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256) { + int24 tickBefore; + int24 tickAfter; + (, tickBefore, , , , , ) = pool.slot0(); + (amount, sqrtPriceX96After, tickAfter) = parseRevertReason(reason); + + initializedTicksCrossed = pool.countInitializedTicksCrossed(tickBefore, tickAfter); + + return (amount, sqrtPriceX96After, initializedTicksCrossed, gasEstimate); + } + + function quoteExactInputSingle( + QuoteExactInputSingleParams memory params + ) + public + override + returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate) + { + bool zeroForOne = params.tokenIn < params.tokenOut; + IListaV3Pool pool = getPool(params.tokenIn, params.tokenOut, params.fee); + + uint256 gasBefore = gasleft(); + try + pool.swap( + address(this), // address(0) might cause issues with some tokens + zeroForOne, + params.amountIn.toInt256(), + params.sqrtPriceLimitX96 == 0 + ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) + : params.sqrtPriceLimitX96, + abi.encodePacked(params.tokenIn, params.fee, params.tokenOut) + ) + {} catch (bytes memory reason) { + gasEstimate = gasBefore - gasleft(); + return handleRevert(reason, pool, gasEstimate); + } + } + + function quoteExactInput( + bytes memory path, + uint256 amountIn + ) + public + override + returns ( + uint256 amountOut, + uint160[] memory sqrtPriceX96AfterList, + uint32[] memory initializedTicksCrossedList, + uint256 gasEstimate + ) + { + sqrtPriceX96AfterList = new uint160[](path.numPools()); + initializedTicksCrossedList = new uint32[](path.numPools()); + + uint256 i = 0; + while (true) { + (address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool(); + + // the outputs of prior swaps become the inputs to subsequent ones + ( + uint256 _amountOut, + uint160 _sqrtPriceX96After, + uint32 _initializedTicksCrossed, + uint256 _gasEstimate + ) = quoteExactInputSingle( + QuoteExactInputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + fee: fee, + amountIn: amountIn, + sqrtPriceLimitX96: 0 + }) + ); + + sqrtPriceX96AfterList[i] = _sqrtPriceX96After; + initializedTicksCrossedList[i] = _initializedTicksCrossed; + amountIn = _amountOut; + gasEstimate += _gasEstimate; + i++; + + // decide whether to continue or terminate + if (path.hasMultiplePools()) { + path = path.skipToken(); + } else { + return (amountIn, sqrtPriceX96AfterList, initializedTicksCrossedList, gasEstimate); + } + } + } + + function quoteExactOutputSingle( + QuoteExactOutputSingleParams memory params + ) + public + override + returns (uint256 amountIn, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate) + { + bool zeroForOne = params.tokenIn < params.tokenOut; + IListaV3Pool pool = getPool(params.tokenIn, params.tokenOut, params.fee); + + // if no price limit has been specified, cache the output amount for comparison in the swap callback + if (params.sqrtPriceLimitX96 == 0) amountOutCached = params.amount; + uint256 gasBefore = gasleft(); + try + pool.swap( + address(this), // address(0) might cause issues with some tokens + zeroForOne, + -params.amount.toInt256(), + params.sqrtPriceLimitX96 == 0 + ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) + : params.sqrtPriceLimitX96, + abi.encodePacked(params.tokenOut, params.fee, params.tokenIn) + ) + {} catch (bytes memory reason) { + gasEstimate = gasBefore - gasleft(); + if (params.sqrtPriceLimitX96 == 0) delete amountOutCached; // clear cache + return handleRevert(reason, pool, gasEstimate); + } + } + + function quoteExactOutput( + bytes memory path, + uint256 amountOut + ) + public + override + returns ( + uint256 amountIn, + uint160[] memory sqrtPriceX96AfterList, + uint32[] memory initializedTicksCrossedList, + uint256 gasEstimate + ) + { + sqrtPriceX96AfterList = new uint160[](path.numPools()); + initializedTicksCrossedList = new uint32[](path.numPools()); + + uint256 i = 0; + while (true) { + (address tokenOut, address tokenIn, uint24 fee) = path.decodeFirstPool(); + + // the inputs of prior swaps become the outputs of subsequent ones + ( + uint256 _amountIn, + uint160 _sqrtPriceX96After, + uint32 _initializedTicksCrossed, + uint256 _gasEstimate + ) = quoteExactOutputSingle( + QuoteExactOutputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + amount: amountOut, + fee: fee, + sqrtPriceLimitX96: 0 + }) + ); + + sqrtPriceX96AfterList[i] = _sqrtPriceX96After; + initializedTicksCrossedList[i] = _initializedTicksCrossed; + amountOut = _amountIn; + gasEstimate += _gasEstimate; + i++; + + // decide whether to continue or terminate + if (path.hasMultiplePools()) { + path = path.skipToken(); + } else { + return (amountOut, sqrtPriceX96AfterList, initializedTicksCrossedList, gasEstimate); + } + } + } +} diff --git a/src/dex/v3/periphery/lens/README.md b/src/dex/v3/periphery/lens/README.md new file mode 100644 index 00000000..8359711f --- /dev/null +++ b/src/dex/v3/periphery/lens/README.md @@ -0,0 +1,4 @@ +# lens + +These contracts are not designed to be called on-chain. They simplify +fetching on-chain data from off-chain. diff --git a/src/dex/v3/periphery/lens/TickLens.sol b/src/dex/v3/periphery/lens/TickLens.sol new file mode 100644 index 00000000..2936ec70 --- /dev/null +++ b/src/dex/v3/periphery/lens/TickLens.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; +pragma abicoder v2; + +import "../../core/interfaces/IListaV3Pool.sol"; + +import "../interfaces/ITickLens.sol"; + +/// @title Tick Lens contract +contract TickLens is ITickLens { + /// @inheritdoc ITickLens + function getPopulatedTicksInWord( + address pool, + int16 tickBitmapIndex + ) public view override returns (PopulatedTick[] memory populatedTicks) { + // fetch bitmap + uint256 bitmap = IListaV3Pool(pool).tickBitmap(tickBitmapIndex); + unchecked { + // calculate the number of populated ticks + uint256 numberOfPopulatedTicks; + for (uint256 i = 0; i < 256; i++) { + if (bitmap & (1 << i) > 0) numberOfPopulatedTicks++; + } + + // fetch populated tick data + int24 tickSpacing = IListaV3Pool(pool).tickSpacing(); + populatedTicks = new PopulatedTick[](numberOfPopulatedTicks); + for (uint256 i = 0; i < 256; i++) { + if (bitmap & (1 << i) > 0) { + int24 populatedTick = ((int24(tickBitmapIndex) << 8) + int24(uint24(i))) * tickSpacing; + (uint128 liquidityGross, int128 liquidityNet, , , , , , ) = IListaV3Pool(pool).ticks(populatedTick); + populatedTicks[--numberOfPopulatedTicks] = PopulatedTick({ + tick: populatedTick, + liquidityNet: liquidityNet, + liquidityGross: liquidityGross + }); + } + } + } + } +} diff --git a/src/dex/v3/periphery/libraries/AddressStringUtil.sol b/src/dex/v3/periphery/libraries/AddressStringUtil.sol new file mode 100644 index 00000000..ba4bc215 --- /dev/null +++ b/src/dex/v3/periphery/libraries/AddressStringUtil.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// from https://github.com/Lista/solidity-lib/blob/master/contracts/libraries/AddressStringUtil.sol +// modified for solidity 0.8 + +pragma solidity 0.8.34; + +library AddressStringUtil { + // converts an address to the uppercase hex string, extracting only len bytes (up to 20, multiple of 2) + function toAsciiString(address addr, uint256 len) internal pure returns (string memory) { + require(len % 2 == 0 && len > 0 && len <= 40, "AddressStringUtil: INVALID_LEN"); + + bytes memory s = new bytes(len); + uint256 addrNum = uint256(uint160(addr)); + for (uint256 i = 0; i < len / 2; i++) { + // shift right and truncate all but the least significant byte to extract the byte at position 19-i + uint8 b = uint8(addrNum >> (8 * (19 - i))); + // first hex character is the most significant 4 bits + uint8 hi = b >> 4; + // second hex character is the least significant 4 bits + uint8 lo = b - (hi << 4); + s[2 * i] = char(hi); + s[2 * i + 1] = char(lo); + } + return string(s); + } + + // hi and lo are only 4 bits and between 0 and 16 + // this method converts those values to the unicode/ascii code point for the hex representation + // uses upper case for the characters + function char(uint8 b) private pure returns (bytes1 c) { + if (b < 10) { + return bytes1(b + 0x30); + } else { + return bytes1(b + 0x37); + } + } +} diff --git a/src/dex/v3/periphery/libraries/BytesLib.sol b/src/dex/v3/periphery/libraries/BytesLib.sol new file mode 100644 index 00000000..2b98b4a2 --- /dev/null +++ b/src/dex/v3/periphery/libraries/BytesLib.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * @title Solidity Bytes Arrays Utils + * @author Gonçalo Sá + * + * @dev Bytes tightly packed arrays utility library for ethereum contracts written in Solidity. + * The library lets you concatenate, slice and type cast bytes arrays both in memory and storage. + */ +pragma solidity 0.8.34 <0.9.0; + +library BytesLib { + function slice(bytes memory _bytes, uint256 _start, uint256 _length) internal pure returns (bytes memory) { + require(_length + 31 >= _length, "slice_overflow"); + require(_bytes.length >= _start + _length, "slice_outOfBounds"); + + bytes memory tempBytes; + + assembly { + switch iszero(_length) + case 0 { + // Get a location of some free memory and store it in tempBytes as + // Solidity does for memory variables. + tempBytes := mload(0x40) + + // The first word of the slice result is potentially a partial + // word read from the original array. To read it, we calculate + // the length of that partial word and start copying that many + // bytes into the array. The first word we copy will start with + // data we don't care about, but the last `lengthmod` bytes will + // land at the beginning of the contents of the new array. When + // we're done copying, we overwrite the full first word with + // the actual length of the slice. + let lengthmod := and(_length, 31) + + // The multiplication in the next line is necessary + // because when slicing multiples of 32 bytes (lengthmod == 0) + // the following copy loop was copying the origin's length + // and then ending prematurely not copying everything it should. + let mc := add(add(tempBytes, lengthmod), mul(0x20, iszero(lengthmod))) + let end := add(mc, _length) + + for { + // The multiplication in the next line has the same exact purpose + // as the one above. + let cc := add(add(add(_bytes, lengthmod), mul(0x20, iszero(lengthmod))), _start) + } lt(mc, end) { + mc := add(mc, 0x20) + cc := add(cc, 0x20) + } { + mstore(mc, mload(cc)) + } + + mstore(tempBytes, _length) + + //update free-memory pointer + //allocating the array padded to 32 bytes like the compiler does now + mstore(0x40, and(add(mc, 31), not(31))) + } + //if we want a zero-length slice let's just return a zero-length array + default { + tempBytes := mload(0x40) + //zero out the 32 bytes slice we are about to return + //we need to do it because Solidity does not garbage collect + mstore(tempBytes, 0) + + mstore(0x40, add(tempBytes, 0x20)) + } + } + + return tempBytes; + } + + function toAddress(bytes memory _bytes, uint256 _start) internal pure returns (address) { + require(_bytes.length >= _start + 20, "toAddress_outOfBounds"); + address tempAddress; + + assembly { + tempAddress := div(mload(add(add(_bytes, 0x20), _start)), 0x1000000000000000000000000) + } + + return tempAddress; + } + + function toUint24(bytes memory _bytes, uint256 _start) internal pure returns (uint24) { + require(_start + 3 >= _start, "toUint24_overflow"); + require(_bytes.length >= _start + 3, "toUint24_outOfBounds"); + uint24 tempUint; + + assembly { + tempUint := mload(add(add(_bytes, 0x3), _start)) + } + + return tempUint; + } +} diff --git a/src/dex/v3/periphery/libraries/CallbackValidation.sol b/src/dex/v3/periphery/libraries/CallbackValidation.sol new file mode 100644 index 00000000..5dc0e0de --- /dev/null +++ b/src/dex/v3/periphery/libraries/CallbackValidation.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.34; + +import "../../core/interfaces/IListaV3Pool.sol"; +import "./PoolAddress.sol"; + +/// @notice Provides validation for callbacks from Lista V3 Pools +library CallbackValidation { + /// @notice Returns the address of a valid Lista V3 Pool + /// @param factory The contract address of the Lista V3 factory + /// @param tokenA The contract address of either token0 or token1 + /// @param tokenB The contract address of the other token + /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip + /// @return pool The V3 pool contract address + function verifyCallback( + address factory, + address tokenA, + address tokenB, + uint24 fee + ) internal view returns (IListaV3Pool pool) { + return verifyCallback(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee)); + } + + /// @notice Returns the address of a valid Lista V3 Pool + /// @param factory The contract address of the Lista V3 factory + /// @param poolKey The identifying key of the V3 pool + /// @return pool The V3 pool contract address + function verifyCallback( + address factory, + PoolAddress.PoolKey memory poolKey + ) internal view returns (IListaV3Pool pool) { + pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey)); + require(msg.sender == address(pool)); + } +} diff --git a/src/dex/v3/periphery/libraries/ChainId.sol b/src/dex/v3/periphery/libraries/ChainId.sol new file mode 100644 index 00000000..5e9a4669 --- /dev/null +++ b/src/dex/v3/periphery/libraries/ChainId.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.0; + +/// @title Function for getting the current chain ID +library ChainId { + /// @dev Gets the current chain ID + /// @return chainId The current chain ID + function get() internal view returns (uint256 chainId) { + assembly { + chainId := chainid() + } + } +} diff --git a/src/dex/v3/periphery/libraries/HexStrings.sol b/src/dex/v3/periphery/libraries/HexStrings.sol new file mode 100644 index 00000000..a0e02185 --- /dev/null +++ b/src/dex/v3/periphery/libraries/HexStrings.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +library HexStrings { + bytes16 internal constant ALPHABET = "0123456789abcdef"; + + /// @notice Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length. + /// @dev Credit to Open Zeppelin under MIT license https://github.com/OpenZeppelin/openzeppelin-contracts/blob/243adff49ce1700e0ecb99fe522fb16cff1d1ddc/contracts/utils/Strings.sol#L55 + function toHexString(uint256 value, uint256 length) internal pure returns (string memory) { + bytes memory buffer = new bytes(2 * length + 2); + buffer[0] = "0"; + buffer[1] = "x"; + for (uint256 i = 2 * length + 1; i > 1; --i) { + buffer[i] = ALPHABET[value & 0xf]; + value >>= 4; + } + require(value == 0, "Strings: hex length insufficient"); + return string(buffer); + } + + function toHexStringNoPrefix(uint256 value, uint256 length) internal pure returns (string memory) { + bytes memory buffer = new bytes(2 * length); + for (uint256 i = buffer.length; i > 0; i--) { + buffer[i - 1] = ALPHABET[value & 0xf]; + value >>= 4; + } + return string(buffer); + } +} diff --git a/src/dex/v3/periphery/libraries/LiquidityAmounts.sol b/src/dex/v3/periphery/libraries/LiquidityAmounts.sol new file mode 100644 index 00000000..6fd9f4ae --- /dev/null +++ b/src/dex/v3/periphery/libraries/LiquidityAmounts.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +import "../../core/libraries/FullMath.sol"; +import "../../core/libraries/FixedPoint96.sol"; + +/// @title Liquidity amount functions +/// @notice Provides functions for computing liquidity amounts from token amounts and prices +library LiquidityAmounts { + /// @notice Downcasts uint256 to uint128 + /// @param x The uint258 to be downcasted + /// @return y The passed value, downcasted to uint128 + function toUint128(uint256 x) private pure returns (uint128 y) { + require((y = uint128(x)) == x); + } + + /// @notice Computes the amount of liquidity received for a given amount of token0 and price range + /// @dev Calculates amount0 * (sqrt(upper) * sqrt(lower)) / (sqrt(upper) - sqrt(lower)) + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param amount0 The amount0 being sent in + /// @return liquidity The amount of returned liquidity + function getLiquidityForAmount0( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint256 amount0 + ) internal pure returns (uint128 liquidity) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + uint256 intermediate = FullMath.mulDiv(sqrtRatioAX96, sqrtRatioBX96, FixedPoint96.Q96); + unchecked { + return toUint128(FullMath.mulDiv(amount0, intermediate, sqrtRatioBX96 - sqrtRatioAX96)); + } + } + + /// @notice Computes the amount of liquidity received for a given amount of token1 and price range + /// @dev Calculates amount1 / (sqrt(upper) - sqrt(lower)). + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param amount1 The amount1 being sent in + /// @return liquidity The amount of returned liquidity + function getLiquidityForAmount1( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint256 amount1 + ) internal pure returns (uint128 liquidity) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + unchecked { + return toUint128(FullMath.mulDiv(amount1, FixedPoint96.Q96, sqrtRatioBX96 - sqrtRatioAX96)); + } + } + + /// @notice Computes the maximum amount of liquidity received for a given amount of token0, token1, the current + /// pool prices and the prices at the tick boundaries + /// @param sqrtRatioX96 A sqrt price representing the current pool prices + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param amount0 The amount of token0 being sent in + /// @param amount1 The amount of token1 being sent in + /// @return liquidity The maximum amount of liquidity received + function getLiquidityForAmounts( + uint160 sqrtRatioX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint256 amount0, + uint256 amount1 + ) internal pure returns (uint128 liquidity) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + if (sqrtRatioX96 <= sqrtRatioAX96) { + liquidity = getLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0); + } else if (sqrtRatioX96 < sqrtRatioBX96) { + uint128 liquidity0 = getLiquidityForAmount0(sqrtRatioX96, sqrtRatioBX96, amount0); + uint128 liquidity1 = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioX96, amount1); + + liquidity = liquidity0 < liquidity1 ? liquidity0 : liquidity1; + } else { + liquidity = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1); + } + } + + /// @notice Computes the amount of token0 for a given amount of liquidity and a price range + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param liquidity The liquidity being valued + /// @return amount0 The amount of token0 + function getAmount0ForLiquidity( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity + ) internal pure returns (uint256 amount0) { + unchecked { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + return + FullMath.mulDiv(uint256(liquidity) << FixedPoint96.RESOLUTION, sqrtRatioBX96 - sqrtRatioAX96, sqrtRatioBX96) / + sqrtRatioAX96; + } + } + + /// @notice Computes the amount of token1 for a given amount of liquidity and a price range + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param liquidity The liquidity being valued + /// @return amount1 The amount of token1 + function getAmount1ForLiquidity( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity + ) internal pure returns (uint256 amount1) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + unchecked { + return FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96); + } + } + + /// @notice Computes the token0 and token1 value for a given amount of liquidity, the current + /// pool prices and the prices at the tick boundaries + /// @param sqrtRatioX96 A sqrt price representing the current pool prices + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param liquidity The liquidity being valued + /// @return amount0 The amount of token0 + /// @return amount1 The amount of token1 + function getAmountsForLiquidity( + uint160 sqrtRatioX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity + ) internal pure returns (uint256 amount0, uint256 amount1) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + if (sqrtRatioX96 <= sqrtRatioAX96) { + amount0 = getAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity); + } else if (sqrtRatioX96 < sqrtRatioBX96) { + amount0 = getAmount0ForLiquidity(sqrtRatioX96, sqrtRatioBX96, liquidity); + amount1 = getAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioX96, liquidity); + } else { + amount1 = getAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity); + } + } +} diff --git a/src/dex/v3/periphery/libraries/NFTDescriptor.sol b/src/dex/v3/periphery/libraries/NFTDescriptor.sol new file mode 100644 index 00000000..7d62f3c8 --- /dev/null +++ b/src/dex/v3/periphery/libraries/NFTDescriptor.sol @@ -0,0 +1,475 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.0; +pragma abicoder v2; + +import "../../core/interfaces/IListaV3Pool.sol"; +import "../../core/libraries/TickMath.sol"; +import "../../core/libraries/BitMath.sol"; +import "../../core/libraries/FullMath.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import "@openzeppelin/contracts/utils/Base64.sol"; +import "./HexStrings.sol"; +import "./NFTSVG.sol"; + +library NFTDescriptor { + using TickMath for int24; + using Strings for uint256; + using HexStrings for uint256; + + uint256 constant sqrt10X128 = 1076067327063303206878105757264492625226; + + struct ConstructTokenURIParams { + uint256 tokenId; + address quoteTokenAddress; + address baseTokenAddress; + string quoteTokenSymbol; + string baseTokenSymbol; + uint8 quoteTokenDecimals; + uint8 baseTokenDecimals; + bool flipRatio; + int24 tickLower; + int24 tickUpper; + int24 tickCurrent; + int24 tickSpacing; + uint24 fee; + address poolAddress; + } + + function constructTokenURI(ConstructTokenURIParams memory params) public pure returns (string memory) { + string memory name = generateName(params, feeToPercentString(params.fee)); + string memory descriptionPartOne = generateDescriptionPartOne( + escapeQuotes(params.quoteTokenSymbol), + escapeQuotes(params.baseTokenSymbol), + addressToString(params.poolAddress) + ); + string memory descriptionPartTwo = generateDescriptionPartTwo( + params.tokenId.toString(), + escapeQuotes(params.baseTokenSymbol), + addressToString(params.quoteTokenAddress), + addressToString(params.baseTokenAddress), + feeToPercentString(params.fee) + ); + string memory image = Base64.encode(bytes(generateSVGImage(params))); + + return + string( + abi.encodePacked( + "data:application/json;base64,", + Base64.encode( + bytes( + abi.encodePacked( + '{"name":"', + name, + '", "description":"', + descriptionPartOne, + descriptionPartTwo, + '", "image": "', + "data:image/svg+xml;base64,", + image, + '"}' + ) + ) + ) + ) + ); + } + + function escapeQuotes(string memory symbol) internal pure returns (string memory) { + bytes memory symbolBytes = bytes(symbol); + uint8 quotesCount = 0; + for (uint8 i = 0; i < symbolBytes.length; i++) { + if (symbolBytes[i] == '"') { + quotesCount++; + } + } + if (quotesCount > 0) { + bytes memory escapedBytes = new bytes(symbolBytes.length + (quotesCount)); + uint256 index; + for (uint8 i = 0; i < symbolBytes.length; i++) { + if (symbolBytes[i] == '"') { + escapedBytes[index++] = "\\"; + } + escapedBytes[index++] = symbolBytes[i]; + } + return string(escapedBytes); + } + return symbol; + } + + function generateDescriptionPartOne( + string memory quoteTokenSymbol, + string memory baseTokenSymbol, + string memory poolAddress + ) private pure returns (string memory) { + return + string( + abi.encodePacked( + "This NFT represents a liquidity position in a Lista V3 ", + quoteTokenSymbol, + "-", + baseTokenSymbol, + " pool. ", + "The owner of this NFT can modify or redeem the position.\\n", + "\\nPool Address: ", + poolAddress, + "\\n", + quoteTokenSymbol + ) + ); + } + + function generateDescriptionPartTwo( + string memory tokenId, + string memory baseTokenSymbol, + string memory quoteTokenAddress, + string memory baseTokenAddress, + string memory feeTier + ) private pure returns (string memory) { + return + string( + abi.encodePacked( + " Address: ", + quoteTokenAddress, + "\\n", + baseTokenSymbol, + " Address: ", + baseTokenAddress, + "\\nFee Tier: ", + feeTier, + "\\nToken ID: ", + tokenId, + "\\n\\n", + unicode"⚠️ DISCLAIMER: Due diligence is imperative when assessing this NFT. Make sure token addresses match the expected tokens, as token symbols may be imitated." + ) + ); + } + + function generateName( + ConstructTokenURIParams memory params, + string memory feeTier + ) private pure returns (string memory) { + return + string( + abi.encodePacked( + "Lista - ", + feeTier, + " - ", + escapeQuotes(params.quoteTokenSymbol), + "/", + escapeQuotes(params.baseTokenSymbol), + " - ", + tickToDecimalString( + !params.flipRatio ? params.tickLower : params.tickUpper, + params.tickSpacing, + params.baseTokenDecimals, + params.quoteTokenDecimals, + params.flipRatio + ), + "<>", + tickToDecimalString( + !params.flipRatio ? params.tickUpper : params.tickLower, + params.tickSpacing, + params.baseTokenDecimals, + params.quoteTokenDecimals, + params.flipRatio + ) + ) + ); + } + + struct DecimalStringParams { + // significant figures of decimal + uint256 sigfigs; + // length of decimal string + uint8 bufferLength; + // ending index for significant figures (funtion works backwards when copying sigfigs) + uint8 sigfigIndex; + // index of decimal place (0 if no decimal) + uint8 decimalIndex; + // start index for trailing/leading 0's for very small/large numbers + uint8 zerosStartIndex; + // end index for trailing/leading 0's for very small/large numbers + uint8 zerosEndIndex; + // true if decimal number is less than one + bool isLessThanOne; + // true if string should include "%" + bool isPercent; + } + + function generateDecimalString(DecimalStringParams memory params) private pure returns (string memory) { + bytes memory buffer = new bytes(params.bufferLength); + if (params.isPercent) { + buffer[buffer.length - 1] = "%"; + } + if (params.isLessThanOne) { + buffer[0] = "0"; + buffer[1] = "."; + } + + // add leading/trailing 0's + for (uint256 zerosCursor = params.zerosStartIndex; zerosCursor < params.zerosEndIndex + 1; zerosCursor++) { + buffer[zerosCursor] = bytes1(uint8(48)); + } + // add sigfigs + unchecked { + while (params.sigfigs > 0) { + if (params.decimalIndex > 0 && params.sigfigIndex == params.decimalIndex) { + buffer[params.sigfigIndex--] = "."; + } + buffer[params.sigfigIndex--] = bytes1(uint8(uint256(48) + (params.sigfigs % 10))); + params.sigfigs /= 10; + } + } + return string(buffer); + } + + function tickToDecimalString( + int24 tick, + int24 tickSpacing, + uint8 baseTokenDecimals, + uint8 quoteTokenDecimals, + bool flipRatio + ) internal pure returns (string memory) { + if (tick == (TickMath.MIN_TICK / tickSpacing) * tickSpacing) { + return !flipRatio ? "MIN" : "MAX"; + } else if (tick == (TickMath.MAX_TICK / tickSpacing) * tickSpacing) { + return !flipRatio ? "MAX" : "MIN"; + } else { + uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick); + if (flipRatio) { + sqrtRatioX96 = uint160(uint256(1 << 192) / sqrtRatioX96); + } + return fixedPointToDecimalString(sqrtRatioX96, baseTokenDecimals, quoteTokenDecimals); + } + } + + function sigfigsRounded(uint256 value, uint8 digits) private pure returns (uint256, bool) { + bool extraDigit; + if (digits > 5) { + value = value / ((10 ** (digits - 5))); + } + bool roundUp = value % 10 > 4; + value = value / 10; + if (roundUp) { + value = value + 1; + } + // 99999 -> 100000 gives an extra sigfig + if (value == 100000) { + value /= 10; + extraDigit = true; + } + return (value, extraDigit); + } + + function adjustForDecimalPrecision( + uint160 sqrtRatioX96, + uint8 baseTokenDecimals, + uint8 quoteTokenDecimals + ) private pure returns (uint256 adjustedSqrtRatioX96) { + uint256 difference = abs(int256(uint256(baseTokenDecimals)) - int256(uint256(quoteTokenDecimals))); + if (difference > 0 && difference <= 18) { + if (baseTokenDecimals > quoteTokenDecimals) { + adjustedSqrtRatioX96 = sqrtRatioX96 * (10 ** (difference / 2)); + if (difference % 2 == 1) { + adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, sqrt10X128, 1 << 128); + } + } else { + adjustedSqrtRatioX96 = sqrtRatioX96 / (10 ** (difference / 2)); + if (difference % 2 == 1) { + adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, 1 << 128, sqrt10X128); + } + } + } else { + adjustedSqrtRatioX96 = uint256(sqrtRatioX96); + } + } + + function abs(int256 x) private pure returns (uint256) { + return uint256(x >= 0 ? x : -x); + } + + // @notice Returns string that includes first 5 significant figures of a decimal number + // @param sqrtRatioX96 a sqrt price + function fixedPointToDecimalString( + uint160 sqrtRatioX96, + uint8 baseTokenDecimals, + uint8 quoteTokenDecimals + ) internal pure returns (string memory) { + uint256 adjustedSqrtRatioX96 = adjustForDecimalPrecision(sqrtRatioX96, baseTokenDecimals, quoteTokenDecimals); + uint256 value = FullMath.mulDiv(adjustedSqrtRatioX96, adjustedSqrtRatioX96, 1 << 64); + + bool priceBelow1 = adjustedSqrtRatioX96 < 2 ** 96; + if (priceBelow1) { + // 10 ** 43 is precision needed to retreive 5 sigfigs of smallest possible price + 1 for rounding + value = FullMath.mulDiv(value, 10 ** 44, 1 << 128); + } else { + // leave precision for 4 decimal places + 1 place for rounding + value = FullMath.mulDiv(value, 10 ** 5, 1 << 128); + } + + // get digit count + uint256 temp = value; + uint8 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + // don't count extra digit kept for rounding + digits = digits - 1; + + // address rounding + (uint256 sigfigs, bool extraDigit) = sigfigsRounded(value, digits); + if (extraDigit) { + digits++; + } + + DecimalStringParams memory params; + if (priceBelow1) { + // 7 bytes ( "0." and 5 sigfigs) + leading 0's bytes + params.bufferLength = uint8(uint8(7) + (uint8(43) - digits)); + params.zerosStartIndex = 2; + params.zerosEndIndex = uint8(uint256(43) - digits + 1); + params.sigfigIndex = uint8(params.bufferLength - 1); + } else if (digits >= 9) { + // no decimal in price string + params.bufferLength = uint8(digits - 4); + params.zerosStartIndex = 5; + params.zerosEndIndex = uint8(params.bufferLength - 1); + params.sigfigIndex = 4; + } else { + // 5 sigfigs surround decimal + params.bufferLength = 6; + params.sigfigIndex = 5; + params.decimalIndex = uint8(digits - 4); + } + params.sigfigs = sigfigs; + params.isLessThanOne = priceBelow1; + params.isPercent = false; + + return generateDecimalString(params); + } + + struct FeeDigits { + uint24 temp; + uint8 numSigfigs; + uint256 digits; + } + + // @notice Returns string as decimal percentage of fee amount. + // @param fee fee amount + function feeToPercentString(uint24 fee) internal pure returns (string memory) { + if (fee == 0) { + return "0%"; + } + + FeeDigits memory feeDigits = FeeDigits(fee, 0, 0); + while (feeDigits.temp != 0) { + if (feeDigits.numSigfigs > 0) { + // count all digits preceding least significant figure + feeDigits.numSigfigs++; + } else if (feeDigits.temp % 10 != 0) { + feeDigits.numSigfigs++; + } + feeDigits.digits++; + feeDigits.temp /= 10; + } + + DecimalStringParams memory params; + uint256 nZeros; + if (feeDigits.digits >= 5) { + // if decimal > 1 (5th digit is the ones place) + uint256 decimalPlace = feeDigits.digits - feeDigits.numSigfigs >= 4 ? 0 : 1; + nZeros = feeDigits.digits - 5 < (feeDigits.numSigfigs - 1) + ? 0 + : feeDigits.digits - 5 - (feeDigits.numSigfigs - 1); + params.zerosStartIndex = feeDigits.numSigfigs; + params.zerosEndIndex = uint8(params.zerosStartIndex + nZeros - 1); + params.sigfigIndex = uint8(params.zerosStartIndex - 1 + decimalPlace); + params.bufferLength = uint8(nZeros + (feeDigits.numSigfigs + 1) + decimalPlace); + } else { + // else if decimal < 1 + nZeros = uint256(5) - feeDigits.digits; + params.zerosStartIndex = 2; + params.zerosEndIndex = uint8(nZeros + params.zerosStartIndex - 1); + params.bufferLength = uint8(nZeros + (feeDigits.numSigfigs + 2)); + params.sigfigIndex = uint8((params.bufferLength) - 2); + params.isLessThanOne = true; + } + params.sigfigs = uint256(fee) / (10 ** (feeDigits.digits - feeDigits.numSigfigs)); + params.isPercent = true; + params.decimalIndex = feeDigits.digits > 4 ? uint8(feeDigits.digits - 4) : 0; + + return generateDecimalString(params); + } + + function addressToString(address addr) internal pure returns (string memory) { + return HexStrings.toHexString(uint256(uint160(addr)), 20); + } + + function generateSVGImage(ConstructTokenURIParams memory params) internal pure returns (string memory svg) { + string memory defs = NFTSVG.generateSVGDefs( + NFTSVG.SVGDefsParams({ + color0: tokenToColorHex(uint256(uint160(params.quoteTokenAddress)), 136), + color1: tokenToColorHex(uint256(uint160(params.baseTokenAddress)), 136), + color2: tokenToColorHex(uint256(uint160(params.quoteTokenAddress)), 0), + color3: tokenToColorHex(uint256(uint160(params.baseTokenAddress)), 0), + x1: scale(getCircleCoord(uint256(uint160(params.quoteTokenAddress)), 16, params.tokenId), 0, 255, 16, 274), + y1: scale(getCircleCoord(uint256(uint160(params.baseTokenAddress)), 16, params.tokenId), 0, 255, 100, 484), + x2: scale(getCircleCoord(uint256(uint160(params.quoteTokenAddress)), 32, params.tokenId), 0, 255, 16, 274), + y2: scale(getCircleCoord(uint256(uint160(params.baseTokenAddress)), 32, params.tokenId), 0, 255, 100, 484), + x3: scale(getCircleCoord(uint256(uint160(params.quoteTokenAddress)), 48, params.tokenId), 0, 255, 16, 274), + y3: scale(getCircleCoord(uint256(uint160(params.baseTokenAddress)), 48, params.tokenId), 0, 255, 100, 484) + }) + ); + + string memory body = NFTSVG.generateSVGBody( + NFTSVG.SVGBodyParams({ + quoteToken: addressToString(params.quoteTokenAddress), + baseToken: addressToString(params.baseTokenAddress), + poolAddress: params.poolAddress, + quoteTokenSymbol: params.quoteTokenSymbol, + baseTokenSymbol: params.baseTokenSymbol, + feeTier: feeToPercentString(params.fee), + tickLower: params.tickLower, + tickUpper: params.tickUpper, + tickSpacing: params.tickSpacing, + overRange: overRange(params.tickLower, params.tickUpper, params.tickCurrent), + tokenId: params.tokenId + }) + ); + + return NFTSVG.generateSVG(defs, body); + } + + function overRange(int24 tickLower, int24 tickUpper, int24 tickCurrent) private pure returns (int8) { + if (tickCurrent < tickLower) { + return -1; + } else if (tickCurrent > tickUpper) { + return 1; + } else { + return 0; + } + } + + function scale( + uint256 n, + uint256 inMn, + uint256 inMx, + uint256 outMn, + uint256 outMx + ) private pure returns (string memory) { + return (((n - inMn) * (outMx - outMn)) / (inMx - inMn) + outMn).toString(); + } + + function tokenToColorHex(uint256 token, uint256 offset) internal pure returns (string memory str) { + return string((token >> offset).toHexStringNoPrefix(3)); + } + + function getCircleCoord(uint256 tokenAddress, uint256 offset, uint256 tokenId) internal pure returns (uint256) { + return (sliceTokenHex(tokenAddress, offset) * tokenId) % 255; + } + + function sliceTokenHex(uint256 token, uint256 offset) internal pure returns (uint256) { + return uint256(uint8(token >> offset)); + } +} diff --git a/src/dex/v3/periphery/libraries/NFTSVG.sol b/src/dex/v3/periphery/libraries/NFTSVG.sol new file mode 100644 index 00000000..66ac2c72 --- /dev/null +++ b/src/dex/v3/periphery/libraries/NFTSVG.sol @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.6; +pragma abicoder v2; + +import "@openzeppelin/contracts/utils/Strings.sol"; +import "../../core/libraries/BitMath.sol"; +import "@openzeppelin/contracts/utils/Base64.sol"; + +/// @title NFTSVG +/// @notice Provides a function for generating an SVG associated with a Lista NFT +library NFTSVG { + using Strings for uint256; + + string constant curve1 = "M1 1C41 41 105 105 145 145"; + string constant curve2 = "M1 1C33 49 97 113 145 145"; + string constant curve3 = "M1 1C33 57 89 113 145 145"; + string constant curve4 = "M1 1C25 65 81 121 145 145"; + string constant curve5 = "M1 1C17 73 73 129 145 145"; + string constant curve6 = "M1 1C9 81 65 137 145 145"; + string constant curve7 = "M1 1C1 89 57.5 145 145 145"; + string constant curve8 = "M1 1C1 97 49 145 145 145"; + + struct SVGBodyParams { + string quoteToken; + string baseToken; + address poolAddress; + string quoteTokenSymbol; + string baseTokenSymbol; + string feeTier; + int24 tickLower; + int24 tickUpper; + int24 tickSpacing; + int8 overRange; + uint256 tokenId; + } + + struct SVGDefsParams { + string color0; + string color1; + string color2; + string color3; + string x1; + string y1; + string x2; + string y2; + string x3; + string y3; + } + + function generateSVG(string memory defs, string memory body) internal pure returns (string memory svg) { + /* + address: "0xe8ab59d3bcde16a29912de83a90eb39628cfc163", + msg: "Forged in SVG for Lista in 2021 by 0xe8ab59d3bcde16a29912de83a90eb39628cfc163", + sig: "0x2df0e99d9cbfec33a705d83f75666d98b22dea7c1af412c584f7d626d83f02875993df740dc87563b9c73378f8462426da572d7989de88079a382ad96c57b68d1b", + version: "2" + */ + return string(abi.encodePacked(defs, body, "")); + } + + function generateSVGBody(SVGBodyParams memory params) internal pure returns (string memory body) { + return + string( + abi.encodePacked( + generateSVGBorderText(params.quoteToken, params.baseToken, params.quoteTokenSymbol, params.baseTokenSymbol), + generateSVGCardMantle(params.quoteTokenSymbol, params.baseTokenSymbol, params.feeTier), + generageSvgCurve(params.tickLower, params.tickUpper, params.tickSpacing, params.overRange), + generateSVGPositionDataAndLocationCurve(params.tokenId.toString(), params.tickLower, params.tickUpper), + generateSVGRareSparkle(params.tokenId, params.poolAddress) + ) + ); + } + + function generateSVGDefs(SVGDefsParams memory params) internal pure returns (string memory svg) { + svg = string( + abi.encodePacked( + '", + "", + '" + ) + ) + ), + '"/>" + ) + ) + ), + '"/>" + ) + ) + ), + '" />', + '" + ) + ) + ), + '" /> ', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ' ', + '', + '', + '' + ) + ); + } + + function generateSVGBorderText( + string memory quoteToken, + string memory baseToken, + string memory quoteTokenSymbol, + string memory baseTokenSymbol + ) private pure returns (string memory svg) { + svg = string( + abi.encodePacked( + '', + '', + baseToken, + unicode" • ", + baseTokenSymbol, + ' ', + ' ', + baseToken, + unicode" • ", + baseTokenSymbol, + ' ', + '', + quoteToken, + unicode" • ", + quoteTokenSymbol, + ' ', + quoteToken, + unicode" • ", + quoteTokenSymbol, + ' ' + ) + ); + } + + function generateSVGCardMantle( + string memory quoteTokenSymbol, + string memory baseTokenSymbol, + string memory feeTier + ) private pure returns (string memory svg) { + svg = string( + abi.encodePacked( + ' ', + quoteTokenSymbol, + "/", + baseTokenSymbol, + '', + feeTier, + "", + '' + ) + ); + } + + function generageSvgCurve( + int24 tickLower, + int24 tickUpper, + int24 tickSpacing, + int8 overRange + ) private pure returns (string memory svg) { + string memory fade = overRange == 1 + ? "#fade-up" + : overRange == -1 + ? "#fade-down" + : "#none"; + string memory curve = getCurve(tickLower, tickUpper, tickSpacing); + svg = string( + abi.encodePacked( + '' + '' + '', + '', + '', + '', + generateSVGCurveCircle(overRange) + ) + ); + } + + function getCurve(int24 tickLower, int24 tickUpper, int24 tickSpacing) internal pure returns (string memory curve) { + int24 tickRange = (tickUpper - tickLower) / tickSpacing; + if (tickRange <= 4) { + curve = curve1; + } else if (tickRange <= 8) { + curve = curve2; + } else if (tickRange <= 16) { + curve = curve3; + } else if (tickRange <= 32) { + curve = curve4; + } else if (tickRange <= 64) { + curve = curve5; + } else if (tickRange <= 128) { + curve = curve6; + } else if (tickRange <= 256) { + curve = curve7; + } else { + curve = curve8; + } + } + + function generateSVGCurveCircle(int8 overRange) internal pure returns (string memory svg) { + string memory curvex1 = "73"; + string memory curvey1 = "190"; + string memory curvex2 = "217"; + string memory curvey2 = "334"; + if (overRange == 1 || overRange == -1) { + svg = string( + abi.encodePacked( + '' + ) + ); + } else { + svg = string( + abi.encodePacked( + '', + '' + ) + ); + } + } + + function generateSVGPositionDataAndLocationCurve( + string memory tokenId, + int24 tickLower, + int24 tickUpper + ) private pure returns (string memory svg) { + string memory tickLowerStr = tickToString(tickLower); + string memory tickUpperStr = tickToString(tickUpper); + uint256 str1length = bytes(tokenId).length + 4; + uint256 str2length = bytes(tickLowerStr).length + 10; + uint256 str3length = bytes(tickUpperStr).length + 10; + (string memory xCoord, string memory yCoord) = rangeLocation(tickLower, tickUpper); + svg = string( + abi.encodePacked( + ' ', + '', + 'ID: ', + tokenId, + "", + ' ', + '', + 'Min Tick: ', + tickLowerStr, + "", + ' ', + '', + 'Max Tick: ', + tickUpperStr, + "" + '', + '', + '', + '' + ) + ); + } + + function tickToString(int24 tick) private pure returns (string memory) { + string memory sign = ""; + if (tick < 0) { + tick = tick * -1; + sign = "-"; + } + return string(abi.encodePacked(sign, uint256(uint24(tick)).toString())); + } + + function rangeLocation(int24 tickLower, int24 tickUpper) internal pure returns (string memory, string memory) { + int24 midPoint = (tickLower + tickUpper) / 2; + if (midPoint < -125_000) { + return ("8", "7"); + } else if (midPoint < -75_000) { + return ("8", "10.5"); + } else if (midPoint < -25_000) { + return ("8", "14.25"); + } else if (midPoint < -5_000) { + return ("10", "18"); + } else if (midPoint < 0) { + return ("11", "21"); + } else if (midPoint < 5_000) { + return ("13", "23"); + } else if (midPoint < 25_000) { + return ("15", "25"); + } else if (midPoint < 75_000) { + return ("18", "26"); + } else if (midPoint < 125_000) { + return ("21", "27"); + } else { + return ("24", "27"); + } + } + + function generateSVGRareSparkle(uint256 tokenId, address poolAddress) private pure returns (string memory svg) { + if (isRare(tokenId, poolAddress)) { + svg = string( + abi.encodePacked( + '', + '', + '' + ) + ); + } else { + svg = ""; + } + } + + function isRare(uint256 tokenId, address poolAddress) internal pure returns (bool) { + bytes32 h = keccak256(abi.encodePacked(tokenId, poolAddress)); + return uint256(h) < type(uint256).max / (1 + BitMath.mostSignificantBit(tokenId) * 2); + } +} diff --git a/src/dex/v3/periphery/libraries/OracleLibrary.sol b/src/dex/v3/periphery/libraries/OracleLibrary.sol new file mode 100644 index 00000000..441d6034 --- /dev/null +++ b/src/dex/v3/periphery/libraries/OracleLibrary.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0 <0.9.0; + +import "../../core/libraries/FullMath.sol"; +import "../../core/libraries/TickMath.sol"; +import "../../core/interfaces/IListaV3Pool.sol"; + +/// @title Oracle library +/// @notice Provides functions to integrate with V3 pool oracle +library OracleLibrary { + /// @notice Calculates time-weighted means of tick and liquidity for a given Lista V3 pool + /// @param pool Address of the pool that we want to observe + /// @param secondsAgo Number of seconds in the past from which to calculate the time-weighted means + /// @return arithmeticMeanTick The arithmetic mean tick from (block.timestamp - secondsAgo) to block.timestamp + /// @return harmonicMeanLiquidity The harmonic mean liquidity from (block.timestamp - secondsAgo) to block.timestamp + function consult( + address pool, + uint32 secondsAgo + ) internal view returns (int24 arithmeticMeanTick, uint128 harmonicMeanLiquidity) { + require(secondsAgo != 0, "BP"); + + uint32[] memory secondsAgos = new uint32[](2); + secondsAgos[0] = secondsAgo; + secondsAgos[1] = 0; + + (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) = IListaV3Pool(pool).observe( + secondsAgos + ); + + int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0]; + uint160 secondsPerLiquidityCumulativesDelta = secondsPerLiquidityCumulativeX128s[1] - + secondsPerLiquidityCumulativeX128s[0]; + + arithmeticMeanTick = int24(tickCumulativesDelta / int56(uint56(secondsAgo))); + // Always round to negative infinity + if (tickCumulativesDelta < 0 && (tickCumulativesDelta % int56(uint56(secondsAgo)) != 0)) arithmeticMeanTick--; + + // We are multiplying here instead of shifting to ensure that harmonicMeanLiquidity doesn't overflow uint128 + uint192 secondsAgoX160 = uint192(secondsAgo) * type(uint160).max; + harmonicMeanLiquidity = uint128(secondsAgoX160 / (uint192(secondsPerLiquidityCumulativesDelta) << 32)); + } + + /// @notice Given a tick and a token amount, calculates the amount of token received in exchange + /// @param tick Tick value used to calculate the quote + /// @param baseAmount Amount of token to be converted + /// @param baseToken Address of an ERC20 token contract used as the baseAmount denomination + /// @param quoteToken Address of an ERC20 token contract used as the quoteAmount denomination + /// @return quoteAmount Amount of quoteToken received for baseAmount of baseToken + function getQuoteAtTick( + int24 tick, + uint128 baseAmount, + address baseToken, + address quoteToken + ) internal pure returns (uint256 quoteAmount) { + uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick); + + // Calculate quoteAmount with better precision if it doesn't overflow when multiplied by itself + if (sqrtRatioX96 <= type(uint128).max) { + uint256 ratioX192 = uint256(sqrtRatioX96) * sqrtRatioX96; + quoteAmount = baseToken < quoteToken + ? FullMath.mulDiv(ratioX192, baseAmount, 1 << 192) + : FullMath.mulDiv(1 << 192, baseAmount, ratioX192); + } else { + uint256 ratioX128 = FullMath.mulDiv(sqrtRatioX96, sqrtRatioX96, 1 << 64); + quoteAmount = baseToken < quoteToken + ? FullMath.mulDiv(ratioX128, baseAmount, 1 << 128) + : FullMath.mulDiv(1 << 128, baseAmount, ratioX128); + } + } + + /// @notice Given a pool, it returns the number of seconds ago of the oldest stored observation + /// @param pool Address of Lista V3 pool that we want to observe + /// @return secondsAgo The number of seconds ago of the oldest observation stored for the pool + function getOldestObservationSecondsAgo(address pool) internal view returns (uint32 secondsAgo) { + (, , uint16 observationIndex, uint16 observationCardinality, , , ) = IListaV3Pool(pool).slot0(); + require(observationCardinality > 0, "NI"); + + (uint32 observationTimestamp, , , bool initialized) = IListaV3Pool(pool).observations( + (observationIndex + 1) % observationCardinality + ); + + // The next index might not be initialized if the cardinality is in the process of increasing + // In this case the oldest observation is always in index 0 + if (!initialized) { + (observationTimestamp, , , ) = IListaV3Pool(pool).observations(0); + } + + unchecked { + secondsAgo = uint32(block.timestamp) - observationTimestamp; + } + } + + /// @notice Given a pool, it returns the tick value as of the start of the current block + /// @param pool Address of Lista V3 pool + /// @return The tick that the pool was in at the start of the current block + function getBlockStartingTickAndLiquidity(address pool) internal view returns (int24, uint128) { + (, int24 tick, uint16 observationIndex, uint16 observationCardinality, , , ) = IListaV3Pool(pool).slot0(); + + // 2 observations are needed to reliably calculate the block starting tick + require(observationCardinality > 1, "NEO"); + + // If the latest observation occurred in the past, then no tick-changing trades have happened in this block + // therefore the tick in `slot0` is the same as at the beginning of the current block. + // We don't need to check if this observation is initialized - it is guaranteed to be. + (uint32 observationTimestamp, int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128, ) = IListaV3Pool( + pool + ).observations(observationIndex); + if (observationTimestamp != uint32(block.timestamp)) { + return (tick, IListaV3Pool(pool).liquidity()); + } + + uint256 prevIndex = (uint256(observationIndex) + observationCardinality - 1) % observationCardinality; + ( + uint32 prevObservationTimestamp, + int56 prevTickCumulative, + uint160 prevSecondsPerLiquidityCumulativeX128, + bool prevInitialized + ) = IListaV3Pool(pool).observations(prevIndex); + + require(prevInitialized, "ONI"); + + uint32 delta = observationTimestamp - prevObservationTimestamp; + tick = int24((tickCumulative - int56(uint56(prevTickCumulative))) / int56(uint56(delta))); + uint128 liquidity = uint128( + (uint192(delta) * type(uint160).max) / + (uint192(secondsPerLiquidityCumulativeX128 - prevSecondsPerLiquidityCumulativeX128) << 32) + ); + return (tick, liquidity); + } + + /// @notice Information for calculating a weighted arithmetic mean tick + struct WeightedTickData { + int24 tick; + uint128 weight; + } + + /// @notice Given an array of ticks and weights, calculates the weighted arithmetic mean tick + /// @param weightedTickData An array of ticks and weights + /// @return weightedArithmeticMeanTick The weighted arithmetic mean tick + /// @dev Each entry of `weightedTickData` should represents ticks from pools with the same underlying pool tokens. If they do not, + /// extreme care must be taken to ensure that ticks are comparable (including decimal differences). + /// @dev Note that the weighted arithmetic mean tick corresponds to the weighted geometric mean price. + function getWeightedArithmeticMeanTick( + WeightedTickData[] memory weightedTickData + ) internal pure returns (int24 weightedArithmeticMeanTick) { + // Accumulates the sum of products between each tick and its weight + int256 numerator; + + // Accumulates the sum of the weights + uint256 denominator; + + // Products fit in 152 bits, so it would take an array of length ~2**104 to overflow this logic + for (uint256 i; i < weightedTickData.length; i++) { + numerator += weightedTickData[i].tick * int256(uint256(weightedTickData[i].weight)); + denominator += weightedTickData[i].weight; + } + + weightedArithmeticMeanTick = int24(numerator / int256(denominator)); + // Always round to negative infinity + if (numerator < 0 && (numerator % int256(denominator) != 0)) weightedArithmeticMeanTick--; + } + + /// @notice Returns the "synthetic" tick which represents the price of the first entry in `tokens` in terms of the last + /// @dev Useful for calculating relative prices along routes. + /// @dev There must be one tick for each pairwise set of tokens. + /// @param tokens The token contract addresses + /// @param ticks The ticks, representing the price of each token pair in `tokens` + /// @return syntheticTick The synthetic tick, representing the relative price of the outermost tokens in `tokens` + function getChainedPrice(address[] memory tokens, int24[] memory ticks) internal pure returns (int256 syntheticTick) { + require(tokens.length - 1 == ticks.length, "DL"); + for (uint256 i = 1; i <= ticks.length; i++) { + // check the tokens for address sort order, then accumulate the + // ticks into the running synthetic tick, ensuring that intermediate tokens "cancel out" + tokens[i - 1] < tokens[i] ? syntheticTick += ticks[i - 1] : syntheticTick -= ticks[i - 1]; + } + } +} diff --git a/src/dex/v3/periphery/libraries/Path.sol b/src/dex/v3/periphery/libraries/Path.sol new file mode 100644 index 00000000..bdfee81a --- /dev/null +++ b/src/dex/v3/periphery/libraries/Path.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.6.0; + +import "./BytesLib.sol"; + +/// @title Functions for manipulating path data for multihop swaps +library Path { + using BytesLib for bytes; + + /// @dev The length of the bytes encoded address + uint256 private constant ADDR_SIZE = 20; + /// @dev The length of the bytes encoded fee + uint256 private constant FEE_SIZE = 3; + + /// @dev The offset of a single token address and pool fee + uint256 private constant NEXT_OFFSET = ADDR_SIZE + FEE_SIZE; + /// @dev The offset of an encoded pool key + uint256 private constant POP_OFFSET = NEXT_OFFSET + ADDR_SIZE; + /// @dev The minimum length of an encoding that contains 2 or more pools + uint256 private constant MULTIPLE_POOLS_MIN_LENGTH = POP_OFFSET + NEXT_OFFSET; + + /// @notice Returns true iff the path contains two or more pools + /// @param path The encoded swap path + /// @return True if path contains two or more pools, otherwise false + function hasMultiplePools(bytes memory path) internal pure returns (bool) { + return path.length >= MULTIPLE_POOLS_MIN_LENGTH; + } + + /// @notice Returns the number of pools in the path + /// @param path The encoded swap path + /// @return The number of pools in the path + function numPools(bytes memory path) internal pure returns (uint256) { + // Ignore the first token address. From then on every fee and token offset indicates a pool. + return ((path.length - ADDR_SIZE) / NEXT_OFFSET); + } + + /// @notice Decodes the first pool in path + /// @param path The bytes encoded swap path + /// @return tokenA The first token of the given pool + /// @return tokenB The second token of the given pool + /// @return fee The fee level of the pool + function decodeFirstPool(bytes memory path) internal pure returns (address tokenA, address tokenB, uint24 fee) { + tokenA = path.toAddress(0); + fee = path.toUint24(ADDR_SIZE); + tokenB = path.toAddress(NEXT_OFFSET); + } + + /// @notice Gets the segment corresponding to the first pool in the path + /// @param path The bytes encoded swap path + /// @return The segment containing all data necessary to target the first pool in the path + function getFirstPool(bytes memory path) internal pure returns (bytes memory) { + return path.slice(0, POP_OFFSET); + } + + /// @notice Skips a token + fee element from the buffer and returns the remainder + /// @param path The swap path + /// @return The remaining token + fee elements in the path + function skipToken(bytes memory path) internal pure returns (bytes memory) { + return path.slice(NEXT_OFFSET, path.length - NEXT_OFFSET); + } +} diff --git a/src/dex/v3/periphery/libraries/PoolAddress.sol b/src/dex/v3/periphery/libraries/PoolAddress.sol new file mode 100644 index 00000000..7614e718 --- /dev/null +++ b/src/dex/v3/periphery/libraries/PoolAddress.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title Provides functions for deriving a pool address from the factory, tokens, and the fee +library PoolAddress { + bytes32 internal constant POOL_INIT_CODE_HASH = 0xa598dd2fba360510c5a8f02f44423a4468e902df5857dbce3ca162a43a3a31ff; + + /// @notice The identifying key of the pool + struct PoolKey { + address token0; + address token1; + uint24 fee; + } + + /// @notice Returns PoolKey: the ordered tokens with the matched fee levels + /// @param tokenA The first token of a pool, unsorted + /// @param tokenB The second token of a pool, unsorted + /// @param fee The fee level of the pool + /// @return Poolkey The pool details with ordered token0 and token1 assignments + function getPoolKey(address tokenA, address tokenB, uint24 fee) internal pure returns (PoolKey memory) { + if (tokenA > tokenB) (tokenA, tokenB) = (tokenB, tokenA); + return PoolKey({ token0: tokenA, token1: tokenB, fee: fee }); + } + + /// @notice Deterministically computes the pool address given the factory and PoolKey + /// @param factory The Lista V3 factory contract address + /// @param key The PoolKey + /// @return pool The contract address of the V3 pool + function computeAddress(address factory, PoolKey memory key) internal pure returns (address pool) { + require(key.token0 < key.token1); + pool = address( + uint160( + uint256( + keccak256( + abi.encodePacked( + hex"ff", + factory, + keccak256(abi.encode(key.token0, key.token1, key.fee)), + POOL_INIT_CODE_HASH + ) + ) + ) + ) + ); + } +} diff --git a/src/dex/v3/periphery/libraries/PoolTicksCounter.sol b/src/dex/v3/periphery/libraries/PoolTicksCounter.sol new file mode 100644 index 00000000..ec9fe2b3 --- /dev/null +++ b/src/dex/v3/periphery/libraries/PoolTicksCounter.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.6.0; + +import "../../core/interfaces/IListaV3Pool.sol"; + +library PoolTicksCounter { + /// @dev This function counts the number of initialized ticks that would incur a gas cost between tickBefore and tickAfter. + /// When tickBefore and/or tickAfter themselves are initialized, the logic over whether we should count them depends on the + /// direction of the swap. If we are swapping upwards (tickAfter > tickBefore) we don't want to count tickBefore but we do + /// want to count tickAfter. The opposite is true if we are swapping downwards. + function countInitializedTicksCrossed( + IListaV3Pool self, + int24 tickBefore, + int24 tickAfter + ) internal view returns (uint32 initializedTicksCrossed) { + int16 wordPosLower; + int16 wordPosHigher; + uint8 bitPosLower; + uint8 bitPosHigher; + bool tickBeforeInitialized; + bool tickAfterInitialized; + + { + // Get the key and offset in the tick bitmap of the active tick before and after the swap. + int16 wordPos = int16((tickBefore / self.tickSpacing()) >> 8); + uint8 bitPos = uint8(int8((tickBefore / self.tickSpacing()) % 256)); + + int16 wordPosAfter = int16((tickAfter / self.tickSpacing()) >> 8); + uint8 bitPosAfter = uint8(int8((tickAfter / self.tickSpacing()) % 256)); + + // In the case where tickAfter is initialized, we only want to count it if we are swapping downwards. + // If the initializable tick after the swap is initialized, our original tickAfter is a + // multiple of tick spacing, and we are swapping downwards we know that tickAfter is initialized + // and we shouldn't count it. + tickAfterInitialized = + ((self.tickBitmap(wordPosAfter) & (1 << bitPosAfter)) > 0) && + ((tickAfter % self.tickSpacing()) == 0) && + (tickBefore > tickAfter); + + // In the case where tickBefore is initialized, we only want to count it if we are swapping upwards. + // Use the same logic as above to decide whether we should count tickBefore or not. + tickBeforeInitialized = + ((self.tickBitmap(wordPos) & (1 << bitPos)) > 0) && + ((tickBefore % self.tickSpacing()) == 0) && + (tickBefore < tickAfter); + + if (wordPos < wordPosAfter || (wordPos == wordPosAfter && bitPos <= bitPosAfter)) { + wordPosLower = wordPos; + bitPosLower = bitPos; + wordPosHigher = wordPosAfter; + bitPosHigher = bitPosAfter; + } else { + wordPosLower = wordPosAfter; + bitPosLower = bitPosAfter; + wordPosHigher = wordPos; + bitPosHigher = bitPos; + } + } + + // Count the number of initialized ticks crossed by iterating through the tick bitmap. + // Our first mask should include the lower tick and everything to its left. + uint256 mask = type(uint256).max << bitPosLower; + while (wordPosLower <= wordPosHigher) { + // If we're on the final tick bitmap page, ensure we only count up to our + // ending tick. + if (wordPosLower == wordPosHigher) { + mask = mask & (type(uint256).max >> (255 - bitPosHigher)); + } + + uint256 masked = self.tickBitmap(wordPosLower) & mask; + initializedTicksCrossed += countOneBits(masked); + wordPosLower++; + // Reset our mask so we consider all bits on the next iteration. + mask = type(uint256).max; + } + + if (tickAfterInitialized) { + initializedTicksCrossed -= 1; + } + + if (tickBeforeInitialized) { + initializedTicksCrossed -= 1; + } + + return initializedTicksCrossed; + } + + function countOneBits(uint256 x) private pure returns (uint16) { + uint16 bits = 0; + while (x != 0) { + bits++; + x &= (x - 1); + } + return bits; + } +} diff --git a/src/dex/v3/periphery/libraries/PositionKey.sol b/src/dex/v3/periphery/libraries/PositionKey.sol new file mode 100644 index 00000000..72f2d8c8 --- /dev/null +++ b/src/dex/v3/periphery/libraries/PositionKey.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +library PositionKey { + /// @dev Returns the key of the position in the core library + function compute(address owner, int24 tickLower, int24 tickUpper) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(owner, tickLower, tickUpper)); + } +} diff --git a/src/dex/v3/periphery/libraries/PositionValue.sol b/src/dex/v3/periphery/libraries/PositionValue.sol new file mode 100644 index 00000000..147ecf94 --- /dev/null +++ b/src/dex/v3/periphery/libraries/PositionValue.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.6.8 <0.9.0; + +import "../../core/interfaces/IListaV3Pool.sol"; +import "../../core/libraries/FixedPoint128.sol"; +import "../../core/libraries/TickMath.sol"; +import "../../core/libraries/Tick.sol"; +import "../interfaces/INonfungiblePositionManager.sol"; +import "./LiquidityAmounts.sol"; +import "./PoolAddress.sol"; +import "./PositionKey.sol"; + +/// @title Returns information about the token value held in a Lista V3 NFT +library PositionValue { + /// @notice Returns the total amounts of token0 and token1, i.e. the sum of fees and principal + /// that a given nonfungible position manager token is worth + /// @param positionManager The Lista V3 NonfungiblePositionManager + /// @param tokenId The tokenId of the token for which to get the total value + /// @param sqrtRatioX96 The square root price X96 for which to calculate the principal amounts + /// @return amount0 The total amount of token0 including principal and fees + /// @return amount1 The total amount of token1 including principal and fees + function total( + INonfungiblePositionManager positionManager, + uint256 tokenId, + uint160 sqrtRatioX96 + ) internal view returns (uint256 amount0, uint256 amount1) { + (uint256 amount0Principal, uint256 amount1Principal) = principal(positionManager, tokenId, sqrtRatioX96); + (uint256 amount0Fee, uint256 amount1Fee) = fees(positionManager, tokenId); + return (amount0Principal + amount0Fee, amount1Principal + amount1Fee); + } + + /// @notice Calculates the principal (currently acting as liquidity) owed to the token owner in the event + /// that the position is burned + /// @param positionManager The Lista V3 NonfungiblePositionManager + /// @param tokenId The tokenId of the token for which to get the total principal owed + /// @param sqrtRatioX96 The square root price X96 for which to calculate the principal amounts + /// @return amount0 The principal amount of token0 + /// @return amount1 The principal amount of token1 + function principal( + INonfungiblePositionManager positionManager, + uint256 tokenId, + uint160 sqrtRatioX96 + ) internal view returns (uint256 amount0, uint256 amount1) { + (, , , , , int24 tickLower, int24 tickUpper, uint128 liquidity, , , , ) = positionManager.positions(tokenId); + + return + LiquidityAmounts.getAmountsForLiquidity( + sqrtRatioX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + liquidity + ); + } + + struct FeeParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint128 liquidity; + uint256 positionFeeGrowthInside0LastX128; + uint256 positionFeeGrowthInside1LastX128; + uint256 tokensOwed0; + uint256 tokensOwed1; + } + + /// @notice Calculates the total fees owed to the token owner + /// @param positionManager The Lista V3 NonfungiblePositionManager + /// @param tokenId The tokenId of the token for which to get the total fees owed + /// @return amount0 The amount of fees owed in token0 + /// @return amount1 The amount of fees owed in token1 + function fees( + INonfungiblePositionManager positionManager, + uint256 tokenId + ) internal view returns (uint256 amount0, uint256 amount1) { + ( + , + , + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 positionFeeGrowthInside0LastX128, + uint256 positionFeeGrowthInside1LastX128, + uint256 tokensOwed0, + uint256 tokensOwed1 + ) = positionManager.positions(tokenId); + + return + _fees( + positionManager, + FeeParams({ + token0: token0, + token1: token1, + fee: fee, + tickLower: tickLower, + tickUpper: tickUpper, + liquidity: liquidity, + positionFeeGrowthInside0LastX128: positionFeeGrowthInside0LastX128, + positionFeeGrowthInside1LastX128: positionFeeGrowthInside1LastX128, + tokensOwed0: tokensOwed0, + tokensOwed1: tokensOwed1 + }) + ); + } + + function _fees( + INonfungiblePositionManager positionManager, + FeeParams memory feeParams + ) private view returns (uint256 amount0, uint256 amount1) { + (uint256 poolFeeGrowthInside0LastX128, uint256 poolFeeGrowthInside1LastX128) = _getFeeGrowthInside( + IListaV3Pool( + PoolAddress.computeAddress( + positionManager.factory(), + PoolAddress.PoolKey({ token0: feeParams.token0, token1: feeParams.token1, fee: feeParams.fee }) + ) + ), + feeParams.tickLower, + feeParams.tickUpper + ); + + amount0 = + FullMath.mulDiv( + poolFeeGrowthInside0LastX128 - feeParams.positionFeeGrowthInside0LastX128, + feeParams.liquidity, + FixedPoint128.Q128 + ) + + feeParams.tokensOwed0; + + amount1 = + FullMath.mulDiv( + poolFeeGrowthInside1LastX128 - feeParams.positionFeeGrowthInside1LastX128, + feeParams.liquidity, + FixedPoint128.Q128 + ) + + feeParams.tokensOwed1; + } + + function _getFeeGrowthInside( + IListaV3Pool pool, + int24 tickLower, + int24 tickUpper + ) private view returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) { + (, int24 tickCurrent, , , , , ) = pool.slot0(); + (, , uint256 lowerFeeGrowthOutside0X128, uint256 lowerFeeGrowthOutside1X128, , , , ) = pool.ticks(tickLower); + (, , uint256 upperFeeGrowthOutside0X128, uint256 upperFeeGrowthOutside1X128, , , , ) = pool.ticks(tickUpper); + + if (tickCurrent < tickLower) { + feeGrowthInside0X128 = lowerFeeGrowthOutside0X128 - upperFeeGrowthOutside0X128; + feeGrowthInside1X128 = lowerFeeGrowthOutside1X128 - upperFeeGrowthOutside1X128; + } else if (tickCurrent < tickUpper) { + uint256 feeGrowthGlobal0X128 = pool.feeGrowthGlobal0X128(); + uint256 feeGrowthGlobal1X128 = pool.feeGrowthGlobal1X128(); + feeGrowthInside0X128 = feeGrowthGlobal0X128 - lowerFeeGrowthOutside0X128 - upperFeeGrowthOutside0X128; + feeGrowthInside1X128 = feeGrowthGlobal1X128 - lowerFeeGrowthOutside1X128 - upperFeeGrowthOutside1X128; + } else { + feeGrowthInside0X128 = upperFeeGrowthOutside0X128 - lowerFeeGrowthOutside0X128; + feeGrowthInside1X128 = upperFeeGrowthOutside1X128 - lowerFeeGrowthOutside1X128; + } + } +} diff --git a/src/dex/v3/periphery/libraries/SafeERC20Namer.sol b/src/dex/v3/periphery/libraries/SafeERC20Namer.sol new file mode 100644 index 00000000..e665c0a9 --- /dev/null +++ b/src/dex/v3/periphery/libraries/SafeERC20Namer.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// from https://github.com/Lista/solidity-lib/blob/master/contracts/libraries/SafeERC20Namer.sol +// modified for solidity 0.8 + +pragma solidity 0.8.34; + +import "./AddressStringUtil.sol"; + +// produces token descriptors from inconsistent or absent ERC20 symbol implementations that can return string or bytes32 +// this library will always produce a string symbol to represent the token +library SafeERC20Namer { + function bytes32ToString(bytes32 x) private pure returns (string memory) { + bytes memory bytesString = new bytes(32); + uint256 charCount = 0; + for (uint256 j = 0; j < 32; j++) { + bytes1 char = x[j]; + if (char != 0) { + bytesString[charCount] = char; + charCount++; + } + } + bytes memory bytesStringTrimmed = new bytes(charCount); + for (uint256 j = 0; j < charCount; j++) { + bytesStringTrimmed[j] = bytesString[j]; + } + return string(bytesStringTrimmed); + } + + // assumes the data is in position 2 + function parseStringData(bytes memory b) private pure returns (string memory) { + uint256 charCount = 0; + // first parse the charCount out of the data + for (uint256 i = 32; i < 64; i++) { + charCount <<= 8; + charCount += uint8(b[i]); + } + + bytes memory bytesStringTrimmed = new bytes(charCount); + for (uint256 i = 0; i < charCount; i++) { + bytesStringTrimmed[i] = b[i + 64]; + } + + return string(bytesStringTrimmed); + } + + // uses a heuristic to produce a token name from the address + // the heuristic returns the full hex of the address string in upper case + function addressToName(address token) private pure returns (string memory) { + return AddressStringUtil.toAsciiString(token, 40); + } + + // uses a heuristic to produce a token symbol from the address + // the heuristic returns the first 6 hex of the address string in upper case + function addressToSymbol(address token) private pure returns (string memory) { + return AddressStringUtil.toAsciiString(token, 6); + } + + // calls an external view token contract method that returns a symbol or name, and parses the output into a string + function callAndParseStringReturn(address token, bytes4 selector) private view returns (string memory) { + (bool success, bytes memory data) = token.staticcall(abi.encodeWithSelector(selector)); + // if not implemented, or returns empty data, return empty string + if (!success || data.length == 0) { + return ""; + } + // bytes32 data always has length 32 + if (data.length == 32) { + bytes32 decoded = abi.decode(data, (bytes32)); + return bytes32ToString(decoded); + } else if (data.length > 64) { + return abi.decode(data, (string)); + } + return ""; + } + + // attempts to extract the token symbol. if it does not implement symbol, returns a symbol derived from the address + function tokenSymbol(address token) internal view returns (string memory) { + // 0x95d89b41 = bytes4(keccak256("symbol()")) + string memory symbol = callAndParseStringReturn(token, 0x95d89b41); + if (bytes(symbol).length == 0) { + // fallback to 6 uppercase hex of address + return addressToSymbol(token); + } + return symbol; + } + + // attempts to extract the token name. if it does not implement name, returns a name derived from the address + function tokenName(address token) internal view returns (string memory) { + // 0x06fdde03 = bytes4(keccak256("name()")) + string memory name = callAndParseStringReturn(token, 0x06fdde03); + if (bytes(name).length == 0) { + // fallback to full hex of address + return addressToName(token); + } + return name; + } +} diff --git a/src/dex/v3/periphery/libraries/SqrtPriceMathPartial.sol b/src/dex/v3/periphery/libraries/SqrtPriceMathPartial.sol new file mode 100644 index 00000000..22510c01 --- /dev/null +++ b/src/dex/v3/periphery/libraries/SqrtPriceMathPartial.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +import "../../core/libraries/FullMath.sol"; +import "../../core/libraries/UnsafeMath.sol"; +import "../../core/libraries/FixedPoint96.sol"; + +/// @title Functions based on Q64.96 sqrt price and liquidity +/// @notice Exposes two functions from @lista/v3-core SqrtPriceMath +/// that use square root of price as a Q64.96 and liquidity to compute deltas +library SqrtPriceMathPartial { + /// @notice Gets the amount0 delta between two prices + /// @dev Calculates liquidity / sqrt(lower) - liquidity / sqrt(upper), + /// i.e. liquidity * (sqrt(upper) - sqrt(lower)) / (sqrt(upper) * sqrt(lower)) + /// @param sqrtRatioAX96 A sqrt price + /// @param sqrtRatioBX96 Another sqrt price + /// @param liquidity The amount of usable liquidity + /// @param roundUp Whether to round the amount up or down + /// @return amount0 Amount of token0 required to cover a position of size liquidity between the two passed prices + function getAmount0Delta( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity, + bool roundUp + ) internal pure returns (uint256 amount0) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + uint256 numerator1 = uint256(liquidity) << FixedPoint96.RESOLUTION; + uint256 numerator2 = sqrtRatioBX96 - sqrtRatioAX96; + + require(sqrtRatioAX96 > 0); + + return + roundUp + ? UnsafeMath.divRoundingUp(FullMath.mulDivRoundingUp(numerator1, numerator2, sqrtRatioBX96), sqrtRatioAX96) + : FullMath.mulDiv(numerator1, numerator2, sqrtRatioBX96) / sqrtRatioAX96; + } + + /// @notice Gets the amount1 delta between two prices + /// @dev Calculates liquidity * (sqrt(upper) - sqrt(lower)) + /// @param sqrtRatioAX96 A sqrt price + /// @param sqrtRatioBX96 Another sqrt price + /// @param liquidity The amount of usable liquidity + /// @param roundUp Whether to round the amount up, or down + /// @return amount1 Amount of token1 required to cover a position of size liquidity between the two passed prices + function getAmount1Delta( + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity, + bool roundUp + ) internal pure returns (uint256 amount1) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + return + roundUp + ? FullMath.mulDivRoundingUp(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96) + : FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96); + } +} diff --git a/src/dex/v3/periphery/libraries/TokenRatioSortOrder.sol b/src/dex/v3/periphery/libraries/TokenRatioSortOrder.sol new file mode 100644 index 00000000..2ebeb96a --- /dev/null +++ b/src/dex/v3/periphery/libraries/TokenRatioSortOrder.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +library TokenRatioSortOrder { + int256 constant NUMERATOR_MOST = 300; + int256 constant NUMERATOR_MORE = 200; + int256 constant NUMERATOR = 100; + + int256 constant DENOMINATOR_MOST = -300; + int256 constant DENOMINATOR_MORE = -200; + int256 constant DENOMINATOR = -100; +} diff --git a/src/dex/v3/periphery/libraries/TransferHelper.sol b/src/dex/v3/periphery/libraries/TransferHelper.sol new file mode 100644 index 00000000..23d380b0 --- /dev/null +++ b/src/dex/v3/periphery/libraries/TransferHelper.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.6.0; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +library TransferHelper { + /// @notice Transfers tokens from the targeted address to the given destination + /// @notice Errors with 'STF' if transfer fails + /// @param token The contract address of the token to be transferred + /// @param from The originating address from which the tokens will be transferred + /// @param to The destination address of the transfer + /// @param value The amount to be transferred + function safeTransferFrom(address token, address from, address to, uint256 value) internal { + (bool success, bytes memory data) = token.call( + abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value) + ); + require(success && (data.length == 0 || abi.decode(data, (bool))), "STF"); + } + + /// @notice Transfers tokens from msg.sender to a recipient + /// @dev Errors with ST if transfer fails + /// @param token The contract address of the token which will be transferred + /// @param to The recipient of the transfer + /// @param value The value of the transfer + function safeTransfer(address token, address to, uint256 value) internal { + (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, value)); + require(success && (data.length == 0 || abi.decode(data, (bool))), "ST"); + } + + /// @notice Approves the stipulated contract to spend the given allowance in the given token + /// @dev Errors with 'SA' if transfer fails + /// @param token The contract address of the token to be approved + /// @param to The target of the approval + /// @param value The amount of the given token the target will be allowed to spend + function safeApprove(address token, address to, uint256 value) internal { + (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.approve.selector, to, value)); + require(success && (data.length == 0 || abi.decode(data, (bool))), "SA"); + } + + /// @notice Transfers ETH to the recipient address + /// @dev Fails with `STE` + /// @param to The destination of the transfer + /// @param value The value to be transferred + function safeTransferETH(address to, uint256 value) internal { + (bool success, ) = to.call{ value: value }(new bytes(0)); + require(success, "STE"); + } +} diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol index a0d2db3d..0ca8e15a 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/V3Provider.sol @@ -8,17 +8,17 @@ import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { TickMath } from "@uniswap/v3-core/contracts/libraries/TickMath.sol"; -import { SqrtPriceMath } from "@uniswap/v3-core/contracts/libraries/SqrtPriceMath.sol"; -import { LiquidityAmounts } from "@uniswap/v3-periphery/contracts/libraries/LiquidityAmounts.sol"; +import { TickMath } from "../dex/v3/core/libraries/TickMath.sol"; +import { SqrtPriceMath } from "../dex/v3/core/libraries/SqrtPriceMath.sol"; +import { LiquidityAmounts } from "../dex/v3/periphery/libraries/LiquidityAmounts.sol"; import { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; import { IOracle, TokenConfig } from "moolah/interfaces/IOracle.sol"; -import { INonfungiblePositionManager } from "./interfaces/INonfungiblePositionManager.sol"; -import { IUniswapV3Factory } from "./interfaces/IUniswapV3Factory.sol"; -import { IUniswapV3Pool } from "./interfaces/IUniswapV3Pool.sol"; +import { INonfungiblePositionManager } from "../dex/v3/periphery/interfaces/INonfungiblePositionManager.sol"; +import { IListaV3Factory } from "../dex/v3/core/interfaces/IListaV3Factory.sol"; +import { IListaV3Pool } from "../dex/v3/core/interfaces/IListaV3Pool.sol"; import { IWBNB } from "./interfaces/IWBNB.sol"; import { IV3Provider } from "./interfaces/IV3Provider.sol"; import { ISlisBNBxMinter } from "../utils/interfaces/ISlisBNBx.sol"; @@ -168,7 +168,7 @@ contract V3Provider is require(_fee > 0, "zero fee"); require(_twapPeriod > 0, "zero twap period"); - address _pool = IUniswapV3Factory(INonfungiblePositionManager(_positionManager).factory()).getPool( + address _pool = IListaV3Factory(INonfungiblePositionManager(_positionManager).factory()).getPool( _token0, _token1, _fee @@ -296,7 +296,7 @@ contract V3Provider is // This catches one-sided deposits in the wrong direction (e.g. token0-only when price // is above tickUpper) before any tokens are pulled from the caller. { - (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + (uint160 sqrtPriceX96, , , , , , ) = IListaV3Pool(POOL).slot0(); require( LiquidityAmounts.getLiquidityForAmounts( sqrtPriceX96, @@ -503,7 +503,7 @@ contract V3Provider is // Guard: prevent rebalance when spot diverges too far from TWAP. if (maxTickDeviation > 0) { - (, int24 spotTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 spotTick, , , , , ) = IListaV3Pool(POOL).slot0(); int24 twapTick = getTwapTick(); int24 delta = spotTick > twapTick ? spotTick - twapTick : twapTick - spotTick; require(uint24(delta) <= maxTickDeviation, "twap deviation too high"); @@ -604,7 +604,7 @@ contract V3Provider is * peek() uses the TWAP price to resist manipulation; see _getTotalAmountsAt. */ function getTotalAmounts() public view returns (uint256 total0, uint256 total1) { - (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + (uint160 sqrtPriceX96, , , , , , ) = IListaV3Pool(POOL).slot0(); return _getTotalAmountsAt(sqrtPriceX96); } @@ -630,7 +630,7 @@ contract V3Provider is uint128 totalLiquidity = _getPositionLiquidity(); uint128 liquidityToRemove = uint128((uint256(totalLiquidity) * shares) / supply); - (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + (uint160 sqrtPriceX96, , , , , , ) = IListaV3Pool(POOL).slot0(); (amount0, amount1) = _getAmountsForLiquidity( sqrtPriceX96, TickMath.getSqrtRatioAtTick(tickLower), @@ -659,7 +659,7 @@ contract V3Provider is uint256 amount0Desired, uint256 amount1Desired ) external view returns (uint128 liquidity, uint256 amount0, uint256 amount1) { - (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + (uint160 sqrtPriceX96, , , , , , ) = IListaV3Pool(POOL).slot0(); uint160 sqrtRatioLower = TickMath.getSqrtRatioAtTick(tickLower); uint160 sqrtRatioUpper = TickMath.getSqrtRatioAtTick(tickUpper); @@ -745,7 +745,7 @@ contract V3Provider is secondsAgos[0] = TWAP_PERIOD; secondsAgos[1] = 0; - (int56[] memory tickCumulatives, ) = IUniswapV3Pool(POOL).observe(secondsAgos); + (int56[] memory tickCumulatives, ) = IListaV3Pool(POOL).observe(secondsAgos); int56 delta = tickCumulatives[1] - tickCumulatives[0]; twapTick = int24(delta / int56(uint56(TWAP_PERIOD))); diff --git a/src/provider/interfaces/INonfungiblePositionManager.sol b/src/provider/interfaces/INonfungiblePositionManager.sol deleted file mode 100644 index 6c0ddb81..00000000 --- a/src/provider/interfaces/INonfungiblePositionManager.sol +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.34; - -/// @title Minimal interface for Uniswap V3 / PancakeSwap V3 NonfungiblePositionManager -interface INonfungiblePositionManager { - struct MintParams { - address token0; - address token1; - uint24 fee; - int24 tickLower; - int24 tickUpper; - uint256 amount0Desired; - uint256 amount1Desired; - uint256 amount0Min; - uint256 amount1Min; - address recipient; - uint256 deadline; - } - - struct IncreaseLiquidityParams { - uint256 tokenId; - uint256 amount0Desired; - uint256 amount1Desired; - uint256 amount0Min; - uint256 amount1Min; - uint256 deadline; - } - - struct DecreaseLiquidityParams { - uint256 tokenId; - uint128 liquidity; - uint256 amount0Min; - uint256 amount1Min; - uint256 deadline; - } - - struct CollectParams { - uint256 tokenId; - address recipient; - uint128 amount0Max; - uint128 amount1Max; - } - - function mint( - MintParams calldata params - ) external returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); - - function increaseLiquidity( - IncreaseLiquidityParams calldata params - ) external returns (uint128 liquidity, uint256 amount0, uint256 amount1); - - function decreaseLiquidity( - DecreaseLiquidityParams calldata params - ) external returns (uint256 amount0, uint256 amount1); - - function collect(CollectParams calldata params) external returns (uint256 amount0, uint256 amount1); - - function burn(uint256 tokenId) external; - - function factory() external view returns (address); - - function positions( - uint256 tokenId - ) - external - view - returns ( - uint96 nonce, - address operator, - address token0, - address token1, - uint24 fee, - int24 tickLower, - int24 tickUpper, - uint128 liquidity, - uint256 feeGrowthInside0LastX128, - uint256 feeGrowthInside1LastX128, - uint128 tokensOwed0, - uint128 tokensOwed1 - ); -} diff --git a/src/provider/interfaces/IUniswapV3Factory.sol b/src/provider/interfaces/IUniswapV3Factory.sol deleted file mode 100644 index 38127cd0..00000000 --- a/src/provider/interfaces/IUniswapV3Factory.sol +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.34; - -/// @title Minimal interface for Uniswap V3 / PancakeSwap V3 factory -interface IUniswapV3Factory { - /// @notice Returns the pool address for a given token pair and fee tier, or address(0) if none. - function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool); -} diff --git a/src/provider/interfaces/IUniswapV3Pool.sol b/src/provider/interfaces/IUniswapV3Pool.sol deleted file mode 100644 index 6d660a3a..00000000 --- a/src/provider/interfaces/IUniswapV3Pool.sol +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.34; - -/// @title Minimal interface for Uniswap V3 / PancakeSwap V3 pool -interface IUniswapV3Pool { - function token0() external view returns (address); - - function token1() external view returns (address); - - function fee() external view returns (uint24); - - /// @return sqrtPriceX96 Current sqrt price as Q64.96 - /// @return tick Current tick - function slot0() - external - view - returns ( - uint160 sqrtPriceX96, - int24 tick, - uint16 observationIndex, - uint16 observationCardinality, - uint16 observationCardinalityNext, - uint32 feeProtocol, - bool unlocked - ); - - /// @param secondsAgos Array of seconds in the past to query - /// @return tickCumulatives Cumulative tick values for each secondsAgo - /// @return secondsPerLiquidityCumulativeX128s Cumulative seconds-per-liquidity for each secondsAgo - function observe( - uint32[] calldata secondsAgos - ) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); - - /// @notice Swap token0 for token1, or token1 for token0 - /// @param recipient Address to receive the output tokens - /// @param zeroForOne True if swapping token0 → token1, false if token1 → token0 - /// @param amountSpecified Exact input (positive) or exact output (negative) - /// @param sqrtPriceLimitX96 Price limit; use MIN_SQRT_RATIO+1 for zeroForOne, MAX_SQRT_RATIO-1 otherwise - /// @param data Arbitrary data forwarded to the swap callback - function swap( - address recipient, - bool zeroForOne, - int256 amountSpecified, - uint160 sqrtPriceLimitX96, - bytes calldata data - ) external returns (int256 amount0, int256 amount1); -} diff --git a/test/liquidator/V3Liquidator.t.sol b/test/liquidator/V3Liquidator.t.sol index 4daf0128..b4862d8f 100644 --- a/test/liquidator/V3Liquidator.t.sol +++ b/test/liquidator/V3Liquidator.t.sol @@ -8,7 +8,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { V3Provider } from "../../src/provider/V3Provider.sol"; import { V3Liquidator } from "../../src/liquidator/V3Liquidator.sol"; -import { IUniswapV3Pool } from "../../src/provider/interfaces/IUniswapV3Pool.sol"; +import { IListaV3Pool } from "../../src/dex/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"; @@ -67,7 +67,7 @@ contract V3LiquidatorTest is Test { moolah = Moolah(MOOLAH_PROXY); // Deploy V3Provider. - (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); V3Provider implP = new V3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); provider = V3Provider( payable( diff --git a/test/provider/V3Provider.t.sol b/test/provider/V3Provider.t.sol index 9844898d..2f58aeca 100644 --- a/test/provider/V3Provider.t.sol +++ b/test/provider/V3Provider.t.sol @@ -7,7 +7,7 @@ import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { V3Provider } from "../../src/provider/V3Provider.sol"; -import { IUniswapV3Pool } from "../../src/provider/interfaces/IUniswapV3Pool.sol"; +import { IListaV3Pool } from "../../src/dex/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"; @@ -25,14 +25,14 @@ contract PoolSwapper { /// zeroForOne = false → token1 in, token0 out (price moves up) function swapExactIn(address pool, bool zeroForOne, uint256 amountIn) external { uint160 limit = zeroForOne ? MIN_SQRT_RATIO + 1 : MAX_SQRT_RATIO - 1; - IUniswapV3Pool(pool).swap(address(this), zeroForOne, int256(amountIn), limit, abi.encode(pool)); + IListaV3Pool(pool).swap(address(this), zeroForOne, int256(amountIn), limit, abi.encode(pool)); } /// @dev PancakeSwap V3 swap callback — pay whatever the pool pulled. function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external { address pool = abi.decode(data, (address)); - if (amount0Delta > 0) IERC20(IUniswapV3Pool(pool).token0()).transfer(msg.sender, uint256(amount0Delta)); - if (amount1Delta > 0) IERC20(IUniswapV3Pool(pool).token1()).transfer(msg.sender, uint256(amount1Delta)); + if (amount0Delta > 0) IERC20(IListaV3Pool(pool).token0()).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) IERC20(IListaV3Pool(pool).token1()).transfer(msg.sender, uint256(amount1Delta)); } } @@ -85,7 +85,7 @@ contract V3ProviderTest is Test { moolah = Moolah(MOOLAH_PROXY); // Derive initial tick range from the live pool. - (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); int24 tickLower = currentTick - 500; int24 tickUpper = currentTick + 500; @@ -288,7 +288,7 @@ contract V3ProviderTest is Test { function test_rebalance_onlyBot() public { _deposit(user, 1_000 ether, 3 ether); - (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); int24 newLower = currentTick - 1000; int24 newUpper = currentTick + 1000; @@ -313,7 +313,7 @@ contract V3ProviderTest is Test { (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); - (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); (uint256 total0, uint256 total1) = provider.getTotalAmounts(); uint256 min0 = (total0 * 999) / 1000; uint256 min1 = (total1 * 999) / 1000; @@ -343,7 +343,7 @@ contract V3ProviderTest is Test { function test_getTwapTick_nearCurrentTick() public view { int24 twapTick = provider.getTwapTick(); - (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); // TWAP tick should be within a reasonable distance of the current tick. int256 diff = int256(currentTick) - int256(twapTick); @@ -377,14 +377,14 @@ contract V3ProviderTest is Test { // return tick cumulatives consistent with the current slot0 tick. Without this, // the 7-day warp shifts the TWAP window from real BSC history to pure extrapolation, // producing a spurious ~0.3% price delta that has nothing to do with fee compounding. - (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); int56[] memory tickCumulatives = new int56[](2); tickCumulatives[0] = 0; tickCumulatives[1] = int56(currentTick) * int56(uint56(TWAP_PERIOD)); uint160[] memory secondsPerLiq = new uint160[](2); vm.mockCall( POOL, - abi.encodeWithSelector(IUniswapV3Pool.observe.selector), + abi.encodeWithSelector(bytes4(keccak256("observe(uint32[])"))), abi.encode(tickCumulatives, secondsPerLiq) ); @@ -522,7 +522,7 @@ contract V3ProviderTest is Test { // Price is inside the tick range: preview predicts both tokens, withdraw returns both. (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); - (, int24 currentTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); assertGt(currentTick, provider.tickLower(), "price should be above tickLower"); assertLt(currentTick, provider.tickUpper(), "price should be below tickUpper"); @@ -819,7 +819,7 @@ contract V3ProviderTest is Test { // Push price below tickLower — position should convert entirely to USDC (token0). _pushPriceBelowRange(); - (, int24 tickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 tickAfterSwap, , , , , ) = IListaV3Pool(POOL).slot0(); assertLt(tickAfterSwap, provider.tickLower(), "tick should be below tickLower after swap"); (uint256 total0, uint256 total1) = provider.getTotalAmounts(); @@ -840,7 +840,7 @@ contract V3ProviderTest is Test { // Rebalance to a range entirely ABOVE the current (very low) tick so that // the entire range is below current price → only token0 (USDC) is needed to mint. - (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); int24 newLower = newTick + 100; int24 newUpper = newTick + 600; @@ -879,7 +879,7 @@ contract V3ProviderTest is Test { // Push price above tickUpper — position should convert entirely to WBNB (token1). _pushPriceAboveRange(); - (, int24 tickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 tickAfterSwap, , , , , ) = IListaV3Pool(POOL).slot0(); assertGt(tickAfterSwap, provider.tickUpper(), "tick should be above tickUpper after swap"); (uint256 total0, uint256 total1) = provider.getTotalAmounts(); @@ -900,7 +900,7 @@ contract V3ProviderTest is Test { // Rebalance to a range entirely BELOW the current (very high) tick so that // the entire range is above current price → only token1 (WBNB) is needed to mint. - (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); int24 newLower = newTick - 600; int24 newUpper = newTick - 100; @@ -931,7 +931,7 @@ contract V3ProviderTest is Test { (uint256 total0, ) = provider.getTotalAmounts(); assertGt(total0, 0, "should hold USDC before rebalance"); - (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); // minAmount0 = total0 (exact), minAmount1 = 0 (position has no WBNB). // amount0Desired = total0, amount1Desired = 0 (reinvest all USDC, no WBNB available). @@ -947,7 +947,7 @@ contract V3ProviderTest is Test { (uint256 total0, ) = provider.getTotalAmounts(); - (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); // minAmount0 one unit above actual → should revert with NPM slippage check. // amount0Desired = total0 (correct available), minAmount0 = total0 + 1 (too tight). @@ -965,7 +965,7 @@ contract V3ProviderTest is Test { (, uint256 total1) = provider.getTotalAmounts(); assertGt(total1, 0, "should hold WBNB before rebalance"); - (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); // minAmount0 = 0 (no USDC), minAmount1 = total1 (exact). // amount0Desired = 0, amount1Desired = total1 (reinvest all WBNB). @@ -981,7 +981,7 @@ contract V3ProviderTest is Test { (, uint256 total1) = provider.getTotalAmounts(); - (, int24 newTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); // minAmount1 one unit above actual → should revert with NPM slippage check. // amount1Desired = total1 (correct available), minAmount1 = total1 + 1 (too tight). @@ -1583,7 +1583,7 @@ contract V3ProviderTest is Test { // TWAP (30-min average) barely moves — it still reflects the old price range. _pushPriceBelowRange(); - (, int24 spotTickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 spotTickAfterSwap, , , , , ) = IListaV3Pool(POOL).slot0(); int24 twapTickAfterSwap = provider.getTwapTick(); // Confirm TWAP is still well above spot — the stale window. @@ -1657,7 +1657,7 @@ contract V3ProviderTest is Test { _pushPriceBelowRange(); - (, int24 spotTickAfterSwap, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 spotTickAfterSwap, , , , , ) = IListaV3Pool(POOL).slot0(); int24 newLower = spotTickAfterSwap + 100; int24 newUpper = spotTickAfterSwap + 500; (uint256 t0, uint256 t1) = provider.getTotalAmounts(); @@ -1676,7 +1676,7 @@ contract V3ProviderTest is Test { vm.prank(manager); provider.setMaxTickDeviation(5000); - (, int24 spotTick, , , , , ) = IUniswapV3Pool(POOL).slot0(); + (, int24 spotTick, , , , , ) = IListaV3Pool(POOL).slot0(); int24 twapTick = provider.getTwapTick(); int24 delta = twapTick > spotTick ? twapTick - spotTick : spotTick - twapTick; assertLt(uint24(delta), 5000, "deviation should be within limit"); From e1759f8e52ed312c71bc061ac1e78008b460c4f5 Mon Sep 17 00:00:00 2001 From: Razorback Date: Sun, 29 Mar 2026 14:31:14 +0800 Subject: [PATCH 04/17] feat: make V3 UUPS upgradable --- .gitmodules | 6 - foundry.toml | 2 - lib/v3-core | 1 - lib/v3-periphery | 1 - src/dex/v3/core/ListaV3Factory.sol | 38 +- src/dex/v3/core/ListaV3Pool.sol | 23 +- src/dex/v3/core/ListaV3PoolDeployer.sol | 5 - .../periphery/NonfungiblePositionManager.sol | 53 +- .../NonfungibleTokenPositionDescriptor.sol | 8 +- src/dex/v3/periphery/SwapRouter.sol | 3 +- .../v3/periphery/base/LiquidityManagement.sol | 2 +- .../base/PeripheryImmutableState.sol | 9 +- src/dex/v3/periphery/lens/Quoter.sol | 3 +- src/dex/v3/periphery/lens/QuoterV2.sol | 3 +- .../libraries/CallbackValidation.sol | 13 +- .../v3/periphery/libraries/PoolAddress.sol | 22 +- .../v3/periphery/libraries/PositionValue.sol | 6 +- test/dex/v3/ListaV3.t.sol | 576 ++++++++++++++++++ 18 files changed, 689 insertions(+), 85 deletions(-) delete mode 160000 lib/v3-core delete mode 160000 lib/v3-periphery create mode 100644 test/dex/v3/ListaV3.t.sol diff --git a/.gitmodules b/.gitmodules index 50fd7241..acda2ad9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,9 +18,3 @@ [submodule "lib/murky"] path = lib/murky url = https://github.com/dmfxyz/murky -[submodule "lib/v3-core"] - path = lib/v3-core - url = https://github.com/Uniswap/v3-core -[submodule "lib/v3-periphery"] - path = lib/v3-periphery - url = https://github.com/uniswap/v3-periphery diff --git a/foundry.toml b/foundry.toml index 8bcada73..b2504df8 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,8 +4,6 @@ src = "src" out = "out" libs = ["lib"] remappings = [ - "@uniswap/v3-core/=lib/v3-core/", - "@uniswap/v3-periphery/=lib/v3-periphery/", "@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/", ] solc = "0.8.34" diff --git a/lib/v3-core b/lib/v3-core deleted file mode 160000 index 6562c52e..00000000 --- a/lib/v3-core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6562c52e8f75f0c10f9deaf44861847585fc8129 diff --git a/lib/v3-periphery b/lib/v3-periphery deleted file mode 160000 index b325bb09..00000000 --- a/lib/v3-periphery +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b325bb0905d922ae61fcc7df85ee802e8df5e96c diff --git a/src/dex/v3/core/ListaV3Factory.sol b/src/dex/v3/core/ListaV3Factory.sol index 39fb30b8..2112614e 100644 --- a/src/dex/v3/core/ListaV3Factory.sol +++ b/src/dex/v3/core/ListaV3Factory.sol @@ -2,15 +2,16 @@ pragma solidity 0.8.34; import { IListaV3Factory } from "./interfaces/IListaV3Factory.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import { ListaV3PoolDeployer } from "./ListaV3PoolDeployer.sol"; -import { NoDelegateCall } from "./NoDelegateCall.sol"; import { ListaV3Pool } from "./ListaV3Pool.sol"; -/// @title Canonical Lista V3 factory +/// @title Canonical Lista V3 factory (UUPS upgradeable) /// @notice Deploys Lista V3 pools and manages ownership and control over pool protocol fees -contract ListaV3Factory is IListaV3Factory, ListaV3PoolDeployer, NoDelegateCall { +contract ListaV3Factory is IListaV3Factory, ListaV3PoolDeployer, Initializable, UUPSUpgradeable { /// @inheritdoc IListaV3Factory address public override owner; @@ -19,9 +20,15 @@ contract ListaV3Factory is IListaV3Factory, ListaV3PoolDeployer, NoDelegateCall /// @inheritdoc IListaV3Factory mapping(address => mapping(address => mapping(uint24 => address))) public override getPool; + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { - owner = msg.sender; - emit OwnerChanged(address(0), msg.sender); + _disableInitializers(); + } + + function initialize(address _owner) external initializer { + require(_owner != address(0), "zero address"); + owner = _owner; + emit OwnerChanged(address(0), _owner); feeAmountTickSpacing[500] = 10; emit FeeAmountEnabled(500, 10); @@ -32,11 +39,7 @@ contract ListaV3Factory is IListaV3Factory, ListaV3PoolDeployer, NoDelegateCall } /// @inheritdoc IListaV3Factory - function createPool( - address tokenA, - address tokenB, - uint24 fee - ) external override noDelegateCall returns (address pool) { + function createPool(address tokenA, address tokenB, uint24 fee) external override returns (address pool) { require(tokenA != tokenB); (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); require(token0 != address(0)); @@ -45,7 +48,6 @@ contract ListaV3Factory is IListaV3Factory, ListaV3PoolDeployer, NoDelegateCall require(getPool[token0][token1][fee] == address(0)); pool = deploy(address(this), token0, token1, fee, tickSpacing); getPool[token0][token1][fee] = pool; - // populate mapping in the reverse direction, deliberate choice to avoid the cost of comparing addresses getPool[token1][token0][fee] = pool; emit PoolCreated(token0, token1, fee, tickSpacing, pool); } @@ -61,13 +63,21 @@ contract ListaV3Factory is IListaV3Factory, ListaV3PoolDeployer, NoDelegateCall function enableFeeAmount(uint24 fee, int24 tickSpacing) public override { require(msg.sender == owner); require(fee < 1000000); - // tick spacing is capped at 16384 to prevent the situation where tickSpacing is so large that - // TickBitmap#nextInitializedTickWithinOneWord overflows int24 container from a valid tick - // 16384 ticks represents a >5x price change with ticks of 1 bips require(tickSpacing > 0 && tickSpacing < 16384); require(feeAmountTickSpacing[fee] == 0); feeAmountTickSpacing[fee] = tickSpacing; emit FeeAmountEnabled(fee, tickSpacing); } + + /// @notice Returns the init code hash for pool deployment. + /// Used by periphery contracts (PoolAddress.computeAddress) to deterministically + /// derive pool addresses from (factory, token0, token1, fee). + function poolInitCodeHash() external pure returns (bytes32) { + return keccak256(type(ListaV3Pool).creationCode); + } + + function _authorizeUpgrade(address) internal override { + require(msg.sender == owner); + } } diff --git a/src/dex/v3/core/ListaV3Pool.sol b/src/dex/v3/core/ListaV3Pool.sol index 4e91aebe..1ffe4ace 100644 --- a/src/dex/v3/core/ListaV3Pool.sol +++ b/src/dex/v3/core/ListaV3Pool.sol @@ -3,8 +3,6 @@ pragma solidity 0.8.34; import { IListaV3PoolImmutables, IListaV3PoolState, IListaV3PoolActions, IListaV3PoolDerivedState, IListaV3PoolOwnerActions, IListaV3Pool } from "./interfaces/IListaV3Pool.sol"; -import { NoDelegateCall } from "./NoDelegateCall.sol"; - import { SafeCast } from "./libraries/SafeCast.sol"; import { Tick } from "./libraries/Tick.sol"; import { TickBitmap } from "./libraries/TickBitmap.sol"; @@ -25,7 +23,7 @@ import { IListaV3MintCallback } from "./interfaces/callback/IListaV3MintCallback import { IListaV3SwapCallback } from "./interfaces/callback/IListaV3SwapCallback.sol"; import { IListaV3FlashCallback } from "./interfaces/callback/IListaV3FlashCallback.sol"; -contract ListaV3Pool is IListaV3Pool, NoDelegateCall { +contract ListaV3Pool is IListaV3Pool { using SafeCast for uint256; using SafeCast for int256; using Tick for mapping(int24 => Tick.Info); @@ -160,7 +158,6 @@ contract ListaV3Pool is IListaV3Pool, NoDelegateCall { external view override - noDelegateCall returns (int56 tickCumulativeInside, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside) { checkTicks(tickLower, tickUpper); @@ -235,7 +232,6 @@ contract ListaV3Pool is IListaV3Pool, NoDelegateCall { external view override - noDelegateCall returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) { return @@ -250,7 +246,7 @@ contract ListaV3Pool is IListaV3Pool, NoDelegateCall { } /// @inheritdoc IListaV3PoolActions - function increaseObservationCardinalityNext(uint16 observationCardinalityNext) external override lock noDelegateCall { + function increaseObservationCardinalityNext(uint16 observationCardinalityNext) external override lock { uint16 observationCardinalityNextOld = slot0.observationCardinalityNext; // for the event uint16 observationCardinalityNextNew = observations.grow(observationCardinalityNextOld, observationCardinalityNext); slot0.observationCardinalityNext = observationCardinalityNextNew; @@ -297,7 +293,7 @@ contract ListaV3Pool is IListaV3Pool, NoDelegateCall { /// @return amount1 the amount of token1 owed to the pool, negative if the pool should pay the recipient function _modifyPosition( ModifyPositionParams memory params - ) private noDelegateCall returns (Position.Info storage position, int256 amount0, int256 amount1) { + ) private returns (Position.Info storage position, int256 amount0, int256 amount1) { checkTicks(params.tickLower, params.tickUpper); Slot0 memory _slot0 = slot0; // SLOAD for gas optimization @@ -439,7 +435,7 @@ contract ListaV3Pool is IListaV3Pool, NoDelegateCall { } /// @inheritdoc IListaV3PoolActions - /// @dev noDelegateCall is applied indirectly via _modifyPosition + /// @dev is applied indirectly via _modifyPosition function mint( address recipient, int24 tickLower, @@ -500,7 +496,7 @@ contract ListaV3Pool is IListaV3Pool, NoDelegateCall { } /// @inheritdoc IListaV3PoolActions - /// @dev noDelegateCall is applied indirectly via _modifyPosition + /// @dev is applied indirectly via _modifyPosition function burn( int24 tickLower, int24 tickUpper, @@ -587,7 +583,7 @@ contract ListaV3Pool is IListaV3Pool, NoDelegateCall { int256 amountSpecified, uint160 sqrtPriceLimitX96, bytes calldata data - ) external override noDelegateCall returns (int256 amount0, int256 amount1) { + ) external override returns (int256 amount0, int256 amount1) { if (amountSpecified == 0) revert AS(); Slot0 memory slot0Start = slot0; @@ -798,12 +794,7 @@ contract ListaV3Pool is IListaV3Pool, NoDelegateCall { } /// @inheritdoc IListaV3PoolActions - function flash( - address recipient, - uint256 amount0, - uint256 amount1, - bytes calldata data - ) external override lock noDelegateCall { + function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external override lock { uint128 _liquidity = liquidity; if (_liquidity <= 0) revert L(); diff --git a/src/dex/v3/core/ListaV3PoolDeployer.sol b/src/dex/v3/core/ListaV3PoolDeployer.sol index e9724f4e..3ac2fecd 100644 --- a/src/dex/v3/core/ListaV3PoolDeployer.sol +++ b/src/dex/v3/core/ListaV3PoolDeployer.sol @@ -19,11 +19,6 @@ contract ListaV3PoolDeployer is IListaV3PoolDeployer { /// @dev Deploys a pool with the given parameters by transiently setting the parameters storage slot and then /// clearing it after deploying the pool. - /// @param factory The contract address of the Lista V3 factory - /// @param token0 The first token of the pool by address sort order - /// @param token1 The second token of the pool by address sort order - /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip - /// @param tickSpacing The spacing between usable ticks function deploy( address factory, address token0, diff --git a/src/dex/v3/periphery/NonfungiblePositionManager.sol b/src/dex/v3/periphery/NonfungiblePositionManager.sol index e25e595c..094f2a3e 100644 --- a/src/dex/v3/periphery/NonfungiblePositionManager.sol +++ b/src/dex/v3/periphery/NonfungiblePositionManager.sol @@ -6,6 +6,9 @@ import "../core/interfaces/IListaV3Pool.sol"; import "../core/libraries/FixedPoint128.sol"; import "../core/libraries/FullMath.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; + import "./interfaces/INonfungiblePositionManager.sol"; import "./interfaces/INonfungibleTokenPositionDescriptor.sol"; import "./libraries/PositionKey.sol"; @@ -28,7 +31,9 @@ contract NonfungiblePositionManager is PoolInitializer, LiquidityManagement, PeripheryValidation, - SelfPermit + SelfPermit, + Initializable, + UUPSUpgradeable { // details about the lista position struct Position { @@ -66,14 +71,48 @@ contract NonfungiblePositionManager is uint80 private _nextPoolId = 1; /// @dev The address of the token descriptor contract, which handles generating token URIs for position tokens - address private immutable _tokenDescriptor; + address private _tokenDescriptor; + + /// @dev Owner for upgrade authorization + address public admin; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() + ERC721Permit("Lista V3 Positions NFT-V1", "LISTA-V3-POS", "1") + PeripheryImmutableState(address(0), address(0)) + { + _disableInitializers(); + } - constructor( + function initialize( address _factory, address _WETH9, - address _tokenDescriptor_ - ) ERC721Permit("Lista V3 Positions NFT-V1", "UNI-V3-POS", "1") PeripheryImmutableState(_factory, _WETH9) { + address _tokenDescriptor_, + address _admin, + bytes32 _poolInitCodeHash + ) external initializer { + require(_factory != address(0) && _WETH9 != address(0) && _admin != address(0), "zero address"); + factory = _factory; + WETH9 = _WETH9; + poolInitCodeHash = _poolInitCodeHash; _tokenDescriptor = _tokenDescriptor_; + admin = _admin; + _nextId = 1; + _nextPoolId = 1; + } + + /// @dev Override name/symbol to return constants — avoids reliance on ERC721 storage + /// which is set by the constructor (runs on implementation, not proxy). + function name() public pure override(ERC721, IERC721Metadata) returns (string memory) { + return "Lista V3 Positions NFT-V1"; + } + + function symbol() public pure override(ERC721, IERC721Metadata) returns (string memory) { + return "LISTA-V3-POS"; + } + + function _authorizeUpgrade(address) internal override { + require(msg.sender == admin, "not admin"); } /// @inheritdoc INonfungiblePositionManager @@ -268,7 +307,7 @@ contract NonfungiblePositionManager is require(positionLiquidity >= params.liquidity); PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; - IListaV3Pool pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey)); + IListaV3Pool pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey, poolInitCodeHash)); (amount0, amount1) = pool.burn(position.tickLower, position.tickUpper, params.liquidity); require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, "Price slippage check"); @@ -316,7 +355,7 @@ contract NonfungiblePositionManager is PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; - IListaV3Pool pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey)); + IListaV3Pool pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey, poolInitCodeHash)); (uint128 tokensOwed0, uint128 tokensOwed1) = (position.tokensOwed0, position.tokensOwed1); diff --git a/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol b/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol index 0abcc022..dcd08fbb 100644 --- a/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol +++ b/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.34; pragma abicoder v2; import "../core/interfaces/IListaV3Pool.sol"; +import "../core/interfaces/IListaV3Factory.sol"; import "./libraries/SafeERC20Namer.sol"; import "./libraries/ChainId.sol"; @@ -52,12 +53,7 @@ contract NonfungibleTokenPositionDescriptor is INonfungibleTokenPositionDescript (, , address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, , , , , ) = positionManager .positions(tokenId); - IListaV3Pool pool = IListaV3Pool( - PoolAddress.computeAddress( - positionManager.factory(), - PoolAddress.PoolKey({ token0: token0, token1: token1, fee: fee }) - ) - ); + IListaV3Pool pool = IListaV3Pool(IListaV3Factory(positionManager.factory()).getPool(token0, token1, fee)); bool _flipRatio = flipRatio(token0, token1, ChainId.get()); address quoteTokenAddress = !_flipRatio ? token1 : token0; diff --git a/src/dex/v3/periphery/SwapRouter.sol b/src/dex/v3/periphery/SwapRouter.sol index 0d2e0d82..de564944 100644 --- a/src/dex/v3/periphery/SwapRouter.sol +++ b/src/dex/v3/periphery/SwapRouter.sol @@ -41,7 +41,8 @@ contract SwapRouter is /// @dev Returns the pool for the given token pair and fee. The pool contract may or may not exist. function getPool(address tokenA, address tokenB, uint24 fee) private view returns (IListaV3Pool) { - return IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee))); + return + IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee), poolInitCodeHash)); } struct SwapCallbackData { diff --git a/src/dex/v3/periphery/base/LiquidityManagement.sol b/src/dex/v3/periphery/base/LiquidityManagement.sol index dbd58795..5e9f1fd6 100644 --- a/src/dex/v3/periphery/base/LiquidityManagement.sol +++ b/src/dex/v3/periphery/base/LiquidityManagement.sol @@ -53,7 +53,7 @@ abstract contract LiquidityManagement is IListaV3MintCallback, PeripheryImmutabl fee: params.fee }); - pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey)); + pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey, poolInitCodeHash)); // compute the liquidity amount { diff --git a/src/dex/v3/periphery/base/PeripheryImmutableState.sol b/src/dex/v3/periphery/base/PeripheryImmutableState.sol index 76944907..d433ebad 100644 --- a/src/dex/v3/periphery/base/PeripheryImmutableState.sol +++ b/src/dex/v3/periphery/base/PeripheryImmutableState.sol @@ -4,12 +4,15 @@ pragma solidity 0.8.34; import "../interfaces/IPeripheryImmutableState.sol"; /// @title Immutable state -/// @notice Immutable state used by periphery contracts +/// @notice State used by periphery contracts — stored as regular storage for UUPS compatibility. abstract contract PeripheryImmutableState is IPeripheryImmutableState { /// @inheritdoc IPeripheryImmutableState - address public immutable override factory; + address public override factory; /// @inheritdoc IPeripheryImmutableState - address public immutable override WETH9; + address public override WETH9; + + /// @dev The keccak256 of the pool proxy creation code, used to compute pool addresses. + bytes32 public poolInitCodeHash; constructor(address _factory, address _WETH9) { factory = _factory; diff --git a/src/dex/v3/periphery/lens/Quoter.sol b/src/dex/v3/periphery/lens/Quoter.sol index 9d3e0a4b..bb077b02 100644 --- a/src/dex/v3/periphery/lens/Quoter.sol +++ b/src/dex/v3/periphery/lens/Quoter.sol @@ -27,7 +27,8 @@ contract Quoter is IQuoter, IListaV3SwapCallback, PeripheryImmutableState { constructor(address _factory, address _WETH9) PeripheryImmutableState(_factory, _WETH9) {} function getPool(address tokenA, address tokenB, uint24 fee) private view returns (IListaV3Pool) { - return IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee))); + return + IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee), poolInitCodeHash)); } /// @inheritdoc IListaV3SwapCallback diff --git a/src/dex/v3/periphery/lens/QuoterV2.sol b/src/dex/v3/periphery/lens/QuoterV2.sol index e7440f00..5c855bb8 100644 --- a/src/dex/v3/periphery/lens/QuoterV2.sol +++ b/src/dex/v3/periphery/lens/QuoterV2.sol @@ -30,7 +30,8 @@ contract QuoterV2 is IQuoterV2, IListaV3SwapCallback, PeripheryImmutableState { constructor(address _factory, address _WETH9) PeripheryImmutableState(_factory, _WETH9) {} function getPool(address tokenA, address tokenB, uint24 fee) private view returns (IListaV3Pool) { - return IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee))); + return + IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee), poolInitCodeHash)); } /// @inheritdoc IListaV3SwapCallback diff --git a/src/dex/v3/periphery/libraries/CallbackValidation.sol b/src/dex/v3/periphery/libraries/CallbackValidation.sol index 5dc0e0de..a2c4595a 100644 --- a/src/dex/v3/periphery/libraries/CallbackValidation.sol +++ b/src/dex/v3/periphery/libraries/CallbackValidation.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.34; import "../../core/interfaces/IListaV3Pool.sol"; +import "../../core/interfaces/IListaV3Factory.sol"; import "./PoolAddress.sol"; /// @notice Provides validation for callbacks from Lista V3 Pools @@ -18,7 +19,15 @@ library CallbackValidation { address tokenB, uint24 fee ) internal view returns (IListaV3Pool pool) { - return verifyCallback(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee)); + return + verifyCallback( + factory, + PoolAddress.PoolKey({ + token0: tokenA < tokenB ? tokenA : tokenB, + token1: tokenA < tokenB ? tokenB : tokenA, + fee: fee + }) + ); } /// @notice Returns the address of a valid Lista V3 Pool @@ -29,7 +38,7 @@ library CallbackValidation { address factory, PoolAddress.PoolKey memory poolKey ) internal view returns (IListaV3Pool pool) { - pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey)); + pool = IListaV3Pool(IListaV3Factory(factory).getPool(poolKey.token0, poolKey.token1, poolKey.fee)); require(msg.sender == address(pool)); } } diff --git a/src/dex/v3/periphery/libraries/PoolAddress.sol b/src/dex/v3/periphery/libraries/PoolAddress.sol index 7614e718..dbefc927 100644 --- a/src/dex/v3/periphery/libraries/PoolAddress.sol +++ b/src/dex/v3/periphery/libraries/PoolAddress.sol @@ -3,8 +3,6 @@ pragma solidity >=0.5.0; /// @title Provides functions for deriving a pool address from the factory, tokens, and the fee library PoolAddress { - bytes32 internal constant POOL_INIT_CODE_HASH = 0xa598dd2fba360510c5a8f02f44423a4468e902df5857dbce3ca162a43a3a31ff; - /// @notice The identifying key of the pool struct PoolKey { address token0; @@ -13,31 +11,27 @@ library PoolAddress { } /// @notice Returns PoolKey: the ordered tokens with the matched fee levels - /// @param tokenA The first token of a pool, unsorted - /// @param tokenB The second token of a pool, unsorted - /// @param fee The fee level of the pool - /// @return Poolkey The pool details with ordered token0 and token1 assignments function getPoolKey(address tokenA, address tokenB, uint24 fee) internal pure returns (PoolKey memory) { if (tokenA > tokenB) (tokenA, tokenB) = (tokenB, tokenA); return PoolKey({ token0: tokenA, token1: tokenB, fee: fee }); } - /// @notice Deterministically computes the pool address given the factory and PoolKey + /// @notice Deterministically computes the pool address given the factory, PoolKey, and init code hash /// @param factory The Lista V3 factory contract address /// @param key The PoolKey + /// @param initCodeHash The keccak256 of the pool proxy creation code (from factory.poolInitCodeHash()) /// @return pool The contract address of the V3 pool - function computeAddress(address factory, PoolKey memory key) internal pure returns (address pool) { + function computeAddress( + address factory, + PoolKey memory key, + bytes32 initCodeHash + ) internal pure returns (address pool) { require(key.token0 < key.token1); pool = address( uint160( uint256( keccak256( - abi.encodePacked( - hex"ff", - factory, - keccak256(abi.encode(key.token0, key.token1, key.fee)), - POOL_INIT_CODE_HASH - ) + abi.encodePacked(hex"ff", factory, keccak256(abi.encode(key.token0, key.token1, key.fee)), initCodeHash) ) ) ) diff --git a/src/dex/v3/periphery/libraries/PositionValue.sol b/src/dex/v3/periphery/libraries/PositionValue.sol index 147ecf94..5b991f9b 100644 --- a/src/dex/v3/periphery/libraries/PositionValue.sol +++ b/src/dex/v3/periphery/libraries/PositionValue.sol @@ -2,6 +2,7 @@ pragma solidity >=0.6.8 <0.9.0; import "../../core/interfaces/IListaV3Pool.sol"; +import "../../core/interfaces/IListaV3Factory.sol"; import "../../core/libraries/FixedPoint128.sol"; import "../../core/libraries/TickMath.sol"; import "../../core/libraries/Tick.sol"; @@ -113,10 +114,7 @@ library PositionValue { ) private view returns (uint256 amount0, uint256 amount1) { (uint256 poolFeeGrowthInside0LastX128, uint256 poolFeeGrowthInside1LastX128) = _getFeeGrowthInside( IListaV3Pool( - PoolAddress.computeAddress( - positionManager.factory(), - PoolAddress.PoolKey({ token0: feeParams.token0, token1: feeParams.token1, fee: feeParams.fee }) - ) + IListaV3Factory(positionManager.factory()).getPool(feeParams.token0, feeParams.token1, feeParams.fee) ), feeParams.tickLower, feeParams.tickUpper diff --git a/test/dex/v3/ListaV3.t.sol b/test/dex/v3/ListaV3.t.sol new file mode 100644 index 00000000..245005bb --- /dev/null +++ b/test/dex/v3/ListaV3.t.sol @@ -0,0 +1,576 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import { ListaV3Factory } from "../../../src/dex/v3/core/ListaV3Factory.sol"; +import { ListaV3Pool } from "../../../src/dex/v3/core/ListaV3Pool.sol"; +import { IListaV3Pool } from "../../../src/dex/v3/core/interfaces/IListaV3Pool.sol"; +import { IListaV3MintCallback } from "../../../src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol"; +import { IListaV3SwapCallback } from "../../../src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol"; +import { TickMath } from "../../../src/dex/v3/core/libraries/TickMath.sol"; +import { LiquidityAmounts } from "../../../src/dex/v3/periphery/libraries/LiquidityAmounts.sol"; +import { NonfungiblePositionManager } from "../../../src/dex/v3/periphery/NonfungiblePositionManager.sol"; +import { INonfungiblePositionManager } from "../../../src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol"; +import { PoolAddress } from "../../../src/dex/v3/periphery/libraries/PoolAddress.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +/* ─────────────────────── Mock ERC20 ─────────────────────── */ + +contract MockERC20 is ERC20 { + uint8 private _dec; + + constructor(string memory name, string memory symbol, uint8 dec_) ERC20(name, symbol) { + _dec = dec_; + } + + function decimals() public view override returns (uint8) { + return _dec; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/* ───────────── Mock WETH9 (needed by NPM constructor) ───────────── */ + +contract MockWETH9 is MockERC20 { + constructor() MockERC20("Wrapped BNB", "WBNB", 18) {} + + function deposit() external payable { + _mint(msg.sender, msg.value); + } + + function withdraw(uint256 amount) external { + _burn(msg.sender, amount); + (bool ok, ) = msg.sender.call{ value: amount }(""); + require(ok); + } + + receive() external payable { + _mint(msg.sender, msg.value); + } +} + +/* ──────────── Mock token descriptor (returns empty URI) ──────────── */ + +contract MockTokenDescriptor { + function tokenURI(address, uint256) external pure returns (string memory) { + return ""; + } +} + +/* ═══════════════════════════════════════════════════════════════════ + Test Suite + ═══════════════════════════════════════════════════════════════════ */ + +contract ListaV3Test is Test, IListaV3MintCallback, IListaV3SwapCallback { + ListaV3Factory factory; + NonfungiblePositionManager npm; + MockWETH9 weth; + MockERC20 tokenA; + MockERC20 tokenB; + address token0; + address token1; + + uint24 constant FEE = 3000; + int24 constant TICK_SPACING = 60; + + // TickMath constants + uint160 constant MIN_SQRT_RATIO = 4295128739; + uint160 constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; + + function setUp() public { + // Deploy Factory behind UUPS proxy + ListaV3Factory factoryImpl = new ListaV3Factory(); + factory = ListaV3Factory( + address(new ERC1967Proxy(address(factoryImpl), abi.encodeCall(ListaV3Factory.initialize, (address(this))))) + ); + + weth = new MockWETH9(); + MockTokenDescriptor descriptor = new MockTokenDescriptor(); + + // Deploy NPM behind UUPS proxy + NonfungiblePositionManager npmImpl = new NonfungiblePositionManager(); + npm = NonfungiblePositionManager( + payable( + new ERC1967Proxy( + address(npmImpl), + abi.encodeCall( + NonfungiblePositionManager.initialize, + (address(factory), address(weth), address(descriptor), address(this), factory.poolInitCodeHash()) + ) + ) + ) + ); + + // Deploy tokens and sort + tokenA = new MockERC20("Token A", "TKA", 18); + tokenB = new MockERC20("Token B", "TKB", 18); + (token0, token1) = address(tokenA) < address(tokenB) + ? (address(tokenA), address(tokenB)) + : (address(tokenB), address(tokenA)); + } + + /* ─────────────────────── helpers ─────────────────────── */ + + function _createAndInitPool(uint160 sqrtPriceX96) internal returns (IListaV3Pool pool) { + address poolAddr = factory.createPool(token0, token1, FEE); + pool = IListaV3Pool(poolAddr); + pool.initialize(sqrtPriceX96); + } + + function _mintTokens(address to, uint256 amount0, uint256 amount1) internal { + MockERC20(token0).mint(to, amount0); + MockERC20(token1).mint(to, amount1); + } + + /* ─────────────── V3 pool callbacks (for direct pool interaction) ─────────────── */ + + function listaV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override { + if (amount0Owed > 0) IERC20(token0).transfer(msg.sender, amount0Owed); + if (amount1Owed > 0) IERC20(token1).transfer(msg.sender, amount1Owed); + } + + function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external override { + if (amount0Delta > 0) IERC20(token0).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) IERC20(token1).transfer(msg.sender, uint256(amount1Delta)); + } + + /* ═══════════════════════════════════════════════════════════ + Factory Tests + ═══════════════════════════════════════════════════════════ */ + + function test_factory_defaultFeeTiers() public view { + assertEq(factory.feeAmountTickSpacing(500), 10); + assertEq(factory.feeAmountTickSpacing(3000), 60); + assertEq(factory.feeAmountTickSpacing(10000), 200); + assertEq(factory.feeAmountTickSpacing(100), 0, "unsupported fee should return 0"); + } + + function test_factory_owner() public view { + assertEq(factory.owner(), address(this)); + } + + function test_factory_createPool() public { + address pool = factory.createPool(token0, token1, FEE); + assertTrue(pool != address(0), "pool should be created"); + assertEq(factory.getPool(token0, token1, FEE), pool); + assertEq(factory.getPool(token1, token0, FEE), pool, "reverse lookup should work"); + } + + function test_factory_createPool_revertsOnDuplicate() public { + factory.createPool(token0, token1, FEE); + vm.expectRevert(); + factory.createPool(token0, token1, FEE); + } + + function test_factory_createPool_revertsOnSameToken() public { + vm.expectRevert(); + factory.createPool(token0, token0, FEE); + } + + function test_factory_createPool_revertsOnUnsupportedFee() public { + vm.expectRevert(); + factory.createPool(token0, token1, 100); // 100 not enabled + } + + function test_factory_enableFeeAmount() public { + factory.enableFeeAmount(100, 1); + assertEq(factory.feeAmountTickSpacing(100), 1); + + // Can now create a pool with the new fee + address pool = factory.createPool(token0, token1, 100); + assertTrue(pool != address(0)); + } + + function test_factory_setOwner() public { + address newOwner = makeAddr("newOwner"); + factory.setOwner(newOwner); + assertEq(factory.owner(), newOwner); + } + + function test_factory_setOwner_revertsIfNotOwner() public { + vm.prank(makeAddr("rando")); + vm.expectRevert(); + factory.setOwner(makeAddr("newOwner")); + } + + /* ═══════════════════════════════════════════════════════════ + Pool Tests + ═══════════════════════════════════════════════════════════ */ + + function test_pool_initialize() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; // sqrt(1) * 2^96 ≈ price = 1.0 + IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); + + (uint160 sqrtPrice, int24 tick, , , , , ) = pool.slot0(); + assertEq(sqrtPrice, sqrtPriceX96); + assertEq(tick, 0, "tick should be 0 at price 1.0"); + assertEq(pool.token0(), token0); + assertEq(pool.token1(), token1); + assertEq(pool.fee(), FEE); + assertEq(pool.tickSpacing(), TICK_SPACING); + } + + function test_pool_initialize_revertsOnDouble() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; + IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); + vm.expectRevert(); + pool.initialize(sqrtPriceX96); + } + + function test_pool_mint() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; // price = 1.0 + IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); + + // Mint liquidity around tick 0 (price 1.0) + int24 tickLower = -TICK_SPACING; + int24 tickUpper = TICK_SPACING; + uint128 liquidityAmount = 1_000_000; + + _mintTokens(address(this), 10 ether, 10 ether); + + (uint256 amount0, uint256 amount1) = pool.mint(address(this), tickLower, tickUpper, liquidityAmount, ""); + assertGt(amount0, 0, "should consume token0"); + assertGt(amount1, 0, "should consume token1"); + + // Verify position + bytes32 posKey = keccak256(abi.encodePacked(address(this), tickLower, tickUpper)); + (uint128 liq, , , , ) = pool.positions(posKey); + assertEq(liq, liquidityAmount, "position liquidity should match"); + } + + function test_pool_swap() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; + IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); + + // Add wide-range liquidity + int24 tickLower = -600; + int24 tickUpper = 600; + uint128 liquidityAmount = 100_000_000_000; + _mintTokens(address(this), 100 ether, 100 ether); + pool.mint(address(this), tickLower, tickUpper, liquidityAmount, ""); + + // Swap token0 → token1 (zeroForOne = true, pushes price down) + uint256 swapAmount = 0.1 ether; + _mintTokens(address(this), swapAmount, 0); + uint256 bal1Before = IERC20(token1).balanceOf(address(this)); + + pool.swap(address(this), true, int256(swapAmount), MIN_SQRT_RATIO + 1, ""); + + uint256 bal1After = IERC20(token1).balanceOf(address(this)); + assertGt(bal1After, bal1Before, "should receive token1 from swap"); + + // Price should have moved down + (uint160 newSqrtPrice, , , , , , ) = pool.slot0(); + assertLt(newSqrtPrice, sqrtPriceX96, "price should decrease after zeroForOne swap"); + } + + function test_pool_burn_and_collect() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; + IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); + + int24 tickLower = -TICK_SPACING; + int24 tickUpper = TICK_SPACING; + uint128 liquidityAmount = 1_000_000; + _mintTokens(address(this), 10 ether, 10 ether); + pool.mint(address(this), tickLower, tickUpper, liquidityAmount, ""); + + // Burn all liquidity + (uint256 amount0, uint256 amount1) = pool.burn(tickLower, tickUpper, liquidityAmount); + assertGt(amount0 + amount1, 0, "should return tokens on burn"); + + // Collect + uint256 bal0Before = IERC20(token0).balanceOf(address(this)); + uint256 bal1Before = IERC20(token1).balanceOf(address(this)); + pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max); + uint256 collected0 = IERC20(token0).balanceOf(address(this)) - bal0Before; + uint256 collected1 = IERC20(token1).balanceOf(address(this)) - bal1Before; + + assertEq(collected0, amount0, "collected0 should match burned amount0"); + assertEq(collected1, amount1, "collected1 should match burned amount1"); + } + + function test_pool_observe() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; + IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); + + // Need to increase cardinality for TWAP + pool.increaseObservationCardinalityNext(10); + + // Add liquidity so the pool is active + _mintTokens(address(this), 10 ether, 10 ether); + pool.mint(address(this), -TICK_SPACING, TICK_SPACING, 1_000_000, ""); + + // Observation at time 0 should work + uint32[] memory secondsAgos = new uint32[](2); + secondsAgos[0] = 0; + secondsAgos[1] = 0; + (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos); + assertEq(tickCumulatives[0], tickCumulatives[1], "same timestamp should give same cumulative"); + } + + function test_pool_feeGrowth_afterSwaps() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; + IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); + + // Add liquidity + int24 tickLower = -600; + int24 tickUpper = 600; + _mintTokens(address(this), 100 ether, 100 ether); + pool.mint(address(this), tickLower, tickUpper, 100_000_000_000, ""); + + // Perform multiple swaps to generate fees + for (uint256 i = 0; i < 5; i++) { + _mintTokens(address(this), 1 ether, 1 ether); + pool.swap(address(this), true, int256(0.5 ether), MIN_SQRT_RATIO + 1, ""); + pool.swap(address(this), false, int256(0.5 ether), MAX_SQRT_RATIO - 1, ""); + } + + // Fee growth should be non-zero + uint256 fg0 = pool.feeGrowthGlobal0X128(); + uint256 fg1 = pool.feeGrowthGlobal1X128(); + assertGt(fg0 + fg1, 0, "fees should have accrued from swaps"); + } + + /* ═══════════════════════════════════════════════════════════ + NonfungiblePositionManager Tests + ═══════════════════════════════════════════════════════════ */ + + function test_npm_mintPosition() public { + // Create and init pool directly via factory (NPM uses computeAddress internally) + uint160 sqrtPriceX96 = 79228162514264337593543950336; + address poolAddr = factory.createPool(token0, token1, FEE); + IListaV3Pool(poolAddr).initialize(sqrtPriceX96); + + // Mint tokens and approve NPM + _mintTokens(address(this), 10 ether, 10 ether); + IERC20(token0).approve(address(npm), 10 ether); + IERC20(token1).approve(address(npm), 10 ether); + + (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) = npm.mint( + INonfungiblePositionManager.MintParams({ + token0: token0, + token1: token1, + fee: FEE, + tickLower: -TICK_SPACING, + tickUpper: TICK_SPACING, + amount0Desired: 1 ether, + amount1Desired: 1 ether, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + }) + ); + + assertEq(tokenId, 1, "first token ID should be 1"); + assertGt(liquidity, 0, "should have minted liquidity"); + assertGt(amount0 + amount1, 0, "should have consumed tokens"); + assertEq(npm.ownerOf(tokenId), address(this)); + } + + function test_npm_increaseLiquidity() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; + address poolAddr = factory.createPool(token0, token1, FEE); + IListaV3Pool(poolAddr).initialize(sqrtPriceX96); + + _mintTokens(address(this), 20 ether, 20 ether); + IERC20(token0).approve(address(npm), 20 ether); + IERC20(token1).approve(address(npm), 20 ether); + + (uint256 tokenId, uint128 liqBefore, , ) = npm.mint( + INonfungiblePositionManager.MintParams({ + token0: token0, + token1: token1, + fee: FEE, + tickLower: -TICK_SPACING, + tickUpper: TICK_SPACING, + amount0Desired: 1 ether, + amount1Desired: 1 ether, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + }) + ); + + (uint128 addedLiq, , ) = npm.increaseLiquidity( + INonfungiblePositionManager.IncreaseLiquidityParams({ + tokenId: tokenId, + amount0Desired: 1 ether, + amount1Desired: 1 ether, + amount0Min: 0, + amount1Min: 0, + deadline: block.timestamp + }) + ); + + assertGt(addedLiq, 0, "should add liquidity"); + + // Check total via positions() + (, , , , , , , uint128 totalLiq, , , , ) = npm.positions(tokenId); + assertEq(totalLiq, liqBefore + addedLiq, "total liquidity should be sum"); + } + + function test_npm_decreaseLiquidity_and_collect() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; + address poolAddr = factory.createPool(token0, token1, FEE); + IListaV3Pool(poolAddr).initialize(sqrtPriceX96); + + _mintTokens(address(this), 10 ether, 10 ether); + IERC20(token0).approve(address(npm), 10 ether); + IERC20(token1).approve(address(npm), 10 ether); + + (uint256 tokenId, uint128 liquidity, , ) = npm.mint( + INonfungiblePositionManager.MintParams({ + token0: token0, + token1: token1, + fee: FEE, + tickLower: -TICK_SPACING, + tickUpper: TICK_SPACING, + amount0Desired: 1 ether, + amount1Desired: 1 ether, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + }) + ); + + // Decrease all liquidity + (uint256 dec0, uint256 dec1) = npm.decreaseLiquidity( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: tokenId, + liquidity: liquidity, + amount0Min: 0, + amount1Min: 0, + deadline: block.timestamp + }) + ); + assertGt(dec0 + dec1, 0, "should return tokens"); + + // Collect + uint256 bal0Before = IERC20(token0).balanceOf(address(this)); + uint256 bal1Before = IERC20(token1).balanceOf(address(this)); + npm.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + uint256 collected0 = IERC20(token0).balanceOf(address(this)) - bal0Before; + uint256 collected1 = IERC20(token1).balanceOf(address(this)) - bal1Before; + assertGt(collected0 + collected1, 0, "should collect tokens"); + } + + function test_npm_burn() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; + address poolAddr = factory.createPool(token0, token1, FEE); + IListaV3Pool(poolAddr).initialize(sqrtPriceX96); + + _mintTokens(address(this), 10 ether, 10 ether); + IERC20(token0).approve(address(npm), 10 ether); + IERC20(token1).approve(address(npm), 10 ether); + + (uint256 tokenId, uint128 liquidity, , ) = npm.mint( + INonfungiblePositionManager.MintParams({ + token0: token0, + token1: token1, + fee: FEE, + tickLower: -TICK_SPACING, + tickUpper: TICK_SPACING, + amount0Desired: 1 ether, + amount1Desired: 1 ether, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + }) + ); + + // Must decrease + collect before burn + npm.decreaseLiquidity( + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: tokenId, + liquidity: liquidity, + amount0Min: 0, + amount1Min: 0, + deadline: block.timestamp + }) + ); + npm.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + + npm.burn(tokenId); + + vm.expectRevert(); + npm.ownerOf(tokenId); + } + + function test_npm_positions_returnsCorrectData() public { + uint160 sqrtPriceX96 = 79228162514264337593543950336; + address poolAddr = factory.createPool(token0, token1, FEE); + IListaV3Pool(poolAddr).initialize(sqrtPriceX96); + + _mintTokens(address(this), 10 ether, 10 ether); + IERC20(token0).approve(address(npm), 10 ether); + IERC20(token1).approve(address(npm), 10 ether); + + int24 tickLower = -120; + int24 tickUpper = 120; + + (uint256 tokenId, uint128 expectedLiq, , ) = npm.mint( + INonfungiblePositionManager.MintParams({ + token0: token0, + token1: token1, + fee: FEE, + tickLower: tickLower, + tickUpper: tickUpper, + amount0Desired: 1 ether, + amount1Desired: 1 ether, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + }) + ); + + (, , address t0, address t1, uint24 fee, int24 tl, int24 tu, uint128 liq, , , , ) = npm.positions(tokenId); + + assertEq(t0, token0); + assertEq(t1, token1); + assertEq(fee, FEE); + assertEq(tl, tickLower); + assertEq(tu, tickUpper); + assertEq(liq, expectedLiq); + } + + /* ═══════════════════════════════════════════════════════════ + Init Code Hash (utility test) + ═══════════════════════════════════════════════════════════ */ + + function test_poolInitCodeHash() public { + bytes32 hash = factory.poolInitCodeHash(); + assertGt(uint256(hash), 0, "poolInitCodeHash should be non-zero"); + emit log_named_bytes32("poolInitCodeHash", hash); + + // Verify the hash correctly derives pool addresses: + // create a pool, then check computeAddress matches + address pool = factory.createPool(token0, token1, FEE); + address derived = PoolAddress.computeAddress(address(factory), PoolAddress.PoolKey(token0, token1, FEE), hash); + assertEq(derived, pool, "computeAddress should match actual pool address"); + } +} From de1ec38f5276b73c283198090b77a0659a5bb322 Mon Sep 17 00:00:00 2001 From: Razorback Date: Thu, 2 Apr 2026 15:28:21 +0800 Subject: [PATCH 05/17] fix: update SPDX license headers and consolidate license files for V3 fork Remove scattered LICENSE files under dex/v3/core/ and add a single NOTICE file with proper attribution. Transition expired BUSL-1.1 headers to GPL-2.0-or-later and fix GPL-3.0 incompatibility in periphery libraries. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/dex/v3/NOTICE | 12 + src/dex/v3/core/ListaV3Factory.sol | 2 +- src/dex/v3/core/ListaV3Pool.sol | 2 +- src/dex/v3/core/ListaV3PoolDeployer.sol | 2 +- src/dex/v3/core/NoDelegateCall.sol | 2 +- src/dex/v3/core/interfaces/LICENSE | 445 ------------------ src/dex/v3/core/libraries/LICENSE | 445 ------------------ src/dex/v3/core/libraries/LICENSE_MIT | 20 - src/dex/v3/core/libraries/Oracle.sol | 2 +- src/dex/v3/core/libraries/Position.sol | 2 +- src/dex/v3/core/libraries/SqrtPriceMath.sol | 2 +- src/dex/v3/core/libraries/SwapMath.sol | 2 +- src/dex/v3/core/libraries/Tick.sol | 2 +- src/dex/v3/core/libraries/TickBitmap.sol | 2 +- .../periphery/libraries/AddressStringUtil.sol | 2 +- .../v3/periphery/libraries/SafeERC20Namer.sol | 2 +- 16 files changed, 24 insertions(+), 922 deletions(-) create mode 100644 src/dex/v3/NOTICE delete mode 100644 src/dex/v3/core/interfaces/LICENSE delete mode 100644 src/dex/v3/core/libraries/LICENSE delete mode 100644 src/dex/v3/core/libraries/LICENSE_MIT diff --git a/src/dex/v3/NOTICE b/src/dex/v3/NOTICE new file mode 100644 index 00000000..1f9c35ca --- /dev/null +++ b/src/dex/v3/NOTICE @@ -0,0 +1,12 @@ +This code is derived from Uniswap V3 (https://github.com/Uniswap/v3-core, +https://github.com/Uniswap/v3-periphery), originally licensed under +Business Source License 1.1, which transitioned to GPL-2.0-or-later +on 2023-04-01. + +Portions of src/dex/v3/core/libraries/ (FullMath, UnsafeMath) are derived +from work by Remco Bloemen, licensed under MIT. + +Modifications by Lista DAO to the original Uniswap V3 code are licensed +under GPL-2.0-or-later, consistent with the original license terms. + +Original files authored by Lista DAO are licensed under MIT. diff --git a/src/dex/v3/core/ListaV3Factory.sol b/src/dex/v3/core/ListaV3Factory.sol index 2112614e..c2d5d420 100644 --- a/src/dex/v3/core/ListaV3Factory.sol +++ b/src/dex/v3/core/ListaV3Factory.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; import { IListaV3Factory } from "./interfaces/IListaV3Factory.sol"; diff --git a/src/dex/v3/core/ListaV3Pool.sol b/src/dex/v3/core/ListaV3Pool.sol index 1ffe4ace..18f34698 100644 --- a/src/dex/v3/core/ListaV3Pool.sol +++ b/src/dex/v3/core/ListaV3Pool.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; import { IListaV3PoolImmutables, IListaV3PoolState, IListaV3PoolActions, IListaV3PoolDerivedState, IListaV3PoolOwnerActions, IListaV3Pool } from "./interfaces/IListaV3Pool.sol"; diff --git a/src/dex/v3/core/ListaV3PoolDeployer.sol b/src/dex/v3/core/ListaV3PoolDeployer.sol index 3ac2fecd..9711a334 100644 --- a/src/dex/v3/core/ListaV3PoolDeployer.sol +++ b/src/dex/v3/core/ListaV3PoolDeployer.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; import { IListaV3PoolDeployer } from "./interfaces/IListaV3PoolDeployer.sol"; diff --git a/src/dex/v3/core/NoDelegateCall.sol b/src/dex/v3/core/NoDelegateCall.sol index 7355564b..b5333244 100644 --- a/src/dex/v3/core/NoDelegateCall.sol +++ b/src/dex/v3/core/NoDelegateCall.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; /// @title Prevents delegatecall to a contract diff --git a/src/dex/v3/core/interfaces/LICENSE b/src/dex/v3/core/interfaces/LICENSE deleted file mode 100644 index 7f6aca78..00000000 --- a/src/dex/v3/core/interfaces/LICENSE +++ /dev/null @@ -1,445 +0,0 @@ -This software is available under your choice of the GNU General Public -License, version 2 or later, or the Business Source License, as set -forth below. - - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. - - -Business Source License 1.1 - -License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. -"Business Source License" is a trademark of MariaDB Corporation Ab. - ------------------------------------------------------------------------------ - -Parameters - -Licensor: Uniswap Labs - -Licensed Work: Uniswap V3 Core - The Licensed Work is (c) 2021 Uniswap Labs - -Additional Use Grant: Any uses listed and defined at - v3-core-license-grants.uniswap.eth - -Change Date: The earlier of 2023-04-01 or a date specified at - v3-core-license-date.uniswap.eth - -Change License: GNU General Public License v2.0 or later - ------------------------------------------------------------------------------ - -Terms - -The Licensor hereby grants you the right to copy, modify, create derivative -works, redistribute, and make non-production use of the Licensed Work. The -Licensor may make an Additional Use Grant, above, permitting limited -production use. - -Effective on the Change Date, or the fourth anniversary of the first publicly -available distribution of a specific version of the Licensed Work under this -License, whichever comes first, the Licensor hereby grants you rights under -the terms of the Change License, and the rights granted in the paragraph -above terminate. - -If your use of the Licensed Work does not comply with the requirements -currently in effect as described in this License, you must purchase a -commercial license from the Licensor, its affiliated entities, or authorized -resellers, or you must refrain from using the Licensed Work. - -All copies of the original and modified Licensed Work, and derivative works -of the Licensed Work, are subject to this License. This License applies -separately for each version of the Licensed Work and the Change Date may vary -for each version of the Licensed Work released by Licensor. - -You must conspicuously display this License on each original or modified copy -of the Licensed Work. If you receive the Licensed Work in original or -modified form from a third party, the terms and conditions set forth in this -License apply to your use of that work. - -Any use of the Licensed Work in violation of this License will automatically -terminate your rights under this License for the current and all other -versions of the Licensed Work. - -This License does not grant you any right in any trademark or logo of -Licensor or its affiliates (provided that you may use a trademark or logo of -Licensor as expressly required by this License). - -TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON -AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, -EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND -TITLE. - -MariaDB hereby grants you permission to use this License’s text to license -your works, and to refer to it using the trademark "Business Source License", -as long as you comply with the Covenants of Licensor below. - ------------------------------------------------------------------------------ - -Covenants of Licensor - -In consideration of the right to use this License’s text and the "Business -Source License" name and trademark, Licensor covenants to MariaDB, and to all -other recipients of the licensed work to be provided by Licensor: - -1. To specify as the Change License the GPL Version 2.0 or any later version, - or a license that is compatible with GPL Version 2.0 or a later version, - where "compatible" means that software provided under the Change License can - be included in a program with software provided under GPL Version 2.0 or a - later version. Licensor may specify additional Change Licenses without - limitation. - -2. To either: (a) specify an additional grant of rights to use that does not - impose any additional restriction on the right granted in this License, as - the Additional Use Grant; or (b) insert the text "None". - -3. To specify a Change Date. - -4. Not to modify this License in any other way. - ------------------------------------------------------------------------------ - -Notice - -The Business Source License (this document, or the "License") is not an Open -Source license. However, the Licensed Work will eventually be made available -under an Open Source License, as stated in this License. diff --git a/src/dex/v3/core/libraries/LICENSE b/src/dex/v3/core/libraries/LICENSE deleted file mode 100644 index 7f6aca78..00000000 --- a/src/dex/v3/core/libraries/LICENSE +++ /dev/null @@ -1,445 +0,0 @@ -This software is available under your choice of the GNU General Public -License, version 2 or later, or the Business Source License, as set -forth below. - - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. - - -Business Source License 1.1 - -License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. -"Business Source License" is a trademark of MariaDB Corporation Ab. - ------------------------------------------------------------------------------ - -Parameters - -Licensor: Uniswap Labs - -Licensed Work: Uniswap V3 Core - The Licensed Work is (c) 2021 Uniswap Labs - -Additional Use Grant: Any uses listed and defined at - v3-core-license-grants.uniswap.eth - -Change Date: The earlier of 2023-04-01 or a date specified at - v3-core-license-date.uniswap.eth - -Change License: GNU General Public License v2.0 or later - ------------------------------------------------------------------------------ - -Terms - -The Licensor hereby grants you the right to copy, modify, create derivative -works, redistribute, and make non-production use of the Licensed Work. The -Licensor may make an Additional Use Grant, above, permitting limited -production use. - -Effective on the Change Date, or the fourth anniversary of the first publicly -available distribution of a specific version of the Licensed Work under this -License, whichever comes first, the Licensor hereby grants you rights under -the terms of the Change License, and the rights granted in the paragraph -above terminate. - -If your use of the Licensed Work does not comply with the requirements -currently in effect as described in this License, you must purchase a -commercial license from the Licensor, its affiliated entities, or authorized -resellers, or you must refrain from using the Licensed Work. - -All copies of the original and modified Licensed Work, and derivative works -of the Licensed Work, are subject to this License. This License applies -separately for each version of the Licensed Work and the Change Date may vary -for each version of the Licensed Work released by Licensor. - -You must conspicuously display this License on each original or modified copy -of the Licensed Work. If you receive the Licensed Work in original or -modified form from a third party, the terms and conditions set forth in this -License apply to your use of that work. - -Any use of the Licensed Work in violation of this License will automatically -terminate your rights under this License for the current and all other -versions of the Licensed Work. - -This License does not grant you any right in any trademark or logo of -Licensor or its affiliates (provided that you may use a trademark or logo of -Licensor as expressly required by this License). - -TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON -AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, -EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND -TITLE. - -MariaDB hereby grants you permission to use this License’s text to license -your works, and to refer to it using the trademark "Business Source License", -as long as you comply with the Covenants of Licensor below. - ------------------------------------------------------------------------------ - -Covenants of Licensor - -In consideration of the right to use this License’s text and the "Business -Source License" name and trademark, Licensor covenants to MariaDB, and to all -other recipients of the licensed work to be provided by Licensor: - -1. To specify as the Change License the GPL Version 2.0 or any later version, - or a license that is compatible with GPL Version 2.0 or a later version, - where "compatible" means that software provided under the Change License can - be included in a program with software provided under GPL Version 2.0 or a - later version. Licensor may specify additional Change Licenses without - limitation. - -2. To either: (a) specify an additional grant of rights to use that does not - impose any additional restriction on the right granted in this License, as - the Additional Use Grant; or (b) insert the text "None". - -3. To specify a Change Date. - -4. Not to modify this License in any other way. - ------------------------------------------------------------------------------ - -Notice - -The Business Source License (this document, or the "License") is not an Open -Source license. However, the Licensed Work will eventually be made available -under an Open Source License, as stated in this License. diff --git a/src/dex/v3/core/libraries/LICENSE_MIT b/src/dex/v3/core/libraries/LICENSE_MIT deleted file mode 100644 index bf4f90a2..00000000 --- a/src/dex/v3/core/libraries/LICENSE_MIT +++ /dev/null @@ -1,20 +0,0 @@ -Copyright (c) 2021 Remco Bloemen - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/src/dex/v3/core/libraries/Oracle.sol b/src/dex/v3/core/libraries/Oracle.sol index 7b833b93..bf338ac5 100644 --- a/src/dex/v3/core/libraries/Oracle.sol +++ b/src/dex/v3/core/libraries/Oracle.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; /// @title Oracle diff --git a/src/dex/v3/core/libraries/Position.sol b/src/dex/v3/core/libraries/Position.sol index ca42a8ae..80c069ae 100644 --- a/src/dex/v3/core/libraries/Position.sol +++ b/src/dex/v3/core/libraries/Position.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; import { FullMath } from "./FullMath.sol"; diff --git a/src/dex/v3/core/libraries/SqrtPriceMath.sol b/src/dex/v3/core/libraries/SqrtPriceMath.sol index 241bf505..f9418032 100644 --- a/src/dex/v3/core/libraries/SqrtPriceMath.sol +++ b/src/dex/v3/core/libraries/SqrtPriceMath.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; import { SafeCast } from "./SafeCast.sol"; diff --git a/src/dex/v3/core/libraries/SwapMath.sol b/src/dex/v3/core/libraries/SwapMath.sol index 37996fff..1ef97535 100644 --- a/src/dex/v3/core/libraries/SwapMath.sol +++ b/src/dex/v3/core/libraries/SwapMath.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; import { FullMath } from "./FullMath.sol"; diff --git a/src/dex/v3/core/libraries/Tick.sol b/src/dex/v3/core/libraries/Tick.sol index 8b73d2a1..6b3c4004 100644 --- a/src/dex/v3/core/libraries/Tick.sol +++ b/src/dex/v3/core/libraries/Tick.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; import { SafeCast } from "./SafeCast.sol"; diff --git a/src/dex/v3/core/libraries/TickBitmap.sol b/src/dex/v3/core/libraries/TickBitmap.sol index 98a3d4f5..f70a5185 100644 --- a/src/dex/v3/core/libraries/TickBitmap.sol +++ b/src/dex/v3/core/libraries/TickBitmap.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.34; import { BitMath } from "./BitMath.sol"; diff --git a/src/dex/v3/periphery/libraries/AddressStringUtil.sol b/src/dex/v3/periphery/libraries/AddressStringUtil.sol index ba4bc215..7bda40a9 100644 --- a/src/dex/v3/periphery/libraries/AddressStringUtil.sol +++ b/src/dex/v3/periphery/libraries/AddressStringUtil.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-License-Identifier: GPL-2.0-or-later // from https://github.com/Lista/solidity-lib/blob/master/contracts/libraries/AddressStringUtil.sol // modified for solidity 0.8 diff --git a/src/dex/v3/periphery/libraries/SafeERC20Namer.sol b/src/dex/v3/periphery/libraries/SafeERC20Namer.sol index e665c0a9..a6dea02a 100644 --- a/src/dex/v3/periphery/libraries/SafeERC20Namer.sol +++ b/src/dex/v3/periphery/libraries/SafeERC20Namer.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-License-Identifier: GPL-2.0-or-later // from https://github.com/Lista/solidity-lib/blob/master/contracts/libraries/SafeERC20Namer.sol // modified for solidity 0.8 From 36962b86277fe1cbab14203bf7d83b983b201620 Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:04:08 +0800 Subject: [PATCH 06/17] refactor: replace forked dex/v3 with lista-v3 submodule dependency Remove the ~9.4k-LoC local fork under src/dex/v3 (a Uniswap-v3 fork ported to 0.8.34) and depend on github.com/lista-dao/lista-v3 instead. lista-v3 master is Solidity 0.7.6, so it cannot be compiled into this 0.8.34 codebase wholesale. Split by what actually compiles under 0.8.34: - Pure interfaces (IListaV3Factory, IListaV3Pool, pragma >=0.5.0) now come from the lib/lista-v3 submodule via the `lista-v3/` remapping. - The V3 math libs V3Provider executes internally (TickMath, SqrtPriceMath, LiquidityAmounts, FullMath, FixedPoint96, SafeCast, UnsafeMath) are 0.7.6 upstream and need 0.8.x unchecked semantics, so the existing 0.8.34 ports are relocated to src/provider/libraries/. - INonfungiblePositionManager: the submodule's interface inherits OZ ERC721-upgradeable interfaces whose v4-era paths/names don't exist in this repo's OZ v5.2, so a minimal self-contained NPM interface (only the methods V3Provider calls) lives in src/provider/interfaces/. Delete test/dex/v3/ListaV3.t.sol (tested the fork implementation itself; the DEX is tested in the lista-v3 repo) and repoint the IListaV3Pool import in V3Provider/V3Liquidator tests to the submodule. The unrelated StableSwap DEX under src/dex/*.sol is untouched. forge build: compiler run successful. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitmodules | 3 + lib/lista-v3 | 1 + remappings.txt | 3 +- src/dex/v3/NOTICE | 12 - src/dex/v3/core/ListaV3Factory.sol | 83 -- src/dex/v3/core/ListaV3Pool.sol | 876 ------------------ src/dex/v3/core/ListaV3PoolDeployer.sol | 33 - src/dex/v3/core/NoDelegateCall.sol | 27 - src/dex/v3/core/interfaces/IERC20Minimal.sol | 48 - .../v3/core/interfaces/IListaV3Factory.sol | 70 -- src/dex/v3/core/interfaces/IListaV3Pool.sol | 24 - .../core/interfaces/IListaV3PoolDeployer.sol | 20 - .../callback/IListaV3FlashCallback.sol | 14 - .../callback/IListaV3MintCallback.sol | 14 - .../callback/IListaV3SwapCallback.sol | 17 - .../interfaces/pool/IListaV3PoolActions.sol | 94 -- .../pool/IListaV3PoolDerivedState.sol | 35 - .../interfaces/pool/IListaV3PoolErrors.sol | 19 - .../interfaces/pool/IListaV3PoolEvents.sol | 118 --- .../pool/IListaV3PoolImmutables.sol | 35 - .../pool/IListaV3PoolOwnerActions.sol | 23 - .../interfaces/pool/IListaV3PoolState.sol | 118 --- src/dex/v3/core/libraries/BitMath.sol | 98 -- src/dex/v3/core/libraries/FixedPoint128.sol | 8 - src/dex/v3/core/libraries/Oracle.sol | 342 ------- src/dex/v3/core/libraries/Position.sol | 85 -- src/dex/v3/core/libraries/SwapMath.sol | 91 -- src/dex/v3/core/libraries/Tick.sol | 190 ---- src/dex/v3/core/libraries/TickBitmap.sol | 80 -- src/dex/v3/core/libraries/TransferHelper.sol | 20 - .../periphery/NonfungiblePositionManager.sol | 436 --------- .../NonfungibleTokenPositionDescriptor.sol | 113 --- src/dex/v3/periphery/SwapRouter.sol | 218 ----- src/dex/v3/periphery/base/BlockTimestamp.sol | 12 - src/dex/v3/periphery/base/ERC721Permit.sol | 79 -- .../v3/periphery/base/LiquidityManagement.sol | 83 -- src/dex/v3/periphery/base/Multicall.sol | 28 - .../base/PeripheryImmutableState.sol | 21 - .../v3/periphery/base/PeripheryPayments.sol | 61 -- .../base/PeripheryPaymentsWithFee.sol | 52 -- .../v3/periphery/base/PeripheryValidation.sol | 11 - src/dex/v3/periphery/base/PoolInitializer.sol | 32 - src/dex/v3/periphery/base/SelfPermit.sol | 63 -- .../periphery/interfaces/IERC20Metadata.sol | 18 - .../v3/periphery/interfaces/IERC721Permit.sol | 25 - .../v3/periphery/interfaces/IMulticall.sol | 13 - .../INonfungiblePositionManager.sol | 170 ---- .../INonfungibleTokenPositionDescriptor.sol | 14 - .../interfaces/IPeripheryImmutableState.sol | 12 - .../interfaces/IPeripheryPayments.sol | 24 - .../interfaces/IPeripheryPaymentsWithFee.sol | 29 - .../periphery/interfaces/IPoolInitializer.sol | 22 - src/dex/v3/periphery/interfaces/IQuoter.sol | 51 - src/dex/v3/periphery/interfaces/IQuoterV2.sol | 96 -- .../v3/periphery/interfaces/ISelfPermit.sol | 69 -- .../v3/periphery/interfaces/ISwapRouter.sol | 67 -- src/dex/v3/periphery/interfaces/ITickLens.sol | 25 - .../interfaces/external/IERC1271.sol | 16 - .../external/IERC20PermitAllowed.sol | 27 - .../periphery/interfaces/external/IWETH9.sol | 13 - .../lens/ListaInterfaceMulticall.sol | 42 - src/dex/v3/periphery/lens/Quoter.sol | 162 ---- src/dex/v3/periphery/lens/QuoterV2.sol | 258 ------ src/dex/v3/periphery/lens/README.md | 4 - src/dex/v3/periphery/lens/TickLens.sol | 41 - .../periphery/libraries/AddressStringUtil.sol | 37 - src/dex/v3/periphery/libraries/BytesLib.sol | 95 -- .../libraries/CallbackValidation.sol | 44 - src/dex/v3/periphery/libraries/ChainId.sol | 13 - src/dex/v3/periphery/libraries/HexStrings.sol | 29 - .../v3/periphery/libraries/NFTDescriptor.sol | 475 ---------- src/dex/v3/periphery/libraries/NFTSVG.sol | 403 -------- .../v3/periphery/libraries/OracleLibrary.sol | 177 ---- src/dex/v3/periphery/libraries/Path.sol | 61 -- .../v3/periphery/libraries/PoolAddress.sol | 40 - .../periphery/libraries/PoolTicksCounter.sol | 96 -- .../v3/periphery/libraries/PositionKey.sol | 9 - .../v3/periphery/libraries/PositionValue.sol | 162 ---- .../v3/periphery/libraries/SafeERC20Namer.sol | 96 -- .../libraries/SqrtPriceMathPartial.sol | 59 -- .../libraries/TokenRatioSortOrder.sol | 12 - .../v3/periphery/libraries/TransferHelper.sol | 48 - src/provider/V3Provider.sol | 20 +- .../INonfungiblePositionManager.sol | 95 ++ .../libraries/FixedPoint96.sol | 0 .../core => provider}/libraries/FullMath.sol | 0 .../libraries/LiquidityAmounts.sol | 4 +- .../core => provider}/libraries/SafeCast.sol | 0 .../libraries/SqrtPriceMath.sol | 0 .../core => provider}/libraries/TickMath.sol | 0 .../libraries/UnsafeMath.sol | 0 test/dex/v3/ListaV3.t.sol | 576 ------------ test/liquidator/V3Liquidator.t.sol | 2 +- test/provider/V3Provider.t.sol | 2 +- 94 files changed, 117 insertions(+), 7446 deletions(-) create mode 160000 lib/lista-v3 delete mode 100644 src/dex/v3/NOTICE delete mode 100644 src/dex/v3/core/ListaV3Factory.sol delete mode 100644 src/dex/v3/core/ListaV3Pool.sol delete mode 100644 src/dex/v3/core/ListaV3PoolDeployer.sol delete mode 100644 src/dex/v3/core/NoDelegateCall.sol delete mode 100644 src/dex/v3/core/interfaces/IERC20Minimal.sol delete mode 100644 src/dex/v3/core/interfaces/IListaV3Factory.sol delete mode 100644 src/dex/v3/core/interfaces/IListaV3Pool.sol delete mode 100644 src/dex/v3/core/interfaces/IListaV3PoolDeployer.sol delete mode 100644 src/dex/v3/core/interfaces/callback/IListaV3FlashCallback.sol delete mode 100644 src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol delete mode 100644 src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol delete mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolActions.sol delete mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolDerivedState.sol delete mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolErrors.sol delete mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolEvents.sol delete mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolImmutables.sol delete mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolOwnerActions.sol delete mode 100644 src/dex/v3/core/interfaces/pool/IListaV3PoolState.sol delete mode 100644 src/dex/v3/core/libraries/BitMath.sol delete mode 100644 src/dex/v3/core/libraries/FixedPoint128.sol delete mode 100644 src/dex/v3/core/libraries/Oracle.sol delete mode 100644 src/dex/v3/core/libraries/Position.sol delete mode 100644 src/dex/v3/core/libraries/SwapMath.sol delete mode 100644 src/dex/v3/core/libraries/Tick.sol delete mode 100644 src/dex/v3/core/libraries/TickBitmap.sol delete mode 100644 src/dex/v3/core/libraries/TransferHelper.sol delete mode 100644 src/dex/v3/periphery/NonfungiblePositionManager.sol delete mode 100644 src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol delete mode 100644 src/dex/v3/periphery/SwapRouter.sol delete mode 100644 src/dex/v3/periphery/base/BlockTimestamp.sol delete mode 100644 src/dex/v3/periphery/base/ERC721Permit.sol delete mode 100644 src/dex/v3/periphery/base/LiquidityManagement.sol delete mode 100644 src/dex/v3/periphery/base/Multicall.sol delete mode 100644 src/dex/v3/periphery/base/PeripheryImmutableState.sol delete mode 100644 src/dex/v3/periphery/base/PeripheryPayments.sol delete mode 100644 src/dex/v3/periphery/base/PeripheryPaymentsWithFee.sol delete mode 100644 src/dex/v3/periphery/base/PeripheryValidation.sol delete mode 100644 src/dex/v3/periphery/base/PoolInitializer.sol delete mode 100644 src/dex/v3/periphery/base/SelfPermit.sol delete mode 100644 src/dex/v3/periphery/interfaces/IERC20Metadata.sol delete mode 100644 src/dex/v3/periphery/interfaces/IERC721Permit.sol delete mode 100644 src/dex/v3/periphery/interfaces/IMulticall.sol delete mode 100644 src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol delete mode 100644 src/dex/v3/periphery/interfaces/INonfungibleTokenPositionDescriptor.sol delete mode 100644 src/dex/v3/periphery/interfaces/IPeripheryImmutableState.sol delete mode 100644 src/dex/v3/periphery/interfaces/IPeripheryPayments.sol delete mode 100644 src/dex/v3/periphery/interfaces/IPeripheryPaymentsWithFee.sol delete mode 100644 src/dex/v3/periphery/interfaces/IPoolInitializer.sol delete mode 100644 src/dex/v3/periphery/interfaces/IQuoter.sol delete mode 100644 src/dex/v3/periphery/interfaces/IQuoterV2.sol delete mode 100644 src/dex/v3/periphery/interfaces/ISelfPermit.sol delete mode 100644 src/dex/v3/periphery/interfaces/ISwapRouter.sol delete mode 100644 src/dex/v3/periphery/interfaces/ITickLens.sol delete mode 100644 src/dex/v3/periphery/interfaces/external/IERC1271.sol delete mode 100644 src/dex/v3/periphery/interfaces/external/IERC20PermitAllowed.sol delete mode 100644 src/dex/v3/periphery/interfaces/external/IWETH9.sol delete mode 100644 src/dex/v3/periphery/lens/ListaInterfaceMulticall.sol delete mode 100644 src/dex/v3/periphery/lens/Quoter.sol delete mode 100644 src/dex/v3/periphery/lens/QuoterV2.sol delete mode 100644 src/dex/v3/periphery/lens/README.md delete mode 100644 src/dex/v3/periphery/lens/TickLens.sol delete mode 100644 src/dex/v3/periphery/libraries/AddressStringUtil.sol delete mode 100644 src/dex/v3/periphery/libraries/BytesLib.sol delete mode 100644 src/dex/v3/periphery/libraries/CallbackValidation.sol delete mode 100644 src/dex/v3/periphery/libraries/ChainId.sol delete mode 100644 src/dex/v3/periphery/libraries/HexStrings.sol delete mode 100644 src/dex/v3/periphery/libraries/NFTDescriptor.sol delete mode 100644 src/dex/v3/periphery/libraries/NFTSVG.sol delete mode 100644 src/dex/v3/periphery/libraries/OracleLibrary.sol delete mode 100644 src/dex/v3/periphery/libraries/Path.sol delete mode 100644 src/dex/v3/periphery/libraries/PoolAddress.sol delete mode 100644 src/dex/v3/periphery/libraries/PoolTicksCounter.sol delete mode 100644 src/dex/v3/periphery/libraries/PositionKey.sol delete mode 100644 src/dex/v3/periphery/libraries/PositionValue.sol delete mode 100644 src/dex/v3/periphery/libraries/SafeERC20Namer.sol delete mode 100644 src/dex/v3/periphery/libraries/SqrtPriceMathPartial.sol delete mode 100644 src/dex/v3/periphery/libraries/TokenRatioSortOrder.sol delete mode 100644 src/dex/v3/periphery/libraries/TransferHelper.sol create mode 100644 src/provider/interfaces/INonfungiblePositionManager.sol rename src/{dex/v3/core => provider}/libraries/FixedPoint96.sol (100%) rename src/{dex/v3/core => provider}/libraries/FullMath.sol (100%) rename src/{dex/v3/periphery => provider}/libraries/LiquidityAmounts.sol (98%) rename src/{dex/v3/core => provider}/libraries/SafeCast.sol (100%) rename src/{dex/v3/core => provider}/libraries/SqrtPriceMath.sol (100%) rename src/{dex/v3/core => provider}/libraries/TickMath.sol (100%) rename src/{dex/v3/core => provider}/libraries/UnsafeMath.sol (100%) delete mode 100644 test/dex/v3/ListaV3.t.sol diff --git a/.gitmodules b/.gitmodules index acda2ad9..a49f7cf9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,3 +18,6 @@ [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 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 616bfe93..3b974d48 100644 --- a/remappings.txt +++ b/remappings.txt @@ -10,4 +10,5 @@ vault-allocator=src/vault-allocator forge-std=lib/forge-std/src timelock=src/timelock revenue=src/revenue -murky=lib/murky \ No newline at end of file +murky=lib/murky +lista-v3/=lib/lista-v3/src/ \ No newline at end of file diff --git a/src/dex/v3/NOTICE b/src/dex/v3/NOTICE deleted file mode 100644 index 1f9c35ca..00000000 --- a/src/dex/v3/NOTICE +++ /dev/null @@ -1,12 +0,0 @@ -This code is derived from Uniswap V3 (https://github.com/Uniswap/v3-core, -https://github.com/Uniswap/v3-periphery), originally licensed under -Business Source License 1.1, which transitioned to GPL-2.0-or-later -on 2023-04-01. - -Portions of src/dex/v3/core/libraries/ (FullMath, UnsafeMath) are derived -from work by Remco Bloemen, licensed under MIT. - -Modifications by Lista DAO to the original Uniswap V3 code are licensed -under GPL-2.0-or-later, consistent with the original license terms. - -Original files authored by Lista DAO are licensed under MIT. diff --git a/src/dex/v3/core/ListaV3Factory.sol b/src/dex/v3/core/ListaV3Factory.sol deleted file mode 100644 index c2d5d420..00000000 --- a/src/dex/v3/core/ListaV3Factory.sol +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import { IListaV3Factory } from "./interfaces/IListaV3Factory.sol"; -import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; - -import { ListaV3PoolDeployer } from "./ListaV3PoolDeployer.sol"; - -import { ListaV3Pool } from "./ListaV3Pool.sol"; - -/// @title Canonical Lista V3 factory (UUPS upgradeable) -/// @notice Deploys Lista V3 pools and manages ownership and control over pool protocol fees -contract ListaV3Factory is IListaV3Factory, ListaV3PoolDeployer, Initializable, UUPSUpgradeable { - /// @inheritdoc IListaV3Factory - address public override owner; - - /// @inheritdoc IListaV3Factory - mapping(uint24 => int24) public override feeAmountTickSpacing; - /// @inheritdoc IListaV3Factory - mapping(address => mapping(address => mapping(uint24 => address))) public override getPool; - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - function initialize(address _owner) external initializer { - require(_owner != address(0), "zero address"); - owner = _owner; - emit OwnerChanged(address(0), _owner); - - feeAmountTickSpacing[500] = 10; - emit FeeAmountEnabled(500, 10); - feeAmountTickSpacing[3000] = 60; - emit FeeAmountEnabled(3000, 60); - feeAmountTickSpacing[10000] = 200; - emit FeeAmountEnabled(10000, 200); - } - - /// @inheritdoc IListaV3Factory - function createPool(address tokenA, address tokenB, uint24 fee) external override returns (address pool) { - require(tokenA != tokenB); - (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); - require(token0 != address(0)); - int24 tickSpacing = feeAmountTickSpacing[fee]; - require(tickSpacing != 0); - require(getPool[token0][token1][fee] == address(0)); - pool = deploy(address(this), token0, token1, fee, tickSpacing); - getPool[token0][token1][fee] = pool; - getPool[token1][token0][fee] = pool; - emit PoolCreated(token0, token1, fee, tickSpacing, pool); - } - - /// @inheritdoc IListaV3Factory - function setOwner(address _owner) external override { - require(msg.sender == owner); - emit OwnerChanged(owner, _owner); - owner = _owner; - } - - /// @inheritdoc IListaV3Factory - function enableFeeAmount(uint24 fee, int24 tickSpacing) public override { - require(msg.sender == owner); - require(fee < 1000000); - require(tickSpacing > 0 && tickSpacing < 16384); - require(feeAmountTickSpacing[fee] == 0); - - feeAmountTickSpacing[fee] = tickSpacing; - emit FeeAmountEnabled(fee, tickSpacing); - } - - /// @notice Returns the init code hash for pool deployment. - /// Used by periphery contracts (PoolAddress.computeAddress) to deterministically - /// derive pool addresses from (factory, token0, token1, fee). - function poolInitCodeHash() external pure returns (bytes32) { - return keccak256(type(ListaV3Pool).creationCode); - } - - function _authorizeUpgrade(address) internal override { - require(msg.sender == owner); - } -} diff --git a/src/dex/v3/core/ListaV3Pool.sol b/src/dex/v3/core/ListaV3Pool.sol deleted file mode 100644 index 18f34698..00000000 --- a/src/dex/v3/core/ListaV3Pool.sol +++ /dev/null @@ -1,876 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import { IListaV3PoolImmutables, IListaV3PoolState, IListaV3PoolActions, IListaV3PoolDerivedState, IListaV3PoolOwnerActions, IListaV3Pool } from "./interfaces/IListaV3Pool.sol"; - -import { SafeCast } from "./libraries/SafeCast.sol"; -import { Tick } from "./libraries/Tick.sol"; -import { TickBitmap } from "./libraries/TickBitmap.sol"; -import { Position } from "./libraries/Position.sol"; -import { Oracle } from "./libraries/Oracle.sol"; - -import { FullMath } from "./libraries/FullMath.sol"; -import { FixedPoint128 } from "./libraries/FixedPoint128.sol"; -import { TransferHelper } from "./libraries/TransferHelper.sol"; -import { TickMath } from "./libraries/TickMath.sol"; -import { SqrtPriceMath } from "./libraries/SqrtPriceMath.sol"; -import { SwapMath } from "./libraries/SwapMath.sol"; - -import { IListaV3PoolDeployer } from "./interfaces/IListaV3PoolDeployer.sol"; -import { IListaV3Factory } from "./interfaces/IListaV3Factory.sol"; -import { IERC20Minimal } from "./interfaces/IERC20Minimal.sol"; -import { IListaV3MintCallback } from "./interfaces/callback/IListaV3MintCallback.sol"; -import { IListaV3SwapCallback } from "./interfaces/callback/IListaV3SwapCallback.sol"; -import { IListaV3FlashCallback } from "./interfaces/callback/IListaV3FlashCallback.sol"; - -contract ListaV3Pool is IListaV3Pool { - using SafeCast for uint256; - using SafeCast for int256; - using Tick for mapping(int24 => Tick.Info); - using TickBitmap for mapping(int16 => uint256); - using Position for mapping(bytes32 => Position.Info); - using Position for Position.Info; - using Oracle for Oracle.Observation[65535]; - - /// @inheritdoc IListaV3PoolImmutables - address public immutable override factory; - /// @inheritdoc IListaV3PoolImmutables - address public immutable override token0; - /// @inheritdoc IListaV3PoolImmutables - address public immutable override token1; - /// @inheritdoc IListaV3PoolImmutables - uint24 public immutable override fee; - - /// @inheritdoc IListaV3PoolImmutables - int24 public immutable override tickSpacing; - - /// @inheritdoc IListaV3PoolImmutables - uint128 public immutable override maxLiquidityPerTick; - - struct Slot0 { - // the current price - uint160 sqrtPriceX96; - // the current tick - int24 tick; - // the most-recently updated index of the observations array - uint16 observationIndex; - // the current maximum number of observations that are being stored - uint16 observationCardinality; - // the next maximum number of observations to store, triggered in observations.write - uint16 observationCardinalityNext; - // the current protocol fee as a percentage of the swap fee taken on withdrawal - // represented as an integer denominator (1/x)% - uint8 feeProtocol; - // whether the pool is locked - bool unlocked; - } - /// @inheritdoc IListaV3PoolState - Slot0 public override slot0; - - /// @inheritdoc IListaV3PoolState - uint256 public override feeGrowthGlobal0X128; - /// @inheritdoc IListaV3PoolState - uint256 public override feeGrowthGlobal1X128; - - // accumulated protocol fees in token0/token1 units - struct ProtocolFees { - uint128 token0; - uint128 token1; - } - /// @inheritdoc IListaV3PoolState - ProtocolFees public override protocolFees; - - /// @inheritdoc IListaV3PoolState - uint128 public override liquidity; - - /// @inheritdoc IListaV3PoolState - mapping(int24 => Tick.Info) public override ticks; - /// @inheritdoc IListaV3PoolState - mapping(int16 => uint256) public override tickBitmap; - /// @inheritdoc IListaV3PoolState - mapping(bytes32 => Position.Info) public override positions; - /// @inheritdoc IListaV3PoolState - Oracle.Observation[65535] public override observations; - - /// @dev Mutually exclusive reentrancy protection into the pool to/from a method. This method also prevents entrance - /// to a function before the pool is initialized. The reentrancy guard is required throughout the contract because - /// we use balance checks to determine the payment status of interactions such as mint, swap and flash. - modifier lock() { - if (!slot0.unlocked) revert LOK(); - slot0.unlocked = false; - _; - slot0.unlocked = true; - } - - /// @dev Prevents calling a function from anyone except the address returned by IListaV3Factory#owner() - modifier onlyFactoryOwner() { - require(msg.sender == IListaV3Factory(factory).owner()); - _; - } - - constructor() { - int24 _tickSpacing; - (factory, token0, token1, fee, _tickSpacing) = IListaV3PoolDeployer(msg.sender).parameters(); - tickSpacing = _tickSpacing; - - maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(_tickSpacing); - } - - /// @dev Common checks for valid tick inputs. - function checkTicks(int24 tickLower, int24 tickUpper) private pure { - if (tickLower >= tickUpper) revert TLU(); - if (tickLower < TickMath.MIN_TICK) revert TLM(); - if (tickUpper > TickMath.MAX_TICK) revert TUM(); - } - - /// @dev Returns the block timestamp truncated to 32 bits, i.e. mod 2**32. This method is overridden in tests. - function _blockTimestamp() internal view virtual returns (uint32) { - return uint32(block.timestamp); // truncation is desired - } - - /// @dev Get the pool's balance of token0 - /// @dev This function is gas optimized to avoid a redundant extcodesize check in addition to the returndatasize - /// check - function balance0() private view returns (uint256) { - (bool success, bytes memory data) = token0.staticcall( - abi.encodeWithSelector(IERC20Minimal.balanceOf.selector, address(this)) - ); - require(success && data.length >= 32); - return abi.decode(data, (uint256)); - } - - /// @dev Get the pool's balance of token1 - /// @dev This function is gas optimized to avoid a redundant extcodesize check in addition to the returndatasize - /// check - function balance1() private view returns (uint256) { - (bool success, bytes memory data) = token1.staticcall( - abi.encodeWithSelector(IERC20Minimal.balanceOf.selector, address(this)) - ); - require(success && data.length >= 32); - return abi.decode(data, (uint256)); - } - - /// @inheritdoc IListaV3PoolDerivedState - function snapshotCumulativesInside( - int24 tickLower, - int24 tickUpper - ) - external - view - override - returns (int56 tickCumulativeInside, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside) - { - checkTicks(tickLower, tickUpper); - - int56 tickCumulativeLower; - int56 tickCumulativeUpper; - uint160 secondsPerLiquidityOutsideLowerX128; - uint160 secondsPerLiquidityOutsideUpperX128; - uint32 secondsOutsideLower; - uint32 secondsOutsideUpper; - - { - Tick.Info storage lower = ticks[tickLower]; - Tick.Info storage upper = ticks[tickUpper]; - bool initializedLower; - (tickCumulativeLower, secondsPerLiquidityOutsideLowerX128, secondsOutsideLower, initializedLower) = ( - lower.tickCumulativeOutside, - lower.secondsPerLiquidityOutsideX128, - lower.secondsOutside, - lower.initialized - ); - require(initializedLower); - - bool initializedUpper; - (tickCumulativeUpper, secondsPerLiquidityOutsideUpperX128, secondsOutsideUpper, initializedUpper) = ( - upper.tickCumulativeOutside, - upper.secondsPerLiquidityOutsideX128, - upper.secondsOutside, - upper.initialized - ); - require(initializedUpper); - } - - Slot0 memory _slot0 = slot0; - - unchecked { - if (_slot0.tick < tickLower) { - return ( - tickCumulativeLower - tickCumulativeUpper, - secondsPerLiquidityOutsideLowerX128 - secondsPerLiquidityOutsideUpperX128, - secondsOutsideLower - secondsOutsideUpper - ); - } else if (_slot0.tick < tickUpper) { - uint32 time = _blockTimestamp(); - (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) = observations.observeSingle( - time, - 0, - _slot0.tick, - _slot0.observationIndex, - liquidity, - _slot0.observationCardinality - ); - return ( - tickCumulative - tickCumulativeLower - tickCumulativeUpper, - secondsPerLiquidityCumulativeX128 - secondsPerLiquidityOutsideLowerX128 - secondsPerLiquidityOutsideUpperX128, - time - secondsOutsideLower - secondsOutsideUpper - ); - } else { - return ( - tickCumulativeUpper - tickCumulativeLower, - secondsPerLiquidityOutsideUpperX128 - secondsPerLiquidityOutsideLowerX128, - secondsOutsideUpper - secondsOutsideLower - ); - } - } - } - - /// @inheritdoc IListaV3PoolDerivedState - function observe( - uint32[] calldata secondsAgos - ) - external - view - override - returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) - { - return - observations.observe( - _blockTimestamp(), - secondsAgos, - slot0.tick, - slot0.observationIndex, - liquidity, - slot0.observationCardinality - ); - } - - /// @inheritdoc IListaV3PoolActions - function increaseObservationCardinalityNext(uint16 observationCardinalityNext) external override lock { - uint16 observationCardinalityNextOld = slot0.observationCardinalityNext; // for the event - uint16 observationCardinalityNextNew = observations.grow(observationCardinalityNextOld, observationCardinalityNext); - slot0.observationCardinalityNext = observationCardinalityNextNew; - if (observationCardinalityNextOld != observationCardinalityNextNew) - emit IncreaseObservationCardinalityNext(observationCardinalityNextOld, observationCardinalityNextNew); - } - - /// @inheritdoc IListaV3PoolActions - /// @dev not locked because it initializes unlocked - function initialize(uint160 sqrtPriceX96) external override { - if (slot0.sqrtPriceX96 != 0) revert AI(); - - int24 tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96); - - (uint16 cardinality, uint16 cardinalityNext) = observations.initialize(_blockTimestamp()); - - slot0 = Slot0({ - sqrtPriceX96: sqrtPriceX96, - tick: tick, - observationIndex: 0, - observationCardinality: cardinality, - observationCardinalityNext: cardinalityNext, - feeProtocol: 0, - unlocked: true - }); - - emit Initialize(sqrtPriceX96, tick); - } - - struct ModifyPositionParams { - // the address that owns the position - address owner; - // the lower and upper tick of the position - int24 tickLower; - int24 tickUpper; - // any change in liquidity - int128 liquidityDelta; - } - - /// @dev Effect some changes to a position - /// @param params the position details and the change to the position's liquidity to effect - /// @return position a storage pointer referencing the position with the given owner and tick range - /// @return amount0 the amount of token0 owed to the pool, negative if the pool should pay the recipient - /// @return amount1 the amount of token1 owed to the pool, negative if the pool should pay the recipient - function _modifyPosition( - ModifyPositionParams memory params - ) private returns (Position.Info storage position, int256 amount0, int256 amount1) { - checkTicks(params.tickLower, params.tickUpper); - - Slot0 memory _slot0 = slot0; // SLOAD for gas optimization - - position = _updatePosition(params.owner, params.tickLower, params.tickUpper, params.liquidityDelta, _slot0.tick); - - if (params.liquidityDelta != 0) { - if (_slot0.tick < params.tickLower) { - // current tick is below the passed range; liquidity can only become in range by crossing from left to - // right, when we'll need _more_ token0 (it's becoming more valuable) so user must provide it - amount0 = SqrtPriceMath.getAmount0Delta( - TickMath.getSqrtRatioAtTick(params.tickLower), - TickMath.getSqrtRatioAtTick(params.tickUpper), - params.liquidityDelta - ); - } else if (_slot0.tick < params.tickUpper) { - // current tick is inside the passed range - uint128 liquidityBefore = liquidity; // SLOAD for gas optimization - - // write an oracle entry - (slot0.observationIndex, slot0.observationCardinality) = observations.write( - _slot0.observationIndex, - _blockTimestamp(), - _slot0.tick, - liquidityBefore, - _slot0.observationCardinality, - _slot0.observationCardinalityNext - ); - - amount0 = SqrtPriceMath.getAmount0Delta( - _slot0.sqrtPriceX96, - TickMath.getSqrtRatioAtTick(params.tickUpper), - params.liquidityDelta - ); - amount1 = SqrtPriceMath.getAmount1Delta( - TickMath.getSqrtRatioAtTick(params.tickLower), - _slot0.sqrtPriceX96, - params.liquidityDelta - ); - - liquidity = params.liquidityDelta < 0 - ? liquidityBefore - uint128(-params.liquidityDelta) - : liquidityBefore + uint128(params.liquidityDelta); - } else { - // current tick is above the passed range; liquidity can only become in range by crossing from right to - // left, when we'll need _more_ token1 (it's becoming more valuable) so user must provide it - amount1 = SqrtPriceMath.getAmount1Delta( - TickMath.getSqrtRatioAtTick(params.tickLower), - TickMath.getSqrtRatioAtTick(params.tickUpper), - params.liquidityDelta - ); - } - } - } - - /// @dev Gets and updates a position with the given liquidity delta - /// @param owner the owner of the position - /// @param tickLower the lower tick of the position's tick range - /// @param tickUpper the upper tick of the position's tick range - /// @param tick the current tick, passed to avoid sloads - function _updatePosition( - address owner, - int24 tickLower, - int24 tickUpper, - int128 liquidityDelta, - int24 tick - ) private returns (Position.Info storage position) { - position = positions.get(owner, tickLower, tickUpper); - - uint256 _feeGrowthGlobal0X128 = feeGrowthGlobal0X128; // SLOAD for gas optimization - uint256 _feeGrowthGlobal1X128 = feeGrowthGlobal1X128; // SLOAD for gas optimization - - // if we need to update the ticks, do it - bool flippedLower; - bool flippedUpper; - if (liquidityDelta != 0) { - uint32 time = _blockTimestamp(); - (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) = observations.observeSingle( - time, - 0, - slot0.tick, - slot0.observationIndex, - liquidity, - slot0.observationCardinality - ); - - flippedLower = ticks.update( - tickLower, - tick, - liquidityDelta, - _feeGrowthGlobal0X128, - _feeGrowthGlobal1X128, - secondsPerLiquidityCumulativeX128, - tickCumulative, - time, - false, - maxLiquidityPerTick - ); - flippedUpper = ticks.update( - tickUpper, - tick, - liquidityDelta, - _feeGrowthGlobal0X128, - _feeGrowthGlobal1X128, - secondsPerLiquidityCumulativeX128, - tickCumulative, - time, - true, - maxLiquidityPerTick - ); - - if (flippedLower) { - tickBitmap.flipTick(tickLower, tickSpacing); - } - if (flippedUpper) { - tickBitmap.flipTick(tickUpper, tickSpacing); - } - } - - (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) = ticks.getFeeGrowthInside( - tickLower, - tickUpper, - tick, - _feeGrowthGlobal0X128, - _feeGrowthGlobal1X128 - ); - - position.update(liquidityDelta, feeGrowthInside0X128, feeGrowthInside1X128); - - // clear any tick data that is no longer needed - if (liquidityDelta < 0) { - if (flippedLower) { - ticks.clear(tickLower); - } - if (flippedUpper) { - ticks.clear(tickUpper); - } - } - } - - /// @inheritdoc IListaV3PoolActions - /// @dev is applied indirectly via _modifyPosition - function mint( - address recipient, - int24 tickLower, - int24 tickUpper, - uint128 amount, - bytes calldata data - ) external override lock returns (uint256 amount0, uint256 amount1) { - require(amount > 0); - (, int256 amount0Int, int256 amount1Int) = _modifyPosition( - ModifyPositionParams({ - owner: recipient, - tickLower: tickLower, - tickUpper: tickUpper, - liquidityDelta: int256(uint256(amount)).toInt128() - }) - ); - - amount0 = uint256(amount0Int); - amount1 = uint256(amount1Int); - - uint256 balance0Before; - uint256 balance1Before; - if (amount0 > 0) balance0Before = balance0(); - if (amount1 > 0) balance1Before = balance1(); - IListaV3MintCallback(msg.sender).listaV3MintCallback(amount0, amount1, data); - if (amount0 > 0 && balance0Before + amount0 > balance0()) revert M0(); - if (amount1 > 0 && balance1Before + amount1 > balance1()) revert M1(); - - emit Mint(msg.sender, recipient, tickLower, tickUpper, amount, amount0, amount1); - } - - /// @inheritdoc IListaV3PoolActions - function collect( - address recipient, - int24 tickLower, - int24 tickUpper, - uint128 amount0Requested, - uint128 amount1Requested - ) external override lock returns (uint128 amount0, uint128 amount1) { - // we don't need to checkTicks here, because invalid positions will never have non-zero tokensOwed{0,1} - Position.Info storage position = positions.get(msg.sender, tickLower, tickUpper); - - amount0 = amount0Requested > position.tokensOwed0 ? position.tokensOwed0 : amount0Requested; - amount1 = amount1Requested > position.tokensOwed1 ? position.tokensOwed1 : amount1Requested; - - unchecked { - if (amount0 > 0) { - position.tokensOwed0 -= amount0; - TransferHelper.safeTransfer(token0, recipient, amount0); - } - if (amount1 > 0) { - position.tokensOwed1 -= amount1; - TransferHelper.safeTransfer(token1, recipient, amount1); - } - } - - emit Collect(msg.sender, recipient, tickLower, tickUpper, amount0, amount1); - } - - /// @inheritdoc IListaV3PoolActions - /// @dev is applied indirectly via _modifyPosition - function burn( - int24 tickLower, - int24 tickUpper, - uint128 amount - ) external override lock returns (uint256 amount0, uint256 amount1) { - unchecked { - (Position.Info storage position, int256 amount0Int, int256 amount1Int) = _modifyPosition( - ModifyPositionParams({ - owner: msg.sender, - tickLower: tickLower, - tickUpper: tickUpper, - liquidityDelta: -int256(uint256(amount)).toInt128() - }) - ); - - amount0 = uint256(-amount0Int); - amount1 = uint256(-amount1Int); - - if (amount0 > 0 || amount1 > 0) { - (position.tokensOwed0, position.tokensOwed1) = ( - position.tokensOwed0 + uint128(amount0), - position.tokensOwed1 + uint128(amount1) - ); - } - - emit Burn(msg.sender, tickLower, tickUpper, amount, amount0, amount1); - } - } - - struct SwapCache { - // the protocol fee for the input token - uint8 feeProtocol; - // liquidity at the beginning of the swap - uint128 liquidityStart; - // the timestamp of the current block - uint32 blockTimestamp; - // the current value of the tick accumulator, computed only if we cross an initialized tick - int56 tickCumulative; - // the current value of seconds per liquidity accumulator, computed only if we cross an initialized tick - uint160 secondsPerLiquidityCumulativeX128; - // whether we've computed and cached the above two accumulators - bool computedLatestObservation; - } - - // the top level state of the swap, the results of which are recorded in storage at the end - struct SwapState { - // the amount remaining to be swapped in/out of the input/output asset - int256 amountSpecifiedRemaining; - // the amount already swapped out/in of the output/input asset - int256 amountCalculated; - // current sqrt(price) - uint160 sqrtPriceX96; - // the tick associated with the current price - int24 tick; - // the global fee growth of the input token - uint256 feeGrowthGlobalX128; - // amount of input token paid as protocol fee - uint128 protocolFee; - // the current liquidity in range - uint128 liquidity; - } - - struct StepComputations { - // the price at the beginning of the step - uint160 sqrtPriceStartX96; - // the next tick to swap to from the current tick in the swap direction - int24 tickNext; - // whether tickNext is initialized or not - bool initialized; - // sqrt(price) for the next tick (1/0) - uint160 sqrtPriceNextX96; - // how much is being swapped in in this step - uint256 amountIn; - // how much is being swapped out - uint256 amountOut; - // how much fee is being paid in - uint256 feeAmount; - } - - /// @inheritdoc IListaV3PoolActions - function swap( - address recipient, - bool zeroForOne, - int256 amountSpecified, - uint160 sqrtPriceLimitX96, - bytes calldata data - ) external override returns (int256 amount0, int256 amount1) { - if (amountSpecified == 0) revert AS(); - - Slot0 memory slot0Start = slot0; - - if (!slot0Start.unlocked) revert LOK(); - require( - zeroForOne - ? sqrtPriceLimitX96 < slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 > TickMath.MIN_SQRT_RATIO - : sqrtPriceLimitX96 > slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 < TickMath.MAX_SQRT_RATIO, - "SPL" - ); - - slot0.unlocked = false; - - SwapCache memory cache = SwapCache({ - liquidityStart: liquidity, - blockTimestamp: _blockTimestamp(), - feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4), - secondsPerLiquidityCumulativeX128: 0, - tickCumulative: 0, - computedLatestObservation: false - }); - - bool exactInput = amountSpecified > 0; - - SwapState memory state = SwapState({ - amountSpecifiedRemaining: amountSpecified, - amountCalculated: 0, - sqrtPriceX96: slot0Start.sqrtPriceX96, - tick: slot0Start.tick, - feeGrowthGlobalX128: zeroForOne ? feeGrowthGlobal0X128 : feeGrowthGlobal1X128, - protocolFee: 0, - liquidity: cache.liquidityStart - }); - - // continue swapping as long as we haven't used the entire input/output and haven't reached the price limit - while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) { - StepComputations memory step; - - step.sqrtPriceStartX96 = state.sqrtPriceX96; - - (step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord( - state.tick, - tickSpacing, - zeroForOne - ); - - // ensure that we do not overshoot the min/max tick, as the tick bitmap is not aware of these bounds - if (step.tickNext < TickMath.MIN_TICK) { - step.tickNext = TickMath.MIN_TICK; - } else if (step.tickNext > TickMath.MAX_TICK) { - step.tickNext = TickMath.MAX_TICK; - } - - // get the price for the next tick - step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext); - - // compute values to swap to the target tick, price limit, or point where input/output amount is exhausted - (state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep( - state.sqrtPriceX96, - (zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96) - ? sqrtPriceLimitX96 - : step.sqrtPriceNextX96, - state.liquidity, - state.amountSpecifiedRemaining, - fee - ); - - if (exactInput) { - // safe because we test that amountSpecified > amountIn + feeAmount in SwapMath - unchecked { - state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256(); - } - state.amountCalculated -= step.amountOut.toInt256(); - } else { - unchecked { - state.amountSpecifiedRemaining += step.amountOut.toInt256(); - } - state.amountCalculated += (step.amountIn + step.feeAmount).toInt256(); - } - - // if the protocol fee is on, calculate how much is owed, decrement feeAmount, and increment protocolFee - if (cache.feeProtocol > 0) { - unchecked { - uint256 delta = step.feeAmount / cache.feeProtocol; - step.feeAmount -= delta; - state.protocolFee += uint128(delta); - } - } - - // update global fee tracker - if (state.liquidity > 0) { - unchecked { - state.feeGrowthGlobalX128 += FullMath.mulDiv(step.feeAmount, FixedPoint128.Q128, state.liquidity); - } - } - - // shift tick if we reached the next price - if (state.sqrtPriceX96 == step.sqrtPriceNextX96) { - // if the tick is initialized, run the tick transition - if (step.initialized) { - // check for the placeholder value, which we replace with the actual value the first time the swap - // crosses an initialized tick - if (!cache.computedLatestObservation) { - (cache.tickCumulative, cache.secondsPerLiquidityCumulativeX128) = observations.observeSingle( - cache.blockTimestamp, - 0, - slot0Start.tick, - slot0Start.observationIndex, - cache.liquidityStart, - slot0Start.observationCardinality - ); - cache.computedLatestObservation = true; - } - int128 liquidityNet = ticks.cross( - step.tickNext, - (zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128), - (zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128), - cache.secondsPerLiquidityCumulativeX128, - cache.tickCumulative, - cache.blockTimestamp - ); - // if we're moving leftward, we interpret liquidityNet as the opposite sign - // safe because liquidityNet cannot be type(int128).min - unchecked { - if (zeroForOne) liquidityNet = -liquidityNet; - } - - state.liquidity = liquidityNet < 0 - ? state.liquidity - uint128(-liquidityNet) - : state.liquidity + uint128(liquidityNet); - } - - unchecked { - state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext; - } - } else if (state.sqrtPriceX96 != step.sqrtPriceStartX96) { - // recompute unless we're on a lower tick boundary (i.e. already transitioned ticks), and haven't moved - state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96); - } - } - - // update tick and write an oracle entry if the tick change - if (state.tick != slot0Start.tick) { - (uint16 observationIndex, uint16 observationCardinality) = observations.write( - slot0Start.observationIndex, - cache.blockTimestamp, - slot0Start.tick, - cache.liquidityStart, - slot0Start.observationCardinality, - slot0Start.observationCardinalityNext - ); - (slot0.sqrtPriceX96, slot0.tick, slot0.observationIndex, slot0.observationCardinality) = ( - state.sqrtPriceX96, - state.tick, - observationIndex, - observationCardinality - ); - } else { - // otherwise just update the price - slot0.sqrtPriceX96 = state.sqrtPriceX96; - } - - // update liquidity if it changed - if (cache.liquidityStart != state.liquidity) liquidity = state.liquidity; - - // update fee growth global and, if necessary, protocol fees - // overflow is acceptable, protocol has to withdraw before it hits type(uint128).max fees - if (zeroForOne) { - feeGrowthGlobal0X128 = state.feeGrowthGlobalX128; - unchecked { - if (state.protocolFee > 0) protocolFees.token0 += state.protocolFee; - } - } else { - feeGrowthGlobal1X128 = state.feeGrowthGlobalX128; - unchecked { - if (state.protocolFee > 0) protocolFees.token1 += state.protocolFee; - } - } - - unchecked { - (amount0, amount1) = zeroForOne == exactInput - ? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated) - : (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining); - } - - // do the transfers and collect payment - if (zeroForOne) { - unchecked { - if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1)); - } - - uint256 balance0Before = balance0(); - IListaV3SwapCallback(msg.sender).listaV3SwapCallback(amount0, amount1, data); - if (balance0Before + uint256(amount0) > balance0()) revert IIA(); - } else { - unchecked { - if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0)); - } - - uint256 balance1Before = balance1(); - IListaV3SwapCallback(msg.sender).listaV3SwapCallback(amount0, amount1, data); - if (balance1Before + uint256(amount1) > balance1()) revert IIA(); - } - - emit Swap(msg.sender, recipient, amount0, amount1, state.sqrtPriceX96, state.liquidity, state.tick); - slot0.unlocked = true; - } - - /// @inheritdoc IListaV3PoolActions - function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external override lock { - uint128 _liquidity = liquidity; - if (_liquidity <= 0) revert L(); - - uint256 fee0 = FullMath.mulDivRoundingUp(amount0, fee, 1e6); - uint256 fee1 = FullMath.mulDivRoundingUp(amount1, fee, 1e6); - uint256 balance0Before = balance0(); - uint256 balance1Before = balance1(); - - if (amount0 > 0) TransferHelper.safeTransfer(token0, recipient, amount0); - if (amount1 > 0) TransferHelper.safeTransfer(token1, recipient, amount1); - - IListaV3FlashCallback(msg.sender).listaV3FlashCallback(fee0, fee1, data); - - uint256 balance0After = balance0(); - uint256 balance1After = balance1(); - - if (balance0Before + fee0 > balance0After) revert F0(); - if (balance1Before + fee1 > balance1After) revert F1(); - - unchecked { - // sub is safe because we know balanceAfter is gt balanceBefore by at least fee - uint256 paid0 = balance0After - balance0Before; - uint256 paid1 = balance1After - balance1Before; - - if (paid0 > 0) { - uint8 feeProtocol0 = slot0.feeProtocol % 16; - uint256 pFees0 = feeProtocol0 == 0 ? 0 : paid0 / feeProtocol0; - if (uint128(pFees0) > 0) protocolFees.token0 += uint128(pFees0); - feeGrowthGlobal0X128 += FullMath.mulDiv(paid0 - pFees0, FixedPoint128.Q128, _liquidity); - } - if (paid1 > 0) { - uint8 feeProtocol1 = slot0.feeProtocol >> 4; - uint256 pFees1 = feeProtocol1 == 0 ? 0 : paid1 / feeProtocol1; - if (uint128(pFees1) > 0) protocolFees.token1 += uint128(pFees1); - feeGrowthGlobal1X128 += FullMath.mulDiv(paid1 - pFees1, FixedPoint128.Q128, _liquidity); - } - - emit Flash(msg.sender, recipient, amount0, amount1, paid0, paid1); - } - } - - /// @inheritdoc IListaV3PoolOwnerActions - function setFeeProtocol(uint8 feeProtocol0, uint8 feeProtocol1) external override lock onlyFactoryOwner { - unchecked { - require( - (feeProtocol0 == 0 || (feeProtocol0 >= 4 && feeProtocol0 <= 10)) && - (feeProtocol1 == 0 || (feeProtocol1 >= 4 && feeProtocol1 <= 10)) - ); - uint8 feeProtocolOld = slot0.feeProtocol; - slot0.feeProtocol = feeProtocol0 + (feeProtocol1 << 4); - emit SetFeeProtocol(feeProtocolOld % 16, feeProtocolOld >> 4, feeProtocol0, feeProtocol1); - } - } - - /// @inheritdoc IListaV3PoolOwnerActions - function collectProtocol( - address recipient, - uint128 amount0Requested, - uint128 amount1Requested - ) external override lock onlyFactoryOwner returns (uint128 amount0, uint128 amount1) { - amount0 = amount0Requested > protocolFees.token0 ? protocolFees.token0 : amount0Requested; - amount1 = amount1Requested > protocolFees.token1 ? protocolFees.token1 : amount1Requested; - - unchecked { - if (amount0 > 0) { - if (amount0 == protocolFees.token0) amount0--; // ensure that the slot is not cleared, for gas savings - protocolFees.token0 -= amount0; - TransferHelper.safeTransfer(token0, recipient, amount0); - } - if (amount1 > 0) { - if (amount1 == protocolFees.token1) amount1--; // ensure that the slot is not cleared, for gas savings - protocolFees.token1 -= amount1; - TransferHelper.safeTransfer(token1, recipient, amount1); - } - } - - emit CollectProtocol(msg.sender, recipient, amount0, amount1); - } -} diff --git a/src/dex/v3/core/ListaV3PoolDeployer.sol b/src/dex/v3/core/ListaV3PoolDeployer.sol deleted file mode 100644 index 9711a334..00000000 --- a/src/dex/v3/core/ListaV3PoolDeployer.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import { IListaV3PoolDeployer } from "./interfaces/IListaV3PoolDeployer.sol"; - -import { ListaV3Pool } from "./ListaV3Pool.sol"; - -contract ListaV3PoolDeployer is IListaV3PoolDeployer { - struct Parameters { - address factory; - address token0; - address token1; - uint24 fee; - int24 tickSpacing; - } - - /// @inheritdoc IListaV3PoolDeployer - Parameters public override parameters; - - /// @dev Deploys a pool with the given parameters by transiently setting the parameters storage slot and then - /// clearing it after deploying the pool. - function deploy( - address factory, - address token0, - address token1, - uint24 fee, - int24 tickSpacing - ) internal returns (address pool) { - parameters = Parameters({ factory: factory, token0: token0, token1: token1, fee: fee, tickSpacing: tickSpacing }); - pool = address(new ListaV3Pool{ salt: keccak256(abi.encode(token0, token1, fee)) }()); - delete parameters; - } -} diff --git a/src/dex/v3/core/NoDelegateCall.sol b/src/dex/v3/core/NoDelegateCall.sol deleted file mode 100644 index b5333244..00000000 --- a/src/dex/v3/core/NoDelegateCall.sol +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -/// @title Prevents delegatecall to a contract -/// @notice Base contract that provides a modifier for preventing delegatecall to methods in a child contract -abstract contract NoDelegateCall { - /// @dev The original address of this contract - address private immutable original; - - constructor() { - // Immutables are computed in the init code of the contract, and then inlined into the deployed bytecode. - // In other words, this variable won't change when it's checked at runtime. - original = address(this); - } - - /// @dev Private method is used instead of inlining into modifier because modifiers are copied into each method, - /// and the use of immutable means the address bytes are copied in every place the modifier is used. - function checkNotDelegateCall() private view { - require(address(this) == original); - } - - /// @notice Prevents delegatecall into the modified method - modifier noDelegateCall() { - checkNotDelegateCall(); - _; - } -} diff --git a/src/dex/v3/core/interfaces/IERC20Minimal.sol b/src/dex/v3/core/interfaces/IERC20Minimal.sol deleted file mode 100644 index 8ef8bfd4..00000000 --- a/src/dex/v3/core/interfaces/IERC20Minimal.sol +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Minimal ERC20 interface for Lista -/// @notice Contains a subset of the full ERC20 interface that is used in Lista V3 -interface IERC20Minimal { - /// @notice Returns the balance of a token - /// @param account The account for which to look up the number of tokens it has, i.e. its balance - /// @return The number of tokens held by the account - function balanceOf(address account) external view returns (uint256); - - /// @notice Transfers the amount of token from the `msg.sender` to the recipient - /// @param recipient The account that will receive the amount transferred - /// @param amount The number of tokens to send from the sender to the recipient - /// @return Returns true for a successful transfer, false for an unsuccessful transfer - function transfer(address recipient, uint256 amount) external returns (bool); - - /// @notice Returns the current allowance given to a spender by an owner - /// @param owner The account of the token owner - /// @param spender The account of the token spender - /// @return The current allowance granted by `owner` to `spender` - function allowance(address owner, address spender) external view returns (uint256); - - /// @notice Sets the allowance of a spender from the `msg.sender` to the value `amount` - /// @param spender The account which will be allowed to spend a given amount of the owners tokens - /// @param amount The amount of tokens allowed to be used by `spender` - /// @return Returns true for a successful approval, false for unsuccessful - function approve(address spender, uint256 amount) external returns (bool); - - /// @notice Transfers `amount` tokens from `sender` to `recipient` up to the allowance given to the `msg.sender` - /// @param sender The account from which the transfer will be initiated - /// @param recipient The recipient of the transfer - /// @param amount The amount of the transfer - /// @return Returns true for a successful transfer, false for unsuccessful - function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); - - /// @notice Event emitted when tokens are transferred from one address to another, either via `#transfer` or `#transferFrom`. - /// @param from The account from which the tokens were sent, i.e. the balance decreased - /// @param to The account to which the tokens were sent, i.e. the balance increased - /// @param value The amount of tokens that were transferred - event Transfer(address indexed from, address indexed to, uint256 value); - - /// @notice Event emitted when the approval amount for the spender of a given owner's tokens changes. - /// @param owner The account that approved spending of its tokens - /// @param spender The account for which the spending allowance was modified - /// @param value The new allowance from the owner to the spender - event Approval(address indexed owner, address indexed spender, uint256 value); -} diff --git a/src/dex/v3/core/interfaces/IListaV3Factory.sol b/src/dex/v3/core/interfaces/IListaV3Factory.sol deleted file mode 100644 index d9e7eba4..00000000 --- a/src/dex/v3/core/interfaces/IListaV3Factory.sol +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title The interface for the Lista V3 Factory -/// @notice The Lista V3 Factory facilitates creation of Lista V3 pools and control over the protocol fees -interface IListaV3Factory { - /// @notice Emitted when the owner of the factory is changed - /// @param oldOwner The owner before the owner was changed - /// @param newOwner The owner after the owner was changed - event OwnerChanged(address indexed oldOwner, address indexed newOwner); - - /// @notice Emitted when a pool is created - /// @param token0 The first token of the pool by address sort order - /// @param token1 The second token of the pool by address sort order - /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip - /// @param tickSpacing The minimum number of ticks between initialized ticks - /// @param pool The address of the created pool - event PoolCreated( - address indexed token0, - address indexed token1, - uint24 indexed fee, - int24 tickSpacing, - address pool - ); - - /// @notice Emitted when a new fee amount is enabled for pool creation via the factory - /// @param fee The enabled fee, denominated in hundredths of a bip - /// @param tickSpacing The minimum number of ticks between initialized ticks for pools created with the given fee - event FeeAmountEnabled(uint24 indexed fee, int24 indexed tickSpacing); - - /// @notice Returns the current owner of the factory - /// @dev Can be changed by the current owner via setOwner - /// @return The address of the factory owner - function owner() external view returns (address); - - /// @notice Returns the tick spacing for a given fee amount, if enabled, or 0 if not enabled - /// @dev A fee amount can never be removed, so this value should be hard coded or cached in the calling context - /// @param fee The enabled fee, denominated in hundredths of a bip. Returns 0 in case of unenabled fee - /// @return The tick spacing - function feeAmountTickSpacing(uint24 fee) external view returns (int24); - - /// @notice Returns the pool address for a given pair of tokens and a fee, or address 0 if it does not exist - /// @dev tokenA and tokenB may be passed in either token0/token1 or token1/token0 order - /// @param tokenA The contract address of either token0 or token1 - /// @param tokenB The contract address of the other token - /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip - /// @return pool The pool address - function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool); - - /// @notice Creates a pool for the given two tokens and fee - /// @param tokenA One of the two tokens in the desired pool - /// @param tokenB The other of the two tokens in the desired pool - /// @param fee The desired fee for the pool - /// @dev tokenA and tokenB may be passed in either order: token0/token1 or token1/token0. tickSpacing is retrieved - /// from the fee. The call will revert if the pool already exists, the fee is invalid, or the token arguments - /// are invalid. - /// @return pool The address of the newly created pool - function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); - - /// @notice Updates the owner of the factory - /// @dev Must be called by the current owner - /// @param _owner The new owner of the factory - function setOwner(address _owner) external; - - /// @notice Enables a fee amount with the given tickSpacing - /// @dev Fee amounts may never be removed once enabled - /// @param fee The fee amount to enable, denominated in hundredths of a bip (i.e. 1e-6) - /// @param tickSpacing The spacing between ticks to be enforced for all pools created with the given fee amount - function enableFeeAmount(uint24 fee, int24 tickSpacing) external; -} diff --git a/src/dex/v3/core/interfaces/IListaV3Pool.sol b/src/dex/v3/core/interfaces/IListaV3Pool.sol deleted file mode 100644 index bf06b2ed..00000000 --- a/src/dex/v3/core/interfaces/IListaV3Pool.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -import { IListaV3PoolImmutables } from "./pool/IListaV3PoolImmutables.sol"; -import { IListaV3PoolState } from "./pool/IListaV3PoolState.sol"; -import { IListaV3PoolDerivedState } from "./pool/IListaV3PoolDerivedState.sol"; -import { IListaV3PoolActions } from "./pool/IListaV3PoolActions.sol"; -import { IListaV3PoolOwnerActions } from "./pool/IListaV3PoolOwnerActions.sol"; -import { IListaV3PoolErrors } from "./pool/IListaV3PoolErrors.sol"; -import { IListaV3PoolEvents } from "./pool/IListaV3PoolEvents.sol"; - -/// @title The interface for a Lista V3 Pool -/// @notice A Lista pool facilitates swapping and automated market making between any two assets that strictly conform -/// to the ERC20 specification -/// @dev The pool interface is broken up into many smaller pieces -interface IListaV3Pool is - IListaV3PoolImmutables, - IListaV3PoolState, - IListaV3PoolDerivedState, - IListaV3PoolActions, - IListaV3PoolOwnerActions, - IListaV3PoolErrors, - IListaV3PoolEvents -{} diff --git a/src/dex/v3/core/interfaces/IListaV3PoolDeployer.sol b/src/dex/v3/core/interfaces/IListaV3PoolDeployer.sol deleted file mode 100644 index d49d20ca..00000000 --- a/src/dex/v3/core/interfaces/IListaV3PoolDeployer.sol +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title An interface for a contract that is capable of deploying Lista V3 Pools -/// @notice A contract that constructs a pool must implement this to pass arguments to the pool -/// @dev This is used to avoid having constructor arguments in the pool contract, which results in the init code hash -/// of the pool being constant allowing the CREATE2 address of the pool to be cheaply computed on-chain -interface IListaV3PoolDeployer { - /// @notice Get the parameters to be used in constructing the pool, set transiently during pool creation. - /// @dev Called by the pool constructor to fetch the parameters of the pool - /// Returns factory The factory address - /// Returns token0 The first token of the pool by address sort order - /// Returns token1 The second token of the pool by address sort order - /// Returns fee The fee collected upon every swap in the pool, denominated in hundredths of a bip - /// Returns tickSpacing The minimum number of ticks between initialized ticks - function parameters() - external - view - returns (address factory, address token0, address token1, uint24 fee, int24 tickSpacing); -} diff --git a/src/dex/v3/core/interfaces/callback/IListaV3FlashCallback.sol b/src/dex/v3/core/interfaces/callback/IListaV3FlashCallback.sol deleted file mode 100644 index fb29204f..00000000 --- a/src/dex/v3/core/interfaces/callback/IListaV3FlashCallback.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Callback for IListaV3PoolActions#flash -/// @notice Any contract that calls IListaV3PoolActions#flash must implement this interface -interface IListaV3FlashCallback { - /// @notice Called to `msg.sender` after transferring to the recipient from IListaV3Pool#flash. - /// @dev In the implementation you must repay the pool the tokens sent by flash plus the computed fee amounts. - /// The caller of this method must be checked to be a ListaV3Pool deployed by the canonical ListaV3Factory. - /// @param fee0 The fee amount in token0 due to the pool by the end of the flash - /// @param fee1 The fee amount in token1 due to the pool by the end of the flash - /// @param data Any data passed through by the caller via the IListaV3PoolActions#flash call - function listaV3FlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external; -} diff --git a/src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol b/src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol deleted file mode 100644 index 67ec5c92..00000000 --- a/src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Callback for IListaV3PoolActions#mint -/// @notice Any contract that calls IListaV3PoolActions#mint must implement this interface -interface IListaV3MintCallback { - /// @notice Called to `msg.sender` after minting liquidity to a position from IListaV3Pool#mint. - /// @dev In the implementation you must pay the pool tokens owed for the minted liquidity. - /// The caller of this method must be checked to be a ListaV3Pool deployed by the canonical ListaV3Factory. - /// @param amount0Owed The amount of token0 due to the pool for the minted liquidity - /// @param amount1Owed The amount of token1 due to the pool for the minted liquidity - /// @param data Any data passed through by the caller via the IListaV3PoolActions#mint call - function listaV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata data) external; -} diff --git a/src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol b/src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol deleted file mode 100644 index ebf4b830..00000000 --- a/src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Callback for IListaV3PoolActions#swap -/// @notice Any contract that calls IListaV3PoolActions#swap must implement this interface -interface IListaV3SwapCallback { - /// @notice Called to `msg.sender` after executing a swap via IListaV3Pool#swap. - /// @dev In the implementation you must pay the pool tokens owed for the swap. - /// The caller of this method must be checked to be a ListaV3Pool deployed by the canonical ListaV3Factory. - /// amount0Delta and amount1Delta can both be 0 if no tokens were swapped. - /// @param amount0Delta The amount of token0 that was sent (negative) or must be received (positive) by the pool by - /// the end of the swap. If positive, the callback must send that amount of token0 to the pool. - /// @param amount1Delta The amount of token1 that was sent (negative) or must be received (positive) by the pool by - /// the end of the swap. If positive, the callback must send that amount of token1 to the pool. - /// @param data Any data passed through by the caller via the IListaV3PoolActions#swap call - function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external; -} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolActions.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolActions.sol deleted file mode 100644 index c778cfb4..00000000 --- a/src/dex/v3/core/interfaces/pool/IListaV3PoolActions.sol +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Permissionless pool actions -/// @notice Contains pool methods that can be called by anyone -interface IListaV3PoolActions { - /// @notice Sets the initial price for the pool - /// @dev Price is represented as a sqrt(amountToken1/amountToken0) Q64.96 value - /// @param sqrtPriceX96 the initial sqrt price of the pool as a Q64.96 - function initialize(uint160 sqrtPriceX96) external; - - /// @notice Adds liquidity for the given recipient/tickLower/tickUpper position - /// @dev The caller of this method receives a callback in the form of IListaV3MintCallback#listaV3MintCallback - /// in which they must pay any token0 or token1 owed for the liquidity. The amount of token0/token1 due depends - /// on tickLower, tickUpper, the amount of liquidity, and the current price. - /// @param recipient The address for which the liquidity will be created - /// @param tickLower The lower tick of the position in which to add liquidity - /// @param tickUpper The upper tick of the position in which to add liquidity - /// @param amount The amount of liquidity to mint - /// @param data Any data that should be passed through to the callback - /// @return amount0 The amount of token0 that was paid to mint the given amount of liquidity. Matches the value in the callback - /// @return amount1 The amount of token1 that was paid to mint the given amount of liquidity. Matches the value in the callback - function mint( - address recipient, - int24 tickLower, - int24 tickUpper, - uint128 amount, - bytes calldata data - ) external returns (uint256 amount0, uint256 amount1); - - /// @notice Collects tokens owed to a position - /// @dev Does not recompute fees earned, which must be done either via mint or burn of any amount of liquidity. - /// Collect must be called by the position owner. To withdraw only token0 or only token1, amount0Requested or - /// amount1Requested may be set to zero. To withdraw all tokens owed, caller may pass any value greater than the - /// actual tokens owed, e.g. type(uint128).max. Tokens owed may be from accumulated swap fees or burned liquidity. - /// @param recipient The address which should receive the fees collected - /// @param tickLower The lower tick of the position for which to collect fees - /// @param tickUpper The upper tick of the position for which to collect fees - /// @param amount0Requested How much token0 should be withdrawn from the fees owed - /// @param amount1Requested How much token1 should be withdrawn from the fees owed - /// @return amount0 The amount of fees collected in token0 - /// @return amount1 The amount of fees collected in token1 - function collect( - address recipient, - int24 tickLower, - int24 tickUpper, - uint128 amount0Requested, - uint128 amount1Requested - ) external returns (uint128 amount0, uint128 amount1); - - /// @notice Burn liquidity from the sender and account tokens owed for the liquidity to the position - /// @dev Can be used to trigger a recalculation of fees owed to a position by calling with an amount of 0 - /// @dev Fees must be collected separately via a call to #collect - /// @param tickLower The lower tick of the position for which to burn liquidity - /// @param tickUpper The upper tick of the position for which to burn liquidity - /// @param amount How much liquidity to burn - /// @return amount0 The amount of token0 sent to the recipient - /// @return amount1 The amount of token1 sent to the recipient - function burn(int24 tickLower, int24 tickUpper, uint128 amount) external returns (uint256 amount0, uint256 amount1); - - /// @notice Swap token0 for token1, or token1 for token0 - /// @dev The caller of this method receives a callback in the form of IListaV3SwapCallback#listaV3SwapCallback - /// @param recipient The address to receive the output of the swap - /// @param zeroForOne The direction of the swap, true for token0 to token1, false for token1 to token0 - /// @param amountSpecified The amount of the swap, which implicitly configures the swap as exact input (positive), or exact output (negative) - /// @param sqrtPriceLimitX96 The Q64.96 sqrt price limit. If zero for one, the price cannot be less than this - /// value after the swap. If one for zero, the price cannot be greater than this value after the swap - /// @param data Any data to be passed through to the callback - /// @return amount0 The delta of the balance of token0 of the pool, exact when negative, minimum when positive - /// @return amount1 The delta of the balance of token1 of the pool, exact when negative, minimum when positive - function swap( - address recipient, - bool zeroForOne, - int256 amountSpecified, - uint160 sqrtPriceLimitX96, - bytes calldata data - ) external returns (int256 amount0, int256 amount1); - - /// @notice Receive token0 and/or token1 and pay it back, plus a fee, in the callback - /// @dev The caller of this method receives a callback in the form of IListaV3FlashCallback#listaV3FlashCallback - /// @dev Can be used to donate underlying tokens pro-rata to currently in-range liquidity providers by calling - /// with 0 amount{0,1} and sending the donation amount(s) from the callback - /// @param recipient The address which will receive the token0 and token1 amounts - /// @param amount0 The amount of token0 to send - /// @param amount1 The amount of token1 to send - /// @param data Any data to be passed through to the callback - function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external; - - /// @notice Increase the maximum number of price and liquidity observations that this pool will store - /// @dev This method is no-op if the pool already has an observationCardinalityNext greater than or equal to - /// the input observationCardinalityNext. - /// @param observationCardinalityNext The desired minimum number of observations for the pool to store - function increaseObservationCardinalityNext(uint16 observationCardinalityNext) external; -} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolDerivedState.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolDerivedState.sol deleted file mode 100644 index 3974cf15..00000000 --- a/src/dex/v3/core/interfaces/pool/IListaV3PoolDerivedState.sol +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Pool state that is not stored -/// @notice Contains view functions to provide information about the pool that is computed rather than stored on the -/// blockchain. The functions here may have variable gas costs. -interface IListaV3PoolDerivedState { - /// @notice Returns the cumulative tick and liquidity as of each timestamp `secondsAgo` from the current block timestamp - /// @dev To get a time weighted average tick or liquidity-in-range, you must call this with two values, one representing - /// the beginning of the period and another for the end of the period. E.g., to get the last hour time-weighted average tick, - /// you must call it with secondsAgos = [3600, 0]. - /// @dev The time weighted average tick represents the geometric time weighted average price of the pool, in - /// log base sqrt(1.0001) of token1 / token0. The TickMath library can be used to go from a tick value to a ratio. - /// @param secondsAgos From how long ago each cumulative tick and liquidity value should be returned - /// @return tickCumulatives Cumulative tick values as of each `secondsAgos` from the current block timestamp - /// @return secondsPerLiquidityCumulativeX128s Cumulative seconds per liquidity-in-range value as of each `secondsAgos` from the current block - /// timestamp - function observe( - uint32[] calldata secondsAgos - ) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); - - /// @notice Returns a snapshot of the tick cumulative, seconds per liquidity and seconds inside a tick range - /// @dev Snapshots must only be compared to other snapshots, taken over a period for which a position existed. - /// I.e., snapshots cannot be compared if a position is not held for the entire period between when the first - /// snapshot is taken and the second snapshot is taken. - /// @param tickLower The lower tick of the range - /// @param tickUpper The upper tick of the range - /// @return tickCumulativeInside The snapshot of the tick accumulator for the range - /// @return secondsPerLiquidityInsideX128 The snapshot of seconds per liquidity for the range - /// @return secondsInside The snapshot of seconds per liquidity for the range - function snapshotCumulativesInside( - int24 tickLower, - int24 tickUpper - ) external view returns (int56 tickCumulativeInside, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside); -} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolErrors.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolErrors.sol deleted file mode 100644 index 0322df98..00000000 --- a/src/dex/v3/core/interfaces/pool/IListaV3PoolErrors.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Errors emitted by a pool -/// @notice Contains all events emitted by the pool -interface IListaV3PoolErrors { - error LOK(); - error TLU(); - error TLM(); - error TUM(); - error AI(); - error M0(); - error M1(); - error AS(); - error IIA(); - error L(); - error F0(); - error F1(); -} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolEvents.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolEvents.sol deleted file mode 100644 index 33b81ae8..00000000 --- a/src/dex/v3/core/interfaces/pool/IListaV3PoolEvents.sol +++ /dev/null @@ -1,118 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Events emitted by a pool -/// @notice Contains all events emitted by the pool -interface IListaV3PoolEvents { - /// @notice Emitted exactly once by a pool when #initialize is first called on the pool - /// @dev Mint/Burn/Swap cannot be emitted by the pool before Initialize - /// @param sqrtPriceX96 The initial sqrt price of the pool, as a Q64.96 - /// @param tick The initial tick of the pool, i.e. log base 1.0001 of the starting price of the pool - event Initialize(uint160 sqrtPriceX96, int24 tick); - - /// @notice Emitted when liquidity is minted for a given position - /// @param sender The address that minted the liquidity - /// @param owner The owner of the position and recipient of any minted liquidity - /// @param tickLower The lower tick of the position - /// @param tickUpper The upper tick of the position - /// @param amount The amount of liquidity minted to the position range - /// @param amount0 How much token0 was required for the minted liquidity - /// @param amount1 How much token1 was required for the minted liquidity - event Mint( - address sender, - address indexed owner, - int24 indexed tickLower, - int24 indexed tickUpper, - uint128 amount, - uint256 amount0, - uint256 amount1 - ); - - /// @notice Emitted when fees are collected by the owner of a position - /// @dev Collect events may be emitted with zero amount0 and amount1 when the caller chooses not to collect fees - /// @param owner The owner of the position for which fees are collected - /// @param tickLower The lower tick of the position - /// @param tickUpper The upper tick of the position - /// @param amount0 The amount of token0 fees collected - /// @param amount1 The amount of token1 fees collected - event Collect( - address indexed owner, - address recipient, - int24 indexed tickLower, - int24 indexed tickUpper, - uint128 amount0, - uint128 amount1 - ); - - /// @notice Emitted when a position's liquidity is removed - /// @dev Does not withdraw any fees earned by the liquidity position, which must be withdrawn via #collect - /// @param owner The owner of the position for which liquidity is removed - /// @param tickLower The lower tick of the position - /// @param tickUpper The upper tick of the position - /// @param amount The amount of liquidity to remove - /// @param amount0 The amount of token0 withdrawn - /// @param amount1 The amount of token1 withdrawn - event Burn( - address indexed owner, - int24 indexed tickLower, - int24 indexed tickUpper, - uint128 amount, - uint256 amount0, - uint256 amount1 - ); - - /// @notice Emitted by the pool for any swaps between token0 and token1 - /// @param sender The address that initiated the swap call, and that received the callback - /// @param recipient The address that received the output of the swap - /// @param amount0 The delta of the token0 balance of the pool - /// @param amount1 The delta of the token1 balance of the pool - /// @param sqrtPriceX96 The sqrt(price) of the pool after the swap, as a Q64.96 - /// @param liquidity The liquidity of the pool after the swap - /// @param tick The log base 1.0001 of price of the pool after the swap - event Swap( - address indexed sender, - address indexed recipient, - int256 amount0, - int256 amount1, - uint160 sqrtPriceX96, - uint128 liquidity, - int24 tick - ); - - /// @notice Emitted by the pool for any flashes of token0/token1 - /// @param sender The address that initiated the swap call, and that received the callback - /// @param recipient The address that received the tokens from flash - /// @param amount0 The amount of token0 that was flashed - /// @param amount1 The amount of token1 that was flashed - /// @param paid0 The amount of token0 paid for the flash, which can exceed the amount0 plus the fee - /// @param paid1 The amount of token1 paid for the flash, which can exceed the amount1 plus the fee - event Flash( - address indexed sender, - address indexed recipient, - uint256 amount0, - uint256 amount1, - uint256 paid0, - uint256 paid1 - ); - - /// @notice Emitted by the pool for increases to the number of observations that can be stored - /// @dev observationCardinalityNext is not the observation cardinality until an observation is written at the index - /// just before a mint/swap/burn. - /// @param observationCardinalityNextOld The previous value of the next observation cardinality - /// @param observationCardinalityNextNew The updated value of the next observation cardinality - event IncreaseObservationCardinalityNext(uint16 observationCardinalityNextOld, uint16 observationCardinalityNextNew); - - /// @notice Emitted when the protocol fee is changed by the pool - /// @param feeProtocol0Old The previous value of the token0 protocol fee - /// @param feeProtocol1Old The previous value of the token1 protocol fee - /// @param feeProtocol0New The updated value of the token0 protocol fee - /// @param feeProtocol1New The updated value of the token1 protocol fee - event SetFeeProtocol(uint8 feeProtocol0Old, uint8 feeProtocol1Old, uint8 feeProtocol0New, uint8 feeProtocol1New); - - /// @notice Emitted when the collected protocol fees are withdrawn by the factory owner - /// @param sender The address that collects the protocol fees - /// @param recipient The address that receives the collected protocol fees - /// @param amount0 The amount of token0 protocol fees that is withdrawn - /// @param amount0 The amount of token1 protocol fees that is withdrawn - event CollectProtocol(address indexed sender, address indexed recipient, uint128 amount0, uint128 amount1); -} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolImmutables.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolImmutables.sol deleted file mode 100644 index e5fb14a4..00000000 --- a/src/dex/v3/core/interfaces/pool/IListaV3PoolImmutables.sol +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Pool state that never changes -/// @notice These parameters are fixed for a pool forever, i.e., the methods will always return the same values -interface IListaV3PoolImmutables { - /// @notice The contract that deployed the pool, which must adhere to the IListaV3Factory interface - /// @return The contract address - function factory() external view returns (address); - - /// @notice The first of the two tokens of the pool, sorted by address - /// @return The token contract address - function token0() external view returns (address); - - /// @notice The second of the two tokens of the pool, sorted by address - /// @return The token contract address - function token1() external view returns (address); - - /// @notice The pool's fee in hundredths of a bip, i.e. 1e-6 - /// @return The fee - function fee() external view returns (uint24); - - /// @notice The pool tick spacing - /// @dev Ticks can only be used at multiples of this value, minimum of 1 and always positive - /// e.g.: a tickSpacing of 3 means ticks can be initialized every 3rd tick, i.e., ..., -6, -3, 0, 3, 6, ... - /// This value is an int24 to avoid casting even though it is always positive. - /// @return The tick spacing - function tickSpacing() external view returns (int24); - - /// @notice The maximum amount of position liquidity that can use any tick in the range - /// @dev This parameter is enforced per tick to prevent liquidity from overflowing a uint128 at any point, and - /// also prevents out-of-range liquidity from being used to prevent adding in-range liquidity to a pool - /// @return The max amount of liquidity per tick - function maxLiquidityPerTick() external view returns (uint128); -} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolOwnerActions.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolOwnerActions.sol deleted file mode 100644 index 35f3b575..00000000 --- a/src/dex/v3/core/interfaces/pool/IListaV3PoolOwnerActions.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Permissioned pool actions -/// @notice Contains pool methods that may only be called by the factory owner -interface IListaV3PoolOwnerActions { - /// @notice Set the denominator of the protocol's % share of the fees - /// @param feeProtocol0 new protocol fee for token0 of the pool - /// @param feeProtocol1 new protocol fee for token1 of the pool - function setFeeProtocol(uint8 feeProtocol0, uint8 feeProtocol1) external; - - /// @notice Collect the protocol fee accrued to the pool - /// @param recipient The address to which collected protocol fees should be sent - /// @param amount0Requested The maximum amount of token0 to send, can be 0 to collect fees in only token1 - /// @param amount1Requested The maximum amount of token1 to send, can be 0 to collect fees in only token0 - /// @return amount0 The protocol fee collected in token0 - /// @return amount1 The protocol fee collected in token1 - function collectProtocol( - address recipient, - uint128 amount0Requested, - uint128 amount1Requested - ) external returns (uint128 amount0, uint128 amount1); -} diff --git a/src/dex/v3/core/interfaces/pool/IListaV3PoolState.sol b/src/dex/v3/core/interfaces/pool/IListaV3PoolState.sol deleted file mode 100644 index 979b96d1..00000000 --- a/src/dex/v3/core/interfaces/pool/IListaV3PoolState.sol +++ /dev/null @@ -1,118 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Pool state that can change -/// @notice These methods compose the pool's state, and can change with any frequency including multiple times -/// per transaction -interface IListaV3PoolState { - /// @notice The 0th storage slot in the pool stores many values, and is exposed as a single method to save gas - /// when accessed externally. - /// @return sqrtPriceX96 The current price of the pool as a sqrt(token1/token0) Q64.96 value - /// @return tick The current tick of the pool, i.e. according to the last tick transition that was run. - /// This value may not always be equal to SqrtTickMath.getTickAtSqrtRatio(sqrtPriceX96) if the price is on a tick - /// boundary. - /// @return observationIndex The index of the last oracle observation that was written, - /// @return observationCardinality The current maximum number of observations stored in the pool, - /// @return observationCardinalityNext The next maximum number of observations, to be updated when the observation. - /// @return feeProtocol The protocol fee for both tokens of the pool. - /// Encoded as two 4 bit values, where the protocol fee of token1 is shifted 4 bits and the protocol fee of token0 - /// is the lower 4 bits. Used as the denominator of a fraction of the swap fee, e.g. 4 means 1/4th of the swap fee. - /// unlocked Whether the pool is currently locked to reentrancy - function slot0() - external - view - returns ( - uint160 sqrtPriceX96, - int24 tick, - uint16 observationIndex, - uint16 observationCardinality, - uint16 observationCardinalityNext, - uint8 feeProtocol, - bool unlocked - ); - - /// @notice The fee growth as a Q128.128 fees of token0 collected per unit of liquidity for the entire life of the pool - /// @dev This value can overflow the uint256 - function feeGrowthGlobal0X128() external view returns (uint256); - - /// @notice The fee growth as a Q128.128 fees of token1 collected per unit of liquidity for the entire life of the pool - /// @dev This value can overflow the uint256 - function feeGrowthGlobal1X128() external view returns (uint256); - - /// @notice The amounts of token0 and token1 that are owed to the protocol - /// @dev Protocol fees will never exceed uint128 max in either token - function protocolFees() external view returns (uint128 token0, uint128 token1); - - /// @notice The currently in range liquidity available to the pool - /// @dev This value has no relationship to the total liquidity across all ticks - /// @return The liquidity at the current price of the pool - function liquidity() external view returns (uint128); - - /// @notice Look up information about a specific tick in the pool - /// @param tick The tick to look up - /// @return liquidityGross the total amount of position liquidity that uses the pool either as tick lower or - /// tick upper - /// @return liquidityNet how much liquidity changes when the pool price crosses the tick, - /// @return feeGrowthOutside0X128 the fee growth on the other side of the tick from the current tick in token0, - /// @return feeGrowthOutside1X128 the fee growth on the other side of the tick from the current tick in token1, - /// @return tickCumulativeOutside the cumulative tick value on the other side of the tick from the current tick - /// @return secondsPerLiquidityOutsideX128 the seconds spent per liquidity on the other side of the tick from the current tick, - /// @return secondsOutside the seconds spent on the other side of the tick from the current tick, - /// @return initialized Set to true if the tick is initialized, i.e. liquidityGross is greater than 0, otherwise equal to false. - /// Outside values can only be used if the tick is initialized, i.e. if liquidityGross is greater than 0. - /// In addition, these values are only relative and must be used only in comparison to previous snapshots for - /// a specific position. - function ticks( - int24 tick - ) - external - view - returns ( - uint128 liquidityGross, - int128 liquidityNet, - uint256 feeGrowthOutside0X128, - uint256 feeGrowthOutside1X128, - int56 tickCumulativeOutside, - uint160 secondsPerLiquidityOutsideX128, - uint32 secondsOutside, - bool initialized - ); - - /// @notice Returns 256 packed tick initialized boolean values. See TickBitmap for more information - function tickBitmap(int16 wordPosition) external view returns (uint256); - - /// @notice Returns the information about a position by the position's key - /// @param key The position's key is a hash of a preimage composed by the owner, tickLower and tickUpper - /// @return liquidity The amount of liquidity in the position, - /// @return feeGrowthInside0LastX128 fee growth of token0 inside the tick range as of the last mint/burn/poke, - /// @return feeGrowthInside1LastX128 fee growth of token1 inside the tick range as of the last mint/burn/poke, - /// @return tokensOwed0 the computed amount of token0 owed to the position as of the last mint/burn/poke, - /// @return tokensOwed1 the computed amount of token1 owed to the position as of the last mint/burn/poke - function positions( - bytes32 key - ) - external - view - returns ( - uint128 liquidity, - uint256 feeGrowthInside0LastX128, - uint256 feeGrowthInside1LastX128, - uint128 tokensOwed0, - uint128 tokensOwed1 - ); - - /// @notice Returns data about a specific observation index - /// @param index The element of the observations array to fetch - /// @dev You most likely want to use #observe() instead of this method to get an observation as of some amount of time - /// ago, rather than at a specific index in the array. - /// @return blockTimestamp The timestamp of the observation, - /// @return tickCumulative the tick multiplied by seconds elapsed for the life of the pool as of the observation timestamp, - /// @return secondsPerLiquidityCumulativeX128 the seconds per in range liquidity for the life of the pool as of the observation timestamp, - /// @return initialized whether the observation has been initialized and the values are safe to use - function observations( - uint256 index - ) - external - view - returns (uint32 blockTimestamp, int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128, bool initialized); -} diff --git a/src/dex/v3/core/libraries/BitMath.sol b/src/dex/v3/core/libraries/BitMath.sol deleted file mode 100644 index 63910775..00000000 --- a/src/dex/v3/core/libraries/BitMath.sol +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -/// @title BitMath -/// @dev This library provides functionality for computing bit properties of an unsigned integer -library BitMath { - /// @notice Returns the index of the most significant bit of the number, - /// where the least significant bit is at index 0 and the most significant bit is at index 255 - /// @dev The function satisfies the property: - /// x >= 2**mostSignificantBit(x) and x < 2**(mostSignificantBit(x)+1) - /// @param x the value for which to compute the most significant bit, must be greater than 0 - /// @return r the index of the most significant bit - function mostSignificantBit(uint256 x) internal pure returns (uint8 r) { - require(x > 0); - - unchecked { - if (x >= 0x100000000000000000000000000000000) { - x >>= 128; - r += 128; - } - if (x >= 0x10000000000000000) { - x >>= 64; - r += 64; - } - if (x >= 0x100000000) { - x >>= 32; - r += 32; - } - if (x >= 0x10000) { - x >>= 16; - r += 16; - } - if (x >= 0x100) { - x >>= 8; - r += 8; - } - if (x >= 0x10) { - x >>= 4; - r += 4; - } - if (x >= 0x4) { - x >>= 2; - r += 2; - } - if (x >= 0x2) r += 1; - } - } - - /// @notice Returns the index of the least significant bit of the number, - /// where the least significant bit is at index 0 and the most significant bit is at index 255 - /// @dev The function satisfies the property: - /// (x & 2**leastSignificantBit(x)) != 0 and (x & (2**(leastSignificantBit(x)) - 1)) == 0) - /// @param x the value for which to compute the least significant bit, must be greater than 0 - /// @return r the index of the least significant bit - function leastSignificantBit(uint256 x) internal pure returns (uint8 r) { - require(x > 0); - - unchecked { - r = 255; - if (x & type(uint128).max > 0) { - r -= 128; - } else { - x >>= 128; - } - if (x & type(uint64).max > 0) { - r -= 64; - } else { - x >>= 64; - } - if (x & type(uint32).max > 0) { - r -= 32; - } else { - x >>= 32; - } - if (x & type(uint16).max > 0) { - r -= 16; - } else { - x >>= 16; - } - if (x & type(uint8).max > 0) { - r -= 8; - } else { - x >>= 8; - } - if (x & 0xf > 0) { - r -= 4; - } else { - x >>= 4; - } - if (x & 0x3 > 0) { - r -= 2; - } else { - x >>= 2; - } - if (x & 0x1 > 0) r -= 1; - } - } -} diff --git a/src/dex/v3/core/libraries/FixedPoint128.sol b/src/dex/v3/core/libraries/FixedPoint128.sol deleted file mode 100644 index 10e265cc..00000000 --- a/src/dex/v3/core/libraries/FixedPoint128.sol +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.4.0; - -/// @title FixedPoint128 -/// @notice A library for handling binary fixed point numbers, see https://en.wikipedia.org/wiki/Q_(number_format) -library FixedPoint128 { - uint256 internal constant Q128 = 0x100000000000000000000000000000000; -} diff --git a/src/dex/v3/core/libraries/Oracle.sol b/src/dex/v3/core/libraries/Oracle.sol deleted file mode 100644 index bf338ac5..00000000 --- a/src/dex/v3/core/libraries/Oracle.sol +++ /dev/null @@ -1,342 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -/// @title Oracle -/// @notice Provides price and liquidity data useful for a wide variety of system designs -/// @dev Instances of stored oracle data, "observations", are collected in the oracle array -/// Every pool is initialized with an oracle array length of 1. Anyone can pay the SSTOREs to increase the -/// maximum length of the oracle array. New slots will be added when the array is fully populated. -/// Observations are overwritten when the full length of the oracle array is populated. -/// The most recent observation is available, independent of the length of the oracle array, by passing 0 to observe() -library Oracle { - error I(); - error OLD(); - - struct Observation { - // the block timestamp of the observation - uint32 blockTimestamp; - // the tick accumulator, i.e. tick * time elapsed since the pool was first initialized - int56 tickCumulative; - // the seconds per liquidity, i.e. seconds elapsed / max(1, liquidity) since the pool was first initialized - uint160 secondsPerLiquidityCumulativeX128; - // whether or not the observation is initialized - bool initialized; - } - - /// @notice Transforms a previous observation into a new observation, given the passage of time and the current tick and liquidity values - /// @dev blockTimestamp _must_ be chronologically equal to or greater than last.blockTimestamp, safe for 0 or 1 overflows - /// @param last The specified observation to be transformed - /// @param blockTimestamp The timestamp of the new observation - /// @param tick The active tick at the time of the new observation - /// @param liquidity The total in-range liquidity at the time of the new observation - /// @return Observation The newly populated observation - function transform( - Observation memory last, - uint32 blockTimestamp, - int24 tick, - uint128 liquidity - ) private pure returns (Observation memory) { - unchecked { - uint32 delta = blockTimestamp - last.blockTimestamp; - return - Observation({ - blockTimestamp: blockTimestamp, - tickCumulative: last.tickCumulative + int56(tick) * int56(uint56(delta)), - secondsPerLiquidityCumulativeX128: last.secondsPerLiquidityCumulativeX128 + - ((uint160(delta) << 128) / (liquidity > 0 ? liquidity : 1)), - initialized: true - }); - } - } - - /// @notice Initialize the oracle array by writing the first slot. Called once for the lifecycle of the observations array - /// @param self The stored oracle array - /// @param time The time of the oracle initialization, via block.timestamp truncated to uint32 - /// @return cardinality The number of populated elements in the oracle array - /// @return cardinalityNext The new length of the oracle array, independent of population - function initialize( - Observation[65535] storage self, - uint32 time - ) internal returns (uint16 cardinality, uint16 cardinalityNext) { - self[0] = Observation({ - blockTimestamp: time, - tickCumulative: 0, - secondsPerLiquidityCumulativeX128: 0, - initialized: true - }); - return (1, 1); - } - - /// @notice Writes an oracle observation to the array - /// @dev Writable at most once per block. Index represents the most recently written element. cardinality and index must be tracked externally. - /// If the index is at the end of the allowable array length (according to cardinality), and the next cardinality - /// is greater than the current one, cardinality may be increased. This restriction is created to preserve ordering. - /// @param self The stored oracle array - /// @param index The index of the observation that was most recently written to the observations array - /// @param blockTimestamp The timestamp of the new observation - /// @param tick The active tick at the time of the new observation - /// @param liquidity The total in-range liquidity at the time of the new observation - /// @param cardinality The number of populated elements in the oracle array - /// @param cardinalityNext The new length of the oracle array, independent of population - /// @return indexUpdated The new index of the most recently written element in the oracle array - /// @return cardinalityUpdated The new cardinality of the oracle array - function write( - Observation[65535] storage self, - uint16 index, - uint32 blockTimestamp, - int24 tick, - uint128 liquidity, - uint16 cardinality, - uint16 cardinalityNext - ) internal returns (uint16 indexUpdated, uint16 cardinalityUpdated) { - unchecked { - Observation memory last = self[index]; - - // early return if we've already written an observation this block - if (last.blockTimestamp == blockTimestamp) return (index, cardinality); - - // if the conditions are right, we can bump the cardinality - if (cardinalityNext > cardinality && index == (cardinality - 1)) { - cardinalityUpdated = cardinalityNext; - } else { - cardinalityUpdated = cardinality; - } - - indexUpdated = (index + 1) % cardinalityUpdated; - self[indexUpdated] = transform(last, blockTimestamp, tick, liquidity); - } - } - - /// @notice Prepares the oracle array to store up to `next` observations - /// @param self The stored oracle array - /// @param current The current next cardinality of the oracle array - /// @param next The proposed next cardinality which will be populated in the oracle array - /// @return next The next cardinality which will be populated in the oracle array - function grow(Observation[65535] storage self, uint16 current, uint16 next) internal returns (uint16) { - unchecked { - if (current <= 0) revert I(); - // no-op if the passed next value isn't greater than the current next value - if (next <= current) return current; - // store in each slot to prevent fresh SSTOREs in swaps - // this data will not be used because the initialized boolean is still false - for (uint16 i = current; i < next; i++) self[i].blockTimestamp = 1; - return next; - } - } - - /// @notice comparator for 32-bit timestamps - /// @dev safe for 0 or 1 overflows, a and b _must_ be chronologically before or equal to time - /// @param time A timestamp truncated to 32 bits - /// @param a A comparison timestamp from which to determine the relative position of `time` - /// @param b From which to determine the relative position of `time` - /// @return Whether `a` is chronologically <= `b` - function lte(uint32 time, uint32 a, uint32 b) private pure returns (bool) { - unchecked { - // if there hasn't been overflow, no need to adjust - if (a <= time && b <= time) return a <= b; - - uint256 aAdjusted = a > time ? a : a + 2 ** 32; - uint256 bAdjusted = b > time ? b : b + 2 ** 32; - - return aAdjusted <= bAdjusted; - } - } - - /// @notice Fetches the observations beforeOrAt and atOrAfter a target, i.e. where [beforeOrAt, atOrAfter] is satisfied. - /// The result may be the same observation, or adjacent observations. - /// @dev The answer must be contained in the array, used when the target is located within the stored observation - /// boundaries: older than the most recent observation and younger, or the same age as, the oldest observation - /// @param self The stored oracle array - /// @param time The current block.timestamp - /// @param target The timestamp at which the reserved observation should be for - /// @param index The index of the observation that was most recently written to the observations array - /// @param cardinality The number of populated elements in the oracle array - /// @return beforeOrAt The observation recorded before, or at, the target - /// @return atOrAfter The observation recorded at, or after, the target - function binarySearch( - Observation[65535] storage self, - uint32 time, - uint32 target, - uint16 index, - uint16 cardinality - ) private view returns (Observation memory beforeOrAt, Observation memory atOrAfter) { - unchecked { - uint256 l = (index + 1) % cardinality; // oldest observation - uint256 r = l + cardinality - 1; // newest observation - uint256 i; - while (true) { - i = (l + r) / 2; - - beforeOrAt = self[i % cardinality]; - - // we've landed on an uninitialized tick, keep searching higher (more recently) - if (!beforeOrAt.initialized) { - l = i + 1; - continue; - } - - atOrAfter = self[(i + 1) % cardinality]; - - bool targetAtOrAfter = lte(time, beforeOrAt.blockTimestamp, target); - - // check if we've found the answer! - if (targetAtOrAfter && lte(time, target, atOrAfter.blockTimestamp)) break; - - if (!targetAtOrAfter) r = i - 1; - else l = i + 1; - } - } - } - - /// @notice Fetches the observations beforeOrAt and atOrAfter a given target, i.e. where [beforeOrAt, atOrAfter] is satisfied - /// @dev Assumes there is at least 1 initialized observation. - /// Used by observeSingle() to compute the counterfactual accumulator values as of a given block timestamp. - /// @param self The stored oracle array - /// @param time The current block.timestamp - /// @param target The timestamp at which the reserved observation should be for - /// @param tick The active tick at the time of the returned or simulated observation - /// @param index The index of the observation that was most recently written to the observations array - /// @param liquidity The total pool liquidity at the time of the call - /// @param cardinality The number of populated elements in the oracle array - /// @return beforeOrAt The observation which occurred at, or before, the given timestamp - /// @return atOrAfter The observation which occurred at, or after, the given timestamp - function getSurroundingObservations( - Observation[65535] storage self, - uint32 time, - uint32 target, - int24 tick, - uint16 index, - uint128 liquidity, - uint16 cardinality - ) private view returns (Observation memory beforeOrAt, Observation memory atOrAfter) { - unchecked { - // optimistically set before to the newest observation - beforeOrAt = self[index]; - - // if the target is chronologically at or after the newest observation, we can early return - if (lte(time, beforeOrAt.blockTimestamp, target)) { - if (beforeOrAt.blockTimestamp == target) { - // if newest observation equals target, we're in the same block, so we can ignore atOrAfter - return (beforeOrAt, atOrAfter); - } else { - // otherwise, we need to transform - return (beforeOrAt, transform(beforeOrAt, target, tick, liquidity)); - } - } - - // now, set before to the oldest observation - beforeOrAt = self[(index + 1) % cardinality]; - if (!beforeOrAt.initialized) beforeOrAt = self[0]; - - // ensure that the target is chronologically at or after the oldest observation - if (!lte(time, beforeOrAt.blockTimestamp, target)) revert OLD(); - - // if we've reached this point, we have to binary search - return binarySearch(self, time, target, index, cardinality); - } - } - - /// @dev Reverts if an observation at or before the desired observation timestamp does not exist. - /// 0 may be passed as `secondsAgo' to return the current cumulative values. - /// If called with a timestamp falling between two observations, returns the counterfactual accumulator values - /// at exactly the timestamp between the two observations. - /// @param self The stored oracle array - /// @param time The current block timestamp - /// @param secondsAgo The amount of time to look back, in seconds, at which point to return an observation - /// @param tick The current tick - /// @param index The index of the observation that was most recently written to the observations array - /// @param liquidity The current in-range pool liquidity - /// @param cardinality The number of populated elements in the oracle array - /// @return tickCumulative The tick * time elapsed since the pool was first initialized, as of `secondsAgo` - /// @return secondsPerLiquidityCumulativeX128 The time elapsed / max(1, liquidity) since the pool was first initialized, as of `secondsAgo` - function observeSingle( - Observation[65535] storage self, - uint32 time, - uint32 secondsAgo, - int24 tick, - uint16 index, - uint128 liquidity, - uint16 cardinality - ) internal view returns (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) { - unchecked { - if (secondsAgo == 0) { - Observation memory last = self[index]; - if (last.blockTimestamp != time) last = transform(last, time, tick, liquidity); - return (last.tickCumulative, last.secondsPerLiquidityCumulativeX128); - } - - uint32 target = time - secondsAgo; - - (Observation memory beforeOrAt, Observation memory atOrAfter) = getSurroundingObservations( - self, - time, - target, - tick, - index, - liquidity, - cardinality - ); - - if (target == beforeOrAt.blockTimestamp) { - // we're at the left boundary - return (beforeOrAt.tickCumulative, beforeOrAt.secondsPerLiquidityCumulativeX128); - } else if (target == atOrAfter.blockTimestamp) { - // we're at the right boundary - return (atOrAfter.tickCumulative, atOrAfter.secondsPerLiquidityCumulativeX128); - } else { - // we're in the middle - uint32 observationTimeDelta = atOrAfter.blockTimestamp - beforeOrAt.blockTimestamp; - uint32 targetDelta = target - beforeOrAt.blockTimestamp; - return ( - beforeOrAt.tickCumulative + - ((atOrAfter.tickCumulative - beforeOrAt.tickCumulative) / int56(uint56(observationTimeDelta))) * - int56(uint56(targetDelta)), - beforeOrAt.secondsPerLiquidityCumulativeX128 + - uint160( - (uint256(atOrAfter.secondsPerLiquidityCumulativeX128 - beforeOrAt.secondsPerLiquidityCumulativeX128) * - targetDelta) / observationTimeDelta - ) - ); - } - } - } - - /// @notice Returns the accumulator values as of each time seconds ago from the given time in the array of `secondsAgos` - /// @dev Reverts if `secondsAgos` > oldest observation - /// @param self The stored oracle array - /// @param time The current block.timestamp - /// @param secondsAgos Each amount of time to look back, in seconds, at which point to return an observation - /// @param tick The current tick - /// @param index The index of the observation that was most recently written to the observations array - /// @param liquidity The current in-range pool liquidity - /// @param cardinality The number of populated elements in the oracle array - /// @return tickCumulatives The tick * time elapsed since the pool was first initialized, as of each `secondsAgo` - /// @return secondsPerLiquidityCumulativeX128s The cumulative seconds / max(1, liquidity) since the pool was first initialized, as of each `secondsAgo` - function observe( - Observation[65535] storage self, - uint32 time, - uint32[] memory secondsAgos, - int24 tick, - uint16 index, - uint128 liquidity, - uint16 cardinality - ) internal view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) { - unchecked { - if (cardinality <= 0) revert I(); - - tickCumulatives = new int56[](secondsAgos.length); - secondsPerLiquidityCumulativeX128s = new uint160[](secondsAgos.length); - for (uint256 i = 0; i < secondsAgos.length; i++) { - (tickCumulatives[i], secondsPerLiquidityCumulativeX128s[i]) = observeSingle( - self, - time, - secondsAgos[i], - tick, - index, - liquidity, - cardinality - ); - } - } - } -} diff --git a/src/dex/v3/core/libraries/Position.sol b/src/dex/v3/core/libraries/Position.sol deleted file mode 100644 index 80c069ae..00000000 --- a/src/dex/v3/core/libraries/Position.sol +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import { FullMath } from "./FullMath.sol"; -import { FixedPoint128 } from "./FixedPoint128.sol"; - -/// @title Position -/// @notice Positions represent an owner address' liquidity between a lower and upper tick boundary -/// @dev Positions store additional state for tracking fees owed to the position -library Position { - error NP(); - - // info stored for each user's position - struct Info { - // the amount of liquidity owned by this position - uint128 liquidity; - // fee growth per unit of liquidity as of the last update to liquidity or fees owed - uint256 feeGrowthInside0LastX128; - uint256 feeGrowthInside1LastX128; - // the fees owed to the position owner in token0/token1 - uint128 tokensOwed0; - uint128 tokensOwed1; - } - - /// @notice Returns the Info struct of a position, given an owner and position boundaries - /// @param self The mapping containing all user positions - /// @param owner The address of the position owner - /// @param tickLower The lower tick boundary of the position - /// @param tickUpper The upper tick boundary of the position - /// @return position The position info struct of the given owners' position - function get( - mapping(bytes32 => Info) storage self, - address owner, - int24 tickLower, - int24 tickUpper - ) internal view returns (Position.Info storage position) { - position = self[keccak256(abi.encodePacked(owner, tickLower, tickUpper))]; - } - - /// @notice Credits accumulated fees to a user's position - /// @param self The individual position to update - /// @param liquidityDelta The change in pool liquidity as a result of the position update - /// @param feeGrowthInside0X128 The all-time fee growth in token0, per unit of liquidity, inside the position's tick boundaries - /// @param feeGrowthInside1X128 The all-time fee growth in token1, per unit of liquidity, inside the position's tick boundaries - function update( - Info storage self, - int128 liquidityDelta, - uint256 feeGrowthInside0X128, - uint256 feeGrowthInside1X128 - ) internal { - Info memory _self = self; - - uint128 liquidityNext; - if (liquidityDelta == 0) { - if (_self.liquidity <= 0) revert NP(); // disallow pokes for 0 liquidity positions - liquidityNext = _self.liquidity; - } else { - liquidityNext = liquidityDelta < 0 - ? _self.liquidity - uint128(-liquidityDelta) - : _self.liquidity + uint128(liquidityDelta); - } - - // calculate accumulated fees. overflow in the subtraction of fee growth is expected - uint128 tokensOwed0; - uint128 tokensOwed1; - unchecked { - tokensOwed0 = uint128( - FullMath.mulDiv(feeGrowthInside0X128 - _self.feeGrowthInside0LastX128, _self.liquidity, FixedPoint128.Q128) - ); - tokensOwed1 = uint128( - FullMath.mulDiv(feeGrowthInside1X128 - _self.feeGrowthInside1LastX128, _self.liquidity, FixedPoint128.Q128) - ); - - // update the position - if (liquidityDelta != 0) self.liquidity = liquidityNext; - self.feeGrowthInside0LastX128 = feeGrowthInside0X128; - self.feeGrowthInside1LastX128 = feeGrowthInside1X128; - if (tokensOwed0 > 0 || tokensOwed1 > 0) { - // overflow is acceptable, user must withdraw before they hit type(uint128).max fees - self.tokensOwed0 += tokensOwed0; - self.tokensOwed1 += tokensOwed1; - } - } - } -} diff --git a/src/dex/v3/core/libraries/SwapMath.sol b/src/dex/v3/core/libraries/SwapMath.sol deleted file mode 100644 index 1ef97535..00000000 --- a/src/dex/v3/core/libraries/SwapMath.sol +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import { FullMath } from "./FullMath.sol"; -import { SqrtPriceMath } from "./SqrtPriceMath.sol"; - -/// @title Computes the result of a swap within ticks -/// @notice Contains methods for computing the result of a swap within a single tick price range, i.e., a single tick. -library SwapMath { - /// @notice Computes the result of swapping some amount in, or amount out, given the parameters of the swap - /// @dev The fee, plus the amount in, will never exceed the amount remaining if the swap's `amountSpecified` is positive - /// @param sqrtRatioCurrentX96 The current sqrt price of the pool - /// @param sqrtRatioTargetX96 The price that cannot be exceeded, from which the direction of the swap is inferred - /// @param liquidity The usable liquidity - /// @param amountRemaining How much input or output amount is remaining to be swapped in/out - /// @param feePips The fee taken from the input amount, expressed in hundredths of a bip - /// @return sqrtRatioNextX96 The price after swapping the amount in/out, not to exceed the price target - /// @return amountIn The amount to be swapped in, of either token0 or token1, based on the direction of the swap - /// @return amountOut The amount to be received, of either token0 or token1, based on the direction of the swap - /// @return feeAmount The amount of input that will be taken as a fee - function computeSwapStep( - uint160 sqrtRatioCurrentX96, - uint160 sqrtRatioTargetX96, - uint128 liquidity, - int256 amountRemaining, - uint24 feePips - ) internal pure returns (uint160 sqrtRatioNextX96, uint256 amountIn, uint256 amountOut, uint256 feeAmount) { - unchecked { - bool zeroForOne = sqrtRatioCurrentX96 >= sqrtRatioTargetX96; - bool exactIn = amountRemaining >= 0; - - if (exactIn) { - uint256 amountRemainingLessFee = FullMath.mulDiv(uint256(amountRemaining), 1e6 - feePips, 1e6); - amountIn = zeroForOne - ? SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true) - : SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true); - if (amountRemainingLessFee >= amountIn) sqrtRatioNextX96 = sqrtRatioTargetX96; - else - sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput( - sqrtRatioCurrentX96, - liquidity, - amountRemainingLessFee, - zeroForOne - ); - } else { - amountOut = zeroForOne - ? SqrtPriceMath.getAmount1Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, false) - : SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, false); - if (uint256(-amountRemaining) >= amountOut) sqrtRatioNextX96 = sqrtRatioTargetX96; - else - sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromOutput( - sqrtRatioCurrentX96, - liquidity, - uint256(-amountRemaining), - zeroForOne - ); - } - - bool max = sqrtRatioTargetX96 == sqrtRatioNextX96; - - // get the input/output amounts - if (zeroForOne) { - amountIn = max && exactIn - ? amountIn - : SqrtPriceMath.getAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, true); - amountOut = max && !exactIn - ? amountOut - : SqrtPriceMath.getAmount1Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false); - } else { - amountIn = max && exactIn - ? amountIn - : SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, true); - amountOut = max && !exactIn - ? amountOut - : SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, false); - } - - // cap the output amount to not exceed the remaining output amount - if (!exactIn && amountOut > uint256(-amountRemaining)) { - amountOut = uint256(-amountRemaining); - } - - if (exactIn && sqrtRatioNextX96 != sqrtRatioTargetX96) { - // we didn't reach the target, so take the remainder of the maximum input as fee - feeAmount = uint256(amountRemaining) - amountIn; - } else { - feeAmount = FullMath.mulDivRoundingUp(amountIn, feePips, 1e6 - feePips); - } - } - } -} diff --git a/src/dex/v3/core/libraries/Tick.sol b/src/dex/v3/core/libraries/Tick.sol deleted file mode 100644 index 6b3c4004..00000000 --- a/src/dex/v3/core/libraries/Tick.sol +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import { SafeCast } from "./SafeCast.sol"; - -import { TickMath } from "./TickMath.sol"; - -/// @title Tick -/// @notice Contains functions for managing tick processes and relevant calculations -library Tick { - error LO(); - - using SafeCast for int256; - - // info stored for each initialized individual tick - struct Info { - // the total position liquidity that references this tick - uint128 liquidityGross; - // amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left), - int128 liquidityNet; - // fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick) - // only has relative meaning, not absolute — the value depends on when the tick is initialized - uint256 feeGrowthOutside0X128; - uint256 feeGrowthOutside1X128; - // the cumulative tick value on the other side of the tick - int56 tickCumulativeOutside; - // the seconds per unit of liquidity on the _other_ side of this tick (relative to the current tick) - // only has relative meaning, not absolute — the value depends on when the tick is initialized - uint160 secondsPerLiquidityOutsideX128; - // the seconds spent on the other side of the tick (relative to the current tick) - // only has relative meaning, not absolute — the value depends on when the tick is initialized - uint32 secondsOutside; - // true iff the tick is initialized, i.e. the value is exactly equivalent to the expression liquidityGross != 0 - // these 8 bits are set to prevent fresh sstores when crossing newly initialized ticks - bool initialized; - } - - /// @notice Derives max liquidity per tick from given tick spacing - /// @dev Executed within the pool constructor - /// @param tickSpacing The amount of required tick separation, realized in multiples of `tickSpacing` - /// e.g., a tickSpacing of 3 requires ticks to be initialized every 3rd tick i.e., ..., -6, -3, 0, 3, 6, ... - /// @return The max liquidity per tick - function tickSpacingToMaxLiquidityPerTick(int24 tickSpacing) internal pure returns (uint128) { - unchecked { - int24 minTick = (TickMath.MIN_TICK / tickSpacing) * tickSpacing; - int24 maxTick = (TickMath.MAX_TICK / tickSpacing) * tickSpacing; - uint24 numTicks = uint24((maxTick - minTick) / tickSpacing) + 1; - return type(uint128).max / numTicks; - } - } - - /// @notice Retrieves fee growth data - /// @param self The mapping containing all tick information for initialized ticks - /// @param tickLower The lower tick boundary of the position - /// @param tickUpper The upper tick boundary of the position - /// @param tickCurrent The current tick - /// @param feeGrowthGlobal0X128 The all-time global fee growth, per unit of liquidity, in token0 - /// @param feeGrowthGlobal1X128 The all-time global fee growth, per unit of liquidity, in token1 - /// @return feeGrowthInside0X128 The all-time fee growth in token0, per unit of liquidity, inside the position's tick boundaries - /// @return feeGrowthInside1X128 The all-time fee growth in token1, per unit of liquidity, inside the position's tick boundaries - function getFeeGrowthInside( - mapping(int24 => Tick.Info) storage self, - int24 tickLower, - int24 tickUpper, - int24 tickCurrent, - uint256 feeGrowthGlobal0X128, - uint256 feeGrowthGlobal1X128 - ) internal view returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) { - unchecked { - Info storage lower = self[tickLower]; - Info storage upper = self[tickUpper]; - - // calculate fee growth below - uint256 feeGrowthBelow0X128; - uint256 feeGrowthBelow1X128; - if (tickCurrent >= tickLower) { - feeGrowthBelow0X128 = lower.feeGrowthOutside0X128; - feeGrowthBelow1X128 = lower.feeGrowthOutside1X128; - } else { - feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128; - feeGrowthBelow1X128 = feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128; - } - - // calculate fee growth above - uint256 feeGrowthAbove0X128; - uint256 feeGrowthAbove1X128; - if (tickCurrent < tickUpper) { - feeGrowthAbove0X128 = upper.feeGrowthOutside0X128; - feeGrowthAbove1X128 = upper.feeGrowthOutside1X128; - } else { - feeGrowthAbove0X128 = feeGrowthGlobal0X128 - upper.feeGrowthOutside0X128; - feeGrowthAbove1X128 = feeGrowthGlobal1X128 - upper.feeGrowthOutside1X128; - } - - feeGrowthInside0X128 = feeGrowthGlobal0X128 - feeGrowthBelow0X128 - feeGrowthAbove0X128; - feeGrowthInside1X128 = feeGrowthGlobal1X128 - feeGrowthBelow1X128 - feeGrowthAbove1X128; - } - } - - /// @notice Updates a tick and returns true if the tick was flipped from initialized to uninitialized, or vice versa - /// @param self The mapping containing all tick information for initialized ticks - /// @param tick The tick that will be updated - /// @param tickCurrent The current tick - /// @param liquidityDelta A new amount of liquidity to be added (subtracted) when tick is crossed from left to right (right to left) - /// @param feeGrowthGlobal0X128 The all-time global fee growth, per unit of liquidity, in token0 - /// @param feeGrowthGlobal1X128 The all-time global fee growth, per unit of liquidity, in token1 - /// @param secondsPerLiquidityCumulativeX128 The all-time seconds per max(1, liquidity) of the pool - /// @param tickCumulative The tick * time elapsed since the pool was first initialized - /// @param time The current block timestamp cast to a uint32 - /// @param upper true for updating a position's upper tick, or false for updating a position's lower tick - /// @param maxLiquidity The maximum liquidity allocation for a single tick - /// @return flipped Whether the tick was flipped from initialized to uninitialized, or vice versa - function update( - mapping(int24 => Tick.Info) storage self, - int24 tick, - int24 tickCurrent, - int128 liquidityDelta, - uint256 feeGrowthGlobal0X128, - uint256 feeGrowthGlobal1X128, - uint160 secondsPerLiquidityCumulativeX128, - int56 tickCumulative, - uint32 time, - bool upper, - uint128 maxLiquidity - ) internal returns (bool flipped) { - Tick.Info storage info = self[tick]; - - uint128 liquidityGrossBefore = info.liquidityGross; - uint128 liquidityGrossAfter = liquidityDelta < 0 - ? liquidityGrossBefore - uint128(-liquidityDelta) - : liquidityGrossBefore + uint128(liquidityDelta); - - if (liquidityGrossAfter > maxLiquidity) revert LO(); - - flipped = (liquidityGrossAfter == 0) != (liquidityGrossBefore == 0); - - if (liquidityGrossBefore == 0) { - // by convention, we assume that all growth before a tick was initialized happened _below_ the tick - if (tick <= tickCurrent) { - info.feeGrowthOutside0X128 = feeGrowthGlobal0X128; - info.feeGrowthOutside1X128 = feeGrowthGlobal1X128; - info.secondsPerLiquidityOutsideX128 = secondsPerLiquidityCumulativeX128; - info.tickCumulativeOutside = tickCumulative; - info.secondsOutside = time; - } - info.initialized = true; - } - - info.liquidityGross = liquidityGrossAfter; - - // when the lower (upper) tick is crossed left to right (right to left), liquidity must be added (removed) - info.liquidityNet = upper ? info.liquidityNet - liquidityDelta : info.liquidityNet + liquidityDelta; - } - - /// @notice Clears tick data - /// @param self The mapping containing all initialized tick information for initialized ticks - /// @param tick The tick that will be cleared - function clear(mapping(int24 => Tick.Info) storage self, int24 tick) internal { - delete self[tick]; - } - - /// @notice Transitions to next tick as needed by price movement - /// @param self The mapping containing all tick information for initialized ticks - /// @param tick The destination tick of the transition - /// @param feeGrowthGlobal0X128 The all-time global fee growth, per unit of liquidity, in token0 - /// @param feeGrowthGlobal1X128 The all-time global fee growth, per unit of liquidity, in token1 - /// @param secondsPerLiquidityCumulativeX128 The current seconds per liquidity - /// @param tickCumulative The tick * time elapsed since the pool was first initialized - /// @param time The current block.timestamp - /// @return liquidityNet The amount of liquidity added (subtracted) when tick is crossed from left to right (right to left) - function cross( - mapping(int24 => Tick.Info) storage self, - int24 tick, - uint256 feeGrowthGlobal0X128, - uint256 feeGrowthGlobal1X128, - uint160 secondsPerLiquidityCumulativeX128, - int56 tickCumulative, - uint32 time - ) internal returns (int128 liquidityNet) { - unchecked { - Tick.Info storage info = self[tick]; - info.feeGrowthOutside0X128 = feeGrowthGlobal0X128 - info.feeGrowthOutside0X128; - info.feeGrowthOutside1X128 = feeGrowthGlobal1X128 - info.feeGrowthOutside1X128; - info.secondsPerLiquidityOutsideX128 = secondsPerLiquidityCumulativeX128 - info.secondsPerLiquidityOutsideX128; - info.tickCumulativeOutside = tickCumulative - info.tickCumulativeOutside; - info.secondsOutside = time - info.secondsOutside; - liquidityNet = info.liquidityNet; - } - } -} diff --git a/src/dex/v3/core/libraries/TickBitmap.sol b/src/dex/v3/core/libraries/TickBitmap.sol deleted file mode 100644 index f70a5185..00000000 --- a/src/dex/v3/core/libraries/TickBitmap.sol +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import { BitMath } from "./BitMath.sol"; - -/// @title Packed tick initialized state library -/// @notice Stores a packed mapping of tick index to its initialized state -/// @dev The mapping uses int16 for keys since ticks are represented as int24 and there are 256 (2^8) values per word. -library TickBitmap { - /// @notice Computes the position in the mapping where the initialized bit for a tick lives - /// @param tick The tick for which to compute the position - /// @return wordPos The key in the mapping containing the word in which the bit is stored - /// @return bitPos The bit position in the word where the flag is stored - function position(int24 tick) private pure returns (int16 wordPos, uint8 bitPos) { - unchecked { - wordPos = int16(tick >> 8); - bitPos = uint8(int8(tick % 256)); - } - } - - /// @notice Flips the initialized state for a given tick from false to true, or vice versa - /// @param self The mapping in which to flip the tick - /// @param tick The tick to flip - /// @param tickSpacing The spacing between usable ticks - function flipTick(mapping(int16 => uint256) storage self, int24 tick, int24 tickSpacing) internal { - unchecked { - require(tick % tickSpacing == 0); // ensure that the tick is spaced - (int16 wordPos, uint8 bitPos) = position(tick / tickSpacing); - uint256 mask = 1 << bitPos; - self[wordPos] ^= mask; - } - } - - /// @notice Returns the next initialized tick contained in the same word (or adjacent word) as the tick that is either - /// to the left (less than or equal to) or right (greater than) of the given tick - /// @param self The mapping in which to compute the next initialized tick - /// @param tick The starting tick - /// @param tickSpacing The spacing between usable ticks - /// @param lte Whether to search for the next initialized tick to the left (less than or equal to the starting tick) - /// @return next The next initialized or uninitialized tick up to 256 ticks away from the current tick - /// @return initialized Whether the next tick is initialized, as the function only searches within up to 256 ticks - function nextInitializedTickWithinOneWord( - mapping(int16 => uint256) storage self, - int24 tick, - int24 tickSpacing, - bool lte - ) internal view returns (int24 next, bool initialized) { - unchecked { - int24 compressed = tick / tickSpacing; - if (tick < 0 && tick % tickSpacing != 0) compressed--; // round towards negative infinity - - if (lte) { - (int16 wordPos, uint8 bitPos) = position(compressed); - // all the 1s at or to the right of the current bitPos - uint256 mask = (1 << bitPos) - 1 + (1 << bitPos); - uint256 masked = self[wordPos] & mask; - - // if there are no initialized ticks to the right of or at the current tick, return rightmost in the word - initialized = masked != 0; - // overflow/underflow is possible, but prevented externally by limiting both tickSpacing and tick - next = initialized - ? (compressed - int24(uint24(bitPos - BitMath.mostSignificantBit(masked)))) * tickSpacing - : (compressed - int24(uint24(bitPos))) * tickSpacing; - } else { - // start from the word of the next tick, since the current tick state doesn't matter - (int16 wordPos, uint8 bitPos) = position(compressed + 1); - // all the 1s at or to the left of the bitPos - uint256 mask = ~((1 << bitPos) - 1); - uint256 masked = self[wordPos] & mask; - - // if there are no initialized ticks to the left of the current tick, return leftmost in the word - initialized = masked != 0; - // overflow/underflow is possible, but prevented externally by limiting both tickSpacing and tick - next = initialized - ? (compressed + 1 + int24(uint24(BitMath.leastSignificantBit(masked) - bitPos))) * tickSpacing - : (compressed + 1 + int24(uint24(type(uint8).max - bitPos))) * tickSpacing; - } - } - } -} diff --git a/src/dex/v3/core/libraries/TransferHelper.sol b/src/dex/v3/core/libraries/TransferHelper.sol deleted file mode 100644 index cc89462d..00000000 --- a/src/dex/v3/core/libraries/TransferHelper.sol +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.6.0; - -import { IERC20Minimal } from "../interfaces/IERC20Minimal.sol"; - -/// @title TransferHelper -/// @notice Contains helper methods for interacting with ERC20 tokens that do not consistently return true/false -library TransferHelper { - error TF(); - - /// @notice Transfers tokens from msg.sender to a recipient - /// @dev Calls transfer on token contract, errors with TF if transfer fails - /// @param token The contract address of the token which will be transferred - /// @param to The recipient of the transfer - /// @param value The value of the transfer - function safeTransfer(address token, address to, uint256 value) internal { - (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Minimal.transfer.selector, to, value)); - if (!(success && (data.length == 0 || abi.decode(data, (bool))))) revert TF(); - } -} diff --git a/src/dex/v3/periphery/NonfungiblePositionManager.sol b/src/dex/v3/periphery/NonfungiblePositionManager.sol deleted file mode 100644 index 094f2a3e..00000000 --- a/src/dex/v3/periphery/NonfungiblePositionManager.sol +++ /dev/null @@ -1,436 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; -pragma abicoder v2; - -import "../core/interfaces/IListaV3Pool.sol"; -import "../core/libraries/FixedPoint128.sol"; -import "../core/libraries/FullMath.sol"; - -import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; - -import "./interfaces/INonfungiblePositionManager.sol"; -import "./interfaces/INonfungibleTokenPositionDescriptor.sol"; -import "./libraries/PositionKey.sol"; -import "./libraries/PoolAddress.sol"; -import "./base/LiquidityManagement.sol"; -import "./base/PeripheryImmutableState.sol"; -import "./base/Multicall.sol"; -import "./base/ERC721Permit.sol"; -import "./base/PeripheryValidation.sol"; -import "./base/SelfPermit.sol"; -import "./base/PoolInitializer.sol"; - -/// @title NFT positions -/// @notice Wraps Lista V3 positions in the ERC721 non-fungible token interface -contract NonfungiblePositionManager is - INonfungiblePositionManager, - Multicall, - ERC721Permit, - PeripheryImmutableState, - PoolInitializer, - LiquidityManagement, - PeripheryValidation, - SelfPermit, - Initializable, - UUPSUpgradeable -{ - // details about the lista position - struct Position { - // the nonce for permits - uint96 nonce; - // the address that is approved for spending this token - address operator; - // the ID of the pool with which this token is connected - uint80 poolId; - // the tick range of the position - int24 tickLower; - int24 tickUpper; - // the liquidity of the position - uint128 liquidity; - // the fee growth of the aggregate position as of the last action on the individual position - uint256 feeGrowthInside0LastX128; - uint256 feeGrowthInside1LastX128; - // how many uncollected tokens are owed to the position, as of the last computation - uint128 tokensOwed0; - uint128 tokensOwed1; - } - - /// @dev IDs of pools assigned by this contract - mapping(address => uint80) private _poolIds; - - /// @dev Pool keys by pool ID, to save on SSTOREs for position data - mapping(uint80 => PoolAddress.PoolKey) private _poolIdToPoolKey; - - /// @dev The token ID position data - mapping(uint256 => Position) private _positions; - - /// @dev The ID of the next token that will be minted. Skips 0 - uint176 private _nextId = 1; - /// @dev The ID of the next pool that is used for the first time. Skips 0 - uint80 private _nextPoolId = 1; - - /// @dev The address of the token descriptor contract, which handles generating token URIs for position tokens - address private _tokenDescriptor; - - /// @dev Owner for upgrade authorization - address public admin; - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() - ERC721Permit("Lista V3 Positions NFT-V1", "LISTA-V3-POS", "1") - PeripheryImmutableState(address(0), address(0)) - { - _disableInitializers(); - } - - function initialize( - address _factory, - address _WETH9, - address _tokenDescriptor_, - address _admin, - bytes32 _poolInitCodeHash - ) external initializer { - require(_factory != address(0) && _WETH9 != address(0) && _admin != address(0), "zero address"); - factory = _factory; - WETH9 = _WETH9; - poolInitCodeHash = _poolInitCodeHash; - _tokenDescriptor = _tokenDescriptor_; - admin = _admin; - _nextId = 1; - _nextPoolId = 1; - } - - /// @dev Override name/symbol to return constants — avoids reliance on ERC721 storage - /// which is set by the constructor (runs on implementation, not proxy). - function name() public pure override(ERC721, IERC721Metadata) returns (string memory) { - return "Lista V3 Positions NFT-V1"; - } - - function symbol() public pure override(ERC721, IERC721Metadata) returns (string memory) { - return "LISTA-V3-POS"; - } - - function _authorizeUpgrade(address) internal override { - require(msg.sender == admin, "not admin"); - } - - /// @inheritdoc INonfungiblePositionManager - function positions( - uint256 tokenId - ) - external - view - override - 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 - ) - { - Position memory position = _positions[tokenId]; - require(position.poolId != 0, "Invalid token ID"); - PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; - return ( - position.nonce, - position.operator, - poolKey.token0, - poolKey.token1, - poolKey.fee, - position.tickLower, - position.tickUpper, - position.liquidity, - position.feeGrowthInside0LastX128, - position.feeGrowthInside1LastX128, - position.tokensOwed0, - position.tokensOwed1 - ); - } - - /// @dev Caches a pool key - function cachePoolKey(address pool, PoolAddress.PoolKey memory poolKey) private returns (uint80 poolId) { - poolId = _poolIds[pool]; - if (poolId == 0) { - _poolIds[pool] = (poolId = _nextPoolId++); - _poolIdToPoolKey[poolId] = poolKey; - } - } - - /// @inheritdoc INonfungiblePositionManager - function mint( - MintParams calldata params - ) - external - payable - override - checkDeadline(params.deadline) - returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) - { - IListaV3Pool pool; - (liquidity, amount0, amount1, pool) = addLiquidity( - AddLiquidityParams({ - token0: params.token0, - token1: params.token1, - fee: params.fee, - recipient: address(this), - tickLower: params.tickLower, - tickUpper: params.tickUpper, - amount0Desired: params.amount0Desired, - amount1Desired: params.amount1Desired, - amount0Min: params.amount0Min, - amount1Min: params.amount1Min - }) - ); - - _mint(params.recipient, (tokenId = _nextId++)); - - bytes32 positionKey = PositionKey.compute(address(this), params.tickLower, params.tickUpper); - (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey); - - // idempotent set - uint80 poolId = cachePoolKey( - address(pool), - PoolAddress.PoolKey({ token0: params.token0, token1: params.token1, fee: params.fee }) - ); - - _positions[tokenId] = Position({ - nonce: 0, - operator: address(0), - poolId: poolId, - tickLower: params.tickLower, - tickUpper: params.tickUpper, - liquidity: liquidity, - feeGrowthInside0LastX128: feeGrowthInside0LastX128, - feeGrowthInside1LastX128: feeGrowthInside1LastX128, - tokensOwed0: 0, - tokensOwed1: 0 - }); - - emit IncreaseLiquidity(tokenId, liquidity, amount0, amount1); - } - - modifier isAuthorizedForToken(uint256 tokenId) { - address owner = _requireOwned(tokenId); - require(_isAuthorized(owner, msg.sender, tokenId), "Not approved"); - _; - } - - function tokenURI(uint256 tokenId) public view override(ERC721, IERC721Metadata) returns (string memory) { - _requireOwned(tokenId); - return INonfungibleTokenPositionDescriptor(_tokenDescriptor).tokenURI(this, tokenId); - } - - // save bytecode by removing implementation of unused method - function baseURI() public pure returns (string memory) {} - - /// @inheritdoc INonfungiblePositionManager - function increaseLiquidity( - IncreaseLiquidityParams calldata params - ) - external - payable - override - checkDeadline(params.deadline) - returns (uint128 liquidity, uint256 amount0, uint256 amount1) - { - Position storage position = _positions[params.tokenId]; - - PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; - - IListaV3Pool pool; - (liquidity, amount0, amount1, pool) = addLiquidity( - AddLiquidityParams({ - token0: poolKey.token0, - token1: poolKey.token1, - fee: poolKey.fee, - tickLower: position.tickLower, - tickUpper: position.tickUpper, - amount0Desired: params.amount0Desired, - amount1Desired: params.amount1Desired, - amount0Min: params.amount0Min, - amount1Min: params.amount1Min, - recipient: address(this) - }) - ); - - bytes32 positionKey = PositionKey.compute(address(this), position.tickLower, position.tickUpper); - - // this is now updated to the current transaction - (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey); - - position.tokensOwed0 += uint128( - FullMath.mulDiv( - feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128, - position.liquidity, - FixedPoint128.Q128 - ) - ); - position.tokensOwed1 += uint128( - FullMath.mulDiv( - feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128, - position.liquidity, - FixedPoint128.Q128 - ) - ); - - position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128; - position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128; - position.liquidity += liquidity; - - emit IncreaseLiquidity(params.tokenId, liquidity, amount0, amount1); - } - - /// @inheritdoc INonfungiblePositionManager - function decreaseLiquidity( - DecreaseLiquidityParams calldata params - ) - external - payable - override - isAuthorizedForToken(params.tokenId) - checkDeadline(params.deadline) - returns (uint256 amount0, uint256 amount1) - { - require(params.liquidity > 0); - Position storage position = _positions[params.tokenId]; - - uint128 positionLiquidity = position.liquidity; - require(positionLiquidity >= params.liquidity); - - PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; - IListaV3Pool pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey, poolInitCodeHash)); - (amount0, amount1) = pool.burn(position.tickLower, position.tickUpper, params.liquidity); - - require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, "Price slippage check"); - - bytes32 positionKey = PositionKey.compute(address(this), position.tickLower, position.tickUpper); - // this is now updated to the current transaction - (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey); - - position.tokensOwed0 += - uint128(amount0) + - uint128( - FullMath.mulDiv( - feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128, - positionLiquidity, - FixedPoint128.Q128 - ) - ); - position.tokensOwed1 += - uint128(amount1) + - uint128( - FullMath.mulDiv( - feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128, - positionLiquidity, - FixedPoint128.Q128 - ) - ); - - position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128; - position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128; - // subtraction is safe because we checked positionLiquidity is gte params.liquidity - position.liquidity = positionLiquidity - params.liquidity; - - emit DecreaseLiquidity(params.tokenId, params.liquidity, amount0, amount1); - } - - /// @inheritdoc INonfungiblePositionManager - function collect( - CollectParams calldata params - ) external payable override isAuthorizedForToken(params.tokenId) returns (uint256 amount0, uint256 amount1) { - require(params.amount0Max > 0 || params.amount1Max > 0); - // allow collecting to the nft position manager address with address 0 - address recipient = params.recipient == address(0) ? address(this) : params.recipient; - - Position storage position = _positions[params.tokenId]; - - PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId]; - - IListaV3Pool pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey, poolInitCodeHash)); - - (uint128 tokensOwed0, uint128 tokensOwed1) = (position.tokensOwed0, position.tokensOwed1); - - // trigger an update of the position fees owed and fee growth snapshots if it has any liquidity - if (position.liquidity > 0) { - pool.burn(position.tickLower, position.tickUpper, 0); - (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions( - PositionKey.compute(address(this), position.tickLower, position.tickUpper) - ); - - tokensOwed0 += uint128( - FullMath.mulDiv( - feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128, - position.liquidity, - FixedPoint128.Q128 - ) - ); - tokensOwed1 += uint128( - FullMath.mulDiv( - feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128, - position.liquidity, - FixedPoint128.Q128 - ) - ); - - position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128; - position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128; - } - - // compute the arguments to give to the pool#collect method - (uint128 amount0Collect, uint128 amount1Collect) = ( - params.amount0Max > tokensOwed0 ? tokensOwed0 : params.amount0Max, - params.amount1Max > tokensOwed1 ? tokensOwed1 : params.amount1Max - ); - - // the actual amounts collected are returned - (amount0, amount1) = pool.collect( - recipient, - position.tickLower, - position.tickUpper, - amount0Collect, - amount1Collect - ); - - // sometimes there will be a few less wei than expected due to rounding down in core, but we just subtract the full amount expected - // instead of the actual amount so we can burn the token - (position.tokensOwed0, position.tokensOwed1) = (tokensOwed0 - amount0Collect, tokensOwed1 - amount1Collect); - - emit Collect(params.tokenId, recipient, amount0Collect, amount1Collect); - } - - /// @inheritdoc INonfungiblePositionManager - function burn(uint256 tokenId) external payable override isAuthorizedForToken(tokenId) { - Position storage position = _positions[tokenId]; - require(position.liquidity == 0 && position.tokensOwed0 == 0 && position.tokensOwed1 == 0, "Not cleared"); - delete _positions[tokenId]; - _burn(tokenId); - } - - function _getAndIncrementNonce(uint256 tokenId) internal override returns (uint256) { - return uint256(_positions[tokenId].nonce++); - } - - /// @inheritdoc IERC721 - function getApproved(uint256 tokenId) public view override(ERC721, IERC721) returns (address) { - _requireOwned(tokenId); - - return _positions[tokenId].operator; - } - - /// @dev Overrides _approve to use the operator in the position, which is packed with the position permit nonce - function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal override { - _positions[tokenId].operator = to; - if (emitEvent) { - emit Approval(ownerOf(tokenId), to, tokenId); - } - } -} diff --git a/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol b/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol deleted file mode 100644 index dcd08fbb..00000000 --- a/src/dex/v3/periphery/NonfungibleTokenPositionDescriptor.sol +++ /dev/null @@ -1,113 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; -pragma abicoder v2; - -import "../core/interfaces/IListaV3Pool.sol"; -import "../core/interfaces/IListaV3Factory.sol"; - -import "./libraries/SafeERC20Namer.sol"; -import "./libraries/ChainId.sol"; -import "./interfaces/INonfungiblePositionManager.sol"; -import "./interfaces/INonfungibleTokenPositionDescriptor.sol"; -import "./interfaces/IERC20Metadata.sol"; -import "./libraries/PoolAddress.sol"; -import "./libraries/NFTDescriptor.sol"; -import "./libraries/TokenRatioSortOrder.sol"; - -/// @title Describes NFT token positions -/// @notice Produces a string containing the data URI for a JSON metadata string -contract NonfungibleTokenPositionDescriptor is INonfungibleTokenPositionDescriptor { - address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; - address private constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address private constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; - address private constant TBTC = 0x8dAEBADE922dF735c38C80C7eBD708Af50815fAa; - address private constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; - - address public immutable WETH9; - /// @dev A null-terminated string - bytes32 public immutable nativeCurrencyLabelBytes; - - constructor(address _WETH9, bytes32 _nativeCurrencyLabelBytes) { - WETH9 = _WETH9; - nativeCurrencyLabelBytes = _nativeCurrencyLabelBytes; - } - - /// @notice Returns the native currency label as a string - function nativeCurrencyLabel() public view returns (string memory) { - uint256 len = 0; - while (len < 32 && nativeCurrencyLabelBytes[len] != 0) { - len++; - } - bytes memory b = new bytes(len); - for (uint256 i = 0; i < len; i++) { - b[i] = nativeCurrencyLabelBytes[i]; - } - return string(b); - } - - /// @inheritdoc INonfungibleTokenPositionDescriptor - function tokenURI( - INonfungiblePositionManager positionManager, - uint256 tokenId - ) external view override returns (string memory) { - (, , address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, , , , , ) = positionManager - .positions(tokenId); - - IListaV3Pool pool = IListaV3Pool(IListaV3Factory(positionManager.factory()).getPool(token0, token1, fee)); - - bool _flipRatio = flipRatio(token0, token1, ChainId.get()); - address quoteTokenAddress = !_flipRatio ? token1 : token0; - address baseTokenAddress = !_flipRatio ? token0 : token1; - (, int24 tick, , , , , ) = pool.slot0(); - - return - NFTDescriptor.constructTokenURI( - NFTDescriptor.ConstructTokenURIParams({ - tokenId: tokenId, - quoteTokenAddress: quoteTokenAddress, - baseTokenAddress: baseTokenAddress, - quoteTokenSymbol: quoteTokenAddress == WETH9 - ? nativeCurrencyLabel() - : SafeERC20Namer.tokenSymbol(quoteTokenAddress), - baseTokenSymbol: baseTokenAddress == WETH9 - ? nativeCurrencyLabel() - : SafeERC20Namer.tokenSymbol(baseTokenAddress), - quoteTokenDecimals: IERC20Metadata(quoteTokenAddress).decimals(), - baseTokenDecimals: IERC20Metadata(baseTokenAddress).decimals(), - flipRatio: _flipRatio, - tickLower: tickLower, - tickUpper: tickUpper, - tickCurrent: tick, - tickSpacing: pool.tickSpacing(), - fee: fee, - poolAddress: address(pool) - }) - ); - } - - function flipRatio(address token0, address token1, uint256 chainId) public view returns (bool) { - return tokenRatioPriority(token0, chainId) > tokenRatioPriority(token1, chainId); - } - - function tokenRatioPriority(address token, uint256 chainId) public view returns (int256) { - if (token == WETH9) { - return TokenRatioSortOrder.DENOMINATOR; - } - if (chainId == 1) { - if (token == USDC) { - return TokenRatioSortOrder.NUMERATOR_MOST; - } else if (token == USDT) { - return TokenRatioSortOrder.NUMERATOR_MORE; - } else if (token == DAI) { - return TokenRatioSortOrder.NUMERATOR; - } else if (token == TBTC) { - return TokenRatioSortOrder.DENOMINATOR_MORE; - } else if (token == WBTC) { - return TokenRatioSortOrder.DENOMINATOR_MOST; - } else { - return 0; - } - } - return 0; - } -} diff --git a/src/dex/v3/periphery/SwapRouter.sol b/src/dex/v3/periphery/SwapRouter.sol deleted file mode 100644 index de564944..00000000 --- a/src/dex/v3/periphery/SwapRouter.sol +++ /dev/null @@ -1,218 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; -pragma abicoder v2; - -import "../core/libraries/SafeCast.sol"; -import "../core/libraries/TickMath.sol"; -import "../core/interfaces/IListaV3Pool.sol"; - -import "./interfaces/ISwapRouter.sol"; -import "./base/PeripheryImmutableState.sol"; -import "./base/PeripheryValidation.sol"; -import "./base/PeripheryPaymentsWithFee.sol"; -import "./base/Multicall.sol"; -import "./base/SelfPermit.sol"; -import "./libraries/Path.sol"; -import "./libraries/PoolAddress.sol"; -import "./libraries/CallbackValidation.sol"; -import "./interfaces/external/IWETH9.sol"; - -/// @title Lista V3 Swap Router -/// @notice Router for stateless execution of swaps against Lista V3 -contract SwapRouter is - ISwapRouter, - PeripheryImmutableState, - PeripheryValidation, - PeripheryPaymentsWithFee, - Multicall, - SelfPermit -{ - using Path for bytes; - using SafeCast for uint256; - - /// @dev Used as the placeholder value for amountInCached, because the computed amount in for an exact output swap - /// can never actually be this value - uint256 private constant DEFAULT_AMOUNT_IN_CACHED = type(uint256).max; - - /// @dev Transient storage variable used for returning the computed amount in for an exact output swap. - uint256 private amountInCached = DEFAULT_AMOUNT_IN_CACHED; - - constructor(address _factory, address _WETH9) PeripheryImmutableState(_factory, _WETH9) {} - - /// @dev Returns the pool for the given token pair and fee. The pool contract may or may not exist. - function getPool(address tokenA, address tokenB, uint24 fee) private view returns (IListaV3Pool) { - return - IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee), poolInitCodeHash)); - } - - struct SwapCallbackData { - bytes path; - address payer; - } - - /// @inheritdoc IListaV3SwapCallback - function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata _data) external override { - require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported - SwapCallbackData memory data = abi.decode(_data, (SwapCallbackData)); - (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool(); - CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee); - - (bool isExactInput, uint256 amountToPay) = amount0Delta > 0 - ? (tokenIn < tokenOut, uint256(amount0Delta)) - : (tokenOut < tokenIn, uint256(amount1Delta)); - if (isExactInput) { - pay(tokenIn, data.payer, msg.sender, amountToPay); - } else { - // either initiate the next swap or pay - if (data.path.hasMultiplePools()) { - data.path = data.path.skipToken(); - exactOutputInternal(amountToPay, msg.sender, 0, data); - } else { - amountInCached = amountToPay; - tokenIn = tokenOut; // swap in/out because exact output swaps are reversed - pay(tokenIn, data.payer, msg.sender, amountToPay); - } - } - } - - /// @dev Performs a single exact input swap - function exactInputInternal( - uint256 amountIn, - address recipient, - uint160 sqrtPriceLimitX96, - SwapCallbackData memory data - ) private returns (uint256 amountOut) { - // allow swapping to the router address with address 0 - if (recipient == address(0)) recipient = address(this); - - (address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool(); - - bool zeroForOne = tokenIn < tokenOut; - - (int256 amount0, int256 amount1) = getPool(tokenIn, tokenOut, fee).swap( - recipient, - zeroForOne, - amountIn.toInt256(), - sqrtPriceLimitX96 == 0 - ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) - : sqrtPriceLimitX96, - abi.encode(data) - ); - - return uint256(-(zeroForOne ? amount1 : amount0)); - } - - /// @inheritdoc ISwapRouter - function exactInputSingle( - ExactInputSingleParams calldata params - ) external payable override checkDeadline(params.deadline) returns (uint256 amountOut) { - amountOut = exactInputInternal( - params.amountIn, - params.recipient, - params.sqrtPriceLimitX96, - SwapCallbackData({ path: abi.encodePacked(params.tokenIn, params.fee, params.tokenOut), payer: msg.sender }) - ); - require(amountOut >= params.amountOutMinimum, "Too little received"); - } - - /// @inheritdoc ISwapRouter - function exactInput( - ExactInputParams memory params - ) external payable override checkDeadline(params.deadline) returns (uint256 amountOut) { - address payer = msg.sender; // msg.sender pays for the first hop - - while (true) { - bool hasMultiplePools = params.path.hasMultiplePools(); - - // the outputs of prior swaps become the inputs to subsequent ones - params.amountIn = exactInputInternal( - params.amountIn, - hasMultiplePools ? address(this) : params.recipient, // for intermediate swaps, this contract custodies - 0, - SwapCallbackData({ - path: params.path.getFirstPool(), // only the first pool in the path is necessary - payer: payer - }) - ); - - // decide whether to continue or terminate - if (hasMultiplePools) { - payer = address(this); // at this point, the caller has paid - params.path = params.path.skipToken(); - } else { - amountOut = params.amountIn; - break; - } - } - - require(amountOut >= params.amountOutMinimum, "Too little received"); - } - - /// @dev Performs a single exact output swap - function exactOutputInternal( - uint256 amountOut, - address recipient, - uint160 sqrtPriceLimitX96, - SwapCallbackData memory data - ) private returns (uint256 amountIn) { - // allow swapping to the router address with address 0 - if (recipient == address(0)) recipient = address(this); - - (address tokenOut, address tokenIn, uint24 fee) = data.path.decodeFirstPool(); - - bool zeroForOne = tokenIn < tokenOut; - - (int256 amount0Delta, int256 amount1Delta) = getPool(tokenIn, tokenOut, fee).swap( - recipient, - zeroForOne, - -amountOut.toInt256(), - sqrtPriceLimitX96 == 0 - ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) - : sqrtPriceLimitX96, - abi.encode(data) - ); - - uint256 amountOutReceived; - (amountIn, amountOutReceived) = zeroForOne - ? (uint256(amount0Delta), uint256(-amount1Delta)) - : (uint256(amount1Delta), uint256(-amount0Delta)); - // it's technically possible to not receive the full output amount, - // so if no price limit has been specified, require this possibility away - if (sqrtPriceLimitX96 == 0) require(amountOutReceived == amountOut); - } - - /// @inheritdoc ISwapRouter - function exactOutputSingle( - ExactOutputSingleParams calldata params - ) external payable override checkDeadline(params.deadline) returns (uint256 amountIn) { - // avoid an SLOAD by using the swap return data - amountIn = exactOutputInternal( - params.amountOut, - params.recipient, - params.sqrtPriceLimitX96, - SwapCallbackData({ path: abi.encodePacked(params.tokenOut, params.fee, params.tokenIn), payer: msg.sender }) - ); - - require(amountIn <= params.amountInMaximum, "Too much requested"); - // has to be reset even though we don't use it in the single hop case - amountInCached = DEFAULT_AMOUNT_IN_CACHED; - } - - /// @inheritdoc ISwapRouter - function exactOutput( - ExactOutputParams calldata params - ) external payable override checkDeadline(params.deadline) returns (uint256 amountIn) { - // it's okay that the payer is fixed to msg.sender here, as they're only paying for the "final" exact output - // swap, which happens first, and subsequent swaps are paid for within nested callback frames - exactOutputInternal( - params.amountOut, - params.recipient, - 0, - SwapCallbackData({ path: params.path, payer: msg.sender }) - ); - - amountIn = amountInCached; - require(amountIn <= params.amountInMaximum, "Too much requested"); - amountInCached = DEFAULT_AMOUNT_IN_CACHED; - } -} diff --git a/src/dex/v3/periphery/base/BlockTimestamp.sol b/src/dex/v3/periphery/base/BlockTimestamp.sol deleted file mode 100644 index 649cfd8a..00000000 --- a/src/dex/v3/periphery/base/BlockTimestamp.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -/// @title Function for getting block timestamp -/// @dev Base contract that is overridden for tests -abstract contract BlockTimestamp { - /// @dev Method that exists purely to be overridden for tests - /// @return The current block timestamp - function _blockTimestamp() internal view virtual returns (uint256) { - return block.timestamp; - } -} diff --git a/src/dex/v3/periphery/base/ERC721Permit.sol b/src/dex/v3/periphery/base/ERC721Permit.sol deleted file mode 100644 index f183f520..00000000 --- a/src/dex/v3/periphery/base/ERC721Permit.sol +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; - -import "../libraries/ChainId.sol"; -import "../interfaces/external/IERC1271.sol"; -import "../interfaces/IERC721Permit.sol"; -import "./BlockTimestamp.sol"; - -/// @title ERC721 with permit -/// @notice Nonfungible tokens that support an approve via signature, i.e. permit -abstract contract ERC721Permit is BlockTimestamp, ERC721Enumerable, IERC721Permit { - /// @dev Gets the current nonce for a token ID and then increments it, returning the original value - function _getAndIncrementNonce(uint256 tokenId) internal virtual returns (uint256); - - /// @dev The hash of the name used in the permit signature verification - bytes32 private immutable nameHash; - - /// @dev The hash of the version string used in the permit signature verification - bytes32 private immutable versionHash; - - /// @notice Computes the nameHash and versionHash - constructor(string memory name_, string memory symbol_, string memory version_) ERC721(name_, symbol_) { - nameHash = keccak256(bytes(name_)); - versionHash = keccak256(bytes(version_)); - } - - /// @inheritdoc IERC721Permit - function DOMAIN_SEPARATOR() public view override returns (bytes32) { - return - keccak256( - abi.encode( - // keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)') - 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, - nameHash, - versionHash, - ChainId.get(), - address(this) - ) - ); - } - - /// @inheritdoc IERC721Permit - /// @dev Value is equal to keccak256("Permit(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)"); - bytes32 public constant override PERMIT_TYPEHASH = 0x49ecf333e5b8c95c40fdafc95c1ad136e8914a8fb55e9dc8bb01eaa83a2df9ad; - - /// @inheritdoc IERC721Permit - function permit( - address spender, - uint256 tokenId, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external payable override { - require(_blockTimestamp() <= deadline, "Permit expired"); - - bytes32 digest = keccak256( - abi.encodePacked( - "\x19\x01", - DOMAIN_SEPARATOR(), - keccak256(abi.encode(PERMIT_TYPEHASH, spender, tokenId, _getAndIncrementNonce(tokenId), deadline)) - ) - ); - address owner = ownerOf(tokenId); - require(spender != owner, "ERC721Permit: approval to current owner"); - - if (owner.code.length > 0) { - require(IERC1271(owner).isValidSignature(digest, abi.encodePacked(r, s, v)) == 0x1626ba7e, "Unauthorized"); - } else { - address recoveredAddress = ecrecover(digest, v, r, s); - require(recoveredAddress != address(0), "Invalid signature"); - require(recoveredAddress == owner, "Unauthorized"); - } - - _approve(spender, tokenId, address(0)); - } -} diff --git a/src/dex/v3/periphery/base/LiquidityManagement.sol b/src/dex/v3/periphery/base/LiquidityManagement.sol deleted file mode 100644 index 5e9f1fd6..00000000 --- a/src/dex/v3/periphery/base/LiquidityManagement.sol +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; -pragma abicoder v2; - -import "../../core/interfaces/IListaV3Factory.sol"; -import "../../core/interfaces/callback/IListaV3MintCallback.sol"; -import "../../core/libraries/TickMath.sol"; - -import "../libraries/PoolAddress.sol"; -import "../libraries/CallbackValidation.sol"; -import "../libraries/LiquidityAmounts.sol"; - -import "./PeripheryPayments.sol"; -import "./PeripheryImmutableState.sol"; - -/// @title Liquidity management functions -/// @notice Internal functions for safely managing liquidity in Lista V3 -abstract contract LiquidityManagement is IListaV3MintCallback, PeripheryImmutableState, PeripheryPayments { - struct MintCallbackData { - PoolAddress.PoolKey poolKey; - address payer; - } - - /// @inheritdoc IListaV3MintCallback - function listaV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata data) external override { - MintCallbackData memory decoded = abi.decode(data, (MintCallbackData)); - CallbackValidation.verifyCallback(factory, decoded.poolKey); - - if (amount0Owed > 0) pay(decoded.poolKey.token0, decoded.payer, msg.sender, amount0Owed); - if (amount1Owed > 0) pay(decoded.poolKey.token1, decoded.payer, msg.sender, amount1Owed); - } - - struct AddLiquidityParams { - address token0; - address token1; - uint24 fee; - address recipient; - int24 tickLower; - int24 tickUpper; - uint256 amount0Desired; - uint256 amount1Desired; - uint256 amount0Min; - uint256 amount1Min; - } - - /// @notice Add liquidity to an initialized pool - function addLiquidity( - AddLiquidityParams memory params - ) internal returns (uint128 liquidity, uint256 amount0, uint256 amount1, IListaV3Pool pool) { - PoolAddress.PoolKey memory poolKey = PoolAddress.PoolKey({ - token0: params.token0, - token1: params.token1, - fee: params.fee - }); - - pool = IListaV3Pool(PoolAddress.computeAddress(factory, poolKey, poolInitCodeHash)); - - // compute the liquidity amount - { - (uint160 sqrtPriceX96, , , , , , ) = pool.slot0(); - uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(params.tickLower); - uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(params.tickUpper); - - liquidity = LiquidityAmounts.getLiquidityForAmounts( - sqrtPriceX96, - sqrtRatioAX96, - sqrtRatioBX96, - params.amount0Desired, - params.amount1Desired - ); - } - - (amount0, amount1) = pool.mint( - params.recipient, - params.tickLower, - params.tickUpper, - liquidity, - abi.encode(MintCallbackData({ poolKey: poolKey, payer: msg.sender })) - ); - - require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, "Price slippage check"); - } -} diff --git a/src/dex/v3/periphery/base/Multicall.sol b/src/dex/v3/periphery/base/Multicall.sol deleted file mode 100644 index 7e4e7f85..00000000 --- a/src/dex/v3/periphery/base/Multicall.sol +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; -pragma abicoder v2; - -import "../interfaces/IMulticall.sol"; - -/// @title Multicall -/// @notice Enables calling multiple methods in a single call to the contract -abstract contract Multicall is IMulticall { - /// @inheritdoc IMulticall - function multicall(bytes[] calldata data) public payable override returns (bytes[] memory results) { - results = new bytes[](data.length); - for (uint256 i = 0; i < data.length; i++) { - (bool success, bytes memory result) = address(this).delegatecall(data[i]); - - if (!success) { - // Next 5 lines from https://ethereum.stackexchange.com/a/83577 - if (result.length < 68) revert(); - assembly { - result := add(result, 0x04) - } - revert(abi.decode(result, (string))); - } - - results[i] = result; - } - } -} diff --git a/src/dex/v3/periphery/base/PeripheryImmutableState.sol b/src/dex/v3/periphery/base/PeripheryImmutableState.sol deleted file mode 100644 index d433ebad..00000000 --- a/src/dex/v3/periphery/base/PeripheryImmutableState.sol +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import "../interfaces/IPeripheryImmutableState.sol"; - -/// @title Immutable state -/// @notice State used by periphery contracts — stored as regular storage for UUPS compatibility. -abstract contract PeripheryImmutableState is IPeripheryImmutableState { - /// @inheritdoc IPeripheryImmutableState - address public override factory; - /// @inheritdoc IPeripheryImmutableState - address public override WETH9; - - /// @dev The keccak256 of the pool proxy creation code, used to compute pool addresses. - bytes32 public poolInitCodeHash; - - constructor(address _factory, address _WETH9) { - factory = _factory; - WETH9 = _WETH9; - } -} diff --git a/src/dex/v3/periphery/base/PeripheryPayments.sol b/src/dex/v3/periphery/base/PeripheryPayments.sol deleted file mode 100644 index 06c74c5f..00000000 --- a/src/dex/v3/periphery/base/PeripheryPayments.sol +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -import "../interfaces/IPeripheryPayments.sol"; -import "../interfaces/external/IWETH9.sol"; - -import "../libraries/TransferHelper.sol"; - -import "./PeripheryImmutableState.sol"; - -abstract contract PeripheryPayments is IPeripheryPayments, PeripheryImmutableState { - receive() external payable { - require(msg.sender == WETH9, "Not WETH9"); - } - - /// @inheritdoc IPeripheryPayments - function unwrapWETH9(uint256 amountMinimum, address recipient) public payable override { - uint256 balanceWETH9 = IWETH9(WETH9).balanceOf(address(this)); - require(balanceWETH9 >= amountMinimum, "Insufficient WETH9"); - - if (balanceWETH9 > 0) { - IWETH9(WETH9).withdraw(balanceWETH9); - TransferHelper.safeTransferETH(recipient, balanceWETH9); - } - } - - /// @inheritdoc IPeripheryPayments - function sweepToken(address token, uint256 amountMinimum, address recipient) public payable override { - uint256 balanceToken = IERC20(token).balanceOf(address(this)); - require(balanceToken >= amountMinimum, "Insufficient token"); - - if (balanceToken > 0) { - TransferHelper.safeTransfer(token, recipient, balanceToken); - } - } - - /// @inheritdoc IPeripheryPayments - function refundETH() external payable override { - if (address(this).balance > 0) TransferHelper.safeTransferETH(msg.sender, address(this).balance); - } - - /// @param token The token to pay - /// @param payer The entity that must pay - /// @param recipient The entity that will receive payment - /// @param value The amount to pay - function pay(address token, address payer, address recipient, uint256 value) internal { - if (token == WETH9 && address(this).balance >= value) { - // pay with WETH9 - IWETH9(WETH9).deposit{ value: value }(); // wrap only what is needed to pay - IWETH9(WETH9).transfer(recipient, value); - } else if (payer == address(this)) { - // pay with tokens already in the contract (for the exact input multihop case) - TransferHelper.safeTransfer(token, recipient, value); - } else { - // pull payment - TransferHelper.safeTransferFrom(token, payer, recipient, value); - } - } -} diff --git a/src/dex/v3/periphery/base/PeripheryPaymentsWithFee.sol b/src/dex/v3/periphery/base/PeripheryPaymentsWithFee.sol deleted file mode 100644 index 0ca766ac..00000000 --- a/src/dex/v3/periphery/base/PeripheryPaymentsWithFee.sol +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -import "./PeripheryPayments.sol"; -import "../interfaces/IPeripheryPaymentsWithFee.sol"; - -import "../interfaces/external/IWETH9.sol"; -import "../libraries/TransferHelper.sol"; - -abstract contract PeripheryPaymentsWithFee is PeripheryPayments, IPeripheryPaymentsWithFee { - /// @inheritdoc IPeripheryPaymentsWithFee - function unwrapWETH9WithFee( - uint256 amountMinimum, - address recipient, - uint256 feeBips, - address feeRecipient - ) public payable override { - require(feeBips > 0 && feeBips <= 100); - - uint256 balanceWETH9 = IWETH9(WETH9).balanceOf(address(this)); - require(balanceWETH9 >= amountMinimum, "Insufficient WETH9"); - - if (balanceWETH9 > 0) { - IWETH9(WETH9).withdraw(balanceWETH9); - uint256 feeAmount = (balanceWETH9 * feeBips) / 10_000; - if (feeAmount > 0) TransferHelper.safeTransferETH(feeRecipient, feeAmount); - TransferHelper.safeTransferETH(recipient, balanceWETH9 - feeAmount); - } - } - - /// @inheritdoc IPeripheryPaymentsWithFee - function sweepTokenWithFee( - address token, - uint256 amountMinimum, - address recipient, - uint256 feeBips, - address feeRecipient - ) public payable override { - require(feeBips > 0 && feeBips <= 100); - - uint256 balanceToken = IERC20(token).balanceOf(address(this)); - require(balanceToken >= amountMinimum, "Insufficient token"); - - if (balanceToken > 0) { - uint256 feeAmount = (balanceToken * feeBips) / 10_000; - if (feeAmount > 0) TransferHelper.safeTransfer(token, feeRecipient, feeAmount); - TransferHelper.safeTransfer(token, recipient, balanceToken - feeAmount); - } - } -} diff --git a/src/dex/v3/periphery/base/PeripheryValidation.sol b/src/dex/v3/periphery/base/PeripheryValidation.sol deleted file mode 100644 index 8b7f804c..00000000 --- a/src/dex/v3/periphery/base/PeripheryValidation.sol +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import "./BlockTimestamp.sol"; - -abstract contract PeripheryValidation is BlockTimestamp { - modifier checkDeadline(uint256 deadline) { - require(_blockTimestamp() <= deadline, "Transaction too old"); - _; - } -} diff --git a/src/dex/v3/periphery/base/PoolInitializer.sol b/src/dex/v3/periphery/base/PoolInitializer.sol deleted file mode 100644 index 286e9111..00000000 --- a/src/dex/v3/periphery/base/PoolInitializer.sol +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import "../../core/interfaces/IListaV3Factory.sol"; -import "../../core/interfaces/IListaV3Pool.sol"; - -import "./PeripheryImmutableState.sol"; -import "../interfaces/IPoolInitializer.sol"; - -/// @title Creates and initializes V3 Pools -abstract contract PoolInitializer is IPoolInitializer, PeripheryImmutableState { - /// @inheritdoc IPoolInitializer - function createAndInitializePoolIfNecessary( - address token0, - address token1, - uint24 fee, - uint160 sqrtPriceX96 - ) external payable override returns (address pool) { - require(token0 < token1); - pool = IListaV3Factory(factory).getPool(token0, token1, fee); - - if (pool == address(0)) { - pool = IListaV3Factory(factory).createPool(token0, token1, fee); - IListaV3Pool(pool).initialize(sqrtPriceX96); - } else { - (uint160 sqrtPriceX96Existing, , , , , , ) = IListaV3Pool(pool).slot0(); - if (sqrtPriceX96Existing == 0) { - IListaV3Pool(pool).initialize(sqrtPriceX96); - } - } - } -} diff --git a/src/dex/v3/periphery/base/SelfPermit.sol b/src/dex/v3/periphery/base/SelfPermit.sol deleted file mode 100644 index 66a6f97d..00000000 --- a/src/dex/v3/periphery/base/SelfPermit.sol +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; - -import "../interfaces/ISelfPermit.sol"; -import "../interfaces/external/IERC20PermitAllowed.sol"; - -/// @title Self Permit -/// @notice Functionality to call permit on any EIP-2612-compliant token for use in the route -/// @dev These functions are expected to be embedded in multicalls to allow EOAs to approve a contract and call a function -/// that requires an approval in a single transaction. -abstract contract SelfPermit is ISelfPermit { - /// @inheritdoc ISelfPermit - function selfPermit( - address token, - uint256 value, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) public payable override { - IERC20Permit(token).permit(msg.sender, address(this), value, deadline, v, r, s); - } - - /// @inheritdoc ISelfPermit - function selfPermitIfNecessary( - address token, - uint256 value, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external payable override { - if (IERC20(token).allowance(msg.sender, address(this)) < value) selfPermit(token, value, deadline, v, r, s); - } - - /// @inheritdoc ISelfPermit - function selfPermitAllowed( - address token, - uint256 nonce, - uint256 expiry, - uint8 v, - bytes32 r, - bytes32 s - ) public payable override { - IERC20PermitAllowed(token).permit(msg.sender, address(this), nonce, expiry, true, v, r, s); - } - - /// @inheritdoc ISelfPermit - function selfPermitAllowedIfNecessary( - address token, - uint256 nonce, - uint256 expiry, - uint8 v, - bytes32 r, - bytes32 s - ) external payable override { - if (IERC20(token).allowance(msg.sender, address(this)) < type(uint256).max) - selfPermitAllowed(token, nonce, expiry, v, r, s); - } -} diff --git a/src/dex/v3/periphery/interfaces/IERC20Metadata.sol b/src/dex/v3/periphery/interfaces/IERC20Metadata.sol deleted file mode 100644 index f1798a41..00000000 --- a/src/dex/v3/periphery/interfaces/IERC20Metadata.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -/// @title IERC20Metadata -/// @title Interface for ERC20 Metadata -/// @notice Extension to IERC20 that includes token metadata -interface IERC20Metadata is IERC20 { - /// @return The name of the token - function name() external view returns (string memory); - - /// @return The symbol of the token - function symbol() external view returns (string memory); - - /// @return The number of decimal places the token has - function decimals() external view returns (uint8); -} diff --git a/src/dex/v3/periphery/interfaces/IERC721Permit.sol b/src/dex/v3/periphery/interfaces/IERC721Permit.sol deleted file mode 100644 index 9cc0d5e0..00000000 --- a/src/dex/v3/periphery/interfaces/IERC721Permit.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; - -import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; - -/// @title ERC721 with permit -/// @notice Extension to ERC721 that includes a permit function for signature based approvals -interface IERC721Permit is IERC721 { - /// @notice The permit typehash used in the permit signature - /// @return The typehash for the permit - function PERMIT_TYPEHASH() external pure returns (bytes32); - - /// @notice The domain separator used in the permit signature - /// @return The domain seperator used in encoding of permit signature - function DOMAIN_SEPARATOR() external view returns (bytes32); - - /// @notice Approve of a specific token ID for spending by spender via signature - /// @param spender The account that is being approved - /// @param tokenId The ID of the token that is being approved for spending - /// @param deadline The deadline timestamp by which the call must be mined for the approve to work - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function permit(address spender, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external payable; -} diff --git a/src/dex/v3/periphery/interfaces/IMulticall.sol b/src/dex/v3/periphery/interfaces/IMulticall.sol deleted file mode 100644 index 32ee7ba8..00000000 --- a/src/dex/v3/periphery/interfaces/IMulticall.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -/// @title Multicall interface -/// @notice Enables calling multiple methods in a single call to the contract -interface IMulticall { - /// @notice Call multiple functions in the current contract and return the data from all of them if they all succeed - /// @dev The `msg.value` should not be trusted for any method callable from multicall. - /// @param data The encoded function data for each of the calls to make to this contract - /// @return results The results from each of the calls passed in via data - function multicall(bytes[] calldata data) external payable returns (bytes[] memory results); -} diff --git a/src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol b/src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol deleted file mode 100644 index 5ebd4617..00000000 --- a/src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -import "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; -import "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol"; - -import "./IPoolInitializer.sol"; -import "./IERC721Permit.sol"; -import "./IPeripheryPayments.sol"; -import "./IPeripheryImmutableState.sol"; -import "../libraries/PoolAddress.sol"; - -/// @title Non-fungible token for positions -/// @notice Wraps Lista V3 positions in a non-fungible token interface which allows for them to be transferred -/// and authorized. -interface INonfungiblePositionManager is - IPoolInitializer, - IPeripheryPayments, - IPeripheryImmutableState, - IERC721Metadata, - IERC721Enumerable, - IERC721Permit -{ - /// @notice Emitted when liquidity is increased for a position NFT - /// @dev Also emitted when a token is minted - /// @param tokenId The ID of the token for which liquidity was increased - /// @param liquidity The amount by which liquidity for the NFT position was increased - /// @param amount0 The amount of token0 that was paid for the increase in liquidity - /// @param amount1 The amount of token1 that was paid for the increase in liquidity - event IncreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); - /// @notice Emitted when liquidity is decreased for a position NFT - /// @param tokenId The ID of the token for which liquidity was decreased - /// @param liquidity The amount by which liquidity for the NFT position was decreased - /// @param amount0 The amount of token0 that was accounted for the decrease in liquidity - /// @param amount1 The amount of token1 that was accounted for the decrease in liquidity - event DecreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); - /// @notice Emitted when tokens are collected for a position NFT - /// @dev The amounts reported may not be exactly equivalent to the amounts transferred, due to rounding behavior - /// @param tokenId The ID of the token for which underlying tokens were collected - /// @param recipient The address of the account that received the collected tokens - /// @param amount0 The amount of token0 owed to the position that was collected - /// @param amount1 The amount of token1 owed to the position that was collected - event Collect(uint256 indexed tokenId, address recipient, uint256 amount0, uint256 amount1); - - /// @notice Returns the position information associated with a given token ID. - /// @dev Throws if the token ID is not valid. - /// @param tokenId The ID of the token that represents the position - /// @return nonce The nonce for permits - /// @return operator The address that is approved for spending - /// @return token0 The address of the token0 for a specific pool - /// @return token1 The address of the token1 for a specific pool - /// @return fee The fee associated with the pool - /// @return tickLower The lower end of the tick range for the position - /// @return tickUpper The higher end of the tick range for the position - /// @return liquidity The liquidity of the position - /// @return feeGrowthInside0LastX128 The fee growth of token0 as of the last action on the individual position - /// @return feeGrowthInside1LastX128 The fee growth of token1 as of the last action on the individual position - /// @return tokensOwed0 The uncollected amount of token0 owed to the position as of the last computation - /// @return tokensOwed1 The uncollected amount of token1 owed to the position as of the last computation - 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 - /// @dev Call this when the pool does exist and is initialized. Note that if the pool is created but not initialized - /// a method does not exist, i.e. the pool is assumed to be initialized. - /// @param params The params necessary to mint a position, encoded as `MintParams` in calldata - /// @return tokenId The ID of the token that represents the minted position - /// @return liquidity The amount of liquidity for this position - /// @return amount0 The amount of token0 - /// @return amount1 The amount of token1 - 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 the `msg.sender` - /// @param params tokenId The ID of the token for which liquidity is being increased, - /// amount0Desired The desired amount of token0 to be spent, - /// amount1Desired The desired amount of token1 to be spent, - /// amount0Min The minimum amount of token0 to spend, which serves as a slippage check, - /// amount1Min The minimum amount of token1 to spend, which serves as a slippage check, - /// deadline The time by which the transaction must be included to effect the change - /// @return liquidity The new liquidity amount as a result of the increase - /// @return amount0 The amount of token0 to acheive resulting liquidity - /// @return amount1 The amount of token1 to acheive resulting liquidity - 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 - /// @param params tokenId The ID of the token for which liquidity is being decreased, - /// amount The amount by which liquidity will be decreased, - /// amount0Min The minimum amount of token0 that should be accounted for the burned liquidity, - /// amount1Min The minimum amount of token1 that should be accounted for the burned liquidity, - /// deadline The time by which the transaction must be included to effect the change - /// @return amount0 The amount of token0 accounted to the position's tokens owed - /// @return amount1 The amount of token1 accounted to the position's tokens owed - 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 - /// @param params tokenId The ID of the NFT for which tokens are being collected, - /// recipient The account that should receive the tokens, - /// amount0Max The maximum amount of token0 to collect, - /// amount1Max The maximum amount of token1 to collect - /// @return amount0 The amount of fees collected in token0 - /// @return amount1 The amount of fees collected in token1 - 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. - /// @param tokenId The ID of the token that is being burned - function burn(uint256 tokenId) external payable; -} diff --git a/src/dex/v3/periphery/interfaces/INonfungibleTokenPositionDescriptor.sol b/src/dex/v3/periphery/interfaces/INonfungibleTokenPositionDescriptor.sol deleted file mode 100644 index 1abcaeb8..00000000 --- a/src/dex/v3/periphery/interfaces/INonfungibleTokenPositionDescriptor.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -import "./INonfungiblePositionManager.sol"; - -/// @title Describes position NFT tokens via URI -interface INonfungibleTokenPositionDescriptor { - /// @notice Produces the URI describing a particular token ID for a position manager - /// @dev Note this URI may be a data: URI with the JSON contents directly inlined - /// @param positionManager The position manager for which to describe the token - /// @param tokenId The ID of the token for which to produce a description, which may not be valid - /// @return The URI of the ERC721-compliant metadata - function tokenURI(INonfungiblePositionManager positionManager, uint256 tokenId) external view returns (string memory); -} diff --git a/src/dex/v3/periphery/interfaces/IPeripheryImmutableState.sol b/src/dex/v3/periphery/interfaces/IPeripheryImmutableState.sol deleted file mode 100644 index 94acc028..00000000 --- a/src/dex/v3/periphery/interfaces/IPeripheryImmutableState.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Immutable state -/// @notice Functions that return immutable state of the router -interface IPeripheryImmutableState { - /// @return Returns the address of the Lista V3 factory - function factory() external view returns (address); - - /// @return Returns the address of WETH9 - function WETH9() external view returns (address); -} diff --git a/src/dex/v3/periphery/interfaces/IPeripheryPayments.sol b/src/dex/v3/periphery/interfaces/IPeripheryPayments.sol deleted file mode 100644 index fc424e52..00000000 --- a/src/dex/v3/periphery/interfaces/IPeripheryPayments.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; - -/// @title Periphery Payments -/// @notice Functions to ease deposits and withdrawals of ETH -interface IPeripheryPayments { - /// @notice Unwraps the contract's WETH9 balance and sends it to recipient as ETH. - /// @dev The amountMinimum parameter prevents malicious contracts from stealing WETH9 from users. - /// @param amountMinimum The minimum amount of WETH9 to unwrap - /// @param recipient The address receiving ETH - function unwrapWETH9(uint256 amountMinimum, address recipient) external payable; - - /// @notice Refunds any ETH balance held by this contract to the `msg.sender` - /// @dev Useful for bundling with mint or increase liquidity that uses ether, or exact output swaps - /// that use ether for the input amount - function refundETH() external payable; - - /// @notice Transfers the full amount of a token held by this contract to recipient - /// @dev The amountMinimum parameter prevents malicious contracts from stealing the token from users - /// @param token The contract address of the token which will be transferred to `recipient` - /// @param amountMinimum The minimum amount of token required for a transfer - /// @param recipient The destination address of the token - function sweepToken(address token, uint256 amountMinimum, address recipient) external payable; -} diff --git a/src/dex/v3/periphery/interfaces/IPeripheryPaymentsWithFee.sol b/src/dex/v3/periphery/interfaces/IPeripheryPaymentsWithFee.sol deleted file mode 100644 index 85a70d8e..00000000 --- a/src/dex/v3/periphery/interfaces/IPeripheryPaymentsWithFee.sol +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; - -import "./IPeripheryPayments.sol"; - -/// @title Periphery Payments -/// @notice Functions to ease deposits and withdrawals of ETH -interface IPeripheryPaymentsWithFee is IPeripheryPayments { - /// @notice Unwraps the contract's WETH9 balance and sends it to recipient as ETH, with a percentage between - /// 0 (exclusive), and 1 (inclusive) going to feeRecipient - /// @dev The amountMinimum parameter prevents malicious contracts from stealing WETH9 from users. - function unwrapWETH9WithFee( - uint256 amountMinimum, - address recipient, - uint256 feeBips, - address feeRecipient - ) external payable; - - /// @notice Transfers the full amount of a token held by this contract to recipient, with a percentage between - /// 0 (exclusive) and 1 (inclusive) going to feeRecipient - /// @dev The amountMinimum parameter prevents malicious contracts from stealing the token from users - function sweepTokenWithFee( - address token, - uint256 amountMinimum, - address recipient, - uint256 feeBips, - address feeRecipient - ) external payable; -} diff --git a/src/dex/v3/periphery/interfaces/IPoolInitializer.sol b/src/dex/v3/periphery/interfaces/IPoolInitializer.sol deleted file mode 100644 index ba0ebace..00000000 --- a/src/dex/v3/periphery/interfaces/IPoolInitializer.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -/// @title Creates and initializes V3 Pools -/// @notice Provides a method for creating and initializing a pool, if necessary, for bundling with other methods that -/// require the pool to exist. -interface IPoolInitializer { - /// @notice Creates a new pool if it does not exist, then initializes if not initialized - /// @dev This method can be bundled with others via IMulticall for the first action (e.g. mint) performed against a pool - /// @param token0 The contract address of token0 of the pool - /// @param token1 The contract address of token1 of the pool - /// @param fee The fee amount of the v3 pool for the specified token pair - /// @param sqrtPriceX96 The initial square root price of the pool as a Q64.96 value - /// @return pool Returns the pool address based on the pair of tokens and fee, will return the newly created pool address if necessary - function createAndInitializePoolIfNecessary( - address token0, - address token1, - uint24 fee, - uint160 sqrtPriceX96 - ) external payable returns (address pool); -} diff --git a/src/dex/v3/periphery/interfaces/IQuoter.sol b/src/dex/v3/periphery/interfaces/IQuoter.sol deleted file mode 100644 index 48b78e70..00000000 --- a/src/dex/v3/periphery/interfaces/IQuoter.sol +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -/// @title Quoter Interface -/// @notice Supports quoting the calculated amounts from exact input or exact output swaps -/// @dev These functions are not marked view because they rely on calling non-view functions and reverting -/// to compute the result. They are also not gas efficient and should not be called on-chain. -interface IQuoter { - /// @notice Returns the amount out received for a given exact input swap without executing the swap - /// @param path The path of the swap, i.e. each token pair and the pool fee - /// @param amountIn The amount of the first token to swap - /// @return amountOut The amount of the last token that would be received - function quoteExactInput(bytes memory path, uint256 amountIn) external returns (uint256 amountOut); - - /// @notice Returns the amount out received for a given exact input but for a swap of a single pool - /// @param tokenIn The token being swapped in - /// @param tokenOut The token being swapped out - /// @param fee The fee of the token pool to consider for the pair - /// @param amountIn The desired input amount - /// @param sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap - /// @return amountOut The amount of `tokenOut` that would be received - function quoteExactInputSingle( - address tokenIn, - address tokenOut, - uint24 fee, - uint256 amountIn, - uint160 sqrtPriceLimitX96 - ) external returns (uint256 amountOut); - - /// @notice Returns the amount in required for a given exact output swap without executing the swap - /// @param path The path of the swap, i.e. each token pair and the pool fee. Path must be provided in reverse order - /// @param amountOut The amount of the last token to receive - /// @return amountIn The amount of first token required to be paid - function quoteExactOutput(bytes memory path, uint256 amountOut) external returns (uint256 amountIn); - - /// @notice Returns the amount in required to receive the given exact output amount but for a swap of a single pool - /// @param tokenIn The token being swapped in - /// @param tokenOut The token being swapped out - /// @param fee The fee of the token pool to consider for the pair - /// @param amountOut The desired output amount - /// @param sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap - /// @return amountIn The amount required as the input for the swap in order to receive `amountOut` - function quoteExactOutputSingle( - address tokenIn, - address tokenOut, - uint24 fee, - uint256 amountOut, - uint160 sqrtPriceLimitX96 - ) external returns (uint256 amountIn); -} diff --git a/src/dex/v3/periphery/interfaces/IQuoterV2.sol b/src/dex/v3/periphery/interfaces/IQuoterV2.sol deleted file mode 100644 index b3e7ed7c..00000000 --- a/src/dex/v3/periphery/interfaces/IQuoterV2.sol +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -/// @title QuoterV2 Interface -/// @notice Supports quoting the calculated amounts from exact input or exact output swaps. -/// @notice For each pool also tells you the number of initialized ticks crossed and the sqrt price of the pool after the swap. -/// @dev These functions are not marked view because they rely on calling non-view functions and reverting -/// to compute the result. They are also not gas efficient and should not be called on-chain. -interface IQuoterV2 { - /// @notice Returns the amount out received for a given exact input swap without executing the swap - /// @param path The path of the swap, i.e. each token pair and the pool fee - /// @param amountIn The amount of the first token to swap - /// @return amountOut The amount of the last token that would be received - /// @return sqrtPriceX96AfterList List of the sqrt price after the swap for each pool in the path - /// @return initializedTicksCrossedList List of the initialized ticks that the swap crossed for each pool in the path - /// @return gasEstimate The estimate of the gas that the swap consumes - function quoteExactInput( - bytes memory path, - uint256 amountIn - ) - external - returns ( - uint256 amountOut, - uint160[] memory sqrtPriceX96AfterList, - uint32[] memory initializedTicksCrossedList, - uint256 gasEstimate - ); - - struct QuoteExactInputSingleParams { - address tokenIn; - address tokenOut; - uint256 amountIn; - uint24 fee; - uint160 sqrtPriceLimitX96; - } - - /// @notice Returns the amount out received for a given exact input but for a swap of a single pool - /// @param params The params for the quote, encoded as `QuoteExactInputSingleParams` - /// tokenIn The token being swapped in - /// tokenOut The token being swapped out - /// fee The fee of the token pool to consider for the pair - /// amountIn The desired input amount - /// sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap - /// @return amountOut The amount of `tokenOut` that would be received - /// @return sqrtPriceX96After The sqrt price of the pool after the swap - /// @return initializedTicksCrossed The number of initialized ticks that the swap crossed - /// @return gasEstimate The estimate of the gas that the swap consumes - function quoteExactInputSingle( - QuoteExactInputSingleParams memory params - ) - external - returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate); - - /// @notice Returns the amount in required for a given exact output swap without executing the swap - /// @param path The path of the swap, i.e. each token pair and the pool fee. Path must be provided in reverse order - /// @param amountOut The amount of the last token to receive - /// @return amountIn The amount of first token required to be paid - /// @return sqrtPriceX96AfterList List of the sqrt price after the swap for each pool in the path - /// @return initializedTicksCrossedList List of the initialized ticks that the swap crossed for each pool in the path - /// @return gasEstimate The estimate of the gas that the swap consumes - function quoteExactOutput( - bytes memory path, - uint256 amountOut - ) - external - returns ( - uint256 amountIn, - uint160[] memory sqrtPriceX96AfterList, - uint32[] memory initializedTicksCrossedList, - uint256 gasEstimate - ); - - struct QuoteExactOutputSingleParams { - address tokenIn; - address tokenOut; - uint256 amount; - uint24 fee; - uint160 sqrtPriceLimitX96; - } - - /// @notice Returns the amount in required to receive the given exact output amount but for a swap of a single pool - /// @param params The params for the quote, encoded as `QuoteExactOutputSingleParams` - /// tokenIn The token being swapped in - /// tokenOut The token being swapped out - /// fee The fee of the token pool to consider for the pair - /// amountOut The desired output amount - /// sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap - /// @return amountIn The amount required as the input for the swap in order to receive `amountOut` - /// @return sqrtPriceX96After The sqrt price of the pool after the swap - /// @return initializedTicksCrossed The number of initialized ticks that the swap crossed - /// @return gasEstimate The estimate of the gas that the swap consumes - function quoteExactOutputSingle( - QuoteExactOutputSingleParams memory params - ) external returns (uint256 amountIn, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate); -} diff --git a/src/dex/v3/periphery/interfaces/ISelfPermit.sol b/src/dex/v3/periphery/interfaces/ISelfPermit.sol deleted file mode 100644 index f7eee60b..00000000 --- a/src/dex/v3/periphery/interfaces/ISelfPermit.sol +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; - -/// @title Self Permit -/// @notice Functionality to call permit on any EIP-2612-compliant token for use in the route -interface ISelfPermit { - /// @notice Permits this contract to spend a given token from `msg.sender` - /// @dev The `owner` is always msg.sender and the `spender` is always address(this). - /// @param token The address of the token spent - /// @param value The amount that can be spent of token - /// @param deadline A timestamp, the current blocktime must be less than or equal to this timestamp - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function selfPermit(address token, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external payable; - - /// @notice Permits this contract to spend a given token from `msg.sender` - /// @dev The `owner` is always msg.sender and the `spender` is always address(this). - /// Can be used instead of #selfPermit to prevent calls from failing due to a frontrun of a call to #selfPermit - /// @param token The address of the token spent - /// @param value The amount that can be spent of token - /// @param deadline A timestamp, the current blocktime must be less than or equal to this timestamp - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function selfPermitIfNecessary( - address token, - uint256 value, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) external payable; - - /// @notice Permits this contract to spend the sender's tokens for permit signatures that have the `allowed` parameter - /// @dev The `owner` is always msg.sender and the `spender` is always address(this) - /// @param token The address of the token spent - /// @param nonce The current nonce of the owner - /// @param expiry The timestamp at which the permit is no longer valid - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function selfPermitAllowed( - address token, - uint256 nonce, - uint256 expiry, - uint8 v, - bytes32 r, - bytes32 s - ) external payable; - - /// @notice Permits this contract to spend the sender's tokens for permit signatures that have the `allowed` parameter - /// @dev The `owner` is always msg.sender and the `spender` is always address(this) - /// Can be used instead of #selfPermitAllowed to prevent calls from failing due to a frontrun of a call to #selfPermitAllowed. - /// @param token The address of the token spent - /// @param nonce The current nonce of the owner - /// @param expiry The timestamp at which the permit is no longer valid - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function selfPermitAllowedIfNecessary( - address token, - uint256 nonce, - uint256 expiry, - uint8 v, - bytes32 r, - bytes32 s - ) external payable; -} diff --git a/src/dex/v3/periphery/interfaces/ISwapRouter.sol b/src/dex/v3/periphery/interfaces/ISwapRouter.sol deleted file mode 100644 index 924fb95d..00000000 --- a/src/dex/v3/periphery/interfaces/ISwapRouter.sol +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -import "../../core/interfaces/callback/IListaV3SwapCallback.sol"; - -/// @title Router token swapping functionality -/// @notice Functions for swapping tokens via Lista V3 -interface ISwapRouter is IListaV3SwapCallback { - struct ExactInputSingleParams { - address tokenIn; - address tokenOut; - uint24 fee; - address recipient; - uint256 deadline; - uint256 amountIn; - uint256 amountOutMinimum; - uint160 sqrtPriceLimitX96; - } - - /// @notice Swaps `amountIn` of one token for as much as possible of another token - /// @param params The parameters necessary for the swap, encoded as `ExactInputSingleParams` in calldata - /// @return amountOut The amount of the received token - function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); - - struct ExactInputParams { - bytes path; - address recipient; - uint256 deadline; - uint256 amountIn; - uint256 amountOutMinimum; - } - - /// @notice Swaps `amountIn` of one token for as much as possible of another along the specified path - /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactInputParams` in calldata - /// @return amountOut The amount of the received token - function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); - - struct ExactOutputSingleParams { - address tokenIn; - address tokenOut; - uint24 fee; - address recipient; - uint256 deadline; - uint256 amountOut; - uint256 amountInMaximum; - uint160 sqrtPriceLimitX96; - } - - /// @notice Swaps as little as possible of one token for `amountOut` of another token - /// @param params The parameters necessary for the swap, encoded as `ExactOutputSingleParams` in calldata - /// @return amountIn The amount of the input token - function exactOutputSingle(ExactOutputSingleParams calldata params) external payable returns (uint256 amountIn); - - struct ExactOutputParams { - bytes path; - address recipient; - uint256 deadline; - uint256 amountOut; - uint256 amountInMaximum; - } - - /// @notice Swaps as little as possible of one token for `amountOut` of another along the specified path (reversed) - /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactOutputParams` in calldata - /// @return amountIn The amount of the input token - function exactOutput(ExactOutputParams calldata params) external payable returns (uint256 amountIn); -} diff --git a/src/dex/v3/periphery/interfaces/ITickLens.sol b/src/dex/v3/periphery/interfaces/ITickLens.sol deleted file mode 100644 index 71eda991..00000000 --- a/src/dex/v3/periphery/interfaces/ITickLens.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.5; -pragma abicoder v2; - -/// @title Tick Lens -/// @notice Provides functions for fetching chunks of tick data for a pool -/// @dev This avoids the waterfall of fetching the tick bitmap, parsing the bitmap to know which ticks to fetch, and -/// then sending additional multicalls to fetch the tick data -interface ITickLens { - struct PopulatedTick { - int24 tick; - int128 liquidityNet; - uint128 liquidityGross; - } - - /// @notice Get all the tick data for the populated ticks from a word of the tick bitmap of a pool - /// @param pool The address of the pool for which to fetch populated tick data - /// @param tickBitmapIndex The index of the word in the tick bitmap for which to parse the bitmap and - /// fetch all the populated ticks - /// @return populatedTicks An array of tick data for the given word in the tick bitmap - function getPopulatedTicksInWord( - address pool, - int16 tickBitmapIndex - ) external view returns (PopulatedTick[] memory populatedTicks); -} diff --git a/src/dex/v3/periphery/interfaces/external/IERC1271.sol b/src/dex/v3/periphery/interfaces/external/IERC1271.sol deleted file mode 100644 index 6cdc487b..00000000 --- a/src/dex/v3/periphery/interfaces/external/IERC1271.sol +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Interface for verifying contract-based account signatures -/// @notice Interface that verifies provided signature for the data -/// @dev Interface defined by EIP-1271 -interface IERC1271 { - /// @notice Returns whether the provided signature is valid for the provided data - /// @dev MUST return the bytes4 magic value 0x1626ba7e when function passes. - /// MUST NOT modify state (using STATICCALL for solc < 0.5, view modifier for solc > 0.5). - /// MUST allow external calls. - /// @param hash Hash of the data to be signed - /// @param signature Signature byte array associated with _data - /// @return magicValue The bytes4 magic value 0x1626ba7e - function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4 magicValue); -} diff --git a/src/dex/v3/periphery/interfaces/external/IERC20PermitAllowed.sol b/src/dex/v3/periphery/interfaces/external/IERC20PermitAllowed.sol deleted file mode 100644 index 0594596c..00000000 --- a/src/dex/v3/periphery/interfaces/external/IERC20PermitAllowed.sol +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Interface for permit -/// @notice Interface used by DAI/CHAI for permit -interface IERC20PermitAllowed { - /// @notice Approve the spender to spend some tokens via the holder signature - /// @dev This is the permit interface used by DAI and CHAI - /// @param holder The address of the token holder, the token owner - /// @param spender The address of the token spender - /// @param nonce The holder's nonce, increases at each call to permit - /// @param expiry The timestamp at which the permit is no longer valid - /// @param allowed Boolean that sets approval amount, true for type(uint256).max and false for 0 - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function permit( - address holder, - address spender, - uint256 nonce, - uint256 expiry, - bool allowed, - uint8 v, - bytes32 r, - bytes32 s - ) external; -} diff --git a/src/dex/v3/periphery/interfaces/external/IWETH9.sol b/src/dex/v3/periphery/interfaces/external/IWETH9.sol deleted file mode 100644 index f9db69bb..00000000 --- a/src/dex/v3/periphery/interfaces/external/IWETH9.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -/// @title Interface for WETH9 -interface IWETH9 is IERC20 { - /// @notice Deposit ether to get wrapped ether - function deposit() external payable; - - /// @notice Withdraw wrapped ether to get ether - function withdraw(uint256) external; -} diff --git a/src/dex/v3/periphery/lens/ListaInterfaceMulticall.sol b/src/dex/v3/periphery/lens/ListaInterfaceMulticall.sol deleted file mode 100644 index bd881f7f..00000000 --- a/src/dex/v3/periphery/lens/ListaInterfaceMulticall.sol +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.34; -pragma abicoder v2; - -/// @notice A fork of Multicall2 specifically tailored for the Lista Interface -contract ListaInterfaceMulticall { - struct Call { - address target; - uint256 gasLimit; - bytes callData; - } - - struct Result { - bool success; - uint256 gasUsed; - bytes returnData; - } - - function getCurrentBlockTimestamp() public view returns (uint256 timestamp) { - timestamp = block.timestamp; - } - - function getEthBalance(address addr) public view returns (uint256 balance) { - balance = addr.balance; - } - - function multicall(Call[] memory calls) public returns (uint256 blockNumber, Result[] memory returnData) { - blockNumber = block.number; - returnData = new Result[](calls.length); - for (uint256 i = 0; i < calls.length; i++) { - (address target, uint256 gasLimit, bytes memory callData) = ( - calls[i].target, - calls[i].gasLimit, - calls[i].callData - ); - uint256 gasLeftBefore = gasleft(); - (bool success, bytes memory ret) = target.call{ gas: gasLimit }(callData); - uint256 gasUsed = gasLeftBefore - gasleft(); - returnData[i] = Result(success, gasUsed, ret); - } - } -} diff --git a/src/dex/v3/periphery/lens/Quoter.sol b/src/dex/v3/periphery/lens/Quoter.sol deleted file mode 100644 index bb077b02..00000000 --- a/src/dex/v3/periphery/lens/Quoter.sol +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; -pragma abicoder v2; - -import "../../core/libraries/SafeCast.sol"; -import "../../core/libraries/TickMath.sol"; -import "../../core/interfaces/IListaV3Pool.sol"; -import "../../core/interfaces/callback/IListaV3SwapCallback.sol"; - -import "../interfaces/IQuoter.sol"; -import "../base/PeripheryImmutableState.sol"; -import "../libraries/Path.sol"; -import "../libraries/PoolAddress.sol"; -import "../libraries/CallbackValidation.sol"; - -/// @title Provides quotes for swaps -/// @notice Allows getting the expected amount out or amount in for a given swap without executing the swap -/// @dev These functions are not gas efficient and should _not_ be called on chain. Instead, optimistically execute -/// the swap and check the amounts in the callback. -contract Quoter is IQuoter, IListaV3SwapCallback, PeripheryImmutableState { - using Path for bytes; - using SafeCast for uint256; - - /// @dev Transient storage variable used to check a safety condition in exact output swaps. - uint256 private amountOutCached; - - constructor(address _factory, address _WETH9) PeripheryImmutableState(_factory, _WETH9) {} - - function getPool(address tokenA, address tokenB, uint24 fee) private view returns (IListaV3Pool) { - return - IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee), poolInitCodeHash)); - } - - /// @inheritdoc IListaV3SwapCallback - function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes memory path) external view override { - require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported - (address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool(); - CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee); - - (bool isExactInput, uint256 amountToPay, uint256 amountReceived) = amount0Delta > 0 - ? (tokenIn < tokenOut, uint256(amount0Delta), uint256(-amount1Delta)) - : (tokenOut < tokenIn, uint256(amount1Delta), uint256(-amount0Delta)); - if (isExactInput) { - assembly { - let ptr := mload(0x40) - mstore(ptr, amountReceived) - revert(ptr, 32) - } - } else { - // if the cache has been populated, ensure that the full output amount has been received - if (amountOutCached != 0) require(amountReceived == amountOutCached); - assembly { - let ptr := mload(0x40) - mstore(ptr, amountToPay) - revert(ptr, 32) - } - } - } - - /// @dev Parses a revert reason that should contain the numeric quote - function parseRevertReason(bytes memory reason) private pure returns (uint256) { - if (reason.length != 32) { - if (reason.length < 68) revert("Unexpected error"); - assembly { - reason := add(reason, 0x04) - } - revert(abi.decode(reason, (string))); - } - return abi.decode(reason, (uint256)); - } - - /// @inheritdoc IQuoter - function quoteExactInputSingle( - address tokenIn, - address tokenOut, - uint24 fee, - uint256 amountIn, - uint160 sqrtPriceLimitX96 - ) public override returns (uint256 amountOut) { - bool zeroForOne = tokenIn < tokenOut; - - try - getPool(tokenIn, tokenOut, fee).swap( - address(this), // address(0) might cause issues with some tokens - zeroForOne, - amountIn.toInt256(), - sqrtPriceLimitX96 == 0 - ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) - : sqrtPriceLimitX96, - abi.encodePacked(tokenIn, fee, tokenOut) - ) - {} catch (bytes memory reason) { - return parseRevertReason(reason); - } - } - - /// @inheritdoc IQuoter - function quoteExactInput(bytes memory path, uint256 amountIn) external override returns (uint256 amountOut) { - while (true) { - bool hasMultiplePools = path.hasMultiplePools(); - - (address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool(); - - // the outputs of prior swaps become the inputs to subsequent ones - amountIn = quoteExactInputSingle(tokenIn, tokenOut, fee, amountIn, 0); - - // decide whether to continue or terminate - if (hasMultiplePools) { - path = path.skipToken(); - } else { - return amountIn; - } - } - } - - /// @inheritdoc IQuoter - function quoteExactOutputSingle( - address tokenIn, - address tokenOut, - uint24 fee, - uint256 amountOut, - uint160 sqrtPriceLimitX96 - ) public override returns (uint256 amountIn) { - bool zeroForOne = tokenIn < tokenOut; - - // if no price limit has been specified, cache the output amount for comparison in the swap callback - if (sqrtPriceLimitX96 == 0) amountOutCached = amountOut; - try - getPool(tokenIn, tokenOut, fee).swap( - address(this), // address(0) might cause issues with some tokens - zeroForOne, - -amountOut.toInt256(), - sqrtPriceLimitX96 == 0 - ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) - : sqrtPriceLimitX96, - abi.encodePacked(tokenOut, fee, tokenIn) - ) - {} catch (bytes memory reason) { - if (sqrtPriceLimitX96 == 0) delete amountOutCached; // clear cache - return parseRevertReason(reason); - } - } - - /// @inheritdoc IQuoter - function quoteExactOutput(bytes memory path, uint256 amountOut) external override returns (uint256 amountIn) { - while (true) { - bool hasMultiplePools = path.hasMultiplePools(); - - (address tokenOut, address tokenIn, uint24 fee) = path.decodeFirstPool(); - - // the inputs of prior swaps become the outputs of subsequent ones - amountOut = quoteExactOutputSingle(tokenIn, tokenOut, fee, amountOut, 0); - - // decide whether to continue or terminate - if (hasMultiplePools) { - path = path.skipToken(); - } else { - return amountOut; - } - } - } -} diff --git a/src/dex/v3/periphery/lens/QuoterV2.sol b/src/dex/v3/periphery/lens/QuoterV2.sol deleted file mode 100644 index 5c855bb8..00000000 --- a/src/dex/v3/periphery/lens/QuoterV2.sol +++ /dev/null @@ -1,258 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; -pragma abicoder v2; - -import "../../core/libraries/SafeCast.sol"; -import "../../core/libraries/TickMath.sol"; -import "../../core/libraries/TickBitmap.sol"; -import "../../core/interfaces/IListaV3Pool.sol"; -import "../../core/interfaces/callback/IListaV3SwapCallback.sol"; - -import "../interfaces/IQuoterV2.sol"; -import "../base/PeripheryImmutableState.sol"; -import "../libraries/Path.sol"; -import "../libraries/PoolAddress.sol"; -import "../libraries/CallbackValidation.sol"; -import "../libraries/PoolTicksCounter.sol"; - -/// @title Provides quotes for swaps -/// @notice Allows getting the expected amount out or amount in for a given swap without executing the swap -/// @dev These functions are not gas efficient and should _not_ be called on chain. Instead, optimistically execute -/// the swap and check the amounts in the callback. -contract QuoterV2 is IQuoterV2, IListaV3SwapCallback, PeripheryImmutableState { - using Path for bytes; - using SafeCast for uint256; - using PoolTicksCounter for IListaV3Pool; - - /// @dev Transient storage variable used to check a safety condition in exact output swaps. - uint256 private amountOutCached; - - constructor(address _factory, address _WETH9) PeripheryImmutableState(_factory, _WETH9) {} - - function getPool(address tokenA, address tokenB, uint24 fee) private view returns (IListaV3Pool) { - return - IListaV3Pool(PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenA, tokenB, fee), poolInitCodeHash)); - } - - /// @inheritdoc IListaV3SwapCallback - function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes memory path) external view override { - require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported - (address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool(); - CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee); - - (bool isExactInput, uint256 amountToPay, uint256 amountReceived) = amount0Delta > 0 - ? (tokenIn < tokenOut, uint256(amount0Delta), uint256(-amount1Delta)) - : (tokenOut < tokenIn, uint256(amount1Delta), uint256(-amount0Delta)); - - IListaV3Pool pool = getPool(tokenIn, tokenOut, fee); - (uint160 sqrtPriceX96After, int24 tickAfter, , , , , ) = pool.slot0(); - - if (isExactInput) { - assembly { - let ptr := mload(0x40) - mstore(ptr, amountReceived) - mstore(add(ptr, 0x20), sqrtPriceX96After) - mstore(add(ptr, 0x40), tickAfter) - revert(ptr, 96) - } - } else { - // if the cache has been populated, ensure that the full output amount has been received - if (amountOutCached != 0) require(amountReceived == amountOutCached); - assembly { - let ptr := mload(0x40) - mstore(ptr, amountToPay) - mstore(add(ptr, 0x20), sqrtPriceX96After) - mstore(add(ptr, 0x40), tickAfter) - revert(ptr, 96) - } - } - } - - /// @dev Parses a revert reason that should contain the numeric quote - function parseRevertReason( - bytes memory reason - ) private pure returns (uint256 amount, uint160 sqrtPriceX96After, int24 tickAfter) { - if (reason.length != 96) { - if (reason.length < 68) revert("Unexpected error"); - assembly { - reason := add(reason, 0x04) - } - revert(abi.decode(reason, (string))); - } - return abi.decode(reason, (uint256, uint160, int24)); - } - - function handleRevert( - bytes memory reason, - IListaV3Pool pool, - uint256 gasEstimate - ) private view returns (uint256 amount, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256) { - int24 tickBefore; - int24 tickAfter; - (, tickBefore, , , , , ) = pool.slot0(); - (amount, sqrtPriceX96After, tickAfter) = parseRevertReason(reason); - - initializedTicksCrossed = pool.countInitializedTicksCrossed(tickBefore, tickAfter); - - return (amount, sqrtPriceX96After, initializedTicksCrossed, gasEstimate); - } - - function quoteExactInputSingle( - QuoteExactInputSingleParams memory params - ) - public - override - returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate) - { - bool zeroForOne = params.tokenIn < params.tokenOut; - IListaV3Pool pool = getPool(params.tokenIn, params.tokenOut, params.fee); - - uint256 gasBefore = gasleft(); - try - pool.swap( - address(this), // address(0) might cause issues with some tokens - zeroForOne, - params.amountIn.toInt256(), - params.sqrtPriceLimitX96 == 0 - ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) - : params.sqrtPriceLimitX96, - abi.encodePacked(params.tokenIn, params.fee, params.tokenOut) - ) - {} catch (bytes memory reason) { - gasEstimate = gasBefore - gasleft(); - return handleRevert(reason, pool, gasEstimate); - } - } - - function quoteExactInput( - bytes memory path, - uint256 amountIn - ) - public - override - returns ( - uint256 amountOut, - uint160[] memory sqrtPriceX96AfterList, - uint32[] memory initializedTicksCrossedList, - uint256 gasEstimate - ) - { - sqrtPriceX96AfterList = new uint160[](path.numPools()); - initializedTicksCrossedList = new uint32[](path.numPools()); - - uint256 i = 0; - while (true) { - (address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool(); - - // the outputs of prior swaps become the inputs to subsequent ones - ( - uint256 _amountOut, - uint160 _sqrtPriceX96After, - uint32 _initializedTicksCrossed, - uint256 _gasEstimate - ) = quoteExactInputSingle( - QuoteExactInputSingleParams({ - tokenIn: tokenIn, - tokenOut: tokenOut, - fee: fee, - amountIn: amountIn, - sqrtPriceLimitX96: 0 - }) - ); - - sqrtPriceX96AfterList[i] = _sqrtPriceX96After; - initializedTicksCrossedList[i] = _initializedTicksCrossed; - amountIn = _amountOut; - gasEstimate += _gasEstimate; - i++; - - // decide whether to continue or terminate - if (path.hasMultiplePools()) { - path = path.skipToken(); - } else { - return (amountIn, sqrtPriceX96AfterList, initializedTicksCrossedList, gasEstimate); - } - } - } - - function quoteExactOutputSingle( - QuoteExactOutputSingleParams memory params - ) - public - override - returns (uint256 amountIn, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate) - { - bool zeroForOne = params.tokenIn < params.tokenOut; - IListaV3Pool pool = getPool(params.tokenIn, params.tokenOut, params.fee); - - // if no price limit has been specified, cache the output amount for comparison in the swap callback - if (params.sqrtPriceLimitX96 == 0) amountOutCached = params.amount; - uint256 gasBefore = gasleft(); - try - pool.swap( - address(this), // address(0) might cause issues with some tokens - zeroForOne, - -params.amount.toInt256(), - params.sqrtPriceLimitX96 == 0 - ? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1) - : params.sqrtPriceLimitX96, - abi.encodePacked(params.tokenOut, params.fee, params.tokenIn) - ) - {} catch (bytes memory reason) { - gasEstimate = gasBefore - gasleft(); - if (params.sqrtPriceLimitX96 == 0) delete amountOutCached; // clear cache - return handleRevert(reason, pool, gasEstimate); - } - } - - function quoteExactOutput( - bytes memory path, - uint256 amountOut - ) - public - override - returns ( - uint256 amountIn, - uint160[] memory sqrtPriceX96AfterList, - uint32[] memory initializedTicksCrossedList, - uint256 gasEstimate - ) - { - sqrtPriceX96AfterList = new uint160[](path.numPools()); - initializedTicksCrossedList = new uint32[](path.numPools()); - - uint256 i = 0; - while (true) { - (address tokenOut, address tokenIn, uint24 fee) = path.decodeFirstPool(); - - // the inputs of prior swaps become the outputs of subsequent ones - ( - uint256 _amountIn, - uint160 _sqrtPriceX96After, - uint32 _initializedTicksCrossed, - uint256 _gasEstimate - ) = quoteExactOutputSingle( - QuoteExactOutputSingleParams({ - tokenIn: tokenIn, - tokenOut: tokenOut, - amount: amountOut, - fee: fee, - sqrtPriceLimitX96: 0 - }) - ); - - sqrtPriceX96AfterList[i] = _sqrtPriceX96After; - initializedTicksCrossedList[i] = _initializedTicksCrossed; - amountOut = _amountIn; - gasEstimate += _gasEstimate; - i++; - - // decide whether to continue or terminate - if (path.hasMultiplePools()) { - path = path.skipToken(); - } else { - return (amountOut, sqrtPriceX96AfterList, initializedTicksCrossedList, gasEstimate); - } - } - } -} diff --git a/src/dex/v3/periphery/lens/README.md b/src/dex/v3/periphery/lens/README.md deleted file mode 100644 index 8359711f..00000000 --- a/src/dex/v3/periphery/lens/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# lens - -These contracts are not designed to be called on-chain. They simplify -fetching on-chain data from off-chain. diff --git a/src/dex/v3/periphery/lens/TickLens.sol b/src/dex/v3/periphery/lens/TickLens.sol deleted file mode 100644 index 2936ec70..00000000 --- a/src/dex/v3/periphery/lens/TickLens.sol +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; -pragma abicoder v2; - -import "../../core/interfaces/IListaV3Pool.sol"; - -import "../interfaces/ITickLens.sol"; - -/// @title Tick Lens contract -contract TickLens is ITickLens { - /// @inheritdoc ITickLens - function getPopulatedTicksInWord( - address pool, - int16 tickBitmapIndex - ) public view override returns (PopulatedTick[] memory populatedTicks) { - // fetch bitmap - uint256 bitmap = IListaV3Pool(pool).tickBitmap(tickBitmapIndex); - unchecked { - // calculate the number of populated ticks - uint256 numberOfPopulatedTicks; - for (uint256 i = 0; i < 256; i++) { - if (bitmap & (1 << i) > 0) numberOfPopulatedTicks++; - } - - // fetch populated tick data - int24 tickSpacing = IListaV3Pool(pool).tickSpacing(); - populatedTicks = new PopulatedTick[](numberOfPopulatedTicks); - for (uint256 i = 0; i < 256; i++) { - if (bitmap & (1 << i) > 0) { - int24 populatedTick = ((int24(tickBitmapIndex) << 8) + int24(uint24(i))) * tickSpacing; - (uint128 liquidityGross, int128 liquidityNet, , , , , , ) = IListaV3Pool(pool).ticks(populatedTick); - populatedTicks[--numberOfPopulatedTicks] = PopulatedTick({ - tick: populatedTick, - liquidityNet: liquidityNet, - liquidityGross: liquidityGross - }); - } - } - } - } -} diff --git a/src/dex/v3/periphery/libraries/AddressStringUtil.sol b/src/dex/v3/periphery/libraries/AddressStringUtil.sol deleted file mode 100644 index 7bda40a9..00000000 --- a/src/dex/v3/periphery/libraries/AddressStringUtil.sol +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -// from https://github.com/Lista/solidity-lib/blob/master/contracts/libraries/AddressStringUtil.sol -// modified for solidity 0.8 - -pragma solidity 0.8.34; - -library AddressStringUtil { - // converts an address to the uppercase hex string, extracting only len bytes (up to 20, multiple of 2) - function toAsciiString(address addr, uint256 len) internal pure returns (string memory) { - require(len % 2 == 0 && len > 0 && len <= 40, "AddressStringUtil: INVALID_LEN"); - - bytes memory s = new bytes(len); - uint256 addrNum = uint256(uint160(addr)); - for (uint256 i = 0; i < len / 2; i++) { - // shift right and truncate all but the least significant byte to extract the byte at position 19-i - uint8 b = uint8(addrNum >> (8 * (19 - i))); - // first hex character is the most significant 4 bits - uint8 hi = b >> 4; - // second hex character is the least significant 4 bits - uint8 lo = b - (hi << 4); - s[2 * i] = char(hi); - s[2 * i + 1] = char(lo); - } - return string(s); - } - - // hi and lo are only 4 bits and between 0 and 16 - // this method converts those values to the unicode/ascii code point for the hex representation - // uses upper case for the characters - function char(uint8 b) private pure returns (bytes1 c) { - if (b < 10) { - return bytes1(b + 0x30); - } else { - return bytes1(b + 0x37); - } - } -} diff --git a/src/dex/v3/periphery/libraries/BytesLib.sol b/src/dex/v3/periphery/libraries/BytesLib.sol deleted file mode 100644 index 2b98b4a2..00000000 --- a/src/dex/v3/periphery/libraries/BytesLib.sol +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -/* - * @title Solidity Bytes Arrays Utils - * @author Gonçalo Sá - * - * @dev Bytes tightly packed arrays utility library for ethereum contracts written in Solidity. - * The library lets you concatenate, slice and type cast bytes arrays both in memory and storage. - */ -pragma solidity 0.8.34 <0.9.0; - -library BytesLib { - function slice(bytes memory _bytes, uint256 _start, uint256 _length) internal pure returns (bytes memory) { - require(_length + 31 >= _length, "slice_overflow"); - require(_bytes.length >= _start + _length, "slice_outOfBounds"); - - bytes memory tempBytes; - - assembly { - switch iszero(_length) - case 0 { - // Get a location of some free memory and store it in tempBytes as - // Solidity does for memory variables. - tempBytes := mload(0x40) - - // The first word of the slice result is potentially a partial - // word read from the original array. To read it, we calculate - // the length of that partial word and start copying that many - // bytes into the array. The first word we copy will start with - // data we don't care about, but the last `lengthmod` bytes will - // land at the beginning of the contents of the new array. When - // we're done copying, we overwrite the full first word with - // the actual length of the slice. - let lengthmod := and(_length, 31) - - // The multiplication in the next line is necessary - // because when slicing multiples of 32 bytes (lengthmod == 0) - // the following copy loop was copying the origin's length - // and then ending prematurely not copying everything it should. - let mc := add(add(tempBytes, lengthmod), mul(0x20, iszero(lengthmod))) - let end := add(mc, _length) - - for { - // The multiplication in the next line has the same exact purpose - // as the one above. - let cc := add(add(add(_bytes, lengthmod), mul(0x20, iszero(lengthmod))), _start) - } lt(mc, end) { - mc := add(mc, 0x20) - cc := add(cc, 0x20) - } { - mstore(mc, mload(cc)) - } - - mstore(tempBytes, _length) - - //update free-memory pointer - //allocating the array padded to 32 bytes like the compiler does now - mstore(0x40, and(add(mc, 31), not(31))) - } - //if we want a zero-length slice let's just return a zero-length array - default { - tempBytes := mload(0x40) - //zero out the 32 bytes slice we are about to return - //we need to do it because Solidity does not garbage collect - mstore(tempBytes, 0) - - mstore(0x40, add(tempBytes, 0x20)) - } - } - - return tempBytes; - } - - function toAddress(bytes memory _bytes, uint256 _start) internal pure returns (address) { - require(_bytes.length >= _start + 20, "toAddress_outOfBounds"); - address tempAddress; - - assembly { - tempAddress := div(mload(add(add(_bytes, 0x20), _start)), 0x1000000000000000000000000) - } - - return tempAddress; - } - - function toUint24(bytes memory _bytes, uint256 _start) internal pure returns (uint24) { - require(_start + 3 >= _start, "toUint24_overflow"); - require(_bytes.length >= _start + 3, "toUint24_outOfBounds"); - uint24 tempUint; - - assembly { - tempUint := mload(add(add(_bytes, 0x3), _start)) - } - - return tempUint; - } -} diff --git a/src/dex/v3/periphery/libraries/CallbackValidation.sol b/src/dex/v3/periphery/libraries/CallbackValidation.sol deleted file mode 100644 index a2c4595a..00000000 --- a/src/dex/v3/periphery/libraries/CallbackValidation.sol +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import "../../core/interfaces/IListaV3Pool.sol"; -import "../../core/interfaces/IListaV3Factory.sol"; -import "./PoolAddress.sol"; - -/// @notice Provides validation for callbacks from Lista V3 Pools -library CallbackValidation { - /// @notice Returns the address of a valid Lista V3 Pool - /// @param factory The contract address of the Lista V3 factory - /// @param tokenA The contract address of either token0 or token1 - /// @param tokenB The contract address of the other token - /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip - /// @return pool The V3 pool contract address - function verifyCallback( - address factory, - address tokenA, - address tokenB, - uint24 fee - ) internal view returns (IListaV3Pool pool) { - return - verifyCallback( - factory, - PoolAddress.PoolKey({ - token0: tokenA < tokenB ? tokenA : tokenB, - token1: tokenA < tokenB ? tokenB : tokenA, - fee: fee - }) - ); - } - - /// @notice Returns the address of a valid Lista V3 Pool - /// @param factory The contract address of the Lista V3 factory - /// @param poolKey The identifying key of the V3 pool - /// @return pool The V3 pool contract address - function verifyCallback( - address factory, - PoolAddress.PoolKey memory poolKey - ) internal view returns (IListaV3Pool pool) { - pool = IListaV3Pool(IListaV3Factory(factory).getPool(poolKey.token0, poolKey.token1, poolKey.fee)); - require(msg.sender == address(pool)); - } -} diff --git a/src/dex/v3/periphery/libraries/ChainId.sol b/src/dex/v3/periphery/libraries/ChainId.sol deleted file mode 100644 index 5e9a4669..00000000 --- a/src/dex/v3/periphery/libraries/ChainId.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.0; - -/// @title Function for getting the current chain ID -library ChainId { - /// @dev Gets the current chain ID - /// @return chainId The current chain ID - function get() internal view returns (uint256 chainId) { - assembly { - chainId := chainid() - } - } -} diff --git a/src/dex/v3/periphery/libraries/HexStrings.sol b/src/dex/v3/periphery/libraries/HexStrings.sol deleted file mode 100644 index a0e02185..00000000 --- a/src/dex/v3/periphery/libraries/HexStrings.sol +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.34; - -library HexStrings { - bytes16 internal constant ALPHABET = "0123456789abcdef"; - - /// @notice Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length. - /// @dev Credit to Open Zeppelin under MIT license https://github.com/OpenZeppelin/openzeppelin-contracts/blob/243adff49ce1700e0ecb99fe522fb16cff1d1ddc/contracts/utils/Strings.sol#L55 - function toHexString(uint256 value, uint256 length) internal pure returns (string memory) { - bytes memory buffer = new bytes(2 * length + 2); - buffer[0] = "0"; - buffer[1] = "x"; - for (uint256 i = 2 * length + 1; i > 1; --i) { - buffer[i] = ALPHABET[value & 0xf]; - value >>= 4; - } - require(value == 0, "Strings: hex length insufficient"); - return string(buffer); - } - - function toHexStringNoPrefix(uint256 value, uint256 length) internal pure returns (string memory) { - bytes memory buffer = new bytes(2 * length); - for (uint256 i = buffer.length; i > 0; i--) { - buffer[i - 1] = ALPHABET[value & 0xf]; - value >>= 4; - } - return string(buffer); - } -} diff --git a/src/dex/v3/periphery/libraries/NFTDescriptor.sol b/src/dex/v3/periphery/libraries/NFTDescriptor.sol deleted file mode 100644 index 7d62f3c8..00000000 --- a/src/dex/v3/periphery/libraries/NFTDescriptor.sol +++ /dev/null @@ -1,475 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.0; -pragma abicoder v2; - -import "../../core/interfaces/IListaV3Pool.sol"; -import "../../core/libraries/TickMath.sol"; -import "../../core/libraries/BitMath.sol"; -import "../../core/libraries/FullMath.sol"; -import "@openzeppelin/contracts/utils/Strings.sol"; -import "@openzeppelin/contracts/utils/Base64.sol"; -import "./HexStrings.sol"; -import "./NFTSVG.sol"; - -library NFTDescriptor { - using TickMath for int24; - using Strings for uint256; - using HexStrings for uint256; - - uint256 constant sqrt10X128 = 1076067327063303206878105757264492625226; - - struct ConstructTokenURIParams { - uint256 tokenId; - address quoteTokenAddress; - address baseTokenAddress; - string quoteTokenSymbol; - string baseTokenSymbol; - uint8 quoteTokenDecimals; - uint8 baseTokenDecimals; - bool flipRatio; - int24 tickLower; - int24 tickUpper; - int24 tickCurrent; - int24 tickSpacing; - uint24 fee; - address poolAddress; - } - - function constructTokenURI(ConstructTokenURIParams memory params) public pure returns (string memory) { - string memory name = generateName(params, feeToPercentString(params.fee)); - string memory descriptionPartOne = generateDescriptionPartOne( - escapeQuotes(params.quoteTokenSymbol), - escapeQuotes(params.baseTokenSymbol), - addressToString(params.poolAddress) - ); - string memory descriptionPartTwo = generateDescriptionPartTwo( - params.tokenId.toString(), - escapeQuotes(params.baseTokenSymbol), - addressToString(params.quoteTokenAddress), - addressToString(params.baseTokenAddress), - feeToPercentString(params.fee) - ); - string memory image = Base64.encode(bytes(generateSVGImage(params))); - - return - string( - abi.encodePacked( - "data:application/json;base64,", - Base64.encode( - bytes( - abi.encodePacked( - '{"name":"', - name, - '", "description":"', - descriptionPartOne, - descriptionPartTwo, - '", "image": "', - "data:image/svg+xml;base64,", - image, - '"}' - ) - ) - ) - ) - ); - } - - function escapeQuotes(string memory symbol) internal pure returns (string memory) { - bytes memory symbolBytes = bytes(symbol); - uint8 quotesCount = 0; - for (uint8 i = 0; i < symbolBytes.length; i++) { - if (symbolBytes[i] == '"') { - quotesCount++; - } - } - if (quotesCount > 0) { - bytes memory escapedBytes = new bytes(symbolBytes.length + (quotesCount)); - uint256 index; - for (uint8 i = 0; i < symbolBytes.length; i++) { - if (symbolBytes[i] == '"') { - escapedBytes[index++] = "\\"; - } - escapedBytes[index++] = symbolBytes[i]; - } - return string(escapedBytes); - } - return symbol; - } - - function generateDescriptionPartOne( - string memory quoteTokenSymbol, - string memory baseTokenSymbol, - string memory poolAddress - ) private pure returns (string memory) { - return - string( - abi.encodePacked( - "This NFT represents a liquidity position in a Lista V3 ", - quoteTokenSymbol, - "-", - baseTokenSymbol, - " pool. ", - "The owner of this NFT can modify or redeem the position.\\n", - "\\nPool Address: ", - poolAddress, - "\\n", - quoteTokenSymbol - ) - ); - } - - function generateDescriptionPartTwo( - string memory tokenId, - string memory baseTokenSymbol, - string memory quoteTokenAddress, - string memory baseTokenAddress, - string memory feeTier - ) private pure returns (string memory) { - return - string( - abi.encodePacked( - " Address: ", - quoteTokenAddress, - "\\n", - baseTokenSymbol, - " Address: ", - baseTokenAddress, - "\\nFee Tier: ", - feeTier, - "\\nToken ID: ", - tokenId, - "\\n\\n", - unicode"⚠️ DISCLAIMER: Due diligence is imperative when assessing this NFT. Make sure token addresses match the expected tokens, as token symbols may be imitated." - ) - ); - } - - function generateName( - ConstructTokenURIParams memory params, - string memory feeTier - ) private pure returns (string memory) { - return - string( - abi.encodePacked( - "Lista - ", - feeTier, - " - ", - escapeQuotes(params.quoteTokenSymbol), - "/", - escapeQuotes(params.baseTokenSymbol), - " - ", - tickToDecimalString( - !params.flipRatio ? params.tickLower : params.tickUpper, - params.tickSpacing, - params.baseTokenDecimals, - params.quoteTokenDecimals, - params.flipRatio - ), - "<>", - tickToDecimalString( - !params.flipRatio ? params.tickUpper : params.tickLower, - params.tickSpacing, - params.baseTokenDecimals, - params.quoteTokenDecimals, - params.flipRatio - ) - ) - ); - } - - struct DecimalStringParams { - // significant figures of decimal - uint256 sigfigs; - // length of decimal string - uint8 bufferLength; - // ending index for significant figures (funtion works backwards when copying sigfigs) - uint8 sigfigIndex; - // index of decimal place (0 if no decimal) - uint8 decimalIndex; - // start index for trailing/leading 0's for very small/large numbers - uint8 zerosStartIndex; - // end index for trailing/leading 0's for very small/large numbers - uint8 zerosEndIndex; - // true if decimal number is less than one - bool isLessThanOne; - // true if string should include "%" - bool isPercent; - } - - function generateDecimalString(DecimalStringParams memory params) private pure returns (string memory) { - bytes memory buffer = new bytes(params.bufferLength); - if (params.isPercent) { - buffer[buffer.length - 1] = "%"; - } - if (params.isLessThanOne) { - buffer[0] = "0"; - buffer[1] = "."; - } - - // add leading/trailing 0's - for (uint256 zerosCursor = params.zerosStartIndex; zerosCursor < params.zerosEndIndex + 1; zerosCursor++) { - buffer[zerosCursor] = bytes1(uint8(48)); - } - // add sigfigs - unchecked { - while (params.sigfigs > 0) { - if (params.decimalIndex > 0 && params.sigfigIndex == params.decimalIndex) { - buffer[params.sigfigIndex--] = "."; - } - buffer[params.sigfigIndex--] = bytes1(uint8(uint256(48) + (params.sigfigs % 10))); - params.sigfigs /= 10; - } - } - return string(buffer); - } - - function tickToDecimalString( - int24 tick, - int24 tickSpacing, - uint8 baseTokenDecimals, - uint8 quoteTokenDecimals, - bool flipRatio - ) internal pure returns (string memory) { - if (tick == (TickMath.MIN_TICK / tickSpacing) * tickSpacing) { - return !flipRatio ? "MIN" : "MAX"; - } else if (tick == (TickMath.MAX_TICK / tickSpacing) * tickSpacing) { - return !flipRatio ? "MAX" : "MIN"; - } else { - uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick); - if (flipRatio) { - sqrtRatioX96 = uint160(uint256(1 << 192) / sqrtRatioX96); - } - return fixedPointToDecimalString(sqrtRatioX96, baseTokenDecimals, quoteTokenDecimals); - } - } - - function sigfigsRounded(uint256 value, uint8 digits) private pure returns (uint256, bool) { - bool extraDigit; - if (digits > 5) { - value = value / ((10 ** (digits - 5))); - } - bool roundUp = value % 10 > 4; - value = value / 10; - if (roundUp) { - value = value + 1; - } - // 99999 -> 100000 gives an extra sigfig - if (value == 100000) { - value /= 10; - extraDigit = true; - } - return (value, extraDigit); - } - - function adjustForDecimalPrecision( - uint160 sqrtRatioX96, - uint8 baseTokenDecimals, - uint8 quoteTokenDecimals - ) private pure returns (uint256 adjustedSqrtRatioX96) { - uint256 difference = abs(int256(uint256(baseTokenDecimals)) - int256(uint256(quoteTokenDecimals))); - if (difference > 0 && difference <= 18) { - if (baseTokenDecimals > quoteTokenDecimals) { - adjustedSqrtRatioX96 = sqrtRatioX96 * (10 ** (difference / 2)); - if (difference % 2 == 1) { - adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, sqrt10X128, 1 << 128); - } - } else { - adjustedSqrtRatioX96 = sqrtRatioX96 / (10 ** (difference / 2)); - if (difference % 2 == 1) { - adjustedSqrtRatioX96 = FullMath.mulDiv(adjustedSqrtRatioX96, 1 << 128, sqrt10X128); - } - } - } else { - adjustedSqrtRatioX96 = uint256(sqrtRatioX96); - } - } - - function abs(int256 x) private pure returns (uint256) { - return uint256(x >= 0 ? x : -x); - } - - // @notice Returns string that includes first 5 significant figures of a decimal number - // @param sqrtRatioX96 a sqrt price - function fixedPointToDecimalString( - uint160 sqrtRatioX96, - uint8 baseTokenDecimals, - uint8 quoteTokenDecimals - ) internal pure returns (string memory) { - uint256 adjustedSqrtRatioX96 = adjustForDecimalPrecision(sqrtRatioX96, baseTokenDecimals, quoteTokenDecimals); - uint256 value = FullMath.mulDiv(adjustedSqrtRatioX96, adjustedSqrtRatioX96, 1 << 64); - - bool priceBelow1 = adjustedSqrtRatioX96 < 2 ** 96; - if (priceBelow1) { - // 10 ** 43 is precision needed to retreive 5 sigfigs of smallest possible price + 1 for rounding - value = FullMath.mulDiv(value, 10 ** 44, 1 << 128); - } else { - // leave precision for 4 decimal places + 1 place for rounding - value = FullMath.mulDiv(value, 10 ** 5, 1 << 128); - } - - // get digit count - uint256 temp = value; - uint8 digits; - while (temp != 0) { - digits++; - temp /= 10; - } - // don't count extra digit kept for rounding - digits = digits - 1; - - // address rounding - (uint256 sigfigs, bool extraDigit) = sigfigsRounded(value, digits); - if (extraDigit) { - digits++; - } - - DecimalStringParams memory params; - if (priceBelow1) { - // 7 bytes ( "0." and 5 sigfigs) + leading 0's bytes - params.bufferLength = uint8(uint8(7) + (uint8(43) - digits)); - params.zerosStartIndex = 2; - params.zerosEndIndex = uint8(uint256(43) - digits + 1); - params.sigfigIndex = uint8(params.bufferLength - 1); - } else if (digits >= 9) { - // no decimal in price string - params.bufferLength = uint8(digits - 4); - params.zerosStartIndex = 5; - params.zerosEndIndex = uint8(params.bufferLength - 1); - params.sigfigIndex = 4; - } else { - // 5 sigfigs surround decimal - params.bufferLength = 6; - params.sigfigIndex = 5; - params.decimalIndex = uint8(digits - 4); - } - params.sigfigs = sigfigs; - params.isLessThanOne = priceBelow1; - params.isPercent = false; - - return generateDecimalString(params); - } - - struct FeeDigits { - uint24 temp; - uint8 numSigfigs; - uint256 digits; - } - - // @notice Returns string as decimal percentage of fee amount. - // @param fee fee amount - function feeToPercentString(uint24 fee) internal pure returns (string memory) { - if (fee == 0) { - return "0%"; - } - - FeeDigits memory feeDigits = FeeDigits(fee, 0, 0); - while (feeDigits.temp != 0) { - if (feeDigits.numSigfigs > 0) { - // count all digits preceding least significant figure - feeDigits.numSigfigs++; - } else if (feeDigits.temp % 10 != 0) { - feeDigits.numSigfigs++; - } - feeDigits.digits++; - feeDigits.temp /= 10; - } - - DecimalStringParams memory params; - uint256 nZeros; - if (feeDigits.digits >= 5) { - // if decimal > 1 (5th digit is the ones place) - uint256 decimalPlace = feeDigits.digits - feeDigits.numSigfigs >= 4 ? 0 : 1; - nZeros = feeDigits.digits - 5 < (feeDigits.numSigfigs - 1) - ? 0 - : feeDigits.digits - 5 - (feeDigits.numSigfigs - 1); - params.zerosStartIndex = feeDigits.numSigfigs; - params.zerosEndIndex = uint8(params.zerosStartIndex + nZeros - 1); - params.sigfigIndex = uint8(params.zerosStartIndex - 1 + decimalPlace); - params.bufferLength = uint8(nZeros + (feeDigits.numSigfigs + 1) + decimalPlace); - } else { - // else if decimal < 1 - nZeros = uint256(5) - feeDigits.digits; - params.zerosStartIndex = 2; - params.zerosEndIndex = uint8(nZeros + params.zerosStartIndex - 1); - params.bufferLength = uint8(nZeros + (feeDigits.numSigfigs + 2)); - params.sigfigIndex = uint8((params.bufferLength) - 2); - params.isLessThanOne = true; - } - params.sigfigs = uint256(fee) / (10 ** (feeDigits.digits - feeDigits.numSigfigs)); - params.isPercent = true; - params.decimalIndex = feeDigits.digits > 4 ? uint8(feeDigits.digits - 4) : 0; - - return generateDecimalString(params); - } - - function addressToString(address addr) internal pure returns (string memory) { - return HexStrings.toHexString(uint256(uint160(addr)), 20); - } - - function generateSVGImage(ConstructTokenURIParams memory params) internal pure returns (string memory svg) { - string memory defs = NFTSVG.generateSVGDefs( - NFTSVG.SVGDefsParams({ - color0: tokenToColorHex(uint256(uint160(params.quoteTokenAddress)), 136), - color1: tokenToColorHex(uint256(uint160(params.baseTokenAddress)), 136), - color2: tokenToColorHex(uint256(uint160(params.quoteTokenAddress)), 0), - color3: tokenToColorHex(uint256(uint160(params.baseTokenAddress)), 0), - x1: scale(getCircleCoord(uint256(uint160(params.quoteTokenAddress)), 16, params.tokenId), 0, 255, 16, 274), - y1: scale(getCircleCoord(uint256(uint160(params.baseTokenAddress)), 16, params.tokenId), 0, 255, 100, 484), - x2: scale(getCircleCoord(uint256(uint160(params.quoteTokenAddress)), 32, params.tokenId), 0, 255, 16, 274), - y2: scale(getCircleCoord(uint256(uint160(params.baseTokenAddress)), 32, params.tokenId), 0, 255, 100, 484), - x3: scale(getCircleCoord(uint256(uint160(params.quoteTokenAddress)), 48, params.tokenId), 0, 255, 16, 274), - y3: scale(getCircleCoord(uint256(uint160(params.baseTokenAddress)), 48, params.tokenId), 0, 255, 100, 484) - }) - ); - - string memory body = NFTSVG.generateSVGBody( - NFTSVG.SVGBodyParams({ - quoteToken: addressToString(params.quoteTokenAddress), - baseToken: addressToString(params.baseTokenAddress), - poolAddress: params.poolAddress, - quoteTokenSymbol: params.quoteTokenSymbol, - baseTokenSymbol: params.baseTokenSymbol, - feeTier: feeToPercentString(params.fee), - tickLower: params.tickLower, - tickUpper: params.tickUpper, - tickSpacing: params.tickSpacing, - overRange: overRange(params.tickLower, params.tickUpper, params.tickCurrent), - tokenId: params.tokenId - }) - ); - - return NFTSVG.generateSVG(defs, body); - } - - function overRange(int24 tickLower, int24 tickUpper, int24 tickCurrent) private pure returns (int8) { - if (tickCurrent < tickLower) { - return -1; - } else if (tickCurrent > tickUpper) { - return 1; - } else { - return 0; - } - } - - function scale( - uint256 n, - uint256 inMn, - uint256 inMx, - uint256 outMn, - uint256 outMx - ) private pure returns (string memory) { - return (((n - inMn) * (outMx - outMn)) / (inMx - inMn) + outMn).toString(); - } - - function tokenToColorHex(uint256 token, uint256 offset) internal pure returns (string memory str) { - return string((token >> offset).toHexStringNoPrefix(3)); - } - - function getCircleCoord(uint256 tokenAddress, uint256 offset, uint256 tokenId) internal pure returns (uint256) { - return (sliceTokenHex(tokenAddress, offset) * tokenId) % 255; - } - - function sliceTokenHex(uint256 token, uint256 offset) internal pure returns (uint256) { - return uint256(uint8(token >> offset)); - } -} diff --git a/src/dex/v3/periphery/libraries/NFTSVG.sol b/src/dex/v3/periphery/libraries/NFTSVG.sol deleted file mode 100644 index 66ac2c72..00000000 --- a/src/dex/v3/periphery/libraries/NFTSVG.sol +++ /dev/null @@ -1,403 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.7.6; -pragma abicoder v2; - -import "@openzeppelin/contracts/utils/Strings.sol"; -import "../../core/libraries/BitMath.sol"; -import "@openzeppelin/contracts/utils/Base64.sol"; - -/// @title NFTSVG -/// @notice Provides a function for generating an SVG associated with a Lista NFT -library NFTSVG { - using Strings for uint256; - - string constant curve1 = "M1 1C41 41 105 105 145 145"; - string constant curve2 = "M1 1C33 49 97 113 145 145"; - string constant curve3 = "M1 1C33 57 89 113 145 145"; - string constant curve4 = "M1 1C25 65 81 121 145 145"; - string constant curve5 = "M1 1C17 73 73 129 145 145"; - string constant curve6 = "M1 1C9 81 65 137 145 145"; - string constant curve7 = "M1 1C1 89 57.5 145 145 145"; - string constant curve8 = "M1 1C1 97 49 145 145 145"; - - struct SVGBodyParams { - string quoteToken; - string baseToken; - address poolAddress; - string quoteTokenSymbol; - string baseTokenSymbol; - string feeTier; - int24 tickLower; - int24 tickUpper; - int24 tickSpacing; - int8 overRange; - uint256 tokenId; - } - - struct SVGDefsParams { - string color0; - string color1; - string color2; - string color3; - string x1; - string y1; - string x2; - string y2; - string x3; - string y3; - } - - function generateSVG(string memory defs, string memory body) internal pure returns (string memory svg) { - /* - address: "0xe8ab59d3bcde16a29912de83a90eb39628cfc163", - msg: "Forged in SVG for Lista in 2021 by 0xe8ab59d3bcde16a29912de83a90eb39628cfc163", - sig: "0x2df0e99d9cbfec33a705d83f75666d98b22dea7c1af412c584f7d626d83f02875993df740dc87563b9c73378f8462426da572d7989de88079a382ad96c57b68d1b", - version: "2" - */ - return string(abi.encodePacked(defs, body, "")); - } - - function generateSVGBody(SVGBodyParams memory params) internal pure returns (string memory body) { - return - string( - abi.encodePacked( - generateSVGBorderText(params.quoteToken, params.baseToken, params.quoteTokenSymbol, params.baseTokenSymbol), - generateSVGCardMantle(params.quoteTokenSymbol, params.baseTokenSymbol, params.feeTier), - generageSvgCurve(params.tickLower, params.tickUpper, params.tickSpacing, params.overRange), - generateSVGPositionDataAndLocationCurve(params.tokenId.toString(), params.tickLower, params.tickUpper), - generateSVGRareSparkle(params.tokenId, params.poolAddress) - ) - ); - } - - function generateSVGDefs(SVGDefsParams memory params) internal pure returns (string memory svg) { - svg = string( - abi.encodePacked( - '", - "", - '" - ) - ) - ), - '"/>" - ) - ) - ), - '"/>" - ) - ) - ), - '" />', - '" - ) - ) - ), - '" /> ', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - ' ', - '', - '', - '' - ) - ); - } - - function generateSVGBorderText( - string memory quoteToken, - string memory baseToken, - string memory quoteTokenSymbol, - string memory baseTokenSymbol - ) private pure returns (string memory svg) { - svg = string( - abi.encodePacked( - '', - '', - baseToken, - unicode" • ", - baseTokenSymbol, - ' ', - ' ', - baseToken, - unicode" • ", - baseTokenSymbol, - ' ', - '', - quoteToken, - unicode" • ", - quoteTokenSymbol, - ' ', - quoteToken, - unicode" • ", - quoteTokenSymbol, - ' ' - ) - ); - } - - function generateSVGCardMantle( - string memory quoteTokenSymbol, - string memory baseTokenSymbol, - string memory feeTier - ) private pure returns (string memory svg) { - svg = string( - abi.encodePacked( - ' ', - quoteTokenSymbol, - "/", - baseTokenSymbol, - '', - feeTier, - "", - '' - ) - ); - } - - function generageSvgCurve( - int24 tickLower, - int24 tickUpper, - int24 tickSpacing, - int8 overRange - ) private pure returns (string memory svg) { - string memory fade = overRange == 1 - ? "#fade-up" - : overRange == -1 - ? "#fade-down" - : "#none"; - string memory curve = getCurve(tickLower, tickUpper, tickSpacing); - svg = string( - abi.encodePacked( - '' - '' - '', - '', - '', - '', - generateSVGCurveCircle(overRange) - ) - ); - } - - function getCurve(int24 tickLower, int24 tickUpper, int24 tickSpacing) internal pure returns (string memory curve) { - int24 tickRange = (tickUpper - tickLower) / tickSpacing; - if (tickRange <= 4) { - curve = curve1; - } else if (tickRange <= 8) { - curve = curve2; - } else if (tickRange <= 16) { - curve = curve3; - } else if (tickRange <= 32) { - curve = curve4; - } else if (tickRange <= 64) { - curve = curve5; - } else if (tickRange <= 128) { - curve = curve6; - } else if (tickRange <= 256) { - curve = curve7; - } else { - curve = curve8; - } - } - - function generateSVGCurveCircle(int8 overRange) internal pure returns (string memory svg) { - string memory curvex1 = "73"; - string memory curvey1 = "190"; - string memory curvex2 = "217"; - string memory curvey2 = "334"; - if (overRange == 1 || overRange == -1) { - svg = string( - abi.encodePacked( - '' - ) - ); - } else { - svg = string( - abi.encodePacked( - '', - '' - ) - ); - } - } - - function generateSVGPositionDataAndLocationCurve( - string memory tokenId, - int24 tickLower, - int24 tickUpper - ) private pure returns (string memory svg) { - string memory tickLowerStr = tickToString(tickLower); - string memory tickUpperStr = tickToString(tickUpper); - uint256 str1length = bytes(tokenId).length + 4; - uint256 str2length = bytes(tickLowerStr).length + 10; - uint256 str3length = bytes(tickUpperStr).length + 10; - (string memory xCoord, string memory yCoord) = rangeLocation(tickLower, tickUpper); - svg = string( - abi.encodePacked( - ' ', - '', - 'ID: ', - tokenId, - "", - ' ', - '', - 'Min Tick: ', - tickLowerStr, - "", - ' ', - '', - 'Max Tick: ', - tickUpperStr, - "" - '', - '', - '', - '' - ) - ); - } - - function tickToString(int24 tick) private pure returns (string memory) { - string memory sign = ""; - if (tick < 0) { - tick = tick * -1; - sign = "-"; - } - return string(abi.encodePacked(sign, uint256(uint24(tick)).toString())); - } - - function rangeLocation(int24 tickLower, int24 tickUpper) internal pure returns (string memory, string memory) { - int24 midPoint = (tickLower + tickUpper) / 2; - if (midPoint < -125_000) { - return ("8", "7"); - } else if (midPoint < -75_000) { - return ("8", "10.5"); - } else if (midPoint < -25_000) { - return ("8", "14.25"); - } else if (midPoint < -5_000) { - return ("10", "18"); - } else if (midPoint < 0) { - return ("11", "21"); - } else if (midPoint < 5_000) { - return ("13", "23"); - } else if (midPoint < 25_000) { - return ("15", "25"); - } else if (midPoint < 75_000) { - return ("18", "26"); - } else if (midPoint < 125_000) { - return ("21", "27"); - } else { - return ("24", "27"); - } - } - - function generateSVGRareSparkle(uint256 tokenId, address poolAddress) private pure returns (string memory svg) { - if (isRare(tokenId, poolAddress)) { - svg = string( - abi.encodePacked( - '', - '', - '' - ) - ); - } else { - svg = ""; - } - } - - function isRare(uint256 tokenId, address poolAddress) internal pure returns (bool) { - bytes32 h = keccak256(abi.encodePacked(tokenId, poolAddress)); - return uint256(h) < type(uint256).max / (1 + BitMath.mostSignificantBit(tokenId) * 2); - } -} diff --git a/src/dex/v3/periphery/libraries/OracleLibrary.sol b/src/dex/v3/periphery/libraries/OracleLibrary.sol deleted file mode 100644 index 441d6034..00000000 --- a/src/dex/v3/periphery/libraries/OracleLibrary.sol +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0 <0.9.0; - -import "../../core/libraries/FullMath.sol"; -import "../../core/libraries/TickMath.sol"; -import "../../core/interfaces/IListaV3Pool.sol"; - -/// @title Oracle library -/// @notice Provides functions to integrate with V3 pool oracle -library OracleLibrary { - /// @notice Calculates time-weighted means of tick and liquidity for a given Lista V3 pool - /// @param pool Address of the pool that we want to observe - /// @param secondsAgo Number of seconds in the past from which to calculate the time-weighted means - /// @return arithmeticMeanTick The arithmetic mean tick from (block.timestamp - secondsAgo) to block.timestamp - /// @return harmonicMeanLiquidity The harmonic mean liquidity from (block.timestamp - secondsAgo) to block.timestamp - function consult( - address pool, - uint32 secondsAgo - ) internal view returns (int24 arithmeticMeanTick, uint128 harmonicMeanLiquidity) { - require(secondsAgo != 0, "BP"); - - uint32[] memory secondsAgos = new uint32[](2); - secondsAgos[0] = secondsAgo; - secondsAgos[1] = 0; - - (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) = IListaV3Pool(pool).observe( - secondsAgos - ); - - int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0]; - uint160 secondsPerLiquidityCumulativesDelta = secondsPerLiquidityCumulativeX128s[1] - - secondsPerLiquidityCumulativeX128s[0]; - - arithmeticMeanTick = int24(tickCumulativesDelta / int56(uint56(secondsAgo))); - // Always round to negative infinity - if (tickCumulativesDelta < 0 && (tickCumulativesDelta % int56(uint56(secondsAgo)) != 0)) arithmeticMeanTick--; - - // We are multiplying here instead of shifting to ensure that harmonicMeanLiquidity doesn't overflow uint128 - uint192 secondsAgoX160 = uint192(secondsAgo) * type(uint160).max; - harmonicMeanLiquidity = uint128(secondsAgoX160 / (uint192(secondsPerLiquidityCumulativesDelta) << 32)); - } - - /// @notice Given a tick and a token amount, calculates the amount of token received in exchange - /// @param tick Tick value used to calculate the quote - /// @param baseAmount Amount of token to be converted - /// @param baseToken Address of an ERC20 token contract used as the baseAmount denomination - /// @param quoteToken Address of an ERC20 token contract used as the quoteAmount denomination - /// @return quoteAmount Amount of quoteToken received for baseAmount of baseToken - function getQuoteAtTick( - int24 tick, - uint128 baseAmount, - address baseToken, - address quoteToken - ) internal pure returns (uint256 quoteAmount) { - uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick); - - // Calculate quoteAmount with better precision if it doesn't overflow when multiplied by itself - if (sqrtRatioX96 <= type(uint128).max) { - uint256 ratioX192 = uint256(sqrtRatioX96) * sqrtRatioX96; - quoteAmount = baseToken < quoteToken - ? FullMath.mulDiv(ratioX192, baseAmount, 1 << 192) - : FullMath.mulDiv(1 << 192, baseAmount, ratioX192); - } else { - uint256 ratioX128 = FullMath.mulDiv(sqrtRatioX96, sqrtRatioX96, 1 << 64); - quoteAmount = baseToken < quoteToken - ? FullMath.mulDiv(ratioX128, baseAmount, 1 << 128) - : FullMath.mulDiv(1 << 128, baseAmount, ratioX128); - } - } - - /// @notice Given a pool, it returns the number of seconds ago of the oldest stored observation - /// @param pool Address of Lista V3 pool that we want to observe - /// @return secondsAgo The number of seconds ago of the oldest observation stored for the pool - function getOldestObservationSecondsAgo(address pool) internal view returns (uint32 secondsAgo) { - (, , uint16 observationIndex, uint16 observationCardinality, , , ) = IListaV3Pool(pool).slot0(); - require(observationCardinality > 0, "NI"); - - (uint32 observationTimestamp, , , bool initialized) = IListaV3Pool(pool).observations( - (observationIndex + 1) % observationCardinality - ); - - // The next index might not be initialized if the cardinality is in the process of increasing - // In this case the oldest observation is always in index 0 - if (!initialized) { - (observationTimestamp, , , ) = IListaV3Pool(pool).observations(0); - } - - unchecked { - secondsAgo = uint32(block.timestamp) - observationTimestamp; - } - } - - /// @notice Given a pool, it returns the tick value as of the start of the current block - /// @param pool Address of Lista V3 pool - /// @return The tick that the pool was in at the start of the current block - function getBlockStartingTickAndLiquidity(address pool) internal view returns (int24, uint128) { - (, int24 tick, uint16 observationIndex, uint16 observationCardinality, , , ) = IListaV3Pool(pool).slot0(); - - // 2 observations are needed to reliably calculate the block starting tick - require(observationCardinality > 1, "NEO"); - - // If the latest observation occurred in the past, then no tick-changing trades have happened in this block - // therefore the tick in `slot0` is the same as at the beginning of the current block. - // We don't need to check if this observation is initialized - it is guaranteed to be. - (uint32 observationTimestamp, int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128, ) = IListaV3Pool( - pool - ).observations(observationIndex); - if (observationTimestamp != uint32(block.timestamp)) { - return (tick, IListaV3Pool(pool).liquidity()); - } - - uint256 prevIndex = (uint256(observationIndex) + observationCardinality - 1) % observationCardinality; - ( - uint32 prevObservationTimestamp, - int56 prevTickCumulative, - uint160 prevSecondsPerLiquidityCumulativeX128, - bool prevInitialized - ) = IListaV3Pool(pool).observations(prevIndex); - - require(prevInitialized, "ONI"); - - uint32 delta = observationTimestamp - prevObservationTimestamp; - tick = int24((tickCumulative - int56(uint56(prevTickCumulative))) / int56(uint56(delta))); - uint128 liquidity = uint128( - (uint192(delta) * type(uint160).max) / - (uint192(secondsPerLiquidityCumulativeX128 - prevSecondsPerLiquidityCumulativeX128) << 32) - ); - return (tick, liquidity); - } - - /// @notice Information for calculating a weighted arithmetic mean tick - struct WeightedTickData { - int24 tick; - uint128 weight; - } - - /// @notice Given an array of ticks and weights, calculates the weighted arithmetic mean tick - /// @param weightedTickData An array of ticks and weights - /// @return weightedArithmeticMeanTick The weighted arithmetic mean tick - /// @dev Each entry of `weightedTickData` should represents ticks from pools with the same underlying pool tokens. If they do not, - /// extreme care must be taken to ensure that ticks are comparable (including decimal differences). - /// @dev Note that the weighted arithmetic mean tick corresponds to the weighted geometric mean price. - function getWeightedArithmeticMeanTick( - WeightedTickData[] memory weightedTickData - ) internal pure returns (int24 weightedArithmeticMeanTick) { - // Accumulates the sum of products between each tick and its weight - int256 numerator; - - // Accumulates the sum of the weights - uint256 denominator; - - // Products fit in 152 bits, so it would take an array of length ~2**104 to overflow this logic - for (uint256 i; i < weightedTickData.length; i++) { - numerator += weightedTickData[i].tick * int256(uint256(weightedTickData[i].weight)); - denominator += weightedTickData[i].weight; - } - - weightedArithmeticMeanTick = int24(numerator / int256(denominator)); - // Always round to negative infinity - if (numerator < 0 && (numerator % int256(denominator) != 0)) weightedArithmeticMeanTick--; - } - - /// @notice Returns the "synthetic" tick which represents the price of the first entry in `tokens` in terms of the last - /// @dev Useful for calculating relative prices along routes. - /// @dev There must be one tick for each pairwise set of tokens. - /// @param tokens The token contract addresses - /// @param ticks The ticks, representing the price of each token pair in `tokens` - /// @return syntheticTick The synthetic tick, representing the relative price of the outermost tokens in `tokens` - function getChainedPrice(address[] memory tokens, int24[] memory ticks) internal pure returns (int256 syntheticTick) { - require(tokens.length - 1 == ticks.length, "DL"); - for (uint256 i = 1; i <= ticks.length; i++) { - // check the tokens for address sort order, then accumulate the - // ticks into the running synthetic tick, ensuring that intermediate tokens "cancel out" - tokens[i - 1] < tokens[i] ? syntheticTick += ticks[i - 1] : syntheticTick -= ticks[i - 1]; - } - } -} diff --git a/src/dex/v3/periphery/libraries/Path.sol b/src/dex/v3/periphery/libraries/Path.sol deleted file mode 100644 index bdfee81a..00000000 --- a/src/dex/v3/periphery/libraries/Path.sol +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.6.0; - -import "./BytesLib.sol"; - -/// @title Functions for manipulating path data for multihop swaps -library Path { - using BytesLib for bytes; - - /// @dev The length of the bytes encoded address - uint256 private constant ADDR_SIZE = 20; - /// @dev The length of the bytes encoded fee - uint256 private constant FEE_SIZE = 3; - - /// @dev The offset of a single token address and pool fee - uint256 private constant NEXT_OFFSET = ADDR_SIZE + FEE_SIZE; - /// @dev The offset of an encoded pool key - uint256 private constant POP_OFFSET = NEXT_OFFSET + ADDR_SIZE; - /// @dev The minimum length of an encoding that contains 2 or more pools - uint256 private constant MULTIPLE_POOLS_MIN_LENGTH = POP_OFFSET + NEXT_OFFSET; - - /// @notice Returns true iff the path contains two or more pools - /// @param path The encoded swap path - /// @return True if path contains two or more pools, otherwise false - function hasMultiplePools(bytes memory path) internal pure returns (bool) { - return path.length >= MULTIPLE_POOLS_MIN_LENGTH; - } - - /// @notice Returns the number of pools in the path - /// @param path The encoded swap path - /// @return The number of pools in the path - function numPools(bytes memory path) internal pure returns (uint256) { - // Ignore the first token address. From then on every fee and token offset indicates a pool. - return ((path.length - ADDR_SIZE) / NEXT_OFFSET); - } - - /// @notice Decodes the first pool in path - /// @param path The bytes encoded swap path - /// @return tokenA The first token of the given pool - /// @return tokenB The second token of the given pool - /// @return fee The fee level of the pool - function decodeFirstPool(bytes memory path) internal pure returns (address tokenA, address tokenB, uint24 fee) { - tokenA = path.toAddress(0); - fee = path.toUint24(ADDR_SIZE); - tokenB = path.toAddress(NEXT_OFFSET); - } - - /// @notice Gets the segment corresponding to the first pool in the path - /// @param path The bytes encoded swap path - /// @return The segment containing all data necessary to target the first pool in the path - function getFirstPool(bytes memory path) internal pure returns (bytes memory) { - return path.slice(0, POP_OFFSET); - } - - /// @notice Skips a token + fee element from the buffer and returns the remainder - /// @param path The swap path - /// @return The remaining token + fee elements in the path - function skipToken(bytes memory path) internal pure returns (bytes memory) { - return path.slice(NEXT_OFFSET, path.length - NEXT_OFFSET); - } -} diff --git a/src/dex/v3/periphery/libraries/PoolAddress.sol b/src/dex/v3/periphery/libraries/PoolAddress.sol deleted file mode 100644 index dbefc927..00000000 --- a/src/dex/v3/periphery/libraries/PoolAddress.sol +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Provides functions for deriving a pool address from the factory, tokens, and the fee -library PoolAddress { - /// @notice The identifying key of the pool - struct PoolKey { - address token0; - address token1; - uint24 fee; - } - - /// @notice Returns PoolKey: the ordered tokens with the matched fee levels - function getPoolKey(address tokenA, address tokenB, uint24 fee) internal pure returns (PoolKey memory) { - if (tokenA > tokenB) (tokenA, tokenB) = (tokenB, tokenA); - return PoolKey({ token0: tokenA, token1: tokenB, fee: fee }); - } - - /// @notice Deterministically computes the pool address given the factory, PoolKey, and init code hash - /// @param factory The Lista V3 factory contract address - /// @param key The PoolKey - /// @param initCodeHash The keccak256 of the pool proxy creation code (from factory.poolInitCodeHash()) - /// @return pool The contract address of the V3 pool - function computeAddress( - address factory, - PoolKey memory key, - bytes32 initCodeHash - ) internal pure returns (address pool) { - require(key.token0 < key.token1); - pool = address( - uint160( - uint256( - keccak256( - abi.encodePacked(hex"ff", factory, keccak256(abi.encode(key.token0, key.token1, key.fee)), initCodeHash) - ) - ) - ) - ); - } -} diff --git a/src/dex/v3/periphery/libraries/PoolTicksCounter.sol b/src/dex/v3/periphery/libraries/PoolTicksCounter.sol deleted file mode 100644 index ec9fe2b3..00000000 --- a/src/dex/v3/periphery/libraries/PoolTicksCounter.sol +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.6.0; - -import "../../core/interfaces/IListaV3Pool.sol"; - -library PoolTicksCounter { - /// @dev This function counts the number of initialized ticks that would incur a gas cost between tickBefore and tickAfter. - /// When tickBefore and/or tickAfter themselves are initialized, the logic over whether we should count them depends on the - /// direction of the swap. If we are swapping upwards (tickAfter > tickBefore) we don't want to count tickBefore but we do - /// want to count tickAfter. The opposite is true if we are swapping downwards. - function countInitializedTicksCrossed( - IListaV3Pool self, - int24 tickBefore, - int24 tickAfter - ) internal view returns (uint32 initializedTicksCrossed) { - int16 wordPosLower; - int16 wordPosHigher; - uint8 bitPosLower; - uint8 bitPosHigher; - bool tickBeforeInitialized; - bool tickAfterInitialized; - - { - // Get the key and offset in the tick bitmap of the active tick before and after the swap. - int16 wordPos = int16((tickBefore / self.tickSpacing()) >> 8); - uint8 bitPos = uint8(int8((tickBefore / self.tickSpacing()) % 256)); - - int16 wordPosAfter = int16((tickAfter / self.tickSpacing()) >> 8); - uint8 bitPosAfter = uint8(int8((tickAfter / self.tickSpacing()) % 256)); - - // In the case where tickAfter is initialized, we only want to count it if we are swapping downwards. - // If the initializable tick after the swap is initialized, our original tickAfter is a - // multiple of tick spacing, and we are swapping downwards we know that tickAfter is initialized - // and we shouldn't count it. - tickAfterInitialized = - ((self.tickBitmap(wordPosAfter) & (1 << bitPosAfter)) > 0) && - ((tickAfter % self.tickSpacing()) == 0) && - (tickBefore > tickAfter); - - // In the case where tickBefore is initialized, we only want to count it if we are swapping upwards. - // Use the same logic as above to decide whether we should count tickBefore or not. - tickBeforeInitialized = - ((self.tickBitmap(wordPos) & (1 << bitPos)) > 0) && - ((tickBefore % self.tickSpacing()) == 0) && - (tickBefore < tickAfter); - - if (wordPos < wordPosAfter || (wordPos == wordPosAfter && bitPos <= bitPosAfter)) { - wordPosLower = wordPos; - bitPosLower = bitPos; - wordPosHigher = wordPosAfter; - bitPosHigher = bitPosAfter; - } else { - wordPosLower = wordPosAfter; - bitPosLower = bitPosAfter; - wordPosHigher = wordPos; - bitPosHigher = bitPos; - } - } - - // Count the number of initialized ticks crossed by iterating through the tick bitmap. - // Our first mask should include the lower tick and everything to its left. - uint256 mask = type(uint256).max << bitPosLower; - while (wordPosLower <= wordPosHigher) { - // If we're on the final tick bitmap page, ensure we only count up to our - // ending tick. - if (wordPosLower == wordPosHigher) { - mask = mask & (type(uint256).max >> (255 - bitPosHigher)); - } - - uint256 masked = self.tickBitmap(wordPosLower) & mask; - initializedTicksCrossed += countOneBits(masked); - wordPosLower++; - // Reset our mask so we consider all bits on the next iteration. - mask = type(uint256).max; - } - - if (tickAfterInitialized) { - initializedTicksCrossed -= 1; - } - - if (tickBeforeInitialized) { - initializedTicksCrossed -= 1; - } - - return initializedTicksCrossed; - } - - function countOneBits(uint256 x) private pure returns (uint16) { - uint16 bits = 0; - while (x != 0) { - bits++; - x &= (x - 1); - } - return bits; - } -} diff --git a/src/dex/v3/periphery/libraries/PositionKey.sol b/src/dex/v3/periphery/libraries/PositionKey.sol deleted file mode 100644 index 72f2d8c8..00000000 --- a/src/dex/v3/periphery/libraries/PositionKey.sol +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -library PositionKey { - /// @dev Returns the key of the position in the core library - function compute(address owner, int24 tickLower, int24 tickUpper) internal pure returns (bytes32) { - return keccak256(abi.encodePacked(owner, tickLower, tickUpper)); - } -} diff --git a/src/dex/v3/periphery/libraries/PositionValue.sol b/src/dex/v3/periphery/libraries/PositionValue.sol deleted file mode 100644 index 5b991f9b..00000000 --- a/src/dex/v3/periphery/libraries/PositionValue.sol +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.6.8 <0.9.0; - -import "../../core/interfaces/IListaV3Pool.sol"; -import "../../core/interfaces/IListaV3Factory.sol"; -import "../../core/libraries/FixedPoint128.sol"; -import "../../core/libraries/TickMath.sol"; -import "../../core/libraries/Tick.sol"; -import "../interfaces/INonfungiblePositionManager.sol"; -import "./LiquidityAmounts.sol"; -import "./PoolAddress.sol"; -import "./PositionKey.sol"; - -/// @title Returns information about the token value held in a Lista V3 NFT -library PositionValue { - /// @notice Returns the total amounts of token0 and token1, i.e. the sum of fees and principal - /// that a given nonfungible position manager token is worth - /// @param positionManager The Lista V3 NonfungiblePositionManager - /// @param tokenId The tokenId of the token for which to get the total value - /// @param sqrtRatioX96 The square root price X96 for which to calculate the principal amounts - /// @return amount0 The total amount of token0 including principal and fees - /// @return amount1 The total amount of token1 including principal and fees - function total( - INonfungiblePositionManager positionManager, - uint256 tokenId, - uint160 sqrtRatioX96 - ) internal view returns (uint256 amount0, uint256 amount1) { - (uint256 amount0Principal, uint256 amount1Principal) = principal(positionManager, tokenId, sqrtRatioX96); - (uint256 amount0Fee, uint256 amount1Fee) = fees(positionManager, tokenId); - return (amount0Principal + amount0Fee, amount1Principal + amount1Fee); - } - - /// @notice Calculates the principal (currently acting as liquidity) owed to the token owner in the event - /// that the position is burned - /// @param positionManager The Lista V3 NonfungiblePositionManager - /// @param tokenId The tokenId of the token for which to get the total principal owed - /// @param sqrtRatioX96 The square root price X96 for which to calculate the principal amounts - /// @return amount0 The principal amount of token0 - /// @return amount1 The principal amount of token1 - function principal( - INonfungiblePositionManager positionManager, - uint256 tokenId, - uint160 sqrtRatioX96 - ) internal view returns (uint256 amount0, uint256 amount1) { - (, , , , , int24 tickLower, int24 tickUpper, uint128 liquidity, , , , ) = positionManager.positions(tokenId); - - return - LiquidityAmounts.getAmountsForLiquidity( - sqrtRatioX96, - TickMath.getSqrtRatioAtTick(tickLower), - TickMath.getSqrtRatioAtTick(tickUpper), - liquidity - ); - } - - struct FeeParams { - address token0; - address token1; - uint24 fee; - int24 tickLower; - int24 tickUpper; - uint128 liquidity; - uint256 positionFeeGrowthInside0LastX128; - uint256 positionFeeGrowthInside1LastX128; - uint256 tokensOwed0; - uint256 tokensOwed1; - } - - /// @notice Calculates the total fees owed to the token owner - /// @param positionManager The Lista V3 NonfungiblePositionManager - /// @param tokenId The tokenId of the token for which to get the total fees owed - /// @return amount0 The amount of fees owed in token0 - /// @return amount1 The amount of fees owed in token1 - function fees( - INonfungiblePositionManager positionManager, - uint256 tokenId - ) internal view returns (uint256 amount0, uint256 amount1) { - ( - , - , - address token0, - address token1, - uint24 fee, - int24 tickLower, - int24 tickUpper, - uint128 liquidity, - uint256 positionFeeGrowthInside0LastX128, - uint256 positionFeeGrowthInside1LastX128, - uint256 tokensOwed0, - uint256 tokensOwed1 - ) = positionManager.positions(tokenId); - - return - _fees( - positionManager, - FeeParams({ - token0: token0, - token1: token1, - fee: fee, - tickLower: tickLower, - tickUpper: tickUpper, - liquidity: liquidity, - positionFeeGrowthInside0LastX128: positionFeeGrowthInside0LastX128, - positionFeeGrowthInside1LastX128: positionFeeGrowthInside1LastX128, - tokensOwed0: tokensOwed0, - tokensOwed1: tokensOwed1 - }) - ); - } - - function _fees( - INonfungiblePositionManager positionManager, - FeeParams memory feeParams - ) private view returns (uint256 amount0, uint256 amount1) { - (uint256 poolFeeGrowthInside0LastX128, uint256 poolFeeGrowthInside1LastX128) = _getFeeGrowthInside( - IListaV3Pool( - IListaV3Factory(positionManager.factory()).getPool(feeParams.token0, feeParams.token1, feeParams.fee) - ), - feeParams.tickLower, - feeParams.tickUpper - ); - - amount0 = - FullMath.mulDiv( - poolFeeGrowthInside0LastX128 - feeParams.positionFeeGrowthInside0LastX128, - feeParams.liquidity, - FixedPoint128.Q128 - ) + - feeParams.tokensOwed0; - - amount1 = - FullMath.mulDiv( - poolFeeGrowthInside1LastX128 - feeParams.positionFeeGrowthInside1LastX128, - feeParams.liquidity, - FixedPoint128.Q128 - ) + - feeParams.tokensOwed1; - } - - function _getFeeGrowthInside( - IListaV3Pool pool, - int24 tickLower, - int24 tickUpper - ) private view returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) { - (, int24 tickCurrent, , , , , ) = pool.slot0(); - (, , uint256 lowerFeeGrowthOutside0X128, uint256 lowerFeeGrowthOutside1X128, , , , ) = pool.ticks(tickLower); - (, , uint256 upperFeeGrowthOutside0X128, uint256 upperFeeGrowthOutside1X128, , , , ) = pool.ticks(tickUpper); - - if (tickCurrent < tickLower) { - feeGrowthInside0X128 = lowerFeeGrowthOutside0X128 - upperFeeGrowthOutside0X128; - feeGrowthInside1X128 = lowerFeeGrowthOutside1X128 - upperFeeGrowthOutside1X128; - } else if (tickCurrent < tickUpper) { - uint256 feeGrowthGlobal0X128 = pool.feeGrowthGlobal0X128(); - uint256 feeGrowthGlobal1X128 = pool.feeGrowthGlobal1X128(); - feeGrowthInside0X128 = feeGrowthGlobal0X128 - lowerFeeGrowthOutside0X128 - upperFeeGrowthOutside0X128; - feeGrowthInside1X128 = feeGrowthGlobal1X128 - lowerFeeGrowthOutside1X128 - upperFeeGrowthOutside1X128; - } else { - feeGrowthInside0X128 = upperFeeGrowthOutside0X128 - lowerFeeGrowthOutside0X128; - feeGrowthInside1X128 = upperFeeGrowthOutside1X128 - lowerFeeGrowthOutside1X128; - } - } -} diff --git a/src/dex/v3/periphery/libraries/SafeERC20Namer.sol b/src/dex/v3/periphery/libraries/SafeERC20Namer.sol deleted file mode 100644 index a6dea02a..00000000 --- a/src/dex/v3/periphery/libraries/SafeERC20Namer.sol +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -// from https://github.com/Lista/solidity-lib/blob/master/contracts/libraries/SafeERC20Namer.sol -// modified for solidity 0.8 - -pragma solidity 0.8.34; - -import "./AddressStringUtil.sol"; - -// produces token descriptors from inconsistent or absent ERC20 symbol implementations that can return string or bytes32 -// this library will always produce a string symbol to represent the token -library SafeERC20Namer { - function bytes32ToString(bytes32 x) private pure returns (string memory) { - bytes memory bytesString = new bytes(32); - uint256 charCount = 0; - for (uint256 j = 0; j < 32; j++) { - bytes1 char = x[j]; - if (char != 0) { - bytesString[charCount] = char; - charCount++; - } - } - bytes memory bytesStringTrimmed = new bytes(charCount); - for (uint256 j = 0; j < charCount; j++) { - bytesStringTrimmed[j] = bytesString[j]; - } - return string(bytesStringTrimmed); - } - - // assumes the data is in position 2 - function parseStringData(bytes memory b) private pure returns (string memory) { - uint256 charCount = 0; - // first parse the charCount out of the data - for (uint256 i = 32; i < 64; i++) { - charCount <<= 8; - charCount += uint8(b[i]); - } - - bytes memory bytesStringTrimmed = new bytes(charCount); - for (uint256 i = 0; i < charCount; i++) { - bytesStringTrimmed[i] = b[i + 64]; - } - - return string(bytesStringTrimmed); - } - - // uses a heuristic to produce a token name from the address - // the heuristic returns the full hex of the address string in upper case - function addressToName(address token) private pure returns (string memory) { - return AddressStringUtil.toAsciiString(token, 40); - } - - // uses a heuristic to produce a token symbol from the address - // the heuristic returns the first 6 hex of the address string in upper case - function addressToSymbol(address token) private pure returns (string memory) { - return AddressStringUtil.toAsciiString(token, 6); - } - - // calls an external view token contract method that returns a symbol or name, and parses the output into a string - function callAndParseStringReturn(address token, bytes4 selector) private view returns (string memory) { - (bool success, bytes memory data) = token.staticcall(abi.encodeWithSelector(selector)); - // if not implemented, or returns empty data, return empty string - if (!success || data.length == 0) { - return ""; - } - // bytes32 data always has length 32 - if (data.length == 32) { - bytes32 decoded = abi.decode(data, (bytes32)); - return bytes32ToString(decoded); - } else if (data.length > 64) { - return abi.decode(data, (string)); - } - return ""; - } - - // attempts to extract the token symbol. if it does not implement symbol, returns a symbol derived from the address - function tokenSymbol(address token) internal view returns (string memory) { - // 0x95d89b41 = bytes4(keccak256("symbol()")) - string memory symbol = callAndParseStringReturn(token, 0x95d89b41); - if (bytes(symbol).length == 0) { - // fallback to 6 uppercase hex of address - return addressToSymbol(token); - } - return symbol; - } - - // attempts to extract the token name. if it does not implement name, returns a name derived from the address - function tokenName(address token) internal view returns (string memory) { - // 0x06fdde03 = bytes4(keccak256("name()")) - string memory name = callAndParseStringReturn(token, 0x06fdde03); - if (bytes(name).length == 0) { - // fallback to full hex of address - return addressToName(token); - } - return name; - } -} diff --git a/src/dex/v3/periphery/libraries/SqrtPriceMathPartial.sol b/src/dex/v3/periphery/libraries/SqrtPriceMathPartial.sol deleted file mode 100644 index 22510c01..00000000 --- a/src/dex/v3/periphery/libraries/SqrtPriceMathPartial.sol +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -import "../../core/libraries/FullMath.sol"; -import "../../core/libraries/UnsafeMath.sol"; -import "../../core/libraries/FixedPoint96.sol"; - -/// @title Functions based on Q64.96 sqrt price and liquidity -/// @notice Exposes two functions from @lista/v3-core SqrtPriceMath -/// that use square root of price as a Q64.96 and liquidity to compute deltas -library SqrtPriceMathPartial { - /// @notice Gets the amount0 delta between two prices - /// @dev Calculates liquidity / sqrt(lower) - liquidity / sqrt(upper), - /// i.e. liquidity * (sqrt(upper) - sqrt(lower)) / (sqrt(upper) * sqrt(lower)) - /// @param sqrtRatioAX96 A sqrt price - /// @param sqrtRatioBX96 Another sqrt price - /// @param liquidity The amount of usable liquidity - /// @param roundUp Whether to round the amount up or down - /// @return amount0 Amount of token0 required to cover a position of size liquidity between the two passed prices - function getAmount0Delta( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint128 liquidity, - bool roundUp - ) internal pure returns (uint256 amount0) { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - - uint256 numerator1 = uint256(liquidity) << FixedPoint96.RESOLUTION; - uint256 numerator2 = sqrtRatioBX96 - sqrtRatioAX96; - - require(sqrtRatioAX96 > 0); - - return - roundUp - ? UnsafeMath.divRoundingUp(FullMath.mulDivRoundingUp(numerator1, numerator2, sqrtRatioBX96), sqrtRatioAX96) - : FullMath.mulDiv(numerator1, numerator2, sqrtRatioBX96) / sqrtRatioAX96; - } - - /// @notice Gets the amount1 delta between two prices - /// @dev Calculates liquidity * (sqrt(upper) - sqrt(lower)) - /// @param sqrtRatioAX96 A sqrt price - /// @param sqrtRatioBX96 Another sqrt price - /// @param liquidity The amount of usable liquidity - /// @param roundUp Whether to round the amount up, or down - /// @return amount1 Amount of token1 required to cover a position of size liquidity between the two passed prices - function getAmount1Delta( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint128 liquidity, - bool roundUp - ) internal pure returns (uint256 amount1) { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - - return - roundUp - ? FullMath.mulDivRoundingUp(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96) - : FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96); - } -} diff --git a/src/dex/v3/periphery/libraries/TokenRatioSortOrder.sol b/src/dex/v3/periphery/libraries/TokenRatioSortOrder.sol deleted file mode 100644 index 2ebeb96a..00000000 --- a/src/dex/v3/periphery/libraries/TokenRatioSortOrder.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.34; - -library TokenRatioSortOrder { - int256 constant NUMERATOR_MOST = 300; - int256 constant NUMERATOR_MORE = 200; - int256 constant NUMERATOR = 100; - - int256 constant DENOMINATOR_MOST = -300; - int256 constant DENOMINATOR_MORE = -200; - int256 constant DENOMINATOR = -100; -} diff --git a/src/dex/v3/periphery/libraries/TransferHelper.sol b/src/dex/v3/periphery/libraries/TransferHelper.sol deleted file mode 100644 index 23d380b0..00000000 --- a/src/dex/v3/periphery/libraries/TransferHelper.sol +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.6.0; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -library TransferHelper { - /// @notice Transfers tokens from the targeted address to the given destination - /// @notice Errors with 'STF' if transfer fails - /// @param token The contract address of the token to be transferred - /// @param from The originating address from which the tokens will be transferred - /// @param to The destination address of the transfer - /// @param value The amount to be transferred - function safeTransferFrom(address token, address from, address to, uint256 value) internal { - (bool success, bytes memory data) = token.call( - abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value) - ); - require(success && (data.length == 0 || abi.decode(data, (bool))), "STF"); - } - - /// @notice Transfers tokens from msg.sender to a recipient - /// @dev Errors with ST if transfer fails - /// @param token The contract address of the token which will be transferred - /// @param to The recipient of the transfer - /// @param value The value of the transfer - function safeTransfer(address token, address to, uint256 value) internal { - (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), "ST"); - } - - /// @notice Approves the stipulated contract to spend the given allowance in the given token - /// @dev Errors with 'SA' if transfer fails - /// @param token The contract address of the token to be approved - /// @param to The target of the approval - /// @param value The amount of the given token the target will be allowed to spend - function safeApprove(address token, address to, uint256 value) internal { - (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.approve.selector, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), "SA"); - } - - /// @notice Transfers ETH to the recipient address - /// @dev Fails with `STE` - /// @param to The destination of the transfer - /// @param value The value to be transferred - function safeTransferETH(address to, uint256 value) internal { - (bool success, ) = to.call{ value: value }(new bytes(0)); - require(success, "STE"); - } -} diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol index 0ca8e15a..8e035bb3 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/V3Provider.sol @@ -8,17 +8,17 @@ import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/ 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 "../dex/v3/core/libraries/TickMath.sol"; -import { SqrtPriceMath } from "../dex/v3/core/libraries/SqrtPriceMath.sol"; -import { LiquidityAmounts } from "../dex/v3/periphery/libraries/LiquidityAmounts.sol"; +import { TickMath } from "./libraries/TickMath.sol"; +import { SqrtPriceMath } from "./libraries/SqrtPriceMath.sol"; +import { LiquidityAmounts } from "./libraries/LiquidityAmounts.sol"; import { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; import { IOracle, TokenConfig } from "moolah/interfaces/IOracle.sol"; -import { INonfungiblePositionManager } from "../dex/v3/periphery/interfaces/INonfungiblePositionManager.sol"; -import { IListaV3Factory } from "../dex/v3/core/interfaces/IListaV3Factory.sol"; -import { IListaV3Pool } from "../dex/v3/core/interfaces/IListaV3Pool.sol"; +import { INonfungiblePositionManager } from "./interfaces/INonfungiblePositionManager.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 { IV3Provider } from "./interfaces/IV3Provider.sol"; import { ISlisBNBxMinter } from "../utils/interfaces/ISlisBNBx.sol"; @@ -39,8 +39,12 @@ import { ISlisBNBxMinter } from "../utils/interfaces/ISlisBNBx.sol"; * - Fees are compounded into the position before every deposit/withdraw/rebalance. * - Only Moolah may transfer shares (prevents bypassing the vault on withdrawal). * - * Dependencies (add to lib/ or remappings): - * uniswap/v3-core - TickMath + * Dependencies: + * lib/lista-v3 (submodule) - IListaV3Factory / IListaV3Pool interfaces + * src/provider/libraries/* - 0.8.34 ports of the V3 math libs (TickMath, SqrtPriceMath, + * LiquidityAmounts, …); the lista-v3 originals are 0.7.6 and + * cannot compile here. + * src/provider/interfaces/INonfungiblePositionManager.sol - minimal local NPM interface. */ contract V3Provider is ERC20Upgradeable, 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/dex/v3/core/libraries/FixedPoint96.sol b/src/provider/libraries/FixedPoint96.sol similarity index 100% rename from src/dex/v3/core/libraries/FixedPoint96.sol rename to src/provider/libraries/FixedPoint96.sol diff --git a/src/dex/v3/core/libraries/FullMath.sol b/src/provider/libraries/FullMath.sol similarity index 100% rename from src/dex/v3/core/libraries/FullMath.sol rename to src/provider/libraries/FullMath.sol diff --git a/src/dex/v3/periphery/libraries/LiquidityAmounts.sol b/src/provider/libraries/LiquidityAmounts.sol similarity index 98% rename from src/dex/v3/periphery/libraries/LiquidityAmounts.sol rename to src/provider/libraries/LiquidityAmounts.sol index 6fd9f4ae..c0247478 100644 --- a/src/dex/v3/periphery/libraries/LiquidityAmounts.sol +++ b/src/provider/libraries/LiquidityAmounts.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity >=0.5.0; -import "../../core/libraries/FullMath.sol"; -import "../../core/libraries/FixedPoint96.sol"; +import "./FullMath.sol"; +import "./FixedPoint96.sol"; /// @title Liquidity amount functions /// @notice Provides functions for computing liquidity amounts from token amounts and prices diff --git a/src/dex/v3/core/libraries/SafeCast.sol b/src/provider/libraries/SafeCast.sol similarity index 100% rename from src/dex/v3/core/libraries/SafeCast.sol rename to src/provider/libraries/SafeCast.sol diff --git a/src/dex/v3/core/libraries/SqrtPriceMath.sol b/src/provider/libraries/SqrtPriceMath.sol similarity index 100% rename from src/dex/v3/core/libraries/SqrtPriceMath.sol rename to src/provider/libraries/SqrtPriceMath.sol diff --git a/src/dex/v3/core/libraries/TickMath.sol b/src/provider/libraries/TickMath.sol similarity index 100% rename from src/dex/v3/core/libraries/TickMath.sol rename to src/provider/libraries/TickMath.sol diff --git a/src/dex/v3/core/libraries/UnsafeMath.sol b/src/provider/libraries/UnsafeMath.sol similarity index 100% rename from src/dex/v3/core/libraries/UnsafeMath.sol rename to src/provider/libraries/UnsafeMath.sol diff --git a/test/dex/v3/ListaV3.t.sol b/test/dex/v3/ListaV3.t.sol deleted file mode 100644 index 245005bb..00000000 --- a/test/dex/v3/ListaV3.t.sol +++ /dev/null @@ -1,576 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.34; - -import "forge-std/Test.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -import { ListaV3Factory } from "../../../src/dex/v3/core/ListaV3Factory.sol"; -import { ListaV3Pool } from "../../../src/dex/v3/core/ListaV3Pool.sol"; -import { IListaV3Pool } from "../../../src/dex/v3/core/interfaces/IListaV3Pool.sol"; -import { IListaV3MintCallback } from "../../../src/dex/v3/core/interfaces/callback/IListaV3MintCallback.sol"; -import { IListaV3SwapCallback } from "../../../src/dex/v3/core/interfaces/callback/IListaV3SwapCallback.sol"; -import { TickMath } from "../../../src/dex/v3/core/libraries/TickMath.sol"; -import { LiquidityAmounts } from "../../../src/dex/v3/periphery/libraries/LiquidityAmounts.sol"; -import { NonfungiblePositionManager } from "../../../src/dex/v3/periphery/NonfungiblePositionManager.sol"; -import { INonfungiblePositionManager } from "../../../src/dex/v3/periphery/interfaces/INonfungiblePositionManager.sol"; -import { PoolAddress } from "../../../src/dex/v3/periphery/libraries/PoolAddress.sol"; -import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - -/* ─────────────────────── Mock ERC20 ─────────────────────── */ - -contract MockERC20 is ERC20 { - uint8 private _dec; - - constructor(string memory name, string memory symbol, uint8 dec_) ERC20(name, symbol) { - _dec = dec_; - } - - function decimals() public view override returns (uint8) { - return _dec; - } - - function mint(address to, uint256 amount) external { - _mint(to, amount); - } -} - -/* ───────────── Mock WETH9 (needed by NPM constructor) ───────────── */ - -contract MockWETH9 is MockERC20 { - constructor() MockERC20("Wrapped BNB", "WBNB", 18) {} - - function deposit() external payable { - _mint(msg.sender, msg.value); - } - - function withdraw(uint256 amount) external { - _burn(msg.sender, amount); - (bool ok, ) = msg.sender.call{ value: amount }(""); - require(ok); - } - - receive() external payable { - _mint(msg.sender, msg.value); - } -} - -/* ──────────── Mock token descriptor (returns empty URI) ──────────── */ - -contract MockTokenDescriptor { - function tokenURI(address, uint256) external pure returns (string memory) { - return ""; - } -} - -/* ═══════════════════════════════════════════════════════════════════ - Test Suite - ═══════════════════════════════════════════════════════════════════ */ - -contract ListaV3Test is Test, IListaV3MintCallback, IListaV3SwapCallback { - ListaV3Factory factory; - NonfungiblePositionManager npm; - MockWETH9 weth; - MockERC20 tokenA; - MockERC20 tokenB; - address token0; - address token1; - - uint24 constant FEE = 3000; - int24 constant TICK_SPACING = 60; - - // TickMath constants - uint160 constant MIN_SQRT_RATIO = 4295128739; - uint160 constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; - - function setUp() public { - // Deploy Factory behind UUPS proxy - ListaV3Factory factoryImpl = new ListaV3Factory(); - factory = ListaV3Factory( - address(new ERC1967Proxy(address(factoryImpl), abi.encodeCall(ListaV3Factory.initialize, (address(this))))) - ); - - weth = new MockWETH9(); - MockTokenDescriptor descriptor = new MockTokenDescriptor(); - - // Deploy NPM behind UUPS proxy - NonfungiblePositionManager npmImpl = new NonfungiblePositionManager(); - npm = NonfungiblePositionManager( - payable( - new ERC1967Proxy( - address(npmImpl), - abi.encodeCall( - NonfungiblePositionManager.initialize, - (address(factory), address(weth), address(descriptor), address(this), factory.poolInitCodeHash()) - ) - ) - ) - ); - - // Deploy tokens and sort - tokenA = new MockERC20("Token A", "TKA", 18); - tokenB = new MockERC20("Token B", "TKB", 18); - (token0, token1) = address(tokenA) < address(tokenB) - ? (address(tokenA), address(tokenB)) - : (address(tokenB), address(tokenA)); - } - - /* ─────────────────────── helpers ─────────────────────── */ - - function _createAndInitPool(uint160 sqrtPriceX96) internal returns (IListaV3Pool pool) { - address poolAddr = factory.createPool(token0, token1, FEE); - pool = IListaV3Pool(poolAddr); - pool.initialize(sqrtPriceX96); - } - - function _mintTokens(address to, uint256 amount0, uint256 amount1) internal { - MockERC20(token0).mint(to, amount0); - MockERC20(token1).mint(to, amount1); - } - - /* ─────────────── V3 pool callbacks (for direct pool interaction) ─────────────── */ - - function listaV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override { - if (amount0Owed > 0) IERC20(token0).transfer(msg.sender, amount0Owed); - if (amount1Owed > 0) IERC20(token1).transfer(msg.sender, amount1Owed); - } - - function listaV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external override { - if (amount0Delta > 0) IERC20(token0).transfer(msg.sender, uint256(amount0Delta)); - if (amount1Delta > 0) IERC20(token1).transfer(msg.sender, uint256(amount1Delta)); - } - - /* ═══════════════════════════════════════════════════════════ - Factory Tests - ═══════════════════════════════════════════════════════════ */ - - function test_factory_defaultFeeTiers() public view { - assertEq(factory.feeAmountTickSpacing(500), 10); - assertEq(factory.feeAmountTickSpacing(3000), 60); - assertEq(factory.feeAmountTickSpacing(10000), 200); - assertEq(factory.feeAmountTickSpacing(100), 0, "unsupported fee should return 0"); - } - - function test_factory_owner() public view { - assertEq(factory.owner(), address(this)); - } - - function test_factory_createPool() public { - address pool = factory.createPool(token0, token1, FEE); - assertTrue(pool != address(0), "pool should be created"); - assertEq(factory.getPool(token0, token1, FEE), pool); - assertEq(factory.getPool(token1, token0, FEE), pool, "reverse lookup should work"); - } - - function test_factory_createPool_revertsOnDuplicate() public { - factory.createPool(token0, token1, FEE); - vm.expectRevert(); - factory.createPool(token0, token1, FEE); - } - - function test_factory_createPool_revertsOnSameToken() public { - vm.expectRevert(); - factory.createPool(token0, token0, FEE); - } - - function test_factory_createPool_revertsOnUnsupportedFee() public { - vm.expectRevert(); - factory.createPool(token0, token1, 100); // 100 not enabled - } - - function test_factory_enableFeeAmount() public { - factory.enableFeeAmount(100, 1); - assertEq(factory.feeAmountTickSpacing(100), 1); - - // Can now create a pool with the new fee - address pool = factory.createPool(token0, token1, 100); - assertTrue(pool != address(0)); - } - - function test_factory_setOwner() public { - address newOwner = makeAddr("newOwner"); - factory.setOwner(newOwner); - assertEq(factory.owner(), newOwner); - } - - function test_factory_setOwner_revertsIfNotOwner() public { - vm.prank(makeAddr("rando")); - vm.expectRevert(); - factory.setOwner(makeAddr("newOwner")); - } - - /* ═══════════════════════════════════════════════════════════ - Pool Tests - ═══════════════════════════════════════════════════════════ */ - - function test_pool_initialize() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; // sqrt(1) * 2^96 ≈ price = 1.0 - IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); - - (uint160 sqrtPrice, int24 tick, , , , , ) = pool.slot0(); - assertEq(sqrtPrice, sqrtPriceX96); - assertEq(tick, 0, "tick should be 0 at price 1.0"); - assertEq(pool.token0(), token0); - assertEq(pool.token1(), token1); - assertEq(pool.fee(), FEE); - assertEq(pool.tickSpacing(), TICK_SPACING); - } - - function test_pool_initialize_revertsOnDouble() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; - IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); - vm.expectRevert(); - pool.initialize(sqrtPriceX96); - } - - function test_pool_mint() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; // price = 1.0 - IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); - - // Mint liquidity around tick 0 (price 1.0) - int24 tickLower = -TICK_SPACING; - int24 tickUpper = TICK_SPACING; - uint128 liquidityAmount = 1_000_000; - - _mintTokens(address(this), 10 ether, 10 ether); - - (uint256 amount0, uint256 amount1) = pool.mint(address(this), tickLower, tickUpper, liquidityAmount, ""); - assertGt(amount0, 0, "should consume token0"); - assertGt(amount1, 0, "should consume token1"); - - // Verify position - bytes32 posKey = keccak256(abi.encodePacked(address(this), tickLower, tickUpper)); - (uint128 liq, , , , ) = pool.positions(posKey); - assertEq(liq, liquidityAmount, "position liquidity should match"); - } - - function test_pool_swap() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; - IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); - - // Add wide-range liquidity - int24 tickLower = -600; - int24 tickUpper = 600; - uint128 liquidityAmount = 100_000_000_000; - _mintTokens(address(this), 100 ether, 100 ether); - pool.mint(address(this), tickLower, tickUpper, liquidityAmount, ""); - - // Swap token0 → token1 (zeroForOne = true, pushes price down) - uint256 swapAmount = 0.1 ether; - _mintTokens(address(this), swapAmount, 0); - uint256 bal1Before = IERC20(token1).balanceOf(address(this)); - - pool.swap(address(this), true, int256(swapAmount), MIN_SQRT_RATIO + 1, ""); - - uint256 bal1After = IERC20(token1).balanceOf(address(this)); - assertGt(bal1After, bal1Before, "should receive token1 from swap"); - - // Price should have moved down - (uint160 newSqrtPrice, , , , , , ) = pool.slot0(); - assertLt(newSqrtPrice, sqrtPriceX96, "price should decrease after zeroForOne swap"); - } - - function test_pool_burn_and_collect() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; - IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); - - int24 tickLower = -TICK_SPACING; - int24 tickUpper = TICK_SPACING; - uint128 liquidityAmount = 1_000_000; - _mintTokens(address(this), 10 ether, 10 ether); - pool.mint(address(this), tickLower, tickUpper, liquidityAmount, ""); - - // Burn all liquidity - (uint256 amount0, uint256 amount1) = pool.burn(tickLower, tickUpper, liquidityAmount); - assertGt(amount0 + amount1, 0, "should return tokens on burn"); - - // Collect - uint256 bal0Before = IERC20(token0).balanceOf(address(this)); - uint256 bal1Before = IERC20(token1).balanceOf(address(this)); - pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max); - uint256 collected0 = IERC20(token0).balanceOf(address(this)) - bal0Before; - uint256 collected1 = IERC20(token1).balanceOf(address(this)) - bal1Before; - - assertEq(collected0, amount0, "collected0 should match burned amount0"); - assertEq(collected1, amount1, "collected1 should match burned amount1"); - } - - function test_pool_observe() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; - IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); - - // Need to increase cardinality for TWAP - pool.increaseObservationCardinalityNext(10); - - // Add liquidity so the pool is active - _mintTokens(address(this), 10 ether, 10 ether); - pool.mint(address(this), -TICK_SPACING, TICK_SPACING, 1_000_000, ""); - - // Observation at time 0 should work - uint32[] memory secondsAgos = new uint32[](2); - secondsAgos[0] = 0; - secondsAgos[1] = 0; - (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos); - assertEq(tickCumulatives[0], tickCumulatives[1], "same timestamp should give same cumulative"); - } - - function test_pool_feeGrowth_afterSwaps() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; - IListaV3Pool pool = _createAndInitPool(sqrtPriceX96); - - // Add liquidity - int24 tickLower = -600; - int24 tickUpper = 600; - _mintTokens(address(this), 100 ether, 100 ether); - pool.mint(address(this), tickLower, tickUpper, 100_000_000_000, ""); - - // Perform multiple swaps to generate fees - for (uint256 i = 0; i < 5; i++) { - _mintTokens(address(this), 1 ether, 1 ether); - pool.swap(address(this), true, int256(0.5 ether), MIN_SQRT_RATIO + 1, ""); - pool.swap(address(this), false, int256(0.5 ether), MAX_SQRT_RATIO - 1, ""); - } - - // Fee growth should be non-zero - uint256 fg0 = pool.feeGrowthGlobal0X128(); - uint256 fg1 = pool.feeGrowthGlobal1X128(); - assertGt(fg0 + fg1, 0, "fees should have accrued from swaps"); - } - - /* ═══════════════════════════════════════════════════════════ - NonfungiblePositionManager Tests - ═══════════════════════════════════════════════════════════ */ - - function test_npm_mintPosition() public { - // Create and init pool directly via factory (NPM uses computeAddress internally) - uint160 sqrtPriceX96 = 79228162514264337593543950336; - address poolAddr = factory.createPool(token0, token1, FEE); - IListaV3Pool(poolAddr).initialize(sqrtPriceX96); - - // Mint tokens and approve NPM - _mintTokens(address(this), 10 ether, 10 ether); - IERC20(token0).approve(address(npm), 10 ether); - IERC20(token1).approve(address(npm), 10 ether); - - (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) = npm.mint( - INonfungiblePositionManager.MintParams({ - token0: token0, - token1: token1, - fee: FEE, - tickLower: -TICK_SPACING, - tickUpper: TICK_SPACING, - amount0Desired: 1 ether, - amount1Desired: 1 ether, - amount0Min: 0, - amount1Min: 0, - recipient: address(this), - deadline: block.timestamp - }) - ); - - assertEq(tokenId, 1, "first token ID should be 1"); - assertGt(liquidity, 0, "should have minted liquidity"); - assertGt(amount0 + amount1, 0, "should have consumed tokens"); - assertEq(npm.ownerOf(tokenId), address(this)); - } - - function test_npm_increaseLiquidity() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; - address poolAddr = factory.createPool(token0, token1, FEE); - IListaV3Pool(poolAddr).initialize(sqrtPriceX96); - - _mintTokens(address(this), 20 ether, 20 ether); - IERC20(token0).approve(address(npm), 20 ether); - IERC20(token1).approve(address(npm), 20 ether); - - (uint256 tokenId, uint128 liqBefore, , ) = npm.mint( - INonfungiblePositionManager.MintParams({ - token0: token0, - token1: token1, - fee: FEE, - tickLower: -TICK_SPACING, - tickUpper: TICK_SPACING, - amount0Desired: 1 ether, - amount1Desired: 1 ether, - amount0Min: 0, - amount1Min: 0, - recipient: address(this), - deadline: block.timestamp - }) - ); - - (uint128 addedLiq, , ) = npm.increaseLiquidity( - INonfungiblePositionManager.IncreaseLiquidityParams({ - tokenId: tokenId, - amount0Desired: 1 ether, - amount1Desired: 1 ether, - amount0Min: 0, - amount1Min: 0, - deadline: block.timestamp - }) - ); - - assertGt(addedLiq, 0, "should add liquidity"); - - // Check total via positions() - (, , , , , , , uint128 totalLiq, , , , ) = npm.positions(tokenId); - assertEq(totalLiq, liqBefore + addedLiq, "total liquidity should be sum"); - } - - function test_npm_decreaseLiquidity_and_collect() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; - address poolAddr = factory.createPool(token0, token1, FEE); - IListaV3Pool(poolAddr).initialize(sqrtPriceX96); - - _mintTokens(address(this), 10 ether, 10 ether); - IERC20(token0).approve(address(npm), 10 ether); - IERC20(token1).approve(address(npm), 10 ether); - - (uint256 tokenId, uint128 liquidity, , ) = npm.mint( - INonfungiblePositionManager.MintParams({ - token0: token0, - token1: token1, - fee: FEE, - tickLower: -TICK_SPACING, - tickUpper: TICK_SPACING, - amount0Desired: 1 ether, - amount1Desired: 1 ether, - amount0Min: 0, - amount1Min: 0, - recipient: address(this), - deadline: block.timestamp - }) - ); - - // Decrease all liquidity - (uint256 dec0, uint256 dec1) = npm.decreaseLiquidity( - INonfungiblePositionManager.DecreaseLiquidityParams({ - tokenId: tokenId, - liquidity: liquidity, - amount0Min: 0, - amount1Min: 0, - deadline: block.timestamp - }) - ); - assertGt(dec0 + dec1, 0, "should return tokens"); - - // Collect - uint256 bal0Before = IERC20(token0).balanceOf(address(this)); - uint256 bal1Before = IERC20(token1).balanceOf(address(this)); - npm.collect( - INonfungiblePositionManager.CollectParams({ - tokenId: tokenId, - recipient: address(this), - amount0Max: type(uint128).max, - amount1Max: type(uint128).max - }) - ); - uint256 collected0 = IERC20(token0).balanceOf(address(this)) - bal0Before; - uint256 collected1 = IERC20(token1).balanceOf(address(this)) - bal1Before; - assertGt(collected0 + collected1, 0, "should collect tokens"); - } - - function test_npm_burn() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; - address poolAddr = factory.createPool(token0, token1, FEE); - IListaV3Pool(poolAddr).initialize(sqrtPriceX96); - - _mintTokens(address(this), 10 ether, 10 ether); - IERC20(token0).approve(address(npm), 10 ether); - IERC20(token1).approve(address(npm), 10 ether); - - (uint256 tokenId, uint128 liquidity, , ) = npm.mint( - INonfungiblePositionManager.MintParams({ - token0: token0, - token1: token1, - fee: FEE, - tickLower: -TICK_SPACING, - tickUpper: TICK_SPACING, - amount0Desired: 1 ether, - amount1Desired: 1 ether, - amount0Min: 0, - amount1Min: 0, - recipient: address(this), - deadline: block.timestamp - }) - ); - - // Must decrease + collect before burn - npm.decreaseLiquidity( - INonfungiblePositionManager.DecreaseLiquidityParams({ - tokenId: tokenId, - liquidity: liquidity, - amount0Min: 0, - amount1Min: 0, - deadline: block.timestamp - }) - ); - npm.collect( - INonfungiblePositionManager.CollectParams({ - tokenId: tokenId, - recipient: address(this), - amount0Max: type(uint128).max, - amount1Max: type(uint128).max - }) - ); - - npm.burn(tokenId); - - vm.expectRevert(); - npm.ownerOf(tokenId); - } - - function test_npm_positions_returnsCorrectData() public { - uint160 sqrtPriceX96 = 79228162514264337593543950336; - address poolAddr = factory.createPool(token0, token1, FEE); - IListaV3Pool(poolAddr).initialize(sqrtPriceX96); - - _mintTokens(address(this), 10 ether, 10 ether); - IERC20(token0).approve(address(npm), 10 ether); - IERC20(token1).approve(address(npm), 10 ether); - - int24 tickLower = -120; - int24 tickUpper = 120; - - (uint256 tokenId, uint128 expectedLiq, , ) = npm.mint( - INonfungiblePositionManager.MintParams({ - token0: token0, - token1: token1, - fee: FEE, - tickLower: tickLower, - tickUpper: tickUpper, - amount0Desired: 1 ether, - amount1Desired: 1 ether, - amount0Min: 0, - amount1Min: 0, - recipient: address(this), - deadline: block.timestamp - }) - ); - - (, , address t0, address t1, uint24 fee, int24 tl, int24 tu, uint128 liq, , , , ) = npm.positions(tokenId); - - assertEq(t0, token0); - assertEq(t1, token1); - assertEq(fee, FEE); - assertEq(tl, tickLower); - assertEq(tu, tickUpper); - assertEq(liq, expectedLiq); - } - - /* ═══════════════════════════════════════════════════════════ - Init Code Hash (utility test) - ═══════════════════════════════════════════════════════════ */ - - function test_poolInitCodeHash() public { - bytes32 hash = factory.poolInitCodeHash(); - assertGt(uint256(hash), 0, "poolInitCodeHash should be non-zero"); - emit log_named_bytes32("poolInitCodeHash", hash); - - // Verify the hash correctly derives pool addresses: - // create a pool, then check computeAddress matches - address pool = factory.createPool(token0, token1, FEE); - address derived = PoolAddress.computeAddress(address(factory), PoolAddress.PoolKey(token0, token1, FEE), hash); - assertEq(derived, pool, "computeAddress should match actual pool address"); - } -} diff --git a/test/liquidator/V3Liquidator.t.sol b/test/liquidator/V3Liquidator.t.sol index b4862d8f..c5a1d568 100644 --- a/test/liquidator/V3Liquidator.t.sol +++ b/test/liquidator/V3Liquidator.t.sol @@ -8,7 +8,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { V3Provider } from "../../src/provider/V3Provider.sol"; import { V3Liquidator } from "../../src/liquidator/V3Liquidator.sol"; -import { IListaV3Pool } from "../../src/dex/v3/core/interfaces/IListaV3Pool.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"; diff --git a/test/provider/V3Provider.t.sol b/test/provider/V3Provider.t.sol index 2f58aeca..84af8e4e 100644 --- a/test/provider/V3Provider.t.sol +++ b/test/provider/V3Provider.t.sol @@ -7,7 +7,7 @@ import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { V3Provider } from "../../src/provider/V3Provider.sol"; -import { IListaV3Pool } from "../../src/dex/v3/core/interfaces/IListaV3Pool.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"; From b146ad17d24d31a5669ede2c175ab5a2417528d4 Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:07:28 +0800 Subject: [PATCH 07/17] refactor: source V3 math libs from audited lista-dao-contracts submodule Replace the 7 locally-vendored 0.8.34 math-lib ports under src/provider/libraries/ with the audited, 0.8-native libraries from the lista-dao-contracts submodule (pinned at 3ac9ef2, matching master). - Register lib/lista-dao-contracts.git as a submodule + `lista-dao-contracts/` remapping. The entry mirrors origin/master exactly, so it is conflict-free when this branch later integrates with master. - V3Provider imports TickMath and LiquidityAmounts from the submodule. - _getAmountsForLiquidity now delegates to LiquidityAmounts.getAmountsForLiquidity, which is mathematically identical to the previous SqrtPriceMath.getAmount{0,1}Delta (roundUp=false) path. This removes the need for SqrtPriceMath (which lista-dao-contracts does not ship a 0.8 version of) and its deps. - Delete all 7 local ports: TickMath, SqrtPriceMath, LiquidityAmounts, FullMath, FixedPoint96, SafeCast, UnsafeMath. Verified: forge build clean; 84 V3Provider + 27 V3Liquidator tests pass (covering preview/withdraw/rebalance across below/inside/above-range, which exercises the swapped getAmountsForLiquidity path). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitmodules | 4 + lib/lista-dao-contracts.git | 1 + remappings.txt | 3 +- src/provider/V3Provider.sol | 34 ++- src/provider/libraries/FixedPoint96.sol | 10 - src/provider/libraries/FullMath.sol | 120 ---------- src/provider/libraries/LiquidityAmounts.sol | 142 ------------ src/provider/libraries/SafeCast.sol | 28 --- src/provider/libraries/SqrtPriceMath.sol | 234 -------------------- src/provider/libraries/TickMath.sol | 216 ------------------ src/provider/libraries/UnsafeMath.sol | 17 -- 11 files changed, 20 insertions(+), 789 deletions(-) create mode 160000 lib/lista-dao-contracts.git delete mode 100644 src/provider/libraries/FixedPoint96.sol delete mode 100644 src/provider/libraries/FullMath.sol delete mode 100644 src/provider/libraries/LiquidityAmounts.sol delete mode 100644 src/provider/libraries/SafeCast.sol delete mode 100644 src/provider/libraries/SqrtPriceMath.sol delete mode 100644 src/provider/libraries/TickMath.sol delete mode 100644 src/provider/libraries/UnsafeMath.sol diff --git a/.gitmodules b/.gitmodules index a49f7cf9..5ba86499 100644 --- a/.gitmodules +++ b/.gitmodules @@ -21,3 +21,7 @@ [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 + branch = 3ac9ef279a7c89908a6e03ed8a1c52738f94601e diff --git a/lib/lista-dao-contracts.git b/lib/lista-dao-contracts.git new file mode 160000 index 00000000..3ac9ef27 --- /dev/null +++ b/lib/lista-dao-contracts.git @@ -0,0 +1 @@ +Subproject commit 3ac9ef279a7c89908a6e03ed8a1c52738f94601e diff --git a/remappings.txt b/remappings.txt index 3b974d48..62cf7683 100644 --- a/remappings.txt +++ b/remappings.txt @@ -11,4 +11,5 @@ forge-std=lib/forge-std/src timelock=src/timelock revenue=src/revenue murky=lib/murky -lista-v3/=lib/lista-v3/src/ \ No newline at end of file +lista-v3/=lib/lista-v3/src/ +lista-dao-contracts/=lib/lista-dao-contracts.git/contracts/ \ No newline at end of file diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol index 8e035bb3..1b8cca06 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/V3Provider.sol @@ -8,9 +8,8 @@ import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/ 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 "./libraries/TickMath.sol"; -import { SqrtPriceMath } from "./libraries/SqrtPriceMath.sol"; -import { LiquidityAmounts } from "./libraries/LiquidityAmounts.sol"; +import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; +import { LiquidityAmounts } from "lista-dao-contracts/libraries/LiquidityAmounts.sol"; import { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; @@ -40,10 +39,12 @@ import { ISlisBNBxMinter } from "../utils/interfaces/ISlisBNBx.sol"; * - Only Moolah may transfer shares (prevents bypassing the vault on withdrawal). * * Dependencies: - * lib/lista-v3 (submodule) - IListaV3Factory / IListaV3Pool interfaces - * src/provider/libraries/* - 0.8.34 ports of the V3 math libs (TickMath, SqrtPriceMath, - * LiquidityAmounts, …); the lista-v3 originals are 0.7.6 and - * cannot compile here. + * lib/lista-v3 (submodule) - IListaV3Factory / IListaV3Pool interfaces (0.7.6 originals + * are interfaces only, so they compile under 0.8.34). + * lib/lista-dao-contracts.git (submod) - audited 0.8 math libs TickMath + LiquidityAmounts + * (LiquidityAmounts.getAmountsForLiquidity replaces the + * former SqrtPriceMath path; lista-dao-contracts ships no + * 0.8 SqrtPriceMath, and it is no longer needed). * src/provider/interfaces/INonfungiblePositionManager.sol - minimal local NPM interface. */ contract V3Provider is @@ -1004,26 +1005,17 @@ contract V3Provider is } /// @dev Computes token amounts for a given liquidity position at sqrtPriceX96. - /// Delegates to SqrtPriceMath from uniswap/v3-core for overflow-safe arithmetic. + /// Delegates to LiquidityAmounts.getAmountsForLiquidity (lista-dao-contracts, audited 0.8). + /// This is mathematically identical to the previous SqrtPriceMath.getAmount{0,1}Delta(..., false) + /// implementation: getAmount0ForLiquidity == getAmount0Delta(roundUp=false) and likewise for + /// token1, with the same below/inside/above-range branching. function _getAmountsForLiquidity( uint160 sqrtPriceX96, uint160 sqrtRatioAX96, uint160 sqrtRatioBX96, uint128 liquidity ) internal pure returns (uint256 amount0, uint256 amount1) { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - - if (sqrtPriceX96 <= sqrtRatioAX96) { - // Current price below range: position is fully TOKEN0. - amount0 = SqrtPriceMath.getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, false); - } else if (sqrtPriceX96 < sqrtRatioBX96) { - // Current price inside range. - amount0 = SqrtPriceMath.getAmount0Delta(sqrtPriceX96, sqrtRatioBX96, liquidity, false); - amount1 = SqrtPriceMath.getAmount1Delta(sqrtRatioAX96, sqrtPriceX96, liquidity, false); - } else { - // Current price above range: position is fully TOKEN1. - amount1 = SqrtPriceMath.getAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, false); - } + return LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioAX96, sqrtRatioBX96, liquidity); } /* ──────────────────────── upgrade guard ─────────────────────────── */ diff --git a/src/provider/libraries/FixedPoint96.sol b/src/provider/libraries/FixedPoint96.sol deleted file mode 100644 index ba308d71..00000000 --- a/src/provider/libraries/FixedPoint96.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.4.0; - -/// @title FixedPoint96 -/// @notice A library for handling binary fixed point numbers, see https://en.wikipedia.org/wiki/Q_(number_format) -/// @dev Used in SqrtPriceMath.sol -library FixedPoint96 { - uint8 internal constant RESOLUTION = 96; - uint256 internal constant Q96 = 0x1000000000000000000000000; -} diff --git a/src/provider/libraries/FullMath.sol b/src/provider/libraries/FullMath.sol deleted file mode 100644 index c8660692..00000000 --- a/src/provider/libraries/FullMath.sol +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.34; - -/// @title Contains 512-bit math functions -/// @notice Facilitates multiplication and division that can have overflow of an intermediate value without any loss of precision -/// @dev Handles "phantom overflow" i.e., allows multiplication and division where an intermediate value overflows 256 bits -library FullMath { - /// @notice Calculates floor(a×b÷denominator) with full precision. Throws if result overflows a uint256 or denominator == 0 - /// @param a The multiplicand - /// @param b The multiplier - /// @param denominator The divisor - /// @return result The 256-bit result - /// @dev Credit to Remco Bloemen under MIT license https://xn--2-umb.com/21/muldiv - function mulDiv(uint256 a, uint256 b, uint256 denominator) internal pure returns (uint256 result) { - unchecked { - // 512-bit multiply [prod1 prod0] = a * b - // Compute the product mod 2**256 and mod 2**256 - 1 - // then use the Chinese Remainder Theorem to reconstruct - // the 512 bit result. The result is stored in two 256 - // variables such that product = prod1 * 2**256 + prod0 - uint256 prod0; // Least significant 256 bits of the product - uint256 prod1; // Most significant 256 bits of the product - assembly { - let mm := mulmod(a, b, not(0)) - prod0 := mul(a, b) - prod1 := sub(sub(mm, prod0), lt(mm, prod0)) - } - - // Handle non-overflow cases, 256 by 256 division - if (prod1 == 0) { - require(denominator > 0); - assembly { - result := div(prod0, denominator) - } - return result; - } - - // Make sure the result is less than 2**256. - // Also prevents denominator == 0 - require(denominator > prod1); - - /////////////////////////////////////////////// - // 512 by 256 division. - /////////////////////////////////////////////// - - // Make division exact by subtracting the remainder from [prod1 prod0] - // Compute remainder using mulmod - uint256 remainder; - assembly { - remainder := mulmod(a, b, denominator) - } - // Subtract 256 bit number from 512 bit number - assembly { - prod1 := sub(prod1, gt(remainder, prod0)) - prod0 := sub(prod0, remainder) - } - - // Factor powers of two out of denominator - // Compute largest power of two divisor of denominator. - // Always >= 1. - uint256 twos = (0 - denominator) & denominator; - // Divide denominator by power of two - assembly { - denominator := div(denominator, twos) - } - - // Divide [prod1 prod0] by the factors of two - assembly { - prod0 := div(prod0, twos) - } - // Shift in bits from prod1 into prod0. For this we need - // to flip `twos` such that it is 2**256 / twos. - // If twos is zero, then it becomes one - assembly { - twos := add(div(sub(0, twos), twos), 1) - } - prod0 |= prod1 * twos; - - // Invert denominator mod 2**256 - // Now that denominator is an odd number, it has an inverse - // modulo 2**256 such that denominator * inv = 1 mod 2**256. - // Compute the inverse by starting with a seed that is correct - // correct for four bits. That is, denominator * inv = 1 mod 2**4 - uint256 inv = (3 * denominator) ^ 2; - // Now use Newton-Raphson iteration to improve the precision. - // Thanks to Hensel's lifting lemma, this also works in modular - // arithmetic, doubling the correct bits in each step. - inv *= 2 - denominator * inv; // inverse mod 2**8 - inv *= 2 - denominator * inv; // inverse mod 2**16 - inv *= 2 - denominator * inv; // inverse mod 2**32 - inv *= 2 - denominator * inv; // inverse mod 2**64 - inv *= 2 - denominator * inv; // inverse mod 2**128 - inv *= 2 - denominator * inv; // inverse mod 2**256 - - // Because the division is now exact we can divide by multiplying - // with the modular inverse of denominator. This will give us the - // correct result modulo 2**256. Since the precoditions guarantee - // that the outcome is less than 2**256, this is the final result. - // We don't need to compute the high bits of the result and prod1 - // is no longer required. - result = prod0 * inv; - return result; - } - } - - /// @notice Calculates ceil(a×b÷denominator) with full precision. Throws if result overflows a uint256 or denominator == 0 - /// @param a The multiplicand - /// @param b The multiplier - /// @param denominator The divisor - /// @return result The 256-bit result - function mulDivRoundingUp(uint256 a, uint256 b, uint256 denominator) internal pure returns (uint256 result) { - unchecked { - result = mulDiv(a, b, denominator); - if (mulmod(a, b, denominator) > 0) { - require(result < type(uint256).max); - result++; - } - } - } -} diff --git a/src/provider/libraries/LiquidityAmounts.sol b/src/provider/libraries/LiquidityAmounts.sol deleted file mode 100644 index c0247478..00000000 --- a/src/provider/libraries/LiquidityAmounts.sol +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -import "./FullMath.sol"; -import "./FixedPoint96.sol"; - -/// @title Liquidity amount functions -/// @notice Provides functions for computing liquidity amounts from token amounts and prices -library LiquidityAmounts { - /// @notice Downcasts uint256 to uint128 - /// @param x The uint258 to be downcasted - /// @return y The passed value, downcasted to uint128 - function toUint128(uint256 x) private pure returns (uint128 y) { - require((y = uint128(x)) == x); - } - - /// @notice Computes the amount of liquidity received for a given amount of token0 and price range - /// @dev Calculates amount0 * (sqrt(upper) * sqrt(lower)) / (sqrt(upper) - sqrt(lower)) - /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary - /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary - /// @param amount0 The amount0 being sent in - /// @return liquidity The amount of returned liquidity - function getLiquidityForAmount0( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint256 amount0 - ) internal pure returns (uint128 liquidity) { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - uint256 intermediate = FullMath.mulDiv(sqrtRatioAX96, sqrtRatioBX96, FixedPoint96.Q96); - unchecked { - return toUint128(FullMath.mulDiv(amount0, intermediate, sqrtRatioBX96 - sqrtRatioAX96)); - } - } - - /// @notice Computes the amount of liquidity received for a given amount of token1 and price range - /// @dev Calculates amount1 / (sqrt(upper) - sqrt(lower)). - /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary - /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary - /// @param amount1 The amount1 being sent in - /// @return liquidity The amount of returned liquidity - function getLiquidityForAmount1( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint256 amount1 - ) internal pure returns (uint128 liquidity) { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - unchecked { - return toUint128(FullMath.mulDiv(amount1, FixedPoint96.Q96, sqrtRatioBX96 - sqrtRatioAX96)); - } - } - - /// @notice Computes the maximum amount of liquidity received for a given amount of token0, token1, the current - /// pool prices and the prices at the tick boundaries - /// @param sqrtRatioX96 A sqrt price representing the current pool prices - /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary - /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary - /// @param amount0 The amount of token0 being sent in - /// @param amount1 The amount of token1 being sent in - /// @return liquidity The maximum amount of liquidity received - function getLiquidityForAmounts( - uint160 sqrtRatioX96, - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint256 amount0, - uint256 amount1 - ) internal pure returns (uint128 liquidity) { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - - if (sqrtRatioX96 <= sqrtRatioAX96) { - liquidity = getLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0); - } else if (sqrtRatioX96 < sqrtRatioBX96) { - uint128 liquidity0 = getLiquidityForAmount0(sqrtRatioX96, sqrtRatioBX96, amount0); - uint128 liquidity1 = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioX96, amount1); - - liquidity = liquidity0 < liquidity1 ? liquidity0 : liquidity1; - } else { - liquidity = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1); - } - } - - /// @notice Computes the amount of token0 for a given amount of liquidity and a price range - /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary - /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary - /// @param liquidity The liquidity being valued - /// @return amount0 The amount of token0 - function getAmount0ForLiquidity( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint128 liquidity - ) internal pure returns (uint256 amount0) { - unchecked { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - - return - FullMath.mulDiv(uint256(liquidity) << FixedPoint96.RESOLUTION, sqrtRatioBX96 - sqrtRatioAX96, sqrtRatioBX96) / - sqrtRatioAX96; - } - } - - /// @notice Computes the amount of token1 for a given amount of liquidity and a price range - /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary - /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary - /// @param liquidity The liquidity being valued - /// @return amount1 The amount of token1 - function getAmount1ForLiquidity( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint128 liquidity - ) internal pure returns (uint256 amount1) { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - - unchecked { - return FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96); - } - } - - /// @notice Computes the token0 and token1 value for a given amount of liquidity, the current - /// pool prices and the prices at the tick boundaries - /// @param sqrtRatioX96 A sqrt price representing the current pool prices - /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary - /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary - /// @param liquidity The liquidity being valued - /// @return amount0 The amount of token0 - /// @return amount1 The amount of token1 - function getAmountsForLiquidity( - uint160 sqrtRatioX96, - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint128 liquidity - ) internal pure returns (uint256 amount0, uint256 amount1) { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - - if (sqrtRatioX96 <= sqrtRatioAX96) { - amount0 = getAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity); - } else if (sqrtRatioX96 < sqrtRatioBX96) { - amount0 = getAmount0ForLiquidity(sqrtRatioX96, sqrtRatioBX96, liquidity); - amount1 = getAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioX96, liquidity); - } else { - amount1 = getAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity); - } - } -} diff --git a/src/provider/libraries/SafeCast.sol b/src/provider/libraries/SafeCast.sol deleted file mode 100644 index 51dc29cb..00000000 --- a/src/provider/libraries/SafeCast.sol +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Safe casting methods -/// @notice Contains methods for safely casting between types -library SafeCast { - /// @notice Cast a uint256 to a uint160, revert on overflow - /// @param y The uint256 to be downcasted - /// @return z The downcasted integer, now type uint160 - function toUint160(uint256 y) internal pure returns (uint160 z) { - require((z = uint160(y)) == y); - } - - /// @notice Cast a int256 to a int128, revert on overflow or underflow - /// @param y The int256 to be downcasted - /// @return z The downcasted integer, now type int128 - function toInt128(int256 y) internal pure returns (int128 z) { - require((z = int128(y)) == y); - } - - /// @notice Cast a uint256 to a int256, revert on overflow - /// @param y The uint256 to be casted - /// @return z The casted integer, now type int256 - function toInt256(uint256 y) internal pure returns (int256 z) { - require(y < 2 ** 255); - z = int256(y); - } -} diff --git a/src/provider/libraries/SqrtPriceMath.sol b/src/provider/libraries/SqrtPriceMath.sol deleted file mode 100644 index f9418032..00000000 --- a/src/provider/libraries/SqrtPriceMath.sol +++ /dev/null @@ -1,234 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -import { SafeCast } from "./SafeCast.sol"; - -import { FullMath } from "./FullMath.sol"; -import { UnsafeMath } from "./UnsafeMath.sol"; -import { FixedPoint96 } from "./FixedPoint96.sol"; - -/// @title Functions based on Q64.96 sqrt price and liquidity -/// @notice Contains the math that uses square root of price as a Q64.96 and liquidity to compute deltas -library SqrtPriceMath { - using SafeCast for uint256; - - /// @notice Gets the next sqrt price given a delta of token0 - /// @dev Always rounds up, because in the exact output case (increasing price) we need to move the price at least - /// far enough to get the desired output amount, and in the exact input case (decreasing price) we need to move the - /// price less in order to not send too much output. - /// The most precise formula for this is liquidity * sqrtPX96 / (liquidity +- amount * sqrtPX96), - /// if this is impossible because of overflow, we calculate liquidity / (liquidity / sqrtPX96 +- amount). - /// @param sqrtPX96 The starting price, i.e. before accounting for the token0 delta - /// @param liquidity The amount of usable liquidity - /// @param amount How much of token0 to add or remove from virtual reserves - /// @param add Whether to add or remove the amount of token0 - /// @return The price after adding or removing amount, depending on add - function getNextSqrtPriceFromAmount0RoundingUp( - uint160 sqrtPX96, - uint128 liquidity, - uint256 amount, - bool add - ) internal pure returns (uint160) { - // we short circuit amount == 0 because the result is otherwise not guaranteed to equal the input price - if (amount == 0) return sqrtPX96; - uint256 numerator1 = uint256(liquidity) << FixedPoint96.RESOLUTION; - - if (add) { - unchecked { - uint256 product; - if ((product = amount * sqrtPX96) / amount == sqrtPX96) { - uint256 denominator = numerator1 + product; - if (denominator >= numerator1) - // always fits in 160 bits - return uint160(FullMath.mulDivRoundingUp(numerator1, sqrtPX96, denominator)); - } - } - // denominator is checked for overflow - return uint160(UnsafeMath.divRoundingUp(numerator1, (numerator1 / sqrtPX96) + amount)); - } else { - unchecked { - uint256 product; - // if the product overflows, we know the denominator underflows - // in addition, we must check that the denominator does not underflow - require((product = amount * sqrtPX96) / amount == sqrtPX96 && numerator1 > product); - uint256 denominator = numerator1 - product; - return FullMath.mulDivRoundingUp(numerator1, sqrtPX96, denominator).toUint160(); - } - } - } - - /// @notice Gets the next sqrt price given a delta of token1 - /// @dev Always rounds down, because in the exact output case (decreasing price) we need to move the price at least - /// far enough to get the desired output amount, and in the exact input case (increasing price) we need to move the - /// price less in order to not send too much output. - /// The formula we compute is within <1 wei of the lossless version: sqrtPX96 +- amount / liquidity - /// @param sqrtPX96 The starting price, i.e., before accounting for the token1 delta - /// @param liquidity The amount of usable liquidity - /// @param amount How much of token1 to add, or remove, from virtual reserves - /// @param add Whether to add, or remove, the amount of token1 - /// @return The price after adding or removing `amount` - function getNextSqrtPriceFromAmount1RoundingDown( - uint160 sqrtPX96, - uint128 liquidity, - uint256 amount, - bool add - ) internal pure returns (uint160) { - // if we're adding (subtracting), rounding down requires rounding the quotient down (up) - // in both cases, avoid a mulDiv for most inputs - if (add) { - uint256 quotient = ( - amount <= type(uint160).max - ? (amount << FixedPoint96.RESOLUTION) / liquidity - : FullMath.mulDiv(amount, FixedPoint96.Q96, liquidity) - ); - - return (uint256(sqrtPX96) + quotient).toUint160(); - } else { - uint256 quotient = ( - amount <= type(uint160).max - ? UnsafeMath.divRoundingUp(amount << FixedPoint96.RESOLUTION, liquidity) - : FullMath.mulDivRoundingUp(amount, FixedPoint96.Q96, liquidity) - ); - - require(sqrtPX96 > quotient); - // always fits 160 bits - unchecked { - return uint160(sqrtPX96 - quotient); - } - } - } - - /// @notice Gets the next sqrt price given an input amount of token0 or token1 - /// @dev Throws if price or liquidity are 0, or if the next price is out of bounds - /// @param sqrtPX96 The starting price, i.e., before accounting for the input amount - /// @param liquidity The amount of usable liquidity - /// @param amountIn How much of token0, or token1, is being swapped in - /// @param zeroForOne Whether the amount in is token0 or token1 - /// @return sqrtQX96 The price after adding the input amount to token0 or token1 - function getNextSqrtPriceFromInput( - uint160 sqrtPX96, - uint128 liquidity, - uint256 amountIn, - bool zeroForOne - ) internal pure returns (uint160 sqrtQX96) { - require(sqrtPX96 > 0); - require(liquidity > 0); - - // round to make sure that we don't pass the target price - return - zeroForOne - ? getNextSqrtPriceFromAmount0RoundingUp(sqrtPX96, liquidity, amountIn, true) - : getNextSqrtPriceFromAmount1RoundingDown(sqrtPX96, liquidity, amountIn, true); - } - - /// @notice Gets the next sqrt price given an output amount of token0 or token1 - /// @dev Throws if price or liquidity are 0 or the next price is out of bounds - /// @param sqrtPX96 The starting price before accounting for the output amount - /// @param liquidity The amount of usable liquidity - /// @param amountOut How much of token0, or token1, is being swapped out - /// @param zeroForOne Whether the amount out is token0 or token1 - /// @return sqrtQX96 The price after removing the output amount of token0 or token1 - function getNextSqrtPriceFromOutput( - uint160 sqrtPX96, - uint128 liquidity, - uint256 amountOut, - bool zeroForOne - ) internal pure returns (uint160 sqrtQX96) { - require(sqrtPX96 > 0); - require(liquidity > 0); - - // round to make sure that we pass the target price - return - zeroForOne - ? getNextSqrtPriceFromAmount1RoundingDown(sqrtPX96, liquidity, amountOut, false) - : getNextSqrtPriceFromAmount0RoundingUp(sqrtPX96, liquidity, amountOut, false); - } - - /// @notice Gets the amount0 delta between two prices - /// @dev Calculates liquidity / sqrt(lower) - liquidity / sqrt(upper), - /// i.e. liquidity * (sqrt(upper) - sqrt(lower)) / (sqrt(upper) * sqrt(lower)) - /// @param sqrtRatioAX96 A sqrt price - /// @param sqrtRatioBX96 Another sqrt price - /// @param liquidity The amount of usable liquidity - /// @param roundUp Whether to round the amount up or down - /// @return amount0 Amount of token0 required to cover a position of size liquidity between the two passed prices - function getAmount0Delta( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint128 liquidity, - bool roundUp - ) internal pure returns (uint256 amount0) { - unchecked { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - - uint256 numerator1 = uint256(liquidity) << FixedPoint96.RESOLUTION; - uint256 numerator2 = sqrtRatioBX96 - sqrtRatioAX96; - - require(sqrtRatioAX96 > 0); - - return - roundUp - ? UnsafeMath.divRoundingUp(FullMath.mulDivRoundingUp(numerator1, numerator2, sqrtRatioBX96), sqrtRatioAX96) - : FullMath.mulDiv(numerator1, numerator2, sqrtRatioBX96) / sqrtRatioAX96; - } - } - - /// @notice Gets the amount1 delta between two prices - /// @dev Calculates liquidity * (sqrt(upper) - sqrt(lower)) - /// @param sqrtRatioAX96 A sqrt price - /// @param sqrtRatioBX96 Another sqrt price - /// @param liquidity The amount of usable liquidity - /// @param roundUp Whether to round the amount up, or down - /// @return amount1 Amount of token1 required to cover a position of size liquidity between the two passed prices - function getAmount1Delta( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint128 liquidity, - bool roundUp - ) internal pure returns (uint256 amount1) { - unchecked { - if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); - - return - roundUp - ? FullMath.mulDivRoundingUp(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96) - : FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96); - } - } - - /// @notice Helper that gets signed token0 delta - /// @param sqrtRatioAX96 A sqrt price - /// @param sqrtRatioBX96 Another sqrt price - /// @param liquidity The change in liquidity for which to compute the amount0 delta - /// @return amount0 Amount of token0 corresponding to the passed liquidityDelta between the two prices - function getAmount0Delta( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - int128 liquidity - ) internal pure returns (int256 amount0) { - unchecked { - return - liquidity < 0 - ? -getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(-liquidity), false).toInt256() - : getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(liquidity), true).toInt256(); - } - } - - /// @notice Helper that gets signed token1 delta - /// @param sqrtRatioAX96 A sqrt price - /// @param sqrtRatioBX96 Another sqrt price - /// @param liquidity The change in liquidity for which to compute the amount1 delta - /// @return amount1 Amount of token1 corresponding to the passed liquidityDelta between the two prices - function getAmount1Delta( - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - int128 liquidity - ) internal pure returns (int256 amount1) { - unchecked { - return - liquidity < 0 - ? -getAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(-liquidity), false).toInt256() - : getAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(liquidity), true).toInt256(); - } - } -} diff --git a/src/provider/libraries/TickMath.sol b/src/provider/libraries/TickMath.sol deleted file mode 100644 index 61e515cd..00000000 --- a/src/provider/libraries/TickMath.sol +++ /dev/null @@ -1,216 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.34; - -/// @title Math library for computing sqrt prices from ticks and vice versa -/// @notice Computes sqrt price for ticks of size 1.0001, i.e. sqrt(1.0001^tick) as fixed point Q64.96 numbers. Supports -/// prices between 2**-128 and 2**128 -library TickMath { - error T(); - error R(); - - /// @dev The minimum tick that may be passed to #getSqrtRatioAtTick computed from log base 1.0001 of 2**-128 - int24 internal constant MIN_TICK = -887272; - /// @dev The maximum tick that may be passed to #getSqrtRatioAtTick computed from log base 1.0001 of 2**128 - int24 internal constant MAX_TICK = -MIN_TICK; - - /// @dev The minimum value that can be returned from #getSqrtRatioAtTick. Equivalent to getSqrtRatioAtTick(MIN_TICK) - uint160 internal constant MIN_SQRT_RATIO = 4295128739; - /// @dev The maximum value that can be returned from #getSqrtRatioAtTick. Equivalent to getSqrtRatioAtTick(MAX_TICK) - uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; - - /// @notice Calculates sqrt(1.0001^tick) * 2^96 - /// @dev Throws if |tick| > max tick - /// @param tick The input tick for the above formula - /// @return sqrtPriceX96 A Fixed point Q64.96 number representing the sqrt of the ratio of the two assets (token1/token0) - /// at the given tick - function getSqrtRatioAtTick(int24 tick) internal pure returns (uint160 sqrtPriceX96) { - unchecked { - uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick)); - if (absTick > uint256(int256(MAX_TICK))) revert T(); - - uint256 ratio = absTick & 0x1 != 0 ? 0xfffcb933bd6fad37aa2d162d1a594001 : 0x100000000000000000000000000000000; - if (absTick & 0x2 != 0) ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128; - if (absTick & 0x4 != 0) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128; - if (absTick & 0x8 != 0) ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0) >> 128; - if (absTick & 0x10 != 0) ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644) >> 128; - if (absTick & 0x20 != 0) ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0) >> 128; - if (absTick & 0x40 != 0) ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861) >> 128; - if (absTick & 0x80 != 0) ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053) >> 128; - if (absTick & 0x100 != 0) ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4) >> 128; - if (absTick & 0x200 != 0) ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54) >> 128; - if (absTick & 0x400 != 0) ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3) >> 128; - if (absTick & 0x800 != 0) ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9) >> 128; - if (absTick & 0x1000 != 0) ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825) >> 128; - if (absTick & 0x2000 != 0) ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5) >> 128; - if (absTick & 0x4000 != 0) ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7) >> 128; - if (absTick & 0x8000 != 0) ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6) >> 128; - if (absTick & 0x10000 != 0) ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9) >> 128; - if (absTick & 0x20000 != 0) ratio = (ratio * 0x5d6af8dedb81196699c329225ee604) >> 128; - if (absTick & 0x40000 != 0) ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98) >> 128; - if (absTick & 0x80000 != 0) ratio = (ratio * 0x48a170391f7dc42444e8fa2) >> 128; - - if (tick > 0) ratio = type(uint256).max / ratio; - - // this divides by 1<<32 rounding up to go from a Q128.128 to a Q128.96. - // we then downcast because we know the result always fits within 160 bits due to our tick input constraint - // we round up in the division so getTickAtSqrtRatio of the output price is always consistent - sqrtPriceX96 = uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1)); - } - } - - /// @notice Calculates the greatest tick value such that getRatioAtTick(tick) <= ratio - /// @dev Throws in case sqrtPriceX96 < MIN_SQRT_RATIO, as MIN_SQRT_RATIO is the lowest value getRatioAtTick may - /// ever return. - /// @param sqrtPriceX96 The sqrt ratio for which to compute the tick as a Q64.96 - /// @return tick The greatest tick for which the ratio is less than or equal to the input ratio - function getTickAtSqrtRatio(uint160 sqrtPriceX96) internal pure returns (int24 tick) { - unchecked { - // second inequality must be < because the price can never reach the price at the max tick - if (!(sqrtPriceX96 >= MIN_SQRT_RATIO && sqrtPriceX96 < MAX_SQRT_RATIO)) revert R(); - uint256 ratio = uint256(sqrtPriceX96) << 32; - - uint256 r = ratio; - uint256 msb = 0; - - assembly { - let f := shl(7, gt(r, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)) - msb := or(msb, f) - r := shr(f, r) - } - assembly { - let f := shl(6, gt(r, 0xFFFFFFFFFFFFFFFF)) - msb := or(msb, f) - r := shr(f, r) - } - assembly { - let f := shl(5, gt(r, 0xFFFFFFFF)) - msb := or(msb, f) - r := shr(f, r) - } - assembly { - let f := shl(4, gt(r, 0xFFFF)) - msb := or(msb, f) - r := shr(f, r) - } - assembly { - let f := shl(3, gt(r, 0xFF)) - msb := or(msb, f) - r := shr(f, r) - } - assembly { - let f := shl(2, gt(r, 0xF)) - msb := or(msb, f) - r := shr(f, r) - } - assembly { - let f := shl(1, gt(r, 0x3)) - msb := or(msb, f) - r := shr(f, r) - } - assembly { - let f := gt(r, 0x1) - msb := or(msb, f) - } - - if (msb >= 128) r = ratio >> (msb - 127); - else r = ratio << (127 - msb); - - int256 log_2 = (int256(msb) - 128) << 64; - - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(63, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(62, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(61, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(60, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(59, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(58, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(57, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(56, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(55, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(54, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(53, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(52, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(51, f)) - r := shr(f, r) - } - assembly { - r := shr(127, mul(r, r)) - let f := shr(128, r) - log_2 := or(log_2, shl(50, f)) - } - - int256 log_sqrt10001 = log_2 * 255738958999603826347141; // 128.128 number - - int24 tickLow = int24((log_sqrt10001 - 3402992956809132418596140100660247210) >> 128); - int24 tickHi = int24((log_sqrt10001 + 291339464771989622907027621153398088495) >> 128); - - tick = tickLow == tickHi - ? tickLow - : getSqrtRatioAtTick(tickHi) <= sqrtPriceX96 - ? tickHi - : tickLow; - } - } -} diff --git a/src/provider/libraries/UnsafeMath.sol b/src/provider/libraries/UnsafeMath.sol deleted file mode 100644 index d043a1fb..00000000 --- a/src/provider/libraries/UnsafeMath.sol +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity >=0.5.0; - -/// @title Math functions that do not check inputs or outputs -/// @notice Contains methods that perform common math functions but do not do any overflow or underflow checks -library UnsafeMath { - /// @notice Returns ceil(x / y) - /// @dev division by 0 has unspecified behavior, and must be checked externally - /// @param x The dividend - /// @param y The divisor - /// @return z The quotient, ceil(x / y) - function divRoundingUp(uint256 x, uint256 y) internal pure returns (uint256 z) { - assembly { - z := add(div(x, y), gt(mod(x, y), 0)) - } - } -} From a51586ff4adc6631649137f686fa24882f213dfc Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:02:29 +0800 Subject: [PATCH 08/17] =?UTF-8?q?feat(provider):=20slisBNB/BNB=20v3=20LP?= =?UTF-8?q?=20provider=20=E2=80=94=20ERC4626=20shell=20+=20exchange-rate?= =?UTF-8?q?=20oracle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename V3Provider → SlisBNBV3Provider and split into a generic abstract base (V3Provider) + slisBNB/BNB specialization, per the slisBNB/BNB v3 LP PRD. - Base/derived split via virtual hooks (_afterCollateralChange, _rebalanceInventory, _valuationSqrtPriceX96, peek/getTokenConfig/receive). - ERC-4626 shell: asset = WBNB, totalAssets() in BNB; single-asset entry disabled (two-token deposit/withdraw/redeemShares + withdrawShares/supplyShares). - Exchange-rate oracle (PRD §4.5): peek/totalAssets/getUserBalanceInBnb value the position at the slisBNB exchange-rate-implied price (StakeManager convertSnBnbToBnb/convertBnbToSnBnb), never the pool spot/TWAP — manipulation-resistant. - Auto-centered range: initialize derives ticks from exchangeRate ±1%; rebalance recenters and converts inventory via the StakeManager (deposit / instantWithdraw). - Libraries: V3PositionLib (NPM primitives) + SlisBnbInventoryLib (stake/redeem + optimal-ratio conversion); audited 0.8 math from lista-dao-contracts. - require strings → custom errors; AccessControl (non-enumerable). - Tests: SlisBNBV3Provider.t.sol (USDC/WBNB generic) + SlisBNBV3ProviderRate.t.sol (slisBNB/WBNB rate-path, forked). Note: runtime bytecode currently exceeds EIP-170; size-reduction refactor pending. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/provider/SlisBNBV3Provider.sol | 426 ++++++++++++ src/provider/V3Provider.sol | 640 +++++++----------- src/provider/interfaces/IStakeManager.sol | 8 + src/provider/interfaces/IV3Provider.sol | 12 + .../libraries/SlisBnbInventoryLib.sol | 201 ++++++ src/provider/libraries/V3PositionLib.sol | 127 ++++ test/liquidator/V3Liquidator.t.sol | 21 +- test/moolah/mocks/MockStakeManager.sol | 6 + ...Provider.t.sol => SlisBNBV3Provider.t.sol} | 320 +++++---- test/provider/SlisBNBV3ProviderRate.t.sol | 256 +++++++ 10 files changed, 1462 insertions(+), 555 deletions(-) create mode 100644 src/provider/SlisBNBV3Provider.sol create mode 100644 src/provider/libraries/SlisBnbInventoryLib.sol create mode 100644 src/provider/libraries/V3PositionLib.sol rename test/provider/{V3Provider.t.sol => SlisBNBV3Provider.t.sol} (87%) create mode 100644 test/provider/SlisBNBV3ProviderRate.t.sol diff --git a/src/provider/SlisBNBV3Provider.sol b/src/provider/SlisBNBV3Provider.sol new file mode 100644 index 00000000..d948c528 --- /dev/null +++ b/src/provider/SlisBNBV3Provider.sol @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +import { IMoolah, Id } from "moolah/interfaces/IMoolah.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; +import { FullMath } from "lista-dao-contracts/oracle/libraries/FullMath.sol"; +import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; +import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; + +import { V3Provider } from "./V3Provider.sol"; +import { IStakeManager } from "./interfaces/IStakeManager.sol"; +import { V3PositionLib } from "./libraries/V3PositionLib.sol"; +import { SlisBnbInventoryLib } from "./libraries/SlisBnbInventoryLib.sol"; +import { ISlisBNBxMinter } from "../utils/interfaces/ISlisBNBx.sol"; + +/** + * @title SlisBNBV3Provider + * @author Lista DAO + * @notice slisBNB/BNB specialization of {V3Provider}. Adds the slisBNB-specific behaviour on top + * of the generic V3 LP provider: + * - Inventory rebalancing via the slisBNB StakeManager (stake BNB -> slisBNB on excess + * BNB; instant-redeem slisBNB -> BNB on excess slisBNB) wired into rebalance(). + * - slisBNBx reward mirroring: tracks each user's collateral per market and pings the + * SlisBNBxMinter after every deposit / withdraw / liquidation; exposes the + * ISlisBNBxModule `getUserBalanceInBnb` callback. + * + * Generic position management (deposit / withdraw / redeem / compounding / share oracle) + * lives in {V3Provider}. + */ +contract SlisBNBV3Provider is V3Provider { + /* ─────────────────────────── constants ──────────────────────────── */ + + /// @dev slisBNB liquid-staking token (BSC). The non-WBNB leg of the managed pool. + address public constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; + + /// @dev Lista slisBNB StakeManager. Used to rebalance inventory between the two pool legs: + /// stake BNB -> slisBNB (deposit) and instant-redeem slisBNB -> BNB (instantWithdraw). + IStakeManager public constant STAKE_MANAGER = IStakeManager(0x1adB950d8bB3dA4bE104211D5AB038628e477fE6); + + /// @dev Virtual address used by the resilient oracle to price native BNB. + address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + uint256 internal constant BPS = 10_000; + uint256 internal constant INITIAL_RANGE_BPS = 100; + int24 internal constant FALLBACK_HALF_RANGE_TICKS = 500; + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev user account > market id > amount of collateral(shares) deposited + mapping(address => mapping(Id => uint256)) public userMarketDeposit; + + /// @dev user account > total amount of collateral(shares) deposited + mapping(address => uint256) public userTotalDeposit; + + /// @dev slisBNBxMinter address + address public slisBNBxMinter; + + /// @dev Exchange rate used to derive the current centered slisBNB/BNB tick range. + uint256 public lastCenterRate; + + /// @dev Min relative exchange-rate drift from lastCenterRate before rebalance is allowed. + /// BPS precision; 0 disables the rate-drift guard. + uint256 public centerRateThresholdBps; + + /* ───────────────────────────── events ───────────────────────────── */ + + event SlisBNBxMinterChanged(address indexed minter); + event CenterRateThresholdChanged(uint256 centerRateThresholdBps); + event LastCenterRateUpdated(uint256 oldCenterRate, uint256 newCenterRate); + + /* ───────────────────────────── errors ───────────────────────────── */ + + error LengthMismatch(); + error DeadlineExpired(); + error InsufficientLiquidityMinted(); + error RateDeviationBelowThreshold(); + error InvalidThreshold(); + + /* ─────────────────────── constructor / init ─────────────────────── */ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address _moolah, + address _positionManager, + address _token0, + address _token1, + uint24 _fee, + uint32 _twapPeriod + ) V3Provider(_moolah, _positionManager, _token0, _token1, _fee, _twapPeriod) {} + + /** + * @param _admin Default admin (can upgrade, grant roles) + * @param _manager Manager role (can rebalance position range) + * @param _bot Bot address granted BOT role (can trigger rebalance) + * @param _resilientOracle Resilient oracle for pricing TOKEN0 and TOKEN1 + * @param _name ERC20 name for shares token + * @param _symbol ERC20 symbol for shares token + */ + function initialize( + address _admin, + address _manager, + address _bot, + address _resilientOracle, + string calldata _name, + string calldata _symbol + ) external initializer { + uint256 initialCenterRate; + if (_isSlisBnbWbnbPool()) initialCenterRate = _poolPriceRate(); + (int24 initialTickLower, int24 initialTickUpper) = _initialTickRange(initialCenterRate); + __V3Provider_init(_admin, _manager, _bot, _resilientOracle, initialTickLower, initialTickUpper, _name, _symbol); + lastCenterRate = initialCenterRate; + centerRateThresholdBps = INITIAL_RANGE_BPS; + } + + /* ─────────────────── slisBNBx: sync / view ──────────────────────── */ + + /** + * @notice Returns the user's total deposited collateral value expressed in BNB (18 decimals). + * Called by SlisBNBxMinter as the ISlisBNBxModule callback to compute how much + * slisBNBx the user is entitled to. + * @param account The user whose position is being priced. + */ + function getUserBalanceInBnb(address account) external view returns (uint256) { + uint256 shares = userTotalDeposit[account]; + if (shares == 0) return 0; + + uint256 supply = totalSupply(); + if (supply == 0) return 0; + + // Value at the exchange-rate-implied price (manipulation-resistant), consistent with peek(). + (uint256 total0, uint256 total1) = _getTotalAmountsAt(_valuationSqrtPriceX96()); + + uint256 user0 = (total0 * shares) / supply; + uint256 user1 = (total1 * shares) / supply; + + uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8-decimal USD + uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8-decimal USD + uint256 bnbPrice = IOracle(resilientOracle).peek(BNB_ADDRESS); // 8-decimal USD + + // Scale up by 1e18 before dividing by bnbPrice so the result is 18-decimal BNB. + uint256 value0 = (user0 * price0 * 1e18) / (10 ** DECIMALS0); + uint256 value1 = (user1 * price1 * 1e18) / (10 ** DECIMALS1); + + return (value0 + value1) / bnbPrice; + } + + /** + * @notice Manually sync one user's deposit tracking and slisBNBx balance for a market. + * @param id Moolah market Id (collateralToken must equal address(this)). + * @param account User to sync. + */ + function syncUserBalance(Id id, address account) external { + if (MOOLAH.idToMarketParams(id).collateralToken != address(this)) revert InvalidMarket(); + _syncPosition(id, account); + } + + /** + * @notice Batch sync multiple users across multiple markets. + * @param ids Array of market Ids. + * @param accounts Array of user addresses (parallel to ids). + */ + function bulkSyncUserBalance(Id[] calldata ids, address[] calldata accounts) external { + 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 ───────────────────── */ + + /// @notice Set (or unset) the SlisBNBxMinter plugin. Pass address(0) to disable. + /// When set, deposit/withdraw/liquidate call minter.rebalance(account). + function setSlisBNBxMinter(address _slisBNBxMinter) external onlyRole(MANAGER) { + slisBNBxMinter = _slisBNBxMinter; + emit SlisBNBxMinterChanged(_slisBNBxMinter); + } + + /// @notice Set min exchange-rate drift from lastCenterRate required for rebalance. + /// Pass 0 to disable the guard. + function setCenterRateThresholdBps(uint256 _centerRateThresholdBps) external onlyRole(MANAGER) { + if (_centerRateThresholdBps > BPS) revert InvalidThreshold(); + centerRateThresholdBps = _centerRateThresholdBps; + emit CenterRateThresholdChanged(_centerRateThresholdBps); + } + + /** + * @notice Recenter the managed position to the exchange-rate-derived range. + * @dev Caller supplies execution guards only. The target ticks and reinvested amounts are computed on-chain. + * @param minAmount0 Min TOKEN0 to receive when removing old liquidity. + * @param minAmount1 Min TOKEN1 to receive when removing old liquidity. + * @param minLiquidity Minimum liquidity that must be minted in the new position. + * @param deadline Latest acceptable timestamp for this rebalance transaction. + */ + function rebalance( + uint256 minAmount0, + uint256 minAmount1, + uint256 minLiquidity, + uint256 deadline + ) external onlyRole(BOT) nonReentrant { + if (block.timestamp > deadline) revert DeadlineExpired(); + + uint256 centerRate; + bool isSlisPool = _isSlisBnbWbnbPool(); + if (isSlisPool) { + centerRate = _poolPriceRate(); + _requireCenterRateDeviation(centerRate); + } + + (int24 newTickLower, int24 newTickUpper) = _initialTickRange(centerRate); + + int24 oldTickLower = tickLower; + int24 oldTickUpper = tickUpper; + + (uint256 total0, uint256 total1) = _collectAll(); + + 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) = _rebalanceInventoryToOptimalRatio(total0, total1, newTickLower, newTickUpper, centerRate); + + 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 (isSlisPool) { + uint256 oldCenterRate = lastCenterRate; + lastCenterRate = centerRate; + emit LastCenterRateUpdated(oldCenterRate, centerRate); + } + + emit Rebalanced(oldTickLower, oldTickUpper, newTickLower, newTickUpper, tokenId); + } + + /* ────────────────────────── hook overrides ──────────────────────── */ + + /// @dev Mirror the collateral change into deposit tracking + slisBNBx after every + /// deposit / withdraw / liquidation. + function _afterCollateralChange(Id id, address account) internal override { + _syncPosition(id, account); + } + + /// @dev Accepts native BNB from WBNB unwrap or from the StakeManager on instantWithdraw. + receive() external payable override { + if (!(msg.sender == WBNB || msg.sender == address(STAKE_MANAGER))) revert NotWBNB(); + } + + /// @dev Lending-oracle valuation price = the slisBNB exchange rate (BNB per slisBNB), NOT the pool + /// spot/TWAP. The position is split into (slisBNB, WBNB) at this fair price, so a pool trade + /// that pushes the AMM price within the narrow band cannot move the reported collateral value + /// (PRD §4.5). The rate comes from the slisBNB StakeManager (on-chain staking state). + /// For any non-slisBNB/WBNB pair this falls back to the base TWAP pricing. + function _valuationSqrtPriceX96() internal view override returns (uint160) { + bool slisIs0 = TOKEN0 == SLISBNB && TOKEN1 == WBNB; + bool wbnbIs0 = TOKEN0 == WBNB && TOKEN1 == SLISBNB; + if (!slisIs0 && !wbnbIs0) return super._valuationSqrtPriceX96(); + + return _sqrtPriceX96FromRate(_poolPriceRate()); + } + + /* ─────────────────────────── internals ──────────────────────────── */ + + /// @dev Initial slisBNB/WBNB range is exchange-rate ±1%, snapped to pool tick spacing. + /// For non-slis test pools, fall back to a spot-centered range so generic V3 tests remain usable. + function _initialTickRange(uint256 centerRate) internal view returns (int24 initialTickLower, int24 initialTickUpper) { + int24 tickSpacing = IListaV3Pool(POOL).tickSpacing(); + + if (_isSlisBnbWbnbPool()) { + (initialTickLower, initialTickUpper) = _tickRangeForRate(centerRate, tickSpacing); + } else { + (, int24 currentTick, , , , , ) = IListaV3Pool(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 pure 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 _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); + } + + function _sqrtPriceX96FromRate(uint256 rate) internal pure returns (uint160) { + return uint160(Math.sqrt(FullMath.mulDiv(rate, 1 << 192, 1e18))); + } + + function _tickAtSqrtRatio(uint160 sqrtPriceX96) internal pure returns (int24 tick) { + 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; + } + + function _rebalanceInventoryToOptimalRatio( + uint256 total0, + uint256 total1, + int24 targetTickLower, + int24 targetTickUpper, + uint256 centerRate + ) internal returns (uint256, uint256) { + if (!_isSlisBnbWbnbPool()) return (total0, total1); + + return + SlisBnbInventoryLib.convertToOptimalRatio( + STAKE_MANAGER, + SLISBNB, + WBNB, + TOKEN0, + TOKEN1, + total0, + total1, + _sqrtPriceX96FromRate(centerRate), + targetTickLower, + targetTickUpper, + centerRate + ); + } + + 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 Reads the user's current Moolah collateral for `id`, diffs against the last + /// recorded snapshot in `userMarketDeposit`, updates `userTotalDeposit`, then + /// calls `slisBNBxMinter.rebalance(account)` if a minter is configured. + 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/V3Provider.sol b/src/provider/V3Provider.sol index 1b8cca06..78c04b50 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/V3Provider.sol @@ -2,7 +2,8 @@ pragma solidity 0.8.34; import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.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"; @@ -16,41 +17,43 @@ import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; import { IOracle, TokenConfig } from "moolah/interfaces/IOracle.sol"; import { INonfungiblePositionManager } from "./interfaces/INonfungiblePositionManager.sol"; +import { V3PositionLib } from "./libraries/V3PositionLib.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 { IV3Provider } from "./interfaces/IV3Provider.sol"; -import { ISlisBNBxMinter } from "../utils/interfaces/ISlisBNBx.sol"; /** * @title V3Provider * @author Lista DAO - * @notice Manages a single Uniswap V3 / PancakeSwap V3 concentrated liquidity position. - * Issues ERC20 shares representing pro-rata ownership of the position. - * Registered as a Moolah provider so it can supply and withdraw collateral - * on behalf of users without requiring per-user Moolah authorization. + * @notice Generic, abstract base that manages a single Uniswap V3 / PancakeSwap V3 concentrated + * liquidity position and issues ERC20 shares representing pro-rata ownership of it. + * Registered as a Moolah provider so it can supply and withdraw collateral on behalf of + * users without requiring per-user Moolah authorization. * * Architecture: * - Shares (this contract's ERC20 token) are the Moolah collateral token for the market. * - On deposit: tokens → V3 liquidity → mint shares → Moolah.supplyCollateral(onBehalf) * - On withdraw: Moolah.withdrawCollateral → burn shares → remove V3 liquidity → tokens to receiver * - On liquidation: Moolah sends shares to liquidator; liquidator calls redeemShares() - * - Fees are compounded into the position before every deposit/withdraw/rebalance. + * - Fees are compounded into the position before every deposit/withdraw/maintenance operation. * - Only Moolah may transfer shares (prevents bypassing the vault on withdrawal). * + * Extension points (overridden by pool/asset-specific subclasses): + * - _afterCollateralChange(id, account): hook called after deposit / withdraw / liquidation, + * e.g. to mirror the position into an external reward system. + * - peek / getTokenConfig / receive: virtual so subclasses can specialize pricing and native + * token acceptance. + * * Dependencies: - * lib/lista-v3 (submodule) - IListaV3Factory / IListaV3Pool interfaces (0.7.6 originals - * are interfaces only, so they compile under 0.8.34). - * lib/lista-dao-contracts.git (submod) - audited 0.8 math libs TickMath + LiquidityAmounts - * (LiquidityAmounts.getAmountsForLiquidity replaces the - * former SqrtPriceMath path; lista-dao-contracts ships no - * 0.8 SqrtPriceMath, and it is no longer needed). + * lib/lista-v3 (submodule) - IListaV3Factory / IListaV3Pool interfaces. + * lib/lista-dao-contracts.git (submod) - audited 0.8 math libs TickMath + LiquidityAmounts. * src/provider/interfaces/INonfungiblePositionManager.sol - minimal local NPM interface. */ -contract V3Provider is - ERC20Upgradeable, +abstract contract V3Provider is + ERC4626Upgradeable, UUPSUpgradeable, - AccessControlEnumerableUpgradeable, + AccessControlUpgradeable, ReentrancyGuardUpgradeable, IOracle, IV3Provider @@ -89,6 +92,9 @@ contract V3Provider is /// and unwrapped on exit when one of the pool tokens is WBNB. address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + bytes32 public constant MANAGER = keccak256("MANAGER"); + bytes32 public constant BOT = keccak256("BOT"); + /* ──────────────────────────── storage ───────────────────────────── */ /// @dev Resilient oracle used to price TOKEN0 and TOKEN1 individually (8-decimal USD) @@ -111,31 +117,12 @@ contract V3Provider is /// Tracked separately to avoid sweeping arbitrary token donations. uint256 public idleToken1; - /// @dev user account > market id > amount of collateral(shares) deposited - mapping(address => mapping(Id => uint256)) public userMarketDeposit; - - /// @dev user account > total amount of collateral(shares) deposited - mapping(address => uint256) public userTotalDeposit; - - /// @dev slisBNBxMinter address - address public slisBNBxMinter; - - /// @dev Maximum allowed absolute tick deviation between slot0 and TWAP. - /// When non-zero, rebalance() reverts if |spotTick - twapTick| exceeds this value. - /// Default 0 = no guard (backwards compatible). - uint24 public maxTickDeviation; - - /// @dev Virtual address used by the resilient oracle to price native BNB. - address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - - bytes32 public constant MANAGER = keccak256("MANAGER"); - bytes32 public constant BOT = keccak256("BOT"); + /// @dev Reserved storage so future base-contract variables can be added without shifting + /// subclass storage. Reduce the array size when adding a new variable. + uint256[50] private __gap; /* ───────────────────────────── events ───────────────────────────── */ - event SlisBNBxMinterChanged(address indexed minter); - event MaxTickDeviationChanged(uint24 maxTickDeviation); - event Deposit( address indexed onBehalf, uint256 amount0Used, @@ -151,10 +138,33 @@ contract V3Provider is 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); event Compounded(uint256 fees0, uint256 fees1, uint128 liquidityAdded); event Rebalanced(int24 oldTickLower, int24 oldTickUpper, int24 newTickLower, int24 newTickUpper, uint256 newTokenId); + /* ───────────────────────────── errors ───────────────────────────── */ + + error ZeroAddress(); + error TokenOrderInvalid(); + error ZeroFee(); + error ZeroTwapPeriod(); + error PoolDoesNotExist(); + error InvalidTickRange(); + error OnlyMoolah(); + error InvalidCollateralToken(); + error PoolHasNoWBNB(); + error ZeroAmounts(); + error ZeroLiquidity(); + error ZeroShares(); + error Unauthorized(); + error InsufficientShares(); + error InvalidMarket(); + error BnbTransferFailed(); + error NotWBNB(); + error StandardEntryDisabled(); + /* ─────────────────────────── constructor ────────────────────────── */ /// @custom:oz-upgrades-unsafe-allow constructor @@ -166,19 +176,19 @@ contract V3Provider is uint24 _fee, uint32 _twapPeriod ) { - require(_moolah != address(0), "zero address"); - require(_positionManager != address(0), "zero address"); - require(_token0 != address(0) && _token1 != address(0), "zero address"); - require(_token0 < _token1, "token0 must be < token1"); - require(_fee > 0, "zero fee"); - require(_twapPeriod > 0, "zero twap period"); + if (_moolah == address(0)) revert ZeroAddress(); + if (_positionManager == address(0)) revert ZeroAddress(); + if (_token0 == address(0) || _token1 == 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 ); - require(_pool != address(0), "pool does not exist"); + if (_pool == address(0)) revert PoolDoesNotExist(); MOOLAH = IMoolah(_moolah); POSITION_MANAGER = INonfungiblePositionManager(_positionManager); @@ -196,8 +206,10 @@ contract V3Provider is /* ─────────────────────────── initializer ────────────────────────── */ /** + * @dev Shared initializer for subclasses. Subclasses expose an external `initialize` + * guarded by the `initializer` modifier and forward to this. * @param _admin Default admin (can upgrade, grant roles) - * @param _manager Manager role (can rebalance position range) + * @param _manager Manager role (can configure provider-level risk controls) * @param _bot Bot address granted BOT role (can trigger rebalance) * @param _resilientOracle Resilient oracle for pricing TOKEN0 and TOKEN1 * @param _tickLower Initial position lower tick @@ -205,7 +217,7 @@ contract V3Provider is * @param _name ERC20 name for shares token * @param _symbol ERC20 symbol for shares token */ - function initialize( + function __V3Provider_init( address _admin, address _manager, address _bot, @@ -214,14 +226,14 @@ contract V3Provider is int24 _tickUpper, string calldata _name, string calldata _symbol - ) external initializer { - require( - _admin != address(0) && _manager != address(0) && _bot != address(0) && _resilientOracle != address(0), - "zero address" - ); - require(_tickLower < _tickUpper, "invalid tick range"); + ) internal onlyInitializing { + if (_admin == address(0) || _manager == address(0) || _bot == address(0) || _resilientOracle == address(0)) { + revert ZeroAddress(); + } + if (_tickLower >= _tickUpper) revert InvalidTickRange(); __ERC20_init(_name, _symbol); + __ERC4626_init(IERC20(WBNB)); // ERC-4626 shell: numéraire asset is WBNB (BNB) __AccessControl_init(); __ReentrancyGuard_init(); @@ -239,15 +251,15 @@ contract V3Provider is /// @dev Only Moolah may transfer shares. This prevents users from transferring /// shares directly without going through withdraw(), which would orphan V3 liquidity. - function transfer(address to, uint256 value) public override returns (bool) { - require(msg.sender == address(MOOLAH), "only moolah"); + 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; } /// @dev Only Moolah may call transferFrom (e.g. when pulling collateral on supplyCollateral). - function transferFrom(address from, address to, uint256 value) public override returns (bool) { - require(msg.sender == address(MOOLAH), "only moolah"); + 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; } @@ -275,8 +287,8 @@ contract V3Provider is uint256 amount1Min, address onBehalf ) external payable nonReentrant returns (uint256 shares, uint256 amount0Used, uint256 amount1Used) { - require(marketParams.collateralToken == address(this), "invalid collateral token"); - require(onBehalf != address(0), "zero address"); + if (marketParams.collateralToken != address(this)) revert InvalidCollateralToken(); + if (onBehalf == address(0)) revert ZeroAddress(); // ── Native token handling ────────────────────────────────────────── // If the caller sends BNB, wrap it and use it in place of the pool token @@ -286,7 +298,7 @@ contract V3Provider is uint256 _amount1Desired = amount1Desired; if (msg.value > 0) { - require(TOKEN0 == WBNB || TOKEN1 == WBNB, "pool has no WBNB"); + if (!(TOKEN0 == WBNB || TOKEN1 == WBNB)) revert PoolHasNoWBNB(); if (TOKEN0 == WBNB) { _amount0Desired = msg.value; } else { @@ -295,23 +307,24 @@ contract V3Provider is IWBNB(WBNB).deposit{ value: msg.value }(); } - require(_amount0Desired > 0 || _amount1Desired > 0, "zero amounts"); + if (_amount0Desired == 0 && _amount1Desired == 0) revert ZeroAmounts(); // Reject upfront if the supplied amounts yield zero liquidity at the current price. // This catches one-sided deposits in the wrong direction (e.g. token0-only when price // is above tickUpper) before any tokens are pulled from the caller. { (uint160 sqrtPriceX96, , , , , , ) = IListaV3Pool(POOL).slot0(); - require( + if ( LiquidityAmounts.getLiquidityForAmounts( sqrtPriceX96, TickMath.getSqrtRatioAtTick(tickLower), TickMath.getSqrtRatioAtTick(tickUpper), _amount0Desired, _amount1Desired - ) > 0, - "zero liquidity" - ); + ) == 0 + ) { + revert ZeroLiquidity(); + } } // Pull ERC-20 tokens from caller. @@ -333,41 +346,32 @@ contract V3Provider is uint128 liquidityAdded; if (tokenId == 0) { // No position exists yet — mint a fresh V3 NFT. - IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), _amount0Desired); - IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), _amount1Desired); - - (tokenId, liquidityAdded, amount0Used, amount1Used) = POSITION_MANAGER.mint( - INonfungiblePositionManager.MintParams({ - token0: TOKEN0, - token1: TOKEN1, - fee: FEE, - tickLower: tickLower, - tickUpper: tickUpper, - amount0Desired: _amount0Desired, - amount1Desired: _amount1Desired, - amount0Min: amount0Min, - amount1Min: amount1Min, - recipient: address(this), - deadline: block.timestamp - }) + (tokenId, liquidityAdded, amount0Used, amount1Used) = V3PositionLib.mint( + POSITION_MANAGER, + TOKEN0, + TOKEN1, + FEE, + tickLower, + tickUpper, + _amount0Desired, + _amount1Desired, + amount0Min, + amount1Min ); // First depositor: shares 1:1 with liquidity units. shares = uint256(liquidityAdded); } else { // Existing position — increase liquidity. - IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), _amount0Desired); - IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), _amount1Desired); - - (liquidityAdded, amount0Used, amount1Used) = POSITION_MANAGER.increaseLiquidity( - INonfungiblePositionManager.IncreaseLiquidityParams({ - tokenId: tokenId, - amount0Desired: _amount0Desired, - amount1Desired: _amount1Desired, - amount0Min: amount0Min, - amount1Min: amount1Min, - deadline: block.timestamp - }) + (liquidityAdded, amount0Used, amount1Used) = V3PositionLib.increaseLiquidity( + POSITION_MANAGER, + TOKEN0, + TOKEN1, + tokenId, + _amount0Desired, + _amount1Desired, + amount0Min, + amount1Min ); // Subsequent depositors: proportional to liquidity contributed vs pre-deposit total. @@ -378,7 +382,7 @@ contract V3Provider is } } - require(shares > 0, "zero shares"); + if (shares == 0) revert ZeroShares(); // Refund any tokens not consumed by the V3 pool (ratio mismatch). // WBNB refunds are unwrapped back to BNB before sending. @@ -394,7 +398,7 @@ contract V3Provider is _approve(address(this), address(MOOLAH), shares); MOOLAH.supplyCollateral(marketParams, shares, onBehalf, ""); - _syncPosition(marketParams.id(), onBehalf); + _afterCollateralChange(marketParams.id(), onBehalf); emit Deposit(onBehalf, amount0Used, amount1Used, shares, marketParams.id()); } @@ -418,16 +422,16 @@ contract V3Provider is address onBehalf, address receiver ) external nonReentrant returns (uint256 amount0, uint256 amount1) { - require(marketParams.collateralToken == address(this), "invalid collateral token"); - require(shares > 0, "zero shares"); - require(receiver != address(0), "zero address"); - require(_isSenderAuthorized(onBehalf), "unauthorized"); + if (marketParams.collateralToken != address(this)) revert InvalidCollateralToken(); + if (shares == 0) revert ZeroShares(); + if (receiver == address(0)) revert ZeroAddress(); + if (!_isSenderAuthorized(onBehalf)) revert Unauthorized(); // Moolah decrements position.collateral and transfers shares to address(this). // Our transfer() allows msg.sender == MOOLAH, so this succeeds. MOOLAH.withdrawCollateral(marketParams, shares, onBehalf, address(this)); - _syncPosition(marketParams.id(), onBehalf); + _afterCollateralChange(marketParams.id(), onBehalf); _collectAndCompound(); @@ -436,6 +440,57 @@ contract V3Provider is emit Withdraw(onBehalf, shares, amount0, amount1, receiver, marketParams.id()); } + /** + * @notice Withdraw provider shares from Moolah collateral without redeeming the underlying V3 position. + * @dev Caller must be `onBehalf` or authorized via MOOLAH.isAuthorized(). + * This enables moving the same vLP shares to another Moolah market through supplyShares(). + * @param marketParams Moolah market (collateralToken must equal address(this)) + * @param shares Number of shares to withdraw from the Moolah collateral position + * @param onBehalf Owner of the Moolah collateral position + * @param receiver Address to receive the provider shares + */ + 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()); + } + + /** + * @notice Supply wallet-held provider shares as Moolah collateral. + * @dev Useful after withdrawShares() when moving vLP collateral between isolated markets. + * @param marketParams Moolah market (collateralToken must equal address(this)) + * @param shares Number of wallet-held provider shares to supply + * @param onBehalf Moolah position owner to credit collateral to + */ + 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()); + } + /** * @notice Redeem shares already held by the caller (typically a liquidator that * received shares from Moolah during liquidation) for TOKEN0/TOKEN1. @@ -450,9 +505,9 @@ contract V3Provider is uint256 minAmount1, address receiver ) external nonReentrant returns (uint256 amount0, uint256 amount1) { - require(shares > 0, "zero shares"); - require(receiver != address(0), "zero address"); - require(balanceOf(msg.sender) >= shares, "insufficient shares"); + if (shares == 0) revert ZeroShares(); + if (receiver == address(0)) revert ZeroAddress(); + if (balanceOf(msg.sender) < shares) revert InsufficientShares(); _collectAndCompound(); @@ -469,134 +524,14 @@ contract V3Provider is /* ──────────────────── Moolah provider callback ──────────────────── */ /** - * @dev Called by Moolah after a liquidation event. - * Syncs the borrower's deposit tracking and triggers slisBNBx rebalance if configured. - * Moolah already transferred the seized shares to the liquidator via transfer(). + * @dev Called by Moolah after a liquidation event. Runs the _afterCollateralChange hook so + * subclasses can resync external state. Moolah already transferred the seized shares to + * the liquidator via transfer(). */ function liquidate(Id id, address borrower) external { - require(msg.sender == address(MOOLAH), "only moolah"); - require(MOOLAH.idToMarketParams(id).collateralToken == address(this), "invalid market"); - _syncPosition(id, borrower); - } - - /* ───────────────────── manager: rebalance range ─────────────────── */ - - /** - * @notice Move the position to a new tick range. Collects all fees, removes all - * liquidity, burns the old NFT, and mints a new position at the new ticks. - * Share count is unchanged — each share now represents the new range. - * @dev Caller must hold MANAGER role. A price movement between decreaseLiquidity - * and the new mint is the primary slippage risk; minAmount0/minAmount1 guard against it. - * @param _tickLower New lower tick - * @param _tickUpper New upper tick - * @param minAmount0 Min TOKEN0 to receive when removing old liquidity - * @param minAmount1 Min TOKEN1 to receive when removing old liquidity - * @param amount0Desired TOKEN0 to reinvest into the new position. Must not exceed - * the total internally collected (fees + idle + removed liquidity). - * Pass type(uint256).max to reinvest everything. - * @param amount1Desired TOKEN1 to reinvest into the new position. Same semantics. - */ - function rebalance( - int24 _tickLower, - int24 _tickUpper, - uint256 minAmount0, - uint256 minAmount1, - uint256 amount0Desired, - uint256 amount1Desired - ) external onlyRole(BOT) nonReentrant { - require(_tickLower < _tickUpper, "invalid tick range"); - - // Guard: prevent rebalance when spot diverges too far from TWAP. - if (maxTickDeviation > 0) { - (, int24 spotTick, , , , , ) = IListaV3Pool(POOL).slot0(); - int24 twapTick = getTwapTick(); - int24 delta = spotTick > twapTick ? spotTick - twapTick : twapTick - spotTick; - require(uint24(delta) <= maxTickDeviation, "twap deviation too high"); - } - - int24 oldTickLower = tickLower; - int24 oldTickUpper = tickUpper; - - // 1. Collect all fees; track amounts explicitly to avoid balanceOf donation surface. - (uint256 total0, uint256 total1) = _collectAll(); - - // Add previously idle tokens from compound ratio mismatches. - total0 += idleToken0; - total1 += idleToken1; - idleToken0 = 0; - idleToken1 = 0; - - // 2. Remove all existing liquidity. - if (tokenId != 0) { - uint128 liquidity = _getPositionLiquidity(); - if (liquidity > 0) { - POSITION_MANAGER.decreaseLiquidity( - INonfungiblePositionManager.DecreaseLiquidityParams({ - tokenId: tokenId, - liquidity: liquidity, - amount0Min: minAmount0, - amount1Min: minAmount1, - deadline: block.timestamp - }) - ); - } - // Collect removed liquidity back to this contract; accumulate into tracked totals. - (uint256 removed0, uint256 removed1) = POSITION_MANAGER.collect( - INonfungiblePositionManager.CollectParams({ - tokenId: tokenId, - recipient: address(this), - amount0Max: type(uint128).max, - amount1Max: type(uint128).max - }) - ); - total0 += removed0; - total1 += removed1; - - POSITION_MANAGER.burn(tokenId); - tokenId = 0; - } - - // 3. Update range. - tickLower = _tickLower; - tickUpper = _tickUpper; - - // 4. Re-mint with caller-specified amounts (capped to internally available). - // This lets the BOT pre-compute the optimal ratio for the new tick range, - // minimising idle remainder. Excess stays in idleToken0/1 for next compound. - uint256 toMint0 = amount0Desired > total0 ? total0 : amount0Desired; - uint256 toMint1 = amount1Desired > total1 ? total1 : amount1Desired; - - if (toMint0 > 0 || toMint1 > 0) { - IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), toMint0); - IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), toMint1); - - (uint256 newTokenId, , uint256 used0, uint256 used1) = POSITION_MANAGER.mint( - INonfungiblePositionManager.MintParams({ - token0: TOKEN0, - token1: TOKEN1, - fee: FEE, - tickLower: _tickLower, - tickUpper: _tickUpper, - amount0Desired: toMint0, - amount1Desired: toMint1, - amount0Min: 0, - amount1Min: 0, - recipient: address(this), - deadline: block.timestamp - }) - ); - tokenId = newTokenId; - - // Any leftover (caller under-specified or ratio mismatch) tracked for next compound. - idleToken0 = total0 - used0; - idleToken1 = total1 - used1; - } else { - // Nothing to mint; park everything as idle. - idleToken0 = total0; - idleToken1 = total1; - } - - emit Rebalanced(oldTickLower, oldTickUpper, _tickLower, _tickUpper, tokenId); + if (msg.sender != address(MOOLAH)) revert OnlyMoolah(); + if (MOOLAH.idToMarketParams(id).collateralToken != address(this)) revert InvalidMarket(); + _afterCollateralChange(id, borrower); } /* ───────────────────────── view functions ───────────────────────── */ @@ -628,7 +563,7 @@ contract V3Provider is * @return amount0 TOKEN0 the caller would receive (≥ minAmount0 to pass slippage guard). * @return amount1 TOKEN1 the caller would receive (≥ minAmount1 to pass slippage guard). */ - function previewRedeem(uint256 shares) external view returns (uint256 amount0, uint256 amount1) { + function previewRedeemUnderlying(uint256 shares) external view returns (uint256 amount0, uint256 amount1) { uint256 supply = totalSupply(); if (supply == 0 || shares == 0) return (0, 0); @@ -660,7 +595,7 @@ contract V3Provider is * @return amount0 TOKEN0 that would actually be consumed (≤ amount0Desired). * @return amount1 TOKEN1 that would actually be consumed (≤ amount1Desired). */ - function previewDeposit( + function previewDepositAmounts( uint256 amount0Desired, uint256 amount1Desired ) external view returns (uint128 liquidity, uint256 amount0, uint256 amount1) { @@ -679,28 +614,56 @@ contract V3Provider is } /// @dev Returns the TOKEN field required by the IProvider interface. - /// For V3Provider, the "token" is this contract itself (the shares ERC20). + /// For a V3Provider, the "token" is this contract itself (the shares ERC20). function TOKEN() external view returns (address) { return address(this); } + /* ─────────────────────── ERC-4626 shell ─────────────────────────── */ + + /// @notice ERC-4626 total managed assets, denominated in the vault asset (WBNB / BNB). + /// @dev Equals the position's BNB value: with the resilient oracle pricing slisBNB as + /// BNB_price × exchangeRate, `USD_value / WBNB_price` collapses to + /// `WBNB_amt + slisBNB_amt × exchangeRate` (PRD §4.5). WBNB has 18 decimals, so the + /// 8-decimal USD value scaled by 1e18 and divided by the 8-decimal WBNB price yields an + /// 18-decimal WBNB amount. convertToShares/convertToAssets derive from this and totalSupply. + function totalAssets() public view override returns (uint256) { + uint256 assetPrice = IOracle(resilientOracle).peek(asset()); // 8 decimals + if (assetPrice == 0) return 0; + return (_positionValueUsd() * 1e18) / assetPrice; + } + + /// @dev The single-asset ERC-4626 entry points are 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(); + } + /* ─────────────────────── IOracle implementation ─────────────────── */ /** * @notice Returns the USD price (8 decimals) for a given token. - * - If token == address(this): prices V3Provider shares as + * - If token == address(this): prices provider shares as * (total0 × price0 + total1 × price1) / totalSupply. * - Otherwise: delegates directly to the resilient oracle. * * @dev Token composition is derived from the TWAP tick (not slot0) so a single-block - * AMM price manipulation cannot inflate the reported collateral value. - * The maxTickDeviation guard on rebalance() prevents rebalancing while spot - * diverges far from TWAP, which would cause a phantom share-price discontinuity. - * pool.observe() reverts when the pool lacks TWAP_PERIOD seconds of history, - * which in turn reverts peek() — intentionally blocking borrows until the market - * has seasoned. + * AMM price manipulation cannot inflate the reported collateral value. Subclasses may + * override to use an exchange-rate-implied price instead of the pool TWAP. */ - function peek(address token) external view override returns (uint256) { + function peek(address token) external view virtual override returns (uint256) { if (token != address(this)) { return IOracle(resilientOracle).peek(token); } @@ -708,16 +671,24 @@ contract V3Provider is uint256 supply = totalSupply(); if (supply == 0) return 0; - uint160 sqrtTwapX96 = TickMath.getSqrtRatioAtTick(getTwapTick()); - (uint256 total0, uint256 total1) = _getTotalAmountsAt(sqrtTwapX96); + // shares are 18-decimal; return 8-decimal price per share + return (_positionValueUsd() * 1e18) / supply; + } + /// @dev Total position value in 8-decimal USD, with leg composition taken at the TWAP price + /// (manipulation-resistant). Shared by peek() and totalAssets(). + function _positionValueUsd() internal view returns (uint256) { + (uint256 total0, uint256 total1) = _getTotalAmountsAt(_valuationSqrtPriceX96()); uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8 decimals uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8 decimals + return (total0 * price0) / (10 ** DECIMALS0) + (total1 * price1) / (10 ** DECIMALS1); + } - uint256 totalValue = (total0 * price0) / (10 ** DECIMALS0) + (total1 * price1) / (10 ** DECIMALS1); - - // shares are 18-decimal; return 8-decimal price per share - return (totalValue * 1e18) / supply; + /// @dev sqrtPriceX96 used to value the position for the lending oracle (peek / totalAssets / + /// getUserBalanceInBnb). Base uses the pool TWAP. Subclasses override to use an + /// exchange-rate-implied price so a pool-trade cannot move the reported collateral value. + function _valuationSqrtPriceX96() internal view virtual returns (uint160) { + return TickMath.getSqrtRatioAtTick(getTwapTick()); } /** @@ -726,7 +697,7 @@ contract V3Provider is * so the resilient oracle can delegate share pricing back to us. * - Otherwise: delegates to the resilient oracle. */ - function getTokenConfig(address token) external view override returns (TokenConfig memory) { + function getTokenConfig(address token) external view virtual override returns (TokenConfig memory) { if (token != address(this)) { return IOracle(resilientOracle).getTokenConfig(token); } @@ -739,12 +710,8 @@ contract V3Provider is }); } - /** - * @notice Returns the TWAP tick for POOL over TWAP_PERIOD seconds. - * Useful for bots to cross-check whether the current slot0 tick deviates - * significantly from the TWAP before triggering a rebalance. - * Public (not external) so peek() can call it directly. - */ + /// @notice Returns the TWAP tick for POOL over TWAP_PERIOD seconds. + /// Public (not external) so peek() can call it directly. function getTwapTick() public view returns (int24 twapTick) { uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = TWAP_PERIOD; @@ -757,98 +724,14 @@ contract V3Provider is if (delta < 0 && (delta % int56(uint56(TWAP_PERIOD)) != 0)) twapTick--; } - /* ─────────────────── slisBNBx: sync / view ──────────────────────── */ - - /** - * @notice Returns the user's total deposited collateral value expressed in BNB (18 decimals). - * Called by SlisBNBxMinter as the ISlisBNBxModule callback to compute how much - * slisBNBx the user is entitled to. - * @param account The user whose position is being priced. - */ - function getUserBalanceInBnb(address account) external view returns (uint256) { - uint256 shares = userTotalDeposit[account]; - if (shares == 0) return 0; - - uint256 supply = totalSupply(); - if (supply == 0) return 0; + /* ────────────────────────── extension hooks ─────────────────────── */ - (uint256 total0, uint256 total1) = getTotalAmounts(); - - uint256 user0 = (total0 * shares) / supply; - uint256 user1 = (total1 * shares) / supply; - - uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8-decimal USD - uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8-decimal USD - uint256 bnbPrice = IOracle(resilientOracle).peek(BNB_ADDRESS); // 8-decimal USD - - // Scale up by 1e18 before dividing by bnbPrice so the result is 18-decimal BNB. - uint256 value0 = (user0 * price0 * 1e18) / (10 ** DECIMALS0); - uint256 value1 = (user1 * price1 * 1e18) / (10 ** DECIMALS1); - - return (value0 + value1) / bnbPrice; - } - - /** - * @notice Manually sync one user's deposit tracking and slisBNBx balance for a market. - * @param id Moolah market Id (collateralToken must equal address(this)). - * @param account User to sync. - */ - function syncUserBalance(Id id, address account) external { - require(MOOLAH.idToMarketParams(id).collateralToken == address(this), "invalid market"); - _syncPosition(id, account); - } - - /** - * @notice Batch sync multiple users across multiple markets. - * @param ids Array of market Ids. - * @param accounts Array of user addresses (parallel to ids). - */ - function bulkSyncUserBalance(Id[] calldata ids, address[] calldata accounts) external { - require(ids.length == accounts.length, "length mismatch"); - for (uint256 i = 0; i < accounts.length; i++) { - require(MOOLAH.idToMarketParams(ids[i]).collateralToken == address(this), "invalid market"); - _syncPosition(ids[i], accounts[i]); - } - } - - /* ──────────────────── manager: slisBNBxMinter ───────────────────── */ - - /// @notice Set (or unset) the SlisBNBxMinter plugin. Pass address(0) to disable. - /// When set, deposit/withdraw/liquidate call minter.rebalance(account). - function setSlisBNBxMinter(address _slisBNBxMinter) external onlyRole(MANAGER) { - slisBNBxMinter = _slisBNBxMinter; - emit SlisBNBxMinterChanged(_slisBNBxMinter); - } - - /// @notice Set the maximum allowed tick deviation between slot0 and TWAP for rebalance(). - /// Pass 0 to disable the guard. - function setMaxTickDeviation(uint24 _maxTickDeviation) external onlyRole(MANAGER) { - maxTickDeviation = _maxTickDeviation; - emit MaxTickDeviationChanged(_maxTickDeviation); - } + /// @dev Hook invoked after deposit / withdraw / liquidation with the affected (market, account). + /// Base is a no-op; subclasses override to mirror the position into external systems. + function _afterCollateralChange(Id id, address account) internal virtual {} /* ─────────────────────────── internals ──────────────────────────── */ - /// @dev Reads the user's current Moolah collateral for `id`, diffs against the last - /// recorded snapshot in `userMarketDeposit`, updates `userTotalDeposit`, then - /// calls `slisBNBxMinter.rebalance(account)` if a minter is configured. - /// Callers that have already validated the market (deposit, withdraw) skip the - /// idToMarketParams check; liquidate() validates before calling this. - function _syncPosition(Id id, address account) internal { - uint256 current = MOOLAH.position(id, account).collateral; - - if (current >= userMarketDeposit[account][id]) { - userTotalDeposit[account] += current - userMarketDeposit[account][id]; - } else { - userTotalDeposit[account] -= userMarketDeposit[account][id] - current; - } - userMarketDeposit[account][id] = current; - - if (slisBNBxMinter != address(0)) { - ISlisBNBxMinter(slisBNBxMinter).rebalance(account); - } - } - /// @dev Collect accrued fees from the position and re-add them plus any previously /// idle tokens (from prior ratio mismatches) as liquidity. /// Idle amounts are tracked in storage rather than read from balanceOf() to @@ -856,32 +739,22 @@ contract V3Provider is function _collectAndCompound() internal { if (tokenId == 0) return; - (uint256 fees0, uint256 fees1) = POSITION_MANAGER.collect( - INonfungiblePositionManager.CollectParams({ - tokenId: tokenId, - recipient: address(this), - amount0Max: type(uint128).max, - amount1Max: type(uint128).max - }) - ); + (uint256 fees0, uint256 fees1) = V3PositionLib.collectAll(POSITION_MANAGER, tokenId); uint256 toCompound0 = fees0 + idleToken0; uint256 toCompound1 = fees1 + idleToken1; if (toCompound0 == 0 && toCompound1 == 0) return; - IERC20(TOKEN0).safeIncreaseAllowance(address(POSITION_MANAGER), toCompound0); - IERC20(TOKEN1).safeIncreaseAllowance(address(POSITION_MANAGER), toCompound1); - - (uint128 liquidityAdded, uint256 used0, uint256 used1) = POSITION_MANAGER.increaseLiquidity( - INonfungiblePositionManager.IncreaseLiquidityParams({ - tokenId: tokenId, - amount0Desired: toCompound0, - amount1Desired: toCompound1, - amount0Min: 0, - amount1Min: 0, - deadline: block.timestamp - }) + (uint128 liquidityAdded, uint256 used0, uint256 used1) = V3PositionLib.increaseLiquidity( + POSITION_MANAGER, + TOKEN0, + TOKEN1, + tokenId, + toCompound0, + toCompound1, + 0, + 0 ); // Track leftover from ratio mismatch so it's swept on the next compound. @@ -895,14 +768,7 @@ contract V3Provider is /// Returns the amounts collected so callers can track totals without balanceOf. function _collectAll() internal returns (uint256 collected0, uint256 collected1) { if (tokenId == 0) return (0, 0); - (collected0, collected1) = POSITION_MANAGER.collect( - INonfungiblePositionManager.CollectParams({ - tokenId: tokenId, - recipient: address(this), - amount0Max: type(uint128).max, - amount1Max: type(uint128).max - }) - ); + (collected0, collected1) = V3PositionLib.collectAll(POSITION_MANAGER, tokenId); } /// @dev Burn `shares` held by address(this), remove proportional V3 liquidity, @@ -923,25 +789,10 @@ contract V3Provider is _burn(address(this), shares); if (liquidityToRemove > 0) { - POSITION_MANAGER.decreaseLiquidity( - INonfungiblePositionManager.DecreaseLiquidityParams({ - tokenId: tokenId, - liquidity: liquidityToRemove, - amount0Min: minAmount0, - amount1Min: minAmount1, - deadline: block.timestamp - }) - ); + V3PositionLib.decreaseLiquidity(POSITION_MANAGER, tokenId, liquidityToRemove, minAmount0, minAmount1); // Collect to address(this) so we can unwrap WBNB before forwarding. - (amount0, amount1) = POSITION_MANAGER.collect( - INonfungiblePositionManager.CollectParams({ - tokenId: tokenId, - recipient: address(this), - amount0Max: type(uint128).max, - amount1Max: type(uint128).max - }) - ); + (amount0, amount1) = V3PositionLib.collectAll(POSITION_MANAGER, tokenId); if (amount0 > 0) _sendToken(TOKEN0, amount0, payable(receiver)); if (amount1 > 0) _sendToken(TOKEN1, amount1, payable(receiver)); @@ -956,15 +807,15 @@ contract V3Provider is if (token == WBNB) { IWBNB(WBNB).withdraw(amount); (bool ok, ) = to.call{ value: amount }(""); - require(ok, "BNB transfer failed"); + if (!ok) revert BnbTransferFailed(); } else { IERC20(token).safeTransfer(to, amount); } } - /// @dev Accepts native BNB sent by WBNB during unwrap. - receive() external payable { - require(msg.sender == WBNB, "not WBNB"); + /// @dev Accepts native BNB sent by WBNB during unwrap. Subclasses may widen the allowed senders. + receive() external payable virtual { + if (msg.sender != WBNB) revert NotWBNB(); } /// @dev Returns the current liquidity of the managed V3 position. @@ -978,12 +829,12 @@ contract V3Provider is return msg.sender == onBehalf || MOOLAH.isAuthorized(onBehalf, msg.sender); } - /* ──────── Uniswap V3 liquidity math (via v3-core libraries) ──────── */ + /* ──────── Uniswap V3 liquidity math (via lista-dao-contracts) ─────── */ /// @dev Shared implementation for getTotalAmounts() and peek(). Callers supply the /// sqrtPriceX96 so each can use the price appropriate for its purpose: /// slot0 for display/bots, TWAP for the lending oracle. - function _getTotalAmountsAt(uint160 sqrtPriceX96) private view returns (uint256 total0, uint256 total1) { + function _getTotalAmountsAt(uint160 sqrtPriceX96) internal view returns (uint256 total0, uint256 total1) { if (tokenId == 0) return (0, 0); (, , , , , , , uint128 liquidity, , , uint128 tokensOwed0, uint128 tokensOwed1) = POSITION_MANAGER.positions( @@ -1006,9 +857,6 @@ contract V3Provider is /// @dev Computes token amounts for a given liquidity position at sqrtPriceX96. /// Delegates to LiquidityAmounts.getAmountsForLiquidity (lista-dao-contracts, audited 0.8). - /// This is mathematically identical to the previous SqrtPriceMath.getAmount{0,1}Delta(..., false) - /// implementation: getAmount0ForLiquidity == getAmount0Delta(roundUp=false) and likewise for - /// token1, with the same below/inside/above-range branching. function _getAmountsForLiquidity( uint160 sqrtPriceX96, uint160 sqrtRatioAX96, 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/IV3Provider.sol b/src/provider/interfaces/IV3Provider.sol index 2f5ce1fc..06f259e9 100644 --- a/src/provider/interfaces/IV3Provider.sol +++ b/src/provider/interfaces/IV3Provider.sol @@ -49,6 +49,18 @@ interface IV3Provider is IProvider, IOracle { address receiver ) external returns (uint256 amount0, uint256 amount1); + /// @notice Withdraw provider shares from Moolah collateral without + /// redeeming the underlying token0/token1 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( diff --git a/src/provider/libraries/SlisBnbInventoryLib.sol b/src/provider/libraries/SlisBnbInventoryLib.sol new file mode 100644 index 00000000..507f15fe --- /dev/null +++ b/src/provider/libraries/SlisBnbInventoryLib.sol @@ -0,0 +1,201 @@ +// 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 { FullMath } from "lista-dao-contracts/oracle/libraries/FullMath.sol"; +import { LiquidityAmounts } from "lista-dao-contracts/libraries/LiquidityAmounts.sol"; +import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; +import { IWBNB } from "../interfaces/IWBNB.sol"; +import { IStakeManager } from "../interfaces/IStakeManager.sol"; + +/** + * @title SlisBnbInventoryLib + * @author Lista DAO + * @notice External library holding the (user-invisible) slisBNB inventory-conversion plumbing used + * by {SlisBNBV3Provider} during rebalance. Deployed once and linked, so the StakeManager + * interaction bytecode lives here instead of inflating the provider implementation. + * + * @dev Invoked via DELEGATECALL, so `address(this)` is the provider: token custody, allowances + * and the native BNB received from `instantWithdraw` all resolve to the provider. The + * StakeManager / token addresses are passed in as arguments (the provider's constants are + * not readable from library code). + */ +library SlisBnbInventoryLib { + using SafeERC20 for IERC20; + + uint128 internal constant RATIO_SAMPLE_LIQUIDITY = 1e18; + uint256 internal constant RATE_SCALE = 1e18; + + error OneDirection(); + error NotSlisBnbWbnbPool(); + + /// @notice Convert the over-weight leg so free inventory matches the target range's optimal + /// token0/token1 injection ratio at the exchange-rate-implied price. + function convertToOptimalRatio( + IStakeManager stakeManager, + address slisBnb, + address wbnb, + address token0, + address token1, + uint256 total0, + uint256 total1, + uint160 exchangeRateSqrtPriceX96, + int24 targetTickLower, + int24 targetTickUpper, + uint256 token1PerToken0Rate + ) external returns (uint256, uint256) { + if (total0 == 0 && total1 == 0) return (total0, total1); + if (token1PerToken0Rate == 0) return (total0, total1); + if (!((token0 == wbnb && token1 == slisBnb) || (token0 == slisBnb && token1 == wbnb))) { + revert NotSlisBnbWbnbPool(); + } + + (uint256 target0, uint256 target1) = _targetAmountsForOptimalRatio( + total0, + total1, + exchangeRateSqrtPriceX96, + TickMath.getSqrtRatioAtTick(targetTickLower), + TickMath.getSqrtRatioAtTick(targetTickUpper), + token1PerToken0Rate + ); + + uint256 token0ToToken1; + uint256 token1ToToken0; + if (total0 > target0) { + token0ToToken1 = total0 - target0; + } else if (target0 > total0) { + uint256 amountByToken0Shortfall = FullMath.mulDiv(target0 - total0, token1PerToken0Rate, RATE_SCALE); + uint256 amountByToken1Excess = total1 > target1 ? total1 - target1 : amountByToken0Shortfall; + token1ToToken0 = amountByToken1Excess > total1 ? total1 : amountByToken1Excess; + } + + (uint256 bnbToStake, uint256 slisBnbToRedeem) = _conversionAmounts( + token0, + token1, + wbnb, + token0ToToken1, + token1ToToken0 + ); + + return _convert(stakeManager, slisBnb, wbnb, token0, token1, total0, total1, bnbToStake, slisBnbToRedeem); + } + + /// @notice Convert free inventory between the WBNB and slisBNB legs and return adjusted totals. + /// - `bnbToStake`: unwrap that much WBNB and stake it (deposit) into slisBNB. + /// - `slisBnbToRedeem`: instant-redeem that much slisBNB into BNB, re-wrapped to WBNB. + /// Goes through the StakeManager at its on-chain exchange rate (not the pool), so it is + /// not market-manipulable; instantWithdraw deducts a deterministic fee. Amounts moved are + /// measured by balance delta so the returned totals stay exact, and capped to availability. + function convert( + IStakeManager stakeManager, + address slisBnb, + address wbnb, + address token0, + address token1, + uint256 total0, + uint256 total1, + uint256 bnbToStake, + uint256 slisBnbToRedeem + ) external returns (uint256, uint256) { + return _convert(stakeManager, slisBnb, wbnb, token0, token1, total0, total1, bnbToStake, slisBnbToRedeem); + } + + function _convert( + IStakeManager stakeManager, + address slisBnb, + address wbnb, + address token0, + address token1, + uint256 total0, + uint256 total1, + uint256 bnbToStake, + uint256 slisBnbToRedeem + ) private returns (uint256, uint256) { + if (bnbToStake == 0 && slisBnbToRedeem == 0) return (total0, total1); + if (bnbToStake > 0 && slisBnbToRedeem > 0) revert OneDirection(); + if (!((token0 == wbnb && token1 == slisBnb) || (token0 == slisBnb && token1 == wbnb))) { + revert NotSlisBnbWbnbPool(); + } + bool wbnbIs0 = token0 == wbnb; + + if (bnbToStake > 0) { + uint256 wbnbAvail = wbnbIs0 ? total0 : total1; + uint256 amt = bnbToStake > wbnbAvail ? wbnbAvail : bnbToStake; + if (amt > 0) { + uint256 sBefore = IERC20(slisBnb).balanceOf(address(this)); + IWBNB(wbnb).withdraw(amt); + stakeManager.deposit{ value: amt }(); + uint256 minted = IERC20(slisBnb).balanceOf(address(this)) - sBefore; + if (wbnbIs0) { + total0 -= amt; + total1 += minted; + } else { + total1 -= amt; + total0 += minted; + } + } + } else { + uint256 slisAvail = wbnbIs0 ? total1 : total0; + uint256 amt = slisBnbToRedeem > slisAvail ? slisAvail : slisBnbToRedeem; + if (amt > 0) { + uint256 bBefore = address(this).balance; + IERC20(slisBnb).safeIncreaseAllowance(address(stakeManager), amt); + stakeManager.instantWithdraw(amt); + uint256 bnbOut = address(this).balance - bBefore; + if (bnbOut > 0) IWBNB(wbnb).deposit{ value: bnbOut }(); + if (wbnbIs0) { + total0 += bnbOut; + total1 -= amt; + } else { + total1 += bnbOut; + total0 -= amt; + } + } + } + return (total0, total1); + } + + function _targetAmountsForOptimalRatio( + uint256 total0, + uint256 total1, + uint160 exchangeRateSqrtPriceX96, + uint160 sqrtLower, + uint160 sqrtUpper, + uint256 token1PerToken0Rate + ) private pure returns (uint256 target0, uint256 target1) { + (uint256 ratio0, uint256 ratio1) = LiquidityAmounts.getAmountsForLiquidity( + exchangeRateSqrtPriceX96, + sqrtLower, + sqrtUpper, + RATIO_SAMPLE_LIQUIDITY + ); + + if (ratio0 == 0) return (0, total1 + FullMath.mulDiv(total0, token1PerToken0Rate, RATE_SCALE)); + + uint256 ratio0ValueInToken1 = FullMath.mulDiv(ratio0, token1PerToken0Rate, RATE_SCALE); + uint256 denominator = ratio0ValueInToken1 + ratio1; + if (denominator == 0) return (total0, total1); + + uint256 totalValueInToken1 = total1 + FullMath.mulDiv(total0, token1PerToken0Rate, RATE_SCALE); + target0 = FullMath.mulDiv(totalValueInToken1, ratio0, denominator); + uint256 target0ValueInToken1 = FullMath.mulDiv(target0, token1PerToken0Rate, RATE_SCALE); + target1 = totalValueInToken1 > target0ValueInToken1 ? totalValueInToken1 - target0ValueInToken1 : 0; + } + + function _conversionAmounts( + address token0, + address token1, + address wbnb, + uint256 token0ToToken1, + uint256 token1ToToken0 + ) private pure returns (uint256 bnbToStake, uint256 slisBnbToRedeem) { + if (token0ToToken1 > 0) { + if (token0 == wbnb) bnbToStake = token0ToToken1; + else slisBnbToRedeem = token0ToToken1; + } else if (token1ToToken0 > 0) { + if (token1 == wbnb) bnbToStake = token1ToToken0; + else slisBnbToRedeem = token1ToToken0; + } + } +} 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/test/liquidator/V3Liquidator.t.sol b/test/liquidator/V3Liquidator.t.sol index c5a1d568..e00687ec 100644 --- a/test/liquidator/V3Liquidator.t.sol +++ b/test/liquidator/V3Liquidator.t.sol @@ -6,7 +6,7 @@ import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { V3Provider } from "../../src/provider/V3Provider.sol"; +import { SlisBNBV3Provider } from "../../src/provider/SlisBNBV3Provider.sol"; import { V3Liquidator } from "../../src/liquidator/V3Liquidator.sol"; import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; import { Moolah } from "../../src/moolah/Moolah.sol"; @@ -43,7 +43,7 @@ contract V3LiquidatorTest is Test { /* ───────────────────────── test contracts ───────────────────────── */ Moolah moolah; - V3Provider provider; + SlisBNBV3Provider provider; V3Liquidator liquidator; MockOneInch mockSwap; MarketParams marketParams; @@ -66,16 +66,15 @@ contract V3LiquidatorTest is Test { UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newImpl, bytes("")); moolah = Moolah(MOOLAH_PROXY); - // Deploy V3Provider. - (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); - V3Provider implP = new V3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); - provider = V3Provider( + // Deploy SlisBNBV3Provider. + SlisBNBV3Provider implP = new SlisBNBV3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); + provider = SlisBNBV3Provider( payable( new ERC1967Proxy( address(implP), abi.encodeCall( - V3Provider.initialize, - (admin, manager, bot, RESILIENT_ORACLE, currentTick - 500, currentTick + 500, "V3LP USDC/WBNB", "v3LP") + SlisBNBV3Provider.initialize, + (admin, manager, bot, RESILIENT_ORACLE, "V3LP USDC/WBNB", "v3LP") ) ) ) @@ -130,7 +129,7 @@ contract V3LiquidatorTest is Test { ) internal returns (uint256 shares, uint256 used0, uint256 used1) { deal(USDC, _user, amount0); deal(WBNB, _user, amount1); - (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); vm.startPrank(_user); IERC20(USDC).approve(address(provider), amount0); IERC20(WBNB).approve(address(provider), amount1); @@ -347,7 +346,7 @@ contract V3LiquidatorTest is Test { borrowed * 2 // amountOutMin — enough to cover repayment ); - // token1 (WBNB) swap: V3Provider unwraps WBNB → native BNB, V3Liquidator sends it via call{value}. + // 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, @@ -431,7 +430,7 @@ contract V3LiquidatorTest is Test { uint256 heldShares = provider.balanceOf(address(liquidator)); assertGt(heldShares, 0, "setup: liquidator holds shares"); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(heldShares); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(heldShares); vm.prank(bot); (uint256 out0, uint256 out1) = liquidator.redeemV3Shares( 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/V3Provider.t.sol b/test/provider/SlisBNBV3Provider.t.sol similarity index 87% rename from test/provider/V3Provider.t.sol rename to test/provider/SlisBNBV3Provider.t.sol index 84af8e4e..3a6c3569 100644 --- a/test/provider/V3Provider.t.sol +++ b/test/provider/SlisBNBV3Provider.t.sol @@ -6,6 +6,7 @@ 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/SlisBNBV3Provider.sol"; import { V3Provider } from "../../src/provider/V3Provider.sol"; import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; import { Moolah } from "../../src/moolah/Moolah.sol"; @@ -36,7 +37,7 @@ contract PoolSwapper { } } -contract V3ProviderTest is Test { +contract SlisBNBV3ProviderTest is Test { using MarketParamsLib for MarketParams; /* ─────────────────── PancakeSwap V3 BSC mainnet ─────────────────── */ @@ -59,10 +60,11 @@ contract V3ProviderTest is Test { uint32 constant TWAP_PERIOD = 1800; // 30 minutes uint256 constant LLTV = 70 * 1e16; + uint256 constant LLTV_SECOND = 71 * 1e16; /* ───────────────────────── test contracts ───────────────────────── */ Moolah moolah; - V3Provider provider; + SlisBNBV3Provider provider; MarketParams marketParams; Id marketId; @@ -84,18 +86,13 @@ contract V3ProviderTest is Test { UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newImpl, bytes("")); moolah = Moolah(MOOLAH_PROXY); - // Derive initial tick range from the live pool. - (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); - int24 tickLower = currentTick - 500; - int24 tickUpper = currentTick + 500; - - // Deploy V3Provider (implementation + UUPS proxy). - V3Provider impl = new V3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); + // Deploy SlisBNBV3Provider (implementation + UUPS proxy). + SlisBNBV3Provider impl = new SlisBNBV3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); bytes memory initData = abi.encodeCall( - V3Provider.initialize, - (admin, manager, bot, RESILIENT_ORACLE, tickLower, tickUpper, "V3Provider USDC/WBNB", "v3LP-USDC-WBNB") + SlisBNBV3Provider.initialize, + (admin, manager, bot, RESILIENT_ORACLE, "SlisBNBV3Provider USDC/WBNB", "v3LP-USDC-WBNB") ); - provider = V3Provider(payable(new ERC1967Proxy(address(impl), initData))); + provider = SlisBNBV3Provider(payable(new ERC1967Proxy(address(impl), initData))); // Build Moolah market: collateral = provider shares, oracle = provider. marketParams = MarketParams({ @@ -107,7 +104,7 @@ contract V3ProviderTest is Test { }); marketId = marketParams.id(); - // Create market and register V3Provider as the Moolah provider. + // Create market and register SlisBNBV3Provider as the Moolah provider. vm.prank(OPERATOR); moolah.createMarket(marketParams); @@ -131,7 +128,7 @@ contract V3ProviderTest is Test { deal(WBNB, _user, amount1); // Derive tight min amounts (0.1% slippage) from previewDeposit so that we // never bypass the slippage guard with zeros. - (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); uint256 min0 = (exp0 * 999) / 1000; uint256 min1 = (exp1 * 999) / 1000; vm.startPrank(_user); @@ -146,6 +143,28 @@ contract V3ProviderTest is Test { return col; } + function _createSecondMarket() internal returns (MarketParams memory secondParams, Id secondId) { + secondParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(provider), + 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 { @@ -157,6 +176,7 @@ contract V3ProviderTest is Test { assertEq(address(provider.POSITION_MANAGER()), NPM); assertEq(provider.resilientOracle(), RESILIENT_ORACLE); assertEq(provider.TWAP_PERIOD(), TWAP_PERIOD); + assertLt(provider.tickLower(), provider.tickUpper()); assertTrue(provider.hasRole(provider.DEFAULT_ADMIN_ROLE(), admin)); assertTrue(provider.hasRole(provider.MANAGER(), manager)); assertTrue(provider.hasRole(provider.BOT(), bot)); @@ -202,7 +222,7 @@ contract V3ProviderTest is Test { uint256 usdcBefore = IERC20(USDC).balanceOf(user); uint256 bnbBefore = user.balance; // WBNB (TOKEN1) is unwrapped to native BNB on withdrawal - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); uint256 min0 = (exp0 * 999) / 1000; uint256 min1 = (exp1 * 999) / 1000; @@ -221,7 +241,7 @@ contract V3ProviderTest is Test { function test_withdraw_partialWithdrawal() public { (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares / 2); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares / 2); uint256 min0 = (exp0 * 999) / 1000; uint256 min1 = (exp1 * 999) / 1000; @@ -238,10 +258,78 @@ contract V3ProviderTest is Test { // user2 cannot withdraw on behalf of user without authorization. // The revert fires on the auth check before min amounts are evaluated; use 1,1. vm.prank(user2); - vm.expectRevert("unauthorized"); + vm.expectRevert(V3Provider.Unauthorized.selector); provider.withdraw(marketParams, shares, 1, 1, user, user2); } + function test_withdrawShares_toWallet_doesNotRedeemUnderlying() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + uint256 supplyBefore = provider.totalSupply(); + uint256 tokenIdBefore = provider.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(provider.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, 1_000 ether, 3 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, 1_000 ether, 3 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, 1_000 ether, 3 ether); + + vm.prank(user2); + vm.expectRevert(V3Provider.InsufficientShares.selector); + provider.supplyShares(marketParams, shares, user2); + } + + function test_withdrawShares_supplyShares_movesCollateralBetweenMarkets() public { + (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 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, 1_000 ether, 3 ether); address liquidator = makeAddr("liquidator"); @@ -256,7 +344,7 @@ contract V3ProviderTest is Test { uint256 usdcBefore = IERC20(USDC).balanceOf(liquidator); uint256 bnbBefore = liquidator.balance; // WBNB (TOKEN1) is unwrapped to native BNB - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); uint256 min0 = (exp0 * 999) / 1000; uint256 min1 = (exp1 * 999) / 1000; @@ -273,7 +361,7 @@ contract V3ProviderTest is Test { _deposit(user, 1_000 ether, 3 ether); vm.prank(user); - vm.expectRevert("only moolah"); + vm.expectRevert(V3Provider.OnlyMoolah.selector); provider.transfer(user2, 1); } @@ -281,31 +369,28 @@ contract V3ProviderTest is Test { _deposit(user, 1_000 ether, 3 ether); vm.prank(user); - vm.expectRevert("only moolah"); + vm.expectRevert(V3Provider.OnlyMoolah.selector); provider.transferFrom(MOOLAH_PROXY, user2, 1); } function test_rebalance_onlyBot() public { _deposit(user, 1_000 ether, 3 ether); - (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); - int24 newLower = currentTick - 1000; - int24 newUpper = currentTick + 1000; - // manager cannot rebalance — revert fires on role check before amounts matter. vm.prank(manager); vm.expectRevert(); - provider.rebalance(newLower, newUpper, 1, 1, 1, 1); + provider.rebalance(1, 1, 1, block.timestamp); - // bot can rebalance — pass full available amounts so pool picks optimal ratio. + // bot can rebalance — range is derived internally by the provider. (uint256 total0, uint256 total1) = provider.getTotalAmounts(); uint256 min0 = (total0 * 999) / 1000; uint256 min1 = (total1 * 999) / 1000; + uint256 oldTokenId = provider.tokenId(); vm.prank(bot); - provider.rebalance(newLower, newUpper, min0, min1, total0, total1); + provider.rebalance(min0, min1, 0, block.timestamp); - assertEq(provider.tickLower(), newLower); - assertEq(provider.tickUpper(), newUpper); + assertGt(provider.tokenId(), oldTokenId, "position NFT should be re-minted"); + assertLt(provider.tickLower(), provider.tickUpper()); } function test_rebalance_liquidity_preserved() public { @@ -313,12 +398,11 @@ contract V3ProviderTest is Test { (uint256 total0Before, uint256 total1Before) = provider.getTotalAmounts(); - (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); (uint256 total0, uint256 total1) = provider.getTotalAmounts(); uint256 min0 = (total0 * 999) / 1000; uint256 min1 = (total1 * 999) / 1000; vm.prank(bot); - provider.rebalance(currentTick - 1000, currentTick + 1000, min0, min1, total0, total1); + provider.rebalance(min0, min1, 0, block.timestamp); // Share count is unchanged after rebalance. assertEq(_collateral(user), shares, "shares should be unchanged after rebalance"); @@ -430,7 +514,7 @@ contract V3ProviderTest is Test { uint256 amount0 = 1_000 ether; uint256 amount1 = 3 ether; - (uint128 liquidity, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + (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. @@ -452,7 +536,7 @@ contract V3ProviderTest is Test { uint256 amount0 = 5_000 ether; uint256 amount1 = 15 ether; - (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); // Apply 0.5% slippage tolerance. uint256 min0 = (exp0 * 995) / 1000; @@ -472,7 +556,7 @@ contract V3ProviderTest is Test { uint256 amount0 = 1_000 ether; uint256 amount1 = 3 ether; - (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); // Position is fully USDC — only token0 consumed, token1 = 0. assertGt(exp0, 0, "expected token0 consumed when price below range"); @@ -486,7 +570,7 @@ contract V3ProviderTest is Test { uint256 amount0 = 1_000 ether; uint256 amount1 = 3 ether; - (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + (, 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"); @@ -500,7 +584,7 @@ contract V3ProviderTest is Test { uint256 amount0 = 2_000 ether; uint256 amount1 = 6 ether; - (, uint256 exp0, uint256 exp1) = provider.previewDeposit(amount0, amount1); + (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); uint256 min0 = exp0 > 0 ? exp0 - 1 : 0; uint256 min1 = exp1 > 0 ? exp1 - 1 : 0; @@ -513,7 +597,7 @@ contract V3ProviderTest is Test { /* ──────────────── previewRedeem tests ──────────────────────────── */ function test_previewRedeem_zeroBeforeDeposit() public view { - (uint256 amount0, uint256 amount1) = provider.previewRedeem(1 ether); + (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"); } @@ -526,7 +610,7 @@ contract V3ProviderTest is Test { assertGt(currentTick, provider.tickLower(), "price should be above tickLower"); assertLt(currentTick, provider.tickUpper(), "price should be below tickUpper"); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (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"); @@ -548,7 +632,7 @@ contract V3ProviderTest is Test { vm.prank(MOOLAH_PROXY); provider.transfer(user2, shares); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); uint256 min0 = exp0 > 0 ? exp0 - 1 : 0; uint256 min1 = exp1 > 0 ? exp1 - 1 : 0; @@ -563,8 +647,8 @@ contract V3ProviderTest is Test { function test_previewRedeem_partialShares_proportional() public { (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); - (uint256 fullExp0, uint256 fullExp1) = provider.previewRedeem(shares); - (uint256 halfExp0, uint256 halfExp1) = provider.previewRedeem(shares / 2); + (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"); @@ -575,7 +659,7 @@ contract V3ProviderTest is Test { (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); _pushPriceBelowRange(); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (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"); } @@ -584,7 +668,7 @@ contract V3ProviderTest is Test { (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); _pushPriceAboveRange(); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (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"); } @@ -592,7 +676,7 @@ contract V3ProviderTest is Test { function test_previewRedeem_derivedMinAmounts_succeed() public { (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); // Apply 0.5% slippage tolerance. uint256 min0 = (exp0 * 995) / 1000; @@ -677,7 +761,7 @@ contract V3ProviderTest is Test { deal(USDC, user, 10_000 ether); vm.startPrank(user); IERC20(USDC).approve(address(provider), 10_000 ether); - vm.expectRevert("zero liquidity"); + vm.expectRevert(V3Provider.ZeroLiquidity.selector); provider.deposit(marketParams, 10_000 ether, 0, 0, 0, user); vm.stopPrank(); } @@ -687,7 +771,7 @@ contract V3ProviderTest is Test { deal(WBNB, user, 30 ether); vm.startPrank(user); IERC20(WBNB).approve(address(provider), 30 ether); - vm.expectRevert("zero liquidity"); + vm.expectRevert(V3Provider.ZeroLiquidity.selector); provider.deposit(marketParams, 0, 30 ether, 0, 0, user); vm.stopPrank(); } @@ -705,7 +789,7 @@ contract V3ProviderTest is Test { deal(USDC, user2, amount0); vm.startPrank(user2); IERC20(USDC).approve(address(provider), amount0); - (, uint256 exp0, ) = provider.previewDeposit(amount0, 0); + (, 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(); @@ -723,7 +807,7 @@ contract V3ProviderTest is Test { deal(WBNB, user2, 30 ether); vm.startPrank(user2); IERC20(WBNB).approve(address(provider), 30 ether); - vm.expectRevert("zero liquidity"); + vm.expectRevert(V3Provider.ZeroLiquidity.selector); provider.deposit(marketParams, 0, 30 ether, 0, 0, user2); vm.stopPrank(); } @@ -737,7 +821,7 @@ contract V3ProviderTest is Test { deal(WBNB, user2, amount1); vm.startPrank(user2); IERC20(WBNB).approve(address(provider), amount1); - (, , uint256 exp1) = provider.previewDeposit(0, 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(); @@ -755,7 +839,7 @@ contract V3ProviderTest is Test { deal(USDC, user2, 10_000 ether); vm.startPrank(user2); IERC20(USDC).approve(address(provider), 10_000 ether); - vm.expectRevert("zero liquidity"); + vm.expectRevert(V3Provider.ZeroLiquidity.selector); provider.deposit(marketParams, 10_000 ether, 0, 0, 0, user2); vm.stopPrank(); } @@ -769,7 +853,7 @@ contract V3ProviderTest is Test { vm.startPrank(user); IERC20(USDC).approve(address(provider), 1_000 ether); IERC20(WBNB).approve(address(provider), 3 ether); - vm.expectRevert("invalid collateral token"); + vm.expectRevert(V3Provider.InvalidCollateralToken.selector); // The revert fires before min amounts are evaluated; use 1,1 for consistency. provider.deposit(badParams, 1_000 ether, 3 ether, 1, 1, user); vm.stopPrank(); @@ -838,22 +922,13 @@ contract V3ProviderTest is Test { uint256 valueBefore = _valueUSD(total0Before, total1Before); assertGt(valueBefore, 0, "should have non-zero value before rebalance"); - // Rebalance to a range entirely ABOVE the current (very low) tick so that - // the entire range is below current price → only token0 (USDC) is needed to mint. - (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); - int24 newLower = newTick + 100; - int24 newUpper = newTick + 600; - - // Position is 100% USDC — only token0 needed for new range (price below it). + // Rebalance uses an internally derived range; caller only supplies execution guards. uint256 min0 = (total0Before * 999) / 1000; vm.prank(bot); - provider.rebalance(newLower, newUpper, min0, 0, total0Before, 0); + provider.rebalance(min0, 0, 0, block.timestamp); - assertEq(provider.tickLower(), newLower, "tickLower updated"); - assertEq(provider.tickUpper(), newUpper, "tickUpper updated"); + assertLt(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); - // Position is still fully USDC (price below new range). All USDC was deployed - // into the new position; getTotalAmounts captures it via position amounts. (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); uint256 valueAfter = _valueUSD(total0After, total1After); @@ -898,22 +973,13 @@ contract V3ProviderTest is Test { uint256 valueBefore = _valueUSD(total0Before, total1Before); assertGt(valueBefore, 0, "should have non-zero value before rebalance"); - // Rebalance to a range entirely BELOW the current (very high) tick so that - // the entire range is above current price → only token1 (WBNB) is needed to mint. - (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); - int24 newLower = newTick - 600; - int24 newUpper = newTick - 100; - - // Position is 100% WBNB — only token1 needed for new range (price above it). + // Rebalance uses an internally derived range; caller only supplies execution guards. uint256 min1 = (total1Before * 999) / 1000; vm.prank(bot); - provider.rebalance(newLower, newUpper, 0, min1, 0, total1Before); + provider.rebalance(0, min1, 0, block.timestamp); - assertEq(provider.tickLower(), newLower, "tickLower updated"); - assertEq(provider.tickUpper(), newUpper, "tickUpper updated"); + assertLt(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); - // Position is still fully WBNB (price above new range). All WBNB was deployed - // into the new position; getTotalAmounts captures it via position amounts. (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); uint256 valueAfter = _valueUSD(total0After, total1After); @@ -931,14 +997,11 @@ contract V3ProviderTest is Test { (uint256 total0, ) = provider.getTotalAmounts(); assertGt(total0, 0, "should hold USDC before rebalance"); - (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); - // minAmount0 = total0 (exact), minAmount1 = 0 (position has no WBNB). - // amount0Desired = total0, amount1Desired = 0 (reinvest all USDC, no WBNB available). vm.prank(bot); - provider.rebalance(newTick + 100, newTick + 600, total0, 0, total0, 0); + provider.rebalance(total0, 0, 0, block.timestamp); - assertEq(provider.tickLower(), newTick + 100, "tickLower updated"); + assertLt(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); } function test_rebalance_priceBelowRange_minAmount0_tooHigh_reverts() public { @@ -947,13 +1010,10 @@ contract V3ProviderTest is Test { (uint256 total0, ) = provider.getTotalAmounts(); - (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); - // minAmount0 one unit above actual → should revert with NPM slippage check. - // amount0Desired = total0 (correct available), minAmount0 = total0 + 1 (too tight). vm.prank(bot); vm.expectRevert(); - provider.rebalance(newTick + 100, newTick + 600, total0 + 1, 0, total0, 0); + provider.rebalance(total0 + 1, 0, 0, block.timestamp); } /// @dev When price is above range the position is 100% WBNB (token1). @@ -965,14 +1025,11 @@ contract V3ProviderTest is Test { (, uint256 total1) = provider.getTotalAmounts(); assertGt(total1, 0, "should hold WBNB before rebalance"); - (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); - // minAmount0 = 0 (no USDC), minAmount1 = total1 (exact). - // amount0Desired = 0, amount1Desired = total1 (reinvest all WBNB). vm.prank(bot); - provider.rebalance(newTick - 600, newTick - 100, 0, total1, 0, total1); + provider.rebalance(0, total1, 0, block.timestamp); - assertEq(provider.tickUpper(), newTick - 100, "tickUpper updated"); + assertLt(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); } function test_rebalance_priceAboveRange_minAmount1_tooHigh_reverts() public { @@ -981,19 +1038,16 @@ contract V3ProviderTest is Test { (, uint256 total1) = provider.getTotalAmounts(); - (, int24 newTick, , , , , ) = IListaV3Pool(POOL).slot0(); - // minAmount1 one unit above actual → should revert with NPM slippage check. - // amount1Desired = total1 (correct available), minAmount1 = total1 + 1 (too tight). vm.prank(bot); vm.expectRevert(); - provider.rebalance(newTick - 600, newTick - 100, 0, total1 + 1, 0, total1); + provider.rebalance(0, total1 + 1, 0, block.timestamp); } function test_withdraw_minAmount_tooHigh_reverts() public { (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); - (uint256 exp0, ) = provider.previewRedeem(shares); + (uint256 exp0, ) = provider.previewRedeemUnderlying(shares); vm.prank(user); vm.expectRevert(); @@ -1006,7 +1060,7 @@ contract V3ProviderTest is Test { vm.prank(MOOLAH_PROXY); provider.transfer(user2, shares); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); uint256 min0 = (exp0 * 999) / 1000; vm.prank(user2); @@ -1021,7 +1075,7 @@ contract V3ProviderTest is Test { (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); _pushPriceBelowRange(); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (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"); @@ -1037,7 +1091,7 @@ contract V3ProviderTest is Test { (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); _pushPriceAboveRange(); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (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"); @@ -1053,7 +1107,7 @@ contract V3ProviderTest is Test { // Setting min to 0 disables the floor but does not change what is received. (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); - (uint256 exp0, ) = provider.previewRedeem(shares); + (uint256 exp0, ) = provider.previewRedeemUnderlying(shares); vm.prank(user); (uint256 out0, uint256 out1) = provider.withdraw(marketParams, shares, (exp0 * 999) / 1000, 0, user, user); @@ -1154,7 +1208,7 @@ contract V3ProviderTest is Test { function test_withdraw_updatesUserMarketDeposit() public { (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); vm.prank(user); provider.withdraw(marketParams, shares, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); @@ -1166,7 +1220,7 @@ contract V3ProviderTest is Test { (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); uint256 half = shares / 2; - (uint256 exp0, uint256 exp1) = provider.previewRedeem(half); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(half); vm.prank(user); provider.withdraw(marketParams, half, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); @@ -1281,7 +1335,7 @@ contract V3ProviderTest is Test { address[] memory accounts = new address[](1); accounts[0] = user; - vm.expectRevert("length mismatch"); + vm.expectRevert(SlisBNBV3Provider.LengthMismatch.selector); provider.bulkSyncUserBalance(ids, accounts); } @@ -1290,7 +1344,7 @@ contract V3ProviderTest is Test { /// @dev Returns the Id of a live Moolah market whose collateralToken != address(provider). function _foreignMarketId() internal pure returns (Id) { // Use the first market in the live Moolah deployment (slisBNB / lisUSD). - // Its collateralToken is slisBNB, not this V3Provider. + // Its collateralToken is slisBNB, not this SlisBNBV3Provider. MarketParams memory foreign = MarketParams({ loanToken: LISUSD, collateralToken: 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B, // slisBNB @@ -1305,7 +1359,7 @@ contract V3ProviderTest is Test { _deposit(user, 1_000 ether, 3 ether); uint256 totalBefore = provider.userTotalDeposit(user); - vm.expectRevert("invalid market"); + vm.expectRevert(V3Provider.InvalidMarket.selector); provider.syncUserBalance(_foreignMarketId(), user); // Deposit tracking must be unchanged. @@ -1321,7 +1375,7 @@ contract V3ProviderTest is Test { address[] memory accounts = new address[](1); accounts[0] = user; - vm.expectRevert("invalid market"); + vm.expectRevert(V3Provider.InvalidMarket.selector); provider.bulkSyncUserBalance(ids, accounts); assertEq(provider.userTotalDeposit(user), totalBefore); @@ -1353,7 +1407,7 @@ contract V3ProviderTest is Test { (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); assertGt(ISlisBNBx(SLISBNBX).balanceOf(user), 0, "setup: slisBNBx minted after deposit"); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); vm.prank(user); provider.withdraw(marketParams, shares, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); @@ -1375,7 +1429,7 @@ contract V3ProviderTest is Test { assertGt(slisBNBxAfterDeposit, 0); uint256 half = shares / 2; - (uint256 exp0, uint256 exp1) = provider.previewRedeem(half); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(half); vm.prank(user); provider.withdraw(marketParams, half, (exp0 * 99) / 100, (exp1 * 99) / 100, user, user); @@ -1527,7 +1581,7 @@ contract V3ProviderTest is Test { moolah.liquidate(marketParams, user, shares, 0, ""); uint256 seizedShares = provider.balanceOf(liquidator); - (uint256 exp0, uint256 exp1) = provider.previewRedeem(seizedShares); + (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(seizedShares); (uint256 out0, uint256 out1) = provider.redeemShares( seizedShares, (exp0 * 99) / 100, @@ -1612,11 +1666,8 @@ contract V3ProviderTest is Test { // Ensure new range is entirely above the spot tick. assertGt(newLower, spotTickAfterSwap, "new tickLower should be above spot tick"); - // Collect total amounts for slippage params. - (uint256 t0, uint256 t1) = provider.getTotalAmounts(); - vm.prank(bot); - provider.rebalance(newLower, newUpper, 0, 0, t0, t1); + provider.rebalance(0, 0, 0, block.timestamp); // 5. peek() after rebalance — TWAP evaluates NEW range. uint256 peekAfterRebalance = provider.peek(address(provider)); @@ -1640,56 +1691,29 @@ contract V3ProviderTest is Test { emit log_named_int("new tickLower", newLower); emit log_named_int("new tickUpper", newUpper); - // Without maxTickDeviation guard, the rebalance succeeds and causes a large + // Without a spot/TWAP rebalance guard, the rebalance succeeds and causes a large // peek() discontinuity. This proves the TWAP-stale-window risk is real. assertGt(pctChange, 0.01e18, "peek() should show a >1% discontinuity due to stale TWAP"); } - /// @notice With maxTickDeviation set, the same rebalance is blocked — preventing - /// the peek() discontinuity from ever occurring. - function test_peek_discontinuity_blocked_by_maxTickDeviation() public { - _mockOraclePrices(); + /// @notice The V3 provider no longer blocks rebalance based on spot/TWAP tick deviation. + function test_rebalance_noLongerUsesTwapDeviationGuard() public { _deposit(user, 10_000 ether, 30 ether); - // Set the guard — only allow rebalance when spot ≈ TWAP. - vm.prank(manager); - provider.setMaxTickDeviation(100); - - _pushPriceBelowRange(); - - (, int24 spotTickAfterSwap, , , , , ) = IListaV3Pool(POOL).slot0(); - int24 newLower = spotTickAfterSwap + 100; - int24 newUpper = spotTickAfterSwap + 500; - (uint256 t0, uint256 t1) = provider.getTotalAmounts(); - vm.prank(bot); - vm.expectRevert("twap deviation too high"); - provider.rebalance(newLower, newUpper, 0, 0, t0, t1); + provider.rebalance(0, 0, 0, block.timestamp); + + assertLt(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); } - /* ───── rebalance TWAP deviation guard ───── */ + /* ───── rebalance without TWAP deviation guard ───── */ - function test_rebalance_succeeds_when_twap_deviation_within_limit() public { + function test_rebalance_succeeds_without_twap_deviation_config() public { _deposit(user, 10_000 ether, 30 ether); - // Set a generous deviation limit — slot0 and TWAP should be close after deposit. - vm.prank(manager); - provider.setMaxTickDeviation(5000); - - (, int24 spotTick, , , , , ) = IListaV3Pool(POOL).slot0(); - int24 twapTick = provider.getTwapTick(); - int24 delta = twapTick > spotTick ? twapTick - spotTick : spotTick - twapTick; - assertLt(uint24(delta), 5000, "deviation should be within limit"); - - // Rebalance to a slightly shifted range — should succeed. - int24 newLower = provider.tickLower() - 100; - int24 newUpper = provider.tickUpper() - 100; - (uint256 t0, uint256 t1) = provider.getTotalAmounts(); - vm.prank(bot); - provider.rebalance(newLower, newUpper, 0, 0, t0, t1); + provider.rebalance(0, 0, 0, block.timestamp); - assertEq(provider.tickLower(), newLower, "tickLower should be updated"); - assertEq(provider.tickUpper(), newUpper, "tickUpper should be updated"); + assertLt(provider.tickLower(), provider.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..d70d80fa --- /dev/null +++ b/test/provider/SlisBNBV3ProviderRate.t.sol @@ -0,0 +1,256 @@ +// 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/SlisBNBV3Provider.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"; + +/// @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 { + 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)); + } +} + +/// @notice Rate-path integration tests for SlisBNBV3Provider, forked against the live +/// PancakeSwap V3 slisBNB/WBNB 1bp pool + the real slisBNB StakeManager. Verifies the +/// exchange-rate oracle (peek / totalAssets / getUserBalanceInBnb) is invariant to pool-price +/// manipulation, and that the custom slisBNB/BNB rebalance entry point runs end-to-end. +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; + SlisBNBV3Provider provider; + 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()); + + // Deploy the (large) provider implementation FIRST, while setUp gas is untouched — forge's setUp + // gas forwarding chokes on the ~5.3M code-deposit if other deploys run before it. + SlisBNBV3Provider impl = new SlisBNBV3Provider(MOOLAH_PROXY, NPM, SLISBNB, WBNB, FEE, TWAP_PERIOD); + + moolah = Moolah(MOOLAH_PROXY); + + // 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); + + swapper = new PoolSwapper(); + emit log_named_uint("gas_after_swapper", gasleft()); + + bytes memory initData = abi.encodeCall( + SlisBNBV3Provider.initialize, + (admin, manager, bot, address(oracle), "slisBNB/BNB vLP", "vLP-slisBNB-BNB") + ); + provider = SlisBNBV3Provider(payable(new ERC1967Proxy(address(impl), initData))); + assertEq(provider.lastCenterRate(), rate, "lastCenterRate initialized from StakeManager"); + assertEq(provider.centerRateThresholdBps(), 100, "default center-rate threshold is 1%"); + + marketParams = MarketParams({ + loanToken: LISUSD, + collateralToken: address(provider), + oracle: address(provider), + irm: IRM, + lltv: LLTV + }); + marketId = marketParams.id(); + + vm.prank(OPERATOR); + moolah.createMarket(marketParams); + vm.prank(MANAGER_ADDR); + moolah.setProvider(marketId, address(provider), true); + } + + 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 = provider.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 = provider.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 = provider.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 = provider.peek(address(provider)); + uint256 oldTokenId = provider.tokenId(); + + vm.prank(manager); + provider.setCenterRateThresholdBps(0); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp); + + assertGt(provider.tokenId(), oldTokenId, "position should be re-minted"); + assertLt(provider.tickLower(), provider.tickUpper(), "rate-derived range should be valid"); + assertApproxEqRel(provider.peek(address(provider)), peekBefore, 2e16, "rebalance is ~value-neutral"); + assertEq(provider.lastCenterRate(), IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18), "center rate updated"); + } + + function test_rebalance_revertsWhenCenterRateDeviationBelowThreshold() public { + _deposit(10 ether, 10 ether); + + vm.prank(bot); + vm.expectRevert(SlisBNBV3Provider.RateDeviationBelowThreshold.selector); + provider.rebalance(0, 0, 0, block.timestamp); + } + + function test_rebalance_revertsAfterDeadline() public { + _deposit(10 ether, 10 ether); + + vm.prank(bot); + vm.expectRevert(SlisBNBV3Provider.DeadlineExpired.selector); + provider.rebalance(0, 0, 0, block.timestamp - 1); + } + + function test_rebalance_revertsWhenMinLiquidityTooHigh() public { + _deposit(10 ether, 10 ether); + + vm.prank(manager); + provider.setCenterRateThresholdBps(0); + + vm.prank(bot); + vm.expectRevert(SlisBNBV3Provider.InsufficientLiquidityMinted.selector); + provider.rebalance(0, 0, type(uint256).max, block.timestamp); + } + + function _tick() internal view returns (int24 tick) { + (, tick, , , , , ) = IListaV3Pool(POOL).slot0(); + } +} From f1ecce92d35f06482454dff7df0a7e608f72be29 Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:48:15 +0800 Subject: [PATCH 09/17] refactor(provider): split slisBNB/BNB V3 LP into vault + DEX adapter + oracle Split the monolithic SlisBNBV3Provider (28.5 KB, over EIP-170) into three UUPS contracts, each well under the limit, with clean responsibility boundaries: - V3Provider / SlisBNBV3Provider: ERC-4626 vLP shares + Moolah wiring + share accounting (holds no NFT); slisBNB subclass adds slisBNBx mirroring + BOT rebalance. - V3DexAdapter / SlisBNBV3DexAdapter: sole V3 NFT/idle custodian + all NPM/pool math; slisBNB subclass adds the exchange-rate-implied fair price, +/-1% tick centering, and the rate-centered rebalance + StakeManager inventory conversion. - SlisBNBV3ProviderOracle: Moolah market.oracle; prices the share off the adapter's fair composition (manipulation-resistant) + resilient oracle, capped haircut. Key points: - CEI: burn shares BEFORE adapter.removeLiquidity pushes underlying to the receiver, so totalSupply stays consistent with the reduced position during the BNB callback. - Oracle reverts on zero leg price / zero total value (finding D); raw-NAV vs haircut split is clean (adapter returns raw, oracle applies the only haircut). - slisBNB/BNB-only: adapter and oracle constructors reject any non-slisBNB/WBNB pair; the oracle also asserts its tokens match the adapter's (no wiring divergence). - IV3PoolMinimal: decode only slot0 sqrtPriceX96/tick, width-agnostic to feeProtocol (Uniswap uint8 vs PancakeSwap uint32) so the adapter works against any V3 fork. - Tests retargeted to the slisBNB/WBNB pool: functional 87, rate-path 8, liquidator 27 (etched StakeManager stand-in for the not-yet-deployed instantWithdraw). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/provider/SlisBNBV3DexAdapter.sol | 287 ++++++ src/provider/SlisBNBV3Provider.sol | 350 +------ src/provider/SlisBNBV3ProviderOracle.sol | 155 ++++ src/provider/V3DexAdapter.sol | 394 ++++++++ src/provider/V3Provider.sol | 688 +++----------- .../interfaces/ISlisBNBV3DexAdapter.sol | 21 + src/provider/interfaces/IV3DexAdapter.sol | 110 +++ src/provider/interfaces/IV3PoolMinimal.sol | 16 + src/provider/interfaces/IV3Provider.sol | 39 +- src/provider/interfaces/IV3ProviderOracle.sol | 32 + test/liquidator/V3Liquidator.t.sol | 126 ++- test/provider/SlisBNBV3Provider.t.sol | 862 ++++++++++-------- test/provider/SlisBNBV3ProviderRate.t.sol | 180 +++- 13 files changed, 1886 insertions(+), 1374 deletions(-) create mode 100644 src/provider/SlisBNBV3DexAdapter.sol create mode 100644 src/provider/SlisBNBV3ProviderOracle.sol create mode 100644 src/provider/V3DexAdapter.sol create mode 100644 src/provider/interfaces/ISlisBNBV3DexAdapter.sol create mode 100644 src/provider/interfaces/IV3DexAdapter.sol create mode 100644 src/provider/interfaces/IV3PoolMinimal.sol create mode 100644 src/provider/interfaces/IV3ProviderOracle.sol diff --git a/src/provider/SlisBNBV3DexAdapter.sol b/src/provider/SlisBNBV3DexAdapter.sol new file mode 100644 index 00000000..b826fd01 --- /dev/null +++ b/src/provider/SlisBNBV3DexAdapter.sol @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { FullMath } from "lista-dao-contracts/oracle/libraries/FullMath.sol"; +import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; +import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; + +import { V3DexAdapter } from "./V3DexAdapter.sol"; +import { IV3PoolMinimal } from "./interfaces/IV3PoolMinimal.sol"; +import { IStakeManager } from "./interfaces/IStakeManager.sol"; +import { IV3DexAdapter } from "./interfaces/IV3DexAdapter.sol"; +import { ISlisBNBV3DexAdapter } from "./interfaces/ISlisBNBV3DexAdapter.sol"; +import { V3PositionLib } from "./libraries/V3PositionLib.sol"; +import { SlisBnbInventoryLib } from "./libraries/SlisBnbInventoryLib.sol"; + +/** + * @title SlisBNBV3DexAdapter + * @author Lista DAO + * @notice slisBNB/BNB specialization of {V3DexAdapter}. Adds: + * - exchange-rate-implied fair price (StakeManager rate, not pool spot/TWAP); + * - exchange-rate ±1% auto-centered tick range derivation; + * - rate-centered `rebalance` with a rate-drift guard + StakeManager inventory conversion. + */ +contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { + /* ─────────────────────────── constants ──────────────────────────── */ + + address public constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; + IStakeManager public constant STAKE_MANAGER = IStakeManager(0x1adB950d8bB3dA4bE104211D5AB038628e477fE6); + + uint256 internal constant BPS = 10_000; + uint256 internal constant INITIAL_RANGE_BPS = 100; // ±1% + int24 internal constant FALLBACK_HALF_RANGE_TICKS = 500; + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev Exchange rate at the last successful center/init; used as the range center. + uint256 public lastCenterRate; + + /// @dev Min relative exchange-rate drift from lastCenterRate before rebalance is allowed (BPS; 0 = off). + uint256 public centerRateThresholdBps; + + /* ───────────────────────────── events ───────────────────────────── */ + + event CenterRateThresholdChanged(uint256 centerRateThresholdBps); + event LastCenterRateUpdated(uint256 oldCenterRate, uint256 newCenterRate); + event Rebalanced(int24 oldTickLower, int24 oldTickUpper, int24 newTickLower, int24 newTickUpper, uint256 newTokenId); + + /* ───────────────────────────── errors ───────────────────────────── */ + + error DeadlineExpired(); + error InsufficientLiquidityMinted(); + error RateDeviationBelowThreshold(); + error InvalidThreshold(); + 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) { + // slisBNB/BNB-ONLY: the rate-implied fair price, ±1% tick centering and StakeManager inventory + // conversion all 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). + */ + function initialize(address _admin, address _manager) external initializer { + uint256 initialCenterRate; + if (_isSlisBnbWbnbPool()) initialCenterRate = _poolPriceRate(); + (int24 initialTickLower, int24 initialTickUpper) = _initialTickRange(initialCenterRate); + __V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper); + lastCenterRate = initialCenterRate; + centerRateThresholdBps = INITIAL_RANGE_BPS; + } + + /* ───────────────────────── view overrides ───────────────────────── */ + + /// @dev Fair price = exchange-rate-implied (manipulation-resistant). Falls back to TWAP for any + /// non-slisBNB/WBNB pair. + function fairSqrtPriceX96() public view override(V3DexAdapter, IV3DexAdapter) returns (uint160) { + if (!_isSlisBnbWbnbPool()) return super.fairSqrtPriceX96(); + return _sqrtPriceX96FromRate(_poolPriceRate()); + } + + /* ─────────────────────── manager / rebalance ────────────────────── */ + + /// @notice Set min exchange-rate drift from lastCenterRate required for rebalance (0 = off). + function setCenterRateThresholdBps(uint256 _centerRateThresholdBps) external onlyRole(MANAGER) { + if (_centerRateThresholdBps > BPS) revert InvalidThreshold(); + centerRateThresholdBps = _centerRateThresholdBps; + emit CenterRateThresholdChanged(_centerRateThresholdBps); + } + + /// @inheritdoc ISlisBNBV3DexAdapter + function rebalance( + uint256 minAmount0, + uint256 minAmount1, + uint256 minLiquidity, + uint256 deadline + ) external onlyProvider nonReentrant { + if (block.timestamp > deadline) revert DeadlineExpired(); + + uint256 centerRate; + bool isSlisPool = _isSlisBnbWbnbPool(); + if (isSlisPool) { + centerRate = _poolPriceRate(); + _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) = _rebalanceInventoryToOptimalRatio(total0, total1, newTickLower, newTickUpper, centerRate); + + 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 (isSlisPool) { + uint256 oldCenterRate = lastCenterRate; + lastCenterRate = centerRate; + emit LastCenterRateUpdated(oldCenterRate, centerRate); + } + + emit Rebalanced(oldTickLower, oldTickUpper, newTickLower, newTickUpper, tokenId); + } + + /// @dev Accept native BNB from WBNB unwrap or StakeManager instantWithdraw. + receive() external payable override { + if (!(msg.sender == WBNB || msg.sender == address(STAKE_MANAGER))) revert NotWBNB(); + } + + /* ─────────────────────────── internals ──────────────────────────── */ + + function _initialTickRange( + uint256 centerRate + ) internal view returns (int24 initialTickLower, int24 initialTickUpper) { + int24 tickSpacing = IListaV3Pool(POOL).tickSpacing(); + + if (_isSlisBnbWbnbPool()) { + (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 pure 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 _rebalanceInventoryToOptimalRatio( + uint256 total0, + uint256 total1, + int24 targetTickLower, + int24 targetTickUpper, + uint256 centerRate + ) internal returns (uint256, uint256) { + if (!_isSlisBnbWbnbPool()) return (total0, total1); + return + SlisBnbInventoryLib.convertToOptimalRatio( + STAKE_MANAGER, + SLISBNB, + WBNB, + TOKEN0, + TOKEN1, + total0, + total1, + _sqrtPriceX96FromRate(centerRate), + targetTickLower, + targetTickUpper, + centerRate + ); + } + + 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(); + } + + 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); + } + + function _sqrtPriceX96FromRate(uint256 rate) internal pure returns (uint160) { + return uint160(Math.sqrt(FullMath.mulDiv(rate, 1 << 192, 1e18))); + } + + 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; + } +} diff --git a/src/provider/SlisBNBV3Provider.sol b/src/provider/SlisBNBV3Provider.sol index d948c528..b94e3ffc 100644 --- a/src/provider/SlisBNBV3Provider.sol +++ b/src/provider/SlisBNBV3Provider.sol @@ -1,136 +1,80 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.34; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; - import { IMoolah, Id } from "moolah/interfaces/IMoolah.sol"; import { IOracle } from "moolah/interfaces/IOracle.sol"; -import { FullMath } from "lista-dao-contracts/oracle/libraries/FullMath.sol"; -import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; -import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; import { V3Provider } from "./V3Provider.sol"; -import { IStakeManager } from "./interfaces/IStakeManager.sol"; -import { V3PositionLib } from "./libraries/V3PositionLib.sol"; -import { SlisBnbInventoryLib } from "./libraries/SlisBnbInventoryLib.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 specialization of {V3Provider}. Adds the slisBNB-specific behaviour on top - * of the generic V3 LP provider: - * - Inventory rebalancing via the slisBNB StakeManager (stake BNB -> slisBNB on excess - * BNB; instant-redeem slisBNB -> BNB on excess slisBNB) wired into rebalance(). - * - slisBNBx reward mirroring: tracks each user's collateral per market and pings the - * SlisBNBxMinter after every deposit / withdraw / liquidation; exposes the - * ISlisBNBxModule `getUserBalanceInBnb` callback. - * - * Generic position management (deposit / withdraw / redeem / compounding / share oracle) - * lives in {V3Provider}. + * @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 { - /* ─────────────────────────── constants ──────────────────────────── */ - - /// @dev slisBNB liquid-staking token (BSC). The non-WBNB leg of the managed pool. - address public constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; - - /// @dev Lista slisBNB StakeManager. Used to rebalance inventory between the two pool legs: - /// stake BNB -> slisBNB (deposit) and instant-redeem slisBNB -> BNB (instantWithdraw). - IStakeManager public constant STAKE_MANAGER = IStakeManager(0x1adB950d8bB3dA4bE104211D5AB038628e477fE6); - /// @dev Virtual address used by the resilient oracle to price native BNB. address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - uint256 internal constant BPS = 10_000; - uint256 internal constant INITIAL_RANGE_BPS = 100; - int24 internal constant FALLBACK_HALF_RANGE_TICKS = 500; - /* ──────────────────────────── storage ───────────────────────────── */ - /// @dev user account > market id > amount of collateral(shares) deposited mapping(address => mapping(Id => uint256)) public userMarketDeposit; - - /// @dev user account > total amount of collateral(shares) deposited mapping(address => uint256) public userTotalDeposit; - - /// @dev slisBNBxMinter address address public slisBNBxMinter; - /// @dev Exchange rate used to derive the current centered slisBNB/BNB tick range. - uint256 public lastCenterRate; - - /// @dev Min relative exchange-rate drift from lastCenterRate before rebalance is allowed. - /// BPS precision; 0 disables the rate-drift guard. - uint256 public centerRateThresholdBps; - /* ───────────────────────────── events ───────────────────────────── */ event SlisBNBxMinterChanged(address indexed minter); - event CenterRateThresholdChanged(uint256 centerRateThresholdBps); - event LastCenterRateUpdated(uint256 oldCenterRate, uint256 newCenterRate); /* ───────────────────────────── errors ───────────────────────────── */ error LengthMismatch(); - error DeadlineExpired(); - error InsufficientLiquidityMinted(); - error RateDeviationBelowThreshold(); - error InvalidThreshold(); - - /* ─────────────────────── constructor / init ─────────────────────── */ /// @custom:oz-upgrades-unsafe-allow constructor - constructor( - address _moolah, - address _positionManager, - address _token0, - address _token1, - uint24 _fee, - uint32 _twapPeriod - ) V3Provider(_moolah, _positionManager, _token0, _token1, _fee, _twapPeriod) {} + constructor(address _moolah, address _adapter) V3Provider(_moolah, _adapter) {} - /** - * @param _admin Default admin (can upgrade, grant roles) - * @param _manager Manager role (can rebalance position range) - * @param _bot Bot address granted BOT role (can trigger rebalance) - * @param _resilientOracle Resilient oracle for pricing TOKEN0 and TOKEN1 - * @param _name ERC20 name for shares token - * @param _symbol ERC20 symbol for shares token - */ function initialize( address _admin, address _manager, address _bot, address _resilientOracle, + address _accountingAsset, string calldata _name, string calldata _symbol ) external initializer { - uint256 initialCenterRate; - if (_isSlisBnbWbnbPool()) initialCenterRate = _poolPriceRate(); - (int24 initialTickLower, int24 initialTickUpper) = _initialTickRange(initialCenterRate); - __V3Provider_init(_admin, _manager, _bot, _resilientOracle, initialTickLower, initialTickUpper, _name, _symbol); - lastCenterRate = initialCenterRate; - centerRateThresholdBps = INITIAL_RANGE_BPS; + __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. + function rebalance( + uint256 minAmount0, + uint256 minAmount1, + uint256 minLiquidity, + uint256 deadline + ) external onlyRole(BOT) nonReentrant { + ISlisBNBV3DexAdapter(ADAPTER).rebalance(minAmount0, minAmount1, minLiquidity, deadline); } /* ─────────────────── slisBNBx: sync / view ──────────────────────── */ - /** - * @notice Returns the user's total deposited collateral value expressed in BNB (18 decimals). - * Called by SlisBNBxMinter as the ISlisBNBxModule callback to compute how much - * slisBNBx the user is entitled to. - * @param account The user whose position is being priced. - */ + /// @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; - // Value at the exchange-rate-implied price (manipulation-resistant), consistent with peek(). - (uint256 total0, uint256 total1) = _getTotalAmountsAt(_valuationSqrtPriceX96()); + (uint256 total0, uint256 total1) = IV3DexAdapter(ADAPTER).positionAmountsAt( + IV3DexAdapter(ADAPTER).fairSqrtPriceX96() + ); uint256 user0 = (total0 * shares) / supply; uint256 user1 = (total1 * shares) / supply; @@ -139,28 +83,16 @@ contract SlisBNBV3Provider is V3Provider { uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8-decimal USD uint256 bnbPrice = IOracle(resilientOracle).peek(BNB_ADDRESS); // 8-decimal USD - // Scale up by 1e18 before dividing by bnbPrice so the result is 18-decimal BNB. uint256 value0 = (user0 * price0 * 1e18) / (10 ** DECIMALS0); uint256 value1 = (user1 * price1 * 1e18) / (10 ** DECIMALS1); - return (value0 + value1) / bnbPrice; } - /** - * @notice Manually sync one user's deposit tracking and slisBNBx balance for a market. - * @param id Moolah market Id (collateralToken must equal address(this)). - * @param account User to sync. - */ function syncUserBalance(Id id, address account) external { if (MOOLAH.idToMarketParams(id).collateralToken != address(this)) revert InvalidMarket(); _syncPosition(id, account); } - /** - * @notice Batch sync multiple users across multiple markets. - * @param ids Array of market Ids. - * @param accounts Array of user addresses (parallel to ids). - */ function bulkSyncUserBalance(Id[] calldata ids, address[] calldata accounts) external { if (ids.length != accounts.length) revert LengthMismatch(); for (uint256 i = 0; i < accounts.length; i++) { @@ -171,247 +103,19 @@ contract SlisBNBV3Provider is V3Provider { /* ──────────────────── manager: slisBNBxMinter ───────────────────── */ - /// @notice Set (or unset) the SlisBNBxMinter plugin. Pass address(0) to disable. - /// When set, deposit/withdraw/liquidate call minter.rebalance(account). function setSlisBNBxMinter(address _slisBNBxMinter) external onlyRole(MANAGER) { slisBNBxMinter = _slisBNBxMinter; emit SlisBNBxMinterChanged(_slisBNBxMinter); } - /// @notice Set min exchange-rate drift from lastCenterRate required for rebalance. - /// Pass 0 to disable the guard. - function setCenterRateThresholdBps(uint256 _centerRateThresholdBps) external onlyRole(MANAGER) { - if (_centerRateThresholdBps > BPS) revert InvalidThreshold(); - centerRateThresholdBps = _centerRateThresholdBps; - emit CenterRateThresholdChanged(_centerRateThresholdBps); - } - - /** - * @notice Recenter the managed position to the exchange-rate-derived range. - * @dev Caller supplies execution guards only. The target ticks and reinvested amounts are computed on-chain. - * @param minAmount0 Min TOKEN0 to receive when removing old liquidity. - * @param minAmount1 Min TOKEN1 to receive when removing old liquidity. - * @param minLiquidity Minimum liquidity that must be minted in the new position. - * @param deadline Latest acceptable timestamp for this rebalance transaction. - */ - function rebalance( - uint256 minAmount0, - uint256 minAmount1, - uint256 minLiquidity, - uint256 deadline - ) external onlyRole(BOT) nonReentrant { - if (block.timestamp > deadline) revert DeadlineExpired(); - - uint256 centerRate; - bool isSlisPool = _isSlisBnbWbnbPool(); - if (isSlisPool) { - centerRate = _poolPriceRate(); - _requireCenterRateDeviation(centerRate); - } - - (int24 newTickLower, int24 newTickUpper) = _initialTickRange(centerRate); - - int24 oldTickLower = tickLower; - int24 oldTickUpper = tickUpper; - - (uint256 total0, uint256 total1) = _collectAll(); - - 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) = _rebalanceInventoryToOptimalRatio(total0, total1, newTickLower, newTickUpper, centerRate); - - 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; - } + /* ────────────────────────── hook override ───────────────────────── */ - if (uint256(mintedLiquidity) < minLiquidity) revert InsufficientLiquidityMinted(); - - if (isSlisPool) { - uint256 oldCenterRate = lastCenterRate; - lastCenterRate = centerRate; - emit LastCenterRateUpdated(oldCenterRate, centerRate); - } - - emit Rebalanced(oldTickLower, oldTickUpper, newTickLower, newTickUpper, tokenId); - } - - /* ────────────────────────── hook overrides ──────────────────────── */ - - /// @dev Mirror the collateral change into deposit tracking + slisBNBx after every - /// deposit / withdraw / liquidation. function _afterCollateralChange(Id id, address account) internal override { _syncPosition(id, account); } - /// @dev Accepts native BNB from WBNB unwrap or from the StakeManager on instantWithdraw. - receive() external payable override { - if (!(msg.sender == WBNB || msg.sender == address(STAKE_MANAGER))) revert NotWBNB(); - } - - /// @dev Lending-oracle valuation price = the slisBNB exchange rate (BNB per slisBNB), NOT the pool - /// spot/TWAP. The position is split into (slisBNB, WBNB) at this fair price, so a pool trade - /// that pushes the AMM price within the narrow band cannot move the reported collateral value - /// (PRD §4.5). The rate comes from the slisBNB StakeManager (on-chain staking state). - /// For any non-slisBNB/WBNB pair this falls back to the base TWAP pricing. - function _valuationSqrtPriceX96() internal view override returns (uint160) { - bool slisIs0 = TOKEN0 == SLISBNB && TOKEN1 == WBNB; - bool wbnbIs0 = TOKEN0 == WBNB && TOKEN1 == SLISBNB; - if (!slisIs0 && !wbnbIs0) return super._valuationSqrtPriceX96(); - - return _sqrtPriceX96FromRate(_poolPriceRate()); - } - - /* ─────────────────────────── internals ──────────────────────────── */ - - /// @dev Initial slisBNB/WBNB range is exchange-rate ±1%, snapped to pool tick spacing. - /// For non-slis test pools, fall back to a spot-centered range so generic V3 tests remain usable. - function _initialTickRange(uint256 centerRate) internal view returns (int24 initialTickLower, int24 initialTickUpper) { - int24 tickSpacing = IListaV3Pool(POOL).tickSpacing(); - - if (_isSlisBnbWbnbPool()) { - (initialTickLower, initialTickUpper) = _tickRangeForRate(centerRate, tickSpacing); - } else { - (, int24 currentTick, , , , , ) = IListaV3Pool(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 pure 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 _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); - } - - function _sqrtPriceX96FromRate(uint256 rate) internal pure returns (uint160) { - return uint160(Math.sqrt(FullMath.mulDiv(rate, 1 << 192, 1e18))); - } - - function _tickAtSqrtRatio(uint160 sqrtPriceX96) internal pure returns (int24 tick) { - 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; - } - - function _rebalanceInventoryToOptimalRatio( - uint256 total0, - uint256 total1, - int24 targetTickLower, - int24 targetTickUpper, - uint256 centerRate - ) internal returns (uint256, uint256) { - if (!_isSlisBnbWbnbPool()) return (total0, total1); - - return - SlisBnbInventoryLib.convertToOptimalRatio( - STAKE_MANAGER, - SLISBNB, - WBNB, - TOKEN0, - TOKEN1, - total0, - total1, - _sqrtPriceX96FromRate(centerRate), - targetTickLower, - targetTickUpper, - centerRate - ); - } - - 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 Reads the user's current Moolah collateral for `id`, diffs against the last - /// recorded snapshot in `userMarketDeposit`, updates `userTotalDeposit`, then - /// calls `slisBNBxMinter.rebalance(account)` if a minter is configured. 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 { diff --git a/src/provider/SlisBNBV3ProviderOracle.sol b/src/provider/SlisBNBV3ProviderOracle.sol new file mode 100644 index 00000000..a51436bd --- /dev/null +++ b/src/provider/SlisBNBV3ProviderOracle.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/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 { IV3ProviderOracle } from "./interfaces/IV3ProviderOracle.sol"; + +/** + * @title SlisBNBV3ProviderOracle + * @author Lista DAO + * @notice Standalone IOracle for the slisBNB/BNB vLP share token (Moolah `market.oracle` points here). + * Prices the share off the DEX adapter's FAIR composition view (staticcall, no double-hop + * through the vault) — for slisBNB/BNB that fair price is exchange-rate-implied (StakeManager + * rate, not pool spot/TWAP) — pricing each leg via the resilient oracle, then applying a + * conservative haircut. Separating pricing from the vault isolates the estimation-bug radius + * from vault state. + * + * @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 SlisBNBV3ProviderOracle is UUPSUpgradeable, AccessControlUpgradeable, 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 slisBNB/BNB-only pair (token0 < token1; slisBNB < WBNB). + address public constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; + address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + + 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 NotSlisBnbWbnbPair(); + error AdapterPairMismatch(); + + /// @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(); + // slisBNB/BNB-ONLY: reject any other pair, and require the tokens (and their order) match the + // adapter's, so peek() prices exactly the composition the adapter reports. + if (!(_token0 == SLISBNB && _token1 == WBNB)) revert NotSlisBnbWbnbPair(); + if (_token0 != IV3DexAdapter(_adapter).TOKEN0() || _token1 != IV3DexAdapter(_adapter).TOKEN1()) + revert AdapterPairMismatch(); + ADAPTER = _adapter; + PROVIDER_SHARE = _providerShare; + TOKEN0 = _token0; + TOKEN1 = _token1; + DECIMALS0 = IERC20Metadata(_token0).decimals(); + DECIMALS1 = IERC20Metadata(_token1).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 (exchange-rate-implied; not pool spot/TWAP). + (uint256 total0, uint256 total1) = IV3DexAdapter(ADAPTER).positionAmountsAt( + IV3DexAdapter(ADAPTER).fairSqrtPriceX96() + ); + + uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8 decimals + 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 1e18 shares, minus the conservative haircut. + uint256 raw = (totalValue * 1e18) / 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/V3DexAdapter.sol b/src/provider/V3DexAdapter.sol new file mode 100644 index 00000000..61e89011 --- /dev/null +++ b/src/provider/V3DexAdapter.sol @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.34; + +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import { 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 { INonfungiblePositionManager } from "./interfaces/INonfungiblePositionManager.sol"; +import { V3PositionLib } from "./libraries/V3PositionLib.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 { 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. + * + * Extension points (slisBNB/BNB subclass overrides): + * - fairSqrtPriceX96(): exchange-rate-implied price instead of pool TWAP. + * - receive(): widen accepted native-BNB senders (StakeManager instantWithdraw). + * - rebalance(): added by the subclass (rate-centered recenter + inventory conversion). + */ +abstract contract V3DexAdapter is + UUPSUpgradeable, + AccessControlUpgradeable, + 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 BSC wrapped native token. + address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + + bytes32 public constant MANAGER = keccak256("MANAGER"); + + /* ──────────────────────────── 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 Reserved storage for future base variables (keep subclass storage stable on upgrade). + uint256[50] 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); + + /* ───────────────────────────── errors ───────────────────────────── */ + + error ZeroAddress(); + error TokenOrderInvalid(); + error ZeroFee(); + error ZeroTwapPeriod(); + error PoolDoesNotExist(); + error InvalidTickRange(); + error OnlyProvider(); + error ProviderAlreadySet(); + error BnbTransferFailed(); + error NotWBNB(); + + /* ─────────────────────────── constructor ────────────────────────── */ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _positionManager, address _token0, address _token1, uint24 _fee, uint32 _twapPeriod) { + if (_positionManager == address(0)) revert ZeroAddress(); + if (_token0 == address(0) || _token1 == 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; + 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. + function setProvider(address _provider) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_provider == address(0)) revert ZeroAddress(); + if (provider != address(0)) revert ProviderAlreadySet(); + 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. WBNB is unwrapped to native BNB. + 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(); + } + + /* ───────────────────────── 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 + function fairSqrtPriceX96() public view virtual returns (uint160) { + return TickMath.getSqrtRatioAtTick(_twapTick()); + } + + /// @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 WBNB to native BNB. + function _sendToken(address token, uint256 amount, address payable to) internal { + if (token == WBNB) { + IWBNB(WBNB).withdraw(amount); + (bool ok, ) = to.call{ value: amount }(""); + if (!ok) revert BnbTransferFailed(); + } else { + IERC20(token).safeTransfer(to, amount); + } + } + + /// @dev Accepts native BNB from WBNB unwrap. Subclasses widen the allowed senders. + receive() external payable virtual { + if (msg.sender != WBNB) revert NotWBNB(); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol index 78c04b50..e31855f9 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/V3Provider.sol @@ -9,53 +9,40 @@ import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/ 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 { IMoolah, MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; import { MarketParamsLib } from "moolah/libraries/MarketParamsLib.sol"; -import { IOracle, TokenConfig } from "moolah/interfaces/IOracle.sol"; +import { IOracle } from "moolah/interfaces/IOracle.sol"; -import { INonfungiblePositionManager } from "./interfaces/INonfungiblePositionManager.sol"; -import { V3PositionLib } from "./libraries/V3PositionLib.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 { IV3Provider } from "./interfaces/IV3Provider.sol"; +import { IV3DexAdapter } from "./interfaces/IV3DexAdapter.sol"; /** * @title V3Provider * @author Lista DAO - * @notice Generic, abstract base that manages a single Uniswap V3 / PancakeSwap V3 concentrated - * liquidity position and issues ERC20 shares representing pro-rata ownership of it. - * Registered as a Moolah provider so it can supply and withdraw collateral on behalf of - * users without requiring per-user Moolah authorization. + * @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: - * - Shares (this contract's ERC20 token) are the Moolah collateral token for the market. - * - On deposit: tokens → V3 liquidity → mint shares → Moolah.supplyCollateral(onBehalf) - * - On withdraw: Moolah.withdrawCollateral → burn shares → remove V3 liquidity → tokens to receiver - * - On liquidation: Moolah sends shares to liquidator; liquidator calls redeemShares() - * - Fees are compounded into the position before every deposit/withdraw/maintenance operation. - * - Only Moolah may transfer shares (prevents bypassing the vault on withdrawal). + * 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. * - * Extension points (overridden by pool/asset-specific subclasses): - * - _afterCollateralChange(id, account): hook called after deposit / withdraw / liquidation, - * e.g. to mirror the position into an external reward system. - * - peek / getTokenConfig / receive: virtual so subclasses can specialize pricing and native - * token acceptance. + * 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) * - * Dependencies: - * lib/lista-v3 (submodule) - IListaV3Factory / IListaV3Pool interfaces. - * lib/lista-dao-contracts.git (submod) - audited 0.8 math libs TickMath + LiquidityAmounts. - * src/provider/interfaces/INonfungiblePositionManager.sol - minimal local NPM interface. + * Extension point: + * - _afterCollateralChange(id, account): hook after deposit / withdraw / liquidation. */ abstract contract V3Provider is ERC4626Upgradeable, UUPSUpgradeable, AccessControlUpgradeable, ReentrancyGuardUpgradeable, - IOracle, IV3Provider { using SafeERC20 for IERC20; @@ -63,33 +50,19 @@ abstract contract V3Provider is /* ─────────────────────────── immutables ─────────────────────────── */ - /// @dev Moolah lending core + /// @dev Moolah lending core. IMoolah public immutable MOOLAH; - /// @dev Uniswap V3 / PancakeSwap V3 NonfungiblePositionManager - INonfungiblePositionManager public immutable POSITION_MANAGER; + /// @dev DEX adapter that custodies the V3 NFT and performs all pool interaction. + address public immutable ADAPTER; - /// @dev V3 pool address for TOKEN0/TOKEN1/FEE, derived from NPM factory in constructor - address public immutable POOL; - - /// @dev token0 of the V3 pool + /// @dev Pool tokens (mirrored from the adapter for deposit/pricing). token0 < token1. address public immutable TOKEN0; - - /// @dev token1 of the V3 pool address public immutable TOKEN1; - - /// @dev V3 pool fee tier (e.g. 500, 3000, 10000) - uint24 public immutable FEE; - - /// @dev TWAP window in seconds for manipulation-resistant tick queries - uint32 public immutable TWAP_PERIOD; - - /// @dev Decimal precision of TOKEN0 and TOKEN1, cached to avoid repeated external calls. uint8 public immutable DECIMALS0; uint8 public immutable DECIMALS1; - /// @dev BSC wrapped native token. Users may send BNB directly; it is wrapped on entry - /// and unwrapped on exit when one of the pool tokens is WBNB. + /// @dev BSC wrapped native token. BNB sent on deposit is wrapped to WBNB before forwarding. address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; bytes32 public constant MANAGER = keccak256("MANAGER"); @@ -97,39 +70,18 @@ abstract contract V3Provider is /* ──────────────────────────── storage ───────────────────────────── */ - /// @dev Resilient oracle used to price TOKEN0 and TOKEN1 individually (8-decimal USD) + /// @dev Resilient oracle pricing TOKEN0/TOKEN1 (8-decimal USD), used for totalAssets(). address public resilientOracle; - /// @dev tokenId of the V3 NFT position held by this contract; 0 means no position yet - uint256 public tokenId; - - /// @dev Lower tick of the current position range - int24 public tickLower; - - /// @dev Upper tick of the current position range - int24 public tickUpper; + /// @dev Decimal precision of the ERC-4626 accounting asset. + uint8 public accountingAssetDecimals; - /// @dev Idle TOKEN0 balance that arose from internal ratio mismatch during compounding. - /// Tracked separately to avoid sweeping arbitrary token donations. - uint256 public idleToken0; - - /// @dev Idle TOKEN1 balance that arose from internal ratio mismatch during compounding. - /// Tracked separately to avoid sweeping arbitrary token donations. - uint256 public idleToken1; - - /// @dev Reserved storage so future base-contract variables can be added without shifting - /// subclass storage. Reduce the array size when adding a new variable. + /// @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 Deposit(address indexed onBehalf, uint256 amount0Used, uint256 amount1Used, uint256 shares, Id indexed marketId); event Withdraw( address indexed onBehalf, uint256 shares, @@ -141,99 +93,57 @@ abstract contract V3Provider is 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); - event Compounded(uint256 fees0, uint256 fees1, uint128 liquidityAdded); - event Rebalanced(int24 oldTickLower, int24 oldTickUpper, int24 newTickLower, int24 newTickUpper, uint256 newTokenId); /* ───────────────────────────── errors ───────────────────────────── */ error ZeroAddress(); - error TokenOrderInvalid(); - error ZeroFee(); - error ZeroTwapPeriod(); - error PoolDoesNotExist(); - error InvalidTickRange(); - error OnlyMoolah(); error InvalidCollateralToken(); error PoolHasNoWBNB(); error ZeroAmounts(); - error ZeroLiquidity(); error ZeroShares(); error Unauthorized(); error InsufficientShares(); + error OnlyMoolah(); error InvalidMarket(); - error BnbTransferFailed(); - error NotWBNB(); error StandardEntryDisabled(); /* ─────────────────────────── constructor ────────────────────────── */ /// @custom:oz-upgrades-unsafe-allow constructor - constructor( - address _moolah, - address _positionManager, - address _token0, - address _token1, - uint24 _fee, - uint32 _twapPeriod - ) { - if (_moolah == address(0)) revert ZeroAddress(); - if (_positionManager == address(0)) revert ZeroAddress(); - if (_token0 == address(0) || _token1 == 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(); - + constructor(address _moolah, address _adapter) { + if (_moolah == address(0) || _adapter == address(0)) revert ZeroAddress(); MOOLAH = IMoolah(_moolah); - POSITION_MANAGER = INonfungiblePositionManager(_positionManager); - TOKEN0 = _token0; - TOKEN1 = _token1; - FEE = _fee; - POOL = _pool; - TWAP_PERIOD = _twapPeriod; - DECIMALS0 = IERC20Metadata(_token0).decimals(); - DECIMALS1 = IERC20Metadata(_token1).decimals(); - + ADAPTER = _adapter; + TOKEN0 = IV3DexAdapter(_adapter).TOKEN0(); + TOKEN1 = IV3DexAdapter(_adapter).TOKEN1(); + DECIMALS0 = IV3DexAdapter(_adapter).DECIMALS0(); + DECIMALS1 = IV3DexAdapter(_adapter).DECIMALS1(); _disableInitializers(); } /* ─────────────────────────── initializer ────────────────────────── */ - /** - * @dev Shared initializer for subclasses. Subclasses expose an external `initialize` - * guarded by the `initializer` modifier and forward to this. - * @param _admin Default admin (can upgrade, grant roles) - * @param _manager Manager role (can configure provider-level risk controls) - * @param _bot Bot address granted BOT role (can trigger rebalance) - * @param _resilientOracle Resilient oracle for pricing TOKEN0 and TOKEN1 - * @param _tickLower Initial position lower tick - * @param _tickUpper Initial position upper tick - * @param _name ERC20 name for shares token - * @param _symbol ERC20 symbol for shares token - */ function __V3Provider_init( address _admin, address _manager, address _bot, address _resilientOracle, - int24 _tickLower, - int24 _tickUpper, + address _accountingAsset, string calldata _name, string calldata _symbol ) internal onlyInitializing { - if (_admin == address(0) || _manager == address(0) || _bot == address(0) || _resilientOracle == address(0)) { + if ( + _admin == address(0) || + _manager == address(0) || + _bot == address(0) || + _resilientOracle == address(0) || + _accountingAsset == address(0) + ) { revert ZeroAddress(); } - if (_tickLower >= _tickUpper) revert InvalidTickRange(); __ERC20_init(_name, _symbol); - __ERC4626_init(IERC20(WBNB)); // ERC-4626 shell: numéraire asset is WBNB (BNB) + __ERC4626_init(IERC20(_accountingAsset)); __AccessControl_init(); __ReentrancyGuard_init(); @@ -243,22 +153,23 @@ abstract contract V3Provider is _grantRole(BOT, _bot); resilientOracle = _resilientOracle; - tickLower = _tickLower; - tickUpper = _tickUpper; + accountingAssetDecimals = IERC20Metadata(_accountingAsset).decimals(); } /* ──────────────────── ERC20 transfer restrictions ───────────────── */ - /// @dev Only Moolah may transfer shares. This prevents users from transferring - /// shares directly without going through withdraw(), which would orphan V3 liquidity. + /// @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; } - /// @dev Only Moolah may call transferFrom (e.g. when pulling collateral on supplyCollateral). - function transferFrom(address from, address to, uint256 value) public override(ERC20Upgradeable, IERC20) returns (bool) { + 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; @@ -266,19 +177,7 @@ abstract contract V3Provider is /* ─────────────────────── core user functions ────────────────────── */ - /** - * @notice Deposit TOKEN0 and TOKEN1, add them to the V3 position, mint shares, - * and supply those shares as Moolah collateral on behalf of `onBehalf`. - * @param marketParams Moolah market (collateralToken must equal address(this)) - * @param amount0Desired Max TOKEN0 to deposit - * @param amount1Desired Max TOKEN1 to deposit - * @param amount0Min Min TOKEN0 accepted after slippage (for V3 mint/increase) - * @param amount1Min Min TOKEN1 accepted after slippage (for V3 mint/increase) - * @param onBehalf Moolah position owner to credit collateral to - * @return shares Shares minted to represent this deposit - * @return amount0Used Actual TOKEN0 consumed by the V3 pool - * @return amount1Used Actual TOKEN1 consumed by the V3 pool - */ + /// @inheritdoc IV3Provider function deposit( MarketParams calldata marketParams, uint256 amount0Desired, @@ -290,13 +189,10 @@ abstract contract V3Provider is if (marketParams.collateralToken != address(this)) revert InvalidCollateralToken(); if (onBehalf == address(0)) revert ZeroAddress(); - // ── Native token handling ────────────────────────────────────────── - // If the caller sends BNB, wrap it and use it in place of the pool token - // that equals WBNB. Pull the other token via transferFrom as usual. - // Idle always stays in wrapped (ERC-20) form; only the entry boundary wraps. uint256 _amount0Desired = amount0Desired; uint256 _amount1Desired = amount1Desired; + // Wrap any native BNB into WBNB and use it for the WBNB leg. if (msg.value > 0) { if (!(TOKEN0 == WBNB || TOKEN1 == WBNB)) revert PoolHasNoWBNB(); if (TOKEN0 == WBNB) { @@ -309,26 +205,7 @@ abstract contract V3Provider is if (_amount0Desired == 0 && _amount1Desired == 0) revert ZeroAmounts(); - // Reject upfront if the supplied amounts yield zero liquidity at the current price. - // This catches one-sided deposits in the wrong direction (e.g. token0-only when price - // is above tickUpper) before any tokens are pulled from the caller. - { - (uint160 sqrtPriceX96, , , , , , ) = IListaV3Pool(POOL).slot0(); - if ( - LiquidityAmounts.getLiquidityForAmounts( - sqrtPriceX96, - TickMath.getSqrtRatioAtTick(tickLower), - TickMath.getSqrtRatioAtTick(tickUpper), - _amount0Desired, - _amount1Desired - ) == 0 - ) { - revert ZeroLiquidity(); - } - } - - // Pull ERC-20 tokens from caller. - // Skip whichever side was funded by msg.value (already wrapped and held by this contract). + // Pull ERC-20 input (skip the side funded by msg.value, already wrapped here). if (_amount0Desired > 0 && !(TOKEN0 == WBNB && msg.value > 0)) { IERC20(TOKEN0).safeTransferFrom(msg.sender, address(this), _amount0Desired); } @@ -336,64 +213,41 @@ abstract contract V3Provider is IERC20(TOKEN1).safeTransferFrom(msg.sender, address(this), _amount1Desired); } - // Compound pending fees before computing share ratio so existing holders - // capture accrued fees before new shares dilute them. - _collectAndCompound(); + // Compound accrued fees first so existing holders capture them before new shares dilute. + IV3DexAdapter(ADAPTER).collectAndCompound(); - uint128 liquidityBefore = _getPositionLiquidity(); + 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 to the depositor. + if (_amount0Desired > 0) IERC20(TOKEN0).safeTransfer(ADAPTER, _amount0Desired); + if (_amount1Desired > 0) IERC20(TOKEN1).safeTransfer(ADAPTER, _amount1Desired); uint128 liquidityAdded; - if (tokenId == 0) { - // No position exists yet — mint a fresh V3 NFT. - (tokenId, liquidityAdded, amount0Used, amount1Used) = V3PositionLib.mint( - POSITION_MANAGER, - TOKEN0, - TOKEN1, - FEE, - tickLower, - tickUpper, - _amount0Desired, - _amount1Desired, - amount0Min, - amount1Min - ); - - // First depositor: shares 1:1 with liquidity units. - shares = uint256(liquidityAdded); + (liquidityAdded, amount0Used, amount1Used) = IV3DexAdapter(ADAPTER).addLiquidity( + _amount0Desired, + _amount1Desired, + amount0Min, + amount1Min, + msg.sender + ); + + (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 { - // Existing position — increase liquidity. - (liquidityAdded, amount0Used, amount1Used) = V3PositionLib.increaseLiquidity( - POSITION_MANAGER, - TOKEN0, - TOKEN1, - tokenId, - _amount0Desired, - _amount1Desired, - amount0Min, - amount1Min - ); - - // Subsequent depositors: proportional to liquidity contributed vs pre-deposit total. - if (supplyBefore == 0 || liquidityBefore == 0) { - shares = uint256(liquidityAdded); - } else { - shares = (uint256(liquidityAdded) * supplyBefore) / uint256(liquidityBefore); - } + shares = (addedValue * supplyBefore) / totalValueBefore; } - if (shares == 0) revert ZeroShares(); - // Refund any tokens not consumed by the V3 pool (ratio mismatch). - // WBNB refunds are unwrapped back to BNB before sending. - uint256 refund0 = _amount0Desired - amount0Used; - uint256 refund1 = _amount1Desired - amount1Used; - if (refund0 > 0) _sendToken(TOKEN0, refund0, payable(msg.sender)); - if (refund1 > 0) _sendToken(TOKEN1, refund1, payable(msg.sender)); - - // Mint shares to this contract, then grant Moolah a one-time allowance so - // supplyCollateral can pull them. Our transferFrom restricts the caller to - // Moolah, so _approve is used internally to set the allowance. _mint(address(this), shares); _approve(address(this), address(MOOLAH), shares); MOOLAH.supplyCollateral(marketParams, shares, onBehalf, ""); @@ -403,17 +257,7 @@ abstract contract V3Provider is emit Deposit(onBehalf, amount0Used, amount1Used, shares, marketParams.id()); } - /** - * @notice Withdraw shares from Moolah, remove the proportional V3 liquidity, - * and return TOKEN0/TOKEN1 to `receiver`. - * @dev Caller must be `onBehalf` or authorized via MOOLAH.isAuthorized(). - * @param marketParams Moolah market (collateralToken must equal address(this)) - * @param shares Number of shares to redeem - * @param minAmount0 Min TOKEN0 to receive (slippage guard) - * @param minAmount1 Min TOKEN1 to receive (slippage guard) - * @param onBehalf Owner of the Moolah collateral position - * @param receiver Address to send TOKEN0/TOKEN1 to - */ + /// @inheritdoc IV3Provider function withdraw( MarketParams calldata marketParams, uint256 shares, @@ -427,28 +271,23 @@ abstract contract V3Provider is if (receiver == address(0)) revert ZeroAddress(); if (!_isSenderAuthorized(onBehalf)) revert Unauthorized(); - // Moolah decrements position.collateral and transfers shares to address(this). - // Our transfer() allows msg.sender == MOOLAH, so this succeeds. MOOLAH.withdrawCollateral(marketParams, shares, onBehalf, address(this)); - _afterCollateralChange(marketParams.id(), onBehalf); - _collectAndCompound(); + IV3DexAdapter(ADAPTER).collectAndCompound(); - (amount0, amount1) = _burnSharesAndRemoveLiquidity(shares, minAmount0, minAmount1, receiver); + // 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()); } - /** - * @notice Withdraw provider shares from Moolah collateral without redeeming the underlying V3 position. - * @dev Caller must be `onBehalf` or authorized via MOOLAH.isAuthorized(). - * This enables moving the same vLP shares to another Moolah market through supplyShares(). - * @param marketParams Moolah market (collateralToken must equal address(this)) - * @param shares Number of shares to withdraw from the Moolah collateral position - * @param onBehalf Owner of the Moolah collateral position - * @param receiver Address to receive the provider shares - */ + /// @inheritdoc IV3Provider function withdrawShares( MarketParams calldata marketParams, uint256 shares, @@ -461,21 +300,13 @@ abstract contract V3Provider is 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()); } - /** - * @notice Supply wallet-held provider shares as Moolah collateral. - * @dev Useful after withdrawShares() when moving vLP collateral between isolated markets. - * @param marketParams Moolah market (collateralToken must equal address(this)) - * @param shares Number of wallet-held provider shares to supply - * @param onBehalf Moolah position owner to credit collateral to - */ + /// @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(); @@ -487,18 +318,12 @@ abstract contract V3Provider is MOOLAH.supplyCollateral(marketParams, shares, onBehalf, ""); _afterCollateralChange(marketParams.id(), onBehalf); - emit SharesSupplied(msg.sender, onBehalf, shares, marketParams.id()); } - /** - * @notice Redeem shares already held by the caller (typically a liquidator that - * received shares from Moolah during liquidation) for TOKEN0/TOKEN1. - * @param shares Number of shares to redeem - * @param minAmount0 Min TOKEN0 to receive (slippage guard) - * @param minAmount1 Min TOKEN1 to receive (slippage guard) - * @param receiver Address to send TOKEN0/TOKEN1 to - */ + /// @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, @@ -509,25 +334,18 @@ abstract contract V3Provider is if (receiver == address(0)) revert ZeroAddress(); if (balanceOf(msg.sender) < shares) revert InsufficientShares(); - _collectAndCompound(); + IV3DexAdapter(ADAPTER).collectAndCompound(); - // Transfer shares from caller to this contract so _burnSharesAndRemoveLiquidity - // can burn from address(this). We use the internal _transfer to bypass the - // Moolah-only restriction (caller holds their own shares). - _transfer(msg.sender, address(this), shares); - - (amount0, amount1) = _burnSharesAndRemoveLiquidity(shares, minAmount0, minAmount1, receiver); + // 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 ──────────────────── */ - /** - * @dev Called by Moolah after a liquidation event. Runs the _afterCollateralChange hook so - * subclasses can resync external state. Moolah already transferred the seized shares to - * the liquidator via transfer(). - */ function liquidate(Id id, address borrower) external { if (msg.sender != address(MOOLAH)) revert OnlyMoolah(); if (MOOLAH.idToMarketParams(id).collateralToken != address(this)) revert InvalidMarket(); @@ -536,105 +354,57 @@ abstract contract V3Provider is /* ───────────────────────── view functions ───────────────────────── */ - /** - * @notice Total TOKEN0 and TOKEN1 represented by the vault at the current spot price. - * Includes amounts locked in the V3 position, uncollected fees (tokensOwed), - * and any idle token balances held by this contract. - * @dev Uses slot0 — suitable for display and bot decisions, NOT for the lending oracle. - * peek() uses the TWAP price to resist manipulation; see _getTotalAmountsAt. - */ + /// @inheritdoc IV3Provider function getTotalAmounts() public view returns (uint256 total0, uint256 total1) { - (uint160 sqrtPriceX96, , , , , , ) = IListaV3Pool(POOL).slot0(); - return _getTotalAmountsAt(sqrtPriceX96); + return IV3DexAdapter(ADAPTER).positionAmountsAt(IV3DexAdapter(ADAPTER).spotSqrtPriceX96()); } - /** - * @notice Simulates a redemption and returns the token amounts a holder would receive - * for burning `shares` at the current pool price. - * Use this to compute tight `minAmount0`/`minAmount1` before calling - * `withdraw` or `redeemShares`: - * - * (uint256 exp0, uint256 exp1) = provider.previewRedeem(shares); - * uint256 min0 = exp0 * 995 / 1000; // 0.5 % slippage tolerance - * uint256 min1 = exp1 * 995 / 1000; - * provider.withdraw(marketParams, shares, min0, min1, onBehalf, receiver); - * - * @param shares Number of provider shares to redeem. - * @return amount0 TOKEN0 the caller would receive (≥ minAmount0 to pass slippage guard). - * @return amount1 TOKEN1 the caller would receive (≥ minAmount1 to pass slippage guard). - */ + /// @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) { - uint256 supply = totalSupply(); - if (supply == 0 || shares == 0) return (0, 0); - - uint128 totalLiquidity = _getPositionLiquidity(); - uint128 liquidityToRemove = uint128((uint256(totalLiquidity) * shares) / supply); - - (uint160 sqrtPriceX96, , , , , , ) = IListaV3Pool(POOL).slot0(); - (amount0, amount1) = _getAmountsForLiquidity( - sqrtPriceX96, - TickMath.getSqrtRatioAtTick(tickLower), - TickMath.getSqrtRatioAtTick(tickUpper), - liquidityToRemove - ); + return IV3DexAdapter(ADAPTER).previewRemoveLiquidity(shares, totalSupply()); } - /** - * @notice Simulates a deposit and returns the token amounts that would actually be consumed - * plus the liquidity that would be minted, given desired input amounts. - * Use this to compute tight `amount0Min`/`amount1Min` before calling `deposit`: - * - * (uint128 liq, uint256 exp0, uint256 exp1) = provider.previewDeposit(des0, des1); - * uint256 min0 = exp0 * 995 / 1000; // 0.5 % slippage tolerance - * uint256 min1 = exp1 * 995 / 1000; - * provider.deposit(marketParams, des0, des1, min0, min1, onBehalf); - * - * @param amount0Desired Amount of TOKEN0 the caller intends to supply. - * @param amount1Desired Amount of TOKEN1 the caller intends to supply. - * @return liquidity Liquidity units that would be added to the position. - * @return amount0 TOKEN0 that would actually be consumed (≤ amount0Desired). - * @return amount1 TOKEN1 that would actually be consumed (≤ amount1Desired). - */ + /// @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) { - (uint160 sqrtPriceX96, , , , , , ) = IListaV3Pool(POOL).slot0(); - uint160 sqrtRatioLower = TickMath.getSqrtRatioAtTick(tickLower); - uint160 sqrtRatioUpper = TickMath.getSqrtRatioAtTick(tickUpper); - - liquidity = LiquidityAmounts.getLiquidityForAmounts( - sqrtPriceX96, - sqrtRatioLower, - sqrtRatioUpper, - amount0Desired, - amount1Desired - ); - (amount0, amount1) = _getAmountsForLiquidity(sqrtPriceX96, sqrtRatioLower, sqrtRatioUpper, liquidity); + return IV3DexAdapter(ADAPTER).previewAddLiquidity(amount0Desired, amount1Desired); } - /// @dev Returns the TOKEN field required by the IProvider interface. - /// For a V3Provider, the "token" is this contract itself (the shares ERC20). + /// @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, denominated in the vault asset (WBNB / BNB). - /// @dev Equals the position's BNB value: with the resilient oracle pricing slisBNB as - /// BNB_price × exchangeRate, `USD_value / WBNB_price` collapses to - /// `WBNB_amt + slisBNB_amt × exchangeRate` (PRD §4.5). WBNB has 18 decimals, so the - /// 8-decimal USD value scaled by 1e18 and divided by the 8-decimal WBNB price yields an - /// 18-decimal WBNB amount. convertToShares/convertToAssets derive from this and totalSupply. + /// @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() * 1e18) / assetPrice; + 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 The single-asset ERC-4626 entry points are disabled — this is a two-token LP vault. - /// Use the two-token deposit(marketParams,…) / withdraw(marketParams,…) / redeemShares(). + /// @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(); } @@ -651,222 +421,16 @@ abstract contract V3Provider is revert StandardEntryDisabled(); } - /* ─────────────────────── IOracle implementation ─────────────────── */ - - /** - * @notice Returns the USD price (8 decimals) for a given token. - * - If token == address(this): prices provider shares as - * (total0 × price0 + total1 × price1) / totalSupply. - * - Otherwise: delegates directly to the resilient oracle. - * - * @dev Token composition is derived from the TWAP tick (not slot0) so a single-block - * AMM price manipulation cannot inflate the reported collateral value. Subclasses may - * override to use an exchange-rate-implied price instead of the pool TWAP. - */ - function peek(address token) external view virtual override returns (uint256) { - if (token != address(this)) { - return IOracle(resilientOracle).peek(token); - } - - uint256 supply = totalSupply(); - if (supply == 0) return 0; - - // shares are 18-decimal; return 8-decimal price per share - return (_positionValueUsd() * 1e18) / supply; - } - - /// @dev Total position value in 8-decimal USD, with leg composition taken at the TWAP price - /// (manipulation-resistant). Shared by peek() and totalAssets(). - function _positionValueUsd() internal view returns (uint256) { - (uint256 total0, uint256 total1) = _getTotalAmountsAt(_valuationSqrtPriceX96()); - uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8 decimals - uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8 decimals - return (total0 * price0) / (10 ** DECIMALS0) + (total1 * price1) / (10 ** DECIMALS1); - } - - /// @dev sqrtPriceX96 used to value the position for the lending oracle (peek / totalAssets / - /// getUserBalanceInBnb). Base uses the pool TWAP. Subclasses override to use an - /// exchange-rate-implied price so a pool-trade cannot move the reported collateral value. - function _valuationSqrtPriceX96() internal view virtual returns (uint160) { - return TickMath.getSqrtRatioAtTick(getTwapTick()); - } - - /** - * @notice Returns the TokenConfig for a given token. - * - If token == address(this): registers this contract as the primary oracle - * so the resilient oracle can delegate share pricing back to us. - * - Otherwise: delegates to the resilient oracle. - */ - function getTokenConfig(address token) external view virtual override returns (TokenConfig memory) { - if (token != address(this)) { - return IOracle(resilientOracle).getTokenConfig(token); - } - return - TokenConfig({ - asset: token, - oracles: [address(this), address(0), address(0)], - enableFlagsForOracles: [true, false, false], - timeDeltaTolerance: 0 - }); - } - - /// @notice Returns the TWAP tick for POOL over TWAP_PERIOD seconds. - /// Public (not external) so peek() can call it directly. - function getTwapTick() public view returns (int24 twapTick) { - uint32[] memory secondsAgos = new uint32[](2); - secondsAgos[0] = TWAP_PERIOD; - secondsAgos[1] = 0; - - (int56[] memory tickCumulatives, ) = 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--; - } - /* ────────────────────────── extension hooks ─────────────────────── */ /// @dev Hook invoked after deposit / withdraw / liquidation with the affected (market, account). - /// Base is a no-op; subclasses override to mirror the position into external systems. function _afterCollateralChange(Id id, address account) internal virtual {} /* ─────────────────────────── internals ──────────────────────────── */ - /// @dev Collect accrued fees from the position and re-add them plus any previously - /// idle tokens (from prior ratio mismatches) as liquidity. - /// Idle amounts are tracked in storage rather than read from balanceOf() to - /// avoid sweeping arbitrary token donations into the position. - function _collectAndCompound() internal { - if (tokenId == 0) return; - - (uint256 fees0, uint256 fees1) = 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 - ); - - // Track leftover from ratio mismatch so it's swept on the next compound. - idleToken0 = toCompound0 - used0; - idleToken1 = toCompound1 - used1; - - emit Compounded(toCompound0, toCompound1, liquidityAdded); - } - - /// @dev Collect all pending fees without compounding (used before rebalance). - /// Returns the amounts collected so callers can track totals without balanceOf. - function _collectAll() internal returns (uint256 collected0, uint256 collected1) { - if (tokenId == 0) return (0, 0); - (collected0, collected1) = V3PositionLib.collectAll(POSITION_MANAGER, tokenId); - } - - /// @dev Burn `shares` held by address(this), remove proportional V3 liquidity, - /// collect the resulting tokens to this contract, then forward to `receiver` - /// — unwrapping WBNB to native BNB along the way. - function _burnSharesAndRemoveLiquidity( - uint256 shares, - uint256 minAmount0, - uint256 minAmount1, - address receiver - ) internal returns (uint256 amount0, uint256 amount1) { - uint256 supply = totalSupply(); - uint128 totalLiquidity = _getPositionLiquidity(); - - // Compute liquidity to remove proportionally to shares being redeemed. - uint128 liquidityToRemove = uint128((uint256(totalLiquidity) * shares) / supply); - - _burn(address(this), shares); - - if (liquidityToRemove > 0) { - V3PositionLib.decreaseLiquidity(POSITION_MANAGER, tokenId, liquidityToRemove, minAmount0, minAmount1); - - // Collect to address(this) so we can unwrap WBNB before forwarding. - (amount0, amount1) = V3PositionLib.collectAll(POSITION_MANAGER, tokenId); - - if (amount0 > 0) _sendToken(TOKEN0, amount0, payable(receiver)); - if (amount1 > 0) _sendToken(TOKEN1, amount1, payable(receiver)); - } - } - - /// @dev Transfer `token` to `to`. If `token == WBNB`, unwrap first and send native BNB; - /// otherwise send as ERC-20. - /// Idle tokens (idleToken0/1) always stay in wrapped ERC-20 form; this helper - /// is only called at the exit boundary (withdraw / redeemShares / deposit refund). - function _sendToken(address token, uint256 amount, address payable to) internal { - if (token == WBNB) { - IWBNB(WBNB).withdraw(amount); - (bool ok, ) = to.call{ value: amount }(""); - if (!ok) revert BnbTransferFailed(); - } else { - IERC20(token).safeTransfer(to, amount); - } - } - - /// @dev Accepts native BNB sent by WBNB during unwrap. Subclasses may widen the allowed senders. - receive() external payable virtual { - if (msg.sender != WBNB) revert NotWBNB(); - } - - /// @dev Returns the current liquidity of the managed V3 position. - function _getPositionLiquidity() internal view returns (uint128 liquidity) { - if (tokenId == 0) return 0; - (, , , , , , , liquidity, , , , ) = POSITION_MANAGER.positions(tokenId); - } - - /// @dev True if the sender may act on behalf of `onBehalf`. function _isSenderAuthorized(address onBehalf) internal view returns (bool) { return msg.sender == onBehalf || MOOLAH.isAuthorized(onBehalf, msg.sender); } - /* ──────── Uniswap V3 liquidity math (via lista-dao-contracts) ─────── */ - - /// @dev Shared implementation for getTotalAmounts() and peek(). Callers supply the - /// sqrtPriceX96 so each can use the price appropriate for its purpose: - /// slot0 for display/bots, TWAP for the lending oracle. - function _getTotalAmountsAt(uint160 sqrtPriceX96) internal view returns (uint256 total0, uint256 total1) { - if (tokenId == 0) return (0, 0); - - (, , , , , , , uint128 liquidity, , , uint128 tokensOwed0, uint128 tokensOwed1) = POSITION_MANAGER.positions( - tokenId - ); - - (uint256 amount0, uint256 amount1) = _getAmountsForLiquidity( - sqrtPriceX96, - TickMath.getSqrtRatioAtTick(tickLower), - TickMath.getSqrtRatioAtTick(tickUpper), - liquidity - ); - - // Add uncollected fees and internally-tracked idle tokens (ratio mismatch leftovers). - // Using idleToken0/1 instead of balanceOf() prevents donated tokens from inflating - // the share price reported by peek(). - total0 = amount0 + uint256(tokensOwed0) + idleToken0; - total1 = amount1 + uint256(tokensOwed1) + idleToken1; - } - - /// @dev Computes token amounts for a given liquidity position at sqrtPriceX96. - /// Delegates to LiquidityAmounts.getAmountsForLiquidity (lista-dao-contracts, audited 0.8). - function _getAmountsForLiquidity( - uint160 sqrtPriceX96, - uint160 sqrtRatioAX96, - uint160 sqrtRatioBX96, - uint128 liquidity - ) internal pure returns (uint256 amount0, uint256 amount1) { - return LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioAX96, sqrtRatioBX96, liquidity); - } - - /* ──────────────────────── upgrade guard ─────────────────────────── */ - function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} } diff --git a/src/provider/interfaces/ISlisBNBV3DexAdapter.sol b/src/provider/interfaces/ISlisBNBV3DexAdapter.sol new file mode 100644 index 00000000..2e756ccd --- /dev/null +++ b/src/provider/interfaces/ISlisBNBV3DexAdapter.sol @@ -0,0 +1,21 @@ +// 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 + * (forwarded from the provider's BOT-gated call) plus the rate-drift state/config. + */ +interface ISlisBNBV3DexAdapter is IV3DexAdapter { + function lastCenterRate() external view returns (uint256); + + function centerRateThresholdBps() external view returns (uint256); + + function setCenterRateThresholdBps(uint256 centerRateThresholdBps) external; + + /// @notice Recenter to the exchange-rate-derived 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) external; +} diff --git a/src/provider/interfaces/IV3DexAdapter.sol b/src/provider/interfaces/IV3DexAdapter.sol new file mode 100644 index 00000000..3862aa5b --- /dev/null +++ b/src/provider/interfaces/IV3DexAdapter.sol @@ -0,0 +1,110 @@ +// 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 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; +} 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 index 06f259e9..d8b3a729 100644 --- a/src/provider/interfaces/IV3Provider.sol +++ b/src/provider/interfaces/IV3Provider.sol @@ -2,33 +2,27 @@ pragma solidity 0.8.34; import { MarketParams, Id } from "moolah/interfaces/IMoolah.sol"; -import { IOracle } from "moolah/interfaces/IOracle.sol"; import { IProvider } from "./IProvider.sol"; -interface IV3Provider is IProvider, IOracle { +/** + * @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); - function FEE() external view returns (uint24); + /// @notice The DEX adapter holding the V3 NFT / idle inventory. + function ADAPTER() external view returns (address); - function POOL() external view returns (address); - - function tokenId() external view returns (uint256); - - function tickLower() external view returns (int24); - - function tickUpper() external view returns (int24); - - /// @notice Returns total token0 and token1 amounts held by the vault, - /// including liquidity-equivalent amounts and uncollected fees. + /// @notice Total token0/token1 backing the vault at the current pool spot (display/bots). function getTotalAmounts() external view returns (uint256 total0, uint256 total1); - /// @notice Returns the TWAP tick for the pool over the configured TWAP_PERIOD. - function getTwapTick() external view returns (int24 twapTick); - - /// @notice Deposit token0/token1 into the V3 position and supply resulting - /// shares as Moolah collateral on behalf of `onBehalf`. + /// @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, @@ -38,8 +32,7 @@ interface IV3Provider is IProvider, IOracle { address onBehalf ) external payable returns (uint256 shares, uint256 amount0Used, uint256 amount1Used); - /// @notice Withdraw shares from Moolah, remove liquidity, and return - /// token0/token1 to `receiver`. + /// @notice Withdraw shares from Moolah, remove liquidity, and return token0/token1 to `receiver`. function withdraw( MarketParams calldata marketParams, uint256 shares, @@ -49,8 +42,7 @@ interface IV3Provider is IProvider, IOracle { address receiver ) external returns (uint256 amount0, uint256 amount1); - /// @notice Withdraw provider shares from Moolah collateral without - /// redeeming the underlying token0/token1 position. + /// @notice Withdraw provider shares from Moolah collateral without redeeming the underlying position. function withdrawShares( MarketParams calldata marketParams, uint256 shares, @@ -61,8 +53,7 @@ interface IV3Provider is IProvider, IOracle { /// @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. + /// @notice Redeem shares already held by the caller (e.g. a liquidator) for the underlying token0/token1. function redeemShares( uint256 shares, uint256 minAmount0, 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/test/liquidator/V3Liquidator.t.sol b/test/liquidator/V3Liquidator.t.sol index e00687ec..7a294c08 100644 --- a/test/liquidator/V3Liquidator.t.sol +++ b/test/liquidator/V3Liquidator.t.sol @@ -7,25 +7,45 @@ import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SlisBNBV3Provider } from "../../src/provider/SlisBNBV3Provider.sol"; +import { SlisBNBV3DexAdapter } from "../../src/provider/SlisBNBV3DexAdapter.sol"; +import { SlisBNBV3ProviderOracle } from "../../src/provider/SlisBNBV3ProviderOracle.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 } from "moolah/interfaces/IOracle.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 = 0x4141325bAc36aFFe9Db165e854982230a14e6d48; // USDC/WBNB - address constant NPM = 0x7b8A01B39D58278b5DE7e48c8449c9f4F5170613; + address constant POOL = 0xe1B404Aaf60eEc5c8A1FEDE7dcDC0EAb9C69662F; // SLISBNB/WBNB + address constant NPM = 0x46A15B0b27311cedF172AB29E4f4766fbE7F4364; uint24 constant FEE = 100; /* ───────────────────────────── tokens ───────────────────────────── */ - address constant USDC = 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d; // token0 + address constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; // token0 address constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; // token1 address constant LISUSD = 0x0782b6d8c4551B9760e74c0545a9bCD90bdc41E5; address constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; @@ -35,17 +55,21 @@ contract V3LiquidatorTest is Test { address constant TIMELOCK = 0x07D274a68393E8b8a2CCf19A2ce4Ba3518735253; address constant OPERATOR = 0xd7e38800201D6a42C408Bf79d8723740C4E7f631; address constant MANAGER_ADDR = 0x8d388136d578dCD791D081c6042284CED6d9B0c6; - address constant RESILIENT_ORACLE = 0xf3afD82A4071f272F403dC176916141f44E6c750; + 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; @@ -66,20 +90,54 @@ contract V3LiquidatorTest is Test { UUPSUpgradeable(MOOLAH_PROXY).upgradeToAndCall(newImpl, bytes("")); moolah = Moolah(MOOLAH_PROXY); - // Deploy SlisBNBV3Provider. - SlisBNBV3Provider implP = new SlisBNBV3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); + // 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, RESILIENT_ORACLE, "V3LP USDC/WBNB", "v3LP") + (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(SlisBNBV3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + ) + ) + ); + // Deploy V3Liquidator. V3Liquidator implL = new V3Liquidator(MOOLAH_PROXY); liquidator = V3Liquidator( @@ -88,11 +146,11 @@ contract V3LiquidatorTest is Test { mockSwap = new MockOneInch(); - // Build Moolah market: collateral = provider shares, oracle = provider. + // Build Moolah market: collateral = provider shares, oracle = providerOracle. marketParams = MarketParams({ loanToken: LISUSD, collateralToken: address(provider), - oracle: address(provider), + oracle: address(providerOracle), irm: IRM, lltv: LLTV }); @@ -111,7 +169,7 @@ contract V3LiquidatorTest is Test { // Configure liquidator whitelists. vm.startPrank(manager); - liquidator.setTokenWhitelist(USDC, true); + liquidator.setTokenWhitelist(SLISBNB, true); liquidator.setTokenWhitelist(LISUSD, true); liquidator.setTokenWhitelist(BNB_ADDRESS, true); liquidator.setMarketWhitelist(Id.unwrap(marketId), true); @@ -127,11 +185,11 @@ contract V3LiquidatorTest is Test { uint256 amount0, uint256 amount1 ) internal returns (uint256 shares, uint256 used0, uint256 used1) { - deal(USDC, _user, amount0); + deal(SLISBNB, _user, amount0); deal(WBNB, _user, amount1); (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); vm.startPrank(_user); - IERC20(USDC).approve(address(provider), amount0); + IERC20(SLISBNB).approve(address(provider), amount0); IERC20(WBNB).approve(address(provider), amount1); (shares, used0, used1) = provider.deposit( marketParams, @@ -152,8 +210,8 @@ contract V3LiquidatorTest is Test { /// @dev Borrow 60% of user's collateral value — healthy, but mocking oracle to 0 makes it unhealthy. function _borrowAgainstCollateral(address _user) internal returns (uint256 borrowed) { (, , uint128 col) = moolah.position(marketId, _user); - uint256 sharePrice = provider.peek(address(provider)); - uint256 loanPrice = provider.peek(LISUSD); + 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); @@ -162,7 +220,7 @@ contract V3LiquidatorTest is Test { /// @dev Mock collateral oracle to zero, making any indebted position liquidatable. function _makeUnhealthy() internal { vm.mockCall( - address(provider), + address(providerOracle), abi.encodeWithSelector(IOracle.peek.selector, address(provider)), abi.encode(uint256(0)) ); @@ -272,13 +330,13 @@ contract V3LiquidatorTest is Test { function test_sellToken_revertsIfNotBot() public { vm.prank(user); vm.expectRevert(); - liquidator.sellToken(address(mockSwap), USDC, LISUSD, 1, 0, ""); + liquidator.sellToken(address(mockSwap), SLISBNB, LISUSD, 1, 0, ""); } /* ─────────────────── liquidate (pre-funded) ─────────────────────── */ function test_liquidate_prefunded_receivesShares() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); _borrowAgainstCollateral(user); _makeUnhealthy(); @@ -304,7 +362,7 @@ contract V3LiquidatorTest is Test { /* ─────────────────── flashLiquidate ─────────────────────────────── */ function test_flashLiquidate_holdShares_noRedeem() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); uint256 borrowed = _borrowAgainstCollateral(user); _makeUnhealthy(); @@ -332,17 +390,17 @@ contract V3LiquidatorTest is Test { } function test_flashLiquidate_redeemAndSwap_coveredBySwapProfit() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); uint256 borrowed = _borrowAgainstCollateral(user); _makeUnhealthy(); - // token0 (USDC) swap: amountIn=0 so mock accepts any approval; produces borrowed*2 lisUSD. + // 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, - USDC, // tokenIn + SLISBNB, // tokenIn LISUSD, // tokenOut - uint256(0), // amountIn (mock pulls nothing; residual USDC stays in liquidator) + uint256(0), // amountIn (mock pulls nothing; residual SLISBNB stays in liquidator) borrowed * 2 // amountOutMin — enough to cover repayment ); @@ -420,7 +478,7 @@ contract V3LiquidatorTest is Test { function test_redeemV3Shares_redemeesSharesToTokens() public { // Acquire shares via pre-funded liquidation. - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); _borrowAgainstCollateral(user); _makeUnhealthy(); deal(LISUSD, address(liquidator), 1_000 ether); @@ -443,7 +501,7 @@ contract V3LiquidatorTest is Test { assertEq(provider.balanceOf(address(liquidator)), 0, "shares burned after redeem"); assertGt(out0 + out1, 0, "tokens received"); - assertEq(IERC20(USDC).balanceOf(address(liquidator)), out0, "USDC received"); + assertEq(IERC20(SLISBNB).balanceOf(address(liquidator)), out0, "SLISBNB received"); assertEq(address(liquidator).balance, out1, "BNB received (WBNB unwrapped)"); } @@ -461,16 +519,16 @@ contract V3LiquidatorTest is Test { function test_sellToken_erc20_swapsAndClearsAllowance() public { uint256 amountIn = 100 ether; uint256 amountOut = 50 ether; - deal(USDC, address(liquidator), amountIn); + deal(SLISBNB, address(liquidator), amountIn); - bytes memory swapData = abi.encodeWithSelector(mockSwap.swap.selector, USDC, LISUSD, amountIn, amountOut); + bytes memory swapData = abi.encodeWithSelector(mockSwap.swap.selector, SLISBNB, LISUSD, amountIn, amountOut); vm.prank(bot); - liquidator.sellToken(address(mockSwap), USDC, LISUSD, amountIn, amountOut, swapData); + liquidator.sellToken(address(mockSwap), SLISBNB, LISUSD, amountIn, amountOut, swapData); assertEq(IERC20(LISUSD).balanceOf(address(liquidator)), amountOut, "received lisUSD"); - assertEq(IERC20(USDC).balanceOf(address(liquidator)), 0, "USDC consumed"); - assertEq(IERC20(USDC).allowance(address(liquidator), address(mockSwap)), 0, "allowance cleared"); + 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 { @@ -483,19 +541,19 @@ contract V3LiquidatorTest is Test { function test_sellToken_revertsIfPairNotWhitelisted() public { address fakePair = makeAddr("fakePair"); - deal(USDC, address(liquidator), 1 ether); + deal(SLISBNB, address(liquidator), 1 ether); vm.prank(bot); vm.expectRevert(V3Liquidator.NotWhitelisted.selector); - liquidator.sellToken(fakePair, USDC, LISUSD, 1 ether, 0, ""); + liquidator.sellToken(fakePair, SLISBNB, LISUSD, 1 ether, 0, ""); } function test_sellToken_revertsIfAmountExceedsBalance() public { - deal(USDC, address(liquidator), 50 ether); + deal(SLISBNB, address(liquidator), 50 ether); vm.prank(bot); vm.expectRevert(V3Liquidator.ExceedAmount.selector); - liquidator.sellToken(address(mockSwap), USDC, LISUSD, 100 ether, 0, ""); + liquidator.sellToken(address(mockSwap), SLISBNB, LISUSD, 100 ether, 0, ""); } function test_sellBNB_swapsNativeBNB() public { diff --git a/test/provider/SlisBNBV3Provider.t.sol b/test/provider/SlisBNBV3Provider.t.sol index 3a6c3569..59e444d5 100644 --- a/test/provider/SlisBNBV3Provider.t.sol +++ b/test/provider/SlisBNBV3Provider.t.sol @@ -2,19 +2,42 @@ 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/SlisBNBV3Provider.sol"; +import { SlisBNBV3DexAdapter } from "../../src/provider/SlisBNBV3DexAdapter.sol"; +import { SlisBNBV3ProviderOracle } from "../../src/provider/SlisBNBV3ProviderOracle.sol"; +import { IStakeManager } from "../../src/provider/interfaces/IStakeManager.sol"; import { V3Provider } from "../../src/provider/V3Provider.sol"; +import { V3DexAdapter } from "../../src/provider/V3DexAdapter.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) @@ -29,25 +52,79 @@ contract PoolSwapper { IListaV3Pool(pool).swap(address(this), zeroForOne, int256(amountIn), limit, abi.encode(pool)); } - /// @dev PancakeSwap V3 swap callback — pay whatever the pool pulled. + /// @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 {} +} + +/// @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 BSC mainnet ─────────────────── */ - address constant POOL = 0x4141325bAc36aFFe9Db165e854982230a14e6d48; // USDC/WBNB - address constant NPM = 0x7b8A01B39D58278b5DE7e48c8449c9f4F5170613; + /* ─────────────────── PancakeSwap V3 slisBNB/WBNB 1bp ─────────────────── */ + address constant POOL = 0xe1B404Aaf60eEc5c8A1FEDE7dcDC0EAb9C69662F; // slisBNB/WBNB + address constant NPM = 0x46A15B0b27311cedF172AB29E4f4766fbE7F4364; uint24 constant FEE = 100; /* ───────────────────────────── tokens ───────────────────────────── */ - address constant USDC = 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d; // token0 + 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; @@ -55,19 +132,26 @@ contract SlisBNBV3ProviderTest is Test { address constant OPERATOR = 0xd7e38800201D6a42C408Bf79d8723740C4E7f631; address constant MANAGER_ADDR = 0x8d388136d578dCD791D081c6042284CED6d9B0c6; address constant LISUSD = 0x0782b6d8c4551B9760e74c0545a9bCD90bdc41E5; - address constant RESILIENT_ORACLE = 0xf3afD82A4071f272F403dC176916141f44E6c750; address constant IRM = 0xFe7dAe87Ebb11a7BEB9F534BB23267992d9cDe7c; uint32 constant TWAP_PERIOD = 1800; // 30 minutes uint256 constant LLTV = 70 * 1e16; 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; 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"); @@ -80,25 +164,79 @@ contract SlisBNBV3ProviderTest is Test { 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)); + + // 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(SlisBNBV3ProviderOracle.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); - // Deploy SlisBNBV3Provider (implementation + UUPS proxy). - SlisBNBV3Provider impl = new SlisBNBV3Provider(MOOLAH_PROXY, NPM, USDC, WBNB, FEE, TWAP_PERIOD); - bytes memory initData = abi.encodeCall( - SlisBNBV3Provider.initialize, - (admin, manager, bot, RESILIENT_ORACLE, "SlisBNBV3Provider USDC/WBNB", "v3LP-USDC-WBNB") - ); - provider = SlisBNBV3Provider(payable(new ERC1967Proxy(address(impl), initData))); - - // Build Moolah market: collateral = provider shares, oracle = provider. + // Build Moolah market: collateral = provider shares, oracle = providerOracle. marketParams = MarketParams({ loanToken: LISUSD, collateralToken: address(provider), - oracle: address(provider), + oracle: address(providerOracle), irm: IRM, lltv: LLTV }); @@ -124,7 +262,7 @@ contract SlisBNBV3ProviderTest is Test { uint256 amount0, uint256 amount1 ) internal returns (uint256 shares, uint256 used0, uint256 used1) { - deal(USDC, _user, amount0); + 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. @@ -132,7 +270,7 @@ contract SlisBNBV3ProviderTest is Test { uint256 min0 = (exp0 * 999) / 1000; uint256 min1 = (exp1 * 999) / 1000; vm.startPrank(_user); - IERC20(USDC).approve(address(provider), amount0); + 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(); @@ -147,7 +285,7 @@ contract SlisBNBV3ProviderTest is Test { secondParams = MarketParams({ loanToken: LISUSD, collateralToken: address(provider), - oracle: address(provider), + oracle: address(providerOracle), irm: IRM, lltv: LLTV_SECOND }); @@ -168,25 +306,29 @@ contract SlisBNBV3ProviderTest is Test { /* ────────────────────────── test cases ─────────────────────────── */ function test_initialize() public view { - assertEq(provider.TOKEN0(), USDC); + assertEq(provider.TOKEN0(), SLISBNB); assertEq(provider.TOKEN1(), WBNB); - assertEq(provider.FEE(), FEE); - assertEq(provider.POOL(), POOL); + assertEq(adapter.FEE(), FEE); + assertEq(adapter.POOL(), POOL); assertEq(address(provider.MOOLAH()), MOOLAH_PROXY); - assertEq(address(provider.POSITION_MANAGER()), NPM); - assertEq(provider.resilientOracle(), RESILIENT_ORACLE); - assertEq(provider.TWAP_PERIOD(), TWAP_PERIOD); - assertLt(provider.tickLower(), provider.tickUpper()); + 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 = 1_000 ether; // USDC - uint256 amount1 = 3 ether; // WBNB + uint256 amount0 = 10 ether; // slisBNB + uint256 amount1 = 10 ether; // WBNB (uint256 shares, uint256 used0, uint256 used1) = _deposit(user, amount0, amount1); @@ -201,25 +343,25 @@ contract SlisBNBV3ProviderTest is Test { assertEq(provider.balanceOf(MOOLAH_PROXY), shares, "Moolah should hold shares"); // Unused tokens refunded to caller. - // USDC refunded as ERC-20; WBNB (TOKEN1 = WRAPPED_NATIVE) refunded as native BNB. - assertEq(IERC20(USDC).balanceOf(user), amount0 - used0); + // 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, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); uint256 sharesAfterFirst = _collateral(user); - (uint256 shares2, , ) = _deposit(user2, 2_000 ether, 6 ether); + (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, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); - uint256 usdcBefore = IERC20(USDC).balanceOf(user); + 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); @@ -234,12 +376,12 @@ contract SlisBNBV3ProviderTest is Test { // Tokens returned. assertGt(out0 + out1, 0, "should receive tokens back"); - assertEq(IERC20(USDC).balanceOf(user), usdcBefore + out0); + 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, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares / 2); uint256 min0 = (exp0 * 999) / 1000; @@ -252,7 +394,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_withdraw_revertsIfUnauthorized() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); uint256 shares = _collateral(user); // user2 cannot withdraw on behalf of user without authorization. @@ -263,9 +405,9 @@ contract SlisBNBV3ProviderTest is Test { } function test_withdrawShares_toWallet_doesNotRedeemUnderlying() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); uint256 supplyBefore = provider.totalSupply(); - uint256 tokenIdBefore = provider.tokenId(); + uint256 tokenIdBefore = adapter.tokenId(); vm.prank(user); provider.withdrawShares(marketParams, shares, user, user); @@ -274,13 +416,13 @@ contract SlisBNBV3ProviderTest is Test { 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(provider.tokenId(), tokenIdBefore, "V3 position should remain intact"); + 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, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); uint256 shares = _collateral(user); vm.prank(user2); @@ -289,7 +431,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_supplyShares_fromWallet_suppliesCollateral() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); vm.startPrank(user); provider.withdrawShares(marketParams, shares, user, user); @@ -304,7 +446,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_supplyShares_revertsIfSenderDoesNotHoldShares() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); vm.prank(user2); vm.expectRevert(V3Provider.InsufficientShares.selector); @@ -312,7 +454,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_withdrawShares_supplyShares_movesCollateralBetweenMarkets() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); (MarketParams memory secondParams, Id secondId) = _createSecondMarket(); vm.startPrank(user); @@ -331,7 +473,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_redeemShares_byLiquidator() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); address liquidator = makeAddr("liquidator"); // Simulate Moolah transferring shares to liquidator during liquidation. @@ -341,7 +483,7 @@ contract SlisBNBV3ProviderTest is Test { assertEq(provider.balanceOf(liquidator), shares); - uint256 usdcBefore = IERC20(USDC).balanceOf(liquidator); + uint256 slisBefore = IERC20(SLISBNB).balanceOf(liquidator); uint256 bnbBefore = liquidator.balance; // WBNB (TOKEN1) is unwrapped to native BNB (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); @@ -353,12 +495,12 @@ contract SlisBNBV3ProviderTest is Test { assertEq(provider.balanceOf(liquidator), 0, "shares should be burned"); assertGt(out0 + out1, 0, "liquidator should receive tokens"); - assertEq(IERC20(USDC).balanceOf(liquidator), usdcBefore + out0); + assertEq(IERC20(SLISBNB).balanceOf(liquidator), slisBefore + out0); assertEq(liquidator.balance, bnbBefore + out1); // WBNB unwrapped to BNB } function test_transferRestriction_directTransferReverts() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); vm.prank(user); vm.expectRevert(V3Provider.OnlyMoolah.selector); @@ -366,7 +508,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_transferRestriction_transferFromReverts() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); vm.prank(user); vm.expectRevert(V3Provider.OnlyMoolah.selector); @@ -374,30 +516,39 @@ contract SlisBNBV3ProviderTest is Test { } function test_rebalance_onlyBot() public { - _deposit(user, 1_000 ether, 3 ether); + _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); - // bot can rebalance — range is derived internally by the provider. + // 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 = provider.tokenId(); + uint256 oldTokenId = adapter.tokenId(); vm.prank(bot); provider.rebalance(min0, min1, 0, block.timestamp); - assertGt(provider.tokenId(), oldTokenId, "position NFT should be re-minted"); - assertLt(provider.tickLower(), provider.tickUpper()); + 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, 1_000 ether, 3 ether); + (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; @@ -415,74 +566,37 @@ contract SlisBNBV3ProviderTest is Test { } function test_peek_zeroBeforeDeposit() public view { - assertEq(provider.peek(address(provider)), 0, "price should be 0 with no deposits"); + assertEq(providerOracle.peek(address(provider)), 0, "price should be 0 with no deposits"); } function test_peek_nonZeroAfterDeposit() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); - uint256 price = provider.peek(address(provider)); + uint256 price = providerOracle.peek(address(provider)); assertGt(price, 0, "share price should be non-zero after deposit"); } - function test_getTwapTick_nearCurrentTick() public view { - int24 twapTick = provider.getTwapTick(); - (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); - - // TWAP tick should be within a reasonable distance of the current tick. - int256 diff = int256(currentTick) - int256(twapTick); - if (diff < 0) diff = -diff; - assertLt(diff, 500, "TWAP tick should be near current tick"); - } - function test_getTotalAmounts_nonZeroAfterDeposit() public { - _deposit(user, 1_000 ether, 3 ether); + _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 { - // mock USDC price - vm.mockCall( - RESILIENT_ORACLE, - abi.encodeWithSelector(IOracle.peek.selector, USDC), - abi.encode(1e8) // $1 with 8 decimals - ); - - // mock WBNB price; $700 - vm.mockCall( - RESILIENT_ORACLE, - abi.encodeWithSelector(IOracle.peek.selector, WBNB), - abi.encode(700 * 1e8) // $700 with 8 decimals - ); - - // Stabilise the TWAP tick across the vm.warp by mocking pool.observe to always - // return tick cumulatives consistent with the current slot0 tick. Without this, - // the 7-day warp shifts the TWAP window from real BSC history to pure extrapolation, - // producing a spurious ~0.3% price delta that has nothing to do with fee compounding. - (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); - int56[] memory tickCumulatives = new int56[](2); - tickCumulatives[0] = 0; - tickCumulatives[1] = int56(currentTick) * int56(uint56(TWAP_PERIOD)); - uint160[] memory secondsPerLiq = new uint160[](2); - vm.mockCall( - POOL, - abi.encodeWithSelector(bytes4(keccak256("observe(uint32[])"))), - abi.encode(tickCumulatives, secondsPerLiq) - ); - - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + // 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 = provider.peek(address(provider)); + 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, 1_000 ether, 3 ether); + _deposit(user2, 10 ether, 10 ether); - uint256 priceAfter = provider.peek(address(provider)); + 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"); @@ -491,6 +605,42 @@ contract SlisBNBV3ProviderTest is Test { 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, @@ -499,10 +649,10 @@ contract SlisBNBV3ProviderTest is Test { uint256 min0, uint256 min1 ) internal returns (uint256 shares, uint256 used0, uint256 used1) { - deal(USDC, _user, amount0); + deal(SLISBNB, _user, amount0); deal(WBNB, _user, amount1); vm.startPrank(_user); - IERC20(USDC).approve(address(provider), amount0); + 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(); @@ -511,8 +661,8 @@ contract SlisBNBV3ProviderTest is Test { /* ──────────────── previewDeposit tests ─────────────────────────── */ function test_previewDeposit_amountsMatchActual() public { - uint256 amount0 = 1_000 ether; - uint256 amount1 = 3 ether; + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; (uint128 liquidity, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); @@ -533,8 +683,8 @@ contract SlisBNBV3ProviderTest is Test { } function test_previewDeposit_derivedMinAmounts_succeed() public { - uint256 amount0 = 5_000 ether; - uint256 amount1 = 15 ether; + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); @@ -550,25 +700,25 @@ contract SlisBNBV3ProviderTest is Test { } function test_previewDeposit_priceBelowRange_onlyToken0() public { - _deposit(user, 10_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceBelowRange(); - uint256 amount0 = 1_000 ether; - uint256 amount1 = 3 ether; + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); - // Position is fully USDC — only token0 consumed, token1 = 0. + // 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_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceAboveRange(); - uint256 amount0 = 1_000 ether; - uint256 amount1 = 3 ether; + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); @@ -579,10 +729,10 @@ contract SlisBNBV3ProviderTest is Test { function test_previewDeposit_secondDeposit_matchesActual() public { // Seed an initial position so the second deposit goes through increaseLiquidity. - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); - uint256 amount0 = 2_000 ether; - uint256 amount1 = 6 ether; + uint256 amount0 = 20 ether; + uint256 amount1 = 20 ether; (, uint256 exp0, uint256 exp1) = provider.previewDepositAmounts(amount0, amount1); @@ -604,11 +754,11 @@ contract SlisBNBV3ProviderTest is Test { function test_previewRedeem_matchesActualWithdraw() public { // Price is inside the tick range: preview predicts both tokens, withdraw returns both. - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); - (, int24 currentTick, , , , , ) = IListaV3Pool(POOL).slot0(); - assertGt(currentTick, provider.tickLower(), "price should be above tickLower"); - assertLt(currentTick, provider.tickUpper(), "price should be below tickUpper"); + (, 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"); @@ -627,7 +777,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_previewRedeem_matchesActualRedeemShares() public { - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); vm.prank(MOOLAH_PROXY); provider.transfer(user2, shares); @@ -645,7 +795,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_previewRedeem_partialShares_proportional() public { - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); (uint256 fullExp0, uint256 fullExp1) = provider.previewRedeemUnderlying(shares); (uint256 halfExp0, uint256 halfExp1) = provider.previewRedeemUnderlying(shares / 2); @@ -656,7 +806,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_previewRedeem_priceBelowRange_onlyToken0() public { - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); _pushPriceBelowRange(); (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); @@ -665,7 +815,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_previewRedeem_priceAboveRange_onlyToken1() public { - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); _pushPriceAboveRange(); (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); @@ -674,7 +824,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_previewRedeem_derivedMinAmounts_succeed() public { - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); @@ -690,14 +840,14 @@ contract SlisBNBV3ProviderTest is Test { } function test_deposit_minAmount0_tooHigh_reverts_firstDeposit() public { - uint256 amount0 = 1_000 ether; - uint256 amount1 = 3 ether; + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; // min0 far exceeds what NPM can place — should revert from NPM slippage check. - deal(USDC, user, amount0); + deal(SLISBNB, user, amount0); deal(WBNB, user, amount1); vm.startPrank(user); - IERC20(USDC).approve(address(provider), amount0); + IERC20(SLISBNB).approve(address(provider), amount0); IERC20(WBNB).approve(address(provider), amount1); vm.expectRevert(); provider.deposit(marketParams, amount0, amount1, amount0 * 2, 0, user); @@ -705,13 +855,13 @@ contract SlisBNBV3ProviderTest is Test { } function test_deposit_minAmount1_tooHigh_reverts_firstDeposit() public { - uint256 amount0 = 1_000 ether; - uint256 amount1 = 3 ether; + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; - deal(USDC, user, amount0); + deal(SLISBNB, user, amount0); deal(WBNB, user, amount1); vm.startPrank(user); - IERC20(USDC).approve(address(provider), amount0); + IERC20(SLISBNB).approve(address(provider), amount0); IERC20(WBNB).approve(address(provider), amount1); vm.expectRevert(); provider.deposit(marketParams, amount0, amount1, 0, amount1 * 2, user); @@ -719,15 +869,15 @@ contract SlisBNBV3ProviderTest is Test { } function test_deposit_minAmount0_tooHigh_reverts_secondDeposit() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); - uint256 amount0 = 1_000 ether; - uint256 amount1 = 3 ether; + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; - deal(USDC, user2, amount0); + deal(SLISBNB, user2, amount0); deal(WBNB, user2, amount1); vm.startPrank(user2); - IERC20(USDC).approve(address(provider), amount0); + IERC20(SLISBNB).approve(address(provider), amount0); IERC20(WBNB).approve(address(provider), amount1); vm.expectRevert(); provider.deposit(marketParams, amount0, amount1, amount0 * 2, 0, user2); @@ -735,15 +885,15 @@ contract SlisBNBV3ProviderTest is Test { } function test_deposit_minAmount1_tooHigh_reverts_secondDeposit() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); - uint256 amount0 = 1_000 ether; - uint256 amount1 = 3 ether; + uint256 amount0 = 10 ether; + uint256 amount1 = 10 ether; - deal(USDC, user2, amount0); + deal(SLISBNB, user2, amount0); deal(WBNB, user2, amount1); vm.startPrank(user2); - IERC20(USDC).approve(address(provider), amount0); + IERC20(SLISBNB).approve(address(provider), amount0); IERC20(WBNB).approve(address(provider), amount1); vm.expectRevert(); provider.deposit(marketParams, amount0, amount1, 0, amount1 * 2, user2); @@ -753,26 +903,32 @@ contract SlisBNBV3ProviderTest is Test { /* ──────────── one-sided deposit tests ──────────────────────────── */ // When the price is in-range both tokens are required to add liquidity. - // Supplying only one token yields 0 liquidity → "zero shares" revert. + // 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 → "zero shares". - // Pass min=0 so NPM doesn't revert first; our guard fires instead. - deal(USDC, user, 10_000 ether); + // 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(USDC).approve(address(provider), 10_000 ether); - vm.expectRevert(V3Provider.ZeroLiquidity.selector); - provider.deposit(marketParams, 10_000 ether, 0, 0, 0, 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 → "zero shares". - deal(WBNB, user, 30 ether); + // 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), 30 ether); - vm.expectRevert(V3Provider.ZeroLiquidity.selector); - provider.deposit(marketParams, 0, 30 ether, 0, 0, user); + IERC20(WBNB).approve(address(provider), 10 ether); + vm.expectRevert(); + provider.deposit(marketParams, 0, 10 ether, 0, 0, user); vm.stopPrank(); } @@ -781,14 +937,14 @@ contract SlisBNBV3ProviderTest is Test { function test_deposit_oneSided_token0Only_belowRange_succeeds() public { // Seed a position first so rebalance can move ticks. - _deposit(user, 10_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceBelowRange(); - // Price below tickLower: only token0 (USDC) is accepted. - uint256 amount0 = 5_000 ether; - deal(USDC, user2, amount0); + // Price below tickLower: only token0 (slisBNB) is accepted. + uint256 amount0 = 10 ether; + deal(SLISBNB, user2, amount0); vm.startPrank(user2); - IERC20(USDC).approve(address(provider), amount0); + 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); @@ -800,24 +956,25 @@ contract SlisBNBV3ProviderTest is Test { } function test_deposit_oneSided_token1Only_belowRange_reverts() public { - _deposit(user, 10_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceBelowRange(); - // Price below range: token1 alone yields 0 liquidity → "zero shares". - deal(WBNB, user2, 30 ether); + // 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), 30 ether); - vm.expectRevert(V3Provider.ZeroLiquidity.selector); - provider.deposit(marketParams, 0, 30 ether, 0, 0, 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_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceAboveRange(); // Price above tickUpper: only token1 (WBNB) is accepted. - uint256 amount1 = 15 ether; + uint256 amount1 = 10 ether; deal(WBNB, user2, amount1); vm.startPrank(user2); IERC20(WBNB).approve(address(provider), amount1); @@ -832,130 +989,124 @@ contract SlisBNBV3ProviderTest is Test { } function test_deposit_oneSided_token0Only_aboveRange_reverts() public { - _deposit(user, 10_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceAboveRange(); - // Price above range: token0 alone yields 0 liquidity → "zero shares". - deal(USDC, user2, 10_000 ether); + // 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(USDC).approve(address(provider), 10_000 ether); - vm.expectRevert(V3Provider.ZeroLiquidity.selector); - provider.deposit(marketParams, 10_000 ether, 0, 0, 0, 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 = USDC; + badParams.collateralToken = SLISBNB; - deal(USDC, user, 1_000 ether); - deal(WBNB, user, 3 ether); + deal(SLISBNB, user, 10 ether); + deal(WBNB, user, 10 ether); vm.startPrank(user); - IERC20(USDC).approve(address(provider), 1_000 ether); - IERC20(WBNB).approve(address(provider), 3 ether); + 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, 1_000 ether, 3 ether, 1, 1, user); + provider.deposit(badParams, 10 ether, 10 ether, 1, 1, user); vm.stopPrank(); } function test_getTokenConfig() public view { - TokenConfig memory config = provider.getTokenConfig(address(provider)); + TokenConfig memory config = providerOracle.getTokenConfig(address(provider)); assertEq(config.asset, address(provider)); - assertEq(config.oracles[0], 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 USDC) ─────── */ - - // Prices: USDC = $1, WBNB = $700 (8-decimal USD) - uint256 constant USDC_PRICE = 1e8; - uint256 constant WBNB_PRICE = 700e8; - // USDC and WBNB are both 18-decimal on BSC. - uint256 constant TOKEN_DECIMALS = 1e18; - - function _mockOraclePrices() internal { - vm.mockCall(RESILIENT_ORACLE, abi.encodeWithSelector(IOracle.peek.selector, USDC), abi.encode(USDC_PRICE)); - vm.mockCall(RESILIENT_ORACLE, abi.encodeWithSelector(IOracle.peek.selector, WBNB), abi.encode(WBNB_PRICE)); - } + /* ─────────── rebalance after price leaves range (fully slisBNB) ─────── */ /// @dev Compute USD value (8-decimal) from raw token amounts. - function _valueUSD(uint256 amount0, uint256 amount1) internal pure returns (uint256) { - return (amount0 * USDC_PRICE) / TOKEN_DECIMALS + (amount1 * WBNB_PRICE) / TOKEN_DECIMALS; + /// 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 USDC → WBNB. + /// @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 (USDC). + /// 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 usdcIn = 5_000_000_000 ether; // 5 billion USDC — enough to blow past ±500 ticks - deal(USDC, address(swapper), usdcIn); - swapper.swapExactIn(POOL, true, usdcIn); + uint256 slisIn = 20_000 ether; + deal(SLISBNB, address(swapper), slisIn); + swapper.swapExactIn(POOL, true, slisIn); } - function test_rebalance_priceBelowRange_positionFullyUSDC() public { - _mockOraclePrices(); - _deposit(user, 10_000 ether, 30 ether); + function test_rebalance_priceBelowRange_positionFullyslisBNB() public { + _deposit(user, 10 ether, 10 ether); - // Push price below tickLower — position should convert entirely to USDC (token0). + // Push price below tickLower — position should convert entirely to slisBNB (token0). _pushPriceBelowRange(); - (, int24 tickAfterSwap, , , , , ) = IListaV3Pool(POOL).slot0(); - assertLt(tickAfterSwap, provider.tickLower(), "tick should be below tickLower after swap"); + (, 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 USDC"); - assertEq(total1, 0, "position should be fully USDC (token1 == 0) when price is below range"); + 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 { - _mockOraclePrices(); - _deposit(user, 10_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceBelowRange(); - // Snapshot USD value before rebalance (position is 100% USDC). + // 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. - uint256 min0 = (total0Before * 999) / 1000; vm.prank(bot); - provider.rebalance(min0, 0, 0, block.timestamp); + provider.rebalance(0, 0, 0, block.timestamp); - assertLt(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); uint256 valueAfter = _valueUSD(total0After, total1After); - assertApproxEqRel(valueAfter, valueBefore, 0.01e16, "total value should be preserved within 0.01% after rebalance"); + // The slisBNB adapter converts inventory to the rate-optimal ratio via the StakeManager and + // re-mints; value is preserved within ~2% (instant-withdraw/stake conversion rounding). + assertApproxEqRel(valueAfter, valueBefore, 0.02e18, "total value should be preserved within 2% after rebalance"); } /* ─────────── rebalance after price leaves range (fully WBNB) ──────── */ - /// @dev Push pool price above tickUpper by swapping a large amount of WBNB → USDC. + /// @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 = 10_000_000 ether; // 10 million WBNB — enough to blow past ±500 ticks + uint256 wbnbIn = 20_000 ether; deal(WBNB, address(swapper), wbnbIn); swapper.swapExactIn(POOL, false, wbnbIn); } function test_rebalance_priceAboveRange_positionFullyWBNB() public { - _mockOraclePrices(); - _deposit(user, 10_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); // Push price above tickUpper — position should convert entirely to WBNB (token1). _pushPriceAboveRange(); - (, int24 tickAfterSwap, , , , , ) = IListaV3Pool(POOL).slot0(); - assertGt(tickAfterSwap, provider.tickUpper(), "tick should be above tickUpper after swap"); + (, 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"); @@ -963,8 +1114,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_rebalance_priceAboveRange_totalValuePreserved() public { - _mockOraclePrices(); - _deposit(user, 10_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceAboveRange(); @@ -973,43 +1123,56 @@ contract SlisBNBV3ProviderTest is Test { 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. - uint256 min1 = (total1Before * 999) / 1000; vm.prank(bot); - provider.rebalance(0, min1, 0, block.timestamp); + provider.rebalance(0, 0, 0, block.timestamp); - assertLt(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); (uint256 total0After, uint256 total1After) = provider.getTotalAmounts(); uint256 valueAfter = _valueUSD(total0After, total1After); - assertApproxEqRel(valueAfter, valueBefore, 0.01e16, "total value should be preserved within 0.01% after rebalance"); + // Inventory converted to the rate-optimal ratio via the StakeManager and re-minted; value + // preserved within ~2% (instant-withdraw/stake conversion rounding). + assertApproxEqRel(valueAfter, valueBefore, 0.02e18, "total value should be preserved within 2% after rebalance"); } /* ──────────── minAmount slippage guard tests ────────────────────── */ - /// @dev When price is below range the position is 100% USDC (token0). - /// rebalance with minAmount0 = actual USDC held passes; minAmount0 > actual reverts. + /// @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_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceBelowRange(); (uint256 total0, ) = provider.getTotalAmounts(); - assertGt(total0, 0, "should hold USDC before rebalance"); + 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(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); } function test_rebalance_priceBelowRange_minAmount0_tooHigh_reverts() public { - _deposit(user, 10_000 ether, 30 ether); + _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(); @@ -1019,25 +1182,33 @@ contract SlisBNBV3ProviderTest is Test { /// @dev When price is above range the position is 100% WBNB (token1). /// rebalance with minAmount1 = actual WBNB held passes; minAmount1 > actual reverts. function test_rebalance_priceAboveRange_minAmount1_passes() public { - _deposit(user, 10_000 ether, 30 ether); + _deposit(user, 10 ether, 10 ether); _pushPriceAboveRange(); (, uint256 total1) = provider.getTotalAmounts(); assertGt(total1, 0, "should hold WBNB before rebalance"); - // minAmount0 = 0 (no USDC), minAmount1 = total1 (exact). + // 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(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); } function test_rebalance_priceAboveRange_minAmount1_tooHigh_reverts() public { - _deposit(user, 10_000 ether, 30 ether); + _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(); @@ -1045,7 +1216,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_withdraw_minAmount_tooHigh_reverts() public { - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); (uint256 exp0, ) = provider.previewRedeemUnderlying(shares); @@ -1055,7 +1226,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_redeemShares_minAmount_tooHigh_reverts() public { - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); vm.prank(MOOLAH_PROXY); provider.transfer(user2, shares); @@ -1072,7 +1243,7 @@ contract SlisBNBV3ProviderTest is Test { function test_withdraw_belowRange_returnsToken0Only() public { // When price is below tickLower the entire position is token0. - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); _pushPriceBelowRange(); (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); @@ -1088,7 +1259,7 @@ contract SlisBNBV3ProviderTest is Test { function test_withdraw_aboveRange_returnsToken1Only() public { // When price is above tickUpper the entire position is token1. - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); _pushPriceAboveRange(); (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); @@ -1105,7 +1276,7 @@ contract SlisBNBV3ProviderTest is Test { function test_withdraw_inRange_cannotForceOneSided_alwaysBoth() public { // Even with minAmount1=0, an in-range withdrawal still returns token1. // Setting min to 0 disables the floor but does not change what is received. - (uint256 shares, , ) = _deposit(user, 10_000 ether, 30 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); (uint256 exp0, ) = provider.previewRedeemUnderlying(shares); @@ -1179,23 +1350,23 @@ contract SlisBNBV3ProviderTest is Test { /* ─────────────────── slisBNBx: deposit tracking ────────────────── */ function test_deposit_updatesUserMarketDeposit() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (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, 1_000 ether, 3 ether); - (uint256 shares2, , ) = _deposit(user, 1_000 ether, 3 ether); + (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, 1_000 ether, 3 ether); - (uint256 shares2, , ) = _deposit(user2, 2_000 ether, 6 ether); + (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); @@ -1206,7 +1377,7 @@ contract SlisBNBV3ProviderTest is Test { /* ─────────────────── slisBNBx: withdraw tracking ───────────────── */ function test_withdraw_updatesUserMarketDeposit() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(shares); vm.prank(user); @@ -1217,7 +1388,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_withdraw_partial_updatesTracking() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); uint256 half = shares / 2; (uint256 exp0, uint256 exp1) = provider.previewRedeemUnderlying(half); @@ -1232,7 +1403,7 @@ contract SlisBNBV3ProviderTest is Test { /* ─────────────────── slisBNBx: liquidate tracking ──────────────── */ function test_liquidate_syncsBorrowerToZero() public { - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); assertEq(provider.userMarketDeposit(user, marketId), shares); // Simulate post-liquidation: Moolah reports 0 collateral for the borrower. @@ -1251,30 +1422,20 @@ contract SlisBNBV3ProviderTest is Test { /* ─────────────────── slisBNBx: getUserBalanceInBnb ─────────────── */ - address constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - uint256 constant BNB_PRICE = 700e8; - - function _mockAllPrices() internal { - _mockOraclePrices(); // mocks USDC and WBNB prices - vm.mockCall(RESILIENT_ORACLE, abi.encodeWithSelector(IOracle.peek.selector, BNB_ADDRESS), abi.encode(BNB_PRICE)); - } - function test_getUserBalanceInBnb_zeroBeforeDeposit() public view { assertEq(provider.getUserBalanceInBnb(user), 0); } function test_getUserBalanceInBnb_nonzeroAfterDeposit() public { - _mockAllPrices(); - _deposit(user, 1_000 ether, 3 ether); + _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 { - _mockAllPrices(); - _deposit(user, 1_000 ether, 3 ether); - _deposit(user2, 2_000 ether, 6 ether); + _deposit(user, 10 ether, 10 ether); + _deposit(user2, 20 ether, 20 ether); uint256 value1 = provider.getUserBalanceInBnb(user); uint256 value2 = provider.getUserBalanceInBnb(user2); @@ -1284,14 +1445,13 @@ contract SlisBNBV3ProviderTest is Test { } function test_getUserBalanceInBnb_matchesShareValueInBnb() public { - _mockAllPrices(); - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (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 = provider.peek(address(provider)); // 8-dec USD * 1e18 / liquidity-unit - uint256 expectedBnbValue = (shares * sharePrice) / BNB_PRICE; + 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. @@ -1301,7 +1461,7 @@ contract SlisBNBV3ProviderTest is Test { /* ─────────────────── slisBNBx: manual sync ─────────────────────── */ function test_syncUserBalance_noOpWhenAlreadySynced() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); uint256 depositBefore = provider.userMarketDeposit(user, marketId); provider.syncUserBalance(marketId, user); @@ -1309,8 +1469,8 @@ contract SlisBNBV3ProviderTest is Test { } function test_bulkSyncUserBalance_syncsMultipleUsers() public { - _deposit(user, 1_000 ether, 3 ether); - _deposit(user2, 2_000 ether, 6 ether); + _deposit(user, 10 ether, 10 ether); + _deposit(user2, 20 ether, 20 ether); uint256 d1 = provider.userMarketDeposit(user, marketId); uint256 d2 = provider.userMarketDeposit(user2, marketId); @@ -1348,7 +1508,7 @@ contract SlisBNBV3ProviderTest is Test { MarketParams memory foreign = MarketParams({ loanToken: LISUSD, collateralToken: 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B, // slisBNB - oracle: RESILIENT_ORACLE, + oracle: 0xf3afD82A4071f272F403dC176916141f44E6c750, // multiOracle irm: 0x5F9f9173B405C6CEAfa7f98d09e4B8447e9797E6, lltv: 90 * 1e16 }); @@ -1356,7 +1516,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_syncUserBalance_foreignMarket_reverts() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); uint256 totalBefore = provider.userTotalDeposit(user); vm.expectRevert(V3Provider.InvalidMarket.selector); @@ -1367,7 +1527,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_bulkSyncUserBalance_foreignMarket_reverts() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); uint256 totalBefore = provider.userTotalDeposit(user); Id[] memory ids = new Id[](1); @@ -1388,8 +1548,7 @@ contract SlisBNBV3ProviderTest is Test { vm.prank(manager); provider.setSlisBNBxMinter(address(minter)); - _mockAllPrices(); - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); // Deposit tracking assertEq(provider.userMarketDeposit(user, marketId), shares, "userMarketDeposit should equal shares"); @@ -1403,8 +1562,7 @@ contract SlisBNBV3ProviderTest is Test { vm.prank(manager); provider.setSlisBNBxMinter(address(minter)); - _mockAllPrices(); - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (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); @@ -1423,8 +1581,7 @@ contract SlisBNBV3ProviderTest is Test { vm.prank(manager); provider.setSlisBNBxMinter(address(minter)); - _mockAllPrices(); - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); uint256 slisBNBxAfterDeposit = ISlisBNBx(SLISBNBX).balanceOf(user); assertGt(slisBNBxAfterDeposit, 0); @@ -1448,8 +1605,7 @@ contract SlisBNBV3ProviderTest is Test { vm.prank(manager); provider.setSlisBNBxMinter(address(minter)); - _mockAllPrices(); - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (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"); @@ -1485,8 +1641,8 @@ contract SlisBNBV3ProviderTest is Test { /// but large enough that mocking the price to zero makes the position unhealthy. function _borrowAgainstCollateral(address _user) internal returns (uint256 borrowed) { (, , uint128 col) = moolah.position(marketId, _user); - uint256 sharePrice = provider.peek(address(provider)); // 8-dec USD per share - uint256 loanPrice = provider.peek(LISUSD); // 8-dec USD per lisUSD (~1e8) + 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); @@ -1495,14 +1651,14 @@ contract SlisBNBV3ProviderTest is Test { /// @dev Set collateral oracle price to zero, making any position with debt unhealthy. function _makeUnhealthy() internal { vm.mockCall( - address(provider), + address(providerOracle), abi.encodeWithSelector(IOracle.peek.selector, address(provider)), abi.encode(uint256(0)) ); } function test_borrow_afterDeposit_receivesLisUSD() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); uint256 balBefore = IERC20(LISUSD).balanceOf(user); _borrow(user, 100 ether); assertEq(IERC20(LISUSD).balanceOf(user), balBefore + 100 ether); @@ -1510,8 +1666,8 @@ contract SlisBNBV3ProviderTest is Test { } function test_borrow_twoUsers_independentDebt() public { - _deposit(user, 1_000 ether, 3 ether); - _deposit(user2, 2_000 ether, 6 ether); + _deposit(user, 10 ether, 10 ether); + _deposit(user2, 20 ether, 20 ether); _borrow(user, 100 ether); _borrow(user2, 200 ether); assertGt(_debtOf(user), 0); @@ -1521,7 +1677,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_repay_full_clearsDebt() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); _borrow(user, 100 ether); assertGt(_debtOf(user), 0); @@ -1535,7 +1691,7 @@ contract SlisBNBV3ProviderTest is Test { } function test_repay_partial_reducesDebt() public { - _deposit(user, 1_000 ether, 3 ether); + _deposit(user, 10 ether, 10 ether); _borrow(user, 100 ether); uint128 sharesBefore = _debtOf(user); @@ -1552,7 +1708,7 @@ contract SlisBNBV3ProviderTest is Test { function test_liquidate_seizedSharesSentToLiquidator() public { address liquidator = makeAddr("liquidator"); - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); _borrowAgainstCollateral(user); _makeUnhealthy(); @@ -1571,7 +1727,7 @@ contract SlisBNBV3ProviderTest is Test { function test_liquidate_liquidatorRedeemsSharesToTokens() public { address liquidator = makeAddr("liquidator"); - (uint256 shares, , ) = _deposit(user, 1_000 ether, 3 ether); + (uint256 shares, , ) = _deposit(user, 10 ether, 10 ether); _borrowAgainstCollateral(user); _makeUnhealthy(); @@ -1592,128 +1748,36 @@ contract SlisBNBV3ProviderTest is Test { assertEq(provider.balanceOf(liquidator), 0, "shares burned after redeem"); assertGt(out0 + out1, 0, "liquidator received tokens"); - assertEq(IERC20(USDC).balanceOf(liquidator), out0); + assertEq(IERC20(SLISBNB).balanceOf(liquidator), out0); assertEq(liquidator.balance, out1); // WBNB unwrapped to BNB } - /* ───── peek() discontinuity when rebalance happens while TWAP lags ───── */ - - /// @notice Demonstrates that rebalancing while spot has diverged far from TWAP - /// causes a peek() discontinuity — the oracle-reported share price jumps - /// even though no real value was created or destroyed. - /// - /// Scenario: - /// 1. User deposits into an in-range position. - /// 2. A large swap pushes spot price far below tickLower (position → 100% USDC). - /// 3. TWAP still reflects the old price (lagging behind spot). - /// 4. peek() is called before and after rebalance — the share price jumps because - /// the TWAP tick lands in a completely different region of the new range vs the old range. - /// - /// Before rebalance: - /// old range [tickLower, tickUpper], TWAP tick < old tickLower - /// → _getTotalAmountsAt(twap) = 100% token0 - /// → peek = total0 × price0 / supply - /// - /// After rebalance (new range centered below TWAP): - /// new range is ABOVE the current spot tick but BELOW the TWAP tick - /// → _getTotalAmountsAt(twap) evaluates position as if price is above new tickUpper - /// → 100% token1 (WBNB) at TWAP-implied amounts — different composition and value - /// - /// This is the TWAP-stale-window risk: the oracle's view of token0/token1 split - /// doesn't match reality, and rebalancing changes which "wrong view" is computed. - function test_peek_discontinuity_on_rebalance_with_stale_twap() public { - _mockOraclePrices(); - - // 1. User deposits and borrows against collateral. - _deposit(user, 10_000 ether, 30 ether); - uint256 shares = _collateral(user); - assertGt(shares, 0); - - // Record peek() at the healthy state. - uint256 peekHealthy = provider.peek(address(provider)); - assertGt(peekHealthy, 0, "peek should be non-zero after deposit"); - - // 2. Push spot price far below tickLower. - // TWAP (30-min average) barely moves — it still reflects the old price range. - _pushPriceBelowRange(); - - (, int24 spotTickAfterSwap, , , , , ) = IListaV3Pool(POOL).slot0(); - int24 twapTickAfterSwap = provider.getTwapTick(); - - // Confirm TWAP is still well above spot — the stale window. - assertLt(spotTickAfterSwap, provider.tickLower(), "spot should be below old tickLower"); - assertGt(twapTickAfterSwap, spotTickAfterSwap + 200, "TWAP should lag significantly behind spot"); - - // 3. peek() before rebalance — TWAP evaluates old range. - uint256 peekBeforeRebalance = provider.peek(address(provider)); - - // 4. Rebalance: create new range centered around the new spot tick. - // Choose a range that is entirely BELOW the TWAP tick so that - // _getTotalAmountsAt(twapSqrtPrice) sees the new range as "price above tickUpper" - // → interprets the position as 100% token1 (WBNB). - // - // Before rebalance, TWAP was below old tickLower → 100% token0 (USDC). - // After rebalance, TWAP is above new tickUpper → 100% token1 (WBNB). - // Same liquidity, but peek() reports a completely different token composition. - // Place new range ABOVE spot (so only token0/USDC is needed to mint, - // matching the 100%-USDC holdings) but BELOW the TWAP tick (so peek() - // evaluates the new position as "price above tickUpper" → 100% token1). - int24 newLower = spotTickAfterSwap + 100; - int24 newUpper = spotTickAfterSwap + 500; - - // Ensure new range is entirely below the TWAP tick. - assertLt(newUpper, twapTickAfterSwap, "new tickUpper should be below TWAP tick"); - // Ensure new range is entirely above the spot tick. - assertGt(newLower, spotTickAfterSwap, "new tickLower should be above spot tick"); - - vm.prank(bot); - provider.rebalance(0, 0, 0, block.timestamp); - - // 5. peek() after rebalance — TWAP evaluates NEW range. - uint256 peekAfterRebalance = provider.peek(address(provider)); - - // The share price SHOULD be approximately the same (no real value change), - // but due to TWAP staleness it can jump significantly. - uint256 priceDelta; - if (peekAfterRebalance > peekBeforeRebalance) { - priceDelta = peekAfterRebalance - peekBeforeRebalance; - } else { - priceDelta = peekBeforeRebalance - peekAfterRebalance; - } - uint256 pctChange = (priceDelta * 1e18) / peekBeforeRebalance; - - // Log for visibility. - emit log_named_uint("peek before rebalance (8 dec)", peekBeforeRebalance); - emit log_named_uint("peek after rebalance (8 dec)", peekAfterRebalance); - emit log_named_uint("change % (18 dec = 100%)", pctChange); - emit log_named_int("spot tick after swap", spotTickAfterSwap); - emit log_named_int("TWAP tick after swap", twapTickAfterSwap); - emit log_named_int("new tickLower", newLower); - emit log_named_int("new tickUpper", newUpper); - - // Without a spot/TWAP rebalance guard, the rebalance succeeds and causes a large - // peek() discontinuity. This proves the TWAP-stale-window risk is real. - assertGt(pctChange, 0.01e18, "peek() should show a >1% discontinuity due to stale TWAP"); - } - /// @notice The V3 provider no longer blocks rebalance based on spot/TWAP tick deviation. function test_rebalance_noLongerUsesTwapDeviationGuard() public { - _deposit(user, 10_000 ether, 30 ether); + _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(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); + 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_000 ether, 30 ether); + _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(provider.tickLower(), provider.tickUpper(), "tick range remains valid"); + assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); } } diff --git a/test/provider/SlisBNBV3ProviderRate.t.sol b/test/provider/SlisBNBV3ProviderRate.t.sol index d70d80fa..5186fa68 100644 --- a/test/provider/SlisBNBV3ProviderRate.t.sol +++ b/test/provider/SlisBNBV3ProviderRate.t.sol @@ -7,12 +7,15 @@ import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SlisBNBV3Provider } from "../../src/provider/SlisBNBV3Provider.sol"; +import { SlisBNBV3DexAdapter } from "../../src/provider/SlisBNBV3DexAdapter.sol"; +import { SlisBNBV3ProviderOracle } from "../../src/provider/SlisBNBV3ProviderOracle.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 { @@ -42,16 +45,72 @@ contract PoolSwapper { } 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)); } } -/// @notice Rate-path integration tests for SlisBNBV3Provider, forked against the live -/// PancakeSwap V3 slisBNB/WBNB 1bp pool + the real slisBNB StakeManager. Verifies the -/// exchange-rate oracle (peek / totalAssets / getUserBalanceInBnb) is invariant to pool-price -/// manipulation, and that the custom slisBNB/BNB rebalance entry point runs end-to-end. +/// @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 {} +} + +/// @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; @@ -77,7 +136,9 @@ contract SlisBNBV3ProviderRateTest is Test { uint256 constant BNB_USD = 600e8; // mock BNB price, 8 decimals Moolah moolah; + SlisBNBV3DexAdapter adapter; SlisBNBV3Provider provider; + SlisBNBV3ProviderOracle providerOracle; MockOracle oracle; PoolSwapper swapper; MarketParams marketParams; @@ -92,11 +153,11 @@ contract SlisBNBV3ProviderRateTest is Test { vm.createSelectFork(vm.envString("BSC_RPC"), 60541406); emit log_named_uint("gas_at_start", gasleft()); - // Deploy the (large) provider implementation FIRST, while setUp gas is untouched — forge's setUp - // gas forwarding chokes on the ~5.3M code-deposit if other deploys run before it. - SlisBNBV3Provider impl = new SlisBNBV3Provider(MOOLAH_PROXY, NPM, SLISBNB, WBNB, FEE, TWAP_PERIOD); - - moolah = Moolah(MOOLAH_PROXY); + // 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(); @@ -105,21 +166,76 @@ contract SlisBNBV3ProviderRateTest is Test { 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(SlisBNBV3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + ) + ) + ); + + moolah = Moolah(MOOLAH_PROXY); + swapper = new PoolSwapper(); emit log_named_uint("gas_after_swapper", gasleft()); - bytes memory initData = abi.encodeCall( - SlisBNBV3Provider.initialize, - (admin, manager, bot, address(oracle), "slisBNB/BNB vLP", "vLP-slisBNB-BNB") - ); - provider = SlisBNBV3Provider(payable(new ERC1967Proxy(address(impl), initData))); - assertEq(provider.lastCenterRate(), rate, "lastCenterRate initialized from StakeManager"); - assertEq(provider.centerRateThresholdBps(), 100, "default center-rate threshold is 1%"); + 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(provider), + oracle: address(providerOracle), irm: IRM, lltv: LLTV }); @@ -154,14 +270,14 @@ contract SlisBNBV3ProviderRateTest is Test { function test_peek_usesRate_invariantToPoolManipulation() public { _deposit(10 ether, 10 ether); - uint256 peekBefore = provider.peek(address(provider)); + 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 = provider.peek(address(provider)); + uint256 peekAfter = providerOracle.peek(address(provider)); (uint256 s0After, uint256 s1After) = provider.getTotalAmounts(); // sanity: the pool price actually moved a lot @@ -200,7 +316,7 @@ contract SlisBNBV3ProviderRateTest is Test { // 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 = provider.peek(address(provider)); + uint256 p = providerOracle.peek(address(provider)); assertGt(p, 0, "rate-based peek works even without TWAP history"); } @@ -208,26 +324,26 @@ contract SlisBNBV3ProviderRateTest is Test { function test_rebalance_recentersToRateDerivedRange() public { _deposit(10 ether, 10 ether); - uint256 peekBefore = provider.peek(address(provider)); - uint256 oldTokenId = provider.tokenId(); + uint256 peekBefore = providerOracle.peek(address(provider)); + uint256 oldTokenId = adapter.tokenId(); vm.prank(manager); - provider.setCenterRateThresholdBps(0); + adapter.setCenterRateThresholdBps(0); vm.prank(bot); provider.rebalance(0, 0, 0, block.timestamp); - assertGt(provider.tokenId(), oldTokenId, "position should be re-minted"); - assertLt(provider.tickLower(), provider.tickUpper(), "rate-derived range should be valid"); - assertApproxEqRel(provider.peek(address(provider)), peekBefore, 2e16, "rebalance is ~value-neutral"); - assertEq(provider.lastCenterRate(), IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18), "center rate updated"); + 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(SlisBNBV3Provider.RateDeviationBelowThreshold.selector); + vm.expectRevert(SlisBNBV3DexAdapter.RateDeviationBelowThreshold.selector); provider.rebalance(0, 0, 0, block.timestamp); } @@ -235,7 +351,7 @@ contract SlisBNBV3ProviderRateTest is Test { _deposit(10 ether, 10 ether); vm.prank(bot); - vm.expectRevert(SlisBNBV3Provider.DeadlineExpired.selector); + vm.expectRevert(SlisBNBV3DexAdapter.DeadlineExpired.selector); provider.rebalance(0, 0, 0, block.timestamp - 1); } @@ -243,14 +359,14 @@ contract SlisBNBV3ProviderRateTest is Test { _deposit(10 ether, 10 ether); vm.prank(manager); - provider.setCenterRateThresholdBps(0); + adapter.setCenterRateThresholdBps(0); vm.prank(bot); - vm.expectRevert(SlisBNBV3Provider.InsufficientLiquidityMinted.selector); + vm.expectRevert(SlisBNBV3DexAdapter.InsufficientLiquidityMinted.selector); provider.rebalance(0, 0, type(uint256).max, block.timestamp); } function _tick() internal view returns (int24 tick) { - (, tick, , , , , ) = IListaV3Pool(POOL).slot0(); + (, tick) = IV3PoolMinimal(POOL).slot0(); } } From 02ad2f28bac50a70a32f75ddbb9e5d9dd6195d40 Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:02:10 +0800 Subject: [PATCH 10/17] chore(provider): prune stale v3 libs from foundry.lock + prettier formatting - foundry.lock: remove orphaned lib/v3-core and lib/v3-periphery entries (superseded by lista-v3; no .gitmodules entry, lib dir, remapping, or import). - Apply prettier formatting to the V3 vault/adapter contracts and the liquidator + rate-path tests (npm run check now passes). Co-Authored-By: Claude Opus 4.8 (1M context) --- foundry.lock | 12 ------------ src/provider/V3DexAdapter.sol | 15 ++++++++------- src/provider/V3Provider.sol | 8 +++++++- test/liquidator/V3Liquidator.t.sol | 11 +++++++---- test/provider/SlisBNBV3ProviderRate.t.sol | 7 +------ 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/foundry.lock b/foundry.lock index 049a12f7..e3c9d9ff 100644 --- a/foundry.lock +++ b/foundry.lock @@ -13,17 +13,5 @@ "name": "v0.1.0", "rev": "5feccd1253d7da820f7cccccdedf64471025455d" } - }, - "lib/v3-core": { - "tag": { - "name": "v1.0.0", - "rev": "e3589b192d0be27e100cd0daaf6c97204fdb1899" - } - }, - "lib/v3-periphery": { - "tag": { - "name": "v1.3.0", - "rev": "80f26c86c57b8a5e4b913f42844d4c8bd274d058" - } } } \ No newline at end of file diff --git a/src/provider/V3DexAdapter.sol b/src/provider/V3DexAdapter.sol index 61e89011..de2c864c 100644 --- a/src/provider/V3DexAdapter.sol +++ b/src/provider/V3DexAdapter.sol @@ -34,12 +34,7 @@ import { IV3PoolMinimal } from "./interfaces/IV3PoolMinimal.sol"; * - receive(): widen accepted native-BNB senders (StakeManager instantWithdraw). * - rebalance(): added by the subclass (rate-centered recenter + inventory conversion). */ -abstract contract V3DexAdapter is - UUPSUpgradeable, - AccessControlUpgradeable, - ReentrancyGuardUpgradeable, - IV3DexAdapter -{ +abstract contract V3DexAdapter is UUPSUpgradeable, AccessControlUpgradeable, ReentrancyGuardUpgradeable, IV3DexAdapter { using SafeERC20 for IERC20; /* ─────────────────────────── immutables ─────────────────────────── */ @@ -305,7 +300,13 @@ abstract contract V3DexAdapter is uint160 sqrtPriceX96 = spotSqrtPriceX96(); uint160 sqrtLower = TickMath.getSqrtRatioAtTick(tickLower); uint160 sqrtUpper = TickMath.getSqrtRatioAtTick(tickUpper); - liquidity = LiquidityAmounts.getLiquidityForAmounts(sqrtPriceX96, sqrtLower, sqrtUpper, amount0Desired, amount1Desired); + liquidity = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + sqrtLower, + sqrtUpper, + amount0Desired, + amount1Desired + ); (amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtLower, sqrtUpper, liquidity); } diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol index e31855f9..0880cfbe 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/V3Provider.sol @@ -81,7 +81,13 @@ abstract contract V3Provider is /* ───────────────────────────── events ───────────────────────────── */ - event Deposit(address indexed onBehalf, uint256 amount0Used, uint256 amount1Used, uint256 shares, Id indexed marketId); + event Deposit( + address indexed onBehalf, + uint256 amount0Used, + uint256 amount1Used, + uint256 shares, + Id indexed marketId + ); event Withdraw( address indexed onBehalf, uint256 shares, diff --git a/test/liquidator/V3Liquidator.t.sol b/test/liquidator/V3Liquidator.t.sol index 7a294c08..3c23cd45 100644 --- a/test/liquidator/V3Liquidator.t.sol +++ b/test/liquidator/V3Liquidator.t.sol @@ -104,9 +104,7 @@ contract V3LiquidatorTest is Test { // 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))) - ) + payable(new ERC1967Proxy(address(adapterImpl), abi.encodeCall(SlisBNBV3DexAdapter.initialize, (admin, manager)))) ); // 2) Provider / vault: ERC-4626 shares = Moolah collateral. accountingAsset = WBNB. @@ -128,7 +126,12 @@ contract V3LiquidatorTest is Test { 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); + SlisBNBV3ProviderOracle oracleImpl = new SlisBNBV3ProviderOracle( + address(adapter), + address(provider), + SLISBNB, + WBNB + ); providerOracle = SlisBNBV3ProviderOracle( payable( new ERC1967Proxy( diff --git a/test/provider/SlisBNBV3ProviderRate.t.sol b/test/provider/SlisBNBV3ProviderRate.t.sol index 5186fa68..14540095 100644 --- a/test/provider/SlisBNBV3ProviderRate.t.sol +++ b/test/provider/SlisBNBV3ProviderRate.t.sol @@ -180,12 +180,7 @@ contract SlisBNBV3ProviderRateTest is Test { // 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)) - ) - ) + payable(new ERC1967Proxy(address(adapterImpl), abi.encodeCall(SlisBNBV3DexAdapter.initialize, (admin, manager)))) ); // 2) Vault (ERC-4626 shares + Moolah wiring). accountingAsset = WBNB for these pools. From 9c43f6c87527078ca70ed25ed6ddcbc6aa2cbcb9 Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:47:34 +0800 Subject: [PATCH 11/17] refactor(provider): use AccessControlEnumerableUpgradeable for V3 contracts Switch V3Provider, V3DexAdapter and SlisBNBV3ProviderOracle from plain AccessControlUpgradeable to AccessControlEnumerableUpgradeable, matching the codebase standard (Moolah, SmartProvider, et al.) so roles are enumerable on-chain via getRoleMember/getRoleMemberCount. Existing role logic (grantRole/hasRole/onlyRole/getRoleAdmin) is unchanged; all three stay under EIP-170. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/provider/SlisBNBV3ProviderOracle.sol | 4 ++-- src/provider/V3DexAdapter.sol | 9 +++++++-- src/provider/V3Provider.sol | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/provider/SlisBNBV3ProviderOracle.sol b/src/provider/SlisBNBV3ProviderOracle.sol index a51436bd..a5143ef0 100644 --- a/src/provider/SlisBNBV3ProviderOracle.sol +++ b/src/provider/SlisBNBV3ProviderOracle.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.34; -import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +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"; @@ -23,7 +23,7 @@ import { IV3ProviderOracle } from "./interfaces/IV3ProviderOracle.sol"; * @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 SlisBNBV3ProviderOracle is UUPSUpgradeable, AccessControlUpgradeable, IV3ProviderOracle { +contract SlisBNBV3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgradeable, IV3ProviderOracle { /* ─────────────────────────── immutables ─────────────────────────── */ /// @inheritdoc IV3ProviderOracle diff --git a/src/provider/V3DexAdapter.sol b/src/provider/V3DexAdapter.sol index de2c864c..47b0cb3e 100644 --- a/src/provider/V3DexAdapter.sol +++ b/src/provider/V3DexAdapter.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.34; -import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.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"; @@ -34,7 +34,12 @@ import { IV3PoolMinimal } from "./interfaces/IV3PoolMinimal.sol"; * - receive(): widen accepted native-BNB senders (StakeManager instantWithdraw). * - rebalance(): added by the subclass (rate-centered recenter + inventory conversion). */ -abstract contract V3DexAdapter is UUPSUpgradeable, AccessControlUpgradeable, ReentrancyGuardUpgradeable, IV3DexAdapter { +abstract contract V3DexAdapter is + UUPSUpgradeable, + AccessControlEnumerableUpgradeable, + ReentrancyGuardUpgradeable, + IV3DexAdapter +{ using SafeERC20 for IERC20; /* ─────────────────────────── immutables ─────────────────────────── */ diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol index 0880cfbe..50c21181 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/V3Provider.sol @@ -3,7 +3,7 @@ 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 { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.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"; @@ -41,7 +41,7 @@ import { IV3DexAdapter } from "./interfaces/IV3DexAdapter.sol"; abstract contract V3Provider is ERC4626Upgradeable, UUPSUpgradeable, - AccessControlUpgradeable, + AccessControlEnumerableUpgradeable, ReentrancyGuardUpgradeable, IV3Provider { From 17d3d05f0a6cedd65129b33529abd126224c0b26 Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:16:55 +0800 Subject: [PATCH 12/17] refactor(liquidator): use AccessControlEnumerableUpgradeable for V3Liquidator Match the V3 provider contracts and the codebase standard so roles are enumerable on-chain (getRoleMember/getRoleMemberCount). Existing role logic is unchanged; V3Liquidator stays well under EIP-170. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/liquidator/V3Liquidator.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/liquidator/V3Liquidator.sol b/src/liquidator/V3Liquidator.sol index 7b3b08ce..d215fe88 100644 --- a/src/liquidator/V3Liquidator.sol +++ b/src/liquidator/V3Liquidator.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.34; -import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +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"; @@ -21,7 +21,7 @@ import "./Interface.sol"; * 3. redeemV3Shares() — standalone: redeem shares held by this contract. * 4. sellToken/sellBNB() — swap any token/BNB held by this contract (e.g. post-redeem). */ -contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessControlUpgradeable { +contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessControlEnumerableUpgradeable { using SafeTransferLib for address; /* ──────────────────────────── errors ────────────────────────────── */ From 389ac41d330862ad5e88eadd85dee9f5392c2279 Mon Sep 17 00:00:00 2001 From: yq <153907566+qingyang-lista@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:20:55 +0800 Subject: [PATCH 13/17] fix(provider): refund after mint+supply to close deposit reentrancy V3Provider.deposit forwarded the unused-input refund to the depositor via a native-BNB call inside adapter.addLiquidity, BEFORE _mint + supplyCollateral. During that callback the adapter NAV already included the freshly-added liquidity while totalSupply() was still stale, so SlisBNBV3ProviderOracle.peek returned a transiently inflated share price (26x on a BSC fork). Moolah was not yet reentrancy-locked at that point, letting a malicious depositor reenter Moolah.borrow and over-borrow against pre-existing collateral, leaving bad debt. Fix: the adapter now refunds to the vault, and the vault forwards the refund to the depositor only AFTER _mint + supplyCollateral, when NAV and totalSupply are consistent. Adds a guarded receive() (adapter-only) + _refund helper. Regression: test_C1_depositRefundReentrancy_neutralized drives the full attack and asserts peek during the refund equals the normal price and the attacker position stays solvent. Fails pre-fix (26x inflation, insolvent), passes post-fix. Co-Authored-By: Claude Fable 5 --- src/provider/V3Provider.sol | 34 ++++- test/provider/V3ProviderReentrancyPoC.t.sol | 148 ++++++++++++++++++++ 2 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 test/provider/V3ProviderReentrancyPoC.t.sol diff --git a/src/provider/V3Provider.sol b/src/provider/V3Provider.sol index 50c21181..b6873822 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/V3Provider.sol @@ -112,6 +112,8 @@ abstract contract V3Provider is error OnlyMoolah(); error InvalidMarket(); error StandardEntryDisabled(); + error BnbTransferFailed(); + error NotAdapter(); /* ─────────────────────────── constructor ────────────────────────── */ @@ -231,7 +233,12 @@ abstract contract V3Provider is if (totalValueBefore == 0) revert ZeroShares(); } - // Forward the input to the adapter, which adds liquidity and refunds unused to the depositor. + // 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); @@ -241,7 +248,7 @@ abstract contract V3Provider is _amount1Desired, amount0Min, amount1Min, - msg.sender + address(this) ); (uint256 added0, uint256 added1) = IV3DexAdapter(ADAPTER).amountsForLiquidity(liquidityAdded, fairSqrtPriceX96); @@ -261,6 +268,13 @@ abstract contract V3Provider is _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 @@ -438,5 +452,21 @@ abstract contract V3Provider is return msg.sender == onBehalf || MOOLAH.isAuthorized(onBehalf, msg.sender); } + /// @dev Forward a deposit refund to the depositor. WBNB is already unwrapped to native BNB by the + /// adapter when it refunds to this vault, so the WBNB leg is paid out as native BNB. + function _refund(address token, uint256 amount, address to) internal { + if (token == WBNB) { + (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/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 {} + } +} From 8e41af13336eac4c7475bffcb44167dba26a624c Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:05:58 +0800 Subject: [PATCH 14/17] =?UTF-8?q?feat(provider):=20V3=20LP=20collateral=20?= =?UTF-8?q?provider=20=E2=80=94=20generalize=20+=20Ethereum=20wstETH/wbETH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the slisBNB V3 LP provider into a chain/LST-agnostic base and add Ethereum wstETH/WETH and wbETH/WETH markets. - Base: chain/LST-agnostic V3Provider / V3DexAdapter / V3ProviderOracle (WRAPPED_NATIVE immutable; virtual _lstNativeRate() / _convertToOptimalRatio() hooks). slisBNB becomes a thin, byte-equivalent subclass. All V3 contracts relocated under src/provider/v3/. - New LST families: wstETH/WETH and wbETH/WETH adapters + vaults + IWstETH/IWbETH. - Oracle: rate-implied (slisBNB) / pool-TWAP-clamped-to-rate (wstETH/wbETH) share pricing; components valued rate-derived via the resilient oracle. - Rebalance swap is DEX-agnostic: backend-built swapData against a whitelisted swapPair (SwapInventoryLib generic swap, backend amountOutMin); drop SwapRouter02 coupling (delete ISwapRouter02). - V3Liquidator: chain-agnostic native leg via provider.WRAPPED_NATIVE() for both legs; wrap native back when loanToken == wrapped-native; reject sensitive swapPair (token/pool/NPM). - Cross-validate provider<->adapter<->oracle wiring in setProvider + oracle ctor. Tests: 245 passing — BSC slisBNB/liquidator regression unchanged + Ethereum fork suites for wstETH, wbETH and V3Liquidator (incl. native-leg + wiring guards). Co-Authored-By: Claude Opus 4.8 --- src/liquidator/V3Liquidator.sol | 81 ++- src/provider/SlisBNBV3DexAdapter.sol | 287 ---------- .../interfaces/ISlisBNBV3DexAdapter.sol | 17 +- src/provider/interfaces/IV3DexAdapter.sol | 24 + src/provider/interfaces/IV3Provider.sol | 5 + src/provider/interfaces/IWbETH.sol | 8 + src/provider/interfaces/IWstETH.sol | 8 + src/provider/libraries/SwapInventoryLib.sol | 74 +++ src/provider/v3/SlisBNBV3DexAdapter.sol | 106 ++++ src/provider/{ => v3}/SlisBNBV3Provider.sol | 9 +- src/provider/v3/SlisBNBV3ProviderOracle.sol | 31 + src/provider/{ => v3}/V3DexAdapter.sol | 261 ++++++++- src/provider/{ => v3}/V3Provider.sol | 32 +- .../V3ProviderOracle.sol} | 52 +- src/provider/v3/WbETHV3DexAdapter.sol | 149 +++++ src/provider/v3/WbETHV3Provider.sol | 43 ++ src/provider/v3/WstETHV3DexAdapter.sol | 157 +++++ src/provider/v3/WstETHV3Provider.sol | 42 ++ test/liquidator/V3Liquidator.t.sol | 9 +- test/liquidator/V3LiquidatorEth.t.sol | 405 +++++++++++++ test/provider/SlisBNBV3Provider.t.sol | 13 +- test/provider/SlisBNBV3ProviderRate.t.sol | 16 +- test/provider/WbETHV3Provider.t.sol | 183 ++++++ test/provider/WstETHV3Provider.t.sol | 542 ++++++++++++++++++ 24 files changed, 2150 insertions(+), 404 deletions(-) delete mode 100644 src/provider/SlisBNBV3DexAdapter.sol create mode 100644 src/provider/interfaces/IWbETH.sol create mode 100644 src/provider/interfaces/IWstETH.sol create mode 100644 src/provider/libraries/SwapInventoryLib.sol create mode 100644 src/provider/v3/SlisBNBV3DexAdapter.sol rename src/provider/{ => v3}/SlisBNBV3Provider.sol (94%) create mode 100644 src/provider/v3/SlisBNBV3ProviderOracle.sol rename src/provider/{ => v3}/V3DexAdapter.sol (57%) rename src/provider/{ => v3}/V3Provider.sol (94%) rename src/provider/{SlisBNBV3ProviderOracle.sol => v3/V3ProviderOracle.sol} (67%) create mode 100644 src/provider/v3/WbETHV3DexAdapter.sol create mode 100644 src/provider/v3/WbETHV3Provider.sol create mode 100644 src/provider/v3/WstETHV3DexAdapter.sol create mode 100644 src/provider/v3/WstETHV3Provider.sol create mode 100644 test/liquidator/V3LiquidatorEth.t.sol create mode 100644 test/provider/WbETHV3Provider.t.sol create mode 100644 test/provider/WstETHV3Provider.t.sol diff --git a/src/liquidator/V3Liquidator.sol b/src/liquidator/V3Liquidator.sol index d215fe88..4a38ef1d 100644 --- a/src/liquidator/V3Liquidator.sol +++ b/src/liquidator/V3Liquidator.sol @@ -8,6 +8,7 @@ import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/ import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; import { IV3Provider } from "../provider/interfaces/IV3Provider.sol"; +import { IWBNB } from "../provider/interfaces/IWBNB.sol"; import "./Interface.sol"; /** @@ -38,12 +39,9 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont bytes32 public constant MANAGER = keccak256("MANAGER"); bytes32 public constant BOT = keccak256("BOT"); - /// @dev Virtual address used to represent native BNB in token whitelists. + /// @dev Virtual address used to represent the native coin in token whitelists. address public constant BNB_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - /// @dev BSC wrapped native token — V3Provider unwraps it to native BNB on exit. - address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; - /* ──────────────────────────── immutables ─────────────────────────── */ address public immutable MOOLAH; @@ -91,13 +89,13 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont * @param minToken0Amt Slippage guard passed to V3Provider.redeemShares. * @param minToken1Amt Slippage guard passed to V3Provider.redeemShares. * @param swapToken0 Swap TOKEN0 → loanToken after redemption. - * @param swapToken1 Swap TOKEN1 / native BNB → loanToken after redemption. + * @param 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 / BNB swap. + * @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 / BNB swap. + * @param swapToken1Data Calldata for TOKEN1 / native coin swap. */ struct V3LiquidateData { address v3Provider; @@ -126,10 +124,10 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont * @param redeemShares Redeem V3 shares in callback? If false, contract holds shares. * @param token0Pair DEX pair for TOKEN0 → loanToken swap. address(0) = no swap. * @param token0Spender Approval target for TOKEN0; if address(0), uses token0Pair. - * @param token1Pair DEX pair for TOKEN1 / BNB → loanToken swap. address(0) = no swap. + * @param 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 / BNB swap. + * @param swapToken1Data Aggregator calldata for TOKEN1 / native coin swap. */ struct FlashLiquidateParams { address v3Provider; @@ -249,9 +247,9 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont /** * @notice Flash liquidation: Moolah delivers seized V3 shares to this contract inside * the onMoolahLiquidate callback. The callback optionally: - * 1. Redeems V3 shares → TOKEN0 + TOKEN1 (TOKEN1 arrives as native BNB if WBNB). + * 1. Redeems V3 shares → TOKEN0 + TOKEN1 (the wrapped-native leg arrives as the native coin). * 2. Swaps TOKEN0 → loanToken. - * 3. Swaps TOKEN1 / BNB → 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 @@ -308,12 +306,12 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont /** * @notice Redeem V3 shares held by this contract. - * TOKEN1 arrives as native BNB if the V3Provider pool contains WBNB. + * 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 / BNB to receive (slippage guard). - * @param receiver Recipient of TOKEN0 and TOKEN1 / BNB. + * @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, @@ -398,8 +396,12 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont 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 as ERC-20, TOKEN1 as ERC-20 or native BNB (if WBNB). + // 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, @@ -409,24 +411,20 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont // Swap TOKEN0 → loanToken (skip if already loanToken or no swap requested). if (d.swapToken0 && amount0 > 0 && token0 != d.loanToken) { - token0.safeApprove(d.token0Spender, amount0); - (bool ok, ) = d.token0Pair.call(d.swapToken0Data); - require(ok, SwapFailed()); - token0.safeApprove(d.token0Spender, 0); + _swapRedeemedLeg(token0 == wrappedNative, d.token0Pair, d.token0Spender, token0, amount0, d.swapToken0Data); } - // Swap TOKEN1 / native BNB → loanToken. - // V3Provider always unwraps WBNB to native BNB, so use call{value} for WBNB pools. + // Swap TOKEN1 → loanToken. if (d.swapToken1 && amount1 > 0 && token1 != d.loanToken) { - if (token1 == WBNB) { - (bool ok, ) = d.token1Pair.call{ value: amount1 }(d.swapToken1Data); - require(ok, SwapFailed()); - } else { - token1.safeApprove(d.token1Spender, amount1); - (bool ok, ) = d.token1Pair.call(d.swapToken1Data); - require(ok, SwapFailed()); - token1.safeApprove(d.token1Spender, 0); - } + _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(); @@ -438,6 +436,29 @@ contract V3Liquidator is ReentrancyGuardUpgradeable, UUPSUpgradeable, AccessCont /* ─────────────────────────── 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, diff --git a/src/provider/SlisBNBV3DexAdapter.sol b/src/provider/SlisBNBV3DexAdapter.sol deleted file mode 100644 index b826fd01..00000000 --- a/src/provider/SlisBNBV3DexAdapter.sol +++ /dev/null @@ -1,287 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.34; - -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; -import { FullMath } from "lista-dao-contracts/oracle/libraries/FullMath.sol"; -import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; -import { IListaV3Pool } from "lista-v3/core/interfaces/IListaV3Pool.sol"; - -import { V3DexAdapter } from "./V3DexAdapter.sol"; -import { IV3PoolMinimal } from "./interfaces/IV3PoolMinimal.sol"; -import { IStakeManager } from "./interfaces/IStakeManager.sol"; -import { IV3DexAdapter } from "./interfaces/IV3DexAdapter.sol"; -import { ISlisBNBV3DexAdapter } from "./interfaces/ISlisBNBV3DexAdapter.sol"; -import { V3PositionLib } from "./libraries/V3PositionLib.sol"; -import { SlisBnbInventoryLib } from "./libraries/SlisBnbInventoryLib.sol"; - -/** - * @title SlisBNBV3DexAdapter - * @author Lista DAO - * @notice slisBNB/BNB specialization of {V3DexAdapter}. Adds: - * - exchange-rate-implied fair price (StakeManager rate, not pool spot/TWAP); - * - exchange-rate ±1% auto-centered tick range derivation; - * - rate-centered `rebalance` with a rate-drift guard + StakeManager inventory conversion. - */ -contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { - /* ─────────────────────────── constants ──────────────────────────── */ - - address public constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; - IStakeManager public constant STAKE_MANAGER = IStakeManager(0x1adB950d8bB3dA4bE104211D5AB038628e477fE6); - - uint256 internal constant BPS = 10_000; - uint256 internal constant INITIAL_RANGE_BPS = 100; // ±1% - int24 internal constant FALLBACK_HALF_RANGE_TICKS = 500; - - /* ──────────────────────────── storage ───────────────────────────── */ - - /// @dev Exchange rate at the last successful center/init; used as the range center. - uint256 public lastCenterRate; - - /// @dev Min relative exchange-rate drift from lastCenterRate before rebalance is allowed (BPS; 0 = off). - uint256 public centerRateThresholdBps; - - /* ───────────────────────────── events ───────────────────────────── */ - - event CenterRateThresholdChanged(uint256 centerRateThresholdBps); - event LastCenterRateUpdated(uint256 oldCenterRate, uint256 newCenterRate); - event Rebalanced(int24 oldTickLower, int24 oldTickUpper, int24 newTickLower, int24 newTickUpper, uint256 newTokenId); - - /* ───────────────────────────── errors ───────────────────────────── */ - - error DeadlineExpired(); - error InsufficientLiquidityMinted(); - error RateDeviationBelowThreshold(); - error InvalidThreshold(); - 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) { - // slisBNB/BNB-ONLY: the rate-implied fair price, ±1% tick centering and StakeManager inventory - // conversion all 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). - */ - function initialize(address _admin, address _manager) external initializer { - uint256 initialCenterRate; - if (_isSlisBnbWbnbPool()) initialCenterRate = _poolPriceRate(); - (int24 initialTickLower, int24 initialTickUpper) = _initialTickRange(initialCenterRate); - __V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper); - lastCenterRate = initialCenterRate; - centerRateThresholdBps = INITIAL_RANGE_BPS; - } - - /* ───────────────────────── view overrides ───────────────────────── */ - - /// @dev Fair price = exchange-rate-implied (manipulation-resistant). Falls back to TWAP for any - /// non-slisBNB/WBNB pair. - function fairSqrtPriceX96() public view override(V3DexAdapter, IV3DexAdapter) returns (uint160) { - if (!_isSlisBnbWbnbPool()) return super.fairSqrtPriceX96(); - return _sqrtPriceX96FromRate(_poolPriceRate()); - } - - /* ─────────────────────── manager / rebalance ────────────────────── */ - - /// @notice Set min exchange-rate drift from lastCenterRate required for rebalance (0 = off). - function setCenterRateThresholdBps(uint256 _centerRateThresholdBps) external onlyRole(MANAGER) { - if (_centerRateThresholdBps > BPS) revert InvalidThreshold(); - centerRateThresholdBps = _centerRateThresholdBps; - emit CenterRateThresholdChanged(_centerRateThresholdBps); - } - - /// @inheritdoc ISlisBNBV3DexAdapter - function rebalance( - uint256 minAmount0, - uint256 minAmount1, - uint256 minLiquidity, - uint256 deadline - ) external onlyProvider nonReentrant { - if (block.timestamp > deadline) revert DeadlineExpired(); - - uint256 centerRate; - bool isSlisPool = _isSlisBnbWbnbPool(); - if (isSlisPool) { - centerRate = _poolPriceRate(); - _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) = _rebalanceInventoryToOptimalRatio(total0, total1, newTickLower, newTickUpper, centerRate); - - 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 (isSlisPool) { - uint256 oldCenterRate = lastCenterRate; - lastCenterRate = centerRate; - emit LastCenterRateUpdated(oldCenterRate, centerRate); - } - - emit Rebalanced(oldTickLower, oldTickUpper, newTickLower, newTickUpper, tokenId); - } - - /// @dev Accept native BNB from WBNB unwrap or StakeManager instantWithdraw. - receive() external payable override { - if (!(msg.sender == WBNB || msg.sender == address(STAKE_MANAGER))) revert NotWBNB(); - } - - /* ─────────────────────────── internals ──────────────────────────── */ - - function _initialTickRange( - uint256 centerRate - ) internal view returns (int24 initialTickLower, int24 initialTickUpper) { - int24 tickSpacing = IListaV3Pool(POOL).tickSpacing(); - - if (_isSlisBnbWbnbPool()) { - (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 pure 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 _rebalanceInventoryToOptimalRatio( - uint256 total0, - uint256 total1, - int24 targetTickLower, - int24 targetTickUpper, - uint256 centerRate - ) internal returns (uint256, uint256) { - if (!_isSlisBnbWbnbPool()) return (total0, total1); - return - SlisBnbInventoryLib.convertToOptimalRatio( - STAKE_MANAGER, - SLISBNB, - WBNB, - TOKEN0, - TOKEN1, - total0, - total1, - _sqrtPriceX96FromRate(centerRate), - targetTickLower, - targetTickUpper, - centerRate - ); - } - - 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(); - } - - 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); - } - - function _sqrtPriceX96FromRate(uint256 rate) internal pure returns (uint160) { - return uint160(Math.sqrt(FullMath.mulDiv(rate, 1 << 192, 1e18))); - } - - 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; - } -} diff --git a/src/provider/interfaces/ISlisBNBV3DexAdapter.sol b/src/provider/interfaces/ISlisBNBV3DexAdapter.sol index 2e756ccd..0b8a53fc 100644 --- a/src/provider/interfaces/ISlisBNBV3DexAdapter.sol +++ b/src/provider/interfaces/ISlisBNBV3DexAdapter.sol @@ -5,17 +5,8 @@ import { IV3DexAdapter } from "./IV3DexAdapter.sol"; /** * @title ISlisBNBV3DexAdapter - * @notice slisBNB/BNB adapter surface consumed by SlisBNBV3Provider: the rate-centered rebalance - * (forwarded from the provider's BOT-gated call) plus the rate-drift state/config. + * @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 { - function lastCenterRate() external view returns (uint256); - - function centerRateThresholdBps() external view returns (uint256); - - function setCenterRateThresholdBps(uint256 centerRateThresholdBps) external; - - /// @notice Recenter to the exchange-rate-derived 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) external; -} +interface ISlisBNBV3DexAdapter is IV3DexAdapter {} diff --git a/src/provider/interfaces/IV3DexAdapter.sol b/src/provider/interfaces/IV3DexAdapter.sol index 3862aa5b..09387643 100644 --- a/src/provider/interfaces/IV3DexAdapter.sol +++ b/src/provider/interfaces/IV3DexAdapter.sol @@ -30,6 +30,9 @@ interface IV3DexAdapter { 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); @@ -107,4 +110,25 @@ interface IV3DexAdapter { /// @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 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/IV3Provider.sol b/src/provider/interfaces/IV3Provider.sol index d8b3a729..b143bcb0 100644 --- a/src/provider/interfaces/IV3Provider.sol +++ b/src/provider/interfaces/IV3Provider.sol @@ -15,6 +15,11 @@ interface IV3Provider is IProvider { 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); 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..2330c5ed --- /dev/null +++ b/src/provider/libraries/SwapInventoryLib.sol @@ -0,0 +1,74 @@ +// 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"; + +/** + * @title SwapInventoryLib + * @author Lista DAO + * @notice DEX-agnostic inventory-conversion swap used by ETH LST V3 LP adapters (e.g. + * {WstETHV3DexAdapter}) during rebalance. Mirrors {Liquidator}'s aggregator pattern: the (BOT) + * backend builds `swapData` for ANY whitelisted venue (1inch / 0x / Uniswap / …); the adapter + * just forwards it via a low-level `swapPair.call(swapData)` and enforces the result with + * MEASURED balance deltas — `spent <= amountIn` and `received >= amountOutMin`. No on-chain + * routing or price math. + * + * @dev Invoked via DELEGATECALL, so `address(this)` is the adapter: token custody and allowances + * 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` / `swapData` + * come from the backend. `amountIn` is capped to the available balance, and the 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(); + + /// @notice Execute one backend-built swap. `sellToken0` ⇒ sell token0 for token1, else token1 for + /// token0. Returns (total0, total1) adjusted by the MEASURED spent/received deltas. + function swap( + address swapPair, + address token0, + address token1, + bool sellToken0, + uint256 amountIn, + uint256 amountOutMin, + bytes memory swapData, + uint256 total0, + uint256 total1 + ) 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)); + + IERC20(tokenIn).forceApprove(swapPair, amountIn); + (bool ok, ) = swapPair.call(swapData); + if (!ok) revert SwapFailed(); + IERC20(tokenIn).forceApprove(swapPair, 0); // clear any residual allowance + + 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/v3/SlisBNBV3DexAdapter.sol b/src/provider/v3/SlisBNBV3DexAdapter.sol new file mode 100644 index 00000000..5a80652e --- /dev/null +++ b/src/provider/v3/SlisBNBV3DexAdapter.sol @@ -0,0 +1,106 @@ +// 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"; +import { SlisBnbInventoryLib } from "../libraries/SlisBnbInventoryLib.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 and the rebalance skeleton; this subclass supplies only the + * slisBNB-specific hooks: + * - _lstNativeRate(): StakeManager slisBNB↔BNB rate (not pool spot/TWAP); + * - _convertToOptimalRatio(): StakeManager stake / instantWithdraw inventory conversion; + * - receive(): also accept native BNB from StakeManager.instantWithdraw. + */ +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, ±1% tick centering and StakeManager inventory + // conversion all 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). + */ + 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; + } + + /// @dev Convert pooled inventory to the optimal ratio via StakeManager stake (WBNB→slisBNB) / + /// instantWithdraw (slisBNB→BNB→WBNB). + function _convertToOptimalRatio( + uint256 total0, + uint256 total1, + int24 targetTickLower, + int24 targetTickUpper, + uint256 rate, + bytes calldata /* swapData */ + ) internal override returns (uint256, uint256) { + if (!_isSlisBnbWbnbPool()) return (total0, total1); + return + SlisBnbInventoryLib.convertToOptimalRatio( + STAKE_MANAGER, + SLISBNB, + WBNB, + TOKEN0, + TOKEN1, + total0, + total1, + _sqrtPriceX96FromRate(rate), + targetTickLower, + targetTickUpper, + rate + ); + } + + /// @dev Accept native BNB from WBNB unwrap or StakeManager instantWithdraw. + receive() external payable override { + if (!(msg.sender == WRAPPED_NATIVE || msg.sender == address(STAKE_MANAGER))) revert NotWrappedNative(); + } + + /* ─────────────────────────── 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/SlisBNBV3Provider.sol b/src/provider/v3/SlisBNBV3Provider.sol similarity index 94% rename from src/provider/SlisBNBV3Provider.sol rename to src/provider/v3/SlisBNBV3Provider.sol index b94e3ffc..4ff46825 100644 --- a/src/provider/SlisBNBV3Provider.sol +++ b/src/provider/v3/SlisBNBV3Provider.sol @@ -5,9 +5,9 @@ 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"; +import { IV3DexAdapter } from "../interfaces/IV3DexAdapter.sol"; +import { ISlisBNBV3DexAdapter } from "../interfaces/ISlisBNBV3DexAdapter.sol"; +import { ISlisBNBxMinter } from "../../utils/interfaces/ISlisBNBx.sol"; /** * @title SlisBNBV3Provider @@ -59,7 +59,8 @@ contract SlisBNBV3Provider is V3Provider { uint256 minLiquidity, uint256 deadline ) external onlyRole(BOT) nonReentrant { - ISlisBNBV3DexAdapter(ADAPTER).rebalance(minAmount0, minAmount1, minLiquidity, deadline); + // slisBNB converts inventory on-chain via the StakeManager — no DEX swapData needed. + ISlisBNBV3DexAdapter(ADAPTER).rebalance(minAmount0, minAmount1, minLiquidity, deadline, ""); } /* ─────────────────── slisBNBx: sync / view ──────────────────────── */ 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/V3DexAdapter.sol b/src/provider/v3/V3DexAdapter.sol similarity index 57% rename from src/provider/V3DexAdapter.sol rename to src/provider/v3/V3DexAdapter.sol index 47b0cb3e..99ef50fb 100644 --- a/src/provider/V3DexAdapter.sol +++ b/src/provider/v3/V3DexAdapter.sol @@ -9,14 +9,17 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I 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 { INonfungiblePositionManager } from "../interfaces/INonfungiblePositionManager.sol"; +import { V3PositionLib } from "../libraries/V3PositionLib.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 { IV3PoolMinimal } from "./interfaces/IV3PoolMinimal.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 @@ -31,7 +34,7 @@ import { IV3PoolMinimal } from "./interfaces/IV3PoolMinimal.sol"; * * Extension points (slisBNB/BNB subclass overrides): * - fairSqrtPriceX96(): exchange-rate-implied price instead of pool TWAP. - * - receive(): widen accepted native-BNB senders (StakeManager instantWithdraw). + * - receive(): widen accepted wrapped-native unwrap senders (e.g. StakeManager instantWithdraw). * - rebalance(): added by the subclass (rate-centered recenter + inventory conversion). */ abstract contract V3DexAdapter is @@ -53,11 +56,18 @@ abstract contract V3DexAdapter is uint8 public immutable DECIMALS0; uint8 public immutable DECIMALS1; - /// @dev BSC wrapped native token. - address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + /// @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. @@ -74,8 +84,15 @@ abstract contract V3DexAdapter is 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 Reserved storage for future base variables (keep subclass storage stable on upgrade). - uint256[50] private __gap; + uint256[48] private __gap; /* ───────────────────────────── events ───────────────────────────── */ @@ -83,6 +100,9 @@ abstract contract V3DexAdapter is 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); /* ───────────────────────────── errors ───────────────────────────── */ @@ -94,15 +114,28 @@ abstract contract V3DexAdapter is error InvalidTickRange(); error OnlyProvider(); error ProviderAlreadySet(); + error ProviderAdapterMismatch(); error BnbTransferFailed(); - error NotWBNB(); + error NotWrappedNative(); + error DeadlineExpired(); + error InsufficientLiquidityMinted(); + error RateDeviationBelowThreshold(); + error InvalidThreshold(); /* ─────────────────────────── constructor ────────────────────────── */ /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address _positionManager, address _token0, address _token1, uint24 _fee, uint32 _twapPeriod) { + 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(); @@ -120,6 +153,7 @@ abstract contract V3DexAdapter is FEE = _fee; POOL = _pool; TWAP_PERIOD = _twapPeriod; + WRAPPED_NATIVE = _wrappedNative; DECIMALS0 = IERC20Metadata(_token0).decimals(); DECIMALS1 = IERC20Metadata(_token1).decimals(); @@ -148,9 +182,13 @@ abstract contract V3DexAdapter is } /// @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); } @@ -196,7 +234,7 @@ abstract contract V3DexAdapter is ); } - // Refund unused input (ratio mismatch) to the depositor. WBNB is unwrapped to native BNB. + // 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)); @@ -246,6 +284,96 @@ abstract contract V3DexAdapter is _collectAndCompound(); } + /* ─────────────────────── manager / rebalance ────────────────────── */ + + /// @inheritdoc IV3DexAdapter + function setCenterRateThresholdBps(uint256 _centerRateThresholdBps) external onlyRole(MANAGER) { + if (_centerRateThresholdBps > BPS) revert InvalidThreshold(); + centerRateThresholdBps = _centerRateThresholdBps; + emit CenterRateThresholdChanged(_centerRateThresholdBps); + } + + /// @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 @@ -287,8 +415,12 @@ abstract contract V3DexAdapter is } /// @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) { - return TickMath.getSqrtRatioAtTick(_twapTick()); + uint256 rate = _lstNativeRate(); + if (rate == 0) return TickMath.getSqrtRatioAtTick(_twapTick()); + return _sqrtPriceX96FromRate(rate); } /// @inheritdoc IV3DexAdapter @@ -380,10 +512,10 @@ abstract contract V3DexAdapter is if (delta < 0 && (delta % int56(uint56(TWAP_PERIOD)) != 0)) twapTick--; } - /// @dev Send `token` to `to`, unwrapping WBNB to native BNB. + /// @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 == WBNB) { - IWBNB(WBNB).withdraw(amount); + if (token == WRAPPED_NATIVE) { + IWBNB(WRAPPED_NATIVE).withdraw(amount); (bool ok, ) = to.call{ value: amount }(""); if (!ok) revert BnbTransferFailed(); } else { @@ -391,9 +523,102 @@ abstract contract V3DexAdapter is } } - /// @dev Accepts native BNB from WBNB unwrap. Subclasses widen the allowed senders. + /// @dev Accepts native coin from the wrapped-native unwrap. Subclasses widen the allowed senders. receive() external payable virtual { - if (msg.sender != WBNB) revert NotWBNB(); + if (msg.sender != WRAPPED_NATIVE) 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 pure 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(); + } + + function _sqrtPriceX96FromRate(uint256 rate) internal pure returns (uint160) { + return uint160(Math.sqrt(FullMath.mulDiv(rate, 1 << 192, 1e18))); + } + + 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 Convert the position's pooled inventory toward the optimal ratio for the target range. + /// Base no-op (TWAP pairs keep raw inventory). Rate-implied subclasses override: slisBNB via + /// StakeManager stake/redeem, wstETH via a router swap with rate-anchored minOut. + function _convertToOptimalRatio( + uint256 total0, + uint256 total1, + int24 /* targetTickLower */, + int24 /* targetTickUpper */, + uint256 /* rate */, + bytes calldata /* swapData */ + ) internal virtual returns (uint256, uint256) { + return (total0, total1); } function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} diff --git a/src/provider/V3Provider.sol b/src/provider/v3/V3Provider.sol similarity index 94% rename from src/provider/V3Provider.sol rename to src/provider/v3/V3Provider.sol index b6873822..6a916ea2 100644 --- a/src/provider/V3Provider.sol +++ b/src/provider/v3/V3Provider.sol @@ -14,9 +14,9 @@ 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"; +import { IWBNB } from "../interfaces/IWBNB.sol"; +import { IV3Provider } from "../interfaces/IV3Provider.sol"; +import { IV3DexAdapter } from "../interfaces/IV3DexAdapter.sol"; /** * @title V3Provider @@ -62,8 +62,9 @@ abstract contract V3Provider is uint8 public immutable DECIMALS0; uint8 public immutable DECIMALS1; - /// @dev BSC wrapped native token. BNB sent on deposit is wrapped to WBNB before forwarding. - address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + /// @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"); @@ -104,7 +105,7 @@ abstract contract V3Provider is error ZeroAddress(); error InvalidCollateralToken(); - error PoolHasNoWBNB(); + error PoolHasNoWrappedNative(); error ZeroAmounts(); error ZeroShares(); error Unauthorized(); @@ -126,6 +127,7 @@ abstract contract V3Provider is TOKEN1 = IV3DexAdapter(_adapter).TOKEN1(); DECIMALS0 = IV3DexAdapter(_adapter).DECIMALS0(); DECIMALS1 = IV3DexAdapter(_adapter).DECIMALS1(); + WRAPPED_NATIVE = IV3DexAdapter(_adapter).WRAPPED_NATIVE(); _disableInitializers(); } @@ -200,24 +202,24 @@ abstract contract V3Provider is uint256 _amount0Desired = amount0Desired; uint256 _amount1Desired = amount1Desired; - // Wrap any native BNB into WBNB and use it for the WBNB leg. + // Wrap any native coin into the wrapped-native token and use it for that leg. if (msg.value > 0) { - if (!(TOKEN0 == WBNB || TOKEN1 == WBNB)) revert PoolHasNoWBNB(); - if (TOKEN0 == WBNB) { + if (!(TOKEN0 == WRAPPED_NATIVE || TOKEN1 == WRAPPED_NATIVE)) revert PoolHasNoWrappedNative(); + if (TOKEN0 == WRAPPED_NATIVE) { _amount0Desired = msg.value; } else { _amount1Desired = msg.value; } - IWBNB(WBNB).deposit{ value: 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 == WBNB && msg.value > 0)) { + if (_amount0Desired > 0 && !(TOKEN0 == WRAPPED_NATIVE && msg.value > 0)) { IERC20(TOKEN0).safeTransferFrom(msg.sender, address(this), _amount0Desired); } - if (_amount1Desired > 0 && !(TOKEN1 == WBNB && msg.value > 0)) { + if (_amount1Desired > 0 && !(TOKEN1 == WRAPPED_NATIVE && msg.value > 0)) { IERC20(TOKEN1).safeTransferFrom(msg.sender, address(this), _amount1Desired); } @@ -452,10 +454,10 @@ abstract contract V3Provider is return msg.sender == onBehalf || MOOLAH.isAuthorized(onBehalf, msg.sender); } - /// @dev Forward a deposit refund to the depositor. WBNB is already unwrapped to native BNB by the - /// adapter when it refunds to this vault, so the WBNB leg is paid out as native BNB. + /// @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 == WBNB) { + if (token == WRAPPED_NATIVE) { (bool ok, ) = payable(to).call{ value: amount }(""); if (!ok) revert BnbTransferFailed(); } else { diff --git a/src/provider/SlisBNBV3ProviderOracle.sol b/src/provider/v3/V3ProviderOracle.sol similarity index 67% rename from src/provider/SlisBNBV3ProviderOracle.sol rename to src/provider/v3/V3ProviderOracle.sol index a5143ef0..30b83f2f 100644 --- a/src/provider/SlisBNBV3ProviderOracle.sol +++ b/src/provider/v3/V3ProviderOracle.sol @@ -7,23 +7,26 @@ 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 { IV3ProviderOracle } from "./interfaces/IV3ProviderOracle.sol"; +import { IV3DexAdapter } from "../interfaces/IV3DexAdapter.sol"; +import { IV3Provider } from "../interfaces/IV3Provider.sol"; +import { IV3ProviderOracle } from "../interfaces/IV3ProviderOracle.sol"; /** - * @title SlisBNBV3ProviderOracle + * @title V3ProviderOracle * @author Lista DAO - * @notice Standalone IOracle for the slisBNB/BNB vLP share token (Moolah `market.oracle` points here). - * Prices the share off the DEX adapter's FAIR composition view (staticcall, no double-hop - * through the vault) — for slisBNB/BNB that fair price is exchange-rate-implied (StakeManager - * rate, not pool spot/TWAP) — pricing each leg via the resilient oracle, then applying a - * conservative haircut. Separating pricing from the vault isolates the estimation-bug radius - * from vault state. + * @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 SlisBNBV3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgradeable, IV3ProviderOracle { +contract V3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgradeable, IV3ProviderOracle { /* ─────────────────────────── immutables ─────────────────────────── */ /// @inheritdoc IV3ProviderOracle @@ -36,10 +39,6 @@ contract SlisBNBV3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgr uint8 public immutable DECIMALS0; uint8 public immutable DECIMALS1; - /// @dev slisBNB/BNB-only pair (token0 < token1; slisBNB < WBNB). - address public constant SLISBNB = 0xB0b84D294e0C75A6abe60171b70edEb2EFd14A1B; - address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; - bytes32 public constant MANAGER = keccak256("MANAGER"); uint256 internal constant BPS = 10_000; /// @dev Hard cap on the configurable haircut (10%). @@ -62,18 +61,20 @@ contract SlisBNBV3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgr error ZeroAddress(); error InvalidHaircut(); error ZeroPrice(); - error NotSlisBnbWbnbPair(); 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(); - // slisBNB/BNB-ONLY: reject any other pair, and require the tokens (and their order) match the - // adapter's, so peek() prices exactly the composition the adapter reports. - if (!(_token0 == SLISBNB && _token1 == WBNB)) revert NotSlisBnbWbnbPair(); + // 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; @@ -111,12 +112,23 @@ contract SlisBNBV3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgr uint256 supply = IERC20(PROVIDER_SHARE).totalSupply(); if (supply == 0) return 0; // pre-market - // Fair composition from the adapter (exchange-rate-implied; not pool spot/TWAP). + // 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() ); - uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8 decimals + // 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 diff --git a/src/provider/v3/WbETHV3DexAdapter.sol b/src/provider/v3/WbETHV3DexAdapter.sol new file mode 100644 index 00000000..f72937e0 --- /dev/null +++ b/src/provider/v3/WbETHV3DexAdapter.sol @@ -0,0 +1,149 @@ +// 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"; +import { SwapInventoryLib } from "../libraries/SwapInventoryLib.sol"; + +/** + * @title WbETHV3DexAdapter + * @author Lista DAO + * @notice wbETH/WETH specialization of {V3DexAdapter} for Ethereum — mechanism identical to + * {WstETHV3DexAdapter}, only the rate source + pair differ. Supplies the LST-specific hooks: + * - _lstNativeRate(): Binance `wbETH.exchangeRate()` (ETH per wbETH ⇒ WETH-per-wbETH); + * - fairSqrtPriceX96(): valuation price = pool TWAP CLAMPED to the rate; + * - _convertToOptimalRatio(): DEX-agnostic, backend-built rebalance swap (à la {Liquidator}). + * The BOT backend supplies (swapPair, sellToken0, amountIn, amountOutMin, innerSwapData); + * the adapter only allows a whitelisted `swapPair` and bounds the swap by the allowance + + * the backend's `amountOutMin` (enforced on the measured output). + * `receive()` is inherited: it accepts native ETH only from the WETH unwrap (no StakeManager). + */ +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 Whitelisted swap venues for rebalance conversions (any DEX / aggregator). The backend builds + /// the calldata; the adapter only allows whitelisted targets — like {Liquidator}'s pairWhitelist. + mapping(address => bool) public swapPairWhitelist; + + /// @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 SwapPairWhitelistSet(address indexed swapPair, bool status); + event MaxTwapDeviationChanged(uint256 maxTwapDeviationBps); + + error NotWbEthWethPair(); + error InvalidDeviation(); + error NotWhitelistedPair(); + error InvalidSwapPair(); + + /// @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, ±1% tick centering and the swap-based inventory + // conversion all 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 Whitelist (or remove) a swap venue the rebalance may call. Backend-built calldata can only + /// target whitelisted venues. + function setSwapPairWhitelist(address swapPair, bool status) external onlyRole(MANAGER) { + if (swapPair == address(0)) revert ZeroAddress(); + // 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. + if ( + status && (swapPair == TOKEN0 || swapPair == TOKEN1 || swapPair == POOL || swapPair == address(POSITION_MANAGER)) + ) revert InvalidSwapPair(); + swapPairWhitelist[swapPair] = status; + emit SwapPairWhitelistSet(swapPair, status); + } + + /// @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; + } + + /// @dev DEX-agnostic, backend-built rebalance conversion. `swapData` (when non-empty) ABI-encodes + /// (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes innerSwapData): + /// the adapter requires `swapPair` whitelisted and forwards `innerSwapData` via a low-level call, + /// bounding the swap by the allowance + `amountOutMin` (see {SwapInventoryLib}). Empty swapData ⇒ + /// recenter without converting inventory. + function _convertToOptimalRatio( + uint256 total0, + uint256 total1, + int24 /* targetTickLower */, + int24 /* targetTickUpper */, + uint256 /* rate */, + bytes calldata swapData + ) internal override returns (uint256, uint256) { + if (swapData.length == 0) return (total0, total1); + (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes memory inner) = abi.decode( + swapData, + (address, bool, uint256, uint256, bytes) + ); + if (!swapPairWhitelist[swapPair]) revert NotWhitelistedPair(); + return SwapInventoryLib.swap(swapPair, TOKEN0, TOKEN1, sellToken0, amountIn, amountOutMin, inner, total0, total1); + } +} 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..7328598a --- /dev/null +++ b/src/provider/v3/WstETHV3DexAdapter.sol @@ -0,0 +1,157 @@ +// 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"; +import { SwapInventoryLib } from "../libraries/SwapInventoryLib.sol"; + +/** + * @title WstETHV3DexAdapter + * @author Lista DAO + * @notice wstETH/WETH specialization of {V3DexAdapter} for Ethereum. Supplies the LST-specific hooks: + * - _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; + * - _convertToOptimalRatio(): DEX-agnostic, backend-built rebalance swap (à la {Liquidator}). + * The BOT backend supplies (swapPair, sellToken0, amountIn, amountOutMin, innerSwapData); + * the adapter only allows a whitelisted `swapPair` and bounds the swap by the allowance + + * the backend's `amountOutMin` (enforced on the measured output). No on-chain instant + * LST→ETH redeem exists, so inventory is converted by a market swap. + * `receive()` is inherited: it accepts native ETH only from the WETH unwrap (no StakeManager). + */ +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 Whitelisted swap venues for rebalance conversions (any DEX / aggregator). The backend builds + /// the calldata; the adapter only allows whitelisted targets — like {Liquidator}'s pairWhitelist. + mapping(address => bool) public swapPairWhitelist; + + /// @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 SwapPairWhitelistSet(address indexed swapPair, bool status); + event MaxTwapDeviationChanged(uint256 maxTwapDeviationBps); + + error NotWstEthWethPair(); + error InvalidDeviation(); + error NotWhitelistedPair(); + error InvalidSwapPair(); + + /// @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, ±1% tick centering and the swap-based inventory + // conversion all 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 Whitelist (or remove) a swap venue the rebalance may call. Backend-built calldata can only + /// target whitelisted venues. + function setSwapPairWhitelist(address swapPair, bool status) external onlyRole(MANAGER) { + if (swapPair == address(0)) revert ZeroAddress(); + // 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. + if ( + status && (swapPair == TOKEN0 || swapPair == TOKEN1 || swapPair == POOL || swapPair == address(POSITION_MANAGER)) + ) revert InvalidSwapPair(); + swapPairWhitelist[swapPair] = status; + emit SwapPairWhitelistSet(swapPair, status); + } + + /// @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; + } + + /// @dev DEX-agnostic, backend-built rebalance conversion. `swapData` (when non-empty) ABI-encodes + /// (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes innerSwapData): + /// the adapter requires `swapPair` whitelisted and forwards `innerSwapData` via a low-level call, + /// bounding the swap by the allowance + `amountOutMin` (see {SwapInventoryLib}). Empty swapData ⇒ + /// recenter without converting inventory. + function _convertToOptimalRatio( + uint256 total0, + uint256 total1, + int24 /* targetTickLower */, + int24 /* targetTickUpper */, + uint256 /* rate */, + bytes calldata swapData + ) internal override returns (uint256, uint256) { + if (swapData.length == 0) return (total0, total1); + (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes memory inner) = abi.decode( + swapData, + (address, bool, uint256, uint256, bytes) + ); + if (!swapPairWhitelist[swapPair]) revert NotWhitelistedPair(); + return SwapInventoryLib.swap(swapPair, TOKEN0, TOKEN1, sellToken0, amountIn, amountOutMin, inner, total0, total1); + } +} 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 index 3c23cd45..61513ecb 100644 --- a/test/liquidator/V3Liquidator.t.sol +++ b/test/liquidator/V3Liquidator.t.sol @@ -6,9 +6,10 @@ 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/SlisBNBV3Provider.sol"; -import { SlisBNBV3DexAdapter } from "../../src/provider/SlisBNBV3DexAdapter.sol"; -import { SlisBNBV3ProviderOracle } from "../../src/provider/SlisBNBV3ProviderOracle.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"; @@ -136,7 +137,7 @@ contract V3LiquidatorTest is Test { payable( new ERC1967Proxy( address(oracleImpl), - abi.encodeCall(SlisBNBV3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + abi.encodeCall(V3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) ) ) ); 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/provider/SlisBNBV3Provider.t.sol b/test/provider/SlisBNBV3Provider.t.sol index 59e444d5..d9a783b0 100644 --- a/test/provider/SlisBNBV3Provider.t.sol +++ b/test/provider/SlisBNBV3Provider.t.sol @@ -7,12 +7,13 @@ 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/SlisBNBV3Provider.sol"; -import { SlisBNBV3DexAdapter } from "../../src/provider/SlisBNBV3DexAdapter.sol"; -import { SlisBNBV3ProviderOracle } from "../../src/provider/SlisBNBV3ProviderOracle.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/V3Provider.sol"; -import { V3DexAdapter } from "../../src/provider/V3DexAdapter.sol"; +import { V3Provider } from "../../src/provider/v3/V3Provider.sol"; +import { V3DexAdapter } from "../../src/provider/v3/V3DexAdapter.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"; @@ -221,7 +222,7 @@ contract SlisBNBV3ProviderTest is Test { payable( new ERC1967Proxy( address(oracleImpl), - abi.encodeCall(SlisBNBV3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + abi.encodeCall(V3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) ) ) ); diff --git a/test/provider/SlisBNBV3ProviderRate.t.sol b/test/provider/SlisBNBV3ProviderRate.t.sol index 14540095..9a56e745 100644 --- a/test/provider/SlisBNBV3ProviderRate.t.sol +++ b/test/provider/SlisBNBV3ProviderRate.t.sol @@ -6,9 +6,11 @@ import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SlisBNBV3Provider } from "../../src/provider/SlisBNBV3Provider.sol"; -import { SlisBNBV3DexAdapter } from "../../src/provider/SlisBNBV3DexAdapter.sol"; -import { SlisBNBV3ProviderOracle } from "../../src/provider/SlisBNBV3ProviderOracle.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"; @@ -212,7 +214,7 @@ contract SlisBNBV3ProviderRateTest is Test { payable( new ERC1967Proxy( address(oracleImpl), - abi.encodeCall(SlisBNBV3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) + abi.encodeCall(V3ProviderOracle.initialize, (admin, manager, address(oracle), uint256(0))) ) ) ); @@ -338,7 +340,7 @@ contract SlisBNBV3ProviderRateTest is Test { _deposit(10 ether, 10 ether); vm.prank(bot); - vm.expectRevert(SlisBNBV3DexAdapter.RateDeviationBelowThreshold.selector); + vm.expectRevert(V3DexAdapter.RateDeviationBelowThreshold.selector); provider.rebalance(0, 0, 0, block.timestamp); } @@ -346,7 +348,7 @@ contract SlisBNBV3ProviderRateTest is Test { _deposit(10 ether, 10 ether); vm.prank(bot); - vm.expectRevert(SlisBNBV3DexAdapter.DeadlineExpired.selector); + vm.expectRevert(V3DexAdapter.DeadlineExpired.selector); provider.rebalance(0, 0, 0, block.timestamp - 1); } @@ -357,7 +359,7 @@ contract SlisBNBV3ProviderRateTest is Test { adapter.setCenterRateThresholdBps(0); vm.prank(bot); - vm.expectRevert(SlisBNBV3DexAdapter.InsufficientLiquidityMinted.selector); + vm.expectRevert(V3DexAdapter.InsufficientLiquidityMinted.selector); provider.rebalance(0, 0, type(uint256).max, block.timestamp); } 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..ad0661f6 --- /dev/null +++ b/test/provider/WstETHV3Provider.t.sol @@ -0,0 +1,542 @@ +// 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, innerSwapData). 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, 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(WstETHV3DexAdapter.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(WstETHV3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(WSTETH, true); + vm.expectRevert(WstETHV3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(WETH, true); + vm.expectRevert(WstETHV3DexAdapter.InvalidSwapPair.selector); + adapter.setSwapPairWhitelist(POOL, true); + vm.expectRevert(WstETHV3DexAdapter.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); + } +} From 48dfeaad85c3d9ab6391994748d9d84782158692 Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:04:43 +0800 Subject: [PATCH 15/17] feat(provider): bound slisBNB instantWithdraw slippage in rebalance conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add maxInstantWithdrawSlippageBps to SlisBnbInventoryLib so the slisBNB→BNB instantWithdraw leg of a rebalance enforces a bound on the StakeManager instant-redeem fee/slippage, reverting InstantWithdrawSlippage when exceeded. Threaded through convertToOptimalRatio/convert/_convert; SlisBNBV3DexAdapter passes the configured bound. Adds tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../libraries/SlisBnbInventoryLib.sol | 54 +++++++++-- src/provider/v3/SlisBNBV3DexAdapter.sol | 32 ++++++- test/provider/SlisBNBV3Provider.t.sol | 92 +++++++++++++++++++ 3 files changed, 169 insertions(+), 9 deletions(-) diff --git a/src/provider/libraries/SlisBnbInventoryLib.sol b/src/provider/libraries/SlisBnbInventoryLib.sol index 507f15fe..3680ee09 100644 --- a/src/provider/libraries/SlisBnbInventoryLib.sol +++ b/src/provider/libraries/SlisBnbInventoryLib.sol @@ -26,9 +26,11 @@ library SlisBnbInventoryLib { uint128 internal constant RATIO_SAMPLE_LIQUIDITY = 1e18; uint256 internal constant RATE_SCALE = 1e18; + uint256 internal constant BPS = 10_000; error OneDirection(); error NotSlisBnbWbnbPool(); + error InstantWithdrawSlippage(); /// @notice Convert the over-weight leg so free inventory matches the target range's optimal /// token0/token1 injection ratio at the exchange-rate-implied price. @@ -43,7 +45,8 @@ library SlisBnbInventoryLib { uint160 exchangeRateSqrtPriceX96, int24 targetTickLower, int24 targetTickUpper, - uint256 token1PerToken0Rate + uint256 token1PerToken0Rate, + uint256 maxInstantWithdrawSlippageBps ) external returns (uint256, uint256) { if (total0 == 0 && total1 == 0) return (total0, total1); if (token1PerToken0Rate == 0) return (total0, total1); @@ -78,15 +81,28 @@ library SlisBnbInventoryLib { token1ToToken0 ); - return _convert(stakeManager, slisBnb, wbnb, token0, token1, total0, total1, bnbToStake, slisBnbToRedeem); + return + _convert( + stakeManager, + slisBnb, + wbnb, + token0, + token1, + total0, + total1, + bnbToStake, + slisBnbToRedeem, + maxInstantWithdrawSlippageBps + ); } /// @notice Convert free inventory between the WBNB and slisBNB legs and return adjusted totals. /// - `bnbToStake`: unwrap that much WBNB and stake it (deposit) into slisBNB. /// - `slisBnbToRedeem`: instant-redeem that much slisBNB into BNB, re-wrapped to WBNB. /// Goes through the StakeManager at its on-chain exchange rate (not the pool), so it is - /// not market-manipulable; instantWithdraw deducts a deterministic fee. Amounts moved are - /// measured by balance delta so the returned totals stay exact, and capped to availability. + /// not market-manipulable; instantWithdraw deducts a deterministic fee bounded by + /// `maxInstantWithdrawSlippageBps`. Amounts moved are measured by balance delta so the + /// returned totals stay exact, and capped to availability. function convert( IStakeManager stakeManager, address slisBnb, @@ -96,9 +112,22 @@ library SlisBnbInventoryLib { uint256 total0, uint256 total1, uint256 bnbToStake, - uint256 slisBnbToRedeem + uint256 slisBnbToRedeem, + uint256 maxInstantWithdrawSlippageBps ) external returns (uint256, uint256) { - return _convert(stakeManager, slisBnb, wbnb, token0, token1, total0, total1, bnbToStake, slisBnbToRedeem); + return + _convert( + stakeManager, + slisBnb, + wbnb, + token0, + token1, + total0, + total1, + bnbToStake, + slisBnbToRedeem, + maxInstantWithdrawSlippageBps + ); } function _convert( @@ -110,7 +139,8 @@ library SlisBnbInventoryLib { uint256 total0, uint256 total1, uint256 bnbToStake, - uint256 slisBnbToRedeem + uint256 slisBnbToRedeem, + uint256 maxInstantWithdrawSlippageBps ) private returns (uint256, uint256) { if (bnbToStake == 0 && slisBnbToRedeem == 0) return (total0, total1); if (bnbToStake > 0 && slisBnbToRedeem > 0) revert OneDirection(); @@ -139,10 +169,20 @@ library SlisBnbInventoryLib { uint256 slisAvail = wbnbIs0 ? total1 : total0; uint256 amt = slisBnbToRedeem > slisAvail ? slisAvail : slisBnbToRedeem; if (amt > 0) { + // Rate-anchored slippage floor: instantWithdraw must return at least the StakeManager + // exchange-rate value of `amt`, minus the configured tolerance. This bounds the deterministic + // instant-withdraw fee (and any rate anomaly). `convertSnBnbToBnb` is the on-chain accounting + // rate (not pool/market) so the floor is not manipulable. + uint256 minBnbOut = FullMath.mulDiv( + stakeManager.convertSnBnbToBnb(amt), + BPS - maxInstantWithdrawSlippageBps, + BPS + ); uint256 bBefore = address(this).balance; IERC20(slisBnb).safeIncreaseAllowance(address(stakeManager), amt); stakeManager.instantWithdraw(amt); uint256 bnbOut = address(this).balance - bBefore; + if (bnbOut < minBnbOut) revert InstantWithdrawSlippage(); if (bnbOut > 0) IWBNB(wbnb).deposit{ value: bnbOut }(); if (wbnbIs0) { total0 += bnbOut; diff --git a/src/provider/v3/SlisBNBV3DexAdapter.sol b/src/provider/v3/SlisBNBV3DexAdapter.sol index 5a80652e..d6f88c07 100644 --- a/src/provider/v3/SlisBNBV3DexAdapter.sol +++ b/src/provider/v3/SlisBNBV3DexAdapter.sol @@ -25,9 +25,24 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { /// @dev BSC wrapped native token (forwarded to the base as WRAPPED_NATIVE). address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + /// @dev Hard cap on the rebalance instant-withdraw slippage tolerance (10%). + uint256 public constant MAX_INSTANT_WITHDRAW_SLIPPAGE_BPS = 1_000; + + /* ──────────────────────────── storage ───────────────────────────── */ + + /// @dev Max tolerated shortfall (BPS) of the rebalance instantWithdraw BNB output vs the StakeManager + /// exchange-rate value of the redeemed slisBNB. Bounds the instant-withdraw fee / any rate + /// anomaly; the conversion reverts if the realized BNB falls below this rate-anchored floor. + uint256 public instantWithdrawSlippageBps; + + /* ───────────────────────────── events ───────────────────────────── */ + + event InstantWithdrawSlippageChanged(uint256 instantWithdrawSlippageBps); + /* ───────────────────────────── errors ───────────────────────────── */ error NotSlisBnbWbnbPair(); + error InvalidSlippage(); /// @custom:oz-upgrades-unsafe-allow constructor constructor( @@ -45,7 +60,7 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { /** * @param _admin Default admin (upgrade / roles). - * @param _manager Manager role (sets centerRateThresholdBps). + * @param _manager Manager role (sets centerRateThresholdBps + instantWithdrawSlippageBps). */ function initialize(address _admin, address _manager) external initializer { uint256 initialCenterRate = _lstNativeRate(); @@ -53,6 +68,18 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { __V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper); lastCenterRate = initialCenterRate; centerRateThresholdBps = INITIAL_RANGE_BPS; + instantWithdrawSlippageBps = 100; // default 1% tolerance; MANAGER tunes to the live fee + buffer + emit InstantWithdrawSlippageChanged(100); + } + + /* ───────────────────────── manager config ───────────────────────── */ + + /// @notice Set the rebalance instant-withdraw slippage tolerance (BPS). 0 ⇒ require the full + /// rate value (no fee tolerated). onlyRole MANAGER. + function setInstantWithdrawSlippageBps(uint256 _instantWithdrawSlippageBps) external onlyRole(MANAGER) { + if (_instantWithdrawSlippageBps > MAX_INSTANT_WITHDRAW_SLIPPAGE_BPS) revert InvalidSlippage(); + instantWithdrawSlippageBps = _instantWithdrawSlippageBps; + emit InstantWithdrawSlippageChanged(_instantWithdrawSlippageBps); } /* ───────────────────────── hook overrides ───────────────────────── */ @@ -85,7 +112,8 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { _sqrtPriceX96FromRate(rate), targetTickLower, targetTickUpper, - rate + rate, + instantWithdrawSlippageBps ); } diff --git a/test/provider/SlisBNBV3Provider.t.sol b/test/provider/SlisBNBV3Provider.t.sol index d9a783b0..ef79607c 100644 --- a/test/provider/SlisBNBV3Provider.t.sol +++ b/test/provider/SlisBNBV3Provider.t.sol @@ -10,6 +10,7 @@ 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 { SlisBnbInventoryLib } from "../../src/provider/libraries/SlisBnbInventoryLib.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"; @@ -109,6 +110,42 @@ contract MockStakeManager { receive() external payable {} } +/// @dev StakeManager stand-in that UNDER-delivers on instantWithdraw (applies a `feeBps` haircut) while +/// convertSnBnbToBnb still reports the full rate — exercises the rebalance slippage floor. +contract LossyMockStakeManager { + uint256 public immutable rate; // BNB per slisBNB, 1e18 + address public immutable slisBnb; + uint256 public immutable feeBps; + + constructor(uint256 _rate, address _slisBnb, uint256 _feeBps) { + rate = _rate; + slisBnb = _slisBnb; + feeBps = _feeBps; + } + + function convertSnBnbToBnb(uint256 amount) external view returns (uint256) { + return (amount * rate) / 1e18; // full rate (no fee) — the rebalance floor anchors to this + } + + function convertBnbToSnBnb(uint256 amount) external view returns (uint256) { + return (amount * 1e18) / rate; + } + + function deposit() external payable { + uint256 out = (msg.value * 1e18) / rate; + IERC20(slisBnb).transfer(msg.sender, out); + } + + function instantWithdraw(uint256 amount) external returns (uint256 bnbAmount) { + IERC20(slisBnb).transferFrom(msg.sender, address(this), amount); + bnbAmount = ((amount * rate) / 1e18) * (10_000 - feeBps) / 10_000; // haircut + (bool ok, ) = msg.sender.call{ value: bnbAmount }(""); + require(ok, "bnb 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. @@ -1088,6 +1125,61 @@ contract SlisBNBV3ProviderTest is Test { assertApproxEqRel(valueAfter, valueBefore, 0.02e18, "total value should be preserved within 2% after rebalance"); } + /* ─────── rebalance instantWithdraw slippage floor (rate-anchored minOut) ─────── */ + + /// @dev Re-etch the StakeManager so instantWithdraw under-delivers by `feeBps` vs the rate value. + function _etchLossyStakeManager(uint256 feeBps) internal { + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + LossyMockStakeManager lossy = new LossyMockStakeManager(rate, SLISBNB, feeBps); + vm.etch(STAKE_MANAGER, address(lossy).code); + } + + /// @notice A below-range rebalance must redeem slisBNB→BNB; if instantWithdraw returns less than the + /// rate-anchored floor (here a 5% haircut vs the default 1% tolerance) the conversion reverts. + function test_rebalance_revertsOnInstantWithdrawSlippage() public { + _etchLossyStakeManager(500); // 5% haircut + _deposit(user, 10 ether, 10 ether); + _pushPriceBelowRange(); // position → fully slisBNB ⇒ rebalance instant-withdraws slisBNB→BNB + + vm.prank(manager); + adapter.setCenterRateThresholdBps(0); + + vm.prank(bot); + vm.expectRevert(SlisBnbInventoryLib.InstantWithdrawSlippage.selector); + provider.rebalance(0, 0, 0, block.timestamp); + } + + /// @notice Raising the tolerance above the realized haircut lets the same conversion through. + function test_rebalance_instantWithdrawSlippage_passesWhenToleranceRaised() public { + _etchLossyStakeManager(500); // 5% haircut + _deposit(user, 10 ether, 10 ether); + _pushPriceBelowRange(); + + vm.startPrank(manager); + adapter.setCenterRateThresholdBps(0); + adapter.setInstantWithdrawSlippageBps(600); // 6% tolerance > 5% haircut + vm.stopPrank(); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp); + assertLt(adapter.tickLower(), adapter.tickUpper(), "rebalanced once tolerance covers the haircut"); + } + + function test_setInstantWithdrawSlippageBps_onlyManagerAndCapped() public { + uint256 overCap = adapter.MAX_INSTANT_WITHDRAW_SLIPPAGE_BPS() + 1; + + vm.expectRevert(); // not MANAGER + adapter.setInstantWithdrawSlippageBps(50); + + vm.prank(manager); + vm.expectRevert(SlisBNBV3DexAdapter.InvalidSlippage.selector); + adapter.setInstantWithdrawSlippageBps(overCap); + + vm.prank(manager); + adapter.setInstantWithdrawSlippageBps(250); + assertEq(adapter.instantWithdrawSlippageBps(), 250, "slippage tolerance updated"); + } + /* ─────────── rebalance after price leaves range (fully WBNB) ──────── */ /// @dev Push pool price above tickUpper by swapping a large amount of WBNB → slisBNB. From 9ba976606d962fbfc7e3a26f299ea0b1e697e57d Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:03:40 +0800 Subject: [PATCH 16/17] refactor(provider): unify slisBNB rebalance with wstETH/wbETH (DEX-agnostic swap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the slisBNB/BNB rebalance inventory conversion identical to the wstETH/WETH and wbETH/WETH families: a backend-built swap against a whitelisted venue, swap-pair-agnostic on chain. instantWithdraw is conceptually just a swap, so the StakeManager special-casing is no longer needed — promoting one shared implementation also simplifies the code. - Promote the generic conversion to the base V3DexAdapter: swapPairWhitelist + setSwapPairWhitelist (rejecting TOKEN0/TOKEN1/POOL/NPM) + the decode-and-swap _convertToOptimalRatio (via SwapInventoryLib, backend amountOutMin). All three rate-implied subclasses inherit it; both declared on IV3DexAdapter. - SwapInventoryLib is native-aware: when a venue settles the wrapped-native leg as the native coin (e.g. a StakeManager instant-redeem → BNB), the received native is wrapped back into the wrapped-native ERC20 before amountOutMin is measured; receive() accepts native from a whitelisted venue. ⇒ instantWithdraw works as a whitelisted swap venue (slisBNB→BNB→WBNB), with no StakeManager special-casing on chain. - Slim SlisBNBV3DexAdapter to _lstNativeRate() (StakeManager rate) + the pair guard; drop the StakeManager conversion override, the instant-withdraw slippage config, and the StakeManager receive() override. Delete SlisBnbInventoryLib. - Slim WstETH/WbETHV3DexAdapter: swap logic now in the base; keep rate hook + TWAP-clamp. - SlisBNBV3Provider.rebalance gains bytes calldata swapData, forwarded to the adapter. Behavioral change: slisBNB rebalance converts via a whitelisted venue (a DEX/aggregator OR the StakeManager instant-redeem) using backend swapData + amountOutMin, instead of a hardcoded path. Tests: 257 passing — slisBNB swap-conversion (instantWithdraw-as-venue, both directions, amountOutMin-revert) + whitelist + sensitive-pair-reject; old StakeManager-special-case/slippage tests removed; wstETH/wbETH/liquidator/reentrancy green. Co-Authored-By: Claude Opus 4.8 --- src/provider/interfaces/IV3DexAdapter.sol | 7 + .../libraries/SlisBnbInventoryLib.sol | 241 ------------------ src/provider/libraries/SwapInventoryLib.sol | 43 +++- src/provider/v3/SlisBNBV3DexAdapter.sol | 81 +----- src/provider/v3/SlisBNBV3Provider.sol | 10 +- src/provider/v3/V3DexAdapter.sol | 72 +++++- src/provider/v3/WbETHV3DexAdapter.sol | 61 +---- src/provider/v3/WstETHV3DexAdapter.sol | 62 +---- test/provider/SlisBNBV3Provider.t.sol | 224 ++++++++++------ test/provider/SlisBNBV3ProviderRate.t.sol | 10 +- test/provider/WstETHV3Provider.t.sol | 10 +- 11 files changed, 282 insertions(+), 539 deletions(-) delete mode 100644 src/provider/libraries/SlisBnbInventoryLib.sol diff --git a/src/provider/interfaces/IV3DexAdapter.sol b/src/provider/interfaces/IV3DexAdapter.sol index 09387643..5f46566d 100644 --- a/src/provider/interfaces/IV3DexAdapter.sol +++ b/src/provider/interfaces/IV3DexAdapter.sol @@ -122,6 +122,13 @@ interface IV3DexAdapter { /// @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( diff --git a/src/provider/libraries/SlisBnbInventoryLib.sol b/src/provider/libraries/SlisBnbInventoryLib.sol deleted file mode 100644 index 3680ee09..00000000 --- a/src/provider/libraries/SlisBnbInventoryLib.sol +++ /dev/null @@ -1,241 +0,0 @@ -// 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 { FullMath } from "lista-dao-contracts/oracle/libraries/FullMath.sol"; -import { LiquidityAmounts } from "lista-dao-contracts/libraries/LiquidityAmounts.sol"; -import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; -import { IWBNB } from "../interfaces/IWBNB.sol"; -import { IStakeManager } from "../interfaces/IStakeManager.sol"; - -/** - * @title SlisBnbInventoryLib - * @author Lista DAO - * @notice External library holding the (user-invisible) slisBNB inventory-conversion plumbing used - * by {SlisBNBV3Provider} during rebalance. Deployed once and linked, so the StakeManager - * interaction bytecode lives here instead of inflating the provider implementation. - * - * @dev Invoked via DELEGATECALL, so `address(this)` is the provider: token custody, allowances - * and the native BNB received from `instantWithdraw` all resolve to the provider. The - * StakeManager / token addresses are passed in as arguments (the provider's constants are - * not readable from library code). - */ -library SlisBnbInventoryLib { - using SafeERC20 for IERC20; - - uint128 internal constant RATIO_SAMPLE_LIQUIDITY = 1e18; - uint256 internal constant RATE_SCALE = 1e18; - uint256 internal constant BPS = 10_000; - - error OneDirection(); - error NotSlisBnbWbnbPool(); - error InstantWithdrawSlippage(); - - /// @notice Convert the over-weight leg so free inventory matches the target range's optimal - /// token0/token1 injection ratio at the exchange-rate-implied price. - function convertToOptimalRatio( - IStakeManager stakeManager, - address slisBnb, - address wbnb, - address token0, - address token1, - uint256 total0, - uint256 total1, - uint160 exchangeRateSqrtPriceX96, - int24 targetTickLower, - int24 targetTickUpper, - uint256 token1PerToken0Rate, - uint256 maxInstantWithdrawSlippageBps - ) external returns (uint256, uint256) { - if (total0 == 0 && total1 == 0) return (total0, total1); - if (token1PerToken0Rate == 0) return (total0, total1); - if (!((token0 == wbnb && token1 == slisBnb) || (token0 == slisBnb && token1 == wbnb))) { - revert NotSlisBnbWbnbPool(); - } - - (uint256 target0, uint256 target1) = _targetAmountsForOptimalRatio( - total0, - total1, - exchangeRateSqrtPriceX96, - TickMath.getSqrtRatioAtTick(targetTickLower), - TickMath.getSqrtRatioAtTick(targetTickUpper), - token1PerToken0Rate - ); - - uint256 token0ToToken1; - uint256 token1ToToken0; - if (total0 > target0) { - token0ToToken1 = total0 - target0; - } else if (target0 > total0) { - uint256 amountByToken0Shortfall = FullMath.mulDiv(target0 - total0, token1PerToken0Rate, RATE_SCALE); - uint256 amountByToken1Excess = total1 > target1 ? total1 - target1 : amountByToken0Shortfall; - token1ToToken0 = amountByToken1Excess > total1 ? total1 : amountByToken1Excess; - } - - (uint256 bnbToStake, uint256 slisBnbToRedeem) = _conversionAmounts( - token0, - token1, - wbnb, - token0ToToken1, - token1ToToken0 - ); - - return - _convert( - stakeManager, - slisBnb, - wbnb, - token0, - token1, - total0, - total1, - bnbToStake, - slisBnbToRedeem, - maxInstantWithdrawSlippageBps - ); - } - - /// @notice Convert free inventory between the WBNB and slisBNB legs and return adjusted totals. - /// - `bnbToStake`: unwrap that much WBNB and stake it (deposit) into slisBNB. - /// - `slisBnbToRedeem`: instant-redeem that much slisBNB into BNB, re-wrapped to WBNB. - /// Goes through the StakeManager at its on-chain exchange rate (not the pool), so it is - /// not market-manipulable; instantWithdraw deducts a deterministic fee bounded by - /// `maxInstantWithdrawSlippageBps`. Amounts moved are measured by balance delta so the - /// returned totals stay exact, and capped to availability. - function convert( - IStakeManager stakeManager, - address slisBnb, - address wbnb, - address token0, - address token1, - uint256 total0, - uint256 total1, - uint256 bnbToStake, - uint256 slisBnbToRedeem, - uint256 maxInstantWithdrawSlippageBps - ) external returns (uint256, uint256) { - return - _convert( - stakeManager, - slisBnb, - wbnb, - token0, - token1, - total0, - total1, - bnbToStake, - slisBnbToRedeem, - maxInstantWithdrawSlippageBps - ); - } - - function _convert( - IStakeManager stakeManager, - address slisBnb, - address wbnb, - address token0, - address token1, - uint256 total0, - uint256 total1, - uint256 bnbToStake, - uint256 slisBnbToRedeem, - uint256 maxInstantWithdrawSlippageBps - ) private returns (uint256, uint256) { - if (bnbToStake == 0 && slisBnbToRedeem == 0) return (total0, total1); - if (bnbToStake > 0 && slisBnbToRedeem > 0) revert OneDirection(); - if (!((token0 == wbnb && token1 == slisBnb) || (token0 == slisBnb && token1 == wbnb))) { - revert NotSlisBnbWbnbPool(); - } - bool wbnbIs0 = token0 == wbnb; - - if (bnbToStake > 0) { - uint256 wbnbAvail = wbnbIs0 ? total0 : total1; - uint256 amt = bnbToStake > wbnbAvail ? wbnbAvail : bnbToStake; - if (amt > 0) { - uint256 sBefore = IERC20(slisBnb).balanceOf(address(this)); - IWBNB(wbnb).withdraw(amt); - stakeManager.deposit{ value: amt }(); - uint256 minted = IERC20(slisBnb).balanceOf(address(this)) - sBefore; - if (wbnbIs0) { - total0 -= amt; - total1 += minted; - } else { - total1 -= amt; - total0 += minted; - } - } - } else { - uint256 slisAvail = wbnbIs0 ? total1 : total0; - uint256 amt = slisBnbToRedeem > slisAvail ? slisAvail : slisBnbToRedeem; - if (amt > 0) { - // Rate-anchored slippage floor: instantWithdraw must return at least the StakeManager - // exchange-rate value of `amt`, minus the configured tolerance. This bounds the deterministic - // instant-withdraw fee (and any rate anomaly). `convertSnBnbToBnb` is the on-chain accounting - // rate (not pool/market) so the floor is not manipulable. - uint256 minBnbOut = FullMath.mulDiv( - stakeManager.convertSnBnbToBnb(amt), - BPS - maxInstantWithdrawSlippageBps, - BPS - ); - uint256 bBefore = address(this).balance; - IERC20(slisBnb).safeIncreaseAllowance(address(stakeManager), amt); - stakeManager.instantWithdraw(amt); - uint256 bnbOut = address(this).balance - bBefore; - if (bnbOut < minBnbOut) revert InstantWithdrawSlippage(); - if (bnbOut > 0) IWBNB(wbnb).deposit{ value: bnbOut }(); - if (wbnbIs0) { - total0 += bnbOut; - total1 -= amt; - } else { - total1 += bnbOut; - total0 -= amt; - } - } - } - return (total0, total1); - } - - function _targetAmountsForOptimalRatio( - uint256 total0, - uint256 total1, - uint160 exchangeRateSqrtPriceX96, - uint160 sqrtLower, - uint160 sqrtUpper, - uint256 token1PerToken0Rate - ) private pure returns (uint256 target0, uint256 target1) { - (uint256 ratio0, uint256 ratio1) = LiquidityAmounts.getAmountsForLiquidity( - exchangeRateSqrtPriceX96, - sqrtLower, - sqrtUpper, - RATIO_SAMPLE_LIQUIDITY - ); - - if (ratio0 == 0) return (0, total1 + FullMath.mulDiv(total0, token1PerToken0Rate, RATE_SCALE)); - - uint256 ratio0ValueInToken1 = FullMath.mulDiv(ratio0, token1PerToken0Rate, RATE_SCALE); - uint256 denominator = ratio0ValueInToken1 + ratio1; - if (denominator == 0) return (total0, total1); - - uint256 totalValueInToken1 = total1 + FullMath.mulDiv(total0, token1PerToken0Rate, RATE_SCALE); - target0 = FullMath.mulDiv(totalValueInToken1, ratio0, denominator); - uint256 target0ValueInToken1 = FullMath.mulDiv(target0, token1PerToken0Rate, RATE_SCALE); - target1 = totalValueInToken1 > target0ValueInToken1 ? totalValueInToken1 - target0ValueInToken1 : 0; - } - - function _conversionAmounts( - address token0, - address token1, - address wbnb, - uint256 token0ToToken1, - uint256 token1ToToken0 - ) private pure returns (uint256 bnbToStake, uint256 slisBnbToRedeem) { - if (token0ToToken1 > 0) { - if (token0 == wbnb) bnbToStake = token0ToToken1; - else slisBnbToRedeem = token0ToToken1; - } else if (token1ToToken0 > 0) { - if (token1 == wbnb) bnbToStake = token1ToToken0; - else slisBnbToRedeem = token1ToToken0; - } - } -} diff --git a/src/provider/libraries/SwapInventoryLib.sol b/src/provider/libraries/SwapInventoryLib.sol index 2330c5ed..854195af 100644 --- a/src/provider/libraries/SwapInventoryLib.sol +++ b/src/provider/libraries/SwapInventoryLib.sol @@ -3,22 +3,27 @@ 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 used by ETH LST V3 LP adapters (e.g. - * {WstETHV3DexAdapter}) during rebalance. Mirrors {Liquidator}'s aggregator pattern: the (BOT) - * backend builds `swapData` for ANY whitelisted venue (1inch / 0x / Uniswap / …); the adapter - * just forwards it via a low-level `swapPair.call(swapData)` and enforces the result with - * MEASURED balance deltas — `spent <= amountIn` and `received >= amountOutMin`. No on-chain - * routing or price math. + * @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 / …); the adapter just forwards it via a + * low-level `swapPair.call(swapData)` and enforces the result with MEASURED balance deltas — + * `spent <= amountIn` and `received >= amountOutMin`. No on-chain routing or price math. * - * @dev Invoked via DELEGATECALL, so `address(this)` is the adapter: token custody and allowances - * 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` / `swapData` - * come from the backend. `amountIn` is capped to the available balance, and the allowance to - * `swapPair` is set to `amountIn` then reset to 0 after the call. + * A venue may settle the wrapped-native leg as the NATIVE coin instead of the ERC20 (e.g. a Lista + * StakeManager `instantWithdraw` that pays out BNB). When the output leg is the wrapped-native + * token, any native delivered by the call is wrapped back into it before `received` is measured — + * so instant-redeem venues are supported without any special-casing in the adapter. + * + * @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`/ + * `swapData` come from the backend. `amountIn` is capped to the available balance, and the allowance + * to `swapPair` is set to `amountIn` then reset to 0 after the call. */ library SwapInventoryLib { using SafeERC20 for IERC20; @@ -28,7 +33,9 @@ library SwapInventoryLib { error InsufficientOutput(); /// @notice Execute one backend-built swap. `sellToken0` ⇒ sell token0 for token1, else token1 for - /// token0. Returns (total0, total1) adjusted by the MEASURED spent/received deltas. + /// token0. `wrappedNative` is the adapter's wrapped-native token: native delivered by the + /// venue for the wrapped-native output leg is wrapped into it. Returns (total0, total1) + /// adjusted by the MEASURED spent/received deltas. function swap( address swapPair, address token0, @@ -38,7 +45,8 @@ library SwapInventoryLib { uint256 amountOutMin, bytes memory swapData, uint256 total0, - uint256 total1 + uint256 total1, + address wrappedNative ) external returns (uint256, uint256) { if (amountIn == 0) return (total0, total1); @@ -51,12 +59,21 @@ library SwapInventoryLib { uint256 beforeIn = IERC20(tokenIn).balanceOf(address(this)); uint256 beforeOut = IERC20(tokenOut).balanceOf(address(this)); + uint256 beforeNative = address(this).balance; IERC20(tokenIn).forceApprove(swapPair, amountIn); (bool ok, ) = swapPair.call(swapData); if (!ok) revert SwapFailed(); IERC20(tokenIn).forceApprove(swapPair, 0); // clear any residual allowance + // A venue may settle the wrapped-native output leg as the native coin (e.g. StakeManager + // instantWithdraw → BNB). Wrap whatever native THIS call delivered so the tokenOut delta captures it. + if (tokenOut == wrappedNative) { + uint256 cur = address(this).balance; + uint256 nativeIn = cur > beforeNative ? cur - beforeNative : 0; + if (nativeIn > 0) IWBNB(wrappedNative).deposit{ value: nativeIn }(); + } + uint256 spent = beforeIn - IERC20(tokenIn).balanceOf(address(this)); uint256 received = IERC20(tokenOut).balanceOf(address(this)) - beforeOut; if (spent > amountIn) revert ExceedAmountIn(); diff --git a/src/provider/v3/SlisBNBV3DexAdapter.sol b/src/provider/v3/SlisBNBV3DexAdapter.sol index d6f88c07..32389388 100644 --- a/src/provider/v3/SlisBNBV3DexAdapter.sol +++ b/src/provider/v3/SlisBNBV3DexAdapter.sol @@ -4,17 +4,18 @@ pragma solidity 0.8.34; import { V3DexAdapter } from "./V3DexAdapter.sol"; import { IStakeManager } from "../interfaces/IStakeManager.sol"; import { ISlisBNBV3DexAdapter } from "../interfaces/ISlisBNBV3DexAdapter.sol"; -import { SlisBnbInventoryLib } from "../libraries/SlisBnbInventoryLib.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 and the rebalance skeleton; this subclass supplies only the - * slisBNB-specific hooks: - * - _lstNativeRate(): StakeManager slisBNB↔BNB rate (not pool spot/TWAP); - * - _convertToOptimalRatio(): StakeManager stake / instantWithdraw inventory conversion; - * - receive(): also accept native BNB from StakeManager.instantWithdraw. + * ±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 ──────────────────────────── */ @@ -25,24 +26,9 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { /// @dev BSC wrapped native token (forwarded to the base as WRAPPED_NATIVE). address public constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; - /// @dev Hard cap on the rebalance instant-withdraw slippage tolerance (10%). - uint256 public constant MAX_INSTANT_WITHDRAW_SLIPPAGE_BPS = 1_000; - - /* ──────────────────────────── storage ───────────────────────────── */ - - /// @dev Max tolerated shortfall (BPS) of the rebalance instantWithdraw BNB output vs the StakeManager - /// exchange-rate value of the redeemed slisBNB. Bounds the instant-withdraw fee / any rate - /// anomaly; the conversion reverts if the realized BNB falls below this rate-anchored floor. - uint256 public instantWithdrawSlippageBps; - - /* ───────────────────────────── events ───────────────────────────── */ - - event InstantWithdrawSlippageChanged(uint256 instantWithdrawSlippageBps); - /* ───────────────────────────── errors ───────────────────────────── */ error NotSlisBnbWbnbPair(); - error InvalidSlippage(); /// @custom:oz-upgrades-unsafe-allow constructor constructor( @@ -52,15 +38,15 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { uint24 _fee, uint32 _twapPeriod ) V3DexAdapter(_positionManager, _token0, _token1, _fee, _twapPeriod, WBNB) { - // slisBNB/BNB-ONLY: the rate-implied fair price, ±1% tick centering and StakeManager inventory - // conversion all 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. + // 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 + instantWithdrawSlippageBps). + * @param _manager Manager role (sets centerRateThresholdBps + the swap-pair whitelist). */ function initialize(address _admin, address _manager) external initializer { uint256 initialCenterRate = _lstNativeRate(); @@ -68,18 +54,6 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { __V3DexAdapter_init(_admin, _manager, initialTickLower, initialTickUpper); lastCenterRate = initialCenterRate; centerRateThresholdBps = INITIAL_RANGE_BPS; - instantWithdrawSlippageBps = 100; // default 1% tolerance; MANAGER tunes to the live fee + buffer - emit InstantWithdrawSlippageChanged(100); - } - - /* ───────────────────────── manager config ───────────────────────── */ - - /// @notice Set the rebalance instant-withdraw slippage tolerance (BPS). 0 ⇒ require the full - /// rate value (no fee tolerated). onlyRole MANAGER. - function setInstantWithdrawSlippageBps(uint256 _instantWithdrawSlippageBps) external onlyRole(MANAGER) { - if (_instantWithdrawSlippageBps > MAX_INSTANT_WITHDRAW_SLIPPAGE_BPS) revert InvalidSlippage(); - instantWithdrawSlippageBps = _instantWithdrawSlippageBps; - emit InstantWithdrawSlippageChanged(_instantWithdrawSlippageBps); } /* ───────────────────────── hook overrides ───────────────────────── */ @@ -89,39 +63,6 @@ contract SlisBNBV3DexAdapter is V3DexAdapter, ISlisBNBV3DexAdapter { return _isSlisBnbWbnbPool() ? _poolPriceRate() : 0; } - /// @dev Convert pooled inventory to the optimal ratio via StakeManager stake (WBNB→slisBNB) / - /// instantWithdraw (slisBNB→BNB→WBNB). - function _convertToOptimalRatio( - uint256 total0, - uint256 total1, - int24 targetTickLower, - int24 targetTickUpper, - uint256 rate, - bytes calldata /* swapData */ - ) internal override returns (uint256, uint256) { - if (!_isSlisBnbWbnbPool()) return (total0, total1); - return - SlisBnbInventoryLib.convertToOptimalRatio( - STAKE_MANAGER, - SLISBNB, - WBNB, - TOKEN0, - TOKEN1, - total0, - total1, - _sqrtPriceX96FromRate(rate), - targetTickLower, - targetTickUpper, - rate, - instantWithdrawSlippageBps - ); - } - - /// @dev Accept native BNB from WBNB unwrap or StakeManager instantWithdraw. - receive() external payable override { - if (!(msg.sender == WRAPPED_NATIVE || msg.sender == address(STAKE_MANAGER))) revert NotWrappedNative(); - } - /* ─────────────────────────── internals ──────────────────────────── */ function _isSlisBnbWbnbPool() internal view returns (bool) { diff --git a/src/provider/v3/SlisBNBV3Provider.sol b/src/provider/v3/SlisBNBV3Provider.sol index 4ff46825..e2318a64 100644 --- a/src/provider/v3/SlisBNBV3Provider.sol +++ b/src/provider/v3/SlisBNBV3Provider.sol @@ -52,15 +52,17 @@ contract SlisBNBV3Provider is V3Provider { /* ─────────────────────────── 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. + /// 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 + uint256 deadline, + bytes calldata swapData ) external onlyRole(BOT) nonReentrant { - // slisBNB converts inventory on-chain via the StakeManager — no DEX swapData needed. - ISlisBNBV3DexAdapter(ADAPTER).rebalance(minAmount0, minAmount1, minLiquidity, deadline, ""); + ISlisBNBV3DexAdapter(ADAPTER).rebalance(minAmount0, minAmount1, minLiquidity, deadline, swapData); } /* ─────────────────── slisBNBx: sync / view ──────────────────────── */ diff --git a/src/provider/v3/V3DexAdapter.sol b/src/provider/v3/V3DexAdapter.sol index 99ef50fb..067a64a6 100644 --- a/src/provider/v3/V3DexAdapter.sol +++ b/src/provider/v3/V3DexAdapter.sol @@ -14,6 +14,7 @@ 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"; @@ -32,10 +33,13 @@ import { IV3PoolMinimal } from "../interfaces/IV3PoolMinimal.sol"; * 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. * - * Extension points (slisBNB/BNB subclass overrides): - * - fairSqrtPriceX96(): exchange-rate-implied price instead of pool TWAP. - * - receive(): widen accepted wrapped-native unwrap senders (e.g. StakeManager instantWithdraw). - * - rebalance(): added by the subclass (rate-centered recenter + inventory conversion). + * 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, @@ -91,8 +95,13 @@ abstract contract V3DexAdapter is /// @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[48] private __gap; + uint256[47] private __gap; /* ───────────────────────────── events ───────────────────────────── */ @@ -103,6 +112,7 @@ abstract contract V3DexAdapter is 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 ───────────────────────────── */ @@ -121,6 +131,8 @@ abstract contract V3DexAdapter is error InsufficientLiquidityMinted(); error RateDeviationBelowThreshold(); error InvalidThreshold(); + error NotWhitelistedPair(); + error InvalidSwapPair(); /* ─────────────────────────── constructor ────────────────────────── */ @@ -293,6 +305,19 @@ abstract contract V3DexAdapter is 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, @@ -523,9 +548,11 @@ abstract contract V3DexAdapter is } } - /// @dev Accepts native coin from the wrapped-native unwrap. Subclasses widen the allowed senders. + /// @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) revert NotWrappedNative(); + if (msg.sender != WRAPPED_NATIVE && !swapPairWhitelist[msg.sender]) revert NotWrappedNative(); } /* ─────────────────── rate-centering math (shared) ────────────────── */ @@ -607,18 +634,39 @@ abstract contract V3DexAdapter is return 0; } - /// @dev Convert the position's pooled inventory toward the optimal ratio for the target range. - /// Base no-op (TWAP pairs keep raw inventory). Rate-implied subclasses override: slisBNB via - /// StakeManager stake/redeem, wstETH via a router swap with rate-anchored minOut. + /// @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, bytes innerSwapData): + /// the adapter requires `swapPair` whitelisted and forwards `innerSwapData` via a low-level call, + /// bounding the swap by the allowance + the backend's `amountOutMin` (see {SwapInventoryLib}). + /// Empty swapData ⇒ recenter without converting inventory (also the TWAP-pair default). function _convertToOptimalRatio( uint256 total0, uint256 total1, int24 /* targetTickLower */, int24 /* targetTickUpper */, uint256 /* rate */, - bytes calldata /* swapData */ + bytes calldata swapData ) internal virtual returns (uint256, uint256) { - return (total0, total1); + if (swapData.length == 0) return (total0, total1); + (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes memory inner) = abi.decode( + swapData, + (address, bool, uint256, uint256, bytes) + ); + if (!swapPairWhitelist[swapPair]) revert NotWhitelistedPair(); + return + SwapInventoryLib.swap( + swapPair, + TOKEN0, + TOKEN1, + sellToken0, + amountIn, + amountOutMin, + inner, + total0, + total1, + WRAPPED_NATIVE + ); } function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} diff --git a/src/provider/v3/WbETHV3DexAdapter.sol b/src/provider/v3/WbETHV3DexAdapter.sol index f72937e0..122d76f5 100644 --- a/src/provider/v3/WbETHV3DexAdapter.sol +++ b/src/provider/v3/WbETHV3DexAdapter.sol @@ -5,20 +5,17 @@ import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; import { V3DexAdapter } from "./V3DexAdapter.sol"; import { IWbETH } from "../interfaces/IWbETH.sol"; -import { SwapInventoryLib } from "../libraries/SwapInventoryLib.sol"; /** * @title WbETHV3DexAdapter * @author Lista DAO * @notice wbETH/WETH specialization of {V3DexAdapter} for Ethereum — mechanism identical to - * {WstETHV3DexAdapter}, only the rate source + pair differ. Supplies the LST-specific hooks: + * {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; - * - _convertToOptimalRatio(): DEX-agnostic, backend-built rebalance swap (à la {Liquidator}). - * The BOT backend supplies (swapPair, sellToken0, amountIn, amountOutMin, innerSwapData); - * the adapter only allows a whitelisted `swapPair` and bounds the swap by the allowance + - * the backend's `amountOutMin` (enforced on the measured output). - * `receive()` is inherited: it accepts native ETH only from the WETH unwrap (no StakeManager). + * - 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 ──────────────────────────── */ @@ -31,10 +28,6 @@ contract WbETHV3DexAdapter is V3DexAdapter { /* ──────────────────────────── storage ───────────────────────────── */ - /// @dev Whitelisted swap venues for rebalance conversions (any DEX / aggregator). The backend builds - /// the calldata; the adapter only allows whitelisted targets — like {Liquidator}'s pairWhitelist. - mapping(address => bool) public swapPairWhitelist; - /// @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. @@ -42,13 +35,10 @@ contract WbETHV3DexAdapter is V3DexAdapter { /* ─────────────────────────── events/errors ──────────────────────── */ - event SwapPairWhitelistSet(address indexed swapPair, bool status); event MaxTwapDeviationChanged(uint256 maxTwapDeviationBps); error NotWbEthWethPair(); error InvalidDeviation(); - error NotWhitelistedPair(); - error InvalidSwapPair(); /// @custom:oz-upgrades-unsafe-allow constructor constructor( @@ -58,9 +48,9 @@ contract WbETHV3DexAdapter is V3DexAdapter { uint24 _fee, uint32 _twapPeriod ) V3DexAdapter(_positionManager, _token0, _token1, _fee, _twapPeriod, WETH) { - // wbETH/WETH-ONLY: the rate-implied valuation, ±1% tick centering and the swap-based inventory - // conversion all assume token0 == wbETH and token1 == WETH. The base enforces token0 < token1, - // and wbETH < WETH, so this is the only valid ordering — reject anything else. + // 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(); } @@ -81,19 +71,6 @@ contract WbETHV3DexAdapter is V3DexAdapter { /* ───────────────────────── manager config ───────────────────────── */ - /// @notice Whitelist (or remove) a swap venue the rebalance may call. Backend-built calldata can only - /// target whitelisted venues. - function setSwapPairWhitelist(address swapPair, bool status) external onlyRole(MANAGER) { - if (swapPair == address(0)) revert ZeroAddress(); - // 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. - if ( - status && (swapPair == TOKEN0 || swapPair == TOKEN1 || swapPair == POOL || swapPair == address(POSITION_MANAGER)) - ) revert InvalidSwapPair(); - swapPairWhitelist[swapPair] = status; - emit SwapPairWhitelistSet(swapPair, status); - } - /// @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(); @@ -124,26 +101,4 @@ contract WbETHV3DexAdapter is V3DexAdapter { if (twapSqrt > sqrtHigh) return sqrtHigh; return twapSqrt; } - - /// @dev DEX-agnostic, backend-built rebalance conversion. `swapData` (when non-empty) ABI-encodes - /// (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes innerSwapData): - /// the adapter requires `swapPair` whitelisted and forwards `innerSwapData` via a low-level call, - /// bounding the swap by the allowance + `amountOutMin` (see {SwapInventoryLib}). Empty swapData ⇒ - /// recenter without converting inventory. - function _convertToOptimalRatio( - uint256 total0, - uint256 total1, - int24 /* targetTickLower */, - int24 /* targetTickUpper */, - uint256 /* rate */, - bytes calldata swapData - ) internal override returns (uint256, uint256) { - if (swapData.length == 0) return (total0, total1); - (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes memory inner) = abi.decode( - swapData, - (address, bool, uint256, uint256, bytes) - ); - if (!swapPairWhitelist[swapPair]) revert NotWhitelistedPair(); - return SwapInventoryLib.swap(swapPair, TOKEN0, TOKEN1, sellToken0, amountIn, amountOutMin, inner, total0, total1); - } } diff --git a/src/provider/v3/WstETHV3DexAdapter.sol b/src/provider/v3/WstETHV3DexAdapter.sol index 7328598a..7769ee9e 100644 --- a/src/provider/v3/WstETHV3DexAdapter.sol +++ b/src/provider/v3/WstETHV3DexAdapter.sol @@ -5,21 +5,17 @@ import { TickMath } from "lista-dao-contracts/libraries/TickMath.sol"; import { V3DexAdapter } from "./V3DexAdapter.sol"; import { IWstETH } from "../interfaces/IWstETH.sol"; -import { SwapInventoryLib } from "../libraries/SwapInventoryLib.sol"; /** * @title WstETHV3DexAdapter * @author Lista DAO - * @notice wstETH/WETH specialization of {V3DexAdapter} for Ethereum. Supplies the LST-specific hooks: + * @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; - * - _convertToOptimalRatio(): DEX-agnostic, backend-built rebalance swap (à la {Liquidator}). - * The BOT backend supplies (swapPair, sellToken0, amountIn, amountOutMin, innerSwapData); - * the adapter only allows a whitelisted `swapPair` and bounds the swap by the allowance + - * the backend's `amountOutMin` (enforced on the measured output). No on-chain instant - * LST→ETH redeem exists, so inventory is converted by a market swap. - * `receive()` is inherited: it accepts native ETH only from the WETH unwrap (no StakeManager). + * 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 ──────────────────────────── */ @@ -32,10 +28,6 @@ contract WstETHV3DexAdapter is V3DexAdapter { /* ──────────────────────────── storage ───────────────────────────── */ - /// @dev Whitelisted swap venues for rebalance conversions (any DEX / aggregator). The backend builds - /// the calldata; the adapter only allows whitelisted targets — like {Liquidator}'s pairWhitelist. - mapping(address => bool) public swapPairWhitelist; - /// @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. @@ -43,13 +35,10 @@ contract WstETHV3DexAdapter is V3DexAdapter { /* ─────────────────────────── events/errors ──────────────────────── */ - event SwapPairWhitelistSet(address indexed swapPair, bool status); event MaxTwapDeviationChanged(uint256 maxTwapDeviationBps); error NotWstEthWethPair(); error InvalidDeviation(); - error NotWhitelistedPair(); - error InvalidSwapPair(); /// @custom:oz-upgrades-unsafe-allow constructor constructor( @@ -59,9 +48,9 @@ contract WstETHV3DexAdapter is V3DexAdapter { uint24 _fee, uint32 _twapPeriod ) V3DexAdapter(_positionManager, _token0, _token1, _fee, _twapPeriod, WETH) { - // wstETH/WETH-ONLY: the rate-implied valuation, ±1% tick centering and the swap-based inventory - // conversion all assume token0 == wstETH and token1 == WETH. The base enforces token0 < token1, - // and wstETH < WETH, so this is the only valid ordering — reject anything else. + // 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(); } @@ -82,19 +71,6 @@ contract WstETHV3DexAdapter is V3DexAdapter { /* ───────────────────────── manager config ───────────────────────── */ - /// @notice Whitelist (or remove) a swap venue the rebalance may call. Backend-built calldata can only - /// target whitelisted venues. - function setSwapPairWhitelist(address swapPair, bool status) external onlyRole(MANAGER) { - if (swapPair == address(0)) revert ZeroAddress(); - // 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. - if ( - status && (swapPair == TOKEN0 || swapPair == TOKEN1 || swapPair == POOL || swapPair == address(POSITION_MANAGER)) - ) revert InvalidSwapPair(); - swapPairWhitelist[swapPair] = status; - emit SwapPairWhitelistSet(swapPair, status); - } - /// @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(); @@ -132,26 +108,4 @@ contract WstETHV3DexAdapter is V3DexAdapter { if (twapSqrt > sqrtHigh) return sqrtHigh; return twapSqrt; } - - /// @dev DEX-agnostic, backend-built rebalance conversion. `swapData` (when non-empty) ABI-encodes - /// (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes innerSwapData): - /// the adapter requires `swapPair` whitelisted and forwards `innerSwapData` via a low-level call, - /// bounding the swap by the allowance + `amountOutMin` (see {SwapInventoryLib}). Empty swapData ⇒ - /// recenter without converting inventory. - function _convertToOptimalRatio( - uint256 total0, - uint256 total1, - int24 /* targetTickLower */, - int24 /* targetTickUpper */, - uint256 /* rate */, - bytes calldata swapData - ) internal override returns (uint256, uint256) { - if (swapData.length == 0) return (total0, total1); - (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes memory inner) = abi.decode( - swapData, - (address, bool, uint256, uint256, bytes) - ); - if (!swapPairWhitelist[swapPair]) revert NotWhitelistedPair(); - return SwapInventoryLib.swap(swapPair, TOKEN0, TOKEN1, sellToken0, amountIn, amountOutMin, inner, total0, total1); - } } diff --git a/test/provider/SlisBNBV3Provider.t.sol b/test/provider/SlisBNBV3Provider.t.sol index ef79607c..dc1cc504 100644 --- a/test/provider/SlisBNBV3Provider.t.sol +++ b/test/provider/SlisBNBV3Provider.t.sol @@ -10,11 +10,11 @@ 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 { SlisBnbInventoryLib } from "../../src/provider/libraries/SlisBnbInventoryLib.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"; @@ -110,40 +110,14 @@ contract MockStakeManager { receive() external payable {} } -/// @dev StakeManager stand-in that UNDER-delivers on instantWithdraw (applies a `feeBps` haircut) while -/// convertSnBnbToBnb still reports the full rate — exercises the rebalance slippage floor. -contract LossyMockStakeManager { - uint256 public immutable rate; // BNB per slisBNB, 1e18 - address public immutable slisBnb; - uint256 public immutable feeBps; - - constructor(uint256 _rate, address _slisBnb, uint256 _feeBps) { - rate = _rate; - slisBnb = _slisBnb; - feeBps = _feeBps; - } - - function convertSnBnbToBnb(uint256 amount) external view returns (uint256) { - return (amount * rate) / 1e18; // full rate (no fee) — the rebalance floor anchors to this - } - - function convertBnbToSnBnb(uint256 amount) external view returns (uint256) { - return (amount * 1e18) / rate; +/// @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); } - - function deposit() external payable { - uint256 out = (msg.value * 1e18) / rate; - IERC20(slisBnb).transfer(msg.sender, out); - } - - function instantWithdraw(uint256 amount) external returns (uint256 bnbAmount) { - IERC20(slisBnb).transferFrom(msg.sender, address(this), amount); - bnbAmount = ((amount * rate) / 1e18) * (10_000 - feeBps) / 10_000; // haircut - (bool ok, ) = msg.sender.call{ value: bnbAmount }(""); - require(ok, "bnb send failed"); - } - - receive() external payable {} } /// @notice Functional integration tests for the slisBNB/BNB V3 LP topology (3-contract split: @@ -183,6 +157,7 @@ contract SlisBNBV3ProviderTest is Test { SlisBNBV3DexAdapter adapter; SlisBNBV3ProviderOracle providerOracle; MockOracle oracle; + MockSwap mockSwap; MarketParams marketParams; Id marketId; @@ -248,6 +223,11 @@ contract SlisBNBV3ProviderTest is Test { 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), @@ -559,7 +539,7 @@ contract SlisBNBV3ProviderTest is Test { // manager cannot rebalance — revert fires on role check before amounts matter. vm.prank(manager); vm.expectRevert(); - provider.rebalance(1, 1, 1, block.timestamp); + 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. @@ -572,7 +552,7 @@ contract SlisBNBV3ProviderTest is Test { uint256 min1 = (total1 * 999) / 1000; uint256 oldTokenId = adapter.tokenId(); vm.prank(bot); - provider.rebalance(min0, min1, 0, block.timestamp); + provider.rebalance(min0, min1, 0, block.timestamp, ""); assertGt(adapter.tokenId(), oldTokenId, "position NFT should be re-minted"); assertLt(adapter.tickLower(), adapter.tickUpper()); @@ -591,7 +571,7 @@ contract SlisBNBV3ProviderTest is Test { uint256 min0 = (total0 * 999) / 1000; uint256 min1 = (total1 * 999) / 1000; vm.prank(bot); - provider.rebalance(min0, min1, 0, block.timestamp); + provider.rebalance(min0, min1, 0, block.timestamp, ""); // Share count is unchanged after rebalance. assertEq(_collateral(user), shares, "shares should be unchanged after rebalance"); @@ -1113,71 +1093,151 @@ contract SlisBNBV3ProviderTest is Test { // Rebalance uses an internally derived range; caller only supplies execution guards. vm.prank(bot); - provider.rebalance(0, 0, 0, block.timestamp); + 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); - // The slisBNB adapter converts inventory to the rate-optimal ratio via the StakeManager and - // re-mints; value is preserved within ~2% (instant-withdraw/stake conversion rounding). - assertApproxEqRel(valueAfter, valueBefore, 0.02e18, "total value should be preserved within 2% after rebalance"); + // 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 instantWithdraw slippage floor (rate-anchored minOut) ─────── */ + /* ─────── rebalance inventory conversion via a whitelisted swap venue (DEX-agnostic) ─────── */ - /// @dev Re-etch the StakeManager so instantWithdraw under-delivers by `feeBps` vs the rate value. - function _etchLossyStakeManager(uint256 feeBps) internal { + /// @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); - LossyMockStakeManager lossy = new LossyMockStakeManager(rate, SLISBNB, feeBps); - vm.etch(STAKE_MANAGER, address(lossy).code); + 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, 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 A below-range rebalance must redeem slisBNB→BNB; if instantWithdraw returns less than the - /// rate-anchored floor (here a 5% haircut vs the default 1% tolerance) the conversion reverts. - function test_rebalance_revertsOnInstantWithdrawSlippage() public { - _etchLossyStakeManager(500); // 5% haircut + /// @notice The adapter only allows whitelisted swap venues; a non-whitelisted target reverts. + function test_rebalance_revertsNotWhitelistedPair() public { _deposit(user, 10 ether, 10 ether); - _pushPriceBelowRange(); // position → fully slisBNB ⇒ rebalance instant-withdraws slisBNB→BNB - 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), inner); + vm.prank(bot); - vm.expectRevert(SlisBnbInventoryLib.InstantWithdrawSlippage.selector); - provider.rebalance(0, 0, 0, block.timestamp); + vm.expectRevert(V3DexAdapter.NotWhitelistedPair.selector); + provider.rebalance(0, 0, 0, block.timestamp, data); } - /// @notice Raising the tolerance above the realized haircut lets the same conversion through. - function test_rebalance_instantWithdrawSlippage_passesWhenToleranceRaised() public { - _etchLossyStakeManager(500); // 5% haircut + /// @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); - _pushPriceBelowRange(); + uint256 peekBefore = providerOracle.peek(address(provider)); + uint256 oldTokenId = adapter.tokenId(); - vm.startPrank(manager); + vm.prank(manager); adapter.setCenterRateThresholdBps(0); - adapter.setInstantWithdrawSlippageBps(600); // 6% tolerance > 5% haircut - vm.stopPrank(); + + // 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, inner); vm.prank(bot); - provider.rebalance(0, 0, 0, block.timestamp); - assertLt(adapter.tickLower(), adapter.tickUpper(), "rebalanced once tolerance covers the haircut"); + 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" + ); } - function test_setInstantWithdrawSlippageBps_onlyManagerAndCapped() public { - uint256 overCap = adapter.MAX_INSTANT_WITHDRAW_SLIPPAGE_BPS() + 1; + /// @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); - vm.expectRevert(); // not MANAGER - adapter.setInstantWithdrawSlippageBps(50); + uint256 rate = IStakeManager(STAKE_MANAGER).convertSnBnbToBnb(1e18); + uint256 amountIn = 0.5 ether; + uint256 fairOut = (amountIn * rate) / 1e18; + deal(WBNB, address(mockSwap), fairOut); - vm.prank(manager); - vm.expectRevert(SlisBNBV3DexAdapter.InvalidSlippage.selector); - adapter.setInstantWithdrawSlippageBps(overCap); + // 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, 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.setInstantWithdrawSlippageBps(250); - assertEq(adapter.instantWithdrawSlippageBps(), 250, "slippage tolerance updated"); + 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, inner); + + vm.prank(bot); + provider.rebalance(0, 0, 0, block.timestamp, data); + assertApproxEqRel(providerOracle.peek(address(provider)), peekBefore, 2e16, "WBNB->slisBNB swap ~value-neutral"); } /* ─────────── rebalance after price leaves range (fully WBNB) ──────── */ @@ -1222,16 +1282,16 @@ contract SlisBNBV3ProviderTest is Test { // Rebalance uses an internally derived range; caller only supplies execution guards. vm.prank(bot); - provider.rebalance(0, 0, 0, block.timestamp); + 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); - // Inventory converted to the rate-optimal ratio via the StakeManager and re-minted; value - // preserved within ~2% (instant-withdraw/stake conversion rounding). - assertApproxEqRel(valueAfter, valueBefore, 0.02e18, "total value should be preserved within 2% after rebalance"); + // 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 ────────────────────── */ @@ -1251,7 +1311,7 @@ contract SlisBNBV3ProviderTest is Test { // minAmount0 = total0 (exact), minAmount1 = 0 (position has no WBNB). vm.prank(bot); - provider.rebalance(total0, 0, 0, block.timestamp); + provider.rebalance(total0, 0, 0, block.timestamp, ""); assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); } @@ -1269,7 +1329,7 @@ contract SlisBNBV3ProviderTest is Test { // minAmount0 one unit above actual → should revert with NPM slippage check. vm.prank(bot); vm.expectRevert(); - provider.rebalance(total0 + 1, 0, 0, block.timestamp); + provider.rebalance(total0 + 1, 0, 0, block.timestamp, ""); } /// @dev When price is above range the position is 100% WBNB (token1). @@ -1287,7 +1347,7 @@ contract SlisBNBV3ProviderTest is Test { // minAmount0 = 0 (no slisBNB), minAmount1 = total1 (exact). vm.prank(bot); - provider.rebalance(0, total1, 0, block.timestamp); + provider.rebalance(0, total1, 0, block.timestamp, ""); assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); } @@ -1305,7 +1365,7 @@ contract SlisBNBV3ProviderTest is Test { // minAmount1 one unit above actual → should revert with NPM slippage check. vm.prank(bot); vm.expectRevert(); - provider.rebalance(0, total1 + 1, 0, block.timestamp); + provider.rebalance(0, total1 + 1, 0, block.timestamp, ""); } function test_withdraw_minAmount_tooHigh_reverts() public { @@ -1854,7 +1914,7 @@ contract SlisBNBV3ProviderTest is Test { adapter.setCenterRateThresholdBps(0); vm.prank(bot); - provider.rebalance(0, 0, 0, block.timestamp); + provider.rebalance(0, 0, 0, block.timestamp, ""); assertLt(adapter.tickLower(), adapter.tickUpper(), "tick range remains valid"); } @@ -1869,7 +1929,7 @@ contract SlisBNBV3ProviderTest is Test { adapter.setCenterRateThresholdBps(0); vm.prank(bot); - provider.rebalance(0, 0, 0, block.timestamp); + 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 index 9a56e745..4addd88e 100644 --- a/test/provider/SlisBNBV3ProviderRate.t.sol +++ b/test/provider/SlisBNBV3ProviderRate.t.sol @@ -66,7 +66,7 @@ contract PoolSwapper { /// (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. +/// 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; @@ -328,7 +328,7 @@ contract SlisBNBV3ProviderRateTest is Test { adapter.setCenterRateThresholdBps(0); vm.prank(bot); - provider.rebalance(0, 0, 0, block.timestamp); + 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"); @@ -341,7 +341,7 @@ contract SlisBNBV3ProviderRateTest is Test { vm.prank(bot); vm.expectRevert(V3DexAdapter.RateDeviationBelowThreshold.selector); - provider.rebalance(0, 0, 0, block.timestamp); + provider.rebalance(0, 0, 0, block.timestamp, ""); } function test_rebalance_revertsAfterDeadline() public { @@ -349,7 +349,7 @@ contract SlisBNBV3ProviderRateTest is Test { vm.prank(bot); vm.expectRevert(V3DexAdapter.DeadlineExpired.selector); - provider.rebalance(0, 0, 0, block.timestamp - 1); + provider.rebalance(0, 0, 0, block.timestamp - 1, ""); } function test_rebalance_revertsWhenMinLiquidityTooHigh() public { @@ -360,7 +360,7 @@ contract SlisBNBV3ProviderRateTest is Test { vm.prank(bot); vm.expectRevert(V3DexAdapter.InsufficientLiquidityMinted.selector); - provider.rebalance(0, 0, type(uint256).max, block.timestamp); + provider.rebalance(0, 0, type(uint256).max, block.timestamp, ""); } function _tick() internal view returns (int24 tick) { diff --git a/test/provider/WstETHV3Provider.t.sol b/test/provider/WstETHV3Provider.t.sol index ad0661f6..55e475e8 100644 --- a/test/provider/WstETHV3Provider.t.sol +++ b/test/provider/WstETHV3Provider.t.sol @@ -440,7 +440,7 @@ contract WstETHV3ProviderTest is Test { bytes memory data = _swapData(address(rogue), true, 0.5 ether, 0, inner); vm.prank(bot); - vm.expectRevert(WstETHV3DexAdapter.NotWhitelistedPair.selector); + vm.expectRevert(V3DexAdapter.NotWhitelistedPair.selector); provider.rebalance(0, 0, 0, block.timestamp, data); } @@ -485,13 +485,13 @@ contract WstETHV3ProviderTest is Test { function test_setSwapPairWhitelist_rejectsSensitiveAddresses() public { address npm = address(adapter.POSITION_MANAGER()); vm.startPrank(manager); - vm.expectRevert(WstETHV3DexAdapter.InvalidSwapPair.selector); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); adapter.setSwapPairWhitelist(WSTETH, true); - vm.expectRevert(WstETHV3DexAdapter.InvalidSwapPair.selector); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); adapter.setSwapPairWhitelist(WETH, true); - vm.expectRevert(WstETHV3DexAdapter.InvalidSwapPair.selector); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); adapter.setSwapPairWhitelist(POOL, true); - vm.expectRevert(WstETHV3DexAdapter.InvalidSwapPair.selector); + vm.expectRevert(V3DexAdapter.InvalidSwapPair.selector); adapter.setSwapPairWhitelist(npm, true); vm.stopPrank(); } From 110544000c86e8bdf36971e7b7cdb3ec93a672f7 Mon Sep 17 00:00:00 2001 From: razww <153905091+razww@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:21:30 +0800 Subject: [PATCH 17/17] fix(provider): native-in/out swap settlement + non-18-decimal support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on the unified DEX-agnostic rebalance swap (9ba9766) so the V3 LP base is correct for native-settling venues and non-18-decimal tokens. - SwapInventoryLib native-aware both directions: the swapData blob gains bool nativeIn — now (swapPair, sellToken0, amountIn, amountOutMin, nativeIn, innerSwapData). nativeIn ⇒ the wrapped-native input leg is unwrapped and forwarded as msg.value (supports StakeManager.deposit{value} for WBNB→slisBNB). Any native a venue delivers (e.g. instantWithdraw → BNB) is wrapped back into the wrapped-native ERC20 before amountOutMin is measured — never stranded; reverts UnexpectedNative if neither leg is the wrapped-native. - Non-18-decimal paired token: _sqrtPriceX96FromRate now scales the raw pool price by 10^(DECIMALS1 - DECIMALS0) instead of assuming equal decimals (pure→view; cascaded to _tickRangeForRate). The wrapped-native stays 18-dec; the paired token may have any decimals. - Oracle share-decimal correctness: V3ProviderOracle quotes peek(share) per ONE WHOLE share (10 ** SHARE_DECIMALS, read from the share token) instead of hardcoded 1e18, matching Moolah._getPrice which uses collateralToken.decimals(). No-op for the current 18-dec accounting assets. Tests: 263 passing — new V3DexAdapterDecimals.t.sol (non-18-dec USDC(6)/WETH(18) + 18/18) proves the sqrtPrice decimal math; slisBNB depositAsSwapVenue (nativeIn), wrongNativeOutput_reverts, instantWithdraw no-stranded-native; swapData blobs updated to the 6-field shape. Backend encoder must add nativeIn. Co-Authored-By: Claude Opus 4.8 --- src/provider/libraries/SwapInventoryLib.sol | 62 ++++++++++------- src/provider/v3/V3DexAdapter.sol | 35 ++++++---- src/provider/v3/V3ProviderOracle.sol | 11 ++- test/provider/SlisBNBV3Provider.t.sol | 76 +++++++++++++++++++-- test/provider/V3DexAdapterDecimals.t.sol | 61 +++++++++++++++++ test/provider/WstETHV3Provider.t.sol | 5 +- 6 files changed, 206 insertions(+), 44 deletions(-) create mode 100644 test/provider/V3DexAdapterDecimals.t.sol diff --git a/src/provider/libraries/SwapInventoryLib.sol b/src/provider/libraries/SwapInventoryLib.sol index 854195af..f053a233 100644 --- a/src/provider/libraries/SwapInventoryLib.sol +++ b/src/provider/libraries/SwapInventoryLib.sol @@ -10,20 +10,24 @@ import { IWBNB } from "../interfaces/IWBNB.sol"; * @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 / …); the adapter just forwards it via a - * low-level `swapPair.call(swapData)` and enforces the result with MEASURED balance deltas — - * `spent <= amountIn` and `received >= amountOutMin`. No on-chain routing or price math. + * (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. * - * A venue may settle the wrapped-native leg as the NATIVE coin instead of the ERC20 (e.g. a Lista - * StakeManager `instantWithdraw` that pays out BNB). When the output leg is the wrapped-native - * token, any native delivered by the call is wrapped back into it before `received` is measured — - * so instant-redeem venues are supported without any special-casing in the adapter. + * 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`/ - * `swapData` come from the backend. `amountIn` is capped to the available balance, and the allowance - * to `swapPair` is set to `amountIn` then reset to 0 after the call. + * `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; @@ -31,11 +35,12 @@ library SwapInventoryLib { error SwapFailed(); error ExceedAmountIn(); error InsufficientOutput(); + error UnexpectedNative(); /// @notice Execute one backend-built swap. `sellToken0` ⇒ sell token0 for token1, else token1 for - /// token0. `wrappedNative` is the adapter's wrapped-native token: native delivered by the - /// venue for the wrapped-native output leg is wrapped into it. Returns (total0, total1) - /// adjusted by the MEASURED spent/received deltas. + /// 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, @@ -46,7 +51,8 @@ library SwapInventoryLib { bytes memory swapData, uint256 total0, uint256 total1, - address wrappedNative + address wrappedNative, + bool nativeIn ) external returns (uint256, uint256) { if (amountIn == 0) return (total0, total1); @@ -61,17 +67,27 @@ library SwapInventoryLib { uint256 beforeOut = IERC20(tokenOut).balanceOf(address(this)); uint256 beforeNative = address(this).balance; - IERC20(tokenIn).forceApprove(swapPair, amountIn); - (bool ok, ) = swapPair.call(swapData); - if (!ok) revert SwapFailed(); - IERC20(tokenIn).forceApprove(swapPair, 0); // clear any residual allowance + 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 + } - // A venue may settle the wrapped-native output leg as the native coin (e.g. StakeManager - // instantWithdraw → BNB). Wrap whatever native THIS call delivered so the tokenOut delta captures it. - if (tokenOut == wrappedNative) { - uint256 cur = address(this).balance; - uint256 nativeIn = cur > beforeNative ? cur - beforeNative : 0; - if (nativeIn > 0) IWBNB(wrappedNative).deposit{ value: nativeIn }(); + // 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)); diff --git a/src/provider/v3/V3DexAdapter.sol b/src/provider/v3/V3DexAdapter.sol index 067a64a6..91bca6b5 100644 --- a/src/provider/v3/V3DexAdapter.sol +++ b/src/provider/v3/V3DexAdapter.sol @@ -580,7 +580,7 @@ abstract contract V3DexAdapter is function _tickRangeForRate( uint256 centerRate, int24 tickSpacing - ) internal pure returns (int24 initialTickLower, int24 initialTickUpper) { + ) 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); @@ -595,8 +595,19 @@ abstract contract V3DexAdapter is if ((delta * BPS) / previousCenterRate < thresholdBps) revert RateDeviationBelowThreshold(); } - function _sqrtPriceX96FromRate(uint256 rate) internal pure returns (uint160) { - return uint160(Math.sqrt(FullMath.mulDiv(rate, 1 << 192, 1e18))); + /// @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) { @@ -636,10 +647,11 @@ abstract contract V3DexAdapter is /// @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, bytes innerSwapData): - /// the adapter requires `swapPair` whitelisted and forwards `innerSwapData` via a low-level call, - /// bounding the swap by the allowance + the backend's `amountOutMin` (see {SwapInventoryLib}). - /// Empty swapData ⇒ recenter without converting inventory (also the TWAP-pair default). + /// (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, @@ -649,10 +661,8 @@ abstract contract V3DexAdapter is bytes calldata swapData ) internal virtual returns (uint256, uint256) { if (swapData.length == 0) return (total0, total1); - (address swapPair, bool sellToken0, uint256 amountIn, uint256 amountOutMin, bytes memory inner) = abi.decode( - swapData, - (address, bool, uint256, uint256, bytes) - ); + (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( @@ -665,7 +675,8 @@ abstract contract V3DexAdapter is inner, total0, total1, - WRAPPED_NATIVE + WRAPPED_NATIVE, + nativeIn ); } diff --git a/src/provider/v3/V3ProviderOracle.sol b/src/provider/v3/V3ProviderOracle.sol index 30b83f2f..41fd4da9 100644 --- a/src/provider/v3/V3ProviderOracle.sol +++ b/src/provider/v3/V3ProviderOracle.sol @@ -39,6 +39,11 @@ contract V3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgradeable 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%). @@ -81,6 +86,7 @@ contract V3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgradeable TOKEN1 = _token1; DECIMALS0 = IERC20Metadata(_token0).decimals(); DECIMALS1 = IERC20Metadata(_token1).decimals(); + SHARE_DECIMALS = IERC20Metadata(_providerShare).decimals(); _disableInitializers(); } @@ -135,8 +141,9 @@ contract V3ProviderOracle is UUPSUpgradeable, AccessControlEnumerableUpgradeable uint256 totalValue = (total0 * price0) / (10 ** DECIMALS0) + (total1 * price1) / (10 ** DECIMALS1); if (totalValue == 0) revert ZeroPrice(); // finding D - // 8-decimal USD price per 1e18 shares, minus the conservative haircut. - uint256 raw = (totalValue * 1e18) / supply; + // 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; } diff --git a/test/provider/SlisBNBV3Provider.t.sol b/test/provider/SlisBNBV3Provider.t.sol index dc1cc504..def5c70c 100644 --- a/test/provider/SlisBNBV3Provider.t.sol +++ b/test/provider/SlisBNBV3Provider.t.sol @@ -120,6 +120,18 @@ contract MockSwap { } } +/// @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. @@ -1126,7 +1138,7 @@ contract SlisBNBV3ProviderTest is Test { 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, inner); + 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); @@ -1143,7 +1155,7 @@ contract SlisBNBV3ProviderTest is Test { 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), inner); + bytes memory data = abi.encode(address(rogue), true, uint256(0.5 ether), uint256(0), false, inner); vm.prank(bot); vm.expectRevert(V3DexAdapter.NotWhitelistedPair.selector); @@ -1185,7 +1197,7 @@ contract SlisBNBV3ProviderTest is Test { 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, inner); + 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); @@ -1197,6 +1209,7 @@ contract SlisBNBV3ProviderTest is Test { 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 @@ -1213,7 +1226,7 @@ contract SlisBNBV3ProviderTest is Test { // 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, inner); + bytes memory data = abi.encode(address(mockSwap), true, amountIn, fairOut, false, inner); vm.prank(bot); vm.expectRevert(SwapInventoryLib.InsufficientOutput.selector); @@ -1233,13 +1246,66 @@ contract SlisBNBV3ProviderTest is Test { 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, inner); + 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. 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/WstETHV3Provider.t.sol b/test/provider/WstETHV3Provider.t.sol index 55e475e8..d76e8bb4 100644 --- a/test/provider/WstETHV3Provider.t.sol +++ b/test/provider/WstETHV3Provider.t.sol @@ -200,7 +200,8 @@ contract WstETHV3ProviderTest is Test { } /// @dev Encode the backend rebalance blob the adapter decodes: (swapPair, sellToken0, amountIn, - /// amountOutMin, innerSwapData). Empty blob ⇒ recenter without converting inventory. + /// 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, @@ -208,7 +209,7 @@ contract WstETHV3ProviderTest is Test { uint256 amountOutMin, bytes memory inner ) internal pure returns (bytes memory) { - return abi.encode(swapPair, sellToken0, amountIn, amountOutMin, inner); + return abi.encode(swapPair, sellToken0, amountIn, amountOutMin, false, inner); } /// @dev Inner calldata the adapter low-level-calls on the whitelisted MockSwap: pull `amountIn` of