/** * 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 = { 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 { 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(p: Promise, ms: number, msg: string): Promise { return Promise.race([ p, new Promise((_, 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 = { 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 = { 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 { 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; } async function buildTrigger(p: BuildTriggerParams): Promise { 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, ): Promise { 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, ): Promise { 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, ): Promise { 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, ): Promise { 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 = { '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 = { 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 = { 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 }; }