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..e4588569c 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 RewardsManagerV6Storage + * @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/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", 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/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); 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/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:" 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.base.config.ts b/packages/issuance/hardhat.base.config.ts new file mode 100644 index 000000000..e4d0cc8bb --- /dev/null +++ b/packages/issuance/hardhat.base.config.ts @@ -0,0 +1,24 @@ +import { hardhatBaseConfig } from '@graphprotocol/toolshed/hardhat' +import type { HardhatUserConfig } from 'hardhat/config' + +// Issuance-specific Solidity configuration with Cancun EVM version +// Based on toolshed solidityUserConfig but with Cancun EVM target +export const issuanceSolidityConfig = { + version: '0.8.27', + settings: { + optimizer: { + enabled: true, + runs: 100, + }, + evmVersion: 'cancun' as const, + }, +} + +// Base configuration for issuance package - inherits from toolshed and overrides Solidity config +export const issuanceBaseConfig = (() => { + const baseConfig = hardhatBaseConfig(require) + return { + ...baseConfig, + solidity: issuanceSolidityConfig, + } as HardhatUserConfig +})() diff --git a/packages/issuance/hardhat.config.ts b/packages/issuance/hardhat.config.ts new file mode 100644 index 000000000..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 new file mode 100644 index 000000000..004578c29 --- /dev/null +++ b/packages/issuance/hardhat.coverage.config.ts @@ -0,0 +1,23 @@ +import '@nomicfoundation/hardhat-ethers' +import '@nomicfoundation/hardhat-chai-matchers' +import '@nomicfoundation/hardhat-network-helpers' +import '@openzeppelin/hardhat-upgrades' +import 'hardhat-gas-reporter' +import 'solidity-coverage' + +import { HardhatUserConfig } from 'hardhat/config' + +import { issuanceBaseConfig } from './hardhat.base.config' + +const config: HardhatUserConfig = { + ...issuanceBaseConfig, + // Coverage-specific paths + paths: { + sources: './contracts', + tests: './test/tests', + artifacts: './artifacts', + cache: './cache', + }, +} as HardhatUserConfig + +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..c90501e44 --- /dev/null +++ b/packages/issuance/package.json @@ -0,0 +1,73 @@ +{ + "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", + "test:coverage": "pnpm --filter @graphprotocol/issuance-test run test:coverage", + "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:md; pnpm lint:json", + "lint:ts": "eslint '**/*.{js,ts,cjs,mjs,jsx,tsx}' --fix --cache; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", + "lint:sol": "solhint --fix --noPrompt --noPoster 'contracts/**/*.sol'; prettier -w --cache --log-level warn 'contracts/**/*.sol'", + "lint:md": "markdownlint --fix --ignore-path ../../.gitignore '**/*.md'; prettier -w --cache --log-level warn '**/*.md'", + "lint:json": "prettier -w --cache --log-level warn '**/*.json'", + "typechain": "hardhat typechain", + "verify": "hardhat verify", + "size": "hardhat size-contracts", + "forge:build": "forge build" + }, + "files": [ + "artifacts/**/*", + "types/**/*", + "contracts/**/*", + "README.md", + "LICENSE" + ], + "author": "The Graph Team", + "license": "GPL-2.0-or-later", + "devDependencies": { + "@graphprotocol/interfaces": "workspace:^", + "@graphprotocol/toolshed": "workspace:^", + "@nomicfoundation/hardhat-ethers": "catalog:", + "@nomicfoundation/hardhat-verify": "catalog:", + "@openzeppelin/contracts": "^5.4.0", + "@openzeppelin/contracts-upgradeable": "^5.4.0", + "@openzeppelin/hardhat-upgrades": "^3.9.0", + "@typechain/ethers-v6": "^0.5.0", + "@typechain/hardhat": "catalog:", + "@types/node": "^20.17.50", + "dotenv": "catalog:", + "eslint": "catalog:", + "ethers": "catalog:", + "glob": "catalog:", + "globals": "catalog:", + "hardhat": "catalog:", + "hardhat-contract-sizer": "catalog:", + "hardhat-secure-accounts": "catalog:", + "hardhat-storage-layout": "catalog:", + "lint-staged": "catalog:", + "markdownlint-cli": "catalog:", + "prettier": "catalog:", + "prettier-plugin-solidity": "catalog:", + "solhint": "catalog:", + "ts-node": "^10.9.2", + "typechain": "^8.3.0", + "typescript": "catalog:", + "typescript-eslint": "catalog:", + "yaml-lint": "catalog:" + }, + "dependencies": { + "@noble/hashes": "^1.8.0" + } +} diff --git a/packages/issuance/prettier.config.cjs b/packages/issuance/prettier.config.cjs new file mode 100644 index 000000000..4e8dcf4f3 --- /dev/null +++ b/packages/issuance/prettier.config.cjs @@ -0,0 +1,5 @@ +const baseConfig = require('../../prettier.config.cjs') + +module.exports = { + ...baseConfig, +} diff --git a/packages/issuance/test/package.json b/packages/issuance/test/package.json new file mode 100644 index 000000000..f81b5cfab --- /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.4.0", + "@openzeppelin/contracts-upgradeable": "^5.4.0", + "@openzeppelin/foundry-upgrades": "0.4.0", + "@types/chai": "^4.3.20", + "@types/mocha": "^10.0.10", + "@types/node": "^20.17.50", + "chai": "^4.3.7", + "dotenv": "^16.5.0", + "eslint": "catalog:", + "eslint-plugin-no-only-tests": "catalog:", + "ethers": "catalog:", + "forge-std": "https://github.com/foundry-rs/forge-std/tarball/v1.9.7", + "glob": "catalog:", + "hardhat": "catalog:", + "hardhat-gas-reporter": "catalog:", + "prettier": "catalog:", + "solidity-coverage": "^0.8.0", + "ts-node": "^10.9.2", + "typescript": "catalog:" + }, + "scripts": { + "build": "pnpm build:dep && pnpm build:self", + "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", + "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", + "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..957307d1e --- /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 - temp file may not exist + } + + 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 - this is expected for non-JSON output lines + } + } + 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..b7b6447d7 --- /dev/null +++ b/packages/issuance/test/tests/RewardsEligibilityOracle.test.ts @@ -0,0 +1,673 @@ +import '@nomicfoundation/hardhat-chai-matchers' + +import { time } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import { ethers } from 'hardhat' + +const { upgrades } = require('hardhat') + +import type { IGraphToken, RewardsEligibilityOracle } from '../../types' +import { + deployRewardsEligibilityOracle, + deployTestGraphToken, + getTestAccounts, + SHARED_CONSTANTS, + type TestAccounts, +} from './helpers/fixtures' + +// Role constants +const GOVERNOR_ROLE = SHARED_CONSTANTS.GOVERNOR_ROLE +const ORACLE_ROLE = SHARED_CONSTANTS.ORACLE_ROLE +const OPERATOR_ROLE = SHARED_CONSTANTS.OPERATOR_ROLE + +// Types +interface SharedContracts { + graphToken: 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 { + // Role management errors during reset are non-fatal and may occur if roles are already revoked or not present. + // These errors are expected and can be safely ignored. + } + + // Reset to default values + try { + // Reset eligibility period to default (14 days) + const defaultEligibilityPeriod = 14 * 24 * 60 * 60 + const currentEligibilityPeriod = await rewardsEligibilityOracle.getEligibilityPeriod() + if (currentEligibilityPeriod !== BigInt(defaultEligibilityPeriod)) { + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.governor.address) + await rewardsEligibilityOracle.connect(accounts.governor).setEligibilityPeriod(defaultEligibilityPeriod) + await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(OPERATOR_ROLE, accounts.governor.address) + } + + // Reset eligibility validation to disabled + if (await rewardsEligibilityOracle.getEligibilityValidation()) { + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.governor.address) + await rewardsEligibilityOracle.connect(accounts.governor).setEligibilityValidation(false) + await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(OPERATOR_ROLE, accounts.governor.address) + } + + // Reset oracle update timeout to default (7 days) + const defaultTimeout = 7 * 24 * 60 * 60 + const currentTimeout = await rewardsEligibilityOracle.getOracleUpdateTimeout() + if (currentTimeout !== BigInt(defaultTimeout)) { + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.governor.address) + await rewardsEligibilityOracle.connect(accounts.governor).setOracleUpdateTimeout(defaultTimeout) + await rewardsEligibilityOracle.connect(accounts.governor).revokeRole(OPERATOR_ROLE, accounts.governor.address) + } + } catch { + // Ignore reset errors + } + } + + beforeEach(async () => { + if (!accounts) { + accounts = await getTestAccounts() + } + await resetOracleState() + }) + + describe('Construction', () => { + it('should revert when constructed with zero GraphToken address', async () => { + const RewardsEligibilityOracleFactory = await ethers.getContractFactory('RewardsEligibilityOracle') + await expect(RewardsEligibilityOracleFactory.deploy(ethers.ZeroAddress)).to.be.revertedWithCustomError( + RewardsEligibilityOracleFactory, + 'GraphTokenCannotBeZeroAddress', + ) + }) + + it('should revert when initialized with zero governor address', async () => { + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + + // Try to deploy proxy with zero governor address - this should hit the BaseUpgradeable check + const RewardsEligibilityOracleFactory = await ethers.getContractFactory('RewardsEligibilityOracle') + await expect( + upgrades.deployProxy(RewardsEligibilityOracleFactory, [ethers.ZeroAddress], { + constructorArgs: [graphTokenAddress], + initializer: 'initialize', + }), + ).to.be.revertedWithCustomError(RewardsEligibilityOracleFactory, 'GovernorCannotBeZeroAddress') + }) + }) + + describe('Initialization', () => { + it('should set the governor role correctly', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.hasRole(GOVERNOR_ROLE, accounts.governor.address)).to.be.true + }) + + it('should not set oracle role to anyone initially', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.hasRole(ORACLE_ROLE, accounts.operator.address)).to.be.false + }) + + it('should set default eligibility period to 14 days', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.getEligibilityPeriod()).to.equal(14 * 24 * 60 * 60) // 14 days in seconds + }) + + it('should set eligibility validation to disabled by default', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.false + }) + + it('should set default oracle update timeout to 7 days', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.getOracleUpdateTimeout()).to.equal(7 * 24 * 60 * 60) // 7 days in seconds + }) + + it('should initialize lastOracleUpdateTime to 0', async () => { + const { rewardsEligibilityOracle } = sharedContracts + expect(await rewardsEligibilityOracle.getLastOracleUpdateTime()).to.equal(0) + }) + + it('should revert when initialize is called more than once', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Try to call initialize again + await expect(rewardsEligibilityOracle.initialize(accounts.governor.address)).to.be.revertedWithCustomError( + rewardsEligibilityOracle, + 'InvalidInitialization', + ) + }) + }) + + describe('Oracle Management', () => { + it('should allow operator to grant oracle role', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role to the operator account + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + + // Operator grants oracle role + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.user.address) + expect(await rewardsEligibilityOracle.hasRole(ORACLE_ROLE, accounts.user.address)).to.be.true + }) + + it('should allow operator to revoke oracle role', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role to the operator account + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + + // Grant oracle role first + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.user.address) + expect(await rewardsEligibilityOracle.hasRole(ORACLE_ROLE, accounts.user.address)).to.be.true + + // Revoke role + await rewardsEligibilityOracle.connect(accounts.operator).revokeRole(ORACLE_ROLE, accounts.user.address) + expect(await rewardsEligibilityOracle.hasRole(ORACLE_ROLE, accounts.user.address)).to.be.false + }) + + // Access control tests moved to consolidated/AccessControl.test.ts + }) + + describe('Operator Functions', () => { + beforeEach(async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role to the operator account + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + }) + + it('should allow operator to set eligibility period', async () => { + const { rewardsEligibilityOracle } = sharedContracts + const newEligibilityPeriod = 14 * 24 * 60 * 60 // 14 days + + // Set eligibility period + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityPeriod(newEligibilityPeriod) + + // Check if eligibility period was updated + expect(await rewardsEligibilityOracle.getEligibilityPeriod()).to.equal(newEligibilityPeriod) + }) + + it('should handle idempotent operations correctly', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Test setting same eligibility period + const currentEligibilityPeriod = await rewardsEligibilityOracle.getEligibilityPeriod() + let result = await rewardsEligibilityOracle + .connect(accounts.operator) + .setEligibilityPeriod.staticCall(currentEligibilityPeriod) + expect(result).to.be.true + + // Verify no event emitted for same value + let tx = await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityPeriod(currentEligibilityPeriod) + let receipt = await tx.wait() + expect(receipt!.logs.length).to.equal(0) + + // Test setting new oracle update timeout + const newTimeout = 60 * 24 * 60 * 60 // 60 days + await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(newTimeout) + expect(await rewardsEligibilityOracle.getOracleUpdateTimeout()).to.equal(newTimeout) + + // Test setting same oracle update timeout + result = await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout.staticCall(newTimeout) + expect(result).to.be.true + + // Verify no event emitted for same value + tx = await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(newTimeout) + receipt = await tx.wait() + expect(receipt!.logs.length).to.equal(0) + }) + + it('should allow operator to disable eligibility checking', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Disable eligibility validation + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + + // Check if eligibility validation is disabled + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.false + }) + + it('should allow operator to enable eligibility checking', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Disable eligibility validation first + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.false + + // Enable eligibility validation + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // Check if eligibility validation is enabled + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.true + }) + + it('should handle setEligibilityValidation return values and events correctly', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Test 1: Return true when enabling eligibility validation that is already enabled + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.true + + const enableResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .setEligibilityValidation.staticCall(true) + expect(enableResult).to.be.true + + // Test 2: No event emitted when setting to same state (enabled) + const enableTx = await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + const enableReceipt = await enableTx.wait() + expect(enableReceipt!.logs.length).to.equal(0) + + // Test 3: Return true when disabling eligibility validation that is already disabled + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + expect(await rewardsEligibilityOracle.getEligibilityValidation()).to.be.false + + const disableResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .setEligibilityValidation.staticCall(false) + expect(disableResult).to.be.true + + // Test 4: No event emitted when setting to same state (disabled) + const disableTx = await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + const disableReceipt = await disableTx.wait() + expect(disableReceipt!.logs.length).to.equal(0) + + // Test 5: Events are emitted when state actually changes + await expect(rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true)) + .to.emit(rewardsEligibilityOracle, 'EligibilityValidationUpdated') + .withArgs(true) + + await expect(rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false)) + .to.emit(rewardsEligibilityOracle, 'EligibilityValidationUpdated') + .withArgs(false) + }) + + // Access control tests moved to consolidated/AccessControl.test.ts + // Event and return value tests consolidated into 'should handle setEligibilityValidation return values and events correctly' + }) + + describe('Indexer Management', () => { + beforeEach(async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role to the operator account + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + + // Grant oracle role + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + }) + + it('should allow oracle to allow a single indexer', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Renew indexer eligibility using renewIndexerEligibility with a single-element array + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Check if indexer is eligible + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + + // Check if allowed timestamp was updated + const eligibilityRenewalTime = await rewardsEligibilityOracle.getEligibilityRenewalTime(accounts.indexer1.address) + expect(eligibilityRenewalTime).to.be.gt(0) + }) + + it('should allow oracle to allow multiple indexers', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Allow multiple indexers + const indexers = [accounts.indexer1.address, accounts.indexer2.address] + await rewardsEligibilityOracle.connect(accounts.operator).renewIndexerEligibility(indexers, '0x') + + // Check if indexers are eligible + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer2.address)).to.be.true + + // Check if allowed timestamps were updated + const eligibilityRenewalTime1 = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + const eligibilityRenewalTime2 = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer2.address, + ) + expect(eligibilityRenewalTime1).to.be.gt(0) + expect(eligibilityRenewalTime2).to.be.gt(0) + }) + + it('should not update last renewal timestamp for indexer already renewed in the same block', async () => { + const { rewardsEligibilityOracle } = sharedContracts + // Renew indexer eligibility first time + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Get the timestamp + const initialEligibilityRenewalTime = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + + // Call renewIndexerEligibility again with the same indexer + const result = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall([accounts.indexer1.address], '0x') + + // The function should return 0 since the indexer was already allowed in this block + expect(result).to.equal(0) + + // Verify the timestamp hasn't changed + const finalEligibilityRenewalTime = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + expect(finalEligibilityRenewalTime).to.equal(initialEligibilityRenewalTime) + + // Mine a new block + await ethers.provider.send('evm_mine', []) + + // Now try again in a new block - it should return 1 + const newBlockResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall([accounts.indexer1.address], '0x') + + // The function should return 1 since we're in a new block + expect(newBlockResult).to.equal(1) + }) + + it('should revert when non-oracle tries to allow a single indexer', async () => { + const { rewardsEligibilityOracle } = sharedContracts + await expect( + rewardsEligibilityOracle + .connect(accounts.nonGovernor) + .renewIndexerEligibility([accounts.indexer1.address], '0x'), + ).to.be.revertedWithCustomError(rewardsEligibilityOracle, 'AccessControlUnauthorizedAccount') + }) + + it('should revert when non-oracle tries to allow multiple indexers', async () => { + const { rewardsEligibilityOracle } = sharedContracts + const indexers = [accounts.indexer1.address, accounts.indexer2.address] + await expect( + rewardsEligibilityOracle.connect(accounts.nonGovernor).renewIndexerEligibility(indexers, '0x'), + ).to.be.revertedWithCustomError(rewardsEligibilityOracle, 'AccessControlUnauthorizedAccount') + }) + + it('should return correct count for various renewIndexerEligibility scenarios', async () => { + const { rewardsEligibilityOracle } = sharedContracts + + // Test 1: Single indexer should return 1 + const singleResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall([accounts.indexer1.address], '0x') + expect(singleResult).to.equal(1) + + // Test 2: Multiple indexers should return correct count + const multipleIndexers = [accounts.indexer1.address, accounts.indexer2.address] + const multipleResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall(multipleIndexers, '0x') + expect(multipleResult).to.equal(2) + + // Test 3: Empty array should return 0 + const emptyResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall([], '0x') + expect(emptyResult).to.equal(0) + + // Test 4: Array with zero addresses should only count non-zero addresses + const withZeroAddresses = [accounts.indexer1.address, ethers.ZeroAddress, accounts.indexer2.address] + const zeroResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall(withZeroAddresses, '0x') + expect(zeroResult).to.equal(2) + + // Test 5: Array with duplicates should only count unique indexers + const withDuplicates = [accounts.indexer1.address, accounts.indexer1.address, accounts.indexer2.address] + const duplicateResult = await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility.staticCall(withDuplicates, '0x') + expect(duplicateResult).to.equal(2) + }) + }) + + describe('View Functions', () => { + // Use shared contracts instead of deploying fresh ones for each test + + it('should return 0 when getting last renewal time for indexer that was never renewed', async () => { + // Use a fresh deployment to avoid contamination from previous tests + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const freshRewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // This should return 0 for a fresh contract + const lastEligibilityRenewalTime = await freshRewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + expect(lastEligibilityRenewalTime).to.equal(0) + }) + + it('should return correct last renewal timestamp for renewed indexer', async function () { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant operator role first (governor can grant operator role) + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + // Then operator can grant oracle role (operator is admin of oracle role) + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Renew indexer eligibility + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Get the last allowed time + const lastEligibilityRenewalTime = await rewardsEligibilityOracle.getEligibilityRenewalTime( + accounts.indexer1.address, + ) + + // Get the current block timestamp + const block = await ethers.provider.getBlock('latest') + const blockTimestamp = block ? block.timestamp : 0 + + // The last allowed time should be close to the current block timestamp + expect(lastEligibilityRenewalTime).to.be.closeTo(blockTimestamp, 5) // Allow 5 seconds of difference + }) + + it('should correctly report if an indexer is eligible', async function () { + // Use a fresh deployment to avoid shared state contamination + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const freshRewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // Grant necessary roles (follow role hierarchy) + await freshRewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await freshRewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await freshRewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // First, set a non-zero lastOracleUpdateTime to prevent the timeout condition from triggering + await freshRewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.nonGovernor.address], '0x') + + // Now check if our test indexer is eligible (it shouldn't be) + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + + // Renew indexer eligibility + await freshRewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + }) + + it('should return true for all indexers when eligibility checking is disabled', async function () { + // Use a fresh deployment to avoid shared state contamination + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const freshRewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // Grant necessary roles (follow role hierarchy) + await freshRewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await freshRewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await freshRewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // First, set a non-zero lastOracleUpdateTime to prevent the timeout condition from triggering + await freshRewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.nonGovernor.address], '0x') + + // Set a very long oracle update timeout to prevent that condition from triggering + await freshRewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(365 * 24 * 60 * 60) // 1 year + + // Now check if our test indexer is eligible (it shouldn't be) + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + + // Disable eligibility validation + await freshRewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(false) + + // Now indexer should be allowed even without being explicitly allowed + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + }) + + it('should return true for all indexers when oracle update timeout is exceeded', async function () { + // Use a fresh deployment to avoid shared state contamination + const graphToken = await deployTestGraphToken() + const graphTokenAddress = await graphToken.getAddress() + const freshRewardsEligibilityOracle = await deployRewardsEligibilityOracle(graphTokenAddress, accounts.governor) + + // Grant necessary roles (follow role hierarchy) + await freshRewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await freshRewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await freshRewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // First, set a non-zero lastOracleUpdateTime to prevent the initial timeout condition from triggering + await freshRewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.nonGovernor.address], '0x') + + // Set a very long oracle update timeout initially + await freshRewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(365 * 24 * 60 * 60) // 1 year + + // Now check if our test indexer is eligible (it shouldn't be) + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + + // Set a short oracle update timeout + await freshRewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(60) // 1 minute + + // Advance time beyond the timeout + await time.increase(120) // 2 minutes + + // Now indexer should be allowed even without being explicitly allowed + expect(await freshRewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + }) + + it('should return false for indexer after eligibility period expires', async function () { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant necessary roles (follow role hierarchy) + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // Set a very long oracle update timeout to prevent that condition from triggering + await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(365 * 24 * 60 * 60) // 1 year + + // Renew indexer eligibility + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + + // Set a short eligibility period + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityPeriod(60) // 1 minute + + // Advance time beyond eligibility period + await time.increase(120) // 2 minutes + + // Now indexer should not be allowed + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + }) + + it('should return true for indexer after re-allowing', async function () { + const { rewardsEligibilityOracle } = sharedContracts + + // Grant necessary roles + await rewardsEligibilityOracle.connect(accounts.governor).grantRole(OPERATOR_ROLE, accounts.operator.address) + await rewardsEligibilityOracle.connect(accounts.operator).grantRole(ORACLE_ROLE, accounts.operator.address) + + // Enable eligibility validation first (since it's disabled by default) + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityValidation(true) + + // Set a very long oracle update timeout to prevent that condition from triggering + await rewardsEligibilityOracle.connect(accounts.operator).setOracleUpdateTimeout(365 * 24 * 60 * 60) // 1 year + + // Renew indexer eligibility + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Set a short eligibility period + await rewardsEligibilityOracle.connect(accounts.operator).setEligibilityPeriod(60) // 1 minute + + // Advance time beyond eligibility period + await time.increase(120) // 2 minutes + + // Indexer should not be allowed + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.false + + // Re-renew indexer eligibility + await rewardsEligibilityOracle + .connect(accounts.operator) + .renewIndexerEligibility([accounts.indexer1.address], '0x') + + // Now indexer should be allowed again + expect(await rewardsEligibilityOracle.isEligible(accounts.indexer1.address)).to.be.true + }) + }) +}) diff --git a/packages/issuance/test/tests/consolidated/AccessControl.test.ts b/packages/issuance/test/tests/consolidated/AccessControl.test.ts new file mode 100644 index 000000000..eb7eb14e0 --- /dev/null +++ b/packages/issuance/test/tests/consolidated/AccessControl.test.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Consolidated Access Control Tests + * Tests access control patterns across all contracts to reduce duplication + */ + +import { expect } from 'chai' + +import { deploySharedContracts, resetContractState, SHARED_CONSTANTS } from '../helpers/fixtures' + +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..4f5c7bf25 --- /dev/null +++ b/packages/issuance/test/tests/helpers/fixtures.ts @@ -0,0 +1,190 @@ +/** + * 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' +import { ethers } from 'hardhat' + +const { upgrades } = require('hardhat') + +// 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 +} + +/** + * 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: 14 days) + */ +export async function deployRewardsEligibilityOracle( + graphToken: string, + governor: HardhatEthersSigner, + 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 + const RewardsEligibilityOracleFactory = await ethers.getContractFactory('RewardsEligibilityOracle') + + // Deploy proxy with implementation + const rewardsEligibilityOracleContract = await upgrades.deployProxy( + RewardsEligibilityOracleFactory, + [governor.address], + { + constructorArgs: [graphToken], + initializer: 'initialize', + }, + ) + + // Get the contract instance + const rewardsEligibilityOracle = rewardsEligibilityOracleContract + + // Set the eligibility period if it's different from the default (14 days) + if (validityPeriod !== 14 * 24 * 60 * 60) { + // First grant operator role to governor so they can set the eligibility period + await rewardsEligibilityOracle.connect(governor).grantRole(SHARED_CONSTANTS.OPERATOR_ROLE, governor.address) + await rewardsEligibilityOracle.connect(governor).setEligibilityPeriod(validityPeriod) + // Now revoke the operator role from governor to ensure tests start with clean state + await rewardsEligibilityOracle.connect(governor).revokeRole(SHARED_CONSTANTS.OPERATOR_ROLE, governor.address) + } + + return rewardsEligibilityOracle +} + +/** + * 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.error( + 'RewardsEligibilityOracle state reset failed:', + error instanceof Error ? error.message : String(error), + ) + throw error + } +} 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/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..86aecd51c --- /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 + */ + +import { expect } from '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/pnpm-lock.yaml b/pnpm-lock.yaml index a9f31a0a0..b148d69cb 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,185 @@ 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 + '@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)) + '@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.4.0 + version: 5.4.0 + '@openzeppelin/contracts-upgradeable': + specifier: ^5.4.0 + version: 5.4.0(@openzeppelin/contracts@5.4.0) + '@openzeppelin/hardhat-upgrades': + specifier: ^3.9.0 + version: 3.9.1(@nomicfoundation/hardhat-ethers@3.1.0(ethers@6.15.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(hardhat@2.26.3(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.19.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.4.0 + version: 5.4.0 + '@openzeppelin/contracts-upgradeable': + specifier: ^5.4.0 + version: 5.4.0(@openzeppelin/contracts@5.4.0) + '@openzeppelin/foundry-upgrades': + specifier: 0.4.0 + version: 0.4.0(@openzeppelin/defender-deploy-client-cli@0.0.1-alpha.10(encoding@0.1.13))(@openzeppelin/upgrades-core@1.44.1) + '@types/chai': + specifier: ^4.3.20 + version: 4.3.20 + '@types/mocha': + specifier: ^10.0.10 + version: 10.0.10 + '@types/node': + specifier: ^20.17.50 + version: 20.19.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 +3529,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 +3686,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 +11442,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 +15814,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 +16001,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 +16019,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 +16029,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 +16060,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 +26448,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + undici@6.22.0: {} + unfetch@4.2.0: {} unicorn-magic@0.1.0: {}