four-flap-meme-sdk 4.3.4 → 4.3.6

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
  }
@@ -97,7 +97,12 @@ function resolveCreateQuoteAmt(params) {
97
97
  function resolveUseV7Portal(params) {
98
98
  return !!(params.useV7Portal ?? params.useV4Portal ?? params.useClInfinityLaunch);
99
99
  }
100
- /** Flap 文档:TOKEN_V3_PERMIT 须 migratorType=3、feeConfigs 单槽 MARKETING_OR_VAULT 10000 bps */
100
+ /**
101
+ * 标准 V3 / TOKEN_V3_PERMIT:对齐 Flap 官网 live newTokenV7。
102
+ * 主网 Portal v5.23.1 拒 MARKETING_OR_VAULT(NewTokenV7RuleViolation(8)),
103
+ * 只收单槽 DIVIDEND 10000 bps、dividendToken == quoteToken(原生为 address(0))。
104
+ */
105
+ const V7_DIVIDEND_MIN_SHARE = 10000n * 10n ** 18n;
101
106
  function buildNewTokenV7PermitParams(params) {
102
107
  const emptyFeeConfig = {
103
108
  feeType: FeeType.NONE,
@@ -106,12 +111,13 @@ function buildNewTokenV7PermitParams(params) {
106
111
  dividendToken: ZeroAddress,
107
112
  minimumShareBalance: 0n,
108
113
  };
109
- const marketingFeeConfig = {
110
- feeType: FeeType.MARKETING_OR_VAULT,
114
+ const quoteToken = params.quoteToken || ZERO_ADDRESS;
115
+ const dividendFeeConfig = {
116
+ feeType: FeeType.DIVIDEND,
111
117
  bps: 10000,
112
- marketingAddress: params.beneficiary,
113
- dividendToken: ZeroAddress,
114
- minimumShareBalance: 0n,
118
+ marketingAddress: ZeroAddress,
119
+ dividendToken: quoteToken,
120
+ minimumShareBalance: V7_DIVIDEND_MIN_SHARE,
115
121
  };
116
122
  return {
117
123
  name: params.tokenInfo.name,
@@ -120,7 +126,7 @@ function buildNewTokenV7PermitParams(params) {
120
126
  dexThresh: (params.dexThresh ?? 1) & 0xff,
121
127
  salt: params.salt ?? zeroBytes32(),
122
128
  migratorType: MigratorType.PCS_INFINITY_CL_MIGRATOR,
123
- quoteToken: params.quoteToken || ZERO_ADDRESS,
129
+ quoteToken,
124
130
  quoteAmt: resolveCreateQuoteAmt(params),
125
131
  permitData: '0x',
126
132
  extensionID: params.extensionID ?? zeroBytes32(),
@@ -132,7 +138,7 @@ function buildNewTokenV7PermitParams(params) {
132
138
  antiFarmerDuration: 0n,
133
139
  commissionReceiver: ZeroAddress,
134
140
  tokenVersion: TokenVersion.TOKEN_V3_PERMIT,
135
- feeConfigs: [marketingFeeConfig, emptyFeeConfig, emptyFeeConfig, emptyFeeConfig],
141
+ feeConfigs: [dividendFeeConfig, emptyFeeConfig, emptyFeeConfig, emptyFeeConfig],
136
142
  };
137
143
  }
138
144
  export async function populateCreateTokenTransaction(params) {
@@ -197,7 +203,7 @@ export async function populateCreateTokenTransaction(params) {
197
203
  sellTaxRate,
198
204
  }, { mktBps: 10000, deflationBps: 0, dividendBps: 0, lpBps: 0 }));
199
205
  }
200
- // 3) 标准 V3 / TOKEN_V3_PERMIT(newTokenV7 — 与 Flap 官网 / Portal v5.8.6+ 一致;V4 已 FeatureDisabled)
206
+ // 3) 标准 V3 / TOKEN_V3_PERMIT(newTokenV7 — 对齐官网 live 单槽 DIVIDEND;V4 已 FeatureDisabled)
201
207
  if (resolveUseV7Portal(params)) {
202
208
  return params.originalPortal.newTokenV7.populateTransaction(buildNewTokenV7PermitParams(params));
203
209
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { Contract, Interface, Wallet, ZeroAddress } from 'ethers';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { FLAP_PORTAL_ABI } from '../../abis/flap/portal-merkle.js';
4
+ import { ZERO_ADDRESS } from '../../core/constants/index.js';
5
+ import { FeeType, MigratorType, TokenVersion } from './portal.js';
6
+ import { populateCreateTokenTransaction } from './portal-create-token.js';
7
+ describe('populateCreateTokenTransaction V7 tax-free', () => {
8
+ it('uses DIVIDEND 10000 bps like flap.sh, not MARKETING_OR_VAULT', async () => {
9
+ const wallet = Wallet.createRandom();
10
+ const portal = new Contract(ZeroAddress, FLAP_PORTAL_ABI, wallet);
11
+ const tx = await populateCreateTokenTransaction({
12
+ chain: 'BSC',
13
+ devWallet: wallet,
14
+ originalPortal: portal,
15
+ tokenInfo: { name: 'zzz', symbol: 'zzz', meta: 'cid' },
16
+ beneficiary: wallet.address,
17
+ useV7Portal: true,
18
+ });
19
+ const parsed = new Interface(FLAP_PORTAL_ABI).parseTransaction({ data: tx.data });
20
+ expect(parsed?.name).toBe('newTokenV7');
21
+ const p = parsed.args[0];
22
+ expect(Number(p[5])).toBe(MigratorType.PCS_INFINITY_CL_MIGRATOR);
23
+ expect(Number(p[17])).toBe(TokenVersion.TOKEN_V3_PERMIT);
24
+ expect(p[16]).toBe(ZERO_ADDRESS);
25
+ const fee0 = p[18][0];
26
+ expect(Number(fee0[0])).toBe(FeeType.DIVIDEND);
27
+ expect(Number(fee0[1])).toBe(10000);
28
+ expect(fee0[2]).toBe(ZERO_ADDRESS);
29
+ expect(fee0[3]).toBe(ZERO_ADDRESS);
30
+ expect(fee0[4]).toBe(10000n * 10n ** 18n);
31
+ });
32
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "four-flap-meme-sdk",
3
- "version": "4.3.4",
3
+ "version": "4.3.6",
4
4
  "description": "SDK for Flap bonding curve and four.meme TokenManager",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",