four-flap-meme-sdk 4.3.3 → 4.3.5

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.
@@ -12,6 +12,19 @@ import { createChainContext, loginFourClient, resolveTokenImage } from '../core-
12
12
  import { TM2_ABI } from './constants.js';
13
13
  import { fourCreateApiBody, fourCreateValueWei, readLaunchFeeWei, resolveCreateRaisedToken, signQuoteApproveIfNeeded, } from './create-token-quote.js';
14
14
  import { isNativeRaisedToken } from '../../../domains/four/raised-token.js';
15
+ function launchProfitIfConfigured(buyAmount, nativeRaised) {
16
+ if (!nativeRaised || buyAmount <= 0n || !shouldExtractProfit())
17
+ return null;
18
+ try {
19
+ const profit = calculateProfit(buyAmount).profit;
20
+ if (profit <= 0n)
21
+ return null;
22
+ return { profit, recipient: getProfitRecipient() };
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
15
28
  export async function createTokenWithBundleBuyMerkle(params) {
16
29
  const { privateKeys, buyAmounts, tokenInfo, config } = params;
17
30
  if (privateKeys.length !== 1) {
@@ -31,6 +44,10 @@ export async function createTokenWithBundleBuyMerkle(params) {
31
44
  ? String(tokenInfo.preSale)
32
45
  : String(buyAmounts[0] ?? '0');
33
46
  const raisedToken = await resolveCreateRaisedToken(fourClient, tokenInfo);
47
+ const originalBuyAmount = isNativeRaisedToken(raisedToken)
48
+ ? ethers.parseEther(effectivePreSaleStr || '0')
49
+ : 0n;
50
+ const launchProfit = launchProfitIfConfigured(originalBuyAmount, isNativeRaisedToken(raisedToken));
34
51
  const createResp = await fourClient.createToken(accessToken, fourCreateApiBody({
35
52
  name: tokenInfo.name,
36
53
  shortName: tokenInfo.symbol,
@@ -51,11 +68,7 @@ export async function createTokenWithBundleBuyMerkle(params) {
51
68
  const txType = getTxType(config);
52
69
  const signedTxs = [];
53
70
  const tmCreateAddr = ADDRESSES.BSC.TokenManagerOriginal;
54
- const extractProfit = shouldExtractProfit() && isNativeRaisedToken(raisedToken);
55
- const originalBuyAmount = isNativeRaisedToken(raisedToken)
56
- ? ethers.parseEther(effectivePreSaleStr || '0')
57
- : 0n;
58
- const profitAmount = extractProfit ? calculateProfit(originalBuyAmount).profit : 0n;
71
+ const profitAmount = launchProfit?.profit ?? 0n;
59
72
  const bribeAmount = getBribeAmount(config);
60
73
  const needBribeTx = bribeAmount > 0n;
61
74
  const tmCreate = new ethers.Contract(tmCreateAddr, TM2_ABI, devWallet);
@@ -92,13 +105,13 @@ export async function createTokenWithBundleBuyMerkle(params) {
92
105
  };
93
106
  signedTxs.push(await devWallet.signTransaction(createTxRequest));
94
107
  let profitHopWallets;
95
- if (extractProfit && profitAmount > 0n) {
108
+ if (launchProfit && profitAmount > 0n) {
96
109
  const profitNonce = await nonceManager.getNextNonce(devWallet);
97
110
  const profitHopResult = await buildProfitHopTransactions({
98
111
  provider,
99
112
  payerWallet: devWallet,
100
113
  profitAmount,
101
- profitRecipient: getProfitRecipient(),
114
+ profitRecipient: launchProfit.recipient,
102
115
  hopCount: PROFIT_HOP_COUNT,
103
116
  gasPrice,
104
117
  chainId,
@@ -109,11 +122,11 @@ export async function createTokenWithBundleBuyMerkle(params) {
109
122
  profitHopWallets = profitHopResult.hopWallets;
110
123
  }
111
124
  nonceManager.clearTemp();
112
- const metadata = extractProfit && profitAmount > 0n
125
+ const metadata = launchProfit && profitAmount > 0n
113
126
  ? {
114
127
  totalBuyAmount: ethers.formatEther(originalBuyAmount),
115
128
  profitAmount: ethers.formatEther(profitAmount),
116
- profitRecipient: getProfitRecipient(),
129
+ profitRecipient: launchProfit.recipient,
117
130
  buyerCount: 1,
118
131
  }
119
132
  : undefined;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { FourClient } from './four.js';
3
+ describe('FourClient private API rate-limit fallback', () => {
4
+ afterEach(() => {
5
+ vi.unstubAllGlobals();
6
+ });
7
+ it('retries generateNonce on the official API after -1013', async () => {
8
+ const calls = [];
9
+ vi.stubGlobal('fetch', vi.fn(async (input) => {
10
+ const url = String(input);
11
+ calls.push(url);
12
+ if (url.includes('bscfourapi.emit.tools')) {
13
+ return new Response(JSON.stringify({ code: -1013, msg: 'Too many requests, banned for 15 minutes' }), {
14
+ status: 429,
15
+ });
16
+ }
17
+ return new Response(JSON.stringify({ code: 0, data: 'nonce-1' }));
18
+ }));
19
+ const nonce = await new FourClient().generateNonce({
20
+ accountAddress: `0x${'11'.repeat(20)}`,
21
+ verifyType: 'LOGIN',
22
+ networkCode: 'BSC',
23
+ });
24
+ expect(nonce).toBe('nonce-1');
25
+ expect(calls.some((url) => url.includes('four.meme'))).toBe(true);
26
+ });
27
+ it('retries generateNonce when the proxy returns HTML', async () => {
28
+ vi.stubGlobal('fetch', vi.fn(async (input) => {
29
+ const url = String(input);
30
+ if (url.includes('bscfourapi.emit.tools')) {
31
+ return new Response('<!doctype html><html></html>', {
32
+ status: 200,
33
+ headers: { 'Content-Type': 'text/html' },
34
+ });
35
+ }
36
+ return new Response(JSON.stringify({ code: 0, data: 'nonce-2' }));
37
+ }));
38
+ await expect(new FourClient().generateNonce({
39
+ accountAddress: `0x${'11'.repeat(20)}`,
40
+ verifyType: 'LOGIN',
41
+ networkCode: 'BSC',
42
+ })).resolves.toBe('nonce-2');
43
+ });
44
+ });
@@ -11,10 +11,8 @@ export type FourConfig = {
11
11
  */
12
12
  baseUrl?: string;
13
13
  /**
14
- * 图片上传备用代理 URL 列表
15
- *
16
- * 当主 baseUrl 触发 IP 限流(code -1038)时,自动依次尝试备用端点。
17
- * 每个 URL 格式同 baseUrl,例如 'https://bscfourapi2.emit.tools'
14
+ * 图片上传 / 登录 / 发币 API 备用 URL。
15
+ * 主 baseUrl 触发 -1038 / -1013 时依次尝试。
18
16
  */
19
17
  uploadFallbackUrls?: string[];
20
18
  };
@@ -113,6 +111,8 @@ export declare class FourClient {
113
111
  private baseUrl;
114
112
  private uploadUrls;
115
113
  constructor(cfg?: FourConfig);
114
+ private attachApiCode;
115
+ private postPrivateJson;
116
116
  generateNonce(req: GenerateNonceReq): Promise<string>;
117
117
  loginDex(body: LoginReq): Promise<string>;
118
118
  /**
@@ -127,15 +127,13 @@ export declare class FourClient {
127
127
  * 执行单次上传请求
128
128
  */
129
129
  private doUpload;
130
- /**
131
- * 判断是否为 IP 限流错误(code: -1038)
132
- */
133
130
  private isRateLimitError;
134
131
  /**
135
132
  * 判断是否为 CORS 或网络错误
136
133
  * 浏览器直连官方 API 时常见(fetch 会抛出 TypeError: Failed to fetch)
137
134
  */
138
135
  private isCorsOrNetworkError;
136
+ private isInvalidJsonError;
139
137
  private getFilenameFromBlob;
140
138
  createToken(accessToken: string, req: CreateTokenReq): Promise<CreateTokenResp>;
141
139
  getPublicConfig(): Promise<FourPublicConfig>;
@@ -107,13 +107,11 @@ async function hashBlob(blob) {
107
107
  const hashArray = Array.from(new Uint8Array(hashBuffer));
108
108
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
109
109
  }
110
- /** IP 限流错误码 */
111
- const RATE_LIMIT_CODE = -1038;
110
+ /** Four.meme 限流:-1038 每日上传配额,-1013 15 分钟封禁 */
111
+ const RATE_LIMIT_CODES = new Set([-1038, -1013]);
112
112
  /**
113
- * Four.meme 官方 API 地址(作为内置备用端点)
114
- *
115
- * - 在 Node.js/服务端环境中:无 CORS 限制,直连走用户自己的 IP,不消耗 Worker 配额
116
- * - 在浏览器环境中:可能被 CORS 拦截,但作为最后的 fallback 仍值得尝试
113
+ * Four.meme 官方 API。nonce/login 浏览器可直连;create/upload 需 `meme-web-access`,
114
+ * 官方 CORS 不放行该头,浏览器应走自有代理。
117
115
  */
118
116
  const FOUR_MEME_OFFICIAL_API = 'https://four.meme/meme-api';
119
117
  export class FourClient {
@@ -133,30 +131,49 @@ export class FourClient {
133
131
  // 去重(如果用户 baseUrl 就是官方地址,避免重复请求)
134
132
  this.uploadUrls = [...new Set(this.uploadUrls)];
135
133
  }
134
+ attachApiCode(err, code) {
135
+ const n = Number(code);
136
+ if (Number.isFinite(n))
137
+ err.apiCode = n;
138
+ return err;
139
+ }
140
+ async postPrivateJson(path, failLabel, init) {
141
+ let lastError = null;
142
+ for (let i = 0; i < this.uploadUrls.length; i++) {
143
+ const baseUrl = this.uploadUrls[i];
144
+ try {
145
+ const r = await fetch(`${baseUrl}${path}`, {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json', ...(init.headers || {}) },
148
+ body: init.body,
149
+ });
150
+ const j = (await r.json());
151
+ if (j.code !== '0' && j.code !== 0) {
152
+ throw this.attachApiCode(new Error(`${failLabel}: ${JSON.stringify(j)}`), j.code);
153
+ }
154
+ return j.data;
155
+ }
156
+ catch (e) {
157
+ const err = e instanceof Error ? e : new Error(getErrorMessageFromUnknown(e));
158
+ lastError = err;
159
+ const canRetry = i < this.uploadUrls.length - 1;
160
+ if (canRetry && (this.isRateLimitError(err) || this.isCorsOrNetworkError(err) || this.isInvalidJsonError(err))) {
161
+ continue;
162
+ }
163
+ break;
164
+ }
165
+ }
166
+ throw lastError ?? new Error(failLabel);
167
+ }
136
168
  async generateNonce(req) {
137
- const r = await fetch(`${this.baseUrl}/v1/private/user/nonce/generate`, {
138
- method: 'POST',
139
- headers: { 'Content-Type': 'application/json' },
169
+ return this.postPrivateJson('/v1/private/user/nonce/generate', 'generateNonce failed', {
140
170
  body: JSON.stringify(req),
141
171
  });
142
- const j = await r.json();
143
- // code 可能是字符串 '0' 或数字 0
144
- if (j.code !== '0' && j.code !== 0) {
145
- throw new Error(`generateNonce failed: ${JSON.stringify(j)}`);
146
- }
147
- return j.data;
148
172
  }
149
173
  async loginDex(body) {
150
- const r = await fetch(`${this.baseUrl}/v1/private/user/login/dex`, {
151
- method: 'POST',
152
- headers: { 'Content-Type': 'application/json' },
174
+ return this.postPrivateJson('/v1/private/user/login/dex', 'login failed', {
153
175
  body: JSON.stringify(body),
154
176
  });
155
- const j = await r.json();
156
- if (j.code !== '0' && j.code !== 0) {
157
- throw new Error(`login failed: ${JSON.stringify(j)}`);
158
- }
159
- return j.data; // access_token
160
177
  }
161
178
  /**
162
179
  * 上传图片到 Four.meme
@@ -189,10 +206,6 @@ export class FourClient {
189
206
  lastError = err;
190
207
  const canRetry = i < this.uploadUrls.length - 1;
191
208
  if (this.isRateLimitError(err) && canRetry) {
192
- // IP 限流 → 切下一个端点
193
- const nextUrl = this.uploadUrls[i + 1];
194
- const nextLabel = nextUrl === FOUR_MEME_OFFICIAL_API ? '官方直连' : `备用端点 #${i + 2}`;
195
- console.warn(`[FourClient] 端点 ${baseUrl} 触发 IP 限流(100次/天),切换到${nextLabel}...`);
196
209
  continue;
197
210
  }
198
211
  if (this.isCorsOrNetworkError(err) && canRetry) {
@@ -207,8 +220,7 @@ export class FourClient {
207
220
  // 所有端点都失败
208
221
  const isRateLimit = lastError && this.isRateLimitError(lastError);
209
222
  if (isRateLimit) {
210
- throw new Error(`图片上传失败:所有端点均触发 IP 限流(每IP每天100次)。` +
211
- `请等待次日重置,或在 FourConfig.uploadFallbackUrls 中添加不同 IP 的备用代理。` +
223
+ throw new Error(`图片上传失败:Four.meme 限流(每日配额或 15 分钟封禁)。请稍后再试。` +
212
224
  `\n原始错误: ${getErrorMessageFromUnknown(lastError)}`);
213
225
  }
214
226
  if (lastError !== null && lastError !== undefined)
@@ -234,17 +246,12 @@ export class FourClient {
234
246
  }
235
247
  return j.data; // imgUrl
236
248
  }
237
- /**
238
- * 判断是否为 IP 限流错误(code: -1038)
239
- */
240
249
  isRateLimitError(e) {
241
- if (e !== null &&
242
- typeof e === 'object' &&
243
- 'apiCode' in e &&
244
- e.apiCode === RATE_LIMIT_CODE) {
250
+ const code = Number(e.apiCode);
251
+ if (RATE_LIMIT_CODES.has(code))
245
252
  return true;
246
- }
247
- return getErrorMessageFromUnknown(e).includes('-1038');
253
+ const msg = getErrorMessageFromUnknown(e);
254
+ return RATE_LIMIT_CODES.has(Number(msg.match(/"code"\s*:\s*(-?\d+)/)?.[1])) || /too many requests|banned for/i.test(msg);
248
255
  }
249
256
  /**
250
257
  * 判断是否为 CORS 或网络错误
@@ -256,6 +263,9 @@ export class FourClient {
256
263
  const msg = getErrorMessageFromUnknown(e).toLowerCase();
257
264
  return msg.includes('failed to fetch') || msg.includes('network') || msg.includes('cors');
258
265
  }
266
+ isInvalidJsonError(e) {
267
+ return e instanceof SyntaxError || /unexpected token|not valid json/i.test(getErrorMessageFromUnknown(e));
268
+ }
259
269
  getFilenameFromBlob(blob) {
260
270
  const mimeToExt = {
261
271
  'image/jpeg': 'jpg',
@@ -271,16 +281,10 @@ export class FourClient {
271
281
  async createToken(accessToken, req) {
272
282
  const raisedToken = req.raisedToken ?? (await this.resolveRaisedToken(req.symbol));
273
283
  const body = buildCreateTokenReq(req, raisedToken);
274
- const r = await fetch(`${this.baseUrl}/v1/private/token/create`, {
275
- method: 'POST',
276
- headers: { 'Content-Type': 'application/json', 'meme-web-access': accessToken },
284
+ return this.postPrivateJson('/v1/private/token/create', 'createToken failed', {
285
+ headers: { 'meme-web-access': accessToken },
277
286
  body: JSON.stringify(body),
278
287
  });
279
- const j = await r.json();
280
- if (j.code !== '0' && j.code !== 0) {
281
- throw new Error(`createToken failed: ${JSON.stringify(j)}`);
282
- }
283
- return j.data;
284
288
  }
285
289
  async getPublicConfig() {
286
290
  const urls = [...new Set([this.baseUrl, FOUR_MEME_OFFICIAL_API])];
@@ -392,16 +396,10 @@ export class FourClient {
392
396
  return last;
393
397
  }
394
398
  async createOpenFourToken(accessToken, req) {
395
- const r = await fetch(`${this.baseUrl}/v1/private/token_template/token/create`, {
396
- method: 'POST',
397
- headers: { 'Content-Type': 'application/json', 'meme-web-access': accessToken },
399
+ const data = await this.postPrivateJson('/v1/private/token_template/token/create', 'createOpenFourToken failed', {
400
+ headers: { 'meme-web-access': accessToken },
398
401
  body: JSON.stringify(req),
399
402
  });
400
- const j = await r.json();
401
- if (j.code !== '0' && j.code !== 0) {
402
- throw new Error(`createOpenFourToken failed: ${JSON.stringify(j)}`);
403
- }
404
- const data = j.data;
405
403
  const first = Array.isArray(data) ? data[0] : data;
406
404
  return first;
407
405
  }
@@ -1,5 +1,21 @@
1
1
  import '../tx/wallet-sign-patch.js';
2
2
  import { CHAINS } from '../constants/index.js';
3
+ function parseRpcQuantity(value) {
4
+ if (typeof value === 'number' && Number.isFinite(value))
5
+ return Math.max(0, Math.floor(value));
6
+ if (typeof value === 'bigint') {
7
+ const n = Number(value);
8
+ return Number.isFinite(n) && n > 0 ? n : 0;
9
+ }
10
+ const s = String(value ?? '').trim();
11
+ if (!s)
12
+ return 0;
13
+ if (/^0x[0-9a-f]+$/i.test(s))
14
+ return parseInt(s, 16) || 0;
15
+ if (/^\d+$/.test(s))
16
+ return Number(s);
17
+ return parseInt(s, 16) || 0;
18
+ }
3
19
  function asBatchSendProvider(provider) {
4
20
  return provider;
5
21
  }
@@ -195,7 +211,7 @@ export class NonceManager {
195
211
  const byId = new Map();
196
212
  for (const row of rawResults) {
197
213
  const id = Number(row.id);
198
- byId.set(id, row.result ? parseInt(row.result, 16) : 0);
214
+ byId.set(id, parseRpcQuantity(row.result));
199
215
  }
200
216
  queryResults = uniqueQueries.map((_, idx) => Math.max(byId.get(idx * 2 + 1) || 0, byId.get(idx * 2 + 2) || 0));
201
217
  }
@@ -61,6 +61,8 @@ async function readGenericTokenTaxBps(token, provider) {
61
61
  for (const item of reads) {
62
62
  if (item.status !== 'fulfilled' || typeof item.value !== 'number')
63
63
  continue;
64
+ if (item.value === 100)
65
+ continue;
64
66
  if (item.value > maxBps)
65
67
  maxBps = item.value;
66
68
  }
@@ -70,6 +72,8 @@ export async function readBscOuterTaxBps(token, rpcUrl) {
70
72
  try {
71
73
  if (await isTaxToken(token, rpcUrl)) {
72
74
  const fee = Number((await getTaxTokenInfo(token, rpcUrl)).config.feeRate);
75
+ if (Number.isFinite(fee) && fee > 0 && fee <= 10000)
76
+ return Math.round(fee);
73
77
  const bps = normalizeFeeToBps(fee);
74
78
  if (bps)
75
79
  return bps;
@@ -34,6 +34,9 @@ describe('normalizeFeeToBps', () => {
34
34
  expect(normalizeFeeToBps(500)).toBe(500);
35
35
  expect(normalizeFeeToBps(0)).toBeUndefined();
36
36
  });
37
+ it('does not treat generic leftover 100 as pancake tax in the max fold', () => {
38
+ expect(normalizeFeeToBps(100)).toBe(100);
39
+ });
37
40
  });
38
41
  describe('flapV8TaxBps', () => {
39
42
  it('reads inner flap tax', () => {
@@ -105,11 +105,14 @@ export async function inspectTokenLP(token, opts) {
105
105
  price: formatBalance(st.price, shouldFormat),
106
106
  taxRate, // ✅ 包含税率
107
107
  };
108
- // ✅ 同时设置顶层税率,方便统一读取
109
- if (taxRate && taxRate > 0) {
110
- result.taxRate = taxRate;
108
+ const reserveBn = BigInt(st.reserve ?? 0n);
109
+ if (reserveBn > 0n) {
110
+ if (taxRate && taxRate > 0) {
111
+ result.taxRate = taxRate;
112
+ }
113
+ return result;
111
114
  }
112
- return result;
115
+ // 空内盘:继续查外盘,避免假内盘把 allPools 吃掉;Portal 残留税也不写进外盘
113
116
  }
114
117
  }
115
118
  catch {
@@ -344,7 +347,7 @@ export async function inspectTokenLP(token, opts) {
344
347
  const stV7 = await flap.getTokenV7(token);
345
348
  taxRate = Number(stV7.taxRate ?? 0n);
346
349
  }
347
- if (taxRate > 0) {
350
+ if (taxRate > 0 && taxRate !== 100) {
348
351
  result.taxRate = taxRate;
349
352
  }
350
353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "four-flap-meme-sdk",
3
- "version": "4.3.3",
3
+ "version": "4.3.5",
4
4
  "description": "SDK for Flap bonding curve and four.meme TokenManager",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",