diff --git a/.gitignore b/.gitignore index 9c3c7dc..1cdc36d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ node_modules/ artifacts/ cache_hardhat/ typechain-types/ + +*settings.json + diff --git a/.gitmodules b/.gitmodules index 45bc574..75229a8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,18 +1,23 @@ [submodule "lib/forge-std"] - path = lib/forge-std - url = https://github.com/foundry-rs/forge-std +path = lib/forge-std +url = https://github.com/foundry-rs/forge-std [submodule "lib/solmate"] - path = lib/solmate - url = https://github.com/rari-capital/solmate +path = lib/solmate +url = https://github.com/rari-capital/solmate [submodule "lib/create3-factory"] - path = lib/create3-factory - url = https://github.com/zeframlou/create3-factory +path = lib/create3-factory +url = https://github.com/zeframlou/create3-factory [submodule "lib/v3-core"] - path = lib/v3-core - url = https://github.com/uniswap/v3-core +path = lib/v3-core +url = https://github.com/uniswap/v3-core [submodule "lib/openzeppelin-contracts-upgradeable"] - path = lib/openzeppelin-contracts-upgradeable - url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +path = lib/openzeppelin-contracts-upgradeable +url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +branch = v4.9.6 [submodule "lib/openzeppelin-contracts"] - path = lib/openzeppelin-contracts - url = https://github.com/OpenZeppelin/openzeppelin-contracts +path = lib/openzeppelin-contracts +url = https://github.com/OpenZeppelin/openzeppelin-contracts +branch = v4.9.6 +[submodule "lib/vault-v2"] +path = lib/vault-v2 +url = https://github.com/Byte-Masons/vault-v2 \ No newline at end of file diff --git a/README.md b/README.md index 9548b78..60a8568 100644 --- a/README.md +++ b/README.md @@ -52,4 +52,36 @@ forge build ``` forge test -``` \ No newline at end of file +``` + +### Checklist + +#### Internal Audit Checklist + +- [x] All functionality that touches funds can be paused +- [ ] Pause function called by 2/7 Guardian +- [ ] Guardian has 7 members globally dispersed +- [x] Arithmetic errors +- [x] Re-entrancy +- [x] Flashloans +- [x] Access Control +- [x] Unchecked External Calls +- [ ] Account abstraction/multicall issues +- [x] USE SLITHER + +#### Pre-deployment Checklist + +- [x] Contracts pass all tests +- [x] Contracts deployed to testnet +- [x] Does this deployment have access to funds, either directly or indirectly (zappers, leveragers, etc.)? + +Minimum security if Yes: + +- [x] Internal Audit (not the author, minimum 1x Junior review + minimum 1x Senior review) +- [x] External Audit (impact scope) + +Action items in support of deployment: + +- [ ] Minimum two people present for deployment +- [ ] All developers who worked on and reviewed the contract should be included in the readme +- [ ] Documentation of deployment procedure if non-standard (i.e. if multiple scripts are necessary) \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 2362c36..8129a9c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,7 +1,7 @@ [profile.default] optimizer_runs = 1000000 verbosity = 1 -via_ir = true +via_ir = false [fmt] line_length = 150 diff --git a/hardhat.config.ts b/hardhat.config.ts index b7c2f29..f54029d 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -9,6 +9,9 @@ import { subtask } from "hardhat/config"; import { config as dotenvConfig } from "dotenv"; +// import "@nomiclabs/hardhat-ethers"; +import "@nomicfoundation/hardhat-verify"; + dotenvConfig(); const PRIVATE_KEY = process.env.PRIVATE_KEY || ""; @@ -20,6 +23,9 @@ const config: HardhatUserConfig = { optimizer: { enabled: true, runs: 9999 } } }, + sourcify: { + enabled: true + }, paths: { sources: "./src", tests: "./test_hardhat", @@ -35,12 +41,28 @@ const config: HardhatUserConfig = { chainId: 56, accounts: [`0x${PRIVATE_KEY}`], }, + mode: { + url: "https://mainnet.mode.network/", + chainId: 34443, + accounts: [`0x${PRIVATE_KEY}`], + }, }, etherscan: { apiKey: { bsc: process.env.ETHERSCAN_KEY || "", - } + }, + // customChains: [ + // { + // network: "mode", + // chainId: 34443, + // urls: { + // apiURL: "https://explorer.mode.network", + // browserURL: "https://explorer.mode.network" + // } + // } + // ] }, + }; subtask(TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS).setAction(async (_, hre, runSuper) => { diff --git a/lib/vault-v2 b/lib/vault-v2 new file mode 160000 index 0000000..6cbaea2 --- /dev/null +++ b/lib/vault-v2 @@ -0,0 +1 @@ +Subproject commit 6cbaea2c6d3f6fbfa3aebab182f9a75d056f7a43 diff --git a/remappings.txt b/remappings.txt index d1a17b6..faaa3df 100644 --- a/remappings.txt +++ b/remappings.txt @@ -2,4 +2,5 @@ create3-factory/=lib/create3-factory/src/ v3-core/=lib/v3-core/contracts/ oz-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ oz/=lib/openzeppelin-contracts/contracts/ +vault/=lib/vault-v2/src diff --git a/src/OptionsToken.sol b/src/OptionsToken.sol index d51db05..a19e8ee 100644 --- a/src/OptionsToken.sol +++ b/src/OptionsToken.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.13; import {OwnableUpgradeable} from "oz-upgradeable/access/OwnableUpgradeable.sol"; import {ERC20Upgradeable} from "oz-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import {UUPSUpgradeable} from "oz-upgradeable/proxy/utils/UUPSUpgradeable.sol"; - +import {PausableUpgradeable} from "oz-upgradeable/security/PausableUpgradeable.sol"; import {IOptionsToken} from "./interfaces/IOptionsToken.sol"; import {IOracle} from "./interfaces/IOracle.sol"; import {IExercise} from "./interfaces/IExercise.sol"; @@ -13,7 +13,7 @@ import {IExercise} from "./interfaces/IExercise.sol"; /// @author Eidolon & lookee /// @notice Options token representing the right to perform an advantageous action, /// such as purchasing the underlying token at a discount to the market price. -contract OptionsToken is IOptionsToken, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable { +contract OptionsToken is IOptionsToken, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable, PausableUpgradeable { /// ----------------------------------------------------------------------- /// Errors /// ----------------------------------------------------------------------- @@ -61,6 +61,7 @@ contract OptionsToken is IOptionsToken, ERC20Upgradeable, OwnableUpgradeable, UU __UUPSUpgradeable_init(); __ERC20_init(name_, symbol_); __Ownable_init(); + __Pausable_init(); tokenAdmin = tokenAdmin_; _clearUpgradeCooldown(); @@ -96,9 +97,14 @@ contract OptionsToken is IOptionsToken, ERC20Upgradeable, OwnableUpgradeable, UU /// @param recipient The recipient of the reward /// @param option The address of the Exercise contract with the redemption logic /// @param params Extra parameters to be used by the exercise function + /// @return paymentAmount token amount paid for exercising + /// @return data0 address data to return by different exerciser contracts + /// @return data1 integer data to return by different exerciser contracts + /// @return data2 additional integer data to return by different exerciser contracts function exercise(uint256 amount, address recipient, address option, bytes calldata params) external virtual + whenNotPaused returns ( uint256 paymentAmount, address, @@ -121,6 +127,16 @@ contract OptionsToken is IOptionsToken, ERC20Upgradeable, OwnableUpgradeable, UU emit SetExerciseContract(_address, _isExercise); } + /// @notice Pauses functionality related to exercises of contracts. + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpauses functionality related to exercises of contracts. + function unpause() external onlyOwner { + _unpause(); + } + /// ----------------------------------------------------------------------- /// Internal functions /// ----------------------------------------------------------------------- diff --git a/src/exercise/DiscountExercise.sol b/src/exercise/DiscountExercise.sol index 54f7dbb..1643a47 100644 --- a/src/exercise/DiscountExercise.sol +++ b/src/exercise/DiscountExercise.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.13; import {Owned} from "solmate/auth/Owned.sol"; import {IERC20} from "oz/token/ERC20/IERC20.sol"; +import {Pausable} from "oz/security/Pausable.sol"; import {SafeERC20} from "oz/token/ERC20/utils/SafeERC20.sol"; import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; @@ -10,9 +11,12 @@ import {BaseExercise} from "../exercise/BaseExercise.sol"; import {IOracle} from "../interfaces/IOracle.sol"; import {OptionsToken} from "../OptionsToken.sol"; +import {ExchangeType, SwapProps, SwapHelper} from "../helpers/SwapHelper.sol"; + struct DiscountExerciseParams { uint256 maxPaymentAmount; uint256 deadline; + bool isInstantExit; } /// @title Options Token Exercise Contract @@ -20,7 +24,7 @@ struct DiscountExerciseParams { /// @notice Contract that allows the holder of options tokens to exercise them, /// in this case, by purchasing the underlying token at a discount to the market price. /// @dev Assumes the underlying token and the payment token both use 18 decimals. -contract DiscountExercise is BaseExercise { +contract DiscountExercise is BaseExercise, SwapHelper, Pausable { /// Library usage using SafeERC20 for IERC20; using FixedPointMathLib for uint256; @@ -30,19 +34,20 @@ contract DiscountExercise is BaseExercise { error Exercise__PastDeadline(); error Exercise__MultiplierOutOfRange(); error Exercise__InvalidOracle(); + error Exercise__FeeGreaterThanMax(); + error Exercise__AmountOutIsZero(); + error Exercise__ZapMultiplierIncompatible(); /// Events event Exercised(address indexed sender, address indexed recipient, uint256 amount, uint256 paymentAmount); event SetOracle(IOracle indexed newOracle); event SetTreasury(address indexed newTreasury); event SetMultiplier(uint256 indexed newMultiplier); + event Claimed(uint256 indexed amount); + event SetInstantFee(uint256 indexed instantFee); + event SetMinAmountToTrigger(uint256 minAmountToTrigger); /// Constants - - /// @notice The denominator for converting the multiplier into a decimal number. - /// i.e. multiplier uses 4 decimals. - uint256 internal constant MULTIPLIER_DENOM = 10000; - /// Immutable parameters /// @notice The token paid by the options token holder during redemption @@ -63,7 +68,16 @@ contract DiscountExercise is BaseExercise { /// @notice The amount of payment tokens the user can claim /// Used when the contract does not have enough tokens to pay the user - mapping (address => uint256) public credit; + mapping(address => uint256) public credit; + + /// @notice The fee amount gathered in the contract to be swapped and distributed + uint256 private feeAmount; + + /// @notice Minimal trigger to swap, if the trigger is not reached then feeAmount counts the ammount to swap and distribute + uint256 public minAmountToTriggerSwap; + + /// @notice configurable parameter that determines what is the fee for zap (instant exit) feature + uint256 public instantExitFee; constructor( OptionsToken oToken_, @@ -72,14 +86,20 @@ contract DiscountExercise is BaseExercise { IERC20 underlyingToken_, IOracle oracle_, uint256 multiplier_, + uint256 instantExitFee_, + uint256 minAmountToTriggerSwap_, address[] memory feeRecipients_, - uint256[] memory feeBPS_ - ) BaseExercise(oToken_, feeRecipients_, feeBPS_) Owned(owner_) { + uint256[] memory feeBPS_, + SwapProps memory swapProps_ + ) BaseExercise(oToken_, feeRecipients_, feeBPS_) Owned(owner_) SwapHelper() { paymentToken = paymentToken_; underlyingToken = underlyingToken_; + _setSwapProps(swapProps_); _setOracle(oracle_); _setMultiplier(multiplier_); + _setInstantExitFee(instantExitFee_); + _setMinAmountToTriggerSwap(minAmountToTriggerSwap_); emit SetOracle(oracle_); } @@ -97,19 +117,32 @@ contract DiscountExercise is BaseExercise { virtual override onlyOToken + whenNotPaused returns (uint256 paymentAmount, address, uint256, uint256) { - return _exercise(from, amount, recipient, params); + DiscountExerciseParams memory _params = abi.decode(params, (DiscountExerciseParams)); + if (_params.isInstantExit) { + return _zap(from, amount, recipient, _params); + } else { + return _redeem(from, amount, recipient, _params); + } } - function claim(address to) external { + /// @notice Transfers not claimed tokens to the sender. + /// @dev When contract doesn't have funds during exercise, this function allows to claim the tokens once contract is funded + /// @param to Destination address for token transfer + function claim(address to) external whenNotPaused { uint256 amount = credit[msg.sender]; if (amount == 0) return; credit[msg.sender] = 0; underlyingToken.safeTransfer(to, amount); + emit Claimed(amount); } /// Owner functions + function setSwapProps(SwapProps memory _swapProps) external virtual override onlyOwner { + _setSwapProps(_swapProps); + } /// @notice Sets the oracle contract. Only callable by the owner. /// @param oracle_ The new oracle contract @@ -119,8 +152,9 @@ contract DiscountExercise is BaseExercise { function _setOracle(IOracle oracle_) internal { (address paymentToken_, address underlyingToken_) = oracle_.getTokens(); - if (paymentToken_ != address(paymentToken) || underlyingToken_ != address(underlyingToken)) + if (paymentToken_ != address(paymentToken) || underlyingToken_ != address(underlyingToken)) { revert Exercise__InvalidOracle(); + } oracle = oracle_; emit SetOracle(oracle_); } @@ -133,31 +167,97 @@ contract DiscountExercise is BaseExercise { function _setMultiplier(uint256 multiplier_) internal { if ( - multiplier_ > MULTIPLIER_DENOM * 2 // over 200% - || multiplier_ < MULTIPLIER_DENOM / 10 // under 10% + multiplier_ > BPS_DENOM * 2 // over 200% + || multiplier_ < BPS_DENOM / 10 // under 10% ) revert Exercise__MultiplierOutOfRange(); multiplier = multiplier_; emit SetMultiplier(multiplier_); } - /// Internal functions + /// @notice Sets the discount instantExitFee. + /// @param _instantExitFee The new instantExitFee + function setInstantExitFee(uint256 _instantExitFee) external onlyOwner { + _setInstantExitFee(_instantExitFee); + } + + function _setInstantExitFee(uint256 _instantExitFee) internal { + if (_instantExitFee > BPS_DENOM) { + revert Exercise__FeeGreaterThanMax(); + } + instantExitFee = _instantExitFee; + emit SetInstantFee(_instantExitFee); + } + + /// @notice Sets the discount minAmountToTriggerSwap. + /// @param _minAmountToTriggerSwap The new minAmountToTriggerSwap + function setMinAmountToTriggerSwap(uint256 _minAmountToTriggerSwap) external onlyOwner { + _setMinAmountToTriggerSwap(_minAmountToTriggerSwap); + } + + function _setMinAmountToTriggerSwap(uint256 _minAmountToTriggerSwap) internal { + minAmountToTriggerSwap = _minAmountToTriggerSwap; + emit SetMinAmountToTrigger(_minAmountToTriggerSwap); + } + + function pause() external onlyOwner { + _pause(); + } - function _exercise(address from, uint256 amount, address recipient, bytes memory params) + function unpause() external onlyOwner { + _unpause(); + } + + /// Internal functions + function _zap(address from, uint256 amount, address recipient, DiscountExerciseParams memory params) internal - virtual returns (uint256 paymentAmount, address, uint256, uint256) { - // decode params - DiscountExerciseParams memory _params = abi.decode(params, (DiscountExerciseParams)); + if (block.timestamp > params.deadline) revert Exercise__PastDeadline(); + if (multiplier >= BPS_DENOM) revert Exercise__ZapMultiplierIncompatible(); + uint256 discountedUnderlying = amount.mulDivUp(BPS_DENOM - multiplier, BPS_DENOM); + uint256 fee = discountedUnderlying.mulDivUp(instantExitFee, BPS_DENOM); + uint256 underlyingAmount = discountedUnderlying - fee; + uint256 balance = underlyingToken.balanceOf(address(this)); - if (block.timestamp > _params.deadline) revert Exercise__PastDeadline(); + // Fee amount in underlying tokens charged for zapping + feeAmount += fee; - // apply multiplier to price - uint256 price = oracle.getPrice().mulDivUp(multiplier, MULTIPLIER_DENOM); + if (feeAmount >= minAmountToTriggerSwap && balance >= (feeAmount + underlyingAmount)) { + uint256 minAmountOut = _getMinAmountOutData(feeAmount, swapProps.maxSwapSlippage, address(oracle)); + /* Approve the underlying token to make swap */ + underlyingToken.approve(swapProps.swapper, feeAmount); + /* Swap underlying token to payment token (asset) */ + uint256 amountOut = _generalSwap( + swapProps.exchangeTypes, address(underlyingToken), address(paymentToken), feeAmount, minAmountOut, swapProps.exchangeAddress + ); + + if (amountOut == 0) { + revert Exercise__AmountOutIsZero(); + } + + feeAmount = 0; + underlyingToken.approve(swapProps.swapper, 0); + + // transfer payment tokens from user to the set receivers + distributeFees(paymentToken.balanceOf(address(this)), paymentToken); + } - paymentAmount = amount.mulWadUp(price); - if (paymentAmount > _params.maxPaymentAmount) revert Exercise__SlippageTooHigh(); + // transfer underlying tokens to recipient without the bonus + _pay(recipient, underlyingAmount); + emit Exercised(from, recipient, underlyingAmount, paymentAmount); + } + + /// Internal functions + function _redeem(address from, uint256 amount, address recipient, DiscountExerciseParams memory params) + internal + virtual + returns (uint256 paymentAmount, address, uint256, uint256) + { + if (block.timestamp > params.deadline) revert Exercise__PastDeadline(); + // apply multiplier to price + paymentAmount = _getPaymentAmount(amount); + if (paymentAmount > params.maxPaymentAmount) revert Exercise__SlippageTooHigh(); // transfer payment tokens from user to the set receivers distributeFeesFrom(paymentAmount, paymentToken, from); // transfer underlying tokens to recipient @@ -182,6 +282,10 @@ contract DiscountExercise is BaseExercise { /// @notice Returns the amount of payment tokens required to exercise the given amount of options tokens. /// @param amount The amount of options tokens to exercise function getPaymentAmount(uint256 amount) external view returns (uint256 paymentAmount) { - paymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(multiplier, MULTIPLIER_DENOM)); + return _getPaymentAmount(amount); + } + + function _getPaymentAmount(uint256 amount) internal view returns (uint256 paymentAmount) { + paymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(multiplier, BPS_DENOM)); } } diff --git a/src/helpers/SwapHelper.sol b/src/helpers/SwapHelper.sol new file mode 100644 index 0000000..87c7af5 --- /dev/null +++ b/src/helpers/SwapHelper.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.13; + +import {ISwapperSwaps, MinAmountOutData, MinAmountOutKind} from "vault-v2/ReaperSwapper.sol"; +import {IOracle} from "../interfaces/IOracle.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; + +enum ExchangeType { + UniV2, + Bal, + VeloSolid, + UniV3 +} + +struct SwapProps { + address swapper; + address exchangeAddress; + ExchangeType exchangeTypes; + uint256 maxSwapSlippage; +} + +abstract contract SwapHelper { + using FixedPointMathLib for uint256; + + /// @notice The denominator for converting the multiplier into a decimal number. + /// i.e. multiplier uses 4 decimals. + uint256 internal constant BPS_DENOM = 10_000; + SwapProps public swapProps; + + error SwapHelper__SlippageGreaterThanMax(); + error SwapHelper__ParamHasAddressZero(); + error SwapHelper__InvalidExchangeType(uint256 exType); + + constructor() {} + + /** + * @dev Override function shall have proper access control + * @param _swapProps - swap properties + */ + function setSwapProps(SwapProps memory _swapProps) external virtual; + + function _setSwapProps(SwapProps memory _swapProps) internal { + if (_swapProps.maxSwapSlippage > BPS_DENOM) { + revert SwapHelper__SlippageGreaterThanMax(); + } + if (_swapProps.exchangeAddress == address(0)) { + revert SwapHelper__ParamHasAddressZero(); + } + if (_swapProps.swapper == address(0)) { + revert SwapHelper__ParamHasAddressZero(); + } + swapProps = _swapProps; + } + + /** + * @dev Private function that allow to swap via multiple exchange types + * @param exType - type of exchange + * @param tokenIn - address of token in + * @param tokenOut - address of token out + * @param amount - amount of tokenIn to swap + * @param minAmountOut - minimal acceptable amount of tokenOut + * @param exchangeAddress - address of the exchange + */ + function _generalSwap(ExchangeType exType, address tokenIn, address tokenOut, uint256 amount, uint256 minAmountOut, address exchangeAddress) + internal + returns (uint256) + { + ISwapperSwaps _swapper = ISwapperSwaps(swapProps.swapper); + MinAmountOutData memory minAmountOutData = MinAmountOutData(MinAmountOutKind.Absolute, minAmountOut); + if (exType == ExchangeType.UniV2) { + return _swapper.swapUniV2(tokenIn, tokenOut, amount, minAmountOutData, exchangeAddress); + } else if (exType == ExchangeType.Bal) { + return _swapper.swapBal(tokenIn, tokenOut, amount, minAmountOutData, exchangeAddress); + } else if (exType == ExchangeType.VeloSolid) { + return _swapper.swapVelo(tokenIn, tokenOut, amount, minAmountOutData, exchangeAddress); + } else if (exType == ExchangeType.UniV3) { + return _swapper.swapUniV3(tokenIn, tokenOut, amount, minAmountOutData, exchangeAddress); + } else { + revert SwapHelper__InvalidExchangeType(uint256(exType)); + } + } + + /** + * @dev Private function that calculates minimal amount token out of swap using oracles + * @param _amountIn - amount of token to be swapped + * @param _maxSlippage - max allowed slippage + */ + function _getMinAmountOutData(uint256 _amountIn, uint256 _maxSlippage, address _oracle) internal view returns (uint256) { + uint256 minAmountOut = 0; + /* Get price from oracle */ + uint256 price = IOracle(_oracle).getPrice(); + /* Deduct slippage amount from predicted amount */ + minAmountOut = (_amountIn.mulWadUp(price) * (BPS_DENOM - _maxSlippage)) / BPS_DENOM; + + return minAmountOut; + } +} diff --git a/src/interfaces/ISwapperSwaps.sol b/src/interfaces/ISwapperSwaps.sol new file mode 100644 index 0000000..2cf6215 --- /dev/null +++ b/src/interfaces/ISwapperSwaps.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: BUSL1.1 + +pragma solidity ^0.8.0; + +enum MinAmountOutKind { + Absolute, + ChainlinkBased +} + +struct MinAmountOutData { + MinAmountOutKind kind; + uint256 absoluteOrBPSValue; // for type "ChainlinkBased", value must be in BPS +} + +struct UniV3SwapData { + address[] path; + uint24[] fees; +} + +interface ISwapperSwaps { + function swapUniV2( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _router, + uint256 _deadline, + bool _tryCatchActive + ) external returns (uint256); + + function swapUniV2( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _router, + uint256 _deadline + ) external returns (uint256); + + function swapUniV2( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _router + ) external returns (uint256); + + function swapBal( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _vault, + uint256 _deadline, + bool _tryCatchActive + ) external returns (uint256); + + function swapBal( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _vault, + uint256 _deadline + ) external returns (uint256); + + function swapBal( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _vault + ) external returns (uint256); + + function swapThenaRam( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _router, + uint256 _deadline, + bool _tryCatchActive + ) external returns (uint256); + + function swapThenaRam( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _router, + uint256 _deadline + ) external returns (uint256); + + function swapThenaRam( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _router + ) external returns (uint256); + + function swapUniV3( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _router, + uint256 _deadline, + bool _tryCatchActive + ) external returns (uint256); + + function swapUniV3( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _router, + uint256 _deadline + ) external returns (uint256); + + function swapUniV3( + address _from, + address _to, + uint256 _amount, + MinAmountOutData memory _minAmountOutData, + address _router + ) external returns (uint256); +} diff --git a/src/oracles/AlgebraOracle.sol b/src/oracles/AlgebraOracle.sol index 349e8aa..79ecf89 100644 --- a/src/oracles/AlgebraOracle.sol +++ b/src/oracles/AlgebraOracle.sol @@ -5,7 +5,7 @@ import {Owned} from "solmate/auth/Owned.sol"; import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; import {IOracle} from "../interfaces/IOracle.sol"; - +import {ERC20} from "solmate/tokens/ERC20.sol"; import {IAlgebraPool} from "../interfaces/IAlgebraPool.sol"; import {TickMath} from "v3-core/libraries/TickMath.sol"; import {FullMath} from "v3-core/libraries/FullMath.sol"; @@ -70,6 +70,7 @@ contract AlgebraOracle is IOracle, Owned { /// ----------------------------------------------------------------------- constructor(IAlgebraPool algebraPool_, address token, address owner_, uint32 secs_, uint32 ago_, uint128 minPrice_) Owned(owner_) { + if (ERC20(algebraPool_.token0()).decimals() != 18 || ERC20(algebraPool_.token1()).decimals() != 18) revert AlgebraOracle__InvalidParams(); if (algebraPool_.token0() != token && algebraPool_.token1() != token) revert AlgebraOracle__InvalidParams(); if (secs_ < MIN_SECS) revert AlgebraOracle__InvalidWindow(); algebraPool = algebraPool_; diff --git a/src/oracles/ThenaOracle.sol b/src/oracles/ThenaOracle.sol index 0b2e8a1..ab43a82 100644 --- a/src/oracles/ThenaOracle.sol +++ b/src/oracles/ThenaOracle.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.13; import {Owned} from "solmate/auth/Owned.sol"; import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; - +import {ERC20} from "solmate/tokens/ERC20.sol"; import {IOracle} from "../interfaces/IOracle.sol"; import {IThenaPair} from "../interfaces/IThenaPair.sol"; @@ -64,6 +64,7 @@ contract ThenaOracle is IOracle, Owned { /// ----------------------------------------------------------------------- constructor(IThenaPair thenaPair_, address token, address owner_, uint56 secs_, uint128 minPrice_) Owned(owner_) { + if (ERC20(thenaPair_.token0()).decimals() != 18 || ERC20(thenaPair_.token1()).decimals() != 18) revert ThenaOracle__InvalidParams(); if (thenaPair_.stable()) revert ThenaOracle__StablePairsUnsupported(); if (thenaPair_.token0() != token && thenaPair_.token1() != token) revert ThenaOracle__InvalidParams(); if (secs_ < MIN_SECS) revert ThenaOracle__InvalidWindow(); diff --git a/src/oracles/UniswapV3Oracle.sol b/src/oracles/UniswapV3Oracle.sol index 8c93e6d..cbad255 100644 --- a/src/oracles/UniswapV3Oracle.sol +++ b/src/oracles/UniswapV3Oracle.sol @@ -5,7 +5,7 @@ import {Owned} from "solmate/auth/Owned.sol"; import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; import {IOracle} from "../interfaces/IOracle.sol"; - +import {ERC20} from "solmate/tokens/ERC20.sol"; import {IUniswapV3Pool} from "v3-core/interfaces/IUniswapV3Pool.sol"; import {TickMath} from "v3-core/libraries/TickMath.sol"; import {FullMath} from "v3-core/libraries/FullMath.sol"; @@ -69,6 +69,7 @@ contract UniswapV3Oracle is IOracle, Owned { /// ----------------------------------------------------------------------- constructor(IUniswapV3Pool uniswapPool_, address token, address owner_, uint32 secs_, uint32 ago_, uint128 minPrice_) Owned(owner_) { + if (ERC20(uniswapPool_.token0()).decimals() != 18 || ERC20(uniswapPool_.token1()).decimals() != 18) revert UniswapOracle__InvalidParams(); //|| ERC20(uniswapPool_.token1()).decimals() != 18 if (uniswapPool_.token0() != token && uniswapPool_.token1() != token) revert UniswapOracle__InvalidParams(); if (secs_ < MIN_SECS) revert UniswapOracle__InvalidWindow(); uniswapPool = uniswapPool_; diff --git a/test/Common.sol b/test/Common.sol new file mode 100644 index 0000000..3043853 --- /dev/null +++ b/test/Common.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import {ReaperVaultV2} from "vault-v2/ReaperVaultV2.sol"; +import {IERC20} from "oz/token/ERC20/IERC20.sol"; +import {ReaperSwapper, MinAmountOutData, MinAmountOutKind, IVeloRouter, ISwapRouter, UniV3SwapData} from "vault-v2/ReaperSwapper.sol"; +import {OptionsToken} from "../src/OptionsToken.sol"; +import {SwapProps, ExchangeType} from "../src/helpers/SwapHelper.sol"; +import {ERC1967Proxy} from "oz/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {DiscountExerciseParams, DiscountExercise} from "../src/exercise/DiscountExercise.sol"; +import {IOracle} from "../src/interfaces/IOracle.sol"; +import {ThenaOracle, IThenaPair} from "../src/oracles/ThenaOracle.sol"; +import {IUniswapV3Factory} from "vault-v2/interfaces/IUniswapV3Factory.sol"; +import {IUniswapV3Pool, UniswapV3Oracle} from "../src/oracles/UniswapV3Oracle.sol"; +import {MockBalancerTwapOracle} from "../test/mocks/MockBalancerTwapOracle.sol"; +import {BalancerOracle} from "../src/oracles/BalancerOracle.sol"; + +error Common__NotYetImplemented(); + +/* Constants */ +uint256 constant NON_ZERO_PROFIT = 1; +uint16 constant PRICE_MULTIPLIER = 5000; // 0.5 +uint256 constant INSTANT_EXIT_FEE = 500; // 0.05 +uint56 constant ORACLE_SECS = 30 minutes; +uint56 constant ORACLE_AGO = 2 minutes; +uint128 constant ORACLE_MIN_PRICE = 1e7; +uint56 constant ORACLE_LARGEST_SAFETY_WINDOW = 24 hours; +uint256 constant ORACLE_MIN_PRICE_DENOM = 10000; +uint256 constant BPS_DENOM = 10_000; +uint256 constant MAX_SUPPLY = 1e27; // the max supply of the options token & the underlying token + +uint256 constant AMOUNT = 2e18; // 2 ETH +address constant REWARDER = 0x6A0406B8103Ec68EE9A713A073C7bD587c5e04aD; +uint256 constant MIN_OATH_FOR_FUZZING = 1e19; + +/* OP */ +address constant OP_POOL_ADDRESSES_PROVIDER_V2 = 0xdDE5dC81e40799750B92079723Da2acAF9e1C6D6; // Granary (aavev2) +// AAVEv3 - 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb; +address constant OP_WETH = 0x4200000000000000000000000000000000000006; +address constant OP_OATHV1 = 0x39FdE572a18448F8139b7788099F0a0740f51205; +address constant OP_OATHV2 = 0x00e1724885473B63bCE08a9f0a52F35b0979e35A; +address constant OP_CUSDC = 0xEC8FEa79026FfEd168cCf5C627c7f486D77b765F; +address constant OP_GUSDC = 0x7A0FDDBA78FF45D353B1630B77f4D175A00df0c0; +address constant OP_GOP = 0x30091e843deb234EBb45c7E1Da4bBC4C33B3f0B4; +address constant OP_SOOP = 0x8cD6b19A07d754bF36AdEEE79EDF4F2134a8F571; +address constant OP_USDC = 0x7F5c764cBc14f9669B88837ca1490cCa17c31607; +address constant OP_OP = 0x4200000000000000000000000000000000000042; +/* Balancer */ +address constant OP_DATA_PROVIDER = 0x9546F673eF71Ff666ae66d01Fd6E7C6Dae5a9995; +bytes32 constant OP_OATHV1_ETH_BPT = 0xd20f6f1d8a675cdca155cb07b5dc9042c467153f0002000000000000000000bc; // OATHv1/ETH BPT +bytes32 constant OP_OATHV2_ETH_BPT = 0xd13d81af624956327a24d0275cbe54b0ee0e9070000200000000000000000109; // OATHv2/ETH BPT +bytes32 constant OP_BTC_WETH_USDC_BPT = 0x5028497af0c9a54ea8c6d42a054c0341b9fc6168000100000000000000000004; +bytes32 constant OP_WETH_OP_USDC_BPT = 0x39965c9dab5448482cf7e002f583c812ceb53046000100000000000000000003; +address constant OP_BEETX_VAULT = 0xBA12222222228d8Ba445958a75a0704d566BF2C8; +/* Uniswap */ +address constant OP_UNIV3_ROUTERV = 0xE592427A0AEce92De3Edee1F18E0157C05861564; +address constant OP_UNIV3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; +/* Velodrome */ +address constant OP_VELO_OATHV2_ETH_PAIR = 0xc3439bC1A747e545887192d6b7F8BE47f608473F; +address constant OP_VELO_ROUTER = 0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858; +address constant OP_VELO_FACTORY = 0xF1046053aa5682b4F9a81b5481394DA16BE5FF5a; + +/* BSC */ +address constant BSC_LENDING_POOL = 0xad441B19a9948c3a3f38C0AB6CCbd853036851d2; +address constant BSC_ADDRESS_PROVIDER = 0xcD2f1565e6d2A83A167FDa6abFc10537d4e984f0; +address constant BSC_DATA_PROVIDER = 0xFa0AC9b741F0868B2a8C4a6001811a5153019818; +address constant BSC_HBR = 0x42c95788F791a2be3584446854c8d9BB01BE88A9; +address constant BSC_USDT = 0x55d398326f99059fF775485246999027B3197955; +address constant BSC_BTCB = 0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c; +address constant BSC_WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; +address constant BSC_GUSDT = 0x686C55C8344E902CD8143Cf4BDF2c5089Be273c5; +address constant BSC_THENA_ROUTER = 0xd4ae6eCA985340Dd434D38F470aCCce4DC78D109; +address constant BSC_THENA_FACTORY = 0x2c788FE40A417612cb654b14a944cd549B5BF130; +address constant BSC_UNIV3_ROUTERV2 = 0xB971eF87ede563556b2ED4b1C0b0019111Dd85d2; +address constant BSC_UNIV3_FACTORY = 0xdB1d10011AD0Ff90774D0C6Bb92e5C5c8b4461F7; +address constant BSC_PANCAKE_ROUTER = 0x1b81D678ffb9C0263b24A97847620C99d213eB14; +address constant BSC_PANCAKE_FACTORY = 0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865; +address constant BSC_REWARDER = 0x071c626C75248E4F672bAb8c21c089166F49B615; + +/* ARB */ +address constant ARB_USDCE = 0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8; +address constant ARB_USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; +address constant ARB_RAM = 0xAAA6C1E32C55A7Bfa8066A6FAE9b42650F262418; +address constant ARB_WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1; +/* Ramses */ +address constant ARB_RAM_ROUTER = 0xAAA87963EFeB6f7E0a2711F397663105Acb1805e; +address constant ARB_RAM_ROUTERV2 = 0xAA23611badAFB62D37E7295A682D21960ac85A90; //univ3 +address constant ARB_RAM_FACTORYV2 = 0xAA2cd7477c451E703f3B9Ba5663334914763edF8; + +/* MODE */ +address constant MODE_MODE = 0xDfc7C877a950e49D2610114102175A06C2e3167a; +address constant MODE_USDC = 0xd988097fb8612cc24eeC14542bC03424c656005f; +address constant MODE_WETH = 0x4200000000000000000000000000000000000006; +/* Velodrome */ +address constant MODE_VELO_USDC_MODE_PAIR = 0x283bA4E204DFcB6381BCBf2cb5d0e765A2B57bC2; // DECIMALS ISSUE +address constant MODE_VELO_WETH_MODE_PAIR = 0x0fba984c97539B3fb49ACDA6973288D0EFA903DB; +address constant MODE_VELO_ROUTER = 0x3a63171DD9BebF4D07BC782FECC7eb0b890C2A45; +address constant MODE_VELO_FACTORY = 0x31832f2a97Fd20664D76Cc421207669b55CE4BC0; + +contract Common is Test { + IERC20 nativeToken; + IERC20 paymentToken; + IERC20 underlyingToken; + IERC20 wantToken; + IVeloRouter veloRouter; + ISwapRouter swapRouter; + IUniswapV3Factory univ3Factory; + ReaperSwapper reaperSwapper; + MockBalancerTwapOracle underlyingPaymentMock; + + address[] treasuries; + uint256[] feeBPS; + bytes32 paymentUnderlyingBpt; + bytes32 paymentWantBpt; + + address veloFactory; + address pool; + address addressProvider; + address dataProvider; + address rewarder; + address balancerVault; + address owner; + address gWantAddress; + address tokenAdmin; + address strategist = address(4); + address vault; + address management1; + address management2; + address management3; + address keeper; + + uint256 targetLtv = 0.77 ether; + uint256 maxLtv = 0.771 ether; + + OptionsToken optionsToken; + ERC1967Proxy tmpProxy; + OptionsToken optionsTokenProxy; + DiscountExercise exerciser; + + function fixture_setupAccountsAndFees(uint256 fee1, uint256 fee2) public { + /* Setup accounts */ + owner = makeAddr("owner"); + tokenAdmin = makeAddr("tokenAdmin"); + treasuries = new address[](2); + treasuries[0] = makeAddr("treasury1"); + treasuries[1] = makeAddr("treasury2"); + vault = makeAddr("vault"); + management1 = makeAddr("management1"); + management2 = makeAddr("management2"); + management3 = makeAddr("management3"); + keeper = makeAddr("keeper"); + + feeBPS = new uint256[](2); + feeBPS[0] = fee1; + feeBPS[1] = fee2; + } + + /* Functions */ + function fixture_prepareOptionToken(uint256 _amount, address _compounder, address _strategy, OptionsToken _optionsToken, address _tokenAdmin) + public + { + /* Mint options tokens and transfer them to the strategy (rewards simulation) */ + vm.prank(_tokenAdmin); + _optionsToken.mint(_strategy, _amount); + vm.prank(_strategy); + _optionsToken.approve(_compounder, _amount); + } + + function fixture_updateSwapperPaths(ExchangeType exchangeType) public { + address[2] memory paths = [address(underlyingToken), address(paymentToken)]; + + if (exchangeType == ExchangeType.Bal) { + /* Configure balancer like dexes */ + reaperSwapper.updateBalSwapPoolID(paths[0], paths[1], balancerVault, paymentUnderlyingBpt); + reaperSwapper.updateBalSwapPoolID(paths[1], paths[0], balancerVault, paymentUnderlyingBpt); + } else if (exchangeType == ExchangeType.VeloSolid) { + /* Configure thena ram like dexes */ + IVeloRouter.Route[] memory veloPath = new IVeloRouter.Route[](1); + veloPath[0] = IVeloRouter.Route(paths[0], paths[1], false); + reaperSwapper.updateVeloSwapPath(paths[0], paths[1], address(veloRouter), veloPath); + veloPath[0] = IVeloRouter.Route(paths[1], paths[0], false); + reaperSwapper.updateVeloSwapPath(paths[1], paths[0], address(veloRouter), veloPath); + } else if (exchangeType == ExchangeType.UniV3) { + /* Configure univ3 like dexes */ + uint24[] memory univ3Fees = new uint24[](1); + univ3Fees[0] = 500; + address[] memory univ3Path = new address[](2); + + univ3Path[0] = paths[0]; + univ3Path[1] = paths[1]; + UniV3SwapData memory swapPathAndFees = UniV3SwapData(univ3Path, univ3Fees); + reaperSwapper.updateUniV3SwapPath(paths[0], paths[1], address(swapRouter), swapPathAndFees); + } else { + revert Common__NotYetImplemented(); + } + } + + function fixture_getMockedOracle(ExchangeType exchangeType) public returns (IOracle) { + IOracle oracle; + address[] memory _tokens = new address[](2); + _tokens[0] = address(paymentToken); + _tokens[1] = address(underlyingToken); + if (exchangeType == ExchangeType.Bal) { + BalancerOracle underlyingPaymentOracle; + underlyingPaymentMock = new MockBalancerTwapOracle(_tokens); + underlyingPaymentOracle = + new BalancerOracle(underlyingPaymentMock, address(underlyingToken), owner, ORACLE_SECS, ORACLE_AGO, ORACLE_MIN_PRICE); + oracle = underlyingPaymentOracle; + } else if (exchangeType == ExchangeType.VeloSolid) { + IVeloRouter router = IVeloRouter(payable(address(veloRouter))); + ThenaOracle underlyingPaymentOracle; + address pair = router.poolFor(address(underlyingToken), address(paymentToken), false); + underlyingPaymentOracle = new ThenaOracle(IThenaPair(pair), address(underlyingToken), owner, ORACLE_SECS, ORACLE_MIN_PRICE); + oracle = IOracle(address(underlyingPaymentOracle)); + } else if (exchangeType == ExchangeType.UniV3) { + IUniswapV3Pool univ3Pool = IUniswapV3Pool(univ3Factory.getPool(address(underlyingToken), address(paymentToken), 500)); + UniswapV3Oracle univ3Oracle = + new UniswapV3Oracle(univ3Pool, address(paymentToken), owner, uint32(ORACLE_SECS), uint32(ORACLE_AGO), ORACLE_MIN_PRICE); + oracle = IOracle(address(univ3Oracle)); + } else { + revert Common__NotYetImplemented(); + } + return oracle; + } + + function fixture_getSwapProps(ExchangeType exchangeType, uint256 slippage) public view returns (SwapProps memory) { + SwapProps memory swapProps; + + if (exchangeType == ExchangeType.Bal) { + swapProps = SwapProps(address(reaperSwapper), address(swapRouter), ExchangeType.Bal, slippage); + } else if (exchangeType == ExchangeType.VeloSolid) { + swapProps = SwapProps(address(reaperSwapper), address(veloRouter), ExchangeType.VeloSolid, slippage); + } else if (exchangeType == ExchangeType.UniV3) { + swapProps = SwapProps(address(reaperSwapper), address(swapRouter), ExchangeType.UniV3, slippage); + } else { + revert Common__NotYetImplemented(); + } + return swapProps; + } +} diff --git a/test/ItBscOptionsToken.t.solNOT b/test/ItBscOptionsToken.t.solNOT new file mode 100644 index 0000000..8508344 --- /dev/null +++ b/test/ItBscOptionsToken.t.solNOT @@ -0,0 +1,407 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {IERC20} from "oz/token/ERC20/IERC20.sol"; +import {ERC1967Proxy} from "oz/proxy/ERC1967/ERC1967Proxy.sol"; + +import {OptionsToken} from "../src/OptionsToken.sol"; +import {DiscountExerciseParams, DiscountExercise, BaseExercise, SwapProps, ExchangeType} from "../src/exercise/DiscountExercise.sol"; +import {TestERC20} from "./mocks/TestERC20.sol"; +import {ThenaOracle} from "../src/oracles/ThenaOracle.sol"; +import {MockBalancerTwapOracle} from "./mocks/MockBalancerTwapOracle.sol"; + +import {ReaperSwapper, MinAmountOutData, MinAmountOutKind, IThenaRamRouter, ISwapRouter, UniV3SwapData} from "vault-v2/ReaperSwapper.sol"; + +contract OptionsTokenTest is Test { + using FixedPointMathLib for uint256; + + uint256 constant FORK_BLOCK = 36349190; + string MAINNET_URL = vm.envString("BSC_RPC_URL"); + + address constant BSC_THENA_ROUTER = 0xd4ae6eCA985340Dd434D38F470aCCce4DC78D109; + address constant BSC_UNIV3_ROUTERV2 = 0xB971eF87ede563556b2ED4b1C0b0019111Dd85d2; + address constant BSC_HBR = 0x42c95788F791a2be3584446854c8d9BB01BE88A9; + address constant BSC_WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + address constant ORACLE_CONTRACT = 0x733D732943aC1333771017e7c9D7b2d5abAdE5C4; + + uint16 constant PRICE_MULTIPLIER = 5000; // 0.5 + uint56 constant ORACLE_SECS = 30 minutes; + uint56 constant ORACLE_AGO = 2 minutes; + uint128 constant ORACLE_MIN_PRICE = 1e17; + uint56 constant ORACLE_LARGEST_SAFETY_WINDOW = 24 hours; + uint256 constant ORACLE_INIT_TWAP_VALUE = 1e19; + uint256 constant ORACLE_MIN_PRICE_DENOM = 10000; + + uint256 constant MAX_SUPPLY = 1e27; // the max supply of the options token & the underlying token + uint256 constant INSTANT_EXIT_FEE = 500; + + address owner; + address tokenAdmin; + address[] feeRecipients_; + uint256[] feeBPS_; + + OptionsToken optionsToken; + DiscountExercise exerciser; + ThenaOracle oracle; + MockBalancerTwapOracle balancerTwapOracle; + IERC20 paymentToken; + address underlyingToken; + ReaperSwapper reaperSwapper; + + function fixture_getSwapProps(ExchangeType exchangeType, uint256 slippage) public view returns (SwapProps memory) { + SwapProps memory swapProps; + + if (exchangeType == ExchangeType.ThenaRam) { + swapProps = SwapProps(address(reaperSwapper), BSC_THENA_ROUTER, ExchangeType.ThenaRam, slippage); + } else if (exchangeType == ExchangeType.UniV3) { + swapProps = SwapProps(address(reaperSwapper), BSC_UNIV3_ROUTERV2, ExchangeType.UniV3, slippage); + } else { + // revert + } + return swapProps; + } + + function fixture_updateSwapperPaths(ExchangeType exchangeType) public { + address[2] memory paths = [address(underlyingToken), address(paymentToken)]; + + if (exchangeType == ExchangeType.ThenaRam) { + /* Configure thena ram like dexes */ + IThenaRamRouter.route[] memory thenaPath = new IThenaRamRouter.route[](1); + thenaPath[0] = IThenaRamRouter.route(paths[0], paths[1], false); + reaperSwapper.updateThenaRamSwapPath(paths[0], paths[1], address(BSC_THENA_ROUTER), thenaPath); + thenaPath[0] = IThenaRamRouter.route(paths[1], paths[0], false); + reaperSwapper.updateThenaRamSwapPath(paths[1], paths[0], address(BSC_THENA_ROUTER), thenaPath); + } else if (exchangeType == ExchangeType.UniV3) { + /* Configure univ3 like dexes */ + uint24[] memory univ3Fees = new uint24[](1); + univ3Fees[0] = 500; + address[] memory univ3Path = new address[](2); + + univ3Path[0] = paths[0]; + univ3Path[1] = paths[1]; + UniV3SwapData memory swapPathAndFees = UniV3SwapData(univ3Path, univ3Fees); + reaperSwapper.updateUniV3SwapPath(paths[0], paths[1], address(BSC_UNIV3_ROUTERV2), swapPathAndFees); + } else { + // revert + } + } + + function setUp() public { + uint256 bscFork = vm.createFork(MAINNET_URL, FORK_BLOCK); + vm.selectFork(bscFork); + + // set up accounts + owner = makeAddr("owner"); + tokenAdmin = makeAddr("tokenAdmin"); + + feeRecipients_ = new address[](2); + feeRecipients_[0] = makeAddr("feeRecipient"); + feeRecipients_[1] = makeAddr("feeRecipient2"); + + feeBPS_ = new uint256[](2); + feeBPS_[0] = 1000; // 10% + feeBPS_[1] = 9000; // 90% + + // deploy contracts + paymentToken = IERC20(BSC_WBNB); + underlyingToken = BSC_HBR; + + address implementation = address(new OptionsToken()); + ERC1967Proxy proxy = new ERC1967Proxy(implementation, ""); + optionsToken = OptionsToken(address(proxy)); + optionsToken.initialize("TIT Call Option Token", "oTIT", tokenAdmin); + optionsToken.transferOwnership(owner); + + /* Reaper deployment and configuration */ + address[] memory strategists = new address[](1); + strategists[0] = makeAddr("strategist"); + reaperSwapper = new ReaperSwapper(); + ERC1967Proxy tmpProxy = new ERC1967Proxy(address(reaperSwapper), ""); + reaperSwapper = ReaperSwapper(address(tmpProxy)); + reaperSwapper.initialize(strategists, address(this), address(this)); + + fixture_updateSwapperPaths(ExchangeType.ThenaRam); + + SwapProps memory swapProps = fixture_getSwapProps(ExchangeType.ThenaRam, 200); + + address[] memory tokens = new address[](2); + tokens[0] = address(paymentToken); + tokens[1] = underlyingToken; + + balancerTwapOracle = new MockBalancerTwapOracle(tokens); + console.log(tokens[0], tokens[1]); + oracle = ThenaOracle(ORACLE_CONTRACT); + exerciser = new DiscountExercise( + optionsToken, + owner, + IERC20(address(paymentToken)), + IERC20(underlyingToken), + oracle, + PRICE_MULTIPLIER, + INSTANT_EXIT_FEE, + feeRecipients_, + feeBPS_, + swapProps + ); + deal(underlyingToken, address(exerciser), 1e20 ether); + + // add exerciser to the list of options + vm.startPrank(owner); + optionsToken.setExerciseContract(address(exerciser), true); + vm.stopPrank(); + + // set up contracts + balancerTwapOracle.setTwapValue(ORACLE_INIT_TWAP_VALUE); + paymentToken.approve(address(exerciser), type(uint256).max); + } + + function test_onlyTokenAdminCanMint(uint256 amount, address hacker) public { + vm.assume(hacker != tokenAdmin); + + // try minting as non token admin + vm.startPrank(hacker); + vm.expectRevert(OptionsToken.OptionsToken__NotTokenAdmin.selector); + optionsToken.mint(address(this), amount); + vm.stopPrank(); + + // mint as token admin + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // verify balance + assertEqDecimal(optionsToken.balanceOf(address(this)), amount, 18); + } + + function test_discountExerciseHappyPath(uint256 amount, address recipient) public { + amount = bound(amount, 100, MAX_SUPPLY); + vm.assume(recipient != address(0)); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + (uint256 paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + // verify options tokens were transferred + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were transferred + assertEqDecimal(paymentToken.balanceOf(address(this)), 0, 18, "user still has payment tokens"); + uint256 paymentFee1 = expectedPaymentAmount.mulDivDown(feeBPS_[0], 10000); + uint256 paymentFee2 = expectedPaymentAmount - paymentFee1; + assertEqDecimal(paymentToken.balanceOf(feeRecipients_[0]), paymentFee1, 18, "fee recipient 1 didn't receive payment tokens"); + assertEqDecimal(paymentToken.balanceOf(feeRecipients_[1]), paymentFee2, 18, "fee recipient 2 didn't receive payment tokens"); + assertEqDecimal(expectedPaymentAmount, paymentAmount, 18, "exercise returned wrong value"); + } + + function test_instantExitExerciseHappyPath(uint256 amount, address recipient) public { + amount = bound(amount, 1e16, 1e22); + vm.assume(recipient != address(0)); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + uint256 discountedUnderlying = amount.mulDivUp(PRICE_MULTIPLIER, 10_000); + uint256 expectedUnderlyingAmount = discountedUnderlying - amount.mulDivUp(INSTANT_EXIT_FEE, 10_000); + deal(address(paymentToken), address(this), expectedPaymentAmount); + console.log("discountedUnderlying:", discountedUnderlying); + console.log("expectedUnderlyingAmount:", expectedUnderlyingAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: true}); + (uint256 paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + // verify options tokens were transferred + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were transferred + assertEq(paymentToken.balanceOf(address(this)), expectedPaymentAmount, "user lost payment tokens during instant exit"); + uint256 calcPaymentAmount = exerciser.getPaymentAmount(amount); + uint256 totalFee = calcPaymentAmount.mulDivUp(INSTANT_EXIT_FEE, 10_000); + uint256 fee1 = totalFee.mulDivDown(feeBPS_[0], 10_000); + uint256 fee2 = totalFee - fee1; + console.log("paymentFee1: ", fee1); + console.log("paymentFee2: ", fee2); + assertApproxEqRel(IERC20(paymentToken).balanceOf(feeRecipients_[0]), fee1, 10e16, "fee recipient 1 didn't receive payment tokens"); + assertApproxEqRel(IERC20(paymentToken).balanceOf(feeRecipients_[1]), fee2, 10e16, "fee recipient 2 didn't receive payment tokens"); + assertEqDecimal(paymentAmount, 0, 18, "exercise returned wrong value"); + assertApproxEqAbs(IERC20(underlyingToken).balanceOf(recipient), expectedUnderlyingAmount, 1, "Recipient got wrong amount of underlying token"); + } + + function test_exerciseMinPrice(uint256 amount, address recipient) public { + amount = bound(amount, 1, MAX_SUPPLY); + vm.assume(recipient != address(0)); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // set TWAP value such that the strike price is below the oracle's minPrice value + balancerTwapOracle.setTwapValue(ORACLE_MIN_PRICE - 1); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_MIN_PRICE); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + vm.expectRevert(bytes4(keccak256("ThenaOracle__BelowMinPrice()"))); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + } + + function test_priceMultiplier(uint256 amount, uint256 multiplier) public { + amount = bound(amount, 1, MAX_SUPPLY / 2); + + vm.prank(owner); + exerciser.setMultiplier(10000); // full price + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount * 2); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + (uint256 paidAmount,,,) = optionsToken.exercise(amount, address(this), address(exerciser), abi.encode(params)); + + // update multiplier + multiplier = bound(multiplier, 1000, 20000); + vm.prank(owner); + exerciser.setMultiplier(multiplier); + + // exercise options tokens + uint256 newPrice = oracle.getPrice().mulDivUp(multiplier, 10000); + uint256 newExpectedPaymentAmount = amount.mulWadUp(newPrice); + params.maxPaymentAmount = newExpectedPaymentAmount; + + deal(address(paymentToken), address(this), newExpectedPaymentAmount); + (uint256 newPaidAmount,,,) = optionsToken.exercise(amount, address(this), address(exerciser), abi.encode(params)); + // verify payment tokens were transferred + assertEqDecimal(paymentToken.balanceOf(address(this)), 0, 18, "user still has payment tokens"); + assertEq(newPaidAmount, paidAmount.mulDivUp(multiplier, 10000), "incorrect discount"); + } + + function test_exerciseHighSlippage(uint256 amount, address recipient) public { + amount = bound(amount, 1, MAX_SUPPLY); + vm.assume(recipient != address(0)); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens which should fail + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount - 1, deadline: type(uint256).max, isInstantExit: false}); + vm.expectRevert(DiscountExercise.Exercise__SlippageTooHigh.selector); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + } + + // function test_exerciseTwapOracleNotReady(uint256 amount, address recipient) public { + // amount = bound(amount, 1, MAX_SUPPLY); + + // // mint options tokens + // vm.prank(tokenAdmin); + // optionsToken.mint(address(this), amount); + + // // mint payment tokens + // uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + // deal(address(paymentToken), address(this), expectedPaymentAmount); + + // // update oracle params + // // such that the TWAP window becomes (block.timestamp - ORACLE_LARGEST_SAFETY_WINDOW - ORACLE_SECS, block.timestamp - ORACLE_LARGEST_SAFETY_WINDOW] + // // which is outside of the largest safety window + // // vm.prank(owner); + // // oracle.setParams(ORACLE_SECS, ORACLE_LARGEST_SAFETY_WINDOW, ORACLE_MIN_PRICE); + + // // exercise options tokens which should fail + // DiscountExerciseParams memory params = + // DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + // vm.expectRevert(ThenaOracle.ThenaOracle__TWAPOracleNotReady.selector); + // optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + // } + + function test_exercisePastDeadline(uint256 amount, address recipient, uint256 deadline) public { + amount = bound(amount, 0, MAX_SUPPLY); + deadline = bound(deadline, 0, block.timestamp - 1); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: deadline, isInstantExit: false}); + if (amount != 0) { + vm.expectRevert(DiscountExercise.Exercise__PastDeadline.selector); + } + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + } + + function test_exerciseNotOToken(uint256 amount, address recipient) public { + amount = bound(amount, 0, MAX_SUPPLY); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens which should fail + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + vm.expectRevert(BaseExercise.Exercise__NotOToken.selector); + exerciser.exercise(address(this), amount, recipient, abi.encode(params)); + } + + function test_exerciseNotExerciseContract(uint256 amount, address recipient) public { + amount = bound(amount, 1, MAX_SUPPLY); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // set option inactive + vm.prank(owner); + optionsToken.setExerciseContract(address(exerciser), false); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens which should fail + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + vm.expectRevert(OptionsToken.OptionsToken__NotExerciseContract.selector); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + } +} diff --git a/test/ItModeOptionsToken.t.sol b/test/ItModeOptionsToken.t.sol new file mode 100644 index 0000000..fea2358 --- /dev/null +++ b/test/ItModeOptionsToken.t.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {IERC20} from "oz/token/ERC20/IERC20.sol"; +import {ERC1967Proxy} from "oz/proxy/ERC1967/ERC1967Proxy.sol"; + +import {OptionsToken} from "../src/OptionsToken.sol"; +import {DiscountExerciseParams, DiscountExercise, BaseExercise} from "../src/exercise/DiscountExercise.sol"; +// import {SwapProps, ExchangeType} from "../src/helpers/SwapHelper.sol"; +import {TestERC20} from "./mocks/TestERC20.sol"; +import {ThenaOracle} from "../src/oracles/ThenaOracle.sol"; +import {MockBalancerTwapOracle} from "./mocks/MockBalancerTwapOracle.sol"; + +import {ReaperSwapper, MinAmountOutData, MinAmountOutKind, IVeloRouter, ISwapRouter, UniV3SwapData} from "vault-v2/ReaperSwapper.sol"; + +import "./Common.sol"; + +contract ModeOptionsTokenTest is Test, Common { + using FixedPointMathLib for uint256; + + uint256 constant FORK_BLOCK = 9260950; + string MAINNET_URL = vm.envString("MODE_RPC_URL"); + uint256 constant ORACLE_INIT_TWAP_VALUE = 1e19; + uint128 constant ORACLE_MIN_PRICE_DENOM = 10000; + + address[] feeRecipients_; + uint256[] feeBPS_; + + ThenaOracle oracle; + MockBalancerTwapOracle balancerTwapOracle; + + function setUp() public { + /* Common assignments */ + ExchangeType exchangeType = ExchangeType.VeloSolid; + // nativeToken = IERC20(OP_WETH); + paymentToken = IERC20(MODE_MODE); + underlyingToken = IERC20(MODE_WETH); + // wantToken = IERC20(OP_OP); + // paymentUnderlyingBpt = OP_OATHV2_ETH_BPT; + // paymentWantBpt = OP_WETH_OP_USDC_BPT; + // balancerVault = OP_BEETX_VAULT; + // swapRouter = ISwapRouter(OP_BEETX_VAULT); + // univ3Factory = IUniswapV3Factory(OP_UNIV3_FACTORY); + veloRouter = IVeloRouter(MODE_VELO_ROUTER); + veloFactory = MODE_VELO_FACTORY; + + /* Setup network */ + uint256 fork = vm.createFork(MAINNET_URL, FORK_BLOCK); + vm.selectFork(fork); + + // set up accounts + owner = makeAddr("owner"); + tokenAdmin = makeAddr("tokenAdmin"); + + uint256 minAmountToTriggerSwap = 1e5; + + feeRecipients_ = new address[](2); + feeRecipients_[0] = makeAddr("feeRecipient"); + feeRecipients_[1] = makeAddr("feeRecipient2"); + + feeBPS_ = new uint256[](2); + feeBPS_[0] = 1000; // 10% + feeBPS_[1] = 9000; // 90% + + address implementation = address(new OptionsToken()); + ERC1967Proxy proxy = new ERC1967Proxy(implementation, ""); + optionsToken = OptionsToken(address(proxy)); + optionsToken.initialize("TIT Call Option Token", "oTIT", tokenAdmin); + optionsToken.transferOwnership(owner); + + /* Reaper deployment and configuration */ + address[] memory strategists = new address[](1); + strategists[0] = makeAddr("strategist"); + reaperSwapper = new ReaperSwapper(); + ERC1967Proxy tmpProxy = new ERC1967Proxy(address(reaperSwapper), ""); + reaperSwapper = ReaperSwapper(address(tmpProxy)); + reaperSwapper.initialize(strategists, address(this), address(this)); + + fixture_updateSwapperPaths(exchangeType); + + SwapProps memory swapProps = fixture_getSwapProps(exchangeType, 200); + + address[] memory tokens = new address[](2); + tokens[0] = address(paymentToken); + tokens[1] = address(underlyingToken); + + balancerTwapOracle = new MockBalancerTwapOracle(tokens); + console.log(tokens[0], tokens[1]); + oracle = new ThenaOracle(IThenaPair(MODE_VELO_WETH_MODE_PAIR), address(underlyingToken), owner, ORACLE_SECS, ORACLE_MIN_PRICE_DENOM); + exerciser = new DiscountExercise( + optionsToken, + owner, + IERC20(address(paymentToken)), + underlyingToken, + oracle, + PRICE_MULTIPLIER, + INSTANT_EXIT_FEE, + minAmountToTriggerSwap, + feeRecipients_, + feeBPS_, + swapProps + ); + deal(address(underlyingToken), address(exerciser), 1e20 ether); + + /* add exerciser to the list of options */ + vm.startPrank(owner); + optionsToken.setExerciseContract(address(exerciser), true); + vm.stopPrank(); + + // set up contracts + balancerTwapOracle.setTwapValue(ORACLE_INIT_TWAP_VALUE); + paymentToken.approve(address(exerciser), type(uint256).max); + } + + function test_modeRedeemPositiveScenario(uint256 amount) public { + amount = bound(amount, 100, MAX_SUPPLY); + address recipient = makeAddr("recipient"); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + (uint256 paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + // verify options tokens were transferred + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were transferred + assertEqDecimal(paymentToken.balanceOf(address(this)), 0, 18, "user still has payment tokens"); + uint256 paymentFee1 = expectedPaymentAmount.mulDivDown(feeBPS_[0], 10000); + uint256 paymentFee2 = expectedPaymentAmount - paymentFee1; + assertEqDecimal(paymentToken.balanceOf(feeRecipients_[0]), paymentFee1, 18, "fee recipient 1 didn't receive payment tokens"); + assertEqDecimal(paymentToken.balanceOf(feeRecipients_[1]), paymentFee2, 18, "fee recipient 2 didn't receive payment tokens"); + assertEqDecimal(expectedPaymentAmount, paymentAmount, 18, "exercise returned wrong value"); + } + + function test_modeZapPositiveScenario(uint256 amount) public { + amount = bound(amount, 1e16, 1e18 - 1); + address recipient = makeAddr("recipient"); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + uint256 discountedUnderlying = amount.mulDivUp(PRICE_MULTIPLIER, 10_000); + uint256 expectedUnderlyingAmount = discountedUnderlying - discountedUnderlying.mulDivUp(INSTANT_EXIT_FEE, 10_000); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // Calculate total fee from zapping + uint256 calcPaymentAmount = exerciser.getPaymentAmount(amount); + uint256 totalFee = calcPaymentAmount.mulDivUp(INSTANT_EXIT_FEE, 10_000); + + vm.prank(owner); + exerciser.setMinAmountToTriggerSwap(discountedUnderlying.mulDivUp(INSTANT_EXIT_FEE, BPS_DENOM) + 1); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: true}); + (uint256 paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + console.log("Exercise 1"); + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were not transferred + assertEq(paymentToken.balanceOf(address(this)), expectedPaymentAmount, "user lost payment tokens during instant exit"); + // verify whether distributions not happened + assertEq(IERC20(paymentToken).balanceOf(feeRecipients_[0]), 0, "fee recipient 1 received payment tokens but shouldn't"); + assertEq(IERC20(paymentToken).balanceOf(feeRecipients_[1]), 0, "fee recipient 2 received payment tokens but shouldn't"); + assertEqDecimal(paymentAmount, 0, 18, "exercise returned wrong value"); + uint256 balanceAfterFirstExercise = underlyingToken.balanceOf(recipient); + assertApproxEqAbs(balanceAfterFirstExercise, expectedUnderlyingAmount, 1, "recipient got wrong amount of underlying token"); + + /*---------- Second call -----------*/ + amount = bound(amount, 1e18, 2e18); + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + expectedPaymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + discountedUnderlying = amount.mulDivUp(PRICE_MULTIPLIER, 10_000); + expectedUnderlyingAmount = discountedUnderlying - discountedUnderlying.mulDivUp(INSTANT_EXIT_FEE, 10_000); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + (paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + // verify options tokens were transferred + console.log("Exercise 2"); + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were not transferred + assertEq(paymentToken.balanceOf(address(this)), expectedPaymentAmount, "user lost payment tokens during instant exit"); + + // verify fee is distributed + calcPaymentAmount = exerciser.getPaymentAmount(amount); + totalFee += calcPaymentAmount.mulDivUp(INSTANT_EXIT_FEE, 10_000); + uint256 fee1 = totalFee.mulDivDown(feeBPS_[0], 10_000); + uint256 fee2 = totalFee - fee1; + console.log("paymentFee1: ", fee1); + console.log("paymentFee2: ", fee2); + assertApproxEqRel(IERC20(paymentToken).balanceOf(feeRecipients_[0]), fee1, 5e16, "fee recipient 1 didn't receive payment tokens"); + assertApproxEqRel(IERC20(paymentToken).balanceOf(feeRecipients_[1]), fee2, 5e16, "fee recipient 2 didn't receive payment tokens"); + assertEqDecimal(paymentAmount, 0, 18, "exercise returned wrong value"); + assertApproxEqAbs( + underlyingToken.balanceOf(recipient), + expectedUnderlyingAmount + balanceAfterFirstExercise, + 1, + "Recipient got wrong amount of underlying token" + ); + } + + function test_modeExerciseNotOToken(uint256 amount) public { + amount = bound(amount, 0, MAX_SUPPLY); + address recipient = makeAddr("recipient"); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens which should fail + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + vm.expectRevert(BaseExercise.Exercise__NotOToken.selector); + exerciser.exercise(address(this), amount, recipient, abi.encode(params)); + } + + function test_modeExerciseNotExerciseContract(uint256 amount) public { + amount = bound(amount, 1, MAX_SUPPLY); + address recipient = makeAddr("recipient"); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // set option inactive + vm.prank(owner); + optionsToken.setExerciseContract(address(exerciser), false); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens which should fail + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + vm.expectRevert(OptionsToken.OptionsToken__NotExerciseContract.selector); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + } +} diff --git a/test/ItOpOptionsToken.t.solNOT b/test/ItOpOptionsToken.t.solNOT new file mode 100644 index 0000000..9f33caa --- /dev/null +++ b/test/ItOpOptionsToken.t.solNOT @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {IERC20} from "oz/token/ERC20/IERC20.sol"; +import {ERC1967Proxy} from "oz/proxy/ERC1967/ERC1967Proxy.sol"; + +import {OptionsToken} from "../src/OptionsToken.sol"; +import {DiscountExerciseParams, DiscountExercise, BaseExercise} from "../src/exercise/DiscountExercise.sol"; +// import {SwapProps, ExchangeType} from "../src/helpers/SwapHelper.sol"; +import {TestERC20} from "./mocks/TestERC20.sol"; +import {ThenaOracle} from "../src/oracles/ThenaOracle.sol"; +import {MockBalancerTwapOracle} from "./mocks/MockBalancerTwapOracle.sol"; + +import {ReaperSwapper, MinAmountOutData, MinAmountOutKind, IVeloRouter, ISwapRouter, UniV3SwapData} from "vault-v2/ReaperSwapper.sol"; + +import "./Common.sol"; + +contract OpOptionsTokenTest is Test, Common { + using FixedPointMathLib for uint256; + + uint256 constant FORK_BLOCK = 121377470; + string MAINNET_URL = vm.envString("OPTIMISM_RPC_URL"); + + uint16 constant PRICE_MULTIPLIER = 5000; // 0.5 + uint56 constant ORACLE_SECS = 30 minutes; + uint56 constant ORACLE_AGO = 2 minutes; + uint128 constant ORACLE_MIN_PRICE = 1e17; + uint56 constant ORACLE_LARGEST_SAFETY_WINDOW = 24 hours; + uint256 constant ORACLE_INIT_TWAP_VALUE = 1e19; + uint128 constant ORACLE_MIN_PRICE_DENOM = 10000; + + uint256 constant MAX_SUPPLY = 1e27; // the max supply of the options token & the underlying token + uint256 constant INSTANT_EXIT_FEE = 500; + + address[] feeRecipients_; + uint256[] feeBPS_; + + ThenaOracle oracle; + MockBalancerTwapOracle balancerTwapOracle; + + // function fixture_getSwapProps(ExchangeType exchangeType, uint256 slippage) public view returns (SwapProps memory) { + // SwapProps memory swapProps; + + // if (exchangeType == ExchangeType.ThenaRam) { + // swapProps = SwapProps(address(reaperSwapper), BSC_THENA_ROUTER, ExchangeType.ThenaRam, slippage); + // } else if (exchangeType == ExchangeType.UniV3) { + // swapProps = SwapProps(address(reaperSwapper), BSC_UNIV3_ROUTERV2, ExchangeType.UniV3, slippage); + // } else { + // // revert + // } + // return swapProps; + // } + + // function fixture_updateSwapperPaths(ExchangeType exchangeType) public { + // address[2] memory paths = [address(underlyingToken), address(paymentToken)]; + + // if (exchangeType == ExchangeType.ThenaRam) { + // /* Configure thena ram like dexes */ + // IThenaRamRouter.route[] memory thenaPath = new IThenaRamRouter.route[](1); + // thenaPath[0] = IThenaRamRouter.route(paths[0], paths[1], false); + // reaperSwapper.updateThenaRamSwapPath(paths[0], paths[1], address(BSC_THENA_ROUTER), thenaPath); + // thenaPath[0] = IThenaRamRouter.route(paths[1], paths[0], false); + // reaperSwapper.updateThenaRamSwapPath(paths[1], paths[0], address(BSC_THENA_ROUTER), thenaPath); + // } else if (exchangeType == ExchangeType.UniV3) { + // /* Configure univ3 like dexes */ + // uint24[] memory univ3Fees = new uint24[](1); + // univ3Fees[0] = 500; + // address[] memory univ3Path = new address[](2); + + // univ3Path[0] = paths[0]; + // univ3Path[1] = paths[1]; + // UniV3SwapData memory swapPathAndFees = UniV3SwapData(univ3Path, univ3Fees); + // reaperSwapper.updateUniV3SwapPath(paths[0], paths[1], address(BSC_UNIV3_ROUTERV2), swapPathAndFees); + // } else { + // // revert + // } + // } + + function setUp() public { + /* Common assignments */ + ExchangeType exchangeType = ExchangeType.VeloSolid; + nativeToken = IERC20(OP_WETH); + paymentToken = nativeToken; + underlyingToken = IERC20(OP_OATHV2); + wantToken = IERC20(OP_OP); + paymentUnderlyingBpt = OP_OATHV2_ETH_BPT; + paymentWantBpt = OP_WETH_OP_USDC_BPT; + balancerVault = OP_BEETX_VAULT; + swapRouter = ISwapRouter(OP_BEETX_VAULT); + univ3Factory = IUniswapV3Factory(OP_UNIV3_FACTORY); + veloRouter = IVeloRouter(OP_VELO_ROUTER); + veloFactory = OP_VELO_FACTORY; + + /* Setup network */ + uint256 fork = vm.createFork(MAINNET_URL, FORK_BLOCK); + vm.selectFork(fork); + + // set up accounts + owner = makeAddr("owner"); + tokenAdmin = makeAddr("tokenAdmin"); + + feeRecipients_ = new address[](2); + feeRecipients_[0] = makeAddr("feeRecipient"); + feeRecipients_[1] = makeAddr("feeRecipient2"); + + feeBPS_ = new uint256[](2); + feeBPS_[0] = 1000; // 10% + feeBPS_[1] = 9000; // 90% + + address implementation = address(new OptionsToken()); + ERC1967Proxy proxy = new ERC1967Proxy(implementation, ""); + optionsToken = OptionsToken(address(proxy)); + optionsToken.initialize("TIT Call Option Token", "oTIT", tokenAdmin); + optionsToken.transferOwnership(owner); + + /* Reaper deployment and configuration */ + address[] memory strategists = new address[](1); + strategists[0] = makeAddr("strategist"); + reaperSwapper = new ReaperSwapper(); + ERC1967Proxy tmpProxy = new ERC1967Proxy(address(reaperSwapper), ""); + reaperSwapper = ReaperSwapper(address(tmpProxy)); + reaperSwapper.initialize(strategists, address(this), address(this)); + + fixture_updateSwapperPaths(ExchangeType.VeloSolid); + + SwapProps memory swapProps = fixture_getSwapProps(ExchangeType.VeloSolid, 200); + + address[] memory tokens = new address[](2); + tokens[0] = address(paymentToken); + tokens[1] = address(underlyingToken); + + balancerTwapOracle = new MockBalancerTwapOracle(tokens); + console.log(tokens[0], tokens[1]); + oracle = new ThenaOracle(IThenaPair(OP_VELO_OATHV2_ETH_PAIR), address(underlyingToken), owner, ORACLE_SECS, ORACLE_MIN_PRICE_DENOM); + exerciser = new DiscountExercise( + optionsToken, + owner, + IERC20(address(paymentToken)), + underlyingToken, + oracle, + PRICE_MULTIPLIER, + INSTANT_EXIT_FEE, + feeRecipients_, + feeBPS_, + swapProps + ); + deal(address(underlyingToken), address(exerciser), 1e20 ether); + + // add exerciser to the list of options + vm.startPrank(owner); + optionsToken.setExerciseContract(address(exerciser), true); + vm.stopPrank(); + + // set up contracts + balancerTwapOracle.setTwapValue(ORACLE_INIT_TWAP_VALUE); + paymentToken.approve(address(exerciser), type(uint256).max); + } + + function test_opRedeemPositiveScenario(uint256 amount, address recipient) public { + amount = bound(amount, 100, MAX_SUPPLY); + vm.assume(recipient != address(0)); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + (uint256 paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + // verify options tokens were transferred + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were transferred + assertEqDecimal(paymentToken.balanceOf(address(this)), 0, 18, "user still has payment tokens"); + uint256 paymentFee1 = expectedPaymentAmount.mulDivDown(feeBPS_[0], 10000); + uint256 paymentFee2 = expectedPaymentAmount - paymentFee1; + assertEqDecimal(paymentToken.balanceOf(feeRecipients_[0]), paymentFee1, 18, "fee recipient 1 didn't receive payment tokens"); + assertEqDecimal(paymentToken.balanceOf(feeRecipients_[1]), paymentFee2, 18, "fee recipient 2 didn't receive payment tokens"); + assertEqDecimal(expectedPaymentAmount, paymentAmount, 18, "exercise returned wrong value"); + } + + function test_opZapPositiveScenario(uint256 amount) public { + amount = bound(amount, 1e10, 1e18 - 1); + address recipient = makeAddr("recipient"); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + console.log("Fee recipient1 balance: ", IERC20(paymentToken).balanceOf(feeRecipients_[0])); + console.log("Fee recipient2 balance: ", IERC20(paymentToken).balanceOf(feeRecipients_[1])); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + console.log("[OUT] Amount: %e\tmultiplier: %e", amount, PRICE_MULTIPLIER); + uint256 discountedUnderlying = amount.mulDivUp(PRICE_MULTIPLIER, 10_000); + uint256 expectedUnderlyingAmount = discountedUnderlying - discountedUnderlying.mulDivUp(INSTANT_EXIT_FEE, 10_000); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + console.log("discountedUnderlying:", discountedUnderlying); + console.log("expectedUnderlyingAmount:", expectedUnderlyingAmount); + + // Calculate total fee from zapping + uint256 calcPaymentAmount = exerciser.getPaymentAmount(amount); + uint256 totalFee = calcPaymentAmount.mulDivUp(INSTANT_EXIT_FEE, 10_000); + + vm.prank(owner); + exerciser.setMinAmountToTriggerSwap(discountedUnderlying.mulDivUp(INSTANT_EXIT_FEE, BPS_DENOM) + 1); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: true}); + (uint256 paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + console.log("Exercise 1"); + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were not transferred + assertEq(paymentToken.balanceOf(address(this)), expectedPaymentAmount, "user lost payment tokens during instant exit"); + //verify whether distributions not happened + assertEq(IERC20(paymentToken).balanceOf(feeRecipients_[0]), 0, "fee recipient 1 received payment tokens but shouldn't"); + assertEq(IERC20(paymentToken).balanceOf(feeRecipients_[1]), 0, "fee recipient 2 received payment tokens but shouldn't"); + assertEqDecimal(paymentAmount, 0, 18, "exercise returned wrong value"); + uint256 balanceAfterFirstExercise = underlyingToken.balanceOf(recipient); + assertApproxEqAbs(balanceAfterFirstExercise, expectedUnderlyingAmount, 1, "Recipient got wrong amount of underlying token"); + + /*---------- Second call -----------*/ + amount = bound(amount, 1e18, 1e24); + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + expectedPaymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + discountedUnderlying = amount.mulDivUp(PRICE_MULTIPLIER, 10_000); + expectedUnderlyingAmount = discountedUnderlying - discountedUnderlying.mulDivUp(INSTANT_EXIT_FEE, 10_000); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + (paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + // verify options tokens were transferred + console.log("Exercise 2"); + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were not transferred + assertEq(paymentToken.balanceOf(address(this)), expectedPaymentAmount, "user lost payment tokens during instant exit"); + + // verify fee is distributed + calcPaymentAmount = exerciser.getPaymentAmount(amount); + totalFee += calcPaymentAmount.mulDivUp(INSTANT_EXIT_FEE, 10_000); + uint256 fee1 = totalFee.mulDivDown(feeBPS_[0], 10_000); + uint256 fee2 = totalFee - fee1; + console.log("paymentFee1: ", fee1); + console.log("paymentFee2: ", fee2); + assertApproxEqRel(IERC20(paymentToken).balanceOf(feeRecipients_[0]), fee1, 5e16, "fee recipient 1 didn't receive payment tokens"); + assertApproxEqRel(IERC20(paymentToken).balanceOf(feeRecipients_[1]), fee2, 5e16, "fee recipient 2 didn't receive payment tokens"); + assertEqDecimal(paymentAmount, 0, 18, "exercise returned wrong value"); + assertApproxEqAbs( + underlyingToken.balanceOf(recipient), + expectedUnderlyingAmount + balanceAfterFirstExercise, + 1, + "Recipient got wrong amount of underlying token" + ); + } + + function test_exerciseNotOToken(uint256 amount, address recipient) public { + amount = bound(amount, 0, MAX_SUPPLY); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens which should fail + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + vm.expectRevert(BaseExercise.Exercise__NotOToken.selector); + exerciser.exercise(address(this), amount, recipient, abi.encode(params)); + } + + function test_exerciseNotExerciseContract(uint256 amount, address recipient) public { + amount = bound(amount, 1, MAX_SUPPLY); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // set option inactive + vm.prank(owner); + optionsToken.setExerciseContract(address(exerciser), false); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens which should fail + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + vm.expectRevert(OptionsToken.OptionsToken__NotExerciseContract.selector); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + } +} diff --git a/test/OptionsToken.t.sol b/test/OptionsToken.t.sol index 89207a5..d09b8b7 100644 --- a/test/OptionsToken.t.sol +++ b/test/OptionsToken.t.sol @@ -8,11 +8,14 @@ import {IERC20} from "oz/token/ERC20/IERC20.sol"; import {ERC1967Proxy} from "oz/proxy/ERC1967/ERC1967Proxy.sol"; import {OptionsToken} from "../src/OptionsToken.sol"; -import {DiscountExerciseParams, DiscountExercise, BaseExercise} from "../src/exercise/DiscountExercise.sol"; +import {DiscountExerciseParams, DiscountExercise, BaseExercise, SwapProps, ExchangeType} from "../src/exercise/DiscountExercise.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; +import {IOracle} from "../src/interfaces/IOracle.sol"; import {BalancerOracle} from "../src/oracles/BalancerOracle.sol"; import {MockBalancerTwapOracle} from "./mocks/MockBalancerTwapOracle.sol"; +import {ReaperSwapperMock} from "./mocks/ReaperSwapperMock.sol"; + contract OptionsTokenTest is Test { using FixedPointMathLib for uint256; @@ -24,6 +27,8 @@ contract OptionsTokenTest is Test { uint256 constant ORACLE_INIT_TWAP_VALUE = 1e19; uint256 constant ORACLE_MIN_PRICE_DENOM = 10000; uint256 constant MAX_SUPPLY = 1e27; // the max supply of the options token & the underlying token + uint256 constant INSTANT_EXIT_FEE = 500; + uint256 constant BPS_DENOM = 10_000; address owner; address tokenAdmin; @@ -32,10 +37,11 @@ contract OptionsTokenTest is Test { OptionsToken optionsToken; DiscountExercise exerciser; - BalancerOracle oracle; + IOracle oracle; MockBalancerTwapOracle balancerTwapOracle; TestERC20 paymentToken; address underlyingToken; + ReaperSwapperMock reaperSwapper; function setUp() public { // set up accounts @@ -60,18 +66,38 @@ contract OptionsTokenTest is Test { optionsToken.initialize("TIT Call Option Token", "oTIT", tokenAdmin); optionsToken.transferOwnership(owner); + /* Reaper deployment and configuration */ + uint256 slippage = 500; // 5% + uint256 minAmountToTriggerSwap = 1e5; + address[] memory tokens = new address[](2); tokens[0] = address(paymentToken); tokens[1] = underlyingToken; balancerTwapOracle = new MockBalancerTwapOracle(tokens); console.log(tokens[0], tokens[1]); - oracle = new BalancerOracle(balancerTwapOracle, underlyingToken, owner, ORACLE_SECS, ORACLE_AGO, ORACLE_MIN_PRICE); - - exerciser = - new DiscountExercise(optionsToken, owner, IERC20(address(paymentToken)), IERC20(underlyingToken), oracle, PRICE_MULTIPLIER, feeRecipients_, feeBPS_); - - TestERC20(underlyingToken).mint(address(exerciser), 1e20 ether); + oracle = IOracle(new BalancerOracle(balancerTwapOracle, underlyingToken, owner, ORACLE_SECS, ORACLE_AGO, ORACLE_MIN_PRICE)); + + reaperSwapper = new ReaperSwapperMock(oracle, address(underlyingToken), address(paymentToken)); + deal(underlyingToken, address(reaperSwapper), 1e27); + deal(address(paymentToken), address(reaperSwapper), 1e27); + + SwapProps memory swapProps = SwapProps(address(reaperSwapper), address(reaperSwapper), ExchangeType.Bal, slippage); + + exerciser = new DiscountExercise( + optionsToken, + owner, + IERC20(address(paymentToken)), + IERC20(underlyingToken), + oracle, + PRICE_MULTIPLIER, + INSTANT_EXIT_FEE, + minAmountToTriggerSwap, + feeRecipients_, + feeBPS_, + swapProps + ); + deal(underlyingToken, address(exerciser), 1e27); // add exerciser to the list of options vm.startPrank(owner); @@ -100,19 +126,21 @@ contract OptionsTokenTest is Test { assertEqDecimal(optionsToken.balanceOf(address(this)), amount, 18); } - function test_exerciseHappyPath(uint256 amount, address recipient) public { + function test_redeemPositiveScenario(uint256 amount) public { amount = bound(amount, 100, MAX_SUPPLY); + address recipient = makeAddr("recipient"); // mint options tokens vm.prank(tokenAdmin); optionsToken.mint(address(this), amount); // mint payment tokens - uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); - paymentToken.mint(address(this), expectedPaymentAmount); + uint256 expectedPaymentAmount = amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); // exercise options tokens - DiscountExerciseParams memory params = DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max}); + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); (uint256 paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); // verify options tokens were transferred @@ -125,11 +153,51 @@ contract OptionsTokenTest is Test { uint256 paymentFee2 = expectedPaymentAmount - paymentFee1; assertEqDecimal(paymentToken.balanceOf(feeRecipients_[0]), paymentFee1, 18, "fee recipient 1 didn't receive payment tokens"); assertEqDecimal(paymentToken.balanceOf(feeRecipients_[1]), paymentFee2, 18, "fee recipient 2 didn't receive payment tokens"); - assertEqDecimal(paymentAmount, expectedPaymentAmount, 18, "exercise returned wrong value"); + assertEqDecimal(expectedPaymentAmount, paymentAmount, 18, "exercise returned wrong value"); } - function test_exerciseMinPrice(uint256 amount, address recipient) public { + function test_zapPositiveScenario(uint256 amount) public { + amount = bound(amount, 1e16, 1e22); + address recipient = makeAddr("recipient"); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + uint256 discountedUnderlying = amount.mulDivUp(PRICE_MULTIPLIER, 10_000); + uint256 expectedUnderlyingAmount = discountedUnderlying - discountedUnderlying.mulDivUp(INSTANT_EXIT_FEE, 10_000); + deal(address(paymentToken), address(this), expectedPaymentAmount); + console.log("discountedUnderlying:", discountedUnderlying); + console.log("expectedUnderlyingAmount:", expectedUnderlyingAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: true}); + (uint256 paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + // verify options tokens were transferred + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were transferred + assertEq(paymentToken.balanceOf(address(this)), expectedPaymentAmount, "user lost payment tokens during instant exit"); + uint256 calcPaymentAmount = exerciser.getPaymentAmount(amount); + uint256 totalFee = calcPaymentAmount.mulDivUp(INSTANT_EXIT_FEE, 10_000); + uint256 fee1 = totalFee.mulDivDown(feeBPS_[0], 10_000); + uint256 fee2 = totalFee - fee1; + console.log("paymentFee1: ", fee1); + console.log("paymentFee2: ", fee2); + assertApproxEqRel(paymentToken.balanceOf(feeRecipients_[0]), fee1, 10e16, "fee recipient 1 didn't receive payment tokens"); + assertApproxEqRel(paymentToken.balanceOf(feeRecipients_[1]), fee2, 10e16, "fee recipient 2 didn't receive payment tokens"); + assertEqDecimal(paymentAmount, 0, 18, "exercise returned wrong value"); + assertApproxEqAbs(IERC20(underlyingToken).balanceOf(recipient), expectedUnderlyingAmount, 1, "Recipient got wrong amount of underlying token"); + } + + function test_exerciseMinPrice(uint256 amount) public { amount = bound(amount, 1, MAX_SUPPLY); + address recipient = makeAddr("recipient"); // mint options tokens vm.prank(tokenAdmin); @@ -140,10 +208,11 @@ contract OptionsTokenTest is Test { // mint payment tokens uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_MIN_PRICE); - paymentToken.mint(address(this), expectedPaymentAmount); + deal(address(paymentToken), address(this), expectedPaymentAmount); // exercise options tokens - DiscountExerciseParams memory params = DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max}); + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); vm.expectRevert(bytes4(keccak256("BalancerOracle__BelowMinPrice()"))); optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); } @@ -160,10 +229,11 @@ contract OptionsTokenTest is Test { // mint payment tokens uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE); - paymentToken.mint(address(this), expectedPaymentAmount); + deal(address(paymentToken), address(this), expectedPaymentAmount); // exercise options tokens - DiscountExerciseParams memory params = DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max}); + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); (uint256 paidAmount,,,) = optionsToken.exercise(amount, address(this), address(exerciser), abi.encode(params)); // update multiplier @@ -176,7 +246,7 @@ contract OptionsTokenTest is Test { uint256 newExpectedPaymentAmount = amount.mulWadUp(newPrice); params.maxPaymentAmount = newExpectedPaymentAmount; - paymentToken.mint(address(this), newExpectedPaymentAmount); + deal(address(paymentToken), address(this), newExpectedPaymentAmount); (uint256 newPaidAmount,,,) = optionsToken.exercise(amount, address(this), address(exerciser), abi.encode(params)); // verify payment tokens were transferred assertEqDecimal(paymentToken.balanceOf(address(this)), 0, 18, "user still has payment tokens"); @@ -185,6 +255,7 @@ contract OptionsTokenTest is Test { function test_exerciseHighSlippage(uint256 amount, address recipient) public { amount = bound(amount, 1, MAX_SUPPLY); + vm.assume(recipient != address(0)); // mint options tokens vm.prank(tokenAdmin); @@ -192,40 +263,43 @@ contract OptionsTokenTest is Test { // mint payment tokens uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); - paymentToken.mint(address(this), expectedPaymentAmount); + deal(address(paymentToken), address(this), expectedPaymentAmount); // exercise options tokens which should fail - DiscountExerciseParams memory params = DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount - 1, deadline: type(uint256).max}); + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount - 1, deadline: type(uint256).max, isInstantExit: false}); vm.expectRevert(DiscountExercise.Exercise__SlippageTooHigh.selector); optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); } - function test_exerciseTwapOracleNotReady(uint256 amount, address recipient) public { - amount = bound(amount, 1, MAX_SUPPLY); + // function test_exerciseTwapOracleNotReady(uint256 amount, address recipient) public { + // amount = bound(amount, 1, MAX_SUPPLY); - // mint options tokens - vm.prank(tokenAdmin); - optionsToken.mint(address(this), amount); + // // mint options tokens + // vm.prank(tokenAdmin); + // optionsToken.mint(address(this), amount); - // mint payment tokens - uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); - paymentToken.mint(address(this), expectedPaymentAmount); + // // mint payment tokens + // uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + // deal(address(paymentToken), address(this), expectedPaymentAmount); - // update oracle params - // such that the TWAP window becomes (block.timestamp - ORACLE_LARGEST_SAFETY_WINDOW - ORACLE_SECS, block.timestamp - ORACLE_LARGEST_SAFETY_WINDOW] - // which is outside of the largest safety window - vm.prank(owner); - oracle.setParams(ORACLE_SECS, ORACLE_LARGEST_SAFETY_WINDOW, ORACLE_MIN_PRICE); + // // update oracle params + // // such that the TWAP window becomes (block.timestamp - ORACLE_LARGEST_SAFETY_WINDOW - ORACLE_SECS, block.timestamp - ORACLE_LARGEST_SAFETY_WINDOW] + // // which is outside of the largest safety window + // // vm.prank(owner); + // // oracle.setParams(ORACLE_SECS, ORACLE_LARGEST_SAFETY_WINDOW, ORACLE_MIN_PRICE); - // exercise options tokens which should fail - DiscountExerciseParams memory params = DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max}); - vm.expectRevert(BalancerOracle.BalancerOracle__TWAPOracleNotReady.selector); - optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); - } + // // exercise options tokens which should fail + // DiscountExerciseParams memory params = + // DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + // vm.expectRevert(ThenaOracle.ThenaOracle__TWAPOracleNotReady.selector); + // optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + // } - function test_exercisePastDeadline(uint256 amount, address recipient, uint256 deadline) public { + function test_exercisePastDeadline(uint256 amount, uint256 deadline) public { amount = bound(amount, 0, MAX_SUPPLY); deadline = bound(deadline, 0, block.timestamp - 1); + address recipient = makeAddr("recipient"); // mint options tokens vm.prank(tokenAdmin); @@ -233,34 +307,38 @@ contract OptionsTokenTest is Test { // mint payment tokens uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); - paymentToken.mint(address(this), expectedPaymentAmount); + deal(address(paymentToken), address(this), expectedPaymentAmount); // exercise options tokens - DiscountExerciseParams memory params = DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: deadline}); + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: deadline, isInstantExit: false}); if (amount != 0) { vm.expectRevert(DiscountExercise.Exercise__PastDeadline.selector); } optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); } - function test_exerciseNotOToken(uint256 amount, address recipient) public { + function test_exerciseNotOToken(uint256 amount) public { amount = bound(amount, 0, MAX_SUPPLY); + address recipient = makeAddr("recipient"); // mint options tokens vm.prank(tokenAdmin); optionsToken.mint(address(this), amount); uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); - paymentToken.mint(address(this), expectedPaymentAmount); + deal(address(paymentToken), address(this), expectedPaymentAmount); // exercise options tokens which should fail - DiscountExerciseParams memory params = DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max}); + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); vm.expectRevert(BaseExercise.Exercise__NotOToken.selector); exerciser.exercise(address(this), amount, recipient, abi.encode(params)); } - function test_exerciseNotExerciseContract(uint256 amount, address recipient) public { + function test_exerciseNotExerciseContract(uint256 amount) public { amount = bound(amount, 1, MAX_SUPPLY); + address recipient = makeAddr("recipient"); // mint options tokens vm.prank(tokenAdmin); @@ -272,11 +350,222 @@ contract OptionsTokenTest is Test { // mint payment tokens uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); - paymentToken.mint(address(this), expectedPaymentAmount); + deal(address(paymentToken), address(this), expectedPaymentAmount); // exercise options tokens which should fail - DiscountExerciseParams memory params = DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max}); + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); vm.expectRevert(OptionsToken.OptionsToken__NotExerciseContract.selector); optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); } + + function test_exerciseWhenPaused(uint256 amount) public { + amount = bound(amount, 100, 1 ether); + address recipient = makeAddr("recipient"); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), 3 * amount); + + // mint payment tokens + uint256 expectedPaymentAmount = 3 * amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + /* Only owner can pause */ + vm.startPrank(recipient); + vm.expectRevert(bytes("UNAUTHORIZED")); // Ownable: caller is not the owner + exerciser.pause(); + vm.stopPrank(); + + vm.prank(owner); + exerciser.pause(); + vm.expectRevert(bytes("Pausable: paused")); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + vm.prank(owner); + exerciser.unpause(); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + } + + function test_oTokenWhenPaused(uint256 amount) public { + amount = bound(amount, 100, 1 ether); + address recipient = makeAddr("recipient"); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), 3 * amount); + + // mint payment tokens + uint256 expectedPaymentAmount = 3 * amount.mulWadUp(oracle.getPrice().mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + deal(address(paymentToken), address(this), expectedPaymentAmount); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + /* Only owner can pause */ + vm.startPrank(recipient); + vm.expectRevert(bytes("Ownable: caller is not the owner")); // Ownable: caller is not the owner + optionsToken.pause(); + vm.stopPrank(); + + vm.prank(owner); + optionsToken.pause(); + vm.expectRevert(bytes("Pausable: paused")); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + vm.prank(owner); + optionsToken.unpause(); + optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + } + + function test_exerciserConfigAccesses() public { + uint256 slippage = 555; // 5.55% + address[] memory tokens = new address[](2); + tokens[0] = address(paymentToken); + tokens[1] = underlyingToken; + balancerTwapOracle = new MockBalancerTwapOracle(tokens); + oracle = IOracle(new BalancerOracle(balancerTwapOracle, underlyingToken, owner, ORACLE_SECS, ORACLE_AGO, ORACLE_MIN_PRICE)); + + reaperSwapper = new ReaperSwapperMock(oracle, address(underlyingToken), address(paymentToken)); + SwapProps memory swapProps = SwapProps(address(reaperSwapper), address(reaperSwapper), ExchangeType.Bal, slippage); + + vm.expectRevert(bytes("UNAUTHORIZED")); + exerciser.setSwapProps(swapProps); + + vm.prank(owner); + exerciser.setSwapProps(swapProps); + + vm.expectRevert(bytes("UNAUTHORIZED")); + exerciser.setOracle(oracle); + + vm.prank(owner); + exerciser.setOracle(oracle); + + vm.expectRevert(bytes("UNAUTHORIZED")); + exerciser.setMultiplier(3333); + + vm.prank(owner); + exerciser.setMultiplier(3333); + + vm.expectRevert(bytes("UNAUTHORIZED")); + exerciser.setInstantExitFee(1444); + + vm.prank(owner); + exerciser.setInstantExitFee(1444); + + vm.expectRevert(bytes("UNAUTHORIZED")); + exerciser.setMinAmountToTriggerSwap(1e16); + + vm.prank(owner); + exerciser.setMinAmountToTriggerSwap(1e16); + } + + function test_zapWhenExerciseUnderfunded(uint256 amount) public { + amount = bound(amount, 1e16, 1e22); + address recipient = makeAddr("recipient"); + + uint256 remainingAmount = 4e15; + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), amount); + + // mint payment tokens + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE.mulDivUp(PRICE_MULTIPLIER, ORACLE_MIN_PRICE_DENOM)); + uint256 discountedUnderlying = amount.mulDivUp(PRICE_MULTIPLIER, 10_000); + uint256 expectedUnderlyingAmount = discountedUnderlying - discountedUnderlying.mulDivUp(INSTANT_EXIT_FEE, 10_000); + deal(address(paymentToken), address(this), expectedPaymentAmount); + console.log("discountedUnderlying:", discountedUnderlying); + console.log("expectedUnderlyingAmount:", expectedUnderlyingAmount); + uint256 calcPaymentAmount = exerciser.getPaymentAmount(amount); + uint256 totalFee = calcPaymentAmount.mulDivUp(INSTANT_EXIT_FEE, 10_000); + uint256 fee1 = totalFee.mulDivDown(feeBPS_[0], 10_000); + uint256 fee2 = totalFee - fee1; + console.log("expected paymentFee1: ", fee1); + console.log("expected paymentFee2: ", fee2); + + // Simulate sitiation when exerciser has less underlying amount than expected from exercise action + vm.prank(address(exerciser)); + // IERC20(underlyingToken).transfer(address(this), 1e27 - (discountedUnderlying - 1)); + IERC20(underlyingToken).transfer(address(this), 1e27 - remainingAmount); + console.log("Balance of exerciser:", IERC20(underlyingToken).balanceOf(address(exerciser))); + + // exercise options tokens + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: true}); + (uint256 paymentAmount,,,) = optionsToken.exercise(amount, recipient, address(exerciser), abi.encode(params)); + + // verify options tokens were transferred + assertEqDecimal(optionsToken.balanceOf(address(this)), 0, 18, "user still has options tokens"); + assertEqDecimal(optionsToken.totalSupply(), 0, 18, "option tokens not burned"); + + // verify payment tokens were transferred + assertEq(paymentToken.balanceOf(address(this)), expectedPaymentAmount, "user lost payment tokens during instant exit"); + + assertEq(paymentToken.balanceOf(feeRecipients_[0]), 0, "fee recipient 1 didn't receive payment tokens"); + assertEq(paymentToken.balanceOf(feeRecipients_[1]), 0, "fee recipient 2 didn't receive payment tokens"); + assertEqDecimal(paymentAmount, 0, 18, "exercise returned wrong value"); + assertEq(IERC20(underlyingToken).balanceOf(recipient), remainingAmount, "Recipient got wrong amount of underlying token"); + } + + function test_modeZapRedeemWithDifferentMultipliers(uint256 multiplier) public { + multiplier = bound(multiplier, BPS_DENOM / 10, BPS_DENOM - 1); + // multiplier = 8000; + uint256 amount = 1000e18; + + address recipient = makeAddr("recipient"); + + // mint options tokens + vm.prank(tokenAdmin); + optionsToken.mint(address(this), 2 * amount); + + vm.prank(owner); + exerciser.setMultiplier(multiplier); + uint256 expectedPaymentAmount = amount.mulWadUp(ORACLE_INIT_TWAP_VALUE) * 4; + deal(address(paymentToken), address(this), expectedPaymentAmount); + + uint256 underlyingBalance = + IERC20(underlyingToken).balanceOf(address(this)) + paymentToken.balanceOf(address(this)).divWadUp(oracle.getPrice()); + console.log("Price: ", oracle.getPrice()); + console.log("Balance before: ", underlyingBalance); + console.log("Underlying amount before: ", IERC20(underlyingToken).balanceOf(address(this))); + + // exercise options tokens -> redeem + DiscountExerciseParams memory params = + DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: false}); + (uint256 paymentAmount,,,) = optionsToken.exercise(amount, address(this), address(exerciser), abi.encode(params)); + + uint256 underlyingBalanceAfterRedeem = + IERC20(underlyingToken).balanceOf(address(this)) + paymentToken.balanceOf(address(this)).divWadUp(oracle.getPrice()); + console.log("Price: ", oracle.getPrice()); + console.log("Underlying amount after redeem: ", IERC20(underlyingToken).balanceOf(address(this))); + console.log("Balance after redeem: ", underlyingBalanceAfterRedeem); + + assertGt(underlyingBalanceAfterRedeem, underlyingBalance, "Redeem not profitable"); + uint256 redeemProfit = underlyingBalanceAfterRedeem - underlyingBalance; + + // exercise options tokens -> zap + params = DiscountExerciseParams({maxPaymentAmount: expectedPaymentAmount, deadline: type(uint256).max, isInstantExit: true}); + (paymentAmount,,,) = optionsToken.exercise(amount, address(this), address(exerciser), abi.encode(params)); + + uint256 underlyingBalanceAfterZap = + IERC20(underlyingToken).balanceOf(address(this)) + paymentToken.balanceOf(address(this)).divWadUp(oracle.getPrice()); + console.log("Price: ", oracle.getPrice()); + console.log("Underlying amount after zap: ", IERC20(underlyingToken).balanceOf(address(this))); + console.log("Balance after zap: ", underlyingBalanceAfterZap); + + assertGt(underlyingBalanceAfterZap, underlyingBalanceAfterRedeem, "Zap not profitable"); + uint256 zapProfit = underlyingBalanceAfterZap - underlyingBalanceAfterRedeem; + + assertGt(redeemProfit, zapProfit, "Profits from zap is greater than profits from redeem"); + + assertEq(redeemProfit - redeemProfit.mulDivUp(INSTANT_EXIT_FEE, BPS_DENOM), zapProfit, "Zap profit is different than redeem profit minus fee"); + } } diff --git a/test/UniswapV3Oracle.t.sol b/test/UniswapV3Oracle.t.sol index 794f103..afec76f 100644 --- a/test/UniswapV3Oracle.t.sol +++ b/test/UniswapV3Oracle.t.sol @@ -47,9 +47,11 @@ contract UniswapOracleTest is Test { Params _default; function setUp() public { + opFork = vm.createSelectFork(OPTIMISM_RPC_URL, FORK_BLOCK); mockV3Pool = new MockUniswapPool(); mockV3Pool.setCumulatives(sampleCumulatives); mockV3Pool.setToken0(OP_ADDRESS); + mockV3Pool.setToken1(WETH_ADDRESS); _default = Params(IUniswapV3Pool(WETH_OP_POOL_ADDRESS), OP_ADDRESS, address(this), 30 minutes, 0, 1000); swapRouter = ISwapRouter(SWAP_ROUTER_ADDRESS); @@ -59,33 +61,15 @@ contract UniswapOracleTest is Test { /// Mock tests /// ---------------------------------------------------------------------- - function test_PriceToken0() public { - UniswapV3Oracle oracle = new UniswapV3Oracle( - mockV3Pool, - OP_ADDRESS, - _default.owner, - _default.secs, - _default.ago, - _default.minPrice - ); + function test_PriceTokens() public { + UniswapV3Oracle oracle0 = new UniswapV3Oracle(mockV3Pool, OP_ADDRESS, _default.owner, _default.secs, _default.ago, _default.minPrice); + UniswapV3Oracle oracle1 = new UniswapV3Oracle(mockV3Pool, WETH_ADDRESS, _default.owner, _default.secs, _default.ago, _default.minPrice); - uint256 price = oracle.getPrice(); - assertEq(price, expectedPriceToken0); - } - - function test_PriceToken1() public { - UniswapV3Oracle oracle = new UniswapV3Oracle( - mockV3Pool, - address(0), - _default.owner, - _default.secs, - _default.ago, - _default.minPrice - ); - - uint256 price = oracle.getPrice(); - uint256 expectedPriceToken1 = price = FixedPointMathLib.divWadUp(1e18, price); - assertEq(price, expectedPriceToken1); + uint256 price0 = oracle0.getPrice(); + uint256 price1 = oracle1.getPrice(); + assertEq(price0, expectedPriceToken0); + uint256 expectedPriceToken1 = FixedPointMathLib.divWadDown(1e18, price0); + assertEq(price1, expectedPriceToken1); //precision } /// ---------------------------------------------------------------------- @@ -93,16 +77,7 @@ contract UniswapOracleTest is Test { /// ---------------------------------------------------------------------- function test_priceWithinAcceptableRange() public { - opFork = vm.createSelectFork(OPTIMISM_RPC_URL, FORK_BLOCK); - - UniswapV3Oracle oracle = new UniswapV3Oracle( - _default.pool, - _default.token, - _default.owner, - _default.secs, - _default.ago, - _default.minPrice - ); + UniswapV3Oracle oracle = new UniswapV3Oracle(_default.pool, _default.token, _default.owner, _default.secs, _default.ago, _default.minPrice); uint256 oraclePrice = oracle.getPrice(); @@ -112,16 +87,7 @@ contract UniswapOracleTest is Test { } function test_revertMinPrice() public { - opFork = vm.createSelectFork(OPTIMISM_RPC_URL, FORK_BLOCK); - - UniswapV3Oracle oracle = new UniswapV3Oracle( - _default.pool, - _default.token, - _default.owner, - _default.secs, - _default.ago, - _default.minPrice - ); + UniswapV3Oracle oracle = new UniswapV3Oracle(_default.pool, _default.token, _default.owner, _default.secs, _default.ago, _default.minPrice); skip(_default.secs); @@ -143,14 +109,8 @@ contract UniswapOracleTest is Test { swapRouter.exactInputSingle(paramsIn); // deploy a new oracle with a minPrice that is too high - UniswapV3Oracle oracleMinPrice = new UniswapV3Oracle( - _default.pool, - _default.token, - _default.owner, - _default.secs, - _default.ago, - uint128(price) - ); + UniswapV3Oracle oracleMinPrice = + new UniswapV3Oracle(_default.pool, _default.token, _default.owner, _default.secs, _default.ago, uint128(price)); skip(_default.secs); @@ -159,16 +119,7 @@ contract UniswapOracleTest is Test { } function test_singleBlockManipulation() public { - opFork = vm.createSelectFork(OPTIMISM_RPC_URL, FORK_BLOCK); - - UniswapV3Oracle oracle = new UniswapV3Oracle( - _default.pool, - _default.token, - _default.owner, - _default.secs, - _default.ago, - _default.minPrice - ); + UniswapV3Oracle oracle = new UniswapV3Oracle(_default.pool, _default.token, _default.owner, _default.secs, _default.ago, _default.minPrice); address manipulator = makeAddr("manipulator"); deal(OP_ADDRESS, manipulator, 1000000 ether); @@ -200,16 +151,8 @@ contract UniswapOracleTest is Test { function test_priceManipulation(uint256 skipTime) public { skipTime = bound(skipTime, 1, _default.secs); - opFork = vm.createSelectFork(OPTIMISM_RPC_URL, FORK_BLOCK); - UniswapV3Oracle oracle = new UniswapV3Oracle( - _default.pool, - _default.token, - _default.owner, - _default.secs, - _default.ago, - _default.minPrice - ); + UniswapV3Oracle oracle = new UniswapV3Oracle(_default.pool, _default.token, _default.owner, _default.secs, _default.ago, _default.minPrice); address manipulator = makeAddr("manipulator"); deal(OP_ADDRESS, manipulator, 1000000 ether); diff --git a/test/mocks/MockUniswapPool.sol b/test/mocks/MockUniswapPool.sol index 42cd0ba..9ef5cd4 100644 --- a/test/mocks/MockUniswapPool.sol +++ b/test/mocks/MockUniswapPool.sol @@ -6,6 +6,7 @@ import {IUniswapV3Pool} from "v3-core/interfaces/IUniswapV3Pool.sol"; contract MockUniswapPool is IUniswapV3Pool { int56[2] cumulatives; address public token0; + address public token1; function setCumulatives(int56[2] memory value) external { cumulatives = value; @@ -15,6 +16,10 @@ contract MockUniswapPool is IUniswapV3Pool { token0 = value; } + function setToken1(address value) external { + token1 = value; + } + function observe(uint32[] calldata secondsAgos) external view @@ -39,8 +44,6 @@ contract MockUniswapPool is IUniswapV3Pool { function factory() external view override returns (address) {} - function token1() external view override returns (address) {} - function fee() external view override returns (uint24) {} function tickSpacing() external view override returns (int24) {} diff --git a/test/mocks/ReaperSwapperMock.sol b/test/mocks/ReaperSwapperMock.sol new file mode 100644 index 0000000..2afa2f7 --- /dev/null +++ b/test/mocks/ReaperSwapperMock.sol @@ -0,0 +1,71 @@ +//SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {IOracle} from "../../src/interfaces/IOracle.sol"; + +import {ISwapperSwaps, MinAmountOutData, MinAmountOutKind} from "vault-v2/ReaperSwapper.sol"; +import {IERC20} from "oz/token/ERC20/IERC20.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; + +import "forge-std/console.sol"; + +contract ReaperSwapperMock { + using FixedPointMathLib for uint256; + + IOracle oracle; + address underlyingToken; + address paymentToken; + + constructor(IOracle _oracle, address _underlyingToken, address _paymentToken) { + oracle = _oracle; + underlyingToken = _underlyingToken; + paymentToken = _paymentToken; + } + + function swapUniV2(address tokenIn, address tokenOut, uint256 amount, MinAmountOutData memory minAmountOutData, address exchangeAddress) + public + returns (uint256) + { + console.log("Called Univ2"); + return _swap(tokenIn, tokenOut, amount, minAmountOutData, exchangeAddress); + } + + function swapBal(address tokenIn, address tokenOut, uint256 amount, MinAmountOutData memory minAmountOutData, address exchangeAddress) + public + returns (uint256) + { + console.log("Called Bal"); + return _swap(tokenIn, tokenOut, amount, minAmountOutData, exchangeAddress); + } + + function swapVelo(address tokenIn, address tokenOut, uint256 amount, MinAmountOutData memory minAmountOutData, address exchangeAddress) + public + returns (uint256) + { + console.log("Called Velo"); + return _swap(tokenIn, tokenOut, amount, minAmountOutData, exchangeAddress); + } + + function swapUniV3(address tokenIn, address tokenOut, uint256 amount, MinAmountOutData memory minAmountOutData, address exchangeAddress) + public + returns (uint256) + { + console.log("Called Univ3"); + return _swap(tokenIn, tokenOut, amount, minAmountOutData, exchangeAddress); + } + + function _swap(address tokenIn, address tokenOut, uint256 amount, MinAmountOutData memory minAmountOutData, address exchangeAddress) + private + returns (uint256) + { + (address oraclePaymentToken, address oracleUnderlyingToken) = oracle.getTokens(); + require(tokenIn == address(oracleUnderlyingToken) || tokenIn == address(oraclePaymentToken), "Not allowed token in"); + require(tokenOut == address(oracleUnderlyingToken) || tokenOut == address(oraclePaymentToken), "Not allowed token"); + IERC20(tokenIn).transferFrom(msg.sender, address(this), amount); + console.log("Price from oracle is: %e", oracle.getPrice()); + uint256 amountToSend = (oracleUnderlyingToken == tokenIn) ? amount.mulWadUp(oracle.getPrice()) : (amount * 1e18) / oracle.getPrice(); + console.log("Amount to send is : %e", amountToSend); + IERC20(tokenOut).transfer(msg.sender, amountToSend); + return amountToSend; + } +}