From ebdd491d0814449aff07d0e93dbad7cf74e12209 Mon Sep 17 00:00:00 2001 From: Liang Xu <24490353+lx3133584@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:10:25 +0800 Subject: [PATCH] store: refuse a proof of indexing for a pruned block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_proof_of_indexing only compared the requested block to the deployment's head. A block below earliest_block_number has had its causality-region digests removed by pruning, so the query at that block returns an incomplete set and ProofOfIndexingFinisher hashes it into a well-formed but wrong POI. Nothing downstream can tell that apart from a correct one — not the operator's other nodes, since they prune the same blocks and agree. Return an error instead, and say which block is the earliest still available. get_public_proof_of_indexing goes through the same path, so it inherits this. A deployment with indexerHints.prune: auto keeps GRAPH_MIN_HISTORY_BLOCKS (default 500, about 100 minutes on mainnet) of history. An allocation that closes after its epoch's start block has been pruned was submitting that wrong value, which has happened in production and been disputed. Signed-off-by: Liang Xu <24490353+lx3133584@users.noreply.github.com> --- store/postgres/src/deployment_store.rs | 14 ++++ store/test-store/tests/postgres.rs | 1 + store/test-store/tests/postgres/pruned_poi.rs | 75 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 store/test-store/tests/postgres/pruned_poi.rs diff --git a/store/postgres/src/deployment_store.rs b/store/postgres/src/deployment_store.rs index d764d451ad6..9162f5ffc9e 100644 --- a/store/postgres/src/deployment_store.rs +++ b/store/postgres/src/deployment_store.rs @@ -993,6 +993,20 @@ impl DeploymentStore { // Unfortunately the machinery needed to do this is not yet in place. // The best we can do right now is just to make sure that the block number // is high enough. + let state = deployment::state(&mut conn, site.as_ref()).await?; + if block.number < state.earliest_block_number { + // Pruning removed the causality-region digests this block's POI is + // computed from. Querying anyway returns a well-formed but wrong POI + // that nobody can tell apart from a correct one, which is worse than + // failing: an allocation closing against a pruned epoch block would + // submit it and could be disputed. + return Err(StoreError::Unknown(anyhow!( + "block {} has been pruned for deployment {}; the earliest available block is {}", + block.number, + site.deployment, + state.earliest_block_number + ))); + } if latest_block_ptr.number < block.number { // If a subgraph has failed deterministically then any blocks past head // should return the same POI diff --git a/store/test-store/tests/postgres.rs b/store/test-store/tests/postgres.rs index 1d5a16ba851..d1be7b8fa53 100644 --- a/store/test-store/tests/postgres.rs +++ b/store/test-store/tests/postgres.rs @@ -2,6 +2,7 @@ pub mod postgres { pub mod aggregation; pub mod chain_head; pub mod graft; + pub mod pruned_poi; pub mod relational; pub mod relational_bytes; pub mod store; diff --git a/store/test-store/tests/postgres/pruned_poi.rs b/store/test-store/tests/postgres/pruned_poi.rs new file mode 100644 index 00000000000..aebaa5f92e5 --- /dev/null +++ b/store/test-store/tests/postgres/pruned_poi.rs @@ -0,0 +1,75 @@ +// A proof of indexing for a block that pruning has already removed cannot be computed: +// the causality-region digests it is hashed from are gone. Returning a well-formed but +// wrong POI is worse than failing, because nothing downstream can tell the difference. + +use graph::prelude::{DeploymentHash, SubgraphStore as _}; +use test_store::*; + +const SUBGRAPH_GQL: &str = " + type User @entity { + id: ID!, + name: String + } +"; + +/// The block pointer the POS tests use: a deterministic hash per number. +fn ptr(n: i32) -> graph::prelude::BlockPtr { + let hash = graph::prelude::alloy::primitives::B256::from( + graph::prelude::alloy::primitives::U256::from(n as u64), + ); + graph::prelude::BlockPtr::new(hash.into(), n.into()) +} + +/// Put the subgraph at `head` and move its history window forward to `earliest`, +/// which is what the pruner leaves behind once it has passed the blocks below it. +async fn set_head_and_prune(deployment: &DeploymentHash, head: i32, earliest: i32) { + let mut conn = PRIMARY_POOL.get().await.unwrap(); + let hash = deployment.as_str().to_string(); + let hash_bytes = format!("\\x{}", "11".repeat(32)); + let stmt = format!( + "UPDATE subgraphs.deployment SET earliest_block_number = {earliest} WHERE subgraph = '{hash}';\ + UPDATE subgraphs.head SET block_number = {head}, block_hash = '{hash_bytes}' WHERE id = \ + (SELECT id FROM subgraphs.deployment WHERE subgraph = '{hash}');" + ); + diesel_async::SimpleAsyncConnection::batch_execute(&mut conn, &stmt) + .await + .unwrap(); +} + +#[test] +fn proof_of_indexing_refuses_a_pruned_block() { + run_test_sequentially(|store| async move { + let id = DeploymentHash::new("prunedPoi").unwrap(); + create_test_subgraph(&id, SUBGRAPH_GQL).await; + + // The subgraph is at block 200; everything below 100 has been pruned away. + set_head_and_prune(&id, 200, 100).await; + + let sg_store = store.subgraph_store(); + let indexer = None; + + // A retained block is unaffected. + let retained = sg_store + .get_proof_of_indexing(&id, &indexer, ptr(120)) + .await; + assert!( + retained.is_ok(), + "a retained block must still produce a POI: {retained:?}" + ); + + // A pruned block must be an error, not a silently wrong hash. + let pruned = sg_store + .get_proof_of_indexing(&id, &indexer, ptr(50)) + .await; + match pruned { + Err(e) => { + let msg = format!("{e}"); + assert!( + msg.contains("pruned") || msg.contains("earliest"), + "the error should say the block was pruned, got: {msg}" + ); + } + Ok(v) => panic!("a pruned block returned {v:?} instead of an error"), + } + }) +}