mempool/unfurler/src/routes.ts
2024-05-08 19:59:50 +00:00

324 lines
8.3 KiB
TypeScript

import fetch from 'node-fetch-commonjs';
import config from './config';
import http from 'node:http';
import https from 'node:https';
const httpAgent = new http.Agent({ keepAlive: true });
const httpsAgent = new https.Agent({ keepAlive: true });
const agentSelector = function(_parsedURL: any) {
if (_parsedURL.protocol == 'http:') {
return httpAgent;
} else {
return httpsAgent;
}
}
interface Match {
render: boolean;
title: string;
fallbackImg: string;
staticImg?: string;
networkMode: string;
params?: string[];
sip?: SipTemplate;
}
interface SipTemplate {
template: string;
getData: Function;
}
async function sipFetchJSON(url, defaultVal = null) {
try {
const response = await fetch(url, { agent: agentSelector });
return response.ok ? response.json() : defaultVal;
} catch (error) {
return defaultVal;
}
}
const routes = {
about: {
title: "About",
fallbackImg: '/resources/previews/about.jpg',
},
acceleration: {
title: "Acceleration",
fallbackImg: '/resources/previews/accelerator.jpg',
},
accelerator: {
title: "Mempool Accelerator",
fallbackImg: '/resources/previews/accelerator.jpg',
},
block: {
render: true,
params: 1,
getTitle(path) {
return `Block: ${path[0]}`;
},
sip: {
template: 'block',
async getData (params: string[]) {
if (params?.length) {
let blockId = params[0];
if (blockId.length !== 64) {
blockId = await (await fetch(config.API.ESPLORA + `/block-height/${blockId}`, { agent: agentSelector })).text();
}
const [block, transactions] = await Promise.all([
sipFetchJSON(config.API.MEMPOOL + `/block/${blockId}`),
sipFetchJSON(config.API.ESPLORA + `/block/${blockId}/txids`),
])
return {
block,
transactions,
canonicalPath: `/block/${blockId}`,
};
}
}
}
},
address: {
render: true,
params: 1,
getTitle(path) {
return `Address: ${path[0]}`;
}
},
blocks: {
title: "Blocks",
fallbackImg: '/resources/previews/blocks.jpg',
},
docs: {
title: "Docs",
fallbackImg: '/resources/previews/faq.jpg',
routes: {
faq: {
title: "FAQ",
fallbackImg: '/resources/previews/faq.jpg',
},
api: {
title: "API Docs",
fallbackImg: '/resources/previews/docs-api.jpg',
}
}
},
tx: {
render: true,
params: 1,
getTitle(path) {
return `Transaction: ${path[0]}`;
},
sip: {
template: 'tx',
async getData (params: string[]) {
if (params?.length) {
let txid = params[0];
const [transaction, times, cpfp, rbf, outspends]: any[] = await Promise.all([
sipFetchJSON(config.API.ESPLORA + `/tx/${txid}`),
sipFetchJSON(config.API.MEMPOOL + `/transaction-times?txId[]=${txid}`),
sipFetchJSON(config.API.MEMPOOL + `/cpfp/${txid}`),
sipFetchJSON(config.API.MEMPOOL + `/tx/${txid}/rbf`),
sipFetchJSON(config.API.MEMPOOL + `/outspends?txId[]=${txid}`),
])
const features = transaction ? {
segwit: transaction.vin.some((v) => v.prevout && ['v0_p2wsh', 'v0_p2wpkh'].includes(v.prevout.scriptpubkey_type)),
taproot: transaction.vin.some((v) => v.prevout && v.prevout.scriptpubkey_type === 'v1_p2tr'),
rbf: transaction.vin.some((v) => v.sequence < 0xfffffffe),
} : {};
return {
transaction,
times,
cpfp,
rbf,
outspends,
features,
hex2ascii: function(hex) {
const opPush = hex.split(' ').filter((_, i, a) => i > 0 && /^OP_PUSH/.test(a[i - 1]));
if (opPush[0]) {
hex = opPush[0];
}
if (!hex) {
return '';
}
const bytes: number[] = [];
for (let i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substr(i, 2), 16));
}
return new TextDecoder('utf8').decode(Uint8Array.from(bytes)).replace(/\uFFFD/g, '').replace(/\\0/g, '');
},
}
}
}
},
routes: {
push: {
title: "Push Transaction",
fallbackImg: '/resources/previews/tx-push.jpg',
}
}
},
enterprise: {
title: "Mempool Enterprise",
fallbackImg: '/resources/previews/enterprise.jpg',
},
lightning: {
title: "Lightning",
fallbackImg: '/resources/previews/lightning.jpg',
routes: {
node: {
render: true,
params: 1,
getTitle(path) {
return `Lightning Node: ${path[0]}`;
}
},
channel: {
render: true,
params: 1,
getTitle(path) {
return `Lightning Channel: ${path[0]}`;
}
},
nodes: {
routes: {
isp: {
render: true,
params: 1,
getTitle(path) {
return `Lightning ISP: ${path[0]}`;
}
}
}
},
group: {
render: true,
params: 1,
getTitle(path) {
return `Lightning Node Group: ${path[0]}`;
}
}
}
},
mining: {
title: "Mining",
fallbackImg: '/resources/previews/mining.jpg',
routes: {
pool: {
render: true,
params: 1,
getTitle(path) {
return `Mining Pool: ${path[0]}`;
}
}
}
},
"privacy-policy": {
title: "Privacy Policy",
fallbackImg: '/resources/previews/privacy-policy.jpg',
},
rbf: {
title: "RBF",
fallbackImg: '/resources/previews/rbf.jpg',
},
sponsor: {
title: "Community Sponsors",
fallbackImg: '/resources/previews/sponsor.jpg',
},
"terms-of-service": {
title: "Terms of Service",
fallbackImg: '/resources/previews/terms-of-service.jpg',
},
"trademark-policy": {
title: "Trademark Policy",
fallbackImg: '/resources/previews/trademark-policy.jpg',
},
};
const networks = {
bitcoin: {
fallbackImg: '/resources/previews/mempool-space-preview.jpg',
routes: {
...routes // all routes supported
}
},
liquid: {
fallbackImg: '/resources/liquid/liquid-network-preview.png',
routes: { // only block, address & tx routes supported
block: routes.block,
address: routes.address,
tx: routes.tx
}
},
bisq: {
fallbackImg: '/resources/bisq/bisq-markets-preview.png',
routes: {} // no routes supported
},
onbtc: {
fallbackImg: '/resources/sv/onbtc-preview.jpg',
routes: { // only dynamic routes supported
block: routes.block,
address: routes.address,
tx: routes.tx,
mining: {
title: "Mining",
routes: {
pool: routes.mining.routes.pool,
}
},
lightning: {
title: "Lightning",
routes: routes.lightning.routes,
}
}
}
};
export function matchRoute(network: string, path: string, matchFor: string = 'render'): Match {
const match: Match = {
render: false,
title: '',
fallbackImg: '',
networkMode: 'mainnet'
}
const parts = path.slice(1).split('/').filter(p => p.length);
if (parts[0] === 'preview') {
parts.shift();
}
if (['testnet', 'signet'].includes(parts[0])) {
match.networkMode = parts.shift() || 'mainnet';
}
let route = networks[network] || networks.bitcoin;
match.fallbackImg = route.fallbackImg;
// traverse the route tree until we run out of route or tree, or hit a renderable match
while (!route[matchFor] && route.routes && parts.length && route.routes[parts[0]]) {
route = route.routes[parts[0]];
parts.shift();
if (route.fallbackImg) {
match.fallbackImg = route.fallbackImg;
}
}
// enough route parts left for title & rendering
if (route[matchFor] && parts.length >= route.params) {
match.render = route.render;
match.sip = route.sip;
match.params = parts;
}
// only use set a static image for exact matches
if (!parts.length && route.staticImg) {
match.staticImg = route.staticImg;
}
// apply the title function if present
if (route.getTitle && typeof route.getTitle === 'function') {
match.title = route.getTitle(parts);
} else {
match.title = route.title;
}
return match;
}