From 73940d94adeb94bf415de1bf04687dbc23e42c1f Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:22:14 +0000 Subject: [PATCH 1/2] chore: gitignore testing-coverage scratch directories --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b1abf9103..e81627835 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,9 @@ tx-builder-*.json **/subgraph-service-localNetwork/ !**/ignition/**/artifacts/ +# Temporary test working directories +**/testing-coverage/ + # Claude AI settings .claude/ From ddee12b111c4ac627c0c608294798c93e47ea3b7 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:57:38 +0000 Subject: [PATCH 2/2] test: improve horizon and subgraph-service test coverage PaymentsEscrow: - thaw() with evenIfTimerReset=false (skip, proceed, same-block) - thaw() no-op when requested equals current thawing - Constructor validation (PaymentsEscrowThawingPeriodTooLong) - cancelThaw() and thaw(,,, false) when paused - Withdraw boundary at exact thawEndTimestamp - Collect: entire balance, tokensThawing cap to zero resets thawEndTimestamp, PaymentsEscrowInconsistentCollection error - State isolation across different payer/collector/receiver tuples SubgraphService: - slash: positive cases (fuzzed tokens/reward), zero reward, reward exceeds max verifier cut - getters: getAllocationData (open + non-existent), getProvisionTokensRange, getThawingPeriodRange, getVerifierCutRange - governance: setMaxPOIStaleness, setPauseGuardian (add/remove/no-change), pause/unpause behavior and access control DisputeManager: - onlyPendingDispute branch tests for accept, draw, and cancel with non-pending disputes - Fix view annotation on query test helper --- .../contracts/payments/PaymentsEscrow.sol | 8 ++ .../horizon/test/unit/escrow/collect.t.sol | 131 ++++++++++++++++++ .../test/unit/escrow/constructor.t.sol | 49 +++++++ .../horizon/test/unit/escrow/isolation.t.sol | 76 ++++++++++ .../horizon/test/unit/escrow/paused.t.sol | 8 ++ .../horizon/test/unit/escrow/withdraw.t.sol | 12 +- .../disputeManager/disputes/disputes.t.sol | 58 ++++++++ .../test/unit/subgraphService/getters.t.sol | 58 ++++++++ .../governance/maxPOIStaleness.t.sol | 26 ++++ .../subgraphService/governance/pause.t.sol | 66 +++++++++ .../governance/pauseGuardian.t.sol | 49 +++++++ .../test/unit/subgraphService/slash.t.sol | 77 ++++++++++ 12 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 packages/horizon/test/unit/escrow/constructor.t.sol create mode 100644 packages/horizon/test/unit/escrow/isolation.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/governance/maxPOIStaleness.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/governance/pause.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/governance/pauseGuardian.t.sol diff --git a/packages/horizon/contracts/payments/PaymentsEscrow.sol b/packages/horizon/contracts/payments/PaymentsEscrow.sol index 2bc8ed966..6af296e42 100644 --- a/packages/horizon/contracts/payments/PaymentsEscrow.sol +++ b/packages/horizon/contracts/payments/PaymentsEscrow.sol @@ -140,6 +140,14 @@ contract PaymentsEscrow is Initializable, MulticallUpgradeable, GraphDirectory, // Reduce amount from account balance account.balance -= tokens; + // Cap tokensThawing to the new balance to keep state consistent + if (account.tokensThawing > account.balance) { + account.tokensThawing = account.balance; + if (account.tokensThawing == 0) { + account.thawEndTimestamp = 0; + } + } + uint256 escrowBalanceBefore = _graphToken().balanceOf(address(this)); _graphToken().approve(address(_graphPayments()), tokens); diff --git a/packages/horizon/test/unit/escrow/collect.t.sol b/packages/horizon/test/unit/escrow/collect.t.sol index 4713b9441..bbd35922c 100644 --- a/packages/horizon/test/unit/escrow/collect.t.sol +++ b/packages/horizon/test/unit/escrow/collect.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; import { GraphEscrowTest } from "./GraphEscrow.t.sol"; @@ -130,4 +131,134 @@ contract GraphEscrowCollectTest is GraphEscrowTest { users.indexer ); } + + function testCollect_EntireBalance(uint256 tokens) public useIndexer { + tokens = bound(tokens, 1, MAX_STAKING_TOKENS); + + resetPrank(users.gateway); + _depositTokens(users.verifier, users.indexer, tokens); + + // burn some tokens to prevent overflow + resetPrank(users.indexer); + token.burn(MAX_STAKING_TOKENS); + + resetPrank(users.verifier); + _collectEscrow( + IGraphPayments.PaymentTypes.QueryFee, + users.gateway, + users.indexer, + tokens, + subgraphDataServiceAddress, + 0, + users.indexer + ); + + // Balance should be zero + (uint256 balance, , ) = escrow.escrowAccounts(users.gateway, users.verifier, users.indexer); + assertEq(balance, 0); + } + + function testCollect_CapsTokensThawingToZero_ResetsThawEndTimestamp(uint256 tokens) public useIndexer { + // When collecting the entire balance while thawing, tokensThawing should cap to 0 + // and thawEndTimestamp should reset to 0 + tokens = bound(tokens, 1, MAX_STAKING_TOKENS); + + resetPrank(users.gateway); + _depositTokens(users.verifier, users.indexer, tokens); + escrow.thaw(users.verifier, users.indexer, tokens); + + // burn some tokens to prevent overflow + resetPrank(users.indexer); + token.burn(MAX_STAKING_TOKENS); + + // Collect entire balance + resetPrank(users.verifier); + _collectEscrow( + IGraphPayments.PaymentTypes.QueryFee, + users.gateway, + users.indexer, + tokens, + subgraphDataServiceAddress, + 0, + users.indexer + ); + + // tokensThawing and thawEndTimestamp should be reset + (uint256 balance, uint256 tokensThawingResult, uint256 thawEndTimestamp) = escrow.escrowAccounts( + users.gateway, + users.verifier, + users.indexer + ); + assertEq(balance, 0); + assertEq(tokensThawingResult, 0, "tokensThawing should be capped to 0"); + assertEq(thawEndTimestamp, 0, "thawEndTimestamp should reset when tokensThawing is 0"); + } + + function testCollect_CapsTokensThawingBelowBalance(uint256 depositAmount, uint256 collectAmount) public useIndexer { + // When collecting reduces balance below tokensThawing, tokensThawing should cap at balance + depositAmount = bound(depositAmount, 3, MAX_STAKING_TOKENS); + collectAmount = bound(collectAmount, 1, depositAmount - 1); + + resetPrank(users.gateway); + _depositTokens(users.verifier, users.indexer, depositAmount); + // Thaw entire balance + escrow.thaw(users.verifier, users.indexer, depositAmount); + + // burn some tokens to prevent overflow + resetPrank(users.indexer); + token.burn(MAX_STAKING_TOKENS); + + // Collect partial amount + resetPrank(users.verifier); + _collectEscrow( + IGraphPayments.PaymentTypes.QueryFee, + users.gateway, + users.indexer, + collectAmount, + subgraphDataServiceAddress, + 0, + users.indexer + ); + + (uint256 balance, uint256 tokensThawingResult, ) = escrow.escrowAccounts( + users.gateway, + users.verifier, + users.indexer + ); + uint256 remainingBalance = depositAmount - collectAmount; + assertEq(balance, remainingBalance); + assertEq(tokensThawingResult, remainingBalance, "tokensThawing should cap at remaining balance"); + } + + function testCollect_RevertWhen_InconsistentCollection(uint256 tokens) public useGateway { + tokens = bound(tokens, 1, MAX_STAKING_TOKENS); + _depositTokens(users.verifier, users.indexer, tokens); + + // Mock GraphPayments.collect to be a no-op: it succeeds but doesn't pull tokens, + // causing the escrow balance to remain unchanged and triggering the consistency check. + vm.mockCall(address(payments), abi.encodeWithSelector(IGraphPayments.collect.selector), abi.encode()); + + uint256 escrowBalance = token.balanceOf(address(escrow)); + + resetPrank(users.verifier); + vm.expectRevert( + abi.encodeWithSelector( + IPaymentsEscrow.PaymentsEscrowInconsistentCollection.selector, + escrowBalance, + escrowBalance, // balance unchanged because mock didn't pull tokens + tokens + ) + ); + escrow.collect( + IGraphPayments.PaymentTypes.QueryFee, + users.gateway, + users.indexer, + tokens, + subgraphDataServiceAddress, + 0, + users.indexer + ); + + vm.clearMockedCalls(); + } } diff --git a/packages/horizon/test/unit/escrow/constructor.t.sol b/packages/horizon/test/unit/escrow/constructor.t.sol new file mode 100644 index 000000000..c1b097010 --- /dev/null +++ b/packages/horizon/test/unit/escrow/constructor.t.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { Test } from "forge-std/Test.sol"; +import { Controller } from "@graphprotocol/contracts/contracts/governance/Controller.sol"; +import { PaymentsEscrow } from "contracts/payments/PaymentsEscrow.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; + +contract GraphEscrowConstructorTest is Test { + Controller public controller; + + function setUp() public { + controller = new Controller(); + + // GraphDirectory requires all proxy contracts to be registered + controller.setContractProxy(keccak256("GraphToken"), makeAddr("GraphToken")); + controller.setContractProxy(keccak256("Staking"), makeAddr("Staking")); + controller.setContractProxy(keccak256("GraphPayments"), makeAddr("GraphPayments")); + controller.setContractProxy(keccak256("PaymentsEscrow"), makeAddr("PaymentsEscrow")); + controller.setContractProxy(keccak256("EpochManager"), makeAddr("EpochManager")); + controller.setContractProxy(keccak256("RewardsManager"), makeAddr("RewardsManager")); + controller.setContractProxy(keccak256("GraphTokenGateway"), makeAddr("GraphTokenGateway")); + controller.setContractProxy(keccak256("GraphProxyAdmin"), makeAddr("GraphProxyAdmin")); + controller.setContractProxy(keccak256("Curation"), makeAddr("Curation")); + } + + function testConstructor_MaxWaitPeriodBoundary() public { + uint256 maxWaitPeriod = 90 days; + + // Exactly at MAX_WAIT_PERIOD should succeed + PaymentsEscrow escrowAtMax = new PaymentsEscrow(address(controller), maxWaitPeriod); + assertEq(escrowAtMax.WITHDRAW_ESCROW_THAWING_PERIOD(), maxWaitPeriod); + } + + function testConstructor_RevertWhen_ThawingPeriodTooLong() public { + uint256 maxWaitPeriod = 90 days; + uint256 tooLong = maxWaitPeriod + 1; + + vm.expectRevert( + abi.encodeWithSelector(IPaymentsEscrow.PaymentsEscrowThawingPeriodTooLong.selector, tooLong, maxWaitPeriod) + ); + new PaymentsEscrow(address(controller), tooLong); + } + + function testConstructor_ZeroThawingPeriod() public { + PaymentsEscrow escrowZero = new PaymentsEscrow(address(controller), 0); + assertEq(escrowZero.WITHDRAW_ESCROW_THAWING_PERIOD(), 0); + } +} diff --git a/packages/horizon/test/unit/escrow/isolation.t.sol b/packages/horizon/test/unit/escrow/isolation.t.sol new file mode 100644 index 000000000..552ec77b7 --- /dev/null +++ b/packages/horizon/test/unit/escrow/isolation.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { GraphEscrowTest } from "./GraphEscrow.t.sol"; + +contract GraphEscrowIsolationTest is GraphEscrowTest { + /* + * TESTS + */ + + function testIsolation_DifferentCollectorsSamePayerReceiver(uint256 amount) public useGateway { + amount = bound(amount, 1, MAX_STAKING_TOKENS / 2); + + address collector1 = users.verifier; + address collector2 = users.operator; + + _depositTokens(collector1, users.indexer, amount); + _depositTokens(collector2, users.indexer, amount * 2); + + (uint256 balance1, , ) = escrow.escrowAccounts(users.gateway, collector1, users.indexer); + (uint256 balance2, , ) = escrow.escrowAccounts(users.gateway, collector2, users.indexer); + + assertEq(balance1, amount); + assertEq(balance2, amount * 2); + } + + function testIsolation_DifferentReceiversSamePayerCollector(uint256 amount) public useGateway { + amount = bound(amount, 1, MAX_STAKING_TOKENS / 2); + + address receiver1 = users.indexer; + address receiver2 = users.delegator; + + _depositTokens(users.verifier, receiver1, amount); + _depositTokens(users.verifier, receiver2, amount * 2); + + (uint256 balance1, , ) = escrow.escrowAccounts(users.gateway, users.verifier, receiver1); + (uint256 balance2, , ) = escrow.escrowAccounts(users.gateway, users.verifier, receiver2); + + assertEq(balance1, amount); + assertEq(balance2, amount * 2); + } + + function testIsolation_ThawOneTupleDoesNotAffectAnother(uint256 amount) public useGateway { + amount = bound(amount, 2, MAX_STAKING_TOKENS / 2); + + _depositTokens(users.verifier, users.indexer, amount); + _depositTokens(users.verifier, users.delegator, amount); + + // Thaw only the first tuple + escrow.thaw(users.verifier, users.indexer, amount / 2); + + // Second tuple should be unaffected + (, uint256 tokensThawing2, uint256 thawEndTimestamp2) = escrow.escrowAccounts( + users.gateway, + users.verifier, + users.delegator + ); + assertEq(tokensThawing2, 0); + assertEq(thawEndTimestamp2, 0); + + // First tuple should have thawing + (, uint256 tokensThawing1, ) = escrow.escrowAccounts(users.gateway, users.verifier, users.indexer); + assertEq(tokensThawing1, amount / 2); + } + + function testIsolation_EscrowAccounts_NeverUsedAccount() public view { + (uint256 balance, uint256 tokensThawing, uint256 thawEndTimestamp) = escrow.escrowAccounts( + address(0xdead), + address(0xbeef), + address(0xface) + ); + assertEq(balance, 0); + assertEq(tokensThawing, 0); + assertEq(thawEndTimestamp, 0); + } +} diff --git a/packages/horizon/test/unit/escrow/paused.t.sol b/packages/horizon/test/unit/escrow/paused.t.sol index 2e9afe819..ea3fce631 100644 --- a/packages/horizon/test/unit/escrow/paused.t.sol +++ b/packages/horizon/test/unit/escrow/paused.t.sol @@ -42,6 +42,14 @@ contract GraphEscrowPausedTest is GraphEscrowTest { escrow.thaw(users.verifier, users.indexer, tokens); } + function testPaused_RevertWhen_CancelThaw( + uint256 tokens, + uint256 thawAmount + ) public useGateway depositAndThawTokens(tokens, thawAmount) usePaused(true) { + vm.expectRevert(abi.encodeWithSelector(IPaymentsEscrow.PaymentsEscrowIsPaused.selector)); + escrow.cancelThaw(users.verifier, users.indexer); + } + function testPaused_RevertWhen_WithdrawTokens( uint256 tokens, uint256 thawAmount diff --git a/packages/horizon/test/unit/escrow/withdraw.t.sol b/packages/horizon/test/unit/escrow/withdraw.t.sol index fa85f379b..bcc116fd1 100644 --- a/packages/horizon/test/unit/escrow/withdraw.t.sol +++ b/packages/horizon/test/unit/escrow/withdraw.t.sol @@ -39,13 +39,23 @@ contract GraphEscrowWithdrawTest is GraphEscrowTest { escrow.withdraw(users.verifier, users.indexer); } + function testWithdraw_SucceedsOneSecondAfterThawEnd( + uint256 amount, + uint256 thawAmount + ) public useGateway depositAndThawTokens(amount, thawAmount) { + // Advance time to exactly one second past thaw end + skip(WITHDRAW_ESCROW_THAWING_PERIOD + 1); + + _withdrawEscrow(users.verifier, users.indexer); + } + function testWithdraw_BalanceAfterCollect( uint256 amountDeposited, uint256 amountThawed, uint256 amountCollected ) public useGateway depositAndThawTokens(amountDeposited, amountThawed) { vm.assume(amountCollected > 0); - vm.assume(amountCollected <= amountDeposited); + vm.assume(amountCollected < amountDeposited); // burn some tokens to prevent overflow resetPrank(users.indexer); diff --git a/packages/subgraph-service/test/unit/disputeManager/disputes/disputes.t.sol b/packages/subgraph-service/test/unit/disputeManager/disputes/disputes.t.sol index c4d2f2825..5286a6610 100644 --- a/packages/subgraph-service/test/unit/disputeManager/disputes/disputes.t.sol +++ b/packages/subgraph-service/test/unit/disputeManager/disputes/disputes.t.sol @@ -69,6 +69,25 @@ contract DisputeManagerDisputeTest is DisputeManagerTest { disputeManager.cancelDispute(disputeId); } + function test_Dispute_Accept_RevertIf_DisputeNotPending(uint256 tokens) public useIndexer useAllocation(tokens) { + // Create and reject a dispute so it is no longer pending + resetPrank(users.fisherman); + // forge-lint: disable-next-line(unsafe-typecast) + bytes32 disputeId = _createIndexingDispute(allocationId, bytes32("POI1"), block.number); + + resetPrank(users.arbitrator); + disputeManager.rejectDispute(disputeId); + + // Attempt to accept the already-rejected dispute + vm.expectRevert( + abi.encodeWithSelector( + IDisputeManager.DisputeManagerDisputeNotPending.selector, + IDisputeManager.DisputeStatus.Rejected + ) + ); + disputeManager.acceptDispute(disputeId, 1); + } + function test_Dispute_Reject_RevertIf_DisputeNotPending(uint256 tokens) public useIndexer useAllocation(tokens) { // Create and accept a dispute so it is no longer pending resetPrank(users.fisherman); @@ -88,6 +107,45 @@ contract DisputeManagerDisputeTest is DisputeManagerTest { disputeManager.rejectDispute(disputeId); } + function test_Dispute_Draw_RevertIf_DisputeNotPending(uint256 tokens) public useIndexer useAllocation(tokens) { + // Create and accept a dispute so it is no longer pending + resetPrank(users.fisherman); + // forge-lint: disable-next-line(unsafe-typecast) + bytes32 disputeId = _createIndexingDispute(allocationId, bytes32("POI1"), block.number); + + resetPrank(users.arbitrator); + _acceptDispute(disputeId, 1); + + // Attempt to draw the already-accepted dispute + vm.expectRevert( + abi.encodeWithSelector( + IDisputeManager.DisputeManagerDisputeNotPending.selector, + IDisputeManager.DisputeStatus.Accepted + ) + ); + disputeManager.drawDispute(disputeId); + } + + function test_Dispute_Cancel_RevertIf_DisputeNotPending(uint256 tokens) public useIndexer useAllocation(tokens) { + // Create and accept a dispute so it is no longer pending + resetPrank(users.fisherman); + // forge-lint: disable-next-line(unsafe-typecast) + bytes32 disputeId = _createIndexingDispute(allocationId, bytes32("POI1"), block.number); + + resetPrank(users.arbitrator); + _acceptDispute(disputeId, 1); + + // Attempt to cancel the already-accepted dispute + resetPrank(users.fisherman); + vm.expectRevert( + abi.encodeWithSelector( + IDisputeManager.DisputeManagerDisputeNotPending.selector, + IDisputeManager.DisputeStatus.Accepted + ) + ); + disputeManager.cancelDispute(disputeId); + } + function test_Dispute_AreConflictingAttestations(uint256 tokens) public useIndexer useAllocation(tokens) { // forge-lint: disable-next-item(unsafe-typecast) IAttestation.State memory att1 = IAttestation.State({ diff --git a/packages/subgraph-service/test/unit/subgraphService/getters.t.sol b/packages/subgraph-service/test/unit/subgraphService/getters.t.sol index dd8c815bd..27c9aafbb 100644 --- a/packages/subgraph-service/test/unit/subgraphService/getters.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/getters.t.sol @@ -22,4 +22,62 @@ contract SubgraphServiceGettersTest is SubgraphServiceTest { address result = subgraphService.getCuration(); assertEq(result, address(curation)); } + + function test_GetAllocationData(uint256 tokens) public useIndexer useAllocation(tokens) { + ( + bool isOpen, + address indexer, + bytes32 subgraphDeploymentIdResult, + uint256 allocatedTokens, + uint256 accRewardsPerAllocatedToken, + uint256 accRewardsPending + ) = subgraphService.getAllocationData(allocationId); + + assertTrue(isOpen); + assertEq(indexer, users.indexer); + assertEq(subgraphDeploymentIdResult, subgraphDeployment); + assertEq(allocatedTokens, tokens); + assertEq(accRewardsPerAllocatedToken, 0); + assertEq(accRewardsPending, 0); + } + + function test_GetAllocationData_NonExistent() public view { + address nonExistent = address(0xdead); + ( + bool isOpen, + address indexer, + bytes32 subgraphDeploymentIdResult, + uint256 allocatedTokens, + uint256 accRewardsPerAllocatedToken, + uint256 accRewardsPending + ) = subgraphService.getAllocationData(nonExistent); + + assertFalse(isOpen); + assertEq(indexer, address(0)); + assertEq(subgraphDeploymentIdResult, bytes32(0)); + assertEq(allocatedTokens, 0); + assertEq(accRewardsPerAllocatedToken, 0); + assertEq(accRewardsPending, 0); + } + + function test_GetProvisionTokensRange() public view { + (uint256 min, uint256 max) = subgraphService.getProvisionTokensRange(); + assertEq(min, MINIMUM_PROVISION_TOKENS); + assertEq(max, type(uint256).max); + } + + function test_GetThawingPeriodRange() public view { + (uint64 min, uint64 max) = subgraphService.getThawingPeriodRange(); + uint64 expectedDisputePeriod = disputeManager.getDisputePeriod(); + assertEq(min, expectedDisputePeriod); + assertEq(max, expectedDisputePeriod); + } + + function test_GetVerifierCutRange() public view { + (uint32 min, uint32 max) = subgraphService.getVerifierCutRange(); + uint32 expectedFishermanRewardCut = disputeManager.getFishermanRewardCut(); + assertEq(min, expectedFishermanRewardCut); + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(max, uint32(MAX_PPM)); + } } diff --git a/packages/subgraph-service/test/unit/subgraphService/governance/maxPOIStaleness.t.sol b/packages/subgraph-service/test/unit/subgraphService/governance/maxPOIStaleness.t.sol new file mode 100644 index 000000000..5968cf623 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/governance/maxPOIStaleness.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; +import { SubgraphServiceTest } from "../SubgraphService.t.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +contract SubgraphServiceGovernanceMaxPOIStalenessTest is SubgraphServiceTest { + /* + * TESTS + */ + + function test_Governance_SetMaxPOIStaleness(uint256 maxPOIStaleness) public useGovernor { + vm.expectEmit(address(subgraphService)); + emit IAllocationManager.MaxPOIStalenessSet(maxPOIStaleness); + subgraphService.setMaxPOIStaleness(maxPOIStaleness); + + assertEq(subgraphService.maxPOIStaleness(), maxPOIStaleness); + } + + function test_Governance_SetMaxPOIStaleness_RevertWhen_NotGovernor() public useIndexer { + uint256 maxPOIStaleness = 14 days; + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, users.indexer)); + subgraphService.setMaxPOIStaleness(maxPOIStaleness); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/governance/pause.t.sol b/packages/subgraph-service/test/unit/subgraphService/governance/pause.t.sol new file mode 100644 index 000000000..99c9debb4 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/governance/pause.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { IDataServicePausable } from "@graphprotocol/interfaces/contracts/data-service/IDataServicePausable.sol"; +import { SubgraphServiceTest } from "../SubgraphService.t.sol"; + +contract SubgraphServiceGovernancePauseTest is SubgraphServiceTest { + /* + * TESTS + */ + + function test_Governance_Pause() public { + resetPrank(users.pauseGuardian); + + vm.expectEmit(address(subgraphService)); + emit PausableUpgradeable.Paused(users.pauseGuardian); + subgraphService.pause(); + + assertTrue(subgraphService.paused()); + } + + function test_Governance_Unpause() public { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + vm.expectEmit(address(subgraphService)); + emit PausableUpgradeable.Unpaused(users.pauseGuardian); + subgraphService.unpause(); + + assertFalse(subgraphService.paused()); + } + + function test_Governance_Pause_RevertWhen_NotPauseGuardian() public useIndexer { + vm.expectRevert( + abi.encodeWithSelector(IDataServicePausable.DataServicePausableNotPauseGuardian.selector, users.indexer) + ); + subgraphService.pause(); + } + + function test_Governance_Unpause_RevertWhen_NotPauseGuardian() public { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + resetPrank(users.indexer); + vm.expectRevert( + abi.encodeWithSelector(IDataServicePausable.DataServicePausableNotPauseGuardian.selector, users.indexer) + ); + subgraphService.unpause(); + } + + function test_Governance_Pause_RevertWhen_AlreadyPaused() public { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + vm.expectRevert(abi.encodeWithSelector(PausableUpgradeable.EnforcedPause.selector)); + subgraphService.pause(); + } + + function test_Governance_Unpause_RevertWhen_NotPaused() public { + resetPrank(users.pauseGuardian); + + vm.expectRevert(abi.encodeWithSelector(PausableUpgradeable.ExpectedPause.selector)); + subgraphService.unpause(); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/governance/pauseGuardian.t.sol b/packages/subgraph-service/test/unit/subgraphService/governance/pauseGuardian.t.sol new file mode 100644 index 000000000..5a53af510 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/governance/pauseGuardian.t.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import { IDataServicePausable } from "@graphprotocol/interfaces/contracts/data-service/IDataServicePausable.sol"; +import { SubgraphServiceTest } from "../SubgraphService.t.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +contract SubgraphServiceGovernancePauseGuardianTest is SubgraphServiceTest { + /* + * TESTS + */ + + function test_Governance_SetPauseGuardian() public useGovernor { + // users.pauseGuardian is already set in setUp, use a new address + address newGuardian = createUser("newPauseGuardian"); + + vm.expectEmit(address(subgraphService)); + emit IDataServicePausable.PauseGuardianSet(newGuardian, true); + subgraphService.setPauseGuardian(newGuardian, true); + + assertTrue(subgraphService.pauseGuardians(newGuardian)); + } + + function test_Governance_SetPauseGuardian_Remove() public useGovernor { + // users.pauseGuardian is already set to true in setUp + vm.expectEmit(address(subgraphService)); + emit IDataServicePausable.PauseGuardianSet(users.pauseGuardian, false); + subgraphService.setPauseGuardian(users.pauseGuardian, false); + + assertFalse(subgraphService.pauseGuardians(users.pauseGuardian)); + } + + function test_Governance_SetPauseGuardian_RevertWhen_NoChange() public useGovernor { + // users.pauseGuardian is already set to true in setUp + vm.expectRevert( + abi.encodeWithSelector( + IDataServicePausable.DataServicePausablePauseGuardianNoChange.selector, + users.pauseGuardian, + true + ) + ); + subgraphService.setPauseGuardian(users.pauseGuardian, true); + } + + function test_Governance_SetPauseGuardian_RevertWhen_NotGovernor() public useIndexer { + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, users.indexer)); + subgraphService.setPauseGuardian(users.pauseGuardian, true); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/slash.t.sol b/packages/subgraph-service/test/unit/subgraphService/slash.t.sol index a8b7bd65c..4a69bf347 100644 --- a/packages/subgraph-service/test/unit/subgraphService/slash.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/slash.t.sol @@ -1,14 +1,91 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; +import { IDataService } from "@graphprotocol/interfaces/contracts/data-service/IDataService.sol"; +import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; +import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; + import { Directory } from "../../../contracts/utilities/Directory.sol"; import { SubgraphServiceTest } from "./SubgraphService.t.sol"; contract SubgraphServiceSlashTest is SubgraphServiceTest { + using PPMMath for uint256; + /* * TESTS */ + function test_SubgraphService_Slash( + uint256 tokens, + uint256 tokensSlash, + uint256 tokensReward + ) public useIndexer useAllocation(tokens) { + IHorizonStakingTypes.Provision memory provision = staking.getProvision(users.indexer, address(subgraphService)); + tokensSlash = bound(tokensSlash, 1, provision.tokens); + uint256 maxVerifierTokens = tokensSlash.mulPPM(provision.maxVerifierCut); + tokensReward = bound(tokensReward, 0, maxVerifierTokens); + + bytes memory data = abi.encode(tokensSlash, tokensReward); + + vm.expectEmit(address(subgraphService)); + emit IDataService.ServiceProviderSlashed(users.indexer, tokensSlash); + + resetPrank(address(disputeManager)); + subgraphService.slash(users.indexer, data); + + IHorizonStakingTypes.Provision memory provisionAfter = staking.getProvision( + users.indexer, + address(subgraphService) + ); + assertEq(provisionAfter.tokens, provision.tokens - tokensSlash); + } + + function test_SubgraphService_Slash_ZeroReward( + uint256 tokens, + uint256 tokensSlash + ) public useIndexer useAllocation(tokens) { + IHorizonStakingTypes.Provision memory provision = staking.getProvision(users.indexer, address(subgraphService)); + tokensSlash = bound(tokensSlash, 1, provision.tokens); + + bytes memory data = abi.encode(tokensSlash, uint256(0)); + + vm.expectEmit(address(subgraphService)); + emit IDataService.ServiceProviderSlashed(users.indexer, tokensSlash); + + resetPrank(address(disputeManager)); + subgraphService.slash(users.indexer, data); + + IHorizonStakingTypes.Provision memory provisionAfter = staking.getProvision( + users.indexer, + address(subgraphService) + ); + assertEq(provisionAfter.tokens, provision.tokens - tokensSlash); + } + + function test_SubgraphService_Slash_RevertWhen_RewardExceedsMax( + uint256 tokens, + uint256 tokensSlash, + uint256 tokensReward + ) public useIndexer useAllocation(tokens) { + IHorizonStakingTypes.Provision memory provision = staking.getProvision(users.indexer, address(subgraphService)); + tokensSlash = bound(tokensSlash, 1, provision.tokens); + uint256 maxVerifierTokens = tokensSlash.mulPPM(provision.maxVerifierCut); + tokensReward = bound(tokensReward, maxVerifierTokens + 1, type(uint256).max); + + bytes memory data = abi.encode(tokensSlash, tokensReward); + + resetPrank(address(disputeManager)); + vm.expectRevert( + abi.encodeWithSelector( + IHorizonStakingMain.HorizonStakingTooManyTokens.selector, + tokensReward, + maxVerifierTokens + ) + ); + subgraphService.slash(users.indexer, data); + } + function test_SubgraphService_Slash_RevertWhen_NotDisputeManager( uint256 tokens ) public useIndexer useAllocation(tokens) {