diff --git a/contracts/libraries/QuotasLogic.sol b/contracts/libraries/QuotasLogic.sol index 92c40c11..3f66df5a 100644 --- a/contracts/libraries/QuotasLogic.sol +++ b/contracts/libraries/QuotasLogic.sol @@ -63,7 +63,7 @@ library QuotasLogic { return uint192( uint256(tokenQuotaParams.cumulativeIndexLU_RAY) + (RAY_DIVIDED_BY_PERCENTAGE * (deltaTimestamp) * rate) / SECONDS_PER_YEAR - ); + ); // U: [QL-1] } /// @dev Computes the accrued quota interest based on the additive index @@ -75,7 +75,7 @@ library QuotasLogic { pure returns (uint256) { - return quoted * (cumulativeIndexNow - cumulativeIndexLU) / RAY; + return uint256(quoted) * (cumulativeIndexNow - cumulativeIndexLU) / RAY; // U: [QL-2] } /// @dev Calculates interest accrued on quota since last update @@ -116,7 +116,7 @@ library QuotasLogic { int96 quotaChange ) internal - initializedQuotasOnly(tokenQuotaParams) + initializedQuotasOnly(tokenQuotaParams) // U: [QL-9] returns ( uint256 caQuotaInterestChange, int256 quotaRevenueChange, @@ -142,17 +142,21 @@ library QuotasLogic { // // When the quota is increased, the new amount is checked against the global limit on quotas - // If the amount is larger than the existing capacity, then only the quota is only increased + // If the amount is larger than the existing capacity, then the quota is only increased // by capacity. This is done instead of reverting to avoid unexpected reverts due to race conditions - uint96 maxQuotaAllowed = tokenQuotaParams.limit - tokenQuotaParams.totalQuoted; - if (maxQuotaAllowed == 0) { - return (caQuotaInterestChange, 0, 0, false, false); + uint96 totalQuoted = tokenQuotaParams.totalQuoted; + uint96 limit = tokenQuotaParams.limit; + + uint96 maxQuotaCapacity = limit > totalQuoted ? limit - totalQuoted : 0; + + if (maxQuotaCapacity == 0) { + return (caQuotaInterestChange, 0, 0, false, false); // U: [QL-3] } change = uint96(quotaChange); - change = change > maxQuotaAllowed ? maxQuotaAllowed : change; // F:[CMQ-08,10] - realQuotaChange = int96(change); + change = change > maxQuotaCapacity ? maxQuotaCapacity : change; // I:[CMQ-08,10] U: [QL-3] + realQuotaChange = int96(change); // U: [QL-3] // Quoted tokens are only enabled in the CM when their quotas are changed // from zero to non-zero. This is done to correctly @@ -160,29 +164,29 @@ library QuotasLogic { // the CM will fail to zero it on closing an account, which will break quota interest computations. // This value is returned in order for Credit Manager to update enabled tokens locally. if (accountQuota.quota <= 1) { - enableToken = true; + enableToken = true; // U: [QL-3] } - accountQuota.quota += change; - tokenQuotaParams.totalQuoted += change; + accountQuota.quota += change; // U: [QL-3] + tokenQuotaParams.totalQuoted += change; // U: [QL-3] // For some tokens, a one-time quota increase fee may be charged. This is a proxy for // trading fees for tokens with high volume but short position duration, in which // case trading fees are a more effective pricing policy than charging interest over time - caQuotaInterestChange += change * tokenQuotaParams.quotaIncreaseFee / PERCENTAGE_FACTOR; + caQuotaInterestChange += uint256(change) * tokenQuotaParams.quotaIncreaseFee / PERCENTAGE_FACTOR; // U: [QL-3] // Quota revenue is a global sum of all quota interest received from all tokens and accounts // per year. It is used by the pool to effectively compute expected quota revenue with just one value - quotaRevenueChange = (uint256(change) * tokenQuotaParams.rate / PERCENTAGE_FACTOR).toInt256(); + quotaRevenueChange = (uint256(change) * tokenQuotaParams.rate / PERCENTAGE_FACTOR).toInt256(); // U: [QL-3] } else { // // DECREASE QUOTA // change = uint96(-quotaChange); - realQuotaChange = quotaChange; + realQuotaChange = quotaChange; // U: [QL-3] - tokenQuotaParams.totalQuoted -= change; - accountQuota.quota -= change; // F:[CMQ-03] + tokenQuotaParams.totalQuoted -= change; // U: [QL-3] + accountQuota.quota -= change; // I:[CMQ-03] U: [QL-3] // Quoted tokens are only disabled in the CM when their quotas are changed // from non-zero to zero. This is done to correctly @@ -190,10 +194,10 @@ library QuotasLogic { // the CM will fail to zero it on closing an account, which will break quota interest computations. // This value is returned in order for Credit Manager to update enabled tokens locally. if (accountQuota.quota <= 1) { - disableToken = true; + disableToken = true; // U: [QL-3] } - quotaRevenueChange = -(uint256(change) * tokenQuotaParams.rate / PERCENTAGE_FACTOR).toInt256(); + quotaRevenueChange = -(uint256(change) * tokenQuotaParams.rate / PERCENTAGE_FACTOR).toInt256(); // U: [QL-3] } } @@ -209,13 +213,17 @@ library QuotasLogic { TokenQuotaParams storage tokenQuotaParams, AccountQuota storage accountQuota, uint256 lastQuotaRateUpdate - ) internal initializedQuotasOnly(tokenQuotaParams) returns (uint256 caQuotaInterestChange) { + ) + internal + initializedQuotasOnly(tokenQuotaParams) // U: [QL-9] + returns (uint256 caQuotaInterestChange) + { uint96 quoted = accountQuota.quota; uint192 cumulativeIndexNow = cumulativeIndexSince(tokenQuotaParams, lastQuotaRateUpdate); if (quoted > 1) { - caQuotaInterestChange = calcAccruedQuotaInterest(quoted, cumulativeIndexNow, accountQuota.cumulativeIndexLU); + caQuotaInterestChange = calcAccruedQuotaInterest(quoted, cumulativeIndexNow, accountQuota.cumulativeIndexLU); // U: [QL-4] } - accountQuota.cumulativeIndexLU = cumulativeIndexNow; + accountQuota.cumulativeIndexLU = cumulativeIndexNow; // U: [QL-4] } /// @dev Internal function to zero the quota for a single quoted token @@ -224,7 +232,7 @@ library QuotasLogic { /// @return quotaRevenueChange Amount to update quota revenue by. function removeQuota(TokenQuotaParams storage tokenQuotaParams, AccountQuota storage accountQuota) internal - initializedQuotasOnly(tokenQuotaParams) + initializedQuotasOnly(tokenQuotaParams) // U: [QL-9] returns (int256 quotaRevenueChange) { uint96 quoted = accountQuota.quota; @@ -235,9 +243,9 @@ library QuotasLogic { if (quoted > 1) { quoted--; - tokenQuotaParams.totalQuoted -= quoted; - accountQuota.quota = 1; - quotaRevenueChange = -(uint256(quoted) * tokenQuotaParams.rate / PERCENTAGE_FACTOR).toInt256(); + tokenQuotaParams.totalQuoted -= quoted; // U: [QL-5] + accountQuota.quota = 1; // U: [QL-5] + quotaRevenueChange = -(uint256(quoted) * tokenQuotaParams.rate / PERCENTAGE_FACTOR).toInt256(); // U: [QL-5] } } @@ -246,12 +254,12 @@ library QuotasLogic { /// @param limit The new limit on total quotas for a token function setLimit(TokenQuotaParams storage tokenQuotaParams, uint96 limit) internal - initializedQuotasOnly(tokenQuotaParams) + initializedQuotasOnly(tokenQuotaParams) // U: [QL-9] returns (bool changed) { if (tokenQuotaParams.limit != limit) { - tokenQuotaParams.limit = limit; // F:[PQK-12] - changed = true; + tokenQuotaParams.limit = limit; // U: [QL-6] + changed = true; // U: [QL-6] } } @@ -260,27 +268,28 @@ library QuotasLogic { /// @param fee The new fee function setQuotaIncreaseFee(TokenQuotaParams storage tokenQuotaParams, uint16 fee) internal - initializedQuotasOnly(tokenQuotaParams) + initializedQuotasOnly(tokenQuotaParams) // U: [QL-9] returns (bool changed) { if (tokenQuotaParams.quotaIncreaseFee != fee) { - tokenQuotaParams.quotaIncreaseFee = fee; - changed = true; + tokenQuotaParams.quotaIncreaseFee = fee; // U: [QL-7] + changed = true; // U: [QL-7] } } /// @dev Saves the current quota interest on a token and updates the interest rate /// @param tokenQuotaParams Quota parameters for a token - /// @param timeFromLastUpdate Time since the last rate update + /// @param lastQuotaRateUpdate Timestamp of the last quota rate update /// @param rate The new interest rate for a token /// @return quotaRevenue The new annual quota revenue for the token. Used to recompute quote revenue for the pool - function updateRate(TokenQuotaParams storage tokenQuotaParams, uint256 timeFromLastUpdate, uint16 rate) + function updateRate(TokenQuotaParams storage tokenQuotaParams, uint256 lastQuotaRateUpdate, uint16 rate) internal + initializedQuotasOnly(tokenQuotaParams) // U: [QL-9] returns (uint256 quotaRevenue) { - tokenQuotaParams.cumulativeIndexLU_RAY = calcAdditiveCumulativeIndex(tokenQuotaParams, rate, timeFromLastUpdate); // F:[PQK-7] - tokenQuotaParams.rate = rate; + tokenQuotaParams.cumulativeIndexLU_RAY = cumulativeIndexSince(tokenQuotaParams, lastQuotaRateUpdate); // U: [QL-8] + tokenQuotaParams.rate = rate; // U: [QL-8] - return uint256(tokenQuotaParams.totalQuoted) * rate / PERCENTAGE_FACTOR; + return uint256(tokenQuotaParams.totalQuoted) * rate / PERCENTAGE_FACTOR; // U: [QL-8] } } diff --git a/contracts/pool/PoolQuotaKeeperV3.sol b/contracts/pool/PoolQuotaKeeperV3.sol index 4ebb60b5..6fae36c5 100644 --- a/contracts/pool/PoolQuotaKeeperV3.sol +++ b/contracts/pool/PoolQuotaKeeperV3.sol @@ -305,7 +305,7 @@ contract PoolQuotaKeeperV3 is IPoolQuotaKeeperV3, ACLNonReentrantTrait, Contract uint16[] memory rates = IGaugeV3(gauge).getRates(tokens); // F:[PQK-7] uint256 quotaRevenue; - uint256 timeFromLastUpdate = block.timestamp - lastQuotaRateUpdate; + uint256 timestampLU = lastQuotaRateUpdate; uint256 len = tokens.length; for (uint256 i; i < len;) { @@ -315,7 +315,7 @@ contract PoolQuotaKeeperV3 is IPoolQuotaKeeperV3, ACLNonReentrantTrait, Contract /// Before writing a new rate, the token's interest index current value is also /// saved, to ensure that further calculations with the new rates are correct TokenQuotaParams storage tokenQuotaParams = totalQuotaParams[token]; - quotaRevenue += tokenQuotaParams.updateRate(timeFromLastUpdate, rate); + quotaRevenue += tokenQuotaParams.updateRate(timestampLU, rate); emit UpdateTokenQuotaRate(token, rate); // F:[PQK-7] diff --git a/contracts/test/unit/libraries/QuotasLogic.t.sol b/contracts/test/unit/libraries/QuotasLogic.t.sol index 2eb44002..2b168040 100644 --- a/contracts/test/unit/libraries/QuotasLogic.t.sol +++ b/contracts/test/unit/libraries/QuotasLogic.t.sol @@ -7,6 +7,8 @@ import {QuotasLogic} from "../../../libraries/QuotasLogic.sol"; import {AccountQuota, TokenQuotaParams} from "../../../interfaces/IPoolQuotaKeeperV3.sol"; import {TestHelper} from "../../lib/helper.sol"; +import "../../../interfaces/IExceptions.sol"; + import {PERCENTAGE_FACTOR} from "@gearbox-protocol/core-v2/contracts/libraries/PercentageMath.sol"; import {RAY, WAD} from "@gearbox-protocol/core-v2/contracts/libraries/Constants.sol"; @@ -130,7 +132,7 @@ contract QuotasLogicTest is TestHelper { /// @notice U:[QL-3]: `changeQuota` works correctly function test_U_QL_03_changeQuota_works_correctly() public { - ChangeQuotaCase[1] memory cases = [ + ChangeQuotaCase[7] memory cases = [ ChangeQuotaCase({ name: "Increasing quota from 0 without trading fee", cumulativeIndexTokenLU: uint192(2 * RAY), @@ -147,6 +149,108 @@ contract QuotasLogicTest is TestHelper { realQuotaChangeExpected: int96(int256(WAD)), enableTokenExpected: true, disableTokenExpected: false + }), + ChangeQuotaCase({ + name: "Increasing quota from non-zero without trading fee", + cumulativeIndexTokenLU: uint192(2 * RAY), + cumulativeIndexAccountLU: uint192(RAY), + timeSinceLastUpdate: 1 days, + rate: 3650, + oneTimeFee: 0, + previousQuota: uint96(WAD), + quotaChange: int96(int256(2 * WAD)), + quotaLimit: uint96(5 * WAD), + totalQuoted: uint96(15 * WAD / 10), + caQuotaInterestChangeExpected: WAD + WAD / 1000, + quotaRevenueChangeExpected: int256(int96(int256(2 * WAD)) * 365 / 1000), + realQuotaChangeExpected: int96(int256(2 * WAD)), + enableTokenExpected: false, + disableTokenExpected: false + }), + ChangeQuotaCase({ + name: "Increasing quota from non-zero with trading fee", + cumulativeIndexTokenLU: uint192(2 * RAY), + cumulativeIndexAccountLU: uint192(RAY), + timeSinceLastUpdate: 1 days, + rate: 3650, + oneTimeFee: 100, + previousQuota: uint96(WAD), + quotaChange: int96(int256(2 * WAD)), + quotaLimit: uint96(5 * WAD), + totalQuoted: uint96(15 * WAD / 10), + caQuotaInterestChangeExpected: WAD + WAD / 1000 + 2 * WAD / 100, + quotaRevenueChangeExpected: int256(int96(int256(2 * WAD)) * 365 / 1000), + realQuotaChangeExpected: int96(int256(2 * WAD)), + enableTokenExpected: false, + disableTokenExpected: false + }), + ChangeQuotaCase({ + name: "Increasing quota from non-zero over capacity", + cumulativeIndexTokenLU: uint192(2 * RAY), + cumulativeIndexAccountLU: uint192(RAY), + timeSinceLastUpdate: 1 days, + rate: 3650, + oneTimeFee: 100, + previousQuota: uint96(WAD), + quotaChange: int96(int256(2 * WAD)), + quotaLimit: uint96(3 * WAD), + totalQuoted: uint96(15 * WAD / 10), + caQuotaInterestChangeExpected: WAD + WAD / 1000 + 15 * WAD / 1000, + quotaRevenueChangeExpected: int256(int96(int256(15 * WAD / 10)) * 365 / 1000), + realQuotaChangeExpected: int96(int256(15 * WAD / 10)), + enableTokenExpected: false, + disableTokenExpected: false + }), + ChangeQuotaCase({ + name: "Increasing quota at the limit", + cumulativeIndexTokenLU: uint192(2 * RAY), + cumulativeIndexAccountLU: uint192(RAY), + timeSinceLastUpdate: 1 days, + rate: 3650, + oneTimeFee: 100, + previousQuota: uint96(WAD), + quotaChange: int96(int256(2 * WAD)), + quotaLimit: uint96(14 * WAD / 10), + totalQuoted: uint96(15 * WAD / 10), + caQuotaInterestChangeExpected: WAD + WAD / 1000, + quotaRevenueChangeExpected: 0, + realQuotaChangeExpected: 0, + enableTokenExpected: false, + disableTokenExpected: false + }), + ChangeQuotaCase({ + name: "Decreasing quota from non-zero to non-zero", + cumulativeIndexTokenLU: uint192(2 * RAY), + cumulativeIndexAccountLU: uint192(RAY), + timeSinceLastUpdate: 1 days, + rate: 3650, + oneTimeFee: 100, + previousQuota: uint96(WAD), + quotaChange: -int96(int256(WAD / 2)), + quotaLimit: uint96(15 * WAD / 10), + totalQuoted: uint96(15 * WAD / 10), + caQuotaInterestChangeExpected: WAD + WAD / 1000, + quotaRevenueChangeExpected: -int256(int96(int256(WAD / 2)) * 365 / 1000), + realQuotaChangeExpected: -int96(int256(WAD / 2)), + enableTokenExpected: false, + disableTokenExpected: false + }), + ChangeQuotaCase({ + name: "Decreasing quota from non-zero to zero", + cumulativeIndexTokenLU: uint192(2 * RAY), + cumulativeIndexAccountLU: uint192(RAY), + timeSinceLastUpdate: 1 days, + rate: 3650, + oneTimeFee: 100, + previousQuota: uint96(WAD), + quotaChange: -int96(int256(WAD)), + quotaLimit: uint96(15 * WAD / 10), + totalQuoted: uint96(15 * WAD / 10), + caQuotaInterestChangeExpected: WAD + WAD / 1000, + quotaRevenueChangeExpected: -int256(int96(int256(WAD)) * 365 / 1000), + realQuotaChangeExpected: -int96(int256(WAD)), + enableTokenExpected: false, + disableTokenExpected: true }) ]; @@ -186,8 +290,167 @@ contract QuotasLogicTest is TestHelper { assertEq( accountQuota.cumulativeIndexLU, - QuotasLogic.cumulativeIndexSince(params, block.timestamp - cases[i].timeSinceLastUpdate) + QuotasLogic.cumulativeIndexSince(params, block.timestamp - cases[i].timeSinceLastUpdate), + "Cumulative index updated incorrectly" + ); + + assertEq( + accountQuota.quota, + uint96(int96(cases[i].previousQuota) + cases[i].realQuotaChangeExpected), + "Quota updated incorrectly" + ); + + assertEq( + params.totalQuoted, + uint96(int96(cases[i].totalQuoted) + cases[i].realQuotaChangeExpected), + "Total quoted updated incorrectly" ); } } + + struct AccrueQuotaInterestCase { + string name; + uint192 cumulativeIndexTokenLU; + uint192 cumulativeIndexAccountLU; + uint256 timeSinceLastUpdate; + uint16 rate; + uint96 quota; + uint256 caQuotaInterestChangeExpected; + uint256 expectedIndexAccountAfter; + } + + /// @notice U:[QL-4]: `accruedQuotaInterest` works correctly + function test_U_QL_04_accrueQuotaInterest_works_correctly() public { + AccrueQuotaInterestCase[2] memory cases = [ + AccrueQuotaInterestCase({ + name: "Quota is zero", + cumulativeIndexTokenLU: uint192(2 * RAY), + cumulativeIndexAccountLU: uint192(RAY), + timeSinceLastUpdate: 1 days, + rate: 3650, + quota: 0, + caQuotaInterestChangeExpected: 0, + expectedIndexAccountAfter: uint192(2001 * RAY / 1000) + }), + AccrueQuotaInterestCase({ + name: "Quota is non-zero", + cumulativeIndexTokenLU: uint192(2 * RAY), + cumulativeIndexAccountLU: uint192(RAY), + timeSinceLastUpdate: 1 days, + rate: 3650, + quota: uint96(WAD), + caQuotaInterestChangeExpected: WAD + WAD / 1000, + expectedIndexAccountAfter: uint192(2001 * RAY / 1000) + }) + ]; + + for (uint256 i = 0; i < cases.length; ++i) { + params.cumulativeIndexLU_RAY = cases[i].cumulativeIndexTokenLU; + params.rate = cases[i].rate; + + accountQuota.quota = cases[i].quota; + accountQuota.cumulativeIndexLU = cases[i].cumulativeIndexAccountLU; + + uint256 caQuotaInterestChange = QuotasLogic.accrueAccountQuotaInterest( + params, accountQuota, block.timestamp - cases[i].timeSinceLastUpdate + ); + + assertEq( + caQuotaInterestChange, cases[i].caQuotaInterestChangeExpected, "Interest change computed incorrectly" + ); + + assertEq( + accountQuota.cumulativeIndexLU, + cases[i].expectedIndexAccountAfter, + "Cumulative index updated incorrectly" + ); + } + } + + /// @notice U:[QL-5]: `removeQuota` works correctly + function test_U_QL_05_removeQuota_works_correctly() public { + params.rate = 3650; + params.cumulativeIndexLU_RAY = uint192(RAY); + params.totalQuoted = uint96(3 * WAD); + accountQuota.quota = uint96(WAD); + + int256 quotaRevenueChange = QuotasLogic.removeQuota(params, accountQuota); + + assertEq(quotaRevenueChange, -(int96(uint96(WAD * 3650 / 10000))) + 1, "Quota revenue change incorrect"); + + assertEq(params.totalQuoted, uint96(2 * WAD + 1), "Incorrect total quoted"); + + assertEq(accountQuota.quota, 1, "Incorrect quota"); + } + + /// @notice U:[QL-6]: `setLimit` works correctly + function test_U_QL_06_setLimit_works_correctly() public { + params.cumulativeIndexLU_RAY = uint192(RAY); + params.limit = uint96(WAD); + + bool changed = QuotasLogic.setLimit(params, uint96(WAD)); + + assertTrue(!changed, "Status is changed despite the same value"); + + changed = QuotasLogic.setLimit(params, uint96(2 * WAD)); + + assertTrue(changed, "Status is not changed despite different value"); + + assertEq(params.limit, uint96(2 * WAD), "Limit is incorrect"); + } + + /// @notice U:[QL-7]: `setQuotaIncreaseFee` works correctly + function test_U_QL_07_setQuotaIncreaseFee_works_correctly() public { + params.cumulativeIndexLU_RAY = uint192(RAY); + params.quotaIncreaseFee = 100; + + bool changed = QuotasLogic.setQuotaIncreaseFee(params, 100); + + assertTrue(!changed, "Status is changed despite the same value"); + + changed = QuotasLogic.setQuotaIncreaseFee(params, 200); + + assertTrue(changed, "Status is not changed despite different value"); + + assertEq(params.quotaIncreaseFee, 200, "Quota increase fee is incorrect"); + } + + /// @notice U:[QL-8]: `updateRate` works correctly + function test_U_QL_08_updateRate_works_correctly() public { + params.cumulativeIndexLU_RAY = uint192(RAY); + params.totalQuoted = uint96(WAD); + params.rate = 1000; + + uint256 lastUpdate = block.timestamp; + vm.warp(block.timestamp + 365 days); + + uint256 quotaRevenue = QuotasLogic.updateRate(params, lastUpdate, 2000); + + assertEq(quotaRevenue, WAD / 5, "Incorrect quota revenue"); + + assertEq(params.rate, 2000, "Incorrect rate set"); + + assertEq(params.cumulativeIndexLU_RAY, uint192(11 * RAY / 10)); + } + + /// @notice U:[QL-9]: state-changing token-dependent functions fail on non-initialized token + function test_U_QL_09_initializedQuotasOnly_reverts_state_changing_functions() public { + vm.expectRevert(TokenIsNotQuotedException.selector); + QuotasLogic.changeQuota(params, accountQuota, block.timestamp, 1); + + vm.expectRevert(TokenIsNotQuotedException.selector); + QuotasLogic.accrueAccountQuotaInterest(params, accountQuota, block.timestamp); + + vm.expectRevert(TokenIsNotQuotedException.selector); + QuotasLogic.removeQuota(params, accountQuota); + + vm.expectRevert(TokenIsNotQuotedException.selector); + QuotasLogic.setLimit(params, 1); + + vm.expectRevert(TokenIsNotQuotedException.selector); + QuotasLogic.setQuotaIncreaseFee(params, 1); + + vm.expectRevert(TokenIsNotQuotedException.selector); + QuotasLogic.updateRate(params, block.timestamp, 1); + } }