diff --git a/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts deleted file mode 100644 index 168166745..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { EpochManager } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { - deriveChannelKey, - formatGRT, - GraphNetworkContracts, - helpers, - randomHexBytes, - toBN, - toGRT, -} from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { BigNumber as BN } from 'bignumber.js' -import { expect } from 'chai' -import { BigNumber, constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { HashZero, WeiPerEther } = constants - -const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] - -describe('Rewards - Calculations', () => { - const graph = hre.graph() - let governor: SignerWithAddress - let curator1: SignerWithAddress - let curator2: SignerWithAddress - let indexer1: SignerWithAddress - let indexer2: SignerWithAddress - let assetHolder: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let epochManager: EpochManager - let staking: IStaking - let rewardsManager: RewardsManager - - // Derive some channel keys for each indexer used to sign attestations - const channelKey1 = deriveChannelKey() - const channelKey2 = deriveChannelKey() - - const subgraphDeploymentID1 = randomHexBytes() - const subgraphDeploymentID2 = randomHexBytes() - - const allocationID1 = channelKey1.address - const allocationID2 = channelKey2.address - - const metadata = HashZero - - const ISSUANCE_RATE_PERIODS = 4 // blocks required to issue 800 GRT rewards - const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block - - // Core formula that gets accumulated rewards per signal for a period of time - const getRewardsPerSignal = (k: BN, t: BN, s: BN): string => { - if (s.eq(0)) { - return '0' - } - return k.times(t).div(s).toPrecision(18).toString() - } - - // Tracks the accumulated rewards as totalSignalled or supply changes across snapshots - class RewardsTracker { - totalSignalled = BigNumber.from(0) - lastUpdatedBlock = 0 - accumulated = BigNumber.from(0) - - static async create() { - const tracker = new RewardsTracker() - await tracker.snapshot() - return tracker - } - - async snapshot() { - this.accumulated = this.accumulated.add(await this.accrued()) - this.totalSignalled = await grt.balanceOf(curation.address) - this.lastUpdatedBlock = await helpers.latestBlock() - return this - } - - async elapsedBlocks() { - const currentBlock = await helpers.latestBlock() - return currentBlock - this.lastUpdatedBlock - } - - async accrued() { - const nBlocks = await this.elapsedBlocks() - return this.accruedByElapsed(nBlocks) - } - - accruedByElapsed(nBlocks: BigNumber | number) { - const n = getRewardsPerSignal( - new BN(ISSUANCE_PER_BLOCK.toString()), - new BN(nBlocks.toString()), - new BN(this.totalSignalled.toString()), - ) - return toGRT(n) - } - } - - // Test accumulated rewards per signal - const shouldGetNewRewardsPerSignal = async (nBlocks = ISSUANCE_RATE_PERIODS) => { - // -- t0 -- - const tracker = await RewardsTracker.create() - - // Jump - await helpers.mine(nBlocks) - - // -- t1 -- - - // Contract calculation - const contractAccrued = await rewardsManager.getNewRewardsPerSignal() - // Local calculation - const expectedAccrued = await tracker.accrued() - - // Check - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - return expectedAccrued - } - - before(async function () { - const testAccounts = await graph.getTestAccounts() - ;[indexer1, indexer2, curator1, curator2, assetHolder] = testAccounts - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - epochManager = contracts.EpochManager - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, indexer2, curator1, curator2, assetHolder]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - - // Set the staking contract as the subgraph service so it can call takeRewards - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - context('issuing rewards', function () { - beforeEach(async function () { - // 5% minute rate (4 blocks) - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - }) - - describe('getNewRewardsPerSignal', function () { - it('accrued per signal when no tokens signalled', async function () { - // When there is no tokens signalled no rewards are accrued - await helpers.mineEpoch(epochManager) - const accrued = await rewardsManager.getNewRewardsPerSignal() - expect(accrued).eq(0) - }) - - it('accrued per signal when tokens signalled', async function () { - // Update total signalled - const tokensToSignal = toGRT('1000') - await curation.connect(curator1).mint(subgraphDeploymentID1, tokensToSignal, 0) - - // Check - await shouldGetNewRewardsPerSignal() - }) - - it('accrued per signal when signalled tokens w/ many subgraphs', async function () { - // Update total signalled - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) - - // Check - await shouldGetNewRewardsPerSignal() - - // Update total signalled - await curation.connect(curator2).mint(subgraphDeploymentID2, toGRT('250'), 0) - - // Check - await shouldGetNewRewardsPerSignal() - }) - }) - - describe('updateAccRewardsPerSignal', function () { - it('update the accumulated rewards per signal state', async function () { - // Update total signalled - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) - // Snapshot - const tracker = await RewardsTracker.create() - - // Update - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const contractAccrued = await rewardsManager.accRewardsPerSignal() - - // Check - const expectedAccrued = await tracker.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - }) - - it('update the accumulated rewards per signal state after many blocks', async function () { - // Update total signalled - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) - // Snapshot - const tracker = await RewardsTracker.create() - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Update - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const contractAccrued = await rewardsManager.accRewardsPerSignal() - - // Check - const expectedAccrued = await tracker.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - }) - }) - - describe('getAccRewardsForSubgraph', function () { - it('accrued for each subgraph', async function () { - // Option B model: rewards only accumulate when allocations exist - const tokensToAllocate = toGRT('12500') - const signalled1 = toGRT('1500') - const signalled2 = toGRT('500') - - // Setup both subgraphs with signal first - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - await curation.connect(curator2).mint(subgraphDeploymentID2, signalled2, 0) - - // Setup both allocations - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - await staking.connect(indexer2).stake(tokensToAllocate) - await staking - .connect(indexer2) - .allocateFrom( - indexer2.address, - subgraphDeploymentID2, - tokensToAllocate, - allocationID2, - metadata, - await channelKey2.generateProof(indexer2.address), - ) - - // Jump to accumulate more rewards - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Get rewards from contract - const contractRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const contractRewardsSG2 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID2) - - // Both subgraphs should have non-zero rewards - expect(contractRewardsSG1).to.be.gt(0) - expect(contractRewardsSG2).to.be.gt(0) - - // SG1 should have more rewards than SG2 (has more signal and allocation was created first) - expect(contractRewardsSG1).to.be.gt(contractRewardsSG2) - }) - - it('should return zero rewards when subgraph signal is below minimum threshold', async function () { - // Set a high minimum signal threshold - const highMinimumSignal = toGRT('2000') - await rewardsManager.connect(governor).setMinimumSubgraphSignal(highMinimumSignal) - - // Signal less than the minimum threshold - const lowSignal = toGRT('1000') - await curation.connect(curator1).mint(subgraphDeploymentID1, lowSignal, 0) - - // Jump some blocks to potentially accrue rewards - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Check that no rewards are accrued due to minimum signal threshold - const contractRewards = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - expect(contractRewards).eq(0) - }) - }) - - describe('onSubgraphSignalUpdate', function () { - it('update the accumulated rewards for subgraph state', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - Option B requires allocation for rewards to accumulate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Snapshot after allocation - const tracker1 = await RewardsTracker.create() - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Update - await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID1) - - // Check - const contractRewardsSG1 = (await rewardsManager.subgraphs(subgraphDeploymentID1)).accRewardsForSubgraph - const rewardsPerSignal1 = await tracker1.accrued() - const expectedRewardsSG1 = rewardsPerSignal1.mul(signalled1).div(WeiPerEther) - expect(toRound(expectedRewardsSG1)).eq(toRound(contractRewardsSG1)) - - const contractAccrued = await rewardsManager.accRewardsPerSignal() - const expectedAccrued = await tracker1.accrued() - expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) - - const contractBlockUpdated = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - const expectedBlockUpdated = await helpers.latestBlock() - expect(expectedBlockUpdated).eq(contractBlockUpdated) - }) - }) - - describe('getAccRewardsPerAllocatedToken', function () { - it('accrued per allocated token', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Check - const sg1 = await rewardsManager.subgraphs(subgraphDeploymentID1) - // We trust this function because it was individually tested in previous test - const accRewardsForSubgraphSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const accruedRewardsSG1 = accRewardsForSubgraphSG1.sub(sg1.accRewardsForSubgraphSnapshot) - const expectedRewardsAT1 = accruedRewardsSG1.mul(WeiPerEther).div(tokensToAllocate) - const contractRewardsAT1 = (await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1))[0] - expect(expectedRewardsAT1).eq(contractRewardsAT1) - }) - }) - - describe('onSubgraphAllocationUpdate', function () { - it('update the accumulated rewards for allocated tokens state', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Prepare expected results - // Option B model: accRewardsForSubgraph only tracks distributable rewards - // 2 blocks before allocation = reclaimed (NO_ALLOCATED_TOKENS), 5 blocks after = distributable - const expectedSubgraphRewards = toGRT('1000') // 5 blocks × 200 GRT/block - const expectedRewardsAT = toGRT('0.08') // 1000 GRT / 12500 allocated tokens - - // Update - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Check on demand results saved - const subgraph = await rewardsManager.subgraphs(subgraphDeploymentID1) - const contractSubgraphRewards = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const contractRewardsAT = subgraph.accRewardsPerAllocatedToken - - expect(toRound(expectedSubgraphRewards)).eq(toRound(contractSubgraphRewards)) - expect(toRound(expectedRewardsAT.mul(1000))).eq(toRound(contractRewardsAT.mul(1000))) - }) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts deleted file mode 100644 index e34ace2fd..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts +++ /dev/null @@ -1,745 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { EpochManager } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { - deriveChannelKey, - formatGRT, - GraphNetworkContracts, - helpers, - randomHexBytes, - toBN, - toGRT, -} from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const MAX_PPM = 1000000 - -// TODO: Behavior change - HorizonRewardsAssigned is no longer emitted when rewards == 0 -// Set to true if the old behavior is restored (emitting event for zero rewards) -const EMIT_EVENT_FOR_ZERO_REWARDS = false - -const { HashZero, WeiPerEther } = constants - -const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] - -describe('Rewards - Distribution', () => { - const graph = hre.graph() - let delegator: SignerWithAddress - let governor: SignerWithAddress - let curator1: SignerWithAddress - let curator2: SignerWithAddress - let indexer1: SignerWithAddress - let assetHolder: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let epochManager: EpochManager - let staking: IStaking - let rewardsManager: RewardsManager - - // Derive some channel keys for each indexer used to sign attestations - const channelKey1 = deriveChannelKey() - const channelKey2 = deriveChannelKey() - const channelKeyNull = deriveChannelKey() - - const subgraphDeploymentID1 = randomHexBytes() - const subgraphDeploymentID2 = randomHexBytes() - - const allocationID1 = channelKey1.address - const allocationID2 = channelKey2.address - const allocationIDNull = channelKeyNull.address - - const metadata = HashZero - - const ISSUANCE_RATE_PERIODS = 4 // blocks required to issue 800 GRT rewards - const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block - - before(async function () { - ;[delegator, curator1, curator2, indexer1, assetHolder] = await graph.getTestAccounts() - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - epochManager = contracts.EpochManager - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, curator1, curator2, assetHolder]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - - // Set the staking contract as the subgraph service so it can call takeRewards - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - context('issuing rewards', function () { - beforeEach(async function () { - // 5% minute rate (4 blocks) - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - }) - - describe('getRewards', function () { - it('calculate rewards using the subgraph signalled + allocated tokens', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Rewards - const contractRewards = await rewardsManager.getRewards(staking.address, allocationID1) - - // We trust using this function in the test because we tested it - // standalone in a previous test - const contractRewardsAT1 = (await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1))[0] - - const expectedRewards = contractRewardsAT1.mul(tokensToAllocate).div(WeiPerEther) - expect(expectedRewards).eq(contractRewards) - }) - it('rewards should be zero if the allocation is closed', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mine(ISSUANCE_RATE_PERIODS) - await helpers.mineEpoch(epochManager) - - // Close allocation - await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // Rewards - const contractRewards = await rewardsManager.getRewards(staking.address, allocationID1) - expect(contractRewards).eq(BigNumber.from(0)) - }) - it('rewards should be zero if the allocation does not exist', async function () { - // Rewards - const contractRewards = await rewardsManager.getRewards(staking.address, allocationIDNull) - expect(contractRewards).eq(BigNumber.from(0)) - }) - }) - - describe('takeRewards', function () { - interface DelegationParameters { - indexingRewardCut: BigNumber - queryFeeCut: BigNumber - cooldownBlocks: number - } - - async function setupIndexerAllocation() { - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } - - async function setupIndexerAllocationSignalingAfter() { - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - } - - async function setupIndexerAllocationWithDelegation( - tokensToDelegate: BigNumber, - delegationParams: DelegationParameters, - ) { - const tokensToAllocate = toGRT('12500') - - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Transfer some funds from the curator, I don't want to mint new tokens - await grt.connect(curator1).transfer(delegator.address, tokensToDelegate) - await grt.connect(delegator).approve(staking.address, tokensToDelegate) - - // Stake and set delegation parameters - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .setDelegationParameters(delegationParams.indexingRewardCut, delegationParams.queryFeeCut, 0) - - // Delegate - await staking.connect(delegator).delegate(indexer1.address, tokensToDelegate) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - } - - it('should distribute rewards on closed allocation and stake', async function () { - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - // Jump - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const beforeIndexer1Balance = await grt.balanceOf(indexer1.address) - const beforeStakingBalance = await grt.balanceOf(staking.address) - - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens - // The first snapshot is after allocating, that is 2 blocks after the signal is minted. - // The final snapshot is when we close the allocation, that happens 9 blocks after signal is minted. - // So the rewards will be ((issuancePerBlock * 7) / allocatedTokens) * allocatedTokens - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be collected for that indexer - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.indexer).eq(indexer1.address) - expect(event.allocationID).eq(allocationID1) - expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) - - // After state - const afterTokenSupply = await grt.totalSupply() - const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const afterIndexer1Balance = await grt.balanceOf(indexer1.address) - const afterStakingBalance = await grt.balanceOf(staking.address) - - // Check that rewards are put into indexer stake - const expectedIndexerStake = beforeIndexer1Stake.add(expectedIndexingRewards) - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) - // Check stake should have increased with the rewards staked - expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) - // Check indexer balance remains the same - expect(afterIndexer1Balance).eq(beforeIndexer1Balance) - // Check indexing rewards are kept in the staking contract - expect(toRound(afterStakingBalance)).eq(toRound(beforeStakingBalance.add(expectedIndexingRewards))) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) - }) - - it('does not revert with an underflow if the minimum signal changes', async function () { - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(14000)) - - // Jump - await helpers.mineEpoch(epochManager) - - // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx) - .emit(rewardsManager, 'HorizonRewardsAssigned') - .withArgs(indexer1.address, allocationID1, toBN(0)) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('does not revert with an underflow if the minimum signal changes, and signal came after allocation', async function () { - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocationSignalingAfter() - - await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(14000)) - - // Jump - await helpers.mineEpoch(epochManager) - - // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx) - .emit(rewardsManager, 'HorizonRewardsAssigned') - .withArgs(indexer1.address, allocationID1, toBN(0)) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('does not revert if signal was already under minimum', async function () { - await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(2000)) - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - // Jump - await helpers.mineEpoch(epochManager) - // Close allocation. At this point rewards should be collected for that indexer - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx) - .emit(rewardsManager, 'HorizonRewardsAssigned') - .withArgs(indexer1.address, allocationID1, toBN(0)) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - }) - - it('should distribute rewards on closed allocation and send to destination', async function () { - const destinationAddress = randomHexBytes(20) - await staking.connect(indexer1).setRewardsDestination(destinationAddress) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup - await setupIndexerAllocation() - - // Jump - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const beforeDestinationBalance = await grt.balanceOf(destinationAddress) - const beforeStakingBalance = await grt.balanceOf(staking.address) - - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens - // The first snapshot is after allocating, that is 2 blocks after the signal is minted. - // The final snapshot is when we close the allocation, that happens 9 blocks after signal is minted. - // So the rewards will be ((issuancePerBlock * 7) / allocatedTokens) * allocatedTokens - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be collected for that indexer - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.indexer).eq(indexer1.address) - expect(event.allocationID).eq(allocationID1) - expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) - - // After state - const afterTokenSupply = await grt.totalSupply() - const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - const afterDestinationBalance = await grt.balanceOf(destinationAddress) - const afterStakingBalance = await grt.balanceOf(staking.address) - - // Check that rewards are properly assigned - const expectedIndexerStake = beforeIndexer1Stake - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) - // Check stake should not have changed - expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) - // Check indexing rewards are received by the rewards destination - expect(toRound(afterDestinationBalance)).eq(toRound(beforeDestinationBalance.add(expectedIndexingRewards))) - // Check indexing rewards were not sent to the staking contract - expect(afterStakingBalance).eq(beforeStakingBalance) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) - }) - - it('should distribute rewards on closed allocation w/delegators', async function () { - // Setup - const delegationParams = { - indexingRewardCut: toBN('823000'), // 82.30% - queryFeeCut: toBN('80000'), // 8% - cooldownBlocks: 0, - } - const tokensToDelegate = toGRT('2000') - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - // Setup the allocation and delegators - await setupIndexerAllocationWithDelegation(tokensToDelegate, delegationParams) - - // Jump - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeDelegationPool = await staking.delegationPools(indexer1.address) - const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - - // Close allocation. At this point rewards should be collected for that indexer - await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // After state - const afterTokenSupply = await grt.totalSupply() - const afterDelegationPool = await staking.delegationPools(indexer1.address) - const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) - - // Check that rewards are put into indexer stake (only indexer cut) - // Check that rewards are put into delegators pool accordingly - - // All the rewards in this subgraph go to this allocation. - // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens - // The first snapshot is after allocating, that is 1 block after the signal is minted. - // The final snapshot is when we close the allocation, that happens 4 blocks after signal is minted. - // So the rewards will be ((issuancePerBlock * 3) / allocatedTokens) * allocatedTokens - const expectedIndexingRewards = toGRT('600') - // Calculate delegators cut - const indexerRewards = delegationParams.indexingRewardCut.mul(expectedIndexingRewards).div(toBN(MAX_PPM)) - // Calculate indexer cut - const delegatorsRewards = expectedIndexingRewards.sub(indexerRewards) - // Check - const expectedIndexerStake = beforeIndexer1Stake.add(indexerRewards) - const expectedDelegatorsPoolTokens = beforeDelegationPool.tokens.add(delegatorsRewards) - const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) - expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) - expect(toRound(afterDelegationPool.tokens)).eq(toRound(expectedDelegatorsPoolTokens)) - // Check that tokens have been minted - expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) - }) - - it('should deny rewards if subgraph on denylist', async function () { - // Setup: create allocation BEFORE denying the subgraph - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Create allocation - await setupIndexerAllocation() - - // Jump to earn some rewards - await helpers.mineEpoch(epochManager) - - // Now deny the subgraph - this freezes accRewardsPerAllocatedToken - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Close allocation - pre-denial rewards should be denied - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - }) - - it('should handle zero rewards scenario correctly', async function () { - // Setup allocation with zero issuance to create zero rewards scenario - await rewardsManager.connect(governor).setIssuancePerBlock(0) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeStakingBalance = await grt.balanceOf(staking.address) - - // Close allocation. At this point rewards should be zero - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - if (EMIT_EVENT_FOR_ZERO_REWARDS) { - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) - } else { - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - } - - // After state - should be unchanged since no rewards were minted - const afterTokenSupply = await grt.totalSupply() - const afterStakingBalance = await grt.balanceOf(staking.address) - - // Check that no tokens were minted (rewards were 0) - expect(afterTokenSupply).eq(beforeTokenSupply) - expect(afterStakingBalance).eq(beforeStakingBalance) - }) - }) - }) - - describe('edge scenarios', function () { - it('close allocation on a subgraph that no longer have signal', async function () { - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Allocate - const tokensToAllocate = toGRT('12500') - await staking.connect(indexer1).stake(tokensToAllocate) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - - // Jump - await helpers.mineEpoch(epochManager) - - // Remove all signal from the subgraph - const curatorShares = await curation.getCuratorSignal(curator1.address, subgraphDeploymentID1) - await curation.connect(curator1).burn(subgraphDeploymentID1, curatorShares, 0) - - // Close allocation. At this point rewards should be collected for that indexer - await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - }) - }) - - describe('multiple allocations', function () { - it('two allocations in the same block with a GRT burn in the middle should succeed', async function () { - // If rewards are not monotonically increasing, this can trigger - // a subtraction overflow error as seen in mainnet tx: - // 0xb6bf7bbc446720a7409c482d714aebac239dd62e671c3c94f7e93dd3a61835ab - await helpers.mineEpoch(epochManager) - - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Stake - const tokensToStake = toGRT('12500') - await staking.connect(indexer1).stake(tokensToStake) - - // Allocate simultaneously, burning in the middle - const tokensToAlloc = toGRT('5000') - await helpers.setAutoMine(false) - const tx1 = await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - const tx2 = await grt.connect(indexer1).burn(toGRT(1)) - const tx3 = await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID2, - metadata, - await channelKey2.generateProof(indexer1.address), - ) - - await helpers.mine() - await helpers.setAutoMine(true) - - await expect(tx1).emit(staking, 'AllocationCreated') - await expect(tx2).emit(grt, 'Transfer') - await expect(tx3).emit(staking, 'AllocationCreated') - }) - it('two simultanous-similar allocations should get same amount of rewards', async function () { - await helpers.mineEpoch(epochManager) - - // Setup - await epochManager.connect(governor).setEpochLength(10) - - // Update total signalled - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Stake - const tokensToStake = toGRT('12500') - await staking.connect(indexer1).stake(tokensToStake) - - // Allocate simultaneously - const tokensToAlloc = toGRT('5000') - const tx1 = await staking.populateTransaction.allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - const tx2 = await staking.populateTransaction.allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAlloc, - allocationID2, - metadata, - await channelKey2.generateProof(indexer1.address), - ) - await staking.connect(indexer1).multicall([tx1.data, tx2.data]) - - // Jump - await helpers.mineEpoch(epochManager) - - // Close allocations simultaneously - const tx3 = await staking.populateTransaction.closeAllocation(allocationID1, randomHexBytes()) - const tx4 = await staking.populateTransaction.closeAllocation(allocationID2, randomHexBytes()) - const tx5 = await staking.connect(indexer1).multicall([tx3.data, tx4.data]) - - // Both allocations should receive the same amount of rewards - const receipt = await tx5.wait() - const event1 = rewardsManager.interface.parseLog(receipt.logs[1]).args - const event2 = rewardsManager.interface.parseLog(receipt.logs[5]).args - expect(event1.amount).eq(event2.amount) - }) - }) - - describe('rewards progression when collecting query fees', function () { - it('collect query fees with two subgraphs and one allocation', async function () { - async function getRewardsAccrual(subgraphs) { - const [sg1, sg2] = await Promise.all(subgraphs.map((sg) => rewardsManager.getAccRewardsForSubgraph(sg))) - return { - sg1, - sg2, - all: sg1.add(sg2), - } - } - - // set curation percentage - await staking.connect(governor).setCurationPercentage(100000) - - // allow the asset holder - const tokensToCollect = toGRT('10000') - - // signal in two subgraphs in the same block - const subgraphs = [subgraphDeploymentID1, subgraphDeploymentID2] - await hre.network.provider.send('evm_setAutomine', [false]) - for (const sub of subgraphs) { - await curation.connect(curator1).mint(sub, toGRT('1500'), 0) - } - await hre.network.provider.send('evm_mine') - await hre.network.provider.send('evm_setAutomine', [true]) - - // allocate - const tokensToAllocate = toGRT('12500') - await staking - .connect(indexer1) - .multicall([ - await staking.populateTransaction.stake(tokensToAllocate).then((tx) => tx.data), - await staking.populateTransaction - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate, - allocationID1, - metadata, - await channelKey1.generateProof(indexer1.address), - ) - .then((tx) => tx.data), - ]) - - // snapshot block after allocation (rewards before allocation were reclaimed for subgraph1) - const b1 = await epochManager.blockNum().then((x) => x.toNumber()) - - // move time fwd - await helpers.mineEpoch(epochManager) - - // collect funds into staking for that sub - await staking.connect(assetHolder).collect(tokensToCollect, allocationID1) - - // check rewards diff - await rewardsManager.getRewards(staking.address, allocationID1).then(formatGRT) - - await helpers.mine() - const accrual = await getRewardsAccrual(subgraphs) - const b2 = await epochManager.blockNum().then((x) => x.toNumber()) - - // Only check subgraph1 (with allocation) - subgraph2 has no allocation so its rewards - // are calculated from signal time, not from allocation time - // Each subgraph gets half the issuance (equal signal) - // Small tolerance for fixed-point arithmetic rounding - const expectedSg1Rewards = ISSUANCE_PER_BLOCK.div(2).mul(b2 - b1) - expect(toRound(accrual.sg1.mul(100).div(expectedSg1Rewards))).eq(toRound(BigNumber.from(100))) - }) - }) -}) diff --git a/packages/issuance/test/unit/rewards-manager/calculations.t.sol b/packages/issuance/test/unit/rewards-manager/calculations.t.sol new file mode 100644 index 000000000..58f890613 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/calculations.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerCalculationsTest is RewardsManagerSharedTest { + uint256 internal constant ISSUANCE_RATE_PERIODS = 4; + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_2 = keccak256("subgraphDeploymentID2"); + + address internal allocationID1 = makeAddr("allocationID1"); + address internal allocationID2 = makeAddr("allocationID2"); + address internal indexer2 = makeAddr("indexer2"); + + /// Signal, then allocate one block later, so the pre-allocation block is reclaimed (NO_ALLOCATED_TOKENS). + function _signalAndAllocate() internal { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + } + + // -- Review Focus 4: same-block semantics -- + + function test_Rewards_ZeroWhenNoBlocksElapsed() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + rm.updateAccRewardsPerSignal(); + uint256 first = rm.accRewardsPerSignal(); + assertEq(first, _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1000 ether)); + + rm.updateAccRewardsPerSignal(); + assertEq(rm.accRewardsPerSignal(), first); + assertEq(rm.getNewRewardsPerSignal(), 0); + } + + // -- getNewRewardsPerSignal -- + + function test_GetNewRewardsPerSignal_ZeroWhenNoTokensSignalled() public { + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + assertEq(rm.getNewRewardsPerSignal(), 0); + } + + function test_GetNewRewardsPerSignal_AccruedWhenTokensSignalled() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + assertEq(rm.getNewRewardsPerSignal(), _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1000 ether)); + } + + function test_GetNewRewardsPerSignal_AccruedWithManySubgraphs() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + assertEq(rm.getNewRewardsPerSignal(), _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1000 ether)); + + // The signal hook brings accRewardsPerSignal up to date, so new rewards restart from here + _signal(SUBGRAPH_DEPLOYMENT_ID_2, 250 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + assertEq(rm.getNewRewardsPerSignal(), _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1250 ether)); + } + + // -- updateAccRewardsPerSignal -- + + function test_UpdateAccRewardsPerSignal_UpdatesState() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + 1); + + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + assertEq(rm.accRewardsPerSignal(), _expectedRewardsPerSignal(1, 1000 ether)); + } + + function test_UpdateAccRewardsPerSignal_UpdatesStateAfterManyBlocks() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + assertEq(rm.accRewardsPerSignal(), _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, 1000 ether)); + } + + // -- getAccRewardsForSubgraph -- + + function test_GetAccRewardsForSubgraph_AccruedForEachSubgraph() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _signal(SUBGRAPH_DEPLOYMENT_ID_2, 500 ether); + + // SG1's allocation is created a block before SG2's + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + 1); + issuer.allocate(allocationID2, indexer2, SUBGRAPH_DEPLOYMENT_ID_2, ALLOCATION); + + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + uint256 rewardsSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1); + uint256 rewardsSG2 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_2); + assertGt(rewardsSG1, 0); + assertGt(rewardsSG2, 0); + assertGt(rewardsSG1, rewardsSG2); + } + + function test_GetAccRewardsForSubgraph_ZeroWhenSignalBelowMinimum() public { + vm.prank(governor); + rm.setMinimumSubgraphSignal(2000 ether); + + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 1000 ether); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + assertEq(rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1), 0); + } + + // -- onSubgraphSignalUpdate -- + + function test_OnSubgraphSignalUpdate_UpdatesAccRewardsForSubgraph() public { + _signalAndAllocate(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + vm.prank(governor); + rm.onSubgraphSignalUpdate(SUBGRAPH_DEPLOYMENT_ID_1); + + // Only the blocks since the allocation count for the subgraph; the block before it was reclaimed + (uint256 accRewardsForSubgraph, , , ) = rm.subgraphs(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(accRewardsForSubgraph, _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, SIGNAL, SIGNAL)); + + // accRewardsPerSignal is global and accrues from the signal block, in two updates (allocation, then hook) + assertEq( + rm.accRewardsPerSignal(), + _expectedRewardsPerSignal(1, SIGNAL) + _expectedRewardsPerSignal(ISSUANCE_RATE_PERIODS, SIGNAL) + ); + assertEq(rm.accRewardsPerSignalLastBlockUpdated(), block.number); + } + + // -- getAccRewardsPerAllocatedToken -- + + function test_GetAccRewardsPerAllocatedToken_AccruedPerAllocatedToken() public { + _signalAndAllocate(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + (, uint256 accRewardsForSubgraphSnapshot, , ) = rm.subgraphs(SUBGRAPH_DEPLOYMENT_ID_1); + uint256 accruedRewardsSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1) - + accRewardsForSubgraphSnapshot; + assertEq(accruedRewardsSG1, _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, SIGNAL, SIGNAL)); + + (uint256 rewardsPerAllocatedToken, ) = rm.getAccRewardsPerAllocatedToken(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(rewardsPerAllocatedToken, _expectedRewardsPerAllocatedToken(accruedRewardsSG1, ALLOCATION)); + } + + // -- onSubgraphAllocationUpdate -- + + function test_OnSubgraphAllocationUpdate_UpdatesAccRewardsPerAllocatedToken() public { + _signalAndAllocate(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + // Only distributable rewards (blocks since the allocation) count; the block before it was reclaimed + uint256 expectedSubgraphRewards = _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, SIGNAL, SIGNAL); + uint256 expectedRewardsPerAllocatedToken = _expectedRewardsPerAllocatedToken( + expectedSubgraphRewards, + ALLOCATION + ); + + vm.prank(governor); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID_1); + + (, , , uint256 accRewardsPerAllocatedToken) = rm.subgraphs(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1), expectedSubgraphRewards); + assertEq(accRewardsPerAllocatedToken, expectedRewardsPerAllocatedToken); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/config.t.sol b/packages/issuance/test/unit/rewards-manager/config.t.sol index 98522440f..6c133a66c 100644 --- a/packages/issuance/test/unit/rewards-manager/config.t.sol +++ b/packages/issuance/test/unit/rewards-manager/config.t.sol @@ -1,8 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import { Vm } from "forge-std/Vm.sol"; - import { RewardsManagerSharedTest } from "./shared.t.sol"; contract RewardsManagerConfigTest is RewardsManagerSharedTest { @@ -18,12 +16,6 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { rm.setSubgraphAvailabilityOracle(availabilityOracle); } - function _assertNotEmitted(Vm.Log[] memory logs, bytes32 topic) internal view { - for (uint256 i = 0; i < logs.length; ++i) { - if (logs[i].emitter == address(rm)) assertTrue(logs[i].topics[0] != topic, "unexpected event"); - } - } - // -- initialize -- function test_Initialize_RevertWhen_CalledOnImplementation() public { diff --git a/packages/issuance/test/unit/rewards-manager/distribution.t.sol b/packages/issuance/test/unit/rewards-manager/distribution.t.sol new file mode 100644 index 000000000..ccf10a54d --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/distribution.t.sol @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerDistributionTest is RewardsManagerSharedTest { + event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount); + event RewardsDenied(address indexed indexer, address indexed allocationID); + event Transfer(address indexed from, address indexed to, uint256 value); + + uint256 internal constant ISSUANCE_RATE_PERIODS = 4; + /// Blocks per `helpers.mineEpoch`: the old tests set the epoch length to 10 + uint256 internal constant EPOCH_LENGTH = 10; + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_2 = keccak256("subgraphDeploymentID2"); + + address internal allocationID1 = makeAddr("allocationID1"); + address internal allocationID2 = makeAddr("allocationID2"); + address internal allocationIDNull = makeAddr("allocationIDNull"); + + /// Signal, then allocate one block later, so the pre-allocation block is reclaimed (NO_ALLOCATED_TOKENS). + function _setupIndexerAllocation() internal { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + } + + function _setupIndexerAllocationSignalingAfter() internal { + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + 1); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + } + + /// Rewards for an allocation of `tokens` out of `allocatedTokens` on a subgraph holding all the signal. + function _expectedAllocationRewards( + uint256 blocks, + uint256 tokens, + uint256 allocatedTokens + ) internal pure returns (uint256) { + uint256 subgraphRewards = _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); + return (_expectedRewardsPerAllocatedToken(subgraphRewards, allocatedTokens) * tokens) / 1e18; + } + + /// RewardsManager half of the legacy-Staking close cases: event amount, mint to the issuer and total supply. + function _assertRewardsDistributedOnClose() internal { + // All the subgraph's rewards since the allocation go to it + uint256 expectedIndexingRewards = _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); + uint256 beforeTokenSupply = token.totalSupply(); + uint256 beforeIssuerBalance = token.balanceOf(address(issuer)); + + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedIndexingRewards); + uint256 rewards = _close(allocationID1); + + assertEq(rewards, expectedIndexingRewards); + assertEq(token.balanceOf(address(issuer)), beforeIssuerBalance + expectedIndexingRewards); + assertEq(token.totalSupply(), beforeTokenSupply + expectedIndexingRewards); + } + + // -- getRewards -- + + function test_GetRewards_UsesSignalledAndAllocatedTokens() public { + _setupIndexerAllocation(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + uint256 rewards = rm.getRewards(address(issuer), allocationID1); + + (uint256 accRewardsPerAllocatedToken, ) = rm.getAccRewardsPerAllocatedToken(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(rewards, (accRewardsPerAllocatedToken * ALLOCATION) / 1e18); + assertEq(rewards, _expectedAllocationRewards(ISSUANCE_RATE_PERIODS, ALLOCATION, ALLOCATION)); + } + + function test_GetRewards_ZeroWhenAllocationClosed() public { + _setupIndexerAllocation(); + vm.roll(block.number + ISSUANCE_RATE_PERIODS + EPOCH_LENGTH); + + _close(allocationID1); + + assertEq(rm.getRewards(address(issuer), allocationID1), 0); + } + + function test_GetRewards_ZeroWhenAllocationDoesNotExist() public view { + assertEq(rm.getRewards(address(issuer), allocationIDNull), 0); + } + + // -- takeRewards -- + + function test_TakeRewards_DistributesOnClosedAllocationAndStake() public { + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + _assertRewardsDistributedOnClose(); + } + + function test_TakeRewards_NoUnderflowWhenMinimumSignalChanges() public { + _setupIndexerAllocation(); + + vm.prank(governor); + rm.setMinimumSubgraphSignal(14_000 ether); + + vm.roll(block.number + EPOCH_LENGTH); + + vm.recordLogs(); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + } + + function test_TakeRewards_NoUnderflowWhenMinimumSignalChangesAndSignalAfterAllocation() public { + _setupIndexerAllocationSignalingAfter(); + + vm.prank(governor); + rm.setMinimumSubgraphSignal(14_000 ether); + + vm.roll(block.number + EPOCH_LENGTH); + + vm.recordLogs(); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + } + + function test_TakeRewards_NoRevertWhenSignalAlreadyUnderMinimum() public { + vm.prank(governor); + rm.setMinimumSubgraphSignal(2000 ether); + + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + vm.recordLogs(); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + } + + function test_TakeRewards_DistributesOnClosedAllocationToDestination() public { + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + _assertRewardsDistributedOnClose(); + } + + function test_TakeRewards_DistributesOnClosedAllocationWithDelegators() public { + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + _assertRewardsDistributedOnClose(); + } + + function test_TakeRewards_DeniesRewardsWhenSubgraphDenied() public { + vm.prank(governor); + rm.setSubgraphAvailabilityOracle(governor); + + // Allocation created before the subgraph is denied + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + vm.prank(governor); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + + vm.expectEmit(address(rm)); + emit RewardsDenied(indexer, allocationID1); + _close(allocationID1); + } + + function test_TakeRewards_ZeroRewards() public { + vm.prank(governor); + rm.setIssuancePerBlock(0); + + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + uint256 beforeTokenSupply = token.totalSupply(); + uint256 beforeIssuerBalance = token.balanceOf(address(issuer)); + + vm.recordLogs(); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), HorizonRewardsAssigned.selector); + + assertEq(token.totalSupply(), beforeTokenSupply); + assertEq(token.balanceOf(address(issuer)), beforeIssuerBalance); + } + + // -- edge scenarios -- + + function test_EdgeScenarios_CloseAllocationOnSubgraphWithNoSignal() public { + _setupIndexerAllocation(); + vm.roll(block.number + EPOCH_LENGTH); + + curation.unsignal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + + // Must not revert + _close(allocationID1); + } + + // -- multiple allocations -- + + function test_MultipleAllocations_SameBlockWithGrtBurnInTheMiddle() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + + uint256 tokensToAlloc = 5000 ether; + + // No vm.roll between the three calls: they run in the same block + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAlloc); + vm.expectEmit(address(token)); + emit Transfer(address(this), address(0), 1 ether); + token.burn(1 ether); + _allocate(allocationID2, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAlloc); + + (bool isActive1, , , , , ) = issuer.getAllocationData(allocationID1); + (bool isActive2, , , , , ) = issuer.getAllocationData(allocationID2); + assertTrue(isActive1); + assertTrue(isActive2); + assertEq(issuer.getSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1), 2 * tokensToAlloc); + } + + function test_MultipleAllocations_SimultaneousSimilarAllocationsGetSameRewards() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + + uint256 tokensToAlloc = 5000 ether; + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAlloc); + _allocate(allocationID2, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAlloc); + + vm.roll(block.number + EPOCH_LENGTH); + + uint256 expectedRewards = _expectedAllocationRewards(EPOCH_LENGTH, tokensToAlloc, 2 * tokensToAlloc); + + // Closed in the same block + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + uint256 rewards1 = _close(allocationID1); + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID2, expectedRewards); + uint256 rewards2 = _close(allocationID2); + + assertEq(rewards1, rewards2); + } + + // -- rewards progression when collecting query fees -- + + function test_RewardsProgressionWhenCollectingQueryFees_TwoSubgraphsOneAllocation() public { + uint256 curationFees = 1_000 ether; // 10% curation share of 10,000 GRT collected + + // Signal in two subgraphs in the same block + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _signal(SUBGRAPH_DEPLOYMENT_ID_2, SIGNAL); + vm.roll(block.number + 1); + + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + uint256 b1 = block.number; + + vm.roll(block.number + EPOCH_LENGTH); + + curation.addFees(SUBGRAPH_DEPLOYMENT_ID_1, curationFees); + + vm.roll(block.number + 1); + uint256 accrualSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1); + uint256 b2 = block.number; + + // Fees reach Curation without a RewardsManager hook, so the view prices every block since the + // allocation at the current signal: SG1's pool and the total both include the fees + assertEq(accrualSG1, _expectedSubgraphRewards(b2 - b1, 2 * SIGNAL + curationFees, SIGNAL + curationFees)); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/shared.t.sol b/packages/issuance/test/unit/rewards-manager/shared.t.sol index 7c4c8416e..6f65b0472 100644 --- a/packages/issuance/test/unit/rewards-manager/shared.t.sol +++ b/packages/issuance/test/unit/rewards-manager/shared.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; +import { Vm } from "forge-std/Vm.sol"; + import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; import { MockGraphToken } from "../mocks/MockGraphToken.sol"; import { IRewardsManagerLike } from "./IRewardsManagerLike.sol"; @@ -81,4 +83,26 @@ contract RewardsManagerSharedTest is ProtocolFixture { function _expectedRewardsPerSignal(uint256 blocks, uint256 totalSignal) internal pure returns (uint256) { return (blocks * ISSUANCE_PER_BLOCK * 1e18) / totalSignal; } + + /// Rewards a subgraph with `subgraphSignal` accrues over `blocks` while total signal is `totalSignal`. + function _expectedSubgraphRewards( + uint256 blocks, + uint256 totalSignal, + uint256 subgraphSignal + ) internal pure returns (uint256) { + return (_expectedRewardsPerSignal(blocks, totalSignal) * subgraphSignal) / 1e18; + } + + function _expectedRewardsPerAllocatedToken( + uint256 subgraphRewards, + uint256 allocatedTokens + ) internal pure returns (uint256) { + return (subgraphRewards * 1e18) / allocatedTokens; + } + + function _assertNotEmitted(Vm.Log[] memory logs, bytes32 topic) internal view { + for (uint256 i = 0; i < logs.length; ++i) { + if (logs[i].emitter == address(rm)) assertTrue(logs[i].topics[0] != topic, "unexpected event"); + } + } } diff --git a/scripts/contracts-migration/test-mapping/t5b.md b/scripts/contracts-migration/test-mapping/t5b.md new file mode 100644 index 000000000..4303f5e65 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5b.md @@ -0,0 +1,37 @@ +# T5b test mapping: RewardsManager calculations and distribution + +New tests are in `packages/issuance`, on T5a's `RewardsManagerSharedTest` (the RewardsManager Hardhat artifact behind a GraphProxy, wired to `MockGraphToken`, `RMCurationMock` and `RMIssuerMock` as the subgraph service). `curation.mint` becomes `_signal`, `curation.burn` becomes `curation.unsignal`, `staking.allocateFrom` becomes `_allocate`, `closeAllocation` becomes `_close`, and `helpers.mine(n)` / `helpers.mineEpoch` become `vm.roll` (an epoch is 10 blocks, the length the old `takeRewards` setups set). Old `mineEpoch` calls that only aligned to an epoch boundary are dropped, since nothing here reads the epoch. The separate stake/allocate transactions become a one-block roll between signal and allocation, so the pre-allocation block is reclaimed (`NO_ALLOCATED_TOKENS`) as before. Expected values are derived from the explicit rolls with `_expectedRewardsPerSignal`, `_expectedSubgraphRewards` and `_expectedRewardsPerAllocatedToken` (shared.t.sol), and asserted exactly where the old tests used `toRound`. No ported case allocates zero tokens. `test/unit/rewards-manager/calculations.t.sol::test_Rewards_ZeroWhenNoBlocksElapsed` is Review Focus 4 and has no old test. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getNewRewardsPerSignal › accrued per signal when no tokens signalled | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_ZeroWhenNoTokensSignalled | | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getNewRewardsPerSignal › accrued per signal when tokens signalled | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_AccruedWhenTokensSignalled | Exact instead of `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getNewRewardsPerSignal › accrued per signal when signalled tokens w/ many subgraphs | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_AccruedWithManySubgraphs | Exact instead of `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › updateAccRewardsPerSignal › update the accumulated rewards per signal state | test/unit/rewards-manager/calculations.t.sol::test_UpdateAccRewardsPerSignal_UpdatesState | The block Hardhat mined for the update tx is an explicit one-block roll; exact | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › updateAccRewardsPerSignal › update the accumulated rewards per signal state after many blocks | test/unit/rewards-manager/calculations.t.sol::test_UpdateAccRewardsPerSignal_UpdatesStateAfterManyBlocks | Exact instead of `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getAccRewardsForSubgraph › accrued for each subgraph | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsForSubgraph_AccruedForEachSubgraph | SG2's allocation is one block after SG1's, as in the old test; same `gt` assertions | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getAccRewardsForSubgraph › should return zero rewards when subgraph signal is below minimum threshold | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsForSubgraph_ZeroWhenSignalBelowMinimum | | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › onSubgraphSignalUpdate › update the accumulated rewards for subgraph state | test/unit/rewards-manager/calculations.t.sol::test_OnSubgraphSignalUpdate_UpdatesAccRewardsForSubgraph | `accRewardsPerSignal` asserted as its real value (accrued from the signal block); the old test compared it with the since-allocation tracker, which only matched under `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › getAccRewardsPerAllocatedToken › accrued per allocated token | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsPerAllocatedToken_AccruedPerAllocatedToken | Old self-referential check kept; the accrual is also checked against the formula | +| packages/contracts-test/tests/unit/rewards/rewards-calculations.test.ts | Rewards - Calculations › issuing rewards › onSubgraphAllocationUpdate › update the accumulated rewards for allocated tokens state | test/unit/rewards-manager/calculations.t.sol::test_OnSubgraphAllocationUpdate_UpdatesAccRewardsPerAllocatedToken | Hard-coded 1000 GRT / 0.08 (5 Hardhat blocks) replaced by values derived from the 4-block roll; exact | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › getRewards › calculate rewards using the subgraph signalled + allocated tokens | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_UsesSignalledAndAllocatedTokens | Old self-referential check kept, plus the formula value | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › getRewards › rewards should be zero if the allocation is closed | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_ZeroWhenAllocationClosed | | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › getRewards › rewards should be zero if the allocation does not exist | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_ZeroWhenAllocationDoesNotExist | | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should distribute rewards on closed allocation and stake | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationAndStake | Port of the RewardsManager half (`HorizonRewardsAssigned` amount, mint to the issuer, total supply), exact instead of `toRound`. Dropped: legacy Staking behaviour; Horizon equivalent in subgraph-service test/unit/subgraphService/collect/indexing/indexing.t.sol, `test_SubgraphService_Collect_Indexing` | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › does not revert with an underflow if the minimum signal changes | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoUnderflowWhenMinimumSignalChanges | `not.emit` via recordLogs (`EMIT_EVENT_FOR_ZERO_REWARDS` is false) | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › does not revert with an underflow if the minimum signal changes, and signal came after allocation | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoUnderflowWhenMinimumSignalChangesAndSignalAfterAllocation | `not.emit` via recordLogs | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › does not revert if signal was already under minimum | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoRevertWhenSignalAlreadyUnderMinimum | `not.emit` via recordLogs | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should distribute rewards on closed allocation and send to destination | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationToDestination | Port of the RewardsManager half (`HorizonRewardsAssigned` amount, mint to the issuer, total supply), exact instead of `toRound`. Dropped: legacy Staking behaviour; Horizon equivalent in subgraph-service test/unit/subgraphService/collect/indexing/indexing.t.sol, `test_SubgraphService_Collect_Indexing_RewardsDestination` | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should distribute rewards on closed allocation w/delegators | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationWithDelegators | Port of the RewardsManager half (`HorizonRewardsAssigned` amount, mint to the issuer, total supply), exact instead of `toRound`. Dropped: legacy Staking behaviour; Horizon equivalent in subgraph-service test/unit/subgraphService/collect/indexing/indexing.t.sol, `test_SubgraphService_Collect_Indexing_WithDelegation`. The delegation setup only affected Staking, so the RewardsManager half has the same setup as the other two | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should deny rewards if subgraph on denylist | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DeniesRewardsWhenSubgraphDenied | | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › issuing rewards › takeRewards › should handle zero rewards scenario correctly | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_ZeroRewards | Staking balance becomes the issuer's balance | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › edge scenarios › close allocation on a subgraph that no longer have signal | test/unit/rewards-manager/distribution.t.sol::test_EdgeScenarios_CloseAllocationOnSubgraphWithNoSignal | No assertions in the old test (must not revert); one-block roll between unsignal and close, as the separate Hardhat txs | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › multiple allocations › two allocations in the same block with a GRT burn in the middle should succeed | test/unit/rewards-manager/distribution.t.sol::test_MultipleAllocations_SameBlockWithGrtBurnInTheMiddle | `MockGraphToken.burn` from the test contract; consecutive calls with no roll; `AllocationCreated` (legacy Staking event) becomes both allocations active in the issuer | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › multiple allocations › two simultanous-similar allocations should get same amount of rewards | test/unit/rewards-manager/distribution.t.sol::test_MultipleAllocations_SimultaneousSimilarAllocationsGetSameRewards | Multicalls become consecutive calls with no roll; both event amounts are the derived value | +| packages/contracts-test/tests/unit/rewards/rewards-distribution.test.ts | Rewards - Distribution › rewards progression when collecting query fees › collect query fees with two subgraphs and one allocation | test/unit/rewards-manager/distribution.t.sol::test_RewardsProgressionWhenCollectingQueryFees_TwoSubgraphsOneAllocation | `staking.collect(10,000 GRT)` at 10% curation becomes `addFees(sd, 1_000 ether)`. The old ±1% check against half the issuance was vacuous (`toRound` formats wei ratios as GRT, so both sides were `"0"`); the real value is asserted exactly: fees reach Curation with no hook, so the view prices every block since the allocation at the post-fee signal | + +## Coverage + +`contracts/rewards/RewardsManager.sol`: MISSING getAccRewardsPerSignal getAllocatedIssuancePerBlock getDefaultReclaimAddress getRawIssuancePerBlock setDefaultReclaimAddress setReclaimAddress: ported in T5c–T5f. `reclaimRewards` matches the grep only through `RMIssuerMock.reclaim`; no T5b test calls it (T5d). `takeRewards` is now reached by the distribution tests through `RMIssuerMock.close`, which must be the caller. The checklist is closed out in T5f. + +`contracts/staking/libs/MathUtils.sol`: `diffOrZero` is internal and reached through `getAccRewardsPerAllocatedToken` (called directly in calculations.t.sol and distribution.t.sol). `min` and `weightedAverageRoundingUp`: reached only through legacy Staking.