From 39196ef58881b1d075a776b432b078a3044bde0e Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Tue, 27 Oct 2026 10:00:44 -0300 Subject: [PATCH] test(issuance): port legacy RewardsManager suite to Foundry Port the unique cases of rewards.test.ts to legacySuite.t.sol, map the duplicates to the existing tests and delete the TS file. Move the shared deny, oracle, reclaim and accrue helpers into shared.t.sol. --- .../tests/unit/rewards/rewards.test.ts | 1419 ----------------- .../test/unit/rewards-manager/config.t.sol | 17 +- .../unit/rewards-manager/eligibility.t.sol | 63 +- .../unit/rewards-manager/legacySuite.t.sol | 188 +++ .../rewards-manager/mocks/RMIssuerMock.sol | 3 +- .../test/unit/rewards-manager/reclaim.t.sol | 65 +- .../test/unit/rewards-manager/shared.t.sol | 23 + .../signalAllocationUpdate.t.sol | 5 - .../contracts-migration/test-mapping/t5f.md | 71 + 9 files changed, 335 insertions(+), 1519 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards.test.ts create mode 100644 packages/issuance/test/unit/rewards-manager/legacySuite.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t5f.md diff --git a/packages/contracts-test/tests/unit/rewards/rewards.test.ts b/packages/contracts-test/tests/unit/rewards/rewards.test.ts deleted file mode 100644 index 240d78178..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards.test.ts +++ /dev/null @@ -1,1419 +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, utils } 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 - -// Condition identifiers (matching RewardsCondition.sol) -const INDEXER_INELIGIBLE = utils.id('INDEXER_INELIGIBLE') -const SUBGRAPH_DENIED = utils.id('SUBGRAPH_DENIED') - -const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] - -describe('Rewards', () => { - const graph = hre.graph() - let delegator: SignerWithAddress - let governor: SignerWithAddress - let curator1: SignerWithAddress - let curator2: SignerWithAddress - let indexer1: SignerWithAddress - let indexer2: SignerWithAddress - let oracle: 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 - - // 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 () { - ;[delegator, curator1, curator2, indexer1, indexer2, oracle, 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, 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')) - } - - // HACK: we set the staking contract as the subgraph service to make tests pass. - // This is due to the test suite being outdated. - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('configuration', function () { - describe('initialize', function () { - it('should revert when called on implementation contract', async function () { - // Try to call initialize on the implementation contract (should revert with onlyImpl) - const tx = rewardsManager.connect(governor).initialize(contracts.Controller.address) - await expect(tx).revertedWith('Only implementation') - }) - }) - - describe('issuance per block update', function () { - it('reject set issuance per block if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setIssuancePerBlock(toGRT('1.025')) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set issuance rate to minimum allowed (0)', async function () { - const newIssuancePerBlock = toGRT('0') - await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) - expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) - }) - - it('should set issuance rate', async function () { - const newIssuancePerBlock = toGRT('100.025') - await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) - expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) - expect(await rewardsManager.accRewardsPerSignalLastBlockUpdated()).eq(await helpers.latestBlock()) - }) - }) - - describe('subgraph availability service', function () { - it('reject set subgraph oracle if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setSubgraphAvailabilityOracle(oracle.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set subgraph oracle if governor', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - expect(await rewardsManager.subgraphAvailabilityOracle()).eq(oracle.address) - }) - - it('reject to deny subgraph if not the oracle', async function () { - const tx = rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - await expect(tx).revertedWith('Caller must be the subgraph availability oracle') - }) - - it('should deny subgraph', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - const blockNum = await helpers.latestBlock() - await expect(tx) - .emit(rewardsManager, 'RewardsDenylistUpdated') - .withArgs(subgraphDeploymentID1, blockNum + 1) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - }) - - it('should allow removing subgraph from denylist', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - // First deny the subgraph - await rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - - // Then remove from denylist - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, false) - await expect(tx).emit(rewardsManager, 'RewardsDenylistUpdated').withArgs(subgraphDeploymentID1, 0) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(false) - }) - - it('reject setMinimumSubgraphSignal if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setMinimumSubgraphSignal(toGRT('1000')) - await expect(tx).revertedWith('Not authorized') - }) - - it('should allow setMinimumSubgraphSignal from subgraph availability oracle', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - const newMinimumSignal = toGRT('2000') - const tx = rewardsManager.connect(oracle).setMinimumSubgraphSignal(newMinimumSignal) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('minimumSubgraphSignal') - - expect(await rewardsManager.minimumSubgraphSignal()).eq(newMinimumSignal) - }) - - it('should allow setMinimumSubgraphSignal from governor', async function () { - const newMinimumSignal = toGRT('3000') - const tx = rewardsManager.connect(governor).setMinimumSubgraphSignal(newMinimumSignal) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('minimumSubgraphSignal') - - expect(await rewardsManager.minimumSubgraphSignal()).eq(newMinimumSignal) - }) - }) - }) - - 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 () { - // Setup: signal and allocations for two subgraphs - const signalled1 = toGRT('1500') - const signalled2 = toGRT('500') - const tokensToStake = toGRT('100000') - const tokensToAllocate = toGRT('10000') - - // Mint signal for both subgraphs - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - await curation.connect(curator2).mint(subgraphDeploymentID2, signalled2, 0) - - // Create allocations for both subgraphs so rewards are accumulated (not reclaimed as NO_ALLOCATED_TOKENS) - await grt.connect(governor).mint(indexer1.address, tokensToStake) - await grt.connect(indexer1).approve(staking.address, tokensToStake) - await staking.connect(indexer1).stake(tokensToStake) - - const channelKey1 = deriveChannelKey() - await staking - .connect(indexer1) - .allocate( - subgraphDeploymentID1, - tokensToAllocate, - channelKey1.address, - HashZero, - await channelKey1.generateProof(indexer1.address), - ) - - const channelKey2 = deriveChannelKey() - await staking - .connect(indexer1) - .allocate( - subgraphDeploymentID2, - tokensToAllocate, - channelKey2.address, - HashZero, - await channelKey2.generateProof(indexer1.address), - ) - - // Record starting point for both subgraphs - const startRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const startRewardsSG2 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID2) - - // Jump blocks to accrue rewards - await helpers.mine(ISSUANCE_RATE_PERIODS) - - // Get final rewards - const endRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) - const endRewardsSG2 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID2) - - // Calculate accrued rewards during the period - const accruedSG1 = endRewardsSG1.sub(startRewardsSG1) - const accruedSG2 = endRewardsSG2.sub(startRewardsSG2) - - // Verify proportional distribution: SG1 has 75% of signal (1500/2000), SG2 has 25% (500/2000) - // So SG1 should accrue 3x the rewards of SG2 - const totalAccrued = accruedSG1.add(accruedSG2) - expect(totalAccrued).to.be.gt(0, 'Should have accrued rewards') - - // Check proportional distribution (allow small rounding error) - const sg1Share = accruedSG1.mul(100).div(totalAccrued) - const sg2Share = accruedSG2.mul(100).div(totalAccrued) - expect(sg1Share.toNumber()).to.be.closeTo(75, 1, 'SG1 should have ~75% of rewards') - expect(sg2Share.toNumber()).to.be.closeTo(25, 1, 'SG2 should have ~25% of rewards') - }) - - 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) - - // Create an allocation so rewards are accumulated (not reclaimed as NO_ALLOCATED_TOKENS) - const tokensToStake = toGRT('100000') - const tokensToAllocate = toGRT('10000') - await grt.connect(governor).mint(indexer1.address, tokensToStake) - await grt.connect(indexer1).approve(staking.address, tokensToStake) - await staking.connect(indexer1).stake(tokensToStake) - const channelKey = deriveChannelKey() - await staking - .connect(indexer1) - .allocate( - subgraphDeploymentID1, - tokensToAllocate, - channelKey.address, - HashZero, - await channelKey.generateProof(indexer1.address), - ) - - // Snapshot - 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 - // With Option B model: accRewardsForSubgraph only tracks DISTRIBUTABLE rewards (not reclaimed) - // 7 blocks total: 2 blocks before allocation (reclaimed, NOT in accRewardsForSubgraph) + 5 blocks after allocation - const expectedSubgraphRewards = toGRT('1000') // only distributable rewards (5 blocks) - // accRewardsPerAllocatedToken reflects distributable rewards (5 blocks) - const expectedRewardsAT = toGRT('0.08') // allocated during 5 blocks: 1000 GRT, divided by 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))) - }) - }) - - 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) - }) - - it('should handle zero rewards with denylist and reclaim address', async function () { - // Setup reclaim address for SubgraphDenied - const reclaimWallet = assetHolder - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation with zero rewards (no signal) - 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), - ) - - // Close allocation immediately (same epoch) - should have zero rewards - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // Should not emit events for zero rewards - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - 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') - } - }) - - it('should handle zero rewards with eligibility oracle and reclaim address', async function () { - // Setup reclaim address for IndexerIneligible - const reclaimWallet = assetHolder - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - - // Setup eligibility oracle that denies - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation with zero rewards (no signal) - 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), - ) - - // Close allocation immediately (same epoch) - should have zero rewards - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // Should not emit events for zero rewards - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - 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') - } - }) - - it('should allow collecting pre-denial rewards after undeny', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with epoch boundary - await helpers.mineEpoch(epochManager) - - // Create allocation - await setupIndexerAllocation() - - // Jump to earn rewards - await helpers.mineEpoch(epochManager) - - // Check rewards exist before deny - const rewardsBefore = await rewardsManager.getRewards(staking.address, allocationID1) - expect(rewardsBefore).gt(0) - - // Deny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Jump while denied (these rewards should be reclaimed, not available) - await helpers.mineEpoch(epochManager) - - // Undeny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, false) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - const beforeIndexerStake = await staking.getIndexerStakedTokens(indexer1.address) - - // Close allocation - should receive pre-denial rewards (frozen value) - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Should emit HorizonRewardsAssigned with non-zero rewards - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.indexer).eq(indexer1.address) - expect(event.allocationID).eq(allocationID1) - expect(event.amount).gt(0) - - // After state - tokens should have been minted - const afterTokenSupply = await grt.totalSupply() - const afterIndexerStake = await staking.getIndexerStakedTokens(indexer1.address) - expect(afterTokenSupply).gt(beforeTokenSupply) - expect(afterIndexerStake).gt(beforeIndexerStake) - }) - - it('allocation created while denied should only earn post-undeny rewards', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup signal first - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Deny the subgraph BEFORE creating allocation - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Jump while denied - await helpers.mineEpoch(epochManager) - - // Create allocation while denied - snapshot = frozen accRewardsPerAllocatedToken - 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 while still denied - await helpers.mineEpoch(epochManager) - - // Undeny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, false) - - // Jump to earn post-undeny rewards - await helpers.mineEpoch(epochManager) - - // Before state - const beforeTokenSupply = await grt.totalSupply() - - // Close allocation - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // After state - const afterTokenSupply = await grt.totalSupply() - - // Should have earned ONLY post-undeny rewards (not denied-period rewards) - // The rewards should be small since only 1 epoch passed after undeny - const event = rewardsManager.interface.parseLog(receipt.logs[1]).args - expect(event.amount).gt(0) - expect(afterTokenSupply).gt(beforeTokenSupply) - }) - - it('should reclaim denied-period rewards via onSubgraphAllocationUpdate', async function () { - // Setup reclaim address - const reclaimWallet = assetHolder - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup signal and allocation - await setupIndexerAllocation() - - // Jump to earn rewards - await helpers.mineEpoch(epochManager) - - // Deny the subgraph - this freezes accRewardsPerAllocatedToken - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Record reclaim wallet balance - const beforeReclaimBalance = await grt.balanceOf(reclaimWallet.address) - - // Jump while denied - new rewards should be reclaimed - await helpers.mineEpoch(epochManager) - - // Trigger onSubgraphAllocationUpdate by creating another allocation - // This will reclaim the denied-period rewards - // Use allocationID2 which already has a matching channelKey2 - const tokensToAllocate2 = toGRT('5000') - await staking.connect(indexer1).stake(tokensToAllocate2) - await staking - .connect(indexer1) - .allocateFrom( - indexer1.address, - subgraphDeploymentID1, - tokensToAllocate2, - allocationID2, - metadata, - await channelKey2.generateProof(indexer1.address), - ) - - // Reclaim wallet should have received rewards - const afterReclaimBalance = await grt.balanceOf(reclaimWallet.address) - expect(afterReclaimBalance).gt(beforeReclaimBalance) - }) - }) - }) - - 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/config.t.sol b/packages/issuance/test/unit/rewards-manager/config.t.sol index 6c133a66c..57754211d 100644 --- a/packages/issuance/test/unit/rewards-manager/config.t.sol +++ b/packages/issuance/test/unit/rewards-manager/config.t.sol @@ -11,11 +11,6 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { address internal availabilityOracle = makeAddr("availabilityOracle"); - function _setAvailabilityOracle() internal { - vm.prank(governor); - rm.setSubgraphAvailabilityOracle(availabilityOracle); - } - // -- initialize -- function test_Initialize_RevertWhen_CalledOnImplementation() public { @@ -117,7 +112,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_SetOracle() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); assertEq(rm.subgraphAvailabilityOracle(), availabilityOracle); } @@ -128,7 +123,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_Deny() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); vm.expectEmit(address(rm)); emit RewardsDenylistUpdated(SUBGRAPH_DEPLOYMENT_ID_1, block.number); @@ -138,7 +133,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_Undeny() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); vm.prank(availabilityOracle); rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); @@ -152,7 +147,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_DenyAlreadyDeniedIsNoOp() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); vm.prank(availabilityOracle); rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); @@ -169,7 +164,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_UndenyNotDeniedIsNoOp() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); assertFalse(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); @@ -189,7 +184,7 @@ contract RewardsManagerConfigTest is RewardsManagerSharedTest { } function test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromOracle() public { - _setAvailabilityOracle(); + _setAvailabilityOracle(availabilityOracle); uint256 newMinimumSignal = 2000 ether; vm.expectEmit(address(rm)); diff --git a/packages/issuance/test/unit/rewards-manager/eligibility.t.sol b/packages/issuance/test/unit/rewards-manager/eligibility.t.sol index 30ac1a6fa..2e679fae3 100644 --- a/packages/issuance/test/unit/rewards-manager/eligibility.t.sol +++ b/packages/issuance/test/unit/rewards-manager/eligibility.t.sol @@ -17,23 +17,6 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { address internal allocationID1 = makeAddr("allocationID1"); - /// Old `setupIndexerAllocation` followed by `mineEpoch`: the allocation earns `EPOCH_LENGTH` blocks. - function _allocateAndAccrue() internal returns (uint256 expectedRewards) { - _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); - vm.roll(block.number + EPOCH_LENGTH); - return _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); - } - - function _setAvailabilityOracle() internal { - vm.prank(governor); - rm.setSubgraphAvailabilityOracle(governor); - } - - function _setDenied(bool deny) internal { - vm.prank(governor); - rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, deny); - } - function _findLog(Vm.Log[] memory logs, bytes32 topic) internal view returns (Vm.Log memory) { for (uint256 i = 0; i < logs.length; ++i) { if (logs[i].emitter == address(rm) && logs[i].topics[0] == topic) return logs[i]; @@ -99,7 +82,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityInTakeRewards_DeniesRewardsDueToEligibilityOracle() public { _useEligibilityOracle(false); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); @@ -108,7 +91,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityInTakeRewards_AllowsRewardsWhenOracleApproves() public { _useEligibilityOracle(true); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); @@ -118,12 +101,12 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { // -- rewards eligibility oracle and denylist interaction -- function test_RewardsEligibilityOracleAndDenylistInteraction_PrioritizesDenylistOverOracleWhenBothDeny() public { - _setAvailabilityOracle(); - _setDenied(true); + _setAvailabilityOracle(governor); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); _useEligibilityOracle(false); // Created while denied: the accumulator is frozen, so the allocation has no rewards to deny - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); @@ -137,7 +120,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { public { _useEligibilityOracle(false); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); @@ -146,7 +129,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesIneligibleMidAllocation() public { _useEligibilityOracle(true); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); // Eligibility is checked at close time, not at creation time oracle.setIndexerEligible(indexer, false); @@ -158,7 +141,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesEligibleMidAllocation() public { _useEligibilityOracle(false); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); oracle.setIndexerEligible(indexer, true); @@ -168,10 +151,10 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { } function test_RewardsEligibilityOracleAndDenylistInteraction_DenylistAddedMidAllocation() public { - _setAvailabilityOracle(); - _allocateAndAccrue(); + _setAvailabilityOracle(governor); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); - _setDenied(true); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + 1); vm.expectEmit(address(rm)); @@ -180,14 +163,14 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { } function test_RewardsEligibilityOracleAndDenylistInteraction_DenylistRemovedMidAllocation() public { - _setAvailabilityOracle(); - _setDenied(true); + _setAvailabilityOracle(governor); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); // Created while denied: rewards accrued while denied are reclaimed at the subgraph level - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); // Undeny snapshots the accumulator, then only post-undeny rewards accrue - _setDenied(false); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); vm.roll(block.number + EPOCH_LENGTH); vm.expectEmit(address(rm)); @@ -202,7 +185,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { function test_RewardsEligibilityOracleAndDenylistInteraction_AllowsRewardsWhenOracleIsZeroAddress() public { assertEq(address(rm.getProviderEligibilityOracle()), address(0)); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); @@ -214,7 +197,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { vm.prank(governor); rm.setRevertOnIneligible(true); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectRevert(bytes("Indexer not eligible for rewards")); issuer.close(allocationID1); @@ -225,7 +208,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { vm.prank(governor); rm.setRevertOnIneligible(true); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm)); emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); @@ -238,7 +221,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { _useEligibilityOracle(false); assertFalse(rm.getRevertOnIneligible()); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.expectEmit(address(rm), 1); emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); @@ -250,9 +233,9 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { uint256 snapshot = vm.snapshotState(); // Denylist denial: event WITHOUT amount. Deny after the allocation so there are rewards to deny. - _setAvailabilityOracle(); - _allocateAndAccrue(); - _setDenied(true); + _setAvailabilityOracle(governor); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + 1); vm.recordLogs(); @@ -268,7 +251,7 @@ contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { // Eligibility denial: event WITH amount, what the indexer would have received _useEligibilityOracle(false); - uint256 expectedRewards = _allocateAndAccrue(); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); diff --git a/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol b/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol new file mode 100644 index 000000000..a30ccbc52 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/legacySuite.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; +import { RewardsCondition } from "@graphprotocol/interfaces/contracts/contracts/rewards/RewardsCondition.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +/// @notice The cases of the legacy `rewards.test.ts` that no other RewardsManager port covers. +contract RewardsManagerLegacySuiteTest is RewardsManagerSharedTest { + event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount); + event RewardsDenied(address indexed indexer, address indexed allocationID); + event RewardsDeniedDueToEligibility(address indexed indexer, address indexed allocationID, uint256 amount); + event RewardsReclaimed( + bytes32 indexed reason, + uint256 amount, + address indexed indexer, + address indexed allocationID, + bytes32 subgraphDeploymentID + ); + + 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 reclaimWallet = makeAddr("reclaimWallet"); + + /// Allocation on a subgraph with no signal, closed one block later: there are no rewards to deny or reclaim. + /// Legacy Staking closed this under-one-epoch allocation through `onSubgraphAllocationUpdate`, not `takeRewards`, + /// so both paths are asserted. + function _allocateWithoutSignalAndClose() internal returns (Vm.Log[] memory logs) { + vm.roll(block.number + EPOCH_LENGTH); + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + 1); + + uint256 supplyBefore = token.totalSupply(); + vm.recordLogs(); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID_1); + assertEq(_close(allocationID1), 0); + logs = vm.getRecordedLogs(); + + assertEq(token.totalSupply(), supplyBefore); + assertEq(token.balanceOf(reclaimWallet), 0); + _assertNotEmitted(logs, RewardsReclaimed.selector); + _assertNotEmitted(logs, HorizonRewardsAssigned.selector); + } + + // -- getAccRewardsForSubgraph -- + + function test_GetAccRewardsForSubgraph_AccruedProportionallyToSignal() public { + uint256 signalled1 = 1500 ether; + uint256 signalled2 = 500 ether; + uint256 tokensToAllocate = 10_000 ether; + + _signal(SUBGRAPH_DEPLOYMENT_ID_1, signalled1); + _signal(SUBGRAPH_DEPLOYMENT_ID_2, signalled2); + vm.roll(block.number + 1); + + // Both subgraphs have an allocation, so their rewards accrue instead of being reclaimed + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, tokensToAllocate); + _allocate(allocationID2, SUBGRAPH_DEPLOYMENT_ID_2, tokensToAllocate); + + uint256 startRewardsSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1); + uint256 startRewardsSG2 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_2); + + vm.roll(block.number + ISSUANCE_RATE_PERIODS); + + uint256 accruedSG1 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_1) - startRewardsSG1; + uint256 accruedSG2 = rm.getAccRewardsForSubgraph(SUBGRAPH_DEPLOYMENT_ID_2) - startRewardsSG2; + uint256 totalAccrued = accruedSG1 + accruedSG2; + assertGt(totalAccrued, 0, "Should have accrued rewards"); + + // SG1 holds 75% of the signal (1500 / 2000), SG2 25% + assertApproxEqAbs((accruedSG1 * 100) / totalAccrued, 75, 1, "SG1 should have ~75% of rewards"); + assertApproxEqAbs((accruedSG2 * 100) / totalAccrued, 25, 1, "SG2 should have ~25% of rewards"); + + uint256 totalSignal = signalled1 + signalled2; + assertEq(accruedSG1, _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, totalSignal, signalled1)); + assertEq(accruedSG2, _expectedSubgraphRewards(ISSUANCE_RATE_PERIODS, totalSignal, signalled2)); + } + + // -- takeRewards -- + + function test_TakeRewards_ZeroRewardsWithDenylistAndReclaimAddress() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setAvailabilityOracle(governor); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + + Vm.Log[] memory logs = _allocateWithoutSignalAndClose(); + _assertNotEmitted(logs, RewardsDenied.selector); + } + + function test_TakeRewards_ZeroRewardsWithEligibilityOracleAndReclaimAddress() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + _useEligibilityOracle(false); + + Vm.Log[] memory logs = _allocateWithoutSignalAndClose(); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + } + + function test_TakeRewards_CollectsPreDenialRewardsAfterUndeny() public { + _setAvailabilityOracle(governor); + uint256 expectedRewards = _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + + uint256 rewardsBefore = rm.getRewards(address(issuer), allocationID1); + assertGt(rewardsBefore, 0); + assertEq(rewardsBefore, expectedRewards); + + // Denying freezes accRewardsPerAllocatedToken; the denied-period rewards are not the allocation's + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + vm.roll(block.number + EPOCH_LENGTH); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); + + uint256 supplyBefore = token.totalSupply(); + uint256 issuerBalanceBefore = token.balanceOf(address(issuer)); + + // Closed in the undeny block, so the amount is exactly the frozen pre-denial value + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, rewardsBefore); + uint256 rewards = _close(allocationID1); + + assertGt(rewards, 0); + assertEq(rewards, rewardsBefore); + assertEq(token.totalSupply(), supplyBefore + rewards); + assertEq(token.balanceOf(address(issuer)), issuerBalanceBefore + rewards); + } + + function test_TakeRewards_AllocationCreatedWhileDeniedEarnsOnlyPostUndenyRewards() public { + _setAvailabilityOracle(governor); + + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + 1); + + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + vm.roll(block.number + EPOCH_LENGTH); + + // Snapshot is the frozen accRewardsPerAllocatedToken + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + EPOCH_LENGTH); + + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); + vm.roll(block.number + EPOCH_LENGTH); + + // Only the epoch after the undeny counts + uint256 expectedRewards = _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); + uint256 supplyBefore = token.totalSupply(); + + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + uint256 rewards = _close(allocationID1); + + assertGt(rewards, 0); + assertEq(rewards, expectedRewards); + assertGt(token.totalSupply(), supplyBefore); + assertEq(token.totalSupply(), supplyBefore + expectedRewards); + } + + function test_TakeRewards_ReclaimsDeniedPeriodRewardsViaOnSubgraphAllocationUpdate() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setAvailabilityOracle(governor); + + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + + // Denying distributes the pre-denial rewards to the allocation, then freezes the accumulator + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.roll(block.number + EPOCH_LENGTH); + + // A second allocation triggers onSubgraphAllocationUpdate, which reclaims the denied period + uint256 deniedPeriodRewards = _subgraphRewards(EPOCH_LENGTH); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + deniedPeriodRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _allocate(allocationID2, SUBGRAPH_DEPLOYMENT_ID_1, 5000 ether); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, deniedPeriodRewards); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol index ce6fe3963..db08782f5 100644 --- a/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol @@ -33,7 +33,8 @@ contract RMIssuerMock is IRewardsIssuer { /// Takes rewards while the allocation is still active, then deactivates it. /// @dev Legacy Staking called `onSubgraphAllocationUpdate` instead of `takeRewards` for a zero POI or an - /// allocation under one epoch old. The ported tests never hit that case. + /// allocation under one epoch old. Only the zero-reward cases in `legacySuite.t.sol` hit that case, and they call + /// the hook themselves. function close(address allocationId) external returns (uint256 rewards) { rewards = rm.takeRewards(allocationId); Allocation storage allocation = _allocations[allocationId]; diff --git a/packages/issuance/test/unit/rewards-manager/reclaim.t.sol b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol index 32ed0ce78..0835c8ecc 100644 --- a/packages/issuance/test/unit/rewards-manager/reclaim.t.sol +++ b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol @@ -32,40 +32,19 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { // -- Helpers -- - /// Old `setupIndexerAllocation` followed by `mineEpoch`: the allocation earns `EPOCH_LENGTH` blocks. - function _allocateAndAccrue() internal { - _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); - vm.roll(block.number + EPOCH_LENGTH); - } - /// Rewards of the single allocation over `EPOCH_LENGTH` blocks. function _epochAllocationRewards() internal pure returns (uint256) { return _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); } - function _setReclaimAddress(bytes32 reason, address target) internal { - vm.prank(governor); - rm.setReclaimAddress(reason, target); - } - function _setDefaultReclaimAddress(address target) internal { vm.prank(governor); rm.setDefaultReclaimAddress(target); } - function _setAvailabilityOracle() internal { - vm.prank(governor); - rm.setSubgraphAvailabilityOracle(governor); - } - - function _setDenied(bool deny) internal { - vm.prank(governor); - rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, deny); - } - function _deny() internal { - _setAvailabilityOracle(); - _setDenied(true); + _setAvailabilityOracle(governor); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); } /// Allocation data on the issuer, as the old test's `MockSubgraphService.setAllocation`. @@ -88,7 +67,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { /// Old `default reclaim address fallback` beforeEach. modifier withAllocationAndAvailabilityOracle() { _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); - _setAvailabilityOracle(); + _setAvailabilityOracle(governor); _; } @@ -147,7 +126,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsSubgraphDenylist_MintsToReclaimAddressWhenSet() public { _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); _deny(); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); uint256 expectedRewards = _subgraphRewards(EPOCH_LENGTH); uint256 balanceBefore = token.balanceOf(reclaimWallet); @@ -169,11 +148,11 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsSubgraphDenylist_ReclaimsPreDenialRewardsWhenDeniedAfterAllocation() public { _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); - _setAvailabilityOracle(); + _setAvailabilityOracle(governor); // Allocate first, then deny: the pre-denial rewards stay with the allocation - _allocateAndAccrue(); - _setDenied(true); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); // Close in the next block, as in Hardhat: that block is reclaimed at the subgraph level vm.roll(block.number + 1); @@ -207,7 +186,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsSubgraphDenylist_NoMintWhenReclaimAddressNotSet() public { _deny(); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); @@ -221,7 +200,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsEligibility_MintsToReclaimAddressWhenSet() public { _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); uint256 expectedRewards = _epochAllocationRewards(); uint256 balanceBefore = token.balanceOf(reclaimWallet); @@ -243,7 +222,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimDeniedRewardsEligibility_NoMintWhenReclaimAddressNotSet() public { _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); vm.expectEmit(address(rm), 1); @@ -259,7 +238,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); _deny(); _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); uint256 expectedRewards = _subgraphRewards(EPOCH_LENGTH); uint256 subgraphDeniedBalanceBefore = token.balanceOf(reclaimWallet); @@ -287,7 +266,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); _deny(); _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); uint256 balanceBefore = token.balanceOf(otherWallet); @@ -306,12 +285,12 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { public { _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); - _setAvailabilityOracle(); + _setAvailabilityOracle(governor); _useEligibilityOracle(false); // Allocate first, then deny, so the rewards reach `_deniedRewards` - _allocateAndAccrue(); - _setDenied(true); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + 1); uint256 expectedRewards = _epochAllocationRewards(); @@ -339,7 +318,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenNeitherAddressConfigured() public { _deny(); _useEligibilityOracle(false); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); @@ -354,7 +333,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { { _deny(); _useEligibilityOracle(true); - _allocateAndAccrue(); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); vm.recordLogs(); _close(allocationID1); @@ -476,7 +455,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { withAllocationAndAvailabilityOracle { _setDefaultReclaimAddress(reclaimWallet); - _setDenied(true); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + MINED_BLOCKS); uint256 expectedRewards = _subgraphRewards(MINED_BLOCKS); @@ -503,7 +482,7 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { { _setDefaultReclaimAddress(otherWallet); _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); - _setDenied(true); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + MINED_BLOCKS); uint256 balanceBefore = token.balanceOf(reclaimWallet); @@ -672,12 +651,12 @@ contract RewardsManagerReclaimTest is RewardsManagerSharedTest { { _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); - _setAvailabilityOracle(); + _setAvailabilityOracle(governor); _useEligibilityOracle(false); // Allocate first, then deny, so the rewards reach `_deniedRewards` - _allocateAndAccrue(); - _setDenied(true); + _allocateAndAccrue(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); vm.roll(block.number + 1); uint256 allocationRewards = _epochAllocationRewards(); diff --git a/packages/issuance/test/unit/rewards-manager/shared.t.sol b/packages/issuance/test/unit/rewards-manager/shared.t.sol index e34e077f1..676a38f54 100644 --- a/packages/issuance/test/unit/rewards-manager/shared.t.sol +++ b/packages/issuance/test/unit/rewards-manager/shared.t.sol @@ -92,6 +92,29 @@ contract RewardsManagerSharedTest is ProtocolFixture { _allocate(allocationId, sd, ALLOCATION); } + /// Old `setupIndexerAllocation` followed by `mineEpoch`: the allocation earns `EPOCH_LENGTH` blocks. + function _allocateAndAccrue(bytes32 sd, address allocationId) internal returns (uint256 expectedRewards) { + _signalThenAllocate(sd, allocationId); + vm.roll(block.number + EPOCH_LENGTH); + return _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION); + } + + function _setAvailabilityOracle(address availabilityOracle) internal { + vm.prank(governor); + rm.setSubgraphAvailabilityOracle(availabilityOracle); + } + + /// Calls `setDenied` as governor, so the caller must have set governor as the availability oracle. + function _setDenied(bytes32 sd, bool deny) internal { + vm.prank(governor); + rm.setDenied(sd, deny); + } + + function _setReclaimAddress(bytes32 reason, address target) internal { + vm.prank(governor); + rm.setReclaimAddress(reason, target); + } + /// Sets the fixture's `oracle` as the eligibility oracle, answering `eligibleByDefault` for every indexer. function _useEligibilityOracle(bool eligibleByDefault) internal { oracle.setDefaultResponse(eligibleByDefault); diff --git a/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol b/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol index b5d295086..9b1e95875 100644 --- a/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol +++ b/packages/issuance/test/unit/rewards-manager/signalAllocationUpdate.t.sol @@ -37,11 +37,6 @@ contract RewardsManagerSignalAllocationUpdateTest is RewardsManagerSharedTest { vm.stopPrank(); } - function _setReclaimAddress(bytes32 reason, address target) internal { - vm.prank(governor); - rm.setReclaimAddress(reason, target); - } - function _perAllocatedToken(uint256 blocks) internal pure returns (uint256) { return _expectedRewardsPerAllocatedToken(_subgraphRewards(blocks), ALLOCATION); } diff --git a/scripts/contracts-migration/test-mapping/t5f.md b/scripts/contracts-migration/test-mapping/t5f.md new file mode 100644 index 000000000..937d62dca --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5f.md @@ -0,0 +1,71 @@ +# T5f test mapping: legacy RewardsManager suite (`rewards.test.ts`) + +`rewards.test.ts` repeats most of the cases T5a–T5e already ported from the split files. For each case the old assertions were compared with the existing Foundry test (not just the title). Where they match, the row points at that test. The cases whose assertions differ or that no other file has are in `test/unit/rewards-manager/legacySuite.t.sol`, on the shared `RewardsManagerSharedTest`. Translations follow T5b: `allocateFrom` is `_allocate`, `closeAllocation` is `_close`, `curation.mint` is `_signal`, `mine` / `mineEpoch` are `vm.roll` (`EPOCH_LENGTH` = 10), and `issuer` is the subgraph service the old suite pointed at legacy Staking. Legacy Staking assertions (stake, destination, delegation pool) follow the drop rule. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › initialize › should revert when called on implementation contract | test/unit/rewards-manager/config.t.sol::test_Initialize_RevertWhen_CalledOnImplementation | Duplicate; assertions match (`Only implementation`, called on the proxy) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › issuance per block update › reject set issuance per block if unauthorized | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_RevertWhen_Unauthorized | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › issuance per block update › should set issuance rate to minimum allowed (0) | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_SetToMinimumZero | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › issuance per block update › should set issuance rate | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_SetIssuanceRate | Duplicate; assertions match (value and `accRewardsPerSignalLastBlockUpdated == block`) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › reject set subgraph oracle if unauthorized | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_SetOracleUnauthorized | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should set subgraph oracle if governor | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetOracle | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › reject to deny subgraph if not the oracle | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_DenyNotOracle | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should deny subgraph | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_Deny | Duplicate; assertions match; `blockNum + 1` is `block.number` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should allow removing subgraph from denylist | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_Undeny | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › reject setMinimumSubgraphSignal if unauthorized | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_SetMinimumSubgraphSignalUnauthorized | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should allow setMinimumSubgraphSignal from subgraph availability oracle | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromOracle | Duplicate; assertions match (event and 2000 GRT) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › configuration › subgraph availability service › should allow setMinimumSubgraphSignal from governor | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromGovernor | Duplicate; assertions match (event and 3000 GRT) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getNewRewardsPerSignal › accrued per signal when no tokens signalled | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_ZeroWhenNoTokensSignalled | Duplicate; assertions match (zero; old `mineEpoch`, existing rolls 4 blocks) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getNewRewardsPerSignal › accrued per signal when tokens signalled | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_AccruedWhenTokensSignalled | Duplicate; assertions match; exact instead of `toRound` (which only compared whole GRT) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getNewRewardsPerSignal › accrued per signal when signalled tokens w/ many subgraphs | test/unit/rewards-manager/calculations.t.sol::test_GetNewRewardsPerSignal_AccruedWithManySubgraphs | Duplicate; assertions match; exact instead of `toRound` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › updateAccRewardsPerSignal › update the accumulated rewards per signal state | test/unit/rewards-manager/calculations.t.sol::test_UpdateAccRewardsPerSignal_UpdatesState | Duplicate; assertions match; the update tx's block is a one-block roll; exact | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › updateAccRewardsPerSignal › update the accumulated rewards per signal state after many blocks | test/unit/rewards-manager/calculations.t.sol::test_UpdateAccRewardsPerSignal_UpdatesStateAfterManyBlocks | Duplicate; assertions match; exact | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getAccRewardsForSubgraph › accrued for each subgraph | test/unit/rewards-manager/legacySuite.t.sol::test_GetAccRewardsForSubgraph_AccruedProportionallyToSignal | Differs from `test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsForSubgraph_AccruedForEachSubgraph` (only `gt` checks): this variant's 75/25 split (±1, `assertApproxEqAbs`) and `total > 0` are ported, with 1500/500 GRT signal and 10,000 GRT allocations on both subgraphs. Accruals also asserted exactly (600 / 200 GRT over 4 blocks) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getAccRewardsForSubgraph › should return zero rewards when subgraph signal is below minimum threshold | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsForSubgraph_ZeroWhenSignalBelowMinimum | Duplicate; assertions match (2000 GRT minimum, 1000 GRT signal, zero) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › onSubgraphSignalUpdate › update the accumulated rewards for subgraph state | test/unit/rewards-manager/calculations.t.sol::test_OnSubgraphSignalUpdate_UpdatesAccRewardsForSubgraph | Duplicate; same three values checked. The old `accRewardsPerSignal` check was vacuous: it compared the contract (accrued from the signal block, ~1.2 GRT) with the since-allocation tracker (~0.67 GRT), equal only under `toRound`. The existing test asserts the real value. The old 10,000 GRT `allocate` (existing: 12,500) enters no asserted value | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getAccRewardsPerAllocatedToken › accrued per allocated token | test/unit/rewards-manager/calculations.t.sol::test_GetAccRewardsPerAllocatedToken_AccruedPerAllocatedToken | Duplicate; assertions match (same self-referential check, plus the formula) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › onSubgraphAllocationUpdate › update the accumulated rewards for allocated tokens state | test/unit/rewards-manager/calculations.t.sol::test_OnSubgraphAllocationUpdate_UpdatesAccRewardsPerAllocatedToken | Duplicate; assertions match; 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.test.ts | Rewards › issuing rewards › getRewards › calculate rewards using the subgraph signalled + allocated tokens | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_UsesSignalledAndAllocatedTokens | Duplicate; assertions match (same self-referential check, plus the formula) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getRewards › rewards should be zero if the allocation is closed | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_ZeroWhenAllocationClosed | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › getRewards › rewards should be zero if the allocation does not exist | test/unit/rewards-manager/distribution.t.sol::test_GetRewards_ZeroWhenAllocationDoesNotExist | Duplicate; assertions match | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should distribute rewards on closed allocation and stake | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationAndStake | Duplicate; RewardsManager half matches (`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.test.ts | Rewards › issuing rewards › takeRewards › does not revert with an underflow if the minimum signal changes | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoUnderflowWhenMinimumSignalChanges | Duplicate; assertions match (`not.emit` via recordLogs; `EMIT_EVENT_FOR_ZERO_REWARDS` is false) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › 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 | Duplicate; assertions match (`not.emit` via recordLogs) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › does not revert if signal was already under minimum | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_NoRevertWhenSignalAlreadyUnderMinimum | Duplicate; assertions match (`not.emit` via recordLogs) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should distribute rewards on closed allocation and send to destination | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationToDestination | Duplicate; RewardsManager half matches (`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.test.ts | Rewards › issuing rewards › takeRewards › should distribute rewards on closed allocation w/delegators | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DistributesOnClosedAllocationWithDelegators | Duplicate; RewardsManager half matches (`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` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should deny rewards if subgraph on denylist | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_DeniesRewardsWhenSubgraphDenied | Duplicate; assertions match (`RewardsDenied(indexer, allocation)`) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should handle zero rewards scenario correctly | test/unit/rewards-manager/distribution.t.sol::test_TakeRewards_ZeroRewards | Duplicate; assertions match; staking balance is the issuer's balance | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should handle zero rewards with denylist and reclaim address | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_ZeroRewardsWithDenylistAndReclaimAddress | Unique. Legacy Staking closed this under-one-epoch allocation through `onSubgraphAllocationUpdate` only (no `takeRewards`); the test calls that hook, then `RMIssuerMock.close`, whose `takeRewards` returns 0 before the denial check because nothing is signalled. Same `not.emit` checks (recordLogs) across both, plus: close returns 0, total supply and reclaim wallet unchanged. Close is one block after the allocation, as the separate Hardhat txs. The allocation has 12,500 GRT, so the zero-token hook difference does not apply | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should handle zero rewards with eligibility oracle and reclaim address | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_ZeroRewardsWithEligibilityOracleAndReclaimAddress | Unique. Legacy Staking closed this under-one-epoch allocation through `onSubgraphAllocationUpdate` only (no `takeRewards`); the test calls that hook, then `RMIssuerMock.close`, whose `takeRewards` returns 0 before the denial check because nothing is signalled. Same `not.emit` checks (recordLogs) across both, plus: close returns 0, total supply and reclaim wallet unchanged. Close is one block after the allocation, as the separate Hardhat txs. The allocation has 12,500 GRT, so the zero-token hook difference does not apply. `MockRewardsEligibilityOracle(false)` is `_useEligibilityOracle(false)` | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should allow collecting pre-denial rewards after undeny | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_CollectsPreDenialRewardsAfterUndeny | Unique. `gt(0)` checks kept; the amount is asserted exactly as the pre-denial `getRewards` (10 blocks). The close runs in the undeny block (in Hardhat it was the next block, adding one post-undeny block that the old `gt` hid), so the amount is the frozen value. `afterIndexerStake > before` (legacy Staking) becomes the mint to the issuer, per the drop rule | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › allocation created while denied should only earn post-undeny rewards | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_AllocationCreatedWhileDeniedEarnsOnlyPostUndenyRewards | Unique. `gt` checks kept; the amount and supply increase are asserted exactly as the one post-undeny epoch (10 blocks) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › issuing rewards › takeRewards › should reclaim denied-period rewards via onSubgraphAllocationUpdate | test/unit/rewards-manager/legacySuite.t.sol::test_TakeRewards_ReclaimsDeniedPeriodRewardsViaOnSubgraphAllocationUpdate | Unique. Second allocation of 5,000 GRT as the old test. `gt` kept; `RewardsReclaimed(SUBGRAPH_DENIED, R(10 blocks), 0, 0, sd)` and the exact wallet increase asserted. Reclaim wallet is a `makeAddr` wallet (old: `assetHolder`) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › edge scenarios › close allocation on a subgraph that no longer have signal | test/unit/rewards-manager/distribution.t.sol::test_EdgeScenarios_CloseAllocationOnSubgraphWithNoSignal | Duplicate; assertions match (no assertions; must not revert) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › 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 | Duplicate; assertions match; `MockGraphToken.burn`, no roll between the calls; `AllocationCreated` becomes both allocations active in the issuer | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › multiple allocations › two simultanous-similar allocations should get same amount of rewards | test/unit/rewards-manager/distribution.t.sol::test_MultipleAllocations_SimultaneousSimilarAllocationsGetSameRewards | Duplicate; assertions match (equal amounts; both also exact) | +| packages/contracts-test/tests/unit/rewards/rewards.test.ts | Rewards › 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 | Duplicate. The old check was vacuous: `toRound` formats a ~100-wei ratio as GRT, so both sides were `"0"`. The existing test asserts the real value exactly. `collect(10,000 GRT)` at 10% curation is `addFees(sd, 1_000 ether)` | + +## Coverage + +`contracts/rewards/RewardsManager.sol` (checklist closed). The globals loop over `packages/issuance/test` prints no `MISSING`. A stricter loop over `test/unit/rewards-manager/*.t.sol` alone (no mocks) also finds every external/public name in `functionsHit` called directly as `rm.(` or through the proxy init data: `takeRewards`, `reclaimRewards`, `beforeIssuanceAllocationChange` and `onSubgraphSignalUpdate` are also called directly, not only through the mocks. `onSubgraphSignalUpdate` is further reached on every `_signal` (`RMCurationMock.signal` / `unsignal` call it first, as `Curation.mint` / `burn`). `initialize` is called directly in `config.t.sol` and runs in every `setUp` via the proxy init data. + +Internal functions and the modifier in the list are reached through public paths: + +- `_calcAllocationRewards`: `takeRewards`, `reclaimRewards`. +- `_calcRewards`: `getRewards`, `takeRewards`. +- `_deniedRewards`: `takeRewards` on a denied subgraph or ineligible indexer (`distribution.t.sol`, `eligibility.t.sol`, `reclaim.t.sol`). +- `_getNewRewardsPerSignal`: `getNewRewardsPerSignal`, `updateAccRewardsPerSignal`. +- `_getSubgraphAllocatedTokens`, `_getSubgraphRewardsState`: `getAccRewardsForSubgraph`, `getAccRewardsPerAllocatedToken`, both hooks. +- `_reclaimRewards`: the reclaim tests (`reclaim.t.sol`, `legacySuite.t.sol`). +- `_setDenied`: `setDenied`. +- `_setIssuancePerBlock`: `setIssuancePerBlock`. +- `_updateSubgraphRewards`: both hooks (`snapshotInversion.t.sol` also from an inverted snapshot). +- `onlySubgraphAvailabilityOracle`: `setDenied`, including `test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_DenyNotOracle`. + +`contracts/staking/libs/MathUtils.sol`: + +- `diffOrZero`: reached through `getAccRewardsPerAllocatedToken` (and so `getRewards`, `takeRewards`). +- `min`: reached only through legacy Staking. +- `weightedAverageRoundingUp`: reached only through legacy Staking.