diff --git a/packages/horizon/tasks/test/fixtures/indexers.ts b/packages/horizon/tasks/test/fixtures/indexers.ts index 6580b3ed0..d92559ac0 100644 --- a/packages/horizon/tasks/test/fixtures/indexers.ts +++ b/packages/horizon/tasks/test/fixtures/indexers.ts @@ -38,7 +38,7 @@ const INDEXER_TWO_SECOND_ALLOCATION_PRIVATE_KEY = '0xab6cb9dbb3646a856e6cac2c0e2 // Indexer three data const INDEXER_THREE_ADDRESS = '0x28a8746e75304c0780E011BEd21C72cD78cd535E' // Hardhat account #6 - +const INDEXER_THREE_REWARDS_DESTINATION = '0xA3D22DDf431A8745888804F520D4eA51Cb43A458' // Subgraph deployment IDs const SUBGRAPH_DEPLOYMENT_ID_ONE = '0x02cd85012c1f075fd58fad178fd23ab841d3b5ddcf5cd3377c30118da97cb2a4' const SUBGRAPH_DEPLOYMENT_ID_TWO = '0x03ca89485a59894f1acfa34660c69024b6b90ce45171dece7662b0886bc375c7' @@ -47,7 +47,7 @@ const SUBGRAPH_DEPLOYMENT_ID_THREE = '0x0472e8c46f728adb65a22187c6740532f82c2eba export const indexers: Indexer[] = [ { address: INDEXER_ONE_ADDRESS, - stake: parseEther('1000000'), + stake: parseEther('1100000'), tokensToUnstake: parseEther('10000'), indexingRewardCut: 900000, // 90% queryFeeCut: 900000, // 90% @@ -74,7 +74,7 @@ export const indexers: Indexer[] = [ }, { address: INDEXER_TWO_ADDRESS, - stake: parseEther('1000000'), + stake: parseEther('1100000'), tokensToUnstake: parseEther('1000000'), indexingRewardCut: 850000, // 85% queryFeeCut: 850000, // 85% @@ -96,9 +96,10 @@ export const indexers: Indexer[] = [ }, { address: INDEXER_THREE_ADDRESS, - stake: parseEther('1000000'), + stake: parseEther('1100000'), indexingRewardCut: 800000, // 80% queryFeeCut: 800000, // 80% + rewardsDestination: INDEXER_THREE_REWARDS_DESTINATION, allocations: [], }, ] diff --git a/packages/subgraph-service/ignition/configs/migrate.integration-test.json5 b/packages/subgraph-service/ignition/configs/migrate.integration-test.json5 new file mode 100644 index 000000000..7effb5dec --- /dev/null +++ b/packages/subgraph-service/ignition/configs/migrate.integration-test.json5 @@ -0,0 +1,33 @@ +{ + "$global": { + // Accounts for new deployment - derived from local network mnemonic + "governor": "0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0", + "arbitrator": "0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b", + "pauseGuardian": "0xE11BA2b4D45Eaed5996Cd0823791E0C93114882d", + + // Addresses for contracts deployed in the original Graph Protocol - Arbitrum Sepolia values + "controllerAddress": "0x9DB3ee191681f092607035d9BDA6e59FbEaCa695", + "curationProxyAddress": "0xDe761f075200E75485F4358978FB4d1dC8644FD5", + "curationImplementationAddress": "0xd90022aB67920212D0F902F5c427DE82732DE136", + + // Must be set for step 2 of the deployment + "disputeManagerProxyAddress": "", + "disputeManagerProxyAdminAddress": "", + "subgraphServiceProxyAddress": "", + "subgraphServiceProxyAdminAddress": "", + "graphTallyCollectorAddress": "" + }, + "DisputeManager": { + "disputePeriod": 2419200, + "disputeDeposit": "10000000000000000000000n", + "fishermanRewardCut": 500000, + "maxSlashingCut": 1000000, + }, + "SubgraphService": { + "minimumProvisionTokens": "100000000000000000000000n", + "maximumDelegationRatio": 16, + "stakeToFeesRatio": 2, + "maxPOIStaleness": 2419200, // 28 days = 2419200 seconds + "curationCut": 100000, + } +} diff --git a/packages/subgraph-service/package.json b/packages/subgraph-service/package.json index 9f35e3796..b53410c44 100644 --- a/packages/subgraph-service/package.json +++ b/packages/subgraph-service/package.json @@ -20,7 +20,8 @@ "clean": "rm -rf build dist cache cache_forge typechain-types", "build": "hardhat compile", "test": "forge test", - "test:deployment": "SECURE_ACCOUNTS_DISABLE_PROVIDER=true hardhat test" + "test:deployment": "SECURE_ACCOUNTS_DISABLE_PROVIDER=true hardhat test", + "test:integration": "./scripts/test/integration" }, "devDependencies": { "@defi-wonderland/natspec-smells": "^1.1.6", @@ -47,6 +48,7 @@ "eslint": "^8.56.0", "eslint-graph-config": "workspace:^0.0.1", "ethers": "^6.13.4", + "glob": "^11.0.1", "hardhat": "^2.22.18", "hardhat-contract-sizer": "^2.10.0", "hardhat-gas-reporter": "^1.0.8", diff --git a/packages/subgraph-service/scripts/test/integration b/packages/subgraph-service/scripts/test/integration new file mode 100755 index 000000000..87db67a2b --- /dev/null +++ b/packages/subgraph-service/scripts/test/integration @@ -0,0 +1,137 @@ +#!/bin/bash + +set -eo pipefail + +NON_INTERACTIVE=${NON_INTERACTIVE:-false} + +# Set environment variables for this script +export SECURE_ACCOUNTS_DISABLE_PROVIDER=true +export FORK_FROM_CHAIN_ID=${FORK_FROM_CHAIN_ID:-421614} + +# Function to cleanup resources +cleanup() { + # Kill hardhat node only if we started it + if [ ! -z "$NODE_PID" ] && [ "$STARTED_NODE" = true ]; then + echo "Cleaning up node process..." + kill $NODE_PID 2>/dev/null || true + fi +} + +# Set trap to call cleanup function on script exit (normal or error) +trap cleanup EXIT + +# Check if any deployment folders exist +SUBGRAPH_DEPLOYMENT_EXISTS=false +HORIZON_DEPLOYMENT_EXISTS=false + +if [ -d "ignition/deployments/subgraph-service-localhost" ]; then + SUBGRAPH_DEPLOYMENT_EXISTS=true +fi + +if [ -d "../horizon/ignition/deployments/horizon-localhost" ]; then + HORIZON_DEPLOYMENT_EXISTS=true +fi + +# If any deployment exists, ask once for confirmation +if [ "$SUBGRAPH_DEPLOYMENT_EXISTS" = true ] || [ "$HORIZON_DEPLOYMENT_EXISTS" = true ]; then + echo "The following deployment files already exist and must be removed for the tests to work properly:" + if [ "$SUBGRAPH_DEPLOYMENT_EXISTS" = true ]; then + echo "- Subgraph Service: ignition/deployments/subgraph-service-localhost" + fi + if [ "$HORIZON_DEPLOYMENT_EXISTS" = true ]; then + echo "- Horizon: ../horizon/ignition/deployments/horizon-localhost" + fi + + read -p "Remove these deployment files? (y/n) [y]: " confirm + confirm=${confirm:-y} + if [[ $confirm == [yY] || $confirm == [yY][eE][sS] ]]; then + if [ "$SUBGRAPH_DEPLOYMENT_EXISTS" = true ]; then + echo "Removing Subgraph Service deployment files..." + rm -rf ignition/deployments/subgraph-service-localhost + fi + if [ "$HORIZON_DEPLOYMENT_EXISTS" = true ]; then + echo "Removing Horizon deployment files..." + rm -rf ../horizon/ignition/deployments/horizon-localhost + fi + else + echo "Cannot continue with existing deployment files. Exiting." + exit 1 + fi +fi + +# Check required env variables +BLOCKCHAIN_RPC=${BLOCKCHAIN_RPC:-$(npx hardhat vars get ARBITRUM_SEPOLIA_RPC)} +if [ -z "$BLOCKCHAIN_RPC" ]; then + echo "BLOCKCHAIN_RPC environment variable is required" + exit 1 +fi + +echo "Starting integration tests..." + +# Check if hardhat node is already running on port 8545 +STARTED_NODE=false +if lsof -i:8545 > /dev/null 2>&1; then + echo "Hardhat node already running on port 8545, using existing node" + # Get the PID of the process using port 8545 + NODE_PID=$(lsof -t -i:8545) +else + # Start local hardhat node forked from Arbitrum Sepolia + echo "Starting local hardhat node..." + npx hardhat node --fork $BLOCKCHAIN_RPC > node.log 2>&1 & + NODE_PID=$! + STARTED_NODE=true + + # Wait for node to start + sleep 10 +fi + +# Setup subgraph service address book +jq '{"31337": ."'"$FORK_FROM_CHAIN_ID"'"}' addresses.json > addresses-localhost.json + +# Run Horizon pre-upgrade steps +cd ../horizon + +# Setup pre horizon migration state needed for the e2e tests +npx hardhat test:seed --network localhost + +# Transfer ownership of protocol to hardhat signer 1 +npx hardhat test:transfer-ownership --network localhost + +# Run Horizon steps 1 deployment +npx hardhat deploy:migrate --network localhost --horizon-config e2e-test --step 1 --account-index 0 --patch-config + +# Run Subgraph Service steps 1 deployment +cd ../subgraph-service +npx hardhat deploy:migrate --network localhost --step 1 --subgraph-service-config integration-test --patch-config --account-index 0 --hide-banner + +# Run Horizon deployment steps 2 and 3 +cd ../horizon +npx hardhat deploy:migrate --network localhost --horizon-config e2e-test --step 2 --patch-config --account-index 1 --hide-banner +npx hardhat deploy:migrate --network localhost --horizon-config e2e-test --step 3 --patch-config --account-index 0 --hide-banner + +# Run Subgraph Service deployment step 2 +cd ../subgraph-service +npx hardhat deploy:migrate --network localhost --step 2 --subgraph-service-config integration-test --patch-config --account-index 0 --hide-banner + +# Run Horizon deployment steps 4 +cd ../horizon +npx hardhat deploy:migrate --network localhost --horizon-config e2e-test --step 4 --patch-config --account-index 1 --hide-banner + +# Run Subgraph Service seed steps +cd ../subgraph-service +npx hardhat test:seed --network localhost + +# Run integration tests - During transition period +npx hardhat test:integration --phase during-transition-period --network localhost + +# Clear thawing period +cd ../horizon +npx hardhat transition:clear-thawing --network localhost --governor-index 1 + +# Run integration tests - After transition period +cd ../subgraph-service +npx hardhat test:integration --phase after-transition-period --network localhost + +echo "" +echo "🎉 ✨ 🚀 ✅ E2E tests completed successfully! 🎉 ✨ 🚀 ✅" +echo "" diff --git a/packages/subgraph-service/tasks/deploy.ts b/packages/subgraph-service/tasks/deploy.ts index cac840b13..5f71ecfa8 100644 --- a/packages/subgraph-service/tasks/deploy.ts +++ b/packages/subgraph-service/tasks/deploy.ts @@ -93,14 +93,19 @@ task('deploy:protocol', 'Deploy a new version of the Graph Protocol Horizon cont task('deploy:migrate', 'Deploy the Subgraph Service on an existing Horizon deployment') .addOptionalParam('step', 'Migration step to run (1, 2)', undefined, types.int) .addOptionalParam('subgraphServiceConfig', 'Name of the Subgraph Service configuration file to use. Format is "migrate..json5", file must be in the "ignition/configs/" directory. Defaults to network name.', undefined, types.string) + .addOptionalParam('accountIndex', 'Derivation path index for the account to use', 0, types.int) .addFlag('patchConfig', 'Patch configuration file using address book values - does not save changes') + .addFlag('hideBanner', 'Hide the banner display') .setAction(async (args, hre: HardhatRuntimeEnvironment) => { // Task parameters const step: number = args.step ?? 0 const patchConfig: boolean = args.patchConfig ?? false const graph = hre.graph() - printHorizonBanner() + + if (!args.hideBanner) { + printHorizonBanner() + } // Migration step to run console.log('\n========== 🏗️ Migration steps ==========') @@ -119,7 +124,7 @@ task('deploy:migrate', 'Deploy the Subgraph Service on an existing Horizon deplo // Display the deployer -- this also triggers the secure accounts prompt if being used console.log('\n========== 🔑 Deployer account ==========') - const deployer = await graph.accounts.getDeployer(args.deployerIndex) + const deployer = await graph.accounts.getDeployer(args.accountIndex) console.log('Using deployer account:', deployer.address) const balance = await hre.ethers.provider.getBalance(deployer.address) console.log('Deployer balance:', hre.ethers.formatEther(balance), 'ETH') diff --git a/packages/subgraph-service/tasks/test/fixtures/indexers.ts b/packages/subgraph-service/tasks/test/fixtures/indexers.ts new file mode 100644 index 000000000..6a952e183 --- /dev/null +++ b/packages/subgraph-service/tasks/test/fixtures/indexers.ts @@ -0,0 +1,101 @@ +import { indexers as horizonIndexers } from '../../../../horizon/tasks/test/fixtures/indexers' +import { parseEther } from 'ethers' + +// Allocation interface +export interface Allocation { + allocationID: string + subgraphDeploymentID: string + allocationPrivateKey: string + tokens: bigint +} + +// Indexer interface +export interface Indexer { + address: string + url: string + geoHash: string + rewardsDestination?: string + provisionTokens: bigint + legacyAllocations: Allocation[] + allocations: Allocation[] +} + +// Subgraph deployment IDs +const SUBGRAPH_DEPLOYMENT_ID_ONE = '0x02cd85012c1f075fd58fad178fd23ab841d3b5ddcf5cd3377c30118da97cb2a4' +const SUBGRAPH_DEPLOYMENT_ID_TWO = '0x03ca89485a59894f1acfa34660c69024b6b90ce45171dece7662b0886bc375c7' +const SUBGRAPH_DEPLOYMENT_ID_THREE = '0x0472e8c46f728adb65a22187c6740532f82c2ebadaeabbbe59a2bb4a1bdde197' + +// Indexer one allocations +const INDEXER_ONE_FIRST_ALLOCATION_ID = '0x097DC23d51A7800f9B1EA37919A5b223C0224eC2' +const INDEXER_ONE_FIRST_ALLOCATION_PRIVATE_KEY = '0xec5739112bc20845cdd80b2612dfb0a75599ea6fbdd8916a1e7d5be98118c315' +const INDEXER_ONE_SECOND_ALLOCATION_ID = '0x897E7056FB86372CB676EBAE73a360c22b21D4aD' +const INDEXER_ONE_SECOND_ALLOCATION_PRIVATE_KEY = '0x298519bdc6a73f0d64c96e1f7c39aba3f825886a37e0349294ce7c407bd88370' +const INDEXER_ONE_THIRD_ALLOCATION_ID = '0x02C64e54100b3Cb324ac50d9b3823402e6aA5297' +const INDEXER_ONE_THIRD_ALLOCATION_PRIVATE_KEY = '0xb8ca0ab93098c2c478c5657da7a7bb89522bb1e3198f8b469de252dfee5469a3' + +// Indexer two allocations +const INDEXER_TWO_FIRST_ALLOCATION_ID = '0xB609bBf1D5Ae3C246dA1F9a5EA327DBa66BbcB05' +const INDEXER_TWO_FIRST_ALLOCATION_PRIVATE_KEY = '0x21dce628700b82e2d9045d756e4d0ba736f652a170655398a15fadae10b0e846' +const INDEXER_TWO_SECOND_ALLOCATION_ID = '0x1bF6afCF9542983432B2fab15717c2537A3d3F2A' +const INDEXER_TWO_SECOND_ALLOCATION_PRIVATE_KEY = '0x4bf454f7d52fff97701c1ea5d1e6184c81543780ca61b82cce155a5a3e35a134' + +// Allocations map +const allocations = new Map([ + [ + horizonIndexers[0].address, + [ + { + allocationID: INDEXER_ONE_FIRST_ALLOCATION_ID, + subgraphDeploymentID: SUBGRAPH_DEPLOYMENT_ID_ONE, + allocationPrivateKey: INDEXER_ONE_FIRST_ALLOCATION_PRIVATE_KEY, + tokens: parseEther('10000'), + }, + { + allocationID: INDEXER_ONE_SECOND_ALLOCATION_ID, + subgraphDeploymentID: SUBGRAPH_DEPLOYMENT_ID_TWO, + allocationPrivateKey: INDEXER_ONE_SECOND_ALLOCATION_PRIVATE_KEY, + tokens: parseEther('8000'), + }, + { + allocationID: INDEXER_ONE_THIRD_ALLOCATION_ID, + subgraphDeploymentID: SUBGRAPH_DEPLOYMENT_ID_THREE, + allocationPrivateKey: INDEXER_ONE_THIRD_ALLOCATION_PRIVATE_KEY, + tokens: parseEther('5000'), + }, + ], + ], + [ + horizonIndexers[2].address, + [ + { + allocationID: INDEXER_TWO_FIRST_ALLOCATION_ID, + subgraphDeploymentID: SUBGRAPH_DEPLOYMENT_ID_ONE, + allocationPrivateKey: INDEXER_TWO_FIRST_ALLOCATION_PRIVATE_KEY, + tokens: parseEther('10000'), + }, + { + allocationID: INDEXER_TWO_SECOND_ALLOCATION_ID, + subgraphDeploymentID: SUBGRAPH_DEPLOYMENT_ID_TWO, + allocationPrivateKey: INDEXER_TWO_SECOND_ALLOCATION_PRIVATE_KEY, + tokens: parseEther('8000'), + }, + ], + ], +]) + +// Indexers data +export const indexers: Indexer[] = horizonIndexers + .filter(indexer => !indexer.tokensToUnstake || indexer.tokensToUnstake <= parseEther('100000')) + .map((indexer) => { + // Move existing allocations to legacyAllocations + const legacyAllocations = indexer.allocations + + return { + ...indexer, + url: 'url', + geoHash: 'geohash', + provisionTokens: parseEther('1000000'), + legacyAllocations, + allocations: allocations.get(indexer.address) || [], + } + }) diff --git a/packages/subgraph-service/tasks/test/integration.ts b/packages/subgraph-service/tasks/test/integration.ts new file mode 100644 index 000000000..0fc3a40ce --- /dev/null +++ b/packages/subgraph-service/tasks/test/integration.ts @@ -0,0 +1,33 @@ +import { glob } from 'glob' +import { task } from 'hardhat/config' +import { TASK_TEST } from 'hardhat/builtin-tasks/task-names' + +import { printBanner } from '@graphprotocol/toolshed/utils' + +task('test:integration', 'Runs all integration tests') + .addParam( + 'phase', + 'Test phase to run: "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled"', + ) + .setAction(async (taskArgs, hre) => { + // Get test files for each phase + const duringTransitionPeriodFiles = await glob('test/integration/during-transition-period/**/*.{js,ts}') + const afterTransitionPeriodFiles = await glob('test/integration/after-transition-period/**/*.{js,ts}') + + // Display banner for the current test phase + printBanner(taskArgs.phase, 'INTEGRATION TESTS: ') + + // Run tests for the current phase + switch (taskArgs.phase) { + case 'during-transition-period': + await hre.run(TASK_TEST, { testFiles: duringTransitionPeriodFiles }) + break + case 'after-transition-period': + await hre.run(TASK_TEST, { testFiles: afterTransitionPeriodFiles }) + break + default: + throw new Error( + 'Invalid phase. Must be "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled", or "all"', + ) + } + }) diff --git a/packages/subgraph-service/tasks/test/seed.ts b/packages/subgraph-service/tasks/test/seed.ts new file mode 100644 index 000000000..29d26ba6b --- /dev/null +++ b/packages/subgraph-service/tasks/test/seed.ts @@ -0,0 +1,110 @@ +import { keccak256, toUtf8Bytes } from 'ethers' +import { task } from 'hardhat/config' + +import { DisputeManager, SubgraphService } from '../../typechain-types' +import { IHorizonStaking } from '@graphprotocol/horizon' + +import { indexers } from './fixtures/indexers' + +task('test:seed', 'Seed the test environment, must be run after deployment') + .setAction(async (_, hre) => { + // Get contracts + const graph = hre.graph() + const { generateAllocationProof } = graph.subgraphService.actions + const horizonStaking = graph.horizon.contracts.HorizonStaking as unknown as IHorizonStaking + const subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as SubgraphService + const disputeManager = graph.subgraphService.contracts.DisputeManager as unknown as DisputeManager + + // Get configs + const disputePeriod = await disputeManager.getDisputePeriod() + const maxSlashingCut = await disputeManager.maxSlashingCut() + + console.log('\n--- STEP 1: Close all legacy allocations ---') + + for (const indexer of indexers) { + // Skip indexers with no allocations + if (indexer.legacyAllocations.length === 0) { + continue + } + + console.log(`Closing allocations for indexer: ${indexer.address}`) + + // Get indexer signer + const indexerSigner = await hre.ethers.getSigner(indexer.address) + + // Close all allocations with POI != 0 + for (const allocation of indexer.legacyAllocations) { + console.log(`Closing allocation: ${allocation.allocationID}`) + + // Close allocation + const poi = hre.ethers.getBytes(keccak256(toUtf8Bytes('poi'))) + await horizonStaking.connect(indexerSigner).closeAllocation( + allocation.allocationID, + poi, + ) + + const allocationData = await horizonStaking.getAllocation(allocation.allocationID) + console.log(`Allocation closed at epoch: ${allocationData.closedAtEpoch}`) + } + } + + console.log('\n--- STEP 2: Create provisions and register indexers ---') + + for (const indexer of indexers) { + console.log(`Creating subgraph service provision for indexer: ${indexer.address}`) + + const indexerSigner = await hre.ethers.getSigner(indexer.address) + await horizonStaking.connect(indexerSigner).provision(indexer.address, await subgraphService.getAddress(), indexer.provisionTokens, maxSlashingCut, disputePeriod) + + console.log(`Provision created for indexer with ${indexer.provisionTokens} tokens`) + + const indexerRegistrationData = hre.ethers.AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'address'], + [indexer.url, indexer.geoHash, indexer.rewardsDestination || hre.ethers.ZeroAddress], + ) + + console.log(`Registering indexer: ${indexer.address}`) + await subgraphService.connect(indexerSigner).register(indexerSigner.address, indexerRegistrationData) + + const indexerData = await subgraphService.indexers(indexerSigner.address) + + console.log(`Indexer registered at: ${indexerData.registeredAt}`) + } + + console.log('\n--- STEP 3: Start allocations ---') + + for (const indexer of indexers) { + // Skip indexers with no allocations + if (indexer.allocations.length === 0) { + continue + } + + console.log(`Starting allocations for indexer: ${indexer.address}`) + + const indexerSigner = await hre.ethers.getSigner(indexer.address) + + for (const allocation of indexer.allocations) { + console.log(`Starting allocation: ${allocation.allocationID}`) + + // Build allocation proof + const signature = await generateAllocationProof(allocation.allocationPrivateKey, [indexer.address, allocation.allocationID]) + const subgraphDeploymentId = allocation.subgraphDeploymentID + const allocationTokens = allocation.tokens + const allocationId = allocation.allocationID + + // Attempt to create an allocation with the same ID + const data = hre.ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, allocationTokens, allocationId, signature], + ) + + // Start allocation + await subgraphService.connect(indexerSigner).startService( + indexerSigner.address, + data, + ) + + console.log(`Allocation started with tokens: ${allocationTokens}`) + } + } + }) diff --git a/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/governance.test.ts b/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/governance.test.ts new file mode 100644 index 000000000..3c8c4c1f6 --- /dev/null +++ b/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/governance.test.ts @@ -0,0 +1,122 @@ +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { DisputeManager } from '../../../../typechain-types' +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +describe('DisputeManager Governance', () => { + let disputeManager: DisputeManager + let snapshotId: string + + // Test addresses + let governor: SignerWithAddress + let nonOwner: SignerWithAddress + let newArbitrator: SignerWithAddress + let newSubgraphService: SignerWithAddress + + before(async () => { + const graph = hre.graph() + disputeManager = graph.subgraphService.contracts.DisputeManager as unknown as DisputeManager + + // Get signers + governor = await graph.accounts.getGovernor() + ;[nonOwner, newArbitrator, newSubgraphService] = await graph.accounts.getTestAccounts() + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Arbitrator', () => { + it('should set arbitrator', async () => { + await disputeManager.connect(governor).setArbitrator(newArbitrator.address) + expect(await disputeManager.arbitrator()).to.equal(newArbitrator.address) + }) + + it('should not allow non-owner to set arbitrator', async () => { + await expect( + disputeManager.connect(nonOwner).setArbitrator(newArbitrator.address), + ).to.be.revertedWithCustomError(disputeManager, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Dispute Period', () => { + it('should set dispute period', async () => { + const newDisputePeriod = 7 * 24 * 60 * 60 // 7 days in seconds + await disputeManager.connect(governor).setDisputePeriod(newDisputePeriod) + expect(await disputeManager.disputePeriod()).to.equal(newDisputePeriod) + }) + + it('should not allow non-owner to set dispute period', async () => { + const newDisputePeriod = 7 * 24 * 60 * 60 + await expect( + disputeManager.connect(nonOwner).setDisputePeriod(newDisputePeriod), + ).to.be.revertedWithCustomError(disputeManager, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Dispute Deposit', () => { + it('should set dispute deposit', async () => { + const newDisputeDeposit = ethers.parseEther('1000') + await disputeManager.connect(governor).setDisputeDeposit(newDisputeDeposit) + expect(await disputeManager.disputeDeposit()).to.equal(newDisputeDeposit) + }) + + it('should not allow non-owner to set dispute deposit', async () => { + const newDisputeDeposit = ethers.parseEther('1000') + await expect( + disputeManager.connect(nonOwner).setDisputeDeposit(newDisputeDeposit), + ).to.be.revertedWithCustomError(disputeManager, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Fisherman Rewards Cut', () => { + it('should set fisherman rewards cut', async () => { + const newFishermanRewardsCut = 100000 // 10% in PPM + await disputeManager.connect(governor).setFishermanRewardCut(newFishermanRewardsCut) + expect(await disputeManager.fishermanRewardCut()).to.equal(newFishermanRewardsCut) + }) + + it('should not allow non-owner to set fisherman rewards cut', async () => { + const newFishermanRewardsCut = 100000 + await expect( + disputeManager.connect(nonOwner).setFishermanRewardCut(newFishermanRewardsCut), + ).to.be.revertedWithCustomError(disputeManager, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Max Slashing Cut', () => { + it('should set max slashing cut', async () => { + const newMaxSlashingCut = 200000 // 20% in PPM + await disputeManager.connect(governor).setMaxSlashingCut(newMaxSlashingCut) + expect(await disputeManager.maxSlashingCut()).to.equal(newMaxSlashingCut) + }) + + it('should not allow non-owner to set max slashing cut', async () => { + const newMaxSlashingCut = 200000 + await expect( + disputeManager.connect(nonOwner).setMaxSlashingCut(newMaxSlashingCut), + ).to.be.revertedWithCustomError(disputeManager, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Subgraph Service Address', () => { + it('should set subgraph service address', async () => { + await disputeManager.connect(governor).setSubgraphService(newSubgraphService.address) + expect(await disputeManager.subgraphService()).to.equal(newSubgraphService.address) + }) + + it('should not allow non-owner to set subgraph service address', async () => { + await expect( + disputeManager.connect(nonOwner).setSubgraphService(newSubgraphService.address), + ).to.be.revertedWithCustomError(disputeManager, 'OwnableUnauthorizedAccount') + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/indexing-disputes.test.ts b/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/indexing-disputes.test.ts new file mode 100644 index 000000000..b2de9166e --- /dev/null +++ b/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/indexing-disputes.test.ts @@ -0,0 +1,249 @@ +import { ethers } from 'hardhat' +import { EventLog } from 'ethers' +import { expect } from 'chai' +import hre from 'hardhat' + +import { DisputeManager, IGraphToken, IHorizonStaking, SubgraphService } from '../../../../typechain-types' +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +import { indexers } from '../../../../tasks/test/fixtures/indexers' + +describe('Indexing Disputes', () => { + let disputeManager: DisputeManager + let graphToken: IGraphToken + let staking: IHorizonStaking + let subgraphService: SubgraphService + + let snapshotId: string + + // Test addresses + let fisherman: SignerWithAddress + let arbitrator: SignerWithAddress + let indexer: SignerWithAddress + + let allocationId: string + + // Dispute manager variables + let disputeDeposit: bigint + let fishermanRewardCut: bigint + let disputePeriod: bigint + + before(async () => { + // Get contracts + const graph = hre.graph() + disputeManager = graph.subgraphService.contracts.DisputeManager as unknown as DisputeManager + graphToken = graph.horizon.contracts.GraphToken as unknown as IGraphToken + staking = graph.horizon.contracts.HorizonStaking as unknown as IHorizonStaking + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as SubgraphService + + // Get signers + arbitrator = await graph.accounts.getArbitrator() + ;[fisherman] = await graph.accounts.getTestAccounts() + + // Get indexer + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + + // Get allocation + const allocation = indexerFixture.allocations[0] + allocationId = allocation.allocationID + + // Dispute manager variables + disputeDeposit = await disputeManager.disputeDeposit() + fishermanRewardCut = await disputeManager.fishermanRewardCut() + disputePeriod = await disputeManager.disputePeriod() + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Fisherman', () => { + it('should allow fisherman to create an indexing dispute', async () => { + // Create dispute + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + + // Approve dispute manager for dispute deposit + await graphToken.connect(fisherman).approve(disputeManager.target, disputeDeposit) + + // Create dispute + const tx = await disputeManager.connect(fisherman).createIndexingDispute(allocationId, poi) + const receipt = await tx.wait() + + // Get dispute ID from event + const disputeCreatedEvent = receipt?.logs.find( + log => log instanceof EventLog && log.fragment?.name === 'IndexingDisputeCreated', + ) as EventLog + const disputeId = disputeCreatedEvent?.args[0] + + // Verify dispute was created + const dispute = await disputeManager.disputes(disputeId) + expect(dispute.indexer).to.equal(indexer.address, 'Indexer address mismatch') + expect(dispute.fisherman).to.equal(fisherman.address, 'Fisherman address mismatch') + expect(dispute.disputeType).to.equal(1, 'Dispute type should be indexing') + expect(dispute.status).to.equal(4, 'Dispute status should be pending') + }) + + it('should allow fisherman to cancel an indexing dispute', async () => { + // Create dispute + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + + // Approve dispute manager for dispute deposit + await graphToken.connect(fisherman).approve(disputeManager.target, disputeDeposit) + + // Create dispute + const tx = await disputeManager.connect(fisherman).createIndexingDispute(allocationId, poi) + const receipt = await tx.wait() + + // Get dispute ID from event + const disputeCreatedEvent = receipt?.logs.find( + log => log instanceof EventLog && log.fragment?.name === 'IndexingDisputeCreated', + ) as EventLog + const disputeId = disputeCreatedEvent?.args[0] + + // Get fisherman's balance before canceling dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Pass dispute period + await ethers.provider.send('evm_increaseTime', [Number(disputePeriod) + 1]) + await ethers.provider.send('evm_mine', []) + + // Cancel dispute + await disputeManager.connect(fisherman).cancelDispute(disputeId) + + // Verify dispute was canceled + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(5, 'Dispute status should be canceled') + + // Verify fisherman got the deposit back + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + expect(fishermanBalance).to.equal(fishermanBalanceBefore + disputeDeposit, 'Fisherman should receive the deposit back') + }) + }) + + describe('Arbitrating Indexing Disputes', () => { + let disputeId: string + + beforeEach(async () => { + // Approve dispute manager for dispute deposit + await graphToken.connect(fisherman).approve(disputeManager.target, disputeDeposit) + + // Create dispute + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const tx = await disputeManager.connect(fisherman).createIndexingDispute(allocationId, poi) + const receipt = await tx.wait() + + // Get dispute ID from event + const disputeCreatedEvent = receipt?.logs.find( + log => log instanceof EventLog && log.fragment?.name === 'IndexingDisputeCreated', + ) as EventLog + disputeId = disputeCreatedEvent?.args[0] + }) + + it('should allow arbitrator to accept an indexing dispute', async () => { + // Get fisherman's balance before accepting dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Get indexer's provision before accepting dispute + const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + + // Get indexer stake snapshot + const dispute = await disputeManager.disputes(disputeId) + const tokensToSlash = dispute.stakeSnapshot / 10n + + // Accept dispute + await disputeManager.connect(arbitrator).acceptDispute(disputeId, tokensToSlash) + + // Verify dispute status + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(1, 'Dispute status should be accepted') + + // Verify indexer's stake was slashed + const updatedProvision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + expect(updatedProvision).to.equal(provision - tokensToSlash, 'Indexer stake should be slashed') + + // Verify fisherman got the deposit plus the reward + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + const fishermanReward = (tokensToSlash * fishermanRewardCut) / 1000000n + const fishermanTotal = fishermanBalanceBefore + fishermanReward + disputeDeposit + expect(fishermanBalance).to.equal(fishermanTotal, 'Fisherman balance should be increased by the reward and deposit') + }) + + it('should allow arbitrator to draw an indexing dispute', async () => { + // Get fisherman's balance before drawing dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Get indexer's provision before drawing dispute + const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + + // Draw dispute + await disputeManager.connect(arbitrator).drawDispute(disputeId) + + // Verify dispute status + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(3, 'Dispute status should be drawn') + + // Verify indexer's provision was not affected + const updatedProvision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + expect(updatedProvision).to.equal(provision, 'Indexer stake should not be affected') + + // Verify fisherman got the deposit back + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + expect(fishermanBalance).to.equal(fishermanBalanceBefore + disputeDeposit, 'Fisherman should receive the deposit back') + }) + + it('should allow arbitrator to reject an indexing dispute', async () => { + // Get fisherman's balance before rejecting dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Get indexer's provision before rejecting dispute + const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + + // Reject dispute + await disputeManager.connect(arbitrator).rejectDispute(disputeId) + + // Verify dispute status + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(2, 'Dispute status should be rejected') + + // Verify indexer's provision was not affected + const updatedProvision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + expect(updatedProvision).to.equal(provision, 'Indexer stake should not be affected') + + // Verify fisherman did not receive the deposit + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + expect(fishermanBalance).to.equal(fishermanBalanceBefore, 'Fisherman balance should not receive the deposit back') + }) + + it('should not allow non-arbitrator to accept an indexing dispute', async () => { + // Get indexer stake snapshot + const dispute = await disputeManager.disputes(disputeId) + const tokensToSlash = dispute.stakeSnapshot / 10n + + // Attempt to accept dispute as fisherman + await expect( + disputeManager.connect(fisherman).acceptDispute(disputeId, tokensToSlash), + ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') + }) + + it('should not allow non-arbitrator to draw an indexing dispute', async () => { + // Attempt to draw dispute as fisherman + await expect( + disputeManager.connect(fisherman).drawDispute(disputeId), + ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') + }) + + it('should not allow non-arbitrator to reject an indexing dispute', async () => { + // Attempt to reject dispute as fisherman + await expect( + disputeManager.connect(fisherman).rejectDispute(disputeId), + ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/query-conflict-disputes.test.ts b/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/query-conflict-disputes.test.ts new file mode 100644 index 000000000..50e88aa4f --- /dev/null +++ b/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/query-conflict-disputes.test.ts @@ -0,0 +1,356 @@ +import { EventLog, Wallet } from 'ethers' +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { DisputeManager, IGraphToken, IHorizonStaking, SubgraphService } from '../../../../typechain-types' +import { createAttestationData } from '@graphprotocol/toolshed' +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +import { indexers } from '../../../../tasks/test/fixtures/indexers' + +describe('Query Conflict Disputes', () => { + let disputeManager: DisputeManager + let graphToken: IGraphToken + let staking: IHorizonStaking + let subgraphService: SubgraphService + + let snapshotId: string + + // Test addresses + let fisherman: SignerWithAddress + let arbitrator: SignerWithAddress + let indexer: SignerWithAddress + let relatedIndexer: SignerWithAddress + + // Allocation variables + let allocationSigner: Wallet + let relatedAllocationSigner: Wallet + let subgraphDeploymentId: string + + // Dispute manager variables + let disputeDeposit: bigint + let fishermanRewardCut: bigint + let disputePeriod: bigint + + before(async () => { + // Get contracts + const graph = hre.graph() + disputeManager = graph.subgraphService.contracts.DisputeManager as unknown as DisputeManager + graphToken = graph.horizon.contracts.GraphToken as unknown as IGraphToken + staking = graph.horizon.contracts.HorizonStaking as unknown as IHorizonStaking + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as SubgraphService + + // Get signers + arbitrator = await graph.accounts.getArbitrator() + ;[fisherman] = await graph.accounts.getTestAccounts() + + // Get indexers + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + const relatedIndexerFixture = indexers[1] + relatedIndexer = await ethers.getSigner(relatedIndexerFixture.address) + + // Get allocation + const allocation = indexerFixture.allocations[0] + allocationSigner = new Wallet(allocation.allocationPrivateKey) + const relatedAllocation = relatedIndexerFixture.allocations[0] + relatedAllocationSigner = new Wallet(relatedAllocation.allocationPrivateKey) + subgraphDeploymentId = allocation.subgraphDeploymentID + + // Dispute manager variables + disputeDeposit = await disputeManager.disputeDeposit() + fishermanRewardCut = await disputeManager.fishermanRewardCut() + disputePeriod = await disputeManager.disputePeriod() + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Fisherman', () => { + it('should allow fisherman to create a query conflict dispute', async () => { + // Create dispute + const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) + const responseHash1 = ethers.keccak256(ethers.toUtf8Bytes('test-response-1')) + const responseHash2 = ethers.keccak256(ethers.toUtf8Bytes('test-response-2')) + + // Create attestation data for both responses + const attestationData1 = await createAttestationData( + disputeManager, + allocationSigner, + queryHash, + responseHash1, + subgraphDeploymentId, + ) + const attestationData2 = await createAttestationData( + disputeManager, + relatedAllocationSigner, + queryHash, + responseHash2, + subgraphDeploymentId, + ) + + // Approve dispute manager for dispute deposit + await graphToken.connect(fisherman).approve(disputeManager.target, disputeDeposit) + + // Create dispute + const tx = await disputeManager.connect(fisherman).createQueryDisputeConflict(attestationData1, attestationData2) + const receipt = await tx.wait() + + // Get dispute ID from event + const disputeLinkedEvent = receipt?.logs.find( + log => log instanceof EventLog && log.fragment?.name === 'DisputeLinked', + ) as EventLog + const disputeId = disputeLinkedEvent?.args[0] + const relatedDisputeId = disputeLinkedEvent?.args[1] + + // Verify dispute was created + const dispute = await disputeManager.disputes(disputeId) + expect(dispute.indexer).to.equal(indexer.address, 'Indexer address mismatch') + expect(dispute.fisherman).to.equal(fisherman.address, 'Fisherman address mismatch') + expect(dispute.disputeType).to.equal(2, 'Dispute type should be query') + expect(dispute.status).to.equal(4, 'Dispute status should be pending') + + // Verify related dispute was created + const relatedDispute = await disputeManager.disputes(relatedDisputeId) + expect(relatedDispute.indexer).to.equal(relatedIndexer.address, 'Related indexer address mismatch') + expect(relatedDispute.fisherman).to.equal(fisherman.address, 'Related fisherman address mismatch') + expect(relatedDispute.disputeType).to.equal(2, 'Related dispute type should be query') + expect(relatedDispute.status).to.equal(4, 'Related dispute status should be pending') + }) + + it('should allow fisherman to cancel a query conflict dispute', async () => { + // Create dispute + const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) + const responseHash1 = ethers.keccak256(ethers.toUtf8Bytes('test-response-1')) + const responseHash2 = ethers.keccak256(ethers.toUtf8Bytes('test-response-2')) + + // Create attestation data for both responses + const attestationData1 = await createAttestationData( + disputeManager, + allocationSigner, + queryHash, + responseHash1, + subgraphDeploymentId, + ) + const attestationData2 = await createAttestationData( + disputeManager, + relatedAllocationSigner, + queryHash, + responseHash2, + subgraphDeploymentId, + ) + + // Approve dispute manager for dispute deposit + await graphToken.connect(fisherman).approve(disputeManager.target, disputeDeposit) + + // Create dispute + const tx = await disputeManager.connect(fisherman).createQueryDisputeConflict(attestationData1, attestationData2) + const receipt = await tx.wait() + + // Get dispute ID from event + const disputeLinkedEvent = receipt?.logs.find( + log => log instanceof EventLog && log.fragment?.name === 'DisputeLinked', + ) as EventLog + const disputeId = disputeLinkedEvent?.args[0] + const relatedDisputeId = disputeLinkedEvent?.args[1] + + // Get fisherman's balance before canceling dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Pass dispute period + await ethers.provider.send('evm_increaseTime', [Number(disputePeriod) + 1]) + await ethers.provider.send('evm_mine', []) + + // Cancel dispute + await disputeManager.connect(fisherman).cancelDispute(disputeId) + + // Verify dispute was canceled + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(5, 'Dispute status should be canceled') + + // Verify related dispute was canceled + const updatedRelatedDispute = await disputeManager.disputes(relatedDisputeId) + expect(updatedRelatedDispute.status).to.equal(5, 'Related dispute status should be canceled') + + // Verify fisherman got the deposit back + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + expect(fishermanBalance).to.equal(fishermanBalanceBefore + disputeDeposit, 'Fisherman should receive the deposit back') + }) + }) + + describe('Arbitrating Query Conflict Disputes', () => { + let disputeId: string + let relatedDisputeId: string + + beforeEach(async () => { + // Create dispute + const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) + const responseHash1 = ethers.keccak256(ethers.toUtf8Bytes('test-response-1')) + const responseHash2 = ethers.keccak256(ethers.toUtf8Bytes('test-response-2')) + + // Create attestation data for both responses + const attestationData1 = await createAttestationData( + disputeManager, + allocationSigner, + queryHash, + responseHash1, + subgraphDeploymentId, + ) + const attestationData2 = await createAttestationData( + disputeManager, + relatedAllocationSigner, + queryHash, + responseHash2, + subgraphDeploymentId, + ) + + // Approve dispute manager for dispute deposit + await graphToken.connect(fisherman).approve(disputeManager.target, disputeDeposit) + + // Create dispute + const tx = await disputeManager.connect(fisherman).createQueryDisputeConflict(attestationData1, attestationData2) + const receipt = await tx.wait() + + // Get dispute ID from event + const disputeLinkedEvent = receipt?.logs.find( + log => log instanceof EventLog && log.fragment?.name === 'DisputeLinked', + ) as EventLog + disputeId = disputeLinkedEvent?.args[0] + relatedDisputeId = disputeLinkedEvent?.args[1] + }) + + it('should allow arbitrator to accept one of the query conflict disputes', async () => { + // Get fisherman's balance before accepting dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Get indexer's provision before accepting dispute + const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + + // Get indexer stake snapshot + const dispute = await disputeManager.disputes(disputeId) + const tokensToSlash = dispute.stakeSnapshot / 10n + + // Accept dispute with first response + await disputeManager.connect(arbitrator).acceptDisputeConflict(disputeId, tokensToSlash, false, 0n) + + // Verify dispute status + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(1, 'Dispute status should be accepted') + + // Verify indexer's stake was slashed + const updatedProvision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + expect(updatedProvision).to.equal(provision - tokensToSlash, 'Indexer stake should be slashed') + + // Verify fisherman got the deposit plus the reward + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + const fishermanReward = (tokensToSlash * fishermanRewardCut) / 1000000n + const fishermanTotal = fishermanBalanceBefore + fishermanReward + disputeDeposit + expect(fishermanBalance).to.equal(fishermanTotal, 'Fisherman balance should be increased by the reward and deposit') + }) + + it('should allow arbitrator to accept both query conflict disputes', async () => { + // Get fisherman's balance before accepting dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Get indexer's provision before accepting dispute + const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + const provisionRelated = await staking.getProviderTokensAvailable(relatedIndexer.address, await subgraphService.getAddress()) + + // Get indexer stake snapshot + const dispute = await disputeManager.disputes(disputeId) + const relatedDispute = await disputeManager.disputes(relatedDisputeId) + const tokensToSlash = dispute.stakeSnapshot / 10n + const tokensToSlashRelated = relatedDispute.stakeSnapshot / 10n + + // Accept dispute with both responses + await disputeManager.connect(arbitrator).acceptDisputeConflict(disputeId, tokensToSlash, true, tokensToSlashRelated) + + // Verify dispute status + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(1, 'Dispute status should be accepted') + + // Verify related dispute status + const updatedRelatedDispute = await disputeManager.disputes(relatedDisputeId) + expect(updatedRelatedDispute.status).to.equal(1, 'Related dispute status should be accepted') + + // Verify indexer's stake was slashed + const updatedProvision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + expect(updatedProvision).to.equal(provision - tokensToSlash, 'Indexer stake should be slashed') + + // Verify related indexer's stake was slashed + const updatedProvisionRelated = await staking.getProviderTokensAvailable(relatedIndexer.address, await subgraphService.getAddress()) + expect(updatedProvisionRelated).to.equal(provisionRelated - tokensToSlashRelated, 'Related indexer stake should be slashed') + + // Verify fisherman got the deposit plus the reward + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + const fishermanReward = ((tokensToSlash + tokensToSlashRelated) * fishermanRewardCut) / 1000000n + const fishermanTotal = fishermanBalanceBefore + fishermanReward + disputeDeposit + expect(fishermanBalance).to.equal(fishermanTotal, 'Fisherman balance should be increased by the reward and deposit') + }) + + it('should allow arbitrator to draw query conflict dispute', async () => { + // Get fisherman's balance before drawing dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Get indexer's provision before drawing disputes + const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + const provisionRelated = await staking.getProviderTokensAvailable(relatedIndexer.address, await subgraphService.getAddress()) + + // Draw dispute + await disputeManager.connect(arbitrator).drawDispute(disputeId) + + // Verify dispute status + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(3, 'Dispute status should be drawn') + + // Verify related dispute status + const updatedRelatedDispute = await disputeManager.disputes(relatedDisputeId) + expect(updatedRelatedDispute.status).to.equal(3, 'Related dispute status should be drawn') + + // Verify indexer's provision was not affected + const updatedProvision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + expect(updatedProvision).to.equal(provision, 'Indexer stake should not be affected') + + // Verify related indexer's provision was not affected + const updatedProvisionRelated = await staking.getProviderTokensAvailable(relatedIndexer.address, await subgraphService.getAddress()) + expect(updatedProvisionRelated).to.equal(provisionRelated, 'Related indexer stake should not be affected') + + // Verify fisherman got the deposit back + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + expect(fishermanBalance).to.equal(fishermanBalanceBefore + disputeDeposit, 'Fisherman should receive the deposit back') + }) + + it('should not allow arbitrator to reject a query conflict dispute', async () => { + // Attempt to reject dispute + await expect( + disputeManager.connect(arbitrator).rejectDispute(disputeId), + ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerDisputeInConflict') + }) + + it('should not allow non-arbitrator to accept a query conflict dispute', async () => { + // Get indexer stake snapshot + const dispute = await disputeManager.disputes(disputeId) + const tokensToSlash = dispute.stakeSnapshot / 10n + + // Attempt to accept dispute as fisherman + await expect( + disputeManager.connect(fisherman).acceptDispute(disputeId, tokensToSlash), + ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') + }) + + it('should not allow non-arbitrator to draw a query conflict dispute', async () => { + // Attempt to draw dispute as fisherman + await expect( + disputeManager.connect(fisherman).drawDispute(disputeId), + ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/query-disputes.test.ts b/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/query-disputes.test.ts new file mode 100644 index 000000000..b92f007d1 --- /dev/null +++ b/packages/subgraph-service/test/integration/after-transition-period/dispute-manager/query-disputes.test.ts @@ -0,0 +1,289 @@ +import { EventLog, Wallet } from 'ethers' +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { DisputeManager, IGraphToken, IHorizonStaking, SubgraphService } from '../../../../typechain-types' +import { createAttestationData } from '@graphprotocol/toolshed' +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +import { indexers } from '../../../../tasks/test/fixtures/indexers' +import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' + +describe('Query Disputes', () => { + let disputeManager: DisputeManager + let graphToken: IGraphToken + let staking: IHorizonStaking + let subgraphService: SubgraphService + + let snapshotId: string + + // Test addresses + let fisherman: SignerWithAddress + let arbitrator: SignerWithAddress + let indexer: SignerWithAddress + + // Allocation variables + let allocationSigner: Wallet + let subgraphDeploymentId: string + + // Dispute manager variables + let disputeDeposit: bigint + let fishermanRewardCut: bigint + let disputePeriod: bigint + + before(async () => { + // Get contracts + const graph = hre.graph() + disputeManager = graph.subgraphService.contracts.DisputeManager as unknown as DisputeManager + graphToken = graph.horizon.contracts.GraphToken as unknown as IGraphToken + staking = graph.horizon.contracts.HorizonStaking as unknown as IHorizonStaking + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as SubgraphService + + // Get signers + arbitrator = await graph.accounts.getArbitrator() + ;[fisherman] = await graph.accounts.getTestAccounts() + + // Get indexer + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + + // Get allocation + const allocation = indexerFixture.allocations[0] + allocationSigner = new Wallet(allocation.allocationPrivateKey) + subgraphDeploymentId = allocation.subgraphDeploymentID + + // Dispute manager variables + disputeDeposit = await disputeManager.disputeDeposit() + fishermanRewardCut = await disputeManager.fishermanRewardCut() + disputePeriod = await disputeManager.disputePeriod() + + // Set GRT balance for fisherman + await setGRTBalance(graph.provider, graphToken.target, fisherman.address, ethers.parseEther('1000000')) + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Fisherman', () => { + it('should allow fisherman to create a query dispute', async () => { + // Create dispute + const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) + const responseHash = ethers.keccak256(ethers.toUtf8Bytes('test-response')) + + // Create attestation data + const attestationData = await createAttestationData( + disputeManager, + allocationSigner, + queryHash, + responseHash, + subgraphDeploymentId, + ) + + // Approve dispute manager for dispute deposit + await graphToken.connect(fisherman).approve(disputeManager.target, disputeDeposit) + + // Create dispute + const tx = await disputeManager.connect(fisherman).createQueryDispute(attestationData) + const receipt = await tx.wait() + + // Get dispute ID from event + const disputeCreatedEvent = receipt?.logs.find( + log => log instanceof EventLog && log.fragment?.name === 'QueryDisputeCreated', + ) as EventLog + const disputeId = disputeCreatedEvent?.args[0] + + // Verify dispute was created + const dispute = await disputeManager.disputes(disputeId) + expect(dispute.indexer).to.equal(indexer.address, 'Indexer address mismatch') + expect(dispute.fisherman).to.equal(fisherman.address, 'Fisherman address mismatch') + expect(dispute.disputeType).to.equal(2, 'Dispute type should be query') + expect(dispute.status).to.equal(4, 'Dispute status should be pending') + }) + + it('should allow fisherman to cancel a query dispute after dispute period', async () => { + // Create dispute + const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) + const responseHash = ethers.keccak256(ethers.toUtf8Bytes('test-response')) + + // Create attestation data + const attestationData = await createAttestationData( + disputeManager, + allocationSigner, + queryHash, + responseHash, + subgraphDeploymentId, + ) + + // Approve dispute manager for dispute deposit + await graphToken.connect(fisherman).approve(disputeManager.target, disputeDeposit) + + // Create dispute + const tx = await disputeManager.connect(fisherman).createQueryDispute(attestationData) + const receipt = await tx.wait() + + // Get dispute ID from event + const disputeCreatedEvent = receipt?.logs.find( + log => log instanceof EventLog && log.fragment?.name === 'QueryDisputeCreated', + ) as EventLog + const disputeId = disputeCreatedEvent?.args[0] + + // Get fisherman's balance before canceling dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Pass dispute period + await ethers.provider.send('evm_increaseTime', [Number(disputePeriod) + 1]) + await ethers.provider.send('evm_mine', []) + + // Cancel dispute + await disputeManager.connect(fisherman).cancelDispute(disputeId) + + // Verify dispute was canceled + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(5, 'Dispute status should be canceled') + + // Verify fisherman got the deposit back + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + expect(fishermanBalance).to.equal(fishermanBalanceBefore + disputeDeposit, 'Fisherman should receive the deposit back') + }) + }) + + describe('Arbitrating Query Disputes', () => { + let disputeId: string + + beforeEach(async () => { + // Create dispute + const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) + const responseHash = ethers.keccak256(ethers.toUtf8Bytes('test-response')) + + // Create attestation data + const attestationData = await createAttestationData( + disputeManager, + allocationSigner, + queryHash, + responseHash, + subgraphDeploymentId, + ) + + // Approve dispute manager for dispute deposit + await graphToken.connect(fisherman).approve(disputeManager.target, disputeDeposit) + + // Create dispute + const tx = await disputeManager.connect(fisherman).createQueryDispute(attestationData) + const receipt = await tx.wait() + + // Get dispute ID from event + const disputeCreatedEvent = receipt?.logs.find( + log => log instanceof EventLog && log.fragment?.name === 'QueryDisputeCreated', + ) as EventLog + disputeId = disputeCreatedEvent?.args[0] + }) + + it('should allow arbitrator to accept a query dispute', async () => { + // Get fisherman's balance before accepting dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Get indexer's provision before accepting dispute + const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + + // Get indexer stake snapshot + const dispute = await disputeManager.disputes(disputeId) + const tokensToSlash = dispute.stakeSnapshot / 10n + + // Accept dispute + await disputeManager.connect(arbitrator).acceptDispute(disputeId, tokensToSlash) + + // Verify dispute status + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(1, 'Dispute status should be accepted') + + // Verify indexer's stake was slashed + const updatedProvision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + expect(updatedProvision).to.equal(provision - tokensToSlash, 'Indexer stake should be slashed') + + // Verify fisherman got the deposit plus the reward + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + const fishermanReward = (tokensToSlash * fishermanRewardCut) / 1000000n + const fishermanTotal = fishermanBalanceBefore + fishermanReward + disputeDeposit + expect(fishermanBalance).to.equal(fishermanTotal, 'Fisherman balance should be increased by the reward and deposit') + }) + + it('should allow arbitrator to draw a query dispute', async () => { + // Get fisherman's balance before drawing dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Get indexer's provision before drawing dispute + const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + + // Draw dispute + await disputeManager.connect(arbitrator).drawDispute(disputeId) + + // Verify dispute status + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(3, 'Dispute status should be drawn') + + // Verify indexer's provision was not affected + const updatedProvision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + expect(updatedProvision).to.equal(provision, 'Indexer stake should not be affected') + + // Verify fisherman got the deposit back + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + expect(fishermanBalance).to.equal(fishermanBalanceBefore + disputeDeposit, 'Fisherman should receive the deposit back') + }) + + it('should allow arbitrator to reject a query dispute', async () => { + // Get fisherman's balance before rejecting dispute + const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) + + // Get indexer's provision before rejecting dispute + const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + + // Reject dispute + await disputeManager.connect(arbitrator).rejectDispute(disputeId) + + // Verify dispute status + const updatedDispute = await disputeManager.disputes(disputeId) + expect(updatedDispute.status).to.equal(2, 'Dispute status should be rejected') + + // Verify indexer's provision was not affected + const updatedProvision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) + expect(updatedProvision).to.equal(provision, 'Indexer stake should not be affected') + + // Verify fisherman did not receive the deposit + const fishermanBalance = await graphToken.balanceOf(fisherman.address) + expect(fishermanBalance).to.equal(fishermanBalanceBefore, 'Fisherman balance should not receive the deposit back') + }) + + it('should not allow non-arbitrator to accept a query dispute', async () => { + // Get indexer stake snapshot + const dispute = await disputeManager.disputes(disputeId) + const tokensToSlash = dispute.stakeSnapshot / 10n + + // Attempt to accept dispute as fisherman + await expect( + disputeManager.connect(fisherman).acceptDispute(disputeId, tokensToSlash), + ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') + }) + + it('should not allow non-arbitrator to draw a query dispute', async () => { + // Attempt to draw dispute as fisherman + await expect( + disputeManager.connect(fisherman).drawDispute(disputeId), + ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') + }) + + it('should not allow non-arbitrator to reject a query dispute', async () => { + // Attempt to reject dispute as fisherman + await expect( + disputeManager.connect(fisherman).rejectDispute(disputeId), + ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/governance.test.ts b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/governance.test.ts new file mode 100644 index 000000000..1d8d8292c --- /dev/null +++ b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/governance.test.ts @@ -0,0 +1,168 @@ +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' +import { SubgraphService } from '../../../../typechain-types' + +describe('Subgraph Service Governance', () => { + let subgraphService: SubgraphService + let snapshotId: string + + // Test addresses + let governor: SignerWithAddress + let nonOwner: SignerWithAddress + let pauseGuardian: SignerWithAddress + + before(async () => { + const graph = hre.graph() + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as SubgraphService + + // Get signers + governor = await graph.accounts.getGovernor() + ;[nonOwner, pauseGuardian] = await graph.accounts.getTestAccounts() + + // Set eth balance for non-owner and pause guardian + await ethers.provider.send('hardhat_setBalance', [nonOwner.address, '0x56BC75E2D63100000']) + await ethers.provider.send('hardhat_setBalance', [pauseGuardian.address, '0x56BC75E2D63100000']) + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Minimum Provision Tokens', () => { + it('should set minimum provision tokens', async () => { + const newMinimumProvisionTokens = ethers.parseEther('1000') + await subgraphService.connect(governor).setMinimumProvisionTokens(newMinimumProvisionTokens) + + // Get the provision tokens range + const [minTokens, maxTokens] = await subgraphService.getProvisionTokensRange() + expect(minTokens).to.equal(newMinimumProvisionTokens, 'Minimum provision tokens should be set') + expect(maxTokens).to.equal(ethers.MaxUint256, 'Maximum provision tokens should be set') + }) + + it('should not allow non-owner to set minimum provision tokens', async () => { + const newMinimumProvisionTokens = ethers.parseEther('1000') + await expect( + subgraphService.connect(nonOwner).setMinimumProvisionTokens(newMinimumProvisionTokens), + 'Non-owner should not be able to set minimum provision tokens', + ).to.be.revertedWithCustomError(subgraphService, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Pause Guardian', () => { + it('should set pause guardian and allow them to pause the service', async () => { + // Set pause guardian + await subgraphService.connect(governor).setPauseGuardian(pauseGuardian.address, true) + + // Pause guardian should be able to pause the service + await subgraphService.connect(pauseGuardian).pause() + expect(await subgraphService.paused(), 'Pause guardian should be able to pause the service').to.be.true + }) + + it('should remove pause guardian and prevent them from pausing the service', async () => { + // First set pause guardian + await subgraphService.connect(governor).setPauseGuardian(pauseGuardian.address, true) + + // Check that pause guardian can pause the service + await subgraphService.connect(pauseGuardian).pause() + expect(await subgraphService.paused(), 'Pause guardian should be able to pause the service').to.be.true + + // Then remove pause guardian + await subgraphService.connect(governor).setPauseGuardian(pauseGuardian.address, false) + + // Pause guardian should no longer be able to unpause the service + await expect( + subgraphService.connect(pauseGuardian).unpause(), + 'Pause guardian should no longer be able to unpause the service', + ).to.be.revertedWithCustomError(subgraphService, 'DataServicePausableNotPauseGuardian') + }) + + it('should not allow non-owner to set pause guardian', async () => { + await expect( + subgraphService.connect(nonOwner).setPauseGuardian(pauseGuardian.address, true), + 'Non-owner should not be able to set pause guardian', + ).to.be.revertedWithCustomError(subgraphService, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Delegation Ratio', () => { + it('should set delegation ratio', async () => { + const newDelegationRatio = 5 + await subgraphService.connect(governor).setDelegationRatio(newDelegationRatio) + expect(await subgraphService.getDelegationRatio(), 'Delegation ratio should be set').to.equal(newDelegationRatio) + }) + + it('should not allow non-owner to set delegation ratio', async () => { + const newDelegationRatio = 5 + await expect( + subgraphService.connect(nonOwner).setDelegationRatio(newDelegationRatio), + 'Non-owner should not be able to set delegation ratio', + ).to.be.revertedWithCustomError(subgraphService, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Stake to Fees Ratio', () => { + it('should set stake to fees ratio', async () => { + const newStakeToFeesRatio = ethers.parseEther('1') + await subgraphService.connect(governor).setStakeToFeesRatio(newStakeToFeesRatio) + + // Get the stake to fees ratio by calling a function that uses it + const stakeToFeesRatio = await subgraphService.stakeToFeesRatio() + expect(stakeToFeesRatio).to.equal(newStakeToFeesRatio, 'Stake to fees ratio should be set') + }) + + it('should not allow non-owner to set stake to fees ratio', async () => { + const newStakeToFeesRatio = ethers.parseEther('1') + await expect( + subgraphService.connect(nonOwner).setStakeToFeesRatio(newStakeToFeesRatio), + 'Non-owner should not be able to set stake to fees ratio', + ).to.be.revertedWithCustomError(subgraphService, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Max POI Staleness', () => { + it('should set max POI staleness', async () => { + const newMaxPOIStaleness = 3600 // 1 hour in seconds + await subgraphService.connect(governor).setMaxPOIStaleness(newMaxPOIStaleness) + + // Get the max POI staleness + const maxPOIStaleness = await subgraphService.maxPOIStaleness() + expect(maxPOIStaleness).to.equal(newMaxPOIStaleness, 'Max POI staleness should be set') + }) + + it('should not allow non-owner to set max POI staleness', async () => { + const newMaxPOIStaleness = 3600 + await expect( + subgraphService.connect(nonOwner).setMaxPOIStaleness(newMaxPOIStaleness), + 'Non-owner should not be able to set max POI staleness', + ).to.be.revertedWithCustomError(subgraphService, 'OwnableUnauthorizedAccount') + }) + }) + + describe('Curation Cut', () => { + it('should set curation cut', async () => { + const newCurationCut = 100000 // 10% in PPM + await subgraphService.connect(governor).setCurationCut(newCurationCut) + + // Get the curation cut + const curationCut = await subgraphService.curationFeesCut() + expect(curationCut).to.equal(newCurationCut, 'Curation cut should be set') + }) + + it('should not allow non-owner to set curation cut', async () => { + const newCurationCut = 100000 + await expect( + subgraphService.connect(nonOwner).setCurationCut(newCurationCut), + 'Non-owner should not be able to set curation cut', + ).to.be.revertedWithCustomError(subgraphService, 'OwnableUnauthorizedAccount') + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/indexer.test.ts b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/indexer.test.ts new file mode 100644 index 000000000..0f3402277 --- /dev/null +++ b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/indexer.test.ts @@ -0,0 +1,636 @@ +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { getSignedRAVCalldata, getSignerProof } from '@graphprotocol/toolshed' +import { GraphPayments, GraphTallyCollector } from '@graphprotocol/horizon' +import { IGraphToken, IHorizonStaking, IPaymentsEscrow, SubgraphService } from '../../../../typechain-types' +import { PaymentTypes } from '@graphprotocol/toolshed' +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +import { Indexer, indexers } from '../../../../tasks/test/fixtures/indexers' +import { delegators } from '@graphprotocol/horizon/tasks/test/fixtures/delegators' +import { HDNodeWallet } from 'ethers' +import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' + +describe('Indexer', () => { + let escrow: IPaymentsEscrow + let graphPayments: GraphPayments + let graphTallyCollector: GraphTallyCollector + let graphToken: IGraphToken + let staking: IHorizonStaking + let subgraphService: SubgraphService + + let snapshotId: string + + // Test addresses + let indexer: SignerWithAddress + + const graph = hre.graph() + const { collect, generateAllocationProof } = graph.subgraphService.actions + + before(() => { + // Get contracts + escrow = graph.horizon.contracts.PaymentsEscrow as unknown as IPaymentsEscrow + graphPayments = graph.horizon.contracts.GraphPayments as unknown as GraphPayments + graphTallyCollector = graph.horizon.contracts.GraphTallyCollector as unknown as GraphTallyCollector + graphToken = graph.horizon.contracts.GraphToken as unknown as IGraphToken + staking = graph.horizon.contracts.HorizonStaking as unknown as IHorizonStaking + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as SubgraphService + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Indexer Registration', () => { + let indexerUrl: string + let indexerGeoHash: string + + beforeEach(async () => { + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + indexerUrl = indexerFixture.url + indexerGeoHash = indexerFixture.geoHash + }) + + it('should register indexer with valid parameters', async () => { + // Verify indexer metadata + const indexerInfo = await subgraphService.indexers(indexer.address) + expect(indexerInfo.url).to.equal(indexerUrl) + expect(indexerInfo.geoHash).to.equal(indexerGeoHash) + }) + }) + + describe('Allocation Management', () => { + let allocationId: string + let allocationPrivateKey: string + let allocationTokens: bigint + let subgraphDeploymentId: string + let indexerFixture: Indexer + + before(async () => { + // Get indexer data + indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + }) + + describe('New allocation', () => { + let provisionTokens: bigint + + before(() => { + // Generate new allocation ID and private key + const wallet = ethers.Wallet.createRandom() + allocationId = wallet.address + allocationPrivateKey = wallet.privateKey + allocationTokens = ethers.parseEther('1000') + subgraphDeploymentId = indexerFixture.allocations[0].subgraphDeploymentID + + // Get provision tokens + provisionTokens = indexerFixture.provisionTokens + }) + + it('should start an allocation with valid parameters', async () => { + // Get locked tokens before allocation + const beforeLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + + // Build allocation proof + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + + // Attempt to create an allocation with the same ID + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, allocationTokens, allocationId, signature], + ) + + // Start allocation + await subgraphService.connect(indexer).startService( + indexer.address, + data, + ) + + // Verify allocation + const allocation = await subgraphService.getAllocation(allocationId) + expect(allocation.indexer).to.equal(indexer.address, 'Allocation indexer is not the expected indexer') + expect(allocation.tokens).to.equal(allocationTokens, 'Allocation tokens are not the expected tokens') + expect(allocation.subgraphDeploymentId).to.equal(subgraphDeploymentId, 'Allocation subgraph deployment ID is not the expected subgraph deployment ID') + + // Verify tokens are locked + const afterLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + expect(afterLockedTokens).to.equal(beforeLockedTokens + allocationTokens) + }) + + it('should be able to start an allocation with zero tokens', async () => { + // Build allocation proof + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + + // Attempt to create an allocation with the same ID + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, 0, allocationId, signature], + ) + + // Start allocation with zero tokens + await subgraphService.connect(indexer).startService( + indexer.address, + data, + ) + + // Verify allocation + const allocation = await subgraphService.getAllocation(allocationId) + expect(allocation.indexer).to.equal(indexer.address, 'Allocation indexer is not the expected indexer') + expect(allocation.tokens).to.equal(0, 'Allocation tokens are not zero') + expect(allocation.subgraphDeploymentId).to.equal(subgraphDeploymentId, 'Allocation subgraph deployment ID is not the expected subgraph deployment ID') + }) + + it('should not start an allocation without enough tokens', async () => { + // Build allocation proof + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + + // Build allocation data + const allocationTokens = provisionTokens + ethers.parseEther('10000000') + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, allocationTokens, allocationId, signature], + ) + + // Attempt to open allocation with excessive tokens + await expect( + subgraphService.connect(indexer).startService( + indexer.address, + data, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'ProvisionTrackerInsufficientTokens', + ) + }) + }) + + describe('Existing allocation', () => { + beforeEach(() => { + // Get allocation data + const allocation = indexerFixture.allocations[0] + allocationId = allocation.allocationID + allocationTokens = allocation.tokens + subgraphDeploymentId = allocation.subgraphDeploymentID + }) + + describe('Resize allocation', () => { + it('should resize an open allocation increasing tokens', async () => { + // Get locked tokens before resize + const beforeLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + + // Resize allocation + const increaseTokens = ethers.parseEther('5000') + const newAllocationTokens = allocationTokens + increaseTokens + await subgraphService.connect(indexer).resizeAllocation( + indexer.address, + allocationId, + newAllocationTokens, + ) + + // Verify allocation + const allocation = await subgraphService.getAllocation(allocationId) + expect(allocation.tokens).to.equal(newAllocationTokens, 'Allocation tokens were not resized') + + // Verify tokens are locked + const afterLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + expect(afterLockedTokens).to.equal(beforeLockedTokens + increaseTokens) + }) + + it('should resize an open allocation decreasing tokens', async () => { + // Get locked tokens before resize + const beforeLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + + // Resize allocation + const decreaseTokens = ethers.parseEther('5000') + const newAllocationTokens = allocationTokens - decreaseTokens + await subgraphService.connect(indexer).resizeAllocation( + indexer.address, + allocationId, + newAllocationTokens, + ) + + // Verify allocation + const allocation = await subgraphService.getAllocation(allocationId) + expect(allocation.tokens).to.equal(newAllocationTokens) + + // Verify tokens are released + const afterLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + expect(afterLockedTokens).to.equal(beforeLockedTokens - decreaseTokens) + }) + }) + + describe('Close allocation', () => { + it('should be able to close an allocation', async () => { + // Get before locked tokens + const beforeLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + + // Close allocation + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address'], + [allocationId], + ) + await subgraphService.connect(indexer).stopService(indexer.address, data) + + // Verify allocation is closed + const allocation = await subgraphService.getAllocation(allocationId) + expect(allocation.closedAt).to.not.equal(0) + + // Verify tokens are released + const afterLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + expect(afterLockedTokens).to.equal(beforeLockedTokens - allocationTokens) + }) + }) + }) + }) + + describe('Indexing Rewards', () => { + let allocationId: string + + describe('Re-provisioning', () => { + let otherAllocationId: string + + beforeEach(async () => { + // Get indexer + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + + // Get allocations + allocationId = indexerFixture.allocations[0].allocationID + otherAllocationId = indexerFixture.allocations[1].allocationID + + // Check rewards destination is not set + const rewardsDestination = await subgraphService.rewardsDestination(indexer.address) + expect(rewardsDestination).to.equal(ethers.ZeroAddress, 'Rewards destination should be zero address') + }) + + it('should collect indexing rewards with re-provisioning', async () => { + // Get before provision tokens + const beforeProvisionTokens = (await staking.getProvision(indexer.address, subgraphService.target)).tokens + + // Mine multiple blocks to simulate time passing + for (let i = 0; i < 1000; i++) { + await ethers.provider.send('evm_mine', []) + } + + // Build data for collect indexing rewards + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [allocationId, poi], + ) + + // Collect rewards + const rewards = await collect(indexer, [indexer.address, PaymentTypes.IndexingRewards, data]) + expect(rewards).to.not.equal(0n, 'Rewards should be greater than zero') + + // Verify rewards are added to provision + const afterProvisionTokens = (await staking.getProvision(indexer.address, subgraphService.target)).tokens + expect(afterProvisionTokens).to.equal(beforeProvisionTokens + rewards, 'Rewards should be collected') + }) + + it('should collect rewards continuously for multiple allocations', async () => { + // Get before provision tokens + const beforeProvisionTokens = (await staking.getProvision(indexer.address, subgraphService.target)).tokens + + // Build data for collect indexing rewards + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const allocationData = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [allocationId, poi], + ) + const otherAllocationData = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [otherAllocationId, poi], + ) + + // Mine multiple blocks to simulate time passing + for (let i = 0; i < 1000; i++) { + await ethers.provider.send('evm_mine', []) + } + + // Collect rewards for first allocation + let rewards = await collect(indexer, [indexer.address, PaymentTypes.IndexingRewards, allocationData]) + expect(rewards).to.not.equal(0n, 'Rewards should be greater than zero') + + // Collect rewards for second allocation + let otherRewards = await collect(indexer, [indexer.address, PaymentTypes.IndexingRewards, otherAllocationData]) + expect(otherRewards).to.not.equal(0n, 'Rewards should be greater than zero') + + // Verify total rewards collected + const afterFirstCollectionProvisionTokens = (await staking.getProvision(indexer.address, subgraphService.target)).tokens + expect(afterFirstCollectionProvisionTokens).to.equal(beforeProvisionTokens + rewards + otherRewards, 'Rewards should be collected continuously') + + // Mine multiple blocks to simulate time passing + for (let i = 0; i < 500; i++) { + await ethers.provider.send('evm_mine', []) + } + + // Collect rewards for first allocation + rewards = await collect(indexer, [indexer.address, PaymentTypes.IndexingRewards, allocationData]) + expect(rewards).to.not.equal(0n, 'Rewards should be greater than zero') + + // Collect rewards for second allocation + otherRewards = await collect(indexer, [indexer.address, PaymentTypes.IndexingRewards, otherAllocationData]) + expect(otherRewards).to.not.equal(0n, 'Rewards should be greater than zero') + + // Verify total rewards collected + const afterSecondCollectionProvisionTokens = (await staking.getProvision(indexer.address, subgraphService.target)).tokens + expect(afterSecondCollectionProvisionTokens).to.equal(afterFirstCollectionProvisionTokens + rewards + otherRewards, 'Rewards should be collected continuously') + }) + + it('should not collect rewards after POI staleness', async () => { + // Get before provision tokens + const beforeProvisionTokens = (await staking.getProvision(indexer.address, subgraphService.target)).tokens + + // Wait for POI staleness + const maxPOIStaleness = await subgraphService.maxPOIStaleness() + await ethers.provider.send('evm_increaseTime', [Number(maxPOIStaleness) + 1]) + await ethers.provider.send('evm_mine', []) + + // Build data for collect indexing rewards + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [allocationId, poi], + ) + + // Attempt to collect rewards + await subgraphService.connect(indexer).collect( + indexer.address, + PaymentTypes.IndexingRewards, + data, + ) + + // Verify no rewards were collected + const afterProvisionTokens = (await staking.getProvision(indexer.address, subgraphService.target)).tokens + expect(afterProvisionTokens).to.equal(beforeProvisionTokens, 'Rewards should not be collected after POI staleness') + }) + + describe('Over allocated', () => { + let subgraphDeploymentId: string + let delegator: SignerWithAddress + let allocationPrivateKey: string + beforeEach(async () => { + // Get delegator + delegator = await ethers.getSigner(delegators[0].address) + + // Get locked tokens + const lockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + + // Get delegation ratio + const delegationRatio = await subgraphService.getDelegationRatio() + const availableTokens = await staking.getTokensAvailable(indexer.address, subgraphService.target, delegationRatio) + + // Create allocation with tokens available + const wallet = ethers.Wallet.createRandom() + allocationId = wallet.address + allocationPrivateKey = wallet.privateKey + subgraphDeploymentId = indexers[0].allocations[0].subgraphDeploymentID + const allocationTokens = availableTokens - lockedTokens + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, allocationTokens, allocationId, signature], + ) + await subgraphService.connect(indexer).startService( + indexer.address, + data, + ) + + // Undelegate from indexer so they become over allocated + const delegation = await staking.getDelegation( + indexer.address, + subgraphService.target, + delegator.address, + ) + + // Undelegate tokens + await staking.connect(delegator)['undelegate(address,address,uint256)'](indexer.address, await subgraphService.getAddress(), delegation.shares) + }) + + it('should collect rewards while over allocated with fresh POI', async () => { + // Get before provision tokens + const beforeProvisionTokens = (await staking.getProvision(indexer.address, subgraphService.target)).tokens + + // Mine multiple blocks to simulate time passing + for (let i = 0; i < 1000; i++) { + await ethers.provider.send('evm_mine', []) + } + + // Build data for collect indexing rewards + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [allocationId, poi], + ) + + // Collect rewards + const rewards = await collect(indexer, [indexer.address, PaymentTypes.IndexingRewards, data]) + expect(rewards).to.not.equal(0n, 'Rewards should be greater than zero') + + // Verify rewards are added to provision + const afterProvisionTokens = (await staking.getProvision(indexer.address, subgraphService.target)).tokens + expect(afterProvisionTokens).to.equal(beforeProvisionTokens + rewards, 'Rewards should be collected') + + // Verify allocation was closed + const allocation = await subgraphService.getAllocation(allocationId) + expect(allocation.closedAt).to.not.equal(0) + }) + }) + }) + + describe('With rewards destination', () => { + let rewardsDestination: string + + beforeEach(async () => { + // Get indexer + const indexerFixture = indexers[1] + indexer = await ethers.getSigner(indexerFixture.address) + + // Get allocation + const allocation = indexerFixture.allocations[0] + allocationId = allocation.allocationID + + // Check rewards destination is set + rewardsDestination = await subgraphService.rewardsDestination(indexer.address) + expect(rewardsDestination).not.equal(ethers.ZeroAddress, 'Rewards destination should be set') + }) + + it('should collect indexing rewards with rewards destination', async () => { + // Get before balance of rewards destination + const beforeRewardsDestinationBalance = await graphToken.balanceOf(rewardsDestination) + + // Mine multiple blocks to simulate time passing + for (let i = 0; i < 500; i++) { + await ethers.provider.send('evm_mine', []) + } + + // Build data for collect indexing rewards + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [allocationId, poi], + ) + + // Collect rewards + const rewards = await collect(indexer, [indexer.address, PaymentTypes.IndexingRewards, data]) + expect(rewards).to.not.equal(0n, 'Rewards should be greater than zero') + + // Verify rewards are transferred to rewards destination + const afterRewardsDestinationBalance = await graphToken.balanceOf(rewardsDestination) + expect(afterRewardsDestinationBalance).to.equal(beforeRewardsDestinationBalance + rewards, 'Rewards should be transferred to rewards destination') + }) + }) + }) + + describe('Query Fees', () => { + let payer: HDNodeWallet + let signer: HDNodeWallet + let allocationId: string + let otherAllocationId: string + let collectTokens: bigint + + before(async () => { + // Get payer + payer = ethers.Wallet.createRandom() + payer = payer.connect(ethers.provider) + + // Get signer + signer = ethers.Wallet.createRandom() + signer = signer.connect(ethers.provider) + + // Mint GRT to payer and fund payer and signer with ETH + await setGRTBalance(graph.provider, graphToken.target, payer.address, ethers.parseEther('1000000')) + await ethers.provider.send('hardhat_setBalance', [payer.address, '0x56BC75E2D63100000']) + await ethers.provider.send('hardhat_setBalance', [signer.address, '0x56BC75E2D63100000']) + + // Authorize payer as signer + const chainId = (await ethers.provider.getNetwork()).chainId + // Block timestamp plus 1 year + const proofDeadline = (await ethers.provider.getBlock('latest'))!.timestamp + 31536000 + const signerProof = await getSignerProof(graphTallyCollector, signer, chainId, BigInt(proofDeadline), payer.address) + await graphTallyCollector.connect(payer).authorizeSigner(signer.address, proofDeadline, signerProof) + + // Get indexer + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + + // Get allocation + allocationId = indexerFixture.allocations[0].allocationID + otherAllocationId = indexerFixture.allocations[1].allocationID + // Get collect tokens + collectTokens = ethers.parseUnits('1000') + }) + + beforeEach(async () => { + // Deposit tokens in escrow + await graphToken.connect(payer).approve(escrow.target, collectTokens) + await escrow.connect(payer).deposit(graphTallyCollector.target, indexer.address, collectTokens) + }) + + it('should collect query fees with SignedRAV', async () => { + const encodedSignedRAV = await getSignedRAVCalldata( + graphTallyCollector, + signer, + allocationId, + payer.address, + indexer.address, + await subgraphService.getAddress(), + 0, + collectTokens, + ethers.toUtf8Bytes(''), + ) + + // Get balance before collect + const beforeBalance = await graphToken.balanceOf(indexer.address) + + // Collect query fees + await collect(indexer, [indexer.address, PaymentTypes.QueryFee, encodedSignedRAV]) + + // Calculate expected rewards + const rewardsAfterTax = collectTokens - (collectTokens * BigInt(await graphPayments.PROTOCOL_PAYMENT_CUT())) / BigInt(1e6) + const rewardsAfterCuration = rewardsAfterTax - (rewardsAfterTax * BigInt(await subgraphService.curationFeesCut())) / BigInt(1e6) + + // Verify indexer received tokens + const afterBalance = await graphToken.balanceOf(indexer.address) + expect(afterBalance).to.equal(beforeBalance + rewardsAfterCuration) + }) + + it('should collect multiple SignedRAVs', async () => { + // Get before balance + const beforeBalance = await graphToken.balanceOf(indexer.address) + + // Get fees + const fees1 = collectTokens / 4n + const fees2 = collectTokens / 2n + + // Get encoded SignedRAVs + const encodedSignedRAV1 = await getSignedRAVCalldata( + graphTallyCollector, + signer, + allocationId, + payer.address, + indexer.address, + await subgraphService.getAddress(), + 0, + fees1, + ethers.toUtf8Bytes(''), + ) + const encodedSignedRAV2 = await getSignedRAVCalldata( + graphTallyCollector, + signer, + otherAllocationId, + payer.address, + indexer.address, + await subgraphService.getAddress(), + 0, + fees2, + ethers.toUtf8Bytes(''), + ) + + // Collect first set of fees + const rewards1 = await collect(indexer, [indexer.address, PaymentTypes.QueryFee, encodedSignedRAV1]) + + // Collect second set of fees + const rewards2 = await collect(indexer, [indexer.address, PaymentTypes.QueryFee, encodedSignedRAV2]) + + // Verify total rewards collected + const totalRewards = rewards1 + rewards2 + const totalRewardsAfterTax = totalRewards - (totalRewards * BigInt(await graphPayments.PROTOCOL_PAYMENT_CUT())) / BigInt(1e6) + const totalRewardsAfterCuration = totalRewardsAfterTax - (totalRewardsAfterTax * BigInt(await subgraphService.curationFeesCut())) / BigInt(1e6) + + // Verify indexer received tokens + const afterBalance = await graphToken.balanceOf(indexer.address) + expect(afterBalance).to.equal(beforeBalance + totalRewardsAfterCuration) + + // Collect new RAV for allocation 1 + const newFees1 = fees1 * 2n + const newRAV1 = await getSignedRAVCalldata( + graphTallyCollector, + signer, + allocationId, + payer.address, + indexer.address, + await subgraphService.getAddress(), + 0, + newFees1, + ethers.toUtf8Bytes(''), + ) + + // Collect new RAV for allocation 1 + const newRewards1 = await collect(indexer, [indexer.address, PaymentTypes.QueryFee, newRAV1]) + + // Verify only the difference was collected + expect(newRewards1).to.equal(newFees1 - fees1) + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/operator.test.ts b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/operator.test.ts new file mode 100644 index 000000000..d9c289d1b --- /dev/null +++ b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/operator.test.ts @@ -0,0 +1,392 @@ +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { DisputeManager, IGraphToken, IHorizonStaking, IPaymentsEscrow, SubgraphService } from '../../../../typechain-types' +import { getSignedRAVCalldata, getSignerProof } from '@graphprotocol/toolshed' +import { GraphTallyCollector } from '@graphprotocol/horizon' +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +import { indexers } from '../../../../tasks/test/fixtures/indexers' +import { PaymentTypes } from '@graphprotocol/toolshed' +import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' + +describe('Operator', () => { + let subgraphService: SubgraphService + let staking: IHorizonStaking + let graphToken: IGraphToken + let escrow: IPaymentsEscrow + let disputeManager: DisputeManager + let graphTallyCollector: GraphTallyCollector + + let snapshotId: string + + // Test addresses + let indexer: SignerWithAddress + let authorizedOperator: SignerWithAddress + let unauthorizedOperator: SignerWithAddress + let allocationId: string + let subgraphDeploymentId: string + let allocationTokens: bigint + + const graph = hre.graph() + const { provision } = graph.horizon.actions + const { collect, generateAllocationProof } = graph.subgraphService.actions + + before(() => { + // Get contracts + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as SubgraphService + staking = graph.horizon.contracts.HorizonStaking as unknown as IHorizonStaking + graphToken = graph.horizon.contracts.GraphToken as unknown as IGraphToken + escrow = graph.horizon.contracts.PaymentsEscrow as unknown as IPaymentsEscrow + graphTallyCollector = graph.horizon.contracts.GraphTallyCollector as unknown as GraphTallyCollector + disputeManager = graph.subgraphService.contracts.DisputeManager as unknown as DisputeManager + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('New indexer', () => { + beforeEach(async () => { + // Get indexer + [indexer, authorizedOperator, unauthorizedOperator] = await graph.accounts.getTestAccounts() + + // Set balances for operators + await ethers.provider.send('hardhat_setBalance', [authorizedOperator.address, '0x56BC75E2D63100000']) + await ethers.provider.send('hardhat_setBalance', [unauthorizedOperator.address, '0x56BC75E2D63100000']) + + // Create provision + const disputePeriod = await disputeManager.getDisputePeriod() + const maxSlashingCut = await disputeManager.maxSlashingCut() + await setGRTBalance(graph.provider, graphToken.target, indexer.address, ethers.parseEther('100000')) + await provision(indexer, [indexer.address, await subgraphService.getAddress(), ethers.parseEther('100000'), maxSlashingCut, disputePeriod]) + }) + + describe('Authorized Operator', () => { + beforeEach(async () => { + // Authorize operator + await staking.connect(indexer).setOperator(await subgraphService.getAddress(), authorizedOperator.address, true) + }) + + it('should be able to register the indexer', async () => { + const indexerUrl = 'https://test-indexer.com' + const indexerGeoHash = 'test-geo-hash' + const indexerRegistrationData = hre.ethers.AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'address'], + [indexerUrl, indexerGeoHash, ethers.ZeroAddress], + ) + + await subgraphService.connect(authorizedOperator).register(indexer.address, indexerRegistrationData) + + // Verify indexer metadata + const indexerInfo = await subgraphService.indexers(indexer.address) + expect(indexerInfo.url).to.equal(indexerUrl) + expect(indexerInfo.geoHash).to.equal(indexerGeoHash) + }) + }) + + describe('Unauthorized Operator', () => { + it('should not be able to register the indexer', async () => { + const indexerUrl = 'https://test-indexer.com' + const indexerGeoHash = 'test-geo-hash' + const indexerRegistrationData = hre.ethers.AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'address'], + [indexerUrl, indexerGeoHash, ethers.ZeroAddress], + ) + + await expect( + subgraphService.connect(unauthorizedOperator).register(indexer.address, indexerRegistrationData), + ).to.be.revertedWithCustomError( + subgraphService, + 'ProvisionManagerNotAuthorized', + ) + }) + }) + }) + + describe('Existing indexer', () => { + beforeEach(async () => { + // Get indexer + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + + ;[authorizedOperator, unauthorizedOperator] = await graph.accounts.getTestAccounts() + }) + + describe('New allocation', () => { + let allocationPrivateKey: string + + beforeEach(() => { + // Generate test allocation + const wallet = ethers.Wallet.createRandom() + allocationId = wallet.address + allocationPrivateKey = wallet.privateKey + subgraphDeploymentId = ethers.keccak256(ethers.toUtf8Bytes('test-subgraph-deployment')) + allocationTokens = ethers.parseEther('10000') + }) + + describe('Authorized Operator', () => { + beforeEach(async () => { + // Authorize operator + await staking.connect(indexer).setOperator(await subgraphService.getAddress(), authorizedOperator.address, true) + }) + + it('should be able to create an allocation', async () => { + // Build allocation proof + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + + // Build allocation data + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, allocationTokens, allocationId, signature], + ) + + // Start allocation + await subgraphService.connect(authorizedOperator).startService( + indexer.address, + data, + ) + + // Verify allocation + const allocation = await subgraphService.getAllocation(allocationId) + expect(allocation.indexer).to.equal(indexer.address) + expect(allocation.tokens).to.equal(allocationTokens) + expect(allocation.subgraphDeploymentId).to.equal(subgraphDeploymentId) + }) + }) + + describe('Unauthorized Operator', () => { + it('should not be able to create an allocation', async () => { + // Build allocation proof + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + + // Build allocation data + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, allocationTokens, allocationId, signature], + ) + + await expect( + subgraphService.connect(unauthorizedOperator).startService( + indexer.address, + data, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'ProvisionManagerNotAuthorized', + ) + }) + }) + }) + + describe('Open allocation', () => { + beforeEach(() => { + // Get allocation data + const allocationFixture = indexers[0].allocations[0] + allocationId = allocationFixture.allocationID + subgraphDeploymentId = allocationFixture.subgraphDeploymentID + allocationTokens = allocationFixture.tokens + }) + + describe('Authorized Operator', () => { + beforeEach(async () => { + // Authorize operator + await staking.connect(indexer).setOperator(await subgraphService.getAddress(), authorizedOperator.address, true) + }) + + it('should be able to resize an allocation', async () => { + // Resize allocation + const newAllocationTokens = allocationTokens + ethers.parseEther('5000') + await subgraphService.connect(authorizedOperator).resizeAllocation( + indexer.address, + allocationId, + newAllocationTokens, + ) + + // Verify allocation + const allocation = await subgraphService.getAllocation(allocationId) + expect(allocation.tokens).to.equal(newAllocationTokens) + }) + + it('should be able to close an allocation', async () => { + // Close allocation + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address'], + [allocationId], + ) + await subgraphService.connect(authorizedOperator).stopService( + indexer.address, + data, + ) + + // Verify allocation is closed + const allocation = await subgraphService.getAllocation(allocationId) + expect(allocation.closedAt).to.not.equal(0) + }) + + it('should be able to collect indexing rewards', async () => { + // Mine multiple blocks to simulate time passing + for (let i = 0; i < 1000; i++) { + await ethers.provider.send('evm_mine', []) + } + + // Build data for collect indexing rewards + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const collectData = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [allocationId, poi], + ) + + // Collect rewards + const rewards = await collect(authorizedOperator, [indexer.address, PaymentTypes.IndexingRewards, collectData]) + expect(rewards).to.not.equal(0n) + }) + + it('should be able to collect query fees', async () => { + // Setup query fees collection + let payer = ethers.Wallet.createRandom() + payer = payer.connect(ethers.provider) + let signer = ethers.Wallet.createRandom() + signer = signer.connect(ethers.provider) + const collectTokens = ethers.parseUnits('1000') + + // Mint GRT to payer and fund payer and signer with ETH + await setGRTBalance(graph.provider, graphToken.target, payer.address, ethers.parseEther('1000000')) + await ethers.provider.send('hardhat_setBalance', [payer.address, '0x56BC75E2D63100000']) + await ethers.provider.send('hardhat_setBalance', [signer.address, '0x56BC75E2D63100000']) + + // Authorize payer as signer + const chainId = (await ethers.provider.getNetwork()).chainId + const proofDeadline = (await ethers.provider.getBlock('latest'))!.timestamp + 31536000 + const signerProof = await getSignerProof(graphTallyCollector, signer, chainId, BigInt(proofDeadline), payer.address) + await graphTallyCollector.connect(payer).authorizeSigner(signer.address, proofDeadline, signerProof) + + // Deposit tokens in escrow + await graphToken.connect(payer).approve(escrow.target, collectTokens) + await escrow.connect(payer).deposit(graphTallyCollector.target, indexer.address, collectTokens) + + // Get encoded SignedRAV + const encodedSignedRAV = await getSignedRAVCalldata( + graphTallyCollector, + signer, + allocationId, + payer.address, + indexer.address, + await subgraphService.getAddress(), + 0, + collectTokens, + ethers.toUtf8Bytes(''), + ) + + // Collect query fees + const rewards = await collect(authorizedOperator, [indexer.address, PaymentTypes.QueryFee, encodedSignedRAV]) + expect(rewards).to.not.equal(0n) + }) + }) + + describe('Unauthorized Operator', () => { + it('should not be able to resize an allocation', async () => { + // Attempt to resize with unauthorized operator + const newAllocationTokens = allocationTokens + ethers.parseEther('5000') + await expect( + subgraphService.connect(unauthorizedOperator).resizeAllocation( + indexer.address, + allocationId, + newAllocationTokens, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'ProvisionManagerNotAuthorized', + ) + }) + + it('should not be able to close an allocation', async () => { + // Attempt to close with unauthorized operator + await expect( + subgraphService.connect(unauthorizedOperator).stopService( + indexer.address, + allocationId, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'ProvisionManagerNotAuthorized', + ) + }) + + it('should not be able to collect indexing rewards', async () => { + // Mine multiple blocks to simulate time passing + for (let i = 0; i < 1000; i++) { + await ethers.provider.send('evm_mine', []) + } + + // Build data for collect indexing rewards + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const collectData = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [allocationId, poi], + ) + + // Attempt to collect rewards with unauthorized operator + await expect( + collect(unauthorizedOperator, [indexer.address, PaymentTypes.IndexingRewards, collectData]), + ).to.be.revertedWithCustomError( + subgraphService, + 'ProvisionManagerNotAuthorized', + ) + }) + + it('should not be able to collect query fees', async () => { + // Setup query fees collection + let payer = ethers.Wallet.createRandom() + payer = payer.connect(ethers.provider) + let signer = ethers.Wallet.createRandom() + signer = signer.connect(ethers.provider) + const collectTokens = ethers.parseUnits('1000') + + // Mint GRT to payer and fund payer and signer with ETH + await setGRTBalance(graph.provider, graphToken.target, payer.address, ethers.parseEther('1000000')) + await ethers.provider.send('hardhat_setBalance', [payer.address, '0x56BC75E2D63100000']) + await ethers.provider.send('hardhat_setBalance', [signer.address, '0x56BC75E2D63100000']) + + // Authorize payer as signer + const chainId = (await ethers.provider.getNetwork()).chainId + const proofDeadline = (await ethers.provider.getBlock('latest'))!.timestamp + 31536000 + const signerProof = await getSignerProof(graphTallyCollector, signer, chainId, BigInt(proofDeadline), payer.address) + await graphTallyCollector.connect(payer).authorizeSigner(signer.address, proofDeadline, signerProof) + + // Deposit tokens in escrow + await graphToken.connect(payer).approve(escrow.target, collectTokens) + await escrow.connect(payer).deposit(escrow.target, indexer.address, collectTokens) + + // Get encoded SignedRAV + const encodedSignedRAV = await getSignedRAVCalldata( + graphTallyCollector, + signer, + allocationId, + payer.address, + indexer.address, + await subgraphService.getAddress(), + 0, + collectTokens, + ethers.toUtf8Bytes(''), + ) + + // Attempt to collect query fees with unauthorized operator + await expect( + collect(unauthorizedOperator, [indexer.address, PaymentTypes.QueryFee, encodedSignedRAV]), + ).to.be.revertedWithCustomError( + subgraphService, + 'ProvisionManagerNotAuthorized', + ) + }) + }) + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/paused.test.ts b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/paused.test.ts new file mode 100644 index 000000000..c89d9d522 --- /dev/null +++ b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/paused.test.ts @@ -0,0 +1,237 @@ +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { DisputeManager, IGraphToken, SubgraphService } from '../../../../typechain-types' +import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +import { indexers } from '../../../../tasks/test/fixtures/indexers' +import { PaymentTypes } from '@graphprotocol/toolshed' + +describe('Paused Protocol', () => { + let disputeManager: DisputeManager + let graphToken: IGraphToken + let subgraphService: SubgraphService + + let snapshotId: string + + // Test addresses + let pauseGuardian: SignerWithAddress + let indexer: SignerWithAddress + let allocationId: string + let subgraphDeploymentId: string + let allocationTokens: bigint + + const graph = hre.graph() + const { provision } = graph.horizon.actions + const { collect, generateAllocationProof } = graph.subgraphService.actions + + before(async () => { + // Get contracts + disputeManager = graph.subgraphService.contracts.DisputeManager as unknown as DisputeManager + graphToken = graph.horizon.contracts.GraphToken as unknown as IGraphToken + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as SubgraphService + + // Get signers + pauseGuardian = await graph.accounts.getPauseGuardian() + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + + // Get indexer + ;[indexer] = await graph.accounts.getTestAccounts() + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Pause actions', () => { + it('should allow pause guardian to pause the protocol', async () => { + await subgraphService.connect(pauseGuardian).pause() + expect(await subgraphService.paused()).to.be.true + }) + + it('should allow pause guardian to unpause the protocol', async () => { + // First pause the protocol + await subgraphService.connect(pauseGuardian).pause() + expect(await subgraphService.paused()).to.be.true + + // Then unpause it + await subgraphService.connect(pauseGuardian).unpause() + expect(await subgraphService.paused()).to.be.false + }) + }) + + describe('Indexer Operations While Paused', () => { + beforeEach(async () => { + // Pause the protocol before each test + await subgraphService.connect(pauseGuardian).pause() + }) + + describe('Existing indexer', () => { + beforeEach(async () => { + // Get indexer + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + }) + + describe('Opened allocation', () => { + beforeEach(() => { + // Get allocation + const allocation = indexers[0].allocations[0] + allocationId = allocation.allocationID + subgraphDeploymentId = allocation.subgraphDeploymentID + allocationTokens = allocation.tokens + }) + + it('should not allow indexer to stop an allocation while paused', async () => { + await expect( + subgraphService.connect(indexer).stopService( + indexer.address, + allocationId, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'EnforcedPause', + ) + }) + + it('should not allow indexer to collect indexing rewards while paused', async () => { + // Build data for collect indexing rewards + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [allocationId, poi], + ) + + await expect( + collect(indexer, [indexer.address, PaymentTypes.IndexingRewards, data]), + ).to.be.revertedWithCustomError( + subgraphService, + 'EnforcedPause', + ) + }) + + it('should not allow indexer to collect query fees while paused', async () => { + // Build data for collect query fees + const poi = ethers.keccak256(ethers.toUtf8Bytes('test-poi')) + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32'], + [allocationId, poi], + ) + + await expect( + collect(indexer, [indexer.address, PaymentTypes.QueryFee, data]), + ).to.be.revertedWithCustomError( + subgraphService, + 'EnforcedPause', + ) + }) + + it('should not allow indexer to resize an allocation while paused', async () => { + await expect( + subgraphService.connect(indexer).resizeAllocation( + indexer.address, + allocationId, + allocationTokens + ethers.parseEther('1000'), + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'EnforcedPause', + ) + }) + }) + + describe('New allocation', () => { + let allocationPrivateKey: string + + beforeEach(() => { + // Get allocation + const wallet = ethers.Wallet.createRandom() + allocationId = wallet.address + allocationPrivateKey = wallet.privateKey + subgraphDeploymentId = indexers[0].allocations[0].subgraphDeploymentID + allocationTokens = 1000n + }) + + it('should not allow indexer to start an allocation while paused', async () => { + // Build allocation proof + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + + // Build allocation data + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, allocationTokens, allocationId, signature], + ) + + await expect( + subgraphService.connect(indexer).startService( + indexer.address, + data, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'EnforcedPause', + ) + }) + }) + }) + + describe('New indexer', () => { + beforeEach(async () => { + // Get indexer + [indexer] = await graph.accounts.getTestAccounts() + + // Create provision + const disputePeriod = await disputeManager.getDisputePeriod() + const maxSlashingCut = await disputeManager.maxSlashingCut() + await setGRTBalance(graph.provider, graphToken.target, indexer.address, ethers.parseEther('100000')) + await provision(indexer, [indexer.address, await subgraphService.getAddress(), ethers.parseEther('100000'), maxSlashingCut, disputePeriod]) + }) + + it('should not allow indexer to register while paused', async () => { + const indexerUrl = 'https://test-indexer.com' + const indexerGeoHash = 'test-geo-hash' + const indexerRegistrationData = hre.ethers.AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'address'], + [indexerUrl, indexerGeoHash, ethers.ZeroAddress], + ) + + await expect( + subgraphService.connect(indexer).register(indexer.address, indexerRegistrationData), + ).to.be.revertedWithCustomError( + subgraphService, + 'EnforcedPause', + ) + }) + }) + + describe('Permissionless', () => { + let anyone: SignerWithAddress + + before(async () => { + // Get anyone address + [anyone] = await graph.accounts.getTestAccounts() + }) + + it('should not allow anyone to close a stale allocation while paused', async () => { + // Wait for POI staleness + const maxPOIStaleness = await subgraphService.maxPOIStaleness() + await ethers.provider.send('evm_increaseTime', [Number(maxPOIStaleness) + 1]) + await ethers.provider.send('evm_mine', []) + + await expect( + subgraphService.connect(anyone).closeStaleAllocation(allocationId), + ).to.be.revertedWithCustomError( + subgraphService, + 'EnforcedPause', + ) + }) + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/permisionless.test.ts b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/permisionless.test.ts new file mode 100644 index 000000000..996be4710 --- /dev/null +++ b/packages/subgraph-service/test/integration/after-transition-period/subgraph-service/permisionless.test.ts @@ -0,0 +1,116 @@ +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' +import { SubgraphService } from '../../../../typechain-types' + +import { indexers } from '../../../../tasks/test/fixtures/indexers' + +describe('Permissionless', () => { + let subgraphService: SubgraphService + let snapshotId: string + + // Test data + let indexer: SignerWithAddress + let anyone: SignerWithAddress + let allocationId: string + let subgraphDeploymentId: string + let allocationTokens: bigint + + const graph = hre.graph() + const { generateAllocationProof } = graph.subgraphService.actions + + before(async () => { + // Get contracts + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as SubgraphService + + // Get anyone address + ;[anyone] = await graph.accounts.getTestAccounts() + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Non-altruistic allocation', () => { + beforeEach(async () => { + // Get indexer + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + + // Get allocation + const allocation = indexerFixture.allocations[0] + allocationId = allocation.allocationID + subgraphDeploymentId = allocation.subgraphDeploymentID + allocationTokens = allocation.tokens + }) + + it('should allow anyone to close an allocation after max POI staleness passes', async () => { + // Wait for POI staleness + const maxPOIStaleness = await subgraphService.maxPOIStaleness() + await ethers.provider.send('evm_increaseTime', [Number(maxPOIStaleness) + 1]) + await ethers.provider.send('evm_mine', []) + + // Get before state + const beforeLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + + // Close allocation as anyone + await subgraphService.connect(anyone).closeStaleAllocation(allocationId) + + // Verify allocation is closed + const afterAllocation = await subgraphService.getAllocation(allocationId) + expect(afterAllocation.closedAt).to.not.equal(0, 'Allocation should be closed') + + // Verify tokens are released + const afterLockedTokens = await subgraphService.allocationProvisionTracker(indexer.address) + expect(afterLockedTokens).to.equal(beforeLockedTokens - allocationTokens, 'Tokens should be released') + }) + }) + + describe('Altruistic allocation', () => { + let allocationPrivateKey: string + + beforeEach(async () => { + // Get indexer + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + + // Generate random allocation + const wallet = ethers.Wallet.createRandom() + allocationId = wallet.address + allocationPrivateKey = wallet.privateKey + subgraphDeploymentId = indexerFixture.allocations[0].subgraphDeploymentID + allocationTokens = 0n + + // Start allocation + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, allocationTokens, allocationId, signature], + ) + await subgraphService.connect(indexer).startService(indexer.address, data) + }) + + it('should not allow closing an altruistic allocation permissionless', async () => { + // Wait for POI staleness + const maxPOIStaleness = await subgraphService.maxPOIStaleness() + await ethers.provider.send('evm_increaseTime', [Number(maxPOIStaleness) + 1]) + await ethers.provider.send('evm_mine', []) + + // Attempt to close allocation as anyone + await expect( + subgraphService.connect(anyone).closeStaleAllocation(allocationId), + ).to.be.revertedWithCustomError( + subgraphService, + 'SubgraphServiceAllocationIsAltruistic', + ).withArgs(allocationId) + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/during-transition-period/governance.test.ts b/packages/subgraph-service/test/integration/during-transition-period/governance.test.ts new file mode 100644 index 000000000..89419ea06 --- /dev/null +++ b/packages/subgraph-service/test/integration/during-transition-period/governance.test.ts @@ -0,0 +1,95 @@ +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +import { ISubgraphService } from '../../../typechain-types' + +describe('Governance', () => { + let subgraphService: ISubgraphService + let snapshotId: string + + // Test addresses + let governor: SignerWithAddress + let indexer: SignerWithAddress + let nonOwner: SignerWithAddress + let allocationId: string + let subgraphDeploymentId: string + + const graph = hre.graph() + + before(() => { + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as ISubgraphService + // Get proxy admin with SubgraphServiceInterface + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + + // Get signers + governor = await graph.accounts.getGovernor() + ;[indexer, nonOwner] = await graph.accounts.getTestAccounts() + + // Generate test addresses + allocationId = ethers.Wallet.createRandom().address + subgraphDeploymentId = ethers.keccak256(ethers.toUtf8Bytes('test-subgraph-deployment')) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Legacy Allocation Migration', () => { + it('should migrate legacy allocation', async () => { + // Migrate legacy allocation + await subgraphService.connect(governor).migrateLegacyAllocation( + indexer.address, + allocationId, + subgraphDeploymentId, + ) + + // Verify the legacy allocation was migrated + const legacyAllocation = await subgraphService.getLegacyAllocation(allocationId) + expect(legacyAllocation.indexer).to.equal(indexer.address) + expect(legacyAllocation.subgraphDeploymentId).to.equal(subgraphDeploymentId) + }) + + it('should not allow non-owner to migrate legacy allocation', async () => { + // Attempt to migrate legacy allocation as non-owner + await expect( + subgraphService.connect(nonOwner).migrateLegacyAllocation( + indexer.address, + allocationId, + subgraphDeploymentId, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'OwnableUnauthorizedAccount', + ) + }) + + it('should not allow migrating a legacy allocation that was already migrated', async () => { + // First migration + await subgraphService.connect(governor).migrateLegacyAllocation( + indexer.address, + allocationId, + subgraphDeploymentId, + ) + + // Attempt to migrate the same allocation again + await expect( + subgraphService.connect(governor).migrateLegacyAllocation( + indexer.address, + allocationId, + subgraphDeploymentId, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'LegacyAllocationAlreadyExists', + ).withArgs(allocationId) + }) + }) +}) diff --git a/packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts b/packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts new file mode 100644 index 000000000..38af97dca --- /dev/null +++ b/packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts @@ -0,0 +1,104 @@ +import { ethers } from 'hardhat' +import { expect } from 'chai' +import hre from 'hardhat' + +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' + +import { indexers } from '../../../tasks/test/fixtures/indexers' +import { ISubgraphService } from '../../../typechain-types' + +describe('Indexer', () => { + let subgraphService: ISubgraphService + let snapshotId: string + + // Test addresses + let governor: SignerWithAddress + let indexer: SignerWithAddress + let allocationId: string + let subgraphDeploymentId: string + let allocationPrivateKey: string + + const graph = hre.graph() + const { generateAllocationProof } = graph.subgraphService.actions + + before(async () => { + // Get contracts + subgraphService = graph.subgraphService.contracts.SubgraphService as unknown as ISubgraphService + + // Get governor and non-owner + governor = await graph.accounts.getGovernor() + }) + + beforeEach(async () => { + // Take a snapshot before each test + snapshotId = await ethers.provider.send('evm_snapshot', []) + }) + + afterEach(async () => { + // Revert to the snapshot after each test + await ethers.provider.send('evm_revert', [snapshotId]) + }) + + describe('Allocation', () => { + beforeEach(async () => { + // Get indexer + const indexerFixture = indexers[0] + indexer = await ethers.getSigner(indexerFixture.address) + + // Generate test addresses + const allocation = indexerFixture.legacyAllocations[0] + allocationId = allocation.allocationID + subgraphDeploymentId = allocation.subgraphDeploymentID + allocationPrivateKey = allocation.allocationPrivateKey + }) + + it('should not be able to create an allocation with an AllocationID that already exists in HorizonStaking contract', async () => { + // Build allocation proof + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + + // Attempt to create an allocation with the same ID + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, 1000, allocationId, signature], + ) + + await expect( + subgraphService.connect(indexer).startService( + indexer.address, + data, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'LegacyAllocationAlreadyExists', + ).withArgs(allocationId) + }) + + it('should not be able to create an allocation that was already migrated by the owner', async () => { + // Migrate legacy allocation + await subgraphService.connect(governor).migrateLegacyAllocation( + indexer.address, + allocationId, + subgraphDeploymentId, + ) + + // Build allocation proof + const signature = await generateAllocationProof(allocationPrivateKey, [indexer.address, allocationId]) + + // Attempt to create the same allocation + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address', 'bytes'], + [subgraphDeploymentId, 1000, allocationId, signature], + ) + + await expect( + subgraphService.connect(indexer).startService( + indexer.address, + data, + ), + ).to.be.revertedWithCustomError( + subgraphService, + 'LegacyAllocationAlreadyExists', + ).withArgs(allocationId) + }) + }) +}) diff --git a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol index 4d65dcaca..cf0844721 100644 --- a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol +++ b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol @@ -7,7 +7,6 @@ import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol" import { IDisputeManager } from "../../../contracts/interfaces/IDisputeManager.sol"; import { Attestation } from "../../../contracts/libraries/Attestation.sol"; import { Allocation } from "../../../contracts/libraries/Allocation.sol"; -import { IDisputeManager } from "../../../contracts/interfaces/IDisputeManager.sol"; import { SubgraphServiceSharedTest } from "../shared/SubgraphServiceShared.t.sol"; diff --git a/packages/toolshed/src/core/attestations.ts b/packages/toolshed/src/core/attestations.ts new file mode 100644 index 000000000..7b3a01445 --- /dev/null +++ b/packages/toolshed/src/core/attestations.ts @@ -0,0 +1,44 @@ +import { ethers, Wallet } from "ethers" + +import { IDisputeManager } from "@graphprotocol/subgraph-service" + +/** + * Creates an attestation data for a given request and response CIDs. + * @param disputeManager The dispute manager contract instance. + * @param signer The allocation ID that will be signing the attestation. + * @param requestCID The request CID. + * @param responseCID The response CID. + * @param subgraphDeploymentId The subgraph deployment ID. + * @returns The attestation data. + */ +export async function createAttestationData( + disputeManager: IDisputeManager, + signer: Wallet, + requestCID: string, + responseCID: string, + subgraphDeploymentId: string +): Promise { + // Create receipt struct + const receipt = { + requestCID, + responseCID, + subgraphDeploymentId + } + + // Encode the receipt using the dispute manager + const receiptHash = await disputeManager.encodeReceipt(receipt) + + // Sign the receipt hash with the allocation private key + const signature = signer.signingKey.sign(ethers.getBytes(receiptHash)) + const sig = ethers.Signature.from(signature) + + // Concatenate the bytes directly + return ethers.concat([ + ethers.getBytes(requestCID), + ethers.getBytes(responseCID), + ethers.getBytes(subgraphDeploymentId), + ethers.getBytes(sig.r), + ethers.getBytes(sig.s), + new Uint8Array([sig.v]) + ]) +} \ No newline at end of file diff --git a/packages/toolshed/src/core/collectors.ts b/packages/toolshed/src/core/collectors.ts new file mode 100644 index 000000000..3a0ee9bd2 --- /dev/null +++ b/packages/toolshed/src/core/collectors.ts @@ -0,0 +1,84 @@ +import { BytesLike, ethers, HDNodeWallet, Signature } from 'ethers' + +import { IGraphTallyCollector } from '@graphprotocol/subgraph-service' + +/** + * Generates a signed RAV calldata + * @param graphTallyCollector The Graph Tally Collector contract + * @param signer The signer + * @param collectionId The collection ID + * @param payer The payer + * @param serviceProvider The service provider + * @param dataService The data service + * @param timestampNs The timestamp in nanoseconds + * @param valueAggregate The value aggregate + * @param metadata The metadata + * @returns The encoded signed RAV calldata + */ +export async function getSignedRAVCalldata( + graphTallyCollector: IGraphTallyCollector, + signer: HDNodeWallet, + allocationId: string, + payer: string, + serviceProvider: string, + dataService: string, + timestampNs: number, + valueAggregate: bigint, + metadata: BytesLike +) { + const ravData = { + collectionId: ethers.zeroPadValue(allocationId, 32), + payer: payer, + serviceProvider: serviceProvider, + dataService: dataService, + timestampNs: timestampNs, + valueAggregate: valueAggregate, + metadata: metadata + } + + const encodedRAV = await graphTallyCollector.encodeRAV(ravData) + const messageHash = ethers.getBytes(encodedRAV) + const signature = ethers.Signature.from(signer.signingKey.sign(messageHash)).serialized + const signedRAV = { rav: ravData, signature } + return ethers.AbiCoder.defaultAbiCoder().encode( + ['tuple(tuple(bytes32 collectionId, address payer, address serviceProvider, address dataService, uint256 timestampNs, uint128 valueAggregate, bytes metadata) rav, bytes signature)'], + [signedRAV] + ) +} + +/** + * Generates a signer proof for authorizing a signer in the Graph Tally Collector + * @param graphTallyCollector The Graph Tally Collector contract + * @param signer The signer + * @param chainId The chain ID + * @param proofDeadline The deadline for the proof + * @param signerPrivateKey The private key of the signer + * @returns The encoded signer proof + */ +export async function getSignerProof( + graphTallyCollector: IGraphTallyCollector, + signer: HDNodeWallet, + chainId: bigint, + proofDeadline: bigint, + payer: string +): Promise { + // Create the message hash + const messageHash = ethers.keccak256( + ethers.solidityPacked( + ['uint256', 'address', 'string', 'uint256', 'address'], + [ + chainId, + await graphTallyCollector.getAddress(), + 'authorizeSignerProof', + proofDeadline, + payer + ] + ) + ) + + // Convert to EIP-191 signed message hash (this is the proofToDigest) + const proofToDigest = ethers.hashMessage(ethers.getBytes(messageHash)) + + // Sign the message + return Signature.from(signer.signingKey.sign(proofToDigest)).serialized +} diff --git a/packages/toolshed/src/core/index.ts b/packages/toolshed/src/core/index.ts index 5096e1093..01e03b9b2 100644 --- a/packages/toolshed/src/core/index.ts +++ b/packages/toolshed/src/core/index.ts @@ -4,3 +4,5 @@ export * from './constants' export * from './allocation' export * from './types' export * from './accounts' +export * from './collectors' +export * from './attestations' diff --git a/packages/toolshed/src/deployments/subgraph-service/actions.ts b/packages/toolshed/src/deployments/subgraph-service/actions.ts new file mode 100644 index 000000000..d24e466f0 --- /dev/null +++ b/packages/toolshed/src/deployments/subgraph-service/actions.ts @@ -0,0 +1,62 @@ +import { ethers } from 'ethers' + +import type { ISubgraphService } from '@graphprotocol/subgraph-service' +import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' +import { Interface } from 'ethers' + +export function loadActions(contracts: { SubgraphService: ISubgraphService }) { + return { + /** + * Collects the allocated funds for a subgraph deployment + * @param signer - The signer that will execute the collect transaction + * @param args Parameters: + * - `[indexer, paymentType, data]` - The collect parameters + * @returns The payment collected + */ + collect: (signer: HardhatEthersSigner, args: Parameters): Promise => collect(contracts, signer, args), + + /** + * Generates an allocation proof for the subgraph services + * @param signer - The signer that will sign the allocation proof + * @param args Parameters: + * - `[indexer, allocationId]` - The encodeAllocationProof parameters + * @returns The allocation proof + */ + generateAllocationProof: (allocationPrivateKey: string, args: Parameters): Promise => generateAllocationProof(contracts, allocationPrivateKey, args), + } +} + +// Collects payment from the subgraph service +async function collect( + contracts: { SubgraphService: ISubgraphService }, + signer: HardhatEthersSigner, + args: Parameters, +): Promise { + const { SubgraphService } = contracts + const [indexer, paymentType, data] = args + + const tx = await SubgraphService.connect(signer).collect(indexer, paymentType, data) + const receipt = await tx.wait() + if (!receipt) throw new Error('Transaction failed') + + const iface = new Interface(['event ServicePaymentCollected(address indexed serviceProvider, uint8 indexed feeType, uint256 tokens)']) + const event = receipt.logs.find(log => log.topics[0] === iface.getEvent('ServicePaymentCollected')?.topicHash) + if (!event) throw new Error('ServicePaymentCollected event not found') + + return BigInt(event.data) +} + +// Generate allocation proof for the subgraph services +async function generateAllocationProof( + contracts: { SubgraphService: ISubgraphService }, + allocationPrivateKey: string, + args: Parameters, +): Promise { + const { SubgraphService } = contracts + const [indexer, allocationId] = args + + const wallet = new ethers.Wallet(allocationPrivateKey) + const messageHash = await SubgraphService.encodeAllocationProof(indexer, allocationId) + const signature = wallet.signingKey.sign(messageHash) + return ethers.Signature.from(signature).serialized +} \ No newline at end of file diff --git a/packages/toolshed/src/deployments/subgraph-service/index.ts b/packages/toolshed/src/deployments/subgraph-service/index.ts index e30e70110..f50c89994 100644 --- a/packages/toolshed/src/deployments/subgraph-service/index.ts +++ b/packages/toolshed/src/deployments/subgraph-service/index.ts @@ -1,4 +1,5 @@ import { HardhatEthersProvider } from '@nomicfoundation/hardhat-ethers/internal/hardhat-ethers-provider' +import { loadActions } from './actions' import { SubgraphServiceAddressBook } from './address-book' export { SubgraphServiceAddressBook } @@ -9,5 +10,6 @@ export function loadSubgraphService(addressBookPath: string, chainId: number, pr return { addressBook: addressBook, contracts: addressBook.loadContracts(provider), + actions: loadActions(addressBook.loadContracts(provider)), } } diff --git a/packages/toolshed/src/deployments/types.ts b/packages/toolshed/src/deployments/types.ts index 5f79130a7..3ae96a884 100644 --- a/packages/toolshed/src/deployments/types.ts +++ b/packages/toolshed/src/deployments/types.ts @@ -1,7 +1,7 @@ import type { GraphHorizonAddressBook, GraphHorizonContracts } from './horizon' import type { SubgraphServiceAddressBook, SubgraphServiceContracts } from './subgraph-service' import type { loadActions } from './horizon/actions' - +import type { loadActions as loadSubgraphServiceActions } from './subgraph-service/actions' export const GraphDeploymentsList = ['horizon', 'subgraphService'] as const export type GraphDeploymentName = (typeof GraphDeploymentsList)[number] @@ -15,5 +15,6 @@ export type GraphDeployments = { subgraphService: { contracts: SubgraphServiceContracts addressBook: SubgraphServiceAddressBook + actions: ReturnType } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa0f48f8c..ce75e36a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -639,6 +639,9 @@ importers: ethers: specifier: ^6.13.4 version: 6.13.5(bufferutil@4.0.9)(utf-8-validate@5.0.10) + glob: + specifier: ^11.0.1 + version: 11.0.1 hardhat: specifier: ^2.22.18 version: 2.22.19(bufferutil@4.0.9)(ts-node@10.9.2(@types/node@20.17.30)(typescript@5.8.2))(typescript@5.8.2)(utf-8-validate@5.0.10)