mirror of
https://github.com/GMGNAI/gmgn-skills.git
synced 2026-08-01 18:57:43 +00:00
40f75e0b64
- OpenApiClient: extend getTrenches/buildTrenchesBody to accept and spread server-side filter fields into each category section of the request body - SKILL.md: clarify entrapment_ratio description as Entrapment/Phishing - workflow-early-project-screening: add filter-preset examples to Step 1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
/**
|
|
* OpenApiClient — GMGN OpenAPI external client
|
|
*
|
|
* Auth modes:
|
|
* Normal (market/token/portfolio): X-APIKEY + timestamp + client_id
|
|
* Critical (swap/order): normal auth + X-Signature (private key signature)
|
|
*/
|
|
|
|
import { buildAuthQuery, buildMessage, detectAlgorithm, sign } from "./signer.js";
|
|
|
|
export interface Config {
|
|
apiKey: string;
|
|
privateKeyPem?: string;
|
|
host: string;
|
|
}
|
|
|
|
export interface SwapParams {
|
|
chain: string;
|
|
from_address: string;
|
|
input_token: string;
|
|
output_token: string;
|
|
input_amount: string;
|
|
swap_mode?: string;
|
|
input_amount_bps?: string;
|
|
output_amount?: string;
|
|
slippage?: number;
|
|
auto_slippage?: boolean;
|
|
min_output_amount?: string;
|
|
is_anti_mev?: boolean;
|
|
priority_fee?: string;
|
|
tip_fee?: string;
|
|
auto_tip_fee?: boolean;
|
|
max_auto_fee?: string;
|
|
gas_price?: string;
|
|
max_fee_per_gas?: string;
|
|
max_priority_fee_per_gas?: string;
|
|
}
|
|
|
|
export class OpenApiClient {
|
|
private readonly apiKey: string;
|
|
private readonly privateKeyPem: string | undefined;
|
|
private readonly host: string;
|
|
|
|
constructor(config: Config) {
|
|
this.apiKey = config.apiKey;
|
|
this.privateKeyPem = config.privateKeyPem;
|
|
this.host = config.host.replace(/\/$/, "");
|
|
}
|
|
|
|
// ---- Token endpoints (normal auth) ----
|
|
|
|
async getTokenInfo(chain: string, address: string): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/token/info", { chain, address });
|
|
}
|
|
|
|
async getTokenSecurity(chain: string, address: string): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/token/security", { chain, address });
|
|
}
|
|
|
|
async getTokenPoolInfo(chain: string, address: string): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/token/pool_info", { chain, address });
|
|
}
|
|
|
|
async getTokenTopHolders(chain: string, address: string, extra: Record<string, string | number> = {}): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/market/token_top_holders", { chain, address, ...extra });
|
|
}
|
|
|
|
async getTokenTopTraders(chain: string, address: string, extra: Record<string, string | number> = {}): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/market/token_top_traders", { chain, address, ...extra });
|
|
}
|
|
|
|
// ---- Market endpoints (normal auth) ----
|
|
|
|
async getTokenKline(
|
|
chain: string,
|
|
address: string,
|
|
resolution: string,
|
|
from?: number,
|
|
to?: number
|
|
): Promise<unknown> {
|
|
const query: Record<string, string | number> = { chain, address, resolution };
|
|
if (from != null) query["from"] = from;
|
|
if (to != null) query["to"] = to;
|
|
return this.normalRequest("GET", "/v1/market/token_kline", query);
|
|
}
|
|
|
|
// ---- Portfolio endpoints (normal auth) ----
|
|
|
|
async getWalletHoldings(
|
|
chain: string,
|
|
walletAddress: string,
|
|
extra: Record<string, string | number> = {}
|
|
): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/user/wallet_holdings", {
|
|
chain,
|
|
wallet_address: walletAddress,
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
async getWalletActivity(
|
|
chain: string,
|
|
walletAddress: string,
|
|
extra: Record<string, string | number | string[]> = {}
|
|
): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/user/wallet_activity", {
|
|
chain,
|
|
wallet_address: walletAddress,
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
async getWalletStats(chain: string, walletAddresses: string[], period = "7d"): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/user/wallet_stats", {
|
|
chain,
|
|
wallet_address: walletAddresses,
|
|
period,
|
|
});
|
|
}
|
|
|
|
async getWalletTokenBalance(
|
|
chain: string,
|
|
walletAddress: string,
|
|
tokenAddress: string
|
|
): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/user/wallet_token_balance", { chain, wallet_address: walletAddress, token_address: tokenAddress });
|
|
}
|
|
|
|
async getTrenches(chain: string, types?: string[], platforms?: string[], limit?: number, filters?: Record<string, number | string>): Promise<unknown> {
|
|
const body = buildTrenchesBody(chain, types, platforms, limit, filters);
|
|
return this.normalRequest("POST", "/v1/trenches", { chain }, body);
|
|
}
|
|
|
|
// ---- Market trending endpoints (normal auth) ----
|
|
|
|
async getTrendingSwaps(
|
|
chain: string,
|
|
interval: string,
|
|
extra: Record<string, string | number | string[]> = {}
|
|
): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/market/rank", { chain, interval, ...extra });
|
|
}
|
|
|
|
// ---- User endpoints (normal auth) ----
|
|
|
|
async getUserInfo(): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/user/info", {});
|
|
}
|
|
|
|
async getFollowWallet(chain: string, extra: Record<string, string | number | string[]> = {}): Promise<unknown> {
|
|
return this.criticalRequest("GET", "/v1/trade/follow_wallet", { chain, ...extra }, null);
|
|
}
|
|
|
|
async getKol(chain?: string, limit?: number): Promise<unknown> {
|
|
const query: Record<string, string | number> = {};
|
|
if (chain) query["chain"] = chain;
|
|
if (limit != null) query["limit"] = limit;
|
|
return this.normalRequest("GET", "/v1/user/kol", query);
|
|
}
|
|
|
|
async getSmartMoney(chain?: string, limit?: number): Promise<unknown> {
|
|
const query: Record<string, string | number> = {};
|
|
if (chain) query["chain"] = chain;
|
|
if (limit != null) query["limit"] = limit;
|
|
return this.normalRequest("GET", "/v1/user/smartmoney", query);
|
|
}
|
|
|
|
async quoteOrder(
|
|
chain: string,
|
|
from_address: string,
|
|
input_token: string,
|
|
output_token: string,
|
|
input_amount: string,
|
|
slippage: number
|
|
): Promise<unknown> {
|
|
return this.normalRequest("GET", "/v1/trade/quote", {
|
|
chain, from_address, input_token, output_token, input_amount, slippage,
|
|
});
|
|
}
|
|
|
|
// ---- Swap endpoints (critical auth) ----
|
|
|
|
async swap(params: SwapParams): Promise<unknown> {
|
|
return this.criticalRequest("POST", "/v1/trade/swap", {}, params);
|
|
}
|
|
|
|
async queryOrder(orderId: string, chain: string): Promise<unknown> {
|
|
return this.criticalRequest("GET", "/v1/trade/query_order", { order_id: orderId, chain }, null);
|
|
}
|
|
|
|
// ---- Internal methods ----
|
|
|
|
private async normalRequest(
|
|
method: string,
|
|
subPath: string,
|
|
queryExtra: Record<string, string | number | string[]>,
|
|
body: unknown = null
|
|
): Promise<unknown> {
|
|
const { timestamp, client_id } = buildAuthQuery();
|
|
const query: Record<string, string | number | string[]> = { ...queryExtra, timestamp, client_id };
|
|
|
|
const url = buildUrl(`${this.host}${subPath}`, query);
|
|
const headers: Record<string, string> = {
|
|
"X-APIKEY": this.apiKey,
|
|
"Content-Type": "application/json",
|
|
};
|
|
const bodyStr = body !== null ? JSON.stringify(body) : null;
|
|
const curlStr = formatCurl(method, url, headers, bodyStr);
|
|
const res = await this.doFetch(method, subPath, url, headers, bodyStr, curlStr);
|
|
return this.parseResponse(method, subPath, res, curlStr);
|
|
}
|
|
|
|
private async criticalRequest(
|
|
method: string,
|
|
subPath: string,
|
|
queryExtra: Record<string, string | number>,
|
|
body: unknown
|
|
): Promise<unknown> {
|
|
if (!this.privateKeyPem) {
|
|
throw new Error("GMGN_PRIVATE_KEY is required for swap/order commands");
|
|
}
|
|
|
|
const { timestamp, client_id } = buildAuthQuery();
|
|
const query: Record<string, string | number> = { ...queryExtra, timestamp, client_id };
|
|
|
|
const bodyStr = body !== null ? JSON.stringify(body) : "";
|
|
const message = buildMessage(subPath, query, bodyStr, timestamp);
|
|
const signature = sign(message, this.privateKeyPem, detectAlgorithm(this.privateKeyPem));
|
|
|
|
const url = buildUrl(`${this.host}${subPath}`, query);
|
|
const headers: Record<string, string> = {
|
|
"X-APIKEY": this.apiKey,
|
|
"X-Signature": signature,
|
|
"Content-Type": "application/json",
|
|
};
|
|
const curlStr = formatCurl(method, url, headers, bodyStr || null);
|
|
const res = await this.doFetch(method, subPath, url, headers, bodyStr || null, curlStr);
|
|
return this.parseResponse(method, subPath, res, curlStr);
|
|
}
|
|
|
|
private async doFetch(
|
|
method: string,
|
|
subPath: string,
|
|
url: string,
|
|
headers: Record<string, string>,
|
|
body: string | null,
|
|
curlStr: string
|
|
): Promise<Response> {
|
|
try {
|
|
return await fetch(url, { method, headers, body: body ?? undefined });
|
|
} catch (err: unknown) {
|
|
const cause = extractRootCause(err);
|
|
const errorCode = (cause as NodeJS.ErrnoException).code;
|
|
|
|
// Detect IPv4 unavailability errors
|
|
if (errorCode === "EADDRNOTAVAIL" || errorCode === "ENETUNREACH") {
|
|
throw new Error(
|
|
`Network unreachable (${errorCode}): Your system may not support IPv4. ` +
|
|
`Please check your network configuration or contact support.`
|
|
);
|
|
}
|
|
|
|
if (process.env.GMGN_DEBUG) console.error(`${curlStr}\n[error] fetch failed: ${cause}`);
|
|
throw new Error(`${method} ${subPath} fetch failed: ${cause}`);
|
|
}
|
|
}
|
|
|
|
private async parseResponse(
|
|
method: string,
|
|
path: string,
|
|
res: Response,
|
|
curlStr: string
|
|
): Promise<unknown> {
|
|
const fail = (msg: string, body: string | null = null): never => {
|
|
if (process.env.GMGN_DEBUG) {
|
|
console.error(`${curlStr}\n${formatResponse(res, body)}`);
|
|
}
|
|
throw new Error(msg);
|
|
};
|
|
|
|
let text!: string;
|
|
try {
|
|
text = await res.text();
|
|
} catch (err) {
|
|
fail(`${method} ${path} failed: HTTP ${res.status} (failed to read response body: ${err})`);
|
|
}
|
|
|
|
let json!: { code: number | string; data?: unknown; message?: string; error?: string };
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
fail(`${method} ${path} failed: HTTP ${res.status} (non-JSON response)`, text);
|
|
}
|
|
|
|
if (json.code !== 0) {
|
|
fail(
|
|
`${method} ${path} failed: HTTP ${res.status} code=${json.code} error=${json.error ?? ""} message=${json.message ?? ""}`,
|
|
text
|
|
);
|
|
}
|
|
|
|
return json.data;
|
|
}
|
|
}
|
|
|
|
function formatResponse(res: Response, body: string | null): string {
|
|
const headerLines = [...res.headers.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
|
|
return `[response] HTTP ${res.status}\n${headerLines}\n\n${body ?? "(no body)"}`;
|
|
}
|
|
|
|
const REDACTED_HEADERS = new Set(["x-apikey"]);
|
|
|
|
function formatCurl(method: string, url: string, headers: Record<string, string>, body: string | null): string {
|
|
const headerArgs = Object.entries(headers)
|
|
.map(([k, v]) => ` -H '${k}: ${REDACTED_HEADERS.has(k.toLowerCase()) ? "***" : v}'`)
|
|
.join(" \\\n");
|
|
const bodyArg = body ? ` \\\n -d '${body.replace(/'/g, "'\\''")}'` : "";
|
|
return `\n[curl]\ncurl -X ${method} '${url}' \\\n${headerArgs}${bodyArg}\n`;
|
|
}
|
|
|
|
const TRENCHES_PLATFORMS: Record<string, string[]> = {
|
|
sol: [
|
|
"Pump.fun", "pump_mayhem", "pump_mayhem_agent", "pump_agent",
|
|
"letsbonk", "bonkers", "bags", "memoo", "liquid", "bankr", "zora",
|
|
"surge", "anoncoin", "moonshot_app", "wendotdev", "heaven", "sugar",
|
|
"token_mill", "believe", "trendsfun", "trends_fun", "jup_studio",
|
|
"Moonshot", "boop", "ray_launchpad", "meteora_virtual_curve", "xstocks",
|
|
],
|
|
bsc: [
|
|
"fourmeme", "fourmeme_agent", "bn_fourmeme", "four_xmode_agent",
|
|
"flap", "clanker", "lunafun",
|
|
],
|
|
base: [
|
|
"clanker", "bankr", "flaunch", "zora", "zora_creator",
|
|
"baseapp", "basememe", "virtuals_v2", "klik",
|
|
],
|
|
};
|
|
|
|
const TRENCHES_QUOTE_ADDRESS_TYPES: Record<string, number[]> = {
|
|
sol: [4, 5, 3, 1, 13, 0],
|
|
bsc: [6, 7, 1, 16, 8, 3, 9, 10, 2, 17, 18, 0],
|
|
base: [11, 3, 12, 13, 0],
|
|
};
|
|
|
|
function buildTrenchesBody(chain: string, types?: string[], platforms?: string[], limit?: number, filters?: Record<string, number | string>): Record<string, unknown> {
|
|
const selectedTypes = types?.length ? types : ["new_creation", "near_completion", "completed"];
|
|
const launchpad_platform = platforms?.length ? platforms : (TRENCHES_PLATFORMS[chain] ?? []);
|
|
const quote_address_type = TRENCHES_QUOTE_ADDRESS_TYPES[chain] ?? [];
|
|
const actualLimit = limit ?? 80;
|
|
const section: Record<string, unknown> = {
|
|
filters: ["offchain", "onchain"],
|
|
launchpad_platform,
|
|
quote_address_type,
|
|
launchpad_platform_v2: true,
|
|
limit: actualLimit,
|
|
...filters,
|
|
};
|
|
const body: Record<string, unknown> = { version: "v2" };
|
|
for (const type of selectedTypes) {
|
|
body[type] = { ...section };
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function buildUrl(base: string, query: Record<string, string | number | string[]>): string {
|
|
const params = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(query)) {
|
|
if (Array.isArray(v)) {
|
|
for (const item of v) params.append(k, item);
|
|
} else {
|
|
params.set(k, String(v));
|
|
}
|
|
}
|
|
return `${base}?${params.toString()}`;
|
|
}
|
|
|
|
// Recursively extract the root cause from nested Error.cause chain
|
|
function extractRootCause(err: unknown): unknown {
|
|
if (err instanceof Error && err.cause) {
|
|
return extractRootCause(err.cause);
|
|
}
|
|
return err;
|
|
}
|