> 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-mint-lifecycle.md).

# VERIFY-2026-09-05-mint-lifecycle

## Verify — mint lifecycle, drop schedules, and the missed sale

### Migration this round

**Yes.** `packages/db/migrations/20260905120000_nft_mint_lifecycle/` adds thirteen nullable columns and three indexes to `nft_collection_markets`. The table holds a few thousand rows and every column is nullable or has a constant default, so this is a metadata-only change — no table rewrite. It does **not** touch `nft_market_events`.

### Deploy

```bash
ssh trevor-server
cd ~/trover
git pull

# Build first. `run --rm migrate` on a stale image silently applies nothing.
docker compose -f docker-compose.yml -f docker-compose.production.yml build migrate api worker
docker compose -f docker-compose.yml -f docker-compose.production.yml run --rm migrate
docker compose -f docker-compose.yml -f docker-compose.production.yml up -d --force-recreate api worker
```

Frontend is Vercel and deploys itself on push to main.

#### Still outstanding from last round

`trover-opensea-browser-canary-1` was built 2026-08-29 and still refills the NFT queue with failing OpenSea syncs. Rebuild it — it shares the OpenSea rate budget the new drop sync needs:

```bash
docker compose -f docker-compose.yml -f docker-compose.opensea-canary.yml build opensea-browser-canary
docker compose -f docker-compose.yml -f docker-compose.opensea-canary.yml up -d --force-recreate opensea-browser-canary
```

### 1. The missed sale — the most important check

A real Seaport sale of **City of Stonks** at block **55,088,589** (`0xd592f66c…`) never reached the product. It was not dropped by a decode bug; it fell into a structural hole.

Both scan lanes shared one cursor, and the head lane committed it only while "contiguous". Once the shared cursor fell outside the 5,000-block catch-up window — it was **124,223 blocks behind**, about 3.4 hours — the head lane had no memory at all. Every five-second tick became an independent peek at the newest 96 blocks, and everything between two peeks went unread until the backfill lane arrived hours later. Robinhood Chain produces roughly ten blocks a second, so 96 blocks is under ten seconds of chain against a five-second tick. That sale sat at head−2,136: outside the peek, and 122,000 blocks ahead of the backfill cursor.

Each head lane now keeps its **own** cursor and commits it every tick, so consecutive ticks are contiguous with each other while the backfill cursor keeps owning history.

The head window itself stays at 96 blocks deliberately — it is sized to fit the shallow endpoint's \~128-block serving window, and it is now only a cold-start fallback.

After deploy, the new cursors must exist and track head:

```sql
SELECT source, "nextBlock", "updatedAt"
FROM stabledex_indexer_cursors
WHERE source LIKE 'nft-onchain-%-head:%' ORDER BY source;
```

Both `nft-onchain-sales-head:robinhood` and `nft-onchain-mints-head:robinhood` should appear within a minute and stay within a few hundred blocks of head. Compare against head:

```bash
ssh trevor-server "cd ~/trover && url=\$(grep -m1 '^ROBINHOOD_LOG_RPC_URLS=' .env | cut -d= -f2- | cut -d, -f1 | cut -d'|' -f1); \
  curl -s -X POST \"\$url\" -H 'content-type: application/json' \
  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}'"
```

Then confirm live mints came back — they had stopped again. In the last 15 minutes there were **zero** `mint | live` rows while sales were flowing:

```sql
SELECT "eventType", phase, source, count(*), max("occurredAt") AS newest
FROM nft_market_events WHERE "createdAt" > now() - interval '15 minutes'
GROUP BY 1,2,3 ORDER BY 4 DESC;
```

Expect both `sale | live | onchain_seaport_scan` **and** `mint | live | onchain_mint_scan` with `newest` within a minute of now.

Finally, the reported sale itself, once the backfill lane reaches it:

```sql
SELECT "eventType", phase, source, "occurredAt" FROM nft_market_events
WHERE "transactionHash" ILIKE '0xd592f66ccc68c47a3d61717bdf2bf41412250dab588be670eaf9729edbfa55f7';
```

Note the backfill cursor is still \~3.4 hours behind; the fix stops *new* sales falling in the hole, it does not fast-forward the backlog.

### 2. Drop schedules

`openSea.drops()` and `openSea.drop(slug)` existed in the client with zero call sites. They are now the source of every countdown.

```sql
SELECT slug, lifecycle, "dropIsMinting", "dropNextStageStart",
       "dropMintedSupply", "dropMaxSupply", "dropSyncStatus"
FROM nft_collection_markets
WHERE chain = 'robinhood' AND "dropSyncStatus" = 'ok'
ORDER BY "dropNextStageStart" NULLS LAST LIMIT 20;
```

`stillfacesnft` must read `lifecycle = 'upcoming'`, `dropNextStageStart = 2026-09-06 13:00Z`, `dropMaxSupply = 7777`. It is the only one of these that exists today. `world-wise-wizards-nfts`, `trust-us-nft` and `robbin-hood-2026` have **no row at all** right now — after the discovery pass they must exist, which is the point of auto-adding upcoming drops.

Watch the OpenSea budget for one full cycle before trusting it. Enumeration is three calls per chain every five minutes plus at most `NFT_DROP_REFRESH_PER_TICK` (8) detail reads per 30s tick, capped by a Redis token bucket at `NFT_DROP_CALLS_PER_MINUTE` (30) and paused for five minutes on any 429:

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml logs --tail 4000 worker \
  | grep -c "OpenSea rate limit"
```

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

### 3. Lifecycle is one definition now

It was three: 15 minutes on the collection detail route, 5 on the trending board, 60 on discover. All three now call `deriveNftLifecycle` in `@trover/domain`, with a single 10-minute mint-recency window.

```bash
curl -s 'https://api.trover.tech/v1/nft-market/collections/stillfacesnft?chain=robinhood' \
  | jq '.mint | {lifecycle, active, startsAt, secondsToStart, soldOut, maxSupply, stages: (.stages|length)}'

curl -s 'https://api.trover.tech/v1/nft-market/trending?chain=robinhood&lifecycle=upcoming&limit=10' \
  | jq '.rows[] | {slug, lifecycle, mintStartsAt, secondsToStart}'
```

The `upcoming` tab returning rows is the proof that matters: the default board ranks on sale events with `HAVING COUNT(*) > 0`, which structurally excludes every collection that has never sold — every upcoming drop and every first mint. That path is untouched for `lifecycle=all`; the tabs use a separate collection-led query.

Before merging further work on it, check the plan:

```sql
EXPLAIN (ANALYZE, BUFFERS) <the lifecycle-tab query with chain='robinhood', lifecycle='{upcoming}'>;
```

It must be an index scan on `nft_collection_markets_chain_lifecycle_idx` with a candidate set in the tens. This endpoint has already caused one 64-second outage; the lateral joins are only cheap because that index bounds the input.

### 4. Sold-out is only ever answered by drop data

`mintedSupply >= totalSupply` read from our own columns is `x >= x`: the provider sync writes one number into both (`nft-market-indexer.ts:975`). Only `dropMintedSupply` vs `dropMaxSupply` may decide it, and with no drop record `soldOut` returns `null` — unknowable — never `false`.

### 5. Perf (already deployed)

```js
performance.getEntriesByType('resource')
  .filter(r => r.name.includes('/nft-market/collections/'))
  .map(r => [new URL(r.name).pathname, Math.round(r.startTime), Math.round(r.duration)])
```

First `startTime` must be under 1500. It was **35351**.

### Not done this round

* Frontend lifecycle chips, countdowns and stage tables. The API now serves every field; nothing renders them yet.
* The backfill lane's 124k-block backlog. It grinds forward on its own; the head-cursor fix means new activity no longer waits for it.
* Metadata coverage — many listed tokens still have no image.

***

## Round 2 — what actually happened on deploy

Recorded because three of these were only visible in the data, not the logs.

### Confirmed working

* **Head cursors.** `nft-onchain-sales-head:robinhood` and `nft-onchain-mints-head:robinhood` both exist and sit at head (55,105,626 / 55,104,324 against a head of \~55,105,xxx). The backfill cursor is still \~130k blocks back and grinding, which is the intended division.
* **Live mints restored.** Before deploy: zero `mint | live` rows in 15 minutes while sales flowed. After: 217 mints, newest 12:01:22.
* **Drop sync.** `discovered: 17, tracked: 17`. Two upcoming drops on this chain with real caps — `world-wise-wizards-nfts` (10,000, opens 09-06 14:00Z) and `trust-us-nft` (4,444, opens 09-11 10:44Z).
* **`/trending?lifecycle=upcoming`** returns both. That is the proof the sales-only `HAVING COUNT(*) > 0` no longer excludes pre-mint collections.

### Bugs found only by checking the rows

1. **The client returns an envelope.** The first enumeration logged `discovered: 0` against an endpoint verified by hand to return two drops. `openSea.drops()` answers `{ data, cached, stale, observedAt, requestId, rateLimit }`, and reading `payload.drops` off the envelope found nothing — no error, no warning. Fixed with a `body()` unwrap.
2. **Never-probed collections were never scheduled.** The refresh query required `dropCheckedAt < now() - <interval>`, and `NULL < anything` is NULL, so a row that had never been probed could not become due. OpenSea's `upcoming` feed does not list `stillfacesnft` — its mint is a day out, not imminent — so the one collection this work started from would never have been asked about. Never-probed rows now sort first, with `0x…` slugs excluded so the sweep does not spend the budget on LP receipts.
3. **Discover hid its own new rows.** The market table filtered on `totalSupply IS NOT NULL`, which excludes every drop that has not minted a token.

### Still to confirm

`stillfacesnft` reads `lifecycle: dormant` until the one-time sweep reaches it (8 rows per 30s tick across \~2,200 collections, negative-cached for a day afterwards). Expect `upcoming`, `dropNextStageStart = 2026-09-06 13:00Z`, `dropMaxSupply = 7777`.

```sql
SELECT "dropSyncStatus", count(*) FROM nft_collection_markets GROUP BY 1;
```

`not_a_drop` should become much the largest bucket — most collections are secondary-market only, and that is the answer being cached.


---

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