Files
cryptowallet/apps/api/src/services/swap-orchestrator.service.ts
ZOMBIIIIIII e88ee3a55f swagger2
2026-05-14 14:41:03 +03:00

848 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}