/** * OpenApiClient — GMGN OpenAPI external client * * Auth modes: * Exist (market/token/portfolio): X-APIKEY + timestamp + client_id * Signed (swap and order routes): X-APIKEY + timestamp + client_id + X-Signature (private key signature) */ import { buildAuthQuery, buildMessage, detectAlgorithm, sign } from "./signer.js"; const RATE_LIMIT_RETRY_BUFFER_MS = 1000; const DEFAULT_RATE_LIMIT_AUTO_RETRY_MAX_WAIT_MS = 5000; interface PreparedRequest { method: string; subPath: string; url: string; headers: Record; body: string | null; curlStr: string; } interface ResponseEnvelope { code: number | string; data?: unknown; message?: string; error?: string; } interface OpenApiErrorParams { method: string; path: string; status: number; apiCode?: number | string; apiError?: string; apiMessage?: string; resetAtUnix?: number; } class OpenApiError extends Error { readonly method: string; readonly path: string; readonly status: number; readonly apiCode?: number | string; readonly apiError?: string; readonly apiMessage?: string; readonly resetAtUnix?: number; constructor(params: OpenApiErrorParams) { super(buildOpenApiErrorMessage(params)); this.name = "OpenApiError"; this.method = params.method; this.path = params.path; this.status = params.status; this.apiCode = params.apiCode; this.apiError = params.apiError; this.apiMessage = params.apiMessage; this.resetAtUnix = params.resetAtUnix; } } 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; gas_price?: string; gas_level?: string; auto_fee?: boolean; max_fee_per_gas?: string; max_priority_fee_per_gas?: string; condition_orders?: StrategyConditionOrder[]; sell_ratio_type?: string; } export interface StrategyConditionOrder { order_type: string; // "profit_stop" | "loss_stop" | "profit_stop_trace" | "loss_stop_trace" side: string; // "sell" price_scale?: string; sell_ratio: string; drawdown_rate?: string; } export interface MultiSwapParams { chain: string; accounts: string[]; input_token: string; output_token: string; input_amount?: Record; input_amount_bps?: Record; output_amount?: Record; swap_mode?: string; slippage?: number; auto_slippage?: boolean; is_anti_mev?: boolean; priority_fee?: string; tip_fee?: string; gas_price?: string; gas_level?: string; auto_fee?: boolean; max_fee_per_gas?: string; max_priority_fee_per_gas?: string; condition_orders?: StrategyConditionOrder[]; sell_ratio_type?: string; } export interface StrategyCreateParams { chain: string; from_address: string; base_token: string; quote_token: string; order_type: string; sub_order_type: string; check_price?: string; open_price?: string; amount_in?: string; amount_in_percent?: string; limit_price_mode?: string; price_gap_ratio?: string; expire_in?: number; sell_ratio_type?: string; slippage?: number; auto_slippage?: boolean; fee?: string; auto_fee?: boolean; gas_price?: string; gas_level?: string; max_fee_per_gas?: string; max_priority_fee_per_gas?: string; is_anti_mev?: boolean; anti_mev_mode?: string; priority_fee?: string; tip_fee?: string; custom_rpc?: string; condition_orders?: StrategyConditionOrder[]; quote_investment?: string; } export interface StrategyCancelParams { chain: string; from_address: string; order_id: string; order_type?: string; close_sell_model?: string; } export interface TokenSignalGroup { signal_type?: number[]; mc_min?: number; mc_max?: number; trigger_mc_min?: number; trigger_mc_max?: number; total_fee_min?: number; total_fee_max?: number; min_create_or_open_ts?: string; max_create_or_open_ts?: string; } export interface PumpFeeShareInfo { provider: string; // "solana" | "twitter" | "github" username: string; // platform username; a SOL address when provider = "solana" basic_points: number; } export interface BAGSFeeShareInfo { provider: string; // "twitter" | "solana" | "kick" | "github" username: string; basic_points: number; } export interface FlapRateConf { tax_rate?: number; // V5 unified tax rate, e.g. 5% -> 500 buy_tax_rate?: number; // V6 separate buy tax rate sell_tax_rate?: number; // V6 separate sell tax rate mkt_bps?: number; deflation_bps?: number; dividend_bps?: number; lp_bps?: number; minimum_share_balance?: number; recipient_type?: string; beneficiary?: string; twitter_account?: string; split_conf?: Array<{ recipient: string; bps: number }>; } export interface FourmemeRateConf { fee_plan?: boolean; fee_rate?: number; burn_rate?: number; divide_rate?: number; liquidity_rate?: number; recipient_rate?: number; recipient_address?: string; min_sharing?: number; } export interface BuyWalletInfo { from_address: string; buy_amt: string; } // Buy/sell execution config for CondMarket orders (snipe buy, auto-sell, pending_sell). // Does NOT affect the main creation tx. Falls back to outer-level fields when omitted. export interface TradeParam { slippage?: number; auto_slippage?: boolean; fee?: string; priority_fee?: string; tip_fee?: string; gas_price?: string; max_priority_fee_per_gas?: string; max_fee_per_gas?: string; is_anti_mev?: boolean; // backend currently forces true; passing has no effect anti_mev_mode?: string; // backend currently forces "secure"; passing has no effect } export interface CookingSellConfig { sell_type: string; // "delay_sell" | "limit_order" delay_sec?: number; // required when sell_type = "delay_sell" delay_mili_sec?: number; // optional; takes precedence over delay_sec sell_ratio: string; // "1" = 100%, "0.5" = 50% check_price?: string; // trigger market cap in USD; required when sell_type = "limit_order" wallet_addresses: string[]; // empty array means the strategy is inert } export interface CreateTokenParams { // Required chain: string; dex: string; from_address: string; name: string; symbol: string; buy_amt: string; // Image (one required) image?: string; image_url?: string; // Social description?: string; website?: string; twitter?: string; telegram?: string; // Transaction slippage?: number; auto_slippage?: boolean; // Fees fee?: string; priority_fee?: string; tip_fee?: string; gas_price?: string; max_priority_fee_per_gas?: string; max_fee_per_gas?: string; // Anti-MEV (SOL only) is_anti_mev?: boolean; anti_mev_mode?: string; // Advanced raised_token?: string; dev_gas?: string; dev_priority?: string; dev_tip?: string; dev_max_fee_per_gas?: string; approve_vision?: string; source?: string; dev_wallet_bps?: number; // CondMarket buy/sell execution config (snipe / bundle / auto-sell) buy_trade_config?: TradeParam; sell_trade_config?: TradeParam; // Auto-sell strategies created after a successful launch sell_configs?: CookingSellConfig[]; // Pump.fun specific is_mayhem?: boolean; is_cashback?: boolean; is_buy_back?: boolean; pump_fee_share_list?: PumpFeeShareInfo[]; // Flap specific flap_rate_conf?: FlapRateConf; // FourMeme specific fourmeme_rate_conf?: FourmemeRateConf; // BAGS specific bags_fee_share_list?: BAGSFeeShareInfo[]; // Bonk specific bonk_model?: string; // Multi-wallet buy buy_wallets?: BuyWalletInfo[]; snip_buy_wallets?: BuyWalletInfo[]; interval_seconds?: number; } 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 (exist auth) ---- async getTokenInfo(chain: string, address: string): Promise { return this.authExistRequest("GET", "/v1/token/info", { chain, address }); } async getTokenSecurity(chain: string, address: string): Promise { return this.authExistRequest("GET", "/v1/token/security", { chain, address }); } async getTokenPoolInfo(chain: string, address: string): Promise { return this.authExistRequest("GET", "/v1/token/pool_info", { chain, address }); } async getTokenTopHolders(chain: string, address: string, extra: Record = {}): Promise { return this.authExistRequest("GET", "/v1/market/token_top_holders", { chain, address, ...extra }); } async getTokenTopTraders(chain: string, address: string, extra: Record = {}): Promise { return this.authExistRequest("GET", "/v1/market/token_top_traders", { chain, address, ...extra }); } // ---- Market endpoints (exist auth) ---- async getTokenKline( chain: string, address: string, resolution: string, from?: number, to?: number ): Promise { const query: Record = { chain, address, resolution }; if (from != null) query["from"] = from; if (to != null) query["to"] = to; return this.authExistRequest("GET", "/v1/market/token_kline", query); } // ---- Portfolio endpoints ---- async getWalletHoldings( chain: string, walletAddress: string, extra: Record = {} ): Promise { return this.authSignedRequest("GET", "/v1/user/wallet_holdings", { chain, wallet_address: walletAddress, ...extra, }, null); } async getWalletActivity( chain: string, walletAddress: string, extra: Record = {} ): Promise { return this.authExistRequest("GET", "/v1/user/wallet_activity", { chain, wallet_address: walletAddress, ...extra, }); } async getWalletStats(chain: string, walletAddresses: string[], period = "7d"): Promise { return this.authExistRequest("GET", "/v1/user/wallet_stats", { chain, wallet_address: walletAddresses, period, }); } async getWalletTokenBalance( chain: string, walletAddress: string, tokenAddress: string ): Promise { return this.authExistRequest("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): Promise { const body = buildTrenchesBody(chain, types, platforms, limit, filters); return this.authExistRequest("POST", "/v1/trenches", { chain }, body); } // ---- Market trending endpoints (exist auth) ---- async getTrendingSwaps( chain: string, interval: string, extra: Record = {} ): Promise { return this.authExistRequest("GET", "/v1/market/rank", { chain, interval, ...extra }); } async getTokenSignalV2(chain: string, groups: TokenSignalGroup[]): Promise { return this.authExistRequest("POST", "/v1/market/token_signal", {}, { chain, groups }); } // ---- User endpoints (exist auth) ---- async getUserInfo(): Promise { return this.authExistRequest("GET", "/v1/user/info", {}); } async getFollowWallet(chain: string, extra: Record = {}): Promise { return this.authSignedRequest("GET", "/v1/trade/follow_wallet", { chain, ...extra }, null); } async getKol(chain?: string, limit?: number): Promise { const query: Record = {}; if (chain) query["chain"] = chain; if (limit != null) query["limit"] = limit; return this.authExistRequest("GET", "/v1/user/kol", query); } async getSmartMoney(chain?: string, limit?: number): Promise { const query: Record = {}; if (chain) query["chain"] = chain; if (limit != null) query["limit"] = limit; return this.authExistRequest("GET", "/v1/user/smartmoney", query); } async getCreatedTokens(chain: string, walletAddress: string, extra: Record = {}): Promise { return this.authExistRequest("GET", "/v1/user/created_tokens", { chain, wallet_address: walletAddress, ...extra }); } async quoteOrder( chain: string, from_address: string, input_token: string, output_token: string, input_amount: string, slippage: number ): Promise { const query = { chain, from_address, input_token, output_token, input_amount, slippage }; return this.authSignedRequest("GET", "/v1/trade/quote", query, null); } // ---- Swap endpoints (signed auth) ---- async swap(params: SwapParams): Promise { return this.authSignedRequest("POST", "/v1/trade/swap", {}, params); } async multiSwap(params: MultiSwapParams): Promise { return this.authSignedRequest("POST", "/v1/trade/multi_swap", {}, params); } async queryOrder(orderId: string, chain: string): Promise { return this.authSignedRequest("GET", "/v1/trade/query_order", { order_id: orderId, chain }, null); } async getGasPrice(chain: string): Promise { return this.authExistRequest("GET", "/v1/trade/gas_price", { chain }); } // ---- Strategy order endpoints (signed auth) ---- async createStrategyOrder(params: StrategyCreateParams): Promise { return this.authSignedRequest("POST", "/v1/trade/strategy/create", {}, params); } async getStrategyOrders(chain: string, extra: Record = {}): Promise { return this.authSignedRequest("GET", "/v1/trade/strategy/orders", { chain, ...extra }, null); } async cancelStrategyOrder(params: StrategyCancelParams): Promise { return this.authSignedRequest("POST", "/v1/trade/strategy/cancel", {}, params); } // ---- Cooking endpoints ---- async getCookingStatistics(): Promise { return this.authExistRequest("GET", "/v1/cooking/statistics", {}); } async createToken(params: CreateTokenParams): Promise { return this.authSignedRequest("POST", "/v1/cooking/create_token", {}, params); } // ---- Internal methods ---- private async authExistRequest( method: string, subPath: string, queryExtra: Record, body: unknown = null ): Promise { return this.executePreparedRequest(() => { const { timestamp, client_id } = buildAuthQuery(); const query: Record = { ...queryExtra, timestamp, client_id }; const url = buildUrl(`${this.host}${subPath}`, query); const headers: Record = { "X-APIKEY": this.apiKey, "Content-Type": "application/json", }; const bodyStr = body !== null ? JSON.stringify(body) : null; return { method, subPath, url, headers, body: bodyStr, curlStr: formatCurl(method, url, headers, bodyStr), }; }, true); } private async authSignedRequest( method: string, subPath: string, queryExtra: Record, body: unknown ): Promise { if (!this.privateKeyPem) { throw new Error("GMGN_PRIVATE_KEY is required for critical-auth commands (swap, order, follow-wallet, and portfolio holdings commands)"); } return this.executePreparedRequest(() => { const { timestamp, client_id } = buildAuthQuery(); const query: Record = { ...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 = { "X-APIKEY": this.apiKey, "X-Signature": signature, "Content-Type": "application/json", }; return { method, subPath, url, headers, body: bodyStr || null, curlStr: formatCurl(method, url, headers, bodyStr || null), }; }, method !== "POST"); } private async executePreparedRequest( prepare: () => PreparedRequest, autoRetryOnRateLimit: boolean ): Promise { const maxAttempts = autoRetryOnRateLimit ? 2 : 1; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const request = prepare(); const res = await this.doFetch( request.method, request.subPath, request.url, request.headers, request.body, request.curlStr ); try { return this.parseResponse(request.method, request.subPath, res, request.curlStr); } catch (err) { const retryDelayMs = getRateLimitRetryDelayMs(err, attempt, maxAttempts, autoRetryOnRateLimit); if (retryDelayMs == null) { throw err; } if (process.env.GMGN_DEBUG) { console.error( `[gmgn-cli] ${request.method} ${request.subPath} hit rate limit, retrying once in ${Math.ceil(retryDelayMs / 1000)}s` ); } await sleep(retryDelayMs); } } throw new Error("Unexpected retry loop exit"); } private async doFetch( method: string, subPath: string, url: string, headers: Record, body: string | null, curlStr: string ): Promise { 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 { 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); }; const resetAtUnix = parseRateLimitReset(res.headers.get("x-ratelimit-reset")); 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!: ResponseEnvelope; try { json = JSON.parse(text); } catch { fail(`${method} ${path} failed: HTTP ${res.status} (non-JSON response)`, text); } if (json.code !== 0) { if (process.env.GMGN_DEBUG) { console.error(`${curlStr}\n${formatResponse(res, text)}`); } throw new OpenApiError({ method, path, status: res.status, apiCode: json.code, apiError: json.error, apiMessage: json.message, resetAtUnix, }); } return json.data; } } function getRateLimitRetryDelayMs( err: unknown, attempt: number, maxAttempts: number, autoRetryOnRateLimit: boolean ): number | null { if (!autoRetryOnRateLimit || attempt >= maxAttempts) { return null; } if (!(err instanceof OpenApiError)) { return null; } if (err.apiError !== "RATE_LIMIT_EXCEEDED" && err.apiError !== "RATE_LIMIT_BANNED") { return null; } if (err.resetAtUnix == null) { return null; } const waitMs = Math.max(err.resetAtUnix * 1000 - Date.now(), 0) + RATE_LIMIT_RETRY_BUFFER_MS; return waitMs <= getAutoRetryMaxWaitMs() ? waitMs : null; } function parseRateLimitReset(raw: string | null): number | undefined { if (raw == null || raw.trim() === "") { return undefined; } const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } function getAutoRetryMaxWaitMs(): number { const raw = process.env.GMGN_RATE_LIMIT_AUTO_RETRY_MAX_WAIT_MS; if (!raw) { return DEFAULT_RATE_LIMIT_AUTO_RETRY_MAX_WAIT_MS; } const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RATE_LIMIT_AUTO_RETRY_MAX_WAIT_MS; } function buildOpenApiErrorMessage(params: OpenApiErrorParams): string { const parts = [`${params.method} ${params.path} failed: HTTP ${params.status}`]; if (params.apiCode != null) parts.push(`code=${params.apiCode}`); if (params.apiError) parts.push(`error=${params.apiError}`); if (params.apiMessage) parts.push(`message=${params.apiMessage}`); let message = parts.join(" "); if (params.status !== 429) { return message; } const resetText = params.resetAtUnix != null ? formatRateLimitReset(params.resetAtUnix) : "an unknown time"; if (params.apiError === "ERROR_RATE_LIMIT_BLOCKED") { return `${message}. Repeated business errors triggered a temporary block until ${resetText}. Fix the underlying request before retrying.`; } if (params.apiError === "RATE_LIMIT_EXCEEDED" || params.apiError === "RATE_LIMIT_BANNED") { return `${message}. Rate limit resets at ${resetText}. Stop sending requests before then; repeated requests can extend the ban by 5s up to 5 minutes.`; } return `${message}. Received HTTP 429; retry after ${resetText}.`; } function formatRateLimitReset(resetAtUnix: number): string { const resetAt = new Date(resetAtUnix * 1000); const remainingSeconds = Math.max(Math.ceil((resetAt.getTime() - Date.now()) / 1000), 0); return `${formatLocalTimestamp(resetAt)} (~${remainingSeconds}s remaining)`; } function formatLocalTimestamp(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); const offsetMinutes = -date.getTimezoneOffset(); const sign = offsetMinutes >= 0 ? "+" : "-"; const absOffsetMinutes = Math.abs(offsetMinutes); const offsetHours = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0"); const offsetMins = String(absOffsetMinutes % 60).padStart(2, "0"); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} GMT${sign}${offsetHours}:${offsetMins}`; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } 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, 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 = { 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 = { 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): Record { 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 = { filters: ["offchain", "onchain"], launchpad_platform, quote_address_type, launchpad_platform_v2: true, limit: actualLimit, ...filters, }; const body: Record = { version: "v2" }; for (const type of selectedTypes) { body[type] = { ...section }; } return body; } function buildUrl(base: string, query: Record): 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; }