151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
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<string, number>;
|
|
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);
|
|
}
|
|
}
|