From b75038d1db82f07d7f47497b77b4182747c164da Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 17:26:57 -0300 Subject: [PATCH] test(issuance): port RewardsManager eligibility and reclaim tests to Foundry --- .../rewards-eligibility-oracle.test.ts | 700 ----------- .../unit/rewards/rewards-reclaim.test.ts | 1115 ----------------- .../unit/rewards-manager/distribution.t.sol | 16 +- .../unit/rewards-manager/eligibility.t.sol | 283 +++++ .../test/unit/rewards-manager/reclaim.t.sol | 715 +++++++++++ .../test/unit/rewards-manager/shared.t.sol | 28 + .../rewards-manager/subgraphService.t.sol | 12 +- .../contracts-migration/test-mapping/t5d.md | 66 + 8 files changed, 1096 insertions(+), 1839 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts create mode 100644 packages/issuance/test/unit/rewards-manager/eligibility.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/reclaim.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t5d.md diff --git a/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts deleted file mode 100644 index c2137dc64..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts +++ /dev/null @@ -1,700 +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, GraphNetworkContracts, helpers, randomHexBytes, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { HashZero } = constants - -// Tolerance for fixed-point arithmetic rounding errors (matching Foundry tests) -const REWARDS_TOLERANCE = 20000 - -// Helper to check approximate equality for rewards (allows for rounding errors in fixed-point math) -function expectApproxEq(actual: BigNumber, expected: BigNumber, message: string) { - const diff = actual.sub(expected).abs() - expect( - diff.lte(REWARDS_TOLERANCE), - `${message}: difference ${diff.toString()} exceeds tolerance ${REWARDS_TOLERANCE}`, - ).to.be.true -} - -describe('Rewards - Eligibility Oracle', () => { - const graph = hre.graph() - let curator1: SignerWithAddress - let governor: SignerWithAddress - let indexer1: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let epochManager: EpochManager - let staking: IStaking - let rewardsManager: RewardsManager - - // Derive channel key for indexer used to sign attestations - const channelKey1 = deriveChannelKey() - - const subgraphDeploymentID1 = randomHexBytes() - - const allocationID1 = channelKey1.address - - const metadata = HashZero - - const ISSUANCE_PER_BLOCK = toGRT('200') // 200 GRT every block - - 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), - ) - } - - before(async function () { - const testAccounts = await graph.getTestAccounts() - curator1 = testAccounts[0] - indexer1 = testAccounts[1] - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - epochManager = contracts.EpochManager - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, curator1]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - - // Set the staking contract as the subgraph service so it can call takeRewards - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('rewards eligibility oracle', function () { - it('should reject setProviderEligibilityOracle if unauthorized', async function () { - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) - await mockOracle.deployed() - const tx = rewardsManager.connect(indexer1).setProviderEligibilityOracle(mockOracle.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set rewards eligibility oracle if governor', async function () { - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) - await mockOracle.deployed() - - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - await expect(tx) - .emit(rewardsManager, 'ProviderEligibilityOracleSet') - .withArgs(constants.AddressZero, mockOracle.address) - - expect(await rewardsManager.getProviderEligibilityOracle()).eq(mockOracle.address) - }) - - it('should allow setting rewards eligibility oracle to zero address', async function () { - // First set an oracle - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Then set to zero address to disable - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(constants.AddressZero) - await expect(tx) - .emit(rewardsManager, 'ProviderEligibilityOracleSet') - .withArgs(mockOracle.address, constants.AddressZero) - - expect(await rewardsManager.getProviderEligibilityOracle()).eq(constants.AddressZero) - }) - - it('should reject setting oracle that does not support interface', async function () { - // Try to set an EOA (externally owned account) as the rewards eligibility oracle - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(indexer1.address) - // EOA doesn't have code, so the call will revert (error message may vary by ethers version) - await expect(tx).to.be.reverted - }) - - it('should reject setting oracle that does not support IProviderEligibility interface', async function () { - // Deploy a contract that supports ERC165 but not IProviderEligibility - const MockERC165Factory = await hre.ethers.getContractFactory('contracts/tests/MockERC165.sol:MockERC165') - const mockERC165 = await MockERC165Factory.deploy() - await mockERC165.deployed() - - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(mockERC165.address) - await expect(tx).revertedWith('Contract does not support IProviderEligibility interface') - }) - - it('should not emit event when setting same oracle address', async function () { - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Setting the same oracle again should not emit an event - const tx = rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - await expect(tx).to.not.emit(rewardsManager, 'ProviderEligibilityOracleSet') - }) - }) - - describe('rewards eligibility in takeRewards', function () { - it('should deny rewards due to rewards eligibility oracle', async function () { - // Setup rewards eligibility oracle that denies rewards for indexer1 - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Default to deny - await mockOracle.deployed() - - // Set the rewards eligibility oracle - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Calculate expected rewards (for verification in the event) - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be denied due to eligibility - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsDeniedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(rewardsDeniedEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - const event = rewardsDeniedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should allow rewards when rewards eligibility oracle approves', async function () { - // Setup rewards eligibility oracle that allows rewards for indexer1 - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) // Default to allow - await mockOracle.deployed() - - // Set the rewards eligibility oracle - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Calculate expected rewards - const expectedIndexingRewards = toGRT('1400') - - // Close allocation. At this point rewards should be assigned normally - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsAssignedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'HorizonRewardsAssigned') - - expect(rewardsAssignedEvents.length).to.equal(1, 'HorizonRewardsAssigned event not found') - const event = rewardsAssignedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - }) - - describe('rewards eligibility oracle and denylist interaction', function () { - // Note: With subgraph-level denial, rewards for denied subgraphs are handled via - // onSubgraphAllocationUpdate() at the subgraph level. The allocation-level _deniedRewards() - // path (which checks eligibility) is not reached because rewards = 0 for allocations - // created while denied (frozen accumulator). - - it('should prioritize denylist over REO when both deny', async function () { - // Setup BOTH denial mechanisms - // 1. Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // 2. Setup REO that also 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 (created while denied - accumulator frozen) - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - subgraph denial takes precedence (handled at subgraph level) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // With subgraph-level denial, rewards = 0 (frozen accumulator), so allocation-level - // denial events are not emitted. Rewards are reclaimed at subgraph level. - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - }) - - it('should check REO when denylist allows but indexer ineligible', async function () { - // Setup: Subgraph is allowed (no denylist), but indexer is ineligible - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny indexer - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - const expectedIndexingRewards = toGRT('1400') - - // Close allocation - REO should be checked - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsDeniedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(rewardsDeniedEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - const event = rewardsDeniedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should handle indexer becoming ineligible mid-allocation', async function () { - // Setup: Indexer starts eligible - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) // Start eligible - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation while indexer is eligible - await setupIndexerAllocation() - - // Jump to next epoch (rewards accrue) - await helpers.mineEpoch(epochManager) - - // Change eligibility AFTER allocation created but BEFORE closing - await mockOracle.setIndexerEligible(indexer1.address, false) - - const expectedIndexingRewards = toGRT('1600') - - // Close allocation - should be denied at close time (not creation time) - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsDeniedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(rewardsDeniedEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - const event = rewardsDeniedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should handle indexer becoming eligible mid-allocation', async function () { - // Setup: Indexer starts ineligible - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) // Start ineligible - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation while indexer is ineligible - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Change eligibility before closing - await mockOracle.setIndexerEligible(indexer1.address, true) - - const expectedIndexingRewards = toGRT('1600') - - // Close allocation - should now be allowed - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsAssignedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'HorizonRewardsAssigned') - - expect(rewardsAssignedEvents.length).to.equal(1, 'HorizonRewardsAssigned event not found') - const event = rewardsAssignedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should handle denylist being added mid-allocation', async function () { - // Setup: Start with subgraph NOT denied - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation when subgraph is allowed - await setupIndexerAllocation() - - // Jump to next epoch (rewards accrue) - await helpers.mineEpoch(epochManager) - - // Deny the subgraph before closing allocation - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Close allocation - should be denied even though it was created when allowed - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - }) - - it('should handle denylist being removed mid-allocation', async function () { - // Setup: Start with subgraph denied - 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 (created while denied - accumulator frozen at this point) - await setupIndexerAllocation() - - // Jump to next epoch (rewards accrue but are reclaimed at subgraph level while denied) - await helpers.mineEpoch(epochManager) - - // Remove from denylist - this snapshots and starts accumulator updating again - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, false) - - // Wait for another epoch to accrue POST-undeny rewards - // Only post-undeny rewards are available (denied-period rewards were reclaimed) - await helpers.mineEpoch(epochManager) - - // Close allocation - should get post-undeny rewards only - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // Verify rewards are assigned (exact amount depends on blocks since undeny) - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned') - }) - - it('should allow rewards when REO is zero address (disabled)', async function () { - // Ensure REO is not set (zero address = disabled) - expect(await rewardsManager.getProviderEligibilityOracle()).eq(constants.AddressZero) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - const expectedIndexingRewards = toGRT('1400') - - // Close allocation - should get rewards (no eligibility check when REO is zero) - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const rewardsAssignedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'HorizonRewardsAssigned') - - expect(rewardsAssignedEvents.length).to.equal(1, 'HorizonRewardsAssigned event not found') - const event = rewardsAssignedEvents[0]! - expect(event.args[0]).to.equal(indexer1.address) - expect(event.args[1]).to.equal(allocationID1) - expectApproxEq(event.args[2], expectedIndexingRewards, 'rewards amount') - }) - - it('should revert for ineligible indexer when revertOnIneligible is true', async function () { - // Setup REO that denies indexer1 - 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) - - // Enable revert on ineligible - await rewardsManager.connect(governor).setRevertOnIneligible(true) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - should revert because indexer is ineligible - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).revertedWith('Indexer not eligible for rewards') - }) - - it('should not revert for eligible indexer when revertOnIneligible is true', async function () { - // Setup REO that allows indexer1 - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) // Allow - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Enable revert on ineligible - await rewardsManager.connect(governor).setRevertOnIneligible(true) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - should succeed (indexer is eligible) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned') - }) - - it('should reclaim (not revert) for ineligible indexer when revertOnIneligible is false', async function () { - // Setup REO that denies indexer1 - 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) - - // Ensure revertOnIneligible is false (default) - expect(await rewardsManager.getRevertOnIneligible()).eq(false) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - should succeed but deny rewards - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Should emit RewardsDeniedDueToEligibility (not revert) - const rewardsDeniedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(rewardsDeniedEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - }) - - it('should verify event structure differences between denial mechanisms', async function () { - // Test 1: Denylist denial - event WITHOUT amount - // Create allocation FIRST, then deny (so there are pre-denial rewards to deny) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - await helpers.mineEpoch(epochManager) - await setupIndexerAllocation() - await helpers.mineEpoch(epochManager) - - // Deny AFTER allocation created (so rewards have accrued) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - const tx1 = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt1 = await tx1.wait() - - // Find the RewardsDenied event - search in logs as events may be from different contracts - const rewardsDeniedEvent = receipt1.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .find((event) => event?.name === 'RewardsDenied') - - expect(rewardsDeniedEvent).to.not.be.undefined - - // Verify it only has indexer and allocationID (no amount parameter) - expect(rewardsDeniedEvent?.args?.indexer).to.equal(indexer1.address) - expect(rewardsDeniedEvent?.args?.allocationID).to.equal(allocationID1) - // RewardsDenied has only 2 args, amount should not exist - expect(rewardsDeniedEvent?.args?.amount).to.be.undefined - - // Reset for test 2 - await fixture.tearDown() - await fixture.setUp() - - // Test 2: REO denial - event WITH amount - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(false) - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - await helpers.mineEpoch(epochManager) - await setupIndexerAllocation() - await helpers.mineEpoch(epochManager) - - const tx2 = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt2 = await tx2.wait() - - // Find the RewardsDeniedDueToEligibility event - const eligibilityEvent = receipt2.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .find((event) => event?.name === 'RewardsDeniedDueToEligibility') - - expect(eligibilityEvent).to.not.be.undefined - - // Verify it has indexer, allocationID, AND amount - expect(eligibilityEvent?.args?.indexer).to.equal(indexer1.address) - expect(eligibilityEvent?.args?.allocationID).to.equal(allocationID1) - expect(eligibilityEvent?.args?.amount).to.not.be.undefined - expect(eligibilityEvent?.args?.amount).to.be.gt(0) // Shows what they would have gotten - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts deleted file mode 100644 index a1a17269a..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts +++ /dev/null @@ -1,1115 +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, GraphNetworkContracts, helpers, randomHexBytes, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber, constants, utils } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const { HashZero } = constants - -// Condition identifiers (matching RewardsCondition.sol) -const INDEXER_INELIGIBLE = utils.id('INDEXER_INELIGIBLE') -const SUBGRAPH_DENIED = utils.id('SUBGRAPH_DENIED') -const CLOSE_ALLOCATION = utils.id('CLOSE_ALLOCATION') -const NO_SIGNAL = utils.id('NO_SIGNAL') - -// Tolerance for fixed-point arithmetic rounding errors (matching Foundry tests) -const REWARDS_TOLERANCE = 20000 - -// Helper to check approximate equality for rewards (allows for rounding errors in fixed-point math) -function expectApproxEq(actual: BigNumber, expected: BigNumber, message: string) { - const diff = actual.sub(expected).abs() - expect( - diff.lte(REWARDS_TOLERANCE), - `${message}: difference ${diff.toString()} exceeds tolerance ${REWARDS_TOLERANCE}`, - ).to.be.true -} -const NO_ALLOCATED_TOKENS = utils.id('NO_ALLOCATED_TOKENS') -const BELOW_MINIMUM_SIGNAL = utils.id('BELOW_MINIMUM_SIGNAL') - -describe('Rewards - Reclaim Addresses', () => { - const graph = hre.graph() - let curator1: SignerWithAddress - let governor: SignerWithAddress - let indexer1: SignerWithAddress - let reclaimWallet: SignerWithAddress - let otherWallet: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let epochManager: EpochManager - let staking: IStaking - let rewardsManager: RewardsManager - - // Derive channel key for indexer used to sign attestations - const channelKey1 = deriveChannelKey() - - const subgraphDeploymentID1 = randomHexBytes() - - const allocationID1 = channelKey1.address - - const metadata = HashZero - - const ISSUANCE_PER_BLOCK = toGRT('200') // 200 GRT every block - - 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), - ) - } - - before(async function () { - const testAccounts = await graph.getTestAccounts() - curator1 = testAccounts[0] - indexer1 = testAccounts[1] - reclaimWallet = testAccounts[2] - otherWallet = testAccounts[3] - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - epochManager = contracts.EpochManager - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, curator1]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - - // Set the staking contract as the subgraph service so it can call takeRewards - await rewardsManager.connect(governor).setSubgraphService(staking.address) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('setReclaimAddress', function () { - it('should reject if not governor', async function () { - const tx = rewardsManager.connect(indexer1).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should reject setting reclaim address for NONE', async function () { - const tx = rewardsManager.connect(governor).setReclaimAddress(HashZero, reclaimWallet.address) - await expect(tx).revertedWith('Cannot set reclaim address for NONE') - }) - - it('should set eligibility reclaim address if governor', async function () { - const tx = rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - await expect(tx) - .emit(rewardsManager, 'ReclaimAddressSet') - .withArgs(INDEXER_INELIGIBLE, constants.AddressZero, reclaimWallet.address) - - expect(await rewardsManager.getReclaimAddress(INDEXER_INELIGIBLE)).eq(reclaimWallet.address) - }) - - it('should set subgraph denied reclaim address if governor', async function () { - const tx = rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await expect(tx) - .emit(rewardsManager, 'ReclaimAddressSet') - .withArgs(SUBGRAPH_DENIED, constants.AddressZero, reclaimWallet.address) - - expect(await rewardsManager.getReclaimAddress(SUBGRAPH_DENIED)).eq(reclaimWallet.address) - }) - - it('should allow setting to zero address', async function () { - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - - const tx = rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, constants.AddressZero) - await expect(tx) - .emit(rewardsManager, 'ReclaimAddressSet') - .withArgs(INDEXER_INELIGIBLE, reclaimWallet.address, constants.AddressZero) - - expect(await rewardsManager.getReclaimAddress(INDEXER_INELIGIBLE)).eq(constants.AddressZero) - }) - - it('should not emit event when setting same address', async function () { - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - - const tx = rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - await expect(tx).to.not.emit(rewardsManager, 'ReclaimAddressSet') - }) - }) - - describe('reclaim denied rewards - subgraph denylist', function () { - // Note: With the new denied-period rewards implementation, rewards for denied subgraphs - // are reclaimed at the subgraph level via onSubgraphAllocationUpdate(), not at the - // allocation level via _deniedRewards(). This means: - // - RewardsDenied is NOT emitted (legacy allocation-level event) - // - RewardsReclaimed IS emitted but with address(0) for indexer/allocationID - // - Allocations created while denied have frozen accumulator, so rewards = 0 at close - - it('should mint to reclaim address when subgraph denied and reclaim address set', async function () { - // Setup reclaim address - 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 - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Calculate expected rewards (approximate - timing can cause slight variations) - const expectedRewards = toGRT('1400') - - // Check reclaim wallet balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Close allocation - rewards are reclaimed at subgraph level (address(0) for indexer/allocationID) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // RewardsDenied is not emitted - denial is handled at subgraph level now - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - // RewardsReclaimed emitted with address(0) for indexer/allocationID (subgraph-level reclaim) - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // Check reclaim wallet received the rewards (allow for rounding errors) - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expectApproxEq(balanceAfter.sub(balanceBefore), expectedRewards, 'reclaimed rewards') - }) - - it('should reclaim pre-denial rewards via _deniedRewards when denied after allocation', async function () { - // Setup reclaim address BEFORE allocation - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Create allocation FIRST (before deny) - await setupIndexerAllocation() - - // Mine blocks to accrue rewards - await helpers.mineEpoch(epochManager) - - // Deny AFTER allocation — pre-denial rewards exist at the allocation level - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Check reclaim wallet balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Close allocation — pre-denial rewards flow through _deniedRewards → _reclaimRewards - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const parsedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((e) => e !== null) - - // RewardsDenied IS emitted (allocation-level denial for pre-denial rewards) - const deniedEvents = parsedEvents.filter((e) => e!.name === 'RewardsDenied') - expect(deniedEvents.length).to.equal(1, 'RewardsDenied event not found') - expect(deniedEvents[0]!.args[0]).to.equal(indexer1.address) - expect(deniedEvents[0]!.args[1]).to.equal(allocationID1) - - // RewardsReclaimed emitted with actual indexer/allocationID (allocation-level reclaim) - const reclaimEvents = parsedEvents.filter((e) => e!.name === 'RewardsReclaimed') - expect(reclaimEvents.length).to.be.gte(1, 'RewardsReclaimed event not found') - // Find the allocation-level reclaim (has non-zero indexer and allocationID) - const allocationReclaim = reclaimEvents.find((e) => e!.args[2] !== constants.AddressZero) - expect(allocationReclaim).to.not.be.undefined - expect(allocationReclaim!.args[0]).to.equal(SUBGRAPH_DENIED) - expectApproxEq(allocationReclaim!.args[1], toGRT('1400'), 'reclaimed amount') - expect(allocationReclaim!.args[2]).to.equal(indexer1.address) - expect(allocationReclaim!.args[3]).to.equal(allocationID1) - expect(allocationReclaim!.args[4]).to.equal(subgraphDeploymentID1) - - // Reclaim wallet received the pre-denial rewards (may receive additional rewards from subgraph-level reclaim) - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter.sub(balanceBefore)).gte(toGRT('1400')) - }) - - it('should not mint to reclaim address when reclaim address not set', async function () { - // Do NOT set reclaim address (defaults to zero 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 - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - no events emitted when no reclaim address configured - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // RewardsDenied is not emitted - denial is handled at subgraph level now - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - }) - - describe('reclaim denied rewards - eligibility', function () { - it('should mint to reclaim address when eligibility denied and reclaim address set', async function () { - // Setup reclaim address - 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 - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Calculate expected rewards - const expectedRewards = toGRT('1400') - - // Check reclaim wallet balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Close allocation - should emit both denial and reclaim events - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const parsedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((e) => e !== null) - - // Check RewardsDeniedDueToEligibility event - const denialEvents = parsedEvents.filter((e) => e!.name === 'RewardsDeniedDueToEligibility') - expect(denialEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - expect(denialEvents[0]!.args[0]).to.equal(indexer1.address) - expect(denialEvents[0]!.args[1]).to.equal(allocationID1) - expectApproxEq(denialEvents[0]!.args[2], expectedRewards, 'denied rewards amount') - - // Check RewardsReclaimed event exists and verify args - const reclaimEvents = parsedEvents.filter((e) => e!.name === 'RewardsReclaimed') - expect(reclaimEvents.length).to.be.gte(1, 'RewardsReclaimed event not found') - const reclaimEvent = reclaimEvents.find((e) => e!.args[0] === INDEXER_INELIGIBLE) - expect(reclaimEvent).to.not.be.undefined - expect(reclaimEvent!.args[0]).to.equal(INDEXER_INELIGIBLE) - expectApproxEq(reclaimEvent!.args[1], expectedRewards, 'reclaimed amount') - expect(reclaimEvent!.args[2]).to.equal(indexer1.address) - expect(reclaimEvent!.args[3]).to.equal(allocationID1) - expect(reclaimEvent!.args[4]).to.equal(subgraphDeploymentID1) - - // Check reclaim wallet received the rewards - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expectApproxEq(balanceAfter.sub(balanceBefore), expectedRewards, 'wallet balance increase') - }) - - it('should not mint to reclaim address when reclaim address not set', async function () { - // Do NOT set reclaim address (defaults to zero 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 - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - const expectedRewards = toGRT('1400') - - // Close allocation - should only emit denial event, not reclaim - const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - const receipt = await tx.wait() - - // Parse RewardsManager events from the transaction receipt - const parsedEvents = receipt.logs - .map((log) => { - try { - return rewardsManager.interface.parseLog(log) - } catch { - return null - } - }) - .filter((e) => e !== null) - - // Check RewardsDeniedDueToEligibility event - const denialEvents = parsedEvents.filter((e) => e!.name === 'RewardsDeniedDueToEligibility') - expect(denialEvents.length).to.equal(1, 'RewardsDeniedDueToEligibility event not found') - expect(denialEvents[0]!.args[0]).to.equal(indexer1.address) - expect(denialEvents[0]!.args[1]).to.equal(allocationID1) - expectApproxEq(denialEvents[0]!.args[2], expectedRewards, 'denied rewards amount') - - // Check no RewardsReclaimed event - const reclaimEvents = parsedEvents.filter((e) => e!.name === 'RewardsReclaimed') - expect(reclaimEvents.length).to.equal(0, 'RewardsReclaimed event should not be emitted') - }) - }) - - describe('reclaim precedence - first successful reclaim wins', function () { - // Note: With subgraph-level denial, rewards are reclaimed via onSubgraphAllocationUpdate() - // and the allocation-level _deniedRewards() path (which checks eligibility) is not reached - // because rewards = 0 for allocations created while denied. - - it('should reclaim to SUBGRAPH_DENIED when both fail and both addresses configured', async function () { - // Setup BOTH reclaim addresses - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, otherWallet.address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // 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 - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - const expectedRewards = toGRT('1400') - - // Check balances before - const subgraphDeniedBalanceBefore = await grt.balanceOf(reclaimWallet.address) - const indexerIneligibleBalanceBefore = await grt.balanceOf(otherWallet.address) - - // Close allocation - subgraph denial takes precedence (handled at subgraph level) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // No allocation-level denial events - handled at subgraph level - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - // RewardsReclaimed emitted (subgraph-level reclaim) - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // Only SUBGRAPH_DENIED wallet should receive rewards (allow for rounding errors) - const subgraphDeniedBalanceAfter = await grt.balanceOf(reclaimWallet.address) - const indexerIneligibleBalanceAfter = await grt.balanceOf(otherWallet.address) - - expectApproxEq( - subgraphDeniedBalanceAfter.sub(subgraphDeniedBalanceBefore), - expectedRewards, - 'SUBGRAPH_DENIED wallet balance', - ) - expect(indexerIneligibleBalanceAfter.sub(indexerIneligibleBalanceBefore)).eq(0) - }) - - it('should reclaim to SUBGRAPH_DENIED even when only INDEXER_INELIGIBLE address configured', async function () { - // Setup ONLY INDEXER_INELIGIBLE reclaim address (not SUBGRAPH_DENIED) - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, otherWallet.address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // 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 - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Check balance before - const balanceBefore = await grt.balanceOf(otherWallet.address) - - // Close allocation - subgraph denial is handled at subgraph level, but no SUBGRAPH_DENIED - // reclaim address is configured, so rewards are dropped (not reclaimed) - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - // No allocation-level denial events - handled at subgraph level - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - // No reclaim because SUBGRAPH_DENIED address not configured (eligibility path not reached) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - - // INDEXER_INELIGIBLE wallet should NOT receive rewards (subgraph denial takes precedence) - const balanceAfter = await grt.balanceOf(otherWallet.address) - expect(balanceAfter.sub(balanceBefore)).eq(0) - }) - - it('should reclaim to INDEXER_INELIGIBLE when both fail but only INDEXER_INELIGIBLE address configured (pre-denial allocation)', async function () { - // This tests the ternary in _deniedRewards that falls back to INDEXER_INELIGIBLE - // when SUBGRAPH_DENIED address is not configured - - // Setup ONLY INDEXER_INELIGIBLE reclaim address (not SUBGRAPH_DENIED) - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, reclaimWallet.address) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.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) - - // Create allocation FIRST (before denial) - this is the key difference - await setupIndexerAllocation() - - // Mine blocks to accrue rewards - await helpers.mineEpoch(epochManager) - - // NOW deny the subgraph (after allocation exists) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - const expectedRewards = toGRT('1400') - - // Check balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Close allocation - pre-denial rewards flow through _deniedRewards - // Both conditions are true, but SUBGRAPH_DENIED address is not set - // So it should fall back to INDEXER_INELIGIBLE - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // RewardsDenied IS emitted (allocation-level denial for pre-denial rewards) - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - // RewardsDeniedDueToEligibility IS emitted - await expect(tx).emit(rewardsManager, 'RewardsDeniedDueToEligibility') - // RewardsReclaimed should emit with INDEXER_INELIGIBLE reason (fallback) - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // INDEXER_INELIGIBLE wallet should receive rewards (fallback from SUBGRAPH_DENIED) - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expectApproxEq(balanceAfter.sub(balanceBefore), expectedRewards, 'INDEXER_INELIGIBLE wallet balance') - }) - - it('should drop rewards when both fail and neither address configured', async function () { - // Do NOT set any reclaim addresses - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // 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 - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - no events, rewards dropped - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should drop rewards when subgraph denied without address even if indexer eligible', async function () { - // Do NOT set SUBGRAPH_DENIED reclaim address - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Setup eligibility oracle that ALLOWS (indexer is eligible) - const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', - ) - const mockOracle = await MockRewardsEligibilityOracleFactory.deploy(true) // Allow - await mockOracle.deployed() - await rewardsManager.connect(governor).setProviderEligibilityOracle(mockOracle.address) - - // Align with the epoch boundary - await helpers.mineEpoch(epochManager) - - // Setup allocation - await setupIndexerAllocation() - - // Jump to next epoch - await helpers.mineEpoch(epochManager) - - // Close allocation - no events because subgraph denial handled at subgraph level - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - await expect(tx).to.not.emit(rewardsManager, 'RewardsDenied') - await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - await expect(tx).to.not.emit(rewardsManager, 'HorizonRewardsAssigned') - }) - }) - - describe('reclaimRewards - force close allocation', function () { - let mockSubgraphService: any - - beforeEach(async function () { - // Deploy mock subgraph service - const MockSubgraphServiceFactory = await hre.ethers.getContractFactory( - 'contracts/tests/MockSubgraphService.sol:MockSubgraphService', - ) - mockSubgraphService = await MockSubgraphServiceFactory.deploy() - await mockSubgraphService.deployed() - - // Set it as the subgraph service in rewards manager - await rewardsManager.connect(governor).setSubgraphService(mockSubgraphService.address) - }) - - it('should reclaim rewards when reclaim address is set', async function () { - // Set reclaim address for ForceCloseAllocation - await rewardsManager.connect(governor).setReclaimAddress(CLOSE_ALLOCATION, reclaimWallet.address) - - // Setup allocation in real staking contract - await setupIndexerAllocation() - - // Also set allocation data in mock so RewardsManager can query it - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - true, // isActive - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, // accRewardsPerAllocatedToken starts at 0 - 0, // accRewardsPending - ) - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Jump to next epoch to accrue rewards - await helpers.mineEpoch(epochManager) - - // Check balance before - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Call reclaimRewards via mock subgraph service - const tx = await mockSubgraphService.callReclaimRewards(rewardsManager.address, CLOSE_ALLOCATION, allocationID1) - - // Verify event was emitted (don't check exact amount, it depends on rewards calculation) - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // Check balance after - should have increased - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - const rewardsClaimed = balanceAfter.sub(balanceBefore) - expect(rewardsClaimed).to.be.gt(0) - }) - - it('should not reclaim when reclaim address is not set', async function () { - // Do NOT set reclaim address (defaults to zero) - - // Setup allocation in real staking contract - await setupIndexerAllocation() - - // Also set allocation data in mock - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - true, - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, - 0, - ) - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Jump to next epoch to accrue rewards - await helpers.mineEpoch(epochManager) - - // Call reclaimRewards via mock subgraph service - should not emit RewardsReclaimed - const tx = await mockSubgraphService.callReclaimRewards(rewardsManager.address, CLOSE_ALLOCATION, allocationID1) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should return 0 and not emit when reclaim address is not set and no rewards', async function () { - // Do NOT set reclaim address (zero address) - - // Setup allocation but mark it as inactive (no rewards) - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - false, // NOT active - this will return 0 rewards - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, - 0, - ) - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Call reclaimRewards - should return 0 and not emit - const result = await mockSubgraphService.callStatic.callReclaimRewards( - rewardsManager.address, - CLOSE_ALLOCATION, - allocationID1, - ) - expect(result).eq(0) - - const tx = await mockSubgraphService.callReclaimRewards(rewardsManager.address, CLOSE_ALLOCATION, allocationID1) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should return 0 when reason is NONE', async function () { - // Setup allocation in real staking contract - await setupIndexerAllocation() - - // Also set allocation data in mock so RewardsManager can query it - const tokensAllocated = toGRT('12500') - await mockSubgraphService.setAllocation( - allocationID1, - true, - indexer1.address, - subgraphDeploymentID1, - tokensAllocated, - 0, - 0, - ) - await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) - - // Jump to next epoch to accrue rewards - await helpers.mineEpoch(epochManager) - - // Call reclaimRewards with NONE (HashZero) - should return 0 - const result = await mockSubgraphService.callStatic.callReclaimRewards( - rewardsManager.address, - HashZero, - allocationID1, - ) - expect(result).eq(0) - - // Verify no RewardsReclaimed event emitted - const tx = await mockSubgraphService.callReclaimRewards(rewardsManager.address, HashZero, allocationID1) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should reject when called by unauthorized address', async function () { - // Try to call reclaimRewards directly from indexer1 (not the subgraph service) - const abiCoder = hre.ethers.utils.defaultAbiCoder - const selector = hre.ethers.utils.id('reclaimRewards(bytes32,address)').slice(0, 10) - const params = abiCoder.encode(['bytes32', 'address'], [CLOSE_ALLOCATION, allocationID1]) - const data = selector + params.slice(2) - - const tx = indexer1.sendTransaction({ - to: rewardsManager.address, - data: data, - }) - await expect(tx).revertedWith('Not a rewards issuer') - }) - }) - - describe('setDefaultReclaimAddress', function () { - it('should reject if not governor', async function () { - const tx = rewardsManager.connect(indexer1).setDefaultReclaimAddress(reclaimWallet.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set default reclaim address if governor', async function () { - const tx = rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - await expect(tx) - .emit(rewardsManager, 'DefaultReclaimAddressSet') - .withArgs(constants.AddressZero, reclaimWallet.address) - - // Verify the getter returns the correct value - expect(await rewardsManager.getDefaultReclaimAddress()).eq(reclaimWallet.address) - }) - - it('should allow setting to zero address', async function () { - await rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - - const tx = rewardsManager.connect(governor).setDefaultReclaimAddress(constants.AddressZero) - await expect(tx) - .emit(rewardsManager, 'DefaultReclaimAddressSet') - .withArgs(reclaimWallet.address, constants.AddressZero) - }) - - it('should not emit event when setting same address', async function () { - await rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - - const tx = rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - await expect(tx).to.not.emit(rewardsManager, 'DefaultReclaimAddressSet') - }) - }) - - describe('default reclaim address fallback', function () { - beforeEach(async function () { - await setupIndexerAllocation() - // Set governor as the subgraph availability oracle for setDenied calls - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - }) - - it('should use default reclaim address when reason-specific not set', async function () { - // Set default but NOT SUBGRAPH_DENIED specific - await rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - - // Deny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Mine blocks to accrue rewards - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger reclaim via onSubgraphAllocationUpdate - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should reclaim to default address with SUBGRAPH_DENIED reason - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - - it('should prefer reason-specific address over default', async function () { - // Set both default AND SUBGRAPH_DENIED specific - await rewardsManager.connect(governor).setDefaultReclaimAddress(otherWallet.address) - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - - // Deny the subgraph - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Mine blocks to accrue rewards - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - const otherBalanceBefore = await grt.balanceOf(otherWallet.address) - - // Trigger reclaim - await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - const otherBalanceAfter = await grt.balanceOf(otherWallet.address) - - // Should go to reason-specific, not default - expect(balanceAfter).gt(balanceBefore) - expect(otherBalanceAfter).eq(otherBalanceBefore) - }) - }) - - describe('reclaim NO_SIGNAL - zero total signal', function () { - it('should reclaim when no signal and NO_SIGNAL address set', async function () { - // Set reclaim address for NO_SIGNAL - await rewardsManager.connect(governor).setReclaimAddress(NO_SIGNAL, reclaimWallet.address) - - // Don't create any signal - just mine blocks - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger updateAccRewardsPerSignal (called internally when signal changes, or directly) - const tx = rewardsManager.connect(governor).updateAccRewardsPerSignal() - - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - - it('should drop rewards when no signal and no reclaim address', async function () { - // Don't set any reclaim address - just mine blocks - await helpers.mine(5) - - // Trigger updateAccRewardsPerSignal - const tx = rewardsManager.connect(governor).updateAccRewardsPerSignal() - - // Should not emit RewardsReclaimed (dropped) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should use default reclaim address for NO_SIGNAL when specific not set', async function () { - // Set default but NOT NO_SIGNAL specific - await rewardsManager.connect(governor).setDefaultReclaimAddress(reclaimWallet.address) - - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - const tx = rewardsManager.connect(governor).updateAccRewardsPerSignal() - - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - }) - - describe('reclaim NO_ALLOCATED_TOKENS - signal but no allocations', function () { - it('should reclaim when signal exists but no allocations and NO_ALLOCATED_TOKENS address set', async function () { - // Set reclaim address for NO_ALLOCATED_TOKENS - await rewardsManager.connect(governor).setReclaimAddress(NO_ALLOCATED_TOKENS, reclaimWallet.address) - - // Create signal but NO allocation - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Mine blocks to accrue rewards - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger onSubgraphAllocationUpdate - will see signal but no allocations - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - - it('should drop rewards when no allocations and no reclaim address', async function () { - // Create signal but NO allocation, and don't set reclaim address - const signalled1 = toGRT('1500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - await helpers.mine(5) - - // Trigger onSubgraphAllocationUpdate - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should not emit RewardsReclaimed (dropped) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - }) - - describe('reclaim BELOW_MINIMUM_SIGNAL', function () { - const MINIMUM_SIGNAL = toGRT('1000') - - beforeEach(async function () { - // Set minimum signal threshold - await rewardsManager.connect(governor).setMinimumSubgraphSignal(MINIMUM_SIGNAL) - }) - - it('should reclaim when signal below minimum and BELOW_MINIMUM_SIGNAL address set', async function () { - // Set reclaim address for BELOW_MINIMUM_SIGNAL - await rewardsManager.connect(governor).setReclaimAddress(BELOW_MINIMUM_SIGNAL, reclaimWallet.address) - - // Create signal BELOW minimum (minimum is 1000, we signal 500) - const signalled1 = toGRT('500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Mine blocks - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger onSubgraphAllocationUpdate - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - - it('should not reclaim when signal at or above minimum', async function () { - // Set reclaim address - await rewardsManager.connect(governor).setReclaimAddress(BELOW_MINIMUM_SIGNAL, reclaimWallet.address) - - // Create signal AT minimum - const signalled1 = MINIMUM_SIGNAL - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Also need an allocation for rewards to accumulate normally - 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), - ) - - await helpers.mine(5) - - // Trigger onSubgraphAllocationUpdate - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should NOT emit RewardsReclaimed for BELOW_MINIMUM_SIGNAL - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should drop rewards when below minimum and no reclaim address', async function () { - // Don't set reclaim address - // Create signal BELOW minimum - const signalled1 = toGRT('500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - await helpers.mine(5) - - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should not emit RewardsReclaimed (dropped) - await expect(tx).to.not.emit(rewardsManager, 'RewardsReclaimed') - }) - - it('should use BELOW_MINIMUM_SIGNAL when denied but SUBGRAPH_DENIED address not configured', async function () { - // This tests line 574: the branch where subgraph is denied but reclaim address is zero, - // so it falls back to BELOW_MINIMUM_SIGNAL - - // Set BELOW_MINIMUM_SIGNAL address but NOT SUBGRAPH_DENIED - await rewardsManager.connect(governor).setReclaimAddress(BELOW_MINIMUM_SIGNAL, reclaimWallet.address) - - // Setup denylist - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Create signal BELOW minimum (minimum is 1000, we signal 500) - const signalled1 = toGRT('500') - await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) - - // Mine blocks - await helpers.mine(5) - - const balanceBefore = await grt.balanceOf(reclaimWallet.address) - - // Trigger onSubgraphAllocationUpdate - // Subgraph is denied but no SUBGRAPH_DENIED address, so should fall back to BELOW_MINIMUM_SIGNAL - const tx = rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) - - // Should reclaim to BELOW_MINIMUM_SIGNAL address - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - const balanceAfter = await grt.balanceOf(reclaimWallet.address) - expect(balanceAfter).gt(balanceBefore) - }) - }) - - describe('dual denial - SUBGRAPH_DENIED takes precedence when configured', function () { - it('should reclaim to SUBGRAPH_DENIED when both conditions true and SUBGRAPH_DENIED address configured (pre-denial allocation)', async function () { - // This tests line 747-748: when both denied and ineligible, and SUBGRAPH_DENIED IS configured - - // Setup BOTH reclaim addresses - await rewardsManager.connect(governor).setReclaimAddress(SUBGRAPH_DENIED, reclaimWallet.address) - await rewardsManager.connect(governor).setReclaimAddress(INDEXER_INELIGIBLE, otherWallet.address) - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.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) - - // Create allocation FIRST (before denial) - await setupIndexerAllocation() - - // Mine blocks to accrue rewards - await helpers.mineEpoch(epochManager) - - // NOW deny the subgraph (after allocation exists) - await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - - // Check balances before - const subgraphDeniedBalanceBefore = await grt.balanceOf(reclaimWallet.address) - const indexerIneligibleBalanceBefore = await grt.balanceOf(otherWallet.address) - - // Close allocation - pre-denial rewards flow through _deniedRewards - // Both conditions are true, SUBGRAPH_DENIED IS configured, so it should use SUBGRAPH_DENIED - const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) - - // RewardsDenied IS emitted - await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) - // RewardsDeniedDueToEligibility IS emitted - await expect(tx).emit(rewardsManager, 'RewardsDeniedDueToEligibility') - // RewardsReclaimed should emit with SUBGRAPH_DENIED reason - await expect(tx).emit(rewardsManager, 'RewardsReclaimed') - - // SUBGRAPH_DENIED wallet should receive rewards (not INDEXER_INELIGIBLE) - const subgraphDeniedBalanceAfter = await grt.balanceOf(reclaimWallet.address) - const indexerIneligibleBalanceAfter = await grt.balanceOf(otherWallet.address) - - expect(subgraphDeniedBalanceAfter.sub(subgraphDeniedBalanceBefore)).gt(0) - expect(indexerIneligibleBalanceAfter.sub(indexerIneligibleBalanceBefore)).eq(0) - }) - }) -}) diff --git a/packages/issuance/test/unit/rewards-manager/distribution.t.sol b/packages/issuance/test/unit/rewards-manager/distribution.t.sol index ccf10a54d..2ec0c444f 100644 --- a/packages/issuance/test/unit/rewards-manager/distribution.t.sol +++ b/packages/issuance/test/unit/rewards-manager/distribution.t.sol @@ -9,8 +9,6 @@ contract RewardsManagerDistributionTest is RewardsManagerSharedTest { event Transfer(address indexed from, address indexed to, uint256 value); uint256 internal constant ISSUANCE_RATE_PERIODS = 4; - /// Blocks per `helpers.mineEpoch`: the old tests set the epoch length to 10 - uint256 internal constant EPOCH_LENGTH = 10; bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_2 = keccak256("subgraphDeploymentID2"); @@ -21,9 +19,7 @@ contract RewardsManagerDistributionTest is RewardsManagerSharedTest { /// Signal, then allocate one block later, so the pre-allocation block is reclaimed (NO_ALLOCATED_TOKENS). function _setupIndexerAllocation() internal { - _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); - vm.roll(block.number + 1); - _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); } function _setupIndexerAllocationSignalingAfter() internal { @@ -32,16 +28,6 @@ contract RewardsManagerDistributionTest is RewardsManagerSharedTest { _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); } - /// Rewards for an allocation of `tokens` out of `allocatedTokens` on a subgraph holding all the signal. - function _expectedAllocationRewards( - uint256 blocks, - uint256 tokens, - uint256 allocatedTokens - ) internal pure returns (uint256) { - uint256 subgraphRewards = _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); - return (_expectedRewardsPerAllocatedToken(subgraphRewards, allocatedTokens) * tokens) / 1e18; - } - /// RewardsManager half of the legacy-Staking close cases: event amount, mint to the issuer and total supply. function _assertRewardsDistributedOnClose() internal { // All the subgraph's rewards since the allocation go to it diff --git a/packages/issuance/test/unit/rewards-manager/eligibility.t.sol b/packages/issuance/test/unit/rewards-manager/eligibility.t.sol new file mode 100644 index 000000000..30ac1a6fa --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/eligibility.t.sol @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; +import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol"; + +import { MockERC165 } from "../../../contracts/test/allocate/MockERC165.sol"; +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerEligibilityTest is RewardsManagerSharedTest { + event ProviderEligibilityOracleSet(IProviderEligibility indexed oldOracle, IProviderEligibility indexed newOracle); + 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); + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + + 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]; + } + revert("log not found"); + } + + // -- rewards eligibility oracle -- + + function test_RewardsEligibilityOracle_RevertWhen_Unauthorized() public { + vm.prank(indexer); + vm.expectRevert(bytes("Only Controller governor")); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + } + + function test_RewardsEligibilityOracle_SetsOracleAsGovernor() public { + vm.expectEmit(address(rm)); + emit ProviderEligibilityOracleSet(IProviderEligibility(address(0)), IProviderEligibility(address(oracle))); + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + + assertEq(address(rm.getProviderEligibilityOracle()), address(oracle)); + } + + function test_RewardsEligibilityOracle_AllowsSettingToZeroAddress() public { + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + + vm.expectEmit(address(rm)); + emit ProviderEligibilityOracleSet(IProviderEligibility(address(oracle)), IProviderEligibility(address(0))); + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(0))); + + assertEq(address(rm.getProviderEligibilityOracle()), address(0)); + } + + function test_RewardsEligibilityOracle_RevertWhen_OracleIsEOA() public { + // An EOA has no code, so the supportsInterface call reverts + vm.prank(governor); + vm.expectRevert(); + rm.setProviderEligibilityOracle(IProviderEligibility(indexer)); + } + + function test_RewardsEligibilityOracle_RevertWhen_InterfaceNotSupported() public { + MockERC165 mockERC165 = new MockERC165(); + + vm.prank(governor); + vm.expectRevert(bytes("Contract does not support IProviderEligibility interface")); + rm.setProviderEligibilityOracle(IProviderEligibility(address(mockERC165))); + } + + function test_RewardsEligibilityOracle_NoEventWhenSettingSameOracle() public { + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + + vm.recordLogs(); + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + _assertNotEmitted(vm.getRecordedLogs(), ProviderEligibilityOracleSet.selector); + } + + // -- rewards eligibility in takeRewards -- + + function test_RewardsEligibilityInTakeRewards_DeniesRewardsDueToEligibilityOracle() public { + _useEligibilityOracle(false); + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityInTakeRewards_AllowsRewardsWhenOracleApproves() public { + _useEligibilityOracle(true); + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + // -- rewards eligibility oracle and denylist interaction -- + + function test_RewardsEligibilityOracleAndDenylistInteraction_PrioritizesDenylistOverOracleWhenBothDeny() public { + _setAvailabilityOracle(); + _setDenied(true); + _useEligibilityOracle(false); + + // Created while denied: the accumulator is frozen, so the allocation has no rewards to deny + _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + _assertNotEmitted(logs, HorizonRewardsAssigned.selector); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_ChecksOracleWhenDenylistAllowsButIndexerIneligible() + public + { + _useEligibilityOracle(false); + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesIneligibleMidAllocation() public { + _useEligibilityOracle(true); + uint256 expectedRewards = _allocateAndAccrue(); + + // Eligibility is checked at close time, not at creation time + oracle.setIndexerEligible(indexer, false); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesEligibleMidAllocation() public { + _useEligibilityOracle(false); + uint256 expectedRewards = _allocateAndAccrue(); + + oracle.setIndexerEligible(indexer, true); + + vm.expectEmit(address(rm), 1); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_DenylistAddedMidAllocation() public { + _setAvailabilityOracle(); + _allocateAndAccrue(); + + _setDenied(true); + vm.roll(block.number + 1); + + vm.expectEmit(address(rm)); + emit RewardsDenied(indexer, allocationID1); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_DenylistRemovedMidAllocation() public { + _setAvailabilityOracle(); + _setDenied(true); + + // Created while denied: rewards accrued while denied are reclaimed at the subgraph level + _allocateAndAccrue(); + + // Undeny snapshots the accumulator, then only post-undeny rewards accrue + _setDenied(false); + vm.roll(block.number + EPOCH_LENGTH); + + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned( + indexer, + allocationID1, + _expectedAllocationRewards(EPOCH_LENGTH, ALLOCATION, ALLOCATION) + ); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_AllowsRewardsWhenOracleIsZeroAddress() public { + assertEq(address(rm.getProviderEligibilityOracle()), address(0)); + + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_RevertWhen_IneligibleAndRevertOnIneligible() public { + _useEligibilityOracle(false); + vm.prank(governor); + rm.setRevertOnIneligible(true); + + _allocateAndAccrue(); + + vm.expectRevert(bytes("Indexer not eligible for rewards")); + issuer.close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_NoRevertForEligibleWhenRevertOnIneligible() public { + _useEligibilityOracle(true); + vm.prank(governor); + rm.setRevertOnIneligible(true); + + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm)); + emit HorizonRewardsAssigned(indexer, allocationID1, expectedRewards); + _close(allocationID1); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_ReclaimsForIneligibleWhenNotRevertOnIneligible() + public + { + _useEligibilityOracle(false); + assertFalse(rm.getRevertOnIneligible()); + + uint256 expectedRewards = _allocateAndAccrue(); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + uint256 rewards = _close(allocationID1); + assertEq(rewards, 0); + } + + function test_RewardsEligibilityOracleAndDenylistInteraction_EventStructureDiffersBetweenDenialMechanisms() public { + uint256 snapshot = vm.snapshotState(); + + // Denylist denial: event WITHOUT amount. Deny after the allocation so there are rewards to deny. + _setAvailabilityOracle(); + _allocateAndAccrue(); + _setDenied(true); + vm.roll(block.number + 1); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log memory denied = _findLog(vm.getRecordedLogs(), RewardsDenied.selector); + assertEq(denied.topics.length, 3); + assertEq(denied.topics[1], bytes32(uint256(uint160(indexer)))); + assertEq(denied.topics[2], bytes32(uint256(uint160(allocationID1)))); + assertEq(denied.data.length, 0); + + // Reset, as the old test's fixture teardown and setup + vm.revertToState(snapshot); + + // Eligibility denial: event WITH amount, what the indexer would have received + _useEligibilityOracle(false); + uint256 expectedRewards = _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log memory ineligible = _findLog(vm.getRecordedLogs(), RewardsDeniedDueToEligibility.selector); + assertEq(ineligible.topics.length, 3); + assertEq(ineligible.topics[1], bytes32(uint256(uint160(indexer)))); + assertEq(ineligible.topics[2], bytes32(uint256(uint160(allocationID1)))); + uint256 amount = abi.decode(ineligible.data, (uint256)); + assertGt(amount, 0); + assertEq(amount, expectedRewards); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/reclaim.t.sol b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol new file mode 100644 index 000000000..1107a487a --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/reclaim.t.sol @@ -0,0 +1,715 @@ +// 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"; + +contract RewardsManagerReclaimTest is RewardsManagerSharedTest { + event ReclaimAddressSet(bytes32 indexed reason, address indexed oldAddress, address indexed newAddress); + event DefaultReclaimAddressSet(address indexed oldAddress, address indexed newAddress); + event RewardsReclaimed( + bytes32 indexed reason, + uint256 amount, + address indexed indexer, + address indexed allocationID, + bytes32 subgraphDeploymentID + ); + 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); + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + uint256 internal constant MINIMUM_SIGNAL = 1000 ether; + uint256 internal constant BELOW_MINIMUM = 500 ether; + /// Blocks mined by the old `helpers.mine(5)` + uint256 internal constant MINED_BLOCKS = 5; + + address internal allocationID1 = makeAddr("allocationID1"); + address internal reclaimWallet = makeAddr("reclaimWallet"); + address internal otherWallet = makeAddr("otherWallet"); + + // -- 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); + } + + /// Rewards of the subgraph (holding all the signal) over `blocks`, as reclaimed at the subgraph level. + function _subgraphRewards(uint256 blocks) internal pure returns (uint256) { + return _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); + } + + 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); + } + + /// Allocation data on the issuer, as the old test's `MockSubgraphService.setAllocation`. + function _setActiveIssuerAllocation(bool isActive) internal { + issuer.setAllocation(allocationID1, isActive, indexer, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION, 0, 0); + issuer.setSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + } + + function _triggerAllocationUpdate() internal { + vm.prank(governor); + rm.onSubgraphAllocationUpdate(SUBGRAPH_DEPLOYMENT_ID_1); + } + + modifier withMinimumSignal() { + vm.prank(governor); + rm.setMinimumSubgraphSignal(MINIMUM_SIGNAL); + _; + } + + /// Old `default reclaim address fallback` beforeEach. + modifier withAllocationAndAvailabilityOracle() { + _signalThenAllocate(SUBGRAPH_DEPLOYMENT_ID_1, allocationID1); + _setAvailabilityOracle(); + _; + } + + // -- setReclaimAddress -- + + function test_SetReclaimAddress_RevertWhen_NotGovernor() public { + vm.prank(indexer); + vm.expectRevert(bytes("Only Controller governor")); + rm.setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + } + + function test_SetReclaimAddress_RevertWhen_ReasonIsNone() public { + vm.prank(governor); + vm.expectRevert(bytes("Cannot set reclaim address for NONE")); + rm.setReclaimAddress(RewardsCondition.NONE, reclaimWallet); + } + + function test_SetReclaimAddress_SetsEligibilityReclaimAddressAsGovernor() public { + vm.expectEmit(address(rm)); + emit ReclaimAddressSet(RewardsCondition.INDEXER_INELIGIBLE, address(0), reclaimWallet); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + + assertEq(rm.getReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE), reclaimWallet); + } + + function test_SetReclaimAddress_SetsSubgraphDeniedReclaimAddressAsGovernor() public { + vm.expectEmit(address(rm)); + emit ReclaimAddressSet(RewardsCondition.SUBGRAPH_DENIED, address(0), reclaimWallet); + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + + assertEq(rm.getReclaimAddress(RewardsCondition.SUBGRAPH_DENIED), reclaimWallet); + } + + function test_SetReclaimAddress_AllowsSettingToZeroAddress() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + + vm.expectEmit(address(rm)); + emit ReclaimAddressSet(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet, address(0)); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, address(0)); + + assertEq(rm.getReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE), address(0)); + } + + function test_SetReclaimAddress_NoEventWhenSettingSameAddress() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + + vm.recordLogs(); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + _assertNotEmitted(vm.getRecordedLogs(), ReclaimAddressSet.selector); + } + + // -- reclaim denied rewards - subgraph denylist -- + // Denied before the allocation: the accumulator is frozen, rewards are reclaimed at the subgraph level + // (address(0) indexer and allocation) and the allocation has none at close. + + function test_ReclaimDeniedRewardsSubgraphDenylist_MintsToReclaimAddressWhenSet() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _deny(); + _allocateAndAccrue(); + + uint256 expectedRewards = _subgraphRewards(EPOCH_LENGTH); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.recordLogs(); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsDenied.selector); + + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimDeniedRewardsSubgraphDenylist_ReclaimsPreDenialRewardsWhenDeniedAfterAllocation() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setAvailabilityOracle(); + + // Allocate first, then deny: the pre-denial rewards stay with the allocation + _allocateAndAccrue(); + _setDenied(true); + // Close in the next block, as in Hardhat: that block is reclaimed at the subgraph level + vm.roll(block.number + 1); + + uint256 allocationRewards = _epochAllocationRewards(); + uint256 subgraphLevelRewards = _subgraphRewards(1); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + subgraphLevelRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + vm.expectEmit(address(rm), 1); + emit RewardsDenied(indexer, allocationID1); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + allocationRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + + assertGe(token.balanceOf(reclaimWallet) - balanceBefore, allocationRewards); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, allocationRewards + subgraphLevelRewards); + } + + function test_ReclaimDeniedRewardsSubgraphDenylist_NoMintWhenReclaimAddressNotSet() public { + _deny(); + _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsReclaimed.selector); + } + + // -- reclaim denied rewards - eligibility -- + + function test_ReclaimDeniedRewardsEligibility_MintsToReclaimAddressWhenSet() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + _useEligibilityOracle(false); + _allocateAndAccrue(); + + uint256 expectedRewards = _epochAllocationRewards(); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.INDEXER_INELIGIBLE, + expectedRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimDeniedRewardsEligibility_NoMintWhenReclaimAddressNotSet() public { + _useEligibilityOracle(false); + _allocateAndAccrue(); + + vm.recordLogs(); + vm.expectEmit(address(rm), 1); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, _epochAllocationRewards()); + _close(allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + // -- reclaim precedence - first successful reclaim wins -- + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_ReclaimsToSubgraphDeniedWhenBothConfigured() public { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); + _deny(); + _useEligibilityOracle(false); + _allocateAndAccrue(); + + uint256 expectedRewards = _subgraphRewards(EPOCH_LENGTH); + uint256 subgraphDeniedBalanceBefore = token.balanceOf(reclaimWallet); + uint256 indexerIneligibleBalanceBefore = token.balanceOf(otherWallet); + + vm.recordLogs(); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + + assertEq(token.balanceOf(reclaimWallet) - subgraphDeniedBalanceBefore, expectedRewards); + assertEq(token.balanceOf(otherWallet), indexerIneligibleBalanceBefore); + } + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenOnlyIndexerIneligibleConfigured() public { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); + _deny(); + _useEligibilityOracle(false); + _allocateAndAccrue(); + + uint256 balanceBefore = token.balanceOf(otherWallet); + + // Subgraph-level denial without a SUBGRAPH_DENIED address: dropped, and the eligibility path is not reached + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + _assertNotEmitted(logs, RewardsReclaimed.selector); + + assertEq(token.balanceOf(otherWallet), balanceBefore); + } + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_FallsBackToIndexerIneligibleForPreDenialAllocation() + public + { + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, reclaimWallet); + _setAvailabilityOracle(); + _useEligibilityOracle(false); + + // Allocate first, then deny, so the rewards reach `_deniedRewards` + _allocateAndAccrue(); + _setDenied(true); + vm.roll(block.number + 1); + + uint256 expectedRewards = _epochAllocationRewards(); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + // Both conditions hold; with no SUBGRAPH_DENIED address the reason falls back to INDEXER_INELIGIBLE + vm.expectEmit(address(rm)); + emit RewardsDenied(indexer, allocationID1); + vm.expectEmit(address(rm)); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, expectedRewards); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.INDEXER_INELIGIBLE, + expectedRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + + // The subgraph-level block after the denial has no SUBGRAPH_DENIED address and is dropped + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenNeitherAddressConfigured() public { + _deny(); + _useEligibilityOracle(false); + _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + _assertNotEmitted(logs, RewardsReclaimed.selector); + } + + function test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenSubgraphDeniedWithoutAddressEvenIfEligible() + public + { + _deny(); + _useEligibilityOracle(true); + _allocateAndAccrue(); + + vm.recordLogs(); + _close(allocationID1); + Vm.Log[] memory logs = vm.getRecordedLogs(); + _assertNotEmitted(logs, RewardsDenied.selector); + _assertNotEmitted(logs, RewardsDeniedDueToEligibility.selector); + _assertNotEmitted(logs, RewardsReclaimed.selector); + _assertNotEmitted(logs, HorizonRewardsAssigned.selector); + } + + // -- reclaimRewards - force close allocation -- + // `issuer` replaces the old MockSubgraphService; the legacy Staking allocation is not needed, since + // RewardsManager only reads allocation data from the subgraph service. + + function test_ReclaimRewardsForceCloseAllocation_ReclaimsWhenReclaimAddressSet() public { + _setReclaimAddress(RewardsCondition.CLOSE_ALLOCATION, reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _setActiveIssuerAllocation(true); + vm.roll(block.number + EPOCH_LENGTH); + + uint256 expectedRewards = _epochAllocationRewards(); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.CLOSE_ALLOCATION, + expectedRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + uint256 reclaimed = issuer.reclaim(RewardsCondition.CLOSE_ALLOCATION, allocationID1); + + uint256 rewardsClaimed = token.balanceOf(reclaimWallet) - balanceBefore; + assertGt(rewardsClaimed, 0); + assertEq(rewardsClaimed, expectedRewards); + assertEq(reclaimed, expectedRewards); + } + + function test_ReclaimRewardsForceCloseAllocation_NoReclaimWhenReclaimAddressNotSet() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _setActiveIssuerAllocation(true); + vm.roll(block.number + EPOCH_LENGTH); + + vm.recordLogs(); + uint256 reclaimed = issuer.reclaim(RewardsCondition.CLOSE_ALLOCATION, allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + assertEq(reclaimed, 0); + } + + function test_ReclaimRewardsForceCloseAllocation_ReturnsZeroWhenNoAddressAndNoRewards() public { + // Inactive allocation: no rewards + _setActiveIssuerAllocation(false); + + vm.recordLogs(); + uint256 reclaimed = issuer.reclaim(RewardsCondition.CLOSE_ALLOCATION, allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + assertEq(reclaimed, 0); + } + + function test_ReclaimRewardsForceCloseAllocation_ReturnsZeroWhenReasonIsNone() public { + // A default reclaim address makes the NONE guard, not a missing address, the reason for 0 + _setDefaultReclaimAddress(reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + _setActiveIssuerAllocation(true); + vm.roll(block.number + EPOCH_LENGTH); + + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.recordLogs(); + uint256 reclaimed = issuer.reclaim(RewardsCondition.NONE, allocationID1); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + assertEq(reclaimed, 0); + assertEq(token.balanceOf(reclaimWallet), balanceBefore); + } + + function test_ReclaimRewardsForceCloseAllocation_RevertWhen_CalledByUnauthorizedAddress() public { + vm.prank(indexer); + vm.expectRevert(bytes("Not a rewards issuer")); + rm.reclaimRewards(RewardsCondition.CLOSE_ALLOCATION, allocationID1); + } + + // -- setDefaultReclaimAddress -- + + function test_SetDefaultReclaimAddress_RevertWhen_NotGovernor() public { + vm.prank(indexer); + vm.expectRevert(bytes("Only Controller governor")); + rm.setDefaultReclaimAddress(reclaimWallet); + } + + function test_SetDefaultReclaimAddress_SetsDefaultReclaimAddressAsGovernor() public { + vm.expectEmit(address(rm)); + emit DefaultReclaimAddressSet(address(0), reclaimWallet); + _setDefaultReclaimAddress(reclaimWallet); + + assertEq(rm.getDefaultReclaimAddress(), reclaimWallet); + } + + function test_SetDefaultReclaimAddress_AllowsSettingToZeroAddress() public { + _setDefaultReclaimAddress(reclaimWallet); + + vm.expectEmit(address(rm)); + emit DefaultReclaimAddressSet(reclaimWallet, address(0)); + _setDefaultReclaimAddress(address(0)); + } + + function test_SetDefaultReclaimAddress_NoEventWhenSettingSameAddress() public { + _setDefaultReclaimAddress(reclaimWallet); + + vm.recordLogs(); + _setDefaultReclaimAddress(reclaimWallet); + _assertNotEmitted(vm.getRecordedLogs(), DefaultReclaimAddressSet.selector); + } + + // -- default reclaim address fallback -- + + function test_DefaultReclaimAddressFallback_UsesDefaultWhenReasonSpecificNotSet() + public + withAllocationAndAvailabilityOracle + { + _setDefaultReclaimAddress(reclaimWallet); + _setDenied(true); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = _subgraphRewards(MINED_BLOCKS); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + // Reclaimed to the default address under the SUBGRAPH_DENIED reason + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_DefaultReclaimAddressFallback_PrefersReasonSpecificOverDefault() + public + withAllocationAndAvailabilityOracle + { + _setDefaultReclaimAddress(otherWallet); + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setDenied(true); + vm.roll(block.number + MINED_BLOCKS); + + uint256 balanceBefore = token.balanceOf(reclaimWallet); + uint256 otherBalanceBefore = token.balanceOf(otherWallet); + + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, _subgraphRewards(MINED_BLOCKS)); + assertEq(token.balanceOf(otherWallet), otherBalanceBefore); + } + + // -- reclaim NO_SIGNAL - zero total signal -- + + function test_ReclaimNoSignalZeroTotalSignal_ReclaimsWhenNoSignalAddressSet() public { + _setReclaimAddress(RewardsCondition.NO_SIGNAL, reclaimWallet); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = MINED_BLOCKS * ISSUANCE_PER_BLOCK; + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed(RewardsCondition.NO_SIGNAL, expectedRewards, address(0), address(0), bytes32(0)); + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimNoSignalZeroTotalSignal_DropsWhenNoReclaimAddress() public { + vm.roll(block.number + MINED_BLOCKS); + + vm.recordLogs(); + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + function test_ReclaimNoSignalZeroTotalSignal_UsesDefaultWhenSpecificNotSet() public { + _setDefaultReclaimAddress(reclaimWallet); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = MINED_BLOCKS * ISSUANCE_PER_BLOCK; + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed(RewardsCondition.NO_SIGNAL, expectedRewards, address(0), address(0), bytes32(0)); + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + // -- reclaim NO_ALLOCATED_TOKENS - signal but no allocations -- + + function test_ReclaimNoAllocatedTokensSignalButNoAllocations_ReclaimsWhenNoAllocatedTokensAddressSet() public { + _setReclaimAddress(RewardsCondition.NO_ALLOCATED_TOKENS, reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = _subgraphRewards(MINED_BLOCKS); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.NO_ALLOCATED_TOKENS, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimNoAllocatedTokensSignalButNoAllocations_DropsWhenNoReclaimAddress() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, SIGNAL); + vm.roll(block.number + MINED_BLOCKS); + + vm.recordLogs(); + _triggerAllocationUpdate(); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + // -- reclaim BELOW_MINIMUM_SIGNAL -- + + function test_ReclaimBelowMinimumSignal_ReclaimsWhenBelowMinimumAddressSet() public withMinimumSignal { + _setReclaimAddress(RewardsCondition.BELOW_MINIMUM_SIGNAL, reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, BELOW_MINIMUM); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = _expectedSubgraphRewards(MINED_BLOCKS, BELOW_MINIMUM, BELOW_MINIMUM); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.BELOW_MINIMUM_SIGNAL, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + function test_ReclaimBelowMinimumSignal_NoReclaimWhenSignalAtOrAboveMinimum() public withMinimumSignal { + _setReclaimAddress(RewardsCondition.BELOW_MINIMUM_SIGNAL, reclaimWallet); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, MINIMUM_SIGNAL); + vm.roll(block.number + 1); + // An allocation, so rewards accumulate normally + _allocate(allocationID1, SUBGRAPH_DEPLOYMENT_ID_1, ALLOCATION); + vm.roll(block.number + MINED_BLOCKS); + + vm.recordLogs(); + _triggerAllocationUpdate(); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + function test_ReclaimBelowMinimumSignal_DropsWhenNoReclaimAddress() public withMinimumSignal { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, BELOW_MINIMUM); + vm.roll(block.number + MINED_BLOCKS); + + vm.recordLogs(); + _triggerAllocationUpdate(); + _assertNotEmitted(vm.getRecordedLogs(), RewardsReclaimed.selector); + } + + function test_ReclaimBelowMinimumSignal_UsesBelowMinimumWhenDeniedWithoutSubgraphDeniedAddress() + public + withMinimumSignal + { + _setReclaimAddress(RewardsCondition.BELOW_MINIMUM_SIGNAL, reclaimWallet); + _deny(); + _signal(SUBGRAPH_DEPLOYMENT_ID_1, BELOW_MINIMUM); + vm.roll(block.number + MINED_BLOCKS); + + uint256 expectedRewards = _expectedSubgraphRewards(MINED_BLOCKS, BELOW_MINIMUM, BELOW_MINIMUM); + uint256 balanceBefore = token.balanceOf(reclaimWallet); + + // Denied, but with no SUBGRAPH_DENIED address the condition falls back to BELOW_MINIMUM_SIGNAL + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.BELOW_MINIMUM_SIGNAL, + expectedRewards, + address(0), + address(0), + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _triggerAllocationUpdate(); + + assertGt(token.balanceOf(reclaimWallet), balanceBefore); + assertEq(token.balanceOf(reclaimWallet) - balanceBefore, expectedRewards); + } + + // -- dual denial - SUBGRAPH_DENIED takes precedence when configured -- + + function test_DualDenialSubgraphDeniedTakesPrecedenceWhenConfigured_ReclaimsToSubgraphDeniedForPreDenialAllocation() + public + { + _setReclaimAddress(RewardsCondition.SUBGRAPH_DENIED, reclaimWallet); + _setReclaimAddress(RewardsCondition.INDEXER_INELIGIBLE, otherWallet); + _setAvailabilityOracle(); + _useEligibilityOracle(false); + + // Allocate first, then deny, so the rewards reach `_deniedRewards` + _allocateAndAccrue(); + _setDenied(true); + vm.roll(block.number + 1); + + uint256 allocationRewards = _epochAllocationRewards(); + uint256 subgraphLevelRewards = _subgraphRewards(1); + uint256 subgraphDeniedBalanceBefore = token.balanceOf(reclaimWallet); + uint256 indexerIneligibleBalanceBefore = token.balanceOf(otherWallet); + + vm.expectEmit(address(rm)); + emit RewardsDenied(indexer, allocationID1); + vm.expectEmit(address(rm)); + emit RewardsDeniedDueToEligibility(indexer, allocationID1, allocationRewards); + vm.expectEmit(address(rm)); + emit RewardsReclaimed( + RewardsCondition.SUBGRAPH_DENIED, + allocationRewards, + indexer, + allocationID1, + SUBGRAPH_DEPLOYMENT_ID_1 + ); + _close(allocationID1); + + // SUBGRAPH_DENIED wallet gets the allocation's rewards plus the subgraph-level block after the denial + assertGt(token.balanceOf(reclaimWallet) - subgraphDeniedBalanceBefore, 0); + assertEq( + token.balanceOf(reclaimWallet) - subgraphDeniedBalanceBefore, + allocationRewards + subgraphLevelRewards + ); + assertEq(token.balanceOf(otherWallet), indexerIneligibleBalanceBefore); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/shared.t.sol b/packages/issuance/test/unit/rewards-manager/shared.t.sol index 6f65b0472..9f7057a97 100644 --- a/packages/issuance/test/unit/rewards-manager/shared.t.sol +++ b/packages/issuance/test/unit/rewards-manager/shared.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; import { Vm } from "forge-std/Vm.sol"; +import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol"; import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; import { MockGraphToken } from "../mocks/MockGraphToken.sol"; @@ -17,6 +18,8 @@ contract RewardsManagerSharedTest is ProtocolFixture { uint256 internal constant ISSUANCE_PER_BLOCK = 200 ether; uint256 internal constant SIGNAL = 1500 ether; uint256 internal constant ALLOCATION = 12_500 ether; + /// Blocks per `helpers.mineEpoch`: the old tests set the epoch length to 10 + uint256 internal constant EPOCH_LENGTH = 10; IRewardsManagerLike internal rm; MockGraphToken internal token; @@ -80,6 +83,21 @@ contract RewardsManagerSharedTest is ProtocolFixture { return issuer.close(allocationId); } + /// Signal `SIGNAL`, then allocate `ALLOCATION` one block later, as the old `setupIndexerAllocation` did + /// in separate transactions: the pre-allocation block is reclaimed under the subgraph's condition. + function _signalThenAllocate(bytes32 sd, address allocationId) internal { + _signal(sd, SIGNAL); + vm.roll(block.number + 1); + _allocate(allocationId, sd, ALLOCATION); + } + + /// Sets the fixture's `oracle` as the eligibility oracle, answering `eligibleByDefault` for every indexer. + function _useEligibilityOracle(bool eligibleByDefault) internal { + oracle.setDefaultResponse(eligibleByDefault); + vm.prank(governor); + rm.setProviderEligibilityOracle(IProviderEligibility(address(oracle))); + } + function _expectedRewardsPerSignal(uint256 blocks, uint256 totalSignal) internal pure returns (uint256) { return (blocks * ISSUANCE_PER_BLOCK * 1e18) / totalSignal; } @@ -100,6 +118,16 @@ contract RewardsManagerSharedTest is ProtocolFixture { return (subgraphRewards * 1e18) / allocatedTokens; } + /// Rewards for an allocation of `tokens` out of `allocatedTokens` on a subgraph holding all the signal. + function _expectedAllocationRewards( + uint256 blocks, + uint256 tokens, + uint256 allocatedTokens + ) internal pure returns (uint256) { + uint256 subgraphRewards = _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); + return (_expectedRewardsPerAllocatedToken(subgraphRewards, allocatedTokens) * tokens) / 1e18; + } + function _assertNotEmitted(Vm.Log[] memory logs, bytes32 topic) internal view { for (uint256 i = 0; i < logs.length; ++i) { if (logs[i].emitter == address(rm)) assertTrue(logs[i].topics[0] != topic, "unexpected event"); diff --git a/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol b/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol index a7e2eca7a..aad516bb4 100644 --- a/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol +++ b/packages/issuance/test/unit/rewards-manager/subgraphService.t.sol @@ -17,12 +17,6 @@ contract RewardsManagerSubgraphServiceTest is RewardsManagerSharedTest { address internal allocationID1 = makeAddr("allocationID1"); - /// Rewards of an allocation holding all of the allocated tokens, on a subgraph holding all the signal. - function _expectedAllocationRewards(uint256 blocks, uint256 tokens) internal pure returns (uint256) { - uint256 subgraphRewards = _expectedSubgraphRewards(blocks, SIGNAL, SIGNAL); - return (_expectedRewardsPerAllocatedToken(subgraphRewards, tokens) * tokens) / 1e18; - } - function _setActiveAllocation(uint256 tokens, uint256 accRewardsPending) internal { issuer.setAllocation(allocationID1, true, indexer, SUBGRAPH_DEPLOYMENT_ID_1, tokens, 0, accRewardsPending); issuer.setSubgraphAllocatedTokens(SUBGRAPH_DEPLOYMENT_ID_1, tokens); @@ -83,7 +77,7 @@ contract RewardsManagerSubgraphServiceTest is RewardsManagerSharedTest { uint256 rewards = rm.getRewards(address(issuer), allocationID1); assertGt(rewards, 0); - assertEq(rewards, _expectedAllocationRewards(10, ALLOCATION)); + assertEq(rewards, _expectedAllocationRewards(10, ALLOCATION, ALLOCATION)); } function test_GetRewards_ZeroForInactiveAllocation() public { @@ -107,7 +101,7 @@ contract RewardsManagerSubgraphServiceTest is RewardsManagerSharedTest { vm.roll(block.number + 10); - uint256 expectedRewards = _expectedAllocationRewards(10, ALLOCATION); + uint256 expectedRewards = _expectedAllocationRewards(10, ALLOCATION, ALLOCATION); uint256 beforeIssuerBalance = token.balanceOf(address(issuer)); uint256 beforeTotalSupply = token.totalSupply(); @@ -249,7 +243,7 @@ contract RewardsManagerSubgraphServiceTest is RewardsManagerSharedTest { emit RewardsDeniedDueToEligibility( indexer, allocationID1, - _expectedAllocationRewards(ISSUANCE_RATE_PERIODS, ALLOCATION) + _expectedAllocationRewards(ISSUANCE_RATE_PERIODS, ALLOCATION, ALLOCATION) ); vm.prank(address(issuer)); rm.takeRewards(allocationID1); diff --git a/scripts/contracts-migration/test-mapping/t5d.md b/scripts/contracts-migration/test-mapping/t5d.md new file mode 100644 index 000000000..a64ae96a0 --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5d.md @@ -0,0 +1,66 @@ +# T5d test mapping: RewardsManager eligibility oracle and reclaim + +New tests are in `packages/issuance`, on T5a's `RewardsManagerSharedTest` (the RewardsManager Hardhat artifact behind a GraphProxy). `RMEligibilityOracleMock` (the fixture's `oracle`, deployed eligible by default) replaces `MockRewardsEligibilityOracle`: where the old test deployed it with `false`, the port calls `setDefaultResponse(false)` through the shared `_useEligibilityOracle`. `RMIssuerMock` (`issuer`) plays the legacy Staking contract and the old `MockSubgraphService`, so `closeAllocation` becomes `_close` (`takeRewards`) and `callReclaimRewards` becomes `issuer.reclaim`. `setupIndexerAllocation` becomes the shared `_signalThenAllocate` (signal, `vm.roll(+1)`, allocate, as the separate Hardhat transactions), and `mineEpoch` / `mine(n)` become `vm.roll(EPOCH_LENGTH)` / `vm.roll(5)`. The hand-counted 1400 / 1600 GRT values (`expectApproxEq`, 20000 wei) are replaced by exact values derived from the rolls with the shared formula helpers. Where a pre-denial allocation was closed in the Hardhat transaction after `setDenied`, the port rolls one block too, so the subgraph-level reclaim of that block stays in play. No ported case uses a zero-token allocation (the carried `RMIssuerMock` hook caveat does not apply). + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should reject setProviderEligibilityOracle if unauthorized | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_RevertWhen_Unauthorized | Fixture `oracle` (default `true`, as the old deploy) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should set rewards eligibility oracle if governor | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_SetsOracleAsGovernor | Fixture `oracle` (default `true`, as the old deploy) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should allow setting rewards eligibility oracle to zero address | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_AllowsSettingToZeroAddress | Fixture `oracle` (default `true`, as the old deploy) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should reject setting oracle that does not support interface | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_RevertWhen_OracleIsEOA | Bare `vm.expectRevert()`, as the old `.reverted` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should reject setting oracle that does not support IProviderEligibility interface | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_RevertWhen_InterfaceNotSupported | Issuance's `MockERC165` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle › should not emit event when setting same oracle address | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracle_NoEventWhenSettingSameOracle | `not.emit` → `_assertNotEmitted` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility in takeRewards › should deny rewards due to rewards eligibility oracle | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityInTakeRewards_DeniesRewardsDueToEligibilityOracle | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). 1400 GRT hand count (`expectApproxEq`, 20000 wei) replaced by the exact `_expectedAllocationRewards(EPOCH_LENGTH, …)` from the explicit rolls. "Exactly one event" → `vm.expectEmit(address(rm), 1)` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility in takeRewards › should allow rewards when rewards eligibility oracle approves | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityInTakeRewards_AllowsRewardsWhenOracleApproves | Fixture `oracle` (default `true`, as the old deploy). 1400 GRT hand count (`expectApproxEq`, 20000 wei) replaced by the exact `_expectedAllocationRewards(EPOCH_LENGTH, …)` from the explicit rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should prioritize denylist over REO when both deny | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_PrioritizesDenylistOverOracleWhenBothDeny | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should check REO when denylist allows but indexer ineligible | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_ChecksOracleWhenDenylistAllowsButIndexerIneligible | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). 1400 GRT hand count (`expectApproxEq`, 20000 wei) replaced by the exact `_expectedAllocationRewards(EPOCH_LENGTH, …)` from the explicit rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should handle indexer becoming ineligible mid-allocation | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesIneligibleMidAllocation | Fixture `oracle` (default `true`, as the old deploy), then `setIndexerEligible(indexer, false)`. 1600 GRT hand count (one extra Hardhat block) replaced by the exact value from the rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should handle indexer becoming eligible mid-allocation | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_IndexerBecomesEligibleMidAllocation | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`), then `setIndexerEligible(indexer, true)`. 1600 GRT hand count replaced by the exact value from the rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should handle denylist being added mid-allocation | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_DenylistAddedMidAllocation | `vm.roll(+1)` between `setDenied` and close, as the next Hardhat transaction | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should handle denylist being removed mid-allocation | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_DenylistRemovedMidAllocation | Old test only checked the event; the amount is now asserted exactly (post-undeny `EPOCH_LENGTH` blocks only) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should allow rewards when REO is zero address (disabled) | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_AllowsRewardsWhenOracleIsZeroAddress | The fixture never sets its `oracle` on the RewardsManager. 1400 GRT hand count (`expectApproxEq`, 20000 wei) replaced by the exact `_expectedAllocationRewards(EPOCH_LENGTH, …)` from the explicit rolls | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should revert for ineligible indexer when revertOnIneligible is true | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_RevertWhen_IneligibleAndRevertOnIneligible | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). `vm.expectRevert` right before `issuer.close` | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should not revert for eligible indexer when revertOnIneligible is true | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_NoRevertForEligibleWhenRevertOnIneligible | Fixture `oracle` (default `true`, as the old deploy). Event amount asserted exactly (old: event only) | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should reclaim (not revert) for ineligible indexer when revertOnIneligible is false | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_ReclaimsForIneligibleWhenNotRevertOnIneligible | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). Event count 1 with exact amount, plus `takeRewards` returning 0 | +| packages/contracts-test/tests/unit/rewards/rewards-eligibility-oracle.test.ts | Rewards - Eligibility Oracle › rewards eligibility oracle and denylist interaction › should verify event structure differences between denial mechanisms | test/unit/rewards-manager/eligibility.t.sol::test_RewardsEligibilityOracleAndDenylistInteraction_EventStructureDiffersBetweenDenialMechanisms | Mid-test `fixture.tearDown/setUp` → `vm.snapshotState`/`vm.revertToState`. "No `amount` arg" → `RewardsDenied` log has 3 topics and empty data; `RewardsDeniedDueToEligibility` data decodes to the exact amount (> 0). Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`) for part 2 | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should reject if not governor | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should reject setting reclaim address for NONE | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_RevertWhen_ReasonIsNone | `RewardsCondition.NONE` (`HashZero`) | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should set eligibility reclaim address if governor | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_SetsEligibilityReclaimAddressAsGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should set subgraph denied reclaim address if governor | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_SetsSubgraphDeniedReclaimAddressAsGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should allow setting to zero address | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_AllowsSettingToZeroAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setReclaimAddress › should not emit event when setting same address | test/unit/rewards-manager/reclaim.t.sol::test_SetReclaimAddress_NoEventWhenSettingSameAddress | `not.emit` → `_assertNotEmitted` | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - subgraph denylist › should mint to reclaim address when subgraph denied and reclaim address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsSubgraphDenylist_MintsToReclaimAddressWhenSet | 1400 GRT `expectApproxEq` → exact `_expectedSubgraphRewards(EPOCH_LENGTH, …)`; `RewardsReclaimed` now checked with all args (SUBGRAPH_DENIED, amount, `address(0)`, `address(0)`, deployment) | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - subgraph denylist › should reclaim pre-denial rewards via \_deniedRewards when denied after allocation | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsSubgraphDenylist_ReclaimsPreDenialRewardsWhenDeniedAfterAllocation | `vm.roll(+1)` before close, as Hardhat: the subgraph-level reclaim of that block and the allocation-level reclaim are both asserted with args, in order. Old `gte(1400)` kept as `assertGe`, plus the exact sum | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - subgraph denylist › should not mint to reclaim address when reclaim address not set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsSubgraphDenylist_NoMintWhenReclaimAddressNotSet | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - eligibility › should mint to reclaim address when eligibility denied and reclaim address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsEligibility_MintsToReclaimAddressWhenSet | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). 1400 GRT `expectApproxEq` → exact allocation rewards | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim denied rewards - eligibility › should not mint to reclaim address when reclaim address not set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimDeniedRewardsEligibility_NoMintWhenReclaimAddressNotSet | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). Exact denied amount | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should reclaim to SUBGRAPH_DENIED when both fail and both addresses configured | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_ReclaimsToSubgraphDeniedWhenBothConfigured | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). Exact subgraph-level amount; event args asserted | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should reclaim to SUBGRAPH_DENIED even when only INDEXER_INELIGIBLE address configured | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenOnlyIndexerIneligibleConfigured | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). Title is stale: the old assertions (and the contract) drop the rewards; ported as asserted | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should reclaim to INDEXER_INELIGIBLE when both fail but only INDEXER_INELIGIBLE address configured (pre-denial allocation) | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_FallsBackToIndexerIneligibleForPreDenialAllocation | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). `vm.roll(+1)` before close; events asserted with args (reason INDEXER_INELIGIBLE); exact balance | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should drop rewards when both fail and neither address configured | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenNeitherAddressConfigured | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`) | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim precedence - first successful reclaim wins › should drop rewards when subgraph denied without address even if indexer eligible | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimPrecedenceFirstSuccessfulReclaimWins_DropsWhenSubgraphDeniedWithoutAddressEvenIfEligible | Fixture `oracle` (default `true`, as the old deploy) | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should reclaim rewards when reclaim address is set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_ReclaimsWhenReclaimAddressSet | `issuer.reclaim` replaces `MockSubgraphService.callReclaimRewards`; the `beforeEach` deploying it is dropped and the legacy Staking allocation is replaced by `_signal` (RM reads allocations only from the subgraph service). Old `gt(0)` kept, plus exact amount, event args and return value | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should not reclaim when reclaim address is not set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_NoReclaimWhenReclaimAddressNotSet | As above; also asserts the return value 0 | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should return 0 and not emit when reclaim address is not set and no rewards | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_ReturnsZeroWhenNoAddressAndNoRewards | `callStatic` + tx → one call checking return value and logs | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should return 0 when reason is NONE | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_ReturnsZeroWhenReasonIsNone | Old assertion was vacuous (no reclaim address, so 0 regardless of reason); a default reclaim address is set so only the NONE guard yields 0, and the wallet balance is checked | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaimRewards - force close allocation › should reject when called by unauthorized address | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimRewardsForceCloseAllocation_RevertWhen_CalledByUnauthorizedAddress | Raw calldata → `vm.prank(indexer)` + `rm.reclaimRewards` | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setDefaultReclaimAddress › should reject if not governor | test/unit/rewards-manager/reclaim.t.sol::test_SetDefaultReclaimAddress_RevertWhen_NotGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setDefaultReclaimAddress › should set default reclaim address if governor | test/unit/rewards-manager/reclaim.t.sol::test_SetDefaultReclaimAddress_SetsDefaultReclaimAddressAsGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setDefaultReclaimAddress › should allow setting to zero address | test/unit/rewards-manager/reclaim.t.sol::test_SetDefaultReclaimAddress_AllowsSettingToZeroAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › setDefaultReclaimAddress › should not emit event when setting same address | test/unit/rewards-manager/reclaim.t.sol::test_SetDefaultReclaimAddress_NoEventWhenSettingSameAddress | `not.emit` → `_assertNotEmitted` | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › default reclaim address fallback › should use default reclaim address when reason-specific not set | test/unit/rewards-manager/reclaim.t.sol::test_DefaultReclaimAddressFallback_UsesDefaultWhenReasonSpecificNotSet | `beforeEach` → modifier `withAllocationAndAvailabilityOracle`. `mine(5)` → `vm.roll`; event args and exact amount asserted, `gt` kept | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › default reclaim address fallback › should prefer reason-specific address over default | test/unit/rewards-manager/reclaim.t.sol::test_DefaultReclaimAddressFallback_PrefersReasonSpecificOverDefault | As above | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_SIGNAL - zero total signal › should reclaim when no signal and NO_SIGNAL address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoSignalZeroTotalSignal_ReclaimsWhenNoSignalAddressSet | Exact `5 * ISSUANCE_PER_BLOCK`; event args asserted, `gt` kept | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_SIGNAL - zero total signal › should drop rewards when no signal and no reclaim address | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoSignalZeroTotalSignal_DropsWhenNoReclaimAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_SIGNAL - zero total signal › should use default reclaim address for NO_SIGNAL when specific not set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoSignalZeroTotalSignal_UsesDefaultWhenSpecificNotSet | As the NO_SIGNAL reclaim case | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_ALLOCATED_TOKENS - signal but no allocations › should reclaim when signal exists but no allocations and NO_ALLOCATED_TOKENS address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoAllocatedTokensSignalButNoAllocations_ReclaimsWhenNoAllocatedTokensAddressSet | Exact subgraph rewards; event args asserted, `gt` kept | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim NO_ALLOCATED_TOKENS - signal but no allocations › should drop rewards when no allocations and no reclaim address | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimNoAllocatedTokensSignalButNoAllocations_DropsWhenNoReclaimAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim BELOW_MINIMUM_SIGNAL › should reclaim when signal below minimum and BELOW_MINIMUM_SIGNAL address set | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimBelowMinimumSignal_ReclaimsWhenBelowMinimumAddressSet | `beforeEach` → modifier `withMinimumSignal`. Exact amount; event args asserted, `gt` kept | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim BELOW_MINIMUM_SIGNAL › should not reclaim when signal at or above minimum | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimBelowMinimumSignal_NoReclaimWhenSignalAtOrAboveMinimum | `staking.allocateFrom` → `_allocate` one block after the signal | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim BELOW_MINIMUM_SIGNAL › should drop rewards when below minimum and no reclaim address | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimBelowMinimumSignal_DropsWhenNoReclaimAddress | | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › reclaim BELOW_MINIMUM_SIGNAL › should use BELOW_MINIMUM_SIGNAL when denied but SUBGRAPH_DENIED address not configured | test/unit/rewards-manager/reclaim.t.sol::test_ReclaimBelowMinimumSignal_UsesBelowMinimumWhenDeniedWithoutSubgraphDeniedAddress | Event reason BELOW_MINIMUM_SIGNAL asserted (old: event only); exact amount | +| packages/contracts-test/tests/unit/rewards/rewards-reclaim.test.ts | Rewards - Reclaim Addresses › dual denial - SUBGRAPH_DENIED takes precedence when configured › should reclaim to SUBGRAPH_DENIED when both conditions true and SUBGRAPH_DENIED address configured (pre-denial allocation) | test/unit/rewards-manager/reclaim.t.sol::test_DualDenialSubgraphDeniedTakesPrecedenceWhenConfigured_ReclaimsToSubgraphDeniedForPreDenialAllocation | Fixture `oracle` with `setDefaultResponse(false)` (old: deployed with `false`). `vm.roll(+1)` before close; events asserted with args (reason SUBGRAPH_DENIED); exact balance = allocation rewards + the subgraph-level block, `gt` kept | + +## Coverage + +`contracts/rewards/RewardsManager.sol`: every external/public name in `functionsHit` is now called by an issuance test (`setReclaimAddress`, `setDefaultReclaimAddress` and `getDefaultReclaimAddress`, left open by T5c, are covered here). No `MISSING` names.