initial import

This commit is contained in:
dev
2026-03-16 18:40:55 +08:00
commit d9d1bf8200
29 changed files with 3287 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
/**
* 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): Promise<unknown> {
return this.normalRequest("GET", "/v1/market/token_top_holders", { chain, address });
}
async getTokenTopTraders(chain: string, address: string): Promise<unknown> {
return this.normalRequest("GET", "/v1/market/token_top_traders", { chain, address });
}
// ---- Market endpoints (normal auth) ----
async getTokenKline(
chain: string,
address: string,
resolution: string,
from: number,
to: number
): Promise<unknown> {
return this.normalRequest("GET", "/v1/market/token_kline", { chain, address, resolution, from, to });
}
// ---- 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 });
}
// ---- 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", {});
}
// ---- 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 = err instanceof Error ? (err.cause ?? err) : err;
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`;
}
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()}`;
}
+83
View File
@@ -0,0 +1,83 @@
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
export type SignAlgorithm = "Ed25519" | "RSA-SHA256";
/**
* Detect signing algorithm from PEM private key
*/
export function detectAlgorithm(pem: string): SignAlgorithm {
const key = crypto.createPrivateKey(pem);
switch (key.asymmetricKeyType) {
case "ed25519": return "Ed25519";
case "rsa": return "RSA-SHA256";
default:
throw new Error(`Unsupported key type: ${key.asymmetricKeyType}. Supported: Ed25519, RSA`);
}
}
/**
* Build auth query params (timestamp + client_id)
* timestamp: Unix seconds, server validates within ±5s
* client_id: UUID, replays rejected within 7s
*/
export function buildAuthQuery(): { timestamp: number; client_id: string } {
return {
timestamp: Math.floor(Date.now() / 1000),
client_id: crypto.randomUUID(),
};
}
/**
* Build the signature message (critical auth)
* Format: {sub_path}:{sorted_query_string}:{request_body}:{timestamp}
* sorted_query_string: all query params (including timestamp, client_id) sorted alphabetically by key
*/
export function buildMessage(
subPath: string,
queryParams: Record<string, string | number>,
body: string,
timestamp: number
): string {
const sortedQs = Object.keys(queryParams)
.sort()
.map((k) => `${k}=${queryParams[k]}`)
.join("&");
return `${subPath}:${sortedQs}:${body}:${timestamp}`;
}
/**
* Load private key file (PEM format)
*/
export function loadPrivateKey(keyPath: string): string {
const resolved = path.resolve(process.cwd(), keyPath);
return fs.readFileSync(resolved, "utf-8");
}
/**
* Sign a message and return the base64-encoded signature
*
* Ed25519: signs raw message bytes (no hashing)
* RSA-SHA256: RSA-PSS + SHA256, salt length = 32 (matches server-side rsa.VerifyPSS nil opts)
*/
export function sign(
message: string,
privateKeyPem: string,
algorithm: SignAlgorithm
): string {
const msgBuf = Buffer.from(message, "utf-8");
if (algorithm === "Ed25519") {
const sig = crypto.sign(null, msgBuf, privateKeyPem);
return sig.toString("base64");
}
// RSA-SHA256 with PSS padding, salt length = digest length (32)
const sig = crypto.sign("sha256", msgBuf, {
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32,
});
return sig.toString("base64");
}
+54
View File
@@ -0,0 +1,54 @@
import { Command } from "commander";
import { OpenApiClient } from "../client/OpenApiClient.js";
import { getConfig } from "../config.js";
import { exitOnError, printResult } from "../output.js";
import { validateAddress, validateChain } from "../validate.js";
export function registerMarketCommands(program: Command): void {
const market = program.command("market").description("Market data commands");
market
.command("kline")
.description("Get token K-line (candlestick) data")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--address <address>", "Token contract address")
.requiredOption("--resolution <resolution>", "Candlestick resolution: 1m / 5m / 15m / 1h / 4h / 1d")
.requiredOption("--from <timestamp>", "Start time (Unix seconds)", parseInt)
.requiredOption("--to <timestamp>", "End time (Unix seconds)", parseInt)
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
validateAddress(opts.address, opts.chain, "--address");
const client = new OpenApiClient(getConfig());
const data = await client
.getTokenKline(opts.chain, opts.address, opts.resolution, opts.from * 1000, opts.to * 1000)
.catch(exitOnError);
printResult(data, opts.raw);
});
market
.command("trending")
.description("Get trending token swap data")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--interval <interval>", "Time interval: 1h / 3h / 6h / 24h")
.option("--limit <n>", "Number of results (default 100, max 100)", parseInt)
.option("--orderby <field>", "Sort field: score / volume / swaps / liquidity / marketcap / holders / price / change / ...")
.option("--direction <dir>", "Sort direction: asc / desc")
.option("--filter <tag...>", "Filter tags, repeatable: has_social / not_risk / not_honeypot / verified / locked / renounced / ...")
.option("--platform <name...>", "Platform filter, repeatable: pump / moonshot / ...")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
const extra: Record<string, string | number | string[]> = {};
if (opts.limit != null) extra["limit"] = opts.limit;
if (opts.orderby) extra["order_by"] = opts.orderby;
if (opts.direction) extra["direction"] = opts.direction;
if (opts.filter?.length) extra["filters"] = opts.filter;
if (opts.platform?.length) extra["platforms"] = opts.platform;
const client = new OpenApiClient(getConfig());
const data = await client.getTrendingSwaps(opts.chain, opts.interval, extra).catch(exitOnError);
printResult(data, opts.raw);
});
}
+116
View File
@@ -0,0 +1,116 @@
import { Command } from "commander";
import { OpenApiClient } from "../client/OpenApiClient.js";
import { getConfig } from "../config.js";
import { exitOnError, printResult } from "../output.js";
import { validateAddress, validateChain } from "../validate.js";
export function registerPortfolioCommands(program: Command): void {
const portfolio = program.command("portfolio").description("Wallet portfolio commands");
portfolio
.command("holdings")
.description("Get wallet token holdings")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--wallet <address>", "Wallet address")
.option("--limit <n>", "Page size (default 20, max 50)", parseInt, 20)
.option("--cursor <cursor>", "Pagination cursor")
.option("--order-by <field>", "Sort field: usd_value / price / price_change / unrealized_profit / realized_profit / ...", "usd_value")
.option("--direction <dir>", "Sort direction: asc / desc", "desc")
.option("--interval <interval>", "Stats interval (default 24h)")
.option("--sell-out", "Include sold-out positions")
.option("--show-small", "Include small-value positions")
.option("--hide-abnormal", "Hide abnormal positions")
.option("--hide-airdrop", "Hide airdrop positions")
.option("--hide-closed", "Hide closed positions")
.option("--hide-open", "Hide open positions")
.option("--tx30d", "Only show positions with trades in last 30 days")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
validateAddress(opts.wallet, opts.chain, "--wallet");
const extra: Record<string, string | number> = {};
if (opts.limit != null) extra["limit"] = opts.limit;
if (opts.cursor) extra["cursor"] = opts.cursor;
if (opts.orderBy) extra["order_by"] = opts.orderBy;
if (opts.direction) extra["direction"] = opts.direction;
if (opts.interval) extra["interval"] = opts.interval;
if (opts.sellOut) extra["sell_out"] = "true";
if (opts.showSmall) extra["show_small"] = "true";
if (opts.hideAbnormal) extra["hide_abnormal"] = "true";
if (opts.hideAirdrop) extra["hide_airdrop"] = "true";
if (opts.hideClosed) extra["hide_closed"] = "true";
if (opts.hideOpen) extra["hide_open"] = "true";
if (opts.tx30d) extra["tx30d"] = "true";
const client = new OpenApiClient(getConfig());
const data = await client.getWalletHoldings(opts.chain, opts.wallet, extra).catch(exitOnError);
printResult(data, opts.raw);
});
portfolio
.command("activity")
.description("Get wallet transaction activity")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--wallet <address>", "Wallet address")
.option("--token <address>", "Filter by token contract address")
.option("--limit <n>", "Page size", parseInt)
.option("--cursor <cursor>", "Pagination cursor")
.option("--type <type...>", "Activity type filter, repeatable: buy / sell / add / remove / transfer")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
validateAddress(opts.wallet, opts.chain, "--wallet");
if (opts.token) validateAddress(opts.token, opts.chain, "--token");
const extra: Record<string, string | number | string[]> = {};
if (opts.token) extra["token"] = opts.token;
if (opts.limit != null) extra["limit"] = opts.limit;
if (opts.cursor) extra["cursor"] = opts.cursor;
if (opts.type?.length) extra["type"] = opts.type;
const client = new OpenApiClient(getConfig());
const data = await client.getWalletActivity(opts.chain, opts.wallet, extra).catch(exitOnError);
printResult(data, opts.raw);
});
portfolio
.command("stats")
.description("Get wallet trading statistics (supports multiple wallets)")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--wallet <address...>", "Wallet address(es), repeatable")
.option("--period <period>", "Stats period: 7d / 30d", "7d")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
for (const w of opts.wallet as string[]) validateAddress(w, opts.chain, "--wallet");
const client = new OpenApiClient(getConfig());
const data = await client.getWalletStats(opts.chain, opts.wallet, opts.period).catch(exitOnError);
printResult(data, opts.raw);
});
portfolio
.command("info")
.description("Get wallets and main currency balances bound to the API Key")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
const client = new OpenApiClient(getConfig());
const data = await client.getUserInfo().catch(exitOnError);
printResult(data, opts.raw);
});
portfolio
.command("token-balance")
.description("Get wallet token balance for a single token")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--wallet <address>", "Wallet address")
.requiredOption("--token <address>", "Token contract address")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
validateAddress(opts.wallet, opts.chain, "--wallet");
validateAddress(opts.token, opts.chain, "--token");
const client = new OpenApiClient(getConfig());
const data = await client.getWalletTokenBalance(opts.chain, opts.wallet, opts.token).catch(exitOnError);
printResult(data, opts.raw);
});
}
+76
View File
@@ -0,0 +1,76 @@
import { Command } from "commander";
import { OpenApiClient, SwapParams } from "../client/OpenApiClient.js";
import { getConfig } from "../config.js";
import { exitOnError, printResult } from "../output.js";
import { validateAddress, validateChain, validatePercent, validatePositiveInt } from "../validate.js";
export function registerSwapCommands(program: Command): void {
program
.command("swap")
.description("Submit a token swap")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base / eth ")
.requiredOption("--from <address>", "Wallet address (must match API Key binding)")
.requiredOption("--input-token <address>", "Input token contract address")
.requiredOption("--output-token <address>", "Output token contract address")
.option("--amount <amount>", "Input raw amount (smallest unit)")
.option("--percent <pct>", "Input amount as a percentage, e.g. 50 = 50%, 1 = 1%; only valid when input_token is NOT a currency", parseFloat)
.option("--slippage <n>", "Slippage tolerance (e.g. 0.01 = 1%)", parseFloat)
.option("--min-output <amount>", "Minimum output amount")
.option("--anti-mev", "Enable anti-MEV protection, default true")
.option("--priority-fee <sol>", "Priority fee in SOL (≥ 0.00001, SOL only)")
.option("--tip-fee <amount>", "Tip fee (SOL ≥ 0.00001 SOL / BSC ≥ 0.000001 BNB)")
.option("--max-auto-fee <amount>", "Max auto fee cap")
.option("--gas-price <gwei>", "Gas price in gwei (BSC ≥ 0.05 / BASE/ETH ≥ 0.01)")
.option("--max-fee-per-gas <amount>", "EIP-1559 max fee per gas (Base)")
.option("--max-priority-fee-per-gas <amount>", "EIP-1559 max priority fee per gas (Base)")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
if (opts.percent == null && !opts.amount) {
console.error("[gmgn-cli] Either --amount or --percent must be provided");
process.exit(1);
}
validateChain(opts.chain);
validateAddress(opts.from, opts.chain, "--from");
validateAddress(opts.inputToken, opts.chain, "--input-token");
validateAddress(opts.outputToken, opts.chain, "--output-token");
if (opts.amount) validatePositiveInt(opts.amount, "--amount");
if (opts.percent != null) validatePercent(opts.percent);
const params: SwapParams = {
chain: opts.chain,
from_address: opts.from,
input_token: opts.inputToken,
output_token: opts.outputToken,
input_amount: opts.percent != null ? (opts.amount ?? "0") : opts.amount,
};
if (opts.percent != null) params.input_amount_bps = String(Math.round(opts.percent * 100));
if (opts.slippage != null) params.slippage = opts.slippage;
if (opts.minOutput) params.min_output_amount = opts.minOutput;
if (opts.antiMev) params.is_anti_mev = true;
if (opts.priorityFee) params.priority_fee = opts.priorityFee;
if (opts.tipFee) params.tip_fee = opts.tipFee;
if (opts.maxAutoFee) params.max_auto_fee = opts.maxAutoFee;
if (opts.gasPrice) params.gas_price = String(Math.round(parseFloat(opts.gasPrice) * 1e9));
if (opts.maxFeePerGas) params.max_fee_per_gas = opts.maxFeePerGas;
if (opts.maxPriorityFeePerGas) params.max_priority_fee_per_gas = opts.maxPriorityFeePerGas;
const client = new OpenApiClient(getConfig(true));
const data = await client.swap(params).catch(exitOnError);
printResult(data, opts.raw);
});
const order = program.command("order").description("Order management commands");
order
.command("get")
.description("Query order status (requires private key)")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base / eth / monad")
.requiredOption("--order-id <id>", "Order ID")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
const client = new OpenApiClient(getConfig(true));
const data = await client.queryOrder(opts.orderId, opts.chain).catch(exitOnError);
printResult(data, opts.raw);
});
}
+80
View File
@@ -0,0 +1,80 @@
import { Command } from "commander";
import { OpenApiClient } from "../client/OpenApiClient.js";
import { getConfig } from "../config.js";
import { exitOnError, printResult } from "../output.js";
import { validateAddress, validateChain } from "../validate.js";
export function registerTokenCommands(program: Command): void {
const token = program.command("token").description("Token information commands");
token
.command("info")
.description("Get token basic information and realtime price")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--address <address>", "Token contract address")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
validateAddress(opts.address, opts.chain, "--address");
const client = new OpenApiClient(getConfig());
const data = await client.getTokenInfo(opts.chain, opts.address).catch(exitOnError);
printResult(data, opts.raw);
});
token
.command("security")
.description("Get token security metrics")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--address <address>", "Token contract address")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
validateAddress(opts.address, opts.chain, "--address");
const client = new OpenApiClient(getConfig());
const data = await client.getTokenSecurity(opts.chain, opts.address).catch(exitOnError);
printResult(data, opts.raw);
});
token
.command("pool")
.description("Get token liquidity pool information")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--address <address>", "Token contract address")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
validateAddress(opts.address, opts.chain, "--address");
const client = new OpenApiClient(getConfig());
const data = await client.getTokenPoolInfo(opts.chain, opts.address).catch(exitOnError);
printResult(data, opts.raw);
});
token
.command("holders")
.description("Get top token holders")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--address <address>", "Token contract address")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
validateAddress(opts.address, opts.chain, "--address");
const client = new OpenApiClient(getConfig());
const data = await client.getTokenTopHolders(opts.chain, opts.address).catch(exitOnError);
printResult(data, opts.raw);
});
token
.command("traders")
.description("Get top token traders")
.requiredOption("--chain <chain>", "Chain: sol / bsc / base")
.requiredOption("--address <address>", "Token contract address")
.option("--raw", "Output raw JSON")
.action(async (opts) => {
validateChain(opts.chain);
validateAddress(opts.address, opts.chain, "--address");
const client = new OpenApiClient(getConfig());
const data = await client.getTokenTopTraders(opts.chain, opts.address).catch(exitOnError);
printResult(data, opts.raw);
});
}
+48
View File
@@ -0,0 +1,48 @@
import { config as loadDotenv } from "dotenv";
import { homedir } from "os";
import { join } from "path";
// Load global config first (~/.config/gmgn/.env), then project .env (project takes precedence)
loadDotenv({ path: join(homedir(), ".config", "gmgn", ".env") });
loadDotenv({ override: true });
export interface Config {
apiKey: string;
privateKeyPem?: string;
host: string;
}
let _config: Config | null = null;
export function getConfig(requirePrivateKey = false): Config {
if (_config) {
if (requirePrivateKey && !_config.privateKeyPem) {
die("GMGN_PRIVATE_KEY is required for swap/order commands");
}
return _config;
}
const apiKey = process.env.GMGN_API_KEY;
if (!apiKey) {
die("GMGN_API_KEY is required. Set it in your .env file or environment.");
}
let privateKeyPem: string | undefined;
const privateKey = process.env.GMGN_PRIVATE_KEY;
if (privateKey) {
// Support escaped newlines (e.g. from single-line .env values)
privateKeyPem = privateKey.replace(/\\n/g, "\n");
} else if (requirePrivateKey) {
die("GMGN_PRIVATE_KEY is required for swap/order commands");
}
const host = process.env.GMGN_HOST ?? "https://openapi.gmgn.ai";
_config = { apiKey: apiKey!, privateKeyPem, host };
return _config;
}
function die(msg: string): never {
console.error(`[gmgn-cli] Error: ${msg}`);
process.exit(1);
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
import { createRequire } from "module";
const { version } = createRequire(import.meta.url)("../package.json") as { version: string };
import { setGlobalDispatcher, ProxyAgent, Agent } from "undici";
import { SocksClient } from "socks";
import * as tls from "tls";
import { Command } from "commander";
import { registerTokenCommands } from "./commands/token.js";
import { registerMarketCommands } from "./commands/market.js";
import { registerPortfolioCommands } from "./commands/portfolio.js";
import { registerSwapCommands } from "./commands/swap.js";
const proxy = process.env.HTTPS_PROXY ?? process.env.https_proxy
?? process.env.HTTP_PROXY ?? process.env.http_proxy;
if (proxy) {
const u = new URL(proxy);
if (u.protocol === "socks5:" || u.protocol === "socks4:") {
const type = u.protocol === "socks5:" ? 5 : 4;
setGlobalDispatcher(new Agent({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
connect: async (options: any, callback: any) => {
try {
const { socket } = await SocksClient.createConnection({
proxy: { host: u.hostname, port: parseInt(u.port || "1080"), type },
command: "connect",
destination: { host: options.hostname!, port: +options.port! },
});
if (options.protocol === "https:") {
callback(null, tls.connect({ socket, servername: options.hostname, rejectUnauthorized: options.rejectUnauthorized !== false }));
} else {
callback(null, socket);
}
} catch (err) {
callback(err as Error, null);
}
},
}));
} else {
setGlobalDispatcher(new ProxyAgent(proxy));
}
}
const program = new Command();
program
.name("gmgn-cli")
.version(version)
.description("GMGN OpenAPI CLI — market data, token info, portfolio and swap");
registerTokenCommands(program);
registerMarketCommands(program);
registerPortfolioCommands(program);
registerSwapCommands(program);
program.parseAsync().catch((err) => {
console.error(`[gmgn-cli] ${err.message}`);
process.exit(1);
});
+21
View File
@@ -0,0 +1,21 @@
export function printResult(data: unknown, raw?: boolean): void {
if (raw) {
console.log(JSON.stringify(data));
} else {
console.log(JSON.stringify(data, null, 2));
}
}
export function exitOnError(err: Error): never {
console.error(`[gmgn-cli] ${err.message}`);
if (process.env.GMGN_DEBUG) {
if ((err as NodeJS.ErrnoException).code) {
console.error(`[gmgn-cli] code: ${(err as NodeJS.ErrnoException).code}`);
}
if ((err as { cause?: unknown }).cause) {
console.error(`[gmgn-cli] cause: ${(err as { cause?: unknown }).cause}`);
}
console.error(err.stack ?? "");
}
process.exit(1);
}
+42
View File
@@ -0,0 +1,42 @@
const VALID_CHAINS = new Set(["sol", "bsc", "base", "eth", "monad"]);
const SOL_ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
const POSITIVE_INT_RE = /^\d+$/;
export function validateChain(chain: string): void {
if (!VALID_CHAINS.has(chain)) {
console.error(
`[gmgn-cli] Invalid chain: "${chain}". Must be one of: ${[...VALID_CHAINS].join(", ")}`
);
process.exit(1);
}
}
export function validateAddress(address: string, chain: string, label: string): void {
const isEvm = chain === "bsc" || chain === "base" || chain === "eth" || chain === "monad";
const valid = isEvm ? EVM_ADDRESS_RE.test(address) : SOL_ADDRESS_RE.test(address);
if (!valid) {
console.error(
`[gmgn-cli] Invalid ${label} address for chain "${chain}": "${address}"`
);
process.exit(1);
}
}
export function validatePositiveInt(value: string, label: string): void {
if (!POSITIVE_INT_RE.test(value) || BigInt(value) <= 0n) {
console.error(
`[gmgn-cli] Invalid ${label}: "${value}". Must be a positive integer.`
);
process.exit(1);
}
}
export function validatePercent(value: number): void {
if (value <= 0 || value > 100) {
console.error(
`[gmgn-cli] Invalid --percent: ${value}. Must be between 0 (exclusive) and 100 (inclusive).`
);
process.exit(1);
}
}