four-flap-meme-sdk 4.3.8 → 4.3.9

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();
@@ -65,4 +66,84 @@ describe('FourClient private API rate-limit fallback', () => {
65
66
  networkCode: 'BSC',
66
67
  })).resolves.toBe('nonce-2');
67
68
  });
69
+ it('browser hits official four.meme first so the request uses the user IP', async () => {
70
+ vi.stubGlobal('window', globalThis);
71
+ const calls = [];
72
+ vi.stubGlobal('fetch', vi.fn(async (input) => {
73
+ calls.push(String(input));
74
+ return new Response(JSON.stringify({ code: 0, data: 'nonce-user-ip' }));
75
+ }));
76
+ await expect(new FourClient({ baseUrl: 'https://bscfourapi.emit.tools' }).generateNonce({
77
+ accountAddress: `0x${'11'.repeat(20)}`,
78
+ verifyType: 'LOGIN',
79
+ networkCode: 'BSC',
80
+ })).resolves.toBe('nonce-user-ip');
81
+ expect(calls[0]).toContain('four.meme/meme-api');
82
+ });
83
+ it('browser createToken sends CORS-allowed Authorization to four.meme', async () => {
84
+ vi.stubGlobal('window', globalThis);
85
+ const headers = [];
86
+ vi.stubGlobal('fetch', vi.fn(async (input, init) => {
87
+ if (!String(input).includes('four.meme')) {
88
+ return new Response(JSON.stringify({ code: -1013, msg: 'Too many requests' }), { status: 429 });
89
+ }
90
+ headers.push((init?.headers || {}));
91
+ return new Response(JSON.stringify({ code: 0, data: { createArg: '0x1', signature: '0x2' } }));
92
+ }));
93
+ await expect(new FourClient({ baseUrl: 'https://bscfourapi.emit.tools' }).createToken('access-token', createReq())).resolves.toEqual({ createArg: '0x1', signature: '0x2' });
94
+ expect(headers[0]?.Authorization).toBe('access-token');
95
+ expect(headers[0]?.['meme-web-access']).toBeUndefined();
96
+ });
97
+ it('retries proxy with meme-web-access if official Authorization is treated as not login', async () => {
98
+ vi.stubGlobal('window', globalThis);
99
+ const seen = [];
100
+ vi.stubGlobal('fetch', vi.fn(async (input, init) => {
101
+ const url = String(input);
102
+ const hdrs = (init?.headers || {});
103
+ seen.push({ url, access: hdrs['meme-web-access'], authorization: hdrs.Authorization });
104
+ if (url.includes('four.meme')) {
105
+ return new Response(JSON.stringify({ code: -1004, msg: 'User not login' }));
106
+ }
107
+ return new Response(JSON.stringify({ code: 0, data: { createArg: '0x9', signature: '0xa' } }));
108
+ }));
109
+ await expect(new FourClient({ baseUrl: 'https://bscfourapi.emit.tools' }).createToken('access-token', createReq())).resolves.toEqual({ createArg: '0x9', signature: '0xa' });
110
+ expect(seen[0]?.url).toContain('four.meme');
111
+ expect(seen[0]?.authorization).toBe('access-token');
112
+ expect(seen.some((item) => item.url.includes('bscfourapi') && item.access === 'access-token')).toBe(true);
113
+ });
114
+ it('keeps proxy -1013 instead of Failed to fetch when browser CORS-upload to four.meme still fails', async () => {
115
+ vi.stubGlobal('window', globalThis);
116
+ const calls = [];
117
+ vi.stubGlobal('fetch', vi.fn(async (input) => {
118
+ const url = String(input);
119
+ calls.push(url);
120
+ if (url.includes('four.meme')) {
121
+ throw new TypeError('Failed to fetch');
122
+ }
123
+ return new Response(JSON.stringify({ code: -1013, msg: 'Too many requests, banned for 15 minutes, remain 120s' }), { status: 429 });
124
+ }));
125
+ await expect(new FourClient({ baseUrl: 'https://bscfourapi.emit.tools' }).createToken('access', createReq())).rejects.toThrow(/-1013|Too many requests/);
126
+ expect(calls.some((url) => url.includes('four.meme'))).toBe(true);
127
+ });
68
128
  });
129
+ function createReq() {
130
+ return {
131
+ name: 't',
132
+ shortName: 't',
133
+ desc: '',
134
+ imgUrl: 'https://example.com/a.png',
135
+ launchTime: 1,
136
+ label: 'Meme',
137
+ preSale: '0',
138
+ onlyMPC: false,
139
+ lpTradingFee: 0.0025,
140
+ symbol: 'BNB',
141
+ totalSupply: 1_000_000_000,
142
+ raisedAmount: 18,
143
+ saleRate: 0.8,
144
+ reserveRate: 0,
145
+ funGroup: false,
146
+ clickFun: false,
147
+ raisedToken: FOUR_BNB_RAISED_TOKEN_FALLBACK,
148
+ };
149
+ }
@@ -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>;
@@ -112,20 +112,20 @@ const RATE_LIMIT_CODES = new Set([-1038, -1013]);
112
112
  /** Cloudflare Worker 代理(独立出口 IP,主线路被封时换这条) */
113
113
  const FOUR_MEME_PROXY_API = 'https://bscfourapi.emit.tools';
114
114
  /**
115
- * Four.meme 官方 API。nonce/login 浏览器可直连;create/upload 需 `meme-web-access`,
116
- * 官方 CORS 不放行该头,浏览器应走自有代理。
115
+ * Four.meme 官方 API。浏览器走这条才是用户自己的 IP,不会被共用代理连坐封禁。
116
+ * 官网 CORS 允许 Authorization,不允许 meme-web-access;浏览器直连时改用前者。
117
117
  */
118
118
  const FOUR_MEME_OFFICIAL_API = 'https://four.meme/meme-api';
119
119
  export class FourClient {
120
120
  baseUrl;
121
121
  uploadUrls;
122
122
  constructor(cfg) {
123
- // 默认使用 Cloudflare Workers 代理,已配置 CORS,浏览器可直接使用
124
123
  this.baseUrl = cfg?.baseUrl || FOUR_MEME_PROXY_API;
125
- // 主 URL → 自定义备用 → emit 代理(换 IP)→ 官方直连(Node 无 CORS)
126
- this.uploadUrls = [
127
- ...new Set([this.baseUrl, ...(cfg?.uploadFallbackUrls || []), FOUR_MEME_PROXY_API, FOUR_MEME_OFFICIAL_API]),
128
- ];
124
+ const urls = [this.baseUrl, ...(cfg?.uploadFallbackUrls || []), FOUR_MEME_PROXY_API, FOUR_MEME_OFFICIAL_API];
125
+ // 浏览器先打官网(用户 IP);Node 仍先走调用方传入的代理
126
+ this.uploadUrls = this.isBrowserEnv()
127
+ ? [...new Set([FOUR_MEME_OFFICIAL_API, ...urls])]
128
+ : [...new Set(urls)];
129
129
  }
130
130
  attachApiCode(err, code) {
131
131
  const n = Number(code);
@@ -133,14 +133,61 @@ export class FourClient {
133
133
  err.apiCode = n;
134
134
  return err;
135
135
  }
136
+ isBrowserEnv() {
137
+ return typeof window !== 'undefined';
138
+ }
139
+ extractAccessToken(headers) {
140
+ if (!headers)
141
+ return undefined;
142
+ for (const [key, value] of Object.entries(headers)) {
143
+ const k = key.toLowerCase();
144
+ if (k === 'meme-web-access' || k === 'authorization')
145
+ return value;
146
+ }
147
+ return undefined;
148
+ }
149
+ /** 浏览器直连官网只用 CORS 放行的 Authorization,避免 meme-web-access 被预检拦成 Failed to fetch。 */
150
+ requestHeaders(baseUrl, headers) {
151
+ const next = { ...(headers || {}) };
152
+ const token = this.extractAccessToken(next);
153
+ for (const key of Object.keys(next)) {
154
+ if (key.toLowerCase() === 'meme-web-access')
155
+ delete next[key];
156
+ }
157
+ if (!token)
158
+ return next;
159
+ const raw = token.replace(/^Bearer\s+/i, '');
160
+ if (this.isBrowserEnv() && baseUrl === FOUR_MEME_OFFICIAL_API) {
161
+ next.Authorization = raw;
162
+ return next;
163
+ }
164
+ next['meme-web-access'] = raw;
165
+ delete next.Authorization;
166
+ return next;
167
+ }
168
+ /** 浏览器直连 four.meme 若仍被 CORS 拦成 Failed to fetch,回退到更早的限流错误。 */
169
+ preferRateLimitError(lastError, rateLimitError, fallback) {
170
+ if (rateLimitError && (!lastError || this.isCorsOrNetworkError(lastError)))
171
+ return rateLimitError;
172
+ return lastError ?? rateLimitError ?? new Error(fallback);
173
+ }
174
+ shouldRetryPrivateApi(err, baseUrl, canRetry) {
175
+ if (!canRetry)
176
+ return false;
177
+ return (this.isRateLimitError(err) ||
178
+ this.isCorsOrNetworkError(err) ||
179
+ this.isInvalidJsonError(err) ||
180
+ (this.isNotLoginError(err) && baseUrl === FOUR_MEME_OFFICIAL_API));
181
+ }
136
182
  async postPrivateJson(path, failLabel, init) {
137
183
  let lastError = null;
184
+ let rateLimitError = null;
138
185
  for (let i = 0; i < this.uploadUrls.length; i++) {
139
186
  const baseUrl = this.uploadUrls[i];
140
187
  try {
141
188
  const r = await fetch(`${baseUrl}${path}`, {
142
189
  method: 'POST',
143
- headers: { 'Content-Type': 'application/json', ...(init.headers || {}) },
190
+ headers: { 'Content-Type': 'application/json', ...this.requestHeaders(baseUrl, init.headers) },
144
191
  body: init.body,
145
192
  });
146
193
  const j = (await r.json());
@@ -152,14 +199,14 @@ export class FourClient {
152
199
  catch (e) {
153
200
  const err = e instanceof Error ? e : new Error(getErrorMessageFromUnknown(e));
154
201
  lastError = err;
155
- const canRetry = i < this.uploadUrls.length - 1;
156
- if (canRetry && (this.isRateLimitError(err) || this.isCorsOrNetworkError(err) || this.isInvalidJsonError(err))) {
202
+ if (this.isRateLimitError(err))
203
+ rateLimitError = err;
204
+ if (this.shouldRetryPrivateApi(err, baseUrl, i < this.uploadUrls.length - 1))
157
205
  continue;
158
- }
159
206
  break;
160
207
  }
161
208
  }
162
- throw lastError ?? new Error(failLabel);
209
+ throw this.preferRateLimitError(lastError, rateLimitError, failLabel);
163
210
  }
164
211
  async generateNonce(req) {
165
212
  return this.postPrivateJson('/v1/private/user/nonce/generate', 'generateNonce failed', {
@@ -188,40 +235,30 @@ export class FourClient {
188
235
  const finalFilename = filename || this.getFilenameFromBlob(file);
189
236
  let lastError = null;
190
237
  // ✅ 依次尝试所有上传端点(代理 → 自定义备用 → 官方直连兜底)
238
+ let rateLimitError = null;
191
239
  for (let i = 0; i < this.uploadUrls.length; i++) {
192
240
  const baseUrl = this.uploadUrls[i];
193
- const isOfficial = baseUrl === FOUR_MEME_OFFICIAL_API;
194
241
  try {
195
242
  const imgUrl = await this.doUpload(baseUrl, accessToken, file, finalFilename);
196
- // 上传成功,写入持久化缓存(localStorage)
197
243
  setCachedUrl(hash, imgUrl);
198
244
  return imgUrl;
199
245
  }
200
246
  catch (e) {
201
247
  const err = e instanceof Error ? e : new Error(getErrorMessageFromUnknown(e));
202
248
  lastError = err;
203
- const canRetry = i < this.uploadUrls.length - 1;
204
- if (this.isRateLimitError(err) && canRetry) {
205
- continue;
206
- }
207
- if (this.isCorsOrNetworkError(err) && canRetry) {
208
- // CORS/网络错误(常见于浏览器直连官方 API)→ 跳过,继续尝试下一个
209
- console.warn(`[FourClient] 端点 ${baseUrl} 请求失败(${isOfficial ? 'CORS限制' : '网络错误'}),跳过`);
249
+ if (this.isRateLimitError(err))
250
+ rateLimitError = err;
251
+ if (this.shouldRetryPrivateApi(err, baseUrl, i < this.uploadUrls.length - 1))
210
252
  continue;
211
- }
212
- // 无法重试,跳出
213
253
  break;
214
254
  }
215
255
  }
216
- // 所有端点都失败
217
- const isRateLimit = lastError && this.isRateLimitError(lastError);
218
- if (isRateLimit) {
256
+ const chosen = this.preferRateLimitError(lastError, rateLimitError, 'upload failed: all endpoints exhausted');
257
+ if (this.isRateLimitError(chosen)) {
219
258
  throw new Error(`图片上传失败:Four.meme 限流(每日配额或 15 分钟封禁)。请稍后再试。` +
220
- `\n原始错误: ${getErrorMessageFromUnknown(lastError)}`);
259
+ `\n原始错误: ${getErrorMessageFromUnknown(chosen)}`);
221
260
  }
222
- if (lastError !== null && lastError !== undefined)
223
- throw lastError;
224
- throw new Error('upload failed: all endpoints exhausted');
261
+ throw chosen;
225
262
  }
226
263
  /**
227
264
  * 执行单次上传请求
@@ -231,7 +268,7 @@ export class FourClient {
231
268
  form.append('file', file, filename);
232
269
  const r = await fetch(`${baseUrl}/v1/private/token/upload`, {
233
270
  method: 'POST',
234
- headers: { 'meme-web-access': accessToken },
271
+ headers: this.requestHeaders(baseUrl, { 'meme-web-access': accessToken }),
235
272
  body: form,
236
273
  });
237
274
  const j = await r.json();
@@ -262,6 +299,12 @@ export class FourClient {
262
299
  isInvalidJsonError(e) {
263
300
  return e instanceof SyntaxError || /unexpected token|not valid json/i.test(getErrorMessageFromUnknown(e));
264
301
  }
302
+ isNotLoginError(e) {
303
+ const code = Number(e.apiCode);
304
+ if (code === -1004)
305
+ return true;
306
+ return /user not login/i.test(getErrorMessageFromUnknown(e));
307
+ }
265
308
  getFilenameFromBlob(blob) {
266
309
  const mimeToExt = {
267
310
  'image/jpeg': 'jpg',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "four-flap-meme-sdk",
3
- "version": "4.3.8",
3
+ "version": "4.3.9",
4
4
  "description": "SDK for Flap bonding curve and four.meme TokenManager",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",