diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c12e62b..4db0edb1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,23 +28,103 @@ jobs: steps: - name: Release please id: release-please - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 with: token: ${{ secrets.GITHUB_TOKEN }} - builds-linux: + prepare: runs-on: ubuntu-latest - needs: release-please - if: always() && (needs.release-please.result == 'success' || needs.release-please.result == 'skipped') + outputs: + targets: ${{ steps.set.outputs.targets }} + steps: + - id: set + run: echo 'targets=["indexer-service-rs","indexer-tap-agent"]' >> $GITHUB_OUTPUT + + build: + name: Build ${{ matrix.target }} (${{ matrix.platform }}) + needs: [prepare, release-please] + if: always() && needs.prepare.result == 'success' && (needs.release-please.result == 'success' || needs.release-please.result == 'skipped') strategy: + fail-fast: false matrix: - target: [indexer-service-rs, indexer-tap-agent] + target: ${{ fromJSON(needs.prepare.outputs.targets) }} + platform: [linux/amd64, linux/arm64] + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} permissions: packages: write steps: + - name: Prepare platform pair + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to the Container registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker labels + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ env.REGISTRY }}/${{ matrix.target }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ./ + file: Dockerfile.${{ matrix.target }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.target }}-${{ env.PLATFORM_PAIR }} + cache-to: type=gha,mode=max,scope=${{ matrix.target }}-${{ env.PLATFORM_PAIR }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.target }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + + - name: Export digest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + with: + name: digests-${{ matrix.target }}-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge ${{ matrix.target }} into multi-arch manifest + needs: [prepare, release-please, build] + if: | + !cancelled() + && needs.build.result == 'success' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + strategy: + fail-fast: false + matrix: + target: ${{ fromJSON(needs.prepare.outputs.targets) }} + runs-on: ubuntu-latest + permissions: + packages: write + steps: + # When release-please is skipped (workflow_dispatch, or no releasable commits) VERSION is empty; + # meta.outputs.version then falls back to the branch/sha tag, which is intentional. - name: Extract version from tag id: extract_version run: | @@ -54,40 +134,51 @@ jobs: else VERSION="" fi - echo $VERSION echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Docker meta - id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - images: | - ${{ env.REGISTRY }}/${{matrix.target}} - tags: | - type=schedule - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern={{major}}.{{minor}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern={{major}}.{{minor}}.{{patch}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern={{major}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern=v{{version}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern=v{{major}}.{{minor}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern=v{{major}}.{{minor}}.{{patch}},value=${{steps.extract_version.outputs.version}} - type=semver,pattern=v{{major}},value=${{steps.extract_version.outputs.version}} - type=sha + path: ${{ runner.temp }}/digests + pattern: digests-${{ matrix.target }}-* + merge-multiple: true - - name: Log in to the Container registry - uses: docker/login-action@3227f5311cb93ffd14d13e65d8cc400d30f4dd8a # v4 + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + - name: Docker tags + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: - context: ./ - push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - tags: ${{ steps.meta.outputs.tags }} - file: Dockerfile.${{ matrix.target }} + images: ${{ env.REGISTRY }}/${{ matrix.target }} + tags: | + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{major}}.{{minor}}.{{patch}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{major}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern=v{{version}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern=v{{major}}.{{minor}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern=v{{major}}.{{minor}}.{{patch}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern=v{{major}},value=${{ steps.extract_version.outputs.version }} + # Forced on so workflow_dispatch from a non-default branch (no `latest`, + # no tag ref) still yields a populated meta.outputs.version for Inspect. + type=sha,enable=true + + # Glob `*` expands to digest-named files written by the build job's Export digest step. + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ matrix.target }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ matrix.target }}:${{ steps.meta.outputs.version }} 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 97a9ef4ea..4f7bb4083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4332,19 +4332,17 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bs58", "build-info", "bytes", "derivative", "futures", "graph-networks-registry", - "http 0.2.12", "indexer-monitor", - "indexer-watcher", "ipfs-api-backend-hyper", "prost 0.14.3", - "rand 0.9.4", + "rand 0.8.5", "serde", - "serde_json", "serde_yaml", "sqlx", "test-assets", diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index 8a39fec93..d247652c5 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -174,17 +174,123 @@ max_receipts_per_request = 10000 0xDDE4cfFd3D9052A9cb618fC05a1Cd02be1f2F467 = "https://tap-aggregator.network.thegraph.com" 0xDD6a6f76eb36B873C1C184e8b9b9e762FE216490 = "https://tap-aggregator-arbitrum-one.graphops.xyz" -# DIPS (Decentralized Indexing Payment System) +# DIPs (Direct Indexer Payments). Payer authorisation is verified +# on-chain at acceptance. GRT prices, not wei. [dips] host = "0.0.0.0" port = "7601" -allowed_payers = ["0x3333333333333333333333333333333333333333"] -price_per_entity = "1000" +# Networks you explicitly support indexing. +# Proposals from the dipper for you to index networks that are not in the list below are rejected. +# See https://github.com/graphprotocol/networks-registry/blob/main/docs/networks-table.md +# e.g. supported_networks = ["mainnet", "arbitrum-one"] +supported_networks = [] -[dips.price_per_epoch] -mainnet = "100" -hardhat = "100" +# 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.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" diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index a633a6e4b..d0005c135 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_"; @@ -664,16 +661,24 @@ 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, + /// 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 { @@ -681,10 +686,10 @@ impl Default for DipsConfig { DipsConfig { host: "0.0.0.0".to_string(), port: "7601".to_string(), - allowed_payers: vec![], - price_per_entity: U256::from(100), - price_per_epoch: BTreeMap::new(), - additional_networks: HashMap::new(), + 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(), } } } @@ -729,17 +734,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}; use tracing_test::traced_test; use super::{DatabaseConfig, IndexerConfig, SHARED_PREFIX}; @@ -765,18 +765,7 @@ 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(), - )]), + min_grt_per_billion_entities_per_30_days: crate::GRT::from_grt("200"), ..Default::default() }); @@ -1311,4 +1300,61 @@ 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 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 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_eq!( + dips.min_grt_per_billion_entities_per_30_days, + crate::GRT::from_grt("200"), + "min_grt_per_billion_entities_per_30_days should be set in maximal config" + ); + } } diff --git a/crates/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/build.rs b/crates/dips/build.rs index 198109003..261010956 100644 --- a/crates/dips/build.rs +++ b/crates/dips/build.rs @@ -13,13 +13,5 @@ fn main() { .protoc_arg("--experimental_allow_proto3_optional") .compile_protos(&["proto/indexer.proto"], &["proto/"]) .expect("Failed to compile DIPs indexer RPC proto(s)"); - - tonic_prost_build::configure() - .build_server(true) - .out_dir("src/proto") - .include_file("gateway.rs") - .protoc_arg("--experimental_allow_proto3_optional") - .compile_protos(&["proto/gateway.proto"], &["proto"]) - .expect("Failed to compile DIPs gateway RPC proto(s)"); } } diff --git a/crates/dips/proto/gateway.proto b/crates/dips/proto/gateway.proto deleted file mode 100644 index 47453b7df..000000000 --- a/crates/dips/proto/gateway.proto +++ /dev/null @@ -1,73 +0,0 @@ -syntax = "proto3"; - -package graphprotocol.gateway.dips; - -service GatewayDipsService { - /** - * Cancel an _indexing agreement_. - * - * This method allows the indexer to notify the DIPs gateway that the agreement - * should be canceled. - */ - rpc CancelAgreement(CancelAgreementRequest) returns (CancelAgreementResponse); - - /** - * Collect payment for an _indexing agreement_. - * - * This method allows the indexer to report the work completed to the DIPs gateway - * and receive payment for the indexing work done. - */ - rpc CollectPayment(CollectPaymentRequest) returns (CollectPaymentResponse); -} - - -/** - * A request to cancel an _indexing agreement_. - * - * See the `DipsService.CancelAgreement` method. - */ -message CancelAgreementRequest { - uint64 version = 1; - bytes signed_cancellation = 2; /// a signed ERC-712 message cancelling an agreement -} - -/** - * A response to a request to cancel an _indexing agreement_. - * - * See the `DipsService.CancelAgreement` method. - */ -message CancelAgreementResponse { - /// Empty response, eventually we may add custom status codes -} - -/** - * A request to collect payment _indexing agreement_. - * - * See the `DipsService.CollectPayment` method. - */ -message CollectPaymentRequest { - uint64 version = 1; - bytes signed_collection = 2; -} - -/** - * A response to a request to collect payment for an _indexing agreement_. - * - * See the `DipsService.CollectAgreement` method. - */ -message CollectPaymentResponse { - uint64 version = 1; - CollectPaymentStatus status = 2; - bytes tap_receipt = 3; -} - -/** - * The status on response to collect an _indexing agreement_. - */ -enum CollectPaymentStatus { - ACCEPT = 0; /// The payment request was accepted. - ERR_TOO_EARLY = 1; /// The payment request was done before min epochs passed - ERR_TOO_LATE = 2; /// The payment request was done after max epochs passed - ERR_AMOUNT_OUT_OF_BOUNDS = 3; /// The payment request is for too large an amount - ERR_UNKNOWN = 99; /// Something else went terribly wrong -} diff --git a/crates/dips/proto/indexer.proto b/crates/dips/proto/indexer.proto index dc97e82e8..7d40d9789 100644 --- a/crates/dips/proto/indexer.proto +++ b/crates/dips/proto/indexer.proto @@ -9,11 +9,6 @@ service IndexerDipsService { * The _indexer_ can `ACCEPT` or `REJECT` the agreement. */ rpc SubmitAgreementProposal(SubmitAgreementProposalRequest) returns (SubmitAgreementProposalResponse); - - /** - * Request to cancel an existing _indexing agreement_. - */ - rpc CancelAgreement(CancelAgreementRequest) returns (CancelAgreementResponse); } /** @@ -23,41 +18,57 @@ service IndexerDipsService { */ message SubmitAgreementProposalRequest { uint64 version = 1; - bytes signed_voucher = 2; /// An ERC-712 signed indexing agreement voucher + bytes signed_rca = 2; /// ABI-encoded SignedRCA (RecurringCollectionAgreement plus signature). } /** * A response to a request to propose a new _indexing agreement_ to an _indexer_. * + * The outcome is either `accepted` or `rejected` -- exactly one is set. A + * rejection carries a `RejectReason` plus an optional free-text detail. + * * See the `DipsService.SubmitAgreementProposal` method. */ message SubmitAgreementProposalResponse { - ProposalResponse response = 1; /// The response to the agreement proposal. + oneof outcome { + Accepted accepted = 1; /// Set when the proposal was accepted. + Rejected rejected = 2; /// Set when the proposal was rejected. + } } /** - * The response to an _indexing agreement_ proposal. + * An accepted _indexing agreement_ proposal. Empty for now; accept-only fields + * can be added later without disturbing the reject path. */ -enum ProposalResponse { - ACCEPT = 0; /// The agreement proposal was accepted. - REJECT = 1; /// The agreement proposal was rejected. -} +message Accepted {} /** - * A request to cancel an _indexing agreement_. - * - * See the `DipsService.CancelAgreement` method. + * A rejected _indexing agreement_ proposal. */ -message CancelAgreementRequest { - uint64 version = 1; - bytes signed_cancellation = 2; /// a signed ERC-712 message cancelling an agreement +message Rejected { + RejectReason reason = 1; /// Why the proposal was rejected. + string detail = 2; /// Optional human-readable detail; may be empty. } /** - * A response to a request to cancel an existing _indexing agreement_. - * - * See the `DipsService.CancelAgreement` method. + * The reason an _indexer_ rejected an _indexing agreement_ proposal. Values may + * be added over time; an older reader should treat an unrecognised reason as + * UNSPECIFIED (the catch-all). */ -message CancelAgreementResponse { - // Empty message, eventually we may add custom status codes +enum RejectReason { + REJECT_REASON_UNSPECIFIED = 0; /// Rejected for a reason not covered below (the catch-all). + REJECT_REASON_PRICE_TOO_LOW = 1; /// The offered price is below the indexer's minimum. + REJECT_REASON_DEADLINE_EXPIRED = 2; /// The proposal deadline has already passed. + REJECT_REASON_UNSUPPORTED_NETWORK = 3; /// The subgraph's network is not supported by this indexer. + REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE = 4; /// The subgraph manifest could not be fetched from IPFS. + REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER = 5; /// The RCA names a different indexer as service provider. + REJECT_REASON_AGREEMENT_EXPIRED = 6; /// The agreement end time has already passed. + REJECT_REASON_UNSUPPORTED_METADATA_VERSION = 7; /// The agreement metadata version is not supported. + REJECT_REASON_INVALID_SIGNATURE = 8; /// The proposal's signature failed to verify. + REJECT_REASON_SENDER_NOT_TRUSTED = 9; /// The signer is not an authorised agreement manager. + REJECT_REASON_CAPACITY_EXCEEDED = 10; /// The indexer is at its DIPs capacity; may resolve later. + REJECT_REASON_MANIFEST_TOO_LARGE = 11; /// The subgraph manifest exceeds the indexer's size cap. + REJECT_REASON_REPLAY_DETECTED = 12; /// A different proposal reuses an already-seen agreement id. + REJECT_REASON_INSUFFICIENT_ESCROW = 13; /// The payer has insufficient escrow to back the agreement. + REJECT_REASON_INDEXER_UNAVAILABLE = 14; /// A transient internal error; the proposal may be resent. } diff --git a/crates/dips/src/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/inflight.rs b/crates/dips/src/inflight.rs new file mode 100644 index 000000000..af95869d6 --- /dev/null +++ b/crates/dips/src/inflight.rs @@ -0,0 +1,64 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared counter of in-flight proposal requests. +//! +//! The counter is incremented when a request enters the gRPC handler and +//! decremented when it leaves. The IPFS client reads it at the start of a +//! fetch to decide whether to use the full retry budget or a single +//! attempt — the latter frees handler slots faster when the service is +//! under load, providing a pressure-relief valve. + +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +pub type InflightCounter = Arc; + +/// RAII guard that increments the counter on construction and decrements +/// on drop. Hold it for the lifetime of the request you want counted. +pub struct InflightGuard { + counter: InflightCounter, +} + +impl InflightGuard { + pub fn new(counter: InflightCounter) -> Self { + counter.fetch_add(1, Ordering::Relaxed); + Self { counter } + } +} + +impl Drop for InflightGuard { + fn drop(&mut self) { + self.counter.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Snapshot the current in-flight count. +pub fn snapshot(counter: &InflightCounter) -> usize { + counter.load(Ordering::Relaxed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guard_increments_and_decrements() { + let counter: InflightCounter = Arc::new(AtomicUsize::new(0)); + assert_eq!(snapshot(&counter), 0); + + let g1 = InflightGuard::new(counter.clone()); + assert_eq!(snapshot(&counter), 1); + + let g2 = InflightGuard::new(counter.clone()); + assert_eq!(snapshot(&counter), 2); + + drop(g1); + assert_eq!(snapshot(&counter), 1); + + drop(g2); + assert_eq!(snapshot(&counter), 0); + } +} diff --git a/crates/dips/src/ipfs.rs b/crates/dips/src/ipfs.rs index 80846c91d..8df258e6e 100644 --- a/crates/dips/src/ipfs.rs +++ b/crates/dips/src/ipfs.rs @@ -1,7 +1,54 @@ // 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, Retry, and Size Limits +//! +//! IPFS fetches have a 30-second timeout per attempt. On failure, the client +//! retries up to 3 times with exponential backoff (10s, 20s, 40s delays). This +//! 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. +//! +//! Each fetch is also capped at `IPFS_MAX_MANIFEST_BYTES`. Real manifests +//! are tens of KB; the cap exists so a caller-supplied CID cannot force +//! an unbounded download from attacker-controlled content. +//! +//! # What This Proves +//! +//! Successfully fetching a manifest proves: +//! - 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; @@ -9,7 +56,30 @@ use futures::TryStreamExt; use ipfs_api_backend_hyper::{IpfsApi, TryFromUri}; use serde::Deserialize; -use crate::DipsError; +use crate::{ + inflight::{self, InflightCounter}, + DipsError, +}; + +/// Timeout for a single IPFS fetch attempt. +const IPFS_FETCH_TIMEOUT: Duration = Duration::from_secs(30); + +/// 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); + +/// Upper bound on bytes read from a single manifest fetch. Real manifests are +/// tens of KB; this 25 MiB cap (aligned with Graph Node's default) bounds the +/// per-request bandwidth cost of a caller-chosen CID resolving to hostile content. +pub(crate) const IPFS_MAX_MANIFEST_BYTES: usize = 25 * 1024 * 1024; + +/// When the in-flight request count exceeds this threshold, IPFS fetches +/// stop retrying — a single attempt only. The fewer-retries mode frees +/// handler slots faster when the service is under load, at the cost of +/// failing proposals whose first IPFS attempt has a transient error. +pub(crate) const IPFS_DURESS_THRESHOLD: usize = 200; #[async_trait] pub trait IpfsFetcher: Send + Sync + std::fmt::Debug { @@ -28,35 +98,99 @@ impl IpfsFetcher for Arc { pub struct IpfsClient { #[derivative(Debug = "ignore")] client: ipfs_api_backend_hyper::IpfsClient, + inflight: InflightCounter, } impl IpfsClient { - pub fn new(url: &str) -> anyhow::Result { + pub fn new(url: &str, inflight: InflightCounter) -> anyhow::Result { let client = ipfs_api_backend_hyper::IpfsClient::from_str(url)?; - Ok(Self { client }) + Ok(Self { client, inflight }) + } + + pub(crate) fn max_attempts(&self) -> u32 { + if inflight::snapshot(&self.inflight) > IPFS_DURESS_THRESHOLD { + 1 + } else { + IPFS_MAX_ATTEMPTS + } } } #[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 max_attempts = self.max_attempts(); + + for attempt in 0..max_attempts { + if attempt > 0 { + // Exponential backoff: 10s, 20s, 40s + let delay = IPFS_RETRY_BASE_DELAY * 2u32.pow(attempt - 1); + 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, + error = %e, + "IPFS fetch attempt failed" + ); + last_error = Some(e); + } + } + } + + // 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 mut stream = self.client.cat(file.as_ref()); + let mut content: Vec = Vec::new(); + while let Some(chunk) = stream + .try_next() + .await + .map_err(|e| DipsError::SubgraphManifestUnavailable(format!("{file}: {e}")))? + { + content.extend_from_slice(&chunk); + if content.len() > IPFS_MAX_MANIFEST_BYTES { + return Err(DipsError::ManifestTooLarge { + file: file.to_string(), + limit_bytes: IPFS_MAX_MANIFEST_BYTES, + }); + } + } + + let manifest: GraphManifest = serde_yaml::from_slice(&content) + .map_err(|e| DipsError::InvalidSubgraphManifest(format!("{file}: {e}")))?; - 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}")) - })?; + Ok(manifest) + }; - 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 +207,101 @@ 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)" + ))) + } +} + +/// Test IPFS fetcher returning a manifest whose single data source has an +/// empty network field, to exercise the malformed-manifest path. +#[derive(Debug, Clone, Default)] +pub struct EmptyNetworkIpfsFetcher; + +#[async_trait] +impl IpfsFetcher for EmptyNetworkIpfsFetcher { + async fn fetch(&self, _file: &str) -> Result { + Ok(GraphManifest { + data_sources: vec![DataSource { + network: String::new(), + }], + }) + } +} + +impl Default for MockIpfsFetcher { + fn default() -> Self { Self { - 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 std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + use crate::ipfs::{ + DataSource, FailingIpfsFetcher, GraphManifest, IpfsClient, IpfsFetcher, MockIpfsFetcher, + IPFS_DURESS_THRESHOLD, IPFS_MAX_ATTEMPTS, + }; #[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 +317,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 @@ -239,4 +508,32 @@ templates: abi: Pool "; + + #[test] + fn max_attempts_uses_full_budget_below_threshold() { + // Arrange + let inflight = Arc::new(AtomicUsize::new(0)); + let client = IpfsClient::new("http://localhost:5001", inflight.clone()).unwrap(); + + // Act + Assert + assert_eq!(client.max_attempts(), IPFS_MAX_ATTEMPTS); + + // Right at the threshold still counts as below — the check is `>`. + inflight.store(IPFS_DURESS_THRESHOLD, Ordering::Relaxed); + assert_eq!(client.max_attempts(), IPFS_MAX_ATTEMPTS); + } + + #[test] + fn max_attempts_drops_to_one_above_threshold() { + // Arrange + let inflight = Arc::new(AtomicUsize::new(IPFS_DURESS_THRESHOLD + 1)); + let client = IpfsClient::new("http://localhost:5001", inflight.clone()).unwrap(); + + // Act + Assert + assert_eq!(client.max_attempts(), 1); + + // And recovers when the counter falls back. + inflight.store(0, Ordering::Relaxed); + assert_eq!(client.max_attempts(), IPFS_MAX_ATTEMPTS); + } } diff --git a/crates/dips/src/lib.rs b/crates/dips/src/lib.rs index d55280b8d..6ce5f6040 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -1,19 +1,67 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::{str::FromStr, sync::Arc}; +//! 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. **Service provider** - RCA is addressed to this indexer +//! 2. **Timestamps** - Deadline and end time haven't passed +//! 3. **IPFS manifest** - Subgraph deployment exists and is parseable +//! 4. **Network** - Subgraph's network is supported by this indexer +//! 5. **Pricing** - Offered price meets indexer's minimum +//! +//! Signature and signer-authorization checks are NOT performed here. With the +//! switch to offer-based authorization, the on-chain `acceptIndexingAgreement` +//! call verifies the signer (via either an ECDSA signature or a pre-stored +//! payer offer) when the indexer-agent submits the acceptance transaction. +//! +//! # Modules +//! +//! - [`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::sync::Arc; use server::DipsServerContext; use thegraph_core::alloy::{ core::primitives::Address, - primitives::{b256, ruint::aliases::U256, ChainId, Signature, Uint, B256}, + primitives::{keccak256, ruint::aliases::U256, Uint}, signers::SignerSync, sol, - sol_types::{eip712_domain, Eip712Domain, SolStruct, SolValue}, + sol_types::{Eip712Domain, SolValue}, }; #[cfg(feature = "db")] pub mod database; +pub mod inflight; pub mod ipfs; pub mod price; #[cfg(feature = "rpc")] @@ -22,155 +70,119 @@ pub mod proto; mod registry; #[cfg(feature = "rpc")] pub mod server; -pub mod signers; pub mod store; -use store::AgreementStore; use thiserror::Error; use uuid::Uuid; -/// DIPs EIP-712 domain salt -const EIP712_DOMAIN_SALT: B256 = - b256!("b4632c657c26dce5d4d7da1d65bda185b14ff8f905ddbb03ea0382ed06c5ef28"); - -/// DIPs Protocol version -pub const PROTOCOL_VERSION: u64 = 1; // MVP - -/// Create an EIP-712 domain given a chain ID and dispute manager address. -pub fn dips_agreement_eip712_domain(chain_id: ChainId) -> Eip712Domain { - eip712_domain! { - name: "Graph Protocol Indexing Agreement", - version: "0", - chain_id: chain_id, - salt: EIP712_DOMAIN_SALT, - } -} - -pub fn dips_cancellation_eip712_domain(chain_id: ChainId) -> Eip712Domain { - eip712_domain! { - name: "Graph Protocol Indexing Agreement Cancellation", - version: "0", - chain_id: chain_id, - salt: EIP712_DOMAIN_SALT, - } -} - -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, - } -} +/// Protocol version (seconds-based RCA) +pub const PROTOCOL_VERSION: u64 = 2; 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)))`. + /// Note: `conditions` is NOT included in the agreement ID preimage. #[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; + uint16 conditions; + uint256 nonce; bytes metadata; } - // the vouchers are generic to each data service, in the case of subgraphs this is an ABI-encoded SubgraphIndexingVoucherMetadata - #[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" - } - + /// Wrapper pairing an RCA with its EIP-712 signature. #[derive(Debug, PartialEq)] - struct SignedCancellationRequest { - CancellationRequest request; + struct SignedRecurringCollectionAgreement { + RecurringCollectionAgreement agreement; bytes signature; } + /// Metadata for indexing agreement acceptance, ABI-encoded into + /// `RecurringCollectionAgreement.metadata`. #[derive(Debug, PartialEq)] - struct CancellationRequest { - bytes16 agreement_id; + struct AcceptIndexingAgreementMetadata { + bytes32 subgraphDeploymentId; + uint8 version; + bytes terms; } + /// Pricing terms, ABI-encoded into `AcceptIndexingAgreementMetadata.terms`. #[derive(Debug, PartialEq)] - struct SignedCollectionRequest { - CollectionRequest request; - bytes signature; + struct IndexingAgreementTermsV1 { + uint256 tokensPerSecond; + uint256 tokensPerEntityPerSecond; } - #[derive(Debug, PartialEq)] - struct CollectionRequest { - 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 - #[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 }, + // RCA validation + #[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("subgraph manifest for {file} exceeds the {limit_bytes} byte cap")] + ManifestTooLarge { file: String, limit_bytes: usize }, + #[error("network {0} is not supported")] + UnsupportedNetwork(String), #[error( - "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, - #[error("signer {0} not authorised")] - SignerNotAuthorised(Address), - #[error("cancellation request has expired")] - ExpiredRequest, + TokensPerSecondTooLow { + network: String, + minimum: U256, + offered: U256, + }, + #[error("tokens per entity per second {offered} is below configured minimum {minimum}")] + TokensPerEntityPerSecondTooLow { minimum: U256, offered: U256 }, // 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,55 +192,37 @@ impl From for tonic::Status { } } -impl IndexingAgreementVoucher { +// === RCA Implementations === + +impl RecurringCollectionAgreement { pub fn sign( &self, domain: &Eip712Domain, signer: S, - ) -> anyhow::Result { - let voucher = SignedIndexingAgreementVoucher { - voucher: 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 SignedIndexingAgreementVoucher { - // TODO: Validate all values - pub fn validate( - &self, - signer_validator: &Arc, - domain: &Eip712Domain, - expected_payee: &Address, - allowed_payers: impl AsRef<[Address]>, - ) -> Result<(), DipsError> { - let sig = Signature::try_from(self.signature.as_ref()) - .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)) - .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, +impl SignedRecurringCollectionAgreement { + /// Validate proposal-time fields. + /// + /// Checks that the service provider matches the expected indexer + /// address. On-chain offer existence is NOT checked here — the offer + /// does not exist yet at proposal time. The contract enforces offer + /// existence when the indexer-agent calls `acceptIndexingAgreement`. + pub fn validate(&self, expected_service_provider: &Address) -> Result<(), DipsError> { + if !self.agreement.serviceProvider.eq(expected_service_provider) { + return Err(DipsError::UnexpectedServiceProvider { + expected: *expected_service_provider, + actual: self.agreement.serviceProvider, }); } - Ok(()) } @@ -237,677 +231,861 @@ impl SignedIndexingAgreementVoucher { } } -impl CancellationRequest { - pub fn sign( - &self, - domain: &Eip712Domain, - signer: S, - ) -> anyhow::Result { - let voucher = SignedCancellationRequest { - request: self.clone(), - signature: signer.sign_typed_data_sync(self, domain)?.as_bytes().into(), - }; - - Ok(voucher) - } -} - -impl SignedCancellationRequest { - // TODO: Validate all values - pub fn validate( - &self, - domain: &Eip712Domain, - expected_signer: &Address, - ) -> Result<(), DipsError> { - let sig = Signature::from_str(&self.signature.to_string()) - .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; - - let signer = sig - .recover_address_from_prehash(&self.request.eip712_signing_hash(domain)) - .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; - - if signer.ne(expected_signer) { - return Err(DipsError::UnexpectedSigner); - } - - Ok(()) - } - pub fn encode_vec(&self) -> Vec { - self.abi_encode() - } -} - -impl SignedCollectionRequest { - pub fn encode_vec(&self) -> Vec { - self.abi_encode() - } +/// 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() } -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) - } +/// Try to extract the deployment ID from raw signed RCA bytes. +/// +/// Best-effort: returns `None` if any decoding step fails. +pub(crate) fn try_extract_deployment_id(rca_bytes: &[u8]) -> Option { + let signed_rca = SignedRecurringCollectionAgreement::abi_decode(rca_bytes).ok()?; + let metadata = + AcceptIndexingAgreementMetadata::abi_decode(signed_rca.agreement.metadata.as_ref()).ok()?; + Some(bytes32_to_ipfs_hash(&metadata.subgraphDeploymentId.0)) } -pub async fn validate_and_create_agreement( +/// Validate and create a RecurringCollectionAgreement. +/// +/// Performs validation: +/// - Service provider match +/// - Deadline and expiry checks +/// - IPFS manifest fetching and network validation +/// - Price minimum enforcement +/// +/// On-chain offer existence is NOT checked here — the offer doesn't exist +/// yet at proposal time. The contract enforces it at `acceptIndexingAgreement`. +/// +/// Returns the agreement ID if successful, stores in database. +pub async fn validate_and_create_rca( ctx: Arc, - domain: &Eip712Domain, - expected_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 service provider + signed_rca.validate(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 V1 terms (IndexingAgreementVersion.V1 = 0 in Solidity enum) + if metadata.version != 0 { + 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; an empty network field is a malformed manifest. + let network_name = manifest + .network() + .filter(|n| !n.is_empty()) + .ok_or_else(|| DipsError::InvalidSubgraphManifest(deployment_id.clone()))?; - 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, - )) - } + // Reject networks this indexer hasn't configured for DIPs at the manifest step, + // instead of relying on the price lookup to miss the network later. + if !price_calculator.is_supported(network_name) { + tracing::info!( + agreement_id = %agreement_id, + network = %network_name, + deployment_id = %deployment_id, + "network not in configured supported_networks, rejecting proposal" + ); + return Err(DipsError::UnsupportedNetwork(network_name.to_string())); } - 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!( + // Resolve chain ID for logging context + let chain_id = registry + .get_network_by_id(network_name) + .map(|n| n.caip2_id.to_string()) + .or_else(|| additional_networks.get(network_name).cloned()) + .unwrap_or_else(|| "unknown".to_string()); + + // Validate price minimums + let offered_tokens_per_second = terms.tokensPerSecond; + match price_calculator.get_minimum_price(network_name) { + 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, - price + network = %network_name, + chain_id = %chain_id, + deployment_id = %deployment_id, + offered = %offered_tokens_per_second, + minimum = %price, + "tokens_per_second below minimum, rejecting proposal" ); - 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, + chain_id = %chain_id, + deployment_id = %deployment_id, + "network not configured in price calculator, rejecting proposal" ); - 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 '{}'", - offered_entity_price, - price_calculator.entity_price() + network = %network_name, + chain_id = %chain_id, + deployment_id = %deployment_id, + offered = %offered_entity_price, + minimum = %price_calculator.entity_price(), + "tokens_per_entity_per_second below minimum, rejecting proposal" ); - return Err(DipsError::PricePerEntityTooLow( - network, - price_calculator.entity_price(), - offered_entity_price.to_string(), - )); + return Err(DipsError::TokensPerEntityPerSecondTooLow { + minimum: price_calculator.entity_price(), + offered: offered_entity_price, + }); } tracing::debug!( agreement_id = %agreement_id, - chain_id = %metadata.chainId, - deployment_id = %metadata.subgraphDeploymentId, - "creating agreement" + network = %network_name, + deployment_id = %deployment_id, + "creating RCA agreement" ); - store - .create_agreement(decoded_voucher.clone(), metadata) + // Store the raw signed RCA bytes + rca_store + .store_rca(agreement_id, rca_bytes, PROTOCOL_VERSION) .await .map_err(|error| { - tracing::error!(%agreement_id, %error, "failed to create agreement"); + tracing::error!(%agreement_id, %error, "failed to store RCA"); 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()))?; +#[cfg(test)] +mod test { + use std::collections::{BTreeMap, HashSet}; + use std::sync::Arc; - // Get the agreement ID from the cancellation request - let agreement_id = Uuid::from_bytes(decoded_request.request.agreement_id.into()); + use crate::{ + derive_agreement_id, + ipfs::{EmptyNetworkIpfsFetcher, FailingIpfsFetcher, MockIpfsFetcher}, + price::PriceCalculator, + server::DipsServerContext, + store::{FailingRcaStore, InMemoryRcaStore}, + AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, + RecurringCollectionAgreement, SignedRecurringCollectionAgreement, + }; + use thegraph_core::alloy::{ + primitives::{keccak256, Address, FixedBytes, U256}, + sol_types::SolValue, + }; - let stored_agreement = store.get_by_id(agreement_id).await?.ok_or_else(|| { - tracing::warn!(%agreement_id, "agreement not found"); - DipsError::AgreementNotFound - })?; + fn create_test_context() -> Arc { + Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }) + } + + /// Helper: encode an RCA as a `SignedRecurringCollectionAgreement` + /// ABI payload. The signature field is no longer consumed by the + /// validator; this just produces the wire bytes that + /// `validate_and_create_rca` expects to decode. + fn rca_to_wire_bytes(rca: RecurringCollectionAgreement) -> Vec { + SignedRecurringCollectionAgreement { + agreement: rca, + signature: Default::default(), + } + .abi_encode() + } + + fn create_test_rca( + payer: Address, + service_provider: Address, + tokens_per_second: U256, + tokens_per_entity_per_second: U256, + ) -> RecurringCollectionAgreement { + let terms = IndexingAgreementTermsV1 { + tokensPerSecond: tokens_per_second, + tokensPerEntityPerSecond: tokens_per_entity_per_second, + }; - // Get the deployment ID from the stored agreement - let deployment_id = stored_agreement.metadata.subgraphDeploymentId; + let metadata = AcceptIndexingAgreementMetadata { + // Any bytes32 works - MockIpfsFetcher ignores the deployment ID + subgraphDeploymentId: FixedBytes::ZERO, + version: 0, // IndexingAgreementVersion.V1 = 0 + terms: terms.abi_encode().into(), + }; - if stored_agreement.cancelled { - tracing::warn!(%agreement_id, %deployment_id, "agreement already cancelled"); - return Err(DipsError::AgreementCancelled); + 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, + conditions: 0, + nonce: U256::from(1), + metadata: metadata.abi_encode().into(), + } } - decoded_request.validate(domain, &stored_agreement.voucher.voucher.payer)?; + #[test] + fn test_derive_agreement_id() { + let rca = RecurringCollectionAgreement { + deadline: 1000, + endsAt: 2000, + payer: Address::repeat_byte(0x01), + dataService: Address::repeat_byte(0x02), + serviceProvider: Address::repeat_byte(0x03), + maxInitialTokens: U256::from(100), + maxOngoingTokensPerSecond: U256::from(10), + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + conditions: 0, + nonce: U256::from(42), + metadata: Default::default(), + }; - tracing::debug!(%agreement_id, %deployment_id, "cancelling agreement"); + 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]); + } - store - .cancel_agreement(decoded_request) - .await - .map_err(|error| { - tracing::error!(%agreement_id, %deployment_id, %error, "failed to cancel agreement"); - error - })?; + /// Shared test vector with dipper (dipper-rpc/src/indexer.rs). + /// Both repos must produce the same bytes16 for this input. + /// If this test fails, the derivation has drifted from the on-chain + /// contract and/or from dipper -- cancellations and agreement + /// matching will break silently. + #[test] + fn test_derive_agreement_id_shared_vector() { + let rca = RecurringCollectionAgreement { + deadline: 1700000300, + endsAt: 1700086400, + payer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + .parse() + .unwrap(), + dataService: "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9" + .parse() + .unwrap(), + serviceProvider: "0xf4EF6650E48d099a4972ea5B414daB86e1998Bd3" + .parse() + .unwrap(), + maxInitialTokens: U256::from(1_000_000_000_000_000_000u64), + maxOngoingTokensPerSecond: U256::from(1_000_000_000_000_000u64), + minSecondsPerCollection: 3600, + maxSecondsPerCollection: 86400, + conditions: 0, + nonce: U256::from(0x019d44a86ac97e938672e2501fe630f2u128), + metadata: Default::default(), + }; - Ok(agreement_id) -} + let id = derive_agreement_id(&rca); -#[cfg(test)] -mod test { - use std::{ - collections::HashMap, - time::{Duration, SystemTime, UNIX_EPOCH}, - }; + // Pinned expected value. If this fails, check: + // 1. dipper: dipper-rpc/src/indexer.rs test_derive_agreement_id_shared_vector + // 2. Solidity: RecurringCollector._generateAgreementId() + let expected: [u8; 16] = [ + 0x55, 0x79, 0x42, 0xae, 0xfa, 0xb6, 0x16, 0x09, 0xcf, 0xb9, 0xee, 0x14, 0xd3, 0x09, + 0xa1, 0x7e, + ]; + assert_eq!( + id.as_bytes(), + &expected, + "derive_agreement_id output does not match pinned shared vector. \ + Actual: 0x{} -- update this test AND the matching test in \ + dipper (dipper-rpc/src/indexer.rs)", + id.as_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + } - use indexer_monitor::EscrowAccounts; - use rand::{distr::Alphanumeric, Rng}; - use thegraph_core::alloy::{ - primitives::{Address, ChainId, FixedBytes, U256}, - signers::local::PrivateKeySigner, - sol_types::{Eip712Domain, SolValue}, - }; - use uuid::Uuid; + /// Guards against drift in the sol! struct layout for the audit-branch + /// `conditions` field. If `conditions` were ever moved, renamed, or + /// dropped from the decoder, this round-trip would either fail to + /// decode or return a corrupted value in the field's slot. + #[test] + fn test_rca_conditions_field_roundtrip() { + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); - pub use crate::store::{AgreementStore, InMemoryAgreementStore}; - use crate::{ - dips_agreement_eip712_domain, dips_cancellation_eip712_domain, server::DipsServerContext, - CancellationRequest, DipsError, IndexingAgreementVoucher, SignedIndexingAgreementVoucher, - SubgraphIndexingVoucherMetadata, - }; + let mut rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + rca.conditions = 0xABCD; // arbitrary non-zero 16-bit value - /// The Arbitrum One (mainnet) chain ID (eip155). - const CHAIN_ID_ARBITRUM_ONE: ChainId = 0xa4b1; // 42161 + let encoded = rca_to_wire_bytes(rca.clone()); + let decoded = SignedRecurringCollectionAgreement::abi_decode(encoded.as_ref()) + .expect("roundtrip decode failed"); + + assert_eq!( + decoded.agreement.conditions, 0xABCD, + "conditions field did not survive ABI round-trip" + ); + // Cross-check surrounding fields are intact, so a failure of the + // conditions field isn't silently misread from a neighbour's slot. + assert_eq!( + decoded.agreement.maxSecondsPerCollection, + rca.maxSecondsPerCollection + ); + assert_eq!(decoded.agreement.nonce, rca.nonce); + } #[tokio::test] - async fn test_validate_and_create_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, - }; + async fn test_validate_and_create_rca_success() { + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); - 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); - - 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 rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); - let stored_agreement = ctx.store.get_by_id(actual_id).await.unwrap().unwrap(); + let ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); - assert_eq!(voucher, stored_agreement.voucher); - assert!(!stored_agreement.cancelled); - Ok(()) - } + let result = + super::validate_and_create_rca(ctx.clone(), &service_provider, rca_bytes).await; - #[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, - }; + assert!(result.is_ok(), "got: {:?}", result); + assert_eq!(result.unwrap(), agreement_id); - 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(), - }; + // 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); + } - 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_wrong_service_provider() { + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let wrong_service_provider = Address::repeat_byte(0x99); + + 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 ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(matches!( + result, + Err(DipsError::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 = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); - 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 ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &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 = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); - 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())), - }, - ]; + // 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)); - 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()), - } - } + let ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(matches!( + result, + Err(DipsError::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 = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + + // 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), + )), + 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 rca_bytes = rca_to_wire_bytes(rca); + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(matches!(result, Err(DipsError::UnsupportedNetwork(_)))); } #[tokio::test] - async fn test_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 = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + 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, + conditions: 0, + nonce: U256::from(1), + metadata: metadata.abi_encode().into(), + }; - assert_eq!(agreement_id, cancelled_id); + let ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); - // Verify agreement is cancelled - let stored_agreement = ctx.store.get_by_id(agreement_id).await?.unwrap(); - assert!(stored_agreement.cancelled); + let result = super::validate_and_create_rca(ctx, &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 = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let terms = IndexingAgreementTermsV1 { + tokensPerSecond: U256::from(200), + tokensPerEntityPerSecond: U256::from(100), + }; + + let metadata = AcceptIndexingAgreementMetadata { + subgraphDeploymentId: FixedBytes::ZERO, + version: 0, // IndexingAgreementVersion.V1 = 0 + 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, + conditions: 0, + nonce: U256::from(1), + metadata: metadata.abi_encode().into(), }; - let low_entity_price_voucher = voucher_ctx.test_voucher(metadata); + let ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(matches!(result, Err(DipsError::DeadlineExpired { .. }))); + } + + #[tokio::test] + async fn test_validate_and_create_rca_agreement_expired() { + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); - 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(), + 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: 0, // IndexingAgreementVersion.V1 = 0 + 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, + conditions: 0, + 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 ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); + + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(matches!(result, Err(DipsError::AgreementExpired { .. }))); + } + + // ========================================================================= + // 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 ctx = create_test_context(); + + let malformed_bytes = vec![0xDE, 0xAD, 0xBE, 0xEF]; // Not valid ABI - let contexts = vec![no_network_ctx, ctx.clone(), ctx.clone(), ctx.clone()]; + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, malformed_bytes).await; - let expected_result: Vec> = vec![ - Err(DipsError::InvalidSubgraphManifest( - voucher_ctx.deployment_id.clone(), + // Assert + assert!( + matches!(result, Err(DipsError::AbiDecoding(_))), + "Expected AbiDecoding error, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_validate_and_create_rca_ipfs_failure() { + // Arrange + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + + // Context with 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::PricePerEntityTooLow( - "mainnet".to_string(), - U256::from(100), - "10".to_string(), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + let rca_bytes = rca_to_wire_bytes(rca); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::SubgraphManifestUnavailable(_))), + "Expected SubgraphManifestUnavailable error, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_validate_and_create_rca_manifest_no_network() { + // Arrange + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + + // Context with 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::PricePerEpochTooLow( - "mainnet".to_string(), - U256::from(200), - "10".to_string(), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + let rca_bytes = rca_to_wire_bytes(rca); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::InvalidSubgraphManifest(_))), + "Expected InvalidSubgraphManifest error, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_validate_and_create_rca_empty_network() { + // Arrange + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + + // Context with a manifest whose data source has an empty network field + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(EmptyNetworkIpfsFetcher), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), )), - 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; + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); - 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:?}"), - } - } + let rca_bytes = rca_to_wire_bytes(rca); - Ok(()) + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::InvalidSubgraphManifest(_))), + "Expected InvalidSubgraphManifest for empty network, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_validate_and_create_rca_store_failure() { + // Arrange + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + + // Context with 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), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }); + + let rca_bytes = rca_to_wire_bytes(rca); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::UnknownError(_))), + "Expected UnknownError from store failure, got: {:?}", + result + ); + } + + // ========================================================================= + // Unit tests for helper functions + // ========================================================================= + + #[test] + fn test_bytes32_to_ipfs_hash_format() { + // Arrange + let bytes: [u8; 32] = [0xAB; 32]; + + // Act + let hash = super::bytes32_to_ipfs_hash(&bytes); + + // Assert - CIDv0 format starts with "Qm" and is 46 characters + assert!( + hash.starts_with("Qm"), + "IPFS CIDv0 should start with 'Qm', got: {}", + hash + ); + assert_eq!( + hash.len(), + 46, + "IPFS CIDv0 should be 46 characters, got: {}", + hash.len() + ); + } + + #[test] + fn test_bytes32_to_ipfs_hash_deterministic() { + // Arrange + let bytes: [u8; 32] = [0x12; 32]; + + // Act + let hash1 = super::bytes32_to_ipfs_hash(&bytes); + let hash2 = super::bytes32_to_ipfs_hash(&bytes); + + // Assert + assert_eq!(hash1, hash2, "Same input should produce same output"); + } + + #[test] + fn test_bytes32_to_ipfs_hash_different_inputs() { + // Arrange + let bytes1: [u8; 32] = [0x00; 32]; + let bytes2: [u8; 32] = [0xFF; 32]; + + // Act + let hash1 = super::bytes32_to_ipfs_hash(&bytes1); + let hash2 = super::bytes32_to_ipfs_hash(&bytes2); + + // Assert + assert_ne!( + hash1, hash2, + "Different inputs should produce different outputs" + ); + } + + #[test] + fn test_bytes32_to_ipfs_hash_known_vector() { + // Arrange - all zeros should produce a known hash + // Multihash: 0x12 (sha256) + 0x20 (32 bytes) + 32 zero bytes + // Base58 encoding of [0x12, 0x20, 0x00 * 32] + let bytes: [u8; 32] = [0x00; 32]; + + // Act + let hash = super::bytes32_to_ipfs_hash(&bytes); + + // Assert - verified by manual calculation + // The multihash [0x12, 0x20, 0, 0, ...] encodes to this CIDv0 + assert_eq!( + hash, "QmNLei78zWmzUdbeRB3CiUfAizWUrbeeZh5K1rhAQKCh51", + "Known test vector mismatch" + ); } } diff --git a/crates/dips/src/price.rs b/crates/dips/src/price.rs index 5cf44164a..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/gateway.rs b/crates/dips/src/proto/gateway.rs deleted file mode 100644 index fd13ab498..000000000 --- a/crates/dips/src/proto/gateway.rs +++ /dev/null @@ -1,8 +0,0 @@ -// This file is @generated by prost-build. -pub mod graphprotocol { - pub mod gateway { - pub mod dips { - include!("graphprotocol.gateway.dips.rs"); - } - } -} diff --git a/crates/dips/src/proto/graphprotocol.gateway.dips.rs b/crates/dips/src/proto/graphprotocol.gateway.dips.rs deleted file mode 100644 index 21766fafb..000000000 --- a/crates/dips/src/proto/graphprotocol.gateway.dips.rs +++ /dev/null @@ -1,503 +0,0 @@ -// This file is @generated by prost-build. -/// * -/// -/// A request to cancel an *indexing agreement*. -/// -/// See the `DipsService.CancelAgreement` method. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CancelAgreementRequest { - #[prost(uint64, tag = "1")] - pub version: u64, - /// / a signed ERC-712 message cancelling an agreement - #[prost(bytes = "vec", tag = "2")] - pub signed_cancellation: ::prost::alloc::vec::Vec, -} -/// * -/// -/// A response to a request to cancel an *indexing agreement*. -/// -/// See the `DipsService.CancelAgreement` method. -/// -/// / Empty response, eventually we may add custom status codes -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CancelAgreementResponse {} -/// * -/// -/// A request to collect payment *indexing agreement*. -/// -/// See the `DipsService.CollectPayment` method. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CollectPaymentRequest { - #[prost(uint64, tag = "1")] - pub version: u64, - #[prost(bytes = "vec", tag = "2")] - pub signed_collection: ::prost::alloc::vec::Vec, -} -/// * -/// -/// A response to a request to collect payment for an *indexing agreement*. -/// -/// See the `DipsService.CollectAgreement` method. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CollectPaymentResponse { - #[prost(uint64, tag = "1")] - pub version: u64, - #[prost(enumeration = "CollectPaymentStatus", tag = "2")] - pub status: i32, - #[prost(bytes = "vec", tag = "3")] - pub tap_receipt: ::prost::alloc::vec::Vec, -} -/// * -/// -/// The status on response to collect an *indexing agreement*. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum CollectPaymentStatus { - /// / The payment request was accepted. - Accept = 0, - /// / The payment request was done before min epochs passed - ErrTooEarly = 1, - /// / The payment request was done after max epochs passed - ErrTooLate = 2, - /// / The payment request is for too large an amount - ErrAmountOutOfBounds = 3, - /// / Something else went terribly wrong - ErrUnknown = 99, -} -impl CollectPaymentStatus { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Accept => "ACCEPT", - Self::ErrTooEarly => "ERR_TOO_EARLY", - Self::ErrTooLate => "ERR_TOO_LATE", - Self::ErrAmountOutOfBounds => "ERR_AMOUNT_OUT_OF_BOUNDS", - Self::ErrUnknown => "ERR_UNKNOWN", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "ACCEPT" => Some(Self::Accept), - "ERR_TOO_EARLY" => Some(Self::ErrTooEarly), - "ERR_TOO_LATE" => Some(Self::ErrTooLate), - "ERR_AMOUNT_OUT_OF_BOUNDS" => Some(Self::ErrAmountOutOfBounds), - "ERR_UNKNOWN" => Some(Self::ErrUnknown), - _ => None, - } - } -} -/// Generated client implementations. -pub mod gateway_dips_service_client { - #![allow( - unused_variables, - dead_code, - missing_docs, - clippy::wildcard_imports, - clippy::let_unit_value, - )] - use tonic::codegen::*; - use tonic::codegen::http::Uri; - #[derive(Debug, Clone)] - pub struct GatewayDipsServiceClient { - inner: tonic::client::Grpc, - } - impl GatewayDipsServiceClient { - /// Attempt to create a new client by connecting to a given endpoint. - pub async fn connect(dst: D) -> Result - where - D: TryInto, - D::Error: Into, - { - let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; - Ok(Self::new(conn)) - } - } - impl GatewayDipsServiceClient - where - T: tonic::client::GrpcService, - T::Error: Into, - T::ResponseBody: Body + std::marker::Send + 'static, - ::Error: Into + std::marker::Send, - { - pub fn new(inner: T) -> Self { - let inner = tonic::client::Grpc::new(inner); - Self { inner } - } - pub fn with_origin(inner: T, origin: Uri) -> Self { - let inner = tonic::client::Grpc::with_origin(inner, origin); - Self { inner } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> GatewayDipsServiceClient> - where - F: tonic::service::Interceptor, - T::ResponseBody: Default, - T: tonic::codegen::Service< - http::Request, - Response = http::Response< - >::ResponseBody, - >, - >, - , - >>::Error: Into + std::marker::Send + std::marker::Sync, - { - GatewayDipsServiceClient::new(InterceptedService::new(inner, interceptor)) - } - /// Compress requests with the given encoding. - /// - /// This requires the server to support it otherwise it might respond with an - /// error. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.inner = self.inner.send_compressed(encoding); - self - } - /// Enable decompressing responses. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.inner = self.inner.accept_compressed(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.inner = self.inner.max_decoding_message_size(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.inner = self.inner.max_encoding_message_size(limit); - self - } - /// * - /// - /// Cancel an *indexing agreement*. - /// - /// This method allows the indexer to notify the DIPs gateway that the agreement - /// should be canceled. - pub async fn cancel_agreement( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/graphprotocol.gateway.dips.GatewayDipsService/CancelAgreement", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert( - GrpcMethod::new( - "graphprotocol.gateway.dips.GatewayDipsService", - "CancelAgreement", - ), - ); - self.inner.unary(req, path, codec).await - } - /// * - /// - /// Collect payment for an *indexing agreement*. - /// - /// This method allows the indexer to report the work completed to the DIPs gateway - /// and receive payment for the indexing work done. - pub async fn collect_payment( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/graphprotocol.gateway.dips.GatewayDipsService/CollectPayment", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert( - GrpcMethod::new( - "graphprotocol.gateway.dips.GatewayDipsService", - "CollectPayment", - ), - ); - self.inner.unary(req, path, codec).await - } - } -} -/// Generated server implementations. -pub mod gateway_dips_service_server { - #![allow( - unused_variables, - dead_code, - missing_docs, - clippy::wildcard_imports, - clippy::let_unit_value, - )] - use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with GatewayDipsServiceServer. - #[async_trait] - pub trait GatewayDipsService: std::marker::Send + std::marker::Sync + 'static { - /// * - /// - /// Cancel an *indexing agreement*. - /// - /// This method allows the indexer to notify the DIPs gateway that the agreement - /// should be canceled. - async fn cancel_agreement( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// * - /// - /// Collect payment for an *indexing agreement*. - /// - /// This method allows the indexer to report the work completed to the DIPs gateway - /// and receive payment for the indexing work done. - async fn collect_payment( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - } - #[derive(Debug)] - pub struct GatewayDipsServiceServer { - inner: Arc, - accept_compression_encodings: EnabledCompressionEncodings, - send_compression_encodings: EnabledCompressionEncodings, - max_decoding_message_size: Option, - max_encoding_message_size: Option, - } - impl GatewayDipsServiceServer { - pub fn new(inner: T) -> Self { - Self::from_arc(Arc::new(inner)) - } - pub fn from_arc(inner: Arc) -> Self { - Self { - inner, - accept_compression_encodings: Default::default(), - send_compression_encodings: Default::default(), - max_decoding_message_size: None, - max_encoding_message_size: None, - } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> InterceptedService - where - F: tonic::service::Interceptor, - { - InterceptedService::new(Self::new(inner), interceptor) - } - /// Enable decompressing requests with the given encoding. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.accept_compression_encodings.enable(encoding); - self - } - /// Compress responses with the given encoding, if the client supports it. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.send_compression_encodings.enable(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.max_decoding_message_size = Some(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.max_encoding_message_size = Some(limit); - self - } - } - impl tonic::codegen::Service> for GatewayDipsServiceServer - where - T: GatewayDipsService, - B: Body + std::marker::Send + 'static, - B::Error: Into + std::marker::Send + 'static, - { - type Response = http::Response; - type Error = std::convert::Infallible; - type Future = BoxFuture; - fn poll_ready( - &mut self, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - fn call(&mut self, req: http::Request) -> Self::Future { - match req.uri().path() { - "/graphprotocol.gateway.dips.GatewayDipsService/CancelAgreement" => { - #[allow(non_camel_case_types)] - struct CancelAgreementSvc(pub Arc); - impl< - T: GatewayDipsService, - > tonic::server::UnaryService - for CancelAgreementSvc { - type Response = super::CancelAgreementResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::cancel_agreement(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = CancelAgreementSvc(inner); - let codec = tonic_prost::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/graphprotocol.gateway.dips.GatewayDipsService/CollectPayment" => { - #[allow(non_camel_case_types)] - struct CollectPaymentSvc(pub Arc); - impl< - T: GatewayDipsService, - > tonic::server::UnaryService - for CollectPaymentSvc { - type Response = super::CollectPaymentResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::collect_payment(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = CollectPaymentSvc(inner); - let codec = tonic_prost::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - _ => { - Box::pin(async move { - let mut response = http::Response::new( - tonic::body::Body::default(), - ); - let headers = response.headers_mut(); - headers - .insert( - tonic::Status::GRPC_STATUS, - (tonic::Code::Unimplemented as i32).into(), - ); - headers - .insert( - http::header::CONTENT_TYPE, - tonic::metadata::GRPC_CONTENT_TYPE, - ); - Ok(response) - }) - } - } - } - } - impl Clone for GatewayDipsServiceServer { - fn clone(&self) -> Self { - let inner = self.inner.clone(); - Self { - inner, - accept_compression_encodings: self.accept_compression_encodings, - send_compression_encodings: self.send_compression_encodings, - max_decoding_message_size: self.max_decoding_message_size, - max_encoding_message_size: self.max_encoding_message_size, - } - } - } - /// Generated gRPC service name - pub const SERVICE_NAME: &str = "graphprotocol.gateway.dips.GatewayDipsService"; - impl tonic::server::NamedService for GatewayDipsServiceServer { - const NAME: &'static str = SERVICE_NAME; - } -} diff --git a/crates/dips/src/proto/graphprotocol.indexer.dips.rs b/crates/dips/src/proto/graphprotocol.indexer.dips.rs index 0f4f2d940..cd058c462 100644 --- a/crates/dips/src/proto/graphprotocol.indexer.dips.rs +++ b/crates/dips/src/proto/graphprotocol.indexer.dips.rs @@ -8,70 +8,146 @@ pub struct SubmitAgreementProposalRequest { #[prost(uint64, tag = "1")] pub version: u64, - /// / An ERC-712 signed indexing agreement voucher + /// / ABI-encoded SignedRCA (RecurringCollectionAgreement plus signature). #[prost(bytes = "vec", tag = "2")] - pub signed_voucher: ::prost::alloc::vec::Vec, + pub signed_rca: ::prost::alloc::vec::Vec, } /// * /// /// A response to a request to propose a new *indexing agreement* to an *indexer*. /// +/// The outcome is either `accepted` or `rejected` -- exactly one is set. A +/// rejection carries a `RejectReason` plus an optional free-text detail. +/// /// See the `DipsService.SubmitAgreementProposal` method. -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct SubmitAgreementProposalResponse { - /// / The response to the agreement proposal. - #[prost(enumeration = "ProposalResponse", tag = "1")] - pub response: i32, + #[prost(oneof = "submit_agreement_proposal_response::Outcome", tags = "1, 2")] + pub outcome: ::core::option::Option, +} +/// Nested message and enum types in `SubmitAgreementProposalResponse`. +pub mod submit_agreement_proposal_response { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Outcome { + /// / Set when the proposal was accepted. + #[prost(message, tag = "1")] + Accepted(super::Accepted), + /// / Set when the proposal was rejected. + #[prost(message, tag = "2")] + Rejected(super::Rejected), + } } /// * /// -/// A request to cancel an *indexing agreement*. +/// An accepted *indexing agreement* proposal. Empty for now; accept-only fields +/// can be added later without disturbing the reject path. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Accepted {} +/// * /// -/// See the `DipsService.CancelAgreement` method. +/// A rejected *indexing agreement* proposal. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CancelAgreementRequest { - #[prost(uint64, tag = "1")] - pub version: u64, - /// / a signed ERC-712 message cancelling an agreement - #[prost(bytes = "vec", tag = "2")] - pub signed_cancellation: ::prost::alloc::vec::Vec, +pub struct Rejected { + /// / Why the proposal was rejected. + #[prost(enumeration = "RejectReason", tag = "1")] + pub reason: i32, + /// / Optional human-readable detail; may be empty. + #[prost(string, tag = "2")] + pub detail: ::prost::alloc::string::String, } /// * /// -/// A response to a request to cancel an existing *indexing agreement*. -/// -/// See the `DipsService.CancelAgreement` method. -/// -/// Empty message, eventually we may add custom status codes -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CancelAgreementResponse {} -/// * -/// -/// The response to an *indexing agreement* proposal. +/// The reason an *indexer* rejected an *indexing agreement* proposal. Values may +/// be added over time; an older reader should treat an unrecognised reason as +/// UNSPECIFIED (the catch-all). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] -pub enum ProposalResponse { - /// / The agreement proposal was accepted. - Accept = 0, - /// / The agreement proposal was rejected. - Reject = 1, +pub enum RejectReason { + /// / Rejected for a reason not covered below (the catch-all). + Unspecified = 0, + /// / The offered price is below the indexer's minimum. + PriceTooLow = 1, + /// / The proposal deadline has already passed. + DeadlineExpired = 2, + /// / The subgraph's network is not supported by this indexer. + UnsupportedNetwork = 3, + /// / The subgraph manifest could not be fetched from IPFS. + SubgraphManifestUnavailable = 4, + /// / The RCA names a different indexer as service provider. + UnexpectedServiceProvider = 5, + /// / The agreement end time has already passed. + AgreementExpired = 6, + /// / The agreement metadata version is not supported. + UnsupportedMetadataVersion = 7, + /// / The proposal's signature failed to verify. + InvalidSignature = 8, + /// / The signer is not an authorised agreement manager. + SenderNotTrusted = 9, + /// / The indexer is at its DIPs capacity; may resolve later. + CapacityExceeded = 10, + /// / The subgraph manifest exceeds the indexer's size cap. + ManifestTooLarge = 11, + /// / A different proposal reuses an already-seen agreement id. + ReplayDetected = 12, + /// / The payer has insufficient escrow to back the agreement. + InsufficientEscrow = 13, + /// / A transient internal error; the proposal may be resent. + IndexerUnavailable = 14, } -impl ProposalResponse { +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::Accept => "ACCEPT", - Self::Reject => "REJECT", + Self::Unspecified => "REJECT_REASON_UNSPECIFIED", + Self::PriceTooLow => "REJECT_REASON_PRICE_TOO_LOW", + 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" + } + Self::InvalidSignature => "REJECT_REASON_INVALID_SIGNATURE", + Self::SenderNotTrusted => "REJECT_REASON_SENDER_NOT_TRUSTED", + Self::CapacityExceeded => "REJECT_REASON_CAPACITY_EXCEEDED", + Self::ManifestTooLarge => "REJECT_REASON_MANIFEST_TOO_LARGE", + Self::ReplayDetected => "REJECT_REASON_REPLAY_DETECTED", + Self::InsufficientEscrow => "REJECT_REASON_INSUFFICIENT_ESCROW", + Self::IndexerUnavailable => "REJECT_REASON_INDEXER_UNAVAILABLE", } } /// Creates an enum from field names used in the ProtoBuf definition. pub fn from_str_name(value: &str) -> ::core::option::Option { match value { - "ACCEPT" => Some(Self::Accept), - "REJECT" => Some(Self::Reject), + "REJECT_REASON_UNSPECIFIED" => Some(Self::Unspecified), + "REJECT_REASON_PRICE_TOO_LOW" => Some(Self::PriceTooLow), + "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) + } + "REJECT_REASON_INVALID_SIGNATURE" => Some(Self::InvalidSignature), + "REJECT_REASON_SENDER_NOT_TRUSTED" => Some(Self::SenderNotTrusted), + "REJECT_REASON_CAPACITY_EXCEEDED" => Some(Self::CapacityExceeded), + "REJECT_REASON_MANIFEST_TOO_LARGE" => Some(Self::ManifestTooLarge), + "REJECT_REASON_REPLAY_DETECTED" => Some(Self::ReplayDetected), + "REJECT_REASON_INSUFFICIENT_ESCROW" => Some(Self::InsufficientEscrow), + "REJECT_REASON_INDEXER_UNAVAILABLE" => Some(Self::IndexerUnavailable), _ => None, } } @@ -201,38 +277,6 @@ pub mod indexer_dips_service_client { ); self.inner.unary(req, path, codec).await } - /// * - /// - /// Request to cancel an existing *indexing agreement*. - pub async fn cancel_agreement( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/graphprotocol.indexer.dips.IndexerDipsService/CancelAgreement", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert( - GrpcMethod::new( - "graphprotocol.indexer.dips.IndexerDipsService", - "CancelAgreement", - ), - ); - self.inner.unary(req, path, codec).await - } } } /// Generated server implementations. @@ -260,16 +304,6 @@ pub mod indexer_dips_service_server { tonic::Response, tonic::Status, >; - /// * - /// - /// Request to cancel an existing *indexing agreement*. - async fn cancel_agreement( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; } #[derive(Debug)] pub struct IndexerDipsServiceServer { @@ -398,52 +432,6 @@ pub mod indexer_dips_service_server { }; Box::pin(fut) } - "/graphprotocol.indexer.dips.IndexerDipsService/CancelAgreement" => { - #[allow(non_camel_case_types)] - struct CancelAgreementSvc(pub Arc); - impl< - T: IndexerDipsService, - > tonic::server::UnaryService - for CancelAgreementSvc { - type Response = super::CancelAgreementResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::cancel_agreement(&inner, request) - .await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = CancelAgreementSvc(inner); - let codec = tonic_prost::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } _ => { Box::pin(async move { let mut response = http::Response::new( diff --git a/crates/dips/src/proto/mod.rs b/crates/dips/src/proto/mod.rs index 873e26690..8fd3b83cc 100644 --- a/crates/dips/src/proto/mod.rs +++ b/crates/dips/src/proto/mod.rs @@ -1,2 +1,7 @@ -pub mod gateway; +//! Protocol buffer definitions for DIPS gRPC services. +//! +//! This module re-exports auto-generated protobuf types from prost-build. +//! Only one service interface remains: `IndexerDipsService`, the +//! Dipper-to-indexer RPC for delivering RCA proposals. + 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..99e027764 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -1,187 +1,585 @@ // 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) +//! ├─ Service-provider match +//! ├─ Timestamp validation (deadline, endsAt) +//! ├─ IPFS manifest fetch +//! ├─ Network validation +//! ├─ Price validation +//! │ +//! └─> Store in pending_rca_proposals table +//! │ +//! └─> Return Accept/Reject +//! ``` +//! +//! Signature and signer-authorization checks are not performed here. The +//! on-chain `acceptIndexingAgreement` call verifies the signer (via either +//! an ECDSA signature or a pre-stored payer offer) when the indexer-agent +//! submits the acceptance transaction. +//! +//! # 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 +//! +//! Cancellation is handled entirely on-chain via the RecurringCollector contract; +//! there is no gRPC method for it. Dipper calls `cancelIndexingAgreementByPayer` +//! directly and indexer-agents observe `IndexingAgreementCanceled` events through +//! the indexing-payments subgraph. + +use std::{collections::BTreeMap, 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, + inflight::{InflightCounter, InflightGuard}, ipfs::IpfsFetcher, price::PriceCalculator, proto::indexer::graphprotocol::indexer::dips::{ - indexer_dips_service_server::IndexerDipsService, CancelAgreementRequest, - CancelAgreementResponse, ProposalResponse, SubmitAgreementProposalRequest, - SubmitAgreementProposalResponse, + indexer_dips_service_server::IndexerDipsService, + submit_agreement_proposal_response::Outcome, Accepted, RejectReason, Rejected, + 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: +/// - 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, - 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()), - }) - } + /// Price calculator for validating minimum prices + pub price_calculator: Arc, + /// 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: +/// - Service-provider match +/// - 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, + /// Shared counter incremented for every request that enters the handler. + /// The IPFS client reads it to decide whether to use the full retry + /// budget or fall back to a single attempt under load. + pub inflight: InflightCounter, +} + +/// Classify a DipsError as the reason for the rejection. The match is exhaustive +/// so any future error variant must be classified here. +fn reject_reason_from_error(err: &DipsError) -> RejectReason { + match err { + DipsError::TokensPerSecondTooLow { .. } + | DipsError::TokensPerEntityPerSecondTooLow { .. } => RejectReason::PriceTooLow, + DipsError::DeadlineExpired { .. } => RejectReason::DeadlineExpired, + DipsError::AgreementExpired { .. } => RejectReason::AgreementExpired, + DipsError::UnsupportedNetwork(_) => RejectReason::UnsupportedNetwork, + DipsError::SubgraphManifestUnavailable(_) => RejectReason::SubgraphManifestUnavailable, + DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, + DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, + DipsError::ManifestTooLarge { .. } => RejectReason::ManifestTooLarge, + // Malformed proposals with no dedicated reason map to the catch-all; the + // detail carries the specifics. + DipsError::AbiDecoding(_) + | DipsError::InvalidSubgraphManifest(_) + | DipsError::InvalidRca(_) => RejectReason::Unspecified, + // A store failure means the proposal was valid but the indexer couldn't persist + // it -- tell dipper this is transient so it retries rather than giving up. + DipsError::UnknownError(_) => RejectReason::IndexerUnavailable, + } +} + +/// Human-readable detail sent to the caller. A store failure wraps a raw database +/// error that could leak schema internals, so it gets a generic message instead. +fn reject_detail_from_error(err: &DipsError) -> String { + match err { + DipsError::UnknownError(_) => "internal error while storing the proposal".to_string(), + other => other.to_string(), + } } #[async_trait] impl IndexerDipsService for DipsServer { + /// Submit an RCA proposal. + /// + /// Validates: + /// - Version 2 only + /// - Service provider match + /// - IPFS manifest and network compatibility + /// - Price minimums + /// + /// On-chain offer existence is NOT checked — the offer doesn't exist yet + /// at proposal time. The contract enforces it at `acceptIndexingAgreement`. + /// + /// Returns Accept/Reject based on validation results. async fn submit_agreement_proposal( &self, request: Request, ) -> Result, Status> { + let _guard = InflightGuard::new(self.inflight.clone()); + let SubmitAgreementProposalRequest { version, - signed_voucher, + signed_rca, } = 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 + ))); } - // 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( - self.ctx.clone(), - &dips_agreement_eip712_domain(self.chain_id), - &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())), - }, + // Basic sanity checks + if signed_rca.is_empty() { + return Err(Status::invalid_argument("signed_rca cannot be empty")); + } + + if signed_rca.len() > 10_000 { + return Err(Status::invalid_argument( + "signed_rca exceeds maximum size of 10KB", + )); + } + + // Validate and store RCA + let deployment_id = crate::try_extract_deployment_id(&signed_rca); + match crate::validate_and_create_rca(self.ctx.clone(), &self.expected_payee, signed_rca) + .await + { + Ok(agreement_id) => { + tracing::info!(%agreement_id, "RCA accepted"); + Ok(Response::new(SubmitAgreementProposalResponse { + outcome: Some(Outcome::Accepted(Accepted {})), + })) + } + Err(e) => { + let reject_reason = reject_reason_from_error(&e); + tracing::info!( + error = %e, + reason = ?reject_reason, + deployment_id = deployment_id.as_deref().unwrap_or("unknown"), + "RCA proposal rejected" + ); + Ok(Response::new(SubmitAgreementProposalResponse { + outcome: Some(Outcome::Rejected(Rejected { + reason: reject_reason.into(), + detail: reject_detail_from_error(&e), + })), + })) + } } } - /// * - /// Request to cancel an existing _indexing agreement_. - async fn cancel_agreement( - &self, - request: Request, - ) -> Result, Status> { - let CancelAgreementRequest { - version, - signed_cancellation, - } = request.into_inner(); +} - if version != 1 { - return Err(Status::invalid_argument("invalid version")); +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicUsize; + + use super::*; + use crate::{ipfs::MockIpfsFetcher, price::PriceCalculator, store::InMemoryRcaStore}; + + fn empty_counter() -> InflightCounter { + Arc::new(AtomicUsize::new(0)) + } + + 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), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + }) } + } + + #[tokio::test] + async fn test_empty_rejected() { + // Arrange + let ctx = DipsServerContext::for_testing(); + let server = DipsServer { + ctx, + expected_payee: Address::ZERO, + inflight: empty_counter(), + }; + let request = Request::new(SubmitAgreementProposalRequest { + version: 2, + signed_rca: vec![], + }); + + // 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, + inflight: empty_counter(), + }; + let large_payload = vec![0u8; 10_001]; + let request = Request::new(SubmitAgreementProposalRequest { + version: 2, + signed_rca: 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, + inflight: empty_counter(), + }; + let request = Request::new(SubmitAgreementProposalRequest { + version: 1, + signed_rca: 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")); + } + + // ========================================================================= + // 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_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_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()); - validate_and_cancel_agreement( - self.ctx.store.clone(), - &dips_cancellation_eip712_domain(self.chain_id), - signed_cancellation, - ) - .await - .map_err(Into::::into)?; + // 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); + } - Ok(tonic::Response::new(CancelAgreementResponse {})) + #[test] + fn test_reject_reason_unsupported_metadata_version() { + // Arrange + let err = DipsError::UnsupportedMetadataVersion(99); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::UnsupportedMetadataVersion); + } + + #[test] + fn test_reject_reason_malformed_proposals_map_to_unspecified() { + // Arrange: malformed inputs share the UNSPECIFIED catch-all; detail carries the specifics. + let abi = DipsError::AbiDecoding("invalid bytes".to_string()); + let manifest = DipsError::InvalidSubgraphManifest("QmTest".to_string()); + let rca = DipsError::InvalidRca("bad rca".to_string()); + + // Act + Assert + assert_eq!( + super::reject_reason_from_error(&abi), + RejectReason::Unspecified + ); + assert_eq!( + super::reject_reason_from_error(&manifest), + RejectReason::Unspecified + ); + assert_eq!( + super::reject_reason_from_error(&rca), + RejectReason::Unspecified + ); + } + + #[test] + fn test_reject_reason_manifest_too_large() { + // Arrange + let err = DipsError::ManifestTooLarge { + file: "QmTest".to_string(), + limit_bytes: 25 * 1024 * 1024, + }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::ManifestTooLarge); + } + + #[test] + fn test_reject_reason_unknown_error_is_transient() { + // Arrange: a store/database failure surfaces as UnknownError. + let err = DipsError::UnknownError(anyhow::anyhow!("connection pool timed out")); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert: a valid proposal the indexer can't persist is transient, so the + // sender should retry rather than treat it as a permanent failure. + assert_eq!(reason, RejectReason::IndexerUnavailable); + } + + #[test] + fn test_reject_detail_sanitizes_store_errors() { + // Arrange: the raw database error mentions schema internals. + let store_err = + DipsError::UnknownError(anyhow::anyhow!("relation \"users\" column secret")); + let decode_err = DipsError::AbiDecoding("offset 7".to_string()); + + // Act + let store_detail = super::reject_detail_from_error(&store_err); + let decode_detail = super::reject_detail_from_error(&decode_err); + + // Assert: the store error becomes generic while a well-formed validation + // error keeps its descriptive detail. + assert_eq!(store_detail, "internal error while storing the proposal"); + assert!(!store_detail.contains("users")); + assert!(decode_detail.contains("offset 7")); + } + + #[tokio::test] + async fn test_reject_response_carries_reason_and_detail() { + // Arrange: a non-decodable payload reaches validation and fails ABI decode. + let ctx = DipsServerContext::for_testing(); + let server = DipsServer { + ctx, + expected_payee: Address::ZERO, + inflight: empty_counter(), + }; + let request = Request::new(SubmitAgreementProposalRequest { + version: 2, + signed_rca: vec![1, 2, 3], + }); + + // Act + let response = server + .submit_agreement_proposal(request) + .await + .unwrap() + .into_inner(); + + // Assert + let Some(Outcome::Rejected(rejected)) = response.outcome else { + panic!("expected a rejected outcome"); + }; + assert_eq!(rejected.reason, RejectReason::Unspecified as i32); + assert!(rejected.detail.contains("ABI decoding")); + } + + #[test] + fn test_reject_reason_wire_names_round_trip() { + let cases = [ + (RejectReason::Unspecified, "REJECT_REASON_UNSPECIFIED"), + (RejectReason::PriceTooLow, "REJECT_REASON_PRICE_TOO_LOW"), + ( + RejectReason::DeadlineExpired, + "REJECT_REASON_DEADLINE_EXPIRED", + ), + ( + RejectReason::UnsupportedNetwork, + "REJECT_REASON_UNSUPPORTED_NETWORK", + ), + ( + RejectReason::SubgraphManifestUnavailable, + "REJECT_REASON_SUBGRAPH_MANIFEST_UNAVAILABLE", + ), + ( + RejectReason::UnexpectedServiceProvider, + "REJECT_REASON_UNEXPECTED_SERVICE_PROVIDER", + ), + ( + RejectReason::AgreementExpired, + "REJECT_REASON_AGREEMENT_EXPIRED", + ), + ( + RejectReason::UnsupportedMetadataVersion, + "REJECT_REASON_UNSUPPORTED_METADATA_VERSION", + ), + ( + RejectReason::InvalidSignature, + "REJECT_REASON_INVALID_SIGNATURE", + ), + ( + RejectReason::SenderNotTrusted, + "REJECT_REASON_SENDER_NOT_TRUSTED", + ), + ( + RejectReason::CapacityExceeded, + "REJECT_REASON_CAPACITY_EXCEEDED", + ), + ( + RejectReason::ManifestTooLarge, + "REJECT_REASON_MANIFEST_TOO_LARGE", + ), + ( + RejectReason::ReplayDetected, + "REJECT_REASON_REPLAY_DETECTED", + ), + ( + RejectReason::InsufficientEscrow, + "REJECT_REASON_INSUFFICIENT_ESCROW", + ), + ( + RejectReason::IndexerUnavailable, + "REJECT_REASON_INDEXER_UNAVAILABLE", + ), + ]; + for (variant, name) in cases { + assert_eq!(variant.as_str_name(), name); + assert_eq!(RejectReason::from_str_name(name), Some(variant)); + } } } diff --git a/crates/dips/src/signers.rs b/crates/dips/src/signers.rs deleted file mode 100644 index b43d098b2..000000000 --- a/crates/dips/src/signers.rs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. -// SPDX-License-Identifier: Apache-2.0 - -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, -} - -impl EscrowSignerValidator { - pub fn new(watcher: EscrowAccountsWatcher) -> Self { - Self { watcher } - } - - #[cfg(test)] - pub async fn mock(accounts: EscrowAccounts) -> Self { - use std::time::Duration; - - let watcher = indexer_watcher::new_watcher(Duration::from_secs(100), move || { - let accounts = accounts.clone(); - - async move { Ok(accounts) } - }) - .await - .unwrap(); - - Self::new(watcher) - } -} - -impl SignerValidator for EscrowSignerValidator { - fn validate(&self, payer: &Address, signer: &Address) -> Result<(), anyhow::Error> { - let signers = self.watcher.borrow().get_signers_for_sender(payer); - - if !signers.contains(signer) { - return Err(anyhow!("Signer is not a valid signer for the sender")); - } - - Ok(()) - } -} - -#[derive(Debug)] -pub struct NoopSignerValidator; - -impl SignerValidator for NoopSignerValidator { - fn validate(&self, _payer: &Address, _signer: &Address) -> Result<(), anyhow::Error> { - Ok(()) - } -} - -#[cfg(test)] -mod test { - use std::{collections::HashMap, time::Duration}; - - use indexer_monitor::EscrowAccounts; - use thegraph_core::alloy::primitives::Address; - - 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(); - - let validator = super::EscrowSignerValidator::new(watcher); - validator.validate(&one, &one).unwrap_err(); - validator.validate(&one, &two).unwrap(); - } -} 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/Cargo.toml b/crates/service/Cargo.toml index 509f11e30..bf0478851 100644 --- a/crates/service/Cargo.toml +++ b/crates/service/Cargo.toml @@ -58,7 +58,7 @@ axum-extra = { version = "0.12.0", features = [ tokio-util = "0.7.10" cost-model = { git = "https://github.com/graphprotocol/agora", rev = "e9530de5f782d68ed409e2a18c62ec532db23737" } bip39.workspace = true -tower = "0.5.1" +tower = { version = "0.5.1", features = ["buffer", "limit", "timeout", "util"] } pin-project = "1.1.7" tonic.workspace = true itertools = "0.14.0" diff --git a/crates/service/src/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 618165dc2..701b8435f 100644 --- a/crates/service/src/service.rs +++ b/crates/service/src/service.rs @@ -1,7 +1,11 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::{net::SocketAddr, sync::Arc, time::Duration}; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; use anyhow::{anyhow, Context}; use axum::{extract::Request, serve, ServiceExt}; @@ -9,38 +13,65 @@ 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, }, 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::U256; use tokio::{net::TcpListener, signal}; use tokio_util::sync::CancellationToken; +use tonic::transport::server::TcpConnectInfo; +use tower::ServiceBuilder; +use tower_governor::{ + errors::GovernorError, governor::GovernorConfigBuilder, key_extractor::KeyExtractor, + GovernorLayer, +}; use tower_http::normalize_path::NormalizePath; use tracing::info; 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 grpc_error_to_response; mod release; mod router; mod tap_receipt_header; +use grpc_error_to_response::GrpcErrorToResponseLayer; + pub use router::ServiceRouter; pub use tap_receipt_header::TapHeader; +/// 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 +112,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; @@ -95,15 +130,12 @@ pub async fn run() -> anyhow::Result<()> { config.blockchain.horizon_receipts_verifier_address(), tap_core::TapVersion::V2, ); - let chain_id = config.blockchain.chain_id as u64; - let host_and_port = config.service.host_and_port; let indexer_address = config.indexer.indexer_address; let ipfs_url = config.service.ipfs_url.clone(); - // 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(); - + // V2 escrow accounts (used by DIPs) live in the network subgraph; no + // separate escrow subgraph is queried. let collector_address = config.blockchain.receipts_verifier_address_v2; let escrow_min_balance_grt_wei = config.subgraphs.network.escrow_min_balance_grt_wei.clone(); let max_signers_per_payer = config.subgraphs.network.max_signers_per_payer; @@ -132,6 +164,18 @@ pub async fn run() -> anyhow::Result<()> { } }; + // Build DipsInfoState if DIPS is configured + let dips_info_state = config.dips.as_ref().map(|dips| DipsInfoState { + min_grt_per_30_days: dips + .min_grt_per_30_days + .iter() + .map(|(network, grt)| (network.clone(), format_grt(grt.wei()))) + .collect(), + min_grt_per_billion_entities_per_30_days: format_grt( + dips.min_grt_per_billion_entities_per_30_days.wei(), + ), + }); + let router = ServiceRouter::builder() .database(database.clone()) .domain_separator_v2(domain_separator_v2.clone()) @@ -143,7 +187,8 @@ pub async fn run() -> anyhow::Result<()> { .blockchain(config.blockchain) .timestamp_buffer_secs(config.tap.rav_request.timestamp_buffer_secs) .network_subgraph(network_subgraph, config.subgraphs.network) - .escrow_accounts_v2(v2_watcher) + .escrow_accounts_v2(v2_watcher.clone()) + .maybe_dips_info(dips_info_state) .build(); serve_metrics(config.metrics.get_socket_addr()); @@ -155,81 +200,111 @@ 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, + supported_networks, + min_grt_per_30_days, + min_grt_per_billion_entities_per_30_days, additional_networks, + .. } = dips; + if supported_networks.is_empty() { + tracing::warn!( + "DIPS enabled but no networks in dips.supported_networks. \ + All proposals will be rejected." + ); + } + + tracing::info!( + supported_networks = ?supported_networks, + ipfs_url = %ipfs_url, + "DIPs configuration loaded" + ); + for (network, grt) in min_grt_per_30_days.iter() { + tracing::info!( + network = %network, + min_grt_per_30_days_wei = %grt.wei(), + "DIPs network pricing" + ); + } + tracing::info!( + min_grt_per_billion_entities_per_30_days_wei = %min_grt_per_billion_entities_per_30_days.wei(), + "DIPs entity pricing" + ); + let addr: SocketAddr = format!("{host}:{port}") .parse() .with_context(|| format!("Invalid DIPS host:port '{host}:{port}'"))?; - let ipfs_fetcher: Arc = Arc::new( - IpfsClient::new(ipfs_url.as_str()) - .with_context(|| format!("Failed to create IPFS client for URL '{ipfs_url}'"))?, + // Shared counter of in-flight gRPC requests. The IPFS client reads + // it to decide whether to use the full retry budget or fall back to + // a single attempt when the service is under load. + let inflight = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + // Initialize validation dependencies + let ipfs_fetcher = Arc::new(IpfsClient::new(ipfs_url.as_str(), inflight.clone())?); + let registry = Arc::new( + NetworksRegistry::from_latest_version() + .await + .context("Failed to fetch NetworksRegistry for DIPS")?, ); - // TODO: Try to re-use the same watcher for both DIPS and TAP - 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, - collector_address, - escrow_min_balance_grt_wei.clone(), - max_signers_per_payer, - ) - .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, + )), + 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, + inflight, }; - info!(address = %addr, "Starting DIPS gRPC server"); + info!( + address = %addr, + "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; }); } @@ -246,12 +321,85 @@ pub async fn run() -> anyhow::Result<()> { .await?) } +/// Per-request timeout across the whole gRPC handler. Long enough to cover +/// the worst-case IPFS retry budget (190s) with headroom; short enough that +/// a hung handler doesn't pin a worker indefinitely. +const DIPS_REQUEST_TIMEOUT: Duration = Duration::from_secs(220); + +/// Global token-bucket rate limit shared across all callers. Bounds the +/// total proposal throughput regardless of per-IP behaviour. Sized to +/// accommodate burst traffic from a single trusted dipper. +const DIPS_RATE_LIMIT_PER_SEC: u64 = 50; + +/// Per-IP rate limit replenishment interval. 200ms per token gives a +/// sustained 5 requests per second per source IP, which is comfortable +/// headroom for a real dipper but quickly cuts off any single misbehaving +/// caller. +const DIPS_PER_IP_REPLENISH_MS: u64 = 200; + +/// Burst allowance for the per-IP limiter. Lets a caller send a brief +/// spike without immediately tripping the limit. +const DIPS_PER_IP_BURST: u32 = 10; + +/// Channel depth of the outer Buffer wrapper. The wrapper makes the layer +/// chain Clone-able so tonic's `Server::layer` accepts it; the actual +/// timeout/rate-limit/per-IP layers run inside the buffered task. Requests +/// beyond this depth are rejected with `BufferError` until earlier ones +/// drain. Sized comfortably above the global rate-limit-per-second so a +/// healthy burst never bumps the channel. +const DIPS_BUFFER_DEPTH: usize = 1024; + +/// Key extractor for tonic that reads the peer IP from `TcpConnectInfo`, +/// which the tonic server adds to request extensions for non-TLS TCP +/// connections. The `tower_governor` defaults look for axum's +/// `ConnectInfo` extension instead, which tonic does not add. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TonicPeerIpKeyExtractor; + +impl KeyExtractor for TonicPeerIpKeyExtractor { + type Key = IpAddr; + + fn extract(&self, req: &axum::http::Request) -> Result { + req.extensions() + .get::() + .and_then(|ci| ci.remote_addr()) + .map(|addr| addr.ip()) + .ok_or(GovernorError::UnableToExtractKey) + } +} + async fn start_dips_server( addr: SocketAddr, service: impl IndexerDipsService, shutdown: impl std::future::Future, ) { + let per_ip_config = Arc::new( + GovernorConfigBuilder::default() + .per_millisecond(DIPS_PER_IP_REPLENISH_MS) + .burst_size(DIPS_PER_IP_BURST) + .key_extractor(TonicPeerIpKeyExtractor) + .finish() + .expect("per-IP governor config invariants"), + ); + let per_ip_layer = GovernorLayer { + config: per_ip_config, + }; + + let layer = ServiceBuilder::new() + .layer(GrpcErrorToResponseLayer) + .buffer(DIPS_BUFFER_DEPTH) + .timeout(DIPS_REQUEST_TIMEOUT) + .layer(per_ip_layer) + .rate_limit(DIPS_RATE_LIMIT_PER_SEC, Duration::from_secs(1)) + // tonic's Routes returns Response, but tower_governor + // hardcodes Response. Convert the inner body before + // the rate-limit layers see it. tonic's Server is happy to serve any + // http_body::Body for the response, so axum's body works end-to-end. + .map_response(|res: axum::http::Response| res.map(axum::body::Body::new)) + .into_inner(); + if let Err(e) = tonic::transport::Server::builder() + .layer(layer) .add_service(IndexerDipsServiceServer::new(service)) .serve_with_shutdown(addr, shutdown) .await @@ -322,3 +470,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/grpc_error_to_response.rs b/crates/service/src/service/grpc_error_to_response.rs new file mode 100644 index 000000000..db0a78cf3 --- /dev/null +++ b/crates/service/src/service/grpc_error_to_response.rs @@ -0,0 +1,144 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! Catches errors from inner tower layers and turns them into gRPC +//! error responses. +//! +//! Tonic's `Server::add_service` requires its routed service to declare +//! `Error = Infallible` — a promise that the service never fails. Tower +//! layers like `RateLimit`, `Timeout`, the per-IP `GovernorLayer`, and +//! `Buffer` all return real errors, so wrapping them around tonic's +//! `Routes` would break that promise. +//! +//! This wrapper sits outside the inner stack, intercepts any inner +//! error, and emits a successful HTTP response carrying an appropriate +//! gRPC status code. The wrapper's own error type is `Infallible`, +//! restoring tonic's expected bound. + +use std::{ + convert::Infallible, + future::Future, + marker::PhantomData, + pin::Pin, + task::{Context, Poll}, +}; + +use axum::http::{Request, Response}; +use pin_project::pin_project; +use tonic::Status; +use tower::{BoxError, Layer, Service}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct GrpcErrorToResponseLayer; + +impl Layer for GrpcErrorToResponseLayer { + type Service = GrpcErrorToResponse; + + fn layer(&self, inner: S) -> Self::Service { + GrpcErrorToResponse { inner } + } +} + +#[derive(Clone, Debug)] +pub struct GrpcErrorToResponse { + inner: S, +} + +impl Service> for GrpcErrorToResponse +where + S: Service, Response = Response>, + S::Error: Into, + RespBody: Default, +{ + type Response = Response; + type Error = Infallible; + type Future = GrpcErrorToResponseFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + match self.inner.poll_ready(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => Poll::Ready(Ok(())), + // The inner stack's `poll_ready` should not error in practice + // — buffer/timeout/rate-limit/governor surface errors through + // the response future, not through readiness. If it does, log + // and report ready; the next `call` will return an error that + // we then map to a status. + Poll::Ready(Err(e)) => { + let boxed: BoxError = e.into(); + tracing::error!( + error = %boxed, + "DIPs middleware poll_ready returned an error; reporting ready and deferring to call" + ); + Poll::Ready(Ok(())) + } + } + } + + fn call(&mut self, req: Request) -> Self::Future { + GrpcErrorToResponseFuture { + inner: self.inner.call(req), + _body: PhantomData, + } + } +} + +#[pin_project] +pub struct GrpcErrorToResponseFuture { + #[pin] + inner: F, + _body: PhantomData, +} + +impl Future for GrpcErrorToResponseFuture +where + F: Future, E>>, + E: Into, + B: Default, +{ + type Output = Result, Infallible>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + match this.inner.poll(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(resp)) => Poll::Ready(Ok(resp)), + Poll::Ready(Err(e)) => { + let boxed: BoxError = e.into(); + let status = classify(&*boxed); + tracing::warn!( + error = %boxed, + code = ?status.code(), + "DIPs middleware mapped inner error to gRPC status" + ); + Poll::Ready(Ok(status.into_http())) + } + } + } +} + +/// Walk the error chain looking for known inner-stack failures and +/// map each to the closest matching gRPC status code. Falls back to +/// `Internal` when nothing matches, which mirrors tonic's default for +/// unhandled service failures. +fn classify(e: &(dyn std::error::Error + 'static)) -> Status { + let mut current: Option<&(dyn std::error::Error + 'static)> = Some(e); + while let Some(err) = current { + if err + .downcast_ref::() + .is_some() + { + return Status::deadline_exceeded("request exceeded the DIPs handler timeout"); + } + if err + .downcast_ref::() + .is_some() + { + return Status::resource_exhausted("per-IP rate limit exceeded"); + } + if err.downcast_ref::().is_some() { + return Status::unavailable("DIPs handler queue closed"); + } + current = err.source(); + } + Status::internal("DIPs handler internal error") +} diff --git a/crates/service/src/service/router.rs b/crates/service/src/service/router.rs index 5db470498..4bea23e94 100644 --- a/crates/service/src/service/router.rs +++ b/crates/service/src/service/router.rs @@ -50,7 +50,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, @@ -84,6 +85,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 { @@ -219,7 +223,6 @@ impl ServiceRouter { indexer_allocations: allocations.clone(), escrow_accounts_v2: self.escrow_accounts_v2.clone(), network_subgraph: network_subgraph_client, - indexer_address: self.indexer.indexer_address, timestamp_error_tolerance, receipt_max_value, allowed_data_services, @@ -368,7 +371,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)) @@ -377,8 +380,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).route( "/status", diff --git a/crates/service/src/tap.rs b/crates/service/src/tap.rs index c13bccf04..879f97a9c 100644 --- a/crates/service/src/tap.rs +++ b/crates/service/src/tap.rs @@ -89,7 +89,6 @@ pub struct TapChecksConfig { pub indexer_allocations: Receiver>, pub escrow_accounts_v2: Receiver, pub network_subgraph: Option<&'static indexer_monitor::SubgraphClient>, - pub indexer_address: Address, pub timestamp_error_tolerance: Duration, pub receipt_max_value: u128, pub allowed_data_services: Option>, @@ -125,10 +124,7 @@ impl IndexerTapContext { pub async fn get_checks(config: TapChecksConfig) -> Vec> { let mut checks: Vec> = vec![ Arc::new(AllocationEligible::new(config.indexer_allocations)), - Arc::new(AllocationRedeemedCheck::new( - config.indexer_address, - config.network_subgraph, - )), + Arc::new(AllocationRedeemedCheck::new(config.network_subgraph)), Arc::new(SenderBalanceCheck::new(config.escrow_accounts_v2)), Arc::new(TimestampCheck::new(config.timestamp_error_tolerance)), Arc::new(DenyListCheck::new(config.pgpool.clone()).await), diff --git a/crates/service/src/tap/checks/allocation_redeemed.rs b/crates/service/src/tap/checks/allocation_redeemed.rs index 7b6cfadbe..f773984f1 100644 --- a/crates/service/src/tap/checks/allocation_redeemed.rs +++ b/crates/service/src/tap/checks/allocation_redeemed.rs @@ -2,17 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use indexer_monitor::{SubgraphClient, SubgraphQueryError}; -use indexer_query::payments_escrow_transactions_redeem; +use indexer_query::closed_allocations::{self, ClosedAllocations}; use tap_core::receipt::checks::{Check, CheckError, CheckResult}; -use thegraph_core::{ - alloy::{hex::ToHexExt, primitives::Address}, - CollectionId, -}; +use thegraph_core::{alloy::hex::ToHexExt, CollectionId}; -use crate::{ - middleware::Sender, - tap::{CheckingReceipt, TapReceipt}, -}; +use crate::tap::{CheckingReceipt, TapReceipt}; /// Errors that can occur during allocation redemption checks. #[derive(Debug, thiserror::Error)] @@ -25,24 +19,16 @@ pub enum AllocationCheckError { } pub struct AllocationRedeemedCheck { - indexer_address: Address, network_subgraph: Option<&'static SubgraphClient>, } impl AllocationRedeemedCheck { - pub fn new( - indexer_address: Address, - network_subgraph: Option<&'static SubgraphClient>, - ) -> Self { - Self { - indexer_address, - network_subgraph, - } + pub fn new(network_subgraph: Option<&'static SubgraphClient>) -> Self { + Self { network_subgraph } } - async fn v2_allocation_redeemed( + async fn v2_allocation_closed( &self, - sender: Address, collection_id: CollectionId, ) -> Result { let network_subgraph = self @@ -51,19 +37,22 @@ impl AllocationRedeemedCheck { // Horizon network subgraph stores allocationId as the 20-byte address derived // from the 32-byte collection_id (rightmost 20 bytes). - let allocation_ids = vec![collection_id.as_address().encode_hex()]; - - let response = network_subgraph - .query::( - payments_escrow_transactions_redeem::Variables { - payer: sender.encode_hex(), - receiver: self.indexer_address.encode_hex(), - allocation_ids: Some(allocation_ids), - }, - ) + let allocation_id = collection_id.as_address().encode_hex_with_prefix(); + + // Only reject receipts if the allocation is actually closed on-chain. + // In the continuous collection model, active allocations get collected + // from periodically — redeem transactions existing doesn't mean the + // allocation is done. + let closed_response = network_subgraph + .query::(closed_allocations::Variables { + allocation_ids: vec![allocation_id], + block: None, + first: 1, + last: String::new(), + }) .await?; - Ok(!response.payments_escrow_transactions.is_empty()) + Ok(!closed_response.allocations.is_empty()) } } @@ -71,22 +60,18 @@ impl AllocationRedeemedCheck { impl Check for AllocationRedeemedCheck { async fn check( &self, - ctx: &tap_core::receipt::Context, + _ctx: &tap_core::receipt::Context, receipt: &CheckingReceipt, ) -> CheckResult { - let Sender(sender) = ctx - .get::() - .ok_or_else(|| CheckError::Failed(anyhow::anyhow!("Missing sender in context")))?; - let collection_id = CollectionId::from(receipt.signed_receipt().as_ref().message.collection_id); - let redeemed = self - .v2_allocation_redeemed(*sender, collection_id) + let closed = self + .v2_allocation_closed(collection_id) .await .map_err(|e| CheckError::Failed(anyhow::anyhow!(e)))?; - if redeemed { + if closed { return Err(CheckError::Failed(anyhow::anyhow!( - "Allocation already redeemed (v2): {}", + "Allocation is closed (v2): {}", collection_id.as_address() ))); } @@ -112,7 +97,7 @@ mod tests { use wiremock::{matchers::body_string_contains, Mock, MockServer, ResponseTemplate}; use super::AllocationRedeemedCheck; - use crate::{middleware::Sender, tap::TapReceipt}; + use crate::tap::TapReceipt; fn create_wallet() -> PrivateKeySigner { MnemonicBuilder::::default() @@ -140,13 +125,16 @@ mod tests { } #[tokio::test] - async fn v2_redeemed_rejects() { + async fn v2_closed_allocation_rejects() { let mock_server: MockServer = MockServer::start().await; mock_server .register( - Mock::given(body_string_contains("paymentsEscrowTransactions")).respond_with( + Mock::given(body_string_contains("allocations")).respond_with( ResponseTemplate::new(200).set_body_json(json!({ - "data": { "paymentsEscrowTransactions": [ { "id": "0x01", "allocationId": TAP_SIGNER.1.to_string(), "timestamp": "1" } ] } + "data": { + "meta": { "block": { "number": 1, "hash": "0x00", "timestamp": 1 } }, + "allocations": [ { "id": "0x01" } ] + } })), ), ) @@ -161,15 +149,48 @@ mod tests { .await, )); - let check = - AllocationRedeemedCheck::new(Address::from([0x22u8; 20]), Some(network_subgraph)); + let check = AllocationRedeemedCheck::new(Some(network_subgraph)); - let mut ctx = Context::default(); - ctx.insert(Sender(TAP_SIGNER.1)); + let ctx = Context::default(); let receipt = create_v2_receipt(TAP_SIGNER.1); let checking = crate::tap::CheckingReceipt::new(receipt); let result = check.check(&ctx, &checking).await; assert!(result.is_err()); } + + #[tokio::test] + async fn v2_open_allocation_passes() { + let mock_server: MockServer = MockServer::start().await; + mock_server + .register( + Mock::given(body_string_contains("allocations")).respond_with( + ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "meta": { "block": { "number": 1, "hash": "0x00", "timestamp": 1 } }, + "allocations": [] + } + })), + ), + ) + .await; + + let network_subgraph = Box::leak(Box::new( + SubgraphClient::new( + reqwest::Client::new(), + None, + DeploymentDetails::for_query_url(&mock_server.uri()).unwrap(), + ) + .await, + )); + + let check = AllocationRedeemedCheck::new(Some(network_subgraph)); + + let ctx = Context::default(); + let receipt = create_v2_receipt(TAP_SIGNER.1); + let checking = crate::tap::CheckingReceipt::new(receipt); + + let result = check.check(&ctx, &checking).await; + assert!(result.is_ok()); + } } diff --git a/crates/service/tests/router_test.rs b/crates/service/tests/router_test.rs index 3f8d8c924..d939d0698 100644 --- a/crates/service/tests/router_test.rs +++ b/crates/service/tests/router_test.rs @@ -126,7 +126,9 @@ async fn full_integration_test() { )); mock_server.register(mock).await; - // Mock network subgraph redemption queries. + // Mock escrow subgraph (v1) and network subgraph (v2) queries. + // The v2 allocation check queries for closed allocations; returning an + // empty list means the allocation is still open (receipts accepted). mock_server .register(Mock::given(method("POST")).and(path("/")).respond_with( ResponseTemplate::new(200).set_body_raw( @@ -134,7 +136,8 @@ async fn full_integration_test() { { "data": { "transactions": [], - "paymentsEscrowTransactions": [] + "meta": { "block": { "number": 1, "hash": "0x00", "timestamp": 1 } }, + "allocations": [] } } "#, 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);