fix(track): switch follow-wallet to critical auth and fix signer array support

GET /v1/trade/follow_wallet requires X-Signature (critical auth) but was
called via normalRequest (API Key only), causing HTTP 401 AUTH_INVALID
with "missing signature".

- OpenApiClient: getFollowWallet now calls criticalRequest; criticalRequest
  queryExtra type widened to accept string[] to support the --filter option
- signer: buildMessage handles string[] values as sorted repeated k=v pairs,
  consistent with buildUrl / URLSearchParams serialization
- config: update PRIVATE_KEY_REQUIRED_MSG to include follow-wallet
- SKILL.md: prerequisites note GMGN_PRIVATE_KEY for follow-wallet; first-time
  setup now saves private key to ~/.config/gmgn/.env and removes /tmp copy;
  Notes section corrected to reflect split auth modes
- CLAUDE.md: Auth Modes table updated — follow-wallet moved to Critical row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
GMGN.AI
2026-04-20 11:42:56 +08:00
co-authored by Claude Sonnet 4.6
parent 6ab9cb108b
commit f19a1919da
5 changed files with 24 additions and 14 deletions
+4 -4
View File
@@ -307,7 +307,7 @@ export class OpenApiClient {
}
async getFollowWallet(chain: string, extra: Record<string, string | number | string[]> = {}): Promise<unknown> {
return this.normalRequest("GET", "/v1/trade/follow_wallet", { chain, ...extra });
return this.criticalRequest("GET", "/v1/trade/follow_wallet", { chain, ...extra }, null);
}
async getKol(chain?: string, limit?: number): Promise<unknown> {
@@ -409,16 +409,16 @@ export class OpenApiClient {
private async criticalRequest(
method: string,
subPath: string,
queryExtra: Record<string, string | number>,
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 and all order commands)");
throw new Error("GMGN_PRIVATE_KEY is required for critical-auth commands (swap, order, and follow-wallet commands)");
}
return this.executePreparedRequest(() => {
const { timestamp, client_id } = buildAuthQuery();
const query: Record<string, string | number> = { ...queryExtra, timestamp, client_id };
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!));
+10 -3
View File
@@ -32,17 +32,24 @@ export function buildAuthQuery(): { timestamp: number; client_id: string } {
/**
* 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
* sorted_query_string: all query params (including timestamp, client_id) sorted alphabetically by key.
* Array values are serialized as repeated k=v pairs (same as buildUrl / URLSearchParams), sorted by value.
*/
export function buildMessage(
subPath: string,
queryParams: Record<string, string | number>,
queryParams: Record<string, string | number | string[]>,
body: string,
timestamp: number
): string {
const sortedQs = Object.keys(queryParams)
.sort()
.map((k) => `${k}=${queryParams[k]}`)
.flatMap((k) => {
const v = queryParams[k];
if (Array.isArray(v)) {
return [...v].sort().map((item) => `${k}=${item}`);
}
return [`${k}=${v}`];
})
.join("&");
return `${subPath}:${sortedQs}:${body}:${timestamp}`;
}