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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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}'"))?;