> 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/nft-realtime-rollout-2026-08-28.md).

# NFT realtime operator rollout — August 28, 2026

## Current state (2026-09-08)

The rollout below is complete and this file is the record of it. What is different now:

* **Providers.** The two websocket lanes are Chainstack and Tenderly, not Alchemy and QuickNode; the Alchemy wss was dropped for its subscription charges. Lanes are named by provider host, `robinhood_wss:<provider>` (`chainstack`, `tenderly`, `publicnode`, `alchemy`, `quicknode`, `robinhood-public`, else `provider-N`), so two lanes never share a cursor (`nft-live-head:<lane>`) or a health row. Both lanes see every fill; only the first fetches the receipt and block, the second reuses them.
* **Endpoint lists.** `ROBINHOOD_NFT_WS_URLS` and `ROBINHOOD_NFT_ARCHIVE_RPC_URLS` accept the same `|rps=|cu=|lanes=` suffixes as every other pool list and strip them before the URL reaches viem. Before that, a `|rps=0.5` suffix travelled into the URL and the second archive lane never worked.
* **Verification.** A fill the lane decoded from a chain log is stored with `onchainVerified=true`; there is no second RPC lookup. Where a lookup does happen, the verdict is `true` / `false` / `null`, and `null` (no node could answer) is accepted, not rejected. About 85% of live fills had been stored as rejected under the old rule; the `nft-rejected-sale-repair` scheduler (every 2 minutes) re-accepted them and now exits at once when nothing is pending.
* **Shadow mode is off** and `NFT_REALTIME_COLLECTIONS` is no longer a small allowlist in practice; readiness reports `watched` in the thousands.
* **Prisma pool** in `docker-compose.nft-realtime.yml` is `connection_limit=30&pool_timeout=20` (the default 17 dropped fills).
* **Deploy.** The service lives only in its own compose file, so every worker deploy must pass `-f docker-compose.nft-realtime.yml` and name `nft-realtime`, or it keeps running the old image. `docker compose logs --since` returns nothing on this host; use `--tail`. See `runbook.md`.
* The browser canary is stopped; the worker drops every `nft-canary-reconcile*` job.

This runbook is intentionally operator-run. The implementation agent does not apply migrations, modify VPS secrets, or start production services.

## Required protected configuration

Set these in the VPS `.env` without committing or printing their values:

```dotenv
NFT_REALTIME_ENABLED=true
NFT_REALTIME_SHADOW_MODE=true
NFT_REALTIME_COLLECTIONS=button-presser
OPENSEA_ENABLED=true
OPENSEA_STREAM_ENABLED=true
OPENSEA_API_KEY=<server-side OpenSea API key>
ROBINHOOD_NFT_WS_URLS=<Alchemy Robinhood WSS>,<QuickNode Robinhood WSS>
ROBINHOOD_NFT_ARCHIVE_RPC_URLS=<Alchemy Robinhood HTTPS>,<QuickNode Robinhood HTTPS>
```

The WSS and HTTPS lists must contain two endpoints in matching order. An "archive RPC" here is an HTTPS endpoint used for historical logs, receipts, block timestamps, reconnect repair, and reorg verification; it is not a separate database product. Two managed providers are preferred. During initial rollout, an existing managed Alchemy endpoint plus Robinhood's public endpoint can be used, with the public endpoint treated as a rate-limited fallback. Existing `DATABASE_URL`, `DIRECT_URL`, and `REDIS_URL` remain required.

`NFT_REALTIME_COLLECTIONS` is the small always-hot allowlist. Other collections are subscribed only while an active terminal watch lease exists; opening a terminal acquires the lease and the connected client renews it. Trending or priority rank alone does not consume a realtime subscription.

The optional browser canary reads either a JSON cookie export or a raw Cookie header only from a read-only Docker secret. Never put the cookie value in `.env`, a command argument, PostgreSQL, Redis, logs, Git, or chat.

## Backup and migration

From the production repository:

```bash
git pull --ff-only origin main
mkdir -p backups
docker compose -f docker-compose.yml -f docker-compose.production.yml exec -T postgres \
  pg_dump -U "${TROVER_DB_USER:-trover}" -d "${TROVER_DB_NAME:-trover}" -Fc \
  > "backups/trover-before-nft-realtime-$(date -u +%Y%m%dT%H%M%SZ).dump"
docker compose -f docker-compose.yml -f docker-compose.production.yml run --rm migrate
```

Run the read-only duplicate report and inspect its output:

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml exec -T postgres \
  psql -U "${TROVER_DB_USER:-trover}" -d "${TROVER_DB_NAME:-trover}" \
  -f /dev/stdin < packages/db/ops/nft-realtime-duplicate-report.sql
```

Only if the report contains duplicates, run the data-preserving quarantine:

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml exec -T postgres \
  psql -v ON_ERROR_STOP=1 -U "${TROVER_DB_USER:-trover}" -d "${TROVER_DB_NAME:-trover}" \
  -f /dev/stdin < packages/db/ops/nft-realtime-quarantine.sql
```

## Build and start shadow mode

```bash
docker compose \
  -f docker-compose.yml \
  -f docker-compose.production.yml \
  -f docker-compose.nft-realtime.yml \
  build api worker nft-realtime
docker compose \
  -f docker-compose.yml \
  -f docker-compose.production.yml \
  -f docker-compose.nft-realtime.yml \
  up -d api worker nft-realtime
docker compose \
  -f docker-compose.yml \
  -f docker-compose.production.yml \
  -f docker-compose.nft-realtime.yml \
  ps
docker compose \
  -f docker-compose.yml \
  -f docker-compose.production.yml \
  -f docker-compose.nft-realtime.yml \
  logs --since=10m --no-log-prefix nft-realtime
```

Check service and public collection health:

```bash
docker compose \
  -f docker-compose.yml \
  -f docker-compose.production.yml \
  -f docker-compose.nft-realtime.yml \
  exec -T nft-realtime node -e \
  "fetch('http://127.0.0.1:3003/health/ready').then(async r=>{console.log(r.status,await r.text());process.exit(r.ok?0:1)})"
curl -fsS 'https://api.trover.tech/v1/nft-market/collections/button-presser/health?chain=robinhood' | jq
```

Keep shadow mode running until Alchemy, QuickNode, and OpenSea agree on Button Presser events and the measured latency is acceptable. Shadow observations do not affect candles, floor, listings, quotes, or execution.

## Enable canonical writes

Change only:

```dotenv
NFT_REALTIME_SHADOW_MODE=false
```

Then recreate the realtime service:

```bash
docker compose \
  -f docker-compose.yml \
  -f docker-compose.production.yml \
  -f docker-compose.nft-realtime.yml \
  up -d --force-recreate nft-realtime
```

Validate Button Presser for at least one hour before expanding `NFT_REALTIME_COLLECTIONS`. The old block scanner remains reconciliation-only and must not be treated as a realtime source.

## Optional browser canary

Place the authorized JSON cookie export or raw Cookie header in a protected file and restrict it:

```bash
sudo install -m 0400 -o root -g root /path/to/export.json /etc/trover/opensea-browser-session.json
export OPENSEA_BROWSER_COOKIE_HOST_FILE=/etc/trover/opensea-browser-session.json
```

Start only the canary overlay:

```bash
docker compose \
  -f docker-compose.yml \
  -f docker-compose.production.yml \
  -f docker-compose.opensea-canary.yml \
  up -d --build opensea-browser-canary
```

Canary observations are always provisional. They trigger official REST/order reconciliation and never affect executable quotes or authoritative market state directly. Revoke the session immediately if it is exposed or no longer needed.


---

# 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/nft-realtime-rollout-2026-08-28.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.
