/** * Outbound HTTP/HTTPS proxy для swap + bridge endpoints (только). * * Когда `OUTBOUND_PROXY_URL` задан в env — все calls к Jupiter / Relay / EVM RPC / * Solana RPC / TronGrid из: * - swap-orchestrator.service.ts (custodial swap BSC/TRX/SOL) * - routes/relay-proxy.routes.ts (Relay /quote, /execute, /intents/status) * - wallet-signer.service.ts:signAndBroadcastRawEvm (bridge sign-raw) * - wallet-signer.service.ts:signAndBroadcastSolanaTx + signAndBroadcastSolanaInstructions * идут через proxy. * * НЕ через proxy (direct outbound): * - /balance, /transactions, /send (basic) * - /prices (CoinGecko) * - gas-suggestions * - legacy /api/{btc,tron,sol/swap,tron/swap,bsc/swap}/* proxy routes * * Proxy формат (squid-style, без auth по дефолту): * OUTBOUND_PROXY_URL=http://37.220.84.34:3128 * OUTBOUND_PROXY_URL=http://user:pass@host:port (если нужен auth) * OUTBOUND_PROXY_URL=https://host:port (если TLS до прокси) * * Если env пустой — fallback на native fetch / прямой Connection. */ import { ProxyAgent, fetch as undiciFetch } from 'undici'; import { ethers } from 'ethers'; import { Connection, type Commitment } from '@solana/web3.js'; import { logger } from './logger'; let _agent: ProxyAgent | null = null; let _agentChecked = false; /** * Lazy-init `ProxyAgent` from `OUTBOUND_PROXY_URL` env. Returns `null` if env is empty * (callers should fallback to native fetch). */ export function getProxyAgent(): ProxyAgent | null { if (_agentChecked) return _agent; _agentChecked = true; const url = process.env.OUTBOUND_PROXY_URL?.trim(); if (!url) return null; try { _agent = new ProxyAgent({ uri: url, // Некоторые RPC endpoints используют неполные cert chains через прокси; // для swap/bridge transport-level MITM не критичен (sigs проверяются on-chain). requestTls: { rejectUnauthorized: false }, }); // Маскируем basic-auth credentials в логе const masked = url.replace(/:\/\/[^@]+@/, '://***:***@'); logger.info(`Outbound proxy enabled (swap+bridge only): ${masked}`); return _agent; } catch (err: any) { logger.error(`Failed to init OUTBOUND_PROXY_URL=${url}: ${err?.message || 'unknown'}`); return null; } } /** * fetch() через proxy если задан, иначе обычный globalThis.fetch. * Сигнатура совместима с native fetch. */ export async function proxiedFetch( input: string | URL, init?: RequestInit & { signal?: AbortSignal }, ): Promise { const agent = getProxyAgent(); if (!agent) { return fetch(input as any, init as any); } // undici.fetch поддерживает `dispatcher` для per-call routing через ProxyAgent. // Возвращаемый тип Response совместим с native — приводим через unknown для TS. return undiciFetch(input as any, { ...(init as any), dispatcher: agent, }) as unknown as Response; } /** * ethers v5 JsonRpcProvider с overridden `send()` — отправляет JSON-RPC через proxiedFetch. * * ethers v5 internal fetchJson не использует globalThis.fetch + не поддерживает proxy agent, * поэтому override `send()` — единственный надёжный путь. */ export class ProxiedJsonRpcProvider extends ethers.providers.StaticJsonRpcProvider { private _id = 1; async send(method: string, params: unknown[]): Promise { const url = (this.connection as any).url as string; const body = JSON.stringify({ jsonrpc: '2.0', id: this._id++, method, params, }); const res = await proxiedFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`RPC ${method} HTTP ${res.status}: ${text.slice(0, 200)}`); } const json = (await res.json()) as { result?: unknown; error?: { code?: number; message?: string; data?: unknown } }; if (json.error) { const e: any = new Error(json.error.message || `RPC ${method} error`); e.code = json.error.code; e.data = json.error.data; throw e; } return json.result; } } /** * Failover: пробуем `rpcs` последовательно через proxied JSON-RPC, возвращаем первый-живой. * Замена `pickProvider` для swap/bridge code paths. */ export async function pickProxiedEvmProvider( rpcs: string[], chainId: number, ): Promise { let lastErr: any; for (const url of rpcs) { const p = new ProxiedJsonRpcProvider(url, chainId); try { await Promise.race([ p.getBlockNumber(), new Promise((_, reject) => setTimeout(() => reject(new Error('rpc_alive_timeout')), 5000), ), ]); return p; } catch (err) { lastErr = err; } } throw new Error( `All proxied RPCs failed (chainId=${chainId}, n=${rpcs.length}): ${(lastErr as any)?.message || 'unknown'}`, ); } /** * Solana Connection с custom fetch = proxiedFetch. * @solana/web3.js ConnectionConfig поддерживает поле `fetch` — наш entry point. */ export function getProxiedSolConnection( rpcUrl: string, commitment: Commitment = 'confirmed', ): Connection { return new Connection(rpcUrl, { commitment, fetch: proxiedFetch as unknown as typeof fetch, }); }