initlast
This commit is contained in:
157
apps/api/src/lib/outbound-proxy.ts
Normal file
157
apps/api/src/lib/outbound-proxy.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user