Skip to main content
Glama
README.md
# nft-farm-mcp

Find **free-mint NFT collections that went up**, and the **wallets farming them**,
ranked by secondary-market PnL read directly from chain state.

Ships as an **MCP server** (stdio) and a **CLI**. Works on any EVM chain with a
Seaport deployment; Robinhood Chain by default.

- Reads only `eth_getLogs` / `eth_call` / `eth_getTransaction`. **No API key, no
  account, no wallet.** Every tool is read-only and never sends a transaction.
- Data comes from Seaport `OrderFulfilled` events (sales) and `Transfer` from
  `0x0` (mints). No third-party indexer.
- Requires **Node ≥ 20**.

> Tiếng Việt: [README.vi.md](./README.vi.md)

---

## Install

```bash
claude mcp add nft-farm -- npx -y nft-farm-mcp@latest
```

```jsonc
{
  "mcpServers": {
    "nft-farm": {
      "command": "npx",
      "args": ["-y", "nft-farm-mcp@latest"],
      // optional — public RPCs are rate-limited; a private endpoint helps a lot
      // on busy chains like Ethereum and Base
      "env": {
        "NFT_FARM_CHAIN": "robinhood",
        "NFT_FARM_RPC_BASE": "https://your-base-endpoint"
      }
    }
  }
}
```

The package has no runtime dependencies — viem and the MCP SDK are bundled, so
`npx` fetches one 305 kB tarball instead of installing a dependency tree.

Two binaries ship: `nft-farm-mcp` is the MCP server, `nft-farm` is the CLI.

---

## Tools

| Tool | Purpose |
|---|---|
| `list_chains` | List built-in chains, or resolve one chain spec and get its measured block time, detected Seaport address and scan-cost estimate |
| `scan_collections` | Rank free-mint collections by secondary PnL in a block window |
| `scan_wallets` | Rank the wallets that minted those collections; flag wallets minting several (overlap = farming signal) |
| `job_status` | Poll / cancel a background scan and read its result |

### Recommended call order

```jsonc
// 1. confirm the chain is supported and see what a scan will cost
list_chains { "chain": "base", "days": 1 }

// 2. rank collections — start narrow, async is the safe default
scan_collections { "chain": "base", "days": 1, "top": 10, "probe": 20, "async": true }

// 3. poll until status leaves "running"
job_status { "jobId": "<id from step 2>" }

// 4. take the interesting collections further: who minted them?
scan_wallets { "chain": "base", "days": 1, "topCollections": 10, "minOverlap": 2, "async": true }
```

**Pass `async: true` unless the window is genuinely small.** A scan that exceeds
the inline budget is started as a background job automatically, but the estimate
is approximate — an explicit `async: true` can never time out, and costs one
extra `job_status` round-trip when the scan turns out to be fast.

---

## Arguments

### Shared by `scan_collections` and `scan_wallets`

**Chain**

| Arg | Type | Default | Meaning |
|---|---|---|---|
| `chain` | string | `robinhood` | Built-in key, numeric chain id (`"8453"`), or an RPC URL |
| `rpcUrls` | string[] | chain defaults | First is primary, the rest are failover |
| `seaport` | string | detected | Marketplace address override |
| `blockTimeMs` | number | measured | Skip block-time measurement |

**Window** — omit all four and you get the last 7 days.

| Arg | Type | Default | Meaning |
|---|---|---|---|
| `days` | number, ≤ 90 | `7` | Rolling window ending at chain tip |
| `fromBlock` / `toBlock` | **string** (decimal) | tip | Explicit blocks. Strings, not numbers |
| `from` / `to` | string | — | ISO date or unix seconds |

**Cost / tuning**

| Arg | Type | Default | Meaning |
|---|---|---|---|
| `async` | boolean | auto | `true` → return a `jobId` immediately |
| `concurrency` | number, 1–16 | `2` | Max in-flight `getLogs` |
| `maxLogSpan` | number | per chain | Max block span per `getLogs`; see `list_chains` |
| `mintProbeCalls` | number, 1–500 | `20` | Max `getLogs` spent finding one collection's mints |
| `mintLookbackDays` | number, ≤ 90 | `90` | How far before the window to look for mints |
| `cache` | boolean | `true` | Disk cache of immutable history |

### `scan_collections` only

| Arg | Type | Default | Meaning |
|---|---|---|---|
| `top` | number, 1–200 | `20` | Rows returned |
| `probe` | number, 1–400 | `80` | Top-by-volume collections to mint-probe. **Dominates runtime** |
| `minSales` | number | `1` | Minimum secondary sales to qualify |
| `sortBy` | enum | `pnlAth` | `pnlAth` \| `pnlAvg` \| `ath` \| `volume` \| `sales` \| `floor` |
| `freeOnly` | boolean | `true` | `false` includes paid mints |

### `scan_wallets` only

| Arg | Type | Default | Meaning |
|---|---|---|---|
| `top` | number, 1–200 | `20` | Wallet rows per table |
| `topCollections` | number, 1–100 | `15` | Profitable collections used as seeds |
| `minOverlap` | number | `2` | Distinct seeded collections a wallet must have minted |
| `maxMints` | number | `400` | Mint events to fetch per collection |
| `minSales` | number | `2` | Minimum sales to seed (note: `scan_collections` defaults to 1) |
| `probe` | number, 1–400 | `max(50, topCollections × 4)` | Candidates before the free-mint filter |
| `walletSort` | enum | `totalEst` | `totalEst` \| `realized` \| `overlap` \| `mints` |

### `list_chains` / `job_status`

`list_chains` takes `chain`, `rpcUrls`, `days` — all optional. With no `chain` it
just lists the built-ins.

`job_status` takes `jobId` (omit to list recent jobs) and `cancel` (abort instead
of read).

Ranges are **rejected**, not clamped: `days: 120` is a validation error.

---

## Reading the output

Every tool returns a markdown table in `content` and typed data in
`structuredContent`. **All `*Eth` fields are decimal strings, not numbers** —
parse before comparing, or `"9"` sorts above `"10"`. Amounts are in the chain's
native token (`chain.nativeSymbol`: POL on Polygon, APE on ApeChain, BNB, AVAX),
despite the `Eth` suffix.

### `collections[]`

| Field | Meaning |
|---|---|
| `volumeEth`, `athEth`, `floorEth`, `avgEth` | **Computed only from sales inside the requested window.** `athEth` is the top sale *in that window*, not an all-time high; `floorEth` is the lowest sale, not a live listing floor |
| `freeMint` | `true` / `false` / `null`. `null` means the probe budget ran out — **unknown, not paid** |
| `mintCostEth` | Median of the sampled mint transaction values; `0` when free, `null` when unknown |
| `pnlAthEth` | `athEth − mintCostEth`. Best-case single sale in the window, gas excluded |
| `pnlAvgEth` | `avgEth − mintCostEth`. The more honest figure |
| `roiAth` | number, or the **string** `"inf"` when the mint was free, or `null` |
| `mintSamples` / `mintsObserved` | Transactions actually sampled / mint logs seen. Low `mintSamples` means a thin verdict |

### `topWallets[]`, `overlapWallets[]`

| Field | Meaning |
|---|---|
| `realizedPnlEth` | **Gross** Seaport proceeds. The only figure backed by real sales. No gas, royalty or marketplace fee deducted |
| `realizedSales` | Number of those sales |
| `estUnrealizedAvgEth` | Mints the wallet **still holds** × the collection's window average sale. Mark-to-market, not money received |
| `totalEstPnlAvgEth` | `realizedPnlEth + estUnrealizedAvgEth` |
| `collectionsMinted` | True count of seeded collections this wallet minted |
| `hits` | Capped at the top 5 collections by mint count — use `collectionsMinted` for the real number. Each hit has `mintCount` and `soldCount` |

**What "realized" counts.** A sale is credited to a wallet only when all three
hold: it is a listing-side fill (Seaport bid fills have no resolvable seller), the
collection is one of the seeded ones, and that wallet appears in the sampled
minter set for it. It is therefore a floor on actual proceeds, not a full P&L.

**How `freeMint` is decided.** At most 8 mint transactions are sampled;
`freeMint` is true when ≥ 80 % of them carry `value == 0`. It reads native
`tx.value` only, so mints priced in ERC-20, routed through an aggregator, or
batched (one transaction minting many tokens) are mis-priced.

**Sampling.** `maxMints` bounds how many mint events are fetched per collection,
so minter sets are a sample of the largest minters, not the full holder list.

None of this is financial advice — verify on-chain before acting on a wallet.

---

## When something goes wrong

| Symptom | What it means / what to do |
|---|---|
| `getLogs failed at … via <url>` | That endpoint refuses log queries. Several widely-listed public RPCs answer `eth_chainId` but reject `eth_getLogs`. Pass different `rpcUrls`, or lower `maxLogSpan` |
| `Chain … has no Seaport deployment at any known address` | No Seaport 1.6/1.5/1.4 on that chain. Pass `seaport: "0x…"` if it uses another marketplace |
| `Unknown chain id N. Pass an RPC for it` | Supply `rpcUrls` alongside the numeric `chain` |
| Tool call times out | Re-issue with `async: true`, then poll `job_status` |
| Empty `collections` | Widen `days`, lower `minSales`, or set `freeOnly: false`. Many rows with `freeMint: null` means the probe budget ran out — raise `mintProbeCalls` |
| `scan_wallets` returns three empty arrays | No collection passed the free + profitable seed filter. Run `scan_collections` first to see what is there |

`job_status.status` is one of `running`, `done`, `error`, `cancelled`. **Stop
polling on any of the last three**, and read `error` when it is non-null. Poll
every 5–15 s. Jobs live in the server process only: the last 32 are retained and
all are lost if the server restarts.

---

## Chain support

Built-in keys: `robinhood`, `ethereum`, `base`, `optimism`, `arbitrum`,
`polygon`, `zora`, `blast`, `apechain`, `abstract`, `bnb`, `avalanche`.

`chain` also accepts a numeric id or a bare RPC URL. For an unknown chain the
server detects and caches what it needs: chain id from `eth_chainId`, block time
measured from two block timestamps, and the marketplace by probing Seaport's
deterministic address (1.6 → 1.5 → 1.4) with `getCode`.

`maxLogSpan` defaults differ per chain — Ethereum 2 000, Base/OP/Polygon/Zora/
Blast/BNB/Avalanche 10 000, ApeChain/Abstract 20 000, Arbitrum 50 000, Robinhood
100 000, auto-detected chains 10 000 — then auto-tune to what the endpoint
actually accepts. `list_chains` reports the current value.

### Environment variables

| Variable | Purpose |
|---|---|
| `RPC_URL` | Default RPC; comma-separated for failover |
| `NFT_FARM_RPC_<CHAIN>` | Per-chain override, e.g. `NFT_FARM_RPC_BASE` |
| `NFT_FARM_CHAIN` | Default chain (default `robinhood`) |
| `NFT_FARM_CACHE_DIR` | Cache location (default `~/.nft-farm/cache`) |
| `NFT_FARM_SYNC_CALL_BUDGET` | Estimated calls allowed inline before switching to a job (default 40) |

---

## Performance and caching

Block history is immutable, so decoded Seaport sales are cached in aligned block
buckets under `~/.nft-farm/cache` and reused; only the tip is re-fetched.
Collection names, token standards, mint-cost probes and minter sets are cached
too. Buckets within ~10 minutes of head are never cached, so a reorg cannot
poison the store. Delete the directory to reset; `cache: false` bypasses it.

| | Robinhood (1 day, 73k sale legs, `probe: 10`) | Base (0.15 day, `probe: 3`) |
|---|---|---|
| Cold — Seaport phase | 34.3 s | 0.4 s |
| Cold — mint probe | 59.6 s | 30 s |
| **Cold total** | **1 m 36 s** | **39 s** |
| Warm — Seaport phase | **0.9 s** | 0.8 s |

Those numbers are at `probe` 3–10. The default is `probe: 80` over 7 days, which
is minutes of work — another reason to start narrow and use `async`.

Cache size scales with sales volume: one day of Robinhood Seaport (73 k sale
legs) is ~2.3 MB gzipped.

---

## CLI

Same engine, no MCP client needed:

```bash
npx -y -p nft-farm-mcp nft-farm scan --days 7 --top 20
npx -y -p nft-farm-mcp nft-farm chains

# or from source
npm install && npm run build
npm run scan -- --chain base --days 1 --top 10
npm run wallets -- --days 1 --top 20 --min-overlap 2
```

`nft-farm --help` lists every flag. Flags mirror the MCP arguments
(`--chain`, `--rpc` repeatable/comma-separated, `--days`, `--top`, `--probe`,
`--sort`, `--all` for paid mints, `--json`, `--concurrency`, `--max-span`,
`--mint-probe-calls`, `--mint-lookback`, `--no-cache`, `--cache-dir`).

Two CLI-only differences worth knowing: `--min-sales` defaults to 1 for both
commands (the MCP `scan_wallets` uses 2), and `--sort pnlFloor` is an alias that
sorts by **floor price**, not by floor PnL.

---

## Development

```bash
npm run typecheck
npm test          # typecheck + bundle + MCP stdio smoke test
```

[MCP_PLAN.md](https://github.com/0xsyncroot/nft-farm-mcp/blob/main/MCP_PLAN.md)
records the RPC measurements behind the design and the places the first design
was wrong — useful if you port this to another chain or RPC provider.

MIT.