swaggerready

This commit is contained in:
ZOMBIIIIIII
2026-05-14 01:11:20 +03:00
parent 0661fffb88
commit 53635806d6
9 changed files with 1139 additions and 56 deletions

View File

@@ -250,14 +250,38 @@ export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string;
// ─── TRX SunSwap ─────────────────────────────────────────────────────
const TRONGRID = 'https://api.trongrid.io';
const SUNSWAP_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax'; // SunSwap V2 Router
// 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: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, isNative: false },
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;
@@ -282,17 +306,324 @@ async function fetchJson(url: string, init?: RequestInit): Promise<any> {
}
}
// ─── 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);
}
/**
* TRX swap через SunSwap. Для упрощения — пока TRX↔USDT only (как в существующем proxy route).
* Расширить через token-registry если потребуется ETH/USDC support.
* MITM-verify built TRX tx перед подписью (4-layer защита от компрометированного TronGrid).
* Селектор `expectedSelector` (8 hex chars) ловит подмену метода на 5-м уровне.
*/
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) {
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) {
@@ -302,10 +633,114 @@ export async function swapTrx(p: SwapTrxParams): Promise<{ txid: string }> {
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.');
// 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 ─────────────────────────────────────────────────────