mirror of
https://github.com/GMGNAI/gmgn-skills.git
synced 2026-08-20 03:28:08 +00:00
initial import
This commit is contained in:
@@ -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()}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user