diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dad3183..8127ce0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Build contracts run: | forge --version - forge build --skip test --sizes + forge build --sizes ./src test: runs-on: ubuntu-latest diff --git a/.gitmodules b/.gitmodules index 5ef5bd7f..fa78450c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -47,3 +47,9 @@ path = lib/spark-vaults-v2 url = https://github.com/sparkdotfi/spark-vaults-v2 branch = dev +[submodule "lib/uniswap-v4-periphery"] + path = lib/uniswap-v4-periphery + url = https://github.com/Uniswap/v4-periphery.git +[submodule "lib/uniswap-v4-core"] + path = lib/uniswap-v4-core + url = https://github.com/Uniswap/v4-core.git diff --git a/README.md b/README.md index 9c17caba..b60160ef 100644 --- a/README.md +++ b/README.md @@ -140,12 +140,14 @@ Below are all stated trust assumptions for using this contract in production: - Assume that the funds return to the OTC Buffer contract via transfer. This is to accommodate most exchanges/OTC desks that only have the ability to complete the swap by sending token to an address (i.e. not being able to make any arbitrary contracts calls outside of the ERC20 spec). - The maximum loss by the protocol is limited to the single outstanding OTC swap amount for a given exchange. - The recharge rate is configured to be low enough that the system will not practically allow for multiple swaps in a row without receiving material funds from the exchange. +- Ethena's delegated signer role can be set by the RELAYER. The delegated signer role can technically be set by a malicious relayer to be a malicious actor. Ethena's API's [Order Validity Checks](https://docs.ethena.fi/solution-design/minting-usde/order-validity-checks) is trusted to prevent attacks in this scenario. ## Operational Requirements - All ERC-4626 vaults that are onboarded MUST have an initial burned shares amount that prevents rounding-based frontrunning attacks. These shares have to be unrecoverable so that they cannot be removed at a later date. - All ERC-20 tokens are to be non-rebasing with sufficiently high decimal precision. - Rate limits must be configured for specific ERC-4626 vaults and AAVE aTokens (vaults without rate limits set will revert). Unlimited rate limits can be used as an onboarding tool. +- All Uniswap V4 pool onboardings are to be done with 1:1 assets. - Rate limits must take into account: - Risk tolerance for a given protocol - Griefing attacks (e.g., repetitive transactions with high slippage by malicious relayer). diff --git a/audits/v190-cantina-audit.pdf b/audits/v190-cantina-audit.pdf new file mode 100644 index 00000000..2fc59c01 Binary files /dev/null and b/audits/v190-cantina-audit.pdf differ diff --git a/audits/v190-certora-audit.pdf b/audits/v190-certora-audit.pdf new file mode 100644 index 00000000..33b1615c Binary files /dev/null and b/audits/v190-certora-audit.pdf differ diff --git a/foundry.toml b/foundry.toml index 51720e59..8927e91b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -18,6 +18,7 @@ remappings = [ '@layerzerolabs/lz-evm-messagelib-v2/=lib/layerzero-v2/packages/layerzero-v2/evm/messagelib/', 'solidity-bytes-utils/=lib/solidity-bytes-utils/', 'forge-std/=lib/forge-std/src/', + '@uniswap/v4-core/=lib/uniswap-v4-core/' ] [fuzz] diff --git a/lib/uniswap-v4-core b/lib/uniswap-v4-core new file mode 160000 index 00000000..e50237c4 --- /dev/null +++ b/lib/uniswap-v4-core @@ -0,0 +1 @@ +Subproject commit e50237c43811bd9b526eff40f26772152a42daba diff --git a/lib/uniswap-v4-periphery b/lib/uniswap-v4-periphery new file mode 160000 index 00000000..3779387e --- /dev/null +++ b/lib/uniswap-v4-periphery @@ -0,0 +1 @@ +Subproject commit 3779387e5d296f39df543d23524b050f89a62917 diff --git a/script/input/1/arbitrum_one-production.json b/script/input/1/arbitrum_one-production.json index 7c1d7c70..51673293 100644 --- a/script/input/1/arbitrum_one-production.json +++ b/script/input/1/arbitrum_one-production.json @@ -6,5 +6,7 @@ "freezer": "0x90D8c80C028B4C09C0d8dcAab9bbB057F0513431", "usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "usds": "0x6491c05A82219b8D1479057361ff1654749b876b", - "susds": "0xdDb46999F8891663a8F2828d25298f70416d7610" + "susds": "0xdDb46999F8891663a8F2828d25298f70416d7610", + "almProxy": "0x92afd6F2385a90e44da3a8B60fe36f6cBe1D8709", + "rateLimits": "0x19D08879851FB54C2dCc4bb32b5a1EA5E9Ad6838" } diff --git a/script/input/1/avalanche-production.json b/script/input/1/avalanche-production.json index 103c001b..886b5ae7 100644 --- a/script/input/1/avalanche-production.json +++ b/script/input/1/avalanche-production.json @@ -4,5 +4,7 @@ "psm": "0x0000000000000000000000000000000000000000", "relayer": "0x8a25A24EDE9482C4Fc0738F99611BE58F1c839AB", "freezer": "0x90D8c80C028B4C09C0d8dcAab9bbB057F0513431", - "usdc": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E" + "usdc": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + "rateLimits": "0xb79972e8B21f0dE911E65AC342ac85ad38C9A77a", + "almProxy": "0xecE6B0E8a54c2f44e066fBb9234e7157B15b7FeC" } diff --git a/script/input/1/optimism-production.json b/script/input/1/optimism-production.json index 6466d3c4..8752e532 100644 --- a/script/input/1/optimism-production.json +++ b/script/input/1/optimism-production.json @@ -6,5 +6,7 @@ "freezer": "0x90D8c80C028B4C09C0d8dcAab9bbB057F0513431", "usdc": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", "usds": "0x4F13a96EC5C4Cf34e442b46Bbd98a0791F20edC3", - "susds": "0xb5B2dc7fd34C249F4be7fB1fCea07950784229e0" + "susds": "0xb5B2dc7fd34C249F4be7fB1fCea07950784229e0", + "rateLimits": "0x6B34A6B84444dC3Fc692821D5d077a1e4927342d", + "almProxy": "0x876664f0c9Ff24D1aa355Ce9f1680AE1A5bf36fB" } diff --git a/script/input/1/unichain-production.json b/script/input/1/unichain-production.json index bdde7e36..9339e53b 100644 --- a/script/input/1/unichain-production.json +++ b/script/input/1/unichain-production.json @@ -6,5 +6,7 @@ "freezer": "0x90D8c80C028B4C09C0d8dcAab9bbB057F0513431", "usdc": "0x078D782b760474a361dDA0AF3839290b0EF57AD6", "usds": "0x7E10036Acc4B56d4dFCa3b77810356CE52313F9C", - "susds": "0xA06b10Db9F390990364A3984C04FaDf1c13691b5" + "susds": "0xA06b10Db9F390990364A3984C04FaDf1c13691b5", + "rateLimits": "0x5A1a44D2192Dd1e21efB9caA50E32D0716b35535", + "almProxy": "0x345E368fcCd62266B3f5F37C9a131FD1c39f5869" } diff --git a/src/ForeignController.sol b/src/ForeignController.sol index ffa834bc..2893cd3c 100644 --- a/src/ForeignController.sol +++ b/src/ForeignController.sol @@ -161,10 +161,8 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable { } function setMaxExchangeRate(address token, uint256 shares, uint256 maxExpectedAssets) - external nonReentrant + external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { - _checkRole(DEFAULT_ADMIN_ROLE); - require(token != address(0), "FC/token-zero-address"); emit MaxExchangeRateSet( @@ -187,13 +185,14 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable { /**********************************************************************************************/ function transferAsset(address asset, address destination, uint256 amount) - external nonReentrant onlyRole(RELAYER) - { - _rateLimited( + external + nonReentrant + onlyRole(RELAYER) + rateLimited( RateLimitHelpers.makeAddressAddressKey(LIMIT_ASSET_TRANSFER, asset, destination), amount - ); - + ) + { bytes memory returnData = proxy.doCall( asset, abi.encodeCall(IERC20(asset).transfer, (destination, amount)) @@ -298,13 +297,18 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable { uint256 amount, uint32 destinationEndpointId ) - external payable nonReentrant - { - _checkRole(RELAYER); - _rateLimited( + external + payable + nonReentrant + onlyRole(RELAYER) + rateLimited( keccak256(abi.encode(LIMIT_LAYERZERO_TRANSFER, oftAddress, destinationEndpointId)), amount - ); + ) + { + bytes32 recipient = layerZeroRecipients[destinationEndpointId]; + + require(recipient != bytes32(0), "FC/recipient-not-set"); // NOTE: Full integration testing of this logic is not possible without OFTs with // approvalRequired == true. Add integration testing for this case before @@ -317,7 +321,7 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable { SendParam memory sendParams = SendParam({ dstEid : destinationEndpointId, - to : layerZeroRecipients[destinationEndpointId], + to : recipient, amountLD : amount, minAmountLD : 0, extraOptions : options, @@ -478,46 +482,6 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable { ); } - /**********************************************************************************************/ - /*** Relayer Morpho functions ***/ - /**********************************************************************************************/ - - function setSupplyQueueMorpho(address morphoVault, Id[] memory newSupplyQueue) - external - nonReentrant - onlyRole(RELAYER) - rateLimitExists(RateLimitHelpers.makeAddressKey(LIMIT_4626_DEPOSIT, morphoVault)) - { - proxy.doCall( - morphoVault, - abi.encodeCall(IMetaMorpho(morphoVault).setSupplyQueue, (newSupplyQueue)) - ); - } - - function updateWithdrawQueueMorpho(address morphoVault, uint256[] calldata indexes) - external - nonReentrant - onlyRole(RELAYER) - rateLimitExists(RateLimitHelpers.makeAddressKey(LIMIT_4626_DEPOSIT, morphoVault)) - { - proxy.doCall( - morphoVault, - abi.encodeCall(IMetaMorpho(morphoVault).updateWithdrawQueue, (indexes)) - ); - } - - function reallocateMorpho(address morphoVault, MarketAllocation[] calldata allocations) - external - nonReentrant - onlyRole(RELAYER) - rateLimitExists(RateLimitHelpers.makeAddressKey(LIMIT_4626_DEPOSIT, morphoVault)) - { - proxy.doCall( - morphoVault, - abi.encodeCall(IMetaMorpho(morphoVault).reallocate, (allocations)) - ); - } - /**********************************************************************************************/ /*** Spark Vault functions ***/ /**********************************************************************************************/ @@ -597,10 +561,6 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable { emit CCTPTransferInitiated(nonce, destinationDomain, mintRecipient, usdcAmount); } - function _rateLimited(bytes32 key, uint256 amount) internal { - rateLimits.triggerRateLimitDecrease(key, amount); - } - /**********************************************************************************************/ /*** Exchange rate helper functions ***/ /**********************************************************************************************/ diff --git a/src/MainnetController.sol b/src/MainnetController.sol index d1078b36..af97c73e 100644 --- a/src/MainnetController.sol +++ b/src/MainnetController.sol @@ -1,8 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.21; -import { IAToken } from "aave-v3-origin/src/core/contracts/interfaces/IAToken.sol"; -import { IPool as IAavePool } from "aave-v3-origin/src/core/contracts/interfaces/IPool.sol"; +import { IAToken } from "aave-v3-origin/src/core/contracts/interfaces/IAToken.sol"; import { AccessControlEnumerable } from "../lib/openzeppelin-contracts/contracts/access/extensions/AccessControlEnumerable.sol"; import { ReentrancyGuard } from "../lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; @@ -17,11 +16,15 @@ import { IALMProxy } from "./interfaces/IALMProxy.sol"; import { ICCTPLike } from "./interfaces/CCTPInterfaces.sol"; import { IRateLimits } from "./interfaces/IRateLimits.sol"; -import "./interfaces/ILayerZero.sol"; +import { ILayerZero, SendParam, OFTReceipt, MessagingFee } from "./interfaces/ILayerZero.sol"; +import { ApproveLib } from "./libraries/ApproveLib.sol"; +import { AaveLib } from "./libraries/AaveLib.sol"; import { CCTPLib } from "./libraries/CCTPLib.sol"; import { CurveLib } from "./libraries/CurveLib.sol"; +import { ERC4626Lib } from "./libraries/ERC4626Lib.sol"; import { IDaiUsdsLike, IPSMLike, PSMLib } from "./libraries/PSMLib.sol"; +import { UniswapV4Lib } from "./libraries/UniswapV4Lib.sol"; import { OptionsBuilder } from "layerzerolabs/oapp-evm/contracts/oapp/libs/OptionsBuilder.sol"; @@ -81,18 +84,18 @@ interface IWstETHLike { function getStETHByWstETH(uint256 _wstETHAmount) external view returns (uint256); } -struct OTC { - address buffer; - uint256 rechargeRate18; - uint256 sent18; - uint256 sentTimestamp; - uint256 claimed18; -} - contract MainnetController is ReentrancyGuard, AccessControlEnumerable { using OptionsBuilder for bytes; + struct OTC { + address buffer; + uint256 rechargeRate18; + uint256 sent18; + uint256 sentTimestamp; + uint256 claimed18; + } + /**********************************************************************************************/ /*** Events ***/ /**********************************************************************************************/ @@ -127,13 +130,17 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { bool isWhitelisted ); event RelayerRemoved(address indexed relayer); + event UniswapV4TickLimitsSet( + bytes32 indexed poolId, + int24 tickLowerMin, + int24 tickUpperMax, + uint24 maxTickSpacing + ); /**********************************************************************************************/ /*** State variables ***/ /**********************************************************************************************/ - uint256 public constant EXCHANGE_RATE_PRECISION = 1e36; - bytes32 public FREEZER = keccak256("FREEZER"); bytes32 public RELAYER = keccak256("RELAYER"); @@ -153,6 +160,9 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { bytes32 public LIMIT_SPARK_VAULT_TAKE = keccak256("LIMIT_SPARK_VAULT_TAKE"); bytes32 public LIMIT_SUPERSTATE_SUBSCRIBE = keccak256("LIMIT_SUPERSTATE_SUBSCRIBE"); bytes32 public LIMIT_SUSDE_COOLDOWN = keccak256("LIMIT_SUSDE_COOLDOWN"); + bytes32 public LIMIT_UNISWAP_V4_DEPOSIT = UniswapV4Lib.LIMIT_DEPOSIT; + bytes32 public LIMIT_UNISWAP_V4_WITHDRAW = UniswapV4Lib.LIMIT_WITHDRAW; + bytes32 public LIMIT_UNISWAP_V4_SWAP = UniswapV4Lib.LIMIT_SWAP; bytes32 public LIMIT_USDC_TO_CCTP = keccak256("LIMIT_USDC_TO_CCTP"); bytes32 public LIMIT_USDC_TO_DOMAIN = keccak256("LIMIT_USDC_TO_DOMAIN"); bytes32 public LIMIT_USDE_BURN = keccak256("LIMIT_USDE_BURN"); @@ -194,6 +204,9 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { // ERC4626 exchange rate thresholds (1e36 precision) mapping(address token => uint256 maxExchangeRate) public maxExchangeRates; + // Uniswap V4 tick ranges + mapping(bytes32 poolId => UniswapV4Lib.TickLimits tickLimits) public uniswapV4TickLimits; + /**********************************************************************************************/ /*** Initialization ***/ /**********************************************************************************************/ @@ -303,10 +316,35 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { emit MaxExchangeRateSet( token, - maxExchangeRates[token] = _getExchangeRate(shares, maxExpectedAssets) + maxExchangeRates[token] = ERC4626Lib.getExchangeRate(shares, maxExpectedAssets) ); } + function setUniswapV4TickLimits( + bytes32 poolId, + int24 tickLowerMin, + int24 tickUpperMax, + uint24 maxTickSpacing + ) + external nonReentrant + { + _checkRole(DEFAULT_ADMIN_ROLE); + + require( + ((tickLowerMin == 0) && (tickUpperMax == 0) && (maxTickSpacing == 0)) || + ((maxTickSpacing > 0) && (tickLowerMin < tickUpperMax)), + "MC/invalid-ticks" + ); + + uniswapV4TickLimits[poolId] = UniswapV4Lib.TickLimits({ + tickLowerMin : tickLowerMin, + tickUpperMax : tickUpperMax, + maxTickSpacing : maxTickSpacing + }); + + emit UniswapV4TickLimitsSet(poolId, tickLowerMin, tickUpperMax, maxTickSpacing); + } + /**********************************************************************************************/ /*** Freezer functions ***/ /**********************************************************************************************/ @@ -456,67 +494,49 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { external nonReentrant returns (uint256 shares) { _checkRole(RELAYER); - _rateLimitedAddress(LIMIT_4626_DEPOSIT, token, amount); - - // Approve asset to token from the proxy (assumes the proxy has enough of the asset). - _approve(IERC4626(token).asset(), token, amount); - // Deposit asset into the token, proxy receives token shares, decode the resulting shares. - shares = abi.decode( - proxy.doCall( - token, - abi.encodeCall(IERC4626(token).deposit, (amount, address(proxy))) - ), - (uint256) - ); - - require( - _getExchangeRate(shares, amount) <= maxExchangeRates[token], - "MC/exchange-rate-too-high" - ); + return ERC4626Lib.deposit({ + proxy : address(proxy), + token : token, + amount : amount, + maxExchangeRate : maxExchangeRates[token], + rateLimits : address(rateLimits), + rateLimitId : LIMIT_4626_DEPOSIT + }); } function withdrawERC4626(address token, uint256 amount) external nonReentrant returns (uint256 shares) { _checkRole(RELAYER); - _rateLimitedAddress(LIMIT_4626_WITHDRAW, token, amount); - - // Withdraw asset from a token, decode resulting shares. - // Assumes proxy has adequate token shares. - shares = abi.decode( - proxy.doCall( - token, - abi.encodeCall(IERC4626(token).withdraw, (amount, address(proxy), address(proxy))) - ), - (uint256) - ); - _cancelRateLimit(RateLimitHelpers.makeAddressKey(LIMIT_4626_DEPOSIT, token), amount); + return ERC4626Lib.withdraw({ + proxy : address(proxy), + token : token, + amount : amount, + rateLimits : address(rateLimits), + withdrawRateLimitId : LIMIT_4626_WITHDRAW, + depositRateLimitId : LIMIT_4626_DEPOSIT + }); } - // NOTE: !!! Rate limited at end of function !!! function redeemERC4626(address token, uint256 shares) external nonReentrant returns (uint256 assets) { _checkRole(RELAYER); - // Redeem shares for assets from the token, decode the resulting assets. - // Assumes proxy has adequate token shares. - assets = abi.decode( - proxy.doCall( - token, - abi.encodeCall(IERC4626(token).redeem, (shares, address(proxy), address(proxy))) - ), - (uint256) - ); - - rateLimits.triggerRateLimitDecrease( - RateLimitHelpers.makeAddressKey(LIMIT_4626_WITHDRAW, token), - assets - ); + return ERC4626Lib.redeem({ + proxy : address(proxy), + token : token, + shares : shares, + rateLimits : address(rateLimits), + withdrawRateLimitId : LIMIT_4626_WITHDRAW, + depositRateLimitId : LIMIT_4626_DEPOSIT + }); + } - _cancelRateLimit(RateLimitHelpers.makeAddressKey(LIMIT_4626_DEPOSIT, token), assets); + function EXCHANGE_RATE_PRECISION() external pure returns (uint256) { + return ERC4626Lib.EXCHANGE_RATE_PRECISION; } /**********************************************************************************************/ @@ -525,62 +545,30 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { function depositAave(address aToken, uint256 amount) external nonReentrant { _checkRole(RELAYER); - _rateLimitedAddress(LIMIT_AAVE_DEPOSIT, aToken, amount); - - require(maxSlippages[aToken] != 0, "MC/max-slippage-not-set"); - - IERC20 underlying = IERC20(IATokenWithPool(aToken).UNDERLYING_ASSET_ADDRESS()); - IAavePool pool = IAavePool(IATokenWithPool(aToken).POOL()); - - // Approve underlying to Aave pool from the proxy (assumes the proxy has enough underlying). - _approve(address(underlying), address(pool), amount); - - uint256 aTokenBalance = IERC20(aToken).balanceOf(address(proxy)); - // Deposit underlying into Aave pool, proxy receives aTokens - proxy.doCall( - address(pool), - abi.encodeCall(pool.supply, (address(underlying), amount, address(proxy), 0)) - ); - - uint256 newATokens = IERC20(aToken).balanceOf(address(proxy)) - aTokenBalance; - - require( - newATokens >= amount * maxSlippages[aToken] / 1e18, - "MC/slippage-too-high" - ); + AaveLib.deposit({ + proxy : address(proxy), + aToken : aToken, + amount : amount, + maxSlippage : maxSlippages[aToken], + rateLimits : address(rateLimits), + rateLimitId : LIMIT_AAVE_DEPOSIT + }); } - // NOTE: !!! Rate limited at end of function !!! function withdrawAave(address aToken, uint256 amount) external nonReentrant returns (uint256 amountWithdrawn) { _checkRole(RELAYER); - IAavePool pool = IAavePool(IATokenWithPool(aToken).POOL()); - - // Withdraw underlying from Aave pool, decode resulting amount withdrawn. - // Assumes proxy has adequate aTokens. - amountWithdrawn = abi.decode( - proxy.doCall( - address(pool), - abi.encodeCall( - pool.withdraw, - (IATokenWithPool(aToken).UNDERLYING_ASSET_ADDRESS(), amount, address(proxy)) - ) - ), - (uint256) - ); - - rateLimits.triggerRateLimitDecrease( - RateLimitHelpers.makeAddressKey(LIMIT_AAVE_WITHDRAW, aToken), - amountWithdrawn - ); - - _cancelRateLimit( - RateLimitHelpers.makeAddressKey(LIMIT_AAVE_DEPOSIT, aToken), - amountWithdrawn - ); + return AaveLib.withdraw({ + proxy : address(proxy), + aToken : aToken, + amount : amount, + rateLimits : address(rateLimits), + rateLimitWithdrawId : LIMIT_AAVE_WITHDRAW, + rateLimitDepositId : LIMIT_AAVE_DEPOSIT + }); } /**********************************************************************************************/ @@ -648,6 +636,101 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { })); } + /**********************************************************************************************/ + /*** Uniswap V4 functions ***/ + /**********************************************************************************************/ + + function mintPositionUniswapV4( + bytes32 poolId, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 amount0Max, + uint256 amount1Max + ) + external nonReentrant + { + _checkRole(RELAYER); + + UniswapV4Lib.mintPosition({ + proxy : address(proxy), + rateLimits : address(rateLimits), + poolId : poolId, + tickLower : tickLower, + tickUpper : tickUpper, + liquidity : liquidity, + amount0Max : amount0Max, + amount1Max : amount1Max, + tickLimits : uniswapV4TickLimits + }); + } + + function increaseLiquidityUniswapV4( + bytes32 poolId, + uint256 tokenId, + uint128 liquidityIncrease, + uint256 amount0Max, + uint256 amount1Max + ) + external nonReentrant + { + _checkRole(RELAYER); + + UniswapV4Lib.increasePosition({ + proxy : address(proxy), + rateLimits : address(rateLimits), + poolId : poolId, + tokenId : tokenId, + liquidityIncrease : liquidityIncrease, + amount0Max : amount0Max, + amount1Max : amount1Max, + tickLimits : uniswapV4TickLimits + }); + } + + function decreaseLiquidityUniswapV4( + bytes32 poolId, + uint256 tokenId, + uint128 liquidityDecrease, + uint256 amount0Min, + uint256 amount1Min + ) + external nonReentrant + { + _checkRole(RELAYER); + + UniswapV4Lib.decreasePosition({ + proxy : address(proxy), + rateLimits : address(rateLimits), + poolId : poolId, + tokenId : tokenId, + liquidityDecrease : liquidityDecrease, + amount0Min : amount0Min, + amount1Min : amount1Min + }); + } + + function swapUniswapV4( + bytes32 poolId, + address tokenIn, + uint128 amountIn, + uint128 amountOutMin + ) + external nonReentrant + { + _checkRole(RELAYER); + + UniswapV4Lib.swap({ + proxy : address(proxy), + rateLimits : address(rateLimits), + poolId : poolId, + tokenIn : tokenIn, + amountIn : amountIn, + amountOutMin : amountOutMin, + maxSlippage : maxSlippages[address(uint160(uint256(poolId)))] + }); + } + /**********************************************************************************************/ /*** Relayer Ethena functions ***/ /**********************************************************************************************/ @@ -674,13 +757,13 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { function prepareUSDeMint(uint256 usdcAmount) external nonReentrant { _checkRole(RELAYER); _rateLimited(LIMIT_USDE_MINT, usdcAmount); - _approve(address(usdc), address(ethenaMinter), usdcAmount); + ApproveLib.approve(address(usdc), address(proxy), address(ethenaMinter), usdcAmount); } function prepareUSDeBurn(uint256 usdeAmount) external nonReentrant { _checkRole(RELAYER); _rateLimited(LIMIT_USDE_BURN, usdeAmount); - _approve(address(usde), address(ethenaMinter), usdeAmount); + ApproveLib.approve(address(usde), address(proxy), address(ethenaMinter), usdeAmount); } function cooldownAssetsSUSDe(uint256 usdeAmount) external nonReentrant { @@ -707,7 +790,7 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { (uint256) ); - rateLimits.triggerRateLimitDecrease(LIMIT_SUSDE_COOLDOWN, cooldownAmount); + _rateLimited(LIMIT_SUSDE_COOLDOWN, cooldownAmount); } function unstakeSUSDe() external nonReentrant { @@ -755,7 +838,7 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { _checkRole(RELAYER); _rateLimited(LIMIT_SUPERSTATE_SUBSCRIBE, usdcAmount); - _approve(address(usdc), address(ustb), usdcAmount); + ApproveLib.approve(address(usdc), address(proxy), address(ustb), usdcAmount); proxy.doCall( address(ustb), @@ -769,7 +852,7 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { function swapUSDSToDAI(uint256 usdsAmount) external nonReentrant onlyRole(RELAYER) { // Approve USDS to DaiUsds migrator from the proxy (assumes the proxy has enough USDS) - _approve(address(usds), address(daiUsds), usdsAmount); + ApproveLib.approve(address(usds), address(proxy), address(daiUsds), usdsAmount); // Swap USDS to DAI 1:1 proxy.doCall( @@ -780,7 +863,7 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { function swapDAIToUSDS(uint256 daiAmount) external nonReentrant onlyRole(RELAYER) { // Approve DAI to DaiUsds migrator from the proxy (assumes the proxy has enough DAI) - _approve(address(dai), address(daiUsds), daiAmount); + ApproveLib.approve(address(dai), address(proxy), address(daiUsds), daiAmount); // Swap DAI to USDS 1:1 proxy.doCall( @@ -843,18 +926,22 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { amount ); + bytes32 recipient = layerZeroRecipients[destinationEndpointId]; + + require(recipient != bytes32(0), "MC/recipient-not-set"); + // NOTE: Full integration testing of this logic is not possible without OFTs with // approvalRequired == false. Add integration testing for this case before // using in production. if (ILayerZero(oftAddress).approvalRequired()) { - _approve(ILayerZero(oftAddress).token(), oftAddress, amount); + ApproveLib.approve(ILayerZero(oftAddress).token(), address(proxy), oftAddress, amount); } bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0); SendParam memory sendParams = SendParam({ dstEid : destinationEndpointId, - to : layerZeroRecipients[destinationEndpointId], + to : recipient, amountLD : amount, minAmountLD : 0, extraOptions : options, @@ -863,7 +950,7 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { }); // Query the min amount received on the destination chain and set it. - ( ,, OFTReceipt memory receipt ) = ILayerZero(oftAddress).quoteOFT(sendParams); + ( , , OFTReceipt memory receipt ) = ILayerZero(oftAddress).quoteOFT(sendParams); sendParams.minAmountLD = receipt.amountReceivedLD; MessagingFee memory fee = ILayerZero(oftAddress).quoteSend(sendParams, false); @@ -908,7 +995,7 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { usdsAmount ); - _approve(address(usds), farm, usdsAmount); + ApproveLib.approve(address(usds), address(proxy), farm, usdsAmount); proxy.doCall( farm, @@ -1032,38 +1119,6 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { /*** Relayer helper functions ***/ /**********************************************************************************************/ - // NOTE: This logic was inspired by OpenZeppelin's forceApprove in SafeERC20 library - function _approve(address token, address spender, uint256 amount) internal { - bytes memory approveData = abi.encodeCall(IERC20.approve, (spender, amount)); - - // Call doCall on proxy to approve the token - ( bool success, bytes memory data ) - = address(proxy).call(abi.encodeCall(IALMProxy.doCall, (token, approveData))); - - bytes memory approveCallReturnData; - - if (success) { - // Data is the ABI-encoding of the approve call bytes return data, need to - // decode it first - approveCallReturnData = abi.decode(data, (bytes)); - // Approve was successful if 1) no return value or 2) true return value - if (approveCallReturnData.length == 0 || abi.decode(approveCallReturnData, (bool))) { - return; - } - } - - // If call was unsuccessful, set to zero and try again - proxy.doCall(token, abi.encodeCall(IERC20.approve, (spender, 0))); - - approveCallReturnData = proxy.doCall(token, approveData); - - // Revert if approve returns false - require( - approveCallReturnData.length == 0 || abi.decode(approveCallReturnData, (bool)), - "MC/approve-failed" - ); - } - function _transfer(address asset, address destination, uint256 amount) internal { bytes memory returnData = proxy.doCall( asset, @@ -1116,18 +1171,4 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable { ); } - /**********************************************************************************************/ - /*** Exchange rate helper functions ***/ - /**********************************************************************************************/ - - function _getExchangeRate(uint256 shares, uint256 assets) internal pure returns (uint256) { - // Return 0 for zero assets first, to handle the valid case of 0 shares and 0 assets. - if (assets == 0) return 0; - - // Zero shares with non-zero assets is invalid (infinite exchange rate). - if (shares == 0) revert("MC/zero-shares"); - - return (EXCHANGE_RATE_PRECISION * assets) / shares; - } - } diff --git a/src/RateLimitHelpers.sol b/src/RateLimitHelpers.sol index 5e634ea3..c2dc231c 100644 --- a/src/RateLimitHelpers.sol +++ b/src/RateLimitHelpers.sol @@ -3,16 +3,20 @@ pragma solidity ^0.8.21; library RateLimitHelpers { - function makeAddressKey(bytes32 key, address asset) internal pure returns (bytes32) { - return keccak256(abi.encode(key, asset)); + function makeAddressKey(bytes32 key, address a) internal pure returns (bytes32) { + return keccak256(abi.encode(key, a)); } - function makeAddressAddressKey(bytes32 key, address asset, address destination) internal pure returns (bytes32) { - return keccak256(abi.encode(key, asset, destination)); + function makeAddressAddressKey(bytes32 key, address a, address b) internal pure returns (bytes32) { + return keccak256(abi.encode(key, a, b)); } - function makeUint32Key(bytes32 key, uint32 domain) internal pure returns (bytes32) { - return keccak256(abi.encode(key, domain)); + function makeBytes32Key(bytes32 key, bytes32 a) internal pure returns (bytes32) { + return keccak256(abi.encode(key, a)); + } + + function makeUint32Key(bytes32 key, uint32 a) internal pure returns (bytes32) { + return keccak256(abi.encode(key, a)); } } diff --git a/src/interfaces/Common.sol b/src/interfaces/Common.sol new file mode 100644 index 00000000..27dfa7b7 --- /dev/null +++ b/src/interfaces/Common.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +interface IERC20Like { + + function approve(address spender, uint256 amount) external returns (bool success); + + function balanceOf(address account) external view returns (uint256 balance); + + function decimals() external view returns (uint8 decimals); + +} + +interface IPermit2Like { + + function approve(address token, address spender, uint160 amount, uint48 expiration) external; + +} diff --git a/src/interfaces/UniswapV4.sol b/src/interfaces/UniswapV4.sol new file mode 100644 index 00000000..78363ff1 --- /dev/null +++ b/src/interfaces/UniswapV4.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +import { PoolId } from "../../lib/uniswap-v4-core/src/types/PoolId.sol"; +import { PoolKey } from "../../lib/uniswap-v4-core/src/types/PoolKey.sol"; + +import { PositionInfo } from "../../lib/uniswap-v4-periphery/src/libraries/PositionInfoLibrary.sol"; + +interface IPositionManagerLike { + + function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable; + + function getPoolAndPositionInfo( + uint256 tokenId + ) external view returns (PoolKey memory poolKey, PositionInfo info); + + function poolKeys(bytes25 poolId) external view returns (PoolKey memory poolKey); + + function ownerOf(uint256 tokenId) external view returns (address owner); + +} + +interface IUniversalRouterLike { + + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external; + +} diff --git a/src/libraries/AaveLib.sol b/src/libraries/AaveLib.sol new file mode 100644 index 00000000..18c44c55 --- /dev/null +++ b/src/libraries/AaveLib.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +import { IAToken } from "aave-v3-origin/src/core/contracts/interfaces/IAToken.sol"; +import { IPool } from "aave-v3-origin/src/core/contracts/interfaces/IPool.sol"; + +import { IERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +import { IALMProxy } from "../interfaces/IALMProxy.sol"; +import { IRateLimits } from "../interfaces/IRateLimits.sol"; + +import { ApproveLib } from "./ApproveLib.sol"; + +import { RateLimitHelpers } from "../RateLimitHelpers.sol"; + +interface IATokenWithPool is IAToken { + function POOL() external view returns(address); +} + +library AaveLib { + + function deposit( + address proxy, + address aToken, + uint256 amount, + uint256 maxSlippage, + address rateLimits, + bytes32 rateLimitId + ) external { + IRateLimits(rateLimits).triggerRateLimitDecrease( + RateLimitHelpers.makeAddressKey(rateLimitId, aToken), + amount + ); + + require(maxSlippage != 0, "MC/max-slippage-not-set"); + + address underlying = IATokenWithPool(aToken).UNDERLYING_ASSET_ADDRESS(); + address pool = IATokenWithPool(aToken).POOL(); + + // Approve underlying to Aave pool from the proxy (assumes the proxy has enough underlying). + ApproveLib.approve(underlying, proxy, pool, amount); + + uint256 aTokenBalance = IERC20(aToken).balanceOf(proxy); + + // Deposit underlying into Aave pool, proxy receives aTokens + IALMProxy(proxy).doCall( + pool, + abi.encodeCall(IPool(pool).supply, (underlying, amount, proxy, 0)) + ); + + uint256 newATokens = IERC20(aToken).balanceOf(proxy) - aTokenBalance; + + require( + newATokens >= amount * maxSlippage / 1e18, + "MC/slippage-too-high" + ); + } + + // NOTE: !!! Rate limited at end of function !!! + function withdraw( + address proxy, + address aToken, + uint256 amount, + address rateLimits, + bytes32 rateLimitWithdrawId, + bytes32 rateLimitDepositId + ) external returns (uint256 amountWithdrawn) { + address pool = IATokenWithPool(aToken).POOL(); + + // Withdraw underlying from Aave pool, decode resulting amount withdrawn. + // Assumes proxy has adequate aTokens. + amountWithdrawn = abi.decode( + IALMProxy(proxy).doCall( + pool, + abi.encodeCall( + IPool(pool).withdraw, + (IATokenWithPool(aToken).UNDERLYING_ASSET_ADDRESS(), amount, proxy) + ) + ), + (uint256) + ); + + IRateLimits(rateLimits).triggerRateLimitDecrease( + RateLimitHelpers.makeAddressKey(rateLimitWithdrawId, aToken), + amountWithdrawn + ); + + IRateLimits(rateLimits).triggerRateLimitIncrease( + RateLimitHelpers.makeAddressKey(rateLimitDepositId, aToken), + amountWithdrawn + ); + } + +} diff --git a/src/libraries/ApproveLib.sol b/src/libraries/ApproveLib.sol new file mode 100644 index 00000000..4988ac85 --- /dev/null +++ b/src/libraries/ApproveLib.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +import { IERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +import { IALMProxy } from "../interfaces/IALMProxy.sol"; + +library ApproveLib { + + // NOTE: This logic was inspired by OpenZeppelin's forceApprove in SafeERC20 library. + function approve(address token, address proxy, address spender, uint256 amount) internal { + bytes memory approveData = abi.encodeCall(IERC20.approve, (spender, amount)); + + // Call doCall on proxy to approve the token. + ( bool success, bytes memory data ) + = proxy.call(abi.encodeCall(IALMProxy.doCall, (token, approveData))); + + bytes memory returnData; + + if (success) { + // Decode the ABI-encoding of the approve call bytes return data first. + returnData = abi.decode(data, (bytes)); + + // Approve was successful if 1) no return value or 2) true return value. + if (returnData.length == 0 || abi.decode(returnData, (bool))) return; + } + + // If call was unsuccessful, set to zero and try again. + IALMProxy(proxy).doCall(token, abi.encodeCall(IERC20.approve, (spender, 0))); + + returnData = IALMProxy(proxy).doCall(token, approveData); + + // Revert if approve returns false. + require(returnData.length == 0 || abi.decode(returnData, (bool)), "MC/approve-failed"); + } + +} diff --git a/src/libraries/CCTPLib.sol b/src/libraries/CCTPLib.sol index 7b0eacd9..a7fbfad4 100644 --- a/src/libraries/CCTPLib.sol +++ b/src/libraries/CCTPLib.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.21; -import { IERC20 } from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import { IERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import { IRateLimits } from "../interfaces/IRateLimits.sol"; import { IALMProxy } from "../interfaces/IALMProxy.sol"; diff --git a/src/libraries/CurveLib.sol b/src/libraries/CurveLib.sol index 757ba255..40e86981 100644 --- a/src/libraries/CurveLib.sol +++ b/src/libraries/CurveLib.sol @@ -1,11 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.21; -import { IERC20 } from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import { IERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import { IALMProxy } from "../interfaces/IALMProxy.sol"; import { IRateLimits } from "../interfaces/IRateLimits.sol"; +import { ApproveLib } from "./ApproveLib.sol"; + import { RateLimitHelpers } from "../RateLimitHelpers.sol"; interface ICurvePoolLike is IERC20 { @@ -113,9 +115,9 @@ library CurveLib { params.amountIn * rates[params.inputIndex] / 1e18 ); - _approve( - params.proxy, + ApproveLib.approve( curvePool.coins(params.inputIndex), + address(params.proxy), params.pool, params.amountIn ); @@ -154,9 +156,9 @@ library CurveLib { // Aggregate the value of the deposited assets (e.g. USD) uint256 valueDeposited; for (uint256 i = 0; i < params.depositAmounts.length; i++) { - _approve( - params.proxy, + ApproveLib.approve( curvePool.coins(i), + address(params.proxy), params.pool, params.depositAmounts[i] ); @@ -268,44 +270,6 @@ library CurveLib { /*** Helper functions ***/ /**********************************************************************************************/ - function _approve( - IALMProxy proxy, - address token, - address spender, - uint256 amount - ) - internal - { - bytes memory approveData = abi.encodeCall(IERC20.approve, (spender, amount)); - - // Call doCall on proxy to approve the token - ( bool success, bytes memory data ) - = address(proxy).call(abi.encodeCall(IALMProxy.doCall, (token, approveData))); - - bytes memory approveCallReturnData; - - if (success) { - // Data is the ABI-encoding of the approve call bytes return data, need to - // decode it first - approveCallReturnData = abi.decode(data, (bytes)); - // Approve was successful if 1) no return value or 2) true return value - if (approveCallReturnData.length == 0 || abi.decode(approveCallReturnData, (bool))) { - return; - } - } - - // If call was unsuccessful, set to zero and try again - proxy.doCall(token, abi.encodeCall(IERC20.approve, (spender, 0))); - - approveCallReturnData = proxy.doCall(token, approveData); - - // Revert if approve returns false - require( - approveCallReturnData.length == 0 || abi.decode(approveCallReturnData, (bool)), - "CurveLib/approve-failed" - ); - } - function _absSubtraction(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a - b : b - a; } diff --git a/src/libraries/ERC4626Lib.sol b/src/libraries/ERC4626Lib.sol new file mode 100644 index 00000000..2c9194ff --- /dev/null +++ b/src/libraries/ERC4626Lib.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +import { IERC4626 } from "../../lib/openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; + +import { IALMProxy } from "../interfaces/IALMProxy.sol"; +import { IRateLimits } from "../interfaces/IRateLimits.sol"; + +import { ApproveLib } from "./ApproveLib.sol"; + +import { RateLimitHelpers } from "../RateLimitHelpers.sol"; + +library ERC4626Lib { + + uint256 internal constant EXCHANGE_RATE_PRECISION = 1e36; + + function deposit( + address proxy, + address token, + uint256 amount, + uint256 maxExchangeRate, + address rateLimits, + bytes32 rateLimitId + ) external returns (uint256 shares) { + IRateLimits(rateLimits).triggerRateLimitDecrease( + RateLimitHelpers.makeAddressKey(rateLimitId, token), + amount + ); + + // Approve asset to token from the proxy (assumes the proxy has enough of the asset). + ApproveLib.approve(IERC4626(token).asset(), proxy, token, amount); + + // Deposit asset into the token, proxy receives token shares, decode the resulting shares. + shares = abi.decode( + IALMProxy(proxy).doCall( + token, + abi.encodeCall(IERC4626(token).deposit, (amount, proxy)) + ), + (uint256) + ); + + require(getExchangeRate(shares, amount) <= maxExchangeRate, "MC/exchange-rate-too-high"); + } + + function withdraw( + address proxy, + address token, + uint256 amount, + address rateLimits, + bytes32 withdrawRateLimitId, + bytes32 depositRateLimitId + ) external returns (uint256 shares) { + IRateLimits(rateLimits).triggerRateLimitDecrease( + RateLimitHelpers.makeAddressKey(withdrawRateLimitId, token), + amount + ); + + // Withdraw asset from a token, decode resulting shares. + // Assumes proxy has adequate token shares. + shares = abi.decode( + IALMProxy(proxy).doCall( + token, + abi.encodeCall(IERC4626(token).withdraw, (amount, proxy, proxy)) + ), + (uint256) + ); + + IRateLimits(rateLimits).triggerRateLimitIncrease( + RateLimitHelpers.makeAddressKey(depositRateLimitId, token), + amount + ); + } + + function redeem( + address proxy, + address token, + uint256 shares, + address rateLimits, + bytes32 withdrawRateLimitId, + bytes32 depositRateLimitId + ) external returns (uint256 assets) { + // Redeem shares for assets from the token, decode the resulting assets. + // Assumes proxy has adequate token shares. + assets = abi.decode( + IALMProxy(proxy).doCall( + token, + abi.encodeCall(IERC4626(token).redeem, (shares, proxy, proxy)) + ), + (uint256) + ); + + IRateLimits(rateLimits).triggerRateLimitDecrease( + RateLimitHelpers.makeAddressKey(withdrawRateLimitId, token), + assets + ); + + IRateLimits(rateLimits).triggerRateLimitIncrease( + RateLimitHelpers.makeAddressKey(depositRateLimitId, token), + assets + ); + } + + function getExchangeRate(uint256 shares, uint256 assets) public pure returns (uint256) { + // Return 0 for zero assets first, to handle the valid case of 0 shares and 0 assets. + if (assets == 0) return 0; + + // Zero shares with non-zero assets is invalid (infinite exchange rate). + if (shares == 0) revert("MC/zero-shares"); + + return (EXCHANGE_RATE_PRECISION * assets) / shares; + } + +} diff --git a/src/libraries/PSMLib.sol b/src/libraries/PSMLib.sol index eca11aee..50032049 100644 --- a/src/libraries/PSMLib.sol +++ b/src/libraries/PSMLib.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.21; -import { IERC20 } from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import { IERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import { IRateLimits } from "../interfaces/IRateLimits.sol"; import { IALMProxy } from "../interfaces/IALMProxy.sol"; diff --git a/src/libraries/UniswapV4Lib.sol b/src/libraries/UniswapV4Lib.sol new file mode 100644 index 00000000..1b91b8b8 --- /dev/null +++ b/src/libraries/UniswapV4Lib.sol @@ -0,0 +1,548 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +import { Currency } from "../../lib/uniswap-v4-core/src/types/Currency.sol"; +import { PoolKey } from "../../lib/uniswap-v4-core/src/types/PoolKey.sol"; + +import { IV4Router } from "../../lib/uniswap-v4-periphery/src/interfaces/IV4Router.sol"; +import { Actions } from "../../lib/uniswap-v4-periphery/src/libraries/Actions.sol"; +import { PositionInfo } from "../../lib/uniswap-v4-periphery/src/libraries/PositionInfoLibrary.sol"; + +import { IERC20Like, IPermit2Like } from "../interfaces/Common.sol"; +import { IALMProxy } from "../interfaces/IALMProxy.sol"; +import { IRateLimits } from "../interfaces/IRateLimits.sol"; +import { IPositionManagerLike, IUniversalRouterLike } from "../interfaces/UniswapV4.sol"; + +import { RateLimitHelpers } from "../RateLimitHelpers.sol"; + +library UniswapV4Lib { + + struct TickLimits { + int24 tickLowerMin; + int24 tickUpperMax; + uint24 maxTickSpacing; + } + + bytes32 public constant LIMIT_DEPOSIT = keccak256("LIMIT_UNISWAP_V4_DEPOSIT"); + bytes32 public constant LIMIT_WITHDRAW = keccak256("LIMIT_UNISWAP_V4_WITHDRAW"); + bytes32 public constant LIMIT_SWAP = keccak256("LIMIT_UNISWAP_V4_SWAP"); + + uint256 internal constant _V4_SWAP = 0x10; + + // NOTE: From https://docs.uniswap.org/contracts/v4/deployments (Ethereum Mainnet). + address internal constant _PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + address internal constant _POSITION_MANAGER = 0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e; + address internal constant _ROUTER = 0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af; + + /**********************************************************************************************/ + /*** Interactive Functions ***/ + /**********************************************************************************************/ + + function mintPosition( + address proxy, + address rateLimits, + bytes32 poolId, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 amount0Max, + uint256 amount1Max, + mapping(bytes32 poolId => TickLimits tickLimits) storage tickLimits + ) + external + { + _checkTickLimits(tickLimits[poolId], tickLower, tickUpper); + + PoolKey memory poolKey = _getPoolKeyFromPoolId(poolId); + + _requirePoolIdMatch(poolId, poolKey); + + bytes memory callData = _getMintCalldata({ + poolKey : poolKey, + tickLower : tickLower, + tickUpper : tickUpper, + liquidity : liquidity, + amount0Max : amount0Max, + amount1Max : amount1Max, + proxy : proxy + }); + + _increaseLiquidity({ + proxy : proxy, + rateLimits : rateLimits, + poolId : poolId, + token0 : Currency.unwrap(poolKey.currency0), + token1 : Currency.unwrap(poolKey.currency1), + amount0Max : amount0Max, + amount1Max : amount1Max, + callData : callData + }); + } + + function increasePosition( + address proxy, + address rateLimits, + bytes32 poolId, + uint256 tokenId, + uint128 liquidityIncrease, + uint256 amount0Max, + uint256 amount1Max, + mapping(bytes32 poolId => TickLimits tickLimits) storage tickLimits + ) + external + { + // Must not increase liquidity on a position that is not owned by the ALMProxy. + require( + IPositionManagerLike(_POSITION_MANAGER).ownerOf(tokenId) == proxy, + "MC/non-proxy-position" + ); + + ( PoolKey memory poolKey, PositionInfo info ) = _getPoolKeyAndPositionInfo(tokenId); + + _requirePoolIdMatch(poolId, poolKey); + + // Since funds are being added to the position, the ticks of the position need to be checked + // against the current constraints, since it's possible the position was minted under + // outdated tick limits, or was transferred to the proxy. + _checkTickLimits(tickLimits[poolId], info.tickLower(), info.tickUpper()); + + bytes memory callData = _getIncreaseLiquidityCallData({ + poolKey : poolKey, + tokenId : tokenId, + liquidityIncrease : liquidityIncrease, + amount0Max : amount0Max, + amount1Max : amount1Max + }); + + _increaseLiquidity({ + proxy : proxy, + rateLimits : rateLimits, + poolId : poolId, + token0 : Currency.unwrap(poolKey.currency0), + token1 : Currency.unwrap(poolKey.currency1), + amount0Max : amount0Max, + amount1Max : amount1Max, + callData : callData + }); + } + + function decreasePosition( + address proxy, + address rateLimits, + bytes32 poolId, + uint256 tokenId, + uint128 liquidityDecrease, + uint256 amount0Min, + uint256 amount1Min + ) + external + { + PoolKey memory poolKey = _getPoolKeyFromTokenId(tokenId); + + // NOTE: No need to check the token ownership here, as the proxy will be defined as the + // recipient of the tokens, so the worst case is that another account's position is + // decreased or closed by the proxy. + _requirePoolIdMatch(poolId, poolKey); + + bytes memory callData = _getDecreaseLiquidityCallData({ + proxy : proxy, + poolKey : poolKey, + tokenId : tokenId, + liquidityDecrease : liquidityDecrease, + amount0Min : amount0Min, + amount1Min : amount1Min + }); + + _decreaseLiquidity({ + proxy : proxy, + rateLimits : rateLimits, + poolId : poolId, + token0 : Currency.unwrap(poolKey.currency0), + token1 : Currency.unwrap(poolKey.currency1), + callData : callData + }); + } + + function swap( + address proxy, + address rateLimits, + bytes32 poolId, + address tokenIn, + uint128 amountIn, + uint128 amountOutMin, + uint256 maxSlippage + ) + external + { + require(maxSlippage != 0, "MC/max-slippage-not-set"); + + PoolKey memory poolKey = _getPoolKeyFromPoolId(poolId); + + _requirePoolIdMatch(poolId, poolKey); + + require( + tokenIn == Currency.unwrap(poolKey.currency0) || + tokenIn == Currency.unwrap(poolKey.currency1), + "MC/invalid-tokenIn" + ); + + // Perform rate limit decrease. + // NOTE: Rate limit decrease does not account for the net amount of tokenIn actually taken. + IRateLimits(rateLimits).triggerRateLimitDecrease( + RateLimitHelpers.makeBytes32Key(LIMIT_SWAP, poolId), + _getNormalizedBalance(tokenIn, amountIn) + ); + + bytes memory actions = abi.encodePacked( + uint8(Actions.SWAP_EXACT_IN_SINGLE), + uint8(Actions.SETTLE_ALL), + uint8(Actions.TAKE_ALL) + ); + + bool zeroForOne = tokenIn == Currency.unwrap(poolKey.currency0); + + address tokenOut = zeroForOne + ? Currency.unwrap(poolKey.currency1) + : Currency.unwrap(poolKey.currency0); + + require( + _getNormalizedBalance(tokenOut, amountOutMin) * 1e18 >= + _getNormalizedBalance(tokenIn, amountIn) * maxSlippage, + "MC/amountOutMin-too-low" + ); + + bytes[] memory params = new bytes[](3); + + params[0] = abi.encode( + IV4Router.ExactInputSingleParams({ + poolKey : poolKey, + zeroForOne : zeroForOne, + amountIn : amountIn, + amountOutMinimum : amountOutMin, + hookData : bytes("") + }) + ); + + params[1] = abi.encode(tokenIn, amountIn); + params[2] = abi.encode(tokenOut, amountOutMin); + + // Combine actions and params into inputs. + bytes[] memory inputs = new bytes[](1); + + inputs[0] = abi.encode(actions, params); + + _approveWithPermit2(proxy, tokenIn, _ROUTER, amountIn); + + // Perform action. + IALMProxy(proxy).doCall( + _ROUTER, + abi.encodeCall( + IUniversalRouterLike.execute, + (abi.encodePacked(uint8(_V4_SWAP)), inputs, block.timestamp) + ) + ); + + // Reset approval of Permit2 in tokenIn. + _approveWithPermit2(proxy, tokenIn, _ROUTER, 0); + } + + /**********************************************************************************************/ + /*** Internal Interactive Functions ***/ + /**********************************************************************************************/ + + function _approveWithPermit2( + address proxy, + address token, + address spender, + uint256 amount + ) + internal + { + require(amount <= type(uint160).max, "MC/amount-too-large-for-permit2"); + + // Approve the Permit2 contract to spend none of the token (success is optional). + // NOTE: We don't care about the success of this call, since the only outcomes are: + // - the allowance is 0 (it was reset or was already 0) + // - the allowance is not 0, in which case the success of the overall set of + // operations is dependent on the success of the subsequent calls. + // In other words, this is a convenience call that may not even be needed for success. + proxy.call( + abi.encodeCall( + IALMProxy.doCall, + (token, abi.encodeCall(IERC20Like.approve, (_PERMIT2, 0))) + ) + ); + + if (amount != 0) { + // Approve the Permit2 contract to spend the amount of token (success is mandatory). + bytes memory approveResult = IALMProxy(proxy).doCall( + token, + abi.encodeCall(IERC20Like.approve, (_PERMIT2, amount)) + ); + + // Revert if approve returns anything, and that anything is not `true`. + require( + approveResult.length == 0 || abi.decode(approveResult, (bool)), + "MC/permit2-approve-failed" + ); + } + + // Finally, approve the spender to spend the token via Permit2. + IALMProxy(proxy).doCall( + _PERMIT2, + abi.encodeCall( + IPermit2Like.approve, + (token, spender, uint160(amount), uint48(block.timestamp)) + ) + ); + } + + function _increaseLiquidity( + address proxy, + address rateLimits, + bytes32 poolId, + address token0, + address token1, + uint256 amount0Max, + uint256 amount1Max, + bytes memory callData + ) + internal + { + _approveWithPermit2(proxy, token0, _POSITION_MANAGER, amount0Max); + _approveWithPermit2(proxy, token1, _POSITION_MANAGER, amount1Max); + + // Get token balances before liquidity increase. + uint256 startingBalance0 = _getBalance(token0, proxy); + uint256 startingBalance1 = _getBalance(token1, proxy); + + // Perform action + IALMProxy(proxy).doCall(_POSITION_MANAGER, callData); + + // Get token balances after liquidity increase. + uint256 endingBalance0 = _getBalance(token0, proxy); + uint256 endingBalance1 = _getBalance(token1, proxy); + + // Account for the theoretical possibility of receiving tokens when adding liquidity by + // using a clamped subtraction. + // NOTE: The limitation of this integration is the assumption that the tokens are valued + // equally (i.e. 1.000000 USDC = 1.000000000000000000 USDS). + uint256 rateLimitDecrease = _clampedSub( + _getNormalizedBalance(token0, startingBalance0) + + _getNormalizedBalance(token1, startingBalance1), + _getNormalizedBalance(token0, endingBalance0) + + _getNormalizedBalance(token1, endingBalance1) + ); + + // Perform rate limit decrease. + // NOTE: Rate limit decrease is net of any token0 or token1 received due to fees. + IRateLimits(rateLimits).triggerRateLimitDecrease( + RateLimitHelpers.makeBytes32Key(LIMIT_DEPOSIT, poolId), + rateLimitDecrease + ); + + // Reset approvals for token0 and token1. + _approveWithPermit2(proxy, token0, _POSITION_MANAGER, 0); + _approveWithPermit2(proxy, token1, _POSITION_MANAGER, 0); + } + + function _decreaseLiquidity( + address proxy, + address rateLimits, + bytes32 poolId, + address token0, + address token1, + bytes memory callData + ) + internal + { + // Get token balances before liquidity decrease. + uint256 startingBalance0 = _getBalance(token0, proxy); + uint256 startingBalance1 = _getBalance(token1, proxy); + + // Perform action. + IALMProxy(proxy).doCall(_POSITION_MANAGER, callData); + + // Get token balances after liquidity decrease. + uint256 endingBalance0 = _getBalance(token0, proxy); + uint256 endingBalance1 = _getBalance(token1, proxy); + + // NOTE: The limitation of this integration is the assumption that the tokens are valued + // equally (i.e. 1.000000 USDC = 1.000000000000000000 USDS). + uint256 rateLimitDecrease = + _getNormalizedBalance(token0, endingBalance0 - startingBalance0) + + _getNormalizedBalance(token1, endingBalance1 - startingBalance1); + + // Perform rate limit decrease. + // NOTE: Rate limit decrease includes any token0 or token1 received due to fees. + IRateLimits(rateLimits).triggerRateLimitDecrease( + RateLimitHelpers.makeBytes32Key(LIMIT_WITHDRAW, poolId), + rateLimitDecrease + ); + } + + /**********************************************************************************************/ + /*** Internal View/Pure Functions ***/ + /**********************************************************************************************/ + + function _checkTickLimits(TickLimits memory limits, int24 tickLower, int24 tickUpper) + internal pure + { + require(limits.maxTickSpacing != 0, "MC/tickLimits-not-set"); + require(tickLower < tickUpper, "MC/ticks-misordered"); + require(tickLower >= limits.tickLowerMin, "MC/tickLower-too-low"); + require(tickUpper <= limits.tickUpperMax, "MC/tickUpper-too-high"); + + require( + uint256(int256(tickUpper) - int256(tickLower)) <= limits.maxTickSpacing, + "MC/tickSpacing-too-wide" + ); + } + + function _clampedSub(uint256 a, uint256 b) internal pure returns (uint256 c) { + return a > b ? a - b : 0; + } + + function _getBalance(address token, address account) internal view returns (uint256 balance) { + return IERC20Like(token).balanceOf(account); + } + + function _getMintCalldata( + address proxy, + PoolKey memory poolKey, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 amount0Max, + uint256 amount1Max + ) + internal view returns (bytes memory callData) + { + bytes memory actions = abi.encodePacked( + uint8(Actions.MINT_POSITION), + uint8(Actions.SETTLE_PAIR) + ); + + bytes[] memory params = new bytes[](2); + + params[0] = abi.encode( + poolKey, // Which pool to mint in + tickLower, // Position's lower price bound + tickUpper, // Position's upper price bound + liquidity, // Amount of liquidity to mint + amount0Max, // Maximum amount of token0 to use + amount1Max, // Maximum amount of token1 to use + proxy, // NFT recipient + "" // No hook data needed + ); + + params[1] = abi.encode( + poolKey.currency0, // First token to settle + poolKey.currency1 // Second token to settle + ); + + return _getModifyLiquiditiesCallData(actions, params); + } + + function _getIncreaseLiquidityCallData( + PoolKey memory poolKey, + uint256 tokenId, + uint128 liquidityIncrease, + uint256 amount0Max, + uint256 amount1Max + ) + internal view returns (bytes memory callData) + { + bytes memory actions = abi.encodePacked( + uint8(Actions.INCREASE_LIQUIDITY), + uint8(Actions.SETTLE_PAIR) + ); + + bytes[] memory params = new bytes[](2); + + params[0] = abi.encode( + tokenId, // Position to increase + liquidityIncrease, // Amount to add + amount0Max, // Maximum token0 to spend + amount1Max, // Maximum token1 to spend + "" // No hook data needed + ); + + params[1] = abi.encode( + poolKey.currency0, // First token to settle + poolKey.currency1 // Second token to settle + ); + + return _getModifyLiquiditiesCallData(actions, params); + } + + function _getDecreaseLiquidityCallData( + address proxy, + PoolKey memory poolKey, + uint256 tokenId, + uint128 liquidityDecrease, + uint256 amount0Min, + uint256 amount1Min + ) + internal view returns (bytes memory callData) + { + bytes memory actions = abi.encodePacked( + uint8(Actions.DECREASE_LIQUIDITY), + uint8(Actions.TAKE_PAIR) + ); + + bytes[] memory params = new bytes[](2); + + params[0] = abi.encode( + tokenId, // Position to decrease + liquidityDecrease, // Amount to remove + amount0Min, // Minimum token0 to receive + amount1Min, // Minimum token1 to receive + "" // No hook data needed + ); + + params[1] = abi.encode( + poolKey.currency0, // First token + poolKey.currency1, // Second token + proxy // Who receives the tokens + ); + + return _getModifyLiquiditiesCallData(actions, params); + } + + function _getModifyLiquiditiesCallData(bytes memory actions, bytes[] memory params) + internal view returns (bytes memory callData) + { + return abi.encodeCall( + IPositionManagerLike.modifyLiquidities, + (abi.encode(actions, params), block.timestamp) + ); + } + + function _getNormalizedBalance(address token, uint256 balance) + internal view returns (uint256 normalizedBalance) + { + return balance * 1e18 / (10 ** IERC20Like(token).decimals()); + } + + function _getPoolKeyAndPositionInfo(uint256 tokenId) + internal view returns (PoolKey memory poolKey, PositionInfo info) + { + return IPositionManagerLike(_POSITION_MANAGER).getPoolAndPositionInfo(tokenId); + } + + function _getPoolKeyFromPoolId(bytes32 poolId) internal view returns (PoolKey memory poolKey) { + return IPositionManagerLike(_POSITION_MANAGER).poolKeys(bytes25(poolId)); + } + + function _getPoolKeyFromTokenId(uint256 tokenId) + internal view returns (PoolKey memory poolKey) + { + (poolKey, ) = _getPoolKeyAndPositionInfo(tokenId); + } + + function _requirePoolIdMatch(bytes32 poolId, PoolKey memory poolKey) internal pure { + require(keccak256(abi.encode(poolKey)) == poolId, "MC/tokenId-poolId-mismatch"); + } + +} diff --git a/test/base-fork/MorphoAllocations.t.sol b/test/base-fork/MorphoAllocations.t.sol deleted file mode 100644 index 0ff945b5..00000000 --- a/test/base-fork/MorphoAllocations.t.sol +++ /dev/null @@ -1,297 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity >=0.8.0; - -import { IERC4626 } from "forge-std/interfaces/IERC4626.sol"; - -import { IMetaMorpho, Id, MarketAllocation } from "metamorpho/interfaces/IMetaMorpho.sol"; - -import { MarketParamsLib } from "morpho-blue/src/libraries/MarketParamsLib.sol"; -import { IMorpho, MarketParams } from "morpho-blue/src/interfaces/IMorpho.sol"; - -import { ReentrancyGuard } from "../../lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; - -import { RateLimitHelpers } from "../../src/RateLimitHelpers.sol"; - -import "./ForkTestBase.t.sol"; - -contract MorphoTestBase is ForkTestBase { - - address constant CBBTC = 0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf; - address constant CBBTC_USDC_ORACLE = 0x663BECd10daE6C4A3Dcd89F1d76c1174199639B9; - address constant MORPHO_DEFAULT_IRM = 0x46415998764C29aB2a25CbeA6254146D50D22687; - - IMetaMorpho morphoVault = IMetaMorpho(Base.MORPHO_VAULT_SUSDC); - IMorpho morpho = IMorpho(Base.MORPHO); - - MarketParams usdcIdle = MarketParams({ - loanToken : Base.USDC, - collateralToken : address(0), - oracle : address(0), - irm : address(0), - lltv : 0 - }); - MarketParams usdcCBBTC = MarketParams({ - loanToken : Base.USDC, - collateralToken : CBBTC, - oracle : CBBTC_USDC_ORACLE, - irm : MORPHO_DEFAULT_IRM, - lltv : 0.86e18 - }); - - function setUp() public override { - super.setUp(); - - // Spell onboarding - vm.startPrank(Base.SPARK_EXECUTOR); - morphoVault.setIsAllocator(address(almProxy), true); - morphoVault.setIsAllocator(address(relayer), false); - rateLimits.setRateLimitData( - RateLimitHelpers.makeAddressKey( - foreignController.LIMIT_4626_DEPOSIT(), - address(morphoVault) - ), - 1_000_000e6, - uint256(1_000_000e6) / 1 days - ); - vm.stopPrank(); - } - - function _getBlock() internal pure override returns (uint256) { - return 25340000; // Jan 21, 2024 - } - - function positionShares(MarketParams memory marketParams) internal view returns (uint256) { - return morpho.position(MarketParamsLib.id(marketParams), address(morphoVault)).supplyShares; - } - - function positionAssets(MarketParams memory marketParams) internal view returns (uint256) { - return positionShares(marketParams) - * marketAssets(marketParams) - / morpho.market(MarketParamsLib.id(marketParams)).totalSupplyShares; - } - - function marketAssets(MarketParams memory marketParams) internal view returns (uint256) { - return morpho.market(MarketParamsLib.id(marketParams)).totalSupplyAssets; - } - -} - -contract MorphoSetSupplyQueueMorphoFailureTests is MorphoTestBase { - - function test_setSupplyQueueMorpho_reentrancy() external { - _setControllerEntered(); - vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); - foreignController.setSupplyQueueMorpho(address(morphoVault), new Id[](0)); - } - - function test_setSupplyQueueMorpho_notRelayer() external { - vm.expectRevert(abi.encodeWithSignature( - "AccessControlUnauthorizedAccount(address,bytes32)", - address(this), - RELAYER - )); - foreignController.setSupplyQueueMorpho(address(morphoVault), new Id[](0)); - } - - function test_setSupplyQueueMorpho_invalidVault() external { - vm.prank(relayer); - vm.expectRevert("FC/invalid-action"); - foreignController.setSupplyQueueMorpho(makeAddr("fake-vault"), new Id[](0)); - } - -} - -contract MorphoSetSupplyQueueMorphoSuccessTests is MorphoTestBase { - - function test_setSupplyQueueMorpho() external { - // Switch order of existing markets - Id[] memory supplyQueueUSDC = new Id[](2); - supplyQueueUSDC[0] = MarketParamsLib.id(usdcIdle); - supplyQueueUSDC[1] = MarketParamsLib.id(usdcCBBTC); - - assertEq(morphoVault.supplyQueueLength(), 2); - - assertEq(Id.unwrap(morphoVault.supplyQueue(0)), Id.unwrap(MarketParamsLib.id(usdcCBBTC))); - assertEq(Id.unwrap(morphoVault.supplyQueue(1)), Id.unwrap(MarketParamsLib.id(usdcIdle))); - - vm.record(); - - vm.prank(relayer); - foreignController.setSupplyQueueMorpho(address(morphoVault), supplyQueueUSDC); - - _assertReentrancyGuardWrittenToTwice(); - - assertEq(morphoVault.supplyQueueLength(), 2); - - assertEq(Id.unwrap(morphoVault.supplyQueue(0)), Id.unwrap(MarketParamsLib.id(usdcIdle))); - assertEq(Id.unwrap(morphoVault.supplyQueue(1)), Id.unwrap(MarketParamsLib.id(usdcCBBTC))); - } - -} - -contract MorphoUpdateWithdrawQueueMorphoFailureTests is MorphoTestBase { - - function test_updateWithdrawQueueMorpho_reentrancy() external { - _setControllerEntered(); - vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); - foreignController.updateWithdrawQueueMorpho(address(morphoVault), new uint256[](0)); - } - - function test_updateWithdrawQueueMorpho_notRelayer() external { - vm.expectRevert(abi.encodeWithSignature( - "AccessControlUnauthorizedAccount(address,bytes32)", - address(this), - RELAYER - )); - foreignController.updateWithdrawQueueMorpho(address(morphoVault), new uint256[](0)); - } - - function test_updateWithdrawQueueMorpho_invalidVault() external { - vm.prank(relayer); - vm.expectRevert("FC/invalid-action"); - foreignController.updateWithdrawQueueMorpho(makeAddr("fake-vault"), new uint256[](0)); - } - -} - -contract MorphoUpdateWithdrawQueueMorphoSuccessTests is MorphoTestBase { - - function test_updateWithdrawQueueMorpho() external { - // Switch order of existing markets - uint256[] memory withdrawQueueUsdc = new uint256[](2); - withdrawQueueUsdc[0] = 1; - withdrawQueueUsdc[1] = 0; - - assertEq(morphoVault.withdrawQueueLength(), 2); - - assertEq(Id.unwrap(morphoVault.withdrawQueue(0)), Id.unwrap(MarketParamsLib.id(usdcIdle))); - assertEq(Id.unwrap(morphoVault.withdrawQueue(1)), Id.unwrap(MarketParamsLib.id(usdcCBBTC))); - - vm.record(); - - vm.prank(relayer); - foreignController.updateWithdrawQueueMorpho(address(morphoVault), withdrawQueueUsdc); - - _assertReentrancyGuardWrittenToTwice(); - - assertEq(morphoVault.withdrawQueueLength(), 2); - - assertEq(Id.unwrap(morphoVault.withdrawQueue(0)), Id.unwrap(MarketParamsLib.id(usdcCBBTC))); - assertEq(Id.unwrap(morphoVault.withdrawQueue(1)), Id.unwrap(MarketParamsLib.id(usdcIdle))); - } - -} - -contract MorphoReallocateMorphoFailureTests is MorphoTestBase { - - function test_reallocateMorpho_reentrancy() external { - _setControllerEntered(); - vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); - foreignController.reallocateMorpho(address(morphoVault), new MarketAllocation[](0)); - } - - function test_reallocateMorpho_notRelayer() external { - vm.expectRevert(abi.encodeWithSignature( - "AccessControlUnauthorizedAccount(address,bytes32)", - address(this), - RELAYER - )); - foreignController.reallocateMorpho(address(morphoVault), new MarketAllocation[](0)); - } - - function test_reallocateMorpho_invalidVault() external { - vm.prank(relayer); - vm.expectRevert("FC/invalid-action"); - foreignController.reallocateMorpho(makeAddr("fake-vault"), new MarketAllocation[](0)); - } - -} - -contract MorphoReallocateMorphoSuccessTests is MorphoTestBase { - - function test_reallocateMorpho() external { - vm.startPrank(Base.SPARK_EXECUTOR); - rateLimits.setRateLimitData( - RateLimitHelpers.makeAddressKey( - foreignController.LIMIT_4626_DEPOSIT(), - address(morphoVault) - ), - 25_000_000e6, - uint256(5_000_000e6) / 1 days - ); - foreignController.setMaxExchangeRate(address(morphoVault), morphoVault.convertToShares(1_000_000e6), 1_100_000e6); - vm.stopPrank(); - - // Refresh markets so calculations don't include interest - vm.prank(relayer); - foreignController.depositERC4626(address(morphoVault), 0); - - uint256 positionCBBTC = positionAssets(usdcCBBTC); - uint256 positionIdle = positionAssets(usdcIdle); - - uint256 marketAssetsCBBTC = marketAssets(usdcCBBTC); - uint256 marketAssetsIdle = marketAssets(usdcIdle); - - assertEq(positionCBBTC, 12_128_319.737383e6); - assertEq(positionIdle, 0); - - assertEq(marketAssetsCBBTC, 56_494_357.047568e6); - assertEq(marketAssetsIdle, 5.205521e6); - - deal(Base.USDC, address(almProxy), 1_000_000e6); - vm.prank(relayer); - foreignController.depositERC4626(address(morphoVault), 1_000_000e6); - - assertEq(positionAssets(usdcCBBTC), positionCBBTC + 1_000_000e6); - assertEq(positionAssets(usdcIdle), 0); - - assertEq(marketAssets(usdcCBBTC), marketAssetsCBBTC + 1_000_000e6); - assertEq(marketAssets(usdcIdle), marketAssetsIdle); - - // Move new allocation into idle market - MarketAllocation[] memory reallocations = new MarketAllocation[](2); - reallocations[0] = MarketAllocation({ - marketParams : usdcCBBTC, - assets : positionCBBTC - }); - reallocations[1] = MarketAllocation({ - marketParams : usdcIdle, - assets : 1_000_000e6 - }); - - vm.record(); - - vm.prank(relayer); - foreignController.reallocateMorpho(address(morphoVault), reallocations); - - _assertReentrancyGuardWrittenToTwice(); - - // NOTE: No interest is accrued because deposit coverered all markets and is atomic - assertEq(positionAssets(usdcCBBTC), positionCBBTC); - assertEq(positionAssets(usdcIdle), 1_000_000e6); - - assertEq(marketAssets(usdcCBBTC), marketAssetsCBBTC); - assertEq(marketAssets(usdcIdle), marketAssetsIdle + 1_000_000e6); - - // Move 400k back into CBBTC, note order has changed because of pulling from idle market - reallocations = new MarketAllocation[](2); - reallocations[0] = MarketAllocation({ - marketParams : usdcIdle, - assets : 600_000e6 - }); - reallocations[1] = MarketAllocation({ - marketParams : usdcCBBTC, - assets : positionCBBTC + 400_000e6 - }); - - vm.prank(relayer); - foreignController.reallocateMorpho(address(morphoVault), reallocations); - - assertEq(positionAssets(usdcCBBTC), positionCBBTC + 400_000e6); - assertEq(positionAssets(usdcIdle), 600_000e6); - - assertEq(marketAssets(usdcCBBTC), marketAssetsCBBTC + 400_000e6); - assertEq(marketAssets(usdcIdle), marketAssetsIdle + 600_000e6); - } - -} diff --git a/test/mainnet-fork/Approve.t.sol b/test/mainnet-fork/Approve.t.sol index b34b1821..f7b68686 100644 --- a/test/mainnet-fork/Approve.t.sol +++ b/test/mainnet-fork/Approve.t.sol @@ -6,7 +6,8 @@ import "./ForkTestBase.t.sol"; import { ForeignController } from "../../src/ForeignController.sol"; import { MainnetController } from "../../src/MainnetController.sol"; -import { CurveLib } from "../../src/libraries/CurveLib.sol"; +import { ApproveLib } from "../../src/libraries/ApproveLib.sol"; +import { CurveLib } from "../../src/libraries/CurveLib.sol"; import { IALMProxy } from "../../src/interfaces/IALMProxy.sol"; @@ -60,11 +61,11 @@ contract MainnetControllerHarness is MainnetController { ) MainnetController(admin_, proxy_, rateLimits_, vault_, psm_, daiUsds_, cctp_) {} function approve(address token, address spender, uint256 amount) external { - _approve(token, spender, amount); + ApproveLib.approve(token, address(proxy), spender, amount); } function approveCurve(address proxy, address token, address spender, uint256 amount) external { - IALMProxy(proxy)._approve(token, spender, amount); + ApproveLib.approve(token, proxy, spender, amount); } } @@ -265,7 +266,7 @@ contract ERC20ApproveReturningFalseNonZeroAmountMainnetTest is MainnetController vm.expectRevert("MC/approve-failed"); IHarness(harness).approve(address(mock), makeAddr("spender"), 100); - vm.expectRevert("CurveLib/approve-failed"); + vm.expectRevert("MC/approve-failed"); IHarness(harness).approveCurve(address(almProxy), address(mock), makeAddr("spender"), 100); } diff --git a/test/mainnet-fork/Curve.t.sol b/test/mainnet-fork/Curve.t.sol index 3c676194..f2fa5748 100644 --- a/test/mainnet-fork/Curve.t.sol +++ b/test/mainnet-fork/Curve.t.sol @@ -1,8 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity >=0.8.0; -import { IERC4626 } from "forge-std/interfaces/IERC4626.sol"; - import { ReentrancyGuard } from "../../lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; import "./ForkTestBase.t.sol"; diff --git a/test/mainnet-fork/LayerZero.t.sol b/test/mainnet-fork/LayerZero.t.sol index 8f42ae1e..6b5e8748 100644 --- a/test/mainnet-fork/LayerZero.t.sol +++ b/test/mainnet-fork/LayerZero.t.sol @@ -62,6 +62,7 @@ contract MainnetControllerTransferLayerZeroFailureTests is MainnetControllerLaye function test_transferTokenLayerZero_zeroMaxAmount() external { vm.startPrank(SPARK_PROXY); + rateLimits.setRateLimitData( keccak256(abi.encode( mainnetController.LIMIT_LAYERZERO_TRANSFER(), @@ -71,6 +72,12 @@ contract MainnetControllerTransferLayerZeroFailureTests is MainnetControllerLaye 0, 0 ); + + mainnetController.setLayerZeroRecipient( + destinationEndpointId, + bytes32(uint256(uint160(makeAddr("layerZeroRecipient")))) + ); + vm.stopPrank(); vm.prank(relayer); @@ -131,6 +138,49 @@ contract MainnetControllerTransferLayerZeroFailureTests is MainnetControllerLaye ); } + function test_transferTokenLayerZero_recipientNotSet() external { + // Set up rate limit, but forget to set recipient + vm.startPrank(SPARK_PROXY); + + rateLimits.setRateLimitData( + keccak256(abi.encode( + mainnetController.LIMIT_LAYERZERO_TRANSFER(), + USDT_OFT, + destinationEndpointId + )), + 10_000_000e6, + 0 + ); + + vm.stopPrank(); + + bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0); + + bytes32 target = bytes32(uint256(uint160(makeAddr("layerZeroRecipient")))); + + SendParam memory sendParams = SendParam({ + dstEid : destinationEndpointId, + to : target, + amountLD : 10_000_000e6, + minAmountLD : 10_000_000e6, + extraOptions : options, + composeMsg : "", + oftCmd : "" + }); + + MessagingFee memory fee = ILayerZero(USDT_OFT).quoteSend(sendParams, false); + + deal(relayer, fee.nativeFee); + + vm.prank(relayer); + vm.expectRevert("MC/recipient-not-set"); + mainnetController.transferTokenLayerZero{value: fee.nativeFee}( + USDT_OFT, + 10_000_000e6, + destinationEndpointId + ); + } + } contract MainnetControllerTransferLayerZeroSuccessTests is MainnetControllerLayerZeroTestBase { @@ -376,6 +426,7 @@ contract ForeignControllerTransferLayerZeroFailureTests is ArbitrumChainLayerZer function test_transferTokenLayerZero_zeroMaxAmount() external { vm.startPrank(SPARK_EXECUTOR); + foreignRateLimits.setRateLimitData( keccak256(abi.encode( foreignController.LIMIT_LAYERZERO_TRANSFER(), @@ -385,6 +436,12 @@ contract ForeignControllerTransferLayerZeroFailureTests is ArbitrumChainLayerZer 0, 0 ); + + foreignController.setLayerZeroRecipient( + destinationEndpointId, + bytes32(uint256(uint160(makeAddr("layerZeroRecipient")))) + ); + vm.stopPrank(); vm.prank(relayer); @@ -448,6 +505,49 @@ contract ForeignControllerTransferLayerZeroFailureTests is ArbitrumChainLayerZer ); } + function test_transferTokenLayerZero_recipientNotSet() external { + // Set up rate limit, but forget to set recipient + vm.startPrank(SPARK_EXECUTOR); + + foreignRateLimits.setRateLimitData( + keccak256(abi.encode( + foreignController.LIMIT_LAYERZERO_TRANSFER(), + USDT_OFT, + destinationEndpointId + )), + 10_000_000e6, + 0 + ); + + vm.stopPrank(); + + bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200_000, 0); + + bytes32 target = bytes32(uint256(uint160(makeAddr("layerZeroRecipient")))); + + SendParam memory sendParams = SendParam({ + dstEid : destinationEndpointId, + to : target, + amountLD : 10_000_000e6, + minAmountLD : 10_000_000e6, + extraOptions : options, + composeMsg : "", + oftCmd : "" + }); + + MessagingFee memory fee = ILayerZero(USDT_OFT).quoteSend(sendParams, false); + + deal(relayer, fee.nativeFee); + + vm.prank(relayer); + vm.expectRevert("FC/recipient-not-set"); + foreignController.transferTokenLayerZero{value: fee.nativeFee}( + USDT_OFT, + 10_000_000e6, + destinationEndpointId + ); + } + } contract ForeignControllerTransferLayerZeroSuccessTests is ArbitrumChainLayerZeroTestBase { diff --git a/test/mainnet-fork/OTCSwaps.t.sol b/test/mainnet-fork/OTCSwaps.t.sol index b12567d5..69bdb54e 100644 --- a/test/mainnet-fork/OTCSwaps.t.sol +++ b/test/mainnet-fork/OTCSwaps.t.sol @@ -6,8 +6,6 @@ import { IERC20Metadata } from "../../lib/openzeppelin-contracts/ import { IERC20 as OzIERC20, SafeERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; import { ReentrancyGuard } from "../../lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; -import { OTC } from "src/MainnetController.sol"; - import { OTCBuffer } from "src/OTCBuffer.sol"; import { MockTokenReturnFalse } from "../mocks/Mocks.sol"; diff --git a/test/mainnet-fork/Uniswapv4.t.sol b/test/mainnet-fork/Uniswapv4.t.sol new file mode 100644 index 00000000..09867a6b --- /dev/null +++ b/test/mainnet-fork/Uniswapv4.t.sol @@ -0,0 +1,3891 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity >=0.8.0; + +import { console } from "../../lib/forge-std/src/console.sol"; + +import { ReentrancyGuard } from "../../lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; + +import { Currency } from "../../lib/uniswap-v4-core/src/types/Currency.sol"; +import { PoolId } from "../../lib/uniswap-v4-core/src/types/PoolId.sol"; +import { PoolKey } from "../../lib/uniswap-v4-core/src/types/PoolKey.sol"; +import { PositionInfo } from "../../lib/uniswap-v4-periphery/src/libraries/PositionInfoLibrary.sol"; +import { FullMath } from "../../lib/uniswap-v4-core/src/libraries/FullMath.sol"; +import { TickMath } from "../../lib/uniswap-v4-core/src/libraries/TickMath.sol"; + +import { IV4Router } from "../../lib/uniswap-v4-periphery/src/interfaces/IV4Router.sol"; +import { Actions } from "../../lib/uniswap-v4-periphery/src/libraries/Actions.sol"; +import { SlippageCheck } from "../../lib/uniswap-v4-periphery/src/libraries/SlippageCheck.sol"; + +import { IAccessControl } from "../../lib/openzeppelin-contracts/contracts/access/IAccessControl.sol"; + +import { ForkTestBase } from "./ForkTestBase.t.sol"; + +interface IERC20Like { + + // NOTE: Purposely not returning bool to avoid issues with non-conformant tokens. + function approve(address spender, uint256 amount) external; + + function allowance(address owner, address spender) external view returns (uint256 allowance); + + function balanceOf(address owner) external view returns (uint256 balance); + + function decimals() external view returns (uint8 decimals); + +} + +interface IPermit2Like { + + function approve(address token, address spender, uint160 amount, uint48 expiration) external; + + function allowance(address user, address token, address spender) + external view returns (uint160 amount, uint48 expiration, uint48 nonce); + +} + +interface IPoolManagerLike { + + error CurrencyNotSettled(); + +} + +interface IPositionManagerLike { + + function transferFrom(address from, address to, uint256 id) external; + + function getPoolAndPositionInfo(uint256 tokenId) + external view returns (PoolKey memory poolKey, PositionInfo info); + + function getPositionLiquidity(uint256 tokenId) external view returns (uint128 liquidity); + + function nextTokenId() external view returns (uint256 nextTokenId); + + function ownerOf(uint256 tokenId) external view returns (address owner); + + function poolKeys(bytes25 poolId) external view returns (PoolKey memory poolKeys); + +} + +interface IStateViewLike { + + function getSlot0(PoolId poolId) + external view returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee); + +} + +interface IUniversalRouterLike { + + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external; + +} + +interface IV4QuoterLike { + + struct QuoteExactSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 exactAmount; + bytes hookData; + } + + function quoteExactInputSingle(QuoteExactSingleParams memory params) + external returns (uint256 amountOut, uint256 gasEstimate); + +} + +interface IV4RouterLike { + + error V4TooLittleReceived(uint256 minAmountOutReceived, uint256 amountReceived); + +} + +contract UniswapV4TestBase is ForkTestBase { + + struct IncreasePositionResult { + uint256 tokenId; + uint256 amount0Spent; + uint256 amount1Spent; + uint128 liquidityIncrease; + int24 tickLower; + int24 tickUpper; + } + + struct DecreasePositionResult { + uint256 tokenId; + uint256 amount0Received; + uint256 amount1Received; + uint128 liquidityDecrease; + int24 tickLower; + int24 tickUpper; + } + + uint256 internal constant _V4_SWAP = 0x10; + + bytes32 internal constant _LIMIT_DEPOSIT = keccak256("LIMIT_UNISWAP_V4_DEPOSIT"); + bytes32 internal constant _LIMIT_WITHDRAW = keccak256("LIMIT_UNISWAP_V4_WITHDRAW"); + bytes32 internal constant _LIMIT_SWAP = keccak256("LIMIT_UNISWAP_V4_SWAP"); + + address internal constant _PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + address internal constant _POSITION_MANAGER = 0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e; + address internal constant _ROUTER = 0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af; + address internal constant _STATE_VIEW = 0x7fFE42C4a5DEeA5b0feC41C94C136Cf115597227; + address internal constant _V4_QUOTER = 0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203; + + address internal immutable _unauthorized = makeAddr("unauthorized"); + address internal immutable _user = makeAddr("user"); + + /**********************************************************************************************/ + /*** Helper Functions ***/ + /**********************************************************************************************/ + + function _setupLiquidity(bytes32 poolId, int24 tickLower, int24 tickUpper, uint128 liquidity) + internal returns (IncreasePositionResult memory minted) + { + bytes32 depositLimitKey = keccak256(abi.encode(_LIMIT_DEPOSIT, poolId)); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(poolId, tickLower, tickUpper, uint24(uint256(int256(tickUpper) - int256(tickLower)))); + rateLimits.setRateLimitData(depositLimitKey, 200_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Max, uint256 amount1Max ) = _getIncreasePositionMaxAmounts(poolId, tickLower, tickUpper, liquidity, 0.9999e18); + + minted = _mintPosition(poolId, tickLower, tickUpper, liquidity, amount0Max, amount1Max); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(poolId, 0, 0, 0); + rateLimits.setRateLimitData(depositLimitKey, 0, 0); + vm.stopPrank(); + } + + function _getIncreasePositionMaxAmounts( + bytes32 poolId, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 maxSlippage + ) + internal returns (uint256 amount0Max, uint256 amount1Max) + { + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + poolId, + tickLower, + tickUpper, + liquidity + ); + + amount0Max = (amount0Forecasted * 1e18) / maxSlippage; + amount1Max = (amount1Forecasted * 1e18) / maxSlippage; + } + + function _mintPosition( + bytes32 poolId, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 amount0Max, + uint256 amount1Max + ) + internal returns (IncreasePositionResult memory result) + { + PoolKey memory poolKey = IPositionManagerLike(_POSITION_MANAGER).poolKeys(bytes25(poolId)); + + deal( + Currency.unwrap(poolKey.currency0), + address(almProxy), + _getBalanceOf(poolKey.currency0, address(almProxy)) + amount0Max + ); + + deal( + Currency.unwrap(poolKey.currency1), + address(almProxy), + _getBalanceOf(poolKey.currency1, address(almProxy)) + amount1Max + ); + + uint256 token0BeforeCall = _getBalanceOf(poolKey.currency0, address(almProxy)); + uint256 token1BeforeCall = _getBalanceOf(poolKey.currency1, address(almProxy)); + bytes32 depositLimitKey = keccak256(abi.encode(_LIMIT_DEPOSIT, poolId)); + uint256 rateLimitBeforeCall = rateLimits.getCurrentRateLimit(depositLimitKey); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : poolId, + tickLower : tickLower, + tickUpper : tickUpper, + liquidity : liquidity, + amount0Max : amount0Max, + amount1Max : amount1Max + }); + + result.tokenId = IPositionManagerLike(_POSITION_MANAGER).nextTokenId() - 1; + result.amount0Spent = token0BeforeCall - _getBalanceOf(poolKey.currency0, address(almProxy)); + result.amount1Spent = token1BeforeCall - _getBalanceOf(poolKey.currency1, address(almProxy)); + result.liquidityIncrease = liquidity; + result.tickLower = tickLower; + result.tickUpper = tickUpper; + + assertLe(result.amount0Spent, amount0Max); + assertLe(result.amount1Spent, amount1Max); + + assertEq( + rateLimitBeforeCall - rateLimits.getCurrentRateLimit(depositLimitKey), + _toNormalizedAmount(poolKey.currency0, result.amount0Spent) + + _toNormalizedAmount(poolKey.currency1, result.amount1Spent) + ); + + assertEq(IPositionManagerLike(_POSITION_MANAGER).ownerOf(result.tokenId), address(almProxy)); + + assertEq(IPositionManagerLike(_POSITION_MANAGER).getPositionLiquidity(result.tokenId), result.liquidityIncrease); + + _assertZeroAllowances(Currency.unwrap(poolKey.currency0)); + _assertZeroAllowances(Currency.unwrap(poolKey.currency1)); + } + + function _increasePosition( + uint256 tokenId, + uint128 liquidityIncrease, + uint256 amount0Max, + uint256 amount1Max + ) + internal returns (IncreasePositionResult memory result) + { + ( + PoolKey memory poolKey, + PositionInfo positionInfo + ) = IPositionManagerLike(_POSITION_MANAGER).getPoolAndPositionInfo(tokenId); + + bytes32 poolId = keccak256(abi.encode(poolKey)); + + deal( + Currency.unwrap(poolKey.currency0), + address(almProxy), + _getBalanceOf(poolKey.currency0, address(almProxy)) + amount0Max + ); + + deal( + Currency.unwrap(poolKey.currency1), + address(almProxy), + _getBalanceOf(poolKey.currency1, address(almProxy)) + amount1Max + ); + + uint256 token0BeforeCall = _getBalanceOf(poolKey.currency0, address(almProxy)); + uint256 token1BeforeCall = _getBalanceOf(poolKey.currency1, address(almProxy)); + bytes32 depositLimitKey = keccak256(abi.encode(_LIMIT_DEPOSIT, poolId)); + uint256 rateLimitBeforeCall = rateLimits.getCurrentRateLimit(depositLimitKey); + + uint256 positionLiquidityBeforeCall = IPositionManagerLike(_POSITION_MANAGER).getPositionLiquidity(tokenId); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : poolId, + tokenId : tokenId, + liquidityIncrease : liquidityIncrease, + amount0Max : amount0Max, + amount1Max : amount1Max + }); + + result.tokenId = tokenId; + result.amount0Spent = token0BeforeCall - _getBalanceOf(poolKey.currency0, address(almProxy)); + result.amount1Spent = token1BeforeCall - _getBalanceOf(poolKey.currency1, address(almProxy)); + result.liquidityIncrease = liquidityIncrease; + result.tickLower = positionInfo.tickLower(); + result.tickUpper = positionInfo.tickUpper(); + + assertLe(result.amount0Spent, amount0Max); + assertLe(result.amount1Spent, amount1Max); + + assertEq( + rateLimitBeforeCall - rateLimits.getCurrentRateLimit(depositLimitKey), + _toNormalizedAmount(poolKey.currency0, result.amount0Spent) + + _toNormalizedAmount(poolKey.currency1, result.amount1Spent) + ); + + assertEq(IPositionManagerLike(_POSITION_MANAGER).ownerOf(result.tokenId), address(almProxy)); + + assertEq( + IPositionManagerLike(_POSITION_MANAGER).getPositionLiquidity(tokenId), + positionLiquidityBeforeCall + result.liquidityIncrease + ); + + _assertZeroAllowances(Currency.unwrap(poolKey.currency0)); + _assertZeroAllowances(Currency.unwrap(poolKey.currency1)); + } + + function _getDecreasePositionMinAmounts(uint256 tokenId, uint128 liquidity, uint256 maxSlippage) + internal returns (uint256 amount0Min, uint256 amount1Min) + { + ( + PoolKey memory poolKey, + PositionInfo positionInfo + ) = IPositionManagerLike(_POSITION_MANAGER).getPoolAndPositionInfo(tokenId); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + keccak256(abi.encode(poolKey)), + positionInfo.tickLower(), + positionInfo.tickUpper(), + liquidity + ); + + amount0Min = (amount0Forecasted * maxSlippage) / 1e18; + amount1Min = (amount1Forecasted * maxSlippage) / 1e18; + } + + function _decreasePosition( + uint256 tokenId, + uint128 liquidityDecrease, + uint256 amount0Min, + uint256 amount1Min + ) + internal returns (DecreasePositionResult memory result) + { + ( + PoolKey memory poolKey, + PositionInfo positionInfo + ) = IPositionManagerLike(_POSITION_MANAGER).getPoolAndPositionInfo(tokenId); + + bytes32 poolId = keccak256(abi.encode(poolKey)); + + uint256 token0BeforeCall = _getBalanceOf(poolKey.currency0, address(almProxy)); + uint256 token1BeforeCall = _getBalanceOf(poolKey.currency1, address(almProxy)); + uint256 rateLimitBeforeCall = rateLimits.getCurrentRateLimit(keccak256(abi.encode(_LIMIT_WITHDRAW, poolId))); + + uint256 positionLiquidityBeforeCall = IPositionManagerLike(_POSITION_MANAGER).getPositionLiquidity(tokenId); + + vm.prank(relayer); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : poolId, + tokenId : tokenId, + liquidityDecrease : liquidityDecrease, + amount0Min : amount0Min, + amount1Min : amount1Min + }); + + result.tokenId = tokenId; + result.amount0Received = _getBalanceOf(poolKey.currency0, address(almProxy)) - token0BeforeCall; + result.amount1Received = _getBalanceOf(poolKey.currency1, address(almProxy)) - token1BeforeCall; + result.liquidityDecrease = liquidityDecrease; + result.tickLower = positionInfo.tickLower(); + result.tickUpper = positionInfo.tickUpper(); + + assertGe(result.amount0Received, amount0Min); + assertGe(result.amount1Received, amount1Min); + + assertEq( + rateLimitBeforeCall - rateLimits.getCurrentRateLimit(keccak256(abi.encode(_LIMIT_WITHDRAW, poolId))), + _toNormalizedAmount(poolKey.currency0, result.amount0Received) + + _toNormalizedAmount(poolKey.currency1, result.amount1Received) + ); + + assertEq(IPositionManagerLike(_POSITION_MANAGER).ownerOf(result.tokenId), address(almProxy)); + + assertEq( + IPositionManagerLike(_POSITION_MANAGER).getPositionLiquidity(tokenId), + positionLiquidityBeforeCall - result.liquidityDecrease + ); + } + + function _getSwapAmountOutMin( + bytes32 poolId, + address tokenIn, + uint128 amountIn, + uint256 maxSlippage + ) + internal returns (uint128 amountOutMin) + { + PoolKey memory poolKey = IPositionManagerLike(_POSITION_MANAGER).poolKeys(bytes25(poolId)); + + address token0 = Currency.unwrap(poolKey.currency0); + address token1 = Currency.unwrap(poolKey.currency1); + + IV4QuoterLike.QuoteExactSingleParams memory params = IV4QuoterLike.QuoteExactSingleParams({ + poolKey : poolKey, + zeroForOne : tokenIn == token0, + exactAmount : amountIn, + hookData : bytes("") + }); + + ( uint256 amountOut, ) = IV4QuoterLike(_V4_QUOTER).quoteExactInputSingle(params); + + return uint128((amountOut * maxSlippage) / 1e18); + } + + function _swap(bytes32 poolId, address tokenIn, uint128 amountIn, uint128 amountOutMin) + internal returns (uint256 amountOut) + { + Currency currencyIn = Currency.wrap(tokenIn); + Currency currencyOut = _getCurrencyOut(poolId, tokenIn); + + deal( + Currency.unwrap(currencyIn), + address(almProxy), _getBalanceOf(currencyIn, address(almProxy)) + amountIn + ); + + uint256 tokenInBeforeCall = _getBalanceOf(currencyIn, address(almProxy)); + uint256 tokenOutBeforeCall = _getBalanceOf(currencyOut, address(almProxy)); + uint256 rateLimitBeforeCall = rateLimits.getCurrentRateLimit(keccak256(abi.encode(_LIMIT_SWAP, poolId))); + + vm.prank(relayer); + mainnetController.swapUniswapV4({ + poolId : poolId, + tokenIn : tokenIn, + amountIn : amountIn, + amountOutMin : amountOutMin + }); + + uint256 tokenInAfterCall = _getBalanceOf(currencyIn, address(almProxy)); + uint256 tokenOutAfterCall = _getBalanceOf(currencyOut, address(almProxy)); + uint256 rateLimitAfterCall = rateLimits.getCurrentRateLimit(keccak256(abi.encode(_LIMIT_SWAP, poolId))); + + assertEq(tokenInBeforeCall - tokenInAfterCall, amountIn); + assertGe(tokenOutAfterCall - tokenOutBeforeCall, amountOutMin); + + assertEq( + rateLimitBeforeCall - rateLimitAfterCall, + _toNormalizedAmount(currencyIn, amountIn) + ); + + _assertZeroAllowances(Currency.unwrap(currencyIn)); + _assertZeroAllowances(Currency.unwrap(currencyOut)); + + return tokenOutAfterCall - tokenOutBeforeCall; + } + + function _getAmount0ForLiquidity( + uint160 sqrtPriceAX96, + uint160 sqrtPriceBX96, + uint128 liquidity + ) + internal pure returns (uint256 amount0) + { + require(sqrtPriceAX96 < sqrtPriceBX96, "invalid-sqrtPrices-0"); + + return FullMath.mulDiv( + uint256(liquidity) << 96, + sqrtPriceBX96 - sqrtPriceAX96, + uint256(sqrtPriceBX96) * sqrtPriceAX96 + ); + } + + function _getAmount1ForLiquidity( + uint160 sqrtPriceAX96, + uint160 sqrtPriceBX96, + uint128 liquidity + ) + internal pure returns (uint256 amount1) + { + require(sqrtPriceAX96 < sqrtPriceBX96, "invalid-sqrtPrices-1"); + + return FullMath.mulDiv(liquidity, sqrtPriceBX96 - sqrtPriceAX96, 1 << 96); + } + + function _getAmountsForLiquidity( + uint160 sqrtPriceX96, + uint160 sqrtPriceAX96, + uint160 sqrtPriceBX96, + uint128 liquidity + ) + internal pure returns (uint256 amount0, uint256 amount1) + { + require(sqrtPriceAX96 < sqrtPriceBX96, "invalid-sqrtPrices"); + + if (sqrtPriceX96 <= sqrtPriceAX96) { + return ( + _getAmount0ForLiquidity(sqrtPriceAX96, sqrtPriceBX96, liquidity), + 0 + ); + } + + if (sqrtPriceX96 >= sqrtPriceBX96) { + return ( + 0, + _getAmount1ForLiquidity(sqrtPriceAX96, sqrtPriceBX96, liquidity) + ); + } + + return ( + _getAmount0ForLiquidity(sqrtPriceX96, sqrtPriceBX96, liquidity), + _getAmount1ForLiquidity(sqrtPriceAX96, sqrtPriceX96, liquidity) + ); + } + + function _getPrice(uint160 sqrtPriceX96) internal view returns (uint256 price) { + uint256 priceRoot = (uint256(sqrtPriceX96) * 1e18) >> 96; + + return (priceRoot * priceRoot) / 1e18; + } + + function _getPrice(int24 tick) internal view returns (uint256 price) { + return _getPrice(TickMath.getSqrtPriceAtTick(tick)); + } + + function _getCurrentTick(bytes32 poolId) internal view returns (int24 tick) { + ( uint160 sqrtPriceX96, , , ) = IStateViewLike(_STATE_VIEW).getSlot0(PoolId.wrap(poolId)); + + return TickMath.getTickAtSqrtPrice(sqrtPriceX96); + } + + function _logCurrentPriceAndTick(bytes32 poolId) internal view { + ( uint160 sqrtPriceX96, , , ) = IStateViewLike(_STATE_VIEW).getSlot0(PoolId.wrap(poolId)); + + uint256 price = _getPrice(sqrtPriceX96); + int24 tick = TickMath.getTickAtSqrtPrice(sqrtPriceX96); + + if (price < 1e1) { + console.log("price: 0.00000000000000000%s", price); + } else if (price < 1e2) { + console.log("price: 0.0000000000000000%s", price); + } else if (price < 1e3) { + console.log("price: 0.000000000000000%s", price); + } else if (price < 1e4) { + console.log("price: 0.00000000000000%s", price); + } else if (price < 1e5) { + console.log("price: 0.0000000000000%s", price); + } else if (price < 1e6) { + console.log("price: 0.000000000000%s", price); + } else if (price < 1e7) { + console.log("price: 0.00000000000%s", price); + } else if (price < 1e8) { + console.log("price: 0.0000000000%s", price); + } else if (price < 1e9) { + console.log("price: 0.000000000%s", price); + } else if (price < 1e10) { + console.log("price: 0.00000000%s", price); + } else if (price < 1e11) { + console.log("price: 0.0000000%s", price); + } else if (price < 1e12) { + console.log("price: 0.000000%s", price); + } else if (price < 1e13) { + console.log("price: 0.00000%s", price); + } else if (price < 1e14) { + console.log("price: 0.0000%s", price); + } else if (price < 1e15) { + console.log("price: 0.000%s", price); + } else if (price < 1e16) { + console.log("price: 0.00%s", price); + } else if (price < 1e17) { + console.log("price: 0.0%s", price); + } else { + uint256 quotient = price / 1e18; + uint256 remainder = price % 1e18; + + if (remainder < 1e1) { + console.log("price: %s.00000000000000000%s", quotient, remainder); + } else if (remainder < 1e2) { + console.log("price: %s.0000000000000000%s", quotient, remainder); + } else if (remainder < 1e3) { + console.log("price: %s.000000000000000%s", quotient, remainder); + } else if (remainder < 1e4) { + console.log("price: %s.00000000000000%s", quotient, remainder); + } else if (remainder < 1e5) { + console.log("price: %s.0000000000000%s", quotient, remainder); + } else if (remainder < 1e6) { + console.log("price: %s.000000000000%s", quotient, remainder); + } else if (remainder < 1e7) { + console.log("price: %s.00000000000%s", quotient, remainder); + } else if (remainder < 1e8) { + console.log("price: %s.0000000000%s", quotient, remainder); + } else if (remainder < 1e9) { + console.log("price: %s.000000000%s", quotient, remainder); + } else if (remainder < 1e10) { + console.log("price: %s.00000000%s", quotient, remainder); + } else if (remainder < 1e11) { + console.log("price: %s.0000000%s", quotient, remainder); + } else if (remainder < 1e12) { + console.log("price: %s.000000%s", quotient, remainder); + } else if (remainder < 1e13) { + console.log("price: %s.00000%s", quotient, remainder); + } else if (remainder < 1e14) { + console.log("price: %s.0000%s", quotient, remainder); + } else if (remainder < 1e15) { + console.log("price: %s.000%s", quotient, remainder); + } else if (remainder < 1e16) { + console.log("price: %s.00%s", quotient, remainder); + } else if (remainder < 1e17) { + console.log("price: %s.0%s", quotient, remainder); + } else { + console.log("price: %s.%s", quotient, remainder); + } + } + + console.log(" -> tick: %s", tick); + } + + function _quoteLiquidity( + bytes32 poolId, + int24 tickLower, + int24 tickUpper, + uint128 liquidityAmount + ) + internal view returns (uint256 amount0, uint256 amount1) + { + ( uint160 sqrtPriceX96, , , ) = IStateViewLike(_STATE_VIEW).getSlot0(PoolId.wrap(poolId)); + + return _getAmountsForLiquidity( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(tickLower), + TickMath.getSqrtPriceAtTick(tickUpper), + liquidityAmount + ); + } + + function _assertZeroAllowances(address token) internal { + ( uint160 allowance, , ) = IPermit2Like(_PERMIT2).allowance(address(almProxy), token, _POSITION_MANAGER); + + assertEq(allowance, 0, "permit2 allowance not 0"); + + assertEq(IERC20Like(token).allowance(address(almProxy), _PERMIT2), 0, "allowance to permit2 not 0"); + } + + function _to18From6Decimals(uint256 amount) internal pure returns (uint256) { + return amount * 1e12; + } + + function _toNormalizedAmount(address token, uint256 amount) + internal view returns (uint256 normalizedAmount) + { + return amount * 1e18 / (10 ** IERC20Like(token).decimals()); + } + + function _toNormalizedAmount(Currency currency, uint256 amount) + internal view returns (uint256 normalizedAmount) + { + return amount * 1e18 / (10 ** IERC20Like(Currency.unwrap(currency)).decimals()); + } + + function _getBlock() internal pure override returns (uint256) { + return 23470490; // September 29, 2025 + } + + function _externalSwap(bytes32 poolId, address account, address tokenIn, uint128 amountIn) + internal returns (uint256 amountOut) + { + PoolKey memory poolKey = IPositionManagerLike(_POSITION_MANAGER).poolKeys(bytes25(poolId)); + + address token0 = Currency.unwrap(poolKey.currency0); + address token1 = Currency.unwrap(poolKey.currency1); + address tokenOut = tokenIn == token0 ? token1 : token0; + + deal(tokenIn, account, amountIn); + + bytes memory commands = abi.encodePacked(uint8(_V4_SWAP)); + + bytes[] memory inputs = new bytes[](1); + + bytes memory actions = abi.encodePacked( + uint8(Actions.SWAP_EXACT_IN_SINGLE), + uint8(Actions.SETTLE_ALL), + uint8(Actions.TAKE_ALL) + ); + + bytes[] memory params = new bytes[](3); + + params[0] = abi.encode( + IV4Router.ExactInputSingleParams({ + poolKey : poolKey, + zeroForOne : tokenIn == token0, + amountIn : amountIn, + amountOutMinimum : 0, + hookData : bytes("") + }) + ); + + params[1] = abi.encode(tokenIn, amountIn); + params[2] = abi.encode(tokenOut, 0); + + // Combine actions and params into inputs + inputs[0] = abi.encode(actions, params); + + uint256 startingOutBalance = IERC20Like(tokenOut).balanceOf(account); + + // Execute the swap + vm.startPrank(account); + IERC20Like(tokenIn).approve(_PERMIT2, amountIn); + IPermit2Like(_PERMIT2).approve(tokenIn, _ROUTER, amountIn, uint48(block.timestamp)); + IUniversalRouterLike(_ROUTER).execute(commands, inputs, block.timestamp); + vm.stopPrank(); + + return IERC20Like(tokenOut).balanceOf(account) - startingOutBalance; + } + + function _getBalanceOf(Currency currency, address account) + internal view returns (uint256 balance) + { + return IERC20Like(Currency.unwrap(currency)).balanceOf(account); + } + + function _getCurrencyOut(bytes32 poolId, address tokenIn) + internal view returns (Currency currencyOut) + { + PoolKey memory poolKey = IPositionManagerLike(_POSITION_MANAGER).poolKeys(bytes25(poolId)); + + return tokenIn == Currency.unwrap(poolKey.currency0) ? poolKey.currency1 : poolKey.currency0; + } + +} + +contract MainnetController_UniswapV4_Tests is UniswapV4TestBase { + + /**********************************************************************************************/ + /*** mintPositionUniswapV4 Tests ***/ + /**********************************************************************************************/ + + function test_mintPositionUniswapV4_reentrancy() external { + _setControllerEntered(); + vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); + mainnetController.mintPositionUniswapV4({ + poolId : bytes32(0), + tickLower : 0, + tickUpper : 0, + liquidity : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + function test_mintPositionUniswapV4_revertsForNonRelayer() external { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + _unauthorized, + mainnetController.RELAYER() + ) + ); + + vm.prank(_unauthorized); + mainnetController.mintPositionUniswapV4({ + poolId : bytes32(0), + tickLower : 0, + tickUpper : 0, + liquidity : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + /**********************************************************************************************/ + /*** increaseLiquidity Tests ***/ + /**********************************************************************************************/ + + function test_increaseLiquidityUniswapV4_reentrancy() external { + _setControllerEntered(); + vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); + mainnetController.increaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : 0, + liquidityIncrease : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + function test_increaseLiquidityUniswapV4_revertsForNonRelayer() external { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + _unauthorized, + mainnetController.RELAYER() + ) + ); + + vm.prank(_unauthorized); + mainnetController.increaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : 0, + liquidityIncrease : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + /**********************************************************************************************/ + /*** decreaseLiquidityUniswapV4 Tests ***/ + /**********************************************************************************************/ + + function test_decreaseLiquidityUniswapV4_reentrancy() external { + _setControllerEntered(); + vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : 0, + liquidityDecrease : 0, + amount0Min : 0, + amount1Min : 0 + }); + } + + function test_decreaseLiquidityUniswapV4_revertsForNonRelayer() external { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + _unauthorized, + mainnetController.RELAYER() + ) + ); + + vm.prank(_unauthorized); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : 0, + liquidityDecrease : 0, + amount0Min : 0, + amount1Min : 0 + }); + } + + /**********************************************************************************************/ + /*** swapUniswapV4 Tests ***/ + /**********************************************************************************************/ + + function test_swapUniswapV4_reentrancy() external { + _setControllerEntered(); + vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); + mainnetController.swapUniswapV4(bytes32(0), address(0), 0, 0); + } + + function test_swapUniswapV4_revertsForNonRelayer() external { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + _unauthorized, + mainnetController.RELAYER() + ) + ); + + vm.prank(_unauthorized); + mainnetController.swapUniswapV4(bytes32(0), address(0), 0, 0); + } + +} + +contract MainnetController_UniswapV4_USDC_USDT_Tests is UniswapV4TestBase { + + // Uniswap V4 USDC/USDT pool + bytes32 internal constant _POOL_ID = 0x8aa4e11cbdf30eedc92100f4c8a31ff748e201d44712cc8c90d189edaa8e4e47; + + bytes32 internal constant _DEPOSIT_LIMIT_KEY = keccak256(abi.encode(_LIMIT_DEPOSIT, _POOL_ID)); + bytes32 internal constant _WITHDRAW_LIMIT_KEY = keccak256(abi.encode(_LIMIT_WITHDRAW, _POOL_ID)); + bytes32 internal constant _SWAP_LIMIT_KEY = keccak256(abi.encode(_LIMIT_SWAP, _POOL_ID)); + + /**********************************************************************************************/ + /*** mintPositionUniswapV4 Tests ***/ + /**********************************************************************************************/ + + function test_mintPositionUniswapV4_revertsWhenTickLimitsNotSet() external { + vm.prank(relayer); + vm.expectRevert("MC/tickLimits-not-set"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 0, + tickUpper : 0, + liquidity : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + function test_mintPositionUniswapV4_revertsWhenTicksMisorderedBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + deal(address(usdt), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/ticks-misordered"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : -6, + liquidity : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + + vm.prank(relayer); + vm.expectRevert("MC/ticks-misordered"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : -5, + liquidity : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : -4, + liquidity : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + } + + function test_mintPositionUniswapV4_revertsWhenTickLowerTooLowBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + deal(address(usdt), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/tickLower-too-low"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -11, + tickUpper : -5, + liquidity : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : -5, + liquidity : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + } + + function test_mintPositionUniswapV4_revertsWhenTickUpperTooHighBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + deal(address(usdt), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/tickUpper-too-high"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : 1, + liquidity : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + } + + function test_mintPositionUniswapV4_revertsWhenTickSpacingTooWideBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 10, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + deal(address(usdt), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/tickSpacing-too-wide"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : 6, + liquidity : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : 5, + liquidity : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + } + + function test_mintPositionUniswapV4_revertsWhenMaxAmountsTooLargeForPermit2Boundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -60, 60, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + deal(address(usdt), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/amount-too-large-for-permit2"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : uint256(type(uint160).max) + 1, + amount1Max : uint256(type(uint160).max) + }); + + vm.prank(relayer); + vm.expectRevert("MC/amount-too-large-for-permit2"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : uint256(type(uint160).max), + amount1Max : uint256(type(uint160).max) + 1 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : uint256(type(uint160).max), + amount1Max : uint256(type(uint160).max) + }); + } + + function test_mintPositionUniswapV4_revertsWhenMaxAmountsSurpassedBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -60, 60, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdc), address(almProxy), amount0Forecasted); + deal(address(usdt), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, amount0Forecasted - 1, amount0Forecasted) + ); + + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : amount0Forecasted - 1, + amount1Max : amount1Forecasted + }); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, amount1Forecasted - 1, amount1Forecasted) + ); + + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted - 1 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_mintPositionUniswapV4_revertsWhenRateLimitExceededBoundary() external { + uint256 expectedDecrease = 499.966111e18; + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -60, 60, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, expectedDecrease - 1, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdc), address(almProxy), amount0Forecasted); + deal(address(usdt), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, expectedDecrease, 0); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_mintPositionUniswapV4() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -60, 60, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Max, uint256 amount1Max ) = _getIncreasePositionMaxAmounts(_POOL_ID, -10, 0, 1_000_000e6, 0.99e18); + + vm.record(); + + IncreasePositionResult memory result = _mintPosition({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 1_000_000e6, + amount0Max : amount0Max, + amount1Max : amount1Max + }); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(result.amount0Spent, 340.756158e6); + assertEq(result.amount1Spent, 159.209953e6); + } + + /**********************************************************************************************/ + /*** increaseLiquidity Tests ***/ + /**********************************************************************************************/ + + function test_increaseLiquidityUniswapV4_revertsWhenPositionIsNotOwnedByProxy() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.prank(address(almProxy)); + IPositionManagerLike(_POSITION_MANAGER).transferFrom(address(almProxy), address(1), minted.tokenId); + + vm.prank(relayer); + vm.expectRevert("MC/non-proxy-position"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : minted.tokenId, + liquidityIncrease : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenTokenIsNotForPool() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/tokenId-poolId-mismatch"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : minted.tokenId, + liquidityIncrease : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenTickLowerTooLowBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -9, 0, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + deal(address(usdt), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/tickLower-too-low"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + + vm.prank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenTickUpperTooHighBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, -1, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + deal(address(usdt), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/tickUpper-too-high"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + + vm.prank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenTickSpacingTooWideBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 9); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + deal(address(usdt), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/tickSpacing-too-wide"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + + vm.prank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e6 + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenAmountsTooLargeForPermit2Boundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + deal(address(usdt), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/amount-too-large-for-permit2"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : uint256(type(uint160).max) + 1, + amount1Max : uint256(type(uint160).max) + }); + + vm.prank(relayer); + vm.expectRevert("MC/amount-too-large-for-permit2"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : uint256(type(uint160).max), + amount1Max : uint256(type(uint160).max) + 1 + }); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : uint256(type(uint160).max), + amount1Max : uint256(type(uint160).max) + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenMaxAmountsSurpassedBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + 1_000_000e6 + ); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdc), address(almProxy), amount0Forecasted); + deal(address(usdt), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, amount0Forecasted - 1, amount0Forecasted) + ); + + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : amount0Forecasted - 1, + amount1Max : amount1Forecasted + }); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, amount1Forecasted - 1, amount1Forecasted) + ); + + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted - 1 + }); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenRateLimitExceededBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + uint256 expectedDecrease = 499.966111e18; + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, expectedDecrease - 1, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + 1_000_000e6 + ); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdc), address(almProxy), amount0Forecasted); + deal(address(usdt), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, expectedDecrease, 0); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e6, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_increaseLiquidityUniswapV4() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10, 0, 10); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Max, uint256 amount1Max ) = _getIncreasePositionMaxAmounts( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + 1_000_000e6, + 0.99e18 + ); + + vm.record(); + + IncreasePositionResult memory result = _increasePosition( + minted.tokenId, + 1_000_000e6, + amount0Max, + amount1Max + ); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(result.amount0Spent, 340.756158e6); + assertEq(result.amount1Spent, 159.209953e6); + } + + /**********************************************************************************************/ + /*** decreaseLiquidityUniswapV4 Tests ***/ + /**********************************************************************************************/ + + function test_decreaseLiquidityUniswapV4_revertsWhenTokenIsNotForPool() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/tokenId-poolId-mismatch"); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : minted.tokenId, + liquidityDecrease : 0, + amount0Min : 0, + amount1Min : 0 + }); + } + + function test_decreaseLiquidityUniswapV4_revertsWhenAmount0MinNotMetBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + minted.liquidityIncrease / 2 + ); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector( + SlippageCheck.MinimumAmountInsufficient.selector, + amount0Forecasted + 1, + amount0Forecasted + ) + ); + + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted + 1, + amount1Min : amount1Forecasted + }); + + vm.prank(relayer); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + }); + } + + function test_decreaseLiquidityUniswapV4_revertsWhenAmount1MinNotMetBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + minted.liquidityIncrease / 2 + ); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector( + SlippageCheck.MinimumAmountInsufficient.selector, + amount1Forecasted + 1, + amount1Forecasted + ) + ); + + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + 1 + }); + + vm.prank(relayer); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + }); + } + + function test_decreaseLiquidityUniswapV4_revertsWhenRateLimitExceededBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + uint256 expectedDecrease = 249.983054e18; + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, expectedDecrease - 1, 0); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + minted.liquidityIncrease / 2 + ); + + vm.prank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + }); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, expectedDecrease, 0); + + vm.prank(relayer); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + }); + } + + function test_decreaseLiquidityUniswapV4_partial() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + + ( uint256 amount0Min, uint256 amount1Min ) = _getDecreasePositionMinAmounts(minted.tokenId, minted.liquidityIncrease / 2, 0.99e18); + + vm.record(); + + DecreasePositionResult memory result = _decreasePosition( + minted.tokenId, + minted.liquidityIncrease / 2, + amount0Min, + amount1Min + ); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(result.amount0Received, 170.378078e6); + assertEq(result.amount1Received, 79.604976e6); + } + + function test_decreaseLiquidityUniswapV4_all() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, -10, 0, 1_000_000e6); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + + ( uint256 amount0Min, uint256 amount1Min ) = _getDecreasePositionMinAmounts( + minted.tokenId, + minted.liquidityIncrease, + 0.99e18 + ); + + vm.record(); + + DecreasePositionResult memory result = _decreasePosition( + minted.tokenId, + minted.liquidityIncrease, + amount0Min, + amount1Min + ); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(result.amount0Received, 340.756157e6); + assertEq(result.amount1Received, 159.209952e6); + } + + /**********************************************************************************************/ + /*** swapUniswapV4 Tests ***/ + /**********************************************************************************************/ + + function test_swapUniswapV4_revertsWhenMaxSlippageNotSet() external { + vm.prank(relayer); + vm.expectRevert("MC/max-slippage-not-set"); + mainnetController.swapUniswapV4(_POOL_ID, address(0), 0, 0); + } + + function test_swapUniswapV4_revertsWhenRateLimitExceededBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 1_000_000e18, 0); + vm.stopPrank(); + + uint128 amountOutMin = _getSwapAmountOutMin(_POOL_ID, address(usdc), 1_000_000e6, 0.99e18); + + deal(address(usdc), address(almProxy), 1_000_000e6 + 1); + + vm.prank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.swapUniswapV4({ + poolId : _POOL_ID, + tokenIn : address(usdc), + amountIn : 1_000_000e6 + 1, + amountOutMin : amountOutMin + }); + + vm.prank(relayer); + mainnetController.swapUniswapV4({ + poolId : _POOL_ID, + tokenIn : address(usdc), + amountIn : 1_000_000e6, + amountOutMin : amountOutMin + }); + } + + function test_swapUniswapV4_revertsWhenInputTokenNotForPool() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + vm.prank(relayer); + vm.expectRevert("MC/invalid-tokenIn"); + mainnetController.swapUniswapV4(_POOL_ID, address(dai), 1_000_000e6, 1_000_000e6); + } + + function test_swapUniswapV4_revertsWhenAmountOutMinTooLowBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/amountOutMin-too-low"); + mainnetController.swapUniswapV4(_POOL_ID, address(usdc), 1_000_000e6, 980_000e6 - 1); + + vm.prank(relayer); + mainnetController.swapUniswapV4(_POOL_ID, address(usdc), 1_000_000e6, 980_000e6); + } + + function test_swapUniswapV4_revertsWhenAmountOutMinNotMetBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdc), address(almProxy), 1_000_000e6); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector( + IV4RouterLike.V4TooLittleReceived.selector, + 999_280.652247e6 + 1, + 999_280.652247e6 + ) + ); + + mainnetController.swapUniswapV4(_POOL_ID, address(usdc), 1_000_000e6, 999_280.652247e6 + 1); + + vm.prank(relayer); + mainnetController.swapUniswapV4(_POOL_ID, address(usdc), 1_000_000e6, 999_280.652247e6); + } + + function test_swapUniswapV4_token0toToken1() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + uint128 amountOutMin = _getSwapAmountOutMin(_POOL_ID, address(usdc), 1_000_000e6, 0.99e18); + + vm.record(); + + uint256 amountOut = _swap(_POOL_ID, address(usdc), 1_000_000e6, amountOutMin); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(amountOut, 999_280.652247e6); + } + + function test_swapUniswapV4_token1toToken0() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + uint128 amountOutMin = _getSwapAmountOutMin(_POOL_ID, address(usdt), 1_000_000e6, 0.99e18); + + vm.record(); + + uint256 amountOut = _swap(_POOL_ID, address(usdt), 1_000_000e6, amountOutMin); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(amountOut, 1_000_646.141415e6); + } + + /**********************************************************************************************/ + /*** Fuzz Tests ***/ + /**********************************************************************************************/ + + /// forge-config: default.fuzz.runs = 100 + function testFuzz_uniswapV4_mintAndDecreaseFullAmounts( + int24 tickLower, + int24 tickUpper, + uint128 liquidity + ) + external + { + tickLower = int24(_bound(int256(tickLower), -10_000, 10_000 - 1)); + + int256 boundedUpperMax = int256(tickLower) + 1_000 > 10_000 ? int256(10_000) : int256(tickLower) + 1_000; + + tickUpper = int24(_bound(int256(tickUpper), int256(tickLower) + 1, boundedUpperMax)); + liquidity = uint128(_bound(uint256(liquidity), 1e6, 1_000_000_000e6)); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10_000, 10_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 1_000_000_000e18, uint256(1_000_000_000e18) / 1 days); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 1_000_000_000e18, uint256(1_000_000_000e18) / 1 days); + vm.stopPrank(); + + IncreasePositionResult memory mintResult = _mintPosition(_POOL_ID, tickLower, tickUpper, liquidity, type(uint160).max, type(uint160).max); + DecreasePositionResult memory decreaseResult = _decreasePosition(mintResult.tokenId, mintResult.liquidityIncrease, 0, 0); + + uint256 valueDeposited = mintResult.amount0Spent + mintResult.amount1Spent; + uint256 valueReceived = decreaseResult.amount0Received + decreaseResult.amount1Received; + + assertApproxEqAbs(valueReceived, valueDeposited, 2); + } + + /// forge-config: default.fuzz.runs = 100 + function testFuzz_uniswapV4_increaseAndDecreaseFullAmounts( + int24 tickLower, + int24 tickUpper, + uint128 initialLiquidity + ) + external + { + tickLower = int24(_bound(int256(tickLower), -10_000, 10_000 - 1)); + + int256 boundedUpperMax = int256(tickLower) + 1_000 > 10_000 ? int256(10_000) : int256(tickLower) + 1_000; + + tickUpper = int24(_bound(int256(tickUpper), int256(tickLower) + 1, boundedUpperMax)); + initialLiquidity = uint128(_bound(uint256(initialLiquidity), 1e6, 2_000_000e6)); + + uint128 additionalLiquidity = initialLiquidity / 2; + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -10_000, 10_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, uint256(2_000_000e18) / 1 days); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, uint256(2_000_000e18) / 1 days); + vm.stopPrank(); + + IncreasePositionResult memory mintResult = _mintPosition(_POOL_ID, tickLower, tickUpper, initialLiquidity, type(uint160).max, type(uint160).max); + IncreasePositionResult memory increaseResult = _increasePosition(mintResult.tokenId, additionalLiquidity, type(uint160).max, type(uint160).max); + + uint256 valueBeforeIncrease = mintResult.amount0Spent + mintResult.amount1Spent; + uint256 valueAdded = increaseResult.amount0Spent + increaseResult.amount1Spent; + uint256 totalValueDeposited = valueBeforeIncrease + valueAdded; + + uint128 totalLiquidity = mintResult.liquidityIncrease + increaseResult.liquidityIncrease; + + DecreasePositionResult memory decreaseResult = _decreasePosition(mintResult.tokenId, totalLiquidity, 0, 0); + + uint256 valueReceived = decreaseResult.amount0Received + decreaseResult.amount1Received; + + assertApproxEqAbs(totalValueDeposited, valueReceived, 10); + } + + /// @param swapDirection true = USDC->USDT, false = USDT->USDC + /// forge-config: default.fuzz.runs = 100 + function testFuzz_uniswapV4_swapUniswapV4_amounts(uint128 amountIn, bool swapDirection) + external + { + amountIn = uint128(_bound(uint256(amountIn), 1e6, 1_000_000e6)); + + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 1_000_000e18, 0); + vm.stopPrank(); + + address tokenIn = swapDirection ? address(usdc) : address(usdt); + + uint128 amountOutMin = _getSwapAmountOutMin(_POOL_ID, tokenIn, amountIn, 0.99e18); + + uint256 rateLimitBefore = rateLimits.getCurrentRateLimit(_SWAP_LIMIT_KEY); + + uint256 amountOut = _swap(_POOL_ID, tokenIn, amountIn, amountOutMin); + + assertEq(rateLimits.getCurrentRateLimit(_SWAP_LIMIT_KEY), rateLimitBefore - _to18From6Decimals(amountIn)); + + assertGe(amountOut, amountOutMin); + + assertApproxEqRel(amountIn, amountOut, 0.005e18); + } + + /**********************************************************************************************/ + /*** Story Tests ***/ + /**********************************************************************************************/ + + /** + * @dev Story 1 is a round trip of liquidity minting, increase, decreasing, and closing/burning, + * each 90 days apart, while an external account swaps tokens in and out of the pool. + * - The relayer mints a position with 4_000e12 liquidity. + * - The relayer increases the liquidity position by 50% (to 2_000e12 liquidity). + * - The relayer decreases the liquidity position by 50% (to 3_000e12 liquidity). + * - The relayer decreases the remaining liquidity position (to 0 liquidity). + */ + function test_uniswapV4_story1() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -60, 60, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, uint256(2_000_000e18) / 1 days); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, uint256(2_000_000e18) / 1 days); + vm.stopPrank(); + + // 1. The relayer mints a position with 1,000,000 liquidity. + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 0, + liquidity : 4_000e12, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 1_363_024.631364e6); + assertEq(increaseResult.amount1Spent, 636_839.809432e6); + + uint256 expectedDecrease = _to18From6Decimals(increaseResult.amount0Spent) + _to18From6Decimals(increaseResult.amount1Spent); + assertEq(rateLimits.getCurrentRateLimit(_DEPOSIT_LIMIT_KEY), 2_000_000e18 - expectedDecrease); + + // 2. 90 days elapse. + vm.warp(block.timestamp + 90 days); + + // 3. Some account swaps 1,000,000 USDT for USDC. + assertEq(_externalSwap(_POOL_ID, _user, address(usdt), 1_000_000e6), 1_000_648.496032e6); + + // 4. The relayer increases the liquidity position by 50%. + increaseResult = _increasePosition(increaseResult.tokenId, 2_000e12, type(uint160).max, type(uint160).max); + + assertEq(increaseResult.amount0Spent, 635_276.445136e6); + assertEq(increaseResult.amount1Spent, 364_624.424738e6); + + // NOTE: Rate recharged to max since 90 days elapsed. + expectedDecrease = _to18From6Decimals(increaseResult.amount0Spent) + _to18From6Decimals(increaseResult.amount1Spent); + assertEq(rateLimits.getCurrentRateLimit(_DEPOSIT_LIMIT_KEY), 2_000_000e18 - expectedDecrease); + + // 5. 90 days elapse. + vm.warp(block.timestamp + 90 days); + + // 6. Some account swaps 1,500,000 USDC for USDT. + assertEq(_externalSwap(_POOL_ID, _user, address(usdc), 1_500_000e6), 1_498_982.907513e6); + + // 7. The relayer decreases the liquidity position by 50%. + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 3_000e12, 0, 0); + + assertEq(decreaseResult.amount0Received, 1_052_773.305651e6); + assertEq(decreaseResult.amount1Received, 447_148.109354e6); + + expectedDecrease = _to18From6Decimals(decreaseResult.amount0Received) + _to18From6Decimals(decreaseResult.amount1Received); + assertEq(rateLimits.getCurrentRateLimit(_WITHDRAW_LIMIT_KEY), 2_000_000e18 - expectedDecrease); + + // 8. 90 days elapse. + vm.warp(block.timestamp + 90 days); + + // 9. Some account swaps 1,000,000 USDT for USDC. + assertEq(_externalSwap(_POOL_ID, _user, address(usdt), 1_000_000e6), 1_000_667.948623e6); + + // 10. The relayer decreases the remaining liquidity position. + decreaseResult = _decreasePosition(increaseResult.tokenId, 3_000e12, 0, 0); + + assertEq(decreaseResult.amount0Received, 981_255.571670e6); + assertEq(decreaseResult.amount1Received, 518_616.097207e6); + + // NOTE: Rate recharged to max since 90 days elapsed. + expectedDecrease = _to18From6Decimals(decreaseResult.amount0Received) + _to18From6Decimals(decreaseResult.amount1Received); + assertEq(rateLimits.getCurrentRateLimit(_WITHDRAW_LIMIT_KEY), 2_000_000e18 - expectedDecrease); + + assertEq( + IPositionManagerLike(_POSITION_MANAGER).getPositionLiquidity(decreaseResult.tokenId), + 0 + ); + } + + /**********************************************************************************************/ + /*** Log Price And Ticks Tests ***/ + /**********************************************************************************************/ + + function test_uniswapV4_logPriceAndTicks_increasingPrice() external { + vm.skip(true); + + for (uint256 i = 0; i <= 100; ++i) { + if (i != 0) { + _externalSwap(_POOL_ID, _user, address(usdt), 200_000e6); + } + + _logCurrentPriceAndTick(_POOL_ID); + console.log(" -> After swapping: %s USDT\n", uint256(i * 200_000)); + } + } + + function test_uniswapV4_logPriceAndTicks_decreasingPrice() external { + vm.skip(true); + + for (uint256 i = 0; i <= 100; ++i) { + if (i != 0) { + _externalSwap(_POOL_ID, _user, address(usdc), 200_000e6); + } + + _logCurrentPriceAndTick(_POOL_ID); + console.log(" -> After swapping: %s USDC\n", uint256(i * 200_000)); + } + } + + /**********************************************************************************************/ + /*** Attack Tests (Current price is expected to be between the range) ***/ + /**********************************************************************************************/ + + function test_uniswapV4_baseline_priceMid() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Add Liquidity (Current price is expected to be between the range) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 10, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 840_606.192834e6); + assertEq(increaseResult.amount1Spent, 159_209.952358e6); + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 999_816.145192e6); + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 840_606.192833e6); + assertEq(decreaseResult.amount1Received, 159_209.952357e6); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 999_816.145190e6); // Lost 0 USD. + } + + function test_uniswapV4_attack_priceMidToAbove() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Frontrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + uint256 amountOut1 = _externalSwap(_POOL_ID, _user, address(usdt), 19_200_000e6); + + /******************************************************************************************/ + /*** Add Liquidity (Current price was expected to be between the range, but is above) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), 11); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 10, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 0); // Expected 840_606.192834e6 as per baseline + assertEq(increaseResult.amount1Spent, 999_950.044994e6); // Expected 159_209.952358e6 as per baseline + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 999_950.044994e6); // Expected 999_816.145192e6 as per baseline + + /******************************************************************************************/ + /*** Backrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), 11); + + uint256 amountOut2 = _externalSwap(_POOL_ID, _user, address(usdc), uint128(amountOut1)); + + assertEq(amountOut2, 19_200_305.050324e6); + assertEq(usdc.balanceOf(_user), 0); + assertEq(usdt.balanceOf(_user), 19_200_305.050324e6); // Gained 305 USDT. + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 819_742.888121e6); + assertEq(decreaseResult.amount1Received, 180_067.672764e6); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 999_810.560885e6); // Lost 139 USD from mint + } + + function test_uniswapV4_attack_priceMidToBelow() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Frontrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + uint256 amountOut1 = _externalSwap(_POOL_ID, _user, address(usdc), 2_500_000e6); + + /******************************************************************************************/ + /*** Add Liquidity (Current price was expected to be between the range, but is below) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -11); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -10, + tickUpper : 10, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 999_950.044994e6); // Expected 840_606.192834e6 as per baseline + assertEq(increaseResult.amount1Spent, 0); // Expected 159_209.952358e6 as per baseline + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 999_950.044994e6); // Expected 999_816.145192e6 as per baseline + + /******************************************************************************************/ + /*** Backrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -11); + + uint256 amountOut2 = _externalSwap(_POOL_ID, _user, address(usdt), uint128(amountOut1)); + + assertEq(amountOut2, 2_499_974.750232e6); + assertEq(usdc.balanceOf(_user), 2_499_974.750232e6); // Lost 26 USDC. + assertEq(usdt.balanceOf(_user), 0); + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 844_561.661143e6); + assertEq(decreaseResult.amount1Received, 155_258.746587e6); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 999_820.40773e6); // Lost 129 USD from mint + } + + /**********************************************************************************************/ + /*** Attack Tests (Current price is expected to be below the range) ***/ + /**********************************************************************************************/ + + function test_uniswapV4_baseline_priceBelow() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Add Liquidity (Current price is expected to be below the range) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : 15, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 999_700.101224e6); + assertEq(increaseResult.amount1Spent, 0); + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 999_700.101224e6); + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 999_700.101223e6); + assertEq(decreaseResult.amount1Received, 0); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 999_700.101223e6); // Lost 0 USD. + } + + function test_uniswapV4_attack_priceBelowToMid() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Frontrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + uint256 amountOut1 = _externalSwap(_POOL_ID, _user, address(usdt), 18_000_000e6); + + /******************************************************************************************/ + /*** Add Liquidity (Current price was expected to be below the range, but is between) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), 2); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : 15, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 632_055.655046e6); // Expected 999_700.101224e6 as per baseline + assertEq(increaseResult.amount1Spent, 367_595.789859e6); // Expected 0 as per baseline + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 999_651.444905e6); // Expected 999_700.101224e6 as per baseline + + /******************************************************************************************/ + /*** Backrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), 2); + + uint256 amountOut2 = _externalSwap(_POOL_ID, _user, address(usdc), uint128(amountOut1)); + + assertEq(amountOut2, 17_999_838.406844e6); + assertEq(usdc.balanceOf(_user), 0); + assertEq(usdt.balanceOf(_user), 17_999_838.406844e6); // Lost 161 USDT. + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 999_703.777704e6); + assertEq(decreaseResult.amount1Received, 0); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 999_703.777704e6); // Gained 52 USD from mint. + } + + function test_uniswapV4_attack_priceBelowToAbove() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Frontrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + uint256 amountOut1 = _externalSwap(_POOL_ID, _user, address(usdt), 19_300_000e6); + + /******************************************************************************************/ + /*** Add Liquidity (Current price was expected to be below the range, but is above) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), 34); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -5, + tickUpper : 15, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 0); // Expected 999_700.101224e6 as per baseline + assertEq(increaseResult.amount1Spent, 1_000_200.051255e6); // Expected 0 as per baseline + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 1_000_200.051255e6); // Expected 999_700.101224e6 as per baseline + + /******************************************************************************************/ + /*** Backrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), 34); + + uint256 amountOut2 = _externalSwap(_POOL_ID, _user, address(usdc), uint128(amountOut1)); + + assertEq(amountOut2, 19_300_769.578693e6); + assertEq(usdc.balanceOf(_user), 0); + assertEq(usdt.balanceOf(_user), 19_300_769.578693e6); // Gained 769 USDT. + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 999_710.098327e6); + assertEq(decreaseResult.amount1Received, 0); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 999_710.098327e6); // Lost 490 USD from mint + } + + /**********************************************************************************************/ + /*** Attack Tests (Current price is expected to be above the range) ***/ + /**********************************************************************************************/ + + function test_uniswapV4_baseline_priceAbove() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Add Liquidity (Current price is expected to be above the range) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -30, + tickUpper : -10, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 0); + assertEq(increaseResult.amount1Spent, 998_950.644702e6); + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 998_950.644702e6); + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 0); + assertEq(decreaseResult.amount1Received, 998_950.644701e6); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 998_950.644701e6); // Lost 0 USD. + } + + function test_uniswapV4_attack_priceAboveToMid() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Frontrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + uint256 amountOut1 = _externalSwap(_POOL_ID, _user, address(usdc), 2_840_000e6); + + /******************************************************************************************/ + /*** Add Liquidity (Current price was expected to be above the range, but is between) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -20); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -30, + tickUpper : -10, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 457_787.249555e6); // Expected 0 as per baseline + assertEq(increaseResult.amount1Spent, 541_830.090075e6); // Expected 998_950.644702e6 as per baseline + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 999_617.339630e6); // Expected 998_950.644702e6 as per baseline + + /******************************************************************************************/ + /*** Backrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -20); + + uint256 amountOut2 = _externalSwap(_POOL_ID, _user, address(usdt), uint128(amountOut1)); + + assertEq(amountOut2, 2_840_292.929030e6); + assertEq(usdc.balanceOf(_user), 2_840_292.929030e6); // Gained 292 USDC. + assertEq(usdt.balanceOf(_user), 0); + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -8); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 0); + assertEq(decreaseResult.amount1Received, 998_955.215954e6); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 998_955.215954e6); // Lost 662 USD from mint + } + + function test_uniswapV4_attack_priceAboveToBelow() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Frontrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + uint256 amountOut1 = _externalSwap(_POOL_ID, _user, address(usdc), 2_900_000e6); + + /******************************************************************************************/ + /*** Add Liquidity (Current price was expected to be above the range, but is below) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -50); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -30, + tickUpper : -10, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 1_000_950.445137e6); // Expected 0 as per baseline + assertEq(increaseResult.amount1Spent, 0); // Expected 998_950.644702e6 as per baseline + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 1_000_950.445137e6); // Expected 998_950.644702e6 as per baseline + + /******************************************************************************************/ + /*** Backrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -50); + + uint256 amountOut2 = _externalSwap(_POOL_ID, _user, address(usdt), uint128(amountOut1)); + + assertEq(amountOut2, 2_901_232.701533e6); + assertEq(usdc.balanceOf(_user), 2_901_232.701533e6); // Gained 1_232 USDC. + assertEq(usdt.balanceOf(_user), 0); + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -8); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 0); + assertEq(decreaseResult.amount1Received, 998_960.634310e6); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 998_960.634310e6); // Lost 1,989 USD from mint + } + + function test_uniswapV4_attack_priceAboveToBelow_defended() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Get max amounts ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + // While recommended usage is to use max amounts that are exactly (or close to exactly) the + // forecasted amounts in production, however this shows that even a value of 0.99 is + // sufficient to prevent an attack. + ( uint256 amount0Max, uint256 amount1Max ) = _getIncreasePositionMaxAmounts(_POOL_ID, -30, -10, 1_000_000_000e6, 0.99e18); + + /******************************************************************************************/ + /*** Frontrun ***/ + /******************************************************************************************/ + + _externalSwap(_POOL_ID, _user, address(usdc), 2_900_000e6); + + /******************************************************************************************/ + /*** Add Liquidity (Current price was expected to be above the range, but is below) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -50); + + deal(address(usdc), address(almProxy), usdc.balanceOf(address(almProxy)) + amount0Max); + deal(address(usdt), address(almProxy), usdt.balanceOf(address(almProxy)) + amount1Max); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, 0, 1_000_950.445137e6) + ); + + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -30, + tickUpper : -10, + liquidity : 1_000_000_000e6, + amount0Max : amount0Max, + amount1Max : amount1Max + }); + } + + /**********************************************************************************************/ + /*** Attack Tests (Current price is expected to be above the range, with wide tick spacing) ***/ + /**********************************************************************************************/ + + function test_uniswapV4_baseline_priceAbove_wideTicks() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 200); // Allow wider tick range. + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 20_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 20_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Add Liquidity (Current price is expected to be above the range) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -200, + tickUpper : -10, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 0); + assertEq(increaseResult.amount1Spent, 9_449_821.223798e6); + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 9_449_821.223798e6); + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 0); + assertEq(decreaseResult.amount1Received, 9_449_821.223797e6); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 9_449_821.223797e6); // Lost 0 USD. + } + + function test_uniswapV4_attack_priceAboveToBelow_wideTicks() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 200); // Allow wider tick spacing. + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 20_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 20_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Frontrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + uint256 amountOut1 = _externalSwap(_POOL_ID, _user, address(usdc), 3_020_000e6); + + /******************************************************************************************/ + /*** Add Liquidity (Current price was expected to be above the range, but is below) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -501); + + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : -200, + tickUpper : -10, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 9_549_562.082877e6); // Expected 0 as per baseline + assertEq(increaseResult.amount1Spent, 0); // Expected 9_449_821.223798e6 as per baseline + assertEq(increaseResult.amount1Spent + increaseResult.amount0Spent, 9_549_562.082877e6); // Expected 9_449_821.223798e6 as per baseline + + /******************************************************************************************/ + /*** Backrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -501); + + uint256 amountOut2 = _externalSwap(_POOL_ID, _user, address(usdt), uint128(amountOut1)); + + assertEq(amountOut2, 3_067_685.526025e6); + assertEq(usdc.balanceOf(_user), 3_067_685.526025e6); // Gained 47_685 USDC. + assertEq(usdt.balanceOf(_user), 0); + + /******************************************************************************************/ + /*** Remove Liquidity ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -141); + + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 1_000_000_000e6, 0, 0); + + assertEq(decreaseResult.amount0Received, 6_528_153.154390e6); + assertEq(decreaseResult.amount1Received, 2_970_499.394905e6); + assertEq(decreaseResult.amount0Received + decreaseResult.amount1Received, 9_498_652.549295e6); // Lost 50,909 USD from mint + } + + function test_uniswapV4_attack_priceAboveToBelow_defended_wideTicks() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -200, 200, 20); // Disallow wider tick spacing. + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 20_000_000e18, 0); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 20_000_000e18, 0); + vm.stopPrank(); + + /******************************************************************************************/ + /*** Frontrun ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -7); + + _externalSwap(_POOL_ID, _user, address(usdc), 3_020_000e6); + + /******************************************************************************************/ + /*** Add Liquidity (Current price was expected to be above the range, but is below) ***/ + /******************************************************************************************/ + + assertEq(_getCurrentTick(_POOL_ID), -501); + + vm.prank(relayer); + vm.expectRevert("MC/tickSpacing-too-wide"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : -200, + tickUpper : -10, + liquidity : 1_000_000_000e6, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + } + +} + +contract MainnetController_UniswapV4_USDT_USDS_Tests is UniswapV4TestBase { + + bytes32 internal constant _POOL_ID = 0xb54ece65cc2ddd3eaec0ad18657470fb043097220273d87368a062c7d4e59180; + + bytes32 internal constant _DEPOSIT_LIMIT_KEY = keccak256(abi.encode(_LIMIT_DEPOSIT, _POOL_ID)); + bytes32 internal constant _WITHDRAW_LIMIT_KEY = keccak256(abi.encode(_LIMIT_WITHDRAW, _POOL_ID)); + bytes32 internal constant _SWAP_LIMIT_KEY = keccak256(abi.encode(_LIMIT_SWAP, _POOL_ID)); + + + /**********************************************************************************************/ + /*** mintPositionUniswapV4 Tests ***/ + /**********************************************************************************************/ + + function test_mintPositionUniswapV4_revertsWhenTickLimitsNotSet() external { + vm.prank(relayer); + vm.expectRevert("MC/tickLimits-not-set"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 0, + tickUpper : 0, + liquidity : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + function test_mintPositionUniswapV4_revertsWhenTicksMisorderedBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 270_000, 280_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdt), address(almProxy), 1_000_000e6); + deal(address(usds), address(almProxy), 1_000_000e18); + + vm.prank(relayer); + vm.expectRevert("MC/ticks-misordered"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_302, + tickUpper : 276_301, + liquidity : 1_000_000e12, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e18 + }); + + vm.prank(relayer); + vm.expectRevert("MC/ticks-misordered"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_301, + tickUpper : 276_301, + liquidity : 1_000_000e12, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e18 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_301, + liquidity : 1_000_000e12, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e18 + }); + } + + function test_mintPositionUniswapV4_revertsWhenTickLowerTooLowBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_300, 280_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdt), address(almProxy), 1_000_000e6); + deal(address(usds), address(almProxy), 1_000_000e18); + + vm.prank(relayer); + vm.expectRevert("MC/tickLower-too-low"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_299, + tickUpper : 276_600, + liquidity : 1_000_000e12, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e18 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_600, + liquidity : 1_000_000e12, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e18 + }); + } + + function test_mintPositionUniswapV4_revertsWhenTickUpperTooHighBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 270_000, 276_600, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdt), address(almProxy), 1_000_000e6); + deal(address(usds), address(almProxy), 1_000_000e18); + + vm.prank(relayer); + vm.expectRevert("MC/tickUpper-too-high"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_601, + liquidity : 1_000_000e12, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e18 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_600, + liquidity : 1_000_000e12, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e18 + }); + } + + function test_mintPositionUniswapV4_revertsWhenTickSpacingTooWideBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_300, 276_600, 100); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdt), address(almProxy), 1_000_000e6); + deal(address(usds), address(almProxy), 1_000_000e18); + + vm.prank(relayer); + vm.expectRevert("MC/tickSpacing-too-wide"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_400, + tickUpper : 276_501, + liquidity : 1_000_000e12, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e18 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_400, + tickUpper : 276_500, + liquidity : 1_000_000e12, + amount0Max : 1_000_000e6, + amount1Max : 1_000_000e18 + }); + } + + function test_mintPositionUniswapV4_revertsWhenMaxAmountsTooLargeForPermit2Boundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 270_000, 280_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdt), address(almProxy), 1_000_000e6); + deal(address(usds), address(almProxy), 1_000_000e18); + + vm.prank(relayer); + vm.expectRevert("MC/amount-too-large-for-permit2"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_400, + liquidity : 1_000_000e12, + amount0Max : uint256(type(uint160).max) + 1, + amount1Max : uint256(type(uint160).max) + }); + + vm.prank(relayer); + vm.expectRevert("MC/amount-too-large-for-permit2"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_400, + liquidity : 1_000_000e12, + amount0Max : uint256(type(uint160).max), + amount1Max : uint256(type(uint160).max) + 1 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_400, + liquidity : 1_000_000e12, + amount0Max : uint256(type(uint160).max), + amount1Max : uint256(type(uint160).max) + }); + } + + function test_mintPositionUniswapV4_revertsWhenMaxAmountsSurpassedBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 270_000, 280_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity(_POOL_ID, 276_300, 276_400, 1_000_000e12); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdt), address(almProxy), amount0Forecasted); + deal(address(usds), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, amount0Forecasted - 1, amount0Forecasted) + ); + + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_400, + liquidity : 1_000_000e12, + amount0Max : amount0Forecasted - 1, + amount1Max : amount1Forecasted + }); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, amount1Forecasted - 1, amount1Forecasted) + ); + + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_400, + liquidity : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted - 1 + }); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_400, + liquidity : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_mintPositionUniswapV4_revertsWhenRateLimitExceededBoundary() external { + uint256 expectedDecrease = 29_773.913458368778256533e18; + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 270_000, 280_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, expectedDecrease - 1, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdt), address(almProxy), amount0Forecasted); + deal(address(usds), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_000, + tickUpper : 276_600, + liquidity : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, expectedDecrease, 0); + + vm.prank(relayer); + mainnetController.mintPositionUniswapV4({ + poolId : _POOL_ID, + tickLower : 276_000, + tickUpper : 276_600, + liquidity : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_mintPositionUniswapV4() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 270_000, 280_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Max, uint256 amount1Max ) = _getIncreasePositionMaxAmounts(_POOL_ID, 276_000, 276_600, 1_000_000e12, 0.99e18); + + vm.record(); + + IncreasePositionResult memory result = _mintPosition({ + poolId : _POOL_ID, + tickLower : 276_000, + tickUpper : 276_600, + liquidity : 1_000_000e12, + amount0Max : amount0Max, + amount1Max : amount1Max + }); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(result.amount0Spent, 12_871.843781e6); + assertEq(result.amount1Spent, 16_902.069677368778256533e18); + } + + /**********************************************************************************************/ + /*** increaseLiquidity Tests ***/ + /**********************************************************************************************/ + + function test_increaseLiquidityUniswapV4_revertsWhenPositionIsNotOwnedByProxy() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.prank(address(almProxy)); + IPositionManagerLike(_POSITION_MANAGER).transferFrom(address(almProxy), address(1), minted.tokenId); + + vm.prank(relayer); + vm.expectRevert("MC/non-proxy-position"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : minted.tokenId, + liquidityIncrease : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenTokenIsNotForPool() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.prank(relayer); + vm.expectRevert("MC/tokenId-poolId-mismatch"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : minted.tokenId, + liquidityIncrease : 0, + amount0Max : 0, + amount1Max : 0 + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenTickLowerTooLowBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_001, 276_600, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + 1_000_000e12 + ); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdt), address(almProxy), amount0Forecasted); + deal(address(usds), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + vm.expectRevert("MC/tickLower-too-low"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + + vm.prank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_000, 276_600, 1_000); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenTickUpperTooHighBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_000, 276_599, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + 1_000_000e12 + ); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdt), address(almProxy), amount0Forecasted); + deal(address(usds), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + vm.expectRevert("MC/tickUpper-too-high"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + + vm.prank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_000, 276_600, 1_000); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenTickSpacingTooWideBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_000, 276_600, 599); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + 1_000_000e12 + ); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdt), address(almProxy), amount0Forecasted); + deal(address(usds), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + vm.expectRevert("MC/tickSpacing-too-wide"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + + vm.prank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_000, 276_600, 600); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenMaxAmountsTooLargeForPermit2Boundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_000, 276_600, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdt), address(almProxy), 1_000_000e6); + deal(address(usds), address(almProxy), 1_000_000e18); + + vm.prank(relayer); + vm.expectRevert("MC/amount-too-large-for-permit2"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : uint256(type(uint160).max) + 1, + amount1Max : uint256(type(uint160).max) + }); + + vm.prank(relayer); + vm.expectRevert("MC/amount-too-large-for-permit2"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : uint256(type(uint160).max), + amount1Max : uint256(type(uint160).max) + 1 + }); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : uint256(type(uint160).max), + amount1Max : uint256(type(uint160).max) + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenMaxAmountsMaxSurpassedBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_000, 276_600, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + 1_000_000e12 + ); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdt), address(almProxy), amount0Forecasted); + deal(address(usds), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, amount0Forecasted - 1, amount0Forecasted) + ); + + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted - 1, + amount1Max : amount1Forecasted + }); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, amount1Forecasted - 1, amount1Forecasted) + ); + + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted - 1 + }); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_increaseLiquidityUniswapV4_revertsWhenRateLimitExceededBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + uint256 expectedDecrease = 29_773.913458368778256533e18; + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_000, 276_600, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, expectedDecrease - 1, 0); + vm.stopPrank(); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + 1_000_000e12 + ); + + amount0Forecasted += 1; // Quote is off by 1 + amount1Forecasted += 1; // Quote is off by 1 + + deal(address(usdt), address(almProxy), amount0Forecasted); + deal(address(usds), address(almProxy), amount1Forecasted); + + vm.prank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, expectedDecrease, 0); + + vm.prank(relayer); + mainnetController.increaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityIncrease : 1_000_000e12, + amount0Max : amount0Forecasted, + amount1Max : amount1Forecasted + }); + } + + function test_increaseLiquidityUniswapV4() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_000, 276_600, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + ( uint256 amount0Max, uint256 amount1Max ) = _getIncreasePositionMaxAmounts( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + 1_000_000e12, + 0.99e18 + ); + + vm.record(); + + IncreasePositionResult memory result = _increasePosition( + minted.tokenId, + 1_000_000e12, + amount0Max, + amount1Max + ); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(result.amount0Spent, 12_871.843781e6); + assertEq(result.amount1Spent, 16_902.069677368778256533e18); + } + + /**********************************************************************************************/ + /*** decreaseLiquidityUniswapV4 Tests ***/ + /**********************************************************************************************/ + + function test_decreaseLiquidityUniswapV4_revertsWhenTokenIsNotForPool() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.prank(relayer); + vm.expectRevert("MC/tokenId-poolId-mismatch"); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : bytes32(0), + tokenId : minted.tokenId, + liquidityDecrease : 0, + amount0Min : 0, + amount1Min : 0 + }); + } + + function test_decreaseLiquidityUniswapV4_revertsWhenAmount0MinNotMetBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + minted.liquidityIncrease / 2 + ); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector( + SlippageCheck.MinimumAmountInsufficient.selector, + amount0Forecasted + 1, + amount0Forecasted + ) + ); + + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted + 1, + amount1Min : amount1Forecasted + }); + + vm.prank(relayer); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + }); + } + + function test_decreaseLiquidityUniswapV4_revertsWhenAmount1MinNotMetBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + minted.liquidityIncrease / 2 + ); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector( + SlippageCheck.MinimumAmountInsufficient.selector, + amount1Forecasted + 1, + amount1Forecasted + ) + ); + + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + 1 + }); + + vm.prank(relayer); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + }); + } + + function test_decreaseLiquidityUniswapV4_revertsWhenRateLimitExceededBoundary() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + uint256 expectedDecrease = 14_886.956728684389128266e18; + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, expectedDecrease - 1, 0); + + ( uint256 amount0Forecasted, uint256 amount1Forecasted ) = _quoteLiquidity( + _POOL_ID, + minted.tickLower, + minted.tickUpper, + minted.liquidityIncrease / 2 + ); + + vm.prank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + }); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, expectedDecrease, 0); + + vm.prank(relayer); + mainnetController.decreaseLiquidityUniswapV4({ + poolId : _POOL_ID, + tokenId : minted.tokenId, + liquidityDecrease : minted.liquidityIncrease / 2, + amount0Min : amount0Forecasted, + amount1Min : amount1Forecasted + }); + } + + function test_decreaseLiquidityUniswapV4_partial() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + + ( uint256 amount0Min, uint256 amount1Min ) = _getDecreasePositionMinAmounts(minted.tokenId, minted.liquidityIncrease / 2, 0.99e18); + + vm.record(); + + DecreasePositionResult memory result = _decreasePosition( + minted.tokenId, + minted.liquidityIncrease / 2, + amount0Min, + amount1Min + ); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(result.amount0Received, 6_435.921890e6); + assertEq(result.amount1Received, 8_451.034838684389128266e18); + } + + function test_decreaseLiquidityUniswapV4_all() external { + IncreasePositionResult memory minted = _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000e12); + + vm.prank(SPARK_PROXY); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, 0); + + ( uint256 amount0Min, uint256 amount1Min ) = _getDecreasePositionMinAmounts( + minted.tokenId, + minted.liquidityIncrease, + 0.99e18 + ); + + vm.record(); + + DecreasePositionResult memory result = _decreasePosition( + minted.tokenId, + minted.liquidityIncrease, + amount0Min, + amount1Min + ); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(result.amount0Received, 12_871.843780e6); + assertEq(result.amount1Received, 16_902.069677368778256532e18); + } + + /**********************************************************************************************/ + /*** swapUniswapV4 Tests ***/ + /**********************************************************************************************/ + + function test_swapUniswapV4_revertsWhenMaxSlippageNotSet() external { + vm.prank(relayer); + vm.expectRevert("MC/max-slippage-not-set"); + mainnetController.swapUniswapV4(_POOL_ID, address(0), 0, 0); + } + + function test_swapUniswapV4_revertsWhenRateLimitExceededBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 10_000e18, 0); + vm.stopPrank(); + + uint128 amountOutMin = _getSwapAmountOutMin(_POOL_ID, address(usdt), 10_000e6, 0.99e18); + + deal(address(usdt), address(almProxy), 10_000e6 + 1); + + vm.prank(relayer); + vm.expectRevert("RateLimits/rate-limit-exceeded"); + mainnetController.swapUniswapV4({ + poolId : _POOL_ID, + tokenIn : address(usdt), + amountIn : 10_000e6 + 1, + amountOutMin : amountOutMin + }); + + vm.prank(relayer); + mainnetController.swapUniswapV4({ + poolId : _POOL_ID, + tokenIn : address(usdt), + amountIn : 10_000e6, + amountOutMin : amountOutMin + }); + } + + function test_swapUniswapV4_revertsWhenInputTokenNotForPool() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + vm.prank(relayer); + vm.expectRevert("MC/invalid-tokenIn"); + mainnetController.swapUniswapV4(_POOL_ID, address(dai), 10_000e6, 10_000e6); + } + + function test_swapUniswapV4_revertsWhenAmountOutMinTooLowBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdt), address(almProxy), 10_000e6); + + vm.prank(relayer); + vm.expectRevert("MC/amountOutMin-too-low"); + mainnetController.swapUniswapV4(_POOL_ID, address(usdt), 10_000e6, 9_800e18 - 1); + + vm.prank(relayer); + mainnetController.swapUniswapV4(_POOL_ID, address(usdt), 10_000e6, 9_800e18); + } + + function test_swapUniswapV4_revertsWhenAmountOutMinNotMetBoundary() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + deal(address(usdt), address(almProxy), 10_000e6); + + vm.prank(relayer); + + vm.expectRevert( + abi.encodeWithSelector( + IV4RouterLike.V4TooLittleReceived.selector, + 9_963.585379886102636344e18 + 1, + 9_963.585379886102636344e18 + ) + ); + + mainnetController.swapUniswapV4(_POOL_ID, address(usdt), 10_000e6, 9_963.585379886102636344e18 + 1); + + vm.prank(relayer); + mainnetController.swapUniswapV4(_POOL_ID, address(usdt), 10_000e6, 9_963.585379886102636344e18); + } + + function test_swapUniswapV4_token0toToken1() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + uint128 amountOutMin = _getSwapAmountOutMin(_POOL_ID, address(usdt), 10_000e6, 0.99e18); + + vm.record(); + + uint256 amountOut = _swap(_POOL_ID, address(usdt), 10_000e6, amountOutMin); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(amountOut, 9_963.585379886102636344e18); + } + + function test_swapUniswapV4_token1toToken0() external { + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 2_000_000e18, 0); + vm.stopPrank(); + + uint128 amountOutMin = _getSwapAmountOutMin(_POOL_ID, address(usds), 3_000e18, 0.99e18); + + vm.record(); + + uint256 amountOut = _swap(_POOL_ID, address(usds), 3_000e18, amountOutMin); + + _assertReentrancyGuardWrittenToTwice(); + + assertEq(amountOut, 2_990.034994e6); + } + + /**********************************************************************************************/ + /*** Fuzz Tests ***/ + /**********************************************************************************************/ + + /// forge-config: default.fuzz.runs = 100 + function testFuzz_uniswapV4_mintAndDecreaseFullAmounts( + int24 tickLower, + int24 tickUpper, + uint128 liquidity + ) + external + { + tickLower = int24(_bound(int256(tickLower), 100_000, 300_000 - 1)); + + int256 boundedUpperMax = int256(tickLower) + 1_000 > 400_000 ? int256(400_000) : int256(tickLower) + 1_000; + + tickUpper = int24(_bound(int256(tickUpper), int256(tickLower) + 1, boundedUpperMax)); + liquidity = uint128(_bound(uint256(liquidity), 1e6, 1_000_000_000e12)); + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 100_000, 400_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 1_000_000_000e18, uint256(1_000_000_000e18) / 1 days); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 1_000_000_000e18, uint256(1_000_000_000e18) / 1 days); + vm.stopPrank(); + + IncreasePositionResult memory mintResult = _mintPosition(_POOL_ID, tickLower, tickUpper, liquidity, type(uint160).max, type(uint160).max); + DecreasePositionResult memory decreaseResult = _decreasePosition(mintResult.tokenId, mintResult.liquidityIncrease, 0, 0); + + uint256 valueDeposited = mintResult.amount0Spent + mintResult.amount1Spent; + uint256 valueReceived = decreaseResult.amount0Received + decreaseResult.amount1Received; + + assertApproxEqAbs(valueReceived, valueDeposited, 2); + } + + /// forge-config: default.fuzz.runs = 100 + function testFuzz_uniswapV4_increaseAndDecreaseFullAmounts( + int24 tickLower, + int24 tickUpper, + uint128 initialLiquidity + ) + external + { + tickLower = int24(_bound(int256(tickLower), 100_000, 300_000 - 1)); + + int256 boundedUpperMax = int256(tickLower) + 1_000 > 400_000 ? int256(400_000) : int256(tickLower) + 1_000; + + tickUpper = int24(_bound(int256(tickUpper), int256(tickLower) + 1, boundedUpperMax)); + initialLiquidity = uint128(_bound(uint256(initialLiquidity), 1e6, 2_000_000e12)); + + uint128 additionalLiquidity = initialLiquidity / 2; + + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 100_000, 400_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, uint256(2_000_000e18) / 1 days); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, uint256(2_000_000e18) / 1 days); + vm.stopPrank(); + + IncreasePositionResult memory mintResult = _mintPosition(_POOL_ID, tickLower, tickUpper, initialLiquidity, type(uint160).max, type(uint160).max); + IncreasePositionResult memory increaseResult = _increasePosition(mintResult.tokenId, additionalLiquidity, type(uint160).max, type(uint160).max); + + uint256 valueBeforeIncrease = mintResult.amount0Spent + mintResult.amount1Spent; + uint256 valueAdded = increaseResult.amount0Spent + increaseResult.amount1Spent; + uint256 totalValueDeposited = valueBeforeIncrease + valueAdded; + + uint128 totalLiquidity = mintResult.liquidityIncrease + increaseResult.liquidityIncrease; + + DecreasePositionResult memory decreaseResult = _decreasePosition(mintResult.tokenId, totalLiquidity, 0, 0); + + uint256 valueReceived = decreaseResult.amount0Received + decreaseResult.amount1Received; + + assertApproxEqAbs(totalValueDeposited, valueReceived, 10); + } + + /// @param swapDirection true = USDT->USDS, false = USDS->USDT + /// forge-config: default.fuzz.runs = 100 + function testFuzz_uniswapV4_swapUniswapV4_amounts(uint128 amountIn, bool swapDirection) + external + { + // Needed due to low liquidity currently in the pool. + _setupLiquidity(_POOL_ID, 276_000, 276_600, 1_000_000_000e12); + + if (swapDirection) { + amountIn = uint128(_bound(uint256(amountIn), 1e6, 1_000_000e6)); + } else { + amountIn = uint128(_bound(uint256(amountIn), 1e18, 1_000_000e18)); + } + + vm.startPrank(SPARK_PROXY); + mainnetController.setMaxSlippage(address(uint160(uint256(_POOL_ID))), 0.98e18); + rateLimits.setRateLimitData(_SWAP_LIMIT_KEY, 1_000_000e18, 0); + vm.stopPrank(); + + address tokenIn = swapDirection ? address(usdt) : address(usds); + + uint128 amountOutMin = _getSwapAmountOutMin(_POOL_ID, tokenIn, amountIn, 0.99e18); + + uint256 rateLimitBefore = rateLimits.getCurrentRateLimit(_SWAP_LIMIT_KEY); + + uint256 amountOut = _swap(_POOL_ID, tokenIn, amountIn, amountOutMin); + + assertEq( + rateLimits.getCurrentRateLimit(_SWAP_LIMIT_KEY), + rateLimitBefore - (swapDirection ? _to18From6Decimals(amountIn) : amountIn) + ); + + assertGe(amountOut, amountOutMin); + + if (swapDirection) { + assertApproxEqRel(_to18From6Decimals(amountIn), amountOut, 0.005e18); + } else { + assertApproxEqRel(amountIn, _to18From6Decimals(amountOut), 0.005e18); + } + } + + /**********************************************************************************************/ + /*** Story Tests ***/ + /**********************************************************************************************/ + + /** + * @dev Story 1 is a round trip of liquidity minting, increase, decreasing, and closing/burning, + * each 90 days apart, while an external account swaps tokens in and out of the pool. + * - The relayer mints a position with 400_000_000e12 liquidity. + * - The relayer increases the liquidity position by 50% (to 200_000_000e12 liquidity). + * - The relayer decreases the liquidity position by 50% (to 300_000_000e12 liquidity). + * - The relayer decreases the remaining liquidity position (to 0 liquidity). + */ + function test_uniswapV4_story1() external { + // Setup the pool and the controller. + vm.startPrank(SPARK_PROXY); + mainnetController.setUniswapV4TickLimits(_POOL_ID, 276_300, 280_000, 1_000); + rateLimits.setRateLimitData(_DEPOSIT_LIMIT_KEY, 2_000_000e18, uint256(2_000_000e18) / 1 days); + rateLimits.setRateLimitData(_WITHDRAW_LIMIT_KEY, 2_000_000e18, uint256(2_000_000e18) / 1 days); + vm.stopPrank(); + + // 1. The relayer mints a position with 1,000,000 liquidity. + IncreasePositionResult memory increaseResult = _mintPosition({ + poolId : _POOL_ID, + tickLower : 276_300, + tickUpper : 276_400, + liquidity : 4_000e17, + amount0Max : type(uint160).max, + amount1Max : type(uint160).max + }); + + assertEq(increaseResult.amount0Spent, 1_183_957.816516e6); + assertEq(increaseResult.amount1Spent, 813_048.360317266265664850e18); + + uint256 expectedDecrease = _to18From6Decimals(increaseResult.amount0Spent) + increaseResult.amount1Spent; + assertEq(rateLimits.getCurrentRateLimit(_DEPOSIT_LIMIT_KEY), 2_000_000e18 - expectedDecrease); + + // 2. 90 days elapse. + vm.warp(block.timestamp + 90 days); + + // 3. Some account swaps 500,000 USDT for USDS. + assertEq(_externalSwap(_POOL_ID, _user, address(usdt), 500_000e6), 500_159.667307969852203416e18); + + // 4. The relayer increases the liquidity position by 50%. + increaseResult = _increasePosition(increaseResult.tokenId, 2_000e17, type(uint160).max, type(uint160).max); + + assertEq(increaseResult.amount0Spent, 840_712.962029e6); + assertEq(increaseResult.amount1Spent, 157_636.030550204975395201e18); + + // NOTE: Rate recharged to max since 90 days elapsed. + expectedDecrease = _to18From6Decimals(increaseResult.amount0Spent) + increaseResult.amount1Spent; + assertEq(rateLimits.getCurrentRateLimit(_DEPOSIT_LIMIT_KEY), 2_000_000e18 - expectedDecrease); + + // 5. 90 days elapse. + vm.warp(block.timestamp + 90 days); + + // 6. Some account swaps 750,000 USDS for USDT. + assertEq(_externalSwap(_POOL_ID, _user, address(usds), 750_000e18), 749_609.539364e6); + + // 7. The relayer decreases the liquidity position by 50%. + DecreasePositionResult memory decreaseResult = _decreasePosition(increaseResult.tokenId, 3_000e17, 0, 0); + + assertEq(decreaseResult.amount0Received, 887_531.893676e6); + assertEq(decreaseResult.amount1Received, 610_298.227601444650560686e18); + + expectedDecrease = _to18From6Decimals(decreaseResult.amount0Received) + decreaseResult.amount1Received; + assertEq(rateLimits.getCurrentRateLimit(_WITHDRAW_LIMIT_KEY), 2_000_000e18 - expectedDecrease); + + // 8. 90 days elapse. + vm.warp(block.timestamp + 90 days); + + // 9. Some account swaps 200,000 USDT for USDS. + assertEq(_externalSwap(_POOL_ID, _user, address(usdt), 200_000e6), 200_180.816044995828458470e18); + + // 10. The relayer decreases the remaining liquidity position. + decreaseResult = _decreasePosition(increaseResult.tokenId, 3_000e17, 0, 0); + + assertEq(decreaseResult.amount0Received, 1_086_263.185036e6); + assertEq(decreaseResult.amount1Received, 411_312.505850170682613151e18); + + // NOTE: Rate recharged to max since 90 days elapsed. + expectedDecrease = _to18From6Decimals(decreaseResult.amount0Received) + decreaseResult.amount1Received; + assertEq(rateLimits.getCurrentRateLimit(_WITHDRAW_LIMIT_KEY), 2_000_000e18 - expectedDecrease); + + assertEq( + IPositionManagerLike(_POSITION_MANAGER).getPositionLiquidity(decreaseResult.tokenId), + 0 + ); + } + +} diff --git a/test/unit/controllers/Admin.t.sol b/test/unit/controllers/Admin.t.sol index 80280bd8..b310ceae 100644 --- a/test/unit/controllers/Admin.t.sol +++ b/test/unit/controllers/Admin.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.21; +import { IAccessControl } from "../../../lib/openzeppelin-contracts/contracts/access/IAccessControl.sol"; import { IERC20Metadata } from "../../../lib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { IERC4626 } from "../../../lib/openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; import { ReentrancyGuard } from "../../../lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; @@ -438,6 +439,67 @@ contract MainnetControllerSetMaxExchangeRateTests is MainnetControllerAdminTestB } +contract MainnetControllerSetUniswapV4TickLimitsTests is MainnetControllerAdminTestBase { + + bytes32 internal constant _POOL_ID = 0x8aa4e11cbdf30eedc92100f4c8a31ff748e201d44712cc8c90d189edaa8e4e47; + + address internal immutable _unauthorized = makeAddr("unauthorized"); + + function test_setUniswapV4TickLimits_reentrancy() external { + _setControllerEntered(); + vm.expectRevert(ReentrancyGuard.ReentrancyGuardReentrantCall.selector); + mainnetController.setUniswapV4TickLimits(bytes32(0), 0, 0, 0); + } + + function test_setUniswapV4TickLimits_revertsForNonAdmin() external { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + _unauthorized, + mainnetController.DEFAULT_ADMIN_ROLE() + ) + ); + + vm.prank(_unauthorized); + mainnetController.setUniswapV4TickLimits(bytes32(0), 0, 0, 0); + } + + function test_setUniswapV4TickLimits_revertsWhenInvalidTicks() external { + vm.prank(admin); + vm.expectRevert("MC/invalid-ticks"); + mainnetController.setUniswapV4TickLimits(bytes32(0), 1, 1, 1); // Reverts when lower >= upper + + vm.prank(admin); + mainnetController.setUniswapV4TickLimits(bytes32(0), 0, 1, 1); // lower must be less than upper + + vm.prank(admin); + vm.expectRevert("MC/invalid-ticks"); + mainnetController.setUniswapV4TickLimits(bytes32(0), 0, 1, 0); // Reverts when maxTickSpacing is zero + + vm.prank(admin); + mainnetController.setUniswapV4TickLimits(bytes32(0), 0, 0, 0); // maxTickSpacing can only be 0 if all 0 + } + + function test_setUniswapV4TickLimits() external { + vm.expectEmit(address(mainnetController)); + emit MainnetController.UniswapV4TickLimitsSet(_POOL_ID, -60, 60, 20); + + vm.record(); + + vm.prank(admin); + mainnetController.setUniswapV4TickLimits(_POOL_ID, -60, 60, 20); + + _assertReentrancyGuardWrittenToTwice(); + + ( int24 tickLowerMin, int24 tickUpperMax, uint24 maxTickSpacing ) = mainnetController.uniswapV4TickLimits(_POOL_ID); + + assertEq(tickLowerMin, -60); + assertEq(tickUpperMax, 60); + assertEq(maxTickSpacing, 20); + } + +} + contract ForeignControllerAdminTests is UnitTestBase { ForeignController foreignController;