Skip to content

Commit

Permalink
feat(Rewards): Implement solo claims (#31)
Browse files Browse the repository at this point in the history
  • Loading branch information
aliXsed authored Jun 14, 2024
1 parent be3380f commit 1c7e5ec
Show file tree
Hide file tree
Showing 4 changed files with 411 additions and 40 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@types/chai": "^4.3.4",
"@types/mocha": "^10.0.1",
"@types/node": "^20.12.12",
"chai": "^5.1.1",
"chai": "^4.4.1",
"dotenv": "^16.4.5",
"ethers": "^6.12.1",
"hardhat": "^2.22.4",
Expand Down
223 changes: 223 additions & 0 deletions src/Rewards.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;

import {NODL} from "./NODL.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";
import {ECDSA} from "openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol";
import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol";

/**
* @title Nodle DePIN Rewards
* @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 {
using Math for uint256;

/**
* @dev The signing domain used for generating signatures.
*/
string public constant SIGNING_DOMAIN = "rewards.depin.nodle";

/**
* @dev The version of the signature scheme used.
*/
string public constant SIGNATURE_VERSION = "1";

/**
* @dev This constant defines the reward type.
* This should be kept consistent with the Reward struct.
*/
bytes public constant REWARD_TYPE = "Reward(address recipient,uint256 amount,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 Reference to the NODL token contract.
*/
NODL public nodlToken;

/**
* @dev Maximum amount of rewards that can be distributed in a period.
*/
uint256 public rewardQuota;

/**
* @dev Duration of each reward period.
*/
uint256 public rewardPeriod;

/**
* @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 rewardsClaimed;

/**
* @dev Address of the authorized oracle.
*/
address public authorizedOracle;

/**
* @dev Mapping to store reward sequences for each recipient to prevent replay attacks.
*/
mapping(address => uint256) public rewardSequences;

/**
* @dev Struct on which basis an individual reward must be issued.
*/
struct Reward {
address recipient;
uint256 amount;
uint256 sequence;
}

/**
* @dev Error when the reward quota is exceeded.
*/
error RewardQuotaExceeded();

/**
* @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 likley due to the period being too long.
*/
error TooLongPeriod();

/**
* @dev Error when the reward is not from the authorized oracle.
*/
error UnauthorizedOracle();

/**
* @dev Error when the recipient's reward sequence does not match.
*/
error InvalidRecipientSequence();

/**
* @dev Event emitted when the reward quota is set.
*/
event RewardQuotaSet(uint256 quota);

/**
* @dev Event emitted when a reward is minted.
*/
event RewardMinted(address indexed recipient, uint256 amount, uint256 totalRewardsClaimed);

/**
* @dev Initializes the contract with the specified parameters.
* @param token Address of the NODL token contract.
* @param initialQuota Initial reward quota.
* @param initialPeriod Initial reward period.
* @param oracleAddress Address of the authorized oracle.
*/
constructor(NODL token, uint256 initialQuota, uint256 initialPeriod, address oracleAddress)
EIP712(SIGNING_DOMAIN, SIGNATURE_VERSION)
{
// This is to avoid the ongoinb overhead of safe math operations
if (initialPeriod == 0) {
revert ZeroPeriod();
}
// This is to prevent overflows while avoiding the ongoing overhead of safe math operations
if (initialPeriod > MAX_PERIOD) {
revert TooLongPeriod();
}

_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);

nodlToken = token;
rewardQuota = initialQuota;
rewardPeriod = initialPeriod;
quotaRenewalTimestamp = block.timestamp + rewardPeriod;
authorizedOracle = oracleAddress;
}

/**
* @dev Sets the reward quota. Only accounts with the QUOTA_SETTER_ROLE can call this function.
* @param newQuota The new reward quota.
*/
function setRewardQuota(uint256 newQuota) external {
_checkRole(DEFAULT_ADMIN_ROLE);
rewardQuota = newQuota;
emit RewardQuotaSet(newQuota);
}

/**
* @dev Mints rewards to the recipient if the signature is valid and quota is not exceeded.
* @param reward The reward details.
* @param signature The signature from the authorized oracle.
*/
function mintReward(Reward memory reward, bytes memory signature) external {
_mustBeFromAuthorizedOracle(digestReward(reward), signature);

_mustBeExpectedSequence(reward.recipient, reward.sequence);

if (block.timestamp >= quotaRenewalTimestamp) {
rewardsClaimed = 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 + rewardPeriod - (timeAhead % rewardPeriod);
}

(bool sucess, uint256 newRewardsClaimed) = rewardsClaimed.tryAdd(reward.amount);
if (!sucess || newRewardsClaimed > rewardQuota) {
revert RewardQuotaExceeded();
}
rewardsClaimed = newRewardsClaimed;

// Safe to increment the sequence after checking this is the expected number (no overflow for the age of universe even with 1000 reward claims per second)
rewardSequences[reward.recipient] = reward.sequence + 1;

nodlToken.mint(reward.recipient, reward.amount);

emit RewardMinted(reward.recipient, reward.amount, rewardsClaimed);
}

/**
* @dev Internal check to ensure the `sequence` value is expected for `receipent`.
* @param receipent The address of the receipent to check.
* @param sequence The sequence value.
*/
function _mustBeExpectedSequence(address receipent, uint256 sequence) internal view {
if (rewardSequences[receipent] != sequence) {
revert InvalidRecipientSequence();
}
}

/**
* @dev Checks if the provided signature is valid for the given hash and authorized oracle address.
* @param hash The hash to be verified.
* @param signature The signature to be checked.
* @dev Throws an `UnauthorizedOracle` exception if the signature is not valid.
*/
function _mustBeFromAuthorizedOracle(bytes32 hash, bytes memory signature) internal view {
if (!SignatureChecker.isValidSignatureNow(authorizedOracle, hash, signature)) {
revert UnauthorizedOracle();
}
}

/**
* @dev Helper function to get the digest of the typed data to be signed.
* @param reward detailing recipient, amount, and sequence.
* @return The hash of the typed data.
*/
function digestReward(Reward memory reward) public view returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(keccak256(REWARD_TYPE), reward.recipient, reward.amount, reward.sequence))
);
}
}
Loading

0 comments on commit 1c7e5ec

Please sign in to comment.