diff --git a/contracts/interfaces/gov/IGovPool.sol b/contracts/interfaces/gov/IGovPool.sol index 9c7df49f..5ab368f4 100644 --- a/contracts/interfaces/gov/IGovPool.sol +++ b/contracts/interfaces/gov/IGovPool.sol @@ -193,6 +193,7 @@ interface IGovPool { uint256[] delegationTimes; uint256[] delegationPowers; mapping(uint256 => bool) isClaimed; + mapping(uint256 => uint256) partiallyClaimed; } /// @notice The struct that holds reward properties (only for internal needs) diff --git a/contracts/libs/gov/gov-pool/GovPoolMicropool.sol b/contracts/libs/gov/gov-pool/GovPoolMicropool.sol index ae88dcd3..9ffc4e0d 100644 --- a/contracts/libs/gov/gov-pool/GovPoolMicropool.sol +++ b/contracts/libs/gov/gov-pool/GovPoolMicropool.sol @@ -71,13 +71,22 @@ library GovPoolMicropool { require(reward != 0, "Gov: no micropool rewards"); - userInfos[delegatee].delegatorInfos[delegator].isClaimed[proposalId] = true; + IGovPool.DelegatorInfo storage delegatorInfo = userInfos[delegatee].delegatorInfos[ + delegator + ]; + + delegatorInfo.isClaimed[proposalId] = true; address rewardToken = proposals[proposalId].core.settings.rewardsInfo.rewardToken; - rewardToken.sendFunds(delegator, reward, TokenBalance.TransferType.TryMint); + uint256 paid = rewardToken.sendFunds(delegator, reward, TokenBalance.TransferType.TryMint); + + if (paid < reward) { + delegatorInfo.isClaimed[proposalId] = false; + delegatorInfo.partiallyClaimed[proposalId] += paid; + } - emit DelegatorRewardsClaimed(proposalId, delegator, delegatee, rewardToken, reward); + emit DelegatorRewardsClaimed(proposalId, delegator, delegatee, rewardToken, paid); } function getDelegatorRewards( @@ -151,9 +160,11 @@ library GovPoolMicropool { uint256 totalVoted = micropoolRawVote.totalVoted; - return - delegatorsRewards.ratio(delegatorInfo.delegationPowers[index], totalVoted).min( - totalVoted - ); + uint256 reward = delegatorsRewards.ratio( + delegatorInfo.delegationPowers[index], + totalVoted + ); + + return reward - delegatorInfo.partiallyClaimed[proposalId]; } } diff --git a/contracts/libs/gov/gov-pool/GovPoolRewards.sol b/contracts/libs/gov/gov-pool/GovPoolRewards.sol index bee1d8d5..54e572e4 100644 --- a/contracts/libs/gov/gov-pool/GovPoolRewards.sol +++ b/contracts/libs/gov/gov-pool/GovPoolRewards.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; import "../../../interfaces/core/ICoreProperties.sol"; @@ -15,6 +16,7 @@ library GovPoolRewards { using EnumerableSet for EnumerableSet.AddressSet; using TokenBalance for address; using MathHelper for uint256; + using Math for uint256; event RewardClaimed(uint256 proposalId, address sender, address token, uint256 rewards); event VotingRewardClaimed( @@ -102,25 +104,40 @@ library GovPoolRewards { require(core.executed, "Gov: proposal is not executed"); - uint256 staticRewards = userRewards.staticRewards[proposalId]; - IGovPool.VotingRewards memory votingRewards = userRewards.votingRewards[proposalId]; + uint256 staticRewardsToPay = userRewards.staticRewards[proposalId]; + uint256 staticRewardsPaid; + + IGovPool.VotingRewards memory votingRewardsToPay = userRewards.votingRewards[ + proposalId + ]; + IGovPool.VotingRewards memory votingRewardsPaid; delete userRewards.staticRewards[proposalId]; delete userRewards.votingRewards[proposalId]; address rewardToken = core.settings.rewardsInfo.rewardToken; - _sendRewards( + uint256 rewardsPaid = _sendRewards( user, core.settings.rewardsInfo.rewardToken, - staticRewards + - votingRewards.personal + - votingRewards.micropool + - votingRewards.treasury + staticRewardsToPay + + votingRewardsToPay.personal + + votingRewardsToPay.micropool + + votingRewardsToPay.treasury + ); + + (staticRewardsToPay, staticRewardsPaid) = _recalculateAllRewards( + rewardsPaid, + staticRewardsToPay, + votingRewardsPaid, + votingRewardsToPay ); - emit RewardClaimed(proposalId, user, rewardToken, staticRewards); - emit VotingRewardClaimed(proposalId, user, rewardToken, votingRewards); + userRewards.staticRewards[proposalId] = staticRewardsToPay; + userRewards.votingRewards[proposalId] = votingRewardsToPay; + + emit RewardClaimed(proposalId, user, rewardToken, staticRewardsPaid); + emit VotingRewardClaimed(proposalId, user, rewardToken, votingRewardsPaid); } else { EnumerableSet.AddressSet storage offchainTokens = userRewards.offchainTokens; mapping(address => uint256) storage offchainRewards = userRewards.offchainRewards; @@ -132,11 +149,16 @@ library GovPoolRewards { uint256 rewards = offchainRewards[rewardToken]; delete offchainRewards[rewardToken]; - offchainTokens.remove(rewardToken); - _sendRewards(user, rewardToken, rewards); + uint256 paid = _sendRewards(user, rewardToken, rewards); + + if (paid < rewards) { + offchainRewards[rewardToken] = rewards - paid; + } else { + offchainTokens.remove(rewardToken); + } - emit RewardClaimed(0, user, rewardToken, rewards); + emit RewardClaimed(0, user, rewardToken, paid); } } } @@ -189,11 +211,15 @@ library GovPoolRewards { } } - function _sendRewards(address receiver, address rewardToken, uint256 rewards) internal { + function _sendRewards( + address receiver, + address rewardToken, + uint256 rewards + ) internal returns (uint256) { require(rewardToken != address(0), "Gov: rewards are off"); require(rewards != 0, "Gov: zero rewards"); - rewardToken.sendFunds(receiver, rewards, TokenBalance.TransferType.TryMint); + return rewardToken.sendFunds(receiver, rewards, TokenBalance.TransferType.TryMint); } function _getMultipliedRewards(address user, uint256 amount) internal view returns (uint256) { @@ -282,4 +308,45 @@ library GovPoolRewards { votingRewards.micropool -= delegatorsRewards; } + + function _recalculateReward( + uint256 rewardsPaid, + uint256 rewardsToPay + ) private pure returns (uint256, uint256, uint256) { + uint256 amountMin = rewardsPaid.min(rewardsToPay); + + return (rewardsPaid - amountMin, amountMin, rewardsToPay - amountMin); + } + + function _recalculateAllRewards( + uint256 rewardsPaid, + uint256 staticRewardsToPay, + IGovPool.VotingRewards memory votingRewardsPaid, + IGovPool.VotingRewards memory votingRewardsToPay + ) private pure returns (uint256, uint256) { + uint256 staticRewardsPaid; + + (rewardsPaid, staticRewardsPaid, staticRewardsToPay) = _recalculateReward( + rewardsPaid, + staticRewardsToPay + ); + + ( + rewardsPaid, + votingRewardsPaid.personal, + votingRewardsToPay.personal + ) = _recalculateReward(rewardsPaid, votingRewardsToPay.personal); + ( + rewardsPaid, + votingRewardsPaid.micropool, + votingRewardsToPay.micropool + ) = _recalculateReward(rewardsPaid, votingRewardsToPay.micropool); + ( + rewardsPaid, + votingRewardsPaid.treasury, + votingRewardsToPay.treasury + ) = _recalculateReward(rewardsPaid, votingRewardsToPay.treasury); + + return (staticRewardsToPay, staticRewardsPaid); + } } diff --git a/contracts/libs/utils/TokenBalance.sol b/contracts/libs/utils/TokenBalance.sol index 5d48f31f..0fbd7212 100644 --- a/contracts/libs/utils/TokenBalance.sol +++ b/contracts/libs/utils/TokenBalance.sol @@ -27,13 +27,14 @@ library TokenBalance { address receiver, uint256 amount, TransferType transferType - ) internal { + ) internal returns (uint256) { uint256 balance = normThisBalance(token); require(balance >= amount || transferType == TransferType.TryMint, "Insufficient funds"); if (token == ETHEREUM_ADDRESS) { - (bool status, ) = payable(receiver).call{value: amount.min(balance)}(""); + amount = amount.min(balance); + (bool status, ) = payable(receiver).call{value: amount}(""); require(status, "Failed to send eth"); } else { @@ -47,6 +48,8 @@ library TokenBalance { IERC20(token).safeTransfer(receiver, amount.from18(token)); } + + return amount; } function sendFunds(address token, address receiver, uint256 amount) internal { diff --git a/test/gov/GovPool.test.js b/test/gov/GovPool.test.js index ecbb3fd5..c96089e5 100644 --- a/test/gov/GovPool.test.js +++ b/test/gov/GovPool.test.js @@ -130,6 +130,8 @@ describe("GovPool", () => { let attacker; + let newToken; + const reverter = new Reverter(); const getProposalByIndex = async (index) => (await govPool.getProposals(index - 1, 1))[0].proposal; @@ -4205,7 +4207,7 @@ describe("GovPool", () => { }); it("should mint when balance < rewards", async () => { - let newToken = await ERC20Mock.new("NT", "NT", 18); + newToken = await ERC20Mock.new("NT", "NT", 18); NEW_SETTINGS.rewardsInfo.rewardToken = newToken.address; @@ -4235,6 +4237,236 @@ describe("GovPool", () => { assert.equal((await newToken.balanceOf(treasury)).toFixed(), wei("3.2")); assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("16")); }); + + describe("rewards with low pool balance", () => { + async function comparePendingRewards( + user, + proposalId, + staticRewards = 0, + presonalRewards = 0, + micropoolRewards = 0, + treasuryRewards = 0 + ) { + let rewards = await govPool.getPendingRewards(user, [proposalId]); + assert.equal(rewards.staticRewards, staticRewards); + assert.equal(rewards.votingRewards[0][0], presonalRewards); + assert.equal(rewards.votingRewards[0][1], micropoolRewards); + assert.equal(rewards.votingRewards[0][2], treasuryRewards); + } + + async function mintNewToken(amount) { + await newToken.toggleMint(); + await newToken.mint(govPool.address, amount); + await newToken.toggleMint(); + } + + beforeEach(async () => { + newToken = await ERC20Mock.new("NT", "NT", 18); + await newToken.toggleMint(); + + NEW_SETTINGS.rewardsInfo.rewardToken = newToken.address; + + const bytes = getBytesEditSettings([1], [NEW_SETTINGS]); + + await govPool.createProposal("example.com", [[settings.address, 0, bytes]], []); + await govPool.vote(1, true, wei("100000000000000000000"), [], { from: SECOND }); + + await govPool.moveProposalToValidators(1); + await validators.voteExternalProposal(1, wei("1000000000000"), true, { from: SECOND }); + + await govPool.execute(1); + }); + + it("should keep rewards with empty treasury", async () => { + await govPool.createProposal("example.com", [[settings.address, 0, getBytesAddSettings([NEW_SETTINGS])]], []); + + await govPool.vote(2, true, wei("1"), []); + + assert.equal((await newToken.balanceOf(treasury)).toFixed(), "0"); + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("0")); + + await executeAndClaim(2, OWNER); + + await comparePendingRewards(OWNER, 2, wei("15"), wei("1")); + + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("0")); + + await newToken.toggleMint(); + await govPool.claimRewards([2], OWNER, { from: OWNER }); + + await comparePendingRewards(OWNER, 2); + + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("16")); + }); + + it("could get rewards by parts", async () => { + await govPool.createProposal("example.com", [[settings.address, 0, getBytesAddSettings([NEW_SETTINGS])]], []); + + await govPool.vote(2, true, wei("1"), []); + + await executeAndClaim(2, OWNER); + + await mintNewToken(wei("5")); + await govPool.claimRewards([2], OWNER, { from: OWNER }); + + assert.equal((await newToken.balanceOf(govPool.address)).toFixed(), wei("0")); + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("5")); + await comparePendingRewards(OWNER, 2, wei("10"), wei("1")); + + await mintNewToken(wei("10.3")); + await govPool.claimRewards([2], OWNER, { from: OWNER }); + + assert.equal((await newToken.balanceOf(govPool.address)).toFixed(), wei("0")); + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("15.3")); + await comparePendingRewards(OWNER, 2, wei("0"), wei("0.7")); + + await mintNewToken(wei("100")); + await govPool.claimRewards([2], OWNER, { from: OWNER }); + + assert.equal((await newToken.balanceOf(govPool.address)).toFixed(), wei("99.3")); + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("16")); + await comparePendingRewards(OWNER, 2); + }); + + it("recieves correct micropool reward", async () => { + await token.mint(SECOND, wei("1")); + await token.approve(userKeeper.address, wei("1"), { from: SECOND }); + await govPool.deposit(wei("1"), [], { from: SECOND }); + await govPool.delegate(OWNER, wei("1"), [], { from: SECOND }); + + await govPool.createProposal( + "example.com", + + [[settings.address, 0, getBytesAddSettings([NEW_SETTINGS])]], + [] + ); + + await govPool.vote(2, true, wei("1"), []); + await govPool.execute(2); + await comparePendingRewards(OWNER, 2, wei("15"), wei("1"), wei("0.2")); + + let rewards = await govPool.getDelegatorRewards([2], SECOND, OWNER); + assert.equal(rewards.expectedRewards, wei("0.8")); + assert.equal(rewards.isClaimed[0], false); + + await mintNewToken(wei("0.3")); + await govPool.claimMicropoolRewards([2], SECOND, OWNER, { from: SECOND }); + + rewards = await govPool.getDelegatorRewards([2], SECOND, OWNER); + assert.equal(rewards.expectedRewards, wei("0.5")); + assert.equal(rewards.isClaimed[0], false); + assert.equal(await newToken.balanceOf(SECOND), wei("0.3")); + + await mintNewToken(wei("1")); + await govPool.claimMicropoolRewards([2], SECOND, OWNER, { from: SECOND }); + + rewards = await govPool.getDelegatorRewards([2], SECOND, OWNER); + assert.equal(rewards.expectedRewards, wei("0")); + assert.equal(rewards.isClaimed[0], true); + assert.equal(await newToken.balanceOf(SECOND), wei("0.8")); + + await truffleAssert.reverts( + govPool.claimMicropoolRewards([2], SECOND, OWNER, { from: SECOND }), + "Gov: no micropool rewards" + ); + }); + + it("receives correct offchain rewards", async () => { + const OWNER_PRIVATE_KEY = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + const resultsHash = "0xc4f46c912cc2a1f30891552ac72871ab0f0e977886852bdd5dccd221a595647d"; + const privateKey = Buffer.from(OWNER_PRIVATE_KEY, "hex"); + + let signHash = await govPool.getOffchainSignHash(resultsHash, OWNER); + let signature = ethSigUtil.personalSign({ privateKey: privateKey, data: signHash }); + + await govPool.saveOffchainResults(resultsHash, signature); + + let rewards = await govPool.getPendingRewards(OWNER, []); + + assert.deepEqual(rewards.offchainTokens, [newToken.address]); + assert.deepEqual( + rewards.offchainRewards.map((e) => toBN(e).toFixed()), + [wei("5")] + ); + + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), "0"); + + await mintNewToken(wei("1")); + await govPool.claimRewards([0], OWNER); + + rewards = await govPool.getPendingRewards(OWNER, []); + + assert.deepEqual(rewards.offchainTokens, [newToken.address]); + assert.deepEqual( + rewards.offchainRewards.map((e) => toBN(e).toFixed()), + [wei("4")] + ); + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("1")); + + await mintNewToken(wei("4")); + await govPool.claimRewards([0], OWNER); + + rewards = await govPool.getPendingRewards(OWNER, []); + + assert.deepEqual(rewards.offchainTokens, []); + assert.deepEqual( + rewards.offchainRewards.map((e) => toBN(e).toFixed()), + [] + ); + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("5")); + }); + + it("deletes offchain tokens correct", async () => { + const OWNER_PRIVATE_KEY = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + let resultsHash = "0xc4f46c912cc2a1f30891552ac72871ab0f0e977886852bdd5dccd221a595647d"; + let privateKey = Buffer.from(OWNER_PRIVATE_KEY, "hex"); + + let signHash = await govPool.getOffchainSignHash(resultsHash, OWNER); + let signature = ethSigUtil.personalSign({ privateKey: privateKey, data: signHash }); + + await govPool.saveOffchainResults(resultsHash, signature); + + newToken2 = await ERC20Mock.new("NT2", "NT2", 18); + await newToken2.toggleMint(); + + NEW_SETTINGS.rewardsInfo.rewardToken = newToken2.address; + + await impersonate(govPool.address); + + await settings.editSettings([1], [NEW_SETTINGS], { from: govPool.address }); + + resultsHash = "0xc4f46c912cc2a1f30891552ac72871ab0f0e977886852bdd5dccd221a595647c"; + + signHash = await govPool.getOffchainSignHash(resultsHash, OWNER); + signature = ethSigUtil.personalSign({ privateKey: privateKey, data: signHash }); + + await govPool.saveOffchainResults(resultsHash, signature); + + let rewards = await govPool.getPendingRewards(OWNER, []); + + assert.deepEqual(rewards.offchainTokens, [newToken.address, newToken2.address]); + assert.deepEqual( + rewards.offchainRewards.map((e) => toBN(e).toFixed()), + [wei("5"), wei("5")] + ); + + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), "0"); + + await mintNewToken(wei("5")); + await govPool.claimRewards([0], OWNER); + + rewards = await govPool.getPendingRewards(OWNER, []); + + assert.deepEqual(rewards.offchainTokens, [newToken2.address]); + assert.deepEqual( + rewards.offchainRewards.map((e) => toBN(e).toFixed()), + [wei("5")] + ); + assert.equal((await newToken.balanceOf(OWNER)).toFixed(), wei("5")); + }); + }); }); describe("powered rewards & micropool & treasury", () => {