From 3b2a00ace16c37cef556f5d4770e0c6c7984b50b Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 26 Oct 2026 13:32:34 -0300 Subject: [PATCH] test(issuance): add RewardsManager Foundry fixture and port interface and config tests --- .../tests/unit/rewards/rewards-config.test.ts | 320 ------------------ .../unit/rewards/rewards-interface.test.ts | 144 -------- packages/issuance/foundry.toml | 2 +- packages/issuance/package.json | 3 +- .../test/unit/mocks/MockGraphToken.sol | 4 + .../rewards-manager/IRewardsManagerLike.sol | 45 +++ .../test/unit/rewards-manager/config.t.sol | 270 +++++++++++++++ .../test/unit/rewards-manager/interface.t.sol | 77 +++++ .../rewards-manager/mocks/RMCurationMock.sol | 46 +++ .../mocks/RMEligibilityOracleMock.sol | 37 ++ .../mocks/RMIssuanceAllocatorMock.sol | 48 +++ .../rewards-manager/mocks/RMIssuerMock.sol | 100 ++++++ .../test/unit/rewards-manager/setup.t.sol | 11 + .../test/unit/rewards-manager/shared.t.sol | 84 +++++ .../test/unit/shared/ProtocolFixture.t.sol | 66 ++++ pnpm-lock.yaml | 3 + .../contracts-migration/test-mapping/t5a.md | 45 +++ 17 files changed, 839 insertions(+), 466 deletions(-) delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-config.test.ts delete mode 100644 packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts create mode 100644 packages/issuance/test/unit/rewards-manager/IRewardsManagerLike.sol create mode 100644 packages/issuance/test/unit/rewards-manager/config.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/interface.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/mocks/RMCurationMock.sol create mode 100644 packages/issuance/test/unit/rewards-manager/mocks/RMEligibilityOracleMock.sol create mode 100644 packages/issuance/test/unit/rewards-manager/mocks/RMIssuanceAllocatorMock.sol create mode 100644 packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol create mode 100644 packages/issuance/test/unit/rewards-manager/setup.t.sol create mode 100644 packages/issuance/test/unit/rewards-manager/shared.t.sol create mode 100644 packages/issuance/test/unit/shared/ProtocolFixture.t.sol create mode 100644 scripts/contracts-migration/test-mapping/t5a.md diff --git a/packages/contracts-test/tests/unit/rewards/rewards-config.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-config.test.ts deleted file mode 100644 index bd3b2569a..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-config.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { Curation } from '@graphprotocol/contracts' -import { GraphToken } from '@graphprotocol/contracts' -import { IStaking } from '@graphprotocol/contracts' -import { RewardsManager } from '@graphprotocol/contracts' -import { GraphNetworkContracts, helpers, randomHexBytes, toBN, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { BigNumber } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block - -describe('Rewards - Configuration', () => { - const graph = hre.graph() - let governor: SignerWithAddress - let indexer1: SignerWithAddress - let indexer2: SignerWithAddress - let curator1: SignerWithAddress - let curator2: SignerWithAddress - let oracle: SignerWithAddress - let assetHolder: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let grt: GraphToken - let curation: Curation - let staking: IStaking - let rewardsManager: RewardsManager - - const subgraphDeploymentID1 = randomHexBytes() - - before(async function () { - const testAccounts = await graph.getTestAccounts() - ;[indexer1, indexer2, curator1, curator2, oracle, assetHolder] = testAccounts - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - grt = contracts.GraphToken as GraphToken - curation = contracts.Curation as Curation - staking = contracts.Staking as IStaking - rewardsManager = contracts.RewardsManager - - // 200 GRT per block - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - - // Distribute test funds - for (const wallet of [indexer1, indexer2, curator1, curator2, assetHolder]) { - await grt.connect(governor).mint(wallet.address, toGRT('1000000')) - await grt.connect(wallet).approve(staking.address, toGRT('1000000')) - await grt.connect(wallet).approve(curation.address, toGRT('1000000')) - } - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - describe('configuration', function () { - describe('initialize', function () { - it('should revert when called on implementation contract', async function () { - // Try to call initialize on the implementation contract (should revert with onlyImpl) - const tx = rewardsManager.connect(governor).initialize(contracts.Controller.address) - await expect(tx).revertedWith('Only implementation') - }) - }) - - describe('issuance per block update', function () { - it('should reject set issuance per block if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setIssuancePerBlock(toGRT('1.025')) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set issuance rate to minimum allowed (0)', async function () { - const newIssuancePerBlock = toGRT('0') - await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) - expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) - }) - - it('should set issuance rate', async function () { - const newIssuancePerBlock = toGRT('100.025') - await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) - expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) - expect(await rewardsManager.accRewardsPerSignalLastBlockUpdated()).eq(await helpers.latestBlock()) - }) - - it('should update timestamp when transitioning from zero to non-zero issuance', async function () { - // Add some signal so rewards can be calculated - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('10000'), 0) - - // Mine some blocks with rewards active - await helpers.mine(10) - - // Set issuance to zero - this updates timestamp correctly - await rewardsManager.connect(governor).setIssuancePerBlock(0) - const blockAfterZeroIssuance = await helpers.latestBlock() - - // Verify timestamp was updated - const timestampAfterZero = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - expect(timestampAfterZero).to.equal(blockAfterZeroIssuance) - - // Mine blocks during zero issuance period - await helpers.mine(10) - - // Set issuance back to non-zero - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - const blockAfterRestore = await helpers.latestBlock() - - // Timestamp should be updated when transitioning from zero issuance - const timestampAfterRestore = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - expect(timestampAfterRestore).to.equal( - blockAfterRestore, - 'Timestamp should be updated when transitioning from zero issuance', - ) - }) - - it('should not over-issue rewards after zero issuance period', async function () { - // Add signal - await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('10000'), 0) - - // Get signalled tokens for calculation - const signalledTokens = await grt.balanceOf(curation.address) - - // Mine some blocks with rewards active - await helpers.mine(10) - - // Capture rewards and timestamp before zero issuance period - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const rewardsAfterFirstPeriod = await rewardsManager.accRewardsPerSignal() - - // Set issuance to zero - await rewardsManager.connect(governor).setIssuancePerBlock(0) - const timestampAfterZeroSet = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - - // Mine blocks during zero issuance - NO rewards should accumulate - await helpers.mine(10) - - // Restore issuance - record the block when non-zero issuance starts - await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) - const timestampAfterRestore = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - - // Mine more blocks with rewards active - await helpers.mine(10) - - // Update and check final rewards - await rewardsManager.connect(governor).updateAccRewardsPerSignal() - const finalRewards = await rewardsManager.accRewardsPerSignal() - const finalTimestamp = await rewardsManager.accRewardsPerSignalLastBlockUpdated() - - // The actual rewards increase from first period to final - const rewardsIncrease = finalRewards.sub(rewardsAfterFirstPeriod) - - // Calculate expected rewards based on ACTUAL blocks where issuance was active - const FIXED_POINT_SCALING_FACTOR = BigNumber.from(10).pow(18) - const activeBlocksAfterRestore = finalTimestamp.sub(timestampAfterRestore) - const expectedIncrease = ISSUANCE_PER_BLOCK.mul(activeBlocksAfterRestore) - .mul(FIXED_POINT_SCALING_FACTOR) - .div(signalledTokens) - - // Key assertion: timestamp should advance during zero issuance period - expect(timestampAfterRestore.toNumber()).to.be.greaterThan( - timestampAfterZeroSet.toNumber(), - 'Timestamp should advance when setting non-zero issuance', - ) - - // Allow some tolerance for block timing (1 block variance) - const tolerance = ISSUANCE_PER_BLOCK.mul(1).mul(FIXED_POINT_SCALING_FACTOR).div(signalledTokens) - - // Rewards should match the active period only - expect(rewardsIncrease).to.be.closeTo(expectedIncrease, tolerance, 'Rewards should match active period only') - }) - }) - - describe('subgraph availability service', function () { - it('should reject set subgraph oracle if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setSubgraphAvailabilityOracle(oracle.address) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set subgraph oracle if governor', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - expect(await rewardsManager.subgraphAvailabilityOracle()).eq(oracle.address) - }) - - it('should reject to deny subgraph if not the oracle', async function () { - const tx = rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) - await expect(tx).revertedWith('Caller must be the subgraph availability oracle') - }) - - it('should deny subgraph', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - const blockNum = await helpers.latestBlock() - await expect(tx) - .emit(rewardsManager, 'RewardsDenylistUpdated') - .withArgs(subgraphDeploymentID1, blockNum + 1) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - }) - - it('should allow removing subgraph from denylist', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - // First deny the subgraph - await rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - - // Then remove from denylist - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, false) - await expect(tx).emit(rewardsManager, 'RewardsDenylistUpdated').withArgs(subgraphDeploymentID1, 0) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(false) - }) - - it('should be a no-op when denying an already denied subgraph', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - // Deny the subgraph - await rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - const denyBlockBefore = await rewardsManager.denylist(subgraphDeploymentID1) - - // Deny again - should not emit event or change denylist block number - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, true) - await expect(tx).not.emit(rewardsManager, 'RewardsDenylistUpdated') - - // State should be unchanged - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(true) - const denyBlockAfter = await rewardsManager.denylist(subgraphDeploymentID1) - expect(denyBlockAfter).eq(denyBlockBefore) - }) - - it('should be a no-op when undenying an already not-denied subgraph', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - // Subgraph is not denied by default - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(false) - - // Undeny should not emit event - const tx = rewardsManager.connect(oracle).setDenied(subgraphDeploymentID1, false) - await expect(tx).not.emit(rewardsManager, 'RewardsDenylistUpdated') - - // State should remain unchanged - expect(await rewardsManager.isDenied(subgraphDeploymentID1)).eq(false) - expect(await rewardsManager.denylist(subgraphDeploymentID1)).eq(0) - }) - - it('should reject setMinimumSubgraphSignal if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setMinimumSubgraphSignal(toGRT('1000')) - await expect(tx).revertedWith('Not authorized') - }) - - it('should allow setMinimumSubgraphSignal from subgraph availability oracle', async function () { - await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(oracle.address) - - const newMinimumSignal = toGRT('2000') - const tx = rewardsManager.connect(oracle).setMinimumSubgraphSignal(newMinimumSignal) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('minimumSubgraphSignal') - - expect(await rewardsManager.minimumSubgraphSignal()).eq(newMinimumSignal) - }) - - it('should allow setMinimumSubgraphSignal from governor', async function () { - const newMinimumSignal = toGRT('3000') - const tx = rewardsManager.connect(governor).setMinimumSubgraphSignal(newMinimumSignal) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('minimumSubgraphSignal') - - expect(await rewardsManager.minimumSubgraphSignal()).eq(newMinimumSignal) - }) - }) - - describe('revertOnIneligible', function () { - it('should reject setRevertOnIneligible if unauthorized', async function () { - const tx = rewardsManager.connect(indexer1).setRevertOnIneligible(true) - await expect(tx).revertedWith('Only Controller governor') - }) - - it('should set revertOnIneligible to true', async function () { - const tx = rewardsManager.connect(governor).setRevertOnIneligible(true) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('revertOnIneligible') - expect(await rewardsManager.getRevertOnIneligible()).eq(true) - }) - - it('should set revertOnIneligible to false', async function () { - // First set to true - await rewardsManager.connect(governor).setRevertOnIneligible(true) - - // Then set back to false - const tx = rewardsManager.connect(governor).setRevertOnIneligible(false) - await expect(tx).emit(rewardsManager, 'ParameterUpdated').withArgs('revertOnIneligible') - expect(await rewardsManager.getRevertOnIneligible()).eq(false) - }) - - it('should be a no-op when setting same value (false to false)', async function () { - // Default is false - expect(await rewardsManager.getRevertOnIneligible()).eq(false) - - const tx = rewardsManager.connect(governor).setRevertOnIneligible(false) - await expect(tx).to.not.emit(rewardsManager, 'ParameterUpdated') - - expect(await rewardsManager.getRevertOnIneligible()).eq(false) - }) - - it('should be a no-op when setting same value (true to true)', async function () { - await rewardsManager.connect(governor).setRevertOnIneligible(true) - - const tx = rewardsManager.connect(governor).setRevertOnIneligible(true) - await expect(tx).to.not.emit(rewardsManager, 'ParameterUpdated') - - expect(await rewardsManager.getRevertOnIneligible()).eq(true) - }) - }) - }) -}) diff --git a/packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts b/packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts deleted file mode 100644 index db77d5f9b..000000000 --- a/packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { RewardsManager } from '@graphprotocol/contracts' -import { - IERC165__factory, - IIssuanceTarget__factory, - IProviderEligibilityManagement__factory, - IRewardsManager__factory, -} from '@graphprotocol/interfaces/types' -import { GraphNetworkContracts, toGRT } from '@graphprotocol/sdk' -import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { expect } from 'chai' -import { constants } from 'ethers' -import hre from 'hardhat' - -import { NetworkFixture } from '../lib/fixtures' - -describe('RewardsManager interfaces', () => { - const graph = hre.graph() - let governor: SignerWithAddress - - let fixture: NetworkFixture - - let contracts: GraphNetworkContracts - let rewardsManager: RewardsManager - - before(async function () { - ;({ governor } = await graph.getNamedAccounts()) - - fixture = new NetworkFixture(graph.provider) - contracts = await fixture.load(governor) - rewardsManager = contracts.RewardsManager - - // Set a default issuance per block - await rewardsManager.connect(governor).setIssuancePerBlock(toGRT('200')) - }) - - beforeEach(async function () { - await fixture.setUp() - }) - - afterEach(async function () { - await fixture.tearDown() - }) - - /** - * Interface ID Stability Tests - * - * These tests verify that interface IDs remain stable across builds. - * Changes to these IDs indicate breaking changes to the interface definitions. - * - * If a test fails: - * 1. Verify the interface change was intentional - * 2. Understand the impact on deployed contracts - * 3. Update the expected ID if the change is correct - * 4. Document the breaking change in release notes - */ - describe('Interface ID Stability', () => { - it('IERC165 should have stable interface ID', () => { - expect(IERC165__factory.interfaceId).to.equal('0x01ffc9a7') - }) - - it('IIssuanceTarget should have stable interface ID', () => { - expect(IIssuanceTarget__factory.interfaceId).to.equal('0x19f6601a') - }) - - it('IRewardsManager should have stable interface ID', () => { - expect(IRewardsManager__factory.interfaceId).to.equal('0x8469b577') - }) - }) - - describe('supportsInterface', function () { - it('should support IIssuanceTarget interface', async function () { - const supports = await rewardsManager.supportsInterface(IIssuanceTarget__factory.interfaceId) - expect(supports).to.be.true - }) - - it('should support IRewardsManager interface', async function () { - const supports = await rewardsManager.supportsInterface(IRewardsManager__factory.interfaceId) - expect(supports).to.be.true - }) - - it('should support IERC165 interface', async function () { - const supports = await rewardsManager.supportsInterface(IERC165__factory.interfaceId) - expect(supports).to.be.true - }) - - it('should support IProviderEligibilityManagement interface', async function () { - const supports = await rewardsManager.supportsInterface(IProviderEligibilityManagement__factory.interfaceId) - expect(supports).to.be.true - }) - - it('should return false for unsupported interfaces', async function () { - // Test with an unknown interface ID - const unknownInterfaceId = '0x12345678' // Random interface ID - const supports = await rewardsManager.supportsInterface(unknownInterfaceId) - expect(supports).to.be.false - }) - }) - - describe('getter functions', function () { - it('should return zero address for issuance allocator when not set', async function () { - const allocator = await rewardsManager.getIssuanceAllocator() - expect(allocator).to.equal(constants.AddressZero) - }) - - it('should return zero address for rewards eligibility oracle when not set', async function () { - const oracle = await rewardsManager.getProviderEligibilityOracle() - expect(oracle).to.equal(constants.AddressZero) - }) - - it('should return zero address for reclaim address when not set', async function () { - const reclaimAddress = await rewardsManager.getReclaimAddress(constants.HashZero) - expect(reclaimAddress).to.equal(constants.AddressZero) - }) - }) - - describe('calcRewards', function () { - it('should calculate rewards correctly', async function () { - const tokens = toGRT('1000') - const accRewardsPerAllocatedToken = toGRT('0.5') - - // Expected: (1000 * 0.5 * 1e18) / 1e18 = 500 GRT - const expectedRewards = toGRT('500') - - const rewards = await rewardsManager.calcRewards(tokens, accRewardsPerAllocatedToken) - expect(rewards).to.equal(expectedRewards) - }) - - it('should return 0 when tokens is 0', async function () { - const tokens = toGRT('0') - const accRewardsPerAllocatedToken = toGRT('0.5') - - const rewards = await rewardsManager.calcRewards(tokens, accRewardsPerAllocatedToken) - expect(rewards).to.equal(0) - }) - - it('should return 0 when accRewardsPerAllocatedToken is 0', async function () { - const tokens = toGRT('1000') - const accRewardsPerAllocatedToken = toGRT('0') - - const rewards = await rewardsManager.calcRewards(tokens, accRewardsPerAllocatedToken) - expect(rewards).to.equal(0) - }) - }) -}) diff --git a/packages/issuance/foundry.toml b/packages/issuance/foundry.toml index 1a256a94c..e21cc61ba 100644 --- a/packages/issuance/foundry.toml +++ b/packages/issuance/foundry.toml @@ -10,7 +10,7 @@ remappings = [ "forge-std/=node_modules/forge-std/src/", ] cache_path = 'cache_forge' -fs_permissions = [{ access = "read", path = "./" }] +fs_permissions = [{ access = "read", path = "./" }, { access = "read", path = "node_modules/@graphprotocol/contracts/artifacts" }] solc_version = '0.8.35' evm_version = 'cancun' isolate = false diff --git a/packages/issuance/package.json b/packages/issuance/package.json index 535b53d85..e3da8f778 100644 --- a/packages/issuance/package.json +++ b/packages/issuance/package.json @@ -27,7 +27,7 @@ "clean": "rm -rf artifacts/ forge-artifacts/ cache_forge/ coverage/ cache/ types/ typechain-src/ .eslintcache test/node_modules/", "compile": "hardhat compile --quiet --no-tests", "typechain": "typechain --target ethers-v6 --out-dir typechain-src 'artifacts/contracts/**/!(*.dbg).json' && tsc -p tsconfig.typechain.json && rm -rf typechain-src && echo '{\"type\":\"commonjs\"}' > types/package.json", - "test": "pnpm test:self", + "test": "pnpm build && pnpm test:self", "test:self": "forge test", "test:coverage": "forge coverage", "test:coverage:self": "mkdir -p coverage && forge coverage --report lcov --report-file coverage/lcov.info", @@ -48,6 +48,7 @@ "README.md" ], "devDependencies": { + "@graphprotocol/contracts": "workspace:^", "@graphprotocol/interfaces": "workspace:^", "@nomicfoundation/hardhat-ethers": "^4.0.0", "@nomicfoundation/hardhat-ethers-chai-matchers": "^3.0.0", diff --git a/packages/issuance/test/unit/mocks/MockGraphToken.sol b/packages/issuance/test/unit/mocks/MockGraphToken.sol index dd07fab6e..664efb6ba 100644 --- a/packages/issuance/test/unit/mocks/MockGraphToken.sol +++ b/packages/issuance/test/unit/mocks/MockGraphToken.sol @@ -12,4 +12,8 @@ contract MockGraphToken is ERC20 { function mint(address to, uint256 amount) external { _mint(to, amount); } + + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } } diff --git a/packages/issuance/test/unit/rewards-manager/IRewardsManagerLike.sol b/packages/issuance/test/unit/rewards-manager/IRewardsManagerLike.sol new file mode 100644 index 000000000..9d98e2088 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/IRewardsManagerLike.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { IManaged } from "@graphprotocol/interfaces/contracts/contracts/governance/IManaged.sol"; +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; +import { IRewardsManagerDeprecated } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManagerDeprecated.sol"; +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; +import { IProviderEligibilityManagement } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibilityManagement.sol"; + +/// Every interface RewardsManager implements, plus the initializer, storage getters and events they omit. +interface IRewardsManagerLike is + IRewardsManager, + IRewardsManagerDeprecated, + IManaged, + IIssuanceTarget, + IProviderEligibilityManagement, + IERC165 +{ + event ParameterUpdated(string param); + + function initialize(address controller) external; + + function accRewardsPerSignal() external view returns (uint256); + + function accRewardsPerSignalLastBlockUpdated() external view returns (uint256); + + function subgraphAvailabilityOracle() external view returns (address); + + function subgraphs( + bytes32 subgraphDeploymentId + ) + external + view + returns ( + uint256 accRewardsForSubgraph, + uint256 accRewardsForSubgraphSnapshot, + uint256 accRewardsPerSignalSnapshot, + uint256 accRewardsPerAllocatedToken + ); + + function denylist(bytes32 subgraphDeploymentId) external view returns (uint256); + + function minimumSubgraphSignal() external view returns (uint256); +} diff --git a/packages/issuance/test/unit/rewards-manager/config.t.sol b/packages/issuance/test/unit/rewards-manager/config.t.sol new file mode 100644 index 000000000..98522440f --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/config.t.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Vm } from "forge-std/Vm.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerConfigTest is RewardsManagerSharedTest { + event ParameterUpdated(string param); + event RewardsDenylistUpdated(bytes32 indexed subgraphDeploymentID, uint256 sinceBlock); + + bytes32 internal constant SUBGRAPH_DEPLOYMENT_ID_1 = keccak256("subgraphDeploymentID1"); + + address internal availabilityOracle = makeAddr("availabilityOracle"); + + function _setAvailabilityOracle() internal { + vm.prank(governor); + rm.setSubgraphAvailabilityOracle(availabilityOracle); + } + + function _assertNotEmitted(Vm.Log[] memory logs, bytes32 topic) internal view { + for (uint256 i = 0; i < logs.length; ++i) { + if (logs[i].emitter == address(rm)) assertTrue(logs[i].topics[0] != topic, "unexpected event"); + } + } + + // -- initialize -- + + function test_Initialize_RevertWhen_CalledOnImplementation() public { + // As in the old test, the call goes through the proxy, so `onlyImpl` rejects the caller + vm.expectRevert(bytes("Only implementation")); + vm.prank(governor); + rm.initialize(address(controller)); + } + + // -- issuance per block update -- + + function test_IssuancePerBlockUpdate_RevertWhen_Unauthorized() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(indexer); + rm.setIssuancePerBlock(1.025 ether); + } + + function test_IssuancePerBlockUpdate_SetToMinimumZero() public { + vm.prank(governor); + rm.setIssuancePerBlock(0); + assertEq(rm.issuancePerBlock(), 0); + } + + function test_IssuancePerBlockUpdate_SetIssuanceRate() public { + uint256 newIssuancePerBlock = 100.025 ether; + vm.prank(governor); + rm.setIssuancePerBlock(newIssuancePerBlock); + assertEq(rm.issuancePerBlock(), newIssuancePerBlock); + assertEq(rm.accRewardsPerSignalLastBlockUpdated(), block.number); + } + + function test_IssuancePerBlockUpdate_UpdatesTimestampFromZeroToNonZero() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 10_000 ether); + vm.roll(block.number + 10); + + vm.prank(governor); + rm.setIssuancePerBlock(0); + assertEq(rm.accRewardsPerSignalLastBlockUpdated(), block.number); + + vm.roll(block.number + 10); + + vm.prank(governor); + rm.setIssuancePerBlock(ISSUANCE_PER_BLOCK); + assertEq( + rm.accRewardsPerSignalLastBlockUpdated(), + block.number, + "Timestamp should be updated when transitioning from zero issuance" + ); + } + + function test_IssuancePerBlockUpdate_NoOverIssuanceAfterZeroPeriod() public { + _signal(SUBGRAPH_DEPLOYMENT_ID_1, 10_000 ether); + uint256 signalledTokens = token.balanceOf(address(curation)); + + vm.roll(block.number + 10); + + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + uint256 rewardsAfterFirstPeriod = rm.accRewardsPerSignal(); + + vm.prank(governor); + rm.setIssuancePerBlock(0); + uint256 timestampAfterZeroSet = rm.accRewardsPerSignalLastBlockUpdated(); + + // No rewards accrue during zero issuance + vm.roll(block.number + 10); + + vm.prank(governor); + rm.setIssuancePerBlock(ISSUANCE_PER_BLOCK); + uint256 timestampAfterRestore = rm.accRewardsPerSignalLastBlockUpdated(); + + vm.roll(block.number + 10); + + vm.prank(governor); + rm.updateAccRewardsPerSignal(); + uint256 finalRewards = rm.accRewardsPerSignal(); + uint256 finalTimestamp = rm.accRewardsPerSignalLastBlockUpdated(); + + assertGt( + timestampAfterRestore, + timestampAfterZeroSet, + "Timestamp should advance when setting non-zero issuance" + ); + + // Exact: the update and the zero-issuance set share a block, so no stray block accrues (old tolerance: 1 block) + assertEq( + finalRewards - rewardsAfterFirstPeriod, + _expectedRewardsPerSignal(finalTimestamp - timestampAfterRestore, signalledTokens), + "Rewards should match active period only" + ); + } + + // -- subgraph availability service -- + + function test_SubgraphAvailabilityService_RevertWhen_SetOracleUnauthorized() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(indexer); + rm.setSubgraphAvailabilityOracle(availabilityOracle); + } + + function test_SubgraphAvailabilityService_SetOracle() public { + _setAvailabilityOracle(); + assertEq(rm.subgraphAvailabilityOracle(), availabilityOracle); + } + + function test_SubgraphAvailabilityService_RevertWhen_DenyNotOracle() public { + vm.expectRevert(bytes("Caller must be the subgraph availability oracle")); + vm.prank(governor); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + } + + function test_SubgraphAvailabilityService_Deny() public { + _setAvailabilityOracle(); + + vm.expectEmit(address(rm)); + emit RewardsDenylistUpdated(SUBGRAPH_DEPLOYMENT_ID_1, block.number); + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + assertTrue(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + } + + function test_SubgraphAvailabilityService_Undeny() public { + _setAvailabilityOracle(); + + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + assertTrue(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + + vm.expectEmit(address(rm)); + emit RewardsDenylistUpdated(SUBGRAPH_DEPLOYMENT_ID_1, 0); + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); + assertFalse(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + } + + function test_SubgraphAvailabilityService_DenyAlreadyDeniedIsNoOp() public { + _setAvailabilityOracle(); + + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + assertTrue(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + uint256 denyBlockBefore = rm.denylist(SUBGRAPH_DEPLOYMENT_ID_1); + + vm.recordLogs(); + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, true); + _assertNotEmitted(vm.getRecordedLogs(), RewardsDenylistUpdated.selector); + + assertTrue(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + assertEq(rm.denylist(SUBGRAPH_DEPLOYMENT_ID_1), denyBlockBefore); + } + + function test_SubgraphAvailabilityService_UndenyNotDeniedIsNoOp() public { + _setAvailabilityOracle(); + + assertFalse(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + + vm.recordLogs(); + vm.prank(availabilityOracle); + rm.setDenied(SUBGRAPH_DEPLOYMENT_ID_1, false); + _assertNotEmitted(vm.getRecordedLogs(), RewardsDenylistUpdated.selector); + + assertFalse(rm.isDenied(SUBGRAPH_DEPLOYMENT_ID_1)); + assertEq(rm.denylist(SUBGRAPH_DEPLOYMENT_ID_1), 0); + } + + function test_SubgraphAvailabilityService_RevertWhen_SetMinimumSubgraphSignalUnauthorized() public { + vm.expectRevert(bytes("Not authorized")); + vm.prank(indexer); + rm.setMinimumSubgraphSignal(1000 ether); + } + + function test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromOracle() public { + _setAvailabilityOracle(); + + uint256 newMinimumSignal = 2000 ether; + vm.expectEmit(address(rm)); + emit ParameterUpdated("minimumSubgraphSignal"); + vm.prank(availabilityOracle); + rm.setMinimumSubgraphSignal(newMinimumSignal); + + assertEq(rm.minimumSubgraphSignal(), newMinimumSignal); + } + + function test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromGovernor() public { + uint256 newMinimumSignal = 3000 ether; + vm.expectEmit(address(rm)); + emit ParameterUpdated("minimumSubgraphSignal"); + vm.prank(governor); + rm.setMinimumSubgraphSignal(newMinimumSignal); + + assertEq(rm.minimumSubgraphSignal(), newMinimumSignal); + } + + // -- revertOnIneligible -- + + function test_RevertOnIneligible_RevertWhen_Unauthorized() public { + vm.expectRevert(bytes("Only Controller governor")); + vm.prank(indexer); + rm.setRevertOnIneligible(true); + } + + function test_RevertOnIneligible_SetTrue() public { + vm.expectEmit(address(rm)); + emit ParameterUpdated("revertOnIneligible"); + vm.prank(governor); + rm.setRevertOnIneligible(true); + assertTrue(rm.getRevertOnIneligible()); + } + + function test_RevertOnIneligible_SetFalse() public { + vm.prank(governor); + rm.setRevertOnIneligible(true); + + vm.expectEmit(address(rm)); + emit ParameterUpdated("revertOnIneligible"); + vm.prank(governor); + rm.setRevertOnIneligible(false); + assertFalse(rm.getRevertOnIneligible()); + } + + function test_RevertOnIneligible_SameValueFalseIsNoOp() public { + assertFalse(rm.getRevertOnIneligible()); + + vm.recordLogs(); + vm.prank(governor); + rm.setRevertOnIneligible(false); + _assertNotEmitted(vm.getRecordedLogs(), ParameterUpdated.selector); + + assertFalse(rm.getRevertOnIneligible()); + } + + function test_RevertOnIneligible_SameValueTrueIsNoOp() public { + vm.prank(governor); + rm.setRevertOnIneligible(true); + + vm.recordLogs(); + vm.prank(governor); + rm.setRevertOnIneligible(true); + _assertNotEmitted(vm.getRecordedLogs(), ParameterUpdated.selector); + + assertTrue(rm.getRevertOnIneligible()); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/interface.t.sol b/packages/issuance/test/unit/rewards-manager/interface.t.sol new file mode 100644 index 000000000..932ba3828 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/interface.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; +import { IProviderEligibilityManagement } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibilityManagement.sol"; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +/// @notice Interface ids are stable across builds: a change is a breaking change to the interface definitions. +contract RewardsManagerInterfaceTest is RewardsManagerSharedTest { + // -- Interface ID Stability -- + + function test_InterfaceIdStability_IERC165() public pure { + assertEq(type(IERC165).interfaceId, bytes4(0x01ffc9a7)); + } + + function test_InterfaceIdStability_IIssuanceTarget() public pure { + assertEq(type(IIssuanceTarget).interfaceId, bytes4(0x19f6601a)); + } + + function test_InterfaceIdStability_IRewardsManager() public pure { + assertEq(type(IRewardsManager).interfaceId, bytes4(0x8469b577)); + } + + // -- supportsInterface -- + + function test_SupportsInterface_IIssuanceTarget() public view { + assertTrue(rm.supportsInterface(type(IIssuanceTarget).interfaceId)); + } + + function test_SupportsInterface_IRewardsManager() public view { + assertTrue(rm.supportsInterface(type(IRewardsManager).interfaceId)); + } + + function test_SupportsInterface_IERC165() public view { + assertTrue(rm.supportsInterface(type(IERC165).interfaceId)); + } + + function test_SupportsInterface_IProviderEligibilityManagement() public view { + assertTrue(rm.supportsInterface(type(IProviderEligibilityManagement).interfaceId)); + } + + function test_SupportsInterface_FalseForUnsupported() public view { + assertFalse(rm.supportsInterface(bytes4(0x12345678))); + } + + // -- getter functions -- + + function test_GetterFunctions_IssuanceAllocatorZeroWhenNotSet() public view { + assertEq(address(rm.getIssuanceAllocator()), address(0)); + } + + function test_GetterFunctions_EligibilityOracleZeroWhenNotSet() public view { + assertEq(address(rm.getProviderEligibilityOracle()), address(0)); + } + + function test_GetterFunctions_ReclaimAddressZeroWhenNotSet() public view { + assertEq(rm.getReclaimAddress(bytes32(0)), address(0)); + } + + // -- calcRewards -- + + function test_CalcRewards_Calculates() public view { + // (1000 * 0.5 * 1e18) / 1e18 = 500 GRT + assertEq(rm.calcRewards(1000 ether, 0.5 ether), 500 ether); + } + + function test_CalcRewards_ZeroWhenTokensZero() public view { + assertEq(rm.calcRewards(0, 0.5 ether), 0); + } + + function test_CalcRewards_ZeroWhenAccRewardsPerAllocatedTokenZero() public view { + assertEq(rm.calcRewards(1000 ether, 0), 0); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMCurationMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMCurationMock.sol new file mode 100644 index 000000000..8d3cddd0a --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMCurationMock.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; +import { MockGraphToken } from "../../mocks/MockGraphToken.sol"; + +/// Curation stand-in for RewardsManager: pool tokens per deployment, with the signal held as this contract's +/// GRT balance (RewardsManager reads total signal as `graphToken.balanceOf(curation)`). +contract RMCurationMock { + MockGraphToken public immutable token; + IRewardsManager public rm; + + mapping(bytes32 subgraphDeploymentId => uint256 tokens) private _poolTokens; + + constructor(MockGraphToken token_) { + token = token_; + } + + function setRewardsManager(address rm_) external { + rm = IRewardsManager(rm_); + } + + /// Hook first, as Curation.mint does, so the RewardsManager accrues on the old signal. + function signal(bytes32 sd, uint256 tokens) external { + rm.onSubgraphSignalUpdate(sd); + _poolTokens[sd] += tokens; + token.mint(address(this), tokens); + } + + /// Hook first, as Curation.burn does. + function unsignal(bytes32 sd, uint256 tokens) external { + rm.onSubgraphSignalUpdate(sd); + _poolTokens[sd] -= tokens; + token.burn(tokens); + } + + /// Curation fees from query collection: no RewardsManager hook, as Curation.collect. + function addFees(bytes32 sd, uint256 tokens) external { + _poolTokens[sd] += tokens; + token.mint(address(this), tokens); + } + + function getCurationPoolTokens(bytes32 sd) external view returns (uint256) { + return _poolTokens[sd]; + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMEligibilityOracleMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMEligibilityOracleMock.sol new file mode 100644 index 000000000..995f627f5 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMEligibilityOracleMock.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { IProviderEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IProviderEligibility.sol"; + +/// 0.8 port of packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol. +contract RMEligibilityOracleMock is IProviderEligibility, IERC165 { + mapping(address indexer => bool eligibility) private _eligible; + mapping(address indexer => bool set) private _isSet; + bool private _defaultResponse; + + constructor(bool newDefaultResponse) { + _defaultResponse = newDefaultResponse; + } + + function setIndexerEligible(address indexer, bool eligibility) external { + _eligible[indexer] = eligibility; + _isSet[indexer] = true; + } + + function setDefaultResponse(bool newDefaultResponse) external { + _defaultResponse = newDefaultResponse; + } + + /// Explicitly set indexers get their value; others get the default response. + function isEligible(address indexer) external view override returns (bool) { + if (_isSet[indexer]) { + return _eligible[indexer]; + } + return _defaultResponse; + } + + function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { + return interfaceId == type(IProviderEligibility).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMIssuanceAllocatorMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuanceAllocatorMock.sol new file mode 100644 index 000000000..ecbb00dbd --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuanceAllocatorMock.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { TargetIssuancePerBlock } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol"; +import { IIssuanceAllocationDistribution } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol"; +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; + +/// 0.8 port of packages/contracts/contracts/tests/MockIssuanceAllocator.sol. +contract RMIssuanceAllocatorMock is IERC165, IIssuanceAllocationDistribution { + mapping(address target => TargetIssuancePerBlock issuance) private _targetIssuance; + + function callBeforeIssuanceAllocationChange(address target) external { + IIssuanceTarget(target).beforeIssuanceAllocationChange(); + } + + function getTargetIssuancePerBlock(address target) external view override returns (TargetIssuancePerBlock memory) { + return _targetIssuance[target]; + } + + /// Always returns the current block number. + function distributeIssuance() external view override returns (uint256) { + return block.number; + } + + function setTargetAllocation( + address target, + uint256 allocatorIssuance, + uint256 selfIssuance, + bool callBefore + ) external { + if (callBefore) { + IIssuanceTarget(target).beforeIssuanceAllocationChange(); + } + _targetIssuance[target] = TargetIssuancePerBlock({ + allocatorIssuanceRate: allocatorIssuance, + allocatorIssuanceBlockAppliedTo: block.number, + selfIssuanceRate: selfIssuance, + selfIssuanceBlockAppliedTo: block.number + }); + } + + function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { + return + interfaceId == type(IIssuanceAllocationDistribution).interfaceId || + interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol new file mode 100644 index 000000000..ce6fe3963 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/mocks/RMIssuerMock.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol"; +import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; + +/// Rewards issuer (subgraph service) stand-in for RewardsManager. +contract RMIssuerMock is IRewardsIssuer { + struct Allocation { + bool isActive; + address indexer; + bytes32 subgraphDeploymentId; + uint256 tokens; + uint256 accRewardsPerAllocatedToken; + uint256 accRewardsPending; + } + + IRewardsManager public rm; + + mapping(address allocationId => Allocation allocation) private _allocations; + mapping(bytes32 subgraphDeploymentId => uint256 tokens) private _subgraphAllocatedTokens; + + function setRewardsManager(address rm_) external { + rm = IRewardsManager(rm_); + } + + /// Hook first, so the snapshot is taken before the allocated tokens change. + function allocate(address allocationId, address indexer, bytes32 sd, uint256 tokens) external { + uint256 snapshot = rm.onSubgraphAllocationUpdate(sd); + _allocations[allocationId] = Allocation(true, indexer, sd, tokens, snapshot, 0); + _subgraphAllocatedTokens[sd] += tokens; + } + + /// Takes rewards while the allocation is still active, then deactivates it. + /// @dev Legacy Staking called `onSubgraphAllocationUpdate` instead of `takeRewards` for a zero POI or an + /// allocation under one epoch old. The ported tests never hit that case. + function close(address allocationId) external returns (uint256 rewards) { + rewards = rm.takeRewards(allocationId); + Allocation storage allocation = _allocations[allocationId]; + allocation.isActive = false; + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] -= allocation.tokens; + } + + function setAllocation( + address allocationId, + bool isActive, + address indexer, + bytes32 sd, + uint256 tokens, + uint256 accRewardsPerAllocatedToken, + uint256 accRewardsPending + ) external { + _allocations[allocationId] = Allocation( + isActive, + indexer, + sd, + tokens, + accRewardsPerAllocatedToken, + accRewardsPending + ); + } + + function setSubgraphAllocatedTokens(bytes32 sd, uint256 tokens) external { + _subgraphAllocatedTokens[sd] = tokens; + } + + function reclaim(bytes32 reason, address allocationId) external returns (uint256) { + return rm.reclaimRewards(reason, allocationId); + } + + function getAllocationData( + address allocationId + ) + external + view + override + returns ( + bool isActive, + address indexer, + bytes32 subgraphDeploymentId, + uint256 tokens, + uint256 accRewardsPerAllocatedToken, + uint256 accRewardsPending + ) + { + Allocation storage allocation = _allocations[allocationId]; + return ( + allocation.isActive, + allocation.indexer, + allocation.subgraphDeploymentId, + allocation.tokens, + allocation.accRewardsPerAllocatedToken, + allocation.accRewardsPending + ); + } + + function getSubgraphAllocatedTokens(bytes32 sd) external view override returns (uint256) { + return _subgraphAllocatedTokens[sd]; + } +} diff --git a/packages/issuance/test/unit/rewards-manager/setup.t.sol b/packages/issuance/test/unit/rewards-manager/setup.t.sol new file mode 100644 index 000000000..9cdb39739 --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/setup.t.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { RewardsManagerSharedTest } from "./shared.t.sol"; + +contract RewardsManagerSetupTest is RewardsManagerSharedTest { + function test_Setup_RewardsManagerWired() public view { + assertEq(address(rm.subgraphService()), address(issuer)); + assertEq(rm.issuancePerBlock(), 200 ether); + } +} diff --git a/packages/issuance/test/unit/rewards-manager/shared.t.sol b/packages/issuance/test/unit/rewards-manager/shared.t.sol new file mode 100644 index 000000000..7c4c8416e --- /dev/null +++ b/packages/issuance/test/unit/rewards-manager/shared.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { ProtocolFixture } from "../shared/ProtocolFixture.t.sol"; +import { MockGraphToken } from "../mocks/MockGraphToken.sol"; +import { IRewardsManagerLike } from "./IRewardsManagerLike.sol"; +import { RMCurationMock } from "./mocks/RMCurationMock.sol"; +import { RMEligibilityOracleMock } from "./mocks/RMEligibilityOracleMock.sol"; +import { RMIssuanceAllocatorMock } from "./mocks/RMIssuanceAllocatorMock.sol"; +import { RMIssuerMock } from "./mocks/RMIssuerMock.sol"; + +/// @notice Shared setup for RewardsManager tests: the 0.7.6 RewardsManager artifact behind a GraphProxy, +/// wired to 0.8 mocks for GraphToken, Curation and the rewards issuer (subgraph service). +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; + + IRewardsManagerLike internal rm; + MockGraphToken internal token; + RMCurationMock internal curation; + RMIssuerMock internal issuer; + RMIssuanceAllocatorMock internal allocator; + RMEligibilityOracleMock internal oracle; + + address internal indexer = makeAddr("indexer"); + + function setUp() public virtual { + vm.warp(1_700_000_000); + vm.roll(1_000_000); + _deployProtocolBase(); + + address impl = _deployArtifact( + string.concat(PKG_ARTIFACTS, "rewards/RewardsManager.sol/RewardsManager.json"), + "" + ); + rm = IRewardsManagerLike( + _deployBehindProxy(impl, abi.encodeCall(IRewardsManagerLike.initialize, (address(controller)))) + ); + + token = new MockGraphToken(); + curation = new RMCurationMock(token); + issuer = new RMIssuerMock(); + allocator = new RMIssuanceAllocatorMock(); + oracle = new RMEligibilityOracleMock(true); + + _registerContract("GraphToken", address(token)); + _registerContract("Curation", address(curation)); + rm.syncAllContracts(); + + curation.setRewardsManager(address(rm)); + issuer.setRewardsManager(address(rm)); + + vm.startPrank(governor); + rm.setSubgraphService(address(issuer)); + rm.setIssuancePerBlock(ISSUANCE_PER_BLOCK); + vm.stopPrank(); + + vm.label(address(rm), "RewardsManager"); + vm.label(address(token), "GraphToken"); + vm.label(address(curation), "Curation"); + vm.label(address(issuer), "RewardsIssuer"); + vm.label(address(allocator), "IssuanceAllocator"); + vm.label(address(oracle), "EligibilityOracle"); + } + + // -- Helpers -- + + function _signal(bytes32 sd, uint256 tokens) internal { + curation.signal(sd, tokens); + } + + function _allocate(address allocationId, bytes32 sd, uint256 tokens) internal { + issuer.allocate(allocationId, indexer, sd, tokens); + } + + function _close(address allocationId) internal returns (uint256) { + return issuer.close(allocationId); + } + + function _expectedRewardsPerSignal(uint256 blocks, uint256 totalSignal) internal pure returns (uint256) { + return (blocks * ISSUANCE_PER_BLOCK * 1e18) / totalSignal; + } +} diff --git a/packages/issuance/test/unit/shared/ProtocolFixture.t.sol b/packages/issuance/test/unit/shared/ProtocolFixture.t.sol new file mode 100644 index 000000000..f38962c8e --- /dev/null +++ b/packages/issuance/test/unit/shared/ProtocolFixture.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; +import { IController } from "@graphprotocol/interfaces/contracts/contracts/governance/IController.sol"; +import { IGraphProxy } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxy.sol"; +import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; + +abstract contract ProtocolFixture is Test { + string internal constant CORE_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + string internal constant PKG_ARTIFACTS = "node_modules/@graphprotocol/contracts/artifacts/contracts/"; + + address internal governor; // makeAddr("governor"), set in _deployProtocolBase + IController internal controller; + IGraphProxyAdmin internal proxyAdmin; + + /// path is a full artifact path, e.g. string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"). + /// Deploys `.bytecode ++ args` with CREATE (honours an active vm.prank). + /// Reverts "artifact needs linking" if `.linkReferences` has any key (vm.parseJsonKeys), + /// and "artifact deploy failed" if CREATE returns 0. + function _deployArtifact(string memory path, bytes memory args) internal returns (address deployed) { + string memory json = vm.readFile(path); + require(vm.parseJsonKeys(json, ".linkReferences").length == 0, "artifact needs linking"); + bytes memory code = bytes.concat(vm.parseJsonBytes(json, ".bytecode"), args); + // solhint-disable-next-line no-inline-assembly + assembly { + deployed := create(0, add(code, 0x20), mload(code)) + } + require(deployed != address(0), "artifact deploy failed"); + } + + /// As governor: deploys Controller and GraphProxyAdmin from CORE_ARTIFACTS, then controller.setPaused(false). + /// Must be called with no active prank. + function _deployProtocolBase() internal { + governor = makeAddr("governor"); + vm.startPrank(governor); + controller = IController( + _deployArtifact(string.concat(CORE_ARTIFACTS, "governance/Controller.sol/Controller.json"), "") + ); + proxyAdmin = IGraphProxyAdmin( + _deployArtifact(string.concat(CORE_ARTIFACTS, "upgrades/GraphProxyAdmin.sol/GraphProxyAdmin.json"), "") + ); + controller.setPaused(false); + vm.stopPrank(); + } + + /// As governor: deploys GraphProxy(address(0), proxyAdmin) from CORE_ARTIFACTS, + /// proxyAdmin.upgrade(proxy, impl), proxyAdmin.acceptProxyAndCall(impl, proxy, initData). + /// Must be called with no active prank. + function _deployBehindProxy(address impl, bytes memory initData) internal returns (address proxy) { + vm.startPrank(governor); + proxy = _deployArtifact( + string.concat(CORE_ARTIFACTS, "upgrades/GraphProxy.sol/GraphProxy.json"), + abi.encode(address(0), address(proxyAdmin)) + ); + proxyAdmin.upgrade(IGraphProxy(proxy), impl); + proxyAdmin.acceptProxyAndCall(impl, IGraphProxy(proxy), initData); + vm.stopPrank(); + } + + /// As governor: controller.setContractProxy(keccak256(bytes(name)), addr). + function _registerContract(string memory name, address addr) internal { + vm.prank(governor); + controller.setContractProxy(keccak256(bytes(name)), addr); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 287d911a7..f9c30f314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -976,6 +976,9 @@ importers: specifier: ^1.8.0 version: 1.8.0 devDependencies: + '@graphprotocol/contracts': + specifier: workspace:^ + version: link:../contracts '@graphprotocol/interfaces': specifier: workspace:^ version: link:../interfaces diff --git a/scripts/contracts-migration/test-mapping/t5a.md b/scripts/contracts-migration/test-mapping/t5a.md new file mode 100644 index 000000000..bd01b600e --- /dev/null +++ b/scripts/contracts-migration/test-mapping/t5a.md @@ -0,0 +1,45 @@ +# T5a test mapping: RewardsManager interface and config + +New tests are in `packages/issuance`, deploying the RewardsManager Hardhat artifact behind a GraphProxy via `ProtocolFixture`. `RewardsManagerSharedTest` (`test/unit/rewards-manager/shared.t.sol`) wires it to 0.8 mocks: `MockGraphToken` as GraphToken, `RMCurationMock` as Curation and `RMIssuerMock` as the subgraph service, with `issuancePerBlock` at 200 GRT as in the old `before` hooks. `curation.mint` becomes `_signal`, `helpers.mine` becomes `vm.roll`, and the random deployment ID is a fixed `keccak256` value. `test/unit/rewards-manager/setup.t.sol::test_Setup_RewardsManagerWired` checks the fixture wiring and has no old test. + +| Old file | Old test (describe › it) | New test (file::function) | Note | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › Interface ID Stability › IERC165 should have stable interface ID | test/unit/rewards-manager/interface.t.sol::test_InterfaceIdStability_IERC165 | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › Interface ID Stability › IIssuanceTarget should have stable interface ID | test/unit/rewards-manager/interface.t.sol::test_InterfaceIdStability_IIssuanceTarget | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › Interface ID Stability › IRewardsManager should have stable interface ID | test/unit/rewards-manager/interface.t.sol::test_InterfaceIdStability_IRewardsManager | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should support IIssuanceTarget interface | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_IIssuanceTarget | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should support IRewardsManager interface | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_IRewardsManager | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should support IERC165 interface | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_IERC165 | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should support IProviderEligibilityManagement interface | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_IProviderEligibilityManagement | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › supportsInterface › should return false for unsupported interfaces | test/unit/rewards-manager/interface.t.sol::test_SupportsInterface_FalseForUnsupported | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › getter functions › should return zero address for issuance allocator when not set | test/unit/rewards-manager/interface.t.sol::test_GetterFunctions_IssuanceAllocatorZeroWhenNotSet | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › getter functions › should return zero address for rewards eligibility oracle when not set | test/unit/rewards-manager/interface.t.sol::test_GetterFunctions_EligibilityOracleZeroWhenNotSet | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › getter functions › should return zero address for reclaim address when not set | test/unit/rewards-manager/interface.t.sol::test_GetterFunctions_ReclaimAddressZeroWhenNotSet | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › calcRewards › should calculate rewards correctly | test/unit/rewards-manager/interface.t.sol::test_CalcRewards_Calculates | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › calcRewards › should return 0 when tokens is 0 | test/unit/rewards-manager/interface.t.sol::test_CalcRewards_ZeroWhenTokensZero | | +| packages/contracts-test/tests/unit/rewards/rewards-interface.test.ts | RewardsManager interfaces › calcRewards › should return 0 when accRewardsPerAllocatedToken is 0 | test/unit/rewards-manager/interface.t.sol::test_CalcRewards_ZeroWhenAccRewardsPerAllocatedTokenZero | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › initialize › should revert when called on implementation contract | test/unit/rewards-manager/config.t.sol::test_Initialize_RevertWhen_CalledOnImplementation | Called on the proxy, as the old test did | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should reject set issuance per block if unauthorized | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_RevertWhen_Unauthorized | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should set issuance rate to minimum allowed (0) | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_SetToMinimumZero | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should set issuance rate | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_SetIssuanceRate | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should update timestamp when transitioning from zero to non-zero issuance | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_UpdatesTimestampFromZeroToNonZero | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › issuance per block update › should not over-issue rewards after zero issuance period | test/unit/rewards-manager/config.t.sol::test_IssuancePerBlockUpdate_NoOverIssuanceAfterZeroPeriod | Exact instead of ±1 block: no stray block between the update and the zero-issuance set | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should reject set subgraph oracle if unauthorized | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_SetOracleUnauthorized | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should set subgraph oracle if governor | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetOracle | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should reject to deny subgraph if not the oracle | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_DenyNotOracle | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should deny subgraph | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_Deny | `blockNum + 1` becomes `block.number` | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should allow removing subgraph from denylist | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_Undeny | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should be a no-op when denying an already denied subgraph | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_DenyAlreadyDeniedIsNoOp | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should be a no-op when undenying an already not-denied subgraph | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_UndenyNotDeniedIsNoOp | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should reject setMinimumSubgraphSignal if unauthorized | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_RevertWhen_SetMinimumSubgraphSignalUnauthorized | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should allow setMinimumSubgraphSignal from subgraph availability oracle | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromOracle | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › subgraph availability service › should allow setMinimumSubgraphSignal from governor | test/unit/rewards-manager/config.t.sol::test_SubgraphAvailabilityService_SetMinimumSubgraphSignalFromGovernor | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should reject setRevertOnIneligible if unauthorized | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_RevertWhen_Unauthorized | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should set revertOnIneligible to true | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_SetTrue | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should set revertOnIneligible to false | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_SetFalse | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should be a no-op when setting same value (false to false) | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_SameValueFalseIsNoOp | | +| packages/contracts-test/tests/unit/rewards/rewards-config.test.ts | Rewards - Configuration › configuration › revertOnIneligible › should be a no-op when setting same value (true to true) | test/unit/rewards-manager/config.t.sol::test_RevertOnIneligible_SameValueTrueIsNoOp | | + +## Coverage + +MISSING getAccRewardsForSubgraph getAccRewardsPerAllocatedToken getAccRewardsPerSignal getAllocatedIssuancePerBlock getDefaultReclaimAddress getNewRewardsPerSignal getRawIssuancePerBlock getRewards setDefaultReclaimAddress setIssuanceAllocator setProviderEligibilityOracle setReclaimAddress: ported in T5b–T5f. `takeRewards`, `reclaimRewards` and `onSubgraphAllocationUpdate` match the grep only through the mocks (`RMIssuerMock`); no T5a test reaches `takeRewards` or `reclaimRewards` (ported in T5b–T5f), and `onSubgraphAllocationUpdate` is reached through `setDenied`. `initialize` runs in every `setUp` through the proxy init data. The checklist is closed out in T5f.