From 8c08f3a63b4f0f766501b8dead917729fed5f82e Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Sun, 16 Nov 2025 20:45:52 +0000 Subject: [PATCH 1/2] feat: baseline issuance package Cherry-picked from ec0c9841 (issuance-baseline-2/3) Rebased onto main with regenerated lockfile --- .../contracts/rewards/RewardsManager.sol | 144 +++- .../rewards/RewardsManagerStorage.sol | 15 + .../contracts/contracts/tests/MockERC165.sol | 20 + .../contracts/tests/MockIssuanceAllocator.sol | 76 ++ .../tests/MockRewardsEligibilityOracle.sol | 71 ++ .../contracts/tests/MockSubgraphService.sol | 105 +++ packages/contracts/test/.solcover.js | 2 +- .../unit/rewards/rewards-calculations.test.ts | 389 ++++++++++ .../tests/unit/rewards/rewards-config.test.ts | 158 ++++ .../unit/rewards/rewards-distribution.test.ts | 708 ++++++++++++++++++ .../rewards-eligibility-oracle.test.ts | 496 ++++++++++++ .../unit/rewards/rewards-interface.test.ts | 116 +++ .../rewards-issuance-allocator.test.ts | 416 ++++++++++ .../rewards/rewards-subgraph-service.test.ts | 468 ++++++++++++ .../contracts/rewards/IRewardsManager.sol | 13 + .../IIssuanceAllocationDistribution.sol | 33 + .../allocate/IIssuanceAllocatorTypes.sol | 18 + .../issuance/allocate/IIssuanceTarget.sol | 27 + .../issuance/common/IPausableControl.sol | 34 + .../eligibility/IRewardsEligibility.sol | 19 + packages/issuance/.markdownlint.json | 3 + packages/issuance/.solcover.js | 15 + packages/issuance/.solhint.json | 3 + packages/issuance/README.md | 62 ++ .../contracts/common/BaseUpgradeable.sol | 159 ++++ packages/issuance/hardhat.base.config.ts | 24 + packages/issuance/hardhat.config.ts | 26 + packages/issuance/hardhat.coverage.config.ts | 22 + packages/issuance/package.json | 79 ++ packages/issuance/prettier.config.cjs | 5 + packages/issuance/test/package.json | 62 ++ packages/issuance/test/prettier.config.cjs | 5 + packages/issuance/test/src/index.ts | 5 + .../common/CommonInterfaceIdStability.test.ts | 27 + .../issuance/test/tests/common/fixtures.ts | 127 ++++ .../test/tests/common/graphTokenHelper.ts | 91 +++ .../test/tests/common/testPatterns.ts | 52 ++ packages/issuance/test/tsconfig.json | 25 + packages/issuance/tsconfig.json | 18 + pnpm-lock.yaml | 287 ++++++- 40 files changed, 4411 insertions(+), 14 deletions(-) create mode 100644 packages/contracts/contracts/tests/MockERC165.sol create mode 100644 packages/contracts/contracts/tests/MockIssuanceAllocator.sol create mode 100644 packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol create mode 100644 packages/contracts/contracts/tests/MockSubgraphService.sol create mode 100644 packages/contracts/test/tests/unit/rewards/rewards-calculations.test.ts create mode 100644 packages/contracts/test/tests/unit/rewards/rewards-config.test.ts create mode 100644 packages/contracts/test/tests/unit/rewards/rewards-distribution.test.ts create mode 100644 packages/contracts/test/tests/unit/rewards/rewards-eligibility-oracle.test.ts create mode 100644 packages/contracts/test/tests/unit/rewards/rewards-interface.test.ts create mode 100644 packages/contracts/test/tests/unit/rewards/rewards-issuance-allocator.test.ts create mode 100644 packages/contracts/test/tests/unit/rewards/rewards-subgraph-service.test.ts create mode 100644 packages/interfaces/contracts/issuance/allocate/IIssuanceAllocationDistribution.sol create mode 100644 packages/interfaces/contracts/issuance/allocate/IIssuanceAllocatorTypes.sol create mode 100644 packages/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol create mode 100644 packages/interfaces/contracts/issuance/common/IPausableControl.sol create mode 100644 packages/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol create mode 100644 packages/issuance/.markdownlint.json create mode 100644 packages/issuance/.solcover.js create mode 100644 packages/issuance/.solhint.json create mode 100644 packages/issuance/README.md create mode 100644 packages/issuance/contracts/common/BaseUpgradeable.sol create mode 100644 packages/issuance/hardhat.base.config.ts create mode 100644 packages/issuance/hardhat.config.ts create mode 100644 packages/issuance/hardhat.coverage.config.ts create mode 100644 packages/issuance/package.json create mode 100644 packages/issuance/prettier.config.cjs create mode 100644 packages/issuance/test/package.json create mode 100644 packages/issuance/test/prettier.config.cjs create mode 100644 packages/issuance/test/src/index.ts create mode 100644 packages/issuance/test/tests/common/CommonInterfaceIdStability.test.ts create mode 100644 packages/issuance/test/tests/common/fixtures.ts create mode 100644 packages/issuance/test/tests/common/graphTokenHelper.ts create mode 100644 packages/issuance/test/tests/common/testPatterns.ts create mode 100644 packages/issuance/test/tsconfig.json create mode 100644 packages/issuance/tsconfig.json 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: {} From fec6aa724a9e8891d30254255a241d81596a8e29 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Wed, 22 Oct 2025 20:34:06 +0000 Subject: [PATCH 2/2] feat: indexer rewards eligibility oracle --- .../IRewardsEligibilityAdministration.sol | 37 + .../eligibility/IRewardsEligibilityEvents.sol | 34 + .../IRewardsEligibilityReporting.sol | 21 + .../eligibility/IRewardsEligibilityStatus.sol | 42 ++ .../eligibility/RewardsEligibilityOracle.md | 197 +++++ .../eligibility/RewardsEligibilityOracle.sol | 272 +++++++ .../tests/eligibility/AccessControl.test.ts | 159 +++++ .../eligibility/InterfaceCompliance.test.ts | 51 ++ .../eligibility/InterfaceIdStability.test.ts | 40 ++ .../RewardsEligibilityOracle.test.ts | 670 ++++++++++++++++++ .../test/tests/eligibility/fixtures.ts | 51 ++ 11 files changed, 1574 insertions(+) create mode 100644 packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityAdministration.sol create mode 100644 packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityEvents.sol create mode 100644 packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityReporting.sol create mode 100644 packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityStatus.sol create mode 100644 packages/issuance/contracts/eligibility/RewardsEligibilityOracle.md create mode 100644 packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol create mode 100644 packages/issuance/test/tests/eligibility/AccessControl.test.ts create mode 100644 packages/issuance/test/tests/eligibility/InterfaceCompliance.test.ts create mode 100644 packages/issuance/test/tests/eligibility/InterfaceIdStability.test.ts create mode 100644 packages/issuance/test/tests/eligibility/RewardsEligibilityOracle.test.ts create mode 100644 packages/issuance/test/tests/eligibility/fixtures.ts diff --git a/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityAdministration.sol b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityAdministration.sol new file mode 100644 index 000000000..e8fc2423f --- /dev/null +++ b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityAdministration.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; + +import { IRewardsEligibilityEvents } from "./IRewardsEligibilityEvents.sol"; + +/** + * @title IRewardsEligibilityAdministration + * @author Edge & Node + * @notice Interface for administrative operations on rewards eligibility + * @dev Functions in this interface are restricted to accounts with OPERATOR_ROLE + */ +interface IRewardsEligibilityAdministration is IRewardsEligibilityEvents { + /** + * @notice Set the eligibility period for indexers + * @dev Only callable by accounts with the OPERATOR_ROLE + * @param eligibilityPeriod New eligibility period in seconds + * @return True if the state is as requested (eligibility period is set to the specified value) + */ + function setEligibilityPeriod(uint256 eligibilityPeriod) external returns (bool); + + /** + * @notice Set the oracle update timeout + * @dev Only callable by accounts with the OPERATOR_ROLE + * @param oracleUpdateTimeout New timeout period in seconds + * @return True if the state is as requested (timeout is set to the specified value) + */ + function setOracleUpdateTimeout(uint256 oracleUpdateTimeout) external returns (bool); + + /** + * @notice Set eligibility validation state + * @dev Only callable by accounts with the OPERATOR_ROLE + * @param enabled True to enable eligibility validation, false to disable + * @return True if successfully set (always the case for current code) + */ + function setEligibilityValidation(bool enabled) external returns (bool); +} diff --git a/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityEvents.sol b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityEvents.sol new file mode 100644 index 000000000..f2214ecb3 --- /dev/null +++ b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityEvents.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; + +/** + * @title IRewardsEligibilityEvents + * @author Edge & Node + * @notice Shared events for rewards eligibility interfaces + */ +interface IRewardsEligibilityEvents { + /// @notice Emitted when an oracle submits eligibility data + /// @param oracle The address of the oracle that submitted the data + /// @param data The eligibility data submitted by the oracle + event IndexerEligibilityData(address indexed oracle, bytes data); + + /// @notice Emitted when an indexer's eligibility is renewed by an oracle + /// @param indexer The address of the indexer whose eligibility was renewed + /// @param oracle The address of the oracle that renewed the indexer's eligibility + event IndexerEligibilityRenewed(address indexed indexer, address indexed oracle); + + /// @notice Emitted when the eligibility period is updated + /// @param oldPeriod The previous eligibility period in seconds + /// @param newPeriod The new eligibility period in seconds + event EligibilityPeriodUpdated(uint256 indexed oldPeriod, uint256 indexed newPeriod); + + /// @notice Emitted when eligibility validation is enabled or disabled + /// @param enabled True if eligibility validation is enabled, false if disabled + event EligibilityValidationUpdated(bool indexed enabled); + + /// @notice Emitted when the oracle update timeout is updated + /// @param oldTimeout The previous timeout period in seconds + /// @param newTimeout The new timeout period in seconds + event OracleUpdateTimeoutUpdated(uint256 indexed oldTimeout, uint256 indexed newTimeout); +} diff --git a/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityReporting.sol b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityReporting.sol new file mode 100644 index 000000000..d702e8b78 --- /dev/null +++ b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityReporting.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; + +import { IRewardsEligibilityEvents } from "./IRewardsEligibilityEvents.sol"; + +/** + * @title IRewardsEligibilityReporting + * @author Edge & Node + * @notice Interface for oracle reporting of indexer eligibility + * @dev Functions in this interface are restricted to accounts with ORACLE_ROLE + */ +interface IRewardsEligibilityReporting is IRewardsEligibilityEvents { + /** + * @notice Renew eligibility for provided indexers to receive rewards + * @param indexers Array of indexer addresses. Zero addresses are ignored. + * @param data Arbitrary calldata for future extensions + * @return Number of indexers whose eligibility renewal timestamp was updated + */ + function renewIndexerEligibility(address[] calldata indexers, bytes calldata data) external returns (uint256); +} diff --git a/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityStatus.sol b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityStatus.sol new file mode 100644 index 000000000..d088e8168 --- /dev/null +++ b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityStatus.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; + +/** + * @title IRewardsEligibilityStatus + * @author Edge & Node + * @notice Interface for querying rewards eligibility status and configuration + * @dev All functions are view-only and can be called by anyone + */ +interface IRewardsEligibilityStatus { + /** + * @notice Get the last eligibility renewal timestamp for an indexer + * @param indexer Address of the indexer + * @return The last eligibility renewal timestamp, or 0 if the indexer's eligibility has never been renewed + */ + function getEligibilityRenewalTime(address indexer) external view returns (uint256); + + /** + * @notice Get the eligibility period + * @return The current eligibility period in seconds + */ + function getEligibilityPeriod() external view returns (uint256); + + /** + * @notice Get the oracle update timeout + * @return The current oracle update timeout in seconds + */ + function getOracleUpdateTimeout() external view returns (uint256); + + /** + * @notice Get the last oracle update time + * @return The timestamp of the last oracle update + */ + function getLastOracleUpdateTime() external view returns (uint256); + + /** + * @notice Get eligibility validation state + * @return True if eligibility validation is enabled, false otherwise + */ + function getEligibilityValidation() external view returns (bool); +} diff --git a/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.md b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.md new file mode 100644 index 000000000..8e0a07eeb --- /dev/null +++ b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.md @@ -0,0 +1,197 @@ +# RewardsEligibilityOracle + +The RewardsEligibilityOracle is a smart contract that manages indexer eligibility for receiving rewards. It implements a time-based eligibility system where indexers must be explicitly marked as eligible by authorized oracles to receive rewards. + +## Overview + +The contract operates on a "deny by default" principle - all indexers are initially ineligible for rewards until their eligibility is explicitly renewed by an authorized oracle. Once eligibility is renewed, indexers remain eligible for a configurable period before their eligibility expires and needs to be renewed again. + +## Key Features + +- **Time-based Eligibility**: Indexers are eligible for a configurable period (default: 14 days) +- **Oracle-based Renewal**: Only authorized oracles can renew indexer eligibility +- **Global Toggle**: Eligibility validation can be globally enabled/disabled +- **Timeout Mechanism**: If oracles don't update for too long, all indexers are automatically eligible +- **Role-based Access Control**: Uses hierarchical roles for governance and operations + +## Architecture + +### Roles + +The contract uses four main roles: + +- **GOVERNOR_ROLE**: Can grant/revoke operator roles and perform governance actions +- **OPERATOR_ROLE**: Can configure contract parameters and manage oracle roles +- **ORACLE_ROLE**: Can approve indexers for rewards +- **PAUSE_ROLE**: Can pause contract operations (inherited from BaseUpgradeable) + +### Storage + +The contract uses ERC-7201 namespaced storage to prevent storage collisions in upgradeable contracts: + +- `indexerEligibilityTimestamps`: Maps indexer addresses to their last eligibility timestamp +- `eligibilityPeriod`: Duration (in seconds) for which eligibility lasts (default: 14 days) +- `eligibilityValidationEnabled`: Global flag to enable/disable eligibility validation (default: false, to be enabled by operator when ready) +- `oracleUpdateTimeout`: Timeout after which all indexers are automatically eligible (default: 7 days) +- `lastOracleUpdateTime`: Timestamp of the last oracle update + +## Core Functions + +### Oracle Management + +Oracle roles are managed through the standard AccessControl functions inherited from BaseUpgradeable: + +- **`grantRole(bytes32 role, address account)`**: Grant oracle privileges to an account (OPERATOR_ROLE only) +- **`revokeRole(bytes32 role, address account)`**: Revoke oracle privileges from an account (OPERATOR_ROLE only) +- **`hasRole(bytes32 role, address account)`**: Check if an account has oracle privileges + +The `ORACLE_ROLE` constant can be used as the role parameter for these functions. + +### Configuration + +#### `setEligibilityPeriod(uint256 eligibilityPeriod) → bool` + +- **Access**: OPERATOR_ROLE only +- **Purpose**: Set how long indexer eligibility lasts +- **Parameters**: `eligibilityPeriod` - Duration in seconds +- **Returns**: Always true for current implementation +- **Events**: Emits `EligibilityPeriodUpdated` if value changes + +#### `setOracleUpdateTimeout(uint256 oracleUpdateTimeout) → bool` + +- **Access**: OPERATOR_ROLE only +- **Purpose**: Set timeout after which all indexers are automatically eligible +- **Parameters**: `oracleUpdateTimeout` - Timeout duration in seconds +- **Returns**: Always true for current implementation +- **Events**: Emits `OracleUpdateTimeoutUpdated` if value changes + +#### `setEligibilityValidation(bool enabled) → bool` + +- **Access**: OPERATOR_ROLE only +- **Purpose**: Enable or disable eligibility validation globally +- **Parameters**: `enabled` - True to enable, false to disable +- **Returns**: Always true for current implementation +- **Events**: Emits `EligibilityValidationUpdated` if state changes + +### Indexer Management + +#### `renewIndexerEligibility(address[] calldata indexers, bytes calldata data) → uint256` + +- **Access**: ORACLE_ROLE only +- **Purpose**: Renew eligibility for indexers to receive rewards +- **Parameters**: + - `indexers` - Array of indexer addresses (zero addresses ignored) + - `data` - Arbitrary calldata for future extensions +- **Returns**: Number of indexers whose eligibility renewal timestamp was updated +- **Events**: + - Emits `IndexerEligibilityData` with oracle and data + - Emits `IndexerEligibilityRenewed` for each indexer whose eligibility was renewed +- **Notes**: + - Updates `lastOracleUpdateTime` to current block timestamp + - Only updates timestamp if less than current block timestamp + - Ignores zero addresses and duplicate updates within same block + +### View Functions + +#### `isEligible(address indexer) → bool` + +- **Purpose**: Check if an indexer is eligible for rewards +- **Logic**: + 1. If eligibility validation is disabled → return true + 2. If oracle timeout exceeded → return true + 3. Otherwise → check if indexer's eligibility is still valid +- **Returns**: True if indexer is eligible, false otherwise + +#### `getEligibilityRenewalTime(address indexer) → uint256` + +- **Purpose**: Get the timestamp when indexer's eligibility was last renewed +- **Returns**: Timestamp or 0 if eligibility was never renewed + +#### `getEligibilityPeriod() → uint256` + +- **Purpose**: Get the current eligibility period +- **Returns**: Duration in seconds + +#### `getOracleUpdateTimeout() → uint256` + +- **Purpose**: Get the current oracle update timeout +- **Returns**: Duration in seconds + +#### `getLastOracleUpdateTime() → uint256` + +- **Purpose**: Get when oracles last updated +- **Returns**: Timestamp of last oracle update + +#### `getEligibilityValidation() → bool` + +- **Purpose**: Get eligibility validation state +- **Returns**: True if enabled, false if disabled + +## Eligibility Logic + +An indexer is considered eligible if ANY of the following conditions are met: + +1. **Valid eligibility** (`block.timestamp < indexerEligibilityTimestamps[indexer] + eligibilityPeriod`) +2. **Oracle timeout exceeded** (`lastOracleUpdateTime + oracleUpdateTimeout < block.timestamp`) +3. **Eligibility validation is disabled** (`eligibilityValidationEnabled = false`) + +This design ensures that: + +- The system fails open if oracles stop updating +- Operators can disable eligibility validation entirely if needed +- Individual indexer eligibility has time limits + +In normal operation, the first condition is expected to be the only one that applies. The other two conditions provide fail-safes for oracle failures, or in extreme cases an operator override. For normal operational failure of oracles, the system gracefully degrades into a "allow all" mode. This mechanism is not perfect in that oracles could still be updating but allowing far fewer indexers than they should. However this is regarded as simple mechanism that is good enough to start with and provide a foundation for future improvements and decentralization. + +While this simple model allows the criteria for providing good service to evolve over time (which is essential for the long-term health of the network), it captures sufficient information on-chain for indexers to be able to monitor their eligibility. This is important to ensure that even in the absence of other sources of information regarding observed indexer service, indexers have a good transparency about if they are being observed to be providing good service, and for how long their current approval is valid. + +It might initially seem safer to allow indexers by default unless an oracle explicitly denies an indexer. While that might seem safer from the perspective of the RewardsEligibilityOracle in isolation, in the absence of a more sophisticated voting system it would make the system vulnerable to a single bad oracle denying many indexers. The design of deny by default is better suited to allowing redundant oracles to be working in parallel, where only one needs to be successfully detecting indexers that are providing quality service, as well as eventually allowing different oracles to have different approval criteria and/or inputs. Therefore deny by default facilitates a more resilient and open oracle system that is less vulnerable to a single points of failure, and more open to increasing decentralization over time. + +In general to be rewarded for providing service on The Graph, there is expected to be proof provided of good operation (such as for proof of indexing). While proof should be required to receive rewards, the system is designed for participants to have confidence is being able to adequately prove good operation (and in the case of oracles, be seen by at least one observer) that is sufficient to allow the indexer to receive rewards. The oracle model is in general far more suited to collecting evidence of good operation, from multiple independent observers, rather than any observer being able to establish that an indexer is not providing good service. + +## Events + +```solidity +event IndexerEligibilityData(address indexed oracle, bytes data); +event IndexerEligibilityRenewed(address indexed indexer, address indexed oracle); +event EligibilityPeriodUpdated(uint256 indexed oldPeriod, uint256 indexed newPeriod); +event EligibilityValidationUpdated(bool indexed enabled); +event OracleUpdateTimeoutUpdated(uint256 indexed oldTimeout, uint256 indexed newTimeout); +``` + +## Default Configuration + +- **Eligibility Period**: 14 days (1,209,600 seconds) +- **Oracle Update Timeout**: 7 days (604,800 seconds) +- **Eligibility Validation**: Disabled (false) +- **Last Oracle Update Time**: 0 (never updated) + +The system is deployed with reasonable defaults but can be adjusted as required. Eligibility validation is disabled by default as the expectation is to first see oracles successfully marking indexers as eligible and having suitably established eligible indexers before enabling. + +## Usage Patterns + +### Initial Setup + +1. Deploy contract with Graph Token address +2. Initialize with governor address +3. Governor grants OPERATOR_ROLE to operational accounts +4. Operators grant ORACLE_ROLE to oracle services using `grantRole(ORACLE_ROLE, oracleAddress)` +5. Configure eligibility period and timeout as needed +6. After demonstration of successful oracle operation and having established indexers with renewed eligibility, eligibility checking is enabled + +### Normal Operation + +1. Oracles periodically call `renewIndexerEligibility()` to renew eligibility for indexers +2. Reward systems call `isEligible()` to check indexer eligibility +3. Operators adjust parameters as needed via configuration functions +4. The operation of the system is monitored and adjusted as needed + +### Emergency Scenarios + +- **Oracle failure**: System automatically reports all indexers as eligible after timeout +- **Eligibility issues**: Operators can disable eligibility checking globally +- **Parameter changes**: Operators can adjust periods and timeouts + +## Integration + +The contract implements four focused interfaces (`IRewardsEligibility`, `IRewardsEligibilityAdministration`, `IRewardsEligibilityReporting`, and `IRewardsEligibilityStatus`) and can be integrated with any system that needs to verify indexer eligibility status. The primary integration point is the `isEligible(address)` function which returns a simple boolean indicating eligibility. diff --git a/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol new file mode 100644 index 000000000..4b5d72acc --- /dev/null +++ b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity 0.8.27; + +import { IRewardsEligibility } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibility.sol"; +import { IRewardsEligibilityAdministration } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityAdministration.sol"; +import { IRewardsEligibilityReporting } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityReporting.sol"; +import { IRewardsEligibilityStatus } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityStatus.sol"; +import { BaseUpgradeable } from "../common/BaseUpgradeable.sol"; + +/** + * @title RewardsEligibilityOracle + * @author Edge & Node + * @notice This contract allows authorized oracles to mark indexers as eligible to receive rewards + * with an expiration mechanism. Indexers are denied by default until they are explicitly marked as eligible, + * and their eligibility expires after a configurable eligible period. + * The contract also includes a global eligibility check toggle and an oracle update timeout mechanism. + * @custom:security-contact Please email security+contracts@thegraph.com if you find any bugs. We might have an active bug bounty program. + */ +contract RewardsEligibilityOracle is + BaseUpgradeable, + IRewardsEligibility, + IRewardsEligibilityAdministration, + IRewardsEligibilityReporting, + IRewardsEligibilityStatus +{ + // -- Role Constants -- + + /** + * @notice Oracle role identifier + * @dev Oracle role holders can: + * - Mark indexers as eligible to receive rewards (based on off-chain quality assessment) + * This role is typically granted to automated quality assessment systems + * Admin: OPERATOR_ROLE (operators can manage oracle roles) + */ + bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE"); + // -- Namespaced Storage -- + + /// @notice ERC-7201 storage location for RewardsEligibilityOracle + bytes32 private constant REWARDS_ELIGIBILITY_ORACLE_STORAGE_LOCATION = + // Not needed for compile time calculation + // solhint-disable-next-line gas-small-strings + keccak256(abi.encode(uint256(keccak256("graphprotocol.storage.RewardsEligibilityOracle")) - 1)) & + ~bytes32(uint256(0xff)); + + /// @notice Main storage structure for RewardsEligibilityOracle using ERC-7201 namespaced storage + /// @param indexerEligibilityTimestamps Mapping of indexers to their eligibility renewal timestamps + /// @param eligibilityPeriod Period in seconds for which indexer eligibility status lasts + /// @param eligibilityValidationEnabled Flag to enable/disable eligibility validation + /// @param oracleUpdateTimeout Timeout period in seconds after which isEligible returns true if no oracle updates + /// @param lastOracleUpdateTime Timestamp of the last oracle update + /// @custom:storage-location erc7201:graphprotocol.storage.RewardsEligibilityOracle + struct RewardsEligibilityOracleData { + /// @dev Mapping of indexers to their eligibility renewal timestamps + mapping(address => uint256) indexerEligibilityTimestamps; + /// @dev Period in seconds for which indexer eligibility status lasts + uint256 eligibilityPeriod; + /// @dev Flag to enable/disable eligibility validation + bool eligibilityValidationEnabled; + /// @dev Timeout period in seconds after which isEligible returns true if no oracle updates + uint256 oracleUpdateTimeout; + /// @dev Timestamp of the last oracle update + uint256 lastOracleUpdateTime; + } + + /** + * @notice Returns the storage struct for RewardsEligibilityOracle + * @return $ contract storage + */ + function _getRewardsEligibilityOracleStorage() private pure returns (RewardsEligibilityOracleData storage $) { + // solhint-disable-previous-line use-natspec + // Solhint does not support $ return variable in natspec + bytes32 slot = REWARDS_ELIGIBILITY_ORACLE_STORAGE_LOCATION; + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := slot + } + } + + // -- Constructor -- + + /** + * @notice Constructor for the RewardsEligibilityOracle contract + * @dev This contract is upgradeable, but we use the constructor to pass the Graph Token address + * to the base contract. + * @param graphToken Address of the Graph Token contract + * @custom:oz-upgrades-unsafe-allow constructor + */ + constructor(address graphToken) BaseUpgradeable(graphToken) {} + + // -- Initialization -- + + /** + * @notice Initialize the RewardsEligibilityOracle contract + * @param governor Address that will have the GOVERNOR_ROLE + * @dev Also sets OPERATOR as admin of ORACLE role + */ + function initialize(address governor) external virtual initializer { + __BaseUpgradeable_init(governor); + + // OPERATOR is admin of ORACLE role + _setRoleAdmin(ORACLE_ROLE, OPERATOR_ROLE); + + // Set default values + RewardsEligibilityOracleData storage $ = _getRewardsEligibilityOracleStorage(); + $.eligibilityPeriod = 14 days; + $.oracleUpdateTimeout = 7 days; + $.eligibilityValidationEnabled = false; // Start with eligibility validation disabled, to be enabled later when the oracle is ready + } + + /** + * @notice Check if this contract supports a given interface + * @dev Overrides the supportsInterface function from ERC165Upgradeable + * @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(IRewardsEligibility).interfaceId || + interfaceId == type(IRewardsEligibilityAdministration).interfaceId || + interfaceId == type(IRewardsEligibilityReporting).interfaceId || + interfaceId == type(IRewardsEligibilityStatus).interfaceId || + super.supportsInterface(interfaceId); + } + + // -- Governance Functions -- + + /** + * @notice Set the eligibility period for indexers + * @dev Only callable by accounts with the OPERATOR_ROLE + * @param eligibilityPeriod New eligibility period in seconds + * @return True if the state is as requested (eligibility period is set to the specified value) + */ + function setEligibilityPeriod(uint256 eligibilityPeriod) external override onlyRole(OPERATOR_ROLE) returns (bool) { + RewardsEligibilityOracleData storage $ = _getRewardsEligibilityOracleStorage(); + uint256 oldEligibilityPeriod = $.eligibilityPeriod; + + if (eligibilityPeriod != oldEligibilityPeriod) { + $.eligibilityPeriod = eligibilityPeriod; + emit EligibilityPeriodUpdated(oldEligibilityPeriod, eligibilityPeriod); + } + + return true; + } + + /** + * @notice Set the oracle update timeout + * @dev Only callable by accounts with the OPERATOR_ROLE + * @param oracleUpdateTimeout New timeout period in seconds + * @return True if the state is as requested (timeout is set to the specified value) + */ + function setOracleUpdateTimeout( + uint256 oracleUpdateTimeout + ) external override onlyRole(OPERATOR_ROLE) returns (bool) { + RewardsEligibilityOracleData storage $ = _getRewardsEligibilityOracleStorage(); + uint256 oldTimeout = $.oracleUpdateTimeout; + + if (oracleUpdateTimeout != oldTimeout) { + $.oracleUpdateTimeout = oracleUpdateTimeout; + emit OracleUpdateTimeoutUpdated(oldTimeout, oracleUpdateTimeout); + } + + return true; + } + + /** + * @notice Set eligibility validation state + * @dev Only callable by accounts with the OPERATOR_ROLE + * @param enabled True to enable eligibility validation, false to disable + * @return True if successfully set (always the case for current code) + */ + function setEligibilityValidation(bool enabled) external override onlyRole(OPERATOR_ROLE) returns (bool) { + RewardsEligibilityOracleData storage $ = _getRewardsEligibilityOracleStorage(); + + if ($.eligibilityValidationEnabled != enabled) { + $.eligibilityValidationEnabled = enabled; + emit EligibilityValidationUpdated(enabled); + } + + return true; + } + + /** + * @notice Renew eligibility for provided indexers to receive rewards + * @param indexers Array of indexer addresses. Zero addresses are ignored. + * @param data Arbitrary calldata for future extensions + * @return Number of indexers whose eligibility renewal timestamp was updated + */ + function renewIndexerEligibility( + address[] calldata indexers, + bytes calldata data + ) external override onlyRole(ORACLE_ROLE) returns (uint256) { + emit IndexerEligibilityData(msg.sender, data); + + uint256 updatedCount = 0; + uint256 blockTimestamp = block.timestamp; + + RewardsEligibilityOracleData storage $ = _getRewardsEligibilityOracleStorage(); + $.lastOracleUpdateTime = blockTimestamp; + + // Update each indexer's eligible timestamp + for (uint256 i = 0; i < indexers.length; ++i) { + address indexer = indexers[i]; + + if (indexer != address(0) && $.indexerEligibilityTimestamps[indexer] < blockTimestamp) { + $.indexerEligibilityTimestamps[indexer] = blockTimestamp; + emit IndexerEligibilityRenewed(indexer, msg.sender); + ++updatedCount; + } + } + + return updatedCount; + } + + // -- View Functions -- + + /** + * @inheritdoc IRewardsEligibility + */ + function isEligible(address indexer) external view override returns (bool) { + RewardsEligibilityOracleData storage $ = _getRewardsEligibilityOracleStorage(); + + // If eligibility validation is disabled, treat all indexers as eligible + if (!$.eligibilityValidationEnabled) return true; + + // If no oracle updates have been made for oracleUpdateTimeout, treat all indexers as eligible + if ($.lastOracleUpdateTime + $.oracleUpdateTimeout < block.timestamp) return true; + + return block.timestamp < $.indexerEligibilityTimestamps[indexer] + $.eligibilityPeriod; + } + + /** + * @notice Get the last eligibility renewal timestamp for an indexer + * @param indexer Address of the indexer + * @return The last eligibility renewal timestamp, or 0 if the indexer's eligibility has never been renewed + */ + function getEligibilityRenewalTime(address indexer) external view override returns (uint256) { + return _getRewardsEligibilityOracleStorage().indexerEligibilityTimestamps[indexer]; + } + + /** + * @notice Get the eligibility period + * @return The current eligibility period in seconds + */ + function getEligibilityPeriod() external view override returns (uint256) { + return _getRewardsEligibilityOracleStorage().eligibilityPeriod; + } + + /** + * @notice Get the oracle update timeout + * @return The current oracle update timeout in seconds + */ + function getOracleUpdateTimeout() external view override returns (uint256) { + return _getRewardsEligibilityOracleStorage().oracleUpdateTimeout; + } + + /** + * @notice Get the last oracle update time + * @return The timestamp of the last oracle update + */ + function getLastOracleUpdateTime() external view override returns (uint256) { + return _getRewardsEligibilityOracleStorage().lastOracleUpdateTime; + } + + /** + * @notice Get eligibility validation state + * @return True if eligibility validation is enabled, false otherwise + */ + function getEligibilityValidation() external view override returns (bool) { + return _getRewardsEligibilityOracleStorage().eligibilityValidationEnabled; + } +} diff --git a/packages/issuance/test/tests/eligibility/AccessControl.test.ts b/packages/issuance/test/tests/eligibility/AccessControl.test.ts new file mode 100644 index 000000000..fe5301251 --- /dev/null +++ b/packages/issuance/test/tests/eligibility/AccessControl.test.ts @@ -0,0 +1,159 @@ +/** + * Eligibility Access Control Tests + * Tests access control patterns for RewardsEligibilityOracle contract + */ + +import { expect } from 'chai' + +import { deployTestGraphToken, getTestAccounts, SHARED_CONSTANTS } from '../common/fixtures' +import { deployRewardsEligibilityOracle } from './fixtures' + +describe('Eligibility Access Control Tests', () => { + let accounts: any + let contracts: any + + before(async () => { + accounts = await getTestAccounts() + + // Deploy eligibility contracts + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const rewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + contracts = { + graphToken, + rewardsEligibilityOracle, + } + }) + + describe('RewardsEligibilityOracle Access Control', () => { + describe('Role Management Methods', () => { + it('should enforce access control on role management methods', async () => { + // First grant governor the OPERATOR_ROLE so they can manage oracle roles + await contracts.rewardsEligibilityOracle + .connect(accounts.governor) + .grantRole(SHARED_CONSTANTS.OPERATOR_ROLE, accounts.governor.address) + + const methods = [ + { + method: 'grantRole', + args: [SHARED_CONSTANTS.ORACLE_ROLE, accounts.operator.address], + description: 'grantRole for ORACLE_ROLE', + }, + { + method: 'revokeRole', + args: [SHARED_CONSTANTS.ORACLE_ROLE, accounts.operator.address], + description: 'revokeRole for ORACLE_ROLE', + }, + ] + + for (const { method, args, description } of methods) { + // Test unauthorized access + await expect( + contracts.rewardsEligibilityOracle.connect(accounts.nonGovernor)[method](...args), + `${description} should revert for unauthorized account`, + ).to.be.revertedWithCustomError(contracts.rewardsEligibilityOracle, 'AccessControlUnauthorizedAccount') + + // Test authorized access + await expect( + contracts.rewardsEligibilityOracle.connect(accounts.governor)[method](...args), + `${description} should succeed for authorized account`, + ).to.not.be.reverted + } + }) + }) + + it('should require ORACLE_ROLE for renewIndexerEligibility', async () => { + // Setup: Grant governor OPERATOR_ROLE first, then grant oracle role + await contracts.rewardsEligibilityOracle + .connect(accounts.governor) + .grantRole(SHARED_CONSTANTS.OPERATOR_ROLE, accounts.governor.address) + await contracts.rewardsEligibilityOracle + .connect(accounts.governor) + .grantRole(SHARED_CONSTANTS.ORACLE_ROLE, accounts.operator.address) + + // Non-oracle should be rejected + await expect( + contracts.rewardsEligibilityOracle + .connect(accounts.nonGovernor) + .renewIndexerEligibility([accounts.nonGovernor.address], '0x'), + ).to.be.revertedWithCustomError(contracts.rewardsEligibilityOracle, 'AccessControlUnauthorizedAccount') + + // Oracle should be allowed + const hasRole = await contracts.rewardsEligibilityOracle.hasRole( + SHARED_CONSTANTS.ORACLE_ROLE, + accounts.operator.address, + ) + expect(hasRole).to.be.true + }) + + it('should require OPERATOR_ROLE for pause operations', async () => { + // Setup: Grant pause role to governor + await contracts.rewardsEligibilityOracle + .connect(accounts.governor) + .grantRole(SHARED_CONSTANTS.PAUSE_ROLE, accounts.governor.address) + + // Non-pause-role account should be rejected + await expect( + contracts.rewardsEligibilityOracle.connect(accounts.nonGovernor).pause(), + ).to.be.revertedWithCustomError(contracts.rewardsEligibilityOracle, 'AccessControlUnauthorizedAccount') + await expect( + contracts.rewardsEligibilityOracle.connect(accounts.nonGovernor).unpause(), + ).to.be.revertedWithCustomError(contracts.rewardsEligibilityOracle, 'AccessControlUnauthorizedAccount') + + // PAUSE_ROLE account should be allowed to pause + await expect(contracts.rewardsEligibilityOracle.connect(accounts.governor).pause()).to.not.be.reverted + + // PAUSE_ROLE account should be allowed to unpause + await expect(contracts.rewardsEligibilityOracle.connect(accounts.governor).unpause()).to.not.be.reverted + }) + + it('should require OPERATOR_ROLE for configuration methods', async () => { + // Test all operator-only configuration methods + const operatorOnlyMethods = [ + { + call: () => + contracts.rewardsEligibilityOracle.connect(accounts.nonGovernor).setEligibilityPeriod(14 * 24 * 60 * 60), + name: 'setEligibilityPeriod', + }, + { + call: () => + contracts.rewardsEligibilityOracle.connect(accounts.nonGovernor).setOracleUpdateTimeout(60 * 24 * 60 * 60), + name: 'setOracleUpdateTimeout', + }, + { + call: () => contracts.rewardsEligibilityOracle.connect(accounts.nonGovernor).setEligibilityValidation(false), + name: 'setEligibilityValidation(false)', + }, + { + call: () => contracts.rewardsEligibilityOracle.connect(accounts.nonGovernor).setEligibilityValidation(true), + name: 'setEligibilityValidation(true)', + }, + ] + + // Test all methods in sequence + for (const method of operatorOnlyMethods) { + await expect(method.call()).to.be.revertedWithCustomError( + contracts.rewardsEligibilityOracle, + 'AccessControlUnauthorizedAccount', + ) + } + }) + }) + + describe('Role Management Consistency', () => { + it('should have consistent GOVERNOR_ROLE for eligibility contracts', async () => { + const governorRole = SHARED_CONSTANTS.GOVERNOR_ROLE + + // RewardsEligibilityOracle should recognize the governor + expect(await contracts.rewardsEligibilityOracle.hasRole(governorRole, accounts.governor.address)).to.be.true + }) + + it('should have correct role admin hierarchy', async () => { + const governorRole = SHARED_CONSTANTS.GOVERNOR_ROLE + + // GOVERNOR_ROLE should be admin of itself (allowing governors to manage other governors) + expect(await contracts.rewardsEligibilityOracle.getRoleAdmin(governorRole)).to.equal(governorRole) + }) + }) +}) diff --git a/packages/issuance/test/tests/eligibility/InterfaceCompliance.test.ts b/packages/issuance/test/tests/eligibility/InterfaceCompliance.test.ts new file mode 100644 index 000000000..1e721a9d9 --- /dev/null +++ b/packages/issuance/test/tests/eligibility/InterfaceCompliance.test.ts @@ -0,0 +1,51 @@ +// Import Typechain-generated factories with interface metadata (interfaceId and interfaceName) +import { + IPausableControl__factory, + IRewardsEligibility__factory, + IRewardsEligibilityAdministration__factory, + IRewardsEligibilityReporting__factory, + IRewardsEligibilityStatus__factory, +} from '@graphprotocol/interfaces/types' +import { IAccessControl__factory } from '@graphprotocol/issuance/types' + +import { deployTestGraphToken, getTestAccounts } from '../common/fixtures' +import { shouldSupportInterfaces } from '../common/testPatterns' +import { deployRewardsEligibilityOracle } from './fixtures' + +/** + * Eligibility ERC-165 Interface Compliance Tests + * Tests interface support for RewardsEligibilityOracle contract + */ +describe('Eligibility ERC-165 Interface Compliance', () => { + let accounts: any + let contracts: any + + before(async () => { + accounts = await getTestAccounts() + + // Deploy eligibility contracts for interface testing + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + + const rewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + contracts = { + rewardsEligibilityOracle, + } + }) + + describe( + 'RewardsEligibilityOracle Interface Compliance', + shouldSupportInterfaces( + () => contracts.rewardsEligibilityOracle, + [ + IRewardsEligibility__factory, + IRewardsEligibilityAdministration__factory, + IRewardsEligibilityReporting__factory, + IRewardsEligibilityStatus__factory, + IPausableControl__factory, + IAccessControl__factory, + ], + ), + ) +}) diff --git a/packages/issuance/test/tests/eligibility/InterfaceIdStability.test.ts b/packages/issuance/test/tests/eligibility/InterfaceIdStability.test.ts new file mode 100644 index 000000000..23cf6e025 --- /dev/null +++ b/packages/issuance/test/tests/eligibility/InterfaceIdStability.test.ts @@ -0,0 +1,40 @@ +import { + IRewardsEligibility__factory, + IRewardsEligibilityAdministration__factory, + IRewardsEligibilityReporting__factory, + IRewardsEligibilityStatus__factory, +} from '@graphprotocol/interfaces/types' +import { expect } from 'chai' + +/** + * Eligibility Interface ID Stability Tests + * + * These tests verify that eligibility-specific 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 + * + * Note: Common interfaces (IPausableControl, IAccessControl) are tested in + * CommonInterfaceIdStability.test.ts at the root level. + */ +describe('Eligibility Interface ID Stability', () => { + it('IRewardsEligibility should have stable interface ID', () => { + expect(IRewardsEligibility__factory.interfaceId).to.equal('0x66e305fd') + }) + + it('IRewardsEligibilityAdministration should have stable interface ID', () => { + expect(IRewardsEligibilityAdministration__factory.interfaceId).to.equal('0x9a69f6aa') + }) + + it('IRewardsEligibilityReporting should have stable interface ID', () => { + expect(IRewardsEligibilityReporting__factory.interfaceId).to.equal('0x38b7c077') + }) + + it('IRewardsEligibilityStatus should have stable interface ID', () => { + expect(IRewardsEligibilityStatus__factory.interfaceId).to.equal('0x53740f19') + }) +}) diff --git a/packages/issuance/test/tests/eligibility/RewardsEligibilityOracle.test.ts b/packages/issuance/test/tests/eligibility/RewardsEligibilityOracle.test.ts new file mode 100644 index 000000000..9f5b585a5 --- /dev/null +++ b/packages/issuance/test/tests/eligibility/RewardsEligibilityOracle.test.ts @@ -0,0 +1,670 @@ +import '@nomicfoundation/hardhat-chai-matchers' + +import { time } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import hre from 'hardhat' + +const { ethers } = hre +const { upgrades } = require('hardhat') + +import type { RewardsEligibilityOracle } from '@graphprotocol/issuance/types' + +import { deployTestGraphToken, getTestAccounts, SHARED_CONSTANTS } from '../common/fixtures' +import { deployRewardsEligibilityOracle } from './fixtures' + +// Role constants +const GOVERNOR_ROLE = SHARED_CONSTANTS.GOVERNOR_ROLE +const ORACLE_ROLE = SHARED_CONSTANTS.ORACLE_ROLE +const OPERATOR_ROLE = SHARED_CONSTANTS.OPERATOR_ROLE + +// Types +interface SharedContracts { + graphToken: any + rewardsEligibilityOracle: RewardsEligibilityOracle + addresses: { + graphToken: string + rewardsEligibilityOracle: string + } +} + +describe('RewardsEligibilityOracle', () => { + // Common variables + let accounts: any + let sharedContracts: SharedContracts + + before(async () => { + accounts = await getTestAccounts() + + // Deploy shared contracts once + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const rewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + const rewardsEligibilityOracleAddress = await rewardsEligibilityOracle.getAddress() + + sharedContracts = { + graphToken, + rewardsEligibilityOracle, + addresses: { + graphToken: graphTokenAddress, + rewardsEligibilityOracle: rewardsEligibilityOracleAddress, + }, + } + }) + + // Fast state reset function + async function resetOracleState() { + if (!sharedContracts) return + + const { rewardsEligibilityOracle } = sharedContracts + + // Remove oracle roles from all accounts + try { + for (const account of [accounts.operator, accounts.user, accounts.nonGovernor]) { + if (await rewardsEligibilityOracle.hasRole(ORACLE_ROLE, account.address)) { + await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(ORACLE_ROLE, account.address) + } + if (await rewardsEligibilityOracle.hasRole(OPERATOR_ROLE, account.address)) { + await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(OPERATOR_ROLE, account.address) + } + } + + // Remove operator role from governor if present + if (await rewardsEligibilityOracle.hasRole(OPERATOR_ROLE, accounts.governor.address)) { + await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(OPERATOR_ROLE, accounts.governor.address) + } + } catch { + // Role management errors during reset are non-fatal and may occur if roles are already revoked or not present. + // These errors are expected and can be safely ignored. + } + + // Reset to default values + try { + // Reset eligibility period to default (14 days) + const defaultEligibilityPeriod = 14 * 24 * 60 * 60 + const currentEligibilityPeriod = await rewardsEligibilityOracle.getEligibilityPeriod() + if (currentEligibilityPeriod !== BigInt(defaultEligibilityPeriod)) { + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.governor.address) + await rewardsEligibilityOracle.connect(accounts.governor).setEligibilityPeriod(defaultEligibilityPeriod) + await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(OPERATOR_ROLE, accounts.governor.address) + } + + // Reset eligibility validation to disabled + if (await rewardsEligibilityOracle.getEligibilityValidation()) { + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.governor.address) + await rewardsEligibilityOracle.connect(accounts.governor).setEligibilityValidation(false) + await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(OPERATOR_ROLE, accounts.governor.address) + } + + // Reset oracle update timeout to default (7 days) + const defaultTimeout = 7 * 24 * 60 * 60 + const currentTimeout = await rewardsEligibilityOracle.getOracleUpdateTimeout() + if (currentTimeout !== BigInt(defaultTimeout)) { + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.governor.address) + await rewardsEligibilityOracle.connect(accounts.governor).setOracleUpdateTimeout(defaultTimeout) + await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(OPERATOR_ROLE, accounts.governor.address) + } + } catch { + // Ignore reset errors + } + } + + beforeEach(async () => { + if (!accounts) { + accounts = await getTestAccounts() + } + await resetOracleState() + }) + + describe('Construction', () => { + it('should revert when constructed with zero GraphToken address', async () => { + const RewardsEligibilityOracleFactory = await ethers.getContractFactory('RewardsEligibilityOracle') + await expect(RewardsEligibilityOracleFactory.deploy(ethers.ZeroAddress)).to.be.revertedWithCustomError( + RewardsEligibilityOracleFactory, + 'GraphTokenCannotBeZeroAddress', + ) + }) + + it('should revert when initialized with zero governor address', async () => { + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + + // Try to deploy proxy with zero governor address - this should hit the BaseUpgradeable check + const RewardsEligibilityOracleFactory = await ethers.getContractFactory('RewardsEligibilityOracle') + await expect( + upgrades.deployProxy(RewardsEligibilityOracleFactory, [ethers.ZeroAddress], { + constructorArgs: [graphTokenAddress], + initializer: 'initialize', + }), + ).to.be.revertedWithCustomError(RewardsEligibilityOracleFactory, 'GovernorCannotBeZeroAddress') + }) + }) + + describe('Initialization', () => { + it('should set the governor role correctly', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.hasRole(GOVERNOR_ROLE, accounts.governor.address)).to.be.true + }) + + it('should not set oracle role to anyone initially', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.hasRole(ORACLE_ROLE, accounts.operator.address)).to.be.false + }) + + it('should set default eligibility period to 14 days', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.getEligibilityPeriod()).to.equal(14 * 24 * 60 * 60) // 14 days in seconds + }) + + it('should set eligibility validation to disabled by default', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.false + }) + + it('should set default oracle update timeout to 7 days', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.getOracleUpdateTimeout()).to.equal(7 * 24 * 60 * 60) // 7 days in seconds + }) + + it('should initialize lastOracleUpdateTime to 0', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.getLastOracleUpdateTime()).to.equal(0) + }) + + it('should revert when initialize is called more than once', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Try to call initialize again + await expect(rewardsEligibilityOracle.initialize(accounts.governor.address)).to.be.revertedWithCustomError( + rewardsEligibilityOracle, + 'InvalidInitialization', + ) + }) + }) + + describe('Oracle Management', () => { + it('should allow operator to grant oracle role', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role to the operator account + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + + // Operator grants oracle role + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.user.address) + expect(await rewardsEligibilityOracle.hasRole(ORACLE_ROLE, accounts.user.address)).to.be.true + }) + + it('should allow operator to revoke oracle role', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role to the operator account + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + + // Grant oracle role first + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.user.address) + expect(await rewardsEligibilityOracle.hasRole(ORACLE_ROLE, accounts.user.address)).to.be.true + + // Revoke role + await rewardsEligibilityOracle.connect(accounts.operator).revokeRole(ORACLE_ROLE, accounts.user.address) + expect(await rewardsEligibilityOracle.hasRole(ORACLE_ROLE, accounts.user.address)).to.be.false + }) + + // Access control tests moved to consolidated/AccessControl.test.ts + }) + + describe('Operator Functions', () => { + beforeEach(async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role to the operator account + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + }) + + it('should allow operator to set eligibility period', async () => { + const { rewardsEligibilityOracle } = sharedContracts + const newEligibilityPeriod = 14 * 24 * 60 * 60 // 14 days + + // Set eligibility period + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityPeriod(newEligibilityPeriod) + + // Check if eligibility period was updated + expect(await rewardsEligibilityOracle.getEligibilityPeriod()).to.equal(newEligibilityPeriod) + }) + + it('should handle idempotent operations correctly', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Test setting same eligibility period + const currentEligibilityPeriod = await rewardsEligibilityOracle.getEligibilityPeriod() + let result = await rewardsEligibilityOracle + .connect(accounts.operator) + .setEligibilityPeriod.staticCall(currentEligibilityPeriod) + expect(result).to.be.true + + // Verify no event emitted for same value + let tx = await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityPeriod(currentEligibilityPeriod) + let receipt = await tx.wait() + expect(receipt!.logs.length).to.equal(0) + + // Test setting new oracle update timeout + const newTimeout = 60 * 24 * 60 * 60 // 60 days + await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(newTimeout) + expect(await rewardsEligibilityOracle.getOracleUpdateTimeout()).to.equal(newTimeout) + + // Test setting same oracle update timeout + result = await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout.staticCall(newTimeout) + expect(result).to.be.true + + // Verify no event emitted for same value + tx = await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(newTimeout) + receipt = await tx.wait() + expect(receipt!.logs.length).to.equal(0) + }) + + it('should allow operator to disable eligibility checking', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Disable eligibility validation + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + + // Check if eligibility validation is disabled + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.false + }) + + it('should allow operator to enable eligibility checking', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Disable eligibility validation first + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.false + + // Enable eligibility validation + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // Check if eligibility validation is enabled + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.true + }) + + it('should handle setEligibilityValidation return values and events correctly', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Test 1: Return true when enabling eligibility validation that is already enabled + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.true + + const enableResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .setEligibilityValidation.staticCall(true) + expect(enableResult).to.be.true + + // Test 2: No event emitted when setting to same state (enabled) + const enableTx = await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + const enableReceipt = await enableTx.wait() + expect(enableReceipt!.logs.length).to.equal(0) + + // Test 3: Return true when disabling eligibility validation that is already disabled + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.false + + const disableResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .setEligibilityValidation.staticCall(false) + expect(disableResult).to.be.true + + // Test 4: No event emitted when setting to same state (disabled) + const disableTx = await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + const disableReceipt = await disableTx.wait() + expect(disableReceipt!.logs.length).to.equal(0) + + // Test 5: Events are emitted when state actually changes + await expect(rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true)) + .to.emit(rewardsEligibilityOracle, 'EligibilityValidationUpdated') + .withArgs(true) + + await expect(rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false)) + .to.emit(rewardsEligibilityOracle, 'EligibilityValidationUpdated') + .withArgs(false) + }) + + // Access control tests moved to consolidated/AccessControl.test.ts + // Event and return value tests consolidated into 'should handle setEligibilityValidation return values and events correctly' + }) + + describe('Indexer Management', () => { + beforeEach(async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role to the operator account + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + + // Grant oracle role + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + }) + + it('should allow oracle to allow a single indexer', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Renew indexer eligibility using renewIndexerEligibility with a single-element array + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Check if indexer is eligible + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + + // Check if allowed timestamp was updated + const eligibilityRenewalTime = await rewardsEligibilityOracle.getEligibilityRenewalTime(accounts.indexer1.address) + expect(eligibilityRenewalTime).to.be.gt(0) + }) + + it('should allow oracle to allow multiple indexers', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Allow multiple indexers + const indexers = [accounts.indexer1.address, accounts.indexer2.address] + await rewardsEligibilityOracle.connect(accounts.operator).renewIndexerEligibility(indexers, '0x') + + // Check if indexers are eligible + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer2.address)).to.be.true + + // Check if allowed timestamps were updated + const eligibilityRenewalTime1 = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + const eligibilityRenewalTime2 = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer2.address, + ) + expect(eligibilityRenewalTime1).to.be.gt(0) + expect(eligibilityRenewalTime2).to.be.gt(0) + }) + + it('should not update last renewal timestamp for indexer already renewed in the same block', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Renew indexer eligibility first time + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Get the timestamp + const initialEligibilityRenewalTime = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + + // Call renewIndexerEligibility again with the same indexer + const result = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall([accounts.indexer1.address], '0x') + + // The function should return 0 since the indexer was already allowed in this block + expect(result).to.equal(0) + + // Verify the timestamp hasn't changed + const finalEligibilityRenewalTime = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + expect(finalEligibilityRenewalTime).to.equal(initialEligibilityRenewalTime) + + // Mine a new block + await ethers.provider.send('evm_mine', []) + + // Now try again in a new block - it should return 1 + const newBlockResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall([accounts.indexer1.address], '0x') + + // The function should return 1 since we're in a new block + expect(newBlockResult).to.equal(1) + }) + + it('should revert when non-oracle tries to allow a single indexer', async () => { + const { rewardsEligibilityOracle } = sharedContracts + await expect( + rewardsEligibilityOracle + .connect(accounts.nonGovernor) + .renewIndexerEligibility([accounts.indexer1.address], '0x'), + ).to.be.revertedWithCustomError(rewardsEligibilityOracle, 'AccessControlUnauthorizedAccount') + }) + + it('should revert when non-oracle tries to allow multiple indexers', async () => { + const { rewardsEligibilityOracle } = sharedContracts + const indexers = [accounts.indexer1.address, accounts.indexer2.address] + await expect( + rewardsEligibilityOracle.connect(accounts.nonGovernor).renewIndexerEligibility(indexers, '0x'), + ).to.be.revertedWithCustomError(rewardsEligibilityOracle, 'AccessControlUnauthorizedAccount') + }) + + it('should return correct count for various renewIndexerEligibility scenarios', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Test 1: Single indexer should return 1 + const singleResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall([accounts.indexer1.address], '0x') + expect(singleResult).to.equal(1) + + // Test 2: Multiple indexers should return correct count + const multipleIndexers = [accounts.indexer1.address, accounts.indexer2.address] + const multipleResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall(multipleIndexers, '0x') + expect(multipleResult).to.equal(2) + + // Test 3: Empty array should return 0 + const emptyResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall([], '0x') + expect(emptyResult).to.equal(0) + + // Test 4: Array with zero addresses should only count non-zero addresses + const withZeroAddresses = [accounts.indexer1.address, ethers.ZeroAddress, accounts.indexer2.address] + const zeroResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall(withZeroAddresses, '0x') + expect(zeroResult).to.equal(2) + + // Test 5: Array with duplicates should only count unique indexers + const withDuplicates = [accounts.indexer1.address, accounts.indexer1.address, accounts.indexer2.address] + const duplicateResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall(withDuplicates, '0x') + expect(duplicateResult).to.equal(2) + }) + }) + + describe('View Functions', () => { + // Use shared contracts instead of deploying fresh ones for each test + + it('should return 0 when getting last renewal time for indexer that was never renewed', async () => { + // Use a fresh deployment to avoid contamination from previous tests + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const freshRewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // This should return 0 for a fresh contract + const lastEligibilityRenewalTime = await freshRewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + expect(lastEligibilityRenewalTime).to.equal(0) + }) + + it('should return correct last renewal timestamp for renewed indexer', async function () { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role first (governor can grant operator role) + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + // Then operator can grant oracle role (operator is admin of oracle role) + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Renew indexer eligibility + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Get the last allowed time + const lastEligibilityRenewalTime = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + + // Get the current block timestamp + const block = await ethers.provider.getBlock('latest') + const blockTimestamp = block ? block.timestamp : 0 + + // The last allowed time should be close to the current block timestamp + expect(lastEligibilityRenewalTime).to.be.closeTo(blockTimestamp, 5) // Allow 5 seconds of difference + }) + + it('should correctly report if an indexer is eligible', async function () { + // Use a fresh deployment to avoid shared state contamination + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const freshRewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // Grant necessary roles (follow role hierarchy) + await freshRewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await freshRewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await freshRewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // First, set a non-zero lastOracleUpdateTime to prevent the timeout condition from triggering + await freshRewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.nonGovernor.address], '0x') + + // Now check if our test indexer is eligible (it shouldn't be) + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + + // Renew indexer eligibility + await freshRewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + }) + + it('should return true for all indexers when eligibility checking is disabled', async function () { + // Use a fresh deployment to avoid shared state contamination + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const freshRewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // Grant necessary roles (follow role hierarchy) + await freshRewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await freshRewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await freshRewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // First, set a non-zero lastOracleUpdateTime to prevent the timeout condition from triggering + await freshRewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.nonGovernor.address], '0x') + + // Set a very long oracle update timeout to prevent that condition from triggering + await freshRewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(365 * 24 * 60 * 60) // 1 year + + // Now check if our test indexer is eligible (it shouldn't be) + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + + // Disable eligibility validation + await freshRewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + + // Now indexer should be allowed even without being explicitly allowed + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + }) + + it('should return true for all indexers when oracle update timeout is exceeded', async function () { + // Use a fresh deployment to avoid shared state contamination + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const freshRewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // Grant necessary roles (follow role hierarchy) + await freshRewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await freshRewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await freshRewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // First, set a non-zero lastOracleUpdateTime to prevent the initial timeout condition from triggering + await freshRewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.nonGovernor.address], '0x') + + // Set a very long oracle update timeout initially + await freshRewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(365 * 24 * 60 * 60) // 1 year + + // Now check if our test indexer is eligible (it shouldn't be) + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + + // Set a short oracle update timeout + await freshRewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(60) // 1 minute + + // Advance time beyond the timeout + await time.increase(120) // 2 minutes + + // Now indexer should be allowed even without being explicitly allowed + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + }) + + it('should return false for indexer after eligibility period expires', async function () { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant necessary roles (follow role hierarchy) + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // Set a very long oracle update timeout to prevent that condition from triggering + await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(365 * 24 * 60 * 60) // 1 year + + // Renew indexer eligibility + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + + // Set a short eligibility period + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityPeriod(60) // 1 minute + + // Advance time beyond eligibility period + await time.increase(120) // 2 minutes + + // Now indexer should not be allowed + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + }) + + it('should return true for indexer after re-allowing', async function () { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant necessary roles + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // Set a very long oracle update timeout to prevent that condition from triggering + await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(365 * 24 * 60 * 60) // 1 year + + // Renew indexer eligibility + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Set a short eligibility period + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityPeriod(60) // 1 minute + + // Advance time beyond eligibility period + await time.increase(120) // 2 minutes + + // Indexer should not be allowed + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + + // Re-renew indexer eligibility + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Now indexer should be allowed again + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + }) + }) +}) diff --git a/packages/issuance/test/tests/eligibility/fixtures.ts b/packages/issuance/test/tests/eligibility/fixtures.ts new file mode 100644 index 000000000..c214942bc --- /dev/null +++ b/packages/issuance/test/tests/eligibility/fixtures.ts @@ -0,0 +1,51 @@ +/** + * Eligibility-specific test fixtures + * Deployment and setup functions for eligibility contracts + */ + +import hre from 'hardhat' + +const { ethers } = hre +const { upgrades } = require('hardhat') + +import { SHARED_CONSTANTS } from '../common/fixtures' + +/** + * Deploy the RewardsEligibilityOracle contract with proxy using OpenZeppelin's upgrades library + * @param {string} graphToken + * @param {HardhatEthersSigner} governor + * @param {number} [validityPeriod=14 * 24 * 60 * 60] The validity period in seconds (default: 14 days) + * @returns {Promise} + */ +export async function deployRewardsEligibilityOracle( + graphToken, + governor, + validityPeriod = 14 * 24 * 60 * 60, // 14 days in seconds +) { + // Deploy implementation and proxy using OpenZeppelin's upgrades library + const RewardsEligibilityOracleFactory = await ethers.getContractFactory('RewardsEligibilityOracle') + + // Deploy proxy with implementation + const rewardsEligibilityOracleContract = await upgrades.deployProxy( + RewardsEligibilityOracleFactory, + [governor.address], + { + constructorArgs: [graphToken], + initializer: 'initialize', + }, + ) + + // Get the contract instance + const rewardsEligibilityOracle = rewardsEligibilityOracleContract + + // Set the eligibility period if it's different from the default (14 days) + if (validityPeriod !== 14 * 24 * 60 * 60) { + // First grant operator role to governor so they can set the eligibility period + await rewardsEligibilityOracle.connect(governor).grantRole(SHARED_CONSTANTS.OPERATOR_ROLE, governor.address) + await rewardsEligibilityOracle.connect(governor).setEligibilityPeriod(validityPeriod) + // Now revoke the operator role from governor to ensure tests start with clean state + await rewardsEligibilityOracle.connect(governor).revokeRole(SHARED_CONSTANTS.OPERATOR_ROLE, governor.address) + } + + return rewardsEligibilityOracle +}