Community project — not affiliated with or endorsed by The Graph Foundation or Edge & Node. This is an independent hobby implementation exploring what a JSON-RPC data service on Horizon might look like.
A decentralised JSON-RPC data 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.
Implementation status: the contract, subgraph, npm packages, and Rust binaries are all deployed. The first provider is live and serving traffic. The full payment loop — receipt signing → RAV aggregation → on-chain collect() — is working end-to-end on the live provider. GRT settles automatically every hour. See Network status for the honest breakdown.
| 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) |
| Consumer-pays escrow | ✅ Enforced — X-Consumer-Address required; no free queries |
| Receipt signing & validation | ✅ Working — every request carries a signed EIP-712 TAP receipt |
| Receipt persistence | ✅ Working — stored in tap_receipts table in postgres |
| RAV aggregation (off-chain) | ✅ Working — gateway /rav/aggregate batches receipts into signed RAVs every 60s |
On-chain collect() |
✅ Working — GRT settles on-chain automatically every hour |
| Provider on-chain registration | ✅ Confirmed — registeredProviders[0xb43B...] = true on Arbitrum One |
| Multi-provider discovery | ✅ Working — gateway polls subgraph every 60s, rebuilds registry dynamically |
| Local demo | ✅ Working — full payment loop on Anvil with mock contracts |
The full payment loop is working end-to-end on the live provider. Requests generate TAP receipts, the gateway aggregates them into RAVs every 60s, and the service calls RPCDataService.collect() every hour — pulling GRT from the consumer's escrow to the provider automatically.
dispatch-smoke
endpoint : https://rpc.cargopete.com
chain_id : 42161
data_svc : 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078
signer : 0x7D14ae5f20cc2f6421317386Aa8E79e8728353d9
[PASS] GET /health → 200 OK
[PASS] eth_blockNumber — returns current block → "0x1b20574f" [95ms]
[PASS] eth_chainId — returns 0xa4b1 (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 → [{"address":"0xa62d...}] [61ms]
5 passed, 0 failed
To become a provider: stake ≥ 10,000 GRT on Arbitrum One, provision it to RPCDataService, run dispatch-service alongside your Ethereum node, and register via the indexer agent. Full guide: Running a Provider.
Consumer (dApp)
│
├── via consumer-sdk (trustless, direct)
│ signs receipts locally, discovers providers via subgraph
│
└── via dispatch-gateway (managed, centralised)
QoS-scored selection, TAP receipt signing
Requires X-Consumer-Address header + funded escrow
│
│ POST /rpc/{chain_id} (or X-Chain-Id header on /rpc)
│ TAP-Receipt: { signed EIP-712 receipt }
▼
dispatch-service ← JSON-RPC proxy, TAP receipt validation,
│ receipt persistence (PostgreSQL → RAV aggregation → collect())
▼
Ethereum client ← Geth / Erigon / Reth / Nethermind
(full or archive)
Payment flow (off-chain → on-chain):
receipts (per request) → dispatch-service aggregates (60s) → RAV → RPCDataService.collect() (hourly)
→ GraphTallyCollector
→ PaymentsEscrow
→ GraphPayments
→ GRT to indexer
crates/
├── dispatch-tap/ Shared TAP v2 primitives: EIP-712 types, receipt signing
├── dispatch-service/ Indexer-side JSON-RPC proxy with TAP middleware
├── dispatch-gateway/ Gateway: provider selection, QoS scoring, receipt issuance
└── dispatch-smoke/ End-to-end smoke test: signs real TAP receipts, hits a live provider
contracts/
├── src/
│ ├── RPCDataService.sol IDataService implementation (Horizon)
│ └── interfaces/IRPCDataService.sol
├── test/
└── script/
├── Deploy.s.sol Mainnet deployment
└── SetupE2E.s.sol Local Anvil stack for tests and demo
consumer-sdk/ TypeScript SDK — dApp developers use this to talk to
providers directly without the gateway
proxy/ Drop-in local JSON-RPC proxy — point any app (MetaMask, Viem,
Ethers.js) at localhost and it routes through the network
indexer-agent/ TypeScript agent — automates provider register/startService/
stopService lifecycle with graceful shutdown
subgraph/ The Graph subgraph — indexes RPCDataService events
docker/ Docker Compose full-stack deployment
demo/ Self-contained local demo: Anvil + contracts + Rust binaries
+ consumer requests + collect() — full payment loop in one command
Shared TAP v2 (GraphTally) primitives used by both service and gateway.
Receipt/SignedReceipttypes with serde- EIP-712 domain separator and receipt hash computation
create_receipt()— signs a receipt with a k256 ECDSA key
Runs on the indexer alongside an Ethereum full/archive node.
Key responsibilities:
- Validate incoming TAP receipts (EIP-712 signature recovery, sender authorisation, staleness check)
- Forward JSON-RPC requests to the backend Ethereum client
- Persist receipts to PostgreSQL; background task aggregates into RAVs every 60s and calls
collect()hourly - WebSocket proxy for
eth_subscribe/eth_unsubscribe
Routes: POST /rpc/{chain_id} · GET /ws/{chain_id} · GET /health · GET /version · GET /chains · GET /block/{chain_id}
Sits between consumers and indexers. Manages provider discovery, quality scoring, and payment issuance.
Key responsibilities:
- Maintain a QoS score per provider (latency EMA, availability, block freshness)
- Probe all providers with synthetic
eth_blockNumberevery 10 seconds - Geographic routing — region-aware score bonus, prefers nearby providers before latency data exists
- Capability tier filtering — Standard / Archive / Debug;
debug_*/trace_*only routed to capable providers - Select top-k providers via weighted random sampling, dispatch concurrently, return first valid response
- JSON-RPC batch — concurrent per-item dispatch, per-item error isolation
- WebSocket proxy — bidirectional forwarding for real-time subscriptions
- Require
X-Consumer-Addressheader — encodes consumer address into receipt metadata so GRT is drawn from the consumer's own escrow; returns402if missing or invalid - Create and sign a fresh TAP receipt per request (EIP-712, random nonce, CU-weighted value, consumer address in metadata)
- Dynamic discovery — polls the RPC network subgraph; rebuilds registry on each poll
- Per-IP rate limiting — token-bucket via
governor(configurable RPS + burst) - Prometheus metrics —
dispatch_requests_total,dispatch_request_duration_seconds
Routes: POST /rpc/{chain_id} · GET /ws/{chain_id} · GET /health · GET /version · GET /providers/{chain_id} · GET /metrics
TypeScript package for dApp developers who want to send requests through the Dispatch network without running a gateway.
Key features:
DISPATCHClient— discovers providers via subgraph, signs TAP receipts per request, updates QoS scores with EMAsignReceipt/buildReceipt— EIP-712 TAP v2 receipt construction and signingdiscoverProviders— subgraph GraphQL query returning active providers for a given chain and tierselectProvider— weighted random selection proportional to QoS score
Install: npm install /consumer-sdk
TypeScript daemon automating the provider lifecycle on-chain.
- Polls on-chain registrations and reconciles against config every N seconds
- Calls
register,startService, andstopServiceas needed - Graceful shutdown: stops all active registrations before exiting on SIGTERM/SIGINT
Install: npm install /indexer-agent
On-chain contract inheriting Horizon's DataService + DataServiceFees + DataServicePausable.
Key functions:
register— validates provision (≥ 10,000 GRT, ≥ 14-day thawing), stores provider metadata andpaymentsDestinationsetPaymentsDestination— decouple the GRT payment recipient from the operator signing keystartService— activates provider for a(chainId, capabilityTier)pairstopService/deregister— lifecycle managementcollect— enforcesQueryFeepayment type; routes throughGraphTallyCollector, locksfees × 5in stake claimsaddChain/removeChain— owner-only chain allowlist managementsetMinThawingPeriod— governance-adjustable thawing period (≥ 14 days)
Reference implementations: SubgraphService (live on Arbitrum One) and substreams-data-service (pre-launch).
| 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 |
All Horizon contracts live on Arbitrum One (chain ID 42161).
| Contract | Address |
|---|---|
| HorizonStaking | 0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03 |
| GraphPayments | 0xb98a3D452E43e40C70F3c0B03C5c7B56A8B3b8CA |
| PaymentsEscrow | 0xf6Fcc27aAf1fcD8B254498c9794451d82afC673E |
| GraphTallyCollector | 0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e |
| RPCDataService | 0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078 |
Subgraph: https://api.studio.thegraph.com/query/1747796/rpc-network/v0.2.0
Fires real TAP-signed JSON-RPC requests directly at a provider and validates responses.
DISPATCH_ENDPOINT=https://rpc.cargopete.com \
DISPATCH_SIGNER_KEY=<authorized-signer-key> \
DISPATCH_PROVIDER_ADDRESS=0xb43B2CCCceadA5292732a8C58ae134AdEFcE09Bb \
cargo run --bin dispatch-smokeDISPATCH_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 — it's embedded in the TAP receipt and validated server-side.
Runs a complete local stack — Anvil, Horizon mock contracts, dispatch-service, dispatch-gateway — makes 5 RPC requests, submits a RAV, and proves GRT lands in the payment wallet.
Requires: Foundry and Rust stable.
cd demo
npm install
npm startcp docker/config.example.toml docker/config.toml
cp docker/gateway.example.toml docker/gateway.toml
# Fill in private keys, provider addresses, and backend URLs.
docker compose upcargo build
cargo testcp config.example.toml config.toml
# fill in: indexer address, operator private key, TAP config, backend node URLs
RUST_LOG=info cargo run --bin dispatch-servicecp docker/gateway.example.toml gateway.toml
# fill in: signer key, data_service_address, provider list
RUST_LOG=info cargo run --bin dispatch-gatewaycd contracts
forge build
forge test -vvv
cp .env.example .env
# fill in PRIVATE_KEY, OWNER, PAUSE_GUARDIAN, GRAPH_CONTROLLER, GRAPH_TALLY_COLLECTOR
forge script script/Deploy.s.sol --rpc-url arbitrum_one --broadcast --verify -vvvvThe quickest way to point any existing app — MetaMask, Viem, Ethers.js, curl — at the Dispatch network without changing a line of application code.
cd proxy
npm install
npm startOn first run the proxy auto-generates a consumer keypair, saves it to ./consumer.key, and prints your consumer address and a link to fund escrow. No private 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
Then go to lodestar-dashboard.com/dispatch, paste your consumer address, and deposit GRT. The dashboard calls depositTo() on the PaymentsEscrow contract so MetaMask can fund the proxy's escrow directly — the proxy itself needs no ETH or GRT in its own wallet.
Configuration via environment variables:
| 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 |
npm install @lodestar-dispatch/consumer-sdkThe live gateway is https://gateway.lodestar-dashboard.com. All requests require an X-Consumer-Address header and a funded GRT escrow — see docs/consumers.md.
import { DISPATCHClient } from "@lodestar-dispatch/consumer-sdk";
const client = new DISPATCHClient({
chainId: 42161, // Arbitrum One — only live chain currently
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,
});
const block = await client.request("eth_blockNumber", []);npm install @lodestar-dispatch/indexer-agentimport { IndexerAgent } from "@lodestar-dispatch/indexer-agent";
const agent = new IndexerAgent({
arbitrumRpcUrl: "https://arb1.arbitrum.io/rpc",
rpcDataServiceAddress: "0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078",
operatorPrivateKey: process.env.OPERATOR_KEY as `0x${string}`,
providerAddress: "0x...",
endpoint: "https://rpc.my-indexer.com",
geoHash: "u1hx",
paymentsDestination: "0x...",
services: [
{ chainId: 1, tier: 0 },
{ chainId: 42161, tier: 0 },
],
});
await agent.reconcile(); // call on a cron/interval[server]
host = "0.0.0.0"
port = 7700
[indexer]
service_provider_address = "0x..."
operator_private_key = "0x..." # signs on-chain collect() transactions
[tap]
data_service_address = "0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078"
authorized_senders = ["0x..."] # gateway signer address(es)
eip712_domain_name = "GraphTallyCollector"
eip712_chain_id = 42161
eip712_verifying_contract = "0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e"
aggregator_url = "http://dispatch-gateway:8080"
# credit_threshold = 100_000_000_000_000_000 # max unconfirmed GRT wei per consumer (default: 0.1 GRT)
# escrow_check_rpc_url = "https://arb1.arbitrum.io/rpc" # falls back to [collector].arbitrum_rpc_url
[database]
url = "postgres://user:pass@localhost/dispatch"
[chains]
supported = [1, 42161, 10, 8453]
[chains.backends]
"1" = "http://localhost:8545"
"42161" = "http://localhost:8546"
"10" = "http://localhost:8547"
"8453" = "http://localhost:8548"
[collector]
arbitrum_rpc_url = "https://arb1.arbitrum.io/rpc"
collect_interval_secs = 3600[gateway]
host = "0.0.0.0"
port = 8080
region = "eu-west" # optional — used for geographic routing
[tap]
signer_private_key = "0x..."
data_service_address = "0xA983b18B8291F0c317Ba4Fe0dc0f7cc9373AF078"
base_price_per_cu = 4000000000000 # ≈ $40/M requests at $0.09 GRT
eip712_domain_name = "GraphTallyCollector"
eip712_chain_id = 42161
eip712_verifying_contract = "0x8f69F5C07477Ac46FBc491B1E6D91E2bb0111A9e"
[qos]
probe_interval_secs = 10
concurrent_k = 3 # dispatch to top-3, first response wins
region_bonus = 0.15 # score boost for same-region providers
[discovery]
subgraph_url = "https://api.studio.thegraph.com/query/1747796/rpc-network/v0.2.0"
interval_secs = 60
[[providers]]
address = "0x..."
endpoint = "https://rpc.my-indexer.com"
chains = [1, 42161, 10, 8453]
region = "eu-west"
capabilities = ["standard"] # or ["standard", "archive", "debug"]| Phase | Status | Scope |
|---|---|---|
| 1 — Core | ✅ Complete | Contract, indexer service, gateway, TAP payments, subgraph, CI |
| 2 — Features | ✅ Complete | CU-weighted pricing, 10+ chains, geographic routing, capability tiers, metrics, rate limiting, WebSocket, batch RPC, dynamic discovery |
| 3 — Ops | ✅ Complete | Unified endpoint, indexer agent, consumer SDK, dynamic thawing period governance |
| Deployment | ✅ Complete | Contract on Arbitrum One, subgraph live, npm packages published, e2e tests passing |
See ROADMAP.md for full detail.
| 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 |
indexer-agent |
✅ /indexer-agent npm package handles register/startService/stopService lifecycle |
edgeandnode/gateway |
✅ dispatch-gateway implements equivalent logic for RPC; /consumer-sdk provides trustless alternative |
| Graph Node | ❌ Not needed — standard Ethereum clients only |
| POI / SubgraphService dispute system | ❌ Not applicable |
Apache-2.0