From 3ec19b09dbcc6ca8491cb647426b91bea448b79f Mon Sep 17 00:00:00 2001 From: Razorback Date: Mon, 23 Mar 2026 21:18:24 +0800 Subject: [PATCH] feat: V3Provider --- .gitmodules | 6 + foundry.lock | 12 + foundry.toml | 4 + lib/v3-core | 1 + lib/v3-periphery | 1 + src/provider/V3Provider.sol | 830 +++++++++++++ .../INonfungiblePositionManager.sol | 81 ++ src/provider/interfaces/IUniswapV3Factory.sol | 8 + src/provider/interfaces/IUniswapV3Pool.sol | 47 + src/provider/interfaces/IV3Provider.sol | 60 + test/provider/V3Provider.t.sol | 1047 +++++++++++++++++ 11 files changed, 2097 insertions(+) create mode 160000 lib/v3-core create mode 160000 lib/v3-periphery 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/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/provider/V3Provider.sol b/src/provider/V3Provider.sol new file mode 100644 index 00000000..90f5c23c --- /dev/null +++ b/src/provider/V3Provider.sol @@ -0,0 +1,830 @@ +// 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"; + +/** + * @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 +{ + 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; + + /* ──────────────────────────── 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; + + bytes32 public constant MANAGER = keccak256("MANAGER"); + bytes32 public constant BOT = keccak256("BOT"); + + /* ───────────────────────────── events ───────────────────────────── */ + + event Deposit( + address indexed onBehalf, + uint256 amount0Used, + uint256 amount1Used, + uint256 shares, + Id indexed marketId + ); + event Withdraw( + address indexed onBehalf, + uint256 shares, + uint256 amount0, + uint256 amount1, + address receiver, + Id indexed marketId + ); + event 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; + + _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 nonReentrant returns (uint256 shares, uint256 amount0Used, uint256 amount1Used) { + require(marketParams.collateralToken == address(this), "invalid collateral token"); + require(amount0Desired > 0 || amount1Desired > 0, "zero amounts"); + require(onBehalf != address(0), "zero address"); + + // 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 tokens from caller + if (amount0Desired > 0) IERC20(TOKEN0).safeTransferFrom(msg.sender, address(this), amount0Desired); + if (amount1Desired > 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). + uint256 refund0 = amount0Desired - amount0Used; + uint256 refund1 = amount1Desired - amount1Used; + if (refund0 > 0) IERC20(TOKEN0).safeTransfer(msg.sender, refund0); + if (refund1 > 0) IERC20(TOKEN1).safeTransfer(msg.sender, refund1); + + // 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, ""); + + 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)); + + _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. + * V3Provider has no per-user state to update, so this is a no-op. + * Moolah already transferred the seized shares to the liquidator via transfer(). + */ + function liquidate(Id, address) external { + require(msg.sender == address(MOOLAH), "only moolah"); + } + + /* ───────────────────── 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. + * Includes amounts locked in the V3 position (computed from current sqrtPriceX96), + * uncollected fees (tokensOwed), and any idle token balances held by this contract. + * @dev Used by V3ProviderOracle to price shares. The V3 amounts are derived from + * the pool's slot0 price — the oracle should apply its own TWAP-based prices + * to the returned quantities. + */ + function getTotalAmounts() public view returns (uint256 total0, uint256 total1) { + if (tokenId == 0) return (0, 0); + + (, , , , , , , uint128 liquidity, , , uint128 tokensOwed0, uint128 tokensOwed1) = POSITION_MANAGER.positions( + tokenId + ); + + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(POOL).slot0(); + + (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; + } + + /** + * @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. + */ + 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; + + (uint256 total0, uint256 total1) = getTotalAmounts(); + + uint256 price0 = IOracle(resilientOracle).peek(TOKEN0); // 8 decimals + uint256 price1 = IOracle(resilientOracle).peek(TOKEN1); // 8 decimals + + uint256 decimals0 = IERC20Metadata(TOKEN0).decimals(); + uint256 decimals1 = IERC20Metadata(TOKEN1).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. + */ + function getTwapTick() external 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--; + } + + /* ─────────────────────────── 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) = 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, and send to `receiver`. + 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 + }) + ); + + (amount0, amount1) = POSITION_MANAGER.collect( + INonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: receiver, + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + } + } + + /// @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 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..cfb605fb --- /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 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/provider/V3Provider.t.sol b/test/provider/V3Provider.t.sol new file mode 100644 index 00000000..4615b0fd --- /dev/null +++ b/test/provider/V3Provider.t.sol @@ -0,0 +1,1047 @@ +// 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"; + +/// @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(address(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. + assertEq(IERC20(USDC).balanceOf(user), amount0 - used0); + assertEq(IERC20(WBNB).balanceOf(user), 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 wbnbBefore = IERC20(WBNB).balanceOf(user); + + (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(IERC20(WBNB).balanceOf(user), wbnbBefore + out1); + } + + 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 wbnbBefore = IERC20(WBNB).balanceOf(liquidator); + + (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(IERC20(WBNB).balanceOf(liquidator), wbnbBefore + out1); + } + + 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 + ); + + (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"); + } +}