From eeaed6976eec332699a9016c86f2b0375dc927fc Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:04:17 -0500 Subject: [PATCH 01/31] refactor(dips): replace off-chain epoch vouchers with on-chain SignedRCA (#942) * feat(dips): implement SignedRCA validation and storage Implement RecurringCollectionAgreement (RCA) protocol for DIPS, aligned with the on-chain IndexingAgreement contract. Changes: - RcaStore trait and PostgreSQL implementation for RCA storage - EIP-712 signature verification via escrow-based authorization - validate_and_create_rca() with full validation pipeline: signature, IPFS manifest, network, pricing, deadline/expiry - Database migration for pending_rca_proposals table The indexer agent queries pending_rca_proposals directly and decides acceptance on-chain via RecurringCollector contract. Co-Authored-By: Claude Opus 4.5 * feat(dips): improve configuration ergonomics and validation - Add #[serde(default)] to DipsConfig for minimal config files - Validate recurring_collector != Address::ZERO at startup - Warn when tokens_per_second is empty (all proposals rejected) - Bump pricing rejection logs to info level for visibility Co-Authored-By: Claude Opus 4.5 * docs(dips): add module-level documentation Add comprehensive documentation explaining architecture, validation flow, trust model, and component responsibilities. Co-Authored-By: Claude Opus 4.5 * test(dips): expand unit test coverage to 43 tests Add comprehensive test suite with AAA pattern: - validate_and_create_rca: 11 tests covering all validation paths - PriceCalculator: 7 tests (previously 0) - SignerValidator implementations: 5 tests - Test doubles: FailingIpfsFetcher, FailingRcaStore, RejectingSignerValidator Co-Authored-By: Claude Opus 4.5 * feat(dips): add IPFS fetch timeout and retry with backoff Add resilience to IPFS manifest fetching: - 30 second timeout per attempt - Up to 4 attempts with exponential backoff (10s, 20s, 40s) - Worst case: ~190 seconds before rejection Dipper gRPC timeout should be >= 220s. See edgeandnode/dipper#557. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- Cargo.lock | 40 +- crates/config/maximal-config-example.toml | 7 +- crates/config/src/config.rs | 37 +- crates/dips/Cargo.toml | 43 +- crates/dips/src/database.rs | 379 +---- crates/dips/src/ipfs.rs | 289 +++- crates/dips/src/lib.rs | 1473 +++++++++++--------- crates/dips/src/price.rs | 155 +- crates/dips/src/proto/gateway.rs | 5 + crates/dips/src/proto/indexer.rs | 8 + crates/dips/src/proto/mod.rs | 10 + crates/dips/src/registry.rs | 9 + crates/dips/src/server.rs | 367 +++-- crates/dips/src/signers.rs | 152 +- crates/dips/src/store.rs | 222 +-- crates/service/src/service.rs | 112 +- migrations/20260209000000_dips_v2.down.sql | 3 + migrations/20260209000000_dips_v2.up.sql | 31 + 18 files changed, 1939 insertions(+), 1403 deletions(-) create mode 100644 migrations/20260209000000_dips_v2.down.sql create mode 100644 migrations/20260209000000_dips_v2.up.sql diff --git a/Cargo.lock b/Cargo.lock index 9833c195c..42e402119 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2651,7 +2651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.108", ] [[package]] @@ -2989,7 +2989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4021,7 +4021,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "system-configuration 0.6.1", "tokio", "tower-service", @@ -4247,19 +4247,17 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bs58", "build-info", "bytes", "derivative", "futures", "graph-networks-registry", - "http 0.2.12", "indexer-monitor", - "indexer-watcher", "ipfs-api-backend-hyper", "prost 0.14.1", - "rand 0.9.2", + "rand 0.8.5", "serde", - "serde_json", "serde_yaml", "sqlx", "test-assets", @@ -4601,7 +4599,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5134,13 +5132,13 @@ dependencies = [ [[package]] name = "match-lookup" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.108", ] [[package]] @@ -5438,7 +5436,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5573,7 +5571,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.1.3", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", "syn 2.0.108", @@ -5739,7 +5737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -6317,7 +6315,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -6543,7 +6541,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.34", - "socket2 0.5.10", + "socket2 0.6.1", "thiserror 2.0.18", "tokio", "tracing", @@ -6580,9 +6578,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.1", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7224,7 +7222,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8624,7 +8622,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9900,7 +9898,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index c3d850932..a77ab4d81 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -187,14 +187,15 @@ max_receipts_per_request = 10000 # DIPS (Decentralized Indexing Payment System) # NOTE: DIPS requires Horizon mode ([horizon].enabled = true) +# Payer authorization is handled via escrow accounts (same trust model as TAP) [dips] host = "0.0.0.0" port = "7601" -allowed_payers = ["0x3333333333333333333333333333333333333333"] +recurring_collector = "0x4444444444444444444444444444444444444444" -price_per_entity = "1000" +tokens_per_entity_per_second = "1000" -[dips.price_per_epoch] +[dips.tokens_per_second] mainnet = "100" hardhat = "100" diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 8b5b57a17..b54b741cd 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -631,16 +631,21 @@ fn default_allocation_reconciliation_interval_secs() -> Duration { Duration::from_secs(300) } +/// DIPS V2 configuration. +/// +/// V2 validates RCA proposals (signature, IPFS manifest, network, pricing) +/// before storing. The indexer agent queries pending proposals from the +/// database and decides on-chain acceptance. #[derive(Debug, Deserialize)] +#[serde(default)] #[cfg_attr(test, derive(PartialEq))] pub struct DipsConfig { pub host: String, pub port: String, - pub allowed_payers: Vec
, - - pub price_per_entity: U256, - pub price_per_epoch: BTreeMap, - pub additional_networks: HashMap, + pub recurring_collector: Address, + pub tokens_per_second: BTreeMap, + pub tokens_per_entity_per_second: U256, + pub additional_networks: BTreeMap, } impl Default for DipsConfig { @@ -648,10 +653,10 @@ impl Default for DipsConfig { DipsConfig { host: "0.0.0.0".to_string(), port: "7601".to_string(), - allowed_payers: vec![], - price_per_entity: U256::from(100), - price_per_epoch: BTreeMap::new(), - additional_networks: HashMap::new(), + recurring_collector: Address::ZERO, + tokens_per_second: BTreeMap::new(), + tokens_per_entity_per_second: U256::ZERO, + additional_networks: BTreeMap::new(), } } } @@ -739,7 +744,7 @@ pub struct HorizonConfig { #[cfg(test)] mod tests { use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, HashSet}, env, fs, path::PathBuf, str::FromStr, @@ -774,15 +779,15 @@ mod tests { max_config.tap.trusted_senders = HashSet::from([address!("deadbeefcafebabedeadbeefcafebabedeadbeef")]); max_config.dips = Some(crate::DipsConfig { - allowed_payers: vec![Address( - FixedBytes::<20>::from_str("0x3333333333333333333333333333333333333333").unwrap(), - )], - price_per_entity: U256::from(1000), - price_per_epoch: BTreeMap::from_iter(vec![ + recurring_collector: Address( + FixedBytes::<20>::from_str("0x4444444444444444444444444444444444444444").unwrap(), + ), + tokens_per_entity_per_second: U256::from(1000), + tokens_per_second: BTreeMap::from_iter(vec![ ("mainnet".to_string(), U256::from(100)), ("hardhat".to_string(), U256::from(100)), ]), - additional_networks: HashMap::from([( + additional_networks: BTreeMap::from([( "eip155:1337".to_string(), "hardhat".to_string(), )]), diff --git a/crates/dips/Cargo.toml b/crates/dips/Cargo.toml index 1cb7bf2c5..c3d4edaf7 100644 --- a/crates/dips/Cargo.toml +++ b/crates/dips/Cargo.toml @@ -12,43 +12,52 @@ rpc = [ "dep:tonic-prost", "dep:tonic-prost-build", "dep:bytes", + "dep:graph-networks-registry", + "dep:serde", + "dep:serde_yaml", +] +db = [ + "dep:sqlx", + "dep:build-info", + "dep:indexer-monitor", + "dep:graph-networks-registry", + "dep:serde", + "dep:serde_yaml", ] -db = ["dep:sqlx"] [dependencies] -build-info.workspace = true -thiserror.workspace = true anyhow.workspace = true thegraph-core.workspace = true async-trait.workspace = true uuid.workspace = true tokio.workspace = true -indexer-monitor = { path = "../monitor" } tracing.workspace = true -graph-networks-registry.workspace = true +bs58 = "0.5" +build-info = { workspace = true, optional = true } +indexer-monitor = { path = "../monitor", optional = true } +thiserror.workspace = true +graph-networks-registry = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_yaml = { version = "0.9", optional = true } -bytes = { version = "1.10.0", optional = true } +# IPFS client dependencies derivative = "2.2.0" - futures.workspace = true -http = "0.2" +ipfs-api-backend-hyper = { version = "0.6.0", features = ["with-send-sync", "with-hyper-tls"] } + +bytes = { version = "1.10.0", optional = true } prost = { workspace = true, optional = true } -ipfs-api-backend-hyper = { version = "0.6.0", features = [ - "with-send-sync", - "with-hyper-tls", -] } -serde_yaml.workspace = true -serde.workspace = true sqlx = { workspace = true, optional = true } tonic = { workspace = true, optional = true } tonic-prost = { workspace = true, optional = true } -serde_json.workspace = true [dev-dependencies] -rand.workspace = true -indexer-watcher = { path = "../watcher" } testcontainers-modules = { workspace = true, features = ["postgres"] } test-assets = { path = "../test-assets" } +indexer-monitor = { path = "../monitor" } +graph-networks-registry.workspace = true +build-info.workspace = true +rand = "0.8" [build-dependencies] tonic-build = { workspace = true, optional = true } diff --git a/crates/dips/src/database.rs b/crates/dips/src/database.rs index fdaf1f66e..04fb17cd2 100644 --- a/crates/dips/src/database.rs +++ b/crates/dips/src/database.rs @@ -1,366 +1,65 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::str::FromStr; +//! PostgreSQL implementation of [`RcaStore`](crate::store::RcaStore). +//! +//! This module provides [`PsqlRcaStore`], which persists validated RCA proposals +//! to the `pending_rca_proposals` table. The indexer-agent queries this table +//! directly to find pending proposals and decide on-chain acceptance. +//! +//! # Shared Database +//! +//! indexer-rs (Rust) and indexer-agent (TypeScript) share the same PostgreSQL +//! database. This module only writes; the agent reads and updates status: +//! +//! ```text +//! indexer-rs ──INSERT──> pending_rca_proposals <──SELECT/UPDATE── indexer-agent +//! ``` +//! +//! # Status Lifecycle +//! +//! 1. indexer-rs inserts with status = "pending" +//! 2. indexer-agent queries pending proposals +//! 3. Agent validates allocation availability, accepts on-chain +//! 4. Agent updates status to "accepted" or "rejected" + +use std::any::Any; use async_trait::async_trait; -use build_info::chrono::{DateTime, Utc}; -use sqlx::{types::BigDecimal, PgPool, Row}; -use thegraph_core::alloy::{core::primitives::U256 as uint256, hex::ToHexExt, sol_types::SolType}; +use sqlx::PgPool; use uuid::Uuid; -use crate::{ - store::{AgreementStore, StoredIndexingAgreement}, - DipsError, SignedCancellationRequest, SignedIndexingAgreementVoucher, - SubgraphIndexingVoucherMetadata, -}; +use crate::{store::RcaStore, DipsError}; +/// PostgreSQL implementation of RcaStore for RecurringCollectionAgreement. #[derive(Debug)] -pub struct PsqlAgreementStore { +pub struct PsqlRcaStore { pub pool: PgPool, } -fn uint256_to_bigdecimal(value: &uint256, field: &str) -> Result { - BigDecimal::from_str(&value.to_string()) - .map_err(|e| DipsError::InvalidVoucher(format!("{field}: {e}"))) -} - #[async_trait] -impl AgreementStore for PsqlAgreementStore { - async fn get_by_id(&self, id: Uuid) -> Result, DipsError> { - let item = sqlx::query("SELECT * FROM indexing_agreements WHERE id=$1") - .bind(id) - .fetch_one(&self.pool) - .await; - - let item = match item { - Ok(item) => item, - Err(sqlx::Error::RowNotFound) => return Ok(None), - Err(err) => return Err(DipsError::UnknownError(err.into())), - }; - - let signed_payload: Vec = item - .try_get("signed_payload") - .map_err(|e| DipsError::UnknownError(e.into()))?; - let signed = SignedIndexingAgreementVoucher::abi_decode(signed_payload.as_ref()) - .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; - let metadata = - SubgraphIndexingVoucherMetadata::abi_decode(signed.voucher.metadata.as_ref()) - .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; - let cancelled_at: Option> = item - .try_get("cancelled_at") - .map_err(|e| DipsError::UnknownError(e.into()))?; - let cancelled = cancelled_at.is_some(); - let current_allocation_id: Option = item - .try_get("current_allocation_id") - .map_err(|e| DipsError::UnknownError(e.into()))?; - let last_allocation_id: Option = item - .try_get("last_allocation_id") - .map_err(|e| DipsError::UnknownError(e.into()))?; - let last_payment_collected_at: Option> = item - .try_get("last_payment_collected_at") - .map_err(|e| DipsError::UnknownError(e.into()))?; - Ok(Some(StoredIndexingAgreement { - voucher: signed, - metadata, - cancelled, - current_allocation_id, - last_allocation_id, - last_payment_collected_at, - })) - } - async fn create_agreement( +impl RcaStore for PsqlRcaStore { + async fn store_rca( &self, - agreement: SignedIndexingAgreementVoucher, - metadata: SubgraphIndexingVoucherMetadata, + agreement_id: Uuid, + signed_rca: Vec, + version: u64, ) -> Result<(), DipsError> { - let id = Uuid::from_bytes(agreement.voucher.agreement_id.into()); - let bs = agreement.encode_vec(); - let now = Utc::now(); - let deadline_i64: i64 = agreement - .voucher - .deadline - .try_into() - .map_err(|_| DipsError::InvalidVoucher("deadline".to_string()))?; - let deadline = DateTime::from_timestamp(deadline_i64, 0) - .ok_or(DipsError::InvalidVoucher("deadline".to_string()))?; - let base_price_per_epoch = - uint256_to_bigdecimal(&metadata.basePricePerEpoch, "basePricePerEpoch")?; - let price_per_entity = uint256_to_bigdecimal(&metadata.pricePerEntity, "pricePerEntity")?; - let duration_epochs: i64 = agreement.voucher.durationEpochs.into(); - let max_initial_amount = - uint256_to_bigdecimal(&agreement.voucher.maxInitialAmount, "maxInitialAmount")?; - let max_ongoing_amount_per_epoch = uint256_to_bigdecimal( - &agreement.voucher.maxOngoingAmountPerEpoch, - "maxOngoingAmountPerEpoch", - )?; - let min_epochs_per_collection: i64 = agreement.voucher.minEpochsPerCollection.into(); - let max_epochs_per_collection: i64 = agreement.voucher.maxEpochsPerCollection.into(); sqlx::query( - "INSERT INTO indexing_agreements VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,null,null,null,null,null)", + "INSERT INTO pending_rca_proposals (id, signed_payload, version, status, created_at, updated_at) + VALUES ($1, $2, $3, 'pending', NOW(), NOW())" ) - .bind(id) - .bind(agreement.signature.as_ref()) - .bind(bs) - .bind(metadata.protocolNetwork) - .bind(metadata.chainId) - .bind(base_price_per_epoch) - .bind(price_per_entity) - .bind(metadata.subgraphDeploymentId) - .bind(agreement.voucher.service.encode_hex()) - .bind(agreement.voucher.recipient.encode_hex()) - .bind(agreement.voucher.payer.encode_hex()) - .bind(deadline) - .bind(duration_epochs) - .bind(max_initial_amount) - .bind(max_ongoing_amount_per_epoch) - .bind(min_epochs_per_collection) - .bind(max_epochs_per_collection) - .bind(now) - .bind(now) + .bind(agreement_id) + .bind(signed_rca) + .bind(version as i16) .execute(&self.pool) .await .map_err(|e| DipsError::UnknownError(e.into()))?; Ok(()) } - async fn cancel_agreement( - &self, - signed_cancellation: SignedCancellationRequest, - ) -> Result { - let id = Uuid::from_bytes(signed_cancellation.request.agreement_id.into()); - let bs = signed_cancellation.encode_vec(); - let now = Utc::now(); - - sqlx::query( - "UPDATE indexing_agreements SET updated_at=$1, cancelled_at=$1, signed_cancellation_payload=$2 WHERE id=$3", - ) - .bind(now) - .bind(bs) - .bind(id) - .execute(&self.pool) - .await - .map_err(|_| DipsError::AgreementNotFound)?; - - Ok(id) - } -} - -#[cfg(test)] -pub(crate) mod test { - use std::sync::Arc; - - use build_info::chrono::Duration; - use sqlx::Row; - use thegraph_core::alloy::{ - primitives::{ruint::aliases::U256, Address}, - sol_types::SolValue, - }; - use uuid::Uuid; - - use super::*; - use crate::{CancellationRequest, IndexingAgreementVoucher}; - - #[tokio::test] - async fn test_store_agreement() { - let test_db = test_assets::setup_shared_test_db().await; - let store = Arc::new(PsqlAgreementStore { pool: test_db.pool }); - let id = Uuid::now_v7(); - - // Create metadata first - let metadata = SubgraphIndexingVoucherMetadata { - protocolNetwork: "eip155:42161".to_string(), - chainId: "eip155:1".to_string(), - basePricePerEpoch: U256::from(5000), - pricePerEntity: U256::from(10), - subgraphDeploymentId: "Qm123".to_string(), - }; - - // Create agreement with encoded metadata - let agreement = SignedIndexingAgreementVoucher { - signature: vec![1, 2, 3].into(), - voucher: IndexingAgreementVoucher { - agreement_id: id.as_bytes().into(), - deadline: (Utc::now() + Duration::days(30)).timestamp() as u64, - payer: Address::from_str("1234567890123456789012345678901234567890").unwrap(), - recipient: Address::from_str("2345678901234567890123456789012345678901").unwrap(), - service: Address::from_str("3456789012345678901234567890123456789012").unwrap(), - durationEpochs: 30, // 30 epochs duration - maxInitialAmount: U256::from(1000), - maxOngoingAmountPerEpoch: U256::from(100), - maxEpochsPerCollection: 5, - minEpochsPerCollection: 1, - metadata: metadata.abi_encode().into(), // Convert Vec to Bytes - }, - }; - - // Store agreement - store - .create_agreement(agreement.clone(), metadata) - .await - .unwrap(); - - // Verify stored agreement - let row = sqlx::query("SELECT * FROM indexing_agreements WHERE id = $1") - .bind(id) - .fetch_one(&store.pool) - .await - .unwrap(); - - let row_id: Uuid = row.try_get("id").unwrap(); - let signature: Vec = row.try_get("signature").unwrap(); - let protocol_network: String = row.try_get("protocol_network").unwrap(); - let chain_id: String = row.try_get("chain_id").unwrap(); - let subgraph_deployment_id: String = row.try_get("subgraph_deployment_id").unwrap(); - - assert_eq!(row_id, id); - assert_eq!(signature, agreement.signature); - assert_eq!(protocol_network, "eip155:42161"); - assert_eq!(chain_id, "eip155:1"); - assert_eq!(subgraph_deployment_id, "Qm123"); - } - - #[tokio::test] - async fn test_get_agreement_by_id() { - let test_db = test_assets::setup_shared_test_db().await; - let store = Arc::new(PsqlAgreementStore { pool: test_db.pool }); - let id = Uuid::parse_str("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d9").unwrap(); - - // Create metadata first - let metadata = SubgraphIndexingVoucherMetadata { - protocolNetwork: "eip155:42161".to_string(), - chainId: "eip155:1".to_string(), - basePricePerEpoch: U256::from(5000), - pricePerEntity: U256::from(10), - subgraphDeploymentId: "Qm123".to_string(), - }; - - // Create agreement with encoded metadata - let agreement = SignedIndexingAgreementVoucher { - signature: vec![1, 2, 3].into(), - voucher: IndexingAgreementVoucher { - agreement_id: id.as_bytes().into(), - deadline: (Utc::now() + Duration::days(30)).timestamp() as u64, - payer: Address::from_str("1234567890123456789012345678901234567890").unwrap(), - recipient: Address::from_str("2345678901234567890123456789012345678901").unwrap(), - service: Address::from_str("3456789012345678901234567890123456789012").unwrap(), - durationEpochs: 30, - maxInitialAmount: U256::from(1000), - maxOngoingAmountPerEpoch: U256::from(100), - maxEpochsPerCollection: 5, - minEpochsPerCollection: 1, - metadata: metadata.abi_encode().into(), - }, - }; - - // Store agreement - store - .create_agreement(agreement.clone(), metadata.clone()) - .await - .unwrap(); - - // Retrieve agreement - let stored_agreement = store.get_by_id(id).await.unwrap().unwrap(); - - let retrieved_voucher = &stored_agreement.voucher; - let retrieved_metadata = stored_agreement.metadata; - - // Verify retrieved agreement matches original - assert_eq!(retrieved_voucher.signature, agreement.signature); - assert_eq!( - retrieved_voucher.voucher.durationEpochs, - agreement.voucher.durationEpochs - ); - assert_eq!(retrieved_metadata.protocolNetwork, metadata.protocolNetwork); - assert_eq!(retrieved_metadata.chainId, metadata.chainId); - assert_eq!( - retrieved_metadata.subgraphDeploymentId, - metadata.subgraphDeploymentId - ); - assert_eq!(retrieved_voucher.voucher.payer, agreement.voucher.payer); - assert_eq!( - retrieved_voucher.voucher.recipient, - agreement.voucher.recipient - ); - assert_eq!(retrieved_voucher.voucher.service, agreement.voucher.service); - assert_eq!( - retrieved_voucher.voucher.maxInitialAmount, - agreement.voucher.maxInitialAmount - ); - assert_eq!( - retrieved_voucher.voucher.maxOngoingAmountPerEpoch, - agreement.voucher.maxOngoingAmountPerEpoch - ); - assert_eq!( - retrieved_voucher.voucher.maxEpochsPerCollection, - agreement.voucher.maxEpochsPerCollection - ); - assert_eq!( - retrieved_voucher.voucher.minEpochsPerCollection, - agreement.voucher.minEpochsPerCollection - ); - assert!(!stored_agreement.cancelled); - } - - #[tokio::test] - async fn test_cancel_agreement() { - let test_db = test_assets::setup_shared_test_db().await; - let store = Arc::new(PsqlAgreementStore { pool: test_db.pool }); - let id = Uuid::parse_str("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7e9").unwrap(); - - // Create metadata first - let metadata = SubgraphIndexingVoucherMetadata { - protocolNetwork: "eip155:42161".to_string(), - chainId: "eip155:1".to_string(), - basePricePerEpoch: U256::from(5000), - pricePerEntity: U256::from(10), - subgraphDeploymentId: "Qm123".to_string(), - }; - - // Create agreement with encoded metadata - let agreement = SignedIndexingAgreementVoucher { - signature: vec![1, 2, 3].into(), - voucher: IndexingAgreementVoucher { - agreement_id: id.as_bytes().into(), - deadline: (Utc::now() + Duration::days(30)).timestamp() as u64, - payer: Address::from_str("1234567890123456789012345678901234567890").unwrap(), - recipient: Address::from_str("2345678901234567890123456789012345678901").unwrap(), - service: Address::from_str("3456789012345678901234567890123456789012").unwrap(), - durationEpochs: 30, - maxInitialAmount: U256::from(1000), - maxOngoingAmountPerEpoch: U256::from(100), - maxEpochsPerCollection: 5, - minEpochsPerCollection: 1, - metadata: metadata.abi_encode().into(), - }, - }; - - // Store agreement - store - .create_agreement(agreement.clone(), metadata) - .await - .unwrap(); - - // Cancel agreement - let cancellation = SignedCancellationRequest { - signature: vec![1, 2, 3].into(), - request: CancellationRequest { - agreement_id: id.as_bytes().into(), - }, - }; - store.cancel_agreement(cancellation.clone()).await.unwrap(); - - // Verify stored agreement - let row = sqlx::query("SELECT * FROM indexing_agreements WHERE id = $1") - .bind(id) - .fetch_one(&store.pool) - .await - .unwrap(); - let cancelled_at: Option> = row.try_get("cancelled_at").unwrap(); - let signed_cancellation_payload: Option> = - row.try_get("signed_cancellation_payload").unwrap(); - assert!(cancelled_at.is_some()); - assert_eq!(signed_cancellation_payload, Some(cancellation.encode_vec())); + fn as_any(&self) -> &dyn Any { + self } } diff --git a/crates/dips/src/ipfs.rs b/crates/dips/src/ipfs.rs index 80846c91d..08accb413 100644 --- a/crates/dips/src/ipfs.rs +++ b/crates/dips/src/ipfs.rs @@ -1,7 +1,50 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::sync::Arc; +//! IPFS client for fetching subgraph manifests. +//! +//! When validating an RCA, we need to verify that the referenced subgraph +//! deployment actually exists and determine which network it indexes. +//! The subgraph deployment ID in the RCA is a bytes32 that maps to an IPFS +//! CIDv0 hash pointing to the subgraph manifest. +//! +//! # Manifest Structure +//! +//! Subgraph manifests are YAML files containing data source definitions. +//! We extract the `network` field to validate that this indexer supports +//! the chain the subgraph indexes: +//! +//! ```yaml +//! dataSources: +//! - network: mainnet # <-- This is what we extract +//! kind: ethereum/contract +//! ... +//! ``` +//! +//! # Timeout and Retry Behavior +//! +//! IPFS fetches have a 30-second timeout per attempt. On failure, the client +//! retries up to 3 times with exponential backoff (10s, 20s, 40s delays). This +//! gives IPFS meaningful recovery time between attempts. +//! +//! Worst case timing: 30s + 10s + 30s + 20s + 30s + 40s + 30s = 190 seconds. +//! +//! Dipper's gRPC timeout should be at least 220 seconds (190s + 30s buffer) +//! to avoid timing out while indexer-rs is still retrying IPFS. +//! +//! # What This Proves +//! +//! Successfully fetching a manifest proves: +//! - The deployment ID maps to real content on IPFS +//! - The content is a valid, parseable subgraph manifest +//! +//! What it does NOT prove: +//! - The subgraph is published on The Graph Network (GNS) +//! - The subgraph is not deprecated +//! +//! Those checks are the indexer-agent's responsibility. + +use std::{sync::Arc, time::Duration}; use async_trait::async_trait; use derivative::Derivative; @@ -11,6 +54,15 @@ use serde::Deserialize; use crate::DipsError; +/// Timeout for a single IPFS fetch attempt. +const IPFS_FETCH_TIMEOUT: Duration = Duration::from_secs(30); + +/// Maximum number of IPFS fetch attempts (1 initial + 3 retries). +const IPFS_MAX_ATTEMPTS: u32 = 4; + +/// Base delay for exponential backoff between retries (10s, 20s, 40s). +const IPFS_RETRY_BASE_DELAY: Duration = Duration::from_secs(10); + #[async_trait] pub trait IpfsFetcher: Send + Sync + std::fmt::Debug { async fn fetch(&self, file: &str) -> Result; @@ -40,23 +92,69 @@ impl IpfsClient { #[async_trait] impl IpfsFetcher for IpfsClient { async fn fetch(&self, file: &str) -> Result { - let content = self - .client - .cat(file.as_ref()) - .map_ok(|chunk| chunk.to_vec()) - .try_concat() - .await - .map_err(|e| { - tracing::warn!("Failed to fetch subgraph manifest {}: {}", file, e); - DipsError::SubgraphManifestUnavailable(format!("{file}: {e}")) - })?; + let mut last_error = None; - let manifest: GraphManifest = serde_yaml::from_slice(&content).map_err(|e| { - tracing::warn!("Failed to parse subgraph manifest {}: {}", file, e); - DipsError::InvalidSubgraphManifest(format!("{file}: {e}")) - })?; + for attempt in 0..IPFS_MAX_ATTEMPTS { + if attempt > 0 { + // Exponential backoff: 1s, 2s, 4s + let delay = IPFS_RETRY_BASE_DELAY * 2u32.pow(attempt - 1); + tracing::debug!( + file = %file, + attempt = attempt + 1, + delay_ms = delay.as_millis(), + "Retrying IPFS fetch after backoff" + ); + tokio::time::sleep(delay).await; + } + + match self.fetch_with_timeout(file).await { + Ok(manifest) => return Ok(manifest), + Err(e) => { + tracing::warn!( + file = %file, + attempt = attempt + 1, + max_attempts = IPFS_MAX_ATTEMPTS, + error = %e, + "IPFS fetch attempt failed" + ); + last_error = Some(e); + } + } + } - Ok(manifest) + // All attempts failed + Err(last_error.unwrap_or_else(|| { + DipsError::SubgraphManifestUnavailable(format!("{file}: all attempts failed")) + })) + } +} + +impl IpfsClient { + /// Fetch with timeout wrapper. + async fn fetch_with_timeout(&self, file: &str) -> Result { + let fetch_future = async { + let content = self + .client + .cat(file.as_ref()) + .map_ok(|chunk| chunk.to_vec()) + .try_concat() + .await + .map_err(|e| DipsError::SubgraphManifestUnavailable(format!("{file}: {e}")))?; + + let manifest: GraphManifest = serde_yaml::from_slice(&content) + .map_err(|e| DipsError::InvalidSubgraphManifest(format!("{file}: {e}")))?; + + Ok(manifest) + }; + + tokio::time::timeout(IPFS_FETCH_TIMEOUT, fetch_future) + .await + .map_err(|_| { + DipsError::SubgraphManifestUnavailable(format!( + "{file}: timeout after {}s", + IPFS_FETCH_TIMEOUT.as_secs() + )) + })? } } @@ -73,52 +171,79 @@ pub struct GraphManifest { } impl GraphManifest { - pub fn network(&self) -> Option { - self.data_sources.first().map(|ds| ds.network.clone()) + pub fn network(&self) -> Option<&str> { + self.data_sources.first().map(|ds| ds.network.as_str()) } } -#[cfg(test)] -#[derive(Debug)] -pub struct TestIpfsClient { - manifest: GraphManifest, +/// Mock IPFS fetcher for testing with configurable network. +#[derive(Debug, Clone)] +pub struct MockIpfsFetcher { + pub network: String, } -#[cfg(test)] -impl TestIpfsClient { - pub fn mainnet() -> Self { +impl MockIpfsFetcher { + /// Creates a fetcher that returns a manifest with no network field. + pub fn no_network() -> Self { Self { - manifest: GraphManifest { - data_sources: vec![DataSource { - network: "mainnet".to_string(), - }], - }, + network: String::new(), } } - pub fn no_network() -> Self { +} + +/// Test IPFS fetcher that always fails. +#[derive(Debug, Clone, Default)] +pub struct FailingIpfsFetcher; + +#[async_trait] +impl IpfsFetcher for FailingIpfsFetcher { + async fn fetch(&self, file: &str) -> Result { + Err(DipsError::SubgraphManifestUnavailable(format!( + "{file}: connection refused (test fetcher)" + ))) + } +} + +impl Default for MockIpfsFetcher { + fn default() -> Self { Self { - manifest: GraphManifest { - data_sources: vec![], - }, + network: "mainnet".to_string(), } } } -#[cfg(test)] #[async_trait] -impl IpfsFetcher for TestIpfsClient { +impl IpfsFetcher for MockIpfsFetcher { async fn fetch(&self, _file: &str) -> Result { - Ok(self.manifest.clone()) + if self.network.is_empty() { + Ok(GraphManifest { + data_sources: vec![], + }) + } else { + Ok(GraphManifest { + data_sources: vec![DataSource { + network: self.network.clone(), + }], + }) + } } } #[cfg(test)] mod test { - use crate::ipfs::{DataSource, GraphManifest}; + use crate::ipfs::{ + DataSource, FailingIpfsFetcher, GraphManifest, IpfsFetcher, MockIpfsFetcher, + }; #[test] fn test_deserialize_manifest() { - let manifest: GraphManifest = serde_yaml::from_str(MANIFEST).unwrap(); + // Arrange + let yaml = MANIFEST; + + // Act + let manifest: GraphManifest = serde_yaml::from_str(yaml).unwrap(); + + // Assert assert_eq!( manifest, GraphManifest { @@ -134,6 +259,92 @@ mod test { ) } + #[test] + fn test_manifest_network_extraction() { + // Arrange + let manifest = GraphManifest { + data_sources: vec![DataSource { + network: "mainnet".to_string(), + }], + }; + + // Act + let network = manifest.network(); + + // Assert + assert_eq!(network, Some("mainnet")); + } + + #[test] + fn test_manifest_network_empty_sources() { + // Arrange + let manifest = GraphManifest { + data_sources: vec![], + }; + + // Act + let network = manifest.network(); + + // Assert + assert_eq!(network, None); + } + + #[tokio::test] + async fn test_mock_ipfs_fetcher_default() { + // Arrange + let fetcher = MockIpfsFetcher::default(); + + // Act + let manifest = fetcher.fetch("QmSomeHash").await.unwrap(); + + // Assert + assert_eq!(manifest.network(), Some("mainnet")); + } + + #[tokio::test] + async fn test_mock_ipfs_fetcher_custom_network() { + // Arrange + let fetcher = MockIpfsFetcher { + network: "arbitrum-one".to_string(), + }; + + // Act + let manifest = fetcher.fetch("QmSomeHash").await.unwrap(); + + // Assert + assert_eq!(manifest.network(), Some("arbitrum-one")); + } + + #[tokio::test] + async fn test_mock_ipfs_fetcher_no_network() { + // Arrange + let fetcher = MockIpfsFetcher::no_network(); + + // Act + let manifest = fetcher.fetch("QmSomeHash").await.unwrap(); + + // Assert + assert_eq!(manifest.network(), None); + } + + #[tokio::test] + async fn test_failing_ipfs_fetcher() { + // Arrange + let fetcher = FailingIpfsFetcher; + + // Act + let result = fetcher.fetch("QmSomeHash").await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, crate::DipsError::SubgraphManifestUnavailable(_)), + "Expected SubgraphManifestUnavailable, got: {:?}", + err + ); + } + const MANIFEST: &str = " dataSources: - kind: ethereum/contract diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index d55280b8d..9b60d41fb 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -1,12 +1,64 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 +//! DIPS (Direct Indexer Payments) for The Graph. +//! +//! This crate implements the indexer-side handling of RecurringCollectionAgreement (RCA) +//! proposals. When a payer wants indexing services, the Dipper service creates and signs +//! an RCA on their behalf, then sends it to the indexer via gRPC. +//! +//! # Architecture +//! +//! ```text +//! Payer (user) ──deposits──> PaymentsEscrow contract +//! │ │ +//! │ authorizes signer │ escrow data indexed +//! ▼ ▼ +//! Dipper ───SignedRCA───> indexer-rs (this crate) +//! │ │ +//! │ │ validates & stores +//! │ ▼ +//! │ pending_rca_proposals table +//! │ │ +//! │ │ agent queries & decides +//! │ ▼ +//! └──────────────────> on-chain acceptance +//! ``` +//! +//! # Validation Flow +//! +//! When an RCA arrives, this crate validates: +//! 1. **Signature** - EIP-712 signature recovers to an authorized signer +//! 2. **Signer authorization** - Signer is authorized for the payer (via escrow accounts) +//! 3. **Service provider** - RCA is addressed to this indexer +//! 4. **Timestamps** - Deadline and end time haven't passed +//! 5. **IPFS manifest** - Subgraph deployment exists and is parseable +//! 6. **Network** - Subgraph's network is supported by this indexer +//! 7. **Pricing** - Offered price meets indexer's minimum +//! +//! # Trust Model +//! +//! Payers deposit funds into the PaymentsEscrow contract and authorize signers. +//! The escrow has a **thawing period** for withdrawals, giving indexers time to +//! collect owed fees before funds can be withdrawn. This crate checks signer +//! authorization via the network subgraph, which may lag chain state slightly. +//! The thawing period protects against this lag. +//! +//! # Modules +//! +//! - [`server`] - gRPC server handling RCA proposals +//! - [`store`] - Storage trait for RCA proposals +//! - [`database`] - PostgreSQL implementation +//! - [`signers`] - Signer authorization via escrow accounts +//! - [`ipfs`] - IPFS client for subgraph manifests +//! - [`price`] - Minimum price enforcement + use std::{str::FromStr, sync::Arc}; use server::DipsServerContext; use thegraph_core::alloy::{ core::primitives::Address, - primitives::{b256, ruint::aliases::U256, ChainId, Signature, Uint, B256}, + primitives::{ruint::aliases::U256, ChainId, Signature, Uint}, signers::SignerSync, sol, sol_types::{eip712_domain, Eip712Domain, SolStruct, SolValue}, @@ -25,152 +77,125 @@ pub mod server; pub mod signers; pub mod store; -use store::AgreementStore; use thiserror::Error; use uuid::Uuid; -/// DIPs EIP-712 domain salt -const EIP712_DOMAIN_SALT: B256 = - b256!("b4632c657c26dce5d4d7da1d65bda185b14ff8f905ddbb03ea0382ed06c5ef28"); - -/// DIPs Protocol version -pub const PROTOCOL_VERSION: u64 = 1; // MVP - -/// Create an EIP-712 domain given a chain ID and dispute manager address. -pub fn dips_agreement_eip712_domain(chain_id: ChainId) -> Eip712Domain { - eip712_domain! { - name: "Graph Protocol Indexing Agreement", - version: "0", - chain_id: chain_id, - salt: EIP712_DOMAIN_SALT, - } -} +/// Protocol version (seconds-based RCA) +pub const PROTOCOL_VERSION: u64 = 2; -pub fn dips_cancellation_eip712_domain(chain_id: ChainId) -> Eip712Domain { +/// Create an EIP-712 domain for RecurringCollectionAgreement. +/// +/// Used to sign `RecurringCollectionAgreement` messages. The `verifying_contract` +/// is the deployed RecurringCollector address. +pub fn rca_eip712_domain(chain_id: ChainId, recurring_collector: Address) -> Eip712Domain { eip712_domain! { - name: "Graph Protocol Indexing Agreement Cancellation", - version: "0", + name: "RecurringCollector", + version: "1", chain_id: chain_id, - salt: EIP712_DOMAIN_SALT, - } -} - -pub fn dips_collection_eip712_domain(chain_id: ChainId) -> Eip712Domain { - eip712_domain! { - name: "Graph Protocol Indexing Agreement Collection", - version: "0", - chain_id: chain_id, - salt: EIP712_DOMAIN_SALT, + verifying_contract: recurring_collector, } } sol! { - // EIP712 encoded bytes - #[derive(Debug, PartialEq)] - struct SignedIndexingAgreementVoucher { - IndexingAgreementVoucher voucher; - bytes signature; - } + // === RCA Types (seconds-based RecurringCollectionAgreement) === + /// The on-chain RecurringCollectionAgreement type. + /// + /// Matches `IRecurringCollector.RecurringCollectionAgreement` exactly. #[derive(Debug, PartialEq)] - struct IndexingAgreementVoucher { - // must be unique for each indexer/gateway pair - bytes16 agreement_id; - // should coincide with signer of this voucher + struct RecurringCollectionAgreement { + bytes16 agreementId; + // NB: The on-chain struct declares these as uint64 for storage efficiency, + // but the EIP-712 typehash uses uint256. We must match the typehash. + uint256 deadline; + uint256 endsAt; address payer; - // should coincide with indexer - address recipient; - // data service that will initiate payment collection - address service; - - uint32 durationEpochs; - - uint256 maxInitialAmount; - uint256 maxOngoingAmountPerEpoch; - - uint32 minEpochsPerCollection; - uint32 maxEpochsPerCollection; - - // Deadline for the indexer to accept the agreement - uint64 deadline; + address dataService; + address serviceProvider; + uint256 maxInitialTokens; + uint256 maxOngoingTokensPerSecond; + uint32 minSecondsPerCollection; + uint32 maxSecondsPerCollection; bytes metadata; } - // the vouchers are generic to each data service, in the case of subgraphs this is an ABI-encoded SubgraphIndexingVoucherMetadata + /// Wrapper pairing an RCA with its EIP-712 signature. #[derive(Debug, PartialEq)] - struct SubgraphIndexingVoucherMetadata { - uint256 basePricePerEpoch; // wei GRT - uint256 pricePerEntity; // wei GRT - string subgraphDeploymentId; // e.g. "Qmbg1qF4YgHjiVfsVt6a13ddrVcRtWyJQfD4LA3CwHM29f" - TODO consider using bytes32 - string protocolNetwork; // e.g. "eip155:42161" - string chainId; // indexed chain, e.g. "eip155:1" + struct SignedRecurringCollectionAgreement { + RecurringCollectionAgreement agreement; + bytes signature; } + /// Metadata for indexing agreement acceptance, ABI-encoded into + /// `RecurringCollectionAgreement.metadata`. #[derive(Debug, PartialEq)] - struct SignedCancellationRequest { - CancellationRequest request; - bytes signature; + struct AcceptIndexingAgreementMetadata { + bytes32 subgraphDeploymentId; + uint8 version; + bytes terms; } + /// Pricing terms, ABI-encoded into `AcceptIndexingAgreementMetadata.terms`. #[derive(Debug, PartialEq)] - struct CancellationRequest { - bytes16 agreement_id; + struct IndexingAgreementTermsV1 { + uint256 tokensPerSecond; + uint256 tokensPerEntityPerSecond; } + // === Cancellation === + #[derive(Debug, PartialEq)] - struct SignedCollectionRequest { - CollectionRequest request; + struct SignedCancellationRequest { + CancellationRequest request; bytes signature; } #[derive(Debug, PartialEq)] - struct CollectionRequest { + struct CancellationRequest { bytes16 agreement_id; - address allocation_id; - uint64 entity_count; } - } #[derive(Error, Debug)] pub enum DipsError { - // agreement creation + // RCA validation #[error("signature is not valid, error: {0}")] InvalidSignature(String), - #[error("payer {0} not authorised")] - PayerNotAuthorised(Address), - #[error("voucher payee {actual} does not match the expected address {expected}")] - UnexpectedPayee { expected: Address, actual: Address }, + #[error("RCA service provider {actual} does not match the expected address {expected}")] + UnexpectedServiceProvider { expected: Address, actual: Address }, #[error("cannot get subgraph manifest for {0}")] SubgraphManifestUnavailable(String), #[error("invalid subgraph id {0}")] InvalidSubgraphManifest(String), - #[error("chainId {0} is not supported")] - UnsupportedChainId(String), - #[error("price per epoch is below configured price for chain {0}, minimum: {1}, offered: {2}")] - PricePerEpochTooLow(String, U256, String), + #[error("network {0} is not supported")] + UnsupportedNetwork(String), #[error( - "price per entity is below configured price for chain {0}, minimum: {1}, offered: {2}" + "tokens per second {offered} is below configured minimum {minimum} for network {network}" )] - PricePerEntityTooLow(String, U256, String), - // cancellation - #[error("cancelled_by is expected to match the signer")] - UnexpectedSigner, + TokensPerSecondTooLow { + network: String, + minimum: U256, + offered: U256, + }, + #[error("tokens per entity per second {offered} is below configured minimum {minimum}")] + TokensPerEntityPerSecondTooLow { minimum: U256, offered: U256 }, #[error("signer {0} not authorised")] SignerNotAuthorised(Address), - #[error("cancellation request has expired")] - ExpiredRequest, + #[error("cancelled_by is expected to match the signer")] + UnexpectedSigner, // misc #[error("unknown error: {0}")] UnknownError(#[from] anyhow::Error), - #[error("agreement not found")] - AgreementNotFound, #[error("ABI decoding error: {0}")] AbiDecoding(String), - #[error("agreement is cancelled")] - AgreementCancelled, - #[error("invalid voucher: {0}")] - InvalidVoucher(String), + #[error("invalid RCA: {0}")] + InvalidRca(String), + #[error("unsupported metadata version: {0}")] + UnsupportedMetadataVersion(u8), + #[error("agreement deadline {deadline} has already passed (current time: {now})")] + DeadlineExpired { deadline: u64, now: u64 }, + #[error("agreement end time {ends_at} has already passed (current time: {now})")] + AgreementExpired { ends_at: u64, now: u64 }, } #[cfg(feature = "rpc")] @@ -180,14 +205,14 @@ impl From for tonic::Status { } } -impl IndexingAgreementVoucher { +impl CancellationRequest { pub fn sign( &self, domain: &Eip712Domain, signer: S, - ) -> anyhow::Result { - let voucher = SignedIndexingAgreementVoucher { - voucher: self.clone(), + ) -> anyhow::Result { + let voucher = SignedCancellationRequest { + request: self.clone(), signature: signer.sign_typed_data_sync(self, domain)?.as_bytes().into(), }; @@ -195,261 +220,259 @@ impl IndexingAgreementVoucher { } } -impl SignedIndexingAgreementVoucher { +impl SignedCancellationRequest { // TODO: Validate all values pub fn validate( &self, - signer_validator: &Arc, domain: &Eip712Domain, - expected_payee: &Address, - allowed_payers: impl AsRef<[Address]>, + expected_signer: &Address, ) -> Result<(), DipsError> { - let sig = Signature::try_from(self.signature.as_ref()) + let sig = Signature::from_str(&self.signature.to_string()) .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; - let payer = self.voucher.payer; let signer = sig - .recover_address_from_prehash(&self.voucher.eip712_signing_hash(domain)) + .recover_address_from_prehash(&self.request.eip712_signing_hash(domain)) .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; - if allowed_payers.as_ref().is_empty() - || !allowed_payers.as_ref().iter().any(|addr| addr.eq(&payer)) - { - return Err(DipsError::PayerNotAuthorised(payer)); - } - - signer_validator - .validate(&payer, &signer) - .map_err(|_| DipsError::SignerNotAuthorised(signer))?; - - if !self.voucher.recipient.eq(expected_payee) { - return Err(DipsError::UnexpectedPayee { - expected: *expected_payee, - actual: self.voucher.recipient, - }); + if signer.ne(expected_signer) { + return Err(DipsError::UnexpectedSigner); } Ok(()) } - pub fn encode_vec(&self) -> Vec { self.abi_encode() } } -impl CancellationRequest { +// === RCA Implementations === + +impl RecurringCollectionAgreement { pub fn sign( &self, domain: &Eip712Domain, signer: S, - ) -> anyhow::Result { - let voucher = SignedCancellationRequest { - request: self.clone(), + ) -> anyhow::Result { + let signed_rca = SignedRecurringCollectionAgreement { + agreement: self.clone(), signature: signer.sign_typed_data_sync(self, domain)?.as_bytes().into(), }; - Ok(voucher) + Ok(signed_rca) } } -impl SignedCancellationRequest { - // TODO: Validate all values +impl SignedRecurringCollectionAgreement { + /// Validate the RCA signature and basic fields. + /// + /// Checks: + /// - EIP-712 signature is valid and recovers to an authorized signer for the payer + /// - Signer is authorized for the payer (via escrow accounts) + /// - Service provider matches expected indexer address pub fn validate( &self, + signer_validator: &Arc, domain: &Eip712Domain, - expected_signer: &Address, + expected_service_provider: &Address, ) -> Result<(), DipsError> { - let sig = Signature::from_str(&self.signature.to_string()) + let sig = Signature::try_from(self.signature.as_ref()) .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; + let payer = self.agreement.payer; let signer = sig - .recover_address_from_prehash(&self.request.eip712_signing_hash(domain)) + .recover_address_from_prehash(&self.agreement.eip712_signing_hash(domain)) .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; - if signer.ne(expected_signer) { - return Err(DipsError::UnexpectedSigner); + signer_validator + .validate(&payer, &signer) + .map_err(|_| DipsError::SignerNotAuthorised(signer))?; + + if !self.agreement.serviceProvider.eq(expected_service_provider) { + return Err(DipsError::UnexpectedServiceProvider { + expected: *expected_service_provider, + actual: self.agreement.serviceProvider, + }); } Ok(()) } - pub fn encode_vec(&self) -> Vec { - self.abi_encode() - } -} -impl SignedCollectionRequest { pub fn encode_vec(&self) -> Vec { self.abi_encode() } } -impl CollectionRequest { - pub fn sign( - &self, - domain: &Eip712Domain, - signer: S, - ) -> anyhow::Result { - let voucher = SignedCollectionRequest { - request: self.clone(), - signature: signer.sign_typed_data_sync(self, domain)?.as_bytes().into(), - }; - - Ok(voucher) - } +/// Convert bytes32 subgraph deployment ID to IPFS CIDv0 string. +/// +/// IPFS CIDv0 format: Qm... (base58-encoded multihash) +/// Multihash format: 0x12 (sha256) + 0x20 (32 bytes) + hash +fn bytes32_to_ipfs_hash(bytes: &[u8; 32]) -> String { + // Prepend multihash prefix: 0x12 (sha256) + 0x20 (32 bytes length) + let mut multihash = vec![0x12, 0x20]; + multihash.extend_from_slice(bytes); + + // Base58 encode + bs58::encode(&multihash).into_string() } -pub async fn validate_and_create_agreement( +/// Validate and create a RecurringCollectionAgreement. +/// +/// Performs validation: +/// - EIP-712 signature verification +/// - IPFS manifest fetching and network validation +/// - Price minimum enforcement +/// +/// Returns the agreement ID if successful, stores in database. +pub async fn validate_and_create_rca( ctx: Arc, domain: &Eip712Domain, - expected_payee: &Address, - allowed_payers: impl AsRef<[Address]>, - voucher: Vec, + expected_service_provider: &Address, + rca_bytes: Vec, ) -> Result { let DipsServerContext { - store, + rca_store, ipfs_fetcher, price_calculator, signer_validator, registry, additional_networks, + .. } = ctx.as_ref(); - let decoded_voucher = SignedIndexingAgreementVoucher::abi_decode(voucher.as_ref()) + + // Decode SignedRCA + let signed_rca = SignedRecurringCollectionAgreement::abi_decode(rca_bytes.as_ref()) .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; + + // Validate signature and basic fields + signed_rca.validate(signer_validator, domain, expected_service_provider)?; + + // Validate deadline hasn't passed + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time before unix epoch") + .as_secs(); + + let deadline: u64 = signed_rca + .agreement + .deadline + .try_into() + .map_err(|_| DipsError::InvalidRca("deadline overflow".to_string()))?; + if deadline < now { + return Err(DipsError::DeadlineExpired { deadline, now }); + } + + // Validate agreement hasn't already expired + let ends_at: u64 = signed_rca + .agreement + .endsAt + .try_into() + .map_err(|_| DipsError::InvalidRca("endsAt overflow".to_string()))?; + if ends_at < now { + return Err(DipsError::AgreementExpired { ends_at, now }); + } + + // Extract agreement ID + let agreement_id = Uuid::from_bytes(signed_rca.agreement.agreementId.into()); + + // Decode metadata let metadata = - SubgraphIndexingVoucherMetadata::abi_decode(decoded_voucher.voucher.metadata.as_ref()) - .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; + AcceptIndexingAgreementMetadata::abi_decode(signed_rca.agreement.metadata.as_ref()) + .map_err(|e| { + DipsError::AbiDecoding(format!( + "Failed to decode AcceptIndexingAgreementMetadata: {e}" + )) + })?; + + // Only support version 1 terms for now + if metadata.version != 1 { + return Err(DipsError::UnsupportedMetadataVersion(metadata.version)); + } - decoded_voucher.validate(signer_validator, domain, expected_payee, allowed_payers)?; + // Decode terms + let terms = IndexingAgreementTermsV1::abi_decode(metadata.terms.as_ref()).map_err(|e| { + DipsError::AbiDecoding(format!("Failed to decode IndexingAgreementTermsV1: {e}")) + })?; - // Extract and parse the agreement ID from the voucher - let agreement_id = Uuid::from_bytes(decoded_voucher.voucher.agreement_id.into()); + // Convert bytes32 deployment ID to IPFS hash + let deployment_id = bytes32_to_ipfs_hash(&metadata.subgraphDeploymentId.0); - let manifest = ipfs_fetcher.fetch(&metadata.subgraphDeploymentId).await?; + // Fetch IPFS manifest + let manifest = ipfs_fetcher.fetch(&deployment_id).await?; - let network = match registry.get_network_by_id(&metadata.chainId) { - Some(network) => network.id.clone(), - None => match additional_networks.get(&metadata.chainId) { - Some(network) => network.clone(), - None => return Err(DipsError::UnsupportedChainId(metadata.chainId)), - }, - }; + // Get network from manifest + let network_name = manifest + .network() + .ok_or_else(|| DipsError::InvalidSubgraphManifest(deployment_id.clone()))?; - match manifest.network() { - Some(manifest_network_name) => { - tracing::debug!( - agreement_id = %agreement_id, - "Subgraph manifest network: {}", manifest_network_name); - if manifest_network_name != network { - return Err(DipsError::InvalidSubgraphManifest( - metadata.subgraphDeploymentId, - )); - } - } - None => { - return Err(DipsError::InvalidSubgraphManifest( - metadata.subgraphDeploymentId, - )) - } + // Validate network is supported + let network_supported = registry.get_network_by_id(network_name).is_some() + || additional_networks.contains_key(network_name); + + if !network_supported { + return Err(DipsError::UnsupportedNetwork(network_name.to_string())); } - let offered_epoch_price = metadata.basePricePerEpoch; - match price_calculator.get_minimum_price(&metadata.chainId) { - Some(price) if offered_epoch_price.lt(&Uint::from(price)) => { - tracing::debug!( + // Validate price minimums + let offered_tokens_per_second = terms.tokensPerSecond; + match price_calculator.get_minimum_price(network_name) { + Some(price) if offered_tokens_per_second.lt(&Uint::from(price)) => { + tracing::info!( agreement_id = %agreement_id, - chain_id = %metadata.chainId, - deployment_id = %metadata.subgraphDeploymentId, - "offered epoch price '{}' is lower than minimum price '{}'", - offered_epoch_price, + network = %network_name, + deployment_id = %deployment_id, + "offered tokens_per_second '{}' is lower than minimum price '{}'", + offered_tokens_per_second, price ); - return Err(DipsError::PricePerEpochTooLow( - network, - price, - offered_epoch_price.to_string(), - )); + return Err(DipsError::TokensPerSecondTooLow { + network: network_name.to_string(), + minimum: price, + offered: offered_tokens_per_second, + }); } Some(_) => {} None => { - tracing::debug!( + tracing::info!( agreement_id = %agreement_id, - chain_id = %metadata.chainId, - deployment_id = %metadata.subgraphDeploymentId, - "chain id '{}' is not supported", - metadata.chainId + network = %network_name, + deployment_id = %deployment_id, + "network '{}' is not configured in price calculator", + network_name ); - return Err(DipsError::UnsupportedChainId(metadata.chainId)); + return Err(DipsError::UnsupportedNetwork(network_name.to_string())); } } - let offered_entity_price = metadata.pricePerEntity; + // Validate entity price minimum + let offered_entity_price = terms.tokensPerEntityPerSecond; if offered_entity_price < price_calculator.entity_price() { - tracing::debug!( + tracing::info!( agreement_id = %agreement_id, - chain_id = %metadata.chainId, - deployment_id = %metadata.subgraphDeploymentId, - "offered entity price '{}' is lower than minimum price '{}'", + network = %network_name, + deployment_id = %deployment_id, + "offered tokens_per_entity_per_second '{}' is lower than minimum price '{}'", offered_entity_price, price_calculator.entity_price() ); - return Err(DipsError::PricePerEntityTooLow( - network, - price_calculator.entity_price(), - offered_entity_price.to_string(), - )); + return Err(DipsError::TokensPerEntityPerSecondTooLow { + minimum: price_calculator.entity_price(), + offered: offered_entity_price, + }); } tracing::debug!( agreement_id = %agreement_id, - chain_id = %metadata.chainId, - deployment_id = %metadata.subgraphDeploymentId, - "creating agreement" + network = %network_name, + deployment_id = %deployment_id, + "creating RCA agreement" ); - store - .create_agreement(decoded_voucher.clone(), metadata) + // Store the raw signed RCA bytes + rca_store + .store_rca(agreement_id, rca_bytes, PROTOCOL_VERSION) .await .map_err(|error| { - tracing::error!(%agreement_id, %error, "failed to create agreement"); - error - })?; - - Ok(agreement_id) -} - -pub async fn validate_and_cancel_agreement( - store: Arc, - domain: &Eip712Domain, - cancellation_request: Vec, -) -> Result { - let decoded_request = SignedCancellationRequest::abi_decode(cancellation_request.as_ref()) - .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; - - // Get the agreement ID from the cancellation request - let agreement_id = Uuid::from_bytes(decoded_request.request.agreement_id.into()); - - let stored_agreement = store.get_by_id(agreement_id).await?.ok_or_else(|| { - tracing::warn!(%agreement_id, "agreement not found"); - DipsError::AgreementNotFound - })?; - - // Get the deployment ID from the stored agreement - let deployment_id = stored_agreement.metadata.subgraphDeploymentId; - - if stored_agreement.cancelled { - tracing::warn!(%agreement_id, %deployment_id, "agreement already cancelled"); - return Err(DipsError::AgreementCancelled); - } - - decoded_request.validate(domain, &stored_agreement.voucher.voucher.payer)?; - - tracing::debug!(%agreement_id, %deployment_id, "cancelling agreement"); - - store - .cancel_agreement(decoded_request) - .await - .map_err(|error| { - tracing::error!(%agreement_id, %deployment_id, %error, "failed to cancel agreement"); + tracing::error!(%agreement_id, %error, "failed to store RCA"); error })?; @@ -458,456 +481,600 @@ pub async fn validate_and_cancel_agreement( #[cfg(test)] mod test { - use std::{ - collections::HashMap, - time::{Duration, SystemTime, UNIX_EPOCH}, - }; + use std::{collections::BTreeMap, sync::Arc}; - use indexer_monitor::EscrowAccounts; - use rand::{distr::Alphanumeric, Rng}; use thegraph_core::alloy::{ - primitives::{Address, ChainId, FixedBytes, U256}, + primitives::{Address, FixedBytes, U256}, signers::local::PrivateKeySigner, - sol_types::{Eip712Domain, SolValue}, + sol_types::SolValue, }; use uuid::Uuid; - pub use crate::store::{AgreementStore, InMemoryAgreementStore}; use crate::{ - dips_agreement_eip712_domain, dips_cancellation_eip712_domain, server::DipsServerContext, - CancellationRequest, DipsError, IndexingAgreementVoucher, SignedIndexingAgreementVoucher, - SubgraphIndexingVoucherMetadata, + ipfs::{FailingIpfsFetcher, MockIpfsFetcher}, + price::PriceCalculator, + rca_eip712_domain, + server::DipsServerContext, + signers::{NoopSignerValidator, RejectingSignerValidator}, + store::{FailingRcaStore, InMemoryRcaStore}, + AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, + RecurringCollectionAgreement, }; - /// The Arbitrum One (mainnet) chain ID (eip155). - const CHAIN_ID_ARBITRUM_ONE: ChainId = 0xa4b1; // 42161 + const CHAIN_ID: u64 = 42161; // Arbitrum One - #[tokio::test] - async fn test_validate_and_create_agreement() -> anyhow::Result<()> { - let deployment_id = "Qmbg1qF4YgHjiVfsVt6a13ddrVcRtWyJQfD4LA3CwHM29f".to_string(); - let payee = PrivateKeySigner::random(); - let payee_addr = payee.address(); - let payer = PrivateKeySigner::random(); - let payer_addr = payer.address(); - - let metadata = SubgraphIndexingVoucherMetadata { - basePricePerEpoch: U256::from(10000_u64), - pricePerEntity: U256::from(100_u64), - protocolNetwork: "eip155:42161".to_string(), - chainId: "mainnet".to_string(), - subgraphDeploymentId: deployment_id, + fn create_test_context() -> Arc { + Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), // Returns "mainnet" + price_calculator: Arc::new(PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + signer_validator: Arc::new(NoopSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }) + } + + fn create_test_rca( + payer: Address, + service_provider: Address, + tokens_per_second: U256, + tokens_per_entity_per_second: U256, + ) -> RecurringCollectionAgreement { + let terms = IndexingAgreementTermsV1 { + tokensPerSecond: tokens_per_second, + tokensPerEntityPerSecond: tokens_per_entity_per_second, }; - let voucher = IndexingAgreementVoucher { - agreement_id: Uuid::now_v7().as_bytes().into(), - payer: payer_addr, - recipient: payee_addr, - service: Address(FixedBytes::ZERO), - maxInitialAmount: U256::from(10000_u64), - maxOngoingAmountPerEpoch: U256::from(10000_u64), - maxEpochsPerCollection: 1000, - minEpochsPerCollection: 1000, - durationEpochs: 1000, - deadline: 10000000, - metadata: metadata.abi_encode().into(), + let metadata = AcceptIndexingAgreementMetadata { + // Any bytes32 works - MockIpfsFetcher ignores the deployment ID + subgraphDeploymentId: FixedBytes::ZERO, + version: 1, + terms: terms.abi_encode().into(), }; - let domain = dips_agreement_eip712_domain(CHAIN_ID_ARBITRUM_ONE); - - let voucher = voucher.sign(&domain, payer)?; - let abi_voucher = voucher.abi_encode(); - let id = Uuid::from_bytes(voucher.voucher.agreement_id.into()); - - let ctx = DipsServerContext::for_testing(); - let actual_id = super::validate_and_create_agreement( - ctx.clone(), - &domain, - &payee_addr, - vec![payer_addr], - abi_voucher, - ) - .await - .unwrap(); - assert_eq!(actual_id, id); - let stored_agreement = ctx.store.get_by_id(actual_id).await.unwrap().unwrap(); + RecurringCollectionAgreement { + agreementId: Uuid::now_v7().as_bytes().into(), + deadline: U256::from(u64::MAX), + endsAt: U256::from(u64::MAX), + payer, + dataService: Address::ZERO, + serviceProvider: service_provider, + maxInitialTokens: U256::from(1000), + maxOngoingTokensPerSecond: U256::from(100), + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + metadata: metadata.abi_encode().into(), + } + } - assert_eq!(voucher, stored_agreement.voucher); - assert!(!stored_agreement.cancelled); - Ok(()) + #[tokio::test] + async fn test_validate_and_create_rca_success() { + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = Uuid::from_bytes(rca.agreementId.into()); + + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); + + let ctx = create_test_context(); + let result = + super::validate_and_create_rca(ctx.clone(), &domain, &service_provider, rca_bytes) + .await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), agreement_id); + + // Verify it was stored + let store = ctx.rca_store.as_ref(); + let in_memory = store.as_any().downcast_ref::().unwrap(); + let data = in_memory.data.read().await; + assert_eq!(data.len(), 1); + assert_eq!(data[0].0, agreement_id); } - #[test] - fn voucher_signature_verification() { - let ctx = DipsServerContext::for_testing(); - let deployment_id = "Qmbg1qF4YgHjiVfsVt6a13ddrVcRtWyJQfD4LA3CwHM29f".to_string(); - let payee = PrivateKeySigner::random(); - let payee_addr = payee.address(); - let payer = PrivateKeySigner::random(); - let payer_addr = payer.address(); - - let metadata = SubgraphIndexingVoucherMetadata { - basePricePerEpoch: U256::from(10000_u64), - pricePerEntity: U256::from(100_u64), - protocolNetwork: "eip155:42161".to_string(), - chainId: "eip155:1".to_string(), - subgraphDeploymentId: deployment_id, - }; + #[tokio::test] + async fn test_validate_and_create_rca_wrong_service_provider() { + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let wrong_service_provider = Address::repeat_byte(0x99); + let recurring_collector = Address::repeat_byte(0x22); + + let rca = create_test_rca( + payer, + wrong_service_provider, + U256::from(200), + U256::from(100), + ); - let voucher = IndexingAgreementVoucher { - agreement_id: Uuid::now_v7().as_bytes().into(), - payer: payer_addr, - recipient: payee.address(), - service: Address(FixedBytes::ZERO), - maxInitialAmount: U256::from(10000_u64), - maxOngoingAmountPerEpoch: U256::from(10000_u64), - maxEpochsPerCollection: 1000, - minEpochsPerCollection: 1000, - durationEpochs: 1000, - deadline: 10000000, - metadata: metadata.abi_encode().into(), - }; + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); - let domain = dips_agreement_eip712_domain(CHAIN_ID_ARBITRUM_ONE); - let signed = voucher.sign(&domain, payer).unwrap(); - assert_eq!( - signed - .validate(&ctx.signer_validator, &domain, &payee_addr, vec![]) - .unwrap_err() - .to_string(), - DipsError::PayerNotAuthorised(voucher.payer).to_string() - ); - assert!(signed - .validate( - &ctx.signer_validator, - &domain, - &payee_addr, - vec![payer_addr] - ) - .is_ok()); + let ctx = create_test_context(); + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + + assert!(matches!( + result, + Err(DipsError::UnexpectedServiceProvider { .. }) + )); } #[tokio::test] - async fn check_voucher_modified() { - let payee = PrivateKeySigner::random(); - let payee_addr = payee.address(); - let payer = PrivateKeySigner::random(); - let payer_addr = payer.address(); - let ctx = DipsServerContext::for_testing_mocked_accounts(EscrowAccounts::new( - HashMap::default(), - HashMap::from_iter(vec![(payer_addr, vec![payer_addr])]), - )) - .await; - - let deployment_id = "Qmbg1qF4YgHjiVfsVt6a13ddrVcRtWyJQfD4LA3CwHM29f".to_string(); - - let metadata = SubgraphIndexingVoucherMetadata { - basePricePerEpoch: U256::from(10000_u64), - pricePerEntity: U256::from(100_u64), - protocolNetwork: "eip155:42161".to_string(), - chainId: "eip155:1".to_string(), - subgraphDeploymentId: deployment_id, - }; + async fn test_validate_and_create_rca_tokens_per_second_too_low() { + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); - let voucher = IndexingAgreementVoucher { - agreement_id: Uuid::now_v7().as_bytes().into(), - payer: payer_addr, - recipient: payee_addr, - service: Address(FixedBytes::ZERO), - maxInitialAmount: U256::from(10000_u64), - maxOngoingAmountPerEpoch: U256::from(10000_u64), - maxEpochsPerCollection: 1000, - minEpochsPerCollection: 1000, - durationEpochs: 1000, - deadline: 10000000, - metadata: metadata.abi_encode().into(), - }; - let domain = dips_agreement_eip712_domain(CHAIN_ID_ARBITRUM_ONE); + // Offer 50, minimum is 100 + let rca = create_test_rca(payer, service_provider, U256::from(50), U256::from(100)); + + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); - let mut signed = voucher.sign(&domain, payer).unwrap(); - signed.voucher.service = Address::repeat_byte(9); + let ctx = create_test_context(); + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; assert!(matches!( - signed - .validate( - &ctx.signer_validator, - &domain, - &payee_addr, - vec![payer_addr] - ) - .unwrap_err(), - DipsError::SignerNotAuthorised(_) + result, + Err(DipsError::TokensPerSecondTooLow { .. }) )); } - #[test] - fn cancel_voucher_validation() { - let payer = PrivateKeySigner::random(); - let payer_addr = payer.address(); - let other_signer = PrivateKeySigner::random(); - - struct Case<'a> { - name: &'a str, - signer: PrivateKeySigner, - error: Option, - } + #[tokio::test] + async fn test_validate_and_create_rca_entity_price_too_low() { + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); - let cases: Vec = vec![ - Case { - name: "happy path payer", - signer: payer.clone(), - error: None, - }, - Case { - name: "invalid signer", - signer: other_signer.clone(), - error: Some(DipsError::SignerNotAuthorised(other_signer.address())), - }, - ]; - - for Case { - name, - signer, - error, - } in cases.into_iter() - { - let voucher = CancellationRequest { - agreement_id: Uuid::now_v7().as_bytes().into(), - }; - let domain = dips_cancellation_eip712_domain(CHAIN_ID_ARBITRUM_ONE); - - let signed = voucher.sign(&domain, signer).unwrap(); - - let res = signed.validate(&domain, &payer_addr); - match error { - Some(_err) => assert!(matches!(res.unwrap_err(), _err), "case: {name}"), - None => assert!(res.is_ok(), "case: {}, err: {}", name, res.unwrap_err()), - } - } + // Offer 200 tokens/sec (ok), but only 10 entity price (minimum is 50) + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(10)); + + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); + + let ctx = create_test_context(); + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + + assert!(matches!( + result, + Err(DipsError::TokensPerEntityPerSecondTooLow { .. }) + )); } - struct VoucherContext { - payee: PrivateKeySigner, - payer: PrivateKeySigner, - deployment_id: String, - } - - impl VoucherContext { - pub fn random() -> Self { - Self { - payee: PrivateKeySigner::random(), - payer: PrivateKeySigner::random(), - deployment_id: rand::rng() - .sample_iter(&Alphanumeric) - .take(32) - .map(char::from) - .collect(), - } - } - pub fn domain(&self) -> Eip712Domain { - dips_agreement_eip712_domain(CHAIN_ID_ARBITRUM_ONE) - } - pub fn test_voucher_with_signer( - &self, - metadata: SubgraphIndexingVoucherMetadata, - signer: PrivateKeySigner, - ) -> SignedIndexingAgreementVoucher { - let agreement_id = Uuid::now_v7(); - - let domain = dips_agreement_eip712_domain(CHAIN_ID_ARBITRUM_ONE); - - let voucher = IndexingAgreementVoucher { - agreement_id: agreement_id.as_bytes().into(), - payer: self.payer.address(), - recipient: self.payee.address(), - service: Address::ZERO, - durationEpochs: 100, - maxInitialAmount: U256::from(1000000_u64), - maxOngoingAmountPerEpoch: U256::from(10000_u64), - minEpochsPerCollection: 1, - maxEpochsPerCollection: 10, - deadline: (SystemTime::now() + Duration::from_secs(3600)) - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(), - metadata: metadata.abi_encode().into(), - }; - - voucher.sign(&domain, signer).unwrap() - } + #[tokio::test] + async fn test_validate_and_create_rca_unsupported_network() { + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); + + // Create context with IPFS fetcher returning unsupported network + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher { + network: "unsupported-network".to_string(), + }), + price_calculator: Arc::new(PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + signer_validator: Arc::new(NoopSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); - pub fn test_voucher( - &self, - metadata: SubgraphIndexingVoucherMetadata, - ) -> SignedIndexingAgreementVoucher { - self.test_voucher_with_signer(metadata, self.payer.clone()) - } + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + + assert!(matches!(result, Err(DipsError::UnsupportedNetwork(_)))); } #[tokio::test] - async fn test_create_and_cancel_agreement() -> anyhow::Result<()> { - let ctx = DipsServerContext::for_testing(); - let voucher_ctx = VoucherContext::random(); - - // Create metadata and voucher - let metadata = SubgraphIndexingVoucherMetadata { - basePricePerEpoch: U256::from(10000_u64), - pricePerEntity: U256::from(100_u64), - protocolNetwork: "eip155:42161".to_string(), - chainId: "mainnet".to_string(), - subgraphDeploymentId: voucher_ctx.deployment_id.clone(), + async fn test_validate_and_create_rca_invalid_metadata_version() { + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + + let terms = IndexingAgreementTermsV1 { + tokensPerSecond: U256::from(200), + tokensPerEntityPerSecond: U256::from(100), }; - let signed_voucher = voucher_ctx.test_voucher(metadata); - - // Create agreement - let agreement_id = super::validate_and_create_agreement( - ctx.clone(), - &voucher_ctx.domain(), - &voucher_ctx.payee.address(), - vec![voucher_ctx.payer.address()], - signed_voucher.encode_vec(), - ) - .await?; - - // Create and sign cancellation request - let cancel_domain = dips_cancellation_eip712_domain(CHAIN_ID_ARBITRUM_ONE); - let cancel_request = CancellationRequest { - agreement_id: agreement_id.as_bytes().into(), + + // Use version 2 (unsupported) + let metadata = AcceptIndexingAgreementMetadata { + subgraphDeploymentId: FixedBytes::ZERO, + version: 2, // Unsupported version + terms: terms.abi_encode().into(), }; - let signed_cancel = cancel_request.sign(&cancel_domain, voucher_ctx.payer)?; - // Cancel agreement - let cancelled_id = super::validate_and_cancel_agreement( - ctx.store.clone(), - &cancel_domain, - signed_cancel.encode_vec(), - ) - .await?; + let rca = RecurringCollectionAgreement { + agreementId: Uuid::now_v7().as_bytes().into(), + deadline: U256::from(u64::MAX), + endsAt: U256::from(u64::MAX), + payer, + dataService: Address::ZERO, + serviceProvider: service_provider, + maxInitialTokens: U256::from(1000), + maxOngoingTokensPerSecond: U256::from(100), + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + metadata: metadata.abi_encode().into(), + }; - assert_eq!(agreement_id, cancelled_id); + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); - // Verify agreement is cancelled - let stored_agreement = ctx.store.get_by_id(agreement_id).await?.unwrap(); - assert!(stored_agreement.cancelled); + let ctx = create_test_context(); + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; - Ok(()) + assert!(matches!( + result, + Err(DipsError::UnsupportedMetadataVersion(2)) + )); } #[tokio::test] - async fn test_create_validations_errors() -> anyhow::Result<()> { - let voucher_ctx = VoucherContext::random(); - let ctx = DipsServerContext::for_testing_mocked_accounts(EscrowAccounts::new( - HashMap::default(), - HashMap::from_iter(vec![( - voucher_ctx.payer.address(), - vec![voucher_ctx.payer.address()], - )]), - )) - .await; - let no_network_ctx = - DipsServerContext::for_testing_mocked_accounts_no_network(EscrowAccounts::new( - HashMap::default(), - HashMap::from_iter(vec![( - voucher_ctx.payer.address(), - vec![voucher_ctx.payer.address()], - )]), - )) - .await; - - let metadata = SubgraphIndexingVoucherMetadata { - basePricePerEpoch: U256::from(10000_u64), - pricePerEntity: U256::from(100_u64), - protocolNetwork: "eip155:42161".to_string(), - chainId: "mainnet".to_string(), - subgraphDeploymentId: voucher_ctx.deployment_id.clone(), + async fn test_validate_and_create_rca_deadline_expired() { + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + + let terms = IndexingAgreementTermsV1 { + tokensPerSecond: U256::from(200), + tokensPerEntityPerSecond: U256::from(100), + }; + + let metadata = AcceptIndexingAgreementMetadata { + subgraphDeploymentId: FixedBytes::ZERO, + version: 1, + terms: terms.abi_encode().into(), }; - // The voucher says mainnet, but the manifest has no network - let no_network_voucher = voucher_ctx.test_voucher(metadata); - - let metadata = SubgraphIndexingVoucherMetadata { - basePricePerEpoch: U256::from(10000_u64), - pricePerEntity: U256::from(10_u64), - protocolNetwork: "eip155:42161".to_string(), - chainId: "mainnet".to_string(), - subgraphDeploymentId: voucher_ctx.deployment_id.clone(), + + // Set deadline to the past + let rca = RecurringCollectionAgreement { + agreementId: Uuid::now_v7().as_bytes().into(), + deadline: U256::from(1), // 1 second after epoch - definitely in the past + endsAt: U256::from(u64::MAX), + payer, + dataService: Address::ZERO, + serviceProvider: service_provider, + maxInitialTokens: U256::from(1000), + maxOngoingTokensPerSecond: U256::from(100), + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + metadata: metadata.abi_encode().into(), }; - let low_entity_price_voucher = voucher_ctx.test_voucher(metadata); + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); + + let ctx = create_test_context(); + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + + assert!(matches!(result, Err(DipsError::DeadlineExpired { .. }))); + } - let metadata = SubgraphIndexingVoucherMetadata { - basePricePerEpoch: U256::from(10_u64), - pricePerEntity: U256::from(10000_u64), - protocolNetwork: "eip155:42161".to_string(), - chainId: "mainnet".to_string(), - subgraphDeploymentId: voucher_ctx.deployment_id.clone(), + #[tokio::test] + async fn test_validate_and_create_rca_agreement_expired() { + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + + let terms = IndexingAgreementTermsV1 { + tokensPerSecond: U256::from(200), + tokensPerEntityPerSecond: U256::from(100), }; - let low_epoch_price_voucher = voucher_ctx.test_voucher(metadata); + let metadata = AcceptIndexingAgreementMetadata { + subgraphDeploymentId: FixedBytes::ZERO, + version: 1, + terms: terms.abi_encode().into(), + }; - let metadata = SubgraphIndexingVoucherMetadata { - basePricePerEpoch: U256::from(10000_u64), - pricePerEntity: U256::from(100_u64), - protocolNetwork: "eip155:42161".to_string(), - chainId: "mainnet".to_string(), - subgraphDeploymentId: voucher_ctx.deployment_id.clone(), + // Set endsAt to the past + let rca = RecurringCollectionAgreement { + agreementId: Uuid::now_v7().as_bytes().into(), + deadline: U256::from(u64::MAX), + endsAt: U256::from(1), // 1 second after epoch - definitely in the past + payer, + dataService: Address::ZERO, + serviceProvider: service_provider, + maxInitialTokens: U256::from(1000), + maxOngoingTokensPerSecond: U256::from(100), + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + metadata: metadata.abi_encode().into(), }; - let signer = PrivateKeySigner::random(); - let valid_voucher_invalid_signer = - voucher_ctx.test_voucher_with_signer(metadata.clone(), signer.clone()); - let valid_voucher = voucher_ctx.test_voucher(metadata); + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); - let contexts = vec![no_network_ctx, ctx.clone(), ctx.clone(), ctx.clone()]; + let ctx = create_test_context(); + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; - let expected_result: Vec> = vec![ - Err(DipsError::InvalidSubgraphManifest( - voucher_ctx.deployment_id.clone(), + assert!(matches!(result, Err(DipsError::AgreementExpired { .. }))); + } + + // ========================================================================= + // Additional tests for complete coverage (following test-arrange-act-assert) + // ========================================================================= + + #[tokio::test] + async fn test_validate_and_create_rca_malformed_abi() { + // Arrange + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let ctx = create_test_context(); + + let malformed_bytes = vec![0xDE, 0xAD, 0xBE, 0xEF]; // Not valid ABI + + // Act + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, malformed_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::AbiDecoding(_))), + "Expected AbiDecoding error, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_validate_and_create_rca_unauthorized_signer() { + // Arrange + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); + + // Context with rejecting signer validator + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), )), - Err(DipsError::PricePerEntityTooLow( - "mainnet".to_string(), - U256::from(100), - "10".to_string(), + signer_validator: Arc::new(RejectingSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + // Act + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::SignerNotAuthorised(_))), + "Expected SignerNotAuthorised error, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_validate_and_create_rca_ipfs_failure() { + // Arrange + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); + + // Context with failing IPFS fetcher + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(FailingIpfsFetcher), + price_calculator: Arc::new(PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), )), - Err(DipsError::PricePerEpochTooLow( - "mainnet".to_string(), - U256::from(200), - "10".to_string(), + signer_validator: Arc::new(NoopSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + // Act + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::SubgraphManifestUnavailable(_))), + "Expected SubgraphManifestUnavailable error, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_validate_and_create_rca_manifest_no_network() { + // Arrange + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); + + // Context with IPFS fetcher returning manifest without network + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher::no_network()), + price_calculator: Arc::new(PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), )), - Err(DipsError::SignerNotAuthorised(signer.address())), - Ok(valid_voucher - .voucher - .agreement_id - .as_slice() - .try_into() - .unwrap()), - ]; - let cases = vec![ - no_network_voucher, - low_entity_price_voucher, - low_epoch_price_voucher, - valid_voucher_invalid_signer, - valid_voucher, - ]; - for ((voucher, result), dips_ctx) in cases - .into_iter() - .zip(expected_result.into_iter()) - .zip(contexts.into_iter()) - { - let out = super::validate_and_create_agreement( - dips_ctx.clone(), - &voucher_ctx.domain(), - &voucher_ctx.payee.address(), - vec![voucher_ctx.payer.address()], - voucher.encode_vec(), - ) - .await; - - match (out, result) { - (Ok(a), Ok(b)) => assert_eq!(a.into_bytes(), b), - (Err(a), Err(b)) => assert_eq!(a.to_string(), b.to_string()), - (a, b) => panic!("{a:?} did not match {b:?}"), - } - } + signer_validator: Arc::new(NoopSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + // Act + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::InvalidSubgraphManifest(_))), + "Expected InvalidSubgraphManifest error, got: {:?}", + result + ); + } - Ok(()) + #[tokio::test] + async fn test_validate_and_create_rca_store_failure() { + // Arrange + let payer_signer = PrivateKeySigner::random(); + let payer = payer_signer.address(); + let service_provider = Address::repeat_byte(0x11); + let recurring_collector = Address::repeat_byte(0x22); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); + + // Context with failing store + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(FailingRcaStore), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + signer_validator: Arc::new(NoopSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + // Act + let result = + super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::UnknownError(_))), + "Expected UnknownError from store failure, got: {:?}", + result + ); + } + + // ========================================================================= + // Unit tests for helper functions + // ========================================================================= + + #[test] + fn test_bytes32_to_ipfs_hash_format() { + // Arrange + let bytes: [u8; 32] = [0xAB; 32]; + + // Act + let hash = super::bytes32_to_ipfs_hash(&bytes); + + // Assert - CIDv0 format starts with "Qm" and is 46 characters + assert!( + hash.starts_with("Qm"), + "IPFS CIDv0 should start with 'Qm', got: {}", + hash + ); + assert_eq!( + hash.len(), + 46, + "IPFS CIDv0 should be 46 characters, got: {}", + hash.len() + ); + } + + #[test] + fn test_bytes32_to_ipfs_hash_deterministic() { + // Arrange + let bytes: [u8; 32] = [0x12; 32]; + + // Act + let hash1 = super::bytes32_to_ipfs_hash(&bytes); + let hash2 = super::bytes32_to_ipfs_hash(&bytes); + + // Assert + assert_eq!(hash1, hash2, "Same input should produce same output"); + } + + #[test] + fn test_bytes32_to_ipfs_hash_different_inputs() { + // Arrange + let bytes1: [u8; 32] = [0x00; 32]; + let bytes2: [u8; 32] = [0xFF; 32]; + + // Act + let hash1 = super::bytes32_to_ipfs_hash(&bytes1); + let hash2 = super::bytes32_to_ipfs_hash(&bytes2); + + // Assert + assert_ne!( + hash1, hash2, + "Different inputs should produce different outputs" + ); + } + + #[test] + fn test_bytes32_to_ipfs_hash_known_vector() { + // Arrange - all zeros should produce a known hash + // Multihash: 0x12 (sha256) + 0x20 (32 bytes) + 32 zero bytes + // Base58 encoding of [0x12, 0x20, 0x00 * 32] + let bytes: [u8; 32] = [0x00; 32]; + + // Act + let hash = super::bytes32_to_ipfs_hash(&bytes); + + // Assert - verified by manual calculation + // The multihash [0x12, 0x20, 0, 0, ...] encodes to this CIDv0 + assert_eq!( + hash, "QmNLei78zWmzUdbeRB3CiUfAizWUrbeeZh5K1rhAQKCh51", + "Known test vector mismatch" + ); } } diff --git a/crates/dips/src/price.rs b/crates/dips/src/price.rs index 5cf44164a..15ef2b773 100644 --- a/crates/dips/src/price.rs +++ b/crates/dips/src/price.rs @@ -1,43 +1,168 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 +//! Minimum price enforcement for RCA proposals. +//! +//! Indexers configure minimum acceptable prices for their services. This module +//! validates that RCA proposals meet these minimums before acceptance. +//! +//! # Pricing Model +//! +//! RCAs specify two pricing components: +//! +//! - **tokens_per_second** - Base rate for the indexing service, per network +//! - **tokens_per_entity_per_second** - Additional rate based on indexed entities +//! +//! Both values are in wei GRT (10^-18 GRT). The indexer configures minimum +//! acceptable values; proposals offering less are rejected. +//! +//! # Per-Network Pricing +//! +//! Different networks have different operational costs (RPC fees, storage, etc.). +//! The `tokens_per_second` minimum is configured per network: +//! +//! ```toml +//! [dips.tokens_per_second] +//! mainnet = "1000000000000" # Higher cost chain +//! arbitrum-one = "500000000000" # Lower cost L2 +//! ``` +//! +//! Networks not in this map are considered unsupported and will be rejected. + use std::collections::BTreeMap; use thegraph_core::alloy::primitives::U256; #[derive(Debug, Default)] pub struct PriceCalculator { - base_price_per_epoch: BTreeMap, - price_per_entity: U256, + tokens_per_second: BTreeMap, + tokens_per_entity_per_second: U256, } impl PriceCalculator { - pub fn new(base_price_per_epoch: BTreeMap, price_per_entity: U256) -> Self { + pub fn new( + tokens_per_second: BTreeMap, + tokens_per_entity_per_second: U256, + ) -> Self { Self { - base_price_per_epoch, - price_per_entity, + tokens_per_second, + tokens_per_entity_per_second, } } #[cfg(test)] pub fn for_testing() -> Self { Self { - base_price_per_epoch: BTreeMap::from_iter(vec![( - "mainnet".to_string(), - U256::from(200), - )]), - price_per_entity: U256::from(100), + tokens_per_second: BTreeMap::from_iter(vec![("mainnet".to_string(), U256::from(200))]), + tokens_per_entity_per_second: U256::from(100), } } - pub fn is_supported(&self, chain_id: &str) -> bool { - self.get_minimum_price(chain_id).is_some() + pub fn is_supported(&self, network: &str) -> bool { + self.get_minimum_price(network).is_some() } - pub fn get_minimum_price(&self, chain_id: &str) -> Option { - self.base_price_per_epoch.get(chain_id).copied() + + pub fn get_minimum_price(&self, network: &str) -> Option { + self.tokens_per_second.get(network).copied() } pub fn entity_price(&self) -> U256 { - self.price_per_entity + self.tokens_per_entity_per_second + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_minimum_price_existing_network() { + // Arrange + let calculator = PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(1000))]), + U256::from(50), + ); + + // Act + let price = calculator.get_minimum_price("mainnet"); + + // Assert + assert_eq!(price, Some(U256::from(1000))); + } + + #[test] + fn test_get_minimum_price_missing_network() { + // Arrange + let calculator = PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(1000))]), + U256::from(50), + ); + + // Act + let price = calculator.get_minimum_price("arbitrum-one"); + + // Assert + assert_eq!(price, None); + } + + #[test] + fn test_is_supported_true() { + // Arrange + let calculator = PriceCalculator::new( + BTreeMap::from([ + ("mainnet".to_string(), U256::from(1000)), + ("arbitrum-one".to_string(), U256::from(500)), + ]), + U256::from(50), + ); + + // Act & Assert + assert!(calculator.is_supported("mainnet")); + assert!(calculator.is_supported("arbitrum-one")); + } + + #[test] + fn test_is_supported_false() { + // Arrange + let calculator = PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(1000))]), + U256::from(50), + ); + + // Act & Assert + assert!(!calculator.is_supported("optimism")); + assert!(!calculator.is_supported("")); + } + + #[test] + fn test_entity_price() { + // Arrange + let calculator = PriceCalculator::new(BTreeMap::new(), U256::from(12345)); + + // Act + let price = calculator.entity_price(); + + // Assert + assert_eq!(price, U256::from(12345)); + } + + #[test] + fn test_empty_networks_map() { + // Arrange + let calculator = PriceCalculator::new(BTreeMap::new(), U256::from(100)); + + // Act & Assert + assert!(!calculator.is_supported("mainnet")); + assert_eq!(calculator.get_minimum_price("mainnet"), None); + } + + #[test] + fn test_default() { + // Arrange & Act + let calculator = PriceCalculator::default(); + + // Assert + assert!(!calculator.is_supported("mainnet")); + assert_eq!(calculator.entity_price(), U256::ZERO); } } diff --git a/crates/dips/src/proto/gateway.rs b/crates/dips/src/proto/gateway.rs index fd13ab498..e7adfe111 100644 --- a/crates/dips/src/proto/gateway.rs +++ b/crates/dips/src/proto/gateway.rs @@ -1,3 +1,8 @@ +//! Gateway DIPS protocol types (auto-generated). +//! +//! These types define the Gateway-to-Dipper interface. Not used by indexer-rs +//! but included for completeness as both services share the same proto package. + // This file is @generated by prost-build. pub mod graphprotocol { pub mod gateway { diff --git a/crates/dips/src/proto/indexer.rs b/crates/dips/src/proto/indexer.rs index 8edb4be22..1782ee530 100644 --- a/crates/dips/src/proto/indexer.rs +++ b/crates/dips/src/proto/indexer.rs @@ -1,3 +1,11 @@ +//! Indexer DIPS protocol types (auto-generated). +//! +//! These types define the Dipper-to-indexer interface. The indexer-service +//! implements `IndexerDipsService` to handle: +//! +//! - `submit_agreement_proposal` - Receive and validate RCA proposals +//! - `cancel_agreement` - Unimplemented (cancellation is on-chain) + // This file is @generated by prost-build. pub mod graphprotocol { pub mod indexer { diff --git a/crates/dips/src/proto/mod.rs b/crates/dips/src/proto/mod.rs index 873e26690..1c4a03c0f 100644 --- a/crates/dips/src/proto/mod.rs +++ b/crates/dips/src/proto/mod.rs @@ -1,2 +1,12 @@ +//! Protocol buffer definitions for DIPS gRPC services. +//! +//! This module re-exports auto-generated protobuf types from prost-build. +//! The `.proto` files define two service interfaces: +//! +//! - **gateway** - Gateway-to-Dipper communication (not used by indexer-rs) +//! - **indexer** - Dipper-to-indexer communication ([`IndexerDipsService`]) +//! +//! The indexer service implements `IndexerDipsService` to receive RCA proposals. + pub mod gateway; pub mod indexer; diff --git a/crates/dips/src/registry.rs b/crates/dips/src/registry.rs index bda4579b1..2260c5d57 100644 --- a/crates/dips/src/registry.rs +++ b/crates/dips/src/registry.rs @@ -1,6 +1,15 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 +//! Test helpers for network registry validation. +//! +//! The Graph maintains a registry of supported networks. During RCA validation, +//! we check that the subgraph's network is in this registry (or in the indexer's +//! `additional_networks` config for custom/test networks). +//! +//! This module provides [`test_registry`] which returns a minimal registry +//! containing "mainnet" and "hardhat" for use in unit tests. + use graph_networks_registry::NetworksRegistry; pub fn test_registry() -> NetworksRegistry { diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 6b6f6e24a..aeb2f1a8e 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -1,17 +1,48 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::{collections::HashMap, sync::Arc}; +//! gRPC server for DIPS RCA proposals. +//! +//! This module implements the `IndexerDipsService` gRPC interface that receives +//! RecurringCollectionAgreement (RCA) proposals from the Dipper service. +//! +//! # Request Flow +//! +//! ```text +//! Dipper ──gRPC──> DipsServer::submit_agreement_proposal() +//! │ +//! ├─ Version check (must be 2) +//! ├─ Size validation (non-empty, max 10KB) +//! ├─ Signature verification +//! ├─ Signer authorization check +//! ├─ Timestamp validation (deadline, endsAt) +//! ├─ IPFS manifest fetch +//! ├─ Network validation +//! ├─ Price validation +//! │ +//! └─> Store in pending_rca_proposals table +//! │ +//! └─> Return Accept/Reject +//! ``` +//! +//! # Response Behavior +//! +//! Returns `Accept` if the RCA passes all validation and is stored successfully. +//! Returns `Reject` if any validation fails. This enables the Dipper to reassign +//! the indexing request to another indexer on rejection. +//! +//! # Cancellation +//! +//! The `cancel_agreement` endpoint is unimplemented. Cancellation is handled +//! on-chain via the RecurringCollector contract, not through this gRPC interface. + +use std::{collections::BTreeMap, sync::Arc}; use async_trait::async_trait; -use graph_networks_registry::NetworksRegistry; -#[cfg(test)] -use indexer_monitor::EscrowAccounts; -use thegraph_core::alloy::primitives::{Address, ChainId}; +use thegraph_core::alloy::primitives::Address; use tonic::{Request, Response, Status}; use crate::{ - dips_agreement_eip712_domain, dips_cancellation_eip712_domain, ipfs::IpfsFetcher, price::PriceCalculator, proto::indexer::graphprotocol::indexer::dips::{ @@ -20,80 +51,60 @@ use crate::{ SubmitAgreementProposalResponse, }, signers::SignerValidator, - store::AgreementStore, - validate_and_cancel_agreement, validate_and_create_agreement, DipsError, PROTOCOL_VERSION, + store::RcaStore, }; -#[derive(Debug)] +/// Context for DIPS server with all validation dependencies. +/// +/// Used for RCA validation: +/// - Signature verification +/// - IPFS manifest fetching +/// - Price minimum enforcement +/// - Network registry lookups +#[derive(Debug, Clone)] pub struct DipsServerContext { - pub store: Arc, + /// RCA store (seconds-based RCA) + pub rca_store: Arc, + /// IPFS client for fetching subgraph manifests pub ipfs_fetcher: Arc, - pub price_calculator: PriceCalculator, + /// Price calculator for validating minimum prices + pub price_calculator: Arc, + /// Signature validator for EIP-712 verification pub signer_validator: Arc, - pub registry: Arc, - pub additional_networks: Arc>, -} - -impl DipsServerContext { - #[cfg(test)] - pub fn for_testing() -> Arc { - use std::sync::Arc; - - use crate::{ - ipfs::TestIpfsClient, registry::test_registry, signers, test::InMemoryAgreementStore, - }; - - Arc::new(DipsServerContext { - store: Arc::new(InMemoryAgreementStore::default()), - ipfs_fetcher: Arc::new(TestIpfsClient::mainnet()), - price_calculator: PriceCalculator::for_testing(), - signer_validator: Arc::new(signers::NoopSignerValidator), - registry: Arc::new(test_registry()), - additional_networks: Arc::new(HashMap::new()), - }) - } - - #[cfg(test)] - pub async fn for_testing_mocked_accounts(accounts: EscrowAccounts) -> Arc { - use crate::{ipfs::TestIpfsClient, signers, test::InMemoryAgreementStore}; - - Arc::new(DipsServerContext { - store: Arc::new(InMemoryAgreementStore::default()), - ipfs_fetcher: Arc::new(TestIpfsClient::mainnet()), - price_calculator: PriceCalculator::for_testing(), - signer_validator: Arc::new(signers::EscrowSignerValidator::mock(accounts).await), - registry: Arc::new(crate::registry::test_registry()), - additional_networks: Arc::new(HashMap::new()), - }) - } - - #[cfg(test)] - pub async fn for_testing_mocked_accounts_no_network(accounts: EscrowAccounts) -> Arc { - use crate::{ - ipfs::TestIpfsClient, registry::test_registry, signers, test::InMemoryAgreementStore, - }; - - Arc::new(DipsServerContext { - store: Arc::new(InMemoryAgreementStore::default()), - ipfs_fetcher: Arc::new(TestIpfsClient::no_network()), - price_calculator: PriceCalculator::for_testing(), - signer_validator: Arc::new(signers::EscrowSignerValidator::mock(accounts).await), - registry: Arc::new(test_registry()), - additional_networks: Arc::new(HashMap::new()), - }) - } + /// Network registry for supported networks + pub registry: Arc, + /// Additional networks beyond the registry + pub additional_networks: Arc>, } +/// DIPS server implementing RCA protocol. +/// +/// Validates RecurringCollectionAgreement proposals before storage: +/// - EIP-712 signature verification +/// - IPFS manifest fetching and network validation +/// - Price minimum enforcement +/// +/// Returns Accept/Reject to enable Dipper reassignment on rejection. #[derive(Debug)] pub struct DipsServer { pub ctx: Arc, pub expected_payee: Address, - pub allowed_payers: Vec
, - pub chain_id: ChainId, + pub chain_id: u64, + /// RecurringCollector contract address for EIP-712 domain + pub recurring_collector: Address, } #[async_trait] impl IndexerDipsService for DipsServer { + /// Submit an RCA proposal. + /// + /// Validates: + /// - Version 2 only + /// - EIP-712 signature + /// - IPFS manifest and network compatibility + /// - Price minimums + /// + /// Returns Accept/Reject based on validation results. async fn submit_agreement_proposal( &self, request: Request, @@ -103,85 +114,181 @@ impl IndexerDipsService for DipsServer { signed_voucher, } = request.into_inner(); - // Ensure the version is 1 - if version != PROTOCOL_VERSION { - return Err(Status::invalid_argument("invalid version")); + // Only accept version 2 + if version != 2 { + return Err(Status::invalid_argument(format!( + "Unsupported version {}. Only version 2 (RecurringCollectionAgreement) is supported.", + version + ))); + } + + // Basic sanity checks + if signed_voucher.is_empty() { + return Err(Status::invalid_argument("signed_voucher cannot be empty")); + } + + if signed_voucher.len() > 10_000 { + return Err(Status::invalid_argument( + "signed_voucher exceeds maximum size of 10KB", + )); } - // TODO: Validate that: - // - The price is over the configured minimum price - // - The subgraph deployment is for a chain we support - // - The subgraph deployment is available on IPFS - let response = validate_and_create_agreement( + // Validate and store RCA + let domain = crate::rca_eip712_domain(self.chain_id, self.recurring_collector); + match crate::validate_and_create_rca( self.ctx.clone(), - &dips_agreement_eip712_domain(self.chain_id), + &domain, &self.expected_payee, - &self.allowed_payers, signed_voucher, ) - .await; - - match response { - Ok(_) => Ok(Response::new(SubmitAgreementProposalResponse { - response: ProposalResponse::Accept.into(), - })), - Err(e) => match e { - // Invalid signature/authorization errors - DipsError::InvalidSignature(msg) => Err(Status::invalid_argument(format!( - "invalid signature: {msg}" - ))), - DipsError::PayerNotAuthorised(addr) => Err(Status::invalid_argument(format!( - "payer {addr} not authorized" - ))), - DipsError::UnexpectedPayee { expected, actual } => Err(Status::invalid_argument( - format!("voucher payee {actual} does not match expected address {expected}"), - )), - DipsError::SignerNotAuthorised(addr) => Err(Status::invalid_argument(format!( - "signer {addr} not authorized" - ))), - - // Deployment/manifest related errors - these should return Reject - DipsError::SubgraphManifestUnavailable(_) - | DipsError::InvalidSubgraphManifest(_) - | DipsError::UnsupportedChainId(_) - | DipsError::PricePerEpochTooLow(_, _, _) - | DipsError::PricePerEntityTooLow(_, _, _) => { - Ok(Response::new(SubmitAgreementProposalResponse { - response: ProposalResponse::Reject.into(), - })) - } - - // Other errors - DipsError::AbiDecoding(msg) => Err(Status::invalid_argument(format!( - "invalid request voucher: {msg}" - ))), - _ => Err(Status::internal(e.to_string())), - }, + .await + { + Ok(agreement_id) => { + tracing::info!(%agreement_id, "RCA accepted"); + Ok(Response::new(SubmitAgreementProposalResponse { + response: ProposalResponse::Accept.into(), + })) + } + Err(e) => { + tracing::warn!(error = %e, "RCA rejected"); + Ok(Response::new(SubmitAgreementProposalResponse { + response: ProposalResponse::Reject.into(), + })) + } } } - /// * - /// Request to cancel an existing _indexing agreement_. + + /// Cancel agreement - unimplemented. + /// + /// Cancellation is handled on-chain via the RecurringCollector contract. async fn cancel_agreement( &self, - request: Request, + _request: Request, ) -> Result, Status> { - let CancelAgreementRequest { - version, - signed_cancellation, - } = request.into_inner(); + Err(Status::unimplemented( + "Cancellation is handled on-chain via RecurringCollector contract", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + ipfs::MockIpfsFetcher, price::PriceCalculator, signers::NoopSignerValidator, + store::InMemoryRcaStore, + }; + + impl DipsServerContext { + pub fn for_testing() -> Arc { + use std::collections::BTreeMap; + use thegraph_core::alloy::primitives::U256; - if version != 1 { - return Err(Status::invalid_argument("invalid version")); + Arc::new(Self { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + BTreeMap::from([("mainnet".to_string(), U256::from(200))]), + U256::from(100), + )), + signer_validator: Arc::new(NoopSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }) } + } - validate_and_cancel_agreement( - self.ctx.store.clone(), - &dips_cancellation_eip712_domain(self.chain_id), - signed_cancellation, - ) - .await - .map_err(Into::::into)?; + #[tokio::test] + async fn test_empty_rejected() { + // Arrange + let ctx = DipsServerContext::for_testing(); + let server = DipsServer { + ctx, + expected_payee: Address::ZERO, + chain_id: 1, + recurring_collector: Address::ZERO, + }; + let request = Request::new(SubmitAgreementProposalRequest { + version: 2, + signed_voucher: vec![], + }); + + // Act + let err = server.submit_agreement_proposal(request).await.unwrap_err(); + + // Assert + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("cannot be empty")); + } + + #[tokio::test] + async fn test_oversized_rejected() { + // Arrange + let ctx = DipsServerContext::for_testing(); + let server = DipsServer { + ctx, + expected_payee: Address::ZERO, + chain_id: 1, + recurring_collector: Address::ZERO, + }; + let large_payload = vec![0u8; 10_001]; + let request = Request::new(SubmitAgreementProposalRequest { + version: 2, + signed_voucher: large_payload, + }); + + // Act + let err = server.submit_agreement_proposal(request).await.unwrap_err(); + + // Assert + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("exceeds maximum size")); + } + + #[tokio::test] + async fn test_unsupported_version_rejected() { + // Arrange + let ctx = DipsServerContext::for_testing(); + let server = DipsServer { + ctx, + expected_payee: Address::ZERO, + chain_id: 1, + recurring_collector: Address::ZERO, + }; + let request = Request::new(SubmitAgreementProposalRequest { + version: 1, + signed_voucher: vec![1, 2, 3], + }); + + // Act + let err = server.submit_agreement_proposal(request).await.unwrap_err(); + + // Assert + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("Unsupported version")); + assert!(err.message().contains("version 2")); + } + + #[tokio::test] + async fn test_cancel_unimplemented() { + // Arrange + let ctx = DipsServerContext::for_testing(); + let server = DipsServer { + ctx, + expected_payee: Address::ZERO, + chain_id: 1, + recurring_collector: Address::ZERO, + }; + let request = Request::new(CancelAgreementRequest { + version: 2, + signed_cancellation: vec![], + }); + + // Act + let err = server.cancel_agreement(request).await.unwrap_err(); - Ok(tonic::Response::new(CancelAgreementResponse {})) + // Assert + assert_eq!(err.code(), tonic::Code::Unimplemented); + assert!(err.message().contains("RecurringCollector")); } } diff --git a/crates/dips/src/signers.rs b/crates/dips/src/signers.rs index b43d098b2..b8d8dff7f 100644 --- a/crates/dips/src/signers.rs +++ b/crates/dips/src/signers.rs @@ -1,6 +1,32 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 +//! Signer authorization for DIPS agreements. +//! +//! When Dipper sends an RCA proposal, it's signed by a key that may differ from +//! the payer's address. Payers authorize signers via the PaymentsEscrow contract, +//! and this authorization data is indexed by the network subgraph. +//! +//! # How It Works +//! +//! [`EscrowSignerValidator`] wraps an `EscrowAccountsWatcher` that periodically +//! syncs escrow account data from the network subgraph. When validating an RCA: +//! +//! 1. Recover the signer address from the EIP-712 signature +//! 2. Look up authorized signers for the payer address +//! 3. Verify the recovered signer is in the authorized list +//! +//! # Security Considerations +//! +//! The network subgraph may lag behind chain state. This means: +//! - A newly authorized signer might be rejected briefly (UX issue, not security) +//! - A revoked signer might be accepted briefly (security concern) +//! +//! The **thawing period** on escrow withdrawals mitigates the second case. +//! Payers cannot withdraw funds instantly - they must wait through a thawing +//! period that exceeds the maximum expected subgraph lag. This gives indexers +//! time to collect owed fees before funds disappear. + use anyhow::anyhow; #[cfg(test)] use indexer_monitor::EscrowAccounts; @@ -22,18 +48,9 @@ impl EscrowSignerValidator { } #[cfg(test)] - pub async fn mock(accounts: EscrowAccounts) -> Self { - use std::time::Duration; - - let watcher = indexer_watcher::new_watcher(Duration::from_secs(100), move || { - let accounts = accounts.clone(); - - async move { Ok(accounts) } - }) - .await - .unwrap(); - - Self::new(watcher) + pub fn mock(accounts: EscrowAccounts) -> Self { + let (_tx, rx) = tokio::sync::watch::channel(accounts); + Self::new(rx) } } @@ -58,30 +75,111 @@ impl SignerValidator for NoopSignerValidator { } } +/// Test validator that always rejects signers. +#[derive(Debug)] +pub struct RejectingSignerValidator; + +impl SignerValidator for RejectingSignerValidator { + fn validate(&self, _payer: &Address, _signer: &Address) -> Result<(), anyhow::Error> { + Err(anyhow!("Signer not authorized (test validator)")) + } +} + #[cfg(test)] mod test { - use std::{collections::HashMap, time::Duration}; + use std::collections::HashMap; use indexer_monitor::EscrowAccounts; use thegraph_core::alloy::primitives::Address; - use crate::signers::SignerValidator; + use crate::signers::{NoopSignerValidator, RejectingSignerValidator, SignerValidator}; + + #[tokio::test] + async fn test_escrow_validator_authorized_signer() { + // Arrange + let payer = Address::ZERO; + let authorized_signer = Address::from_slice(&[1u8; 20]); + let (_tx, watcher) = tokio::sync::watch::channel(EscrowAccounts::new( + HashMap::default(), + HashMap::from_iter(vec![(payer, vec![authorized_signer])]), + )); + let validator = super::EscrowSignerValidator::new(watcher); + + // Act & Assert + assert!( + validator.validate(&payer, &authorized_signer).is_ok(), + "Authorized signer should be accepted" + ); + } #[tokio::test] - async fn test_escrow_validator() { - let one = Address::ZERO; - let two = Address::from_slice(&[1u8; 20]); - let watcher = indexer_watcher::new_watcher(Duration::from_secs(100), move || async move { - Ok(EscrowAccounts::new( - HashMap::default(), - HashMap::from_iter(vec![(one, vec![two])]), - )) - }) - .await - .unwrap(); + async fn test_escrow_validator_unauthorized_signer() { + // Arrange + let payer = Address::ZERO; + let authorized_signer = Address::from_slice(&[1u8; 20]); + let unauthorized_signer = Address::from_slice(&[2u8; 20]); + let (_tx, watcher) = tokio::sync::watch::channel(EscrowAccounts::new( + HashMap::default(), + HashMap::from_iter(vec![(payer, vec![authorized_signer])]), + )); + let validator = super::EscrowSignerValidator::new(watcher); + // Act + let result = validator.validate(&payer, &unauthorized_signer); + + // Assert + assert!(result.is_err(), "Unauthorized signer should be rejected"); + } + + #[tokio::test] + async fn test_escrow_validator_payer_not_signer() { + // Arrange - payer authorizes someone else, not themselves + let payer = Address::ZERO; + let other_signer = Address::from_slice(&[1u8; 20]); + let (_tx, watcher) = tokio::sync::watch::channel(EscrowAccounts::new( + HashMap::default(), + HashMap::from_iter(vec![(payer, vec![other_signer])]), + )); let validator = super::EscrowSignerValidator::new(watcher); - validator.validate(&one, &one).unwrap_err(); - validator.validate(&one, &two).unwrap(); + + // Act + let result = validator.validate(&payer, &payer); + + // Assert + assert!( + result.is_err(), + "Payer signing for themselves without authorization should be rejected" + ); + } + + #[test] + fn test_noop_validator_always_accepts() { + // Arrange + let validator = NoopSignerValidator; + let payer = Address::ZERO; + let signer = Address::from_slice(&[0xAB; 20]); + + // Act + let result = validator.validate(&payer, &signer); + + // Assert + assert!(result.is_ok(), "NoopSignerValidator should always accept"); + } + + #[test] + fn test_rejecting_validator_always_rejects() { + // Arrange + let validator = RejectingSignerValidator; + let payer = Address::ZERO; + let signer = Address::from_slice(&[0xAB; 20]); + + // Act + let result = validator.validate(&payer, &signer); + + // Assert + assert!( + result.is_err(), + "RejectingSignerValidator should always reject" + ); } } diff --git a/crates/dips/src/store.rs b/crates/dips/src/store.rs index 1a987732e..153d95645 100644 --- a/crates/dips/src/store.rs +++ b/crates/dips/src/store.rs @@ -1,106 +1,166 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashMap; +//! Storage abstraction for RCA proposals. +//! +//! This module defines the [`RcaStore`] trait for persisting validated RCA proposals. +//! The indexer-service validates incoming proposals and stores them; the indexer-agent +//! (a separate TypeScript process) queries this table to decide on-chain acceptance. +//! +//! # Database Schema +//! +//! Proposals are stored in the `pending_rca_proposals` table: +//! +//! | Column | Type | Description | +//! |----------------|-------------|------------------------------------------| +//! | id | UUID | Agreement ID from the RCA | +//! | signed_payload | BYTEA | Raw ABI-encoded SignedRCA bytes | +//! | version | SMALLINT | Protocol version (currently 2) | +//! | status | VARCHAR(20) | "pending", "accepted", "rejected", etc. | +//! | created_at | TIMESTAMPTZ | When the proposal was received | +//! | updated_at | TIMESTAMPTZ | Last status change | +//! +//! # Implementations +//! +//! - [`InMemoryRcaStore`] - In-memory store for unit tests +//! - [`PsqlRcaStore`](crate::database::PsqlRcaStore) - PostgreSQL implementation + +use std::any::Any; use async_trait::async_trait; -use build_info::chrono::{DateTime, Utc}; use uuid::Uuid; -use crate::{ - DipsError, SignedCancellationRequest, SignedIndexingAgreementVoucher, - SubgraphIndexingVoucherMetadata, -}; - -#[derive(Debug, Clone)] -pub struct StoredIndexingAgreement { - pub voucher: SignedIndexingAgreementVoucher, - pub metadata: SubgraphIndexingVoucherMetadata, - pub cancelled: bool, - pub current_allocation_id: Option, - pub last_allocation_id: Option, - pub last_payment_collected_at: Option>, -} +use crate::DipsError; +/// Store for RCA (RecurringCollectionAgreement) proposals. +/// +/// Stores validated RCA proposals. The indexer agent queries this table, +/// validates allocation availability, and submits on-chain acceptance. #[async_trait] -pub trait AgreementStore: Sync + Send + std::fmt::Debug { - async fn get_by_id(&self, id: Uuid) -> Result, DipsError>; - async fn create_agreement( +pub trait RcaStore: Sync + Send + std::fmt::Debug { + /// Store a validated RCA proposal. + /// + /// Only called after successful validation (signature, IPFS, pricing). + async fn store_rca( &self, - agreement: SignedIndexingAgreementVoucher, - metadata: SubgraphIndexingVoucherMetadata, + agreement_id: Uuid, + signed_rca: Vec, + version: u64, ) -> Result<(), DipsError>; - async fn cancel_agreement( - &self, - signed_cancellation: SignedCancellationRequest, - ) -> Result; + + /// Downcast to concrete type for testing. + fn as_any(&self) -> &dyn Any; } +/// In-memory implementation of RcaStore for testing. #[derive(Default, Debug)] -pub struct InMemoryAgreementStore { - pub data: tokio::sync::RwLock>, +pub struct InMemoryRcaStore { + pub data: tokio::sync::RwLock, u64)>>, } #[async_trait] -impl AgreementStore for InMemoryAgreementStore { - async fn get_by_id(&self, id: Uuid) -> Result, DipsError> { - Ok(self - .data - .try_read() - .map_err(|e| DipsError::UnknownError(e.into()))? - .get(&id) - .cloned()) - } - async fn create_agreement( +impl RcaStore for InMemoryRcaStore { + async fn store_rca( &self, - agreement: SignedIndexingAgreementVoucher, - metadata: SubgraphIndexingVoucherMetadata, + agreement_id: Uuid, + signed_rca: Vec, + version: u64, ) -> Result<(), DipsError> { - let id = Uuid::from_bytes(agreement.voucher.agreement_id.into()); - let stored_agreement = StoredIndexingAgreement { - voucher: agreement, - metadata, - cancelled: false, - current_allocation_id: None, - last_allocation_id: None, - last_payment_collected_at: None, - }; self.data - .try_write() - .map_err(|e| DipsError::UnknownError(e.into()))? - .insert(id, stored_agreement); - + .write() + .await + .push((agreement_id, signed_rca, version)); Ok(()) } - async fn cancel_agreement( + + fn as_any(&self) -> &dyn Any { + self + } +} + +/// Test store that always fails. +#[derive(Default, Debug)] +pub struct FailingRcaStore; + +#[async_trait] +impl RcaStore for FailingRcaStore { + async fn store_rca( &self, - signed_cancellation: SignedCancellationRequest, - ) -> Result { - let id = Uuid::from_bytes(signed_cancellation.request.agreement_id.into()); - - let mut agreement = { - let read_lock = self - .data - .try_read() - .map_err(|e| DipsError::UnknownError(e.into()))?; - read_lock - .get(&id) - .cloned() - .ok_or(DipsError::AgreementNotFound)? - }; - - if agreement.cancelled { - return Err(DipsError::AgreementCancelled); - } - - agreement.cancelled = true; - - let mut write_lock = self - .data - .try_write() - .map_err(|e| DipsError::UnknownError(e.into()))?; - write_lock.insert(id, agreement); - - Ok(id) + _agreement_id: Uuid, + _signed_rca: Vec, + _version: u64, + ) -> Result<(), DipsError> { + Err(DipsError::UnknownError(anyhow::anyhow!( + "database connection failed (test store)" + ))) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_store_rca() { + // Arrange + let store = InMemoryRcaStore::default(); + let id = Uuid::now_v7(); + let blob = vec![1, 2, 3, 4, 5]; + + // Act + store.store_rca(id, blob.clone(), 2).await.unwrap(); + + // Assert + let data = store.data.read().await; + assert_eq!(data.len(), 1); + assert_eq!(data[0].0, id); + assert_eq!(data[0].1, blob); + assert_eq!(data[0].2, 2); + } + + #[tokio::test] + async fn test_store_multiple_rcas() { + // Arrange + let store = InMemoryRcaStore::default(); + let id1 = Uuid::now_v7(); + let id2 = Uuid::now_v7(); + let blob1 = vec![1, 2, 3]; + let blob2 = vec![4, 5, 6]; + + // Act + store.store_rca(id1, blob1.clone(), 2).await.unwrap(); + store.store_rca(id2, blob2.clone(), 2).await.unwrap(); + + // Assert + let data = store.data.read().await; + assert_eq!(data.len(), 2); + assert_eq!(data[0].0, id1); + assert_eq!(data[0].1, blob1); + assert_eq!(data[1].0, id2); + assert_eq!(data[1].1, blob2); + } + + #[tokio::test] + async fn test_failing_rca_store() { + // Arrange + let store = FailingRcaStore; + let id = Uuid::now_v7(); + let blob = vec![1, 2, 3]; + + // Act + let result = store.store_rca(id, blob, 2).await; + + // Assert + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, DipsError::UnknownError(_)), + "Expected UnknownError, got: {:?}", + err + ); } } diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 30e48bfdf..cdf6face2 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -9,8 +9,8 @@ use clap::Parser; use graph_networks_registry::NetworksRegistry; use indexer_config::{Config, DipsConfig, GraphNodeConfig, SubgraphConfig}; use indexer_dips::{ - database::PsqlAgreementStore, - ipfs::{IpfsClient, IpfsFetcher}, + database::PsqlRcaStore, + ipfs::IpfsClient, price::PriceCalculator, proto::indexer::graphprotocol::indexer::dips::indexer_dips_service_server::{ IndexerDipsService, IndexerDipsServiceServer, @@ -18,21 +18,17 @@ use indexer_dips::{ server::{DipsServer, DipsServerContext}, signers::EscrowSignerValidator, }; -use indexer_monitor::{escrow_accounts_v2, DeploymentDetails, SubgraphClient}; +use indexer_monitor::{DeploymentDetails, SubgraphClient}; use release::IndexerServiceRelease; use reqwest::Url; use tap_core::tap_eip712_domain; +use thegraph_core::alloy::primitives::Address; use tokio::{net::TcpListener, signal}; use tokio_util::sync::CancellationToken; use tower_http::normalize_path::NormalizePath; use tracing::info; -use crate::{ - cli::Cli, - constants::{DIPS_HTTP_CLIENT_TIMEOUT, HTTP_CLIENT_TIMEOUT}, - database, - metrics::serve_metrics, -}; +use crate::{cli::Cli, constants::HTTP_CLIENT_TIMEOUT, database, metrics::serve_metrics}; mod release; mod router; @@ -106,9 +102,6 @@ pub async fn run() -> anyhow::Result<()> { anyhow::bail!("Horizon mode is required; legacy mode is no longer supported."); } - // V2 escrow accounts (used by DIPS) are in the network subgraph - let escrow_v2_query_url_for_dips = config.subgraphs.network.config.query_url.clone(); - tracing::info!("Horizon mode configured; checking network subgraph readiness"); match indexer_monitor::is_horizon_active(network_subgraph).await { Ok(true) => { @@ -171,7 +164,7 @@ pub async fn run() -> anyhow::Result<()> { .timestamp_buffer_secs(config.tap.rav_request.timestamp_buffer_secs) .network_subgraph(network_subgraph, config.subgraphs.network) .escrow_subgraph(escrow_subgraph, config.subgraphs.escrow) - .escrow_accounts_v2(v2_watcher) + .escrow_accounts_v2(v2_watcher.clone()) .build(); serve_metrics(config.metrics.get_socket_addr()); @@ -183,79 +176,76 @@ pub async fn run() -> anyhow::Result<()> { address = %host_and_port, "Serving requests", ); + // DIPS: RecurringCollectionAgreement validation and storage if let Some(dips) = config.dips.as_ref() { let DipsConfig { host, port, - allowed_payers, - price_per_entity, - price_per_epoch, + recurring_collector, + tokens_per_second, + tokens_per_entity_per_second, additional_networks, } = dips; + // Validate required configuration + if *recurring_collector == Address::ZERO { + anyhow::bail!( + "DIPS is enabled but dips.recurring_collector is not configured. \ + Set it to the deployed RecurringCollector contract address." + ); + } + + if tokens_per_second.is_empty() { + tracing::warn!( + "DIPS enabled but no networks configured in dips.tokens_per_second. \ + All proposals will be rejected. See issue #943 for pricing guidance." + ); + } + let addr: SocketAddr = format!("{host}:{port}") .parse() .with_context(|| format!("Invalid DIPS host:port '{host}:{port}'"))?; - let ipfs_fetcher: Arc = Arc::new( - IpfsClient::new(ipfs_url.as_str()) - .with_context(|| format!("Failed to create IPFS client for URL '{ipfs_url}'"))?, + // Initialize validation dependencies + let ipfs_fetcher = Arc::new(IpfsClient::new(ipfs_url.as_str())?); + let registry = Arc::new( + NetworksRegistry::from_latest_version() + .await + .context("Failed to fetch NetworksRegistry for DIPS")?, ); - // TODO: Try to re-use the same watcher for both DIPS and TAP - // DIPS requires Horizon/V2, so always use V2 escrow from network subgraph - let dips_http_client = create_http_client(DIPS_HTTP_CLIENT_TIMEOUT, false) - .context("Failed to create DIPS HTTP client")?; - - tracing::info!("DIPS using V2 escrow from network subgraph"); - let escrow_subgraph_for_dips = Box::leak(Box::new( - SubgraphClient::new( - dips_http_client, - None, // No local deployment - DeploymentDetails::for_query_url_with_token( - escrow_v2_query_url_for_dips.clone(), - None, // No auth token - ), - ) - .await, - )); - - let watcher = escrow_accounts_v2( - escrow_subgraph_for_dips, - indexer_address, - Duration::from_secs(500), - true, - ) - .await - .with_context(|| "Failed to create escrow accounts V2 watcher for DIPS")?; - - let registry = NetworksRegistry::from_latest_version() - .await - .context("Failed to fetch networks registry")?; - - let ctx = DipsServerContext { - store: Arc::new(PsqlAgreementStore { + // Build server context + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(PsqlRcaStore { pool: database.clone(), }), ipfs_fetcher, - price_calculator: PriceCalculator::new(price_per_epoch.clone(), *price_per_entity), - signer_validator: Arc::new(EscrowSignerValidator::new(watcher)), - registry: Arc::new(registry), + price_calculator: Arc::new(PriceCalculator::new( + tokens_per_second.clone(), + *tokens_per_entity_per_second, + )), + signer_validator: Arc::new(EscrowSignerValidator::new(v2_watcher.clone())), + registry, additional_networks: Arc::new(additional_networks.clone()), - }; + }); - let dips = DipsServer { - ctx: Arc::new(ctx), + // Create DIPS server + let server = DipsServer { + ctx, expected_payee: indexer_address, - allowed_payers: allowed_payers.clone(), chain_id, + recurring_collector: *recurring_collector, }; - info!(address = %addr, "Starting DIPS gRPC server"); + info!( + address = %addr, + recurring_collector = ?recurring_collector, + "Starting DIPS gRPC server (RecurringCollectionAgreement validation)" + ); let dips_shutdown_token = shutdown_token.clone(); tokio::spawn(async move { - start_dips_server(addr, dips, dips_shutdown_token.cancelled()).await; + start_dips_server(addr, server, dips_shutdown_token.cancelled()).await; }); } diff --git a/migrations/20260209000000_dips_v2.down.sql b/migrations/20260209000000_dips_v2.down.sql new file mode 100644 index 000000000..a5a0bf4a4 --- /dev/null +++ b/migrations/20260209000000_dips_v2.down.sql @@ -0,0 +1,3 @@ +-- Rollback DIPS migration + +DROP TABLE IF EXISTS pending_rca_proposals; diff --git a/migrations/20260209000000_dips_v2.up.sql b/migrations/20260209000000_dips_v2.up.sql new file mode 100644 index 000000000..05fc308f8 --- /dev/null +++ b/migrations/20260209000000_dips_v2.up.sql @@ -0,0 +1,31 @@ +-- Drop legacy table if exists +DROP TABLE IF EXISTS indexing_agreements; + +-- Table for validated RCA proposals +-- +-- Design rationale: This table is intentionally minimal (6 columns vs 24 in the old schema). +-- The RecurringCollector contract is the source of truth for agreement state. This table +-- serves only as a temporary queue between indexer-rs (validates) and indexer-agent (accepts on-chain). +-- +-- We store the raw signed payload rather than denormalizing fields (network, payer, etc.) because: +-- 1. The signed payload IS the agreement - no risk of columns drifting out of sync +-- 2. Schema stability - RCA format changes don't require migrations +-- 3. Agent decodes the blob anyway to verify signature and submit on-chain +-- 4. Once accepted on-chain, all state queries go to the contract/subgraph, not here +-- +-- If operational needs arise (e.g., "show pending proposals by network"), fields can be +-- extracted into columns. But start minimal - you can always add columns, removing is harder. +CREATE TABLE IF NOT EXISTS pending_rca_proposals ( + id UUID PRIMARY KEY, + signed_payload BYTEA NOT NULL, + version SMALLINT NOT NULL DEFAULT 2, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Index for agent queries: "give me all pending proposals, newest first" +CREATE INDEX idx_pending_rca_status ON pending_rca_proposals(status, created_at); + +-- Index for time-ordered retrieval +CREATE INDEX idx_pending_rca_created ON pending_rca_proposals(created_at DESC); From b52b3feabf51c901bce013a807335d7f5bc724ee Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:01:05 -0500 Subject: [PATCH 02/31] feat(dips): improve config ergonomics with GRT pricing and explicit networks (#947) * feat(dips): improve config ergonomics with GRT pricing and explicit networks Changes human-readable GRT per 30 days pricing config and adds explicit network support list. Addresses #943 and #944. Config changes: - Add supported_networks list (proposals for unlisted networks rejected) - Add min_grt_per_30_days per-network base pricing (GRT/30 days) - Add min_grt_per_million_entities_per_30_days global entity pricing - Add 90+ networks with calculated pricing examples from IISA model Pricing derived from archive node costs (storage $25/TB, memory $1.50/GB, CPU $10/vCPU) divided by expected subgraph count per indexer. Co-Authored-By: Claude Opus 4.6 * test(dips): add startup validation tests and fix backoff comment - Fix IPFS retry comment: actual delays are 10s, 20s, 40s (not 1s, 2s, 4s) - Add 5 tests for DIPS startup validation: - test_dips_absent_in_minimal_config - test_dips_config_defaults_recurring_collector_zero - test_dips_config_defaults_empty_supported_networks - test_dips_partial_config_uses_defaults - test_dips_maximal_config_parses Co-Authored-By: Claude Opus 4.6 * fix(dips): use ceiling division to protect indexer minimums When converting GRT/30days to wei/second, truncating division caused indexers to accept slightly less than their configured minimum (up to 0.2% loss). Changed to ceiling division so minimums round UP, ensuring indexers never accept offers below their stated price floor. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.6 --- crates/config/maximal-config-example.toml | 114 ++++++++++++++++- crates/config/src/config.rs | 142 ++++++++++++++++++---- crates/config/src/grt.rs | 76 ++++++++++++ crates/dips/src/ipfs.rs | 2 +- crates/dips/src/lib.rs | 9 +- crates/dips/src/price.rs | 69 ++++++++--- crates/dips/src/proto/gateway.rs | 5 - crates/dips/src/proto/indexer.rs | 8 -- crates/dips/src/server.rs | 3 +- crates/service/src/service.rs | 40 ++++-- 10 files changed, 400 insertions(+), 68 deletions(-) diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index a77ab4d81..503e7d048 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -188,19 +188,123 @@ max_receipts_per_request = 10000 # DIPS (Decentralized Indexing Payment System) # NOTE: DIPS requires Horizon mode ([horizon].enabled = true) # Payer authorization is handled via escrow accounts (same trust model as TAP) +# +# Pricing uses human-readable GRT values (not wei), e.g. "100" = 100 GRT per 30 days. [dips] host = "0.0.0.0" port = "7601" recurring_collector = "0x4444444444444444444444444444444444444444" -tokens_per_entity_per_second = "1000" +# Networks you explicitly support indexing. +# Proposals from the dipper for you to index networks that are not in the list below are rejected. +# See https://github.com/graphprotocol/networks-registry/blob/main/docs/networks-table.md +# e.g. supported_networks = ["mainnet", "arbitrum-one"] +supported_networks = [] + +# Minimum payment you are willing to accept in order to accept indexing agreements +# (base price + entity-based price). Total payment = base price + (entities on sg * entity_rate) +min_grt_per_million_entities_per_30_days = "0.2" # entity-based component (global) -[dips.tokens_per_second] -mainnet = "100" -hardhat = "100" +[dips.min_grt_per_30_days] # base rate component (per-network) +# arbitrum-one = "450" +# matic = "300" +# fantom = "300" +# avalanche = "225" +# bsc = "200" +# base = "80" +# gnosis = "45" +# near-mainnet = "45" +# fuji = "45" +# mainnet = "45" +# optimism = "30" +# xdai = "30" +# polygon-zkevm = "30" +# polygon-amoy = "30" +# xlayer-mainnet = "30" +# soneium = "30" +# abstract = "30" +# fantom-testnet = "30" +# lens = "30" +# rootstock-testnet = "30" +# kaia = "30" +# chiliz = "30" +# linea-sepolia = "30" +# joc-testnet = "30" +# etherlink-mainnet = "30" +# apechain = "30" +# ink = "30" +# unichain-testnet = "30" +# blast-testnet = "30" +# megaeth = "30" +# sei-atlantic = "30" +# zksync-era-sepolia = "30" +# arbitrum-nova = "30" +# hoodi = "30" +# celo-sepolia = "30" +# vana = "30" +# joc = "30" +# swellchain = "30" +# soneium-testnet = "30" +# zetachain = "30" +# hemi-sepolia = "30" +# megaeth-testnet = "30" +# iotex = "30" +# stable = "30" +# cronos = "30" +# ronin = "30" +# fraxtal = "30" +# kaia-testnet = "30" +# abstract-testnet = "30" +# neox-testnet = "30" +# fuse-testnet = "30" +# manta = "30" +# viction = "30" +# peaq = "30" +# boba-testnet = "30" +# hashkeychain = "30" +# vana-moksha = "30" +# botanix-testnet = "30" +# corn = "30" +# chiliz-testnet = "30" +# apechain-curtis = "30" +# megaeth-timothy = "30" +# status-sepolia = "30" +# etherlink-shadownet = "30" +# etherlink-testnet = "30" +# mint = "30" +# ink-sepolia = "30" +# iotex-testnet = "30" +# neox = "30" +# lumia = "30" +# mint-sepolia = "30" +# lens-testnet = "30" +# berachain = "30" +# sonic = "25" +# katana = "25" +# hemi = "20" +# zksync-era = "20" +# sei-mainnet = "20" +# scroll = "15" +# optimism-sepolia = "15" +# celo = "15" +# linea = "15" +# base-sepolia = "15" +# unichain = "15" +# monad-testnet = "10" +# monad = "10" +# fuse = "10" +# scroll-sepolia = "10" +# rootstock = "10" +# near-testnet = "10" +# moonriver = "10" +# chapel = "10" +# moonbeam = "10" +# blast-mainnet = "5" +# arbitrum-sepolia = "5" +# boba = "5" +# sepolia = "5" [dips.additional_networks] -"eip155:1337" = "hardhat" [horizon] # Enable Horizon support and detection diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index b54b741cd..55b477a5a 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -19,13 +19,10 @@ use regex::Regex; use serde::Deserialize; use serde_repr::Deserialize_repr; use serde_with::{serde_as, DurationSecondsWithFrac}; -use thegraph_core::{ - alloy::primitives::{Address, U256}, - DeploymentId, -}; +use thegraph_core::{alloy::primitives::Address, DeploymentId}; use url::Url; -use crate::NonZeroGRT; +use crate::{NonZeroGRT, GRT}; const SHARED_PREFIX: &str = "INDEXER_"; @@ -643,8 +640,12 @@ pub struct DipsConfig { pub host: String, pub port: String, pub recurring_collector: Address, - pub tokens_per_second: BTreeMap, - pub tokens_per_entity_per_second: U256, + /// Networks this indexer explicitly supports. Proposals for other networks are rejected. + pub supported_networks: HashSet, + /// Minimum acceptable GRT per 30 days, per network. Converted to wei/second internally. + pub min_grt_per_30_days: BTreeMap, + /// Minimum acceptable GRT per million entities per 30 days. + pub min_grt_per_million_entities_per_30_days: GRT, pub additional_networks: BTreeMap, } @@ -654,8 +655,9 @@ impl Default for DipsConfig { host: "0.0.0.0".to_string(), port: "7601".to_string(), recurring_collector: Address::ZERO, - tokens_per_second: BTreeMap::new(), - tokens_per_entity_per_second: U256::ZERO, + supported_networks: HashSet::new(), + min_grt_per_30_days: BTreeMap::new(), + min_grt_per_million_entities_per_30_days: GRT::ZERO, additional_networks: BTreeMap::new(), } } @@ -743,17 +745,12 @@ pub struct HorizonConfig { #[cfg(test)] mod tests { - use std::{ - collections::{BTreeMap, HashSet}, - env, fs, - path::PathBuf, - str::FromStr, - }; + use std::{collections::HashSet, env, fs, path::PathBuf, str::FromStr}; use bip39::Mnemonic; use figment::value::Uncased; use sealed_test::prelude::*; - use thegraph_core::alloy::primitives::{address, Address, FixedBytes, U256}; + use thegraph_core::alloy::primitives::{address, Address, FixedBytes}; use tracing_test::traced_test; use super::{DatabaseConfig, IndexerConfig, SHARED_PREFIX}; @@ -782,15 +779,7 @@ mod tests { recurring_collector: Address( FixedBytes::<20>::from_str("0x4444444444444444444444444444444444444444").unwrap(), ), - tokens_per_entity_per_second: U256::from(1000), - tokens_per_second: BTreeMap::from_iter(vec![ - ("mainnet".to_string(), U256::from(100)), - ("hardhat".to_string(), U256::from(100)), - ]), - additional_networks: BTreeMap::from([( - "eip155:1337".to_string(), - "hardhat".to_string(), - )]), + min_grt_per_million_entities_per_30_days: crate::GRT::from_grt("0.2"), ..Default::default() }); @@ -1291,4 +1280,107 @@ mod tests { .unwrap_err() .contains("No operator mnemonic configured")); } + + // === DIPS Startup Validation Tests === + + /// Test that minimal config has no DIPS section (safe default for existing indexers). + #[test] + fn test_dips_absent_in_minimal_config() { + // Arrange & Act + let config = Config::parse( + ConfigPrefix::Service, + Some(PathBuf::from("minimal-config-example.toml")).as_ref(), + ) + .unwrap(); + + // Assert + assert!( + config.dips.is_none(), + "Minimal config should not have DIPS enabled" + ); + } + + /// Test that DipsConfig defaults have recurring_collector as Address::ZERO. + /// This is important because the service startup validation checks for this + /// and fails with a clear error message if DIPS is enabled but recurring_collector + /// is not configured. + #[test] + fn test_dips_config_defaults_recurring_collector_zero() { + // Arrange & Act + let dips_config = crate::DipsConfig::default(); + + // Assert + assert_eq!( + dips_config.recurring_collector, + Address::ZERO, + "Default recurring_collector should be Address::ZERO to trigger startup validation" + ); + } + + /// Test that DipsConfig defaults have empty supported_networks. + /// This triggers a warning at startup that all proposals will be rejected. + #[test] + fn test_dips_config_defaults_empty_supported_networks() { + // Arrange & Act + let dips_config = crate::DipsConfig::default(); + + // Assert + assert!( + dips_config.supported_networks.is_empty(), + "Default supported_networks should be empty" + ); + assert!( + dips_config.min_grt_per_30_days.is_empty(), + "Default min_grt_per_30_days should be empty" + ); + } + + /// Test that a DIPS config with only recurring_collector set uses defaults for other fields. + #[test] + fn test_dips_partial_config_uses_defaults() { + // Arrange - create a DipsConfig with just recurring_collector set + let dips_config = crate::DipsConfig { + recurring_collector: Address( + FixedBytes::<20>::from_str("0x1234567890123456789012345678901234567890").unwrap(), + ), + ..Default::default() + }; + + // Assert - recurring_collector is set, others use defaults + assert_ne!( + dips_config.recurring_collector, + Address::ZERO, + "recurring_collector should be set" + ); + assert_eq!(dips_config.host, "0.0.0.0", "host should use default"); + assert_eq!(dips_config.port, "7601", "port should use default"); + assert!( + dips_config.supported_networks.is_empty(), + "supported_networks should default to empty" + ); + assert!( + dips_config.min_grt_per_30_days.is_empty(), + "min_grt_per_30_days should default to empty" + ); + } + + /// Test that maximal config with DIPS section parses correctly. + #[test] + fn test_dips_maximal_config_parses() { + // Arrange & Act + let config: Config = toml::from_str( + fs::read_to_string("maximal-config-example.toml") + .unwrap() + .as_str(), + ) + .unwrap(); + + // Assert + let dips = config.dips.expect("maximal config should have DIPS"); + assert_ne!( + dips.recurring_collector, + Address::ZERO, + "recurring_collector should be set in maximal config" + ); + } } diff --git a/crates/config/src/grt.rs b/crates/config/src/grt.rs index 03f667712..b9facb1d5 100644 --- a/crates/config/src/grt.rs +++ b/crates/config/src/grt.rs @@ -4,6 +4,53 @@ use bigdecimal::{BigDecimal, ToPrimitive}; use serde::{de::Error, Deserialize}; +/// GRT value stored as wei (10^-18 GRT). Allows zero. +/// +/// Deserializes from human-readable GRT strings like "1.5" or "0.001". +#[derive(Debug, PartialEq, Default, Clone, Copy)] +pub struct GRT(u128); + +impl GRT { + pub const ZERO: GRT = GRT(0); + + /// Convert GRT string to wei for test construction. + /// Panics on invalid input - only use in tests. + #[cfg(test)] + pub fn from_grt(grt: &str) -> Self { + use bigdecimal::{BigDecimal, ToPrimitive}; + use std::str::FromStr; + let v = BigDecimal::from_str(grt).expect("invalid GRT value"); + let wei = (v * BigDecimal::from(10u64.pow(18))) + .to_u128() + .expect("GRT value too large"); + GRT(wei) + } + + pub fn wei(&self) -> u128 { + self.0 + } +} + +impl<'de> Deserialize<'de> for GRT { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let v = BigDecimal::deserialize(deserializer)?; + if v < 0.into() { + return Err(Error::custom("GRT value cannot be negative")); + } + // Convert to wei + let v = v * BigDecimal::from(10u64.pow(18)); + // Convert to u128 + let wei = v.to_u128().ok_or_else(|| { + Error::custom("GRT value cannot be represented as a u128 GRT wei value") + })?; + + Ok(Self(wei)) + } +} + #[derive(Debug, PartialEq, Default, Clone)] pub struct NonZeroGRT(u128); @@ -47,6 +94,35 @@ mod tests { use super::*; + #[test] + fn test_grt_deserialize() { + // Arrange & Act & Assert + assert_de_tokens(&GRT(1_000_000_000_000_000_000), &[Token::Str("1")]); + assert_de_tokens(&GRT(1_100_000_000_000_000_000), &[Token::Str("1.1")]); + assert_de_tokens(&GRT(0), &[Token::Str("0")]); + } + + #[test] + fn test_grt_negative_rejected() { + // Arrange & Act & Assert + assert_de_tokens_error::(&[Token::Str("-1")], "GRT value cannot be negative"); + } + + #[test] + fn test_grt_wei() { + // Arrange + let grt = GRT(1_500_000_000_000_000_000); + + // Act & Assert + assert_eq!(grt.wei(), 1_500_000_000_000_000_000); + } + + #[test] + fn test_grt_zero_constant() { + // Arrange & Act & Assert + assert_eq!(GRT::ZERO.wei(), 0); + } + #[test] fn test_parse_grt_value_to_u128_deserialize() { assert_de_tokens(&NonZeroGRT(1_000_000_000_000_000_000), &[Token::Str("1")]); diff --git a/crates/dips/src/ipfs.rs b/crates/dips/src/ipfs.rs index 08accb413..57c439a32 100644 --- a/crates/dips/src/ipfs.rs +++ b/crates/dips/src/ipfs.rs @@ -96,7 +96,7 @@ impl IpfsFetcher for IpfsClient { for attempt in 0..IPFS_MAX_ATTEMPTS { if attempt > 0 { - // Exponential backoff: 1s, 2s, 4s + // Exponential backoff: 10s, 20s, 40s let delay = IPFS_RETRY_BASE_DELAY * 2u32.pow(attempt - 1); tracing::debug!( file = %file, diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 9b60d41fb..ea74fc702 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -481,7 +481,8 @@ pub async fn validate_and_create_rca( #[cfg(test)] mod test { - use std::{collections::BTreeMap, sync::Arc}; + use std::collections::{BTreeMap, HashSet}; + use std::sync::Arc; use thegraph_core::alloy::{ primitives::{Address, FixedBytes, U256}, @@ -508,6 +509,7 @@ mod test { rca_store: Arc::new(InMemoryRcaStore::default()), ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), // Returns "mainnet" price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), @@ -677,6 +679,7 @@ mod test { network: "unsupported-network".to_string(), }), price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), @@ -870,6 +873,7 @@ mod test { rca_store: Arc::new(InMemoryRcaStore::default()), ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), @@ -908,6 +912,7 @@ mod test { rca_store: Arc::new(InMemoryRcaStore::default()), ipfs_fetcher: Arc::new(FailingIpfsFetcher), price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), @@ -946,6 +951,7 @@ mod test { rca_store: Arc::new(InMemoryRcaStore::default()), ipfs_fetcher: Arc::new(MockIpfsFetcher::no_network()), price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), @@ -984,6 +990,7 @@ mod test { rca_store: Arc::new(FailingRcaStore), ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), diff --git a/crates/dips/src/price.rs b/crates/dips/src/price.rs index 15ef2b773..982ee2836 100644 --- a/crates/dips/src/price.rs +++ b/crates/dips/src/price.rs @@ -19,32 +19,29 @@ //! # Per-Network Pricing //! //! Different networks have different operational costs (RPC fees, storage, etc.). -//! The `tokens_per_second` minimum is configured per network: +//! The `tokens_per_second` minimum is configured per network. //! -//! ```toml -//! [dips.tokens_per_second] -//! mainnet = "1000000000000" # Higher cost chain -//! arbitrum-one = "500000000000" # Lower cost L2 -//! ``` -//! -//! Networks not in this map are considered unsupported and will be rejected. +//! Networks must also be in `supported_networks` to accept proposals. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use thegraph_core::alloy::primitives::U256; #[derive(Debug, Default)] pub struct PriceCalculator { + supported_networks: HashSet, tokens_per_second: BTreeMap, tokens_per_entity_per_second: U256, } impl PriceCalculator { pub fn new( + supported_networks: HashSet, tokens_per_second: BTreeMap, tokens_per_entity_per_second: U256, ) -> Self { Self { + supported_networks, tokens_per_second, tokens_per_entity_per_second, } @@ -53,16 +50,25 @@ impl PriceCalculator { #[cfg(test)] pub fn for_testing() -> Self { Self { + supported_networks: HashSet::from(["mainnet".to_string()]), tokens_per_second: BTreeMap::from_iter(vec![("mainnet".to_string(), U256::from(200))]), tokens_per_entity_per_second: U256::from(100), } } + /// Check if a network is supported. + /// + /// A network is supported if: + /// 1. It's in the explicit `supported_networks` list, AND + /// 2. It has pricing configured pub fn is_supported(&self, network: &str) -> bool { - self.get_minimum_price(network).is_some() + self.supported_networks.contains(network) && self.tokens_per_second.contains_key(network) } pub fn get_minimum_price(&self, network: &str) -> Option { + if !self.supported_networks.contains(network) { + return None; + } self.tokens_per_second.get(network).copied() } @@ -79,6 +85,7 @@ mod tests { fn test_get_minimum_price_existing_network() { // Arrange let calculator = PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(1000))]), U256::from(50), ); @@ -94,6 +101,7 @@ mod tests { fn test_get_minimum_price_missing_network() { // Arrange let calculator = PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(1000))]), U256::from(50), ); @@ -109,6 +117,7 @@ mod tests { fn test_is_supported_true() { // Arrange let calculator = PriceCalculator::new( + HashSet::from(["mainnet".to_string(), "arbitrum-one".to_string()]), BTreeMap::from([ ("mainnet".to_string(), U256::from(1000)), ("arbitrum-one".to_string(), U256::from(500)), @@ -122,9 +131,41 @@ mod tests { } #[test] - fn test_is_supported_false() { + fn test_is_supported_false_not_in_list() { + // Arrange - network has pricing but not in supported list + let calculator = PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([ + ("mainnet".to_string(), U256::from(1000)), + ("arbitrum-one".to_string(), U256::from(500)), + ]), + U256::from(50), + ); + + // Act & Assert + assert!(calculator.is_supported("mainnet")); + assert!(!calculator.is_supported("arbitrum-one")); // Has pricing but not in supported list + } + + #[test] + fn test_is_supported_false_no_pricing() { + // Arrange - network in supported list but no pricing + let calculator = PriceCalculator::new( + HashSet::from(["mainnet".to_string(), "arbitrum-one".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(1000))]), + U256::from(50), + ); + + // Act & Assert + assert!(calculator.is_supported("mainnet")); + assert!(!calculator.is_supported("arbitrum-one")); // In list but no pricing + } + + #[test] + fn test_is_supported_false_unknown() { // Arrange let calculator = PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(1000))]), U256::from(50), ); @@ -137,7 +178,7 @@ mod tests { #[test] fn test_entity_price() { // Arrange - let calculator = PriceCalculator::new(BTreeMap::new(), U256::from(12345)); + let calculator = PriceCalculator::new(HashSet::new(), BTreeMap::new(), U256::from(12345)); // Act let price = calculator.entity_price(); @@ -147,9 +188,9 @@ mod tests { } #[test] - fn test_empty_networks_map() { + fn test_empty_config() { // Arrange - let calculator = PriceCalculator::new(BTreeMap::new(), U256::from(100)); + let calculator = PriceCalculator::new(HashSet::new(), BTreeMap::new(), U256::from(100)); // Act & Assert assert!(!calculator.is_supported("mainnet")); diff --git a/crates/dips/src/proto/gateway.rs b/crates/dips/src/proto/gateway.rs index e7adfe111..fd13ab498 100644 --- a/crates/dips/src/proto/gateway.rs +++ b/crates/dips/src/proto/gateway.rs @@ -1,8 +1,3 @@ -//! Gateway DIPS protocol types (auto-generated). -//! -//! These types define the Gateway-to-Dipper interface. Not used by indexer-rs -//! but included for completeness as both services share the same proto package. - // This file is @generated by prost-build. pub mod graphprotocol { pub mod gateway { diff --git a/crates/dips/src/proto/indexer.rs b/crates/dips/src/proto/indexer.rs index 1782ee530..8edb4be22 100644 --- a/crates/dips/src/proto/indexer.rs +++ b/crates/dips/src/proto/indexer.rs @@ -1,11 +1,3 @@ -//! Indexer DIPS protocol types (auto-generated). -//! -//! These types define the Dipper-to-indexer interface. The indexer-service -//! implements `IndexerDipsService` to handle: -//! -//! - `submit_agreement_proposal` - Receive and validate RCA proposals -//! - `cancel_agreement` - Unimplemented (cancellation is on-chain) - // This file is @generated by prost-build. pub mod graphprotocol { pub mod indexer { diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index aeb2f1a8e..93a93cb1f 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -181,13 +181,14 @@ mod tests { impl DipsServerContext { pub fn for_testing() -> Arc { - use std::collections::BTreeMap; + use std::collections::{BTreeMap, HashSet}; use thegraph_core::alloy::primitives::U256; Arc::new(Self { rca_store: Arc::new(InMemoryRcaStore::default()), ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(200))]), U256::from(100), )), diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index cdf6face2..2c3e610be 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -22,7 +22,7 @@ use indexer_monitor::{DeploymentDetails, SubgraphClient}; use release::IndexerServiceRelease; use reqwest::Url; use tap_core::tap_eip712_domain; -use thegraph_core::alloy::primitives::Address; +use thegraph_core::alloy::primitives::{Address, U256}; use tokio::{net::TcpListener, signal}; use tokio_util::sync::CancellationToken; use tower_http::normalize_path::NormalizePath; @@ -182,8 +182,9 @@ pub async fn run() -> anyhow::Result<()> { host, port, recurring_collector, - tokens_per_second, - tokens_per_entity_per_second, + supported_networks, + min_grt_per_30_days, + min_grt_per_million_entities_per_30_days, additional_networks, } = dips; @@ -195,10 +196,10 @@ pub async fn run() -> anyhow::Result<()> { ); } - if tokens_per_second.is_empty() { + if supported_networks.is_empty() { tracing::warn!( - "DIPS enabled but no networks configured in dips.tokens_per_second. \ - All proposals will be rejected. See issue #943 for pricing guidance." + "DIPS enabled but no networks in dips.supported_networks. \ + All proposals will be rejected." ); } @@ -214,6 +215,28 @@ pub async fn run() -> anyhow::Result<()> { .context("Failed to fetch NetworksRegistry for DIPS")?, ); + // Convert GRT/30days to wei/second for protocol compatibility. + // Use ceiling division to protect indexers: configured minimums round UP, + // ensuring indexers never accept less than their stated minimum. + // 30 days = 2,592,000 seconds + const SECONDS_PER_30_DAYS: u128 = 30 * 24 * 60 * 60; + let tokens_per_second = min_grt_per_30_days + .iter() + .map(|(network, grt)| { + let wei_per_second = grt.wei().div_ceil(SECONDS_PER_30_DAYS); + (network.clone(), U256::from(wei_per_second)) + }) + .collect(); + + // Entity pricing: config is per-million-entities, convert to per-entity. + // Ceiling division protects indexer from precision loss. + let entity_divisor = SECONDS_PER_30_DAYS * 1_000_000; + let tokens_per_entity_per_second = U256::from( + min_grt_per_million_entities_per_30_days + .wei() + .div_ceil(entity_divisor), + ); + // Build server context let ctx = Arc::new(DipsServerContext { rca_store: Arc::new(PsqlRcaStore { @@ -221,8 +244,9 @@ pub async fn run() -> anyhow::Result<()> { }), ipfs_fetcher, price_calculator: Arc::new(PriceCalculator::new( - tokens_per_second.clone(), - *tokens_per_entity_per_second, + supported_networks.clone(), + tokens_per_second, + tokens_per_entity_per_second, )), signer_validator: Arc::new(EscrowSignerValidator::new(v2_watcher.clone())), registry, From ed1d47654436a76a380a5e91a3026665c5093a45 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:19:27 -0500 Subject: [PATCH 03/31] feat(dips): make RCA storage idempotent for safe retries (#948) When Dipper sends an RCA and times out (network partition, crash after INSERT but before response), it retries. Previously, the retry failed with a duplicate key error, causing Dipper to mark the agreement as failed even though it was stored successfully. Now uses ON CONFLICT DO NOTHING so retries succeed. Both first attempt and retry return success, enabling Dipper to safely retry without creating inconsistent state. Co-authored-by: Claude Opus 4.5 --- crates/dips/src/database.rs | 14 +++++++++++++- crates/dips/src/store.rs | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/dips/src/database.rs b/crates/dips/src/database.rs index 04fb17cd2..dcff22331 100644 --- a/crates/dips/src/database.rs +++ b/crates/dips/src/database.rs @@ -22,6 +22,15 @@ //! 2. indexer-agent queries pending proposals //! 3. Agent validates allocation availability, accepts on-chain //! 4. Agent updates status to "accepted" or "rejected" +//! +//! # Idempotency +//! +//! The `store_rca` operation is idempotent: inserting the same agreement ID twice +//! succeeds both times. This handles retry scenarios where Dipper re-sends an RCA +//! after a timeout (network partition, crash after INSERT but before response, etc.). +//! +//! Without idempotency, the retry would fail with a duplicate key error, causing +//! Dipper to mark the agreement as failed even though it was successfully stored. use std::any::Any; @@ -45,9 +54,12 @@ impl RcaStore for PsqlRcaStore { signed_rca: Vec, version: u64, ) -> Result<(), DipsError> { + // ON CONFLICT DO NOTHING makes this idempotent: retries with the same + // agreement_id succeed without error, enabling safe Dipper retries. sqlx::query( "INSERT INTO pending_rca_proposals (id, signed_payload, version, status, created_at, updated_at) - VALUES ($1, $2, $3, 'pending', NOW(), NOW())" + VALUES ($1, $2, $3, 'pending', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", ) .bind(agreement_id) .bind(signed_rca) diff --git a/crates/dips/src/store.rs b/crates/dips/src/store.rs index 153d95645..d5f3da040 100644 --- a/crates/dips/src/store.rs +++ b/crates/dips/src/store.rs @@ -41,6 +41,12 @@ pub trait RcaStore: Sync + Send + std::fmt::Debug { /// Store a validated RCA proposal. /// /// Only called after successful validation (signature, IPFS, pricing). + /// + /// # Idempotency + /// + /// This operation MUST be idempotent: storing the same `agreement_id` twice + /// must succeed both times. This enables safe retries when Dipper re-sends + /// an RCA after timeout or network partition. async fn store_rca( &self, agreement_id: Uuid, @@ -66,10 +72,11 @@ impl RcaStore for InMemoryRcaStore { signed_rca: Vec, version: u64, ) -> Result<(), DipsError> { - self.data - .write() - .await - .push((agreement_id, signed_rca, version)); + let mut data = self.data.write().await; + // Idempotent: skip if already exists + if !data.iter().any(|(id, _, _)| *id == agreement_id) { + data.push((agreement_id, signed_rca, version)); + } Ok(()) } @@ -163,4 +170,24 @@ mod tests { err ); } + + #[tokio::test] + async fn test_store_rca_idempotent() { + // Arrange + let store = InMemoryRcaStore::default(); + let id = Uuid::now_v7(); + let blob = vec![1, 2, 3, 4, 5]; + + // Act - store same ID twice + let result1 = store.store_rca(id, blob.clone(), 2).await; + let result2 = store.store_rca(id, blob.clone(), 2).await; + + // Assert - both succeed, only one entry stored + assert!(result1.is_ok(), "First store should succeed"); + assert!(result2.is_ok(), "Second store (retry) should also succeed"); + + let data = store.data.read().await; + assert_eq!(data.len(), 1, "Duplicate should not create second entry"); + assert_eq!(data[0].0, id); + } } From 1189d75fbf4876cf13ce9f23de1f69525b206ecc Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:28:58 -0500 Subject: [PATCH 04/31] feat(dips): add `/dips/info` endpoint and indexing agreement rejection reasons (#954) * feat(dips): add /dips/info endpoint and rejection reasons to gRPC Add a public /dips/info HTTP endpoint on port 7600 that advertises the indexer's DIPS pricing configuration (min GRT per 30 days per network, min GRT per million entities, supported networks, and protocol version). This allows the Dipper to discover indexer pricing before sending RCA proposals. Update the gRPC protobuf to include a RejectReason enum on SubmitAgreementProposalResponse, distinguishing PRICE_TOO_LOW from OTHER rejection reasons. The server maps DipsError::TokensPerSecondTooLow and TokensPerEntityPerSecondTooLow to PRICE_TOO_LOW, with all other errors mapped to OTHER. Co-Authored-By: Claude Opus 4.6 * fix(dips): gate EscrowSignerValidator behind db feature The signers module was importing indexer_monitor unconditionally, but that crate is only available with the db feature. This caused compilation failures when using only the rpc feature (as dipper does). Changes: - Move EscrowSignerValidator and its imports into a conditionally compiled module (#[cfg(feature = "db")]) - Keep SignerValidator trait, NoopSignerValidator, and RejectingSignerValidator always available since they have no external dependencies - Gate escrow validator tests with #[cfg(all(test, feature = "db"))] - Restore dips_cancellation_eip712_domain function that was accidentally removed during the V2 migration (needed for backwards compatibility) Co-Authored-By: Claude Opus 4.6 * fix(dips): rename proto enum values to follow naming conventions The protobuf convention is to prefix enum values with the enum name. Changed PRICE_TOO_LOW -> REJECT_REASON_PRICE_TOO_LOW and OTHER -> REJECT_REASON_OTHER to match REJECT_REASON_UNSPECIFIED. Co-Authored-By: Claude Opus 4.6 * test(dips): add unit tests for reject_reason_from_error Tests verify the mapping from DipsError variants to RejectReason: - TokensPerSecondTooLow -> PriceTooLow - TokensPerEntityPerSecondTooLow -> PriceTooLow - All other errors (UnsupportedNetwork, InvalidSignature, etc.) -> Other Co-Authored-By: Claude Opus 4.6 * refactor(dips): extract GRT formatting to helper function The format_grt() function converts wei (10^-18 GRT) to a human-readable GRT string with up to 18 decimal places, trimming trailing zeros. This removes duplicated formatting logic in the dips_info_state setup. Co-Authored-By: Claude Opus 4.6 * test(dips): add tests for GRT formatting edge cases Tests cover: - Zero value - Whole numbers (1, 1000 GRT) - Small values less than 1 GRT (0.5 GRT) - Very small values (1 wei = 0.000000000000000001 GRT) - Mixed values with decimals - Trailing zeros are trimmed - Values with many decimal places - Large values with decimals Co-Authored-By: Claude Opus 4.6 * chore: apply nightly rustfmt * fix: use maybe_dips_info for optional builder field * fix: make DipsInfoResponse and DipsInfoPricing public * chore: remove dips_version field from /dips/info response V1 never existed in production, so versioning is unnecessary. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- crates/dips/proto/indexer.proto | 11 ++ crates/dips/src/lib.rs | 18 ++- .../src/proto/graphprotocol.indexer.dips.rs | 39 +++++ crates/dips/src/server.rs | 117 +++++++++++++- crates/dips/src/signers.rs | 123 +++++++------- crates/service/src/routes/dips_info.rs | 38 +++++ crates/service/src/routes/mod.rs | 2 + crates/service/src/service.rs | 151 +++++++++++++++++- crates/service/src/service/router.rs | 18 ++- 9 files changed, 455 insertions(+), 62 deletions(-) create mode 100644 crates/service/src/routes/dips_info.rs diff --git a/crates/dips/proto/indexer.proto b/crates/dips/proto/indexer.proto index dc97e82e8..904839787 100644 --- a/crates/dips/proto/indexer.proto +++ b/crates/dips/proto/indexer.proto @@ -33,6 +33,7 @@ message SubmitAgreementProposalRequest { */ message SubmitAgreementProposalResponse { ProposalResponse response = 1; /// The response to the agreement proposal. + RejectReason reject_reason = 2; /// Only set when response = REJECT. } /** @@ -43,6 +44,16 @@ enum ProposalResponse { REJECT = 1; /// The agreement proposal was rejected. } +/** + * The reason for rejecting an _indexing agreement_ proposal. + * Only meaningful when ProposalResponse = REJECT. + */ +enum RejectReason { + REJECT_REASON_UNSPECIFIED = 0; /// Default / not set (used for ACCEPT responses). + REJECT_REASON_PRICE_TOO_LOW = 1; /// The offered price is below the indexer's minimum. + REJECT_REASON_OTHER = 2; /// Any other reason (unsupported network, bad signature, etc.). +} + /** * A request to cancel an _indexing agreement_. * diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index ea74fc702..7074a20f2 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -58,7 +58,7 @@ use std::{str::FromStr, sync::Arc}; use server::DipsServerContext; use thegraph_core::alloy::{ core::primitives::Address, - primitives::{ruint::aliases::U256, ChainId, Signature, Uint}, + primitives::{b256, ruint::aliases::U256, ChainId, Signature, Uint, B256}, signers::SignerSync, sol, sol_types::{eip712_domain, Eip712Domain, SolStruct, SolValue}, @@ -96,6 +96,22 @@ pub fn rca_eip712_domain(chain_id: ChainId, recurring_collector: Address) -> Eip } } +/// EIP-712 domain salt for DIPs-specific messages. +const EIP712_DOMAIN_SALT: B256 = + b256!("a070ffb1cd7af433c73e0d016c7c4ce31dc1ec7366a3f5d20cfa22a80391e549"); + +/// Create an EIP-712 domain for cancellation requests. +/// +/// Used for signing `CancellationRequest` messages. +pub fn dips_cancellation_eip712_domain(chain_id: ChainId) -> Eip712Domain { + eip712_domain! { + name: "Graph Protocol Indexing Agreement Cancellation", + version: "0", + chain_id: chain_id, + salt: EIP712_DOMAIN_SALT, + } +} + sol! { // === RCA Types (seconds-based RecurringCollectionAgreement) === diff --git a/crates/dips/src/proto/graphprotocol.indexer.dips.rs b/crates/dips/src/proto/graphprotocol.indexer.dips.rs index 0f4f2d940..dcead95a3 100644 --- a/crates/dips/src/proto/graphprotocol.indexer.dips.rs +++ b/crates/dips/src/proto/graphprotocol.indexer.dips.rs @@ -22,6 +22,9 @@ pub struct SubmitAgreementProposalResponse { /// / The response to the agreement proposal. #[prost(enumeration = "ProposalResponse", tag = "1")] pub response: i32, + /// / Only set when response = REJECT. + #[prost(enumeration = "RejectReason", tag = "2")] + pub reject_reason: i32, } /// * /// @@ -76,6 +79,42 @@ impl ProposalResponse { } } } +/// * +/// +/// The reason for rejecting an *indexing agreement* proposal. +/// Only meaningful when ProposalResponse = REJECT. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum RejectReason { + /// / Default / not set (used for ACCEPT responses). + Unspecified = 0, + /// / The offered price is below the indexer's minimum. + PriceTooLow = 1, + /// / Any other reason (unsupported network, bad signature, etc.). + Other = 2, +} +impl RejectReason { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "REJECT_REASON_UNSPECIFIED", + Self::PriceTooLow => "REJECT_REASON_PRICE_TOO_LOW", + Self::Other => "REJECT_REASON_OTHER", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "REJECT_REASON_UNSPECIFIED" => Some(Self::Unspecified), + "REJECT_REASON_PRICE_TOO_LOW" => Some(Self::PriceTooLow), + "REJECT_REASON_OTHER" => Some(Self::Other), + _ => None, + } + } +} /// Generated client implementations. pub mod indexer_dips_service_client { #![allow( diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 93a93cb1f..4dea24983 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -47,11 +47,12 @@ use crate::{ price::PriceCalculator, proto::indexer::graphprotocol::indexer::dips::{ indexer_dips_service_server::IndexerDipsService, CancelAgreementRequest, - CancelAgreementResponse, ProposalResponse, SubmitAgreementProposalRequest, + CancelAgreementResponse, ProposalResponse, RejectReason, SubmitAgreementProposalRequest, SubmitAgreementProposalResponse, }, signers::SignerValidator, store::RcaStore, + DipsError, }; /// Context for DIPS server with all validation dependencies. @@ -94,6 +95,15 @@ pub struct DipsServer { pub recurring_collector: Address, } +/// Map a DipsError to the appropriate RejectReason for the gRPC response. +fn reject_reason_from_error(err: &DipsError) -> RejectReason { + match err { + DipsError::TokensPerSecondTooLow { .. } + | DipsError::TokensPerEntityPerSecondTooLow { .. } => RejectReason::PriceTooLow, + _ => RejectReason::Other, + } +} + #[async_trait] impl IndexerDipsService for DipsServer { /// Submit an RCA proposal. @@ -147,12 +157,15 @@ impl IndexerDipsService for DipsServer { tracing::info!(%agreement_id, "RCA accepted"); Ok(Response::new(SubmitAgreementProposalResponse { response: ProposalResponse::Accept.into(), + reject_reason: RejectReason::Unspecified.into(), })) } Err(e) => { - tracing::warn!(error = %e, "RCA rejected"); + let reject_reason = reject_reason_from_error(&e); + tracing::warn!(error = %e, reason = ?reject_reason, "RCA rejected"); Ok(Response::new(SubmitAgreementProposalResponse { response: ProposalResponse::Reject.into(), + reject_reason: reject_reason.into(), })) } } @@ -292,4 +305,104 @@ mod tests { assert_eq!(err.code(), tonic::Code::Unimplemented); assert!(err.message().contains("RecurringCollector")); } + + // ========================================================================= + // Tests for reject_reason_from_error + // ========================================================================= + + #[test] + fn test_reject_reason_tokens_per_second_too_low() { + // Arrange + use thegraph_core::alloy::primitives::U256; + let err = DipsError::TokensPerSecondTooLow { + network: "mainnet".to_string(), + minimum: U256::from(100), + offered: U256::from(50), + }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::PriceTooLow); + } + + #[test] + fn test_reject_reason_tokens_per_entity_per_second_too_low() { + // Arrange + use thegraph_core::alloy::primitives::U256; + let err = DipsError::TokensPerEntityPerSecondTooLow { + minimum: U256::from(100), + offered: U256::from(10), + }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::PriceTooLow); + } + + #[test] + fn test_reject_reason_unsupported_network() { + // Arrange + let err = DipsError::UnsupportedNetwork("unknown-network".to_string()); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert - UnsupportedNetwork maps to Other, not PriceTooLow + assert_eq!(reason, RejectReason::Other); + } + + #[test] + fn test_reject_reason_invalid_signature() { + // Arrange + let err = DipsError::InvalidSignature("bad signature".to_string()); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::Other); + } + + #[test] + fn test_reject_reason_signer_not_authorised() { + // Arrange + let err = DipsError::SignerNotAuthorised(Address::ZERO); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::Other); + } + + #[test] + fn test_reject_reason_deadline_expired() { + // Arrange + let err = DipsError::DeadlineExpired { + deadline: 1000, + now: 2000, + }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::Other); + } + + #[test] + fn test_reject_reason_abi_decoding() { + // Arrange + let err = DipsError::AbiDecoding("invalid bytes".to_string()); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::Other); + } } diff --git a/crates/dips/src/signers.rs b/crates/dips/src/signers.rs index b8d8dff7f..f70b283f4 100644 --- a/crates/dips/src/signers.rs +++ b/crates/dips/src/signers.rs @@ -28,44 +28,52 @@ //! time to collect owed fees before funds disappear. use anyhow::anyhow; -#[cfg(test)] -use indexer_monitor::EscrowAccounts; -use indexer_monitor::EscrowAccountsWatcher; use thegraph_core::alloy::primitives::Address; pub trait SignerValidator: Sync + Send + std::fmt::Debug { fn validate(&self, payer: &Address, signer: &Address) -> Result<(), anyhow::Error>; } -#[derive(Debug)] -pub struct EscrowSignerValidator { - watcher: EscrowAccountsWatcher, -} +#[cfg(feature = "db")] +mod escrow_validator { + use super::*; + #[cfg(test)] + use indexer_monitor::EscrowAccounts; + use indexer_monitor::EscrowAccountsWatcher; -impl EscrowSignerValidator { - pub fn new(watcher: EscrowAccountsWatcher) -> Self { - Self { watcher } + #[derive(Debug)] + pub struct EscrowSignerValidator { + watcher: EscrowAccountsWatcher, } - #[cfg(test)] - pub fn mock(accounts: EscrowAccounts) -> Self { - let (_tx, rx) = tokio::sync::watch::channel(accounts); - Self::new(rx) + impl EscrowSignerValidator { + pub fn new(watcher: EscrowAccountsWatcher) -> Self { + Self { watcher } + } + + #[cfg(test)] + pub fn mock(accounts: EscrowAccounts) -> Self { + let (_tx, rx) = tokio::sync::watch::channel(accounts); + Self::new(rx) + } } -} -impl SignerValidator for EscrowSignerValidator { - fn validate(&self, payer: &Address, signer: &Address) -> Result<(), anyhow::Error> { - let signers = self.watcher.borrow().get_signers_for_sender(payer); + impl SignerValidator for EscrowSignerValidator { + fn validate(&self, payer: &Address, signer: &Address) -> Result<(), anyhow::Error> { + let signers = self.watcher.borrow().get_signers_for_sender(payer); - if !signers.contains(signer) { - return Err(anyhow!("Signer is not a valid signer for the sender")); - } + if !signers.contains(signer) { + return Err(anyhow!("Signer is not a valid signer for the sender")); + } - Ok(()) + Ok(()) + } } } +#[cfg(feature = "db")] +pub use escrow_validator::EscrowSignerValidator; + #[derive(Debug)] pub struct NoopSignerValidator; @@ -87,12 +95,50 @@ impl SignerValidator for RejectingSignerValidator { #[cfg(test)] mod test { + use thegraph_core::alloy::primitives::Address; + + use crate::signers::{NoopSignerValidator, RejectingSignerValidator, SignerValidator}; + + #[test] + fn test_noop_validator_always_accepts() { + // Arrange + let validator = NoopSignerValidator; + let payer = Address::ZERO; + let signer = Address::from_slice(&[0xAB; 20]); + + // Act + let result = validator.validate(&payer, &signer); + + // Assert + assert!(result.is_ok(), "NoopSignerValidator should always accept"); + } + + #[test] + fn test_rejecting_validator_always_rejects() { + // Arrange + let validator = RejectingSignerValidator; + let payer = Address::ZERO; + let signer = Address::from_slice(&[0xAB; 20]); + + // Act + let result = validator.validate(&payer, &signer); + + // Assert + assert!( + result.is_err(), + "RejectingSignerValidator should always reject" + ); + } +} + +#[cfg(all(test, feature = "db"))] +mod escrow_tests { use std::collections::HashMap; use indexer_monitor::EscrowAccounts; use thegraph_core::alloy::primitives::Address; - use crate::signers::{NoopSignerValidator, RejectingSignerValidator, SignerValidator}; + use crate::signers::SignerValidator; #[tokio::test] async fn test_escrow_validator_authorized_signer() { @@ -151,35 +197,4 @@ mod test { "Payer signing for themselves without authorization should be rejected" ); } - - #[test] - fn test_noop_validator_always_accepts() { - // Arrange - let validator = NoopSignerValidator; - let payer = Address::ZERO; - let signer = Address::from_slice(&[0xAB; 20]); - - // Act - let result = validator.validate(&payer, &signer); - - // Assert - assert!(result.is_ok(), "NoopSignerValidator should always accept"); - } - - #[test] - fn test_rejecting_validator_always_rejects() { - // Arrange - let validator = RejectingSignerValidator; - let payer = Address::ZERO; - let signer = Address::from_slice(&[0xAB; 20]); - - // Act - let result = validator.validate(&payer, &signer); - - // Assert - assert!( - result.is_err(), - "RejectingSignerValidator should always reject" - ); - } } diff --git a/crates/service/src/routes/dips_info.rs b/crates/service/src/routes/dips_info.rs new file mode 100644 index 000000000..2a11287e0 --- /dev/null +++ b/crates/service/src/routes/dips_info.rs @@ -0,0 +1,38 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +use axum::{extract::State, Json}; +use serde::Serialize; +use std::collections::BTreeMap; + +/// State for the /dips/info endpoint, derived from DipsConfig at startup. +#[derive(Clone, Debug)] +pub struct DipsInfoState { + pub min_grt_per_30_days: BTreeMap, + pub min_grt_per_million_entities_per_30_days: String, +} + +#[derive(Serialize)] +pub struct DipsInfoPricing { + pub min_grt_per_30_days: BTreeMap, + pub min_grt_per_million_entities_per_30_days: String, +} + +#[derive(Serialize)] +pub struct DipsInfoResponse { + pub pricing: DipsInfoPricing, + pub supported_networks: Vec, +} + +pub async fn dips_info(State(state): State) -> Json { + let supported_networks: Vec = state.min_grt_per_30_days.keys().cloned().collect(); + + Json(DipsInfoResponse { + pricing: DipsInfoPricing { + min_grt_per_30_days: state.min_grt_per_30_days, + min_grt_per_million_entities_per_30_days: state + .min_grt_per_million_entities_per_30_days, + }, + supported_networks, + }) +} diff --git a/crates/service/src/routes/mod.rs b/crates/service/src/routes/mod.rs index 7f6a19716..b5db1ca5e 100644 --- a/crates/service/src/routes/mod.rs +++ b/crates/service/src/routes/mod.rs @@ -30,12 +30,14 @@ //! - [`healthz`]: Checks connectivity to database and graph-node dependencies pub mod cost; +pub mod dips_info; mod health; mod healthz; mod request_handler; mod static_subgraph; mod status; +pub use dips_info::{dips_info, DipsInfoState}; pub use health::health; pub use healthz::{healthz, HealthzState}; pub use request_handler::request_handler; diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 2c3e610be..e13d4572f 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -28,7 +28,10 @@ use tokio_util::sync::CancellationToken; use tower_http::normalize_path::NormalizePath; use tracing::info; -use crate::{cli::Cli, constants::HTTP_CLIENT_TIMEOUT, database, metrics::serve_metrics}; +use crate::{ + cli::Cli, constants::HTTP_CLIENT_TIMEOUT, database, metrics::serve_metrics, + routes::DipsInfoState, +}; mod release; mod router; @@ -37,6 +40,26 @@ mod tap_receipt_header; pub use router::ServiceRouter; pub use tap_receipt_header::TapHeader; +/// Format a wei value as a human-readable GRT string. +/// +/// Converts wei (10^-18 GRT) to GRT with up to 18 decimal places, +/// trimming trailing zeros. For example: +/// - 1_000_000_000_000_000_000 wei -> "1" +/// - 1_500_000_000_000_000_000 wei -> "1.5" +/// - 500_000_000_000_000_000 wei -> "0.5" +fn format_grt(wei: u128) -> String { + let whole = wei / 10u128.pow(18); + let frac = wei % 10u128.pow(18); + if frac == 0 { + whole.to_string() + } else { + // Format with up to 18 decimal places, trimming trailing zeros + let frac_str = format!("{:018}", frac); + let trimmed = frac_str.trim_end_matches('0'); + format!("{}.{}", whole, trimmed) + } +} + #[derive(Clone)] pub struct GraphNodeState { pub graph_node_client: reqwest::Client, @@ -152,6 +175,18 @@ pub async fn run() -> anyhow::Result<()> { ) .await; + // Build DipsInfoState if DIPS is configured + let dips_info_state = config.dips.as_ref().map(|dips| DipsInfoState { + min_grt_per_30_days: dips + .min_grt_per_30_days + .iter() + .map(|(network, grt)| (network.clone(), format_grt(grt.wei()))) + .collect(), + min_grt_per_million_entities_per_30_days: format_grt( + dips.min_grt_per_million_entities_per_30_days.wei(), + ), + }); + let router = ServiceRouter::builder() .database(database.clone()) .domain_separator_v2(domain_separator_v2.clone()) @@ -165,6 +200,7 @@ pub async fn run() -> anyhow::Result<()> { .network_subgraph(network_subgraph, config.subgraphs.network) .escrow_subgraph(escrow_subgraph, config.subgraphs.escrow) .escrow_accounts_v2(v2_watcher.clone()) + .maybe_dips_info(dips_info_state) .build(); serve_metrics(config.metrics.get_socket_addr()); @@ -362,3 +398,116 @@ async fn shutdown_handler(shutdown_token: CancellationToken) { tracing::info!("Signal received, starting graceful shutdown"); shutdown_token.cancel(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_grt_zero() { + // Arrange + let wei = 0u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "0"); + } + + #[test] + fn test_format_grt_whole_number() { + // Arrange - 1 GRT = 10^18 wei + let wei = 1_000_000_000_000_000_000u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "1"); + } + + #[test] + fn test_format_grt_large_whole_number() { + // Arrange - 1000 GRT + let wei = 1_000_000_000_000_000_000_000u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "1000"); + } + + #[test] + fn test_format_grt_small_value_less_than_one() { + // Arrange - 0.5 GRT = 5 * 10^17 wei + let wei = 500_000_000_000_000_000u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "0.5"); + } + + #[test] + fn test_format_grt_very_small_value() { + // Arrange - 0.000000000000000001 GRT = 1 wei + let wei = 1u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "0.000000000000000001"); + } + + #[test] + fn test_format_grt_mixed_value() { + // Arrange - 1.5 GRT + let wei = 1_500_000_000_000_000_000u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "1.5"); + } + + #[test] + fn test_format_grt_trims_trailing_zeros() { + // Arrange - 1.100 GRT should become "1.1" + let wei = 1_100_000_000_000_000_000u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "1.1"); + } + + #[test] + fn test_format_grt_many_decimal_places() { + // Arrange - 0.123456789012345678 GRT + let wei = 123_456_789_012_345_678u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "0.123456789012345678"); + } + + #[test] + fn test_format_grt_large_value_with_decimals() { + // Arrange - 12345.6789 GRT + let wei = 12_345_678_900_000_000_000_000u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "12345.6789"); + } +} diff --git a/crates/service/src/service/router.rs b/crates/service/src/service/router.rs index daa2ffbfd..01c10c8ad 100644 --- a/crates/service/src/service/router.rs +++ b/crates/service/src/service/router.rs @@ -51,7 +51,8 @@ use crate::{ PrometheusMetricsMiddlewareLayer, SenderState, TapContextState, }, routes::{ - self, health, healthz, request_handler, static_subgraph_request_handler, HealthzState, + self, dips_info, health, healthz, request_handler, static_subgraph_request_handler, + DipsInfoState, HealthzState, }, tap::{IndexerTapContext, TapChecksConfig}, wallet::public_key, @@ -91,6 +92,9 @@ pub struct ServiceRouter { network_subgraph: Option<(&'static SubgraphClient, NetworkSubgraphConfig)>, allocations: Option, dispute_manager: Option, + + // optional DIPS info for /dips/info endpoint + dips_info: Option, } impl ServiceRouter { @@ -417,7 +421,7 @@ impl ServiceRouter { graph_node_status_url: self.graph_node.status_url.clone(), }; - let misc_routes = Router::new() + let mut misc_routes = Router::new() .route("/", get("Service is up and running")) .route("/info", get(operator_address)) .route("/healthz", get(healthz).with_state(healthz_state)) @@ -427,8 +431,14 @@ impl ServiceRouter { .route( "/subgraph/health/{deployment_id}", get(health).with_state(graphnode_state.clone()), - ) - .layer(misc_rate_limiter); + ); + + if let Some(dips_info_state) = self.dips_info { + misc_routes = + misc_routes.route("/dips/info", get(dips_info).with_state(dips_info_state)); + } + + let misc_routes = misc_routes.layer(misc_rate_limiter); let extra_routes = Router::new() .route("/cost", post_cost) From 41feb8ca7b4fdacdd1c5d6e8a8069b1565c27242 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:22:24 -0500 Subject: [PATCH 05/31] refactor: use GRT per billion entities instead of per million (#959) * refactor: use GRT per billion entities instead of per million The entity pricing unit has been changed from "GRT per million entities" to "GRT per billion entities" for better human readability. At scale, "0.2 GRT per million entities" sounds negligible but actually translates to ~$4.50/TB/month - a meaningful cost that indexers might overlook. Using "200 GRT per billion entities" makes the cost more apparent. Changes: - Config: min_grt_per_million_entities_per_30_days -> min_grt_per_billion_entities_per_30_days - Default value: 0.2 -> 200 (same economics, just different unit) - /dips/info endpoint: field renamed in response - Internal conversion divisor: 1_000_000 -> 1_000_000_000 Co-Authored-By: Claude Opus 4.6 * feat(dips): add SIGNER_NOT_AUTHORISED rejection reason (#961) SignerNotAuthorised errors were mapped to RejectReason::Other, which causes dipper to block the indexer for 30 days. Signer authorization is a transient config issue that resolves once the operator registers the signer on the escrow contract, so a dedicated rejection reason allows dipper to apply a much shorter lookback window. Co-authored-by: Claude Opus 4.6 * feat(dips): align RCA struct with indexing-payments-management-audit contracts (#964) The RecurringCollector contract on the `indexing-payments-management-audit` branch removed `bytes16 agreementId` from the RCA struct and replaced it with `uint256 nonce`. Agreement IDs are now derived on-chain via `bytes16(keccak256(abi.encode(payer, dataService, serviceProvider, deadline, nonce)))`. The `deadline` and `endsAt` fields also changed from `uint256` to `uint64`. Updates the sol! struct definition, adds `derive_agreement_id`, simplifies `validate_and_create_rca` by removing fallible U256-to-u64 conversion, and updates all test RCA constructions. Co-authored-by: Claude Opus 4.6 * chore: remove misleading "v2" from DIPs migration and docs (#965) There is no DIPs v1 -- the off-chain voucher system was abandoned before deployment. DIPs refers exclusively to the on-chain RCA system. Rename the migration from dips_v2 to dips_pending_proposals and clean up doc comments that referenced "V2". Also clarifies the migration ownership comment in service.rs: the indexer-service does not run migrations by convention, the agent owns DDL, and the SQL files here are for local dev and tests only. Co-authored-by: Claude Opus 4.6 * feat(dips): add specific rejection reasons to gRPC proto (#966) The RejectReason proto enum only had 4 values (Unspecified, PriceTooLow, Other, SignerNotAuthorised), so 6 of the 8 validation failures in indexer-service mapped to the generic Other. Dipper uses the reason to set exclusion periods and Other gets 30 days, meaning transient issues like DeadlineExpired would incorrectly exclude an indexer for a month. Added DeadlineExpired, UnsupportedNetwork, SubgraphManifestUnavailable, UnexpectedServiceProvider, AgreementExpired, and UnsupportedMetadataVersion to the proto and updated reject_reason_from_error to map each DipsError variant to its specific reason. Co-authored-by: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- crates/config/maximal-config-example.toml | 6 +- crates/config/src/config.rs | 12 +- crates/dips/proto/indexer.proto | 13 +- crates/dips/src/lib.rs | 119 ++++++++++++------ .../src/proto/graphprotocol.indexer.dips.rs | 42 ++++++- crates/dips/src/server.rs | 69 +++++++++- crates/service/src/routes/dips_info.rs | 8 +- crates/service/src/service.rs | 28 +++-- ...302000000_dips_pending_proposals.down.sql} | 0 ...60302000000_dips_pending_proposals.up.sql} | 0 10 files changed, 228 insertions(+), 69 deletions(-) rename migrations/{20260209000000_dips_v2.down.sql => 20260302000000_dips_pending_proposals.down.sql} (100%) rename migrations/{20260209000000_dips_v2.up.sql => 20260302000000_dips_pending_proposals.up.sql} (100%) diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index 503e7d048..e0f417adb 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -203,7 +203,11 @@ supported_networks = [] # Minimum payment you are willing to accept in order to accept indexing agreements # (base price + entity-based price). Total payment = base price + (entities on sg * entity_rate) -min_grt_per_million_entities_per_30_days = "0.2" # entity-based component (global) +# +# For reference: analysis of subgraphs indexed by the upgrade indexer in Q1 2025 found +# the average entity size to be ~0.759 KiB. At this size, 1 billion entities ≈ 0.707 TiB. +# Your own observations may differ - adjust pricing accordingly. +min_grt_per_billion_entities_per_30_days = "200" # entity-based component (global) [dips.min_grt_per_30_days] # base rate component (per-network) # arbitrum-one = "450" diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 55b477a5a..f313d3db0 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -628,9 +628,9 @@ fn default_allocation_reconciliation_interval_secs() -> Duration { Duration::from_secs(300) } -/// DIPS V2 configuration. +/// DIPs configuration. /// -/// V2 validates RCA proposals (signature, IPFS manifest, network, pricing) +/// Validates RCA proposals (signature, IPFS manifest, network, pricing) /// before storing. The indexer agent queries pending proposals from the /// database and decides on-chain acceptance. #[derive(Debug, Deserialize)] @@ -644,8 +644,8 @@ pub struct DipsConfig { pub supported_networks: HashSet, /// Minimum acceptable GRT per 30 days, per network. Converted to wei/second internally. pub min_grt_per_30_days: BTreeMap, - /// Minimum acceptable GRT per million entities per 30 days. - pub min_grt_per_million_entities_per_30_days: GRT, + /// Minimum acceptable GRT per billion entities per 30 days. + pub min_grt_per_billion_entities_per_30_days: GRT, pub additional_networks: BTreeMap, } @@ -657,7 +657,7 @@ impl Default for DipsConfig { recurring_collector: Address::ZERO, supported_networks: HashSet::new(), min_grt_per_30_days: BTreeMap::new(), - min_grt_per_million_entities_per_30_days: GRT::ZERO, + min_grt_per_billion_entities_per_30_days: GRT::ZERO, additional_networks: BTreeMap::new(), } } @@ -779,7 +779,7 @@ mod tests { recurring_collector: Address( FixedBytes::<20>::from_str("0x4444444444444444444444444444444444444444").unwrap(), ), - min_grt_per_million_entities_per_30_days: crate::GRT::from_grt("0.2"), + min_grt_per_billion_entities_per_30_days: crate::GRT::from_grt("200"), ..Default::default() }); diff --git a/crates/dips/proto/indexer.proto b/crates/dips/proto/indexer.proto index 904839787..0521f5a26 100644 --- a/crates/dips/proto/indexer.proto +++ b/crates/dips/proto/indexer.proto @@ -49,9 +49,16 @@ enum ProposalResponse { * Only meaningful when ProposalResponse = REJECT. */ enum RejectReason { - REJECT_REASON_UNSPECIFIED = 0; /// Default / not set (used for ACCEPT responses). - REJECT_REASON_PRICE_TOO_LOW = 1; /// The offered price is below the indexer's minimum. - REJECT_REASON_OTHER = 2; /// Any other reason (unsupported network, bad signature, etc.). + REJECT_REASON_UNSPECIFIED = 0; /// Default / not set (used for ACCEPT responses). + REJECT_REASON_PRICE_TOO_LOW = 1; /// The offered price is below the indexer's minimum. + REJECT_REASON_OTHER = 2; /// Any other reason (bad signature, etc.). + REJECT_REASON_SIGNER_NOT_AUTHORISED = 3; /// The proposal signer is not authorised on the escrow contract. + REJECT_REASON_DEADLINE_EXPIRED = 4; /// The proposal deadline has already passed. + REJECT_REASON_UNSUPPORTED_NETWORK = 5; /// The subgraph's network is not supported by this indexer. + REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE = 6; /// The subgraph manifest could not be fetched from IPFS. + REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER = 7; /// The RCA service provider does not match this indexer. + REJECT_REASON_AGREEMENT_EXPIRED = 8; /// The agreement end time has already passed. + REJECT_REASON_UNSUPPORTED_METADATA_VERSION = 9; /// The metadata version is not supported. } /** diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 7074a20f2..5fe6f912b 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -58,7 +58,7 @@ use std::{str::FromStr, sync::Arc}; use server::DipsServerContext; use thegraph_core::alloy::{ core::primitives::Address, - primitives::{b256, ruint::aliases::U256, ChainId, Signature, Uint, B256}, + primitives::{b256, keccak256, ruint::aliases::U256, ChainId, Signature, Uint, B256}, signers::SignerSync, sol, sol_types::{eip712_domain, Eip712Domain, SolStruct, SolValue}, @@ -118,13 +118,12 @@ sol! { /// The on-chain RecurringCollectionAgreement type. /// /// Matches `IRecurringCollector.RecurringCollectionAgreement` exactly. + /// The agreement ID is derived on-chain via + /// `bytes16(keccak256(abi.encode(payer, dataService, serviceProvider, deadline, nonce)))`. #[derive(Debug, PartialEq)] struct RecurringCollectionAgreement { - bytes16 agreementId; - // NB: The on-chain struct declares these as uint64 for storage efficiency, - // but the EIP-712 typehash uses uint256. We must match the typehash. - uint256 deadline; - uint256 endsAt; + uint64 deadline; + uint64 endsAt; address payer; address dataService; address serviceProvider; @@ -132,6 +131,7 @@ sol! { uint256 maxOngoingTokensPerSecond; uint32 minSecondsPerCollection; uint32 maxSecondsPerCollection; + uint256 nonce; bytes metadata; } @@ -172,6 +172,25 @@ sol! { } } +/// Derive the agreement ID deterministically from the RCA fields. +/// +/// Matches the on-chain derivation: +/// `bytes16(keccak256(abi.encode(payer, dataService, serviceProvider, deadline, nonce)))` +fn derive_agreement_id(rca: &RecurringCollectionAgreement) -> Uuid { + let encoded = ( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce, + ) + .abi_encode(); + let hash = keccak256(&encoded); + let mut id_bytes = [0u8; 16]; + id_bytes.copy_from_slice(&hash[..16]); + Uuid::from_bytes(id_bytes) +} + #[derive(Error, Debug)] pub enum DipsError { // RCA validation @@ -368,27 +387,19 @@ pub async fn validate_and_create_rca( .expect("system time before unix epoch") .as_secs(); - let deadline: u64 = signed_rca - .agreement - .deadline - .try_into() - .map_err(|_| DipsError::InvalidRca("deadline overflow".to_string()))?; + let deadline: u64 = signed_rca.agreement.deadline; if deadline < now { return Err(DipsError::DeadlineExpired { deadline, now }); } // Validate agreement hasn't already expired - let ends_at: u64 = signed_rca - .agreement - .endsAt - .try_into() - .map_err(|_| DipsError::InvalidRca("endsAt overflow".to_string()))?; + let ends_at: u64 = signed_rca.agreement.endsAt; if ends_at < now { return Err(DipsError::AgreementExpired { ends_at, now }); } - // Extract agreement ID - let agreement_id = Uuid::from_bytes(signed_rca.agreement.agreementId.into()); + // Derive agreement ID deterministically from the RCA fields + let agreement_id = derive_agreement_id(&signed_rca.agreement); // Decode metadata let metadata = @@ -500,14 +511,8 @@ mod test { use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; - use thegraph_core::alloy::{ - primitives::{Address, FixedBytes, U256}, - signers::local::PrivateKeySigner, - sol_types::SolValue, - }; - use uuid::Uuid; - use crate::{ + derive_agreement_id, ipfs::{FailingIpfsFetcher, MockIpfsFetcher}, price::PriceCalculator, rca_eip712_domain, @@ -517,6 +522,11 @@ mod test { AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, RecurringCollectionAgreement, }; + use thegraph_core::alloy::{ + primitives::{keccak256, Address, FixedBytes, U256}, + signers::local::PrivateKeySigner, + sol_types::SolValue, + }; const CHAIN_ID: u64 = 42161; // Arbitrum One @@ -554,9 +564,8 @@ mod test { }; RecurringCollectionAgreement { - agreementId: Uuid::now_v7().as_bytes().into(), - deadline: U256::from(u64::MAX), - endsAt: U256::from(u64::MAX), + deadline: u64::MAX, + endsAt: u64::MAX, payer, dataService: Address::ZERO, serviceProvider: service_provider, @@ -564,10 +573,44 @@ mod test { maxOngoingTokensPerSecond: U256::from(100), minSecondsPerCollection: 60, maxSecondsPerCollection: 3600, + nonce: U256::from(1), metadata: metadata.abi_encode().into(), } } + #[test] + fn test_derive_agreement_id() { + let rca = RecurringCollectionAgreement { + deadline: 1000, + endsAt: 2000, + payer: Address::repeat_byte(0x01), + dataService: Address::repeat_byte(0x02), + serviceProvider: Address::repeat_byte(0x03), + maxInitialTokens: U256::from(100), + maxOngoingTokensPerSecond: U256::from(10), + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + nonce: U256::from(42), + metadata: Default::default(), + }; + + let id = derive_agreement_id(&rca); + + // Verify against the on-chain formula: + // bytes16(keccak256(abi.encode(payer, dataService, serviceProvider, deadline, nonce))) + let expected_hash = keccak256( + ( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce, + ) + .abi_encode(), + ); + assert_eq!(id.as_bytes(), &expected_hash[..16]); + } + #[tokio::test] async fn test_validate_and_create_rca_success() { let payer_signer = PrivateKeySigner::random(); @@ -576,7 +619,7 @@ mod test { let recurring_collector = Address::repeat_byte(0x22); let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); - let agreement_id = Uuid::from_bytes(rca.agreementId.into()); + let agreement_id = derive_agreement_id(&rca); let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); let signed_rca = rca.sign(&domain, payer_signer).unwrap(); @@ -730,9 +773,8 @@ mod test { }; let rca = RecurringCollectionAgreement { - agreementId: Uuid::now_v7().as_bytes().into(), - deadline: U256::from(u64::MAX), - endsAt: U256::from(u64::MAX), + deadline: u64::MAX, + endsAt: u64::MAX, payer, dataService: Address::ZERO, serviceProvider: service_provider, @@ -740,6 +782,7 @@ mod test { maxOngoingTokensPerSecond: U256::from(100), minSecondsPerCollection: 60, maxSecondsPerCollection: 3600, + nonce: U256::from(1), metadata: metadata.abi_encode().into(), }; @@ -777,9 +820,8 @@ mod test { // Set deadline to the past let rca = RecurringCollectionAgreement { - agreementId: Uuid::now_v7().as_bytes().into(), - deadline: U256::from(1), // 1 second after epoch - definitely in the past - endsAt: U256::from(u64::MAX), + deadline: 1, // 1 second after epoch - definitely in the past + endsAt: u64::MAX, payer, dataService: Address::ZERO, serviceProvider: service_provider, @@ -787,6 +829,7 @@ mod test { maxOngoingTokensPerSecond: U256::from(100), minSecondsPerCollection: 60, maxSecondsPerCollection: 3600, + nonce: U256::from(1), metadata: metadata.abi_encode().into(), }; @@ -821,9 +864,8 @@ mod test { // Set endsAt to the past let rca = RecurringCollectionAgreement { - agreementId: Uuid::now_v7().as_bytes().into(), - deadline: U256::from(u64::MAX), - endsAt: U256::from(1), // 1 second after epoch - definitely in the past + deadline: u64::MAX, + endsAt: 1, // 1 second after epoch - definitely in the past payer, dataService: Address::ZERO, serviceProvider: service_provider, @@ -831,6 +873,7 @@ mod test { maxOngoingTokensPerSecond: U256::from(100), minSecondsPerCollection: 60, maxSecondsPerCollection: 3600, + nonce: U256::from(1), metadata: metadata.abi_encode().into(), }; diff --git a/crates/dips/src/proto/graphprotocol.indexer.dips.rs b/crates/dips/src/proto/graphprotocol.indexer.dips.rs index dcead95a3..5202021bb 100644 --- a/crates/dips/src/proto/graphprotocol.indexer.dips.rs +++ b/crates/dips/src/proto/graphprotocol.indexer.dips.rs @@ -90,8 +90,22 @@ pub enum RejectReason { Unspecified = 0, /// / The offered price is below the indexer's minimum. PriceTooLow = 1, - /// / Any other reason (unsupported network, bad signature, etc.). + /// / Any other reason (bad signature, etc.). Other = 2, + /// / The proposal signer is not authorised on the escrow contract. + SignerNotAuthorised = 3, + /// / The proposal deadline has already passed. + DeadlineExpired = 4, + /// / The subgraph's network is not supported by this indexer. + UnsupportedNetwork = 5, + /// / The subgraph manifest could not be fetched from IPFS. + SubgraphManifestUnavailable = 6, + /// / The RCA service provider does not match this indexer. + UnexpectedServiceProvider = 7, + /// / The agreement end time has already passed. + AgreementExpired = 8, + /// / The metadata version is not supported. + UnsupportedMetadataVersion = 9, } impl RejectReason { /// String value of the enum field names used in the ProtoBuf definition. @@ -103,6 +117,19 @@ impl RejectReason { Self::Unspecified => "REJECT_REASON_UNSPECIFIED", Self::PriceTooLow => "REJECT_REASON_PRICE_TOO_LOW", Self::Other => "REJECT_REASON_OTHER", + Self::SignerNotAuthorised => "REJECT_REASON_SIGNER_NOT_AUTHORISED", + Self::DeadlineExpired => "REJECT_REASON_DEADLINE_EXPIRED", + Self::UnsupportedNetwork => "REJECT_REASON_UNSUPPORTED_NETWORK", + Self::SubgraphManifestUnavailable => { + "REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE" + } + Self::UnexpectedServiceProvider => { + "REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER" + } + Self::AgreementExpired => "REJECT_REASON_AGREEMENT_EXPIRED", + Self::UnsupportedMetadataVersion => { + "REJECT_REASON_UNSUPPORTED_METADATA_VERSION" + } } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -111,6 +138,19 @@ impl RejectReason { "REJECT_REASON_UNSPECIFIED" => Some(Self::Unspecified), "REJECT_REASON_PRICE_TOO_LOW" => Some(Self::PriceTooLow), "REJECT_REASON_OTHER" => Some(Self::Other), + "REJECT_REASON_SIGNER_NOT_AUTHORISED" => Some(Self::SignerNotAuthorised), + "REJECT_REASON_DEADLINE_EXPIRED" => Some(Self::DeadlineExpired), + "REJECT_REASON_UNSUPPORTED_NETWORK" => Some(Self::UnsupportedNetwork), + "REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE" => { + Some(Self::SubgraphManifestUnavailable) + } + "REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER" => { + Some(Self::UnexpectedServiceProvider) + } + "REJECT_REASON_AGREEMENT_EXPIRED" => Some(Self::AgreementExpired), + "REJECT_REASON_UNSUPPORTED_METADATA_VERSION" => { + Some(Self::UnsupportedMetadataVersion) + } _ => None, } } diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 4dea24983..caef201c8 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -100,6 +100,13 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { match err { DipsError::TokensPerSecondTooLow { .. } | DipsError::TokensPerEntityPerSecondTooLow { .. } => RejectReason::PriceTooLow, + DipsError::SignerNotAuthorised(_) => RejectReason::SignerNotAuthorised, + DipsError::DeadlineExpired { .. } => RejectReason::DeadlineExpired, + DipsError::AgreementExpired { .. } => RejectReason::AgreementExpired, + DipsError::UnsupportedNetwork(_) => RejectReason::UnsupportedNetwork, + DipsError::SubgraphManifestUnavailable(_) => RejectReason::SubgraphManifestUnavailable, + DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, + DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, _ => RejectReason::Other, } } @@ -351,8 +358,8 @@ mod tests { // Act let reason = super::reject_reason_from_error(&err); - // Assert - UnsupportedNetwork maps to Other, not PriceTooLow - assert_eq!(reason, RejectReason::Other); + // Assert + assert_eq!(reason, RejectReason::UnsupportedNetwork); } #[test] @@ -376,7 +383,7 @@ mod tests { let reason = super::reject_reason_from_error(&err); // Assert - assert_eq!(reason, RejectReason::Other); + assert_eq!(reason, RejectReason::SignerNotAuthorised); } #[test] @@ -391,7 +398,7 @@ mod tests { let reason = super::reject_reason_from_error(&err); // Assert - assert_eq!(reason, RejectReason::Other); + assert_eq!(reason, RejectReason::DeadlineExpired); } #[test] @@ -405,4 +412,58 @@ mod tests { // Assert assert_eq!(reason, RejectReason::Other); } + + #[test] + fn test_reject_reason_agreement_expired() { + // Arrange + let err = DipsError::AgreementExpired { + ends_at: 1000, + now: 2000, + }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::AgreementExpired); + } + + #[test] + fn test_reject_reason_subgraph_manifest_unavailable() { + // Arrange + let err = DipsError::SubgraphManifestUnavailable("QmTest".to_string()); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::SubgraphManifestUnavailable); + } + + #[test] + fn test_reject_reason_unexpected_service_provider() { + // Arrange + let err = DipsError::UnexpectedServiceProvider { + expected: Address::repeat_byte(0x01), + actual: Address::repeat_byte(0x02), + }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::UnexpectedServiceProvider); + } + + #[test] + fn test_reject_reason_unsupported_metadata_version() { + // Arrange + let err = DipsError::UnsupportedMetadataVersion(99); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::UnsupportedMetadataVersion); + } } diff --git a/crates/service/src/routes/dips_info.rs b/crates/service/src/routes/dips_info.rs index 2a11287e0..54666c340 100644 --- a/crates/service/src/routes/dips_info.rs +++ b/crates/service/src/routes/dips_info.rs @@ -9,13 +9,13 @@ use std::collections::BTreeMap; #[derive(Clone, Debug)] pub struct DipsInfoState { pub min_grt_per_30_days: BTreeMap, - pub min_grt_per_million_entities_per_30_days: String, + pub min_grt_per_billion_entities_per_30_days: String, } #[derive(Serialize)] pub struct DipsInfoPricing { pub min_grt_per_30_days: BTreeMap, - pub min_grt_per_million_entities_per_30_days: String, + pub min_grt_per_billion_entities_per_30_days: String, } #[derive(Serialize)] @@ -30,8 +30,8 @@ pub async fn dips_info(State(state): State) -> Json anyhow::Result<()> { // V2 escrow accounts are in the network subgraph, not a separate escrow_v2 subgraph // Establish Database connection necessary for serving indexer management - // requests with defined schema - // Note: Typically, you'd call `sqlx::migrate!();` here to sync the models - // which defaults to files in "./migrations" to sync the database; - // however, this can cause conflicts with the migrations run by indexer - // agent. Hence we leave syncing and migrating entirely to the agent and - // assume the models are up to date in the service. + // requests with defined schema. + // + // This binary does not run migrations. By convention, the indexer-agent + // (graphprotocol/indexer, TypeScript) owns schema migrations to avoid + // conflicting DDL from two processes sharing one database. The SQL files + // in indexer-rs/migrations/ exist for local development (`sqlx migrate + // run`) and tests only -- they are not executed by any production binary. + // + // For new tables (e.g. pending_rca_proposals), a corresponding migration + // must be added to the agent before the feature ships to production. let database = database::connect(config.database.clone().get_formated_postgres_url().as_ref()).await; @@ -182,8 +186,8 @@ pub async fn run() -> anyhow::Result<()> { .iter() .map(|(network, grt)| (network.clone(), format_grt(grt.wei()))) .collect(), - min_grt_per_million_entities_per_30_days: format_grt( - dips.min_grt_per_million_entities_per_30_days.wei(), + min_grt_per_billion_entities_per_30_days: format_grt( + dips.min_grt_per_billion_entities_per_30_days.wei(), ), }); @@ -220,7 +224,7 @@ pub async fn run() -> anyhow::Result<()> { recurring_collector, supported_networks, min_grt_per_30_days, - min_grt_per_million_entities_per_30_days, + min_grt_per_billion_entities_per_30_days, additional_networks, } = dips; @@ -264,11 +268,11 @@ pub async fn run() -> anyhow::Result<()> { }) .collect(); - // Entity pricing: config is per-million-entities, convert to per-entity. + // Entity pricing: config is per-billion-entities, convert to per-entity. // Ceiling division protects indexer from precision loss. - let entity_divisor = SECONDS_PER_30_DAYS * 1_000_000; + let entity_divisor = SECONDS_PER_30_DAYS * 1_000_000_000; let tokens_per_entity_per_second = U256::from( - min_grt_per_million_entities_per_30_days + min_grt_per_billion_entities_per_30_days .wei() .div_ceil(entity_divisor), ); diff --git a/migrations/20260209000000_dips_v2.down.sql b/migrations/20260302000000_dips_pending_proposals.down.sql similarity index 100% rename from migrations/20260209000000_dips_v2.down.sql rename to migrations/20260302000000_dips_pending_proposals.down.sql diff --git a/migrations/20260209000000_dips_v2.up.sql b/migrations/20260302000000_dips_pending_proposals.up.sql similarity index 100% rename from migrations/20260209000000_dips_v2.up.sql rename to migrations/20260302000000_dips_pending_proposals.up.sql From df80934aa237e3e3d74b988e28b056b3f986800d Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:52:29 +0000 Subject: [PATCH 06/31] build: add docker-compose.yml and build-image just target Enables local image builds via `just build-image`, producing ghcr.io/graphprotocol/indexer-service-rs:local and ghcr.io/graphprotocol/indexer-tap-agent:local from the existing per-crate Dockerfiles. Lets downstream consumers (local-network) consume this repo as image tags instead of source clones. --- docker-compose.yml | 12 ++++++++++++ justfile | 5 +++++ 2 files changed, 17 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..ab97df6ef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + indexer-service-rs: + image: ghcr.io/graphprotocol/indexer-service-rs:${TAG:-local} + build: + context: . + dockerfile: Dockerfile.indexer-service-rs + + indexer-tap-agent: + image: ghcr.io/graphprotocol/indexer-tap-agent:${TAG:-local} + build: + context: . + dockerfile: Dockerfile.indexer-tap-agent diff --git a/justfile b/justfile index fbc6afb68..9dec95266 100644 --- a/justfile +++ b/justfile @@ -24,6 +24,11 @@ fmt: cargo fmt sqlx-prepare: cargo sqlx prepare --workspace -- --all-targets --all-features + +# Build images ghcr.io/graphprotocol/indexer-service-rs and ghcr.io/graphprotocol/indexer-tap-agent (defaults to :local; set TAG=... to override) +build-image: + docker compose build + psql-up: @docker run -d --name indexer-rs-psql -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres @sleep 5 From c25add99f234278e45bc7958e431b962af2de42a Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:21:40 +0000 Subject: [PATCH 07/31] chore: ignore .claude/ local agent config --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 333840cbf..260141fbb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ indexer.toml .vscode/ # migrations/ .helix +.claude/ # Node.js related files crates/dips/node_modules/ From ad67f051424e01a83b16ea5298d93e71854d7ff4 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 5 May 2026 13:04:57 +0100 Subject: [PATCH 08/31] ci(containers): multi-arch images + workflow_dispatch for main-dips (#1029) * ci: add workflow_dispatch trigger to containers.yml Allows on-demand image builds from any branch via gh workflow run, producing :sha- tags for downstream integration testing without widening the push-trigger branches list. * ci(containers): build multi-arch images (linux/amd64,linux/arm64) - Native runner per platform (ubuntu-24.04, ubuntu-24.04-arm), push-by-digest, manifest fused in a follow-up merge job. - Per-platform, per-target buildcache scopes to avoid collisions. - SHA-pin third-party actions with version comments. - Merge gate: !cancelled() && needs.build.result == 'success' + fork-PR check, so workflow_dispatch from a non-default branch doesn't leave orphan per-platform digest blobs in GHCR. - Target list owned by a small prepare job and consumed via fromJSON in build and merge. - Force type=sha,enable=true so meta.outputs.version is populated for the Inspect step on workflow_dispatch. --- .github/workflows/containers.yml | 165 ++++++++++++++++++++++++------- 1 file changed, 127 insertions(+), 38 deletions(-) diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 804b5f79b..1ec11c872 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -1,6 +1,7 @@ name: Build and upload Docker image on: + workflow_dispatch: push: branches: - main @@ -28,69 +29,157 @@ jobs: steps: - name: Release please id: release-please - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 with: token: ${{ secrets.GITHUB_TOKEN }} - builds-linux: + prepare: runs-on: ubuntu-latest - needs: release-please - if: always() && (needs.release-please.result == 'success' || needs.release-please.result == 'skipped') + outputs: + targets: ${{ steps.set.outputs.targets }} + steps: + - id: set + run: echo 'targets=["indexer-service-rs","indexer-tap-agent"]' >> $GITHUB_OUTPUT + + build: + name: Build ${{ matrix.target }} (${{ matrix.platform }}) + needs: [prepare, release-please] + if: always() && needs.prepare.result == 'success' && (needs.release-please.result == 'success' || needs.release-please.result == 'skipped') strategy: + fail-fast: false matrix: - target: [indexer-service-rs, indexer-tap-agent] - + target: ${{ fromJSON(needs.prepare.outputs.targets) }} + platform: [linux/amd64, linux/arm64] + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} permissions: packages: write - steps: + - name: Prepare platform pair + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to the Container registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker labels + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ env.REGISTRY }}/${{ matrix.target }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ./ + file: Dockerfile.${{ matrix.target }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.target }}-${{ env.PLATFORM_PAIR }} + cache-to: type=gha,mode=max,scope=${{ matrix.target }}-${{ env.PLATFORM_PAIR }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.target }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + + - name: Export digest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + with: + name: digests-${{ matrix.target }}-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge ${{ matrix.target }} into multi-arch manifest + needs: [prepare, release-please, build] + if: | + !cancelled() + && needs.build.result == 'success' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + strategy: + fail-fast: false + matrix: + target: ${{ fromJSON(needs.prepare.outputs.targets) }} + runs-on: ubuntu-latest + permissions: + packages: write + steps: + # When release-please is skipped (workflow_dispatch, or no releasable commits) VERSION is empty; + # meta.outputs.version then falls back to the branch/sha tag, which is intentional. - name: Extract version from tag id: extract_version run: | TAG_NAME="${{ needs.release-please.outputs[matrix.target] }}" - # Extract the version part from tags with prefix "${{ matrix.target }}-" using a regex pattern if [[ "$TAG_NAME" =~ ^${{ matrix.target }}-(.*)$ ]]; then VERSION="${BASH_REMATCH[1]}" else VERSION="" fi - echo $VERSION echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Docker meta - id: meta - uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - # list of Docker images to use as base name for tags - images: | - ${{ env.REGISTRY }}/${{matrix.target}} - # generate Docker tags based on the following events/attributes - tags: | - type=schedule - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern={{major}}.{{minor}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern={{major}}.{{minor}}.{{patch}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern={{major}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern=v{{version}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern=v{{major}}.{{minor}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern=v{{major}}.{{minor}}.{{patch}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern=v{{major}},value=${{steps.extract_version.outputs.version}} - type=sha + path: ${{ runner.temp }}/digests + pattern: digests-${{ matrix.target }}-* + merge-multiple: true - - name: Log in to the Container registry - uses: docker/login-action@3227f5311cb93ffd14d13e65d8cc400d30f4dd8a + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 + - name: Docker tags + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: - context: ./ - push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - tags: ${{ steps.meta.outputs.tags }} - file: Dockerfile.${{ matrix.target }} + images: ${{ env.REGISTRY }}/${{ matrix.target }} + tags: | + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{major}}.{{minor}}.{{patch}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{major}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern=v{{version}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern=v{{major}}.{{minor}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern=v{{major}}.{{minor}}.{{patch}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern=v{{major}},value=${{ steps.extract_version.outputs.version }} + # Forced on so workflow_dispatch from a non-default branch (no `latest`, + # no tag ref) still yields a populated meta.outputs.version for Inspect. + type=sha,enable=true + + # Glob `*` expands to digest-named files written by the build job's Export digest step. + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ matrix.target }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ matrix.target }}:${{ steps.meta.outputs.version }} From 809bcc097ac730d6ed97778d5912324e1d2bb332 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Fri, 8 May 2026 11:28:59 +0800 Subject: [PATCH 09/31] fix: use version 0 for IndexingAgreementVersion.V1 in metadata validation (#983) The Solidity enum IndexingAgreementVersion has V1 as its first variant, which encodes as 0 in the ABI. The validation check was comparing against 1, causing all valid V1 proposals to be rejected with UnsupportedMetadataVersion. Test data also updated to use version 0. Companion to edgeandnode/dipper#583. Co-authored-by: Claude Opus 4.6 (1M context) --- crates/dips/src/lib.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 5fe6f912b..45a84c511 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -410,8 +410,8 @@ pub async fn validate_and_create_rca( )) })?; - // Only support version 1 terms for now - if metadata.version != 1 { + // Only support V1 terms (IndexingAgreementVersion.V1 = 0 in Solidity enum) + if metadata.version != 0 { return Err(DipsError::UnsupportedMetadataVersion(metadata.version)); } @@ -559,7 +559,7 @@ mod test { let metadata = AcceptIndexingAgreementMetadata { // Any bytes32 works - MockIpfsFetcher ignores the deployment ID subgraphDeploymentId: FixedBytes::ZERO, - version: 1, + version: 0, // IndexingAgreementVersion.V1 = 0 terms: terms.abi_encode().into(), }; @@ -814,7 +814,7 @@ mod test { let metadata = AcceptIndexingAgreementMetadata { subgraphDeploymentId: FixedBytes::ZERO, - version: 1, + version: 0, // IndexingAgreementVersion.V1 = 0 terms: terms.abi_encode().into(), }; @@ -858,7 +858,7 @@ mod test { let metadata = AcceptIndexingAgreementMetadata { subgraphDeploymentId: FixedBytes::ZERO, - version: 1, + version: 0, // IndexingAgreementVersion.V1 = 0 terms: terms.abi_encode().into(), }; From afc859aaf0063977a652f128f32994da6eb6af4b Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Fri, 8 May 2026 11:29:29 +0800 Subject: [PATCH 10/31] feat: improve DIPs observability for indexer operators (#1032) * fix: use version 0 for IndexingAgreementVersion.V1 in metadata validation The Solidity enum IndexingAgreementVersion has V1 as its first variant, which encodes as 0 in the ABI. The validation check was comparing against 1, causing all valid V1 proposals to be rejected with UnsupportedMetadataVersion. Test data also updated to use version 0. Companion to edgeandnode/dipper#583. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(dips): log proposal rejections at INFO level When an RCA proposal is rejected, log the rejection reason, error, and deployment ID (when decodable) at INFO level. Previously the rejection was returned via the gRPC response with no server-side log at INFO, making debugging difficult without access to the client. Co-Authored-By: Claude Opus 4.6 * style: fix nightly fmt Co-Authored-By: Claude Opus 4.6 * feat(dips): log startup configuration at INFO level Log supported networks, recurring collector address, IPFS URL, and per-network minimum pricing when DIPs is enabled. Previously only a warning was emitted when supported_networks was empty. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: correct iterator and type errors in DIPs startup logging min_grt_per_30_days is destructured from a reference, so use .iter() instead of &ref to avoid &&BTreeMap. min_grt_per_billion_entities is GRT not Option, so remove the if-let-Some wrapper. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(dips): add chain_id and structured fields to price rejection logs Price rejection logs now include chain_id (CAIP-2 identifier) and use structured tracing fields (offered, minimum) instead of format string interpolation. Makes it easier to filter and query rejection events in production log aggregation. Co-Authored-By: Claude Opus 4.6 (1M context) * test: add shared test vector for derive_agreement_id Pins the expected bytes16 output for a fixed set of RCA inputs. The same test vector exists in dipper (dipper-rpc/src/indexer.rs). If either repo's derivation drifts, the test fails with a message pointing to the counterpart. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- crates/dips/src/lib.rs | 84 +++++++++++++++++++++++++++++++---- crates/dips/src/server.rs | 8 +++- crates/service/src/service.rs | 18 ++++++++ 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 45a84c511..bd9085232 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -350,6 +350,16 @@ fn bytes32_to_ipfs_hash(bytes: &[u8; 32]) -> String { bs58::encode(&multihash).into_string() } +/// Try to extract the deployment ID from raw signed RCA bytes. +/// +/// Best-effort: returns `None` if any decoding step fails. +pub(crate) fn try_extract_deployment_id(rca_bytes: &[u8]) -> Option { + let signed_rca = SignedRecurringCollectionAgreement::abi_decode(rca_bytes).ok()?; + let metadata = + AcceptIndexingAgreementMetadata::abi_decode(signed_rca.agreement.metadata.as_ref()).ok()?; + Some(bytes32_to_ipfs_hash(&metadata.subgraphDeploymentId.0)) +} + /// Validate and create a RecurringCollectionAgreement. /// /// Performs validation: @@ -439,6 +449,13 @@ pub async fn validate_and_create_rca( return Err(DipsError::UnsupportedNetwork(network_name.to_string())); } + // Resolve chain ID for logging context + let chain_id = registry + .get_network_by_id(network_name) + .map(|n| n.caip2_id.to_string()) + .or_else(|| additional_networks.get(network_name).cloned()) + .unwrap_or_else(|| "unknown".to_string()); + // Validate price minimums let offered_tokens_per_second = terms.tokensPerSecond; match price_calculator.get_minimum_price(network_name) { @@ -446,10 +463,11 @@ pub async fn validate_and_create_rca( tracing::info!( agreement_id = %agreement_id, network = %network_name, + chain_id = %chain_id, deployment_id = %deployment_id, - "offered tokens_per_second '{}' is lower than minimum price '{}'", - offered_tokens_per_second, - price + offered = %offered_tokens_per_second, + minimum = %price, + "tokens_per_second below minimum, rejecting proposal" ); return Err(DipsError::TokensPerSecondTooLow { network: network_name.to_string(), @@ -462,9 +480,9 @@ pub async fn validate_and_create_rca( tracing::info!( agreement_id = %agreement_id, network = %network_name, + chain_id = %chain_id, deployment_id = %deployment_id, - "network '{}' is not configured in price calculator", - network_name + "network not configured in price calculator, rejecting proposal" ); return Err(DipsError::UnsupportedNetwork(network_name.to_string())); } @@ -476,10 +494,11 @@ pub async fn validate_and_create_rca( tracing::info!( agreement_id = %agreement_id, network = %network_name, + chain_id = %chain_id, deployment_id = %deployment_id, - "offered tokens_per_entity_per_second '{}' is lower than minimum price '{}'", - offered_entity_price, - price_calculator.entity_price() + offered = %offered_entity_price, + minimum = %price_calculator.entity_price(), + "tokens_per_entity_per_second below minimum, rejecting proposal" ); return Err(DipsError::TokensPerEntityPerSecondTooLow { minimum: price_calculator.entity_price(), @@ -611,6 +630,55 @@ mod test { assert_eq!(id.as_bytes(), &expected_hash[..16]); } + /// Shared test vector with dipper (dipper-rpc/src/indexer.rs). + /// Both repos must produce the same bytes16 for this input. + /// If this test fails, the derivation has drifted from the on-chain + /// contract and/or from dipper -- cancellations and agreement + /// matching will break silently. + #[test] + fn test_derive_agreement_id_shared_vector() { + let rca = RecurringCollectionAgreement { + deadline: 1700000300, + endsAt: 1700086400, + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + .parse() + .unwrap(), + dataService: "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9" + .parse() + .unwrap(), + serviceProvider: "0xf4EF6650E48d099a4972ea5B414daB86e1998Bd3" + .parse() + .unwrap(), + maxInitialTokens: U256::from(1_000_000_000_000_000_000u64), + maxOngoingTokensPerSecond: U256::from(1_000_000_000_000_000u64), + minSecondsPerCollection: 3600, + maxSecondsPerCollection: 86400, + nonce: U256::from(0x019d44a86ac97e938672e2501fe630f2u128), + metadata: Default::default(), + }; + + let id = derive_agreement_id(&rca); + + // Pinned expected value. If this fails, check: + // 1. dipper: dipper-rpc/src/indexer.rs test_derive_agreement_id_shared_vector + // 2. Solidity: RecurringCollector._generateAgreementId() + let expected: [u8; 16] = [ + 0x55, 0x79, 0x42, 0xae, 0xfa, 0xb6, 0x16, 0x09, 0xcf, 0xb9, 0xee, 0x14, 0xd3, 0x09, + 0xa1, 0x7e, + ]; + assert_eq!( + id.as_bytes(), + &expected, + "derive_agreement_id output does not match pinned shared vector. \ + Actual: 0x{} -- update this test AND the matching test in \ + dipper (dipper-rpc/src/indexer.rs)", + id.as_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + } + #[tokio::test] async fn test_validate_and_create_rca_success() { let payer_signer = PrivateKeySigner::random(); diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index caef201c8..84a10682c 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -152,6 +152,7 @@ impl IndexerDipsService for DipsServer { // Validate and store RCA let domain = crate::rca_eip712_domain(self.chain_id, self.recurring_collector); + let deployment_id = crate::try_extract_deployment_id(&signed_voucher); match crate::validate_and_create_rca( self.ctx.clone(), &domain, @@ -169,7 +170,12 @@ impl IndexerDipsService for DipsServer { } Err(e) => { let reject_reason = reject_reason_from_error(&e); - tracing::warn!(error = %e, reason = ?reject_reason, "RCA rejected"); + tracing::info!( + error = %e, + reason = ?reject_reason, + deployment_id = deployment_id.as_deref().unwrap_or("unknown"), + "RCA proposal rejected" + ); Ok(Response::new(SubmitAgreementProposalResponse { response: ProposalResponse::Reject.into(), reject_reason: reject_reason.into(), diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index cdca73525..fec64a2f1 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -243,6 +243,24 @@ pub async fn run() -> anyhow::Result<()> { ); } + tracing::info!( + supported_networks = ?supported_networks, + recurring_collector = %recurring_collector, + ipfs_url = %ipfs_url, + "DIPs configuration loaded" + ); + for (network, grt) in min_grt_per_30_days.iter() { + tracing::info!( + network = %network, + min_grt_per_30_days_wei = %grt.wei(), + "DIPs network pricing" + ); + } + tracing::info!( + min_grt_per_billion_entities_per_30_days_wei = %min_grt_per_billion_entities_per_30_days.wei(), + "DIPs entity pricing" + ); + let addr: SocketAddr = format!("{host}:{port}") .parse() .with_context(|| format!("Invalid DIPS host:port '{host}:{port}'"))?; From f1dd81bf13497b1655f47c60cbcb924a443f0453 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Wed, 27 May 2026 17:36:10 +1200 Subject: [PATCH 11/31] chore(deps): reconcile Cargo.lock after merging main The merge brought in main's lockfile pins for indexer-dips (rand 0.9.4, plus http, indexer-watcher, serde_json), but the merged Cargo.toml asks for rand 0.8 and no longer references those crates. Running cargo against the merged tree updated the lockfile to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97a9ef4ea..4f7bb4083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4332,19 +4332,17 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bs58", "build-info", "bytes", "derivative", "futures", "graph-networks-registry", - "http 0.2.12", "indexer-monitor", - "indexer-watcher", "ipfs-api-backend-hyper", "prost 0.14.3", - "rand 0.9.4", + "rand 0.8.5", "serde", - "serde_json", "serde_yaml", "sqlx", "test-assets", From 1a82d7cb6e1b12a9cd91fa73962c2e26567a3e7b Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Wed, 27 May 2026 21:35:15 +1200 Subject: [PATCH 12/31] fix(service): drop unused Horizon check and side escrow connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service tried at startup to check whether Horizon was active, and to talk to a separate escrow data source. Both are no longer needed — Horizon is always on, and escrow moved into the main data feed. Dropping the dead code fixes the build. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/service/src/service.rs | 41 +++++------------------------------ 1 file changed, 6 insertions(+), 35 deletions(-) diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index ced1709b8..799f0e776 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -124,32 +124,11 @@ pub async fn run() -> anyhow::Result<()> { let indexer_address = config.indexer.indexer_address; let ipfs_url = config.service.ipfs_url.clone(); - // Horizon is required; verify contracts are active. - if !config.tap_mode().is_horizon() { - anyhow::bail!("Horizon mode is required; legacy mode is no longer supported."); - } - - tracing::info!("Horizon mode configured; checking network subgraph readiness"); - match indexer_monitor::is_horizon_active(network_subgraph).await { - Ok(true) => { - tracing::info!("Horizon contracts detected in network subgraph"); - } - Ok(false) => { - anyhow::bail!( - "Horizon mode is required, but the Network Subgraph indicates Horizon is not active (no PaymentsEscrow accounts found). \ - Ensure Horizon contracts are deployed and the Network Subgraph is updated before starting the indexer service." - ); - } - Err(e) => { - anyhow::bail!( - "Failed to detect Horizon contracts due to network/subgraph error: {}. \ - Cannot start with Horizon mode enabled when network status is unknown.", - e - ); - } - } - - tracing::info!("Horizon contracts detected - using Horizon (V2) mode"); + // V2 escrow accounts (used by DIPs) live in the network subgraph; no + // separate escrow subgraph is queried. + let collector_address = config.blockchain.receipts_verifier_address_v2; + let escrow_min_balance_grt_wei = config.subgraphs.network.escrow_min_balance_grt_wei.clone(); + let max_signers_per_payer = config.subgraphs.network.max_signers_per_payer; let v2_watcher = match indexer_monitor::escrow_accounts_v2( network_subgraph, @@ -175,13 +154,6 @@ pub async fn run() -> anyhow::Result<()> { } }; - let escrow_subgraph = create_subgraph_client( - http_client.clone(), - &config.graph_node, - &config.subgraphs.escrow.config, - ) - .await; - // Build DipsInfoState if DIPS is configured let dips_info_state = config.dips.as_ref().map(|dips| DipsInfoState { min_grt_per_30_days: dips @@ -205,8 +177,7 @@ pub async fn run() -> anyhow::Result<()> { .blockchain(config.blockchain) .timestamp_buffer_secs(config.tap.rav_request.timestamp_buffer_secs) .network_subgraph(network_subgraph, config.subgraphs.network) - .escrow_subgraph(escrow_subgraph, config.subgraphs.escrow) - .escrow_accounts_v2(v2_watcher.clone()) + .escrow_accounts_v2(v2_watcher) .maybe_dips_info(dips_info_state) .build(); From 0ab9ca8a68f52f96f81155411766277d0facea4b Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Wed, 27 May 2026 22:58:33 +1200 Subject: [PATCH 13/31] fix(service): restore v2_watcher clone so the second use compiles The escrow-accounts watcher is used in two places: once to wire into the router and once to drive signer validation later on. The first call consumed the value, so the second use no longer had anything to clone from. Clone at the first site. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/service/src/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 799f0e776..7438ce7a4 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -177,7 +177,7 @@ pub async fn run() -> anyhow::Result<()> { .blockchain(config.blockchain) .timestamp_buffer_secs(config.tap.rav_request.timestamp_buffer_secs) .network_subgraph(network_subgraph, config.subgraphs.network) - .escrow_accounts_v2(v2_watcher) + .escrow_accounts_v2(v2_watcher.clone()) .maybe_dips_info(dips_info_state) .build(); From a1192e99027baaea7bf2e7e07721598d684b4b44 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Thu, 28 May 2026 01:19:30 +1200 Subject: [PATCH 14/31] feat: switch to on-chain offer-based authorization (#1009) * feat(dips): switch to on-chain offer-based authorization The smart contract now verifies authorisation via on-chain offers at agreement acceptance, so the indexer-side signature check has no job left. The RCA struct gains a `conditions` field to match the contract. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dips): cap IPFS manifest size at 5MB Real subgraph manifests are tens of kilobytes, but the IPFS hash in a proposal is chosen by the caller. Without a cap, a hostile caller can point at very large content and force the indexer to download it all. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(service): apply rate-limit and timeout to DIPs gRPC Adds two tower layers on the DIPs gRPC server: a 220-second timeout covering the IPFS retry budget plus headroom, and a 50-token-per- second global rate limit shared across callers. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dips): cut IPFS retries when many proposals are in flight When too many proposals are in flight, the IPFS retry budget lets a hostile caller hold handler slots for up to 190s. The new counter tracks in-flight requests; above 200 the IPFS client tries once. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(service): rate-limit DIPs proposals per source IP A new layer rate-limits per source IP: burst of 10, then 5 tokens per second sustained. Keyed on the peer IP via tonic's TcpConnectInfo extension. Single-source spam is cut off without touching other IPs. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dips): allowlist accepted payers via DipsConfig A new `allowed_payers` config field gates which payer addresses the indexer-service will even consider. Omit the field for legacy permissive behaviour; set an empty array to deny everyone; list addresses to allow. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(service): wrap DIPs gRPC middleware to make it cloneable The gRPC layer requires the wrapping around it to be cloneable, but the rate-limit and per-IP pieces aren't on their own. Putting a buffer around the whole chain makes it cloneable. The buffer's channel is sized so a healthy burst at the global rate never bumps it. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(service): convert response body so DIPs middleware compiles Tower_governor wants axum's body type, while tonic's routing layer hands back its own; the rate-limit stack would not build. Convert in between, and turn any inner stack error into a gRPC status response rather than a dropped connection. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(config): drop deprecated [horizon] block from maximal example The [horizon] section sets a field the code now ignores; main already dropped the section from its copy of the example. Merging main back in here kept the block, so the test comparing maximal against the defaults-filled minimal failed. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- crates/config/maximal-config-example.toml | 24 +- crates/config/src/config.rs | 68 +-- crates/dips/src/inflight.rs | 64 +++ crates/dips/src/ipfs.rs | 95 +++- crates/dips/src/lib.rs | 485 +++++++----------- crates/dips/src/server.rs | 102 ++-- crates/dips/src/signers.rs | 200 -------- crates/service/Cargo.toml | 2 +- crates/service/src/service.rs | 118 ++++- .../src/service/grpc_error_to_response.rs | 144 ++++++ 10 files changed, 652 insertions(+), 650 deletions(-) create mode 100644 crates/dips/src/inflight.rs delete mode 100644 crates/dips/src/signers.rs create mode 100644 crates/service/src/service/grpc_error_to_response.rs diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index a2c1ab7df..cab0067a4 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -174,17 +174,18 @@ max_receipts_per_request = 10000 0xDDE4cfFd3D9052A9cb618fC05a1Cd02be1f2F467 = "https://tap-aggregator.network.thegraph.com" 0xDD6a6f76eb36B873C1C184e8b9b9e762FE216490 = "https://tap-aggregator-arbitrum-one.graphops.xyz" -# DIPS (Decentralized Indexing Payment System) -# NOTE: DIPS requires Horizon mode ([horizon].enabled = true) -# Payer authorization is handled via escrow accounts (same trust model as TAP) -# -# Pricing uses human-readable GRT values (not wei), e.g. "100" = 100 GRT per 30 days. +# DIPS (Decentralized Indexing Payment System). Requires Horizon mode. +# Payer auth is verified on-chain at acceptance; the `allowed_payers` +# list below gates which payers may even propose. GRT prices, not wei. [dips] host = "0.0.0.0" port = "7601" -recurring_collector = "0x4444444444444444444444444444444444444444" -# Networks you explicitly support indexing. +# Trusted payers. Omit field = accept any caller (legacy); `[]` = deny +# all. Production addresses TBD — list real dipper addresses to use it. +# allowed_payers = ["0x0000000000000000000000000000000000000000"] + +# Networks you explicitly support indexing. # Proposals from the dipper for you to index networks that are not in the list below are rejected. # See https://github.com/graphprotocol/networks-registry/blob/main/docs/networks-table.md # e.g. supported_networks = ["mainnet", "arbitrum-one"] @@ -298,12 +299,3 @@ min_grt_per_billion_entities_per_30_days = "200" # entity-based component (glob # sepolia = "5" [dips.additional_networks] - -[horizon] -# Enable Horizon support and detection -# When enabled: checks that Horizon contracts are active via the network subgraph -# When enabled, set `blockchain.subgraph_service_address` and -# `blockchain.receipts_verifier_address_v2` -# -# Horizon is required in production. -enabled = true diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 8301a2f39..9a35a8180 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -672,7 +672,6 @@ fn default_allocation_reconciliation_interval_secs() -> Duration { pub struct DipsConfig { pub host: String, pub port: String, - pub recurring_collector: Address, /// Networks this indexer explicitly supports. Proposals for other networks are rejected. pub supported_networks: HashSet, /// Minimum acceptable GRT per 30 days, per network. Converted to wei/second internally. @@ -680,6 +679,12 @@ pub struct DipsConfig { /// Minimum acceptable GRT per billion entities per 30 days. pub min_grt_per_billion_entities_per_30_days: GRT, pub additional_networks: BTreeMap, + /// Optional allowlist of payer addresses. When `Some`, only proposals + /// whose `payer` field is in this set are accepted; everything else is + /// rejected before the IPFS fetch. When `None` (the default), proposals + /// from any payer are accepted — the operator should normally list the + /// addresses of the dippers they do business with. + pub allowed_payers: Option>, } impl Default for DipsConfig { @@ -687,11 +692,11 @@ impl Default for DipsConfig { DipsConfig { host: "0.0.0.0".to_string(), port: "7601".to_string(), - recurring_collector: Address::ZERO, supported_networks: HashSet::new(), min_grt_per_30_days: BTreeMap::new(), min_grt_per_billion_entities_per_30_days: GRT::ZERO, additional_networks: BTreeMap::new(), + allowed_payers: None, } } } @@ -741,7 +746,7 @@ mod tests { use bip39::Mnemonic; use figment::value::Uncased; use sealed_test::prelude::*; - use thegraph_core::alloy::primitives::{address, Address, FixedBytes}; + use thegraph_core::alloy::primitives::{address, Address}; use tracing_test::traced_test; use super::{DatabaseConfig, IndexerConfig, SHARED_PREFIX}; @@ -767,9 +772,6 @@ mod tests { max_config.tap.trusted_senders = HashSet::from([address!("deadbeefcafebabedeadbeefcafebabedeadbeef")]); max_config.dips = Some(crate::DipsConfig { - recurring_collector: Address( - FixedBytes::<20>::from_str("0x4444444444444444444444444444444444444444").unwrap(), - ), min_grt_per_billion_entities_per_30_days: crate::GRT::from_grt("200"), ..Default::default() }); @@ -1325,23 +1327,6 @@ mod tests { ); } - /// Test that DipsConfig defaults have recurring_collector as Address::ZERO. - /// This is important because the service startup validation checks for this - /// and fails with a clear error message if DIPS is enabled but recurring_collector - /// is not configured. - #[test] - fn test_dips_config_defaults_recurring_collector_zero() { - // Arrange & Act - let dips_config = crate::DipsConfig::default(); - - // Assert - assert_eq!( - dips_config.recurring_collector, - Address::ZERO, - "Default recurring_collector should be Address::ZERO to trigger startup validation" - ); - } - /// Test that DipsConfig defaults have empty supported_networks. /// This triggers a warning at startup that all proposals will be rejected. #[test] @@ -1360,35 +1345,6 @@ mod tests { ); } - /// Test that a DIPS config with only recurring_collector set uses defaults for other fields. - #[test] - fn test_dips_partial_config_uses_defaults() { - // Arrange - create a DipsConfig with just recurring_collector set - let dips_config = crate::DipsConfig { - recurring_collector: Address( - FixedBytes::<20>::from_str("0x1234567890123456789012345678901234567890").unwrap(), - ), - ..Default::default() - }; - - // Assert - recurring_collector is set, others use defaults - assert_ne!( - dips_config.recurring_collector, - Address::ZERO, - "recurring_collector should be set" - ); - assert_eq!(dips_config.host, "0.0.0.0", "host should use default"); - assert_eq!(dips_config.port, "7601", "port should use default"); - assert!( - dips_config.supported_networks.is_empty(), - "supported_networks should default to empty" - ); - assert!( - dips_config.min_grt_per_30_days.is_empty(), - "min_grt_per_30_days should default to empty" - ); - } - /// Test that maximal config with DIPS section parses correctly. #[test] fn test_dips_maximal_config_parses() { @@ -1402,10 +1358,10 @@ mod tests { // Assert let dips = config.dips.expect("maximal config should have DIPS"); - assert_ne!( - dips.recurring_collector, - Address::ZERO, - "recurring_collector should be set in maximal config" + assert_eq!( + dips.min_grt_per_billion_entities_per_30_days, + crate::GRT::from_grt("200"), + "min_grt_per_billion_entities_per_30_days should be set in maximal config" ); } } diff --git a/crates/dips/src/inflight.rs b/crates/dips/src/inflight.rs new file mode 100644 index 000000000..af95869d6 --- /dev/null +++ b/crates/dips/src/inflight.rs @@ -0,0 +1,64 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared counter of in-flight proposal requests. +//! +//! The counter is incremented when a request enters the gRPC handler and +//! decremented when it leaves. The IPFS client reads it at the start of a +//! fetch to decide whether to use the full retry budget or a single +//! attempt — the latter frees handler slots faster when the service is +//! under load, providing a pressure-relief valve. + +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +pub type InflightCounter = Arc; + +/// RAII guard that increments the counter on construction and decrements +/// on drop. Hold it for the lifetime of the request you want counted. +pub struct InflightGuard { + counter: InflightCounter, +} + +impl InflightGuard { + pub fn new(counter: InflightCounter) -> Self { + counter.fetch_add(1, Ordering::Relaxed); + Self { counter } + } +} + +impl Drop for InflightGuard { + fn drop(&mut self) { + self.counter.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Snapshot the current in-flight count. +pub fn snapshot(counter: &InflightCounter) -> usize { + counter.load(Ordering::Relaxed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guard_increments_and_decrements() { + let counter: InflightCounter = Arc::new(AtomicUsize::new(0)); + assert_eq!(snapshot(&counter), 0); + + let g1 = InflightGuard::new(counter.clone()); + assert_eq!(snapshot(&counter), 1); + + let g2 = InflightGuard::new(counter.clone()); + assert_eq!(snapshot(&counter), 2); + + drop(g1); + assert_eq!(snapshot(&counter), 1); + + drop(g2); + assert_eq!(snapshot(&counter), 0); + } +} diff --git a/crates/dips/src/ipfs.rs b/crates/dips/src/ipfs.rs index 57c439a32..6ed4f0bbd 100644 --- a/crates/dips/src/ipfs.rs +++ b/crates/dips/src/ipfs.rs @@ -21,7 +21,7 @@ //! ... //! ``` //! -//! # Timeout and Retry Behavior +//! # Timeout, Retry, and Size Limits //! //! IPFS fetches have a 30-second timeout per attempt. On failure, the client //! retries up to 3 times with exponential backoff (10s, 20s, 40s delays). This @@ -32,6 +32,10 @@ //! Dipper's gRPC timeout should be at least 220 seconds (190s + 30s buffer) //! to avoid timing out while indexer-rs is still retrying IPFS. //! +//! Each fetch is also capped at `IPFS_MAX_MANIFEST_BYTES`. Real manifests +//! are tens of KB; the cap exists so a caller-supplied CID cannot force +//! an unbounded download from attacker-controlled content. +//! //! # What This Proves //! //! Successfully fetching a manifest proves: @@ -52,7 +56,10 @@ use futures::TryStreamExt; use ipfs_api_backend_hyper::{IpfsApi, TryFromUri}; use serde::Deserialize; -use crate::DipsError; +use crate::{ + inflight::{self, InflightCounter}, + DipsError, +}; /// Timeout for a single IPFS fetch attempt. const IPFS_FETCH_TIMEOUT: Duration = Duration::from_secs(30); @@ -63,6 +70,17 @@ const IPFS_MAX_ATTEMPTS: u32 = 4; /// Base delay for exponential backoff between retries (10s, 20s, 40s). const IPFS_RETRY_BASE_DELAY: Duration = Duration::from_secs(10); +/// Upper bound on bytes read from a single manifest fetch. Real subgraph +/// manifests are tens of KB; this cap bounds the per-request bandwidth +/// cost when the caller-chosen CID resolves to attacker-controlled content. +pub(crate) const IPFS_MAX_MANIFEST_BYTES: usize = 5 * 1024 * 1024; + +/// When the in-flight request count exceeds this threshold, IPFS fetches +/// stop retrying — a single attempt only. The fewer-retries mode frees +/// handler slots faster when the service is under load, at the cost of +/// failing proposals whose first IPFS attempt has a transient error. +pub(crate) const IPFS_DURESS_THRESHOLD: usize = 200; + #[async_trait] pub trait IpfsFetcher: Send + Sync + std::fmt::Debug { async fn fetch(&self, file: &str) -> Result; @@ -80,12 +98,21 @@ impl IpfsFetcher for Arc { pub struct IpfsClient { #[derivative(Debug = "ignore")] client: ipfs_api_backend_hyper::IpfsClient, + inflight: InflightCounter, } impl IpfsClient { - pub fn new(url: &str) -> anyhow::Result { + pub fn new(url: &str, inflight: InflightCounter) -> anyhow::Result { let client = ipfs_api_backend_hyper::IpfsClient::from_str(url)?; - Ok(Self { client }) + Ok(Self { client, inflight }) + } + + pub(crate) fn max_attempts(&self) -> u32 { + if inflight::snapshot(&self.inflight) > IPFS_DURESS_THRESHOLD { + 1 + } else { + IPFS_MAX_ATTEMPTS + } } } @@ -93,8 +120,9 @@ impl IpfsClient { impl IpfsFetcher for IpfsClient { async fn fetch(&self, file: &str) -> Result { let mut last_error = None; + let max_attempts = self.max_attempts(); - for attempt in 0..IPFS_MAX_ATTEMPTS { + for attempt in 0..max_attempts { if attempt > 0 { // Exponential backoff: 10s, 20s, 40s let delay = IPFS_RETRY_BASE_DELAY * 2u32.pow(attempt - 1); @@ -113,7 +141,7 @@ impl IpfsFetcher for IpfsClient { tracing::warn!( file = %file, attempt = attempt + 1, - max_attempts = IPFS_MAX_ATTEMPTS, + max_attempts, error = %e, "IPFS fetch attempt failed" ); @@ -133,13 +161,20 @@ impl IpfsClient { /// Fetch with timeout wrapper. async fn fetch_with_timeout(&self, file: &str) -> Result { let fetch_future = async { - let content = self - .client - .cat(file.as_ref()) - .map_ok(|chunk| chunk.to_vec()) - .try_concat() + let mut stream = self.client.cat(file.as_ref()); + let mut content: Vec = Vec::new(); + while let Some(chunk) = stream + .try_next() .await - .map_err(|e| DipsError::SubgraphManifestUnavailable(format!("{file}: {e}")))?; + .map_err(|e| DipsError::SubgraphManifestUnavailable(format!("{file}: {e}")))? + { + content.extend_from_slice(&chunk); + if content.len() > IPFS_MAX_MANIFEST_BYTES { + return Err(DipsError::SubgraphManifestUnavailable(format!( + "{file}: manifest exceeds {IPFS_MAX_MANIFEST_BYTES} byte cap" + ))); + } + } let manifest: GraphManifest = serde_yaml::from_slice(&content) .map_err(|e| DipsError::InvalidSubgraphManifest(format!("{file}: {e}")))?; @@ -231,8 +266,14 @@ impl IpfsFetcher for MockIpfsFetcher { #[cfg(test)] mod test { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use crate::ipfs::{ - DataSource, FailingIpfsFetcher, GraphManifest, IpfsFetcher, MockIpfsFetcher, + DataSource, FailingIpfsFetcher, GraphManifest, IpfsClient, IpfsFetcher, MockIpfsFetcher, + IPFS_DURESS_THRESHOLD, IPFS_MAX_ATTEMPTS, }; #[test] @@ -450,4 +491,32 @@ templates: abi: Pool "; + + #[test] + fn max_attempts_uses_full_budget_below_threshold() { + // Arrange + let inflight = Arc::new(AtomicUsize::new(0)); + let client = IpfsClient::new("http://localhost:5001", inflight.clone()).unwrap(); + + // Act + Assert + assert_eq!(client.max_attempts(), IPFS_MAX_ATTEMPTS); + + // Right at the threshold still counts as below — the check is `>`. + inflight.store(IPFS_DURESS_THRESHOLD, Ordering::Relaxed); + assert_eq!(client.max_attempts(), IPFS_MAX_ATTEMPTS); + } + + #[test] + fn max_attempts_drops_to_one_above_threshold() { + // Arrange + let inflight = Arc::new(AtomicUsize::new(IPFS_DURESS_THRESHOLD + 1)); + let client = IpfsClient::new("http://localhost:5001", inflight.clone()).unwrap(); + + // Act + Assert + assert_eq!(client.max_attempts(), 1); + + // And recovers when the counter falls back. + inflight.store(0, Ordering::Relaxed); + assert_eq!(client.max_attempts(), IPFS_MAX_ATTEMPTS); + } } diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index bd9085232..d9c96faf6 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -28,21 +28,16 @@ //! # Validation Flow //! //! When an RCA arrives, this crate validates: -//! 1. **Signature** - EIP-712 signature recovers to an authorized signer -//! 2. **Signer authorization** - Signer is authorized for the payer (via escrow accounts) -//! 3. **Service provider** - RCA is addressed to this indexer -//! 4. **Timestamps** - Deadline and end time haven't passed -//! 5. **IPFS manifest** - Subgraph deployment exists and is parseable -//! 6. **Network** - Subgraph's network is supported by this indexer -//! 7. **Pricing** - Offered price meets indexer's minimum +//! 1. **Service provider** - RCA is addressed to this indexer +//! 2. **Timestamps** - Deadline and end time haven't passed +//! 3. **IPFS manifest** - Subgraph deployment exists and is parseable +//! 4. **Network** - Subgraph's network is supported by this indexer +//! 5. **Pricing** - Offered price meets indexer's minimum //! -//! # Trust Model -//! -//! Payers deposit funds into the PaymentsEscrow contract and authorize signers. -//! The escrow has a **thawing period** for withdrawals, giving indexers time to -//! collect owed fees before funds can be withdrawn. This crate checks signer -//! authorization via the network subgraph, which may lag chain state slightly. -//! The thawing period protects against this lag. +//! Signature and signer-authorization checks are NOT performed here. With the +//! switch to offer-based authorization, the on-chain `acceptIndexingAgreement` +//! call verifies the signer (via either an ECDSA signature or a pre-stored +//! payer offer) when the indexer-agent submits the acceptance transaction. //! //! # Modules //! @@ -53,19 +48,20 @@ //! - [`ipfs`] - IPFS client for subgraph manifests //! - [`price`] - Minimum price enforcement -use std::{str::FromStr, sync::Arc}; +use std::sync::Arc; use server::DipsServerContext; use thegraph_core::alloy::{ core::primitives::Address, - primitives::{b256, keccak256, ruint::aliases::U256, ChainId, Signature, Uint, B256}, + primitives::{keccak256, ruint::aliases::U256, Uint}, signers::SignerSync, sol, - sol_types::{eip712_domain, Eip712Domain, SolStruct, SolValue}, + sol_types::{Eip712Domain, SolValue}, }; #[cfg(feature = "db")] pub mod database; +pub mod inflight; pub mod ipfs; pub mod price; #[cfg(feature = "rpc")] @@ -74,7 +70,6 @@ pub mod proto; mod registry; #[cfg(feature = "rpc")] pub mod server; -pub mod signers; pub mod store; use thiserror::Error; @@ -83,35 +78,6 @@ use uuid::Uuid; /// Protocol version (seconds-based RCA) pub const PROTOCOL_VERSION: u64 = 2; -/// Create an EIP-712 domain for RecurringCollectionAgreement. -/// -/// Used to sign `RecurringCollectionAgreement` messages. The `verifying_contract` -/// is the deployed RecurringCollector address. -pub fn rca_eip712_domain(chain_id: ChainId, recurring_collector: Address) -> Eip712Domain { - eip712_domain! { - name: "RecurringCollector", - version: "1", - chain_id: chain_id, - verifying_contract: recurring_collector, - } -} - -/// EIP-712 domain salt for DIPs-specific messages. -const EIP712_DOMAIN_SALT: B256 = - b256!("a070ffb1cd7af433c73e0d016c7c4ce31dc1ec7366a3f5d20cfa22a80391e549"); - -/// Create an EIP-712 domain for cancellation requests. -/// -/// Used for signing `CancellationRequest` messages. -pub fn dips_cancellation_eip712_domain(chain_id: ChainId) -> Eip712Domain { - eip712_domain! { - name: "Graph Protocol Indexing Agreement Cancellation", - version: "0", - chain_id: chain_id, - salt: EIP712_DOMAIN_SALT, - } -} - sol! { // === RCA Types (seconds-based RecurringCollectionAgreement) === @@ -120,6 +86,7 @@ sol! { /// Matches `IRecurringCollector.RecurringCollectionAgreement` exactly. /// The agreement ID is derived on-chain via /// `bytes16(keccak256(abi.encode(payer, dataService, serviceProvider, deadline, nonce)))`. + /// Note: `conditions` is NOT included in the agreement ID preimage. #[derive(Debug, PartialEq)] struct RecurringCollectionAgreement { uint64 deadline; @@ -131,6 +98,7 @@ sol! { uint256 maxOngoingTokensPerSecond; uint32 minSecondsPerCollection; uint32 maxSecondsPerCollection; + uint16 conditions; uint256 nonce; bytes metadata; } @@ -158,18 +126,6 @@ sol! { uint256 tokensPerEntityPerSecond; } - // === Cancellation === - - #[derive(Debug, PartialEq)] - struct SignedCancellationRequest { - CancellationRequest request; - bytes signature; - } - - #[derive(Debug, PartialEq)] - struct CancellationRequest { - bytes16 agreement_id; - } } /// Derive the agreement ID deterministically from the RCA fields. @@ -194,8 +150,6 @@ fn derive_agreement_id(rca: &RecurringCollectionAgreement) -> Uuid { #[derive(Error, Debug)] pub enum DipsError { // RCA validation - #[error("signature is not valid, error: {0}")] - InvalidSignature(String), #[error("RCA service provider {actual} does not match the expected address {expected}")] UnexpectedServiceProvider { expected: Address, actual: Address }, #[error("cannot get subgraph manifest for {0}")] @@ -214,10 +168,8 @@ pub enum DipsError { }, #[error("tokens per entity per second {offered} is below configured minimum {minimum}")] TokensPerEntityPerSecondTooLow { minimum: U256, offered: U256 }, - #[error("signer {0} not authorised")] - SignerNotAuthorised(Address), - #[error("cancelled_by is expected to match the signer")] - UnexpectedSigner, + #[error("payer {0} is not in the configured allowlist")] + PayerNotAllowed(Address), // misc #[error("unknown error: {0}")] UnknownError(#[from] anyhow::Error), @@ -240,46 +192,6 @@ impl From for tonic::Status { } } -impl CancellationRequest { - pub fn sign( - &self, - domain: &Eip712Domain, - signer: S, - ) -> anyhow::Result { - let voucher = SignedCancellationRequest { - request: self.clone(), - signature: signer.sign_typed_data_sync(self, domain)?.as_bytes().into(), - }; - - Ok(voucher) - } -} - -impl SignedCancellationRequest { - // TODO: Validate all values - pub fn validate( - &self, - domain: &Eip712Domain, - expected_signer: &Address, - ) -> Result<(), DipsError> { - let sig = Signature::from_str(&self.signature.to_string()) - .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; - - let signer = sig - .recover_address_from_prehash(&self.request.eip712_signing_hash(domain)) - .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; - - if signer.ne(expected_signer) { - return Err(DipsError::UnexpectedSigner); - } - - Ok(()) - } - pub fn encode_vec(&self) -> Vec { - self.abi_encode() - } -} - // === RCA Implementations === impl RecurringCollectionAgreement { @@ -298,37 +210,19 @@ impl RecurringCollectionAgreement { } impl SignedRecurringCollectionAgreement { - /// Validate the RCA signature and basic fields. + /// Validate proposal-time fields. /// - /// Checks: - /// - EIP-712 signature is valid and recovers to an authorized signer for the payer - /// - Signer is authorized for the payer (via escrow accounts) - /// - Service provider matches expected indexer address - pub fn validate( - &self, - signer_validator: &Arc, - domain: &Eip712Domain, - expected_service_provider: &Address, - ) -> Result<(), DipsError> { - let sig = Signature::try_from(self.signature.as_ref()) - .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; - - let payer = self.agreement.payer; - let signer = sig - .recover_address_from_prehash(&self.agreement.eip712_signing_hash(domain)) - .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; - - signer_validator - .validate(&payer, &signer) - .map_err(|_| DipsError::SignerNotAuthorised(signer))?; - + /// Checks that the service provider matches the expected indexer + /// address. On-chain offer existence is NOT checked here — the offer + /// does not exist yet at proposal time. The contract enforces offer + /// existence when the indexer-agent calls `acceptIndexingAgreement`. + pub fn validate(&self, expected_service_provider: &Address) -> Result<(), DipsError> { if !self.agreement.serviceProvider.eq(expected_service_provider) { return Err(DipsError::UnexpectedServiceProvider { expected: *expected_service_provider, actual: self.agreement.serviceProvider, }); } - Ok(()) } @@ -363,14 +257,17 @@ pub(crate) fn try_extract_deployment_id(rca_bytes: &[u8]) -> Option { /// Validate and create a RecurringCollectionAgreement. /// /// Performs validation: -/// - EIP-712 signature verification +/// - Service provider match +/// - Deadline and expiry checks /// - IPFS manifest fetching and network validation /// - Price minimum enforcement /// +/// On-chain offer existence is NOT checked here — the offer doesn't exist +/// yet at proposal time. The contract enforces it at `acceptIndexingAgreement`. +/// /// Returns the agreement ID if successful, stores in database. pub async fn validate_and_create_rca( ctx: Arc, - domain: &Eip712Domain, expected_service_provider: &Address, rca_bytes: Vec, ) -> Result { @@ -378,9 +275,9 @@ pub async fn validate_and_create_rca( rca_store, ipfs_fetcher, price_calculator, - signer_validator, registry, additional_networks, + allowed_payers, .. } = ctx.as_ref(); @@ -388,8 +285,17 @@ pub async fn validate_and_create_rca( let signed_rca = SignedRecurringCollectionAgreement::abi_decode(rca_bytes.as_ref()) .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; - // Validate signature and basic fields - signed_rca.validate(signer_validator, domain, expected_service_provider)?; + // Reject proposals whose claimed payer is not in the configured allowlist + // before doing any I/O. When the allowlist is `None` every payer is + // accepted (legacy default). + if let Some(allowed) = allowed_payers { + if !allowed.contains(&signed_rca.agreement.payer) { + return Err(DipsError::PayerNotAllowed(signed_rca.agreement.payer)); + } + } + + // Validate service provider + signed_rca.validate(expected_service_provider)?; // Validate deadline hasn't passed let now = std::time::SystemTime::now() @@ -534,36 +440,43 @@ mod test { derive_agreement_id, ipfs::{FailingIpfsFetcher, MockIpfsFetcher}, price::PriceCalculator, - rca_eip712_domain, server::DipsServerContext, - signers::{NoopSignerValidator, RejectingSignerValidator}, store::{FailingRcaStore, InMemoryRcaStore}, AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, - RecurringCollectionAgreement, + RecurringCollectionAgreement, SignedRecurringCollectionAgreement, }; use thegraph_core::alloy::{ primitives::{keccak256, Address, FixedBytes, U256}, - signers::local::PrivateKeySigner, sol_types::SolValue, }; - const CHAIN_ID: u64 = 42161; // Arbitrum One - fn create_test_context() -> Arc { Arc::new(DipsServerContext { rca_store: Arc::new(InMemoryRcaStore::default()), - ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), // Returns "mainnet" + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), price_calculator: Arc::new(PriceCalculator::new( HashSet::from(["mainnet".to_string()]), BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), - signer_validator: Arc::new(NoopSignerValidator), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + allowed_payers: None, }) } + /// Helper: encode an RCA as a `SignedRecurringCollectionAgreement` + /// ABI payload. The signature field is no longer consumed by the + /// validator; this just produces the wire bytes that + /// `validate_and_create_rca` expects to decode. + fn rca_to_wire_bytes(rca: RecurringCollectionAgreement) -> Vec { + SignedRecurringCollectionAgreement { + agreement: rca, + signature: Default::default(), + } + .abi_encode() + } + fn create_test_rca( payer: Address, service_provider: Address, @@ -592,6 +505,7 @@ mod test { maxOngoingTokensPerSecond: U256::from(100), minSecondsPerCollection: 60, maxSecondsPerCollection: 3600, + conditions: 0, nonce: U256::from(1), metadata: metadata.abi_encode().into(), } @@ -609,6 +523,7 @@ mod test { maxOngoingTokensPerSecond: U256::from(10), minSecondsPerCollection: 60, maxSecondsPerCollection: 3600, + conditions: 0, nonce: U256::from(42), metadata: Default::default(), }; @@ -653,6 +568,7 @@ mod test { maxOngoingTokensPerSecond: U256::from(1_000_000_000_000_000u64), minSecondsPerCollection: 3600, maxSecondsPerCollection: 86400, + conditions: 0, nonce: U256::from(0x019d44a86ac97e938672e2501fe630f2u128), metadata: Default::default(), }; @@ -679,26 +595,50 @@ mod test { ); } + /// Guards against drift in the sol! struct layout for the audit-branch + /// `conditions` field. If `conditions` were ever moved, renamed, or + /// dropped from the decoder, this round-trip would either fail to + /// decode or return a corrupted value in the field's slot. + #[test] + fn test_rca_conditions_field_roundtrip() { + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let mut rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + rca.conditions = 0xABCD; // arbitrary non-zero 16-bit value + + let encoded = rca_to_wire_bytes(rca.clone()); + let decoded = SignedRecurringCollectionAgreement::abi_decode(encoded.as_ref()) + .expect("roundtrip decode failed"); + + assert_eq!( + decoded.agreement.conditions, 0xABCD, + "conditions field did not survive ABI round-trip" + ); + // Cross-check surrounding fields are intact, so a failure of the + // conditions field isn't silently misread from a neighbour's slot. + assert_eq!( + decoded.agreement.maxSecondsPerCollection, + rca.maxSecondsPerCollection + ); + assert_eq!(decoded.agreement.nonce, rca.nonce); + } + #[tokio::test] async fn test_validate_and_create_rca_success() { - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); let agreement_id = derive_agreement_id(&rca); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); - let ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); + let result = - super::validate_and_create_rca(ctx.clone(), &domain, &service_provider, rca_bytes) - .await; + super::validate_and_create_rca(ctx.clone(), &service_provider, rca_bytes).await; - assert!(result.is_ok()); + assert!(result.is_ok(), "got: {:?}", result); assert_eq!(result.unwrap(), agreement_id); // Verify it was stored @@ -711,11 +651,9 @@ mod test { #[tokio::test] async fn test_validate_and_create_rca_wrong_service_provider() { - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); let wrong_service_provider = Address::repeat_byte(0x99); - let recurring_collector = Address::repeat_byte(0x22); let rca = create_test_rca( payer, @@ -724,13 +662,10 @@ mod test { U256::from(100), ); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); - let ctx = create_test_context(); - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; assert!(matches!( result, @@ -738,23 +673,78 @@ mod test { )); } + #[tokio::test] + async fn test_validate_and_create_rca_rejects_payer_not_in_allowlist() { + let payer = Address::repeat_byte(0x42); + let allowed_payer = Address::repeat_byte(0x99); + let service_provider = Address::repeat_byte(0x11); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + allowed_payers: Some(HashSet::from([allowed_payer])), + }); + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!( + matches!(result, Err(DipsError::PayerNotAllowed(addr)) if addr == payer), + "Expected PayerNotAllowed({:?}), got {:?}", + payer, + result + ); + } + + #[tokio::test] + async fn test_validate_and_create_rca_accepts_payer_in_allowlist() { + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + allowed_payers: Some(HashSet::from([payer])), + }); + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(result.is_ok(), "expected Ok, got {:?}", result); + assert_eq!(result.unwrap(), agreement_id); + } + #[tokio::test] async fn test_validate_and_create_rca_tokens_per_second_too_low() { - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); // Offer 50, minimum is 100 let rca = create_test_rca(payer, service_provider, U256::from(50), U256::from(100)); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); - let ctx = create_test_context(); - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; assert!(matches!( result, @@ -764,21 +754,16 @@ mod test { #[tokio::test] async fn test_validate_and_create_rca_entity_price_too_low() { - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); // Offer 200 tokens/sec (ok), but only 10 entity price (minimum is 50) let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(10)); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); - let ctx = create_test_context(); - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; assert!(matches!( result, @@ -788,17 +773,11 @@ mod test { #[tokio::test] async fn test_validate_and_create_rca_unsupported_network() { - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); - // Create context with IPFS fetcher returning unsupported network let ctx = Arc::new(DipsServerContext { rca_store: Arc::new(InMemoryRcaStore::default()), @@ -810,23 +789,21 @@ mod test { BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), - signer_validator: Arc::new(NoopSignerValidator), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + allowed_payers: None, }); - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let rca_bytes = rca_to_wire_bytes(rca); + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; assert!(matches!(result, Err(DipsError::UnsupportedNetwork(_)))); } #[tokio::test] async fn test_validate_and_create_rca_invalid_metadata_version() { - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); let terms = IndexingAgreementTermsV1 { tokensPerSecond: U256::from(200), @@ -850,17 +827,15 @@ mod test { maxOngoingTokensPerSecond: U256::from(100), minSecondsPerCollection: 60, maxSecondsPerCollection: 3600, + conditions: 0, nonce: U256::from(1), metadata: metadata.abi_encode().into(), }; - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); - let ctx = create_test_context(); - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; assert!(matches!( result, @@ -870,10 +845,8 @@ mod test { #[tokio::test] async fn test_validate_and_create_rca_deadline_expired() { - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); let terms = IndexingAgreementTermsV1 { tokensPerSecond: U256::from(200), @@ -897,27 +870,23 @@ mod test { maxOngoingTokensPerSecond: U256::from(100), minSecondsPerCollection: 60, maxSecondsPerCollection: 3600, + conditions: 0, nonce: U256::from(1), metadata: metadata.abi_encode().into(), }; - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); - let ctx = create_test_context(); - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; assert!(matches!(result, Err(DipsError::DeadlineExpired { .. }))); } #[tokio::test] async fn test_validate_and_create_rca_agreement_expired() { - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); let terms = IndexingAgreementTermsV1 { tokensPerSecond: U256::from(200), @@ -941,17 +910,15 @@ mod test { maxOngoingTokensPerSecond: U256::from(100), minSecondsPerCollection: 60, maxSecondsPerCollection: 3600, + conditions: 0, nonce: U256::from(1), metadata: metadata.abi_encode().into(), }; - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); - let ctx = create_test_context(); - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; assert!(matches!(result, Err(DipsError::AgreementExpired { .. }))); } @@ -964,15 +931,12 @@ mod test { async fn test_validate_and_create_rca_malformed_abi() { // Arrange let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); let ctx = create_test_context(); let malformed_bytes = vec![0xDE, 0xAD, 0xBE, 0xEF]; // Not valid ABI // Act - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, malformed_bytes).await; + let result = super::validate_and_create_rca(ctx, &service_provider, malformed_bytes).await; // Assert assert!( @@ -982,57 +946,13 @@ mod test { ); } - #[tokio::test] - async fn test_validate_and_create_rca_unauthorized_signer() { - // Arrange - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); - let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); - - let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); - - // Context with rejecting signer validator - let ctx = Arc::new(DipsServerContext { - rca_store: Arc::new(InMemoryRcaStore::default()), - ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), - price_calculator: Arc::new(PriceCalculator::new( - HashSet::from(["mainnet".to_string()]), - BTreeMap::from([("mainnet".to_string(), U256::from(100))]), - U256::from(50), - )), - signer_validator: Arc::new(RejectingSignerValidator), - registry: Arc::new(crate::registry::test_registry()), - additional_networks: Arc::new(BTreeMap::new()), - }); - - // Act - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; - - // Assert - assert!( - matches!(result, Err(DipsError::SignerNotAuthorised(_))), - "Expected SignerNotAuthorised error, got: {:?}", - result - ); - } - #[tokio::test] async fn test_validate_and_create_rca_ipfs_failure() { // Arrange - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); // Context with failing IPFS fetcher let ctx = Arc::new(DipsServerContext { @@ -1043,14 +963,15 @@ mod test { BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), - signer_validator: Arc::new(NoopSignerValidator), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + allowed_payers: None, }); + let rca_bytes = rca_to_wire_bytes(rca); + // Act - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; // Assert assert!( @@ -1063,15 +984,10 @@ mod test { #[tokio::test] async fn test_validate_and_create_rca_manifest_no_network() { // Arrange - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); // Context with IPFS fetcher returning manifest without network let ctx = Arc::new(DipsServerContext { @@ -1082,14 +998,15 @@ mod test { BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), - signer_validator: Arc::new(NoopSignerValidator), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + allowed_payers: None, }); + let rca_bytes = rca_to_wire_bytes(rca); + // Act - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; // Assert assert!( @@ -1102,15 +1019,10 @@ mod test { #[tokio::test] async fn test_validate_and_create_rca_store_failure() { // Arrange - let payer_signer = PrivateKeySigner::random(); - let payer = payer_signer.address(); + let payer = Address::repeat_byte(0x42); let service_provider = Address::repeat_byte(0x11); - let recurring_collector = Address::repeat_byte(0x22); let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); - let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); - let signed_rca = rca.sign(&domain, payer_signer).unwrap(); - let rca_bytes = signed_rca.abi_encode(); // Context with failing store let ctx = Arc::new(DipsServerContext { @@ -1121,14 +1033,15 @@ mod test { BTreeMap::from([("mainnet".to_string(), U256::from(100))]), U256::from(50), )), - signer_validator: Arc::new(NoopSignerValidator), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + allowed_payers: None, }); + let rca_bytes = rca_to_wire_bytes(rca); + // Act - let result = - super::validate_and_create_rca(ctx, &domain, &service_provider, rca_bytes).await; + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; // Assert assert!( diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 84a10682c..e7ba7dbb8 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -13,8 +13,7 @@ //! │ //! ├─ Version check (must be 2) //! ├─ Size validation (non-empty, max 10KB) -//! ├─ Signature verification -//! ├─ Signer authorization check +//! ├─ Service-provider match //! ├─ Timestamp validation (deadline, endsAt) //! ├─ IPFS manifest fetch //! ├─ Network validation @@ -25,6 +24,11 @@ //! └─> Return Accept/Reject //! ``` //! +//! Signature and signer-authorization checks are not performed here. The +//! on-chain `acceptIndexingAgreement` call verifies the signer (via either +//! an ECDSA signature or a pre-stored payer offer) when the indexer-agent +//! submits the acceptance transaction. +//! //! # Response Behavior //! //! Returns `Accept` if the RCA passes all validation and is stored successfully. @@ -36,13 +40,17 @@ //! The `cancel_agreement` endpoint is unimplemented. Cancellation is handled //! on-chain via the RecurringCollector contract, not through this gRPC interface. -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + collections::{BTreeMap, HashSet}, + sync::Arc, +}; use async_trait::async_trait; use thegraph_core::alloy::primitives::Address; use tonic::{Request, Response, Status}; use crate::{ + inflight::{InflightCounter, InflightGuard}, ipfs::IpfsFetcher, price::PriceCalculator, proto::indexer::graphprotocol::indexer::dips::{ @@ -50,7 +58,6 @@ use crate::{ CancelAgreementResponse, ProposalResponse, RejectReason, SubmitAgreementProposalRequest, SubmitAgreementProposalResponse, }, - signers::SignerValidator, store::RcaStore, DipsError, }; @@ -58,7 +65,6 @@ use crate::{ /// Context for DIPS server with all validation dependencies. /// /// Used for RCA validation: -/// - Signature verification /// - IPFS manifest fetching /// - Price minimum enforcement /// - Network registry lookups @@ -70,18 +76,20 @@ pub struct DipsServerContext { pub ipfs_fetcher: Arc, /// Price calculator for validating minimum prices pub price_calculator: Arc, - /// Signature validator for EIP-712 verification - pub signer_validator: Arc, /// Network registry for supported networks pub registry: Arc, /// Additional networks beyond the registry pub additional_networks: Arc>, + /// Optional allowlist of payer addresses. When `Some`, proposals whose + /// `payer` field is not in this set are rejected before any I/O. When + /// `None`, every payer is accepted (legacy default). + pub allowed_payers: Option>, } /// DIPS server implementing RCA protocol. /// /// Validates RecurringCollectionAgreement proposals before storage: -/// - EIP-712 signature verification +/// - Service-provider match /// - IPFS manifest fetching and network validation /// - Price minimum enforcement /// @@ -90,9 +98,10 @@ pub struct DipsServerContext { pub struct DipsServer { pub ctx: Arc, pub expected_payee: Address, - pub chain_id: u64, - /// RecurringCollector contract address for EIP-712 domain - pub recurring_collector: Address, + /// Shared counter incremented for every request that enters the handler. + /// The IPFS client reads it to decide whether to use the full retry + /// budget or fall back to a single attempt under load. + pub inflight: InflightCounter, } /// Map a DipsError to the appropriate RejectReason for the gRPC response. @@ -100,13 +109,16 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { match err { DipsError::TokensPerSecondTooLow { .. } | DipsError::TokensPerEntityPerSecondTooLow { .. } => RejectReason::PriceTooLow, - DipsError::SignerNotAuthorised(_) => RejectReason::SignerNotAuthorised, DipsError::DeadlineExpired { .. } => RejectReason::DeadlineExpired, DipsError::AgreementExpired { .. } => RejectReason::AgreementExpired, DipsError::UnsupportedNetwork(_) => RejectReason::UnsupportedNetwork, DipsError::SubgraphManifestUnavailable(_) => RejectReason::SubgraphManifestUnavailable, DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, + // Deliberately maps to Other so the wire response doesn't disclose + // why the rejection happened — a caller probing for who is on the + // allowlist would otherwise learn that by trial and error. + DipsError::PayerNotAllowed(_) => RejectReason::Other, _ => RejectReason::Other, } } @@ -117,15 +129,20 @@ impl IndexerDipsService for DipsServer { /// /// Validates: /// - Version 2 only - /// - EIP-712 signature + /// - Service provider match /// - IPFS manifest and network compatibility /// - Price minimums /// + /// On-chain offer existence is NOT checked — the offer doesn't exist yet + /// at proposal time. The contract enforces it at `acceptIndexingAgreement`. + /// /// Returns Accept/Reject based on validation results. async fn submit_agreement_proposal( &self, request: Request, ) -> Result, Status> { + let _guard = InflightGuard::new(self.inflight.clone()); + let SubmitAgreementProposalRequest { version, signed_voucher, @@ -151,15 +168,9 @@ impl IndexerDipsService for DipsServer { } // Validate and store RCA - let domain = crate::rca_eip712_domain(self.chain_id, self.recurring_collector); let deployment_id = crate::try_extract_deployment_id(&signed_voucher); - match crate::validate_and_create_rca( - self.ctx.clone(), - &domain, - &self.expected_payee, - signed_voucher, - ) - .await + match crate::validate_and_create_rca(self.ctx.clone(), &self.expected_payee, signed_voucher) + .await { Ok(agreement_id) => { tracing::info!(%agreement_id, "RCA accepted"); @@ -199,11 +210,14 @@ impl IndexerDipsService for DipsServer { #[cfg(test)] mod tests { + use std::sync::atomic::AtomicUsize; + use super::*; - use crate::{ - ipfs::MockIpfsFetcher, price::PriceCalculator, signers::NoopSignerValidator, - store::InMemoryRcaStore, - }; + use crate::{ipfs::MockIpfsFetcher, price::PriceCalculator, store::InMemoryRcaStore}; + + fn empty_counter() -> InflightCounter { + Arc::new(AtomicUsize::new(0)) + } impl DipsServerContext { pub fn for_testing() -> Arc { @@ -218,9 +232,9 @@ mod tests { BTreeMap::from([("mainnet".to_string(), U256::from(200))]), U256::from(100), )), - signer_validator: Arc::new(NoopSignerValidator), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + allowed_payers: None, }) } } @@ -232,8 +246,7 @@ mod tests { let server = DipsServer { ctx, expected_payee: Address::ZERO, - chain_id: 1, - recurring_collector: Address::ZERO, + inflight: empty_counter(), }; let request = Request::new(SubmitAgreementProposalRequest { version: 2, @@ -255,8 +268,7 @@ mod tests { let server = DipsServer { ctx, expected_payee: Address::ZERO, - chain_id: 1, - recurring_collector: Address::ZERO, + inflight: empty_counter(), }; let large_payload = vec![0u8; 10_001]; let request = Request::new(SubmitAgreementProposalRequest { @@ -279,8 +291,7 @@ mod tests { let server = DipsServer { ctx, expected_payee: Address::ZERO, - chain_id: 1, - recurring_collector: Address::ZERO, + inflight: empty_counter(), }; let request = Request::new(SubmitAgreementProposalRequest { version: 1, @@ -303,8 +314,7 @@ mod tests { let server = DipsServer { ctx, expected_payee: Address::ZERO, - chain_id: 1, - recurring_collector: Address::ZERO, + inflight: empty_counter(), }; let request = Request::new(CancelAgreementRequest { version: 2, @@ -368,30 +378,6 @@ mod tests { assert_eq!(reason, RejectReason::UnsupportedNetwork); } - #[test] - fn test_reject_reason_invalid_signature() { - // Arrange - let err = DipsError::InvalidSignature("bad signature".to_string()); - - // Act - let reason = super::reject_reason_from_error(&err); - - // Assert - assert_eq!(reason, RejectReason::Other); - } - - #[test] - fn test_reject_reason_signer_not_authorised() { - // Arrange - let err = DipsError::SignerNotAuthorised(Address::ZERO); - - // Act - let reason = super::reject_reason_from_error(&err); - - // Assert - assert_eq!(reason, RejectReason::SignerNotAuthorised); - } - #[test] fn test_reject_reason_deadline_expired() { // Arrange diff --git a/crates/dips/src/signers.rs b/crates/dips/src/signers.rs deleted file mode 100644 index f70b283f4..000000000 --- a/crates/dips/src/signers.rs +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. -// SPDX-License-Identifier: Apache-2.0 - -//! Signer authorization for DIPS agreements. -//! -//! When Dipper sends an RCA proposal, it's signed by a key that may differ from -//! the payer's address. Payers authorize signers via the PaymentsEscrow contract, -//! and this authorization data is indexed by the network subgraph. -//! -//! # How It Works -//! -//! [`EscrowSignerValidator`] wraps an `EscrowAccountsWatcher` that periodically -//! syncs escrow account data from the network subgraph. When validating an RCA: -//! -//! 1. Recover the signer address from the EIP-712 signature -//! 2. Look up authorized signers for the payer address -//! 3. Verify the recovered signer is in the authorized list -//! -//! # Security Considerations -//! -//! The network subgraph may lag behind chain state. This means: -//! - A newly authorized signer might be rejected briefly (UX issue, not security) -//! - A revoked signer might be accepted briefly (security concern) -//! -//! The **thawing period** on escrow withdrawals mitigates the second case. -//! Payers cannot withdraw funds instantly - they must wait through a thawing -//! period that exceeds the maximum expected subgraph lag. This gives indexers -//! time to collect owed fees before funds disappear. - -use anyhow::anyhow; -use thegraph_core::alloy::primitives::Address; - -pub trait SignerValidator: Sync + Send + std::fmt::Debug { - fn validate(&self, payer: &Address, signer: &Address) -> Result<(), anyhow::Error>; -} - -#[cfg(feature = "db")] -mod escrow_validator { - use super::*; - #[cfg(test)] - use indexer_monitor::EscrowAccounts; - use indexer_monitor::EscrowAccountsWatcher; - - #[derive(Debug)] - pub struct EscrowSignerValidator { - watcher: EscrowAccountsWatcher, - } - - impl EscrowSignerValidator { - pub fn new(watcher: EscrowAccountsWatcher) -> Self { - Self { watcher } - } - - #[cfg(test)] - pub fn mock(accounts: EscrowAccounts) -> Self { - let (_tx, rx) = tokio::sync::watch::channel(accounts); - Self::new(rx) - } - } - - impl SignerValidator for EscrowSignerValidator { - fn validate(&self, payer: &Address, signer: &Address) -> Result<(), anyhow::Error> { - let signers = self.watcher.borrow().get_signers_for_sender(payer); - - if !signers.contains(signer) { - return Err(anyhow!("Signer is not a valid signer for the sender")); - } - - Ok(()) - } - } -} - -#[cfg(feature = "db")] -pub use escrow_validator::EscrowSignerValidator; - -#[derive(Debug)] -pub struct NoopSignerValidator; - -impl SignerValidator for NoopSignerValidator { - fn validate(&self, _payer: &Address, _signer: &Address) -> Result<(), anyhow::Error> { - Ok(()) - } -} - -/// Test validator that always rejects signers. -#[derive(Debug)] -pub struct RejectingSignerValidator; - -impl SignerValidator for RejectingSignerValidator { - fn validate(&self, _payer: &Address, _signer: &Address) -> Result<(), anyhow::Error> { - Err(anyhow!("Signer not authorized (test validator)")) - } -} - -#[cfg(test)] -mod test { - use thegraph_core::alloy::primitives::Address; - - use crate::signers::{NoopSignerValidator, RejectingSignerValidator, SignerValidator}; - - #[test] - fn test_noop_validator_always_accepts() { - // Arrange - let validator = NoopSignerValidator; - let payer = Address::ZERO; - let signer = Address::from_slice(&[0xAB; 20]); - - // Act - let result = validator.validate(&payer, &signer); - - // Assert - assert!(result.is_ok(), "NoopSignerValidator should always accept"); - } - - #[test] - fn test_rejecting_validator_always_rejects() { - // Arrange - let validator = RejectingSignerValidator; - let payer = Address::ZERO; - let signer = Address::from_slice(&[0xAB; 20]); - - // Act - let result = validator.validate(&payer, &signer); - - // Assert - assert!( - result.is_err(), - "RejectingSignerValidator should always reject" - ); - } -} - -#[cfg(all(test, feature = "db"))] -mod escrow_tests { - use std::collections::HashMap; - - use indexer_monitor::EscrowAccounts; - use thegraph_core::alloy::primitives::Address; - - use crate::signers::SignerValidator; - - #[tokio::test] - async fn test_escrow_validator_authorized_signer() { - // Arrange - let payer = Address::ZERO; - let authorized_signer = Address::from_slice(&[1u8; 20]); - let (_tx, watcher) = tokio::sync::watch::channel(EscrowAccounts::new( - HashMap::default(), - HashMap::from_iter(vec![(payer, vec![authorized_signer])]), - )); - let validator = super::EscrowSignerValidator::new(watcher); - - // Act & Assert - assert!( - validator.validate(&payer, &authorized_signer).is_ok(), - "Authorized signer should be accepted" - ); - } - - #[tokio::test] - async fn test_escrow_validator_unauthorized_signer() { - // Arrange - let payer = Address::ZERO; - let authorized_signer = Address::from_slice(&[1u8; 20]); - let unauthorized_signer = Address::from_slice(&[2u8; 20]); - let (_tx, watcher) = tokio::sync::watch::channel(EscrowAccounts::new( - HashMap::default(), - HashMap::from_iter(vec![(payer, vec![authorized_signer])]), - )); - let validator = super::EscrowSignerValidator::new(watcher); - - // Act - let result = validator.validate(&payer, &unauthorized_signer); - - // Assert - assert!(result.is_err(), "Unauthorized signer should be rejected"); - } - - #[tokio::test] - async fn test_escrow_validator_payer_not_signer() { - // Arrange - payer authorizes someone else, not themselves - let payer = Address::ZERO; - let other_signer = Address::from_slice(&[1u8; 20]); - let (_tx, watcher) = tokio::sync::watch::channel(EscrowAccounts::new( - HashMap::default(), - HashMap::from_iter(vec![(payer, vec![other_signer])]), - )); - let validator = super::EscrowSignerValidator::new(watcher); - - // Act - let result = validator.validate(&payer, &payer); - - // Assert - assert!( - result.is_err(), - "Payer signing for themselves without authorization should be rejected" - ); - } -} diff --git a/crates/service/Cargo.toml b/crates/service/Cargo.toml index 509f11e30..bf0478851 100644 --- a/crates/service/Cargo.toml +++ b/crates/service/Cargo.toml @@ -58,7 +58,7 @@ axum-extra = { version = "0.12.0", features = [ tokio-util = "0.7.10" cost-model = { git = "https://github.com/graphprotocol/agora", rev = "e9530de5f782d68ed409e2a18c62ec532db23737" } bip39.workspace = true -tower = "0.5.1" +tower = { version = "0.5.1", features = ["buffer", "limit", "timeout", "util"] } pin-project = "1.1.7" tonic.workspace = true itertools = "0.14.0" diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 7438ce7a4..50574cbdd 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -1,7 +1,11 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::{net::SocketAddr, sync::Arc, time::Duration}; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; use anyhow::{anyhow, Context}; use axum::{extract::Request, serve, ServiceExt}; @@ -16,15 +20,20 @@ use indexer_dips::{ IndexerDipsService, IndexerDipsServiceServer, }, server::{DipsServer, DipsServerContext}, - signers::EscrowSignerValidator, }; use indexer_monitor::{DeploymentDetails, SubgraphClient}; use release::IndexerServiceRelease; use reqwest::Url; use tap_core::tap_eip712_domain; -use thegraph_core::alloy::primitives::{Address, U256}; +use thegraph_core::alloy::primitives::U256; use tokio::{net::TcpListener, signal}; use tokio_util::sync::CancellationToken; +use tonic::transport::server::TcpConnectInfo; +use tower::ServiceBuilder; +use tower_governor::{ + errors::GovernorError, governor::GovernorConfigBuilder, key_extractor::KeyExtractor, + GovernorLayer, +}; use tower_http::normalize_path::NormalizePath; use tracing::info; @@ -33,10 +42,13 @@ use crate::{ routes::DipsInfoState, }; +mod grpc_error_to_response; mod release; mod router; mod tap_receipt_header; +use grpc_error_to_response::GrpcErrorToResponseLayer; + pub use router::ServiceRouter; pub use tap_receipt_header::TapHeader; @@ -118,8 +130,6 @@ pub async fn run() -> anyhow::Result<()> { config.blockchain.horizon_receipts_verifier_address(), tap_core::TapVersion::V2, ); - let chain_id = config.blockchain.chain_id as u64; - let host_and_port = config.service.host_and_port; let indexer_address = config.indexer.indexer_address; let ipfs_url = config.service.ipfs_url.clone(); @@ -195,21 +205,14 @@ pub async fn run() -> anyhow::Result<()> { let DipsConfig { host, port, - recurring_collector, supported_networks, min_grt_per_30_days, min_grt_per_billion_entities_per_30_days, additional_networks, + allowed_payers, + .. } = dips; - // Validate required configuration - if *recurring_collector == Address::ZERO { - anyhow::bail!( - "DIPS is enabled but dips.recurring_collector is not configured. \ - Set it to the deployed RecurringCollector contract address." - ); - } - if supported_networks.is_empty() { tracing::warn!( "DIPS enabled but no networks in dips.supported_networks. \ @@ -219,7 +222,6 @@ pub async fn run() -> anyhow::Result<()> { tracing::info!( supported_networks = ?supported_networks, - recurring_collector = %recurring_collector, ipfs_url = %ipfs_url, "DIPs configuration loaded" ); @@ -239,8 +241,13 @@ pub async fn run() -> anyhow::Result<()> { .parse() .with_context(|| format!("Invalid DIPS host:port '{host}:{port}'"))?; + // Shared counter of in-flight gRPC requests. The IPFS client reads + // it to decide whether to use the full retry budget or fall back to + // a single attempt when the service is under load. + let inflight = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + // Initialize validation dependencies - let ipfs_fetcher = Arc::new(IpfsClient::new(ipfs_url.as_str())?); + let ipfs_fetcher = Arc::new(IpfsClient::new(ipfs_url.as_str(), inflight.clone())?); let registry = Arc::new( NetworksRegistry::from_latest_version() .await @@ -280,22 +287,20 @@ pub async fn run() -> anyhow::Result<()> { tokens_per_second, tokens_per_entity_per_second, )), - signer_validator: Arc::new(EscrowSignerValidator::new(v2_watcher.clone())), registry, additional_networks: Arc::new(additional_networks.clone()), + allowed_payers: allowed_payers.clone(), }); // Create DIPS server let server = DipsServer { ctx, expected_payee: indexer_address, - chain_id, - recurring_collector: *recurring_collector, + inflight, }; info!( address = %addr, - recurring_collector = ?recurring_collector, "Starting DIPS gRPC server (RecurringCollectionAgreement validation)" ); @@ -318,12 +323,85 @@ pub async fn run() -> anyhow::Result<()> { .await?) } +/// Per-request timeout across the whole gRPC handler. Long enough to cover +/// the worst-case IPFS retry budget (190s) with headroom; short enough that +/// a hung handler doesn't pin a worker indefinitely. +const DIPS_REQUEST_TIMEOUT: Duration = Duration::from_secs(220); + +/// Global token-bucket rate limit shared across all callers. Bounds the +/// total proposal throughput regardless of per-IP behaviour. Sized to +/// accommodate burst traffic from a single trusted dipper. +const DIPS_RATE_LIMIT_PER_SEC: u64 = 50; + +/// Per-IP rate limit replenishment interval. 200ms per token gives a +/// sustained 5 requests per second per source IP, which is comfortable +/// headroom for a real dipper but quickly cuts off any single misbehaving +/// caller. +const DIPS_PER_IP_REPLENISH_MS: u64 = 200; + +/// Burst allowance for the per-IP limiter. Lets a caller send a brief +/// spike without immediately tripping the limit. +const DIPS_PER_IP_BURST: u32 = 10; + +/// Channel depth of the outer Buffer wrapper. The wrapper makes the layer +/// chain Clone-able so tonic's `Server::layer` accepts it; the actual +/// timeout/rate-limit/per-IP layers run inside the buffered task. Requests +/// beyond this depth are rejected with `BufferError` until earlier ones +/// drain. Sized comfortably above the global rate-limit-per-second so a +/// healthy burst never bumps the channel. +const DIPS_BUFFER_DEPTH: usize = 1024; + +/// Key extractor for tonic that reads the peer IP from `TcpConnectInfo`, +/// which the tonic server adds to request extensions for non-TLS TCP +/// connections. The `tower_governor` defaults look for axum's +/// `ConnectInfo` extension instead, which tonic does not add. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TonicPeerIpKeyExtractor; + +impl KeyExtractor for TonicPeerIpKeyExtractor { + type Key = IpAddr; + + fn extract(&self, req: &axum::http::Request) -> Result { + req.extensions() + .get::() + .and_then(|ci| ci.remote_addr()) + .map(|addr| addr.ip()) + .ok_or(GovernorError::UnableToExtractKey) + } +} + async fn start_dips_server( addr: SocketAddr, service: impl IndexerDipsService, shutdown: impl std::future::Future, ) { + let per_ip_config = Arc::new( + GovernorConfigBuilder::default() + .per_millisecond(DIPS_PER_IP_REPLENISH_MS) + .burst_size(DIPS_PER_IP_BURST) + .key_extractor(TonicPeerIpKeyExtractor) + .finish() + .expect("per-IP governor config invariants"), + ); + let per_ip_layer = GovernorLayer { + config: per_ip_config, + }; + + let layer = ServiceBuilder::new() + .layer(GrpcErrorToResponseLayer) + .buffer(DIPS_BUFFER_DEPTH) + .timeout(DIPS_REQUEST_TIMEOUT) + .layer(per_ip_layer) + .rate_limit(DIPS_RATE_LIMIT_PER_SEC, Duration::from_secs(1)) + // tonic's Routes returns Response, but tower_governor + // hardcodes Response. Convert the inner body before + // the rate-limit layers see it. tonic's Server is happy to serve any + // http_body::Body for the response, so axum's body works end-to-end. + .map_response(|res: axum::http::Response| res.map(axum::body::Body::new)) + .into_inner(); + if let Err(e) = tonic::transport::Server::builder() + .layer(layer) .add_service(IndexerDipsServiceServer::new(service)) .serve_with_shutdown(addr, shutdown) .await diff --git a/crates/service/src/service/grpc_error_to_response.rs b/crates/service/src/service/grpc_error_to_response.rs new file mode 100644 index 000000000..db0a78cf3 --- /dev/null +++ b/crates/service/src/service/grpc_error_to_response.rs @@ -0,0 +1,144 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! Catches errors from inner tower layers and turns them into gRPC +//! error responses. +//! +//! Tonic's `Server::add_service` requires its routed service to declare +//! `Error = Infallible` — a promise that the service never fails. Tower +//! layers like `RateLimit`, `Timeout`, the per-IP `GovernorLayer`, and +//! `Buffer` all return real errors, so wrapping them around tonic's +//! `Routes` would break that promise. +//! +//! This wrapper sits outside the inner stack, intercepts any inner +//! error, and emits a successful HTTP response carrying an appropriate +//! gRPC status code. The wrapper's own error type is `Infallible`, +//! restoring tonic's expected bound. + +use std::{ + convert::Infallible, + future::Future, + marker::PhantomData, + pin::Pin, + task::{Context, Poll}, +}; + +use axum::http::{Request, Response}; +use pin_project::pin_project; +use tonic::Status; +use tower::{BoxError, Layer, Service}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct GrpcErrorToResponseLayer; + +impl Layer for GrpcErrorToResponseLayer { + type Service = GrpcErrorToResponse; + + fn layer(&self, inner: S) -> Self::Service { + GrpcErrorToResponse { inner } + } +} + +#[derive(Clone, Debug)] +pub struct GrpcErrorToResponse { + inner: S, +} + +impl Service> for GrpcErrorToResponse +where + S: Service, Response = Response>, + S::Error: Into, + RespBody: Default, +{ + type Response = Response; + type Error = Infallible; + type Future = GrpcErrorToResponseFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + match self.inner.poll_ready(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => Poll::Ready(Ok(())), + // The inner stack's `poll_ready` should not error in practice + // — buffer/timeout/rate-limit/governor surface errors through + // the response future, not through readiness. If it does, log + // and report ready; the next `call` will return an error that + // we then map to a status. + Poll::Ready(Err(e)) => { + let boxed: BoxError = e.into(); + tracing::error!( + error = %boxed, + "DIPs middleware poll_ready returned an error; reporting ready and deferring to call" + ); + Poll::Ready(Ok(())) + } + } + } + + fn call(&mut self, req: Request) -> Self::Future { + GrpcErrorToResponseFuture { + inner: self.inner.call(req), + _body: PhantomData, + } + } +} + +#[pin_project] +pub struct GrpcErrorToResponseFuture { + #[pin] + inner: F, + _body: PhantomData, +} + +impl Future for GrpcErrorToResponseFuture +where + F: Future, E>>, + E: Into, + B: Default, +{ + type Output = Result, Infallible>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + match this.inner.poll(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(resp)) => Poll::Ready(Ok(resp)), + Poll::Ready(Err(e)) => { + let boxed: BoxError = e.into(); + let status = classify(&*boxed); + tracing::warn!( + error = %boxed, + code = ?status.code(), + "DIPs middleware mapped inner error to gRPC status" + ); + Poll::Ready(Ok(status.into_http())) + } + } + } +} + +/// Walk the error chain looking for known inner-stack failures and +/// map each to the closest matching gRPC status code. Falls back to +/// `Internal` when nothing matches, which mirrors tonic's default for +/// unhandled service failures. +fn classify(e: &(dyn std::error::Error + 'static)) -> Status { + let mut current: Option<&(dyn std::error::Error + 'static)> = Some(e); + while let Some(err) = current { + if err + .downcast_ref::() + .is_some() + { + return Status::deadline_exceeded("request exceeded the DIPs handler timeout"); + } + if err + .downcast_ref::() + .is_some() + { + return Status::resource_exhausted("per-IP rate limit exceeded"); + } + if err.downcast_ref::().is_some() { + return Status::unavailable("DIPs handler queue closed"); + } + current = err.source(); + } + Status::internal("DIPs handler internal error") +} From 29b2997be9d10edf9fce11856001f8633f8d5b43 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Thu, 28 May 2026 01:35:43 +1200 Subject: [PATCH 15/31] refactor: remove dead DIPs cancel and collect endpoints (#1036) * feat(dips): switch to on-chain offer-based authorization The smart contract now verifies authorisation via on-chain offers at agreement acceptance, so the indexer-side signature check has no job left. The RCA struct gains a `conditions` field to match the contract. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dips): cap IPFS manifest size at 5MB Real subgraph manifests are tens of kilobytes, but the IPFS hash in a proposal is chosen by the caller. Without a cap, a hostile caller can point at very large content and force the indexer to download it all. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(service): apply rate-limit and timeout to DIPs gRPC Adds two tower layers on the DIPs gRPC server: a 220-second timeout covering the IPFS retry budget plus headroom, and a 50-token-per- second global rate limit shared across callers. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dips): cut IPFS retries when many proposals are in flight When too many proposals are in flight, the IPFS retry budget lets a hostile caller hold handler slots for up to 190s. The new counter tracks in-flight requests; above 200 the IPFS client tries once. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(service): rate-limit DIPs proposals per source IP A new layer rate-limits per source IP: burst of 10, then 5 tokens per second sustained. Keyed on the peer IP via tonic's TcpConnectInfo extension. Single-source spam is cut off without touching other IPs. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dips): allowlist accepted payers via DipsConfig A new `allowed_payers` config field gates which payer addresses the indexer-service will even consider. Omit the field for legacy permissive behaviour; set an empty array to deny everyone; list addresses to allow. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(service): wrap DIPs gRPC middleware to make it cloneable The gRPC layer requires the wrapping around it to be cloneable, but the rate-limit and per-IP pieces aren't on their own. Putting a buffer around the whole chain makes it cloneable. The buffer's channel is sized so a healthy burst at the global rate never bumps it. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(service): convert response body so DIPs middleware compiles Tower_governor wants axum's body type, while tonic's routing layer hands back its own; the rate-limit stack would not build. Convert in between, and turn any inner stack error into a gRPC status response rather than a dropped connection. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(config): drop deprecated [horizon] block from maximal example The [horizon] section sets a field the code now ignores; main already dropped the section from its copy of the example. Merging main back in here kept the block, so the test comparing maximal against the defaults-filled minimal failed. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(dips): drop CancelAgreement RPC and rename signed_voucher CancelAgreement was a no-op on the indexer side: the handler returned Unimplemented because cancellation now lives on-chain in the RecurringCollector contract. Dipper retried the dead-letter every 20s forever. Also rename signed_voucher to signed_rca to match the bytes. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(dips): drop unused CollectPayment RPC and gateway proto CollectPayment was the off-chain voucher-era collection RPC: indexers submitted a signed work claim and dipper returned a TAP micropayment receipt. On-chain RCA collects directly via SubgraphService.collect(), so this RPC is dead. gateway.proto and its bindings can also go. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- crates/dips/build.rs | 8 - crates/dips/proto/gateway.proto | 73 --- crates/dips/proto/indexer.proto | 26 +- crates/dips/src/proto/gateway.rs | 8 - .../src/proto/graphprotocol.gateway.dips.rs | 503 ------------------ .../src/proto/graphprotocol.indexer.dips.rs | 114 +--- crates/dips/src/proto/mod.rs | 9 +- crates/dips/src/server.rs | 65 +-- 8 files changed, 21 insertions(+), 785 deletions(-) delete mode 100644 crates/dips/proto/gateway.proto delete mode 100644 crates/dips/src/proto/gateway.rs delete mode 100644 crates/dips/src/proto/graphprotocol.gateway.dips.rs diff --git a/crates/dips/build.rs b/crates/dips/build.rs index 198109003..261010956 100644 --- a/crates/dips/build.rs +++ b/crates/dips/build.rs @@ -13,13 +13,5 @@ fn main() { .protoc_arg("--experimental_allow_proto3_optional") .compile_protos(&["proto/indexer.proto"], &["proto/"]) .expect("Failed to compile DIPs indexer RPC proto(s)"); - - tonic_prost_build::configure() - .build_server(true) - .out_dir("src/proto") - .include_file("gateway.rs") - .protoc_arg("--experimental_allow_proto3_optional") - .compile_protos(&["proto/gateway.proto"], &["proto"]) - .expect("Failed to compile DIPs gateway RPC proto(s)"); } } diff --git a/crates/dips/proto/gateway.proto b/crates/dips/proto/gateway.proto deleted file mode 100644 index 47453b7df..000000000 --- a/crates/dips/proto/gateway.proto +++ /dev/null @@ -1,73 +0,0 @@ -syntax = "proto3"; - -package graphprotocol.gateway.dips; - -service GatewayDipsService { - /** - * Cancel an _indexing agreement_. - * - * This method allows the indexer to notify the DIPs gateway that the agreement - * should be canceled. - */ - rpc CancelAgreement(CancelAgreementRequest) returns (CancelAgreementResponse); - - /** - * Collect payment for an _indexing agreement_. - * - * This method allows the indexer to report the work completed to the DIPs gateway - * and receive payment for the indexing work done. - */ - rpc CollectPayment(CollectPaymentRequest) returns (CollectPaymentResponse); -} - - -/** - * A request to cancel an _indexing agreement_. - * - * See the `DipsService.CancelAgreement` method. - */ -message CancelAgreementRequest { - uint64 version = 1; - bytes signed_cancellation = 2; /// a signed ERC-712 message cancelling an agreement -} - -/** - * A response to a request to cancel an _indexing agreement_. - * - * See the `DipsService.CancelAgreement` method. - */ -message CancelAgreementResponse { - /// Empty response, eventually we may add custom status codes -} - -/** - * A request to collect payment _indexing agreement_. - * - * See the `DipsService.CollectPayment` method. - */ -message CollectPaymentRequest { - uint64 version = 1; - bytes signed_collection = 2; -} - -/** - * A response to a request to collect payment for an _indexing agreement_. - * - * See the `DipsService.CollectAgreement` method. - */ -message CollectPaymentResponse { - uint64 version = 1; - CollectPaymentStatus status = 2; - bytes tap_receipt = 3; -} - -/** - * The status on response to collect an _indexing agreement_. - */ -enum CollectPaymentStatus { - ACCEPT = 0; /// The payment request was accepted. - ERR_TOO_EARLY = 1; /// The payment request was done before min epochs passed - ERR_TOO_LATE = 2; /// The payment request was done after max epochs passed - ERR_AMOUNT_OUT_OF_BOUNDS = 3; /// The payment request is for too large an amount - ERR_UNKNOWN = 99; /// Something else went terribly wrong -} diff --git a/crates/dips/proto/indexer.proto b/crates/dips/proto/indexer.proto index 0521f5a26..6199036f3 100644 --- a/crates/dips/proto/indexer.proto +++ b/crates/dips/proto/indexer.proto @@ -9,11 +9,6 @@ service IndexerDipsService { * The _indexer_ can `ACCEPT` or `REJECT` the agreement. */ rpc SubmitAgreementProposal(SubmitAgreementProposalRequest) returns (SubmitAgreementProposalResponse); - - /** - * Request to cancel an existing _indexing agreement_. - */ - rpc CancelAgreement(CancelAgreementRequest) returns (CancelAgreementResponse); } /** @@ -23,7 +18,7 @@ service IndexerDipsService { */ message SubmitAgreementProposalRequest { uint64 version = 1; - bytes signed_voucher = 2; /// An ERC-712 signed indexing agreement voucher + bytes signed_rca = 2; /// ABI-encoded SignedRCA (RecurringCollectionAgreement plus signature). } /** @@ -60,22 +55,3 @@ enum RejectReason { REJECT_REASON_AGREEMENT_EXPIRED = 8; /// The agreement end time has already passed. REJECT_REASON_UNSUPPORTED_METADATA_VERSION = 9; /// The metadata version is not supported. } - -/** - * A request to cancel an _indexing agreement_. - * - * See the `DipsService.CancelAgreement` method. - */ -message CancelAgreementRequest { - uint64 version = 1; - bytes signed_cancellation = 2; /// a signed ERC-712 message cancelling an agreement -} - -/** - * A response to a request to cancel an existing _indexing agreement_. - * - * See the `DipsService.CancelAgreement` method. - */ -message CancelAgreementResponse { - // Empty message, eventually we may add custom status codes -} diff --git a/crates/dips/src/proto/gateway.rs b/crates/dips/src/proto/gateway.rs deleted file mode 100644 index fd13ab498..000000000 --- a/crates/dips/src/proto/gateway.rs +++ /dev/null @@ -1,8 +0,0 @@ -// This file is @generated by prost-build. -pub mod graphprotocol { - pub mod gateway { - pub mod dips { - include!("graphprotocol.gateway.dips.rs"); - } - } -} diff --git a/crates/dips/src/proto/graphprotocol.gateway.dips.rs b/crates/dips/src/proto/graphprotocol.gateway.dips.rs deleted file mode 100644 index 21766fafb..000000000 --- a/crates/dips/src/proto/graphprotocol.gateway.dips.rs +++ /dev/null @@ -1,503 +0,0 @@ -// This file is @generated by prost-build. -/// * -/// -/// A request to cancel an *indexing agreement*. -/// -/// See the `DipsService.CancelAgreement` method. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CancelAgreementRequest { - #[prost(uint64, tag = "1")] - pub version: u64, - /// / a signed ERC-712 message cancelling an agreement - #[prost(bytes = "vec", tag = "2")] - pub signed_cancellation: ::prost::alloc::vec::Vec, -} -/// * -/// -/// A response to a request to cancel an *indexing agreement*. -/// -/// See the `DipsService.CancelAgreement` method. -/// -/// / Empty response, eventually we may add custom status codes -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CancelAgreementResponse {} -/// * -/// -/// A request to collect payment *indexing agreement*. -/// -/// See the `DipsService.CollectPayment` method. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CollectPaymentRequest { - #[prost(uint64, tag = "1")] - pub version: u64, - #[prost(bytes = "vec", tag = "2")] - pub signed_collection: ::prost::alloc::vec::Vec, -} -/// * -/// -/// A response to a request to collect payment for an *indexing agreement*. -/// -/// See the `DipsService.CollectAgreement` method. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CollectPaymentResponse { - #[prost(uint64, tag = "1")] - pub version: u64, - #[prost(enumeration = "CollectPaymentStatus", tag = "2")] - pub status: i32, - #[prost(bytes = "vec", tag = "3")] - pub tap_receipt: ::prost::alloc::vec::Vec, -} -/// * -/// -/// The status on response to collect an *indexing agreement*. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum CollectPaymentStatus { - /// / The payment request was accepted. - Accept = 0, - /// / The payment request was done before min epochs passed - ErrTooEarly = 1, - /// / The payment request was done after max epochs passed - ErrTooLate = 2, - /// / The payment request is for too large an amount - ErrAmountOutOfBounds = 3, - /// / Something else went terribly wrong - ErrUnknown = 99, -} -impl CollectPaymentStatus { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Accept => "ACCEPT", - Self::ErrTooEarly => "ERR_TOO_EARLY", - Self::ErrTooLate => "ERR_TOO_LATE", - Self::ErrAmountOutOfBounds => "ERR_AMOUNT_OUT_OF_BOUNDS", - Self::ErrUnknown => "ERR_UNKNOWN", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "ACCEPT" => Some(Self::Accept), - "ERR_TOO_EARLY" => Some(Self::ErrTooEarly), - "ERR_TOO_LATE" => Some(Self::ErrTooLate), - "ERR_AMOUNT_OUT_OF_BOUNDS" => Some(Self::ErrAmountOutOfBounds), - "ERR_UNKNOWN" => Some(Self::ErrUnknown), - _ => None, - } - } -} -/// Generated client implementations. -pub mod gateway_dips_service_client { - #![allow( - unused_variables, - dead_code, - missing_docs, - clippy::wildcard_imports, - clippy::let_unit_value, - )] - use tonic::codegen::*; - use tonic::codegen::http::Uri; - #[derive(Debug, Clone)] - pub struct GatewayDipsServiceClient { - inner: tonic::client::Grpc, - } - impl GatewayDipsServiceClient { - /// Attempt to create a new client by connecting to a given endpoint. - pub async fn connect(dst: D) -> Result - where - D: TryInto, - D::Error: Into, - { - let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; - Ok(Self::new(conn)) - } - } - impl GatewayDipsServiceClient - where - T: tonic::client::GrpcService, - T::Error: Into, - T::ResponseBody: Body + std::marker::Send + 'static, - ::Error: Into + std::marker::Send, - { - pub fn new(inner: T) -> Self { - let inner = tonic::client::Grpc::new(inner); - Self { inner } - } - pub fn with_origin(inner: T, origin: Uri) -> Self { - let inner = tonic::client::Grpc::with_origin(inner, origin); - Self { inner } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> GatewayDipsServiceClient> - where - F: tonic::service::Interceptor, - T::ResponseBody: Default, - T: tonic::codegen::Service< - http::Request, - Response = http::Response< - >::ResponseBody, - >, - >, - , - >>::Error: Into + std::marker::Send + std::marker::Sync, - { - GatewayDipsServiceClient::new(InterceptedService::new(inner, interceptor)) - } - /// Compress requests with the given encoding. - /// - /// This requires the server to support it otherwise it might respond with an - /// error. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.inner = self.inner.send_compressed(encoding); - self - } - /// Enable decompressing responses. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.inner = self.inner.accept_compressed(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.inner = self.inner.max_decoding_message_size(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.inner = self.inner.max_encoding_message_size(limit); - self - } - /// * - /// - /// Cancel an *indexing agreement*. - /// - /// This method allows the indexer to notify the DIPs gateway that the agreement - /// should be canceled. - pub async fn cancel_agreement( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/graphprotocol.gateway.dips.GatewayDipsService/CancelAgreement", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert( - GrpcMethod::new( - "graphprotocol.gateway.dips.GatewayDipsService", - "CancelAgreement", - ), - ); - self.inner.unary(req, path, codec).await - } - /// * - /// - /// Collect payment for an *indexing agreement*. - /// - /// This method allows the indexer to report the work completed to the DIPs gateway - /// and receive payment for the indexing work done. - pub async fn collect_payment( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/graphprotocol.gateway.dips.GatewayDipsService/CollectPayment", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert( - GrpcMethod::new( - "graphprotocol.gateway.dips.GatewayDipsService", - "CollectPayment", - ), - ); - self.inner.unary(req, path, codec).await - } - } -} -/// Generated server implementations. -pub mod gateway_dips_service_server { - #![allow( - unused_variables, - dead_code, - missing_docs, - clippy::wildcard_imports, - clippy::let_unit_value, - )] - use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with GatewayDipsServiceServer. - #[async_trait] - pub trait GatewayDipsService: std::marker::Send + std::marker::Sync + 'static { - /// * - /// - /// Cancel an *indexing agreement*. - /// - /// This method allows the indexer to notify the DIPs gateway that the agreement - /// should be canceled. - async fn cancel_agreement( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// * - /// - /// Collect payment for an *indexing agreement*. - /// - /// This method allows the indexer to report the work completed to the DIPs gateway - /// and receive payment for the indexing work done. - async fn collect_payment( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - } - #[derive(Debug)] - pub struct GatewayDipsServiceServer { - inner: Arc, - accept_compression_encodings: EnabledCompressionEncodings, - send_compression_encodings: EnabledCompressionEncodings, - max_decoding_message_size: Option, - max_encoding_message_size: Option, - } - impl GatewayDipsServiceServer { - pub fn new(inner: T) -> Self { - Self::from_arc(Arc::new(inner)) - } - pub fn from_arc(inner: Arc) -> Self { - Self { - inner, - accept_compression_encodings: Default::default(), - send_compression_encodings: Default::default(), - max_decoding_message_size: None, - max_encoding_message_size: None, - } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> InterceptedService - where - F: tonic::service::Interceptor, - { - InterceptedService::new(Self::new(inner), interceptor) - } - /// Enable decompressing requests with the given encoding. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.accept_compression_encodings.enable(encoding); - self - } - /// Compress responses with the given encoding, if the client supports it. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.send_compression_encodings.enable(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.max_decoding_message_size = Some(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.max_encoding_message_size = Some(limit); - self - } - } - impl tonic::codegen::Service> for GatewayDipsServiceServer - where - T: GatewayDipsService, - B: Body + std::marker::Send + 'static, - B::Error: Into + std::marker::Send + 'static, - { - type Response = http::Response; - type Error = std::convert::Infallible; - type Future = BoxFuture; - fn poll_ready( - &mut self, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - fn call(&mut self, req: http::Request) -> Self::Future { - match req.uri().path() { - "/graphprotocol.gateway.dips.GatewayDipsService/CancelAgreement" => { - #[allow(non_camel_case_types)] - struct CancelAgreementSvc(pub Arc); - impl< - T: GatewayDipsService, - > tonic::server::UnaryService - for CancelAgreementSvc { - type Response = super::CancelAgreementResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::cancel_agreement(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = CancelAgreementSvc(inner); - let codec = tonic_prost::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/graphprotocol.gateway.dips.GatewayDipsService/CollectPayment" => { - #[allow(non_camel_case_types)] - struct CollectPaymentSvc(pub Arc); - impl< - T: GatewayDipsService, - > tonic::server::UnaryService - for CollectPaymentSvc { - type Response = super::CollectPaymentResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::collect_payment(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = CollectPaymentSvc(inner); - let codec = tonic_prost::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - _ => { - Box::pin(async move { - let mut response = http::Response::new( - tonic::body::Body::default(), - ); - let headers = response.headers_mut(); - headers - .insert( - tonic::Status::GRPC_STATUS, - (tonic::Code::Unimplemented as i32).into(), - ); - headers - .insert( - http::header::CONTENT_TYPE, - tonic::metadata::GRPC_CONTENT_TYPE, - ); - Ok(response) - }) - } - } - } - } - impl Clone for GatewayDipsServiceServer { - fn clone(&self) -> Self { - let inner = self.inner.clone(); - Self { - inner, - accept_compression_encodings: self.accept_compression_encodings, - send_compression_encodings: self.send_compression_encodings, - max_decoding_message_size: self.max_decoding_message_size, - max_encoding_message_size: self.max_encoding_message_size, - } - } - } - /// Generated gRPC service name - pub const SERVICE_NAME: &str = "graphprotocol.gateway.dips.GatewayDipsService"; - impl tonic::server::NamedService for GatewayDipsServiceServer { - const NAME: &'static str = SERVICE_NAME; - } -} diff --git a/crates/dips/src/proto/graphprotocol.indexer.dips.rs b/crates/dips/src/proto/graphprotocol.indexer.dips.rs index 5202021bb..7cce8f90b 100644 --- a/crates/dips/src/proto/graphprotocol.indexer.dips.rs +++ b/crates/dips/src/proto/graphprotocol.indexer.dips.rs @@ -8,9 +8,9 @@ pub struct SubmitAgreementProposalRequest { #[prost(uint64, tag = "1")] pub version: u64, - /// / An ERC-712 signed indexing agreement voucher + /// / ABI-encoded SignedRCA (RecurringCollectionAgreement plus signature). #[prost(bytes = "vec", tag = "2")] - pub signed_voucher: ::prost::alloc::vec::Vec, + pub signed_rca: ::prost::alloc::vec::Vec, } /// * /// @@ -28,28 +28,6 @@ pub struct SubmitAgreementProposalResponse { } /// * /// -/// A request to cancel an *indexing agreement*. -/// -/// See the `DipsService.CancelAgreement` method. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CancelAgreementRequest { - #[prost(uint64, tag = "1")] - pub version: u64, - /// / a signed ERC-712 message cancelling an agreement - #[prost(bytes = "vec", tag = "2")] - pub signed_cancellation: ::prost::alloc::vec::Vec, -} -/// * -/// -/// A response to a request to cancel an existing *indexing agreement*. -/// -/// See the `DipsService.CancelAgreement` method. -/// -/// Empty message, eventually we may add custom status codes -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CancelAgreementResponse {} -/// * -/// /// The response to an *indexing agreement* proposal. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] @@ -280,38 +258,6 @@ pub mod indexer_dips_service_client { ); self.inner.unary(req, path, codec).await } - /// * - /// - /// Request to cancel an existing *indexing agreement*. - pub async fn cancel_agreement( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/graphprotocol.indexer.dips.IndexerDipsService/CancelAgreement", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert( - GrpcMethod::new( - "graphprotocol.indexer.dips.IndexerDipsService", - "CancelAgreement", - ), - ); - self.inner.unary(req, path, codec).await - } } } /// Generated server implementations. @@ -339,16 +285,6 @@ pub mod indexer_dips_service_server { tonic::Response, tonic::Status, >; - /// * - /// - /// Request to cancel an existing *indexing agreement*. - async fn cancel_agreement( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; } #[derive(Debug)] pub struct IndexerDipsServiceServer { @@ -477,52 +413,6 @@ pub mod indexer_dips_service_server { }; Box::pin(fut) } - "/graphprotocol.indexer.dips.IndexerDipsService/CancelAgreement" => { - #[allow(non_camel_case_types)] - struct CancelAgreementSvc(pub Arc); - impl< - T: IndexerDipsService, - > tonic::server::UnaryService - for CancelAgreementSvc { - type Response = super::CancelAgreementResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::cancel_agreement(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = CancelAgreementSvc(inner); - let codec = tonic_prost::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } _ => { Box::pin(async move { let mut response = http::Response::new( diff --git a/crates/dips/src/proto/mod.rs b/crates/dips/src/proto/mod.rs index 1c4a03c0f..8fd3b83cc 100644 --- a/crates/dips/src/proto/mod.rs +++ b/crates/dips/src/proto/mod.rs @@ -1,12 +1,7 @@ //! Protocol buffer definitions for DIPS gRPC services. //! //! This module re-exports auto-generated protobuf types from prost-build. -//! The `.proto` files define two service interfaces: -//! -//! - **gateway** - Gateway-to-Dipper communication (not used by indexer-rs) -//! - **indexer** - Dipper-to-indexer communication ([`IndexerDipsService`]) -//! -//! The indexer service implements `IndexerDipsService` to receive RCA proposals. +//! Only one service interface remains: `IndexerDipsService`, the +//! Dipper-to-indexer RPC for delivering RCA proposals. -pub mod gateway; pub mod indexer; diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index e7ba7dbb8..f79cb403e 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -37,8 +37,10 @@ //! //! # Cancellation //! -//! The `cancel_agreement` endpoint is unimplemented. Cancellation is handled -//! on-chain via the RecurringCollector contract, not through this gRPC interface. +//! Cancellation is handled entirely on-chain via the RecurringCollector contract; +//! there is no gRPC method for it. Dipper calls `cancelIndexingAgreementByPayer` +//! directly and indexer-agents observe `IndexingAgreementCanceled` events through +//! the indexing-payments subgraph. use std::{ collections::{BTreeMap, HashSet}, @@ -54,9 +56,8 @@ use crate::{ ipfs::IpfsFetcher, price::PriceCalculator, proto::indexer::graphprotocol::indexer::dips::{ - indexer_dips_service_server::IndexerDipsService, CancelAgreementRequest, - CancelAgreementResponse, ProposalResponse, RejectReason, SubmitAgreementProposalRequest, - SubmitAgreementProposalResponse, + indexer_dips_service_server::IndexerDipsService, ProposalResponse, RejectReason, + SubmitAgreementProposalRequest, SubmitAgreementProposalResponse, }, store::RcaStore, DipsError, @@ -145,7 +146,7 @@ impl IndexerDipsService for DipsServer { let SubmitAgreementProposalRequest { version, - signed_voucher, + signed_rca, } = request.into_inner(); // Only accept version 2 @@ -157,19 +158,19 @@ impl IndexerDipsService for DipsServer { } // Basic sanity checks - if signed_voucher.is_empty() { - return Err(Status::invalid_argument("signed_voucher cannot be empty")); + if signed_rca.is_empty() { + return Err(Status::invalid_argument("signed_rca cannot be empty")); } - if signed_voucher.len() > 10_000 { + if signed_rca.len() > 10_000 { return Err(Status::invalid_argument( - "signed_voucher exceeds maximum size of 10KB", + "signed_rca exceeds maximum size of 10KB", )); } // Validate and store RCA - let deployment_id = crate::try_extract_deployment_id(&signed_voucher); - match crate::validate_and_create_rca(self.ctx.clone(), &self.expected_payee, signed_voucher) + let deployment_id = crate::try_extract_deployment_id(&signed_rca); + match crate::validate_and_create_rca(self.ctx.clone(), &self.expected_payee, signed_rca) .await { Ok(agreement_id) => { @@ -194,18 +195,6 @@ impl IndexerDipsService for DipsServer { } } } - - /// Cancel agreement - unimplemented. - /// - /// Cancellation is handled on-chain via the RecurringCollector contract. - async fn cancel_agreement( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Cancellation is handled on-chain via RecurringCollector contract", - )) - } } #[cfg(test)] @@ -250,7 +239,7 @@ mod tests { }; let request = Request::new(SubmitAgreementProposalRequest { version: 2, - signed_voucher: vec![], + signed_rca: vec![], }); // Act @@ -273,7 +262,7 @@ mod tests { let large_payload = vec![0u8; 10_001]; let request = Request::new(SubmitAgreementProposalRequest { version: 2, - signed_voucher: large_payload, + signed_rca: large_payload, }); // Act @@ -295,7 +284,7 @@ mod tests { }; let request = Request::new(SubmitAgreementProposalRequest { version: 1, - signed_voucher: vec![1, 2, 3], + signed_rca: vec![1, 2, 3], }); // Act @@ -307,28 +296,6 @@ mod tests { assert!(err.message().contains("version 2")); } - #[tokio::test] - async fn test_cancel_unimplemented() { - // Arrange - let ctx = DipsServerContext::for_testing(); - let server = DipsServer { - ctx, - expected_payee: Address::ZERO, - inflight: empty_counter(), - }; - let request = Request::new(CancelAgreementRequest { - version: 2, - signed_cancellation: vec![], - }); - - // Act - let err = server.cancel_agreement(request).await.unwrap_err(); - - // Assert - assert_eq!(err.code(), tonic::Code::Unimplemented); - assert!(err.message().contains("RecurringCollector")); - } - // ========================================================================= // Tests for reject_reason_from_error // ========================================================================= From 2d646e47f3223e6a884aaba8467bf5b68777b982 Mon Sep 17 00:00:00 2001 From: Maikol <86025070+Maikol@users.noreply.github.com> Date: Thu, 28 May 2026 11:32:09 -0300 Subject: [PATCH 16/31] fix: check allocation closed status instead of redeem transactions (#981) --- crates/service/src/service/router.rs | 1 - crates/service/src/tap.rs | 6 +- .../src/tap/checks/allocation_redeemed.rs | 117 +++++++++++------- crates/service/tests/router_test.rs | 7 +- 4 files changed, 75 insertions(+), 56 deletions(-) diff --git a/crates/service/src/service/router.rs b/crates/service/src/service/router.rs index f4448a463..4bea23e94 100644 --- a/crates/service/src/service/router.rs +++ b/crates/service/src/service/router.rs @@ -223,7 +223,6 @@ impl ServiceRouter { indexer_allocations: allocations.clone(), escrow_accounts_v2: self.escrow_accounts_v2.clone(), network_subgraph: network_subgraph_client, - indexer_address: self.indexer.indexer_address, timestamp_error_tolerance, receipt_max_value, allowed_data_services, diff --git a/crates/service/src/tap.rs b/crates/service/src/tap.rs index c13bccf04..879f97a9c 100644 --- a/crates/service/src/tap.rs +++ b/crates/service/src/tap.rs @@ -89,7 +89,6 @@ pub struct TapChecksConfig { pub indexer_allocations: Receiver>, pub escrow_accounts_v2: Receiver, pub network_subgraph: Option<&'static indexer_monitor::SubgraphClient>, - pub indexer_address: Address, pub timestamp_error_tolerance: Duration, pub receipt_max_value: u128, pub allowed_data_services: Option>, @@ -125,10 +124,7 @@ impl IndexerTapContext { pub async fn get_checks(config: TapChecksConfig) -> Vec> { let mut checks: Vec> = vec![ Arc::new(AllocationEligible::new(config.indexer_allocations)), - Arc::new(AllocationRedeemedCheck::new( - config.indexer_address, - config.network_subgraph, - )), + Arc::new(AllocationRedeemedCheck::new(config.network_subgraph)), Arc::new(SenderBalanceCheck::new(config.escrow_accounts_v2)), Arc::new(TimestampCheck::new(config.timestamp_error_tolerance)), Arc::new(DenyListCheck::new(config.pgpool.clone()).await), diff --git a/crates/service/src/tap/checks/allocation_redeemed.rs b/crates/service/src/tap/checks/allocation_redeemed.rs index 7b6cfadbe..f773984f1 100644 --- a/crates/service/src/tap/checks/allocation_redeemed.rs +++ b/crates/service/src/tap/checks/allocation_redeemed.rs @@ -2,17 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use indexer_monitor::{SubgraphClient, SubgraphQueryError}; -use indexer_query::payments_escrow_transactions_redeem; +use indexer_query::closed_allocations::{self, ClosedAllocations}; use tap_core::receipt::checks::{Check, CheckError, CheckResult}; -use thegraph_core::{ - alloy::{hex::ToHexExt, primitives::Address}, - CollectionId, -}; +use thegraph_core::{alloy::hex::ToHexExt, CollectionId}; -use crate::{ - middleware::Sender, - tap::{CheckingReceipt, TapReceipt}, -}; +use crate::tap::{CheckingReceipt, TapReceipt}; /// Errors that can occur during allocation redemption checks. #[derive(Debug, thiserror::Error)] @@ -25,24 +19,16 @@ pub enum AllocationCheckError { } pub struct AllocationRedeemedCheck { - indexer_address: Address, network_subgraph: Option<&'static SubgraphClient>, } impl AllocationRedeemedCheck { - pub fn new( - indexer_address: Address, - network_subgraph: Option<&'static SubgraphClient>, - ) -> Self { - Self { - indexer_address, - network_subgraph, - } + pub fn new(network_subgraph: Option<&'static SubgraphClient>) -> Self { + Self { network_subgraph } } - async fn v2_allocation_redeemed( + async fn v2_allocation_closed( &self, - sender: Address, collection_id: CollectionId, ) -> Result { let network_subgraph = self @@ -51,19 +37,22 @@ impl AllocationRedeemedCheck { // Horizon network subgraph stores allocationId as the 20-byte address derived // from the 32-byte collection_id (rightmost 20 bytes). - let allocation_ids = vec![collection_id.as_address().encode_hex()]; - - let response = network_subgraph - .query::( - payments_escrow_transactions_redeem::Variables { - payer: sender.encode_hex(), - receiver: self.indexer_address.encode_hex(), - allocation_ids: Some(allocation_ids), - }, - ) + let allocation_id = collection_id.as_address().encode_hex_with_prefix(); + + // Only reject receipts if the allocation is actually closed on-chain. + // In the continuous collection model, active allocations get collected + // from periodically — redeem transactions existing doesn't mean the + // allocation is done. + let closed_response = network_subgraph + .query::(closed_allocations::Variables { + allocation_ids: vec![allocation_id], + block: None, + first: 1, + last: String::new(), + }) .await?; - Ok(!response.payments_escrow_transactions.is_empty()) + Ok(!closed_response.allocations.is_empty()) } } @@ -71,22 +60,18 @@ impl AllocationRedeemedCheck { impl Check for AllocationRedeemedCheck { async fn check( &self, - ctx: &tap_core::receipt::Context, + _ctx: &tap_core::receipt::Context, receipt: &CheckingReceipt, ) -> CheckResult { - let Sender(sender) = ctx - .get::() - .ok_or_else(|| CheckError::Failed(anyhow::anyhow!("Missing sender in context")))?; - let collection_id = CollectionId::from(receipt.signed_receipt().as_ref().message.collection_id); - let redeemed = self - .v2_allocation_redeemed(*sender, collection_id) + let closed = self + .v2_allocation_closed(collection_id) .await .map_err(|e| CheckError::Failed(anyhow::anyhow!(e)))?; - if redeemed { + if closed { return Err(CheckError::Failed(anyhow::anyhow!( - "Allocation already redeemed (v2): {}", + "Allocation is closed (v2): {}", collection_id.as_address() ))); } @@ -112,7 +97,7 @@ mod tests { use wiremock::{matchers::body_string_contains, Mock, MockServer, ResponseTemplate}; use super::AllocationRedeemedCheck; - use crate::{middleware::Sender, tap::TapReceipt}; + use crate::tap::TapReceipt; fn create_wallet() -> PrivateKeySigner { MnemonicBuilder::::default() @@ -140,13 +125,16 @@ mod tests { } #[tokio::test] - async fn v2_redeemed_rejects() { + async fn v2_closed_allocation_rejects() { let mock_server: MockServer = MockServer::start().await; mock_server .register( - Mock::given(body_string_contains("paymentsEscrowTransactions")).respond_with( + Mock::given(body_string_contains("allocations")).respond_with( ResponseTemplate::new(200).set_body_json(json!({ - "data": { "paymentsEscrowTransactions": [ { "id": "0x01", "allocationId": TAP_SIGNER.1.to_string(), "timestamp": "1" } ] } + "data": { + "meta": { "block": { "number": 1, "hash": "0x00", "timestamp": 1 } }, + "allocations": [ { "id": "0x01" } ] + } })), ), ) @@ -161,15 +149,48 @@ mod tests { .await, )); - let check = - AllocationRedeemedCheck::new(Address::from([0x22u8; 20]), Some(network_subgraph)); + let check = AllocationRedeemedCheck::new(Some(network_subgraph)); - let mut ctx = Context::default(); - ctx.insert(Sender(TAP_SIGNER.1)); + let ctx = Context::default(); let receipt = create_v2_receipt(TAP_SIGNER.1); let checking = crate::tap::CheckingReceipt::new(receipt); let result = check.check(&ctx, &checking).await; assert!(result.is_err()); } + + #[tokio::test] + async fn v2_open_allocation_passes() { + let mock_server: MockServer = MockServer::start().await; + mock_server + .register( + Mock::given(body_string_contains("allocations")).respond_with( + ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "meta": { "block": { "number": 1, "hash": "0x00", "timestamp": 1 } }, + "allocations": [] + } + })), + ), + ) + .await; + + let network_subgraph = Box::leak(Box::new( + SubgraphClient::new( + reqwest::Client::new(), + None, + DeploymentDetails::for_query_url(&mock_server.uri()).unwrap(), + ) + .await, + )); + + let check = AllocationRedeemedCheck::new(Some(network_subgraph)); + + let ctx = Context::default(); + let receipt = create_v2_receipt(TAP_SIGNER.1); + let checking = crate::tap::CheckingReceipt::new(receipt); + + let result = check.check(&ctx, &checking).await; + assert!(result.is_ok()); + } } diff --git a/crates/service/tests/router_test.rs b/crates/service/tests/router_test.rs index 3f8d8c924..d939d0698 100644 --- a/crates/service/tests/router_test.rs +++ b/crates/service/tests/router_test.rs @@ -126,7 +126,9 @@ async fn full_integration_test() { )); mock_server.register(mock).await; - // Mock network subgraph redemption queries. + // Mock escrow subgraph (v1) and network subgraph (v2) queries. + // The v2 allocation check queries for closed allocations; returning an + // empty list means the allocation is still open (receipts accepted). mock_server .register(Mock::given(method("POST")).and(path("/")).respond_with( ResponseTemplate::new(200).set_body_raw( @@ -134,7 +136,8 @@ async fn full_integration_test() { { "data": { "transactions": [], - "paymentsEscrowTransactions": [] + "meta": { "block": { "number": 1, "hash": "0x00", "timestamp": 1 } }, + "allocations": [] } } "#, From 1a90fa302cfe04dea8ba1ebf7bd2d1ce20d46e66 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Fri, 29 May 2026 04:01:13 +1200 Subject: [PATCH 17/31] refactor: drop misleading DIPs payer allowlist (#1039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(dips): drop misleading payer allowlist The allowed_payers config gave operators false access control: the check ran on the payer field inside the proposal, which the caller writes, so anyone could spoof the address and walk past. The on-chain offer at acceptance is the real trust gate. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(config): correct DIPs expansion and drop stale Horizon mode line The example listed "Decentralized Indexing Payment System" — the correct expansion of DIPs is "Direct Indexer Payments". The "Requires Horizon mode" line was stale too, since the runtime Horizon check has been dropped and the mode is now always on. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- crates/config/maximal-config-example.toml | 9 +-- crates/config/src/config.rs | 7 --- crates/dips/src/lib.rs | 77 ----------------------- crates/dips/src/server.rs | 14 +---- crates/service/src/service.rs | 2 - 5 files changed, 3 insertions(+), 106 deletions(-) diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index cab0067a4..d247652c5 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -174,17 +174,12 @@ max_receipts_per_request = 10000 0xDDE4cfFd3D9052A9cb618fC05a1Cd02be1f2F467 = "https://tap-aggregator.network.thegraph.com" 0xDD6a6f76eb36B873C1C184e8b9b9e762FE216490 = "https://tap-aggregator-arbitrum-one.graphops.xyz" -# DIPS (Decentralized Indexing Payment System). Requires Horizon mode. -# Payer auth is verified on-chain at acceptance; the `allowed_payers` -# list below gates which payers may even propose. GRT prices, not wei. +# DIPs (Direct Indexer Payments). Payer authorisation is verified +# on-chain at acceptance. GRT prices, not wei. [dips] host = "0.0.0.0" port = "7601" -# Trusted payers. Omit field = accept any caller (legacy); `[]` = deny -# all. Production addresses TBD — list real dipper addresses to use it. -# allowed_payers = ["0x0000000000000000000000000000000000000000"] - # Networks you explicitly support indexing. # Proposals from the dipper for you to index networks that are not in the list below are rejected. # See https://github.com/graphprotocol/networks-registry/blob/main/docs/networks-table.md diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 9a35a8180..d0005c135 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -679,12 +679,6 @@ pub struct DipsConfig { /// Minimum acceptable GRT per billion entities per 30 days. pub min_grt_per_billion_entities_per_30_days: GRT, pub additional_networks: BTreeMap, - /// Optional allowlist of payer addresses. When `Some`, only proposals - /// whose `payer` field is in this set are accepted; everything else is - /// rejected before the IPFS fetch. When `None` (the default), proposals - /// from any payer are accepted — the operator should normally list the - /// addresses of the dippers they do business with. - pub allowed_payers: Option>, } impl Default for DipsConfig { @@ -696,7 +690,6 @@ impl Default for DipsConfig { min_grt_per_30_days: BTreeMap::new(), min_grt_per_billion_entities_per_30_days: GRT::ZERO, additional_networks: BTreeMap::new(), - allowed_payers: None, } } } diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index d9c96faf6..7fb280722 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -168,8 +168,6 @@ pub enum DipsError { }, #[error("tokens per entity per second {offered} is below configured minimum {minimum}")] TokensPerEntityPerSecondTooLow { minimum: U256, offered: U256 }, - #[error("payer {0} is not in the configured allowlist")] - PayerNotAllowed(Address), // misc #[error("unknown error: {0}")] UnknownError(#[from] anyhow::Error), @@ -277,7 +275,6 @@ pub async fn validate_and_create_rca( price_calculator, registry, additional_networks, - allowed_payers, .. } = ctx.as_ref(); @@ -285,15 +282,6 @@ pub async fn validate_and_create_rca( let signed_rca = SignedRecurringCollectionAgreement::abi_decode(rca_bytes.as_ref()) .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; - // Reject proposals whose claimed payer is not in the configured allowlist - // before doing any I/O. When the allowlist is `None` every payer is - // accepted (legacy default). - if let Some(allowed) = allowed_payers { - if !allowed.contains(&signed_rca.agreement.payer) { - return Err(DipsError::PayerNotAllowed(signed_rca.agreement.payer)); - } - } - // Validate service provider signed_rca.validate(expected_service_provider)?; @@ -461,7 +449,6 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), - allowed_payers: None, }) } @@ -673,66 +660,6 @@ mod test { )); } - #[tokio::test] - async fn test_validate_and_create_rca_rejects_payer_not_in_allowlist() { - let payer = Address::repeat_byte(0x42); - let allowed_payer = Address::repeat_byte(0x99); - let service_provider = Address::repeat_byte(0x11); - - let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); - - let ctx = Arc::new(DipsServerContext { - rca_store: Arc::new(InMemoryRcaStore::default()), - ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), - price_calculator: Arc::new(PriceCalculator::new( - HashSet::from(["mainnet".to_string()]), - BTreeMap::from([("mainnet".to_string(), U256::from(100))]), - U256::from(50), - )), - registry: Arc::new(crate::registry::test_registry()), - additional_networks: Arc::new(BTreeMap::new()), - allowed_payers: Some(HashSet::from([allowed_payer])), - }); - let rca_bytes = rca_to_wire_bytes(rca); - - let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; - - assert!( - matches!(result, Err(DipsError::PayerNotAllowed(addr)) if addr == payer), - "Expected PayerNotAllowed({:?}), got {:?}", - payer, - result - ); - } - - #[tokio::test] - async fn test_validate_and_create_rca_accepts_payer_in_allowlist() { - let payer = Address::repeat_byte(0x42); - let service_provider = Address::repeat_byte(0x11); - - let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); - let agreement_id = derive_agreement_id(&rca); - - let ctx = Arc::new(DipsServerContext { - rca_store: Arc::new(InMemoryRcaStore::default()), - ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), - price_calculator: Arc::new(PriceCalculator::new( - HashSet::from(["mainnet".to_string()]), - BTreeMap::from([("mainnet".to_string(), U256::from(100))]), - U256::from(50), - )), - registry: Arc::new(crate::registry::test_registry()), - additional_networks: Arc::new(BTreeMap::new()), - allowed_payers: Some(HashSet::from([payer])), - }); - let rca_bytes = rca_to_wire_bytes(rca); - - let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; - - assert!(result.is_ok(), "expected Ok, got {:?}", result); - assert_eq!(result.unwrap(), agreement_id); - } - #[tokio::test] async fn test_validate_and_create_rca_tokens_per_second_too_low() { let payer = Address::repeat_byte(0x42); @@ -791,7 +718,6 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), - allowed_payers: None, }); let rca_bytes = rca_to_wire_bytes(rca); @@ -965,7 +891,6 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), - allowed_payers: None, }); let rca_bytes = rca_to_wire_bytes(rca); @@ -1000,7 +925,6 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), - allowed_payers: None, }); let rca_bytes = rca_to_wire_bytes(rca); @@ -1035,7 +959,6 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), - allowed_payers: None, }); let rca_bytes = rca_to_wire_bytes(rca); diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index f79cb403e..935e6e90d 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -42,10 +42,7 @@ //! directly and indexer-agents observe `IndexingAgreementCanceled` events through //! the indexing-payments subgraph. -use std::{ - collections::{BTreeMap, HashSet}, - sync::Arc, -}; +use std::{collections::BTreeMap, sync::Arc}; use async_trait::async_trait; use thegraph_core::alloy::primitives::Address; @@ -81,10 +78,6 @@ pub struct DipsServerContext { pub registry: Arc, /// Additional networks beyond the registry pub additional_networks: Arc>, - /// Optional allowlist of payer addresses. When `Some`, proposals whose - /// `payer` field is not in this set are rejected before any I/O. When - /// `None`, every payer is accepted (legacy default). - pub allowed_payers: Option>, } /// DIPS server implementing RCA protocol. @@ -116,10 +109,6 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { DipsError::SubgraphManifestUnavailable(_) => RejectReason::SubgraphManifestUnavailable, DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, - // Deliberately maps to Other so the wire response doesn't disclose - // why the rejection happened — a caller probing for who is on the - // allowlist would otherwise learn that by trial and error. - DipsError::PayerNotAllowed(_) => RejectReason::Other, _ => RejectReason::Other, } } @@ -223,7 +212,6 @@ mod tests { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), - allowed_payers: None, }) } } diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 50574cbdd..701b8435f 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -209,7 +209,6 @@ pub async fn run() -> anyhow::Result<()> { min_grt_per_30_days, min_grt_per_billion_entities_per_30_days, additional_networks, - allowed_payers, .. } = dips; @@ -289,7 +288,6 @@ pub async fn run() -> anyhow::Result<()> { )), registry, additional_networks: Arc::new(additional_networks.clone()), - allowed_payers: allowed_payers.clone(), }); // Create DIPS server From 8a46d3327a3fba0338842dd91d18060cb06e472c Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:28:52 +1200 Subject: [PATCH 18/31] feat(dips): separate accept and reject in the proposal response (#1041) The proposal response used one shape for accept and reject, so an accept had to carry a dormant reject reason. Split it into accept and reject variants; a reject carries a reason and optional detail, store failures map to a retryable reason, and malformed ones to a catch-all. Co-authored-by: Claude Opus 4.8 (1M context) --- crates/dips/proto/indexer.proto | 53 +++-- .../src/proto/graphprotocol.indexer.dips.rs | 127 +++++++----- crates/dips/src/server.rs | 191 ++++++++++++++++-- 3 files changed, 280 insertions(+), 91 deletions(-) diff --git a/crates/dips/proto/indexer.proto b/crates/dips/proto/indexer.proto index 6199036f3..7d40d9789 100644 --- a/crates/dips/proto/indexer.proto +++ b/crates/dips/proto/indexer.proto @@ -24,34 +24,51 @@ message SubmitAgreementProposalRequest { /** * A response to a request to propose a new _indexing agreement_ to an _indexer_. * + * The outcome is either `accepted` or `rejected` -- exactly one is set. A + * rejection carries a `RejectReason` plus an optional free-text detail. + * * See the `DipsService.SubmitAgreementProposal` method. */ message SubmitAgreementProposalResponse { - ProposalResponse response = 1; /// The response to the agreement proposal. - RejectReason reject_reason = 2; /// Only set when response = REJECT. + oneof outcome { + Accepted accepted = 1; /// Set when the proposal was accepted. + Rejected rejected = 2; /// Set when the proposal was rejected. + } } /** - * The response to an _indexing agreement_ proposal. + * An accepted _indexing agreement_ proposal. Empty for now; accept-only fields + * can be added later without disturbing the reject path. + */ +message Accepted {} + +/** + * A rejected _indexing agreement_ proposal. */ -enum ProposalResponse { - ACCEPT = 0; /// The agreement proposal was accepted. - REJECT = 1; /// The agreement proposal was rejected. +message Rejected { + RejectReason reason = 1; /// Why the proposal was rejected. + string detail = 2; /// Optional human-readable detail; may be empty. } /** - * The reason for rejecting an _indexing agreement_ proposal. - * Only meaningful when ProposalResponse = REJECT. + * The reason an _indexer_ rejected an _indexing agreement_ proposal. Values may + * be added over time; an older reader should treat an unrecognised reason as + * UNSPECIFIED (the catch-all). */ enum RejectReason { - REJECT_REASON_UNSPECIFIED = 0; /// Default / not set (used for ACCEPT responses). - REJECT_REASON_PRICE_TOO_LOW = 1; /// The offered price is below the indexer's minimum. - REJECT_REASON_OTHER = 2; /// Any other reason (bad signature, etc.). - REJECT_REASON_SIGNER_NOT_AUTHORISED = 3; /// The proposal signer is not authorised on the escrow contract. - REJECT_REASON_DEADLINE_EXPIRED = 4; /// The proposal deadline has already passed. - REJECT_REASON_UNSUPPORTED_NETWORK = 5; /// The subgraph's network is not supported by this indexer. - REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE = 6; /// The subgraph manifest could not be fetched from IPFS. - REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER = 7; /// The RCA service provider does not match this indexer. - REJECT_REASON_AGREEMENT_EXPIRED = 8; /// The agreement end time has already passed. - REJECT_REASON_UNSUPPORTED_METADATA_VERSION = 9; /// The metadata version is not supported. + REJECT_REASON_UNSPECIFIED = 0; /// Rejected for a reason not covered below (the catch-all). + REJECT_REASON_PRICE_TOO_LOW = 1; /// The offered price is below the indexer's minimum. + REJECT_REASON_DEADLINE_EXPIRED = 2; /// The proposal deadline has already passed. + REJECT_REASON_UNSUPPORTED_NETWORK = 3; /// The subgraph's network is not supported by this indexer. + REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE = 4; /// The subgraph manifest could not be fetched from IPFS. + REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER = 5; /// The RCA names a different indexer as service provider. + REJECT_REASON_AGREEMENT_EXPIRED = 6; /// The agreement end time has already passed. + REJECT_REASON_UNSUPPORTED_METADATA_VERSION = 7; /// The agreement metadata version is not supported. + REJECT_REASON_INVALID_SIGNATURE = 8; /// The proposal's signature failed to verify. + REJECT_REASON_SENDER_NOT_TRUSTED = 9; /// The signer is not an authorised agreement manager. + REJECT_REASON_CAPACITY_EXCEEDED = 10; /// The indexer is at its DIPs capacity; may resolve later. + REJECT_REASON_MANIFEST_TOO_LARGE = 11; /// The subgraph manifest exceeds the indexer's size cap. + REJECT_REASON_REPLAY_DETECTED = 12; /// A different proposal reuses an already-seen agreement id. + REJECT_REASON_INSUFFICIENT_ESCROW = 13; /// The payer has insufficient escrow to back the agreement. + REJECT_REASON_INDEXER_UNAVAILABLE = 14; /// A transient internal error; the proposal may be resent. } diff --git a/crates/dips/src/proto/graphprotocol.indexer.dips.rs b/crates/dips/src/proto/graphprotocol.indexer.dips.rs index 7cce8f90b..cd058c462 100644 --- a/crates/dips/src/proto/graphprotocol.indexer.dips.rs +++ b/crates/dips/src/proto/graphprotocol.indexer.dips.rs @@ -16,74 +16,83 @@ pub struct SubmitAgreementProposalRequest { /// /// A response to a request to propose a new *indexing agreement* to an *indexer*. /// +/// The outcome is either `accepted` or `rejected` -- exactly one is set. A +/// rejection carries a `RejectReason` plus an optional free-text detail. +/// /// See the `DipsService.SubmitAgreementProposal` method. -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct SubmitAgreementProposalResponse { - /// / The response to the agreement proposal. - #[prost(enumeration = "ProposalResponse", tag = "1")] - pub response: i32, - /// / Only set when response = REJECT. - #[prost(enumeration = "RejectReason", tag = "2")] - pub reject_reason: i32, + #[prost(oneof = "submit_agreement_proposal_response::Outcome", tags = "1, 2")] + pub outcome: ::core::option::Option, +} +/// Nested message and enum types in `SubmitAgreementProposalResponse`. +pub mod submit_agreement_proposal_response { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Outcome { + /// / Set when the proposal was accepted. + #[prost(message, tag = "1")] + Accepted(super::Accepted), + /// / Set when the proposal was rejected. + #[prost(message, tag = "2")] + Rejected(super::Rejected), + } } /// * /// -/// The response to an *indexing agreement* proposal. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum ProposalResponse { - /// / The agreement proposal was accepted. - Accept = 0, - /// / The agreement proposal was rejected. - Reject = 1, -} -impl ProposalResponse { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Accept => "ACCEPT", - Self::Reject => "REJECT", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "ACCEPT" => Some(Self::Accept), - "REJECT" => Some(Self::Reject), - _ => None, - } - } +/// An accepted *indexing agreement* proposal. Empty for now; accept-only fields +/// can be added later without disturbing the reject path. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Accepted {} +/// * +/// +/// A rejected *indexing agreement* proposal. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Rejected { + /// / Why the proposal was rejected. + #[prost(enumeration = "RejectReason", tag = "1")] + pub reason: i32, + /// / Optional human-readable detail; may be empty. + #[prost(string, tag = "2")] + pub detail: ::prost::alloc::string::String, } /// * /// -/// The reason for rejecting an *indexing agreement* proposal. -/// Only meaningful when ProposalResponse = REJECT. +/// The reason an *indexer* rejected an *indexing agreement* proposal. Values may +/// be added over time; an older reader should treat an unrecognised reason as +/// UNSPECIFIED (the catch-all). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum RejectReason { - /// / Default / not set (used for ACCEPT responses). + /// / Rejected for a reason not covered below (the catch-all). Unspecified = 0, /// / The offered price is below the indexer's minimum. PriceTooLow = 1, - /// / Any other reason (bad signature, etc.). - Other = 2, - /// / The proposal signer is not authorised on the escrow contract. - SignerNotAuthorised = 3, /// / The proposal deadline has already passed. - DeadlineExpired = 4, + DeadlineExpired = 2, /// / The subgraph's network is not supported by this indexer. - UnsupportedNetwork = 5, + UnsupportedNetwork = 3, /// / The subgraph manifest could not be fetched from IPFS. - SubgraphManifestUnavailable = 6, - /// / The RCA service provider does not match this indexer. - UnexpectedServiceProvider = 7, + SubgraphManifestUnavailable = 4, + /// / The RCA names a different indexer as service provider. + UnexpectedServiceProvider = 5, /// / The agreement end time has already passed. - AgreementExpired = 8, - /// / The metadata version is not supported. - UnsupportedMetadataVersion = 9, + AgreementExpired = 6, + /// / The agreement metadata version is not supported. + UnsupportedMetadataVersion = 7, + /// / The proposal's signature failed to verify. + InvalidSignature = 8, + /// / The signer is not an authorised agreement manager. + SenderNotTrusted = 9, + /// / The indexer is at its DIPs capacity; may resolve later. + CapacityExceeded = 10, + /// / The subgraph manifest exceeds the indexer's size cap. + ManifestTooLarge = 11, + /// / A different proposal reuses an already-seen agreement id. + ReplayDetected = 12, + /// / The payer has insufficient escrow to back the agreement. + InsufficientEscrow = 13, + /// / A transient internal error; the proposal may be resent. + IndexerUnavailable = 14, } impl RejectReason { /// String value of the enum field names used in the ProtoBuf definition. @@ -94,8 +103,6 @@ impl RejectReason { match self { Self::Unspecified => "REJECT_REASON_UNSPECIFIED", Self::PriceTooLow => "REJECT_REASON_PRICE_TOO_LOW", - Self::Other => "REJECT_REASON_OTHER", - Self::SignerNotAuthorised => "REJECT_REASON_SIGNER_NOT_AUTHORISED", Self::DeadlineExpired => "REJECT_REASON_DEADLINE_EXPIRED", Self::UnsupportedNetwork => "REJECT_REASON_UNSUPPORTED_NETWORK", Self::SubgraphManifestUnavailable => { @@ -108,6 +115,13 @@ impl RejectReason { Self::UnsupportedMetadataVersion => { "REJECT_REASON_UNSUPPORTED_METADATA_VERSION" } + Self::InvalidSignature => "REJECT_REASON_INVALID_SIGNATURE", + Self::SenderNotTrusted => "REJECT_REASON_SENDER_NOT_TRUSTED", + Self::CapacityExceeded => "REJECT_REASON_CAPACITY_EXCEEDED", + Self::ManifestTooLarge => "REJECT_REASON_MANIFEST_TOO_LARGE", + Self::ReplayDetected => "REJECT_REASON_REPLAY_DETECTED", + Self::InsufficientEscrow => "REJECT_REASON_INSUFFICIENT_ESCROW", + Self::IndexerUnavailable => "REJECT_REASON_INDEXER_UNAVAILABLE", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -115,8 +129,6 @@ impl RejectReason { match value { "REJECT_REASON_UNSPECIFIED" => Some(Self::Unspecified), "REJECT_REASON_PRICE_TOO_LOW" => Some(Self::PriceTooLow), - "REJECT_REASON_OTHER" => Some(Self::Other), - "REJECT_REASON_SIGNER_NOT_AUTHORISED" => Some(Self::SignerNotAuthorised), "REJECT_REASON_DEADLINE_EXPIRED" => Some(Self::DeadlineExpired), "REJECT_REASON_UNSUPPORTED_NETWORK" => Some(Self::UnsupportedNetwork), "REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE" => { @@ -129,6 +141,13 @@ impl RejectReason { "REJECT_REASON_UNSUPPORTED_METADATA_VERSION" => { Some(Self::UnsupportedMetadataVersion) } + "REJECT_REASON_INVALID_SIGNATURE" => Some(Self::InvalidSignature), + "REJECT_REASON_SENDER_NOT_TRUSTED" => Some(Self::SenderNotTrusted), + "REJECT_REASON_CAPACITY_EXCEEDED" => Some(Self::CapacityExceeded), + "REJECT_REASON_MANIFEST_TOO_LARGE" => Some(Self::ManifestTooLarge), + "REJECT_REASON_REPLAY_DETECTED" => Some(Self::ReplayDetected), + "REJECT_REASON_INSUFFICIENT_ESCROW" => Some(Self::InsufficientEscrow), + "REJECT_REASON_INDEXER_UNAVAILABLE" => Some(Self::IndexerUnavailable), _ => None, } } diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 935e6e90d..ccb8d7165 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -53,7 +53,8 @@ use crate::{ ipfs::IpfsFetcher, price::PriceCalculator, proto::indexer::graphprotocol::indexer::dips::{ - indexer_dips_service_server::IndexerDipsService, ProposalResponse, RejectReason, + indexer_dips_service_server::IndexerDipsService, + submit_agreement_proposal_response::Outcome, Accepted, RejectReason, Rejected, SubmitAgreementProposalRequest, SubmitAgreementProposalResponse, }, store::RcaStore, @@ -98,7 +99,8 @@ pub struct DipsServer { pub inflight: InflightCounter, } -/// Map a DipsError to the appropriate RejectReason for the gRPC response. +/// Classify a DipsError as the reason for the rejection. The match is exhaustive +/// so any future error variant must be classified here. fn reject_reason_from_error(err: &DipsError) -> RejectReason { match err { DipsError::TokensPerSecondTooLow { .. } @@ -109,7 +111,23 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { DipsError::SubgraphManifestUnavailable(_) => RejectReason::SubgraphManifestUnavailable, DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, - _ => RejectReason::Other, + // Malformed proposals with no dedicated reason map to the catch-all; the + // detail carries the specifics. + DipsError::AbiDecoding(_) + | DipsError::InvalidSubgraphManifest(_) + | DipsError::InvalidRca(_) => RejectReason::Unspecified, + // A store failure means the proposal was valid but the indexer couldn't persist + // it -- tell dipper this is transient so it retries rather than giving up. + DipsError::UnknownError(_) => RejectReason::IndexerUnavailable, + } +} + +/// Human-readable detail sent to the caller. A store failure wraps a raw database +/// error that could leak schema internals, so it gets a generic message instead. +fn reject_detail_from_error(err: &DipsError) -> String { + match err { + DipsError::UnknownError(_) => "internal error while storing the proposal".to_string(), + other => other.to_string(), } } @@ -165,8 +183,7 @@ impl IndexerDipsService for DipsServer { Ok(agreement_id) => { tracing::info!(%agreement_id, "RCA accepted"); Ok(Response::new(SubmitAgreementProposalResponse { - response: ProposalResponse::Accept.into(), - reject_reason: RejectReason::Unspecified.into(), + outcome: Some(Outcome::Accepted(Accepted {})), })) } Err(e) => { @@ -178,8 +195,10 @@ impl IndexerDipsService for DipsServer { "RCA proposal rejected" ); Ok(Response::new(SubmitAgreementProposalResponse { - response: ProposalResponse::Reject.into(), - reject_reason: reject_reason.into(), + outcome: Some(Outcome::Rejected(Rejected { + reason: reject_reason.into(), + detail: reject_detail_from_error(&e), + })), })) } } @@ -348,18 +367,6 @@ mod tests { assert_eq!(reason, RejectReason::DeadlineExpired); } - #[test] - fn test_reject_reason_abi_decoding() { - // Arrange - let err = DipsError::AbiDecoding("invalid bytes".to_string()); - - // Act - let reason = super::reject_reason_from_error(&err); - - // Assert - assert_eq!(reason, RejectReason::Other); - } - #[test] fn test_reject_reason_agreement_expired() { // Arrange @@ -413,4 +420,150 @@ mod tests { // Assert assert_eq!(reason, RejectReason::UnsupportedMetadataVersion); } + + #[test] + fn test_reject_reason_malformed_proposals_map_to_unspecified() { + // Arrange: malformed inputs share the UNSPECIFIED catch-all; detail carries the specifics. + let abi = DipsError::AbiDecoding("invalid bytes".to_string()); + let manifest = DipsError::InvalidSubgraphManifest("QmTest".to_string()); + let rca = DipsError::InvalidRca("bad rca".to_string()); + + // Act + Assert + assert_eq!( + super::reject_reason_from_error(&abi), + RejectReason::Unspecified + ); + assert_eq!( + super::reject_reason_from_error(&manifest), + RejectReason::Unspecified + ); + assert_eq!( + super::reject_reason_from_error(&rca), + RejectReason::Unspecified + ); + } + + #[test] + fn test_reject_reason_unknown_error_is_transient() { + // Arrange: a store/database failure surfaces as UnknownError. + let err = DipsError::UnknownError(anyhow::anyhow!("connection pool timed out")); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert: a valid proposal the indexer can't persist is transient, so the + // sender should retry rather than treat it as a permanent failure. + assert_eq!(reason, RejectReason::IndexerUnavailable); + } + + #[test] + fn test_reject_detail_sanitizes_store_errors() { + // Arrange: the raw database error mentions schema internals. + let store_err = + DipsError::UnknownError(anyhow::anyhow!("relation \"users\" column secret")); + let decode_err = DipsError::AbiDecoding("offset 7".to_string()); + + // Act + let store_detail = super::reject_detail_from_error(&store_err); + let decode_detail = super::reject_detail_from_error(&decode_err); + + // Assert: the store error becomes generic while a well-formed validation + // error keeps its descriptive detail. + assert_eq!(store_detail, "internal error while storing the proposal"); + assert!(!store_detail.contains("users")); + assert!(decode_detail.contains("offset 7")); + } + + #[tokio::test] + async fn test_reject_response_carries_reason_and_detail() { + // Arrange: a non-decodable payload reaches validation and fails ABI decode. + let ctx = DipsServerContext::for_testing(); + let server = DipsServer { + ctx, + expected_payee: Address::ZERO, + inflight: empty_counter(), + }; + let request = Request::new(SubmitAgreementProposalRequest { + version: 2, + signed_rca: vec![1, 2, 3], + }); + + // Act + let response = server + .submit_agreement_proposal(request) + .await + .unwrap() + .into_inner(); + + // Assert + let Some(Outcome::Rejected(rejected)) = response.outcome else { + panic!("expected a rejected outcome"); + }; + assert_eq!(rejected.reason, RejectReason::Unspecified as i32); + assert!(rejected.detail.contains("ABI decoding")); + } + + #[test] + fn test_reject_reason_wire_names_round_trip() { + let cases = [ + (RejectReason::Unspecified, "REJECT_REASON_UNSPECIFIED"), + (RejectReason::PriceTooLow, "REJECT_REASON_PRICE_TOO_LOW"), + ( + RejectReason::DeadlineExpired, + "REJECT_REASON_DEADLINE_EXPIRED", + ), + ( + RejectReason::UnsupportedNetwork, + "REJECT_REASON_UNSUPPORTED_NETWORK", + ), + ( + RejectReason::SubgraphManifestUnavailable, + "REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE", + ), + ( + RejectReason::UnexpectedServiceProvider, + "REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER", + ), + ( + RejectReason::AgreementExpired, + "REJECT_REASON_AGREEMENT_EXPIRED", + ), + ( + RejectReason::UnsupportedMetadataVersion, + "REJECT_REASON_UNSUPPORTED_METADATA_VERSION", + ), + ( + RejectReason::InvalidSignature, + "REJECT_REASON_INVALID_SIGNATURE", + ), + ( + RejectReason::SenderNotTrusted, + "REJECT_REASON_SENDER_NOT_TRUSTED", + ), + ( + RejectReason::CapacityExceeded, + "REJECT_REASON_CAPACITY_EXCEEDED", + ), + ( + RejectReason::ManifestTooLarge, + "REJECT_REASON_MANIFEST_TOO_LARGE", + ), + ( + RejectReason::ReplayDetected, + "REJECT_REASON_REPLAY_DETECTED", + ), + ( + RejectReason::InsufficientEscrow, + "REJECT_REASON_INSUFFICIENT_ESCROW", + ), + ( + RejectReason::IndexerUnavailable, + "REJECT_REASON_INDEXER_UNAVAILABLE", + ), + ]; + for (variant, name) in cases { + assert_eq!(variant.as_str_name(), name); + assert_eq!(RejectReason::from_str_name(name), Some(variant)); + } + } } From 103c7cc1120414e0ccf1fe74e0cd71938ed2d5e7 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:46:43 +1200 Subject: [PATCH 19/31] feat: cap DIPs manifest download at Graph Node's default size (#1042) * feat(dips): separate accept and reject in the proposal response The proposal response used one shape for accept and reject, so an accept had to carry a dormant reject reason. Split it into accept and reject variants; a reject carries a reason and optional detail, store failures map to a retryable reason, and malformed ones to a catch-all. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(dips): cap manifest download at Graph Node's default size The indexer caps the IPFS manifest download to bound the cost of a hostile CID, but the 5 MiB cap was below Graph Node's own ~25 MiB limit, so large-but-legitimate manifests were rejected. Raise the cap to 25 MiB and report an oversize manifest with its own area and nature, not the generic one. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- crates/dips/src/ipfs.rs | 15 ++++++++------- crates/dips/src/lib.rs | 2 ++ crates/dips/src/server.rs | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/dips/src/ipfs.rs b/crates/dips/src/ipfs.rs index 6ed4f0bbd..6750a7409 100644 --- a/crates/dips/src/ipfs.rs +++ b/crates/dips/src/ipfs.rs @@ -70,10 +70,10 @@ const IPFS_MAX_ATTEMPTS: u32 = 4; /// Base delay for exponential backoff between retries (10s, 20s, 40s). const IPFS_RETRY_BASE_DELAY: Duration = Duration::from_secs(10); -/// Upper bound on bytes read from a single manifest fetch. Real subgraph -/// manifests are tens of KB; this cap bounds the per-request bandwidth -/// cost when the caller-chosen CID resolves to attacker-controlled content. -pub(crate) const IPFS_MAX_MANIFEST_BYTES: usize = 5 * 1024 * 1024; +/// Upper bound on bytes read from a single manifest fetch. Real manifests are +/// tens of KB; this 25 MiB cap (aligned with Graph Node's default) bounds the +/// per-request bandwidth cost of a caller-chosen CID resolving to hostile content. +pub(crate) const IPFS_MAX_MANIFEST_BYTES: usize = 25 * 1024 * 1024; /// When the in-flight request count exceeds this threshold, IPFS fetches /// stop retrying — a single attempt only. The fewer-retries mode frees @@ -170,9 +170,10 @@ impl IpfsClient { { content.extend_from_slice(&chunk); if content.len() > IPFS_MAX_MANIFEST_BYTES { - return Err(DipsError::SubgraphManifestUnavailable(format!( - "{file}: manifest exceeds {IPFS_MAX_MANIFEST_BYTES} byte cap" - ))); + return Err(DipsError::ManifestTooLarge { + file: file.to_string(), + limit_bytes: IPFS_MAX_MANIFEST_BYTES, + }); } } diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 7fb280722..7787bbc8e 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -156,6 +156,8 @@ pub enum DipsError { SubgraphManifestUnavailable(String), #[error("invalid subgraph id {0}")] InvalidSubgraphManifest(String), + #[error("subgraph manifest for {file} exceeds the {limit_bytes} byte cap")] + ManifestTooLarge { file: String, limit_bytes: usize }, #[error("network {0} is not supported")] UnsupportedNetwork(String), #[error( diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index ccb8d7165..99e027764 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -111,6 +111,7 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { DipsError::SubgraphManifestUnavailable(_) => RejectReason::SubgraphManifestUnavailable, DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, + DipsError::ManifestTooLarge { .. } => RejectReason::ManifestTooLarge, // Malformed proposals with no dedicated reason map to the catch-all; the // detail carries the specifics. DipsError::AbiDecoding(_) @@ -443,6 +444,21 @@ mod tests { ); } + #[test] + fn test_reject_reason_manifest_too_large() { + // Arrange + let err = DipsError::ManifestTooLarge { + file: "QmTest".to_string(), + limit_bytes: 25 * 1024 * 1024, + }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::ManifestTooLarge); + } + #[test] fn test_reject_reason_unknown_error_is_transient() { // Arrange: a store/database failure surfaces as UnknownError. From ff4869994e08051bd0c14be71eeb6da2fed6edb8 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:11:15 +1200 Subject: [PATCH 20/31] feat: verify DIPs proposal manifest network is supported (#1043) * feat(dips): separate accept and reject in the proposal response The proposal response used one shape for accept and reject, so an accept had to carry a dormant reject reason. Split it into accept and reject variants; a reject carries a reason and optional detail, store failures map to a retryable reason, and malformed ones to a catch-all. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(dips): verify proposal manifest network is configured for DIPs The indexer reads a proposal's network from the subgraph manifest but only rejected an unconfigured network indirectly, when the price lookup later missed it, and an empty network field slipped through. Check the network against the indexer's configured DIPs networks up front, and reject an empty one. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- crates/dips/src/ipfs.rs | 16 +++++++++++++ crates/dips/src/lib.rs | 53 +++++++++++++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/crates/dips/src/ipfs.rs b/crates/dips/src/ipfs.rs index 6750a7409..8df258e6e 100644 --- a/crates/dips/src/ipfs.rs +++ b/crates/dips/src/ipfs.rs @@ -240,6 +240,22 @@ impl IpfsFetcher for FailingIpfsFetcher { } } +/// Test IPFS fetcher returning a manifest whose single data source has an +/// empty network field, to exercise the malformed-manifest path. +#[derive(Debug, Clone, Default)] +pub struct EmptyNetworkIpfsFetcher; + +#[async_trait] +impl IpfsFetcher for EmptyNetworkIpfsFetcher { + async fn fetch(&self, _file: &str) -> Result { + Ok(GraphManifest { + data_sources: vec![DataSource { + network: String::new(), + }], + }) + } +} + impl Default for MockIpfsFetcher { fn default() -> Self { Self { diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 7787bbc8e..6ce5f6040 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -332,16 +332,21 @@ pub async fn validate_and_create_rca( // Fetch IPFS manifest let manifest = ipfs_fetcher.fetch(&deployment_id).await?; - // Get network from manifest + // Get network from manifest; an empty network field is a malformed manifest. let network_name = manifest .network() + .filter(|n| !n.is_empty()) .ok_or_else(|| DipsError::InvalidSubgraphManifest(deployment_id.clone()))?; - // Validate network is supported - let network_supported = registry.get_network_by_id(network_name).is_some() - || additional_networks.contains_key(network_name); - - if !network_supported { + // Reject networks this indexer hasn't configured for DIPs at the manifest step, + // instead of relying on the price lookup to miss the network later. + if !price_calculator.is_supported(network_name) { + tracing::info!( + agreement_id = %agreement_id, + network = %network_name, + deployment_id = %deployment_id, + "network not in configured supported_networks, rejecting proposal" + ); return Err(DipsError::UnsupportedNetwork(network_name.to_string())); } @@ -428,7 +433,7 @@ mod test { use crate::{ derive_agreement_id, - ipfs::{FailingIpfsFetcher, MockIpfsFetcher}, + ipfs::{EmptyNetworkIpfsFetcher, FailingIpfsFetcher, MockIpfsFetcher}, price::PriceCalculator, server::DipsServerContext, store::{FailingRcaStore, InMemoryRcaStore}, @@ -942,6 +947,40 @@ mod test { ); } + #[tokio::test] + async fn test_validate_and_create_rca_empty_network() { + // Arrange + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + + // Context with a manifest whose data source has an empty network field + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(EmptyNetworkIpfsFetcher), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + let rca_bytes = rca_to_wire_bytes(rca); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::InvalidSubgraphManifest(_))), + "Expected InvalidSubgraphManifest for empty network, got: {:?}", + result + ); + } + #[tokio::test] async fn test_validate_and_create_rca_store_failure() { // Arrange From 64268f14052f39c1b4fd6567f05e0406b846efe2 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:22:50 +1200 Subject: [PATCH 21/31] feat: verify the signature on indexing agreement proposals (#1045) * feat(dips): verify the EIP-712 signature on agreement proposals Incoming agreement proposals were no longer signature-checked, so a tampered or unsigned proposal was acted on. Recover the EIP-712 signer over the RecurringCollector domain and reject missing or malformed signatures; the recovered signer feeds a later trust check. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(dips): fetch the EIP-712 domain from the deployed contract When dips.rpc_url is set, the service reads the EIP-712 domain from the RecurringCollector contract (EIP-5267) at startup, so signer recovery tracks the deployed contract. DIPs startup failures now disable DIPs with an error log instead of taking down the whole service. Co-Authored-By: Claude Opus 4.8 * feat(service): report DIPs status on the info endpoint and metrics When DIPs initialization fails the service keeps serving queries, but /dips/info kept advertising prices, so selection systems would still pick the indexer. It now returns a generic unavailable status and an indexer_dips_enabled gauge exposes the degraded state to monitoring. Co-Authored-By: Claude Opus 4.8 * fix(config): comment out the DIPs RPC URL in the example config The example set rpc_url to a placeholder that answers with garbage, so anyone copying the file verbatim would start their indexer with DIPs disabled. The line is now commented out with a real public Arbitrum endpoint, and copied configs use the built-in domain constants. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 + crates/config/maximal-config-example.toml | 8 + crates/config/src/config.rs | 9 + crates/dips/Cargo.toml | 2 + crates/dips/src/eip5267.rs | 313 ++++++++++++++++++++++ crates/dips/src/lib.rs | 160 ++++++++++- crates/dips/src/server.rs | 18 +- crates/service/src/metrics.rs | 14 +- crates/service/src/routes/dips_info.rs | 95 +++++-- crates/service/src/service.rs | 313 +++++++++++++--------- 10 files changed, 780 insertions(+), 154 deletions(-) create mode 100644 crates/dips/src/eip5267.rs diff --git a/Cargo.lock b/Cargo.lock index 4f7bb4083..699c2e029 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4343,6 +4343,7 @@ dependencies = [ "prost 0.14.3", "rand 0.8.5", "serde", + "serde_json", "serde_yaml", "sqlx", "test-assets", @@ -4356,6 +4357,7 @@ dependencies = [ "tonic-prost-build", "tracing", "uuid", + "wiremock", ] [[package]] diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index d247652c5..d11e9217b 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -194,6 +194,14 @@ supported_networks = [] # Your own observations may differ - adjust pricing accordingly. min_grt_per_billion_entities_per_30_days = "200" # entity-based component (global) +# RecurringCollector contract address. The EIP-712 verifying contract used to +# recover the signer of incoming agreement proposals. Required when DIPs is enabled. +recurring_collector = "0xcccccccccccccccccccccccccccccccccccccccc" + +# Ethereum JSON-RPC endpoint for fetching the RecurringCollector's EIP-712 +# domain at startup (EIP-5267). When unset, built-in domain constants are used. +# rpc_url = "https://arb1.arbitrum.io/rpc" + [dips.min_grt_per_30_days] # base rate component (per-network) # arbitrum-one = "450" # matic = "300" diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index d0005c135..ae0a8a2c9 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -679,6 +679,12 @@ pub struct DipsConfig { /// Minimum acceptable GRT per billion entities per 30 days. pub min_grt_per_billion_entities_per_30_days: GRT, pub additional_networks: BTreeMap, + /// RecurringCollector address: the EIP-712 verifying contract used to recover + /// the signer of incoming RCA proposals. Required when DIPs is enabled. + pub recurring_collector: Option
, + /// Ethereum JSON-RPC endpoint used to fetch the EIP-712 domain from the + /// RecurringCollector at startup (EIP-5267). Unset: built-in domain constants. + pub rpc_url: Option, } impl Default for DipsConfig { @@ -690,6 +696,8 @@ impl Default for DipsConfig { min_grt_per_30_days: BTreeMap::new(), min_grt_per_billion_entities_per_30_days: GRT::ZERO, additional_networks: BTreeMap::new(), + recurring_collector: None, + rpc_url: None, } } } @@ -766,6 +774,7 @@ mod tests { HashSet::from([address!("deadbeefcafebabedeadbeefcafebabedeadbeef")]); max_config.dips = Some(crate::DipsConfig { min_grt_per_billion_entities_per_30_days: crate::GRT::from_grt("200"), + recurring_collector: Some(address!("cccccccccccccccccccccccccccccccccccccccc")), ..Default::default() }); diff --git a/crates/dips/Cargo.toml b/crates/dips/Cargo.toml index c3d4edaf7..b6a2f9ca6 100644 --- a/crates/dips/Cargo.toml +++ b/crates/dips/Cargo.toml @@ -58,6 +58,8 @@ indexer-monitor = { path = "../monitor" } graph-networks-registry.workspace = true build-info.workspace = true rand = "0.8" +serde_json.workspace = true +wiremock.workspace = true [build-dependencies] tonic-build = { workspace = true, optional = true } diff --git a/crates/dips/src/eip5267.rs b/crates/dips/src/eip5267.rs new file mode 100644 index 000000000..e25fd388b --- /dev/null +++ b/crates/dips/src/eip5267.rs @@ -0,0 +1,313 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! EIP-5267 domain discovery: derive the RCA verification domain from the +//! RecurringCollector's own `eip712Domain()` report so it tracks in-place +//! contract upgrades instead of relying on compile-time constants. + +use std::time::Duration; + +use anyhow::Context; +use thegraph_core::alloy::{ + network::TransactionBuilder, + primitives::{Address, Bytes, U256}, + providers::{Provider, ProviderBuilder}, + rpc::types::TransactionRequest, + sol, + sol_types::{Eip712Domain, SolCall}, +}; + +sol! { + /// EIP-5267 introspection: a contract reports its own EIP-712 domain. + function eip712Domain() external view returns ( + bytes1 fields, + string name, + string version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] extensions + ); +} + +/// Attempts before giving up on the RPC fetch. +const FETCH_ATTEMPTS: u32 = 3; +/// Delay between fetch attempts. +const FETCH_RETRY_DELAY: Duration = Duration::from_secs(2); +/// Upper bound on the reported domain name/version. The real values are short +/// ("RecurringCollector", "1"); anything huge is a hostile or broken RPC. +const MAX_DOMAIN_FIELD_LEN: usize = 256; + +/// Fetch the RCA EIP-712 domain from the RecurringCollector via `eip712Domain()`. +/// Only network errors are retried; a response that fails to decode or fails +/// validation (wrong chain id or contract) errors immediately, retries won't fix it. +pub async fn fetch_rca_eip712_domain( + rpc_url: &str, + recurring_collector: Address, + expected_chain_id: u64, +) -> anyhow::Result { + let mut attempt = 1; + let output = loop { + match call_eip712_domain(rpc_url, recurring_collector).await { + Ok(output) => break output, + Err(err) if attempt < FETCH_ATTEMPTS => { + tracing::warn!( + attempt, + error = format!("{err:#}"), + "eip712Domain() fetch failed, retrying" + ); + attempt += 1; + tokio::time::sleep(FETCH_RETRY_DELAY).await; + } + Err(err) => return Err(err), + } + }; + let report = eip712DomainCall::abi_decode_returns(&output) + .context("decoding the eip712Domain() response")?; + let domain = domain_from_report(report, recurring_collector, expected_chain_id)?; + tracing::info!( + name = domain.name.as_deref().unwrap_or_default(), + version = domain.version.as_deref().unwrap_or_default(), + chain_id = expected_chain_id, + verifying_contract = %recurring_collector, + "RCA EIP-712 domain fetched from RecurringCollector" + ); + Ok(domain) +} + +async fn call_eip712_domain(rpc_url: &str, recurring_collector: Address) -> anyhow::Result { + let provider = ProviderBuilder::new() + .connect(rpc_url) + .await + .context("connecting to dips.rpc_url")?; + let request = TransactionRequest::default() + .with_to(recurring_collector) + .with_input(eip712DomainCall {}.abi_encode()); + provider + .call(request) + .await + .context("calling eip712Domain() on the RecurringCollector") +} + +/// Build the verification domain from the contract's report, rejecting +/// reports that don't match the configured chain id and collector address. +fn domain_from_report( + report: eip712DomainReturn, + recurring_collector: Address, + expected_chain_id: u64, +) -> anyhow::Result { + anyhow::ensure!( + report.fields.0 == [0x0f], + "eip712Domain() fields bitmap {:#04x} unsupported: expected 0x0f \ + (name, version, chainId, verifyingContract)", + report.fields.0[0] + ); + anyhow::ensure!( + report.extensions.is_empty(), + "eip712Domain() reports {} extensions, which are not supported", + report.extensions.len() + ); + anyhow::ensure!( + !report.name.is_empty() && report.name.len() <= MAX_DOMAIN_FIELD_LEN, + "eip712Domain() reports a domain name of {} bytes; expected 1-{MAX_DOMAIN_FIELD_LEN}", + report.name.len() + ); + anyhow::ensure!( + !report.version.is_empty() && report.version.len() <= MAX_DOMAIN_FIELD_LEN, + "eip712Domain() reports a domain version of {} bytes; expected 1-{MAX_DOMAIN_FIELD_LEN}", + report.version.len() + ); + anyhow::ensure!( + report.chainId == U256::from(expected_chain_id), + "eip712Domain() reports chain id {} but blockchain.chain_id is {expected_chain_id}; \ + check that dips.rpc_url points at the right network", + report.chainId + ); + anyhow::ensure!( + report.verifyingContract == recurring_collector, + "eip712Domain() reports verifying contract {} but dips.recurring_collector \ + is {recurring_collector}", + report.verifyingContract + ); + Ok(Eip712Domain::new( + Some(report.name.into()), + Some(report.version.into()), + Some(U256::from(expected_chain_id)), + Some(recurring_collector), + None, + )) +} + +#[cfg(test)] +mod tests { + use thegraph_core::alloy::primitives::{hex, Address, FixedBytes, B256, U256}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + use super::*; + use crate::rca_eip712_domain; + + const CHAIN_ID: u64 = 1337; + + fn collector() -> Address { + Address::repeat_byte(0xCC) + } + + fn report( + fields: u8, + name: &str, + version: &str, + chain_id: u64, + verifying_contract: Address, + ) -> eip712DomainReturn { + eip712DomainReturn { + fields: FixedBytes([fields]), + name: name.to_string(), + version: version.to_string(), + chainId: U256::from(chain_id), + verifyingContract: verifying_contract, + salt: B256::ZERO, + extensions: vec![], + } + } + + #[test] + fn test_domain_from_report_matches_builtin_domain() { + // Arrange + let report = report(0x0f, "RecurringCollector", "1", CHAIN_ID, collector()); + + // Act + let domain = domain_from_report(report, collector(), CHAIN_ID).expect("valid report"); + + // Assert + assert_eq!(domain, rca_eip712_domain(CHAIN_ID, collector())); + } + + #[test] + fn test_domain_from_report_rejects_chain_id_mismatch() { + // Arrange + let report = report(0x0f, "RecurringCollector", "1", 42161, collector()); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("chain id"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_contract_mismatch() { + // Arrange + let report = report( + 0x0f, + "RecurringCollector", + "1", + CHAIN_ID, + Address::repeat_byte(0xDD), + ); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("verifying contract"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_nonempty_extensions() { + // Arrange + let mut report = report(0x0f, "RecurringCollector", "1", CHAIN_ID, collector()); + report.extensions = vec![U256::ZERO]; + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("extensions"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_empty_name() { + // Arrange + let report = report(0x0f, "", "1", CHAIN_ID, collector()); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("domain name"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_oversized_version() { + // Arrange + let oversized = "1".repeat(MAX_DOMAIN_FIELD_LEN + 1); + let report = report( + 0x0f, + "RecurringCollector", + &oversized, + CHAIN_ID, + collector(), + ); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("domain version"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_unknown_fields_bitmap() { + // Arrange: 0x1f would mean the domain also uses a salt, which the + // built domain would silently omit, so it must be rejected. + let report = report(0x1f, "RecurringCollector", "1", CHAIN_ID, collector()); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("fields bitmap"), "got: {err}"); + } + + /// Replies to any JSON-RPC request with the given eth_call result, + /// echoing the request id so the client accepts the response. + struct EthCallResponder { + result: Vec, + } + + impl Respond for EthCallResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": body["id"], + "result": format!("0x{}", hex::encode(&self.result)), + })) + } + } + + #[tokio::test] + async fn test_fetch_rca_eip712_domain_round_trip() { + // Arrange + let encoded = eip712DomainCall::abi_encode_returns(&report( + 0x0f, + "RecurringCollector", + "1", + CHAIN_ID, + collector(), + )); + let server = MockServer::start().await; + Mock::given(wiremock::matchers::method("POST")) + .respond_with(EthCallResponder { result: encoded }) + .mount(&server) + .await; + + // Act + let domain = fetch_rca_eip712_domain(&server.uri(), collector(), CHAIN_ID) + .await + .expect("fetch should succeed"); + + // Assert + assert_eq!(domain, rca_eip712_domain(CHAIN_ID, collector())); + } +} diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 6ce5f6040..805c7ab0c 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -53,14 +53,15 @@ use std::sync::Arc; use server::DipsServerContext; use thegraph_core::alloy::{ core::primitives::Address, - primitives::{keccak256, ruint::aliases::U256, Uint}, + primitives::{keccak256, ruint::aliases::U256, Signature, Uint}, signers::SignerSync, sol, - sol_types::{Eip712Domain, SolValue}, + sol_types::{eip712_domain, Eip712Domain, SolStruct, SolValue}, }; #[cfg(feature = "db")] pub mod database; +pub mod eip5267; pub mod inflight; pub mod ipfs; pub mod price; @@ -75,6 +76,8 @@ pub mod store; use thiserror::Error; use uuid::Uuid; +pub use crate::eip5267::fetch_rca_eip712_domain; + /// Protocol version (seconds-based RCA) pub const PROTOCOL_VERSION: u64 = 2; @@ -147,9 +150,23 @@ fn derive_agreement_id(rca: &RecurringCollectionAgreement) -> Uuid { Uuid::from_bytes(id_bytes) } +/// EIP-712 domain for RecurringCollectionAgreement signatures. Must match the +/// domain dipper signs with and the on-chain RecurringCollector contract; any +/// drift in name, version, chain id, or address makes every recovered signer wrong. +pub fn rca_eip712_domain(chain_id: u64, recurring_collector: Address) -> Eip712Domain { + eip712_domain! { + name: "RecurringCollector", + version: "1", + chain_id: chain_id, + verifying_contract: recurring_collector, + } +} + #[derive(Error, Debug)] pub enum DipsError { // RCA validation + #[error("invalid signature: {0}")] + InvalidSignature(String), #[error("RCA service provider {actual} does not match the expected address {expected}")] UnexpectedServiceProvider { expected: Address, actual: Address }, #[error("cannot get subgraph manifest for {0}")] @@ -210,6 +227,20 @@ impl RecurringCollectionAgreement { } impl SignedRecurringCollectionAgreement { + /// Recover the EIP-712 signer of the RCA over the RecurringCollector domain. + /// An empty, malformed, or non-recovering signature is rejected as + /// InvalidSignature. The recovered address is what PR B gates on. + pub fn recover_signer(&self, domain: &Eip712Domain) -> Result { + if self.signature.is_empty() { + return Err(DipsError::InvalidSignature("missing signature".to_string())); + } + let signature = Signature::try_from(self.signature.as_ref()) + .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; + signature + .recover_address_from_prehash(&self.agreement.eip712_signing_hash(domain)) + .map_err(|err| DipsError::InvalidSignature(err.to_string())) + } + /// Validate proposal-time fields. /// /// Checks that the service provider matches the expected indexer @@ -277,13 +308,19 @@ pub async fn validate_and_create_rca( price_calculator, registry, additional_networks, - .. + rca_domain, } = ctx.as_ref(); // Decode SignedRCA let signed_rca = SignedRecurringCollectionAgreement::abi_decode(rca_bytes.as_ref()) .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; + // Authenticate the sender before acting on the proposal: recover the EIP-712 + // signer. PR B gates this address against the on-chain agreement-manager role + // set; for now any cryptographically valid signature passes. + let signer = signed_rca.recover_signer(rca_domain)?; + tracing::debug!(%signer, "recovered RCA signer"); + // Validate service provider signed_rca.validate(expected_service_provider)?; @@ -435,6 +472,7 @@ mod test { derive_agreement_id, ipfs::{EmptyNetworkIpfsFetcher, FailingIpfsFetcher, MockIpfsFetcher}, price::PriceCalculator, + rca_eip712_domain, server::DipsServerContext, store::{FailingRcaStore, InMemoryRcaStore}, AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, @@ -442,9 +480,23 @@ mod test { }; use thegraph_core::alloy::{ primitives::{keccak256, Address, FixedBytes, U256}, - sol_types::SolValue, + signers::local::PrivateKeySigner, + sol_types::{Eip712Domain, SolValue}, }; + // Fixed signer and domain for round-tripping RCA signatures in tests. The + // recovered address equals `test_signer().address()`; the domain must match + // the one stored in the test context so recovery succeeds. + fn test_signer() -> PrivateKeySigner { + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + .parse() + .unwrap() + } + + fn test_rca_domain() -> Eip712Domain { + rca_eip712_domain(1337, Address::repeat_byte(0xCC)) + } + fn create_test_context() -> Arc { Arc::new(DipsServerContext { rca_store: Arc::new(InMemoryRcaStore::default()), @@ -456,19 +508,17 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), }) } - /// Helper: encode an RCA as a `SignedRecurringCollectionAgreement` - /// ABI payload. The signature field is no longer consumed by the - /// validator; this just produces the wire bytes that - /// `validate_and_create_rca` expects to decode. + /// Sign an RCA with the fixed test key over the test domain and ABI-encode + /// the wrapper, producing the wire bytes `validate_and_create_rca` decodes + /// and recovers the signer from. fn rca_to_wire_bytes(rca: RecurringCollectionAgreement) -> Vec { - SignedRecurringCollectionAgreement { - agreement: rca, - signature: Default::default(), - } - .abi_encode() + rca.sign(&test_rca_domain(), test_signer()) + .expect("signing test RCA") + .abi_encode() } fn create_test_rca( @@ -643,6 +693,85 @@ mod test { assert_eq!(data[0].0, agreement_id); } + #[test] + fn test_recover_signer_recovers_the_signing_key() { + let rca = create_test_rca( + Address::repeat_byte(0x42), + Address::repeat_byte(0x11), + U256::from(200), + U256::from(100), + ); + + let signed = rca + .sign(&test_rca_domain(), test_signer()) + .expect("signing test RCA"); + let recovered = signed + .recover_signer(&test_rca_domain()) + .expect("recovering signer"); + + assert_eq!(recovered, test_signer().address()); + } + + #[test] + fn test_recover_signer_rejects_empty_signature() { + let rca = create_test_rca( + Address::repeat_byte(0x42), + Address::repeat_byte(0x11), + U256::from(200), + U256::from(100), + ); + let signed = SignedRecurringCollectionAgreement { + agreement: rca, + signature: Default::default(), + }; + + assert!(matches!( + signed.recover_signer(&test_rca_domain()), + Err(DipsError::InvalidSignature(_)) + )); + } + + #[test] + fn test_recover_signer_rejects_malformed_signature() { + let rca = create_test_rca( + Address::repeat_byte(0x42), + Address::repeat_byte(0x11), + U256::from(200), + U256::from(100), + ); + let signed = SignedRecurringCollectionAgreement { + agreement: rca, + signature: vec![0xAA; 10].into(), + }; + + assert!(matches!( + signed.recover_signer(&test_rca_domain()), + Err(DipsError::InvalidSignature(_)) + )); + } + + #[tokio::test] + async fn test_validate_and_create_rca_missing_signature_rejected() { + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + // An unsigned proposal must be rejected before any other validation. + let rca_bytes = SignedRecurringCollectionAgreement { + agreement: rca, + signature: Default::default(), + } + .abi_encode(); + + let ctx = create_test_context(); + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(matches!(result, Err(DipsError::InvalidSignature(_)))); + } + #[tokio::test] async fn test_validate_and_create_rca_wrong_service_provider() { let payer = Address::repeat_byte(0x42); @@ -725,6 +854,7 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), }); let rca_bytes = rca_to_wire_bytes(rca); @@ -898,6 +1028,7 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), }); let rca_bytes = rca_to_wire_bytes(rca); @@ -932,6 +1063,7 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), }); let rca_bytes = rca_to_wire_bytes(rca); @@ -966,6 +1098,7 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), }); let rca_bytes = rca_to_wire_bytes(rca); @@ -1000,6 +1133,7 @@ mod test { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), }); let rca_bytes = rca_to_wire_bytes(rca); diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 99e027764..9b32ddf8b 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -45,7 +45,7 @@ use std::{collections::BTreeMap, sync::Arc}; use async_trait::async_trait; -use thegraph_core::alloy::primitives::Address; +use thegraph_core::alloy::{primitives::Address, sol_types::Eip712Domain}; use tonic::{Request, Response, Status}; use crate::{ @@ -79,6 +79,8 @@ pub struct DipsServerContext { pub registry: Arc, /// Additional networks beyond the registry pub additional_networks: Arc>, + /// EIP-712 domain for recovering the RCA signer (RecurringCollector). + pub rca_domain: Eip712Domain, } /// DIPS server implementing RCA protocol. @@ -109,6 +111,7 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { DipsError::AgreementExpired { .. } => RejectReason::AgreementExpired, DipsError::UnsupportedNetwork(_) => RejectReason::UnsupportedNetwork, DipsError::SubgraphManifestUnavailable(_) => RejectReason::SubgraphManifestUnavailable, + DipsError::InvalidSignature(_) => RejectReason::InvalidSignature, DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, DipsError::ManifestTooLarge { .. } => RejectReason::ManifestTooLarge, @@ -232,6 +235,7 @@ mod tests { )), registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), + rca_domain: crate::rca_eip712_domain(1337, Address::repeat_byte(0xCC)), }) } } @@ -395,6 +399,18 @@ mod tests { assert_eq!(reason, RejectReason::SubgraphManifestUnavailable); } + #[test] + fn test_reject_reason_invalid_signature() { + // Arrange + let err = DipsError::InvalidSignature("bad signature".to_string()); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::InvalidSignature); + } + #[test] fn test_reject_reason_unexpected_service_provider() { // Arrange diff --git a/crates/service/src/metrics.rs b/crates/service/src/metrics.rs index a71540545..3fe312b76 100644 --- a/crates/service/src/metrics.rs +++ b/crates/service/src/metrics.rs @@ -5,7 +5,8 @@ use std::{net::SocketAddr, sync::LazyLock}; use axum::{routing::get, serve, Router}; use prometheus::{ - register_counter_vec, register_histogram_vec, CounterVec, HistogramVec, TextEncoder, + register_counter_vec, register_histogram_vec, register_int_gauge, CounterVec, HistogramVec, + IntGauge, TextEncoder, }; use reqwest::StatusCode; use tokio::net::TcpListener; @@ -36,6 +37,17 @@ pub static FAILED_RECEIPT: LazyLock = LazyLock::new(|| { .unwrap() }); +/// Set to 1 when the DIPs gRPC server started, 0 when DIPs was configured but +/// failed to initialize (the service then runs without DIPs). Not exported +/// when DIPs is not configured, so alerting on 0 is safe for DIPs operators. +pub static DIPS_ENABLED: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "indexer_dips_enabled", + "1 if the DIPs gRPC server is running; 0 if DIPs is configured but failed to initialize" + ) + .unwrap() +}); + pub fn serve_metrics(host_and_port: SocketAddr) { tracing::info!(address = %host_and_port, "Serving prometheus metrics"); diff --git a/crates/service/src/routes/dips_info.rs b/crates/service/src/routes/dips_info.rs index 54666c340..a25a0c8e3 100644 --- a/crates/service/src/routes/dips_info.rs +++ b/crates/service/src/routes/dips_info.rs @@ -1,15 +1,25 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use axum::{extract::State, Json}; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; use serde::Serialize; use std::collections::BTreeMap; -/// State for the /dips/info endpoint, derived from DipsConfig at startup. +/// State for the /dips/info endpoint. `Failed` deliberately carries no error +/// detail: init errors can reference the operator's RPC endpoint (which may +/// embed credentials), so the cause stays in logs and metrics only. #[derive(Clone, Debug)] -pub struct DipsInfoState { - pub min_grt_per_30_days: BTreeMap, - pub min_grt_per_billion_entities_per_30_days: String, +pub enum DipsInfoState { + Available { + min_grt_per_30_days: BTreeMap, + min_grt_per_billion_entities_per_30_days: String, + }, + Failed, } #[derive(Serialize)] @@ -24,15 +34,68 @@ pub struct DipsInfoResponse { pub supported_networks: Vec, } -pub async fn dips_info(State(state): State) -> Json { - let supported_networks: Vec = state.min_grt_per_30_days.keys().cloned().collect(); - - Json(DipsInfoResponse { - pricing: DipsInfoPricing { - min_grt_per_30_days: state.min_grt_per_30_days, - min_grt_per_billion_entities_per_30_days: state - .min_grt_per_billion_entities_per_30_days, - }, - supported_networks, - }) +#[derive(Serialize)] +pub struct DipsInfoUnavailable { + pub status: &'static str, + pub error: &'static str, +} + +pub async fn dips_info(State(state): State) -> Response { + match state { + DipsInfoState::Available { + min_grt_per_30_days, + min_grt_per_billion_entities_per_30_days, + } => { + let supported_networks: Vec = min_grt_per_30_days.keys().cloned().collect(); + + Json(DipsInfoResponse { + pricing: DipsInfoPricing { + min_grt_per_30_days, + min_grt_per_billion_entities_per_30_days, + }, + supported_networks, + }) + .into_response() + } + DipsInfoState::Failed => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(DipsInfoUnavailable { + status: "unavailable", + error: "DIPs initialization failed; check the indexer-service logs", + }), + ) + .into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_dips_info_available_returns_pricing() { + // Arrange + let state = DipsInfoState::Available { + min_grt_per_30_days: BTreeMap::from([("mainnet".to_string(), "450".to_string())]), + min_grt_per_billion_entities_per_30_days: "200".to_string(), + }; + + // Act + let response = dips_info(State(state)).await; + + // Assert + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_dips_info_failed_returns_service_unavailable() { + // Arrange + let state = DipsInfoState::Failed; + + // Act + let response = dips_info(State(state)).await; + + // Assert + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } } diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 701b8435f..90dc3e575 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -25,7 +25,7 @@ use indexer_monitor::{DeploymentDetails, SubgraphClient}; use release::IndexerServiceRelease; use reqwest::Url; use tap_core::tap_eip712_domain; -use thegraph_core::alloy::primitives::U256; +use thegraph_core::alloy::primitives::{Address, U256}; use tokio::{net::TcpListener, signal}; use tokio_util::sync::CancellationToken; use tonic::transport::server::TcpConnectInfo; @@ -38,7 +38,10 @@ use tower_http::normalize_path::NormalizePath; use tracing::info; use crate::{ - cli::Cli, constants::HTTP_CLIENT_TIMEOUT, database, metrics::serve_metrics, + cli::Cli, + constants::HTTP_CLIENT_TIMEOUT, + database, + metrics::{serve_metrics, DIPS_ENABLED}, routes::DipsInfoState, }; @@ -132,6 +135,9 @@ pub async fn run() -> anyhow::Result<()> { ); let host_and_port = config.service.host_and_port; let indexer_address = config.indexer.indexer_address; + // Captured before `config.blockchain` is moved into the router state below; + // used to build the RCA EIP-712 domain for DIPs signer recovery. + let blockchain_chain_id = config.blockchain.chain_id as u64; let ipfs_url = config.service.ipfs_url.clone(); // V2 escrow accounts (used by DIPs) live in the network subgraph; no @@ -164,17 +170,48 @@ pub async fn run() -> anyhow::Result<()> { } }; - // Build DipsInfoState if DIPS is configured - let dips_info_state = config.dips.as_ref().map(|dips| DipsInfoState { - min_grt_per_30_days: dips - .min_grt_per_30_days - .iter() - .map(|(network, grt)| (network.clone(), format_grt(grt.wei()))) - .collect(), - min_grt_per_billion_entities_per_30_days: format_grt( - dips.min_grt_per_billion_entities_per_30_days.wei(), - ), - }); + // Create a cancellation token for coordinated graceful shutdown + let shutdown_token = CancellationToken::new(); + + // DIPS: RecurringCollectionAgreement validation and storage. An init + // failure here must not take down query serving: log it loudly and run + // without DIPs instead. /dips/info and the metric reflect the outcome. + let dips_info_state = match config.dips.as_ref() { + Some(dips) => match start_dips( + dips, + blockchain_chain_id, + &ipfs_url, + database.clone(), + indexer_address, + &shutdown_token, + ) + .await + { + Ok(()) => { + DIPS_ENABLED.set(1); + Some(DipsInfoState::Available { + min_grt_per_30_days: dips + .min_grt_per_30_days + .iter() + .map(|(network, grt)| (network.clone(), format_grt(grt.wei()))) + .collect(), + min_grt_per_billion_entities_per_30_days: format_grt( + dips.min_grt_per_billion_entities_per_30_days.wei(), + ), + }) + } + Err(err) => { + DIPS_ENABLED.set(0); + tracing::error!( + error = format!("{err:#}"), + "DIPs initialization failed; the service is running WITHOUT DIPs \ + and will not receive indexing agreement proposals until restarted" + ); + Some(DipsInfoState::Failed) + } + }, + None => None, + }; let router = ServiceRouter::builder() .database(database.clone()) @@ -193,120 +230,10 @@ pub async fn run() -> anyhow::Result<()> { serve_metrics(config.metrics.get_socket_addr()); - // Create a cancellation token for coordinated graceful shutdown - let shutdown_token = CancellationToken::new(); - tracing::info!( address = %host_and_port, "Serving requests", ); - // DIPS: RecurringCollectionAgreement validation and storage - if let Some(dips) = config.dips.as_ref() { - let DipsConfig { - host, - port, - supported_networks, - min_grt_per_30_days, - min_grt_per_billion_entities_per_30_days, - additional_networks, - .. - } = dips; - - if supported_networks.is_empty() { - tracing::warn!( - "DIPS enabled but no networks in dips.supported_networks. \ - All proposals will be rejected." - ); - } - - tracing::info!( - supported_networks = ?supported_networks, - ipfs_url = %ipfs_url, - "DIPs configuration loaded" - ); - for (network, grt) in min_grt_per_30_days.iter() { - tracing::info!( - network = %network, - min_grt_per_30_days_wei = %grt.wei(), - "DIPs network pricing" - ); - } - tracing::info!( - min_grt_per_billion_entities_per_30_days_wei = %min_grt_per_billion_entities_per_30_days.wei(), - "DIPs entity pricing" - ); - - let addr: SocketAddr = format!("{host}:{port}") - .parse() - .with_context(|| format!("Invalid DIPS host:port '{host}:{port}'"))?; - - // Shared counter of in-flight gRPC requests. The IPFS client reads - // it to decide whether to use the full retry budget or fall back to - // a single attempt when the service is under load. - let inflight = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - - // Initialize validation dependencies - let ipfs_fetcher = Arc::new(IpfsClient::new(ipfs_url.as_str(), inflight.clone())?); - let registry = Arc::new( - NetworksRegistry::from_latest_version() - .await - .context("Failed to fetch NetworksRegistry for DIPS")?, - ); - - // Convert GRT/30days to wei/second for protocol compatibility. - // Use ceiling division to protect indexers: configured minimums round UP, - // ensuring indexers never accept less than their stated minimum. - // 30 days = 2,592,000 seconds - const SECONDS_PER_30_DAYS: u128 = 30 * 24 * 60 * 60; - let tokens_per_second = min_grt_per_30_days - .iter() - .map(|(network, grt)| { - let wei_per_second = grt.wei().div_ceil(SECONDS_PER_30_DAYS); - (network.clone(), U256::from(wei_per_second)) - }) - .collect(); - - // Entity pricing: config is per-billion-entities, convert to per-entity. - // Ceiling division protects indexer from precision loss. - let entity_divisor = SECONDS_PER_30_DAYS * 1_000_000_000; - let tokens_per_entity_per_second = U256::from( - min_grt_per_billion_entities_per_30_days - .wei() - .div_ceil(entity_divisor), - ); - - // Build server context - let ctx = Arc::new(DipsServerContext { - rca_store: Arc::new(PsqlRcaStore { - pool: database.clone(), - }), - ipfs_fetcher, - price_calculator: Arc::new(PriceCalculator::new( - supported_networks.clone(), - tokens_per_second, - tokens_per_entity_per_second, - )), - registry, - additional_networks: Arc::new(additional_networks.clone()), - }); - - // Create DIPS server - let server = DipsServer { - ctx, - expected_payee: indexer_address, - inflight, - }; - - info!( - address = %addr, - "Starting DIPS gRPC server (RecurringCollectionAgreement validation)" - ); - - let dips_shutdown_token = shutdown_token.clone(); - tokio::spawn(async move { - start_dips_server(addr, server, dips_shutdown_token.cancelled()).await; - }); - } let listener = TcpListener::bind(&host_and_port) .await @@ -321,6 +248,146 @@ pub async fn run() -> anyhow::Result<()> { .await?) } +/// Initialize and start the DIPS gRPC server. Errors return to the caller, +/// which runs the service without DIPs rather than letting an optional +/// subsystem take down query serving. +async fn start_dips( + dips: &DipsConfig, + blockchain_chain_id: u64, + ipfs_url: &Url, + database: sqlx::PgPool, + indexer_address: Address, + shutdown_token: &CancellationToken, +) -> anyhow::Result<()> { + let DipsConfig { + host, + port, + supported_networks, + min_grt_per_30_days, + min_grt_per_billion_entities_per_30_days, + additional_networks, + recurring_collector, + rpc_url, + } = dips; + + // The RecurringCollector address is the EIP-712 verifying contract used to + // recover RCA signers; without it DIPs cannot authenticate proposals. + let recurring_collector = (*recurring_collector).ok_or_else(|| { + anyhow::anyhow!("dips.recurring_collector must be set when DIPs is enabled") + })?; + + // Prefer the domain the deployed contract reports (EIP-5267) so it tracks + // upgrades; without an RPC endpoint, fall back to built-in constants. + let rca_domain = match rpc_url { + Some(url) => { + indexer_dips::fetch_rca_eip712_domain( + url.as_str(), + recurring_collector, + blockchain_chain_id, + ) + .await? + } + None => { + tracing::info!("dips.rpc_url not set; using built-in RCA EIP-712 domain constants"); + indexer_dips::rca_eip712_domain(blockchain_chain_id, recurring_collector) + } + }; + + if supported_networks.is_empty() { + tracing::warn!( + "DIPS enabled but no networks in dips.supported_networks. \ + All proposals will be rejected." + ); + } + + tracing::info!( + supported_networks = ?supported_networks, + ipfs_url = %ipfs_url, + "DIPs configuration loaded" + ); + for (network, grt) in min_grt_per_30_days.iter() { + tracing::info!( + network = %network, + min_grt_per_30_days_wei = %grt.wei(), + "DIPs network pricing" + ); + } + tracing::info!( + min_grt_per_billion_entities_per_30_days_wei = %min_grt_per_billion_entities_per_30_days.wei(), + "DIPs entity pricing" + ); + + let addr: SocketAddr = format!("{host}:{port}") + .parse() + .with_context(|| format!("Invalid DIPS host:port '{host}:{port}'"))?; + + // Shared counter of in-flight gRPC requests. The IPFS client reads + // it to decide whether to use the full retry budget or fall back to + // a single attempt when the service is under load. + let inflight = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + // Initialize validation dependencies + let ipfs_fetcher = Arc::new(IpfsClient::new(ipfs_url.as_str(), inflight.clone())?); + let registry = Arc::new( + NetworksRegistry::from_latest_version() + .await + .context("Failed to fetch NetworksRegistry for DIPS")?, + ); + + // Convert GRT/30days to wei/second for protocol compatibility. + // Use ceiling division to protect indexers: configured minimums round UP, + // ensuring indexers never accept less than their stated minimum. + const SECONDS_PER_30_DAYS: u128 = 30 * 24 * 60 * 60; + let tokens_per_second = min_grt_per_30_days + .iter() + .map(|(network, grt)| { + let wei_per_second = grt.wei().div_ceil(SECONDS_PER_30_DAYS); + (network.clone(), U256::from(wei_per_second)) + }) + .collect(); + + // Entity pricing: config is per-billion-entities, convert to per-entity. + // Ceiling division protects indexer from precision loss. + let entity_divisor = SECONDS_PER_30_DAYS * 1_000_000_000; + let tokens_per_entity_per_second = U256::from( + min_grt_per_billion_entities_per_30_days + .wei() + .div_ceil(entity_divisor), + ); + + // Build server context + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(PsqlRcaStore { pool: database }), + ipfs_fetcher, + price_calculator: Arc::new(PriceCalculator::new( + supported_networks.clone(), + tokens_per_second, + tokens_per_entity_per_second, + )), + registry, + additional_networks: Arc::new(additional_networks.clone()), + rca_domain, + }); + + // Create DIPS server + let server = DipsServer { + ctx, + expected_payee: indexer_address, + inflight, + }; + + info!( + address = %addr, + "Starting DIPS gRPC server (RecurringCollectionAgreement validation)" + ); + + let dips_shutdown_token = shutdown_token.clone(); + tokio::spawn(async move { + start_dips_server(addr, server, dips_shutdown_token.cancelled()).await; + }); + Ok(()) +} + /// Per-request timeout across the whole gRPC handler. Long enough to cover /// the worst-case IPFS retry budget (190s) with headroom; short enough that /// a hung handler doesn't pin a worker indefinitely. From 750f9a3dd1cfdc6551618bc40634485cff22ee74 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:32:05 +1200 Subject: [PATCH 22/31] feat: introduce optional cap on # of DIPs agreements accepted per day (#1050) Indexers can now set a limit on how many DIPs agreements they take on in a day. The limit is off by default, so nothing changes unless an operator sets it. --- crates/config/maximal-config-example.toml | 4 + crates/config/src/config.rs | 5 + crates/dips/src/database.rs | 80 ++++++++- crates/dips/src/lib.rs | 208 +++++++++++++++++++++- crates/dips/src/server.rs | 16 ++ crates/dips/src/store.rs | 17 +- crates/service/src/constants.rs | 11 -- crates/service/src/service.rs | 3 + 8 files changed, 330 insertions(+), 14 deletions(-) diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index d11e9217b..fcb588e76 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -202,6 +202,10 @@ recurring_collector = "0xcccccccccccccccccccccccccccccccccccccccc" # domain at startup (EIP-5267). When unset, built-in domain constants are used. # rpc_url = "https://arb1.arbitrum.io/rpc" +# Optional safeguard: cap live DIPs agreements (awaiting acceptance or accepted) per +# rolling 24h; rejected and expired proposals don't count. Unset = no cap; 0 rejects all. +# max_new_agreements_per_24h = 30 + [dips.min_grt_per_30_days] # base rate component (per-network) # arbitrum-one = "450" # matic = "300" diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index ae0a8a2c9..8919132a4 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -685,6 +685,10 @@ pub struct DipsConfig { /// Ethereum JSON-RPC endpoint used to fetch the EIP-712 domain from the /// RecurringCollector at startup (EIP-5267). Unset: built-in domain constants. pub rpc_url: Option, + /// Max live DIPs agreements (awaiting acceptance or accepted) per rolling 24h + /// window; rejected and expired proposals don't count. None (unset) disables the + /// cap; a best-effort safeguard, not a security control. + pub max_new_agreements_per_24h: Option, } impl Default for DipsConfig { @@ -698,6 +702,7 @@ impl Default for DipsConfig { additional_networks: BTreeMap::new(), recurring_collector: None, rpc_url: None, + max_new_agreements_per_24h: None, } } } diff --git a/crates/dips/src/database.rs b/crates/dips/src/database.rs index dcff22331..1881c1234 100644 --- a/crates/dips/src/database.rs +++ b/crates/dips/src/database.rs @@ -32,7 +32,7 @@ //! Without idempotency, the retry would fail with a duplicate key error, causing //! Dipper to mark the agreement as failed even though it was successfully stored. -use std::any::Any; +use std::{any::Any, time::Duration}; use async_trait::async_trait; use sqlx::PgPool; @@ -71,7 +71,85 @@ impl RcaStore for PsqlRcaStore { Ok(()) } + async fn count_since(&self, window: Duration) -> Result { + // Count live agreements only: 'pending' (awaiting the agent) and 'accepted'. + // The agent records rejected and expired proposals as 'rejected', so they + // drop out. NOW() is the DB clock, so the window ignores host clock skew. + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pending_rca_proposals + WHERE status IN ('pending', 'accepted') + AND created_at >= NOW() - ($1 * interval '1 second')", + ) + .bind(window.as_secs() as i64) + .fetch_one(&self.pool) + .await + .map_err(|e| DipsError::UnknownError(e.into()))?; + + Ok(count.max(0) as u64) + } + fn as_any(&self) -> &dyn Any { self } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + /// Insert a proposal row with `status`, created `hours_ago` before the DB clock. + async fn insert_at(pool: &PgPool, id: Uuid, hours_ago: i64, status: &str) { + sqlx::query( + "INSERT INTO pending_rca_proposals (id, signed_payload, version, status, created_at, updated_at) + VALUES ($1, $2, 2, $4, NOW() - ($3 * interval '1 hour'), NOW())", + ) + .bind(id) + .bind(vec![1u8, 2, 3]) + .bind(hours_ago) + .bind(status) + .execute(pool) + .await + .unwrap(); + } + + // Exercises the rolling-window SQL against a real Postgres (testcontainers); + // the in-memory store can't, since it ignores the window. + #[tokio::test] + async fn count_since_excludes_rows_outside_the_window() { + let test_db = test_assets::setup_shared_test_db().await; + let store = PsqlRcaStore { + pool: test_db.pool.clone(), + }; + insert_at(&test_db.pool, Uuid::from_u128(1), 1, "pending").await; // inside 24h + insert_at(&test_db.pool, Uuid::from_u128(2), 25, "pending").await; // outside 24h + + let count = store + .count_since(Duration::from_secs(24 * 60 * 60)) + .await + .unwrap(); + + assert_eq!(count, 1, "only the row inside the window should count"); + } + + // The cap counts live agreements only; the agent records both rejected and + // expired proposals as 'rejected', so neither should count toward the cap. + #[tokio::test] + async fn count_since_counts_only_pending_and_accepted() { + let test_db = test_assets::setup_shared_test_db().await; + let store = PsqlRcaStore { + pool: test_db.pool.clone(), + }; + insert_at(&test_db.pool, Uuid::from_u128(1), 1, "pending").await; + insert_at(&test_db.pool, Uuid::from_u128(2), 1, "accepted").await; + insert_at(&test_db.pool, Uuid::from_u128(3), 1, "rejected").await; + + let count = store + .count_since(Duration::from_secs(24 * 60 * 60)) + .await + .unwrap(); + + assert_eq!(count, 2, "only pending and accepted rows should count"); + } +} diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 805c7ab0c..d8ddffd96 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -200,6 +200,8 @@ pub enum DipsError { DeadlineExpired { deadline: u64, now: u64 }, #[error("agreement end time {ends_at} has already passed (current time: {now})")] AgreementExpired { ends_at: u64, now: u64 }, + #[error("indexer is at its DIPs agreement capacity ({limit} per 24h)")] + CapacityExceeded { limit: u64 }, } #[cfg(feature = "rpc")] @@ -285,6 +287,9 @@ pub(crate) fn try_extract_deployment_id(rca_bytes: &[u8]) -> Option { Some(bytes32_to_ipfs_hash(&metadata.subgraphDeploymentId.0)) } +/// Window over which the cap counts live agreements (pending or accepted). +const CAPACITY_WINDOW: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); + /// Validate and create a RecurringCollectionAgreement. /// /// Performs validation: @@ -309,6 +314,7 @@ pub async fn validate_and_create_rca( registry, additional_networks, rca_domain, + max_new_agreements_per_24h, } = ctx.as_ref(); // Decode SignedRCA @@ -341,6 +347,21 @@ pub async fn validate_and_create_rca( return Err(DipsError::AgreementExpired { ends_at, now }); } + // Capacity safeguard: cap live agreements (pending or accepted) per rolling + // 24h, checked before the IPFS fetch so an over-cap proposal costs no download. + // Count-then-store isn't atomic, so concurrent proposals may overshoot slightly. + if let Some(limit) = max_new_agreements_per_24h { + match rca_store.count_since(CAPACITY_WINDOW).await { + Ok(count) if count >= *limit => { + return Err(DipsError::CapacityExceeded { limit: *limit }); + } + Ok(_) => {} + // Fail open: the cap is a best-effort safeguard, not a security control, + // so a transient count failure shouldn't reject a valid proposal. + Err(e) => tracing::warn!(error = %e, "DIPs capacity check failed; allowing proposal"), + } + } + // Derive agreement ID deterministically from the RCA fields let agreement_id = derive_agreement_id(&signed_rca.agreement); @@ -474,7 +495,7 @@ mod test { price::PriceCalculator, rca_eip712_domain, server::DipsServerContext, - store::{FailingRcaStore, InMemoryRcaStore}, + store::{FailingRcaStore, InMemoryRcaStore, RcaStore}, AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, RecurringCollectionAgreement, SignedRecurringCollectionAgreement, }; @@ -509,6 +530,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + max_new_agreements_per_24h: None, }) } @@ -855,6 +877,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + max_new_agreements_per_24h: None, }); let rca_bytes = rca_to_wire_bytes(rca); @@ -1029,6 +1052,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + max_new_agreements_per_24h: None, }); let rca_bytes = rca_to_wire_bytes(rca); @@ -1064,6 +1088,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + max_new_agreements_per_24h: None, }); let rca_bytes = rca_to_wire_bytes(rca); @@ -1099,6 +1124,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + max_new_agreements_per_24h: None, }); let rca_bytes = rca_to_wire_bytes(rca); @@ -1134,6 +1160,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + max_new_agreements_per_24h: None, }); let rca_bytes = rca_to_wire_bytes(rca); @@ -1222,4 +1249,183 @@ mod test { "Known test vector mismatch" ); } + + /// Build a context backed by `store` with the per-24h cap set to `max`. + fn capacity_ctx(store: Arc, max: Option) -> Arc { + Arc::new(DipsServerContext { + rca_store: store, + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + max_new_agreements_per_24h: max, + }) + } + + /// Fill an in-memory store with `n` distinct stored proposals. + async fn prefill(store: &InMemoryRcaStore, n: u64) { + for i in 0..n { + store + .store_rca(uuid::Uuid::from_u128(i as u128 + 1), vec![i as u8], 2) + .await + .unwrap(); + } + } + + /// A store whose count query fails but whose writes succeed, to exercise the + /// capacity check's fail-open path. + #[derive(Debug, Default)] + struct CountFailingStore(InMemoryRcaStore); + + #[async_trait::async_trait] + impl RcaStore for CountFailingStore { + async fn store_rca( + &self, + agreement_id: uuid::Uuid, + signed_rca: Vec, + version: u64, + ) -> Result<(), DipsError> { + self.0.store_rca(agreement_id, signed_rca, version).await + } + + async fn count_since(&self, _window: std::time::Duration) -> Result { + Err(DipsError::UnknownError(anyhow::anyhow!( + "count failed (test)" + ))) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + } + + #[tokio::test] + async fn capacity_at_limit_rejects() { + let service_provider = Address::repeat_byte(0x11); + let store = Arc::new(InMemoryRcaStore::default()); + prefill(&store, 2).await; + let ctx = capacity_ctx(store, Some(2)); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx.clone(), &service_provider, rca_to_wire_bytes(rca)) + .await; + + assert!(matches!( + result, + Err(DipsError::CapacityExceeded { limit: 2 }) + )); + // The over-cap proposal must not have been stored. + let stored = ctx + .rca_store + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(stored.data.read().await.len(), 2); + } + + #[tokio::test] + async fn capacity_under_limit_passes() { + let service_provider = Address::repeat_byte(0x11); + let store = Arc::new(InMemoryRcaStore::default()); + prefill(&store, 1).await; + let ctx = capacity_ctx(store, Some(2)); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx.clone(), &service_provider, rca_to_wire_bytes(rca)) + .await; + + assert!(result.is_ok(), "got: {:?}", result); + // The accepted proposal is stored, taking the count from 1 to 2. + let stored = ctx + .rca_store + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(stored.data.read().await.len(), 2); + } + + #[tokio::test] + async fn capacity_unset_skips_check() { + let service_provider = Address::repeat_byte(0x11); + let store = Arc::new(InMemoryRcaStore::default()); + // Five already stored, but no cap configured, so a new one still passes. + prefill(&store, 5).await; + let ctx = capacity_ctx(store, None); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx, &service_provider, rca_to_wire_bytes(rca)).await; + + assert!(result.is_ok(), "got: {:?}", result); + } + + #[tokio::test] + async fn capacity_count_failure_fails_open() { + let service_provider = Address::repeat_byte(0x11); + // The count query errors; the cap is best-effort, so the proposal is + // allowed through rather than rejected. + let ctx = capacity_ctx(Arc::new(CountFailingStore::default()), Some(1)); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx.clone(), &service_provider, rca_to_wire_bytes(rca)) + .await; + + assert!(result.is_ok(), "got: {:?}", result); + // Fail-open still completes the path and stores the proposal. + let stored = ctx + .rca_store + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(stored.0.data.read().await.len(), 1); + } + + #[tokio::test] + async fn capacity_zero_limit_rejects_all() { + let service_provider = Address::repeat_byte(0x11); + // An empty store with a cap of 0 still rejects every proposal (0 >= 0). + let ctx = capacity_ctx(Arc::new(InMemoryRcaStore::default()), Some(0)); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx, &service_provider, rca_to_wire_bytes(rca)).await; + + assert!(matches!( + result, + Err(DipsError::CapacityExceeded { limit: 0 }) + )); + } } diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 9b32ddf8b..384e55c76 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -81,6 +81,8 @@ pub struct DipsServerContext { pub additional_networks: Arc>, /// EIP-712 domain for recovering the RCA signer (RecurringCollector). pub rca_domain: Eip712Domain, + /// Max live DIPs agreements (pending or accepted) per rolling 24h window. None disables the cap. + pub max_new_agreements_per_24h: Option, } /// DIPS server implementing RCA protocol. @@ -115,6 +117,7 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, DipsError::ManifestTooLarge { .. } => RejectReason::ManifestTooLarge, + DipsError::CapacityExceeded { .. } => RejectReason::CapacityExceeded, // Malformed proposals with no dedicated reason map to the catch-all; the // detail carries the specifics. DipsError::AbiDecoding(_) @@ -236,6 +239,7 @@ mod tests { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: crate::rca_eip712_domain(1337, Address::repeat_byte(0xCC)), + max_new_agreements_per_24h: None, }) } } @@ -475,6 +479,18 @@ mod tests { assert_eq!(reason, RejectReason::ManifestTooLarge); } + #[test] + fn test_reject_reason_capacity_exceeded() { + // Arrange + let err = DipsError::CapacityExceeded { limit: 30 }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::CapacityExceeded); + } + #[test] fn test_reject_reason_unknown_error_is_transient() { // Arrange: a store/database failure surfaces as UnknownError. diff --git a/crates/dips/src/store.rs b/crates/dips/src/store.rs index d5f3da040..a15f36e66 100644 --- a/crates/dips/src/store.rs +++ b/crates/dips/src/store.rs @@ -25,7 +25,7 @@ //! - [`InMemoryRcaStore`] - In-memory store for unit tests //! - [`PsqlRcaStore`](crate::database::PsqlRcaStore) - PostgreSQL implementation -use std::any::Any; +use std::{any::Any, time::Duration}; use async_trait::async_trait; use uuid::Uuid; @@ -54,6 +54,11 @@ pub trait RcaStore: Sync + Send + std::fmt::Debug { version: u64, ) -> Result<(), DipsError>; + /// Count live agreements (status `pending` or `accepted`) within the trailing + /// `window`; rejected and expired proposals are excluded. The in-memory test + /// store has no status, so it ignores the window and counts every entry. + async fn count_since(&self, window: Duration) -> Result; + /// Downcast to concrete type for testing. fn as_any(&self) -> &dyn Any; } @@ -80,6 +85,10 @@ impl RcaStore for InMemoryRcaStore { Ok(()) } + async fn count_since(&self, _window: Duration) -> Result { + Ok(self.data.read().await.len() as u64) + } + fn as_any(&self) -> &dyn Any { self } @@ -102,6 +111,12 @@ impl RcaStore for FailingRcaStore { ))) } + async fn count_since(&self, _window: Duration) -> Result { + Err(DipsError::UnknownError(anyhow::anyhow!( + "database connection failed (test store)" + ))) + } + fn as_any(&self) -> &dyn Any { self } diff --git a/crates/service/src/constants.rs b/crates/service/src/constants.rs index dcecf0343..d2f65a697 100644 --- a/crates/service/src/constants.rs +++ b/crates/service/src/constants.rs @@ -39,17 +39,6 @@ use std::time::Duration; /// - Graph-node processing time pub const HTTP_CLIENT_TIMEOUT: Duration = Duration::from_secs(30); -/// Timeout for DIPS HTTP client requests. -/// -/// DIPS (Decentralized Indexer Payment System) operations involve: -/// - IPFS content fetching -/// - Agreement validation and storage -/// - Network registry lookups -/// -/// 60 seconds provides additional headroom for these heavier operations -/// compared to standard graph-node queries. -pub const DIPS_HTTP_CLIENT_TIMEOUT: Duration = Duration::from_secs(60); - // ============================================================================= // DATABASE CONFIGURATION // ============================================================================= diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 90dc3e575..3bbbe5309 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -268,6 +268,7 @@ async fn start_dips( additional_networks, recurring_collector, rpc_url, + max_new_agreements_per_24h, } = dips; // The RecurringCollector address is the EIP-712 verifying contract used to @@ -303,6 +304,7 @@ async fn start_dips( tracing::info!( supported_networks = ?supported_networks, ipfs_url = %ipfs_url, + max_new_agreements_per_24h = ?max_new_agreements_per_24h, "DIPs configuration loaded" ); for (network, grt) in min_grt_per_30_days.iter() { @@ -367,6 +369,7 @@ async fn start_dips( registry, additional_networks: Arc::new(additional_networks.clone()), rca_domain, + max_new_agreements_per_24h: *max_new_agreements_per_24h, }); // Create DIPS server From 55b39a296386e8fbf4531b7e44331e5b0bbf8d31 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:43:17 +1200 Subject: [PATCH 23/31] feat: reject proposals from untrusted senders and update docs (#1048) * feat(dips): verify the EIP-712 signature on agreement proposals Incoming agreement proposals were no longer signature-checked, so a tampered or unsigned proposal was acted on. Recover the EIP-712 signer over the RecurringCollector domain and reject missing or malformed signatures; the recovered signer feeds a later trust check. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(dips): reject agreement proposals from untrusted senders The payments service recovers each proposal's signer but accepts any valid signature, with no check on who sent it. Require the signer to hold the on-chain agreement-manager role, read from the indexing-payments subgraph, and reject senders that are not in that set. Co-Authored-By: Claude Opus 4.8 * refactor(dips): fetch agreement-manager roles via typed paginated query The role-holder read hand-built a raw GraphQL string, parsed five bespoke structs, and capped silently at 1000 holders. Switch to a typed, compile-checked query paginated by id cursor so a larger set can't be truncated, and derive the role hash to prevent drift. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(dips): cache rejected signers so repeats skip the subgraph query A proposal from a signer not in the cached role set always triggered a subgraph fetch, so a peer retrying the same unauthorised proposal re-queried every time. Remember a definitive "not a holder" for an hour; a genuinely new signer still fetches at once. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(dips): retry a failed role refresh on a short backoff The background refresh runs once a day, so one failed run left the cached role set to go stale and the gate rejecting every proposal as transient until the next day. Now retry on a rising backoff until it succeeds, so a brief outage heals in seconds, not a day. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(config): reject a zero DIPs role-refresh interval A zero role_refresh_interval_secs reached the refresh timer and silently killed the background refresh task on startup, leaving the role gate dead with no error. Reject it in config validation, matching the zero-checks already applied to every other interval. Co-Authored-By: Claude Opus 4.8 (1M context) * test(dips): cover the fail-open admit and chain-lag success paths The decision a proposal gets after a refresh -- trust, reject, fail-open admit, or transient -- lived inline, so the fail-open admit (a known holder kept during an outage) only ran on a rare race and went untested. Lift it into a pure function and check it all. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(dips): count proposal outcomes and role-set health as metrics Whether a proposal was accepted, rejected as untrusted, bounced as transient, or refused on other grounds was only in the logs, which don't roll up into rates an operator can alert on. Add counters and gauges for the outcomes, the role-set size and its refresh age. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(config): document the indexing-payments subgraph and role tunables The example enabled DIPs but left out the indexing-payments subgraph it now needs and the three role-refresh knobs, so an operator copying it would hit a startup error. Add the subgraph block and the knobs with their defaults, and update the parse test to match. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(dips): cap on-demand role refreshes to one per 5s An unknown signer triggered a full subgraph refetch on every request, so a spray of distinct signers mapped one-to-one onto refetches. Reuse a fetch from the last 5 seconds instead; a newly-approved manager is still recognised within seconds. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(config): validate DIPs collector and subgraph at load time The recurring-collector address and indexing-payments subgraph were only checked partway through service boot. Reject a DIPs-enabled config that omits either at load time, so startup fails fast with a clear message instead of a late error. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(config): fix DIPs config contradictions and document tunables The [dips] header described the removed payer-allowlist model (payer, on-chain, at acceptance) and the entity-price example posed as a default it is not. Correct both, rewrite the stale DipsConfig doc, and document every tunable's unit, default, required-ness, and failure mode. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(config): clarify DIPs and subgraph config comments for indexers Several options in the example config were documented tersely or with contradictory notes -- the role-refresh and rpc_url tunables, the subgraph auth token, and a claim that query_url and deployment_id are mutually exclusive (the code requires query_url). Reword each for clarity. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- Cargo.lock | 3 + crates/config/maximal-config-example.toml | 87 +- crates/config/src/config.rs | 48 +- crates/dips/Cargo.toml | 9 +- crates/dips/src/lib.rs | 60 +- crates/dips/src/metrics.rs | 40 + crates/dips/src/server.rs | 54 +- crates/dips/src/trusted_signers.rs | 861 ++++++++++++++++++ .../agreement_manager_roles.query.graphql | 30 + .../graphql/indexing_payments.schema.graphql | 64 ++ crates/query/src/lib.rs | 16 + crates/service/src/service.rs | 29 + crates/tap-agent/src/agent.rs | 3 +- 13 files changed, 1270 insertions(+), 34 deletions(-) create mode 100644 crates/dips/src/metrics.rs create mode 100644 crates/dips/src/trusted_signers.rs create mode 100644 crates/query/graphql/agreement_manager_roles.query.graphql create mode 100644 crates/query/graphql/indexing_payments.schema.graphql diff --git a/Cargo.lock b/Cargo.lock index 699c2e029..4077726f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4339,9 +4339,12 @@ dependencies = [ "futures", "graph-networks-registry", "indexer-monitor", + "indexer-query", "ipfs-api-backend-hyper", + "prometheus 0.13.4", "prost 0.14.3", "rand 0.8.5", + "reqwest 0.12.24", "serde", "serde_json", "serde_yaml", diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index fcb588e76..e94b8777c 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -64,12 +64,13 @@ status_url = "http://graph-node:8000/graphql" [subgraphs.network] # Query URL for the Graph Network subgraph. query_url = "http://example.com/network-subgraph" -# Optional, Auth token will used a "bearer auth" +# Bearer token sent with queries to query_url. Set only if that endpoint needs auth (e.g. a +# hosted/gateway URL); leave unset for an open or self-hosted graph-node. Default unset. # query_auth_token = "super-secret" -# Optional, deployment to look for in the local `graph-node`, if locally indexed. -# Locally indexing the subgraph is recommended. -# NOTE: Use `query_url` or `deployment_id` only +# Optional: index this subgraph on your own graph-node and put its deployment id here -- queries +# then prefer your local node (free) and fall back to query_url only if it is unsynced or errors. +# query_url stays required as that fallback. Indexing locally is recommended. deployment_id = "Qmaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Refreshing interval for the Graph contracts information from the Graph Network # subgraph. @@ -91,6 +92,22 @@ escrow_min_balance_grt_wei = "100000000000000000" # signers, orphaning receipts and enabling free queries. max_signers_per_payer = 0 +# Indexing-payments subgraph, read for the set of accounts allowed to send DIPs +# agreement proposals (the agreement-manager role). Required when [dips] is set. +[subgraphs.indexing_payments] +# Query URL for the indexing-payments subgraph -- a SEPARATE deployment from [subgraphs.network]. +query_url = "http://example.com/indexing-payments-subgraph" +# Required when this section is present (no default). How often to refresh from this subgraph, in +# seconds. Distinct from dips.role_refresh_interval_secs, which governs the role-set pull. +syncing_interval_secs = 60 +# Bearer token sent with queries to query_url. Set only if that endpoint needs auth; leave unset +# for an open or self-hosted graph-node. Default unset. +# query_auth_token = "super-secret" + +# Optional: index this subgraph yourself and set its deployment id -- queries then prefer your +# local node (free) and fall back to query_url if it is unsynced. query_url stays required. +# deployment_id = "Qmbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + [blockchain] # The chain ID of the network that the graph network is running on chain_id = 1337 @@ -110,9 +127,11 @@ url_prefix = "/" # Serve the network subgraph on `common.server.host_and_port`/network serve_network_subgraph = false #### OPTIONAL VALUES #### -## use this to add a layer while serving network/escrow subgraph + +# Bearer token that gates the served network/escrow subgraph endpoint (serve_network_subgraph). # serve_auth_token = "token" -## allow queries using this token + +# Lets anyone presenting this token query for FREE -- no TAP receipt required. Share sparingly. # free_query_auth_token = "i-am-authorized-right?" ipfs_url = "https://api.thegraph.com/ipfs/" # Maximum number of deployments allowed in a single /cost GraphQL batch query. @@ -144,8 +163,9 @@ max_receipt_value_grt = "0.001" # 0.001 GRT. We use strings to prevent rounding # max_amount_willing_to_lose_grt = "0.1" max_amount_willing_to_lose_grt = 20 -# List of Senders that are allowed to spend up to `max_amount_willing_to_lose_grt` -# over the escrow balance +# Senders allowed to spend up to `max_amount_willing_to_lose_grt` beyond their escrow balance. +# This is unsecured credit: if such a sender stops aggregating you may never recover those fees, +# so only list senders you trust to settle. Default empty. trusted_senders = ["0xdeadbeefcafebabedeadbeefcafebabedeadbeef"] @@ -174,32 +194,55 @@ max_receipts_per_request = 10000 0xDDE4cfFd3D9052A9cb618fC05a1Cd02be1f2F467 = "https://tap-aggregator.network.thegraph.com" 0xDD6a6f76eb36B873C1C184e8b9b9e762FE216490 = "https://tap-aggregator-arbitrum-one.graphops.xyz" -# DIPs (Direct Indexer Payments). Payer authorisation is verified -# on-chain at acceptance. GRT prices, not wei. +# DIPs (Direct Indexer Payments). Each proposal's recovered EIP-712 signer is checked against +# the on-chain agreement-manager role set, read off the indexing-payments subgraph at proposal +# time -- not the payer, not on-chain at acceptance. Prices below are GRT, not wei. + +# To enable DIPs you must set: recurring_collector, [subgraphs.indexing_payments] (query_url + +# syncing_interval_secs), and at least one supported_networks entry with a matching price under +# [dips.min_grt_per_30_days]. Everything else here has a default. + +# Failure modes: an empty role set, or a subgraph outage past role_failopen_grace_secs, rejects +# all proposals; if DIPs init fails the service keeps serving queries but runs WITHOUT DIPs +# (check the /dips/info endpoint and the dips_enabled metric). Per-field consequences below. [dips] +# Address/port the DIPs gRPC server binds to, where the dipper sends proposals; needs ingress +# reachable by the dipper. port is a quoted string. Defaults: "0.0.0.0" / "7601". host = "0.0.0.0" port = "7601" -# Networks you explicitly support indexing. -# Proposals from the dipper for you to index networks that are not in the list below are rejected. -# See https://github.com/graphprotocol/networks-registry/blob/main/docs/networks-table.md -# e.g. supported_networks = ["mainnet", "arbitrum-one"] +# Networks you support indexing. REQUIRED to accept anything: list at least one here AND set its +# price under [dips.min_grt_per_30_days]; empty = ALL proposals rejected (not "accept all"). +# Names: https://github.com/graphprotocol/networks-registry (e.g. ["mainnet", "arbitrum-one"]). supported_networks = [] -# Minimum payment you are willing to accept in order to accept indexing agreements -# (base price + entity-based price). Total payment = base price + (entities on sg * entity_rate) +# Minimum payment you require to accept indexing agreements (base + entity-based). Total = +# min_grt_per_30_days[network] + (entities_in_billions * min_grt_per_billion_entities_per_30_days) # # For reference: analysis of subgraphs indexed by the upgrade indexer in Q1 2025 found # the average entity size to be ~0.759 KiB. At this size, 1 billion entities ≈ 0.707 TiB. # Your own observations may differ - adjust pricing accordingly. -min_grt_per_billion_entities_per_30_days = "200" # entity-based component (global) +min_grt_per_billion_entities_per_30_days = "200" # illustrative, NOT the default (default 0) -# RecurringCollector contract address. The EIP-712 verifying contract used to -# recover the signer of incoming agreement proposals. Required when DIPs is enabled. +# RecurringCollector contract address: the EIP-712 verifying contract used to recover each +# proposal's signer, which is then checked against the agreement-manager role set. Also the +# contract rpc_url reads the domain from. Required when DIPs is enabled. recurring_collector = "0xcccccccccccccccccccccccccccccccccccccccc" -# Ethereum JSON-RPC endpoint for fetching the RecurringCollector's EIP-712 -# domain at startup (EIP-5267). When unset, built-in domain constants are used. +# How often (seconds) to re-read the indexing-payments subgraph for which addresses may send you +# agreements. New senders are picked up within seconds anyway; this mainly sets how fast a REVOKED +# sender stops being accepted, so lower it to apply revocations sooner. Must be > 0. Default 86400. +role_refresh_interval_secs = 86400 +# What happens during a subgraph outage. If re-reads keep failing, senders already on the last +# good list stay accepted for this many extra seconds (brand-new senders never are); once it +# elapses, every proposal is rejected until the subgraph recovers. Default 3600 (1 hour). +role_failopen_grace_secs = 3600 +# Safety check on subgraph freshness: if the subgraph has fallen more than this many seconds +# behind the chain, its sender list is treated as untrustworthy and proposals are rejected as +# "try again later" rather than risk stale data. 0 turns the check off. Default 1800 (30 min). +role_subgraph_max_lag_secs = 1800 +# Startup chain RPC for the RecurringCollector's EIP-712 signing domain. Unset uses built-in +# values (correct on standard networks); set on local/dev or upgraded chains, else every proposal fails. # rpc_url = "https://arb1.arbitrum.io/rpc" # Optional safeguard: cap live DIPs agreements (awaiting acceptance or accepted) per @@ -305,4 +348,6 @@ recurring_collector = "0xcccccccccccccccccccccccccccccccccccccccc" # boba = "5" # sepolia = "5" +# Custom or test networks not in the global networks-registry: maps network name to CAIP-2 +# chain id so proposals for them pass the registry check. Default empty. e.g. hardhat = "eip155:1337" [dips.additional_networks] diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 8919132a4..6ccd9dcc1 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -286,6 +286,23 @@ impl Config { ); } + // Validate DIPs settings up front rather than partway through boot. A zero + // refresh interval panics the refresh task (tokio rejects a zero period); the + // collector address and indexing-payments subgraph are both needed to authorise. + if let Some(dips) = &self.dips { + if dips.role_refresh_interval_secs == 0 { + return Err("dips.role_refresh_interval_secs must be greater than 0".to_string()); + } + if dips.recurring_collector.is_none() { + return Err("dips.recurring_collector must be set when DIPs is enabled".to_string()); + } + if self.subgraphs.indexing_payments.is_none() { + return Err( + "subgraphs.indexing_payments must be set when DIPs is enabled".to_string(), + ); + } + } + // Warn about auth tokens over cleartext HTTP (TRST-L-3) // This is a security risk as tokens can be intercepted Self::warn_if_token_over_http( @@ -473,6 +490,10 @@ impl MetricsConfig { #[allow(deprecated)] // Allow using deprecated EscrowSubgraphConfig type pub struct SubgraphsConfig { pub network: NetworkSubgraphConfig, + /// Indexing-payments subgraph, read for the DIPs agreement-manager role set. + /// Required when DIPs is enabled. + #[serde(default)] + pub indexing_payments: Option, #[deprecated(note = "V2 escrow accounts are in the network subgraph; this field is ignored.")] #[serde(default)] pub escrow: Option, @@ -661,11 +682,9 @@ fn default_allocation_reconciliation_interval_secs() -> Duration { Duration::from_secs(300) } -/// DIPs configuration. -/// -/// Validates RCA proposals (signature, IPFS manifest, network, pricing) -/// before storing. The indexer agent queries pending proposals from the -/// database and decides on-chain acceptance. +/// DIPs configuration. Authorises proposal senders -- recovers each agreement's EIP-712 signer +/// and requires it to hold the on-chain agreement-manager role (from the indexing-payments +/// subgraph) -- then validates accepted proposals before storing them for the indexer-agent. #[derive(Debug, Deserialize)] #[serde(default)] #[cfg_attr(test, derive(PartialEq))] @@ -682,6 +701,14 @@ pub struct DipsConfig { /// RecurringCollector address: the EIP-712 verifying contract used to recover /// the signer of incoming RCA proposals. Required when DIPs is enabled. pub recurring_collector: Option
, + /// How often to re-pull the agreement-manager role set from the subgraph. + pub role_refresh_interval_secs: u64, + /// Extra time past the refresh interval that a cached role set stays trusted + /// while refreshes fail, before the gate fails closed (the fail-open window). + pub role_failopen_grace_secs: u64, + /// Max seconds the role subgraph head may lag wall-clock before its data is + /// treated as unreliable. 0 disables the check. + pub role_subgraph_max_lag_secs: u64, /// Ethereum JSON-RPC endpoint used to fetch the EIP-712 domain from the /// RecurringCollector at startup (EIP-5267). Unset: built-in domain constants. pub rpc_url: Option, @@ -701,6 +728,9 @@ impl Default for DipsConfig { min_grt_per_billion_entities_per_30_days: GRT::ZERO, additional_networks: BTreeMap::new(), recurring_collector: None, + role_refresh_interval_secs: 86_400, + role_failopen_grace_secs: 3_600, + role_subgraph_max_lag_secs: 1_800, rpc_url: None, max_new_agreements_per_24h: None, } @@ -782,6 +812,14 @@ mod tests { recurring_collector: Some(address!("cccccccccccccccccccccccccccccccccccccccc")), ..Default::default() }); + max_config.subgraphs.indexing_payments = Some(crate::SubgraphConfig { + query_url: "http://example.com/indexing-payments-subgraph" + .parse() + .unwrap(), + query_auth_token: None, + deployment_id: None, + syncing_interval_secs: std::time::Duration::from_secs(60), + }); let max_config_file: Config = toml::from_str( fs::read_to_string("maximal-config-example.toml") diff --git a/crates/dips/Cargo.toml b/crates/dips/Cargo.toml index b6a2f9ca6..b9d92bd1d 100644 --- a/crates/dips/Cargo.toml +++ b/crates/dips/Cargo.toml @@ -15,14 +15,17 @@ rpc = [ "dep:graph-networks-registry", "dep:serde", "dep:serde_yaml", + "dep:prometheus", ] db = [ "dep:sqlx", "dep:build-info", "dep:indexer-monitor", + "dep:indexer-query", "dep:graph-networks-registry", "dep:serde", "dep:serde_yaml", + "dep:prometheus", ] [dependencies] @@ -35,10 +38,12 @@ tracing.workspace = true bs58 = "0.5" build-info = { workspace = true, optional = true } indexer-monitor = { path = "../monitor", optional = true } +indexer-query = { path = "../query", optional = true } thiserror.workspace = true graph-networks-registry = { workspace = true, optional = true } -serde = { workspace = true, optional = true } +serde = { workspace = true, optional = true, features = ["derive"] } serde_yaml = { version = "0.9", optional = true } +prometheus = { workspace = true, optional = true } # IPFS client dependencies derivative = "2.2.0" @@ -58,7 +63,9 @@ indexer-monitor = { path = "../monitor" } graph-networks-registry.workspace = true build-info.workspace = true rand = "0.8" +reqwest = { workspace = true, features = ["json"] } serde_json.workspace = true +tokio = { workspace = true, features = ["test-util"] } wiremock.workspace = true [build-dependencies] diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index d8ddffd96..9028232c5 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -64,6 +64,8 @@ pub mod database; pub mod eip5267; pub mod inflight; pub mod ipfs; +#[cfg(any(feature = "rpc", feature = "db"))] +pub mod metrics; pub mod price; #[cfg(feature = "rpc")] pub mod proto; @@ -72,6 +74,7 @@ mod registry; #[cfg(feature = "rpc")] pub mod server; pub mod store; +pub mod trusted_signers; use thiserror::Error; use uuid::Uuid; @@ -200,6 +203,10 @@ pub enum DipsError { DeadlineExpired { deadline: u64, now: u64 }, #[error("agreement end time {ends_at} has already passed (current time: {now})")] AgreementExpired { ends_at: u64, now: u64 }, + #[error("sender {signer} is not authorised to send agreement proposals")] + SenderNotTrusted { signer: Address }, + #[error("could not verify sender authorisation: {0}")] + TrustVerificationUnavailable(String), #[error("indexer is at its DIPs agreement capacity ({limit} per 24h)")] CapacityExceeded { limit: u64 }, } @@ -314,6 +321,7 @@ pub async fn validate_and_create_rca( registry, additional_networks, rca_domain, + trusted_signers, max_new_agreements_per_24h, } = ctx.as_ref(); @@ -321,11 +329,11 @@ pub async fn validate_and_create_rca( let signed_rca = SignedRecurringCollectionAgreement::abi_decode(rca_bytes.as_ref()) .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; - // Authenticate the sender before acting on the proposal: recover the EIP-712 - // signer. PR B gates this address against the on-chain agreement-manager role - // set; for now any cryptographically valid signature passes. + // Authenticate then authorize the sender: recover the EIP-712 signer, then + // require it to hold the on-chain agreement-manager role before doing any work. let signer = signed_rca.recover_signer(rca_domain)?; tracing::debug!(%signer, "recovered RCA signer"); + trusted_signers.verify_trusted(signer).await?; // Validate service provider signed_rca.validate(expected_service_provider)?; @@ -496,6 +504,7 @@ mod test { rca_eip712_domain, server::DipsServerContext, store::{FailingRcaStore, InMemoryRcaStore, RcaStore}, + trusted_signers::{StaticTrustedSigners, TrustedSignerSource}, AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, RecurringCollectionAgreement, SignedRecurringCollectionAgreement, }; @@ -518,6 +527,12 @@ mod test { rca_eip712_domain(1337, Address::repeat_byte(0xCC)) } + fn trusted_signers_for_test() -> Arc { + Arc::new(StaticTrustedSigners(HashSet::from([ + test_signer().address() + ]))) + } + fn create_test_context() -> Arc { Arc::new(DipsServerContext { rca_store: Arc::new(InMemoryRcaStore::default()), @@ -530,10 +545,43 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), max_new_agreements_per_24h: None, }) } + #[tokio::test] + async fn test_validate_and_create_rca_untrusted_signer_rejected() { + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + // Trusted set excludes the test signer, so a valid signature is rejected. + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + trusted_signers: Arc::new(StaticTrustedSigners::default()), + max_new_agreements_per_24h: None, + }); + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(matches!(result, Err(DipsError::SenderNotTrusted { .. }))); + } + /// Sign an RCA with the fixed test key over the test domain and ABI-encode /// the wrapper, producing the wire bytes `validate_and_create_rca` decodes /// and recovers the signer from. @@ -877,6 +925,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), max_new_agreements_per_24h: None, }); @@ -1052,6 +1101,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), max_new_agreements_per_24h: None, }); @@ -1088,6 +1138,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), max_new_agreements_per_24h: None, }); @@ -1124,6 +1175,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), max_new_agreements_per_24h: None, }); @@ -1160,6 +1212,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), max_new_agreements_per_24h: None, }); @@ -1263,6 +1316,7 @@ mod test { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), max_new_agreements_per_24h: max, }) } diff --git a/crates/dips/src/metrics.rs b/crates/dips/src/metrics.rs new file mode 100644 index 000000000..026eecc75 --- /dev/null +++ b/crates/dips/src/metrics.rs @@ -0,0 +1,40 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! Prometheus metrics for the DIPs proposal path. Registered in the global +//! registry, so the indexer-service /metrics endpoint exposes them. + +use std::sync::LazyLock; + +use prometheus::{register_counter_vec, register_int_gauge, CounterVec, IntGauge}; + +/// Incoming agreement proposals by outcome: `accepted`, `untrusted` (signer is +/// not a role holder), `transient` (couldn't verify -- sender should retry), or +/// `rejected` (other validation failure). +pub static PROPOSAL_OUTCOMES: LazyLock = LazyLock::new(|| { + register_counter_vec!( + "dips_proposal_outcomes_total", + "Incoming DIPs agreement proposals by outcome", + &["outcome"] + ) + .unwrap() +}); + +/// Agreement-manager role holders in the last successful subgraph fetch. +pub static ROLE_SET_SIZE: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "dips_agreement_manager_role_holders", + "Agreement-manager role holders in the last successful subgraph fetch" + ) + .unwrap() +}); + +/// Unix time of the last successful role-set fetch. Alert on `time() - this` to +/// catch a refresh that has stalled. +pub static ROLE_LAST_REFRESH_TIMESTAMP: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "dips_agreement_manager_role_last_refresh_timestamp", + "Unix time of the last successful agreement-manager role fetch" + ) + .unwrap() +}); diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 384e55c76..28872040a 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -58,6 +58,7 @@ use crate::{ SubmitAgreementProposalRequest, SubmitAgreementProposalResponse, }, store::RcaStore, + trusted_signers::TrustedSignerSource, DipsError, }; @@ -81,6 +82,8 @@ pub struct DipsServerContext { pub additional_networks: Arc>, /// EIP-712 domain for recovering the RCA signer (RecurringCollector). pub rca_domain: Eip712Domain, + /// Authorises the recovered signer against the on-chain agreement-manager role. + pub trusted_signers: Arc, /// Max live DIPs agreements (pending or accepted) per rolling 24h window. None disables the cap. pub max_new_agreements_per_24h: Option, } @@ -123,9 +126,12 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { DipsError::AbiDecoding(_) | DipsError::InvalidSubgraphManifest(_) | DipsError::InvalidRca(_) => RejectReason::Unspecified, - // A store failure means the proposal was valid but the indexer couldn't persist - // it -- tell dipper this is transient so it retries rather than giving up. - DipsError::UnknownError(_) => RejectReason::IndexerUnavailable, + DipsError::SenderNotTrusted { .. } => RejectReason::SenderNotTrusted, + // A store failure or an unverifiable role set means a valid proposal the + // indexer couldn't act on -- tell dipper it's transient so it retries. + DipsError::TrustVerificationUnavailable(_) | DipsError::UnknownError(_) => { + RejectReason::IndexerUnavailable + } } } @@ -138,6 +144,16 @@ fn reject_detail_from_error(err: &DipsError) -> String { } } +/// Coarse `outcome` label for the proposal metric: an untrusted signer, a +/// transient failure the sender should retry, or some other validation rejection. +fn outcome_label(err: &DipsError) -> &'static str { + match err { + DipsError::SenderNotTrusted { .. } => "untrusted", + DipsError::TrustVerificationUnavailable(_) | DipsError::UnknownError(_) => "transient", + _ => "rejected", + } +} + #[async_trait] impl IndexerDipsService for DipsServer { /// Submit an RCA proposal. @@ -188,6 +204,9 @@ impl IndexerDipsService for DipsServer { .await { Ok(agreement_id) => { + crate::metrics::PROPOSAL_OUTCOMES + .with_label_values(&["accepted"]) + .inc(); tracing::info!(%agreement_id, "RCA accepted"); Ok(Response::new(SubmitAgreementProposalResponse { outcome: Some(Outcome::Accepted(Accepted {})), @@ -195,6 +214,9 @@ impl IndexerDipsService for DipsServer { } Err(e) => { let reject_reason = reject_reason_from_error(&e); + crate::metrics::PROPOSAL_OUTCOMES + .with_label_values(&[outcome_label(&e)]) + .inc(); tracing::info!( error = %e, reason = ?reject_reason, @@ -226,8 +248,11 @@ mod tests { impl DipsServerContext { pub fn for_testing() -> Arc { use std::collections::{BTreeMap, HashSet}; + use thegraph_core::alloy::primitives::U256; + use crate::trusted_signers::StaticTrustedSigners; + Arc::new(Self { rca_store: Arc::new(InMemoryRcaStore::default()), ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), @@ -239,6 +264,7 @@ mod tests { registry: Arc::new(crate::registry::test_registry()), additional_networks: Arc::new(BTreeMap::new()), rca_domain: crate::rca_eip712_domain(1337, Address::repeat_byte(0xCC)), + trusted_signers: Arc::new(StaticTrustedSigners::default()), max_new_agreements_per_24h: None, }) } @@ -504,6 +530,28 @@ mod tests { assert_eq!(reason, RejectReason::IndexerUnavailable); } + #[test] + fn test_reject_reason_sender_not_trusted() { + let err = DipsError::SenderNotTrusted { + signer: Address::repeat_byte(0x55), + }; + + assert_eq!( + super::reject_reason_from_error(&err), + RejectReason::SenderNotTrusted + ); + } + + #[test] + fn test_reject_reason_trust_unverifiable_is_transient() { + let err = DipsError::TrustVerificationUnavailable("role subgraph down".to_string()); + + assert_eq!( + super::reject_reason_from_error(&err), + RejectReason::IndexerUnavailable + ); + } + #[test] fn test_reject_detail_sanitizes_store_errors() { // Arrange: the raw database error mentions schema internals. diff --git a/crates/dips/src/trusted_signers.rs b/crates/dips/src/trusted_signers.rs new file mode 100644 index 000000000..86a7f3a61 --- /dev/null +++ b/crates/dips/src/trusted_signers.rs @@ -0,0 +1,861 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! Trust gate for incoming agreement proposals: require the recovered EIP-712 +//! signer to hold the on-chain `AGREEMENT_MANAGER_ROLE` (read from the +//! indexing-payments-subgraph), gating on the signer, never the spoofable payer. + +use async_trait::async_trait; +use thegraph_core::alloy::primitives::Address; + +use crate::DipsError; + +/// Source of truth for which signers may send agreement proposals. +#[async_trait] +pub trait TrustedSignerSource: std::fmt::Debug + Send + Sync { + /// Confirm `signer` may send agreement proposals. `Ok` = trusted; + /// `SenderNotTrusted` = definitively not a role holder; any other error means + /// the check failed and the caller should treat it as transient and retry. + async fn verify_trusted(&self, signer: Address) -> Result<(), DipsError>; +} + +/// A fixed set of trusted signers. Used in tests and as a simple in-memory +/// source; production uses [`SubgraphTrustedSigners`]. +#[derive(Debug, Clone, Default)] +pub struct StaticTrustedSigners(pub std::collections::HashSet
); + +#[async_trait] +impl TrustedSignerSource for StaticTrustedSigners { + async fn verify_trusted(&self, signer: Address) -> Result<(), DipsError> { + if self.0.contains(&signer) { + Ok(()) + } else { + Err(DipsError::SenderNotTrusted { signer }) + } + } +} + +#[cfg(feature = "db")] +pub use subgraph::SubgraphTrustedSigners; + +#[cfg(feature = "db")] +mod subgraph { + use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, RwLock}, + time::Duration, + }; + + use async_trait::async_trait; + use indexer_monitor::SubgraphClient; + use indexer_query::agreement_manager_roles::{self, AgreementManagerRolesQuery}; + use thegraph_core::alloy::primitives::{keccak256, Address}; + use tokio::{sync::Mutex, time::Instant}; + + use super::TrustedSignerSource; + use crate::DipsError; + + /// Page size for the holder query. The manager set is tiny in practice, so one + /// page almost always suffices; the fetch still pages so a larger set can't be + /// silently truncated. + const ROLE_PAGE_SIZE: i64 = 1000; + + /// After a failed on-demand fetch, don't re-hit the subgraph again for this + /// long, so a burst of unknown signers can't turn into a fetch storm. + const REFRESH_DEBOUNCE: Duration = Duration::from_secs(5); + + /// Don't refetch the holder set on demand more than once per this interval: a + /// recent successful fetch is authoritative, so a burst of distinct unknown + /// signers is answered from it instead of triggering one refetch each. + const ON_DEMAND_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(5); + + /// How long a "not a role holder" answer is reused from memory before that + /// signer is re-checked. Caps how often a repeated unknown signer drives a + /// fetch, and bounds how long a later-granted signer waits to be recognised. + const REJECTED_SIGNER_TTL: Duration = Duration::from_secs(3600); + + /// Hard cap on the negative cache so a flood of distinct unknown signers + /// can't grow it without bound. + const MAX_REJECTED_SIGNERS: usize = 100_000; + + #[derive(Default)] + struct RoleCache { + holders: HashSet
, + /// When the holder set was last fetched successfully. + last_success: Option, + /// When a fetch most recently failed (for debouncing retries). + last_failed_attempt: Option, + /// Signers recently confirmed *not* to hold the role, with the time of + /// that confirmation. A repeat of the same signer is rejected from here + /// until [`REJECTED_SIGNER_TTL`] passes; bounded by [`MAX_REJECTED_SIGNERS`]. + rejected: HashMap, + } + + /// Caches the AGREEMENT_MANAGER_ROLE holder set, refreshing on a timer and on + /// demand for unknown signers. A cache hit within the bounded window is + /// answered from memory (fail-open through outages); past it, fails closed. + pub struct SubgraphTrustedSigners { + subgraph: &'static SubgraphClient, + cache: Arc>, + refresh_lock: Arc>, + /// Oldest a successful fetch may be before a cache hit stops being + /// trusted: the bounded fail-open window. Set to the refresh interval + /// plus a grace period so a healthy deploy never trips it. + max_stale: Duration, + /// Largest gap between the subgraph head and wall-clock that is still + /// trusted; past it the data is treated as unreliable. 0 disables it. + max_chain_lag: Duration, + /// Holder-query page size. Production uses [`ROLE_PAGE_SIZE`]; tests set a + /// small value to exercise pagination. + page_size: i64, + } + + impl std::fmt::Debug for SubgraphTrustedSigners { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubgraphTrustedSigners") + .field("max_stale", &self.max_stale) + .field("max_chain_lag", &self.max_chain_lag) + .finish_non_exhaustive() + } + } + + impl SubgraphTrustedSigners { + /// Construct the source without fetching or starting the refresh task. + fn new( + subgraph: &'static SubgraphClient, + max_stale: Duration, + max_chain_lag: Duration, + ) -> Arc { + Self::new_with_page_size(subgraph, max_stale, max_chain_lag, ROLE_PAGE_SIZE) + } + + fn new_with_page_size( + subgraph: &'static SubgraphClient, + max_stale: Duration, + max_chain_lag: Duration, + page_size: i64, + ) -> Arc { + Arc::new(Self { + subgraph, + cache: Arc::new(RwLock::new(RoleCache::default())), + refresh_lock: Arc::new(Mutex::new(())), + max_stale, + max_chain_lag, + page_size, + }) + } + + /// Build the source, seed it once (best-effort), and start the periodic + /// refresh. A failed initial fetch is logged, not fatal: proposals are + /// rejected as transient until the first successful fetch. + pub async fn spawn( + subgraph: &'static SubgraphClient, + refresh_interval: Duration, + failopen_grace: Duration, + max_chain_lag: Duration, + ) -> Arc { + let this = Self::new(subgraph, refresh_interval + failopen_grace, max_chain_lag); + + if let Err(e) = this.refresh().await { + tracing::warn!( + error = %e, + "initial agreement-manager role fetch failed; DIPs proposals \ + will be rejected as transient until it succeeds" + ); + } + + let bg = this.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(refresh_interval); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + tick.tick().await; // the immediate first tick; already seeded above + loop { + tick.tick().await; + bg.refresh_with_retry().await; + } + }); + + this + } + + /// Refresh on the periodic schedule, retrying a failed fetch on a short + /// backoff rather than waiting a whole refresh interval. A brief subgraph + /// blip self-heals in seconds, so the fail-open window keeps its meaning. + async fn refresh_with_retry(&self) { + // Backoffs sit above REFRESH_DEBOUNCE so each retry actually re-fetches. + const BACKOFFS: [Duration; 4] = [ + Duration::from_secs(10), + Duration::from_secs(30), + Duration::from_secs(60), + Duration::from_secs(120), + ]; + let mut attempt = 0; + loop { + match self.refresh().await { + Ok(()) => return, + Err(e) if attempt < BACKOFFS.len() => { + let backoff = BACKOFFS[attempt]; + tracing::warn!( + error = %e, + ?backoff, + "periodic agreement-manager role refresh failed; retrying" + ); + tokio::time::sleep(backoff).await; + attempt += 1; + } + Err(e) => { + tracing::warn!( + error = %e, + "agreement-manager role refresh still failing; \ + waiting for the next scheduled refresh" + ); + return; + } + } + } + } + + fn is_fresh(&self, last_success: Option) -> bool { + last_success.is_some_and(|t| t.elapsed() < self.max_stale) + } + + /// Record a definitive "not a role holder" so a repeat of the same signer + /// is answered from memory until it ages out. Prunes expired entries and + /// refuses to grow past a hard cap, bounding the map under a flood. + fn note_rejected(&self, signer: Address) { + let mut cache = self.cache.write().unwrap(); + if cache.rejected.len() >= MAX_REJECTED_SIGNERS { + cache + .rejected + .retain(|_, t| t.elapsed() < REJECTED_SIGNER_TTL); + if cache.rejected.len() >= MAX_REJECTED_SIGNERS { + tracing::warn!( + cap = MAX_REJECTED_SIGNERS, + "rejected-signer cache is full; not caching this rejection" + ); + return; + } + } + cache.rejected.insert(signer, Instant::now()); + } + + /// Fetch the whole holder set and swap it into the cache. Single-flighted + /// (concurrent callers coalesce onto one fetch) and debounced after a + /// failure so an outage doesn't trigger a fetch per request. + async fn refresh(&self) -> Result<(), DipsError> { + let started = Instant::now(); + let _guard = self.refresh_lock.lock().await; + + { + let cache = self.cache.read().unwrap(); + // A concurrent refresh already succeeded after we started waiting. + if cache.last_success.is_some_and(|t| t >= started) { + return Ok(()); + } + // We failed very recently; don't hammer the subgraph. + if cache + .last_failed_attempt + .is_some_and(|t| t.elapsed() < REFRESH_DEBOUNCE) + { + return Err(DipsError::TrustVerificationUnavailable( + "agreement-manager role subgraph was unreachable moments ago".to_string(), + )); + } + } + + match self.fetch_holders().await { + Ok(holders) => { + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let mut cache = self.cache.write().unwrap(); + cache.holders = holders; + cache.last_success = Some(Instant::now()); + cache.last_failed_attempt = None; + crate::metrics::ROLE_SET_SIZE.set(cache.holders.len() as i64); + crate::metrics::ROLE_LAST_REFRESH_TIMESTAMP.set(now_unix); + Ok(()) + } + Err(e) => { + self.cache.write().unwrap().last_failed_attempt = Some(Instant::now()); + Err(DipsError::TrustVerificationUnavailable(e.to_string())) + } + } + } + + async fn fetch_holders(&self) -> anyhow::Result> { + // Derive the role id from its name so it can never drift from the + // on-chain AGREEMENT_MANAGER_ROLE constant. + let role = format!("0x{:x}", keccak256(b"AGREEMENT_MANAGER_ROLE")); + + let mut holders = HashSet::new(); + let mut last = String::new(); + let mut block_hash: Option = None; + let mut head_timestamp: Option = None; + let mut first_page = true; + + loop { + let data = self + .subgraph + .query::(agreement_manager_roles::Variables { + role: role.clone(), + first: self.page_size, + last: last.clone(), + block: block_hash.clone().map(|hash| { + agreement_manager_roles::Block_height { + hash: Some(hash), + number: None, + number_gte: None, + } + }), + }) + .await + .map_err(|e| anyhow::anyhow!("role-holder query failed: {e}"))?; + + // The first page fixes the head timestamp (for the staleness guard) + // and the block that later pages pin to for a consistent read. + if first_page { + first_page = false; + if let Some(meta) = data.meta.as_ref() { + head_timestamp = meta.block.timestamp; + block_hash = meta.block.hash.clone(); + } + } + + let page_len = data.role_assignments.len(); + for row in data.role_assignments { + last = row.id; + let account = row.account.parse::
().map_err(|e| { + anyhow::anyhow!("invalid role-holder account {:?}: {e}", row.account) + })?; + holders.insert(account); + } + if (page_len as i64) < self.page_size { + break; + } + } + + // A badly-lagging subgraph may be missing recent grants or revokes, + // so treat it as unreliable rather than trusting a stale role set. + let max_lag = self.max_chain_lag.as_secs() as i64; + if max_lag > 0 { + let timestamp = head_timestamp.ok_or_else(|| { + anyhow::anyhow!( + "indexing-payments subgraph returned no block timestamp; \ + cannot verify agreement-manager role freshness" + ) + })?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| anyhow::anyhow!("system time before unix epoch: {e}"))? + .as_secs() as i64; + let lag = now - timestamp; + if lag > max_lag { + return Err(anyhow::anyhow!( + "indexing-payments subgraph is {lag}s behind wall-clock (max {max_lag}s); \ + treating agreement-manager role data as unreliable" + )); + } + } + + if holders.is_empty() { + tracing::warn!( + "agreement-manager role set is empty; all DIPs proposals will be \ + rejected as untrusted" + ); + } + Ok(holders) + } + } + + /// What `verify_trusted` should do once it holds the freshest cache state. + /// Pulled out as a pure function so every branch -- including the fail-open + /// admit, which otherwise only fires on a rare race -- is unit-testable. + enum Decision { + Trusted, + Untrusted, + FailOpen, + Transient(DipsError), + } + + /// Decide from a refresh result plus the post-refresh cache state: `present` + /// is whether the signer is in the holder set, `fresh` whether that set is + /// still inside the fail-open window. + fn decide(refreshed: Result<(), DipsError>, present: bool, fresh: bool) -> Decision { + match refreshed { + Ok(()) if present => Decision::Trusted, + Ok(()) => Decision::Untrusted, + Err(_) if present && fresh => Decision::FailOpen, + Err(transient) => Decision::Transient(transient), + } + } + + #[async_trait] + impl TrustedSignerSource for SubgraphTrustedSigners { + async fn verify_trusted(&self, signer: Address) -> Result<(), DipsError> { + // Fast path, answered from memory: a known holder inside the window + // passes; a signer recently confirmed *not* a holder is rejected, so a + // repeated unknown signer can't drive a fetch until its entry ages out. + { + let cache = self.cache.read().unwrap(); + if cache.holders.contains(&signer) && self.is_fresh(cache.last_success) { + return Ok(()); + } + if cache + .rejected + .get(&signer) + .is_some_and(|t| t.elapsed() < REJECTED_SIGNER_TTL) + { + return Err(DipsError::SenderNotTrusted { signer }); + } + } + + // A new or stale-cache signer: refresh on demand to catch a just-granted + // manager, but skip the refetch when the last success is recent and still + // fresh -- bounds a distinct-unknown-signer flood to one fetch per window. + let recently_refreshed = { + let cache = self.cache.read().unwrap(); + cache.last_success.is_some_and(|t| { + t.elapsed() < ON_DEMAND_REFRESH_MIN_INTERVAL && self.is_fresh(Some(t)) + }) + }; + let refreshed = if recently_refreshed { + Ok(()) + } else { + self.refresh().await + }; + + let (present, fresh) = { + let cache = self.cache.read().unwrap(); + ( + cache.holders.contains(&signer), + self.is_fresh(cache.last_success), + ) + }; + + match decide(refreshed, present, fresh) { + // A newly-granted signer clears any stale negative entry. + Decision::Trusted => { + self.cache.write().unwrap().rejected.remove(&signer); + Ok(()) + } + Decision::Untrusted => { + self.note_rejected(signer); + Err(DipsError::SenderNotTrusted { signer }) + } + Decision::FailOpen => { + tracing::warn!( + %signer, + "agreement-manager role refresh failed; admitting known \ + signer from cached set within fail-open window" + ); + Ok(()) + } + // Couldn't verify and can't fail open; report transient and record + // no negative entry, since this isn't a definitive "no". + Decision::Transient(e) => Err(e), + } + } + } + + #[cfg(test)] + mod test { + use std::time::Duration; + + use indexer_monitor::{DeploymentDetails, SubgraphClient}; + use serde_json::json; + use thegraph_core::alloy::primitives::{address, Address}; + use wiremock::{ + matchers::{body_partial_json, method}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + + const HOLDER: Address = address!("f39fd6e51aad88f6f4ce6ab8827279cfffb92266"); + const STRANGER: Address = address!("0000000000000000000000000000000000000099"); + + fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + } + + /// A graphql_client response body for the role query: one `roleAssignments` + /// row per account (with a synthetic id cursor) plus the `_meta` head. + fn holders_body(accounts: &[Address], timestamp: i64) -> serde_json::Value { + roles_page(accounts, 1, timestamp) + } + + /// Like [`holders_body`] but with id cursors starting at `first_id`, so a + /// test can stitch several pages together (ids must strictly increase). + fn roles_page(accounts: &[Address], first_id: usize, timestamp: i64) -> serde_json::Value { + json!({ + "data": { + "meta": { "block": { "number": 1, "hash": null, "timestamp": timestamp } }, + "roleAssignments": accounts + .iter() + .enumerate() + .map(|(i, a)| json!({ + "id": format!("0x{:064x}", first_id + i), + "account": a, + })) + .collect::>(), + } + }) + } + + async fn leak_client(server: &MockServer) -> &'static SubgraphClient { + Box::leak(Box::new( + SubgraphClient::new( + reqwest::Client::new(), + None, + DeploymentDetails::for_query_url(&server.uri()).unwrap(), + ) + .await, + )) + } + + async fn client_always( + template: ResponseTemplate, + ) -> (&'static SubgraphClient, MockServer) { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(template) + .mount(&server) + .await; + let client = leak_client(&server).await; + (client, server) + } + + #[tokio::test] + async fn known_signer_passes() { + let (client, _server) = client_always( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + src.refresh().await.unwrap(); + + assert!(src.verify_trusted(HOLDER).await.is_ok()); + } + + #[tokio::test] + async fn unknown_signer_rejected_after_refresh() { + let (client, _server) = client_always( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .await; + // Cold cache: the verify triggers an on-demand refresh, then rejects. + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + assert!(matches!( + src.verify_trusted(STRANGER).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + } + + #[tokio::test] + async fn empty_role_set_rejects() { + let (client, _server) = client_always( + ResponseTemplate::new(200).set_body_json(holders_body(&[], now_secs())), + ) + .await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + assert!(matches!( + src.verify_trusted(HOLDER).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + } + + #[tokio::test] + async fn unreachable_subgraph_is_transient() { + let (client, _server) = client_always(ResponseTemplate::new(500)).await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + assert!(matches!( + src.verify_trusted(HOLDER).await, + Err(DipsError::TrustVerificationUnavailable(_)) + )); + } + + #[tokio::test] + async fn stale_subgraph_head_is_transient() { + // Head far behind wall-clock with a 60s tolerance: treated as unreliable. + let (client, _server) = + client_always(ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], 1))) + .await; + let src = SubgraphTrustedSigners::new( + client, + Duration::from_secs(60), + Duration::from_secs(60), + ); + + assert!(matches!( + src.verify_trusted(HOLDER).await, + Err(DipsError::TrustVerificationUnavailable(_)) + )); + } + + #[tokio::test] + async fn known_signer_fails_closed_past_window() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + + // Tiny window so the cache ages out within the test. Chain-lag check off. + let src = + SubgraphTrustedSigners::new(client, Duration::from_millis(400), Duration::ZERO); + src.refresh().await.unwrap(); + assert!( + src.verify_trusted(HOLDER).await.is_ok(), + "a known holder passes from cache while the window is fresh" + ); + + // The subgraph goes down and the cache ages past the window. + server.reset().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + tokio::time::sleep(Duration::from_millis(600)).await; + + assert!( + matches!( + src.verify_trusted(HOLDER).await, + Err(DipsError::TrustVerificationUnavailable(_)) + ), + "past the window with the subgraph down, even a known holder fails closed" + ); + } + + #[tokio::test] + async fn paginates_through_all_holders() { + // Page size 2 against three holders forces a second page: page 1 is + // full (ids 1-2), page 2 partial (id 3), selected by the id_gt cursor + // carried in the request body. All three must end up trusted. + const A: Address = address!("0000000000000000000000000000000000000001"); + const B: Address = address!("0000000000000000000000000000000000000002"); + const C: Address = address!("0000000000000000000000000000000000000003"); + + let server = MockServer::start().await; + // First page: empty cursor. + Mock::given(method("POST")) + .and(body_partial_json(json!({ "variables": { "last": "" } }))) + .respond_with(ResponseTemplate::new(200).set_body_json(roles_page( + &[A, B], + 1, + now_secs(), + ))) + .mount(&server) + .await; + // Second page: cursor is page 1's last id. + let page1_last = format!("0x{:064x}", 2); + Mock::given(method("POST")) + .and(body_partial_json( + json!({ "variables": { "last": page1_last } }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(roles_page( + &[C], + 3, + now_secs(), + ))) + .mount(&server) + .await; + let client = leak_client(&server).await; + + let src = SubgraphTrustedSigners::new_with_page_size( + client, + Duration::from_secs(60), + Duration::ZERO, + 2, + ); + src.refresh().await.unwrap(); + + for holder in [A, B, C] { + assert!( + src.verify_trusted(holder).await.is_ok(), + "holder {holder} from a later page should be trusted" + ); + } + } + + #[tokio::test] + async fn repeat_unknown_signer_is_not_refetched() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + // First lookup: cold cache, one on-demand fetch, definitive reject. + assert!(matches!( + src.verify_trusted(STRANGER).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + // Second lookup of the same signer: served from the negative cache. + assert!(matches!( + src.verify_trusted(STRANGER).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + + let posts = server.received_requests().await.unwrap().len(); + assert_eq!(posts, 1, "a repeat of a known-bad signer must not re-query"); + } + + #[tokio::test] + async fn concurrent_unknown_signers_coalesce_one_fetch() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(holders_body(&[HOLDER], now_secs())) + .set_delay(Duration::from_millis(200)), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + // Two distinct unknown signers arriving together coalesce onto one + // fetch via the single-flight refresh lock. + let a = address!("00000000000000000000000000000000000000aa"); + let b = address!("00000000000000000000000000000000000000bb"); + let (ra, rb) = tokio::join!(src.verify_trusted(a), src.verify_trusted(b)); + assert!(matches!(ra, Err(DipsError::SenderNotTrusted { .. }))); + assert!(matches!(rb, Err(DipsError::SenderNotTrusted { .. }))); + + let posts = server.received_requests().await.unwrap().len(); + assert_eq!( + posts, 1, + "concurrent unknown signers should share a single fetch" + ); + } + + #[test] + fn decide_truth_table() { + let err = || DipsError::TrustVerificationUnavailable("down".to_string()); + + assert!(matches!(decide(Ok(()), true, true), Decision::Trusted)); + // A successful fetch is authoritative, so presence alone admits. + assert!(matches!(decide(Ok(()), true, false), Decision::Trusted)); + assert!(matches!(decide(Ok(()), false, true), Decision::Untrusted)); + // The fail-open admit: refresh failed but a known holder is still fresh. + assert!(matches!(decide(Err(err()), true, true), Decision::FailOpen)); + assert!(matches!( + decide(Err(err()), true, false), + Decision::Transient(_) + )); + assert!(matches!( + decide(Err(err()), false, true), + Decision::Transient(_) + )); + assert!(matches!( + decide(Err(err()), false, false), + Decision::Transient(_) + )); + } + + #[tokio::test] + async fn known_holder_served_from_cache_during_outage() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + src.refresh().await.unwrap(); + + // The subgraph goes down; within the window a known holder still passes. + server.reset().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + assert!( + src.verify_trusted(HOLDER).await.is_ok(), + "a known holder is admitted from cache while the subgraph is down" + ); + } + + #[tokio::test] + async fn fresh_head_within_lag_tolerance_passes() { + let (client, _server) = client_always( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .await; + // Non-zero lag tolerance with a head at ~now: lag is inside tolerance, + // so the success side of the chain-lag guard trusts the fetch. + let src = SubgraphTrustedSigners::new( + client, + Duration::from_secs(60), + Duration::from_secs(1800), + ); + assert!(src.verify_trusted(HOLDER).await.is_ok()); + } + + #[tokio::test] + async fn on_demand_refetch_is_rate_limited() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + // Window far larger than the 5s min-interval so the freshness gate never + // trips here; lag check off. + let src = + SubgraphTrustedSigners::new(client, Duration::from_secs(3600), Duration::ZERO); + + // Freeze time so the on-demand min-interval is exercised deterministically. + tokio::time::pause(); + + // First unknown signer: cold cache, one real fetch, then rejected. + assert!(matches!( + src.verify_trusted(Address::repeat_byte(0xa0)).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + // More distinct unknowns inside the window: answered from the recent fetch. + for b in [0xa1u8, 0xa2, 0xa3] { + assert!(matches!( + src.verify_trusted(Address::repeat_byte(b)).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + } + assert_eq!( + server.received_requests().await.unwrap().len(), + 1, + "distinct unknown signers within the window must share one fetch" + ); + + // Past the window, the next unknown signer triggers exactly one more fetch. + tokio::time::advance(Duration::from_secs(6)).await; + assert!(matches!( + src.verify_trusted(Address::repeat_byte(0xa4)).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + assert_eq!( + server.received_requests().await.unwrap().len(), + 2, + "an unknown signer past the window triggers one more fetch" + ); + } + } +} diff --git a/crates/query/graphql/agreement_manager_roles.query.graphql b/crates/query/graphql/agreement_manager_roles.query.graphql new file mode 100644 index 000000000..65dfcac46 --- /dev/null +++ b/crates/query/graphql/agreement_manager_roles.query.graphql @@ -0,0 +1,30 @@ +# Active AGREEMENT_MANAGER_ROLE holders from the indexing-payments subgraph: the +# signers the indexer trusts to send agreement proposals. Cursor-paginated by id; +# $block pins later pages to the first page's block for a consistent read. + +query AgreementManagerRolesQuery( + $role: Bytes! + $first: Int! + $last: ID! + $block: Block_height +) { + meta: _meta(block: $block) { + block { + number + hash + timestamp + } + } + roleAssignments( + block: $block + orderBy: id + orderDirection: asc + first: $first + # active: true is load-bearing -- a revoke flags the row rather than + # deleting it, and several roles are indexed, so both filters are needed. + where: { id_gt: $last, role: $role, active: true } + ) { + id + account + } +} diff --git a/crates/query/graphql/indexing_payments.schema.graphql b/crates/query/graphql/indexing_payments.schema.graphql new file mode 100644 index 000000000..2e5f7fac8 --- /dev/null +++ b/crates/query/graphql/indexing_payments.schema.graphql @@ -0,0 +1,64 @@ +# Subset of the indexing-payments subgraph's graph-node API schema, holding only +# the RoleAssignment and _meta fields the agreement-manager-role query reads. +# graphql_client uses it for compile-time type generation; grow it with the query. + +scalar Bytes + +type _Block_ { + hash: Bytes + number: Int! + timestamp: Int +} + +type _Meta_ { + block: _Block_! + deployment: String! + hasIndexingErrors: Boolean! +} + +input Block_height { + hash: Bytes + number: Int + number_gte: Int +} + +enum OrderDirection { + asc + desc +} + +type RoleAssignment { + id: ID! + role: Bytes! + account: Bytes! + active: Boolean! +} + +enum RoleAssignment_orderBy { + id + role + account + active +} + +input RoleAssignment_filter { + id_gt: ID + role: Bytes + active: Boolean +} + +type Query { + _meta(block: Block_height): _Meta_ + roleAssignments( + skip: Int + first: Int + orderBy: RoleAssignment_orderBy + orderDirection: OrderDirection + where: RoleAssignment_filter + block: Block_height + ): [RoleAssignment!]! +} + +schema { + query: Query +} diff --git a/crates/query/src/lib.rs b/crates/query/src/lib.rs index 4c1888ee5..4deec7b89 100644 --- a/crates/query/src/lib.rs +++ b/crates/query/src/lib.rs @@ -190,3 +190,19 @@ pub mod payments_escrow_transactions_redeem { pub use payments_escrow_transactions_redeem_query::*; } + +pub mod agreement_manager_roles { + use graphql_client::GraphQLQuery; + type Bytes = String; + + #[derive(GraphQLQuery)] + #[graphql( + schema_path = "graphql/indexing_payments.schema.graphql", + query_path = "graphql/agreement_manager_roles.query.graphql", + response_derives = "Debug", + variables_derives = "Clone" + )] + pub struct AgreementManagerRolesQuery; + + pub use agreement_manager_roles_query::*; +} diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 3bbbe5309..097a41618 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -20,6 +20,7 @@ use indexer_dips::{ IndexerDipsService, IndexerDipsServiceServer, }, server::{DipsServer, DipsServerContext}, + trusted_signers::{SubgraphTrustedSigners, TrustedSignerSource}, }; use indexer_monitor::{DeploymentDetails, SubgraphClient}; use release::IndexerServiceRelease; @@ -112,6 +113,17 @@ pub async fn run() -> anyhow::Result<()> { ) .await; + // DIPs reads the agreement-manager role set from the indexing-payments + // subgraph; build its client before the router consumes graph_node. + let indexing_payments_subgraph = if config.dips.is_some() { + let subgraph_config = config.subgraphs.indexing_payments.as_ref().ok_or_else(|| { + anyhow!("subgraphs.indexing_payments must be set when DIPs is enabled") + })?; + Some(create_subgraph_client(http_client.clone(), &config.graph_node, subgraph_config).await) + } else { + None + }; + // V2 escrow accounts are in the network subgraph, not a separate escrow_v2 subgraph // Establish Database connection necessary for serving indexer management @@ -180,6 +192,8 @@ pub async fn run() -> anyhow::Result<()> { Some(dips) => match start_dips( dips, blockchain_chain_id, + indexing_payments_subgraph + .expect("indexing_payments client is built whenever DIPs is enabled"), &ipfs_url, database.clone(), indexer_address, @@ -254,6 +268,7 @@ pub async fn run() -> anyhow::Result<()> { async fn start_dips( dips: &DipsConfig, blockchain_chain_id: u64, + indexing_payments_subgraph: &'static SubgraphClient, ipfs_url: &Url, database: sqlx::PgPool, indexer_address: Address, @@ -268,6 +283,9 @@ async fn start_dips( additional_networks, recurring_collector, rpc_url, + role_refresh_interval_secs, + role_failopen_grace_secs, + role_subgraph_max_lag_secs, max_new_agreements_per_24h, } = dips; @@ -357,6 +375,16 @@ async fn start_dips( .div_ceil(entity_divisor), ); + // Authorise proposal senders against the on-chain agreement-manager role set, + // read from the indexing-payments subgraph. + let trusted_signers: Arc = SubgraphTrustedSigners::spawn( + indexing_payments_subgraph, + Duration::from_secs(*role_refresh_interval_secs), + Duration::from_secs(*role_failopen_grace_secs), + Duration::from_secs(*role_subgraph_max_lag_secs), + ) + .await; + // Build server context let ctx = Arc::new(DipsServerContext { rca_store: Arc::new(PsqlRcaStore { pool: database }), @@ -369,6 +397,7 @@ async fn start_dips( registry, additional_networks: Arc::new(additional_networks.clone()), rca_domain, + trusted_signers, max_new_agreements_per_24h: *max_new_agreements_per_24h, }); diff --git a/crates/tap-agent/src/agent.rs b/crates/tap-agent/src/agent.rs index 736a58494..2f9e6fc46 100644 --- a/crates/tap-agent/src/agent.rs +++ b/crates/tap-agent/src/agent.rs @@ -104,7 +104,8 @@ pub async fn start_agent( ref escrow_min_balance_grt_wei, max_signers_per_payer, }, - escrow: _, // Deprecated and ignored + indexing_payments: _, // DIPs only; not used by tap-agent + escrow: _, // Deprecated and ignored }, tap: TapConfig { sender_aggregator_endpoints, From 04c506d6d752af4d754a76423528a346792d4f57 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:03:14 +1200 Subject: [PATCH 24/31] refactor: represent "no signer limit" as an absent value (#1056) * refactor(config): express unlimited signer cap as an absent value The per-payer signer cap used 0 to mean "no limit", overloading a value that reads as "fetch zero signers". It is now an optional positive number: unset means no cap, and 0 is rejected at load. The default (no cap) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): pass None for the no-limit cap in the v2 escrow test The signer-per-payer cap became an optional value; this test still passed 0, which no longer type-checks. Pass None (no cap). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- crates/config/default_values.toml | 1 - crates/config/maximal-config-example.toml | 7 ++-- crates/config/src/config.rs | 16 +++---- crates/monitor/src/escrow_accounts.rs | 51 +++++++++++++---------- crates/service/tests/router_test.rs | 2 +- 5 files changed, 39 insertions(+), 38 deletions(-) diff --git a/crates/config/default_values.toml b/crates/config/default_values.toml index 292858d9d..aa4829598 100644 --- a/crates/config/default_values.toml +++ b/crates/config/default_values.toml @@ -6,7 +6,6 @@ syncing_interval_secs = 60 recently_closed_allocation_buffer_secs = 3600 max_data_staleness_mins = 30 escrow_min_balance_grt_wei = "100000000000000000" -max_signers_per_payer = 0 [service] ipfs_url = "https://api.thegraph.com/ipfs/" diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index e94b8777c..a57e701fd 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -87,10 +87,9 @@ max_data_staleness_mins = 30 # Minimum escrow balance (GRT wei) for V2 escrow queries. Filters dust deposits # to raise the cost of crowding attacks. Default: 0.1 GRT. escrow_min_balance_grt_wei = "100000000000000000" -# Maximum signers to fetch per payer. 0 = no limit (recommended). -# A positive value caps pagination but allows attackers to crowd out legitimate -# signers, orphaning receipts and enabling free queries. -max_signers_per_payer = 0 +# Maximum signers to fetch per payer. Unset = no limit (recommended); a positive +# value caps pagination but lets attackers crowd out legitimate signers. +# max_signers_per_payer = 1000 # Indexing-payments subgraph, read for the set of accounts allowed to send DIPs # agreement proposals (the agreement-manager role). Required when [dips] is set. diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 6ccd9dcc1..5db5d2f0c 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -5,6 +5,7 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, env, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, + num::NonZeroUsize, path::PathBuf, time::Duration, }; @@ -522,12 +523,11 @@ pub struct NetworkSubgraphConfig { #[serde(default = "default_escrow_min_balance_grt_wei")] pub escrow_min_balance_grt_wei: String, - /// Maximum signers to fetch per payer. 0 = no limit (recommended). - /// Setting a positive value caps pagination but allows attackers to crowd out - /// legitimate signers, orphaning receipts and enabling free queries. - /// Default: 0. - #[serde(default = "default_max_signers_per_payer")] - pub max_signers_per_payer: usize, + /// Maximum signers to fetch per payer. Leave unset for no limit (recommended); + /// a positive cap lets attackers crowd out legitimate signers, orphaning + /// receipts and enabling free queries. + #[serde(default)] + pub max_signers_per_payer: Option, } fn default_max_data_staleness_mins() -> u64 { @@ -538,10 +538,6 @@ fn default_escrow_min_balance_grt_wei() -> String { "100000000000000000".to_string() // 0.1 GRT } -fn default_max_signers_per_payer() -> usize { - 0 -} - #[deprecated(note = "V2 escrow accounts are in the network subgraph; escrow config is ignored.")] #[derive(Debug, Deserialize, Default)] #[cfg_attr(test, derive(PartialEq))] diff --git a/crates/monitor/src/escrow_accounts.rs b/crates/monitor/src/escrow_accounts.rs index c3449ea28..77982cd32 100644 --- a/crates/monitor/src/escrow_accounts.rs +++ b/crates/monitor/src/escrow_accounts.rs @@ -3,6 +3,7 @@ use std::{ collections::{HashMap, HashSet}, + num::NonZeroUsize, str::FromStr, time::Duration, }; @@ -116,7 +117,7 @@ pub async fn escrow_accounts_v2( reject_thawing_signers: bool, collector_address: Address, min_balance_grt_wei: String, - max_signers_per_payer: usize, + max_signers_per_payer: Option, ) -> Result { indexer_watcher::new_watcher(interval, move || { get_escrow_accounts_v2( @@ -137,7 +138,7 @@ async fn get_escrow_accounts_v2( reject_thawing_signers: bool, collector_address: Address, min_balance_grt_wei: String, - max_signers_per_payer: usize, + max_signers_per_payer: Option, ) -> anyhow::Result { tracing::trace!( indexer_address = ?indexer_address, @@ -150,6 +151,9 @@ async fn get_escrow_accounts_v2( }; use indexer_query::signers_by_payer::{self as signers_by_payer, SignersByPayerQuery}; + // Cap on signers fetched per payer; None means no limit. + let max_signers = max_signers_per_payer.map(NonZeroUsize::get); + let page_size: i64 = 200; let mut last: Option = None; let mut block_hash: Option = None; @@ -191,15 +195,17 @@ async fn get_escrow_accounts_v2( // Paginate additional signers for any payer that hit the 1000-per-payer nested cap let mut response = response; for account in &mut response.payments_escrow_accounts { - if max_signers_per_payer > 0 && account.payer.signers.len() >= max_signers_per_payer { - tracing::warn!( - payer = %account.payer.id, - signers = account.payer.signers.len(), - max = max_signers_per_payer, - "Payer signers already at or above max_signers_per_payer cap; skipping follow-up pagination" - ); - account.payer.signers.truncate(max_signers_per_payer); - continue; + if let Some(max) = max_signers { + if account.payer.signers.len() >= max { + tracing::warn!( + payer = %account.payer.id, + signers = account.payer.signers.len(), + max, + "Payer signers already at or above max_signers_per_payer cap; skipping follow-up pagination" + ); + account.payer.signers.truncate(max); + continue; + } } if account.payer.signers.len() < 1000 { @@ -245,8 +251,7 @@ async fn get_escrow_accounts_v2( } if page_len < 1000 - || (max_signers_per_payer > 0 - && account.payer.signers.len() >= max_signers_per_payer) + || max_signers.is_some_and(|max| account.payer.signers.len() >= max) { break; } @@ -258,14 +263,16 @@ async fn get_escrow_accounts_v2( .unwrap_or_default(); } - if max_signers_per_payer > 0 && account.payer.signers.len() >= max_signers_per_payer { - tracing::warn!( - payer = %account.payer.id, - signers = account.payer.signers.len(), - max = max_signers_per_payer, - "Payer signers capped at max_signers_per_payer" - ); - account.payer.signers.truncate(max_signers_per_payer); + if let Some(max) = max_signers { + if account.payer.signers.len() >= max { + tracing::warn!( + payer = %account.payer.id, + signers = account.payer.signers.len(), + max, + "Payer signers capped at max_signers_per_payer" + ); + account.payer.signers.truncate(max); + } } if account.payer.signers.len() > 1000 { @@ -419,7 +426,7 @@ mod tests { true, Address::ZERO, // collector address; mock ignores query variables "100000000000000000".to_string(), - 0, + None, // no signer-per-payer cap ) .await .unwrap(); diff --git a/crates/service/tests/router_test.rs b/crates/service/tests/router_test.rs index d939d0698..c81bf873b 100644 --- a/crates/service/tests/router_test.rs +++ b/crates/service/tests/router_test.rs @@ -88,7 +88,7 @@ fn build_service_router(inputs: RouterInputs) -> ServiceRouter { recently_closed_allocation_buffer_secs: Duration::from_secs(0), max_data_staleness_mins: 0, escrow_min_balance_grt_wei: "100000000000000000".to_string(), - max_signers_per_payer: 0, + max_signers_per_payer: None, }, ) .escrow_accounts_v2(inputs.escrow_accounts_v2) From 4fdfaf2d98dd600892053fc121406785d3c22a2b Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:46:06 +1200 Subject: [PATCH 25/31] refactor: reject zero for the two subgraph freshness checks (#1057) * refactor(config): express unlimited signer cap as an absent value The per-payer signer cap used 0 to mean "no limit", overloading a value that reads as "fetch zero signers". It is now an optional positive number: unset means no cap, and 0 is rejected at load. The default (no cap) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(monitor): pass None for the no-limit cap in the v2 escrow test The signer-per-payer cap became an optional value; this test still passed 0, which no longer type-checks. Pass None (no cap). Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(config): reject zero for subgraph freshness guards The network-subgraph staleness window and the DIPs role-subgraph lag window used 0 to mean "disable the check". They are now non-zero values: 0 is rejected at config load, and disabling is no longer offered. Defaults (30 min and 1800 s) are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- crates/config/maximal-config-example.toml | 9 ++++----- crates/config/src/config.rs | 22 ++++++++++------------ crates/monitor/src/allocations.rs | 2 +- crates/service/src/service.rs | 2 +- crates/service/src/service/router.rs | 2 +- crates/service/tests/router_test.rs | 6 ++++-- crates/tap-agent/src/agent.rs | 2 +- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index a57e701fd..4e15d3a83 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -79,10 +79,9 @@ syncing_interval_secs = 60 # So that we can keep serving queries while the information about the allocation closure # propagates to all the consumers. recently_closed_allocation_buffer_secs = 3600 -# Maximum allowed age of network subgraph data in minutes. -# Responses older than this are rejected to prevent stale data from replacing fresh data. -# This protects against Gateway routing queries to indexers that are significantly behind. -# Set to 0 to disable staleness checking. +# Maximum allowed age of network subgraph data in minutes; responses older than this are +# rejected so stale data can't replace fresh data or hide an indexer that's behind. +# Must be positive. Default 30. max_data_staleness_mins = 30 # Minimum escrow balance (GRT wei) for V2 escrow queries. Filters dust deposits # to raise the cost of crowding attacks. Default: 0.1 GRT. @@ -238,7 +237,7 @@ role_refresh_interval_secs = 86400 role_failopen_grace_secs = 3600 # Safety check on subgraph freshness: if the subgraph has fallen more than this many seconds # behind the chain, its sender list is treated as untrustworthy and proposals are rejected as -# "try again later" rather than risk stale data. 0 turns the check off. Default 1800 (30 min). +# "try again later" rather than risk stale data. Must be positive. Default 1800 (30 min). role_subgraph_max_lag_secs = 1800 # Startup chain RPC for the RecurringCollector's EIP-712 signing domain. Unset uses built-in # values (correct on standard networks); set on local/dev or upgraded chains, else every proposal fails. diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 5db5d2f0c..66649409e 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -5,7 +5,7 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, env, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, - num::NonZeroUsize, + num::{NonZeroU64, NonZeroUsize}, path::PathBuf, time::Duration, }; @@ -510,13 +510,11 @@ pub struct NetworkSubgraphConfig { #[serde_as(as = "DurationSecondsWithFrac")] pub recently_closed_allocation_buffer_secs: Duration, - /// Maximum allowed age of network subgraph data in minutes. - /// Responses older than this are rejected to prevent stale data from replacing fresh data. - /// This protects against Gateway routing queries to indexers that are significantly behind. - /// Set to 0 to disable staleness checking. - /// Default: 30 (minutes) + /// Maximum allowed age of network subgraph data in minutes; responses older than + /// this are rejected so stale data can't replace fresh data or hide an indexer + /// that is significantly behind. Must be positive. Default: 30 (minutes). #[serde(default = "default_max_data_staleness_mins")] - pub max_data_staleness_mins: u64, + pub max_data_staleness_mins: NonZeroU64, /// Minimum escrow balance (GRT wei) for the V2 escrow query. Filters dust /// deposits to raise the cost of crowding attacks. Default: 0.1 GRT. @@ -530,8 +528,8 @@ pub struct NetworkSubgraphConfig { pub max_signers_per_payer: Option, } -fn default_max_data_staleness_mins() -> u64 { - 30 +fn default_max_data_staleness_mins() -> NonZeroU64 { + NonZeroU64::new(30).expect("30 is non-zero") } fn default_escrow_min_balance_grt_wei() -> String { @@ -703,8 +701,8 @@ pub struct DipsConfig { /// while refreshes fail, before the gate fails closed (the fail-open window). pub role_failopen_grace_secs: u64, /// Max seconds the role subgraph head may lag wall-clock before its data is - /// treated as unreliable. 0 disables the check. - pub role_subgraph_max_lag_secs: u64, + /// treated as unreliable. Must be positive. + pub role_subgraph_max_lag_secs: NonZeroU64, /// Ethereum JSON-RPC endpoint used to fetch the EIP-712 domain from the /// RecurringCollector at startup (EIP-5267). Unset: built-in domain constants. pub rpc_url: Option, @@ -726,7 +724,7 @@ impl Default for DipsConfig { recurring_collector: None, role_refresh_interval_secs: 86_400, role_failopen_grace_secs: 3_600, - role_subgraph_max_lag_secs: 1_800, + role_subgraph_max_lag_secs: NonZeroU64::new(1_800).expect("1800 is non-zero"), rpc_url: None, max_new_agreements_per_24h: None, } diff --git a/crates/monitor/src/allocations.rs b/crates/monitor/src/allocations.rs index 1b7b426cf..ed4f3a15f 100644 --- a/crates/monitor/src/allocations.rs +++ b/crates/monitor/src/allocations.rs @@ -39,7 +39,7 @@ pub struct AllocationQueryResponse { /// * `indexer_address` - The indexer's address /// * `interval` - How often to poll for updates /// * `recently_closed_allocation_buffer` - How long to keep closed allocations -/// * `max_data_staleness_mins` - Maximum allowed age of data in minutes. Set to 0 to disable. +/// * `max_data_staleness_mins` - Maximum allowed age of data in minutes (must be positive). /// When data is older than this threshold, the update is rejected unless it's fresher than /// the current best data. This protects against Gateway routing to stale indexers. /// diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 097a41618..2099394e8 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -381,7 +381,7 @@ async fn start_dips( indexing_payments_subgraph, Duration::from_secs(*role_refresh_interval_secs), Duration::from_secs(*role_failopen_grace_secs), - Duration::from_secs(*role_subgraph_max_lag_secs), + Duration::from_secs(role_subgraph_max_lag_secs.get()), ) .await; diff --git a/crates/service/src/service/router.rs b/crates/service/src/service/router.rs index 4bea23e94..5073be36c 100644 --- a/crates/service/src/service/router.rs +++ b/crates/service/src/service/router.rs @@ -131,7 +131,7 @@ impl ServiceRouter { indexer_address, network.config.syncing_interval_secs, network.recently_closed_allocation_buffer_secs, - network.max_data_staleness_mins, + network.max_data_staleness_mins.get(), ) .await .expect("Failed to initialize indexer_allocations watcher"), diff --git a/crates/service/tests/router_test.rs b/crates/service/tests/router_test.rs index c81bf873b..3f063d84d 100644 --- a/crates/service/tests/router_test.rs +++ b/crates/service/tests/router_test.rs @@ -1,7 +1,7 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::{net::SocketAddr, time::Duration}; +use std::{net::SocketAddr, num::NonZeroU64, time::Duration}; use axum::{body::to_bytes, extract::ConnectInfo, http::Request, Extension}; use axum_extra::headers::Header; @@ -86,7 +86,9 @@ fn build_service_router(inputs: RouterInputs) -> ServiceRouter { NetworkSubgraphConfig { config: inputs.network_subgraph_config, recently_closed_allocation_buffer_secs: Duration::from_secs(0), - max_data_staleness_mins: 0, + // Effectively unbounded: this routing test uses a placeholder block + // timestamp and does not exercise staleness checking. + max_data_staleness_mins: NonZeroU64::new(1_000_000_000).unwrap(), escrow_min_balance_grt_wei: "100000000000000000".to_string(), max_signers_per_payer: None, }, diff --git a/crates/tap-agent/src/agent.rs b/crates/tap-agent/src/agent.rs index 2f9e6fc46..7ccc6cff4 100644 --- a/crates/tap-agent/src/agent.rs +++ b/crates/tap-agent/src/agent.rs @@ -140,7 +140,7 @@ pub async fn start_agent( *indexer_address, *network_sync_interval, *recently_closed_allocation_buffer, - *max_data_staleness_mins, + max_data_staleness_mins.get(), ) .await .with_context(|| "Failed to initialize indexer_allocations watcher")?; From 9ce686123e59750fe0ffd04ef9faf2145f8e04d2 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:39:52 +1200 Subject: [PATCH 26/31] feat: skip duplicate work when an indexing proposal is re-sent (#1052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an indexing-payment proposal reaches the indexer a second time, it used to rerun every check — including re-downloading the subgraph manifest — before quietly dropping the duplicate at the end. This change recognises the repeat as soon as the proposal's stable identifier is known and answers from the record it already holds, so the manifest download never runs twice. --- crates/dips/proto/indexer.proto | 2 +- crates/dips/src/database.rs | 51 ++- crates/dips/src/lib.rs | 320 +++++++++++++++++- .../src/proto/graphprotocol.indexer.dips.rs | 2 +- crates/dips/src/server.rs | 28 ++ crates/dips/src/store.rs | 177 +++++++--- .../20260613000000_dips_status_check.down.sql | 2 + .../20260613000000_dips_status_check.up.sql | 7 + 8 files changed, 538 insertions(+), 51 deletions(-) create mode 100644 migrations/20260613000000_dips_status_check.down.sql create mode 100644 migrations/20260613000000_dips_status_check.up.sql diff --git a/crates/dips/proto/indexer.proto b/crates/dips/proto/indexer.proto index 7d40d9789..3bc87993c 100644 --- a/crates/dips/proto/indexer.proto +++ b/crates/dips/proto/indexer.proto @@ -68,7 +68,7 @@ enum RejectReason { REJECT_REASON_SENDER_NOT_TRUSTED = 9; /// The signer is not an authorised agreement manager. REJECT_REASON_CAPACITY_EXCEEDED = 10; /// The indexer is at its DIPs capacity; may resolve later. REJECT_REASON_MANIFEST_TOO_LARGE = 11; /// The subgraph manifest exceeds the indexer's size cap. - REJECT_REASON_REPLAY_DETECTED = 12; /// A different proposal reuses an already-seen agreement id. + REJECT_REASON_REPLAY_DETECTED = 12; /// A re-sent proposal: an exact replay of one already rejected, or a different payload reusing an already-seen agreement id. REJECT_REASON_INSUFFICIENT_ESCROW = 13; /// The payer has insufficient escrow to back the agreement. REJECT_REASON_INDEXER_UNAVAILABLE = 14; /// A transient internal error; the proposal may be resent. } diff --git a/crates/dips/src/database.rs b/crates/dips/src/database.rs index 1881c1234..729ac0bcb 100644 --- a/crates/dips/src/database.rs +++ b/crates/dips/src/database.rs @@ -35,10 +35,13 @@ use std::{any::Any, time::Duration}; use async_trait::async_trait; -use sqlx::PgPool; +use sqlx::{PgPool, Row}; use uuid::Uuid; -use crate::{store::RcaStore, DipsError}; +use crate::{ + store::{RcaStore, StoredProposal}, + DipsError, +}; /// PostgreSQL implementation of RcaStore for RecurringCollectionAgreement. #[derive(Debug)] @@ -71,6 +74,20 @@ impl RcaStore for PsqlRcaStore { Ok(()) } + async fn lookup(&self, agreement_id: Uuid) -> Result, DipsError> { + let row = + sqlx::query("SELECT status, signed_payload FROM pending_rca_proposals WHERE id = $1") + .bind(agreement_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| DipsError::UnknownError(e.into()))?; + + Ok(row.map(|row| StoredProposal { + status: row.get("status"), + signed_payload: row.get("signed_payload"), + })) + } + async fn count_since(&self, window: Duration) -> Result { // Count live agreements only: 'pending' (awaiting the agent) and 'accepted'. // The agent records rejected and expired proposals as 'rejected', so they @@ -98,6 +115,7 @@ mod tests { use std::time::Duration; use super::*; + use crate::PROTOCOL_VERSION; /// Insert a proposal row with `status`, created `hours_ago` before the DB clock. async fn insert_at(pool: &PgPool, id: Uuid, hours_ago: i64, status: &str) { @@ -114,6 +132,35 @@ mod tests { .unwrap(); } + // Needs Docker; skipped locally, runs on CI. + #[tokio::test] + async fn test_lookup_roundtrip() { + // Arrange + let test_db = test_assets::setup_shared_test_db().await; + let store = PsqlRcaStore { + pool: test_db.pool.clone(), + }; + let id = Uuid::now_v7(); + let payload = vec![1u8, 2, 3, 4, 5]; + + // Act + Assert: absent id resolves to None. + assert!(store.lookup(id).await.unwrap().is_none()); + + // Act + Assert: after store_rca the row is found with default status. + store + .store_rca(id, payload.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + let found = store.lookup(id).await.unwrap(); + assert_eq!( + found, + Some(StoredProposal { + status: "pending".to_string(), + signed_payload: payload, + }) + ); + } + // Exercises the rolling-window SQL against a real Postgres (testcontainers); // the in-memory store can't, since it ignores the window. #[tokio::test] diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index 9028232c5..cfcc96696 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -30,9 +30,11 @@ //! When an RCA arrives, this crate validates: //! 1. **Service provider** - RCA is addressed to this indexer //! 2. **Timestamps** - Deadline and end time haven't passed -//! 3. **IPFS manifest** - Subgraph deployment exists and is parseable -//! 4. **Network** - Subgraph's network is supported by this indexer -//! 5. **Pricing** - Offered price meets indexer's minimum +//! 3. **Replay/idempotency** - Once the deterministic agreement id is derived, +//! a re-sent proposal is resolved against the store before any IPFS fetch +//! 4. **IPFS manifest** - Subgraph deployment exists and is parseable +//! 5. **Network** - Subgraph's network is supported by this indexer +//! 6. **Pricing** - Offered price meets indexer's minimum //! //! Signature and signer-authorization checks are NOT performed here. With the //! switch to offer-based authorization, the on-chain `acceptIndexingAgreement` @@ -209,6 +211,11 @@ pub enum DipsError { TrustVerificationUnavailable(String), #[error("indexer is at its DIPs agreement capacity ({limit} per 24h)")] CapacityExceeded { limit: u64 }, + // Replay detection (early dedup, before the IPFS fetch). + #[error("agreement {agreement_id} was already rejected")] + ReplayRejected { agreement_id: Uuid }, + #[error("agreement id {agreement_id} already stored with a different payload")] + ReplayConflict { agreement_id: Uuid }, } #[cfg(feature = "rpc")] @@ -302,6 +309,7 @@ const CAPACITY_WINDOW: std::time::Duration = std::time::Duration::from_secs(24 * /// Performs validation: /// - Service provider match /// - Deadline and expiry checks +/// - Replay/idempotency check on the derived agreement id (before any IPFS fetch) /// - IPFS manifest fetching and network validation /// - Price minimum enforcement /// @@ -355,6 +363,36 @@ pub async fn validate_and_create_rca( return Err(DipsError::AgreementExpired { ends_at, now }); } + // Derive agreement ID deterministically from the RCA fields + let agreement_id = derive_agreement_id(&signed_rca.agreement); + + // Early replay/idempotency check, ahead of the capacity cap and the IPFS + // fetch: a recorded proposal's retry is resolved from the store, never + // capacity-rejected or charged for the download. Failing open is best-effort. + match rca_store.lookup(agreement_id).await { + Ok(None) => {} + Ok(Some(prior)) => { + // A different payload reusing the same id is the only true conflict. + if prior.signed_payload != rca_bytes { + tracing::warn!( + %agreement_id, + "replay conflict: agreement id re-sent with a different payload" + ); + return Err(DipsError::ReplayConflict { agreement_id }); + } + // Byte-identical re-send: return the stored outcome, skipping the fetch. + // A rejected proposal stays rejected; any other status + // (pending/accepted/completed/future) resolves to an accept. + if prior.status == crate::store::STATUS_REJECTED { + return Err(DipsError::ReplayRejected { agreement_id }); + } + return Ok(agreement_id); + } + Err(error) => { + tracing::warn!(%agreement_id, %error, "replay lookup failed, continuing validation"); + } + } + // Capacity safeguard: cap live agreements (pending or accepted) per rolling // 24h, checked before the IPFS fetch so an over-cap proposal costs no download. // Count-then-store isn't atomic, so concurrent proposals may overshoot slightly. @@ -370,9 +408,6 @@ pub async fn validate_and_create_rca( } } - // Derive agreement ID deterministically from the RCA fields - let agreement_id = derive_agreement_id(&signed_rca.agreement); - // Decode metadata let metadata = AcceptIndexingAgreementMetadata::abi_decode(signed_rca.agreement.metadata.as_ref()) @@ -503,7 +538,7 @@ mod test { price::PriceCalculator, rca_eip712_domain, server::DipsServerContext, - store::{FailingRcaStore, InMemoryRcaStore, RcaStore}, + store::{FailingRcaStore, InMemoryRcaStore, LookupFailsStore, RcaStore}, trusted_signers::{StaticTrustedSigners, TrustedSignerSource}, AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, RecurringCollectionAgreement, SignedRecurringCollectionAgreement, @@ -1303,6 +1338,270 @@ mod test { ); } + // ========================================================================= + // Early replay / idempotency check (before the IPFS fetch) + // ========================================================================= + + use crate::{ipfs::GraphManifest, ipfs::IpfsFetcher, PROTOCOL_VERSION}; + + /// IPFS fetcher that fails the test if reached. Proves the replay + /// short-circuit returns before any manifest download. + #[derive(Debug)] + struct PanicIpfsFetcher; + + #[async_trait::async_trait] + impl IpfsFetcher for PanicIpfsFetcher { + async fn fetch(&self, _file: &str) -> Result { + panic!("IPFS fetch must not run on the replay short-circuit path"); + } + } + + /// Build a context whose store is the supplied seeded one and whose IPFS + /// fetcher panics if called, so a reached download surfaces as a test panic. + fn context_with_store_and_panic_fetcher( + store: Arc, + ) -> Arc { + Arc::new(DipsServerContext { + rca_store: store, + ipfs_fetcher: Arc::new(PanicIpfsFetcher), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }) + } + + /// Overwrite the status of the single seeded row, for the accepted/rejected cases. + async fn set_status(store: &InMemoryRcaStore, status: &str) { + let mut data = store.data.write().await; + data[0].3 = status.to_string(); + } + + #[tokio::test] + async fn test_replay_first_time_runs_full_pipeline() { + // Arrange: empty store, real mock fetcher; this is the non-replay path. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_identical_pending_accepts_without_fetch() { + // Arrange: seed the identical bytes as a pending row. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + let ctx = context_with_store_and_panic_fetcher(store); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert: replays the prior accept, panicking fetcher proves no download. + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_identical_accepted_accepts_without_fetch() { + // Arrange: seed identical bytes, then promote the row to accepted. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + set_status(&store, "accepted").await; + let ctx = context_with_store_and_panic_fetcher(store); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_identical_completed_accepts_without_fetch() { + // Arrange: seed identical bytes, then mark the row completed (the status + // the agent sets when an agreement goes live on-chain and is retired). + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + set_status(&store, "completed").await; + let ctx = context_with_store_and_panic_fetcher(store); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert: a completed proposal replays Ok, not a ReplayConflict. + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_identical_rejected_rerejects_without_fetch() { + // Arrange: seed identical bytes, then mark the row rejected. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + set_status(&store, "rejected").await; + let ctx = context_with_store_and_panic_fetcher(store); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert!(matches!(result, Err(DipsError::ReplayRejected { .. }))); + } + + #[tokio::test] + async fn test_replay_same_id_different_bytes_conflicts_without_fetch() { + // Arrange: seed one payload, then submit a different payload sharing the id. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + // Same id preimage (payer, dataService, serviceProvider, deadline, nonce), + // different terms so the wire bytes differ. + let seeded = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let conflicting = + create_test_rca(payer, service_provider, U256::from(999), U256::from(100)); + let agreement_id = derive_agreement_id(&seeded); + assert_eq!(agreement_id, derive_agreement_id(&conflicting)); + + let seeded_bytes = rca_to_wire_bytes(seeded); + let conflicting_bytes = rca_to_wire_bytes(conflicting); + assert_ne!(seeded_bytes, conflicting_bytes); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, seeded_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + let ctx = context_with_store_and_panic_fetcher(store.clone()); + + // Act + let result = + super::validate_and_create_rca(ctx, &service_provider, conflicting_bytes).await; + + // Assert: conflict reported and the stored row is left unchanged. + assert!(matches!(result, Err(DipsError::ReplayConflict { .. }))); + let stored = store.lookup(agreement_id).await.unwrap().unwrap(); + assert_eq!(stored.signed_payload, seeded_bytes); + } + + #[tokio::test] + async fn test_replay_lookup_error_fails_open_and_validates() { + // Arrange: a store whose lookup errors but whose write succeeds, paired + // with a real fetcher so validation can run to completion. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(LookupFailsStore), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert: the swallowed lookup error still lets validation run to an + // accept, rather than surfacing as a rejection. + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_accepts_even_when_at_capacity() { + // Arrange: a recorded pending proposal with the per-24h cap already at + // one, so the capacity check alone would reject. The panicking fetcher + // proves the replay short-circuits before any manifest download. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + let ctx = Arc::new(DipsServerContext { + rca_store: store, + ipfs_fetcher: Arc::new(PanicIpfsFetcher), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: Some(1), + }); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert: the prior accept comes back rather than CapacityExceeded. + assert_eq!(result.unwrap(), agreement_id); + } + /// Build a context backed by `store` with the per-24h cap set to `max`. fn capacity_ctx(store: Arc, max: Option) -> Arc { Arc::new(DipsServerContext { @@ -1347,6 +1646,13 @@ mod test { self.0.store_rca(agreement_id, signed_rca, version).await } + async fn lookup( + &self, + agreement_id: uuid::Uuid, + ) -> Result, DipsError> { + self.0.lookup(agreement_id).await + } + async fn count_since(&self, _window: std::time::Duration) -> Result { Err(DipsError::UnknownError(anyhow::anyhow!( "count failed (test)" diff --git a/crates/dips/src/proto/graphprotocol.indexer.dips.rs b/crates/dips/src/proto/graphprotocol.indexer.dips.rs index cd058c462..bb9abcdf4 100644 --- a/crates/dips/src/proto/graphprotocol.indexer.dips.rs +++ b/crates/dips/src/proto/graphprotocol.indexer.dips.rs @@ -87,7 +87,7 @@ pub enum RejectReason { CapacityExceeded = 10, /// / The subgraph manifest exceeds the indexer's size cap. ManifestTooLarge = 11, - /// / A different proposal reuses an already-seen agreement id. + /// / A re-sent proposal: an exact replay of one already rejected, or a different payload reusing an already-seen agreement id. ReplayDetected = 12, /// / The payer has insufficient escrow to back the agreement. InsufficientEscrow = 13, diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 28872040a..3d50ac5c2 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -15,6 +15,7 @@ //! ├─ Size validation (non-empty, max 10KB) //! ├─ Service-provider match //! ├─ Timestamp validation (deadline, endsAt) +//! ├─ Replay/idempotency check (before any IPFS fetch) //! ├─ IPFS manifest fetch //! ├─ Network validation //! ├─ Price validation @@ -121,6 +122,11 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, DipsError::ManifestTooLarge { .. } => RejectReason::ManifestTooLarge, DipsError::CapacityExceeded { .. } => RejectReason::CapacityExceeded, + // A re-sent proposal that conflicts or was already rejected: re-reject + // without re-running the pipeline. + DipsError::ReplayRejected { .. } | DipsError::ReplayConflict { .. } => { + RejectReason::ReplayDetected + } // Malformed proposals with no dedicated reason map to the catch-all; the // detail carries the specifics. DipsError::AbiDecoding(_) @@ -505,6 +511,28 @@ mod tests { assert_eq!(reason, RejectReason::ManifestTooLarge); } + #[test] + fn test_reject_reason_replay_variants_map_to_replay_detected() { + // Arrange + use uuid::Uuid; + let rejected = DipsError::ReplayRejected { + agreement_id: Uuid::now_v7(), + }; + let conflict = DipsError::ReplayConflict { + agreement_id: Uuid::now_v7(), + }; + + // Act + Assert + assert_eq!( + super::reject_reason_from_error(&rejected), + RejectReason::ReplayDetected + ); + assert_eq!( + super::reject_reason_from_error(&conflict), + RejectReason::ReplayDetected + ); + } + #[test] fn test_reject_reason_capacity_exceeded() { // Arrange diff --git a/crates/dips/src/store.rs b/crates/dips/src/store.rs index a15f36e66..000892a09 100644 --- a/crates/dips/src/store.rs +++ b/crates/dips/src/store.rs @@ -1,29 +1,9 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -//! Storage abstraction for RCA proposals. -//! -//! This module defines the [`RcaStore`] trait for persisting validated RCA proposals. -//! The indexer-service validates incoming proposals and stores them; the indexer-agent -//! (a separate TypeScript process) queries this table to decide on-chain acceptance. -//! -//! # Database Schema -//! -//! Proposals are stored in the `pending_rca_proposals` table: -//! -//! | Column | Type | Description | -//! |----------------|-------------|------------------------------------------| -//! | id | UUID | Agreement ID from the RCA | -//! | signed_payload | BYTEA | Raw ABI-encoded SignedRCA bytes | -//! | version | SMALLINT | Protocol version (currently 2) | -//! | status | VARCHAR(20) | "pending", "accepted", "rejected", etc. | -//! | created_at | TIMESTAMPTZ | When the proposal was received | -//! | updated_at | TIMESTAMPTZ | Last status change | -//! -//! # Implementations -//! -//! - [`InMemoryRcaStore`] - In-memory store for unit tests -//! - [`PsqlRcaStore`](crate::database::PsqlRcaStore) - PostgreSQL implementation +//! Storage abstraction for RCA proposals. [`RcaStore`] persists validated +//! proposals to the shared `pending_rca_proposals` table; indexer-rs writes them +//! and the indexer-agent reads them and advances `status`. See the migration. use std::{any::Any, time::Duration}; @@ -32,21 +12,30 @@ use uuid::Uuid; use crate::DipsError; -/// Store for RCA (RecurringCollectionAgreement) proposals. -/// -/// Stores validated RCA proposals. The indexer agent queries this table, -/// validates allocation availability, and submits on-chain acceptance. +/// Lifecycle statuses for a `pending_rca_proposals` row. Keep this set in sync +/// with the CHECK constraint in the `dips_status_check` migration: indexer-rs +/// writes `pending`; the agent advances a row to accepted, completed, rejected. +pub const STATUS_PENDING: &str = "pending"; +pub const STATUS_ACCEPTED: &str = "accepted"; +pub const STATUS_COMPLETED: &str = "completed"; +pub const STATUS_REJECTED: &str = "rejected"; + +/// A proposal already on record, returned by [`RcaStore::lookup`]. Carries the +/// lifecycle `status` and raw `signed_payload` so the early replay check can +/// compare byte-for-byte against a re-sent proposal. +#[derive(Debug, Clone, PartialEq)] +pub struct StoredProposal { + pub status: String, + pub signed_payload: Vec, +} + +/// Store for validated RCA proposals: indexer-rs writes them here and the +/// indexer-agent reads them to decide on-chain acceptance. #[async_trait] pub trait RcaStore: Sync + Send + std::fmt::Debug { - /// Store a validated RCA proposal. - /// - /// Only called after successful validation (signature, IPFS, pricing). - /// - /// # Idempotency - /// - /// This operation MUST be idempotent: storing the same `agreement_id` twice - /// must succeed both times. This enables safe retries when Dipper re-sends - /// an RCA after timeout or network partition. + /// Store a validated RCA proposal. MUST be idempotent: storing the same + /// `agreement_id` twice must both succeed, so a Dipper re-send after a + /// timeout or network partition doesn't fail on a duplicate. async fn store_rca( &self, agreement_id: Uuid, @@ -54,6 +43,11 @@ pub trait RcaStore: Sync + Send + std::fmt::Debug { version: u64, ) -> Result<(), DipsError>; + /// Look up a stored proposal by its deterministic agreement ID, `Ok(None)` + /// when absent. The early replay check calls this before the IPFS fetch so a + /// re-sent proposal skips the download. + async fn lookup(&self, agreement_id: Uuid) -> Result, DipsError>; + /// Count live agreements (status `pending` or `accepted`) within the trailing /// `window`; rejected and expired proposals are excluded. The in-memory test /// store has no status, so it ignores the window and counts every entry. @@ -63,10 +57,15 @@ pub trait RcaStore: Sync + Send + std::fmt::Debug { fn as_any(&self) -> &dyn Any; } -/// In-memory implementation of RcaStore for testing. +/// One in-memory row: (id, signed payload, version, status). +type InMemoryRow = (Uuid, Vec, u64, String); + +/// In-memory implementation of RcaStore for testing. Each row carries a status +/// string (defaulting to "pending" on insert) so the replay check can be +/// exercised against accepted and rejected rows in tests. #[derive(Default, Debug)] pub struct InMemoryRcaStore { - pub data: tokio::sync::RwLock, u64)>>, + pub data: tokio::sync::RwLock>, } #[async_trait] @@ -79,12 +78,28 @@ impl RcaStore for InMemoryRcaStore { ) -> Result<(), DipsError> { let mut data = self.data.write().await; // Idempotent: skip if already exists - if !data.iter().any(|(id, _, _)| *id == agreement_id) { - data.push((agreement_id, signed_rca, version)); + if !data.iter().any(|(id, _, _, _)| *id == agreement_id) { + data.push(( + agreement_id, + signed_rca, + version, + STATUS_PENDING.to_string(), + )); } Ok(()) } + async fn lookup(&self, agreement_id: Uuid) -> Result, DipsError> { + let data = self.data.read().await; + Ok(data + .iter() + .find(|(id, _, _, _)| *id == agreement_id) + .map(|(_, payload, _, status)| StoredProposal { + status: status.clone(), + signed_payload: payload.clone(), + })) + } + async fn count_since(&self, _window: Duration) -> Result { Ok(self.data.read().await.len() as u64) } @@ -111,6 +126,12 @@ impl RcaStore for FailingRcaStore { ))) } + async fn lookup(&self, _agreement_id: Uuid) -> Result, DipsError> { + Err(DipsError::UnknownError(anyhow::anyhow!( + "database connection failed (test store)" + ))) + } + async fn count_since(&self, _window: Duration) -> Result { Err(DipsError::UnknownError(anyhow::anyhow!( "database connection failed (test store)" @@ -122,6 +143,37 @@ impl RcaStore for FailingRcaStore { } } +/// Test double whose `lookup` errors but whose `store_rca` succeeds, to exercise +/// the fail-open path: a lookup error must be swallowed so validation still runs. +#[derive(Default, Debug)] +pub struct LookupFailsStore; + +#[async_trait] +impl RcaStore for LookupFailsStore { + async fn store_rca( + &self, + _agreement_id: Uuid, + _signed_rca: Vec, + _version: u64, + ) -> Result<(), DipsError> { + Ok(()) + } + + async fn lookup(&self, _agreement_id: Uuid) -> Result, DipsError> { + Err(DipsError::UnknownError(anyhow::anyhow!( + "lookup failed (test store)" + ))) + } + + async fn count_since(&self, _window: Duration) -> Result { + Ok(0) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + #[cfg(test)] mod tests { use super::*; @@ -205,4 +257,49 @@ mod tests { assert_eq!(data.len(), 1, "Duplicate should not create second entry"); assert_eq!(data[0].0, id); } + + #[tokio::test] + async fn test_lookup_absent_returns_none() { + // Arrange + let store = InMemoryRcaStore::default(); + + // Act + let found = store.lookup(Uuid::now_v7()).await.unwrap(); + + // Assert + assert!(found.is_none()); + } + + #[tokio::test] + async fn test_lookup_returns_pending_payload() { + // Arrange + let store = InMemoryRcaStore::default(); + let id = Uuid::now_v7(); + let blob = vec![9, 8, 7, 6]; + store.store_rca(id, blob.clone(), 2).await.unwrap(); + + // Act + let found = store.lookup(id).await.unwrap(); + + // Assert + assert_eq!( + found, + Some(StoredProposal { + status: "pending".to_string(), + signed_payload: blob, + }) + ); + } + + #[tokio::test] + async fn test_failing_store_lookup_errors() { + // Arrange + let store = FailingRcaStore; + + // Act + let result = store.lookup(Uuid::now_v7()).await; + + // Assert + assert!(matches!(result, Err(DipsError::UnknownError(_)))); + } } diff --git a/migrations/20260613000000_dips_status_check.down.sql b/migrations/20260613000000_dips_status_check.down.sql new file mode 100644 index 000000000..dda7bdc4f --- /dev/null +++ b/migrations/20260613000000_dips_status_check.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE pending_rca_proposals + DROP CONSTRAINT IF EXISTS pending_rca_proposals_status_check; diff --git a/migrations/20260613000000_dips_status_check.up.sql b/migrations/20260613000000_dips_status_check.up.sql new file mode 100644 index 000000000..bd2446be7 --- /dev/null +++ b/migrations/20260613000000_dips_status_check.up.sql @@ -0,0 +1,7 @@ +-- Constrain pending_rca_proposals.status to the vocabulary shared with the +-- indexer-agent (it writes 'accepted'/'completed'/'rejected'); a value outside +-- this set means the two services have drifted and should fail at write time. +-- Keep this set in sync with the STATUS_* constants in crates/dips/src/store.rs. +ALTER TABLE pending_rca_proposals + ADD CONSTRAINT pending_rca_proposals_status_check + CHECK (status IN ('pending', 'accepted', 'completed', 'rejected')); From 9e7e6c35dc5cb5584683490efacb7d8536297e7d Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:14:36 +1000 Subject: [PATCH 27/31] feat(dips): log subgraph deployment id on RCA acceptance (#1064) The RCA acceptance log recorded only the agreement id, so filtering indexer logs by a subgraph deployment hash never matched an acceptance; the deployment id appeared only on rejection logs. Add it to the acceptance log as well, mirroring the rejection log. --- crates/dips/src/server.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 3d50ac5c2..721455b16 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -213,7 +213,11 @@ impl IndexerDipsService for DipsServer { crate::metrics::PROPOSAL_OUTCOMES .with_label_values(&["accepted"]) .inc(); - tracing::info!(%agreement_id, "RCA accepted"); + tracing::info!( + %agreement_id, + deployment_id = deployment_id.as_deref().unwrap_or("unknown"), + "RCA accepted" + ); Ok(Response::new(SubmitAgreementProposalResponse { outcome: Some(Outcome::Accepted(Accepted {})), })) From 667d6385df1b86e0ae809c396648f293d9f8960b Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:14:54 +1000 Subject: [PATCH 28/31] feat(config): validate the escrow dust floor at load time (#1058) The escrow dust floor is an unvalidated string passed straight to the subgraph, so a typo only fails later as a broken query. Parse it at config load and reject non-numeric input. 0 still means no floor and the default is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) --- crates/config/src/config.rs | 58 ++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 7058975cf..e461404df 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -20,7 +20,10 @@ use regex::Regex; use serde::Deserialize; use serde_repr::Deserialize_repr; use serde_with::{serde_as, DurationSecondsWithFrac}; -use thegraph_core::{alloy::primitives::Address, DeploymentId}; +use thegraph_core::{ + alloy::primitives::{Address, U256}, + DeploymentId, +}; use url::Url; use crate::{NonZeroGRT, GRT}; @@ -273,6 +276,21 @@ impl Config { ); } + // The escrow dust floor is sent to the subgraph as an integer; reject a + // non-numeric value at load rather than failing later at query time. + if self + .subgraphs + .network + .escrow_min_balance_grt_wei + .parse::() + .is_err() + { + return Err( + "subgraphs.network.escrow_min_balance_grt_wei must be a non-negative integer (wei)" + .to_string(), + ); + } + if self.tap.allocation_reconciliation_interval_secs < Duration::from_secs(60) { tracing::warn!( "Your `tap.allocation_reconciliation_interval_secs` value is too low. \ @@ -1340,6 +1358,44 @@ mod tests { .contains("No operator mnemonic configured")); } + /// Test that config validation rejects a non-numeric escrow dust floor. + #[sealed_test(files = ["minimal-config-example.toml"])] + fn test_validation_rejects_non_numeric_escrow_min_balance() { + let mut minimal_config: toml::Value = toml::from_str( + fs::read_to_string("minimal-config-example.toml") + .unwrap() + .as_str(), + ) + .unwrap(); + + minimal_config + .get_mut("subgraphs") + .unwrap() + .get_mut("network") + .unwrap() + .as_table_mut() + .unwrap() + .insert( + "escrow_min_balance_grt_wei".to_string(), + toml::Value::String("not-a-number".to_string()), + ); + + let temp_config_path = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp_config_path.path(), + toml::to_string(&minimal_config).unwrap(), + ) + .unwrap(); + + let result = Config::parse( + ConfigPrefix::Service, + Some(PathBuf::from(temp_config_path.path())).as_ref(), + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("escrow_min_balance_grt_wei")); + } + // === DIPS Startup Validation Tests === /// Test that minimal config has no DIPS section (safe default for existing indexers). From 108b2b4fb412673d1d5a3a92ae321b56ce5b7fb0 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:51:11 +1000 Subject: [PATCH 29/31] feat: reject zero for the metrics port and size limits (#1059) * feat(config): validate the escrow dust floor at load time The escrow dust floor is an unvalidated string passed straight to the subgraph, so a typo only fails later as a broken query. Parse it at config load and reject non-numeric input. 0 still means no floor and the default is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(config): reject zero for the metrics port and size limits A metrics port of 0 makes the OS bind a random port no scraper can find, and a zero batch or request-body size rejects all work. Reject these at config load instead of failing in confusing ways later. Defaults are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- crates/config/src/config.rs | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index e461404df..1ffea6a75 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -291,6 +291,21 @@ impl Config { ); } + // Reject zero for limits where it is nonsensical: a metrics port of 0 binds a + // random OS port the scraper can't find, and a zero batch/body size rejects all work. + if self.metrics.port == 0 { + return Err("metrics.port must be greater than 0".to_string()); + } + if self.service.max_cost_model_batch_size == 0 { + return Err("service.max_cost_model_batch_size must be greater than 0".to_string()); + } + if self.service.max_request_body_size == 0 { + return Err("service.max_request_body_size must be greater than 0".to_string()); + } + if self.service.max_status_request_body_size == 0 { + return Err("service.max_status_request_body_size must be greater than 0".to_string()); + } + if self.tap.allocation_reconciliation_interval_secs < Duration::from_secs(60) { tracing::warn!( "Your `tap.allocation_reconciliation_interval_secs` value is too low. \ @@ -1396,6 +1411,39 @@ mod tests { assert!(result.unwrap_err().contains("escrow_min_balance_grt_wei")); } + /// Test that config validation rejects a metrics port of 0. + #[sealed_test(files = ["minimal-config-example.toml"])] + fn test_validation_rejects_zero_metrics_port() { + let mut minimal_config: toml::Value = toml::from_str( + fs::read_to_string("minimal-config-example.toml") + .unwrap() + .as_str(), + ) + .unwrap(); + + let mut metrics = toml::value::Table::new(); + metrics.insert("port".to_string(), toml::Value::Integer(0)); + minimal_config + .as_table_mut() + .unwrap() + .insert("metrics".to_string(), toml::Value::Table(metrics)); + + let temp_config_path = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp_config_path.path(), + toml::to_string(&minimal_config).unwrap(), + ) + .unwrap(); + + let result = Config::parse( + ConfigPrefix::Service, + Some(PathBuf::from(temp_config_path.path())).as_ref(), + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("metrics.port")); + } + // === DIPS Startup Validation Tests === /// Test that minimal config has no DIPS section (safe default for existing indexers). From b25a703a61021452126775ffe37dd990bfe88b74 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:05:36 +1000 Subject: [PATCH 30/31] docs: correct DIPs documentation that disagrees with the code (#1075) * docs(dips): correct the crate header that denied signer checks happen The header told readers that signature and signer-authorization checks were not performed by this crate, when the code recovers the EIP-712 signer and requires it to hold the agreement manager role before anything else. Also redraws the diagram and lists the modules that exist. * docs(grpc): record that the server authorises the signer before any work The module said signer authorization was left to the later on-chain acceptance call, when it runs here first. Notes that a bad request version or payload size returns a gRPC error rather than a rejection, and points the 3 partial validation checklists at the single ordered one. * docs(registry): drop the network check the registry never performed The module claimed proposals were validated against the registry of known blockchains. Nothing checks that: a network is accepted purely on the operator's supported_networks list plus a price, and the registry only supplies a chain id for log lines, though it must download at boot. * docs(config): name the metric the service actually publishes This comment is the one place an operator is told how to notice that DIPs failed to start and the service is running without it. It named a metric that does not exist, so an alert written against it would match nothing and stay silent for the same reason a healthy indexer does. * docs(networks): explain what really decides which networks are accepted The example told operators that additional_networks let proposals for custom chains through. It does not: a network is accepted only when it is in supported_networks AND has a price, and that map just fills a chain id in rejection logs. Also replaces double hyphens used as dashes. * docs(store): document the fourth status a proposal row can hold The lifecycle stopped at accepted or rejected. A proposal can also reach completed, which is the state that frees capacity: the optional cap on new agreements per 24 hours counts only pending and accepted rows, so a finished agreement stops holding a slot. * docs(ipfs): note the single-attempt mode the retry policy switches to The documented policy is 4 attempts with growing pauses and a 190 second worst case. Above 200 requests in flight a fetch gets 1 attempt and no retries, so a proposal whose only try hits a brief error is rejected rather than retried, and both published timings stop applying. * docs(routes): replace 2 routes that do not exist with the ones that do The list offered a DIPs GraphQL API that was never built and an escrow route the service stopped serving, while omitting /dips/info and the separate gRPC listener the dipper sends proposals to. A second copy of the same list lived in the code and is now a pointer, so the 2 cannot disagree. * docs(metrics): document the 4 DIPs metrics operators can alert on Nothing here covered DIPs, though the configuration example tells operators to watch one of these to notice a silent failure. Each entry now carries the threshold or expression to alert on, and warns that the startup gauge reports initialisation rather than continued liveness. * docs(dips): centralize the validation-step list in the crate docs The same ordered list of checks a payment proposal must pass was written out 3 times, so 2 copies could quietly fall out of step with the code. The crate front page now holds the only copy, and the other 2 places point at it. * docs(database): attribute the agent-side status steps to their own repo The lifecycle list here describes a shared database table, but only the first step belongs to this code. A note now says the indexer-agent owns the rest and ships from a different repository, so a reader does not take those steps for behaviour they can change here. * docs(metrics): move the DIPs alerting guidance out of the table cells In the metrics reference, 2 of the 4 rows documenting Direct Indexer Payments ran to 3 or 4 sentences of operator advice, while every other table in the file keeps cells to a single line. Those cells are now one-liners and the same advice, unchanged, reads as prose under the heading. * docs(routes): drop the anecdote about a route that no longer exists The note explaining why the service keeps a single route list ended with the story of one stale entry, which tells a new reader nothing about the code in front of them. * docs(comments): replace 4 double hyphens with real punctuation In 4 code comments a double hyphen stood in for a dash; each now uses the comma, semicolon or brackets that the sentence actually needs. * docs(dips): correct the expansion of DIPs to Direct Indexing Payments The crate docs and the example config expanded the DIPs acronym as Direct Indexer Payments. The Graph's proposal (GIP-0081) names the mechanism Indexing Payments, and public Edge & Node updates expand DIPs as Direct Indexing Payments, so the 2 comments now match that. --- crates/config/maximal-config-example.toml | 23 ++++--- crates/config/src/config.rs | 14 ++-- crates/dips/src/database.rs | 15 ++++- crates/dips/src/ipfs.rs | 13 +++- crates/dips/src/lib.rs | 79 +++++++++++++---------- crates/dips/src/metrics.rs | 2 +- crates/dips/src/registry.rs | 13 ++-- crates/dips/src/server.rs | 74 +++++++++++---------- crates/dips/src/store.rs | 4 +- crates/dips/src/trusted_signers.rs | 6 +- crates/service/src/routes/mod.rs | 12 +--- crates/service/src/service.rs | 2 +- docs/Metrics.md | 17 +++++ docs/Routes.md | 11 ++-- 14 files changed, 165 insertions(+), 120 deletions(-) diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index d1bf013b9..4f4f5c977 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -68,7 +68,7 @@ query_url = "http://example.com/network-subgraph" # hosted/gateway URL); leave unset for an open or self-hosted graph-node. Default unset. # query_auth_token = "super-secret" -# Optional: index this subgraph on your own graph-node and put its deployment id here -- queries +# Optional: index this subgraph on your own graph-node and put its deployment id here; queries # then prefer your local node (free) and fall back to query_url only if it is unsynced or errors. # query_url stays required as that fallback. Indexing locally is recommended. deployment_id = "Qmaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -93,7 +93,7 @@ escrow_min_balance_grt_wei = "100000000000000000" # Indexing-payments subgraph, read for the set of accounts allowed to send DIPs # agreement proposals (the agreement-manager role). Required when [dips] is set. [subgraphs.indexing_payments] -# Query URL for the indexing-payments subgraph -- a SEPARATE deployment from [subgraphs.network]. +# Query URL for the indexing-payments subgraph, a SEPARATE deployment from [subgraphs.network]. query_url = "http://example.com/indexing-payments-subgraph" # Required when this section is present (no default). How often to refresh from this subgraph, in # seconds. Distinct from dips.role_refresh_interval_secs, which governs the role-set pull. @@ -102,7 +102,7 @@ syncing_interval_secs = 60 # for an open or self-hosted graph-node. Default unset. # query_auth_token = "super-secret" -# Optional: index this subgraph yourself and set its deployment id -- queries then prefer your +# Optional: index this subgraph yourself and set its deployment id; queries then prefer your # local node (free) and fall back to query_url if it is unsynced. query_url stays required. # deployment_id = "Qmbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" @@ -126,10 +126,10 @@ url_prefix = "/" serve_network_subgraph = false #### OPTIONAL VALUES #### -# Bearer token that gates the served network/escrow subgraph endpoint (serve_network_subgraph). +# Bearer token that gates the served network subgraph endpoint (serve_network_subgraph). # serve_auth_token = "token" -# Lets anyone presenting this token query for FREE -- no TAP receipt required. Share sparingly. +# Lets anyone presenting this token query for FREE, with no TAP receipt required. Share sparingly. # free_query_auth_token = "i-am-authorized-right?" ipfs_url = "https://api.thegraph.com/ipfs/" # Maximum number of deployments allowed in a single /cost GraphQL batch query. @@ -190,9 +190,9 @@ max_receipts_per_request = 10000 0xDDE4cfFd3D9052A9cb618fC05a1Cd02be1f2F467 = "https://tap-aggregator.network.thegraph.com" 0xDD6a6f76eb36B873C1C184e8b9b9e762FE216490 = "https://tap-aggregator-arbitrum-one.graphops.xyz" -# DIPs (Direct Indexer Payments). Each proposal's recovered EIP-712 signer is checked against +# DIPs (Direct Indexing Payments). Each proposal's recovered EIP-712 signer is checked against # the on-chain agreement-manager role set, read off the indexing-payments subgraph at proposal -# time -- not the payer, not on-chain at acceptance. Prices below are GRT, not wei. +# time, not the payer and not on-chain at acceptance. Prices below are GRT, not wei. # To enable DIPs you must set: recurring_collector, [subgraphs.indexing_payments] (query_url + # syncing_interval_secs), and at least one supported_networks entry with a matching price under @@ -200,7 +200,7 @@ max_receipts_per_request = 10000 # Failure modes: an empty role set, or a subgraph outage past role_failopen_grace_secs, rejects # all proposals; if DIPs init fails the service keeps serving queries but runs WITHOUT DIPs -# (check the /dips/info endpoint and the dips_enabled metric). Per-field consequences below. +# (check the /dips/info endpoint and the indexer_dips_enabled metric). Per-field consequences below. [dips] # Address/port the DIPs gRPC server binds to, where the dipper sends proposals; needs ingress @@ -264,6 +264,9 @@ role_subgraph_max_lag_secs = 1800 # base-sepolia = "15" # sepolia = "5" -# Custom or test networks not in the global networks-registry: maps network name to CAIP-2 -# chain id so proposals for them pass the registry check. Default empty. e.g. hardhat = "eip155:1337" +# Custom or test networks not in the global networks-registry: maps network name to CAIP-2 chain id. +# Its only effect is the chain_id field on the price-rejection logs, which reads "unknown" without it. +# It blocks nothing: acceptance needs supported_networks plus a [dips.min_grt_per_30_days] price, and +# a network missing from those is rejected before any chain_id is resolved. +# Default empty. e.g. hardhat = "eip155:1337" [dips.additional_networks] diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 1ffea6a75..39f5bf42a 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -703,9 +703,9 @@ fn default_allocation_reconciliation_interval_secs() -> Duration { Duration::from_secs(300) } -/// DIPs configuration. Authorises proposal senders -- recovers each agreement's EIP-712 signer -/// and requires it to hold the on-chain agreement-manager role (from the indexing-payments -/// subgraph) -- then validates accepted proposals before storing them for the indexer-agent. +/// DIPs configuration. Authorises proposal senders by recovering each agreement's EIP-712 +/// signer and requiring it to hold the agreement-manager role (read from the indexing-payments +/// subgraph), then validates accepted proposals before storing them for the indexer-agent. #[derive(Debug, Deserialize)] #[serde(default)] #[cfg_attr(test, derive(PartialEq))] @@ -713,12 +713,18 @@ pub struct DipsConfig { /// Network interface the DIPs gRPC server binds to (e.g. `"0.0.0.0"` for all interfaces). pub host: String, pub port: String, - /// Networks this indexer explicitly supports. Proposals for other networks are rejected. + /// Networks this indexer explicitly supports. A network is accepted only when it + /// appears here AND has a `min_grt_per_30_days` entry; listing it without a price + /// still rejects every proposal. Proposals for other networks are rejected. pub supported_networks: HashSet, /// Minimum acceptable GRT per 30 days, per network. Converted to wei/second internally. + /// A network priced here is still rejected unless it also appears in `supported_networks`. pub min_grt_per_30_days: BTreeMap, /// Minimum acceptable GRT per billion entities per 30 days. pub min_grt_per_billion_entities_per_30_days: GRT, + /// CAIP-2 chain id per network name, for chains the global networks registry does not + /// cover. Used only to resolve the chain id logged with a price rejection; it grants no + /// network access on its own. pub additional_networks: BTreeMap, /// RecurringCollector address: the EIP-712 verifying contract used to recover /// the signer of incoming RCA proposals. Required when DIPs is enabled. diff --git a/crates/dips/src/database.rs b/crates/dips/src/database.rs index 729ac0bcb..e79c31e2b 100644 --- a/crates/dips/src/database.rs +++ b/crates/dips/src/database.rs @@ -1,7 +1,7 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -//! PostgreSQL implementation of [`RcaStore`](crate::store::RcaStore). +//! PostgreSQL implementation of [`RcaStore`]. //! //! This module provides [`PsqlRcaStore`], which persists validated RCA proposals //! to the `pending_rca_proposals` table. The indexer-agent queries this table @@ -22,6 +22,14 @@ //! 2. indexer-agent queries pending proposals //! 3. Agent validates allocation availability, accepts on-chain //! 4. Agent updates status to "accepted" or "rejected" +//! 5. Once an accepted agreement finishes, the agent marks it "completed" +//! +//! Steps 2 to 5 are the indexer-agent's side of the contract and live in that +//! separate repository; the only status this crate ever writes is "pending". +//! +//! The optional `max_new_agreements_per_24h` cap counts only "pending" and +//! "accepted" rows from the last 24 hours, so reaching "completed" or "rejected" +//! frees capacity again. //! //! # Idempotency //! @@ -90,8 +98,9 @@ impl RcaStore for PsqlRcaStore { async fn count_since(&self, window: Duration) -> Result { // Count live agreements only: 'pending' (awaiting the agent) and 'accepted'. - // The agent records rejected and expired proposals as 'rejected', so they - // drop out. NOW() is the DB clock, so the window ignores host clock skew. + // The agent records rejected and expired proposals as 'rejected' and finished + // ones as 'completed', so neither counts. NOW() is the DB clock, so the window + // ignores host clock skew. let count: i64 = sqlx::query_scalar( "SELECT count(*) FROM pending_rca_proposals WHERE status IN ('pending', 'accepted') diff --git a/crates/dips/src/ipfs.rs b/crates/dips/src/ipfs.rs index 8df258e6e..0ca998ce9 100644 --- a/crates/dips/src/ipfs.rs +++ b/crates/dips/src/ipfs.rs @@ -27,7 +27,14 @@ //! retries up to 3 times with exponential backoff (10s, 20s, 40s delays). This //! gives IPFS meaningful recovery time between attempts. //! -//! Worst case timing: 30s + 10s + 30s + 20s + 30s + 40s + 30s = 190 seconds. +//! Retries are skipped when the service is busy: if more than 200 proposal +//! requests are in flight (`IPFS_DURESS_THRESHOLD`) as a fetch starts, that +//! fetch gets a single attempt and no retries. A loaded indexer frees handler +//! slots faster, at the cost of rejecting proposals whose one attempt hits a +//! transient error. Dipper can resend those. +//! +//! Worst case timing while retrying: 30s + 10s + 30s + 20s + 30s + 40s + 30s = +//! 190 seconds. A busy indexer's single attempt is capped at 30 seconds. //! //! Dipper's gRPC timeout should be at least 220 seconds (190s + 30s buffer) //! to avoid timing out while indexer-rs is still retrying IPFS. @@ -76,7 +83,7 @@ const IPFS_RETRY_BASE_DELAY: Duration = Duration::from_secs(10); pub(crate) const IPFS_MAX_MANIFEST_BYTES: usize = 25 * 1024 * 1024; /// When the in-flight request count exceeds this threshold, IPFS fetches -/// stop retrying — a single attempt only. The fewer-retries mode frees +/// stop retrying: a single attempt only. The fewer-retries mode frees /// handler slots faster when the service is under load, at the cost of /// failing proposals whose first IPFS attempt has a transient error. pub(crate) const IPFS_DURESS_THRESHOLD: usize = 200; @@ -518,7 +525,7 @@ templates: // Act + Assert assert_eq!(client.max_attempts(), IPFS_MAX_ATTEMPTS); - // Right at the threshold still counts as below — the check is `>`. + // Right at the threshold still counts as below, because the check is `>`. inflight.store(IPFS_DURESS_THRESHOLD, Ordering::Relaxed); assert_eq!(client.max_attempts(), IPFS_MAX_ATTEMPTS); } diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index cfcc96696..3821d9826 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -1,7 +1,7 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -//! DIPS (Direct Indexer Payments) for The Graph. +//! DIPS (Direct Indexing Payments) for The Graph. //! //! This crate implements the indexer-side handling of RecurringCollectionAgreement (RCA) //! proposals. When a payer wants indexing services, the Dipper service creates and signs @@ -10,45 +10,57 @@ //! # Architecture //! //! ```text -//! Payer (user) ──deposits──> PaymentsEscrow contract -//! │ │ -//! │ authorizes signer │ escrow data indexed -//! ▼ ▼ -//! Dipper ───SignedRCA───> indexer-rs (this crate) -//! │ │ -//! │ │ validates & stores -//! │ ▼ -//! │ pending_rca_proposals table -//! │ │ -//! │ │ agent queries & decides -//! │ ▼ -//! └──────────────────> on-chain acceptance +//! AGREEMENT_MANAGER_ROLE (on-chain) ──indexed by──> indexing-payments subgraph +//! │ │ +//! │ granted to Dipper's signing key │ role holders read +//! ▼ ▼ +//! Dipper ──────────SignedRCA───────────────> indexer-rs (this crate) +//! │ +//! │ validates & stores +//! ▼ +//! pending_rca_proposals table +//! │ +//! │ agent queries & decides +//! ▼ +//! on-chain acceptance //! ``` //! //! # Validation Flow //! //! When an RCA arrives, this crate validates: -//! 1. **Service provider** - RCA is addressed to this indexer -//! 2. **Timestamps** - Deadline and end time haven't passed -//! 3. **Replay/idempotency** - Once the deterministic agreement id is derived, +//! 1. **Signer** - The EIP-712 signature recovers to an address that holds the +//! agreement-manager role; this runs before anything in the agreement is +//! validated. The role set is read from the indexing-payments subgraph on an +//! interval and cached rather than read from chain per proposal, so subgraph +//! outages and lag change the answer (see [`trusted_signers`]) +//! 2. **Service provider** - RCA is addressed to this indexer +//! 3. **Timestamps** - Deadline and end time haven't passed +//! 4. **Replay/idempotency** - Once the deterministic agreement id is derived, //! a re-sent proposal is resolved against the store before any IPFS fetch -//! 4. **IPFS manifest** - Subgraph deployment exists and is parseable -//! 5. **Network** - Subgraph's network is supported by this indexer -//! 6. **Pricing** - Offered price meets indexer's minimum +//! 5. **Capacity** - When a cap is configured, live agreements (pending or +//! accepted) from the last 24 hours must stay under it +//! 6. **Terms version** - The agreement metadata declares V1 terms +//! 7. **IPFS manifest** - Subgraph deployment exists and is parseable +//! 8. **Network** - Subgraph's network is supported by this indexer +//! 9. **Pricing** - Offered price meets indexer's minimum //! -//! Signature and signer-authorization checks are NOT performed here. With the -//! switch to offer-based authorization, the on-chain `acceptIndexingAgreement` -//! call verifies the signer (via either an ECDSA signature or a pre-stored -//! payer offer) when the indexer-agent submits the acceptance transaction. +//! Authorization is decided on the recovered signer, not on the payer address +//! carried in the proposal. On-chain offer existence is not checked here: the +//! offer does not exist yet at proposal time, so the contract enforces it when +//! the indexer-agent submits the `acceptIndexingAgreement` transaction. //! //! # Modules //! //! - [`server`] - gRPC server handling RCA proposals //! - [`store`] - Storage trait for RCA proposals //! - [`database`] - PostgreSQL implementation -//! - [`signers`] - Signer authorization via escrow accounts +//! - [`trusted_signers`] - Signer authorization against the on-chain role holders +//! - [`eip5267`] - EIP-5267 discovery of the RCA signing domain from RecurringCollector //! - [`ipfs`] - IPFS client for subgraph manifests //! - [`price`] - Minimum price enforcement +//! - [`inflight`] - Shared counter of in-flight proposal requests +//! - [`metrics`] - Prometheus metrics for the proposal path +//! - [`proto`] - Generated protobuf types for the gRPC service use std::sync::Arc; @@ -245,7 +257,8 @@ impl RecurringCollectionAgreement { impl SignedRecurringCollectionAgreement { /// Recover the EIP-712 signer of the RCA over the RecurringCollector domain. /// An empty, malformed, or non-recovering signature is rejected as - /// InvalidSignature. The recovered address is what PR B gates on. + /// InvalidSignature. The recovered address is what the trusted-signer check + /// is applied to, in `validate_and_create_rca`. pub fn recover_signer(&self, domain: &Eip712Domain) -> Result { if self.signature.is_empty() { return Err(DipsError::InvalidSignature("missing signature".to_string())); @@ -260,7 +273,7 @@ impl SignedRecurringCollectionAgreement { /// Validate proposal-time fields. /// /// Checks that the service provider matches the expected indexer - /// address. On-chain offer existence is NOT checked here — the offer + /// address. On-chain offer existence is NOT checked here: the offer /// does not exist yet at proposal time. The contract enforces offer /// existence when the indexer-agent calls `acceptIndexingAgreement`. pub fn validate(&self, expected_service_provider: &Address) -> Result<(), DipsError> { @@ -306,14 +319,10 @@ const CAPACITY_WINDOW: std::time::Duration = std::time::Duration::from_secs(24 * /// Validate and create a RecurringCollectionAgreement. /// -/// Performs validation: -/// - Service provider match -/// - Deadline and expiry checks -/// - Replay/idempotency check on the derived agreement id (before any IPFS fetch) -/// - IPFS manifest fetching and network validation -/// - Price minimum enforcement +/// Runs the 9 validation steps in the order the crate-level docs list them, from +/// the signer check through to the price minimum. /// -/// On-chain offer existence is NOT checked here — the offer doesn't exist +/// On-chain offer existence is NOT checked here: the offer doesn't exist /// yet at proposal time. The contract enforces it at `acceptIndexingAgreement`. /// /// Returns the agreement ID if successful, stores in database. @@ -697,7 +706,7 @@ mod test { /// Shared test vector with dipper (dipper-rpc/src/indexer.rs). /// Both repos must produce the same bytes16 for this input. /// If this test fails, the derivation has drifted from the on-chain - /// contract and/or from dipper -- cancellations and agreement + /// contract and/or from dipper: cancellations and agreement /// matching will break silently. #[test] fn test_derive_agreement_id_shared_vector() { diff --git a/crates/dips/src/metrics.rs b/crates/dips/src/metrics.rs index 026eecc75..218dbeae6 100644 --- a/crates/dips/src/metrics.rs +++ b/crates/dips/src/metrics.rs @@ -9,7 +9,7 @@ use std::sync::LazyLock; use prometheus::{register_counter_vec, register_int_gauge, CounterVec, IntGauge}; /// Incoming agreement proposals by outcome: `accepted`, `untrusted` (signer is -/// not a role holder), `transient` (couldn't verify -- sender should retry), or +/// not a role holder), `transient` (couldn't verify, so the sender should retry), or /// `rejected` (other validation failure). pub static PROPOSAL_OUTCOMES: LazyLock = LazyLock::new(|| { register_counter_vec!( diff --git a/crates/dips/src/registry.rs b/crates/dips/src/registry.rs index 2260c5d57..5fa40d9c3 100644 --- a/crates/dips/src/registry.rs +++ b/crates/dips/src/registry.rs @@ -1,17 +1,16 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -//! Test helpers for network registry validation. +//! Test helpers for the network registry. RCA validation never uses the registry to decide +//! whether a network is allowed (that comes from the indexer's `supported_networks` plus a +//! configured price); it reads the registry afterwards to resolve a chain ID for logging. //! -//! The Graph maintains a registry of supported networks. During RCA validation, -//! we check that the subgraph's network is in this registry (or in the indexer's -//! `additional_networks` config for custom/test networks). -//! -//! This module provides [`test_registry`] which returns a minimal registry -//! containing "mainnet" and "hardhat" for use in unit tests. +//! That describes how the registry is read per proposal, not how load-bearing it is at boot: +//! the service downloads it when DIPs starts and a failed download aborts DIPs initialisation. use graph_networks_registry::NetworksRegistry; +/// Minimal registry containing "mainnet" and "hardhat" for use in unit tests. pub fn test_registry() -> NetworksRegistry { use graph_networks_registry::{Network, NetworkType, Services}; diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 721455b16..cb80b42e6 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -11,30 +11,37 @@ //! ```text //! Dipper ──gRPC──> DipsServer::submit_agreement_proposal() //! │ -//! ├─ Version check (must be 2) -//! ├─ Size validation (non-empty, max 10KB) -//! ├─ Service-provider match -//! ├─ Timestamp validation (deadline, endsAt) -//! ├─ Replay/idempotency check (before any IPFS fetch) -//! ├─ IPFS manifest fetch -//! ├─ Network validation -//! ├─ Price validation +//! ├─ Request envelope version (must be 2) ─┐ answered with +//! ├─ Size validation (non-empty, max 10KB) ─┘ InvalidArgument +//! │ +//! ├─ validate_and_create_rca() runs the 9 checks the crate docs list //! │ //! └─> Store in pending_rca_proposals table //! │ //! └─> Return Accept/Reject //! ``` //! -//! Signature and signer-authorization checks are not performed here. The -//! on-chain `acceptIndexingAgreement` call verifies the signer (via either -//! an ECDSA signature or a pre-stored payer offer) when the indexer-agent -//! submits the acceptance transaction. +//! The ordered list of validation steps lives in the crate-level docs. This +//! module only adds the gRPC envelope handling around it. +//! +//! The signer is checked here: the EIP-712 signature recovers the signer's +//! address, which must hold the agreement-manager role. Authorization is never +//! decided on the `payer` address carried inside the proposal. That check runs +//! before anything in the agreement is validated, and any other address is +//! rejected with `RejectReason::SenderNotTrusted`. If the role set itself cannot +//! be read, the proposal is rejected with `RejectReason::IndexerUnavailable` +//! instead, telling the sender to retry rather than to try another indexer. //! //! # Response Behavior //! -//! Returns `Accept` if the RCA passes all validation and is stored successfully. -//! Returns `Reject` if any validation fails. This enables the Dipper to reassign -//! the indexing request to another indexer on rejection. +//! Returns `Accept` if the RCA passes all validation and is stored successfully, +//! and `Reject` if a check on the decoded agreement fails. This enables the +//! Dipper to reassign the indexing request to another indexer on rejection. +//! +//! The first 2 steps are the exception. A request whose envelope version is not +//! 2, or whose payload is empty or over 10KB, is answered with a gRPC +//! `InvalidArgument` status rather than a rejection, so the Dipper sees a +//! malformed call rather than an indexer declining the work. //! //! # Cancellation //! @@ -63,12 +70,11 @@ use crate::{ DipsError, }; -/// Context for DIPS server with all validation dependencies. -/// -/// Used for RCA validation: -/// - IPFS manifest fetching -/// - Price minimum enforcement -/// - Network registry lookups +/// Context for DIPS server with all validation dependencies: the trusted-signer +/// source, the proposal store, the IPFS fetcher and the price calculator, which +/// also holds the `supported_networks` list. A network is accepted only if it is +/// in that list AND has a configured price. See the crate-level docs for the +/// order the checks run in. #[derive(Debug, Clone)] pub struct DipsServerContext { /// RCA store (seconds-based RCA) @@ -77,9 +83,9 @@ pub struct DipsServerContext { pub ipfs_fetcher: Arc, /// Price calculator for validating minimum prices pub price_calculator: Arc, - /// Network registry for supported networks + /// Network registry, read only to resolve a chain ID for log context. pub registry: Arc, - /// Additional networks beyond the registry + /// Chain IDs for networks the registry doesn't cover, also log context only. pub additional_networks: Arc>, /// EIP-712 domain for recovering the RCA signer (RecurringCollector). pub rca_domain: Eip712Domain, @@ -91,12 +97,9 @@ pub struct DipsServerContext { /// DIPS server implementing RCA protocol. /// -/// Validates RecurringCollectionAgreement proposals before storage: -/// - Service-provider match -/// - IPFS manifest fetching and network validation -/// - Price minimum enforcement -/// -/// Returns Accept/Reject to enable Dipper reassignment on rejection. +/// Validates RecurringCollectionAgreement proposals before storage, in the order +/// the crate-level docs set out, and returns Accept/Reject so the Dipper can +/// reassign the request to another indexer on rejection. #[derive(Debug)] pub struct DipsServer { pub ctx: Arc, @@ -134,7 +137,7 @@ fn reject_reason_from_error(err: &DipsError) -> RejectReason { | DipsError::InvalidRca(_) => RejectReason::Unspecified, DipsError::SenderNotTrusted { .. } => RejectReason::SenderNotTrusted, // A store failure or an unverifiable role set means a valid proposal the - // indexer couldn't act on -- tell dipper it's transient so it retries. + // indexer couldn't act on: tell dipper it's transient so it retries. DipsError::TrustVerificationUnavailable(_) | DipsError::UnknownError(_) => { RejectReason::IndexerUnavailable } @@ -164,16 +167,11 @@ fn outcome_label(err: &DipsError) -> &'static str { impl IndexerDipsService for DipsServer { /// Submit an RCA proposal. /// - /// Validates: - /// - Version 2 only - /// - Service provider match - /// - IPFS manifest and network compatibility - /// - Price minimums + /// Runs the checks listed in the crate-level docs, in that order, starting + /// with the request envelope and the trusted-signer check. /// - /// On-chain offer existence is NOT checked — the offer doesn't exist yet + /// On-chain offer existence is NOT checked: the offer doesn't exist yet /// at proposal time. The contract enforces it at `acceptIndexingAgreement`. - /// - /// Returns Accept/Reject based on validation results. async fn submit_agreement_proposal( &self, request: Request, diff --git a/crates/dips/src/store.rs b/crates/dips/src/store.rs index 000892a09..b242bb4b6 100644 --- a/crates/dips/src/store.rs +++ b/crates/dips/src/store.rs @@ -49,8 +49,8 @@ pub trait RcaStore: Sync + Send + std::fmt::Debug { async fn lookup(&self, agreement_id: Uuid) -> Result, DipsError>; /// Count live agreements (status `pending` or `accepted`) within the trailing - /// `window`; rejected and expired proposals are excluded. The in-memory test - /// store has no status, so it ignores the window and counts every entry. + /// `window`; `rejected` and `completed` proposals are excluded. The in-memory + /// test store has no status, so it ignores the window and counts every entry. async fn count_since(&self, window: Duration) -> Result; /// Downcast to concrete type for testing. diff --git a/crates/dips/src/trusted_signers.rs b/crates/dips/src/trusted_signers.rs index 86a7f3a61..c33a8ff7d 100644 --- a/crates/dips/src/trusted_signers.rs +++ b/crates/dips/src/trusted_signers.rs @@ -370,8 +370,8 @@ mod subgraph { } /// What `verify_trusted` should do once it holds the freshest cache state. - /// Pulled out as a pure function so every branch -- including the fail-open - /// admit, which otherwise only fires on a rare race -- is unit-testable. + /// Pulled out as a pure function so every branch (including the fail-open + /// admit, which otherwise only fires on a rare race) is unit-testable. enum Decision { Trusted, Untrusted, @@ -413,7 +413,7 @@ mod subgraph { // A new or stale-cache signer: refresh on demand to catch a just-granted // manager, but skip the refetch when the last success is recent and still - // fresh -- bounds a distinct-unknown-signer flood to one fetch per window. + // fresh; that bounds a distinct-unknown-signer flood to one fetch per window. let recently_refreshed = { let cache = self.cache.read().unwrap(); cache.last_success.is_some_and(|t| { diff --git a/crates/service/src/routes/mod.rs b/crates/service/src/routes/mod.rs index b5db1ca5e..57e3044b7 100644 --- a/crates/service/src/routes/mod.rs +++ b/crates/service/src/routes/mod.rs @@ -7,15 +7,9 @@ //! //! ## Route Overview //! -//! | Route | Handler | Description | -//! |-------|---------|-------------| -//! | `POST /subgraphs/id/:id` | [`request_handler`] | Main query endpoint for paid queries | -//! | `GET /health/:deployment` | [`health`] | Deployment health status from graph-node | -//! | `GET /healthz` | [`healthz`] | Service dependency health check | -//! | `POST /status` | [`status`] | Indexing status queries (allowlisted fields) | -//! | `GET /cost` | [`cost::cost_handler`] | Cost model information | -//! | `POST /network` | [`static_subgraph_request_handler`] | Network subgraph proxy | -//! | `POST /escrow` | [`static_subgraph_request_handler`] | Escrow subgraph proxy | +//! The route list lives in `docs/Routes.md`, and the registrations themselves in +//! `crate::service::router`. Keeping it in one place stops this list drifting from +//! what the service actually serves. //! //! ## Query Flow //! diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 80c66f929..259514d5f 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -133,7 +133,7 @@ pub async fn run() -> anyhow::Result<()> { // (graphprotocol/indexer, TypeScript) owns schema migrations to avoid // conflicting DDL from two processes sharing one database. The SQL files // in indexer-rs/migrations/ exist for local development (`sqlx migrate - // run`) and tests only -- they are not executed by any production binary. + // run`) and tests only; they are not executed by any production binary. // // For new tables (e.g. pending_rca_proposals), a corresponding migration // must be added to the agent before the feature ships to production. diff --git a/docs/Metrics.md b/docs/Metrics.md index b9aa775f5..750458194 100644 --- a/docs/Metrics.md +++ b/docs/Metrics.md @@ -33,6 +33,23 @@ | `indexer_cost_model_batch_failed_total` | Total number of failed batch `cost_model` queries. | - | | `indexer_cost_model_batch_invalid_total` | Total number of batch `cost_model` queries with invalid deployment IDs. | - | +### DIPs + +Only 1 of these 4 carries the `indexer_` prefix, so a dashboard or alert selector has to cover both spellings. + +| Metric Name | Description | Labels | +|------------------------------------------------------|---------------------------------------------------------------------------------------------|-----------------| +| `indexer_dips_enabled` | The outcome of DIPs startup: 1 when initialization succeeded and the gRPC server was started, 0 when it failed. | - | +| `dips_proposal_outcomes_total` | Incoming DIPs agreement proposals by outcome. | outcome | +| `dips_agreement_manager_role_holders` | Agreement-manager role holders in the last successful subgraph fetch. | - | +| `dips_agreement_manager_role_last_refresh_timestamp` | Unix time of the last successful agreement-manager role fetch. | - | + +`indexer_dips_enabled` reports how startup went, not whether DIPs is still running. It is set once, to 1 when initialization succeeded and the gRPC server was started or to 0 when DIPs is configured but initialization failed, and never updated afterwards. A listener that later fails to bind or exits therefore leaves the gauge sitting at 1, and the `DIPs gRPC server error` log line is what catches that. When `[dips]` is absent the metric is not exported at all. + +The `outcome` label on `dips_proposal_outcomes_total` takes 4 values: `accepted`; `untrusted`, meaning the signer does not hold the agreement-manager role; `transient`, meaning the role list could not be read or the database write failed, so the sender should retry; and `rejected` for any other validation failure. Counting starts after the gRPC argument checks, so a proposal with the wrong envelope version, an empty payload or a payload over 10,000 bytes never appears here. + +Both role metrics are worth alerting on. `dips_agreement_manager_role_holders == 0` means no address is authorised to send you proposals, so every proposal is rejected. For the refresh timestamp, alert when `time() - dips_agreement_manager_role_last_refresh_timestamp` exceeds `role_refresh_interval_secs` (default 86,400) plus `role_failopen_grace_secs` (default 3,600), the point past which the cached role list has expired and all proposals are rejected. Neither metric appears until the first fetch has succeeded, so pair that expression with `absent(dips_agreement_manager_role_last_refresh_timestamp)` to catch a fetch that has never succeeded. + --- ## Tap Agent Metrics diff --git a/docs/Routes.md b/docs/Routes.md index 7d377a592..20886285d 100644 --- a/docs/Routes.md +++ b/docs/Routes.md @@ -2,6 +2,10 @@ This section lists the routes currently exposed by the Subgraph Service. Each route includes a brief description of its purpose and any requirements (e.g., tokens) for access. +Two listeners sit outside the HTTP routes below. Prometheus metrics are served on the `[metrics]` port (default 7300) at `/metrics`. When the `[dips]` section is configured, the service also starts a separate gRPC server on its own host and port (default `0.0.0.0:7601`), which is where the dipper, the service that offers indexing agreements, sends its proposals. That gRPC server only starts if DIPs initialisation succeeds; if it fails, the service carries on serving queries without DIPs. + +`/dips/info` is how you tell which of those happened. It answers 200 with the pricing body when DIPs started, 503 with `{"status": "unavailable"}` when `[dips]` is configured but initialisation failed, and is not registered at all when `[dips]` is absent. + ## Public Routes | Route | Description | @@ -10,26 +14,25 @@ This section lists the routes currently exposed by the Subgraph Service. Each ro | `/info` | Displays the operator's public address. | | `/healthz` | Reports service dependency health (database and graph-node). | | `/version` | Provides the current version of `indexer-service-rs` and its dependencies. | +| `/dips/info` | Reports the configured DIPs prices. See the note above on its 3 responses. | ## Token-Protected Routes | Route | Description | |-------------------------|----------------------------------------------------------------------------------------------| -| `/escrow` | Routes queries to the escrow subgraph. Requires a valid token. | | `/network` | Routes queries to the network subgraph. Requires a valid token. | ## GraphQL API Routes | Route | Description | |-------------------------|----------------------------------------------------------------------------------------------| -| `/dips` | Provides access to the Dips GraphQL API. | | `/cost` | Provides access to the Cost Model GraphQL API. | ## Subgraph Routes | Route | Description | |--------------------------------------|----------------------------------------------------------------------------------------------| -| `/subgraph/health/{id}` | Retrieves the health state of a specified subgraph using its ID. | +| `/subgraph/health/{deployment_id}` | Retrieves the health state of a specified subgraph using its ID. | | `/subgraphs/id/{id}` | Routes a query to a specific subgraph using its ID. Requires a receipt or valid token. | ## Node Status Route @@ -43,4 +46,4 @@ This section lists the routes currently exposed by the Subgraph Service. Each ro ## Note You can always view the latest complete and up-to-date list of routes in the source code: -[Service Router Implementation](./crates/service/src/service/router.rs) +[Service Router Implementation](../crates/service/src/service/router.rs) From 9194dbbebcdc253aaa4146cdf7931d84166555c0 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:05:48 +1000 Subject: [PATCH 31/31] fix: listen for DIPs proposals on the port the payer dials (#1076) * docs(dips): correct the crate header that denied signer checks happen The header told readers that signature and signer-authorization checks were not performed by this crate, when the code recovers the EIP-712 signer and requires it to hold the agreement manager role before anything else. Also redraws the diagram and lists the modules that exist. * docs(grpc): record that the server authorises the signer before any work The module said signer authorization was left to the later on-chain acceptance call, when it runs here first. Notes that a bad request version or payload size returns a gRPC error rather than a rejection, and points the 3 partial validation checklists at the single ordered one. * docs(registry): drop the network check the registry never performed The module claimed proposals were validated against the registry of known blockchains. Nothing checks that: a network is accepted purely on the operator's supported_networks list plus a price, and the registry only supplies a chain id for log lines, though it must download at boot. * docs(config): name the metric the service actually publishes This comment is the one place an operator is told how to notice that DIPs failed to start and the service is running without it. It named a metric that does not exist, so an alert written against it would match nothing and stay silent for the same reason a healthy indexer does. * docs(networks): explain what really decides which networks are accepted The example told operators that additional_networks let proposals for custom chains through. It does not: a network is accepted only when it is in supported_networks AND has a price, and that map just fills a chain id in rejection logs. Also replaces double hyphens used as dashes. * docs(store): document the fourth status a proposal row can hold The lifecycle stopped at accepted or rejected. A proposal can also reach completed, which is the state that frees capacity: the optional cap on new agreements per 24 hours counts only pending and accepted rows, so a finished agreement stops holding a slot. * docs(ipfs): note the single-attempt mode the retry policy switches to The documented policy is 4 attempts with growing pauses and a 190 second worst case. Above 200 requests in flight a fetch gets 1 attempt and no retries, so a proposal whose only try hits a brief error is rejected rather than retried, and both published timings stop applying. * docs(routes): replace 2 routes that do not exist with the ones that do The list offered a DIPs GraphQL API that was never built and an escrow route the service stopped serving, while omitting /dips/info and the separate gRPC listener the dipper sends proposals to. A second copy of the same list lived in the code and is now a pointer, so the 2 cannot disagree. * docs(metrics): document the 4 DIPs metrics operators can alert on Nothing here covered DIPs, though the configuration example tells operators to watch one of these to notice a silent failure. Each entry now carries the threshold or expression to alert on, and warns that the startup gauge reports initialisation rather than continued liveness. * docs(dips): centralize the validation-step list in the crate docs The same ordered list of checks a payment proposal must pass was written out 3 times, so 2 copies could quietly fall out of step with the code. The crate front page now holds the only copy, and the other 2 places point at it. * docs(database): attribute the agent-side status steps to their own repo The lifecycle list here describes a shared database table, but only the first step belongs to this code. A note now says the indexer-agent owns the rest and ships from a different repository, so a reader does not take those steps for behaviour they can change here. * docs(metrics): move the DIPs alerting guidance out of the table cells In the metrics reference, 2 of the 4 rows documenting Direct Indexer Payments ran to 3 or 4 sentences of operator advice, while every other table in the file keeps cells to a single line. Those cells are now one-liners and the same advice, unchanged, reads as prose under the heading. * docs(routes): drop the anecdote about a route that no longer exists The note explaining why the service keeps a single route list ended with the story of one stale entry, which tells a new reader nothing about the code in front of them. * docs(comments): replace 4 double hyphens with real punctuation In 4 code comments a double hyphen stood in for a dash; each now uses the comma, semicolon or brackets that the sentence actually needs. * docs(dips): correct the expansion of DIPs to Direct Indexing Payments The crate docs and the example config expanded the DIPs acronym as Direct Indexer Payments. The Graph's proposal (GIP-0081) names the mechanism Indexing Payments, and public Edge & Node updates expand DIPs as Direct Indexing Payments, so the 2 comments now match that. * fix(dips): listen on the port the payer actually dials Indexing agreement proposals arrive over gRPC from a payer that dials port 7602 on the URL an indexer registers. The server defaulted to 7601, so an indexer on the defaults received nothing and saw no error. Default to 7602, document it, and warn when the bind address is unreachable. --- crates/config/maximal-config-example.toml | 7 ++++--- crates/config/src/config.rs | 11 ++++++++++- crates/service/src/service.rs | 11 +++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index 4f4f5c977..9ed4701e8 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -203,10 +203,11 @@ max_receipts_per_request = 10000 # (check the /dips/info endpoint and the indexer_dips_enabled metric). Per-field consequences below. [dips] -# Address/port the DIPs gRPC server binds to, where the dipper sends proposals; needs ingress -# reachable by the dipper. port is a quoted string. Defaults: "0.0.0.0" / "7601". +# Address/port the DIPs gRPC server binds to. The dipper dials port 7602 on the URL you +# registered on chain, so that port needs public ingress; move it and proposals silently +# stop arriving. port is a quoted string. Defaults: "0.0.0.0" / "7602". host = "0.0.0.0" -port = "7601" +port = "7602" # Networks you support indexing. REQUIRED to accept anything: list at least one here AND set its # price under [dips.min_grt_per_30_days]; empty = ALL proposals rejected (not "accept all"). diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 39f5bf42a..2c78d9dda 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -712,6 +712,9 @@ fn default_allocation_reconciliation_interval_secs() -> Duration { pub struct DipsConfig { /// Network interface the DIPs gRPC server binds to (e.g. `"0.0.0.0"` for all interfaces). pub host: String, + /// Port the DIPs gRPC server binds to. The payer dials this port on the URL the indexer + /// registered on chain, so changing it makes this indexer unreachable for DIPs unless + /// something forwards the default port to it. pub port: String, /// Networks this indexer explicitly supports. A network is accepted only when it /// appears here AND has a `min_grt_per_30_days` entry; listing it without a price @@ -750,7 +753,7 @@ impl Default for DipsConfig { fn default() -> Self { DipsConfig { host: "0.0.0.0".to_string(), - port: "7601".to_string(), + port: "7602".to_string(), supported_networks: HashSet::new(), min_grt_per_30_days: BTreeMap::new(), min_grt_per_billion_entities_per_30_days: GRT::ZERO, @@ -1505,5 +1508,11 @@ mod tests { crate::GRT::from_grt("200"), "min_grt_per_billion_entities_per_30_days should be set in maximal config" ); + assert_eq!( + dips.port, + super::DipsConfig::default().port, + "the example config must document the default DIPs port: the payer dials the \ + default, so an indexer copying a stale example silently receives no proposals" + ); } } diff --git a/crates/service/src/service.rs b/crates/service/src/service.rs index 259514d5f..1607b130d 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -341,6 +341,17 @@ async fn start_dips( .parse() .with_context(|| format!("Invalid DIPS host:port '{host}:{port}'"))?; + // Warn rather than fail: a loopback bind is a legitimate choice when a reverse proxy + // forwards the port to it, and the service cannot tell whether one is in front of it. + if addr.ip().is_loopback() { + tracing::warn!( + address = %addr, + "DIPs gRPC server is bound to a loopback address, which the payer cannot reach \ + directly. No agreement proposals will arrive unless a proxy forwards this port \ + to it, and the failure is silent on this side." + ); + } + // Shared counter of in-flight gRPC requests. The IPFS client reads // it to decide whether to use the full retry budget or fall back to // a single attempt when the service is under load.