Files
cryptowallet/apps/api/src/lib/outbound-proxy.ts
ZOMBIIIIIII 11ee5a2c7f initlast
2026-05-14 15:20:00 +03:00

158 lines
5.6 KiB
TypeScript
Raw Blame History

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