diff --git a/contracts/v2/paranets/ParanetNeuroIncentivesPool.sol b/contracts/v2/paranets/ParanetNeuroIncentivesPool.sol index 9b278b39..1a0b84ff 100644 --- a/contracts/v2/paranets/ParanetNeuroIncentivesPool.sol +++ b/contracts/v2/paranets/ParanetNeuroIncentivesPool.sol @@ -244,6 +244,7 @@ contract ParanetNeuroIncentivesPool is Named, Versioned { function getVoter( address voterAddress ) external view returns (ParanetStructs.ParanetIncentivizationProposalVoter memory) { + require(isProposalVoter(voterAddress), "Given addr isn't a voter"); return voters[votersIndexes[voterAddress]]; } @@ -384,7 +385,7 @@ contract ParanetNeuroIncentivesPool is Named, Versioned { // and total NEURO received by the contract, so that Miners don't get tokens belonging to Operator/Voters // Following the example from the above, if we have 100 NEURO as a total reward, Miners should never get // more than 80 NEURO. minersRewardLimit = 80 NEURO - uint256 minersRewardLimit = ((address(this).balance + + uint256 minersRewardLimit = (((isNativeNeuro() ? address(this).balance : token.balanceOf(address(this))) + totalMinersClaimedNeuro + totalOperatorsClaimedNeuro + totalVotersClaimedNeuro) * @@ -401,7 +402,7 @@ contract ParanetNeuroIncentivesPool is Named, Versioned { function getClaimableAllKnowledgeMinersRewardAmount() public view returns (uint256) { uint256 neuroReward = getTotalAllKnowledgeMinersIncentiveEstimation(); - uint256 minersRewardLimit = ((address(this).balance + + uint256 minersRewardLimit = (((isNativeNeuro() ? address(this).balance : token.balanceOf(address(this))) + totalMinersClaimedNeuro + totalOperatorsClaimedNeuro + totalVotersClaimedNeuro) * @@ -482,7 +483,7 @@ contract ParanetNeuroIncentivesPool is Named, Versioned { function getClaimableParanetOperatorRewardAmount() public view returns (uint256) { uint256 neuroReward = getTotalParanetOperatorIncentiveEstimation(); - uint256 operatorRewardLimit = ((address(this).balance + + uint256 operatorRewardLimit = (((isNativeNeuro() ? address(this).balance : token.balanceOf(address(this))) + totalMinersClaimedNeuro + totalOperatorsClaimedNeuro + totalVotersClaimedNeuro) * paranetOperatorRewardPercentage) / PERCENTAGE_SCALING_FACTOR; @@ -559,7 +560,7 @@ contract ParanetNeuroIncentivesPool is Named, Versioned { uint256 neuroReward = getTotalProposalVoterIncentiveEstimation(); - uint256 voterRewardLimit = ((((address(this).balance + + uint256 voterRewardLimit = (((((isNativeNeuro() ? address(this).balance : token.balanceOf(address(this))) + totalMinersClaimedNeuro + totalOperatorsClaimedNeuro + totalVotersClaimedNeuro) * paranetIncentivizationProposalVotersRewardPercentage) / @@ -574,7 +575,7 @@ contract ParanetNeuroIncentivesPool is Named, Versioned { function getClaimableAllProposalVotersRewardAmount() public view returns (uint256) { uint256 neuroReward = getTotalAllProposalVotersIncentiveEstimation(); - uint256 votersRewardLimit = ((address(this).balance + + uint256 votersRewardLimit = (((isNativeNeuro() ? address(this).balance : token.balanceOf(address(this))) + totalMinersClaimedNeuro + totalOperatorsClaimedNeuro + totalVotersClaimedNeuro) * paranetIncentivizationProposalVotersRewardPercentage) / diff --git a/test/v2/unit/ParanetNeuroIncentivesPool.test.ts b/test/v2/unit/ParanetNeuroIncentivesPool.test.ts index ca01dbb0..52223109 100644 --- a/test/v2/unit/ParanetNeuroIncentivesPool.test.ts +++ b/test/v2/unit/ParanetNeuroIncentivesPool.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers'; import { expect } from 'chai'; import { BigNumberish } from 'ethers'; import hre from 'hardhat'; @@ -64,6 +64,10 @@ describe('@v2 @unit ParanetNeuroIncentivesPool contract', function () { let Token: Token; let NeuroERC20: Token; let ServiceAgreementV1: ServiceAgreementV1; + let Hub: Hub; + + const EMISSION_MULTIPLIER_SCALING_FACTOR = hre.ethers.constants.WeiPerEther; // 1e18 + const PERCENTAGE_SCALING_FACTOR = 10_000; // as per the contract async function deployParanetFixture(): Promise { await hre.deployments.fixture( @@ -87,7 +91,7 @@ describe('@v2 @unit ParanetNeuroIncentivesPool contract', function () { { keepExistingDeployments: false }, ); - const Hub = await hre.ethers.getContract('Hub'); + Hub = await hre.ethers.getContract('Hub'); HubController = await hre.ethers.getContract('HubController'); Paranet = await hre.ethers.getContract('Paranet'); ContentAssetV2 = await hre.ethers.getContract('ContentAsset'); @@ -136,7 +140,23 @@ describe('@v2 @unit ParanetNeuroIncentivesPool contract', function () { beforeEach(async () => { hre.helpers.resetDeploymentsJson(); - ({ accounts, Paranet, ParanetIncentivesPoolFactory } = await loadFixture(deployParanetFixture)); + ({ + accounts, + Paranet, + HubController, + ContentAssetV2, + ContentAssetStorageV2, + ParanetsRegistry, + ParanetServicesRegistry, + ParanetKnowledgeMinersRegistry, + ParanetKnowledgeAssetsRegistry, + ParanetIncentivesPoolFactory, + HashingProxy, + ServiceAgreementStorageProxy, + Token, + NeuroERC20, + ServiceAgreementV1, + } = await loadFixture(deployParanetFixture)); }); it('The contract is named "ParanetNeuroIncentivesPool"', async () => { @@ -185,6 +205,216 @@ describe('@v2 @unit ParanetNeuroIncentivesPool contract', function () { expect(await IncentivesPool.getNeuroBalance()).to.be.equal(neuroAmount); }); + it('Knowledge miner can claim the correct NEURO reward', async () => { + const { paranetKAStorageContract, paranetKATokenId, paranetId } = await registerParanet(accounts, Paranet, 1); + + const incentivesPoolParams = { + paranetKAStorageContract, + paranetKATokenId, + tracToNeuroEmissionMultiplier: hre.ethers.utils.parseEther('0.5'), // 0.5 NEURO per 1 TRAC + paranetOperatorRewardPercentage: 1_000, // 10% + paranetIncentivizationProposalVotersRewardPercentage: 1_000, // 10% + }; + const IncentivesPool = await deployERC20NeuroIncentivesPool(accounts, incentivesPoolParams, 1); + + const neuroAmount = hre.ethers.utils.parseEther('1000'); + await NeuroERC20.transfer(IncentivesPool.address, neuroAmount); + + const knowledgeMiner = accounts[2]; + + // Simulate the knowledge miner minting a knowledge asset (spending TRAC) + const tokenAmount = '10'; + await createParanetKnowledgeAsset(knowledgeMiner, paranetKAStorageContract, paranetKATokenId, 2, tokenAmount); + + // Get unrewardedTracSpent + const unrewardedTracSpent = await ParanetKnowledgeMinersRegistry.getUnrewardedTracSpent( + knowledgeMiner.address, + paranetId, + ); + + // Act + const claimableReward = await IncentivesPool.connect(knowledgeMiner).getClaimableKnowledgeMinerRewardAmount(); + + // Assert + const minersRewardPercentage = + PERCENTAGE_SCALING_FACTOR - + incentivesPoolParams.paranetOperatorRewardPercentage - + incentivesPoolParams.paranetIncentivizationProposalVotersRewardPercentage; + + const expectedReward = unrewardedTracSpent + .mul(incentivesPoolParams.tracToNeuroEmissionMultiplier) + .div(EMISSION_MULTIPLIER_SCALING_FACTOR) + .mul(minersRewardPercentage) + .div(PERCENTAGE_SCALING_FACTOR); + + expect(claimableReward).to.equal(expectedReward); + + const initialNeuroBalance = await NeuroERC20.balanceOf(knowledgeMiner.address); + + // Claim the reward + await IncentivesPool.connect(knowledgeMiner).claimKnowledgeMinerReward(); + + // Check balances + const finalNeuroBalance = await NeuroERC20.balanceOf(knowledgeMiner.address); + expect(finalNeuroBalance.sub(initialNeuroBalance)).to.equal(expectedReward); + + const claimedNeuro = await IncentivesPool.minerClaimedNeuro(knowledgeMiner.address); + expect(claimedNeuro).to.equal(expectedReward); + + const totalMinersClaimedNeuro = await IncentivesPool.totalMinersClaimedNeuro(); + expect(totalMinersClaimedNeuro).to.equal(expectedReward); + + const newUnrewardedTracSpent = await ParanetKnowledgeMinersRegistry.getUnrewardedTracSpent( + knowledgeMiner.address, + paranetId, + ); + expect(newUnrewardedTracSpent).to.equal(0); + }); + + it('Knowledge miner cannot claim more NEURO than their share', async () => { + const { paranetKAStorageContract, paranetKATokenId, paranetId } = await registerParanet(accounts, Paranet, 1); + + const incentivesPoolParams = { + paranetKAStorageContract, + paranetKATokenId, + tracToNeuroEmissionMultiplier: hre.ethers.utils.parseEther('1'), // 1 NEURO per 1 TRAC + paranetOperatorRewardPercentage: 2_000, // 20% + paranetIncentivizationProposalVotersRewardPercentage: 2_000, // 20% + }; + const IncentivesPool = await deployERC20NeuroIncentivesPool(accounts, incentivesPoolParams, 1); + + const neuroAmount = hre.ethers.utils.parseEther('50'); // Less NEURO in the pool + await NeuroERC20.transfer(IncentivesPool.address, neuroAmount); + + const knowledgeMiner = accounts[2]; + + // Simulate the knowledge miner minting a knowledge asset (spending TRAC) + const tokenAmount = '100'; // 100 TRAC + await createParanetKnowledgeAsset(knowledgeMiner, paranetKAStorageContract, paranetKATokenId, 2, tokenAmount); + + // Act + const claimableReward = await IncentivesPool.connect(knowledgeMiner).getClaimableKnowledgeMinerRewardAmount(); + + // Assert + const minersRewardPercentage = + PERCENTAGE_SCALING_FACTOR - + incentivesPoolParams.paranetOperatorRewardPercentage - + incentivesPoolParams.paranetIncentivizationProposalVotersRewardPercentage; + + // Expected reward based on NEURO balance and miners' percentage + const minersRewardLimit = neuroAmount.mul(minersRewardPercentage).div(PERCENTAGE_SCALING_FACTOR); + expect(claimableReward).to.equal(minersRewardLimit); + + const initialNeuroBalance = await NeuroERC20.balanceOf(knowledgeMiner.address); + + // Claim the reward + await IncentivesPool.connect(knowledgeMiner).claimKnowledgeMinerReward(); + + // Check balances + const finalNeuroBalance = await NeuroERC20.balanceOf(knowledgeMiner.address); + expect(finalNeuroBalance.sub(initialNeuroBalance)).to.equal(minersRewardLimit); + + const claimedNeuro = await IncentivesPool.minerClaimedNeuro(knowledgeMiner.address); + expect(claimedNeuro).to.equal(minersRewardLimit); + + const totalMinersClaimedNeuro = await IncentivesPool.totalMinersClaimedNeuro(); + expect(totalMinersClaimedNeuro).to.equal(minersRewardLimit); + + // Unrewarded TRAC should not be zero since they couldn't claim the full amount + const newUnrewardedTracSpent = await ParanetKnowledgeMinersRegistry.getUnrewardedTracSpent( + knowledgeMiner.address, + paranetId, + ); + expect(newUnrewardedTracSpent).to.be.gt(0); + }); + + it('Only authorized users can claim rewards', async () => { + const { paranetKAStorageContract, paranetKATokenId } = await registerParanet(accounts, Paranet, 1); + const incentivesPoolParams = { + paranetKAStorageContract, + paranetKATokenId, + tracToNeuroEmissionMultiplier: hre.ethers.utils.parseEther('1'), + paranetOperatorRewardPercentage: 1_000, + paranetIncentivizationProposalVotersRewardPercentage: 1_000, + }; + const IncentivesPool = await deployERC20NeuroIncentivesPool(accounts, incentivesPoolParams, 1); + + const unauthorizedUser = accounts[5]; + + await expect(IncentivesPool.connect(unauthorizedUser).claimKnowledgeMinerReward()).to.be.revertedWith( + 'Fn can only be used by K-Miners', + ); + + await expect(IncentivesPool.connect(unauthorizedUser).claimParanetOperatorReward()).to.be.revertedWith( + 'Fn can only be used by operator', + ); + + await expect(IncentivesPool.connect(unauthorizedUser).claimIncentivizationProposalVoterReward()).to.be.revertedWith( + 'Fn can only be used by voter', + ); + }); + + it('Emission multiplier update process works correctly', async () => { + const { paranetKAStorageContract, paranetKATokenId } = await registerParanet(accounts, Paranet, 1); + const incentivesPoolParams = { + paranetKAStorageContract, + paranetKATokenId, + tracToNeuroEmissionMultiplier: hre.ethers.utils.parseEther('1'), + paranetOperatorRewardPercentage: 1_000, + paranetIncentivizationProposalVotersRewardPercentage: 1_000, + }; + const IncentivesPool = await deployERC20NeuroIncentivesPool(accounts, incentivesPoolParams, 1); + + // Initiate an emission multiplier update + const newMultiplier = hre.ethers.utils.parseEther('2'); // New multiplier + await IncentivesPool.connect(accounts[0]).initiateNeuroEmissionMultiplierUpdate(newMultiplier); + + // Check that the update is scheduled + const neuroEmissionMultipliers = await IncentivesPool.getNeuroEmissionMultipliers(); + expect(neuroEmissionMultipliers.length).to.equal(2); + expect(neuroEmissionMultipliers[1].multiplier).to.equal(newMultiplier); + expect(neuroEmissionMultipliers[1].finalized).to.equal(false); + + // Try to finalize before delay period + await expect(IncentivesPool.connect(accounts[0]).finalizeNeuroEmissionMultiplierUpdate()).to.be.revertedWith( + 'Delay period not yet passed', + ); + + // Increase time to pass the delay + const delay = await IncentivesPool.neuroEmissionMultiplierUpdateDelay(); + await time.increase(delay.toNumber() + 1); + + // Finalize the update + await IncentivesPool.connect(accounts[0]).finalizeNeuroEmissionMultiplierUpdate(); + + // Check that the multiplier is updated + const updatedMultipliers = await IncentivesPool.getNeuroEmissionMultipliers(); + expect(updatedMultipliers[1].finalized).to.equal(true); + }); + + it('Cannot add voters exceeding MAX_CUMULATIVE_VOTERS_WEIGHT', async () => { + const { paranetKAStorageContract, paranetKATokenId } = await registerParanet(accounts, Paranet, 1); + const incentivesPoolParams = { + paranetKAStorageContract, + paranetKATokenId, + tracToNeuroEmissionMultiplier: hre.ethers.utils.parseEther('1'), + paranetOperatorRewardPercentage: 1_000, + paranetIncentivizationProposalVotersRewardPercentage: 1_000, + }; + const IncentivesPool = await deployERC20NeuroIncentivesPool(accounts, incentivesPoolParams, 1); + + // Try to add voters exceeding the maximum cumulative weight + const voters = []; + for (let i = 0; i < 11; i++) { + voters.push({ addr: accounts[i].address, weight: 1_000 }); + } + + await expect(IncentivesPool.connect(accounts[0]).addVoters(voters)).to.be.revertedWith( + 'Cumulative weight is too big', + ); + }); + + // Helper functions async function registerParanet(accounts: SignerWithAddress[], Paranet: Paranet, number: number) { const assetInputArgs = { assertionId: getHashFromNumber(number), @@ -249,13 +479,14 @@ describe('@v2 @unit ParanetNeuroIncentivesPool contract', function () { } async function createParanetKnowledgeAsset( + knowledgeMinerAccount: SignerWithAddress, paranetKAStorageContract: string, paranetKATokenId: number, - number: number, + assertionIdNumber: number, tokenAmount: string, ) { const assetInputArgs = { - assertionId: getHashFromNumber(number), + assertionId: getHashFromNumber(assertionIdNumber), size: 3, triplesNumber: 1, chunksNumber: 1, @@ -265,12 +496,12 @@ describe('@v2 @unit ParanetNeuroIncentivesPool contract', function () { immutable_: false, }; - await Token.connect(accounts[100 + number]).increaseAllowance( + await Token.connect(knowledgeMinerAccount).increaseAllowance( ServiceAgreementV1.address, assetInputArgs.tokenAmount, ); - await Paranet.connect(accounts[100 + number]).mintKnowledgeAsset( + await Paranet.connect(knowledgeMinerAccount).mintKnowledgeAsset( paranetKAStorageContract, paranetKATokenId, assetInputArgs, @@ -280,8 +511,4 @@ describe('@v2 @unit ParanetNeuroIncentivesPool contract', function () { function getHashFromNumber(number: number) { return hre.ethers.utils.keccak256(hre.ethers.utils.solidityPack(['uint256'], [number])); } - - function getknowledgeAssetId(address: string, number: number) { - return hre.ethers.utils.keccak256(hre.ethers.utils.solidityPack(['address', 'uint256'], [address, number])); - } });