Dispatch

Community project — not affiliated with or endorsed by The Graph Foundation or Edge & Node. This is an independent exploration of what a JSON-RPC data service on Horizon might look like.

Dispatch is a decentralised JSON-RPC service built on The Graph Protocol's Horizon framework. Indexers stake GRT, register to serve specific chains, and get paid per request via GraphTally (TAP v2) micropayments.

Inspired by the Q3 2026 "Experimental JSON-RPC Data Service" direction in The Graph's 2026 Technical Roadmap — but this codebase is an independent community effort, not an official implementation.


What it does

An application calls eth_getBalance. Instead of hitting a centralised RPC provider, the request routes to a staked indexer in the Dispatch network. The indexer persists a TAP receipt and returns the data. GRT flows on-chain automatically via GraphTally.

That's the loop.


Network status

ComponentStatus
RPCDataService contract✅ Live on Arbitrum One
Subgraph✅ Live on The Graph Studio
npm packages✅ Published (@lodestar-dispatch/consumer-sdk, @lodestar-dispatch/indexer-agent)
Active providers1https://rpc.cargopete.com (Arbitrum One, Standard + Archive)
Receipt signing & validation✅ Working — every request carries a signed EIP-712 TAP receipt
Response attestation✅ Working — provider signs every response; gateway verifies before forwarding
Quorum consensus✅ Working — deterministic methods cross-checked across 3 providers
Receipt persistence✅ Working — stored in tap_receipts table
RAV aggregation (off-chain)✅ Working — gateway batches receipts into signed RAVs every 60s
On-chain collect()✅ Working — GRT settles on-chain automatically every hour
Provider on-chain registration✅ Confirmed on-chain
Multi-provider discovery✅ Working — subgraph-driven dynamic registry
Local demo✅ Working — full payment loop on Anvil

The full payment loop is working end-to-end. Requests generate TAP receipts, the service aggregates them into RAVs every 60s, and calls RPCDataService.collect() every hour — pulling GRT from the consumer's escrow to the provider automatically.


Relation to The Graph

Dispatch reuses most of the Horizon stack rather than reinventing it:

ComponentStatus
HorizonStaking / GraphPayments / PaymentsEscrow✅ Reused as-is
GraphTallyCollector (TAP v2)✅ Reused as-is
indexer-tap-agent❌ Not used — TAP aggregation and on-chain collection are built into dispatch-service
indexer-service-rs TAP middleware✅ Logic ported to dispatch-service
Graph Node❌ Not needed — standard Ethereum clients only
POI / SubgraphService dispute system❌ Not applicable — JSON-RPC responses have no on-chain verifier

Architecture

Dispatch has two deployment paths: a managed gateway (centralised routing, good for apps) and a direct consumer SDK (trustless, peer-to-peer).

Consumer (dApp)
   │
   ├── via consumer-sdk (trustless, direct)
   │     signs receipts locally, discovers providers via subgraph
   │
   └── via dispatch-gateway (managed)
         QoS-scored selection, TAP receipt signing
   │
   │  POST /rpc/{chain_id}
   │  TAP-Receipt: { signed EIP-712 receipt }
   ▼
dispatch-service          ← JSON-RPC proxy, TAP receipt validation,
   │                         receipt persistence
   ▼
Ethereum client           ← Geth / Erigon / Reth / Nethermind
(full or archive)

Payment flow

Receipts accumulate off-chain and settle on-chain in batches via GraphTally (TAP v2):

receipts (per request)
  → dispatch-service aggregates into RAV (every 60s)
  → RPCDataService.collect() (every hour)
  → GraphTallyCollector
  → PaymentsEscrow
  → GraphPayments
  → GRT to indexer (via paymentsDestination)

valueAggregate in a RAV is cumulative and never resets. The collector tracks previously collected amounts to calculate deltas on each collect() call.


Workspace layout

crates/
├── dispatch-tap/      EIP-712 types, receipt signing (shared by service + gateway)
├── dispatch-service/  Indexer-side JSON-RPC proxy with TAP middleware
├── dispatch-gateway/  Gateway: QoS scoring, provider selection, receipt issuance
└── dispatch-smoke/    End-to-end smoke test against a live provider

contracts/
├── src/RPCDataService.sol   IDataService implementation (Horizon)
└── src/interfaces/

consumer-sdk/   TypeScript SDK — direct provider access without a gateway
indexer-agent/  TypeScript agent — automates provider lifecycle on-chain
subgraph/       The Graph subgraph — indexes RPCDataService events
docker/         Docker Compose full-stack deployment
demo/           Self-contained local demo: full payment loop on Anvil

Horizon integration

Dispatch is a data service in the Horizon framework. Three Horizon layers are in play:

HorizonStaking — indexers call provision(serviceProvider, RPCDataService, tokens, maxVerifierCut, thawingPeriod). Minimum 10,000 GRT, 14-day thawing period.

GraphPayments + PaymentsEscrow — consumers deposit GRT into escrow keyed by (sender, serviceProvider). Every request carries a TAP receipt; the TAP agent batches these into RAVs redeemed via collect().

DataService frameworkRPCDataService inherits DataService + DataServiceFees + DataServicePausable. The framework handles stake claim linked lists, fee locking at the configured ratio, and pause logic.

Getting Started

Hit the live network

The gateway is live at https://gateway.lodestar-dashboard.com. Every request must include your Ethereum address in the X-Consumer-Address header — the gateway uses it to charge GRT from your escrow on-chain.

curl -s -X POST https://gateway.lodestar-dashboard.com/rpc/42161 \
  -H "Content-Type: application/json" \
  -H "X-Consumer-Address: 0xYOUR_ADDRESS" \
  -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'

No X-Consumer-Address header → 402 Payment Required. No funded escrow → the provider rejects the request.

Fund your escrow at lodestar-dashboard.com/dispatch before making requests. See Payments for the full escrow setup.

Every response carries an x-drpc-attestation header — an ECDSA signature from the provider over keccak256(chainId || method || params || response || blockHash). You can verify this with the consumer SDK (see Using the Network).


Smoke test a live provider

Fires real TAP-signed JSON-RPC requests directly at the provider endpoint (bypasses the gateway). Requires a key that is in the provider's authorized_senders list.

DISPATCH_ENDPOINT=https://rpc.cargopete.com \
DISPATCH_SIGNER_KEY=<authorized-signer-key> \
DISPATCH_PROVIDER_ADDRESS=0xb43B2CCCceadA5292732a8C58ae134AdEFcE09Bb \
cargo run --bin dispatch-smoke

Expected output:

dispatch-smoke
  endpoint   : http://167.235.29.213:7700
  chain_id   : 42161
  data_svc   : 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078
  signer     : 0x7D14ae5f20cc2f6421317386Aa8E79e8728353d9

  [PASS] GET /health → 200 OK
  [PASS] eth_blockNumber — returns current block → "0x1b1623cf" [95ms]
  [PASS] eth_chainId — returns 0x61a9 (42161) → "0xa4b1" [58ms]
  [PASS] eth_getBalance — returns balance at latest block (Standard) → "0x6f3a59e597c5342" [74ms]
  [PASS] eth_getBalance — historical block (Archive) → "0x0" [629ms]
  [PASS] eth_getLogs — recent block range (Tier 2 quorum) → [{"address":"0xa62d...] [61ms]

  5 passed, 0 failed

DISPATCH_SIGNER_KEY must be the private key of an address in the provider's authorized_senders list. DISPATCH_PROVIDER_ADDRESS must match the provider's registered address exactly — it is embedded in the TAP receipt and validated on-chain.


Run the drop-in proxy

Point MetaMask, Viem, or any Ethereum library at the Dispatch network without changing application code. The proxy runs locally and handles everything — key management, provider discovery, receipt signing.

cd proxy
npm install
npm start

On first run it generates a consumer keypair, prints your consumer address, and links to the funding dashboard. Fund the escrow at lodestar-dashboard.com/dispatch, then add http://localhost:8545 to MetaMask as a custom network.

See Using the Network → dispatch-proxy for full configuration options.


Run the local demo

Runs a complete local stack on Anvil — Horizon contracts, dispatch-service, dispatch-gateway — makes 5 RPC requests, submits a RAV, and proves GRT lands in the payment wallet. The full loop in one command.

Requires: Foundry and Rust stable.

cd demo
npm install
npm start

Build from source

cargo build
cargo test

Docker Compose

The quickest path to a full running stack:

cp docker/config.example.toml  docker/config.toml
cp docker/gateway.example.toml docker/gateway.toml

# Fill in private keys, provider addresses, and backend RPC URLs
docker compose up

The default stack starts dispatch-service, dispatch-gateway, and PostgreSQL.

Running a Provider

This guide walks through everything needed to join the Dispatch network as a provider — from staking GRT to receiving your first payment. By the end you will have dispatch-service running, registered on-chain, and serving live traffic.


What you need

RequirementDetails
GRT≥ 10,000 GRT on Arbitrum One for the provision
ETH on ArbitrumSmall amount for gas (~0.005 ETH is plenty)
Ethereum node(s)Full or archive node for each chain you want to serve
ServerLinux VPS with 2+ vCPUs, ≥ 4 GB RAM, SSD
PostgreSQLFor TAP receipt + RAV persistence (Docker Compose sets this up automatically)
Public HTTPS endpointConsumers and the gateway need to reach your dispatch-service

1. Keys

You need two separate keys:

Provider key — your on-chain identity. This is the address that holds the GRT provision in HorizonStaking and appears on-chain as serviceProvider. You call staking transactions with this key, but it does not need to be on the server.

Operator key — a hot key on the server. dispatch-service uses this key to sign response attestations and on-chain collect() transactions. It must be authorised in HorizonStaking to act on behalf of the provider address.

If you want to keep things simple, you can use the same key for both — isAuthorized always returns true when msg.sender == serviceProvider. For better security use separate keys.

Generate a fresh operator key if you don't have one:

cast wallet new

Note the address — you will need it in step 2 and again in the service config.


2. Stake on Horizon

All staking happens on Arbitrum One via the HorizonStaking contract at 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03.

2a. Stake GRT

If your GRT is in a wallet (not yet staked), approve and stake it:

# Approve HorizonStaking to spend your GRT
cast send 0x9623063377AD1B27544C965cCd7342f7EA7e88C7 \
  "approve(address,uint256)" \
  0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 \
  10000000000000000000000 \
  --private-key $PROVIDER_KEY \
  --rpc-url https://arb1.arbitrum.io/rpc

# Stake to your provider address
cast send 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 \
  "stakeTo(address,uint256)" \
  $PROVIDER_ADDRESS \
  10000000000000000000000 \
  --private-key $PROVIDER_KEY \
  --rpc-url https://arb1.arbitrum.io/rpc

Replace 10000000000000000000000 with the amount in wei (1e18 per GRT). The minimum required by RPCDataService is 10,000 GRT (10000000000000000000000).

2b. Create a provision

A provision locks a portion of your staked GRT specifically for RPCDataService. This is what the contract checks when you register.

cast send 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 \
  "provision(address,address,uint256,uint32,uint64)" \
  $PROVIDER_ADDRESS \
  0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 \
  10000000000000000000000 \
  1000000 \
  1209600 \
  --private-key $PROVIDER_KEY \
  --rpc-url https://arb1.arbitrum.io/rpc

Arguments:

  • serviceProvider — your provider address
  • dataService0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 (RPCDataService)
  • tokens — amount in wei, minimum 10000000000000000000000 (10,000 GRT)
  • maxVerifierCut1000000 (100% in PPM — the contract cannot slash, so this doesn't matter in practice)
  • thawingPeriod1209600 (14 days in seconds — the contract minimum)

2c. Authorise your operator key

If your provider key and operator key are different, authorise the operator:

cast send 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 \
  "setOperator(address,address,bool)" \
  0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 \
  $OPERATOR_ADDRESS \
  true \
  --private-key $PROVIDER_KEY \
  --rpc-url https://arb1.arbitrum.io/rpc

Arguments:

  • dataService0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 (RPCDataService)
  • operator — your operator address (derived from the hot key on your server)
  • allowedtrue

If you use the same key for both provider and operator, skip this step.

Verify the provision

cast call 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 \
  "getProvision(address,address)(uint256,uint256,uint256,uint32,uint64,uint64,uint32,uint64,uint256,uint32)" \
  $PROVIDER_ADDRESS \
  0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 \
  --rpc-url https://arb1.arbitrum.io/rpc

The first number is tokens. It should be ≥ 10000000000000000000000.


3. Configure dispatch-service

Clone the repo and copy the example config:

git clone https://github.com/cargopete/dispatch.git
cd dispatch
cp docker/config.example.toml docker/config.toml

Edit docker/config.toml:

[server]
host = "0.0.0.0"
port = 7700

[indexer]
# Your on-chain provider address (the one holding the GRT provision).
service_provider_address = "0xYOUR_PROVIDER_ADDRESS"

# 32-byte hex ECDSA private key of your OPERATOR key.
# This key signs response attestations and on-chain collect() transactions.
# Use a dedicated hot key — NOT your wallet or staking key.
operator_private_key = "0xYOUR_OPERATOR_PRIVATE_KEY"

[tap]
# RPCDataService contract address — do not change this.
data_service_address      = "0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078"

# Address(es) of gateway signers that are allowed to send you TAP receipts.
# This is the Ethereum address derived from the gateway's signer_private_key.
# Leave empty ([]) to accept receipts from any sender (less secure but simpler
# when starting out — tighten this once you know your gateway's signer address).
authorized_senders        = []

# EIP-712 domain — must match the deployed GraphTallyCollector. Do not change.
eip712_domain_name        = "GraphTallyCollector"
eip712_chain_id           = 42161
eip712_verifying_contract = "0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e"

# Internal URL of your dispatch-gateway (if you're running one).
# The service posts receipts here for RAV aggregation every 60s.
aggregator_url            = "http://dispatch-gateway:8080"

[chains]
# Chain IDs you want to serve — must have a backend URL for each.
supported = [42161]

[chains.backends]
# Internal RPC URL of your Ethereum node for each chain.
"42161" = "http://your-arbitrum-node:8545"
# "1"   = "http://your-eth-node:8545"

[database]
url = "postgres://dispatch:dispatch@postgres:5432/dispatch"

[collector]
# Arbitrum One RPC for sending the on-chain collect() transaction.
arbitrum_rpc_url      = "https://arb1.arbitrum.io/rpc"
collect_interval_secs = 3600   # collect GRT every hour

Key settings explained

service_provider_address — your on-chain provider address. This is the address with the GRT provision, registered in RPCDataService. It does not need to hold any ETH or signing keys on the server.

operator_private_key — the hot key on this server. Its address must be authorised as an operator in HorizonStaking (step 2c). It signs TAP response attestations and broadcasts on-chain collect() transactions, so it needs a small amount of ETH on Arbitrum One for gas.

authorized_senders — list of gateway signer addresses allowed to send TAP receipts to this service. A gateway's signer address is derived from the signer_private_key in its gateway.toml. If you're using the public gateway, add its signer address here. Leave empty during initial setup to accept all senders.

aggregator_url — the internal URL of your dispatch-gateway. The service POSTs raw receipts here every 60 seconds; the gateway aggregates them into signed RAVs and returns them. If you're running your own gateway in Docker Compose, this is http://dispatch-gateway:8080.

[collector] — when present, dispatch-service automatically calls RPCDataService.collect() on a timer, pulling GRT from the consumer's escrow to your paymentsDestination. If you omit this section, collection does not happen and receipts accumulate without being redeemed.


4. Run with Docker Compose

Docker Compose is the recommended deployment. It runs dispatch-service, dispatch-gateway, and PostgreSQL together with health checks and automatic restarts.

# Copy and fill in both config files
cp docker/config.example.toml  docker/config.toml   # indexer service
cp docker/gateway.example.toml docker/gateway.toml   # gateway (optional)

# Start everything
docker compose -f docker/docker-compose.yml up -d dispatch-service dispatch-gateway postgres

Check that all three containers are healthy:

docker ps

You should see (healthy) next to dispatch-service, dispatch-gateway, and postgres.

Check the service logs:

docker logs docker-dispatch-service-1 --tail 30

Expected output on startup:

INFO dispatch_service::db: database migrations applied
INFO dispatch_service::tap_aggregator: RAV aggregator started url=http://dispatch-gateway:8080 interval_secs=60
INFO dispatch_service::collector: on-chain RAV collector started interval_secs=3600
INFO dispatch_service::server: dispatch-service starting addr=0.0.0.0:7700

5. Register on-chain

Once the service is running, register your provider in RPCDataService and activate each chain you want to serve. The indexer agent handles this automatically.

Using the npm package

npm install @lodestar-dispatch/indexer-agent

Create agent.config.json:

{
  "arbitrumRpcUrl": "https://arb1.arbitrum.io/rpc",
  "rpcDataServiceAddress": "0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078",
  "operatorPrivateKey": "0xYOUR_OPERATOR_PRIVATE_KEY",
  "providerAddress": "0xYOUR_PROVIDER_ADDRESS",
  "endpoint": "https://rpc.your-domain.com",
  "geoHash": "u1hx",
  "paymentsDestination": "0xYOUR_PAYMENT_WALLET",
  "services": [
    { "chainId": 42161, "tier": 0 },
    { "chainId": 42161, "tier": 1 }
  ]
}

Run it:

AGENT_CONFIG=./agent.config.json npx tsx src/index.ts

The agent calls register(), startService() for each entry in services, and stopService() / deregister() on SIGTERM. It reconciles on-chain state against the config on every run — safe to run on a cron or as a persistent daemon.

Config fields

FieldDescription
operatorPrivateKeyHot key on your server — must be authorised as operator in HorizonStaking
providerAddressYour on-chain provider address (holds the GRT provision)
endpointPublic HTTPS base URL of your dispatch-service, reachable by gateways and consumers
geoHashGeohash of your server location — used for geographic routing. 4 characters is sufficient (e.g. u1hx for Amsterdam, dr4g for New York)
paymentsDestinationAddress that receives collected GRT. If omitted, defaults to providerAddress. Use a cold wallet here
servicesList of { chainId, tier } pairs — see Capability tiers below

Verify registration

cast call 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 \
  "isRegistered(address)(bool)" \
  $PROVIDER_ADDRESS \
  --rpc-url https://arb1.arbitrum.io/rpc

Should return true.

cast call 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 \
  "getChainRegistrations(address)" \
  $PROVIDER_ADDRESS \
  --rpc-url https://arb1.arbitrum.io/rpc

Should show your registered (chainId, tier) pairs with active = true.


6. Expose your endpoint

Your dispatch-service must be reachable at a public HTTPS URL. Port 7700 by default — put it behind nginx or Caddy with a TLS cert.

Minimal nginx config:

server {
    server_name rpc.your-domain.com;

    location / {
        proxy_pass         http://127.0.0.1:7700;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;

        # WebSocket support
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 3600s;
    }

    listen 443 ssl;
    ssl_certificate     /etc/letsencrypt/live/rpc.your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rpc.your-domain.com/privkey.pem;
}

Test it:

curl -s https://rpc.your-domain.com/health

Should return {"status":"ok"}.


7. Verify the payment loop

Make a test request through your service (with a valid TAP receipt) and confirm the full loop works. The easiest way is the smoke test binary:

DISPATCH_ENDPOINT=https://rpc.your-domain.com \
DISPATCH_SIGNER_KEY=<any-key-in-authorized_senders-or-any-key-if-empty> \
DISPATCH_PROVIDER_ADDRESS=$PROVIDER_ADDRESS \
cargo run --bin dispatch-smoke

All 5 checks should pass. After 60 seconds, check service logs for:

INFO dispatch_service::tap_aggregator: RAV aggregated collection_id=... value=...

After an hour (or force a collect manually):

INFO dispatch_service::collector: collect() success tx=0x...

GRT lands in your paymentsDestination wallet.


Capability tiers

Not all Ethereum nodes can answer all requests. A standard full node only keeps recent state (~128 blocks) — ask it for a balance at block 1,000,000 and it fails. A node without debug APIs enabled can't serve debug_traceTransaction. If a gateway routed those requests blindly it would just get errors.

Capability tiers are how the network avoids that. Each tier describes a distinct infrastructure capability. You declare which tiers your node supports at registration time, and the gateway only routes requests to providers that can actually answer them.

TierValueWhat it servesNode requirement
Standard0All standard JSON-RPC methods, recent ~128 blocksAny full node
Archive1Historical state at any block numberArchive node (~10–20× more disk)
Debug/Trace2debug_* and trace_* methodsFull/archive node with debug APIs enabled (--http.api=debug,trace)
WebSocket3eth_subscribe, real-time event streamsFull node with a WebSocket endpoint

One registration per (chain, tier) pair

Registration is granular. You call startService(chainId, tier, endpoint) once for each capability you want to advertise — each call is a separate on-chain record. This means you can mix and match freely:

  • Archive on Ethereum mainnet, Standard only on Arbitrum
  • Debug on one chain, nothing on another
  • WebSocket on all chains, Archive on none

The services array in your indexer agent config maps directly to these calls:

"services": [
  { "chainId": 1,     "tier": 0 },
  { "chainId": 1,     "tier": 1 },
  { "chainId": 42161, "tier": 0 }
]

This registers Standard and Archive on Ethereum mainnet, and Standard only on Arbitrum One. Three startService calls, three on-chain records.

Stake is shared

Your staked GRT covers all tiers and all chains — there is no per-tier or per-chain stake splitting. The full provision applies regardless of how many (chain, tier) pairs you register for.

Start with what your node supports

If you're running a standard full node, register tier 0 only. If it's an archive node, add tier 1. Only enable tier 2 if you've explicitly enabled debug/trace APIs on your node — requests routed to you will fail otherwise and hurt your QoS score.


Supported chains

ChainChain ID
Ethereum1
Arbitrum One42161
Optimism10
Base8453
Polygon137
BNB Chain56
Avalanche C-Chain43114
zkSync Era324
Linea59144
Scroll534352

Chains are governance-controlled. New chains are added via RPCDataService.addChain().


Managing your provision

Add more stake to your provision (if you want to serve more chains or increase your safety margin):

cast send 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 \
  "addToProvision(address,address,uint256)" \
  $PROVIDER_ADDRESS \
  0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 \
  5000000000000000000000 \
  --private-key $PROVIDER_KEY \
  --rpc-url https://arb1.arbitrum.io/rpc

Start thawing (to eventually remove GRT from the provision):

cast send 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 \
  "thaw(address,address,uint256)" \
  $PROVIDER_ADDRESS \
  0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 \
  10000000000000000000000 \
  --private-key $PROVIDER_KEY \
  --rpc-url https://arb1.arbitrum.io/rpc

After the 14-day thawing period, call deprovision to release the tokens back to idle stake, then unstake to return them to your wallet.

Update your payments destination (without re-registering):

cast send 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 \
  "setPaymentsDestination(address)" \
  $NEW_WALLET \
  --private-key $OPERATOR_KEY \
  --rpc-url https://arb1.arbitrum.io/rpc

Stop serving a chain (without deregistering):

Send stopService via the indexer agent by removing the entry from services in agent.config.json and re-running. Or call directly:

cast send 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 \
  "stopService(address,bytes)" \
  $PROVIDER_ADDRESS \
  $(cast abi-encode "f(uint64,uint8)" 42161 0) \
  --private-key $OPERATOR_KEY \
  --rpc-url https://arb1.arbitrum.io/rpc

Deployed addresses (Arbitrum One)

ContractAddress
HorizonStaking0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03
GRT Token0x9623063377AD1B27544C965cCd7342f7EA7e88C7
GraphTallyCollector0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e
PaymentsEscrow0xf6Fcc27aAf1fcD8B254498c9794451d82afC673E
GraphPayments0xb98a3D452E43e40C70F3c0B03C5c7B56A8B3b8CA
RPCDataService0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078

Using the Network

Two ways to consume the Dispatch network: hit the gateway directly or use the consumer SDK (trustless, signs receipts locally). Both require GRT in your escrow — there are no free queries.


Via the Gateway

The gateway handles provider selection and TAP receipt signing. You must include your Ethereum address in every request via the X-Consumer-Address header — the gateway encodes it into the TAP receipt so GRT is drawn from your escrow on-chain, not the gateway's.

Live gateway: https://gateway.lodestar-dashboard.com

# curl
curl -s -X POST https://gateway.lodestar-dashboard.com/rpc/42161 \
  -H "Content-Type: application/json" \
  -H "X-Consumer-Address: 0xYOUR_ADDRESS" \
  -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'

# Check the attestation header
curl -si -X POST https://gateway.lodestar-dashboard.com/rpc/42161 \
  -H "Content-Type: application/json" \
  -H "X-Consumer-Address: 0xYOUR_ADDRESS" \
  -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
  | grep -E "x-drpc-attestation|result"

With ethers.js or viem:

import { createPublicClient, http } from "viem";
import { arbitrum } from "viem/chains";

const client = createPublicClient({
  chain: arbitrum,
  transport: http("https://gateway.lodestar-dashboard.com/rpc/42161", {
    fetchOptions: {
      headers: { "X-Consumer-Address": "0xYOUR_ADDRESS" },
    },
  }),
});

const block = await client.getBlockNumber();

Missing the X-Consumer-Address header returns 402 Payment Required. Requests from addresses with no funded escrow are rejected by the provider. See Funding the escrow below.

Routes:

POST /rpc/{chain_id}     # chain ID in path
POST /rpc                # chain ID via X-Chain-Id header

Currently live: Arbitrum One (42161) — Standard and Archive tiers.


dispatch-proxy (drop-in local server)

The easiest way to point any existing app at the Dispatch network without changing application code. Starts a standard JSON-RPC HTTP server on localhost; MetaMask, Viem, Ethers.js, and curl all work against it without modification.

cd proxy
npm install
npm start

On first run the proxy auto-generates a consumer keypair, saves it to ./consumer.key, and tells you where to fund escrow. No key needed upfront.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
dispatch-proxy v0.1.0
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Chain:     Ethereum Mainnet (1)
Listening: http://localhost:8545
Consumer:  0xABCD...1234
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠  New consumer key generated → ./consumer.key
Fund escrow at:  https://lodestar-dashboard.com/dispatch
Consumer address: 0xABCD...1234
Or use an existing funded key: DISPATCH_SIGNER_KEY=0x...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Add to MetaMask  →  Settings → Networks → Add a network
  RPC URL:  http://localhost:8545
  Chain ID: 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[12:34:56] ✓ eth_blockNumber      42ms  0.000004 GRT   total: 0.000004 GRT
[12:34:57] ✓ eth_getBalance       38ms  0.000008 GRT   total: 0.000012 GRT

Configuration:

VariableDefaultDescription
DISPATCH_SIGNER_KEY(auto-generated)Consumer private key. If unset, loaded from ./consumer.key or generated fresh
DISPATCH_CHAIN_ID1Chain to proxy (1 = Ethereum, 42161 = Arbitrum One, etc.)
DISPATCH_PORT8545Local port to listen on
DISPATCH_BASE_PRICE_PER_CU4000000000000GRT wei per compute unit

The proxy handles provider discovery, TAP receipt signing, QoS-scored provider selection, CORS, and JSON-RPC batch requests. On exit (Ctrl+C) it prints a session summary of total requests and GRT spent.

Unlike the gateway, the proxy runs locally and signs receipts with your own key — you pay providers directly from your own escrow. See Funding the escrow below.


Consumer SDK

For trustless access — signs receipts locally and talks directly to providers, no gateway in the loop.

npm install @lodestar-dispatch/consumer-sdk
import { DISPATCHClient } from "@lodestar-dispatch/consumer-sdk";

const client = new DISPATCHClient({
  chainId: 42161,                                               // Arbitrum One (only live chain)
  dataServiceAddress: "0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078",
  graphTallyCollector: "0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e",
  subgraphUrl: "https://api.studio.thegraph.com/query/1747796/rpc-network/v0.2.0",
  signerPrivateKey: process.env.CONSUMER_KEY as `0x${string}`,
  basePricePerCU: 4_000_000_000_000n,  // GRT wei per compute unit
});

const blockNumber = await client.request("eth_blockNumber", []);
const balance = await client.request("eth_getBalance", ["0x...", "latest"]);

The client discovers providers via the subgraph, selects one by QoS score, signs a TAP receipt per request, and tracks latency with an EMA.

Low-level utilities

import {
  discoverProviders,
  selectProvider,
  buildReceipt,
  signReceipt,
} from "@lodestar-dispatch/consumer-sdk";

// Discover active providers for a chain + tier
const providers = await discoverProviders(
  "https://api.studio.thegraph.com/query/1747796/rpc-network/v0.2.0",
  42161,  // chainId
  0,      // tier: 0 = Standard, 1 = Archive
);

const provider = selectProvider(providers);

// Build and sign a receipt
const receipt = buildReceipt(
  "0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078",  // dataService
  provider.address,                                // serviceProvider
  4_000_000_000_000n,                             // value (GRT wei)
);
const signed = await signReceipt(
  receipt,
  { verifyingContract: "0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e" },
  privateKey,
);

Funding the escrow

Before GRT flows to providers you need to deposit into PaymentsEscrow on Arbitrum One. This is required regardless of which access method you use — the gateway, proxy, and consumer SDK all charge from your own escrow.

Via the Lodestar dashboard (easiest)

Go to lodestar-dashboard.com/dispatch. Connect MetaMask, paste your consumer address, and deposit GRT. The dashboard calls depositTo() on the PaymentsEscrow contract so you can fund any address's escrow directly — the consumer wallet itself needs no ETH or GRT. Useful for funding dispatch-proxy from a separate hot wallet.

Manually (cast / ethers)

// 1. Approve the escrow contract
GRT.approve(0xf6Fcc27aAf1fcD8B254498c9794451d82afC673E, amount);

// 2a. Deposit from your own address
PaymentsEscrow.deposit(
    0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e,  // collector: GraphTallyCollector
    providerAddress,                                // receiver: the indexer you're paying
    amount
);

// 2b. Or fund any address's escrow (useful for the proxy key)
PaymentsEscrow.depositTo(
    consumerAddress,                                // payer: the consumer key you're funding
    0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e,  // collector: GraphTallyCollector
    providerAddress,                                // receiver
    amount
);

Deposits are keyed by (payer, collector, receiver). dispatch-service draws down automatically on each collect() cycle (hourly by default). Providers reject requests from addresses with zero escrow balance (checked on-chain every 30 seconds). Check your balance with:

cast call 0xf6Fcc27aAf1fcD8B254498c9794451d82afC673E \
  "getBalance(address,address,address)(uint256)" \
  <YOUR_ADDRESS> \
  0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e \
  <PROVIDER_ADDRESS> \
  --rpc-url https://arb1.arbitrum.io/rpc

Payments

Dispatch uses GraphTally (TAP v2) — the same payment infrastructure used by The Graph's SubgraphService. GRT moves off-chain per request via signed receipts, then settles on-chain in batches.


End-to-end flow

1. Consumer deposits GRT into PaymentsEscrow (keyed by their own address as payer)
2. Consumer includes X-Consumer-Address header on every gateway request
3. Per request: gateway signs a TAP receipt (EIP-712 ECDSA, random nonce, CU-weighted value)
   — consumer address is encoded in receipt metadata so the correct escrow is charged
4. Receipt sent in TAP-Receipt header alongside JSON-RPC request to dispatch-service
5. dispatch-service extracts consumer address from metadata, checks their escrow balance,
   validates signature, persists receipt to PostgreSQL
6. dispatch-service TAP aggregator batches receipts per consumer → sends to /rav/aggregate
7. Gateway returns a signed RAV (Receipt Aggregate Voucher) with payer = consumer address
8. dispatch-service collector submits RAV on-chain every hour: RPCDataService.collect()
                               → GraphTallyCollector (verifies EIP-712, tracks cumulative value)
                               → PaymentsEscrow (draws GRT from consumer's escrow)
                               → GraphPayments (distributes: protocol tax → delegators → provider)
                               → GRT lands at paymentsDestination

valueAggregate in a RAV is cumulative and never resets. Each collect() call computes the delta from the last collected value. This means a lost RAV doesn't lose funds — the next RAV covers the gap.


EIP-712 domain

All receipts and RAVs are signed against this domain on Arbitrum One:

name: protocol-configured
version: "1"
chainId: 42161
verifyingContract: 0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e  // GraphTallyCollector

The data_service field in every receipt is set to RPCDataService's address, preventing cross-service receipt replay.


CU-weighted pricing

Request value is proportional to compute units (CUs) — a weight assigned per method.

MethodCU
eth_chainId, net_version, eth_blockNumber1
eth_getBalance, eth_getTransactionCount, eth_getCode, eth_getStorageAt5
eth_sendRawTransaction, eth_getBlockByHash/Number5
eth_call, eth_estimateGas, eth_getTransactionReceipt, eth_getTransactionByHash10
eth_getLogs (bounded)20
debug_traceTransaction500+

Receipt value = CU x base_price_per_cu. Default base_price_per_cu is 4_000_000_000_000 GRT wei (~$40/million requests at $0.09 GRT).


TAP receipt overhead

Receipt processing must not slow down requests. In practice:

OperationLatency
ECDSA signature verification~0.1ms
Receipt storage (async, not on critical path)~0ms
Total overhead<1ms

Stake locking

On each collect(), RPCDataService locks fees x stakeToFeesRatio in a stake claim via DataServiceFees._createStakeClaim(). The claim releases after thawingPeriod. This ensures providers maintain sufficient economic stake relative to fees collected.

Default stakeToFeesRatio is 5 — consistent with SubgraphService.


Payments destination

The GRT recipient on collect() is paymentsDestination[serviceProvider], not necessarily the provider's staking address. Providers can separate their operator key from their payment wallet via setPaymentsDestination(address). Defaults to the provider address on registration.

Verification

Dispatch uses two complementary mechanisms to catch bad providers: attestation (cryptographic proof of what was served) and quorum (cross-provider consensus on deterministic results).


Attestation

Every dispatch-service response carries a signed attestation header:

X-Drpc-Attestation: {"signer":"0x…","signature":"0x…"}

The provider signs the following message with its operator key:

keccak256(
    chain_id_be8        // chain ID as 8 bytes big-endian
    || method_utf8      // method name as UTF-8 bytes
    || keccak256(params_json)   // keccak of the serialised params field
    || keccak256(result_json)   // keccak of the serialised result (or error) field
)

The signature is a 65-byte secp256k1 ECDSA signature (r || s || v, Ethereum-style v = 27/28).

Gateway verification happens automatically on every response before it is forwarded to the consumer. The gateway recovers the signer from the signature and checks it matches the signer address stated in the header. A mismatch logs a warning and penalises the provider's QoS score.

Without slashing, attestations serve as:

  • A tamper-evident audit trail — consumers can verify a provider claimed a specific response
  • A QoS signal — providers that forge or omit attestations are deprioritised over time
  • Future-proof groundwork — if slash infrastructure is added later, the format is already in place

Quorum

For deterministic methods, the gateway queries quorum_k providers (default: 3) concurrently and takes the majority result. If providers disagree, the minority is outvoted and the disagreement is logged as a warning.

Methods subject to quorum:

Method
eth_call
eth_getLogs
eth_getBalance
eth_getCode
eth_getTransactionCount
eth_getStorageAt
eth_getBlockByHash
eth_getTransactionByHash
eth_getTransactionReceipt

All other methods (including eth_blockNumber, eth_estimateGas, eth_sendRawTransaction) use concurrent dispatch — first valid response wins.


What is not implemented

  • Slashingslash() reverts with "not supported". RPC responses have no canonical on-chain truth to verify against, so there is no safe basis for slashing.
  • EIP-1186 Merkle proof verification — on-demand proof attachment and MPT verification; deferred with slashing.
  • Block header trust oracle — required for Merkle proof verification; not built.

Components

dispatch-service

Runs on the indexer alongside an Ethereum node. Validates TAP receipts, proxies JSON-RPC requests, signs responses, and persists receipts to PostgreSQL.


Routes

MethodPathDescription
POST/rpc/{chain_id}JSON-RPC request (single or batch)
GET/ws/{chain_id}WebSocket proxy for eth_subscribe
GET/healthLiveness check
GET/versionVersion info
GET/chainsList of supported chains
GET/block/{chain_id}Unauthenticated probe — returns current block number (used by gateways for QoS freshness scoring)

Request flow

Gateway POST /rpc/42161
  + TAP-Receipt: { signed EIP-712 receipt }
        │
        ▼
TAP middleware
  - recover signer from EIP-712 signature
  - check sender is in authorized_senders
  - check timestamp not stale (configurable window)
  - persist receipt to tap_receipts table (async)
        │
        ▼
Parse JSON-RPC method + params
        │
        ▼
Forward to backend Ethereum client (chain_id → backend URL mapping)
        │
        ▼
Sign response attestation:
  keccak256(chainId || method || paramsHash || responseHash || blockNumber || blockHash)
        │
        ▼
Return JSON-RPC response
  + X-Dispatch-Attestation: <signature>

TAP receipt validation

The service validates every incoming receipt before forwarding the request:

  • Signature: EIP-712 ECDSA recovery against GraphTallyCollector as verifying contract
  • Sender authorisation: recovered signer must be in authorized_senders config list
  • Staleness: timestamp_ns must be within the configured window
  • Data service: data_service field must match RPCDataService address

Invalid receipts are rejected with 400 Bad Request.


Archive tier detection

The service inspects block parameters to determine if a request requires archive state:

  • Hex block numbers below current head - 128 → routed to archive backend
  • String tags "earliest", "pending" → archive
  • JSON integers → parsed and compared against head

This allows a single endpoint to serve both standard and archive requests when both backends are configured.

dispatch-gateway

Sits between consumers and providers. Handles provider discovery, QoS scoring, geographic routing, TAP receipt issuance, quorum consensus, and rate limiting.


Routes

MethodPathDescription
POST/rpc/{chain_id}JSON-RPC request
POST/rpcJSON-RPC with chain from X-Chain-Id header
GET/ws/{chain_id}WebSocket proxy
GET/healthLiveness check
GET/versionVersion info
GET/providers/{chain_id}List active providers for chain
GET/metricsPrometheus metrics
POST/rav/aggregateTAP agent submits receipts, receives signed RAV

Provider selection

  1. Query registry for providers serving the requested (chain_id, tier) pair
  2. Score each provider by QoS (latency EMA, availability, block freshness)
  3. Apply geographic bonus (15% score boost for same-region providers)
  4. Weighted random selection from top-k candidates
  5. For deterministic methods: quorum dispatch (majority wins). For all others: concurrent dispatch (first valid wins)

Quorum methodseth_call, eth_getLogs, eth_getBalance, eth_getCode, eth_getTransactionCount, eth_getStorageAt, eth_getBlockByHash, eth_getTransactionByHash, eth_getTransactionReceipt. All other methods use concurrent dispatch.


QoS scoring

MetricWeight
Latency (p50 EMA)35%
Availability35%
Block freshness (blocks behind chain head)30%

A synthetic eth_blockNumber probe fires to every provider every 10 seconds. Results feed freshness and availability scores.

New providers start with a neutral score and receive a geographic bonus until latency data accumulates.


Consumer address requirement

Every request to the gateway must include an X-Consumer-Address header containing the consumer's Ethereum address:

X-Consumer-Address: 0xYOUR_ADDRESS

Missing or invalid header → 402 Payment Required. The address must have GRT deposited in PaymentsEscrow or the provider will reject the request.


TAP receipt issuance

The gateway signs a fresh EIP-712 TAP receipt for every request:

  • data_service: RPCDataService address
  • service_provider: selected provider's address
  • nonce: random uint64
  • value: CU_weight × base_price_per_cu in GRT wei
  • timestamp_ns: current Unix nanoseconds
  • metadata: first 20 bytes = consumer address (from X-Consumer-Address)

The consumer address in metadata is how dispatch-service determines whose escrow to check and how the RAV's payer field is set — ensuring on-chain collect() debits the correct account.

The receipt is sent to dispatch-service in the TAP-Receipt HTTP header.


Dynamic discovery

The gateway polls the RPC network subgraph every 60 seconds (configurable) and rebuilds its provider registry. Providers appearing in the subgraph are probed for liveness before being added to the active pool.

Static provider config (via [[providers]] in gateway.toml) takes precedence over subgraph discovery and is used when the subgraph is unavailable.


Batch JSON-RPC

Batch requests are dispatched concurrently — each item in the batch is routed independently. Per-item errors are isolated and don't fail the whole batch.


Rate limiting

Per-IP token bucket via governor. Configurable requests-per-second and burst size. Returns 429 Too Many Requests when the bucket is exhausted.


Metrics

Prometheus endpoint at GET /metrics:

  • dispatch_requests_total{chain_id, method, status} — request counter
  • dispatch_request_duration_seconds{chain_id, method} — latency histogram

dispatch-tap

Shared library crate used by both dispatch-service and dispatch-gateway. Contains all TAP v2 (GraphTally) primitives.


Types

#![allow(unused)]
fn main() {
pub struct Receipt {
    pub data_service: Address,
    pub service_provider: Address,
    pub timestamp_ns: u64,
    pub nonce: u64,
    pub value: u128,
    pub metadata: Bytes,
}

pub struct SignedReceipt {
    pub receipt: Receipt,
    pub signature: Signature,  // k256 ECDSA
}

pub struct ReceiptAggregateVoucher {
    pub data_service: Address,
    pub service_provider: Address,
    pub timestamp_ns: u64,
    pub value_aggregate: u128,  // cumulative, never resets
    pub metadata: Bytes,
}
}

EIP-712 domain

The domain separator is fixed to the Arbitrum One GraphTallyCollector:

#![allow(unused)]
fn main() {
pub const EIP712_DOMAIN: Eip712Domain = Eip712Domain {
    chain_id: 42161,
    verifying_contract: address!("8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e"),
    // name and version from protocol config
};
}

API

#![allow(unused)]
fn main() {
// Create and sign a receipt
let signed = dispatch_tap::create_receipt(
    &signer_key,
    data_service,
    service_provider,
    value_grt_wei,
    metadata,
)?;

// Compute EIP-712 hash (for verification)
let hash = dispatch_tap::eip712_receipt_hash(&receipt);

// Recover signer from signed receipt
let signer = dispatch_tap::recover_signer(&signed)?;
}

Cross-language compatibility

The EIP-712 hash must be identical across Rust and Solidity. contracts/test/EIP712CrossLanguage.t.sol verifies this with a golden test — fixed inputs, pre-computed Rust hash checked against Solidity _hashReceipt(). Both the hash and ECDSA signature recovery are validated.

Contract Reference

RPCDataService is deployed on Arbitrum One at 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078.

It inherits Horizon's DataService + DataServiceFees + DataServicePausable and implements IDataService.


Functions

register(serviceProvider, data)

Registers a new provider.

  • data: abi.encode(string endpoint, string geoHash, address paymentsDestination)
  • Validates: provision ≥ minimumProvisionTokens, thawing period ≥ minimumThawingPeriod
  • Sets paymentsDestination[serviceProvider] (defaults to serviceProvider if zero address)
  • Emits ProviderRegistered

setPaymentsDestination(destination)

Changes the GRT recipient for collected fees. The new address takes effect on the next collect() call. Callable by a registered provider or their authorised operator at any time.

startService(serviceProvider, data)

Activates a provider for a specific chain and tier.

  • data: abi.encode(uint64 chainId, uint8 tier, string endpoint)
  • Validates: chainId in supportedChains, provider registered, provision meets per-chain minimum
  • Emits ServiceStarted

stopService(serviceProvider, data)

Deactivates a provider for a (chainId, tier) pair.

deregister(serviceProvider, data)

Removes the provider from the registry. Must stop all active services first.

collect(serviceProvider, paymentType, data)

Redeems a signed RAV for GRT.

  • data: abi.encode(SignedRAV, tokensToCollect)
  • Reverts with InvalidPaymentType if paymentType != QueryFee
  • Calls GraphTallyCollector.collect() — verifies EIP-712 signature, tracks cumulative value
  • Routes GRT to paymentsDestination[serviceProvider]
  • Locks fees × STAKE_TO_FEES_RATIO via _lockStake() (releases after minThawingPeriod)

addChain(chainId, minProvisionTokens) / removeChain(chainId)

Owner-only chain allowlist management. minProvisionTokens = 0 uses the protocol default (10,000 GRT).

setMinThawingPeriod(period)

Governance setter. Lower-bounded by MIN_THAWING_PERIOD (14 days).

slash(serviceProvider, data)

No-op — reverts with "slashing not supported". Present to satisfy the IDataService interface.


Parameters

ParameterValueNotes
Minimum provision10,000 GRTGovernance-adjustable per chain
Minimum thawing period14 daysGovernance-adjustable, lower-bounded
stakeToFeesRatio5Same as SubgraphService

Configuration


dispatch-service (config.toml)

[server]
host = "0.0.0.0"
port = 7700

[indexer]
# Your on-chain provider address (holds the GRT provision).
service_provider_address = "0x..."

# 32-byte hex ECDSA private key of your operator key.
# Signs response attestations and on-chain collect() transactions.
# Use a dedicated hot key — NOT your wallet or staking key.
operator_private_key = "0x..."

[tap]
# RPCDataService contract address.
data_service_address      = "0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078"

# Ethereum addresses authorised to send TAP receipts to this service.
# Derived from the gateway's signer_private_key. Leave empty to accept all.
authorized_senders        = ["0x..."]

# EIP-712 domain — must match the deployed GraphTallyCollector. Do not change.
eip712_domain_name        = "GraphTallyCollector"
eip712_chain_id           = 42161
eip712_verifying_contract = "0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e"

# Internal URL of your dispatch-gateway (for RAV aggregation).
aggregator_url            = "http://dispatch-gateway:8080"

# How often to aggregate receipts into RAVs (default: 60s).
# aggregation_interval_secs = 60

# Maximum unconfirmed GRT wei per consumer before rejecting requests (default: 0.1 GRT).
# credit_threshold = 100_000_000_000_000_000

# Arbitrum One RPC for on-chain escrow balance pre-checks (cached 30s per consumer).
# Falls back to [collector].arbitrum_rpc_url. Omit both to disable escrow checks.
# escrow_check_rpc_url = "https://arb1.arbitrum.io/rpc"

# PaymentsEscrow contract — defaults to the live Horizon deployment, no need to change.
# payments_escrow_address = "0xf6Fcc27aAf1fcD8B254498c9794451d82afC673E"

[chains]
# Chain IDs this node is registered to serve.
supported = [1, 42161]

[chains.backends]
# Internal RPC URL of your Ethereum node for each chain.
"1"     = "http://eth-node:8545"
"42161" = "http://arbitrum-node:8545"

[database]
url = "postgres://dispatch:dispatch@postgres:5432/dispatch"

[collector]
# Arbitrum One RPC for sending on-chain collect() transactions.
arbitrum_rpc_url      = "https://arb1.arbitrum.io/rpc"
collect_interval_secs = 3600   # collect GRT every hour
# min_collect_value = 0        # skip collect if accumulated value is below this (GRT wei)

dispatch-gateway (gateway.toml)

[gateway]
host   = "0.0.0.0"
port   = 8080
region = "eu-west"   # optional — used for geographic routing bonus

[tap]
# 32-byte hex ECDSA private key — gateway signs TAP receipts with this.
# Its derived Ethereum address must be in each provider's authorized_senders list.
signer_private_key        = "0x..."

# RPCDataService contract address.
data_service_address      = "0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078"

# GRT wei per compute unit. Default ≈ $40/M requests at $0.09/GRT.
base_price_per_cu         = 4000000000000

# EIP-712 domain — must match the deployed GraphTallyCollector. Do not change.
eip712_domain_name        = "GraphTallyCollector"
eip712_chain_id           = 42161
eip712_verifying_contract = "0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e"

[qos]
probe_interval_secs = 10    # synthetic eth_blockNumber probe period
concurrent_k        = 3     # dispatch to top-k providers, return first valid response
region_bonus        = 0.15  # score boost for providers in the same region

[discovery]
# The Graph subgraph URL for dynamic provider discovery.
subgraph_url  = "https://api.studio.thegraph.com/query/1747796/rpc-network/v0.2.0"
interval_secs = 60

# Optional: static providers used at startup and as fallback.
[[providers]]
address      = "0x..."
endpoint     = "https://rpc.your-indexer.com"
chains       = [1, 42161]
region       = "eu-west"
capabilities = ["standard", "archive"]

Environment variables

dispatch-service:

DISPATCH_CONFIG=/etc/dispatch/config.toml   # path to config file (default: config.toml)
RUST_LOG=info                                # log level: error, warn, info, debug, trace

dispatch-gateway:

DISPATCH_GATEWAY_CONFIG=/etc/dispatch/gateway.toml
RUST_LOG=info

Deployed Addresses


Dispatch contracts

ContractNetworkAddress
RPCDataServiceArbitrum One (42161)0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078

Subgraph: https://api.studio.thegraph.com/query/1747796/rpc-network/v0.2.0


Horizon contracts — Arbitrum One (42161)

ContractAddress
HorizonStaking0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03
GraphPayments0xb98a3D452E43e40C70F3c0B03C5c7B56A8B3b8CA
PaymentsEscrow0xf6Fcc27aAf1fcD8B254498c9794451d82afC673E
GraphTallyCollector0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e
SubgraphService0xb2Bb92d0DE618878E438b55D5846cfecD9301105
DisputeManager0x0Ab2B043138352413Bb02e67E626a70320E3BD46
RewardsManager0x971B9d3d0Ae3ECa029CAB5eA1fB0F72c85e6a525
GRT Token0x9623063377AD1B27544C965cCd7342f7EA7e88C7

Horizon contracts — Arbitrum Sepolia (421614, testnet)

ContractAddress
HorizonStaking0x865365C425f3A593Ffe698D9c4E6707D14d51e08
GraphTallyCollector0x382863e7B662027117449bd2c49285582bbBd21B
PaymentsEscrow0x1e4dC4f9F95E102635D8F7ED71c5CdbFa20e2d02
SubgraphService0xc24A3dAC5d06d771f657A48B20cE1a671B78f26b

Active providers

AddressEndpointChainsTiers
0xb43B...https://rpc.cargopete.com42161Standard, Archive

Roadmap

Aligns with The Graph's 2026 Technical Roadmap ("Experimental JSON-RPC Data Service", Q3 2026).


MVP ✅ Complete

The full working system is shipped. The goal was a minimal, stable payment loop — no dead weight.

Payment infrastructure

  • RPCDataService.sol — register, startService, stopService, collect
  • paymentsDestination — decouple payment recipient from operator key
  • TAP receipt validation, RAV aggregation, on-chain collect()
  • Integration tests against real Horizon payment contracts (mock staking only)
  • EIP-712 cross-language compatibility tests (Solidity ↔ Rust)

Gateway & routing

  • QoS routing — latency EMA (35%) + availability (35%) + block freshness (30%)
  • Capability tiers — Standard / Archive / Debug; per-method tier detection
  • Archive tier routing — inspects block parameters (hex numbers, "earliest")
  • debug_* / trace_* routing — per-chain capability map
  • 10+ chains — Ethereum, Arbitrum, Optimism, Base, Polygon, BNB, Avalanche, zkSync Era, Linea, Scroll
  • CU-weighted pricing — 1–20 CU per method
  • Geographic routing — region-aware score bonus
  • Cross-chain unified /rpc endpoint — chain via X-Chain-Id header
  • JSON-RPC batch support
  • WebSocket subscriptions — eth_subscribe / eth_unsubscribe proxied bidirectionally
  • Per-IP rate limiting, Prometheus metrics

Verification

  • Response attestation — provider signs every response with operator key; gateway verifies before forwarding
  • Quorum consensus — deterministic methods sent to 3 providers; majority wins; disagreements logged

Discovery & operations

  • RPC network subgraph — indexes RPCDataService events for dynamic provider discovery
  • Indexer agent — TypeScript; automates provider lifecycle (register, startService, stopService)
  • Consumer SDK — TypeScript; receipt signing, provider discovery, QoS-weighted selection
  • Docker Compose full-stack deployment
  • CI (Rust + Solidity)

Not planned

These were considered and deliberately excluded.

FeatureWhy
slash() / fraud proofsRPC responses have no canonical on-chain truth to slash against
Block header trust oracleDependency of slashing; dropped with it
EIP-1186 MPT proof verificationSame
Permissionless chain registrationGovernance allowlist is sufficient
GRT issuance / rewards poolOut of scope for this data service

Potential future work

  • TEE-based response verification — cryptographic correctness via trusted execution; requires enclave hardware and a security audit
  • P2P SDK — gateway-optional model; consumer connects directly to provider
  • Permissionless chain registration — bond-based governance; deferred until the allowlist becomes a bottleneck