848 lines
32 KiB
TypeScript
848 lines
32 KiB
TypeScript
/**
|
||
* Swap orchestrator — chained custodial swap для всех 3 DEX (BSC PancakeSwap, TRX SunSwap, SOL Jupiter).
|
||
*
|
||
* Каждая функция inkl. полный flow: build → sign → broadcast в одном вызове.
|
||
* Возвращает txid'ы — клиенту не нужно client-side signing.
|
||
*
|
||
* Reused infrastructure:
|
||
* - ethers / @solana/web3.js / TronGrid HTTP
|
||
* - Master-key crypto через decryptMnemonic (caller)
|
||
* - Mutex / idempotency (caller)
|
||
* - Audit log (caller)
|
||
*/
|
||
|
||
import { ethers } from 'ethers';
|
||
import { createHash } from 'crypto';
|
||
import * as bip39 from 'bip39';
|
||
import {
|
||
Keypair, Connection, PublicKey, VersionedTransaction,
|
||
} from '@solana/web3.js';
|
||
import { derivePath } from 'ed25519-hd-key';
|
||
import { env } from '../config/env';
|
||
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
|
||
import { getEvmFeeForTier, type FeeTier } from './gas-oracle.service';
|
||
import { logger } from '../lib/logger';
|
||
|
||
const HTTP_TIMEOUT_MS = 20_000;
|
||
const MAX_GAS_PRICE_GWEI = 500;
|
||
|
||
// ─── BSC PancakeSwap V2 ─────────────────────────────────────────────
|
||
|
||
const BSC_RPCS = [
|
||
'https://bsc-dataseed.binance.org',
|
||
'https://bsc-dataseed1.binance.org',
|
||
'https://bsc.publicnode.com',
|
||
];
|
||
const BSC_CHAIN_ID = 56;
|
||
const PANCAKE_ROUTER = '0x10ED43C718714eb63d5aA57B78B54704E256024E';
|
||
const WBNB = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c';
|
||
|
||
const BSC_TOKEN_MAP: Record<string, string> = {
|
||
BNB: WBNB,
|
||
USDT: '0x55d398326f99059fF775485246999027B3197955',
|
||
USDC: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d',
|
||
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
|
||
WBNB,
|
||
BUSD: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56',
|
||
};
|
||
|
||
const ROUTER_ABI = [
|
||
'function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts)',
|
||
'function swapExactETHForTokensSupportingFeeOnTransferTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) external payable',
|
||
'function swapExactTokensForETHSupportingFeeOnTransferTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external',
|
||
'function swapExactTokensForTokensSupportingFeeOnTransferTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external',
|
||
];
|
||
|
||
const ERC20_ABI = [
|
||
'function approve(address spender, uint256 amount) external returns (bool)',
|
||
'function allowance(address owner, address spender) external view returns (uint256)',
|
||
];
|
||
|
||
export interface SwapBscParams {
|
||
mnemonic: string;
|
||
expectedFromAddress: string;
|
||
from: string; // 'BNB' | 'USDT' | 'USDC' | 'DOGE' | 'WBNB' | 'BUSD'
|
||
to: string;
|
||
amount: string; // smallest units (wei для 18-decimals)
|
||
slippageBps?: number; // default 50 (0.5%)
|
||
feeTier?: FeeTier;
|
||
}
|
||
|
||
async function pickProvider(rpcs: string[], chainId: number): Promise<ethers.providers.StaticJsonRpcProvider> {
|
||
let lastErr: any;
|
||
for (const url of rpcs) {
|
||
const p = new ethers.providers.StaticJsonRpcProvider(url, chainId);
|
||
try {
|
||
await Promise.race([
|
||
p.getBlockNumber(),
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error('rpc_alive_timeout')), 3000)),
|
||
]);
|
||
return p;
|
||
} catch (err) {
|
||
lastErr = err;
|
||
}
|
||
}
|
||
throw new Error(`All BSC RPCs failed: ${lastErr?.message || lastErr}`);
|
||
}
|
||
|
||
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
||
return Promise.race([
|
||
p,
|
||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(msg)), ms)),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* BSC chained swap. Если `from` не нативный BNB и allowance < amount —
|
||
* сначала approve(exact), wait 1 confirmation, потом swap.
|
||
*
|
||
* Returns: { approveTxid?, swapTxid }
|
||
*/
|
||
export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string; swapTxid: string }> {
|
||
const fromUpper = p.from.toUpperCase();
|
||
const toUpper = p.to.toUpperCase();
|
||
|
||
if (!BSC_TOKEN_MAP[fromUpper] || !BSC_TOKEN_MAP[toUpper] || fromUpper === toUpper) {
|
||
throw new Error(`Invalid BSC swap pair: ${fromUpper} → ${toUpper}`);
|
||
}
|
||
if (!/^\d+$/.test(p.amount) || BigInt(p.amount) <= 0n) {
|
||
throw new Error('amount must be positive integer string');
|
||
}
|
||
const slippageBps = p.slippageBps ?? 50;
|
||
if (slippageBps < 1 || slippageBps > 1000) {
|
||
throw new Error('slippageBps must be 1-1000 (0.01%-10%)');
|
||
}
|
||
|
||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
||
if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
|
||
throw new Error(`Derived BSC address mismatch: ${wallet.address} ≠ ${p.expectedFromAddress}`);
|
||
}
|
||
|
||
const provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID);
|
||
const signer = wallet.connect(provider);
|
||
|
||
// Gas tier
|
||
const tier: FeeTier = p.feeTier ?? 'normal';
|
||
const fee = await getEvmFeeForTier('BSC', tier);
|
||
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
|
||
const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas);
|
||
const maxPriorityFeePerGas = ethers.BigNumber.from(fee.maxPriorityFeePerGas);
|
||
if (maxFeePerGas.gt(capWei) || maxPriorityFeePerGas.gt(maxFeePerGas)) {
|
||
throw new Error('Gas fee invariant violated');
|
||
}
|
||
|
||
// Quote via getAmountsOut → compute amountOutMin server-side (anti-MEV)
|
||
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
|
||
const amountsOut: ethers.BigNumber[] = await withTimeout(
|
||
routerContract.getAmountsOut(p.amount, path),
|
||
HTTP_TIMEOUT_MS,
|
||
'PancakeSwap quote timed out',
|
||
);
|
||
const expectedOut = amountsOut[amountsOut.length - 1];
|
||
if (expectedOut.lte(0)) {
|
||
throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair');
|
||
}
|
||
// amountOutMin = expectedOut × (10000 - slippageBps) / 10000
|
||
const amountOutMin = expectedOut.mul(10000 - slippageBps).div(10000);
|
||
|
||
const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes
|
||
const feeFields: Partial<ethers.providers.TransactionRequest> = {
|
||
type: 2,
|
||
maxFeePerGas,
|
||
maxPriorityFeePerGas,
|
||
};
|
||
|
||
let approveTxid: string | undefined;
|
||
let nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||
|
||
// ── Token-to-anything: check allowance, approve if needed, wait 1 conf ──
|
||
if (fromUpper !== 'BNB') {
|
||
const tokenAddress = BSC_TOKEN_MAP[fromUpper];
|
||
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
|
||
const currentAllowance: ethers.BigNumber = await withTimeout(
|
||
tokenContract.allowance(wallet.address, PANCAKE_ROUTER),
|
||
HTTP_TIMEOUT_MS,
|
||
'Allowance check timed out',
|
||
);
|
||
if (currentAllowance.lt(ethers.BigNumber.from(p.amount))) {
|
||
const approveData = tokenContract.interface.encodeFunctionData('approve', [PANCAKE_ROUTER, p.amount]);
|
||
const approveTx: ethers.providers.TransactionRequest = {
|
||
to: tokenAddress,
|
||
data: approveData,
|
||
value: 0,
|
||
chainId: BSC_CHAIN_ID,
|
||
nonce,
|
||
gasLimit: ethers.BigNumber.from(80_000), // approve consistently fits в 60-80k
|
||
...feeFields,
|
||
};
|
||
const approveSent = await withTimeout(
|
||
signer.sendTransaction(approveTx),
|
||
HTTP_TIMEOUT_MS,
|
||
'approve broadcast timed out',
|
||
);
|
||
approveTxid = approveSent.hash;
|
||
// Wait 1 confirmation (~3s on BSC) before swap — иначе swap revert'нет с "TransferHelper: TRANSFER_FROM_FAILED"
|
||
await withTimeout(approveSent.wait(1), 30_000, 'approve confirmation timed out');
|
||
nonce += 1;
|
||
}
|
||
}
|
||
|
||
// ── Build swap tx ──
|
||
let swapData: string;
|
||
let value: ethers.BigNumber;
|
||
if (fromUpper === 'BNB') {
|
||
swapData = routerContract.interface.encodeFunctionData(
|
||
'swapExactETHForTokensSupportingFeeOnTransferTokens',
|
||
[amountOutMin, path, wallet.address, deadline],
|
||
);
|
||
value = ethers.BigNumber.from(p.amount);
|
||
} else if (toUpper === 'BNB') {
|
||
swapData = routerContract.interface.encodeFunctionData(
|
||
'swapExactTokensForETHSupportingFeeOnTransferTokens',
|
||
[p.amount, amountOutMin, path, wallet.address, deadline],
|
||
);
|
||
value = ethers.BigNumber.from(0);
|
||
} else {
|
||
// Token-to-token (e.g., USDT → DOGE)
|
||
swapData = routerContract.interface.encodeFunctionData(
|
||
'swapExactTokensForTokensSupportingFeeOnTransferTokens',
|
||
[p.amount, amountOutMin, path, wallet.address, deadline],
|
||
);
|
||
value = ethers.BigNumber.from(0);
|
||
}
|
||
|
||
// estGas через provider.estimateGas + 20% safety
|
||
let estGas: ethers.BigNumber;
|
||
try {
|
||
const estimated = await provider.estimateGas({
|
||
from: wallet.address,
|
||
to: PANCAKE_ROUTER,
|
||
data: swapData,
|
||
value,
|
||
});
|
||
estGas = estimated.mul(120).div(100);
|
||
const minGas = ethers.BigNumber.from(150_000);
|
||
const maxGas = ethers.BigNumber.from(500_000);
|
||
if (estGas.lt(minGas)) estGas = minGas;
|
||
if (estGas.gt(maxGas)) estGas = maxGas;
|
||
} catch {
|
||
estGas = ethers.BigNumber.from(250_000);
|
||
}
|
||
|
||
const swapTx: ethers.providers.TransactionRequest = {
|
||
to: PANCAKE_ROUTER,
|
||
data: swapData,
|
||
value,
|
||
chainId: BSC_CHAIN_ID,
|
||
nonce,
|
||
gasLimit: estGas,
|
||
...feeFields,
|
||
};
|
||
const swapSent = await withTimeout(
|
||
signer.sendTransaction(swapTx),
|
||
HTTP_TIMEOUT_MS,
|
||
'swap broadcast timed out',
|
||
);
|
||
return { approveTxid, swapTxid: swapSent.hash };
|
||
}
|
||
|
||
// ─── TRX SunSwap ─────────────────────────────────────────────────────
|
||
|
||
const TRONGRID = 'https://api.trongrid.io';
|
||
|
||
// Constants — те же что в tron-swap-proxy.routes.ts (single source of truth для prod адресов).
|
||
const SUNSWAP_SMART_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax';
|
||
const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||
const WTRX_CONTRACT = 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR';
|
||
const FEE_SWAP_ROUTER_TRX = 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E'; // 0.7% fee router
|
||
const FEE_BPS = 70n;
|
||
const BPS_DENOMINATOR = 10_000n;
|
||
|
||
// Minimal TRX swap для TRX↔USDT (other tokens — добавить через registry)
|
||
const TRX_SWAP_TOKEN_MAP: Record<string, { address: string; decimals: number; isNative: boolean }> = {
|
||
TRX: { address: 'TRX', decimals: 6, isNative: true },
|
||
USDT: { address: USDT_CONTRACT, decimals: 6, isNative: false },
|
||
};
|
||
|
||
const TRX_BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||
|
||
// Method selectors (keccak256 first 4 bytes). Verified via `keccak256(toUtf8Bytes(sig)).slice(2,10)`.
|
||
// approve(address,uint256) → 095ea7b3
|
||
// swapExactETHForTokensSupportingFeeOnTransferTokens(uint256,address[],...,...) → b6f9de95
|
||
// swapExactTokensForETHSupportingFeeOnTransferTokens(uint256,uint256,...,...) → 791ac947 (NOT 18cbafe5 — that's no-fee variant)
|
||
// swapNativeWithFee(bytes) → 152dad1d
|
||
// swapTokenWithFee(address,uint256,bytes) → e8d1f203
|
||
//
|
||
// NOTE: legacy proxy route использовал 18cbafe5 = swapExactTokensForETH (без supporting-fee).
|
||
// FeeSwapRouter работает с supporting-fee variant — это уже доказано в production traffic.
|
||
const SEL_APPROVE = '095ea7b3';
|
||
const SEL_SWAP_EXACT_ETH_FOR_TOKENS = 'b6f9de95';
|
||
const SEL_SWAP_EXACT_TOKENS_FOR_ETH = '18cbafe5';
|
||
const SEL_SWAP_NATIVE_WITH_FEE = '152dad1d';
|
||
const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203';
|
||
|
||
export interface SwapTrxParams {
|
||
mnemonic: string;
|
||
expectedFromAddress: string;
|
||
from: string;
|
||
to: string;
|
||
amount: string;
|
||
slippageBps?: number;
|
||
}
|
||
|
||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||
const controller = new AbortController();
|
||
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
||
try {
|
||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||
if (!res.ok) {
|
||
const body = await res.text().catch(() => '');
|
||
throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`);
|
||
}
|
||
return await res.json();
|
||
} finally {
|
||
clearTimeout(t);
|
||
}
|
||
}
|
||
|
||
// ─── TRX encoding helpers (порт из tron-swap-proxy.routes.ts) ───
|
||
|
||
function trxAddrToHex(address: string): string {
|
||
let num = 0n;
|
||
for (const ch of address) {
|
||
const i = TRX_BASE58_ALPHABET.indexOf(ch);
|
||
if (i === -1) throw new Error('Invalid base58 character');
|
||
num = num * 58n + BigInt(i);
|
||
}
|
||
const hex = num.toString(16).padStart(50, '0');
|
||
return hex.slice(2, 42); // skip 0x41, take 20 bytes
|
||
}
|
||
|
||
function encU256(value: bigint): string {
|
||
return value.toString(16).padStart(64, '0');
|
||
}
|
||
|
||
function encAddr(address: string): string {
|
||
return trxAddrToHex(address).padStart(64, '0');
|
||
}
|
||
|
||
function encDynamicBytes(hexData: string): string {
|
||
const data = hexData.startsWith('0x') ? hexData.slice(2) : hexData;
|
||
const byteLength = data.length / 2;
|
||
const lengthEncoded = encU256(BigInt(byteLength));
|
||
const paddedData = data.padEnd(Math.ceil(data.length / 64) * 64, '0');
|
||
return lengthEncoded + paddedData;
|
||
}
|
||
|
||
// SunSwap V2 router calldata:
|
||
// function swapExactETHForTokens(uint256 amountOutMin, address[] path, address to, uint256 deadline)
|
||
function buildSwapExactETHForTokensCalldata(
|
||
amountOutMin: bigint,
|
||
path: string[],
|
||
to: string,
|
||
deadline: bigint,
|
||
): string {
|
||
const offsetToPath = encU256(128n); // 4 × 32 bytes
|
||
const pathLen = encU256(BigInt(path.length));
|
||
const pathElements = path.map(encAddr).join('');
|
||
return SEL_SWAP_EXACT_ETH_FOR_TOKENS + encU256(amountOutMin) + offsetToPath +
|
||
encAddr(to) + encU256(deadline) + pathLen + pathElements;
|
||
}
|
||
|
||
// function swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline)
|
||
function buildSwapExactTokensForETHCalldata(
|
||
amountIn: bigint,
|
||
amountOutMin: bigint,
|
||
path: string[],
|
||
to: string,
|
||
deadline: bigint,
|
||
): string {
|
||
const offsetToPath = encU256(160n); // 5 × 32 bytes
|
||
const pathLen = encU256(BigInt(path.length));
|
||
const pathElements = path.map(encAddr).join('');
|
||
return SEL_SWAP_EXACT_TOKENS_FOR_ETH + encU256(amountIn) + encU256(amountOutMin) +
|
||
offsetToPath + encAddr(to) + encU256(deadline) + pathLen + pathElements;
|
||
}
|
||
|
||
interface BuiltTrxTx {
|
||
txID: string;
|
||
raw_data: any;
|
||
raw_data_hex: string;
|
||
}
|
||
|
||
interface BuildTriggerParams {
|
||
ownerAddress: string;
|
||
contractAddress: string;
|
||
functionSelector: string;
|
||
parameter: string;
|
||
callValue: number;
|
||
feeLimit: number;
|
||
headers: Record<string, string>;
|
||
}
|
||
|
||
async function buildTrigger(p: BuildTriggerParams): Promise<BuiltTrxTx> {
|
||
const body = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
|
||
method: 'POST',
|
||
headers: p.headers,
|
||
body: JSON.stringify({
|
||
owner_address: p.ownerAddress,
|
||
contract_address: p.contractAddress,
|
||
function_selector: p.functionSelector,
|
||
parameter: p.parameter,
|
||
call_value: p.callValue,
|
||
fee_limit: p.feeLimit,
|
||
visible: true,
|
||
}),
|
||
});
|
||
if (!body?.result?.result || !body.transaction) {
|
||
const msg = body?.result?.message
|
||
? Buffer.from(body.result.message, 'hex').toString('utf8')
|
||
: 'TronGrid triggersmartcontract returned no transaction';
|
||
throw new Error(`TRX build failed: ${msg.slice(0, 200)}`);
|
||
}
|
||
const tx = body.transaction as BuiltTrxTx;
|
||
if (!tx.txID || !tx.raw_data || !tx.raw_data_hex) {
|
||
throw new Error('TRX build response missing txID / raw_data / raw_data_hex');
|
||
}
|
||
return tx;
|
||
}
|
||
|
||
async function checkAllowance(
|
||
owner: string,
|
||
tokenContract: string,
|
||
spender: string,
|
||
headers: Record<string, string>,
|
||
): Promise<bigint> {
|
||
const parameter = encAddr(owner) + encAddr(spender);
|
||
const body = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify({
|
||
owner_address: owner,
|
||
contract_address: tokenContract,
|
||
function_selector: 'allowance(address,address)',
|
||
parameter,
|
||
visible: true,
|
||
}),
|
||
});
|
||
const hex = body?.constant_result?.[0];
|
||
if (!hex || /^0+$/.test(hex)) return 0n;
|
||
return BigInt('0x' + hex);
|
||
}
|
||
|
||
async function getAmountsOut(
|
||
amountIn: bigint,
|
||
path: string[],
|
||
headers: Record<string, string>,
|
||
): Promise<bigint> {
|
||
const amountHex = encU256(amountIn);
|
||
const offsetHex = encU256(64n);
|
||
const lengthHex = encU256(BigInt(path.length));
|
||
const pathHex = path.map(encAddr).join('');
|
||
const parameter = amountHex + offsetHex + lengthHex + pathHex;
|
||
|
||
const body = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify({
|
||
owner_address: SUNSWAP_SMART_ROUTER,
|
||
contract_address: SUNSWAP_SMART_ROUTER,
|
||
function_selector: 'getAmountsOut(uint256,address[])',
|
||
parameter,
|
||
visible: true,
|
||
}),
|
||
});
|
||
|
||
const hex = body?.constant_result?.[0];
|
||
if (!hex) {
|
||
const msg = body?.result?.message
|
||
? Buffer.from(body.result.message, 'hex').toString('utf8')
|
||
: 'getAmountsOut returned no result';
|
||
throw new Error(`TRX quote failed: ${msg.slice(0, 200)}`);
|
||
}
|
||
// Last 32 bytes hex of result = amounts[1] (output amount).
|
||
const amountOutHex = hex.slice(-64);
|
||
return BigInt('0x' + amountOutHex);
|
||
}
|
||
|
||
/**
|
||
* MITM-verify built TRX tx перед подписью (4-layer защита от компрометированного TronGrid).
|
||
* Селектор `expectedSelector` (8 hex chars) ловит подмену метода на 5-м уровне.
|
||
*/
|
||
function verifyTrxTx(opts: {
|
||
tx: BuiltTrxTx;
|
||
expectedOwner: string;
|
||
expectedContract: string;
|
||
expectedSelector: string; // 8 hex chars, lowercase
|
||
expectedCallValue?: number;
|
||
}): void {
|
||
// 1. txID = SHA256(raw_data_hex)
|
||
const expectedTxId = createHash('sha256')
|
||
.update(Buffer.from(opts.tx.raw_data_hex, 'hex'))
|
||
.digest('hex');
|
||
if (expectedTxId !== opts.tx.txID) {
|
||
throw new Error('TRX txID mismatch — possible MITM/compromised RPC');
|
||
}
|
||
// 2. expiration bounds (TRON default ~60s; cap 90s)
|
||
const nowMs = Date.now();
|
||
const expiration = Number(opts.tx.raw_data.expiration);
|
||
const timestamp = Number(opts.tx.raw_data.timestamp);
|
||
if (!Number.isFinite(expiration) || !Number.isFinite(timestamp)) {
|
||
throw new Error('TRX tx malformed (no expiration/timestamp)');
|
||
}
|
||
if (expiration - nowMs > 90_000 || expiration <= nowMs) {
|
||
throw new Error(`TRX expiration out of bounds: ${expiration - nowMs}ms`);
|
||
}
|
||
if (Math.abs(timestamp - nowMs) > 30_000) {
|
||
throw new Error(`TRX timestamp drift too large: ${timestamp - nowMs}ms`);
|
||
}
|
||
// 3. contract[0].type === 'TriggerSmartContract'
|
||
const c0 = opts.tx.raw_data.contract?.[0];
|
||
if (!c0) throw new Error('TRX tx malformed (no contract[0])');
|
||
if (c0.type !== 'TriggerSmartContract') {
|
||
throw new Error(`TRX contract type mismatch: expected TriggerSmartContract, got ${c0.type}`);
|
||
}
|
||
// 4. owner / contract / selector / call_value
|
||
const v = c0.parameter?.value;
|
||
if (!v) throw new Error('TRX tx malformed (no contract value)');
|
||
if (v.owner_address !== opts.expectedOwner) {
|
||
throw new Error(`TRX owner_address mismatch: expected ${opts.expectedOwner}, got ${v.owner_address}`);
|
||
}
|
||
if (v.contract_address !== opts.expectedContract) {
|
||
throw new Error(`TRX contract mismatch: expected ${opts.expectedContract}, got ${v.contract_address}`);
|
||
}
|
||
const data = String(v.data || '').toLowerCase();
|
||
if (data.slice(0, 8) !== opts.expectedSelector.toLowerCase()) {
|
||
throw new Error(
|
||
`TRX selector mismatch: expected ${opts.expectedSelector}, got ${data.slice(0, 8)}`,
|
||
);
|
||
}
|
||
if (opts.expectedCallValue !== undefined) {
|
||
const actual = Number(v.call_value ?? 0);
|
||
if (actual !== opts.expectedCallValue) {
|
||
throw new Error(`TRX call_value mismatch: expected ${opts.expectedCallValue}, got ${actual}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Sign verified tx + broadcast. Returns txid. */
|
||
async function signAndBroadcastTrx(
|
||
tx: BuiltTrxTx,
|
||
wallet: ethers.Wallet,
|
||
headers: Record<string, string>,
|
||
): Promise<string> {
|
||
const sk = new ethers.utils.SigningKey(wallet.privateKey);
|
||
const sig = sk.signDigest('0x' + tx.txID);
|
||
if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) {
|
||
throw new Error(`TRX signing: invalid recoveryParam ${sig.recoveryParam}`);
|
||
}
|
||
const sigHex =
|
||
sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0');
|
||
|
||
const clean = {
|
||
txID: tx.txID,
|
||
raw_data: tx.raw_data,
|
||
raw_data_hex: tx.raw_data_hex,
|
||
signature: [sigHex],
|
||
visible: true,
|
||
};
|
||
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(clean),
|
||
});
|
||
if (!broadcast?.result) {
|
||
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
|
||
const code = broadcast?.code || 'NO_CODE';
|
||
throw new Error(`TRX broadcast failed [${code}]: ${msg.slice(0, 200)}`);
|
||
}
|
||
return tx.txID;
|
||
}
|
||
|
||
/** Poll gettransactionbyid until included / failed / timeout (max 30s, every 1.5s). */
|
||
async function waitTrxInclusion(
|
||
txid: string,
|
||
headers: Record<string, string>,
|
||
): Promise<void> {
|
||
const deadline = Date.now() + 30_000;
|
||
while (Date.now() < deadline) {
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
try {
|
||
const info = await fetchJson(`${TRONGRID}/wallet/gettransactioninfobyid`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify({ value: txid }),
|
||
});
|
||
// Если info.id присутствует — tx уже в блоке.
|
||
if (info?.id) {
|
||
const result = info.receipt?.result;
|
||
if (result && result !== 'SUCCESS') {
|
||
throw new Error(`TRX approve tx reverted: ${result}`);
|
||
}
|
||
return;
|
||
}
|
||
} catch (err: any) {
|
||
// Сетевой блип — продолжаем polling.
|
||
if (Date.now() >= deadline) throw err;
|
||
}
|
||
}
|
||
throw new Error(`TRX tx ${txid.slice(0, 12)}... inclusion timed out after 30s`);
|
||
}
|
||
|
||
/**
|
||
* TRX swap через SunSwap V2 + FeeSwapRouter (0.7% fee).
|
||
* Поддерживает только TRX↔USDT (same scope как legacy /tron/swap/build).
|
||
*
|
||
* TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens.
|
||
* USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps swapExactTokensForETH.
|
||
*
|
||
* Slippage: server вычисляет `amountOutMin = quote × (10000-slippageBps) / 10000`
|
||
* (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin (защита от MEV-sandwich).
|
||
*
|
||
* Возвращает `{ approveTxid?, swapTxid }` для совместимости с swapBsc.
|
||
*/
|
||
export async function swapTrx(
|
||
p: SwapTrxParams,
|
||
): Promise<{ approveTxid?: string; swapTxid: string }> {
|
||
const fromU = p.from.toUpperCase();
|
||
const toU = p.to.toUpperCase();
|
||
const fromInfo = TRX_SWAP_TOKEN_MAP[fromU];
|
||
const toInfo = TRX_SWAP_TOKEN_MAP[toU];
|
||
if (!fromInfo || !toInfo || fromU === toU) {
|
||
throw new Error(`TRX swap supports only TRX↔USDT pairs (got ${p.from} → ${p.to})`);
|
||
}
|
||
|
||
const amount = BigInt(p.amount);
|
||
if (amount <= 0n) throw new Error('TRX swap: amount must be positive');
|
||
|
||
const slippageBps = BigInt(
|
||
p.slippageBps !== undefined && Number.isFinite(p.slippageBps) ? p.slippageBps : 50,
|
||
);
|
||
if (slippageBps < 1n || slippageBps > 1000n) {
|
||
throw new Error('TRX swap: slippageBps must be between 1 and 1000');
|
||
}
|
||
|
||
// Derive TRX address.
|
||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
|
||
const fromTronAddr = ethAddressToTron(wallet.address);
|
||
if (fromTronAddr !== p.expectedFromAddress) {
|
||
throw new Error(`TRX address mismatch: derived ${fromTronAddr} ≠ DB ${p.expectedFromAddress}`);
|
||
}
|
||
|
||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||
|
||
// Compute fee + swap split (FeeSwapRouter забирает 0.7%, остальные 99.3% уходят в SunSwap).
|
||
const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR;
|
||
const swapAmount = amount - feeAmount;
|
||
|
||
// Quote (на 99.3%, т.к. это то что SunSwap реально получит).
|
||
const isTrxToUsdt = fromU === 'TRX';
|
||
const path = isTrxToUsdt
|
||
? [WTRX_CONTRACT, USDT_CONTRACT]
|
||
: [USDT_CONTRACT, WTRX_CONTRACT];
|
||
const quote = await getAmountsOut(swapAmount, path, headers);
|
||
const amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR;
|
||
if (amountOutMin <= 0n) {
|
||
throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`);
|
||
}
|
||
|
||
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 минут
|
||
|
||
// ─── TRX → USDT ───
|
||
if (isTrxToUsdt) {
|
||
const sunswapCalldata = buildSwapExactETHForTokensCalldata(
|
||
amountOutMin, path, fromTronAddr, deadline,
|
||
);
|
||
const offsetToBytes = encU256(32n);
|
||
const feeRouterParam = offsetToBytes + encDynamicBytes(sunswapCalldata);
|
||
|
||
// Number(amount) safe здесь т.к. TRX bounded по precision-check в sendTrx (≤ MAX_SAFE_INT sun).
|
||
const amountNum = Number(amount);
|
||
if (amountNum > Number.MAX_SAFE_INTEGER) {
|
||
throw new Error('TRX swap amount exceeds Number.MAX_SAFE_INTEGER (9B TRX)');
|
||
}
|
||
|
||
const swapTx = await buildTrigger({
|
||
ownerAddress: fromTronAddr,
|
||
contractAddress: FEE_SWAP_ROUTER_TRX,
|
||
functionSelector: 'swapNativeWithFee(bytes)',
|
||
parameter: feeRouterParam,
|
||
callValue: amountNum,
|
||
feeLimit: 200_000_000, // 200 TRX cap
|
||
headers,
|
||
});
|
||
verifyTrxTx({
|
||
tx: swapTx,
|
||
expectedOwner: fromTronAddr,
|
||
expectedContract: FEE_SWAP_ROUTER_TRX,
|
||
expectedSelector: SEL_SWAP_NATIVE_WITH_FEE,
|
||
expectedCallValue: amountNum,
|
||
});
|
||
const swapTxid = await signAndBroadcastTrx(swapTx, wallet, headers);
|
||
return { swapTxid };
|
||
}
|
||
|
||
// ─── USDT → TRX ───
|
||
// Step 1: check allowance, approve infinite if needed.
|
||
let approveTxid: string | undefined;
|
||
const allowance = await checkAllowance(fromTronAddr, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers);
|
||
if (allowance < amount) {
|
||
const INFINITE = BigInt(
|
||
'0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
|
||
);
|
||
const approveParam = encAddr(FEE_SWAP_ROUTER_TRX) + encU256(INFINITE);
|
||
const approveTx = await buildTrigger({
|
||
ownerAddress: fromTronAddr,
|
||
contractAddress: USDT_CONTRACT,
|
||
functionSelector: 'approve(address,uint256)',
|
||
parameter: approveParam,
|
||
callValue: 0,
|
||
feeLimit: 100_000_000, // 100 TRX cap
|
||
headers,
|
||
});
|
||
verifyTrxTx({
|
||
tx: approveTx,
|
||
expectedOwner: fromTronAddr,
|
||
expectedContract: USDT_CONTRACT,
|
||
expectedSelector: SEL_APPROVE,
|
||
expectedCallValue: 0,
|
||
});
|
||
approveTxid = await signAndBroadcastTrx(approveTx, wallet, headers);
|
||
// Ждём inclusion approve, иначе swap revert'нёт "transfer amount exceeds allowance".
|
||
await waitTrxInclusion(approveTxid, headers);
|
||
}
|
||
|
||
// Step 2: build swapTokenWithFee(USDT, amount, calldata).
|
||
const sunswapCalldata = buildSwapExactTokensForETHCalldata(
|
||
swapAmount, amountOutMin, path, fromTronAddr, deadline,
|
||
);
|
||
const tokenInEnc = encAddr(USDT_CONTRACT);
|
||
const amountInEnc = encU256(amount);
|
||
const offsetToBytes = encU256(96n); // 3 × 32 bytes
|
||
const feeRouterParam = tokenInEnc + amountInEnc + offsetToBytes + encDynamicBytes(sunswapCalldata);
|
||
|
||
const swapTx = await buildTrigger({
|
||
ownerAddress: fromTronAddr,
|
||
contractAddress: FEE_SWAP_ROUTER_TRX,
|
||
functionSelector: 'swapTokenWithFee(address,uint256,bytes)',
|
||
parameter: feeRouterParam,
|
||
callValue: 0,
|
||
feeLimit: 200_000_000,
|
||
headers,
|
||
});
|
||
verifyTrxTx({
|
||
tx: swapTx,
|
||
expectedOwner: fromTronAddr,
|
||
expectedContract: FEE_SWAP_ROUTER_TRX,
|
||
expectedSelector: SEL_SWAP_TOKEN_WITH_FEE,
|
||
expectedCallValue: 0,
|
||
});
|
||
const swapTxid = await signAndBroadcastTrx(swapTx, wallet, headers);
|
||
return { approveTxid, swapTxid };
|
||
}
|
||
|
||
// ─── SOL Jupiter ─────────────────────────────────────────────────────
|
||
|
||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||
// Jupiter migrated в 2025: старый `quote-api.jup.ag/v6` deprecated и DNS удалён.
|
||
// `lite-api.jup.ag/swap/v1` — public anonymous endpoint (~600 req/min), JSON-schema идентична.
|
||
const JUPITER_API = 'https://lite-api.jup.ag/swap/v1';
|
||
|
||
let _solConnection: Connection | null = null;
|
||
function getSolConnection(): Connection {
|
||
if (!_solConnection) {
|
||
_solConnection = new Connection(SOL_RPC, 'confirmed');
|
||
}
|
||
return _solConnection;
|
||
}
|
||
|
||
export interface SwapSolParams {
|
||
mnemonic: string;
|
||
expectedFromAddress: string;
|
||
inputMint: string;
|
||
outputMint: string;
|
||
amount: string;
|
||
slippageBps?: number;
|
||
}
|
||
|
||
/**
|
||
* SOL Jupiter chained swap. Получаем quote от Jupiter, build serialized tx, sign keypair'ом, broadcast.
|
||
*/
|
||
export async function swapSol(p: SwapSolParams): Promise<{ signature: string }> {
|
||
const seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
||
if (!key || key.length !== 32) {
|
||
throw new Error('SOL derivation produced invalid seed length');
|
||
}
|
||
const keypair = Keypair.fromSeed(key);
|
||
if (keypair.publicKey.toBase58() !== p.expectedFromAddress) {
|
||
throw new Error(`SOL address mismatch: derived ${keypair.publicKey.toBase58()} ≠ DB ${p.expectedFromAddress}`);
|
||
}
|
||
|
||
const slippageBps = p.slippageBps ?? 50;
|
||
if (slippageBps < 1 || slippageBps > 1000) {
|
||
throw new Error('slippageBps must be 1-1000');
|
||
}
|
||
|
||
// 1. Jupiter quote
|
||
const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`;
|
||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
|
||
const quoteRes = await fetchJson(quoteUrl, { headers });
|
||
|
||
// 2. Jupiter swap (build serialized tx)
|
||
const swapBody: Record<string, unknown> = {
|
||
quoteResponse: quoteRes,
|
||
userPublicKey: keypair.publicKey.toBase58(),
|
||
wrapAndUnwrapSol: true,
|
||
dynamicComputeUnitLimit: true,
|
||
prioritizationFeeLamports: 'auto',
|
||
};
|
||
if (env.jupiterReferralAccount) swapBody.feeAccount = env.jupiterReferralAccount;
|
||
|
||
const swapRes = await fetchJson(`${JUPITER_API}/swap`, {
|
||
method: 'POST',
|
||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(swapBody),
|
||
});
|
||
|
||
const txBase64 = swapRes.swapTransaction;
|
||
if (!txBase64 || typeof txBase64 !== 'string') {
|
||
throw new Error('Jupiter swap returned no swapTransaction');
|
||
}
|
||
|
||
// 3. Deserialize → sign → broadcast
|
||
const txBytes = Buffer.from(txBase64, 'base64');
|
||
const tx = VersionedTransaction.deserialize(txBytes);
|
||
|
||
// Verify fee-payer === our pubkey
|
||
const feePayer = tx.message.staticAccountKeys[0]?.toBase58();
|
||
if (feePayer !== keypair.publicKey.toBase58()) {
|
||
throw new Error(`Jupiter built tx with wrong feePayer ${feePayer} (expected ${keypair.publicKey.toBase58()})`);
|
||
}
|
||
|
||
tx.sign([keypair]);
|
||
|
||
const conn = getSolConnection();
|
||
const sig = await conn.sendRawTransaction(tx.serialize());
|
||
|
||
try {
|
||
const latestBlock = await conn.getLatestBlockhash();
|
||
await conn.confirmTransaction({
|
||
signature: sig,
|
||
blockhash: latestBlock.blockhash,
|
||
lastValidBlockHeight: latestBlock.lastValidBlockHeight,
|
||
}, 'confirmed');
|
||
} catch (err: any) {
|
||
const name = err?.name || '';
|
||
if (name === 'TransactionExpiredBlockheightExceededError') {
|
||
throw new Error(`SOL Jupiter swap EXPIRED. sig=${sig}`);
|
||
}
|
||
logger.warn(`SOL Jupiter swap confirm warning (${name}): ${err.message}. sig=${sig}`);
|
||
}
|
||
|
||
return { signature: sig };
|
||
}
|