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/ 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..e0f417adb 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -187,19 +187,128 @@ 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" -allowed_payers = ["0x3333333333333333333333333333333333333333"] +recurring_collector = "0x4444444444444444444444444444444444444444" + +# 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 = [] -price_per_entity = "1000" +# 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) +# +# 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.price_per_epoch] -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 8b5b57a17..f313d3db0 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_"; @@ -631,16 +628,25 @@ fn default_allocation_reconciliation_interval_secs() -> Duration { Duration::from_secs(300) } +/// DIPs configuration. +/// +/// Validates RCA proposals (signature, IPFS manifest, network, pricing) +/// before storing. The indexer agent queries pending proposals from the +/// database and decides on-chain acceptance. #[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, + /// 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 billion entities per 30 days. + pub min_grt_per_billion_entities_per_30_days: GRT, + pub additional_networks: BTreeMap, } impl Default for DipsConfig { @@ -648,10 +654,11 @@ 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, + supported_networks: HashSet::new(), + min_grt_per_30_days: BTreeMap::new(), + min_grt_per_billion_entities_per_30_days: GRT::ZERO, + additional_networks: BTreeMap::new(), } } } @@ -738,17 +745,12 @@ pub struct HorizonConfig { #[cfg(test)] mod tests { - use std::{ - collections::{BTreeMap, HashMap, 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}; @@ -774,18 +776,10 @@ 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![ - ("mainnet".to_string(), U256::from(100)), - ("hardhat".to_string(), U256::from(100)), - ]), - additional_networks: HashMap::from([( - "eip155:1337".to_string(), - "hardhat".to_string(), - )]), + recurring_collector: Address( + FixedBytes::<20>::from_str("0x4444444444444444444444444444444444444444").unwrap(), + ), + min_grt_per_billion_entities_per_30_days: crate::GRT::from_grt("200"), ..Default::default() }); @@ -1286,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/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/proto/indexer.proto b/crates/dips/proto/indexer.proto index dc97e82e8..0521f5a26 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,23 @@ 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 (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. +} + /** * A request to cancel an _indexing agreement_. * diff --git a/crates/dips/src/database.rs b/crates/dips/src/database.rs index fdaf1f66e..dcff22331 100644 --- a/crates/dips/src/database.rs +++ b/crates/dips/src/database.rs @@ -1,366 +1,77 @@ // 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" +//! +//! # 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; 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(); + // ON CONFLICT DO NOTHING makes this idempotent: retries with the same + // agreement_id succeed without error, enabling safe Dipper retries. 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()) + ON CONFLICT (id) DO NOTHING", ) - .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..57c439a32 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: 10s, 20s, 40s + 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..5fe6f912b 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::{b256, keccak256, ruint::aliases::U256, ChainId, Signature, Uint, B256}, signers::SignerSync, sol, sol_types::{eip712_domain, Eip712Domain, SolStruct, SolValue}, @@ -25,27 +77,32 @@ 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 +/// Protocol version (seconds-based RCA) +pub const PROTOCOL_VERSION: u64 = 2; -/// Create an EIP-712 domain given a chain ID and dispute manager address. -pub fn dips_agreement_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", - version: "0", + name: "RecurringCollector", + version: "1", chain_id: chain_id, - salt: EIP712_DOMAIN_SALT, + verifying_contract: recurring_collector, } } +/// EIP-712 domain salt for DIPs-specific messages. +const EIP712_DOMAIN_SALT: B256 = + b256!("a070ffb1cd7af433c73e0d016c7c4ce31dc1ec7366a3f5d20cfa22a80391e549"); + +/// Create an EIP-712 domain for cancellation requests. +/// +/// Used for signing `CancellationRequest` messages. pub fn dips_cancellation_eip712_domain(chain_id: ChainId) -> Eip712Domain { eip712_domain! { name: "Graph Protocol Indexing Agreement Cancellation", @@ -55,122 +112,125 @@ pub fn dips_cancellation_eip712_domain(chain_id: ChainId) -> Eip712Domain { } } -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, - } -} - 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. + /// The agreement ID is derived on-chain via + /// `bytes16(keccak256(abi.encode(payer, dataService, serviceProvider, deadline, nonce)))`. #[derive(Debug, PartialEq)] - struct IndexingAgreementVoucher { - // must be unique for each indexer/gateway pair - bytes16 agreement_id; - // should coincide with signer of this voucher - 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 + struct RecurringCollectionAgreement { uint64 deadline; + uint64 endsAt; + address payer; + address dataService; + address serviceProvider; + uint256 maxInitialTokens; + uint256 maxOngoingTokensPerSecond; + uint32 minSecondsPerCollection; + uint32 maxSecondsPerCollection; + uint256 nonce; 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 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 { - // 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 +240,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 +255,251 @@ 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; + if deadline < now { + return Err(DipsError::DeadlineExpired { deadline, now }); + } + + // Validate agreement hasn't already expired + let ends_at: u64 = signed_rca.agreement.endsAt; + if ends_at < now { + return Err(DipsError::AgreementExpired { ends_at, now }); + } + + // Derive agreement ID deterministically from the RCA fields + let agreement_id = derive_agreement_id(&signed_rca.agreement); + + // 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) - .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) + // Store the raw signed RCA bytes + rca_store + .store_rca(agreement_id, rca_bytes, PROTOCOL_VERSION) .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 +508,639 @@ pub async fn validate_and_cancel_agreement( #[cfg(test)] mod test { - use std::{ - collections::HashMap, - time::{Duration, SystemTime, UNIX_EPOCH}, - }; + use std::collections::{BTreeMap, HashSet}; + use std::sync::Arc; - use indexer_monitor::EscrowAccounts; - use rand::{distr::Alphanumeric, Rng}; + use crate::{ + derive_agreement_id, + ipfs::{FailingIpfsFetcher, MockIpfsFetcher}, + price::PriceCalculator, + rca_eip712_domain, + server::DipsServerContext, + signers::{NoopSignerValidator, RejectingSignerValidator}, + store::{FailingRcaStore, InMemoryRcaStore}, + AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, + RecurringCollectionAgreement, + }; use thegraph_core::alloy::{ - primitives::{Address, ChainId, FixedBytes, U256}, + primitives::{keccak256, 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, - }; + const CHAIN_ID: u64 = 42161; // Arbitrum One - /// The Arbitrum One (mainnet) chain ID (eip155). - const CHAIN_ID_ARBITRUM_ONE: ChainId = 0xa4b1; // 42161 + 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( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + signer_validator: Arc::new(NoopSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }) + } - #[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_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(); - - assert_eq!(voucher, stored_agreement.voucher); - assert!(!stored_agreement.cancelled); - Ok(()) + RecurringCollectionAgreement { + deadline: u64::MAX, + endsAt: u64::MAX, + payer, + dataService: Address::ZERO, + serviceProvider: service_provider, + maxInitialTokens: U256::from(1000), + maxOngoingTokensPerSecond: U256::from(100), + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + nonce: U256::from(1), + metadata: metadata.abi_encode().into(), + } } #[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, + 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 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 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]); + } - 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() + #[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 = derive_agreement_id(&rca); + + let domain = rca_eip712_domain(CHAIN_ID, recurring_collector); + let signed_rca = rca.sign(&domain, payer_signer).unwrap(); + let rca_bytes = signed_rca.abi_encode(); + + let ctx = create_test_context(); + let 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); + } + + #[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), ); - assert!(signed - .validate( - &ctx.signer_validator, - &domain, - &payee_addr, - vec![payer_addr] - ) - .is_ok()); + + 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::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 mut signed = voucher.sign(&domain, payer).unwrap(); - signed.voucher.service = Address::repeat_byte(9); + 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!( - 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( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + signer_validator: Arc::new(NoopSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); - 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 { + deadline: u64::MAX, + endsAt: u64::MAX, + payer, + dataService: Address::ZERO, + serviceProvider: service_provider, + maxInitialTokens: U256::from(1000), + maxOngoingTokensPerSecond: U256::from(100), + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + nonce: U256::from(1), + 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 { + deadline: 1, // 1 second after epoch - definitely in the past + endsAt: u64::MAX, + payer, + dataService: Address::ZERO, + serviceProvider: service_provider, + maxInitialTokens: U256::from(1000), + maxOngoingTokensPerSecond: U256::from(100), + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + nonce: U256::from(1), + 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 { + deadline: u64::MAX, + endsAt: 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, + nonce: U256::from(1), + 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( + HashSet::from(["mainnet".to_string()]), + 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( + HashSet::from(["mainnet".to_string()]), + 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( + HashSet::from(["mainnet".to_string()]), + 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; + 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 + ); + } - 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:?}"), - } - } + #[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( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + signer_validator: Arc::new(NoopSignerValidator), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + // 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 + ); + } - Ok(()) + // ========================================================================= + // 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..982ee2836 100644 --- a/crates/dips/src/price.rs +++ b/crates/dips/src/price.rs @@ -1,43 +1,209 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::collections::BTreeMap; +//! 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. +//! +//! Networks must also be in `supported_networks` to accept proposals. + +use std::collections::{BTreeMap, HashSet}; use thegraph_core::alloy::primitives::U256; #[derive(Debug, Default)] pub struct PriceCalculator { - base_price_per_epoch: BTreeMap, - price_per_entity: U256, + supported_networks: HashSet, + 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( + supported_networks: HashSet, + tokens_per_second: BTreeMap, + tokens_per_entity_per_second: U256, + ) -> Self { Self { - base_price_per_epoch, - price_per_entity, + supported_networks, + 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), + 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), } } - pub fn is_supported(&self, chain_id: &str) -> bool { - self.get_minimum_price(chain_id).is_some() + /// 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.supported_networks.contains(network) && self.tokens_per_second.contains_key(network) } - 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 { + if !self.supported_networks.contains(network) { + return None; + } + 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( + HashSet::from(["mainnet".to_string()]), + 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( + HashSet::from(["mainnet".to_string()]), + 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( + HashSet::from(["mainnet".to_string(), "arbitrum-one".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")); + } + + #[test] + 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), + ); + + // Act & Assert + assert!(!calculator.is_supported("optimism")); + assert!(!calculator.is_supported("")); + } + + #[test] + fn test_entity_price() { + // Arrange + let calculator = PriceCalculator::new(HashSet::new(), BTreeMap::new(), U256::from(12345)); + + // Act + let price = calculator.entity_price(); + + // Assert + assert_eq!(price, U256::from(12345)); + } + + #[test] + fn test_empty_config() { + // Arrange + let calculator = PriceCalculator::new(HashSet::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/graphprotocol.indexer.dips.rs b/crates/dips/src/proto/graphprotocol.indexer.dips.rs index 0f4f2d940..5202021bb 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,82 @@ 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 (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. + /// + /// 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", + 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. + 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), + "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, + } + } +} /// Generated client implementations. pub mod indexer_dips_service_client { #![allow( 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..caef201c8 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -1,99 +1,127 @@ // 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::{ indexer_dips_service_server::IndexerDipsService, CancelAgreementRequest, - CancelAgreementResponse, ProposalResponse, SubmitAgreementProposalRequest, + CancelAgreementResponse, ProposalResponse, RejectReason, SubmitAgreementProposalRequest, SubmitAgreementProposalResponse, }, signers::SignerValidator, - store::AgreementStore, - validate_and_cancel_agreement, validate_and_create_agreement, DipsError, PROTOCOL_VERSION, + store::RcaStore, + DipsError, }; -#[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, +} + +/// 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, + 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, + } } #[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 +131,339 @@ 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")); } - // 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( + if signed_voucher.len() > 10_000 { + return Err(Status::invalid_argument( + "signed_voucher exceeds maximum size of 10KB", + )); + } + + // 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(), + reject_reason: RejectReason::Unspecified.into(), + })) + } + Err(e) => { + 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(), + })) + } } } - /// * - /// 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", + )) + } +} - if version != 1 { - return Err(Status::invalid_argument("invalid version")); +#[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, 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), + )), + 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(); + + // Assert + 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 + assert_eq!(reason, RejectReason::UnsupportedNetwork); + } + + #[test] + fn test_reject_reason_invalid_signature() { + // Arrange + let err = DipsError::InvalidSignature("bad signature".to_string()); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::Other); + } + + #[test] + fn test_reject_reason_signer_not_authorised() { + // Arrange + let err = DipsError::SignerNotAuthorised(Address::ZERO); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::SignerNotAuthorised); + } + + #[test] + fn test_reject_reason_deadline_expired() { + // Arrange + let err = DipsError::DeadlineExpired { + deadline: 1000, + now: 2000, + }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::DeadlineExpired); + } + + #[test] + fn test_reject_reason_abi_decoding() { + // Arrange + let err = DipsError::AbiDecoding("invalid bytes".to_string()); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::Other); + } + + #[test] + fn test_reject_reason_agreement_expired() { + // Arrange + 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); - Ok(tonic::Response::new(CancelAgreementResponse {})) + // Assert + assert_eq!(reason, RejectReason::UnsupportedMetadataVersion); } } diff --git a/crates/dips/src/signers.rs b/crates/dips/src/signers.rs index b43d098b2..f70b283f4 100644 --- a/crates/dips/src/signers.rs +++ b/crates/dips/src/signers.rs @@ -1,66 +1,139 @@ // 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; -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 async fn mock(accounts: EscrowAccounts) -> Self { - use std::time::Duration; + 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) + } + } - let watcher = indexer_watcher::new_watcher(Duration::from_secs(100), move || { - let accounts = accounts.clone(); + impl SignerValidator for EscrowSignerValidator { + fn validate(&self, payer: &Address, signer: &Address) -> Result<(), anyhow::Error> { + let signers = self.watcher.borrow().get_signers_for_sender(payer); - async move { Ok(accounts) } - }) - .await - .unwrap(); + if !signers.contains(signer) { + return Err(anyhow!("Signer is not a valid signer for the sender")); + } - Self::new(watcher) + Ok(()) + } } } -impl SignerValidator for EscrowSignerValidator { - fn validate(&self, payer: &Address, signer: &Address) -> Result<(), anyhow::Error> { - let signers = self.watcher.borrow().get_signers_for_sender(payer); +#[cfg(feature = "db")] +pub use escrow_validator::EscrowSignerValidator; - if !signers.contains(signer) { - return Err(anyhow!("Signer is not a valid signer for the sender")); - } +#[derive(Debug)] +pub struct NoopSignerValidator; +impl SignerValidator for NoopSignerValidator { + fn validate(&self, _payer: &Address, _signer: &Address) -> Result<(), anyhow::Error> { Ok(()) } } +/// Test validator that always rejects signers. #[derive(Debug)] -pub struct NoopSignerValidator; +pub struct RejectingSignerValidator; -impl SignerValidator for NoopSignerValidator { +impl SignerValidator for RejectingSignerValidator { fn validate(&self, _payer: &Address, _signer: &Address) -> Result<(), anyhow::Error> { - Ok(()) + Err(anyhow!("Signer not authorized (test validator)")) } } #[cfg(test)] mod test { - use std::{collections::HashMap, time::Duration}; + 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; @@ -68,20 +141,60 @@ mod test { use crate::signers::SignerValidator; #[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_authorized_signer() { + // Arrange + let payer = Address::ZERO; + let authorized_signer = Address::from_slice(&[1u8; 20]); + let (_tx, watcher) = tokio::sync::watch::channel(EscrowAccounts::new( + HashMap::default(), + HashMap::from_iter(vec![(payer, vec![authorized_signer])]), + )); + let validator = super::EscrowSignerValidator::new(watcher); + // Act & Assert + assert!( + validator.validate(&payer, &authorized_signer).is_ok(), + "Authorized signer should be accepted" + ); + } + + #[tokio::test] + async fn test_escrow_validator_unauthorized_signer() { + // Arrange + let payer = Address::ZERO; + let authorized_signer = Address::from_slice(&[1u8; 20]); + let unauthorized_signer = Address::from_slice(&[2u8; 20]); + let (_tx, watcher) = tokio::sync::watch::channel(EscrowAccounts::new( + HashMap::default(), + HashMap::from_iter(vec![(payer, vec![authorized_signer])]), + )); + let validator = super::EscrowSignerValidator::new(watcher); + + // Act + let result = validator.validate(&payer, &unauthorized_signer); + + // Assert + assert!(result.is_err(), "Unauthorized signer should be rejected"); + } + + #[tokio::test] + async fn test_escrow_validator_payer_not_signer() { + // Arrange - payer authorizes someone else, not themselves + let payer = Address::ZERO; + let other_signer = Address::from_slice(&[1u8; 20]); + let (_tx, watcher) = tokio::sync::watch::channel(EscrowAccounts::new( + HashMap::default(), + HashMap::from_iter(vec![(payer, vec![other_signer])]), + )); let validator = super::EscrowSignerValidator::new(watcher); - 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" + ); } } diff --git a/crates/dips/src/store.rs b/crates/dips/src/store.rs index 1a987732e..d5f3da040 100644 --- a/crates/dips/src/store.rs +++ b/crates/dips/src/store.rs @@ -1,106 +1,193 @@ // 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). + /// + /// # 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: 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); - + 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(()) } - 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_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 + ); + } + + #[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]; - agreement.cancelled = true; + // 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; - let mut write_lock = self - .data - .try_write() - .map_err(|e| DipsError::UnknownError(e.into()))?; - write_lock.insert(id, agreement); + // 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"); - Ok(id) + let data = store.data.read().await; + assert_eq!(data.len(), 1, "Duplicate should not create second entry"); + assert_eq!(data[0].0, id); } } diff --git a/crates/service/src/routes/dips_info.rs b/crates/service/src/routes/dips_info.rs new file mode 100644 index 000000000..54666c340 --- /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_billion_entities_per_30_days: String, +} + +#[derive(Serialize)] +pub struct DipsInfoPricing { + pub min_grt_per_30_days: BTreeMap, + pub min_grt_per_billion_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_billion_entities_per_30_days: state + .min_grt_per_billion_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 30e48bfdf..cdca73525 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,20 +18,19 @@ 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, U256}; 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, + cli::Cli, constants::HTTP_CLIENT_TIMEOUT, database, metrics::serve_metrics, + routes::DipsInfoState, }; mod release; @@ -41,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, @@ -81,12 +100,16 @@ pub async fn run() -> 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; @@ -106,9 +129,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) => { @@ -159,6 +179,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_billion_entities_per_30_days: format_grt( + dips.min_grt_per_billion_entities_per_30_days.wei(), + ), + }); + let router = ServiceRouter::builder() .database(database.clone()) .domain_separator_v2(domain_separator_v2.clone()) @@ -171,7 +203,8 @@ 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()) + .maybe_dips_info(dips_info_state) .build(); serve_metrics(config.metrics.get_socket_addr()); @@ -183,79 +216,100 @@ 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, + supported_networks, + min_grt_per_30_days, + min_grt_per_billion_entities_per_30_days, 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 supported_networks.is_empty() { + tracing::warn!( + "DIPS enabled but no networks in dips.supported_networks. \ + All proposals will be rejected." + ); + } + 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")?; + // Convert GRT/30days to wei/second for protocol compatibility. + // Use ceiling division to protect indexers: configured minimums round UP, + // ensuring indexers never accept less than their stated minimum. + // 30 days = 2,592,000 seconds + const SECONDS_PER_30_DAYS: u128 = 30 * 24 * 60 * 60; + let tokens_per_second = min_grt_per_30_days + .iter() + .map(|(network, grt)| { + let wei_per_second = grt.wei().div_ceil(SECONDS_PER_30_DAYS); + (network.clone(), U256::from(wei_per_second)) + }) + .collect(); + + // Entity pricing: config is per-billion-entities, convert to per-entity. + // Ceiling division protects indexer from precision loss. + let entity_divisor = SECONDS_PER_30_DAYS * 1_000_000_000; + let tokens_per_entity_per_second = U256::from( + min_grt_per_billion_entities_per_30_days + .wei() + .div_ceil(entity_divisor), + ); - 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( + supported_networks.clone(), + tokens_per_second, + tokens_per_entity_per_second, + )), + signer_validator: Arc::new(EscrowSignerValidator::new(v2_watcher.clone())), + registry, additional_networks: Arc::new(additional_networks.clone()), - }; + }); - 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; }); } @@ -348,3 +402,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) 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 diff --git a/migrations/20260302000000_dips_pending_proposals.down.sql b/migrations/20260302000000_dips_pending_proposals.down.sql new file mode 100644 index 000000000..a5a0bf4a4 --- /dev/null +++ b/migrations/20260302000000_dips_pending_proposals.down.sql @@ -0,0 +1,3 @@ +-- Rollback DIPS migration + +DROP TABLE IF EXISTS pending_rca_proposals; diff --git a/migrations/20260302000000_dips_pending_proposals.up.sql b/migrations/20260302000000_dips_pending_proposals.up.sql new file mode 100644 index 000000000..05fc308f8 --- /dev/null +++ b/migrations/20260302000000_dips_pending_proposals.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);