diff --git a/contracts/ExchangeRates.sol b/contracts/ExchangeRates.sol index 03759d99c0..eff307e3e7 100644 --- a/contracts/ExchangeRates.sol +++ b/contracts/ExchangeRates.sol @@ -117,25 +117,33 @@ contract ExchangeRates is Owned, MixinSystemSettings, IExchangeRates { return _getCurrentRoundId(currencyKey); } - function effectiveValueAtRound( + function effectiveValueAndRatesAtRound( bytes32 sourceCurrencyKey, uint sourceAmount, bytes32 destinationCurrencyKey, uint roundIdForSrc, uint roundIdForDest - ) external view returns (uint value) { + ) + external + view + returns ( + uint value, + uint sourceRate, + uint destinationRate + ) + { + (sourceRate, ) = _getRateAndTimestampAtRound(sourceCurrencyKey, roundIdForSrc); // If there's no change in the currency, then just return the amount they gave us - if (sourceCurrencyKey == destinationCurrencyKey) return sourceAmount; - - (uint srcRate, ) = _getRateAndTimestampAtRound(sourceCurrencyKey, roundIdForSrc); - (uint destRate, ) = _getRateAndTimestampAtRound(destinationCurrencyKey, roundIdForDest); - if (destRate == 0) { - // prevent divide-by 0 error (this can happen when roundIDs jump epochs due - // to aggregator upgrades) - return 0; + if (sourceCurrencyKey == destinationCurrencyKey) { + value = sourceAmount; + } else { + (destinationRate, ) = _getRateAndTimestampAtRound(destinationCurrencyKey, roundIdForDest); + // prevent divide-by 0 error (this happens if the dest is not a valid rate) + if (destinationRate > 0) { + // Calculate the effective value by going from source -> USD -> destination + value = sourceAmount.multiplyDecimalRound(sourceRate).divideDecimalRound(destinationRate); + } } - // Calculate the effective value by going from source -> USD -> destination - value = sourceAmount.multiplyDecimalRound(srcRate).divideDecimalRound(destRate); } function rateAndTimestampAtRound(bytes32 currencyKey, uint roundId) external view returns (uint rate, uint time) { @@ -202,15 +210,20 @@ contract ExchangeRates is Owned, MixinSystemSettings, IExchangeRates { return _getRateAndUpdatedTime(currencyKey).rate; } - function ratesAndUpdatedTimeForCurrencyLastNRounds(bytes32 currencyKey, uint numRounds) - external - view - returns (uint[] memory rates, uint[] memory times) - { + /// @notice getting N rounds of rates for a currency at a specific round + /// @param currencyKey the currency key + /// @param numRounds the number of rounds to get + /// @param roundId the round id + /// @return a list of rates and a list of times + function ratesAndUpdatedTimeForCurrencyLastNRounds( + bytes32 currencyKey, + uint numRounds, + uint roundId + ) external view returns (uint[] memory rates, uint[] memory times) { rates = new uint[](numRounds); times = new uint[](numRounds); - uint roundId = _getCurrentRoundId(currencyKey); + roundId = roundId > 0 ? roundId : _getCurrentRoundId(currencyKey); for (uint i = 0; i < numRounds; i++) { // fetch the rate and treat is as current, so inverse limits if frozen will always be applied // regardless of current rate @@ -299,6 +312,27 @@ contract ExchangeRates is Owned, MixinSystemSettings, IExchangeRates { return false; } + function anyRateIsInvalidAtRound(bytes32[] calldata currencyKeys, uint[] calldata roundIds) + external + view + returns (bool) + { + // Loop through each key and check whether the data point is stale. + + require(roundIds.length == currencyKeys.length, "roundIds must be the same length as currencyKeys"); + + uint256 _rateStalePeriod = getRateStalePeriod(); + bool[] memory flagList = getFlagsForRates(currencyKeys); + + for (uint i = 0; i < currencyKeys.length; i++) { + if (flagList[i] || _rateIsStaleAtRound(currencyKeys[i], roundIds[i], _rateStalePeriod)) { + return true; + } + } + + return false; + } + function synthTooVolatileForAtomicExchange(bytes32) external view returns (bool) { _notImplemented(); } @@ -379,7 +413,7 @@ contract ExchangeRates is Owned, MixinSystemSettings, IExchangeRates { function _getCurrentRoundId(bytes32 currencyKey) internal view returns (uint) { if (currencyKey == sUSD) { - return 0; // no roundIds for sUSD + return 0; } AggregatorV2V3Interface aggregator = aggregators[currencyKey]; if (aggregator != AggregatorV2V3Interface(0)) { @@ -395,7 +429,6 @@ contract ExchangeRates is Owned, MixinSystemSettings, IExchangeRates { return (SafeDecimalMath.unit(), 0); } else { AggregatorV2V3Interface aggregator = aggregators[currencyKey]; - if (aggregator != AggregatorV2V3Interface(0)) { // this view from the aggregator is the most gas efficient but it can throw when there's no data, // so let's call it low-level to suppress any reverts @@ -450,18 +483,34 @@ contract ExchangeRates is Owned, MixinSystemSettings, IExchangeRates { function _rateIsStale(bytes32 currencyKey, uint _rateStalePeriod) internal view returns (bool) { // sUSD is a special case and is never stale (check before an SLOAD of getRateAndUpdatedTime) - if (currencyKey == sUSD) return false; - + if (currencyKey == sUSD) { + return false; + } return _rateIsStaleWithTime(_rateStalePeriod, _getUpdatedTime(currencyKey)); } + function _rateIsStaleAtRound( + bytes32 currencyKey, + uint roundId, + uint _rateStalePeriod + ) internal view returns (bool) { + // sUSD is a special case and is never stale (check before an SLOAD of getRateAndUpdatedTime) + if (currencyKey == sUSD) { + return false; + } + (, uint time) = _getRateAndTimestampAtRound(currencyKey, roundId); + return _rateIsStaleWithTime(_rateStalePeriod, time); + } + function _rateIsStaleWithTime(uint _rateStalePeriod, uint _time) internal view returns (bool) { return _time.add(_rateStalePeriod) < now; } function _rateIsFlagged(bytes32 currencyKey, FlagsInterface flags) internal view returns (bool) { // sUSD is a special case and is never invalid - if (currencyKey == sUSD) return false; + if (currencyKey == sUSD) { + return false; + } address aggregator = address(aggregators[currencyKey]); // when no aggregator or when the flags haven't been setup if (aggregator == address(0) || flags == FlagsInterface(0)) { diff --git a/contracts/Exchanger.sol b/contracts/Exchanger.sol index aebfdf06b1..9ff1a888f6 100644 --- a/contracts/Exchanger.sol +++ b/contracts/Exchanger.sol @@ -73,17 +73,6 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { using SafeMath for uint; using SafeDecimalMath for uint; - struct ExchangeEntrySettlement { - bytes32 src; - uint amount; - bytes32 dest; - uint reclaim; - uint rebate; - uint srcRoundIdAtPeriodEnd; - uint destRoundIdAtPeriodEnd; - uint timestamp; - } - bytes32 public constant CONTRACT_NAME = "Exchanger"; bytes32 internal constant sUSD = "sUSD"; @@ -91,6 +80,9 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { // SIP-65: Decentralized circuit breaker uint public constant CIRCUIT_BREAKER_SUSPENSION_REASON = 65; + /// @notice Return the last exchange rate + /// @param currencyKey is the currency key of the synth to be exchanged + /// @return the last exchange rate of the synth to sUSD mapping(bytes32 => uint) public lastExchangeRate; /* ========== ADDRESS RESOLVER CONFIGURATION ========== */ @@ -196,14 +188,14 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { uint reclaimAmount, uint rebateAmount, uint numEntries, - ExchangeEntrySettlement[] memory + IExchanger.ExchangeEntrySettlement[] memory ) { // Need to sum up all reclaim and rebate amounts for the user and the currency key numEntries = exchangeState().getLengthOfEntries(account, currencyKey); // For each unsettled exchange - ExchangeEntrySettlement[] memory settlements = new ExchangeEntrySettlement[](numEntries); + IExchanger.ExchangeEntrySettlement[] memory settlements = new IExchanger.ExchangeEntrySettlement[](numEntries); for (uint i = 0; i < numEntries; i++) { uint reclaim; uint rebate; @@ -214,8 +206,8 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { (uint srcRoundIdAtPeriodEnd, uint destRoundIdAtPeriodEnd) = getRoundIdsAtPeriodEnd(exchangeEntry); // given these round ids, determine what effective value they should have received - uint destinationAmount = - exchangeRates().effectiveValueAtRound( + (uint destinationAmount, , ) = + exchangeRates().effectiveValueAndRatesAtRound( exchangeEntry.src, exchangeEntry.amount, exchangeEntry.dest, @@ -240,7 +232,7 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { } } - settlements[i] = ExchangeEntrySettlement({ + settlements[i] = IExchanger.ExchangeEntrySettlement({ src: exchangeEntry.src, amount: exchangeEntry.amount, dest: exchangeEntry.dest, @@ -443,7 +435,36 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { IVirtualSynth vSynth ) { - _ensureCanExchange(sourceCurrencyKey, sourceAmount, destinationCurrencyKey); + // Using struct to resolve stack too deep error + IExchanger.ExchangeEntry memory entry; + + entry.roundIdForSrc = exchangeRates().getCurrentRoundId(sourceCurrencyKey); + entry.roundIdForDest = exchangeRates().getCurrentRoundId(destinationCurrencyKey); + + (entry.destinationAmount, entry.sourceRate, entry.destinationRate) = exchangeRates().effectiveValueAndRatesAtRound( + sourceCurrencyKey, + sourceAmount, + destinationCurrencyKey, + entry.roundIdForSrc, + entry.roundIdForDest + ); + + _ensureCanExchangeAtRound( + sourceCurrencyKey, + sourceAmount, + destinationCurrencyKey, + entry.roundIdForSrc, + entry.roundIdForDest + ); + + // SIP-65: Decentralized Circuit Breaker + // mutative call to suspend system if the rate is invalid + if ( + _suspendIfRateInvalid(sourceCurrencyKey, entry.sourceRate) || + _suspendIfRateInvalid(destinationCurrencyKey, entry.destinationRate) + ) { + return (0, 0, IVirtualSynth(0)); + } uint sourceAmountAfterSettlement = _settleAndCalcSourceAmountRemaining(sourceAmount, from, sourceCurrencyKey); @@ -453,28 +474,19 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { return (0, 0, IVirtualSynth(0)); } - uint exchangeFeeRate; - uint sourceRate; - uint destinationRate; - - // Note: `fee` is denominated in the destinationCurrencyKey. - (amountReceived, fee, exchangeFeeRate, sourceRate, destinationRate) = _getAmountsForExchangeMinusFees( - sourceAmountAfterSettlement, + entry.exchangeFeeRate = _feeRateForExchangeAtRounds( sourceCurrencyKey, - destinationCurrencyKey + destinationCurrencyKey, + entry.roundIdForSrc, + entry.roundIdForDest ); - // SIP-65: Decentralized Circuit Breaker - if ( - _suspendIfRateInvalid(sourceCurrencyKey, sourceRate) || - _suspendIfRateInvalid(destinationCurrencyKey, destinationRate) - ) { - return (0, 0, IVirtualSynth(0)); - } + amountReceived = _deductFeesFromAmount(entry.destinationAmount, entry.exchangeFeeRate); + // Note: `fee` is denominated in the destinationCurrencyKey. + fee = entry.destinationAmount.sub(amountReceived); // Note: We don't need to check their balance as the _convert() below will do a safe subtraction which requires // the subtraction to not overflow, which would happen if their balance is not sufficient. - vSynth = _convert( sourceCurrencyKey, from, @@ -508,7 +520,10 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { // Nothing changes as far as issuance data goes because the total value in the system hasn't changed. // But we will update the debt snapshot in case exchange rates have fluctuated since the last exchange // in these currencies - _updateSNXIssuedDebtOnExchange([sourceCurrencyKey, destinationCurrencyKey], [sourceRate, destinationRate]); + _updateSNXIssuedDebtOnExchange( + [sourceCurrencyKey, destinationCurrencyKey], + [entry.sourceRate, entry.destinationRate] + ); // Let the DApps know there was a Synth exchange ISynthetixInternal(address(synthetix())).emitSynthExchange( @@ -529,7 +544,7 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { sourceAmountAfterSettlement, destinationCurrencyKey, amountReceived, - exchangeFeeRate + entry.exchangeFeeRate ); } } @@ -611,7 +626,27 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { bytes32[] memory synthKeys = new bytes32[](2); synthKeys[0] = sourceCurrencyKey; synthKeys[1] = destinationCurrencyKey; - require(!exchangeRates().anyRateIsInvalid(synthKeys), "Src/dest rate invalid or not found"); + require(!exchangeRates().anyRateIsInvalid(synthKeys), "src/dest rate stale or flagged"); + } + + function _ensureCanExchangeAtRound( + bytes32 sourceCurrencyKey, + uint sourceAmount, + bytes32 destinationCurrencyKey, + uint roundIdForSrc, + uint roundIdForDest + ) internal view { + require(sourceCurrencyKey != destinationCurrencyKey, "Can't be same synth"); + require(sourceAmount > 0, "Zero amount"); + + bytes32[] memory synthKeys = new bytes32[](2); + synthKeys[0] = sourceCurrencyKey; + synthKeys[1] = destinationCurrencyKey; + + uint[] memory roundIds = new uint[](2); + roundIds[0] = roundIdForSrc; + roundIds[1] = roundIdForDest; + require(!exchangeRates().anyRateIsInvalidAtRound(synthKeys, roundIds), "src/dest rate stale or flagged"); } function _isSynthRateInvalid(bytes32 currencyKey, uint currentRate) internal view returns (bool) { @@ -626,7 +661,7 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { } // if no last exchange for this synth, then we need to look up last 3 rates (+1 for current rate) - (uint[] memory rates, ) = exchangeRates().ratesAndUpdatedTimeForCurrencyLastNRounds(currencyKey, 4); + (uint[] memory rates, ) = exchangeRates().ratesAndUpdatedTimeForCurrencyLastNRounds(currencyKey, 4, 0); // start at index 1 to ignore current rate for (uint i = 1; i < rates.length; i++) { @@ -668,7 +703,7 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { { require(maxSecsLeftInWaitingPeriod(from, currencyKey) == 0, "Cannot settle during waiting period"); - (uint reclaimAmount, uint rebateAmount, uint entries, ExchangeEntrySettlement[] memory settlements) = + (uint reclaimAmount, uint rebateAmount, uint entries, IExchanger.ExchangeEntrySettlement[] memory settlements) = _settlementOwing(from, currencyKey); if (reclaimAmount > rebateAmount) { @@ -737,39 +772,164 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { return timestamp.add(_waitingPeriodSecs).sub(now); } - function feeRateForExchange(bytes32 sourceCurrencyKey, bytes32 destinationCurrencyKey) + /* ========== Exchange Related Fees ========== */ + /// @notice public function to get the total fee rate for a given exchange + /// @param sourceCurrencyKey The source currency key + /// @param destinationCurrencyKey The destination currency key + /// @return The exchange fee rate + function feeRateForExchange(bytes32 sourceCurrencyKey, bytes32 destinationCurrencyKey) external view returns (uint) { + return _feeRateForExchange(sourceCurrencyKey, destinationCurrencyKey); + } + + /// @notice public function to get the dynamic fee rate for a given exchange + /// @param sourceCurrencyKey The source currency key + /// @param destinationCurrencyKey The destination currency key + /// @return The exchange dynamic fee rate + function dynamicFeeRateForExchange(bytes32 sourceCurrencyKey, bytes32 destinationCurrencyKey) external view - returns (uint exchangeFeeRate) + returns (uint) { - exchangeFeeRate = _feeRateForExchange(sourceCurrencyKey, destinationCurrencyKey); + return _dynamicFeeRateForExchange(sourceCurrencyKey, destinationCurrencyKey); } + /// @notice Calculate the exchange fee for a given source and destination currency key + /// @param sourceCurrencyKey The source currency key + /// @param destinationCurrencyKey The destination currency key + /// @return The exchange fee rate + /// @return The exchange dynamic fee rate function _feeRateForExchange(bytes32 sourceCurrencyKey, bytes32 destinationCurrencyKey) internal view returns (uint) { // Get the exchange fee rate as per destination currencyKey uint baseRate = getExchangeFeeRate(destinationCurrencyKey); - return _calculateFeeRateFromExchangeSynths(baseRate, sourceCurrencyKey, destinationCurrencyKey); + uint feeRate = baseRate.add(_dynamicFeeRateForExchange(sourceCurrencyKey, destinationCurrencyKey)); + // cap fee rate to 100% to prevent negative amounts + return feeRate > SafeDecimalMath.unit() ? SafeDecimalMath.unit() : feeRate; } - function _calculateFeeRateFromExchangeSynths( - uint exchangeFeeRate, + /// @notice Calculate the exchange fee for a given source and destination currency key + /// @param sourceCurrencyKey The source currency key + /// @param destinationCurrencyKey The destination currency key + /// @param roundIdForSrc The round id of the source currency. + /// @param roundIdForDest The round id of the target currency. + /// @return The exchange fee rate + /// @return The exchange dynamic fee rate + function _feeRateForExchangeAtRounds( bytes32 sourceCurrencyKey, - bytes32 destinationCurrencyKey + bytes32 destinationCurrencyKey, + uint roundIdForSrc, + uint roundIdForDest + ) internal view returns (uint) { + // Get the exchange fee rate as per destination currencyKey + uint baseRate = getExchangeFeeRate(destinationCurrencyKey); + uint dynamicFee = + _dynamicFeeRateForExchangeAtRounds(sourceCurrencyKey, destinationCurrencyKey, roundIdForSrc, roundIdForDest); + uint feeRate = baseRate.add(dynamicFee); + // cap fee rate to 100% to prevent negative amounts + return feeRate > SafeDecimalMath.unit() ? SafeDecimalMath.unit() : feeRate; + } + + function _dynamicFeeRateForExchange(bytes32 sourceCurrencyKey, bytes32 destinationCurrencyKey) + internal + view + returns (uint) + { + DynamicFeeConfig memory config = getExchangeDynamicFeeConfig(); + uint dynamicFee = _dynamicFeeRateForCurrency(destinationCurrencyKey, config); + dynamicFee = dynamicFee.add(_dynamicFeeRateForCurrency(sourceCurrencyKey, config)); + // cap to maxFee + return dynamicFee > config.maxFee ? config.maxFee : dynamicFee; + } + + function _dynamicFeeRateForExchangeAtRounds( + bytes32 sourceCurrencyKey, + bytes32 destinationCurrencyKey, + uint roundIdForSrc, + uint roundIdForDest + ) internal view returns (uint) { + DynamicFeeConfig memory config = getExchangeDynamicFeeConfig(); + uint dynamicFee = _dynamicFeeRateForCurrencyRound(destinationCurrencyKey, roundIdForDest, config); + dynamicFee = dynamicFee.add(_dynamicFeeRateForCurrencyRound(sourceCurrencyKey, roundIdForSrc, config)); + // cap to maxFee + return dynamicFee > config.maxFee ? config.maxFee : dynamicFee; + } + + /// @notice Get dynamic dynamicFee for a given currency key (SIP-184) + /// @param currencyKey The given currency key + /// @param config dynamic fee calculation configuration params + /// @return The dyanmic dynamicFee + function _dynamicFeeRateForCurrency(bytes32 currencyKey, DynamicFeeConfig memory config) internal view returns (uint) { + // no dynamic dynamicFee for sUSD or too few rounds + if (currencyKey == sUSD || config.rounds <= 1) { + return 0; + } + uint roundId = exchangeRates().getCurrentRoundId(currencyKey); + return _dynamicFeeRateForCurrencyRound(currencyKey, roundId, config); + } + + /// @notice Get dynamicFee for a given currency key (SIP-184) + /// @param currencyKey The given currency key + /// @param roundId The round id + /// @param config dynamic fee calculation configuration params + /// @return The dyanmic dynamicFee + function _dynamicFeeRateForCurrencyRound( + bytes32 currencyKey, + uint roundId, + DynamicFeeConfig memory config + ) internal view returns (uint) { + // no dynamic dynamicFee for sUSD or too few rounds + if (currencyKey == sUSD || config.rounds <= 1) { + return 0; + } + uint[] memory prices; + (prices, ) = exchangeRates().ratesAndUpdatedTimeForCurrencyLastNRounds(currencyKey, config.rounds, roundId); + return _dynamicFeeCalculation(prices, config.threshold, config.weightDecay); + } + + /// @notice Calculate dynamic fee according to SIP-184 + /// @param prices A list of prices from the current round to the previous rounds + /// @param threshold A threshold to clip the price deviation ratop + /// @param weightDecay A weight decay constant + /// @return uint dynamic fee rate as decimal + function _dynamicFeeCalculation( + uint[] memory prices, + uint threshold, + uint weightDecay ) internal pure returns (uint) { - if (sourceCurrencyKey == sUSD || destinationCurrencyKey == sUSD) { - return exchangeFeeRate; + // don't underflow + if (prices.length == 0) { + return 0; } - // Is this a swing trade? long to short or short to long skipping sUSD. - if ( - (sourceCurrencyKey[0] == 0x73 && destinationCurrencyKey[0] == 0x69) || - (sourceCurrencyKey[0] == 0x69 && destinationCurrencyKey[0] == 0x73) - ) { - // Double the exchange fee - return exchangeFeeRate.mul(2); + uint dynamicFee = 0; // start with 0 + // go backwards in price array + for (uint i = prices.length - 1; i > 0; i--) { + // apply decay from previous round (will be 0 for first round) + dynamicFee = dynamicFee.multiplyDecimal(weightDecay); + // calculate price deviation + uint deviation = _thresholdedAbsDeviationRatio(prices[i - 1], prices[i], threshold); + // add to total fee + dynamicFee = dynamicFee.add(deviation); } + return dynamicFee; + } - return exchangeFeeRate; + /// absolute price deviation ratio used by dynamic fee calculation + /// deviationRatio = (abs(current - previous) / previous) - threshold + /// if negative, zero is returned + function _thresholdedAbsDeviationRatio( + uint price, + uint previousPrice, + uint threshold + ) public pure returns (uint) { + if (previousPrice == 0) { + return 0; // don't divide by zero + } + // abs difference between prices + uint absDelta = price > previousPrice ? price - previousPrice : previousPrice - price; + // relative to previous price + uint deviationRatio = absDelta.divideDecimal(previousPrice); + // only the positive difference from threshold + return deviationRatio > threshold ? deviationRatio - threshold : 0; } function getAmountsForExchange( @@ -785,7 +945,7 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { uint exchangeFeeRate ) { - (amountReceived, fee, exchangeFeeRate, , ) = _getAmountsForExchangeMinusFees( + (amountReceived, fee, exchangeFeeRate) = _getAmountsForExchangeMinusFees( sourceAmount, sourceCurrencyKey, destinationCurrencyKey @@ -802,18 +962,24 @@ contract Exchanger is Owned, MixinSystemSettings, IExchanger { returns ( uint amountReceived, uint fee, - uint exchangeFeeRate, - uint sourceRate, - uint destinationRate + uint exchangeFeeRate ) { + exchangeFeeRate = _feeRateForExchange(sourceCurrencyKey, destinationCurrencyKey); + uint destinationAmount; - (destinationAmount, sourceRate, destinationRate) = exchangeRates().effectiveValueAndRates( + uint destinationRate; + (destinationAmount, , destinationRate) = exchangeRates().effectiveValueAndRates( sourceCurrencyKey, sourceAmount, destinationCurrencyKey ); - exchangeFeeRate = _feeRateForExchange(sourceCurrencyKey, destinationCurrencyKey); + + // Return when invalid rate + if (destinationRate == 0) { + return (destinationAmount, 0, 0); + } + amountReceived = _deductFeesFromAmount(destinationAmount, exchangeFeeRate); fee = destinationAmount.sub(amountReceived); } diff --git a/contracts/ExchangerWithFeeRecAlternatives.sol b/contracts/ExchangerWithFeeRecAlternatives.sol index b6e767a958..7c6d871cf1 100644 --- a/contracts/ExchangerWithFeeRecAlternatives.sol +++ b/contracts/ExchangerWithFeeRecAlternatives.sol @@ -279,7 +279,7 @@ contract ExchangerWithFeeRecAlternatives is MinimalProxyFactory, Exchanger { baseRate = getExchangeFeeRate(destinationCurrencyKey); } - return _calculateFeeRateFromExchangeSynths(baseRate, sourceCurrencyKey, destinationCurrencyKey); + return baseRate.add(_dynamicFeeRateForExchange(sourceCurrencyKey, destinationCurrencyKey)); } function _getAmountsForAtomicExchangeMinusFees( diff --git a/contracts/MixinSystemSettings.sol b/contracts/MixinSystemSettings.sol index 7e59eec559..0facfd1794 100644 --- a/contracts/MixinSystemSettings.sol +++ b/contracts/MixinSystemSettings.sol @@ -19,7 +19,13 @@ contract MixinSystemSettings is MixinResolver { bytes32 internal constant SETTING_LIQUIDATION_RATIO = "liquidationRatio"; bytes32 internal constant SETTING_LIQUIDATION_PENALTY = "liquidationPenalty"; bytes32 internal constant SETTING_RATE_STALE_PERIOD = "rateStalePeriod"; + /* ========== Exchange Fees Related ========== */ bytes32 internal constant SETTING_EXCHANGE_FEE_RATE = "exchangeFeeRate"; + bytes32 internal constant SETTING_EXCHANGE_DYNAMIC_FEE_THRESHOLD = "exchangeDynamicFeeThreshold"; + bytes32 internal constant SETTING_EXCHANGE_DYNAMIC_FEE_WEIGHT_DECAY = "exchangeDynamicFeeWeightDecay"; + bytes32 internal constant SETTING_EXCHANGE_DYNAMIC_FEE_ROUNDS = "exchangeDynamicFeeRounds"; + bytes32 internal constant SETTING_EXCHANGE_MAX_DYNAMIC_FEE = "exchangeMaxDynamicFee"; + /* ========== End Exchange Fees Related ========== */ bytes32 internal constant SETTING_MINIMUM_STAKE_TIME = "minimumStakeTime"; bytes32 internal constant SETTING_AGGREGATOR_WARNING_FLAGS = "aggregatorWarningFlags"; bytes32 internal constant SETTING_TRADING_REWARDS_ENABLED = "tradingRewardsEnabled"; @@ -49,6 +55,13 @@ contract MixinSystemSettings is MixinResolver { enum CrossDomainMessageGasLimits {Deposit, Escrow, Reward, Withdrawal, Relay} + struct DynamicFeeConfig { + uint threshold; + uint weightDecay; + uint rounds; + uint maxFee; + } + constructor(address _resolver) internal MixinResolver(_resolver) {} function resolverAddressesRequired() public view returns (bytes32[] memory addresses) { @@ -123,6 +136,7 @@ contract MixinSystemSettings is MixinResolver { return flexibleStorage().getUIntValue(SETTING_CONTRACT_NAME, SETTING_RATE_STALE_PERIOD); } + /* ========== Exchange Related Fees ========== */ function getExchangeFeeRate(bytes32 currencyKey) internal view returns (uint) { return flexibleStorage().getUIntValue( @@ -131,6 +145,20 @@ contract MixinSystemSettings is MixinResolver { ); } + /// @notice Get exchange dynamic fee related keys + /// @return threshold, weight decay, rounds, and max fee + function getExchangeDynamicFeeConfig() internal view returns (DynamicFeeConfig memory) { + bytes32[] memory keys = new bytes32[](4); + keys[0] = SETTING_EXCHANGE_DYNAMIC_FEE_THRESHOLD; + keys[1] = SETTING_EXCHANGE_DYNAMIC_FEE_WEIGHT_DECAY; + keys[2] = SETTING_EXCHANGE_DYNAMIC_FEE_ROUNDS; + keys[3] = SETTING_EXCHANGE_MAX_DYNAMIC_FEE; + uint[] memory values = flexibleStorage().getUIntValues(SETTING_CONTRACT_NAME, keys); + return DynamicFeeConfig({threshold: values[0], weightDecay: values[1], rounds: values[2], maxFee: values[3]}); + } + + /* ========== End Exchange Related Fees ========== */ + function getMinimumStakeTime() internal view returns (uint) { return flexibleStorage().getUIntValue(SETTING_CONTRACT_NAME, SETTING_MINIMUM_STAKE_TIME); } diff --git a/contracts/SystemSettings.sol b/contracts/SystemSettings.sol index ac754726a4..cfa37d9deb 100644 --- a/contracts/SystemSettings.sol +++ b/contracts/SystemSettings.sol @@ -2,7 +2,6 @@ pragma solidity ^0.5.16; // Inheritance import "./Owned.sol"; -import "./MixinResolver.sol"; import "./MixinSystemSettings.sol"; import "./interfaces/ISystemSettings.sol"; import "./SystemSettingsLib.sol"; @@ -84,10 +83,38 @@ contract SystemSettings is Owned, MixinSystemSettings, ISystemSettings { return getRateStalePeriod(); } + /* ========== Exchange Related Fees ========== */ function exchangeFeeRate(bytes32 currencyKey) external view returns (uint) { return getExchangeFeeRate(currencyKey); } + // SIP-184 Dynamic Fee + /// @notice Get the dynamic fee threshold + /// @return The dynamic fee threshold + function exchangeDynamicFeeThreshold() external view returns (uint) { + return getExchangeDynamicFeeConfig().threshold; + } + + /// @notice Get the dynamic fee weight decay per round + /// @return The dynamic fee weight decay per round + function exchangeDynamicFeeWeightDecay() external view returns (uint) { + return getExchangeDynamicFeeConfig().weightDecay; + } + + /// @notice Get the dynamic fee total rounds for calculation + /// @return The dynamic fee total rounds for calculation + function exchangeDynamicFeeRounds() external view returns (uint) { + return getExchangeDynamicFeeConfig().rounds; + } + + /// @notice Get the max dynamic fee + /// @return The max dynamic fee + function exchangeMaxDynamicFee() external view returns (uint) { + return getExchangeDynamicFeeConfig().maxFee; + } + + /* ========== End Exchange Related Fees ========== */ + function minimumStakeTime() external view returns (uint) { return getMinimumStakeTime(); } @@ -206,9 +233,9 @@ contract SystemSettings is Owned, MixinSystemSettings, ISystemSettings { emit CrossDomainMessageGasLimitChanged(_gasLimitType, _crossDomainMessageGasLimit); } - function setIssuanceRatio(uint issuanceRatio) external onlyOwner { - flexibleStorage().setIssuanceRatio(SETTING_ISSUANCE_RATIO, issuanceRatio); - emit IssuanceRatioUpdated(issuanceRatio); + function setIssuanceRatio(uint ratio) external onlyOwner { + flexibleStorage().setIssuanceRatio(SETTING_ISSUANCE_RATIO, ratio); + emit IssuanceRatioUpdated(ratio); } function setTradingRewardsEnabled(bool _tradingRewardsEnabled) external onlyOwner { @@ -235,8 +262,8 @@ contract SystemSettings is Owned, MixinSystemSettings, ISystemSettings { } function setTargetThreshold(uint percent) external onlyOwner { - uint targetThreshold = flexibleStorage().setTargetThreshold(SETTING_TARGET_THRESHOLD, percent); - emit TargetThresholdUpdated(targetThreshold); + uint threshold = flexibleStorage().setTargetThreshold(SETTING_TARGET_THRESHOLD, percent); + emit TargetThresholdUpdated(threshold); } function setLiquidationDelay(uint time) external onlyOwner { @@ -266,6 +293,7 @@ contract SystemSettings is Owned, MixinSystemSettings, ISystemSettings { emit RateStalePeriodUpdated(period); } + /* ========== Exchange Fees Related ========== */ function setExchangeFeeRateForSynths(bytes32[] calldata synthKeys, uint256[] calldata exchangeFeeRates) external onlyOwner @@ -276,6 +304,48 @@ contract SystemSettings is Owned, MixinSystemSettings, ISystemSettings { } } + /// @notice Set exchange dynamic fee threshold constant in decimal ratio + /// @param threshold The exchange dynamic fee threshold + /// @return uint threshold constant + function setExchangeDynamicFeeThreshold(uint threshold) external onlyOwner { + require(threshold != 0, "Threshold cannot be 0"); + + flexibleStorage().setUIntValue(SETTING_CONTRACT_NAME, SETTING_EXCHANGE_DYNAMIC_FEE_THRESHOLD, threshold); + + emit ExchangeDynamicFeeThresholdUpdated(threshold); + } + + /// @notice Set exchange dynamic fee weight decay constant + /// @param weightDecay The exchange dynamic fee weight decay + /// @return uint weight decay constant + function setExchangeDynamicFeeWeightDecay(uint weightDecay) external onlyOwner { + require(weightDecay != 0, "Weight decay cannot be 0"); + + flexibleStorage().setUIntValue(SETTING_CONTRACT_NAME, SETTING_EXCHANGE_DYNAMIC_FEE_WEIGHT_DECAY, weightDecay); + + emit ExchangeDynamicFeeWeightDecayUpdated(weightDecay); + } + + /// @notice Set exchange dynamic fee last N rounds with minimum 2 rounds + /// @param rounds The exchange dynamic fee last N rounds + /// @return uint dynamic fee last N rounds + function setExchangeDynamicFeeRounds(uint rounds) external onlyOwner { + flexibleStorage().setUIntValue(SETTING_CONTRACT_NAME, SETTING_EXCHANGE_DYNAMIC_FEE_ROUNDS, rounds); + + emit ExchangeDynamicFeeRoundsUpdated(rounds); + } + + /// @notice Set max exchange dynamic fee + /// @param maxFee The max exchange dynamic fee + /// @return uint dynamic fee last N rounds + function setExchangeMaxDynamicFee(uint maxFee) external onlyOwner { + require(maxFee != 0, "Max dynamic fee cannot be 0"); + + flexibleStorage().setUIntValue(SETTING_CONTRACT_NAME, SETTING_EXCHANGE_MAX_DYNAMIC_FEE, maxFee); + + emit ExchangeMaxDynamicFeeUpdated(maxFee); + } + function setMinimumStakeTime(uint _seconds) external onlyOwner { flexibleStorage().setMinimumStakeTime(SETTING_MINIMUM_STAKE_TIME, _seconds); emit MinimumStakeTimeUpdated(_seconds); @@ -400,7 +470,13 @@ contract SystemSettings is Owned, MixinSystemSettings, ISystemSettings { event LiquidationRatioUpdated(uint newRatio); event LiquidationPenaltyUpdated(uint newPenalty); event RateStalePeriodUpdated(uint rateStalePeriod); + /* ========== Exchange Fees Related ========== */ event ExchangeFeeUpdated(bytes32 synthKey, uint newExchangeFeeRate); + event ExchangeDynamicFeeThresholdUpdated(uint dynamicFeeThreshold); + event ExchangeDynamicFeeWeightDecayUpdated(uint dynamicFeeWeightDecay); + event ExchangeDynamicFeeRoundsUpdated(uint dynamicFeeRounds); + event ExchangeMaxDynamicFeeUpdated(uint maxDynamicFee); + /* ========== End Exchange Fees Related ========== */ event MinimumStakeTimeUpdated(uint minimumStakeTime); event DebtSnapshotStaleTimeUpdated(uint debtSnapshotStaleTime); event AggregatorWarningFlagsUpdated(address flags); diff --git a/contracts/SystemSettingsLib.sol b/contracts/SystemSettingsLib.sol index 1a137ea43d..17d171aa92 100644 --- a/contracts/SystemSettingsLib.sol +++ b/contracts/SystemSettingsLib.sol @@ -76,10 +76,10 @@ library SystemSettingsLib { function setIssuanceRatio( IFlexibleStorage flexibleStorage, bytes32 settingName, - uint issuanceRatio + uint ratio ) external { - require(issuanceRatio <= MAX_ISSUANCE_RATIO, "New issuance ratio cannot exceed MAX_ISSUANCE_RATIO"); - flexibleStorage.setUIntValue(SETTINGS_CONTRACT_NAME, settingName, issuanceRatio); + require(ratio <= MAX_ISSUANCE_RATIO, "New issuance ratio cannot exceed MAX_ISSUANCE_RATIO"); + flexibleStorage.setUIntValue(SETTINGS_CONTRACT_NAME, settingName, ratio); } function setTradingRewardsEnabled( @@ -120,12 +120,12 @@ library SystemSettingsLib { function setTargetThreshold( IFlexibleStorage flexibleStorage, bytes32 settingName, - uint _percent - ) external returns (uint targetThreshold) { - require(_percent <= MAX_TARGET_THRESHOLD, "Threshold too high"); - targetThreshold = _percent.mul(SafeDecimalMath.unit()).div(100); + uint percent + ) external returns (uint threshold) { + require(percent <= MAX_TARGET_THRESHOLD, "Threshold too high"); + threshold = percent.mul(SafeDecimalMath.unit()).div(100); - flexibleStorage.setUIntValue(SETTINGS_CONTRACT_NAME, settingName, targetThreshold); + flexibleStorage.setUIntValue(SETTINGS_CONTRACT_NAME, settingName, threshold); } function setLiquidationDelay( diff --git a/contracts/interfaces/IExchangeRates.sol b/contracts/interfaces/IExchangeRates.sol index e55b1e87f0..43e4f7fbfa 100644 --- a/contracts/interfaces/IExchangeRates.sol +++ b/contracts/interfaces/IExchangeRates.sol @@ -15,6 +15,8 @@ interface IExchangeRates { function anyRateIsInvalid(bytes32[] calldata currencyKeys) external view returns (bool); + function anyRateIsInvalidAtRound(bytes32[] calldata currencyKeys, uint[] calldata roundIds) external view returns (bool); + function currenciesUsingAggregator(address aggregator) external view returns (bytes32[] memory); function effectiveValue( @@ -36,6 +38,21 @@ interface IExchangeRates { uint destinationRate ); + function effectiveValueAndRatesAtRound( + bytes32 sourceCurrencyKey, + uint sourceAmount, + bytes32 destinationCurrencyKey, + uint roundIdForSrc, + uint roundIdForDest + ) + external + view + returns ( + uint value, + uint sourceRate, + uint destinationRate + ); + function effectiveAtomicValueAndRates( bytes32 sourceCurrencyKey, uint sourceAmount, @@ -50,14 +67,6 @@ interface IExchangeRates { uint systemDestinationRate ); - function effectiveValueAtRound( - bytes32 sourceCurrencyKey, - uint sourceAmount, - bytes32 destinationCurrencyKey, - uint roundIdForSrc, - uint roundIdForDest - ) external view returns (uint value); - function getCurrentRoundId(bytes32 currencyKey) external view returns (uint); function getLastRoundIdBeforeElapsedSecs( @@ -85,10 +94,11 @@ interface IExchangeRates { function rateStalePeriod() external view returns (uint); - function ratesAndUpdatedTimeForCurrencyLastNRounds(bytes32 currencyKey, uint numRounds) - external - view - returns (uint[] memory rates, uint[] memory times); + function ratesAndUpdatedTimeForCurrencyLastNRounds( + bytes32 currencyKey, + uint numRounds, + uint roundId + ) external view returns (uint[] memory rates, uint[] memory times); function ratesAndInvalidForCurrencies(bytes32[] calldata currencyKeys) external diff --git a/contracts/interfaces/IExchanger.sol b/contracts/interfaces/IExchanger.sol index 1c5f6e31a8..13113b35a8 100644 --- a/contracts/interfaces/IExchanger.sol +++ b/contracts/interfaces/IExchanger.sol @@ -4,6 +4,27 @@ import "./IVirtualSynth.sol"; // https://docs.synthetix.io/contracts/source/interfaces/iexchanger interface IExchanger { + struct ExchangeEntrySettlement { + bytes32 src; + uint amount; + bytes32 dest; + uint reclaim; + uint rebate; + uint srcRoundIdAtPeriodEnd; + uint destRoundIdAtPeriodEnd; + uint timestamp; + } + + struct ExchangeEntry { + uint sourceRate; + uint destinationRate; + uint destinationAmount; + uint exchangeFeeRate; + uint exchangeDynamicFeeRate; + uint roundIdForSrc; + uint roundIdForDest; + } + // Views function calculateAmountAfterSettlement( address from, @@ -32,6 +53,11 @@ interface IExchanger { view returns (uint exchangeFeeRate); + function dynamicFeeRateForExchange(bytes32 sourceCurrencyKey, bytes32 destinationCurrencyKey) + external + view + returns (uint); + function getAmountsForExchange( uint sourceAmount, bytes32 sourceCurrencyKey, diff --git a/contracts/test-helpers/TestableDynamicFee.sol b/contracts/test-helpers/TestableDynamicFee.sol new file mode 100644 index 0000000000..121a10e323 --- /dev/null +++ b/contracts/test-helpers/TestableDynamicFee.sol @@ -0,0 +1,24 @@ +pragma solidity ^0.5.16; + +// Libraries +import "../Exchanger.sol"; + +contract TestableDynamicFee is Exchanger { + constructor(address _owner, address _resolver) public Exchanger(_owner, _resolver) {} + + function thresholdedAbsDeviationRatio( + uint price, + uint previousPrice, + uint threshold + ) external view returns (uint) { + return _thresholdedAbsDeviationRatio(price, previousPrice, threshold); + } + + function dynamicFeeCalculation( + uint[] calldata prices, + uint threshold, + uint weightDecay + ) external view returns (uint) { + return _dynamicFeeCalculation(prices, threshold, weightDecay); + } +} diff --git a/index.js b/index.js index 3d067eb942..a5b49cb98a 100644 --- a/index.js +++ b/index.js @@ -134,6 +134,10 @@ const defaults = { crypto: w3utils.toWei('0.01'), index: w3utils.toWei('0.01'), }, + EXCHANGE_DYNAMIC_FEE_THRESHOLD: w3utils.toWei('0.004'), // 40 bps + EXCHANGE_DYNAMIC_FEE_WEIGHT_DECAY: w3utils.toWei('0.9'), // dynamic fee weight decay for each round + EXCHANGE_DYNAMIC_FEE_ROUNDS: '10', // dynamic fee rounds + EXCHANGE_MAX_DYNAMIC_FEE: w3utils.toWei('1'), // cap max dynamic fee to 100% MINIMUM_STAKE_TIME: (3600 * 24).toString(), // 1 days DEBT_SNAPSHOT_STALE_TIME: (43800).toString(), // 12 hour heartbeat + 10 minutes mining time AGGREGATOR_WARNING_FLAGS: { diff --git a/package.json b/package.json index b92a7a3ee8..3af27b7fea 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "pack": "webpack --mode production", "fork": "node --max-old-space-size=4096 ./node_modules/.bin/hardhat node", "fork:mainnet": "node --max-old-space-size=4096 ./node_modules/.bin/hardhat node --target-network mainnet", - "test": "hardhat test", + "test": "node --max-old-space-size=4096 ./node_modules/.bin/hardhat test", "describe": "hardhat describe", "test:deployments": "mocha test/deployments -- --timeout 60000", "test:etherscan": "node test/etherscan", diff --git a/publish/deployed/mainnet/params.json b/publish/deployed/mainnet/params.json index 54a6aab3b2..55615d3800 100644 --- a/publish/deployed/mainnet/params.json +++ b/publish/deployed/mainnet/params.json @@ -1,4 +1,8 @@ [ + { + "name": "EXCHANGE_DYNAMIC_FEE_ROUNDS", + "value": "0" + }, { "name": "DEX_PRICE_AGGREGATOR", "value": "0xf120F029Ac143633d1942e48aE2Dfa2036C5786c" diff --git a/publish/releases.json b/publish/releases.json index aab538b740..de3d48d7fa 100644 --- a/publish/releases.json +++ b/publish/releases.json @@ -402,6 +402,11 @@ ], "released": "both" }, + { + "sip": 184, + "layer": "both", + "sources": ["Exchanger", "ExchangeRates", "SystemSettings"] + }, { "sip": 187, "layer": "both", diff --git a/publish/src/commands/deploy/configure-system-settings.js b/publish/src/commands/deploy/configure-system-settings.js index 78efa75de7..819f54602d 100644 --- a/publish/src/commands/deploy/configure-system-settings.js +++ b/publish/src/commands/deploy/configure-system-settings.js @@ -368,6 +368,54 @@ module.exports = async ({ comment: 'Set the fee rate for burning sETH for ETH in the EtherWrapper (SIP-112)', }); + // SIP-184 Exchange Dynamic Fee Rate + const exchangeDynamicFeeThreshold = await getDeployParameter('EXCHANGE_DYNAMIC_FEE_THRESHOLD'); + await runStep({ + contract: 'SystemSettings', + target: SystemSettings, + read: 'exchangeDynamicFeeThreshold', + readTarget: previousSystemSettings, + expected: allowZeroOrUpdateIfNonZero(exchangeDynamicFeeThreshold), + write: 'setExchangeDynamicFeeThreshold', + writeArg: exchangeDynamicFeeThreshold, + comment: 'Set exchange dynamic fee threshold (SIP-184)', + }); + const exchangeDynamicFeeWeightDecay = await getDeployParameter( + 'EXCHANGE_DYNAMIC_FEE_WEIGHT_DECAY' + ); + await runStep({ + contract: 'SystemSettings', + target: SystemSettings, + read: 'exchangeDynamicFeeWeightDecay', + readTarget: previousSystemSettings, + expected: allowZeroOrUpdateIfNonZero(exchangeDynamicFeeWeightDecay), + write: 'setExchangeDynamicFeeWeightDecay', + writeArg: exchangeDynamicFeeWeightDecay, + comment: 'Set exchange dynamic fee weight decay (SIP-184)', + }); + const exchangeDynamicFeeRounds = await getDeployParameter('EXCHANGE_DYNAMIC_FEE_ROUNDS'); + await runStep({ + contract: 'SystemSettings', + target: SystemSettings, + read: 'exchangeDynamicFeeRounds', + readTarget: previousSystemSettings, + expected: allowZeroOrUpdateIfNonZero(exchangeDynamicFeeRounds), + write: 'setExchangeDynamicFeeRounds', + writeArg: exchangeDynamicFeeRounds, + comment: 'Set exchange dynamic fee rounds (SIP-184)', + }); + const exchangeMaxDynamicFee = await getDeployParameter('EXCHANGE_MAX_DYNAMIC_FEE'); + await runStep({ + contract: 'SystemSettings', + target: SystemSettings, + read: 'exchangeMaxDynamicFee', + readTarget: previousSystemSettings, + expected: allowZeroOrUpdateIfNonZero(exchangeMaxDynamicFee), + write: 'setExchangeMaxDynamicFee', + writeArg: exchangeMaxDynamicFee, + comment: 'Set exchange max dynamic fee (SIP-184)', + }); + // SIP-120 Atomic swap settings if (SystemSettings.atomicMaxVolumePerBlock) { // TODO (SIP-120): finish configuring new atomic exchange system settings diff --git a/test/contracts/BaseSynthetix.js b/test/contracts/BaseSynthetix.js index c9a78e39ca..18959c8ae1 100644 --- a/test/contracts/BaseSynthetix.js +++ b/test/contracts/BaseSynthetix.js @@ -820,6 +820,9 @@ contract('BaseSynthetix', async accounts => { }); it("should lock newly received synthetix if the user's collaterisation is too high", async () => { + // Disable Dynamic fee so that we can neglect it. + await systemSettings.setExchangeDynamicFeeRounds('0', { from: owner }); + // Set sEUR for purposes of this test await updateAggregatorRates(exchangeRates, [sEUR], [toUnit('0.75')]); await debtCache.takeDebtSnapshot(); @@ -860,6 +863,9 @@ contract('BaseSynthetix', async accounts => { }); it('should unlock synthetix when collaterisation ratio changes', async () => { + // Disable Dynamic fee so that we can neglect it. + await systemSettings.setExchangeDynamicFeeRounds('0', { from: owner }); + // prevent circuit breaker from firing by upping the threshold to factor 5 await systemSettings.setPriceDeviationThresholdFactor(toUnit('5'), { from: owner }); diff --git a/test/contracts/CollateralShort.js b/test/contracts/CollateralShort.js index 3e9afb874a..eb2c937cfa 100644 --- a/test/contracts/CollateralShort.js +++ b/test/contracts/CollateralShort.js @@ -59,6 +59,8 @@ contract('CollateralShort', async accounts => { }; const updateRatesWithDefaults = async () => { + const sBTC = toBytes32('sBTC'); + await updateAggregatorRates(exchangeRates, [sETH, sBTC], [100, 10000].map(toUnit)); }; diff --git a/test/contracts/DebtCache.js b/test/contracts/DebtCache.js index 18949aeb36..9a319d3c63 100644 --- a/test/contracts/DebtCache.js +++ b/test/contracts/DebtCache.js @@ -293,7 +293,6 @@ contract('DebtCache', async accounts => { ['0.5', '1.25', '10', '200', '200', '200'].map(toUnit) ); - // set a 0.3% default exchange fee rate const exchangeFeeRate = toUnit('0.003'); await setExchangeFeeRateForSynths({ owner, @@ -949,6 +948,9 @@ contract('DebtCache', async accounts => { }); it('exchanging between synths updates sUSD debt total due to fees', async () => { + // Disable Dynamic fee so that we can neglect it. + await systemSettings.setExchangeDynamicFeeRounds('0', { from: owner }); + await systemSettings.setExchangeFeeRateForSynths( [sAUD, sUSD, sEUR], [toUnit(0.1), toUnit(0.1), toUnit(0.1)], @@ -971,9 +973,12 @@ contract('DebtCache', async accounts => { }); it('exchanging between synths updates debt properly when prices have changed', async () => { + // Zero exchange fees so that we can neglect them. await systemSettings.setExchangeFeeRateForSynths([sAUD, sUSD], [toUnit(0), toUnit(0)], { from: owner, }); + // Disable Dynamic fee so that we can neglect it. + await systemSettings.setExchangeDynamicFeeRounds('0', { from: owner }); await sEURContract.issue(account1, toUnit(20)); await debtCache.takeDebtSnapshot(); @@ -995,9 +1000,13 @@ contract('DebtCache', async accounts => { }); it('settlement updates debt totals', async () => { + // Zero exchange fees so that we can neglect them. await systemSettings.setExchangeFeeRateForSynths([sAUD, sEUR], [toUnit(0), toUnit(0)], { from: owner, }); + // Disable Dynamic fee so that we can neglect it. + await systemSettings.setExchangeDynamicFeeRounds('0', { from: owner }); + await sAUDContract.issue(account1, toUnit(100)); await debtCache.takeDebtSnapshot(); diff --git a/test/contracts/DynamicFee.js b/test/contracts/DynamicFee.js new file mode 100644 index 0000000000..e10799661a --- /dev/null +++ b/test/contracts/DynamicFee.js @@ -0,0 +1,149 @@ +const { contract, artifacts } = require('hardhat'); +const { assert } = require('./common'); +const { toUnit, toBN } = require('../utils')(); +const SafeDecimalMath = artifacts.require('SafeDecimalMath'); +const TestableDynamicFee = artifacts.require('TestableDynamicFee'); + +contract('DynamicFee', accounts => { + const [, owner, account1] = accounts; + + let testableDynamicFee; + + const threshold = toUnit('0.004'); + const weightDecay = toUnit('0.9'); + + before(async () => { + const safeDecimalMath = await SafeDecimalMath.new(); + TestableDynamicFee.link(safeDecimalMath); + const addressResolver = account1; // is not important for these tests + testableDynamicFee = await TestableDynamicFee.new(owner, addressResolver); + }); + + it('Can get price differential', async () => { + const priceDiff1 = await testableDynamicFee.thresholdedAbsDeviationRatio( + toUnit('8'), + toUnit('10'), + threshold + ); + assert.bnEqual(priceDiff1, '196000000000000000'); + const priceDiff2 = await testableDynamicFee.thresholdedAbsDeviationRatio( + toUnit('12'), + toUnit('10'), + threshold + ); + assert.bnEqual(priceDiff2, '196000000000000000'); + assert.bnEqual(priceDiff1, priceDiff2); + }); + + it('Fee is similar to dynamic-fee-calc.csv rounds 22-11, all below threshold', async () => { + const prices = [ + toUnit('49535.05178912'), + toUnit('49714.05205647'), + toUnit('49691.8024553899'), + toUnit('49714.05205647'), + toUnit('49722.83886705'), + toUnit('49838.87627216'), + toUnit('49842.74988613'), + toUnit('49933.34034209'), + toUnit('49871.92313713'), + toUnit('49981'), + toUnit('49960.65493467'), + toUnit('49994'), + ]; + const dynamicFee = await testableDynamicFee.dynamicFeeCalculation( + prices, + threshold, + weightDecay + ); + assert.bnEqual(dynamicFee, '0'); + }); + + it('Fee is similar to dynamic-fee-calc.csv rounds 23-14, last one above threshold', async () => { + const prices = [ + toUnit('49234.65005734'), + toUnit('49535.05178912'), + toUnit('49714.05205647'), + toUnit('49691.8024553899'), + toUnit('49714.05205647'), + toUnit('49722.83886705'), + toUnit('49838.87627216'), + toUnit('49842.74988613'), + toUnit('49933.34034209'), + toUnit('49871.92313713'), + toUnit('49981'), + ]; + const dynamicFee = await testableDynamicFee.dynamicFeeCalculation( + prices, + threshold, + weightDecay + ); + assert.bnClose(dynamicFee, toUnit(20.6442753020364).div(toBN(10000)), 1e4); + }); + + it('Fee is similar to dynamic-fee-calc.csv rounds 32-22, first one above threshold', async () => { + const prices = [ + toUnit('49198.77'), + toUnit('49143.5399999999'), + toUnit('49096.77'), + toUnit('49131.10261767'), + toUnit('49088.63670793'), + toUnit('49046.17079819'), + toUnit('49088.63670793'), + toUnit('49234.65005734'), + toUnit('49190.99117585'), + toUnit('49234.65005734'), + toUnit('49535.05178912'), + ]; + const dynamicFee = await testableDynamicFee.dynamicFeeCalculation( + prices, + threshold, + weightDecay + ); + assert.bnClose(dynamicFee, toUnit(7.99801523256557).div(toBN(10000)), 1e4); + }); + + it('Fee is similar to dynamic-fee-calc.csv rounds 72-63, 70% above threshold', async () => { + const prices = [ + toUnit('44661.70868763'), + toUnit('44672.6561639399'), + toUnit('45483.8961602099'), + toUnit('45586.5085919099'), + toUnit('45919.00562933'), + toUnit('46183.17440371'), + toUnit('46217.7336139799'), + toUnit('46463.74676537'), + toUnit('46675.18493538'), + toUnit('46948.76815888'), + toUnit('47222.35138239'), + toUnit('47382.88726893'), + ]; + const dynamicFee = await testableDynamicFee.dynamicFeeCalculation( + prices, + threshold, + weightDecay + ); + assert.bnClose(dynamicFee, toUnit(183.663338097394).div(toBN(10000)), 1e4); + }); + + it('Fee is similar to dynamic-fee-calc.csv rounds 67-58, 50% above threshold', async () => { + const prices = [ + toUnit('46183.17440371'), + toUnit('46217.7336139799'), + toUnit('46463.74676537'), + toUnit('46675.18493538'), + toUnit('46948.76815888'), + toUnit('47222.35138239'), + toUnit('47382.88726893'), + toUnit('47449.76309439'), + toUnit('47580.67384441'), + toUnit('47670.81054939'), + toUnit('47911.8471578599'), + ]; + const dynamicFee = await testableDynamicFee.dynamicFeeCalculation( + prices, + threshold, + weightDecay + ); + assert.bnClose(dynamicFee, toUnit(45.0272321178039).div(toBN(10000)), 1e4); + }); +}); diff --git a/test/contracts/ExchangeRates.js b/test/contracts/ExchangeRates.js index c5a78a4cfb..7ef19f9ac6 100644 --- a/test/contracts/ExchangeRates.js +++ b/test/contracts/ExchangeRates.js @@ -949,7 +949,7 @@ contract('Exchange Rates', async accounts => { }); it('ratesAndUpdatedTimeForCurrencyLastNRounds() shows first entry for sUSD', async () => { - assert.deepEqual(await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sUSD, '3'), [ + assert.deepEqual(await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sUSD, '3', '0'), [ [toUnit('1'), '0', '0'], [0, 0, 0], ]); @@ -957,7 +957,7 @@ contract('Exchange Rates', async accounts => { it('ratesAndUpdatedTimeForCurrencyLastNRounds() returns 0s for other currencies without updates', async () => { const fiveZeros = new Array(5).fill('0'); await setupAggregators([sJPY]); - assert.deepEqual(await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sJPY, '5'), [ + assert.deepEqual(await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sJPY, '5', '0'), [ fiveZeros, fiveZeros, ]); @@ -1012,7 +1012,7 @@ contract('Exchange Rates', async accounts => { it('then it returns zeros', async () => { const fiveZeros = new Array(5).fill('0'); assert.deepEqual( - await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sAUD, '5'), + await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sAUD, '5', '0'), [fiveZeros, fiveZeros] ); }); @@ -1020,7 +1020,7 @@ contract('Exchange Rates', async accounts => { describe('when invoked for an aggregated price', () => { it('then it returns the rates as expected', async () => { assert.deepEqual( - await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sJPY, '3'), + await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sJPY, '3', '0'), [ [toUnit('102'), toUnit('101'), toUnit('100')], ['1002', '1001', '1000'], @@ -1030,7 +1030,7 @@ contract('Exchange Rates', async accounts => { it('then it returns the rates as expected, even over the edge', async () => { assert.deepEqual( - await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sJPY, '5'), + await instance.ratesAndUpdatedTimeForCurrencyLastNRounds(sJPY, '5', '0'), [ [toUnit('102'), toUnit('101'), toUnit('100'), '0', '0'], ['1002', '1001', '1000', '0', '0'], @@ -1111,7 +1111,7 @@ contract('Exchange Rates', async accounts => { }); }); - describe('effectiveValueAtRound()', () => { + describe('effectiveValueAndRatesAtRound()', () => { describe('when both aggregated prices have been given three rates with current timestamps', () => { beforeEach(async () => { await setupAggregators([sBNB]); @@ -1126,43 +1126,61 @@ contract('Exchange Rates', async accounts => { }); it('accepts various changes to src roundId', async () => { assert.bnEqual( - await instance.effectiveValueAtRound(sJPY, toUnit('1'), sBNB, '1', '1'), + ( + await instance.effectiveValueAndRatesAtRound(sJPY, toUnit('1'), sBNB, '1', '1') + )[0], toUnit('0.1') ); assert.bnEqual( - await instance.effectiveValueAtRound(sJPY, toUnit('1'), sBNB, '2', '1'), + ( + await instance.effectiveValueAndRatesAtRound(sJPY, toUnit('1'), sBNB, '2', '1') + )[0], toUnit('0.2') ); assert.bnEqual( - await instance.effectiveValueAtRound(sJPY, toUnit('1'), sBNB, '3', '1'), + ( + await instance.effectiveValueAndRatesAtRound(sJPY, toUnit('1'), sBNB, '3', '1') + )[0], toUnit('0.3') ); }); it('accepts various changes to dest roundId', async () => { assert.bnEqual( - await instance.effectiveValueAtRound(sJPY, toUnit('1'), sBNB, '1', '1'), + ( + await instance.effectiveValueAndRatesAtRound(sJPY, toUnit('1'), sBNB, '1', '1') + )[0], toUnit('0.1') ); assert.bnEqual( - await instance.effectiveValueAtRound(sJPY, toUnit('1'), sBNB, '1', '2'), + ( + await instance.effectiveValueAndRatesAtRound(sJPY, toUnit('1'), sBNB, '1', '2') + )[0], toUnit('0.05') ); assert.bnEqual( - await instance.effectiveValueAtRound(sJPY, toUnit('1'), sBNB, '1', '3'), + ( + await instance.effectiveValueAndRatesAtRound(sJPY, toUnit('1'), sBNB, '1', '3') + )[0], toUnit('0.025') ); }); it('and combinations therein', async () => { assert.bnEqual( - await instance.effectiveValueAtRound(sJPY, toUnit('1'), sBNB, '2', '2'), + ( + await instance.effectiveValueAndRatesAtRound(sJPY, toUnit('1'), sBNB, '2', '2') + )[0], toUnit('0.1') ); assert.bnEqual( - await instance.effectiveValueAtRound(sJPY, toUnit('1'), sBNB, '3', '3'), + ( + await instance.effectiveValueAndRatesAtRound(sJPY, toUnit('1'), sBNB, '3', '3') + )[0], toUnit('0.075') ); assert.bnEqual( - await instance.effectiveValueAtRound(sJPY, toUnit('1'), sBNB, '3', '2'), + ( + await instance.effectiveValueAndRatesAtRound(sJPY, toUnit('1'), sBNB, '3', '2') + )[0], toUnit('0.15') ); }); diff --git a/test/contracts/Exchanger.spec.js b/test/contracts/Exchanger.spec.js index 5ca67e5e57..b86bf3a591 100644 --- a/test/contracts/Exchanger.spec.js +++ b/test/contracts/Exchanger.spec.js @@ -5,7 +5,14 @@ const { smockit } = require('@eth-optimism/smock'); const BN = require('bn.js'); const { assert, addSnapshotBeforeRestoreAfterEach } = require('./common'); -const { currentTime, fastForward, multiplyDecimal, divideDecimal, toUnit } = require('../utils')(); +const { + currentTime, + fastForward, + multiplyDecimal, + divideDecimal, + toUnit, + toBN, +} = require('../utils')(); const { setupAllContracts } = require('./setup'); @@ -24,7 +31,14 @@ const { const { toBytes32, - defaults: { WAITING_PERIOD_SECS, PRICE_DEVIATION_THRESHOLD_FACTOR, ATOMIC_MAX_VOLUME_PER_BLOCK }, + defaults: { + WAITING_PERIOD_SECS, + PRICE_DEVIATION_THRESHOLD_FACTOR, + ATOMIC_MAX_VOLUME_PER_BLOCK, + EXCHANGE_DYNAMIC_FEE_ROUNDS, + EXCHANGE_DYNAMIC_FEE_WEIGHT_DECAY, + EXCHANGE_DYNAMIC_FEE_THRESHOLD, + }, } = require('../..'); const bnCloseVariance = '30'; @@ -209,6 +223,7 @@ contract('Exchanger (spec tests)', async accounts => { new web3.utils.BN(1), new web3.utils.BN(2), ], + bnCloseVariance, }); }); @@ -560,6 +575,173 @@ contract('Exchanger (spec tests)', async accounts => { assert.bnEqual(amountReceived, effectiveValue.sub(tripleFee)); }); }); + + describe('dynamic fee when rates change', () => { + const threshold = toBN(EXCHANGE_DYNAMIC_FEE_THRESHOLD); + + it('initial fee is correct', async () => { + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sBTC), bipsCrypto); + assert.bnEqual(await exchanger.dynamicFeeRateForExchange(sUSD, sBTC), 0); + }); + + describe('fee is caluclated correctly when rates spike or drop', () => { + it('.3% spike is below threshold', async () => { + await updateRates([sETH], [toUnit(100.3)]); + // spike + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sETH), bipsCrypto); + assert.bnEqual(await exchanger.dynamicFeeRateForExchange(sUSD, sETH), 0); + // control + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sBTC), bipsCrypto); + assert.bnEqual(await exchanger.dynamicFeeRateForExchange(sBTC, sBTC), 0); + }); + + it('.3% drop is below threshold', async () => { + await updateRates([sETH], [toUnit(99.7)]); + // spike + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sETH), bipsCrypto); + assert.bnEqual(await exchanger.dynamicFeeRateForExchange(sUSD, sETH), 0); + // control + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sBTC), bipsCrypto); + assert.bnEqual(await exchanger.dynamicFeeRateForExchange(sBTC, sBTC), 0); + }); + + it('1% spike result in correct dynamic fee', async () => { + await updateRates([sETH], [toUnit(101)]); + // price diff ratio (1%)- threshold + const expectedDynamicFee = toUnit(0.01).sub(threshold); + assert.bnEqual( + await exchanger.feeRateForExchange(sUSD, sETH), + bipsCrypto.add(expectedDynamicFee) + ); + assert.bnEqual( + await exchanger.dynamicFeeRateForExchange(sUSD, sETH), + expectedDynamicFee + ); + // control + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sBTC), bipsCrypto); + }); + + it('1% drop result in correct dynamic fee', async () => { + await updateRates([sETH], [toUnit(99)]); + // price diff ratio (1%)- threshold + const expectedDynamicFee = toUnit(0.01).sub(threshold); + assert.bnEqual( + await exchanger.feeRateForExchange(sUSD, sETH), + bipsCrypto.add(expectedDynamicFee) + ); + assert.bnEqual( + await exchanger.dynamicFeeRateForExchange(sUSD, sETH), + expectedDynamicFee + ); + // control + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sBTC), bipsCrypto); + }); + + it('10% spike result in correct dynamic fee', async () => { + await updateRates([sETH], [toUnit(110)]); + // price diff ratio (10%)- threshold + const expectedDynamicFee = toUnit(0.1).sub(threshold); + assert.bnEqual( + await exchanger.feeRateForExchange(sUSD, sETH), + bipsCrypto.add(expectedDynamicFee) + ); + assert.bnEqual( + await exchanger.dynamicFeeRateForExchange(sUSD, sETH), + expectedDynamicFee + ); + // control + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sBTC), bipsCrypto); + }); + + it('10% drop result in correct dynamic fee', async () => { + await updateRates([sETH], [toUnit(90)]); + // price diff ratio (10%)- threshold + const expectedDynamicFee = toUnit(0.1).sub(threshold); + assert.bnEqual( + await exchanger.feeRateForExchange(sUSD, sETH), + bipsCrypto.add(expectedDynamicFee) + ); + assert.bnEqual( + await exchanger.dynamicFeeRateForExchange(sUSD, sETH), + expectedDynamicFee + ); + // control + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sBTC), bipsCrypto); + }); + + it('trading between two spiked rates is correctly calculated ', async () => { + await updateRates([sETH, sBTC], [toUnit(110), toUnit(5500)]); + // base fee + (price diff ratio (10%)- threshold) * 2 + const expectedDynamicFee = toUnit(0.1) + .sub(threshold) + .mul(toBN(2)); + assert.bnEqual( + await exchanger.feeRateForExchange(sBTC, sETH), + bipsCrypto.add(expectedDynamicFee) + ); + assert.bnEqual( + await exchanger.dynamicFeeRateForExchange(sBTC, sETH), + expectedDynamicFee + ); + // reverse direction is the same + assert.bnEqual( + await exchanger.feeRateForExchange(sETH, sBTC), + bipsCrypto.add(expectedDynamicFee) + ); + assert.bnEqual( + await exchanger.dynamicFeeRateForExchange(sETH, sBTC), + expectedDynamicFee + ); + }); + }); + + it('dynamic fee decays with time', async () => { + await updateRates([sETH], [toUnit(110)]); + // (price diff ratio (10%)- threshold) + let expectedDynamicFee = toUnit(0.1).sub(threshold); + assert.bnEqual( + await exchanger.feeRateForExchange(sUSD, sETH), + bipsCrypto.add(expectedDynamicFee) + ); + assert.bnEqual( + await exchanger.dynamicFeeRateForExchange(sUSD, sETH), + expectedDynamicFee + ); + + const decay = toBN(EXCHANGE_DYNAMIC_FEE_WEIGHT_DECAY); + + // next round + await updateRates([sETH], [toUnit(110)]); + expectedDynamicFee = multiplyDecimal(expectedDynamicFee, decay); + assert.bnEqual( + await exchanger.feeRateForExchange(sUSD, sETH), + bipsCrypto.add(expectedDynamicFee) + ); + assert.bnEqual( + await exchanger.dynamicFeeRateForExchange(sUSD, sETH), + expectedDynamicFee + ); + + // another round + await updateRates([sETH], [toUnit(110)]); + expectedDynamicFee = multiplyDecimal(expectedDynamicFee, decay); + assert.bnEqual( + await exchanger.feeRateForExchange(sUSD, sETH), + bipsCrypto.add(expectedDynamicFee) + ); + assert.bnEqual( + await exchanger.dynamicFeeRateForExchange(sUSD, sETH), + expectedDynamicFee + ); + + // EXCHANGE_DYNAMIC_FEE_ROUNDS after spike dynamic fee is 0 + for (let i = 0; i < EXCHANGE_DYNAMIC_FEE_ROUNDS - 3; i++) { + await updateRates([sETH], [toUnit(110)]); + } + assert.bnEqual(await exchanger.feeRateForExchange(sUSD, sETH), bipsCrypto); + assert.bnEqual(await exchanger.dynamicFeeRateForExchange(sUSD, sETH), 0); + }); + }); }); }); }; @@ -615,6 +797,8 @@ contract('Exchanger (spec tests)', async accounts => { describe(`when ${section} is suspended`, () => { beforeEach(async () => { await setStatus({ owner, systemStatus, section, suspend: true, synth }); + // Disable Dynamic Fee here as settlement is L1 and Dynamic fee is on L2 + await systemSettings.setExchangeDynamicFeeRounds('0', { from: owner }); }); it('then calling settle() reverts', async () => { await assert.revert( @@ -660,6 +844,8 @@ contract('Exchanger (spec tests)', async accounts => { beforeEach(async () => { // set sUSD:sEUR as 2:1, sUSD:sETH at 100:1, sUSD:sBTC at 9000:1 await updateRates([sEUR, sETH, sBTC], ['2', '100', '9000'].map(toUnit)); + // Disable Dynamic Fee by setting rounds to 0 + await systemSettings.setExchangeDynamicFeeRounds('0', { from: owner }); }); describe('and the exchange fee rate is 1% for easier human consumption', () => { beforeEach(async () => { @@ -1860,7 +2046,7 @@ contract('Exchanger (spec tests)', async accounts => { it(`attempting to ${type} from sUSD into sAUD reverts with dest stale`, async () => { await assert.revert( exchange({ from: sUSD, amount: amountIssued, to: sAUD }), - 'Src/dest rate invalid or not found' + 'src/dest rate stale or flagged' ); }); it('settling still works ', async () => { @@ -1883,7 +2069,7 @@ contract('Exchanger (spec tests)', async accounts => { it(`${type} back to sUSD fails as the source has no rate`, async () => { await assert.revert( exchange({ from: sAUD, amount: amountIssued, to: sUSD }), - 'Src/dest rate invalid or not found' + 'src/dest rate stale or flagged' ); }); }); @@ -2478,11 +2664,10 @@ contract('Exchanger (spec tests)', async accounts => { // CL aggregator with past price data const aggregator = await MockAggregator.new({ from: owner }); await exchangeRates.addAggregator(sETH, aggregator.address, { from: owner }); - // set prices with no valatility - await aggregator.setLatestAnswer(ethOnCL, (await currentTime()) - 20 * 60); - await aggregator.setLatestAnswer(ethOnCL, (await currentTime()) - 15 * 60); - await aggregator.setLatestAnswer(ethOnCL, (await currentTime()) - 10 * 60); - await aggregator.setLatestAnswer(ethOnCL, (await currentTime()) - 5 * 60); + // set prices with no volatility over the course of last 20 minutes + for (let i = 4; i > 0; i--) { + await aggregator.setLatestAnswer(ethOnCL, (await currentTime()) - i * 5 * 60); + } // DexPriceAggregator const dexPriceAggregator = await MockDexPriceAggregator.new(); @@ -3088,6 +3273,8 @@ contract('Exchanger (spec tests)', async accounts => { describe('settlement ignores deviations', () => { describe('when a user exchange 100 sUSD into sETH', () => { beforeEach(async () => { + // Disable Dynamic Fee in settlement by setting rounds to 0 + await systemSettings.setExchangeDynamicFeeRounds('0', { from: owner }); await synthetix.exchange(sUSD, toUnit('100'), sETH, { from: account1 }); }); describe('and the sETH rate moves up by a factor of 2 to 200', () => { @@ -3374,6 +3561,9 @@ contract('Exchanger (spec tests)', async accounts => { const newCryptoBIPS = toUnit('0.04'); beforeEach(async () => { + // Disable Dynamic Fee here as it's testing for the base exchange fee rate + await systemSettings.setExchangeDynamicFeeRounds('0', { from: owner }); + // Store multiple rates await systemSettings.setExchangeFeeRateForSynths( [sUSD, sAUD, sBTC, sETH], @@ -3485,7 +3675,6 @@ contract('Exchanger (spec tests)', async accounts => { await setupPriceAggregators(exchangeRates, owner, keys); await updateRates(keys, rates); - // set a 0.5% exchange fee rate (1/200) exchangeFeeRate = toUnit('0.005'); await setExchangeFeeRateForSynths({ owner, diff --git a/test/contracts/ExchangerWithFeeRecAlternatives.behaviors.js b/test/contracts/ExchangerWithFeeRecAlternatives.behaviors.js index fe91a49508..281b320fb4 100644 --- a/test/contracts/ExchangerWithFeeRecAlternatives.behaviors.js +++ b/test/contracts/ExchangerWithFeeRecAlternatives.behaviors.js @@ -21,7 +21,8 @@ module.exports = function({ accounts }) { }); before(async () => { - ExchangerWithFeeRecAlternatives.link(await artifacts.require('SafeDecimalMath').new()); + const safeDecimalMath = await artifacts.require('SafeDecimalMath').new(); + ExchangerWithFeeRecAlternatives.link(safeDecimalMath); }); beforeEach(async () => { @@ -109,6 +110,14 @@ module.exports = function({ accounts }) { cb(); }); }, + whenMockedWithExchangeRatesValidityAtRound: ({ valid = true }, cb) => { + describe(`when mocked with ${valid ? 'valid' : 'invalid'} exchange rates`, () => { + beforeEach(async () => { + this.mocks.ExchangeRates.smocked.anyRateIsInvalidAtRound.will.return.with(!valid); + }); + cb(); + }); + }, whenMockedWithNoPriorExchangesToSettle: cb => { describe(`when mocked with no prior exchanges to settle`, () => { beforeEach(async () => { @@ -134,6 +143,14 @@ module.exports = function({ accounts }) { cb(); }); }, + whenMockedWithUintsSystemSetting: ({ setting, value }, cb) => { + describe(`when SystemSetting.${setting} is mocked to ${value}`, () => { + beforeEach(async () => { + this.flexibleStorageMock.mockSystemSetting({ setting, value, type: 'uints' }); + }); + cb(); + }); + }, whenMockedWithSynthUintSystemSetting: ({ setting, synth, value }, cb) => { const settingForSynth = web3.utils.soliditySha3( { type: 'bytes32', value: toBytes32(setting) }, @@ -161,6 +178,16 @@ module.exports = function({ accounts }) { cb(); }); }, + whenMockedEffectiveRateAsEqualAtRound: cb => { + describe(`when mocked with exchange rates giving an effective value of 1:1`, () => { + beforeEach(async () => { + this.mocks.ExchangeRates.smocked.effectiveValueAndRatesAtRound.will.return.with( + (srcKey, amount, destKey) => [amount, (1e18).toString(), (1e18).toString()] + ); + }); + cb(); + }); + }, whenMockedLastNRates: cb => { describe(`when mocked 1e18 as last n rates`, () => { beforeEach(async () => { diff --git a/test/contracts/ExchangerWithFeeRecAlternatives.unit.js b/test/contracts/ExchangerWithFeeRecAlternatives.unit.js index 277a6651f2..9648db9160 100644 --- a/test/contracts/ExchangerWithFeeRecAlternatives.unit.js +++ b/test/contracts/ExchangerWithFeeRecAlternatives.unit.js @@ -76,38 +76,21 @@ contract('ExchangerWithFeeRecAlternatives (unit tests)', async accounts => { ); }); - describe('feeRateForAtomicExchange()', () => { - // Mimic settings not being configured - behaviors.whenMockedWithSynthUintSystemSetting( - { setting: 'exchangeFeeRate', synth: sETH, value: '0' }, - () => { - it('is set to 0', async () => { - assert.bnEqual(await this.instance.feeRateForAtomicExchange(sUSD, sETH), '0'); - }); - } - ); - - // With configured override value - behaviors.whenMockedWithSynthUintSystemSetting( - { setting: 'atomicExchangeFeeRate', synth: sETH, value: overrideFeeRate }, - () => { - it('is set to the configured atomic override value', async () => { - assert.bnEqual( - await this.instance.feeRateForAtomicExchange(sUSD, sETH), - overrideFeeRate - ); - }); - } - ); - - // With configured base and override values - behaviors.whenMockedWithSynthUintSystemSetting( - { setting: 'exchangeFeeRate', synth: sETH, value: baseFeeRate }, - () => { - it('is set to the configured base value', async () => { - assert.bnEqual(await this.instance.feeRateForAtomicExchange(sUSD, sETH), baseFeeRate); - }); + behaviors.whenMockedWithUintSystemSetting( + { setting: 'exchangeMaxDynamicFee', value: toUnit('1') }, + () => { + describe('feeRateForAtomicExchange()', () => { + // Mimic settings not being configured + behaviors.whenMockedWithSynthUintSystemSetting( + { setting: 'exchangeFeeRate', synth: sETH, value: '0' }, + () => { + it('is set to 0', async () => { + assert.bnEqual(await this.instance.feeRateForAtomicExchange(sUSD, sETH), '0'); + }); + } + ); + // With configured override value behaviors.whenMockedWithSynthUintSystemSetting( { setting: 'atomicExchangeFeeRate', synth: sETH, value: overrideFeeRate }, () => { @@ -119,9 +102,34 @@ contract('ExchangerWithFeeRecAlternatives (unit tests)', async accounts => { }); } ); - } - ); - }); + + // With configured base and override values + behaviors.whenMockedWithSynthUintSystemSetting( + { setting: 'exchangeFeeRate', synth: sETH, value: baseFeeRate }, + () => { + it('is set to the configured base value', async () => { + assert.bnEqual( + await this.instance.feeRateForAtomicExchange(sUSD, sETH), + baseFeeRate + ); + }); + + behaviors.whenMockedWithSynthUintSystemSetting( + { setting: 'atomicExchangeFeeRate', synth: sETH, value: overrideFeeRate }, + () => { + it('is set to the configured atomic override value', async () => { + assert.bnEqual( + await this.instance.feeRateForAtomicExchange(sUSD, sETH), + overrideFeeRate + ); + }); + } + ); + } + ); + }); + } + ); describe('getAmountsForAtomicExchange()', () => { const atomicRate = toUnit('0.01'); @@ -239,11 +247,11 @@ contract('ExchangerWithFeeRecAlternatives (unit tests)', async accounts => { }; describe('failure modes', () => { - behaviors.whenMockedWithExchangeRatesValidity({ valid: false }, () => { + behaviors.whenMockedWithExchangeRatesValidityAtRound({ valid: false }, () => { it('reverts when either rate is invalid', async () => { await assert.revert( this.instance.exchange(...getExchangeArgs()), - 'Src/dest rate invalid or not found' + 'src/dest rate stale or flagged' ); }); }); @@ -253,7 +261,7 @@ contract('ExchangerWithFeeRecAlternatives (unit tests)', async accounts => { behaviors.whenMockedWithUintSystemSetting( { setting: 'waitingPeriodSecs', value: '0' }, () => { - behaviors.whenMockedEffectiveRateAsEqual(() => { + behaviors.whenMockedEffectiveRateAsEqualAtRound(() => { behaviors.whenMockedLastNRates(() => { behaviors.whenMockedASingleSynthToIssueAndBurn(() => { behaviors.whenMockedExchangeStatePersistance(() => { @@ -289,7 +297,7 @@ contract('ExchangerWithFeeRecAlternatives (unit tests)', async accounts => { behaviors.whenMockedWithUintSystemSetting( { setting: 'waitingPeriodSecs', value: '0' }, () => { - behaviors.whenMockedEffectiveRateAsEqual(() => { + behaviors.whenMockedEffectiveRateAsEqualAtRound(() => { behaviors.whenMockedLastNRates(() => { behaviors.whenMockedASingleSynthToIssueAndBurn(() => { behaviors.whenMockedExchangeStatePersistance(() => { @@ -415,7 +423,7 @@ contract('ExchangerWithFeeRecAlternatives (unit tests)', async accounts => { it('reverts when either rate is invalid', async () => { await assert.revert( this.instance.exchangeAtomically(...getExchangeArgs()), - 'Src/dest rate invalid or not found' + 'src/dest rate stale or flagged' ); }); }); @@ -636,224 +644,246 @@ contract('ExchangerWithFeeRecAlternatives (unit tests)', async accounts => { }, () => { behaviors.whenMockedWithUintSystemSetting( - { setting: 'atomicMaxVolumePerBlock', value: maxAtomicValuePerBlock }, + { setting: 'exchangeMaxDynamicFee', value: toUnit('1') }, () => { - const itExchangesCorrectly = ({ - exchangeFeeRate, - setAsOverrideRate, - tradingRewardsEnabled, - trackingCode, - }) => { - behaviors.whenMockedWithBoolSystemSetting( - { - setting: 'tradingRewardsEnabled', - value: !!tradingRewardsEnabled, - }, - () => { - behaviors.whenMockedWithSynthUintSystemSetting( + behaviors.whenMockedWithUintSystemSetting( + { setting: 'atomicMaxVolumePerBlock', value: maxAtomicValuePerBlock }, + () => { + const itExchangesCorrectly = ({ + exchangeFeeRate, + setAsOverrideRate, + tradingRewardsEnabled, + trackingCode, + }) => { + behaviors.whenMockedWithBoolSystemSetting( { - setting: setAsOverrideRate - ? 'atomicExchangeFeeRate' - : 'exchangeFeeRate', - synth: sETH, - value: exchangeFeeRate, + setting: 'tradingRewardsEnabled', + value: !!tradingRewardsEnabled, }, () => { - let expectedAmountReceived; - let expectedFee; - beforeEach('attempt exchange', async () => { - expectedFee = multiplyDecimal(amountIn, exchangeFeeRate); - expectedAmountReceived = divideDecimal( - amountIn.sub(expectedFee), - lastEthRate - ); - - await this.instance.exchangeAtomically( - ...getExchangeArgs({ - trackingCode, - }) - ); - }); - it('burned correct amount of sUSD', () => { - assert.equal( - this.mocks.sUSD.smocked.burn.calls[0][0], - owner - ); - assert.bnEqual( - this.mocks.sUSD.smocked.burn.calls[0][1], - amountIn - ); - }); - it('issued correct amount of sETH', () => { - assert.equal( - this.mocks.sETH.smocked.issue.calls[0][0], - owner - ); - assert.bnEqual( - this.mocks.sETH.smocked.issue.calls[0][1], - expectedAmountReceived - ); - }); - it('tracked atomic volume', async () => { - assert.bnEqual( - (await this.instance.lastAtomicVolume()).volume, - amountIn - ); - }); - it('updated debt cache', () => { - const debtCacheUpdateCall = this.mocks.DebtCache.smocked - .updateCachedSynthDebtsWithRates; - assert.deepEqual(debtCacheUpdateCall.calls[0][0], [ - sUSD, - sETH, - ]); - assert.deepEqual(debtCacheUpdateCall.calls[0][1], [ - lastUsdRate, - lastEthRate, - ]); - }); - it('asked Synthetix to emit an exchange event', () => { - const synthetixEmitExchangeCall = this.mocks.Synthetix - .smocked.emitSynthExchange; - assert.equal(synthetixEmitExchangeCall.calls[0][0], owner); - assert.equal(synthetixEmitExchangeCall.calls[0][1], sUSD); - assert.bnEqual( - synthetixEmitExchangeCall.calls[0][2], - amountIn - ); - assert.equal(synthetixEmitExchangeCall.calls[0][3], sETH); - assert.bnEqual( - synthetixEmitExchangeCall.calls[0][4], - expectedAmountReceived - ); - assert.equal(synthetixEmitExchangeCall.calls[0][5], owner); - }); - it('asked Synthetix to emit an atomic exchange event', () => { - const synthetixEmitAtomicExchangeCall = this.mocks.Synthetix - .smocked.emitAtomicSynthExchange; - assert.equal( - synthetixEmitAtomicExchangeCall.calls[0][0], - owner - ); - assert.equal( - synthetixEmitAtomicExchangeCall.calls[0][1], - sUSD - ); - assert.bnEqual( - synthetixEmitAtomicExchangeCall.calls[0][2], - amountIn - ); - assert.equal( - synthetixEmitAtomicExchangeCall.calls[0][3], - sETH - ); - assert.bnEqual( - synthetixEmitAtomicExchangeCall.calls[0][4], - expectedAmountReceived - ); - assert.equal( - synthetixEmitAtomicExchangeCall.calls[0][5], - owner - ); - }); - it('did not add any fee reclamation entries to exchange state', () => { - assert.equal( - this.mocks.ExchangeState.smocked.appendExchangeEntry.calls - .length, - 0 - ); - }); - - // Conditional based on test settings - if (toBN(exchangeFeeRate).isZero()) { - it('did not report a fee', () => { - assert.equal( - this.mocks.FeePool.smocked.recordFeePaid.calls.length, - 0 - ); - }); - } else { - it('remitted correct fee to fee pool', () => { - assert.equal( - this.mocks.sUSD.smocked.issue.calls[0][0], - getUsers({ network: 'mainnet', user: 'fee' }).address - ); - assert.bnEqual( - this.mocks.sUSD.smocked.issue.calls[0][1], - expectedFee - ); - assert.bnEqual( - this.mocks.FeePool.smocked.recordFeePaid.calls[0], - expectedFee - ); - }); - } - if (!tradingRewardsEnabled) { - it('did not report trading rewards', () => { - assert.equal( - this.mocks.TradingRewards.smocked - .recordExchangeFeeForAccount.calls.length, - 0 - ); - }); - } else { - it('reported trading rewards', () => { - const trRecordCall = this.mocks.TradingRewards.smocked - .recordExchangeFeeForAccount; - assert.bnEqual(trRecordCall.calls[0][0], expectedFee); - assert.equal(trRecordCall.calls[0][1], owner); - }); - } - if (!trackingCode) { - it('did not ask Synthetix to emit tracking event', () => { - assert.equal( - this.mocks.Synthetix.smocked.emitExchangeTracking.calls - .length, - 0 - ); - }); - } else { - it('asked Synthetix to emit tracking event', () => { - const synthetixEmitTrackingCall = this.mocks.Synthetix - .smocked.emitExchangeTracking; - assert.equal( - synthetixEmitTrackingCall.calls[0][0], - trackingCode - ); - }); - } + behaviors.whenMockedWithSynthUintSystemSetting( + { + setting: setAsOverrideRate + ? 'atomicExchangeFeeRate' + : 'exchangeFeeRate', + synth: sETH, + value: exchangeFeeRate, + }, + () => { + let expectedAmountReceived; + let expectedFee; + beforeEach('attempt exchange', async () => { + expectedFee = multiplyDecimal( + amountIn, + exchangeFeeRate + ); + expectedAmountReceived = divideDecimal( + amountIn.sub(expectedFee), + lastEthRate + ); + + await this.instance.exchangeAtomically( + ...getExchangeArgs({ + trackingCode, + }) + ); + }); + it('burned correct amount of sUSD', () => { + assert.equal( + this.mocks.sUSD.smocked.burn.calls[0][0], + owner + ); + assert.bnEqual( + this.mocks.sUSD.smocked.burn.calls[0][1], + amountIn + ); + }); + it('issued correct amount of sETH', () => { + assert.equal( + this.mocks.sETH.smocked.issue.calls[0][0], + owner + ); + assert.bnEqual( + this.mocks.sETH.smocked.issue.calls[0][1], + expectedAmountReceived + ); + }); + it('tracked atomic volume', async () => { + assert.bnEqual( + (await this.instance.lastAtomicVolume()).volume, + amountIn + ); + }); + it('updated debt cache', () => { + const debtCacheUpdateCall = this.mocks.DebtCache.smocked + .updateCachedSynthDebtsWithRates; + assert.deepEqual(debtCacheUpdateCall.calls[0][0], [ + sUSD, + sETH, + ]); + assert.deepEqual(debtCacheUpdateCall.calls[0][1], [ + lastUsdRate, + lastEthRate, + ]); + }); + it('asked Synthetix to emit an exchange event', () => { + const synthetixEmitExchangeCall = this.mocks.Synthetix + .smocked.emitSynthExchange; + assert.equal( + synthetixEmitExchangeCall.calls[0][0], + owner + ); + assert.equal( + synthetixEmitExchangeCall.calls[0][1], + sUSD + ); + assert.bnEqual( + synthetixEmitExchangeCall.calls[0][2], + amountIn + ); + assert.equal( + synthetixEmitExchangeCall.calls[0][3], + sETH + ); + assert.bnEqual( + synthetixEmitExchangeCall.calls[0][4], + expectedAmountReceived + ); + assert.equal( + synthetixEmitExchangeCall.calls[0][5], + owner + ); + }); + it('asked Synthetix to emit an atomic exchange event', () => { + const synthetixEmitAtomicExchangeCall = this.mocks + .Synthetix.smocked.emitAtomicSynthExchange; + assert.equal( + synthetixEmitAtomicExchangeCall.calls[0][0], + owner + ); + assert.equal( + synthetixEmitAtomicExchangeCall.calls[0][1], + sUSD + ); + assert.bnEqual( + synthetixEmitAtomicExchangeCall.calls[0][2], + amountIn + ); + assert.equal( + synthetixEmitAtomicExchangeCall.calls[0][3], + sETH + ); + assert.bnEqual( + synthetixEmitAtomicExchangeCall.calls[0][4], + expectedAmountReceived + ); + assert.equal( + synthetixEmitAtomicExchangeCall.calls[0][5], + owner + ); + }); + it('did not add any fee reclamation entries to exchange state', () => { + assert.equal( + this.mocks.ExchangeState.smocked.appendExchangeEntry + .calls.length, + 0 + ); + }); + + // Conditional based on test settings + if (toBN(exchangeFeeRate).isZero()) { + it('did not report a fee', () => { + assert.equal( + this.mocks.FeePool.smocked.recordFeePaid.calls + .length, + 0 + ); + }); + } else { + it('remitted correct fee to fee pool', () => { + assert.equal( + this.mocks.sUSD.smocked.issue.calls[0][0], + getUsers({ network: 'mainnet', user: 'fee' }) + .address + ); + assert.bnEqual( + this.mocks.sUSD.smocked.issue.calls[0][1], + expectedFee + ); + assert.bnEqual( + this.mocks.FeePool.smocked.recordFeePaid.calls[0], + expectedFee + ); + }); + } + if (!tradingRewardsEnabled) { + it('did not report trading rewards', () => { + assert.equal( + this.mocks.TradingRewards.smocked + .recordExchangeFeeForAccount.calls.length, + 0 + ); + }); + } else { + it('reported trading rewards', () => { + const trRecordCall = this.mocks.TradingRewards.smocked + .recordExchangeFeeForAccount; + assert.bnEqual(trRecordCall.calls[0][0], expectedFee); + assert.equal(trRecordCall.calls[0][1], owner); + }); + } + if (!trackingCode) { + it('did not ask Synthetix to emit tracking event', () => { + assert.equal( + this.mocks.Synthetix.smocked.emitExchangeTracking + .calls.length, + 0 + ); + }); + } else { + it('asked Synthetix to emit tracking event', () => { + const synthetixEmitTrackingCall = this.mocks.Synthetix + .smocked.emitExchangeTracking; + assert.equal( + synthetixEmitTrackingCall.calls[0][0], + trackingCode + ); + }); + } + } + ); } ); - } - ); - }; - - describe('when no exchange fees are configured', () => { - itExchangesCorrectly({ - exchangeFeeRate: '0', - }); - }); - - describe('with tracking code', () => { - itExchangesCorrectly({ - exchangeFeeRate: '0', - trackingCode: toBytes32('TRACKING'), - }); - }); - - describe('when an exchange fee is configured', () => { - itExchangesCorrectly({ - exchangeFeeRate: baseFeeRate, - tradingRewardsEnabled: true, - }); - }); - describe('when an exchange fee override for atomic exchanges is configured', () => { - itExchangesCorrectly({ - exchangeFeeRate: overrideFeeRate, - setAsOverrideRate: true, - tradingRewardsEnabled: true, - }); - }); + }; + + describe('when no exchange fees are configured', () => { + itExchangesCorrectly({ + exchangeFeeRate: '0', + }); + }); + + describe('with tracking code', () => { + itExchangesCorrectly({ + exchangeFeeRate: '0', + trackingCode: toBytes32('TRACKING'), + }); + }); + + describe('when an exchange fee is configured', () => { + itExchangesCorrectly({ + exchangeFeeRate: baseFeeRate, + tradingRewardsEnabled: true, + }); + }); + describe('when an exchange fee override for atomic exchanges is configured', () => { + itExchangesCorrectly({ + exchangeFeeRate: overrideFeeRate, + setAsOverrideRate: true, + tradingRewardsEnabled: true, + }); + }); + } + ); } ); } diff --git a/test/contracts/FeePool.js b/test/contracts/FeePool.js index f951ddc5c7..07a6cd881c 100644 --- a/test/contracts/FeePool.js +++ b/test/contracts/FeePool.js @@ -767,13 +767,9 @@ contract('FeePool', async accounts => { }); it('should disallow closing the current fee period too early', async () => { - const feePeriodDuration = await feePool.feePeriodDuration(); - // Close the current one so we know exactly what we're dealing with await closeFeePeriod(); - // Try to close the new fee period 5 seconds early - await fastForward(feePeriodDuration.sub(web3.utils.toBN('5'))); await assert.revert( feePool.closeCurrentFeePeriod({ from: account1 }), 'Too early to close fee period' diff --git a/test/contracts/PurgeableSynth.js b/test/contracts/PurgeableSynth.js index a66cb71a00..391af33332 100644 --- a/test/contracts/PurgeableSynth.js +++ b/test/contracts/PurgeableSynth.js @@ -200,7 +200,7 @@ contract('PurgeableSynth', accounts => { it('then purge() reverts', async () => { await assert.revert( iETHContract.purge([account1], { from: owner }), - 'Src/dest rate invalid or not found' + 'src/dest rate stale or flagged' ); }); describe('when rates are received', () => { diff --git a/test/contracts/RewardsIntegrationTests.js b/test/contracts/RewardsIntegrationTests.js index f50af7f70c..dcb090d0a2 100644 --- a/test/contracts/RewardsIntegrationTests.js +++ b/test/contracts/RewardsIntegrationTests.js @@ -559,8 +559,8 @@ contract('Rewards Integration Tests', accounts => { assert.bnClose(account3Rewards[1], rewardsAmount, '1'); // Accounts 2 & 3 claim + await updateRatesWithDefaults({ exchangeRates, owner, debtCache }); await feePool.claimFees({ from: account2 }); - // updateRatesWithDefaults(); await feePool.claimFees({ from: account3 }); // Accounts 2 & 3 now have the rewards escrowed diff --git a/test/contracts/SystemSettings.js b/test/contracts/SystemSettings.js index a1ec5c00b8..87359588ea 100644 --- a/test/contracts/SystemSettings.js +++ b/test/contracts/SystemSettings.js @@ -85,6 +85,10 @@ contract('SystemSettings', async accounts => { 'setWrapperBurnFeeRate', 'setWrapperMaxTokenAmount', 'setWrapperMintFeeRate', + 'setExchangeDynamicFeeThreshold', + 'setExchangeDynamicFeeWeightDecay', + 'setExchangeDynamicFeeRounds', + 'setExchangeMaxDynamicFee', ], }); }); @@ -1483,4 +1487,90 @@ contract('SystemSettings', async accounts => { }); }); }); + + describe('setExchangeDynamicFeeThreshold()', () => { + const threshold = toUnit('0.004'); + it('only owner can invoke', async () => { + await onlyGivenAddressCanInvoke({ + fnc: systemSettings.setExchangeDynamicFeeThreshold, + args: [threshold], + accounts, + address: owner, + reason: 'Only the contract owner may perform this action', + }); + }); + it('the owner can invoke and replace with emitted event', async () => { + const txn = await systemSettings.setExchangeDynamicFeeThreshold(threshold, { from: owner }); + const actual = await systemSettings.exchangeDynamicFeeThreshold(); + assert.bnEqual( + actual, + threshold, + 'Configured exchange dynamic fee threshold is set correctly' + ); + assert.eventEqual(txn, 'ExchangeDynamicFeeThresholdUpdated', [threshold]); + }); + }); + + describe('setExchangeDynamicFeeWeightDecay()', () => { + const weightDecay = toUnit('0.9'); + it('only owner can invoke', async () => { + await onlyGivenAddressCanInvoke({ + fnc: systemSettings.setExchangeDynamicFeeWeightDecay, + args: [weightDecay], + accounts, + address: owner, + reason: 'Only the contract owner may perform this action', + }); + }); + it('the owner can invoke and replace with emitted event', async () => { + const txn = await systemSettings.setExchangeDynamicFeeWeightDecay(weightDecay, { + from: owner, + }); + const actual = await systemSettings.exchangeDynamicFeeWeightDecay(); + assert.bnEqual( + actual, + weightDecay, + 'Configured exchange dynamic fee weight decay is set correctly' + ); + assert.eventEqual(txn, 'ExchangeDynamicFeeWeightDecayUpdated', [weightDecay]); + }); + }); + + describe('setExchangeDynamicFeeRounds()', () => { + const rounds = '10'; + it('only owner can invoke', async () => { + await onlyGivenAddressCanInvoke({ + fnc: systemSettings.setExchangeDynamicFeeRounds, + args: [rounds], + accounts, + address: owner, + reason: 'Only the contract owner may perform this action', + }); + }); + it('the owner can invoke and replace with emitted event', async () => { + const txn = await systemSettings.setExchangeDynamicFeeRounds(rounds, { from: owner }); + const actual = await systemSettings.exchangeDynamicFeeRounds(); + assert.equal(actual, rounds, 'Configured exchange dynamic fee rounds is set correctly'); + assert.eventEqual(txn, 'ExchangeDynamicFeeRoundsUpdated', [rounds]); + }); + }); + + describe('setExchangeMaxDynamicFee()', () => { + const maxDynamicFee = toUnit('1'); + it('only owner can invoke', async () => { + await onlyGivenAddressCanInvoke({ + fnc: systemSettings.setExchangeMaxDynamicFee, + args: [maxDynamicFee], + accounts, + address: owner, + reason: 'Only the contract owner may perform this action', + }); + }); + it('the owner can invoke and replace with emitted event', async () => { + const txn = await systemSettings.setExchangeMaxDynamicFee(maxDynamicFee, { from: owner }); + const actual = await systemSettings.exchangeMaxDynamicFee(); + assert.bnEqual(actual, maxDynamicFee, 'Configured exchange max dynamic fee is set correctly'); + assert.eventEqual(txn, 'ExchangeMaxDynamicFeeUpdated', [maxDynamicFee]); + }); + }); }); diff --git a/test/contracts/helpers.js b/test/contracts/helpers.js index 37a83e09de..3510cb7579 100644 --- a/test/contracts/helpers.js +++ b/test/contracts/helpers.js @@ -330,6 +330,7 @@ module.exports = { const flexibleStorageTypes = [ ['uint', 'getUIntValue', '0'], + ['uints', 'getUIntValues', ['0', '0', '0', '0']], ['int', 'getIntValue', '0'], ['address', 'getAddressValue', ZERO_ADDRESS], ['bool', 'getBoolValue', false], diff --git a/test/contracts/setup.js b/test/contracts/setup.js index 4ddbec7be1..9a7bc91892 100644 --- a/test/contracts/setup.js +++ b/test/contracts/setup.js @@ -20,6 +20,10 @@ const { LIQUIDATION_RATIO, LIQUIDATION_PENALTY, RATE_STALE_PERIOD, + EXCHANGE_DYNAMIC_FEE_THRESHOLD, + EXCHANGE_DYNAMIC_FEE_WEIGHT_DECAY, + EXCHANGE_DYNAMIC_FEE_ROUNDS, + EXCHANGE_MAX_DYNAMIC_FEE, MINIMUM_STAKE_TIME, DEBT_SNAPSHOT_STALE_TIME, ATOMIC_MAX_VOLUME_PER_BLOCK, @@ -122,14 +126,16 @@ const setupContract = async ({ ); }; - // if it needs library linking + // Linking libraries if needed if (Object.keys((await artifacts.readArtifact(contract)).linkReferences).length > 0) { const safeDecimalMath = await artifacts.require('SafeDecimalMath').new(); if (artifact._json.contractName === 'SystemSettings') { + // SafeDecimalMath -> SystemSettingsLib -> SystemSettings const SystemSettingsLib = artifacts.require('SystemSettingsLib'); SystemSettingsLib.link(safeDecimalMath); artifact.link(await SystemSettingsLib.new()); } else { + // SafeDecimalMath -> anything else that expects linking artifact.link(safeDecimalMath); } } @@ -1100,6 +1106,21 @@ const setupAllContracts = async ({ returnObj['SystemSettings'].setLiquidationRatio(LIQUIDATION_RATIO, { from: owner }), returnObj['SystemSettings'].setLiquidationPenalty(LIQUIDATION_PENALTY, { from: owner }), returnObj['SystemSettings'].setRateStalePeriod(RATE_STALE_PERIOD, { from: owner }), + returnObj['SystemSettings'].setExchangeDynamicFeeThreshold(EXCHANGE_DYNAMIC_FEE_THRESHOLD, { + from: owner, + }), + returnObj['SystemSettings'].setExchangeDynamicFeeWeightDecay( + EXCHANGE_DYNAMIC_FEE_WEIGHT_DECAY, + { + from: owner, + } + ), + returnObj['SystemSettings'].setExchangeDynamicFeeRounds(EXCHANGE_DYNAMIC_FEE_ROUNDS, { + from: owner, + }), + returnObj['SystemSettings'].setExchangeMaxDynamicFee(EXCHANGE_MAX_DYNAMIC_FEE, { + from: owner, + }), returnObj['SystemSettings'].setMinimumStakeTime(MINIMUM_STAKE_TIME, { from: owner }), returnObj['SystemSettings'].setDebtSnapshotStaleTime(DEBT_SNAPSHOT_STALE_TIME, { from: owner, diff --git a/test/dynamic-fee-calc.csv b/test/dynamic-fee-calc.csv new file mode 100644 index 0000000000..bbbf2b3086 --- /dev/null +++ b/test/dynamic-fee-calc.csv @@ -0,0 +1,125 @@ +To recreate / alter see formulas at the bottom,,,,,,, +,round,price,ΔP (bp),boost,dynamic_fee (bp),,deviation threshold (bp) +,1,50007.73057899,,,,,20 +,2,49953.81262846,10.7819230958361,0,,, +,3,50051.305,19.5165026271593,0,,,exchange fee (bp) +,4,50069.7111646299,3.67745948480325,0,,,20 +,5,50070.40589905,0.13875343075398,0,,, +,6,50078.36597318,1.58977623349932,0,,,boost threshold (bp) +,7,50080.61,0.448103043377834,0,,,40 +,8,50070.3465,2.04939596382681,0,0,, +,9,50074.192,0.768019450394508,0,0,,boost decay constant +,10,50076.03,0.367055348591272,0,0,,0.9 +,11,49994,16.3810909131568,0,0,, +,12,49960.65493467,6.66981344361384,0,0,, +,13,49981,4.07221749927134,0,0,, +,14,49871.92313713,21.8236655669157,0,0,, +,15,49933.34034209,12.3149862882022,0,0,,max weight +,16,49842.74988613,18.1422783533758,0,0,,1 +,17,49838.87627216,0.777166985940214,0,0,, +,18,49722.83886705,23.2825083126564,0,0,,decay constant +,19,49714.05205647,1.76715786552206,0,0,,0.9 +,20,49691.8024553899,4.47551550511793,0,0,, +,21,49714.05205647,4.47751942587837,0,0,,0.387420489 +,22,49535.05178912,36.0059701322823,0,0,,0.43046721 +,23,49234.65005734,60.6442753020364,20.6442753020364,20.6442753020364,,0.4782969 +,24,49190.99117585,8.86751128303942,0,18.5798477718328,,0.531441 +,25,49234.65005734,8.8753815376319,0,16.7218629946495,,0.59049 +,26,49088.63670793,29.6566237883167,0,15.0496766951846,,0.6561 +,27,49046.17079819,8.65086353745492,0,13.5447090256661,,0.729 +,28,49088.63670793,8.65835376113955,0,12.1902381230995,,0.81 +,29,49131.10261767,8.65086353745381,0,10.9712143107895,,0.9 +,30,49096.77,6.9879599359246,0,9.87409287971058,,1 +,31,49143.5399999999,9.52608491350926,0,8.88668359173952,, +,32,49198.77,11.2385066277465,0,7.99801523256557,, +,33,49254,11.2258904033591,0,0,, +,34,49208,9.33934299752304,0,0,, +,35,49101.41,21.6611120143062,0,0,, +,36,49095.24231598,1.2561113866183,0,0,, +,37,49093.89744338,0.273931349873413,0,0,, +,38,49054.03062906,8.12052340435687,0,0,, +,39,49009.46274509,9.08546828843004,0,0,, +,40,49054.03062906,9.09373036831695,0,0,, +,41,48964.89486113,18.1709365748206,0,0,, +,42,48954.93260767,2.03457058128076,0,0,, +,43,48364.4121895,120.625315308367,80.625315308367,80.625315308367,, +,44,48342.34371195,4.56295787562344,0,72.5627837775303,, +,45,48267.37667219,15.5075310801422,0,65.3065053997772,, +,46,48225.00037245,8.77949096504738,0,58.7758548597995,, +,47,47783.75,91.4982621134564,51.4982621134564,104.396531487276,, +,48,48198.4621158,86.7893616135196,46.7893616135196,140.746239952068,, +,49,48043.1218078999,32.2293079656533,0,126.671615956861,, +,50,48198.4621158,32.3335166522343,0,114.004454361175,, +,51,48044.80901916,31.8792529667944,0,102.604008925058,, +,52,48108.4029515399,13.236379471202,0,92.3436080325518,, +,53,48112,0.747696501943729,0,54.9969380550045,, +,54,48056.45718986,11.5444816553045,0,49.4972442495041,, +,55,47988.17926024,14.2078575102289,0,44.5475198245537,, +,56,47902.24487106,17.9074077209684,0,40.0927678420983,, +,57,47911.8471578599,2.0045588313744,0,18.1271573563076,, +,58,47670.81054939,50.3083522694781,10.3083522694781,10.3083522694781,, +,59,47580.67384441,18.9081544746583,0,9.27751704253025,, +,60,47449.76309439,27.5134291809487,0,8.34976533827722,, +,61,47382.88726893,14.0940272614154,0,7.5147888044495,, +,62,47222.35138239,33.8805623280924,0,6.76330992400455,, +,63,46948.76815888,57.9351123993432,17.9351123993432,24.0220913309473,, +,64,46675.18493538,58.2727160325403,18.2727160325403,39.8925982303928,, +,65,46463.74676537,45.2999104990637,5.29991049906368,41.2032489064172,, +,66,46217.7336139799,52.9473338928954,12.9473338928954,50.0302579086709,, +,67,46183.17440371,7.47747835463941,0,45.0272321178038,, +,68,45919.00562933,57.2002201647659,17.2002201647659,54.1304288814665,, +,69,45586.5085919099,72.4094594086167,32.4094594086167,81.1268454019365,, +,70,45483.8961602099,22.509385971754,0,73.0141608617428,, +,71,44672.6561639399,178.357630888202,138.357630888202,204.07037566377,, +,72,44661.70868763,2.4505989233603,0,183.663338097393,, +,73,44650.76121132,2.45119961409634,0,159.043417273233,, +,74,44668.7953606,4.03893434081759,0,136.767773423293,, +,75,44774.49388229,23.6627204375495,0,121.243031555481,, +,76,44853.05,17.5448365572861,0,104.604272214704,, +,77,45204.4549999999,78.345842701868,38.345842701868,132.489687695102,, +,78,45231.276,5.93326476341804,0,113.243372989164,, +,79,45477.5075,54.4383271433691,14.4383271433691,105.056883082536,, +,80,45568.3515,19.9755890315667,0,94.5511947742825,, +,81,45468.09,22.0024417604836,0,36.8537523828245,, +,82,45757.7,63.6952200983143,23.6952200983143,56.8635972428563,, +,83,45846.1785,19.3363084245934,0,51.1772375185707,, +,84,46060.93771094,46.8434268605389,6.84342686053888,52.9029406272525,, +,85,46081.215,4.40227447978669,0,47.6126465645273,, +,86,46167.82,18.7939923025038,0,42.8513819080745,, +,87,45860.8944999999,66.480396951838,26.4803969518379,51.6762720514978,, +,88,45844.31854774,3.61439793981844,0,46.508644846348,, +,89,46040.66042188,42.8279621902417,2.82796219024165,39.6514091659514,, +,90,45975.5952109399,14.1321193796728,0,35.6862682493563,, +,91,45942.48948481,7.20071724531368,0,32.1176414244206,, +,92,46040.66042188,21.3682232223089,0,20.6438649002722,, +,93,46090.3302109399,10.7882442616525,0,18.579478410245,, +,94,46132.86,9.2274863003694,0,14.3353751665493,, +,95,45874.42216374,56.0203369702217,16.0203369702217,28.9221746201161,, +,96,45830.84933011,9.49828500824923,0,26.0299571581045,, +,97,45888.1962499999,12.5127333942343,0,14.1938179398984,, +,98,46038.42444459,32.7378731061145,0,12.7744361459085,, +,99,46039.54243323,0.242838162576309,0,10.5109430861624,, +,100,46218.23018745,38.8118006340177,0,9.4598487775462,, +,101,46213.94,0.928245723083032,0,8.51386389979158,, +,102,46437.773,48.4340872039901,8.43408720399012,16.0965647138025,, +,103,46506.479,14.7952831415932,0,14.4869082424223,, +,104,46519.9745,2.90185373956087,0,13.0382174181801,, +,105,46533.47,2.90101190833836,0,6.1484495717088,, +,106,46622.632,19.1608319774983,0,5.53360461453792,, +,107,46619.216,0.732691367574256,0,4.98024415308413,, +,108,46615.8,0.732745055171957,0,4.48221973777571,, +,109,46707.1025,19.5861703542577,0,4.03399776399814,, +,110,46826.18,25.4945165994824,0,3.63059798759833,, +,111,46819.33062006,1.46272447165252,0,3.26753818883849,, +,112,46826.18,1.46293845924239,0,0,, +,113,46961.35210329,28.8667799273834,0,0,, +,114,47001.22734027,8.4910751488354,0,0,, +,115,46829.45375,36.5466180332763,0,0,, +,116,46796.05875,7.13119571675636,0,0,, +,117,46903.6069999999,22.9823307502164,0,0,, +,118,46789.378,24.3539905150381,0,0,, +,119,46752.593,7.86182710101335,0,0,, +,120,46766.34475,2.94138765736474,0,0,, +,121,46780.0965,2.94052273563716,0,0,, +,122,46946.0995,35.4858182047568,0,0,, +formulas (apply from bottom up),,,"""=ABS(C125/C124-1)*10000”","""=IF(D125-$H$9>0,D125-$H$9,0)”","""=SUMPRODUCT(E116:E125,$H$23:$H$32)”",, diff --git a/test/integration/behaviors/liquidations.behavior.js b/test/integration/behaviors/liquidations.behavior.js index 55990c819b..b40b730c09 100644 --- a/test/integration/behaviors/liquidations.behavior.js +++ b/test/integration/behaviors/liquidations.behavior.js @@ -1,7 +1,7 @@ const ethers = require('ethers'); const { toBytes32 } = require('../../../index'); const { assert } = require('../../contracts/common'); -const { getRate, setRate } = require('../utils/rates'); +const { getRate, addAggregatorAndSetRate } = require('../utils/rates'); const { ensureBalance } = require('../utils/balances'); const { skipLiquidationDelay } = require('../utils/skip'); @@ -46,7 +46,11 @@ function itCanLiquidate({ ctx }) { before('exchange rate is set', async () => { exchangeRate = await getRate({ ctx, symbol: 'SNX' }); - await setRate({ ctx, symbol: 'SNX', rate: '1000000000000000000' }); + await addAggregatorAndSetRate({ + ctx, + currencyKey: toBytes32('SNX'), + rate: '1000000000000000000', + }); }); before('someUser stakes their SNX', async () => { @@ -59,7 +63,11 @@ function itCanLiquidate({ ctx }) { describe('getting marked', () => { before('exchange rate changes to allow liquidation', async () => { - await setRate({ ctx, symbol: 'SNX', rate: '200000000000000000' }); + await addAggregatorAndSetRate({ + ctx, + currencyKey: toBytes32('SNX'), + rate: '200000000000000000', + }); }); before('liquidation is marked', async () => { @@ -67,7 +75,11 @@ function itCanLiquidate({ ctx }) { }); after('restore exchange rate', async () => { - await setRate({ ctx, symbol: 'SNX', rate: exchangeRate.toString() }); + await addAggregatorAndSetRate({ + ctx, + currencyKey: toBytes32('SNX'), + rate: exchangeRate.toString(), + }); }); it('still not open for liquidation', async () => { diff --git a/test/integration/l1/Liquidations.l1.integrations.js b/test/integration/l1/Liquidations.l1.integrations.js index 1027828391..e382b86573 100644 --- a/test/integration/l1/Liquidations.l1.integrations.js +++ b/test/integration/l1/Liquidations.l1.integrations.js @@ -1,31 +1,9 @@ const { bootstrapL1 } = require('../utils/bootstrap'); const { itCanLiquidate } = require('../behaviors/liquidations.behavior'); -const { ethers } = require('hardhat'); - -// Load Compiled -const path = require('path'); -const { - constants: { BUILD_FOLDER }, -} = require('../../..'); -const buildPath = path.join(__dirname, '..', '..', '..', `${BUILD_FOLDER}`); -const { loadCompiledFiles } = require('../../../publish/src/solidity'); -const { compiled } = loadCompiledFiles({ buildPath }); describe('Liquidations (L1)', () => { const ctx = this; bootstrapL1({ ctx }); - before(async () => { - const { - abi, - evm: { - bytecode: { object: bytecode }, - }, - } = compiled.MockAggregatorV2V3; - const MockAggregatorFactory = new ethers.ContractFactory(abi, bytecode, ctx.users.owner); - const MockAggregator = await MockAggregatorFactory.deploy(); - ctx.contracts.MockAggregator = MockAggregator; - }); - itCanLiquidate({ ctx }); }); diff --git a/test/integration/l2/Liquidations.l2.integration.js b/test/integration/l2/Liquidations.l2.integration.js index 34bb13634f..6a0c7a15ad 100644 --- a/test/integration/l2/Liquidations.l2.integration.js +++ b/test/integration/l2/Liquidations.l2.integration.js @@ -1,31 +1,9 @@ const { bootstrapL2 } = require('../utils/bootstrap'); const { itCanLiquidate } = require('../behaviors/liquidations.behavior'); -const { ethers } = require('hardhat'); - -// Load Compiled -const path = require('path'); -const { - constants: { BUILD_FOLDER }, -} = require('../../..'); -const buildPath = path.join(__dirname, '..', '..', '..', `${BUILD_FOLDER}`); -const { loadCompiledFiles } = require('../../../publish/src/solidity'); -const { compiled } = loadCompiledFiles({ buildPath }); describe('Liquidations (L2)', () => { const ctx = this; bootstrapL2({ ctx }); - before(async () => { - const { - abi, - evm: { - bytecode: { object: bytecode }, - }, - } = compiled.MockAggregatorV2V3; - const MockAggregatorFactory = new ethers.ContractFactory(abi, bytecode, ctx.users.owner); - const MockAggregator = await MockAggregatorFactory.deploy(); - ctx.contracts.MockAggregator = MockAggregator; - }); - itCanLiquidate({ ctx }); }); diff --git a/test/integration/utils/rates.js b/test/integration/utils/rates.js index 7692b017d1..386553796c 100644 --- a/test/integration/utils/rates.js +++ b/test/integration/utils/rates.js @@ -25,6 +25,26 @@ async function increaseStalePeriodAndCheckRatesAndCache({ ctx }) { } } +/// this creates and adds a new aggregator (even if a previous one exists) and sets the latest rate in it +async function addAggregatorAndSetRate({ ctx, currencyKey, rate }) { + const owner = ctx.users.owner; + const exchangeRates = ctx.contracts.ExchangeRates.connect(owner); + + // factory for price aggregators contracts + const MockAggregatorFactory = await createMockAggregatorFactory(owner); + + // deploy an aggregator + const aggregator = (await MockAggregatorFactory.deploy()).connect(owner); + + // set decimals + await (await aggregator.setDecimals(18)).wait(); + // push the new price + const { timestamp } = await ctx.provider.getBlock(); + await (await aggregator.setLatestAnswer(rate, timestamp)).wait(); + // set the aggregator in ExchangeRates + await (await exchangeRates.addAggregator(currencyKey, aggregator.address)).wait(); +} + async function _isCacheInvalid({ ctx }) { const { DebtCache } = ctx.contracts; @@ -86,26 +106,11 @@ async function _setMissingRates({ ctx }) { currencyKeys = await _getAvailableCurrencyKeys({ ctx }); } - const owner = ctx.users.owner; - const ExchangeRates = ctx.contracts.ExchangeRates.connect(owner); - - // factory for price aggregators contracts - const MockAggregatorFactory = await createMockAggregatorFactory(owner); - - // got over all rates and add aggregators - const { timestamp } = await ctx.provider.getBlock(); + // got over all rates and add aggregators if rate is missing for (const currencyKey of currencyKeys) { - const rate = await ExchangeRates.rateForCurrency(currencyKey); + const rate = await ctx.contracts.ExchangeRates.rateForCurrency(currencyKey); if (rate.toString() === '0') { - // deploy an aggregator - let aggregator = await MockAggregatorFactory.deploy(); - aggregator = aggregator.connect(owner); - // set decimals - await (await aggregator.setDecimals(18)).wait(); - // push the new price - await (await aggregator.setLatestAnswer(ethers.utils.parseEther('1'), timestamp)).wait(); - // set the aggregator in ExchangeRates - await (await ExchangeRates.addAggregator(currencyKey, aggregator.address)).wait(); + await addAggregatorAndSetRate({ ctx, currencyKey, rate: ethers.utils.parseEther('1') }); } } } @@ -129,21 +134,9 @@ async function getRate({ ctx, symbol }) { return ExchangeRates.rateForCurrency(toBytes32(symbol)); } -async function setRate({ ctx, symbol, rate }) { - const ExchangeRates = ctx.contracts.ExchangeRates.connect(ctx.users.owner); - const MockAggregator = ctx.contracts.MockAggregator.connect(ctx.users.owner); - - const { timestamp } = await ctx.provider.getBlock(); - - await (await MockAggregator.setDecimals(18)).wait(); - await (await MockAggregator.setLatestAnswer(ethers.utils.parseEther(rate), timestamp)).wait(); - await (await ExchangeRates.addAggregator(toBytes32(symbol), MockAggregator.address)).wait(); - await MockAggregator.setLatestAnswer(rate, timestamp); -} - module.exports = { increaseStalePeriodAndCheckRatesAndCache, + addAggregatorAndSetRate, getRate, - setRate, updateCache, }; diff --git a/test/utils/index.js b/test/utils/index.js index e49020edbe..38a9830308 100644 --- a/test/utils/index.js +++ b/test/utils/index.js @@ -371,11 +371,11 @@ module.exports = ({ web3 } = {}) => { assert.ok( actual.gte(expected.sub(variance)), - `Number is too small to be close (Delta between actual and expected is ${actualDelta.toString()}, but variance was only ${variance.toString()}` + `Number is too small to be close (actual is ${actualDelta.toString()}, but variance was only ${variance.toString()}` ); assert.ok( actual.lte(expected.add(variance)), - `Number is too large to be close (Delta between actual and expected is ${actualDelta.toString()}, but variance was only ${variance.toString()})` + `Number is too large to be close (actual is ${actualDelta.toString()}, but variance was only ${variance.toString()})` ); }; @@ -592,6 +592,7 @@ module.exports = ({ web3 } = {}) => { divideDecimalRound, powerToDecimal, + toBN, toUnit, fromUnit,