> 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-08-28.md).

# Verify — session 2026-08-28

## Round 8 (Discover page — trending / new mints / market, Robinhood only)

Modeled directly on mintgo.fun (browsed it live to match the layout): a three-panel dashboard at `/nfts/discover` (both hosts, same rewrite pattern as the rest of `/nfts`), linked from the compact header nav.

**Backend**: new `GET /v1/nft-market/discover?chain=robinhood&limit=` (`apps/api/src/nft-market-routes.ts`) returns all three panels in one call, DB-backed (not a fresh OpenSea pass-through, since this needs to be cross-collection):

* `trending` — top tracked collections by `sales24h`.
* `mints` — most recent mint events across every tracked Robinhood collection (`NftMarketEvent` where `eventType: "mint"`, joined to its collection for name/image) — reuses the same event data the per-collection Mints tab (Round 7) reads, just cross-collection here.
* `market` — collections with floor/volume/sales, plus a real 24h floor % change computed from `NftFloorSnapshot` (the nearest snapshot ≥24h old vs. current floor) — this field didn't exist anywhere before; the `/nfts` leaderboard's own "24h floor change" column has been silently unavailable all along since nothing ever populated `floor_change_24h`.

**Frontend**: `apps/web/src/components/nft/NftDiscover.tsx` + route at `apps/web/src/app/(site)/nfts/discover/page.tsx`, polls the new endpoint every 15s. Market panel is client-sort-able by volume/floor/change/sales. Verified server-rendered output locally (bypassing the dev-only http→https canonicalization redirect in `middleware.ts`, unrelated to this change) — correct empty-state copy, no render errors. Real data needs the API deploy below; nothing here needs a migration.

## Round 7 (collection page layout, chart autoscale, prefs persistence, mints tab)

1. **Listings panel + chart height.** The listings `<aside>` now stretches to match the row's full height (`self-start` → `h-full`, item list wrapped in `flex-1 overflow-y-auto` so extra height doesn't leave an awkward fixed gap) instead of sizing to its own short content. Chart container bumped from 300-340px to 440-560px.
2. **Chart y-axis dominated by rare outlier sales — not a data bug.** Checked `button-presser`'s raw candle/event data directly: some sales really are $115-135 (rare items) against a \~$2 floor, all correctly converted (verified `priceNative * ethUsd ≈ priceUsd` matches exactly). The chart's default autoscale used the true min/max, so a handful of expensive sales flattened every normal \~$2 candle into a single-pixel sliver. Added a custom `autoscaleInfoProvider` on the candlestick series (`DexNftFloorChart.tsx`) that clips to the 2nd-98th percentile of candle highs/lows, unioned with the most recent 5 candles so the current price is never clipped out of view.
3. **Interval/range persistence.** `interval` (1m/15m/30m/1h) and `chartRange` (24h/7d/30d/all) now persist to `localStorage` (`trover:nft-chart-interval` / `trover:nft-chart-range`) and restore on next visit, read post-mount (not during initial render) to avoid a hydration mismatch — same pattern as `use-dex-network.ts`.
4. **Listings/Mints tab.** Listings panel now has a tab row above the sort controls; "Mints" shows mint events (thumbnail, token id, minter address, time-ago) filtered client-side from the already-fetched activity feed (`eventType === "mint"`, no backend change — `NftMarketEvent` already captures mints).

Discover Tab (mintgo.fun-style trending/new-mints/market for Robinhood) is a separate, larger feature — scoping it before building, see chat.

## Round 6 (new/hot collections showing all-zero stats — `button-presser`)

Reported: `dex.trover.tech/nfts/robinhood/button-presser` showed floor $0, 24h volume $0, owners 0, listed —, top-10-holders —, despite the collection being extremely active (sales every \~10-50s per OpenSea) and OpenSea itself showing real floor/volume/owner numbers.

**Bug 1 — the real one, and the one that actually explains the zeros.** `numeric()` (the field-extraction helper duplicated in `DexNftCollectionTerminal.tsx` and `NftCollectionTerminal.tsx`) did `Number(record[key])` without checking whether the key was present-but-null first. `Number(null) === 0`, which is finite — so any field our own indexed `NftCollectionMarket` row hadn't populated yet (an explicit `null`, not a missing key) was read as a hard `0` and short-circuited the `??` fallback chain to OpenSea's live `stats`/`collection` data, which was sitting right there in the same API response with real numbers. Fixed by skipping `null`/`undefined` values before the `Number()` call in both copies, so a not-yet-synced collection now correctly falls through to live OpenSea data instead of showing zero. This is a general fix — it very likely corrected other silently-wrong fields across both terminal components too, not just these four stat tiles.

**Bug 2 — why the collection's own sync had genuinely never completed.** `button-presser`'s `NftCollectionMarket.syncStatus` was still `"queued"` — its *initial* value — meaning the expensive full sync (`syncNftCollection`, \~50 OpenSea calls, the only thing that sets `floorNative`/`volume24hNative`/ `ownersCount`/`listedCount`/`totalSupply`/`contractAddress`) had never even been attempted, despite the collection being tracked and getting fresh lightweight sales pings (Round 4's job) the whole time. Root cause: every NFT sync job — the expensive per-collection full sync, the round-robin, the 20s recent-sales job, the 30s on-chain fallback — shared the same `scheduled` BullMQ queue as unrelated trading/cron jobs, at `concurrency: 3` total. A hot/newly-tracked collection's one-off sync job could sit behind that shared pool indefinitely. This also explains why holders never populated: `indexNftOwnership` only runs *after* a full sync sets `contractAddress`, so it was blocked on the same starvation.

Fixed by giving NFT market sync its own dedicated BullMQ queue (`trover-nft-market`, `QUEUE_NAMES.nftMarket`) with its own worker at `concurrency: 5`, separate from `scheduled`. `trackCollection()` (API) now enqueues onto it; `syncTrackedNftMarkets`/`syncRecentNftSales`/ `indexOnchainNftSales` schedulers (worker) now register on it. Trading/cron jobs on `scheduled` can no longer be blocked by NFT sync load, and vice versa — a newly-hot collection's first sync can't get stuck behind unrelated work anymore.

## Round 5 (checkout UI trim, last-sale wrong-token fix, activity feed enrichment, realtime wiring)

**1. Checkout estimate panel trimmed.** `EstimateBreakdown` (in `DexNftCollectionTerminal.tsx`, the pre-quote "quick trade" sidebar) now shows only item price / subtotal / native total / USD total — dropped marketplace fee, creator fee, and estimated gas from the display. The backend `/estimate` response is unchanged; those fields just aren't rendered there anymore. (Two other, differently-labeled cost breakdowns elsewhere in the app were left alone — the request was a specific match for this one panel's copy.)

**2. "Last sale" badge was structurally unable to show the right token — now fixed.** Root cause: the badge was derived from the **candle** series (30-minute buckets by default), which carries a price and a bucket-start time but no `tokenId`. It could never actually reflect which token sold — it just showed whichever bucket happened to contain a sale, off by up to 2x the bucket interval. `GET /v1/nft-market/collections/:slug` now returns an authoritative `lastSale` object (`tokenId`, `occurredAt`, `priceNative`, `priceUsd`, `transactionHash`, `imageUrl`) sourced directly from the most recent accepted `sale`-type `NftMarketEvent` row. The chart badge now shows `last sale #<tokenId> · <price> · <time ago>` from that field instead of scanning candles.

**3. Activity feed: thumbnails, time-ago, hover traits.** `NftMarketEvent` already stores OpenSea's full raw event payload in `providerPayload` (`image_url`/`display_image_url`/`traits` included) — none of it was previously being surfaced. `GET /v1/nft-market/collections/:slug/activity` now extracts `imageUrl` and `traits` per row (no schema migration, no new OpenSea calls — it's data already on disk). The activity panel now renders a square rounded thumbnail per row, a relative time-ago label (new shared `apps/web/src/lib/time-ago.ts`, extracted from the chart component), and a hover popover on the thumbnail showing the token id and trait list — falling back to a lazy per-token fetch against the existing `/v1/nft-market/assets/:chain/:contract/:tokenId` route (cached by tokenId in component state) for the rare event whose payload didn't carry traits.

**4. Realtime wiring — already built, now actually live.** Turned out the frontend already had a working WebSocket subscription to `/v1/nft-market/collections/:slug/stream`, and the worker's OpenSea push handler (`ingestStreamEvent`) already stored events, rebuilt candles, and published `market_event` on receipt — all gated behind `OPENSEA_STREAM_ENABLED`, which defaulted off and was never set anywhere in the compose files. **You've now set `OPENSEA_STREAM_ENABLED=true` directly on the server env.** Once `worker` is redeployed with that in place, new sales push from OpenSea to our DB to the browser in close to real time, instead of waiting on the 20s polling job from Round 4. No code change was needed for this part — just the env flag + redeploy.

## Round 4 (last-sale staleness — root cause + the actual fix)

**You checked rhmachines just now and it was still \~3-4h stale on the live site.** That's expected, not a new failure: none of this session's backend fixes (Round 2's jobId dedup fix, the on-chain Seaport fallback from earlier in the session, this round's fix below) can be live until you redeploy `api`/`worker` — they're all pushed to `main`, none deployed yet. Deploy first, then re-check before concluding anything is still broken.

**What I found checking rhmachines directly (curl against the live API):** `sync.coverageEnd` was \~3h20m behind `observedAt`, and — more importantly — the last 100 activity rows for that collection had **zero** with `onchainVerified: true` or a `transactionHash`, ever. That's the on-chain Seaport-log fallback (built earlier this session specifically to catch sales within 30s regardless of the OpenSea sync backlog) showing no sign of having ever recorded anything for this collection. I can't see worker logs from here to confirm whether it's erroring, never actually deployed, or genuinely finding no matching on-chain logs — that needs a `docker compose logs worker | grep -i "onchain"` check on your end after deploying.

**The deeper, permanent fix (this round):** `syncTrackedNftMarkets` — the job that's supposed to keep every tracked collection's data fresh — does a full, expensive sync per collection (collection detail + stats + up to 25 pages of listings + up to 25 pages of offers + events: up to \~50 OpenSea calls) and only gets through **24 collections, serially, per 60-second tick**. Every collection ever searched/browsed/viewed stays in that same rotation forever. With enough tracked collections, the round trip back to any one of them stretches to tens of minutes to hours — that's the actual, permanent mechanism behind "last sale is stale," and it gets worse over time as more collections get tracked, not better.

Added a second, much cheaper job (`syncRecentNftSalesForAllTracked` / `nft-recent-sales-sync`, `apps/worker/src/nft-market-indexer.ts`) that does **one lean events call per collection** (no listings/offers/stats/holders/ traits) across **every** tracked collection, with concurrency 10, every 20 seconds. This is what actually bounds how stale "last sale" can ever be — independent of collection count, and independent of whether anyone is viewing that specific page right now. The existing expensive full sync still runs on its own slower cadence for floor/listed/owners/holders/traits, which don't need second-by-second freshness the way "last sale" does.

## Round 3 (explorer domain swap)

Robinhood-chain explorer links (transaction confirmations, holder addresses, wallet rows) now default to `https://rh-scan.com` instead of the old blockscout instance, in code (`BLOCKSCOUT_BASE_URL`, `EXECUTION_CHAINS` Robinhood `explorerUrl`, and the three frontend fallback constants).

**Check your env files for an explicit override before assuming this took effect** — if the server's `.env` sets `BLOCKSCOUT_BASE_URL` explicitly, or Vercel's project env sets `NEXT_PUBLIC_EXPLORER_URL` explicitly, those win over the code default and still point at the old explorer until you update them there too. If neither is explicitly set anywhere, this ships automatically (frontend via Vercel now, backend once you redeploy `api`/`worker`).

## Commands

```bash
cd /Users/user/Desktop/trover-dex-production
pnpm --filter @trover/web typecheck            # already run clean
pnpm --filter @trover/api typecheck            # already run clean
pnpm --filter @trover/integrations typecheck   # already run clean
pnpm --filter @trover/web build
```

## Deploy

`apps/api` and `packages/integrations` changed (new "new" discovery mode) — needs an API rebuild. `apps/web` is frontend-only, ships via Vercel. No schema/migration change.

```bash
cd /home/teztap/trover
git pull --ff-only origin main
docker compose -f docker-compose.yml -f docker-compose.production.yml build api
docker compose -f docker-compose.yml -f docker-compose.production.yml up -d --force-recreate api
```

## What shipped

1. **Floor overlay on by default** on the NFT chart (was off).
2. **Items grid capped** to `60vh` with internal scroll instead of growing the page indefinitely — genuinely screen-size-relative, not a fixed pixel guess.
3. **ETH icon**: swapped the green "◆" glyph for your `ethlogo.png` in the live feed and items grid. Copied to `apps/web/public/chains/eth.png`.
4. **Robinhood logo**: swapped the hand-drawn SVG mark for your `robinhood.png` in the network switcher. Copied to `apps/web/public/chains/robinhood.png`.
5. **Last sale badge**: now shows a star icon, USD in parens, and relative time ("15m ago"), computed from the live ETH/USD rate already on the page (no new API call).
6. **Chart auto-fit robustness**: the "sometimes bugs out" zoom was almost certainly the chart fitting its content before the surrounding grid layout (chart + feed + trade panel, which I moved around last session) had finished settling. It now re-fits across a short settle window after mount/resize instead of once, immediately — a manual zoom after that window is left alone.
7. **"ALL" network option**: header network switcher now lists "All networks" first, then Robinhood/Stable/BNB/X Layer/Ink. Selecting a specific network filters the homepage leaderboard to it (already worked); selecting "All" drops the chain filter entirely instead of defaulting to Ethereum.
8. **`/nfts` page rebuilt** (this is `dex.trover.tech/nfts` — same component also serves trover.tech's `/nfts`, they're not separate pages): the old card grid is now a proper leaderboard table (rank, floor with USD, 24h volume, owners, listed, 24h floor change), matching the borderless-row treatment used everywhere else this session. New filter tabs: Trending / Top / Newly launched / Owned. "Newly launched" is new — OpenSea's collections list sorted by creation date (`order_by=created_date`), added as mode `"new"` on `GET /v1/nft-market/collections`. The owned/portfolio view, search, and all the sweep/offer/list/transfer trading modals are untouched — this only replaced the browse/discovery section.

## Note on the pnpm scare mid-session

While chasing a stale workspace-package link I ran `pnpm store prune`, which broke the global store and cascaded into unrelated typecheck failures in files I hadn't touched (`trading-routes.ts`, `trading.ts`, `transfers.ts`). Fully repaired via `rm -rf node_modules` (root + all packages) + `pnpm install`

* `pnpm --filter @trover/db generate` — all packages typecheck clean now, confirmed individually. I won't run `store prune` again. If anything looks off after you pull, a full reinstall on your end (`rm -rf node_modules && pnpm install`) is the safe reset, though CI/your normal install should be unaffected since this was a local `node_modules` state issue, not a lockfile or committed-code issue.

## Round 2 (same day, after your testing)

### 1. `/nfts` leaderboard showed names but no floor/volume/owners/listed

Root cause: `NftMarketplace.tsx` rendered OpenSea's raw `/collections`, `/collections/top`, `/collections/trending` objects directly — those never carry floor/volume/owner/listed data (confirmed by hitting the API directly; OpenSea's list endpoints only return identity/description fields). The numbers were sitting unused in the same response's `normalized` array (our DB-indexed data). Fixed by building a `chain:slug` lookup from `normalized` and merging it into both the leaderboard table and the quick-actions modal.

### 2. The live listings feed showed all listings at once (e.g. 17 rows)

Capped to 4 rows per page with a prev/next pager (chevron buttons + "1 / 5" counter) instead of one long list. The panel no longer stretches to match the chart column's height — it sizes to its own content now.

### 3. The floor/ceiling line on the NFT chart was too thin against candles

Bumped both dashed price lines (the persistent floor overlay and the drag-only order/ceiling line) from `lineWidth: 1`/`2` to `3`, and raised the floor overlay's opacity from `.6` to `.85`.

### 4. Stale last-sale/price data (chart showed "1h ago" for a sale OpenSea

already listed as "1m ago") This was the important one. `trackCollection()` (called on every collection page view and every `/candles` poll) enqueues a BullMQ sync job with a **stable** jobId: `nft-sync:{chain}:{slug}`. BullMQ dedupes by jobId across every state — including completed jobs, which `removeOnComplete: {count:100}` keeps around instead of freeing immediately. Net effect: after a collection's very first sync, every later "resync" call from viewing its page was a silent no-op, forever (until unrelated queue churn evicted it from the completed-jobs window at random). The only thing keeping any given collection fresh was the blind `syncTrackedNftMarkets` round robin — 24 collections synced per 60s tick, cycling through however many collections have ever been tracked. With enough tracked collections (every trending/top/new/search hit tracks up to 24 more), that cycle stretches to tens of minutes — matches the \~49 minute gap you saw almost exactly.

Fixed by salting the jobId with a time bucket (`nft-sync:{chain}:{slug}:{bucket}`), so a fresh job can actually be enqueued again once the bucket rolls over — 20 seconds for the two hot paths (collection detail page, candle polling), 5 minutes for background list/search discovery tracking. The Redis lock in `syncNftCollection` still prevents overlapping runs of the same collection, so this doesn't add load beyond one extra sync roughly every 20s per **actively viewed** collection.

**This alone won't get you sub-minute freshness on its own** — OpenSea's Events API plus the on-chain Seaport-log fallback are still polling-based. For push-based, near-instant updates, the other lever is turning on OpenSea's websocket stream, which is coded and wired (`ensureStream()` subscribes any collection right after it syncs) but **defaults off** (`OPENSEA_STREAM_ENABLED=false`) and nothing in this repo's compose files turns it on — so it's almost certainly off in production right now (`lastStreamAt` was `null` for hopium-machines when I checked). Recommend adding `OPENSEA_STREAM_ENABLED=true` to the server's env file this deploy.

## Deploy (round 2)

```bash
cd /home/teztap/trover
# add to your env file if not already present:
#   OPENSEA_STREAM_ENABLED=true
git pull --ff-only origin main
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
```

No schema/migration change. `apps/web` ships via Vercel automatically.

## Check live (round 2)

1. `/nfts`: floor/24h volume/owners/listed columns populated for every row, not just names.
2. NFT collection page: listings feed shows at most 4 rows with a "1 / N" pager at the bottom; prev/next buttons page through the rest.
3. Chart floor line and ceiling (drag) line are visibly thicker/brighter against the candles.
4. Open a collection, wait \~20-30s, refresh: `sync.coverageEnd` (visible in the `/collections/:slug` API response, or just watch the last-sale badge) should track much closer to real time than before. Full effect depends on `OPENSEA_STREAM_ENABLED=true` being set on the server.

## Check live

1. NFT collection page: floor overlay visible by default; items grid stops growing past \~60% of viewport height and scrolls internally; ETH icon is your logo, not a green diamond; last-sale badge shows star + USD + "Xm ago".
2. Header network switcher: "All networks" appears first; selecting it on the homepage shows markets across every chain; selecting Robinhood/etc filters to just that one (as before).
3. `dex.trover.tech/nfts`: leaderboard table with working tabs, "newly launched" shows recently created collections, click a row to open its terminal, existing quick-actions modal still opens correctly.


---

# 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-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.
