four-flap-meme-sdk 4.3.8 → 4.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { FourClient } from './four.js';
|
|
3
|
+
import { FOUR_BNB_RAISED_TOKEN_FALLBACK } from '../../domains/four/raised-token.js';
|
|
3
4
|
describe('FourClient private API rate-limit fallback', () => {
|
|
4
5
|
afterEach(() => {
|
|
5
6
|
vi.unstubAllGlobals();
|
|
@@ -9,7 +10,7 @@ describe('FourClient private API rate-limit fallback', () => {
|
|
|
9
10
|
vi.stubGlobal('fetch', vi.fn(async (input) => {
|
|
10
11
|
const url = String(input);
|
|
11
12
|
calls.push(url);
|
|
12
|
-
if (url.includes('bscfourapi.emit.tools')) {
|
|
13
|
+
if (url.includes('bscfourapi.emit.tools') || url.includes('api.emit.tools')) {
|
|
13
14
|
return new Response(JSON.stringify({ code: -1013, msg: 'Too many requests, banned for 15 minutes' }), {
|
|
14
15
|
status: 429,
|
|
15
16
|
});
|
|
@@ -48,6 +49,25 @@ describe('FourClient private API rate-limit fallback', () => {
|
|
|
48
49
|
expect(calls.some((url) => url.includes('example.com'))).toBe(true);
|
|
49
50
|
expect(calls.some((url) => url.includes('bscfourapi.emit.tools'))).toBe(true);
|
|
50
51
|
});
|
|
52
|
+
it('retries generateNonce on EC2 after Cloudflare proxies are banned', async () => {
|
|
53
|
+
const calls = [];
|
|
54
|
+
vi.stubGlobal('fetch', vi.fn(async (input) => {
|
|
55
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : String(input);
|
|
56
|
+
calls.push(url);
|
|
57
|
+
if (url.includes('api.emit.tools')) {
|
|
58
|
+
return new Response(JSON.stringify({ code: 0, data: 'nonce-ec2' }));
|
|
59
|
+
}
|
|
60
|
+
return new Response(JSON.stringify({ code: -1013, msg: 'Too many requests, banned for 15 minutes' }), {
|
|
61
|
+
status: 429,
|
|
62
|
+
});
|
|
63
|
+
}));
|
|
64
|
+
await expect(new FourClient().generateNonce({
|
|
65
|
+
accountAddress: `0x${'11'.repeat(20)}`,
|
|
66
|
+
verifyType: 'LOGIN',
|
|
67
|
+
networkCode: 'BSC',
|
|
68
|
+
})).resolves.toBe('nonce-ec2');
|
|
69
|
+
expect(calls.some((url) => url.includes('api.emit.tools'))).toBe(true);
|
|
70
|
+
});
|
|
51
71
|
it('retries generateNonce when the proxy returns HTML', async () => {
|
|
52
72
|
vi.stubGlobal('fetch', vi.fn(async (input) => {
|
|
53
73
|
const url = String(input);
|
|
@@ -57,6 +77,11 @@ describe('FourClient private API rate-limit fallback', () => {
|
|
|
57
77
|
headers: { 'Content-Type': 'text/html' },
|
|
58
78
|
});
|
|
59
79
|
}
|
|
80
|
+
if (url.includes('api.emit.tools')) {
|
|
81
|
+
return new Response(JSON.stringify({ code: -1013, msg: 'Too many requests, banned for 15 minutes' }), {
|
|
82
|
+
status: 429,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
60
85
|
return new Response(JSON.stringify({ code: 0, data: 'nonce-2' }));
|
|
61
86
|
}));
|
|
62
87
|
await expect(new FourClient().generateNonce({
|
|
@@ -65,4 +90,84 @@ describe('FourClient private API rate-limit fallback', () => {
|
|
|
65
90
|
networkCode: 'BSC',
|
|
66
91
|
})).resolves.toBe('nonce-2');
|
|
67
92
|
});
|
|
93
|
+
it('browser hits official four.meme first so the request uses the user IP', async () => {
|
|
94
|
+
vi.stubGlobal('window', globalThis);
|
|
95
|
+
const calls = [];
|
|
96
|
+
vi.stubGlobal('fetch', vi.fn(async (input) => {
|
|
97
|
+
calls.push(String(input));
|
|
98
|
+
return new Response(JSON.stringify({ code: 0, data: 'nonce-user-ip' }));
|
|
99
|
+
}));
|
|
100
|
+
await expect(new FourClient({ baseUrl: 'https://bscfourapi.emit.tools' }).generateNonce({
|
|
101
|
+
accountAddress: `0x${'11'.repeat(20)}`,
|
|
102
|
+
verifyType: 'LOGIN',
|
|
103
|
+
networkCode: 'BSC',
|
|
104
|
+
})).resolves.toBe('nonce-user-ip');
|
|
105
|
+
expect(calls[0]).toContain('four.meme/meme-api');
|
|
106
|
+
});
|
|
107
|
+
it('browser createToken sends CORS-allowed Authorization to four.meme', async () => {
|
|
108
|
+
vi.stubGlobal('window', globalThis);
|
|
109
|
+
const headers = [];
|
|
110
|
+
vi.stubGlobal('fetch', vi.fn(async (input, init) => {
|
|
111
|
+
if (!String(input).includes('four.meme')) {
|
|
112
|
+
return new Response(JSON.stringify({ code: -1013, msg: 'Too many requests' }), { status: 429 });
|
|
113
|
+
}
|
|
114
|
+
headers.push((init?.headers || {}));
|
|
115
|
+
return new Response(JSON.stringify({ code: 0, data: { createArg: '0x1', signature: '0x2' } }));
|
|
116
|
+
}));
|
|
117
|
+
await expect(new FourClient({ baseUrl: 'https://bscfourapi.emit.tools' }).createToken('access-token', createReq())).resolves.toEqual({ createArg: '0x1', signature: '0x2' });
|
|
118
|
+
expect(headers[0]?.Authorization).toBe('access-token');
|
|
119
|
+
expect(headers[0]?.['meme-web-access']).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
it('retries proxy with meme-web-access if official Authorization is treated as not login', async () => {
|
|
122
|
+
vi.stubGlobal('window', globalThis);
|
|
123
|
+
const seen = [];
|
|
124
|
+
vi.stubGlobal('fetch', vi.fn(async (input, init) => {
|
|
125
|
+
const url = String(input);
|
|
126
|
+
const hdrs = (init?.headers || {});
|
|
127
|
+
seen.push({ url, access: hdrs['meme-web-access'], authorization: hdrs.Authorization });
|
|
128
|
+
if (url.includes('four.meme')) {
|
|
129
|
+
return new Response(JSON.stringify({ code: -1004, msg: 'User not login' }));
|
|
130
|
+
}
|
|
131
|
+
return new Response(JSON.stringify({ code: 0, data: { createArg: '0x9', signature: '0xa' } }));
|
|
132
|
+
}));
|
|
133
|
+
await expect(new FourClient({ baseUrl: 'https://bscfourapi.emit.tools' }).createToken('access-token', createReq())).resolves.toEqual({ createArg: '0x9', signature: '0xa' });
|
|
134
|
+
expect(seen[0]?.url).toContain('four.meme');
|
|
135
|
+
expect(seen[0]?.authorization).toBe('access-token');
|
|
136
|
+
expect(seen.some((item) => item.url.includes('bscfourapi') && item.access === 'access-token')).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
it('keeps proxy -1013 instead of Failed to fetch when browser CORS-upload to four.meme still fails', async () => {
|
|
139
|
+
vi.stubGlobal('window', globalThis);
|
|
140
|
+
const calls = [];
|
|
141
|
+
vi.stubGlobal('fetch', vi.fn(async (input) => {
|
|
142
|
+
const url = String(input);
|
|
143
|
+
calls.push(url);
|
|
144
|
+
if (url.includes('four.meme')) {
|
|
145
|
+
throw new TypeError('Failed to fetch');
|
|
146
|
+
}
|
|
147
|
+
return new Response(JSON.stringify({ code: -1013, msg: 'Too many requests, banned for 15 minutes, remain 120s' }), { status: 429 });
|
|
148
|
+
}));
|
|
149
|
+
await expect(new FourClient({ baseUrl: 'https://bscfourapi.emit.tools' }).createToken('access', createReq())).rejects.toThrow(/-1013|Too many requests/);
|
|
150
|
+
expect(calls.some((url) => url.includes('four.meme'))).toBe(true);
|
|
151
|
+
});
|
|
68
152
|
});
|
|
153
|
+
function createReq() {
|
|
154
|
+
return {
|
|
155
|
+
name: 't',
|
|
156
|
+
shortName: 't',
|
|
157
|
+
desc: '',
|
|
158
|
+
imgUrl: 'https://example.com/a.png',
|
|
159
|
+
launchTime: 1,
|
|
160
|
+
label: 'Meme',
|
|
161
|
+
preSale: '0',
|
|
162
|
+
onlyMPC: false,
|
|
163
|
+
lpTradingFee: 0.0025,
|
|
164
|
+
symbol: 'BNB',
|
|
165
|
+
totalSupply: 1_000_000_000,
|
|
166
|
+
raisedAmount: 18,
|
|
167
|
+
saleRate: 0.8,
|
|
168
|
+
reserveRate: 0,
|
|
169
|
+
funGroup: false,
|
|
170
|
+
clickFun: false,
|
|
171
|
+
raisedToken: FOUR_BNB_RAISED_TOKEN_FALLBACK,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -112,6 +112,13 @@ export declare class FourClient {
|
|
|
112
112
|
private uploadUrls;
|
|
113
113
|
constructor(cfg?: FourConfig);
|
|
114
114
|
private attachApiCode;
|
|
115
|
+
private isBrowserEnv;
|
|
116
|
+
private extractAccessToken;
|
|
117
|
+
/** 浏览器直连官网只用 CORS 放行的 Authorization,避免 meme-web-access 被预检拦成 Failed to fetch。 */
|
|
118
|
+
private requestHeaders;
|
|
119
|
+
/** 浏览器直连 four.meme 若仍被 CORS 拦成 Failed to fetch,回退到更早的限流错误。 */
|
|
120
|
+
private preferRateLimitError;
|
|
121
|
+
private shouldRetryPrivateApi;
|
|
115
122
|
private postPrivateJson;
|
|
116
123
|
generateNonce(req: GenerateNonceReq): Promise<string>;
|
|
117
124
|
loginDex(body: LoginReq): Promise<string>;
|
|
@@ -134,6 +141,7 @@ export declare class FourClient {
|
|
|
134
141
|
*/
|
|
135
142
|
private isCorsOrNetworkError;
|
|
136
143
|
private isInvalidJsonError;
|
|
144
|
+
private isNotLoginError;
|
|
137
145
|
private getFilenameFromBlob;
|
|
138
146
|
createToken(accessToken: string, req: CreateTokenReq): Promise<CreateTokenResp>;
|
|
139
147
|
getPublicConfig(): Promise<FourPublicConfig>;
|
|
@@ -109,23 +109,31 @@ async function hashBlob(blob) {
|
|
|
109
109
|
}
|
|
110
110
|
/** Four.meme 限流:-1038 每日上传配额,-1013 15 分钟封禁 */
|
|
111
111
|
const RATE_LIMIT_CODES = new Set([-1038, -1013]);
|
|
112
|
-
/** Cloudflare Worker
|
|
112
|
+
/** Cloudflare Worker 代理(与 Pages 同源反代同属 CF 出口,可能连坐) */
|
|
113
113
|
const FOUR_MEME_PROXY_API = 'https://bscfourapi.emit.tools';
|
|
114
|
+
/** emit-api EC2 反代(新加坡出口,CF 被 -1013 后换这条) */
|
|
115
|
+
const FOUR_MEME_EC2_PROXY_API = 'https://api.emit.tools/v1/four-meme-api';
|
|
114
116
|
/**
|
|
115
|
-
* Four.meme 官方 API
|
|
116
|
-
*
|
|
117
|
+
* Four.meme 官方 API。浏览器走这条才是用户自己的 IP,不会被共用代理连坐封禁。
|
|
118
|
+
* 官网 CORS 允许 Authorization,不允许 meme-web-access;浏览器直连时改用前者。
|
|
117
119
|
*/
|
|
118
120
|
const FOUR_MEME_OFFICIAL_API = 'https://four.meme/meme-api';
|
|
119
121
|
export class FourClient {
|
|
120
122
|
baseUrl;
|
|
121
123
|
uploadUrls;
|
|
122
124
|
constructor(cfg) {
|
|
123
|
-
// 默认使用 Cloudflare Workers 代理,已配置 CORS,浏览器可直接使用
|
|
124
125
|
this.baseUrl = cfg?.baseUrl || FOUR_MEME_PROXY_API;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
...
|
|
126
|
+
const urls = [
|
|
127
|
+
this.baseUrl,
|
|
128
|
+
...(cfg?.uploadFallbackUrls || []),
|
|
129
|
+
FOUR_MEME_PROXY_API,
|
|
130
|
+
FOUR_MEME_EC2_PROXY_API,
|
|
131
|
+
FOUR_MEME_OFFICIAL_API,
|
|
128
132
|
];
|
|
133
|
+
// 浏览器先打官网(用户 IP);Node 仍先走调用方传入的代理
|
|
134
|
+
this.uploadUrls = this.isBrowserEnv()
|
|
135
|
+
? [...new Set([FOUR_MEME_OFFICIAL_API, ...urls])]
|
|
136
|
+
: [...new Set(urls)];
|
|
129
137
|
}
|
|
130
138
|
attachApiCode(err, code) {
|
|
131
139
|
const n = Number(code);
|
|
@@ -133,14 +141,61 @@ export class FourClient {
|
|
|
133
141
|
err.apiCode = n;
|
|
134
142
|
return err;
|
|
135
143
|
}
|
|
144
|
+
isBrowserEnv() {
|
|
145
|
+
return typeof window !== 'undefined';
|
|
146
|
+
}
|
|
147
|
+
extractAccessToken(headers) {
|
|
148
|
+
if (!headers)
|
|
149
|
+
return undefined;
|
|
150
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
151
|
+
const k = key.toLowerCase();
|
|
152
|
+
if (k === 'meme-web-access' || k === 'authorization')
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
/** 浏览器直连官网只用 CORS 放行的 Authorization,避免 meme-web-access 被预检拦成 Failed to fetch。 */
|
|
158
|
+
requestHeaders(baseUrl, headers) {
|
|
159
|
+
const next = { ...(headers || {}) };
|
|
160
|
+
const token = this.extractAccessToken(next);
|
|
161
|
+
for (const key of Object.keys(next)) {
|
|
162
|
+
if (key.toLowerCase() === 'meme-web-access')
|
|
163
|
+
delete next[key];
|
|
164
|
+
}
|
|
165
|
+
if (!token)
|
|
166
|
+
return next;
|
|
167
|
+
const raw = token.replace(/^Bearer\s+/i, '');
|
|
168
|
+
if (this.isBrowserEnv() && baseUrl === FOUR_MEME_OFFICIAL_API) {
|
|
169
|
+
next.Authorization = raw;
|
|
170
|
+
return next;
|
|
171
|
+
}
|
|
172
|
+
next['meme-web-access'] = raw;
|
|
173
|
+
delete next.Authorization;
|
|
174
|
+
return next;
|
|
175
|
+
}
|
|
176
|
+
/** 浏览器直连 four.meme 若仍被 CORS 拦成 Failed to fetch,回退到更早的限流错误。 */
|
|
177
|
+
preferRateLimitError(lastError, rateLimitError, fallback) {
|
|
178
|
+
if (rateLimitError && (!lastError || this.isCorsOrNetworkError(lastError)))
|
|
179
|
+
return rateLimitError;
|
|
180
|
+
return lastError ?? rateLimitError ?? new Error(fallback);
|
|
181
|
+
}
|
|
182
|
+
shouldRetryPrivateApi(err, baseUrl, canRetry) {
|
|
183
|
+
if (!canRetry)
|
|
184
|
+
return false;
|
|
185
|
+
return (this.isRateLimitError(err) ||
|
|
186
|
+
this.isCorsOrNetworkError(err) ||
|
|
187
|
+
this.isInvalidJsonError(err) ||
|
|
188
|
+
(this.isNotLoginError(err) && baseUrl === FOUR_MEME_OFFICIAL_API));
|
|
189
|
+
}
|
|
136
190
|
async postPrivateJson(path, failLabel, init) {
|
|
137
191
|
let lastError = null;
|
|
192
|
+
let rateLimitError = null;
|
|
138
193
|
for (let i = 0; i < this.uploadUrls.length; i++) {
|
|
139
194
|
const baseUrl = this.uploadUrls[i];
|
|
140
195
|
try {
|
|
141
196
|
const r = await fetch(`${baseUrl}${path}`, {
|
|
142
197
|
method: 'POST',
|
|
143
|
-
headers: { 'Content-Type': 'application/json', ...(init.headers
|
|
198
|
+
headers: { 'Content-Type': 'application/json', ...this.requestHeaders(baseUrl, init.headers) },
|
|
144
199
|
body: init.body,
|
|
145
200
|
});
|
|
146
201
|
const j = (await r.json());
|
|
@@ -152,14 +207,14 @@ export class FourClient {
|
|
|
152
207
|
catch (e) {
|
|
153
208
|
const err = e instanceof Error ? e : new Error(getErrorMessageFromUnknown(e));
|
|
154
209
|
lastError = err;
|
|
155
|
-
|
|
156
|
-
|
|
210
|
+
if (this.isRateLimitError(err))
|
|
211
|
+
rateLimitError = err;
|
|
212
|
+
if (this.shouldRetryPrivateApi(err, baseUrl, i < this.uploadUrls.length - 1))
|
|
157
213
|
continue;
|
|
158
|
-
}
|
|
159
214
|
break;
|
|
160
215
|
}
|
|
161
216
|
}
|
|
162
|
-
throw lastError
|
|
217
|
+
throw this.preferRateLimitError(lastError, rateLimitError, failLabel);
|
|
163
218
|
}
|
|
164
219
|
async generateNonce(req) {
|
|
165
220
|
return this.postPrivateJson('/v1/private/user/nonce/generate', 'generateNonce failed', {
|
|
@@ -188,40 +243,30 @@ export class FourClient {
|
|
|
188
243
|
const finalFilename = filename || this.getFilenameFromBlob(file);
|
|
189
244
|
let lastError = null;
|
|
190
245
|
// ✅ 依次尝试所有上传端点(代理 → 自定义备用 → 官方直连兜底)
|
|
246
|
+
let rateLimitError = null;
|
|
191
247
|
for (let i = 0; i < this.uploadUrls.length; i++) {
|
|
192
248
|
const baseUrl = this.uploadUrls[i];
|
|
193
|
-
const isOfficial = baseUrl === FOUR_MEME_OFFICIAL_API;
|
|
194
249
|
try {
|
|
195
250
|
const imgUrl = await this.doUpload(baseUrl, accessToken, file, finalFilename);
|
|
196
|
-
// 上传成功,写入持久化缓存(localStorage)
|
|
197
251
|
setCachedUrl(hash, imgUrl);
|
|
198
252
|
return imgUrl;
|
|
199
253
|
}
|
|
200
254
|
catch (e) {
|
|
201
255
|
const err = e instanceof Error ? e : new Error(getErrorMessageFromUnknown(e));
|
|
202
256
|
lastError = err;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
if (this.isCorsOrNetworkError(err) && canRetry) {
|
|
208
|
-
// CORS/网络错误(常见于浏览器直连官方 API)→ 跳过,继续尝试下一个
|
|
209
|
-
console.warn(`[FourClient] 端点 ${baseUrl} 请求失败(${isOfficial ? 'CORS限制' : '网络错误'}),跳过`);
|
|
257
|
+
if (this.isRateLimitError(err))
|
|
258
|
+
rateLimitError = err;
|
|
259
|
+
if (this.shouldRetryPrivateApi(err, baseUrl, i < this.uploadUrls.length - 1))
|
|
210
260
|
continue;
|
|
211
|
-
}
|
|
212
|
-
// 无法重试,跳出
|
|
213
261
|
break;
|
|
214
262
|
}
|
|
215
263
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (isRateLimit) {
|
|
264
|
+
const chosen = this.preferRateLimitError(lastError, rateLimitError, 'upload failed: all endpoints exhausted');
|
|
265
|
+
if (this.isRateLimitError(chosen)) {
|
|
219
266
|
throw new Error(`图片上传失败:Four.meme 限流(每日配额或 15 分钟封禁)。请稍后再试。` +
|
|
220
|
-
`\n原始错误: ${getErrorMessageFromUnknown(
|
|
267
|
+
`\n原始错误: ${getErrorMessageFromUnknown(chosen)}`);
|
|
221
268
|
}
|
|
222
|
-
|
|
223
|
-
throw lastError;
|
|
224
|
-
throw new Error('upload failed: all endpoints exhausted');
|
|
269
|
+
throw chosen;
|
|
225
270
|
}
|
|
226
271
|
/**
|
|
227
272
|
* 执行单次上传请求
|
|
@@ -231,7 +276,7 @@ export class FourClient {
|
|
|
231
276
|
form.append('file', file, filename);
|
|
232
277
|
const r = await fetch(`${baseUrl}/v1/private/token/upload`, {
|
|
233
278
|
method: 'POST',
|
|
234
|
-
headers: { 'meme-web-access': accessToken },
|
|
279
|
+
headers: this.requestHeaders(baseUrl, { 'meme-web-access': accessToken }),
|
|
235
280
|
body: form,
|
|
236
281
|
});
|
|
237
282
|
const j = await r.json();
|
|
@@ -262,6 +307,12 @@ export class FourClient {
|
|
|
262
307
|
isInvalidJsonError(e) {
|
|
263
308
|
return e instanceof SyntaxError || /unexpected token|not valid json/i.test(getErrorMessageFromUnknown(e));
|
|
264
309
|
}
|
|
310
|
+
isNotLoginError(e) {
|
|
311
|
+
const code = Number(e.apiCode);
|
|
312
|
+
if (code === -1004)
|
|
313
|
+
return true;
|
|
314
|
+
return /user not login/i.test(getErrorMessageFromUnknown(e));
|
|
315
|
+
}
|
|
265
316
|
getFilenameFromBlob(blob) {
|
|
266
317
|
const mimeToExt = {
|
|
267
318
|
'image/jpeg': 'jpg',
|