import { Request, Response, Router } from 'express'; const router = Router(); const BLOCKSTREAM_BASE = 'https://blockstream.info/api'; const BTC_TIMEOUT_MS = 10_000; // Validate Bitcoin address format (mainnet only) const BTC_ADDRESS_RE = /^(bc1[a-zA-HJ-NP-Z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/; router.get('/utxos/:address', getUtxos); router.get('/fee-estimates', getFeeEstimates); router.post('/broadcast', broadcastTx); export default router; /** * GET /api/btc/utxos/:address * Returns confirmed UTXOs for the given address. */ async function getUtxos(req: Request, res: Response) { const address = String(req.params.address); if (!BTC_ADDRESS_RE.test(address)) { res.status(400).json({ success: false, error: 'Invalid Bitcoin address' }); return; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), BTC_TIMEOUT_MS); try { const response = await fetch(`${BLOCKSTREAM_BASE}/address/${address}/utxo`, { headers: { Accept: 'application/json' }, signal: controller.signal, }); if (!response.ok) { res.status(response.status).json({ success: false, error: 'Blockstream API error' }); return; } const utxos = await response.json(); // Filter to confirmed only const confirmed = (utxos as Array<{ status: { confirmed: boolean }; txid: string; vout: number; value: number }>) .filter((u) => u.status?.confirmed) .map((u) => ({ txid: u.txid, vout: u.vout, value: u.value, })); res.json({ success: true, data: confirmed }); } catch (error) { if (controller.signal.aborted) { res.status(504).json({ success: false, error: 'Blockstream request timeout' }); return; } res.status(502).json({ success: false, error: 'Failed to reach Blockstream' }); } finally { clearTimeout(timeout); } } /** * GET /api/btc/fee-estimates * Returns fee rate estimates in sat/vB for different confirmation targets. */ async function getFeeEstimates(_req: Request, res: Response) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), BTC_TIMEOUT_MS); try { const response = await fetch(`${BLOCKSTREAM_BASE}/fee-estimates`, { headers: { Accept: 'application/json' }, signal: controller.signal, }); if (!response.ok) { res.status(response.status).json({ success: false, error: 'Blockstream fee estimates error' }); return; } const data = await response.json(); // Return top 3 tiers: 1-block, 3-block, 6-block confirmation targets const estimates = data as Record; res.json({ success: true, data: { fast: Math.ceil(estimates['1'] ?? estimates['2'] ?? 10), normal: Math.ceil(estimates['3'] ?? estimates['6'] ?? 5), slow: Math.ceil(estimates['6'] ?? estimates['12'] ?? 2), }, }); } catch (error) { if (controller.signal.aborted) { res.status(504).json({ success: false, error: 'Blockstream fee estimates timeout' }); return; } res.status(502).json({ success: false, error: 'Failed to reach Blockstream' }); } finally { clearTimeout(timeout); } } /** * POST /api/btc/broadcast * Broadcasts a raw transaction hex. */ async function broadcastTx(req: Request, res: Response) { const { hex } = req.body; // BTC max tx serialized ~100KB = 200_000 hex chars. Cap чтобы не abuse'или bandwidth. if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex) || hex.length > 200_000) { res.status(400).json({ success: false, error: 'Invalid transaction hex' }); return; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), BTC_TIMEOUT_MS); try { const response = await fetch(`${BLOCKSTREAM_BASE}/tx`, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: hex, signal: controller.signal, }); const text = await response.text(); if (!response.ok) { // Don't leak Blockstream error body (could contain UTXO state oracle). res.status(502).json({ success: false, error: 'BTC broadcast failed' }); return; } // Blockstream returns the txid as plain text res.json({ success: true, data: { txid: text.trim() } }); } catch (error) { if (controller.signal.aborted) { res.status(504).json({ success: false, error: 'Broadcast timeout' }); return; } res.status(502).json({ success: false, error: 'Failed to broadcast transaction' }); } finally { clearTimeout(timeout); } }