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 15ce1c5b3..5198c91bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4331,17 +4331,19 @@ 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", + "indexer-query", "ipfs-api-backend-hyper", + "prometheus 0.13.4", "prost 0.14.3", - "rand 0.9.5", + "rand 0.8.5", + "reqwest 0.12.24", "serde", "serde_json", "serde_yaml", @@ -4357,6 +4359,7 @@ dependencies = [ "tonic-prost-build", "tracing", "uuid", + "wiremock", ] [[package]] diff --git a/crates/config/default_values.toml b/crates/config/default_values.toml index 292858d9d..aa4829598 100644 --- a/crates/config/default_values.toml +++ b/crates/config/default_values.toml @@ -6,7 +6,6 @@ syncing_interval_secs = 60 recently_closed_allocation_buffer_secs = 3600 max_data_staleness_mins = 30 escrow_min_balance_grt_wei = "100000000000000000" -max_signers_per_payer = 0 [service] ipfs_url = "https://api.thegraph.com/ipfs/" diff --git a/crates/config/maximal-config-example.toml b/crates/config/maximal-config-example.toml index 847229990..9ed4701e8 100644 --- a/crates/config/maximal-config-example.toml +++ b/crates/config/maximal-config-example.toml @@ -64,12 +64,13 @@ status_url = "http://graph-node:8000/graphql" [subgraphs.network] # Query URL for the Graph Network subgraph. query_url = "http://example.com/network-subgraph" -# Optional, Auth token will used a "bearer auth" +# Bearer token sent with queries to query_url. Set only if that endpoint needs auth (e.g. a +# hosted/gateway URL); leave unset for an open or self-hosted graph-node. Default unset. # query_auth_token = "super-secret" -# Optional, deployment to look for in the local `graph-node`, if locally indexed. -# Locally indexing the subgraph is recommended. -# NOTE: Use `query_url` or `deployment_id` only +# Optional: index this subgraph on your own graph-node and put its deployment id here; queries +# then prefer your local node (free) and fall back to query_url only if it is unsynced or errors. +# query_url stays required as that fallback. Indexing locally is recommended. deployment_id = "Qmaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Refreshing interval for the Graph contracts information from the Graph Network # subgraph. @@ -78,18 +79,32 @@ syncing_interval_secs = 60 # So that we can keep serving queries while the information about the allocation closure # propagates to all the consumers. recently_closed_allocation_buffer_secs = 3600 -# Maximum allowed age of network subgraph data in minutes. -# Responses older than this are rejected to prevent stale data from replacing fresh data. -# This protects against Gateway routing queries to indexers that are significantly behind. -# Set to 0 to disable staleness checking. +# Maximum allowed age of network subgraph data in minutes; responses older than this are +# rejected so stale data can't replace fresh data or hide an indexer that's behind. +# Must be positive. Default 30. max_data_staleness_mins = 30 # Minimum escrow balance (GRT wei) for V2 escrow queries. Filters dust deposits # to raise the cost of crowding attacks. Default: 0.1 GRT. escrow_min_balance_grt_wei = "100000000000000000" -# Maximum signers to fetch per payer. 0 = no limit (recommended). -# A positive value caps pagination but allows attackers to crowd out legitimate -# signers, orphaning receipts and enabling free queries. -max_signers_per_payer = 0 +# Maximum signers to fetch per payer. Unset = no limit (recommended); a positive +# value caps pagination but lets attackers crowd out legitimate signers. +# max_signers_per_payer = 1000 + +# Indexing-payments subgraph, read for the set of accounts allowed to send DIPs +# agreement proposals (the agreement-manager role). Required when [dips] is set. +[subgraphs.indexing_payments] +# Query URL for the indexing-payments subgraph, a SEPARATE deployment from [subgraphs.network]. +query_url = "http://example.com/indexing-payments-subgraph" +# Required when this section is present (no default). How often to refresh from this subgraph, in +# seconds. Distinct from dips.role_refresh_interval_secs, which governs the role-set pull. +syncing_interval_secs = 60 +# Bearer token sent with queries to query_url. Set only if that endpoint needs auth; leave unset +# for an open or self-hosted graph-node. Default unset. +# query_auth_token = "super-secret" + +# Optional: index this subgraph yourself and set its deployment id; queries then prefer your +# local node (free) and fall back to query_url if it is unsynced. query_url stays required. +# deployment_id = "Qmbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" [blockchain] # The chain ID of the network that the graph network is running on @@ -110,9 +125,11 @@ url_prefix = "/" # Serve the network subgraph on `common.server.host_and_port`/network serve_network_subgraph = false #### OPTIONAL VALUES #### -## use this to add a layer while serving network/escrow subgraph + +# Bearer token that gates the served network subgraph endpoint (serve_network_subgraph). # serve_auth_token = "token" -## allow queries using this token + +# Lets anyone presenting this token query for FREE, with no TAP receipt required. Share sparingly. # free_query_auth_token = "i-am-authorized-right?" ipfs_url = "https://api.thegraph.com/ipfs/" # Maximum number of deployments allowed in a single /cost GraphQL batch query. @@ -123,7 +140,6 @@ max_cost_model_batch_size = 200 # Default: 2MB (2097152 bytes). Increase if you need to support larger GraphQL queries. max_request_body_size = 2097152 - [service.tap] # Maximum value of a receipt, in GRT wei. # We need this because a large receipt, especially if it's larger than the RAV request trigger, @@ -144,11 +160,11 @@ max_receipt_value_grt = "0.001" # 0.001 GRT. We use strings to prevent rounding # max_amount_willing_to_lose_grt = "0.1" max_amount_willing_to_lose_grt = 20 -# List of Senders that are allowed to spend up to `max_amount_willing_to_lose_grt` -# over the escrow balance +# Senders allowed to spend up to `max_amount_willing_to_lose_grt` beyond their escrow balance. +# This is unsecured credit: if such a sender stops aggregating you may never recover those fees, +# so only list senders you trust to settle. Default empty. trusted_senders = ["0xdeadbeefcafebabedeadbeefcafebabedeadbeef"] - # Receipts query timeout sender_timeout_secs = 30 @@ -174,35 +190,71 @@ max_receipts_per_request = 10000 0xDDE4cfFd3D9052A9cb618fC05a1Cd02be1f2F467 = "https://tap-aggregator.network.thegraph.com" 0xDD6a6f76eb36B873C1C184e8b9b9e762FE216490 = "https://tap-aggregator-arbitrum-one.graphops.xyz" -# DIPs (Direct Indexing Payments) -# These fields are consumed by the public `/dips/info` HTTP endpoint. External -# tooling reads them to discover which networks you are willing to index and at -# what minimum price, ahead of any indexing agreement actually being offered. -# -# Pricing uses human-readable GRT values (not wei), e.g. "100" = 100 GRT per 30 days. -# Pricing a chain in [dips.min_grt_per_30_days] is also your public declaration -# of support for that chain. -# See https://github.com/graphprotocol/networks-registry/blob/main/docs/networks-table.md +# DIPs (Direct Indexing Payments). Each proposal's recovered EIP-712 signer is checked against +# the on-chain agreement-manager role set, read off the indexing-payments subgraph at proposal +# time, not the payer and not on-chain at acceptance. Prices below are GRT, not wei. + +# To enable DIPs you must set: recurring_collector, [subgraphs.indexing_payments] (query_url + +# syncing_interval_secs), and at least one supported_networks entry with a matching price under +# [dips.min_grt_per_30_days]. Everything else here has a default. + +# Failure modes: an empty role set, or a subgraph outage past role_failopen_grace_secs, rejects +# all proposals; if DIPs init fails the service keeps serving queries but runs WITHOUT DIPs +# (check the /dips/info endpoint and the indexer_dips_enabled metric). Per-field consequences below. + [dips] +# Address/port the DIPs gRPC server binds to. The dipper dials port 7602 on the URL you +# registered on chain, so that port needs public ingress; move it and proposals silently +# stop arriving. port is a quoted string. Defaults: "0.0.0.0" / "7602". host = "0.0.0.0" -port = "7601" +port = "7602" + +# Networks you support indexing. REQUIRED to accept anything: list at least one here AND set its +# price under [dips.min_grt_per_30_days]; empty = ALL proposals rejected (not "accept all"). +# Names: https://github.com/graphprotocol/networks-registry (e.g. ["mainnet", "arbitrum-one"]). +supported_networks = [] # Minimum payment you are willing to accept, expressed as GRT per 30 days. Two parts add up: # - min_grt_per_30_days[network]: flat base rate, per chain # - min_grt_per_billion_entities_per_30_days: scaled by the subgraph's entity count -# + # For a subgraph with N entities, your 30-day minimum is: # min_grt_per_30_days[network] + min_grt_per_billion_entities_per_30_days * (N / 1_000_000_000) -# + # Payment is metered per second (not per block), so a shorter agreement earns the # matching fraction of this 30-day figure. -# + # For reference: analysis of subgraphs indexed by the upgrade indexer in Q1 2025 found # the average entity size to be ~0.759 KiB. At this size, 1 billion entities ≈ 0.707 TiB. # Your own observations may differ - adjust pricing accordingly. -# min_grt_per_billion_entities_per_30_days = "200" # entity-based component (global) +min_grt_per_billion_entities_per_30_days = "200" # entity-based component (global) + +# RecurringCollector contract address: the EIP-712 verifying contract used to recover each +# proposal's signer, which is then checked against the agreement-manager role set. Also the +# contract rpc_url reads the domain from. Required when DIPs is enabled. +recurring_collector = "0xcccccccccccccccccccccccccccccccccccccccc" + +# How often (seconds) to re-read the indexing-payments subgraph for which addresses may send you +# agreements. New senders are picked up within seconds anyway; this mainly sets how fast a REVOKED +# sender stops being accepted, so lower it to apply revocations sooner. Must be > 0. Default 86400. +role_refresh_interval_secs = 86400 +# What happens during a subgraph outage. If re-reads keep failing, senders already on the last +# good list stay accepted for this many extra seconds (brand-new senders never are); once it +# elapses, every proposal is rejected until the subgraph recovers. Default 3600 (1 hour). +role_failopen_grace_secs = 3600 +# Safety check on subgraph freshness: if the subgraph has fallen more than this many seconds +# behind the chain, its sender list is treated as untrustworthy and proposals are rejected as +# "try again later" rather than risk stale data. Must be positive. Default 1800 (30 min). +role_subgraph_max_lag_secs = 1800 +# Startup chain RPC for the RecurringCollector's EIP-712 signing domain. Unset uses built-in +# values (correct on standard networks); set on local/dev or upgraded chains, else every proposal fails. +# rpc_url = "https://arb1.arbitrum.io/rpc" -[dips.min_grt_per_30_days] # base rate component (per-network); keys = supported chains +# Optional safeguard: cap live DIPs agreements (awaiting acceptance or accepted) per +# rolling 24h; rejected and expired proposals don't count. Unset = no cap; 0 rejects all. +# max_new_agreements_per_24h = 30 + +[dips.min_grt_per_30_days] # base rate component (per-network) # arbitrum-one = "450" # matic = "300" # avalanche = "225" @@ -212,3 +264,10 @@ port = "7601" # optimism = "30" # base-sepolia = "15" # sepolia = "5" + +# Custom or test networks not in the global networks-registry: maps network name to CAIP-2 chain id. +# Its only effect is the chain_id field on the price-rejection logs, which reads "unknown" without it. +# It blocks nothing: acceptance needs supported_networks plus a [dips.min_grt_per_30_days] price, and +# a network missing from those is rejected before any chain_id is resolved. +# Default empty. e.g. hardhat = "eip155:1337" +[dips.additional_networks] diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index 779c1c1f1..2c78d9dda 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -5,6 +5,7 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, env, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, + num::{NonZeroU64, NonZeroUsize}, path::PathBuf, time::Duration, }; @@ -275,6 +276,36 @@ impl Config { ); } + // The escrow dust floor is sent to the subgraph as an integer; reject a + // non-numeric value at load rather than failing later at query time. + if self + .subgraphs + .network + .escrow_min_balance_grt_wei + .parse::() + .is_err() + { + return Err( + "subgraphs.network.escrow_min_balance_grt_wei must be a non-negative integer (wei)" + .to_string(), + ); + } + + // Reject zero for limits where it is nonsensical: a metrics port of 0 binds a + // random OS port the scraper can't find, and a zero batch/body size rejects all work. + if self.metrics.port == 0 { + return Err("metrics.port must be greater than 0".to_string()); + } + if self.service.max_cost_model_batch_size == 0 { + return Err("service.max_cost_model_batch_size must be greater than 0".to_string()); + } + if self.service.max_request_body_size == 0 { + return Err("service.max_request_body_size must be greater than 0".to_string()); + } + if self.service.max_status_request_body_size == 0 { + return Err("service.max_status_request_body_size must be greater than 0".to_string()); + } + if self.tap.allocation_reconciliation_interval_secs < Duration::from_secs(60) { tracing::warn!( "Your `tap.allocation_reconciliation_interval_secs` value is too low. \ @@ -283,6 +314,23 @@ impl Config { ); } + // Validate DIPs settings up front rather than partway through boot. A zero + // refresh interval panics the refresh task (tokio rejects a zero period); the + // collector address and indexing-payments subgraph are both needed to authorise. + if let Some(dips) = &self.dips { + if dips.role_refresh_interval_secs == 0 { + return Err("dips.role_refresh_interval_secs must be greater than 0".to_string()); + } + if dips.recurring_collector.is_none() { + return Err("dips.recurring_collector must be set when DIPs is enabled".to_string()); + } + if self.subgraphs.indexing_payments.is_none() { + return Err( + "subgraphs.indexing_payments must be set when DIPs is enabled".to_string(), + ); + } + } + // Warn about auth tokens over cleartext HTTP (TRST-L-3) // This is a security risk as tokens can be intercepted Self::warn_if_token_over_http( @@ -470,6 +518,10 @@ impl MetricsConfig { #[allow(deprecated)] // Allow using deprecated EscrowSubgraphConfig type pub struct SubgraphsConfig { pub network: NetworkSubgraphConfig, + /// Indexing-payments subgraph, read for the DIPs agreement-manager role set. + /// Required when DIPs is enabled. + #[serde(default)] + pub indexing_payments: Option, #[deprecated(note = "V2 escrow accounts are in the network subgraph; this field is ignored.")] #[serde(default)] pub escrow: Option, @@ -485,39 +537,32 @@ pub struct NetworkSubgraphConfig { #[serde_as(as = "DurationSecondsWithFrac")] pub recently_closed_allocation_buffer_secs: Duration, - /// Maximum allowed age of network subgraph data in minutes. - /// Responses older than this are rejected to prevent stale data from replacing fresh data. - /// This protects against Gateway routing queries to indexers that are significantly behind. - /// Set to 0 to disable staleness checking. - /// Default: 30 (minutes) + /// Maximum allowed age of network subgraph data in minutes; responses older than + /// this are rejected so stale data can't replace fresh data or hide an indexer + /// that is significantly behind. Must be positive. Default: 30 (minutes). #[serde(default = "default_max_data_staleness_mins")] - pub max_data_staleness_mins: u64, + pub max_data_staleness_mins: NonZeroU64, /// Minimum escrow balance (GRT wei) for the V2 escrow query. Filters dust /// deposits to raise the cost of crowding attacks. Default: 0.1 GRT. #[serde(default = "default_escrow_min_balance_grt_wei")] pub escrow_min_balance_grt_wei: String, - /// Maximum signers to fetch per payer. 0 = no limit (recommended). - /// Setting a positive value caps pagination but allows attackers to crowd out - /// legitimate signers, orphaning receipts and enabling free queries. - /// Default: 0. - #[serde(default = "default_max_signers_per_payer")] - pub max_signers_per_payer: usize, + /// Maximum signers to fetch per payer. Leave unset for no limit (recommended); + /// a positive cap lets attackers crowd out legitimate signers, orphaning + /// receipts and enabling free queries. + #[serde(default)] + pub max_signers_per_payer: Option, } -fn default_max_data_staleness_mins() -> u64 { - 30 +fn default_max_data_staleness_mins() -> NonZeroU64 { + NonZeroU64::new(30).expect("30 is non-zero") } fn default_escrow_min_balance_grt_wei() -> String { "100000000000000000".to_string() // 0.1 GRT } -fn default_max_signers_per_payer() -> usize { - 0 -} - #[deprecated(note = "V2 escrow accounts are in the network subgraph; escrow config is ignored.")] #[derive(Debug, Deserialize, Default)] #[cfg_attr(test, derive(PartialEq))] @@ -658,60 +703,67 @@ fn default_allocation_reconciliation_interval_secs() -> Duration { Duration::from_secs(300) } +/// DIPs configuration. Authorises proposal senders by recovering each agreement's EIP-712 +/// signer and requiring it to hold the agreement-manager role (read from the indexing-payments +/// subgraph), then validates accepted proposals before storing them for the indexer-agent. #[derive(Debug, Deserialize)] +#[serde(default)] #[cfg_attr(test, derive(PartialEq))] pub struct DipsConfig { /// Network interface the DIPs gRPC server binds to (e.g. `"0.0.0.0"` for all interfaces). pub host: String, - - /// Port the DIPs gRPC server listens on. + /// Port the DIPs gRPC server binds to. The payer dials this port on the URL the indexer + /// registered on chain, so changing it makes this indexer unreachable for DIPs unless + /// something forwards the default port to it. pub port: String, - - /// Fallback mapping for chains the on-chain networks registry does not - /// know about. Keys are chain identifiers in the form `eip155:`; - /// values must match the network name declared in subgraph manifests - /// (e.g. `"eip155:1337" = "hardhat"`). - #[serde(default)] - pub additional_networks: HashMap, - - // Legacy gRPC handler fields. Scheduled for removal when the on-chain - // agreement validation path lands. Kept here with serde defaults so - // configs predating the signalling surface continue to parse. - #[serde(default)] - pub allowed_payers: Vec
, - #[serde(default = "default_price_per_entity")] - pub price_per_entity: U256, - #[serde(default)] - pub price_per_epoch: BTreeMap, - - /// Per-network base price floor in GRT per 30 days. The set of map keys - /// is also this indexer's public declaration of supported chains — - /// pricing a chain here means committing to support it. Surfaced via - /// `/dips/info`. - #[serde(default)] + /// Networks this indexer explicitly supports. A network is accepted only when it + /// appears here AND has a `min_grt_per_30_days` entry; listing it without a price + /// still rejects every proposal. Proposals for other networks are rejected. + pub supported_networks: HashSet, + /// Minimum acceptable GRT per 30 days, per network. Converted to wei/second internally. + /// A network priced here is still rejected unless it also appears in `supported_networks`. pub min_grt_per_30_days: BTreeMap, - - /// Global per-entity price floor in GRT per billion entities per 30 days. - /// Surfaced via `/dips/info`. - #[serde(default)] + /// Minimum acceptable GRT per billion entities per 30 days. pub min_grt_per_billion_entities_per_30_days: GRT, -} - -fn default_price_per_entity() -> U256 { - U256::from(100) + /// CAIP-2 chain id per network name, for chains the global networks registry does not + /// cover. Used only to resolve the chain id logged with a price rejection; it grants no + /// network access on its own. + pub additional_networks: BTreeMap, + /// RecurringCollector address: the EIP-712 verifying contract used to recover + /// the signer of incoming RCA proposals. Required when DIPs is enabled. + pub recurring_collector: Option
, + /// How often to re-pull the agreement-manager role set from the subgraph. + pub role_refresh_interval_secs: u64, + /// Extra time past the refresh interval that a cached role set stays trusted + /// while refreshes fail, before the gate fails closed (the fail-open window). + pub role_failopen_grace_secs: u64, + /// Max seconds the role subgraph head may lag wall-clock before its data is + /// treated as unreliable. Must be positive. + pub role_subgraph_max_lag_secs: NonZeroU64, + /// Ethereum JSON-RPC endpoint used to fetch the EIP-712 domain from the + /// RecurringCollector at startup (EIP-5267). Unset: built-in domain constants. + pub rpc_url: Option, + /// Max live DIPs agreements (awaiting acceptance or accepted) per rolling 24h + /// window; rejected and expired proposals don't count. None (unset) disables the + /// cap; a best-effort safeguard, not a security control. + pub max_new_agreements_per_24h: Option, } impl Default for DipsConfig { fn default() -> Self { 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(), + port: "7602".to_string(), + supported_networks: HashSet::new(), min_grt_per_30_days: BTreeMap::new(), min_grt_per_billion_entities_per_30_days: GRT::ZERO, + additional_networks: BTreeMap::new(), + recurring_collector: None, + role_refresh_interval_secs: 86_400, + role_failopen_grace_secs: 3_600, + role_subgraph_max_lag_secs: NonZeroU64::new(1_800).expect("1800 is non-zero"), + rpc_url: None, + max_new_agreements_per_24h: None, } } } @@ -785,8 +837,18 @@ mod tests { max_config.tap.trusted_senders = HashSet::from([address!("deadbeefcafebabedeadbeefcafebabedeadbeef")]); max_config.dips = Some(crate::DipsConfig { + min_grt_per_billion_entities_per_30_days: crate::GRT::from_grt("200"), + recurring_collector: Some(address!("cccccccccccccccccccccccccccccccccccccccc")), ..Default::default() }); + max_config.subgraphs.indexing_payments = Some(crate::SubgraphConfig { + query_url: "http://example.com/indexing-payments-subgraph" + .parse() + .unwrap(), + query_auth_token: None, + deployment_id: None, + syncing_interval_secs: std::time::Duration::from_secs(60), + }); let max_config_file: Config = toml::from_str( fs::read_to_string("maximal-config-example.toml") @@ -1319,4 +1381,138 @@ mod tests { .unwrap_err() .contains("No operator mnemonic configured")); } + + /// Test that config validation rejects a non-numeric escrow dust floor. + #[sealed_test(files = ["minimal-config-example.toml"])] + fn test_validation_rejects_non_numeric_escrow_min_balance() { + let mut minimal_config: toml::Value = toml::from_str( + fs::read_to_string("minimal-config-example.toml") + .unwrap() + .as_str(), + ) + .unwrap(); + + minimal_config + .get_mut("subgraphs") + .unwrap() + .get_mut("network") + .unwrap() + .as_table_mut() + .unwrap() + .insert( + "escrow_min_balance_grt_wei".to_string(), + toml::Value::String("not-a-number".to_string()), + ); + + let temp_config_path = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp_config_path.path(), + toml::to_string(&minimal_config).unwrap(), + ) + .unwrap(); + + let result = Config::parse( + ConfigPrefix::Service, + Some(PathBuf::from(temp_config_path.path())).as_ref(), + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("escrow_min_balance_grt_wei")); + } + + /// Test that config validation rejects a metrics port of 0. + #[sealed_test(files = ["minimal-config-example.toml"])] + fn test_validation_rejects_zero_metrics_port() { + let mut minimal_config: toml::Value = toml::from_str( + fs::read_to_string("minimal-config-example.toml") + .unwrap() + .as_str(), + ) + .unwrap(); + + let mut metrics = toml::value::Table::new(); + metrics.insert("port".to_string(), toml::Value::Integer(0)); + minimal_config + .as_table_mut() + .unwrap() + .insert("metrics".to_string(), toml::Value::Table(metrics)); + + let temp_config_path = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp_config_path.path(), + toml::to_string(&minimal_config).unwrap(), + ) + .unwrap(); + + let result = Config::parse( + ConfigPrefix::Service, + Some(PathBuf::from(temp_config_path.path())).as_ref(), + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("metrics.port")); + } + + // === DIPS Startup Validation Tests === + + /// Test that minimal config has no DIPS section (safe default for existing indexers). + #[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" + ); + assert_eq!( + dips.port, + super::DipsConfig::default().port, + "the example config must document the default DIPs port: the payer dials the \ + default, so an indexer copying a stale example silently receives no proposals" + ); + } } diff --git a/crates/config/src/grt.rs b/crates/config/src/grt.rs index a86e973dc..74f3b8b8b 100644 --- a/crates/config/src/grt.rs +++ b/crates/config/src/grt.rs @@ -20,7 +20,16 @@ pub struct GRT(u128); impl GRT { pub const ZERO: GRT = GRT(0); - pub fn new(wei: u128) -> Self { + /// 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) } diff --git a/crates/dips/Cargo.toml b/crates/dips/Cargo.toml index 1cb7bf2c5..b9d92bd1d 100644 --- a/crates/dips/Cargo.toml +++ b/crates/dips/Cargo.toml @@ -12,43 +12,61 @@ rpc = [ "dep:tonic-prost", "dep:tonic-prost-build", "dep:bytes", + "dep:graph-networks-registry", + "dep:serde", + "dep:serde_yaml", + "dep:prometheus", +] +db = [ + "dep:sqlx", + "dep:build-info", + "dep:indexer-monitor", + "dep:indexer-query", + "dep:graph-networks-registry", + "dep:serde", + "dep:serde_yaml", + "dep:prometheus", ] -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 } +indexer-query = { path = "../query", optional = true } +thiserror.workspace = true +graph-networks-registry = { workspace = true, optional = true } +serde = { workspace = true, optional = true, features = ["derive"] } +serde_yaml = { version = "0.9", optional = true } +prometheus = { workspace = true, 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" +reqwest = { workspace = true, features = ["json"] } +serde_json.workspace = true +tokio = { workspace = true, features = ["test-util"] } +wiremock.workspace = true [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..3bc87993c 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 re-sent proposal: an exact replay of one already rejected, or a different payload reusing an already-seen agreement id. + REJECT_REASON_INSUFFICIENT_ESCROW = 13; /// The payer has insufficient escrow to back the agreement. + REJECT_REASON_INDEXER_UNAVAILABLE = 14; /// A transient internal error; the proposal may be resent. } diff --git a/crates/dips/src/database.rs b/crates/dips/src/database.rs index fdaf1f66e..e79c31e2b 100644 --- a/crates/dips/src/database.rs +++ b/crates/dips/src/database.rs @@ -1,366 +1,211 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::str::FromStr; +//! PostgreSQL implementation of [`RcaStore`]. +//! +//! This module provides [`PsqlRcaStore`], which persists validated RCA proposals +//! to the `pending_rca_proposals` table. The indexer-agent queries this table +//! 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" +//! 5. Once an accepted agreement finishes, the agent marks it "completed" +//! +//! Steps 2 to 5 are the indexer-agent's side of the contract and live in that +//! separate repository; the only status this crate ever writes is "pending". +//! +//! The optional `max_new_agreements_per_24h` cap counts only "pending" and +//! "accepted" rows from the last 24 hours, so reaching "completed" or "rejected" +//! frees capacity again. +//! +//! # Idempotency +//! +//! 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, time::Duration}; 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, Row}; use uuid::Uuid; use crate::{ - store::{AgreementStore, StoredIndexingAgreement}, - DipsError, SignedCancellationRequest, SignedIndexingAgreementVoucher, - SubgraphIndexingVoucherMetadata, + store::{RcaStore, StoredProposal}, + 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", + async fn lookup(&self, agreement_id: Uuid) -> Result, DipsError> { + let row = + sqlx::query("SELECT status, signed_payload FROM pending_rca_proposals WHERE id = $1") + .bind(agreement_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| DipsError::UnknownError(e.into()))?; + + Ok(row.map(|row| StoredProposal { + status: row.get("status"), + signed_payload: row.get("signed_payload"), + })) + } + + async fn count_since(&self, window: Duration) -> Result { + // Count live agreements only: 'pending' (awaiting the agent) and 'accepted'. + // The agent records rejected and expired proposals as 'rejected' and finished + // ones as 'completed', so neither counts. NOW() is the DB clock, so the window + // ignores host clock skew. + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pending_rca_proposals + WHERE status IN ('pending', 'accepted') + AND created_at >= NOW() - ($1 * interval '1 second')", ) - .bind(now) - .bind(bs) - .bind(id) - .execute(&self.pool) + .bind(window.as_secs() as i64) + .fetch_one(&self.pool) .await - .map_err(|_| DipsError::AgreementNotFound)?; + .map_err(|e| DipsError::UnknownError(e.into()))?; - Ok(id) + Ok(count.max(0) as u64) + } + + fn as_any(&self) -> &dyn Any { + self } } #[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; +mod tests { + use std::time::Duration; 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(), - }; + use crate::PROTOCOL_VERSION; - // 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"); + /// Insert a proposal row with `status`, created `hours_ago` before the DB clock. + async fn insert_at(pool: &PgPool, id: Uuid, hours_ago: i64, status: &str) { + sqlx::query( + "INSERT INTO pending_rca_proposals (id, signed_payload, version, status, created_at, updated_at) + VALUES ($1, $2, 2, $4, NOW() - ($3 * interval '1 hour'), NOW())", + ) + .bind(id) + .bind(vec![1u8, 2, 3]) + .bind(hours_ago) + .bind(status) + .execute(pool) + .await + .unwrap(); } + // Needs Docker; skipped locally, runs on CI. #[tokio::test] - async fn test_get_agreement_by_id() { + async fn test_lookup_roundtrip() { + // Arrange 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(), + let store = PsqlRcaStore { + pool: test_db.pool.clone(), }; + let id = Uuid::now_v7(); + let payload = vec![1u8, 2, 3, 4, 5]; - // 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(), - }, - }; + // Act + Assert: absent id resolves to None. + assert!(store.lookup(id).await.unwrap().is_none()); - // Store agreement + // Act + Assert: after store_rca the row is found with default status. store - .create_agreement(agreement.clone(), metadata.clone()) + .store_rca(id, payload.clone(), PROTOCOL_VERSION) .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); + let found = store.lookup(id).await.unwrap(); assert_eq!( - retrieved_voucher.voucher.recipient, - agreement.voucher.recipient + found, + Some(StoredProposal { + status: "pending".to_string(), + signed_payload: payload, + }) ); - 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); } + // Exercises the rolling-window SQL against a real Postgres (testcontainers); + // the in-memory store can't, since it ignores the window. #[tokio::test] - async fn test_cancel_agreement() { + async fn count_since_excludes_rows_outside_the_window() { 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(), - }, + let store = PsqlRcaStore { + pool: test_db.pool.clone(), }; + insert_at(&test_db.pool, Uuid::from_u128(1), 1, "pending").await; // inside 24h + insert_at(&test_db.pool, Uuid::from_u128(2), 25, "pending").await; // outside 24h - // Store agreement - store - .create_agreement(agreement.clone(), metadata) + let count = store + .count_since(Duration::from_secs(24 * 60 * 60)) .await .unwrap(); - // Cancel agreement - let cancellation = SignedCancellationRequest { - signature: vec![1, 2, 3].into(), - request: CancellationRequest { - agreement_id: id.as_bytes().into(), - }, + assert_eq!(count, 1, "only the row inside the window should count"); + } + + // The cap counts live agreements only; the agent records both rejected and + // expired proposals as 'rejected', so neither should count toward the cap. + #[tokio::test] + async fn count_since_counts_only_pending_and_accepted() { + let test_db = test_assets::setup_shared_test_db().await; + let store = PsqlRcaStore { + pool: test_db.pool.clone(), }; - store.cancel_agreement(cancellation.clone()).await.unwrap(); + insert_at(&test_db.pool, Uuid::from_u128(1), 1, "pending").await; + insert_at(&test_db.pool, Uuid::from_u128(2), 1, "accepted").await; + insert_at(&test_db.pool, Uuid::from_u128(3), 1, "rejected").await; - // Verify stored agreement - let row = sqlx::query("SELECT * FROM indexing_agreements WHERE id = $1") - .bind(id) - .fetch_one(&store.pool) + let count = store + .count_since(Duration::from_secs(24 * 60 * 60)) .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())); + assert_eq!(count, 2, "only pending and accepted rows should count"); } } diff --git a/crates/dips/src/eip5267.rs b/crates/dips/src/eip5267.rs new file mode 100644 index 000000000..e25fd388b --- /dev/null +++ b/crates/dips/src/eip5267.rs @@ -0,0 +1,313 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! EIP-5267 domain discovery: derive the RCA verification domain from the +//! RecurringCollector's own `eip712Domain()` report so it tracks in-place +//! contract upgrades instead of relying on compile-time constants. + +use std::time::Duration; + +use anyhow::Context; +use thegraph_core::alloy::{ + network::TransactionBuilder, + primitives::{Address, Bytes, U256}, + providers::{Provider, ProviderBuilder}, + rpc::types::TransactionRequest, + sol, + sol_types::{Eip712Domain, SolCall}, +}; + +sol! { + /// EIP-5267 introspection: a contract reports its own EIP-712 domain. + function eip712Domain() external view returns ( + bytes1 fields, + string name, + string version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] extensions + ); +} + +/// Attempts before giving up on the RPC fetch. +const FETCH_ATTEMPTS: u32 = 3; +/// Delay between fetch attempts. +const FETCH_RETRY_DELAY: Duration = Duration::from_secs(2); +/// Upper bound on the reported domain name/version. The real values are short +/// ("RecurringCollector", "1"); anything huge is a hostile or broken RPC. +const MAX_DOMAIN_FIELD_LEN: usize = 256; + +/// Fetch the RCA EIP-712 domain from the RecurringCollector via `eip712Domain()`. +/// Only network errors are retried; a response that fails to decode or fails +/// validation (wrong chain id or contract) errors immediately, retries won't fix it. +pub async fn fetch_rca_eip712_domain( + rpc_url: &str, + recurring_collector: Address, + expected_chain_id: u64, +) -> anyhow::Result { + let mut attempt = 1; + let output = loop { + match call_eip712_domain(rpc_url, recurring_collector).await { + Ok(output) => break output, + Err(err) if attempt < FETCH_ATTEMPTS => { + tracing::warn!( + attempt, + error = format!("{err:#}"), + "eip712Domain() fetch failed, retrying" + ); + attempt += 1; + tokio::time::sleep(FETCH_RETRY_DELAY).await; + } + Err(err) => return Err(err), + } + }; + let report = eip712DomainCall::abi_decode_returns(&output) + .context("decoding the eip712Domain() response")?; + let domain = domain_from_report(report, recurring_collector, expected_chain_id)?; + tracing::info!( + name = domain.name.as_deref().unwrap_or_default(), + version = domain.version.as_deref().unwrap_or_default(), + chain_id = expected_chain_id, + verifying_contract = %recurring_collector, + "RCA EIP-712 domain fetched from RecurringCollector" + ); + Ok(domain) +} + +async fn call_eip712_domain(rpc_url: &str, recurring_collector: Address) -> anyhow::Result { + let provider = ProviderBuilder::new() + .connect(rpc_url) + .await + .context("connecting to dips.rpc_url")?; + let request = TransactionRequest::default() + .with_to(recurring_collector) + .with_input(eip712DomainCall {}.abi_encode()); + provider + .call(request) + .await + .context("calling eip712Domain() on the RecurringCollector") +} + +/// Build the verification domain from the contract's report, rejecting +/// reports that don't match the configured chain id and collector address. +fn domain_from_report( + report: eip712DomainReturn, + recurring_collector: Address, + expected_chain_id: u64, +) -> anyhow::Result { + anyhow::ensure!( + report.fields.0 == [0x0f], + "eip712Domain() fields bitmap {:#04x} unsupported: expected 0x0f \ + (name, version, chainId, verifyingContract)", + report.fields.0[0] + ); + anyhow::ensure!( + report.extensions.is_empty(), + "eip712Domain() reports {} extensions, which are not supported", + report.extensions.len() + ); + anyhow::ensure!( + !report.name.is_empty() && report.name.len() <= MAX_DOMAIN_FIELD_LEN, + "eip712Domain() reports a domain name of {} bytes; expected 1-{MAX_DOMAIN_FIELD_LEN}", + report.name.len() + ); + anyhow::ensure!( + !report.version.is_empty() && report.version.len() <= MAX_DOMAIN_FIELD_LEN, + "eip712Domain() reports a domain version of {} bytes; expected 1-{MAX_DOMAIN_FIELD_LEN}", + report.version.len() + ); + anyhow::ensure!( + report.chainId == U256::from(expected_chain_id), + "eip712Domain() reports chain id {} but blockchain.chain_id is {expected_chain_id}; \ + check that dips.rpc_url points at the right network", + report.chainId + ); + anyhow::ensure!( + report.verifyingContract == recurring_collector, + "eip712Domain() reports verifying contract {} but dips.recurring_collector \ + is {recurring_collector}", + report.verifyingContract + ); + Ok(Eip712Domain::new( + Some(report.name.into()), + Some(report.version.into()), + Some(U256::from(expected_chain_id)), + Some(recurring_collector), + None, + )) +} + +#[cfg(test)] +mod tests { + use thegraph_core::alloy::primitives::{hex, Address, FixedBytes, B256, U256}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + use super::*; + use crate::rca_eip712_domain; + + const CHAIN_ID: u64 = 1337; + + fn collector() -> Address { + Address::repeat_byte(0xCC) + } + + fn report( + fields: u8, + name: &str, + version: &str, + chain_id: u64, + verifying_contract: Address, + ) -> eip712DomainReturn { + eip712DomainReturn { + fields: FixedBytes([fields]), + name: name.to_string(), + version: version.to_string(), + chainId: U256::from(chain_id), + verifyingContract: verifying_contract, + salt: B256::ZERO, + extensions: vec![], + } + } + + #[test] + fn test_domain_from_report_matches_builtin_domain() { + // Arrange + let report = report(0x0f, "RecurringCollector", "1", CHAIN_ID, collector()); + + // Act + let domain = domain_from_report(report, collector(), CHAIN_ID).expect("valid report"); + + // Assert + assert_eq!(domain, rca_eip712_domain(CHAIN_ID, collector())); + } + + #[test] + fn test_domain_from_report_rejects_chain_id_mismatch() { + // Arrange + let report = report(0x0f, "RecurringCollector", "1", 42161, collector()); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("chain id"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_contract_mismatch() { + // Arrange + let report = report( + 0x0f, + "RecurringCollector", + "1", + CHAIN_ID, + Address::repeat_byte(0xDD), + ); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("verifying contract"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_nonempty_extensions() { + // Arrange + let mut report = report(0x0f, "RecurringCollector", "1", CHAIN_ID, collector()); + report.extensions = vec![U256::ZERO]; + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("extensions"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_empty_name() { + // Arrange + let report = report(0x0f, "", "1", CHAIN_ID, collector()); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("domain name"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_oversized_version() { + // Arrange + let oversized = "1".repeat(MAX_DOMAIN_FIELD_LEN + 1); + let report = report( + 0x0f, + "RecurringCollector", + &oversized, + CHAIN_ID, + collector(), + ); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("domain version"), "got: {err}"); + } + + #[test] + fn test_domain_from_report_rejects_unknown_fields_bitmap() { + // Arrange: 0x1f would mean the domain also uses a salt, which the + // built domain would silently omit, so it must be rejected. + let report = report(0x1f, "RecurringCollector", "1", CHAIN_ID, collector()); + + // Act + let err = domain_from_report(report, collector(), CHAIN_ID).unwrap_err(); + + // Assert + assert!(err.to_string().contains("fields bitmap"), "got: {err}"); + } + + /// Replies to any JSON-RPC request with the given eth_call result, + /// echoing the request id so the client accepts the response. + struct EthCallResponder { + result: Vec, + } + + impl Respond for EthCallResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": body["id"], + "result": format!("0x{}", hex::encode(&self.result)), + })) + } + } + + #[tokio::test] + async fn test_fetch_rca_eip712_domain_round_trip() { + // Arrange + let encoded = eip712DomainCall::abi_encode_returns(&report( + 0x0f, + "RecurringCollector", + "1", + CHAIN_ID, + collector(), + )); + let server = MockServer::start().await; + Mock::given(wiremock::matchers::method("POST")) + .respond_with(EthCallResponder { result: encoded }) + .mount(&server) + .await; + + // Act + let domain = fetch_rca_eip712_domain(&server.uri(), collector(), CHAIN_ID) + .await + .expect("fetch should succeed"); + + // Assert + assert_eq!(domain, rca_eip712_domain(CHAIN_ID, collector())); + } +} diff --git a/crates/dips/src/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..0ca998ce9 100644 --- a/crates/dips/src/ipfs.rs +++ b/crates/dips/src/ipfs.rs @@ -1,7 +1,61 @@ // 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. +//! +//! Retries are skipped when the service is busy: if more than 200 proposal +//! requests are in flight (`IPFS_DURESS_THRESHOLD`) as a fetch starts, that +//! fetch gets a single attempt and no retries. A loaded indexer frees handler +//! slots faster, at the cost of rejecting proposals whose one attempt hits a +//! transient error. Dipper can resend those. +//! +//! Worst case timing while retrying: 30s + 10s + 30s + 20s + 30s + 40s + 30s = +//! 190 seconds. A busy indexer's single attempt is capped at 30 seconds. +//! +//! Dipper's gRPC timeout should be at least 220 seconds (190s + 30s buffer) +//! to avoid timing out while indexer-rs is still retrying IPFS. +//! +//! 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 +63,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 +105,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 +214,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 +324,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 +515,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, because 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..3821d9826 100644 --- a/crates/dips/src/lib.rs +++ b/crates/dips/src/lib.rs @@ -1,12 +1,73 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::{str::FromStr, sync::Arc}; +//! DIPS (Direct Indexing Payments) for The Graph. +//! +//! This crate implements the indexer-side handling of RecurringCollectionAgreement (RCA) +//! proposals. When a payer wants indexing services, the Dipper service creates and signs +//! an RCA on their behalf, then sends it to the indexer via gRPC. +//! +//! # Architecture +//! +//! ```text +//! AGREEMENT_MANAGER_ROLE (on-chain) ──indexed by──> indexing-payments subgraph +//! │ │ +//! │ granted to Dipper's signing key │ role holders read +//! ▼ ▼ +//! Dipper ──────────SignedRCA───────────────> indexer-rs (this crate) +//! │ +//! │ validates & stores +//! ▼ +//! pending_rca_proposals table +//! │ +//! │ agent queries & decides +//! ▼ +//! on-chain acceptance +//! ``` +//! +//! # Validation Flow +//! +//! When an RCA arrives, this crate validates: +//! 1. **Signer** - The EIP-712 signature recovers to an address that holds the +//! agreement-manager role; this runs before anything in the agreement is +//! validated. The role set is read from the indexing-payments subgraph on an +//! interval and cached rather than read from chain per proposal, so subgraph +//! outages and lag change the answer (see [`trusted_signers`]) +//! 2. **Service provider** - RCA is addressed to this indexer +//! 3. **Timestamps** - Deadline and end time haven't passed +//! 4. **Replay/idempotency** - Once the deterministic agreement id is derived, +//! a re-sent proposal is resolved against the store before any IPFS fetch +//! 5. **Capacity** - When a cap is configured, live agreements (pending or +//! accepted) from the last 24 hours must stay under it +//! 6. **Terms version** - The agreement metadata declares V1 terms +//! 7. **IPFS manifest** - Subgraph deployment exists and is parseable +//! 8. **Network** - Subgraph's network is supported by this indexer +//! 9. **Pricing** - Offered price meets indexer's minimum +//! +//! Authorization is decided on the recovered signer, not on the payer address +//! carried in the proposal. On-chain offer existence is not checked here: the +//! offer does not exist yet at proposal time, so the contract enforces it when +//! the indexer-agent submits the `acceptIndexingAgreement` transaction. +//! +//! # Modules +//! +//! - [`server`] - gRPC server handling RCA proposals +//! - [`store`] - Storage trait for RCA proposals +//! - [`database`] - PostgreSQL implementation +//! - [`trusted_signers`] - Signer authorization against the on-chain role holders +//! - [`eip5267`] - EIP-5267 discovery of the RCA signing domain from RecurringCollector +//! - [`ipfs`] - IPFS client for subgraph manifests +//! - [`price`] - Minimum price enforcement +//! - [`inflight`] - Shared counter of in-flight proposal requests +//! - [`metrics`] - Prometheus metrics for the proposal path +//! - [`proto`] - Generated protobuf types for the gRPC service + +use std::sync::Arc; use server::DipsServerContext; use thegraph_core::alloy::{ core::primitives::Address, - primitives::{b256, ruint::aliases::U256, ChainId, Signature, Uint, B256}, + primitives::{keccak256, ruint::aliases::U256, Signature, Uint}, signers::SignerSync, sol, sol_types::{eip712_domain, Eip712Domain, SolStruct, SolValue}, @@ -14,7 +75,11 @@ use thegraph_core::alloy::{ #[cfg(feature = "db")] pub mod database; +pub mod eip5267; +pub mod inflight; pub mod ipfs; +#[cfg(any(feature = "rpc", feature = "db"))] +pub mod metrics; pub mod price; #[cfg(feature = "rpc")] pub mod proto; @@ -22,155 +87,147 @@ pub mod proto; mod registry; #[cfg(feature = "rpc")] pub mod server; -pub mod signers; pub mod store; +pub mod trusted_signers; -use store::AgreementStore; use thiserror::Error; use uuid::Uuid; -/// DIPs EIP-712 domain salt -const EIP712_DOMAIN_SALT: B256 = - b256!("b4632c657c26dce5d4d7da1d65bda185b14ff8f905ddbb03ea0382ed06c5ef28"); +pub use crate::eip5267::fetch_rca_eip712_domain; -/// 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 + /// Wrapper pairing an RCA with its EIP-712 signature. #[derive(Debug, PartialEq)] - struct SubgraphIndexingVoucherMetadata { - uint256 basePricePerEpoch; // wei GRT - uint256 pricePerEntity; // wei GRT - string subgraphDeploymentId; // e.g. "Qmbg1qF4YgHjiVfsVt6a13ddrVcRtWyJQfD4LA3CwHM29f" - TODO consider using bytes32 - string protocolNetwork; // e.g. "eip155:42161" - string chainId; // indexed chain, e.g. "eip155:1" - } - - #[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) +} + +/// EIP-712 domain for RecurringCollectionAgreement signatures. Must match the +/// domain dipper signs with and the on-chain RecurringCollector contract; any +/// drift in name, version, chain id, or address makes every recovered signer wrong. +pub fn rca_eip712_domain(chain_id: u64, recurring_collector: Address) -> Eip712Domain { + eip712_domain! { + name: "RecurringCollector", + version: "1", + chain_id: chain_id, + verifying_contract: recurring_collector, + } } #[derive(Error, Debug)] pub enum DipsError { - // agreement creation - #[error("signature is not valid, error: {0}")] + // RCA validation + #[error("invalid signature: {0}")] InvalidSignature(String), - #[error("payer {0} not authorised")] - PayerNotAuthorised(Address), - #[error("voucher payee {actual} does not match the expected address {expected}")] - UnexpectedPayee { expected: Address, actual: Address }, + #[error("RCA service provider {actual} does not match the expected address {expected}")] + UnexpectedServiceProvider { expected: Address, actual: Address }, #[error("cannot get subgraph manifest for {0}")] SubgraphManifestUnavailable(String), #[error("invalid subgraph id {0}")] InvalidSubgraphManifest(String), - #[error("chainId {0} is not supported")] - UnsupportedChainId(String), - #[error("price per epoch is below configured price for chain {0}, minimum: {1}, offered: {2}")] - PricePerEpochTooLow(String, U256, String), + #[error("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 }, + #[error("sender {signer} is not authorised to send agreement proposals")] + SenderNotTrusted { signer: Address }, + #[error("could not verify sender authorisation: {0}")] + TrustVerificationUnavailable(String), + #[error("indexer is at its DIPs agreement capacity ({limit} per 24h)")] + CapacityExceeded { limit: u64 }, + // Replay detection (early dedup, before the IPFS fetch). + #[error("agreement {agreement_id} was already rejected")] + ReplayRejected { agreement_id: Uuid }, + #[error("agreement id {agreement_id} already stored with a different payload")] + ReplayConflict { agreement_id: Uuid }, } #[cfg(feature = "rpc")] @@ -180,55 +237,52 @@ 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)); +impl SignedRecurringCollectionAgreement { + /// Recover the EIP-712 signer of the RCA over the RecurringCollector domain. + /// An empty, malformed, or non-recovering signature is rejected as + /// InvalidSignature. The recovered address is what the trusted-signer check + /// is applied to, in `validate_and_create_rca`. + pub fn recover_signer(&self, domain: &Eip712Domain) -> Result { + if self.signature.is_empty() { + return Err(DipsError::InvalidSignature("missing signature".to_string())); } + let signature = Signature::try_from(self.signature.as_ref()) + .map_err(|err| DipsError::InvalidSignature(err.to_string()))?; + signature + .recover_address_from_prehash(&self.agreement.eip712_signing_hash(domain)) + .map_err(|err| DipsError::InvalidSignature(err.to_string())) + } - 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, + /// 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 +291,1510 @@ 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) - } +/// 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 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() - } -} - -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( +/// Window over which the cap counts live agreements (pending or accepted). +const CAPACITY_WINDOW: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); + +/// Validate and create a RecurringCollectionAgreement. +/// +/// Runs the 9 validation steps in the order the crate-level docs list them, from +/// the signer check through to the price minimum. +/// +/// On-chain offer existence is NOT checked here: the offer doesn't exist +/// 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, + rca_domain, + trusted_signers, + max_new_agreements_per_24h, } = 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()))?; - let metadata = - SubgraphIndexingVoucherMetadata::abi_decode(decoded_voucher.voucher.metadata.as_ref()) - .map_err(|e| DipsError::AbiDecoding(e.to_string()))?; - decoded_voucher.validate(signer_validator, domain, expected_payee, allowed_payers)?; + // Authenticate then authorize the sender: recover the EIP-712 signer, then + // require it to hold the on-chain agreement-manager role before doing any work. + let signer = signed_rca.recover_signer(rca_domain)?; + tracing::debug!(%signer, "recovered RCA signer"); + trusted_signers.verify_trusted(signer).await?; - // Extract and parse the agreement ID from the voucher - let agreement_id = Uuid::from_bytes(decoded_voucher.voucher.agreement_id.into()); + // Validate service provider + signed_rca.validate(expected_service_provider)?; - let manifest = ipfs_fetcher.fetch(&metadata.subgraphDeploymentId).await?; + // 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 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)), - }, - }; + let deadline: u64 = signed_rca.agreement.deadline; + if deadline < now { + return Err(DipsError::DeadlineExpired { deadline, now }); + } - 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, - )); + // 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); + + // Early replay/idempotency check, ahead of the capacity cap and the IPFS + // fetch: a recorded proposal's retry is resolved from the store, never + // capacity-rejected or charged for the download. Failing open is best-effort. + match rca_store.lookup(agreement_id).await { + Ok(None) => {} + Ok(Some(prior)) => { + // A different payload reusing the same id is the only true conflict. + if prior.signed_payload != rca_bytes { + tracing::warn!( + %agreement_id, + "replay conflict: agreement id re-sent with a different payload" + ); + return Err(DipsError::ReplayConflict { agreement_id }); + } + // Byte-identical re-send: return the stored outcome, skipping the fetch. + // A rejected proposal stays rejected; any other status + // (pending/accepted/completed/future) resolves to an accept. + if prior.status == crate::store::STATUS_REJECTED { + return Err(DipsError::ReplayRejected { agreement_id }); } + return Ok(agreement_id); } - None => { - return Err(DipsError::InvalidSubgraphManifest( - metadata.subgraphDeploymentId, - )) + Err(error) => { + tracing::warn!(%agreement_id, %error, "replay lookup failed, continuing validation"); + } + } + + // Capacity safeguard: cap live agreements (pending or accepted) per rolling + // 24h, checked before the IPFS fetch so an over-cap proposal costs no download. + // Count-then-store isn't atomic, so concurrent proposals may overshoot slightly. + if let Some(limit) = max_new_agreements_per_24h { + match rca_store.count_since(CAPACITY_WINDOW).await { + Ok(count) if count >= *limit => { + return Err(DipsError::CapacityExceeded { limit: *limit }); + } + Ok(_) => {} + // Fail open: the cap is a best-effort safeguard, not a security control, + // so a transient count failure shouldn't reject a valid proposal. + Err(e) => tracing::warn!(error = %e, "DIPs capacity check failed; allowing proposal"), } } - 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!( + // Decode metadata + let metadata = + 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)); + } + + // Decode terms + let terms = IndexingAgreementTermsV1::abi_decode(metadata.terms.as_ref()).map_err(|e| { + DipsError::AbiDecoding(format!("Failed to decode IndexingAgreementTermsV1: {e}")) + })?; + + // Convert bytes32 deployment ID to IPFS hash + let deployment_id = bytes32_to_ipfs_hash(&metadata.subgraphDeploymentId.0); + + // Fetch IPFS manifest + let manifest = ipfs_fetcher.fetch(&deployment_id).await?; + + // 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()))?; + + // 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())); + } + + // 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()))?; - - // Get the agreement ID from the cancellation request - let agreement_id = Uuid::from_bytes(decoded_request.request.agreement_id.into()); +#[cfg(test)] +mod test { + use std::collections::{BTreeMap, HashSet}; + use std::sync::Arc; - let stored_agreement = store.get_by_id(agreement_id).await?.ok_or_else(|| { - tracing::warn!(%agreement_id, "agreement not found"); - DipsError::AgreementNotFound - })?; + use crate::{ + derive_agreement_id, + ipfs::{EmptyNetworkIpfsFetcher, FailingIpfsFetcher, MockIpfsFetcher}, + price::PriceCalculator, + rca_eip712_domain, + server::DipsServerContext, + store::{FailingRcaStore, InMemoryRcaStore, LookupFailsStore, RcaStore}, + trusted_signers::{StaticTrustedSigners, TrustedSignerSource}, + AcceptIndexingAgreementMetadata, DipsError, IndexingAgreementTermsV1, + RecurringCollectionAgreement, SignedRecurringCollectionAgreement, + }; + use thegraph_core::alloy::{ + primitives::{keccak256, Address, FixedBytes, U256}, + signers::local::PrivateKeySigner, + sol_types::{Eip712Domain, SolValue}, + }; - // Get the deployment ID from the stored agreement - let deployment_id = stored_agreement.metadata.subgraphDeploymentId; + // Fixed signer and domain for round-tripping RCA signatures in tests. The + // recovered address equals `test_signer().address()`; the domain must match + // the one stored in the test context so recovery succeeds. + fn test_signer() -> PrivateKeySigner { + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + .parse() + .unwrap() + } - if stored_agreement.cancelled { - tracing::warn!(%agreement_id, %deployment_id, "agreement already cancelled"); - return Err(DipsError::AgreementCancelled); + fn test_rca_domain() -> Eip712Domain { + rca_eip712_domain(1337, Address::repeat_byte(0xCC)) } - decoded_request.validate(domain, &stored_agreement.voucher.voucher.payer)?; + fn trusted_signers_for_test() -> Arc { + Arc::new(StaticTrustedSigners(HashSet::from([ + test_signer().address() + ]))) + } - tracing::debug!(%agreement_id, %deployment_id, "cancelling agreement"); + 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()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }) + } - store - .cancel_agreement(decoded_request) - .await - .map_err(|error| { - tracing::error!(%agreement_id, %deployment_id, %error, "failed to cancel agreement"); - error - })?; + #[tokio::test] + async fn test_validate_and_create_rca_untrusted_signer_rejected() { + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); - Ok(agreement_id) -} + // Trusted set excludes the test signer, so a valid signature is rejected. + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(InMemoryRcaStore::default()), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + trusted_signers: Arc::new(StaticTrustedSigners::default()), + max_new_agreements_per_24h: None, + }); + let rca_bytes = rca_to_wire_bytes(rca); -#[cfg(test)] -mod test { - use std::{ - collections::HashMap, - time::{Duration, SystemTime, UNIX_EPOCH}, - }; + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; - 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; + assert!(matches!(result, Err(DipsError::SenderNotTrusted { .. }))); + } - pub use crate::store::{AgreementStore, InMemoryAgreementStore}; - use crate::{ - dips_agreement_eip712_domain, dips_cancellation_eip712_domain, server::DipsServerContext, - CancellationRequest, DipsError, IndexingAgreementVoucher, SignedIndexingAgreementVoucher, - SubgraphIndexingVoucherMetadata, - }; + /// Sign an RCA with the fixed test key over the test domain and ABI-encode + /// the wrapper, producing the wire bytes `validate_and_create_rca` decodes + /// and recovers the signer from. + fn rca_to_wire_bytes(rca: RecurringCollectionAgreement) -> Vec { + rca.sign(&test_rca_domain(), test_signer()) + .expect("signing test RCA") + .abi_encode() + } - /// The Arbitrum One (mainnet) chain ID (eip155). - const CHAIN_ID_ARBITRUM_ONE: ChainId = 0xa4b1; // 42161 + 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, + }; - #[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, + let metadata = AcceptIndexingAgreementMetadata { + // Any bytes32 works - MockIpfsFetcher ignores the deployment ID + subgraphDeploymentId: FixedBytes::ZERO, + version: 0, // IndexingAgreementVersion.V1 = 0 + terms: terms.abi_encode().into(), }; - 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, + 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(), - }; - let domain = dips_agreement_eip712_domain(CHAIN_ID_ARBITRUM_ONE); - - let voucher = voucher.sign(&domain, payer)?; - let abi_voucher = voucher.abi_encode(); - let id = Uuid::from_bytes(voucher.voucher.agreement_id.into()); - - let ctx = DipsServerContext::for_testing(); - let actual_id = super::validate_and_create_agreement( - ctx.clone(), - &domain, - &payee_addr, - vec![payer_addr], - abi_voucher, - ) - .await - .unwrap(); - assert_eq!(actual_id, id); + } + } - let stored_agreement = ctx.store.get_by_id(actual_id).await.unwrap().unwrap(); + #[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(), + }; - assert_eq!(voucher, stored_agreement.voucher); - assert!(!stored_agreement.cancelled); - Ok(()) + 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]); } + /// 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 voucher_signature_verification() { - let ctx = DipsServerContext::for_testing(); - let deployment_id = "Qmbg1qF4YgHjiVfsVt6a13ddrVcRtWyJQfD4LA3CwHM29f".to_string(); - let payee = PrivateKeySigner::random(); - let payee_addr = payee.address(); - let payer = PrivateKeySigner::random(); - let payer_addr = payer.address(); - - let metadata = SubgraphIndexingVoucherMetadata { - basePricePerEpoch: U256::from(10000_u64), - pricePerEntity: U256::from(100_u64), - protocolNetwork: "eip155:42161".to_string(), - chainId: "eip155:1".to_string(), - subgraphDeploymentId: deployment_id, + fn test_derive_agreement_id_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(), }; - let voucher = IndexingAgreementVoucher { - agreement_id: Uuid::now_v7().as_bytes().into(), - payer: payer_addr, - recipient: payee.address(), - service: Address(FixedBytes::ZERO), - maxInitialAmount: U256::from(10000_u64), - maxOngoingAmountPerEpoch: U256::from(10000_u64), - maxEpochsPerCollection: 1000, - minEpochsPerCollection: 1000, - durationEpochs: 1000, - deadline: 10000000, - metadata: metadata.abi_encode().into(), - }; + let id = derive_agreement_id(&rca); - let domain = dips_agreement_eip712_domain(CHAIN_ID_ARBITRUM_ONE); - let signed = voucher.sign(&domain, payer).unwrap(); + // 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!( - signed - .validate(&ctx.signer_validator, &domain, &payee_addr, vec![]) - .unwrap_err() - .to_string(), - DipsError::PayerNotAuthorised(voucher.payer).to_string() + 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::() ); - assert!(signed - .validate( - &ctx.signer_validator, - &domain, - &payee_addr, - vec![payer_addr] - ) - .is_ok()); + } + + /// Guards against drift in the sol! struct layout for the audit-branch + /// `conditions` field. If `conditions` were ever moved, renamed, or + /// dropped from the decoder, this round-trip would either fail to + /// decode or return a corrupted value in the field's slot. + #[test] + fn test_rca_conditions_field_roundtrip() { + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + let mut rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + rca.conditions = 0xABCD; // arbitrary non-zero 16-bit value + + let encoded = rca_to_wire_bytes(rca.clone()); + let decoded = SignedRecurringCollectionAgreement::abi_decode(encoded.as_ref()) + .expect("roundtrip decode failed"); + + assert_eq!( + decoded.agreement.conditions, 0xABCD, + "conditions field did not survive ABI round-trip" + ); + // Cross-check surrounding fields are intact, so a failure of the + // conditions field isn't silently misread from a neighbour's slot. + assert_eq!( + decoded.agreement.maxSecondsPerCollection, + rca.maxSecondsPerCollection + ); + assert_eq!(decoded.agreement.nonce, rca.nonce); } #[tokio::test] - async fn 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_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 rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + + let ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); + + let result = + super::validate_and_create_rca(ctx.clone(), &service_provider, rca_bytes).await; + + assert!(result.is_ok(), "got: {:?}", result); + assert_eq!(result.unwrap(), agreement_id); + + // Verify it was stored + let store = ctx.rca_store.as_ref(); + let in_memory = store.as_any().downcast_ref::().unwrap(); + let data = in_memory.data.read().await; + assert_eq!(data.len(), 1); + assert_eq!(data[0].0, agreement_id); + } + + #[test] + fn test_recover_signer_recovers_the_signing_key() { + let rca = create_test_rca( + Address::repeat_byte(0x42), + Address::repeat_byte(0x11), + U256::from(200), + U256::from(100), + ); + + let signed = rca + .sign(&test_rca_domain(), test_signer()) + .expect("signing test RCA"); + let recovered = signed + .recover_signer(&test_rca_domain()) + .expect("recovering signer"); + + assert_eq!(recovered, test_signer().address()); + } - let mut signed = voucher.sign(&domain, payer).unwrap(); - signed.voucher.service = Address::repeat_byte(9); + #[test] + fn test_recover_signer_rejects_empty_signature() { + let rca = create_test_rca( + Address::repeat_byte(0x42), + Address::repeat_byte(0x11), + U256::from(200), + U256::from(100), + ); + let signed = SignedRecurringCollectionAgreement { + agreement: rca, + signature: Default::default(), + }; assert!(matches!( - signed - .validate( - &ctx.signer_validator, - &domain, - &payee_addr, - vec![payer_addr] - ) - .unwrap_err(), - DipsError::SignerNotAuthorised(_) + signed.recover_signer(&test_rca_domain()), + Err(DipsError::InvalidSignature(_)) )); } #[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, - } + fn test_recover_signer_rejects_malformed_signature() { + let rca = create_test_rca( + Address::repeat_byte(0x42), + Address::repeat_byte(0x11), + U256::from(200), + U256::from(100), + ); + let signed = SignedRecurringCollectionAgreement { + agreement: rca, + signature: vec![0xAA; 10].into(), + }; - 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())), - }, - ]; + assert!(matches!( + signed.recover_signer(&test_rca_domain()), + Err(DipsError::InvalidSignature(_)) + )); + } - 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()), - } + #[tokio::test] + async fn test_validate_and_create_rca_missing_signature_rejected() { + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + // An unsigned proposal must be rejected before any other validation. + let rca_bytes = SignedRecurringCollectionAgreement { + agreement: rca, + signature: Default::default(), } + .abi_encode(); + + let ctx = create_test_context(); + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(matches!(result, Err(DipsError::InvalidSignature(_)))); } - 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_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), + ); - pub fn test_voucher( - &self, - metadata: SubgraphIndexingVoucherMetadata, - ) -> SignedIndexingAgreementVoucher { - self.test_voucher_with_signer(metadata, self.payer.clone()) - } + 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 test_validate_and_create_rca_tokens_per_second_too_low() { + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + // Offer 50, minimum is 100 + let rca = create_test_rca(payer, service_provider, U256::from(50), U256::from(100)); + + 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::TokensPerSecondTooLow { .. }) + )); } #[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_entity_price_too_low() { + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + // Offer 200 tokens/sec (ok), but only 10 entity price (minimum is 50) + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(10)); + + let 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 { .. }) + )); + } + + #[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()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }); + + let rca_bytes = rca_to_wire_bytes(rca); + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + assert!(matches!(result, Err(DipsError::UnsupportedNetwork(_)))); + } + + #[tokio::test] + 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), }; - // 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(), + + let metadata = AcceptIndexingAgreementMetadata { + subgraphDeploymentId: FixedBytes::ZERO, + version: 0, // IndexingAgreementVersion.V1 = 0 + terms: terms.abi_encode().into(), }; - let low_entity_price_voucher = voucher_ctx.test_voucher(metadata); + // 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 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; - 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(), + 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 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 { .. }))); + } - let contexts = vec![no_network_ctx, ctx.clone(), ctx.clone(), ctx.clone()]; + // ========================================================================= + // Additional tests for complete coverage (following test-arrange-act-assert) + // ========================================================================= - let expected_result: Vec> = vec![ - Err(DipsError::InvalidSubgraphManifest( - voucher_ctx.deployment_id.clone(), + #[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 + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, malformed_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::AbiDecoding(_))), + "Expected AbiDecoding error, got: {:?}", + result + ); + } + + #[tokio::test] + async fn test_validate_and_create_rca_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()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }); + + 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()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }); + + 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()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }); + + let rca_bytes = rca_to_wire_bytes(rca); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert!( + matches!(result, Err(DipsError::InvalidSubgraphManifest(_))), + "Expected InvalidSubgraphManifest for empty network, got: {:?}", + result + ); + } - match (out, result) { - (Ok(a), Ok(b)) => assert_eq!(a.into_bytes(), b), - (Err(a), Err(b)) => assert_eq!(a.to_string(), b.to_string()), - (a, b) => panic!("{a:?} did not match {b:?}"), - } + #[tokio::test] + async fn test_validate_and_create_rca_store_failure() { + // Arrange + let payer = 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()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }); + + 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" + ); + } + + // ========================================================================= + // Early replay / idempotency check (before the IPFS fetch) + // ========================================================================= + + use crate::{ipfs::GraphManifest, ipfs::IpfsFetcher, PROTOCOL_VERSION}; + + /// IPFS fetcher that fails the test if reached. Proves the replay + /// short-circuit returns before any manifest download. + #[derive(Debug)] + struct PanicIpfsFetcher; + + #[async_trait::async_trait] + impl IpfsFetcher for PanicIpfsFetcher { + async fn fetch(&self, _file: &str) -> Result { + panic!("IPFS fetch must not run on the replay short-circuit path"); } + } - Ok(()) + /// Build a context whose store is the supplied seeded one and whose IPFS + /// fetcher panics if called, so a reached download surfaces as a test panic. + fn context_with_store_and_panic_fetcher( + store: Arc, + ) -> Arc { + Arc::new(DipsServerContext { + rca_store: store, + ipfs_fetcher: Arc::new(PanicIpfsFetcher), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }) + } + + /// Overwrite the status of the single seeded row, for the accepted/rejected cases. + async fn set_status(store: &InMemoryRcaStore, status: &str) { + let mut data = store.data.write().await; + data[0].3 = status.to_string(); + } + + #[tokio::test] + async fn test_replay_first_time_runs_full_pipeline() { + // Arrange: empty store, real mock fetcher; this is the non-replay path. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let ctx = create_test_context(); + let rca_bytes = rca_to_wire_bytes(rca); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_identical_pending_accepts_without_fetch() { + // Arrange: seed the identical bytes as a pending row. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + let ctx = context_with_store_and_panic_fetcher(store); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert: replays the prior accept, panicking fetcher proves no download. + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_identical_accepted_accepts_without_fetch() { + // Arrange: seed identical bytes, then promote the row to accepted. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + set_status(&store, "accepted").await; + let ctx = context_with_store_and_panic_fetcher(store); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_identical_completed_accepts_without_fetch() { + // Arrange: seed identical bytes, then mark the row completed (the status + // the agent sets when an agreement goes live on-chain and is retired). + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + set_status(&store, "completed").await; + let ctx = context_with_store_and_panic_fetcher(store); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert: a completed proposal replays Ok, not a ReplayConflict. + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_identical_rejected_rerejects_without_fetch() { + // Arrange: seed identical bytes, then mark the row rejected. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + set_status(&store, "rejected").await; + let ctx = context_with_store_and_panic_fetcher(store); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert + assert!(matches!(result, Err(DipsError::ReplayRejected { .. }))); + } + + #[tokio::test] + async fn test_replay_same_id_different_bytes_conflicts_without_fetch() { + // Arrange: seed one payload, then submit a different payload sharing the id. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + + // Same id preimage (payer, dataService, serviceProvider, deadline, nonce), + // different terms so the wire bytes differ. + let seeded = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let conflicting = + create_test_rca(payer, service_provider, U256::from(999), U256::from(100)); + let agreement_id = derive_agreement_id(&seeded); + assert_eq!(agreement_id, derive_agreement_id(&conflicting)); + + let seeded_bytes = rca_to_wire_bytes(seeded); + let conflicting_bytes = rca_to_wire_bytes(conflicting); + assert_ne!(seeded_bytes, conflicting_bytes); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, seeded_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + let ctx = context_with_store_and_panic_fetcher(store.clone()); + + // Act + let result = + super::validate_and_create_rca(ctx, &service_provider, conflicting_bytes).await; + + // Assert: conflict reported and the stored row is left unchanged. + assert!(matches!(result, Err(DipsError::ReplayConflict { .. }))); + let stored = store.lookup(agreement_id).await.unwrap().unwrap(); + assert_eq!(stored.signed_payload, seeded_bytes); + } + + #[tokio::test] + async fn test_replay_lookup_error_fails_open_and_validates() { + // Arrange: a store whose lookup errors but whose write succeeds, paired + // with a real fetcher so validation can run to completion. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(LookupFailsStore), + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: None, + }); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert: the swallowed lookup error still lets validation run to an + // accept, rather than surfacing as a rejection. + assert_eq!(result.unwrap(), agreement_id); + } + + #[tokio::test] + async fn test_replay_accepts_even_when_at_capacity() { + // Arrange: a recorded pending proposal with the per-24h cap already at + // one, so the capacity check alone would reject. The panicking fetcher + // proves the replay short-circuits before any manifest download. + let payer = Address::repeat_byte(0x42); + let service_provider = Address::repeat_byte(0x11); + let rca = create_test_rca(payer, service_provider, U256::from(200), U256::from(100)); + let agreement_id = derive_agreement_id(&rca); + let rca_bytes = rca_to_wire_bytes(rca); + + let store = Arc::new(InMemoryRcaStore::default()); + store + .store_rca(agreement_id, rca_bytes.clone(), PROTOCOL_VERSION) + .await + .unwrap(); + let ctx = Arc::new(DipsServerContext { + rca_store: store, + ipfs_fetcher: Arc::new(PanicIpfsFetcher), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: Some(1), + }); + + // Act + let result = super::validate_and_create_rca(ctx, &service_provider, rca_bytes).await; + + // Assert: the prior accept comes back rather than CapacityExceeded. + assert_eq!(result.unwrap(), agreement_id); + } + + /// Build a context backed by `store` with the per-24h cap set to `max`. + fn capacity_ctx(store: Arc, max: Option) -> Arc { + Arc::new(DipsServerContext { + rca_store: store, + ipfs_fetcher: Arc::new(MockIpfsFetcher::default()), + price_calculator: Arc::new(PriceCalculator::new( + HashSet::from(["mainnet".to_string()]), + BTreeMap::from([("mainnet".to_string(), U256::from(100))]), + U256::from(50), + )), + registry: Arc::new(crate::registry::test_registry()), + additional_networks: Arc::new(BTreeMap::new()), + rca_domain: test_rca_domain(), + trusted_signers: trusted_signers_for_test(), + max_new_agreements_per_24h: max, + }) + } + + /// Fill an in-memory store with `n` distinct stored proposals. + async fn prefill(store: &InMemoryRcaStore, n: u64) { + for i in 0..n { + store + .store_rca(uuid::Uuid::from_u128(i as u128 + 1), vec![i as u8], 2) + .await + .unwrap(); + } + } + + /// A store whose count query fails but whose writes succeed, to exercise the + /// capacity check's fail-open path. + #[derive(Debug, Default)] + struct CountFailingStore(InMemoryRcaStore); + + #[async_trait::async_trait] + impl RcaStore for CountFailingStore { + async fn store_rca( + &self, + agreement_id: uuid::Uuid, + signed_rca: Vec, + version: u64, + ) -> Result<(), DipsError> { + self.0.store_rca(agreement_id, signed_rca, version).await + } + + async fn lookup( + &self, + agreement_id: uuid::Uuid, + ) -> Result, DipsError> { + self.0.lookup(agreement_id).await + } + + async fn count_since(&self, _window: std::time::Duration) -> Result { + Err(DipsError::UnknownError(anyhow::anyhow!( + "count failed (test)" + ))) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + } + + #[tokio::test] + async fn capacity_at_limit_rejects() { + let service_provider = Address::repeat_byte(0x11); + let store = Arc::new(InMemoryRcaStore::default()); + prefill(&store, 2).await; + let ctx = capacity_ctx(store, Some(2)); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx.clone(), &service_provider, rca_to_wire_bytes(rca)) + .await; + + assert!(matches!( + result, + Err(DipsError::CapacityExceeded { limit: 2 }) + )); + // The over-cap proposal must not have been stored. + let stored = ctx + .rca_store + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(stored.data.read().await.len(), 2); + } + + #[tokio::test] + async fn capacity_under_limit_passes() { + let service_provider = Address::repeat_byte(0x11); + let store = Arc::new(InMemoryRcaStore::default()); + prefill(&store, 1).await; + let ctx = capacity_ctx(store, Some(2)); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx.clone(), &service_provider, rca_to_wire_bytes(rca)) + .await; + + assert!(result.is_ok(), "got: {:?}", result); + // The accepted proposal is stored, taking the count from 1 to 2. + let stored = ctx + .rca_store + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(stored.data.read().await.len(), 2); + } + + #[tokio::test] + async fn capacity_unset_skips_check() { + let service_provider = Address::repeat_byte(0x11); + let store = Arc::new(InMemoryRcaStore::default()); + // Five already stored, but no cap configured, so a new one still passes. + prefill(&store, 5).await; + let ctx = capacity_ctx(store, None); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx, &service_provider, rca_to_wire_bytes(rca)).await; + + assert!(result.is_ok(), "got: {:?}", result); + } + + #[tokio::test] + async fn capacity_count_failure_fails_open() { + let service_provider = Address::repeat_byte(0x11); + // The count query errors; the cap is best-effort, so the proposal is + // allowed through rather than rejected. + let ctx = capacity_ctx(Arc::new(CountFailingStore::default()), Some(1)); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx.clone(), &service_provider, rca_to_wire_bytes(rca)) + .await; + + assert!(result.is_ok(), "got: {:?}", result); + // Fail-open still completes the path and stores the proposal. + let stored = ctx + .rca_store + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(stored.0.data.read().await.len(), 1); + } + + #[tokio::test] + async fn capacity_zero_limit_rejects_all() { + let service_provider = Address::repeat_byte(0x11); + // An empty store with a cap of 0 still rejects every proposal (0 >= 0). + let ctx = capacity_ctx(Arc::new(InMemoryRcaStore::default()), Some(0)); + let rca = create_test_rca( + Address::repeat_byte(0x42), + service_provider, + U256::from(200), + U256::from(100), + ); + + let result = + super::validate_and_create_rca(ctx, &service_provider, rca_to_wire_bytes(rca)).await; + + assert!(matches!( + result, + Err(DipsError::CapacityExceeded { limit: 0 }) + )); } } diff --git a/crates/dips/src/metrics.rs b/crates/dips/src/metrics.rs new file mode 100644 index 000000000..218dbeae6 --- /dev/null +++ b/crates/dips/src/metrics.rs @@ -0,0 +1,40 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! Prometheus metrics for the DIPs proposal path. Registered in the global +//! registry, so the indexer-service /metrics endpoint exposes them. + +use std::sync::LazyLock; + +use prometheus::{register_counter_vec, register_int_gauge, CounterVec, IntGauge}; + +/// Incoming agreement proposals by outcome: `accepted`, `untrusted` (signer is +/// not a role holder), `transient` (couldn't verify, so the sender should retry), or +/// `rejected` (other validation failure). +pub static PROPOSAL_OUTCOMES: LazyLock = LazyLock::new(|| { + register_counter_vec!( + "dips_proposal_outcomes_total", + "Incoming DIPs agreement proposals by outcome", + &["outcome"] + ) + .unwrap() +}); + +/// Agreement-manager role holders in the last successful subgraph fetch. +pub static ROLE_SET_SIZE: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "dips_agreement_manager_role_holders", + "Agreement-manager role holders in the last successful subgraph fetch" + ) + .unwrap() +}); + +/// Unix time of the last successful role-set fetch. Alert on `time() - this` to +/// catch a refresh that has stalled. +pub static ROLE_LAST_REFRESH_TIMESTAMP: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "dips_agreement_manager_role_last_refresh_timestamp", + "Unix time of the last successful agreement-manager role fetch" + ) + .unwrap() +}); diff --git a/crates/dips/src/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..bb9abcdf4 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 re-sent proposal: an exact replay of one already rejected, or a different payload reusing an already-seen agreement id. + ReplayDetected = 12, + /// / The payer has insufficient escrow to back the agreement. + InsufficientEscrow = 13, + /// / 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..5fa40d9c3 100644 --- a/crates/dips/src/registry.rs +++ b/crates/dips/src/registry.rs @@ -1,8 +1,16 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 +//! Test helpers for the network registry. RCA validation never uses the registry to decide +//! whether a network is allowed (that comes from the indexer's `supported_networks` plus a +//! configured price); it reads the registry afterwards to resolve a chain ID for logging. +//! +//! That describes how the registry is read per proposal, not how load-bearing it is at boot: +//! the service downloads it when DIPs starts and a failed download aborts DIPs initialisation. + use graph_networks_registry::NetworksRegistry; +/// Minimal registry containing "mainnet" and "hardhat" for use in unit tests. pub fn test_registry() -> NetworksRegistry { use graph_networks_registry::{Network, NetworkType, Services}; diff --git a/crates/dips/src/server.rs b/crates/dips/src/server.rs index 6b6f6e24a..cb80b42e6 100644 --- a/crates/dips/src/server.rs +++ b/crates/dips/src/server.rs @@ -1,187 +1,695 @@ // 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() +//! │ +//! ├─ Request envelope version (must be 2) ─┐ answered with +//! ├─ Size validation (non-empty, max 10KB) ─┘ InvalidArgument +//! │ +//! ├─ validate_and_create_rca() runs the 9 checks the crate docs list +//! │ +//! └─> Store in pending_rca_proposals table +//! │ +//! └─> Return Accept/Reject +//! ``` +//! +//! The ordered list of validation steps lives in the crate-level docs. This +//! module only adds the gRPC envelope handling around it. +//! +//! The signer is checked here: the EIP-712 signature recovers the signer's +//! address, which must hold the agreement-manager role. Authorization is never +//! decided on the `payer` address carried inside the proposal. That check runs +//! before anything in the agreement is validated, and any other address is +//! rejected with `RejectReason::SenderNotTrusted`. If the role set itself cannot +//! be read, the proposal is rejected with `RejectReason::IndexerUnavailable` +//! instead, telling the sender to retry rather than to try another indexer. +//! +//! # Response Behavior +//! +//! Returns `Accept` if the RCA passes all validation and is stored successfully, +//! and `Reject` if a check on the decoded agreement fails. This enables the +//! Dipper to reassign the indexing request to another indexer on rejection. +//! +//! The first 2 steps are the exception. A request whose envelope version is not +//! 2, or whose payload is empty or over 10KB, is answered with a gRPC +//! `InvalidArgument` status rather than a rejection, so the Dipper sees a +//! malformed call rather than an indexer declining the work. +//! +//! # Cancellation +//! +//! 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, sol_types::Eip712Domain}; 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, + trusted_signers::TrustedSignerSource, + DipsError, }; -#[derive(Debug)] +/// Context for DIPS server with all validation dependencies: the trusted-signer +/// source, the proposal store, the IPFS fetcher and the price calculator, which +/// also holds the `supported_networks` list. A network is accepted only if it is +/// in that list AND has a configured price. See the crate-level docs for the +/// order the checks run in. +#[derive(Debug, Clone)] pub struct DipsServerContext { - 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>, + /// Price calculator for validating minimum prices + pub price_calculator: Arc, + /// Network registry, read only to resolve a chain ID for log context. + pub registry: Arc, + /// Chain IDs for networks the registry doesn't cover, also log context only. + pub additional_networks: Arc>, + /// EIP-712 domain for recovering the RCA signer (RecurringCollector). + pub rca_domain: Eip712Domain, + /// Authorises the recovered signer against the on-chain agreement-manager role. + pub trusted_signers: Arc, + /// Max live DIPs agreements (pending or accepted) per rolling 24h window. None disables the cap. + pub max_new_agreements_per_24h: Option, } -impl DipsServerContext { - #[cfg(test)] - pub fn for_testing() -> Arc { - use std::sync::Arc; - - use crate::{ - ipfs::TestIpfsClient, registry::test_registry, signers, test::InMemoryAgreementStore, - }; +/// DIPS server implementing RCA protocol. +/// +/// Validates RecurringCollectionAgreement proposals before storage, in the order +/// the crate-level docs set out, and returns Accept/Reject so the Dipper can +/// reassign the request to another indexer on rejection. +#[derive(Debug)] +pub struct DipsServer { + pub ctx: Arc, + pub expected_payee: Address, + /// Shared counter incremented for every request that enters the handler. + /// The IPFS client reads it to decide whether to use the full retry + /// budget or fall back to a single attempt under load. + pub inflight: InflightCounter, +} - 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, - }; +/// 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::InvalidSignature(_) => RejectReason::InvalidSignature, + DipsError::UnexpectedServiceProvider { .. } => RejectReason::UnexpectedServiceProvider, + DipsError::UnsupportedMetadataVersion(_) => RejectReason::UnsupportedMetadataVersion, + DipsError::ManifestTooLarge { .. } => RejectReason::ManifestTooLarge, + DipsError::CapacityExceeded { .. } => RejectReason::CapacityExceeded, + // A re-sent proposal that conflicts or was already rejected: re-reject + // without re-running the pipeline. + DipsError::ReplayRejected { .. } | DipsError::ReplayConflict { .. } => { + RejectReason::ReplayDetected + } + // Malformed proposals with no dedicated reason map to the catch-all; the + // detail carries the specifics. + DipsError::AbiDecoding(_) + | DipsError::InvalidSubgraphManifest(_) + | DipsError::InvalidRca(_) => RejectReason::Unspecified, + DipsError::SenderNotTrusted { .. } => RejectReason::SenderNotTrusted, + // A store failure or an unverifiable role set means a valid proposal the + // indexer couldn't act on: tell dipper it's transient so it retries. + DipsError::TrustVerificationUnavailable(_) | DipsError::UnknownError(_) => { + RejectReason::IndexerUnavailable + } + } +} - 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()), - }) +/// 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(), } } -#[derive(Debug)] -pub struct DipsServer { - pub ctx: Arc, - pub expected_payee: Address, - pub allowed_payers: Vec
, - pub chain_id: ChainId, +/// Coarse `outcome` label for the proposal metric: an untrusted signer, a +/// transient failure the sender should retry, or some other validation rejection. +fn outcome_label(err: &DipsError) -> &'static str { + match err { + DipsError::SenderNotTrusted { .. } => "untrusted", + DipsError::TrustVerificationUnavailable(_) | DipsError::UnknownError(_) => "transient", + _ => "rejected", + } } #[async_trait] impl IndexerDipsService for DipsServer { + /// Submit an RCA proposal. + /// + /// Runs the checks listed in the crate-level docs, in that order, starting + /// with the request envelope and the trusted-signer check. + /// + /// On-chain offer existence is NOT checked: the offer doesn't exist yet + /// at proposal time. The contract enforces it at `acceptIndexingAgreement`. 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) => { + crate::metrics::PROPOSAL_OUTCOMES + .with_label_values(&["accepted"]) + .inc(); + tracing::info!( + %agreement_id, + deployment_id = deployment_id.as_deref().unwrap_or("unknown"), + "RCA accepted" + ); + Ok(Response::new(SubmitAgreementProposalResponse { + outcome: Some(Outcome::Accepted(Accepted {})), + })) + } + Err(e) => { + let reject_reason = reject_reason_from_error(&e); + crate::metrics::PROPOSAL_OUTCOMES + .with_label_values(&[outcome_label(&e)]) + .inc(); + tracing::info!( + error = %e, + reason = ?reject_reason, + 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(); +} + +#[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; - if version != 1 { - return Err(Status::invalid_argument("invalid version")); + use crate::trusted_signers::StaticTrustedSigners; + + 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()), + rca_domain: crate::rca_eip712_domain(1337, Address::repeat_byte(0xCC)), + trusted_signers: Arc::new(StaticTrustedSigners::default()), + max_new_agreements_per_24h: None, + }) } + } + + #[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 + // ========================================================================= - validate_and_cancel_agreement( - self.ctx.store.clone(), - &dips_cancellation_eip712_domain(self.chain_id), - signed_cancellation, - ) - .await - .map_err(Into::::into)?; + #[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); + } - Ok(tonic::Response::new(CancelAgreementResponse {})) + #[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()); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::SubgraphManifestUnavailable); + } + + #[test] + fn test_reject_reason_invalid_signature() { + // Arrange + let err = DipsError::InvalidSignature("bad signature".to_string()); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::InvalidSignature); + } + + #[test] + fn test_reject_reason_unexpected_service_provider() { + // Arrange + let err = DipsError::UnexpectedServiceProvider { + expected: Address::repeat_byte(0x01), + actual: Address::repeat_byte(0x02), + }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::UnexpectedServiceProvider); + } + + #[test] + fn test_reject_reason_unsupported_metadata_version() { + // Arrange + let err = DipsError::UnsupportedMetadataVersion(99); + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::UnsupportedMetadataVersion); + } + + #[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_replay_variants_map_to_replay_detected() { + // Arrange + use uuid::Uuid; + let rejected = DipsError::ReplayRejected { + agreement_id: Uuid::now_v7(), + }; + let conflict = DipsError::ReplayConflict { + agreement_id: Uuid::now_v7(), + }; + + // Act + Assert + assert_eq!( + super::reject_reason_from_error(&rejected), + RejectReason::ReplayDetected + ); + assert_eq!( + super::reject_reason_from_error(&conflict), + RejectReason::ReplayDetected + ); + } + + #[test] + fn test_reject_reason_capacity_exceeded() { + // Arrange + let err = DipsError::CapacityExceeded { limit: 30 }; + + // Act + let reason = super::reject_reason_from_error(&err); + + // Assert + assert_eq!(reason, RejectReason::CapacityExceeded); + } + + #[test] + fn test_reject_reason_unknown_error_is_transient() { + // Arrange: a store/database failure surfaces as UnknownError. + 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_reason_sender_not_trusted() { + let err = DipsError::SenderNotTrusted { + signer: Address::repeat_byte(0x55), + }; + + assert_eq!( + super::reject_reason_from_error(&err), + RejectReason::SenderNotTrusted + ); + } + + #[test] + fn test_reject_reason_trust_unverifiable_is_transient() { + let err = DipsError::TrustVerificationUnavailable("role subgraph down".to_string()); + + assert_eq!( + super::reject_reason_from_error(&err), + RejectReason::IndexerUnavailable + ); + } + + #[test] + fn test_reject_detail_sanitizes_store_errors() { + // Arrange: the raw database error mentions schema internals. + 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..b242bb4b6 100644 --- a/crates/dips/src/store.rs +++ b/crates/dips/src/store.rs @@ -1,106 +1,305 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashMap; +//! Storage abstraction for RCA proposals. [`RcaStore`] persists validated +//! proposals to the shared `pending_rca_proposals` table; indexer-rs writes them +//! and the indexer-agent reads them and advances `status`. See the migration. + +use std::{any::Any, time::Duration}; 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; + +/// Lifecycle statuses for a `pending_rca_proposals` row. Keep this set in sync +/// with the CHECK constraint in the `dips_status_check` migration: indexer-rs +/// writes `pending`; the agent advances a row to accepted, completed, rejected. +pub const STATUS_PENDING: &str = "pending"; +pub const STATUS_ACCEPTED: &str = "accepted"; +pub const STATUS_COMPLETED: &str = "completed"; +pub const STATUS_REJECTED: &str = "rejected"; + +/// A proposal already on record, returned by [`RcaStore::lookup`]. Carries the +/// lifecycle `status` and raw `signed_payload` so the early replay check can +/// compare byte-for-byte against a re-sent proposal. +#[derive(Debug, Clone, PartialEq)] +pub struct StoredProposal { + pub status: String, + pub signed_payload: Vec, } +/// Store for validated RCA proposals: indexer-rs writes them here and the +/// indexer-agent reads them to decide on-chain acceptance. #[async_trait] -pub trait 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. MUST be idempotent: storing the same + /// `agreement_id` twice must both succeed, so a Dipper re-send after a + /// timeout or network partition doesn't fail on a duplicate. + async fn store_rca( &self, - agreement: SignedIndexingAgreementVoucher, - metadata: SubgraphIndexingVoucherMetadata, + agreement_id: Uuid, + signed_rca: Vec, + version: u64, ) -> Result<(), DipsError>; - async fn cancel_agreement( - &self, - signed_cancellation: SignedCancellationRequest, - ) -> Result; + + /// Look up a stored proposal by its deterministic agreement ID, `Ok(None)` + /// when absent. The early replay check calls this before the IPFS fetch so a + /// re-sent proposal skips the download. + async fn lookup(&self, agreement_id: Uuid) -> Result, DipsError>; + + /// Count live agreements (status `pending` or `accepted`) within the trailing + /// `window`; `rejected` and `completed` proposals are excluded. The in-memory + /// test store has no status, so it ignores the window and counts every entry. + async fn count_since(&self, window: Duration) -> Result; + + /// Downcast to concrete type for testing. + fn as_any(&self) -> &dyn Any; } +/// One in-memory row: (id, signed payload, version, status). +type InMemoryRow = (Uuid, Vec, u64, String); + +/// In-memory implementation of RcaStore for testing. Each row carries a status +/// string (defaulting to "pending" on insert) so the replay check can be +/// exercised against accepted and rejected rows in tests. #[derive(Default, Debug)] -pub struct InMemoryAgreementStore { - pub data: tokio::sync::RwLock>, +pub struct InMemoryRcaStore { + pub data: tokio::sync::RwLock>, } #[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, + STATUS_PENDING.to_string(), + )); + } Ok(()) } - async fn cancel_agreement( + + async fn lookup(&self, agreement_id: Uuid) -> Result, DipsError> { + let data = self.data.read().await; + Ok(data + .iter() + .find(|(id, _, _, _)| *id == agreement_id) + .map(|(_, payload, _, status)| StoredProposal { + status: status.clone(), + signed_payload: payload.clone(), + })) + } + + async fn count_since(&self, _window: Duration) -> Result { + Ok(self.data.read().await.len() as u64) + } + + 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, + _agreement_id: Uuid, + _signed_rca: Vec, + _version: u64, + ) -> Result<(), DipsError> { + Err(DipsError::UnknownError(anyhow::anyhow!( + "database connection failed (test store)" + ))) + } + + async fn lookup(&self, _agreement_id: Uuid) -> Result, DipsError> { + Err(DipsError::UnknownError(anyhow::anyhow!( + "database connection failed (test store)" + ))) + } + + async fn count_since(&self, _window: Duration) -> Result { + Err(DipsError::UnknownError(anyhow::anyhow!( + "database connection failed (test store)" + ))) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +/// Test double whose `lookup` errors but whose `store_rca` succeeds, to exercise +/// the fail-open path: a lookup error must be swallowed so validation still runs. +#[derive(Default, Debug)] +pub struct LookupFailsStore; + +#[async_trait] +impl RcaStore for LookupFailsStore { + async fn store_rca( &self, - 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> { + Ok(()) + } + + async fn lookup(&self, _agreement_id: Uuid) -> Result, DipsError> { + Err(DipsError::UnknownError(anyhow::anyhow!( + "lookup failed (test store)" + ))) + } + + async fn count_since(&self, _window: Duration) -> Result { + Ok(0) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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]; + + // Act - store same ID twice + let result1 = store.store_rca(id, blob.clone(), 2).await; + let result2 = store.store_rca(id, blob.clone(), 2).await; + + // Assert - both succeed, only one entry stored + assert!(result1.is_ok(), "First store should succeed"); + assert!(result2.is_ok(), "Second store (retry) should also succeed"); + + let data = store.data.read().await; + assert_eq!(data.len(), 1, "Duplicate should not create second entry"); + assert_eq!(data[0].0, id); + } + + #[tokio::test] + async fn test_lookup_absent_returns_none() { + // Arrange + let store = InMemoryRcaStore::default(); + + // Act + let found = store.lookup(Uuid::now_v7()).await.unwrap(); + + // Assert + assert!(found.is_none()); + } + + #[tokio::test] + async fn test_lookup_returns_pending_payload() { + // Arrange + let store = InMemoryRcaStore::default(); + let id = Uuid::now_v7(); + let blob = vec![9, 8, 7, 6]; + store.store_rca(id, blob.clone(), 2).await.unwrap(); + + // Act + let found = store.lookup(id).await.unwrap(); + + // Assert + assert_eq!( + found, + Some(StoredProposal { + status: "pending".to_string(), + signed_payload: blob, + }) + ); + } - agreement.cancelled = true; + #[tokio::test] + async fn test_failing_store_lookup_errors() { + // Arrange + let store = FailingRcaStore; - let mut write_lock = self - .data - .try_write() - .map_err(|e| DipsError::UnknownError(e.into()))?; - write_lock.insert(id, agreement); + // Act + let result = store.lookup(Uuid::now_v7()).await; - Ok(id) + // Assert + assert!(matches!(result, Err(DipsError::UnknownError(_)))); } } diff --git a/crates/dips/src/trusted_signers.rs b/crates/dips/src/trusted_signers.rs new file mode 100644 index 000000000..c33a8ff7d --- /dev/null +++ b/crates/dips/src/trusted_signers.rs @@ -0,0 +1,861 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +//! Trust gate for incoming agreement proposals: require the recovered EIP-712 +//! signer to hold the on-chain `AGREEMENT_MANAGER_ROLE` (read from the +//! indexing-payments-subgraph), gating on the signer, never the spoofable payer. + +use async_trait::async_trait; +use thegraph_core::alloy::primitives::Address; + +use crate::DipsError; + +/// Source of truth for which signers may send agreement proposals. +#[async_trait] +pub trait TrustedSignerSource: std::fmt::Debug + Send + Sync { + /// Confirm `signer` may send agreement proposals. `Ok` = trusted; + /// `SenderNotTrusted` = definitively not a role holder; any other error means + /// the check failed and the caller should treat it as transient and retry. + async fn verify_trusted(&self, signer: Address) -> Result<(), DipsError>; +} + +/// A fixed set of trusted signers. Used in tests and as a simple in-memory +/// source; production uses [`SubgraphTrustedSigners`]. +#[derive(Debug, Clone, Default)] +pub struct StaticTrustedSigners(pub std::collections::HashSet
); + +#[async_trait] +impl TrustedSignerSource for StaticTrustedSigners { + async fn verify_trusted(&self, signer: Address) -> Result<(), DipsError> { + if self.0.contains(&signer) { + Ok(()) + } else { + Err(DipsError::SenderNotTrusted { signer }) + } + } +} + +#[cfg(feature = "db")] +pub use subgraph::SubgraphTrustedSigners; + +#[cfg(feature = "db")] +mod subgraph { + use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, RwLock}, + time::Duration, + }; + + use async_trait::async_trait; + use indexer_monitor::SubgraphClient; + use indexer_query::agreement_manager_roles::{self, AgreementManagerRolesQuery}; + use thegraph_core::alloy::primitives::{keccak256, Address}; + use tokio::{sync::Mutex, time::Instant}; + + use super::TrustedSignerSource; + use crate::DipsError; + + /// Page size for the holder query. The manager set is tiny in practice, so one + /// page almost always suffices; the fetch still pages so a larger set can't be + /// silently truncated. + const ROLE_PAGE_SIZE: i64 = 1000; + + /// After a failed on-demand fetch, don't re-hit the subgraph again for this + /// long, so a burst of unknown signers can't turn into a fetch storm. + const REFRESH_DEBOUNCE: Duration = Duration::from_secs(5); + + /// Don't refetch the holder set on demand more than once per this interval: a + /// recent successful fetch is authoritative, so a burst of distinct unknown + /// signers is answered from it instead of triggering one refetch each. + const ON_DEMAND_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(5); + + /// How long a "not a role holder" answer is reused from memory before that + /// signer is re-checked. Caps how often a repeated unknown signer drives a + /// fetch, and bounds how long a later-granted signer waits to be recognised. + const REJECTED_SIGNER_TTL: Duration = Duration::from_secs(3600); + + /// Hard cap on the negative cache so a flood of distinct unknown signers + /// can't grow it without bound. + const MAX_REJECTED_SIGNERS: usize = 100_000; + + #[derive(Default)] + struct RoleCache { + holders: HashSet
, + /// When the holder set was last fetched successfully. + last_success: Option, + /// When a fetch most recently failed (for debouncing retries). + last_failed_attempt: Option, + /// Signers recently confirmed *not* to hold the role, with the time of + /// that confirmation. A repeat of the same signer is rejected from here + /// until [`REJECTED_SIGNER_TTL`] passes; bounded by [`MAX_REJECTED_SIGNERS`]. + rejected: HashMap, + } + + /// Caches the AGREEMENT_MANAGER_ROLE holder set, refreshing on a timer and on + /// demand for unknown signers. A cache hit within the bounded window is + /// answered from memory (fail-open through outages); past it, fails closed. + pub struct SubgraphTrustedSigners { + subgraph: &'static SubgraphClient, + cache: Arc>, + refresh_lock: Arc>, + /// Oldest a successful fetch may be before a cache hit stops being + /// trusted: the bounded fail-open window. Set to the refresh interval + /// plus a grace period so a healthy deploy never trips it. + max_stale: Duration, + /// Largest gap between the subgraph head and wall-clock that is still + /// trusted; past it the data is treated as unreliable. 0 disables it. + max_chain_lag: Duration, + /// Holder-query page size. Production uses [`ROLE_PAGE_SIZE`]; tests set a + /// small value to exercise pagination. + page_size: i64, + } + + impl std::fmt::Debug for SubgraphTrustedSigners { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubgraphTrustedSigners") + .field("max_stale", &self.max_stale) + .field("max_chain_lag", &self.max_chain_lag) + .finish_non_exhaustive() + } + } + + impl SubgraphTrustedSigners { + /// Construct the source without fetching or starting the refresh task. + fn new( + subgraph: &'static SubgraphClient, + max_stale: Duration, + max_chain_lag: Duration, + ) -> Arc { + Self::new_with_page_size(subgraph, max_stale, max_chain_lag, ROLE_PAGE_SIZE) + } + + fn new_with_page_size( + subgraph: &'static SubgraphClient, + max_stale: Duration, + max_chain_lag: Duration, + page_size: i64, + ) -> Arc { + Arc::new(Self { + subgraph, + cache: Arc::new(RwLock::new(RoleCache::default())), + refresh_lock: Arc::new(Mutex::new(())), + max_stale, + max_chain_lag, + page_size, + }) + } + + /// Build the source, seed it once (best-effort), and start the periodic + /// refresh. A failed initial fetch is logged, not fatal: proposals are + /// rejected as transient until the first successful fetch. + pub async fn spawn( + subgraph: &'static SubgraphClient, + refresh_interval: Duration, + failopen_grace: Duration, + max_chain_lag: Duration, + ) -> Arc { + let this = Self::new(subgraph, refresh_interval + failopen_grace, max_chain_lag); + + if let Err(e) = this.refresh().await { + tracing::warn!( + error = %e, + "initial agreement-manager role fetch failed; DIPs proposals \ + will be rejected as transient until it succeeds" + ); + } + + let bg = this.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(refresh_interval); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + tick.tick().await; // the immediate first tick; already seeded above + loop { + tick.tick().await; + bg.refresh_with_retry().await; + } + }); + + this + } + + /// Refresh on the periodic schedule, retrying a failed fetch on a short + /// backoff rather than waiting a whole refresh interval. A brief subgraph + /// blip self-heals in seconds, so the fail-open window keeps its meaning. + async fn refresh_with_retry(&self) { + // Backoffs sit above REFRESH_DEBOUNCE so each retry actually re-fetches. + const BACKOFFS: [Duration; 4] = [ + Duration::from_secs(10), + Duration::from_secs(30), + Duration::from_secs(60), + Duration::from_secs(120), + ]; + let mut attempt = 0; + loop { + match self.refresh().await { + Ok(()) => return, + Err(e) if attempt < BACKOFFS.len() => { + let backoff = BACKOFFS[attempt]; + tracing::warn!( + error = %e, + ?backoff, + "periodic agreement-manager role refresh failed; retrying" + ); + tokio::time::sleep(backoff).await; + attempt += 1; + } + Err(e) => { + tracing::warn!( + error = %e, + "agreement-manager role refresh still failing; \ + waiting for the next scheduled refresh" + ); + return; + } + } + } + } + + fn is_fresh(&self, last_success: Option) -> bool { + last_success.is_some_and(|t| t.elapsed() < self.max_stale) + } + + /// Record a definitive "not a role holder" so a repeat of the same signer + /// is answered from memory until it ages out. Prunes expired entries and + /// refuses to grow past a hard cap, bounding the map under a flood. + fn note_rejected(&self, signer: Address) { + let mut cache = self.cache.write().unwrap(); + if cache.rejected.len() >= MAX_REJECTED_SIGNERS { + cache + .rejected + .retain(|_, t| t.elapsed() < REJECTED_SIGNER_TTL); + if cache.rejected.len() >= MAX_REJECTED_SIGNERS { + tracing::warn!( + cap = MAX_REJECTED_SIGNERS, + "rejected-signer cache is full; not caching this rejection" + ); + return; + } + } + cache.rejected.insert(signer, Instant::now()); + } + + /// Fetch the whole holder set and swap it into the cache. Single-flighted + /// (concurrent callers coalesce onto one fetch) and debounced after a + /// failure so an outage doesn't trigger a fetch per request. + async fn refresh(&self) -> Result<(), DipsError> { + let started = Instant::now(); + let _guard = self.refresh_lock.lock().await; + + { + let cache = self.cache.read().unwrap(); + // A concurrent refresh already succeeded after we started waiting. + if cache.last_success.is_some_and(|t| t >= started) { + return Ok(()); + } + // We failed very recently; don't hammer the subgraph. + if cache + .last_failed_attempt + .is_some_and(|t| t.elapsed() < REFRESH_DEBOUNCE) + { + return Err(DipsError::TrustVerificationUnavailable( + "agreement-manager role subgraph was unreachable moments ago".to_string(), + )); + } + } + + match self.fetch_holders().await { + Ok(holders) => { + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let mut cache = self.cache.write().unwrap(); + cache.holders = holders; + cache.last_success = Some(Instant::now()); + cache.last_failed_attempt = None; + crate::metrics::ROLE_SET_SIZE.set(cache.holders.len() as i64); + crate::metrics::ROLE_LAST_REFRESH_TIMESTAMP.set(now_unix); + Ok(()) + } + Err(e) => { + self.cache.write().unwrap().last_failed_attempt = Some(Instant::now()); + Err(DipsError::TrustVerificationUnavailable(e.to_string())) + } + } + } + + async fn fetch_holders(&self) -> anyhow::Result> { + // Derive the role id from its name so it can never drift from the + // on-chain AGREEMENT_MANAGER_ROLE constant. + let role = format!("0x{:x}", keccak256(b"AGREEMENT_MANAGER_ROLE")); + + let mut holders = HashSet::new(); + let mut last = String::new(); + let mut block_hash: Option = None; + let mut head_timestamp: Option = None; + let mut first_page = true; + + loop { + let data = self + .subgraph + .query::(agreement_manager_roles::Variables { + role: role.clone(), + first: self.page_size, + last: last.clone(), + block: block_hash.clone().map(|hash| { + agreement_manager_roles::Block_height { + hash: Some(hash), + number: None, + number_gte: None, + } + }), + }) + .await + .map_err(|e| anyhow::anyhow!("role-holder query failed: {e}"))?; + + // The first page fixes the head timestamp (for the staleness guard) + // and the block that later pages pin to for a consistent read. + if first_page { + first_page = false; + if let Some(meta) = data.meta.as_ref() { + head_timestamp = meta.block.timestamp; + block_hash = meta.block.hash.clone(); + } + } + + let page_len = data.role_assignments.len(); + for row in data.role_assignments { + last = row.id; + let account = row.account.parse::
().map_err(|e| { + anyhow::anyhow!("invalid role-holder account {:?}: {e}", row.account) + })?; + holders.insert(account); + } + if (page_len as i64) < self.page_size { + break; + } + } + + // A badly-lagging subgraph may be missing recent grants or revokes, + // so treat it as unreliable rather than trusting a stale role set. + let max_lag = self.max_chain_lag.as_secs() as i64; + if max_lag > 0 { + let timestamp = head_timestamp.ok_or_else(|| { + anyhow::anyhow!( + "indexing-payments subgraph returned no block timestamp; \ + cannot verify agreement-manager role freshness" + ) + })?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| anyhow::anyhow!("system time before unix epoch: {e}"))? + .as_secs() as i64; + let lag = now - timestamp; + if lag > max_lag { + return Err(anyhow::anyhow!( + "indexing-payments subgraph is {lag}s behind wall-clock (max {max_lag}s); \ + treating agreement-manager role data as unreliable" + )); + } + } + + if holders.is_empty() { + tracing::warn!( + "agreement-manager role set is empty; all DIPs proposals will be \ + rejected as untrusted" + ); + } + Ok(holders) + } + } + + /// What `verify_trusted` should do once it holds the freshest cache state. + /// Pulled out as a pure function so every branch (including the fail-open + /// admit, which otherwise only fires on a rare race) is unit-testable. + enum Decision { + Trusted, + Untrusted, + FailOpen, + Transient(DipsError), + } + + /// Decide from a refresh result plus the post-refresh cache state: `present` + /// is whether the signer is in the holder set, `fresh` whether that set is + /// still inside the fail-open window. + fn decide(refreshed: Result<(), DipsError>, present: bool, fresh: bool) -> Decision { + match refreshed { + Ok(()) if present => Decision::Trusted, + Ok(()) => Decision::Untrusted, + Err(_) if present && fresh => Decision::FailOpen, + Err(transient) => Decision::Transient(transient), + } + } + + #[async_trait] + impl TrustedSignerSource for SubgraphTrustedSigners { + async fn verify_trusted(&self, signer: Address) -> Result<(), DipsError> { + // Fast path, answered from memory: a known holder inside the window + // passes; a signer recently confirmed *not* a holder is rejected, so a + // repeated unknown signer can't drive a fetch until its entry ages out. + { + let cache = self.cache.read().unwrap(); + if cache.holders.contains(&signer) && self.is_fresh(cache.last_success) { + return Ok(()); + } + if cache + .rejected + .get(&signer) + .is_some_and(|t| t.elapsed() < REJECTED_SIGNER_TTL) + { + return Err(DipsError::SenderNotTrusted { signer }); + } + } + + // A new or stale-cache signer: refresh on demand to catch a just-granted + // manager, but skip the refetch when the last success is recent and still + // fresh; that bounds a distinct-unknown-signer flood to one fetch per window. + let recently_refreshed = { + let cache = self.cache.read().unwrap(); + cache.last_success.is_some_and(|t| { + t.elapsed() < ON_DEMAND_REFRESH_MIN_INTERVAL && self.is_fresh(Some(t)) + }) + }; + let refreshed = if recently_refreshed { + Ok(()) + } else { + self.refresh().await + }; + + let (present, fresh) = { + let cache = self.cache.read().unwrap(); + ( + cache.holders.contains(&signer), + self.is_fresh(cache.last_success), + ) + }; + + match decide(refreshed, present, fresh) { + // A newly-granted signer clears any stale negative entry. + Decision::Trusted => { + self.cache.write().unwrap().rejected.remove(&signer); + Ok(()) + } + Decision::Untrusted => { + self.note_rejected(signer); + Err(DipsError::SenderNotTrusted { signer }) + } + Decision::FailOpen => { + tracing::warn!( + %signer, + "agreement-manager role refresh failed; admitting known \ + signer from cached set within fail-open window" + ); + Ok(()) + } + // Couldn't verify and can't fail open; report transient and record + // no negative entry, since this isn't a definitive "no". + Decision::Transient(e) => Err(e), + } + } + } + + #[cfg(test)] + mod test { + use std::time::Duration; + + use indexer_monitor::{DeploymentDetails, SubgraphClient}; + use serde_json::json; + use thegraph_core::alloy::primitives::{address, Address}; + use wiremock::{ + matchers::{body_partial_json, method}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + + const HOLDER: Address = address!("f39fd6e51aad88f6f4ce6ab8827279cfffb92266"); + const STRANGER: Address = address!("0000000000000000000000000000000000000099"); + + fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + } + + /// A graphql_client response body for the role query: one `roleAssignments` + /// row per account (with a synthetic id cursor) plus the `_meta` head. + fn holders_body(accounts: &[Address], timestamp: i64) -> serde_json::Value { + roles_page(accounts, 1, timestamp) + } + + /// Like [`holders_body`] but with id cursors starting at `first_id`, so a + /// test can stitch several pages together (ids must strictly increase). + fn roles_page(accounts: &[Address], first_id: usize, timestamp: i64) -> serde_json::Value { + json!({ + "data": { + "meta": { "block": { "number": 1, "hash": null, "timestamp": timestamp } }, + "roleAssignments": accounts + .iter() + .enumerate() + .map(|(i, a)| json!({ + "id": format!("0x{:064x}", first_id + i), + "account": a, + })) + .collect::>(), + } + }) + } + + async fn leak_client(server: &MockServer) -> &'static SubgraphClient { + Box::leak(Box::new( + SubgraphClient::new( + reqwest::Client::new(), + None, + DeploymentDetails::for_query_url(&server.uri()).unwrap(), + ) + .await, + )) + } + + async fn client_always( + template: ResponseTemplate, + ) -> (&'static SubgraphClient, MockServer) { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(template) + .mount(&server) + .await; + let client = leak_client(&server).await; + (client, server) + } + + #[tokio::test] + async fn known_signer_passes() { + let (client, _server) = client_always( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + src.refresh().await.unwrap(); + + assert!(src.verify_trusted(HOLDER).await.is_ok()); + } + + #[tokio::test] + async fn unknown_signer_rejected_after_refresh() { + let (client, _server) = client_always( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .await; + // Cold cache: the verify triggers an on-demand refresh, then rejects. + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + assert!(matches!( + src.verify_trusted(STRANGER).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + } + + #[tokio::test] + async fn empty_role_set_rejects() { + let (client, _server) = client_always( + ResponseTemplate::new(200).set_body_json(holders_body(&[], now_secs())), + ) + .await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + assert!(matches!( + src.verify_trusted(HOLDER).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + } + + #[tokio::test] + async fn unreachable_subgraph_is_transient() { + let (client, _server) = client_always(ResponseTemplate::new(500)).await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + assert!(matches!( + src.verify_trusted(HOLDER).await, + Err(DipsError::TrustVerificationUnavailable(_)) + )); + } + + #[tokio::test] + async fn stale_subgraph_head_is_transient() { + // Head far behind wall-clock with a 60s tolerance: treated as unreliable. + let (client, _server) = + client_always(ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], 1))) + .await; + let src = SubgraphTrustedSigners::new( + client, + Duration::from_secs(60), + Duration::from_secs(60), + ); + + assert!(matches!( + src.verify_trusted(HOLDER).await, + Err(DipsError::TrustVerificationUnavailable(_)) + )); + } + + #[tokio::test] + async fn known_signer_fails_closed_past_window() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + + // Tiny window so the cache ages out within the test. Chain-lag check off. + let src = + SubgraphTrustedSigners::new(client, Duration::from_millis(400), Duration::ZERO); + src.refresh().await.unwrap(); + assert!( + src.verify_trusted(HOLDER).await.is_ok(), + "a known holder passes from cache while the window is fresh" + ); + + // The subgraph goes down and the cache ages past the window. + server.reset().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + tokio::time::sleep(Duration::from_millis(600)).await; + + assert!( + matches!( + src.verify_trusted(HOLDER).await, + Err(DipsError::TrustVerificationUnavailable(_)) + ), + "past the window with the subgraph down, even a known holder fails closed" + ); + } + + #[tokio::test] + async fn paginates_through_all_holders() { + // Page size 2 against three holders forces a second page: page 1 is + // full (ids 1-2), page 2 partial (id 3), selected by the id_gt cursor + // carried in the request body. All three must end up trusted. + const A: Address = address!("0000000000000000000000000000000000000001"); + const B: Address = address!("0000000000000000000000000000000000000002"); + const C: Address = address!("0000000000000000000000000000000000000003"); + + let server = MockServer::start().await; + // First page: empty cursor. + Mock::given(method("POST")) + .and(body_partial_json(json!({ "variables": { "last": "" } }))) + .respond_with(ResponseTemplate::new(200).set_body_json(roles_page( + &[A, B], + 1, + now_secs(), + ))) + .mount(&server) + .await; + // Second page: cursor is page 1's last id. + let page1_last = format!("0x{:064x}", 2); + Mock::given(method("POST")) + .and(body_partial_json( + json!({ "variables": { "last": page1_last } }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(roles_page( + &[C], + 3, + now_secs(), + ))) + .mount(&server) + .await; + let client = leak_client(&server).await; + + let src = SubgraphTrustedSigners::new_with_page_size( + client, + Duration::from_secs(60), + Duration::ZERO, + 2, + ); + src.refresh().await.unwrap(); + + for holder in [A, B, C] { + assert!( + src.verify_trusted(holder).await.is_ok(), + "holder {holder} from a later page should be trusted" + ); + } + } + + #[tokio::test] + async fn repeat_unknown_signer_is_not_refetched() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + // First lookup: cold cache, one on-demand fetch, definitive reject. + assert!(matches!( + src.verify_trusted(STRANGER).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + // Second lookup of the same signer: served from the negative cache. + assert!(matches!( + src.verify_trusted(STRANGER).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + + let posts = server.received_requests().await.unwrap().len(); + assert_eq!(posts, 1, "a repeat of a known-bad signer must not re-query"); + } + + #[tokio::test] + async fn concurrent_unknown_signers_coalesce_one_fetch() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(holders_body(&[HOLDER], now_secs())) + .set_delay(Duration::from_millis(200)), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + + // Two distinct unknown signers arriving together coalesce onto one + // fetch via the single-flight refresh lock. + let a = address!("00000000000000000000000000000000000000aa"); + let b = address!("00000000000000000000000000000000000000bb"); + let (ra, rb) = tokio::join!(src.verify_trusted(a), src.verify_trusted(b)); + assert!(matches!(ra, Err(DipsError::SenderNotTrusted { .. }))); + assert!(matches!(rb, Err(DipsError::SenderNotTrusted { .. }))); + + let posts = server.received_requests().await.unwrap().len(); + assert_eq!( + posts, 1, + "concurrent unknown signers should share a single fetch" + ); + } + + #[test] + fn decide_truth_table() { + let err = || DipsError::TrustVerificationUnavailable("down".to_string()); + + assert!(matches!(decide(Ok(()), true, true), Decision::Trusted)); + // A successful fetch is authoritative, so presence alone admits. + assert!(matches!(decide(Ok(()), true, false), Decision::Trusted)); + assert!(matches!(decide(Ok(()), false, true), Decision::Untrusted)); + // The fail-open admit: refresh failed but a known holder is still fresh. + assert!(matches!(decide(Err(err()), true, true), Decision::FailOpen)); + assert!(matches!( + decide(Err(err()), true, false), + Decision::Transient(_) + )); + assert!(matches!( + decide(Err(err()), false, true), + Decision::Transient(_) + )); + assert!(matches!( + decide(Err(err()), false, false), + Decision::Transient(_) + )); + } + + #[tokio::test] + async fn known_holder_served_from_cache_during_outage() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + let src = SubgraphTrustedSigners::new(client, Duration::from_secs(60), Duration::ZERO); + src.refresh().await.unwrap(); + + // The subgraph goes down; within the window a known holder still passes. + server.reset().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + assert!( + src.verify_trusted(HOLDER).await.is_ok(), + "a known holder is admitted from cache while the subgraph is down" + ); + } + + #[tokio::test] + async fn fresh_head_within_lag_tolerance_passes() { + let (client, _server) = client_always( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .await; + // Non-zero lag tolerance with a head at ~now: lag is inside tolerance, + // so the success side of the chain-lag guard trusts the fetch. + let src = SubgraphTrustedSigners::new( + client, + Duration::from_secs(60), + Duration::from_secs(1800), + ); + assert!(src.verify_trusted(HOLDER).await.is_ok()); + } + + #[tokio::test] + async fn on_demand_refetch_is_rate_limited() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(holders_body(&[HOLDER], now_secs())), + ) + .mount(&server) + .await; + let client = leak_client(&server).await; + // Window far larger than the 5s min-interval so the freshness gate never + // trips here; lag check off. + let src = + SubgraphTrustedSigners::new(client, Duration::from_secs(3600), Duration::ZERO); + + // Freeze time so the on-demand min-interval is exercised deterministically. + tokio::time::pause(); + + // First unknown signer: cold cache, one real fetch, then rejected. + assert!(matches!( + src.verify_trusted(Address::repeat_byte(0xa0)).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + // More distinct unknowns inside the window: answered from the recent fetch. + for b in [0xa1u8, 0xa2, 0xa3] { + assert!(matches!( + src.verify_trusted(Address::repeat_byte(b)).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + } + assert_eq!( + server.received_requests().await.unwrap().len(), + 1, + "distinct unknown signers within the window must share one fetch" + ); + + // Past the window, the next unknown signer triggers exactly one more fetch. + tokio::time::advance(Duration::from_secs(6)).await; + assert!(matches!( + src.verify_trusted(Address::repeat_byte(0xa4)).await, + Err(DipsError::SenderNotTrusted { .. }) + )); + assert_eq!( + server.received_requests().await.unwrap().len(), + 2, + "an unknown signer past the window triggers one more fetch" + ); + } + } +} diff --git a/crates/monitor/src/allocations.rs b/crates/monitor/src/allocations.rs index 1b7b426cf..ed4f3a15f 100644 --- a/crates/monitor/src/allocations.rs +++ b/crates/monitor/src/allocations.rs @@ -39,7 +39,7 @@ pub struct AllocationQueryResponse { /// * `indexer_address` - The indexer's address /// * `interval` - How often to poll for updates /// * `recently_closed_allocation_buffer` - How long to keep closed allocations -/// * `max_data_staleness_mins` - Maximum allowed age of data in minutes. Set to 0 to disable. +/// * `max_data_staleness_mins` - Maximum allowed age of data in minutes (must be positive). /// When data is older than this threshold, the update is rejected unless it's fresher than /// the current best data. This protects against Gateway routing to stale indexers. /// diff --git a/crates/monitor/src/escrow_accounts.rs b/crates/monitor/src/escrow_accounts.rs index c3449ea28..77982cd32 100644 --- a/crates/monitor/src/escrow_accounts.rs +++ b/crates/monitor/src/escrow_accounts.rs @@ -3,6 +3,7 @@ use std::{ collections::{HashMap, HashSet}, + num::NonZeroUsize, str::FromStr, time::Duration, }; @@ -116,7 +117,7 @@ pub async fn escrow_accounts_v2( reject_thawing_signers: bool, collector_address: Address, min_balance_grt_wei: String, - max_signers_per_payer: usize, + max_signers_per_payer: Option, ) -> Result { indexer_watcher::new_watcher(interval, move || { get_escrow_accounts_v2( @@ -137,7 +138,7 @@ async fn get_escrow_accounts_v2( reject_thawing_signers: bool, collector_address: Address, min_balance_grt_wei: String, - max_signers_per_payer: usize, + max_signers_per_payer: Option, ) -> anyhow::Result { tracing::trace!( indexer_address = ?indexer_address, @@ -150,6 +151,9 @@ async fn get_escrow_accounts_v2( }; use indexer_query::signers_by_payer::{self as signers_by_payer, SignersByPayerQuery}; + // Cap on signers fetched per payer; None means no limit. + let max_signers = max_signers_per_payer.map(NonZeroUsize::get); + let page_size: i64 = 200; let mut last: Option = None; let mut block_hash: Option = None; @@ -191,15 +195,17 @@ async fn get_escrow_accounts_v2( // Paginate additional signers for any payer that hit the 1000-per-payer nested cap let mut response = response; for account in &mut response.payments_escrow_accounts { - if max_signers_per_payer > 0 && account.payer.signers.len() >= max_signers_per_payer { - tracing::warn!( - payer = %account.payer.id, - signers = account.payer.signers.len(), - max = max_signers_per_payer, - "Payer signers already at or above max_signers_per_payer cap; skipping follow-up pagination" - ); - account.payer.signers.truncate(max_signers_per_payer); - continue; + if let Some(max) = max_signers { + if account.payer.signers.len() >= max { + tracing::warn!( + payer = %account.payer.id, + signers = account.payer.signers.len(), + max, + "Payer signers already at or above max_signers_per_payer cap; skipping follow-up pagination" + ); + account.payer.signers.truncate(max); + continue; + } } if account.payer.signers.len() < 1000 { @@ -245,8 +251,7 @@ async fn get_escrow_accounts_v2( } if page_len < 1000 - || (max_signers_per_payer > 0 - && account.payer.signers.len() >= max_signers_per_payer) + || max_signers.is_some_and(|max| account.payer.signers.len() >= max) { break; } @@ -258,14 +263,16 @@ async fn get_escrow_accounts_v2( .unwrap_or_default(); } - if max_signers_per_payer > 0 && account.payer.signers.len() >= max_signers_per_payer { - tracing::warn!( - payer = %account.payer.id, - signers = account.payer.signers.len(), - max = max_signers_per_payer, - "Payer signers capped at max_signers_per_payer" - ); - account.payer.signers.truncate(max_signers_per_payer); + if let Some(max) = max_signers { + if account.payer.signers.len() >= max { + tracing::warn!( + payer = %account.payer.id, + signers = account.payer.signers.len(), + max, + "Payer signers capped at max_signers_per_payer" + ); + account.payer.signers.truncate(max); + } } if account.payer.signers.len() > 1000 { @@ -419,7 +426,7 @@ mod tests { true, Address::ZERO, // collector address; mock ignores query variables "100000000000000000".to_string(), - 0, + None, // no signer-per-payer cap ) .await .unwrap(); diff --git a/crates/query/graphql/agreement_manager_roles.query.graphql b/crates/query/graphql/agreement_manager_roles.query.graphql new file mode 100644 index 000000000..65dfcac46 --- /dev/null +++ b/crates/query/graphql/agreement_manager_roles.query.graphql @@ -0,0 +1,30 @@ +# Active AGREEMENT_MANAGER_ROLE holders from the indexing-payments subgraph: the +# signers the indexer trusts to send agreement proposals. Cursor-paginated by id; +# $block pins later pages to the first page's block for a consistent read. + +query AgreementManagerRolesQuery( + $role: Bytes! + $first: Int! + $last: ID! + $block: Block_height +) { + meta: _meta(block: $block) { + block { + number + hash + timestamp + } + } + roleAssignments( + block: $block + orderBy: id + orderDirection: asc + first: $first + # active: true is load-bearing -- a revoke flags the row rather than + # deleting it, and several roles are indexed, so both filters are needed. + where: { id_gt: $last, role: $role, active: true } + ) { + id + account + } +} diff --git a/crates/query/graphql/indexing_payments.schema.graphql b/crates/query/graphql/indexing_payments.schema.graphql new file mode 100644 index 000000000..2e5f7fac8 --- /dev/null +++ b/crates/query/graphql/indexing_payments.schema.graphql @@ -0,0 +1,64 @@ +# Subset of the indexing-payments subgraph's graph-node API schema, holding only +# the RoleAssignment and _meta fields the agreement-manager-role query reads. +# graphql_client uses it for compile-time type generation; grow it with the query. + +scalar Bytes + +type _Block_ { + hash: Bytes + number: Int! + timestamp: Int +} + +type _Meta_ { + block: _Block_! + deployment: String! + hasIndexingErrors: Boolean! +} + +input Block_height { + hash: Bytes + number: Int + number_gte: Int +} + +enum OrderDirection { + asc + desc +} + +type RoleAssignment { + id: ID! + role: Bytes! + account: Bytes! + active: Boolean! +} + +enum RoleAssignment_orderBy { + id + role + account + active +} + +input RoleAssignment_filter { + id_gt: ID + role: Bytes + active: Boolean +} + +type Query { + _meta(block: Block_height): _Meta_ + roleAssignments( + skip: Int + first: Int + orderBy: RoleAssignment_orderBy + orderDirection: OrderDirection + where: RoleAssignment_filter + block: Block_height + ): [RoleAssignment!]! +} + +schema { + query: Query +} diff --git a/crates/query/src/lib.rs b/crates/query/src/lib.rs index 4c1888ee5..4deec7b89 100644 --- a/crates/query/src/lib.rs +++ b/crates/query/src/lib.rs @@ -190,3 +190,19 @@ pub mod payments_escrow_transactions_redeem { pub use payments_escrow_transactions_redeem_query::*; } + +pub mod agreement_manager_roles { + use graphql_client::GraphQLQuery; + type Bytes = String; + + #[derive(GraphQLQuery)] + #[graphql( + schema_path = "graphql/indexing_payments.schema.graphql", + query_path = "graphql/agreement_manager_roles.query.graphql", + response_derives = "Debug", + variables_derives = "Clone" + )] + pub struct AgreementManagerRolesQuery; + + pub use agreement_manager_roles_query::*; +} diff --git a/crates/service/Cargo.toml b/crates/service/Cargo.toml index c9586efef..690629d45 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/constants.rs b/crates/service/src/constants.rs index 1246630ed..d2f65a697 100644 --- a/crates/service/src/constants.rs +++ b/crates/service/src/constants.rs @@ -39,17 +39,6 @@ use std::time::Duration; /// - Graph-node processing time pub const HTTP_CLIENT_TIMEOUT: Duration = Duration::from_secs(30); -/// Timeout for DIPs HTTP client requests. -/// -/// DIPs (Direct Indexing Payments) operations involve: -/// - IPFS content fetching -/// - Agreement validation and storage -/// - Network registry lookups -/// -/// 60 seconds provides additional headroom for these heavier operations -/// compared to standard graph-node queries. -pub const DIPS_HTTP_CLIENT_TIMEOUT: Duration = Duration::from_secs(60); - // ============================================================================= // DATABASE CONFIGURATION // ============================================================================= diff --git a/crates/service/src/metrics.rs b/crates/service/src/metrics.rs index a71540545..3fe312b76 100644 --- a/crates/service/src/metrics.rs +++ b/crates/service/src/metrics.rs @@ -5,7 +5,8 @@ use std::{net::SocketAddr, sync::LazyLock}; use axum::{routing::get, serve, Router}; use prometheus::{ - register_counter_vec, register_histogram_vec, CounterVec, HistogramVec, TextEncoder, + register_counter_vec, register_histogram_vec, register_int_gauge, CounterVec, HistogramVec, + IntGauge, TextEncoder, }; use reqwest::StatusCode; use tokio::net::TcpListener; @@ -36,6 +37,17 @@ pub static FAILED_RECEIPT: LazyLock = LazyLock::new(|| { .unwrap() }); +/// Set to 1 when the DIPs gRPC server started, 0 when DIPs was configured but +/// failed to initialize (the service then runs without DIPs). Not exported +/// when DIPs is not configured, so alerting on 0 is safe for DIPs operators. +pub static DIPS_ENABLED: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "indexer_dips_enabled", + "1 if the DIPs gRPC server is running; 0 if DIPs is configured but failed to initialize" + ) + .unwrap() +}); + pub fn serve_metrics(host_and_port: SocketAddr) { tracing::info!(address = %host_and_port, "Serving prometheus metrics"); diff --git a/crates/service/src/routes/dips_info.rs b/crates/service/src/routes/dips_info.rs index 6f65543b1..a25a0c8e3 100644 --- a/crates/service/src/routes/dips_info.rs +++ b/crates/service/src/routes/dips_info.rs @@ -1,89 +1,101 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use axum::{extract::State, Json}; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; use serde::Serialize; use std::collections::BTreeMap; -/// Indexer DIPs pricing signal. Derived from `DipsConfig` -/// at startup and served as-is on every request. -#[derive(Clone, Debug, Serialize)] -pub struct DipsInfo { +/// State for the /dips/info endpoint. `Failed` deliberately carries no error +/// detail: init errors can reference the operator's RPC endpoint (which may +/// embed credentials), so the cause stays in logs and metrics only. +#[derive(Clone, Debug)] +pub enum DipsInfoState { + Available { + min_grt_per_30_days: BTreeMap, + min_grt_per_billion_entities_per_30_days: String, + }, + Failed, +} + +#[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 { - Json(state) +#[derive(Serialize)] +pub struct DipsInfoUnavailable { + pub status: &'static str, + pub error: &'static str, } -#[cfg(test)] -mod tests { - use super::*; - use axum::extract::State; +pub async fn dips_info(State(state): State) -> Response { + match state { + DipsInfoState::Available { + min_grt_per_30_days, + min_grt_per_billion_entities_per_30_days, + } => { + let supported_networks: Vec = min_grt_per_30_days.keys().cloned().collect(); - fn sample_state() -> DipsInfo { - DipsInfo { - min_grt_per_30_days: BTreeMap::from_iter([ - ("arbitrum-one".to_string(), "450".to_string()), - ("mainnet".to_string(), "45".to_string()), - ]), - min_grt_per_billion_entities_per_30_days: "200".to_string(), - supported_networks: vec!["arbitrum-one".to_string(), "mainnet".to_string()], + Json(DipsInfoResponse { + pricing: DipsInfoPricing { + min_grt_per_30_days, + min_grt_per_billion_entities_per_30_days, + }, + supported_networks, + }) + .into_response() } + DipsInfoState::Failed => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(DipsInfoUnavailable { + status: "unavailable", + error: "DIPs initialization failed; check the indexer-service logs", + }), + ) + .into_response(), } +} + +#[cfg(test)] +mod tests { + use super::*; #[tokio::test] - async fn returns_state_verbatim() { + async fn test_dips_info_available_returns_pricing() { // Arrange - let state = sample_state(); - let expected = state.clone(); + let state = DipsInfoState::Available { + min_grt_per_30_days: BTreeMap::from([("mainnet".to_string(), "450".to_string())]), + min_grt_per_billion_entities_per_30_days: "200".to_string(), + }; // Act let response = dips_info(State(state)).await; // Assert - assert_eq!(response.min_grt_per_30_days, expected.min_grt_per_30_days); - assert_eq!( - response.min_grt_per_billion_entities_per_30_days, - expected.min_grt_per_billion_entities_per_30_days - ); - assert_eq!(response.supported_networks, expected.supported_networks); + assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] - async fn empty_state_renders_empty_lists_and_zero() { - // Arrange: indexer has [dips] configured but priced nothing. - let state = DipsInfo { - min_grt_per_30_days: BTreeMap::new(), - min_grt_per_billion_entities_per_30_days: "0".to_string(), - supported_networks: vec![], - }; + async fn test_dips_info_failed_returns_service_unavailable() { + // Arrange + let state = DipsInfoState::Failed; // Act let response = dips_info(State(state)).await; // Assert - assert!(response.min_grt_per_30_days.is_empty()); - assert!(response.supported_networks.is_empty()); - assert_eq!(response.min_grt_per_billion_entities_per_30_days, "0"); - } - - #[test] - fn json_shape_is_flat_with_three_top_level_fields() { - // Arrange - let state = sample_state(); - - // Act - let json = serde_json::to_value(&state).expect("serializes"); - - // Assert: three top-level keys, no `pricing` nesting. - let obj = json.as_object().expect("object"); - assert!(obj.contains_key("min_grt_per_30_days")); - assert!(obj.contains_key("min_grt_per_billion_entities_per_30_days")); - assert!(obj.contains_key("supported_networks")); - assert_eq!(obj.len(), 3); - assert!(!obj.contains_key("pricing")); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); } } diff --git a/crates/service/src/routes/mod.rs b/crates/service/src/routes/mod.rs index a43d69fff..57e3044b7 100644 --- a/crates/service/src/routes/mod.rs +++ b/crates/service/src/routes/mod.rs @@ -7,15 +7,9 @@ //! //! ## Route Overview //! -//! | Route | Handler | Description | -//! |-------|---------|-------------| -//! | `POST /subgraphs/id/:id` | [`request_handler`] | Main query endpoint for paid queries | -//! | `GET /health/:deployment` | [`health`] | Deployment health status from graph-node | -//! | `GET /healthz` | [`healthz`] | Service dependency health check | -//! | `POST /status` | [`status`] | Indexing status queries (allowlisted fields) | -//! | `GET /cost` | [`cost::cost_handler`] | Cost model information | -//! | `POST /network` | [`static_subgraph_request_handler`] | Network subgraph proxy | -//! | `POST /escrow` | [`static_subgraph_request_handler`] | Escrow subgraph proxy | +//! The route list lives in `docs/Routes.md`, and the registrations themselves in +//! `crate::service::router`. Keeping it in one place stops this list drifting from +//! what the service actually serves. //! //! ## Query Flow //! @@ -30,14 +24,14 @@ //! - [`healthz`]: Checks connectivity to database and graph-node dependencies pub mod cost; -mod dips_info; +pub mod dips_info; mod health; mod healthz; mod request_handler; mod static_subgraph; mod status; -pub use dips_info::{dips_info, DipsInfo}; +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 35583837c..1607b130d 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::{collections::BTreeMap, 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,36 +13,46 @@ 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, + trusted_signers::{SubgraphTrustedSigners, TrustedSignerSource}, }; -use indexer_monitor::{escrow_accounts_v2, DeploymentDetails, SubgraphClient}; +use indexer_monitor::{DeploymentDetails, SubgraphClient}; use release::IndexerServiceRelease; use reqwest::Url; use tap_core::tap_eip712_domain; +use thegraph_core::alloy::primitives::{Address, U256}; use tokio::{net::TcpListener, signal}; use tokio_util::sync::CancellationToken; +use 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}, + constants::HTTP_CLIENT_TIMEOUT, database, - metrics::serve_metrics, - routes::DipsInfo, + metrics::{serve_metrics, DIPS_ENABLED}, + 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; @@ -55,6 +69,7 @@ fn format_grt(wei: u128) -> String { 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) @@ -98,15 +113,30 @@ pub async fn run() -> anyhow::Result<()> { ) .await; + // DIPs reads the agreement-manager role set from the indexing-payments + // subgraph; build its client before the router consumes graph_node. + let indexing_payments_subgraph = if config.dips.is_some() { + let subgraph_config = config.subgraphs.indexing_payments.as_ref().ok_or_else(|| { + anyhow!("subgraphs.indexing_payments must be set when DIPs is enabled") + })?; + Some(create_subgraph_client(http_client.clone(), &config.graph_node, subgraph_config).await) + } else { + None + }; + // V2 escrow accounts are in the network subgraph, not a separate escrow_v2 subgraph // Establish Database connection necessary for serving indexer management - // 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; @@ -115,15 +145,15 @@ 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; + // Captured before `config.blockchain` is moved into the router state below; + // used to build the RCA EIP-712 domain for DIPs signer recovery. + let blockchain_chain_id = config.blockchain.chain_id as u64; let ipfs_url = config.service.ipfs_url.clone(); - // V2 escrow accounts (used by DIPs) 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; @@ -152,23 +182,50 @@ pub async fn run() -> anyhow::Result<()> { } }; - let dips_info_state = config.dips.as_ref().map(|dips| { - let min_grt_per_30_days: BTreeMap = dips - .min_grt_per_30_days - .iter() - .map(|(network, grt)| (network.clone(), format_grt(grt.wei()))) - .collect(); - // Pricing a chain in config is the act of declaring support for it, - // so derive the published list directly from the price-map keys. - let supported_networks = min_grt_per_30_days.keys().cloned().collect(); - DipsInfo { - min_grt_per_30_days, - min_grt_per_billion_entities_per_30_days: format_grt( - dips.min_grt_per_billion_entities_per_30_days.wei(), - ), - supported_networks, - } - }); + // Create a cancellation token for coordinated graceful shutdown + let shutdown_token = CancellationToken::new(); + + // DIPS: RecurringCollectionAgreement validation and storage. An init + // failure here must not take down query serving: log it loudly and run + // without DIPs instead. /dips/info and the metric reflect the outcome. + let dips_info_state = match config.dips.as_ref() { + Some(dips) => match start_dips( + dips, + blockchain_chain_id, + indexing_payments_subgraph + .expect("indexing_payments client is built whenever DIPs is enabled"), + &ipfs_url, + database.clone(), + indexer_address, + &shutdown_token, + ) + .await + { + Ok(()) => { + DIPS_ENABLED.set(1); + Some(DipsInfoState::Available { + min_grt_per_30_days: dips + .min_grt_per_30_days + .iter() + .map(|(network, grt)| (network.clone(), format_grt(grt.wei()))) + .collect(), + min_grt_per_billion_entities_per_30_days: format_grt( + dips.min_grt_per_billion_entities_per_30_days.wei(), + ), + }) + } + Err(err) => { + DIPS_ENABLED.set(0); + tracing::error!( + error = format!("{err:#}"), + "DIPs initialization failed; the service is running WITHOUT DIPs \ + and will not receive indexing agreement proposals until restarted" + ); + Some(DipsInfoState::Failed) + } + }, + None => None, + }; let router = ServiceRouter::builder() .database(database.clone()) @@ -181,100 +238,16 @@ 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()); - // Create a cancellation token for coordinated graceful shutdown - let shutdown_token = CancellationToken::new(); - tracing::info!( address = %host_and_port, "Serving requests", ); - if let Some(dips) = config.dips.as_ref() { - let DipsConfig { - host, - port, - allowed_payers, - price_per_entity, - price_per_epoch, - additional_networks, - // Signalling fields are consumed by the `/dips/info` route built - // earlier from `config.dips`; the gRPC handler does not read them. - min_grt_per_30_days: _, - min_grt_per_billion_entities_per_30_days: _, - } = dips; - - 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}'"))?, - ); - - // 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")?; - - let ctx = DipsServerContext { - store: Arc::new(PsqlAgreementStore { - 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), - additional_networks: Arc::new(additional_networks.clone()), - }; - - let dips = DipsServer { - ctx: Arc::new(ctx), - expected_payee: indexer_address, - allowed_payers: allowed_payers.clone(), - chain_id, - }; - - info!(address = %addr, "Starting DIPs gRPC server"); - - let dips_shutdown_token = shutdown_token.clone(); - tokio::spawn(async move { - start_dips_server(addr, dips, dips_shutdown_token.cancelled()).await; - }); - } let listener = TcpListener::bind(&host_and_port) .await @@ -289,12 +262,254 @@ pub async fn run() -> anyhow::Result<()> { .await?) } +/// Initialize and start the DIPS gRPC server. Errors return to the caller, +/// which runs the service without DIPs rather than letting an optional +/// subsystem take down query serving. +async fn start_dips( + dips: &DipsConfig, + blockchain_chain_id: u64, + indexing_payments_subgraph: &'static SubgraphClient, + ipfs_url: &Url, + database: sqlx::PgPool, + indexer_address: Address, + shutdown_token: &CancellationToken, +) -> anyhow::Result<()> { + let DipsConfig { + host, + port, + supported_networks, + min_grt_per_30_days, + min_grt_per_billion_entities_per_30_days, + additional_networks, + recurring_collector, + rpc_url, + role_refresh_interval_secs, + role_failopen_grace_secs, + role_subgraph_max_lag_secs, + max_new_agreements_per_24h, + } = dips; + + // The RecurringCollector address is the EIP-712 verifying contract used to + // recover RCA signers; without it DIPs cannot authenticate proposals. + let recurring_collector = (*recurring_collector).ok_or_else(|| { + anyhow::anyhow!("dips.recurring_collector must be set when DIPs is enabled") + })?; + + // Prefer the domain the deployed contract reports (EIP-5267) so it tracks + // upgrades; without an RPC endpoint, fall back to built-in constants. + let rca_domain = match rpc_url { + Some(url) => { + indexer_dips::fetch_rca_eip712_domain( + url.as_str(), + recurring_collector, + blockchain_chain_id, + ) + .await? + } + None => { + tracing::info!("dips.rpc_url not set; using built-in RCA EIP-712 domain constants"); + indexer_dips::rca_eip712_domain(blockchain_chain_id, recurring_collector) + } + }; + + if supported_networks.is_empty() { + tracing::warn!( + "DIPS enabled but no networks in dips.supported_networks. \ + All proposals will be rejected." + ); + } + + tracing::info!( + supported_networks = ?supported_networks, + ipfs_url = %ipfs_url, + max_new_agreements_per_24h = ?max_new_agreements_per_24h, + "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}'"))?; + + // Warn rather than fail: a loopback bind is a legitimate choice when a reverse proxy + // forwards the port to it, and the service cannot tell whether one is in front of it. + if addr.ip().is_loopback() { + tracing::warn!( + address = %addr, + "DIPs gRPC server is bound to a loopback address, which the payer cannot reach \ + directly. No agreement proposals will arrive unless a proxy forwards this port \ + to it, and the failure is silent on this side." + ); + } + + // Shared counter of in-flight gRPC requests. The IPFS client reads + // it to decide whether to use the full retry budget or fall back to + // a single attempt when the service is under load. + let inflight = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + // Initialize validation dependencies + let ipfs_fetcher = Arc::new(IpfsClient::new(ipfs_url.as_str(), inflight.clone())?); + let registry = Arc::new( + NetworksRegistry::from_latest_version() + .await + .context("Failed to fetch NetworksRegistry for DIPS")?, + ); + + // Convert GRT/30days to wei/second for protocol compatibility. + // Use ceiling division to protect indexers: configured minimums round UP, + // ensuring indexers never accept less than their stated minimum. + const SECONDS_PER_30_DAYS: u128 = 30 * 24 * 60 * 60; + let tokens_per_second = min_grt_per_30_days + .iter() + .map(|(network, grt)| { + let wei_per_second = grt.wei().div_ceil(SECONDS_PER_30_DAYS); + (network.clone(), U256::from(wei_per_second)) + }) + .collect(); + + // Entity pricing: config is per-billion-entities, convert to per-entity. + // Ceiling division protects indexer from precision loss. + let entity_divisor = SECONDS_PER_30_DAYS * 1_000_000_000; + let tokens_per_entity_per_second = U256::from( + min_grt_per_billion_entities_per_30_days + .wei() + .div_ceil(entity_divisor), + ); + + // Authorise proposal senders against the on-chain agreement-manager role set, + // read from the indexing-payments subgraph. + let trusted_signers: Arc = SubgraphTrustedSigners::spawn( + indexing_payments_subgraph, + Duration::from_secs(*role_refresh_interval_secs), + Duration::from_secs(*role_failopen_grace_secs), + Duration::from_secs(role_subgraph_max_lag_secs.get()), + ) + .await; + + // Build server context + let ctx = Arc::new(DipsServerContext { + rca_store: Arc::new(PsqlRcaStore { pool: database }), + ipfs_fetcher, + price_calculator: Arc::new(PriceCalculator::new( + supported_networks.clone(), + tokens_per_second, + tokens_per_entity_per_second, + )), + registry, + additional_networks: Arc::new(additional_networks.clone()), + rca_domain, + trusted_signers, + max_new_agreements_per_24h: *max_new_agreements_per_24h, + }); + + // Create DIPS server + let server = DipsServer { + ctx, + expected_payee: indexer_address, + inflight, + }; + + info!( + address = %addr, + "Starting DIPS gRPC server (RecurringCollectionAgreement validation)" + ); + + let dips_shutdown_token = shutdown_token.clone(); + tokio::spawn(async move { + start_dips_server(addr, server, dips_shutdown_token.cancelled()).await; + }); + Ok(()) +} + +/// Per-request timeout across the whole gRPC handler. Long enough to cover +/// the worst-case IPFS retry budget (190s) with headroom; short enough that +/// a hung handler doesn't pin a worker indefinitely. +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 @@ -372,57 +587,109 @@ mod tests { #[test] fn test_format_grt_zero() { - assert_eq!(format_grt(0u128), "0"); + // Arrange + let wei = 0u128; + + // Act + let result = format_grt(wei); + + // Assert + assert_eq!(result, "0"); } #[test] fn test_format_grt_whole_number() { - // 1 GRT = 10^18 wei - assert_eq!(format_grt(1_000_000_000_000_000_000u128), "1"); + // 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() { - // 1000 GRT - assert_eq!(format_grt(1_000_000_000_000_000_000_000u128), "1000"); + // 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() { - // 0.5 GRT = 5 * 10^17 wei - assert_eq!(format_grt(500_000_000_000_000_000u128), "0.5"); + // 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() { - // 1 wei = 0.000000000000000001 GRT - assert_eq!(format_grt(1u128), "0.000000000000000001"); + // 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() { - // 1.5 GRT - assert_eq!(format_grt(1_500_000_000_000_000_000u128), "1.5"); + // 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() { - // 1.100 GRT should become "1.1" - assert_eq!(format_grt(1_100_000_000_000_000_000u128), "1.1"); + // 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() { - // 0.123456789012345678 GRT - assert_eq!( - format_grt(123_456_789_012_345_678u128), - "0.123456789012345678" - ); + // 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() { - // 12345.6789 GRT - assert_eq!(format_grt(12_345_678_900_000_000_000_000u128), "12345.6789"); + // 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 fbadaae5c..a4edb564e 100644 --- a/crates/service/src/service/router.rs +++ b/crates/service/src/service/router.rs @@ -51,7 +51,7 @@ use crate::{ }, routes::{ self, dips_info, health, healthz, request_handler, static_subgraph_request_handler, - DipsInfo, HealthzState, + DipsInfoState, HealthzState, }, tap::{IndexerTapContext, TapChecksConfig}, wallet::public_key, @@ -85,10 +85,9 @@ pub struct ServiceRouter { network_subgraph: Option<(&'static SubgraphClient, NetworkSubgraphConfig)>, allocations: Option, dispute_manager: Option, - // Optional state for the public `/dips/info` endpoint. Populated only when - // `[dips]` is set in config; consumed purely for signalling indexer pricing - // and supported networks, never feeds the gRPC proposal handler. - dips_info: Option, + + // optional DIPS info for /dips/info endpoint + dips_info: Option, } impl ServiceRouter { @@ -132,7 +131,7 @@ impl ServiceRouter { indexer_address, network.config.syncing_interval_secs, network.recently_closed_allocation_buffer_secs, - network.max_data_staleness_mins, + network.max_data_staleness_mins.get(), ) .await .expect("Failed to initialize indexer_allocations watcher"), diff --git a/crates/service/tests/router_test.rs b/crates/service/tests/router_test.rs index 3f8d8c924..3f063d84d 100644 --- a/crates/service/tests/router_test.rs +++ b/crates/service/tests/router_test.rs @@ -1,7 +1,7 @@ // Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. // SPDX-License-Identifier: Apache-2.0 -use std::{net::SocketAddr, time::Duration}; +use std::{net::SocketAddr, num::NonZeroU64, time::Duration}; use axum::{body::to_bytes, extract::ConnectInfo, http::Request, Extension}; use axum_extra::headers::Header; @@ -86,9 +86,11 @@ fn build_service_router(inputs: RouterInputs) -> ServiceRouter { NetworkSubgraphConfig { config: inputs.network_subgraph_config, recently_closed_allocation_buffer_secs: Duration::from_secs(0), - max_data_staleness_mins: 0, + // Effectively unbounded: this routing test uses a placeholder block + // timestamp and does not exercise staleness checking. + max_data_staleness_mins: NonZeroU64::new(1_000_000_000).unwrap(), escrow_min_balance_grt_wei: "100000000000000000".to_string(), - max_signers_per_payer: 0, + max_signers_per_payer: None, }, ) .escrow_accounts_v2(inputs.escrow_accounts_v2) @@ -126,7 +128,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 +138,8 @@ async fn full_integration_test() { { "data": { "transactions": [], - "paymentsEscrowTransactions": [] + "meta": { "block": { "number": 1, "hash": "0x00", "timestamp": 1 } }, + "allocations": [] } } "#, diff --git a/crates/tap-agent/src/agent.rs b/crates/tap-agent/src/agent.rs index 736a58494..7ccc6cff4 100644 --- a/crates/tap-agent/src/agent.rs +++ b/crates/tap-agent/src/agent.rs @@ -104,7 +104,8 @@ pub async fn start_agent( ref escrow_min_balance_grt_wei, max_signers_per_payer, }, - escrow: _, // Deprecated and ignored + indexing_payments: _, // DIPs only; not used by tap-agent + escrow: _, // Deprecated and ignored }, tap: TapConfig { sender_aggregator_endpoints, @@ -139,7 +140,7 @@ pub async fn start_agent( *indexer_address, *network_sync_interval, *recently_closed_allocation_buffer, - *max_data_staleness_mins, + max_data_staleness_mins.get(), ) .await .with_context(|| "Failed to initialize indexer_allocations watcher")?; 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/docs/Metrics.md b/docs/Metrics.md index b9aa775f5..750458194 100644 --- a/docs/Metrics.md +++ b/docs/Metrics.md @@ -33,6 +33,23 @@ | `indexer_cost_model_batch_failed_total` | Total number of failed batch `cost_model` queries. | - | | `indexer_cost_model_batch_invalid_total` | Total number of batch `cost_model` queries with invalid deployment IDs. | - | +### DIPs + +Only 1 of these 4 carries the `indexer_` prefix, so a dashboard or alert selector has to cover both spellings. + +| Metric Name | Description | Labels | +|------------------------------------------------------|---------------------------------------------------------------------------------------------|-----------------| +| `indexer_dips_enabled` | The outcome of DIPs startup: 1 when initialization succeeded and the gRPC server was started, 0 when it failed. | - | +| `dips_proposal_outcomes_total` | Incoming DIPs agreement proposals by outcome. | outcome | +| `dips_agreement_manager_role_holders` | Agreement-manager role holders in the last successful subgraph fetch. | - | +| `dips_agreement_manager_role_last_refresh_timestamp` | Unix time of the last successful agreement-manager role fetch. | - | + +`indexer_dips_enabled` reports how startup went, not whether DIPs is still running. It is set once, to 1 when initialization succeeded and the gRPC server was started or to 0 when DIPs is configured but initialization failed, and never updated afterwards. A listener that later fails to bind or exits therefore leaves the gauge sitting at 1, and the `DIPs gRPC server error` log line is what catches that. When `[dips]` is absent the metric is not exported at all. + +The `outcome` label on `dips_proposal_outcomes_total` takes 4 values: `accepted`; `untrusted`, meaning the signer does not hold the agreement-manager role; `transient`, meaning the role list could not be read or the database write failed, so the sender should retry; and `rejected` for any other validation failure. Counting starts after the gRPC argument checks, so a proposal with the wrong envelope version, an empty payload or a payload over 10,000 bytes never appears here. + +Both role metrics are worth alerting on. `dips_agreement_manager_role_holders == 0` means no address is authorised to send you proposals, so every proposal is rejected. For the refresh timestamp, alert when `time() - dips_agreement_manager_role_last_refresh_timestamp` exceeds `role_refresh_interval_secs` (default 86,400) plus `role_failopen_grace_secs` (default 3,600), the point past which the cached role list has expired and all proposals are rejected. Neither metric appears until the first fetch has succeeded, so pair that expression with `absent(dips_agreement_manager_role_last_refresh_timestamp)` to catch a fetch that has never succeeded. + --- ## Tap Agent Metrics diff --git a/docs/Routes.md b/docs/Routes.md index 7d377a592..20886285d 100644 --- a/docs/Routes.md +++ b/docs/Routes.md @@ -2,6 +2,10 @@ This section lists the routes currently exposed by the Subgraph Service. Each route includes a brief description of its purpose and any requirements (e.g., tokens) for access. +Two listeners sit outside the HTTP routes below. Prometheus metrics are served on the `[metrics]` port (default 7300) at `/metrics`. When the `[dips]` section is configured, the service also starts a separate gRPC server on its own host and port (default `0.0.0.0:7601`), which is where the dipper, the service that offers indexing agreements, sends its proposals. That gRPC server only starts if DIPs initialisation succeeds; if it fails, the service carries on serving queries without DIPs. + +`/dips/info` is how you tell which of those happened. It answers 200 with the pricing body when DIPs started, 503 with `{"status": "unavailable"}` when `[dips]` is configured but initialisation failed, and is not registered at all when `[dips]` is absent. + ## Public Routes | Route | Description | @@ -10,26 +14,25 @@ This section lists the routes currently exposed by the Subgraph Service. Each ro | `/info` | Displays the operator's public address. | | `/healthz` | Reports service dependency health (database and graph-node). | | `/version` | Provides the current version of `indexer-service-rs` and its dependencies. | +| `/dips/info` | Reports the configured DIPs prices. See the note above on its 3 responses. | ## Token-Protected Routes | Route | Description | |-------------------------|----------------------------------------------------------------------------------------------| -| `/escrow` | Routes queries to the escrow subgraph. Requires a valid token. | | `/network` | Routes queries to the network subgraph. Requires a valid token. | ## GraphQL API Routes | Route | Description | |-------------------------|----------------------------------------------------------------------------------------------| -| `/dips` | Provides access to the Dips GraphQL API. | | `/cost` | Provides access to the Cost Model GraphQL API. | ## Subgraph Routes | Route | Description | |--------------------------------------|----------------------------------------------------------------------------------------------| -| `/subgraph/health/{id}` | Retrieves the health state of a specified subgraph using its ID. | +| `/subgraph/health/{deployment_id}` | Retrieves the health state of a specified subgraph using its ID. | | `/subgraphs/id/{id}` | Routes a query to a specific subgraph using its ID. Requires a receipt or valid token. | ## Node Status Route @@ -43,4 +46,4 @@ This section lists the routes currently exposed by the Subgraph Service. Each ro ## Note You can always view the latest complete and up-to-date list of routes in the source code: -[Service Router Implementation](./crates/service/src/service/router.rs) +[Service Router Implementation](../crates/service/src/service/router.rs) 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); diff --git a/migrations/20260613000000_dips_status_check.down.sql b/migrations/20260613000000_dips_status_check.down.sql new file mode 100644 index 000000000..dda7bdc4f --- /dev/null +++ b/migrations/20260613000000_dips_status_check.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE pending_rca_proposals + DROP CONSTRAINT IF EXISTS pending_rca_proposals_status_check; diff --git a/migrations/20260613000000_dips_status_check.up.sql b/migrations/20260613000000_dips_status_check.up.sql new file mode 100644 index 000000000..bd2446be7 --- /dev/null +++ b/migrations/20260613000000_dips_status_check.up.sql @@ -0,0 +1,7 @@ +-- Constrain pending_rca_proposals.status to the vocabulary shared with the +-- indexer-agent (it writes 'accepted'/'completed'/'rejected'); a value outside +-- this set means the two services have drifted and should fail at write time. +-- Keep this set in sync with the STATUS_* constants in crates/dips/src/store.rs. +ALTER TABLE pending_rca_proposals + ADD CONSTRAINT pending_rca_proposals_status_check + CHECK (status IN ('pending', 'accepted', 'completed', 'rejected'));