> 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-round2-snipe.md).

# VERIFY-2026-09-05-round2-snipe

## Verify — reveal sniping

### Migration this round

**Yes.** `packages/db/migrations/20260905140000_nft_snipe_and_reveal/` adds two tables (`nft_snipe_plans`, `nft_snipe_fills`), one enum, two columns on `nft_token_metadata`, four on `nft_collection_markets`, and a partial index. All additive; the two columns on `nft_token_metadata` are nullable with no default, so it is metadata-only — no table rewrite.

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

### The feature is ON

`NFT_SNIPE_ENABLED` now defaults to true. The flag was never what stopped money moving — it only decided whether the lane ran at all, so leaving it off just meant an armed plan sat there doing nothing.

What actually gates spending is unchanged and unrelated: a user has to arm a plan, with a delegated wallet, an explicit budget and a rank or trait filter, and every fill still clears both the plan's ceiling and the profile's USD limits.

Cost, since that was the question: the ERC-4906 subscription is one websocket filtered to the contracts of armed plans. The probe poll is now a 20-second backstop — five `tokenURI` reads per watched collection, and only collections with an armed plan are watched. Both run on the **basic** RPC pool (Chainstack/publicnode), never the metered log pool. The one expensive moment is the burst re-read at reveal, which is bounded to 180 seconds and is the entire point of the feature.

Set `NFT_SNIPE_ENABLED=false` in `~/trover/.env` to switch the lane off.

### 1. Spend limits — check this first

`evaluateExecutionPolicy` has never been called on the NFT path, so `maxTradeUsd`, `dailyLimitUsd` and the wallet's `allowedNotionalUsd` were unenforced for NFT buys and NFT spend was invisible to the daily aggregate (which sums `trade_executions` only). Tolerable while a human clicked each buy. Not tolerable for a plan that fires unattended.

`processNftAction` now checks them directly, in `nftSpendDenial`. It is deliberately **not** routed through `evaluateExecutionPolicy`: that function wants a canonical token, an oracle, a route and a slippage budget, none of which an NFT purchase has, and inventing values for them would produce a check that reads as enforced and is not.

Confirm a cap actually bites before enabling sniping. Set a low `maxTradeUsd` on a test profile, attempt a buy above it, and expect the intent to land in `rejected` with `errorCode = 'per_trade_limit'`:

```sql
SELECT status, "errorCode", "priceLimit" FROM nft_action_intents
ORDER BY "createdAt" DESC LIMIT 5;
```

Other codes from this guard: `wallet_notional_limit`, `daily_limit`, and `usd_rate_unavailable` — that last one **denies**, because an unpriceable trade is exactly the case where a cap cannot be shown to hold.

### 2. Reveal detection

Three layers, because a missed reveal is the failure that matters:

1. tokenURI hash comparison on a fixed set of probe tokens
2. trait-shape — a placeholder is uniform, so trait *variety* appearing where there was none is a reveal even if the URI string never changed
3. **ERC-4906 subscriptions** — `MetadataUpdate` / `BatchMetadataUpdate` over a websocket, filtered to the contracts armed plans care about. This is the fast path: a reveal is caught in the block it happens, with no poll interval in the way. It is not the only path because plenty of contracts swap baseURI and emit nothing at all — which is exactly what layers 1 and 2 above are for. The subscription set is resynced whenever the armed-plan set changes, and an unchanged set costs one string comparison.

Two things had to change for any of this to be possible. `tokenUriHash` is now persisted — only the parsed document was ever stored, so a baseURI swap left no trace. And `resolveNftTokenMetadata` gained a `force` flag: it permanently skipped any token that already had traits, which a pre-reveal placeholder does ("Status: Unrevealed"), so the placeholder would have been kept forever.

```sql
SELECT slug, "revealState", "revealCheckedAt", "revealedAt"
FROM nft_collection_markets WHERE "revealState" <> 'unknown' LIMIT 20;
```

Only collections with an armed plan are polled — these are contract reads, so the candidate set stays small. They run on the **basic** RPC pool, never the metered log pool.

### 3. Ordering that is easy to get wrong

On reveal, trait counts are refreshed **before** ranks. `rankTokensByRarity` scores a trait it has never counted as **zero, not infinity**, so ranking first would put a genuine 1-of-1 last — precisely inverted from what a snipe needs. `refreshTraitCounts` also gained a `force` flag to bypass its 15-minute debounce, which would otherwise guarantee ranks do not exist during the only window in which they decide anything.

After a reveal, ranks should exist:

```sql
SELECT count(*) FILTER (WHERE rank IS NOT NULL) AS ranked, count(*) AS total
FROM nft_token_metadata WHERE "collectionId" = '<id>';
```

### 4. A plan end to end

Arm one from the terminal's **snipe** tab, or:

```bash
curl -s -X POST 'https://api.trover.tech/v1/nft-market/snipes' \
  -H 'authorization: Bearer <token>' -H 'content-type: application/json' \
  -d '{"collectionSlug":"<slug>","trigger":"on_reveal","maxRank":500,"maxPricePerItemNative":0.05,"budgetNative":0.1,"maxItems":2}'
```

Rejections to expect, and each is deliberate: `delegated_execution_wallet_required` (a plan runs with no browser open), `confirmation_required_incompatible` (a profile that confirms every trade cannot be sniped for — better to say so than to let every fill stall), and a validation error when neither a rank ceiling nor a trait is set, because that is a sweep rather than a snipe.

Then watch it:

```sql
SELECT id, status, "collectionSlug", "maxRank", "budgetNative", "spentNative",
       "filledCount", "maxItems", "failureCode"
FROM nft_snipe_plans ORDER BY "createdAt" DESC LIMIT 10;

SELECT "tokenId", rank, "priceNative", status, "failureCode"
FROM nft_snipe_fills ORDER BY "createdAt" DESC LIMIT 20;
```

### 5. Budget accounting

Budget is committed at **submission**, not confirmation. Committing on confirmation would let a burst of in-flight buys each see the full remaining budget and collectively overspend it. A terminal failure gives it back through `releaseNftSnipeFill`, wired into `finish()` in `nft-market.ts`.

Force a failure (a plan whose `maxPricePerItemNative` is below any live listing, or a cancelled order) and confirm `spentNative` and `filledCount` return to their previous values and the plan goes back to `armed`.

The unique index on `(planId, tokenId)` is the concurrency control: two evaluations racing on the same token collide there rather than both spending. A Redis lock per plan (`trover:nft-snipe:<id>`) stops two evaluations reading the same remaining budget.

### 6. Delegation drift

The same re-validation auto-invest does. A plan pauses rather than spending from a wallet the user has since changed: `wallet_inactive`, `wallet_not_delegated`, `delegation_disabled`, `wallet_changed`, `wallet_revision_changed`, `confirmation_required`. Change the canonical wallet on a test profile and confirm the plan flips to `paused` with the reason set.

### Known limits, stated rather than discovered later

* **OpenSea is on the critical path.** There is no local Seaport order builder; a buy is fulfilled through OpenSea's `/listings/fulfillment_data`. If that is down or rate limited, snipes do not execute. `offer` still throws `opensea_offer_requires_local_order_builder`.
* **Speed depends on whether the contract emits.** With ERC-4906 the reaction is same-block. Without it, detection waits for the probe poll — 20s by default — and against a bot watching the mempool the honest framing there is "fast", not "first". Nothing can make a silent baseURI swap observable earlier than reading the contract.
* **A truncated burst does not count as revealed.** The re-read is bounded to 180s; if a collection is too large to finish, it stays `placeholder` and resumes next tick rather than letting a plan act on half-computed ranks.
* **Ranks depend on trait counts we can compute.** If OpenSea's trait endpoint is unavailable at reveal, counts fall back to what our own metadata sweep has seen, which on a fresh collection may be partial.

***

## Reliability — what breaks, and what catches it

Written after auditing the paths rather than assuming them. Three real bugs were found and fixed in this pass; they are listed first because they are the ones that would have failed silently.

### Fixed: the subscription could never rebuild itself

The resubscribe guard compared only the contract set, and the key was recorded *before* the socket was known to be up. So a socket that never connected, or one that later died, left the key stored and every subsequent tick short-circuited — the listener stayed permanently dead while the probe poll carried on looking healthy. Liveness is now part of the condition, `onError` marks the subscription unhealthy instead of swallowing the error, and each tick proves the socket still carries traffic with one `eth_blockNumber` over the same transport.

**Check it:** `grep "Watching NFT metadata update events"` in the worker log after arming a plan, and `grep "resubscribing"` after any socket trouble.

### Fixed: a truncated burst restarted from token 1 forever

The burst re-reads tokens that already have traits — that is the whole point, since a placeholder has traits. But it also meant a collection too large to finish inside the 180-second budget would restart from the beginning on every tick and never reach the end. The burst start is now recorded in Redis (`trover:nft-reveal:start:<id>`) and anything re-read since then is skipped, so successive ticks make progress. A truncated burst also stays `placeholder` rather than being marked revealed — half a collection's ranks are worse than none, because a plan would act on them — and resumes deterministically instead of waiting to be re-detected.

### Fixed: one tick could outlive its own lock

`watchNftReveals` looked at up to 25 collections and would have ingested every revealed one inline. At up to three minutes each, a single tick could run for over an hour, outliving its BullMQ lock — at which point the job is re-run while the first copy is still going. One burst per tick now; ticks are 20s apart, and the event path is unaffected.

### Timeouts, and what bounds each one

| Step                         | Bound                             | What happens on breach                 |
| ---------------------------- | --------------------------------- | -------------------------------------- |
| Reveal burst                 | 180s wall clock                   | Stays `placeholder`, resumes next tick |
| BullMQ job lock (drops lane) | 300s                              | Comfortably above the burst bound      |
| Metadata batch               | 30s (`jobBudgetMs`)               | Batch truncates, outer loop continues  |
| Plan evaluation              | 120s Redis lock                   | Lock expires, next tick re-evaluates   |
| OpenSea drop call            | token bucket + 5-min pause on 429 | Lane yields, others keep their share   |

The drops lane runs at concurrency **2** so a reveal burst cannot block snipe evaluation for other collections behind it.

### Concurrency, and why each guard exists

* `ingestInFlight` — the event subscription and the poll can both decide to ingest the same collection. Two bursts would double every contract read and race each other's rank recomputation.
* `trover:nft-snipe:<planId>` (Redis, 120s) — two evaluations of one plan would each read the same remaining budget and both commit it.
* `nft_snipe_fills (planId, tokenId)` unique — two evaluations racing on the same token collide in the database rather than buying it twice.
* Budget commits at **submission**. Committing at confirmation would let a burst of in-flight buys each see the full remaining budget.

### Detection latency — four layers, fastest first

| Layer                          | Latency                    | Catches                                            |
| ------------------------------ | -------------------------- | -------------------------------------------------- |
| ERC-4906 `BatchMetadataUpdate` | same block, zero reads     | contracts that implement it                        |
| **Any log from the contract**  | same block, one probe read | contracts that emit *anything* on a baseURI change |
| **Every new block**            | sub-second                 | everything else, including silent proxy upgrades   |
| Probe poll                     | 20s                        | the floor if the socket is down                    |

The middle two are new and they are what closes the "contract emits nothing" gap. A `setBaseURI` almost always emits *something* — an Ownable event, a custom one, a Transfer — so treating any activity on a watched contract as "check the URI now" converts most silent reveals into same-block ones. What it cannot catch is a metadata change with no transaction at all (an IPFS directory swapped behind an unchanged CID path); that is what the per-block probe exists for, and on a chain producing \~10 blocks a second it is sub-second.

Per-collection throttle is 400ms, so "check on every block" never becomes "issue a read on every block regardless of whether the last one finished".

Reveal reads now run on the **head** pool, not the basic one. Probe reads are cheap and unhurried; what they are *for* is neither. Routing snipe detection through whichever endpoint was cheapest was optimising the wrong axis.

### The OpenSea dependency cannot be removed — here is the proof

Not "not yet implemented". Checked directly against a live listing:

```
protocol_data.signature = null
parameters.orderType   = 2      (FULL_RESTRICTED)
parameters.zone        = 0x000056f7000000ece9003ca63978907a00ffd100
```

Two independent blockers. The order **signature is withheld** from every public endpoint, including `GET /orders/chain/.../protocol/.../{hash}` — verified, it returns the parameters with a null signature. And `orderType: 2` routes the order through OpenSea's SignedZone, which requires a **separate attestation from OpenSea's own signer** at fulfilment time, with a short TTL.

So a local Seaport builder cannot substitute for `/listings/fulfillment_data` on these orders. That is a deliberate property of how OpenSea lists, not a gap in this code, and no amount of work on our side changes it.

What did change: a snipe now retries that call up to four times with a tight backoff, where a manual buy still tries once. A person watching a spinner will click again; a snipe has one window and nobody watching. Rate limits and 4xx responses are not retried — neither fixes itself inside the window.

### Ranks no longer depend on OpenSea being up

`refreshTraitCounts` asked OpenSea first and used our own data only as a fallback. On a reveal that is backwards: the burst has just re-read every token in the collection, so our counts are complete and first-hand, while OpenSea's trait endpoint may be slow, rate limited, or simply behind on a collection that revealed thirty seconds ago. On the reveal path our own counts are now used first, and the provider is consulted only if ours came back empty.

This matters more than it looks: `rankTokensByRarity` scores an uncounted trait as **zero**, so stale counts would file a genuine 1-of-1 last — the exact inverse of what a snipe needs.

### What still fails, honestly

* **OpenSea's fulfilment endpoint remains a hard dependency**, for the reasons proved above. Retries soften a blip; a sustained outage still stops snipes. This is the one item on this list that cannot be engineered away from here.
* **A metadata change with no transaction at all** — same CID path, different content — is caught by the per-block probe rather than instantly, because there is nothing on chain to observe until the URI is read.
* **Our own trait counts are only complete if the burst finished.** A truncated burst stays `placeholder` and no plan fires against it, so the failure is a delayed buy rather than a wrong one.
* **A worker restart drops the subscription** until the next tick rebuilds it — at most 20 seconds, and the poll covers the interval.

### The single check that matters most

Arm a plan on a collection that has not revealed, then confirm the watcher is actually watching:

```bash
docker compose -f docker-compose.yml -f docker-compose.production.yml logs --tail 2000 worker \
  | grep -E "Watching NFT metadata update events|reveal ingest|resubscribing"
```

If that first line is absent while a plan is armed, the fast path is not running and detection has silently degraded to the 20-second poll.


---

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