init
This commit is contained in:
903
apps/api/src/services/bridge-execute.service.ts
Normal file
903
apps/api/src/services/bridge-execute.service.ts
Normal file
@@ -0,0 +1,903 @@
|
||||
/**
|
||||
* Bridge Execute — one-click "Подтвердить" для bridge'ей через Jumper (LiFi) и Relay.
|
||||
*
|
||||
* Pipeline (per-call):
|
||||
* 1. Re-fetch fresh quote из upstream (anti-stale) — provider = 'jumper' | 'relay'
|
||||
* 2. JWT-bind fromAddress ≡ user's wallet (через WalletModel)
|
||||
* 3. Anti-MEV guard: estimate.toAmountMin ≥ acceptedMinOut (передан клиентом из quote-preview)
|
||||
* 4. Dispatch по source chainId:
|
||||
* ETH / BSC → executeEvm (approve allowance? + BSC 0.7% fee? + bridge tx)
|
||||
* SOL → executeSol (sign+broadcast base64 VersionedTransaction)
|
||||
* TRX → executeTron (TRC20 approve? + bridge tx) — NEW path
|
||||
* BTC → executeBtc (UTXO build P2WPKH PSBT deposit) — NEW path
|
||||
* 5. Return { approveTxid?, feeTxid?, bridgeTxid, trackerUrl, provider, toolName, ... }
|
||||
*
|
||||
* Security invariants:
|
||||
* - mnemonic decrypt'ится только после успешной валидации quote + bind
|
||||
* - Approve amount = exact (не unlimited)
|
||||
* - BSC fee 0.7% применяется ВСЕГДА для BSC ERC20 from-token (off-chain double-tx)
|
||||
* - Все upstream HTTP calls идут через `proxiedFetch` (cloud IPs rate-limited у LiFi/Relay)
|
||||
*/
|
||||
|
||||
import { logger } from '../lib/logger';
|
||||
import { proxiedFetch } from '../lib/outbound-proxy';
|
||||
import {
|
||||
signAndBroadcast,
|
||||
signAndBroadcastRawEvm,
|
||||
signAndBroadcastBscFeeTx,
|
||||
signAndBroadcastEvmFeeTx,
|
||||
signAndBroadcastSolanaTx,
|
||||
} from './wallet-signer.service';
|
||||
import { computeAppFee, getAppFeeWallet, APP_FEE_WALLET_SOL, APP_FEE_WALLET_TRX } from '../lib/app-fee';
|
||||
import { SOL_TOKENS, TRX_TOKENS } from '../lib/token-registry';
|
||||
|
||||
/**
|
||||
* Resolve SPL mint address → token symbol (для signAndBroadcast SOL token transfer).
|
||||
* Returns null если mint не в registry — caller treats as native.
|
||||
*/
|
||||
function _splMintToSymbol(mint: string): string | null {
|
||||
const t = SOL_TOKENS.find((x) => x.mint === mint);
|
||||
return t?.symbol ?? null;
|
||||
}
|
||||
|
||||
/** Resolve TRC20 contract → token symbol. Returns null если не в registry. */
|
||||
function _trc20ContractToSymbol(contract: string): string | null {
|
||||
const t = TRX_TOKENS.find((x) => x.contractAddress === contract);
|
||||
return t?.symbol ?? null;
|
||||
}
|
||||
import {
|
||||
fetchNearIntentsQuote,
|
||||
submitNearIntentsDeposit,
|
||||
resolveAsset,
|
||||
assertValidDepositAddress,
|
||||
nearIntentsTrackerUrl,
|
||||
} from '../lib/nearintents-client';
|
||||
import { ethers } from 'ethers';
|
||||
import { pickProxiedEvmProvider } from '../lib/outbound-proxy';
|
||||
import {
|
||||
signAndBroadcastEvmApprove,
|
||||
readErc20Allowance,
|
||||
readEvmNativeBalance,
|
||||
readErc20Balance,
|
||||
readSolBalance,
|
||||
readSplTokenBalance,
|
||||
signAndBroadcastRawTron,
|
||||
signAndBroadcastTronPrebuiltTx,
|
||||
signAndBroadcastTrc20Approve,
|
||||
readTrc20Allowance,
|
||||
readTrxBalance,
|
||||
readTrc20Balance,
|
||||
signAndBroadcastBtcDeposit,
|
||||
readBtcConfirmedBalance,
|
||||
InsufficientBalanceError,
|
||||
BridgeSimulationError,
|
||||
} from './wallet-signer-bridge';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
// EVM RPC endpoints (для pre-sim eth_call). Дублирует константы из wallet-signer.service.ts
|
||||
// чтобы избежать circular dep.
|
||||
const ETH_RPCS_FOR_SIM = [
|
||||
'https://ethereum-rpc.publicnode.com',
|
||||
'https://eth.llamarpc.com',
|
||||
'https://rpc.ankr.com/eth',
|
||||
];
|
||||
const BSC_RPCS_FOR_SIM = [
|
||||
'https://bsc-dataseed.binance.org',
|
||||
'https://bsc-dataseed1.binance.org',
|
||||
'https://bsc-dataseed2.binance.org',
|
||||
'https://bsc.publicnode.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-simulate EVM tx через eth_call. Если revert — throw BridgeSimulationError (caller
|
||||
* передаст к HTTP 400 без broadcast'а). Если RPC unreachable — log warning и proceed
|
||||
* (degraded mode, не блокируем юзера на upstream outage).
|
||||
*/
|
||||
async function simulateEvmTx(
|
||||
chain: 'ETH' | 'BSC',
|
||||
from: string,
|
||||
to: string,
|
||||
data: string,
|
||||
value: bigint,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
const chainId = chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = chain === 'ETH' ? ETH_RPCS_FOR_SIM : BSC_RPCS_FOR_SIM;
|
||||
let provider: ethers.providers.StaticJsonRpcProvider;
|
||||
try {
|
||||
provider = await pickProxiedEvmProvider(rpcs, chainId);
|
||||
} catch (err: any) {
|
||||
logger.warn(`EVM ${chain} pre-sim RPC pick failed (proceed degraded): ${err?.message}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await provider.call({ from, to, data, value: ethers.BigNumber.from(value.toString()) });
|
||||
} catch (err: any) {
|
||||
// ethers wraps revert reasons в err.reason / err.data.message / err.error.body
|
||||
const reason = err?.reason || err?.data?.message || err?.error?.message || err?.message || 'unknown';
|
||||
throw new BridgeSimulationError(
|
||||
`${description} simulation reverted on ${chain}: ${String(reason).slice(0, 200)}. NOT broadcast (no gas burned). Re-quote and retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// SOL native sentinel — System Program (32 единицы)
|
||||
const SOL_NATIVE_SENTINEL = '11111111111111111111111111111111';
|
||||
// TRX native sentinel (LiFi format)
|
||||
const TRX_NATIVE_SENTINEL = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb';
|
||||
|
||||
/**
|
||||
* Минимальный gas reserve для EVM native check (юзеру нужно ещё на gas approve+bridge).
|
||||
* 0.001 ETH/BNB hopefully покрывает 3 EVM tx по 0.0003 каждая.
|
||||
*/
|
||||
const EVM_GAS_RESERVE_WEI = 1_000_000_000_000_000n; // 0.001 ETH/BNB
|
||||
// Для SOL — резерв на rent + fees (~0.001 SOL = 1_000_000 lamports).
|
||||
const SOL_FEE_RESERVE_LAMPORTS = 1_000_000n;
|
||||
// Для TRX — резерв на bandwidth/energy (~5 TRX = 5_000_000 sun).
|
||||
const TRX_FEE_RESERVE_SUN = 5_000_000n;
|
||||
|
||||
function formatAmountForHumanError(raw: bigint, decimals: number): string {
|
||||
if (decimals <= 0) return raw.toString();
|
||||
const s = raw.toString().padStart(decimals + 1, '0');
|
||||
const intPart = s.slice(0, -decimals);
|
||||
const fracPart = s.slice(-decimals).replace(/0+$/, '');
|
||||
return fracPart ? `${intPart}.${fracPart}` : intPart;
|
||||
}
|
||||
|
||||
const LIFI_API_URL = 'https://li.quest/v1';
|
||||
const RELAY_API_URL = 'https://api.relay.link';
|
||||
const UPSTREAM_TIMEOUT_MS = 20_000;
|
||||
|
||||
// Chain id → ChainCode (наш custodial chains). Source chain должен быть из этого map'а
|
||||
// для bind'инга, destination — без ограничений (bridge solver сам доставит куда угодно).
|
||||
const CHAINID_TO_CHAIN: Record<number, ChainCode> = {
|
||||
// EVM
|
||||
1: 'ETH',
|
||||
56: 'BSC',
|
||||
// Jumper (LiFi)
|
||||
1151111081099710: 'SOL',
|
||||
728126428: 'TRX',
|
||||
20000000000001: 'BTC',
|
||||
// Relay
|
||||
792703809: 'SOL',
|
||||
8253038: 'BTC',
|
||||
};
|
||||
|
||||
// EVM-native sentinels (0x0000...) — означает что from-token = native (BNB / ETH).
|
||||
const EVM_NATIVE_SENTINEL = '0x0000000000000000000000000000000000000000';
|
||||
|
||||
export type BridgeProvider = 'jumper' | 'relay' | 'nearintents';
|
||||
|
||||
export interface BridgeExecuteParams {
|
||||
provider: BridgeProvider;
|
||||
fromChain: number; // chainId (LiFi / Relay)
|
||||
toChain: number;
|
||||
fromToken: string; // contract address / native sentinel
|
||||
toToken: string;
|
||||
fromAmount: string; // smallest units, decimal string
|
||||
fromAddress: string; // user's source wallet
|
||||
toAddress: string; // user's destination wallet
|
||||
acceptedMinOut: string; // expected min toAmount, anti-MEV guard
|
||||
/** decrypted mnemonic (controller responsibility) */
|
||||
mnemonic: string;
|
||||
/** stored user wallet address для source chain (для extra bind check) */
|
||||
expectedFromAddress: string;
|
||||
/** stored user wallet address для destination chain (для extra bind check) */
|
||||
expectedToAddress: string;
|
||||
}
|
||||
|
||||
export interface BridgeExecuteResult {
|
||||
provider: BridgeProvider;
|
||||
fromChain: number;
|
||||
toChain: number;
|
||||
toolName?: string;
|
||||
/** только для EVM ERC20 / TRX TRC20 path */
|
||||
approveTxid?: string;
|
||||
/** только для BSC + ERC20 (off-chain 0.7% fee) */
|
||||
feeTxid?: string;
|
||||
feeAmount?: string;
|
||||
/** main bridge tx — самый важный, всегда присутствует */
|
||||
bridgeTxid: string;
|
||||
/** human display */
|
||||
fromAmount: string;
|
||||
toAmountMin: string;
|
||||
fromAmountUSD?: string;
|
||||
toAmountUSD?: string;
|
||||
/** опционально — внешний tracker (LiFi /scan, Relay /intents/status) */
|
||||
trackerUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Главная entry-point — controller вызывает её после валидации JWT и idempotency claim.
|
||||
*/
|
||||
export async function executeBridge(p: BridgeExecuteParams): Promise<BridgeExecuteResult> {
|
||||
// 1. Re-fetch fresh quote
|
||||
const quote = await fetchFreshQuote(p);
|
||||
if (!quote) {
|
||||
throw new Error('upstream returned no quote (no route or rate-limited)');
|
||||
}
|
||||
|
||||
// 2. Anti-MEV: compare toAmountMin
|
||||
const newMin = BigInt(quote.toAmountMin);
|
||||
const accepted = BigInt(p.acceptedMinOut);
|
||||
if (accepted > 0n && newMin < accepted) {
|
||||
// Slippage hardening — explicit price-moved error для UI retry
|
||||
const lossBps = Number(((accepted - newMin) * 10000n) / accepted);
|
||||
if (lossBps > 50) {
|
||||
const err: any = new Error(
|
||||
`Price moved: fresh toAmountMin=${newMin} < acceptedMinOut=${accepted} (-${lossBps} bps)`
|
||||
);
|
||||
err.code = 'PRICE_MOVED';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dispatch по source chain
|
||||
const sourceCode = CHAINID_TO_CHAIN[p.fromChain];
|
||||
if (!sourceCode) {
|
||||
throw new Error(`Unsupported source chainId ${p.fromChain}`);
|
||||
}
|
||||
|
||||
if (sourceCode === 'ETH' || sourceCode === 'BSC') {
|
||||
return executeEvm(p, quote, sourceCode);
|
||||
}
|
||||
if (sourceCode === 'SOL') {
|
||||
return executeSol(p, quote);
|
||||
}
|
||||
if (sourceCode === 'TRX') {
|
||||
return executeTron(p, quote);
|
||||
}
|
||||
if (sourceCode === 'BTC') {
|
||||
return executeBtc(p, quote);
|
||||
}
|
||||
throw new Error(`Unsupported source chain ${sourceCode}`);
|
||||
}
|
||||
|
||||
// ─── EVM execute ─────────────────────────────────────────────────────
|
||||
|
||||
interface NormalizedQuote {
|
||||
toolName?: string;
|
||||
toAmount: string;
|
||||
toAmountMin: string;
|
||||
fromAmountUSD?: string;
|
||||
toAmountUSD?: string;
|
||||
trackerUrl?: string;
|
||||
approvalAddress?: string | null;
|
||||
/** Готовая EVM unsigned tx или Solana base64 — формат зависит от source chain */
|
||||
tx: any;
|
||||
/** Для Relay: массив steps (approve + deposit) — каждый со своим tx */
|
||||
steps?: Array<{ id: string; data: any; check?: any }>;
|
||||
}
|
||||
|
||||
async function executeEvm(
|
||||
p: BridgeExecuteParams,
|
||||
quote: NormalizedQuote,
|
||||
chain: 'ETH' | 'BSC',
|
||||
): Promise<BridgeExecuteResult> {
|
||||
const isErc20 = p.fromToken.toLowerCase() !== EVM_NATIVE_SENTINEL;
|
||||
const needed = BigInt(p.fromAmount);
|
||||
const nativeSym = chain === 'ETH' ? 'ETH' : 'BNB';
|
||||
|
||||
// ── Balance pre-check ── (защита от raw RPC "insufficient" errors после потраченного gas)
|
||||
const nativeBal = await readEvmNativeBalance(chain, p.expectedFromAddress);
|
||||
if (isErc20) {
|
||||
// Need: gas reserve (native) + token balance ≥ amount
|
||||
if (nativeBal < EVM_GAS_RESERVE_WEI) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient ${nativeSym} for gas: have ${formatAmountForHumanError(nativeBal, 18)}, need at least ${formatAmountForHumanError(EVM_GAS_RESERVE_WEI, 18)} (approve+bridge gas reserve). Top up ${nativeSym}.`,
|
||||
);
|
||||
}
|
||||
const tokenBal = await readErc20Balance(chain, p.fromToken, p.expectedFromAddress);
|
||||
if (tokenBal < needed) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient ERC20 balance on ${chain}: have ${tokenBal}, need ${needed} (smallest units). Top up token first.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Native bridge: need amount + gas reserve
|
||||
if (nativeBal < needed + EVM_GAS_RESERVE_WEI) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient ${nativeSym}: have ${formatAmountForHumanError(nativeBal, 18)}, need ${formatAmountForHumanError(needed + EVM_GAS_RESERVE_WEI, 18)} (= ${formatAmountForHumanError(needed, 18)} bridge amount + ${formatAmountForHumanError(EVM_GAS_RESERVE_WEI, 18)} gas reserve).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let approveTxid: string | undefined;
|
||||
let feeTxid: string | undefined;
|
||||
let feeAmount: string | undefined;
|
||||
|
||||
// ── Step 1 (ERC20 only): approve если allowance недостаточен ──
|
||||
if (isErc20) {
|
||||
const spender = quote.approvalAddress;
|
||||
if (!spender) {
|
||||
throw new Error('Upstream quote did not return approvalAddress — cannot approve ERC20');
|
||||
}
|
||||
const allowance = await readErc20Allowance({
|
||||
chain,
|
||||
token: p.fromToken,
|
||||
owner: p.expectedFromAddress,
|
||||
spender,
|
||||
});
|
||||
const needed = BigInt(p.fromAmount);
|
||||
if (allowance < needed) {
|
||||
// signAndBroadcastEvmApprove внутри сам wait'ит 1 conf — bridge tx после возврата
|
||||
// гарантировано видит свежий allowance.
|
||||
const approveRes = await signAndBroadcastEvmApprove({
|
||||
chain,
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
spender,
|
||||
token: p.fromToken,
|
||||
amount: p.fromAmount,
|
||||
});
|
||||
approveTxid = approveRes.txid;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: off-chain 0.7% app fee (atomic — fee tx ПЕРЕД main bridge) ──
|
||||
// Применяется для ETH + BSC, для native и ERC20. Если fee < 1 unit (small amount)
|
||||
// → computeAppFee возвращает 0 → throw "too small" → fail (anti-bypass).
|
||||
{
|
||||
const feeResult = await signAndBroadcastEvmFeeTx({
|
||||
chain,
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
bridgeAmount: p.fromAmount,
|
||||
bridgeToken: isErc20 ? p.fromToken : null,
|
||||
});
|
||||
feeTxid = feeResult.feeTxid;
|
||||
feeAmount = feeResult.feeAmount;
|
||||
}
|
||||
// (signAndBroadcastBscFeeTx kept exported as backwards-compat alias — для wallet.controller's
|
||||
// bridgeAmount path. Здесь напрямую используем generalized version.)
|
||||
void signAndBroadcastBscFeeTx;
|
||||
|
||||
// ── Step 3: main bridge tx ──
|
||||
const tx = quote.tx;
|
||||
if (!tx || typeof tx !== 'object') {
|
||||
throw new Error('Upstream quote missing transactionRequest');
|
||||
}
|
||||
const chainId = Number(tx.chainId);
|
||||
const expectedChainId = chain === 'ETH' ? 1 : 56;
|
||||
if (chainId !== expectedChainId) {
|
||||
throw new Error(`Quote transactionRequest.chainId=${chainId} mismatch source ${chain} (${expectedChainId})`);
|
||||
}
|
||||
|
||||
const value = String(tx.value ?? '0');
|
||||
const gas = String(parseHexOrDec(tx.gas ?? tx.gasLimit));
|
||||
const maxFeePerGas = String(parseHexOrDec(tx.maxFeePerGas ?? tx.gasPrice));
|
||||
const maxPriorityFeePerGas = String(parseHexOrDec(tx.maxPriorityFeePerGas ?? tx.gasPrice));
|
||||
|
||||
// ── Pre-simulate main bridge tx через eth_call ──
|
||||
// Если LiFi/Relay вернул stale calldata (allowance/balance state changed) → revert на sim,
|
||||
// мы НЕ broadcast'им → user не теряет gas. Гарантия: simulation бесплатна.
|
||||
await simulateEvmTx(
|
||||
chain,
|
||||
p.expectedFromAddress,
|
||||
tx.to,
|
||||
tx.data,
|
||||
parseHexOrDec(value),
|
||||
'Bridge tx',
|
||||
);
|
||||
|
||||
const bridge = await signAndBroadcastRawEvm({
|
||||
chain,
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
tx: {
|
||||
to: tx.to,
|
||||
data: tx.data,
|
||||
value: parseHexOrDec(value).toString(),
|
||||
chainId: expectedChainId,
|
||||
gas,
|
||||
maxFeePerGas,
|
||||
maxPriorityFeePerGas,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
provider: p.provider,
|
||||
fromChain: p.fromChain,
|
||||
toChain: p.toChain,
|
||||
toolName: quote.toolName,
|
||||
approveTxid,
|
||||
feeTxid,
|
||||
feeAmount,
|
||||
bridgeTxid: bridge.txid,
|
||||
fromAmount: p.fromAmount,
|
||||
toAmountMin: quote.toAmountMin,
|
||||
fromAmountUSD: quote.fromAmountUSD,
|
||||
toAmountUSD: quote.toAmountUSD,
|
||||
trackerUrl: quote.trackerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── SOL execute ──────────────────────────────────────────────────────
|
||||
|
||||
async function executeSol(
|
||||
p: BridgeExecuteParams,
|
||||
quote: NormalizedQuote,
|
||||
): Promise<BridgeExecuteResult> {
|
||||
const needed = BigInt(p.fromAmount);
|
||||
const isSpl = p.fromToken !== SOL_NATIVE_SENTINEL;
|
||||
|
||||
// ── Balance pre-check ──
|
||||
const lamports = await readSolBalance(p.expectedFromAddress);
|
||||
if (isSpl) {
|
||||
if (lamports < SOL_FEE_RESERVE_LAMPORTS) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient SOL for fees: have ${formatAmountForHumanError(lamports, 9)}, need at least ${formatAmountForHumanError(SOL_FEE_RESERVE_LAMPORTS, 9)} SOL (rent + tx fees). Top up SOL first.`,
|
||||
);
|
||||
}
|
||||
const tokenBal = await readSplTokenBalance(p.expectedFromAddress, p.fromToken);
|
||||
if (tokenBal < needed) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient SPL token balance on SOL: have ${tokenBal}, need ${needed} (smallest units, mint ${p.fromToken.slice(0, 8)}...). Top up token first.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Native SOL bridge: amount + fees
|
||||
const totalNeeded = needed + SOL_FEE_RESERVE_LAMPORTS;
|
||||
if (lamports < totalNeeded) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient SOL: have ${formatAmountForHumanError(lamports, 9)}, need ${formatAmountForHumanError(totalNeeded, 9)} SOL (= ${formatAmountForHumanError(needed, 9)} bridge + ${formatAmountForHumanError(SOL_FEE_RESERVE_LAMPORTS, 9)} fees). Top up SOL first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── App fee 0.7% (atomic — fee tx ПЕРЕД main bridge) ──
|
||||
// Native SOL bridge → fee in native SOL. SPL bridge → fee in same SPL token.
|
||||
// Если fee = 0 (amount слишком мал) → throw (anti-bypass).
|
||||
let feeTxid: string | undefined;
|
||||
let feeAmount: string | undefined;
|
||||
const feeAmountBig = computeAppFee(p.fromAmount);
|
||||
if (feeAmountBig <= 0n) {
|
||||
throw new Error('SOL bridge: fromAmount too small — fee = 0');
|
||||
}
|
||||
const feeSymbol: string | undefined = isSpl ? (_splMintToSymbol(p.fromToken) || undefined) : undefined;
|
||||
if (isSpl && !feeSymbol) {
|
||||
throw new Error(`SOL bridge: SPL mint ${p.fromToken} not in registry — fee transfer not supported`);
|
||||
}
|
||||
const feeRes = await signAndBroadcast({
|
||||
chain: 'SOL',
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
to: APP_FEE_WALLET_SOL,
|
||||
amount: feeAmountBig.toString(),
|
||||
token: feeSymbol,
|
||||
});
|
||||
feeTxid = feeRes.txid;
|
||||
feeAmount = feeAmountBig.toString();
|
||||
logger.info(`SOL bridge fee broadcast: ${feeAmount} ${feeSymbol || 'lamports'} → ${APP_FEE_WALLET_SOL} (txid ${feeTxid})`);
|
||||
|
||||
// Для SOL LiFi возвращает в `transactionRequest.data` = base64-encoded VersionedTransaction.
|
||||
// Relay для SOL → 'transaction' inside step.items[0].data.
|
||||
const tx = quote.tx;
|
||||
let base64: string | undefined;
|
||||
if (tx && typeof tx === 'object') {
|
||||
base64 = tx.data || tx.transaction;
|
||||
} else if (typeof tx === 'string') {
|
||||
base64 = tx;
|
||||
}
|
||||
if (!base64 || typeof base64 !== 'string') {
|
||||
throw new Error('Solana quote missing base64 transaction data');
|
||||
}
|
||||
|
||||
const result = await signAndBroadcastSolanaTx({
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
serializedTransaction: base64,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: p.provider,
|
||||
fromChain: p.fromChain,
|
||||
toChain: p.toChain,
|
||||
toolName: quote.toolName,
|
||||
feeTxid,
|
||||
feeAmount,
|
||||
bridgeTxid: result.signature,
|
||||
fromAmount: p.fromAmount,
|
||||
toAmountMin: quote.toAmountMin,
|
||||
fromAmountUSD: quote.fromAmountUSD,
|
||||
toAmountUSD: quote.toAmountUSD,
|
||||
trackerUrl: quote.trackerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── TRX execute ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* TRX bridge execute via NearIntents 1Click API (direct, не через LiFi).
|
||||
*
|
||||
* Old LiFi path сломан — returns pre-built protobuf raw_data_hex с NearIntents intent внутри,
|
||||
* который имеет 30-60s off-chain TTL. Наш pipeline превышал TTL → on-chain reverts + burned
|
||||
* fees (~6.6 TRX потеряно на trash).
|
||||
*
|
||||
* Новый flow:
|
||||
* 1. Fetch свежий quote напрямую из NearIntents (1click.chaindefuser.com) — deadline мы
|
||||
* выбираем сами (30 мин), depositAddress валидный Tron base58.
|
||||
* 2. Anti-MEV guard: minAmountOut ≥ acceptedMinOut (на 50 bps cushion).
|
||||
* 3. Deadline safety: refuse if <20s осталось (вместо потенциального revert на solver TTL).
|
||||
* 4. Balance pre-check через existing readTrxBalance/readTrc20Balance.
|
||||
* 5. Send TRX/TRC20 на depositAddress через existing `signAndBroadcast` (battle-tested
|
||||
* sendTrx с MITM защитой). НЕТ contract call, НЕТ approve, НЕТ protobuf — обычный transfer.
|
||||
* 6. Best-effort notify NearIntents о txHash (solver всё равно мониторит on-chain).
|
||||
*
|
||||
* Игнорируем upstream `quote` parameter — мы делаем свой quote через NearIntents.
|
||||
*/
|
||||
async function executeTron(
|
||||
p: BridgeExecuteParams,
|
||||
_upstreamQuote: NormalizedQuote,
|
||||
): Promise<BridgeExecuteResult> {
|
||||
// Detect TRC20 vs native TRX. LiFi sentinel для native = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb';
|
||||
// в наших frontend dropdowns native передаётся как этот же sentinel.
|
||||
const isTrc20 = p.fromToken !== TRX_NATIVE_SENTINEL;
|
||||
const needed = BigInt(p.fromAmount);
|
||||
|
||||
// Map destination chain (для NearIntents asset resolution)
|
||||
const destCode = CHAINID_TO_CHAIN[p.toChain];
|
||||
if (!destCode) {
|
||||
throw new Error(`NearIntents (TRX): destination chainId ${p.toChain} not in our chain map`);
|
||||
}
|
||||
|
||||
// toToken: для native dest LiFi/Jumper передаёт chain-specific sentinels.
|
||||
// NearIntents resolver сам разберётся — мы передаём null для native, contract для tokens.
|
||||
// Detect "native" по sentinel-pattern (исключаем Tron T-prefixed которые могут быть TRC20):
|
||||
const NATIVE_SENTINELS = new Set([
|
||||
'0x0000000000000000000000000000000000000000', // EVM
|
||||
'11111111111111111111111111111111', // SOL System Program
|
||||
'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb', // TRX native
|
||||
'bitcoin', // BTC literal
|
||||
'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8', // Relay BTC sentinel
|
||||
]);
|
||||
const destToken = NATIVE_SENTINELS.has(p.toToken) ? null : p.toToken;
|
||||
const originToken = isTrc20 ? p.fromToken : null;
|
||||
|
||||
// ── 1. Resolve assetIds dynamically (fetches /v0/tokens, cached 1h) ──
|
||||
const originAssetId = await resolveAsset('TRX', originToken);
|
||||
if (!originAssetId) {
|
||||
const err: any = new Error(
|
||||
`NearIntents: origin asset TRX:${originToken || 'native'} не поддерживается. Используй TRX native или USDT TRC20.`,
|
||||
);
|
||||
err.code = 'NO_ROUTE';
|
||||
throw err;
|
||||
}
|
||||
const destAssetId = await resolveAsset(destCode, destToken);
|
||||
if (!destAssetId) {
|
||||
const err: any = new Error(
|
||||
`NearIntents: destination asset ${destCode}:${destToken || 'native'} не поддерживается. ` +
|
||||
`Попробуй другой токен (поддерживаются native + USDT/USDC на большинстве chains).`,
|
||||
);
|
||||
err.code = 'NO_ROUTE';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ── 2. NearIntents quote ──
|
||||
const niQuote = await fetchNearIntentsQuote({
|
||||
originAssetId,
|
||||
destinationAssetId: destAssetId,
|
||||
amount: p.fromAmount,
|
||||
slippageBps: 50, // 0.5% default — hardcoded server-side
|
||||
refundTo: p.expectedFromAddress, // user's TRX wallet — refund target если intent fails
|
||||
recipient: p.expectedToAddress, // user's destination wallet
|
||||
deadlineMinutes: 30, // 30 минут TTL — хватает на ALL operations + solver delivery
|
||||
});
|
||||
|
||||
// ── 1.1 depositAddress security validation ──
|
||||
// Защита от compromised NearIntents response: ensure депозитный address — реальный Tron base58.
|
||||
assertValidDepositAddress('TRX', niQuote.depositAddress);
|
||||
|
||||
// ── 2. Anti-MEV: minAmountOut должен быть ≥ acceptedMinOut (с tolerance 50 bps) ──
|
||||
const acceptedMinOut = BigInt(p.acceptedMinOut);
|
||||
const niMinOut = BigInt(niQuote.minAmountOut);
|
||||
if (acceptedMinOut > 0n && niMinOut < acceptedMinOut) {
|
||||
const lossBps = Number(((acceptedMinOut - niMinOut) * 10000n) / acceptedMinOut);
|
||||
if (lossBps > 50) {
|
||||
const err: any = new Error(
|
||||
`NearIntents quote worse than user-accepted: minOut ${niMinOut} < accepted ${acceptedMinOut} (-${lossBps} bps). Re-quote and retry.`,
|
||||
);
|
||||
err.code = 'PRICE_MOVED';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Deadline safety: at least 20s margin для broadcast + propagation ──
|
||||
const remainingMs = niQuote.timeWhenInactiveMs - Date.now();
|
||||
if (remainingMs < 20_000) {
|
||||
const err: any = new Error(
|
||||
`NearIntents quote deadline too close (${Math.round(remainingMs / 1000)}s left). Re-quote and retry.`,
|
||||
);
|
||||
err.code = 'PRICE_MOVED';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ── 4. Balance pre-check (existing helpers) ──
|
||||
const trxBal = await readTrxBalance(p.expectedFromAddress);
|
||||
if (isTrc20) {
|
||||
if (trxBal < TRX_FEE_RESERVE_SUN) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient TRX for fees: have ${formatAmountForHumanError(trxBal, 6)}, need at least ${formatAmountForHumanError(TRX_FEE_RESERVE_SUN, 6)} TRX (bandwidth/energy). Top up TRX first.`,
|
||||
);
|
||||
}
|
||||
const tokenBal = await readTrc20Balance(p.fromToken, p.expectedFromAddress);
|
||||
if (tokenBal < needed) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient TRC20 balance on TRX: have ${tokenBal}, need ${needed} (smallest units, token ${p.fromToken.slice(0, 8)}...). Top up token first.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const totalNeeded = needed + TRX_FEE_RESERVE_SUN;
|
||||
if (trxBal < totalNeeded) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient TRX: have ${formatAmountForHumanError(trxBal, 6)}, need ${formatAmountForHumanError(totalNeeded, 6)} (= ${formatAmountForHumanError(needed, 6)} bridge + ${formatAmountForHumanError(TRX_FEE_RESERVE_SUN, 6)} fees).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve TRC20 token symbol для sendTrx ──
|
||||
// Для NearIntents MVP мы поддерживаем только USDT TRC20. Native TRX = no token.
|
||||
let tokenSymbol: string | undefined;
|
||||
if (isTrc20) {
|
||||
tokenSymbol = _trc20ContractToSymbol(p.fromToken) ?? undefined;
|
||||
if (!tokenSymbol) {
|
||||
throw new Error(`NearIntents (TRX): unsupported TRC20 token ${p.fromToken} (not in registry)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5a. App fee 0.7% (atomic — fee TRX/TRC20 tx ПЕРЕД main bridge) ──
|
||||
const feeAmountBig = computeAppFee(p.fromAmount);
|
||||
if (feeAmountBig <= 0n) {
|
||||
throw new Error('TRX bridge: fromAmount too small — fee = 0');
|
||||
}
|
||||
const feeRes = await signAndBroadcast({
|
||||
chain: 'TRX',
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
to: APP_FEE_WALLET_TRX,
|
||||
amount: feeAmountBig.toString(),
|
||||
token: tokenSymbol,
|
||||
});
|
||||
const feeTxid = feeRes.txid;
|
||||
const feeAmount = feeAmountBig.toString();
|
||||
logger.info(`TRX bridge fee broadcast: ${feeAmount} ${tokenSymbol || 'sun'} → ${APP_FEE_WALLET_TRX} (txid ${feeTxid})`);
|
||||
// Brief wait чтобы fee tx сделала inclusion before main (avoid nonce/sequence collision)
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
|
||||
logger.info(
|
||||
`NearIntents TRX bridge: ${p.fromAmount} ${tokenSymbol || 'TRX'} → ${destCode} ${destToken || 'native'} ` +
|
||||
`deposit=${niQuote.depositAddress} correlationId=${niQuote.correlationId} deadlineLeft=${Math.round(remainingMs / 1000)}s`,
|
||||
);
|
||||
|
||||
// ── 5b. Main bridge tx: TRX/TRC20 → NearIntents depositAddress via existing sendTrx ──
|
||||
const sendResult = await signAndBroadcast({
|
||||
chain: 'TRX',
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
to: niQuote.depositAddress,
|
||||
amount: p.fromAmount,
|
||||
token: tokenSymbol,
|
||||
});
|
||||
|
||||
// ── 6. Best-effort notify NearIntents (fire-and-forget, не блокируем response) ──
|
||||
submitNearIntentsDeposit(niQuote.depositAddress, sendResult.txid).catch((err) => {
|
||||
logger.warn(`NearIntents submitDeposit failed (non-fatal): ${err?.message}`);
|
||||
});
|
||||
|
||||
return {
|
||||
provider: 'nearintents' as BridgeProvider,
|
||||
fromChain: p.fromChain,
|
||||
toChain: p.toChain,
|
||||
toolName: 'NearIntents 1Click',
|
||||
feeTxid,
|
||||
feeAmount,
|
||||
bridgeTxid: sendResult.txid,
|
||||
fromAmount: p.fromAmount,
|
||||
toAmountMin: niQuote.minAmountOut,
|
||||
fromAmountUSD: niQuote.amountInUsd,
|
||||
toAmountUSD: niQuote.amountOutUsd,
|
||||
trackerUrl: nearIntentsTrackerUrl(niQuote.depositAddress),
|
||||
};
|
||||
}
|
||||
|
||||
// Discard unused imports from older path (kept в case future protobuf-decoded path)
|
||||
void signAndBroadcastTronPrebuiltTx;
|
||||
void signAndBroadcastRawTron;
|
||||
void signAndBroadcastTrc20Approve;
|
||||
void readTrc20Allowance;
|
||||
|
||||
// ─── BTC execute ──────────────────────────────────────────────────────
|
||||
|
||||
async function executeBtc(
|
||||
p: BridgeExecuteParams,
|
||||
quote: NormalizedQuote,
|
||||
): Promise<BridgeExecuteResult> {
|
||||
// Для BTC source через Relay: quote.steps[0] = deposit step с {data.depositAddress, ...}.
|
||||
// Relay просит юзера отправить BTC tx на их deposit address; solver видит UTXO в mempool
|
||||
// → доставляет destination asset.
|
||||
let depositAddress: string | undefined;
|
||||
let amountSat: bigint | undefined;
|
||||
if (p.provider === 'relay' && Array.isArray(quote.steps)) {
|
||||
const depositStep = quote.steps.find((s) => s.id === 'deposit' || s.id === 'tx');
|
||||
if (depositStep) {
|
||||
depositAddress = depositStep.data?.depositAddress || depositStep.data?.to;
|
||||
const amt = depositStep.data?.amount || depositStep.data?.value;
|
||||
if (amt) amountSat = BigInt(amt);
|
||||
}
|
||||
}
|
||||
if (!depositAddress) {
|
||||
throw new Error('Relay BTC quote missing depositAddress');
|
||||
}
|
||||
if (!amountSat) {
|
||||
amountSat = BigInt(p.fromAmount);
|
||||
}
|
||||
|
||||
// ── Balance pre-check (BTC) ──
|
||||
// Минимум для tx: amount + fee (≥ ~500 sat для 1-input 2-output P2WPKH).
|
||||
// Точный fee рассчитается в signAndBroadcastBtcDeposit; здесь делаем conservative
|
||||
// нижнюю границу 1000 sat для anti-dust reject.
|
||||
const btcBal = await readBtcConfirmedBalance(p.expectedFromAddress);
|
||||
const BTC_FEE_RESERVE_SAT = 1000n;
|
||||
const totalNeeded = amountSat + BTC_FEE_RESERVE_SAT;
|
||||
if (btcBal < totalNeeded) {
|
||||
throw new InsufficientBalanceError(
|
||||
`Insufficient BTC: confirmed UTXOs total ${formatAmountForHumanError(btcBal, 8)}, need at least ${formatAmountForHumanError(totalNeeded, 8)} BTC (= ${formatAmountForHumanError(amountSat, 8)} bridge + ${formatAmountForHumanError(BTC_FEE_RESERVE_SAT, 8)} fee floor). Top up BTC first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await signAndBroadcastBtcDeposit({
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
depositAddress,
|
||||
amountSat,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: p.provider,
|
||||
fromChain: p.fromChain,
|
||||
toChain: p.toChain,
|
||||
toolName: quote.toolName,
|
||||
bridgeTxid: result.txid,
|
||||
fromAmount: p.fromAmount,
|
||||
toAmountMin: quote.toAmountMin,
|
||||
fromAmountUSD: quote.fromAmountUSD,
|
||||
toAmountUSD: quote.toAmountUSD,
|
||||
trackerUrl: quote.trackerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Quote re-fetch (anti-stale) ─────────────────────────────────────
|
||||
|
||||
async function fetchFreshQuote(p: BridgeExecuteParams): Promise<NormalizedQuote | null> {
|
||||
if (p.provider === 'jumper') {
|
||||
return fetchJumperQuote(p);
|
||||
}
|
||||
if (p.provider === 'relay') {
|
||||
return fetchRelayQuote(p);
|
||||
}
|
||||
throw new Error(`Unknown provider ${p.provider}`);
|
||||
}
|
||||
|
||||
async function fetchJumperQuote(p: BridgeExecuteParams): Promise<NormalizedQuote | null> {
|
||||
const params = new URLSearchParams({
|
||||
fromChain: String(p.fromChain),
|
||||
toChain: String(p.toChain),
|
||||
fromToken: p.fromToken,
|
||||
toToken: p.toToken,
|
||||
fromAmount: p.fromAmount,
|
||||
fromAddress: p.fromAddress,
|
||||
toAddress: p.toAddress,
|
||||
});
|
||||
const url = `${LIFI_API_URL}/quote?${params.toString()}`;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), UPSTREAM_TIMEOUT_MS);
|
||||
let res: globalThis.Response;
|
||||
try {
|
||||
res = await proxiedFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
logger.warn(`Jumper /quote refetch failed ${res.status}: ${body.slice(0, 200)}`);
|
||||
return null;
|
||||
}
|
||||
const json: any = await res.json();
|
||||
const estimate = json?.estimate;
|
||||
const action = json?.action;
|
||||
if (!estimate || !action) return null;
|
||||
|
||||
// For non-EVM source chains (SOL/TRX/BTC), LiFi возвращает разные shape'ы:
|
||||
// - EVM: top-level transactionRequest = { to, data, value, chainId, gasPrice, gasLimit }
|
||||
// - SOL: transactionRequest.data = base64 VersionedTransaction
|
||||
// - TRX: transactionRequest = { to, data, value, ... } (нативный Tron call format)
|
||||
return {
|
||||
toolName: json.tool || json.toolDetails?.name,
|
||||
toAmount: String(estimate.toAmount),
|
||||
toAmountMin: String(estimate.toAmountMin),
|
||||
fromAmountUSD: estimate.fromAmountUSD ? String(estimate.fromAmountUSD) : undefined,
|
||||
toAmountUSD: estimate.toAmountUSD ? String(estimate.toAmountUSD) : undefined,
|
||||
approvalAddress: estimate.approvalAddress || null,
|
||||
tx: json.transactionRequest,
|
||||
trackerUrl: json.transactionId ? `https://scan.bridge.li.fi/tx/${json.transactionId}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRelayQuote(p: BridgeExecuteParams): Promise<NormalizedQuote | null> {
|
||||
const body = {
|
||||
user: p.fromAddress,
|
||||
recipient: p.toAddress,
|
||||
originChainId: p.fromChain,
|
||||
destinationChainId: p.toChain,
|
||||
originCurrency: p.fromToken,
|
||||
destinationCurrency: p.toToken,
|
||||
amount: p.fromAmount,
|
||||
tradeType: 'EXACT_INPUT',
|
||||
};
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), UPSTREAM_TIMEOUT_MS);
|
||||
let res: globalThis.Response;
|
||||
try {
|
||||
res = await proxiedFetch(`${RELAY_API_URL}/quote`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
logger.warn(`Relay /quote refetch failed ${res.status}: ${text.slice(0, 200)}`);
|
||||
return null;
|
||||
}
|
||||
const json: any = await res.json();
|
||||
const steps = (json?.steps || []) as Array<any>;
|
||||
if (!steps.length) return null;
|
||||
|
||||
// Relay структурирует через steps[].items[].data. Approve step имеет id='approve',
|
||||
// deposit/bridge — id='deposit' или 'tx'.
|
||||
const flatSteps = steps.flatMap((s) =>
|
||||
(s.items || []).map((it: any) => ({ id: s.id, data: it.data, check: it.check }))
|
||||
);
|
||||
const depositStep = flatSteps.find((s) => s.id === 'deposit' || s.id === 'tx');
|
||||
const approveStep = flatSteps.find((s) => s.id === 'approve');
|
||||
|
||||
// Для EVM source — берём deposit step как main tx (approve handled отдельно)
|
||||
const tx = depositStep?.data;
|
||||
// Для Relay approve spender = depositStep.data.to (router), либо approve.data.to.
|
||||
const approvalAddress = approveStep?.data?.to || tx?.to || null;
|
||||
|
||||
const details = json?.details || {};
|
||||
const requestId = (depositStep?.check?.endpoint || '').match(/requestId=([^&]+)/)?.[1];
|
||||
|
||||
return {
|
||||
toolName: 'relay',
|
||||
toAmount: String(details?.currencyOut?.amount || '0'),
|
||||
toAmountMin: String(details?.currencyOut?.minimumAmount || details?.currencyOut?.amount || '0'),
|
||||
fromAmountUSD: details?.currencyIn?.amountUsd ? String(details.currencyIn.amountUsd) : undefined,
|
||||
toAmountUSD: details?.currencyOut?.amountUsd ? String(details.currencyOut.amountUsd) : undefined,
|
||||
approvalAddress,
|
||||
tx,
|
||||
steps: flatSteps,
|
||||
trackerUrl: requestId ? `${RELAY_API_URL}/intents/status/v3?requestId=${requestId}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function parseHexOrDec(v: any): bigint {
|
||||
if (v === null || v === undefined || v === '') return 0n;
|
||||
const s = String(v);
|
||||
if (s.startsWith('0x') || s.startsWith('0X')) return BigInt(s);
|
||||
return BigInt(s);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* USD price oracle for wallet balance responses.
|
||||
* USD price oracle for wallet balance responses + 24h change percentage.
|
||||
*
|
||||
* Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price).
|
||||
* Cache: KeyDB (Redis), TTL = 300s.
|
||||
*
|
||||
* Now also returns `change24h` (price change percent over rolling 24h) — used by
|
||||
* `/api/prices/dynamics`. Existing helpers `getPricesByIds` / `getPricesBySymbols`
|
||||
* остаются backward-compatible (возвращают только number | null) — для них достаточно
|
||||
* `usd` поля из cache.
|
||||
*
|
||||
* Security (см. план §"Security checklist"):
|
||||
* S1 — whitelist через getCoingeckoId → user input не попадает в URL.
|
||||
* S2 — лимит размеров вызовов через caller (controller `/prices`).
|
||||
@@ -25,26 +30,35 @@ const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price';
|
||||
const CACHE_TTL_SECONDS = 300;
|
||||
const CACHE_KEY_PREFIX = 'price:';
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
const MAX_IDS_PER_REQUEST = 100; // CoinGecko allows ~250, мы консервативно 100.
|
||||
const MAX_IDS_PER_REQUEST = 100;
|
||||
|
||||
export interface PriceWithChange {
|
||||
usd: number;
|
||||
change24h: number | null; // например -1.38 (= -1.38%), 0.06, null если CG не отдал
|
||||
}
|
||||
|
||||
interface CachedPrice {
|
||||
usd: number;
|
||||
change24h: number | null;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
|
||||
const _inflight = new Map<string, Promise<Record<string, number | null>>>();
|
||||
const _inflight = new Map<string, Promise<Record<string, PriceWithChange | null>>>();
|
||||
|
||||
function isValidPrice(n: unknown): n is number {
|
||||
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
||||
}
|
||||
|
||||
function isValidChange(n: unknown): n is number {
|
||||
// change24h может быть negative (падение цены), но конечное число
|
||||
return typeof n === 'number' && Number.isFinite(n);
|
||||
}
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
const key = process.env.COINGECKO_API_KEY;
|
||||
if (key && key.length > 0) {
|
||||
// CoinGecko Demo API key → `x-cg-demo-api-key`. Pro → `x-cg-pro-api-key`.
|
||||
// Не печатаем header нигде, см. S9.
|
||||
headers['x-cg-demo-api-key'] = key;
|
||||
}
|
||||
return headers;
|
||||
@@ -52,10 +66,10 @@ function buildHeaders(): Record<string, string> {
|
||||
|
||||
/**
|
||||
* Fetches CoinGecko /simple/price for a batch of coin ids.
|
||||
* Internal — caller must ensure `ids.length > 0 && ids.length <= MAX_IDS_PER_REQUEST`.
|
||||
* Now includes `include_24hr_change=true` — отдаёт usd_24h_change поле.
|
||||
*/
|
||||
async function fetchCoingecko(ids: string[]): Promise<Record<string, number | null>> {
|
||||
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd`;
|
||||
async function fetchCoingecko(ids: string[]): Promise<Record<string, PriceWithChange | null>> {
|
||||
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true`;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
@@ -64,22 +78,29 @@ async function fetchCoingecko(ids: string[]): Promise<Record<string, number | nu
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// S5: не логируем URL целиком (содержит query string).
|
||||
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
|
||||
const out: Record<string, number | null> = {};
|
||||
const out: Record<string, PriceWithChange | null> = {};
|
||||
for (const id of ids) out[id] = null;
|
||||
return out;
|
||||
}
|
||||
const json = (await res.json()) as Record<string, { usd?: unknown }>;
|
||||
const out: Record<string, number | null> = {};
|
||||
const json = (await res.json()) as Record<string, { usd?: unknown; usd_24h_change?: unknown }>;
|
||||
const out: Record<string, PriceWithChange | null> = {};
|
||||
for (const id of ids) {
|
||||
const usd = json?.[id]?.usd;
|
||||
out[id] = isValidPrice(usd) ? usd : null;
|
||||
const change = json?.[id]?.usd_24h_change;
|
||||
if (isValidPrice(usd)) {
|
||||
out[id] = {
|
||||
usd,
|
||||
change24h: isValidChange(change) ? change : null,
|
||||
};
|
||||
} else {
|
||||
out[id] = null;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (err: any) {
|
||||
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
|
||||
const out: Record<string, number | null> = {};
|
||||
const out: Record<string, PriceWithChange | null> = {};
|
||||
for (const id of ids) out[id] = null;
|
||||
return out;
|
||||
} finally {
|
||||
@@ -88,25 +109,25 @@ async function fetchCoingecko(ids: string[]): Promise<Record<string, number | nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает USD-цены для списка CoinGecko ids.
|
||||
* Возвращает USD-цены + 24h change для списка CoinGecko ids.
|
||||
* Никогда не throws — degrades to `null` per-id.
|
||||
*
|
||||
* Cache: read-through KeyDB, 300s TTL. Только валидные числа кэшируются (S12).
|
||||
* Cache: read-through KeyDB, 300s TTL. Только валидные usd кэшируются (S12).
|
||||
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
|
||||
*/
|
||||
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
|
||||
export async function getPricesWithChangeByIds(
|
||||
ids: string[],
|
||||
): Promise<Record<string, PriceWithChange | null>> {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return {};
|
||||
|
||||
// Дедупликация ids (на случай если caller передал duplicates).
|
||||
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
|
||||
if (uniqIds.length === 0) return {};
|
||||
|
||||
const result: Record<string, number | null> = {};
|
||||
const result: Record<string, PriceWithChange | null> = {};
|
||||
let redis: ReturnType<typeof getRedis> | null = null;
|
||||
try {
|
||||
redis = getRedis();
|
||||
} catch {
|
||||
// Redis singleton недоступен — продолжаем без cache, сразу идём в CG.
|
||||
redis = null;
|
||||
}
|
||||
|
||||
@@ -124,7 +145,10 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as CachedPrice;
|
||||
if (isValidPrice(parsed?.usd)) {
|
||||
result[id] = parsed.usd;
|
||||
result[id] = {
|
||||
usd: parsed.usd,
|
||||
change24h: isValidChange(parsed?.change24h) ? parsed.change24h : null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -135,7 +159,6 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
|
||||
// Cache miss for ALL ids — degrade to upstream fetch.
|
||||
for (const id of uniqIds) {
|
||||
if (!(id in result)) misses.push(id);
|
||||
}
|
||||
@@ -146,8 +169,8 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
||||
|
||||
if (misses.length === 0) return result;
|
||||
|
||||
// 2) Fetch misses в batches (S2-style guard) + in-flight dedup (S4).
|
||||
const fetched: Record<string, number | null> = {};
|
||||
// 2) Fetch misses в batches + in-flight dedup (S4).
|
||||
const fetched: Record<string, PriceWithChange | null> = {};
|
||||
for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) {
|
||||
const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST);
|
||||
const batchKey = batch.join('|');
|
||||
@@ -167,10 +190,14 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
||||
const setP = redis.pipeline();
|
||||
let writes = 0;
|
||||
for (const [id, val] of Object.entries(fetched)) {
|
||||
if (isValidPrice(val)) {
|
||||
if (val && isValidPrice(val.usd)) {
|
||||
setP.set(
|
||||
CACHE_KEY_PREFIX + id,
|
||||
JSON.stringify({ usd: val, ts: Date.now() } satisfies CachedPrice),
|
||||
JSON.stringify({
|
||||
usd: val.usd,
|
||||
change24h: val.change24h,
|
||||
ts: Date.now(),
|
||||
} satisfies CachedPrice),
|
||||
'EX',
|
||||
CACHE_TTL_SECONDS,
|
||||
);
|
||||
@@ -179,7 +206,6 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
||||
}
|
||||
if (writes > 0) await setP.exec();
|
||||
} catch (err: any) {
|
||||
// Cache write failure → не критично, продолжаем.
|
||||
logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
@@ -192,14 +218,24 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible thin wrapper: возвращает только usd (без change24h).
|
||||
* Все существующие callers (portfolio, swap quote USD enrichment) используют это.
|
||||
*/
|
||||
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
|
||||
const rich = await getPricesWithChangeByIds(ids);
|
||||
const out: Record<string, number | null> = {};
|
||||
for (const id of Object.keys(rich)) {
|
||||
out[id] = rich[id]?.usd ?? null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
|
||||
*
|
||||
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
|
||||
* Ключ совпадает с тем что caller затем использует на lookup'е.
|
||||
*
|
||||
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
|
||||
* Никаких throw'ов, никаких побочек кроме cache writes.
|
||||
*/
|
||||
export async function getPricesBySymbols(
|
||||
pairs: { chain: ChainCode; symbol: string }[],
|
||||
@@ -207,13 +243,12 @@ export async function getPricesBySymbols(
|
||||
const out = new Map<string, number | null>();
|
||||
if (!Array.isArray(pairs) || pairs.length === 0) return out;
|
||||
|
||||
// (chain:symbol) → coingeckoId | null
|
||||
const pairToId = new Map<string, string | null>();
|
||||
const idsToFetch = new Set<string>();
|
||||
|
||||
for (const { chain, symbol } of pairs) {
|
||||
const key = `${chain}:${symbol}`;
|
||||
if (pairToId.has(key)) continue; // dedup
|
||||
if (pairToId.has(key)) continue;
|
||||
const id = getCoingeckoId(chain, symbol);
|
||||
pairToId.set(key, id);
|
||||
if (id) idsToFetch.add(id);
|
||||
@@ -233,3 +268,39 @@ export async function getPricesBySymbols(
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as getPricesBySymbols но возвращает PriceWithChange.
|
||||
* Используется в /api/prices/dynamics.
|
||||
*/
|
||||
export async function getPricesWithChangeBySymbols(
|
||||
pairs: { chain: ChainCode; symbol: string }[],
|
||||
): Promise<Map<string, PriceWithChange | null>> {
|
||||
const out = new Map<string, PriceWithChange | null>();
|
||||
if (!Array.isArray(pairs) || pairs.length === 0) return out;
|
||||
|
||||
const pairToId = new Map<string, string | null>();
|
||||
const idsToFetch = new Set<string>();
|
||||
|
||||
for (const { chain, symbol } of pairs) {
|
||||
const key = `${chain}:${symbol}`;
|
||||
if (pairToId.has(key)) continue;
|
||||
const id = getCoingeckoId(chain, symbol);
|
||||
pairToId.set(key, id);
|
||||
if (id) idsToFetch.add(id);
|
||||
else out.set(key, null);
|
||||
}
|
||||
|
||||
const prices = await getPricesWithChangeByIds(Array.from(idsToFetch));
|
||||
|
||||
for (const [key, id] of pairToId.entries()) {
|
||||
if (out.has(key)) continue;
|
||||
if (!id) {
|
||||
out.set(key, null);
|
||||
continue;
|
||||
}
|
||||
out.set(key, prices[id] ?? null);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ export interface QuoteBscParams {
|
||||
}
|
||||
|
||||
export interface SwapQuoteRaw {
|
||||
amountIn: string; // smallest units
|
||||
expectedOut: string; // mid-market quote (smallest units)
|
||||
amountIn: string; // smallest units (gross — что юзер отдаёт всего)
|
||||
expectedOut: string; // mid-market quote (smallest units), based on (amount - appFee)
|
||||
minOut: string; // expectedOut × (10000 - slippageBps) / 10000
|
||||
slippageBps: number;
|
||||
route: string[]; // symbol path (info; не используется в execute)
|
||||
@@ -96,6 +96,12 @@ export interface SwapQuoteRaw {
|
||||
asset: string;
|
||||
amount: string;
|
||||
};
|
||||
/** App fee (0.7% BSC) — отправляется на BSC_FEE_WALLET перед swap. Только для BSC. */
|
||||
appFee?: {
|
||||
asset: string; // BNB или token symbol
|
||||
amount: string; // smallest units (= amount * 70 / 10000)
|
||||
recipient: string; // BSC_FEE_WALLET
|
||||
};
|
||||
/** Per-token decimals для controller форматирования. */
|
||||
fromDecimals: number;
|
||||
toDecimals: number;
|
||||
@@ -129,6 +135,7 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||
// BSC decimals map (для quote response). Native BNB = 18. Все BSC tokens из BSC_TOKENS
|
||||
// = 18 (USDT/USDC/WBNB/BUSD/DOGE — да, на BSC DOGE — 8, остальное 18). Lookup через registry.
|
||||
import { getEvmTokens } from '../lib/token-registry';
|
||||
import { BSC_FEE_WALLET, computeBscFee } from '../lib/bsc-fee';
|
||||
|
||||
function bscTokenDecimals(symbol: string): number {
|
||||
const upper = symbol.toUpperCase();
|
||||
@@ -169,11 +176,21 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
||||
throw new Error('Gas fee exceeds policy cap');
|
||||
}
|
||||
|
||||
// Quote via getAmountsOut
|
||||
// App fee 0.7% (off-chain double-tx). Применяется ВСЕГДА на BSC swap.
|
||||
// amount → fee + swap = amount × 70/10000 + amount × 9930/10000.
|
||||
// getAmountsOut вычисляется на swapAmount (после fee) → expectedOut учитывает fee.
|
||||
const feeAmountBig = computeBscFee(p.amount); // 0.7% от amount
|
||||
const swapAmountBig = BigInt(p.amount) - feeAmountBig;
|
||||
if (swapAmountBig <= 0n) {
|
||||
throw new Error('amount too small after 0.7% fee deduction');
|
||||
}
|
||||
const swapAmountStr = swapAmountBig.toString();
|
||||
|
||||
// Quote via getAmountsOut на swapAmount (не на full amount)
|
||||
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),
|
||||
routerContract.getAmountsOut(swapAmountStr, path),
|
||||
HTTP_TIMEOUT_MS,
|
||||
'PancakeSwap quote timed out',
|
||||
);
|
||||
@@ -184,6 +201,7 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
||||
const minOut = expectedOut.mul(10000 - slippageBps).div(10000);
|
||||
|
||||
// Allowance check — approveRequired? (только для token-in)
|
||||
// Check allowance >= swapAmount (не full amount — fee tx сам payer'ом юзером).
|
||||
let approveRequired = false;
|
||||
if (fromUpper !== 'BNB') {
|
||||
const tokenContract = new ethers.Contract(BSC_TOKEN_MAP[fromUpper], ERC20_ABI, provider);
|
||||
@@ -193,7 +211,7 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
||||
HTTP_TIMEOUT_MS,
|
||||
'Allowance check timed out',
|
||||
);
|
||||
approveRequired = currentAllowance.lt(ethers.BigNumber.from(p.amount));
|
||||
approveRequired = currentAllowance.lt(ethers.BigNumber.from(swapAmountStr));
|
||||
} catch {
|
||||
// Allowance check failed → assume approve needed (conservative)
|
||||
approveRequired = true;
|
||||
@@ -201,7 +219,9 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
||||
}
|
||||
|
||||
// Estimate gas (rough; without simulating actual approve)
|
||||
let estGas = ethers.BigNumber.from(approveRequired ? 330_000 : 250_000);
|
||||
// +21k для fee tx (native transfer) или +65k (ERC-20 transfer) добавляется на off-chain double-tx.
|
||||
const feeTxGas = fromUpper === 'BNB' ? 21_000 : 65_000;
|
||||
let estGas = ethers.BigNumber.from(approveRequired ? 330_000 + feeTxGas : 250_000 + feeTxGas);
|
||||
try {
|
||||
const deadline = Math.floor(Date.now() / 1000) + 1200;
|
||||
let swapData: string;
|
||||
@@ -211,17 +231,17 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
||||
'swapExactETHForTokensSupportingFeeOnTransferTokens',
|
||||
[minOut, path, p.fromAddress, deadline],
|
||||
);
|
||||
value = ethers.BigNumber.from(p.amount);
|
||||
value = ethers.BigNumber.from(swapAmountStr);
|
||||
} else if (toUpper === 'BNB') {
|
||||
swapData = routerContract.interface.encodeFunctionData(
|
||||
'swapExactTokensForETHSupportingFeeOnTransferTokens',
|
||||
[p.amount, minOut, path, p.fromAddress, deadline],
|
||||
[swapAmountStr, minOut, path, p.fromAddress, deadline],
|
||||
);
|
||||
value = ethers.BigNumber.from(0);
|
||||
} else {
|
||||
swapData = routerContract.interface.encodeFunctionData(
|
||||
'swapExactTokensForTokensSupportingFeeOnTransferTokens',
|
||||
[p.amount, minOut, path, p.fromAddress, deadline],
|
||||
[swapAmountStr, minOut, path, p.fromAddress, deadline],
|
||||
);
|
||||
value = ethers.BigNumber.from(0);
|
||||
}
|
||||
@@ -233,14 +253,15 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
||||
data: swapData,
|
||||
value,
|
||||
});
|
||||
estGas = estimated.mul(120).div(100);
|
||||
const minGas = ethers.BigNumber.from(150_000);
|
||||
const maxGas = ethers.BigNumber.from(500_000);
|
||||
// +feeTxGas на дополнительную fee transfer
|
||||
estGas = estimated.mul(120).div(100).add(feeTxGas);
|
||||
const minGas = ethers.BigNumber.from(150_000 + feeTxGas);
|
||||
const maxGas = ethers.BigNumber.from(500_000 + feeTxGas);
|
||||
if (estGas.lt(minGas)) estGas = minGas;
|
||||
if (estGas.gt(maxGas)) estGas = maxGas;
|
||||
} else {
|
||||
// Сложить approve (~80k) + swap (~250k) для approximate fee
|
||||
estGas = ethers.BigNumber.from(330_000);
|
||||
// Сложить approve (~80k) + fee tx + swap (~250k)
|
||||
estGas = ethers.BigNumber.from(330_000 + feeTxGas);
|
||||
}
|
||||
} catch {
|
||||
// Estimate failed — оставляем default
|
||||
@@ -249,8 +270,8 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
||||
const networkFeeWei = estGas.mul(maxFeePerGas);
|
||||
|
||||
return {
|
||||
amountIn: p.amount,
|
||||
expectedOut: expectedOut.toString(),
|
||||
amountIn: p.amount, // gross — что юзер отдаёт всего
|
||||
expectedOut: expectedOut.toString(), // based on swapAmount (после fee)
|
||||
minOut: minOut.toString(),
|
||||
slippageBps,
|
||||
route: [fromUpper, toUpper],
|
||||
@@ -260,6 +281,11 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
||||
asset: 'BNB',
|
||||
amount: networkFeeWei.toString(),
|
||||
},
|
||||
appFee: {
|
||||
asset: fromUpper, // BNB / USDT / etc.
|
||||
amount: feeAmountBig.toString(),
|
||||
recipient: BSC_FEE_WALLET,
|
||||
},
|
||||
fromDecimals: bscTokenDecimals(fromUpper),
|
||||
toDecimals: bscTokenDecimals(toUpper),
|
||||
};
|
||||
@@ -274,7 +300,7 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
||||
*
|
||||
* Returns: { approveTxid?, swapTxid }
|
||||
*/
|
||||
export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: string; swapTxid: string }> {
|
||||
export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: string; feeTxid?: string; swapTxid: string }> {
|
||||
const fromUpper = p.from.toUpperCase();
|
||||
const toUpper = p.to.toUpperCase();
|
||||
|
||||
@@ -297,6 +323,14 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
||||
const provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID);
|
||||
const signer = wallet.connect(provider);
|
||||
|
||||
// App fee 0.7% — off-chain double-tx. Применяется ВСЕГДА на BSC swap.
|
||||
const feeAmountBig = computeBscFee(p.amount);
|
||||
const swapAmountBig = BigInt(p.amount) - feeAmountBig;
|
||||
if (swapAmountBig <= 0n) {
|
||||
throw new Error('amount too small after 0.7% fee deduction');
|
||||
}
|
||||
const swapAmountStr = swapAmountBig.toString();
|
||||
|
||||
// Gas tier
|
||||
const tier: FeeTier = p.feeTier ?? 'normal';
|
||||
const fee = await getEvmFeeForTier('BSC', tier);
|
||||
@@ -307,7 +341,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
||||
throw new Error('Gas fee invariant violated');
|
||||
}
|
||||
|
||||
// minOut: locked from quote OR re-quote on-chain
|
||||
// minOut: locked from quote OR re-quote on-chain (на swapAmount, не p.amount!)
|
||||
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||||
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
|
||||
let amountOutMin: ethers.BigNumber;
|
||||
@@ -315,7 +349,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
||||
amountOutMin = ethers.BigNumber.from(p.lockedMinOut);
|
||||
} else {
|
||||
const amountsOut: ethers.BigNumber[] = await withTimeout(
|
||||
routerContract.getAmountsOut(p.amount, path),
|
||||
routerContract.getAmountsOut(swapAmountStr, path),
|
||||
HTTP_TIMEOUT_MS,
|
||||
'PancakeSwap quote timed out',
|
||||
);
|
||||
@@ -334,9 +368,11 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
||||
};
|
||||
|
||||
let approveTxid: string | undefined;
|
||||
let feeTxid: string | undefined;
|
||||
let nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||||
|
||||
// ── Token-to-anything: check allowance, approve if needed, wait 1 conf ──
|
||||
// Approve на swapAmount (не full amount — fee идёт через отдельную transfer tx).
|
||||
if (fromUpper !== 'BNB') {
|
||||
const tokenAddress = BSC_TOKEN_MAP[fromUpper];
|
||||
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
|
||||
@@ -345,15 +381,15 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
||||
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]);
|
||||
if (currentAllowance.lt(ethers.BigNumber.from(swapAmountStr))) {
|
||||
const approveData = tokenContract.interface.encodeFunctionData('approve', [PANCAKE_ROUTER, swapAmountStr]);
|
||||
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
|
||||
gasLimit: ethers.BigNumber.from(80_000),
|
||||
...feeFields,
|
||||
};
|
||||
const approveSent = await withTimeout(
|
||||
@@ -362,13 +398,46 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
||||
'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 ──
|
||||
// ── App fee 0.7% transfer ПЕРЕД swap. Если fee tx revert'нёт — swap НЕ делается. ──
|
||||
// Для BNB (native) — простой transfer на BSC_FEE_WALLET (gas 21k).
|
||||
// Для ERC-20 — token.transfer(BSC_FEE_WALLET, feeAmount) (gas ~65k).
|
||||
if (feeAmountBig > 0n) {
|
||||
const feeTx: ethers.providers.TransactionRequest = fromUpper === 'BNB'
|
||||
? {
|
||||
to: BSC_FEE_WALLET,
|
||||
value: ethers.BigNumber.from(feeAmountBig.toString()),
|
||||
chainId: BSC_CHAIN_ID,
|
||||
nonce,
|
||||
gasLimit: ethers.BigNumber.from(21_000),
|
||||
...feeFields,
|
||||
}
|
||||
: {
|
||||
to: BSC_TOKEN_MAP[fromUpper],
|
||||
data: new ethers.utils.Interface(['function transfer(address,uint256) returns (bool)'])
|
||||
.encodeFunctionData('transfer', [BSC_FEE_WALLET, feeAmountBig.toString()]),
|
||||
value: 0,
|
||||
chainId: BSC_CHAIN_ID,
|
||||
nonce,
|
||||
gasLimit: ethers.BigNumber.from(65_000),
|
||||
...feeFields,
|
||||
};
|
||||
const feeSent = await withTimeout(
|
||||
signer.sendTransaction(feeTx),
|
||||
HTTP_TIMEOUT_MS,
|
||||
'fee tx broadcast timed out',
|
||||
);
|
||||
feeTxid = feeSent.hash;
|
||||
// Wait 1 confirmation — гарантия что fee пришёл до swap.
|
||||
await withTimeout(feeSent.wait(1), 30_000, 'fee tx confirmation timed out');
|
||||
nonce += 1;
|
||||
}
|
||||
|
||||
// ── Build swap tx (на swapAmount, не p.amount) ──
|
||||
let swapData: string;
|
||||
let value: ethers.BigNumber;
|
||||
if (fromUpper === 'BNB') {
|
||||
@@ -376,18 +445,17 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
||||
'swapExactETHForTokensSupportingFeeOnTransferTokens',
|
||||
[amountOutMin, path, wallet.address, deadline],
|
||||
);
|
||||
value = ethers.BigNumber.from(p.amount);
|
||||
value = ethers.BigNumber.from(swapAmountStr);
|
||||
} else if (toUpper === 'BNB') {
|
||||
swapData = routerContract.interface.encodeFunctionData(
|
||||
'swapExactTokensForETHSupportingFeeOnTransferTokens',
|
||||
[p.amount, amountOutMin, path, wallet.address, deadline],
|
||||
[swapAmountStr, 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],
|
||||
[swapAmountStr, amountOutMin, path, wallet.address, deadline],
|
||||
);
|
||||
value = ethers.BigNumber.from(0);
|
||||
}
|
||||
@@ -424,7 +492,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
||||
HTTP_TIMEOUT_MS,
|
||||
'swap broadcast timed out',
|
||||
);
|
||||
return { approveTxid, swapTxid: swapSent.hash };
|
||||
return { approveTxid, feeTxid, swapTxid: swapSent.hash };
|
||||
}
|
||||
|
||||
// ─── TRX SunSwap ─────────────────────────────────────────────────────
|
||||
@@ -1168,7 +1236,7 @@ export async function quoteSol(p: QuoteSolParams): Promise<QuoteSolResult> {
|
||||
* SOL execute (формерно `swapSol`) — Jupiter chained swap.
|
||||
* Принимает либо `jupiterQuoteResponse` (locked from quote step) либо re-fetch'ит.
|
||||
*/
|
||||
export async function executeSol(p: ExecuteSolParams): Promise<{ signature: string }> {
|
||||
export async function executeSol(p: ExecuteSolParams): Promise<{ signature: string; feeTxid?: string; feeAmount?: string }> {
|
||||
const seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||||
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
||||
if (!key || key.length !== 32) {
|
||||
@@ -1181,6 +1249,41 @@ export async function executeSol(p: ExecuteSolParams): Promise<{ signature: stri
|
||||
|
||||
const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps);
|
||||
|
||||
// ── App fee 0.7% (atomic — fee SOL/SPL tx ПЕРЕД Jupiter swap) ──
|
||||
// Fee in inputMint same as swap input. SOL native → fee in lamports; SPL → fee in token.
|
||||
// Jupiter quote/swap remains intact (user видит swap rate на full amount; fee — extra cost).
|
||||
let feeTxid: string | undefined;
|
||||
let feeAmount: string | undefined;
|
||||
try {
|
||||
const { computeAppFee, APP_FEE_WALLET_SOL } = await import('../lib/app-fee');
|
||||
const { SOL_TOKENS } = await import('../lib/token-registry');
|
||||
const feeBig = computeAppFee(p.amount);
|
||||
if (feeBig > 0n) {
|
||||
const SOL_NATIVE_MINT = 'So11111111111111111111111111111111111111112';
|
||||
const isNative = p.inputMint === SOL_NATIVE_MINT || p.inputMint === '11111111111111111111111111111111';
|
||||
const splToken = isNative ? null : SOL_TOKENS.find((t) => t.mint === p.inputMint);
|
||||
if (!isNative && !splToken) {
|
||||
throw new Error(`SOL swap fee: SPL mint ${p.inputMint} not in registry`);
|
||||
}
|
||||
// Use sendSol через signAndBroadcast wrapper (existing helper handles SPL ATA + native).
|
||||
const { signAndBroadcast: _swapFeeSend } = await import('./wallet-signer.service');
|
||||
const feeRes = await _swapFeeSend({
|
||||
chain: 'SOL',
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
to: APP_FEE_WALLET_SOL,
|
||||
amount: feeBig.toString(),
|
||||
token: splToken ? splToken.symbol : undefined,
|
||||
});
|
||||
feeTxid = feeRes.txid;
|
||||
feeAmount = feeBig.toString();
|
||||
logger.info(`SOL swap fee: ${feeAmount} ${splToken ? splToken.symbol : 'lamports'} → ${APP_FEE_WALLET_SOL} txid=${feeTxid}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Fee failed — abort swap (atomic). User не видит quote-locked swap, fee не списано (revert before send).
|
||||
throw new Error(`SOL swap fee failed (swap NOT executed): ${err?.message || err}`);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
|
||||
|
||||
@@ -1242,5 +1345,5 @@ export async function executeSol(p: ExecuteSolParams): Promise<{ signature: stri
|
||||
logger.warn(`SOL Jupiter swap confirm warning (${name}): ${err.message}. sig=${sig}`);
|
||||
}
|
||||
|
||||
return { signature: sig };
|
||||
return { signature: sig, feeTxid, feeAmount };
|
||||
}
|
||||
|
||||
854
apps/api/src/services/wallet-signer-bridge.ts
Normal file
854
apps/api/src/services/wallet-signer-bridge.ts
Normal file
@@ -0,0 +1,854 @@
|
||||
/**
|
||||
* Bridge-specific signers/helpers — отдельный файл чтобы не разрастать `wallet-signer.service.ts`.
|
||||
*
|
||||
* Чем отличается от обычного signAndBroadcast:
|
||||
* - EVM `signAndBroadcastEvmApprove` — ERC20.approve(spender, amount) для bridge router'а;
|
||||
* включает wait 1 conf чтобы next tx видел свежий allowance.
|
||||
* - `readErc20Allowance` — direct view call (без подписи) для pre-check.
|
||||
* - TRX/BTC — bridge-specific path для unsigned tx от Relay/LiFi.
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import * as bip39 from 'bip39';
|
||||
import { BIP32Factory } from 'bip32';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { env } from '../config/env';
|
||||
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
|
||||
import { pickProxiedEvmProvider } from '../lib/outbound-proxy';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
|
||||
const ETH_RPCS = [
|
||||
'https://ethereum-rpc.publicnode.com',
|
||||
'https://eth.llamarpc.com',
|
||||
'https://rpc.ankr.com/eth',
|
||||
];
|
||||
const BSC_RPCS = [
|
||||
'https://bsc-dataseed.binance.org',
|
||||
'https://bsc-dataseed1.binance.org',
|
||||
'https://bsc-dataseed2.binance.org',
|
||||
'https://bsc.publicnode.com',
|
||||
];
|
||||
|
||||
const TRONGRID = 'https://api.trongrid.io';
|
||||
const BLOCKSTREAM = 'https://blockstream.info/api';
|
||||
|
||||
const HTTP_TIMEOUT_MS = 20_000;
|
||||
const APPROVE_GAS_LIMIT = 80_000; // EIP-2 approve ~50k базовая + overhead
|
||||
const MAX_GAS_PRICE_GWEI = 500;
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function approve(address spender, uint256 amount) returns (bool)',
|
||||
'function allowance(address owner, address spender) view returns (uint256)',
|
||||
'function balanceOf(address owner) view returns (uint256)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Структурированная ошибка для balance pre-check. Controller'ы маппят `code === 'INSUFFICIENT_BALANCE'`
|
||||
* в HTTP 400 с human-readable message.
|
||||
*/
|
||||
export class InsufficientBalanceError extends Error {
|
||||
code = 'INSUFFICIENT_BALANCE' as const;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InsufficientBalanceError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Структурированная ошибка для pre-broadcast simulation revert. Controller'ы маппят
|
||||
* `code === 'SIMULATION_FAILED'` в HTTP 400. Поскольку simulation НЕ broadcast'ит — fees
|
||||
* пользователя не сгорают.
|
||||
*/
|
||||
export class BridgeSimulationError extends Error {
|
||||
code = 'SIMULATION_FAILED' as const;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BridgeSimulationError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EVM helpers ──────────────────────────────────────────────────────
|
||||
|
||||
export interface SignEvmApproveParams {
|
||||
chain: 'ETH' | 'BSC';
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
spender: string; // bridge router address (LiFi diamond / Relay router)
|
||||
token: string; // ERC20 contract address
|
||||
amount: string; // exact approve amount (smallest units, decimal string)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign + broadcast ERC20.approve(spender, amount). Waits 1 confirmation
|
||||
* перед return — bridge tx сразу следующий видит свежий allowance.
|
||||
*/
|
||||
export async function signAndBroadcastEvmApprove(p: SignEvmApproveParams): Promise<{ txid: string }> {
|
||||
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
||||
if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
|
||||
throw new Error(`Derived ${p.chain} address ${wallet.address} ≠ stored ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
|
||||
const signer = wallet.connect(provider);
|
||||
const token = new ethers.Contract(p.token, ERC20_ABI, signer);
|
||||
|
||||
// Fee tier: используем provider.getFeeData() — это OK для approve (low priority).
|
||||
const feeData = await provider.getFeeData();
|
||||
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
|
||||
let maxFeePerGas = feeData.maxFeePerGas ?? ethers.utils.parseUnits('30', 'gwei');
|
||||
let maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.utils.parseUnits('1', 'gwei');
|
||||
if (maxFeePerGas.gt(capWei)) maxFeePerGas = capWei;
|
||||
if (maxPriorityFeePerGas.gt(maxFeePerGas)) maxPriorityFeePerGas = maxFeePerGas;
|
||||
|
||||
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||||
const data = token.interface.encodeFunctionData('approve', [p.spender, ethers.BigNumber.from(p.amount)]);
|
||||
|
||||
const sent = await signer.sendTransaction({
|
||||
to: p.token,
|
||||
data,
|
||||
value: 0,
|
||||
chainId: expectedChainId,
|
||||
nonce,
|
||||
gasLimit: APPROVE_GAS_LIMIT,
|
||||
type: 2,
|
||||
maxFeePerGas,
|
||||
maxPriorityFeePerGas,
|
||||
});
|
||||
// Wait 1 conf чтобы bridge tx (next) видел updated allowance
|
||||
await Promise.race([
|
||||
sent.wait(1),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('approve confirm timeout')), 60_000)),
|
||||
]);
|
||||
logger.info(`EVM approve confirmed: chain=${p.chain} token=${p.token} spender=${p.spender} amount=${p.amount} txid=${sent.hash}`);
|
||||
return { txid: sent.hash };
|
||||
}
|
||||
|
||||
export interface ReadErc20AllowanceParams {
|
||||
chain: 'ETH' | 'BSC';
|
||||
token: string;
|
||||
owner: string;
|
||||
spender: string;
|
||||
}
|
||||
|
||||
export async function readErc20Allowance(p: ReadErc20AllowanceParams): Promise<bigint> {
|
||||
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
|
||||
const token = new ethers.Contract(p.token, ERC20_ABI, provider);
|
||||
const res: ethers.BigNumber = await token.allowance(p.owner, p.spender);
|
||||
return BigInt(res.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read EVM native balance (BNB / ETH) for an address. Smallest units (wei) as bigint.
|
||||
*/
|
||||
export async function readEvmNativeBalance(chain: 'ETH' | 'BSC', address: string): Promise<bigint> {
|
||||
const chainId = chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
const provider = await pickProxiedEvmProvider(rpcs, chainId);
|
||||
const bal: ethers.BigNumber = await provider.getBalance(address);
|
||||
return BigInt(bal.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ERC20 token balance (USDT / USDC / etc.) for an address. Smallest units as bigint.
|
||||
*/
|
||||
export async function readErc20Balance(chain: 'ETH' | 'BSC', token: string, owner: string): Promise<bigint> {
|
||||
const chainId = chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
const provider = await pickProxiedEvmProvider(rpcs, chainId);
|
||||
const c = new ethers.Contract(token, ERC20_ABI, provider);
|
||||
const res: ethers.BigNumber = await c.balanceOf(owner);
|
||||
return BigInt(res.toString());
|
||||
}
|
||||
|
||||
// ─── SOL balance helpers ──────────────────────────────────────────────
|
||||
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
|
||||
/**
|
||||
* Read SOL native balance in lamports. Используется для bridge-execute pre-check
|
||||
* чтобы сразу отвергать "insufficient lamports" simulation errors с человеческим message.
|
||||
*/
|
||||
export async function readSolBalance(address: string): Promise<bigint> {
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getBalance',
|
||||
params: [address],
|
||||
});
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const lamports = res?.result?.value;
|
||||
if (typeof lamports !== 'number') {
|
||||
throw new Error(`SOL balance read failed: ${JSON.stringify(res).slice(0, 200)}`);
|
||||
}
|
||||
return BigInt(lamports);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read SPL token balance (USDC/USDT/...) для SOL owner. Returns smallest units.
|
||||
* Если token account не существует (юзер ни разу не получал token) — возвращает 0n.
|
||||
*/
|
||||
export async function readSplTokenBalance(owner: string, mint: string): Promise<bigint> {
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getTokenAccountsByOwner',
|
||||
params: [owner, { mint }, { encoding: 'jsonParsed' }],
|
||||
});
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const accounts = res?.result?.value || [];
|
||||
let total = 0n;
|
||||
for (const acc of accounts) {
|
||||
const raw = acc?.account?.data?.parsed?.info?.tokenAmount?.amount;
|
||||
if (typeof raw === 'string') total += BigInt(raw);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─── TRX bridge helpers ───────────────────────────────────────────────
|
||||
|
||||
export interface SignRawTronParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
contractAddress: string; // TRC20 token / LiFi router (T...base58)
|
||||
callData: string; // hex calldata (без 0x ИЛИ с 0x — нормализуем)
|
||||
callValue: bigint; // TRX amount в sun (0 для most contract calls)
|
||||
feeLimit: number; // максимум sun сжигается на energy/bandwidth (typical 30-150 TRX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign + broadcast arbitrary Tron smart-contract call. Используется для bridge'а
|
||||
* через LiFi/Jumper (которые возвращают raw contract call для TRC20 token approve / bridge).
|
||||
*
|
||||
* Flow (HTTP-only через TronGrid, no tronweb lib):
|
||||
* 1. POST /wallet/triggersmartcontract (build unsigned tx)
|
||||
* 2. MITM check: recompute txID, verify expiration/timestamp bounds, verify owner/contract
|
||||
* 3. Sign (ECDSA secp256k1, same as EVM signing с recoveryParam append)
|
||||
* 4. POST /wallet/broadcasttransaction
|
||||
*/
|
||||
export async function signAndBroadcastRawTron(p: SignRawTronParams): Promise<{ txid: string }> {
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
|
||||
const fromTronAddr = ethAddressToTron(wallet.address);
|
||||
if (fromTronAddr !== p.expectedFromAddress) {
|
||||
throw new Error(`Derived TRX address ${fromTronAddr} ≠ stored ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
// Normalize calldata. triggersmartcontract API принимает либо:
|
||||
// - function_selector (canonical string "transfer(address,uint256)") + parameter (hex args)
|
||||
// → TronGrid keccak'ит selector NAME и prepend'ит к parameter
|
||||
// - data (full hex calldata = selector + params) → используется как-есть
|
||||
//
|
||||
// Если у нас неизвестный selector (LiFi bridge calls, custom routers) — мы НЕ можем
|
||||
// передать "0x..." как function_selector (TronGrid keccak'нёт строку и получит
|
||||
// полностью другие 4 байта → contract revert). Используем `data` напрямую.
|
||||
let data = p.callData.startsWith('0x') ? p.callData.slice(2) : p.callData;
|
||||
if (data.length < 8) throw new Error('TRX call data too short (need >= 4-byte selector)');
|
||||
const selector8 = data.slice(0, 8);
|
||||
const knownCanonical = lookupKnownSelector(selector8);
|
||||
|
||||
const callBody: any = {
|
||||
owner_address: fromTronAddr,
|
||||
contract_address: p.contractAddress,
|
||||
fee_limit: p.feeLimit,
|
||||
call_value: p.callValue > 0n ? Number(p.callValue) : 0,
|
||||
visible: true,
|
||||
};
|
||||
if (knownCanonical) {
|
||||
callBody.function_selector = knownCanonical;
|
||||
callBody.parameter = data.slice(8);
|
||||
} else {
|
||||
// Unknown selector (LiFi bridge call) — pass full calldata as-is
|
||||
callBody.data = data;
|
||||
}
|
||||
|
||||
// ── Fix 2: pre-simulation guard ──
|
||||
// Dry-run через triggerconstantcontract (read-only) ДО build+broadcast'а.
|
||||
// Если контракт revert'нёт на simulation — НЕ broadcast'им, fees не сгорают.
|
||||
// Это catches LiFi/Relay stale quotes + bad calldata + insufficient allowance + др.
|
||||
try {
|
||||
const simRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(callBody),
|
||||
});
|
||||
const simOk = simRes?.result?.result === true;
|
||||
if (!simOk) {
|
||||
const rawMsg = simRes?.result?.message;
|
||||
const msgDecoded = rawMsg
|
||||
? Buffer.from(rawMsg, 'hex').toString().replace(/[ | ||||