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
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.
- Asset / track: The Graph - Smart Contracts
- Affected component:
DisputeManager(createIndexingDispute/_createIndexingDisputeWithAllocation) - In-scope asset: DisputeManager - Arbitrum One (
0x2FE023a575449AcB698648eD21276293Fa176f96) - Severity: High
- Version: present in
main(HEAD687d928b, 2026-08-03); introduced when theblockNumberparameter was added to fix "repeated same-POI disputes" (commit97dceb13, 2025-06-05, PR #1183 follow-up)
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.
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.
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).
- A bad indexer presents a POI they know is wrong (they control what POI they submit; POIs are never verified on-chain).
- They compute the dispute ID for their own fault:
keccak256(abi.encodePacked(allocationId, poi, blockNumber)). They knowallocationIdandpoi(they submitted it), and they pick anyblockNumber. - 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. - A legitimate fisherman who saw the bad POI calls
createIndexingDispute()on the same fault. It reverts withDisputeManagerDisputeAlreadyCreated- permanently. - After 28 days the indexer calls
cancelDispute()and gets the deposit back. The ID stays consumed forever. - 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.
- An attacker (fisherman) observes a real POI presented by an indexer.
- They open 10 disputes on the same
(allocationId, poi)at 10 fabricatedblockNumbers. - Each dispute is a separate
Disputeentry with its ownstakeSnapshotand can be accepted independently, slashing up tomaxSlashingCut(10%) each. - 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.
- 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
maxSlashingCutper 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 bothentitiesandblockNumberare 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.
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
}
}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-createIndexingDisputeaccepts callerblockNumber.DisputeManager.sol:433-483-_createIndexingDisputeWithAllocation: dispute ID =keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber)), no validation.DisputeManager.sol:354-356-isDisputeCreatedtrue for any status, incl. Cancelled.DisputeManager.sol:269-279and597-602-cancelDisputerefunds 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-_slashIndexerslashes up tomaxSlashingCutper dispute.
- 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.
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.
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.
Bind the dispute to something the contract can verify on-chain. Two options:
-
Record
(allocationId, poi, blockNumber)when a POI is presented (inAllocationHandler.presentPOI), and havecreateIndexingDisputerequire 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. -
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-
blockNumberdouble-slash by itself, so option 1 (or an on-chain POI-presentation registry) is the stronger fix.
- 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), theblockNumber-based dispute ID. - Prior context: OZ "Missed Issues" PR #1183 review (added
blockNumberto 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 testtest_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).