diff --git a/src/server.ts b/src/server.ts index 94dd087..75d5dbe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,11 +7,15 @@ * - WhatsOnChain API key configuration * - Custom bulk/live ingestor settings * - Event subscriptions (header and reorg listeners) + * - V1 and V2 API routes */ -import { ChaintracksService, ChaintracksServiceOptions, BlockHeader, Chaintracks, createDefaultNoDbChaintracksOptions, Services, Chain, ChaintracksFs } from '@bsv/wallet-toolbox' +import { BlockHeader, Chaintracks, createDefaultNoDbChaintracksOptions, Services, Chain, ChaintracksFs } from '@bsv/wallet-toolbox' import * as path from 'path' import * as express from 'express' +import * as bodyParser from 'body-parser' +import { createV1Routes } from './v1-routes' +import { createV2Routes } from './v2-routes' async function main() { const chain: Chain = (process.env.CHAIN as Chain) || 'main' @@ -211,19 +215,42 @@ async function main() { // Note: Services uses the chain parameter to configure network services const services = new Services(chain) - // Create ChaintracksService with custom chaintracks and services - const serviceOptions: ChaintracksServiceOptions = { - chain, - routingPrefix: '', - chaintracks, // Use our custom chaintracks instance - services, // Use our custom services instance - port - } + // Create Express app with both v1 and v2 routes + const app = express.default() + + // CORS middleware + app.use((_req: express.Request, res: express.Response, next: express.NextFunction) => { + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Headers', '*') + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + next() + }) - const service = new ChaintracksService(serviceOptions) + // Body parser for POST requests + app.use(bodyParser.json()) - // Start the ChaintracksService server - await service.startJsonRpcServer(port) + // Root endpoint + app.get('/', (_req: express.Request, res: express.Response) => { + res.json({ status: 'success', value: 'chaintracks-server' }) + }) + + // Robots.txt + app.get('/robots.txt', (_req: express.Request, res: express.Response) => { + res.type('text/plain').send('User-agent: *\nDisallow: /\n') + }) + + // Mount v1 routes (RPC-style, original API) + const v1Routes = createV1Routes({ chaintracks, services, chain }) + app.use('/', v1Routes) + + // Mount v2 routes (RESTful, go-chaintracks compatible) + const v2Routes = createV2Routes(chaintracks) + app.use('/v2', v2Routes) + + // Start the API server + const apiServer = app.listen(port, () => { + console.log(`✓ API server running on port ${port}`) + }) // Start a separate CDN server for bulk headers if enabled let cdnServer: any @@ -274,17 +301,28 @@ async function main() { }, bulkHeadersAutoExportInterval) } - console.log(`\n✓ ChaintracksService is running on port ${port}`) - console.log('\nAvailable Endpoints:') + console.log(`\n✓ Chaintracks API server is running on port ${port}`) + console.log('\nV1 Endpoints (original API):') + console.log(` GET http://localhost:${port}/getChain - Get chain name`) console.log(` GET http://localhost:${port}/getInfo - Get detailed service info`) console.log(` GET http://localhost:${port}/getPresentHeight - Get current height`) - console.log(` GET http://localhost:${port}/findChainTipHeaderHex - Get chain tip`) + console.log(` GET http://localhost:${port}/findChainTipHashHex - Get chain tip hash`) + console.log(` GET http://localhost:${port}/findChainTipHeaderHex - Get chain tip header`) + console.log(` GET http://localhost:${port}/findHeaderHexForHeight?height=N - Get header by height`) + console.log(` GET http://localhost:${port}/findHeaderHexForBlockHash?hash=X - Get header by hash`) + console.log(` GET http://localhost:${port}/getHeaders?height=N&count=M - Get multiple headers`) + console.log(` POST http://localhost:${port}/addHeaderHex - Submit new header`) + console.log('\nV2 Endpoints (RESTful API):') + console.log(` GET http://localhost:${port}/v2/network - Get chain name`) + console.log(` GET http://localhost:${port}/v2/tip - Get chain tip header`) + console.log(` GET http://localhost:${port}/v2/header/height/:height - Get header by height`) + console.log(` GET http://localhost:${port}/v2/header/hash/:hash - Get header by hash`) + console.log(` GET http://localhost:${port}/v2/headers?height=N&count=M - Get multiple headers (binary)`) if (enableBulkHeadersCDN) { - console.log(`\n CDN Endpoints (port ${cdnPort}):`) + console.log(`\nCDN Endpoints (port ${cdnPort}):`) console.log(` GET http://localhost:${cdnPort}/${chain}NetBlockHeaders.json - Bulk headers metadata`) console.log(` GET http://localhost:${cdnPort}/*.headers - Bulk header files`) } - console.log('\nAll standard ChaintracksService endpoints are available.') console.log('Press Ctrl+C to stop the server') // Enhanced shutdown with cleanup @@ -313,9 +351,18 @@ async function main() { await chaintracks.unsubscribe(headerSubscriptionId) await chaintracks.unsubscribe(reorgSubscriptionId) - // Stop the service (this also destroys chaintracks) - console.log('Stopping ChaintracksService server...') - await service.stopJsonRpcServer() + // Stop the API server + console.log('Stopping API server...') + await new Promise((resolve) => { + apiServer.close(() => { + console.log('✓ API server stopped') + resolve() + }) + }) + + // Stop chaintracks + console.log('Stopping chaintracks...') + await chaintracks.destroy() console.log('✓ All servers stopped successfully') process.exit(0) diff --git a/src/v1-routes.ts b/src/v1-routes.ts new file mode 100644 index 0000000..0367ed1 --- /dev/null +++ b/src/v1-routes.ts @@ -0,0 +1,227 @@ +/** + * V1 API Routes for ChaintracksService + * + * RPC-style API matching the original ChaintracksService endpoints + */ + +import { Router, Request, Response } from 'express' +import { Chaintracks, Services } from '@bsv/wallet-toolbox' + +interface ApiResponse { + status: 'success' | 'error' + value?: unknown + code?: string + description?: string +} + +function success(value: unknown): ApiResponse { + return { status: 'success', value } +} + +function error(code: string, description: string): ApiResponse { + return { status: 'error', code, description } +} + +export interface V1RoutesOptions { + chaintracks: Chaintracks + services?: Services + chain: string +} + +export function createV1Routes(options: V1RoutesOptions): Router { + const { chaintracks, services, chain } = options + const router = Router() + + // GET /getChain - Get blockchain network name + router.get('/getChain', async (_req: Request, res: Response) => { + try { + res.json(success(chain)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get chain')) + } + }) + + // GET /getInfo - Get detailed service info + router.get('/getInfo', async (_req: Request, res: Response) => { + try { + res.set('Cache-Control', 'no-cache') + const info = await chaintracks.getInfo() + res.json(success(info)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get info')) + } + }) + + // GET /getPresentHeight - Get current external blockchain height + router.get('/getPresentHeight', async (_req: Request, res: Response) => { + try { + res.set('Cache-Control', 'no-cache') + const height = await chaintracks.getPresentHeight() + res.json(success(height)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get present height')) + } + }) + + // GET /findChainTipHashHex - Get chain tip hash as hex + router.get('/findChainTipHashHex', async (_req: Request, res: Response) => { + try { + res.set('Cache-Control', 'no-cache') + const hash = await chaintracks.findChainTipHash() + if (!hash) { + return res.status(404).json(error('ERR_NO_TIP', 'Chain tip not found')) + } + res.json(success(hash)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get chain tip hash')) + } + }) + + // GET /findChainTipHeaderHex - Get chain tip header + router.get('/findChainTipHeaderHex', async (_req: Request, res: Response) => { + try { + res.set('Cache-Control', 'no-cache') + const header = await chaintracks.findChainTipHeader() + if (!header) { + return res.status(404).json(error('ERR_NO_TIP', 'Chain tip not found')) + } + res.json(success(header)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get chain tip header')) + } + }) + + // GET /findHeaderHexForHeight - Get header by height (query param) + router.get('/findHeaderHexForHeight', async (req: Request, res: Response) => { + try { + const height = parseInt(req.query.height as string, 10) + if (isNaN(height) || height < 0) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid or missing height parameter')) + } + + const currentHeight = await chaintracks.currentHeight() + if (height < currentHeight - 100) { + res.set('Cache-Control', 'public, max-age=3600') + } else { + res.set('Cache-Control', 'no-cache') + } + + const header = await chaintracks.findHeaderForHeight(height) + if (!header) { + return res.status(404).json(error('ERR_NOT_FOUND', `Header not found at height ${height}`)) + } + res.json(success(header)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get header')) + } + }) + + // GET /findHeaderHexForBlockHash - Get header by hash (query param) + router.get('/findHeaderHexForBlockHash', async (req: Request, res: Response) => { + try { + const hash = req.query.hash as string + if (!hash || !/^[a-fA-F0-9]{64}$/.test(hash)) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid or missing hash parameter')) + } + + const header = await chaintracks.findHeaderForBlockHash(hash) + if (!header) { + return res.status(404).json(error('ERR_NOT_FOUND', `Header not found for hash ${hash}`)) + } + + const currentHeight = await chaintracks.currentHeight() + if (header.height < currentHeight - 100) { + res.set('Cache-Control', 'public, max-age=3600') + } else { + res.set('Cache-Control', 'no-cache') + } + + res.json(success(header)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get header')) + } + }) + + // GET /getHeaders - Get multiple headers as hex string + router.get('/getHeaders', async (req: Request, res: Response) => { + try { + const height = parseInt(req.query.height as string, 10) + const count = parseInt(req.query.count as string, 10) + + if (isNaN(height) || height < 0) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid or missing height parameter')) + } + if (isNaN(count) || count <= 0) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid or missing count parameter')) + } + + const currentHeight = await chaintracks.currentHeight() + if (height < currentHeight - 100) { + res.set('Cache-Control', 'public, max-age=3600') + } else { + res.set('Cache-Control', 'no-cache') + } + + // Collect headers as hex string (160 hex chars = 80 bytes each) + let hexString = '' + for (let i = 0; i < count; i++) { + const header = await chaintracks.findHeaderForHeight(height + i) + if (!header) break + // Convert to hex - version (8) + prevHash (64) + merkleRoot (64) + time (8) + bits (8) + nonce (8) = 160 + const versionHex = header.version.toString(16).padStart(8, '0') + const timeHex = header.time.toString(16).padStart(8, '0') + const bitsHex = header.bits.toString(16).padStart(8, '0') + const nonceHex = header.nonce.toString(16).padStart(8, '0') + // Little-endian conversion for numeric fields + const versionLE = versionHex.match(/.{2}/g)!.reverse().join('') + const timeLE = timeHex.match(/.{2}/g)!.reverse().join('') + const bitsLE = bitsHex.match(/.{2}/g)!.reverse().join('') + const nonceLE = nonceHex.match(/.{2}/g)!.reverse().join('') + hexString += versionLE + header.previousHash + header.merkleRoot + timeLE + bitsLE + nonceLE + } + + res.json(success(hexString)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get headers')) + } + }) + + // POST /addHeaderHex - Submit new block header + router.post('/addHeaderHex', async (req: Request, res: Response) => { + try { + const { version, previousHash, merkleRoot, time, bits, nonce } = req.body + + if (version === undefined || !previousHash || !merkleRoot || time === undefined || bits === undefined || nonce === undefined) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Missing required header fields')) + } + + await chaintracks.addHeader({ + version, + previousHash, + merkleRoot, + time, + bits, + nonce + }) + + res.json(success(true)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to add header')) + } + }) + + // GET /getFiatExchangeRates - Get BSV exchange rates (requires services) + router.get('/getFiatExchangeRates', async (_req: Request, res: Response) => { + try { + if (!services) { + return res.status(501).json(error('ERR_NOT_IMPLEMENTED', 'Services not configured')) + } + const rates = await services.getFiatExchangeRate('USD') + res.json(success(rates)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get exchange rates')) + } + }) + + return router +} diff --git a/src/v2-routes.ts b/src/v2-routes.ts new file mode 100644 index 0000000..ebd3c0c --- /dev/null +++ b/src/v2-routes.ts @@ -0,0 +1,272 @@ +/** + * V2 API Routes for ChaintracksService + * + * RESTful API with path parameters matching go-chaintracks v2 API + */ + +import { Router, Request, Response } from 'express' +import { Chaintracks } from '@bsv/wallet-toolbox' + +interface ApiResponse { + status: 'success' | 'error' + value?: unknown + code?: string + description?: string +} + +function success(value: unknown): ApiResponse { + return { status: 'success', value } +} + +function error(code: string, description: string): ApiResponse { + return { status: 'error', code, description } +} + +// Reverse a hex string's byte order (for converting display hash to internal byte order) +function reverseHex(hex: string): Buffer { + const buf = Buffer.from(hex, 'hex') + return buf.reverse() +} + +// Convert header to 80-byte binary format +// Note: previousHash and merkleRoot are byte-reversed in JSON (display format) +// but need to be in internal byte order for binary serialization +function headerToBytes(header: { version: number; previousHash: string; merkleRoot: string; time: number; bits: number; nonce: number }): Buffer { + const buf = Buffer.alloc(80) + buf.writeUInt32LE(header.version, 0) + reverseHex(header.previousHash).copy(buf, 4) // Reverse from display to internal + reverseHex(header.merkleRoot).copy(buf, 36) // Reverse from display to internal + buf.writeUInt32LE(header.time, 68) + buf.writeUInt32LE(header.bits, 72) + buf.writeUInt32LE(header.nonce, 76) + return buf +} + +export function createV2Routes(chaintracks: Chaintracks): Router { + const router = Router() + + // GET /v2/network - Get blockchain network name + router.get('/network', async (_req: Request, res: Response) => { + try { + const network = chaintracks.chain + res.json(success(network)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get network')) + } + }) + + // GET /v2/tip - Get chain tip header + router.get('/tip', async (_req: Request, res: Response) => { + try { + res.set('Cache-Control', 'no-cache') + const header = await chaintracks.findChainTipHeader() + if (!header) { + return res.status(404).json(error('ERR_NO_TIP', 'Chain tip not found')) + } + res.json(success(header)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get chain tip')) + } + }) + + // GET /v2/header/height/:height - Get header by height + router.get('/header/height/:height', async (req: Request, res: Response) => { + try { + const height = parseInt(req.params.height, 10) + if (isNaN(height) || height < 0) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid height parameter')) + } + + const currentHeight = await chaintracks.currentHeight() + if (height < currentHeight - 100) { + res.set('Cache-Control', 'public, max-age=3600') + } else { + res.set('Cache-Control', 'no-cache') + } + + const header = await chaintracks.findHeaderForHeight(height) + if (!header) { + return res.status(404).json(error('ERR_NOT_FOUND', `Header not found at height ${height}`)) + } + res.json(success(header)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get header')) + } + }) + + // GET /v2/header/hash/:hash - Get header by hash + router.get('/header/hash/:hash', async (req: Request, res: Response) => { + try { + const hash = req.params.hash + if (!hash || !/^[a-fA-F0-9]{64}$/.test(hash)) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid hash parameter')) + } + + const header = await chaintracks.findHeaderForBlockHash(hash) + if (!header) { + return res.status(404).json(error('ERR_NOT_FOUND', `Header not found for hash ${hash}`)) + } + + const currentHeight = await chaintracks.currentHeight() + if (header.height < currentHeight - 100) { + res.set('Cache-Control', 'public, max-age=3600') + } else { + res.set('Cache-Control', 'no-cache') + } + + res.json(success(header)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get header')) + } + }) + + // GET /v2/headers?height=N&count=M - Get multiple headers as binary + router.get('/headers', async (req: Request, res: Response) => { + try { + const height = parseInt(req.query.height as string, 10) + const count = parseInt(req.query.count as string, 10) + + if (isNaN(height) || height < 0) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid or missing height parameter')) + } + if (isNaN(count) || count <= 0) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid or missing count parameter')) + } + + const currentHeight = await chaintracks.currentHeight() + if (height < currentHeight - 100) { + res.set('Cache-Control', 'public, max-age=3600') + } else { + res.set('Cache-Control', 'no-cache') + } + + // Collect headers as binary (80 bytes each) + const buffers: Buffer[] = [] + for (let i = 0; i < count; i++) { + const header = await chaintracks.findHeaderForHeight(height + i) + if (!header) break + buffers.push(headerToBytes(header)) + } + + res.set('Content-Type', 'application/octet-stream') + res.send(Buffer.concat(buffers)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get headers')) + } + }) + + // Binary routes (80 bytes per header, height returned in X-Block-Height header) + + // GET /v2/tip.bin - Get chain tip as 80-byte binary + router.get('/tip.bin', async (_req: Request, res: Response) => { + try { + res.set('Cache-Control', 'no-cache') + const header = await chaintracks.findChainTipHeader() + if (!header) { + return res.status(404).json(error('ERR_NO_TIP', 'Chain tip not found')) + } + res.set('Content-Type', 'application/octet-stream') + res.set('X-Block-Height', String(header.height)) + res.send(headerToBytes(header)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get chain tip')) + } + }) + + // GET /v2/header/height/:height.bin - Get header by height as 80-byte binary + router.get('/header/height/:height.bin', async (req: Request, res: Response) => { + try { + const heightStr = req.params.height.replace('.bin', '') + const height = parseInt(heightStr, 10) + if (isNaN(height) || height < 0) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid height parameter')) + } + + const currentHeight = await chaintracks.currentHeight() + if (height < currentHeight - 100) { + res.set('Cache-Control', 'public, max-age=3600') + } else { + res.set('Cache-Control', 'no-cache') + } + + const header = await chaintracks.findHeaderForHeight(height) + if (!header) { + return res.status(404).json(error('ERR_NOT_FOUND', `Header not found at height ${height}`)) + } + res.set('Content-Type', 'application/octet-stream') + res.set('X-Block-Height', String(header.height)) + res.send(headerToBytes(header)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get header')) + } + }) + + // GET /v2/header/hash/:hash.bin - Get header by hash as 80-byte binary + router.get('/header/hash/:hash.bin', async (req: Request, res: Response) => { + try { + const hash = req.params.hash.replace('.bin', '') + if (!hash || !/^[a-fA-F0-9]{64}$/.test(hash)) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid hash parameter')) + } + + const header = await chaintracks.findHeaderForBlockHash(hash) + if (!header) { + return res.status(404).json(error('ERR_NOT_FOUND', `Header not found for hash ${hash}`)) + } + + const currentHeight = await chaintracks.currentHeight() + if (header.height < currentHeight - 100) { + res.set('Cache-Control', 'public, max-age=3600') + } else { + res.set('Cache-Control', 'no-cache') + } + + res.set('Content-Type', 'application/octet-stream') + res.set('X-Block-Height', String(header.height)) + res.send(headerToBytes(header)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get header')) + } + }) + + // GET /v2/headers.bin?height=N&count=M - Get multiple headers as binary (80 bytes each) + router.get('/headers.bin', async (req: Request, res: Response) => { + try { + const height = parseInt(req.query.height as string, 10) + const count = parseInt(req.query.count as string, 10) + + if (isNaN(height) || height < 0) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid or missing height parameter')) + } + if (isNaN(count) || count <= 0) { + return res.status(400).json(error('ERR_INVALID_PARAMS', 'Invalid or missing count parameter')) + } + + const currentHeight = await chaintracks.currentHeight() + if (height < currentHeight - 100) { + res.set('Cache-Control', 'public, max-age=3600') + } else { + res.set('Cache-Control', 'no-cache') + } + + // Collect headers as binary (80 bytes each) + const buffers: Buffer[] = [] + let headerCount = 0 + for (let i = 0; i < count; i++) { + const header = await chaintracks.findHeaderForHeight(height + i) + if (!header) break + buffers.push(headerToBytes(header)) + headerCount++ + } + + res.set('Content-Type', 'application/octet-stream') + res.set('X-Start-Height', String(height)) + res.set('X-Header-Count', String(headerCount)) + res.send(Buffer.concat(buffers)) + } catch (err) { + res.status(500).json(error('ERR_INTERNAL', 'Failed to get headers')) + } + }) + + return router +}