Skip to content

Commit

Permalink
feat(Payment): add budget and quota controlling contract for mission …
Browse files Browse the repository at this point in the history
…rewards payments (#63)
  • Loading branch information
aliXsed authored Oct 9, 2024
2 parents 6afd0c7 + 3ee112e commit b6f228f
Show file tree
Hide file tree
Showing 6 changed files with 544 additions and 122 deletions.
86 changes: 86 additions & 0 deletions src/Payment.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
164 changes: 164 additions & 0 deletions src/QuotaControl.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Loading

0 comments on commit b6f228f

Please sign in to comment.