diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol index 767449026..458893308 100644 --- a/packages/contracts/contracts/rewards/RewardsManager.sol +++ b/packages/contracts/contracts/rewards/RewardsManager.sol @@ -7,15 +7,19 @@ pragma abicoder v2; // solhint-disable gas-increment-by-one, gas-indexed-events, gas-small-strings, gas-strict-inequalities import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { IERC165 } from "@openzeppelin/contracts/introspection/IERC165.sol"; import { GraphUpgradeable } from "../upgrades/GraphUpgradeable.sol"; import { Managed } from "../governance/Managed.sol"; import { MathUtils } from "../staking/libs/MathUtils.sol"; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; -import { RewardsManagerV5Storage } from "./RewardsManagerStorage.sol"; +import { RewardsManagerV6Storage } from "./RewardsManagerStorage.sol"; import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol"; import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; +import { IIssuanceAllocationDistribution } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol"; +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; +import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; /** * @title Rewards Manager Contract @@ -27,6 +31,10 @@ import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/r * total rewards for the Subgraph are split up for each Indexer based on much they have Staked on * that Subgraph. * + * @dev If an `issuanceAllocator` is set, it is used to determine the amount of GRT to be issued per block. + * Otherwise, the `issuancePerBlock` variable is used. In relation to the IssuanceAllocator, this contract + * is a self-minting target responsible for directly minting allocated GRT. + * * Note: * The contract provides getter functions to query the state of accrued rewards: * - getAccRewardsPerSignal @@ -37,7 +45,7 @@ import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/r * until the actual takeRewards function is called. * custom:security-contact Please email security+contracts@ thegraph.com (remove space) if you find any bugs. We might have an active bug bounty program. */ -contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsManager { +contract RewardsManager is RewardsManagerV6Storage, GraphUpgradeable, IERC165, IRewardsManager, IIssuanceTarget { using SafeMath for uint256; /// @dev Fixed point scaling factor used for decimals in reward calculations @@ -61,6 +69,14 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa */ event RewardsDenied(address indexed indexer, address indexed allocationID); + /** + * @notice Emitted when rewards are denied to an indexer due to eligibility + * @param indexer Address of the indexer being denied rewards + * @param allocationID Address of the allocation being denied rewards + * @param amount Amount of rewards that would have been assigned + */ + event RewardsDeniedDueToEligibility(address indexed indexer, address indexed allocationID, uint256 amount); + /** * @notice Emitted when a subgraph is denied for claiming rewards * @param subgraphDeploymentID Subgraph deployment ID being denied @@ -75,6 +91,23 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa */ event SubgraphServiceSet(address indexed oldSubgraphService, address indexed newSubgraphService); + /** + * @notice Emitted when the issuance allocator is set + * @param oldIssuanceAllocator Previous issuance allocator address + * @param newIssuanceAllocator New issuance allocator address + */ + event IssuanceAllocatorSet(address indexed oldIssuanceAllocator, address indexed newIssuanceAllocator); + + /** + * @notice Emitted when the rewards eligibility oracle contract is set + * @param oldRewardsEligibilityOracle Previous rewards eligibility oracle address + * @param newRewardsEligibilityOracle New rewards eligibility oracle address + */ + event RewardsEligibilityOracleSet( + address indexed oldRewardsEligibilityOracle, + address indexed newRewardsEligibilityOracle + ); + // -- Modifiers -- /** @@ -93,12 +126,27 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa Managed._initialize(_controller); } + /** + * @inheritdoc IERC165 + * @dev Implements ERC165 interface detection + * Returns true if this contract implements the interface defined by interfaceId. + * See: https://eips.ethereum.org/EIPS/eip-165 + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return + interfaceId == type(IERC165).interfaceId || + interfaceId == type(IIssuanceTarget).interfaceId || + interfaceId == type(IRewardsManager).interfaceId; + } + // -- Config -- /** * @inheritdoc IRewardsManager + * @dev When an IssuanceAllocator is set, the effective issuance will be determined by the allocator, + * but this local value can still be updated for cases when the allocator is later removed. * - * @dev The issuance is defined as a fixed amount of rewards per block in GRT. + * The issuance is defined as a fixed amount of rewards per block in GRT. * Whenever this function is called in layer 2, the updateL2MintAllowance function * _must_ be called on the L1GraphTokenGateway in L1, to ensure the bridge can mint the * right amount of tokens. @@ -152,6 +200,70 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa emit SubgraphServiceSet(oldSubgraphService, _subgraphService); } + /** + * @inheritdoc IIssuanceTarget + * @dev This function facilitates upgrades by providing a standard way for targets + * to change their allocator. Only the governor can call this function. + * Note that the IssuanceAllocator can be set to the zero address to disable use of an allocator, and + * use the local `issuancePerBlock` variable instead to control issuance. + */ + function setIssuanceAllocator(address newIssuanceAllocator) external override onlyGovernor { + if (address(issuanceAllocator) != newIssuanceAllocator) { + // Update rewards calculation before changing the issuance allocator + updateAccRewardsPerSignal(); + + // Check that the contract supports the IIssuanceAllocationDistribution interface + // Allow zero address to disable the allocator + if (newIssuanceAllocator != address(0)) { + require( + IERC165(newIssuanceAllocator).supportsInterface(type(IIssuanceAllocationDistribution).interfaceId), + "Contract does not support IIssuanceAllocationDistribution interface" + ); + } + + address oldIssuanceAllocator = address(issuanceAllocator); + issuanceAllocator = IIssuanceAllocationDistribution(newIssuanceAllocator); + emit IssuanceAllocatorSet(oldIssuanceAllocator, newIssuanceAllocator); + } + } + + /** + * @inheritdoc IIssuanceTarget + * @dev Ensures that all reward calculations are up-to-date with the current block + * before any allocation changes take effect. + * + * This function can be called by anyone to update the rewards calculation state. + * The IssuanceAllocator calls this function before changing a target's allocation to ensure + * all issuance is properly accounted for with the current issuance rate before applying an + * issuance allocation change. + */ + function beforeIssuanceAllocationChange() external override { + // Update rewards calculation with the current issuance rate + updateAccRewardsPerSignal(); + } + + /** + * @inheritdoc IRewardsManager + * @dev Note that the rewards eligibility oracle can be set to the zero address to disable use of an oracle, in + * which case no indexers will be denied rewards due to eligibility. + */ + function setRewardsEligibilityOracle(address newRewardsEligibilityOracle) external override onlyGovernor { + if (address(rewardsEligibilityOracle) != newRewardsEligibilityOracle) { + // Check that the contract supports the IRewardsEligibility interface + // Allow zero address to disable the oracle + if (newRewardsEligibilityOracle != address(0)) { + require( + IERC165(newRewardsEligibilityOracle).supportsInterface(type(IRewardsEligibility).interfaceId), + "Contract does not support IRewardsEligibility interface" + ); + } + + address oldRewardsEligibilityOracle = address(rewardsEligibilityOracle); + rewardsEligibilityOracle = IRewardsEligibility(newRewardsEligibilityOracle); + emit RewardsEligibilityOracleSet(oldRewardsEligibilityOracle, newRewardsEligibilityOracle); + } + } + // -- Denylist -- /** @@ -180,6 +292,17 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa // -- Getters -- + /** + * @inheritdoc IRewardsManager + * @dev Gets the effective issuance per block, taking into account the IssuanceAllocator if set + */ + function getRewardsIssuancePerBlock() public view override returns (uint256) { + if (address(issuanceAllocator) != address(0)) { + return issuanceAllocator.getTargetIssuancePerBlock(address(this)).selfIssuancePerBlock; + } + return issuancePerBlock; + } + /** * @inheritdoc IRewardsManager * @dev Linear formula: `x = r * t` @@ -197,8 +320,10 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa if (t == 0) { return 0; } - // ...or if issuance is zero - if (issuancePerBlock == 0) { + + uint256 rewardsIssuancePerBlock = getRewardsIssuancePerBlock(); + + if (rewardsIssuancePerBlock == 0) { return 0; } @@ -209,7 +334,7 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa return 0; } - uint256 x = issuancePerBlock.mul(t); + uint256 x = rewardsIssuancePerBlock.mul(t); // Get the new issuance per signalled token // We multiply the decimals to keep the precision as fixed-point number @@ -405,6 +530,13 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa rewards = accRewardsPending.add( _calcRewards(tokens, accRewardsPerAllocatedToken, updatedAccRewardsPerAllocatedToken) ); + + // Do not reward if indexer is not eligible based on rewards eligibility + if (address(rewardsEligibilityOracle) != address(0) && !rewardsEligibilityOracle.isEligible(indexer)) { + emit RewardsDeniedDueToEligibility(indexer, _allocationID, rewards); + return 0; + } + if (rewards > 0) { // Mint directly to rewards issuer for the reward amount // The rewards issuer contract will do bookkeeping of the reward and diff --git a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol index d78eb81ef..63897f431 100644 --- a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol +++ b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol @@ -7,6 +7,8 @@ pragma solidity ^0.7.6 || 0.8.27; +import { IIssuanceAllocationDistribution } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol"; +import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol"; import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; import { Managed } from "../governance/Managed.sol"; @@ -76,3 +78,16 @@ contract RewardsManagerV5Storage is RewardsManagerV4Storage { /// @notice Address of the subgraph service IRewardsIssuer public subgraphService; } + +/** + * @title RewardsManagerV6Storage + * @author Edge & Node + * @notice Storage layout for RewardsManager V6 + * Includes support for Rewards Eligibility Oracle and Issuance Allocator. + */ +contract RewardsManagerV6Storage is RewardsManagerV5Storage { + /// @notice Address of the rewards eligibility oracle contract + IRewardsEligibility public rewardsEligibilityOracle; + /// @notice Address of the issuance allocator + IIssuanceAllocationDistribution public issuanceAllocator; +} diff --git a/packages/contracts/contracts/tests/MockERC165.sol b/packages/contracts/contracts/tests/MockERC165.sol new file mode 100644 index 000000000..056493fd3 --- /dev/null +++ b/packages/contracts/contracts/tests/MockERC165.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity 0.7.6; + +import { IERC165 } from "@openzeppelin/contracts/introspection/IERC165.sol"; + +/** + * @title MockERC165 + * @author Edge & Node + * @dev Minimal implementation of IERC165 for testing + * @notice Used to test interface validation - supports only ERC165, not specific interfaces + */ +contract MockERC165 is IERC165 { + /** + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { + return interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/contracts/contracts/tests/MockIssuanceAllocator.sol b/packages/contracts/contracts/tests/MockIssuanceAllocator.sol new file mode 100644 index 000000000..ba1f8f2bd --- /dev/null +++ b/packages/contracts/contracts/tests/MockIssuanceAllocator.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +// solhint-disable gas-increment-by-one, gas-indexed-events, named-parameters-mapping, use-natspec + +pragma solidity 0.7.6; +pragma abicoder v2; + +import { IERC165 } from "@openzeppelin/contracts/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"; + +/** + * @title MockIssuanceAllocator + * @dev A simple mock contract for the IssuanceAllocator interfaces used by RewardsManager. + */ +contract MockIssuanceAllocator is IERC165, IIssuanceAllocationDistribution { + /// @dev Mapping to store TargetIssuancePerBlock for each target + mapping(address => TargetIssuancePerBlock) private _targetIssuance; + + /** + * @dev Call beforeIssuanceAllocationChange on a target + * @param target The target contract address + */ + function callBeforeIssuanceAllocationChange(address target) external { + IIssuanceTarget(target).beforeIssuanceAllocationChange(); + } + + /** + * @inheritdoc IIssuanceAllocationDistribution + */ + function getTargetIssuancePerBlock(address target) external view override returns (TargetIssuancePerBlock memory) { + return _targetIssuance[target]; + } + + /** + * @inheritdoc IIssuanceAllocationDistribution + * @dev Mock always returns current block number + */ + function distributeIssuance() external view override returns (uint256) { + return block.number; + } + + /** + * @dev Set target issuance directly for testing + * @param target The target contract address + * @param allocatorIssuance The allocator issuance per block + * @param selfIssuance The self issuance per block + * @param callBefore Whether to call beforeIssuanceAllocationChange on the target + */ + function setTargetAllocation( + address target, + uint256 allocatorIssuance, + uint256 selfIssuance, + bool callBefore + ) external { + if (callBefore) { + IIssuanceTarget(target).beforeIssuanceAllocationChange(); + } + _targetIssuance[target] = TargetIssuancePerBlock({ + allocatorIssuancePerBlock: allocatorIssuance, + allocatorIssuanceBlockAppliedTo: block.number, + selfIssuancePerBlock: selfIssuance, + selfIssuanceBlockAppliedTo: block.number + }); + } + + /** + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { + return + interfaceId == type(IIssuanceAllocationDistribution).interfaceId || + interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol b/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol new file mode 100644 index 000000000..6b13d4d76 --- /dev/null +++ b/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +// solhint-disable named-parameters-mapping + +pragma solidity 0.7.6; + +import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; +import { IERC165 } from "@openzeppelin/contracts/introspection/IERC165.sol"; + +/** + * @title MockRewardsEligibilityOracle + * @author Edge & Node + * @notice A simple mock contract for the RewardsEligibilityOracle interface + * @dev A simple mock contract for the RewardsEligibilityOracle interface + */ +contract MockRewardsEligibilityOracle is IRewardsEligibility, IERC165 { + /// @dev Mapping to store eligibility status for each indexer + mapping(address => bool) private eligible; + + /// @dev Mapping to track which indexers have been explicitly set + mapping(address => bool) private isSet; + + /// @dev Default response for indexers not explicitly set + bool private defaultResponse; + + /** + * @notice Constructor + * @param newDefaultResponse Default response for isEligible + */ + constructor(bool newDefaultResponse) { + defaultResponse = newDefaultResponse; + } + + /** + * @notice Set whether a specific indexer is eligible + * @param indexer The indexer address + * @param eligibility Whether the indexer is eligible + */ + function setIndexerEligible(address indexer, bool eligibility) external { + eligible[indexer] = eligibility; + isSet[indexer] = true; + } + + /** + * @notice Set the default response for indexers not explicitly set + * @param newDefaultResponse The default response + */ + function setDefaultResponse(bool newDefaultResponse) external { + defaultResponse = newDefaultResponse; + } + + /** + * @inheritdoc IRewardsEligibility + */ + function isEligible(address indexer) external view override returns (bool) { + // If the indexer has been explicitly set, return that value + if (isSet[indexer]) { + return eligible[indexer]; + } + + // Otherwise return the default response + return defaultResponse; + } + + /** + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { + return interfaceId == type(IRewardsEligibility).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/contracts/contracts/tests/MockSubgraphService.sol b/packages/contracts/contracts/tests/MockSubgraphService.sol new file mode 100644 index 000000000..703edd010 --- /dev/null +++ b/packages/contracts/contracts/tests/MockSubgraphService.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +// solhint-disable named-parameters-mapping + +pragma solidity 0.7.6; + +import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol"; + +/** + * @title MockSubgraphService + * @author Edge & Node + * @notice A mock contract for testing SubgraphService as a rewards issuer + * @dev Implements IRewardsIssuer interface to simulate SubgraphService behavior in tests + */ +contract MockSubgraphService is IRewardsIssuer { + /// @dev Struct to store allocation data + struct Allocation { + bool isActive; + address indexer; + bytes32 subgraphDeploymentId; + uint256 tokens; + uint256 accRewardsPerAllocatedToken; + uint256 accRewardsPending; + } + + /// @dev Mapping of allocation ID to allocation data + mapping(address => Allocation) private allocations; + + /// @dev Mapping of subgraph deployment ID to total allocated tokens + mapping(bytes32 => uint256) private subgraphAllocatedTokens; + + /** + * @notice Set allocation data for testing + * @param allocationId The allocation ID + * @param isActive Whether the allocation is active + * @param indexer The indexer address + * @param subgraphDeploymentId The subgraph deployment ID + * @param tokens Amount of allocated tokens + * @param accRewardsPerAllocatedToken Rewards snapshot + * @param accRewardsPending Accumulated rewards pending + */ + function setAllocation( + address allocationId, + bool isActive, + address indexer, + bytes32 subgraphDeploymentId, + uint256 tokens, + uint256 accRewardsPerAllocatedToken, + uint256 accRewardsPending + ) external { + allocations[allocationId] = Allocation({ + isActive: isActive, + indexer: indexer, + subgraphDeploymentId: subgraphDeploymentId, + tokens: tokens, + accRewardsPerAllocatedToken: accRewardsPerAllocatedToken, + accRewardsPending: accRewardsPending + }); + } + + /** + * @notice Set total allocated tokens for a subgraph + * @param subgraphDeploymentId The subgraph deployment ID + * @param tokens Total tokens allocated + */ + function setSubgraphAllocatedTokens(bytes32 subgraphDeploymentId, uint256 tokens) external { + subgraphAllocatedTokens[subgraphDeploymentId] = tokens; + } + + /** + * @inheritdoc IRewardsIssuer + */ + function getAllocationData( + address allocationId + ) + external + view + override + returns ( + bool isActive, + address indexer, + bytes32 subgraphDeploymentId, + uint256 tokens, + uint256 accRewardsPerAllocatedToken, + uint256 accRewardsPending + ) + { + Allocation memory allocation = allocations[allocationId]; + return ( + allocation.isActive, + allocation.indexer, + allocation.subgraphDeploymentId, + allocation.tokens, + allocation.accRewardsPerAllocatedToken, + allocation.accRewardsPending + ); + } + + /** + * @inheritdoc IRewardsIssuer + */ + function getSubgraphAllocatedTokens(bytes32 subgraphDeploymentId) external view override returns (uint256) { + return subgraphAllocatedTokens[subgraphDeploymentId]; + } +} diff --git a/packages/contracts/test/.solcover.js b/packages/contracts/test/.solcover.js index 7181b78fa..125581cd1 100644 --- a/packages/contracts/test/.solcover.js +++ b/packages/contracts/test/.solcover.js @@ -1,4 +1,4 @@ -const skipFiles = ['bancor', 'ens', 'erc1056', 'arbitrum', 'tests/arbitrum'] +const skipFiles = ['bancor', 'ens', 'erc1056', 'arbitrum', 'tests', '*Mock.sol'] module.exports = { providerOptions: { diff --git a/packages/contracts/test/tests/unit/rewards/rewards-calculations.test.ts b/packages/contracts/test/tests/unit/rewards/rewards-calculations.test.ts new file mode 100644 index 000000000..b100905b0 --- /dev/null +++ b/packages/contracts/test/tests/unit/rewards/rewards-calculations.test.ts @@ -0,0 +1,389 @@ +import { Curation } from '@graphprotocol/contracts' +import { EpochManager } from '@graphprotocol/contracts' +import { GraphToken } from '@graphprotocol/contracts' +import { IStaking } from '@graphprotocol/contracts' +import { RewardsManager } from '@graphprotocol/contracts' +import { + deriveChannelKey, + formatGRT, + GraphNetworkContracts, + helpers, + randomHexBytes, + toBN, + toGRT, +} from '@graphprotocol/sdk' +import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { BigNumber as BN } from 'bignumber.js' +import { expect } from 'chai' +import { BigNumber, constants } from 'ethers' +import hre from 'hardhat' + +import { NetworkFixture } from '../lib/fixtures' + +const { HashZero, WeiPerEther } = constants + +const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] + +describe('Rewards - Calculations', () => { + const graph = hre.graph() + let governor: SignerWithAddress + let curator1: SignerWithAddress + let curator2: SignerWithAddress + let indexer1: SignerWithAddress + let indexer2: SignerWithAddress + let assetHolder: SignerWithAddress + + let fixture: NetworkFixture + + let contracts: GraphNetworkContracts + let grt: GraphToken + let curation: Curation + let epochManager: EpochManager + let staking: IStaking + let rewardsManager: RewardsManager + + // Derive some channel keys for each indexer used to sign attestations + const channelKey1 = deriveChannelKey() + + const subgraphDeploymentID1 = randomHexBytes() + const subgraphDeploymentID2 = randomHexBytes() + + const allocationID1 = channelKey1.address + + const metadata = HashZero + + const ISSUANCE_RATE_PERIODS = 4 // blocks required to issue 800 GRT rewards + const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block + + // Core formula that gets accumulated rewards per signal for a period of time + const getRewardsPerSignal = (k: BN, t: BN, s: BN): string => { + if (s.eq(0)) { + return '0' + } + return k.times(t).div(s).toPrecision(18).toString() + } + + // Tracks the accumulated rewards as totalSignalled or supply changes across snapshots + class RewardsTracker { + totalSignalled = BigNumber.from(0) + lastUpdatedBlock = 0 + accumulated = BigNumber.from(0) + + static async create() { + const tracker = new RewardsTracker() + await tracker.snapshot() + return tracker + } + + async snapshot() { + this.accumulated = this.accumulated.add(await this.accrued()) + this.totalSignalled = await grt.balanceOf(curation.address) + this.lastUpdatedBlock = await helpers.latestBlock() + return this + } + + async elapsedBlocks() { + const currentBlock = await helpers.latestBlock() + return currentBlock - this.lastUpdatedBlock + } + + async accrued() { + const nBlocks = await this.elapsedBlocks() + return this.accruedByElapsed(nBlocks) + } + + accruedByElapsed(nBlocks: BigNumber | number) { + const n = getRewardsPerSignal( + new BN(ISSUANCE_PER_BLOCK.toString()), + new BN(nBlocks.toString()), + new BN(this.totalSignalled.toString()), + ) + return toGRT(n) + } + } + + // Test accumulated rewards per signal + const shouldGetNewRewardsPerSignal = async (nBlocks = ISSUANCE_RATE_PERIODS) => { + // -- t0 -- + const tracker = await RewardsTracker.create() + + // Jump + await helpers.mine(nBlocks) + + // -- t1 -- + + // Contract calculation + const contractAccrued = await rewardsManager.getNewRewardsPerSignal() + // Local calculation + const expectedAccrued = await tracker.accrued() + + // Check + expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) + return expectedAccrued + } + + before(async function () { + const testAccounts = await graph.getTestAccounts() + ;[indexer1, indexer2, curator1, curator2, assetHolder] = testAccounts + ;({ governor } = await graph.getNamedAccounts()) + + fixture = new NetworkFixture(graph.provider) + contracts = await fixture.load(governor) + grt = contracts.GraphToken as GraphToken + curation = contracts.Curation as Curation + epochManager = contracts.EpochManager + staking = contracts.Staking as IStaking + rewardsManager = contracts.RewardsManager + + // 200 GRT per block + await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) + + // Distribute test funds + for (const wallet of [indexer1, indexer2, curator1, curator2, assetHolder]) { + await grt.connect(governor).mint(wallet.address, toGRT('1000000')) + await grt.connect(wallet).approve(staking.address, toGRT('1000000')) + await grt.connect(wallet).approve(curation.address, toGRT('1000000')) + } + }) + + beforeEach(async function () { + await fixture.setUp() + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + context('issuing rewards', function () { + beforeEach(async function () { + // 5% minute rate (4 blocks) + await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) + }) + + describe('getNewRewardsPerSignal', function () { + it('accrued per signal when no tokens signalled', async function () { + // When there is no tokens signalled no rewards are accrued + await helpers.mineEpoch(epochManager) + const accrued = await rewardsManager.getNewRewardsPerSignal() + expect(accrued).eq(0) + }) + + it('accrued per signal when tokens signalled', async function () { + // Update total signalled + const tokensToSignal = toGRT('1000') + await curation.connect(curator1).mint(subgraphDeploymentID1, tokensToSignal, 0) + + // Check + await shouldGetNewRewardsPerSignal() + }) + + it('accrued per signal when signalled tokens w/ many subgraphs', async function () { + // Update total signalled + await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) + + // Check + await shouldGetNewRewardsPerSignal() + + // Update total signalled + await curation.connect(curator2).mint(subgraphDeploymentID2, toGRT('250'), 0) + + // Check + await shouldGetNewRewardsPerSignal() + }) + }) + + describe('updateAccRewardsPerSignal', function () { + it('update the accumulated rewards per signal state', async function () { + // Update total signalled + await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) + // Snapshot + const tracker = await RewardsTracker.create() + + // Update + await rewardsManager.connect(governor).updateAccRewardsPerSignal() + const contractAccrued = await rewardsManager.accRewardsPerSignal() + + // Check + const expectedAccrued = await tracker.accrued() + expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) + }) + + it('update the accumulated rewards per signal state after many blocks', async function () { + // Update total signalled + await curation.connect(curator1).mint(subgraphDeploymentID1, toGRT('1000'), 0) + // Snapshot + const tracker = await RewardsTracker.create() + + // Jump + await helpers.mine(ISSUANCE_RATE_PERIODS) + + // Update + await rewardsManager.connect(governor).updateAccRewardsPerSignal() + const contractAccrued = await rewardsManager.accRewardsPerSignal() + + // Check + const expectedAccrued = await tracker.accrued() + expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) + }) + }) + + describe('getAccRewardsForSubgraph', function () { + it('accrued for each subgraph', async function () { + // Curator1 - Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + const tracker1 = await RewardsTracker.create() + + // Curator2 - Update total signalled + const signalled2 = toGRT('500') + await curation.connect(curator2).mint(subgraphDeploymentID2, signalled2, 0) + + // Snapshot + const tracker2 = await RewardsTracker.create() + await tracker1.snapshot() + + // Jump + await helpers.mine(ISSUANCE_RATE_PERIODS) + + // Snapshot + await tracker1.snapshot() + await tracker2.snapshot() + + // Calculate rewards + const rewardsPerSignal1 = tracker1.accumulated + const rewardsPerSignal2 = tracker2.accumulated + const expectedRewardsSG1 = rewardsPerSignal1.mul(signalled1).div(WeiPerEther) + const expectedRewardsSG2 = rewardsPerSignal2.mul(signalled2).div(WeiPerEther) + + // Get rewards from contract + const contractRewardsSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) + const contractRewardsSG2 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID2) + + // Check + expect(toRound(expectedRewardsSG1)).eq(toRound(contractRewardsSG1)) + expect(toRound(expectedRewardsSG2)).eq(toRound(contractRewardsSG2)) + }) + + it('should return zero rewards when subgraph signal is below minimum threshold', async function () { + // Set a high minimum signal threshold + const highMinimumSignal = toGRT('2000') + await rewardsManager.connect(governor).setMinimumSubgraphSignal(highMinimumSignal) + + // Signal less than the minimum threshold + const lowSignal = toGRT('1000') + await curation.connect(curator1).mint(subgraphDeploymentID1, lowSignal, 0) + + // Jump some blocks to potentially accrue rewards + await helpers.mine(ISSUANCE_RATE_PERIODS) + + // Check that no rewards are accrued due to minimum signal threshold + const contractRewards = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) + expect(contractRewards).eq(0) + }) + }) + + describe('onSubgraphSignalUpdate', function () { + it('update the accumulated rewards for subgraph state', async function () { + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + // Snapshot + const tracker1 = await RewardsTracker.create() + + // Jump + await helpers.mine(ISSUANCE_RATE_PERIODS) + + // Update + await rewardsManager.connect(governor).onSubgraphSignalUpdate(subgraphDeploymentID1) + + // Check + const contractRewardsSG1 = (await rewardsManager.subgraphs(subgraphDeploymentID1)).accRewardsForSubgraph + const rewardsPerSignal1 = await tracker1.accrued() + const expectedRewardsSG1 = rewardsPerSignal1.mul(signalled1).div(WeiPerEther) + expect(toRound(expectedRewardsSG1)).eq(toRound(contractRewardsSG1)) + + const contractAccrued = await rewardsManager.accRewardsPerSignal() + const expectedAccrued = await tracker1.accrued() + expect(toRound(expectedAccrued)).eq(toRound(contractAccrued)) + + const contractBlockUpdated = await rewardsManager.accRewardsPerSignalLastBlockUpdated() + const expectedBlockUpdated = await helpers.latestBlock() + expect(expectedBlockUpdated).eq(contractBlockUpdated) + }) + }) + + describe('getAccRewardsPerAllocatedToken', function () { + it('accrued per allocated token', async function () { + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1).stake(tokensToAllocate) + await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + + // Jump + await helpers.mine(ISSUANCE_RATE_PERIODS) + + // Check + const sg1 = await rewardsManager.subgraphs(subgraphDeploymentID1) + // We trust this function because it was individually tested in previous test + const accRewardsForSubgraphSG1 = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) + const accruedRewardsSG1 = accRewardsForSubgraphSG1.sub(sg1.accRewardsForSubgraphSnapshot) + const expectedRewardsAT1 = accruedRewardsSG1.mul(WeiPerEther).div(tokensToAllocate) + const contractRewardsAT1 = (await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1))[0] + expect(expectedRewardsAT1).eq(contractRewardsAT1) + }) + }) + + describe('onSubgraphAllocationUpdate', function () { + it('update the accumulated rewards for allocated tokens state', async function () { + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1).stake(tokensToAllocate) + await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + + // Jump + await helpers.mine(ISSUANCE_RATE_PERIODS) + + // Prepare expected results + const expectedSubgraphRewards = toGRT('1400') // 7 blocks since signaling to when we do getAccRewardsForSubgraph + const expectedRewardsAT = toGRT('0.08') // allocated during 5 blocks: 1000 GRT, divided by 12500 allocated tokens + + // Update + await rewardsManager.connect(governor).onSubgraphAllocationUpdate(subgraphDeploymentID1) + + // Check on demand results saved + const subgraph = await rewardsManager.subgraphs(subgraphDeploymentID1) + const contractSubgraphRewards = await rewardsManager.getAccRewardsForSubgraph(subgraphDeploymentID1) + const contractRewardsAT = subgraph.accRewardsPerAllocatedToken + + expect(toRound(expectedSubgraphRewards)).eq(toRound(contractSubgraphRewards)) + expect(toRound(expectedRewardsAT.mul(1000))).eq(toRound(contractRewardsAT.mul(1000))) + }) + }) + }) +}) diff --git a/packages/contracts/test/tests/unit/rewards/rewards-config.test.ts b/packages/contracts/test/tests/unit/rewards/rewards-config.test.ts new file mode 100644 index 000000000..8edcbb113 --- /dev/null +++ b/packages/contracts/test/tests/unit/rewards/rewards-config.test.ts @@ -0,0 +1,158 @@ +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 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()) + }) + }) + + 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 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) + }) + }) + }) +}) diff --git a/packages/contracts/test/tests/unit/rewards/rewards-distribution.test.ts b/packages/contracts/test/tests/unit/rewards/rewards-distribution.test.ts new file mode 100644 index 000000000..cb3f46107 --- /dev/null +++ b/packages/contracts/test/tests/unit/rewards/rewards-distribution.test.ts @@ -0,0 +1,708 @@ +import { Curation } from '@graphprotocol/contracts' +import { EpochManager } from '@graphprotocol/contracts' +import { GraphToken } from '@graphprotocol/contracts' +import { IStaking } from '@graphprotocol/contracts' +import { RewardsManager } from '@graphprotocol/contracts' +import { + deriveChannelKey, + formatGRT, + GraphNetworkContracts, + helpers, + randomHexBytes, + toBN, + toGRT, +} from '@graphprotocol/sdk' +import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { expect } from 'chai' +import { BigNumber, constants } from 'ethers' +import hre from 'hardhat' + +import { NetworkFixture } from '../lib/fixtures' + +const MAX_PPM = 1000000 + +const { HashZero, WeiPerEther } = constants + +const toRound = (n: BigNumber) => formatGRT(n.add(toGRT('0.5'))).split('.')[0] + +describe('Rewards - Distribution', () => { + const graph = hre.graph() + let delegator: SignerWithAddress + let governor: SignerWithAddress + let curator1: SignerWithAddress + let curator2: SignerWithAddress + let indexer1: SignerWithAddress + let assetHolder: SignerWithAddress + + let fixture: NetworkFixture + + let contracts: GraphNetworkContracts + let grt: GraphToken + let curation: Curation + let epochManager: EpochManager + let staking: IStaking + let rewardsManager: RewardsManager + + // Derive some channel keys for each indexer used to sign attestations + const channelKey1 = deriveChannelKey() + const channelKey2 = deriveChannelKey() + const channelKeyNull = deriveChannelKey() + + const subgraphDeploymentID1 = randomHexBytes() + const subgraphDeploymentID2 = randomHexBytes() + + const allocationID1 = channelKey1.address + const allocationID2 = channelKey2.address + const allocationIDNull = channelKeyNull.address + + const metadata = HashZero + + const ISSUANCE_RATE_PERIODS = 4 // blocks required to issue 800 GRT rewards + const ISSUANCE_PER_BLOCK = toBN('200000000000000000000') // 200 GRT every block + + before(async function () { + ;[delegator, curator1, curator2, indexer1, assetHolder] = await graph.getTestAccounts() + ;({ governor } = await graph.getNamedAccounts()) + + fixture = new NetworkFixture(graph.provider) + contracts = await fixture.load(governor) + grt = contracts.GraphToken as GraphToken + curation = contracts.Curation as Curation + epochManager = contracts.EpochManager + staking = contracts.Staking as IStaking + rewardsManager = contracts.RewardsManager + + // 200 GRT per block + await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) + + // Distribute test funds + for (const wallet of [indexer1, curator1, curator2, assetHolder]) { + await grt.connect(governor).mint(wallet.address, toGRT('1000000')) + await grt.connect(wallet).approve(staking.address, toGRT('1000000')) + await grt.connect(wallet).approve(curation.address, toGRT('1000000')) + } + }) + + beforeEach(async function () { + await fixture.setUp() + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + context('issuing rewards', function () { + beforeEach(async function () { + // 5% minute rate (4 blocks) + await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) + }) + + describe('getRewards', function () { + it('calculate rewards using the subgraph signalled + allocated tokens', async function () { + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1).stake(tokensToAllocate) + await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + + // Jump + await helpers.mine(ISSUANCE_RATE_PERIODS) + + // Rewards + const contractRewards = await rewardsManager.getRewards(staking.address, allocationID1) + + // We trust using this function in the test because we tested it + // standalone in a previous test + const contractRewardsAT1 = (await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1))[0] + + const expectedRewards = contractRewardsAT1.mul(tokensToAllocate).div(WeiPerEther) + expect(expectedRewards).eq(contractRewards) + }) + it('rewards should be zero if the allocation is closed', async function () { + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1).stake(tokensToAllocate) + await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + + // Jump + await helpers.mine(ISSUANCE_RATE_PERIODS) + await helpers.mineEpoch(epochManager) + + // Close allocation + await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + + // Rewards + const contractRewards = await rewardsManager.getRewards(staking.address, allocationID1) + expect(contractRewards).eq(BigNumber.from(0)) + }) + it('rewards should be zero if the allocation does not exist', async function () { + // Rewards + const contractRewards = await rewardsManager.getRewards(staking.address, allocationIDNull) + expect(contractRewards).eq(BigNumber.from(0)) + }) + }) + + describe('takeRewards', function () { + interface DelegationParameters { + indexingRewardCut: BigNumber + queryFeeCut: BigNumber + cooldownBlocks: number + } + + async function setupIndexerAllocation() { + // Setup + await epochManager.connect(governor).setEpochLength(10) + + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1).stake(tokensToAllocate) + await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + } + + async function setupIndexerAllocationSignalingAfter() { + // Setup + await epochManager.connect(governor).setEpochLength(10) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1).stake(tokensToAllocate) + await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + } + + async function setupIndexerAllocationWithDelegation( + tokensToDelegate: BigNumber, + delegationParams: DelegationParameters, + ) { + const tokensToAllocate = toGRT('12500') + + // Setup + await epochManager.connect(governor).setEpochLength(10) + + // Transfer some funds from the curator, I don't want to mint new tokens + await grt.connect(curator1).transfer(delegator.address, tokensToDelegate) + await grt.connect(delegator).approve(staking.address, tokensToDelegate) + + // Stake and set delegation parameters + await staking.connect(indexer1).stake(tokensToAllocate) + await staking + .connect(indexer1) + .setDelegationParameters(delegationParams.indexingRewardCut, delegationParams.queryFeeCut, 0) + + // Delegate + await staking.connect(delegator).delegate(indexer1.address, tokensToDelegate) + + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + } + + it('should distribute rewards on closed allocation and stake', async function () { + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + // Setup + await setupIndexerAllocation() + + // Jump + await helpers.mineEpoch(epochManager) + + // Before state + const beforeTokenSupply = await grt.totalSupply() + const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) + const beforeIndexer1Balance = await grt.balanceOf(indexer1.address) + const beforeStakingBalance = await grt.balanceOf(staking.address) + + // All the rewards in this subgraph go to this allocation. + // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens + // The first snapshot is after allocating, that is 2 blocks after the signal is minted. + // The final snapshot is when we close the allocation, that happens 9 blocks after signal is minted. + // So the rewards will be ((issuancePerBlock * 7) / allocatedTokens) * allocatedTokens + const expectedIndexingRewards = toGRT('1400') + + // Close allocation. At this point rewards should be collected for that indexer + const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + const receipt = await tx.wait() + const event = rewardsManager.interface.parseLog(receipt.logs[1]).args + expect(event.indexer).eq(indexer1.address) + expect(event.allocationID).eq(allocationID1) + expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) + + // After state + const afterTokenSupply = await grt.totalSupply() + const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) + const afterIndexer1Balance = await grt.balanceOf(indexer1.address) + const afterStakingBalance = await grt.balanceOf(staking.address) + + // Check that rewards are put into indexer stake + const expectedIndexerStake = beforeIndexer1Stake.add(expectedIndexingRewards) + const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) + // Check stake should have increased with the rewards staked + expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) + // Check indexer balance remains the same + expect(afterIndexer1Balance).eq(beforeIndexer1Balance) + // Check indexing rewards are kept in the staking contract + expect(toRound(afterStakingBalance)).eq(toRound(beforeStakingBalance.add(expectedIndexingRewards))) + // Check that tokens have been minted + expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) + }) + + it('does not revert with an underflow if the minimum signal changes', async function () { + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + // Setup + await setupIndexerAllocation() + + await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(14000)) + + // Jump + await helpers.mineEpoch(epochManager) + + // Close allocation. At this point rewards should be collected for that indexer + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'HorizonRewardsAssigned') + .withArgs(indexer1.address, allocationID1, toBN(0)) + }) + + it('does not revert with an underflow if the minimum signal changes, and signal came after allocation', async function () { + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + // Setup + await setupIndexerAllocationSignalingAfter() + + await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(14000)) + + // Jump + await helpers.mineEpoch(epochManager) + + // Close allocation. At this point rewards should be collected for that indexer + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'HorizonRewardsAssigned') + .withArgs(indexer1.address, allocationID1, toBN(0)) + }) + + it('does not revert if signal was already under minimum', async function () { + await rewardsManager.connect(governor).setMinimumSubgraphSignal(toGRT(2000)) + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + // Setup + await setupIndexerAllocation() + + // Jump + await helpers.mineEpoch(epochManager) + // Close allocation. At this point rewards should be collected for that indexer + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + + await expect(tx) + .emit(rewardsManager, 'HorizonRewardsAssigned') + .withArgs(indexer1.address, allocationID1, toBN(0)) + }) + + it('should distribute rewards on closed allocation and send to destination', async function () { + const destinationAddress = randomHexBytes(20) + await staking.connect(indexer1).setRewardsDestination(destinationAddress) + + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + // Setup + await setupIndexerAllocation() + + // Jump + await helpers.mineEpoch(epochManager) + + // Before state + const beforeTokenSupply = await grt.totalSupply() + const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) + const beforeDestinationBalance = await grt.balanceOf(destinationAddress) + const beforeStakingBalance = await grt.balanceOf(staking.address) + + // All the rewards in this subgraph go to this allocation. + // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens + // The first snapshot is after allocating, that is 2 blocks after the signal is minted. + // The final snapshot is when we close the allocation, that happens 9 blocks after signal is minted. + // So the rewards will be ((issuancePerBlock * 7) / allocatedTokens) * allocatedTokens + const expectedIndexingRewards = toGRT('1400') + + // Close allocation. At this point rewards should be collected for that indexer + const tx = await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + const receipt = await tx.wait() + const event = rewardsManager.interface.parseLog(receipt.logs[1]).args + expect(event.indexer).eq(indexer1.address) + expect(event.allocationID).eq(allocationID1) + expect(toRound(event.amount)).eq(toRound(expectedIndexingRewards)) + + // After state + const afterTokenSupply = await grt.totalSupply() + const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) + const afterDestinationBalance = await grt.balanceOf(destinationAddress) + const afterStakingBalance = await grt.balanceOf(staking.address) + + // Check that rewards are properly assigned + const expectedIndexerStake = beforeIndexer1Stake + const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) + // Check stake should not have changed + expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) + // Check indexing rewards are received by the rewards destination + expect(toRound(afterDestinationBalance)).eq(toRound(beforeDestinationBalance.add(expectedIndexingRewards))) + // Check indexing rewards were not sent to the staking contract + expect(afterStakingBalance).eq(beforeStakingBalance) + // Check that tokens have been minted + expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) + }) + + it('should distribute rewards on closed allocation w/delegators', async function () { + // Setup + const delegationParams = { + indexingRewardCut: toBN('823000'), // 82.30% + queryFeeCut: toBN('80000'), // 8% + cooldownBlocks: 0, + } + const tokensToDelegate = toGRT('2000') + + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + // Setup the allocation and delegators + await setupIndexerAllocationWithDelegation(tokensToDelegate, delegationParams) + + // Jump + await helpers.mineEpoch(epochManager) + + // Before state + const beforeTokenSupply = await grt.totalSupply() + const beforeDelegationPool = await staking.delegationPools(indexer1.address) + const beforeIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) + + // Close allocation. At this point rewards should be collected for that indexer + await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + + // After state + const afterTokenSupply = await grt.totalSupply() + const afterDelegationPool = await staking.delegationPools(indexer1.address) + const afterIndexer1Stake = await staking.getIndexerStakedTokens(indexer1.address) + + // Check that rewards are put into indexer stake (only indexer cut) + // Check that rewards are put into delegators pool accordingly + + // All the rewards in this subgraph go to this allocation. + // Rewards per token will be (issuancePerBlock * nBlocks) / allocatedTokens + // The first snapshot is after allocating, that is 1 block after the signal is minted. + // The final snapshot is when we close the allocation, that happens 4 blocks after signal is minted. + // So the rewards will be ((issuancePerBlock * 3) / allocatedTokens) * allocatedTokens + const expectedIndexingRewards = toGRT('600') + // Calculate delegators cut + const indexerRewards = delegationParams.indexingRewardCut.mul(expectedIndexingRewards).div(toBN(MAX_PPM)) + // Calculate indexer cut + const delegatorsRewards = expectedIndexingRewards.sub(indexerRewards) + // Check + const expectedIndexerStake = beforeIndexer1Stake.add(indexerRewards) + const expectedDelegatorsPoolTokens = beforeDelegationPool.tokens.add(delegatorsRewards) + const expectedTokenSupply = beforeTokenSupply.add(expectedIndexingRewards) + expect(toRound(afterIndexer1Stake)).eq(toRound(expectedIndexerStake)) + expect(toRound(afterDelegationPool.tokens)).eq(toRound(expectedDelegatorsPoolTokens)) + // Check that tokens have been minted + expect(toRound(afterTokenSupply)).eq(toRound(expectedTokenSupply)) + }) + + it('should deny rewards if subgraph on denylist', async function () { + // Setup + await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) + await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) + await setupIndexerAllocation() + + // Jump + await helpers.mineEpoch(epochManager) + + // Close allocation. At this point rewards should be collected for that indexer + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) + }) + + it('should handle zero rewards scenario correctly', async function () { + // Setup allocation with zero issuance to create zero rewards scenario + await rewardsManager.connect(governor).setIssuancePerBlock(0) + + // Align with the epoch boundary + await helpers.mineEpoch(epochManager) + + // Setup allocation + await setupIndexerAllocation() + + // Jump to next epoch + await helpers.mineEpoch(epochManager) + + // Before state + const beforeTokenSupply = await grt.totalSupply() + const beforeStakingBalance = await grt.balanceOf(staking.address) + + // Close allocation. At this point rewards should be zero + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) + + // After state - should be unchanged since no rewards were minted + const afterTokenSupply = await grt.totalSupply() + const afterStakingBalance = await grt.balanceOf(staking.address) + + // Check that no tokens were minted (rewards were 0) + expect(afterTokenSupply).eq(beforeTokenSupply) + expect(afterStakingBalance).eq(beforeStakingBalance) + }) + }) + }) + + describe('edge scenarios', function () { + it('close allocation on a subgraph that no longer have signal', async function () { + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Allocate + const tokensToAllocate = toGRT('12500') + await staking.connect(indexer1).stake(tokensToAllocate) + await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + + // Jump + await helpers.mineEpoch(epochManager) + + // Remove all signal from the subgraph + const curatorShares = await curation.getCuratorSignal(curator1.address, subgraphDeploymentID1) + await curation.connect(curator1).burn(subgraphDeploymentID1, curatorShares, 0) + + // Close allocation. At this point rewards should be collected for that indexer + await staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + }) + }) + + describe('multiple allocations', function () { + it('two allocations in the same block with a GRT burn in the middle should succeed', async function () { + // If rewards are not monotonically increasing, this can trigger + // a subtraction overflow error as seen in mainnet tx: + // 0xb6bf7bbc446720a7409c482d714aebac239dd62e671c3c94f7e93dd3a61835ab + await helpers.mineEpoch(epochManager) + + // Setup + await epochManager.connect(governor).setEpochLength(10) + + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Stake + const tokensToStake = toGRT('12500') + await staking.connect(indexer1).stake(tokensToStake) + + // Allocate simultaneously, burning in the middle + const tokensToAlloc = toGRT('5000') + await helpers.setAutoMine(false) + const tx1 = await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAlloc, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + const tx2 = await grt.connect(indexer1).burn(toGRT(1)) + const tx3 = await staking + .connect(indexer1) + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAlloc, + allocationID2, + metadata, + await channelKey2.generateProof(indexer1.address), + ) + + await helpers.mine() + await helpers.setAutoMine(true) + + await expect(tx1).emit(staking, 'AllocationCreated') + await expect(tx2).emit(grt, 'Transfer') + await expect(tx3).emit(staking, 'AllocationCreated') + }) + it('two simultanous-similar allocations should get same amount of rewards', async function () { + await helpers.mineEpoch(epochManager) + + // Setup + await epochManager.connect(governor).setEpochLength(10) + + // Update total signalled + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Stake + const tokensToStake = toGRT('12500') + await staking.connect(indexer1).stake(tokensToStake) + + // Allocate simultaneously + const tokensToAlloc = toGRT('5000') + const tx1 = await staking.populateTransaction.allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAlloc, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + const tx2 = await staking.populateTransaction.allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAlloc, + allocationID2, + metadata, + await channelKey2.generateProof(indexer1.address), + ) + await staking.connect(indexer1).multicall([tx1.data, tx2.data]) + + // Jump + await helpers.mineEpoch(epochManager) + + // Close allocations simultaneously + const tx3 = await staking.populateTransaction.closeAllocation(allocationID1, randomHexBytes()) + const tx4 = await staking.populateTransaction.closeAllocation(allocationID2, randomHexBytes()) + const tx5 = await staking.connect(indexer1).multicall([tx3.data, tx4.data]) + + // Both allocations should receive the same amount of rewards + const receipt = await tx5.wait() + const event1 = rewardsManager.interface.parseLog(receipt.logs[1]).args + const event2 = rewardsManager.interface.parseLog(receipt.logs[5]).args + expect(event1.amount).eq(event2.amount) + }) + }) + + describe('rewards progression when collecting query fees', function () { + it('collect query fees with two subgraphs and one allocation', async function () { + async function getRewardsAccrual(subgraphs) { + const [sg1, sg2] = await Promise.all(subgraphs.map((sg) => rewardsManager.getAccRewardsForSubgraph(sg))) + return { + sg1, + sg2, + all: sg1.add(sg2), + } + } + + // set curation percentage + await staking.connect(governor).setCurationPercentage(100000) + + // allow the asset holder + const tokensToCollect = toGRT('10000') + + // signal in two subgraphs in the same block + const subgraphs = [subgraphDeploymentID1, subgraphDeploymentID2] + for (const sub of subgraphs) { + await curation.connect(curator1).mint(sub, toGRT('1500'), 0) + } + + // snapshot block before any accrual (we substract 1 because accrual starts after the first mint happens) + const b1 = await epochManager.blockNum().then((x) => x.toNumber() - 1) + + // allocate + const tokensToAllocate = toGRT('12500') + await staking + .connect(indexer1) + .multicall([ + await staking.populateTransaction.stake(tokensToAllocate).then((tx) => tx.data), + await staking.populateTransaction + .allocateFrom( + indexer1.address, + subgraphDeploymentID1, + tokensToAllocate, + allocationID1, + metadata, + await channelKey1.generateProof(indexer1.address), + ) + .then((tx) => tx.data), + ]) + + // move time fwd + await helpers.mineEpoch(epochManager) + + // collect funds into staking for that sub + await staking.connect(assetHolder).collect(tokensToCollect, allocationID1) + + // check rewards diff + await rewardsManager.getRewards(staking.address, allocationID1).then(formatGRT) + + await helpers.mine() + const accrual = await getRewardsAccrual(subgraphs) + const b2 = await epochManager.blockNum().then((x) => x.toNumber()) + + // round comparison because there is a small precision error due to dividing and accrual per signal + expect(toRound(accrual.all)).eq(toRound(ISSUANCE_PER_BLOCK.mul(b2 - b1))) + }) + }) +}) 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 new file mode 100644 index 000000000..108eb3391 --- /dev/null +++ b/packages/contracts/test/tests/unit/rewards/rewards-eligibility-oracle.test.ts @@ -0,0 +1,496 @@ +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 { constants } from 'ethers' +import hre from 'hardhat' + +import { NetworkFixture } from '../lib/fixtures' + +const { HashZero } = constants + +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')) + } + }) + + beforeEach(async function () { + await fixture.setUp() + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + describe('rewards eligibility oracle', function () { + it('should reject setRewardsEligibilityOracle 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).setRewardsEligibilityOracle(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).setRewardsEligibilityOracle(mockOracle.address) + await expect(tx) + .emit(rewardsManager, 'RewardsEligibilityOracleSet') + .withArgs(constants.AddressZero, mockOracle.address) + + expect(await rewardsManager.rewardsEligibilityOracle()).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).setRewardsEligibilityOracle(mockOracle.address) + + // Then set to zero address to disable + const tx = rewardsManager.connect(governor).setRewardsEligibilityOracle(constants.AddressZero) + await expect(tx) + .emit(rewardsManager, 'RewardsEligibilityOracleSet') + .withArgs(mockOracle.address, constants.AddressZero) + + expect(await rewardsManager.rewardsEligibilityOracle()).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).setRewardsEligibilityOracle(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 IRewardsEligibility interface', async function () { + // Deploy a contract that supports ERC165 but not IRewardsEligibility + const MockERC165Factory = await hre.ethers.getContractFactory('contracts/tests/MockERC165.sol:MockERC165') + const mockERC165 = await MockERC165Factory.deploy() + await mockERC165.deployed() + + const tx = rewardsManager.connect(governor).setRewardsEligibilityOracle(mockERC165.address) + await expect(tx).revertedWith('Contract does not support IRewardsEligibility 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).setRewardsEligibilityOracle(mockOracle.address) + + // Setting the same oracle again should not emit an event + const tx = rewardsManager.connect(governor).setRewardsEligibilityOracle(mockOracle.address) + await expect(tx).to.not.emit(rewardsManager, 'RewardsEligibilityOracleSet') + }) + }) + + 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).setRewardsEligibilityOracle(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 = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'RewardsDeniedDueToEligibility') + .withArgs(indexer1.address, allocationID1, expectedIndexingRewards) + }) + + 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).setRewardsEligibilityOracle(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 = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'HorizonRewardsAssigned') + .withArgs(indexer1.address, allocationID1, expectedIndexingRewards) + }) + }) + + describe('rewards eligibility oracle and denylist interaction', function () { + 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).setRewardsEligibilityOracle(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 - denylist should be checked first + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + + // Verify: Denylist wins (checked first in RewardsManager.takeRewards line 522) + // Should emit RewardsDenied (not RewardsDeniedDueToEligibility) + await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) + + // Verify: REO event is NOT emitted + await expect(tx).to.not.emit(rewardsManager, 'RewardsDeniedDueToEligibility') + }) + + 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).setRewardsEligibilityOracle(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 = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'RewardsDeniedDueToEligibility') + .withArgs(indexer1.address, allocationID1, expectedIndexingRewards) + }) + + 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).setRewardsEligibilityOracle(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 = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'RewardsDeniedDueToEligibility') + .withArgs(indexer1.address, allocationID1, expectedIndexingRewards) + }) + + 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).setRewardsEligibilityOracle(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 = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'HorizonRewardsAssigned') + .withArgs(indexer1.address, allocationID1, expectedIndexingRewards) + }) + + 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 (can still allocate to denied subgraph) + await setupIndexerAllocation() + + // Jump to next epoch + await helpers.mineEpoch(epochManager) + + // Remove from denylist before closing + await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, false) + + const expectedIndexingRewards = toGRT('1600') + + // Close allocation - should now get rewards + const tx = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'HorizonRewardsAssigned') + .withArgs(indexer1.address, allocationID1, expectedIndexingRewards) + }) + + it('should allow rewards when REO is zero address (disabled)', async function () { + // Ensure REO is not set (zero address = disabled) + expect(await rewardsManager.rewardsEligibilityOracle()).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 = staking.connect(indexer1).closeAllocation(allocationID1, randomHexBytes()) + await expect(tx) + .emit(rewardsManager, 'HorizonRewardsAssigned') + .withArgs(indexer1.address, allocationID1, expectedIndexingRewards) + }) + + it('should verify event structure differences between denial mechanisms', async function () { + // Test 1: Denylist denial - event WITHOUT amount + await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) + await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) + + await helpers.mineEpoch(epochManager) + await setupIndexerAllocation() + await helpers.mineEpoch(epochManager) + + 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).setRewardsEligibilityOracle(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-interface.test.ts b/packages/contracts/test/tests/unit/rewards/rewards-interface.test.ts new file mode 100644 index 000000000..3a9b7c23b --- /dev/null +++ b/packages/contracts/test/tests/unit/rewards/rewards-interface.test.ts @@ -0,0 +1,116 @@ +import { RewardsManager } from '@graphprotocol/contracts' +import { IERC165__factory, IIssuanceTarget__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 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('0xaee4dc43') + }) + + it('IRewardsManager should have stable interface ID', () => { + expect(IRewardsManager__factory.interfaceId).to.equal('0xa31d8306') + }) + }) + + 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 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('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/contracts/test/tests/unit/rewards/rewards-issuance-allocator.test.ts b/packages/contracts/test/tests/unit/rewards/rewards-issuance-allocator.test.ts new file mode 100644 index 000000000..c74679ad9 --- /dev/null +++ b/packages/contracts/test/tests/unit/rewards/rewards-issuance-allocator.test.ts @@ -0,0 +1,416 @@ +import { Curation } from '@graphprotocol/contracts' +import { GraphToken } from '@graphprotocol/contracts' +import { RewardsManager } from '@graphprotocol/contracts' +import { GraphNetworkContracts, helpers, randomHexBytes, 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('Rewards - Issuance Allocator', () => { + 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 rewardsManager: RewardsManager + + const subgraphDeploymentID1 = randomHexBytes() + + const ISSUANCE_PER_BLOCK = toGRT('200') // 200 GRT every block + + 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 + rewardsManager = contracts.RewardsManager as RewardsManager + + // 200 GRT per block + await rewardsManager.connect(governor).setIssuancePerBlock(ISSUANCE_PER_BLOCK) + + // Distribute test funds + for (const wallet of [curator1]) { + await grt.connect(governor).mint(wallet.address, toGRT('1000000')) + await grt.connect(wallet).approve(curation.address, toGRT('1000000')) + } + }) + + beforeEach(async function () { + await fixture.setUp() + // Reset issuance allocator to ensure we use direct issuancePerBlock + await rewardsManager.connect(governor).setIssuanceAllocator(constants.AddressZero) + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + describe('setIssuanceAllocator', function () { + describe('ERC-165 validation', function () { + it('should successfully set an issuance allocator that supports the interface', async function () { + // Deploy a mock issuance allocator that supports ERC-165 and IIssuanceAllocationDistribution + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockAllocator.deployed() + + // Should succeed because MockIssuanceAllocator supports IIssuanceAllocationDistribution + await expect(rewardsManager.connect(governor).setIssuanceAllocator(mockAllocator.address)) + .to.emit(rewardsManager, 'IssuanceAllocatorSet') + .withArgs(constants.AddressZero, mockAllocator.address) + + // Verify the allocator was set + expect(await rewardsManager.issuanceAllocator()).to.equal(mockAllocator.address) + }) + + it('should revert when setting to EOA address (no contract code)', async function () { + const eoaAddress = indexer1.address + + // Should revert because EOAs don't have contract code to call supportsInterface on + await expect(rewardsManager.connect(governor).setIssuanceAllocator(eoaAddress)).to.be.reverted + }) + + it('should revert when setting to contract that does not support IIssuanceAllocationDistribution', async function () { + // Deploy a contract that supports ERC-165 but not IIssuanceAllocationDistribution + const MockERC165Factory = await hre.ethers.getContractFactory('contracts/tests/MockERC165.sol:MockERC165') + const mockERC165 = await MockERC165Factory.deploy() + await mockERC165.deployed() + + // Should revert because the contract doesn't support IIssuanceAllocationDistribution + await expect(rewardsManager.connect(governor).setIssuanceAllocator(mockERC165.address)).to.be.revertedWith( + 'Contract does not support IIssuanceAllocationDistribution interface', + ) + }) + + it('should validate interface before updating rewards calculation', async function () { + // This test ensures that ERC165 validation happens before updateAccRewardsPerSignal + // Deploy a contract that supports ERC-165 but not IIssuanceAllocationDistribution + const MockERC165Factory = await hre.ethers.getContractFactory('contracts/tests/MockERC165.sol:MockERC165') + const mockERC165 = await MockERC165Factory.deploy() + await mockERC165.deployed() + + // Should revert with interface error, not with any rewards calculation error + await expect(rewardsManager.connect(governor).setIssuanceAllocator(mockERC165.address)).to.be.revertedWith( + 'Contract does not support IIssuanceAllocationDistribution interface', + ) + }) + }) + + describe('access control', function () { + it('should revert when called by non-governor', async function () { + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockAllocator.deployed() + + // Should revert because indexer1 is not the governor + await expect(rewardsManager.connect(indexer1).setIssuanceAllocator(mockAllocator.address)).to.be.revertedWith( + 'Only Controller governor', + ) + }) + }) + + describe('state management', function () { + it('should allow setting issuance allocator to zero address (disable)', async function () { + // First set a valid allocator + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockAllocator.deployed() + + await rewardsManager.connect(governor).setIssuanceAllocator(mockAllocator.address) + expect(await rewardsManager.issuanceAllocator()).to.equal(mockAllocator.address) + + // Now disable by setting to zero address + await expect(rewardsManager.connect(governor).setIssuanceAllocator(constants.AddressZero)) + .to.emit(rewardsManager, 'IssuanceAllocatorSet') + .withArgs(mockAllocator.address, constants.AddressZero) + + expect(await rewardsManager.issuanceAllocator()).to.equal(constants.AddressZero) + + // Should now use local issuancePerBlock again + expect(await rewardsManager.getRewardsIssuancePerBlock()).eq(ISSUANCE_PER_BLOCK) + }) + + it('should emit IssuanceAllocatorSet event when setting allocator', async function () { + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockIssuanceAllocator.deployed() + + const tx = rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) + await expect(tx) + .emit(rewardsManager, 'IssuanceAllocatorSet') + .withArgs(constants.AddressZero, mockIssuanceAllocator.address) + }) + + it('should not emit event when setting to same allocator address', async function () { + // Deploy a mock issuance allocator + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockAllocator.deployed() + + // Set the allocator first time + await rewardsManager.connect(governor).setIssuanceAllocator(mockAllocator.address) + + // Setting to same address should not emit event + const tx = await rewardsManager.connect(governor).setIssuanceAllocator(mockAllocator.address) + const receipt = await tx.wait() + + // Filter for IssuanceAllocatorSet events + const events = receipt.events?.filter((e) => e.event === 'IssuanceAllocatorSet') || [] + expect(events.length).to.equal(0) + }) + + it('should update rewards before changing issuance allocator', async function () { + // This test verifies that updateAccRewardsPerSignal is called when setting allocator + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockIssuanceAllocator.deployed() + + // Setting the allocator should trigger updateAccRewardsPerSignal + // We can't easily test this directly, but we can verify the allocator was set + await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) + expect(await rewardsManager.issuanceAllocator()).eq(mockIssuanceAllocator.address) + }) + }) + }) + + describe('getRewardsIssuancePerBlock', function () { + it('should return issuancePerBlock when no issuanceAllocator is set', async function () { + const expectedIssuance = toGRT('100.025') + await rewardsManager.connect(governor).setIssuancePerBlock(expectedIssuance) + + // Ensure no issuanceAllocator is set + expect(await rewardsManager.issuanceAllocator()).eq(constants.AddressZero) + + // Should return the direct issuancePerBlock value + expect(await rewardsManager.getRewardsIssuancePerBlock()).eq(expectedIssuance) + }) + + it('should return value from issuanceAllocator when set', async function () { + // Create a mock IssuanceAllocator + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockIssuanceAllocator.deployed() + + // Set the mock allocator on RewardsManager + await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) + + // Verify the allocator was set + expect(await rewardsManager.issuanceAllocator()).eq(mockIssuanceAllocator.address) + + // Set RewardsManager as a self-minting target with 25 GRT per block + const expectedIssuance = toGRT('25') + await mockIssuanceAllocator['setTargetAllocation(address,uint256,uint256,bool)']( + rewardsManager.address, + 0, // allocator issuance + expectedIssuance, // self issuance + true, + ) + + // Should return the value from the allocator, not the local issuancePerBlock + expect(await rewardsManager.getRewardsIssuancePerBlock()).eq(expectedIssuance) + }) + + it('should return 0 when issuanceAllocator is set but target not registered as self-minter', async function () { + // Create a mock IssuanceAllocator + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockIssuanceAllocator.deployed() + + // Set the mock allocator on RewardsManager + await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) + + // Set RewardsManager as an allocator-minting target (only allocator issuance) + await mockIssuanceAllocator['setTargetAllocation(address,uint256,uint256,bool)']( + rewardsManager.address, + toGRT('25'), // allocator issuance + 0, // self issuance + false, + ) + + // Should return 0 because it's not a self-minting target + expect(await rewardsManager.getRewardsIssuancePerBlock()).eq(0) + }) + }) + + describe('setIssuancePerBlock', function () { + it('should allow setIssuancePerBlock when issuanceAllocator is set', async function () { + // Create and set a mock IssuanceAllocator + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockIssuanceAllocator.deployed() + await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) + + // Should allow setting issuancePerBlock even when allocator is set + const newIssuancePerBlock = toGRT('100') + await rewardsManager.connect(governor).setIssuancePerBlock(newIssuancePerBlock) + + // The local issuancePerBlock should be updated + expect(await rewardsManager.issuancePerBlock()).eq(newIssuancePerBlock) + + // But the effective issuance should still come from the allocator + // (assuming the allocator returns a different value) + expect(await rewardsManager.getRewardsIssuancePerBlock()).not.eq(newIssuancePerBlock) + }) + }) + + describe('beforeIssuanceAllocationChange', function () { + it('should handle beforeIssuanceAllocationChange correctly', async function () { + // Create and set a mock IssuanceAllocator + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + const mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockIssuanceAllocator.deployed() + await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) + + // Anyone should be able to call this function + await rewardsManager.connect(governor).beforeIssuanceAllocationChange() + + // Should also succeed when called by the allocator + await mockIssuanceAllocator.callBeforeIssuanceAllocationChange(rewardsManager.address) + }) + }) + + describe('issuance allocator integration', function () { + let mockIssuanceAllocator: any + + beforeEach(async function () { + // Create and setup mock allocator + const MockIssuanceAllocatorFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockIssuanceAllocator.sol:MockIssuanceAllocator', + ) + mockIssuanceAllocator = await MockIssuanceAllocatorFactory.deploy() + await mockIssuanceAllocator.deployed() + }) + + it('should accumulate rewards using allocator rate over time', async function () { + // Setup: Create signal + const totalSignal = toGRT('1000') + await curation.connect(curator1).mint(subgraphDeploymentID1, totalSignal, 0) + + // Set allocator with specific rate (50 GRT per block, different from local 200 GRT) + const allocatorRate = toGRT('50') + await mockIssuanceAllocator.setTargetAllocation(rewardsManager.address, 0, allocatorRate, false) + await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) + + // Snapshot state after setting allocator + const rewardsAfterSet = await rewardsManager.getAccRewardsPerSignal() + + // Mine blocks to accrue rewards at allocator rate + const blocksToMine = 10 + await helpers.mine(blocksToMine) + + // Get accumulated rewards + const rewardsAfterMining = await rewardsManager.getAccRewardsPerSignal() + const actualAccrued = rewardsAfterMining.sub(rewardsAfterSet) + + // Calculate expected rewards: (rate × blocks) / totalSignal + // Expected = (50 GRT × 10 blocks) / 1000 GRT signal = 0.5 GRT per signal + const expectedAccrued = allocatorRate.mul(blocksToMine).mul(toGRT('1')).div(totalSignal) + + // Verify rewards accumulated at allocator rate (not local rate of 200 GRT/block) + expect(actualAccrued).to.eq(expectedAccrued) + + // Verify NOT using local rate (would be 4x higher: 200 vs 50) + const wrongExpected = ISSUANCE_PER_BLOCK.mul(blocksToMine).mul(toGRT('1')).div(totalSignal) + expect(actualAccrued).to.not.eq(wrongExpected) + }) + + it('should maintain reward consistency when switching between rates', async function () { + // Setup: Create signal + const totalSignal = toGRT('2000') + await curation.connect(curator1).mint(subgraphDeploymentID1, totalSignal, 0) + + // Snapshot initial state + const block0 = await helpers.latestBlock() + const rewards0 = await rewardsManager.getAccRewardsPerSignal() + + // Phase 1: Accrue at local rate (200 GRT/block) + await helpers.mine(5) + const block1 = await helpers.latestBlock() + const rewards1 = await rewardsManager.getAccRewardsPerSignal() + + // Calculate phase 1 accrual + const blocksPhase1 = block1 - block0 + const phase1Accrued = rewards1.sub(rewards0) + const expectedPhase1 = ISSUANCE_PER_BLOCK.mul(blocksPhase1).mul(toGRT('1')).div(totalSignal) + expect(phase1Accrued).to.eq(expectedPhase1) + + // Phase 2: Switch to allocator with different rate (100 GRT/block) + const allocatorRate = toGRT('100') + await mockIssuanceAllocator.setTargetAllocation(rewardsManager.address, 0, allocatorRate, false) + await rewardsManager.connect(governor).setIssuanceAllocator(mockIssuanceAllocator.address) + + const block2 = await helpers.latestBlock() + const rewards2 = await rewardsManager.getAccRewardsPerSignal() + + await helpers.mine(8) + const block3 = await helpers.latestBlock() + const rewards3 = await rewardsManager.getAccRewardsPerSignal() + + // Calculate phase 2 accrual (includes the setIssuanceAllocator block at local rate) + const blocksPhase2 = block3 - block2 + const phase2Accrued = rewards3.sub(rewards2) + const expectedPhase2 = allocatorRate.mul(blocksPhase2).mul(toGRT('1')).div(totalSignal) + expect(phase2Accrued).to.eq(expectedPhase2) + + // Phase 3: Switch back to local rate (200 GRT/block) + await rewardsManager.connect(governor).setIssuanceAllocator(constants.AddressZero) + + const block4 = await helpers.latestBlock() + const rewards4 = await rewardsManager.getAccRewardsPerSignal() + + await helpers.mine(4) + const block5 = await helpers.latestBlock() + const rewards5 = await rewardsManager.getAccRewardsPerSignal() + + // Calculate phase 3 accrual + const blocksPhase3 = block5 - block4 + const phase3Accrued = rewards5.sub(rewards4) + const expectedPhase3 = ISSUANCE_PER_BLOCK.mul(blocksPhase3).mul(toGRT('1')).div(totalSignal) + expect(phase3Accrued).to.eq(expectedPhase3) + + // Verify total consistency: all rewards from start to end must equal sum of all phases + // including the transition blocks (setIssuanceAllocator calls mine blocks too) + const transitionPhase1to2 = rewards2.sub(rewards1) // Block mined by setIssuanceAllocator + const transitionPhase2to3 = rewards4.sub(rewards3) // Block mined by removing allocator + const totalExpected = phase1Accrued + .add(transitionPhase1to2) + .add(phase2Accrued) + .add(transitionPhase2to3) + .add(phase3Accrued) + const totalActual = rewards5.sub(rewards0) + expect(totalActual).to.eq(totalExpected) + }) + }) +}) diff --git a/packages/contracts/test/tests/unit/rewards/rewards-subgraph-service.test.ts b/packages/contracts/test/tests/unit/rewards/rewards-subgraph-service.test.ts new file mode 100644 index 000000000..f75785ecd --- /dev/null +++ b/packages/contracts/test/tests/unit/rewards/rewards-subgraph-service.test.ts @@ -0,0 +1,468 @@ +import { Curation } from '@graphprotocol/contracts' +import { GraphToken } from '@graphprotocol/contracts' +import { RewardsManager } from '@graphprotocol/contracts' +import { GraphNetworkContracts, helpers, randomAddress, randomHexBytes, 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 { network } from 'hardhat' + +import { NetworkFixture } from '../lib/fixtures' + +describe('Rewards - SubgraphService', () => { + 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 rewardsManager: RewardsManager + + const subgraphDeploymentID1 = randomHexBytes() + const allocationID1 = randomAddress() + + const ISSUANCE_PER_BLOCK = toGRT('200') // 200 GRT every block + + 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 + 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(curation.address, toGRT('1000000')) + } + }) + + beforeEach(async function () { + await fixture.setUp() + }) + + afterEach(async function () { + await fixture.tearDown() + }) + + describe('subgraph service configuration', function () { + it('should reject setSubgraphService if unauthorized', async function () { + const newService = randomAddress() + const tx = rewardsManager.connect(indexer1).setSubgraphService(newService) + await expect(tx).revertedWith('Only Controller governor') + }) + + it('should set subgraph service if governor', async function () { + const newService = randomAddress() + const tx = rewardsManager.connect(governor).setSubgraphService(newService) + + await expect(tx).emit(rewardsManager, 'SubgraphServiceSet').withArgs(constants.AddressZero, newService) + + expect(await rewardsManager.subgraphService()).eq(newService) + }) + + it('should allow setting to zero address', async function () { + const service = randomAddress() + await rewardsManager.connect(governor).setSubgraphService(service) + + const tx = rewardsManager.connect(governor).setSubgraphService(constants.AddressZero) + await expect(tx).emit(rewardsManager, 'SubgraphServiceSet').withArgs(service, constants.AddressZero) + + expect(await rewardsManager.subgraphService()).eq(constants.AddressZero) + }) + + it('should emit event when setting different address', async function () { + const service1 = randomAddress() + const service2 = randomAddress() + + await rewardsManager.connect(governor).setSubgraphService(service1) + + // Setting a different address should emit event + const tx = await rewardsManager.connect(governor).setSubgraphService(service2) + await expect(tx).emit(rewardsManager, 'SubgraphServiceSet').withArgs(service1, service2) + }) + }) + + describe('subgraph service as rewards issuer', function () { + let mockSubgraphService: any + + beforeEach(async function () { + // Deploy mock SubgraphService + const MockSubgraphServiceFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockSubgraphService.sol:MockSubgraphService', + ) + mockSubgraphService = await MockSubgraphServiceFactory.deploy() + await mockSubgraphService.deployed() + + // Set it on RewardsManager + await rewardsManager.connect(governor).setSubgraphService(mockSubgraphService.address) + }) + + describe('getRewards from subgraph service', function () { + it('should calculate rewards for subgraph service allocations', async function () { + // Setup: Create signal for rewards calculation + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Setup allocation data in mock + const tokensAllocated = toGRT('12500') + await mockSubgraphService.setAllocation( + allocationID1, + true, // isActive + indexer1.address, + subgraphDeploymentID1, + tokensAllocated, + 0, // accRewardsPerAllocatedToken + 0, // accRewardsPending + ) + + await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) + + // Mine some blocks to accrue rewards + await helpers.mine(10) + + // Get rewards - should return calculated amount + const rewards = await rewardsManager.getRewards(mockSubgraphService.address, allocationID1) + expect(rewards).to.be.gt(0) + }) + + it('should return zero for inactive allocation', async function () { + // Setup allocation as inactive + await mockSubgraphService.setAllocation( + allocationID1, + false, // isActive = false + indexer1.address, + subgraphDeploymentID1, + toGRT('12500'), + 0, + 0, + ) + + const rewards = await rewardsManager.getRewards(mockSubgraphService.address, allocationID1) + expect(rewards).to.equal(0) + }) + + it('should reject getRewards from non-rewards-issuer contract', async function () { + const randomContract = randomAddress() + const tx = rewardsManager.getRewards(randomContract, allocationID1) + await expect(tx).revertedWith('Not a rewards issuer') + }) + }) + + describe('takeRewards from subgraph service', function () { + it('should take rewards through subgraph service', async function () { + // Setup: Create signal for rewards calculation + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Setup allocation data in mock + const tokensAllocated = toGRT('12500') + await mockSubgraphService.setAllocation( + allocationID1, + true, // isActive + indexer1.address, + subgraphDeploymentID1, + tokensAllocated, + 0, // accRewardsPerAllocatedToken + 0, // accRewardsPending + ) + + await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) + + // Mine some blocks to accrue rewards + await helpers.mine(10) + + // Before state + const beforeSubgraphServiceBalance = await grt.balanceOf(mockSubgraphService.address) + const beforeTotalSupply = await grt.totalSupply() + + // Impersonate the mock subgraph service contract + await network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [mockSubgraphService.address], + }) + await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) + + const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) + + // Take rewards (called by subgraph service) + const tx = await rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) + const receipt = await tx.wait() + + // Stop impersonating + await network.provider.request({ + method: 'hardhat_stopImpersonatingAccount', + params: [mockSubgraphService.address], + }) + + // Parse the event + const event = receipt.logs + .map((log: any) => { + try { + return rewardsManager.interface.parseLog(log) + } catch { + return null + } + }) + .find((e: any) => e?.name === 'HorizonRewardsAssigned') + + expect(event).to.not.be.undefined + expect(event?.args.indexer).to.equal(indexer1.address) + expect(event?.args.allocationID).to.equal(allocationID1) + expect(event?.args.amount).to.be.gt(0) + + // After state - verify tokens minted to subgraph service + const afterSubgraphServiceBalance = await grt.balanceOf(mockSubgraphService.address) + const afterTotalSupply = await grt.totalSupply() + + expect(afterSubgraphServiceBalance).to.be.gt(beforeSubgraphServiceBalance) + expect(afterTotalSupply).to.be.gt(beforeTotalSupply) + }) + + it('should return zero rewards for inactive allocation', async function () { + // Setup allocation as inactive + await mockSubgraphService.setAllocation( + allocationID1, + false, // isActive = false + indexer1.address, + subgraphDeploymentID1, + toGRT('12500'), + 0, + 0, + ) + + // Impersonate the mock subgraph service contract + await network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [mockSubgraphService.address], + }) + await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) + + const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) + + // Take rewards should return 0 and emit event with 0 amount + const tx = rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) + await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) + + // Stop impersonating + await network.provider.request({ + method: 'hardhat_stopImpersonatingAccount', + params: [mockSubgraphService.address], + }) + }) + + it('should reject takeRewards from non-rewards-issuer contract', async function () { + const tx = rewardsManager.connect(indexer1).takeRewards(allocationID1) + await expect(tx).revertedWith('Caller must be a rewards issuer') + }) + + it('should handle zero rewards scenario', async function () { + // Setup with zero issuance + await rewardsManager.connect(governor).setIssuancePerBlock(0) + + // Setup allocation + await mockSubgraphService.setAllocation( + allocationID1, + true, + indexer1.address, + subgraphDeploymentID1, + toGRT('12500'), + 0, + 0, + ) + + await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, toGRT('12500')) + + // Mine blocks + await helpers.mine(10) + + // Impersonate the mock subgraph service contract + await network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [mockSubgraphService.address], + }) + await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) + + const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) + + // Take rewards should succeed with 0 amount + const tx = rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) + await expect(tx).emit(rewardsManager, 'HorizonRewardsAssigned').withArgs(indexer1.address, allocationID1, 0) + + // Stop impersonating + await network.provider.request({ + method: 'hardhat_stopImpersonatingAccount', + params: [mockSubgraphService.address], + }) + }) + }) + + describe('mixed allocations from staking and subgraph service', function () { + it('should account for both staking and subgraph service allocations in getAccRewardsPerAllocatedToken', async function () { + // This test verifies that getSubgraphAllocatedTokens is called for both issuers + // and rewards are distributed proportionally + + // Setup: Create signal + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Setup subgraph service allocation + const tokensFromSubgraphService = toGRT('5000') + await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensFromSubgraphService) + + // Note: We can't easily create a real staking allocation in this test + // but the contract code at lines 381-388 loops through both issuers + // and sums their allocated tokens. This test verifies the subgraph service path. + + // Mine some blocks + await helpers.mine(5) + + // Get accumulated rewards per allocated token + const [accRewardsPerAllocatedToken, accRewardsForSubgraph] = + await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1) + + // Should have calculated rewards based on subgraph service allocations + expect(accRewardsPerAllocatedToken).to.be.gt(0) + expect(accRewardsForSubgraph).to.be.gt(0) + }) + + it('should handle case where only subgraph service has allocations', async function () { + // Setup: Create signal + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Only subgraph service has allocations + const tokensFromSubgraphService = toGRT('10000') + await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensFromSubgraphService) + + // Mine blocks + await helpers.mine(5) + + // Get rewards + const [accRewardsPerAllocatedToken] = await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1) + + expect(accRewardsPerAllocatedToken).to.be.gt(0) + }) + + it('should return zero when neither issuer has allocations', async function () { + // Setup: Create signal but no allocations + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // No allocations from either issuer + await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, 0) + + // Mine blocks + await helpers.mine(5) + + // Get rewards - should return 0 when no allocations + const [accRewardsPerAllocatedToken, accRewardsForSubgraph] = + await rewardsManager.getAccRewardsPerAllocatedToken(subgraphDeploymentID1) + + expect(accRewardsPerAllocatedToken).to.equal(0) + expect(accRewardsForSubgraph).to.be.gt(0) // Subgraph still accrues, but no per-token rewards + }) + }) + + describe('subgraph service with denylist and eligibility', function () { + it('should deny rewards from subgraph service when subgraph is on denylist', async function () { + // Setup denylist + await rewardsManager.connect(governor).setSubgraphAvailabilityOracle(governor.address) + await rewardsManager.connect(governor).setDenied(subgraphDeploymentID1, true) + + // Setup allocation + await mockSubgraphService.setAllocation( + allocationID1, + true, + indexer1.address, + subgraphDeploymentID1, + toGRT('12500'), + 0, + 0, + ) + + // Impersonate the mock subgraph service contract + await network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [mockSubgraphService.address], + }) + await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) + + const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) + + // Take rewards should be denied + const tx = rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) + await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) + + // Stop impersonating + await network.provider.request({ + method: 'hardhat_stopImpersonatingAccount', + params: [mockSubgraphService.address], + }) + }) + + it('should deny rewards from subgraph service when indexer is ineligible', async function () { + // Setup REO that denies indexer1 + const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( + 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', + ) + const mockREO = await MockRewardsEligibilityOracleFactory.deploy(false) // Deny by default + await mockREO.deployed() + await rewardsManager.connect(governor).setRewardsEligibilityOracle(mockREO.address) + + // Setup: Create signal + const signalled1 = toGRT('1500') + await curation.connect(curator1).mint(subgraphDeploymentID1, signalled1, 0) + + // Setup allocation + const tokensAllocated = toGRT('12500') + await mockSubgraphService.setAllocation( + allocationID1, + true, + indexer1.address, + subgraphDeploymentID1, + tokensAllocated, + 0, + 0, + ) + + await mockSubgraphService.setSubgraphAllocatedTokens(subgraphDeploymentID1, tokensAllocated) + + // Mine blocks to accrue rewards + await helpers.mine(5) + + // Impersonate the mock subgraph service contract + await network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [mockSubgraphService.address], + }) + await network.provider.send('hardhat_setBalance', [mockSubgraphService.address, '0x1000000000000000000']) + + const mockSubgraphServiceSigner = await hre.ethers.getSigner(mockSubgraphService.address) + + // Take rewards should be denied due to eligibility + const tx = rewardsManager.connect(mockSubgraphServiceSigner).takeRewards(allocationID1) + await expect(tx).emit(rewardsManager, 'RewardsDeniedDueToEligibility') + + // Stop impersonating + await network.provider.request({ + method: 'hardhat_stopImpersonatingAccount', + params: [mockSubgraphService.address], + }) + }) + }) + }) +}) diff --git a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol index 72a73e19b..bd8da3508 100644 --- a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol +++ b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol @@ -43,6 +43,12 @@ interface IRewardsManager { */ function setSubgraphService(address subgraphService) external; + /** + * @notice Set the rewards eligibility oracle address + * @param newRewardsEligibilityOracle The address of the rewards eligibility oracle + */ + function setRewardsEligibilityOracle(address newRewardsEligibilityOracle) external; + // -- Denylist -- /** @@ -67,6 +73,13 @@ interface IRewardsManager { // -- Getters -- + /** + * @notice Gets the effective issuance per block for rewards + * @dev Takes into account the issuance allocator if set + * @return The effective issuance per block + */ + function getRewardsIssuancePerBlock() external view returns (uint256); + /** * @notice Gets the issuance of rewards per signal since last updated * @return newly accrued rewards per signal since last update diff --git a/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol new file mode 100644 index 000000000..4b27eaf39 --- /dev/null +++ b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; +pragma abicoder v2; + +import { TargetIssuancePerBlock } from "./IIssuanceAllocatorTypes.sol"; + +/** + * @title IIssuanceAllocationDistribution + * @author Edge & Node + * @notice Interface for distribution and target interaction with the issuance allocator. + * This is the minimal interface that targets need to interact with the allocator. + */ +interface IIssuanceAllocationDistribution { + /** + * @notice Distribute issuance to allocated non-self-minting targets. + * @return Block number that issuance has been distributed to. That will normally be the current block number, unless the contract is paused. + * + * @dev When the contract is paused, no issuance is distributed and lastIssuanceBlock is not updated. + * @dev This function is permissionless and can be called by anyone, including targets as part of their normal flow. + */ + function distributeIssuance() external returns (uint256); + + /** + * @notice Target issuance per block information + * @param target Address of the target + * @return TargetIssuancePerBlock struct containing allocatorIssuanceBlockAppliedTo, selfIssuanceBlockAppliedTo, allocatorIssuancePerBlock, and selfIssuancePerBlock + * @dev This function does not revert when paused, instead the caller is expected to correctly read and apply the information provided. + * @dev Targets should check allocatorIssuanceBlockAppliedTo and selfIssuanceBlockAppliedTo - if either is not the current block, that type of issuance is paused for that target. + * @dev Targets should not check the allocator's pause state directly, but rely on the blockAppliedTo fields to determine if issuance is paused. + */ + function getTargetIssuancePerBlock(address target) external view returns (TargetIssuancePerBlock memory); +} diff --git a/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol new file mode 100644 index 000000000..b4a5d33a7 --- /dev/null +++ b/packages/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; +pragma abicoder v2; + +/** + * @notice Target issuance per block information + * @param allocatorIssuancePerBlock Issuance per block for allocator-minting (non-self-minting) + * @param allocatorIssuanceBlockAppliedTo The block up to which allocator issuance has been applied + * @param selfIssuancePerBlock Issuance per block for self-minting + * @param selfIssuanceBlockAppliedTo The block up to which self issuance has been applied + */ +struct TargetIssuancePerBlock { + uint256 allocatorIssuancePerBlock; + uint256 allocatorIssuanceBlockAppliedTo; + uint256 selfIssuancePerBlock; + uint256 selfIssuanceBlockAppliedTo; +} diff --git a/packages/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol b/packages/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol new file mode 100644 index 000000000..3fe539b95 --- /dev/null +++ b/packages/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; + +/** + * @title IIssuanceTarget + * @author Edge & Node + * @notice Interface for contracts that receive issuance from an issuance allocator + */ +interface IIssuanceTarget { + /** + * @notice Called by the issuance allocator before the target's issuance allocation changes + * @dev The target should ensure that all issuance related calculations are up-to-date + * with the current block so that an allocation change can be applied correctly. + * Note that the allocation could change multiple times in the same block after + * this function has been called, only the final allocation is relevant. + */ + function beforeIssuanceAllocationChange() external; + + /** + * @notice Sets the issuance allocator for this target + * @dev This function facilitates upgrades by providing a standard way for targets + * to change their allocator. Implementations can define their own access control. + * @param newIssuanceAllocator Address of the issuance allocator + */ + function setIssuanceAllocator(address newIssuanceAllocator) external; +} diff --git a/packages/interfaces/contracts/issuance/common/IPausableControl.sol b/packages/interfaces/contracts/issuance/common/IPausableControl.sol new file mode 100644 index 000000000..83cfbc364 --- /dev/null +++ b/packages/interfaces/contracts/issuance/common/IPausableControl.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; + +/** + * @title IPausableControl + * @author Edge & Node + * @notice Interface for contracts that support pause/unpause functionality + * @dev This interface extends standard pausable functionality with explicit + * pause and unpause functions. Contracts implementing this interface allow + * authorized accounts to pause and unpause contract operations. + * Events (Paused, Unpaused) are inherited from OpenZeppelin's PausableUpgradeable. + */ +interface IPausableControl { + /** + * @notice Pause the contract + * @dev Pauses contract operations. Only functions using whenNotPaused + * modifier will be affected. + */ + function pause() external; + + /** + * @notice Unpause the contract + * @dev Resumes contract operations. Only functions using whenPaused + * modifier will be affected. + */ + function unpause() external; + + /** + * @notice Check if the contract is currently paused + * @return True if the contract is paused, false otherwise + */ + function paused() external view returns (bool); +} diff --git a/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol new file mode 100644 index 000000000..53c8acf85 --- /dev/null +++ b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; + +/** + * @title IRewardsEligibility + * @author Edge & Node + * @notice Minimal interface for checking indexer rewards eligibility + * @dev This is the interface that consumers (e.g., RewardsManager) need to check + * if an indexer is eligible to receive rewards + */ +interface IRewardsEligibility { + /** + * @notice Check if an indexer is eligible to receive rewards + * @param indexer Address of the indexer + * @return True if the indexer is eligible to receive rewards, false otherwise + */ + function isEligible(address indexer) external view returns (bool); +} diff --git a/packages/issuance/.markdownlint.json b/packages/issuance/.markdownlint.json new file mode 100644 index 000000000..18947b0be --- /dev/null +++ b/packages/issuance/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "extends": "../../.markdownlint.json" +} diff --git a/packages/issuance/.solcover.js b/packages/issuance/.solcover.js new file mode 100644 index 000000000..d8bbec4bb --- /dev/null +++ b/packages/issuance/.solcover.js @@ -0,0 +1,15 @@ +module.exports = { + skipFiles: ['test/'], + providerOptions: { + mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', + network_id: 1337, + }, + // Use default istanbulFolder: './coverage' + // Exclude 'html' to avoid duplicate HTML files (lcov already generates HTML in lcov-report/) + istanbulReporter: ['lcov', 'text', 'json'], + configureYulOptimizer: true, + mocha: { + grep: '@skip-on-coverage', + invert: true, + }, +} diff --git a/packages/issuance/.solhint.json b/packages/issuance/.solhint.json new file mode 100644 index 000000000..d30847305 --- /dev/null +++ b/packages/issuance/.solhint.json @@ -0,0 +1,3 @@ +{ + "extends": ["solhint:recommended", "./../../.solhint.json"] +} diff --git a/packages/issuance/README.md b/packages/issuance/README.md new file mode 100644 index 000000000..16e2520b6 --- /dev/null +++ b/packages/issuance/README.md @@ -0,0 +1,62 @@ +# The Graph Issuance Contracts + +This package contains smart contracts for The Graph's issuance functionality. + +## Overview + +The issuance contracts handle token issuance mechanisms for The Graph protocol. + +### Contracts + +- **[IssuanceAllocator](contracts/allocate/IssuanceAllocator.md)** - Central distribution hub for token issuance, allocating tokens to different protocol components based on configured proportions +- **[RewardsEligibilityOracle](contracts/eligibility/RewardsEligibilityOracle.md)** - Oracle-based eligibility system for indexer rewards with time-based expiration +- **DirectAllocation** - Simple target contract for receiving and distributing allocated tokens + +## Development + +### Setup + +```bash +# Install dependencies +pnpm install + +# Build +pnpm build + +# Test +pnpm test +``` + +### Testing + +To run the tests: + +```bash +pnpm test +``` + +For coverage: + +```bash +pnpm test:coverage +``` + +### Linting + +To lint the contracts and tests: + +```bash +pnpm lint +``` + +### Contract Size + +To check contract sizes: + +```bash +pnpm size +``` + +## License + +GPL-2.0-or-later diff --git a/packages/issuance/contracts/common/BaseUpgradeable.sol b/packages/issuance/contracts/common/BaseUpgradeable.sol new file mode 100644 index 000000000..ead4f6a4f --- /dev/null +++ b/packages/issuance/contracts/common/BaseUpgradeable.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity 0.8.27; + +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; +import { IPausableControl } from "@graphprotocol/interfaces/contracts/issuance/common/IPausableControl.sol"; + +/** + * @title BaseUpgradeable + * @author Edge & Node + * @notice A base contract that provides role-based access control and pausability. + * + * @dev This contract combines OpenZeppelin's AccessControl and Pausable + * to provide a standardized way to manage access control and pausing functionality. + * It uses ERC-7201 namespaced storage pattern for better storage isolation. + * This contract is abstract and meant to be inherited by other contracts. + * @custom:security-contact Please email security+contracts@thegraph.com if you find any bugs. We might have an active bug bounty program. + */ +abstract contract BaseUpgradeable is Initializable, AccessControlUpgradeable, PausableUpgradeable, IPausableControl { + // -- Constants -- + + /// @notice One million - used as the denominator for values provided as Parts Per Million (PPM) + /// @dev This constant represents 1,000,000 and serves as the denominator when working with + /// PPM values. For example, 50% would be represented as 500,000 PPM, calculated as + /// (500,000 / MILLION) = 0.5 = 50% + uint256 public constant MILLION = 1_000_000; + + // -- Role Constants -- + + /** + * @notice Role identifier for governor accounts + * @dev Governors have the highest level of access and can: + * - Grant and revoke roles within the established hierarchy + * - Perform administrative functions and system configuration + * - Set critical parameters and upgrade contracts + * Admin of: GOVERNOR_ROLE, PAUSE_ROLE, OPERATOR_ROLE + */ + bytes32 public constant GOVERNOR_ROLE = keccak256("GOVERNOR_ROLE"); + + /** + * @notice Role identifier for pause accounts + * @dev Pause role holders can: + * - Pause and unpause contract operations for emergency situations + * Typically granted to automated monitoring systems or emergency responders. + * Pausing is intended for quick response to potential threats, and giving time for investigation and resolution (potentially with governance intervention). + * Admin: GOVERNOR_ROLE + */ + bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); + + /** + * @notice Role identifier for operator accounts + * @dev Operators can: + * - Perform operational tasks as defined by inheriting contracts + * - Manage roles that are designated as operator-administered + * Admin: GOVERNOR_ROLE + */ + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + + // -- Immutable Variables -- + + /// @notice The Graph Token contract + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + IGraphToken internal immutable GRAPH_TOKEN; + + // -- Custom Errors -- + + /// @notice Thrown when attempting to set the Graph Token to the zero address + error GraphTokenCannotBeZeroAddress(); + + /// @notice Thrown when attempting to set the governor to the zero address + error GovernorCannotBeZeroAddress(); + + // -- Constructor -- + + /** + * @notice Constructor for the BaseUpgradeable contract + * @dev This contract is upgradeable, but we use the constructor to set immutable variables + * and disable initializers to prevent the implementation contract from being initialized. + * @param graphToken Address of the Graph Token contract + * @custom:oz-upgrades-unsafe-allow constructor + */ + constructor(address graphToken) { + require(graphToken != address(0), GraphTokenCannotBeZeroAddress()); + GRAPH_TOKEN = IGraphToken(graphToken); + _disableInitializers(); + } + + // -- Initialization -- + + /** + * @notice Internal function to initialize the BaseUpgradeable contract + * @dev This function is used by child contracts to initialize the BaseUpgradeable contract + * @param governor Address that will have the GOVERNOR_ROLE + */ + function __BaseUpgradeable_init(address governor) internal { + // solhint-disable-previous-line func-name-mixedcase + + __AccessControl_init(); + __Pausable_init(); + + __BaseUpgradeable_init_unchained(governor); + } + + /** + * @notice Internal unchained initialization function for BaseUpgradeable + * @dev This function sets up the governor role and role admin hierarchy + * @param governor Address that will have the GOVERNOR_ROLE + */ + function __BaseUpgradeable_init_unchained(address governor) internal { + // solhint-disable-previous-line func-name-mixedcase + + require(governor != address(0), GovernorCannotBeZeroAddress()); + + // Set up role admin hierarchy: + // GOVERNOR is admin of GOVERNOR, PAUSE, and OPERATOR roles + _setRoleAdmin(GOVERNOR_ROLE, GOVERNOR_ROLE); + _setRoleAdmin(PAUSE_ROLE, GOVERNOR_ROLE); + _setRoleAdmin(OPERATOR_ROLE, GOVERNOR_ROLE); + + // Grant initial governor role + _grantRole(GOVERNOR_ROLE, governor); + } + + // -- External Functions -- + + /** + * @inheritdoc IPausableControl + */ + function pause() external override onlyRole(PAUSE_ROLE) { + _pause(); + } + + /** + * @inheritdoc IPausableControl + */ + function unpause() external override onlyRole(PAUSE_ROLE) { + _unpause(); + } + + /** + * @inheritdoc IPausableControl + */ + function paused() public view virtual override(PausableUpgradeable, IPausableControl) returns (bool) { + return super.paused(); + } + + /** + * @notice Check if this contract supports a given interface + * @dev Adds support for IPausableControl interface + * @param interfaceId The interface identifier to check + * @return True if the contract supports the interface, false otherwise + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IPausableControl).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/packages/issuance/hardhat.base.config.ts b/packages/issuance/hardhat.base.config.ts new file mode 100644 index 000000000..e4d0cc8bb --- /dev/null +++ b/packages/issuance/hardhat.base.config.ts @@ -0,0 +1,24 @@ +import { hardhatBaseConfig } from '@graphprotocol/toolshed/hardhat' +import type { HardhatUserConfig } from 'hardhat/config' + +// Issuance-specific Solidity configuration with Cancun EVM version +// Based on toolshed solidityUserConfig but with Cancun EVM target +export const issuanceSolidityConfig = { + version: '0.8.27', + settings: { + optimizer: { + enabled: true, + runs: 100, + }, + evmVersion: 'cancun' as const, + }, +} + +// Base configuration for issuance package - inherits from toolshed and overrides Solidity config +export const issuanceBaseConfig = (() => { + const baseConfig = hardhatBaseConfig(require) + return { + ...baseConfig, + solidity: issuanceSolidityConfig, + } as HardhatUserConfig +})() diff --git a/packages/issuance/hardhat.config.ts b/packages/issuance/hardhat.config.ts new file mode 100644 index 000000000..f76949af8 --- /dev/null +++ b/packages/issuance/hardhat.config.ts @@ -0,0 +1,26 @@ +import '@nomicfoundation/hardhat-ethers' +import '@typechain/hardhat' +import 'hardhat-contract-sizer' +import '@openzeppelin/hardhat-upgrades' +import '@nomicfoundation/hardhat-verify' + +import type { HardhatUserConfig } from 'hardhat/config' + +import { issuanceBaseConfig } from './hardhat.base.config' + +const config: HardhatUserConfig = { + ...issuanceBaseConfig, + // Main config specific settings + typechain: { + outDir: 'types', + target: 'ethers-v6', + }, + paths: { + sources: './contracts', + tests: './test/tests', + artifacts: './artifacts', + cache: './cache', + }, +} + +export default config diff --git a/packages/issuance/hardhat.coverage.config.ts b/packages/issuance/hardhat.coverage.config.ts new file mode 100644 index 000000000..01ee96e83 --- /dev/null +++ b/packages/issuance/hardhat.coverage.config.ts @@ -0,0 +1,22 @@ +import '@nomicfoundation/hardhat-ethers' +import '@nomicfoundation/hardhat-chai-matchers' +import '@nomicfoundation/hardhat-network-helpers' +import '@openzeppelin/hardhat-upgrades' +import 'hardhat-gas-reporter' +import 'solidity-coverage' + +import { HardhatUserConfig } from 'hardhat/config' + +import { issuanceBaseConfig } from './hardhat.base.config' + +const config: HardhatUserConfig = { + ...issuanceBaseConfig, + paths: { + sources: './contracts', + tests: './test/tests', + artifacts: './coverage/artifacts', + cache: './coverage/cache', + }, +} as HardhatUserConfig + +export default config diff --git a/packages/issuance/package.json b/packages/issuance/package.json new file mode 100644 index 000000000..fbb658193 --- /dev/null +++ b/packages/issuance/package.json @@ -0,0 +1,79 @@ +{ + "name": "@graphprotocol/issuance", + "version": "1.0.0", + "publishConfig": { + "access": "public" + }, + "description": "The Graph Issuance Contracts", + "author": "Edge & Node", + "license": "GPL-2.0-or-later", + "main": "index.js", + "exports": { + ".": "./index.js", + "./artifacts/*": "./artifacts/*", + "./contracts/*": "./contracts/*", + "./types": "./types/index.ts", + "./types/*": "./types/*" + }, + "scripts": { + "build": "pnpm build:dep && pnpm build:self", + "build:dep": "pnpm --filter '@graphprotocol/issuance^...' run build:self", + "build:self": "pnpm compile && pnpm build:self:typechain", + "build:coverage": "pnpm build:dep && pnpm build:self:coverage", + "build:self:coverage": "npx hardhat compile --config hardhat.coverage.config.ts && pnpm build:self:typechain", + "build:self:typechain": "bash -c 'missing=$(grep -rL \"static readonly interfaceId\" types/factories --include=\"*__factory.ts\" 2>/dev/null | wc -l); if [ $missing -gt 0 ]; then node -e \"require('\"'\"'@graphprotocol/interfaces/utils'\"'\"').addInterfaceIds('\"'\"'types/factories'\"'\"')\"; fi'", + "clean": "rm -rf artifacts/ types/ forge-artifacts/ cache_forge/ coverage/ cache/ .eslintcache", + "compile": "hardhat compile --quiet", + "test": "pnpm --filter @graphprotocol/issuance-test test", + "test:coverage": "pnpm --filter @graphprotocol/issuance-test run test:coverage", + "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:md; pnpm lint:json", + "lint:ts": "eslint '**/*.{js,ts,cjs,mjs,jsx,tsx}' --fix --cache; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", + "lint:sol": "solhint --fix --noPrompt --noPoster 'contracts/**/*.sol'; prettier -w --cache --log-level warn 'contracts/**/*.sol'", + "lint:md": "markdownlint --fix --ignore-path ../../.gitignore '**/*.md'; prettier -w --cache --log-level warn '**/*.md'", + "lint:json": "prettier -w --cache --log-level warn '**/*.json'", + "typechain": "hardhat typechain", + "verify": "hardhat verify", + "size": "hardhat size-contracts", + "forge:build": "forge build" + }, + "files": [ + "artifacts/**/*", + "types/**/*", + "contracts/**/*", + "README.md" + ], + "devDependencies": { + "@graphprotocol/interfaces": "workspace:^", + "@graphprotocol/toolshed": "workspace:^", + "@nomicfoundation/hardhat-ethers": "catalog:", + "@nomicfoundation/hardhat-verify": "catalog:", + "@openzeppelin/contracts": "^5.4.0", + "@openzeppelin/contracts-upgradeable": "^5.4.0", + "@openzeppelin/hardhat-upgrades": "^3.9.0", + "@typechain/ethers-v6": "^0.5.0", + "@typechain/hardhat": "catalog:", + "@types/node": "^20.17.50", + "dotenv": "catalog:", + "eslint": "catalog:", + "ethers": "catalog:", + "glob": "catalog:", + "globals": "catalog:", + "hardhat": "catalog:", + "hardhat-contract-sizer": "catalog:", + "hardhat-secure-accounts": "catalog:", + "hardhat-storage-layout": "catalog:", + "lint-staged": "catalog:", + "markdownlint-cli": "catalog:", + "prettier": "catalog:", + "prettier-plugin-solidity": "catalog:", + "solhint": "catalog:", + "ts-node": "^10.9.2", + "typechain": "^8.3.0", + "typescript": "catalog:", + "typescript-eslint": "catalog:", + "yaml-lint": "catalog:" + }, + "dependencies": { + "@noble/hashes": "^1.8.0" + } +} diff --git a/packages/issuance/prettier.config.cjs b/packages/issuance/prettier.config.cjs new file mode 100644 index 000000000..4e8dcf4f3 --- /dev/null +++ b/packages/issuance/prettier.config.cjs @@ -0,0 +1,5 @@ +const baseConfig = require('../../prettier.config.cjs') + +module.exports = { + ...baseConfig, +} diff --git a/packages/issuance/test/package.json b/packages/issuance/test/package.json new file mode 100644 index 000000000..f362b4c9b --- /dev/null +++ b/packages/issuance/test/package.json @@ -0,0 +1,62 @@ +{ + "name": "@graphprotocol/issuance-test", + "version": "1.0.0", + "private": true, + "description": "Test utilities for @graphprotocol/issuance", + "author": "Edge & Node", + "license": "GPL-2.0-or-later", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "default": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "build": "pnpm build:dep && pnpm build:self", + "build:dep": "pnpm --filter '@graphprotocol/issuance-test^...' run build:self", + "build:self": "tsc --build", + "build:coverage": "pnpm build:dep:coverage && pnpm build:self", + "build:dep:coverage": "pnpm --filter '@graphprotocol/issuance-test^...' run build:coverage", + "clean": "rm -rf .eslintcache artifacts/", + "test": "pnpm build && pnpm test:self", + "test:self": "cd .. && hardhat test", + "test:coverage": "pnpm build:coverage && pnpm test:coverage:self", + "test:coverage:self": "cd .. && npx hardhat coverage --config hardhat.coverage.config.ts", + "lint": "pnpm lint:ts; pnpm lint:json", + "lint:ts": "eslint '**/*.{js,ts,cjs,mjs,jsx,tsx}' --fix --cache; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", + "lint:json": "prettier -w --cache --log-level warn '**/*.json'" + }, + "dependencies": { + "@graphprotocol/issuance": "workspace:^", + "@graphprotocol/interfaces": "workspace:^", + "@graphprotocol/contracts": "workspace:^" + }, + "devDependencies": { + "@nomicfoundation/hardhat-chai-matchers": "^2.0.0", + "@nomicfoundation/hardhat-ethers": "catalog:", + "@nomicfoundation/hardhat-foundry": "^1.1.1", + "@nomicfoundation/hardhat-network-helpers": "^1.0.0", + "@nomicfoundation/hardhat-toolbox": "5.0.0", + "@openzeppelin/contracts": "^5.4.0", + "@openzeppelin/contracts-upgradeable": "^5.4.0", + "@openzeppelin/foundry-upgrades": "0.4.0", + "@types/chai": "^4.3.20", + "@types/mocha": "^10.0.10", + "@types/node": "^20.17.50", + "chai": "^4.3.7", + "dotenv": "^16.5.0", + "eslint": "catalog:", + "eslint-plugin-no-only-tests": "catalog:", + "ethers": "catalog:", + "forge-std": "https://github.com/foundry-rs/forge-std/tarball/v1.9.7", + "glob": "catalog:", + "hardhat": "catalog:", + "hardhat-gas-reporter": "catalog:", + "prettier": "catalog:", + "solidity-coverage": "^0.8.0", + "ts-node": "^10.9.2", + "typescript": "catalog:" + } +} diff --git a/packages/issuance/test/prettier.config.cjs b/packages/issuance/test/prettier.config.cjs new file mode 100644 index 000000000..8eb0a0bee --- /dev/null +++ b/packages/issuance/test/prettier.config.cjs @@ -0,0 +1,5 @@ +const baseConfig = require('../prettier.config.cjs') + +module.exports = { + ...baseConfig, +} diff --git a/packages/issuance/test/src/index.ts b/packages/issuance/test/src/index.ts new file mode 100644 index 000000000..614cfd50d --- /dev/null +++ b/packages/issuance/test/src/index.ts @@ -0,0 +1,5 @@ +// Test utilities for @graphprotocol/issuance +// This package contains test files, test helpers, and testing utilities + +// This package provides test utilities for issuance contracts +export const PACKAGE_NAME = '@graphprotocol/issuance-test' diff --git a/packages/issuance/test/tests/common/CommonInterfaceIdStability.test.ts b/packages/issuance/test/tests/common/CommonInterfaceIdStability.test.ts new file mode 100644 index 000000000..e91b12bd2 --- /dev/null +++ b/packages/issuance/test/tests/common/CommonInterfaceIdStability.test.ts @@ -0,0 +1,27 @@ +import { IPausableControl__factory } from '@graphprotocol/interfaces/types' +import { IAccessControl__factory } from '@graphprotocol/issuance/types' +import { expect } from 'chai' + +/** + * Common Interface ID Stability Tests + * + * These tests verify that common interface IDs remain stable across builds. + * These interfaces are used by both allocate and eligibility contracts. + * + * 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('Common Interface ID Stability', () => { + it('IPausableControl should have stable interface ID', () => { + expect(IPausableControl__factory.interfaceId).to.equal('0xe78a39d8') + }) + + it('IAccessControl should have stable interface ID', () => { + expect(IAccessControl__factory.interfaceId).to.equal('0x7965db0b') + }) +}) diff --git a/packages/issuance/test/tests/common/fixtures.ts b/packages/issuance/test/tests/common/fixtures.ts new file mode 100644 index 000000000..5feaa0e6a --- /dev/null +++ b/packages/issuance/test/tests/common/fixtures.ts @@ -0,0 +1,127 @@ +/** + * Common test fixtures shared by all test domains + * Contains only truly shared functionality used by both allocate and eligibility tests + */ + +import '@nomicfoundation/hardhat-chai-matchers' + +import fs from 'fs' +import hre from 'hardhat' + +const { ethers } = hre +const { upgrades } = require('hardhat') + +import type { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +import { GraphTokenHelper } from './graphTokenHelper' + +/** + * Standard test accounts interface + */ +export interface TestAccounts { + governor: SignerWithAddress + nonGovernor: SignerWithAddress + operator: SignerWithAddress + user: SignerWithAddress + indexer1: SignerWithAddress + indexer2: SignerWithAddress + selfMintingTarget: SignerWithAddress +} + +/** + * Get standard test accounts + */ +export async function getTestAccounts(): Promise { + const [governor, nonGovernor, operator, user, indexer1, indexer2, selfMintingTarget] = await ethers.getSigners() + + return { + governor, + nonGovernor, + operator, + user, + indexer1, + indexer2, + selfMintingTarget, + } +} + +/** + * Common constants used in tests + */ +export const Constants = { + PPM: 1_000_000, // Parts per million (100%) + DEFAULT_ISSUANCE_PER_BLOCK: ethers.parseEther('100'), // 100 GRT per block +} + +// Shared test constants +export const SHARED_CONSTANTS = { + PPM: 1_000_000, + + // Pre-calculated role constants to avoid repeated async calls + GOVERNOR_ROLE: ethers.keccak256(ethers.toUtf8Bytes('GOVERNOR_ROLE')), + OPERATOR_ROLE: ethers.keccak256(ethers.toUtf8Bytes('OPERATOR_ROLE')), + PAUSE_ROLE: ethers.keccak256(ethers.toUtf8Bytes('PAUSE_ROLE')), + ORACLE_ROLE: ethers.keccak256(ethers.toUtf8Bytes('ORACLE_ROLE')), +} as const + +/** + * Deploy a test GraphToken for testing + * This uses the real GraphToken contract + * @returns {Promise} + */ +export async function deployTestGraphToken() { + // Get the governor account + const [governor] = await ethers.getSigners() + + // Load the GraphToken artifact directly from the contracts package + const graphTokenArtifactPath = require.resolve( + '@graphprotocol/contracts/artifacts/contracts/token/GraphToken.sol/GraphToken.json', + ) + const GraphTokenArtifact = JSON.parse(fs.readFileSync(graphTokenArtifactPath, 'utf8')) + + // Create a contract factory using the artifact + const GraphTokenFactory = new ethers.ContractFactory(GraphTokenArtifact.abi, GraphTokenArtifact.bytecode, governor) + + // Deploy the contract + const graphToken = await GraphTokenFactory.deploy(ethers.parseEther('1000000000')) + await graphToken.waitForDeployment() + + return graphToken +} + +/** + * Get a GraphTokenHelper for an existing token + * @param {string} tokenAddress The address of the GraphToken + * @param {boolean} [isFork=false] Whether this is running on a forked network + * @returns {Promise} + */ +export async function getGraphTokenHelper(tokenAddress, isFork = false) { + // Get the governor account + const [governor] = await ethers.getSigners() + + // Get the GraphToken at the specified address + const graphToken = await ethers.getContractAt(isFork ? 'IGraphToken' : 'GraphToken', tokenAddress) + + return new GraphTokenHelper(graphToken, governor) +} + +/** + * Upgrade a contract using OpenZeppelin's upgrades library + * This is a generic function that can be used to upgrade any contract + * @param {string} contractAddress + * @param {string} contractName + * @param {any[]} [constructorArgs=[]] + * @returns {Promise} + */ +export async function upgradeContract(contractAddress, contractName, constructorArgs = []) { + // Get the contract factory + const ContractFactory = await ethers.getContractFactory(contractName) + + // Upgrade the contract + const upgradedContractInstance = await upgrades.upgradeProxy(contractAddress, ContractFactory, { + constructorArgs, + }) + + // Return the upgraded contract instance + return upgradedContractInstance +} diff --git a/packages/issuance/test/tests/common/graphTokenHelper.ts b/packages/issuance/test/tests/common/graphTokenHelper.ts new file mode 100644 index 000000000..f4adbcc8a --- /dev/null +++ b/packages/issuance/test/tests/common/graphTokenHelper.ts @@ -0,0 +1,91 @@ +import fs from 'fs' +import hre from 'hardhat' +const { ethers } = hre +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' +import { Contract } from 'ethers' + +/** + * Helper class for working with GraphToken in tests + * This provides a consistent interface for minting tokens + * and managing minters + */ +export class GraphTokenHelper { + private graphToken: Contract + private governor: SignerWithAddress + + /** + * Create a new GraphTokenHelper + * @param graphToken The GraphToken instance + * @param governor The governor account + */ + constructor(graphToken: Contract, governor: SignerWithAddress) { + this.graphToken = graphToken + this.governor = governor + } + + /** + * Get the GraphToken instance + */ + getToken(): Contract { + return this.graphToken + } + + /** + * Get the GraphToken address + */ + async getAddress(): Promise { + return await this.graphToken.getAddress() + } + + /** + * Mint tokens to an address + */ + async mint(to: string, amount: bigint): Promise { + await (this.graphToken as any).connect(this.governor).mint(to, amount) + } + + /** + * Add a minter to the GraphToken + */ + async addMinter(minter: string): Promise { + await (this.graphToken as any).connect(this.governor).addMinter(minter) + } + + /** + * Deploy a new GraphToken for testing + * @param {SignerWithAddress} governor The governor account + * @returns {Promise} + */ + static async deploy(governor) { + // Load the GraphToken artifact directly from the contracts package + const graphTokenArtifactPath = require.resolve( + '@graphprotocol/contracts/artifacts/contracts/token/GraphToken.sol/GraphToken.json', + ) + const GraphTokenArtifact = JSON.parse(fs.readFileSync(graphTokenArtifactPath, 'utf8')) + + // Create a contract factory using the artifact + const GraphTokenFactory = new ethers.ContractFactory(GraphTokenArtifact.abi, GraphTokenArtifact.bytecode, governor) + + // Deploy the contract + const graphToken = await GraphTokenFactory.deploy(ethers.parseEther('1000000000')) + await graphToken.waitForDeployment() + + return new GraphTokenHelper(graphToken as any, governor) + } + + /** + * Create a GraphTokenHelper for an existing GraphToken on a forked network + * @param {string} tokenAddress The GraphToken address + * @param {SignerWithAddress} governor The governor account + * @returns {Promise} + */ + static async forFork(tokenAddress, governor) { + // Get the GraphToken at the specified address + const graphToken = await ethers.getContractAt('IGraphToken', tokenAddress) + + // Create a helper + const helper = new GraphTokenHelper(graphToken as any, governor) + + return helper + } +} diff --git a/packages/issuance/test/tests/common/testPatterns.ts b/packages/issuance/test/tests/common/testPatterns.ts new file mode 100644 index 000000000..5af5bc73c --- /dev/null +++ b/packages/issuance/test/tests/common/testPatterns.ts @@ -0,0 +1,52 @@ +/** + * Common test patterns shared by both allocate and eligibility tests + */ + +import { expect } from 'chai' + +/** + * Comprehensive interface compliance test suite + * Replaces multiple individual interface support tests + * + * @param contractGetter - Function that returns the contract instance to test + * @param interfaces - Array of Typechain factory classes with interfaceId and interfaceName + * + * @example + * import { IPausableControl__factory, IAccessControl__factory } from '@graphprotocol/interfaces/types' + * + * shouldSupportInterfaces( + * () => contract, + * [ + * IPausableControl__factory, + * IAccessControl__factory, + * ] + * ) + */ +export function shouldSupportInterfaces( + contractGetter: () => T, + interfaces: Array<{ + interfaceId: string + interfaceName: string + }>, +) { + return function () { + describe('Interface Compliance', () => { + it('should support ERC-165 interface', async function () { + const contract = contractGetter() + expect(await (contract as any).supportsInterface('0x01ffc9a7')).to.be.true + }) + + interfaces.forEach((iface) => { + it(`should support ${iface.interfaceName} interface`, async function () { + const contract = contractGetter() + expect(await (contract as any).supportsInterface(iface.interfaceId)).to.be.true + }) + }) + + it('should not support random interface', async function () { + const contract = contractGetter() + expect(await (contract as any).supportsInterface('0x12345678')).to.be.false + }) + }) + } +} diff --git a/packages/issuance/test/tsconfig.json b/packages/issuance/test/tsconfig.json new file mode 100644 index 000000000..dfecc9bcf --- /dev/null +++ b/packages/issuance/test/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "target": "es2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": false, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowJs": true, + "checkJs": false, + "incremental": true, + "noEmitOnError": false, + "noImplicitAny": false, + "outDir": "./artifacts" + }, + "include": ["tests/**/*", "utils/**/*", "../types/**/*"], + "exclude": ["node_modules", "build", "scripts/**/*"] +} diff --git a/packages/issuance/tsconfig.json b/packages/issuance/tsconfig.json new file mode 100644 index 000000000..00aa1b8ef --- /dev/null +++ b/packages/issuance/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "Node16", + "moduleResolution": "node16", + "strict": true, + "esModuleInterop": true, + "declaration": true, + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "incremental": true + }, + + "include": ["./scripts", "./test", "./typechain"], + "files": ["./hardhat.config.cjs"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1da271388..07029ae86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,12 +21,21 @@ catalogs: '@nomicfoundation/hardhat-ethers': specifier: ^3.1.0 version: 3.1.0 + '@nomicfoundation/hardhat-verify': + specifier: ^2.0.10 + version: 2.1.1 + '@typechain/hardhat': + specifier: ^9.0.0 + version: 9.1.0 '@typescript-eslint/eslint-plugin': specifier: ^8.46.1 version: 8.46.2 '@typescript-eslint/parser': specifier: ^8.46.1 version: 8.46.2 + dotenv: + specifier: ^16.5.0 + version: 16.6.1 eslint: specifier: ^9.37.0 version: 9.38.0 @@ -66,6 +75,9 @@ catalogs: hardhat-ignore-warnings: specifier: ^0.2.12 version: 0.2.12 + hardhat-secure-accounts: + specifier: ^1.0.5 + version: 1.0.5 hardhat-storage-layout: specifier: ^0.1.7 version: 0.1.7 @@ -956,6 +968,185 @@ importers: specifier: ^2.31.7 version: 2.37.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + packages/issuance: + dependencies: + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 + devDependencies: + '@graphprotocol/interfaces': + specifier: workspace:^ + version: link:../interfaces + '@graphprotocol/toolshed': + specifier: workspace:^ + version: link:../toolshed + '@nomicfoundation/hardhat-ethers': + specifier: 'catalog:' + version: 3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@nomicfoundation/hardhat-verify': + specifier: 'catalog:' + version: 2.1.1(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@openzeppelin/contracts': + specifier: ^5.4.0 + version: 5.4.0 + '@openzeppelin/contracts-upgradeable': + specifier: ^5.4.0 + version: 5.4.0(@openzeppelin/contracts@5.4.0) + '@openzeppelin/hardhat-upgrades': + specifier: ^3.9.0 + version: 3.9.1(@nomicfoundation/hardhat-ethers@3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(@nomicfoundation/hardhat-verify@2.1.1(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(encoding@0.1.13)(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@typechain/ethers-v6': + specifier: ^0.5.0 + version: 0.5.1(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3))(typescript@5.9.3) + '@typechain/hardhat': + specifier: 'catalog:' + version: 9.1.0(@typechain/ethers-v6@0.5.1(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3))(typescript@5.9.3))(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3)) + '@types/node': + specifier: ^20.17.50 + version: 20.19.14 + dotenv: + specifier: 'catalog:' + version: 16.6.1 + eslint: + specifier: 'catalog:' + version: 9.38.0(jiti@2.5.1) + ethers: + specifier: 'catalog:' + version: 6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + glob: + specifier: 'catalog:' + version: 11.0.3 + globals: + specifier: 'catalog:' + version: 16.4.0 + hardhat: + specifier: 'catalog:' + version: 2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10) + hardhat-contract-sizer: + specifier: 'catalog:' + version: 2.10.1(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + hardhat-secure-accounts: + specifier: 'catalog:' + version: 1.0.5(@nomicfoundation/hardhat-ethers@3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + hardhat-storage-layout: + specifier: 'catalog:' + version: 0.1.7(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + lint-staged: + specifier: 'catalog:' + version: 16.2.6 + markdownlint-cli: + specifier: 'catalog:' + version: 0.45.0 + prettier: + specifier: 'catalog:' + version: 3.6.2 + prettier-plugin-solidity: + specifier: 'catalog:' + version: 2.1.0(prettier@3.6.2) + solhint: + specifier: 'catalog:' + version: 6.0.1(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.14)(typescript@5.9.3) + typechain: + specifier: ^8.3.0 + version: 8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + typescript-eslint: + specifier: 'catalog:' + version: 8.46.2(eslint@9.38.0(jiti@2.5.1))(typescript@5.9.3) + yaml-lint: + specifier: 'catalog:' + version: 1.7.0 + + packages/issuance/test: + dependencies: + '@graphprotocol/contracts': + specifier: workspace:^ + version: link:../../contracts + '@graphprotocol/interfaces': + specifier: workspace:^ + version: link:../../interfaces + '@graphprotocol/issuance': + specifier: workspace:^ + version: link:.. + devDependencies: + '@nomicfoundation/hardhat-chai-matchers': + specifier: ^2.0.0 + version: 2.1.0(@nomicfoundation/hardhat-ethers@3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(chai@4.5.0)(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@nomicfoundation/hardhat-ethers': + specifier: 'catalog:' + version: 3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@nomicfoundation/hardhat-foundry': + specifier: ^1.1.1 + version: 1.2.0(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@nomicfoundation/hardhat-network-helpers': + specifier: ^1.0.0 + version: 1.1.0(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@nomicfoundation/hardhat-toolbox': + specifier: 5.0.0 + version: 5.0.0(d4ea276d64fbf8f2a60adf85f1748ee6) + '@openzeppelin/contracts': + specifier: ^5.4.0 + version: 5.4.0 + '@openzeppelin/contracts-upgradeable': + specifier: ^5.4.0 + version: 5.4.0(@openzeppelin/contracts@5.4.0) + '@openzeppelin/foundry-upgrades': + specifier: 0.4.0 + version: 0.4.0(@openzeppelin/defender-deploy-client-cli@0.0.1-alpha.10(encoding@0.1.13))(@openzeppelin/upgrades-core@1.44.1) + '@types/chai': + specifier: ^4.3.20 + version: 4.3.20 + '@types/mocha': + specifier: ^10.0.10 + version: 10.0.10 + '@types/node': + specifier: ^20.17.50 + version: 20.19.14 + chai: + specifier: ^4.3.7 + version: 4.5.0 + dotenv: + specifier: ^16.5.0 + version: 16.6.1 + eslint: + specifier: 'catalog:' + version: 9.38.0(jiti@2.5.1) + eslint-plugin-no-only-tests: + specifier: 'catalog:' + version: 3.3.0 + ethers: + specifier: 'catalog:' + version: 6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + forge-std: + specifier: https://github.com/foundry-rs/forge-std/tarball/v1.9.7 + version: https://github.com/foundry-rs/forge-std/tarball/v1.9.7 + glob: + specifier: 'catalog:' + version: 11.0.3 + hardhat: + specifier: 'catalog:' + version: 2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10) + hardhat-gas-reporter: + specifier: 'catalog:' + version: 1.0.10(bufferutil@4.0.9)(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) + prettier: + specifier: 'catalog:' + version: 3.6.2 + solidity-coverage: + specifier: ^0.8.0 + version: 0.8.16(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.14)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/subgraph-service: devDependencies: '@graphprotocol/contracts': @@ -3290,6 +3481,28 @@ packages: typechain: ^8.3.0 typescript: '>=4.5.0' + '@nomicfoundation/hardhat-toolbox@5.0.0': + resolution: {integrity: sha512-FnUtUC5PsakCbwiVNsqlXVIWG5JIb5CEZoSXbJUsEBun22Bivx2jhF1/q9iQbzuaGpJKFQyOhemPB2+XlEE6pQ==} + peerDependencies: + '@nomicfoundation/hardhat-chai-matchers': ^2.0.0 + '@nomicfoundation/hardhat-ethers': ^3.0.0 + '@nomicfoundation/hardhat-ignition-ethers': ^0.15.0 + '@nomicfoundation/hardhat-network-helpers': ^1.0.0 + '@nomicfoundation/hardhat-verify': ^2.0.0 + '@typechain/ethers-v6': ^0.5.0 + '@typechain/hardhat': ^9.0.0 + '@types/chai': ^4.2.0 + '@types/mocha': '>=9.1.0' + '@types/node': ^20.17.50 + chai: ^4.2.0 + ethers: ^6.4.0 + hardhat: ^2.11.0 + hardhat-gas-reporter: ^1.0.8 + solidity-coverage: ^0.8.1 + ts-node: '>=8.0.0' + typechain: ^8.3.0 + typescript: '>=4.5.0' + '@nomicfoundation/hardhat-verify@2.1.1': resolution: {integrity: sha512-K1plXIS42xSHDJZRkrE2TZikqxp9T4y6jUMUNI/imLgN5uCcEQokmfU0DlyP9zzHncYK92HlT5IWP35UVCLrPw==} peerDependencies: @@ -3425,6 +3638,18 @@ packages: '@nomiclabs/harhdat-etherscan': optional: true + '@openzeppelin/hardhat-upgrades@3.9.1': + resolution: {integrity: sha512-pSDjlOnIpP+PqaJVe144dK6VVKZw2v6YQusyt0OOLiCsl+WUzfo4D0kylax7zjrOxqy41EK2ipQeIF4T+cCn2A==} + hasBin: true + peerDependencies: + '@nomicfoundation/hardhat-ethers': ^3.0.6 + '@nomicfoundation/hardhat-verify': ^2.0.14 + ethers: ^6.6.0 + hardhat: ^2.24.1 + peerDependenciesMeta: + '@nomicfoundation/hardhat-verify': + optional: true + '@openzeppelin/platform-deploy-client@0.8.0': resolution: {integrity: sha512-POx3AsnKwKSV/ZLOU/gheksj0Lq7Is1q2F3pKmcFjGZiibf+4kjGxr4eSMrT+2qgKYZQH1ZLQZ+SkbguD8fTvA==} deprecated: '@openzeppelin/platform-deploy-client is deprecated. Please use @openzeppelin/defender-sdk-deploy-client' @@ -11244,6 +11469,10 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} + undici@6.22.0: + resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} + engines: {node: '>=18.17'} + unfetch@4.2.0: resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} @@ -13287,7 +13516,7 @@ snapshots: '@ethereumjs/common@2.6.0': dependencies: crc-32: 1.2.2 - ethereumjs-util: 7.1.3 + ethereumjs-util: 7.1.5 '@ethereumjs/common@2.6.5': dependencies: @@ -13309,7 +13538,7 @@ snapshots: '@ethereumjs/tx@3.4.0': dependencies: '@ethereumjs/common': 2.6.0 - ethereumjs-util: 7.1.3 + ethereumjs-util: 7.1.5 '@ethereumjs/tx@3.5.2': dependencies: @@ -13336,7 +13565,7 @@ snapshots: async-eventemitter: 0.2.4 core-js-pure: 3.45.1 debug: 2.6.9 - ethereumjs-util: 7.1.3 + ethereumjs-util: 7.1.5 functional-red-black-tree: 1.0.1 mcl-wasm: 0.7.9 merkle-patricia-tree: 4.2.4 @@ -15560,6 +15789,27 @@ snapshots: typechain: 8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3) typescript: 5.9.3 + '@nomicfoundation/hardhat-toolbox@5.0.0(d4ea276d64fbf8f2a60adf85f1748ee6)': + dependencies: + '@nomicfoundation/hardhat-chai-matchers': 2.1.0(@nomicfoundation/hardhat-ethers@3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(chai@4.5.0)(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@nomicfoundation/hardhat-ethers': 3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@nomicfoundation/hardhat-ignition-ethers': 0.15.14(@nomicfoundation/hardhat-ethers@3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(@nomicfoundation/hardhat-ignition@0.15.13(@nomicfoundation/hardhat-verify@2.1.1(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10))(@nomicfoundation/ignition-core@0.15.13(bufferutil@4.0.9)(utf-8-validate@5.0.10))(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@nomicfoundation/hardhat-network-helpers': 1.1.0(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@nomicfoundation/hardhat-verify': 2.1.1(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@typechain/ethers-v6': 0.5.1(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3))(typescript@5.9.3) + '@typechain/hardhat': 9.1.0(@typechain/ethers-v6@0.5.1(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3))(typescript@5.9.3))(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(typechain@8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3)) + '@types/chai': 4.3.20 + '@types/mocha': 10.0.10 + '@types/node': 20.19.14 + chai: 4.5.0 + ethers: 6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + hardhat: 2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10) + hardhat-gas-reporter: 1.0.10(bufferutil@4.0.9)(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) + solidity-coverage: 0.8.16(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + ts-node: 10.9.2(@types/node@20.19.14)(typescript@5.9.3) + typechain: 8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3) + typescript: 5.9.3 + '@nomicfoundation/hardhat-verify@2.1.1(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))': dependencies: '@ethersproject/abi': 5.8.0 @@ -15726,8 +15976,8 @@ snapshots: '@openzeppelin/defender-deploy-client-cli@0.0.1-alpha.10(encoding@0.1.13)': dependencies: '@openzeppelin/defender-sdk-base-client': 2.7.0(encoding@0.1.13) - '@openzeppelin/defender-sdk-deploy-client': 2.7.0(encoding@0.1.13) - '@openzeppelin/defender-sdk-network-client': 2.7.0(encoding@0.1.13) + '@openzeppelin/defender-sdk-deploy-client': 2.7.0(debug@4.4.3)(encoding@0.1.13) + '@openzeppelin/defender-sdk-network-client': 2.7.0(debug@4.4.3)(encoding@0.1.13) dotenv: 16.6.1 minimist: 1.2.8 transitivePeerDependencies: @@ -15744,7 +15994,7 @@ snapshots: - aws-crt - encoding - '@openzeppelin/defender-sdk-deploy-client@2.7.0(encoding@0.1.13)': + '@openzeppelin/defender-sdk-deploy-client@2.7.0(debug@4.4.3)(encoding@0.1.13)': dependencies: '@openzeppelin/defender-sdk-base-client': 2.7.0(encoding@0.1.13) axios: 1.12.2(debug@4.4.3) @@ -15754,7 +16004,7 @@ snapshots: - debug - encoding - '@openzeppelin/defender-sdk-network-client@2.7.0(encoding@0.1.13)': + '@openzeppelin/defender-sdk-network-client@2.7.0(debug@4.4.3)(encoding@0.1.13)': dependencies: '@openzeppelin/defender-sdk-base-client': 2.7.0(encoding@0.1.13) axios: 1.12.2(debug@4.4.3) @@ -15785,6 +16035,27 @@ snapshots: - encoding - supports-color + '@openzeppelin/hardhat-upgrades@3.9.1(@nomicfoundation/hardhat-ethers@3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(@nomicfoundation/hardhat-verify@2.1.1(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)))(encoding@0.1.13)(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))': + dependencies: + '@nomicfoundation/hardhat-ethers': 3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@openzeppelin/defender-sdk-base-client': 2.7.0(encoding@0.1.13) + '@openzeppelin/defender-sdk-deploy-client': 2.7.0(debug@4.4.3)(encoding@0.1.13) + '@openzeppelin/defender-sdk-network-client': 2.7.0(debug@4.4.3)(encoding@0.1.13) + '@openzeppelin/upgrades-core': 1.44.1 + chalk: 4.1.2 + debug: 4.4.3(supports-color@9.4.0) + ethereumjs-util: 7.1.5 + ethers: 6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + hardhat: 2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10) + proper-lockfile: 4.1.2 + undici: 6.22.0 + optionalDependencies: + '@nomicfoundation/hardhat-verify': 2.1.1(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.14)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + transitivePeerDependencies: + - aws-crt + - encoding + - supports-color + '@openzeppelin/platform-deploy-client@0.8.0(debug@4.4.3)(encoding@0.1.13)': dependencies: '@ethersproject/abi': 5.8.0 @@ -26240,6 +26511,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + undici@6.22.0: {} + unfetch@4.2.0: {} unicorn-magic@0.1.0: {}