mirror of
https://github.com/GMGNAI/gmgn-skills.git
synced 2026-07-27 16:57:44 +00:00
2d7d0043e6
Add robinhood as a supported chain across the CLI and skills, and stop advertising chains that are not publicly supported yet. robinhood: - validate.ts: add to VALID_CHAINS and treat as EVM for address checks - reject guards for commands that do not support it: track kol, track smartmoney, cooking create (mirrors market signal) - --chain help updated for supported commands (token/portfolio/swap/ market kline·trending·trenches·hot-searches/track follow-*/order get/ gas-price); OpenApiClient chain comment - docs: all SKILL.md, Readme.md, Readme.zh.md (split-out row for unsupported commands), cli-usage.md, plugin/marketplace descriptions disable monad/megaeth/hyperevm/tron: - comment monad out of VALID_CHAINS (kept for quick re-enable) - strip these chains from all --chain help, SKILL.md, cli-usage.md (hot-searches default now 4 chains: sol/bsc/base/eth) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
893 lines
28 KiB
TypeScript
893 lines
28 KiB
TypeScript
/**
|
|
* 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 { createRequire } from "node:module";
|
|
|
|
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;
|
|
|
|
const { version: CLI_VERSION } = createRequire(import.meta.url)("../../package.json") as { version: string };
|
|
const USER_AGENT = `gmgn-cli/${CLI_VERSION}`;
|
|
|
|
interface PreparedRequest {
|
|
method: string;
|
|
subPath: string;
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
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<string, string>;
|
|
input_amount_bps?: Record<string, string>;
|
|
output_amount?: Record<string, string>;
|
|
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;
|
|
sell_param?: TradeParam;
|
|
buy_param?: TradeParam;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// HotSearchesParam carries its filter fields flattened (no nested `filter` object):
|
|
// label/interval/chain plus optional `filters` boolean tags, `limit`, and rank-style
|
|
// numeric range bounds (min_<metric>/max_<metric>) incl. min_created/max_created.
|
|
// Extra range keys are forwarded verbatim; the service translates metric names per
|
|
// interval. See `market hot-searches` docs for the supported metric list.
|
|
export interface HotSearchesParam {
|
|
label?: string;
|
|
interval: string; // "1m" | "5m" | "1h" | "6h" | "24h"
|
|
chain: string; // "sol" | "bsc" | "base" | "eth" | "robinhood"
|
|
filters?: string[];
|
|
limit?: number;
|
|
[key: string]: string[] | number | string | undefined;
|
|
}
|
|
|
|
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[];
|
|
}
|
|
|
|
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<unknown> {
|
|
return this.authExistRequest("GET", "/v1/token/info", { chain, address });
|
|
}
|
|
|
|
async getTokenSecurity(chain: string, address: string): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/token/security", { chain, address });
|
|
}
|
|
|
|
async getTokenPoolInfo(chain: string, address: string): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/token/pool_info", { chain, address });
|
|
}
|
|
|
|
async getTokenTopHolders(chain: string, address: string, extra: Record<string, string | number> = {}): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/market/token_top_holders", { chain, address, ...extra });
|
|
}
|
|
|
|
async getTokenTopTraders(chain: string, address: string, extra: Record<string, string | number> = {}): Promise<unknown> {
|
|
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<unknown> {
|
|
const query: Record<string, string | number> = { 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<string, string | number> = {}
|
|
): Promise<unknown> {
|
|
return this.authSignedRequest("GET", "/v1/user/wallet_holdings", {
|
|
chain,
|
|
wallet_address: walletAddress,
|
|
...extra,
|
|
}, null);
|
|
}
|
|
|
|
async getWalletActivity(
|
|
chain: string,
|
|
walletAddress: string,
|
|
extra: Record<string, string | number | string[]> = {}
|
|
): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/user/wallet_activity", {
|
|
chain,
|
|
wallet_address: walletAddress,
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
async getWalletStats(chain: string, walletAddresses: string[], period = "7d"): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/user/wallet_stats", {
|
|
chain,
|
|
wallet_address: walletAddresses,
|
|
period,
|
|
});
|
|
}
|
|
|
|
async getWalletTokenBalance(
|
|
chain: string,
|
|
walletAddress: string,
|
|
tokenAddress: string
|
|
): Promise<unknown> {
|
|
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<string, number | string>): Promise<unknown> {
|
|
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<string, string | number | string[]> = {}
|
|
): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/market/rank", { chain, interval, ...extra });
|
|
}
|
|
|
|
async getTokenSignalV2(chain: string, groups: TokenSignalGroup[]): Promise<unknown> {
|
|
return this.authExistRequest("POST", "/v1/market/token_signal", {}, { chain, groups });
|
|
}
|
|
|
|
async getHotSearches(params: HotSearchesParam[]): Promise<unknown> {
|
|
return this.authExistRequest("POST", "/v1/market/hot_searches", {}, { params });
|
|
}
|
|
|
|
// ---- User endpoints (exist auth) ----
|
|
|
|
async getUserInfo(): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/user/info", {});
|
|
}
|
|
|
|
async getFollowWallet(chain: string, extra: Record<string, string | number | string[]> = {}): Promise<unknown> {
|
|
return this.authSignedRequest("GET", "/v1/trade/follow_wallet", { chain, ...extra }, null);
|
|
}
|
|
|
|
async getFollowTokens(chain: string, walletAddress: string, extra: Record<string, string | number> = {}): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/user/follow_tokens", { chain, wallet_address: walletAddress, ...extra });
|
|
}
|
|
|
|
async getFollowGroupNames(chain: string, walletAddress: string): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/user/follow_token_groups", { chain, wallet_address: walletAddress });
|
|
}
|
|
|
|
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.authExistRequest("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.authExistRequest("GET", "/v1/user/smartmoney", query);
|
|
}
|
|
|
|
async getCreatedTokens(chain: string, walletAddress: string, extra: Record<string, string | number> = {}): Promise<unknown> {
|
|
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<unknown> {
|
|
const query = { chain, from_address, input_token, output_token, input_amount, slippage };
|
|
return this.authExistRequest("GET", "/v1/trade/quote", query);
|
|
}
|
|
|
|
// ---- Swap endpoints (signed auth) ----
|
|
|
|
async swap(params: SwapParams): Promise<unknown> {
|
|
return this.authSignedRequest("POST", "/v1/trade/swap", {}, params);
|
|
}
|
|
|
|
async multiSwap(params: MultiSwapParams): Promise<unknown> {
|
|
return this.authSignedRequest("POST", "/v1/trade/multi_swap", {}, params);
|
|
}
|
|
|
|
async queryOrder(orderId: string, chain: string): Promise<unknown> {
|
|
return this.authSignedRequest("GET", "/v1/trade/query_order", { order_id: orderId, chain }, null);
|
|
}
|
|
|
|
async getGasPrice(chain: string): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/trade/gas_price", { chain });
|
|
}
|
|
|
|
// ---- Strategy order endpoints (signed auth) ----
|
|
|
|
async createStrategyOrder(params: StrategyCreateParams): Promise<unknown> {
|
|
return this.authSignedRequest("POST", "/v1/trade/strategy/create", {}, params);
|
|
}
|
|
|
|
async getStrategyOrders(chain: string, extra: Record<string, string | number> = {}): Promise<unknown> {
|
|
return this.authSignedRequest("GET", "/v1/trade/strategy/orders", { chain, ...extra }, null);
|
|
}
|
|
|
|
async cancelStrategyOrder(params: StrategyCancelParams): Promise<unknown> {
|
|
return this.authSignedRequest("POST", "/v1/trade/strategy/cancel", {}, params);
|
|
}
|
|
|
|
// ---- Cooking endpoints ----
|
|
|
|
async getCookingStatistics(): Promise<unknown> {
|
|
return this.authExistRequest("GET", "/v1/cooking/statistics", {});
|
|
}
|
|
|
|
async createToken(params: CreateTokenParams): Promise<unknown> {
|
|
return this.authSignedRequest("POST", "/v1/cooking/create_token", {}, params);
|
|
}
|
|
|
|
// ---- Internal methods ----
|
|
|
|
private async authExistRequest(
|
|
method: string,
|
|
subPath: string,
|
|
queryExtra: Record<string, string | number | string[]>,
|
|
body: unknown = null
|
|
): Promise<unknown> {
|
|
return this.executePreparedRequest(() => {
|
|
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",
|
|
"User-Agent": USER_AGENT,
|
|
};
|
|
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<string, string | number | string[]>,
|
|
body: unknown
|
|
): Promise<unknown> {
|
|
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<string, string | number | string[]> = { ...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",
|
|
"User-Agent": USER_AGENT,
|
|
};
|
|
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<unknown> {
|
|
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<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);
|
|
};
|
|
|
|
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<void> {
|
|
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<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",
|
|
"cubepeg", "likwid", "goplus_creator", "goplus_skills", "openfour",
|
|
"flap", "flap_stocks", "flap_aioracle", "clanker", "lunafun",
|
|
],
|
|
base: [
|
|
"clanker", "bankr", "flaunch", "zora", "zora_creator",
|
|
"baseapp", "basememe", "virtuals_v2", "klik",
|
|
],
|
|
eth: [
|
|
"trench", "clanker", "klik", "livo", "stroid",
|
|
"pool_uniswap_v2", "pool_uniswap_v3", "printr",
|
|
],
|
|
};
|
|
|
|
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],
|
|
eth: [20, 11, 8, 3, 12, 1, 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;
|
|
}
|