four-flap-meme-sdk 1.3.43 → 1.3.45
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.
|
@@ -80,23 +80,29 @@ const V2_ROUTER_ABI = [
|
|
|
80
80
|
'function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts)',
|
|
81
81
|
];
|
|
82
82
|
/**
|
|
83
|
-
* SwapRouter02 的 V2 方法 ABI (PotatoSwap
|
|
84
|
-
* SwapRouter02 同时支持 V2 和 V3,V2 方法通过 multicall 调用
|
|
83
|
+
* SwapRouter02 的 V2 方法 ABI (PotatoSwap)
|
|
85
84
|
*
|
|
86
|
-
*
|
|
87
|
-
* - swapExactTokensForTokens(amountIn, amountOutMin, path[], to)
|
|
88
|
-
* -
|
|
85
|
+
* 重要:SwapRouter02 的 V2 方法只有:
|
|
86
|
+
* - swapExactTokensForTokens(amountIn, amountOutMin, path[], to)
|
|
87
|
+
* - swapTokensForExactTokens(amountOut, amountInMax, path[], to)
|
|
88
|
+
*
|
|
89
|
+
* 没有 swapExactETHForTokens / swapExactTokensForETH!
|
|
90
|
+
* 需要手动处理 ETH/WETH 转换:
|
|
91
|
+
* - 买入(ETH->Token): wrapETH + swapExactTokensForTokens
|
|
92
|
+
* - 卖出(Token->ETH): swapExactTokensForTokens(to=ADDRESS_THIS) + unwrapWETH9
|
|
89
93
|
*/
|
|
90
94
|
const SWAP_ROUTER02_V2_ABI = [
|
|
91
|
-
// V2
|
|
95
|
+
// V2 交换方法(只有 token-to-token)
|
|
92
96
|
'function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to) external payable returns (uint256 amountOut)',
|
|
97
|
+
'function swapTokensForExactTokens(uint256 amountOut, uint256 amountInMax, address[] calldata path, address to) external payable returns (uint256 amountIn)',
|
|
93
98
|
// Multicall - 多种重载
|
|
94
99
|
'function multicall(uint256 deadline, bytes[] calldata data) external payable returns (bytes[] memory results)',
|
|
95
100
|
'function multicall(bytes[] calldata data) external payable returns (bytes[] memory results)',
|
|
96
|
-
// 辅助方法
|
|
97
|
-
'function
|
|
101
|
+
// 辅助方法 - ETH/WETH 转换
|
|
102
|
+
'function wrapETH(uint256 value) external payable',
|
|
98
103
|
'function unwrapWETH9(uint256 amountMinimum, address recipient) external payable',
|
|
99
104
|
'function unwrapWETH9(uint256 amountMinimum) external payable',
|
|
105
|
+
'function refundETH() external payable',
|
|
100
106
|
// pull 方法 - 从用户拉取代币到 Router
|
|
101
107
|
'function pull(address token, uint256 value) external payable',
|
|
102
108
|
// sweepToken - 将 Router 中的代币发送给用户
|
|
@@ -177,8 +183,11 @@ const V3_ROUTER_LEGACY_ABI = [
|
|
|
177
183
|
* - Uniswap V3: 0xd6145b2d3f379919e8cdeda7b97e37c4b2ca9c40
|
|
178
184
|
* - PancakeSwap V3: 0x1b81D678ffb9C0263b24A97847620C99d213eB14
|
|
179
185
|
*
|
|
186
|
+
* XLayer:
|
|
187
|
+
* - V3 Router (0xBB069e9465BcabC4F488d21e793BDEf0F2d41D41) - 旧版(exactInputSingle 包含 deadline)
|
|
188
|
+
* - SwapRouter02 (0xB45D0149249488333E3F3f9F359807F4b810C1FC) - 新版(V2+V3 混合)
|
|
189
|
+
*
|
|
180
190
|
* BSC PancakeSwap V3 使用新版 SwapRouter02
|
|
181
|
-
* XLayer PotatoSwap SwapRouter02 使用新版
|
|
182
191
|
*/
|
|
183
192
|
function isLegacySwapRouter(chain, routerAddress) {
|
|
184
193
|
const chainUpper = chain.toUpperCase();
|
|
@@ -192,8 +201,15 @@ function isLegacySwapRouter(chain, routerAddress) {
|
|
|
192
201
|
return true;
|
|
193
202
|
}
|
|
194
203
|
}
|
|
195
|
-
// ✅ XLayer
|
|
196
|
-
|
|
204
|
+
// ✅ XLayer V3 专用 Router 是旧版(exactInputSingle 包含 deadline)
|
|
205
|
+
if (chainUpper === 'XLAYER') {
|
|
206
|
+
if (routerLower === DIRECT_ROUTERS.XLAYER.V3_ROUTER.toLowerCase()) {
|
|
207
|
+
return true; // V3 Router 是旧版
|
|
208
|
+
}
|
|
209
|
+
// SwapRouter02 是新版
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
// ✅ BSC PancakeSwap V3 使用新版 SwapRouter02
|
|
197
213
|
return false;
|
|
198
214
|
}
|
|
199
215
|
/** ERC20 ABI */
|
|
@@ -310,18 +326,40 @@ export async function directV2BatchBuy(params) {
|
|
|
310
326
|
let txData;
|
|
311
327
|
let txValue;
|
|
312
328
|
if (useSwapRouter02) {
|
|
313
|
-
// ✅ SwapRouter02
|
|
314
|
-
//
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
329
|
+
// ✅ SwapRouter02: 只有 swapExactTokensForTokens,需要手动处理 ETH wrap
|
|
330
|
+
// 文档:没有 swapExactETHForTokens / swapExactTokensForETH
|
|
331
|
+
const multicallData = [];
|
|
332
|
+
if (useNative) {
|
|
333
|
+
// ETH -> 代币:
|
|
334
|
+
// 1. wrapETH(msg.value) - 将 ETH 转为 WETH
|
|
335
|
+
// 2. swapExactTokensForTokens(WETH -> Token)
|
|
336
|
+
const wrapData = routerIface.encodeFunctionData('wrapETH', [amountWei]);
|
|
337
|
+
multicallData.push(wrapData);
|
|
338
|
+
const swapData = routerIface.encodeFunctionData('swapExactTokensForTokens', [
|
|
339
|
+
amountWei,
|
|
340
|
+
0n, // amountOutMin
|
|
341
|
+
path, // path: [WETH, ..., tokenOut]
|
|
342
|
+
wallet.address, // to
|
|
343
|
+
]);
|
|
344
|
+
multicallData.push(swapData);
|
|
345
|
+
// refundETH - 退回多余的 ETH(如果有)
|
|
346
|
+
const refundData = routerIface.encodeFunctionData('refundETH', []);
|
|
347
|
+
multicallData.push(refundData);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
// 代币 -> 代币:直接 swapExactTokensForTokens
|
|
351
|
+
const swapData = routerIface.encodeFunctionData('swapExactTokensForTokens', [
|
|
352
|
+
amountWei,
|
|
353
|
+
0n, // amountOutMin
|
|
354
|
+
path,
|
|
355
|
+
wallet.address, // to
|
|
356
|
+
]);
|
|
357
|
+
multicallData.push(swapData);
|
|
358
|
+
}
|
|
321
359
|
// 使用 multicall 包装,传递 deadline
|
|
322
|
-
txData = routerIface.encodeFunctionData('multicall', [
|
|
360
|
+
txData = routerIface.encodeFunctionData('multicall(uint256,bytes[])', [
|
|
323
361
|
deadline,
|
|
324
|
-
|
|
362
|
+
multicallData,
|
|
325
363
|
]);
|
|
326
364
|
txValue = useNative ? amountWei : 0n;
|
|
327
365
|
}
|
|
@@ -450,26 +488,41 @@ export async function directV2BatchSell(params) {
|
|
|
450
488
|
// 卖出交易
|
|
451
489
|
let txData;
|
|
452
490
|
if (useSwapRouter02) {
|
|
453
|
-
// ✅ SwapRouter02:
|
|
454
|
-
// 流程:swapExactTokensForTokens (token -> WETH) + unwrapWETH9 (WETH -> ETH)
|
|
491
|
+
// ✅ SwapRouter02: 只有 swapExactTokensForTokens,需要手动处理 ETH unwrap
|
|
455
492
|
const router02Iface = new ethers.Interface(SWAP_ROUTER02_V2_ABI);
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
path,
|
|
462
|
-
useNativeOutput ? ADDRESS_THIS : wallet.address, // 如果输出是原生币,先发到 Router
|
|
463
|
-
]);
|
|
464
|
-
const multicallData = [swapData];
|
|
465
|
-
// 2. 如果输出是原生币,需要 unwrapWETH9
|
|
493
|
+
const multicallData = [];
|
|
494
|
+
// SwapRouter02 特殊地址约定:
|
|
495
|
+
// address(1) = ADDRESS_THIS = Router 合约自己
|
|
496
|
+
// address(2) = MSG_SENDER = 调用者
|
|
497
|
+
const ADDRESS_THIS = '0x0000000000000000000000000000000000000001'; // Router 自己
|
|
466
498
|
if (useNativeOutput) {
|
|
499
|
+
// 代币 -> ETH:
|
|
500
|
+
// 1. swapExactTokensForTokens(token -> WETH, to = ADDRESS_THIS)
|
|
501
|
+
// 2. unwrapWETH9(WETH -> ETH, to = 用户)
|
|
502
|
+
const swapData = router02Iface.encodeFunctionData('swapExactTokensForTokens', [
|
|
503
|
+
sellAmount,
|
|
504
|
+
0n, // amountOutMin
|
|
505
|
+
path, // path: [token, ..., WETH]
|
|
506
|
+
ADDRESS_THIS, // to = Router 自己,WETH 留在 Router 中
|
|
507
|
+
]);
|
|
508
|
+
multicallData.push(swapData);
|
|
509
|
+
// unwrap WETH -> ETH 发送给用户
|
|
467
510
|
const unwrapData = router02Iface.encodeFunctionData('unwrapWETH9(uint256,address)', [
|
|
468
511
|
0n, // amountMinimum
|
|
469
|
-
wallet.address, // recipient
|
|
512
|
+
wallet.address, // recipient = 用户
|
|
470
513
|
]);
|
|
471
514
|
multicallData.push(unwrapData);
|
|
472
515
|
}
|
|
516
|
+
else {
|
|
517
|
+
// 代币 -> 代币:直接 swapExactTokensForTokens
|
|
518
|
+
const swapData = router02Iface.encodeFunctionData('swapExactTokensForTokens', [
|
|
519
|
+
sellAmount,
|
|
520
|
+
0n, // amountOutMin
|
|
521
|
+
path,
|
|
522
|
+
wallet.address, // to
|
|
523
|
+
]);
|
|
524
|
+
multicallData.push(swapData);
|
|
525
|
+
}
|
|
473
526
|
// 使用 multicall 包装,传递 deadline
|
|
474
527
|
txData = router02Iface.encodeFunctionData('multicall(uint256,bytes[])', [
|
|
475
528
|
deadline,
|
|
@@ -297,17 +297,62 @@ export async function batchSellWithBundleMerkle(params) {
|
|
|
297
297
|
const useNativeOutput = outputToken === ZERO_ADDRESS;
|
|
298
298
|
// ✅ 优化:如果前端传入了 nonces,直接使用(跳过 RPC 调用)
|
|
299
299
|
const presetNonces = config.nonces;
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
const extractProfit = shouldExtractProfit(config);
|
|
301
|
+
// ✅ 并行执行 gasPrice 和 quoteSellOutputs(Multicall3)
|
|
302
|
+
const [gasPrice, quotedOutputs] = await Promise.all([
|
|
302
303
|
resolveGasPrice(provider, config),
|
|
303
|
-
quoteSellOutputsWithQuote(readOnlyPortal, tokenAddress, amountsWei, outputToken)
|
|
304
|
-
presetNonces && presetNonces.length === wallets.length
|
|
305
|
-
? Promise.resolve(presetNonces) // ✅ 使用前端传入的 nonces
|
|
306
|
-
: nonceManager.getNextNoncesForWallets(wallets)
|
|
304
|
+
quoteSellOutputsWithQuote(readOnlyPortal, tokenAddress, amountsWei, outputToken)
|
|
307
305
|
]);
|
|
308
|
-
|
|
306
|
+
// ✅ 计算利润和 maxRevenueIndex(用于 nonce 分配)
|
|
307
|
+
let totalTokenProfit = 0n;
|
|
308
|
+
let maxRevenueIndex = -1;
|
|
309
|
+
let maxRevenue = 0n;
|
|
310
|
+
if (extractProfit && quotedOutputs.length > 0) {
|
|
311
|
+
for (let i = 0; i < wallets.length; i++) {
|
|
312
|
+
const quoted = quotedOutputs[i];
|
|
313
|
+
if (quoted > 0n) {
|
|
314
|
+
const { profit } = calculateProfit(quoted, config);
|
|
315
|
+
totalTokenProfit += profit;
|
|
316
|
+
if (quoted > maxRevenue) {
|
|
317
|
+
maxRevenue = quoted;
|
|
318
|
+
maxRevenueIndex = i;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// ✅ 修复:根据是否需要利润交易,统一分配 nonces
|
|
324
|
+
const needProfitTx = extractProfit && totalTokenProfit > 0n && maxRevenueIndex >= 0;
|
|
325
|
+
let nonces;
|
|
326
|
+
let profitNonce;
|
|
327
|
+
if (presetNonces && presetNonces.length === wallets.length) {
|
|
328
|
+
nonces = presetNonces;
|
|
309
329
|
console.log('🚀 SDK 使用前端传入的 nonces:', presetNonces);
|
|
310
330
|
}
|
|
331
|
+
else if (needProfitTx) {
|
|
332
|
+
// maxRevenueIndex 钱包需要 2 个连续 nonce(卖出 + 利润)
|
|
333
|
+
const maxRevenueNonces = await nonceManager.getNextNonceBatch(wallets[maxRevenueIndex], 2);
|
|
334
|
+
// 其他钱包各需要 1 个 nonce
|
|
335
|
+
const otherWallets = wallets.filter((_, i) => i !== maxRevenueIndex);
|
|
336
|
+
const otherNonces = otherWallets.length > 0
|
|
337
|
+
? await nonceManager.getNextNoncesForWallets(otherWallets)
|
|
338
|
+
: [];
|
|
339
|
+
// 组装最终的 nonces 数组(保持原顺序)
|
|
340
|
+
nonces = [];
|
|
341
|
+
let otherIdx = 0;
|
|
342
|
+
for (let i = 0; i < wallets.length; i++) {
|
|
343
|
+
if (i === maxRevenueIndex) {
|
|
344
|
+
nonces.push(maxRevenueNonces[0]); // 卖出交易用第一个 nonce
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
nonces.push(otherNonces[otherIdx++]);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
profitNonce = maxRevenueNonces[1]; // 利润交易用第二个 nonce
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// 不需要利润交易,所有钱包各 1 个 nonce
|
|
354
|
+
nonces = await nonceManager.getNextNoncesForWallets(wallets);
|
|
355
|
+
}
|
|
311
356
|
const minOuts = resolveMinOutputs(minOutputAmounts, wallets.length, quotedOutputs);
|
|
312
357
|
// ✅ 优化:构建未签名交易(这里是本地操作,但仍然并行执行以提高效率)
|
|
313
358
|
const unsignedList = await Promise.all(portals.map((portal, i) => portal.swapExactInput.populateTransaction({
|
|
@@ -330,19 +375,31 @@ export async function batchSellWithBundleMerkle(params) {
|
|
|
330
375
|
value: 0n // ✅ 卖出交易不发送原生代币
|
|
331
376
|
})));
|
|
332
377
|
signedTxs.push(...signedList);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
378
|
+
// ✅ 修复:使用预先分配的 profitNonce 添加利润交易
|
|
379
|
+
if (needProfitTx && profitNonce !== undefined) {
|
|
380
|
+
// ERC20 输出时:获取代币利润等值的原生代币(BNB)报价
|
|
381
|
+
let nativeProfitAmount = totalTokenProfit;
|
|
382
|
+
if (!useNativeOutput && outputToken) {
|
|
383
|
+
nativeProfitAmount = await getTokenToNativeQuote(provider, outputToken, totalTokenProfit, chainId);
|
|
384
|
+
console.log('🔍 SDK ERC20 卖出利润转换: ', ethers.formatEther(totalTokenProfit), ' Token -> ', ethers.formatEther(nativeProfitAmount), ' BNB');
|
|
385
|
+
// 如果报价失败(返回 0),跳过利润提取
|
|
386
|
+
if (nativeProfitAmount === 0n) {
|
|
387
|
+
console.log('🔍 SDK ERC20 卖出利润转换失败,跳过利润提取');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (nativeProfitAmount > 0n) {
|
|
391
|
+
const profitTx = await wallets[maxRevenueIndex].signTransaction({
|
|
392
|
+
to: getProfitRecipient(),
|
|
393
|
+
value: nativeProfitAmount,
|
|
394
|
+
nonce: profitNonce,
|
|
395
|
+
gasPrice,
|
|
396
|
+
gasLimit: 23000n,
|
|
397
|
+
chainId,
|
|
398
|
+
type: getTxType(config)
|
|
399
|
+
});
|
|
400
|
+
signedTxs.push(profitTx);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
346
403
|
nonceManager.clearTemp();
|
|
347
404
|
return {
|
|
348
405
|
signedTransactions: signedTxs
|
|
@@ -434,19 +491,33 @@ function buildGasLimitList(length, config) {
|
|
|
434
491
|
return new Array(length).fill(gasLimit);
|
|
435
492
|
}
|
|
436
493
|
/**
|
|
437
|
-
* ✅
|
|
438
|
-
* 使用 getNextNoncesForWallets 批量获取,比 Promise.all 更高效
|
|
494
|
+
* ✅ 修复:明确分配 nonces,避免隐式状态依赖
|
|
439
495
|
*/
|
|
440
496
|
async function allocateBuyerNonces(buyers, extractProfit, maxIndex, totalProfit, nonceManager) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (extractProfit && totalProfit > 0n && maxIndex >= 0 && maxIndex < buyers.length) {
|
|
446
|
-
// 再获取一个 nonce 给利润交易(从缓存中获取,不会触发 RPC)
|
|
447
|
-
await nonceManager.getNextNonce(buyers[maxIndex]);
|
|
497
|
+
const needProfitTx = extractProfit && totalProfit > 0n && maxIndex >= 0 && maxIndex < buyers.length;
|
|
498
|
+
if (!needProfitTx) {
|
|
499
|
+
// 不需要利润交易,所有钱包各 1 个 nonce
|
|
500
|
+
return await nonceManager.getNextNoncesForWallets(buyers);
|
|
448
501
|
}
|
|
449
|
-
|
|
502
|
+
// 需要利润交易:maxIndex 钱包需要 2 个连续 nonce(买入 + 利润)
|
|
503
|
+
const maxIndexNonces = await nonceManager.getNextNonceBatch(buyers[maxIndex], 2);
|
|
504
|
+
// 其他钱包各需要 1 个 nonce
|
|
505
|
+
const otherBuyers = buyers.filter((_, i) => i !== maxIndex);
|
|
506
|
+
const otherNonces = otherBuyers.length > 0
|
|
507
|
+
? await nonceManager.getNextNoncesForWallets(otherBuyers)
|
|
508
|
+
: [];
|
|
509
|
+
// 组装最终的 nonces 数组(保持原顺序)
|
|
510
|
+
const nonces = [];
|
|
511
|
+
let otherIdx = 0;
|
|
512
|
+
for (let i = 0; i < buyers.length; i++) {
|
|
513
|
+
if (i === maxIndex) {
|
|
514
|
+
nonces.push(maxIndexNonces[0]); // 买入交易用第一个 nonce
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
nonces.push(otherNonces[otherIdx++]);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return nonces;
|
|
450
521
|
}
|
|
451
522
|
async function signBuyTransactions({ unsignedBuys, buyers, nonces, gasLimits, gasPrice, chainId, config, fundsList, useNativeToken = true // ✅ 默认使用原生代币
|
|
452
523
|
}) {
|
|
@@ -471,7 +542,7 @@ async function appendProfitTransaction({ extractProfit, totalProfit, buyers, max
|
|
|
471
542
|
value: totalProfit,
|
|
472
543
|
nonce: profitNonce,
|
|
473
544
|
gasPrice,
|
|
474
|
-
gasLimit:
|
|
545
|
+
gasLimit: 23000n,
|
|
475
546
|
chainId,
|
|
476
547
|
type: getTxType(config)
|
|
477
548
|
});
|
|
@@ -562,45 +633,4 @@ function resolveMinOutputs(provided, walletCount, _quotedOutputs) {
|
|
|
562
633
|
// 原因:大额交易时 5% 滑点可能不够,导致交易失败
|
|
563
634
|
return Array(walletCount).fill(0n);
|
|
564
635
|
}
|
|
565
|
-
|
|
566
|
-
if (!extractProfit || quotedOutputs.length === 0) {
|
|
567
|
-
return;
|
|
568
|
-
}
|
|
569
|
-
let totalTokenProfit = 0n; // 代币利润
|
|
570
|
-
let maxRevenueIndex = -1;
|
|
571
|
-
let maxRevenue = 0n;
|
|
572
|
-
for (let i = 0; i < wallets.length; i++) {
|
|
573
|
-
const quoted = quotedOutputs[i];
|
|
574
|
-
if (quoted > 0n) {
|
|
575
|
-
const { profit } = calculateProfit(quoted, config);
|
|
576
|
-
totalTokenProfit += profit;
|
|
577
|
-
if (quoted > maxRevenue) {
|
|
578
|
-
maxRevenue = quoted;
|
|
579
|
-
maxRevenueIndex = i;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
if (totalTokenProfit === 0n || maxRevenueIndex < 0) {
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
// ✅ ERC20 输出:获取代币利润等值的原生代币(BNB)报价
|
|
587
|
-
let nativeProfitAmount = totalTokenProfit; // 原生代币输出时直接使用
|
|
588
|
-
if (!useNativeOutput && outputToken && provider) {
|
|
589
|
-
nativeProfitAmount = await getTokenToNativeQuote(provider, outputToken, totalTokenProfit, chainId);
|
|
590
|
-
// 如果报价失败(返回 0),跳过利润提取
|
|
591
|
-
if (nativeProfitAmount === 0n) {
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
const profitNonce = await nonceManager.getNextNonce(wallets[maxRevenueIndex]);
|
|
596
|
-
const profitTx = await wallets[maxRevenueIndex].signTransaction({
|
|
597
|
-
to: getProfitRecipient(),
|
|
598
|
-
value: nativeProfitAmount, // ✅ 转等值原生代币
|
|
599
|
-
nonce: profitNonce,
|
|
600
|
-
gasPrice,
|
|
601
|
-
gasLimit: 21000n,
|
|
602
|
-
chainId,
|
|
603
|
-
type: getTxType(config)
|
|
604
|
-
});
|
|
605
|
-
signedTxs.push(profitTx);
|
|
606
|
-
}
|
|
636
|
+
// ✅ appendSellProfitTransaction 已内联到 batchSellWithBundleMerkle 中,避免 nonce 竞争问题
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECDH + AES-GCM 加密工具(浏览器兼容)
|
|
3
|
+
* 用于将签名交易用服务器公钥加密
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* 用服务器公钥加密签名交易(ECDH + AES-GCM)
|
|
7
|
+
*
|
|
8
|
+
* @param signedTransactions 签名后的交易数组
|
|
9
|
+
* @param publicKeyBase64 服务器提供的公钥(Base64 格式)
|
|
10
|
+
* @returns JSON 字符串 {e: 临时公钥, i: IV, d: 密文}
|
|
11
|
+
*/
|
|
12
|
+
export declare function encryptWithPublicKey(signedTransactions: string[], publicKeyBase64: string): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* 验证公钥格式(Base64)
|
|
15
|
+
*/
|
|
16
|
+
export declare function validatePublicKey(publicKeyBase64: string): boolean;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECDH + AES-GCM 加密工具(浏览器兼容)
|
|
3
|
+
* 用于将签名交易用服务器公钥加密
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* 获取全局 crypto 对象(最简单直接的方式)
|
|
7
|
+
*/
|
|
8
|
+
function getCryptoAPI() {
|
|
9
|
+
// 尝试所有可能的全局对象,优先浏览器环境
|
|
10
|
+
const cryptoObj = (typeof window !== 'undefined' && window.crypto) ||
|
|
11
|
+
(typeof self !== 'undefined' && self.crypto) ||
|
|
12
|
+
(typeof global !== 'undefined' && global.crypto) ||
|
|
13
|
+
(typeof globalThis !== 'undefined' && globalThis.crypto);
|
|
14
|
+
if (!cryptoObj) {
|
|
15
|
+
const env = typeof window !== 'undefined' ? 'Browser' : 'Node.js';
|
|
16
|
+
const protocol = typeof location !== 'undefined' ? location.protocol : 'unknown';
|
|
17
|
+
throw new Error(`❌ Crypto API 不可用。环境: ${env}, 协议: ${protocol}. ` +
|
|
18
|
+
'请确保在 HTTPS 或 localhost 下运行');
|
|
19
|
+
}
|
|
20
|
+
return cryptoObj;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 获取 SubtleCrypto(用于加密操作)
|
|
24
|
+
*/
|
|
25
|
+
function getSubtleCrypto() {
|
|
26
|
+
const crypto = getCryptoAPI();
|
|
27
|
+
if (!crypto.subtle) {
|
|
28
|
+
const protocol = typeof location !== 'undefined' ? location.protocol : 'unknown';
|
|
29
|
+
const hostname = typeof location !== 'undefined' ? location.hostname : 'unknown';
|
|
30
|
+
throw new Error(`❌ SubtleCrypto API 不可用。协议: ${protocol}, 主机: ${hostname}. ` +
|
|
31
|
+
'请确保:1) 使用 HTTPS (或 localhost);2) 浏览器支持 Web Crypto API;' +
|
|
32
|
+
'3) 不在无痕/隐私浏览模式下');
|
|
33
|
+
}
|
|
34
|
+
return crypto.subtle;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Base64 转 ArrayBuffer(优先使用浏览器 API)
|
|
38
|
+
*/
|
|
39
|
+
function base64ToArrayBuffer(base64) {
|
|
40
|
+
// 浏览器环境(优先)
|
|
41
|
+
if (typeof atob !== 'undefined') {
|
|
42
|
+
const binaryString = atob(base64);
|
|
43
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
44
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
45
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
46
|
+
}
|
|
47
|
+
return bytes.buffer;
|
|
48
|
+
}
|
|
49
|
+
// Node.js 环境(fallback)
|
|
50
|
+
if (typeof Buffer !== 'undefined') {
|
|
51
|
+
return Buffer.from(base64, 'base64').buffer;
|
|
52
|
+
}
|
|
53
|
+
throw new Error('❌ Base64 解码不可用');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* ArrayBuffer 转 Base64(优先使用浏览器 API)
|
|
57
|
+
*/
|
|
58
|
+
function arrayBufferToBase64(buffer) {
|
|
59
|
+
// 浏览器环境(优先)
|
|
60
|
+
if (typeof btoa !== 'undefined') {
|
|
61
|
+
const bytes = new Uint8Array(buffer);
|
|
62
|
+
let binary = '';
|
|
63
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
64
|
+
binary += String.fromCharCode(bytes[i]);
|
|
65
|
+
}
|
|
66
|
+
return btoa(binary);
|
|
67
|
+
}
|
|
68
|
+
// Node.js 环境(fallback)
|
|
69
|
+
if (typeof Buffer !== 'undefined') {
|
|
70
|
+
return Buffer.from(buffer).toString('base64');
|
|
71
|
+
}
|
|
72
|
+
throw new Error('❌ Base64 编码不可用');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 生成随机 Hex 字符串
|
|
76
|
+
*/
|
|
77
|
+
function randomHex(length) {
|
|
78
|
+
const crypto = getCryptoAPI();
|
|
79
|
+
const array = new Uint8Array(length);
|
|
80
|
+
crypto.getRandomValues(array);
|
|
81
|
+
return Array.from(array)
|
|
82
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
83
|
+
.join('');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 用服务器公钥加密签名交易(ECDH + AES-GCM)
|
|
87
|
+
*
|
|
88
|
+
* @param signedTransactions 签名后的交易数组
|
|
89
|
+
* @param publicKeyBase64 服务器提供的公钥(Base64 格式)
|
|
90
|
+
* @returns JSON 字符串 {e: 临时公钥, i: IV, d: 密文}
|
|
91
|
+
*/
|
|
92
|
+
export async function encryptWithPublicKey(signedTransactions, publicKeyBase64) {
|
|
93
|
+
try {
|
|
94
|
+
// 0. 获取 SubtleCrypto 和 Crypto API
|
|
95
|
+
const subtle = getSubtleCrypto();
|
|
96
|
+
const crypto = getCryptoAPI();
|
|
97
|
+
// 1. 准备数据
|
|
98
|
+
const payload = {
|
|
99
|
+
signedTransactions,
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
nonce: randomHex(8)
|
|
102
|
+
};
|
|
103
|
+
const plaintext = JSON.stringify(payload);
|
|
104
|
+
// 2. 生成临时 ECDH 密钥对
|
|
105
|
+
const ephemeralKeyPair = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']);
|
|
106
|
+
// 3. 导入服务器公钥
|
|
107
|
+
const publicKeyBuffer = base64ToArrayBuffer(publicKeyBase64);
|
|
108
|
+
const publicKey = await subtle.importKey('raw', publicKeyBuffer, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
109
|
+
// 4. 派生共享密钥(AES-256)
|
|
110
|
+
const sharedKey = await subtle.deriveKey({ name: 'ECDH', public: publicKey }, ephemeralKeyPair.privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
|
|
111
|
+
// 5. AES-GCM 加密
|
|
112
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
113
|
+
const encrypted = await subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, new TextEncoder().encode(plaintext));
|
|
114
|
+
// 6. 导出临时公钥
|
|
115
|
+
const ephemeralPublicKeyRaw = await subtle.exportKey('raw', ephemeralKeyPair.publicKey);
|
|
116
|
+
// 7. 返回加密包(JSON 格式)
|
|
117
|
+
return JSON.stringify({
|
|
118
|
+
e: arrayBufferToBase64(ephemeralPublicKeyRaw), // 临时公钥
|
|
119
|
+
i: arrayBufferToBase64(iv.buffer), // IV
|
|
120
|
+
d: arrayBufferToBase64(encrypted) // 密文
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw new Error(`加密失败: ${error?.message || String(error)}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 验证公钥格式(Base64)
|
|
129
|
+
*/
|
|
130
|
+
export function validatePublicKey(publicKeyBase64) {
|
|
131
|
+
try {
|
|
132
|
+
if (!publicKeyBase64)
|
|
133
|
+
return false;
|
|
134
|
+
// Base64 字符集验证
|
|
135
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(publicKeyBase64))
|
|
136
|
+
return false;
|
|
137
|
+
// ECDH P-256 公钥固定长度 65 字节(未压缩)
|
|
138
|
+
// Base64 编码后约 88 字符
|
|
139
|
+
if (publicKeyBase64.length < 80 || publicKeyBase64.length > 100)
|
|
140
|
+
return false;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|