@parabolicfamily/mcp 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ import { getAddress, isAddress } from "viem";
2
+ import type { Fetch } from "./restapi.js";
3
+ import { parseVenueRefInput } from "./venues.js";
4
+
5
+ /**
6
+ * `parabolic.venue_history` (docs/52 §5 "API and MCP", P-F): another launchpad's coin as the Parabolic site reads it
7
+ * from the chain's logs, through `GET /api/v1/coins?view=venue-history|venue-holders|venue-claimable` and a wallet's
8
+ * positions through `view=venue-position`. Read-only: nothing here builds or sends a transaction, and nothing here is
9
+ * read from a chain by this server, so every figure is the site's, in the coin's own quote, with how far back it reaches.
10
+ *
11
+ * The site answers 404 for all four while its history of other launchpads' coins is closed (`VENUE_HISTORY_OPEN`), and
12
+ * this says so rather than an empty answer.
13
+ */
14
+ export type VenueHistoryPart = "chart" | "holders" | "position" | "claimable";
15
+ export const VENUE_HISTORY_PARTS: readonly VenueHistoryPart[] = ["chart", "holders", "position", "claimable"];
16
+ const INTERVALS = [15, 60, 300, 900, 3600] as const;
17
+
18
+ type Coverage = { from?: number; to?: number; launch: number; complete: boolean; fromTime?: number; toTime?: number; fills: number; transfers: number; cut: boolean };
19
+
20
+ export async function venueHistory(apiUrl: string | undefined, chainId: number, args: { coin?: string; part: VenueHistoryPart; tf?: number; limit?: number; wallet?: string }, fetchImpl: Fetch = globalThis.fetch): Promise<Record<string, unknown>> {
21
+ if (!apiUrl) throw new Error("PARABOLIC_API_URL is empty, so there is no Parabolic site to ask. A venue coin's history is read from the site's /api/v1/coins views.");
22
+ const base = apiUrl.replace(/\/+$/, "");
23
+ const qs = new URLSearchParams({ chain: String(chainId) });
24
+ if (args.part === "position") {
25
+ if (typeof args.wallet !== "string" || !isAddress(args.wallet, { strict: false })) throw new Error(`wallet must be a 0x wallet address, got ${JSON.stringify(args.wallet)}`);
26
+ qs.set("view", "venue-position");
27
+ qs.set("wallet", getAddress(args.wallet));
28
+ } else {
29
+ const ref = parseVenueRefInput(String(args.coin ?? ""));
30
+ if (!ref) throw new Error("coin must be the site's reference of a coin made on another launchpad: `rhc:pons:0x…` (or `pons:0x…` on this server's chain).");
31
+ qs.set("view", args.part === "chart" ? "venue-history" : args.part === "holders" ? "venue-holders" : "venue-claimable");
32
+ qs.set("venue", ref.venue);
33
+ qs.set("coin", ref.address);
34
+ if (args.part === "chart") {
35
+ const tf = args.tf ?? 3600;
36
+ if (!(INTERVALS as readonly number[]).includes(tf)) throw new Error("tf must be one of 15, 60, 300, 900 or 3600 seconds.");
37
+ qs.set("tf", String(tf));
38
+ qs.set("limit", String(Math.min(500, Math.max(1, args.limit ?? 50))));
39
+ }
40
+ if (args.part === "holders") qs.set("limit", String(Math.min(1000, Math.max(1, args.limit ?? 50))));
41
+ }
42
+ const url = `${base}/coins?${qs}`;
43
+ const res = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
44
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
45
+ if (res.status === 404) throw new Error(`The site answered 404 for GET ${url}: ${String(body.error ?? "no such coin, or the history of other launchpads' coins is not open there")}.`);
46
+ if (!res.ok) throw new Error(`GET ${url} answered ${res.status}${body.error ? `: ${String(body.error)}` : ""}`);
47
+ const source = `Parabolic site: GET ${url}`;
48
+ const coverage = body.coverage as Coverage | undefined;
49
+ const reach = coverage ? (coverage.complete ? "Every fill and transfer since the coin's launch was read." : `Read back to block ${coverage.from ?? "?"} so far, not yet to the launch (block ${coverage.launch}); the site reads further on each ask.`) : undefined;
50
+ if (args.part === "chart") {
51
+ const candles = (body.candles as number[][] | undefined) ?? [];
52
+ return {
53
+ coin: body.coin, venue: body.venue, chain: body.chain, unit: body.unit, tf: body.tf, fills: body.fills, complete: body.complete, coverage,
54
+ candles: candles.slice(-200).map((c) => ({ t: c[0], open: c[1], high: c[2], low: c[3], close: c[4], volume: c[5], trades: c[6] })),
55
+ trades: body.trades, source,
56
+ notes: [reach, "Prices and volumes are in the coin's own quote (`unit`), from each trade's own amounts; nothing here prices a trade. Venue figures never count toward Parabolic's totals."].filter(Boolean),
57
+ };
58
+ }
59
+ if (args.part === "holders") {
60
+ return {
61
+ coin: body.coin, venue: body.venue, chain: body.chain, count: body.count, complete: body.complete, supply: body.supply, confirmedAt: body.confirmedAt, facts: body.facts, holders: body.holders, coverage, source,
62
+ notes: [reach, "Balances are each address's transfers folded since launch; the largest are confirmed with balanceOf at the chain's head (`confirmed`). `role` names the coin's own contracts (its curve, its pool), never counted as wallets."].filter(Boolean),
63
+ };
64
+ }
65
+ if (args.part === "claimable") {
66
+ return { ...body, source, notes: ["The fee recipient's whole balance on the launchpad's own escrow (every coin of theirs there), read-only: it is claimed on the launchpad, never through Parabolic."] };
67
+ }
68
+ return { ...body, source, notes: ["Positions in the launchpads the site indexes itself: balances across their listed coins, trades routed through Parabolic's router, and fills from a coin's history where it reaches the launch. A coin outside the listed set and never routed is not found here."] };
69
+ }
package/src/venues.ts CHANGED
@@ -91,3 +91,46 @@ export function venueSubgraphsFrom(env: Record<string, string | undefined>, chai
91
91
  }
92
92
  return out;
93
93
  }
94
+
95
+ // ---- reading another launchpad's coin through the Parabolic site (docs/52 P-D) ----------------------------------
96
+
97
+ /**
98
+ * A coin made on another launchpad, as the site writes its reference: `<chain>:<venue>:0x…` (`rhc:pons:0x…`,
99
+ * `arc:solonpad:0x…`), or `<venue>:0x…` on this server's chain. Undefined for anything else, so a plain address or a
100
+ * Parabolic ref (`rhc:0x…`) keeps the path it has always taken. The venue is any lowercase word the site might know
101
+ * (it knows more launchpads than this package routes); the site answers a 400 for one it does not read there.
102
+ */
103
+ export type VenueRefInput = { chainPart?: string; venue: string; address: Address };
104
+ export function parseVenueRefInput(input: string): VenueRefInput | undefined {
105
+ const parts = String(input ?? "").trim().split(":");
106
+ if (parts.length < 2 || parts.length > 3) return undefined;
107
+ const address = parts[parts.length - 1];
108
+ const venue = parts[parts.length - 2].toLowerCase();
109
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address) || !/^[a-z]{2,24}$/.test(venue) || venue === "parabolic") return undefined;
110
+ // `rhc:0x…` is a chain prefix, not a venue: a two-part ref whose first part names a chain is Parabolic's.
111
+ if (parts.length === 2 && ["arc", "rhc", "base", "eth", "bnb", "sol", "arct", "anvil"].includes(venue)) return undefined;
112
+ return { ...(parts.length === 3 ? { chainPart: parts[0].toLowerCase() } : {}), venue, address: address as Address };
113
+ }
114
+
115
+ /** The coin as the site's public API answers it (web/lib/api/coins.ts `serializeCoin`), the fields this package reads. */
116
+ export type SiteVenueCoin = {
117
+ chain?: string; venue?: string; address: string; curve?: string | null; name: string; ticker: string; creator: string | null; logo?: string | null; description?: string | null;
118
+ pair: string; pairToken?: string | null; quoteDecimals?: number | null; quoteDollar?: boolean | null;
119
+ status: "climbing" | "graduated"; price: number; marketCap: number; raised: number; threshold: number; progressPct: number;
120
+ holders?: number | null; volume24h?: number | null; change24h?: number | null; tradeCount?: number | null;
121
+ createdAt?: number | null; lastTradeAt?: number | null; graduatedAt?: number | null; poolId?: string | null; url?: string;
122
+ };
123
+
124
+ /**
125
+ * One venue coin from the site (GET <api>/coins/{address}?chain=<key>&venue=<id>), which reads the launchpad's index or
126
+ * subgraph and, for a coin neither holds, the launchpad's own factory at the head. Undefined on a 404 (no such coin on
127
+ * that launchpad); any other failure throws a sentence naming the site's answer, never the URL's query.
128
+ */
129
+ export async function fetchSiteVenueCoin(apiUrl: string, chainKey: string, venue: string, address: Address, fetchImpl: typeof fetch = fetch): Promise<SiteVenueCoin | undefined> {
130
+ const base = apiUrl.replace(/\/+$/, "");
131
+ const res = await fetchImpl(`${base}/coins/${address}?chain=${encodeURIComponent(chainKey)}&venue=${encodeURIComponent(venue)}`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
132
+ if (res.status === 404) return undefined;
133
+ const body = (await res.json().catch(() => ({}))) as { coin?: SiteVenueCoin; error?: string };
134
+ if (!res.ok || !body.coin) throw new Error(`The Parabolic site could not read that coin (HTTP ${res.status}${body.error ? `: ${body.error}` : ""}).`);
135
+ return body.coin;
136
+ }