init383838
This commit is contained in:
410
apps/api/src/services/swap-orchestrator.service.ts
Normal file
410
apps/api/src/services/swap-orchestrator.service.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* 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';
|
||||
const SUNSWAP_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax'; // SunSwap V2 Router
|
||||
|
||||
// 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: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, isNative: false },
|
||||
};
|
||||
|
||||
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 swap через SunSwap. Для упрощения — пока TRX↔USDT only (как в существующем proxy route).
|
||||
* Расширить через token-registry если потребуется ETH/USDC support.
|
||||
*/
|
||||
export async function swapTrx(p: SwapTrxParams): Promise<{ txid: string }> {
|
||||
const fromInfo = TRX_SWAP_TOKEN_MAP[p.from.toUpperCase()];
|
||||
const toInfo = TRX_SWAP_TOKEN_MAP[p.to.toUpperCase()];
|
||||
if (!fromInfo || !toInfo || p.from === p.to) {
|
||||
throw new Error(`TRX swap supports only TRX↔USDT pairs (got ${p.from} → ${p.to})`);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Build SunSwap unsigned tx через triggersmartcontract
|
||||
// (Полная implementation SunSwap calldata builder — большой кусок; для prod — call existing
|
||||
// /tron/swap/build endpoint logic. Пока MVP: throw "use legacy /tron/swap/build + /broadcast")
|
||||
throw new Error('TRX swap orchestrator: pending implementation. Use legacy /tron/swap/build + custodial broadcast.');
|
||||
}
|
||||
|
||||
// ─── SOL Jupiter ─────────────────────────────────────────────────────
|
||||
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
const JUPITER_API = 'https://quote-api.jup.ag/v6';
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user