> 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-04-nft-identity-columns.md).

# Verify — filling the trending board's blank identity columns

## There is NO migration this round

Nothing in `packages/db` changed. Services that changed: **api + worker**. Web ships via Vercel on push to main.

```bash
cd ~/trover
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
```

## What was actually wrong

The board's Supply / Unique Holders / Listed / Floor columns read `—` on \~94% of trading collections. Measured before this round, on robinhood:

|                        | collections | with supply | owners | listed | floor |
| ---------------------- | ----------- | ----------- | ------ | ------ | ----- |
| all                    | 1,219       | 188         | 184    | 200    | 166   |
| **traded in last 24h** | **572**     | **32**      | 32     | 33     | 32    |

**1,017 of 1,219 sat at `syncStatus: "queued"`.** Not because a job was broken — because of a gate that was correct until the board existed. `syncTrackedNftMarkets` selects collections holding a **watch lease**, and `indexNftOwnership` — the provider-free walk that reconstructs holders and supply from ERC-721 `Transfer` logs — only ever ran as the tail of that sync. A collection auto-registered by the chain scanners has no lease, so it was never walked, however heavily it traded. That was fine while a collection was only visible once someone opened its terminal. The trending board publishes collections nobody has opened, so "nobody is watching" stopped meaning "nobody can see it".

## What changed

1. **The walk is driven by trading, not by watch leases** — new `index_nft_ownership` job, every 30s, 6 collections a tick on a sliced 24s budget. Half the slots go to the busiest collections (so the visible rows converge first), half to whichever have waited longest (so the tail is never abandoned).
2. **The walk now writes `ownersCount`**, not just `mintedSupply`. It was populating `nft_ownerships` and stopping, leaving the column the provider sync sets null on every collection the sync never visited.
3. **Adaptive window, parallel fetch.** Measured on the busiest robinhood collection: 100k blocks → 4,069 logs in 1.6s; 500k → refused, over the provider's 10,000-log ceiling. The window now halves on refusal and doubles when a window comes back under 1,500 logs, four windows in flight, applied strictly in order.
4. **Walk starts at genesis**, and `findDeploymentBlock` is gone. Its binary search cost \~26 *sequential* `getCode` calls — on a paced endpoint longer than a collection's whole slice of the tick — and bought nothing, because the widening crosses an empty prefix in a few parallel rounds.
5. **The walk uses the paced pool.** It was on a raw `http()` endpoint. It is now the largest source of log reads we make, so leaving it outside the per-endpoint budget would have spent the metered provider's quota on ownership and starved the scanners.
6. **A per-collection Redis lock**, because the walk now has two callers whose schedules are independent. Balances are accumulated, not recomputed, so a double-applied window would persist rather than wash out.
7. **Seaport counterparties are recorded.** `offerer` and `recipient` were decoded and then dropped, so every chain-scanned sale had `maker: null, taker: null` — the Live Sales rail's buyer was always blank. A filled listing has the NFT in the offer, so the maker is the seller and the fulfiller is the buyer; an accepted bid is the mirror, with the buyer read off the NFT's own consideration recipient.
8. **A supply floor while the walk catches up.** The worker fills `mintedSupply` from distinct minted token ids for trading collections that have none, a few per tick. It is a **lower bound** — the scanners' cursors may not have reached the contract — so the API marks it `supplySource: "mints"` and the board renders it as `≥12,345`. `"walk"` means the exact figure.

## The walk has its own queue

It was first shipped onto `nft-market`, and that queue was already saturated: `wait=35 active=5`, because it also carries the head scanners that run every five seconds. A tick holds a slot for \~25s, so the walk ran every **4.6 minutes** instead of every 30s, and took a slot away from the scanners in return. It now has `trover-nft-ownership` to itself at concurrency 1. If you are looking for the job, that is where it is:

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml exec -T redis \
  redis-cli llen bull:trover-nft-ownership:wait
```

Should sit at 0 or 1. A growing number means ticks are overrunning the 30s schedule.

## Expected timing

Simulated against the live chain on 2026-09-04, walking all \~54M blocks from zero:

| collection          | wall clock | RPC calls | Transfer logs          |
| ------------------- | ---------- | --------- | ---------------------- |
| the-artificial-inus | 4.5s       | 40        | 1,683 (99.7% of chain) |
| honzomomorobinhood  | 3.4s       | 28        | 7,843 (100%)           |
| ponsgirl            | 3.9s       | 31        | 0 (90%)                |

At \~4s per collection and 6 per 30s tick, the \~570 trading collections should be reconstructed in roughly **45–60 minutes**. Production will be slower than the simulation where the paced endpoint is picked.

## Confirm it

**1. The walk is running.**

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml logs --tail 3000 worker \
  | grep -c "NFT ownership walk advanced trading collections"
```

Non-zero within a minute of the restart. Use `--tail`, never `--since` — on this host `--since` returns 0 lines at any window.

**2. Coverage climbs.** Run this at deploy and again an hour later; every column should rise, `supply` and `owners` fastest.

```bash
cat > /tmp/coverage.sql <<'SQL'
SELECT count(*) AS traded_24h,
       count(*) FILTER (WHERE c."mintedSupply" IS NOT NULL) AS supply,
       count(*) FILTER (WHERE c."ownersCount"  IS NOT NULL) AS owners,
       count(*) FILTER (WHERE c."listedCount"  IS NOT NULL) AS listed,
       count(*) FILTER (WHERE c."floorNative"  IS NOT NULL) AS floor
FROM nft_collection_markets c
WHERE c.chain = 'robinhood'
  AND EXISTS (SELECT 1 FROM nft_market_events e
              WHERE e."collectionId" = c.id AND e.accepted
                AND e."eventType" = 'sale' AND e."occurredAt" >= now() - interval '24 hours');
SELECT count(*) AS ownership_cursors FROM stabledex_indexer_cursors WHERE source LIKE 'nft-owners:%';
SQL
docker compose -f docker-compose.yml -f docker-compose.production.yml cp /tmp/coverage.sql postgres:/tmp/coverage.sql
docker compose -f docker-compose.yml -f docker-compose.production.yml exec -T postgres \
  sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /tmp/coverage.sql'
```

Baseline before this round was **572 traded / 32 supply / 32 owners / 33 listed / 32 floor**.

**3. Counterparties on new sales.** Only rows scanned *after* the restart carry them; existing rows are not backfilled.

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml exec -T postgres \
  sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT count(*) total, count(taker) with_taker, count(maker) with_maker FROM nft_market_events WHERE source = '"'"'onchain_seaport_scan'"'"' AND \"createdAt\" >= now() - interval '"'"'10 minutes'"'"';"'
```

`with_taker` and `with_maker` should track `total`.

**4. The board.** `curl -s 'https://api.trover.tech/v1/nft-market/trending?chain=robinhood&window=24h&limit=5' | python3 -m json.tool` — rows should start showing `mintedSupply`, `owners` and a `supplySource`.

**5. RPC budget did not blow up.** The walk is new load on the same pool:

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml logs --tail 4000 worker \
  | grep -ci "429\|rate limit"
docker compose -f docker-compose.yml -f docker-compose.production.yml logs --tail 4000 worker \
  | grep -ci "budget exceeded"
```

Last round these were **1** and **0**. Some rise is expected; sustained hundreds means `ownershipCollectionsPerTick` or `ownershipFetchConcurrency` in `apps/worker/src/nft-market-indexer.ts` is too high for the plan.

## What is still blank, and why

**`listedCount` and `floorNative` are not fixed by this round and cannot be.** They come from `nft_order_snapshots`, which is populated only from OpenSea's orderbook. Seaport listings live off-chain until they are filled — there is no on-chain orderbook to read — so a listing count and a floor genuinely require either the provider (currently `openSeaConnected: false` chain-wide) or our own order collection. Nothing here fakes them; they stay `—`.

Also unchanged: `trover-nft-realtime` reports `Up (unhealthy)`, and OpenSea metadata quota is exhausted.


---

# 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-04-nft-identity-columns.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.
