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
| Component | Status |
|---|---|
RPCDataService contract | ✅ Live on Arbitrum One |
| Subgraph | ✅ Live on The Graph Studio |
| npm packages | ✅ Published (@lodestar-dispatch/consumer-sdk, @lodestar-dispatch/indexer-agent) |
| Active providers | ✅ 1 — https://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:
| Component | Status |
|---|---|
| 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 framework — RPCDataService 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
| Requirement | Details |
|---|---|
| GRT | ≥ 10,000 GRT on Arbitrum One for the provision |
| ETH on Arbitrum | Small amount for gas (~0.005 ETH is plenty) |
| Ethereum node(s) | Full or archive node for each chain you want to serve |
| Server | Linux VPS with 2+ vCPUs, ≥ 4 GB RAM, SSD |
| PostgreSQL | For TAP receipt + RAV persistence (Docker Compose sets this up automatically) |
| Public HTTPS endpoint | Consumers 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 addressdataService—0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078(RPCDataService)tokens— amount in wei, minimum10000000000000000000000(10,000 GRT)maxVerifierCut—1000000(100% in PPM — the contract cannot slash, so this doesn't matter in practice)thawingPeriod—1209600(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:
dataService—0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078(RPCDataService)operator— your operator address (derived from the hot key on your server)allowed—true
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
| Field | Description |
|---|---|
operatorPrivateKey | Hot key on your server — must be authorised as operator in HorizonStaking |
providerAddress | Your on-chain provider address (holds the GRT provision) |
endpoint | Public HTTPS base URL of your dispatch-service, reachable by gateways and consumers |
geoHash | Geohash of your server location — used for geographic routing. 4 characters is sufficient (e.g. u1hx for Amsterdam, dr4g for New York) |
paymentsDestination | Address that receives collected GRT. If omitted, defaults to providerAddress. Use a cold wallet here |
services | List 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.
| Tier | Value | What it serves | Node requirement |
|---|---|---|---|
| Standard | 0 | All standard JSON-RPC methods, recent ~128 blocks | Any full node |
| Archive | 1 | Historical state at any block number | Archive node (~10–20× more disk) |
| Debug/Trace | 2 | debug_* and trace_* methods | Full/archive node with debug APIs enabled (--http.api=debug,trace) |
| WebSocket | 3 | eth_subscribe, real-time event streams | Full 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
| Chain | Chain ID |
|---|---|
| Ethereum | 1 |
| Arbitrum One | 42161 |
| Optimism | 10 |
| Base | 8453 |
| Polygon | 137 |
| BNB Chain | 56 |
| Avalanche C-Chain | 43114 |
| zkSync Era | 324 |
| Linea | 59144 |
| Scroll | 534352 |
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)
| Contract | Address |
|---|---|
| HorizonStaking | 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 |
| GRT Token | 0x9623063377AD1B27544C965cCd7342f7EA7e88C7 |
| GraphTallyCollector | 0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e |
| PaymentsEscrow | 0xf6Fcc27aAf1fcD8B254498c9794451d82afC673E |
| GraphPayments | 0xb98a3D452E43e40C70F3c0B03C5c7B56A8B3b8CA |
| RPCDataService | 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 |
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:
| Variable | Default | Description |
|---|---|---|
DISPATCH_SIGNER_KEY | (auto-generated) | Consumer private key. If unset, loaded from ./consumer.key or generated fresh |
DISPATCH_CHAIN_ID | 1 | Chain to proxy (1 = Ethereum, 42161 = Arbitrum One, etc.) |
DISPATCH_PORT | 8545 | Local port to listen on |
DISPATCH_BASE_PRICE_PER_CU | 4000000000000 | GRT 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.
| Method | CU |
|---|---|
eth_chainId, net_version, eth_blockNumber | 1 |
eth_getBalance, eth_getTransactionCount, eth_getCode, eth_getStorageAt | 5 |
eth_sendRawTransaction, eth_getBlockByHash/Number | 5 |
eth_call, eth_estimateGas, eth_getTransactionReceipt, eth_getTransactionByHash | 10 |
eth_getLogs (bounded) | 20 |
debug_traceTransaction | 500+ |
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:
| Operation | Latency |
|---|---|
| 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
- Slashing —
slash()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
| Method | Path | Description |
|---|---|---|
| POST | /rpc/{chain_id} | JSON-RPC request (single or batch) |
| GET | /ws/{chain_id} | WebSocket proxy for eth_subscribe |
| GET | /health | Liveness check |
| GET | /version | Version info |
| GET | /chains | List 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
GraphTallyCollectoras verifying contract - Sender authorisation: recovered signer must be in
authorized_sendersconfig list - Staleness:
timestamp_nsmust be within the configured window - Data service:
data_servicefield must matchRPCDataServiceaddress
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
| Method | Path | Description |
|---|---|---|
| POST | /rpc/{chain_id} | JSON-RPC request |
| POST | /rpc | JSON-RPC with chain from X-Chain-Id header |
| GET | /ws/{chain_id} | WebSocket proxy |
| GET | /health | Liveness check |
| GET | /version | Version info |
| GET | /providers/{chain_id} | List active providers for chain |
| GET | /metrics | Prometheus metrics |
| POST | /rav/aggregate | TAP agent submits receipts, receives signed RAV |
Provider selection
- Query registry for providers serving the requested
(chain_id, tier)pair - Score each provider by QoS (latency EMA, availability, block freshness)
- Apply geographic bonus (15% score boost for same-region providers)
- Weighted random selection from top-k candidates
- For deterministic methods: quorum dispatch (majority wins). For all others: concurrent dispatch (first valid wins)
Quorum methods — eth_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
| Metric | Weight |
|---|---|
| Latency (p50 EMA) | 35% |
| Availability | 35% |
| 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:RPCDataServiceaddressservice_provider: selected provider's addressnonce: randomuint64value:CU_weight × base_price_per_cuin GRT weitimestamp_ns: current Unix nanosecondsmetadata: first 20 bytes = consumer address (fromX-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 counterdispatch_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 toserviceProviderif 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:
chainIdinsupportedChains, 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
InvalidPaymentTypeifpaymentType != QueryFee - Calls
GraphTallyCollector.collect()— verifies EIP-712 signature, tracks cumulative value - Routes GRT to
paymentsDestination[serviceProvider] - Locks
fees × STAKE_TO_FEES_RATIOvia_lockStake()(releases afterminThawingPeriod)
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
| Parameter | Value | Notes |
|---|---|---|
| Minimum provision | 10,000 GRT | Governance-adjustable per chain |
| Minimum thawing period | 14 days | Governance-adjustable, lower-bounded |
| stakeToFeesRatio | 5 | Same 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
| Contract | Network | Address |
|---|---|---|
| RPCDataService | Arbitrum One (42161) | 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 |
Subgraph: https://api.studio.thegraph.com/query/1747796/rpc-network/v0.2.0
Horizon contracts — Arbitrum One (42161)
| Contract | Address |
|---|---|
| HorizonStaking | 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 |
| GraphPayments | 0xb98a3D452E43e40C70F3c0B03C5c7B56A8B3b8CA |
| PaymentsEscrow | 0xf6Fcc27aAf1fcD8B254498c9794451d82afC673E |
| GraphTallyCollector | 0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e |
| SubgraphService | 0xb2Bb92d0DE618878E438b55D5846cfecD9301105 |
| DisputeManager | 0x0Ab2B043138352413Bb02e67E626a70320E3BD46 |
| RewardsManager | 0x971B9d3d0Ae3ECa029CAB5eA1fB0F72c85e6a525 |
| GRT Token | 0x9623063377AD1B27544C965cCd7342f7EA7e88C7 |
Horizon contracts — Arbitrum Sepolia (421614, testnet)
| Contract | Address |
|---|---|
| HorizonStaking | 0x865365C425f3A593Ffe698D9c4E6707D14d51e08 |
| GraphTallyCollector | 0x382863e7B662027117449bd2c49285582bbBd21B |
| PaymentsEscrow | 0x1e4dC4f9F95E102635D8F7ED71c5CdbFa20e2d02 |
| SubgraphService | 0xc24A3dAC5d06d771f657A48B20cE1a671B78f26b |
Active providers
| Address | Endpoint | Chains | Tiers |
|---|---|---|---|
0xb43B... | https://rpc.cargopete.com | 42161 | Standard, 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, collectpaymentsDestination— 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
/rpcendpoint — chain viaX-Chain-Idheader - JSON-RPC batch support
- WebSocket subscriptions —
eth_subscribe/eth_unsubscribeproxied 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.
| Feature | Why |
|---|---|
slash() / fraud proofs | RPC responses have no canonical on-chain truth to slash against |
| Block header trust oracle | Dependency of slashing; dropped with it |
| EIP-1186 MPT proof verification | Same |
| Permissionless chain registration | Governance allowlist is sufficient |
| GRT issuance / rewards pool | Out 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