diff --git a/src/Payment.sol b/src/Payment.sol new file mode 100644 index 0000000..d6600a5 --- /dev/null +++ b/src/Payment.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.23; + +import {QuotaControl} from "./QuotaControl.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title Payment Contract + * @dev A contract that enables secure payments and withdrawals using ERC20 tokens. + * The contract is controlled by an Oracle role and incorporates quota control + * to manage payments within a specified limit. + * + * Inherits from `QuotaControl` to manage payment quotas. + */ +contract Payment is QuotaControl { + using SafeERC20 for IERC20; + + /// @dev Role identifier for the Oracle. Accounts with this role can trigger payments. + bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE"); + + /// @dev ERC20 token used for payments in this contract. + IERC20 public immutable token; + + /** + * @dev Error emitted when an attempt to pay fails due to insufficient contract balance. + * @param balance The current token balance of the contract. + * @param needed The minimum token amount required to execute the payment. + */ + error InsufficientBalance(uint256 balance, uint256 needed); + + /** + * @dev Constructor that initializes the contract with the provided parameters. + * @param oracle The address of the account assigned the `ORACLE_ROLE` to manage payments. + * @param _token The address of the ERC20 token contract used for payments. + * @param initialQuota The initial quota limit for payments. + * @param initialPeriod The initial time period for which the quota is valid. + * @param admin The address assigned the `DEFAULT_ADMIN_ROLE` for administrative privileges. + */ + constructor(address oracle, address _token, uint256 initialQuota, uint256 initialPeriod, address admin) + QuotaControl(initialQuota, initialPeriod, admin) + { + _grantRole(ORACLE_ROLE, oracle); // Grant ORACLE_ROLE to the specified oracle address. + token = IERC20(_token); // Set the ERC20 token used for payments. + } + + /** + * @notice Pays a specified amount to a list of recipients. + * @dev Can only be called by accounts with the `ORACLE_ROLE`. + * The total required amount is calculated by multiplying the number of recipients by the specified amount. + * If the contract's token balance is insufficient, the transaction reverts with `InsufficientBalance`. + * + * @param recipients An array of addresses to receive the payments. + * @param amount The amount of tokens to be paid to each recipient. + * + * Emits a `Transfer` event from the token for each recipient. + */ + function pay(address[] calldata recipients, uint256 amount) external onlyRole(ORACLE_ROLE) { + uint256 needed = recipients.length * amount; // Calculate the total tokens required. + uint256 balance = token.balanceOf(address(this)); // Get the current balance of the contract. + + if (balance < needed) { + revert InsufficientBalance(balance, needed); + } + + _checkedResetClaimed(); + _checkedUpdateClaimed(needed); + + for (uint256 i = 0; i < recipients.length; i++) { + token.safeTransfer(recipients[i], amount); + } + } + + /** + * @notice Withdraws a specified amount of tokens to the provided recipient address. + * @dev Can only be called by accounts with the `DEFAULT_ADMIN_ROLE`. + * + * @param recipient The address to receive the withdrawn tokens. + * @param amount The amount of tokens to be transferred to the recipient. + * + * Emits a `Transfer` event from the token to the recipient. + */ + function withdraw(address recipient, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + token.safeTransfer(recipient, amount); + } +} diff --git a/src/QuotaControl.sol b/src/QuotaControl.sol new file mode 100644 index 0000000..90440d5 --- /dev/null +++ b/src/QuotaControl.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.23; + +import {AccessControl} from "openzeppelin-contracts/contracts/access/AccessControl.sol"; +import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; + +/** + * @title QuotaControl + * @dev A contract designed to manage and control periodic quotas, such as token distributions or other `uint256`-based allowances. + * It ensures that the total claims or usage within a given period do not exceed the predefined quota, thus regulating the flow of resources. + * + * The contract employs a time-based system where the quota is automatically reset at the start of each period. + * An administrator, holding the `DEFAULT_ADMIN_ROLE`, has the authority to set and modify both the quota and the renewal period. + * + * Key Features: + * - Prevents claims that exceed the current quota. + * - Allows the admin to update the quota and renewal period. + * - Automatically resets the claimed amount when a new period begins. + */ +contract QuotaControl is AccessControl { + using Math for uint256; + + /** + * @dev The maximum allowable period for reward quota renewal. This limit prevents potential overflows and reduces the need for safe math checks. + */ + uint256 public constant MAX_PERIOD = 30 days; + + /** + * @dev The current period for reward quota renewal, specified in seconds. + */ + uint256 public period; + + /** + * @dev The maximum amount of rewards that can be distributed in the current period. + */ + uint256 public quota; + + /** + * @dev The timestamp when the reward quota will be renewed next. + */ + uint256 public quotaRenewalTimestamp; + + /** + * @dev The total amount of rewards claimed within the current period. + */ + uint256 public claimed; + + /** + * @dev Error triggered when the claim amount exceeds the current reward quota. + */ + error QuotaExceeded(); + + /** + * @dev Error triggered when the period for reward renewal is set to zero. + */ + error ZeroPeriod(); + + /** + * @dev Error triggered when the set period exceeds the maximum allowable period. + */ + error TooLongPeriod(); + + /** + * @dev Emitted when the reward quota is updated. + * @param quota The new reward quota. + */ + event QuotaSet(uint256 quota); + + /** + * @dev Emitted when the reward period is updated. + * @param period The new reward period in seconds. + */ + event PeriodSet(uint256 period); + + /** + * @dev Initializes the contract with an initial reward quota, reward period, and admin. + * @param initialQuota The initial maximum amount of rewards distributable in each period. + * @param initialPeriod The initial duration of the reward period in seconds. + * @param admin The address granted the `DEFAULT_ADMIN_ROLE`, responsible for updating contract settings. + * + * Requirements: + * - `initialPeriod` must be within the acceptable range (greater than 0 and less than or equal to `MAX_PERIOD`). + */ + constructor(uint256 initialQuota, uint256 initialPeriod, address admin) { + _mustBeWithinPeriodRange(initialPeriod); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + + quota = initialQuota; + period = initialPeriod; + quotaRenewalTimestamp = block.timestamp + period; + } + + /** + * @dev Sets a new reward quota. Can only be called by an account with the `DEFAULT_ADMIN_ROLE`. + * @param newQuota The new maximum amount of rewards distributable in each period. + */ + function setQuota(uint256 newQuota) external onlyRole(DEFAULT_ADMIN_ROLE) { + quota = newQuota; + emit QuotaSet(newQuota); + } + + /** + * @dev Sets a new reward period. Can only be called by an account with the `DEFAULT_ADMIN_ROLE`. + * @param newPeriod The new duration of the reward period in seconds. + * + * Requirements: + * - `newPeriod` must be greater than 0 and less than or equal to `MAX_PERIOD`. + */ + function setPeriod(uint256 newPeriod) external onlyRole(DEFAULT_ADMIN_ROLE) { + _mustBeWithinPeriodRange(newPeriod); + period = newPeriod; + emit PeriodSet(newPeriod); + } + + /** + * @dev Internal function that resets the claimed rewards to 0 and updates the next quota renewal timestamp. + * If the current timestamp is beyond the quota renewal timestamp, a new period begins. + * + * The reset calculation ensures that the renewal timestamp will always be aligned with the period's duration, even if time has passed beyond the expected renewal time. + */ + function _checkedResetClaimed() internal { + if (block.timestamp >= quotaRenewalTimestamp) { + claimed = 0; + + // Align the quota renewal timestamp to the next period start + uint256 timeAhead = block.timestamp - quotaRenewalTimestamp; + quotaRenewalTimestamp = block.timestamp + period - (timeAhead % period); + } + } + + /** + * @dev Internal function to update the claimed rewards by a specified amount. + * + * If the total claimed amount exceeds the quota, the transaction is reverted. + * @param amount The amount of rewards being claimed. + * + * Requirements: + * - The updated `claimed` amount must not exceed the current reward quota. + */ + function _checkedUpdateClaimed(uint256 amount) internal { + (bool success, uint256 newClaimed) = claimed.tryAdd(amount); + if (!success || newClaimed > quota) { + revert QuotaExceeded(); + } + claimed = newClaimed; + } + + /** + * @dev Internal function to validate that the provided reward period is within the allowed range. + * @param newPeriod The period to validate. + * + * Requirements: + * - The `newPeriod` must be greater than 0. + * - The `newPeriod` must not exceed `MAX_PERIOD`. + */ + function _mustBeWithinPeriodRange(uint256 newPeriod) internal { + if (newPeriod == 0) { + revert ZeroPeriod(); + } + if (newPeriod > MAX_PERIOD) { + revert TooLongPeriod(); + } + } +} diff --git a/src/Rewards.sol b/src/Rewards.sol index c74e386..f99f975 100644 --- a/src/Rewards.sol +++ b/src/Rewards.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.23; import {NODL} from "./NODL.sol"; +import {QuotaControl} from "./QuotaControl.sol"; import {AccessControl} from "openzeppelin-contracts/contracts/access/AccessControl.sol"; import {EIP712} from "openzeppelin-contracts/contracts/utils/cryptography/EIP712.sol"; import {SignatureChecker} from "openzeppelin-contracts/contracts/utils/cryptography/SignatureChecker.sol"; @@ -12,7 +13,7 @@ import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol"; * @dev This contract allows an authorized oracle to issue off-chain signed rewards to recipients. * This contract must have the MINTER_ROLE in the NODL token contract. */ -contract Rewards is AccessControl, EIP712 { +contract Rewards is QuotaControl, EIP712 { using Math for uint256; /** @@ -36,10 +37,6 @@ contract Rewards is AccessControl, EIP712 { */ bytes32 public constant BATCH_REWARD_TYPE_HASH = keccak256("BatchReward(bytes32 recipientsHash,bytes32 amountsHash,uint256 sequence)"); - /** - * @dev The maximum period for reward quota renewal. This is to prevent overflows while avoiding the ongoing overhead of safe math operations. - */ - uint256 public constant MAX_PERIOD = 30 days; /** * @dev The maximum value for basis points values. @@ -74,23 +71,6 @@ contract Rewards is AccessControl, EIP712 { * @dev Address of the authorized oracle. */ address public immutable authorizedOracle; - /** - * @dev Reward quota renewal period. - */ - uint256 public period; - - /** - * @dev Maximum amount of rewards that can be distributed in a period. - */ - uint256 public quota; - /** - * @dev Timestamp indicating when the reward quota is due to be renewed. - */ - uint256 public quotaRenewalTimestamp; - /** - * @dev Amount of rewards claimed in the current period. - */ - uint256 public claimed; /** * @dev Mapping to store reward sequences for each recipient to prevent replay attacks. */ @@ -108,18 +88,6 @@ contract Rewards is AccessControl, EIP712 { */ uint16 public batchSubmitterRewardBasisPoints; - /** - * @dev Error when the reward quota is exceeded. - */ - error QuotaExceeded(); - /** - * @dev Error indicating the reward renewal period is set to zero which is not acceptable. - */ - error ZeroPeriod(); - /** - * @dev Error indicating that scheduling the reward quota renewal has failed most likely due to the period being too long. - */ - error TooLongPeriod(); /** * @dev Error when the reward is not from the authorized oracle. */ @@ -142,16 +110,6 @@ contract Rewards is AccessControl, EIP712 { */ error OutOfRangeValue(); - /** - * @dev Event emitted when the reward quota is set. - */ - event QuotaSet(uint256 quota); - - /** - * @dev Event emitted when the reward period is set. - */ - event PeriodSet(uint256 period); - /** * @dev Event emitted when the submitter's reward basis point is set. */ @@ -184,41 +142,14 @@ contract Rewards is AccessControl, EIP712 { address oracleAddress, uint16 rewardBasisPoints, address admin - ) EIP712(SIGNING_DOMAIN, SIGNATURE_VERSION) { - _mustBeWithinPeriodRange(initialPeriod); + ) QuotaControl(initialQuota, initialPeriod, admin) EIP712(SIGNING_DOMAIN, SIGNATURE_VERSION) { _mustBeLessThanBasisPointsDivisor(rewardBasisPoints); - _grantRole(DEFAULT_ADMIN_ROLE, admin); - nodl = token; - quota = initialQuota; - period = initialPeriod; - quotaRenewalTimestamp = block.timestamp + period; authorizedOracle = oracleAddress; batchSubmitterRewardBasisPoints = rewardBasisPoints; } - /** - * @dev Sets the reward quota. Only accounts with the DEFAULT_ADMIN_ROLE can call this function. - * @param newQuota The new reward quota. - */ - function setQuota(uint256 newQuota) external { - _checkRole(DEFAULT_ADMIN_ROLE); - quota = newQuota; - emit QuotaSet(newQuota); - } - - /** - * @dev Sets the reward period. Only accounts with the DEFAULT_ADMIN_ROLE can call this function. - * @param newPeriod The new reward period. - */ - function setPeriod(uint256 newPeriod) external { - _checkRole(DEFAULT_ADMIN_ROLE); - _mustBeWithinPeriodRange(newPeriod); - period = newPeriod; - emit PeriodSet(newPeriod); - } - /** * @dev Mints rewards to the recipient if the signature is valid and quota is not exceeded. * @param reward The reward details. @@ -283,35 +214,6 @@ contract Rewards is AccessControl, EIP712 { emit BatchSubmitterRewardSet(newBasisPoints); } - /** - * @notice This function resets the rewards claimed to 0 and updates the quota renewal timestamp based on the reward period. - * @notice The following operations are safe based on the constructor's requirements for longer than the age of the universe. - */ - function _checkedResetClaimed() internal { - if (block.timestamp >= quotaRenewalTimestamp) { - claimed = 0; - - // The following operations are safe based on the constructor's requirements for longer than the age of universe :) - uint256 timeAhead = block.timestamp - quotaRenewalTimestamp; - quotaRenewalTimestamp = block.timestamp + period - (timeAhead % period); - } - } - - /** - * @dev Internal function to update the rewards claimed by a given amount. - * @param amount The amount of rewards to be added. - * @notice This function is used to update the rewards claimed by a user. It checks if the new rewards claimed - * exceeds the reward quota and reverts the transaction if it does. Otherwise, it updates the rewards claimed - * by adding the specified amount. - */ - function _checkedUpdateClaimed(uint256 amount) internal { - (bool success, uint256 newClaimed) = claimed.tryAdd(amount); - if (!success || newClaimed > quota) { - revert QuotaExceeded(); - } - claimed = newClaimed; - } - /** * @dev Internal check to ensure the basis points value is less than the divisor. * @param basisPoints The basis points value to be checked. @@ -378,21 +280,6 @@ contract Rewards is AccessControl, EIP712 { return sum; } - /** - * @dev Internal function to ensure the period is within the acceptable range. - * @param newPeriod The new period to be checked. - */ - function _mustBeWithinPeriodRange(uint256 newPeriod) internal { - // This is to avoid the ongoing overhead of safe math operations - if (newPeriod == 0) { - revert ZeroPeriod(); - } - // This is to prevent overflows while avoiding the ongoing overhead of safe math operations - if (newPeriod > MAX_PERIOD) { - revert TooLongPeriod(); - } - } - /** * @dev Helper function to get the digest of the typed data to be signed. * @param reward detailing recipient, amount, and sequence. diff --git a/test/Payment.t.sol b/test/Payment.t.sol new file mode 100644 index 0000000..f3052ad --- /dev/null +++ b/test/Payment.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "../src/Payment.sol"; +import "../src/QuotaControl.sol"; +import "./__helpers__/AccessControlUtils.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockToken is ERC20 { + constructor() ERC20("MockToken", "MTK") { + _mint(msg.sender, 1000000); + } +} + +contract PaymentTest is Test { + using AccessControlUtils for Vm; + + address admin; + address oracle; + address user; + Payment payment; + MockToken token; + + function setUp() public { + admin = address(1); + oracle = address(2); + user = address(3); + token = new MockToken(); + payment = new Payment(oracle, address(token), 1000, 1 days, admin); + } + + function test_onlyAdminCanControlQuotaAndWithdraw() public { + uint256 budget = 100; + token.transfer(address(payment), budget); + + uint256 quota = payment.quota(); + uint256 period = payment.period(); + + vm.startPrank(oracle); + vm.expectRevert_AccessControlUnauthorizedAccount(oracle, payment.DEFAULT_ADMIN_ROLE()); + payment.setQuota(quota * 2); + vm.expectRevert_AccessControlUnauthorizedAccount(oracle, payment.DEFAULT_ADMIN_ROLE()); + payment.setPeriod(period + 1 seconds); + vm.expectRevert_AccessControlUnauthorizedAccount(oracle, payment.DEFAULT_ADMIN_ROLE()); + payment.withdraw(user, budget); + vm.stopPrank(); + + vm.startPrank(user); + vm.expectRevert_AccessControlUnauthorizedAccount(user, payment.DEFAULT_ADMIN_ROLE()); + payment.setQuota(quota * 2); + vm.expectRevert_AccessControlUnauthorizedAccount(user, payment.DEFAULT_ADMIN_ROLE()); + payment.setPeriod(period + 1 seconds); + vm.expectRevert_AccessControlUnauthorizedAccount(user, payment.DEFAULT_ADMIN_ROLE()); + payment.withdraw(user, budget); + vm.stopPrank(); + + vm.startPrank(admin); + payment.setQuota(quota * 2); + assertEq(payment.quota(), quota * 2); + payment.setPeriod(period + 1 seconds); + assertEq(payment.period(), period + 1 seconds); + payment.withdraw(user, budget); + assertEq(token.balanceOf(user), budget); + + // restore the original settings + payment.setQuota(quota); + payment.setPeriod(period); + vm.stopPrank(); + } + + function test_onlyOracleCanPay() public { + uint256 budget = 100; + token.transfer(address(payment), budget); + + assertEq(token.balanceOf(user), 0); + + address[] memory payees = new address[](1); + payees[0] = user; + vm.startPrank(user); + vm.expectRevert_AccessControlUnauthorizedAccount(user, payment.ORACLE_ROLE()); + payment.pay(payees, budget); + vm.stopPrank(); + + assertEq(token.balanceOf(user), 0); + + vm.prank(oracle); + payment.pay(payees, budget); + + assertEq(token.balanceOf(user), budget); + } + + function test_goingOverBudgetRevertsEarly() public { + uint256 budget = 150; + token.transfer(address(payment), budget); + + address[] memory payees = new address[](3); + payees[0] = user; + payees[1] = user; + payees[2] = user; + + vm.prank(oracle); + vm.expectRevert(abi.encodeWithSelector(Payment.InsufficientBalance.selector, budget, budget * 3)); + payment.pay(payees, budget); + } + + function test_goingOverQuotaReverts() public { + uint256 quota = payment.quota(); + uint256 budget = quota * 2; + token.transfer(address(payment), budget); + + address[] memory payees = new address[](1); + payees[0] = user; + + vm.prank(oracle); + vm.expectRevert(QuotaControl.QuotaExceeded.selector); + payment.pay(payees, quota + 1); + } + + function test_usedQuotaAccumulates() public { + uint256 quota = payment.quota(); + uint256 budget = quota * 2; + token.transfer(address(payment), budget); + + address[] memory payees = new address[](1); + payees[0] = user; + + assertEq(payment.claimed(), 0); + vm.startPrank(oracle); + payment.pay(payees, quota / 2); + assertEq(payment.claimed(), quota / 2); + payment.pay(payees, quota / 2); + assertEq(payment.claimed(), quota); + + vm.expectRevert(QuotaControl.QuotaExceeded.selector); + payment.pay(payees, 1); + vm.stopPrank(); + } + + function test_quotaIsRenewedAfterEnoughTime() public { + uint256 quota = payment.quota(); + uint256 budget = quota * 2; + token.transfer(address(payment), budget); + + address[] memory payees = new address[](1); + payees[0] = user; + + vm.startPrank(oracle); + payment.pay(payees, quota); + assertEq(payment.claimed(), quota); + + vm.expectRevert(QuotaControl.QuotaExceeded.selector); + payment.pay(payees, 1); + + uint256 upcomingRenewal = payment.quotaRenewalTimestamp(); + vm.warp(upcomingRenewal + 1 seconds); + + payment.pay(payees, quota); + vm.stopPrank(); + } +} diff --git a/test/QuotaControl.t.sol b/test/QuotaControl.t.sol new file mode 100644 index 0000000..0601029 --- /dev/null +++ b/test/QuotaControl.t.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "../src/QuotaControl.sol"; +import "./__helpers__/AccessControlUtils.sol"; + +contract TestableQuotaControl is QuotaControl { + constructor(uint256 initialQuota, uint256 initialPeriod, address admin) + QuotaControl(initialQuota, initialPeriod, admin) + {} + + function exposeCheckedResetClaimed() external { + _checkedResetClaimed(); + } + + function exposeCheckedUpdateClaimed(uint256 amount) external { + _checkedUpdateClaimed(amount); + } +} + +contract QuotaControlTest is Test { + using AccessControlUtils for Vm; + + address admin; + TestableQuotaControl quotaControl; + + uint256 constant RENEWAL_PERIOD = 1 days; + + function setUp() public { + admin = address(1); + quotaControl = new TestableQuotaControl(1000, RENEWAL_PERIOD, admin); + } + + function test_setQuota() public { + assertEq(quotaControl.quota(), 1000); + vm.prank(admin); + vm.expectEmit(); + emit QuotaControl.QuotaSet(2000); + quotaControl.setQuota(2000); + assertEq(quotaControl.quota(), 2000); + } + + function test_setPeriod() public { + assertEq(quotaControl.period(), RENEWAL_PERIOD); + vm.startPrank(admin); + vm.expectEmit(); + emit QuotaControl.PeriodSet(2 * RENEWAL_PERIOD); + quotaControl.setPeriod(2 * RENEWAL_PERIOD); + assertEq(quotaControl.period(), 2 * RENEWAL_PERIOD); + quotaControl.setPeriod(RENEWAL_PERIOD); + vm.stopPrank(); + } + + function test_setPeriodOutOfRange() public { + vm.startPrank(admin); + vm.expectRevert(QuotaControl.ZeroPeriod.selector); + quotaControl.setPeriod(0); + uint256 tooLongPeriod = quotaControl.MAX_PERIOD() + 1; + vm.expectRevert(QuotaControl.TooLongPeriod.selector); + quotaControl.setPeriod(tooLongPeriod); + vm.stopPrank(); + } + + function test_setPeriodUnauthorized() public { + address bob = address(3); + vm.expectRevert_AccessControlUnauthorizedAccount(bob, quotaControl.DEFAULT_ADMIN_ROLE()); + vm.prank(bob); + quotaControl.setPeriod(2 * RENEWAL_PERIOD); + } + + function test_setQuotaUnauthorized() public { + address bob = address(3); + vm.expectRevert_AccessControlUnauthorizedAccount(bob, quotaControl.DEFAULT_ADMIN_ROLE()); + vm.prank(bob); + quotaControl.setQuota(2000); + } + + function test_rewardsClaimedResetsOnNewPeriod() public { + uint256 upcomingRenewal = quotaControl.quotaRenewalTimestamp(); + + vm.warp(upcomingRenewal + 1 seconds); + + quotaControl.exposeCheckedResetClaimed(); + assertEq(quotaControl.claimed(), 0); + quotaControl.exposeCheckedUpdateClaimed(100); + assertEq(quotaControl.claimed(), 100); + uint256 nextRenewal = RENEWAL_PERIOD + upcomingRenewal; + assertEq(quotaControl.quotaRenewalTimestamp(), nextRenewal); + + vm.warp(nextRenewal + 1 seconds); + + quotaControl.exposeCheckedResetClaimed(); + assertEq(quotaControl.claimed(), 0); + } + + function test_rewardsClaimedAccumulates() public { + uint256 renewalTimeStamp = quotaControl.quotaRenewalTimestamp(); + + vm.warp(renewalTimeStamp + 1 seconds); + + quotaControl.exposeCheckedResetClaimed(); + assertEq(quotaControl.claimed(), 0); + quotaControl.exposeCheckedUpdateClaimed(100); + assertEq(quotaControl.claimed(), 100); + quotaControl.exposeCheckedUpdateClaimed(50); + assertEq(quotaControl.claimed(), 150); + } + + function test_claimFailsToExceedQuota() public { + uint256 renewalTimeStamp = quotaControl.quotaRenewalTimestamp(); + + vm.warp(renewalTimeStamp + 1 seconds); + quotaControl.exposeCheckedResetClaimed(); + assertEq(quotaControl.claimed(), 0); + + uint256 quota = quotaControl.quota(); + vm.expectRevert(QuotaControl.QuotaExceeded.selector); + quotaControl.exposeCheckedUpdateClaimed(quota + 1); + } +} diff --git a/test/Rewards.t.sol b/test/Rewards.t.sol index f9a1656..e5117b6 100644 --- a/test/Rewards.t.sol +++ b/test/Rewards.t.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.23; import "forge-std/Test.sol"; import "forge-std/console.sol"; import "../src/NODL.sol"; +import "../src/QuotaControl.sol"; import "../src/Rewards.sol"; import "openzeppelin-contracts/contracts/utils/cryptography/MessageHashUtils.sol"; import "./__helpers__/AccessControlUtils.sol"; @@ -41,7 +42,7 @@ contract RewardsTest is Test { // Set the new quota vm.prank(alice); vm.expectEmit(); - emit Rewards.QuotaSet(2000); + emit QuotaControl.QuotaSet(2000); rewards.setQuota(2000); // Check new quota @@ -51,17 +52,17 @@ contract RewardsTest is Test { function test_setPeriod() public { assertEq(rewards.period(), RENEWAL_PERIOD); vm.expectEmit(); - emit Rewards.PeriodSet(2 * RENEWAL_PERIOD); + emit QuotaControl.PeriodSet(2 * RENEWAL_PERIOD); rewards.setPeriod(2 * RENEWAL_PERIOD); assertEq(rewards.period(), 2 * RENEWAL_PERIOD); rewards.setPeriod(RENEWAL_PERIOD); } function test_setPeriodOutOfRange() public { - vm.expectRevert(Rewards.ZeroPeriod.selector); + vm.expectRevert(QuotaControl.ZeroPeriod.selector); rewards.setPeriod(0); uint256 tooLongPeriod = rewards.MAX_PERIOD() + 1; - vm.expectRevert(Rewards.TooLongPeriod.selector); + vm.expectRevert(QuotaControl.TooLongPeriod.selector); rewards.setPeriod(tooLongPeriod); } @@ -99,7 +100,7 @@ contract RewardsTest is Test { bytes memory signature = createSignature(reward, oraclePrivateKey); // Expect the quota to be exceeded - vm.expectRevert(Rewards.QuotaExceeded.selector); + vm.expectRevert(QuotaControl.QuotaExceeded.selector); rewards.mintReward(reward, signature); } @@ -201,7 +202,7 @@ contract RewardsTest is Test { bytes memory signature = createBatchSignature(rewardsBatch, oraclePrivateKey); - vm.expectRevert(Rewards.QuotaExceeded.selector); + vm.expectRevert(QuotaControl.QuotaExceeded.selector); rewards.mintBatchReward(rewardsBatch, signature); }