> 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/architecture.md).

# Architecture

## Data flow

1. X sends a CRC or signed Account Activity payload to the Fastify API.
2. The API verifies the raw body, returns `200`, and writes an idempotent BullMQ job.
3. The worker normalizes posts and DMs, stores a deduplicated event, and creates one persisted Trover agent run for every supported user interaction.
4. Identity resolution runs before retrieval. A non-canonical `$trover` address becomes an explicit conflict and is never merged.
5. Read-only providers run with timeouts and bounded retries. Each observation becomes normalized evidence with a raw hash and 30-day raw retention.
6. The model planner selects registered tools using conversation, account, action, and market context. The server validates every call and owns facts, permissions, policy, simulation, confirmation, and execution.
7. Executors emit immutable action results. A centralized response composer references those facts and rejects drafts that alter protected values.
8. One channel-edge publisher handles idempotent web, X mention, and X DM delivery. Scheduled proactive posts retain their separate approval flow.

## Identity priority

Operator-reviewed registry records have the highest priority. The seed creates only the canonical contract from the project brief. Aliases have their own status and data; even an `allowed` alias is not a license to reuse canonical metrics. Explorer, DEX, social, and model claims cannot overwrite registry identity.

## Risk and confidence

Risk flags are deterministic and cite evidence IDs. Thin liquidity defaults to `$25,000`; very thin liquidity defaults to `$5,000`. Provider honeypot, wash-trading, and concentration flags are emitted only when a supported source explicitly reports them.

Confidence weights are registry `0.20`, RPC `0.25`, Blockscout `0.20`, Dexscreener `0.20`, X `0.10`, and GMGN `0.05`. Unsupported GMGN is removed from the denominator. Identity conflict caps confidence at `0.49`; no contract bytecode caps it at `0.20`.

## NFT market pipeline

The chain is the source of truth for mints, sales and collection discovery. OpenSea is enrichment (orderbook, listings, stats, images, drop schedules, discovery lists) and a fallback; it never overrides a chain-verified event, and turning it off degrades cosmetics and the orderbook rather than the feed.

**Live sales.** The `nft-realtime` service runs as its own container (`docker-compose.nft-realtime.yml`, health on `:3003/health/ready`). It holds two `eth_subscribe` websocket lanes over `ROBINHOOD_NFT_WS_URLS`, currently Chainstack and Tenderly, each paired by position with an HTTPS archive endpoint from `ROBINHOOD_NFT_ARCHIVE_RPC_URLS`. Lanes are named by provider host (`robinhood_wss:chainstack`, `robinhood_wss:tenderly`) so two lanes never share a cursor (`nft-live-head:<lane>`) or a health row. Both lanes see every Seaport fill; only the first fetches the receipt and block, the second reuses them. A fill decoded from a chain log is stored with `onchainVerified=true` and no second RPC lookup. Pool option suffixes (`|rps=`, `|cu=`, `|lanes=`) are stripped from these two lists before the URL reaches viem. The service's Prisma pool is `connection_limit=30&pool_timeout=20`. Provisional events become `confirmed` once two lanes agree on the block hash `NFT_REALTIME_CONFIRMATION_BLOCKS` behind the lower head.

**Scanners.** Per chain (Robinhood and Ink), two lanes each for mints and sales. The head lane runs every 5 s on the head pool and reads a fixed `NFT_ONCHAIN_HEAD_WINDOW_BLOCKS` (96) window back from the tip; the backfill lane runs every 15 s on the log pool and follows its committed cursor in `stabledex_indexer_cursors`. The in-flight guard and the wall-clock ceiling are keyed per `lane:chain`, so a 30-minute Ink run never holds the Robinhood tick; an abandoned run keeps its lane marked in flight until it actually settles, so there is at most one run per lane and chain. A cursor more than `NFT_ONCHAIN_MAX_LAG_BLOCKS` behind the head is abandoned and reset to the tip. To re-scan a span, never edit the cursor row (the in-flight run commits `toBlock + 1` over it): set the Redis gap marker `trover:nft-mint-gap:v1:<chain>` to `{"from":"<block>","to":"<block>"}`. The mint backfill lane drains it before its cursor, advances the marker per window, logs "On-chain NFT mint gap drain", and deletes the key when done. Only the mints lane has a marker. A window's mints are written per collection as one batch (`recordOnchainMintBatch` in `nft-market-indexer.ts`: one twin lookup, one `createMany` on the same dedupe keys `storeEvent` uses, one read-back, one observations `createMany`); per-mint commits were queueing on `LWLock:WALWrite`. Cross-source twins still take the per-event path so an OpenSea "transfer" row can be promoted.

**Verification verdicts.** `verifyTransaction` answers `true`, `false` or `null`. `false` only when a node answered and the transaction reverted or never touched the contract, or two healthy nodes both report it missing; `null` means unverifiable and the event is accepted. The `nft-rejected-sale-repair` job (every 2 min, 100 s budget per run, 40 collections per query) re-accepts rows stored as `onchain_asset_verification_failed` under the old rule from any source, marks the listings they closed as filled, refreshes `lastSaleAt`, rebuilds candles and publishes `snapshot_required`. Where OpenSea already stored the same fill as accepted, the duplicate is relabelled `duplicate_of_accepted_row`. It exits at once when nothing is pending.

**OpenSea budget.** One key, 120 calls a minute, shared by the API and the four worker clients through `trover:opensea-rate-budget:v1` in Redis. Each process paces its calls 250 ms apart, waits out a block of up to 3 s inline, and retries a burst 429 once. Worker clients are constructed with `reserveRemaining: 20` and stand aside when the shared minute budget is down to that reserve, so the interactive API keeps the last slice. The in-process response cache is a 300-entry hot set in front of Redis (it had no eviction and was the worker's heap leak).

**Discovery lists.** The worker job `nft-discovery-lists` (every 60 s) fetches trending, top and new per chain (`OPENSEA_CHAIN` plus `NFT_DISCOVERY_CHAINS`; three calls a minute, the "new" list on a 45 s cache), stores each raw list in Redis (`trover:nft-discovery:v1:<chain>:<mode>`, 15 min), creates a row for every listed collection whose slug is not already held on another chain with a contract, writes 24 h volume and sales for every row from our own sale tape, and fills floor and owners for up to 10 rows per mode per tick from `collectionStats` (marker `trover:nft-discovery-stats:v1:<id>`, 3 min). A provider zero is retried like a null; owners fall back to distinct recipients of accepted mints and sales in our tape. `GET /v1/nft-market/collections` serves the warm list, never the provider list, and caches the whole page for 90 s (`trover:nft-discovery-page:v2:<chain>:<mode>:<timeframe>:<limit>:<scope>:<next>`). The page is fresh for 15 s; past that it is served stale while one request per interval rebuilds it in the background (`x-trover-cache: hit|stale|miss`); only a page nobody asked for in 90 s is built inline. With the warm list present the route skips tracking, the stats fill and the tape read. `scope=chain` (default) drops provider rows from other chains and tops the page up from our own rows on that chain (synthetic rows carry `source: "trover_index"`); `scope=all` returns the provider's mixed list for the all-networks view. Provider identities with no chain are resolved through our contract rows.

**Lifecycle presentation.** `presentNftLifecycle()` in `@trover/domain` is applied to every market payload: `minted_out` is shown for `nftSoldOutBadgeMs` (15 minutes) after the last mint, then presented as `secondary`. The stored `lifecycle` column is untouched, so the "ended" tab still lists those collections. Rows carry `lastMintAt`.

**Freshness.** `/v1/nft-market/collections` and `/v1/nft-market/trending` responses carry `freshness { chain, headBlock, indexedBlock, indexedAt, lagBlocks, listRefreshedAt, servedAt }`, read from the live sale lane's lag record in Redis. A lane that has not reported inside its TTL is absent, and absent is reported as null (not running), never as zero lag.

**Per-visitor API rate limit.** The API is reached through the Cloudflare tunnel, so Fastify runs with `trustProxy: true` and `@fastify/rate-limit` allows 600 requests per minute keyed on `cf-connecting-ip` (falling back to `request.ip`). Before this every visitor shared one 120/min bucket keyed on the docker bridge address. The 429 body carries `provider: "trover"` and `retryAfterMs`, the same shape as a provider 429, so the web fetchers wait and retry both. Action routes keep their own tighter per-route limits.

**Agent reads.** The worker's `nft-market-reads.ts` answers the twelve NFT agent capabilities through the API over `AGENT_INTERNAL_API_URL`, with entity resolution (contract, slug, name search), the `freshness` block and a 200-character summary; X gets the summary, the web gets summary and data. The customer MCP exposes the same reads in-process.

## Schedules

| Job                                            | Default                      |
| ---------------------------------------------- | ---------------------------- |
| X polling/backfill                             | every 120 seconds            |
| inferred trend analysis                        | every 15 minutes             |
| market radar eligibility                       | every 6 hours                |
| bot profile refresh                            | hourly                       |
| daily evaluation                               | `03:00 UTC`                  |
| raw retention cleanup                          | hourly                       |
| NFT market sync (OpenSea, tracked collections) | every 60 seconds             |
| NFT recent-sales sync (OpenSea)                | every 20 seconds             |
| NFT on-chain head lanes (mints, sales)         | every 5 seconds              |
| NFT on-chain backfill lanes (mints, sales)     | every 15 seconds             |
| NFT ownership walk                             | every 30 seconds             |
| NFT lifecycle recompute                        | every 30 seconds             |
| NFT collection merge                           | every 2 minutes              |
| `nft-rejected-sale-repair`                     | every 2 minutes              |
| `nft-discovery-lists`                          | every 60 seconds             |
| `contract-balance-watch`                       | every 10 minutes             |
| NFT drops discovery / refresh                  | every 5 minutes / 30 seconds |

Failed jobs retry up to five times with exponential backoff, then enter `job_dead_letters`. The NFT jobs run on their own queues (`trover-nft-scheduled`, `trover-nft-market`, `trover-nft-onchain-head`, `trover-nft-ownership`, `trover-nft-drops`) so a burst of collection work cannot starve trading and cron jobs, and vice versa. The chain scanners, the ownership walk and the lifecycle recompute are not gated on `OPENSEA_ENABLED`; `contract-balance-watch` is a no-op until the stack addresses are set. Official X trends are opt-in through `ENABLE_OFFICIAL_X_TRENDS`; otherwise output is labeled “inferred” or “emerging,” never “official.”

## Security boundaries

* Webhook HMAC uses the raw request body and constant-time comparison.
* Admin actions require an API key and produce audit rows.
* Logs redact authorization headers, credentials, raw provider payloads, and user tokens.
* Environment safe/dry-run gates cannot be changed through the database or CLI.
* The worker stores no wallet private key. Delegated transactions are submitted through Privy; external wallets sign in the authenticated browser session.
* Non-command DMs are classified and discarded without storing their content; explicit trade/bridge command fields are retained as audited intents.


---

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