MovixOpenSource/API/Mainapi/utils/chainExplorer.js
Movix 1401dc20f3 tird release
mon compte github a été détecté comme du spam je crois
2026-04-28 17:55:00 +02:00

130 lines
3.7 KiB
JavaScript

/**
* Chain explorer client — replaces BlockCypher for VIP invoice polling.
* BTC -> mempool.space, LTC -> litecoinspace.org (both ESplora-compatible).
* Rotates through SOCKS5_PROXIES to multiply per-IP rate-limit headroom.
*/
const axios = require('axios');
const { PROXIES, pickNextSocks5Proxy, getProxyAgent } = require('./proxyManager');
const DEFAULT_BASES = Object.freeze({
btc: 'https://mempool.space/api',
ltc: 'https://litecoinspace.org/api'
});
const TIP_HEIGHT_TTL_MS = 30_000;
const MAX_ATTEMPTS = 3;
const REQUEST_TIMEOUT_MS = 15_000;
const PER_PROXY_MIN_INTERVAL_MS = 1_100;
const RETRY_BACKOFF_MS = 400;
const tipHeightCache = new Map();
function normalizeBase(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
function getExplorerBase(coin) {
if (coin === 'btc') {
return normalizeBase(process.env.BTC_EXPLORER_API) || DEFAULT_BASES.btc;
}
if (coin === 'ltc') {
return normalizeBase(process.env.LTC_EXPLORER_API) || DEFAULT_BASES.ltc;
}
throw new Error(`Coin non supporté: ${coin}`);
}
function shouldRetry(error) {
if (!error.response) return true;
const status = error.response.status;
return status === 429 || (status >= 500 && status < 600);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function callExplorer(coin, path, { responseType = 'json' } = {}) {
const url = `${getExplorerBase(coin)}${path}`;
let lastError = null;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
let agent = null;
let proxyLabel = 'direct';
if (PROXIES.length > 0) {
const proxy = await pickNextSocks5Proxy({
minIntervalMs: PER_PROXY_MIN_INTERVAL_MS,
waitTimeoutMs: 1_500
});
if (proxy) {
agent = getProxyAgent(proxy);
proxyLabel = `${proxy.host}:${proxy.port}`;
}
}
console.log(
`[CHAIN EXPLORER] ${coin.toUpperCase()} ${path} via ${proxyLabel}${attempt > 0 ? ` (retry ${attempt})` : ''}`
);
try {
const response = await axios.get(url, {
timeout: REQUEST_TIMEOUT_MS,
responseType,
httpAgent: agent || undefined,
httpsAgent: agent || undefined,
proxy: false,
validateStatus: (status) => status >= 200 && status < 500
});
if (response.status === 404) {
return response;
}
if (response.status >= 400) {
const error = new Error(`Explorer ${coin.toUpperCase()} ${response.status} via ${proxyLabel}`);
error.response = response;
throw error;
}
return response;
} catch (error) {
lastError = error;
if (attempt < MAX_ATTEMPTS - 1 && shouldRetry(error)) {
await sleep(RETRY_BACKOFF_MS * (attempt + 1));
continue;
}
throw error;
}
}
throw lastError || new Error(`Explorer ${coin.toUpperCase()} indisponible`);
}
async function fetchTipHeight(coin) {
const cached = tipHeightCache.get(coin);
if (cached && Date.now() - cached.fetchedAt < TIP_HEIGHT_TTL_MS) {
return cached.height;
}
const response = await callExplorer(coin, '/blocks/tip/height', { responseType: 'text' });
const height = Number.parseInt(String(response.data).trim(), 10);
if (!Number.isFinite(height) || height <= 0) {
throw new Error(`Tip height invalide pour ${coin.toUpperCase()}`);
}
tipHeightCache.set(coin, { height, fetchedAt: Date.now() });
return height;
}
async function fetchAddressTxs(coin, address) {
const response = await callExplorer(coin, `/address/${encodeURIComponent(address)}/txs`);
if (response.status === 404) {
return [];
}
return Array.isArray(response.data) ? response.data : [];
}
module.exports = {
fetchAddressTxs,
fetchTipHeight,
getExplorerBase
};