From fdb51a58192bec39632f59bee536407f58449882 Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:30:05 -0400 Subject: [PATCH 01/10] Discount factor handling in bill broker --- spot-vaults/contracts/BillBroker.sol | 195 ++++++++---------- .../_interfaces/errors/BillBrokerErrors.sol | 8 + .../CommonErrors.sol} | 11 +- .../{ => types}/BillBrokerTypes.sol | 20 +- .../_interfaces/types/CommonTypes.sol | 22 ++ spot-vaults/contracts/_utils/LineHelpers.sol | 84 ++++++++ 6 files changed, 202 insertions(+), 138 deletions(-) create mode 100644 spot-vaults/contracts/_interfaces/errors/BillBrokerErrors.sol rename spot-vaults/contracts/_interfaces/{BillBrokerErrors.sol => errors/CommonErrors.sol} (63%) rename spot-vaults/contracts/_interfaces/{ => types}/BillBrokerTypes.sol (68%) create mode 100644 spot-vaults/contracts/_interfaces/types/CommonTypes.sol create mode 100644 spot-vaults/contracts/_utils/LineHelpers.sol diff --git a/spot-vaults/contracts/BillBroker.sol b/spot-vaults/contracts/BillBroker.sol index 0e12f3b4..0b57c279 100644 --- a/spot-vaults/contracts/BillBroker.sol +++ b/spot-vaults/contracts/BillBroker.sol @@ -5,17 +5,18 @@ import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/O import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; import { MathUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; -import { SafeCastUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; -import { SignedMathUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/math/SignedMathUpgradeable.sol"; import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; import { ERC20BurnableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; import { IERC20MetadataUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20MetadataUpgradeable.sol"; +import { LineHelpers } from "./_utils/LineHelpers.sol"; import { IPerpetualTranche } from "@ampleforthorg/spot-contracts/contracts/_interfaces/IPerpetualTranche.sol"; -import { ISpotPricingStrategy } from "./_interfaces/ISpotPricingStrategy.sol"; -import { ReserveState, BillBrokerFees, Line, Range } from "./_interfaces/BillBrokerTypes.sol"; -import { UnacceptableSwap, UnreliablePrice, UnexpectedDecimals, InvalidPerc, InvalidARBound, SlippageTooHigh, UnauthorizedCall, UnexpectedARDelta } from "./_interfaces/BillBrokerErrors.sol"; +import { IPerpPricer } from "./_interfaces/IPerpPricer.sol"; +import { ReserveState, BillBrokerFees } from "./_interfaces/types/BillBrokerTypes.sol"; +import { Line, Range } from "./_interfaces/types/CommonTypes.sol"; +import { UnacceptableSwap, UnreliablePrice, UnexpectedDecimals, InvalidPerc, SlippageTooHigh, UnauthorizedCall } from "./_interfaces/errors/CommonErrors.sol"; +import { InvalidARBound, UnexpectedARDelta } from "./_interfaces/errors/BillBrokerErrors.sol"; /** * @title BillBroker @@ -70,9 +71,7 @@ contract BillBroker is // Math using MathUpgradeable for uint256; - using SafeCastUpgradeable for uint256; - using SafeCastUpgradeable for int256; - using SignedMathUpgradeable for int256; + using LineHelpers for Line; //------------------------------------------------------------------------- // Constants & Immutables @@ -102,8 +101,8 @@ contract BillBroker is /// @return The address of the keeper. address public keeper; - /// @notice The pricing strategy. - ISpotPricingStrategy public pricingStrategy; + /// @notice The pricing oracle for perp and usd tokens. + IPerpPricer public oracle; /// @notice All of the system fees. BillBrokerFees public fees; @@ -118,24 +117,24 @@ contract BillBroker is //-------------------------------------------------------------------------- // Events - /// @notice Emitted when a user deposits USD tokens to mint LP tokens. - /// @param usdAmtIn The amount of USD tokens deposited. - /// @param preOpState The reserve state before the deposit operation. + /// @notice Emitted when a user deposits usd tokens to mint LP tokens. + /// @param usdAmtIn The amount of usd tokens deposited. + /// @param preOpState Pre-operation reserve state. event DepositUSD(uint256 usdAmtIn, ReserveState preOpState); /// @notice Emitted when a user deposits Perp tokens to mint LP tokens. /// @param perpAmtIn The amount of Perp tokens deposited. - /// @param preOpState The reserve state before the deposit operation. + /// @param preOpState Pre-operation reserve state. event DepositPerp(uint256 perpAmtIn, ReserveState preOpState); - /// @notice Emitted when a user swaps Perp tokens for USD tokens. + /// @notice Emitted when a user swaps Perp tokens for usd tokens. /// @param perpAmtIn The amount of Perp tokens swapped in. - /// @param preOpState The reserve state before the swap operation. + /// @param preOpState Pre-operation reserve state. event SwapPerpsForUSD(uint256 perpAmtIn, ReserveState preOpState); - /// @notice Emitted when a user swaps USD tokens for Perp tokens. - /// @param usdAmtIn The amount of USD tokens swapped in. - /// @param preOpState The reserve state before the swap operation. + /// @notice Emitted when a user swaps usd tokens for Perp tokens. + /// @param usdAmtIn The amount of usd tokens swapped in. + /// @param preOpState Pre-operation reserve state. event SwapUSDForPerps(uint256 usdAmtIn, ReserveState preOpState); //-------------------------------------------------------------------------- @@ -161,13 +160,13 @@ contract BillBroker is /// @param symbol ERC-20 Symbol of the Bill broker LP token. /// @param usd_ Address of the usd token. /// @param perp_ Address of the perp token. - /// @param pricingStrategy_ Address of the pricing strategy contract. + /// @param oracle_ Address of the oracle contract. function init( string memory name, string memory symbol, IERC20Upgradeable usd_, IPerpetualTranche perp_, - ISpotPricingStrategy pricingStrategy_ + IPerpPricer oracle_ ) public initializer { // initialize dependencies __ERC20_init(name, symbol); @@ -183,7 +182,7 @@ contract BillBroker is perpUnitAmt = 10 ** IERC20MetadataUpgradeable(address(perp_)).decimals(); updateKeeper(owner()); - updatePricingStrategy(pricingStrategy_); + updateOracle(oracle_); updateFees( BillBrokerFees({ mintFeePerc: 0, @@ -211,15 +210,13 @@ contract BillBroker is keeper = keeper_; } - /// @notice Updates the reference to the pricing strategy. - /// @param pricingStrategy_ The address of the new pricing strategy. - function updatePricingStrategy( - ISpotPricingStrategy pricingStrategy_ - ) public onlyOwner { - if (pricingStrategy_.decimals() != DECIMALS) { + /// @notice Updates the reference to the oracle. + /// @param oracle_ The address of the new oracle. + function updateOracle(IPerpPricer oracle_) public onlyOwner { + if (oracle_.decimals() != DECIMALS) { revert UnexpectedDecimals(); } - pricingStrategy = pricingStrategy_; + oracle = oracle_; } /// @notice Updates the system fees. @@ -323,15 +320,10 @@ contract BillBroker is uint256 usdAmtIn, uint256 postOpAssetRatioMax ) external nonReentrant whenNotPaused returns (uint256 mintAmt) { - ReserveState memory preOpState = reserveState(); - uint256 preOpAssetRatio = assetRatio(preOpState); + ReserveState memory s = reserveState(); + uint256 preOpAssetRatio = assetRatio(s); uint256 postOpAssetRatio = assetRatio( - ReserveState({ - usdBalance: preOpState.usdBalance + usdAmtIn, - perpBalance: preOpState.perpBalance, - usdPrice: preOpState.usdPrice, - perpPrice: preOpState.perpPrice - }) + _updatedReserveState(s, s.usdBalance + usdAmtIn, s.perpBalance) ); // We allow minting only pool is underweight usd @@ -339,7 +331,7 @@ contract BillBroker is return 0; } - mintAmt = computeMintAmtWithUSD(usdAmtIn, preOpState); + mintAmt = computeMintAmtWithUSD(usdAmtIn, s); if (mintAmt <= 0) { return 0; } @@ -354,7 +346,7 @@ contract BillBroker is _mint(msg.sender, mintAmt); // Emit deposit info - emit DepositUSD(usdAmtIn, preOpState); + emit DepositUSD(usdAmtIn, s); } /// @notice Single sided perp token deposit and mint LP tokens. @@ -365,15 +357,10 @@ contract BillBroker is uint256 perpAmtIn, uint256 postOpAssetRatioMin ) external nonReentrant whenNotPaused returns (uint256 mintAmt) { - ReserveState memory preOpState = reserveState(); - uint256 preOpAssetRatio = assetRatio(preOpState); + ReserveState memory s = reserveState(); + uint256 preOpAssetRatio = assetRatio(s); uint256 postOpAssetRatio = assetRatio( - ReserveState({ - usdBalance: preOpState.usdBalance, - perpBalance: preOpState.perpBalance + perpAmtIn, - usdPrice: preOpState.usdPrice, - perpPrice: preOpState.perpPrice - }) + _updatedReserveState(s, s.usdBalance, s.perpBalance + perpAmtIn) ); // We allow minting only pool is underweight perp @@ -381,7 +368,7 @@ contract BillBroker is return 0; } - mintAmt = computeMintAmtWithPerp(perpAmtIn, preOpState); + mintAmt = computeMintAmtWithPerp(perpAmtIn, s); if (mintAmt <= 0) { return 0; } @@ -396,7 +383,7 @@ contract BillBroker is _mint(msg.sender, mintAmt); // Emit deposit info - emit DepositPerp(perpAmtIn, preOpState); + emit DepositPerp(perpAmtIn, s); } /// @notice Burns LP tokens and redeems usd and perp tokens. @@ -433,12 +420,9 @@ contract BillBroker is uint256 perpAmtMin ) external nonReentrant whenNotPaused returns (uint256 perpAmtOut) { // compute perp amount out - ReserveState memory preOpState = reserveState(); + ReserveState memory s = reserveState(); uint256 protocolFeePerpAmt; - (perpAmtOut, , protocolFeePerpAmt) = computeUSDToPerpSwapAmt( - usdAmtIn, - preOpState - ); + (perpAmtOut, , protocolFeePerpAmt) = computeUSDToPerpSwapAmt(usdAmtIn, s); if (usdAmtIn <= 0 || perpAmtOut <= 0) { revert UnacceptableSwap(); } @@ -458,7 +442,7 @@ contract BillBroker is perp.safeTransfer(msg.sender, perpAmtOut); // Emit swap info - emit SwapUSDForPerps(usdAmtIn, preOpState); + emit SwapUSDForPerps(usdAmtIn, s); } /// @notice Swaps perp tokens from the user for usd tokens from the reserve. @@ -470,9 +454,9 @@ contract BillBroker is uint256 usdAmtMin ) external nonReentrant whenNotPaused returns (uint256 usdAmtOut) { // Compute swap amount - ReserveState memory preOpState = reserveState(); + ReserveState memory s = reserveState(); uint256 protocolFeeUsdAmt; - (usdAmtOut, , protocolFeeUsdAmt) = computePerpToUSDSwapAmt(perpAmtIn, preOpState); + (usdAmtOut, , protocolFeeUsdAmt) = computePerpToUSDSwapAmt(perpAmtIn, s); if (perpAmtIn <= 0 || usdAmtOut <= 0) { revert UnacceptableSwap(); } @@ -492,7 +476,7 @@ contract BillBroker is usd.safeTransfer(msg.sender, usdAmtOut); // Emit swap info - emit SwapPerpsForUSD(perpAmtIn, preOpState); + emit SwapPerpsForUSD(perpAmtIn, s); } //----------------------------------------------------------------------------- @@ -545,24 +529,25 @@ contract BillBroker is }); } - /// @dev Reverts if the pricing strategy returns an invalid price. - /// @return The price of usd tokens from the pricing strategy. + /// @dev Reverts if the oracle returns an invalid price. + /// @return The price of usd tokens from the oracle. function usdPrice() public returns (uint256) { - (uint256 p, bool v) = pricingStrategy.usdPrice(); + (uint256 p, bool v) = oracle.usdPrice(); if (!v) { revert UnreliablePrice(); } return p; } - /// @dev Reverts if the pricing strategy returns an invalid price. - /// @return The price of perp tokens from the pricing strategy. + /// @dev Reverts if the oracle returns an invalid price. + /// @return The price of perp tokens from the oracle. function perpPrice() public returns (uint256) { - (uint256 p, bool v) = pricingStrategy.perpPrice(); - if (!v) { + (uint256 p, bool v1) = oracle.perpFmvUsdPrice(); + (uint256 beta, bool v2) = oracle.perpBeta(); + if (!v1 || !v2) { revert UnreliablePrice(); } - return p; + return p.mulDiv(beta, ONE); } //----------------------------------------------------------------------------- @@ -652,10 +637,10 @@ contract BillBroker is return 0; } + // We compute equal value of perp tokens going out. uint256 valueIn = s.usdPrice.mulDiv(usdAmtIn, usdUnitAmt); uint256 totalReserveVal = (s.usdPrice.mulDiv(s.usdBalance, usdUnitAmt) + s.perpPrice.mulDiv(s.perpBalance, perpUnitAmt)); - return (totalReserveVal > 0) ? valueIn.mulDiv(totalSupply(), totalReserveVal).mulDiv( @@ -678,10 +663,10 @@ contract BillBroker is return 0; } + // We compute equal value of perp tokens coming in. uint256 valueIn = s.perpPrice.mulDiv(perpAmtIn, perpUnitAmt); uint256 totalReserveVal = (s.usdPrice.mulDiv(s.usdBalance, usdUnitAmt) + s.perpPrice.mulDiv(s.perpBalance, perpUnitAmt)); - return (totalReserveVal > 0) ? valueIn.mulDiv(totalSupply(), totalReserveVal).mulDiv( @@ -730,7 +715,7 @@ contract BillBroker is view returns (uint256 usdAmtOut, uint256 lpFeeUsdAmt, uint256 protocolFeeUsdAmt) { - // We compute equal value of usd tokens out given perp tokens in. + // We compute equal value tokens to swap out. usdAmtOut = perpAmtIn.mulDiv(s.perpPrice, s.usdPrice).mulDiv( usdUnitAmt, perpUnitAmt @@ -740,12 +725,11 @@ contract BillBroker is uint256 totalFeePerc = computePerpToUSDSwapFeePerc( assetRatio(s), assetRatio( - ReserveState({ - usdBalance: s.usdBalance - usdAmtOut, - perpBalance: s.perpBalance + perpAmtIn, - usdPrice: s.usdPrice, - perpPrice: s.perpPrice - }) + _updatedReserveState( + s, + s.usdBalance - usdAmtOut, + s.perpBalance + perpAmtIn + ) ) ); if (totalFeePerc >= ONE) { @@ -773,21 +757,21 @@ contract BillBroker is view returns (uint256 perpAmtOut, uint256 lpFeePerpAmt, uint256 protocolFeePerpAmt) { - // We compute equal value of perp tokens out given usd tokens in. + // We compute equal value tokens to swap out. perpAmtOut = usdAmtIn.mulDiv(s.usdPrice, s.perpPrice).mulDiv( perpUnitAmt, usdUnitAmt ); + // We compute the total fee percentage, lp fees and protocol fees uint256 totalFeePerc = computeUSDToPerpSwapFeePerc( assetRatio(s), assetRatio( - ReserveState({ - usdBalance: s.usdBalance + usdAmtIn, - perpBalance: s.perpBalance - perpAmtOut, - usdPrice: s.usdPrice, - perpPrice: s.perpPrice - }) + _updatedReserveState( + s, + s.usdBalance + usdAmtIn, + s.perpBalance - perpAmtOut + ) ) ); if (totalFeePerc >= ONE) { @@ -931,6 +915,21 @@ contract BillBroker is //----------------------------------------------------------------------------- // Private methods + /// @dev Constructs the new reserve state based on provided balances. + function _updatedReserveState( + ReserveState memory s, + uint256 usdBalance_, + uint256 perpBalance_ + ) private pure returns (ReserveState memory) { + return + ReserveState({ + usdBalance: usdBalance_, + perpBalance: perpBalance_, + usdPrice: s.usdPrice, + perpPrice: s.perpPrice + }); + } + /// @dev The function assumes the fee curve is defined as a pair-wise linear function which merge at the cutoff point. /// The swap fee is computed as avg height of the fee curve between {arL,arU}. function _computeFeePerc( @@ -941,34 +940,12 @@ contract BillBroker is uint256 cutoff ) private pure returns (uint256 feePerc) { if (arU <= cutoff) { - feePerc = _avgY(fn1, arL, arU); + feePerc = fn1.avgY(arL, arU, 0, ONE); } else if (arL >= cutoff) { - feePerc = _avgY(fn2, arL, arU); + feePerc = fn2.avgY(arL, arU, 0, ONE); } else { - feePerc = (_avgY(fn1, arL, cutoff).mulDiv(cutoff - arL, arU - arL) + - _avgY(fn2, cutoff, arU).mulDiv(arU - cutoff, arU - arL)); - } - feePerc = MathUpgradeable.min(feePerc, ONE); - } - - /// @dev We compute the average height of the line between {xL,xU}. - function _avgY( - Line memory fn, - uint256 xL, - uint256 xU - ) private pure returns (uint256) { - // if the line has a zero slope, return any y - if (fn.y1 == fn.y2) { - return fn.y2; - } - - // m = dlY/dlX - // c = y2 - m . x2 - // Avg height => (yL + yU) / 2 - // => m . ( xL + xU ) / 2 + c - int256 dlY = fn.y2.toInt256() - fn.y1.toInt256(); - int256 dlX = fn.x2.toInt256() - fn.x1.toInt256(); - int256 c = fn.y2.toInt256() - ((fn.x2.toInt256() * dlY) / dlX); - return ((((xL + xU).toInt256() * dlY) / (2 * dlX)) + c).abs(); + feePerc = (fn1.avgY(arL, cutoff, 0, ONE).mulDiv(cutoff - arL, arU - arL) + + fn2.avgY(cutoff, arU, 0, ONE).mulDiv(arU - cutoff, arU - arL)); + } } } diff --git a/spot-vaults/contracts/_interfaces/errors/BillBrokerErrors.sol b/spot-vaults/contracts/_interfaces/errors/BillBrokerErrors.sol new file mode 100644 index 00000000..e1dc341f --- /dev/null +++ b/spot-vaults/contracts/_interfaces/errors/BillBrokerErrors.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +/// @notice Expect AR lower bound to be under the upper bound. +error InvalidARBound(); + +/// @notice Expected pre and post swap AR delta to be non-increasing or non-decreasing. +error UnexpectedARDelta(); diff --git a/spot-vaults/contracts/_interfaces/BillBrokerErrors.sol b/spot-vaults/contracts/_interfaces/errors/CommonErrors.sol similarity index 63% rename from spot-vaults/contracts/_interfaces/BillBrokerErrors.sol rename to spot-vaults/contracts/_interfaces/errors/CommonErrors.sol index babbae79..c8d6b263 100644 --- a/spot-vaults/contracts/_interfaces/BillBrokerErrors.sol +++ b/spot-vaults/contracts/_interfaces/errors/CommonErrors.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +/// SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.24; /// @notice Expected contract call to be triggered by authorized caller. @@ -10,15 +10,6 @@ error UnexpectedDecimals(); /// @notice Expected perc value to be at most (1 * 10**DECIMALS), i.e) 1.0 or 100%. error InvalidPerc(); -/// @notice Expected Senior CDR bound to be more than 1.0 or 100%. -error InvalidSeniorCDRBound(); - -/// @notice Expect AR lower bound to be under the upper bound. -error InvalidARBound(); - -/// @notice Expected pre and post swap AR delta to be non-increasing or non-decreasing. -error UnexpectedARDelta(); - /// @notice Slippage higher than tolerance requested by user. error SlippageTooHigh(); diff --git a/spot-vaults/contracts/_interfaces/BillBrokerTypes.sol b/spot-vaults/contracts/_interfaces/types/BillBrokerTypes.sol similarity index 68% rename from spot-vaults/contracts/_interfaces/BillBrokerTypes.sol rename to spot-vaults/contracts/_interfaces/types/BillBrokerTypes.sol index d24601ad..a99b3c27 100644 --- a/spot-vaults/contracts/_interfaces/BillBrokerTypes.sol +++ b/spot-vaults/contracts/_interfaces/types/BillBrokerTypes.sol @@ -1,25 +1,7 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.24; -/// @notice A data structure to define a geometric Line with two points. -struct Line { - /// @notice x-coordinate of the first point. - uint256 x1; - /// @notice y-coordinate of the first point. - uint256 y1; - /// @notice x-coordinate of the second point. - uint256 x2; - /// @notice y-coordinate of the second point. - uint256 y2; -} - -/// @notice A data structure to define a numeric Range. -struct Range { - /// @notice Lower bound of the range. - uint256 lower; - /// @notice Upper bound of the range. - uint256 upper; -} +import { Range } from "./CommonTypes.sol"; /// @notice A data structure to store various fees associated with BillBroker operations. struct BillBrokerFees { diff --git a/spot-vaults/contracts/_interfaces/types/CommonTypes.sol b/spot-vaults/contracts/_interfaces/types/CommonTypes.sol new file mode 100644 index 00000000..00ea2009 --- /dev/null +++ b/spot-vaults/contracts/_interfaces/types/CommonTypes.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +/// @notice A data structure to define a geometric Line with two points. +struct Line { + // @dev x-coordinate of the first point. + uint256 x1; + // @dev y-coordinate of the first point. + uint256 y1; + // @dev x-coordinate of the second point. + uint256 x2; + // @dev y-coordinate of the second point. + uint256 y2; +} + +/// @notice A data structure to define a numeric Range. +struct Range { + // @dev Lower bound of the range. + uint256 lower; + // @dev Upper bound of the range. + uint256 upper; +} diff --git a/spot-vaults/contracts/_utils/LineHelpers.sol b/spot-vaults/contracts/_utils/LineHelpers.sol new file mode 100644 index 00000000..33de080b --- /dev/null +++ b/spot-vaults/contracts/_utils/LineHelpers.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { Line } from "../_interfaces/types/CommonTypes.sol"; + +/** + * @title LineHelpers + * + * @notice Library with helper functions for the Line data structure. + * + */ +library LineHelpers { + /// @dev We compute the average height of the line between {xL,xU}. + /// Clips the final y value between [yMin, yMax]. + function avgY( + Line memory fn, + uint256 xL, + uint256 xU, + uint256 yMin, + uint256 yMax + ) internal pure returns (uint256) { + // if the line has a zero slope, return any y + if (fn.y1 == fn.y2) { + return _clip(fn.y1, yMin, yMax); + } + + uint256 yL = computeY(fn, xL, 0, type(uint256).max); + uint256 yU = computeY(fn, xU, 0, type(uint256).max); + uint256 avgY_ = (yL + yU) / 2; + return _clip(avgY_, yMin, yMax); + } + + /// @dev This function computes y for a given x on the line (fn), bounded by yMin and yMax. + function computeY( + Line memory fn, + uint256 x, + uint256 yMin, + uint256 yMax + ) internal pure returns (uint256) { + // m = (y2-y1)/(x2-x1) + // y = y1 + m * (x-x1) + + // If the line has a zero slope, return a y value clipped between yMin and yMax + if (fn.y1 == fn.y2) { + return _clip(fn.y1, yMin, yMax); + } + + // Determine if m is positive + bool posM = (fn.y2 > fn.y1 && fn.x2 > fn.x1) || (fn.y2 < fn.y1 && fn.x2 < fn.x1); + + // Determine if (x - x1) is positive + bool posDelX1 = (x > fn.x1); + + // Calculate absolute differences to ensure no underflow + uint256 dlY = fn.y2 > fn.y1 ? (fn.y2 - fn.y1) : (fn.y1 - fn.y2); + uint256 dlX = fn.x2 > fn.x1 ? (fn.x2 - fn.x1) : (fn.x1 - fn.x2); + uint256 delX1 = posDelX1 ? (x - fn.x1) : (fn.x1 - x); + + // Calculate m * (x-x1) + uint256 mDelX1 = Math.mulDiv(delX1, dlY, dlX); + + uint256 y = 0; + + // When m * (x-x1) is positive + if ((posM && posDelX1) || (!posM && !posDelX1)) { + y = fn.y1 + mDelX1; + } + // When m * (x-x1) is negative + else { + y = (fn.y1 > mDelX1) ? (fn.y1 - mDelX1) : yMin; // Ensures no underflow + } + + // Return the y value clipped between yMin and yMax + return _clip(y, yMin, yMax); + } + + // @dev Helper function to clip y between min and max values + function _clip(uint256 y, uint256 min, uint256 max) private pure returns (uint256) { + y = (y <= min) ? min : y; + y = (y >= max) ? max : y; + return y; + } +} From 1fa271de9609ed6971afbe12f02069698b26f0f1 Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Tue, 17 Sep 2024 19:37:49 -0400 Subject: [PATCH 02/10] new spot pricer --- .../contracts/_interfaces/IMetaOracle.sol | 46 +++ .../contracts/_interfaces/IPerpPricer.sol | 28 ++ .../_interfaces/ISpotPricingStrategy.sol | 22 -- .../contracts/_strategies/SpotAppraiser.sol | 374 +++++++++++------- .../contracts/_strategies/SpotCDRPricer.sol | 126 ------ .../contracts/_utils/UniswapV3PoolHelpers.sol | 48 +++ 6 files changed, 347 insertions(+), 297 deletions(-) create mode 100644 spot-vaults/contracts/_interfaces/IMetaOracle.sol create mode 100644 spot-vaults/contracts/_interfaces/IPerpPricer.sol delete mode 100644 spot-vaults/contracts/_interfaces/ISpotPricingStrategy.sol delete mode 100644 spot-vaults/contracts/_strategies/SpotCDRPricer.sol create mode 100644 spot-vaults/contracts/_utils/UniswapV3PoolHelpers.sol diff --git a/spot-vaults/contracts/_interfaces/IMetaOracle.sol b/spot-vaults/contracts/_interfaces/IMetaOracle.sol new file mode 100644 index 00000000..6bc22f88 --- /dev/null +++ b/spot-vaults/contracts/_interfaces/IMetaOracle.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: BUSL-1.1 + +/// @notice Oracle adapter for AMPL and its family of assets. +// solhint-disable-next-line compiler-version +interface IMetaOracle { + /// @return Number of decimals representing the prices returned. + function decimals() external pure returns (uint8); + + /// @return price The price of USDC tokens in dollars. + /// @return isValid True if the returned price is valid. + function usdcPrice() external returns (uint256 price, bool isValid); + + /// @notice Computes the deviation between SPOT's market price and FMV price. + /// @return deviation The computed deviation factor. + /// @return isValid True if the returned deviation is valid. + function spotPriceDeviation() external returns (uint256 deviation, bool isValid); + + /// @notice Computes the deviation between AMPL's market price and price target. + /// @return deviation The computed deviation factor. + /// @return isValid True if the returned deviation is valid. + function amplPriceDeviation() external returns (uint256 deviation, bool isValid); + + /// @return price The price of SPOT in dollars. + /// @return isValid True if the returned price is valid. + function spotUsdPrice() external returns (uint256 price, bool isValid); + + /// @return price The price of AMPL in dollars. + /// @return isValid True if the returned price is valid. + function amplUsdPrice() external returns (uint256 price, bool isValid); + + /// @return price The SPOT FMV price in dollars. + /// @return isValid True if the returned price is valid. + function spotFmvUsdPrice() external returns (uint256 price, bool isValid); + + /// @return price The AMPL target price in dollars. + /// @return isValid True if the returned price is valid. + function amplTargetUsdPrice() external returns (uint256 price, bool isValid); + + /// @return price The WAMPL price in dollars. + /// @return isValid True if the returned price is valid. + function wamplUsdPrice() external returns (uint256 price, bool isValid); + + /// @return price The ETH price in dollars. + /// @return isValid True if the returned price is valid. + function ethUsdPrice() external returns (uint256 price, bool isValid); +} diff --git a/spot-vaults/contracts/_interfaces/IPerpPricer.sol b/spot-vaults/contracts/_interfaces/IPerpPricer.sol new file mode 100644 index 00000000..0823262f --- /dev/null +++ b/spot-vaults/contracts/_interfaces/IPerpPricer.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BUSL-1.1 + +/// @notice Oracle contract to price perps and its underlying token. +// solhint-disable-next-line compiler-version +interface IPerpPricer { + /// @return Number of decimals representing the prices returned. + function decimals() external pure returns (uint8); + + /// @return price The price of reference USD tokens. + /// @return isValid True if the returned price is valid. + function usdPrice() external returns (uint256 price, bool isValid); + + /// @return price The price of perp tokens in dollars. + /// @return isValid True if the returned price is valid. + function perpUsdPrice() external returns (uint256 price, bool isValid); + + /// @return price The price of underlying tokens (which back perp) in dollars. + /// @return isValid True if the returned price is valid. + function underlyingUsdPrice() external returns (uint256 price, bool isValid); + + /// @return price Perp's fmv price in dollars. + /// @return isValid True if the returned price is valid. + function perpFmvUsdPrice() external returns (uint256 price, bool isValid); + + /// @return beta Perp's volatility measure. + /// @return isValid True if the returned measure is valid. + function perpBeta() external returns (uint256, bool); +} diff --git a/spot-vaults/contracts/_interfaces/ISpotPricingStrategy.sol b/spot-vaults/contracts/_interfaces/ISpotPricingStrategy.sol deleted file mode 100644 index 2f794c76..00000000 --- a/spot-vaults/contracts/_interfaces/ISpotPricingStrategy.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 - -/** - * @title ISpotPricingStrategy - * - * @notice Pricing strategy adapter for a BillBroker vault - * which accepts Perp and USDC tokens. - * - */ -// solhint-disable-next-line compiler-version -interface ISpotPricingStrategy { - /// @return Number of decimals representing the prices returned. - function decimals() external pure returns (uint8); - - /// @return price The price of USD tokens. - /// @return isValid True if the returned price is valid. - function usdPrice() external returns (uint256 price, bool isValid); - - /// @return price The price of perp tokens. - /// @return isValid True if the returned price is valid. - function perpPrice() external returns (uint256 price, bool isValid); -} diff --git a/spot-vaults/contracts/_strategies/SpotAppraiser.sol b/spot-vaults/contracts/_strategies/SpotAppraiser.sol index 05cd2b28..6e965706 100644 --- a/spot-vaults/contracts/_strategies/SpotAppraiser.sol +++ b/spot-vaults/contracts/_strategies/SpotAppraiser.sol @@ -1,217 +1,293 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.24; +// solhint-disable-next-line compiler-version +pragma solidity ^0.7.6; +pragma abicoder v2; -import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { FullMath } from "@uniswap/v3-core/contracts/libraries/FullMath.sol"; +import { UniswapV3PoolHelpers } from "../_utils/UniswapV3PoolHelpers.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { ITranche } from "@ampleforthorg/spot-contracts/contracts/_interfaces/buttonwood/ITranche.sol"; -import { IBondController } from "@ampleforthorg/spot-contracts/contracts/_interfaces/buttonwood/IBondController.sol"; -import { IPerpetualTranche } from "@ampleforthorg/spot-contracts/contracts/_interfaces/IPerpetualTranche.sol"; +import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; +import { IERC20 } from "../_interfaces/external/IERC20.sol"; +import { IWAMPL } from "../_interfaces/external/IWAMPL.sol"; +import { IPerpetualTranche } from "../_interfaces/external/IPerpetualTranche.sol"; import { IChainlinkOracle } from "../_interfaces/external/IChainlinkOracle.sol"; import { IAmpleforthOracle } from "../_interfaces/external/IAmpleforthOracle.sol"; -import { ISpotPricingStrategy } from "../_interfaces/ISpotPricingStrategy.sol"; -import { InvalidSeniorCDRBound } from "../_interfaces/BillBrokerErrors.sol"; + +import { IPerpPricer } from "../_interfaces/IPerpPricer.sol"; +import { IMetaOracle } from "../_interfaces/IMetaOracle.sol"; /** - * @title SpotAppraiser - * - * @notice Pricing strategy adapter for a BillBroker vault which accepts - * SPOT (as the perp token) and dollar tokens like USDC. - * - * AMPL is the underlying token for SPOT. - * The market price of AMPL is mean reverting and eventually converges to its target. - * However, it can significantly deviate from the target in the near term. - * - * SPOT is a perpetual claim on AMPL senior tranches. Insofar as SPOT is fully backed by - * healthy senior tranches, we can price spot reliably using the following strategy: - * - * SPOT_PRICE = MULTIPLIER * AMPL_TARGET - * MULTIPLIER = spot.getTVL() / spot.totalSupply(), which is it's enrichment/debasement factor. - * To know more, read the spot documentation. + * @title SpotPricer * - * We get the AMPL target price from Ampleforth's CPI oracle, - * which is also used by the protocol to adjust AMPL supply through rebasing. + * @notice A pricing oracle for SPOT, a perpetual claim on AMPL senior tranches. * - * And the MULTIPLIER is directly queried from the SPOT contract. + * Internally aggregates prices from multiple oracles. + * Chainlink for USDC and ETH prices, + * The Ampleforth CPI oracle for the AMPL price target and + * UniV3 pools for current AMPL and SPOT market prices. * */ -contract SpotAppraiser is Ownable, ISpotPricingStrategy { +contract SpotPricer is IPerpPricer, IMetaOracle { //------------------------------------------------------------------------- - // Libraries - using Math for uint256; - - //------------------------------------------------------------------------- - // Constants & Immutables + // Constants + /// @dev Standardizes prices from various oracles and returns the final value + /// as a fixed point number with {DECIMALS} places. uint256 private constant DECIMALS = 18; uint256 private constant ONE = (10 ** DECIMALS); - uint256 private constant SPOT_DR_DECIMALS = 8; - uint256 private constant SPOT_DR_ONE = (10 ** SPOT_DR_DECIMALS); - uint256 public constant CL_ORACLE_DECIMALS = 8; - uint256 public constant CL_ORACLE_STALENESS_THRESHOLD_SEC = 3600 * 48; // 2 days - uint256 public constant USD_UPPER_BOUND = (101 * ONE) / 100; // 1.01$ - uint256 public constant USD_LOWER_BOUND = (99 * ONE) / 100; // 0.99$ - uint256 public constant AMPL_DUST_AMT = 25000 * (10 ** 9); // 25000 AMPL - /// @notice Address of the SPOT (perpetual tranche) ERC-20 token contract. - IPerpetualTranche public immutable SPOT; + /// @dev We bound the deviation factor to 100.0. + uint256 public constant MAX_DEVIATION = 100 * ONE; // 100.0 - /// @notice Address of the AMPL ERC-20 token contract. - IERC20 public immutable AMPL; + /// @dev Token denominations. + uint256 private constant ONE_USDC = 1e6; + uint256 private constant ONE_WETH = 1e18; + uint256 private constant ONE_SPOT = 1e9; + uint256 private constant ONE_AMPL = 1e9; + uint256 private constant ONE_WAMPL = 1e18; + + /// @dev Oracle constants. + uint256 private constant CL_ETH_ORACLE_STALENESS_THRESHOLD_SEC = 3600 * 12; // 12 hours + uint256 private constant CL_USDC_ORACLE_STALENESS_THRESHOLD_SEC = 3600 * 48; // 2 day + uint256 private constant USDC_UPPER_BOUND = (101 * ONE) / 100; // 1.01$ + uint256 private constant USDC_LOWER_BOUND = (99 * ONE) / 100; // 0.99$ + uint32 private constant TWAP_DURATION = 3600; + + //-------------------------------------------------------------------------- + // Modifiers + + /// @dev Throws if called by any account other than the owner. + modifier onlyOwner() { + // solhint-disable-next-line custom-errors + require(msg.sender == owner, "UnauthorizedCall"); + _; + } + + //------------------------------------------------------------------------- + // Storage + + /// @notice Address of the WETH-WAMPL univ3 pool. + IUniswapV3Pool public immutable WETH_WAMPL_POOL; + + /// @notice Address of the USDC-SPOT univ3 pool. + IUniswapV3Pool public immutable USDC_SPOT_POOL; + + /// @notice Address of the ETH token market price oracle. + IChainlinkOracle public immutable ETH_ORACLE; /// @notice Address of the USD token market price oracle. - IChainlinkOracle public immutable USD_ORACLE; + IChainlinkOracle public immutable USDC_ORACLE; - /// @notice Number of decimals representing the prices returned by the chainlink oracle. - uint256 public immutable USD_ORACLE_DECIMALS; + /// @notice Address of the Ampleforth CPI oracle. + IAmpleforthOracle public immutable CPI_ORACLE; - /// @notice Address of the Ampleforth CPI oracle. (provides the inflation-adjusted target price for AMPL). - IAmpleforthOracle public immutable AMPL_CPI_ORACLE; + /// @notice Address of the WAMPL ERC-20 token contract. + IWAMPL public immutable WAMPL; - /// @notice Number of decimals representing the prices returned by the ampleforth oracle. - uint256 public immutable AMPL_CPI_ORACLE_DECIMALS; + /// @notice Address of the USDC ERC-20 token contract. + IERC20 public immutable USDC; + + /// @notice Address of the SPOT (perpetual tranche) ERC-20 token contract. + IPerpetualTranche public immutable SPOT; + + /// @notice Address of the AMPL ERC-20 token contract. + IERC20 public immutable AMPL; //------------------------------------------------------------------------- // Storage - /// @notice The minimum "deviation ratio" of the SPOT outside which it's considered unhealthy. - uint256 public minSPOTDR; + /// @notice Address of the owner. + address public owner; - /// @notice The minimum CDR of senior tranches backing SPOT outside which it's considered unhealthy. - uint256 public minSeniorCDR; + /// @notice Scalar price multiplier which captures spot's predicted future volatility. + uint256 public spotDiscountFactor; //----------------------------------------------------------------------------- // Constructor /// @notice Contract constructor. - /// @param spot Address of the SPOT token. - /// @param usdOracle Address of the USD token market price oracle token. - /// @param cpiOracle Address of the Ampleforth CPI oracle. + /// @param wethWamplPool Address of the WETH-WAMPL univ3 pool. + /// @param usdcSpotPool Address of the USDC-SPOT univ3 pool. + /// @param ethOracle Address of the ETH market price oracle. + /// @param usdcOracle Address of the USD coin market price oracle. + /// @param cpiOracle Address Ampleforth's cpi oracle. constructor( - IPerpetualTranche spot, - IChainlinkOracle usdOracle, + IUniswapV3Pool wethWamplPool, + IUniswapV3Pool usdcSpotPool, + IChainlinkOracle ethOracle, + IChainlinkOracle usdcOracle, IAmpleforthOracle cpiOracle - ) Ownable() { - SPOT = spot; - AMPL = IERC20(address(spot.underlying())); + ) { + owner = msg.sender; + + WETH_WAMPL_POOL = wethWamplPool; + USDC_SPOT_POOL = usdcSpotPool; - USD_ORACLE = usdOracle; - USD_ORACLE_DECIMALS = usdOracle.decimals(); + ETH_ORACLE = ethOracle; + USDC_ORACLE = usdcOracle; + CPI_ORACLE = cpiOracle; - AMPL_CPI_ORACLE = cpiOracle; - AMPL_CPI_ORACLE_DECIMALS = cpiOracle.DECIMALS(); + WAMPL = IWAMPL(wethWamplPool.token1()); + USDC = IERC20(usdcSpotPool.token0()); - minSPOTDR = (ONE * 8) / 10; // 0.8 - minSeniorCDR = (ONE * 11) / 10; // 110% + IPerpetualTranche spot = IPerpetualTranche(usdcSpotPool.token1()); + SPOT = spot; + AMPL = IERC20(spot.underlying()); } //-------------------------------------------------------------------------- // Owner only methods - /// @notice Controls the minimum `deviationRatio` ratio of SPOT below which SPOT is considered unhealthy. - /// @param minSPOTDR_ The minimum SPOT `deviationRatio`. - function updateMinSPOTDR(uint256 minSPOTDR_) external onlyOwner { - minSPOTDR = minSPOTDR_; + /// @notice Updates spot's discount factor. + /// @param p New discount factor. + function updateSpotDiscountFactor(uint256 d) external onlyOwner { + spotDiscountFactor = d; } - /// @notice Controls the minimum CDR of SPOT's senior tranche below which SPOT is considered unhealthy. - /// @param minSeniorCDR_ The minimum senior tranche CDR. - function updateMinPerpCollateralCDR(uint256 minSeniorCDR_) external onlyOwner { - if (minSeniorCDR_ < ONE) { - revert InvalidSeniorCDRBound(); - } - minSeniorCDR = minSeniorCDR_; + /// @notice Transfer contract ownership. + /// @param newOwner Address of new owner. + function transferOwnership(address newOwner) external onlyOwner { + owner = newOwner; } //-------------------------------------------------------------------------- - // External methods + // IPerpPricer methods + + /// @inheritdoc IPerpPricer + function decimals() external pure override(IPerpPricer, IMetaOracle) returns (uint8) { + return uint8(DECIMALS); + } - /// @return p The price of the usd token in dollars. - /// @return v True if the price is valid and can be used by downstream consumers. + /// @inheritdoc IPerpPricer function usdPrice() external view override returns (uint256, bool) { - (uint256 p, bool v) = _getCLOracleData(USD_ORACLE, USD_ORACLE_DECIMALS); + return usdcPrice(); + } + + /// @inheritdoc IPerpPricer + function perpUsdPrice() external view override returns (uint256, bool) { + return spotUsdPrice(); + } + + /// @inheritdoc IPerpPricer + function underlyingUsdPrice() external view override returns (uint256, bool) { + return amplUsdPrice(); + } + + /// @inheritdoc IPerpPricer + function perpFmvUsdPrice() external override returns (uint256, bool) { + return spotFmvUsdPrice(); + } + + /// @inheritdoc IPerpPricer + function perpBeta() external override returns (uint256, bool) { + return (spotDiscountFactor, true); + } + + //-------------------------------------------------------------------------- + // IMetaOracle methods + + /// @inheritdoc IMetaOracle + function usdcPrice() public view override returns (uint256, bool) { + (uint256 p, bool v) = _getCLOracleData( + USDC_ORACLE, + CL_USDC_ORACLE_STALENESS_THRESHOLD_SEC + ); // If the market price of the USD coin deviated too much from 1$, // it's an indication of some systemic issue with the USD token // and thus its price should be considered unreliable. - return (ONE, (v && p < USD_UPPER_BOUND && p > USD_LOWER_BOUND)); + return (ONE, (v && p < USDC_UPPER_BOUND && p > USDC_LOWER_BOUND)); } - /// @return p The price of the spot token in dollar coins. - /// @return v True if the price is valid and can be used by downstream consumers. - function perpPrice() external override returns (uint256, bool) { - // NOTE: Since {DECIMALS} == {AMPL_CPI_ORACLE_DECIMALS} == 18 - // we don't adjust the returned values. - (uint256 targetPrice, bool targetPriceValid) = AMPL_CPI_ORACLE.getData(); - uint256 p = targetPrice.mulDiv(SPOT.getTVL(), SPOT.totalSupply()); - bool v = (targetPriceValid && isSPOTHealthy()); - return (p, v); + /// @inheritdoc IMetaOracle + function spotPriceDeviation() public override returns (uint256, bool) { + (uint256 marketPrice, bool marketPriceValid) = spotUsdPrice(); + (uint256 targetPrice, bool targetPriceValid) = spotFmvUsdPrice(); + uint256 deviation = (targetPrice > 0) + ? FullMath.mulDiv(marketPrice, ONE, targetPrice) + : type(uint256).max; + deviation = (deviation > MAX_DEVIATION) ? MAX_DEVIATION : deviation; + return (deviation, (marketPriceValid && targetPriceValid)); } - /// @return Number of decimals representing a price of 1.0 USD. - function decimals() external pure override returns (uint8) { - return uint8(DECIMALS); + /// @inheritdoc IMetaOracle + function amplPriceDeviation() public override returns (uint256, bool) { + (uint256 marketPrice, bool marketPriceValid) = amplUsdPrice(); + (uint256 targetPrice, bool targetPriceValid) = amplTargetUsdPrice(); + bool deviationValid = (marketPriceValid && targetPriceValid); + uint256 deviation = (targetPrice > 0) + ? FullMath.mulDiv(marketPrice, ONE, targetPrice) + : type(uint256).max; + deviation = (deviation > MAX_DEVIATION) ? MAX_DEVIATION : deviation; + return (deviation, deviationValid); } - //----------------------------------------------------------------------------- - // Public methods - - /// @return If the spot token is healthy. - function isSPOTHealthy() public returns (bool) { - // If the SPOT's `deviationRatio` is lower than the defined bound - // i.e) it doesn't have enough capital to cover future rollovers, - // we consider it unhealthy. - uint256 spotDR = SPOT.deviationRatio().mulDiv(ONE, SPOT_DR_ONE); - if (spotDR < minSPOTDR) { - return false; - } - - // We compute the CDR of all the senior tranches backing perp. - // If any one of the seniors is mature or has a CDR below below the defined minimum, - // we consider it unhealthy. - // NOTE: Any CDR below 100%, means that the tranche is impaired - // and is roughly equivalent to holding AMPL. - uint8 reserveCount = uint8(SPOT.getReserveCount()); - for (uint8 i = 1; i < reserveCount; i++) { - ITranche tranche = ITranche(address(SPOT.getReserveAt(i))); - IBondController bond = IBondController(tranche.bond()); - if (bond.isMature()) { - return false; - } - uint256 seniorCDR = AMPL.balanceOf(address(bond)).mulDiv( - ONE, - tranche.totalSupply() - ); - if (seniorCDR < minSeniorCDR) { - return false; - } - } - - // If SPOT has ANY raw AMPL as collateral, we consider it unhealthy. - // NOTE: In practice some dust might exist or someone could grief this check - // by transferring some dust AMPL into the spot contract. - // We consider SPOT unhealthy if it has more than `AMPL_DUST_AMT` AMPL. - if (AMPL.balanceOf(address(SPOT)) > AMPL_DUST_AMT) { - return false; - } - - return true; + /// @inheritdoc IMetaOracle + function spotUsdPrice() public view override returns (uint256, bool) { + uint256 usdcPerSpot = UniswapV3PoolHelpers.calculateTwap( + UniswapV3PoolHelpers.getTwapTick(USDC_SPOT_POOL, TWAP_DURATION), + ONE_USDC, + ONE_SPOT, + ONE + ); + (, bool usdcPriceValid) = usdcPrice(); + return (usdcPerSpot, usdcPriceValid); } - //----------------------------------------------------------------------------- + /// @inheritdoc IMetaOracle + function amplUsdPrice() public view override returns (uint256, bool) { + (uint256 wamplPrice, bool wamplPriceValid) = wamplUsdPrice(); + uint256 amplPrice = FullMath.mulDiv( + wamplPrice, + ONE_AMPL, + WAMPL.wrapperToUnderlying(ONE_WAMPL) + ); + return (amplPrice, wamplPriceValid); + } + + /// @inheritdoc IMetaOracle + function spotFmvUsdPrice() public override returns (uint256, bool) { + (uint256 targetPrice, bool targetPriceValid) = amplTargetUsdPrice(); + return ( + FullMath.mulDiv(targetPrice, SPOT.getTVL(), SPOT.totalSupply()), + targetPriceValid + ); + } + + /// @inheritdoc IMetaOracle + function amplTargetUsdPrice() public override returns (uint256, bool) { + // NOTE: Ampleforth oracle returns price as a fixed point number with 18 decimals. + // Redenomination not required here. + return CPI_ORACLE.getData(); + } + + /// @inheritdoc IMetaOracle + function wamplUsdPrice() public view override returns (uint256, bool) { + uint256 wethPerWampl = UniswapV3PoolHelpers.calculateTwap( + UniswapV3PoolHelpers.getTwapTick(WETH_WAMPL_POOL, TWAP_DURATION), + ONE_WETH, + ONE_WAMPL, + ONE + ); + (uint256 ethPrice, bool ethPriceValid) = ethUsdPrice(); + uint256 wamplPrice = FullMath.mulDiv(ethPrice, wethPerWampl, ONE); + return (wamplPrice, ethPriceValid); + } + + /// @inheritdoc IMetaOracle + function ethUsdPrice() public view override returns (uint256, bool) { + return _getCLOracleData(ETH_ORACLE, CL_ETH_ORACLE_STALENESS_THRESHOLD_SEC); + } + + //-------------------------------------------------------------------------- // Private methods - /// @dev Fetches most recent report from the given chain link oracle contract. - /// The data is considered invalid if the latest report is stale. + /// @dev Fetches price from a given Chainlink oracle. function _getCLOracleData( IChainlinkOracle oracle, - uint256 oracleDecimals + uint256 stalenessThresholdSec ) private view returns (uint256, bool) { (, int256 p, , uint256 updatedAt, ) = oracle.latestRoundData(); - uint256 price = uint256(p).mulDiv(ONE, 10 ** oracleDecimals); - return ( - price, - (block.timestamp - updatedAt) <= CL_ORACLE_STALENESS_THRESHOLD_SEC - ); + uint256 price = FullMath.mulDiv(uint256(p), ONE, 10 ** oracle.decimals()); + return (price, (block.timestamp - updatedAt) <= stalenessThresholdSec); } } diff --git a/spot-vaults/contracts/_strategies/SpotCDRPricer.sol b/spot-vaults/contracts/_strategies/SpotCDRPricer.sol deleted file mode 100644 index 2848949d..00000000 --- a/spot-vaults/contracts/_strategies/SpotCDRPricer.sol +++ /dev/null @@ -1,126 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.24; - -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; - -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { IPerpetualTranche } from "@ampleforthorg/spot-contracts/contracts/_interfaces/IPerpetualTranche.sol"; -import { IChainlinkOracle } from "../_interfaces/external/IChainlinkOracle.sol"; -import { IAmpleforthOracle } from "../_interfaces/external/IAmpleforthOracle.sol"; -import { ISpotPricingStrategy } from "../_interfaces/ISpotPricingStrategy.sol"; - -/** - * @title SpotCDRPricer - * - * @notice Pricing strategy adapter for SPOT. - * - * SPOT is a perpetual claim on AMPL senior tranches. - * We price spot based on the redeemable value of it's collateral at maturity. - * NOTE: SPOT's internal `getTVL` prices the collateral this way. - * - * SPOT_PRICE = (spot.getTVL() / spot.totalSupply()) * AMPL_TARGET - * - * We get the AMPL target price from Ampleforth's CPI oracle, - * which is also used by the protocol to adjust AMPL supply through rebasing. - * - */ -contract SpotCDRPricer is ISpotPricingStrategy { - //------------------------------------------------------------------------- - // Libraries - using Math for uint256; - - //------------------------------------------------------------------------- - // Constants & Immutables - - uint256 private constant DECIMALS = 18; - uint256 private constant ONE = (10 ** DECIMALS); - uint256 public constant CL_ORACLE_DECIMALS = 8; - uint256 public constant CL_ORACLE_STALENESS_THRESHOLD_SEC = 3600 * 48; // 2 days - uint256 public constant USD_UPPER_BOUND = (101 * ONE) / 100; // 1.01$ - uint256 public constant USD_LOWER_BOUND = (99 * ONE) / 100; // 0.99$ - - /// @notice Address of the SPOT (perpetual tranche) ERC-20 token contract. - IPerpetualTranche public immutable SPOT; - - /// @notice Address of the AMPL ERC-20 token contract. - IERC20 public immutable AMPL; - - /// @notice Address of the USD token market price oracle. - IChainlinkOracle public immutable USD_ORACLE; - - /// @notice Number of decimals representing the prices returned by the chainlink oracle. - uint256 public immutable USD_ORACLE_DECIMALS; - - /// @notice Address of the Ampleforth CPI oracle. (provides the inflation-adjusted target price for AMPL). - IAmpleforthOracle public immutable AMPL_CPI_ORACLE; - - /// @notice Number of decimals representing the prices returned by the ampleforth oracle. - uint256 public immutable AMPL_CPI_ORACLE_DECIMALS; - - //----------------------------------------------------------------------------- - // Constructor - - /// @notice Contract constructor. - /// @param spot Address of the SPOT token. - /// @param usdOracle Address of the USD token market price oracle token. - /// @param cpiOracle Address of the Ampleforth CPI oracle. - constructor( - IPerpetualTranche spot, - IChainlinkOracle usdOracle, - IAmpleforthOracle cpiOracle - ) { - SPOT = spot; - AMPL = IERC20(address(spot.underlying())); - - USD_ORACLE = usdOracle; - USD_ORACLE_DECIMALS = usdOracle.decimals(); - - AMPL_CPI_ORACLE = cpiOracle; - AMPL_CPI_ORACLE_DECIMALS = cpiOracle.DECIMALS(); - } - - //-------------------------------------------------------------------------- - // External methods - - /// @return p The price of the usd token in dollars. - /// @return v True if the price is valid and can be used by downstream consumers. - function usdPrice() external view override returns (uint256, bool) { - (uint256 p, bool v) = _getCLOracleData(USD_ORACLE, USD_ORACLE_DECIMALS); - // If the market price of the USD coin deviated too much from 1$, - // it's an indication of some systemic issue with the USD token - // and thus its price should be considered unreliable. - return (ONE, (v && p < USD_UPPER_BOUND && p > USD_LOWER_BOUND)); - } - - /// @return p The price of the spot token in dollar coins. - /// @return v True if the price is valid and can be used by downstream consumers. - function perpPrice() external override returns (uint256, bool) { - // NOTE: Since {DECIMALS} == {AMPL_CPI_ORACLE_DECIMALS} == 18 - // we don't adjust the returned values. - (uint256 targetPrice, bool targetPriceValid) = AMPL_CPI_ORACLE.getData(); - uint256 p = targetPrice.mulDiv(SPOT.getTVL(), SPOT.totalSupply()); - return (p, targetPriceValid); - } - - /// @return Number of decimals representing a price of 1.0 USD. - function decimals() external pure override returns (uint8) { - return uint8(DECIMALS); - } - - //----------------------------------------------------------------------------- - // Private methods - - /// @dev Fetches most recent report from the given chain link oracle contract. - /// The data is considered invalid if the latest report is stale. - function _getCLOracleData( - IChainlinkOracle oracle, - uint256 oracleDecimals - ) private view returns (uint256, bool) { - (, int256 p, , uint256 updatedAt, ) = oracle.latestRoundData(); - uint256 price = uint256(p).mulDiv(ONE, 10 ** oracleDecimals); - return ( - price, - (block.timestamp - updatedAt) <= CL_ORACLE_STALENESS_THRESHOLD_SEC - ); - } -} diff --git a/spot-vaults/contracts/_utils/UniswapV3PoolHelpers.sol b/spot-vaults/contracts/_utils/UniswapV3PoolHelpers.sol new file mode 100644 index 00000000..8c44d3cd --- /dev/null +++ b/spot-vaults/contracts/_utils/UniswapV3PoolHelpers.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// solhint-disable-next-line compiler-version +pragma solidity ^0.7.6; + +import { FullMath } from "@uniswap/v3-core/contracts/libraries/FullMath.sol"; +import { TickMath } from "@uniswap/v3-core/contracts/libraries/TickMath.sol"; +import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; + +/** + * @title UniswapV3PoolHelpers + * + * @notice Library with helper functions for a UniswapV3Pool. + * + */ +library UniswapV3PoolHelpers { + /// @notice Calculates the Time-Weighted Average Price (TWAP) given the TWAP tick and unit token amounts. + /// @param twapTick The Time-Weighted Average Price tick. + /// @param token0UnitAmt The fixed-point amount of token0 equivalent to 1.0. + /// @param token1UnitAmt The fixed-point amount of token1 equivalent to 1.0. + /// @param one 1.0 represented in the same fixed point denomination as calculated TWAP. + /// @return The computed TWAP price. + function calculateTwap( + int24 twapTick, + uint256 token0UnitAmt, + uint256 token1UnitAmt, + uint256 one + ) internal pure returns (uint256) { + uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(twapTick); + uint256 ratioX192 = uint256(sqrtPriceX96) * sqrtPriceX96; + uint256 twapPrice = FullMath.mulDiv(one, (1 << 192), ratioX192); + return FullMath.mulDiv(twapPrice, token1UnitAmt, token0UnitAmt); + } + + /// @notice Retrieves the Time-Weighted Average Price (TWAP) tick from a Uniswap V3 pool over a given duration. + /// @param pool The Uniswap V3 pool. + /// @param twapDuration The TWAP duration. + /// @return The TWAP tick. + function getTwapTick( + IUniswapV3Pool pool, + uint32 twapDuration + ) internal view returns (int24) { + uint32[] memory secondsAgo = new uint32[](2); + secondsAgo[0] = twapDuration; + secondsAgo[1] = 0; + (int56[] memory tickCumulatives, ) = pool.observe(secondsAgo); + return int24((tickCumulatives[1] - tickCumulatives[0]) / twapDuration); + } +} From 21ed063584731810ece2cac7650edb02521ef9d5 Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Tue, 17 Sep 2024 19:37:59 -0400 Subject: [PATCH 03/10] updated external interfaces --- .../_interfaces/external/IBondController.sol | 5 +++++ .../contracts/_interfaces/external/IERC20.sol | 14 ++++++++++++++ .../_interfaces/external/IPerpFeePolicy.sol | 7 +++++++ .../_interfaces/external/IPerpetualTranche.sol | 13 +++++++++++++ .../contracts/_interfaces/external/ITranche.sol | 6 ++++++ .../contracts/_interfaces/external/IWAMPL.sol | 2 -- .../{SpotAppraiser.sol => SpotPricer.sol} | 0 7 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 spot-vaults/contracts/_interfaces/external/IBondController.sol create mode 100644 spot-vaults/contracts/_interfaces/external/IERC20.sol create mode 100644 spot-vaults/contracts/_interfaces/external/IPerpFeePolicy.sol create mode 100644 spot-vaults/contracts/_interfaces/external/IPerpetualTranche.sol create mode 100644 spot-vaults/contracts/_interfaces/external/ITranche.sol rename spot-vaults/contracts/_strategies/{SpotAppraiser.sol => SpotPricer.sol} (100%) diff --git a/spot-vaults/contracts/_interfaces/external/IBondController.sol b/spot-vaults/contracts/_interfaces/external/IBondController.sol new file mode 100644 index 00000000..5488c920 --- /dev/null +++ b/spot-vaults/contracts/_interfaces/external/IBondController.sol @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// solhint-disable-next-line compiler-version +interface IBondController { + function collateralBalance() external view returns (uint256); +} diff --git a/spot-vaults/contracts/_interfaces/external/IERC20.sol b/spot-vaults/contracts/_interfaces/external/IERC20.sol new file mode 100644 index 00000000..caab7e44 --- /dev/null +++ b/spot-vaults/contracts/_interfaces/external/IERC20.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// solhint-disable-next-line compiler-version +interface IERC20 { + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 value) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function approve(address spender, uint256 value) external returns (bool); + function transferFrom( + address from, + address to, + uint256 value + ) external returns (bool); +} diff --git a/spot-vaults/contracts/_interfaces/external/IPerpFeePolicy.sol b/spot-vaults/contracts/_interfaces/external/IPerpFeePolicy.sol new file mode 100644 index 00000000..b4aba5c9 --- /dev/null +++ b/spot-vaults/contracts/_interfaces/external/IPerpFeePolicy.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// solhint-disable-next-line compiler-version +interface IPerpFeePolicy { + function decimals() external returns (uint8); + function deviationRatio() external returns (uint256); + function computePerpRolloverFeePerc(uint256 dr) external returns (int256); +} diff --git a/spot-vaults/contracts/_interfaces/external/IPerpetualTranche.sol b/spot-vaults/contracts/_interfaces/external/IPerpetualTranche.sol new file mode 100644 index 00000000..a730f331 --- /dev/null +++ b/spot-vaults/contracts/_interfaces/external/IPerpetualTranche.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// solhint-disable-next-line compiler-version +interface IPerpetualTranche { + function underlying() external view returns (address); + function getTVL() external returns (uint256); + function totalSupply() external returns (uint256); + function getReserveCount() external returns (uint256); + function getReserveAt(uint256 index) external returns (address); + function deviationRatio() external returns (uint256); + function getReserveTokenValue(address t) external returns (uint256); + function getReserveTokenBalance(address t) external returns (uint256); + function feePolicy() external returns (address); +} diff --git a/spot-vaults/contracts/_interfaces/external/ITranche.sol b/spot-vaults/contracts/_interfaces/external/ITranche.sol new file mode 100644 index 00000000..51a582d3 --- /dev/null +++ b/spot-vaults/contracts/_interfaces/external/ITranche.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// solhint-disable-next-line compiler-version +interface ITranche { + function bond() external view returns (address); + function totalSupply() external view returns (uint256); +} diff --git a/spot-vaults/contracts/_interfaces/external/IWAMPL.sol b/spot-vaults/contracts/_interfaces/external/IWAMPL.sol index 0176dd22..b5f24a1a 100644 --- a/spot-vaults/contracts/_interfaces/external/IWAMPL.sol +++ b/spot-vaults/contracts/_interfaces/external/IWAMPL.sol @@ -1,7 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later // solhint-disable-next-line compiler-version -pragma solidity ^0.7.6; - interface IWAMPL { function wrapperToUnderlying(uint256 wamples) external view returns (uint256); } diff --git a/spot-vaults/contracts/_strategies/SpotAppraiser.sol b/spot-vaults/contracts/_strategies/SpotPricer.sol similarity index 100% rename from spot-vaults/contracts/_strategies/SpotAppraiser.sol rename to spot-vaults/contracts/_strategies/SpotPricer.sol From 6fe76d3e92b2e15cbedd0e4d97a891d3623dda0c Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Tue, 17 Sep 2024 20:18:50 -0400 Subject: [PATCH 04/10] fixed typo in contract docstring --- spot-vaults/contracts/_strategies/SpotPricer.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spot-vaults/contracts/_strategies/SpotPricer.sol b/spot-vaults/contracts/_strategies/SpotPricer.sol index 6e965706..3c1899c7 100644 --- a/spot-vaults/contracts/_strategies/SpotPricer.sol +++ b/spot-vaults/contracts/_strategies/SpotPricer.sol @@ -139,7 +139,7 @@ contract SpotPricer is IPerpPricer, IMetaOracle { // Owner only methods /// @notice Updates spot's discount factor. - /// @param p New discount factor. + /// @param d New discount factor. function updateSpotDiscountFactor(uint256 d) external onlyOwner { spotDiscountFactor = d; } From 4b87fa92336e1394ffb441cea3bd3b370fa2f308 Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Tue, 17 Sep 2024 20:19:23 -0400 Subject: [PATCH 05/10] made perp beta view method --- spot-vaults/contracts/_strategies/SpotPricer.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spot-vaults/contracts/_strategies/SpotPricer.sol b/spot-vaults/contracts/_strategies/SpotPricer.sol index 3c1899c7..edb91215 100644 --- a/spot-vaults/contracts/_strategies/SpotPricer.sol +++ b/spot-vaults/contracts/_strategies/SpotPricer.sol @@ -179,7 +179,7 @@ contract SpotPricer is IPerpPricer, IMetaOracle { } /// @inheritdoc IPerpPricer - function perpBeta() external override returns (uint256, bool) { + function perpBeta() external view override returns (uint256, bool) { return (spotDiscountFactor, true); } From 5878b4199bb6612ac6da45f66f81ba82a9433537 Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Tue, 17 Sep 2024 20:20:20 -0400 Subject: [PATCH 06/10] setting discount initial value --- spot-vaults/contracts/_strategies/SpotPricer.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spot-vaults/contracts/_strategies/SpotPricer.sol b/spot-vaults/contracts/_strategies/SpotPricer.sol index edb91215..0f0dbd9b 100644 --- a/spot-vaults/contracts/_strategies/SpotPricer.sol +++ b/spot-vaults/contracts/_strategies/SpotPricer.sol @@ -133,6 +133,8 @@ contract SpotPricer is IPerpPricer, IMetaOracle { IPerpetualTranche spot = IPerpetualTranche(usdcSpotPool.token1()); SPOT = spot; AMPL = IERC20(spot.underlying()); + + spotDiscountFactor = ONE; } //-------------------------------------------------------------------------- From f74a65a9c5ed2632768f1a93880b449cc428e516 Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Mon, 16 Sep 2024 18:15:52 -0400 Subject: [PATCH 07/10] updated charm managers to use the new pricer --- spot-vaults/contracts/UsdcSpotManager.sol | 202 +++------- spot-vaults/contracts/WethWamplManager.sol | 363 ++++-------------- .../_interfaces/external/IAlphaProVault.sol | 3 +- .../contracts/_utils/AlphaVaultHelpers.sol | 90 +++++ 4 files changed, 237 insertions(+), 421 deletions(-) create mode 100644 spot-vaults/contracts/_utils/AlphaVaultHelpers.sol diff --git a/spot-vaults/contracts/UsdcSpotManager.sol b/spot-vaults/contracts/UsdcSpotManager.sol index 05d985b3..51f64968 100644 --- a/spot-vaults/contracts/UsdcSpotManager.sol +++ b/spot-vaults/contracts/UsdcSpotManager.sol @@ -1,98 +1,65 @@ // SPDX-License-Identifier: BUSL-1.1 -// solhint-disable-next-line compiler-version -pragma solidity ^0.7.6; -pragma abicoder v2; +pragma solidity ^0.8.24; -import { FullMath } from "@uniswap/v3-core/contracts/libraries/FullMath.sol"; -import { TickMath } from "@uniswap/v3-core/contracts/libraries/TickMath.sol"; -import { PositionKey } from "@uniswap/v3-periphery/contracts/libraries/PositionKey.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { AlphaVaultHelpers } from "../_utils/AlphaVaultHelpers.sol"; -import { ISpotPricingStrategy } from "./_interfaces/ISpotPricingStrategy.sol"; -import { IAlphaProVault } from "./_interfaces/external/IAlphaProVault.sol"; +import { IMetaOracle } from "../_interfaces/IMetaOracle.sol"; +import { IAlphaProVault } from "../_interfaces/external/IAlphaProVault.sol"; import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; /// @title UsdcSpotManager /// @notice This contract is a programmatic manager for the USDC-SPOT Charm AlphaProVault. -contract UsdcSpotManager { - /// @dev Token Constants. - uint256 public constant ONE_SPOT = 1e9; - uint256 public constant ONE_USDC = 1e6; +contract UsdcSpotManager is Ownable { + //------------------------------------------------------------------------- + // Libraries + using AlphaVaultHelpers for IAlphaProVault; + + //------------------------------------------------------------------------- + // Constants & Immutables /// @dev Decimals. uint256 public constant DECIMALS = 18; uint256 public constant ONE = (10 ** DECIMALS); - /// @dev We bound the deviation factor to 100.0. - uint256 public constant MAX_DEVIATION = 100 * ONE; // 100.0 - - //------------------------------------------------------------------------- - // Storage - /// @notice The USDC-SPOT charm alpha vault. IAlphaProVault public immutable VAULT; /// @notice The underlying USDC-SPOT univ3 pool. IUniswapV3Pool public immutable POOL; - /// @notice The vault's token0, the USDC token. - address public immutable USDC; - - /// @notice The vault's token1, the SPOT token. - address public immutable SPOT; - - /// @notice Pricing strategy to price the SPOT token. - ISpotPricingStrategy public pricingStrategy; - - /// @notice The contract owner. - address public owner; - //------------------------------------------------------------------------- - // Manager storage + // Storage + + /// @notice The meta oracle which returns prices of AMPL asset family. + IMetaOracle public oracle; /// @notice The recorded deviation factor at the time of the last successful rebalance operation. uint256 public prevDeviation; - //-------------------------------------------------------------------------- - // Modifiers - - modifier onlyOwner() { - // solhint-disable-next-line custom-errors - require(msg.sender == owner, "Unauthorized caller"); - _; - } - //----------------------------------------------------------------------------- // Constructor and Initializer /// @notice Constructor initializes the contract with provided addresses. /// @param vault_ Address of the AlphaProVault contract. - /// @param pricingStrategy_ Address of the spot appraiser. - constructor(IAlphaProVault vault_, ISpotPricingStrategy pricingStrategy_) { - owner = msg.sender; - + /// @param oracle_ Address of the MetaOracle contract. + constructor(IAlphaProVault vault_, IMetaOracle oracle_) Ownable() { VAULT = vault_; POOL = vault_.pool(); - USDC = vault_.token0(); - SPOT = vault_.token1(); - pricingStrategy = pricingStrategy_; - // solhint-disable-next-line custom-errors - require(pricingStrategy.decimals() == DECIMALS, "Invalid decimals"); + updateOracle(oracle_); + + prevDeviation = 0; } //-------------------------------------------------------------------------- // Owner only methods - /// @notice Updates the owner role. - function transferOwnership(address owner_) external onlyOwner { - owner = owner_; - } - - /// @notice Updates the Spot pricing strategy reference. - function updatePricingStrategy( - ISpotPricingStrategy pricingStrategy_ - ) external onlyOwner { - pricingStrategy = pricingStrategy_; + /// @notice Updates the MetaOracle. + function updateOracle(IMetaOracle oracle_) public onlyOwner { + // solhint-disable-next-line custom-errors + require(DECIMALS == oracle_.decimals(), "UnexpectedDecimals"); + oracle = oracle_; } /// @notice Updates the vault's liquidity range parameters. @@ -116,7 +83,7 @@ contract UsdcSpotManager { // solhint-disable-next-line avoid-low-level-calls (bool success, bytes memory r) = address(VAULT).call(callData); // solhint-disable-next-line custom-errors - require(success, "Vault call failed"); + require(success, "VaultExecutionFailed"); return r; } @@ -125,107 +92,60 @@ contract UsdcSpotManager { /// @notice Executes vault rebalance. function rebalance() public { - (uint256 deviation, bool deviationValid) = computeDeviationFactor(); - - // We rebalance if the deviation factor has crossed ONE (in either direction). - bool forceLiquidityUpdate = ((deviation <= ONE && prevDeviation > ONE) || - (deviation >= ONE && prevDeviation < ONE)); + (uint256 deviation, bool deviationValid) = oracle.spotPriceDeviation(); // Execute rebalance. // NOTE: the vault.rebalance() will revert if enough time has not elapsed. // We thus override with a force rebalance. // https://learn.charm.fi/charm/technical-references/core/alphaprovault#rebalance - forceLiquidityUpdate ? _execForceRebalance() : VAULT.rebalance(); - - // We only activate the limit range liquidity, when - // the vault sells SPOT and deviation is above ONE, or when - // the vault buys SPOT and deviation is below ONE - bool extraSpot = isOverweightSpot(); - bool activeLimitRange = deviationValid && - ((deviation >= ONE && extraSpot) || (deviation <= ONE && !extraSpot)); + (deviationValid && shouldForceRebalance(deviation, prevDeviation)) + ? VAULT.forceRebalance() + : VAULT.rebalance(); // Trim positions after rebalance. - if (!activeLimitRange) { - _removeLimitLiquidity(); + if (!deviationValid || shouldRemoveLimitRange(deviation)) { + VAULT.removeLimitLiquidity(POOL); } - // Update rebalance state. - prevDeviation = deviation; + // Update valid rebalance state. + if (deviationValid) { + prevDeviation = deviation; + } } - /// @notice Computes the deviation between SPOT's market price and it's FMV price. - /// @return The computed deviation factor. - function computeDeviationFactor() public returns (uint256, bool) { - uint256 spotMarketPrice = getSpotUSDPrice(); - (uint256 spotTargetPrice, bool spotTargetPriceValid) = pricingStrategy - .perpPrice(); - (, bool usdcPriceValid) = pricingStrategy.usdPrice(); - bool deviationValid = (spotTargetPriceValid && usdcPriceValid); - uint256 deviation = spotTargetPrice > 0 - ? FullMath.mulDiv(spotMarketPrice, ONE, spotTargetPrice) - : type(uint256).max; - deviation = (deviation > MAX_DEVIATION) ? MAX_DEVIATION : deviation; - return (deviation, deviationValid); + //----------------------------------------------------------------------------- + // External/Public view methods + + /// @notice Checks if a rebalance has to be forced. + function shouldForceRebalance( + uint256 deviation, + uint256 prevDeviation_ + ) public pure returns (bool) { + // We rebalance if the deviation factor has crossed ONE (in either direction). + return ((deviation <= ONE && prevDeviation_ > ONE) || + (deviation >= ONE && prevDeviation_ < ONE)); } - //----------------------------------------------------------------------------- - // External Public view methods - - /// @return The computed SPOT price in USD from the underlying univ3 pool. - function getSpotUSDPrice() public view returns (uint256) { - uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(VAULT.getTwap()); - uint256 ratioX192 = uint256(sqrtPriceX96) * sqrtPriceX96; - uint256 usdcPerSpot = FullMath.mulDiv(ONE, (1 << 192), ratioX192); - return FullMath.mulDiv(usdcPerSpot, ONE_SPOT, ONE_USDC); + /// @notice Checks if limit range liquidity needs to be removed. + function shouldRemoveLimitRange(uint256 deviation) public view returns (bool) { + // We only activate the limit range liquidity, when + // the vault sells SPOT and deviation is above ONE, or when + // the vault buys SPOT and deviation is below ONE + bool extraSpot = isOverweightSpot(); + bool activeLimitRange = ((deviation >= ONE && extraSpot) || + (deviation <= ONE && !extraSpot)); + return (!activeLimitRange); } - /// @notice Checks the vault is overweight SPOT, and looking to sell the extra SPOT for USDC. + /// @notice Checks the vault is overweight SPOT and looking to sell the extra SPOT for USDC. function isOverweightSpot() public view returns (bool) { - // NOTE: This assumes that in the underlying univ3 pool and - // token0 is USDC and token1 is SPOT. - int24 _marketPrice = VAULT.getTwap(); - int24 _limitLower = VAULT.limitLower(); - int24 _limitUpper = VAULT.limitUpper(); - int24 _limitPrice = (_limitLower + _limitUpper) / 2; - // The limit range has more token1 than token0 if `_marketPrice >= _limitPrice`, - // so the vault looks to sell token1. - return (_marketPrice >= _limitPrice); + // NOTE: In the underlying univ3 pool and token0 is USDC and token1 is SPOT. + // Underweight Token0 implies that the limit range has less USDC and more SPOT. + return VAULT.isUnderweightToken0(); } /// @return Number of decimals representing 1.0. function decimals() external pure returns (uint8) { return uint8(DECIMALS); } - - //----------------------------------------------------------------------------- - // Private methods - - /// @dev A low-level method, which interacts directly with the vault and executes - /// a rebalance even when enough time hasn't elapsed since the last rebalance. - function _execForceRebalance() private { - uint32 _period = VAULT.period(); - VAULT.setPeriod(0); - VAULT.rebalance(); - VAULT.setPeriod(_period); - } - - /// @dev Removes the vault's limit range liquidity. - /// To be invoked right after a rebalance operation, as it assumes that - /// the vault has a active limit range liquidity. - function _removeLimitLiquidity() private { - int24 _limitLower = VAULT.limitLower(); - int24 _limitUpper = VAULT.limitUpper(); - (uint128 limitLiquidity, , , , ) = _position(_limitLower, _limitUpper); - // docs: https://learn.charm.fi/charm/technical-references/core/alphaprovault#emergencyburn - VAULT.emergencyBurn(_limitLower, _limitUpper, limitLiquidity); - } - - /// @dev Wrapper around `IUniswapV3Pool.positions()`. - function _position( - int24 tickLower, - int24 tickUpper - ) private view returns (uint128, uint256, uint256, uint128, uint128) { - bytes32 positionKey = PositionKey.compute(address(VAULT), tickLower, tickUpper); - return POOL.positions(positionKey); - } } diff --git a/spot-vaults/contracts/WethWamplManager.sol b/spot-vaults/contracts/WethWamplManager.sol index b51cc16c..6af065e0 100644 --- a/spot-vaults/contracts/WethWamplManager.sol +++ b/spot-vaults/contracts/WethWamplManager.sol @@ -1,25 +1,27 @@ // SPDX-License-Identifier: BUSL-1.1 -// solhint-disable-next-line compiler-version -pragma solidity ^0.7.6; -pragma abicoder v2; - -import { FullMath } from "@uniswap/v3-core/contracts/libraries/FullMath.sol"; -import { TickMath } from "@uniswap/v3-core/contracts/libraries/TickMath.sol"; -import { PositionKey } from "@uniswap/v3-periphery/contracts/libraries/PositionKey.sol"; -import { SafeCast } from "@uniswap/v3-core/contracts/libraries/SafeCast.sol"; - -import { IAlphaProVault } from "./_interfaces/external/IAlphaProVault.sol"; -import { IChainlinkOracle } from "./_interfaces/external/IChainlinkOracle.sol"; -import { IAmpleforthOracle } from "./_interfaces/external/IAmpleforthOracle.sol"; -import { IWAMPL } from "./_interfaces/external/IWAMPL.sol"; +pragma solidity ^0.8.24; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { AlphaVaultHelpers } from "../_utils/AlphaVaultHelpers.sol"; +import { LineHelpers } from "../_utils/LineHelpers.sol"; +import { Line } from "../_interfaces/types/CommonTypes.sol"; + +import { IMetaOracle } from "../_interfaces/IMetaOracle.sol"; +import { IAlphaProVault } from "../_interfaces/external/IAlphaProVault.sol"; import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; /// @title WethWamplManager /// @notice This contract is a programmatic manager for the WETH-WAMPL Charm AlphaProVault. -contract WethWamplManager { - /// @dev Constants for AMPL and WAMPL units and supply limits. - uint256 public constant ONE_AMPL = 1e9; - uint256 public constant ONE_WAMPL = 1e18; +contract WethWamplManager is Ownable { + //------------------------------------------------------------------------- + // Libraries + using AlphaVaultHelpers for IAlphaProVault; + using SafeCast for uint256; + using LineHelpers for Line; + + //------------------------------------------------------------------------- + // Constants & Immutables /// @dev Decimals. uint256 public constant DECIMALS = 18; @@ -28,35 +30,17 @@ contract WethWamplManager { /// @dev At all times active liquidity percentage is no lower than 20%. uint256 public constant MIN_ACTIVE_LIQ_PERC = ONE / 5; // 20% - /// @dev We bound the deviation factor to 100.0. - uint256 public constant MAX_DEVIATION = 100 * ONE; // 100.0 - - /// @dev Oracle constants. - uint256 public constant CL_ORACLE_STALENESS_THRESHOLD_SEC = 3600 * 24; // 1 day - - //------------------------------------------------------------------------- - // Storage - /// @notice The WETH-WAMPL charm alpha vault. IAlphaProVault public immutable VAULT; /// @notice The underlying WETH-WAMPL univ3 pool. IUniswapV3Pool public immutable POOL; - /// @notice The vault's token0, the WETH token. - address public immutable WETH; - - /// @notice The vault's token1, the WAMPL token. - IWAMPL public immutable WAMPL; - - /// @notice The cpi oracle which returns AMPL's price target in USD. - IAmpleforthOracle public cpiOracle; - - /// @notice The chainlink oracle which returns ETH's current USD price. - IChainlinkOracle public ethOracle; + //------------------------------------------------------------------------- + // Storage - /// @notice The contract owner. - address public owner; + /// @notice The meta oracle which returns prices of AMPL asset family. + IMetaOracle public oracle; //------------------------------------------------------------------------- // Active percentage calculation parameters @@ -77,67 +61,30 @@ contract WethWamplManager { // as a fixed-point number with {DECIMALS} places. // - /// @notice A data structure to define a geometric Line with two points. - struct Line { - // x-coordinate of the first point. - uint256 x1; - // y-coordinate of the first point. - uint256 y1; - // x-coordinate of the second point. - uint256 x2; - // y-coordinate of the second point. - uint256 y2; - } - /// @notice Active percentage calculation function for when deviation is below ONE. Line public activeLiqPercFn1; /// @notice Active percentage calculation function for when deviation is above ONE. Line public activeLiqPercFn2; - //------------------------------------------------------------------------- - // Manager parameters - /// @notice The delta between the current and last recorded active liquidity percentage values /// outside which a rebalance is executed forcefully. uint256 public tolerableActiveLiqPercDelta; - //------------------------------------------------------------------------- - // Manager storage - /// @notice The recorded deviation factor at the time of the last successful rebalance operation. uint256 public prevDeviation; - //-------------------------------------------------------------------------- - // Modifiers - - modifier onlyOwner() { - // solhint-disable-next-line custom-errors - require(msg.sender == owner, "Unauthorized caller"); - _; - } - //----------------------------------------------------------------------------- // Constructor and Initializer /// @notice Constructor initializes the contract with provided addresses. /// @param vault_ Address of the AlphaProVault contract. - /// @param cpiOracle_ Address of the Ampleforth CPI oracle contract. - /// @param ethOracle_ Address of the Chainlink ETH price oracle contract. - constructor( - IAlphaProVault vault_, - IAmpleforthOracle cpiOracle_, - IChainlinkOracle ethOracle_ - ) { - owner = msg.sender; - + /// @param oracle_ Address of the MetaOracle contract. + constructor(IAlphaProVault vault_, IMetaOracle oracle_) Ownable() { VAULT = vault_; POOL = vault_.pool(); - WETH = vault_.token0(); - WAMPL = IWAMPL(vault_.token1()); - cpiOracle = cpiOracle_; - ethOracle = ethOracle_; + updateOracle(oracle_); activeLiqPercFn1 = Line({ x1: ONE / 2, // 0.5 @@ -159,30 +106,11 @@ contract WethWamplManager { //-------------------------------------------------------------------------- // Owner only methods - /// @notice Updates the owner role. - function transferOwnership(address owner_) external onlyOwner { - owner = owner_; - } - - /// @notice Updates the ampleforth cpi oracle. - function setCpiOracle(IAmpleforthOracle cpiOracle_) external onlyOwner { - cpiOracle = cpiOracle_; - } - - /// @notice Updates the chainlink eth usd price oracle. - function setEthOracle(IChainlinkOracle ethOracle_) external onlyOwner { - ethOracle = ethOracle_; - } - - /// @notice Updates the active liquidity percentage calculation parameters. - function setActivePercParams( - uint256 tolerableActiveLiqPercDelta_, - Line memory activeLiqPercFn1_, - Line memory activeLiqPercFn2_ - ) external onlyOwner { - tolerableActiveLiqPercDelta = tolerableActiveLiqPercDelta_; - activeLiqPercFn1 = activeLiqPercFn1_; - activeLiqPercFn2 = activeLiqPercFn2_; + /// @notice Updates the MetaOracle. + function updateOracle(IMetaOracle oracle_) public onlyOwner { + // solhint-disable-next-line custom-errors + require(DECIMALS == oracle_.decimals(), "UnexpectedDecimals"); + oracle = oracle_; } /// @notice Updates the vault's liquidity range parameters. @@ -206,68 +134,57 @@ contract WethWamplManager { // solhint-disable-next-line avoid-low-level-calls (bool success, bytes memory r) = address(VAULT).call(callData); // solhint-disable-next-line custom-errors - require(success, "Vault call failed"); + require(success, "VaultExecutionFailed"); return r; } + /// @notice Updates the active liquidity percentage calculation parameters. + function setActivePercParams( + uint256 tolerableActiveLiqPercDelta_, + Line memory activeLiqPercFn1_, + Line memory activeLiqPercFn2_ + ) external onlyOwner { + tolerableActiveLiqPercDelta = tolerableActiveLiqPercDelta_; + activeLiqPercFn1 = activeLiqPercFn1_; + activeLiqPercFn2 = activeLiqPercFn2_; + } + //-------------------------------------------------------------------------- // External write methods /// @notice Executes vault rebalance. function rebalance() public { // Get the current deviation factor. - (uint256 deviation, bool deviationValid) = computeDeviationFactor(); + (uint256 deviation, bool deviationValid) = oracle.amplPriceDeviation(); - // Calculate the current active liquidity percentage. + // Calculate the active liquidity percentages. uint256 activeLiqPerc = deviationValid ? computeActiveLiqPerc(deviation) : MIN_ACTIVE_LIQ_PERC; - - // We have to rebalance out of turn - // - if the active liquidity perc has deviated significantly, or - // - if the deviation factor has crossed ONE (in either direction). uint256 prevActiveLiqPerc = computeActiveLiqPerc(prevDeviation); uint256 activeLiqPercDelta = (activeLiqPerc > prevActiveLiqPerc) ? activeLiqPerc - prevActiveLiqPerc : prevActiveLiqPerc - activeLiqPerc; - bool forceLiquidityUpdate = (activeLiqPercDelta > tolerableActiveLiqPercDelta) || - ((deviation <= ONE && prevDeviation > ONE) || - (deviation >= ONE && prevDeviation < ONE)); // Execute rebalance. // NOTE: the vault.rebalance() will revert if enough time has not elapsed. // We thus override with a force rebalance. // https://learn.charm.fi/charm/technical-references/core/alphaprovault#rebalance - forceLiquidityUpdate ? _execForceRebalance() : VAULT.rebalance(); - - // We only activate the limit range liquidity, when - // the vault sells WAMPL and deviation is above ONE, or when - // the vault buys WAMPL and deviation is below ONE - bool extraWampl = isOverweightWampl(); - bool activeLimitRange = deviationValid && - ((deviation >= ONE && extraWampl) || (deviation <= ONE && !extraWampl)); + (deviationValid && + shouldForceRebalance(deviation, prevDeviation, activeLiqPercDelta)) + ? VAULT.forceRebalance() + : VAULT.rebalance(); // Trim positions after rebalance. - _trimLiquidity(activeLiqPerc, activeLimitRange); - - // Update rebalance state. - prevDeviation = deviation; - } + VAULT.trimLiquidity(POOL, ONE - activeLiqPerc, ONE); + if (!deviationValid || shouldRemoveLimitRange(deviation)) { + VAULT.removeLimitLiquidity(POOL); + } - /// @notice Computes the deviation between AMPL's market price and target. - /// @return The computed deviation factor. - function computeDeviationFactor() public returns (uint256, bool) { - (uint256 ethUSDPrice, bool ethPriceValid) = getEthUSDPrice(); - uint256 marketPrice = getAmplUSDPrice(ethUSDPrice); - (uint256 targetPrice, bool targetPriceValid) = _getAmpleforthOracleData( - cpiOracle - ); - bool deviationValid = (ethPriceValid && targetPriceValid); - uint256 deviation = (targetPrice > 0) - ? FullMath.mulDiv(marketPrice, ONE, targetPrice) - : type(uint256).max; - deviation = (deviation > MAX_DEVIATION) ? MAX_DEVIATION : deviation; - return (deviation, deviationValid); + // Update valid rebalance state. + if (deviationValid) { + prevDeviation = deviation; + } } //----------------------------------------------------------------------------- @@ -276,156 +193,46 @@ contract WethWamplManager { /// @notice Computes active liquidity percentage based on the provided deviation factor. /// @return The computed active liquidity percentage. function computeActiveLiqPerc(uint256 deviation) public view returns (uint256) { - return - (deviation <= ONE) - ? _computeActiveLiqPerc(activeLiqPercFn1, deviation) - : _computeActiveLiqPerc(activeLiqPercFn2, deviation); + Line memory fn = (deviation <= ONE) ? activeLiqPercFn1 : activeLiqPercFn2; + return fn.computeY(deviation, MIN_ACTIVE_LIQ_PERC, ONE); } - /// @notice Computes the AMPL price in USD. - /// @param ethUSDPrice The ETH price in USD. - /// @return The computed AMPL price in USD. - function getAmplUSDPrice(uint256 ethUSDPrice) public view returns (uint256) { + /// @notice Checks if a rebalance has to be forced. + function shouldForceRebalance( + uint256 deviation, + uint256 prevDeviation_, + uint256 activeLiqPercDelta + ) public view returns (bool) { + // We have to rebalance out of turn + // - if the active liquidity perc has deviated significantly, or + // - if the deviation factor has crossed ONE (in either direction). return - FullMath.mulDiv( - getWamplUSDPrice(ethUSDPrice), - ONE_AMPL, - WAMPL.wrapperToUnderlying(ONE_WAMPL) // #AMPL per WAMPL - ); - } - - /// @notice Computes the WAMPL price in USD based on ETH price. - /// @param ethUSDPrice The ETH price in USD. - /// @return The computed WAMPL price in USD. - function getWamplUSDPrice(uint256 ethUSDPrice) public view returns (uint256) { - // We first get the WETH-WAMPL price from the pool and then convert that - // to a USD price using the given ETH-USD price. - uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(VAULT.getTwap()); - uint256 ratioX192 = uint256(sqrtPriceX96) * sqrtPriceX96; - // NOTE: Since both weth and wampl have 18 decimals, - // we don't adjust the `wamplPerWeth`. - uint256 wamplPerWeth = FullMath.mulDiv(ONE, ratioX192, (1 << 192)); - return FullMath.mulDiv(ethUSDPrice, ONE, wamplPerWeth); + (activeLiqPercDelta > tolerableActiveLiqPercDelta) || + ((deviation <= ONE && prevDeviation_ > ONE) || + (deviation >= ONE && prevDeviation_ < ONE)); } - /// @notice Fetches the current ETH price in USD from the Chainlink oracle. - /// @return The ETH price in USD and its validity. - function getEthUSDPrice() public view returns (uint256, bool) { - return _getCLOracleData(ethOracle); + /// @notice Checks if limit range liquidity needs to be removed. + function shouldRemoveLimitRange(uint256 deviation) public view returns (bool) { + // We only activate the limit range liquidity, when + // the vault sells WAMPL and deviation is above ONE, or when + // the vault buys WAMPL and deviation is below ONE + bool extraWampl = isOverweightWampl(); + bool activeLimitRange = ((deviation >= ONE && extraWampl) || + (deviation <= ONE && !extraWampl)); + return (!activeLimitRange); } - /// @notice Checks the vault is overweight WAMPL, and looking to sell the extra WAMPL for WETH. + /// @notice Checks the vault is overweight WAMPL, + /// and looking to sell the extra WAMPL for WETH. function isOverweightWampl() public view returns (bool) { - // NOTE: This assumes that in the underlying univ3 pool and - // token0 is WETH and token1 is WAMPL. - int24 _marketPrice = VAULT.getTwap(); - int24 _limitLower = VAULT.limitLower(); - int24 _limitUpper = VAULT.limitUpper(); - int24 _limitPrice = (_limitLower + _limitUpper) / 2; - // The limit range has more token1 than token0 if `_marketPrice >= _limitPrice`, - // so the vault looks to sell token1. - return (_marketPrice >= _limitPrice); + // NOTE: In the underlying univ3 pool and token0 is WETH and token1 is WAMPL. + // Underweight Token0 implies that the limit range has less WETH and more WAMPL. + return VAULT.isUnderweightToken0(); } /// @return Number of decimals representing 1.0. function decimals() external pure returns (uint8) { return uint8(DECIMALS); } - - //----------------------------------------------------------------------------- - // Private methods - - /// @dev Trims the vault's current liquidity. - /// To be invoked right after a rebalance operation, as it assumes that all of the vault's - /// liquidity has been deployed before trimming. - function _trimLiquidity(uint256 activePerc, bool activeLimitRange) private { - // Calculated baseLiquidityToBurn, baseLiquidityToBurn will be lesser than fullLiquidity, baseLiquidity - // Thus, there's no risk of overflow. - if (activePerc < ONE) { - int24 _fullLower = VAULT.fullLower(); - int24 _fullUpper = VAULT.fullUpper(); - int24 _baseLower = VAULT.baseLower(); - int24 _baseUpper = VAULT.baseUpper(); - (uint128 fullLiquidity, , , , ) = _position(_fullLower, _fullUpper); - (uint128 baseLiquidity, , , , ) = _position(_baseLower, _baseUpper); - uint128 fullLiquidityToBurn = uint128( - FullMath.mulDiv(uint256(fullLiquidity), ONE - activePerc, ONE) - ); - uint128 baseLiquidityToBurn = uint128( - FullMath.mulDiv(uint256(baseLiquidity), ONE - activePerc, ONE) - ); - // docs: https://learn.charm.fi/charm/technical-references/core/alphaprovault#emergencyburn - // We remove the calculated percentage of base and full range liquidity. - VAULT.emergencyBurn(_fullLower, _fullUpper, fullLiquidityToBurn); - VAULT.emergencyBurn(_baseLower, _baseUpper, baseLiquidityToBurn); - } - - // When the limit range is not active, we remove entirely. - if (!activeLimitRange) { - int24 _limitLower = VAULT.limitLower(); - int24 _limitUpper = VAULT.limitUpper(); - (uint128 limitLiquidity, , , , ) = _position(_limitLower, _limitUpper); - // docs: https://learn.charm.fi/charm/technical-references/core/alphaprovault#emergencyburn - VAULT.emergencyBurn(_limitLower, _limitUpper, limitLiquidity); - } - } - - /// @dev Fetches most recent report from the given ampleforth oracle contract. - /// The returned report is a fixed point number with {DECIMALS} places. - function _getAmpleforthOracleData( - IAmpleforthOracle oracle - ) private returns (uint256, bool) { - (uint256 p, bool valid) = oracle.getData(); - return (FullMath.mulDiv(p, ONE, 10 ** oracle.DECIMALS()), valid); - } - - /// @dev A low-level method, which interacts directly with the vault and executes - /// a rebalance even when enough time hasn't elapsed since the last rebalance. - function _execForceRebalance() private { - uint32 _period = VAULT.period(); - VAULT.setPeriod(0); - VAULT.rebalance(); - VAULT.setPeriod(_period); - } - - /// @dev Wrapper around `IUniswapV3Pool.positions()`. - function _position( - int24 tickLower, - int24 tickUpper - ) private view returns (uint128, uint256, uint256, uint128, uint128) { - bytes32 positionKey = PositionKey.compute(address(VAULT), tickLower, tickUpper); - return POOL.positions(positionKey); - } - - /// @dev Fetches most recent report from the given chain link oracle contract. - /// The data is considered invalid if the latest report is stale. - /// The returned report is a fixed point number with {DECIMALS} places. - function _getCLOracleData( - IChainlinkOracle oracle - ) private view returns (uint256, bool) { - (, int256 p, , uint256 updatedAt, ) = oracle.latestRoundData(); - uint256 price = FullMath.mulDiv(uint256(p), ONE, 10 ** oracle.decimals()); - return ( - price, - (block.timestamp - updatedAt) <= CL_ORACLE_STALENESS_THRESHOLD_SEC - ); - } - - /// @dev We compute activeLiqPerc value given a linear fn and deviation. - function _computeActiveLiqPerc( - Line memory fn, - uint256 deviation - ) private pure returns (uint256) { - deviation = (deviation > MAX_DEVIATION) ? MAX_DEVIATION : deviation; - int256 dlY = SafeCast.toInt256(fn.y2) - SafeCast.toInt256(fn.y1); - int256 dlX = SafeCast.toInt256(fn.x2) - SafeCast.toInt256(fn.x1); - int256 activeLiqPerc = SafeCast.toInt256(fn.y2) + - (((SafeCast.toInt256(deviation) - SafeCast.toInt256(fn.x2)) * dlY) / dlX); - activeLiqPerc = (activeLiqPerc < int256(MIN_ACTIVE_LIQ_PERC)) - ? int256(MIN_ACTIVE_LIQ_PERC) - : activeLiqPerc; - activeLiqPerc = (activeLiqPerc > int256(ONE)) ? int256(ONE) : activeLiqPerc; - // Casting from int256 to uint256 here is safe as activeLiqPerc >= 0. - return uint256(activeLiqPerc); - } } diff --git a/spot-vaults/contracts/_interfaces/external/IAlphaProVault.sol b/spot-vaults/contracts/_interfaces/external/IAlphaProVault.sol index d599cdfd..39b4d5fe 100644 --- a/spot-vaults/contracts/_interfaces/external/IAlphaProVault.sol +++ b/spot-vaults/contracts/_interfaces/external/IAlphaProVault.sol @@ -1,6 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// solhint-disable-next-line compiler-version -pragma solidity ^0.7.6; +pragma solidity ^0.8.24; import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; diff --git a/spot-vaults/contracts/_utils/AlphaVaultHelpers.sol b/spot-vaults/contracts/_utils/AlphaVaultHelpers.sol new file mode 100644 index 00000000..d738bcf6 --- /dev/null +++ b/spot-vaults/contracts/_utils/AlphaVaultHelpers.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { IAlphaProVault } from "../_interfaces/external/IAlphaProVault.sol"; +import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; + +/** + * @title AlphaVaultHelpers + * + * @notice Library with helper functions for Charm's Alpha Vaults. + * + */ +library AlphaVaultHelpers { + /// @dev Checks if the vault is underweight token0 (ie overweight token1). + function isUnderweightToken0(IAlphaProVault vault) internal view returns (bool) { + // `vault.getTwap()` returns the twap tick from the underlying univ3 pool. + // https://learn.charm.fi/charm/technical-references/core/alphaprovault#gettwap + int24 _priceTick = vault.getTwap(); + int24 _limitLower = vault.limitLower(); + int24 _limitUpper = vault.limitUpper(); + int24 _limitPriceTick = (_limitLower + _limitUpper) / 2; + // The limit range has more token1 than token0 if `_priceTick >= _limitPriceTick`, + // so the vault looks to sell token1. + return (_priceTick >= _limitPriceTick); + } + + /// @dev Removes the vault's limit range liquidity completely. + function removeLimitLiquidity(IAlphaProVault vault, IUniswapV3Pool pool) internal { + int24 _limitLower = vault.limitLower(); + int24 _limitUpper = vault.limitUpper(); + uint128 limitLiquidity = getLiquidity(vault, pool, _limitLower, _limitUpper); + // docs: https://learn.charm.fi/charm/technical-references/core/alphaprovault#emergencyburn + vault.emergencyBurn(_limitLower, _limitUpper, limitLiquidity); + } + + /// @dev Removes a percentage of the base and full range liquidity. + function trimLiquidity( + IAlphaProVault vault, + IUniswapV3Pool pool, + uint256 percToRemove, + uint256 one + ) internal { + if (percToRemove <= 0) { + return; + } + + int24 _fullLower = vault.fullLower(); + int24 _fullUpper = vault.fullUpper(); + int24 _baseLower = vault.baseLower(); + int24 _baseUpper = vault.baseUpper(); + uint128 fullLiquidity = getLiquidity(vault, pool, _fullLower, _fullUpper); + uint128 baseLiquidity = getLiquidity(vault, pool, _baseLower, _baseUpper); + // Calculated baseLiquidityToBurn, baseLiquidityToBurn will be lesser than fullLiquidity, baseLiquidity + // Thus, there's no risk of overflow. + uint128 fullLiquidityToBurn = uint128( + Math.mulDiv(uint256(fullLiquidity), percToRemove, one) + ); + uint128 baseLiquidityToBurn = uint128( + Math.mulDiv(uint256(baseLiquidity), percToRemove, one) + ); + // docs: https://learn.charm.fi/charm/technical-references/core/alphaprovault#emergencyburn + // We remove the calculated percentage of base and full range liquidity. + vault.emergencyBurn(_fullLower, _fullUpper, fullLiquidityToBurn); + vault.emergencyBurn(_baseLower, _baseUpper, baseLiquidityToBurn); + } + + /// @dev A low-level method, which interacts directly with the vault and executes + /// a rebalance even when enough time hasn't elapsed since the last rebalance. + function forceRebalance(IAlphaProVault vault) internal { + uint32 _period = vault.period(); + vault.setPeriod(0); + vault.rebalance(); + vault.setPeriod(_period); + } + + /// @dev Wrapper around `IUniswapV3Pool.positions()`. + function getLiquidity( + IAlphaProVault vault, + IUniswapV3Pool pool, + int24 tickLower, + int24 tickUpper + ) internal view returns (uint128) { + bytes32 positionKey = keccak256( + abi.encodePacked(address(vault), tickLower, tickUpper) + ); + (uint128 liquidity, , , , ) = pool.positions(positionKey); + return liquidity; + } +} From a28966fad01db3340518eaff762d38aaada74f13 Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Mon, 16 Sep 2024 18:16:27 -0400 Subject: [PATCH 08/10] updated unit tests --- .../contracts/{ => charm}/UsdcSpotManager.sol | 0 .../{ => charm}/WethWamplManager.sol | 0 spot-vaults/package.json | 2 +- spot-vaults/test/BillBroker.ts | 334 +++---- spot-vaults/test/BillBroker_deposit_redeem.ts | 108 +-- spot-vaults/test/BillBroker_swap.ts | 320 +++---- spot-vaults/test/SpotAppraiser.ts | 277 ------ spot-vaults/test/SpotCDRPricer.ts | 167 ---- spot-vaults/test/SpotPricer.ts | 433 +++++++++ spot-vaults/test/UsdcSpotManager.ts | 471 ++++------ spot-vaults/test/WethWamplManager.ts | 883 +++++------------- spot-vaults/test/helpers.ts | 15 +- yarn.lock | 10 +- 13 files changed, 1228 insertions(+), 1792 deletions(-) rename spot-vaults/contracts/{ => charm}/UsdcSpotManager.sol (100%) rename spot-vaults/contracts/{ => charm}/WethWamplManager.sol (100%) delete mode 100644 spot-vaults/test/SpotAppraiser.ts delete mode 100644 spot-vaults/test/SpotCDRPricer.ts create mode 100644 spot-vaults/test/SpotPricer.ts diff --git a/spot-vaults/contracts/UsdcSpotManager.sol b/spot-vaults/contracts/charm/UsdcSpotManager.sol similarity index 100% rename from spot-vaults/contracts/UsdcSpotManager.sol rename to spot-vaults/contracts/charm/UsdcSpotManager.sol diff --git a/spot-vaults/contracts/WethWamplManager.sol b/spot-vaults/contracts/charm/WethWamplManager.sol similarity index 100% rename from spot-vaults/contracts/WethWamplManager.sol rename to spot-vaults/contracts/charm/WethWamplManager.sol diff --git a/spot-vaults/package.json b/spot-vaults/package.json index b2915325..1368665c 100644 --- a/spot-vaults/package.json +++ b/spot-vaults/package.json @@ -62,7 +62,7 @@ "ethers": "^6.6.0", "ethers-v5": "npm:ethers@^5.7.0", "ganache-cli": "latest", - "hardhat": "^2.22.8", + "hardhat": "^2.22.10", "hardhat-gas-reporter": "latest", "lodash": "^4.17.21", "prettier": "^2.7.1", diff --git a/spot-vaults/test/BillBroker.ts b/spot-vaults/test/BillBroker.ts index 230b794e..085b766e 100644 --- a/spot-vaults/test/BillBroker.ts +++ b/spot-vaults/test/BillBroker.ts @@ -1,7 +1,7 @@ import { ethers, upgrades } from "hardhat"; import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; import { expect } from "chai"; -import { DMock, usdFP, perpFP, percentageFP, priceFP } from "./helpers"; +import { DMock, usdFP, perpFP, percFP, priceFP } from "./helpers"; describe("BillBroker", function () { async function setupContracts() { @@ -13,30 +13,29 @@ describe("BillBroker", function () { await usd.init("USD token", "usd", 6); const perp = await Token.deploy(); await perp.init("Perp token", "perp", 9); - const pricingStrategy = new DMock("SpotAppraiser"); - await pricingStrategy.deploy(); - await pricingStrategy.mockMethod("decimals()", [18]); - await pricingStrategy.mockMethod("perpPrice()", [0, false]); - await pricingStrategy.mockMethod("usdPrice()", [0, false]); + const oracle = new DMock("IPerpPricer"); + await oracle.deploy(); + await oracle.mockMethod("decimals()", [18]); + await oracle.mockMethod("perpFmvUsdPrice()", [0, false]); + await oracle.mockMethod("usdPrice()", [0, false]); + await oracle.mockMethod("perpBeta()", [percFP("1"), true]); const BillBroker = await ethers.getContractFactory("BillBroker"); const billBroker = await upgrades.deployProxy( BillBroker.connect(deployer), - ["BillBroker LP", "LP token", usd.target, perp.target, pricingStrategy.target], + ["BillBroker LP", "LP token", usd.target, perp.target, oracle.target], { initializer: "init(string,string,address,address,address)", }, ); - return { deployer, usd, perp, pricingStrategy, billBroker }; + return { deployer, usd, perp, oracle, billBroker }; } describe("init", function () { it("should set initial values", async function () { - const { deployer, billBroker, usd, pricingStrategy } = await loadFixture( - setupContracts, - ); + const { deployer, billBroker, usd, oracle } = await loadFixture(setupContracts); expect(await billBroker.usd()).to.eq(usd.target); - expect(await billBroker.pricingStrategy()).to.eq(pricingStrategy.target); + expect(await billBroker.oracle()).to.eq(oracle.target); expect(await billBroker.usdUnitAmt()).to.eq(usdFP("1")); expect(await billBroker.perpUnitAmt()).to.eq(perpFP("1")); @@ -54,10 +53,10 @@ describe("BillBroker", function () { const fees = await billBroker.fees(); expect(fees.mintFeePerc).to.eq(0); expect(fees.burnFeePerc).to.eq(0); - expect(fees.perpToUSDSwapFeePercs.lower).to.eq(percentageFP("1")); - expect(fees.perpToUSDSwapFeePercs.upper).to.eq(percentageFP("1")); - expect(fees.usdToPerpSwapFeePercs.lower).to.eq(percentageFP("1")); - expect(fees.usdToPerpSwapFeePercs.upper).to.eq(percentageFP("1")); + expect(fees.perpToUSDSwapFeePercs.lower).to.eq(percFP("1")); + expect(fees.perpToUSDSwapFeePercs.upper).to.eq(percFP("1")); + expect(fees.usdToPerpSwapFeePercs.lower).to.eq(percFP("1")); + expect(fees.usdToPerpSwapFeePercs.upper).to.eq(percFP("1")); expect(fees.protocolSwapSharePerc).to.eq(0); expect(await billBroker.usdBalance()).to.eq(0n); @@ -85,35 +84,35 @@ describe("BillBroker", function () { }); }); - describe("#updatePricingStrategy", function () { + describe("#updateOracle", function () { describe("when triggered by non-owner", function () { it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.renounceOwnership(); - await expect( - billBroker.updatePricingStrategy(ethers.ZeroAddress), - ).to.be.revertedWith("Ownable: caller is not the owner"); + await expect(billBroker.updateOracle(ethers.ZeroAddress)).to.be.revertedWith( + "Ownable: caller is not the owner", + ); }); }); - describe("when pricing strategy is not valid", function () { + describe("when oracle is not valid", function () { it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); - const pricingStrategy = new DMock("SpotAppraiser"); - await pricingStrategy.deploy(); - await pricingStrategy.mockMethod("decimals()", [17]); + const oracle = new DMock("SpotPricer"); + await oracle.deploy(); + await oracle.mockMethod("decimals()", [17]); await expect( - billBroker.updatePricingStrategy(pricingStrategy.target), + billBroker.updateOracle(oracle.target), ).to.be.revertedWithCustomError(billBroker, "UnexpectedDecimals"); }); it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); - const pricingStrategy = new DMock("SpotAppraiser"); - await pricingStrategy.deploy(); - await pricingStrategy.mockMethod("decimals()", [18]); + const oracle = new DMock("SpotPricer"); + await oracle.deploy(); + await oracle.mockMethod("decimals()", [18]); - await billBroker.updatePricingStrategy(pricingStrategy.target); - expect(await billBroker.pricingStrategy()).to.eq(pricingStrategy.target); + await billBroker.updateOracle(oracle.target); + expect(await billBroker.oracle()).to.eq(oracle.target); }); }); }); @@ -122,17 +121,17 @@ describe("BillBroker", function () { let fees: any; beforeEach(async function () { fees = { - mintFeePerc: percentageFP("0.005"), - burnFeePerc: percentageFP("0.025"), + mintFeePerc: percFP("0.005"), + burnFeePerc: percFP("0.025"), perpToUSDSwapFeePercs: { - lower: percentageFP("0.01"), - upper: percentageFP("0.1"), + lower: percFP("0.01"), + upper: percFP("0.1"), }, usdToPerpSwapFeePercs: { - lower: percentageFP("0.02"), - upper: percentageFP("0.2"), + lower: percFP("0.02"), + upper: percFP("0.2"), }, - protocolSwapSharePerc: percentageFP("0.05"), + protocolSwapSharePerc: percFP("0.05"), }; }); @@ -149,7 +148,7 @@ describe("BillBroker", function () { describe("when parameters are invalid", function () { it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); - fees.mintFeePerc = percentageFP("1.01"); + fees.mintFeePerc = percFP("1.01"); await expect(billBroker.updateFees(fees)).to.be.revertedWithCustomError( billBroker, "InvalidPerc", @@ -160,7 +159,7 @@ describe("BillBroker", function () { describe("when parameters are invalid", function () { it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); - fees.burnFeePerc = percentageFP("1.01"); + fees.burnFeePerc = percFP("1.01"); await expect(billBroker.updateFees(fees)).to.be.revertedWithCustomError( billBroker, "InvalidPerc", @@ -168,8 +167,8 @@ describe("BillBroker", function () { }); it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); - fees.perpToUSDSwapFeePercs.lower = percentageFP("0.2"); - fees.perpToUSDSwapFeePercs.upper = percentageFP("0.1"); + fees.perpToUSDSwapFeePercs.lower = percFP("0.2"); + fees.perpToUSDSwapFeePercs.upper = percFP("0.1"); await expect(billBroker.updateFees(fees)).to.be.revertedWithCustomError( billBroker, "InvalidPerc", @@ -177,8 +176,8 @@ describe("BillBroker", function () { }); it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); - fees.usdToPerpSwapFeePercs.lower = percentageFP("0.2"); - fees.usdToPerpSwapFeePercs.upper = percentageFP("0.1"); + fees.usdToPerpSwapFeePercs.lower = percFP("0.2"); + fees.usdToPerpSwapFeePercs.upper = percFP("0.1"); await expect(billBroker.updateFees(fees)).to.be.revertedWithCustomError( billBroker, "InvalidPerc", @@ -186,7 +185,7 @@ describe("BillBroker", function () { }); it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); - fees.protocolSwapSharePerc = percentageFP("1.01"); + fees.protocolSwapSharePerc = percFP("1.01"); await expect(billBroker.updateFees(fees)).to.be.revertedWithCustomError( billBroker, "InvalidPerc", @@ -217,8 +216,8 @@ describe("BillBroker", function () { await billBroker.renounceOwnership(); await expect( billBroker.updateARBounds( - [percentageFP("0.9"), percentageFP("1.1")], - [percentageFP("0.8"), percentageFP("1.2")], + [percFP("0.9"), percFP("1.1")], + [percFP("0.8"), percFP("1.2")], ), ).to.be.revertedWith("Ownable: caller is not the owner"); }); @@ -229,8 +228,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await expect( billBroker.updateARBounds( - [percentageFP("1.1"), percentageFP("1.0")], - [percentageFP("0.8"), percentageFP("1.2")], + [percFP("1.1"), percFP("1.0")], + [percFP("0.8"), percFP("1.2")], ), ).to.be.revertedWithCustomError(billBroker, "InvalidARBound"); }); @@ -239,8 +238,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await expect( billBroker.updateARBounds( - [percentageFP("0.9"), percentageFP("1.1")], - [percentageFP("1.2"), percentageFP("0.8")], + [percFP("0.9"), percFP("1.1")], + [percFP("1.2"), percFP("0.8")], ), ).to.be.revertedWithCustomError(billBroker, "InvalidARBound"); }); @@ -249,8 +248,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await expect( billBroker.updateARBounds( - [percentageFP("0.9"), percentageFP("0.8")], - [percentageFP("1.1"), percentageFP("1.2")], + [percFP("0.9"), percFP("0.8")], + [percFP("1.1"), percFP("1.2")], ), ).to.be.revertedWithCustomError(billBroker, "InvalidARBound"); }); @@ -259,8 +258,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await expect( billBroker.updateARBounds( - [percentageFP("0.8"), percentageFP("1.2")], - [percentageFP("0.9"), percentageFP("1.1")], + [percFP("0.8"), percFP("1.2")], + [percFP("0.9"), percFP("1.1")], ), ).to.be.revertedWithCustomError(billBroker, "InvalidARBound"); }); @@ -270,16 +269,16 @@ describe("BillBroker", function () { it("should update bound", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.updateARBounds( - [percentageFP("0.9"), percentageFP("1.1")], - [percentageFP("0.8"), percentageFP("1.2")], + [percFP("0.9"), percFP("1.1")], + [percFP("0.8"), percFP("1.2")], ); const b1 = await billBroker.arSoftBound(); - expect(b1.lower).to.eq(percentageFP("0.9")); - expect(b1.upper).to.eq(percentageFP("1.1")); + expect(b1.lower).to.eq(percFP("0.9")); + expect(b1.upper).to.eq(percFP("1.1")); const b2 = await billBroker.arHardBound(); - expect(b2.lower).to.eq(percentageFP("0.8")); - expect(b2.upper).to.eq(percentageFP("1.2")); + expect(b2.lower).to.eq(percFP("0.8")); + expect(b2.upper).to.eq(percFP("1.2")); }); }); }); @@ -346,8 +345,8 @@ describe("BillBroker", function () { describe("#usdPrice", function () { describe("when the price is invalid", function () { it("should revert", async function () { - const { billBroker, pricingStrategy } = await loadFixture(setupContracts); - await pricingStrategy.mockMethod("usdPrice()", [priceFP("1"), false]); + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("usdPrice()", [priceFP("1"), false]); await expect(billBroker.usdPrice()).to.be.revertedWithCustomError( billBroker, "UnreliablePrice", @@ -357,8 +356,8 @@ describe("BillBroker", function () { describe("when the price is valid", function () { it("should return strategy price", async function () { - const { billBroker, pricingStrategy } = await loadFixture(setupContracts); - await pricingStrategy.mockMethod("usdPrice()", [priceFP("1.001"), true]); + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("usdPrice()", [priceFP("1.001"), true]); expect(await billBroker.usdPrice.staticCall()).to.eq(priceFP("1.001")); }); }); @@ -367,8 +366,8 @@ describe("BillBroker", function () { describe("#perpPrice", function () { describe("when the price is invalid", function () { it("should revert", async function () { - const { billBroker, pricingStrategy } = await loadFixture(setupContracts); - await pricingStrategy.mockMethod("perpPrice()", [priceFP("1.17"), false]); + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpFmvUsdPrice()", [priceFP("1.17"), false]); await expect(billBroker.perpPrice()).to.be.revertedWithCustomError( billBroker, "UnreliablePrice", @@ -378,11 +377,29 @@ describe("BillBroker", function () { describe("when the price is valid", function () { it("should return strategy price", async function () { - const { billBroker, pricingStrategy } = await loadFixture(setupContracts); - await pricingStrategy.mockMethod("perpPrice()", [priceFP("1.17"), true]); + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpFmvUsdPrice()", [priceFP("1.17"), true]); expect(await billBroker.perpPrice.staticCall()).to.eq(priceFP("1.17")); }); }); + + describe("when the beta is set", function () { + it("should return strategy price", async function () { + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpFmvUsdPrice()", [priceFP("1.2"), true]); + await oracle.mockMethod("perpBeta()", [priceFP("1.5"), true]); + expect(await billBroker.perpPrice.staticCall()).to.eq(priceFP("1.8")); + }); + }); + + describe("when the beta is set", function () { + it("should return strategy price", async function () { + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpFmvUsdPrice()", [priceFP("1.2"), true]); + await oracle.mockMethod("perpBeta()", [priceFP("0.5"), true]); + expect(await billBroker.perpPrice.staticCall()).to.eq(priceFP("0.6")); + }); + }); }); describe("#usdBalance", function () { @@ -403,23 +420,16 @@ describe("BillBroker", function () { describe("#reserveState", function () { it("should return the reserve state", async function () { - const { billBroker, perp, usd, pricingStrategy } = await loadFixture( - setupContracts, - ); + const { billBroker, perp, usd, oracle } = await loadFixture(setupContracts); await usd.mint(billBroker.target, usdFP("115")); await perp.mint(billBroker.target, perpFP("100")); - await pricingStrategy.mockMethod("usdPrice()", [priceFP("1"), true]); - await pricingStrategy.mockMethod("perpPrice()", [priceFP("1.3"), true]); - const r = { - usdBalance: await billBroker.usdBalance(), - perpBalance: await billBroker.perpBalance(), - usdPrice: await billBroker.usdPrice.staticCall(), - perpPrice: await billBroker.perpPrice.staticCall(), - }; - expect(r.usdBalance).to.eq(usdFP("115")); - expect(r.perpBalance).to.eq(perpFP("100")); - expect(r.usdPrice).to.eq(priceFP("1")); - expect(r.perpPrice).to.eq(priceFP("1.3")); + await oracle.mockMethod("usdPrice()", [priceFP("1"), true]); + await oracle.mockMethod("perpFmvUsdPrice()", [priceFP("1.3"), true]); + const r = await billBroker.reserveState.staticCall(); + expect(r[0]).to.eq(usdFP("115")); + expect(r[1]).to.eq(perpFP("100")); + expect(r[2]).to.eq(priceFP("1")); + expect(r[3]).to.eq(priceFP("1.3")); }); }); @@ -427,8 +437,8 @@ describe("BillBroker", function () { it("should compute the right fee perc", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.updateARBounds( - [percentageFP("0.75"), percentageFP("1.25")], - [percentageFP("0.5"), percentageFP("1.5")], + [percFP("0.75"), percFP("1.25")], + [percFP("0.5"), percFP("1.5")], ); await billBroker.updateFees({ @@ -439,77 +449,50 @@ describe("BillBroker", function () { upper: 0n, }, usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("1.5"), + lower: percFP("0.05"), + upper: percFP("1.5"), }, protocolSwapSharePerc: 0n, }); await expect( - billBroker.computeUSDToPerpSwapFeePerc(percentageFP("1.5"), percentageFP("0.5")), + billBroker.computeUSDToPerpSwapFeePerc(percFP("1.5"), percFP("0.5")), ).to.be.revertedWithCustomError(billBroker, "UnexpectedARDelta"); await expect( - billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("1.25"), - percentageFP("1.249"), - ), + billBroker.computeUSDToPerpSwapFeePerc(percFP("1.25"), percFP("1.249")), ).to.be.revertedWithCustomError(billBroker, "UnexpectedARDelta"); expect( - await billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("0.25"), - percentageFP("1.2"), - ), - ).to.eq(percentageFP("0.05")); + await billBroker.computeUSDToPerpSwapFeePerc(percFP("0.25"), percFP("1.2")), + ).to.eq(percFP("0.05")); expect( - await billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("0.25"), - percentageFP("1.25"), - ), - ).to.eq(percentageFP("0.05")); + await billBroker.computeUSDToPerpSwapFeePerc(percFP("0.25"), percFP("1.25")), + ).to.eq(percFP("0.05")); expect( - await billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("1.2"), - percentageFP("1.3"), - ), - ).to.eq(percentageFP("0.1225")); + await billBroker.computeUSDToPerpSwapFeePerc(percFP("1.2"), percFP("1.3")), + ).to.eq(percFP("0.1225")); expect( - await billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("1.3"), - percentageFP("1.45"), - ), - ).to.eq(percentageFP("0.775")); + await billBroker.computeUSDToPerpSwapFeePerc(percFP("1.3"), percFP("1.45")), + ).to.eq(percFP("0.775")); expect( - await billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("1.3"), - percentageFP("1.5"), - ), - ).to.eq(percentageFP("0.92")); + await billBroker.computeUSDToPerpSwapFeePerc(percFP("1.3"), percFP("1.5")), + ).to.eq(percFP("0.92")); expect( - await billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("0.5"), - percentageFP("1.5"), - ), - ).to.eq(percentageFP("0.23125")); + await billBroker.computeUSDToPerpSwapFeePerc(percFP("0.5"), percFP("1.5")), + ).to.eq(percFP("0.23125")); expect( - await billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("1.3"), - percentageFP("1.501"), - ), - ).to.eq(percentageFP("1")); + await billBroker.computeUSDToPerpSwapFeePerc(percFP("1.3"), percFP("1.501")), + ).to.eq(percFP("1")); expect( - await billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("1.3"), - percentageFP("2"), - ), - ).to.eq(percentageFP("1")); + await billBroker.computeUSDToPerpSwapFeePerc(percFP("1.3"), percFP("2")), + ).to.eq(percFP("1")); }); it("should compute the right fee perc when outside bounds", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.updateARBounds( - [percentageFP("0.75"), percentageFP("1.25")], - [percentageFP("0"), percentageFP("10")], + [percFP("0.75"), percFP("1.25")], + [percFP("0"), percFP("10")], ); await billBroker.updateFees({ @@ -520,18 +503,15 @@ describe("BillBroker", function () { upper: 0n, }, usdToPerpSwapFeePercs: { - lower: percentageFP("1.01"), - upper: percentageFP("2"), + lower: percFP("1.01"), + upper: percFP("2"), }, protocolSwapSharePerc: 0n, }); expect( - await billBroker.computeUSDToPerpSwapFeePerc( - percentageFP("1"), - percentageFP("1.25"), - ), - ).to.eq(percentageFP("1")); + await billBroker.computeUSDToPerpSwapFeePerc(percFP("1"), percFP("1.25")), + ).to.eq(percFP("1")); }); }); @@ -539,16 +519,16 @@ describe("BillBroker", function () { it("should compute the right fee perc", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.updateARBounds( - [percentageFP("0.75"), percentageFP("1.25")], - [percentageFP("0.5"), percentageFP("1.5")], + [percFP("0.75"), percFP("1.25")], + [percFP("0.5"), percFP("1.5")], ); await billBroker.updateFees({ mintFeePerc: 0n, burnFeePerc: 0n, perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, usdToPerpSwapFeePercs: { lower: 0n, @@ -558,66 +538,45 @@ describe("BillBroker", function () { }); await expect( - billBroker.computePerpToUSDSwapFeePerc(percentageFP("0.5"), percentageFP("1.5")), + billBroker.computePerpToUSDSwapFeePerc(percFP("0.5"), percFP("1.5")), ).to.be.revertedWithCustomError(billBroker, "UnexpectedARDelta"); await expect( - billBroker.computePerpToUSDSwapFeePerc( - percentageFP("1.25"), - percentageFP("1.251"), - ), + billBroker.computePerpToUSDSwapFeePerc(percFP("1.25"), percFP("1.251")), ).to.be.revertedWithCustomError(billBroker, "UnexpectedARDelta"); expect( - await billBroker.computePerpToUSDSwapFeePerc( - percentageFP("2"), - percentageFP("0.8"), - ), - ).to.eq(percentageFP("0.1")); + await billBroker.computePerpToUSDSwapFeePerc(percFP("2"), percFP("0.8")), + ).to.eq(percFP("0.1")); expect( - await billBroker.computePerpToUSDSwapFeePerc( - percentageFP("1.45"), - percentageFP("0.8"), - ), - ).to.eq(percentageFP("0.1")); + await billBroker.computePerpToUSDSwapFeePerc(percFP("1.45"), percFP("0.8")), + ).to.eq(percFP("0.1")); expect( - await billBroker.computePerpToUSDSwapFeePerc( - percentageFP("0.8"), - percentageFP("0.7"), - ), - ).to.eq(percentageFP("0.12")); + await billBroker.computePerpToUSDSwapFeePerc(percFP("0.8"), percFP("0.7")), + ).to.eq(percFP("0.12")); expect( - await billBroker.computePerpToUSDSwapFeePerc( - percentageFP("0.8"), - percentageFP("0.5"), - ), - ).to.eq(percentageFP("0.266666666666666666")); + await billBroker.computePerpToUSDSwapFeePerc(percFP("0.8"), percFP("0.5")), + ).to.eq(percFP("0.266666666666666666")); expect( - await billBroker.computePerpToUSDSwapFeePerc( - percentageFP("1.5"), - percentageFP("0.5"), - ), - ).to.eq(percentageFP("0.15")); + await billBroker.computePerpToUSDSwapFeePerc(percFP("1.5"), percFP("0.5")), + ).to.eq(percFP("0.15")); expect( - await billBroker.computePerpToUSDSwapFeePerc( - percentageFP("1.0"), - percentageFP("0.49"), - ), - ).to.eq(percentageFP("1")); + await billBroker.computePerpToUSDSwapFeePerc(percFP("1.0"), percFP("0.49")), + ).to.eq(percFP("1")); }); it("should compute the right fee perc when outside bounds", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.updateARBounds( - [percentageFP("0.75"), percentageFP("1.25")], - [percentageFP("0"), percentageFP("10")], + [percFP("0.75"), percFP("1.25")], + [percFP("0"), percFP("10")], ); await billBroker.updateFees({ mintFeePerc: 0n, burnFeePerc: 0n, perpToUSDSwapFeePercs: { - lower: percentageFP("1.01"), - upper: percentageFP("2"), + lower: percFP("1.01"), + upper: percFP("2"), }, usdToPerpSwapFeePercs: { lower: 0n, @@ -627,11 +586,8 @@ describe("BillBroker", function () { }); expect( - await billBroker.computePerpToUSDSwapFeePerc( - percentageFP("1.25"), - percentageFP("1.11"), - ), - ).to.eq(percentageFP("1")); + await billBroker.computePerpToUSDSwapFeePerc(percFP("1.25"), percFP("1.11")), + ).to.eq(percFP("1")); }); }); }); diff --git a/spot-vaults/test/BillBroker_deposit_redeem.ts b/spot-vaults/test/BillBroker_deposit_redeem.ts index 784f65a9..802cc8db 100644 --- a/spot-vaults/test/BillBroker_deposit_redeem.ts +++ b/spot-vaults/test/BillBroker_deposit_redeem.ts @@ -1,7 +1,7 @@ import { ethers, upgrades } from "hardhat"; import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; import { expect } from "chai"; -import { DMock, usdFP, perpFP, lpAmtFP, percentageFP, priceFP } from "./helpers"; +import { DMock, usdFP, perpFP, lpAmtFP, percFP, priceFP } from "./helpers"; describe("BillBroker", function () { async function setupContracts() { @@ -14,16 +14,17 @@ describe("BillBroker", function () { await usd.init("USD token", "usd", 6); const perp = await Token.deploy(); await perp.init("Perp token", "perp", 9); - const pricingStrategy = new DMock("SpotAppraiser"); - await pricingStrategy.deploy(); - await pricingStrategy.mockMethod("decimals()", [18]); - await pricingStrategy.mockMethod("perpPrice()", [priceFP("1.15"), true]); - await pricingStrategy.mockMethod("usdPrice()", [priceFP("1"), true]); + const oracle = new DMock("IPerpPricer"); + await oracle.deploy(); + await oracle.mockMethod("decimals()", [18]); + await oracle.mockMethod("perpFmvUsdPrice()", [priceFP("1.15"), true]); + await oracle.mockMethod("usdPrice()", [priceFP("1"), true]); + await oracle.mockMethod("perpBeta()", [percFP("1"), true]); const BillBroker = await ethers.getContractFactory("BillBroker"); const billBroker = await upgrades.deployProxy( BillBroker.connect(deployer), - ["BillBroker LP", "LP token", usd.target, perp.target, pricingStrategy.target], + ["BillBroker LP", "LP token", usd.target, perp.target, oracle.target], { initializer: "init(string,string,address,address,address)", }, @@ -45,7 +46,7 @@ describe("BillBroker", function () { await perp.mint(await deployer.getAddress(), perpFP("2000")); await usd.mint(await otherUser.getAddress(), usdFP("2000")); await perp.mint(await otherUser.getAddress(), perpFP("2000")); - return { deployer, otherUser, usd, perp, pricingStrategy, billBroker }; + return { deployer, otherUser, usd, perp, oracle, billBroker }; } async function assetRatio(billBroker) { @@ -145,7 +146,7 @@ describe("BillBroker", function () { perpFP("100"), ); await billBroker.updateFees({ - mintFeePerc: percentageFP("0.1"), + mintFeePerc: percFP("0.1"), burnFeePerc: 0n, perpToUSDSwapFeePercs: { lower: 0n, @@ -259,7 +260,7 @@ describe("BillBroker", function () { perpFP("200"), ); await billBroker.updateFees({ - mintFeePerc: percentageFP("0.1"), + mintFeePerc: percFP("0.1"), burnFeePerc: 0n, perpToUSDSwapFeePercs: { lower: 0n, @@ -347,7 +348,7 @@ describe("BillBroker", function () { perpFP("100"), ); await billBroker.updateFees({ - mintFeePerc: percentageFP("0.1"), + mintFeePerc: percFP("0.1"), burnFeePerc: 0n, perpToUSDSwapFeePercs: { lower: 0n, @@ -565,7 +566,7 @@ describe("BillBroker", function () { it("should withhold fees and mint lp tokens", async function () { const { billBroker, usd, perp, deployer } = await loadFixture(setupContracts); await billBroker.updateFees({ - mintFeePerc: percentageFP("0.1"), + mintFeePerc: percFP("0.1"), burnFeePerc: 0n, perpToUSDSwapFeePercs: { lower: 0n, @@ -645,15 +646,14 @@ describe("BillBroker", function () { it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.pause(); - await expect(billBroker.depositUSD(usdFP("115"), percentageFP("1"))).to.be - .reverted; + await expect(billBroker.depositUSD(usdFP("115"), percFP("1"))).to.be.reverted; }); }); describe("when usdAmtIn is zero", function () { it("should return zero", async function () { const { billBroker } = await loadFixture(setupContracts); - const r = await billBroker.depositUSD.staticCall(0n, percentageFP("1")); + const r = await billBroker.depositUSD.staticCall(0n, percFP("1")); expect(r).to.eq(0n); }); }); @@ -744,7 +744,7 @@ describe("BillBroker", function () { await usd.approve(billBroker.target, usdFP("10")); await expect( - billBroker.depositUSD(usdFP("10"), percentageFP("0.50")), + billBroker.depositUSD(usdFP("10"), percFP("0.50")), ).to.be.revertedWithCustomError(billBroker, "SlippageTooHigh"); }); }); @@ -763,7 +763,7 @@ describe("BillBroker", function () { await usd.approve(billBroker.target, usdFP("10")); await expect(() => - billBroker.depositUSD(usdFP("10"), percentageFP("1")), + billBroker.depositUSD(usdFP("10"), percFP("1")), ).to.changeTokenBalance(usd, deployer, usdFP("-10")); }); @@ -780,7 +780,7 @@ describe("BillBroker", function () { await usd.approve(billBroker.target, usdFP("10")); await expect(() => - billBroker.depositUSD(usdFP("10"), percentageFP("1")), + billBroker.depositUSD(usdFP("10"), percFP("1")), ).to.changeTokenBalance( billBroker, deployer, @@ -803,7 +803,7 @@ describe("BillBroker", function () { ); await usd.approve(billBroker.target, usdFP("10")); const r = await billBroker.reserveState.staticCall(); - await expect(billBroker.depositUSD(usdFP("10"), percentageFP("1"))) + await expect(billBroker.depositUSD(usdFP("10"), percFP("1"))) .to.emit(billBroker, "DepositUSD") .withArgs(usdFP("10"), r); expect(await billBroker.totalSupply()).to.eq( @@ -823,7 +823,7 @@ describe("BillBroker", function () { ); await usd.approve(billBroker.target, usdFP("10")); - const r = await billBroker.depositUSD.staticCall(usdFP("10"), percentageFP("1")); + const r = await billBroker.depositUSD.staticCall(usdFP("10"), percFP("1")); expect(r).to.eq(lpAmtFP("9.130434782608695652173913")); }); }); @@ -832,7 +832,7 @@ describe("BillBroker", function () { it("should withhold fees and mint lp tokens", async function () { const { billBroker, usd, perp, deployer } = await loadFixture(setupContracts); await billBroker.updateFees({ - mintFeePerc: percentageFP("0.1"), + mintFeePerc: percFP("0.1"), burnFeePerc: 0n, perpToUSDSwapFeePercs: { lower: 0n, @@ -856,7 +856,7 @@ describe("BillBroker", function () { await usd.approve(billBroker.target, usdFP("10")); await expect(() => - billBroker.depositUSD(usdFP("10"), percentageFP("1")), + billBroker.depositUSD(usdFP("10"), percFP("1")), ).to.changeTokenBalance( billBroker, deployer, @@ -871,15 +871,14 @@ describe("BillBroker", function () { it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.pause(); - await expect(billBroker.depositPerp(perpFP("100"), percentageFP("1"))).to.be - .reverted; + await expect(billBroker.depositPerp(perpFP("100"), percFP("1"))).to.be.reverted; }); }); describe("when perpAmtIn is zero", function () { it("should return zero", async function () { const { billBroker } = await loadFixture(setupContracts); - const r = await billBroker.depositPerp.staticCall(0n, percentageFP("1")); + const r = await billBroker.depositPerp.staticCall(0n, percFP("1")); expect(r).to.eq(0n); }); }); @@ -959,7 +958,7 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("10")); await expect( - billBroker.depositPerp(perpFP("10"), percentageFP("1.85")), + billBroker.depositPerp(perpFP("10"), percFP("1.85")), ).to.be.revertedWithCustomError(billBroker, "SlippageTooHigh"); }); }); @@ -978,7 +977,7 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("10")); await expect(() => - billBroker.depositPerp(perpFP("10"), percentageFP("1")), + billBroker.depositPerp(perpFP("10"), percFP("1")), ).to.changeTokenBalance(perp, deployer, perpFP("-10")); }); @@ -995,7 +994,7 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("10")); await expect(() => - billBroker.depositPerp(perpFP("10"), percentageFP("1")), + billBroker.depositPerp(perpFP("10"), percFP("1")), ).to.changeTokenBalance(billBroker, deployer, lpAmtFP("11")); expect(await billBroker.totalSupply()).to.eq(lpAmtFP("341")); }); @@ -1013,7 +1012,7 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("10")); const r = await billBroker.reserveState.staticCall(); - await expect(billBroker.depositPerp(perpFP("10"), percentageFP("1"))) + await expect(billBroker.depositPerp(perpFP("10"), percFP("1"))) .to.emit(billBroker, "DepositPerp") .withArgs(perpFP("10"), r); expect(await billBroker.totalSupply()).to.eq(lpAmtFP("341")); @@ -1031,10 +1030,7 @@ describe("BillBroker", function () { ); await perp.approve(billBroker.target, perpFP("10")); - const r = await billBroker.depositPerp.staticCall( - perpFP("10"), - percentageFP("1"), - ); + const r = await billBroker.depositPerp.staticCall(perpFP("10"), percFP("1")); expect(r).to.eq(lpAmtFP("11")); }); }); @@ -1052,7 +1048,7 @@ describe("BillBroker", function () { ); await billBroker.updateFees({ - mintFeePerc: percentageFP("0.1"), + mintFeePerc: percFP("0.1"), burnFeePerc: 0n, perpToUSDSwapFeePercs: { lower: 0n, @@ -1066,7 +1062,7 @@ describe("BillBroker", function () { }); await perp.approve(billBroker.target, perpFP("10")); await expect(() => - billBroker.depositPerp(perpFP("10"), percentageFP("1")), + billBroker.depositPerp(perpFP("10"), percFP("1")), ).to.changeTokenBalance(billBroker, deployer, lpAmtFP("9.9")); }); }); @@ -1129,7 +1125,7 @@ describe("BillBroker", function () { const { billBroker, usd, perp } = await loadFixture(setupContracts); await billBroker.updateFees({ mintFeePerc: 0n, - burnFeePerc: percentageFP("0.1"), + burnFeePerc: percFP("0.1"), perpToUSDSwapFeePercs: { lower: 0n, upper: 0n, @@ -1170,15 +1166,7 @@ describe("BillBroker", function () { await billBroker.swapUSDForPerps(usdFP("115"), 0n); expect(await perp.balanceOf(billBroker.target)).to.eq(0n); - const s = await billBroker.reserveState.staticCall(); - expect( - await billBroker.assetRatio({ - usdBalance: s[0], - perpBalance: s[1], - usdPrice: s[2], - perpPrice: s[3], - }), - ).to.eq(ethers.MaxUint256); + expect(await assetRatio(billBroker)).to.eq(ethers.MaxUint256); const r = await billBroker.computeRedemptionAmts.staticCall(lpAmtFP("100")); expect(r[0]).to.eq(usdFP("106.976744")); @@ -1202,15 +1190,7 @@ describe("BillBroker", function () { await billBroker.swapPerpsForUSD(perpFP("100"), 0n); expect(await usd.balanceOf(billBroker.target)).to.eq(0n); - const s = await billBroker.reserveState.staticCall(); - expect( - await billBroker.assetRatio({ - usdBalance: s[0], - perpBalance: s[1], - usdPrice: s[2], - perpPrice: s[3], - }), - ).to.eq(0); + expect(await assetRatio(billBroker)).to.eq(0); const r = await billBroker.computeRedemptionAmts.staticCall(lpAmtFP("100")); expect(r[0]).to.eq(0n); @@ -1426,15 +1406,7 @@ describe("BillBroker", function () { await billBroker.swapUSDForPerps(usdFP("115"), 0n); expect(await perp.balanceOf(billBroker.target)).to.eq(0n); - const s = await billBroker.reserveState.staticCall(); - expect( - await billBroker.assetRatio({ - usdBalance: s[0], - perpBalance: s[1], - usdPrice: s[2], - perpPrice: s[3], - }), - ).to.eq(ethers.MaxUint256); + expect(await assetRatio(billBroker)).to.eq(ethers.MaxUint256); const perpBal = await perp.balanceOf(await deployer.getAddress()); await expect(() => billBroker.redeem(lpAmtFP("100"))).to.changeTokenBalance( @@ -1463,15 +1435,7 @@ describe("BillBroker", function () { await billBroker.swapPerpsForUSD(perpFP("100"), 0n); expect(await usd.balanceOf(billBroker.target)).to.eq(0n); - const s = await billBroker.reserveState.staticCall(); - expect( - await billBroker.assetRatio({ - usdBalance: s[0], - perpBalance: s[1], - usdPrice: s[2], - perpPrice: s[3], - }), - ).to.eq(0); + expect(await assetRatio(billBroker)).to.eq(0); const usdBal = await usd.balanceOf(await deployer.getAddress()); await expect(() => billBroker.redeem(lpAmtFP("100"))).to.changeTokenBalance( diff --git a/spot-vaults/test/BillBroker_swap.ts b/spot-vaults/test/BillBroker_swap.ts index d22bca2d..5fdf5317 100644 --- a/spot-vaults/test/BillBroker_swap.ts +++ b/spot-vaults/test/BillBroker_swap.ts @@ -2,7 +2,7 @@ import { ethers, upgrades } from "hardhat"; import { Contract } from "ethers"; import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; import { expect } from "chai"; -import { DMock, usdFP, perpFP, priceFP, percentageFP } from "./helpers"; +import { DMock, usdFP, perpFP, priceFP, percFP } from "./helpers"; async function updateFees(billBroker: Contract, fees: any) { const currentFees = await billBroker.fees(); @@ -38,7 +38,7 @@ async function checkUSDToPerpSwapAmt( expect(r[2]).to.eq(amoutsOut[2]); } -async function checkPerpTpUSDSwapAmt( +async function checkPerpToUSDSwapAmt( billBroker: Contract, perpAmtIn: BigInt, reserveState: any, @@ -77,16 +77,17 @@ describe("BillBroker", function () { await usd.init("USD token", "usd", 6); const perp = await Token.deploy(); await perp.init("Perp token", "perp", 9); - const pricingStrategy = new DMock("SpotAppraiser"); - await pricingStrategy.deploy(); - await pricingStrategy.mockMethod("decimals()", [18]); - await pricingStrategy.mockMethod("perpPrice()", [priceFP("1.15"), true]); - await pricingStrategy.mockMethod("usdPrice()", [priceFP("1"), true]); + const oracle = new DMock("IPerpPricer"); + await oracle.deploy(); + await oracle.mockMethod("decimals()", [18]); + await oracle.mockMethod("perpFmvUsdPrice()", [priceFP("1.15"), true]); + await oracle.mockMethod("usdPrice()", [priceFP("1"), true]); + await oracle.mockMethod("perpBeta()", [percFP("1"), true]); const BillBroker = await ethers.getContractFactory("BillBroker"); const billBroker = await upgrades.deployProxy( BillBroker.connect(deployer), - ["BillBroker LP", "LP token", usd.target, perp.target, pricingStrategy.target], + ["BillBroker LP", "LP token", usd.target, perp.target, oracle.target], { initializer: "init(string,string,address,address,address)", }, @@ -105,8 +106,8 @@ describe("BillBroker", function () { protocolSwapSharePerc: 0n, }); await billBroker.updateARBounds( - [percentageFP("0.9"), percentageFP("1.1")], - [percentageFP("0.75"), percentageFP("1.25")], + [percFP("0.9"), percFP("1.1")], + [percFP("0.75"), percFP("1.25")], ); await usd.mint(billBroker.target, usdFP("115000")); @@ -122,7 +123,7 @@ describe("BillBroker", function () { expect(r.usdPrice).to.eq(priceFP("1")); expect(r.perpPrice).to.eq(priceFP("1.15")); - return { deployer, feeCollector, usd, perp, pricingStrategy, billBroker }; + return { deployer, feeCollector, usd, perp, oracle, billBroker }; } describe("#computeUSDToPerpSwapAmt", function () { @@ -162,6 +163,7 @@ describe("BillBroker", function () { billBroker, usdFP("11112"), [usdFP("100000"), perpFP("100000"), priceFP("1"), priceFP("1")], + [0n, 0n, 0n], ); }); @@ -181,8 +183,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await checkUSDToPerpSwapAmt( @@ -197,8 +199,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await checkUSDToPerpSwapAmt( @@ -213,8 +215,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await checkUSDToPerpSwapAmt( @@ -229,8 +231,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await checkUSDToPerpSwapAmt( @@ -245,14 +247,15 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await checkUSDToPerpSwapAmt( billBroker, usdFP("20000"), [usdFP("100000"), perpFP("100000"), priceFP("1"), priceFP("1")], + [0n, 0n, 0n], ); }); @@ -263,10 +266,10 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); await checkUSDToPerpSwapAmt( billBroker, @@ -316,7 +319,7 @@ describe("BillBroker", function () { describe("#computePerpToUSDSwapAmt", function () { it("should return the perp amount and fees", async function () { const { billBroker } = await loadFixture(setupContracts); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("100"), [usdFP("115000"), perpFP("100000"), priceFP("1"), priceFP("1.15")], @@ -326,7 +329,7 @@ describe("BillBroker", function () { it("should return the perp amount and fees", async function () { const { billBroker } = await loadFixture(setupContracts); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("100"), [usdFP("110000"), perpFP("100000"), priceFP("1"), priceFP("1")], @@ -336,7 +339,7 @@ describe("BillBroker", function () { it("should return the perp amount and fees", async function () { const { billBroker } = await loadFixture(setupContracts); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("14285"), [usdFP("100000"), perpFP("100000"), priceFP("1"), priceFP("1")], @@ -346,7 +349,7 @@ describe("BillBroker", function () { it("should return the perp amount and fees", async function () { const { billBroker } = await loadFixture(setupContracts); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("14286"), [usdFP("100000"), perpFP("100000"), priceFP("1"), priceFP("1")], @@ -356,7 +359,7 @@ describe("BillBroker", function () { it("should return the perp amount and fees", async function () { const { billBroker } = await loadFixture(setupContracts); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("100"), [usdFP("100000"), perpFP("100000"), priceFP("1"), priceFP("0.9")], @@ -369,11 +372,11 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("100"), [usdFP("115000"), perpFP("100000"), priceFP("1"), priceFP("1.15")], @@ -385,11 +388,11 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("100"), [usdFP("110000"), perpFP("100000"), priceFP("1"), priceFP("1")], @@ -401,11 +404,11 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("14285"), [usdFP("100000"), perpFP("100000"), priceFP("1"), priceFP("1")], @@ -417,14 +420,15 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("14286"), [usdFP("100000"), perpFP("100000"), priceFP("1"), priceFP("1")], + [0n, 0n, 0n], ); }); @@ -433,11 +437,11 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("100"), [usdFP("100000"), perpFP("100000"), priceFP("1"), priceFP("0.9")], @@ -451,12 +455,12 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("100"), [usdFP("110000"), perpFP("100000"), priceFP("1"), priceFP("1")], @@ -473,7 +477,7 @@ describe("BillBroker", function () { await billBroker.swapUSDForPerps(usdFP("115000"), 0n); expect(await perp.balanceOf(billBroker.target)).to.eq(0n); - await checkPerpTpUSDSwapAmt( + await checkPerpToUSDSwapAmt( billBroker, perpFP("100"), [usdFP("100000"), 0n, priceFP("1"), priceFP("1")], @@ -529,15 +533,15 @@ describe("BillBroker", function () { describe("when oracle price is unreliable", function () { it("should revert", async function () { - const { billBroker, pricingStrategy } = await loadFixture(setupContracts); - await pricingStrategy.mockMethod("perpPrice()", [0n, false]); + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpFmvUsdPrice()", [0n, false]); await expect( billBroker.swapUSDForPerps(usdFP("115"), perpFP("100")), ).to.be.revertedWithCustomError(billBroker, "UnreliablePrice"); }); it("should revert", async function () { - const { billBroker, pricingStrategy } = await loadFixture(setupContracts); - await pricingStrategy.mockMethod("usdPrice()", [0n, false]); + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("usdPrice()", [0n, false]); await expect( billBroker.swapUSDForPerps(usdFP("115"), perpFP("100")), ).to.be.revertedWithCustomError(billBroker, "UnreliablePrice"); @@ -559,9 +563,9 @@ describe("BillBroker", function () { }); it("should increase the reserve ar", async function () { const { billBroker } = await loadFixture(setupContracts); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1")); + expect(await assetRatio(billBroker)).to.eq(percFP("1")); await billBroker.swapUSDForPerps(usdFP("115"), perpFP("100")); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1.002002002002002002")); + expect(await assetRatio(billBroker)).to.eq(percFP("1.002002002002002002")); }); it("should update the reserve", async function () { const { billBroker } = await loadFixture(setupContracts); @@ -585,8 +589,8 @@ describe("BillBroker", function () { const { billBroker, deployer, usd } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await expect(() => @@ -597,8 +601,8 @@ describe("BillBroker", function () { const { billBroker, deployer, perp } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await expect(() => @@ -609,20 +613,20 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1")); + expect(await assetRatio(billBroker)).to.eq(percFP("1")); await billBroker.swapUSDForPerps(usdFP("115"), perpFP("95")); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1.001951854261548471")); + expect(await assetRatio(billBroker)).to.eq(percFP("1.001951854261548471")); }); it("should update the reserve", async function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await billBroker.swapUSDForPerps(usdFP("115"), perpFP("95")); @@ -634,8 +638,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); const r = await billBroker.reserveState.staticCall(); @@ -650,10 +654,10 @@ describe("BillBroker", function () { const { billBroker, deployer, usd } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); await expect(() => billBroker.swapUSDForPerps(usdFP("115"), perpFP("95")), @@ -665,10 +669,10 @@ describe("BillBroker", function () { ); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); await billBroker .connect(deployer) @@ -681,10 +685,10 @@ describe("BillBroker", function () { const { billBroker, perp, feeCollector } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); await billBroker.transferOwnership(await feeCollector.getAddress()); await expect(() => @@ -695,23 +699,23 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1")); + expect(await assetRatio(billBroker)).to.eq(percFP("1")); await billBroker.swapUSDForPerps(usdFP("115"), perpFP("95")); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1.001956868809713276")); + expect(await assetRatio(billBroker)).to.eq(percFP("1.001956868809713276")); }); it("should update the reserve", async function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); await billBroker.swapUSDForPerps(usdFP("115"), perpFP("95")); const r = await reserveState(billBroker); @@ -722,10 +726,10 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); const r = await billBroker.reserveState.staticCall(); await expect(billBroker.swapUSDForPerps(usdFP("115"), perpFP("95"))) @@ -739,8 +743,8 @@ describe("BillBroker", function () { const { billBroker, deployer, usd } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await expect(() => @@ -751,8 +755,8 @@ describe("BillBroker", function () { const { billBroker, deployer, perp } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await expect(() => @@ -764,20 +768,20 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1")); + expect(await assetRatio(billBroker)).to.eq(percFP("1")); await billBroker.swapUSDForPerps(usdFP("3795"), perpFP("3130")); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1.066432664016930779")); + expect(await assetRatio(billBroker)).to.eq(percFP("1.066432664016930779")); }); it("should update the reserve", async function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await billBroker.swapUSDForPerps(usdFP("3795"), perpFP("3130")); @@ -789,8 +793,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); const r = await billBroker.reserveState.staticCall(); @@ -804,13 +808,13 @@ describe("BillBroker", function () { it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.updateARBounds( - [percentageFP("0.8"), percentageFP("1")], - [percentageFP("0.75"), percentageFP("1.05")], + [percFP("0.8"), percFP("1")], + [percFP("0.75"), percFP("1.05")], ); await updateFees(billBroker, { usdToPerpSwapFeePercs: { - lower: percentageFP("0.05"), - upper: percentageFP("0.5"), + lower: percFP("0.05"), + upper: percFP("0.5"), }, }); await expect( @@ -879,15 +883,15 @@ describe("BillBroker", function () { describe("when oracle price is unreliable", function () { it("should revert", async function () { - const { billBroker, pricingStrategy } = await loadFixture(setupContracts); - await pricingStrategy.mockMethod("perpPrice()", [0n, false]); + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpFmvUsdPrice()", [0n, false]); await expect( billBroker.swapPerpsForUSD(perpFP("115"), usdFP("100")), ).to.be.revertedWithCustomError(billBroker, "UnreliablePrice"); }); it("should revert", async function () { - const { billBroker, pricingStrategy } = await loadFixture(setupContracts); - await pricingStrategy.mockMethod("usdPrice()", [0n, false]); + const { billBroker, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("usdPrice()", [0n, false]); await expect( billBroker.swapPerpsForUSD(perpFP("115"), usdFP("100")), ).to.be.revertedWithCustomError(billBroker, "UnreliablePrice"); @@ -910,9 +914,9 @@ describe("BillBroker", function () { it("should decrease the reserve ar", async function () { const { billBroker } = await loadFixture(setupContracts); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1")); + expect(await assetRatio(billBroker)).to.eq(percFP("1")); await billBroker.swapPerpsForUSD(perpFP("100"), usdFP("115")); - expect(await assetRatio(billBroker)).to.eq(percentageFP("0.998001998001998001")); + expect(await assetRatio(billBroker)).to.eq(percFP("0.998001998001998001")); }); it("should update the reserve", async function () { const { billBroker } = await loadFixture(setupContracts); @@ -935,8 +939,8 @@ describe("BillBroker", function () { const { billBroker, deployer, perp } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); await expect(() => @@ -947,8 +951,8 @@ describe("BillBroker", function () { const { billBroker, deployer, usd } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); await expect(() => @@ -959,20 +963,20 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1")); + expect(await assetRatio(billBroker)).to.eq(percFP("1")); await billBroker.swapPerpsForUSD(perpFP("100"), usdFP("103")); - expect(await assetRatio(billBroker)).to.eq(percentageFP("0.998101898101898101")); + expect(await assetRatio(billBroker)).to.eq(percFP("0.998101898101898101")); }); it("should update the reserve", async function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); await billBroker.swapPerpsForUSD(perpFP("100"), usdFP("103")); @@ -984,8 +988,8 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); const r = await billBroker.reserveState.staticCall(); @@ -1000,10 +1004,10 @@ describe("BillBroker", function () { const { billBroker, deployer, perp } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); await expect(() => billBroker.swapPerpsForUSD(perpFP("100"), usdFP("103")), @@ -1015,10 +1019,10 @@ describe("BillBroker", function () { ); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); await billBroker.transferOwnership(await feeCollector.getAddress()); await expect(() => @@ -1029,10 +1033,10 @@ describe("BillBroker", function () { const { billBroker, usd, feeCollector } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); await billBroker.transferOwnership(await feeCollector.getAddress()); await expect(() => @@ -1043,23 +1047,23 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1")); + expect(await assetRatio(billBroker)).to.eq(percFP("1")); await billBroker.swapPerpsForUSD(perpFP("100"), usdFP("103")); - expect(await assetRatio(billBroker)).to.eq(percentageFP("0.998091908091908091")); + expect(await assetRatio(billBroker)).to.eq(percFP("0.998091908091908091")); }); it("should update the reserve", async function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); await billBroker.swapPerpsForUSD(perpFP("100"), usdFP("103")); const r = await reserveState(billBroker); @@ -1070,10 +1074,10 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); const r = await billBroker.reserveState.staticCall(); await expect(billBroker.swapPerpsForUSD(perpFP("100"), usdFP("103"))) @@ -1087,8 +1091,8 @@ describe("BillBroker", function () { const { billBroker, deployer, perp } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); await expect(() => @@ -1099,8 +1103,8 @@ describe("BillBroker", function () { const { billBroker, deployer, usd } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); await expect(() => @@ -1111,20 +1115,20 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); - expect(await assetRatio(billBroker)).to.eq(percentageFP("1")); + expect(await assetRatio(billBroker)).to.eq(percFP("1")); await billBroker.swapPerpsForUSD(perpFP("3600"), usdFP("3700")); - expect(await assetRatio(billBroker)).to.eq(percentageFP("0.933976833976833976")); + expect(await assetRatio(billBroker)).to.eq(percFP("0.933976833976833976")); }); it("should update the reserve", async function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); await billBroker.swapPerpsForUSD(perpFP("3600"), usdFP("3700")); @@ -1138,13 +1142,13 @@ describe("BillBroker", function () { it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); await billBroker.updateARBounds( - [percentageFP("1"), percentageFP("1.1")], - [percentageFP("0.95"), percentageFP("1.25")], + [percFP("1"), percFP("1.1")], + [percFP("0.95"), percFP("1.25")], ); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, }); await expect( @@ -1155,10 +1159,10 @@ describe("BillBroker", function () { const { billBroker } = await loadFixture(setupContracts); await updateFees(billBroker, { perpToUSDSwapFeePercs: { - lower: percentageFP("0.1"), - upper: percentageFP("0.5"), + lower: percFP("0.1"), + upper: percFP("0.5"), }, - protocolSwapSharePerc: percentageFP("0.1"), + protocolSwapSharePerc: percFP("0.1"), }); const r = await billBroker.reserveState.staticCall(); await expect(billBroker.swapPerpsForUSD(perpFP("5000"), usdFP("4000"))) diff --git a/spot-vaults/test/SpotAppraiser.ts b/spot-vaults/test/SpotAppraiser.ts deleted file mode 100644 index 80f1e85b..00000000 --- a/spot-vaults/test/SpotAppraiser.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { ethers } from "hardhat"; -import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; -import { expect } from "chai"; -import { oracleAnsFP, perpFP, percentageFP, priceFP, DMock } from "./helpers"; - -const nowTS = () => parseInt(Date.now() / 1000); - -describe("SpotAppraiser", function () { - async function setupContracts() { - const accounts = await ethers.getSigners(); - const deployer = accounts[0]; - - const amplTargetOracle = new DMock("MedianOracle"); - await amplTargetOracle.deploy(); - await amplTargetOracle.mockMethod("getData()", [priceFP("1.15"), true]); - await amplTargetOracle.mockMethod("DECIMALS()", [18]); - - const policy = new DMock("UFragmentsPolicy"); - await policy.deploy(); - await policy.mockMethod("cpiOracle()", [amplTargetOracle.target]); - - const ampl = new DMock("UFragments"); - await ampl.deploy(); - await ampl.mockMethod("decimals()", [9]); - await ampl.mockMethod("monetaryPolicy()", [policy.target]); - - const bond = new DMock("BondController"); - await bond.deploy(); - await bond.mockMethod("isMature()", [false]); - await bond.mockMethod("trancheCount()", [2]); - await bond.mockMethod("totalDebt()", [perpFP("500000")]); - await ampl.mockCall("balanceOf(address)", [bond.target], [perpFP("500000")]); - - const tranche = new DMock("Tranche"); - await tranche.deploy(); - await tranche.mockMethod("bond()", [bond.target]); - await tranche.mockMethod("totalSupply()", [perpFP("100000")]); - await bond.mockCall("tranches(uint256)", [0], [tranche.target, 200]); - await bond.mockCall("tranches(uint256)", [1], [ethers.ZeroAddress, 800]); - - const spot = new DMock("PerpetualTranche"); - await spot.deploy(); - await spot.mockMethod("underlying()", [ampl.target]); - await spot.mockMethod("getTVL()", [perpFP("1000000")]); - await spot.mockMethod("totalSupply()", [perpFP("1000000")]); - await spot.mockMethod("deviationRatio()", [oracleAnsFP("1.5")]); - await spot.mockMethod("getReserveCount()", [2]); - await spot.mockCall("getReserveAt(uint256)", [0], [ampl.target]); - await spot.mockCall("getReserveAt(uint256)", [1], [tranche.target]); - await ampl.mockCall("balanceOf(address)", [spot.target], [perpFP("1000")]); - - const usdPriceOrcle = new DMock("IChainlinkOracle"); - await usdPriceOrcle.deploy(); - await usdPriceOrcle.mockMethod("decimals()", [8]); - await usdPriceOrcle.mockMethod("latestRoundData()", [ - 0, - oracleAnsFP("1"), - 0, - nowTS(), - 0, - ]); - - const SpotAppraiser = await ethers.getContractFactory("SpotAppraiser"); - const strategy = await SpotAppraiser.deploy( - spot.target, - usdPriceOrcle.target, - amplTargetOracle.target, - ); - return { - deployer, - ampl, - spot, - usdPriceOrcle, - amplTargetOracle, - bond, - tranche, - strategy, - }; - } - - describe("init", function () { - it("should initial params", async function () { - const { deployer, strategy, ampl, spot, usdPriceOrcle, amplTargetOracle } = - await loadFixture(setupContracts); - expect(await strategy.AMPL()).to.eq(ampl.target); - expect(await strategy.SPOT()).to.eq(spot.target); - expect(await strategy.USD_ORACLE()).to.eq(usdPriceOrcle.target); - expect(await strategy.AMPL_CPI_ORACLE()).to.eq(amplTargetOracle.target); - expect(await strategy.AMPL_DUST_AMT()).to.eq(perpFP("25000")); - expect(await strategy.minSPOTDR()).to.eq(percentageFP("0.8")); - expect(await strategy.minSeniorCDR()).to.eq(percentageFP("1.1")); - expect(await strategy.owner()).to.eq(await deployer.getAddress()); - expect(await strategy.decimals()).to.eq(18); - }); - }); - - describe("#updateMinSPOTDR", function () { - describe("when triggered by non-owner", function () { - it("should revert", async function () { - const { strategy } = await loadFixture(setupContracts); - await strategy.renounceOwnership(); - await expect(strategy.updateMinSPOTDR(percentageFP("1.15"))).to.be.revertedWith( - "Ownable: caller is not the owner", - ); - }); - }); - - describe("when triggered by owner", function () { - it("should update value", async function () { - const { strategy } = await loadFixture(setupContracts); - await strategy.updateMinSPOTDR(percentageFP("1.15")); - expect(await strategy.minSPOTDR()).to.eq(percentageFP("1.15")); - }); - }); - }); - - describe("#updateMinPerpCollateralCDR", function () { - describe("when triggered by non-owner", function () { - it("should revert", async function () { - const { strategy } = await loadFixture(setupContracts); - await strategy.renounceOwnership(); - await expect( - strategy.updateMinPerpCollateralCDR(percentageFP("1.25")), - ).to.be.revertedWith("Ownable: caller is not the owner"); - }); - }); - - describe("when cdr is invalid", function () { - it("should revert", async function () { - const { strategy } = await loadFixture(setupContracts); - await expect( - strategy.updateMinPerpCollateralCDR(percentageFP("0.9")), - ).to.be.revertedWithCustomError(strategy, "InvalidSeniorCDRBound"); - }); - }); - - describe("when triggered by owner", function () { - it("should update value", async function () { - const { strategy } = await loadFixture(setupContracts); - await strategy.updateMinPerpCollateralCDR(percentageFP("1.25")); - expect(await strategy.minSeniorCDR()).to.eq(percentageFP("1.25")); - }); - }); - }); - - describe("#usdPrice", function () { - describe("when data is stale", function () { - it("should return invalid", async function () { - const { strategy, usdPriceOrcle } = await loadFixture(setupContracts); - await usdPriceOrcle.mockMethod("latestRoundData()", [ - 0, - oracleAnsFP("1"), - 0, - nowTS() - 50 * 3600, - 0, - ]); - const p = await strategy.usdPrice(); - expect(p[0]).to.eq(priceFP("1")); - expect(p[1]).to.eq(false); - }); - }); - - describe("when oracle price is below thresh", function () { - it("should return invalid", async function () { - const { strategy, usdPriceOrcle } = await loadFixture(setupContracts); - await usdPriceOrcle.mockMethod("latestRoundData()", [ - 0, - oracleAnsFP("0.98"), - 0, - nowTS(), - 0, - ]); - const p = await strategy.usdPrice(); - expect(p[0]).to.eq(priceFP("1")); - expect(p[1]).to.eq(false); - }); - }); - - describe("when oracle price is above thresh", function () { - it("should return invalid", async function () { - const { strategy, usdPriceOrcle } = await loadFixture(setupContracts); - await usdPriceOrcle.mockMethod("latestRoundData()", [ - 0, - oracleAnsFP("1.02"), - 0, - nowTS(), - 0, - ]); - const p = await strategy.usdPrice(); - expect(p[0]).to.eq(priceFP("1")); - expect(p[1]).to.eq(false); - }); - }); - - it("should return price", async function () { - const { strategy } = await loadFixture(setupContracts); - const p = await strategy.usdPrice(); - expect(p[0]).to.eq(priceFP("1")); - expect(p[1]).to.eq(true); - }); - }); - - describe("#perpPrice", function () { - describe("when AMPL target data is invalid", function () { - it("should return invalid", async function () { - const { strategy, amplTargetOracle } = await loadFixture(setupContracts); - await amplTargetOracle.mockMethod("getData()", [priceFP("1.2"), false]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.2")); - expect(p[1]).to.eq(false); - }); - }); - - describe("when balancer DR is too low", function () { - it("should return invalid", async function () { - const { strategy, spot } = await loadFixture(setupContracts); - await spot.mockMethod("deviationRatio()", [oracleAnsFP("0.79999")]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.15")); - expect(p[1]).to.eq(false); - }); - }); - - describe("when spot senior cdr is too low", function () { - it("should return invalid", async function () { - const { strategy, ampl, bond } = await loadFixture(setupContracts); - await ampl.mockCall("balanceOf(address)", [bond.target], [perpFP("109999")]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.15")); - expect(p[1]).to.eq(false); - }); - it("should return invalid", async function () { - const { strategy, bond } = await loadFixture(setupContracts); - await bond.mockMethod("isMature()", [true]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.15")); - expect(p[1]).to.eq(false); - }); - }); - - describe("when spot has mature AMPL", function () { - it("should return invalid", async function () { - const { strategy, ampl, spot } = await loadFixture(setupContracts); - await ampl.mockCall("balanceOf(address)", [spot.target], [perpFP("25001")]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.15")); - expect(p[1]).to.eq(false); - }); - }); - - it("should return price", async function () { - const { strategy } = await loadFixture(setupContracts); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.15")); - expect(p[1]).to.eq(true); - }); - - describe("when debasement/enrichment multiplier is not 1", function () { - it("should return price", async function () { - const { strategy, spot } = await loadFixture(setupContracts); - await spot.mockMethod("getTVL()", [perpFP("1500000")]); - await spot.mockMethod("totalSupply()", [perpFP("1000000")]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.725")); - expect(p[1]).to.eq(true); - }); - it("should return price", async function () { - const { strategy, spot } = await loadFixture(setupContracts); - await spot.mockMethod("getTVL()", [perpFP("900000")]); - await spot.mockMethod("totalSupply()", [perpFP("1000000")]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.035")); - expect(p[1]).to.eq(true); - }); - }); - }); -}); diff --git a/spot-vaults/test/SpotCDRPricer.ts b/spot-vaults/test/SpotCDRPricer.ts deleted file mode 100644 index b3095ee7..00000000 --- a/spot-vaults/test/SpotCDRPricer.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { ethers } from "hardhat"; -import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; -import { expect } from "chai"; -import { oracleAnsFP, perpFP, priceFP, DMock } from "./helpers"; - -const nowTS = () => parseInt(Date.now() / 1000); - -describe("SpotCDRPricer", function () { - async function setupContracts() { - const accounts = await ethers.getSigners(); - const deployer = accounts[0]; - - const amplTargetOracle = new DMock("MedianOracle"); - await amplTargetOracle.deploy(); - await amplTargetOracle.mockMethod("getData()", [priceFP("1.15"), true]); - await amplTargetOracle.mockMethod("DECIMALS()", [18]); - - const policy = new DMock("UFragmentsPolicy"); - await policy.deploy(); - await policy.mockMethod("cpiOracle()", [amplTargetOracle.target]); - - const ampl = new DMock("UFragments"); - await ampl.deploy(); - await ampl.mockMethod("decimals()", [9]); - await ampl.mockMethod("monetaryPolicy()", [policy.target]); - - const spot = new DMock("PerpetualTranche"); - await spot.deploy(); - await spot.mockMethod("underlying()", [ampl.target]); - await spot.mockMethod("getTVL()", [perpFP("1000000")]); - await spot.mockMethod("totalSupply()", [perpFP("1000000")]); - - const usdPriceOrcle = new DMock("IChainlinkOracle"); - await usdPriceOrcle.deploy(); - await usdPriceOrcle.mockMethod("decimals()", [8]); - await usdPriceOrcle.mockMethod("latestRoundData()", [ - 0, - oracleAnsFP("1"), - 0, - nowTS(), - 0, - ]); - - const SpotCDRPricer = await ethers.getContractFactory("SpotCDRPricer"); - const strategy = await SpotCDRPricer.deploy( - spot.target, - usdPriceOrcle.target, - amplTargetOracle.target, - ); - return { - deployer, - ampl, - spot, - usdPriceOrcle, - amplTargetOracle, - strategy, - }; - } - - describe("init", function () { - it("should initial params", async function () { - const { strategy, ampl, spot, usdPriceOrcle, amplTargetOracle } = await loadFixture( - setupContracts, - ); - expect(await strategy.AMPL()).to.eq(ampl.target); - expect(await strategy.SPOT()).to.eq(spot.target); - expect(await strategy.USD_ORACLE()).to.eq(usdPriceOrcle.target); - expect(await strategy.AMPL_CPI_ORACLE()).to.eq(amplTargetOracle.target); - expect(await strategy.decimals()).to.eq(18); - }); - }); - - describe("#usdPrice", function () { - describe("when data is stale", function () { - it("should return invalid", async function () { - const { strategy, usdPriceOrcle } = await loadFixture(setupContracts); - await usdPriceOrcle.mockMethod("latestRoundData()", [ - 0, - oracleAnsFP("1"), - 0, - nowTS() - 50 * 3600, - 0, - ]); - const p = await strategy.usdPrice(); - expect(p[0]).to.eq(priceFP("1")); - expect(p[1]).to.eq(false); - }); - }); - - describe("when oracle price is below thresh", function () { - it("should return invalid", async function () { - const { strategy, usdPriceOrcle } = await loadFixture(setupContracts); - await usdPriceOrcle.mockMethod("latestRoundData()", [ - 0, - oracleAnsFP("0.98"), - 0, - nowTS(), - 0, - ]); - const p = await strategy.usdPrice(); - expect(p[0]).to.eq(priceFP("1")); - expect(p[1]).to.eq(false); - }); - }); - - describe("when oracle price is above thresh", function () { - it("should return invalid", async function () { - const { strategy, usdPriceOrcle } = await loadFixture(setupContracts); - await usdPriceOrcle.mockMethod("latestRoundData()", [ - 0, - oracleAnsFP("1.02"), - 0, - nowTS(), - 0, - ]); - const p = await strategy.usdPrice(); - expect(p[0]).to.eq(priceFP("1")); - expect(p[1]).to.eq(false); - }); - }); - - it("should return price", async function () { - const { strategy } = await loadFixture(setupContracts); - const p = await strategy.usdPrice(); - expect(p[0]).to.eq(priceFP("1")); - expect(p[1]).to.eq(true); - }); - }); - - describe("#perpPrice", function () { - describe("when AMPL target data is invalid", function () { - it("should return invalid", async function () { - const { strategy, amplTargetOracle } = await loadFixture(setupContracts); - await amplTargetOracle.mockMethod("getData()", [priceFP("1.2"), false]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.2")); - expect(p[1]).to.eq(false); - }); - }); - - it("should return price", async function () { - const { strategy } = await loadFixture(setupContracts); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.15")); - expect(p[1]).to.eq(true); - }); - - describe("when debasement/enrichment multiplier is not 1", function () { - it("should return price", async function () { - const { strategy, spot } = await loadFixture(setupContracts); - await spot.mockMethod("getTVL()", [perpFP("1500000")]); - await spot.mockMethod("totalSupply()", [perpFP("1000000")]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.725")); - expect(p[1]).to.eq(true); - }); - it("should return price", async function () { - const { strategy, spot } = await loadFixture(setupContracts); - await spot.mockMethod("getTVL()", [perpFP("900000")]); - await spot.mockMethod("totalSupply()", [perpFP("1000000")]); - const p = await strategy.perpPrice.staticCall(); - expect(p[0]).to.eq(priceFP("1.035")); - expect(p[1]).to.eq(true); - }); - }); - }); -}); diff --git a/spot-vaults/test/SpotPricer.ts b/spot-vaults/test/SpotPricer.ts new file mode 100644 index 00000000..03550b91 --- /dev/null +++ b/spot-vaults/test/SpotPricer.ts @@ -0,0 +1,433 @@ +import { ethers } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { expect } from "chai"; +import { + amplOracleFP, + usdOracleFP, + ethOracleFP, + perpFP, + percFP, + priceFP, + wamplFP, + amplFP, + drFP, + DMock, +} from "./helpers"; + +const nowTS = () => parseInt(Date.now() / 1000); + +describe("SpotPricer", function () { + async function setupContracts() { + const accounts = await ethers.getSigners(); + const deployer = accounts[0]; + + const amplTargetOracle = new DMock("MedianOracle"); + await amplTargetOracle.deploy(); + await amplTargetOracle.mockMethod("getData()", [amplOracleFP("1.15"), true]); + await amplTargetOracle.mockMethod("DECIMALS()", [18]); + + const usdcPriceOrcle = new DMock("IChainlinkOracle"); + await usdcPriceOrcle.deploy(); + await usdcPriceOrcle.mockMethod("decimals()", [8]); + await usdcPriceOrcle.mockMethod("latestRoundData()", [ + 0, + usdOracleFP("1"), + 0, + nowTS(), + 0, + ]); + + const ethPriceOrcle = new DMock("IChainlinkOracle"); + await ethPriceOrcle.deploy(); + await ethPriceOrcle.mockMethod("decimals()", [18]); + await ethPriceOrcle.mockMethod("latestRoundData()", [ + 0, + ethOracleFP("2357.76"), + 0, + nowTS(), + 0, + ]); + + const usdc = new DMock("contracts/_interfaces/external/IERC20.sol:IERC20"); + await usdc.deploy(); + + const ampl = new DMock("UFragments"); + await ampl.deploy(); + + const wampl = new DMock("IWAMPL"); + await wampl.deploy(); + await wampl.mockCall( + "wrapperToUnderlying(uint256)", + [wamplFP("1")], + [amplFP("7.692284616")], + ); + + const bond = new DMock( + "contracts/_interfaces/external/IBondController.sol:IBondController", + ); + await bond.deploy(); + await bond.mockMethod("collateralBalance()", [perpFP("2000")]); + + const tranche = new DMock("Tranche"); + await tranche.deploy(); + await tranche.mockMethod("bond()", [bond.target]); + + const feePolicy = new DMock("IPerpFeePolicy"); + await feePolicy.deploy(); + await feePolicy.mockMethod("decimals()", [8]); + await feePolicy.mockMethod("deviationRatio()", [drFP("1")]); + await feePolicy.mockMethod("computePerpRolloverFeePerc(uint256)", [0]); + + const spot = new DMock("PerpetualTranche"); + await spot.deploy(); + await spot.mockMethod("feePolicy()", [feePolicy.target]); + await spot.mockMethod("underlying()", [ampl.target]); + await spot.mockMethod("getTVL()", [perpFP("1000")]); + await spot.mockMethod("totalSupply()", [perpFP("1000")]); + await spot.mockMethod("getReserveCount()", [2]); + await spot.mockCall("getReserveAt(uint256)", [0], [ampl.target]); + await spot.mockCall("getReserveTokenBalance(address)", [ampl.target], ["0"]); + await spot.mockCall("getReserveAt(uint256)", [1], [tranche.target]); + await spot.mockCall( + "getReserveTokenBalance(address)", + [tranche.target], + [perpFP("1000")], + ); + await spot.mockCall( + "getReserveTokenValue(address)", + [tranche.target], + [perpFP("1000")], + ); + + const wamplPool = new DMock("IUniswapV3Pool"); + await wamplPool.deploy(); + await wamplPool.mockMethod("token1()", [wampl.target]); + await wamplPool.mockMethod("observe(uint32[])", [ + ["376921685400", "377121673968"], + ["5338479444003340079488911551", "5338479669430834262798687400"], + ]); + + const spotPool = new DMock("IUniswapV3Pool"); + await spotPool.deploy(); + await spotPool.mockMethod("token0()", [usdc.target]); + await spotPool.mockMethod("token1()", [spot.target]); + await spotPool.mockMethod("observe(uint32[])", [ + ["3780978019944", "3781218388344"], + [ + "15033345577239143106349258268248842184594399522", + "15033345577239143129748458314415242759127803748", + ], + ]); + + const SpotPricer = await ethers.getContractFactory("SpotPricer"); + const strategy = await SpotPricer.deploy( + wamplPool.target, + spotPool.target, + ethPriceOrcle.target, + usdcPriceOrcle.target, + amplTargetOracle.target, + ); + return { + deployer, + amplTargetOracle, + ethPriceOrcle, + usdcPriceOrcle, + usdc, + ampl, + wampl, + spot, + feePolicy, + bond, + tranche, + wamplPool, + spotPool, + strategy, + }; + } + + describe("init", function () { + it("should initial params", async function () { + const { + deployer, + amplTargetOracle, + ethPriceOrcle, + usdcPriceOrcle, + usdc, + ampl, + wampl, + spot, + wamplPool, + spotPool, + strategy, + } = await loadFixture(setupContracts); + expect(await strategy.WETH_WAMPL_POOL()).to.eq(wamplPool.target); + expect(await strategy.USDC_SPOT_POOL()).to.eq(spotPool.target); + + expect(await strategy.ETH_ORACLE()).to.eq(ethPriceOrcle.target); + expect(await strategy.USDC_ORACLE()).to.eq(usdcPriceOrcle.target); + expect(await strategy.CPI_ORACLE()).to.eq(amplTargetOracle.target); + + expect(await strategy.WAMPL()).to.eq(wampl.target); + expect(await strategy.USDC()).to.eq(usdc.target); + expect(await strategy.AMPL()).to.eq(ampl.target); + expect(await strategy.SPOT()).to.eq(spot.target); + + expect(await strategy.spotDiscountFactor()).to.eq(percFP("1")); + const b = await strategy.perpBeta(); + expect(b[0]).to.eq(percFP("1")); + expect(b[1]).to.eq(true); + expect(await strategy.owner()).to.eq(await deployer.getAddress()); + expect(await strategy.decimals()).to.eq(18); + }); + }); + + describe("#updateSpotDiscountFactor", function () { + describe("when triggered by non-owner", function () { + it("should revert", async function () { + const { strategy } = await loadFixture(setupContracts); + await strategy.transferOwnership(ethers.ZeroAddress); + await expect(strategy.updateSpotDiscountFactor(percFP("2"))).to.be.revertedWith( + "UnauthorizedCall", + ); + }); + }); + + describe("when triggered by owner", function () { + it("should update value", async function () { + const { strategy } = await loadFixture(setupContracts); + await strategy.updateSpotDiscountFactor(percFP("2")); + expect(await strategy.spotDiscountFactor()).to.eq(percFP("2")); + const b = await strategy.perpBeta(); + expect(b[0]).to.eq(percFP("2")); + expect(b[1]).to.eq(true); + }); + }); + }); + + describe("#transferOwnership", function () { + describe("when triggered by non-owner", function () { + it("should revert", async function () { + const { strategy } = await loadFixture(setupContracts); + await strategy.transferOwnership(ethers.ZeroAddress); + await expect(strategy.transferOwnership(ethers.ZeroAddress)).to.be.revertedWith( + "UnauthorizedCall", + ); + }); + }); + + describe("when triggered by owner", function () { + it("should update value", async function () { + const { strategy } = await loadFixture(setupContracts); + await strategy.transferOwnership(ethers.ZeroAddress); + expect(await strategy.owner()).to.eq(ethers.ZeroAddress); + }); + }); + }); + + describe("#usdPrice", function () { + describe("when data is stale", function () { + it("should return invalid", async function () { + const { strategy, usdcPriceOrcle } = await loadFixture(setupContracts); + await usdcPriceOrcle.mockMethod("latestRoundData()", [ + 0, + usdOracleFP("1"), + 0, + nowTS() - 50 * 3600, + 0, + ]); + const p = await strategy.usdPrice(); + expect(p[0]).to.eq(amplOracleFP("1")); + expect(p[1]).to.eq(false); + }); + }); + + describe("when oracle price is below thresh", function () { + it("should return invalid", async function () { + const { strategy, usdcPriceOrcle } = await loadFixture(setupContracts); + await usdcPriceOrcle.mockMethod("latestRoundData()", [ + 0, + usdOracleFP("0.98"), + 0, + nowTS(), + 0, + ]); + const p = await strategy.usdPrice(); + expect(p[0]).to.eq(amplOracleFP("1")); + expect(p[1]).to.eq(false); + }); + }); + + describe("when oracle price is above thresh", function () { + it("should return invalid", async function () { + const { strategy, usdcPriceOrcle } = await loadFixture(setupContracts); + await usdcPriceOrcle.mockMethod("latestRoundData()", [ + 0, + usdOracleFP("1.02"), + 0, + nowTS(), + 0, + ]); + const p = await strategy.usdPrice(); + expect(p[0]).to.eq(amplOracleFP("1")); + expect(p[1]).to.eq(false); + }); + }); + + it("should return price", async function () { + const { strategy } = await loadFixture(setupContracts); + const p = await strategy.usdPrice(); + expect(p[0]).to.eq(amplOracleFP("1")); + expect(p[1]).to.eq(true); + }); + }); + + describe("#perpFmvUsdPrice", function () { + describe("when AMPL target data is invalid", function () { + it("should return invalid", async function () { + const { strategy, amplTargetOracle } = await loadFixture(setupContracts); + await amplTargetOracle.mockMethod("getData()", [amplOracleFP("1.2"), false]); + const p = await strategy.perpFmvUsdPrice.staticCall(); + expect(p[0]).to.eq(amplOracleFP("1.2")); + expect(p[1]).to.eq(false); + }); + }); + + it("should return price", async function () { + const { strategy } = await loadFixture(setupContracts); + const p = await strategy.perpFmvUsdPrice.staticCall(); + expect(p[0]).to.eq(amplOracleFP("1.15")); + expect(p[1]).to.eq(true); + }); + + describe("when debasement/enrichment multiplier is not 1", function () { + it("should return price", async function () { + const { strategy, spot } = await loadFixture(setupContracts); + await spot.mockMethod("getTVL()", [perpFP("1500000")]); + await spot.mockMethod("totalSupply()", [perpFP("1000000")]); + const p = await strategy.perpFmvUsdPrice.staticCall(); + expect(p[0]).to.eq(amplOracleFP("1.725")); + expect(p[1]).to.eq(true); + }); + it("should return price", async function () { + const { strategy, spot } = await loadFixture(setupContracts); + await spot.mockMethod("getTVL()", [perpFP("900000")]); + await spot.mockMethod("totalSupply()", [perpFP("1000000")]); + const p = await strategy.perpFmvUsdPrice.staticCall(); + expect(p[0]).to.eq(amplOracleFP("1.035")); + expect(p[1]).to.eq(true); + }); + }); + }); + + describe("#perpUsdPrice", function () { + describe("when usdc price is invalid", function () { + it("should return invalid", async function () { + const { strategy, usdcPriceOrcle } = await loadFixture(setupContracts); + await usdcPriceOrcle.mockMethod("latestRoundData()", [ + 0, + usdOracleFP("1"), + 0, + nowTS() - 50 * 3600, + 0, + ]); + const p = await strategy.perpUsdPrice.staticCall(); + expect(p[0]).to.eq(priceFP("1.260097503535148000")); + expect(p[1]).to.eq(false); + }); + }); + + it("should compute spot usd price", async function () { + const { strategy } = await loadFixture(setupContracts); + const p = await strategy.perpUsdPrice.staticCall(); + expect(p[0]).to.eq(priceFP("1.260097503535148000")); + expect(p[1]).to.eq(true); + }); + }); + + describe("#underlyingUsdPrice", function () { + describe("when eth price is invalid", function () { + it("should return invalid", async function () { + const { strategy, ethPriceOrcle } = await loadFixture(setupContracts); + await ethPriceOrcle.mockMethod("latestRoundData()", [ + 0, + ethOracleFP("3000"), + 0, + nowTS() - 50 * 3600, + 0, + ]); + const p = await strategy.underlyingUsdPrice.staticCall(); + expect(p[0]).to.eq(priceFP("1.508668510241881174")); + expect(p[1]).to.eq(false); + }); + }); + + it("should compute ampl price", async function () { + const { strategy } = await loadFixture(setupContracts); + const p = await strategy.underlyingUsdPrice.staticCall(); + expect(p[0]).to.eq(priceFP("1.185692755569299252")); + expect(p[1]).to.eq(true); + }); + }); + + describe("intermediate prices", function () { + it("should compute eth price", async function () { + const { strategy } = await loadFixture(setupContracts); + const p = await strategy.ethUsdPrice.staticCall(); + expect(p[0]).to.eq(priceFP("2357.76")); + expect(p[1]).to.eq(true); + }); + + it("should compute wampl price", async function () { + const { strategy } = await loadFixture(setupContracts); + const p = await strategy.wamplUsdPrice.staticCall(); + expect(p[0]).to.eq(priceFP("9.120686142968368965")); + expect(p[1]).to.eq(true); + }); + + it("should compute spot price deviation", async function () { + const { strategy } = await loadFixture(setupContracts); + const p = await strategy.spotPriceDeviation.staticCall(); + expect(p[0]).to.eq(percFP("1.095736959595780869")); + expect(p[1]).to.eq(true); + }); + + it("should compute spot price deviation", async function () { + const { strategy, amplTargetOracle } = await loadFixture(setupContracts); + await amplTargetOracle.mockMethod("getData()", [amplOracleFP("2"), true]); + const p = await strategy.spotPriceDeviation.staticCall(); + expect(p[0]).to.eq(percFP("0.630048751767574000")); + expect(p[1]).to.eq(true); + }); + + it("should compute spot price deviation", async function () { + const { strategy, amplTargetOracle } = await loadFixture(setupContracts); + await amplTargetOracle.mockMethod("getData()", [amplOracleFP("0"), false]); + const p = await strategy.spotPriceDeviation.staticCall(); + expect(p[0]).to.eq(percFP("100")); + expect(p[1]).to.eq(false); + }); + + it("should compute ampl price deviation", async function () { + const { strategy } = await loadFixture(setupContracts); + const p = await strategy.amplPriceDeviation.staticCall(); + expect(p[0]).to.eq(percFP("1.031037178755912393")); + expect(p[1]).to.eq(true); + }); + + it("should compute spot price deviation", async function () { + const { strategy, amplTargetOracle } = await loadFixture(setupContracts); + await amplTargetOracle.mockMethod("getData()", [amplOracleFP("1.5"), true]); + const p = await strategy.amplPriceDeviation.staticCall(); + expect(p[0]).to.eq(percFP("0.790461837046199501")); + expect(p[1]).to.eq(true); + }); + + it("should compute spot price deviation", async function () { + const { strategy, amplTargetOracle } = await loadFixture(setupContracts); + await amplTargetOracle.mockMethod("getData()", [amplOracleFP("0"), false]); + const p = await strategy.amplPriceDeviation.staticCall(); + expect(p[0]).to.eq(percFP("100")); + expect(p[1]).to.eq(false); + }); + }); +}); diff --git a/spot-vaults/test/UsdcSpotManager.ts b/spot-vaults/test/UsdcSpotManager.ts index e4e437a2..59fa6d0d 100644 --- a/spot-vaults/test/UsdcSpotManager.ts +++ b/spot-vaults/test/UsdcSpotManager.ts @@ -17,22 +17,22 @@ describe("UsdcSpotManager", function () { // Deploy mock contracts const mockVault = new DMock("IAlphaProVault"); await mockVault.deploy(); - await mockVault.mockMethod("fullLower()", [-800000]); - await mockVault.mockMethod("fullUpper()", [800000]); - await mockVault.mockMethod("baseLower()", [45000]); - await mockVault.mockMethod("baseUpper()", [55000]); - await mockVault.mockMethod("getTwap()", [67200]); - await mockVault.mockMethod("limitThreshold()", [800000]); const mockPool = new DMock("IUniswapV3Pool"); await mockPool.deploy(); + await mockPool.mockCall( + "positions(bytes32)", + [univ3PositionKey(mockVault.target, 20000, 40000)], + [50000, 0, 0, 0, 0], + ); + await mockVault.mockMethod("limitLower()", [20000]); + await mockVault.mockMethod("limitUpper()", [40000]); await mockVault.mockMethod("pool()", [mockPool.target]); - const mockAppraiser = new DMock("ISpotPricingStrategy"); - await mockAppraiser.deploy(); - await mockAppraiser.mockMethod("decimals()", [18]); - await mockAppraiser.mockMethod("perpPrice()", [priceFP("1.2"), true]); - await mockAppraiser.mockMethod("usdPrice()", [priceFP("1"), true]); + const mockOracle = new DMock("IMetaOracle"); + await mockOracle.deploy(); + await mockOracle.mockMethod("decimals()", [18]); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1.2"), true]); const mockUsdc = new DMock("IERC20Upgradeable"); await mockUsdc.deploy(); @@ -44,13 +44,13 @@ describe("UsdcSpotManager", function () { // Deploy Manager contract const Manager = await ethers.getContractFactory("UsdcSpotManager"); - const manager = await Manager.deploy(mockVault.target, mockAppraiser.target); + const manager = await Manager.deploy(mockVault.target, mockOracle.target); return { owner, addr1, mockVault, - mockAppraiser, + mockOracle, mockUsdc, mockSpot, mockPool, @@ -58,6 +58,40 @@ describe("UsdcSpotManager", function () { }; } + async function stubRebalance(mockVault) { + await mockVault.clearMockMethod("setPeriod(uint32)"); + await mockVault.clearMockMethod("period()"); + await mockVault.mockMethod("rebalance()", []); + } + + async function stubForceRebalance(mockVault) { + await mockVault.mockMethod("period()", [86400]); + await mockVault.mockCall("setPeriod(uint32)", [0], []); + await mockVault.mockCall("setPeriod(uint32)", [86400], []); + await mockVault.mockMethod("rebalance()", []); + } + + async function stubOverweightSpot(mockVault) { + await mockVault.mockMethod("getTwap()", [30001]); + } + + async function stubOverweightUsdc(mockVault) { + await mockVault.mockMethod("getTwap()", [29999]); + } + + async function stubUnchangedLimitRange(mockVault) { + await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); + } + + async function stubRemovedLimitRange(mockVault) { + await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); + await mockVault.mockCall( + "emergencyBurn(int24,int24,uint128)", + [20000, 40000, 50000], + [], + ); + } + describe("Initialization", function () { it("should set the correct owner", async function () { const { manager, owner } = await loadFixture(setupContracts); @@ -70,15 +104,13 @@ describe("UsdcSpotManager", function () { }); it("should set the appraiser address", async function () { - const { manager, mockAppraiser } = await loadFixture(setupContracts); - expect(await manager.pricingStrategy()).to.eq(mockAppraiser.target); + const { manager, mockOracle } = await loadFixture(setupContracts); + expect(await manager.oracle()).to.eq(mockOracle.target); }); it("should set the token refs", async function () { - const { manager, mockUsdc, mockSpot, mockPool } = await loadFixture(setupContracts); + const { manager, mockPool } = await loadFixture(setupContracts); expect(await manager.POOL()).to.eq(mockPool.target); - expect(await manager.USDC()).to.eq(mockUsdc.target); - expect(await manager.SPOT()).to.eq(mockSpot.target); }); it("should return the decimals", async function () { @@ -87,33 +119,34 @@ describe("UsdcSpotManager", function () { }); }); - describe("#transferOwnership", function () { + describe("#updateOracle", function () { it("should fail to when called by non-owner", async function () { const { manager, addr1 } = await loadFixture(setupContracts); + const mockOracle = new DMock("IMetaOracle"); + await mockOracle.deploy(); + await mockOracle.mockMethod("decimals()", [18]); await expect( - manager.connect(addr1).transferOwnership(addr1.address), - ).to.be.revertedWith("Unauthorized caller"); + manager.connect(addr1).updateOracle(mockOracle.target), + ).to.be.revertedWith("Ownable: caller is not the owner"); }); - it("should succeed when called by owner", async function () { - const { manager, addr1 } = await loadFixture(setupContracts); - await manager.transferOwnership(addr1.address); - expect(await manager.owner()).to.eq(await addr1.getAddress()); - }); - }); - - describe("#updatePricingStrategy", function () { - it("should fail to when called by non-owner", async function () { - const { manager, addr1 } = await loadFixture(setupContracts); - await expect( - manager.connect(addr1).updatePricingStrategy(addr1.address), - ).to.be.revertedWith("Unauthorized caller"); + it("should fail when decimals dont match", async function () { + const { manager } = await loadFixture(setupContracts); + const mockOracle = new DMock("IMetaOracle"); + await mockOracle.deploy(); + await mockOracle.mockMethod("decimals()", [9]); + await expect(manager.updateOracle(mockOracle.target)).to.be.revertedWith( + "UnexpectedDecimals", + ); }); it("should succeed when called by owner", async function () { - const { manager, addr1 } = await loadFixture(setupContracts); - await manager.updatePricingStrategy(addr1.address); - expect(await manager.pricingStrategy()).to.eq(await addr1.getAddress()); + const { manager } = await loadFixture(setupContracts); + const mockOracle = new DMock("IMetaOracle"); + await mockOracle.deploy(); + await mockOracle.mockMethod("decimals()", [18]); + await manager.updateOracle(mockOracle.target); + expect(await manager.oracle()).to.eq(mockOracle.target); }); }); @@ -122,7 +155,7 @@ describe("UsdcSpotManager", function () { const { manager, addr1 } = await loadFixture(setupContracts); await expect( manager.connect(addr1).setLiquidityRanges(7200, 330000, 1200), - ).to.be.revertedWith("Unauthorized caller"); + ).to.be.revertedWith("Ownable: caller is not the owner"); }); it("should succeed when called by owner", async function () { @@ -143,7 +176,7 @@ describe("UsdcSpotManager", function () { .execOnVault( mockVault.refFactory.interface.encodeFunctionData("acceptManager"), ), - ).to.be.revertedWith("Unauthorized caller"); + ).to.be.revertedWith("Ownable: caller is not the owner"); }); it("should succeed when called by owner", async function () { @@ -162,7 +195,7 @@ describe("UsdcSpotManager", function () { manager.execOnVault( mockVault.refFactory.interface.encodeFunctionData("acceptManager"), ), - ).to.be.revertedWith("Vault call failed"); + ).to.be.revertedWith("VaultExecutionFailed"); await mockVault.mockCall("acceptManager()", [], []); await manager.execOnVault( mockVault.refFactory.interface.encodeFunctionData("acceptManager"), @@ -170,275 +203,153 @@ describe("UsdcSpotManager", function () { }); }); - describe("#computeDeviationFactor", function () { - describe("when spot price is invalid", function () { - it("should return invalid", async function () { - const { manager, mockAppraiser } = await loadFixture(setupContracts); - await mockAppraiser.mockMethod("perpPrice()", [priceFP("1.2"), false]); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("1.0057863765655975")); - expect(r[1]).to.eq(false); + describe("isOverweightSpot", function () { + describe("when spot sell", function () { + it("should return true", async function () { + const { manager, mockVault } = await loadFixture(setupContracts); + await stubOverweightSpot(mockVault); + expect(await manager.isOverweightSpot()).to.eq(true); }); }); - describe("when usd price is invalid", function () { - it("should return invalid", async function () { - const { manager, mockAppraiser } = await loadFixture(setupContracts); - await mockAppraiser.mockMethod("usdPrice()", [priceFP("0.8"), false]); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("1.0057863765655975")); - expect(r[1]).to.eq(false); + describe("when spot buy", function () { + it("should return false", async function () { + const { manager, mockVault } = await loadFixture(setupContracts); + await stubOverweightUsdc(mockVault); + expect(await manager.isOverweightSpot()).to.eq(false); }); }); + }); - it("should return deviation factor", async function () { - const { manager } = await loadFixture(setupContracts); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("1.0057863765655975")); - expect(r[1]).to.eq(true); - }); - - it("should return deviation factor", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - await mockVault.mockMethod("getTwap()", [65800]); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("1.1569216182711425")); - expect(r[1]).to.eq(true); - }); - - it("should return deviation factor", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - await mockVault.mockMethod("getTwap()", [67800]); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("0.947216779268338333")); - expect(r[1]).to.eq(true); - }); - - it("should return deviation factor", async function () { - const { manager, mockAppraiser } = await loadFixture(setupContracts); - await mockAppraiser.mockMethod("perpPrice()", [priceFP("1.5"), true]); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("0.804629101252478")); - expect(r[1]).to.eq(true); - }); - - it("should return deviation factor", async function () { - const { manager, mockAppraiser } = await loadFixture(setupContracts); - await mockAppraiser.mockMethod("perpPrice()", [priceFP("1"), true]); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("1.206943651878717")); - expect(r[1]).to.eq(true); + describe("shouldRemoveLimitRange", function () { + describe("is overweight spot", function () { + it("should return bool", async function () { + const { manager, mockVault } = await loadFixture(setupContracts); + await stubOverweightSpot(mockVault); + expect(await manager.shouldRemoveLimitRange(percFP("1.01"))).to.eq(false); + expect(await manager.shouldRemoveLimitRange(percFP("1"))).to.eq(false); + expect(await manager.shouldRemoveLimitRange(percFP("0.99"))).to.eq(true); + }); }); - it("should return deviation factor when perp price is invalid", async function () { - const { manager, mockAppraiser } = await loadFixture(setupContracts); - await mockAppraiser.mockMethod("perpPrice()", [priceFP("0"), true]); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("100")); - expect(r[1]).to.eq(true); + describe("is overweight usdc", function () { + it("should return bool", async function () { + const { manager, mockVault } = await loadFixture(setupContracts); + await stubOverweightUsdc(mockVault); + expect(await manager.shouldRemoveLimitRange(percFP("1.01"))).to.eq(true); + expect(await manager.shouldRemoveLimitRange(percFP("1"))).to.eq(false); + expect(await manager.shouldRemoveLimitRange(percFP("0.99"))).to.eq(false); + }); }); }); - describe("isOverweightSpot", function () { - describe("when spot sell", function () { + describe("shouldForceRebalance", function () { + describe("when deviation crosses 1", function () { it("should return true", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - await mockVault.mockMethod("getTwap()", [30001]); - await mockVault.mockMethod("limitLower()", [20000]); - await mockVault.mockMethod("limitUpper()", [40000]); - expect(await manager.isOverweightSpot()).to.eq(true); + const { manager } = await loadFixture(setupContracts); + expect(await manager.shouldForceRebalance(percFP("0.9"), percFP("1.1"))).to.eq( + true, + ); + expect(await manager.shouldForceRebalance(percFP("1.5"), percFP("0.99"))).to.eq( + true, + ); + expect(await manager.shouldForceRebalance(percFP("1"), percFP("1.1"))).to.eq( + true, + ); + expect(await manager.shouldForceRebalance(percFP("1"), percFP("0.99"))).to.eq( + true, + ); }); }); - describe("when spot buy", function () { + describe("when deviation does not cross 1", function () { it("should return false", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - await mockVault.mockMethod("getTwap()", [29999]); - await mockVault.mockMethod("limitLower()", [20000]); - await mockVault.mockMethod("limitUpper()", [40000]); - expect(await manager.isOverweightSpot()).to.eq(false); + const { manager } = await loadFixture(setupContracts); + expect(await manager.shouldForceRebalance(percFP("0.9"), percFP("0.99"))).to.eq( + false, + ); + expect(await manager.shouldForceRebalance(percFP("1.5"), percFP("1.1"))).to.eq( + false, + ); + expect(await manager.shouldForceRebalance(percFP("0.9"), percFP("1"))).to.eq( + false, + ); + expect(await manager.shouldForceRebalance(percFP("1.5"), percFP("1"))).to.eq( + false, + ); }); }); }); describe("#rebalance", function () { - describe("when deviation is < 1 and goes > 1", function () { - describe("when overweight spot", function () { - it("should keep limit range", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [66200]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - - expect(await manager.prevDeviation()).to.eq("0"); - expect(await manager.isOverweightSpot()).to.eq(true); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.111560295732100833")); - }); - }); + it("should rebalance, update limit range and prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await stubOverweightUsdc(mockVault); + await stubRebalance(mockVault); + await stubUnchangedLimitRange(mockVault); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("0.99"), true]); + + expect(await manager.isOverweightSpot()).to.eq(false); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("0.99")); + }); - describe("when overweight usdc", function () { - it("should remove limit range", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [66200]); - await mockVault.mockMethod("limitLower()", [73000]); - await mockVault.mockMethod("limitUpper()", [75000]); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 73000, 75000)], - [50000, 0, 0, 0, 0], - ); - - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [73000, 75000, 50000], - [], - ); - - expect(await manager.prevDeviation()).to.eq("0"); - expect(await manager.isOverweightSpot()).to.eq(false); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.111560295732100833")); - }); - }); + it("should rebalance, update limit range and prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await stubOverweightSpot(mockVault); + await stubRebalance(mockVault); + await stubRemovedLimitRange(mockVault); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("0.99"), true]); + + expect(await manager.isOverweightSpot()).to.eq(true); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("0.99")); }); - describe("when deviation is > 1 and goes < 1", function () { - describe("when overweight spot", function () { - it("should remove limit range", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [66200]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - await manager.rebalance(); - - await mockVault.mockMethod("getTwap()", [67800]); - await mockVault.mockMethod("limitLower()", [60000]); - await mockVault.mockMethod("limitUpper()", [65000]); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 60000, 65000)], - [50000, 0, 0, 0, 0], - ); - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [60000, 65000, 50000], - [], - ); - - expect(await manager.prevDeviation()).to.eq(percFP("1.111560295732100833")); - expect(await manager.isOverweightSpot()).to.eq(true); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.947216779268338333")); - }); - }); + it("should rebalance, update limit range and prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); - describe("when overweight usdc", function () { - it("should keep limit range", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [66200]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - await manager.rebalance(); - - await mockVault.mockMethod("getTwap()", [67800]); - await mockVault.mockMethod("limitLower()", [75000]); - await mockVault.mockMethod("limitUpper()", [80000]); - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); - - expect(await manager.prevDeviation()).to.eq(percFP("1.111560295732100833")); - expect(await manager.isOverweightSpot()).to.eq(false); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.947216779268338333")); - }); - }); + await stubOverweightUsdc(mockVault); + await stubForceRebalance(mockVault); + await stubRemovedLimitRange(mockVault); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1.2"), true]); + + expect(await manager.isOverweightSpot()).to.eq(false); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("1.2")); }); - describe("when deviation remains below 1", function () { - describe("when overweight spot", function () { - it("should not force rebalance", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [67800]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("rebalance()", []); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 40000, 45000)], - [50000, 0, 0, 0, 0], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [40000, 45000, 50000], - [], - ); - - expect(await manager.prevDeviation()).to.eq("0"); - expect(await manager.isOverweightSpot()).to.eq(true); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.947216779268338333")); - }); - }); + it("should rebalance, update limit range and prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await stubOverweightSpot(mockVault); + await stubForceRebalance(mockVault); + await stubUnchangedLimitRange(mockVault); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1.2"), true]); + + expect(await manager.isOverweightSpot()).to.eq(true); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("1.2")); }); - describe("when deviation remains above 1", function () { - describe("when overweight usdc", function () { - it("should not force rebalance", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [66200]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - await manager.rebalance(); - - await mockVault.clearMockCall("setPeriod(uint32)", [0]); - await mockVault.clearMockCall("setPeriod(uint32)", [86400]); - await mockVault.clearMockCall("period()", []); - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); - - await mockVault.mockMethod("getTwap()", [66800]); - await mockVault.mockMethod("limitLower()", [75000]); - await mockVault.mockMethod("limitUpper()", [80000]); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 75000, 80000)], - [50000, 0, 0, 0, 0], - ); - await mockVault.mockMethod("emergencyBurn(int24,int24,uint128)", []); - - expect(await manager.prevDeviation()).to.eq(percFP("1.111560295732100833")); - expect(await manager.isOverweightSpot()).to.eq(false); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.0468312037404625")); - }); - }); + it("should rebalance, remove limit range and not change prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await stubOverweightSpot(mockVault); + await stubRebalance(mockVault); + await stubRemovedLimitRange(mockVault); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1.2"), false]); + + expect(await manager.isOverweightSpot()).to.eq(true); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("0")); }); }); }); diff --git a/spot-vaults/test/WethWamplManager.ts b/spot-vaults/test/WethWamplManager.ts index 2506d5c6..3eb6acda 100644 --- a/spot-vaults/test/WethWamplManager.ts +++ b/spot-vaults/test/WethWamplManager.ts @@ -4,12 +4,7 @@ import { expect } from "chai"; import { DMock, sciParseFloat, univ3PositionKey } from "./helpers"; export const percFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); -export const amplFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 9); -export const wamplFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); -export const ethOracleFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 8); -export const amplOracleFP = (a: string): BigInt => - ethers.parseUnits(sciParseFloat(a), 18); -const nowTS = () => parseInt(Date.now() / 1000); +export const priceFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); describe("WethWamplManager", function () { async function setupContracts() { @@ -20,12 +15,6 @@ describe("WethWamplManager", function () { // Deploy mock contracts const mockVault = new DMock("IAlphaProVault"); await mockVault.deploy(); - await mockVault.mockMethod("fullLower()", [-800000]); - await mockVault.mockMethod("fullUpper()", [800000]); - await mockVault.mockMethod("baseLower()", [45000]); - await mockVault.mockMethod("baseUpper()", [55000]); - await mockVault.mockMethod("getTwap()", [49875]); - await mockVault.mockMethod("limitThreshold()", [800000]); const mockPool = new DMock("IUniswapV3Pool"); await mockPool.deploy(); @@ -36,26 +25,26 @@ describe("WethWamplManager", function () { ); await mockPool.mockCall( "positions(bytes32)", - [univ3PositionKey(mockVault.target, 45000, 55000)], - [20000, 0, 0, 0, 0], + [univ3PositionKey(mockVault.target, -100000, 100000)], + [100000, 0, 0, 0, 0], ); + await mockPool.mockCall( + "positions(bytes32)", + [univ3PositionKey(mockVault.target, 20000, 40000)], + [50000, 0, 0, 0, 0], + ); + await mockVault.mockMethod("baseLower()", [-100000]); + await mockVault.mockMethod("baseUpper()", [100000]); + await mockVault.mockMethod("fullLower()", [-800000]); + await mockVault.mockMethod("fullUpper()", [800000]); + await mockVault.mockMethod("limitLower()", [20000]); + await mockVault.mockMethod("limitUpper()", [40000]); await mockVault.mockMethod("pool()", [mockPool.target]); - const mockCPIOracle = new DMock("IAmpleforthOracle"); - await mockCPIOracle.deploy(); - await mockCPIOracle.mockMethod("DECIMALS()", [18]); - await mockCPIOracle.mockMethod("getData()", [amplOracleFP("1.19"), true]); - - const mockETHOracle = new DMock("IChainlinkOracle"); - await mockETHOracle.deploy(); - await mockETHOracle.mockMethod("decimals()", [8]); - await mockETHOracle.mockMethod("latestRoundData()", [ - 0, - ethOracleFP("3300"), - 0, - nowTS(), - 0, - ]); + const mockOracle = new DMock("IMetaOracle"); + await mockOracle.deploy(); + await mockOracle.mockMethod("decimals()", [18]); + await mockOracle.mockMethod("amplPriceDeviation()", [priceFP("1.2"), true]); const mockWeth = new DMock("IERC20Upgradeable"); await mockWeth.deploy(); @@ -63,27 +52,17 @@ describe("WethWamplManager", function () { const mockWampl = new DMock("IWAMPL"); await mockWampl.deploy(); - await mockWampl.mockCall( - "wrapperToUnderlying(uint256)", - [wamplFP("1")], - [amplFP("18")], - ); await mockVault.mockMethod("token1()", [mockWampl.target]); // Deploy Manager contract const Manager = await ethers.getContractFactory("WethWamplManager"); - const manager = await Manager.deploy( - mockVault.target, - mockCPIOracle.target, - mockETHOracle.target, - ); + const manager = await Manager.deploy(mockVault.target, mockOracle.target); return { owner, addr1, mockVault, - mockCPIOracle, - mockETHOracle, + mockOracle, mockWeth, mockWampl, mockPool, @@ -91,6 +70,56 @@ describe("WethWamplManager", function () { }; } + async function stubRebalance(mockVault) { + await mockVault.clearMockMethod("setPeriod(uint32)"); + await mockVault.clearMockMethod("period()"); + await mockVault.mockMethod("rebalance()", []); + } + + async function stubForceRebalance(mockVault) { + await mockVault.mockMethod("period()", [86400]); + await mockVault.mockCall("setPeriod(uint32)", [0], []); + await mockVault.mockCall("setPeriod(uint32)", [86400], []); + await mockVault.mockMethod("rebalance()", []); + } + + async function stubOverweightWampl(mockVault) { + await mockVault.mockMethod("getTwap()", [30001]); + } + async function stubOverweightWeth(mockVault) { + await mockVault.mockMethod("getTwap()", [29999]); + await mockVault.mockMethod("limitLower()", [20000]); + await mockVault.mockMethod("limitUpper()", [40000]); + } + + async function stubTrimLiquidity(mockVault, burntLiq) { + await mockVault.mockCall( + "emergencyBurn(int24,int24,uint128)", + [-800000, 800000, burntLiq], + [], + ); + await mockVault.mockCall( + "emergencyBurn(int24,int24,uint128)", + [-100000, 100000, burntLiq], + [], + ); + } + + async function stubUnchangedLimitRange(mockVault) { + await mockVault.clearMockCall( + "emergencyBurn(int24,int24,uint128)", + [20000, 40000, 50000], + ); + } + + async function stubRemovedLimitRange(mockVault) { + await mockVault.mockCall( + "emergencyBurn(int24,int24,uint128)", + [20000, 40000, 50000], + [], + ); + } + describe("Initialization", function () { it("should set the correct owner", async function () { const { manager, owner } = await loadFixture(setupContracts); @@ -102,23 +131,14 @@ describe("WethWamplManager", function () { expect(await manager.VAULT()).to.eq(mockVault.target); }); - it("should set the correct CPI oracle address", async function () { - const { manager, mockCPIOracle } = await loadFixture(setupContracts); - expect(await manager.cpiOracle()).to.eq(mockCPIOracle.target); - }); - - it("should set the correct ETH oracle address", async function () { - const { manager, mockETHOracle } = await loadFixture(setupContracts); - expect(await manager.ethOracle()).to.eq(mockETHOracle.target); + it("should set the correct oracle address", async function () { + const { manager, mockOracle } = await loadFixture(setupContracts); + expect(await manager.oracle()).to.eq(mockOracle.target); }); it("should set the token refs", async function () { - const { manager, mockWeth, mockWampl, mockPool } = await loadFixture( - setupContracts, - ); + const { manager, mockPool } = await loadFixture(setupContracts); expect(await manager.POOL()).to.eq(mockPool.target); - expect(await manager.WETH()).to.eq(mockWeth.target); - expect(await manager.WAMPL()).to.eq(mockWampl.target); }); it("should set the active perc calculation params", async function () { @@ -145,48 +165,34 @@ describe("WethWamplManager", function () { }); }); - describe("#transferOwnership", function () { + describe("#updateOracle", function () { it("should fail to when called by non-owner", async function () { const { manager, addr1 } = await loadFixture(setupContracts); + const mockOracle = new DMock("IMetaOracle"); + await mockOracle.deploy(); + await mockOracle.mockMethod("decimals()", [18]); await expect( - manager.connect(addr1).transferOwnership(addr1.address), - ).to.be.revertedWith("Unauthorized caller"); - }); - - it("should succeed when called by owner", async function () { - const { manager, addr1 } = await loadFixture(setupContracts); - await manager.transferOwnership(addr1.address); - expect(await manager.owner()).to.eq(await addr1.getAddress()); - }); - }); - - describe("#setCpiOracle", function () { - it("should fail to when called by non-owner", async function () { - const { manager, addr1 } = await loadFixture(setupContracts); - await expect(manager.connect(addr1).setCpiOracle(addr1.address)).to.be.revertedWith( - "Unauthorized caller", - ); + manager.connect(addr1).updateOracle(mockOracle.target), + ).to.be.revertedWith("Ownable: caller is not the owner"); }); - it("should succeed when called by owner", async function () { - const { manager, addr1 } = await loadFixture(setupContracts); - await manager.setCpiOracle(addr1.address); - expect(await manager.cpiOracle()).to.eq(await addr1.getAddress()); - }); - }); - - describe("#setEthOracle", function () { - it("should fail to when called by non-owner", async function () { - const { manager, addr1 } = await loadFixture(setupContracts); - await expect(manager.connect(addr1).setEthOracle(addr1.address)).to.be.revertedWith( - "Unauthorized caller", + it("should fail when decimals dont match", async function () { + const { manager } = await loadFixture(setupContracts); + const mockOracle = new DMock("IMetaOracle"); + await mockOracle.deploy(); + await mockOracle.mockMethod("decimals()", [9]); + await expect(manager.updateOracle(mockOracle.target)).to.be.revertedWith( + "UnexpectedDecimals", ); }); it("should succeed when called by owner", async function () { - const { manager, addr1 } = await loadFixture(setupContracts); - await manager.setEthOracle(addr1.address); - expect(await manager.ethOracle()).to.eq(await addr1.getAddress()); + const { manager } = await loadFixture(setupContracts); + const mockOracle = new DMock("IMetaOracle"); + await mockOracle.deploy(); + await mockOracle.mockMethod("decimals()", [18]); + await manager.updateOracle(mockOracle.target); + expect(await manager.oracle()).to.eq(mockOracle.target); }); }); @@ -201,7 +207,7 @@ describe("WethWamplManager", function () { [percFP("0.5"), percFP("0.2"), percFP("1"), percFP("1")], [percFP("1"), percFP("1"), percFP("2"), percFP("0.2")], ), - ).to.be.revertedWith("Unauthorized caller"); + ).to.be.revertedWith("Ownable: caller is not the owner"); }); it("should succeed when called by owner", async function () { @@ -233,7 +239,7 @@ describe("WethWamplManager", function () { const { manager, addr1 } = await loadFixture(setupContracts); await expect( manager.connect(addr1).setLiquidityRanges(7200, 330000, 1200), - ).to.be.revertedWith("Unauthorized caller"); + ).to.be.revertedWith("Ownable: caller is not the owner"); }); it("should succeed when called by owner", async function () { @@ -254,7 +260,7 @@ describe("WethWamplManager", function () { .execOnVault( mockVault.refFactory.interface.encodeFunctionData("acceptManager"), ), - ).to.be.revertedWith("Unauthorized caller"); + ).to.be.revertedWith("Ownable: caller is not the owner"); }); it("should succeed when called by owner", async function () { @@ -273,7 +279,7 @@ describe("WethWamplManager", function () { manager.execOnVault( mockVault.refFactory.interface.encodeFunctionData("acceptManager"), ), - ).to.be.revertedWith("Vault call failed"); + ).to.be.revertedWith("VaultExecutionFailed"); await mockVault.mockCall("acceptManager()", [], []); await manager.execOnVault( mockVault.refFactory.interface.encodeFunctionData("acceptManager"), @@ -298,145 +304,7 @@ describe("WethWamplManager", function () { expect(await manager.computeActiveLiqPerc(percFP("5"))).to.eq(percFP("0.2")); expect(await manager.computeActiveLiqPerc(percFP("10"))).to.eq(percFP("0.2")); expect(await manager.computeActiveLiqPerc(percFP("100000"))).to.eq(percFP("0.2")); - expect(await manager.computeActiveLiqPerc(ethers.MaxUint256)).to.eq(percFP("0.2")); - }); - }); - - describe("#computeDeviationFactor", function () { - describe("when cpi is invalid", function () { - it("should return invalid", async function () { - const { manager, mockETHOracle, mockCPIOracle, mockWampl } = await loadFixture( - setupContracts, - ); - await mockETHOracle.mockMethod("latestRoundData()", [ - 0, - ethOracleFP("3300"), - 0, - nowTS(), - 0, - ]); - await mockCPIOracle.mockMethod("getData()", [amplOracleFP("1.19"), false]); - await mockWampl.mockCall( - "wrapperToUnderlying(uint256)", - [wamplFP("1")], - [amplFP("18")], - ); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("1.051378374404781289")); - expect(r[1]).to.eq(false); - }); - }); - - describe("when eth price is invalid", function () { - it("should return invalid", async function () { - const { manager, mockETHOracle, mockCPIOracle, mockWampl } = await loadFixture( - setupContracts, - ); - await mockETHOracle.mockMethod("latestRoundData()", [ - 0, - ethOracleFP("3300"), - 0, - nowTS() - 86400 * 7, - 0, - ]); - await mockCPIOracle.mockMethod("getData()", [amplOracleFP("1.19"), true]); - await mockWampl.mockCall( - "wrapperToUnderlying(uint256)", - [wamplFP("1")], - [amplFP("18")], - ); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("1.051378374404781289")); - expect(r[1]).to.eq(false); - }); - }); - - it("should return deviation factor", async function () { - const { manager, mockETHOracle, mockCPIOracle, mockWampl } = await loadFixture( - setupContracts, - ); - await mockETHOracle.mockMethod("latestRoundData()", [ - 0, - ethOracleFP("3300"), - 0, - nowTS(), - 0, - ]); - await mockCPIOracle.mockMethod("getData()", [amplOracleFP("1.19"), true]); - await mockWampl.mockCall( - "wrapperToUnderlying(uint256)", - [wamplFP("1")], - [amplFP("18")], - ); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("1.051378374404781289")); - expect(r[1]).to.eq(true); - }); - - it("should return deviation factor", async function () { - const { manager, mockETHOracle, mockCPIOracle, mockWampl } = await loadFixture( - setupContracts, - ); - await mockETHOracle.mockMethod("latestRoundData()", [ - 0, - ethOracleFP("3300"), - 0, - nowTS(), - 0, - ]); - await mockCPIOracle.mockMethod("getData()", [amplOracleFP("1.19"), true]); - await mockWampl.mockCall( - "wrapperToUnderlying(uint256)", - [wamplFP("1")], - [amplFP("10")], - ); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("1.892481073928606322")); - expect(r[1]).to.eq(true); - }); - - it("should return deviation factor", async function () { - const { manager, mockETHOracle, mockCPIOracle, mockWampl } = await loadFixture( - setupContracts, - ); - await mockETHOracle.mockMethod("latestRoundData()", [ - 0, - ethOracleFP("3300"), - 0, - nowTS(), - 0, - ]); - await mockCPIOracle.mockMethod("getData()", [amplOracleFP("1.19"), true]); - await mockWampl.mockCall( - "wrapperToUnderlying(uint256)", - [wamplFP("1")], - [amplFP("25")], - ); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("0.756992429571442528")); - expect(r[1]).to.eq(true); - }); - - it("should return max deviation when price is too high", async function () { - const { manager, mockVault, mockETHOracle, mockCPIOracle, mockWampl } = - await loadFixture(setupContracts); - await mockVault.mockMethod("getTwap()", [1]); - await mockETHOracle.mockMethod("latestRoundData()", [ - 0, - ethOracleFP("3300"), - 0, - nowTS(), - 0, - ]); - await mockCPIOracle.mockMethod("getData()", [amplOracleFP("1.19"), true]); - await mockWampl.mockCall( - "wrapperToUnderlying(uint256)", - [wamplFP("1")], - [amplFP("18")], - ); - const r = await manager.computeDeviationFactor.staticCall(); - expect(r[0]).to.eq(percFP("100")); - expect(r[1]).to.eq(true); + expect(await manager.computeActiveLiqPerc(ethers.MaxInt256)).to.eq(percFP("0.2")); }); }); @@ -444,9 +312,7 @@ describe("WethWamplManager", function () { describe("when wampl sell", function () { it("should return true", async function () { const { manager, mockVault } = await loadFixture(setupContracts); - await mockVault.mockMethod("getTwap()", [30001]); - await mockVault.mockMethod("limitLower()", [20000]); - await mockVault.mockMethod("limitUpper()", [40000]); + await stubOverweightWampl(mockVault); expect(await manager.isOverweightWampl()).to.eq(true); }); }); @@ -454,427 +320,166 @@ describe("WethWamplManager", function () { describe("when wampl buy", function () { it("should return false", async function () { const { manager, mockVault } = await loadFixture(setupContracts); - await mockVault.mockMethod("getTwap()", [29999]); - await mockVault.mockMethod("limitLower()", [20000]); - await mockVault.mockMethod("limitUpper()", [40000]); + await stubOverweightWeth(mockVault); expect(await manager.isOverweightWampl()).to.eq(false); }); }); }); - describe("#rebalance", function () { - describe("when activePercDelta is within threshold", function () { - describe("when deviation is < 1 and goes > 1", function () { - describe("when overweight wampl", function () { - it("should trim liquidity & keep limit range", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - await manager.setActivePercParams( - percFP("1"), - [percFP("0.5"), percFP("0.2"), percFP("1"), percFP("1")], - [percFP("1"), percFP("1"), percFP("2"), percFP("0.2")], - ); - - await mockVault.mockMethod("getTwap()", [49500]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [-800000, 800000, 7324], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [45000, 55000, 1464], - [], - ); - - expect(await manager.prevDeviation()).to.eq("0"); - expect(await manager.isOverweightWampl()).to.eq(true); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.091551595254704898")); - }); - }); - - describe("when overweight weth", function () { - it("should trim liquidity & remove limit range", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - await manager.setActivePercParams( - percFP("1"), - [percFP("0.5"), percFP("0.2"), percFP("1"), percFP("1")], - [percFP("1"), percFP("1"), percFP("2"), percFP("0.2")], - ); - - await mockVault.mockMethod("getTwap()", [49500]); - await mockVault.mockMethod("limitLower()", [50000]); - await mockVault.mockMethod("limitUpper()", [55000]); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 50000, 55000)], - [50000, 0, 0, 0, 0], - ); - - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [-800000, 800000, 7324], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [45000, 55000, 1464], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [50000, 55000, 50000], - [], - ); - - expect(await manager.prevDeviation()).to.eq("0"); - expect(await manager.isOverweightWampl()).to.eq(false); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.091551595254704898")); - }); - }); + describe("shouldRemoveLimitRange", function () { + describe("is overweight wampl", function () { + it("should return bool", async function () { + const { manager, mockVault } = await loadFixture(setupContracts); + await stubOverweightWampl(mockVault); + expect(await manager.shouldRemoveLimitRange(percFP("1.01"))).to.eq(false); + expect(await manager.shouldRemoveLimitRange(percFP("1"))).to.eq(false); + expect(await manager.shouldRemoveLimitRange(percFP("0.99"))).to.eq(true); }); + }); - describe("when deviation is > 1 and goes < 1", function () { - describe("when overweight wampl", function () { - it("should trim liquidity & remove limit range", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - await manager.setActivePercParams( - percFP("1"), - [percFP("0.5"), percFP("0.2"), percFP("1"), percFP("1")], - [percFP("1"), percFP("1"), percFP("2"), percFP("0.2")], - ); - - await mockVault.mockMethod("getTwap()", [49500]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - await mockVault.mockMethod("emergencyBurn(int24,int24,uint128)", []); - await manager.rebalance(); - - await mockVault.mockMethod("getTwap()", [52000]); - await mockVault.mockMethod("limitLower()", [50000]); - await mockVault.mockMethod("limitUpper()", [51000]); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 50000, 51000)], - [50000, 0, 0, 0, 0], - ); - - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [-800000, 800000, 23982], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [45000, 55000, 4796], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [50000, 51000, 50000], - [], - ); - - expect(await manager.prevDeviation()).to.eq(percFP("1.091551595254704898")); - expect(await manager.isOverweightWampl()).to.eq(true); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.850111862770710708")); - }); - }); - - describe("when overweight weth", function () { - it("should trim liquidity & keep limit range", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - await manager.setActivePercParams( - percFP("1"), - [percFP("0.5"), percFP("0.2"), percFP("1"), percFP("1")], - [percFP("1"), percFP("1"), percFP("2"), percFP("0.2")], - ); - - await mockVault.mockMethod("getTwap()", [49500]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - await mockVault.mockMethod("emergencyBurn(int24,int24,uint128)", []); - await manager.rebalance(); - - await mockVault.mockMethod("getTwap()", [52000]); - await mockVault.mockMethod("limitLower()", [53000]); - await mockVault.mockMethod("limitUpper()", [55000]); - - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [-800000, 800000, 23982], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [45000, 55000, 4796], - [], - ); - - expect(await manager.prevDeviation()).to.eq(percFP("1.091551595254704898")); - expect(await manager.isOverweightWampl()).to.eq(false); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.850111862770710708")); - }); - }); + describe("is overweight weth", function () { + it("should return bool", async function () { + const { manager, mockVault } = await loadFixture(setupContracts); + await stubOverweightWeth(mockVault); + expect(await manager.shouldRemoveLimitRange(percFP("1.01"))).to.eq(true); + expect(await manager.shouldRemoveLimitRange(percFP("1"))).to.eq(false); + expect(await manager.shouldRemoveLimitRange(percFP("0.99"))).to.eq(false); }); }); + }); - describe("when activePercDelta is outside threshold", function () { - describe("when deviation is < 1 and goes > 1", function () { - describe("when overweight wampl", function () { - it("should trim liquidity & keep limit range", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [49500]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [-800000, 800000, 7324], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [45000, 55000, 1464], - [], - ); - - expect(await manager.prevDeviation()).to.eq("0"); - expect(await manager.isOverweightWampl()).to.eq(true); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.091551595254704898")); - }); - }); - - describe("when overweight weth", function () { - it("should trim liquidity & remove limit range", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [49500]); - await mockVault.mockMethod("limitLower()", [50000]); - await mockVault.mockMethod("limitUpper()", [55000]); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 50000, 55000)], - [50000, 0, 0, 0, 0], - ); - - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [-800000, 800000, 7324], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [45000, 55000, 1464], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [50000, 55000, 50000], - [], - ); - - expect(await manager.prevDeviation()).to.eq("0"); - expect(await manager.isOverweightWampl()).to.eq(false); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.091551595254704898")); - }); - }); - }); + describe("shouldForceRebalance", function () { + it("should return bool", async function () { + const { manager } = await loadFixture(setupContracts); - describe("when deviation is > 1 and goes < 1", function () { - describe("when overweight wampl", function () { - it("should trim liquidity & remove limit range", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [49500]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - await mockVault.mockMethod("emergencyBurn(int24,int24,uint128)", []); - await manager.rebalance(); - - await mockVault.mockMethod("getTwap()", [52000]); - await mockVault.mockMethod("limitLower()", [50000]); - await mockVault.mockMethod("limitUpper()", [51000]); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 50000, 51000)], - [50000, 0, 0, 0, 0], - ); - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [-800000, 800000, 23982], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [45000, 55000, 4796], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [50000, 51000, 50000], - [], - ); - - expect(await manager.prevDeviation()).to.eq(percFP("1.091551595254704898")); - expect(await manager.isOverweightWampl()).to.eq(true); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.850111862770710708")); - }); - }); - - describe("when overweight weth", function () { - it("should trim liquidity & keep limit range", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - - await mockVault.mockMethod("getTwap()", [49500]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - await mockVault.mockMethod("emergencyBurn(int24,int24,uint128)", []); - await manager.rebalance(); - - await mockVault.mockMethod("getTwap()", [52000]); - await mockVault.mockMethod("limitLower()", [53000]); - await mockVault.mockMethod("limitUpper()", [55000]); - - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [-800000, 800000, 23982], - [], - ); - await mockVault.mockCall( - "emergencyBurn(int24,int24,uint128)", - [45000, 55000, 4796], - [], - ); - - expect(await manager.prevDeviation()).to.eq(percFP("1.091551595254704898")); - expect(await manager.isOverweightWampl()).to.eq(false); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.850111862770710708")); - }); - }); - }); + // inside active delta + expect( + await manager.shouldForceRebalance(percFP("0.5"), percFP("0.8"), percFP("0.09")), + ).to.eq(false); + expect( + await manager.shouldForceRebalance(percFP("0.9"), percFP("1.1"), percFP("0.09")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("1.0"), percFP("1.1"), percFP("0.09")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("1.05"), percFP("1.1"), percFP("0.09")), + ).to.eq(false); + expect( + await manager.shouldForceRebalance(percFP("1.1"), percFP("1"), percFP("0.09")), + ).to.eq(false); + expect( + await manager.shouldForceRebalance(percFP("1.1"), percFP("0.99"), percFP("0.09")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("1"), percFP("0.8"), percFP("0.09")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("0.9"), percFP("0.8"), percFP("0.09")), + ).to.eq(false); + + // outside active delta + expect( + await manager.shouldForceRebalance(percFP("0.5"), percFP("0.8"), percFP("1.1")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("0.9"), percFP("1.1"), percFP("1.1")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("1.0"), percFP("1.1"), percFP("1.1")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("1.05"), percFP("1.1"), percFP("1.1")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("1.1"), percFP("1"), percFP("1.1")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("1.1"), percFP("0.99"), percFP("1.1")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("1"), percFP("0.8"), percFP("1.1")), + ).to.eq(true); + expect( + await manager.shouldForceRebalance(percFP("0.9"), percFP("0.8"), percFP("1.1")), + ).to.eq(true); }); + }); - describe("when deviation remains below 1", function () { - describe("when overweight wampl", function () { - it("should not force rebalance", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - await manager.setActivePercParams( - percFP("1"), - [percFP("0.5"), percFP("0.2"), percFP("1"), percFP("1")], - [percFP("1"), percFP("1"), percFP("2"), percFP("0.2")], - ); - - await mockVault.mockMethod("getTwap()", [51500]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 40000, 45000)], - [50000, 0, 0, 0, 0], - ); - await mockVault.mockMethod("rebalance()", []); - await mockVault.mockMethod("emergencyBurn(int24,int24,uint128)", []); - - expect(await manager.prevDeviation()).to.eq("0"); - expect(await manager.isOverweightWampl()).to.eq(true); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.893695795923885030")); - }); - }); + describe("#rebalance", function () { + it("should rebalance, trim liquidity and prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await stubOverweightWeth(mockVault); + await stubForceRebalance(mockVault); + await stubTrimLiquidity(mockVault, 1600); + await stubUnchangedLimitRange(mockVault); + await mockOracle.mockMethod("amplPriceDeviation()", [priceFP("0.99"), true]); + + expect(await manager.isOverweightWampl()).to.eq(false); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("0.99")); }); - describe("when deviation remains above 1", function () { - describe("when overweight weth", function () { - it("should not force rebalance", async function () { - const { manager, mockVault, mockPool } = await loadFixture(setupContracts); - await manager.setActivePercParams( - percFP("1"), - [percFP("0.5"), percFP("0.2"), percFP("1"), percFP("1")], - [percFP("1"), percFP("1"), percFP("2"), percFP("0.2")], - ); - - await mockVault.mockMethod("getTwap()", [49500]); - await mockVault.mockMethod("limitLower()", [40000]); - await mockVault.mockMethod("limitUpper()", [45000]); - await mockVault.mockMethod("period()", [86400]); - await mockVault.mockCall("setPeriod(uint32)", [0], []); - await mockVault.mockCall("setPeriod(uint32)", [86400], []); - await mockVault.mockMethod("rebalance()", []); - await mockVault.mockMethod("emergencyBurn(int24,int24,uint128)", []); - await manager.rebalance(); - - await mockVault.clearMockCall("setPeriod(uint32)", [0]); - await mockVault.clearMockCall("setPeriod(uint32)", [86400]); - await mockVault.clearMockCall("period()", []); - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); - - await mockVault.mockMethod("getTwap()", [50000]); - await mockVault.mockMethod("limitLower()", [53000]); - await mockVault.mockMethod("limitUpper()", [55000]); - await mockPool.mockCall( - "positions(bytes32)", - [univ3PositionKey(mockVault.target, 53000, 55000)], - [50000, 0, 0, 0, 0], - ); - await mockVault.mockMethod("emergencyBurn(int24,int24,uint128)", []); - - expect(await manager.prevDeviation()).to.eq(percFP("1.091551595254704898")); - expect(await manager.isOverweightWampl()).to.eq(false); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.038318591387163286")); - }); - }); + it("should rebalance, trim liquidity and prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await stubOverweightWampl(mockVault); + await stubForceRebalance(mockVault); + await stubTrimLiquidity(mockVault, 1600); + await stubRemovedLimitRange(mockVault); + await mockOracle.mockMethod("amplPriceDeviation()", [priceFP("0.99"), true]); + + expect(await manager.isOverweightWampl()).to.eq(true); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("0.99")); + }); + + it("should rebalance, trim liquidity and prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await stubOverweightWeth(mockVault); + await stubForceRebalance(mockVault); + await stubTrimLiquidity(mockVault, 16000); + await stubRemovedLimitRange(mockVault); + await mockOracle.mockMethod("amplPriceDeviation()", [priceFP("1.2"), true]); + + expect(await manager.isOverweightWampl()).to.eq(false); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("1.2")); + }); + + it("should rebalance, trim liquidity and prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await stubOverweightWampl(mockVault); + await stubForceRebalance(mockVault); + await stubTrimLiquidity(mockVault, 16000); + await stubUnchangedLimitRange(mockVault); + await mockOracle.mockMethod("amplPriceDeviation()", [priceFP("1.2"), true]); + + expect(await manager.isOverweightWampl()).to.eq(true); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("1.2")); + }); + + it("should rebalance, trim liquidity and not change prev_deviation", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await stubOverweightWampl(mockVault); + await stubRebalance(mockVault); + await stubTrimLiquidity(mockVault, 80000); + await stubRemovedLimitRange(mockVault); + await mockOracle.mockMethod("amplPriceDeviation()", [priceFP("1.2"), false]); + + expect(await manager.isOverweightWampl()).to.eq(true); + expect(await manager.prevDeviation()).to.eq("0"); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevDeviation()).to.eq(percFP("0")); }); }); }); diff --git a/spot-vaults/test/helpers.ts b/spot-vaults/test/helpers.ts index aa3bfa14..141635fd 100644 --- a/spot-vaults/test/helpers.ts +++ b/spot-vaults/test/helpers.ts @@ -3,13 +3,20 @@ import { Contract, ContractFactory } from "ethers"; export const sciParseFloat = (a: string): BigInt => a.includes("e") ? parseFloat(a).toFixed(18) : a; +export const percFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); +export const priceFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); + export const usdFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 6); export const perpFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 9); -export const percentageFP = (a: string): BigInt => - ethers.parseUnits(sciParseFloat(a), 18); -export const priceFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); export const lpAmtFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 24); -export const oracleAnsFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 8); +export const amplFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 9); +export const wamplFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); +export const wethFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); +export const usdOracleFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 8); +export const ethOracleFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); +export const amplOracleFP = (a: string): BigInt => + ethers.parseUnits(sciParseFloat(a), 18); +export const drFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 8); export class DMock { private refArtifact: string; diff --git a/yarn.lock b/yarn.lock index 50ffa547..0ac2c2f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -157,7 +157,7 @@ __metadata: ethers: ^6.6.0 ethers-v5: "npm:ethers@^5.7.0" ganache-cli: latest - hardhat: ^2.22.8 + hardhat: ^2.22.10 hardhat-gas-reporter: latest lodash: ^4.17.21 prettier: ^2.7.1 @@ -9321,9 +9321,9 @@ __metadata: languageName: node linkType: hard -"hardhat@npm:^2.22.8": - version: 2.22.8 - resolution: "hardhat@npm:2.22.8" +"hardhat@npm:^2.22.10": + version: 2.22.10 + resolution: "hardhat@npm:2.22.10" dependencies: "@ethersproject/abi": ^5.1.2 "@metamask/eth-sig-util": ^4.0.0 @@ -9378,7 +9378,7 @@ __metadata: optional: true bin: hardhat: internal/cli/bootstrap.js - checksum: 3731b510540f800b3b931e3ad7db4510dbd35eff18f34382875778db43de2a5ce1c52596c392aa694324f66392e844fede85954ab3cdc08df3da10f2a810135f + checksum: 2bb961a11f428fd025f990ea18472f4197c8352dd81f4231f27c04b7a8e94bc71d668262475102ae2c339ad83dd0e759b90ac7e4905f043be7bde471c04b5951 languageName: node linkType: hard From bf7065aae1c56bc594d8d4fc282f999e12ad111b Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Tue, 17 Sep 2024 22:10:15 -0400 Subject: [PATCH 09/10] Swing trader vault --- spot-vaults/contracts/SwingTrader.sol | 662 ++++++++++++++++++ .../_interfaces/errors/CommonErrors.sol | 3 + .../_interfaces/errors/SwingTraderErrors.sol | 11 + 3 files changed, 676 insertions(+) create mode 100644 spot-vaults/contracts/SwingTrader.sol create mode 100644 spot-vaults/contracts/_interfaces/errors/SwingTraderErrors.sol diff --git a/spot-vaults/contracts/SwingTrader.sol b/spot-vaults/contracts/SwingTrader.sol new file mode 100644 index 00000000..ee00ff58 --- /dev/null +++ b/spot-vaults/contracts/SwingTrader.sol @@ -0,0 +1,662 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import { MathUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; +import { SafeCastUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; +import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import { ERC20BurnableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; + +import { IPerpetualTranche } from "@ampleforthorg/spot-contracts/contracts/_interfaces/IPerpetualTranche.sol"; +import { IPerpPricer } from "./_interfaces/IPerpPricer.sol"; +import { Range } from "./_interfaces/types/CommonTypes.sol"; +import { UnacceptableSwap, UnexpectedDecimals, SlippageTooHigh, UnauthorizedCall, InvalidRange } from "./_interfaces/errors/CommonErrors.sol"; +import { TooManyRedemptionRequests, SwapLimitExceeded, WaittimeTooHigh } from "./_interfaces/errors/SwingTraderErrors.sol"; + +/** + * @title SwingTrader + * + * @notice The `SwingTrader` contract a counter-cyclical trader which gradually buys/sells underlying tokens for + * perps above/below a defined band of operation. + * + * The vault quoted prices are centered around perp's redeemable underlying token value P (and P' = 1/P). + * For example if the `tradingBand` is +-5%. The vault buys underlying tokens for perps at (0.95*P') and + * sells underlying tokens for perps at (1.05*P'). + * + * Additionally, the vault uses and external market price oracle to be better informed. + * - If the underlying token market price is below the lower band price (0.95*P'), + * it quotes the market price. + * This is essentially a "buy stop order" which stops buying above the lower band price. + * - If the underlying token market price is above the upper band price (1.05*P'), + * it again quotes the market price. + * This is essentially a "sell stop order" which stops selling below the upper band price. + * + * It limits daily swap volume thereby making it's market impact gradual + * (somewhat similar to a twap buy/sell). + * + * The vault is open access and doesn't charge fees for any operation. + * However, it does NOT allow on-demand redemption and enforces a waiting period. + * + */ +contract SwingTrader is + ERC20BurnableUpgradeable, + OwnableUpgradeable, + PausableUpgradeable, + ReentrancyGuardUpgradeable +{ + //------------------------------------------------------------------------- + // Libraries + + // ERC20 operations + using SafeERC20Upgradeable for IERC20Upgradeable; + using SafeERC20Upgradeable for IPerpetualTranche; + + // Math + using MathUpgradeable for uint256; + using SafeCastUpgradeable for uint256; + + //------------------------------------------------------------------------- + // Constants & Immutables + + uint256 public constant DECIMALS = 18; + uint256 public constant ONE = (10 ** DECIMALS); + uint256 private constant DAY_SEC = 86400; + uint256 private constant MAX_REDEMPTION_WAIT_SEC = (86400 * 120); + uint256 private constant INITIAL_RATE = 10 ** 6; + uint256 public constant MINIMUM_LIQUIDITY = 10 ** 12; + uint8 public constant MAX_REDEMPTION_REQUESTS_PER_ACCOUNT = 32; + + //------------------------------------------------------------------------- + // Storage + + /// @notice The perpetual senior tranche token. + IPerpetualTranche public perp; + + /// @notice The underlying token. + IERC20Upgradeable public underlying; + + /// @notice Reference to the address that has the ability to pause/unpause operations. + /// @dev The keeper is meant for time-sensitive operations, and may be different from the owner address. + /// @return The address of the keeper. + address public keeper; + + /// @notice The band around which the vault buys/sells underlying tokens for perps. + /// @dev Quotes a price to buy underlying tokens for perps below the lower band. + /// Quotes a price to sell underlying tokens for perps above the upper band. + Range public tradingBand; + + /// @notice Amount of time before the redeemed tokens can be released to the user. + uint256 public redemptionWaitTimeSec; + + /// @notice The redemption request data structure keeps track of the amount of LP tokens + /// to be redeemed and the request can be resolved (i.e when the lockup expires). + struct RedemptionRequest { + /// @notice The amount of LP tokens to be redeemed. + uint256 amount; + /// @notice Timestamp when the request can be resolved. + uint256 resolutionTimestampSec; + } + + /// @notice Mapping between account address and a list of pending redemptions. + mapping(address => RedemptionRequest[]) public pendingRedemptions; + + /// @notice The daily swap limit data structure keeps track of the total volume of tokens + /// that can be traded daily as a fixed amount and a percentage of the current vault balance. + /// @dev Swaps are allowed only if daily volume is below both the defined absolute amount + /// and percentage of vault balance. + struct DailySwapLimit { + uint256 amount; + uint256 perc; + } + + /// @notice The defined limits on the vault's underlying to perp token swaps. + DailySwapLimit public perpSellLimit; + + /// @notice The defined limits on the vault's perps to underlying token swaps. + DailySwapLimit public underlyingSellLimit; + + /// @notice The daily volume data structure keeps track of the total volume of + /// of assets leaving the vault through swaps on a daily basis. + struct DailyVolume { + /// @notice The day timestamp of the last recorded swap. + uint256 dayTimestamp; + /// @notice The total amount of underlying tokens which have left the vault + /// through swaps in the last day. + uint256 underlyingAmt; + /// @notice The total amount of perp tokens which have left the vault + /// through swaps in the last day. + uint256 perpAmt; + } + /// @notice The daily swap volume flowing through the vault. + DailyVolume public dailyVolume; + + /// @notice External oracle which returns the current market price of perp + /// and the underlying tokens denominated in dollars. + /// @dev This reference is optional when it is not set, the vault simply buys and sells + /// around the trading band. + IPerpPricer public oracle; + + /// @notice The premium/discount the vault applies to market price to encourage arbitrage. + uint256 public arbTolerancePerc; + + //-------------------------------------------------------------------------- + // Modifiers + + /// @dev Throws if called by any account other than the keeper. + modifier onlyKeeper() { + if (msg.sender != keeper) { + revert UnauthorizedCall(); + } + _; + } + + //----------------------------------------------------------------------------- + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Contract initializer. + /// @param name ERC-20 Name of the Bill broker LP token. + /// @param symbol ERC-20 Symbol of the Bill broker LP token. + /// @param perp_ Address of the perp token. + /// @param oracle_ Address of the oracle contract. + function init( + string memory name, + string memory symbol, + IERC20Upgradeable underlying_, + IPerpetualTranche perp_, + IPerpPricer oracle_ + ) public initializer { + // initialize dependencies + __ERC20_init(name, symbol); + __ERC20Burnable_init(); + __Ownable_init(); + __Pausable_init(); + __ReentrancyGuard_init(); + + // initialize storage variables + underlying = underlying_; + perp = perp_; + + updateKeeper(owner()); + updateOracle(oracle_); + + updateTradingConfig( + Range({ + lower: (ONE * 95) / 100, // 0.95 + upper: (ONE * 105) / 100 // 1.05 + }), + (ONE * 25) / 1000 // 0.025 or 2.5% + ); + updateRedemptionWaitTimeSec(28 * 86400); // 28 days + + updateDailySwapLimit(DailySwapLimit(0, 0), DailySwapLimit(0, 0)); + dailyVolume = DailyVolume(0, 0, 0); + } + + //-------------------------------------------------------------------------- + // Owner only methods + + /// @notice Updates the reference to the keeper. + /// @param keeper_ The address of the new keeper. + function updateKeeper(address keeper_) public onlyOwner { + keeper = keeper_; + } + + /// @notice Updates the reference to the oracle. + /// @param oracle_ The address of the new oracle. + function updateOracle(IPerpPricer oracle_) public onlyOwner { + if (oracle_.decimals() != DECIMALS) { + revert UnexpectedDecimals(); + } + oracle = oracle_; + } + + /// @notice Updates the redemption wait time. + /// @param redemptionWaitTimeSec_ The new redemption wait time in seconds. + function updateRedemptionWaitTimeSec( + uint256 redemptionWaitTimeSec_ + ) public onlyOwner { + if (redemptionWaitTimeSec_ > MAX_REDEMPTION_WAIT_SEC) { + revert WaittimeTooHigh(); + } + redemptionWaitTimeSec = redemptionWaitTimeSec_; + } + + /// @notice Updates the training configuration. + /// @param tradingBand_ The new trading band. + /// @param arbTolerancePerc_ The discount/premium on top of market price to facilitate arb. + function updateTradingConfig( + Range memory tradingBand_, + uint256 arbTolerancePerc_ + ) public onlyOwner { + if (tradingBand_.lower > tradingBand_.upper) { + revert InvalidRange(); + } + tradingBand = tradingBand_; + arbTolerancePerc = arbTolerancePerc_; + } + + /// @notice Updates the daily swap limits. + function updateDailySwapLimit( + DailySwapLimit memory underlyingSellLimit_, + DailySwapLimit memory perpSellLimit_ + ) public onlyOwner { + underlyingSellLimit = underlyingSellLimit_; + perpSellLimit = perpSellLimit_; + } + + //-------------------------------------------------------------------------- + // Keeper only methods + + /// @notice Pauses deposits, withdrawals and swaps. + /// @dev ERC-20 functions, like transfers will always remain operational. + function pause() external onlyKeeper { + _pause(); + } + + /// @notice Unpauses deposits, withdrawals and rollovers. + /// @dev ERC-20 functions, like transfers will always remain operational. + function unpause() external onlyKeeper { + _unpause(); + } + + //-------------------------------------------------------------------------- + // External & Public write methods + + /// @notice Single sided underlying token deposit and mint LP tokens. + /// @param underlyingAmtIn The amount of underlying tokens to be deposited. + /// @return mintAmt The amount of LP tokens minted. + function depositUnderlying( + uint256 underlyingAmtIn + ) external nonReentrant whenNotPaused returns (uint256 mintAmt) { + bool isFirstMint; + (mintAmt, isFirstMint) = computeMintAmtWithUnderlying(underlyingAmtIn); + if (mintAmt <= 0) { + return 0; + } + + // Transfer underlying tokens from the user + underlying.safeTransferFrom(msg.sender, address(this), underlyingAmtIn); + + // Permanently lock the MINIMUM_LIQUIDITY tokens on first mint + if (isFirstMint) { + _mint(address(this), MINIMUM_LIQUIDITY); + mintAmt -= MINIMUM_LIQUIDITY; + } + + // mint LP tokens to the user + _mint(msg.sender, mintAmt); + } + + /// @notice Single sided perp token deposit and mint LP tokens. + /// @param perpAmtIn The amount of perp tokens to be deposited. + /// @return mintAmt The amount of LP tokens minted. + function depositPerp( + uint256 perpAmtIn + ) external nonReentrant whenNotPaused returns (uint256 mintAmt) { + mintAmt = computeMintAmtWithPerp(perpAmtIn); + if (mintAmt <= 0) { + return 0; + } + + // Transfer perp tokens from the user + perp.safeTransferFrom(msg.sender, address(this), perpAmtIn); + + // mint LP tokens to the user + _mint(msg.sender, mintAmt); + } + + /// @notice Queues up redemption request. + /// @param burnAmt The LP tokens to be redeem. + function requestRedeem(uint256 burnAmt) external nonReentrant whenNotPaused { + if (burnAmt == 0) { + return; + } + + // Takes custody of LP tokens and queues up redemption. + transfer(address(this), burnAmt); + + // Queues up redemption request + _addRequest(msg.sender, burnAmt); + } + + /// @notice Burns LP tokens and redeems underlying and perp tokens. + /// @return underlyingAmtOut The amount underlying tokens returned. + /// @return perpAmtOut The amount perp tokens returned. + function execRedeem() + external + nonReentrant + whenNotPaused + returns (uint256 underlyingAmtOut, uint256 perpAmtOut) + { + // Removes resolved requests from the pending list and + // calculates total LP tokens that can be burnt now. + uint256 burnAmt = _removeResolvedRequests(msg.sender); + + // Compute redemption accounts. + (underlyingAmtOut, perpAmtOut) = computeRedemptionAmts(burnAmt); + if (burnAmt == 0 || underlyingAmtOut == 0 || perpAmtOut == 0) { + return (0, 0); + } + + // Burn LP tokens. + _burn(address(this), burnAmt); + + // Transfer underlying tokens and perps back to the user. + underlying.safeTransfer(msg.sender, underlyingAmtOut); + perp.safeTransfer(msg.sender, perpAmtOut); + } + + /// @notice Swaps underlying tokens from the user for perp tokens from the vault. + /// @dev The vault buys underlying tokens and sells perps. + /// @param underlyingAmtIn The amount of underlying tokens swapped in. + /// @param perpAmtMin The minimum amount of perp tokens that are expected out. + /// @return perpAmtOut The amount perp tokens swapped out. + function swapUnderlyingForPerps( + uint256 underlyingAmtIn, + uint256 perpAmtMin + ) external nonReentrant whenNotPaused returns (uint256 perpAmtOut) { + // compute perp amount out + perpAmtOut = computeUnderlyingToPerpSwapAmt(underlyingAmtIn); + if (underlyingAmtIn <= 0 || perpAmtOut <= 0) { + revert UnacceptableSwap(); + } + if (perpAmtOut < perpAmtMin) { + revert SlippageTooHigh(); + } + + // Transfer underlying tokens from user + underlying.safeTransferFrom(msg.sender, address(this), underlyingAmtIn); + + // enforce daily swap limit + _enforcePerpSellLimit(perpAmtOut); + + // transfer perps out to the user + perp.safeTransfer(msg.sender, perpAmtOut); + } + + /// @notice Swaps perp tokens from the user for underlying tokens from the vault. + /// @dev The vault sells underlying tokens and buys perps. + /// @param perpAmtIn The amount of perp tokens swapped in. + /// @param underlyingAmtMin The minimum amount of underlying tokens that are expected out. + /// @return underlyingAmtOut The amount underlying tokens swapped out. + function swapPerpsForUnderlying( + uint256 perpAmtIn, + uint256 underlyingAmtMin + ) external nonReentrant whenNotPaused returns (uint256 underlyingAmtOut) { + // Compute swap amount + underlyingAmtOut = computePerpToUnderlyingSwapAmt(perpAmtIn); + if (perpAmtIn <= 0 || underlyingAmtOut <= 0) { + revert UnacceptableSwap(); + } + if (underlyingAmtOut < underlyingAmtMin) { + revert SlippageTooHigh(); + } + + // Transfer perp tokens from user + perp.safeTransferFrom(msg.sender, address(this), perpAmtIn); + + // enforce daily swap limit + _enforceUnderlyingSellLimit(underlyingAmtOut); + + // transfer underlying out to the user + underlying.safeTransfer(msg.sender, underlyingAmtOut); + } + + //----------------------------------------------------------------------------- + // Public methods + + /// @notice Computes the amount of LP tokens minted, + /// when the given number of underlying tokens are deposited. + /// @param underlyingAmtIn The amount of underlying tokens deposited. + /// @return mintAmt The amount of LP tokens minted. + function computeMintAmtWithUnderlying( + uint256 underlyingAmtIn + ) public returns (uint256 mintAmt, bool isFirstMint) { + uint256 totalReserveVal = underlying.balanceOf(address(this)) + + perp.balanceOf(address(this)).mulDiv(perp.getTVL(), perp.totalSupply()); + uint256 totalSupply_ = totalSupply(); + mintAmt = (totalReserveVal > 0) + ? underlyingAmtIn.mulDiv(totalSupply_, totalReserveVal) + : (underlyingAmtIn * INITIAL_RATE); + isFirstMint = (totalSupply_ == 0); + } + + /// @notice Computes the amount of LP tokens minted, + /// when the given number of perp tokens are deposited. + /// @param perpAmtIn The amount of perp tokens deposited. + /// @return mintAmt The amount of LP tokens minted. + function computeMintAmtWithPerp(uint256 perpAmtIn) public returns (uint256 mintAmt) { + uint256 perpPrice = ONE.mulDiv(perp.getTVL(), perp.totalSupply()); + uint256 valueIn = perpAmtIn.mulDiv(perpPrice, ONE); + uint256 totalReserveVal = underlying.balanceOf(address(this)) + + perp.balanceOf(address(this)).mulDiv(perpPrice, ONE); + mintAmt = (totalReserveVal > 0) + ? valueIn.mulDiv(totalSupply(), totalReserveVal) + : 0; + } + + /// @notice Computes the amount of underlying tokens swapped out, + /// when the given number of perp tokens are sent in. + /// @param perpAmtIn The amount of perp tokens swapped in. + /// @return underlyingAmtOut The amount underlying tokens swapped out. + function computePerpToUnderlyingSwapAmt( + uint256 perpAmtIn + ) public returns (uint256 underlyingAmtOut) { + // NOTE: Vault sells underlying tokens to the user. + + // We calculate underlying token to perp exchange rate. + uint256 underlyingPerPerp = ONE.mulDiv(perp.totalSupply(), perp.getTVL()); + underlyingPerPerp = underlyingPerPerp.mulDiv(tradingBand.upper, ONE); + + // If the market price is higher than offered price, + // the vault quotes the market price. + (uint256 marketRate, bool marketRateValid) = getMarketRate(); + if (marketRateValid) { + // The vault offers a slight discount on top of the market price to allow for arb. + marketRate = marketRate.mulDiv(ONE - arbTolerancePerc, ONE); + underlyingPerPerp = MathUpgradeable.max(underlyingPerPerp, marketRate); + } + + return perpAmtIn.mulDiv(ONE, underlyingPerPerp); + } + + /// @notice Computes the amount of perp tokens swapped out, + /// when the given number of underlying tokens are sent in. + /// @param underlyingAmtIn The number of underlying tokens sent in. + /// @return perpAmtOut The amount of perp tokens swapped out. + function computeUnderlyingToPerpSwapAmt( + uint256 underlyingAmtIn + ) public returns (uint256 perpAmtOut) { + // NOTE: Vault buys underlying tokens from the user. + + // We calculate underlying token to perp exchange rate. + uint256 underlyingPerPerp = ONE.mulDiv(perp.totalSupply(), perp.getTVL()); + underlyingPerPerp = underlyingPerPerp.mulDiv(tradingBand.lower, ONE); + + // If the market price is lower than offered price, + // the vault quotes the market price. + (uint256 marketRate, bool marketRateValid) = getMarketRate(); + if (marketRateValid) { + // The vault offers a slight premium on top of the market price to allow for arb. + marketRate = marketRate.mulDiv(ONE + arbTolerancePerc, ONE); + underlyingPerPerp = MathUpgradeable.min(underlyingPerPerp, marketRate); + } + return underlyingAmtIn.mulDiv(underlyingPerPerp, ONE); + } + + /// @notice Fetches the exchange rate between underlying tokens and perps + /// based on market prices. + function getMarketRate() public returns (uint256, bool) { + // When the oracle reference is not set, it returns. + if (address(oracle) == address(0)) { + return (0, false); + } + (uint256 underlyingPerUsd, bool underlyingUsdRateValid) = oracle + .underlyingUsdPrice(); + (uint256 perpPerUsd, bool perpUnderlyingRateValid) = oracle.perpUsdPrice(); + if (!underlyingUsdRateValid || !perpUnderlyingRateValid) { + return (0, false); + } + return (underlyingPerUsd.mulDiv(ONE, perpPerUsd), true); + } + + //----------------------------------------------------------------------------- + // External view methods + + /// @return The balance of underlying tokens in the reserve. + function underlyingBalance() external view returns (uint256) { + return underlying.balanceOf(address(this)); + } + + /// @return The balance of perp tokens in the reserve. + function perpBalance() external view returns (uint256) { + return perp.balanceOf(address(this)); + } + + /// @return The redemption request for a given account and list index. + function getRedemptionRequest( + address account, + uint8 reqIdx + ) external view returns (RedemptionRequest memory) { + return pendingRedemptions[account][reqIdx]; + } + + /// @return The number of redemption requests active from the given account. + function getRedemptionRequestCount(address account) external view returns (uint8) { + return uint8(pendingRedemptions[account].length); + } + + //----------------------------------------------------------------------------- + // Public view methods + + /// @notice Computes the amount of underlying and perp tokens redeemed, + /// when the given number of LP tokens are burnt. + /// @param burnAmt The amount of LP tokens to be burnt. + /// @return underlyingAmtOut The amount of underlying tokens redeemed. + /// @return perpAmtOut The amount of perp tokens redeemed. + function computeRedemptionAmts( + uint256 burnAmt + ) public view returns (uint256 underlyingAmtOut, uint256 perpAmtOut) { + if (burnAmt <= 0) { + return (0, 0); + } + + uint256 totalSupply_ = totalSupply(); + underlyingAmtOut = underlying.balanceOf(address(this)).mulDiv( + burnAmt, + totalSupply_ + ); + perpAmtOut = perp.balanceOf(address(this)).mulDiv(burnAmt, totalSupply_); + } + + /// @notice Computes the total amount of LP tokens have no more lockups and are available to be burnt now. + /// @param account Account address. + /// @return burnAmt Total amount of LP tokens. + function computeBurnableAmt( + address account + ) public view returns (uint256 burnAmt) { + uint8 nRequests = uint8(pendingRedemptions[account].length); + if (nRequests <= 0) { + return 0; + } + for (uint8 i = nRequests; i > 0; i--) { + RedemptionRequest memory req = pendingRedemptions[account][i - 1]; + if (req.resolutionTimestampSec >= block.timestamp) { + continue; + } + burnAmt += req.amount; + } + } + + //----------------------------------------------------------------------------- + // Private methods + + /// @dev Adds redemption request to the list. + function _addRequest(address account, uint256 burnAmt) private { + pendingRedemptions[account].push( + RedemptionRequest(burnAmt, block.timestamp + redemptionWaitTimeSec) + ); + if (pendingRedemptions[account].length > MAX_REDEMPTION_REQUESTS_PER_ACCOUNT) { + revert TooManyRedemptionRequests(); + } + } + + /// @dev Removes resolved redemption requests from the list. + function _removeResolvedRequests( + address account + ) private returns (uint256 amountToBurn) { + uint8 nRequests = uint8(pendingRedemptions[account].length); + if (nRequests <= 0) { + return 0; + } + + for (uint8 i = nRequests; i > 0; i--) { + RedemptionRequest storage req = pendingRedemptions[account][i - 1]; + + // Redemption request has not yet been resolved, + // still in the waiting period, so skip to the next request. + if (req.resolutionTimestampSec >= block.timestamp) { + continue; + } + + // Keep track of the total amount. + amountToBurn += req.amount; + + // We delete current redemption request by over-writing with the last element and + // removing the last element. + RedemptionRequest memory req_ = pendingRedemptions[account][ + pendingRedemptions[account].length - 1 + ]; + req.amount = req_.amount; + req.resolutionTimestampSec = req_.resolutionTimestampSec; + pendingRedemptions[account].pop(); + } + } + + /// @dev Enforces underlying to perp token swap volume under limits. + function _enforcePerpSellLimit(uint256 perpAmtOut) private { + _resetDailyVolume(); + dailyVolume.perpAmt += perpAmtOut; + uint256 swapVolumePerc = ONE.mulDiv( + dailyVolume.perpAmt, + perp.balanceOf(address(this)) + ); + if ( + dailyVolume.perpAmt > perpSellLimit.amount || + swapVolumePerc > perpSellLimit.perc + ) { + revert SwapLimitExceeded(); + } + } + + /// @dev Enforces perps to underlying token swap volume under limits. + function _enforceUnderlyingSellLimit(uint256 underlyingAmtOut) private { + _resetDailyVolume(); + dailyVolume.underlyingAmt += underlyingAmtOut; + uint256 swapVolumePerc = ONE.mulDiv( + dailyVolume.underlyingAmt, + underlying.balanceOf(address(this)) + ); + if ( + dailyVolume.underlyingAmt > underlyingSellLimit.amount || + swapVolumePerc > underlyingSellLimit.perc + ) { + revert SwapLimitExceeded(); + } + } + + /// @dev Resets daily volume book-keeping when in a new calendar day. + function _resetDailyVolume() private { + uint256 currentWindow = block.timestamp - (block.timestamp % DAY_SEC); + if (currentWindow > dailyVolume.dayTimestamp) { + dailyVolume = DailyVolume(currentWindow, 0, 0); + } + } +} diff --git a/spot-vaults/contracts/_interfaces/errors/CommonErrors.sol b/spot-vaults/contracts/_interfaces/errors/CommonErrors.sol index c8d6b263..a90fcd14 100644 --- a/spot-vaults/contracts/_interfaces/errors/CommonErrors.sol +++ b/spot-vaults/contracts/_interfaces/errors/CommonErrors.sol @@ -18,3 +18,6 @@ error UnacceptableSwap(); /// @notice Expected usable external price. error UnreliablePrice(); + +/// @notice Expected range to be strictly increasing. +error InvalidRange(); diff --git a/spot-vaults/contracts/_interfaces/errors/SwingTraderErrors.sol b/spot-vaults/contracts/_interfaces/errors/SwingTraderErrors.sol new file mode 100644 index 00000000..62175bac --- /dev/null +++ b/spot-vaults/contracts/_interfaces/errors/SwingTraderErrors.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +/// @notice Exceeded max active redemption requests per account. +error TooManyRedemptionRequests(); + +/// @notice Exceeded enforced swap limit. +error SwapLimitExceeded(); + +/// @notice Wait time exceeded enforced limit. +error WaittimeTooHigh(); From a9781c9fd557110e4d5336ffa8cbe67e8b4611e0 Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Tue, 17 Sep 2024 22:10:24 -0400 Subject: [PATCH 10/10] unit tests --- spot-vaults/contracts/_test/MockPerp.sol | 15 + spot-vaults/test/BillBroker_deposit_redeem.ts | 136 +-- spot-vaults/test/SpotPricer.ts | 3 +- spot-vaults/test/SwingTrader.ts | 975 ++++++++++++++++++ spot-vaults/test/helpers.ts | 31 +- 5 files changed, 1092 insertions(+), 68 deletions(-) create mode 100644 spot-vaults/contracts/_test/MockPerp.sol create mode 100644 spot-vaults/test/SwingTrader.ts diff --git a/spot-vaults/contracts/_test/MockPerp.sol b/spot-vaults/contracts/_test/MockPerp.sol new file mode 100644 index 00000000..376c3a28 --- /dev/null +++ b/spot-vaults/contracts/_test/MockPerp.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import { MockERC20 } from "./MockERC20.sol"; + +contract MockPerp is MockERC20 { + uint256 private _tvl; + function getTVL() public view returns (uint256) { + return _tvl; + } + + function setTVL(uint256 tvl) public { + _tvl = tvl; + } +} diff --git a/spot-vaults/test/BillBroker_deposit_redeem.ts b/spot-vaults/test/BillBroker_deposit_redeem.ts index 802cc8db..bedc6c34 100644 --- a/spot-vaults/test/BillBroker_deposit_redeem.ts +++ b/spot-vaults/test/BillBroker_deposit_redeem.ts @@ -1,7 +1,7 @@ import { ethers, upgrades } from "hardhat"; import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; import { expect } from "chai"; -import { DMock, usdFP, perpFP, lpAmtFP, percFP, priceFP } from "./helpers"; +import { DMock, usdFP, perpFP, bbLPAmtFP, percFP, priceFP } from "./helpers"; describe("BillBroker", function () { async function setupContracts() { @@ -74,7 +74,7 @@ describe("BillBroker", function () { it("should compute mint amount", async function () { const { billBroker } = await loadFixture(setupContracts); const r = await billBroker.computeMintAmt.staticCall(usdFP("115"), perpFP("100")); - expect(r[0]).to.eq(lpAmtFP("215")); + expect(r[0]).to.eq(bbLPAmtFP("215")); expect(r[1]).to.eq(usdFP("115")); expect(r[2]).to.eq(perpFP("100")); }); @@ -92,7 +92,7 @@ describe("BillBroker", function () { perpFP("100"), ); const r = await billBroker.computeMintAmt.staticCall(usdFP("230"), perpFP("200")); - expect(r[0]).to.eq(lpAmtFP("430")); + expect(r[0]).to.eq(bbLPAmtFP("430")); expect(r[1]).to.eq(usdFP("230")); expect(r[2]).to.eq(perpFP("200")); }); @@ -110,7 +110,7 @@ describe("BillBroker", function () { perpFP("100"), ); const r = await billBroker.computeMintAmt.staticCall(usdFP("230"), perpFP("100")); - expect(r[0]).to.eq(lpAmtFP("215")); + expect(r[0]).to.eq(bbLPAmtFP("215")); expect(r[1]).to.eq(usdFP("115")); expect(r[2]).to.eq(perpFP("100")); }); @@ -128,7 +128,7 @@ describe("BillBroker", function () { perpFP("100"), ); const r = await billBroker.computeMintAmt.staticCall(usdFP("50"), perpFP("100")); - expect(r[0]).to.eq(lpAmtFP("93.478260869565217391304347")); + expect(r[0]).to.eq(bbLPAmtFP("93.478260869565217391304347")); expect(r[1]).to.eq(usdFP("50")); expect(r[2]).to.eq(perpFP("43.478260869")); }); @@ -159,7 +159,7 @@ describe("BillBroker", function () { protocolSwapSharePerc: 0n, }); const r = await billBroker.computeMintAmt.staticCall(usdFP("115"), perpFP("100")); - expect(r[0]).to.eq(lpAmtFP("193.5")); + expect(r[0]).to.eq(bbLPAmtFP("193.5")); expect(r[1]).to.eq(usdFP("115")); expect(r[2]).to.eq(perpFP("100")); }); @@ -184,7 +184,7 @@ describe("BillBroker", function () { expect(await assetRatio(billBroker)).to.eq(ethers.MaxUint256); const r = await billBroker.computeMintAmt.staticCall(usdFP("100"), 0n); - expect(r[0]).to.eq(lpAmtFP("93.478260869565217391304347")); + expect(r[0]).to.eq(bbLPAmtFP("93.478260869565217391304347")); expect(r[1]).to.eq(usdFP("100")); expect(r[2]).to.eq(0n); }); @@ -209,7 +209,7 @@ describe("BillBroker", function () { expect(await assetRatio(billBroker)).to.eq(0); const r = await billBroker.computeMintAmt.staticCall(0n, perpFP("100")); - expect(r[0]).to.eq(lpAmtFP("107.5")); + expect(r[0]).to.eq(bbLPAmtFP("107.5")); expect(r[1]).to.eq(0n); expect(r[2]).to.eq(perpFP("100")); }); @@ -243,7 +243,7 @@ describe("BillBroker", function () { perpFP("200"), ); expect(await billBroker.computeMintAmtWithUSD.staticCall(usdFP("11.5"))).to.eq( - lpAmtFP("10.5"), + bbLPAmtFP("10.5"), ); }); }); @@ -273,7 +273,7 @@ describe("BillBroker", function () { protocolSwapSharePerc: 0n, }); expect(await billBroker.computeMintAmtWithUSD.staticCall(usdFP("11.5"))).to.eq( - lpAmtFP("9.45"), + bbLPAmtFP("9.45"), ); }); }); @@ -296,7 +296,7 @@ describe("BillBroker", function () { expect(await assetRatio(billBroker)).to.eq(0); expect(await billBroker.computeMintAmtWithUSD.staticCall(usdFP("115"))).to.eq( - lpAmtFP("107.5"), + bbLPAmtFP("107.5"), ); }); }); @@ -331,7 +331,7 @@ describe("BillBroker", function () { perpFP("100"), ); expect(await billBroker.computeMintAmtWithPerp.staticCall(perpFP("10.5"))).to.eq( - lpAmtFP("11.5"), + bbLPAmtFP("11.5"), ); }); }); @@ -361,7 +361,7 @@ describe("BillBroker", function () { protocolSwapSharePerc: 0n, }); expect(await billBroker.computeMintAmtWithPerp.staticCall(perpFP("10.5"))).to.eq( - lpAmtFP("10.35"), + bbLPAmtFP("10.35"), ); }); }); @@ -384,7 +384,7 @@ describe("BillBroker", function () { expect(await assetRatio(billBroker)).to.eq(ethers.MaxUint256); expect(await billBroker.computeMintAmtWithPerp.staticCall(perpFP("100"))).to.eq( - lpAmtFP("107.5"), + bbLPAmtFP("107.5"), ); }); }); @@ -466,8 +466,8 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("100")); await expect(() => billBroker.deposit(usdFP("115"), perpFP("100"), usdFP("115"), perpFP("100")), - ).to.changeTokenBalance(billBroker, deployer, lpAmtFP("214.99")); - expect(await billBroker.totalSupply()).to.eq(lpAmtFP("215")); + ).to.changeTokenBalance(billBroker, deployer, bbLPAmtFP("214.99")); + expect(await billBroker.totalSupply()).to.eq(bbLPAmtFP("215")); }); it("should return mint amount", async function () { @@ -480,7 +480,7 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - expect(r).to.eq(lpAmtFP("214.99")); + expect(r).to.eq(bbLPAmtFP("214.99")); }); }); @@ -539,8 +539,8 @@ describe("BillBroker", function () { billBroker .connect(otherUser) .deposit(usdFP("23"), perpFP("100"), usdFP("20"), perpFP("20")), - ).to.changeTokenBalance(billBroker, otherUser, lpAmtFP("43")); - expect(await billBroker.totalSupply()).to.eq(lpAmtFP("258")); + ).to.changeTokenBalance(billBroker, otherUser, bbLPAmtFP("43")); + expect(await billBroker.totalSupply()).to.eq(bbLPAmtFP("258")); }); it("should return mint amount", async function () { @@ -558,7 +558,7 @@ describe("BillBroker", function () { const r = await billBroker .connect(otherUser) .deposit.staticCall(usdFP("23"), perpFP("100"), usdFP("20"), perpFP("20")); - expect(r).to.eq(lpAmtFP("43")); + expect(r).to.eq(bbLPAmtFP("43")); }); }); @@ -582,7 +582,7 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("100")); await expect(() => billBroker.deposit(usdFP("115"), perpFP("100"), usdFP("115"), perpFP("100")), - ).to.changeTokenBalance(billBroker, deployer, lpAmtFP("193.49")); + ).to.changeTokenBalance(billBroker, deployer, bbLPAmtFP("193.49")); }); }); @@ -610,7 +610,7 @@ describe("BillBroker", function () { ).to.changeTokenBalance( billBroker, deployer, - lpAmtFP("93.478260869565217391304347"), + bbLPAmtFP("93.478260869565217391304347"), ); }); }); @@ -636,7 +636,7 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("100")); await expect(() => billBroker.deposit(usdFP("100"), perpFP("100"), 0n, perpFP("100")), - ).to.changeTokenBalance(billBroker, deployer, lpAmtFP("107.5")); + ).to.changeTokenBalance(billBroker, deployer, bbLPAmtFP("107.5")); }); }); }); @@ -652,7 +652,15 @@ describe("BillBroker", function () { describe("when usdAmtIn is zero", function () { it("should return zero", async function () { - const { billBroker } = await loadFixture(setupContracts); + const { billBroker, usd, perp } = await loadFixture(setupContracts); + await usd.approve(billBroker.target, usdFP("115")); + await perp.approve(billBroker.target, perpFP("200")); + await billBroker.deposit( + usdFP("115"), + perpFP("200"), + usdFP("115"), + perpFP("200"), + ); const r = await billBroker.depositUSD.staticCall(0n, percFP("1")); expect(r).to.eq(0n); }); @@ -726,7 +734,7 @@ describe("BillBroker", function () { await usd.approve(billBroker.target, usdFP("115")); expect( await billBroker.depositUSD.staticCall(usdFP("115"), ethers.MaxUint256), - ).to.eq(lpAmtFP("105")); + ).to.eq(bbLPAmtFP("105")); }); }); @@ -784,10 +792,10 @@ describe("BillBroker", function () { ).to.changeTokenBalance( billBroker, deployer, - lpAmtFP("9.130434782608695652173913"), + bbLPAmtFP("9.130434782608695652173913"), ); expect(await billBroker.totalSupply()).to.eq( - lpAmtFP("324.130434782608695652173913"), + bbLPAmtFP("324.130434782608695652173913"), ); }); @@ -807,7 +815,7 @@ describe("BillBroker", function () { .to.emit(billBroker, "DepositUSD") .withArgs(usdFP("10"), r); expect(await billBroker.totalSupply()).to.eq( - lpAmtFP("324.130434782608695652173913"), + bbLPAmtFP("324.130434782608695652173913"), ); }); @@ -824,7 +832,7 @@ describe("BillBroker", function () { await usd.approve(billBroker.target, usdFP("10")); const r = await billBroker.depositUSD.staticCall(usdFP("10"), percFP("1")); - expect(r).to.eq(lpAmtFP("9.130434782608695652173913")); + expect(r).to.eq(bbLPAmtFP("9.130434782608695652173913")); }); }); @@ -860,7 +868,7 @@ describe("BillBroker", function () { ).to.changeTokenBalance( billBroker, deployer, - lpAmtFP("7.395652173913043478260868"), + bbLPAmtFP("7.395652173913043478260868"), ); }); }); @@ -939,7 +947,7 @@ describe("BillBroker", function () { await billBroker.deposit(usdFP("115"), perpFP("90"), usdFP("115"), perpFP("90")); await perp.approve(billBroker.target, perpFP("10")); expect(await billBroker.depositPerp.staticCall(perpFP("10"), 0n)).to.eq( - lpAmtFP("10.789473684210526315789473"), + bbLPAmtFP("10.789473684210526315789473"), ); }); }); @@ -995,8 +1003,8 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("10")); await expect(() => billBroker.depositPerp(perpFP("10"), percFP("1")), - ).to.changeTokenBalance(billBroker, deployer, lpAmtFP("11")); - expect(await billBroker.totalSupply()).to.eq(lpAmtFP("341")); + ).to.changeTokenBalance(billBroker, deployer, bbLPAmtFP("11")); + expect(await billBroker.totalSupply()).to.eq(bbLPAmtFP("341")); }); it("should emit DepositPerp", async function () { @@ -1015,7 +1023,7 @@ describe("BillBroker", function () { await expect(billBroker.depositPerp(perpFP("10"), percFP("1"))) .to.emit(billBroker, "DepositPerp") .withArgs(perpFP("10"), r); - expect(await billBroker.totalSupply()).to.eq(lpAmtFP("341")); + expect(await billBroker.totalSupply()).to.eq(bbLPAmtFP("341")); }); it("should return mint amount", async function () { @@ -1031,7 +1039,7 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("10")); const r = await billBroker.depositPerp.staticCall(perpFP("10"), percFP("1")); - expect(r).to.eq(lpAmtFP("11")); + expect(r).to.eq(bbLPAmtFP("11")); }); }); @@ -1063,7 +1071,7 @@ describe("BillBroker", function () { await perp.approve(billBroker.target, perpFP("10")); await expect(() => billBroker.depositPerp(perpFP("10"), percFP("1")), - ).to.changeTokenBalance(billBroker, deployer, lpAmtFP("9.9")); + ).to.changeTokenBalance(billBroker, deployer, bbLPAmtFP("9.9")); }); }); }); @@ -1081,7 +1089,7 @@ describe("BillBroker", function () { describe("when supply is zero", function () { it("should revert", async function () { const { billBroker } = await loadFixture(setupContracts); - await expect(billBroker.computeRedemptionAmts.staticCall(lpAmtFP("100"))).to.be + await expect(billBroker.computeRedemptionAmts.staticCall(bbLPAmtFP("100"))).to.be .reverted; }); }); @@ -1097,7 +1105,7 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - const r = await billBroker.computeRedemptionAmts.staticCall(lpAmtFP("100")); + const r = await billBroker.computeRedemptionAmts.staticCall(bbLPAmtFP("100")); expect(r[0]).to.eq(usdFP("53.488372")); expect(r[1]).to.eq(perpFP("46.511627906")); }); @@ -1114,7 +1122,7 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - const r = await billBroker.computeRedemptionAmts.staticCall(lpAmtFP("215")); + const r = await billBroker.computeRedemptionAmts.staticCall(bbLPAmtFP("215")); expect(r[0]).to.eq(usdFP("115")); expect(r[1]).to.eq(perpFP("100")); }); @@ -1144,7 +1152,7 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - const r = await billBroker.computeRedemptionAmts.staticCall(lpAmtFP("215")); + const r = await billBroker.computeRedemptionAmts.staticCall(bbLPAmtFP("215")); expect(r[0]).to.eq(usdFP("103.5")); expect(r[1]).to.eq(perpFP("90")); }); @@ -1168,7 +1176,7 @@ describe("BillBroker", function () { expect(await assetRatio(billBroker)).to.eq(ethers.MaxUint256); - const r = await billBroker.computeRedemptionAmts.staticCall(lpAmtFP("100")); + const r = await billBroker.computeRedemptionAmts.staticCall(bbLPAmtFP("100")); expect(r[0]).to.eq(usdFP("106.976744")); expect(r[1]).to.eq(0n); }); @@ -1192,7 +1200,7 @@ describe("BillBroker", function () { expect(await assetRatio(billBroker)).to.eq(0); - const r = await billBroker.computeRedemptionAmts.staticCall(lpAmtFP("100")); + const r = await billBroker.computeRedemptionAmts.staticCall(bbLPAmtFP("100")); expect(r[0]).to.eq(0n); expect(r[1]).to.eq(perpFP("93.023255813")); }); @@ -1228,7 +1236,7 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - await expect(billBroker.redeem.staticCall(lpAmtFP("1000"))).to.be.reverted; + await expect(billBroker.redeem.staticCall(bbLPAmtFP("1000"))).to.be.reverted; }); }); @@ -1243,14 +1251,14 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - await billBroker.transfer(await otherUser.getAddress(), lpAmtFP("101")); + await billBroker.transfer(await otherUser.getAddress(), bbLPAmtFP("101")); await expect(() => - billBroker.connect(otherUser).redeem(lpAmtFP("100")), - ).to.changeTokenBalance(billBroker, otherUser, lpAmtFP("-100")); + billBroker.connect(otherUser).redeem(bbLPAmtFP("100")), + ).to.changeTokenBalance(billBroker, otherUser, bbLPAmtFP("-100")); expect(await billBroker.balanceOf(await otherUser.getAddress())).to.eq( - lpAmtFP("1"), + bbLPAmtFP("1"), ); - expect(await billBroker.totalSupply()).to.eq(lpAmtFP("115")); + expect(await billBroker.totalSupply()).to.eq(bbLPAmtFP("115")); }); it("should not change other balances", async function () { @@ -1265,9 +1273,9 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - await billBroker.transfer(await otherUser.getAddress(), lpAmtFP("101")); + await billBroker.transfer(await otherUser.getAddress(), bbLPAmtFP("101")); await expect(() => - billBroker.connect(otherUser).redeem(lpAmtFP("100")), + billBroker.connect(otherUser).redeem(bbLPAmtFP("100")), ).to.changeTokenBalance(billBroker, deployer, 0n); }); @@ -1281,8 +1289,8 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - await billBroker.transfer(await otherUser.getAddress(), lpAmtFP("101")); - const r = await billBroker.connect(otherUser).redeem.staticCall(lpAmtFP("100")); + await billBroker.transfer(await otherUser.getAddress(), bbLPAmtFP("101")); + const r = await billBroker.connect(otherUser).redeem.staticCall(bbLPAmtFP("100")); expect(r[0]).to.eq(usdFP("53.488372")); expect(r[1]).to.eq(perpFP("46.511627906")); }); @@ -1297,9 +1305,9 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - await billBroker.transfer(await otherUser.getAddress(), lpAmtFP("101")); + await billBroker.transfer(await otherUser.getAddress(), bbLPAmtFP("101")); await expect(() => - billBroker.connect(otherUser).redeem(lpAmtFP("100")), + billBroker.connect(otherUser).redeem(bbLPAmtFP("100")), ).to.changeTokenBalance(usd, otherUser, usdFP("53.488372")); }); @@ -1313,9 +1321,9 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - await billBroker.transfer(await otherUser.getAddress(), lpAmtFP("101")); + await billBroker.transfer(await otherUser.getAddress(), bbLPAmtFP("101")); await expect(() => - billBroker.connect(otherUser).redeem(lpAmtFP("100")), + billBroker.connect(otherUser).redeem(bbLPAmtFP("100")), ).to.changeTokenBalance(perp, otherUser, perpFP("46.511627906")); }); }); @@ -1331,13 +1339,13 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - await expect(() => billBroker.redeem(lpAmtFP("214.99"))).to.changeTokenBalance( + await expect(() => billBroker.redeem(bbLPAmtFP("214.99"))).to.changeTokenBalance( billBroker, deployer, - lpAmtFP("-214.99"), + bbLPAmtFP("-214.99"), ); expect(await billBroker.balanceOf(await deployer.getAddress())).to.eq(0n); - expect(await billBroker.totalSupply()).to.eq(lpAmtFP("0.01")); + expect(await billBroker.totalSupply()).to.eq(bbLPAmtFP("0.01")); }); it("should return returned amounts", async function () { @@ -1350,7 +1358,7 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - const r = await billBroker.redeem.staticCall(lpAmtFP("214.99")); + const r = await billBroker.redeem.staticCall(bbLPAmtFP("214.99")); expect(r[0]).to.eq(usdFP("114.994651")); expect(r[1]).to.eq(perpFP("99.995348837")); }); @@ -1365,7 +1373,7 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - await expect(() => billBroker.redeem(lpAmtFP("214.99"))).to.changeTokenBalance( + await expect(() => billBroker.redeem(bbLPAmtFP("214.99"))).to.changeTokenBalance( usd, deployer, usdFP("114.994651"), @@ -1382,7 +1390,7 @@ describe("BillBroker", function () { usdFP("115"), perpFP("100"), ); - await expect(() => billBroker.redeem(lpAmtFP("214.99"))).to.changeTokenBalance( + await expect(() => billBroker.redeem(bbLPAmtFP("214.99"))).to.changeTokenBalance( perp, deployer, perpFP("99.995348837"), @@ -1409,7 +1417,7 @@ describe("BillBroker", function () { expect(await assetRatio(billBroker)).to.eq(ethers.MaxUint256); const perpBal = await perp.balanceOf(await deployer.getAddress()); - await expect(() => billBroker.redeem(lpAmtFP("100"))).to.changeTokenBalance( + await expect(() => billBroker.redeem(bbLPAmtFP("100"))).to.changeTokenBalance( usd, deployer, usdFP("106.976744"), @@ -1438,7 +1446,7 @@ describe("BillBroker", function () { expect(await assetRatio(billBroker)).to.eq(0); const usdBal = await usd.balanceOf(await deployer.getAddress()); - await expect(() => billBroker.redeem(lpAmtFP("100"))).to.changeTokenBalance( + await expect(() => billBroker.redeem(bbLPAmtFP("100"))).to.changeTokenBalance( perp, deployer, perpFP("93.023255813"), diff --git a/spot-vaults/test/SpotPricer.ts b/spot-vaults/test/SpotPricer.ts index 03550b91..ac7b40ba 100644 --- a/spot-vaults/test/SpotPricer.ts +++ b/spot-vaults/test/SpotPricer.ts @@ -12,10 +12,9 @@ import { amplFP, drFP, DMock, + nowTS, } from "./helpers"; -const nowTS = () => parseInt(Date.now() / 1000); - describe("SpotPricer", function () { async function setupContracts() { const accounts = await ethers.getSigners(); diff --git a/spot-vaults/test/SwingTrader.ts b/spot-vaults/test/SwingTrader.ts new file mode 100644 index 00000000..a87598c8 --- /dev/null +++ b/spot-vaults/test/SwingTrader.ts @@ -0,0 +1,975 @@ +import { ethers, upgrades } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; +import { expect } from "chai"; +import { DMock, perpFP, percFP, priceFP, stLPAmtFP, nowTS, TimeHelpers } from "./helpers"; + +describe("SwingTrader", function () { + async function setupContracts() { + const accounts = await ethers.getSigners(); + const deployer = accounts[0]; + const otherUser = accounts[1]; + const deployerAddress = await deployer.getAddress(); + + const Token = await ethers.getContractFactory("MockERC20"); + const underlying = await Token.deploy(); + await underlying.init("Underlying token", "underlying", 9); + await underlying.mint(deployerAddress, perpFP("100")); + + const Perp = await ethers.getContractFactory("MockPerp"); + const perp = await Perp.deploy(); + await perp.init("Perp token", "perp", 9); + await perp.mint(deployerAddress, perpFP("100")); + await perp.setTVL(perpFP("110")); + + const oracle = new DMock("IPerpPricer"); + await oracle.deploy(); + await oracle.mockMethod("decimals()", [18]); + await oracle.mockMethod("perpUsdPrice()", [0, false]); + await oracle.mockMethod("underlyingUsdPrice()", [0, false]); + + const SwingTrader = await ethers.getContractFactory("SwingTrader"); + const swingTrader = await upgrades.deployProxy( + SwingTrader.connect(deployer), + ["SwingTrader LP", "LP token", underlying.target, perp.target, oracle.target], + { + initializer: "init(string,string,address,address,address)", + }, + ); + return { + deployer, + deployerAddress, + otherUser, + perp, + underlying, + oracle, + swingTrader, + }; + } + + describe("init", function () { + it("should set initial values", async function () { + const { deployerAddress, swingTrader, underlying, perp, oracle } = + await loadFixture(setupContracts); + expect(await swingTrader.underlying()).to.eq(underlying.target); + expect(await swingTrader.perp()).to.eq(perp.target); + + const t = await swingTrader.tradingBand(); + expect(t[0]).to.eq(percFP("0.95")); + expect(t[1]).to.eq(percFP("1.05")); + expect(await swingTrader.redemptionWaitTimeSec()).to.eq(86400 * 28); + expect(await swingTrader.arbTolerancePerc()).to.eq(percFP("0.025")); + + const perpSellLimit = await swingTrader.perpSellLimit(); + expect(perpSellLimit[0]).to.eq(0); + expect(perpSellLimit[1]).to.eq(0); + + const underlyingSellLimit = await swingTrader.underlyingSellLimit(); + expect(underlyingSellLimit[0]).to.eq(0); + expect(underlyingSellLimit[1]).to.eq(0); + + const dailyVolume = await swingTrader.dailyVolume(); + expect(dailyVolume[0]).to.eq(0); + expect(dailyVolume[1]).to.eq(0); + expect(dailyVolume[2]).to.eq(0); + + expect(await swingTrader.oracle()).to.eq(oracle.target); + expect(await swingTrader.owner()).to.eq(deployerAddress); + expect(await swingTrader.keeper()).to.eq(deployerAddress); + + expect(await swingTrader.underlyingBalance()).to.eq(0); + expect(await swingTrader.perpBalance()).to.eq(0); + }); + }); + + describe("#updateKeeper", function () { + describe("when triggered by non-owner", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.renounceOwnership(); + await expect(swingTrader.updateKeeper(ethers.ZeroAddress)).to.be.revertedWith( + "Ownable: caller is not the owner", + ); + }); + }); + + describe("when set address is valid", function () { + it("should update reference", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.updateKeeper(swingTrader.target); + expect(await swingTrader.keeper()).to.eq(swingTrader.target); + }); + }); + }); + + describe("#updateOracle", function () { + describe("when triggered by non-owner", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.renounceOwnership(); + await expect(swingTrader.updateOracle(ethers.ZeroAddress)).to.be.revertedWith( + "Ownable: caller is not the owner", + ); + }); + }); + + describe("when oracle is not valid", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + const oracle = new DMock("SpotPricer"); + await oracle.deploy(); + await oracle.mockMethod("decimals()", [17]); + await expect( + swingTrader.updateOracle(oracle.target), + ).to.be.revertedWithCustomError(swingTrader, "UnexpectedDecimals"); + }); + }); + + it("should update", async function () { + const { swingTrader } = await loadFixture(setupContracts); + const oracle = new DMock("SpotPricer"); + await oracle.deploy(); + await oracle.mockMethod("decimals()", [18]); + + await swingTrader.updateOracle(oracle.target); + expect(await swingTrader.oracle()).to.eq(oracle.target); + }); + }); + + describe("#pause", function () { + describe("when triggered by non-keeper", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.updateKeeper(ethers.ZeroAddress); + await expect(swingTrader.pause()).to.be.revertedWithCustomError( + swingTrader, + "UnauthorizedCall", + ); + }); + }); + + describe("when already paused", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.pause(); + await expect(swingTrader.pause()).to.be.revertedWith("Pausable: paused"); + }); + }); + + describe("when valid", function () { + it("should pause", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.pause(); + expect(await swingTrader.paused()).to.eq(true); + }); + }); + }); + + describe("#unpause", function () { + describe("when triggered by non-keeper", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.pause(); + await swingTrader.updateKeeper(ethers.ZeroAddress); + await expect(swingTrader.unpause()).to.be.revertedWithCustomError( + swingTrader, + "UnauthorizedCall", + ); + }); + }); + + describe("when not paused", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await expect(swingTrader.unpause()).to.be.revertedWith("Pausable: not paused"); + }); + }); + + describe("when valid", function () { + it("should unpause", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.pause(); + await swingTrader.unpause(); + expect(await swingTrader.paused()).to.eq(false); + }); + }); + }); + + describe("#updateRedemptionWaitTimeSec", function () { + describe("when triggered by non-owner", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.renounceOwnership(); + await expect(swingTrader.updateRedemptionWaitTimeSec(0)).to.be.revertedWith( + "Ownable: caller is not the owner", + ); + }); + }); + + describe("when wait time too high", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await expect( + swingTrader.updateRedemptionWaitTimeSec(86400 * 200), + ).to.be.revertedWithCustomError(swingTrader, "WaittimeTooHigh"); + }); + }); + + it("should update", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.updateRedemptionWaitTimeSec(0); + expect(await swingTrader.redemptionWaitTimeSec()).to.eq(0); + }); + }); + + describe("#updateTradingConfig", function () { + describe("when triggered by non-owner", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.renounceOwnership(); + await expect( + swingTrader.updateTradingConfig( + [percFP("0.95"), percFP("1.05")], + percFP("0.05"), + ), + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + }); + + describe("when range is invalid", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await expect( + swingTrader.updateTradingConfig( + [percFP("1.2"), percFP("1.05")], + percFP("0.05"), + ), + ).to.be.revertedWithCustomError(swingTrader, "InvalidRange"); + }); + }); + + it("should update", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.updateTradingConfig( + [percFP("0.95"), percFP("1.05")], + percFP("0.05"), + ); + const b = await swingTrader.tradingBand(); + expect(b[0]).to.eq(percFP("0.95")); + expect(b[1]).to.eq(percFP("1.05")); + expect(await swingTrader.arbTolerancePerc()).to.eq(percFP("0.05")); + }); + }); + + describe("#updateDailySwapLimit", function () { + describe("when triggered by non-owner", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.renounceOwnership(); + await expect( + swingTrader.updateDailySwapLimit( + [perpFP("10000"), percFP("0.05")], + [perpFP("5000"), percFP("0.1")], + ), + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + }); + it("should update", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await swingTrader.updateDailySwapLimit( + [perpFP("10000"), percFP("0.05")], + [perpFP("5000"), percFP("0.1")], + ); + const underlyingSellLimit = await swingTrader.underlyingSellLimit(); + expect(underlyingSellLimit[0]).to.eq(perpFP("10000")); + expect(underlyingSellLimit[1]).to.eq(percFP("0.05")); + const perpSellLimit = await swingTrader.perpSellLimit(); + expect(perpSellLimit[0]).to.eq(perpFP("5000")); + expect(perpSellLimit[1]).to.eq(percFP("0.1")); + }); + }); + + describe("#computeMintAmtWithUnderlying", function () { + describe("when supply is zero", function () { + it("should return mint amt", async function () { + const { swingTrader } = await loadFixture(setupContracts); + const r = await swingTrader.computeMintAmtWithUnderlying.staticCall( + perpFP("1000"), + ); + expect(r[0]).to.eq(stLPAmtFP("1000")); + expect(r[1]).to.eq(true); + }); + }); + + describe("when supply > zero", function () { + it("should return mint amt", async function () { + const { swingTrader, underlying, perp } = await loadFixture(setupContracts); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("100")); + await perp.approve(swingTrader.target, perpFP("50")); + await swingTrader.depositPerp(perpFP("50")); + await perp.setTVL(perpFP("120")); + const r = await swingTrader.computeMintAmtWithUnderlying.staticCall( + perpFP("1000"), + ); + expect(r[0]).to.eq(stLPAmtFP("968.75")); + expect(r[1]).to.eq(false); + }); + }); + }); + + describe("#computeMintAmtWithPerp", function () { + describe("when supply is zero", function () { + it("should return zero", async function () { + const { swingTrader } = await loadFixture(setupContracts); + expect(await swingTrader.computeMintAmtWithPerp.staticCall(perpFP("1000"))).to.eq( + 0, + ); + }); + }); + + describe("when supply > zero", function () { + it("should return mint amt", async function () { + const { swingTrader, underlying, perp } = await loadFixture(setupContracts); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("100")); + await perp.approve(swingTrader.target, perpFP("50")); + await swingTrader.depositPerp(perpFP("50")); + await perp.setTVL(perpFP("120")); + expect(await swingTrader.computeMintAmtWithPerp.staticCall(perpFP("1000"))).to.eq( + stLPAmtFP("1162.5"), + ); + }); + }); + }); + + describe("#computePerpToUnderlyingSwapAmt", function () { + describe("when market rate is not valid", function () { + it("should compute the swap amt", async function () { + const { swingTrader } = await loadFixture(setupContracts); + expect( + await swingTrader.computePerpToUnderlyingSwapAmt.staticCall(perpFP("100")), + ).to.eq(perpFP("104.761904761")); + }); + }); + + describe("when market rate is valid", function () { + describe("when market rate is higher than exchange rate", function () { + it("should compute the swap amt", async function () { + const { swingTrader, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpUsdPrice()", [priceFP("1.5"), true]); + await oracle.mockMethod("underlyingUsdPrice()", [priceFP("1.2"), true]); + expect( + await swingTrader.computePerpToUnderlyingSwapAmt.staticCall(perpFP("100")), + ).to.eq(perpFP("104.761904761")); + }); + }); + describe("when market rate is lower than exchange rate", function () { + it("should compute the swap amt", async function () { + const { swingTrader, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpUsdPrice()", [priceFP("0.75"), true]); + await oracle.mockMethod("underlyingUsdPrice()", [priceFP("1.2"), true]); + expect( + await swingTrader.computePerpToUnderlyingSwapAmt.staticCall(perpFP("100")), + ).to.eq(perpFP("64.102564102")); + }); + }); + }); + }); + + describe("#computeUnderlyingToPerpSwapAmt", function () { + describe("when market rate is not valid", function () { + it("should compute the swap amt", async function () { + const { swingTrader } = await loadFixture(setupContracts); + expect( + await swingTrader.computeUnderlyingToPerpSwapAmt.staticCall(perpFP("100")), + ).to.eq(perpFP("86.363636363")); + }); + }); + + describe("when market rate is valid", function () { + describe("when market rate is higher than exchange rate", function () { + it("should compute the swap amt", async function () { + const { swingTrader, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpUsdPrice()", [priceFP("1.5"), true]); + await oracle.mockMethod("underlyingUsdPrice()", [priceFP("1.2"), true]); + expect( + await swingTrader.computeUnderlyingToPerpSwapAmt.staticCall(perpFP("100")), + ).to.eq(perpFP("82")); + }); + }); + describe("when market rate is lower than exchange rate", function () { + it("should compute the swap amt", async function () { + const { swingTrader, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpUsdPrice()", [priceFP("0.75"), true]); + await oracle.mockMethod("underlyingUsdPrice()", [priceFP("1.2"), true]); + expect( + await swingTrader.computeUnderlyingToPerpSwapAmt.staticCall(perpFP("100")), + ).to.eq(perpFP("86.363636363")); + }); + }); + }); + }); + + describe("#getMarketRate", function () { + describe("when perp rate is invalid", function () { + it("should return 0", async function () { + const { swingTrader, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpUsdPrice()", [priceFP("0.75"), false]); + await oracle.mockMethod("underlyingUsdPrice()", [priceFP("1.2"), true]); + const p = await swingTrader.getMarketRate.staticCall(); + expect(p[0]).to.eq(0); + expect(p[1]).to.eq(false); + }); + }); + describe("when underlying rate is invalid", function () { + it("should return 0", async function () { + const { swingTrader, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpUsdPrice()", [priceFP("0.75"), true]); + await oracle.mockMethod("underlyingUsdPrice()", [priceFP("1.2"), false]); + const p = await swingTrader.getMarketRate.staticCall(); + expect(p[0]).to.eq(0); + expect(p[1]).to.eq(false); + }); + }); + + it("should market rate", async function () { + const { swingTrader, oracle } = await loadFixture(setupContracts); + await oracle.mockMethod("perpUsdPrice()", [priceFP("1.3"), true]); + await oracle.mockMethod("underlyingUsdPrice()", [priceFP("1.2"), true]); + const p = await swingTrader.getMarketRate.staticCall(); + expect(p[0]).to.eq(priceFP("0.923076923076923076")); + expect(p[1]).to.eq(true); + }); + }); + + describe("#depositUnderlying", function () { + describe("when amount is zero", function () { + it("should be a no-op", async function () { + const { swingTrader, underlying, deployer } = await loadFixture(setupContracts); + await expect(() => swingTrader.depositUnderlying(0)).to.changeTokenBalance( + underlying, + deployer, + 0, + ); + }); + }); + + describe("when supply is zero", function () { + it("should transfer underlying from user", async function () { + const { swingTrader, underlying, deployer } = await loadFixture(setupContracts); + await underlying.approve(swingTrader.target, perpFP("100")); + await expect(() => + swingTrader.depositUnderlying(perpFP("100")), + ).to.changeTokenBalance(underlying, deployer, perpFP("-100")); + }); + + it("should mint lp tokens", async function () { + const { swingTrader, underlying, deployer } = await loadFixture(setupContracts); + await underlying.approve(swingTrader.target, perpFP("100")); + await expect(() => + swingTrader.depositUnderlying(perpFP("100")), + ).to.changeTokenBalance(swingTrader, deployer, stLPAmtFP("99.999")); + expect(await swingTrader.totalSupply()).to.eq(stLPAmtFP("100")); + }); + }); + + describe("when supply > zero", function () { + it("should transfer underlying from user", async function () { + const { swingTrader, underlying, deployer, otherUser, deployerAddress } = + await loadFixture(setupContracts); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("100")); + await underlying.mint(deployerAddress, perpFP("100")); + await underlying.transfer(swingTrader.target, perpFP("50")); + await underlying.transfer(await otherUser.getAddress(), perpFP("50")); + await underlying.connect(otherUser).approve(swingTrader.target, perpFP("50")); + await expect(() => + swingTrader.connect(otherUser).depositUnderlying(perpFP("50")), + ).to.changeTokenBalances(underlying, [otherUser, deployer], [perpFP("-50"), 0]); + }); + + it("should mint lp tokens", async function () { + const { swingTrader, underlying, deployer, otherUser, deployerAddress } = + await loadFixture(setupContracts); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("100")); + await underlying.mint(deployerAddress, perpFP("100")); + await underlying.transfer(swingTrader.target, perpFP("50")); + await underlying.transfer(await otherUser.getAddress(), perpFP("50")); + await underlying.connect(otherUser).approve(swingTrader.target, perpFP("50")); + await expect(() => + swingTrader.connect(otherUser).depositUnderlying(perpFP("50")), + ).to.changeTokenBalances( + swingTrader, + [otherUser, deployer], + [stLPAmtFP("33.333333333333333"), 0], + ); + expect(await swingTrader.totalSupply()).to.eq(stLPAmtFP("133.333333333333333")); + }); + }); + }); + + describe("#depositPerp", function () { + describe("when amount is zero", function () { + it("should be a no-op", async function () { + const { swingTrader, perp, deployer } = await loadFixture(setupContracts); + await expect(() => swingTrader.depositPerp(0)).to.changeTokenBalance( + perp, + deployer, + 0, + ); + }); + }); + + describe("when supply is zero", function () { + it("should be a no-op", async function () { + const { swingTrader, perp, deployer } = await loadFixture(setupContracts); + await perp.approve(swingTrader.target, perpFP("100")); + await expect(() => swingTrader.depositPerp(perpFP("100"))).to.changeTokenBalance( + perp, + deployer, + 0, + ); + expect(await swingTrader.totalSupply()).to.eq(0); + }); + }); + + describe("when supply > zero", function () { + it("should transfer perp from user", async function () { + const { swingTrader, underlying, perp, deployer, otherUser } = await loadFixture( + setupContracts, + ); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("100")); + await perp.transfer(swingTrader.target, perpFP("50")); + await perp.transfer(await otherUser.getAddress(), perpFP("50")); + await perp.connect(otherUser).approve(swingTrader.target, perpFP("50")); + await expect(() => + swingTrader.connect(otherUser).depositPerp(perpFP("50")), + ).to.changeTokenBalances(perp, [otherUser, deployer], [perpFP("-50"), 0]); + }); + + it("should mint lp tokens", async function () { + const { swingTrader, underlying, perp, deployer, otherUser } = await loadFixture( + setupContracts, + ); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("100")); + await perp.transfer(swingTrader.target, perpFP("50")); + await perp.transfer(await otherUser.getAddress(), perpFP("50")); + await perp.connect(otherUser).approve(swingTrader.target, perpFP("50")); + await expect(() => + swingTrader.connect(otherUser).depositPerp(perpFP("50")), + ).to.changeTokenBalances( + swingTrader, + [otherUser, deployer], + [stLPAmtFP("35.483870967741935"), 0], + ); + expect(await swingTrader.totalSupply()).to.eq(stLPAmtFP("135.483870967741935")); + }); + }); + }); + + describe("#requestRedeem", function () { + describe("when amount is zero", function () { + it("should be a no-op", async function () { + const { swingTrader, deployer } = await loadFixture(setupContracts); + await expect(() => swingTrader.requestRedeem(0)).to.changeTokenBalances( + swingTrader, + [deployer, swingTrader], + [0, 0], + ); + }); + }); + it("should take custody of tokens and add redemption request", async function () { + const { swingTrader, underlying, perp, deployer, deployerAddress } = + await loadFixture(setupContracts); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("100")); + await perp.approve(swingTrader.target, perpFP("50")); + await swingTrader.depositPerp(perpFP("50")); + + await expect(() => + swingTrader.requestRedeem(stLPAmtFP("33")), + ).to.changeTokenBalances( + swingTrader, + [deployer, swingTrader], + [stLPAmtFP("-33"), stLPAmtFP("33")], + ); + + expect(await swingTrader.getRedemptionRequestCount(deployerAddress)).to.eq(1); + const r = await swingTrader.getRedemptionRequest(deployerAddress, 0); + expect(r[0]).to.eq(stLPAmtFP("33")); + expect(r[1]).to.lte(nowTS() + 86400 * 29); + }); + + describe("when there are too many active requests", function () { + it("should revert", async function () { + const { swingTrader, underlying, perp } = await loadFixture(setupContracts); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("100")); + await perp.approve(swingTrader.target, perpFP("50")); + await swingTrader.depositPerp(perpFP("50")); + for (let i = 0; i < 32; i++) { + await swingTrader.requestRedeem(stLPAmtFP("1")); + } + await expect( + swingTrader.requestRedeem(stLPAmtFP("1")), + ).to.be.revertedWithCustomError(swingTrader, "TooManyRedemptionRequests"); + }); + }); + }); + + describe("#execRedeem", function () { + async function setupRedeem() { + const { swingTrader, underlying, perp, deployer, deployerAddress } = + await loadFixture(setupContracts); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("100")); + await perp.approve(swingTrader.target, perpFP("50")); + await swingTrader.depositPerp(perpFP("50")); + return { swingTrader, underlying, perp, deployer, deployerAddress }; + } + describe("when lock is active", function () { + it("should not redeem tokens", async function () { + const { swingTrader, deployer, deployerAddress } = await setupRedeem(); + await swingTrader.requestRedeem(stLPAmtFP("25")); + expect(await swingTrader.computeBurnableAmt(deployerAddress)).to.eq(0); + await expect(() => swingTrader.execRedeem()).to.changeTokenBalances( + swingTrader, + [deployer, swingTrader], + [0, 0], + ); + }); + }); + + describe("when lock has expired", function () { + it("should burn lp tokens", async function () { + const { swingTrader, deployer, deployerAddress } = await setupRedeem(); + await swingTrader.requestRedeem(stLPAmtFP("15.5")); + + await TimeHelpers.increaseTime(30 * 86400); + expect(await swingTrader.computeBurnableAmt(deployerAddress)).to.eq( + stLPAmtFP("15.5"), + ); + await expect(() => swingTrader.execRedeem()).to.changeTokenBalances( + swingTrader, + [deployer, swingTrader], + [0, stLPAmtFP("-15.5")], + ); + expect(await swingTrader.computeBurnableAmt(deployerAddress)).to.eq(0); + expect(await swingTrader.getRedemptionRequestCount(deployerAddress)).to.eq(0); + expect(await swingTrader.totalSupply()).to.eq(stLPAmtFP("139.5")); + }); + + it("should redeem underlying", async function () { + const { swingTrader, underlying, deployer } = await setupRedeem(); + await swingTrader.requestRedeem(stLPAmtFP("15.5")); + + await TimeHelpers.increaseTime(30 * 86400); + await expect(() => swingTrader.execRedeem()).to.changeTokenBalances( + underlying, + [deployer, swingTrader], + [perpFP("10"), perpFP("-10")], + ); + }); + + it("should redeem perp", async function () { + const { swingTrader, perp, deployer } = await setupRedeem(); + await swingTrader.requestRedeem(stLPAmtFP("15.5")); + + await TimeHelpers.increaseTime(30 * 86400); + await expect(() => swingTrader.execRedeem()).to.changeTokenBalances( + perp, + [deployer, swingTrader], + [perpFP("5"), perpFP("-5")], + ); + }); + }); + + describe("when redeeming multiple requests", function () { + it("should burn lp tokens", async function () { + const { swingTrader, deployer, deployerAddress } = await setupRedeem(); + for (let i = 0; i < 5; i++) { + await swingTrader.requestRedeem(stLPAmtFP(`${5 + i}`)); + await TimeHelpers.increaseTime(7 * 86400); + } + + expect(await swingTrader.computeBurnableAmt(deployerAddress)).to.eq( + stLPAmtFP("11"), + ); + await expect(() => swingTrader.execRedeem()).to.changeTokenBalances( + swingTrader, + [deployer, swingTrader], + [0, stLPAmtFP("-11")], + ); + expect(await swingTrader.computeBurnableAmt(deployerAddress)).to.eq(0); + expect(await swingTrader.getRedemptionRequestCount(deployerAddress)).to.eq(3); + expect(await swingTrader.totalSupply()).to.eq(stLPAmtFP("144")); + + const r0 = await swingTrader.getRedemptionRequest(deployerAddress, 0); + const r1 = await swingTrader.getRedemptionRequest(deployerAddress, 1); + const r2 = await swingTrader.getRedemptionRequest(deployerAddress, 2); + expect(r2[0]).to.eq(stLPAmtFP("7")); + expect(r0[0]).to.eq(stLPAmtFP("8")); + expect(r1[0]).to.eq(stLPAmtFP("9")); + }); + + it("should redeem underlying", async function () { + const { swingTrader, underlying, deployer } = await setupRedeem(); + for (let i = 0; i < 5; i++) { + await swingTrader.requestRedeem(stLPAmtFP(`${5 + i}`)); + await TimeHelpers.increaseTime(7 * 86400); + } + + await expect(() => swingTrader.execRedeem()).to.changeTokenBalances( + underlying, + [deployer, swingTrader], + [perpFP("7.096774193"), perpFP("-7.096774193")], + ); + }); + + it("should redeem perp", async function () { + const { swingTrader, perp, deployer } = await setupRedeem(); + for (let i = 0; i < 5; i++) { + await swingTrader.requestRedeem(stLPAmtFP(`${5 + i}`)); + await TimeHelpers.increaseTime(7 * 86400); + } + + await expect(() => swingTrader.execRedeem()).to.changeTokenBalances( + perp, + [deployer, swingTrader], + [perpFP("3.548387096"), perpFP("-3.548387096")], + ); + }); + }); + }); + + describe("#swapUnderlyingForPerps", function () { + async function setupSwap() { + const { swingTrader, underlying, perp, deployer } = await loadFixture( + setupContracts, + ); + await underlying.approve(swingTrader.target, perpFP("100")); + await swingTrader.depositUnderlying(perpFP("50")); + await perp.transfer(swingTrader.target, perpFP("50")); + return { swingTrader, underlying, perp, deployer }; + } + describe("when amount is zero", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await expect( + swingTrader.swapUnderlyingForPerps(0, 0), + ).to.be.revertedWithCustomError(swingTrader, "UnacceptableSwap"); + }); + }); + + describe("when slippage too high", function () { + it("should revert", async function () { + const { swingTrader } = await setupSwap(); + await expect( + swingTrader.swapUnderlyingForPerps(perpFP("10"), perpFP("20")), + ).to.be.revertedWithCustomError(swingTrader, "SlippageTooHigh"); + }); + }); + + describe("when abs swap limit is reached", function () { + it("should transfer revert", async function () { + const { swingTrader } = await setupSwap(); + + await swingTrader.updateDailySwapLimit([0, 0], [perpFP("8"), percFP("0.5")]); + await expect( + swingTrader.swapUnderlyingForPerps(perpFP("10"), 0), + ).to.be.revertedWithCustomError(swingTrader, "SwapLimitExceeded"); + }); + }); + + describe("when perc swap limit is reached", function () { + it("should transfer revert", async function () { + const { swingTrader } = await setupSwap(); + + await swingTrader.updateDailySwapLimit([0, 0], [perpFP("12"), percFP("0.01")]); + await expect( + swingTrader.swapUnderlyingForPerps(perpFP("10"), 0), + ).to.be.revertedWithCustomError(swingTrader, "SwapLimitExceeded"); + }); + }); + + describe("when swap is valid", function () { + it("should transfer underlying tokens from the user", async function () { + const { swingTrader, underlying, deployer } = await setupSwap(); + + await swingTrader.updateDailySwapLimit([0, 0], [perpFP("100"), percFP("0.5")]); + await expect(() => + swingTrader.swapUnderlyingForPerps(perpFP("10"), 0), + ).to.changeTokenBalances( + underlying, + [deployer, swingTrader], + [perpFP("-10"), perpFP("10")], + ); + }); + it("should transfer perp tokens to the user", async function () { + const { swingTrader, perp, deployer } = await setupSwap(); + + await swingTrader.updateDailySwapLimit([0, 0], [perpFP("100"), percFP("0.5")]); + await expect(() => + swingTrader.swapUnderlyingForPerps(perpFP("10"), 0), + ).to.changeTokenBalances( + perp, + [deployer, swingTrader], + [perpFP("8.636363636"), perpFP("-8.636363636")], + ); + }); + + it("should update volumes", async function () { + const { swingTrader } = await setupSwap(); + + await swingTrader.updateDailySwapLimit([0, 0], [perpFP("100"), percFP("0.5")]); + const ts = await nowTS(); + const d = await swingTrader.dailyVolume(); + expect(d[0]).to.lte(ts); + expect(d[2]).to.eq(0); + await swingTrader.swapUnderlyingForPerps(perpFP("10"), 0); + const d_ = await swingTrader.dailyVolume(); + expect(d_[0]).to.eq(ts - (ts % 86400)); + expect(d_[2]).to.eq(perpFP("8.636363636")); + }); + }); + + describe("when abs swap limit time has surpassed", function () { + it("should reset swap limit", async function () { + const { swingTrader } = await setupSwap(); + + await swingTrader.updateDailySwapLimit([0, 0], [perpFP("10"), percFP("0.5")]); + await swingTrader.swapUnderlyingForPerps(perpFP("10"), 0); + + await expect( + swingTrader.swapUnderlyingForPerps(perpFP("10"), perpFP("8.5")), + ).to.be.revertedWithCustomError(swingTrader, "SwapLimitExceeded"); + + await TimeHelpers.increaseTime(86400); + + const d = await swingTrader.dailyVolume(); + expect(d[2]).to.eq(perpFP("8.636363636")); + + await expect(swingTrader.swapUnderlyingForPerps(perpFP("5"), perpFP("3"))).not.to + .be.reverted; + const d_ = await swingTrader.dailyVolume(); + expect(d_[0] - d[0]).to.eq(86400); + expect(d_[2]).to.eq(perpFP("4.318181818")); + }); + }); + }); + + describe("#swapPerpsForUnderlying", function () { + async function setupSwap() { + const { swingTrader, underlying, perp, deployer } = await loadFixture( + setupContracts, + ); + await underlying.approve(swingTrader.target, perpFP("50")); + await swingTrader.depositUnderlying(perpFP("50")); + await perp.transfer(swingTrader.target, perpFP("50")); + await perp.approve(swingTrader.target, perpFP("50")); + return { swingTrader, underlying, perp, deployer }; + } + describe("when amount is zero", function () { + it("should revert", async function () { + const { swingTrader } = await loadFixture(setupContracts); + await expect( + swingTrader.swapPerpsForUnderlying(0, 0), + ).to.be.revertedWithCustomError(swingTrader, "UnacceptableSwap"); + }); + }); + + describe("when slippage too high", function () { + it("should revert", async function () { + const { swingTrader } = await setupSwap(); + await expect( + swingTrader.swapPerpsForUnderlying(perpFP("10"), perpFP("20")), + ).to.be.revertedWithCustomError(swingTrader, "SlippageTooHigh"); + }); + }); + + describe("when abs swap limit is reached", function () { + it("should transfer revert", async function () { + const { swingTrader } = await setupSwap(); + await swingTrader.updateDailySwapLimit([perpFP("8"), percFP("0.5")], [0, 0]); + await expect( + swingTrader.swapPerpsForUnderlying(perpFP("10"), 0), + ).to.be.revertedWithCustomError(swingTrader, "SwapLimitExceeded"); + }); + }); + + describe("when perc swap limit is reached", function () { + it("should transfer revert", async function () { + const { swingTrader } = await setupSwap(); + await swingTrader.updateDailySwapLimit([perpFP("12"), percFP("0.01")], [0, 0]); + await expect( + swingTrader.swapPerpsForUnderlying(perpFP("10"), 0), + ).to.be.revertedWithCustomError(swingTrader, "SwapLimitExceeded"); + }); + }); + + describe("when swap is valid", function () { + it("should transfer perp tokens from the user", async function () { + const { swingTrader, perp, deployer } = await setupSwap(); + await swingTrader.updateDailySwapLimit([perpFP("100"), percFP("0.5")], [0, 0]); + await expect(() => + swingTrader.swapPerpsForUnderlying(perpFP("10"), 0), + ).to.changeTokenBalances( + perp, + [deployer, swingTrader], + [perpFP("-10"), perpFP("10")], + ); + }); + it("should transfer underlying tokens to the user", async function () { + const { swingTrader, underlying, deployer } = await setupSwap(); + await swingTrader.updateDailySwapLimit([perpFP("100"), percFP("0.5")], [0, 0]); + await expect(() => + swingTrader.swapPerpsForUnderlying(perpFP("10"), 0), + ).to.changeTokenBalances( + underlying, + [deployer, swingTrader], + [perpFP("10.476190476"), perpFP("-10.476190476")], + ); + }); + + it("should update volumes", async function () { + const { swingTrader } = await setupSwap(); + await swingTrader.updateDailySwapLimit([perpFP("100"), percFP("0.5")], [0, 0]); + const ts = await nowTS(); + const d = await swingTrader.dailyVolume(); + expect(d[0]).to.lte(ts); + expect(d[1]).to.eq(0); + await swingTrader.swapPerpsForUnderlying(perpFP("10"), 0); + const d_ = await swingTrader.dailyVolume(); + expect(d_[0]).to.eq(ts - (ts % 86400)); + expect(d_[1]).to.eq(perpFP("10.476190476")); + }); + }); + + describe("when abs swap limit time has surpassed", function () { + it("should reset swap limit", async function () { + const { swingTrader } = await setupSwap(); + + await swingTrader.updateDailySwapLimit([perpFP("11"), percFP("0.5")], [0, 0]); + await swingTrader.swapPerpsForUnderlying(perpFP("10"), 0); + + await expect( + swingTrader.swapPerpsForUnderlying(perpFP("10"), 0), + ).to.be.revertedWithCustomError(swingTrader, "SwapLimitExceeded"); + + await TimeHelpers.increaseTime(86400); + + const d = await swingTrader.dailyVolume(); + expect(d[1]).to.eq(perpFP("10.476190476")); + + await expect(swingTrader.swapPerpsForUnderlying(perpFP("5"), perpFP("3"))).not.to + .be.reverted; + const d_ = await swingTrader.dailyVolume(); + expect(d_[0] - d[0]).to.eq(86400); + expect(d_[1]).to.eq(perpFP("5.238095238")); + }); + }); + }); +}); diff --git a/spot-vaults/test/helpers.ts b/spot-vaults/test/helpers.ts index 141635fd..ec8c2036 100644 --- a/spot-vaults/test/helpers.ts +++ b/spot-vaults/test/helpers.ts @@ -1,6 +1,7 @@ -import { ethers } from "hardhat"; +import hre, { ethers } from "hardhat"; import { Contract, ContractFactory } from "ethers"; +export const nowTS = () => parseInt(Date.now() / 1000); export const sciParseFloat = (a: string): BigInt => a.includes("e") ? parseFloat(a).toFixed(18) : a; export const percFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); @@ -8,7 +9,8 @@ export const priceFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a) export const usdFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 6); export const perpFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 9); -export const lpAmtFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 24); +export const bbLPAmtFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 24); +export const stLPAmtFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 15); export const amplFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 9); export const wamplFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); export const wethFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); @@ -18,6 +20,31 @@ export const amplOracleFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 18); export const drFP = (a: string): BigInt => ethers.parseUnits(sciParseFloat(a), 8); +export const TimeHelpers = { + secondsFromNow: async (secondsFromNow: number): Promise => { + return (await TimeHelpers.currentTime()) + secondsFromNow; + }, + + increaseTime: async (seconds: number): Promise => { + await hre.network.provider.send("evm_increaseTime", [seconds]); + await hre.network.provider.send("evm_mine"); + }, + + setNextBlockTimestamp: async (timestamp: number): Promise => { + await ethers.provider.send("evm_setNextBlockTimestamp", [timestamp]); + await hre.network.provider.send("evm_mine"); + }, + + currentTime: async (): Promise => { + const res = await hre.network.provider.send("eth_getBlockByNumber", [ + "latest", + false, + ]); + const timestamp = parseInt(res.timestamp, 16); + return timestamp; + }, +}; + export class DMock { private refArtifact: string; private refFactory: ContractFactory;