From fed7913ccc31d82aa668af1c267630ca444e47ce Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Mon, 6 Oct 2025 20:12:13 +0000 Subject: [PATCH 01/14] feat: add Rewards Eligibility Oracle (REO) --- .../contracts/rewards/RewardsManager.sol | 53 +- .../rewards/RewardsManagerStorage.sol | 11 + .../tests/MockRewardsEligibilityOracle.sol | 71 ++ .../test/tests/unit/rewards/rewards.test.ts | 6 +- .../contracts/rewards/IRewardsManager.sol | 6 + .../eligibility/IRewardsEligibilityOracle.sol | 17 + .../toolshed/IRewardsManagerToolshed.sol | 3 + packages/issuance/.markdownlint.json | 3 + packages/issuance/.solcover.js | 19 + packages/issuance/.solhint.json | 3 + .../contracts/common/BaseUpgradeable.sol | 143 ++++ .../eligibility/RewardsEligibilityOracle.md | 197 +++++ .../eligibility/RewardsEligibilityOracle.sol | 282 ++++++++ .../contracts/test/InterfaceIdExtractor.sol | 22 + packages/issuance/hardhat.config.cjs | 115 +++ packages/issuance/hardhat.coverage.config.ts | 82 +++ packages/issuance/index.js | 11 + packages/issuance/package.json | 71 ++ packages/issuance/prettier.config.cjs | 5 + packages/issuance/test/hardhat.config.ts | 82 +++ packages/issuance/test/package.json | 59 ++ packages/issuance/test/prettier.config.cjs | 5 + packages/issuance/test/scripts/coverage | 7 + .../test/scripts/generateInterfaceIds.js | 144 ++++ packages/issuance/test/src/index.ts | 5 + .../tests/RewardsEligibilityOracle.test.ts | 671 ++++++++++++++++++ .../tests/consolidated/AccessControl.test.ts | 151 ++++ .../consolidated/InterfaceCompliance.test.ts | 53 ++ .../issuance/test/tests/helpers/fixtures.ts | 97 +++ .../test/tests/helpers/interfaceIds.js | 4 + .../test/tests/helpers/sharedFixtures.ts | 104 +++ packages/issuance/test/tsconfig.json | 8 + packages/issuance/test/utils/testPatterns.ts | 35 + packages/issuance/tsconfig.json | 18 + .../.graphclient-extracted/index.d.ts | 2 +- pnpm-lock.yaml | 278 +++++++- 36 files changed, 2833 insertions(+), 10 deletions(-) create mode 100644 packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol create mode 100644 packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityOracle.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/contracts/common/BaseUpgradeable.sol create mode 100644 packages/issuance/contracts/eligibility/RewardsEligibilityOracle.md create mode 100644 packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol create mode 100644 packages/issuance/contracts/test/InterfaceIdExtractor.sol create mode 100644 packages/issuance/hardhat.config.cjs create mode 100644 packages/issuance/hardhat.coverage.config.ts create mode 100644 packages/issuance/index.js create mode 100644 packages/issuance/package.json create mode 100644 packages/issuance/prettier.config.cjs create mode 100644 packages/issuance/test/hardhat.config.ts create mode 100644 packages/issuance/test/package.json create mode 100644 packages/issuance/test/prettier.config.cjs create mode 100755 packages/issuance/test/scripts/coverage create mode 100644 packages/issuance/test/scripts/generateInterfaceIds.js create mode 100644 packages/issuance/test/src/index.ts create mode 100644 packages/issuance/test/tests/RewardsEligibilityOracle.test.ts create mode 100644 packages/issuance/test/tests/consolidated/AccessControl.test.ts create mode 100644 packages/issuance/test/tests/consolidated/InterfaceCompliance.test.ts create mode 100644 packages/issuance/test/tests/helpers/fixtures.ts create mode 100644 packages/issuance/test/tests/helpers/interfaceIds.js create mode 100644 packages/issuance/test/tests/helpers/sharedFixtures.ts create mode 100644 packages/issuance/test/tsconfig.json create mode 100644 packages/issuance/test/utils/testPatterns.ts create mode 100644 packages/issuance/tsconfig.json diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol index fc3c7da85..ed868bc66 100644 --- a/packages/contracts/contracts/rewards/RewardsManager.sol +++ b/packages/contracts/contracts/rewards/RewardsManager.sol @@ -7,15 +7,17 @@ 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 "../token/IGraphToken.sol"; -import { RewardsManagerV5Storage } from "./RewardsManagerStorage.sol"; +import { RewardsManagerV6Storage } from "./RewardsManagerStorage.sol"; import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; import { IRewardsIssuer } from "./IRewardsIssuer.sol"; +import { IRewardsEligibilityOracle } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityOracle.sol"; /** * @title Rewards Manager Contract @@ -37,7 +39,7 @@ import { IRewardsIssuer } from "./IRewardsIssuer.sol"; * 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, IRewardsManager { using SafeMath for uint256; /// @dev Fixed point scaling factor used for decimals in reward calculations @@ -61,6 +63,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 +85,16 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa */ event SubgraphServiceSet(address indexed oldSubgraphService, address indexed newSubgraphService); + /** + * @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 -- /** @@ -151,6 +171,28 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa emit SubgraphServiceSet(oldSubgraphService, _subgraphService); } + /** + * @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 IRewardsEligibilityOracle interface + // Allow zero address to disable the oracle + if (newRewardsEligibilityOracle != address(0)) { + require( + IERC165(newRewardsEligibilityOracle).supportsInterface(type(IRewardsEligibilityOracle).interfaceId), + "Contract does not support IRewardsEligibilityOracle interface" + ); + } + + address oldRewardsEligibilityOracle = address(rewardsEligibilityOracle); + rewardsEligibilityOracle = IRewardsEligibilityOracle(newRewardsEligibilityOracle); + emit RewardsEligibilityOracleSet(oldRewardsEligibilityOracle, newRewardsEligibilityOracle); + } + } + // -- Denylist -- /** @@ -404,6 +446,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 27883d340..6f54a6e22 100644 --- a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol +++ b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol @@ -7,6 +7,7 @@ pragma solidity ^0.7.6 || 0.8.27; +import { IRewardsEligibilityOracle } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityOracle.sol"; import { IRewardsIssuer } from "./IRewardsIssuer.sol"; import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; import { Managed } from "../governance/Managed.sol"; @@ -75,3 +76,13 @@ contract RewardsManagerV5Storage is RewardsManagerV4Storage { /// @notice Address of the subgraph service IRewardsIssuer public subgraphService; } + +/** + * @title RewardsManagerV5Storage + * @author Edge & Node + * @notice Storage layout for RewardsManager V6 + */ +contract RewardsManagerV6Storage is RewardsManagerV5Storage { + /// @notice Address of the rewards eligibility oracle contract + IRewardsEligibilityOracle public rewardsEligibilityOracle; +} diff --git a/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol b/packages/contracts/contracts/tests/MockRewardsEligibilityOracle.sol new file mode 100644 index 000000000..6264c4b7a --- /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 { IRewardsEligibilityOracle } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityOracle.sol"; +import { ERC165 } from "@openzeppelin/contracts/introspection/ERC165.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 IRewardsEligibilityOracle, ERC165 { + /// @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 IRewardsEligibilityOracle + */ + 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 ERC165 + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IRewardsEligibilityOracle).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/packages/contracts/test/tests/unit/rewards/rewards.test.ts b/packages/contracts/test/tests/unit/rewards/rewards.test.ts index 84f836681..67d4f2d97 100644 --- a/packages/contracts/test/tests/unit/rewards/rewards.test.ts +++ b/packages/contracts/test/tests/unit/rewards/rewards.test.ts @@ -190,7 +190,7 @@ describe('Rewards', () => { }) }) - describe.skip('rewards eligibility oracle', function () { + describe('rewards eligibility oracle', function () { it('should reject setRewardsEligibilityOracle if unauthorized', async function () { const MockRewardsEligibilityOracleFactory = await hre.ethers.getContractFactory( 'contracts/tests/MockRewardsEligibilityOracle.sol:MockRewardsEligibilityOracle', @@ -893,7 +893,7 @@ describe('Rewards', () => { await expect(tx).emit(rewardsManager, 'RewardsDenied').withArgs(indexer1.address, allocationID1) }) - it.skip('should deny rewards due to rewards eligibility oracle', async 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', @@ -923,7 +923,7 @@ describe('Rewards', () => { .withArgs(indexer1.address, allocationID1, expectedIndexingRewards) }) - it.skip('should allow rewards when rewards eligibility oracle approves', async function () { + 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', diff --git a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol index 72a73e19b..87aa24ea2 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 -- /** diff --git a/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityOracle.sol b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityOracle.sol new file mode 100644 index 000000000..907dad561 --- /dev/null +++ b/packages/interfaces/contracts/issuance/eligibility/IRewardsEligibilityOracle.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.7.6 || ^0.8.0; + +/** + * @title IRewardsEligibilityOracle + * @author Edge & Node + * @notice Interface to check if an indexer is eligible to receive rewards + */ +interface IRewardsEligibilityOracle { + /** + * @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/interfaces/contracts/toolshed/IRewardsManagerToolshed.sol b/packages/interfaces/contracts/toolshed/IRewardsManagerToolshed.sol index 0f26080f9..4e52cbaf3 100644 --- a/packages/interfaces/contracts/toolshed/IRewardsManagerToolshed.sol +++ b/packages/interfaces/contracts/toolshed/IRewardsManagerToolshed.sol @@ -36,4 +36,7 @@ interface IRewardsManagerToolshed is IRewardsManager { event SubgraphServiceSet(address indexed oldSubgraphService, address indexed newSubgraphService); function subgraphService() external view returns (address); + + /// @inheritdoc IRewardsManager + function setRewardsEligibilityOracle(address newRewardsEligibilityOracle) external; } 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..e3dbe2e27 --- /dev/null +++ b/packages/issuance/.solcover.js @@ -0,0 +1,19 @@ +module.exports = { + skipFiles: ['test/'], + providerOptions: { + mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', + network_id: 1337, + }, + istanbulFolder: './test/reports/coverage', + configureYulOptimizer: true, + mocha: { + grep: '@skip-on-coverage', + invert: true, + }, + reporter: ['html', 'lcov', 'text'], + reporterOptions: { + html: { + directory: './test/reports/coverage/html', + }, + }, +} 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/contracts/common/BaseUpgradeable.sol b/packages/issuance/contracts/common/BaseUpgradeable.sol new file mode 100644 index 000000000..20fccd3aa --- /dev/null +++ b/packages/issuance/contracts/common/BaseUpgradeable.sol @@ -0,0 +1,143 @@ +// 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"; + +/** + * @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 { + // -- 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 -- + + /** + * @notice Pause the contract + * @dev Only callable by accounts with the PAUSE_ROLE + */ + function pause() external onlyRole(PAUSE_ROLE) { + _pause(); + } + + /** + * @notice Unpause the contract + * @dev Only callable by accounts with the PAUSE_ROLE + */ + function unpause() external onlyRole(PAUSE_ROLE) { + _unpause(); + } +} diff --git a/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.md b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.md new file mode 100644 index 000000000..b03db2169 --- /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 the `IRewardsEligibilityOracle` interface 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..7ae178f1b --- /dev/null +++ b/packages/issuance/contracts/eligibility/RewardsEligibilityOracle.sol @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity 0.8.27; + +import { IRewardsEligibilityOracle } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityOracle.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, IRewardsEligibilityOracle { + // -- 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 + } + } + + // -- Events -- + + /// @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); // solhint-disable-line gas-indexed-events + + /// @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); + + // -- 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(IRewardsEligibilityOracle).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 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 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 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 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 IRewardsEligibilityOracle + */ + 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 returns (uint256) { + return _getRewardsEligibilityOracleStorage().indexerEligibilityTimestamps[indexer]; + } + + /** + * @notice Get the eligibility period + * @return The current eligibility period in seconds + */ + function getEligibilityPeriod() external view returns (uint256) { + return _getRewardsEligibilityOracleStorage().eligibilityPeriod; + } + + /** + * @notice Get the oracle update timeout + * @return The current oracle update timeout in seconds + */ + function getOracleUpdateTimeout() external view returns (uint256) { + return _getRewardsEligibilityOracleStorage().oracleUpdateTimeout; + } + + /** + * @notice Get the last oracle update time + * @return The timestamp of the last oracle update + */ + function getLastOracleUpdateTime() external view returns (uint256) { + return _getRewardsEligibilityOracleStorage().lastOracleUpdateTime; + } + + /** + * @notice Get eligibility validation state + * @return True if eligibility validation is enabled, false otherwise + */ + function getEligibilityValidation() external view returns (bool) { + return _getRewardsEligibilityOracleStorage().eligibilityValidationEnabled; + } +} diff --git a/packages/issuance/contracts/test/InterfaceIdExtractor.sol b/packages/issuance/contracts/test/InterfaceIdExtractor.sol new file mode 100644 index 000000000..10b67e120 --- /dev/null +++ b/packages/issuance/contracts/test/InterfaceIdExtractor.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import { IRewardsEligibilityOracle } from "@graphprotocol/interfaces/contracts/issuance/eligibility/IRewardsEligibilityOracle.sol"; + +/** + * @title InterfaceIdExtractor + * @author Edge & Node + * @notice Utility contract for extracting ERC-165 interface IDs from Solidity interfaces + * @dev This contract is used during the build process to generate interface ID constants + * that match Solidity's own calculations, ensuring consistency between tests and actual + * interface implementations. + */ +contract InterfaceIdExtractor { + /** + * @notice Returns the ERC-165 interface ID for IRewardsEligibilityOracle + * @return The interface ID as calculated by Solidity + */ + function getIRewardsEligibilityOracleId() external pure returns (bytes4) { + return type(IRewardsEligibilityOracle).interfaceId; + } +} diff --git a/packages/issuance/hardhat.config.cjs b/packages/issuance/hardhat.config.cjs new file mode 100644 index 000000000..27f2f6214 --- /dev/null +++ b/packages/issuance/hardhat.config.cjs @@ -0,0 +1,115 @@ +require('@nomicfoundation/hardhat-ethers') +require('@nomicfoundation/hardhat-chai-matchers') +require('@typechain/hardhat') +require('hardhat-abi-exporter') +require('hardhat-contract-sizer') +require('hardhat-gas-reporter') +require('solidity-coverage') +require('@openzeppelin/hardhat-upgrades') +require('@nomicfoundation/hardhat-verify') + +const dotenv = require('dotenv') + +dotenv.config() + +const config = { + // paths: { + // sources: './contracts', + // tests: './test', + // artifacts: './build/contracts', + // cache: './cache', + // }, + solidity: { + compilers: [ + { + version: '0.8.27', + }, + ], + }, + defaultNetwork: 'hardhat', + networks: { + hardhat: { + chainId: 1337, + loggingEnabled: false, + gas: 12000000, + gasPrice: 'auto', + initialBaseFeePerGas: 0, + blockGasLimit: 12000000, + // Support for forking + forking: + process.env.FORK === 'true' + ? { + url: + process.env.FORK_NETWORK === 'arbitrumSepolia' + ? process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc' + : process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', + blockNumber: process.env.FORK_BLOCK_NUMBER ? parseInt(process.env.FORK_BLOCK_NUMBER) : undefined, + } + : undefined, + }, + localhost: { + chainId: 1337, + url: 'http://127.0.0.1:8545', + accounts: { + mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', + }, + }, + // For connecting to Anvil fork + anvilFork: { + chainId: 31337, // Anvil's default chainId + url: process.env.ANVIL_FORK_URL || 'http://127.0.0.1:8545', + accounts: process.env.PRIVATE_KEY + ? [process.env.PRIVATE_KEY] + : { + mnemonic: 'test test test test test test test test test test test junk', + }, + // Pass through environment variables to the deployment script + params_file: process.env.PARAMS_FILE, + }, + arbitrumSepolia: { + chainId: 421614, + url: process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc', + accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + gasPrice: 'auto', + }, + arbitrumOne: { + chainId: 42161, + url: process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', + accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + gasPrice: 'auto', + }, + }, + gasReporter: { + enabled: process.env.REPORT_GAS ? true : false, + showTimeSpent: true, + currency: 'USD', + outputFile: 'reports/gas-report.log', + }, + typechain: { + outDir: 'types', + target: 'ethers-v6', + }, + contractSizer: { + alphaSort: true, + runOnCompile: false, + disambiguatePaths: false, + }, + etherscan: { + apiKey: { + arbitrumOne: process.env.ARBISCAN_API_KEY, + arbitrumSepolia: process.env.ARBISCAN_API_KEY, + }, + customChains: [ + { + network: 'arbitrumSepolia', + chainId: 421614, + urls: { + apiURL: 'https://api-sepolia.arbiscan.io/api', + browserURL: 'https://sepolia.arbiscan.io', + }, + }, + ], + }, +} + +module.exports = config diff --git a/packages/issuance/hardhat.coverage.config.ts b/packages/issuance/hardhat.coverage.config.ts new file mode 100644 index 000000000..19d508b48 --- /dev/null +++ b/packages/issuance/hardhat.coverage.config.ts @@ -0,0 +1,82 @@ +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 'dotenv/config' + +import { HardhatUserConfig } from 'hardhat/config' + +const config: HardhatUserConfig = { + paths: { + sources: './contracts', + tests: './test/tests', + artifacts: './artifacts', + cache: './cache', + }, + solidity: { + compilers: [ + { + version: '0.8.27', + }, + ], + }, + defaultNetwork: 'hardhat', + networks: { + hardhat: { + chainId: 1337, + loggingEnabled: false, + gas: 12000000, + gasPrice: 'auto', + initialBaseFeePerGas: 0, + blockGasLimit: 12000000, + forking: + process.env.FORK === 'true' + ? { + url: + process.env.FORK_NETWORK === 'arbitrumSepolia' + ? process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc' + : process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', + blockNumber: process.env.FORK_BLOCK_NUMBER ? parseInt(process.env.FORK_BLOCK_NUMBER) : undefined, + } + : undefined, + }, + localhost: { + chainId: 1337, + url: 'http://127.0.0.1:8545', + accounts: { + mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', + }, + }, + anvilFork: { + chainId: 31337, + url: process.env.ANVIL_FORK_URL || 'http://127.0.0.1:8545', + accounts: process.env.PRIVATE_KEY + ? [process.env.PRIVATE_KEY] + : { + mnemonic: 'test test test test test test test test test test test junk', + }, + }, + arbitrumSepolia: { + chainId: 421614, + url: process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc', + accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + gasPrice: 'auto', + }, + arbitrumOne: { + chainId: 42161, + url: process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', + accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + gasPrice: 'auto', + }, + }, + gasReporter: { + enabled: process.env.REPORT_GAS ? true : false, + showTimeSpent: true, + currency: 'USD', + outputFile: 'reports/gas-report.log', + }, +} + +export default config diff --git a/packages/issuance/index.js b/packages/issuance/index.js new file mode 100644 index 000000000..4b0935649 --- /dev/null +++ b/packages/issuance/index.js @@ -0,0 +1,11 @@ +// Main entry point for @graphprotocol/issuance +// This package provides issuance contracts and artifacts + +const path = require('path') + +module.exports = { + contractsDir: path.join(__dirname, 'contracts'), + artifactsDir: path.join(__dirname, 'artifacts'), + typesDir: path.join(__dirname, 'types'), + cacheDir: path.join(__dirname, 'cache'), +} diff --git a/packages/issuance/package.json b/packages/issuance/package.json new file mode 100644 index 000000000..e21abeb2a --- /dev/null +++ b/packages/issuance/package.json @@ -0,0 +1,71 @@ +{ + "name": "@graphprotocol/issuance", + "version": "1.0.0", + "description": "The Graph Issuance Contracts", + "main": "index.js", + "exports": { + ".": "./index.js", + "./artifacts/*": "./artifacts/*", + "./contracts/*": "./contracts/*", + "./types/*": "./types/*" + }, + "scripts": { + "build": "pnpm build:dep && pnpm build:self", + "build:dep": "pnpm --filter '@graphprotocol/issuance^...' run build:self", + "build:self": "pnpm compile; pnpm typechain", + "clean": "rm -rf build/ cache/ dist/ forge-artifacts/ cache_forge/", + "compile": "hardhat compile", + "test": "pnpm --filter @graphprotocol/issuance-test test", + "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", + "LICENSE" + ], + "author": "The Graph Team", + "license": "GPL-2.0-or-later", + "devDependencies": { + "@graphprotocol/interfaces": "workspace:^", + "@nomicfoundation/hardhat-ethers": "catalog:", + "@nomicfoundation/hardhat-verify": "catalog:", + "@openzeppelin/contracts": "^5.3.0", + "@openzeppelin/contracts-upgradeable": "^5.3.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/hardhat.config.ts b/packages/issuance/test/hardhat.config.ts new file mode 100644 index 000000000..c485d1936 --- /dev/null +++ b/packages/issuance/test/hardhat.config.ts @@ -0,0 +1,82 @@ +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 'dotenv/config' + +import { artifactsDir, cacheDir } from '@graphprotocol/issuance' +import { HardhatUserConfig } from 'hardhat/config' + +const config: HardhatUserConfig = { + paths: { + tests: './tests', + artifacts: artifactsDir, + cache: cacheDir, + }, + solidity: { + compilers: [ + { + version: '0.8.27', + }, + ], + }, + defaultNetwork: 'hardhat', + networks: { + hardhat: { + chainId: 1337, + loggingEnabled: false, + gas: 12000000, + gasPrice: 'auto', + initialBaseFeePerGas: 0, + blockGasLimit: 12000000, + forking: + process.env.FORK === 'true' + ? { + url: + process.env.FORK_NETWORK === 'arbitrumSepolia' + ? process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc' + : process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', + blockNumber: process.env.FORK_BLOCK_NUMBER ? parseInt(process.env.FORK_BLOCK_NUMBER) : undefined, + } + : undefined, + }, + localhost: { + chainId: 1337, + url: 'http://127.0.0.1:8545', + accounts: { + mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', + }, + }, + anvilFork: { + chainId: 31337, + url: process.env.ANVIL_FORK_URL || 'http://127.0.0.1:8545', + accounts: process.env.PRIVATE_KEY + ? [process.env.PRIVATE_KEY] + : { + mnemonic: 'test test test test test test test test test test test junk', + }, + }, + arbitrumSepolia: { + chainId: 421614, + url: process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc', + accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + gasPrice: 'auto', + }, + arbitrumOne: { + chainId: 42161, + url: process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', + accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + gasPrice: 'auto', + }, + }, + gasReporter: { + enabled: process.env.REPORT_GAS ? true : false, + showTimeSpent: true, + currency: 'USD', + outputFile: 'reports/gas-report.log', + }, +} + +export default config diff --git a/packages/issuance/test/package.json b/packages/issuance/test/package.json new file mode 100644 index 000000000..6235404b5 --- /dev/null +++ b/packages/issuance/test/package.json @@ -0,0 +1,59 @@ +{ + "name": "@graphprotocol/issuance-test", + "version": "1.0.0", + "private": true, + "description": "Test utilities for @graphprotocol/issuance", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "default": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "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.3.0", + "@openzeppelin/contracts-upgradeable": "^5.3.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:" + }, + "scripts": { + "build": "pnpm build:dep && pnpm build:self", + "build:dep": "pnpm --filter '@graphprotocol/issuance-test^...' run build:self", + "build:self": "tsc --build && pnpm --filter @graphprotocol/issuance compile && pnpm generate:interfaces", + "generate:interfaces": "node scripts/generateInterfaceIds.js --silent", + "clean": "rm -rf build", + "test": "pnpm build && pnpm test:self", + "test:self": "hardhat test tests/*.test.ts tests/**/*.test.ts", + "test:coverage": "pnpm build && pnpm test:coverage:self", + "test:coverage:self": "scripts/coverage", + "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'" + } +} 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/scripts/coverage b/packages/issuance/test/scripts/coverage new file mode 100755 index 000000000..4937a482d --- /dev/null +++ b/packages/issuance/test/scripts/coverage @@ -0,0 +1,7 @@ +#!/bin/bash + +set -eo pipefail + +# Run coverage from the parent issuance directory where contracts are local +cd .. +npx hardhat coverage --config hardhat.coverage.config.ts --testfiles "test/tests/**/*.test.ts" diff --git a/packages/issuance/test/scripts/generateInterfaceIds.js b/packages/issuance/test/scripts/generateInterfaceIds.js new file mode 100644 index 000000000..174a2316d --- /dev/null +++ b/packages/issuance/test/scripts/generateInterfaceIds.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +/** + * Generate interface ID constants by deploying and calling InterfaceIdExtractor contract + */ + +const fs = require('fs') +const path = require('path') +const { spawn } = require('child_process') + +const OUTPUT_FILE = path.join(__dirname, '../tests/helpers/interfaceIds.js') +const SILENT = process.argv.includes('--silent') + +function log(...args) { + if (!SILENT) { + console.log(...args) + } +} + +async function runHardhatTask() { + return new Promise((resolve, reject) => { + const hardhatScript = ` +const hre = require('hardhat') + +async function main() { + const InterfaceIdExtractor = await hre.ethers.getContractFactory('InterfaceIdExtractor') + const extractor = await InterfaceIdExtractor.deploy() + await extractor.waitForDeployment() + + const results = { + IRewardsEligibilityOracle: await extractor.getIRewardsEligibilityOracleId(), + } + + console.log(JSON.stringify(results)) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) +` + + // Write temporary script + const tempScript = path.join(__dirname, 'temp-extract.js') + fs.writeFileSync(tempScript, hardhatScript) + + // Run the script with hardhat + const child = spawn('npx', ['hardhat', 'run', tempScript, '--network', 'hardhat'], { + cwd: path.join(__dirname, '../..'), + stdio: 'pipe', + }) + + let output = '' + let errorOutput = '' + + child.stdout.on('data', (data) => { + output += data.toString() + }) + + child.stderr.on('data', (data) => { + errorOutput += data.toString() + }) + + child.on('close', (code) => { + // Clean up temp script + try { + fs.unlinkSync(tempScript) + } catch { + // Ignore cleanup errors + } + + if (code === 0) { + // Extract JSON from output + const lines = output.split('\n') + for (const line of lines) { + try { + const result = JSON.parse(line.trim()) + if (result && typeof result === 'object') { + resolve(result) + return + } + } catch { + // Not JSON, continue + } + } + reject(new Error('Could not parse interface IDs from output')) + } else { + reject(new Error(`Hardhat script failed with code ${code}: ${errorOutput}`)) + } + }) + }) +} + +async function extractInterfaceIds() { + const extractorPath = path.join( + __dirname, + '../../artifacts/contracts/test/InterfaceIdExtractor.sol/InterfaceIdExtractor.json', + ) + + if (!fs.existsSync(extractorPath)) { + console.error('❌ InterfaceIdExtractor artifact not found') + console.error('Run: pnpm compile to build the extractor contract') + throw new Error('InterfaceIdExtractor not compiled') + } + + log('Deploying InterfaceIdExtractor contract to extract interface IDs...') + + try { + const results = await runHardhatTask() + + // Convert from ethers BigNumber format to hex strings + const processed = {} + for (const [name, value] of Object.entries(results)) { + processed[name] = typeof value === 'string' ? value : `0x${value.toString(16).padStart(8, '0')}` + log(`✅ Extracted ${name}: ${processed[name]}`) + } + + return processed + } catch (error) { + console.error('Error extracting interface IDs:', error.message) + throw error + } +} + +async function main() { + log('Extracting interface IDs from Solidity compilation...') + + const results = await extractInterfaceIds() + + const content = `// Auto-generated interface IDs from Solidity compilation +module.exports = { +${Object.entries(results) + .map(([name, id]) => ` ${name}: '${id}',`) + .join('\n')} +} +` + + fs.writeFileSync(OUTPUT_FILE, content) + log(`✅ Generated ${OUTPUT_FILE}`) +} + +if (require.main === module) { + main().catch(console.error) +} 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/RewardsEligibilityOracle.test.ts b/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts new file mode 100644 index 000000000..87940ca3d --- /dev/null +++ b/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts @@ -0,0 +1,671 @@ +import '@nomicfoundation/hardhat-chai-matchers' + +import { time } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' + +const { ethers, upgrades } = require('hardhat') + +import type { IGraphToken, RewardsEligibilityOracle } from '../../types' +import { + deployRewardsEligibilityOracle, + deployTestGraphToken, + getTestAccounts, + type TestAccounts, +} from './helpers/fixtures' +import { SHARED_CONSTANTS } from './helpers/sharedFixtures' + +// 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: IGraphToken + rewardsEligibilityOracle: RewardsEligibilityOracle + addresses: { + graphToken: string + rewardsEligibilityOracle: string + } +} + +describe('RewardsEligibilityOracle', () => { + // Common variables + let accounts: TestAccounts + 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 { + // Ignore role management errors during reset + } + + // 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/consolidated/AccessControl.test.ts b/packages/issuance/test/tests/consolidated/AccessControl.test.ts new file mode 100644 index 000000000..9eb42434c --- /dev/null +++ b/packages/issuance/test/tests/consolidated/AccessControl.test.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Consolidated Access Control Tests + * Tests access control patterns across all contracts to reduce duplication + */ + +const { expect } = require('chai') +const { deploySharedContracts, resetContractState, SHARED_CONSTANTS } = require('../helpers/sharedFixtures') + +describe('Consolidated Access Control Tests', () => { + let accounts: any + let contracts: any + + before(async () => { + const sharedSetup = await deploySharedContracts() + accounts = sharedSetup.accounts + contracts = sharedSetup.contracts + }) + + beforeEach(async () => { + await resetContractState(contracts, accounts) + }) + + 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 + await expect(contracts.rewardsEligibilityOracle.connect(accounts.governor).pause()).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 across all contracts', async () => { + const governorRole = SHARED_CONSTANTS.GOVERNOR_ROLE + + // All contracts 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/consolidated/InterfaceCompliance.test.ts b/packages/issuance/test/tests/consolidated/InterfaceCompliance.test.ts new file mode 100644 index 000000000..fbbe52979 --- /dev/null +++ b/packages/issuance/test/tests/consolidated/InterfaceCompliance.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect } from 'chai' +import { ethers } from 'hardhat' + +import { shouldSupportERC165Interface } from '../../utils/testPatterns' +import { deployRewardsEligibilityOracle, deployTestGraphToken, getTestAccounts } from '../helpers/fixtures' +// Import generated interface IDs +import interfaceIds from '../helpers/interfaceIds' + +/** + * Consolidated ERC-165 Interface Compliance Tests + * Tests interface support across all contracts to reduce duplication + */ +describe('ERC-165 Interface Compliance', () => { + let accounts: any + let contracts: any + + before(async () => { + accounts = await getTestAccounts() + + // Deploy all 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', + shouldSupportERC165Interface( + () => contracts.rewardsEligibilityOracle, + interfaceIds.IRewardsEligibilityOracle, + 'IRewardsEligibilityOracle', + ), + ) + + describe('Interface ID Consistency', () => { + it('should have consistent interface IDs with Solidity calculations', async () => { + const InterfaceIdExtractorFactory = await ethers.getContractFactory('InterfaceIdExtractor') + const extractor = await InterfaceIdExtractorFactory.deploy() + + expect(await extractor.getIRewardsEligibilityOracleId()).to.equal(interfaceIds.IRewardsEligibilityOracle) + }) + + it('should have valid interface IDs (not zero)', () => { + expect(interfaceIds.IRewardsEligibilityOracle).to.not.equal('0x00000000') + }) + }) +}) diff --git a/packages/issuance/test/tests/helpers/fixtures.ts b/packages/issuance/test/tests/helpers/fixtures.ts new file mode 100644 index 000000000..0c2ff3e18 --- /dev/null +++ b/packages/issuance/test/tests/helpers/fixtures.ts @@ -0,0 +1,97 @@ +import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' +import * as fs from 'fs' + +const { ethers, upgrades } = require('hardhat') +const { SHARED_CONSTANTS } = require('./sharedFixtures') +const { OPERATOR_ROLE } = SHARED_CONSTANTS + +// Types +export interface TestAccounts { + governor: HardhatEthersSigner + nonGovernor: HardhatEthersSigner + operator: HardhatEthersSigner + user: HardhatEthersSigner + indexer1: HardhatEthersSigner + indexer2: HardhatEthersSigner +} + +/** + * Get standard test accounts + */ +export async function getTestAccounts(): Promise { + const [governor, nonGovernor, operator, user, indexer1, indexer2] = await ethers.getSigners() + + return { + governor, + nonGovernor, + operator, + user, + indexer1, + indexer2, + } +} + +/** + * Deploy a test GraphToken for testing + * This uses the real GraphToken contract + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function deployTestGraphToken(): Promise { + // 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 +} + +/** + * Deploy the RewardsEligibilityOracle contract with proxy using OpenZeppelin's upgrades library + * @param graphToken The Graph Token contract address + * @param governor The governor signer + * @param validityPeriod The validity period in seconds (default: 7 days) + */ +export async function deployRewardsEligibilityOracle( + graphToken: string, + governor: HardhatEthersSigner, + validityPeriod: number = 7 * 24 * 60 * 60, // 7 days in seconds + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + // 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 validity period if it's different from the default + if (validityPeriod !== 7 * 24 * 60 * 60) { + // First grant operator role to governor so they can set the validity period + await rewardsEligibilityOracle.connect(governor).grantOperatorRole(governor.address) + await rewardsEligibilityOracle.connect(governor).setValidityPeriod(validityPeriod) + // Now revoke the operator role from governor to ensure tests start with clean state + await rewardsEligibilityOracle.connect(governor).revokeRole(OPERATOR_ROLE, governor.address) + } + + return rewardsEligibilityOracle +} diff --git a/packages/issuance/test/tests/helpers/interfaceIds.js b/packages/issuance/test/tests/helpers/interfaceIds.js new file mode 100644 index 000000000..3cbe4e22d --- /dev/null +++ b/packages/issuance/test/tests/helpers/interfaceIds.js @@ -0,0 +1,4 @@ +// Auto-generated interface IDs from Solidity compilation +module.exports = { + IRewardsEligibilityOracle: '0x66e305fd', +} diff --git a/packages/issuance/test/tests/helpers/sharedFixtures.ts b/packages/issuance/test/tests/helpers/sharedFixtures.ts new file mode 100644 index 000000000..8e56c4619 --- /dev/null +++ b/packages/issuance/test/tests/helpers/sharedFixtures.ts @@ -0,0 +1,104 @@ +/** + * Shared fixtures and setup utilities for all test files + * Reduces duplication of deployment and state management logic + */ + +import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' + +const { ethers } = require('hardhat') +const { getTestAccounts, deployTestGraphToken, deployRewardsEligibilityOracle } = require('./fixtures') + +// 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 + +// Interface IDs +export const INTERFACE_IDS = { + IERC165: '0x01ffc9a7', +} as const + +// Types +export interface TestAccounts { + governor: HardhatEthersSigner + nonGovernor: HardhatEthersSigner + operator: HardhatEthersSigner + user: HardhatEthersSigner + indexer1: HardhatEthersSigner + indexer2: HardhatEthersSigner +} + +export interface SharedContracts { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + graphToken: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rewardsEligibilityOracle: any +} + +export interface SharedAddresses { + graphToken: string + rewardsEligibilityOracle: string +} + +export interface SharedFixtures { + accounts: TestAccounts + contracts: SharedContracts + addresses: SharedAddresses +} + +/** + * Shared contract deployment and setup + */ +export async function deploySharedContracts(): Promise { + const accounts = await getTestAccounts() + + // Deploy base contracts + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + + const rewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // Cache addresses + const addresses: SharedAddresses = { + graphToken: graphTokenAddress, + rewardsEligibilityOracle: await rewardsEligibilityOracle.getAddress(), + } + + // Create helper + return { + accounts, + contracts: { + graphToken, + rewardsEligibilityOracle, + }, + addresses, + } +} + +/** + * Reset contract state to initial conditions + * Optimized to avoid redeployment while ensuring clean state + */ +export async function resetContractState(contracts: SharedContracts, accounts: TestAccounts): Promise { + const { rewardsEligibilityOracle } = contracts + + // Reset RewardsEligibilityOracle state + try { + if (await rewardsEligibilityOracle.paused()) { + await rewardsEligibilityOracle.connect(accounts.governor).unpause() + } + + // Reset eligibility validation to default (disabled) + if (await rewardsEligibilityOracle.getEligibilityValidation()) { + await rewardsEligibilityOracle.connect(accounts.governor).setEligibilityValidation(false) + } + } catch (error) { + console.warn('RewardsEligibilityOracle state reset failed:', error instanceof Error ? error.message : String(error)) + } +} diff --git a/packages/issuance/test/tsconfig.json b/packages/issuance/test/tsconfig.json new file mode 100644 index 000000000..0b5c1a868 --- /dev/null +++ b/packages/issuance/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["tests/**/*", "utils/**/*", "../types/**/*"], + "exclude": ["node_modules", "build", "scripts/**/*"] +} diff --git a/packages/issuance/test/utils/testPatterns.ts b/packages/issuance/test/utils/testPatterns.ts new file mode 100644 index 000000000..52c3a7a56 --- /dev/null +++ b/packages/issuance/test/utils/testPatterns.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Shared test patterns and utilities to reduce duplication across test files + */ + +const { expect } = require('chai') + +// Test constants - centralized to avoid magic numbers +export const TestConstants = { + // Interface IDs + IERC165_INTERFACE_ID: '0x01ffc9a7', +} as const + +/** + * Shared test pattern for ERC-165 interface compliance + */ +export function shouldSupportERC165Interface(contractGetter: () => T, interfaceId: string, interfaceName: string) { + return function () { + it(`should support ERC-165 interface`, async function () { + const contract = contractGetter() + expect(await (contract as any).supportsInterface(TestConstants.IERC165_INTERFACE_ID)).to.be.true + }) + + it(`should support ${interfaceName} interface`, async function () { + const contract = contractGetter() + expect(await (contract as any).supportsInterface(interfaceId)).to.be.true + }) + + it('should not support random interface', async function () { + const contract = contractGetter() + const randomInterfaceId = '0x12345678' + expect(await (contract as any).supportsInterface(randomInterfaceId)).to.be.false + }) + } +} 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/packages/token-distribution/.graphclient-extracted/index.d.ts b/packages/token-distribution/.graphclient-extracted/index.d.ts index faaba1f58..3ec32af02 100644 --- a/packages/token-distribution/.graphclient-extracted/index.d.ts +++ b/packages/token-distribution/.graphclient-extracted/index.d.ts @@ -4,8 +4,8 @@ import type { GetMeshOptions } from '@graphql-mesh/runtime'; import type { YamlConfig } from '@graphql-mesh/types'; import { MeshHTTPHandler } from '@graphql-mesh/http'; import { ExecuteMeshFn, SubscribeMeshFn, MeshContext as BaseMeshContext, MeshInstance } from '@graphql-mesh/runtime'; -import type { TokenDistributionTypes } from './sources/token-distribution/types'; import type { GraphNetworkTypes } from './sources/graph-network/types'; +import type { TokenDistributionTypes } from './sources/token-distribution/types'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Scalars = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9f31a0a0..7db18eb5f 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.45.0 version: 8.45.0 '@typescript-eslint/parser': specifier: ^8.45.0 version: 8.45.0 + dotenv: + specifier: ^16.5.0 + version: 16.6.1 eslint: specifier: ^9.37.0 version: 9.37.0 @@ -63,6 +72,9 @@ catalogs: hardhat-gas-reporter: specifier: ^1.0.8 version: 1.0.10 + hardhat-secure-accounts: + specifier: ^1.0.5 + version: 1.0.5 hardhat-storage-layout: specifier: ^0.1.7 version: 0.1.7 @@ -938,6 +950,182 @@ importers: specifier: ^2.31.7 version: 2.37.13(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.11) + packages/issuance: + dependencies: + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 + devDependencies: + '@graphprotocol/interfaces': + specifier: workspace:^ + version: link:../interfaces + '@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.19)(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.19)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@openzeppelin/contracts': + specifier: ^5.3.0 + version: 5.4.0 + '@openzeppelin/contracts-upgradeable': + specifier: ^5.3.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.19)(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.19)(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.19)(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.19)(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.19 + dotenv: + specifier: 'catalog:' + version: 16.6.1 + eslint: + specifier: 'catalog:' + version: 9.37.0(jiti@2.6.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.19)(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.19)(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.19)(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.19)(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.19)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + lint-staged: + specifier: 'catalog:' + version: 16.2.3 + 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.19)(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.45.0(eslint@9.37.0(jiti@2.6.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.19)(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.19)(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.19)(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.19)(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.19)(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(2132343380e0584d85bc36e173d91a88) + '@openzeppelin/contracts': + specifier: ^5.3.0 + version: 5.4.0 + '@openzeppelin/contracts-upgradeable': + specifier: ^5.3.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.19 + chai: + specifier: ^4.3.7 + version: 4.5.0 + dotenv: + specifier: ^16.5.0 + version: 16.6.1 + eslint: + specifier: 'catalog:' + version: 9.37.0(jiti@2.6.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.19)(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.19)(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.19)(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.19)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/subgraph-service: devDependencies: '@graphprotocol/contracts': @@ -3338,6 +3526,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: @@ -3473,6 +3683,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' @@ -11217,6 +11439,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==} @@ -15585,6 +15811,27 @@ snapshots: typechain: 8.3.2(patch_hash=b34ed6afcf99760666fdc85ecb2094fdd20ce509f947eb09cef21665a2a6a1d6)(typescript@5.9.3) typescript: 5.9.3 + '@nomicfoundation/hardhat-toolbox@5.0.0(2132343380e0584d85bc36e173d91a88)': + 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.19)(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.19)(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.19)(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.19)(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.19)(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.19)(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.19)(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.19)(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.19)(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.19)(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.19 + 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.19)(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.19)(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.19)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) + ts-node: 10.9.2(@types/node@20.19.19)(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.19)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))': dependencies: '@ethersproject/abi': 5.8.0 @@ -15751,8 +15998,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: @@ -15769,7 +16016,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) @@ -15779,7 +16026,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) @@ -15810,6 +16057,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.19)(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.19)(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.19)(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.19)(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.19)(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.19)(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 @@ -26177,6 +26445,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + undici@6.22.0: {} + unfetch@4.2.0: {} unicorn-magic@0.1.0: {} From 13f0ae08b72759836e1d6b65e1753d3374f6d6be Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 07:50:00 +0000 Subject: [PATCH 02/14] refactor: consolidate test fixtures and fix documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Consolidated duplicate test fixture files into single fixtures.ts • Removed circular dependency between fixtures.ts and sharedFixtures.ts • Fixed incorrect function calls (grantOperatorRole → grantRole, setValidityPeriod → setEligibilityPeriod) • Updated default eligibility period from 7 days to 14 days (matches contract default) • Updated all test imports to use consolidated fixtures • Fixed RewardsManagerV6Storage class documentation comment --- .../rewards/RewardsManagerStorage.sol | 2 +- .../tests/RewardsEligibilityOracle.test.ts | 2 +- .../tests/consolidated/AccessControl.test.ts | 2 +- .../issuance/test/tests/helpers/fixtures.ts | 108 ++++++++++++++++-- .../test/tests/helpers/sharedFixtures.ts | 104 ----------------- 5 files changed, 101 insertions(+), 117 deletions(-) delete mode 100644 packages/issuance/test/tests/helpers/sharedFixtures.ts diff --git a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol index 6f54a6e22..e4588569c 100644 --- a/packages/contracts/contracts/rewards/RewardsManagerStorage.sol +++ b/packages/contracts/contracts/rewards/RewardsManagerStorage.sol @@ -78,7 +78,7 @@ contract RewardsManagerV5Storage is RewardsManagerV4Storage { } /** - * @title RewardsManagerV5Storage + * @title RewardsManagerV6Storage * @author Edge & Node * @notice Storage layout for RewardsManager V6 */ diff --git a/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts b/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts index 87940ca3d..c64d1dc70 100644 --- a/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts +++ b/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts @@ -10,9 +10,9 @@ import { deployRewardsEligibilityOracle, deployTestGraphToken, getTestAccounts, + SHARED_CONSTANTS, type TestAccounts, } from './helpers/fixtures' -import { SHARED_CONSTANTS } from './helpers/sharedFixtures' // Role constants const GOVERNOR_ROLE = SHARED_CONSTANTS.GOVERNOR_ROLE diff --git a/packages/issuance/test/tests/consolidated/AccessControl.test.ts b/packages/issuance/test/tests/consolidated/AccessControl.test.ts index 9eb42434c..0678fde2a 100644 --- a/packages/issuance/test/tests/consolidated/AccessControl.test.ts +++ b/packages/issuance/test/tests/consolidated/AccessControl.test.ts @@ -5,7 +5,7 @@ */ const { expect } = require('chai') -const { deploySharedContracts, resetContractState, SHARED_CONSTANTS } = require('../helpers/sharedFixtures') +const { deploySharedContracts, resetContractState, SHARED_CONSTANTS } = require('../helpers/fixtures') describe('Consolidated Access Control Tests', () => { let accounts: any diff --git a/packages/issuance/test/tests/helpers/fixtures.ts b/packages/issuance/test/tests/helpers/fixtures.ts index 0c2ff3e18..2efc97e88 100644 --- a/packages/issuance/test/tests/helpers/fixtures.ts +++ b/packages/issuance/test/tests/helpers/fixtures.ts @@ -1,9 +1,28 @@ +/** + * Test fixtures and setup utilities + * Contains deployment functions, shared constants, and test utilities + */ + import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' import * as fs from 'fs' const { ethers, upgrades } = require('hardhat') -const { SHARED_CONSTANTS } = require('./sharedFixtures') -const { OPERATOR_ROLE } = SHARED_CONSTANTS + +// 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 + +// Interface IDs +export const INTERFACE_IDS = { + IERC165: '0x01ffc9a7', +} as const // Types export interface TestAccounts { @@ -15,6 +34,24 @@ export interface TestAccounts { indexer2: HardhatEthersSigner } +export interface SharedContracts { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + graphToken: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rewardsEligibilityOracle: any +} + +export interface SharedAddresses { + graphToken: string + rewardsEligibilityOracle: string +} + +export interface SharedFixtures { + accounts: TestAccounts + contracts: SharedContracts + addresses: SharedAddresses +} + /** * Get standard test accounts */ @@ -60,12 +97,12 @@ export async function deployTestGraphToken(): Promise { * Deploy the RewardsEligibilityOracle contract with proxy using OpenZeppelin's upgrades library * @param graphToken The Graph Token contract address * @param governor The governor signer - * @param validityPeriod The validity period in seconds (default: 7 days) + * @param validityPeriod The validity period in seconds (default: 14 days) */ export async function deployRewardsEligibilityOracle( graphToken: string, governor: HardhatEthersSigner, - validityPeriod: number = 7 * 24 * 60 * 60, // 7 days in seconds + validityPeriod: number = 14 * 24 * 60 * 60, // 14 days in seconds (contract default) // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { // Deploy implementation and proxy using OpenZeppelin's upgrades library @@ -84,14 +121,65 @@ export async function deployRewardsEligibilityOracle( // Get the contract instance const rewardsEligibilityOracle = rewardsEligibilityOracleContract - // Set the validity period if it's different from the default - if (validityPeriod !== 7 * 24 * 60 * 60) { - // First grant operator role to governor so they can set the validity period - await rewardsEligibilityOracle.connect(governor).grantOperatorRole(governor.address) - await rewardsEligibilityOracle.connect(governor).setValidityPeriod(validityPeriod) + // 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(OPERATOR_ROLE, governor.address) + await rewardsEligibilityOracle.connect(governor).revokeRole(SHARED_CONSTANTS.OPERATOR_ROLE, governor.address) } return rewardsEligibilityOracle } + +/** + * Shared contract deployment and setup + */ +export async function deploySharedContracts(): Promise { + const accounts = await getTestAccounts() + + // Deploy base contracts + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + + const rewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // Cache addresses + const addresses: SharedAddresses = { + graphToken: graphTokenAddress, + rewardsEligibilityOracle: await rewardsEligibilityOracle.getAddress(), + } + + // Create helper + return { + accounts, + contracts: { + graphToken, + rewardsEligibilityOracle, + }, + addresses, + } +} + +/** + * Reset contract state to initial conditions + * Optimized to avoid redeployment while ensuring clean state + */ +export async function resetContractState(contracts: SharedContracts, accounts: TestAccounts): Promise { + const { rewardsEligibilityOracle } = contracts + + // Reset RewardsEligibilityOracle state + try { + if (await rewardsEligibilityOracle.paused()) { + await rewardsEligibilityOracle.connect(accounts.governor).unpause() + } + + // Reset eligibility validation to default (disabled) + if (await rewardsEligibilityOracle.getEligibilityValidation()) { + await rewardsEligibilityOracle.connect(accounts.governor).setEligibilityValidation(false) + } + } catch (error) { + console.warn('RewardsEligibilityOracle state reset failed:', error instanceof Error ? error.message : String(error)) + } +} diff --git a/packages/issuance/test/tests/helpers/sharedFixtures.ts b/packages/issuance/test/tests/helpers/sharedFixtures.ts deleted file mode 100644 index 8e56c4619..000000000 --- a/packages/issuance/test/tests/helpers/sharedFixtures.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Shared fixtures and setup utilities for all test files - * Reduces duplication of deployment and state management logic - */ - -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' - -const { ethers } = require('hardhat') -const { getTestAccounts, deployTestGraphToken, deployRewardsEligibilityOracle } = require('./fixtures') - -// 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 - -// Interface IDs -export const INTERFACE_IDS = { - IERC165: '0x01ffc9a7', -} as const - -// Types -export interface TestAccounts { - governor: HardhatEthersSigner - nonGovernor: HardhatEthersSigner - operator: HardhatEthersSigner - user: HardhatEthersSigner - indexer1: HardhatEthersSigner - indexer2: HardhatEthersSigner -} - -export interface SharedContracts { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - graphToken: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rewardsEligibilityOracle: any -} - -export interface SharedAddresses { - graphToken: string - rewardsEligibilityOracle: string -} - -export interface SharedFixtures { - accounts: TestAccounts - contracts: SharedContracts - addresses: SharedAddresses -} - -/** - * Shared contract deployment and setup - */ -export async function deploySharedContracts(): Promise { - const accounts = await getTestAccounts() - - // Deploy base contracts - const graphToken = await deployTestGraphToken() - const graphTokenAddress = await graphToken.getAddress() - - const rewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) - - // Cache addresses - const addresses: SharedAddresses = { - graphToken: graphTokenAddress, - rewardsEligibilityOracle: await rewardsEligibilityOracle.getAddress(), - } - - // Create helper - return { - accounts, - contracts: { - graphToken, - rewardsEligibilityOracle, - }, - addresses, - } -} - -/** - * Reset contract state to initial conditions - * Optimized to avoid redeployment while ensuring clean state - */ -export async function resetContractState(contracts: SharedContracts, accounts: TestAccounts): Promise { - const { rewardsEligibilityOracle } = contracts - - // Reset RewardsEligibilityOracle state - try { - if (await rewardsEligibilityOracle.paused()) { - await rewardsEligibilityOracle.connect(accounts.governor).unpause() - } - - // Reset eligibility validation to default (disabled) - if (await rewardsEligibilityOracle.getEligibilityValidation()) { - await rewardsEligibilityOracle.connect(accounts.governor).setEligibilityValidation(false) - } - } catch (error) { - console.warn('RewardsEligibilityOracle state reset failed:', error instanceof Error ? error.message : String(error)) - } -} From f8233f3806cd0dfe0be3f07b2b991d72494e860b Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 08:25:13 +0000 Subject: [PATCH 03/14] refactor: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Convert require() to ES6 imports for consistency (chai, ethers) • Add descriptive comments to empty catch blocks in generateInterfaceIds.js • Add mocha configuration to hardhat.config.cjs • Remove commented-out paths configuration • Add test:coverage script to package.json • Improve error handling in fixtures.ts (console.warn → console.error + throw) --- packages/issuance/hardhat.config.cjs | 10 ++++------ packages/issuance/package.json | 1 + packages/issuance/test/scripts/generateInterfaceIds.js | 4 ++-- .../test/tests/RewardsEligibilityOracle.test.ts | 6 ++++-- .../test/tests/consolidated/AccessControl.test.ts | 5 +++-- packages/issuance/test/tests/helpers/fixtures.ts | 9 +++++++-- packages/issuance/test/utils/testPatterns.ts | 2 +- 7 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/issuance/hardhat.config.cjs b/packages/issuance/hardhat.config.cjs index 27f2f6214..2067e0bd4 100644 --- a/packages/issuance/hardhat.config.cjs +++ b/packages/issuance/hardhat.config.cjs @@ -13,12 +13,6 @@ const dotenv = require('dotenv') dotenv.config() const config = { - // paths: { - // sources: './contracts', - // tests: './test', - // artifacts: './build/contracts', - // cache: './cache', - // }, solidity: { compilers: [ { @@ -85,6 +79,10 @@ const config = { currency: 'USD', outputFile: 'reports/gas-report.log', }, + mocha: { + reporter: 'spec', + timeout: 60000, + }, typechain: { outDir: 'types', target: 'ethers-v6', diff --git a/packages/issuance/package.json b/packages/issuance/package.json index e21abeb2a..5b51bbe26 100644 --- a/packages/issuance/package.json +++ b/packages/issuance/package.json @@ -16,6 +16,7 @@ "clean": "rm -rf build/ cache/ dist/ forge-artifacts/ cache_forge/", "compile": "hardhat compile", "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'", diff --git a/packages/issuance/test/scripts/generateInterfaceIds.js b/packages/issuance/test/scripts/generateInterfaceIds.js index 174a2316d..957307d1e 100644 --- a/packages/issuance/test/scripts/generateInterfaceIds.js +++ b/packages/issuance/test/scripts/generateInterfaceIds.js @@ -66,7 +66,7 @@ main().catch((error) => { try { fs.unlinkSync(tempScript) } catch { - // Ignore cleanup errors + // Ignore cleanup errors - temp file may not exist } if (code === 0) { @@ -80,7 +80,7 @@ main().catch((error) => { return } } catch { - // Not JSON, continue + // Not JSON, continue - this is expected for non-JSON output lines } } reject(new Error('Could not parse interface IDs from output')) diff --git a/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts b/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts index c64d1dc70..b7b6447d7 100644 --- a/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts +++ b/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts @@ -2,8 +2,9 @@ import '@nomicfoundation/hardhat-chai-matchers' import { time } from '@nomicfoundation/hardhat-network-helpers' import { expect } from 'chai' +import { ethers } from 'hardhat' -const { ethers, upgrades } = require('hardhat') +const { upgrades } = require('hardhat') import type { IGraphToken, RewardsEligibilityOracle } from '../../types' import { @@ -75,7 +76,8 @@ describe('RewardsEligibilityOracle', () => { await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(OPERATOR_ROLE, accounts.governor.address) } } catch { - // Ignore role management errors during reset + // 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 diff --git a/packages/issuance/test/tests/consolidated/AccessControl.test.ts b/packages/issuance/test/tests/consolidated/AccessControl.test.ts index 0678fde2a..eb7eb14e0 100644 --- a/packages/issuance/test/tests/consolidated/AccessControl.test.ts +++ b/packages/issuance/test/tests/consolidated/AccessControl.test.ts @@ -4,8 +4,9 @@ * Tests access control patterns across all contracts to reduce duplication */ -const { expect } = require('chai') -const { deploySharedContracts, resetContractState, SHARED_CONSTANTS } = require('../helpers/fixtures') +import { expect } from 'chai' + +import { deploySharedContracts, resetContractState, SHARED_CONSTANTS } from '../helpers/fixtures' describe('Consolidated Access Control Tests', () => { let accounts: any diff --git a/packages/issuance/test/tests/helpers/fixtures.ts b/packages/issuance/test/tests/helpers/fixtures.ts index 2efc97e88..4f5c7bf25 100644 --- a/packages/issuance/test/tests/helpers/fixtures.ts +++ b/packages/issuance/test/tests/helpers/fixtures.ts @@ -5,8 +5,9 @@ import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' import * as fs from 'fs' +import { ethers } from 'hardhat' -const { ethers, upgrades } = require('hardhat') +const { upgrades } = require('hardhat') // Shared test constants export const SHARED_CONSTANTS = { @@ -180,6 +181,10 @@ export async function resetContractState(contracts: SharedContracts, accounts: T await rewardsEligibilityOracle.connect(accounts.governor).setEligibilityValidation(false) } } catch (error) { - console.warn('RewardsEligibilityOracle state reset failed:', error instanceof Error ? error.message : String(error)) + console.error( + 'RewardsEligibilityOracle state reset failed:', + error instanceof Error ? error.message : String(error), + ) + throw error } } diff --git a/packages/issuance/test/utils/testPatterns.ts b/packages/issuance/test/utils/testPatterns.ts index 52c3a7a56..86aecd51c 100644 --- a/packages/issuance/test/utils/testPatterns.ts +++ b/packages/issuance/test/utils/testPatterns.ts @@ -3,7 +3,7 @@ * Shared test patterns and utilities to reduce duplication across test files */ -const { expect } = require('chai') +import { expect } from 'chai' // Test constants - centralized to avoid magic numbers export const TestConstants = { From e198a3b845c8abaeb57e7da3ed11c4fb640ac67b Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 08:32:30 +0000 Subject: [PATCH 04/14] feat: add test:coverage script to packages/contracts --- packages/contracts/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 090757e8a..d10af6e6f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -33,6 +33,7 @@ "build:self": "pnpm compile", "compile": "hardhat compile", "test": "pnpm --filter @graphprotocol/contracts-tests test", + "test:coverage": "pnpm --filter @graphprotocol/contracts-tests run test:coverage", "deploy": "pnpm predeploy && pnpm build", "deploy-localhost": "pnpm build", "predeploy": "scripts/predeploy", From 61e32c91e3adcc903f0f11b6c72253b9dceb80d9 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 08:42:10 +0000 Subject: [PATCH 05/14] chore: not sure if this file format is stable or keeps changing --- packages/token-distribution/.graphclient-extracted/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/token-distribution/.graphclient-extracted/index.d.ts b/packages/token-distribution/.graphclient-extracted/index.d.ts index 3ec32af02..faaba1f58 100644 --- a/packages/token-distribution/.graphclient-extracted/index.d.ts +++ b/packages/token-distribution/.graphclient-extracted/index.d.ts @@ -4,8 +4,8 @@ import type { GetMeshOptions } from '@graphql-mesh/runtime'; import type { YamlConfig } from '@graphql-mesh/types'; import { MeshHTTPHandler } from '@graphql-mesh/http'; import { ExecuteMeshFn, SubscribeMeshFn, MeshContext as BaseMeshContext, MeshInstance } from '@graphql-mesh/runtime'; -import type { GraphNetworkTypes } from './sources/graph-network/types'; import type { TokenDistributionTypes } from './sources/token-distribution/types'; +import type { GraphNetworkTypes } from './sources/graph-network/types'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Scalars = { From 39b161c4c45a3c2ba149b4a829dfdfc450c39dc3 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:18:16 +0000 Subject: [PATCH 06/14] refactor(issuance): clean up Hardhat configuration with proper inheritance - Convert from CommonJS to TypeScript for consistency - Create issuance-specific base config that inherits from toolshed - Remove unnecessary configuration duplication: - Remove unused hardhat-abi-exporter, hardhat-gas-reporter, solidity-coverage - Remove unnecessary mocha config (tests run in separate package) - Remove redundant etherscan customChains (arbitrumSepolia is built-in) - Remove manual etherscan API key config (inherited from toolshed) - Set evmVersion to 'cancun' for issuance contracts - Eliminate ~95% duplication across 3 config files - Follow established inheritance pattern like horizon/subgraph-service Before: 289 lines across 3 files with massive duplication After: ~90 lines with proper inheritance and minimal duplication --- packages/issuance/hardhat.base.config.ts | 24 ++++ packages/issuance/hardhat.config.cjs | 113 ------------------- packages/issuance/hardhat.config.ts | 20 ++++ packages/issuance/hardhat.coverage.config.ts | 69 +---------- packages/issuance/package.json | 1 + packages/issuance/test/hardhat.config.ts | 67 +---------- pnpm-lock.yaml | 3 + 7 files changed, 57 insertions(+), 240 deletions(-) create mode 100644 packages/issuance/hardhat.base.config.ts delete mode 100644 packages/issuance/hardhat.config.cjs create mode 100644 packages/issuance/hardhat.config.ts 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.cjs b/packages/issuance/hardhat.config.cjs deleted file mode 100644 index 2067e0bd4..000000000 --- a/packages/issuance/hardhat.config.cjs +++ /dev/null @@ -1,113 +0,0 @@ -require('@nomicfoundation/hardhat-ethers') -require('@nomicfoundation/hardhat-chai-matchers') -require('@typechain/hardhat') -require('hardhat-abi-exporter') -require('hardhat-contract-sizer') -require('hardhat-gas-reporter') -require('solidity-coverage') -require('@openzeppelin/hardhat-upgrades') -require('@nomicfoundation/hardhat-verify') - -const dotenv = require('dotenv') - -dotenv.config() - -const config = { - solidity: { - compilers: [ - { - version: '0.8.27', - }, - ], - }, - defaultNetwork: 'hardhat', - networks: { - hardhat: { - chainId: 1337, - loggingEnabled: false, - gas: 12000000, - gasPrice: 'auto', - initialBaseFeePerGas: 0, - blockGasLimit: 12000000, - // Support for forking - forking: - process.env.FORK === 'true' - ? { - url: - process.env.FORK_NETWORK === 'arbitrumSepolia' - ? process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc' - : process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', - blockNumber: process.env.FORK_BLOCK_NUMBER ? parseInt(process.env.FORK_BLOCK_NUMBER) : undefined, - } - : undefined, - }, - localhost: { - chainId: 1337, - url: 'http://127.0.0.1:8545', - accounts: { - mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', - }, - }, - // For connecting to Anvil fork - anvilFork: { - chainId: 31337, // Anvil's default chainId - url: process.env.ANVIL_FORK_URL || 'http://127.0.0.1:8545', - accounts: process.env.PRIVATE_KEY - ? [process.env.PRIVATE_KEY] - : { - mnemonic: 'test test test test test test test test test test test junk', - }, - // Pass through environment variables to the deployment script - params_file: process.env.PARAMS_FILE, - }, - arbitrumSepolia: { - chainId: 421614, - url: process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc', - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], - gasPrice: 'auto', - }, - arbitrumOne: { - chainId: 42161, - url: process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], - gasPrice: 'auto', - }, - }, - gasReporter: { - enabled: process.env.REPORT_GAS ? true : false, - showTimeSpent: true, - currency: 'USD', - outputFile: 'reports/gas-report.log', - }, - mocha: { - reporter: 'spec', - timeout: 60000, - }, - typechain: { - outDir: 'types', - target: 'ethers-v6', - }, - contractSizer: { - alphaSort: true, - runOnCompile: false, - disambiguatePaths: false, - }, - etherscan: { - apiKey: { - arbitrumOne: process.env.ARBISCAN_API_KEY, - arbitrumSepolia: process.env.ARBISCAN_API_KEY, - }, - customChains: [ - { - network: 'arbitrumSepolia', - chainId: 421614, - urls: { - apiURL: 'https://api-sepolia.arbiscan.io/api', - browserURL: 'https://sepolia.arbiscan.io', - }, - }, - ], - }, -} - -module.exports = config diff --git a/packages/issuance/hardhat.config.ts b/packages/issuance/hardhat.config.ts new file mode 100644 index 000000000..cce483193 --- /dev/null +++ b/packages/issuance/hardhat.config.ts @@ -0,0 +1,20 @@ +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', + }, +} + +export default config diff --git a/packages/issuance/hardhat.coverage.config.ts b/packages/issuance/hardhat.coverage.config.ts index 19d508b48..004578c29 100644 --- a/packages/issuance/hardhat.coverage.config.ts +++ b/packages/issuance/hardhat.coverage.config.ts @@ -4,79 +4,20 @@ import '@nomicfoundation/hardhat-network-helpers' import '@openzeppelin/hardhat-upgrades' import 'hardhat-gas-reporter' import 'solidity-coverage' -import 'dotenv/config' import { HardhatUserConfig } from 'hardhat/config' +import { issuanceBaseConfig } from './hardhat.base.config' + const config: HardhatUserConfig = { + ...issuanceBaseConfig, + // Coverage-specific paths paths: { sources: './contracts', tests: './test/tests', artifacts: './artifacts', cache: './cache', }, - solidity: { - compilers: [ - { - version: '0.8.27', - }, - ], - }, - defaultNetwork: 'hardhat', - networks: { - hardhat: { - chainId: 1337, - loggingEnabled: false, - gas: 12000000, - gasPrice: 'auto', - initialBaseFeePerGas: 0, - blockGasLimit: 12000000, - forking: - process.env.FORK === 'true' - ? { - url: - process.env.FORK_NETWORK === 'arbitrumSepolia' - ? process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc' - : process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', - blockNumber: process.env.FORK_BLOCK_NUMBER ? parseInt(process.env.FORK_BLOCK_NUMBER) : undefined, - } - : undefined, - }, - localhost: { - chainId: 1337, - url: 'http://127.0.0.1:8545', - accounts: { - mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', - }, - }, - anvilFork: { - chainId: 31337, - url: process.env.ANVIL_FORK_URL || 'http://127.0.0.1:8545', - accounts: process.env.PRIVATE_KEY - ? [process.env.PRIVATE_KEY] - : { - mnemonic: 'test test test test test test test test test test test junk', - }, - }, - arbitrumSepolia: { - chainId: 421614, - url: process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc', - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], - gasPrice: 'auto', - }, - arbitrumOne: { - chainId: 42161, - url: process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], - gasPrice: 'auto', - }, - }, - gasReporter: { - enabled: process.env.REPORT_GAS ? true : false, - showTimeSpent: true, - currency: 'USD', - outputFile: 'reports/gas-report.log', - }, -} +} as HardhatUserConfig export default config diff --git a/packages/issuance/package.json b/packages/issuance/package.json index 5b51bbe26..227d4947b 100644 --- a/packages/issuance/package.json +++ b/packages/issuance/package.json @@ -38,6 +38,7 @@ "license": "GPL-2.0-or-later", "devDependencies": { "@graphprotocol/interfaces": "workspace:^", + "@graphprotocol/toolshed": "workspace:^", "@nomicfoundation/hardhat-ethers": "catalog:", "@nomicfoundation/hardhat-verify": "catalog:", "@openzeppelin/contracts": "^5.3.0", diff --git a/packages/issuance/test/hardhat.config.ts b/packages/issuance/test/hardhat.config.ts index c485d1936..3d7b4c506 100644 --- a/packages/issuance/test/hardhat.config.ts +++ b/packages/issuance/test/hardhat.config.ts @@ -4,79 +4,20 @@ import '@nomicfoundation/hardhat-network-helpers' import '@openzeppelin/hardhat-upgrades' import 'hardhat-gas-reporter' import 'solidity-coverage' -import 'dotenv/config' import { artifactsDir, cacheDir } from '@graphprotocol/issuance' import { HardhatUserConfig } from 'hardhat/config' +import { issuanceBaseConfig } from '../hardhat.base.config' + const config: HardhatUserConfig = { + ...issuanceBaseConfig, + // Test-specific paths using issuance package exports paths: { tests: './tests', artifacts: artifactsDir, cache: cacheDir, }, - solidity: { - compilers: [ - { - version: '0.8.27', - }, - ], - }, - defaultNetwork: 'hardhat', - networks: { - hardhat: { - chainId: 1337, - loggingEnabled: false, - gas: 12000000, - gasPrice: 'auto', - initialBaseFeePerGas: 0, - blockGasLimit: 12000000, - forking: - process.env.FORK === 'true' - ? { - url: - process.env.FORK_NETWORK === 'arbitrumSepolia' - ? process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc' - : process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', - blockNumber: process.env.FORK_BLOCK_NUMBER ? parseInt(process.env.FORK_BLOCK_NUMBER) : undefined, - } - : undefined, - }, - localhost: { - chainId: 1337, - url: 'http://127.0.0.1:8545', - accounts: { - mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', - }, - }, - anvilFork: { - chainId: 31337, - url: process.env.ANVIL_FORK_URL || 'http://127.0.0.1:8545', - accounts: process.env.PRIVATE_KEY - ? [process.env.PRIVATE_KEY] - : { - mnemonic: 'test test test test test test test test test test test junk', - }, - }, - arbitrumSepolia: { - chainId: 421614, - url: process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc', - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], - gasPrice: 'auto', - }, - arbitrumOne: { - chainId: 42161, - url: process.env.ARBITRUM_ONE_RPC_URL || 'https://arb1.arbitrum.io/rpc', - accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], - gasPrice: 'auto', - }, - }, - gasReporter: { - enabled: process.env.REPORT_GAS ? true : false, - showTimeSpent: true, - currency: 'USD', - outputFile: 'reports/gas-report.log', - }, } export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7db18eb5f..cce888f3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -959,6 +959,9 @@ importers: '@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.19)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) From a9ba749e6c3a6f74cfa2bc645a5a346ac523848f Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:41:01 +0000 Subject: [PATCH 07/14] chore: upgrade @openzeppelin/contracts to 5.4.0 in issuance packages - Updates issuance package from 5.3.0 to 5.4.0 (latest stable) - Updates issuance test package from 5.3.0 to 5.4.0 - Maintains compatibility with Solidity 0.8.27 - All tests passing with updated dependencies --- packages/issuance/package.json | 4 ++-- packages/issuance/test/package.json | 4 ++-- pnpm-lock.yaml | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/issuance/package.json b/packages/issuance/package.json index 227d4947b..c90501e44 100644 --- a/packages/issuance/package.json +++ b/packages/issuance/package.json @@ -41,8 +41,8 @@ "@graphprotocol/toolshed": "workspace:^", "@nomicfoundation/hardhat-ethers": "catalog:", "@nomicfoundation/hardhat-verify": "catalog:", - "@openzeppelin/contracts": "^5.3.0", - "@openzeppelin/contracts-upgradeable": "^5.3.0", + "@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:", diff --git a/packages/issuance/test/package.json b/packages/issuance/test/package.json index 6235404b5..bc1939ba2 100644 --- a/packages/issuance/test/package.json +++ b/packages/issuance/test/package.json @@ -22,8 +22,8 @@ "@nomicfoundation/hardhat-foundry": "^1.1.1", "@nomicfoundation/hardhat-network-helpers": "^1.0.0", "@nomicfoundation/hardhat-toolbox": "5.0.0", - "@openzeppelin/contracts": "^5.3.0", - "@openzeppelin/contracts-upgradeable": "^5.3.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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cce888f3c..b148d69cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -969,10 +969,10 @@ importers: specifier: 'catalog:' version: 2.1.1(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.19)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) '@openzeppelin/contracts': - specifier: ^5.3.0 + specifier: ^5.4.0 version: 5.4.0 '@openzeppelin/contracts-upgradeable': - specifier: ^5.3.0 + specifier: ^5.4.0 version: 5.4.0(@openzeppelin/contracts@5.4.0) '@openzeppelin/hardhat-upgrades': specifier: ^3.9.0 @@ -1072,10 +1072,10 @@ importers: specifier: 5.0.0 version: 5.0.0(2132343380e0584d85bc36e173d91a88) '@openzeppelin/contracts': - specifier: ^5.3.0 + specifier: ^5.4.0 version: 5.4.0 '@openzeppelin/contracts-upgradeable': - specifier: ^5.3.0 + specifier: ^5.4.0 version: 5.4.0(@openzeppelin/contracts@5.4.0) '@openzeppelin/foundry-upgrades': specifier: 0.4.0 From e697c3d6b14892af401566a2e2e214cc23c94b11 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:47:50 +0000 Subject: [PATCH 08/14] fix(interfaces): add incremental build logic to avoid unnecessary rebuilding - Add timestamp-based checks for WAGMI, ethers-v5, and TypeScript compilation - Skip type generation when output files are newer than source files - Improve file-by-file comparison for dist directory organization - Reduce build time from 5+ seconds to ~600ms when no changes needed - Maintain build correctness while improving developer experience --- packages/interfaces/scripts/build.sh | 146 ++++++++++++++++++++------- 1 file changed, 112 insertions(+), 34 deletions(-) diff --git a/packages/interfaces/scripts/build.sh b/packages/interfaces/scripts/build.sh index 30f723a44..5c16d2864 100755 --- a/packages/interfaces/scripts/build.sh +++ b/packages/interfaces/scripts/build.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Complete build script for the interfaces package +# Complete build script for the interfaces package with incremental build support # This script handles: # 1. Hardhat compilation (generates artifacts and ethers-v6 types) # 2. Type generation (WAGMI and ethers-v5 types) @@ -8,42 +8,120 @@ set -e # Exit on any error -echo "🔨 Starting complete build process..." +echo "🔨 Starting build process..." + +# Helper function to check if target is newer than sources +is_newer() { + local target="$1" + shift + local sources=("$@") + + # If target doesn't exist, it needs to be built + if [[ ! -e "$target" ]]; then + return 1 + fi + + # Check if any source is newer than target + for source in "${sources[@]}"; do + if [[ -e "$source" && "$source" -nt "$target" ]]; then + return 1 + fi + done + + return 0 +} + +# Helper function to find files matching patterns +find_files() { + local pattern="$1" + find . -path "$pattern" -type f 2>/dev/null || true +} # Step 1: Hardhat compilation echo "📦 Compiling contracts with Hardhat..." -hardhat compile - -# Step 2: Generate types -echo "🏗️ Generating type definitions..." - -# Build wagmi types -echo " - Generating WAGMI types..." -pnpm wagmi generate - -# Build ethers-v5 types -echo " - Generating ethers-v5 types..." -pnpm typechain \ - --target ethers-v5 \ - --out-dir types-v5 \ - 'artifacts/contracts/**/!(*.dbg).json' \ - 'artifacts/@openzeppelin/**/!(*.dbg).json' - -# Step 3: TypeScript compilation -echo "🔧 Compiling TypeScript..." - -# Compile v6 types (default tsconfig) -echo " - Compiling ethers-v6 types..." -tsc - -# Compile v5 types (separate tsconfig) -echo " - Compiling ethers-v5 types..." -tsc -p tsconfig.v5.json - -# Step 4: Merge v5 types into dist directory -echo "📁 Organizing compiled types..." -mkdir -p dist/types-v5 -cp -r dist-v5/* dist/types-v5/ +pnpm hardhat compile + +# Step 2: Generate types (only if needed) +echo "🏗️ Checking type definitions..." + +# Check if WAGMI types need regeneration +wagmi_sources=( + "wagmi.config.mts" + $(find_files "./artifacts/contracts/**/!(*.dbg).json") +) +if ! is_newer "wagmi/generated.ts" "${wagmi_sources[@]}"; then + echo " - Generating WAGMI types..." + pnpm wagmi generate +else + echo " - WAGMI types are up to date" +fi + +# Check if ethers-v5 types need regeneration +v5_artifacts=($(find_files "./artifacts/contracts/**/!(*.dbg).json") $(find_files "./artifacts/@openzeppelin/**/!(*.dbg).json")) +if ! is_newer "types-v5/index.ts" "${v5_artifacts[@]}"; then + echo " - Generating ethers-v5 types..." + pnpm typechain \ + --target ethers-v5 \ + --out-dir types-v5 \ + 'artifacts/contracts/**/!(*.dbg).json' \ + 'artifacts/@openzeppelin/**/!(*.dbg).json' +else + echo " - ethers-v5 types are up to date" +fi + +# Step 3: TypeScript compilation (only if needed) +echo "🔧 Checking TypeScript compilation..." + +# Check if v6 types need compilation +v6_sources=( + "hardhat.config.ts" + $(find_files "./src/**/*.ts") + $(find_files "./types/**/*.ts") + $(find_files "./wagmi/**/*.ts") +) +if ! is_newer "dist/tsconfig.tsbuildinfo" "${v6_sources[@]}"; then + echo " - Compiling ethers-v6 types..." + pnpm tsc + touch dist/tsconfig.tsbuildinfo +else + echo " - ethers-v6 types are up to date" +fi + +# Check if v5 types need compilation +v5_sources=($(find_files "./types-v5/**/*.ts")) +if ! is_newer "dist-v5/tsconfig.v5.tsbuildinfo" "${v5_sources[@]}"; then + echo " - Compiling ethers-v5 types..." + pnpm tsc -p tsconfig.v5.json + touch dist-v5/tsconfig.v5.tsbuildinfo +else + echo " - ethers-v5 types are up to date" +fi + +# Step 4: Merge v5 types into dist directory (only if needed) +needs_copy=false +if [[ -d "dist-v5" ]]; then + if [[ ! -d "dist/types-v5" ]]; then + needs_copy=true + else + # Check if any file in dist-v5 is newer than the corresponding file in dist/types-v5 + while IFS= read -r -d '' file; do + relative_path="${file#dist-v5/}" + target_file="dist/types-v5/$relative_path" + if [[ ! -e "$target_file" || "$file" -nt "$target_file" ]]; then + needs_copy=true + break + fi + done < <(find dist-v5 -type f -print0) + fi +fi + +if [[ "$needs_copy" == "true" ]]; then + echo "📁 Organizing compiled types..." + mkdir -p dist/types-v5 + cp -r dist-v5/* dist/types-v5/ +else + echo "📁 Compiled types organization is up to date" +fi echo "✅ Build completed successfully!" echo "📄 Generated types:" From 49f19a4490b1de4a4072acbf52b6335aad64dffe Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:23:47 +0000 Subject: [PATCH 09/14] fix(horizon): respect minimum delegation requirement in slash test The testSlash_RoundDown_TokensThawing_Delegation test was failing because it could generate scenarios where undelegating would leave less than the minimum delegation amount (1 ether) in the pool, which violates the protocol's minimum delegation constraint. Added assumption to ensure undelegation either removes all tokens or leaves at least MIN_DELEGATION in the pool, making the test compliant with the protocol's validation rules introduced in commit 91cda561. --- packages/horizon/test/unit/staking/slash/slash.t.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/horizon/test/unit/staking/slash/slash.t.sol b/packages/horizon/test/unit/staking/slash/slash.t.sol index e5c365d67..3f4c4e63e 100644 --- a/packages/horizon/test/unit/staking/slash/slash.t.sol +++ b/packages/horizon/test/unit/staking/slash/slash.t.sol @@ -172,6 +172,8 @@ contract HorizonStakingSlashTest is HorizonStakingTest { vm.assume(delegationTokensToSlash <= delegationTokens); vm.assume(delegationTokensToUndelegate <= delegationTokens); vm.assume(delegationTokensToUndelegate > 0); + // Ensure that after undelegating, either we undelegate everything or leave at least MIN_DELEGATION + vm.assume(delegationTokensToUndelegate == delegationTokens || delegationTokens - delegationTokensToUndelegate >= MIN_DELEGATION); resetPrank(users.delegator); _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); From 25d88b644b444592b4e9e03bf589d5c9416e961b Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:28:38 +0000 Subject: [PATCH 10/14] fix(issuance): fix test configuration to resolve CI failure - Update test script to use main hardhat config instead of test-specific config - Remove unused test/hardhat.config.ts file - Fixes OpenZeppelin upgrades plugin contract resolution issue - All 40 RewardsEligibilityOracle tests now pass Resolves issue introduced in 39b161c4 where test config couldn't properly resolve contract sources for OpenZeppelin upgrades plugin validation. --- packages/issuance/test/hardhat.config.ts | 23 ----------------------- packages/issuance/test/package.json | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) delete mode 100644 packages/issuance/test/hardhat.config.ts diff --git a/packages/issuance/test/hardhat.config.ts b/packages/issuance/test/hardhat.config.ts deleted file mode 100644 index 3d7b4c506..000000000 --- a/packages/issuance/test/hardhat.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 { artifactsDir, cacheDir } from '@graphprotocol/issuance' -import { HardhatUserConfig } from 'hardhat/config' - -import { issuanceBaseConfig } from '../hardhat.base.config' - -const config: HardhatUserConfig = { - ...issuanceBaseConfig, - // Test-specific paths using issuance package exports - paths: { - tests: './tests', - artifacts: artifactsDir, - cache: cacheDir, - }, -} - -export default config diff --git a/packages/issuance/test/package.json b/packages/issuance/test/package.json index bc1939ba2..1adac78f4 100644 --- a/packages/issuance/test/package.json +++ b/packages/issuance/test/package.json @@ -49,7 +49,7 @@ "generate:interfaces": "node scripts/generateInterfaceIds.js --silent", "clean": "rm -rf build", "test": "pnpm build && pnpm test:self", - "test:self": "hardhat test tests/*.test.ts tests/**/*.test.ts", + "test:self": "cd .. && hardhat test test/tests/*.test.ts test/tests/**/*.test.ts", "test:coverage": "pnpm build && pnpm test:coverage:self", "test:coverage:self": "scripts/coverage", "lint": "pnpm lint:ts; pnpm lint:json", From 0abe931d18e55ad9eb2d2c93f6af2e467df95ade Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:33:25 +0000 Subject: [PATCH 11/14] fix(issuance): remove redundant compilation from test build script The build:dep step already compiles @graphprotocol/issuance as a dependency, so the explicit --filter @graphprotocol/issuance compile in build:self was redundant and inefficient. Now follows the same pattern as packages/contracts/test which only builds dependencies without redundant compilation steps. --- packages/issuance/test/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/issuance/test/package.json b/packages/issuance/test/package.json index 1adac78f4..f81b5cfab 100644 --- a/packages/issuance/test/package.json +++ b/packages/issuance/test/package.json @@ -45,7 +45,7 @@ "scripts": { "build": "pnpm build:dep && pnpm build:self", "build:dep": "pnpm --filter '@graphprotocol/issuance-test^...' run build:self", - "build:self": "tsc --build && pnpm --filter @graphprotocol/issuance compile && pnpm generate:interfaces", + "build:self": "tsc --build && pnpm generate:interfaces", "generate:interfaces": "node scripts/generateInterfaceIds.js --silent", "clean": "rm -rf build", "test": "pnpm build && pnpm test:self", From 373e2e95f65a1f2613390652cb97e7a0595725ed Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:52:04 +0000 Subject: [PATCH 12/14] fix: eliminate duplicate HTML coverage reports - Remove 'html' from istanbulReporter in .solcover.js files - Keep only ['lcov', 'text', 'json'] reporters to avoid duplicates - HTML reports now only generated in lcov-report/ subdirectory - Use default ./coverage output directory for modern pattern - Apply fix to both contracts and issuance packages --- packages/contracts/test/.solcover.js | 4 +++- packages/issuance/.solcover.js | 10 +++------- packages/issuance/hardhat.config.ts | 6 ++++++ packages/issuance/hardhat.coverage.config.ts | 5 ++--- packages/issuance/package.json | 2 +- packages/issuance/test/package.json | 2 +- packages/issuance/test/tsconfig.json | 2 +- 7 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/contracts/test/.solcover.js b/packages/contracts/test/.solcover.js index 25abcb002..a76089348 100644 --- a/packages/contracts/test/.solcover.js +++ b/packages/contracts/test/.solcover.js @@ -6,7 +6,9 @@ module.exports = { network_id: 1337, }, skipFiles, - istanbulFolder: './reports/coverage', + // Use default istanbulFolder: './coverage' + // Remove 'html' reporter to avoid duplicates, keep lcov for lcov.info + istanbulReporter: ['lcov', 'text', 'json'], configureYulOptimizer: true, mocha: { grep: '@skip-on-coverage', diff --git a/packages/issuance/.solcover.js b/packages/issuance/.solcover.js index e3dbe2e27..d8bbec4bb 100644 --- a/packages/issuance/.solcover.js +++ b/packages/issuance/.solcover.js @@ -4,16 +4,12 @@ module.exports = { mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', network_id: 1337, }, - istanbulFolder: './test/reports/coverage', + // 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, }, - reporter: ['html', 'lcov', 'text'], - reporterOptions: { - html: { - directory: './test/reports/coverage/html', - }, - }, } diff --git a/packages/issuance/hardhat.config.ts b/packages/issuance/hardhat.config.ts index cce483193..f76949af8 100644 --- a/packages/issuance/hardhat.config.ts +++ b/packages/issuance/hardhat.config.ts @@ -15,6 +15,12 @@ const config: HardhatUserConfig = { 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 index 004578c29..01ee96e83 100644 --- a/packages/issuance/hardhat.coverage.config.ts +++ b/packages/issuance/hardhat.coverage.config.ts @@ -11,12 +11,11 @@ import { issuanceBaseConfig } from './hardhat.base.config' const config: HardhatUserConfig = { ...issuanceBaseConfig, - // Coverage-specific paths paths: { sources: './contracts', tests: './test/tests', - artifacts: './artifacts', - cache: './cache', + artifacts: './coverage/artifacts', + cache: './coverage/cache', }, } as HardhatUserConfig diff --git a/packages/issuance/package.json b/packages/issuance/package.json index c90501e44..9fd7194af 100644 --- a/packages/issuance/package.json +++ b/packages/issuance/package.json @@ -13,7 +13,7 @@ "build": "pnpm build:dep && pnpm build:self", "build:dep": "pnpm --filter '@graphprotocol/issuance^...' run build:self", "build:self": "pnpm compile; pnpm typechain", - "clean": "rm -rf build/ cache/ dist/ forge-artifacts/ cache_forge/", + "clean": "rm -rf artifacts/ types/ forge-artifacts/ cache_forge/ coverage/ cache/ .eslintcache", "compile": "hardhat compile", "test": "pnpm --filter @graphprotocol/issuance-test test", "test:coverage": "pnpm --filter @graphprotocol/issuance-test run test:coverage", diff --git a/packages/issuance/test/package.json b/packages/issuance/test/package.json index f81b5cfab..1e215ff27 100644 --- a/packages/issuance/test/package.json +++ b/packages/issuance/test/package.json @@ -47,7 +47,7 @@ "build:dep": "pnpm --filter '@graphprotocol/issuance-test^...' run build:self", "build:self": "tsc --build && pnpm generate:interfaces", "generate:interfaces": "node scripts/generateInterfaceIds.js --silent", - "clean": "rm -rf build", + "clean": "rm -rf .eslintcache artifacts/", "test": "pnpm build && pnpm test:self", "test:self": "cd .. && hardhat test test/tests/*.test.ts test/tests/**/*.test.ts", "test:coverage": "pnpm build && pnpm test:coverage:self", diff --git a/packages/issuance/test/tsconfig.json b/packages/issuance/test/tsconfig.json index 0b5c1a868..46766ab90 100644 --- a/packages/issuance/test/tsconfig.json +++ b/packages/issuance/test/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../../tsconfig.json", "compilerOptions": { - "outDir": "./build" + "outDir": "./artifacts" }, "include": ["tests/**/*", "utils/**/*", "../types/**/*"], "exclude": ["node_modules", "build", "scripts/**/*"] From 247421610d84d82559102337f43d3a48219aeb26 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:13:14 +0000 Subject: [PATCH 13/14] refactor: centralize coverage detection with type-safe helper - Replace manual environment variable management with hre.__SOLIDITY_COVERAGE_RUNNING - Add centralized isRunningUnderCoverage() utility function - Simplify test coverage detection using clean if statements - Remove duplicate .solcover.js file (keep only test/.solcover.js) - Update coverage configuration for better reporting --- packages/contracts/.solcover.js | 23 ------------ packages/contracts/package.json | 2 +- packages/contracts/test/.solcover.js | 10 +----- packages/contracts/test/package.json | 2 +- packages/contracts/test/scripts/coverage | 1 - .../contracts/test/tests/unit/gns.test.ts | 30 ++++------------ .../tests/unit/l2/l2GraphTokenGateway.test.ts | 35 ++++--------------- .../contracts/test/tests/unit/lib/fixtures.ts | 4 ++- packages/contracts/test/utils/coverage.ts | 13 +++++++ .../test/unit/staking/slash/slash.t.sol | 6 ++-- 10 files changed, 36 insertions(+), 90 deletions(-) delete mode 100644 packages/contracts/.solcover.js create mode 100644 packages/contracts/test/utils/coverage.ts diff --git a/packages/contracts/.solcover.js b/packages/contracts/.solcover.js deleted file mode 100644 index 25abcb002..000000000 --- a/packages/contracts/.solcover.js +++ /dev/null @@ -1,23 +0,0 @@ -const skipFiles = ['bancor', 'ens', 'erc1056', 'arbitrum', 'tests/arbitrum'] - -module.exports = { - providerOptions: { - mnemonic: 'myth like bonus scare over problem client lizard pioneer submit female collect', - network_id: 1337, - }, - skipFiles, - istanbulFolder: './reports/coverage', - configureYulOptimizer: true, - mocha: { - grep: '@skip-on-coverage', - invert: true, - }, - onCompileComplete: async function (/* config */) { - // Set environment variable to indicate we're running under coverage - process.env.SOLIDITY_COVERAGE = 'true' - }, - onIstanbulComplete: async function (/* config */) { - // Clean up environment variable - delete process.env.SOLIDITY_COVERAGE - }, -} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index d10af6e6f..1f69f2e76 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -28,7 +28,7 @@ ], "scripts": { "prepack": "pnpm build", - "clean": "rm -rf artifacts/ cache/ types/ abis/ build/ dist/", + "clean": "rm -rf artifacts/ cache/ types/ abis/ build/ dist/ coverage/", "build": "pnpm build:self", "build:self": "pnpm compile", "compile": "hardhat compile", diff --git a/packages/contracts/test/.solcover.js b/packages/contracts/test/.solcover.js index a76089348..7181b78fa 100644 --- a/packages/contracts/test/.solcover.js +++ b/packages/contracts/test/.solcover.js @@ -6,7 +6,7 @@ module.exports = { network_id: 1337, }, skipFiles, - // Use default istanbulFolder: './coverage' + istanbulFolder: '../coverage', // Remove 'html' reporter to avoid duplicates, keep lcov for lcov.info istanbulReporter: ['lcov', 'text', 'json'], configureYulOptimizer: true, @@ -14,12 +14,4 @@ module.exports = { grep: '@skip-on-coverage', invert: true, }, - onCompileComplete: async function (/* config */) { - // Set environment variable to indicate we're running under coverage - process.env.SOLIDITY_COVERAGE = 'true' - }, - onIstanbulComplete: async function (/* config */) { - // Clean up environment variable - delete process.env.SOLIDITY_COVERAGE - }, } diff --git a/packages/contracts/test/package.json b/packages/contracts/test/package.json index 2deffeef2..7c80d38e2 100644 --- a/packages/contracts/test/package.json +++ b/packages/contracts/test/package.json @@ -56,7 +56,7 @@ }, "scripts": { "postinstall": "scripts/setup-symlinks", - "clean": "rm -rf artifacts/ cache/ reports/ types/", + "clean": "rm -rf artifacts/ cache/ types/", "build": "pnpm build:dep", "build:dep": "pnpm --filter '@graphprotocol/contracts-tests^...' run build:self", "test": "pnpm build && pnpm test:self", diff --git a/packages/contracts/test/scripts/coverage b/packages/contracts/test/scripts/coverage index bede10274..76f36856b 100755 --- a/packages/contracts/test/scripts/coverage +++ b/packages/contracts/test/scripts/coverage @@ -5,7 +5,6 @@ set -eo pipefail echo {} > addresses-local.json DISABLE_SECURE_ACCOUNTS=true \ -SOLIDITY_COVERAGE=true \ L1_GRAPH_CONFIG=config/graph.hardhat.yml \ L2_GRAPH_CONFIG=config/graph.arbitrum-hardhat.yml \ ADDRESS_BOOK=addresses-local.json \ diff --git a/packages/contracts/test/tests/unit/gns.test.ts b/packages/contracts/test/tests/unit/gns.test.ts index 8fd68d1c2..92e5d83e8 100644 --- a/packages/contracts/test/tests/unit/gns.test.ts +++ b/packages/contracts/test/tests/unit/gns.test.ts @@ -27,6 +27,7 @@ import { BigNumber, ContractTransaction, ethers, Event } from 'ethers' import { defaultAbiCoder } from 'ethers/lib/utils' import hre from 'hardhat' +import { isRunningUnderCoverage } from '../../utils/coverage' import { NetworkFixture } from './lib/fixtures' import { AccountDefaultName, @@ -787,16 +788,9 @@ describe.skip('L1GNS @skip-on-coverage', () => { const tx = gns.connect(me).multicall([bogusPayload, tx2.data]) // Under coverage, the error message may be different due to instrumentation - const isRunningUnderCoverage = - hre.network.name === 'coverage' || - process.env.SOLIDITY_COVERAGE === 'true' || - process.env.npm_lifecycle_event === 'test:coverage' - - if (isRunningUnderCoverage) { - // Under coverage, the transaction should still revert, but the message might be empty + if (isRunningUnderCoverage()) { await expect(tx).to.be.reverted } else { - // Normal test run should have the specific error message await expect(tx).revertedWith("function selector was not recognized and there's no fallback function") } }) @@ -1295,14 +1289,8 @@ describe.skip('L1GNS @skip-on-coverage', () => { await expect(tx2).revertedWith('NO_SIGNAL') }) it('sets the curator signal to zero so they cannot withdraw', async function () { - // Check if we're running under coverage - const isRunningUnderCoverage = - hre.network.name === 'coverage' || - process.env.SOLIDITY_COVERAGE === 'true' || - process.env.npm_lifecycle_event === 'test:coverage' - - if (isRunningUnderCoverage) { - // Under coverage, skip this test as it has issues with BigNumber values + // Under coverage, skip this test as it has issues with BigNumber values + if (isRunningUnderCoverage()) { this.skip() return } @@ -1328,14 +1316,8 @@ describe.skip('L1GNS @skip-on-coverage', () => { await expect(tx).revertedWith('GNS: No signal to withdraw GRT') }) it('gives each curator an amount of tokens proportional to their nSignal', async function () { - // Check if we're running under coverage - const isRunningUnderCoverage = - hre.network.name === 'coverage' || - process.env.SOLIDITY_COVERAGE === 'true' || - process.env.npm_lifecycle_event === 'test:coverage' - - if (isRunningUnderCoverage) { - // Under coverage, skip this test as it has issues with BigNumber values + // Under coverage, skip this test as it has issues with BigNumber values + if (isRunningUnderCoverage()) { this.skip() return } diff --git a/packages/contracts/test/tests/unit/l2/l2GraphTokenGateway.test.ts b/packages/contracts/test/tests/unit/l2/l2GraphTokenGateway.test.ts index b4bb7bcf4..45e6b0fc1 100644 --- a/packages/contracts/test/tests/unit/l2/l2GraphTokenGateway.test.ts +++ b/packages/contracts/test/tests/unit/l2/l2GraphTokenGateway.test.ts @@ -6,6 +6,7 @@ import { expect, use } from 'chai' import { constants, ContractTransaction, Signer, utils, Wallet } from 'ethers' import hre from 'hardhat' +import { isRunningUnderCoverage } from '../../../utils/coverage' import { NetworkFixture } from '../lib/fixtures' use(smock.matchers) @@ -80,12 +81,7 @@ describe('L2GraphTokenGateway', () => { await fixture.setUp() // Thanks to Livepeer: https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/test/unit/L2/l2LPTGateway.test.ts#L86 // Skip smock setup when running under coverage due to provider compatibility issues - const isRunningUnderCoverage = - hre.network.name === 'coverage' || - process.env.SOLIDITY_COVERAGE === 'true' || - process.env.npm_lifecycle_event === 'test:coverage' - - if (!isRunningUnderCoverage) { + if (!isRunningUnderCoverage()) { arbSysMock = await smock.fake('ArbSys', { address: '0x0000000000000000000000000000000000000064', }) @@ -237,12 +233,7 @@ describe('L2GraphTokenGateway', () => { // expect(arbSysMock.sendTxToL1).to.have.been.calledOnce // Only check smock expectations when not running under coverage - const isRunningUnderCoverage = - hre.network.name === 'coverage' || - process.env.SOLIDITY_COVERAGE === 'true' || - process.env.npm_lifecycle_event === 'test:coverage' - - if (!isRunningUnderCoverage && arbSysMock) { + if (!isRunningUnderCoverage() && arbSysMock) { expect(arbSysMock.sendTxToL1).to.have.been.calledWith(l1GRTGatewayMock.address, calldata) } const senderBalance = await grt.balanceOf(tokenSender.address) @@ -271,14 +262,8 @@ describe('L2GraphTokenGateway', () => { await expect(tx).revertedWith('TOKEN_NOT_GRT') }) it('burns tokens and triggers an L1 call', async function () { - // Check if we're running under coverage - const isRunningUnderCoverage = - hre.network.name === 'coverage' || - process.env.SOLIDITY_COVERAGE === 'true' || - process.env.npm_lifecycle_event === 'test:coverage' - - if (isRunningUnderCoverage) { - // Skip this test under coverage due to complex instrumentation issues + // Skip this test under coverage due to complex instrumentation issues + if (isRunningUnderCoverage()) { this.skip() return } @@ -287,14 +272,8 @@ describe('L2GraphTokenGateway', () => { await testValidOutboundTransfer(tokenSender, defaultData) }) it('decodes the sender address from messages sent by the router', async function () { - // Check if we're running under coverage - const isRunningUnderCoverage = - hre.network.name === 'coverage' || - process.env.SOLIDITY_COVERAGE === 'true' || - process.env.npm_lifecycle_event === 'test:coverage' - - if (isRunningUnderCoverage) { - // Skip this test under coverage due to complex instrumentation issues + // Skip this test under coverage due to complex instrumentation issues + if (isRunningUnderCoverage()) { this.skip() return } diff --git a/packages/contracts/test/tests/unit/lib/fixtures.ts b/packages/contracts/test/tests/unit/lib/fixtures.ts index 23e60c593..44ed50faa 100644 --- a/packages/contracts/test/tests/unit/lib/fixtures.ts +++ b/packages/contracts/test/tests/unit/lib/fixtures.ts @@ -28,6 +28,8 @@ import { import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' import { providers, Wallet } from 'ethers' +import { isRunningUnderCoverage } from '../../../utils/coverage' + export interface L1FixtureContracts { controller: Controller disputeManager: DisputeManager @@ -72,7 +74,7 @@ export class NetworkFixture { async load(deployer: SignerWithAddress, l2Deploy?: boolean): Promise { // Use instrumented artifacts when running coverage tests, otherwise use local artifacts - const artifactsDir = process.env.SOLIDITY_COVERAGE ? './artifacts' : '../artifacts' + const artifactsDir = isRunningUnderCoverage() ? './artifacts' : '../artifacts' const contracts = await deployGraphNetwork( 'addresses-local.json', diff --git a/packages/contracts/test/utils/coverage.ts b/packages/contracts/test/utils/coverage.ts new file mode 100644 index 000000000..068ab8527 --- /dev/null +++ b/packages/contracts/test/utils/coverage.ts @@ -0,0 +1,13 @@ +import hre from 'hardhat' + +/** + * Utility functions for detecting and handling coverage test execution + */ + +/** + * Checks if tests are currently running under solidity-coverage instrumentation + * @returns true if running under coverage, false otherwise + */ +export function isRunningUnderCoverage(): boolean { + return hre.__SOLIDITY_COVERAGE_RUNNING === true +} diff --git a/packages/horizon/test/unit/staking/slash/slash.t.sol b/packages/horizon/test/unit/staking/slash/slash.t.sol index 3f4c4e63e..003625d3b 100644 --- a/packages/horizon/test/unit/staking/slash/slash.t.sol +++ b/packages/horizon/test/unit/staking/slash/slash.t.sol @@ -172,8 +172,10 @@ contract HorizonStakingSlashTest is HorizonStakingTest { vm.assume(delegationTokensToSlash <= delegationTokens); vm.assume(delegationTokensToUndelegate <= delegationTokens); vm.assume(delegationTokensToUndelegate > 0); - // Ensure that after undelegating, either we undelegate everything or leave at least MIN_DELEGATION - vm.assume(delegationTokensToUndelegate == delegationTokens || delegationTokens - delegationTokensToUndelegate >= MIN_DELEGATION); + vm.assume( + delegationTokensToUndelegate == delegationTokens || + MIN_DELEGATION <= delegationTokens - delegationTokensToUndelegate + ); resetPrank(users.delegator); _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); From f2dca1036e38a566e2d1c29c240738206d5ca10b Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:23:28 +0000 Subject: [PATCH 14/14] ci: update coverage path for contracts package Update CI to look for coverage files in packages/contracts/coverage/ instead of packages/contracts/test/reports/coverage/ to match the updated .solcover.js configuration. --- .github/workflows/build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 3d9159478..9f7c1a7a7 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -35,7 +35,7 @@ jobs: id: coverage_files run: | # Find all coverage-final.json files - COVERAGE_FILES=$(find ./packages -name "coverage-final.json" -path "*/reports/coverage/*" | tr '\n' ',' | sed 's/,$//') + COVERAGE_FILES=$(find ./packages -name "coverage-final.json" -path "*/coverage/*" | tr '\n' ',' | sed 's/,$//') echo "files=$COVERAGE_FILES" >> $GITHUB_OUTPUT echo "Found coverage files: $COVERAGE_FILES"