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