From d43e88af650d846aa870b1ad77075c07f1d88b30 Mon Sep 17 00:00:00 2001 From: Matthew Di Ferrante Date: Tue, 1 Apr 2025 14:59:09 +0200 Subject: [PATCH 1/6] support for submissions based rewards --- src/RewardsDistributor.sol | 7 + src/RewardsDistributorFixed.sol | 7 + src/RewardsDistributorWorkSubmission.sol | 220 +++++++++ ...ewardsDistributorWorkSubmissionFactory.sol | 29 ++ src/SyntheticDataWorkValidator.sol | 18 +- src/interfaces/IRewardsDistributor.sol | 1 + test/PrimeNetwork.t.sol | 3 +- test/RewardsDistributorWorkSubmission.t.sol | 453 ++++++++++++++++++ test/SyntheticDataWorkValidator.t.sol | 36 +- 9 files changed, 753 insertions(+), 21 deletions(-) create mode 100644 src/RewardsDistributorWorkSubmission.sol create mode 100644 src/RewardsDistributorWorkSubmissionFactory.sol create mode 100644 test/RewardsDistributorWorkSubmission.t.sol diff --git a/src/RewardsDistributor.sol b/src/RewardsDistributor.sol index 81a68b4..c0a4f9f 100644 --- a/src/RewardsDistributor.sol +++ b/src/RewardsDistributor.sol @@ -185,4 +185,11 @@ contract RewardsDistributor is IRewardsDistributor, AccessControlEnumerable { _updateGlobalIndex(); endTime = block.timestamp; } + + function submitWork(address node, uint256 workUnits) external pure { + // suppress warnings + node == node; + workUnits == workUnits; + return; + } } diff --git a/src/RewardsDistributorFixed.sol b/src/RewardsDistributorFixed.sol index c171b3a..26264cd 100644 --- a/src/RewardsDistributorFixed.sol +++ b/src/RewardsDistributorFixed.sol @@ -172,4 +172,11 @@ contract RewardsDistributorFixed is IRewardsDistributor, AccessControlEnumerable _updateGlobalIndex(); endTime = block.timestamp; } + + function submitWork(address node, uint256 workUnits) external pure { + // suppress warnings + node == node; + workUnits == workUnits; + return; + } } diff --git a/src/RewardsDistributorWorkSubmission.sol b/src/RewardsDistributorWorkSubmission.sol new file mode 100644 index 0000000..c82fba3 --- /dev/null +++ b/src/RewardsDistributorWorkSubmission.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "./interfaces/IRewardsDistributor.sol"; +import "./interfaces/IComputePool.sol"; +import "./interfaces/IComputeRegistry.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; + +contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlEnumerable { + bytes32 public constant PRIME_ROLE = keccak256("PRIME_ROLE"); + bytes32 public constant FEDERATOR_ROLE = keccak256("FEDERATOR_ROLE"); + bytes32 public constant REWARDS_MANAGER_ROLE = keccak256("REWARDS_MANAGER_ROLE"); + bytes32 public constant COMPUTE_POOL_ROLE = keccak256("COMPUTE_POOL_ROLE"); + + IComputePool public computePool; + IComputeRegistry public computeRegistry; + uint256 public poolId; + IERC20 public rewardToken; // Token to distribute + uint256 rewardRatePerUnit; + uint256 endTime; + + // Ring buffer config for a 24h window, 1h bucket size + // (Adjust as needed: e.g. 12 buckets of 2h each, etc.) + uint256 public constant NUM_BUCKETS = 24; + uint256 public constant BUCKET_DURATION = 3600; // 1 hour + + // Holds ring-buffer data for each node + struct NodeBuckets { + uint256[NUM_BUCKETS] buckets; // Each bucket’s total submissions + uint256 currentBucket; // Index of the active bucket + uint256 lastBucketTimestamp; // Timestamp when we last rolled the bucket + uint256 totalLast24H; // Sum of all buckets + // Optional fields for “locked vs. unlocked” reward logic: + uint256 totalAllSubmissions; // Running total of all-time submissions + uint256 lastClaimed; // Last totalAllSubmissions used in claim + } + + mapping(address => NodeBuckets) private nodeBuckets; + + // -------------------------------------------------------------------------------------------- + // Constructor + // -------------------------------------------------------------------------------------------- + + constructor(IComputePool _computePool, IComputeRegistry _computeRegistry, uint256 _poolId) { + computePool = _computePool; + computeRegistry = _computeRegistry; + poolId = _poolId; + + rewardToken = IERC20(computePool.getRewardToken()); + _grantRole(COMPUTE_POOL_ROLE, address(computePool)); + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + + // By default, grant the REWARDS_MANAGER_ROLE to your Federator + address primeContract = computePool.getRoleMember(PRIME_ROLE, 0); + address federator = IAccessControlEnumerable(primeContract).getRoleMember(FEDERATOR_ROLE, 0); + _grantRole(REWARDS_MANAGER_ROLE, federator); + } + + // -------------------------------------------------------------------------------------------- + // Per-node ring buffer rolling + // -------------------------------------------------------------------------------------------- + + function _rollBuckets(address node) internal { + NodeBuckets storage nb = nodeBuckets[node]; + uint256 elapsed = (block.timestamp - nb.lastBucketTimestamp) / BUCKET_DURATION; + if (elapsed > 0) { + // If more than 24h has passed, reset everything + if (elapsed >= NUM_BUCKETS) { + for (uint256 i = 0; i < NUM_BUCKETS; i++) { + nb.buckets[i] = 0; + } + nb.currentBucket = 0; + nb.totalLast24H = 0; + } else { + // Advance the ring buffer by 'elapsed' buckets + for (uint256 i = 0; i < elapsed; i++) { + nb.currentBucket = (nb.currentBucket + 1) % NUM_BUCKETS; + // Subtract the old bucket from total, then zero it + nb.totalLast24H -= nb.buckets[nb.currentBucket]; + nb.buckets[nb.currentBucket] = 0; + } + } + // Snap lastBucketTimestamp forward by however many full buckets elapsed + nb.lastBucketTimestamp += elapsed * BUCKET_DURATION; + } + } + + // -------------------------------------------------------------------------------------------- + // Submission + // -------------------------------------------------------------------------------------------- + + /// @notice Called by the pool to record that `node` performed `workUnits`. + /// This increments the node’s current bucket, ensuring O(1) ring buffer updates. + function submitWork(address node, uint256 workUnits) external onlyRole(COMPUTE_POOL_ROLE) { + require(endTime == 0, "Rewards have ended"); + require(computePool.isNodeInPool(poolId, node), "Node not in pool"); + require(workUnits > 0, "Work units must be positive"); + + NodeBuckets storage nb = nodeBuckets[node]; + // Roll forward first to ensure we’re in the correct active bucket + _rollBuckets(node); + + // Increment the current bucket + nb.buckets[nb.currentBucket] += workUnits; + nb.totalLast24H += workUnits; + + // Track an all-time total if you want to do “locked/unlocked” logic + nb.totalAllSubmissions += workUnits; + + // Optionally, ensure lastBucketTimestamp is set if first time + if (nb.lastBucketTimestamp == 0) { + nb.lastBucketTimestamp = block.timestamp; + } + } + + // -------------------------------------------------------------------------------------------- + // Example "Locked for 24h" Reward Logic + // -------------------------------------------------------------------------------------------- + + /** + * @notice Example approach: + * - totalAllSubmissions: total submissions ever done by this node. + * - totalLast24H: the sum of the ring buffer’s most recent 24h. + * We treat that as “locked.” + * - The difference (totalAllSubmissions - totalLast24H) is “unlocked.” + * - We track lastClaimed to ensure we only pay incremental amounts. + */ + function claimRewards(address node) external { + require(rewardRatePerUnit != 0, "Rate not set"); + require(msg.sender == computeRegistry.getNodeProvider(node), "Unauthorized"); + + _rollBuckets(node); + + NodeBuckets storage nb = nodeBuckets[node]; + + uint256 unlockedNow = nb.totalAllSubmissions - nb.totalLast24H; + uint256 claimable = unlockedNow - nb.lastClaimed; + if (claimable == 0) { + return; // nothing to claim + } + nb.lastClaimed = unlockedNow; + + uint256 tokensToSend = claimable * rewardRatePerUnit; + require(tokensToSend <= rewardToken.balanceOf(address(this)), "Insufficient tokens"); + + rewardToken.transfer(node, tokensToSend); + } + + // -------------------------------------------------------------------------------------------- + // Optional informational views + // -------------------------------------------------------------------------------------------- + + function calculateRewards(address node) external view returns (uint256) { + require(rewardRatePerUnit != 0, "Rate not set"); + + NodeBuckets memory nb = nodeBuckets[node]; + + // Simulate the ring buffer if updated “now” + uint256 elapsed = (block.timestamp - nb.lastBucketTimestamp) / BUCKET_DURATION; + uint256 simulatedTotalLast24H = nb.totalLast24H; + if (elapsed >= NUM_BUCKETS) { + simulatedTotalLast24H = 0; // older than 24h + } else if (elapsed > 0) { + // Subtract out each elapsed bucket + // (This is only an approximate view—no state changes here.) + // Safe to loop up to `elapsed` because `elapsed` < NUM_BUCKETS. + uint256 idx = nb.currentBucket; + for (uint256 i = 0; i < elapsed; i++) { + idx = (idx + 1) % NUM_BUCKETS; + simulatedTotalLast24H -= nb.buckets[idx]; + } + } + // “Unlocked so far” if we hypothetically updated now + uint256 unlockedNow = nb.totalAllSubmissions - simulatedTotalLast24H; + uint256 claimable = unlockedNow - nb.lastClaimed; + return claimable * rewardRatePerUnit; + } + + function nodeInfo(address node) + external + view + returns (uint256 last24H, uint256 totalAll, uint256 lastClaimed_, bool isActive) + { + NodeBuckets storage nb = nodeBuckets[node]; + last24H = nb.totalLast24H; + totalAll = nb.totalAllSubmissions; + lastClaimed_ = nb.lastClaimed; + isActive = computePool.isNodeInPool(poolId, node); + } + + // -------------------------------------------------------------------------------------------- + // The following methods are left for compatibility with your existing setup. + // You can remove or adapt them to your new ring-buffer logic as needed. + // -------------------------------------------------------------------------------------------- + + function setRewardRate(uint256 newRate) external onlyRole(REWARDS_MANAGER_ROLE) { + require(rewardRatePerUnit == 0, "Rate can only be set once"); + rewardRatePerUnit = newRate; + } + + function joinPool(address node) external onlyRole(COMPUTE_POOL_ROLE) { + // If you require special logic on node join, do it here. + // E.g., initialize the node’s lastBucketTimestamp if needed. + if (nodeBuckets[node].lastBucketTimestamp == 0) { + nodeBuckets[node].lastBucketTimestamp = block.timestamp; + } + } + + function leavePool(address node) external onlyRole(COMPUTE_POOL_ROLE) { + // Optionally roll + finalize the node’s data. You might zero out buckets, etc. + _rollBuckets(node); + } + + function endRewards() external onlyRole(COMPUTE_POOL_ROLE) { + // If you want to freeze further submissions, do so here. + require(endTime == 0, "Already ended"); + endTime = block.timestamp; + } +} diff --git a/src/RewardsDistributorWorkSubmissionFactory.sol b/src/RewardsDistributorWorkSubmissionFactory.sol new file mode 100644 index 0000000..33d8d74 --- /dev/null +++ b/src/RewardsDistributorWorkSubmissionFactory.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "./RewardsDistributorWorkSubmission.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "./interfaces/IRewardsDistributorFactory.sol"; + +contract RewardsDistributorWorkSubmissionFactory is AccessControl, IRewardsDistributorFactory { + bytes32 public constant REWARD_CREATOR = keccak256("REWARD_CREATOR"); + IComputePool computePool; + + constructor() { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(REWARD_CREATOR, msg.sender); + } + + function setComputePool(IComputePool _computePool) external onlyRole(DEFAULT_ADMIN_ROLE) { + _grantRole(REWARD_CREATOR, address(_computePool)); + computePool = _computePool; + } + + function createRewardsDistributor(IComputeRegistry _computeRegistry, uint256 _poolId) + external + onlyRole(REWARD_CREATOR) + returns (IRewardsDistributor) + { + return new RewardsDistributorWorkSubmission(computePool, _computeRegistry, _poolId); + } +} diff --git a/src/SyntheticDataWorkValidator.sol b/src/SyntheticDataWorkValidator.sol index 0dc4bee..f2ce90f 100644 --- a/src/SyntheticDataWorkValidator.sol +++ b/src/SyntheticDataWorkValidator.sol @@ -4,9 +4,9 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import "./interfaces/IWorkValidation.sol"; -event WorkSubmitted(uint256 poolId, address provider, address nodeId, bytes32 workKey); +event WorkSubmitted(uint256 poolId, address provider, address nodeId, bytes32 workKey, uint256 workUnits); -event WorkInvalidated(uint256 poolId, address provider, address nodeId, bytes32 workKey); +event WorkInvalidated(uint256 poolId, address provider, address nodeId, bytes32 workKey, uint256 workUnits); contract SyntheticDataWorkValidator is IWorkValidation { using EnumerableSet for EnumerableSet.Bytes32Set; @@ -14,6 +14,7 @@ contract SyntheticDataWorkValidator is IWorkValidation { uint256 domainId; address computePool; uint256 workValidityPeriod = 1 days; + uint256 constant MAX_WORK_UNITS = 1000; struct WorkState { EnumerableSet.Bytes32Set workKeys; @@ -25,6 +26,7 @@ contract SyntheticDataWorkValidator is IWorkValidation { address provider; address nodeId; uint64 timestamp; + uint256 workUnits; } mapping(uint256 => WorkState) poolWork; @@ -40,18 +42,22 @@ contract SyntheticDataWorkValidator is IWorkValidation { returns (bool) { require(msg.sender == computePool, "Unauthorized"); - require(data.length >= 32, "Data too short"); + require(data.length >= 64, "Data too short"); require(domainId == _domainId, "Invalid domainId"); bytes32 workKey; + uint256 workUnits = 0; assembly { workKey := calldataload(data.offset) + workUnits := calldataload(add(data.offset, 32)) } require(!poolWork[poolId].workKeys.contains(workKey), "Work already submitted"); + require(!poolWork[poolId].invalidWorkKeys.contains(workKey), "Work already invalidated"); + require(workUnits > 0 && workUnits <= MAX_WORK_UNITS, "Invalid work units"); poolWork[poolId].workKeys.add(workKey); - poolWork[poolId].work[workKey] = WorkInfo(provider, nodeId, uint64(block.timestamp)); + poolWork[poolId].work[workKey] = WorkInfo(provider, nodeId, uint64(block.timestamp), workUnits); - emit WorkSubmitted(poolId, provider, nodeId, workKey); + emit WorkSubmitted(poolId, provider, nodeId, workKey, workUnits); return true; } @@ -74,7 +80,7 @@ contract SyntheticDataWorkValidator is IWorkValidation { WorkInfo memory info = poolWork[poolId].work[workKey]; poolWork[poolId].workKeys.remove(workKey); - emit WorkInvalidated(poolId, info.provider, info.nodeId, workKey); + emit WorkInvalidated(poolId, info.provider, info.nodeId, workKey, info.workUnits); return (info.provider, info.nodeId); } diff --git a/src/interfaces/IRewardsDistributor.sol b/src/interfaces/IRewardsDistributor.sol index e451262..4f88613 100644 --- a/src/interfaces/IRewardsDistributor.sol +++ b/src/interfaces/IRewardsDistributor.sol @@ -12,4 +12,5 @@ interface IRewardsDistributor { function endRewards() external; function joinPool(address node) external; function leavePool(address node) external; + function submitWork(address node, uint256 workUnits) external; } diff --git a/test/PrimeNetwork.t.sol b/test/PrimeNetwork.t.sol index 267f632..220cfb5 100644 --- a/test/PrimeNetwork.t.sol +++ b/test/PrimeNetwork.t.sol @@ -752,6 +752,7 @@ contract PrimeNetworkTest is Test { uint256 nodeComputeUnits = 1000; bytes memory work_id = hex"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + uint256 workUnits = 10; startPool(pool); @@ -769,7 +770,7 @@ contract PrimeNetworkTest is Test { validateNode(provider_good1, node_good1); nodeJoin(domain, pool, provider_good1, node_good1); - computePool.submitWork(pool, node_good1, work_id); + computePool.submitWork(pool, node_good1, abi.encodePacked(work_id, workUnits)); // slash provider uint256 stake = stakeManager.getStake(provider_good1); diff --git a/test/RewardsDistributorWorkSubmission.t.sol b/test/RewardsDistributorWorkSubmission.t.sol new file mode 100644 index 0000000..f7b0e76 --- /dev/null +++ b/test/RewardsDistributorWorkSubmission.t.sol @@ -0,0 +1,453 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/RewardsDistributorWorkSubmission.sol"; +import "../src/interfaces/IComputePool.sol"; +import "../src/interfaces/IComputeRegistry.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "../src/interfaces/IRewardsDistributor.sol"; + +contract MockERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockComputePool { + bytes32 public constant PRIME_ROLE = keccak256("PRIME_ROLE"); + bytes32 public constant FEDERATOR_ROLE = keccak256("FEDERATOR_ROLE"); + + address public rewardToken; + IRewardsDistributor public distributor; + MockComputeRegistry public computeRegistry; + mapping(address => bool) public nodes; + uint256 public poolId; + uint256 public totalCompute; + + constructor(address _rewardToken, uint256 _poolId, MockComputeRegistry _computeRegistry) { + rewardToken = _rewardToken; + poolId = _poolId; + computeRegistry = _computeRegistry; + } + + function getRewardToken() external view returns (address) { + return rewardToken; + } + + function getRoleMember(bytes32 role, uint256 /*index*/ ) external view returns (address) { + // For simplicity, return this contract if PRIME_ROLE or FEDERATOR_ROLE + if (role == PRIME_ROLE || role == FEDERATOR_ROLE) { + return address(this); + } + // else + return address(0); + } + + function isNodeInPool(uint256 _poolId, address node) external view returns (bool) { + require(_poolId == poolId, "Wrong poolId"); + return nodes[node]; + } + + function joinComputePool(address node, uint256 cu) external { + if (nodes[node]) { + revert("Node already active"); + } + nodes[node] = true; + computeRegistry.setNodeComputeUnits(node, cu); + distributor.joinPool(node); + totalCompute += cu; + } + + function leaveComputePool(address node) external { + require(nodes[node], "Node not active"); + nodes[node] = false; + distributor.leavePool(node); + totalCompute -= computeRegistry.getNodeComputeUnits(node); + } + + function setDistributorContract(IRewardsDistributor _distributor) external { + distributor = _distributor; + } + + function getComputePoolTotalCompute(uint256 _poolId) external view returns (uint256) { + require(_poolId == poolId, "Wrong poolId"); + return totalCompute; + } +} + +contract MockComputeRegistry { + mapping(address => address) public nodeProviderMap; + mapping(address => uint256) public nodeComputeUnits; + + function setNodeProvider(address node, address provider) external { + nodeProviderMap[node] = provider; + } + + function getNodeProvider(address node) external view returns (address) { + return nodeProviderMap[node]; + } + + function setNodeComputeUnits(address node, uint256 cu) external { + nodeComputeUnits[node] = cu; + } + + function getNodeComputeUnits(address node) external view returns (uint256) { + return nodeComputeUnits[node]; + } +} + +contract RewardsDistributorWorkSubmissionRingBufferTest is Test { + // Contracts + RewardsDistributorWorkSubmission public distributor; + MockComputePool public mockComputePool; + MockComputeRegistry public mockComputeRegistry; + MockERC20 public mockRewardToken; + + // Test addresses + address public manager = address(0x1); // granted REWARDS_MANAGER_ROLE + address public computePoolAddress = address(0x2); + address public nodeProvider = address(0x3); + address public node = address(0x4); + + // Additional nodes/providers + address public node1 = address(0x5); + address public node2 = address(0x6); + address public nodeProvider1 = address(0x7); + address public nodeProvider2 = address(0x8); + + // Helper: Ring buffer settings from the distributor + // Adjust if you changed them in the ring-buffer contract + uint256 public constant NUM_BUCKETS = 24; + uint256 public constant BUCKET_DURATION = 3600; // 1 hour + + function setUp() public { + // Deploy and mint tokens + mockRewardToken = new MockERC20("MockToken", "MTK"); + mockRewardToken.mint(address(this), 1_000_000 ether); + + // Setup mocks + mockComputeRegistry = new MockComputeRegistry(); + mockComputePool = new MockComputePool(address(mockRewardToken), 1, mockComputeRegistry); + + // Deploy the ring-buffer version + distributor = new RewardsDistributorWorkSubmission( + IComputePool(address(mockComputePool)), IComputeRegistry(address(mockComputeRegistry)), 1 + ); + + // Grant roles + distributor.grantRole(distributor.REWARDS_MANAGER_ROLE(), manager); + + // Transfer tokens to the distributor so it can pay out claims + mockRewardToken.transfer(address(distributor), 500_000 ether); + + // Setup registry mappings + mockComputeRegistry.setNodeProvider(node, nodeProvider); + mockComputeRegistry.setNodeProvider(node1, nodeProvider1); + mockComputeRegistry.setNodeProvider(node2, nodeProvider2); + + // Link the distributor to the pool + mockComputePool.setDistributorContract(distributor); + + vm.prank(manager); + distributor.setRewardRate(1); // Set a reward rate for testing + } + + // ----------------------------------------------------------------------- + // Test: joinPool + // ----------------------------------------------------------------------- + function testJoinPool() public { + (uint256 last24H, uint256 totalAll,, bool isActive) = distributor.nodeInfo(node); + assertEq(last24H, 0); + assertEq(totalAll, 0); + assertFalse(isActive); + + // Join the pool + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node, 10); + + // After join + (last24H, totalAll,, isActive) = distributor.nodeInfo(node); + assertEq(last24H, 0); + assertEq(totalAll, 0); + assertTrue(isActive); + + // Attempt to join again should revert + vm.expectRevert("Node already active"); + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node, 10); + } + + // ----------------------------------------------------------------------- + // Test: submitWork & ring buffer basics + // ----------------------------------------------------------------------- + function testSubmitWorkAndLocking() public { + // Join with 10 CU, though "CU" isn't directly used for ring buffer anymore. + // (The ring buffer logic just sums submissions. You can still track CU if desired.) + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node, 10); + + // Initially, node's ring buffer is empty + (uint256 last24H, uint256 totalAll,,) = distributor.nodeInfo(node); + assertEq(last24H, 0); + assertEq(totalAll, 0); + + // Submit some work + vm.prank(address(mockComputePool)); + distributor.submitWork(node, 1000); + + // Now totalLast24H = 1000, totalAllSubmissions = 1000 + (last24H, totalAll,,) = distributor.nodeInfo(node); + assertEq(last24H, 1000); + assertEq(totalAll, 1000); + + // The ring buffer locks the last 24h, so if we claim now, we get 0 (all locked). + uint256 calcBefore = distributor.calculateRewards(node); + assertEq(calcBefore, 0, "Should be zero because it's < 24h old"); + + // Move forward 12 hours + skip(12 hours); + // The 1000 is still within 24h, so locked + assertEq(distributor.calculateRewards(node), 0); + + // Move forward another 13 hours (total 25h) + // That should push the first 1000 submission outside the 24h window + skip(13 hours); + // Now it's been 25h, so the 1000 is fully unlocked. + // The ring buffer automatically resets that bucket on the next call or view. + uint256 calcAfter = distributor.calculateRewards(node); + // We expect 1000 unlocked + assertEq(calcAfter, 1000); + + // Claim should pay out the 1000 + vm.prank(nodeProvider); + distributor.claimRewards(node); + (,, uint256 lastClaimed,) = distributor.nodeInfo(node); + assertEq(lastClaimed, 1000, "lastClaimed should be 1000 now"); + + // The node receives the tokens + assertEq(mockRewardToken.balanceOf(node), 1000, "node's token balance mismatch"); + } + + // ----------------------------------------------------------------------- + // Test: multiple submissions within 24h + // ----------------------------------------------------------------------- + function testMultipleSubmissionsWithin24h() public { + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node, 10); + + // Submit 1000 at t=0 + vm.prank(address(mockComputePool)); + distributor.submitWork(node, 1000); + + // Skip 2 hours, submit another 500 + skip(2 hours); + vm.prank(address(mockComputePool)); + distributor.submitWork(node, 500); + + // Both submissions are within 24h => locked + uint256 unlocked = distributor.calculateRewards(node); + assertEq(unlocked, 0, "All should be locked still (24h not passed)"); + + // Skip 23 more hours => total 25h from the first submission, 23h from the second + skip(23 hours); + // Now the first 1000 is older than 24h => unlocked + // The second 500 is at 25-2=23h old => still locked + unlocked = distributor.calculateRewards(node); + assertEq(unlocked, 1000, "First submission unlocked, second still locked"); + + // Claim + vm.prank(nodeProvider); + distributor.claimRewards(node); + assertEq(mockRewardToken.balanceOf(node), 1000); + + // Skip 2 more hours => total 25h from the second submission + skip(2 hours); + // Now the 500 is also older than 24h => unlocked + unlocked = distributor.calculateRewards(node); + assertEq(unlocked, 500); + + vm.prank(nodeProvider); + distributor.claimRewards(node); + assertEq(mockRewardToken.balanceOf(node), 1500); + } + + // ----------------------------------------------------------------------- + // Test: large skip that resets ring buffer + // ----------------------------------------------------------------------- + function testResetRingBuffer() public { + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node, 10); + + vm.prank(address(mockComputePool)); + distributor.submitWork(node, 1000); + + // Immediately skip more than 24 hours => entire ring buffer is stale + skip(2 days); // 48 hours + + // The ring buffer will be fully reset for that node upon next submission or roll + // So the old 1000 is definitely unlocked + uint256 unlocked = distributor.calculateRewards(node); + assertEq(unlocked, 1000); + + vm.prank(nodeProvider); + distributor.claimRewards(node); + assertEq(mockRewardToken.balanceOf(node), 1000); + + // Submit again + vm.prank(address(mockComputePool)); + distributor.submitWork(node, 500); + + (uint256 last24H, uint256 totalAll,,) = distributor.nodeInfo(node); + // Last 24h is 500, totalAll is 1500 + assertEq(last24H, 500); + assertEq(totalAll, 1500); + } + + // ----------------------------------------------------------------------- + // Test: multiple nodes + // ----------------------------------------------------------------------- + function testMultipleNodes() public { + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node1, 10); + + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node2, 5); + + // Node1 submits 200 + vm.prank(address(mockComputePool)); + distributor.submitWork(node1, 200); + + // Node2 submits 300 + vm.prank(address(mockComputePool)); + distributor.submitWork(node2, 300); + + // Both are <24h => locked, no one gets anything if we claim + assertEq(distributor.calculateRewards(node1), 0); + assertEq(distributor.calculateRewards(node2), 0); + + skip(25 hours); + // Now both are fully unlocked + uint256 node1Unlocked = distributor.calculateRewards(node1); + uint256 node2Unlocked = distributor.calculateRewards(node2); + + assertEq(node1Unlocked, 200); + assertEq(node2Unlocked, 300); + + vm.prank(nodeProvider1); + distributor.claimRewards(node1); + vm.prank(nodeProvider2); + distributor.claimRewards(node2); + + assertEq(mockRewardToken.balanceOf(node1), 200); + assertEq(mockRewardToken.balanceOf(node2), 300); + } + + // ----------------------------------------------------------------------- + // Test: partial locked/unlocked for multiple nodes + // ----------------------------------------------------------------------- + function testPartialLockMultipleNodes() public { + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node1, 10); + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node2, 5); + + // Node1 submits 100 at t=0 + vm.prank(address(mockComputePool)); + distributor.submitWork(node1, 100); + + // Node2 submits 50 at t=0 + vm.prank(address(mockComputePool)); + distributor.submitWork(node2, 50); + + // Skip 12h + skip(12 hours); + // Node1 submits another 100 at t=12h + vm.prank(address(mockComputePool)); + distributor.submitWork(node1, 100); + + // Node2 has no new submissions + // Skip another 13h => total t=25h from first submission + skip(13 hours); + + // At t=25h: + // Node1's first 100 is unlocked, second 100 is 13h old => locked + // Node2's 50 is 25h old => unlocked + uint256 node1Unlocked = distributor.calculateRewards(node1); + uint256 node2Unlocked = distributor.calculateRewards(node2); + + assertEq(node1Unlocked, 100, "Node1 only the first 100 is unlocked"); + assertEq(node2Unlocked, 50, "Node2's entire 50 is unlocked"); + + // Claim for both + vm.prank(nodeProvider1); + distributor.claimRewards(node1); + vm.prank(nodeProvider2); + distributor.claimRewards(node2); + + assertEq(mockRewardToken.balanceOf(node1), 100); + assertEq(mockRewardToken.balanceOf(node2), 50); + + // Move ahead another 12 hours => t=37h from the second submission for Node1 + skip(12 hours); + + // Now Node1's second 100 is also >24h => unlocked + node1Unlocked = distributor.calculateRewards(node1); + assertEq(node1Unlocked, 100); + + vm.prank(nodeProvider1); + distributor.claimRewards(node1); + assertEq(mockRewardToken.balanceOf(node1), 200); + } + + // ----------------------------------------------------------------------- + // Test: leavePool (no special logic, but ensures ring buffer not affected) + // ----------------------------------------------------------------------- + function testLeavePool() public { + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node, 10); + + vm.prank(address(mockComputePool)); + distributor.submitWork(node, 500); + + (uint256 last24HBefore, uint256 totalAllBefore,, bool isActiveBefore) = distributor.nodeInfo(node); + assertEq(last24HBefore, 500); + assertEq(totalAllBefore, 500); + assertTrue(isActiveBefore); + + vm.prank(address(mockComputePool)); + mockComputePool.leaveComputePool(node); + + (uint256 last24HAfter, uint256 totalAllAfter,, bool isActiveAfter) = distributor.nodeInfo(node); + // The ring buffer data remains the same; isActive is false + assertEq(last24HAfter, 500); + assertEq(totalAllAfter, 500); + assertFalse(isActiveAfter); + + // Move 25 hours => old data is unlocked + skip(25 hours); + uint256 unlocked = distributor.calculateRewards(node); + assertEq(unlocked, 500); + + // Claim + vm.prank(nodeProvider); + distributor.claimRewards(node); + assertEq(mockRewardToken.balanceOf(node), 500); + } + + // ----------------------------------------------------------------------- + // Test: setRewardRate and endRewards in ring-buffer version + // ----------------------------------------------------------------------- + function testSetRewardRate() public { + vm.prank(manager); + vm.expectRevert(); + distributor.setRewardRate(12345); + } + + function testEndRewards() public { + vm.prank(address(mockComputePool)); + distributor.endRewards(); + } +} diff --git a/test/SyntheticDataWorkValidator.t.sol b/test/SyntheticDataWorkValidator.t.sol index 16156f0..308733c 100644 --- a/test/SyntheticDataWorkValidator.t.sol +++ b/test/SyntheticDataWorkValidator.t.sol @@ -22,7 +22,8 @@ contract SyntheticDataWorkValidatorTest is Test { function testSubmitWork() public { bytes32 workKey = keccak256("test_work"); - bytes memory data = abi.encodePacked(workKey); + uint256 workUnits = 10; + bytes memory data = abi.encodePacked(workKey, workUnits); vm.warp(42); bool success = validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, data); @@ -40,7 +41,8 @@ contract SyntheticDataWorkValidatorTest is Test { function testCannotSubmitDuplicateWork() public { bytes32 workKey = keccak256("test_work"); - bytes memory data = abi.encodePacked(workKey); + uint256 workUnits = 10; + bytes memory data = abi.encodePacked(workKey, workUnits); validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, data); @@ -50,7 +52,8 @@ contract SyntheticDataWorkValidatorTest is Test { function testInvalidateWork() public { bytes32 workKey = keccak256("test_work"); - bytes memory data = abi.encodePacked(workKey); + uint256 workUnits = 10; + bytes memory data = abi.encodePacked(workKey, workUnits); validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, data); @@ -69,7 +72,8 @@ contract SyntheticDataWorkValidatorTest is Test { function testCannotInvalidateAfterValidityPeriod() public { bytes32 workKey = keccak256("test_work"); - bytes memory data = abi.encodePacked(workKey); + uint256 workUnits = 10; + bytes memory data = abi.encodePacked(workKey, workUnits); validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, data); @@ -83,15 +87,16 @@ contract SyntheticDataWorkValidatorTest is Test { function testGetWorkSince() public { bytes32 workKey1 = keccak256("test_work_1"); bytes32 workKey2 = keccak256("test_work_2"); + uint256 workUnits = 10; bytes32[] memory recentWork = validator.getWorkSince(POOL_ID, 0); assertEq(recentWork.length, 0, "Should have no work"); vm.warp(1000); - validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey1)); + validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey1, workUnits)); vm.warp(2000); - validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey2)); + validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey2, workUnits)); recentWork = validator.getWorkSince(POOL_ID, 2001); assertEq(recentWork.length, 0, "Should have no work"); @@ -111,21 +116,22 @@ contract SyntheticDataWorkValidatorTest is Test { bytes32 workKey2 = keccak256("test_work_2"); bytes32 workKey3 = keccak256("test_work_3"); bytes32 workKey4 = keccak256("test_work_4"); + uint256 workUnits = 10; vm.warp(1000); - validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey1)); + validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey1, workUnits)); vm.warp(2000); - validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey2)); + validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey2, workUnits)); vm.warp(3000); - validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey3)); + validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey3, workUnits)); vm.warp(4000); - validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey4)); + validator.submitWork(DOMAIN_ID, POOL_ID, provider, nodeId, abi.encodePacked(workKey4, workUnits)); bytes32[] memory recentInvalidWork = validator.getInvalidWorkSince(POOL_ID, 0); assertEq(recentInvalidWork.length, 0, "Should have no recent invalid work"); - validator.invalidateWork(POOL_ID, abi.encodePacked(workKey2)); - validator.invalidateWork(POOL_ID, abi.encodePacked(workKey3)); + validator.invalidateWork(POOL_ID, abi.encodePacked(workKey2, workUnits)); + validator.invalidateWork(POOL_ID, abi.encodePacked(workKey3, workUnits)); recentInvalidWork = validator.getInvalidWorkSince(POOL_ID, 3001); assertEq(recentInvalidWork.length, 0, "Should have one recent invalid work"); @@ -142,7 +148,8 @@ contract SyntheticDataWorkValidatorTest is Test { function testUnauthorizedSubmission() public { bytes32 workKey = keccak256("test_work"); - bytes memory data = abi.encodePacked(workKey); + uint256 workUnits = 10; + bytes memory data = abi.encodePacked(workKey, workUnits); // Set msg.sender to a different address vm.prank(address(0x3)); @@ -153,7 +160,8 @@ contract SyntheticDataWorkValidatorTest is Test { function testInvalidDomainId() public { bytes32 workKey = keccak256("test_work"); - bytes memory data = abi.encodePacked(workKey); + uint256 workUnits = 10; + bytes memory data = abi.encodePacked(workKey, workUnits); vm.expectRevert("Invalid domainId"); validator.submitWork(DOMAIN_ID + 1, POOL_ID, provider, nodeId, data); From 43092c7a2e314d3152ee3e67082859411d0fa70a Mon Sep 17 00:00:00 2001 From: Matthew Di Ferrante Date: Tue, 1 Apr 2025 15:30:34 +0200 Subject: [PATCH 2/6] add function to slash pending rewards --- src/RewardsDistributorWorkSubmission.sol | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/RewardsDistributorWorkSubmission.sol b/src/RewardsDistributorWorkSubmission.sol index c82fba3..b5fe199 100644 --- a/src/RewardsDistributorWorkSubmission.sol +++ b/src/RewardsDistributorWorkSubmission.sol @@ -95,7 +95,6 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE function submitWork(address node, uint256 workUnits) external onlyRole(COMPUTE_POOL_ROLE) { require(endTime == 0, "Rewards have ended"); require(computePool.isNodeInPool(poolId, node), "Node not in pool"); - require(workUnits > 0, "Work units must be positive"); NodeBuckets storage nb = nodeBuckets[node]; // Roll forward first to ensure we’re in the correct active bucket @@ -147,6 +146,24 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE rewardToken.transfer(node, tokensToSend); } + function slashPendingRewards(address node) external onlyRole(REWARDS_MANAGER_ROLE) { + _rollBuckets(node); + NodeBuckets storage nb = nodeBuckets[node]; + uint256 pending24h = nb.totalLast24H; + if (pending24h == 0) { + return; // nothing to slash + } + for (uint256 i = 0; i < NUM_BUCKETS; i++) { + nb.buckets[i] = 0; // reset to zero + } + nb.totalAllSubmissions -= pending24h; // decrement total + nb.totalLast24H = 0; // reset to zero + nb.currentBucket = 0; // reset to first bucket + nb.lastBucketTimestamp = 0; // reset to zero + // Optionally, send the slashed tokens to a treasury or burn them + // rewardToken.transfer(treasury, pending24h * rewardRatePerUnit); + } + // -------------------------------------------------------------------------------------------- // Optional informational views // -------------------------------------------------------------------------------------------- @@ -190,8 +207,7 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE } // -------------------------------------------------------------------------------------------- - // The following methods are left for compatibility with your existing setup. - // You can remove or adapt them to your new ring-buffer logic as needed. + // The following methods are left for compatibility with the compute based rewards distributor // -------------------------------------------------------------------------------------------- function setRewardRate(uint256 newRate) external onlyRole(REWARDS_MANAGER_ROLE) { From b30c8fd3baa2135f8b41f7b3111a576f52ad309a Mon Sep 17 00:00:00 2001 From: Matthew Di Ferrante Date: Tue, 1 Apr 2025 15:36:55 +0200 Subject: [PATCH 3/6] fix some comments --- src/RewardsDistributorWorkSubmission.sol | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/RewardsDistributorWorkSubmission.sol b/src/RewardsDistributorWorkSubmission.sol index b5fe199..6426e29 100644 --- a/src/RewardsDistributorWorkSubmission.sol +++ b/src/RewardsDistributorWorkSubmission.sol @@ -114,11 +114,11 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE } // -------------------------------------------------------------------------------------------- - // Example "Locked for 24h" Reward Logic + // "Locked for 24h" Reward Logic // -------------------------------------------------------------------------------------------- /** - * @notice Example approach: + * @notice Bucket approach: * - totalAllSubmissions: total submissions ever done by this node. * - totalLast24H: the sum of the ring buffer’s most recent 24h. * We treat that as “locked.” @@ -146,6 +146,19 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE rewardToken.transfer(node, tokensToSend); } + // -------------------------------------------------------------------------------------------- + // "Slash Pending Rewards" Logic + // -------------------------------------------------------------------------------------------- + + /** + * @notice Slashes the pending rewards for a node. + * This is useful for slashing rewards if a node is inactive or misbehaving. + * It resets the node's buckets and totalLast24H to zero. + * Optionally, you can send the slashed tokens to a treasury or burn them. + * @param node The address of the node whose pending rewards are to be slashed. + * @dev This function can only be called by the REWARDS_MANAGER_ROLE. + * It resets the node's buckets and totalLast24H to zero. + */ function slashPendingRewards(address node) external onlyRole(REWARDS_MANAGER_ROLE) { _rollBuckets(node); NodeBuckets storage nb = nodeBuckets[node]; @@ -216,20 +229,19 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE } function joinPool(address node) external onlyRole(COMPUTE_POOL_ROLE) { - // If you require special logic on node join, do it here. - // E.g., initialize the node’s lastBucketTimestamp if needed. + // If special logic is required on node join, add it here. if (nodeBuckets[node].lastBucketTimestamp == 0) { nodeBuckets[node].lastBucketTimestamp = block.timestamp; } } function leavePool(address node) external onlyRole(COMPUTE_POOL_ROLE) { - // Optionally roll + finalize the node’s data. You might zero out buckets, etc. + // Optionally roll + finalize the node’s data. Zero out buckets, etc. _rollBuckets(node); } function endRewards() external onlyRole(COMPUTE_POOL_ROLE) { - // If you want to freeze further submissions, do so here. + // We freeze further submissions here. require(endTime == 0, "Already ended"); endTime = block.timestamp; } From f8eede34928579caa0dd8009efe0a8eb899d0fb4 Mon Sep 17 00:00:00 2001 From: Matthew Di Ferrante Date: Wed, 2 Apr 2025 15:44:56 +0200 Subject: [PATCH 4/6] return tuple from calculate rewards, transfer rewards to provider, fix tests and deploy script --- src/RewardsDistributor.sol | 6 +-- src/RewardsDistributorFixed.sol | 6 +-- src/RewardsDistributorWorkSubmission.sol | 8 +-- src/interfaces/IRewardsDistributor.sol | 2 +- test/RewardsDistributor.t.sol | 14 ++--- test/RewardsDistributorFixed.t.sol | 14 ++--- test/RewardsDistributorWorkSubmission.t.sol | 59 ++++++++++++--------- 7 files changed, 60 insertions(+), 49 deletions(-) diff --git a/src/RewardsDistributor.sol b/src/RewardsDistributor.sol index c0a4f9f..431bed9 100644 --- a/src/RewardsDistributor.sol +++ b/src/RewardsDistributor.sol @@ -142,14 +142,14 @@ contract RewardsDistributor is IRewardsDistributor, AccessControlEnumerable { rewardToken.transfer(node, payableAmount); } - function calculateRewards(address node) external view returns (uint256) { + function calculateRewards(address node) external view returns (uint256, uint256) { NodeDataInternal memory nd = nodeInfoInternal[node]; uint256 timeDelta; uint256 totalActiveComputeUnits = computePool.getComputePoolTotalCompute(poolId); // If the node has never joined, or there are no active computeUnits in total, no extra rewards to calculate. if (!computePool.isNodeInPool(poolId, node) && nd.unclaimedRewards == 0) { - return 0; + return (0, 0); } // 1. Calculate how many rewards would be distributed if we updated the global index now @@ -178,7 +178,7 @@ contract RewardsDistributor is IRewardsDistributor, AccessControlEnumerable { pending += newlyAccrued; } - return pending; + return (pending, 0); } function endRewards() external onlyRole(COMPUTE_POOL_ROLE) { diff --git a/src/RewardsDistributorFixed.sol b/src/RewardsDistributorFixed.sol index 26264cd..bfa772a 100644 --- a/src/RewardsDistributorFixed.sol +++ b/src/RewardsDistributorFixed.sol @@ -136,12 +136,12 @@ contract RewardsDistributorFixed is IRewardsDistributor, AccessControlEnumerable rewardToken.transfer(node, payableAmount); } - function calculateRewards(address node) external view returns (uint256) { + function calculateRewards(address node) external view returns (uint256, uint256) { NodeDataInternal memory nd = nodeInfoInternal[node]; uint256 timeDelta; // If the node has never joined, or there are no active computeUnits in total, no extra rewards to calculate. if (!computePool.isNodeInPool(poolId, node) && nd.unclaimedRewards == 0) { - return 0; + return (0, 0); } // 1. Calculate how many rewards would be distributed if we updated the global index now @@ -165,7 +165,7 @@ contract RewardsDistributorFixed is IRewardsDistributor, AccessControlEnumerable pending += newlyAccrued; } - return pending; + return (pending, 0); } function endRewards() external onlyRole(COMPUTE_POOL_ROLE) { diff --git a/src/RewardsDistributorWorkSubmission.sol b/src/RewardsDistributorWorkSubmission.sol index 6426e29..1d369a5 100644 --- a/src/RewardsDistributorWorkSubmission.sol +++ b/src/RewardsDistributorWorkSubmission.sol @@ -143,7 +143,7 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE uint256 tokensToSend = claimable * rewardRatePerUnit; require(tokensToSend <= rewardToken.balanceOf(address(this)), "Insufficient tokens"); - rewardToken.transfer(node, tokensToSend); + rewardToken.transfer(msg.sender, tokensToSend); } // -------------------------------------------------------------------------------------------- @@ -181,7 +181,7 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE // Optional informational views // -------------------------------------------------------------------------------------------- - function calculateRewards(address node) external view returns (uint256) { + function calculateRewards(address node) external view returns (uint256, uint256) { require(rewardRatePerUnit != 0, "Rate not set"); NodeBuckets memory nb = nodeBuckets[node]; @@ -204,7 +204,9 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE // “Unlocked so far” if we hypothetically updated now uint256 unlockedNow = nb.totalAllSubmissions - simulatedTotalLast24H; uint256 claimable = unlockedNow - nb.lastClaimed; - return claimable * rewardRatePerUnit; + uint256 claimableTokens = claimable * rewardRatePerUnit; + uint256 lockedTokens = simulatedTotalLast24H * rewardRatePerUnit; + return (claimableTokens, lockedTokens); } function nodeInfo(address node) diff --git a/src/interfaces/IRewardsDistributor.sol b/src/interfaces/IRewardsDistributor.sol index 4f88613..5d72eaa 100644 --- a/src/interfaces/IRewardsDistributor.sol +++ b/src/interfaces/IRewardsDistributor.sol @@ -6,7 +6,7 @@ event RewardRate(uint256 indexed poolId, uint256 rate); event RewardsClaimed(uint256 indexed poolId, address indexed provider, address indexed nodekey, uint256 reward); interface IRewardsDistributor { - function calculateRewards(address node) external view returns (uint256); + function calculateRewards(address node) external view returns (uint256, uint256); function claimRewards(address node) external; function setRewardRate(uint256 newRate) external; function endRewards() external; diff --git a/test/RewardsDistributor.t.sol b/test/RewardsDistributor.t.sol index ceae6c6..0874faa 100644 --- a/test/RewardsDistributor.t.sol +++ b/test/RewardsDistributor.t.sol @@ -229,7 +229,7 @@ contract RewardsDistributorTest is Test { assertFalse(isActive); // Check that calculateRewards shows the same - uint256 calculatedRewards = distributor.calculateRewards(node); + (uint256 calculatedRewards,) = distributor.calculateRewards(node); // The unclaimedRewards should have accrued (,, uint256 unclaimedRewards,) = distributor.nodeInfo(node); @@ -257,7 +257,7 @@ contract RewardsDistributorTest is Test { distributor.claimRewards(node); // 5. Node's provider claims on behalf of that node - uint256 calculatedRewards = distributor.calculateRewards(node); + (uint256 calculatedRewards,) = distributor.calculateRewards(node); vm.prank(nodeProvider); distributor.claimRewards(node); @@ -306,7 +306,7 @@ contract RewardsDistributorTest is Test { vm.warp(block.timestamp + 100); // Claim - uint256 calculatedRewards = distributor.calculateRewards(node); + (uint256 calculatedRewards,) = distributor.calculateRewards(node); vm.prank(nodeProvider); distributor.claimRewards(node); @@ -344,12 +344,12 @@ contract RewardsDistributorTest is Test { skip(15); // 4. Let both nodes claim // We'll do it from their providers - uint256 node1Pending = distributor.calculateRewards(node1); + (uint256 node1Pending,) = distributor.calculateRewards(node1); vm.startPrank(nodeProvider1); distributor.claimRewards(node1); vm.stopPrank(); - uint256 node2Pending = distributor.calculateRewards(node2); + (uint256 node2Pending,) = distributor.calculateRewards(node2); vm.startPrank(nodeProvider2); distributor.claimRewards(node2); vm.stopPrank(); @@ -423,12 +423,12 @@ contract RewardsDistributorTest is Test { skip(10); // Step 7: Claim for both from their respective providers - uint256 node1Pending = distributor.calculateRewards(node1); + (uint256 node1Pending,) = distributor.calculateRewards(node1); vm.startPrank(nodeProvider1); distributor.claimRewards(node1); vm.stopPrank(); - uint256 node2Pending = distributor.calculateRewards(node2); + (uint256 node2Pending,) = distributor.calculateRewards(node2); vm.startPrank(nodeProvider2); distributor.claimRewards(node2); vm.stopPrank(); diff --git a/test/RewardsDistributorFixed.t.sol b/test/RewardsDistributorFixed.t.sol index 75c2d78..da63f38 100644 --- a/test/RewardsDistributorFixed.t.sol +++ b/test/RewardsDistributorFixed.t.sol @@ -192,7 +192,7 @@ contract RewardsDistributorFixedTest is Test { (,,, isActive) = distributor.nodeInfo(node); assertFalse(isActive); - uint256 calculatedRewards = distributor.calculateRewards(node); + (uint256 calculatedRewards,) = distributor.calculateRewards(node); (,, uint256 unclaimedRewards,) = distributor.nodeInfo(node); // New logic: 1 token/sec * 10 CU * 100 sec = 1000 tokens expected. assertEq(unclaimedRewards, calculatedRewards); @@ -214,7 +214,7 @@ contract RewardsDistributorFixedTest is Test { vm.expectRevert("Unauthorized"); distributor.claimRewards(node); - uint256 calculatedRewards = distributor.calculateRewards(node); + (uint256 calculatedRewards,) = distributor.calculateRewards(node); vm.prank(nodeProvider); distributor.claimRewards(node); @@ -244,7 +244,7 @@ contract RewardsDistributorFixedTest is Test { vm.warp(block.timestamp + 100); - uint256 calculatedRewards = distributor.calculateRewards(node); + (uint256 calculatedRewards,) = distributor.calculateRewards(node); vm.prank(nodeProvider); distributor.claimRewards(node); @@ -275,12 +275,12 @@ contract RewardsDistributorFixedTest is Test { skip(15); - uint256 node1Pending = distributor.calculateRewards(node1); + (uint256 node1Pending,) = distributor.calculateRewards(node1); vm.startPrank(nodeProvider1); distributor.claimRewards(node1); vm.stopPrank(); - uint256 node2Pending = distributor.calculateRewards(node2); + (uint256 node2Pending,) = distributor.calculateRewards(node2); vm.startPrank(nodeProvider2); distributor.claimRewards(node2); vm.stopPrank(); @@ -327,12 +327,12 @@ contract RewardsDistributorFixedTest is Test { skip(10); - uint256 node1Pending = distributor.calculateRewards(node1); + (uint256 node1Pending,) = distributor.calculateRewards(node1); vm.startPrank(nodeProvider1); distributor.claimRewards(node1); vm.stopPrank(); - uint256 node2Pending = distributor.calculateRewards(node2); + (uint256 node2Pending,) = distributor.calculateRewards(node2); vm.startPrank(nodeProvider2); distributor.claimRewards(node2); vm.stopPrank(); diff --git a/test/RewardsDistributorWorkSubmission.t.sol b/test/RewardsDistributorWorkSubmission.t.sol index f7b0e76..fc4452c 100644 --- a/test/RewardsDistributorWorkSubmission.t.sol +++ b/test/RewardsDistributorWorkSubmission.t.sol @@ -123,6 +123,15 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { uint256 public constant NUM_BUCKETS = 24; uint256 public constant BUCKET_DURATION = 3600; // 1 hour + function fetchRewards(address _node, bool b) public view returns (uint256) { + (uint256 claimable, uint256 locked) = distributor.calculateRewards(_node); + if (b == false) { + return claimable; + } else { + return locked; + } + } + function setUp() public { // Deploy and mint tokens mockRewardToken = new MockERC20("MockToken", "MTK"); @@ -204,20 +213,20 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { assertEq(totalAll, 1000); // The ring buffer locks the last 24h, so if we claim now, we get 0 (all locked). - uint256 calcBefore = distributor.calculateRewards(node); + uint256 calcBefore = fetchRewards(node, false); assertEq(calcBefore, 0, "Should be zero because it's < 24h old"); // Move forward 12 hours skip(12 hours); // The 1000 is still within 24h, so locked - assertEq(distributor.calculateRewards(node), 0); + assertEq(fetchRewards(node, false), 0); // Move forward another 13 hours (total 25h) // That should push the first 1000 submission outside the 24h window skip(13 hours); // Now it's been 25h, so the 1000 is fully unlocked. // The ring buffer automatically resets that bucket on the next call or view. - uint256 calcAfter = distributor.calculateRewards(node); + uint256 calcAfter = fetchRewards(node, false); // We expect 1000 unlocked assertEq(calcAfter, 1000); @@ -228,7 +237,7 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { assertEq(lastClaimed, 1000, "lastClaimed should be 1000 now"); // The node receives the tokens - assertEq(mockRewardToken.balanceOf(node), 1000, "node's token balance mismatch"); + assertEq(mockRewardToken.balanceOf(nodeProvider), 1000, "node's token balance mismatch"); } // ----------------------------------------------------------------------- @@ -248,30 +257,30 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { distributor.submitWork(node, 500); // Both submissions are within 24h => locked - uint256 unlocked = distributor.calculateRewards(node); + uint256 unlocked = fetchRewards(node, false); assertEq(unlocked, 0, "All should be locked still (24h not passed)"); // Skip 23 more hours => total 25h from the first submission, 23h from the second skip(23 hours); // Now the first 1000 is older than 24h => unlocked // The second 500 is at 25-2=23h old => still locked - unlocked = distributor.calculateRewards(node); + unlocked = fetchRewards(node, false); assertEq(unlocked, 1000, "First submission unlocked, second still locked"); // Claim vm.prank(nodeProvider); distributor.claimRewards(node); - assertEq(mockRewardToken.balanceOf(node), 1000); + assertEq(mockRewardToken.balanceOf(nodeProvider), 1000); // Skip 2 more hours => total 25h from the second submission skip(2 hours); // Now the 500 is also older than 24h => unlocked - unlocked = distributor.calculateRewards(node); + unlocked = fetchRewards(node, false); assertEq(unlocked, 500); vm.prank(nodeProvider); distributor.claimRewards(node); - assertEq(mockRewardToken.balanceOf(node), 1500); + assertEq(mockRewardToken.balanceOf(nodeProvider), 1500); } // ----------------------------------------------------------------------- @@ -289,12 +298,12 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { // The ring buffer will be fully reset for that node upon next submission or roll // So the old 1000 is definitely unlocked - uint256 unlocked = distributor.calculateRewards(node); + uint256 unlocked = fetchRewards(node, false); assertEq(unlocked, 1000); vm.prank(nodeProvider); distributor.claimRewards(node); - assertEq(mockRewardToken.balanceOf(node), 1000); + assertEq(mockRewardToken.balanceOf(nodeProvider), 1000); // Submit again vm.prank(address(mockComputePool)); @@ -325,13 +334,13 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { distributor.submitWork(node2, 300); // Both are <24h => locked, no one gets anything if we claim - assertEq(distributor.calculateRewards(node1), 0); - assertEq(distributor.calculateRewards(node2), 0); + assertEq(fetchRewards(node1, false), 0); + assertEq(fetchRewards(node2, false), 0); skip(25 hours); // Now both are fully unlocked - uint256 node1Unlocked = distributor.calculateRewards(node1); - uint256 node2Unlocked = distributor.calculateRewards(node2); + uint256 node1Unlocked = fetchRewards(node1, false); + uint256 node2Unlocked = fetchRewards(node2, false); assertEq(node1Unlocked, 200); assertEq(node2Unlocked, 300); @@ -341,8 +350,8 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { vm.prank(nodeProvider2); distributor.claimRewards(node2); - assertEq(mockRewardToken.balanceOf(node1), 200); - assertEq(mockRewardToken.balanceOf(node2), 300); + assertEq(mockRewardToken.balanceOf(nodeProvider1), 200); + assertEq(mockRewardToken.balanceOf(nodeProvider2), 300); } // ----------------------------------------------------------------------- @@ -375,8 +384,8 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { // At t=25h: // Node1's first 100 is unlocked, second 100 is 13h old => locked // Node2's 50 is 25h old => unlocked - uint256 node1Unlocked = distributor.calculateRewards(node1); - uint256 node2Unlocked = distributor.calculateRewards(node2); + uint256 node1Unlocked = fetchRewards(node1, false); + uint256 node2Unlocked = fetchRewards(node2, false); assertEq(node1Unlocked, 100, "Node1 only the first 100 is unlocked"); assertEq(node2Unlocked, 50, "Node2's entire 50 is unlocked"); @@ -387,19 +396,19 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { vm.prank(nodeProvider2); distributor.claimRewards(node2); - assertEq(mockRewardToken.balanceOf(node1), 100); - assertEq(mockRewardToken.balanceOf(node2), 50); + assertEq(mockRewardToken.balanceOf(nodeProvider1), 100); + assertEq(mockRewardToken.balanceOf(nodeProvider2), 50); // Move ahead another 12 hours => t=37h from the second submission for Node1 skip(12 hours); // Now Node1's second 100 is also >24h => unlocked - node1Unlocked = distributor.calculateRewards(node1); + node1Unlocked = fetchRewards(node1, false); assertEq(node1Unlocked, 100); vm.prank(nodeProvider1); distributor.claimRewards(node1); - assertEq(mockRewardToken.balanceOf(node1), 200); + assertEq(mockRewardToken.balanceOf(nodeProvider1), 200); } // ----------------------------------------------------------------------- @@ -428,13 +437,13 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { // Move 25 hours => old data is unlocked skip(25 hours); - uint256 unlocked = distributor.calculateRewards(node); + uint256 unlocked = fetchRewards(node, false); assertEq(unlocked, 500); // Claim vm.prank(nodeProvider); distributor.claimRewards(node); - assertEq(mockRewardToken.balanceOf(node), 500); + assertEq(mockRewardToken.balanceOf(nodeProvider), 500); } // ----------------------------------------------------------------------- From 37932854ccb11895430c99c7a5a29233e800834b Mon Sep 17 00:00:00 2001 From: Matthew Di Ferrante Date: Wed, 2 Apr 2025 21:53:36 +0200 Subject: [PATCH 5/6] add test for slashing, actually commit deploy script fix, make the slashing of pending rewards automatic --- script/Deploy.s.sol | 5 +- src/ComputePool.sol | 2 + src/RewardsDistributor.sol | 4 ++ src/RewardsDistributorFixed.sol | 4 ++ src/RewardsDistributorWorkSubmission.sol | 6 +- src/interfaces/IRewardsDistributor.sol | 1 + test/RewardsDistributorWorkSubmission.t.sol | 62 +++++++++++++++++++++ 7 files changed, 81 insertions(+), 3 deletions(-) diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 036af64..b62d5b7 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -8,7 +8,7 @@ import "../src/ComputeRegistry.sol"; import "../src/DomainRegistry.sol"; import "../src/PrimeNetwork.sol"; import "../src/StakeManager.sol"; -import {RewardsDistributorFixedFactory} from "../src/RewardsDistributorFixedFactory.sol"; +import {RewardsDistributorWorkSubmissionFactory} from "../src/RewardsDistributorWorkSubmissionFactory.sol"; contract DeployScript is Script { function run() external { @@ -36,7 +36,8 @@ contract DeployScript is Script { StakeManager stakeManager = new StakeManager(address(primeNetwork), 7 days, aiToken); // Deploy RewardsDistributorFixedFactory - RewardsDistributorFixedFactory rewardsDistributorFactory = new RewardsDistributorFixedFactory(); + RewardsDistributorWorkSubmissionFactory rewardsDistributorFactory = + new RewardsDistributorWorkSubmissionFactory(); // Deploy ComputePool with deployer as admin ComputePool computePool = new ComputePool(address(primeNetwork), domainRegistry, computeRegistry, rewardsDistributorFactory, aiToken); diff --git a/src/ComputePool.sol b/src/ComputePool.sol index ee28387..f806dd1 100644 --- a/src/ComputePool.sol +++ b/src/ComputePool.sol @@ -320,6 +320,8 @@ contract ComputePool is IComputePool, AccessControlEnumerable { IDomainRegistry.Domain memory domainInfo = domainRegistry.get(pools[poolId].domainId); IWorkValidation workValidation = IWorkValidation(domainInfo.validationLogic); (address provider, address node) = workValidation.invalidateWork(poolId, data); + IRewardsDistributor rewardsDistributor = poolStates[poolId].rewardsDistributor; + rewardsDistributor.slashPendingRewards(node); if (poolStates[poolId].poolNodes.contains(node)) { _ejectNode(poolId, node); } diff --git a/src/RewardsDistributor.sol b/src/RewardsDistributor.sol index 431bed9..19725f1 100644 --- a/src/RewardsDistributor.sol +++ b/src/RewardsDistributor.sol @@ -181,6 +181,10 @@ contract RewardsDistributor is IRewardsDistributor, AccessControlEnumerable { return (pending, 0); } + function slashPendingRewards(address node) external view onlyRole(COMPUTE_POOL_ROLE) { + node == node; + } + function endRewards() external onlyRole(COMPUTE_POOL_ROLE) { _updateGlobalIndex(); endTime = block.timestamp; diff --git a/src/RewardsDistributorFixed.sol b/src/RewardsDistributorFixed.sol index bfa772a..97115f0 100644 --- a/src/RewardsDistributorFixed.sol +++ b/src/RewardsDistributorFixed.sol @@ -168,6 +168,10 @@ contract RewardsDistributorFixed is IRewardsDistributor, AccessControlEnumerable return (pending, 0); } + function slashPendingRewards(address node) external view onlyRole(COMPUTE_POOL_ROLE) { + node == node; + } + function endRewards() external onlyRole(COMPUTE_POOL_ROLE) { _updateGlobalIndex(); endTime = block.timestamp; diff --git a/src/RewardsDistributorWorkSubmission.sol b/src/RewardsDistributorWorkSubmission.sol index 1d369a5..213b83a 100644 --- a/src/RewardsDistributorWorkSubmission.sol +++ b/src/RewardsDistributorWorkSubmission.sol @@ -159,7 +159,11 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE * @dev This function can only be called by the REWARDS_MANAGER_ROLE. * It resets the node's buckets and totalLast24H to zero. */ - function slashPendingRewards(address node) external onlyRole(REWARDS_MANAGER_ROLE) { + function slashPendingRewards(address node) external { + // this can be called directly by the REWARDS_MANAGER_ROLE or by the COMPUTE_POOL_ROLE + // through a work invalidation submission + require(hasRole(REWARDS_MANAGER_ROLE, msg.sender) || hasRole(COMPUTE_POOL_ROLE, msg.sender), "Unauthorized"); + _rollBuckets(node); NodeBuckets storage nb = nodeBuckets[node]; uint256 pending24h = nb.totalLast24H; diff --git a/src/interfaces/IRewardsDistributor.sol b/src/interfaces/IRewardsDistributor.sol index 5d72eaa..95bc658 100644 --- a/src/interfaces/IRewardsDistributor.sol +++ b/src/interfaces/IRewardsDistributor.sol @@ -9,6 +9,7 @@ interface IRewardsDistributor { function calculateRewards(address node) external view returns (uint256, uint256); function claimRewards(address node) external; function setRewardRate(uint256 newRate) external; + function slashPendingRewards(address node) external; function endRewards() external; function joinPool(address node) external; function leavePool(address node) external; diff --git a/test/RewardsDistributorWorkSubmission.t.sol b/test/RewardsDistributorWorkSubmission.t.sol index fc4452c..bc75581 100644 --- a/test/RewardsDistributorWorkSubmission.t.sol +++ b/test/RewardsDistributorWorkSubmission.t.sol @@ -446,6 +446,68 @@ contract RewardsDistributorWorkSubmissionRingBufferTest is Test { assertEq(mockRewardToken.balanceOf(nodeProvider), 500); } + // ----------------------------------------------------------------------- + // Test: slashPendingRewards + // ----------------------------------------------------------------------- + function testSlashPendingRewards() public { + vm.prank(address(mockComputePool)); + mockComputePool.joinComputePool(node, 10); + + // 1) Node submits some work => last24H = 100 + vm.prank(address(mockComputePool)); + distributor.submitWork(node, 100); + + // Skip 25h so the 100 is unlocked. totalAll=100, last24H=100, but effectively 0 locked. + skip(25 hours); + + // 2) Node submits more work => last24H = 200, totalAll=300 + vm.prank(address(mockComputePool)); + distributor.submitWork(node, 200); + + // Check ring-buffer state + (uint256 last24HBefore,,, bool isActive) = distributor.nodeInfo(node); + assertTrue(isActive); + assertEq( + last24HBefore, + 300 - 100, + "Only the new 200 should be truly locked, but last24H tracks all submissions in <24h" + ); + // Because we haven't done a "roll" on the now-unlocked 100 yet, last24H might read 300. + // But the oldest 100 is older than 24h. + // The .calculateRewards(...) should reflect 100 unlocked, 200 locked. + + // Confirm the actual "unlocked" portion is 100 + uint256 unlockedNow = fetchRewards(node, false); + assertEq(unlockedNow, 100, "First 100 is unlocked, second 200 is locked."); + + // 3) Slash the node’s pending 24h => manager only + // That should remove the locked 200 from totalAll, zero the ring buffer, etc. + vm.prank(manager); + distributor.slashPendingRewards(node); + + // 4) After slash, the last24H and buckets are all zero. totalAll = 100 (the unlocked portion remains). + (uint256 last24HAfter, uint256 totalAllAfter,,) = distributor.nodeInfo(node); + assertEq(last24HAfter, 0, "Should have cleared the ring buffer"); + assertEq(totalAllAfter, 100, "Should have subtracted the slashed 200 from totalAll"); + // lastClaimed should remain the same, because we didn’t claim. + + // 5) Confirm that now, if we skip 25 hours more, there is no "locked" portion to unlock + skip(25 hours); + uint256 unlockedAfterSlash = fetchRewards(node, false); + // The 100 is still unlocked, but we never claimed it, so it remains unclaimed. + // Because slash only subtracted from totalAll the “locked” portion, the older 100 is unaffected. + // So unlockedAfterSlash == 100 - lastClaimedAfter. But we haven't claimed at all, so lastClaimedAfter=0. + assertEq(unlockedAfterSlash, 100, "The older 100 remains claimable."); + + // 6) Claim the 100 + vm.prank(nodeProvider); + distributor.claimRewards(node); + + // Confirm node got 100 + uint256 nodeBalance = mockRewardToken.balanceOf(nodeProvider); + assertEq(nodeBalance, 100, "Node should receive the older (unlocked) 100 tokens"); + } + // ----------------------------------------------------------------------- // Test: setRewardRate and endRewards in ring-buffer version // ----------------------------------------------------------------------- From 00e876ec17526fc478c851cc749fbd5cf5ab6556 Mon Sep 17 00:00:00 2001 From: Matthew Di Ferrante Date: Wed, 2 Apr 2025 23:52:41 +0200 Subject: [PATCH 6/6] emit PendingRewardsSlashed event for tracking --- src/RewardsDistributorWorkSubmission.sol | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/RewardsDistributorWorkSubmission.sol b/src/RewardsDistributorWorkSubmission.sol index 213b83a..5da6864 100644 --- a/src/RewardsDistributorWorkSubmission.sol +++ b/src/RewardsDistributorWorkSubmission.sol @@ -7,6 +7,8 @@ import "./interfaces/IComputeRegistry.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; +event PendingRewardsSlashed(uint256 indexed poolId, address indexed node, uint256 slashedAmount); + contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlEnumerable { bytes32 public constant PRIME_ROLE = keccak256("PRIME_ROLE"); bytes32 public constant FEDERATOR_ROLE = keccak256("FEDERATOR_ROLE"); @@ -177,8 +179,11 @@ contract RewardsDistributorWorkSubmission is IRewardsDistributor, AccessControlE nb.totalLast24H = 0; // reset to zero nb.currentBucket = 0; // reset to first bucket nb.lastBucketTimestamp = 0; // reset to zero - // Optionally, send the slashed tokens to a treasury or burn them - // rewardToken.transfer(treasury, pending24h * rewardRatePerUnit); + + // Optionally, send the slashed tokens to a treasury or burn them + // rewardToken.transfer(treasury, pending24h * rewardRatePerUnit); + + emit PendingRewardsSlashed(poolId, node, pending24h * rewardRatePerUnit); } // --------------------------------------------------------------------------------------------