Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 67 additions & 20 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void>((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)
Expand Down
227 changes: 227 additions & 0 deletions src/v1-routes.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading