Skip to content

Instantly share code, notes, and snippets.

@CharaD7
Last active September 2, 2026 18:50
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 proxy 0x2FE023a5 -> impl 0x40b17388), deployed == main 1c9eefde (keccak@441). PoC + write-up + live/pre-upgrade impl source + novelty proofs.
// 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;
}
}

Live implementation + novelty proofs (2026-09-02)

This gist accompanies the Immunefi submission. The PoC source and the full write-up are in the sibling files. The older deployed_DisputeManager_arbitrum_PREUPGRADE_impl_...sol file here is the JANUARY-2026 implementation (see below) kept for reference; it is NOT what the proxy currently points to.

Live on-chain facts (Arbitrum One, re-verified 2026-09-02, block 500987761)

In-scope asset (program scope): DisputeManager - Arbitrum One, proxy 0x2FE023a575449AcB698648eD21276293Fa176f96 (arbiscan link on the Immunefi asset table).

EIP-1967 implementation slot (0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc):

cast storage 0x2FE023a575449AcB698648eD21276293Fa176f96 <slot> --rpc-url https://arb1.arbitrum.io/rpc
-> 0x00000000000000000000000040b17388b078d24ccc6bd3d3d64e475a7b0383fb

Live implementation: 0x40b17388b078d24ccc6bd3d3d64e475a7b0383fb

  • Sourcify full match: https://sourcify.dev/server/v2/contract/42161/0x40b17388b078d24ccc6bd3d3d64e475a7b0383fb (verified 2026-07-23, solc 0.8.27)
  • Its contracts/DisputeManager.sol is byte-for-byte identical (whitespace/comment-insensitive) to repo main (origin/main 1c9eefde) - vulnerable line at 441 in both: bytes32 disputeId = keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber));
  • 3-arg createIndexingDispute(address,bytes32,uint256) selector 0x417c10fd present in runtime bytecode. => deployed == GitHub (the eligibility gate).

The pre-upgrade implementation (kept for reference)

0x0fa6925f21d0493072ad29f3aF66f4E11655faF1 - Sourcify exact_match verified 2026-01-24, solc 0.8.27. Its source is the deployed_DisputeManager_arbitrum_PREUPGRADE_impl_...sol file in this gist (vulnerable keccak at line 461). Also vulnerable, but the proxy no longer points to it; treat the live impl (above) as the authoritative "deployed" code.

Repo version

origin/main 1c9eefde (2026-09-01). git log origin/main --since 2026-09-01 -- packages/subgraph-service/contracts/DisputeManager.sol is empty -> no fix. Vulnerable line still at origin/main:441.

Public novelty (no fix / no disclosure found)

Performed 2026-09-02 as gh user CharaD7 (public read-only only; Immunefi's private DB is not visible): zero PRs/issues updated since 2026-09-02; historical DisputeManager PRs (#347, #766, #1075, #1183) are unrelated (see submission appendix); the only fix-pattern code searches (..._blockNumber, _fisherman) and abi.encode(...) return nothing anywhere; the vulnerable expression appears only in graphprotocol/contracts and three unpatched mirror copies; the only published GHSA for the contracts repo are the 2026 vesting, 2024 staking-thawing (GHSA-7477) and 2024 curation-tax (GHSA-p4j4) advisories - none related. Re-scan immediately before submission.

Program out-of-scope notes (defensive)

  • "Known issues previously reported in security audits are out of scope": nearest is OZ Horizon L-09 "Double Jeopardy" (2025-05) - predates the 97dceb13 blockNumber change, is about the arbitration charter, and does not describe the self-dispute->cancel->permanent-shield consequence. Addressed in the submission.
  • "Frontrunning, including back running and sandwich attacks": this finding is an irreversible design flaw (dispute ID excludes the fisherman; (allocationId, poi, blockNumber) is never recorded/verified on-chain), exploitable with zero mempool racing by an indexer that presents
    • self-disputes atomically in one block it controls. Addressed in the submission.
// 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)

Note before anything else: I verified every fact in this report on-chain and against the repo, not just by reading source. I cannot see Immunefi's private submissions database, so when I say "novel" I mean "no public issue, PR, advisory, CVE, or fork that I could find", and I re-ran that public scan immediately before writing this (proof in the appendix). All on-chain calls below are reproducible with cast against a public Arbitrum One RPC. The full PoC is pasted inline in this report, per the program's PoC rule. The same evidence is mirrored in my gist: https://gist.github.com/CharaD7/a38ff10ebbe4b4844664b01c8b314cae

How to categorize in the submission form

  • Asset / track: The Graph - Smart Contracts
  • Affected component: DisputeManager (createIndexingDispute / _createIndexingDisputeWithAllocation)
  • In-scope asset: DisputeManager - Arbitrum One (proxy 0x2FE023a575449AcB698648eD21276293Fa176f96, the exact arbiscan asset on the program scope)
  • Severity: High (see "Claimed in-scope impact" for the honest mapping)
  • Version: present in main at origin/main 1c9eefde (2026-09-01). DisputeManager.sol is unchanged since at least 2026-08-03. Introduced by commit 97dceb13 (2025-06-05, the PR #1183 Missed-Issues follow-up) which added the blockNumber field to the indexing dispute ID.

Claimed in-scope impact (be explicit)

The program's in-scope Smart Contract impacts are:

  • CRITICAL: > $1M user funds lost or stolen directly from protocol smart contracts (not including slashing)
  • HIGH: private information being stolen
  • HIGH: economic attack (other than basic 51% governance) causing > $1M user funds lost or stolen directly from the protocol smart contracts
  • HIGH: network participants impersonated and unwanted actions taken (e.g. user funds stolen directly from the protocol)

What I am claiming:

  • The indexing-dispute mechanism is the protocol's only on-chain penalty for bad indexing. Attack A lets a determined indexer disable it permanently for any fault they commit: they consume the dispute ID for their own fault, wait out the 28-day period, cancel to recover their 10,000 GRT deposit, and the ID stays consumed forever, so no fisherman can ever dispute or slash that fault. The indexer keeps collecting indexing rewards and indexing fees on fraudulent service indefinitely, with no slashing risk. This removes the economic deterrent that keeps the indexing network honest. I believe this is the closest fit to the "economic attack" HIGH row as a security-mechanism bypass, even though it is not a single direct > $1M theft.

Honest ceiling (I disclose this so the team can classify on the merits):

  • "Slashing" is excluded from the CRITICAL row, and the economic-attack HIGH row is framed as "user funds lost or stolen directly from the protocol". This finding is a slashing-bypass, which is the inverse of slashing, so it does not cleanly map to a listed row.
  • I checked on-chain that HorizonStaking.isDelegationSlashingEnabled() returns false today on Arbitrum One (0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03), so delegator capital is not slashable - only indexer self-stake. Attack B (over-slash via fabricated block numbers) therefore moves indexer self-stake only, and it depends on arbitrators not applying GIP-0085 clause 10a/17, so I present B as secondary, not as the primary severity driver.
  • I am therefore asking The Graph team to classify this per their impact tables. If it lands below High, I ask that it be recorded as an acknowledged limitation with a fix (bind the fisherman, or record the POI-presentation block) rather than closed silently. There is no public disclosure of this anywhere that I could find.

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 keeps the indexing network honest.

The dispute ID 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. 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 dispute their OWN fault (self-dispute), wait out the 28-day dispute period, and cancel to recover their 10,000 GRT deposit. The dispute ID stays consumed forever (Cancelled is not Null), so no one can ever dispute that fault again. Slashing is permanently bypassed for that fault, and the indexer repeats this per fault. I verified this on a local fork with the real contracts; the PoC passes (included inline).

Secondary - one fault, many fabricated block numbers. The same fault can be disputed at N different caller-supplied block numbers, each with its own stake snapshot and up to maxSlashingCut (10% live) of penalty. I disclose this as secondary because the Horizon Arbitration Charter (GIP-0085 clause 10a) tells arbitrators to resolve a dispute whose blockNumber does not match the on-chain POI submission as a Draw, and clause 17 lets arbitrators reject or slash the bond of dispute-spamming fishermen. So vector B is mitigated off-chain by governance; I include it for completeness and do not rely on it for severity.

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));

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 tell 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() (DisputeManager.sol:354-356) returns true for any status including Cancelled. cancelDispute() (DisputeManager.sol:269+) only flips the status to Cancelled and refunds the deposit (_cancelDispute at 597+); it does not free the ID.

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.

The same pattern is reused in _createIndexingFeeDisputeV1 (DisputeManager.sol:495-552, keccak at 517) with (agreementId, poi, entities, blockNumber). entities and blockNumber are unverifiable caller inputs there too, and the fee path is worse: the canonical (agreementId, poi, blockNumber) the indexer actually collected on IS recorded in the agreement (the IndexingAgreement stores poiBlockNumber), so the indexer can preempt the exact tuple a compliant fisherman would use.

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 = 2419200 seconds (28 days).

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

Key premise: a POI presentation is NOT stored on-chain. presentPOI (AllocationHandler.sol:322; Allocation.sol:70-74) only sets lastPOIPresentedAt = block.timestamp and emits a POIPresented(indexer, allocationId, subgraphDeploymentId, poi, poiMetadata, condition) event. There is no contract record of (allocationId -> poi -> block). So the only "truth" a dispute can reference is the block in which the POI was presented, and per GIP-0085 clause 10a a dispute whose blockNumber does not match the on-chain POI submission is resolved as a Draw. That means for a given fault there is exactly ONE tuple a compliant fisherman can win with: the real presentation block.

  1. A bad indexer presents a POI they know is wrong (they control what POI they submit; the POI value is never verified on-chain).
  2. The indexer knows the winning tuple (allocationId, poi, blockNumber) exactly - they signed the presentation tx, so they know its block - and they can atomically bundle the presentation and a self-dispute into the same block (presentPOI at block X, then createIndexingDispute(allocationId, poi, X)), consuming the only winnable dispute ID before any third-party watcher can react. There is no mempool racing required here; the indexer controls the submission tx.
  3. A legitimate fisherman who saw the bad POI calls createIndexingDispute() on that fault with the real presentation block (the only one that wins per clause 10a). It reverts with DisputeManagerDisputeAlreadyCreated - permanently.
  4. After 28 days the indexer calls cancelDispute() and gets the deposit back. The ID stays consumed forever (status Cancelled is not Null; isDisputeCreated stays true).
  5. Result: that fault can never be disputed or slashed. The indexer repeats this for every fault (each POI posting is a new tuple it controls), 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.

The PoC below demonstrates the on-chain state transition (self-dispute, then cancel, then permanent DisputeManagerDisputeAlreadyCreated for the fisherman). The block-convergence argument above is what makes the shield total rather than a coincidence of the test's shared faultBlock.

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 N disputes on the same (allocationId, poi) at N 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. Nothing in the contract caps the number of concurrent disputes on one fault or the aggregate slash.
  4. Caveat: GIP-0085 clause 10a instructs arbitrators to rule a dispute whose blockNumber does not match the on-chain POI submission as a Draw, and clause 17 allows arbitrators to reject or 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, not for severity.

Impact

  • The indexing-dispute mechanism is the protocol's only on-chain penalty for bad indexing. Attack A disables it entirely for a determined indexer: present bad POIs, collect indexing rewards and indexing fees, never be slashed. That removes the economic incentive to index correctly. This is the defensible finding.
  • Attack B lets a fisherman multiply the penalty for a single fault up to maxSlashingCut per fabricated block number, but the governance charter 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.

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 this is ordinary frontrunning or MEV (The Graph lists frontrunning out of scope). This is not a transient ordering advantage: it is an irreversible design flaw - the indexing-dispute ID excludes the fisherman, and the (allocationId, poi, blockNumber) tuple is never recorded or verified on-chain - that lets a participant permanently disable the protocol's only slashing backstop for a fault (cancel does not free the ID). Even with zero mempool racing, an indexer that presents a POI and self-disputes it atomically in the same block it controls consumes the only tuple a compliant fisherman can win with.
  • I am not claiming Attack B is unmitigated: GIP-0085 clauses 10a and 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.

Proof of Concept (inline, per the program PoC rule)

The PoC is PoC_DisputePreemption.t.sol. It extends the repo's own test harness (packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol, which inherits SubgraphServiceSharedTest), so it needs no external downloads - it runs inside the repo on the real DisputeManager, SubgraphService, HorizonStaking, and GRT token. Full source:

// 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 two tests cover the two on-chain claims:

  1. test_Indexer_Shields_Fault_From_Legitimate_Fisherman - proves the permanent shield:
    • indexer self-disputes its own fault (tuple consumed),
    • a legitimate fisherman's identical dispute reverts with DisputeManagerDisputeAlreadyCreated,
    • after the dispute period the indexer cancels and recovers the deposit,
    • the fisherman's dispute STILL reverts after the cancel: the ID is consumed forever.
  2. test_Single_Fault_Disputable_At_Arbitrary_BlockNumbers - proves blockNumber is a free caller input: one fault is disputable at blockNumbers 1..5, all five succeed, each a separate slashable Dispute.

How to run (in the repo, from packages/subgraph-service):

# place the file at test/unit/disputeManager/PoC_DisputePreemption.t.sol
forge test --match-contract PoC_DisputePreemption -vvv

Fresh output, re-run 2026-09-02 immediately before submission on code byte-identical to origin/main 1c9eefde (git diff origin/main on DisputeManager.sol and the harness DisputeManager.t.sol is empty; vulnerable line still at 441):

$ forge test --match-contract PoC_DisputePreemption -vvv
[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; finished in 78.10ms (44.70ms CPU time)

Ran 1 test suite in 103.00ms (78.10ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

On-chain and deployed-versus-main verification (the eligibility gate)

The in-scope asset is the DisputeManager proxy on Arbitrum One. Proof that the deployed contract equals the in-scope GitHub file and still carries the bug (re-run 2026-09-02):

$ cast block-number --rpc-url https://arb1.arbitrum.io/rpc
500987761

# EIP-1967 implementation slot of the in-scope proxy
$ cast storage 0x2FE023a575449AcB698648eD21276293Fa176f96 \
    0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \
    --rpc-url https://arb1.arbitrum.io/rpc
0x00000000000000000000000040b17388b078d24ccc6bd3d3d64e475a7b0383fb

# 3-arg createIndexingDispute(address,bytes32,uint256) selector present in runtime code
$ cast code 0x40b17388b078d24ccc6bd3d3d64e475a7b0383fb --rpc-url <arb> | grep -c 417c10fd
1

The live implementation behind the proxy is 0x40b17388b078d24ccc6bd3d3d64e475a7b0383fb. Sourcify returns a full match (verified 2026-07-23, solc 0.8.27): https://sourcify.dev/server/v2/contract/42161/0x40b17388b078d24ccc6bd3d3d64e475a7b0383fb

I compared the Sourcify source of that implementation (contracts/DisputeManager.sol) with repo main. They are byte-for-byte identical once whitespace and comments are ignored. The vulnerable line is at 441 in both:

$ git grep -n "keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber))" origin/main -- packages/subgraph-service/contracts/DisputeManager.sol
origin/main:packages/subgraph-service/contracts/DisputeManager.sol:441:
        bytes32 disputeId = keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber));

The repo's ignition deployment file also lists an earlier implementation, 0x0fa6925f21d0493072ad29f3aF66f4E11655faF1 (Sourcify exact_match 2026-01-24), whose source is the local file deployed_DisputeManager_arbitrum_PREUPGRADE_impl_0x0fa6925f21d0_2026-01-24.sol in my gist (same vulnerable keccak at line 461). It is also vulnerable, but it is the pre-upgrade implementation; the proxy currently points to 0x40b17388 above. I cite the live implementation as the authoritative deployed code.

Key code references (line numbers verified against both repo main and the live implementation source):

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

Novelty

I searched graphprotocol/contracts, graphprotocol/graph-node, 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 that covers the preemption (slashing-bypass) chain in Attack A - the self-dispute-then-cancel permanent shield is not described anywhere public that I could find.

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, at packages/subgraph-service/test/unit/disputeManager/disputes/indexing/create.t.sol:89) 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. Two things matter here: (a) L-09 predates the 97dceb13 change that added blockNumber and is framed as an arbitration-charter documentation gap, and (b) neither the audit nor the test considers the indexer self-disputing to permanently consume the tuple. My finding is that consequence.
  • GIP-0085 Horizon Arbitration Charter (approved 2025-10-20), clause 10a, 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; clause 17 lets arbitrators punish dispute-spamming fishermen. So Attack B's fabricated-blockNumber vector is anticipated and off-chain mitigated - I disclose this rather than claim it as novel. The same clause is what makes Attack A total (only the real presentation block wins, and the indexer knows it).
  • 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 that I found.
  • Issue #506 (closed, GIP) "Validate the POI is from the canonical chain and close to chain head" - the team already knows POI freshness and 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.

I cannot see Immunefi's private submissions database, so I cannot rule out a prior private report. Publicly, I found no prior art.

Version eligibility

Present in main at origin/main 1c9eefde (2026-09-01) and in the deployed Arbitrum One implementation behind the in-scope proxy (EIP-1967 slot -> 0x40b17388b078d24ccc6bd3d3d64e475a7b0383fb, Sourcify verified 2026-07-23, solc 0.8.27); the deployed source is byte-identical to repo main, with the same vulnerable 3-arg createIndexingDispute and the same keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber)) dispute ID at line 441. The blockNumber parameter was introduced by commit 97dceb13 (2025-06-05) to allow one dispute per POI posting (the PR #1183 Missed-Issues review). The flaw is live on the deployed contract and in the in-scope GitHub file.

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, which today only stores lastPOIPresentedAt) 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 (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_DisputePreemption.t.sol (included inline above; 2 passing tests).
  • Introducing change: commit 97dceb13 (2025-06-05), the blockNumber-based dispute ID.
  • Prior context: OZ Horizon audit 2025-05, finding L-09 "Double Jeopardy" (documents the no-fisherman indexing dispute ID and the query-dispute contrast); OZ "Missed Issues" PR #1183 review (added blockNumber); 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 clauses 10a (draw for blockNumber mismatch), 17 (punish dispute-spamming fishermen), 10b (per-epoch slashing cap), 16 (cancellation); GIP-0068 (cancel-after-period + flexible slashing).
  • Live on-chain facts: Allocation.sol:70-74 presentPOI records only lastPOIPresentedAt (a timestamp); the POIPresented event at AllocationHandler.sol presentPOI (entry at line 322) is the only place the POI plus block appear; HorizonStaking.isDelegationSlashingEnabled() returns false on-chain on Arbitrum One (0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03).
  • Deployed: Arbitrum One DisputeManager proxy 0x2FE023a575449AcB698648eD21276293Fa176f96 -> impl 0x40b17388b078d24ccc6bd3d3d64e475a7b0383fb (EIP-1967 slot, Sourcify verified 2026-07-23, solc 0.8.27; source == repo main byte-for-byte). Pre-upgrade impl 0x0fa6925f21d0493072ad29f3aF66f4E11655faF1 (Sourcify exact_match 2026-01-24) is also vulnerable (gist file deployed_DisputeManager_arbitrum_PREUPGRADE_impl_0x0fa6925f21d0_2026-01-24.sol).
  • Live config: packages/subgraph-service/ignition/configs/migrate.arbitrumOne.json5 (maxSlashingCut 10%, fishermanRewardCut 50%, disputeDeposit 10k GRT, period 28 days).
  • Repo main: 1c9eefde (2026-09-01); DisputeManager.sol unchanged since 2026-08-03.

Appendix: public novelty re-scan, proof of execution (2026-09-02)

I re-ran the public novelty checks immediately before writing this report, as the gh user CharaD7 (public read-only calls only). All commands are reproducible.

  1. Repo history:
$ git fetch origin --prune
$ git rev-parse --short origin/main
1c9eefde
$ git log --oneline origin/main --since="2026-09-01" -- packages/subgraph-service/contracts/DisputeManager.sol
(empty)                 # no commit touches DisputeManager.sol
$ git log --oneline origin/main --since="2026-09-02"
(empty)                 # no commit of any kind since 2026-09-02
$ git branch -a         # only main and origin/main; no fix branch

Vulnerable line still present at origin/main:441.

  1. PR and issue scan (gh api on graphprotocol/contracts, sorted by updated):
  • No PR and no issue updated since 2026-09-02.
  • Historical DisputeManager PRs reviewed and found unrelated: #347 (2021, L1) minimum-stake slash bypass; #766 (2023, L1, GIP-0041) keeps accepted-dispute IDs; #1075 (2024) TRST audit fixes; #1183 (2025) Missed-Issues review that added blockNumber. Recent issues #1360 (SubgraphService collect OOG, different subsystem) and #1355 (AegisVale-001 inline assembly, different) are unrelated.
  1. Code and fork scan (gh search code, global):
  • Zero results for any fix pattern: abi.encodePacked(_allocationId, _poi, _blockNumber, _fisherman) and abi.encode(_allocationId, _poi, _blockNumber).
  • The vulnerable expression appears only in graphprotocol/contracts (source and its own test expectations) and in four unpatched mirror copies of the source (Jramone3/Audit-The-Graph-Inflation-Critical, nightswatchhq/compass, codertjay/Web3ProjectFinder3-1, marjon-call/graph-security-review - the last is a vendored-source audit template with no DisputeManager finding document). No fork has fixed the file.
  1. Advisory and CVE scan (GitHub security-advisories):
  • graphprotocol/contracts published advisories: GHSA-qx35-rc5x-x39r (2026-03, revocable vesting), GHSA-7477-q5g6-cj48 (2024, L1 staking thawing rounding), GHSA-p4j4-4h8c-rrc6 (2024, L1 curation tax). None relate to DisputeManager dispute IDs.
  • graphprotocol/graph-node and edgeandnode/graph-disputes: zero published advisories.
  • No issue or PR in graph-node, graph-network-subgraph, or graph-disputes describes an indexer self-dispute, dispute-ID preemption, or permanent slashing shield.
  1. On-chain re-verification (block 500987761):
  • EIP-1967 implementation slot of the in-scope proxy still reads 0x40b17388b078d24ccc6bd3d3d64e475a7b0383fb.
  • Runtime bytecode of that implementation still contains the 3-arg createIndexingDispute(address,bytes32,uint256) selector 0x417c10fd.
  • Sourcify source of that implementation is still byte-identical to repo main (vulnerable keccak at line 441).
  1. Program out-of-scope re-read:
  • "Known issues previously reported in security audits are out of scope." The nearest public audit item is OZ L-09 "Double Jeopardy", which I address above: it predates the 97dceb13 change, is framed as an arbitration-charter documentation gap, and does not describe the self-dispute-then-cancel permanent shield.
  • "Frontrunning, including back running and sandwich attacks." I do not rely on mempool frontrunning for severity: the finding is an irreversible design flaw, and an indexer can present and self-dispute atomically in one block it controls with no racing at all.

Verdict: as of 2026-09-02 (blocks 500984397 and 500987761, origin/main 1c9eefde), I found no public issue, PR, advisory, CVE, fork, or code change that reports or fixes the indexing-dispute ID preemption / permanent slashing shield described in this report. Public novelty is time-sensitive, so I will re-run this exact checklist immediately before I submit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment