{
  "markdown": "# merchant-check-mcp\n\n**Gradient Decisions provides merchant intelligence for autonomous\ncommerce.** `x402 Merchant Check` evaluates observable on-chain payment\nbehavior so agents can make more informed decisions before paying\nunfamiliar x402 merchants. Agent-native, machine-readable, x402-native —\n$0.01 per check, paid via x402. It is a pre-payment decision primitive, not\na certification, a guarantee of safety, or a replacement for an agent's own\npayment policy. See [INTEGRATION.md](INTEGRATION.md) for the full\ndiscover → check → decide → pay flow, the 402-response-to-input mapping,\nand the exact response shape. See the\n[privacy policy](https://gradientdecisions.com/privacy) for how data is\nhandled. This file tracks what's actually built, what's stubbed, and what\nneeds you before this goes further.\n\n## Privacy policy\n\nLive at `gradientdecisions.com/privacy` (`src/privacy.ts`), linked from the\nhomepage footer and here. Drafted 2026-08-13 by Claude at direct request,\ngrounded in this system's actual data practices (what `query_log` and\n`merchant_signals` really store — see \"Internal caller-tracking dashboard\"\nabove) rather than generic boilerplate. **Not reviewed by a lawyer** — the\npage itself says so, and that caveat should stay until it has been. Contact\nemail is `info@gradientdecisions.com` (switched 2026-08-19 from the\ndeveloper's personal address — see \"Data access policy\" below for why and\nhow). `src/privacy.ts`'s `CONTACT_EMAIL` constant is the source of truth if\nit ever needs to change again.\n\n```\nDISCOVER → IDENTIFY PAYMENT DESTINATION → GRADIENT MERCHANT CHECK → AGENT PAYMENT POLICY → X402 PAYMENT\n```\n\n## Status: two live surfaces, one product\n\n- **Agents**: `https://mcp.gradientdecisions.com/mcp` — the paid\n  `check_merchant` MCP tool. **Live on Base mainnet with real USDC**\n  (switched from Base Sepolia testnet — see \"Going to mainnet\"; real\n  settled payments confirmed in `query_log`, real tx hashes on Base). Merchant\n  coverage spans both Base and Solana (PayAI/Helius) — see \"Solana data\n  source\". Payment flow verified end-to-end: free `tools/list` discovery →\n  `tools/call` correctly 402s → agent builds and signs an x402 payment,\n  submits it → facilitator settles it → real tier comes back. See \"Try it\n  yourself\" below.\n- **Humans**: `https://gradientdecisions.com` — as of 2026-08-19 a minimal\n  static placeholder (`src/homePlaceholder.ts`), not a live dashboard. It\n  used to render every scored merchant's recommendation/signals/pricing\n  directly, plus serve the same dataset as raw JSON at `/api/wallets` — see\n  \"Data access policy\" below for why that was locked down. The real\n  redesigned homepage/dashboard is a separate later task, built from Figma\n  mockups, not this placeholder.\n\nNote: the two-chain merchant-signal *data* above is separate from this\nendpoint's own payment rail — `check_merchant` itself is still only paid via\nBase x402 (see \"Payment flow\" line above); nothing about that has changed.\n\n## Data access policy (2026-08-19)\n\nStructured merchant data — `recommendation`, `signals`, `pricing`,\n`reasons`, `platforms`, anything `check_merchant` returns — is available\n**only** through the paid `check_merchant` MCP tool, paid per-query via\nx402. This wasn't always true, and the change is worth recording plainly:\n\n- `/` and `/dashboard` used to render a full live dashboard: every scored\n  merchant, with its recommendation, signals, reasons, and pricing, straight\n  from D1. Now a static placeholder (`src/homePlaceholder.ts`) with no D1\n  query at all.\n- `/api/wallets` used to serve the exact same dataset as raw JSON — no\n  rendering, no throttling, trivially scriptable. Now returns `404` for\n  every method and every sub-path (`/api/wallets/`, `?query` strings, HEAD,\n  OPTIONS, POST, ...), with `Cache-Control: no-store` and no CORS headers.\n- `/merchant/<address>` (a per-merchant profile page, added 2026-08-18 —\n  see git history) is retired the same way, same reasoning: it returned the\n  same fields for one wallet, and an agent calling `check_merchant` already\n  has the one address it would need to read that page for free instead of\n  paying. Code stays in `src/merchantProfile.ts`, unused, not deleted, in\n  case a future *paid* or curated variant reuses it.\n\nAll three existed because giving humans/agents a free read of the same data\n`check_merchant` charges $0.01/query for directly undercut the product.\n`check_merchant` itself (`src/tool.ts`) was never affected — it has always\nread directly from D1 via `src/db/queries.ts` (`getMerchantSignals`,\n`getComparablePrices`, `getOwnPrices`), never through any of the routes\nabove, so none of this required touching the paid tool's logic or schema.\n\n**Contact email.** The privacy policy's `CONTACT_EMAIL` (`src/privacy.ts`)\nis `info@gradientdecisions.com`, replacing the developer's personal Gmail\naddress that used to appear there and in this file. It's set up as a\n**Cloudflare Email Routing forward** to that same personal inbox — mail to\n`info@` arrives at the existing Gmail, but the personal address itself no\nlonger appears anywhere public-facing or committed. Email Routing is\nreceive/forward-only: it cannot *send* mail as `info@gradientdecisions.com`.\nIf sending-as is ever needed, that's a separate manual task (Google\nWorkspace or custom SMTP), not something Email Routing does.\n\nTo enable it (Cloudflare dashboard, since this session has no Cloudflare\nAPI token to do it via API — see \"Manual steps\" note in the PR/commit this\nsection shipped with):\n1. Cloudflare dashboard → the `gradientdecisions.com` zone → **Email** →\n   **Email Routing**.\n2. Enable Email Routing for the zone. Cloudflare adds the required `MX`\n   records (routing to its own mail servers) and a `TXT` record\n   (`v=spf1 include:_spf.mx.cloudflare.net ~all`, or merged into an existing\n   SPF record if one's already there) automatically — no manual DNS entry\n   needed for a zone whose DNS Cloudflare already manages (true here, since\n   `custom_domain = true` in `wrangler.toml` already put this zone on\n   Cloudflare DNS).\n3. Add a routing rule: `info@gradientdecisions.com` → **Destination\n   address** → the existing personal Gmail address.\n4. Cloudflare sends a verification email to that Gmail address the first\n   time it's added as a destination — **this needs a human to click the\n   link**, it can't be done programmatically. Until that's clicked, mail to\n   `info@` won't actually forward.\n\n## Additional payment protocols (researched 2026-08-19)\n\nPrompted by Cloudflare's Agent Readiness \"Commerce\" checklist (ACP, AP2,\nMPP, UCP, x402) — the user explicitly said they don't care which protocol\ngets used, just that agents can actually pay, so each was researched\nagainst its real spec (not assumed) before deciding.\n\n**Shipped**: `GET /check` (`src/httpCheckEndpoint.ts`) — a plain-HTTP x402\nmirror of `check_merchant`, reusing the exact same facilitator/settlement\npath as `/mcp`. See that file's module comment for the full reasoning;\nshort version, the MCP path's 402 challenge lives inside a JSON-RPC\nresponse body, invisible to any plain HTTP client (including Cloudflare's\nown scanner) that just sends a bare GET.\n\n**Researched, not implemented**:\n- **ACP** (Agentic Commerce Protocol — OpenAI/Stripe) and **UCP**\n  (Universal Commerce Protocol — Google/Shopify/Etsy/Walmart): both are\n  full retail-checkout standards — cart construction, delegated payment\n  tokens, tax/shipping, returns, credential providers like Google Pay.\n  Built for buying *products* from a *catalog*, not paying per metered API\n  call. Confirmed via their actual specs (agenticcommerce.dev,\n  developers.openai.com/commerce, orkes.io/blog/universal-commerce-protocol-ucp-explained)\n  — the shape mismatch is structural, not a matter of more implementation\n  effort.\n- **AP2** (Agent Payments Protocol — Google/PayPal): genuinely does support\n  MCP as a transport, not only A2A as Cloudflare's scanner implies. Its\n  core mechanic is cryptographically signed \"mandates\" — verifiable proof\n  of a *user's* delegated purchase authority to an agent. Real and\n  interesting, but solves a different problem (delegated consent for agent\n  shopping on a user's behalf) than this service's model (an agent pays\n  directly, no user-delegation layer needed). A proper project on its own,\n  not a same-session addition.\n- **MPP** (Machine Payments Protocol — Tempo/Stripe, mpp.dev): the closest\n  conceptual fit — its own docs describe it for \"paying for an API call...\n  buying access to data,\" and its `usdc` charge method uses **EIP-3009\n  transferWithAuthorization**, the same signing primitive x402's `exact`\n  scheme already uses. The blocker: MPP's spec has **no facilitator\n  model** — unlike x402 (Coinbase's CDP facilitator submits/settles for\n  us, never touching a private key on our side), MPP expects the\n  *merchant's own server* to submit the transaction on-chain and pay gas\n  directly. Real implementation needs:\n  1. A dedicated EVM wallet funded with ETH for gas, held as a new secret\n     — a genuinely new operational responsibility (keeping it funded),\n     unlike x402 where we never custody signing keys.\n  2. Server-side relayer code: parse the `Authorization: Payment`\n     credential, submit the `transferWithAuthorization` call via an RPC\n     provider, poll for confirmation, handle retries/failures.\n  3. A real security review before shipping — this is direct-custody,\n     money-moving code, categorically different from x402's\n     facilitator-mediated model.\n  4. `WWW-Authenticate: Payment` / `Authorization: Payment` header\n     handling (RFC draft-ryan-httpauth-payment) alongside the existing\n     x402 headers.\n\n  This is a multi-day infrastructure project (wallet funding, relayer,\n  monitoring, security review), not a config change. **Recommendation**:\n  revisit once `GET /check` or `/mcp` traffic shows real organic demand —\n  `query_log` currently shows near-zero organic paid usage (see caller\n  analytics), so a second payment rail isn't justified yet. Tracked here\n  as a real roadmap item, not dropped.\n\n## Categorization\n\nAdditive to trust-tier scoring, doesn't touch `src/scoring.ts`. Every\nmerchant gets a `category` from a fixed six-value set\n(`src/categorize/types.ts`: `data_api`, `compute`, `content_generation`,\n`financial_data`, `storage`, `other`) — never invented outside that set;\nanything the pipeline can't confidently place lands in `other` and is\nlogged to `category_review_log` for a spot-check, not guessed.\n\nTwo passes, run once per wallet on first ingestion (not the 2-hour\ntrust-signal cadence — a separate monthly cron force-re-runs everyone in\ncase a listing's description changed, see `wrangler.toml`):\n1. **Rules** (`src/categorize/rules.ts`) — keyword match against the Bazaar\n   listing text. Only counts as confident if exactly one category matches;\n   zero or multiple matches (ambiguous) fall through to pass 2.\n2. **Model** (`src/categorize/model.ts`) — Claude Haiku (`claude-haiku-4-5-20251001`)\n   given the fixed category list and the description, asked for exactly one\n   value back. Response is validated against the fixed set before use —\n   never trusted blindly; anything unparseable becomes `other` + logged.\n\n**`ANTHROPIC_API_KEY`** (`wrangler secret put ANTHROPIC_API_KEY`) powers\npass 2 — without it, every non-rule-matched description lands straight in\n`other` (logged as `other_model_unavailable`, not silently guessed). Set\nand confirmed working on 2026-08-11. `POST /categorize` (admin-token\ngated, same pattern as `/refresh`) processes the backlog — `?force=true`\nre-categorizes everyone, `?limit=N` caps how many per call (default 200)\nsince a full force run across hundreds of wallets could exceed a single\nWorker invocation's execution time (confirmed by batching 4×100 manually).\n\nTwo real bugs found by actually running this against production, not by\ninspection — both fixed and redeployed:\n- Rule matching used plain substring checks, which false-matched\n  `\"compute\"` inside `\"computer vision\"` and would have matched a bare\n  `\"search\"` inside `\"research\"`. Two real listings (\"Tavily Search\", \"Exa\n  /search endpoint\") had been model-classified `content_generation` as a\n  result of falling through to pass 2 when they should've ruled confidently\n  to `data_api`. Fixed with word-boundary regex matching instead of\n  `.includes()`.\n- `runCategorization`'s `remaining` count was wrong for `force=true`: since\n  that mode's WHERE clause never excludes already-processed rows, a naive\n  recount just reported the total every time — caught by literally watching\n  it report the same number after 4 real batches that were each actually\n  processing different wallets (confirmed via `category_updated_at`\n  spread). Fixed by snapshotting a timestamp before each run and counting\n  rows still older than it.\n\nCurrent live distribution (2026-08-11, 375 real merchants): `data_api` 145,\n`other` 93 (genuinely ambiguous now, not \"model unavailable\"),\n`financial_data` 59, `content_generation` 45, `compute` 21, `storage` 4,\n8 legitimately uncategorized (stale, delisted from Bazaar since the last\nrefresh — untouched by design, not a bug).\n\n## Try it yourself\n\n```bash\nnpm run demo\n```\n\n**Stale as of 2026-08-11 — the deployed server only accepts Base mainnet\nnow** (`X402_NETWORK = eip155:8453`, see \"Going to mainnet\"). This script\nstill pays on Base Sepolia, so it'll fail against the live deployment;\nkept as reference and for anyone testing a testnet-configured branch\nlocally. For a real, working live test, see \"Mainnet live payment test\"\nbelow.\n\nUses the throwaway keypair in `.env.demo` (gitignored, testnet-only, zero\nreal value). To get past the \"insufficient balance\" step and see an actual\npaid `trusted`/`avoid` response on a testnet deployment:\n\n1. Get the payer address: `DEMO_PAYER_ADDRESS` in `.env.demo`\n   (`0x9AaF5bB90307bacb9cB60f54c1be2B65B0771282`).\n2. Fund it with Base Sepolia test USDC:\n   [faucet.circle.com](https://faucet.circle.com) (select Base Sepolia).\n3. Re-run `npm run demo`. The two seeded wallets\n   (`0x1111...11d1` / `0x2222...22d2`, inserted directly into D1 for this\n   demo — see \"Demo data\" below) should come back `trusted` and `avoid`\n   respectively, with a real settlement tx hash.\n\n## Mainnet live payment test\n\n**Real money.** `scripts/mainnet-live-test.ts` makes one real $0.01 x402\npayment against the live mainnet deployment — the actual proof that\nsettlement genuinely works end to end, not just that the facilitator\nconfig resolves correctly (which was already verified separately without\nspending anything). Deliberately not wired into `npm run demo` or any\nother default command — only runs via the explicit `npm run mainnet-test`,\nand only with a private key you provide via a local, gitignored\n`.env.mainnet-test` file that never leaves your machine (same pattern as\nevery other secret in this project — I don't generate, hold, or touch it).\n\n```bash\ncd \"/Users/colincleven/Documents/merchant-check-mcp\"\n\n# 1. Generate a fresh throwaway keypair (runs locally, nothing sent anywhere)\nPATH=\"/Users/colincleven/.nvm/versions/node/v24.15.0/bin:$PATH\" node --input-type=module -e \"\nimport { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';\nconst k = generatePrivateKey();\nconst a = privateKeyToAccount(k);\nconsole.log('address:', a.address);\nconsole.log('private key:', k);\n\"\n\n# 2. Save the private key locally (paste the value the command above printed)\ncat > .env.mainnet-test << 'EOF'\nMAINNET_PAYER_PRIVATE_KEY=paste_the_private_key_here\nEOF\n\n# 3. Send a small amount of real USDC on Base mainnet (e.g. $0.05) to the\n#    \"address:\" printed in step 1, from your own wallet/exchange.\n\n# 4. Run the real test\nPATH=\"/Users/colincleven/.nvm/versions/node/v24.15.0/bin:$PATH\" npm run mainnet-test\n```\n\nChecks a real trusted-tier merchant from the live dataset by default\n(`0xffc458db291b4abce020fe3de4f91f2770e537b1`) — override with\n`TEST_MERCHANT_WALLET=0x... npm run mainnet-test`. Success prints a real\ntransaction hash and a BaseScan link.\n\n## Demo data\n\n`merchant_signals` has two synthetic rows I inserted directly via\n`wrangler d1 execute --remote` — clearly fake addresses\n(`0x11111111111111111111111111111111111111d1`,\n`0x22222222222222222222222222222222222222d2`), not real merchants, kept\naround because there's still no real `avoid` example (see \"Still needed\"\nbelow). They're marked `is_demo = 1` **and** tagged `network = 'eip155:84532'`\n(Base Sepolia — the network the demo/test payment flow actually runs on,\nvs. `eip155:8453` Base mainnet for every real `BazaarDataSource` row). Two\nseparate columns on purpose: `is_demo` marks *fake* rows, `network` marks\n*which chain real rows came from* — different failure modes to guard\nagainst (synthetic data vs. real testnet data leaking into the mainnet\ndataset), so both stay explicit instead of collapsing into one flag. Both\nfilters (`WHERE is_demo = 0 AND network = 'eip155:8453'`) currently produce\nan identical result set, but that changes the moment any real data source\ncan observe testnet activity. Excluded from the public dashboard and\n`/api/wallets` (`src/dashboard.ts`) — `gradientdecisions.com` only ever\nshows real mainnet data — but `check_merchant`\nitself still sees them, so `npm run demo` keeps exercising all three tiers.\n(An earlier version of these two addresses was 38 hex characters instead of\n40 — `isValidWalletAddress`'s own regex rejected them, so every demo call\ncame back \"not a valid EVM address\" even though payment settled fine. Verify\naddress length programmatically, not by eye — see git history.)\n\n## Known issue found and fixed during deployment\n\n`resource.serviceName` in `src/index.ts`'s `createPaymentWrapper` config\nmust be printable ASCII only (no em-dash) and ≤32 characters —\n`@x402/core`'s `ResourceInfoSchema` rejects anything else with a `ZodError`,\nwhich silently broke payment-required detection on the client side (the\nmalformed response just looked like an inert error result, not something\nworth auto-paying for). Found by testing against the live deployment, not\nfrom any docs — worth knowing if you add more resource metadata elsewhere.\n\n## Remaining one-time account setup (done)\n\n**Stale heading, kept for history.** The `workers.dev` subdomain blocker\nthat failed on every single deploy this session (`wrangler` reporting the\naccount needed a subdomain before the cron trigger could attach) is\nresolved as of 2026-08-18 — visiting the Workers section of the Cloudflare\ndashboard once provisioned it. Confirmed via a live `wrangler deploy`:\n```\nschedule: 0 */4 * * *\nschedule: 0 0 1 * *\n```\nBoth cron triggers now attach cleanly. The refresh worker ran every 4\nhours at the time of that deploy output above; changed to every 2 hours\non 2026-08-19 (current: `schedule: 0 */2 * * *`, see `wrangler.toml`).\nMonthly forced re-categorization runs on the 1st. Data had gone stale for\n6+ days before this was caught and fixed — worth periodically checking\n`lastRefreshedAt` on the homepage (`/api/wallets` is retired, see \"Data\naccess policy\") even with the cron working, same as any scheduled job.\n\n## Manual refresh trigger\n\nNot required anymore now that the cron is attached, but still useful for\nan on-demand refresh outside the 2-hour cadence (shared-secret header\n`X-Admin-Token`):\n```bash\ncurl -X POST https://mcp.gradientdecisions.com/refresh \\\n  -H \"X-Admin-Token: <your ADMIN_TOKEN>\"\n```\n\n## Internal caller-tracking dashboard\n\nAdded 2026-08-13, separate from the public merchant-scoring dashboard.\n`GET /admin/callers` (same `X-Admin-Token` gate as `/refresh`/`/metrics`;\nappend `?format=json` for raw JSON, `?window=<seconds>` to change the\nheadline-stats window, default 7 days) tracks usage of `check_merchant`\nitself — who's calling it, how often, what they're checking — as opposed\nto which merchants score well. Not linked from the public site.\n\nPurpose (verbatim from the request that added this): *\"this caller data is\nconfirmed-intent ground truth (a wallet paying to check a merchant is\nactively evaluating a real purchase), intended to eventually inform\nbuyer-side behavioral segmentation, not just merchant scoring.\"* That\nsegmentation is **not built here** — this only ships the tracking/\naggregation infrastructure (unique callers over time, query frequency per\nwallet to distinguish one-off scripts from repeat/active agents, category\ndistribution of what's being checked, 30-day retention). Segmentation logic\nis explicit future work.\n\n**No-identity-resolution principle, same as everywhere else in this\nproject**: every view here is keyed on `payer_address` (the on-chain wallet\nthat paid) — nothing resolves that to an off-chain identity. See\n`src/callerDashboard.ts` for the full design comment.\n\nImplementation note: this mostly *extends* `query_log` (already existed for\n`/metrics`) rather than a new table — added `queried_category` (the checked\nmerchant's category from the response) and `caller_supplied_price_atomic`\n(genuinely caller-supplied, from `input.price`) as two new columns, not a\nparallel logging path.\n\n## Phase 0 resolution (stack compatibility)\n\nConfirmed via Cloudflare's own docs and by actually installing/typechecking\nagainst the real packages (not just reading about them):\n\n- **MCP transport**: `@modelcontextprotocol/sdk`'s `WebStandardStreamableHTTPServerTransport`\n  — a fetch()/Request/Response-based transport whose own JSDoc includes a\n  Cloudflare Workers usage example. Runs natively on Workers, no polyfills.\n- **Payments**: `@x402/core` + `@x402/evm` + `@x402/mcp` — the official\n  x402-foundation/Coinbase packages (same publishers as the protocol spec\n  itself). **Not** `x402-hono`, and **not** Cloudflare's own `agents` package\n  — see \"Why not x402-hono\" and \"Why not Cloudflare's `agents` package\" below.\n- **Data store**: D1 (per brief) — relational joins across payer wallets and\n  price observations need more than KV's key-value model gives you.\n\nNet result: no fallback to Vercel/Railway was needed. Cloudflare Workers\nworks for both legs.\n\n### Why not x402-hono\n\n`x402-hono` (and the framework-middleware family generally) gates by HTTP\n*route*. MCP puts every JSON-RPC method — `initialize`, `tools/list`,\n`tools/call` — on a single POST endpoint. Gating the whole route would put\n`tools/list` behind a paywall too, which breaks agent discovery (the brief's\nown GTM plan depends on agents being able to read the tool description before\ndeciding to pay). `@x402/mcp`'s `createPaymentWrapper` instead wraps a\n*specific tool handler*, so discovery stays free and only `check_merchant`'s\nactual execution is metered.\n\n### Why not Cloudflare's `agents` package\n\nThe initial plan (before checking) was Cloudflare's `agents` package\n(`createMcpHandler`, built on `@modelcontextprotocol/server`). Once actually\ninstalled, `@x402/mcp` turned out to depend directly on\n`@modelcontextprotocol/sdk` (a different, if related, package) — and there's\nno documented integration between `@x402/mcp` and `agents`' MCP server\nwrapper. Rather than combine two payment-adjacent SDKs in a way nobody's\ndocumented, this uses `@modelcontextprotocol/sdk` directly, exactly as\n`@x402/mcp`'s own README examples do. `agents` isn't a dependency here at\nall.\n\n## Platform / website info\n\nAdded 2026-08-12. `check_merchant` and the dashboard now surface the actual\nresource URL(s) (and service name, where the discovery feed gives one) a\nmerchant wallet backs — e.g. `https://api.example.com/v1/weather` — not just\nthe raw wallet address. This isn't new data collection: both Bazaar and\nPayAI already return a `resource` URL + `serviceName` per listing, it was\npreviously only ever blended into `bazaar_description`'s free-text blob\n(for categorization) and thrown away otherwise. `MerchantSignalRow.\nplatforms_json` (JSON array of `{url, serviceName}`, one wallet can back\nseveral resources) stores it structured now; `CheckMerchantOutput.platforms`\nsurfaces it to callers; the dashboard's Platform column links to it\ndirectly. NULL/empty until a wallet's been ingested at least once — same\nstatus as `category`, not a scoring input.\n\n## What's built\n\n- [`src/index.ts`](src/index.ts) — Worker entry point, routed by pathname\n  (not hostname, so it works on the workers.dev fallback URL too): `/mcp`\n  wires `x402ResourceServer` + `ExactEvmScheme` + `createPaymentWrapper`\n  around `check_merchant` over `WebStandardStreamableHTTPServerTransport`;\n  `/` and `/dashboard` serve the human dashboard; `/api/wallets` the same\n  data as JSON; `/refresh` the admin-gated manual refresh trigger.\n- [`src/dashboard.ts`](src/dashboard.ts) — public dashboard (served at\n  `gradientdecisions.com`, same Worker as the MCP endpoint at\n  `mcp.gradientdecisions.com`). Self-contained HTML/CSS/JS, no external\n  dependencies, light/dark aware, client-side search + tier filter. Excludes\n  `is_demo = 1` rows — see \"Demo data\".\n- [`src/tool.ts`](src/tool.ts) — `check_merchant` logic. Reads only from D1\n  (`merchant_signals`, `price_observations`) — no chain access on the paid\n  request path, per the brief's precomputed-store requirement.\n- [`src/scoring.ts`](src/scoring.ts) — rules-based v1 composite. Every\n  `reasons` entry maps to one named signal; thresholds are constants at the\n  top of the file.\n- [`src/refresh/index.ts`](src/refresh/index.ts) — scheduled worker (cron:\n  every 2 hours, see `wrangler.toml`) that aggregates raw activity into\n  `merchant_signals` rows.\n- [`src/refresh/indexer.ts`](src/refresh/indexer.ts) — `ChainDataSource`\n  interface + `BazaarDataSource`, a real (not stubbed) implementation\n  against the public x402 Bazaar. See \"Data source\" below.\n- [`src/refresh/solana-indexer.ts`](src/refresh/solana-indexer.ts) —\n  `PayAIDataSource`, the Solana counterpart to `BazaarDataSource`: PayAI\n  discovery + optional Helius payer-diversity augmentation. See \"Solana\n  data source\" below.\n- [`src/chains.ts`](src/chains.ts) — chain detection/address normalization\n  shared by every module that touches a wallet address, now that the store\n  holds both Base (0x-hex, case-insensitive) and Solana (base58,\n  case-sensitive) wallets in one table.\n- [`db/schema.sql`](db/schema.sql) — D1 schema. Applied to the live remote\n  `merchant-signals` D1 database (`wrangler d1 execute --remote`).\n- [`scripts/backtest.ts`](scripts/backtest.ts) +\n  [`scripts/labeled-wallets.json`](scripts/labeled-wallets.json) — the\n  brief's required validation step, before charging for real queries.\n\n## What's deliberately stubbed\n\n- **Signal 6 (velocity/harness-break anomalies)**: per your 2026-08-10\n  decision, the buyer-side wallet-harness pipeline doesn't exist yet, so\n  `detectVelocityAnomalyStub()` in `src/refresh/index.ts` always returns \"no\n  anomaly\" rather than a fabricated heuristic that would silently miscalibrate\n  the tier logic. Replace its body once that pipeline exists.\n- **Payer-clustering (signal 2's cluster flag)**: same file, hardcoded to 0.\n  A real implementation needs to check whether payer wallets share funding\n  sources or were created in a burst — out of scope until there's a real data\n  source to check it against.\n- **Wallet age (signal 1)** and **refunds (signal 4)**: `BazaarDataSource`\n  can't see these (see \"Data source\" below) — every Bazaar-sourced row has\n  `wallet_age_days = null` and `refund_count = 0`, so `scoreMerchant()` never\n  flags either for real data yet. Not fabricated as \"fine\", just unmeasured.\n- **Price variance (signal 5)**: same reason — `BazaarDataSource` never\n  populates `price_observations`, so `computePriceVarianceFlag()` always\n  returns 0 for real data. The logic itself is real and already wired up;\n  it activates for free once a source that can populate this exists.\n\n## Data source\n\n**`BazaarDataSource`** (`src/refresh/indexer.ts`) — real, not stubbed. Pulls\nfrom the **x402 Bazaar**, Coinbase's own facilitator discovery catalog\n(`GET https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources`) —\npublic, no account or API key needed. Confirmed live: as of 2026-08-10 it\nhas ~14,500 registered resources; a refresh run indexed 365 unique Base-\nmainnet merchant wallets from the first 2,000, giving real\n`total_tx_count` / `unique_payer_count` from Coinbase's own 30-day\ncall-volume and unique-payer metrics per merchant.\n\nReal scope limits (see \"What's deliberately stubbed\" above for exactly which\nsignals this affects): only covers merchants who've registered a resource on\nBazaar, not every wallet that's ever received an x402 payment; no\nfirst-activity timestamp; no settlement-completion or refund visibility from\na directory listing; Bazaar's \"resource\" (an API endpoint) doesn't map to\nthe goods/services `resource_type` buckets `check_merchant`'s price-fairness\ncheck uses.\n\nFilling those gaps means either Coinbase's CDP wallet-history API (needs a\nfree CDP account + API key at\n[portal.cdp.coinbase.com](https://portal.cdp.coinbase.com/access/api) —\naccount creation has to be you, not me) or a custom chain indexer. Both are\nfuture work, not blocking anything currently running. `FixtureDataSource`\nis also available for local testing without live network access.\n\n## Solana data source\n\nAdded 2026-08-12 (requirement: Solana carries real, live x402 volume today,\nnot just testnet activity, so it feeds the same trust-tier scoring Base\ndoes — see `src/refresh/solana-indexer.ts` for the full implementation\nwriteup). Two pieces:\n\n**Discovery — `PayAIDataSource`.** The brief's original plan was \"Solana's\nx402 Agent Registry,\" but that name doesn't correspond to a real merchant\ncatalog: `solana.com/agent-registry` turned out to be a *buyer*-side agent-\nidentity product, not merchant discovery. The real functional equivalent is\n**PayAI Network's facilitator discovery feed**\n(`https://facilitator.payai.network/discovery/resources`) — confirmed live\nvia direct curl during research: 25,928 total items, a mixed Base+Solana\ncatalog in the same discovery-list JSON shape as x402 Bazaar. Free, public,\nno key. Unlike Bazaar, it has no `quality` field on any sampled item — no\ncall-volume or unique-payer counts ship with the listing itself.\n\n**Payer-diversity augmentation — Helius (optional, `HELIUS_API_KEY`).** Fills\nthe gap PayAI's discovery feed leaves. For each Solana merchant wallet PayAI\nsurfaces, counts real USDC (SPL) transfers to that wallet via Helius's\nEnhanced Transactions API (`mainnet.helius-rpc.com` — not `api.helius.xyz`,\nwhich 401s; Helius moved this endpoint onto their RPC host, found the hard\nway via a live failed refresh, confirmed against their current docs) and the\nunique source addresses behind them — capped at 8 wallets, 1 page (100 txs)\nper wallet per refresh run. That cap is **not** Helius's free-tier limit\n(1M credits/month comfortably covers far more); it's this Cloudflare\naccount's real, confirmed-live Worker subrequest budget: ~50 external\nfetches per invocation, shared across `BazaarDataSource` (≤20 pages),\n`PayAIDataSource`'s own discovery pages (≤5), Helius augmentation, and any\nfirst-time-wallet categorization calls to Anthropic (capped separately at 8\nper run in `src/refresh/index.ts`) — all in the *same* invocation, since\n`runRefresh` runs every source back-to-back. First deploy of this feature\nhit exactly this ceiling (`Too many subrequests by single Worker\ninvocation`, caught via `wrangler tail` against a real production request,\nnot simulated) before these caps existed. Raising Cloudflare's Workers plan\n(Bundled/Paid raises the ceiling to 1000) would remove the need for this\ntight a budget — a real option, left as a billing decision for you rather\nthan done here. Wallets beyond the cap, or with `HELIUS_API_KEY` unset\nentirely, keep PayAI's bare listing (usually 0 calls / 0 payers) —\n`scoreMerchant()` reads that as insufficient data, not a trust signal either\nway, never a guess.\n\n**Why Helius and not x402scan's paid API.** x402scan (`x402scan.com`) is a\nchain-agnostic x402 explorer with its own real per-call paid API\n($0.01–0.02/call via x402, confirmed from its own OpenAPI spec) that would\ngive richer, x402-specific merchant/transaction data than raw Helius\ntransfer-counting can. Decided against it for this v1 for a concrete reason,\nnot a blanket \"avoid paid data\" stance — using it would mean this backend\nautonomously holding and spending from a funded wallet on a schedule, a\ncustody/architecture commitment bigger than \"is $5–15/month reasonable.\"\nHelius's free tier gets real, live Solana payer-diversity data shipped now\nwithout that commitment. This isn't a closed door: once Helius-based data is\nlive, the plan is to compare what it actually delivers against what\nx402scan's paid API would add on top, with real numbers instead of\nspeculation, and revisit from there — see conversation history 2026-08-12\nfor the fuller reasoning (the business-model symmetry point: an aggregator\nthat itself charges for data isn't inherently wrong to pay a nominal fee\nfor better upstream data — the open question is what marginal value it buys\nover what's already free, not whether spending is acceptable in principle).\n\n## Price fairness caveats\n\nReferenced from `src/scoring.ts`'s `scorePriceFairness` comment. Two real\nfindings from 2026-08-13, both checked against live production data before\nacting on them, not assumed:\n\n- **The original ±25%-of-median \"fair\" band was wrong, confirmed against\n  real data.** Checked the live price distribution across all six\n  categories (392 priced merchants): only 19% landed on \"fair,\" with\n  \"high\"/\"low\" each roughly 2x more common — not because most merchants are\n  actually mispriced, but because real category price spreads are far wider\n  than ±25%. `data_api` alone spans $0.001 to $5.12 (a >5000x range); every\n  category's real interquartile range (p25–p75) covers roughly 2–5x the\n  median in *each* direction. Recalibrated `HIGH_PRICE_RATIO`/\n  `LOW_PRICE_RATIO` to 3.0x/0.35x, chosen from that observed IQR rather than\n  guessed — produces 52% fair with high/low roughly balanced (99/91) against\n  the same live data. Same epistemic status as the payer-diversity fixes\n  above: real-data-grounded, not proven-optimal.\n- **This does not fully fix the underlying cause — category is a coarse\n  bucket.** The six categories (`data_api`, `compute`,\n  `content_generation`, `financial_data`, `storage`, `other`) each bundle\n  genuinely different kinds of resource at genuinely different natural\n  price points (e.g. a simple lookup API and a complex real-time analysis\n  API can both be `data_api`). Widening the ratio band reduces false\n  high/low flags but doesn't make \"compared to every other `data_api`\n  merchant regardless of what it actually does\" a precise comparison —\n  `price_fairness`/`pricing.fairness_vs_category` should be read as a rough\n  signal, not a precise valuation, until (if ever) a finer-grained resource\n  taxonomy exists to compare within.\n\n## Solana signal caveats\n\nReferenced from `db/schema.sql`'s `velocity_anomaly_flag` comment and\n`src/scoring.ts`'s per-signal cross-chain read — collected here in one place:\n\n- **Helius counts any USDC transfer, not specifically x402 payments.** A\n  merchant receiving USDC through some other channel (a direct transfer, an\n  unrelated payment app) looks like extra x402 volume. Bazaar has the\n  mirror-image gap on Base (undercounting — it only sees registered\n  listings). Neither source is ground truth; both are documented\n  approximations.\n- **`firstSeenAt` is window-bounded, not true wallet age**, for Solana rows —\n  it only reflects the earliest transfer within the 90-day lookback and the\n  3-page-per-wallet Helius cap, same null-vs-approximate tradeoff Bazaar-\n  sourced Base rows already have for this signal (Bazaar gives no\n  first-activity timestamp at all).\n- **Fee-payer sponsorship is NOT a misattribution risk.** Solana's x402\n  \"exact\" scheme cryptographically excludes the fee-payer from being\n  transfer source/authority/destination (see\n  `specs/schemes/exact/scheme_exact.md` in `x402-foundation/x402`) — a\n  counted transfer's source address is always the real payer, never a\n  sponsoring relayer. Verified against the spec directly, not assumed.\n- **Signal 6 (velocity/harness-break) is the one signal known NOT to\n  translate once implemented**, not just currently stubbed like it is on\n  Base. Solana settles roughly 4x faster (~0.5s vs Base's ~2s) — a\n  transaction-frequency threshold tuned on Base traffic would over-flag\n  entirely normal Solana activity as anomalous. Whoever builds this signal\n  needs Solana-specific thresholds, not Base's reused unmodified.\n- **Signal 2's diversity-ratio threshold (`LOW_PAYER_DIVERSITY_RATIO = 0.3`\n  in `src/scoring.ts`) is Base-calibrated, not cross-chain-validated.** The\n  ratio itself (`unique_payers / total_tx`) is dimensionless and should\n  translate in principle, but the specific cutoff was derived from 365 real\n  Base Bazaar merchants only. Treat it as an assumption until enough\n  Helius-augmented Solana rows exist to check it against real Solana payer\n  distributions.\n- **USDC decimals match across chains (both 6)** — cross-checked against\n  x402scan's own facilitator constants and Solana's official USDC mint\n  registry — so atomic-unit price comparisons in\n  `db/queries.ts getComparablePrices` are valid across Base and Solana\n  within the same category without any conversion step. This is the one\n  place cross-chain comparison is *intentionally* pooled rather than kept\n  separate — see that function's own comment for why.\n\n## Done vs. still needed\n\n**Corrected 2026-08-12 — most of this list was stale**, written back when\nthe deployment was still testnet-only; several \"still needed\" items below\nhad actually already been completed and the list hadn't been updated to\nsay so. Verified against the live deployment before rewriting, not just\nedited from memory.\n\nDone:\n- ✅ Cloudflare account authenticated (`wrangler login`, developer's own account).\n- ✅ D1 database created (`merchant-signals`) and schema applied remotely.\n- ✅ **Live on Base mainnet**, real `PAYOUT_ADDRESS` — confirmed via real\n  settled mainnet transactions with real tx hashes in `query_log` (I never\n  saw or handled the actual address value, consistent with this project's\n  security practice throughout).\n- ✅ Deployed to `mcp.gradientdecisions.com` (`custom_domain = true` in\n  `wrangler.toml` auto-provisioned DNS + SSL since the zone was already on\n  this Cloudflare account).\n- ✅ Two synthetic demo rows seeded into `merchant_signals` (see \"Demo data\").\n- ✅ Real data source wired and run: `BazaarDataSource` indexes real Base\n  mainnet merchant wallets from the public x402 Bazaar (no account needed).\n- ✅ Backtest passes against real data: 2/2 cases (`trusted` + `caution`,\n  both real Bazaar merchants — see `scripts/labeled-wallets.json`). `avoid`\n  is still explicitly unvalidated — no real bad-actor source exists yet\n  (see `_avoid_bucket` in that file for why a thin-history wallet isn't a\n  valid stand-in) — genuinely still open, not corrected here.\n- ✅ Solana added as a second data source (2026-08-12): `chain` column\n  migrated onto remote D1, `PayAIDataSource` + Helius wired into\n  `runRefresh()` alongside `BazaarDataSource`, `check_merchant` output and\n  the dashboard both surface `chain`, tool descriptions rewritten for\n  semantic-intent matching mentioning both chains, `platforms` (merchant\n  website/API URLs) surfaced in output and dashboard — see \"Solana data\n  source\" and \"Platform / website info\" above.\n- ✅ `HELIUS_API_KEY` set and confirmed working — real Solana payer-diversity\n  data flowing (see \"Solana data source\").\n- ✅ `ANTHROPIC_API_KEY` set 2026-08-11, confirmed working, categorization\n  backlog processed.\n- ✅ Registry submission — live on the official MCP registry\n  (`com.gradientdecisions/merchant-check`) and indexed by third-party\n  directories (e.g. mcp.so) as a result. **Worth a periodic check**: these\n  directories may cache descriptions and not immediately reflect README/tool\n  changes made here — if something there looks stale, it's a caching lag on\n  their end, not necessarily a stale source here, but check both.\n- ✅ `workers.dev` subdomain enabled 2026-08-18 — the cron trigger now\n  attaches on deploy (confirmed live, see \"Remaining one-time account\n  setup\" above). Data had silently gone stale for 6+ days before this was\n  caught; the automatic refresh (now every 2 hours) should prevent that recurring, but\n  it's still worth spot-checking `lastRefreshedAt`.\n\nStill genuinely needed:\n1. **A real `avoid` example** for the backtest — needs either a genuine\n   x402-specific bad-actor source (none found publicly — the tech's too new)\n   or enough real usage data over time to observe one organically.\n3. **Robinhood Chain calibration data source** — explicitly deferred\n   2026-08-13, not forgotten. Chain ID 4663 (Arbitrum Orbit L2, live since\n   2026-07-01) and its Virtuals Protocol / Agentic Accounts ecosystem are\n   real and confirmed, but two things are still missing before building\n   this: (a) real, verified contract addresses for identifying agent\n   wallets — public search only turned up what look like unofficial\n   imitation tokens riding the \"Agentic\" branding, not genuine Robinhood/\n   Virtuals infrastructure; (b) the stated purpose (calibrate harness-break\n   detection against \"the same logic currently running on Base\") has no\n   real baseline yet — signal 6 is a permanent stub (`detectVelocityAnomalyStub`,\n   always 0) on Base today. Either a real contact with verified addresses,\n   or building signal 6 for real first, unblocks this.\n\n## Going to mainnet (done)\n\n**Stale heading, kept for history — this already happened.** `X402_NETWORK`\nin `wrangler.toml` is `\"eip155:8453\"` (Base mainnet), not the Sepolia\ntestnet value this section originally described. `PAYOUT_ADDRESS` receives\nreal USDC from real callers; `query_log` has real settled Base mainnet\ntransactions with real tx hashes, confirmed directly against remote D1 (see\n\"Try it yourself\" above). If this ever needs to move back to testnet for\nlocal dev, the value to change is the same one-line `X402_NETWORK` edit\nthis section originally documented, just in reverse.\n\n## Local dev limitation (this machine)\n\n`wrangler dev` / `wrangler d1 execute --local` need workerd, which requires\nmacOS 13.5.0+; this machine is on 12.6.0, so neither ran here. Schema syntax\nwas instead verified directly with `sqlite3` (D1 is SQLite-compatible) — all\nthree tables created cleanly. `npm run typecheck` passes end-to-end. Actual\nruntime testing (`wrangler dev`, then hitting `/mcp` with a real MCP client)\nneeds either a newer macOS, a Linux devcontainer, or testing directly against\n`wrangler dev --remote` / a deployed Worker.\n\n## Known rough edge\n\n`query_log` (usage/revenue visibility, not a scoring input) logs `\"settled\"`\nas a placeholder `tier_returned` instead of the real tier — `@x402/mcp`'s\n`onAfterSettlement` hook doesn't have access to the tool handler's return\nvalue, only payment/settlement info. Fine for v1; see the comment in\n`src/index.ts` if this ever needs to carry the real tier.\n\n## category and price_fairness now actually reach agents\n\nUntil 2026-08-11, `category` existed only in D1/the dashboard —\n`check_merchant`'s response never included it — and `price_fairness` was a\npermanent `\"unknown\"` stub for every real merchant, since nothing had ever\npopulated `price_observations`. Both fixed: `category` is in the tool's\noutput now, and `price_fairness` compares a merchant's price against real\npeers in its own category (not the old caller-supplied `resource_type`,\nwhich nothing ever populated data for — kept in the input schema for\ncompatibility, documented as unused).\n\nThree real bugs surfaced getting this actually working end-to-end against\nproduction (each found via `wrangler tail` against a real failing request,\nnot by inspection):\n1. Per-wallet D1 writes for price data (up to one INSERT per resource, some\n   wallets have 65+) blew through D1's 1000-queries-per-invocation cap\n   across ~370 wallets in one refresh. Fixed by restructuring to bulk\n   operations — one upfront category lookup instead of 370, price rows\n   accumulated in memory and written as ~20 chunked statements at the end.\n2. D1's real bound-parameter limit is ~100/statement, not SQLite's usual\n   999, and not stated in D1's own error message. `env.DB.batch()` sums\n   params across every statement in the call against that same ceiling —\n   batching multiple large inserts together doesn't dodge it, only fewer\n   total bound params per individual statement does.\n3. `getComparablePrices` sorted by `observed_at DESC` for recency, but\n   every row from one bulk refresh shares the exact same timestamp —\n   sorting a fully-tied key returns an arbitrary, non-representative\n   subset. A real merchant priced 50-500x below its category's true\n   median still came back `\"high\"`, because the `LIMIT 200` subset it\n   landed on was itself skewed low. Fixed with `ORDER BY RANDOM()`.\n\nVerified live: a real `data_api` merchant's category appears correctly in\n`check_merchant`'s output, and price_fairness resolves `fair`/`low`/`high`\ncorrectly around the real computed median (~$0.01, from 735 real\ncomparable observations) for that category.\n",
  "bytes": 45195,
  "sha": "6a38b5a778ed2b5d2ba3aa16c82d881d3035ef006001cc86d897a732d7f85389",
  "repo_slug": "clevbot/merchant-check-mcp-",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_com_gradientdecisions_merchant_check_2a3409df/readme"
}