> For the complete documentation index, see [llms.txt](https://docs.trover.tech/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.trover.tech/engineering/rpc-cost-control.md).

# RPC cost control

## The plan

Alchemy Pay As You Go: **$0.45/M CU** to 300M/month, $0.40/M above, 10,000 CU/s. Alchemy is the only paid endpoint. It serves one thing: `eth_getLogs` at depth when every free endpoint has failed, under a 15M CU monthly ceiling.

## Measured facts, not assumptions

Probed against `robinhood-mainnet.g.alchemy.com` on 2026-09-04:

* Chain id `0x1237` (4663): Alchemy does serve Robinhood Chain.
* `eth_getLogs` limit, quoted from its own error: up to a **5,000 block range and no limit on the response size**, or **any block range with a cap of 10K logs**, and the error names the range that would have worked (`this block range should work: [0x0, 0x19dc63e]`). The walk takes that hint instead of halving blindly; every refused call is still a billed call.
* A 40,000,000-block empty range returned in 416 ms. Empty history is nearly free to cross.

Free endpoints measured from the server on 2026-09-06:

| endpoint                                                      | getLogs                                      | depth       | notes                                                |
| ------------------------------------------------------------- | -------------------------------------------- | ----------- | ---------------------------------------------------- |
| `rpc.mainnet.chain.robinhood.com` (public)                    | yes                                          | archive     | Cloudflare 403 interstitial under sustained bursts   |
| Chainstack (free tier)                                        | yes, \~128 recent blocks                     | non-archive | 20 of 20 concurrent within its window; the head pool |
| Tenderly public gateway `robinhood-chain.gateway.tenderly.co` | 2,000-block windows                          | archive     | working wss; realtime lane                           |
| blockmachine                                                  | 2,000-block windows                          | archive     | \~300 requests per 15-minute window                  |
| SolidRPC                                                      | 100-block windows                            | archive     |                                                      |
| PublicNode                                                    | no (rejects every log query)                 | recent only | working wss; cheap reads only                        |
| Dwellir (free plan)                                           | no (`403 plan does not support eth_getLogs`) | archive     | `getTransaction*` only, `rps=0.5`                    |

What the meter showed before the tiering fix (one 15-minute window, 2026-09-06, 24.4M of 26.7M CU spent in two days):

| host                                   | calls |      CU | getLogs |
| -------------------------------------- | ----: | ------: | ------: |
| robinhood-mainnet.g.alchemy.com        | 4,100 | 258,574 |   3,268 |
| rpc.mainnet.chain.robinhood.com (free) |   192 |   4,755 |      17 |
| chainstack (free tier)                 |   268 |   6,786 |      48 |

Projected 745M CU/month. The ranked pool put the paid node first because a paid node never 429s, so "stability" ranked it above every free node, and the ranking probes themselves (`net_listening`, 117 per window; 664 `eth_getBlockByNumber`) were billed. After the fix the Alchemy line is in the tens of calls per window and `net_listening` is absent.

## The pools

Endpoint lists are comma-separated; each entry may carry pipe-delimited options after the URL. Parsed by `parseEndpoints()` in `packages/core/src/rpc-pools.ts`, enforced by `pooledTransport()` in `apps/worker/src/rpc-transport.ts`:

```
https://node.example/key|rps=0.5|lanes=log|cu=15M
```

* `rps=N`: sustained requests per second. One reservation clock per URL, shared by every client in the process (the mint scanner, the sale scanner and the DEX indexer all draw from it, because the provider counts one budget). A caller that would wait more than 15 s is refused at once so the pool moves on.
* `lanes=log|head|basic`: which work the endpoint may serve. `log` is deep `eth_getLogs` and archive reads, `head` the 5-second tail polls, `basic` cheap one-off reads. Untagged serves all. An unrecognised list is ignored rather than narrowing the endpoint to nothing.
* `cu=15M` (`15000000`, `15m`, `15000k` all work): monthly compute-unit ceiling. Counted in Redis under `trover:rpc-cu:<host>:<YYYY-MM>` so it survives a restart and rolls over on its own; flushed every 10 s; primed from Redis at pool construction so a redeploy does not start at zero. Once spent the endpoint is refused and the pool falls through to a free one: the month ends degraded, not overdrawn.

The three pools per chain, from `rpc-pools.ts`:

| pool  | env                                             | falls back to                      | used by                                         |
| ----- | ----------------------------------------------- | ---------------------------------- | ----------------------------------------------- |
| log   | `ROBINHOOD_LOG_RPC_URLS` / `INK_LOG_RPC_URLS`   | public URL                         | backfill scanners, ownership walk               |
| head  | `ROBINHOOD_HEAD_RPC_URLS` / `INK_HEAD_RPC_URLS` | log pool minus `lanes=log` entries | head lanes, contract watch                      |
| basic | `ROBINHOOD_BASIC_RPC_URLS` + public URLs        | log pool minus `lanes=log` entries | block times, `eth_call`, receipts, verification |

The realtime service's `ROBINHOOD_NFT_WS_URLS` and `ROBINHOOD_NFT_ARCHIVE_RPC_URLS` accept the same suffixes but strip them before the URL reaches viem; a `|rps=0.5` suffix once travelled into the URL and made the second archive lane unusable.

## Free-first tiered transport

`pooledTransport()` builds two tiers, instantiated once per client:

1. **Free tier**: every endpoint without a `cu=` ceiling, ranked among themselves by viem (latency 0.3, stability 0.7, 5 samples every 30 s).
2. **Paid tier**: every `cu=` endpoint, unranked and never probed. It sees a request only after every free endpoint has failed it; a budget refusal there surfaces as the error.

Never nest a ranked viem `fallback` inside another `fallback`: viem re-instantiates inner transports on every request and each instance starts a probe timer. That version put 15,873 `net_listening` calls on the free node in fifteen minutes and leaked a timer per request.

## Block-time cache

A block's timestamp is immutable, so `apps/worker/src/block-time-cache.ts` keeps it in Redis with no expiry (`trover:block-time:<chainId>:<block>`) with a 50,000-entry process map in front. Before this the scanners re-read the same blocks after every restart and evicted their own history past a 20,000-entry map; that was most of the `eth_getBlockByNumber` traffic on the metered node.

## The meter

Every 15 minutes the worker logs one line per host that took a call:

```
{"host":"...","calls":N,"computeUnits":N,"windowMinutes":15,
 "projectedMonthlyCu":N,"projectedMonthlyUsd":N,"monthToDateCu":N,
 "topMethods":["eth_getLogs:12","eth_blockNumber:8"],"msg":"RPC usage"}
```

Read with `--tail`, never `--since` (it returns nothing on this host):

```bash
docker logs --tail 20000 trover-worker-1 | grep '"RPC usage"'
docker logs --tail 20000 trover-worker-1 | grep '"RPC usage"' | grep alchemy
```

How to read the projected-$ column: `projectedMonthlyUsd` is the window's compute units extrapolated to 30 days at $0.45/M, applied to **every** host, free ones included. For a free host it is what that traffic would cost on Alchemy, not a bill. The number that matters is the Alchemy row: calls should be in the tens per window, `topMethods` should be `eth_getLogs` only, and `monthToDateCu` should stay under the `cu=` ceiling. `monthToDateCu` is only populated for budgeted hosts. CU per method are Alchemy's published figures (`eth_getLogs` 75, `eth_getBlockByNumber` 16, `eth_blockNumber` 10); anything unlisted counts 20.

## Current configuration

```
ROBINHOOD_LOG_RPC_URLS=<public>,<tenderly>|rps=4,<blockmachine>|rps=0.8,<alchemy>|lanes=log|cu=15M   # measured layout on 2026-09-07; SolidRPC sits in the BASIC pool
ROBINHOOD_HEAD_RPC_URLS=<chainstack>,<publicnode>            # no Alchemy
ROBINHOOD_NFT_ARCHIVE_RPC_URLS=<free archive>,<dwellir>|rps=0.5   # getTransaction* only
ROBINHOOD_NFT_WS_URLS=<chainstack wss>,<tenderly wss>        # realtime lanes
ROBINHOOD_RPC_URL=<chainstack>                                # ordinary reads
ROBINHOOD_SIGNER_RPC_URL=<alchemy>                            # signing path only
```

The Alchemy entry is the last log fallback. Its `cu=15M` is a policy, not a measurement; set it to what you are willing to spend. The month-to-date counter was already above 15M when the ceiling was lowered on 2026-09-06, so Alchemy getLogs were refused for the rest of that cycle by design.

Not visible to our meter: Alchemy's own charge for websocket subscription notifications. The Alchemy wss was dropped from `ROBINHOOD_NFT_WS_URLS` for that reason; if the dashboard still shows spend, that is where to look.

## If you need to cut further

In order of saving per unit of pain:

1. **Slow the head lanes** from 5 s to 10 s: halves the largest call count, at the cost of the live feed being up to ten seconds behind instead of five. They are on free nodes, so this only matters if you ever move them.
2. **Slow the ownership walk** from 30 s to 60 s once the backfill is done. It is only expensive while catching up; afterwards it is a liveness check.
3. **Lower `cu=`** on the Alchemy entry.

Do not raise `ownershipFetchConcurrency` past 5 without checking the meter first; the public node answers Cloudflare challenges above that, and a challenged call that falls through is billed on Alchemy while returning nothing. Never add an endpoint to these lists without deciding its budget.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.trover.tech/engineering/rpc-cost-control.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
