> 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/verify-2026-09-05-round1-fixes.md).

# Verify — five fixes: degraded, instant drops, sold-in-place, decimals, RPC cost

## Migration this round

**No migration.** Nothing in `packages/db/migrations` changed.

## Deploy

Frontend is Vercel, on push to main. Backend:

```bash
ssh trevor-server
cd ~/trover
git pull
docker compose -f docker-compose.yml -f docker-compose.production.yml build api worker
docker compose -f docker-compose.yml -f docker-compose.production.yml up -d --force-recreate api worker
```

### One env change, on the server only

`ROBINHOOD_RPC_URL` is currently a bare Alchemy URL with no lane restriction and no CU ceiling, and it is the **first** endpoint every ordinary read tries. Split it: Alchemy keeps the signing path, Chainstack takes the reads.

```bash
cp ~/trover/.env ~/trover/.env.bak.$(date +%Y%m%d-%H%M%S)

# 1. Add, with the CURRENT Alchemy value of ROBINHOOD_RPC_URL:
#      ROBINHOOD_SIGNER_RPC_URL=<the existing Alchemy URL>
# 2. Change ROBINHOOD_RPC_URL to the Chainstack URL already in
#    ROBINHOOD_HEAD_RPC_URLS.
```

`ROBINHOOD_SIGNER_RPC_URL` falls back to `ROBINHOOD_RPC_URL` when unset, so deploying the code before editing `.env` changes nothing — but then nothing is saved either. Both must happen.

## 1. RPC spend

Three separate leaks, all now closed:

* **Ordering.** `createEvmClient` built its live transport as `fallback([...privateRpcUrls, ...publicRpcUrls])`, and viem always tries the first entry. With Alchemy as the private endpoint, every `eth_blockNumber`, `eth_getBlockByNumber`, balance and contract read billed to Alchemy while free endpoints sat idle. Reversed.
* **`net_listening`.** viem's `fallback` ranks endpoints by probing each with `net_listening` — a metered call, forever, to answer a question we never ask. Now `{ rank: false }`.
* **Block timestamps.** Cached in a process `Map` capped at 20,000, so every worker restart re-read the same blocks. A block timestamp cannot change, so it is now in Redis with no expiry (`apps/worker/src/block-time-cache.ts`), with the map kept in front as an L1.

In the Alchemy dashboard expect `net_listening` and `eth_blockNumber` to stop almost entirely, `eth_getBlockByNumber` to fall sharply and keep falling as the Redis cache fills, and `eth_getLogs` to remain — that one is what Alchemy is for, and it is already lane-restricted (`lanes=log|cu=200M`).

Cross-check against the 15-minute report the worker already emits:

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml logs --tail 4000 worker | grep "RPC usage" | tail -5
```

(`--since` is broken on this host — returns 0 lines at any window. Use `--tail`.)

Also confirm the LOG pool's free endpoint is actually serving, since Alchemy is only meant to be its fallback, and re-check the Dwellir entry in `ROBINHOOD_NFT_ARCHIVE_RPC_URLS` — its free plan cannot serve `eth_getLogs` at all.

## 2. "degraded — retry" on every collection

The API was already right: `status: "realtime"`, `headLagBlocks: 0`. The client demanded **both** the chain and OpenSea's stream be fresh, and `nft_source_health` shows `opensea_stream` stuck `connecting` on 77 collections against `connected` on 15. So a terminal whose trade feed was live called itself degraded because a supplementary orderbook stream was down.

Sales and mints come from chain logs; OpenSea only adds listings, offers and cancels. Chain-at-head is now sufficient for `realtime`.

Open three collection pages — the badge reads `realtime` while the health body still reports `openSeaConnected: false`:

```bash
curl -s 'https://api.trover.tech/v1/nft-market/collections/argonauts?chain=robinhood' | jq '.health | {status, openSeaConnected, headLagBlocks}'
```

That combination is the proof: the label changed because the rule changed, not because the stream came back.

**Not fixed here, and worth its own look:** why `opensea_stream` sits at `connecting` for 77 collections.

## 3. Instant drop resolution

A probe takes 0.4s. The sweep did 16 rows/minute across 2,220 collections — \~2.3 hours to reach any given one, which is why `stillfacesnft` read `dormant` for so long. (It has since resolved on its own: `upcoming`, opens 2026-09-06 13:00Z, 0 / 7,777.)

Two changes: opening a collection now enqueues its probe at the front of the queue, and the sweep's job id is bucketed per 30s instead of per minute — the two ticks inside a minute were deduping into one, so `NFT_DROP_REFRESH_PER_TICK` of 8 was really 8/min, not 16. Now 15 per tick = 30/min, the existing ceiling.

Pick an unprobed slug, open its page, then:

```sql
SELECT slug, lifecycle, "dropSyncStatus", "dropCheckedAt"
FROM nft_collection_markets WHERE slug = '<slug>';
```

`dropCheckedAt` should be set within seconds. And the sweep rate:

```sql
SELECT count(*) FROM nft_collection_markets WHERE "dropCheckedAt" IS NOT NULL;
```

Measured 32 → 48 over a minute before; expect \~30/min now, draining the remaining \~2,100 in about seventy minutes, once.

## 4. A sold token lights up where it already is

The row is no longer removed and re-rendered as a slim line at the bottom. It keeps its slot, artwork, rank, id and price, and turns red in place; the floor grid card gets the same treatment.

**Sold and delisted are now different things.** A listing vanishing is a fill *or* a cancellation, and the orderbook cannot tell them apart — tolerable when it was a six-pixel row nobody read, not tolerable now it lights the artwork red. The live sale feed is consulted: a matching sale in the last minute is `sold` (rose), anything else is `delisted` (grey). Both share one hook, `useJustSold`, so the panel and the grid can never disagree.

Watch a busy collection until a listing fills. Then check a cancellation reads `delisted`, not `sold`.

## 5. Two decimals

`formatTokenPrice` used `maximumFractionDigits: 4` above $1 — `$869.2137`, a hundredth of a cent. Now 2.

Deliberately unchanged below $1: the existing precision and the subscript-zeros branch stay, because a 0.000408 floor rendered as `$0.00` is a worse lie than a long number. Confirm both on the board — a four-figure price shows two decimals, a sub-cent floor still shows its subscript form.

## Not in this round

* The reveal sniper. Its own round, its own migration, its own deploy — it spends money unattended and does not belong in a batch of UI fixes.
* The 124k-block on-chain sales backfill backlog; it grinds forward on its own.


---

# 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/verify-2026-09-05-round1-fixes.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.
