diff --git a/.env.example b/.env.example index 7d8e06d..af9ab2d 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,14 @@ FAIRCOIN_RPC_PORT=46373 FAIRCOIN_RPC_SCHEME=http # MongoDB Configuration +# Both Mongoose and the native cache client use the same db name: +# MONGODB_DB_NAME if set, otherwise the path segment of MONGODB_URI. MONGODB_URI=mongodb://localhost:27017/faircoin-explorer +# MONGODB_DB_NAME=faircoin-explorer + +# Vite dev proxy target (optional). Defaults to http://localhost:8080 to match +# the API server PORT below. Set when pointing the SPA at a remote API. +# VITE_API_TARGET=http://localhost:8080 # WebSocket Configuration WEBSOCKET_ENABLED=true diff --git a/README.md b/README.md index 2e7b802..bdc8b01 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Behind a reverse proxy (e.g. nginx/Cloudflare), set `TRUSTED_PROXY_CIDRS` to the The Express server exposes a read-only JSON API under `/api`: - `GET /api/blocks`, `/api/block/:hashOrHeight`, `/api/blockcount` +- `GET /api/transactions` (paginated recent txs from mempool + recent blocks) - `GET /api/transaction/:txid`, `POST /api/tx/broadcast` - `GET /api/address/:address`, `/api/address/:address/txs?page=&limit=`, `/api/address/:address/utxos` (requires a node with `addressindex` for full data) - `GET /api/mempool`, `/api/masternodes`, `/api/peers`, `/api/stats`, `/api/network-info`, `/api/mining-info` @@ -138,6 +139,7 @@ Only key generation is done in-process (using the audited `@noble/curves` secp25 ## Features - Dashboard with live blocks, mempool, price and network stats +- Global recent transactions feed, charts/analytics, and tools (broadcast, address validator, API docs) - Block, transaction and address pages (with paginated address history when the node has `addressindex`) - Masternode list and reward stats, peers, network status - Universal search (height, hash, txid, address) diff --git a/index.html b/index.html index d5f6196..ce50112 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + FairCoin Explorer | Real-Time FairCoin Blockchain Analytics @@ -21,11 +21,11 @@ - + - - + + diff --git a/public/browserconfig.xml b/public/browserconfig.xml index 1e75b5e..1174457 100644 --- a/public/browserconfig.xml +++ b/public/browserconfig.xml @@ -3,7 +3,7 @@ - #0f0f23 + #3d9948 diff --git a/server/index.ts b/server/index.ts index 8641a82..8b2f2aa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,7 +13,8 @@ import fs from 'fs' import { fileURLToPath } from 'url' import { blockCache } from './lib/cache' import { handleRouteError, parseNetwork, parseLimit, parseOffset, parseBlockOffset, parseAddress, ValidationError } from './lib/http' -import { computeCirculatingSupply, currentBlockReward } from './lib/supply' +import { computeCirculatingSupply, currentBlockReward } from '../shared/supply' +import { logger } from './lib/logger' import { toPublicNetworkInfo } from './lib/network-info' import { rpcWithNetwork } from '@fairco.in/rpc-client' import priceRouter from './routes/price' @@ -23,6 +24,7 @@ import broadcastRouter from './routes/broadcast' import feeEstimateRouter from './routes/fee-estimate' import githubRouter from './routes/github' import mcpInfoRouter from './routes/mcp-info' +import transactionsRouter from './routes/transactions' import { createMcpPostHandler, handleMcpMethodNotAllowed, handleMcpOptions } from './mcp/http' import packageJson from '../package.json' with { type: 'json' } @@ -241,6 +243,9 @@ app.get('/api/transaction/:txid', async (req, res) => { } }) +// Paginated recent-transaction feed (blocks + optional mempool tip) +app.use('/api/transactions', transactionsRouter) + // Address routes (addressindex RPC + fallback) app.use('/api/address', addressRouter) @@ -308,15 +313,24 @@ app.get('/api/masternodes', async (req, res) => { const network = parseNetwork(req.query.network) const limit = parseLimit(req.query.limit) const offset = parseOffset(req.query.offset) + // Stats-only by default (cheap `masternode count`). Pass `include=list` when + // the caller needs per-node rows — that triggers the heavier `masternodelist`. + const includeList = + typeof req.query.include === 'string' && + req.query.include.split(',').map((part) => part.trim().toLowerCase()).includes('list') const COLLATERAL_PER_MASTERNODE = 5000 - const [masternodeList, masternodeCount, blockHeight] = await Promise.all([ - blockCache.getMasternodeList(network).catch(() => [] as unknown[]), - blockCache.getMasternodeCount(network).catch(() => null), - blockCache.getBlockCount(network).catch(() => 0), - ]) - - interface MasternodeEntry { txid: string; outidx: number; address: string; protocol: number; status: string; activeTime: number; lastSeen: number; lastPaid: number; rank: number } + interface MasternodeEntry { + txid: string + outidx: number + address: string + protocol: number + status: string + activeTime: number + lastSeen: number + lastPaid: number + rank: number + } // FairCoin v3.0.5 `masternodelist` (default mode) returns an array of objects: // { rank, txhash, outidx, status, addr, version, lastseen, activetime, lastpaid }. @@ -335,23 +349,58 @@ app.get('/api/masternodes', async (req, res) => { } } - const masternodes = (Array.isArray(masternodeList) ? masternodeList : []).map(parseMasternodeEntry) - // Real supply schedule (premine + halvings), not a naive height*reward guess. + const [masternodeCount, blockHeight, masternodeList] = await Promise.all([ + blockCache.getMasternodeCount(network), + blockCache.getBlockCount(network).catch(() => 0), + includeList + ? blockCache.getMasternodeList(network).catch((error: unknown) => { + logger.error('Error fetching masternode list:', error) + return [] as Awaited> + }) + : Promise.resolve(null), + ]) + + const masternodes = (masternodeList ?? []).map(parseMasternodeEntry) const totalSupply = computeCirculatingSupply(blockHeight) - const totalCollateral = masternodes.length * COLLATERAL_PER_MASTERNODE - const collateralPercentage = totalSupply > 0 ? (totalCollateral / totalSupply) * 100 : 0 - const statusCounts = masternodes.reduce((acc, mn) => { acc[mn.status] = (acc[mn.status] || 0) + 1; return acc }, {} as Record) + const statusCounts = masternodes.reduce((acc, mn) => { + acc[mn.status] = (acc[mn.status] || 0) + 1 + return acc + }, {} as Record) + const enabledCount = masternodeCount?.enabled ?? statusCounts.ENABLED ?? 0 - const totalCount = masternodeCount?.total ?? masternodes.length + const totalCount = masternodeCount?.total ?? (includeList ? masternodes.length : 0) + const totalCollateral = totalCount * COLLATERAL_PER_MASTERNODE + const collateralPercentage = totalSupply > 0 ? (totalCollateral / totalSupply) * 100 : 0 const stats = { - total: totalCount, enabled: enabledCount, - preEnabled: statusCounts.PRE_ENABLED || 0, expired: statusCounts.EXPIRED || 0, - newStartRequired: statusCounts.NEW_START_REQUIRED || 0, watchdogExpired: statusCounts.WATCHDOG_EXPIRED || 0, - totalCollateral, collateralPercentage, - averageActiveTime: masternodes.length > 0 ? masternodes.reduce((sum, mn) => sum + mn.activeTime, 0) / masternodes.length : 0, - networkSecurity: { masternodeRewards: MASTERNODE_REWARD_PERCENT, stakingRewards: STAKING_REWARD_PERCENT, budgetRewards: BUDGET_REWARD_PERCENT } + total: totalCount, + enabled: enabledCount, + preEnabled: statusCounts.PRE_ENABLED || 0, + expired: statusCounts.EXPIRED || 0, + newStartRequired: statusCounts.NEW_START_REQUIRED || 0, + watchdogExpired: statusCounts.WATCHDOG_EXPIRED || 0, + totalCollateral, + collateralPercentage, + averageActiveTime: + masternodes.length > 0 + ? masternodes.reduce((sum, mn) => sum + mn.activeTime, 0) / masternodes.length + : 0, + networkSecurity: { + masternodeRewards: MASTERNODE_REWARD_PERCENT, + stakingRewards: STAKING_REWARD_PERCENT, + budgetRewards: BUDGET_REWARD_PERCENT, + }, + } + + if (!includeList) { + res.json({ + masternodes: [], + stats, + network, + pagination: { total: 0, limit, offset }, + }) + return } // Sort by rank (active masternodes first) so pagination is stable and useful. diff --git a/server/lib/blockchain-monitor.ts b/server/lib/blockchain-monitor.ts index 5d539fc..cdc6ae6 100644 --- a/server/lib/blockchain-monitor.ts +++ b/server/lib/blockchain-monitor.ts @@ -10,7 +10,7 @@ import { BlockCountEvent, MempoolUpdateEvent, NetworkStatsEvent -} from './websocket-types' +} from '../../shared/websocket-types' import { blockCache } from './cache' import { logger } from './logger' @@ -21,6 +21,10 @@ export class BlockchainMonitor { private statsInterval: NodeJS.Timeout | null = null private config: BlockchainMonitorConfig private isRunning: boolean = false + /** Last broadcast `network-stats` identity fields (not on NetworkState). */ + private lastBroadcastVersion = new Map() + private lastBroadcastSubversion = new Map() + private lastBroadcastProtocol = new Map() constructor(wsManager: WebSocketManager, config?: Partial) { this.wsManager = wsManager @@ -136,7 +140,7 @@ export class BlockchainMonitor { logger.debug(`[BlockchainMonitor] ${network} initialized: Block ${blockCount}`) } catch (error) { - console.error(`[BlockchainMonitor] Error initializing ${network}:`, error) + logger.error(`[BlockchainMonitor] Error initializing ${network}:`, error) } } @@ -192,7 +196,7 @@ export class BlockchainMonitor { state.lastUpdate = new Date() } catch (error) { - console.error(`[BlockchainMonitor] Error polling ${network}:`, error) + logger.error(`[BlockchainMonitor] Error polling ${network}:`, error) } } @@ -221,15 +225,15 @@ export class BlockchainMonitor { time: block.time, nTx: block.nTx || block.tx?.length || 0, size: block.size, - difficulty: block.difficulty, - tx: block.tx || [], + difficulty: block.difficulty ?? 0, + tx: Array.isArray(block.tx) ? block.tx.filter((entry): entry is string => typeof entry === 'string') : [], previousblockhash: block.previousblockhash, nextblockhash: block.nextblockhash } } this.wsManager.broadcast(newBlockEvent, network) } catch (error) { - console.error(`[BlockchainMonitor] Error handling new block ${height} on ${network}:`, error) + logger.error(`[BlockchainMonitor] Error handling new block ${height} on ${network}:`, error) } } @@ -244,16 +248,18 @@ export class BlockchainMonitor { } const mempoolInfo = await blockCache.getMempoolInfo(network) + const size = mempoolInfo?.size ?? 0 + const bytes = mempoolInfo?.bytes ?? 0 + const usage = mempoolInfo?.usage ?? 0 + const maxmempool = mempoolInfo?.maxmempool ?? 0 + const mempoolminfee = mempoolInfo?.mempoolminfee ?? 0 // Check if mempool changed - if ( - mempoolInfo && - (mempoolInfo.size !== state.mempoolSize || mempoolInfo.bytes !== state.mempoolBytes) - ) { - logger.debug(`[BlockchainMonitor] ${network}: Mempool changed (${state.mempoolSize} -> ${mempoolInfo.size} tx)`) + if (size !== state.mempoolSize || bytes !== state.mempoolBytes) { + logger.debug(`[BlockchainMonitor] ${network}: Mempool changed (${state.mempoolSize} -> ${size} tx)`) - state.mempoolSize = mempoolInfo.size - state.mempoolBytes = mempoolInfo.bytes + state.mempoolSize = size + state.mempoolBytes = bytes // Broadcast mempool update event const mempoolEvent: MempoolUpdateEvent = { @@ -261,18 +267,18 @@ export class BlockchainMonitor { network, timestamp: Date.now(), data: { - size: mempoolInfo.size, - bytes: mempoolInfo.bytes, - usage: mempoolInfo.usage, - maxmempool: mempoolInfo.maxmempool, - mempoolminfee: mempoolInfo.mempoolminfee, + size, + bytes, + usage, + maxmempool, + mempoolminfee, transactions: [] } } this.wsManager.broadcast(mempoolEvent, network) } } catch (error) { - console.error(`[BlockchainMonitor] Error polling mempool for ${network}:`, error) + logger.error(`[BlockchainMonitor] Error polling mempool for ${network}:`, error) } } @@ -286,7 +292,8 @@ export class BlockchainMonitor { } /** - * Poll network stats for single network + * Poll network stats for single network. Broadcasts only when the payload + * actually changed — avoids redundant `network-stats` WS traffic every 30s. */ private async pollNetworkStatsForNetwork(network: NetworkType): Promise { try { @@ -295,42 +302,71 @@ export class BlockchainMonitor { return } - // Get network info and mining info const [networkInfo, miningInfo] = await Promise.all([ - blockCache.getNetworkInfo(network).catch(() => null), - blockCache.getMiningInfo(network).catch(() => null) + blockCache.getNetworkInfo(network).catch((error: unknown) => { + logger.debug(`[BlockchainMonitor] getNetworkInfo failed for ${network}:`, error) + return null + }), + blockCache.getMiningInfo(network).catch((error: unknown) => { + logger.debug(`[BlockchainMonitor] getMiningInfo failed for ${network}:`, error) + return null + }), ]) - if (networkInfo || miningInfo) { - // Update state - if (networkInfo) { - state.connections = networkInfo.connections || 0 - } - if (miningInfo) { - state.difficulty = miningInfo.difficulty || 0 - state.hashrate = miningInfo.networkhashps || miningInfo.hashrate || '0' - } + if (!networkInfo && !miningInfo) { + return + } - // Broadcast network stats event - const statsEvent: NetworkStatsEvent = { - type: 'network-stats', - network, - timestamp: Date.now(), - data: { - connections: state.connections, - difficulty: state.difficulty, - hashrate: state.hashrate, - version: networkInfo?.version || 'Unknown', - subversion: networkInfo?.subversion, - protocolversion: networkInfo?.protocolversion - } - } - this.wsManager.broadcast(statsEvent, network) + const connections = networkInfo?.connections ?? state.connections + const difficulty = miningInfo?.difficulty ?? state.difficulty + const rawHashrate = miningInfo?.networkhashps ?? miningInfo?.hashrate ?? state.hashrate + const hashrate = typeof rawHashrate === 'string' ? rawHashrate : String(rawHashrate ?? '0') + const version = + networkInfo?.version !== undefined && networkInfo?.version !== null + ? String(networkInfo.version) + : 'Unknown' + const subversion = networkInfo?.subversion + const protocolversion = networkInfo?.protocolversion + + const unchanged = + connections === state.connections && + difficulty === state.difficulty && + hashrate === state.hashrate && + version === this.lastBroadcastVersion.get(network) && + subversion === this.lastBroadcastSubversion.get(network) && + protocolversion === this.lastBroadcastProtocol.get(network) + + state.connections = connections + state.difficulty = difficulty + state.hashrate = hashrate + + if (unchanged) { + logger.debug(`[BlockchainMonitor] ${network}: network stats unchanged, skip broadcast`) + return + } + + this.lastBroadcastVersion.set(network, version) + this.lastBroadcastSubversion.set(network, subversion) + this.lastBroadcastProtocol.set(network, protocolversion) - logger.debug(`[BlockchainMonitor] ${network}: Broadcasted network stats update`) + const statsEvent: NetworkStatsEvent = { + type: 'network-stats', + network, + timestamp: Date.now(), + data: { + connections, + difficulty, + hashrate, + version, + subversion, + protocolversion, + }, } + this.wsManager.broadcast(statsEvent, network) + + logger.debug(`[BlockchainMonitor] ${network}: Broadcasted network stats update`) } catch (error) { - console.error(`[BlockchainMonitor] Error polling network stats for ${network}:`, error) + logger.error(`[BlockchainMonitor] Error polling network stats for ${network}:`, error) } } diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 68fd86e..03ae222 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -1,8 +1,10 @@ -import { MongoClient, Db } from 'mongodb' +import { MongoClient, Db, type Collection, type Document, type Filter } from 'mongodb' import { rpcWithNetwork, type NetworkType, type RpcParam } from '@fairco.in/rpc-client' import { escapeRegex, MAX_BLOCK_OFFSET, sanitizeAddressValidation } from './http' +import { getDefaultMongoUri, getMongoDatabaseName } from './db/name' +import { logger } from './logger' -const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/faircoin-explorer' +const MONGODB_URI = process.env.MONGODB_URI || getDefaultMongoUri() /** Networks accepted for cache keys; guards values that flow into Mongo `$regex`. */ const VALID_NETWORKS: readonly NetworkType[] = ['mainnet', 'testnet'] @@ -18,27 +20,53 @@ interface CacheOptions { network: NetworkType } -interface CachedDocument { +/** Document stored in the `cache` collection. */ +interface CacheDocument extends Document { _id: string - data: any + data: unknown timestamp: number ttl: number + expiresAt: Date network: NetworkType } interface CachedData { - data: any + data: unknown timestamp: number ttl: number network: NetworkType } +/** Summary row stored in the `recent_blocks` collection. */ +interface RecentBlockSummary { + height: number + hash: string + time: number + nTx: number + size: number + tx: string[] +} + +interface RecentBlocksDocument extends Document { + _id: string + blocks: RecentBlockSummary[] + timestamp: number + expiresAt: Date + network: NetworkType +} + /** Extra seconds past a document's logical TTL before Mongo physically removes it. */ const TTL_INDEX_GRACE_SECONDS = 3600 /** All-zero outpoint hash used by coinbase-style inputs that reference no parent. */ const ZERO_HASH = '0000000000000000000000000000000000000000000000000000000000000000' +/** + * Max concurrent `getblock` RPCs when rebuilding the recent-blocks window on a + * cache miss. Keeps latency low without stampeding the daemon. + */ +const RECENT_BLOCKS_CONCURRENCY = 6 + /** * Shape returned by the FairCoin `masternode count` RPC. Fields beyond `total` * and `enabled` (e.g. `inqueue`, `ipv4`) vary by build and are not relied upon. @@ -49,6 +77,34 @@ export interface MasternodeCount { inqueue?: number } +/** + * Run `fn` over `items` with at most `concurrency` in-flight promises. + * Results preserve input order. + */ +async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) { + return [] + } + const limit = Math.max(1, Math.min(concurrency, items.length)) + const results: R[] = new Array(items.length) + let nextIndex = 0 + + async function worker(): Promise { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await fn(items[index], index) + } + } + + await Promise.all(Array.from({ length: limit }, () => worker())) + return results +} + export class BlockchainCache { private db: Db | null = null private client: MongoClient | null = null @@ -62,14 +118,24 @@ export class BlockchainCache { if (!this.db) { this.client = new MongoClient(MONGODB_URI) await this.client.connect() - const dbName = new URL(MONGODB_URI).pathname.slice(1) || 'faircoin-explorer' + const dbName = getMongoDatabaseName(MONGODB_URI) this.db = this.client.db(dbName) await this.ensureIndexes(this.db) - console.log(`Connected to MongoDB database: ${dbName}`) + logger.info(`Connected to MongoDB (cache) database: ${dbName}`) } return this.db } + protected async cacheCollection(): Promise> { + const db = await this.getDb() + return db.collection('cache') + } + + protected async recentBlocksCollection(): Promise> { + const db = await this.getDb() + return db.collection('recent_blocks') + } + /** * Create TTL indexes so cache documents self-prune. `expiresAt` is a wall-clock * Date; Mongo's TTL monitor deletes the doc once it passes. We add a grace @@ -83,11 +149,11 @@ export class BlockchainCache { db.collection('recent_blocks').createIndex({ expiresAt: 1 }, { expireAfterSeconds: TTL_INDEX_GRACE_SECONDS }), ]) } catch (error) { - console.error('Failed to ensure cache TTL indexes:', error) + logger.error('Failed to ensure cache TTL indexes:', error) } } - protected getCacheKey(method: string, params: any[], network: NetworkType) { + protected getCacheKey(method: string, params: RpcParam[], network: NetworkType) { return `${network}:${method}:${JSON.stringify(params)}` } @@ -96,42 +162,41 @@ export class BlockchainCache { return now - cachedData.timestamp > cachedData.ttl * 1000 } - async get(method: string, params: any[] = [], options: CacheOptions): Promise { + async get(method: string, params: RpcParam[] = [], options: CacheOptions): Promise { assertValidNetwork(options.network) - const db = await this.getDb() - const collection = db.collection('cache') + const collection = await this.cacheCollection() const cacheKey = this.getCacheKey(method, params, options.network) const ttlSeconds = options.ttl ?? 3600 try { - // Check cache first - const cached = await collection.findOne({ _id: cacheKey } as any) + const cached = await collection.findOne({ _id: cacheKey }) - if (cached && !this.isExpired(cached as any)) { - return cached.data + if (cached && !this.isExpired(cached)) { + return cached.data as T } // Fetch from RPC behind single-flight so concurrent misses share one call. const data = await this.fetchSingleFlight(cacheKey, method, params, options.network) // Store in cache. `expiresAt` powers the Mongo TTL index. + // `_id` comes from the filter; do not set it on the replacement body + // (Mongo's `WithoutId` typing rejects it). const now = Date.now() await collection.replaceOne( - { _id: cacheKey } as any, + { _id: cacheKey }, { - _id: cacheKey, data, timestamp: now, ttl: ttlSeconds, expiresAt: new Date(now + ttlSeconds * 1000), - network: options.network - } as any, - { upsert: true } + network: options.network, + }, + { upsert: true }, ) return data } catch (error) { - console.error(`Error in cache.get for ${method}:`, error) + logger.error(`Error in cache.get for ${method}:`, error) // Fallback to RPC (still single-flighted) if cache read/write fails. return await this.fetchSingleFlight(cacheKey, method, params, options.network) } @@ -160,40 +225,37 @@ export class BlockchainCache { } } - async invalidate(method: string, params: any[] = [], network: NetworkType) { - const db = await this.getDb() - const collection = db.collection('cache') + async invalidate(method: string, params: RpcParam[] = [], network: NetworkType) { + const collection = await this.cacheCollection() const cacheKey = this.getCacheKey(method, params, network) - - await collection.deleteOne({ _id: cacheKey } as any) + await collection.deleteOne({ _id: cacheKey }) } async invalidatePattern(pattern: string, network: NetworkType) { assertValidNetwork(network) - const db = await this.getDb() - const collection = db.collection('cache') + const collection = await this.cacheCollection() // Escape `network` so it cannot inject regex metacharacters into the key match. // `pattern` is caller-supplied and intentionally treated as a regex fragment. - await collection.deleteMany({ - _id: { $regex: `^${escapeRegex(network)}:${pattern}` } - } as any) + const filter: Filter = { + _id: { $regex: `^${escapeRegex(network)}:${pattern}` }, + } + await collection.deleteMany(filter) } async clearExpired() { - const db = await this.getDb() - const collection = db.collection('cache') + const collection = await this.cacheCollection() const now = Date.now() - - const result = await collection.deleteMany({ + + const filter: Filter = { $expr: { - $gt: [now, { $add: ['$timestamp', { $multiply: ['$ttl', 1000] }] }] - } - } as any) - - // Only log if there were expired entries to clean up + $gt: [now, { $add: ['$timestamp', { $multiply: ['$ttl', 1000] }] }], + }, + } + const result = await collection.deleteMany(filter) + if (result.deletedCount > 0) { - console.log(`Cleared ${result.deletedCount} expired cache entries`) + logger.info(`Cleared ${result.deletedCount} expired cache entries`) } } } @@ -290,19 +352,91 @@ interface TransactionLookupOptions { prevoutLookupBudget?: PrevoutLookupBudget } +/** Verbose block fields used when building recent-block summaries. */ +export interface VerboseBlock { + height: number + hash: string + time: number + nTx?: number + size: number + tx?: string[] + difficulty?: number + previousblockhash?: string + nextblockhash?: string + [key: string]: unknown +} + +/** Verbose transaction body returned by `getrawtransaction true`. */ +export interface VerboseTransaction extends RawTransactionConfirmationFields { + txid?: string + size?: number + vin?: RawVin[] + vout?: RawVout[] + [key: string]: unknown +} + +/** Address validation RPC result before sanitization. */ +interface AddressValidationResult { + isvalid?: boolean + address?: string + scriptPubKey?: string + ismine?: boolean + iswatchonly?: boolean + isscript?: boolean + iswitness?: boolean + [key: string]: unknown +} + +interface NetworkInfoResult { + connections?: number + version?: number | string + subversion?: string + protocolversion?: number + [key: string]: unknown +} + +interface MiningInfoResult { + difficulty?: number + networkhashps?: number | string + hashrate?: number | string + [key: string]: unknown +} + +interface MempoolInfoResult { + size?: number + bytes?: number + usage?: number + maxmempool?: number + mempoolminfee?: number + [key: string]: unknown +} + +/** One row from FairCoin `masternodelist` (default mode). */ +export interface MasternodeListEntry { + rank?: number + txhash?: string + outidx?: number + status?: string + addr?: string + version?: number + lastseen?: number + activetime?: number + lastpaid?: number + [key: string]: unknown +} + // Block-specific caching with different TTLs export class BlockCache extends BlockchainCache { async getBlock(hashOrHeight: string | number, network: NetworkType, verbose: boolean = true) { // Determine if it's a hash or height const isHeight = typeof hashOrHeight === 'number' || /^\d+$/.test(hashOrHeight.toString()) - + if (isHeight) { - const height = parseInt(hashOrHeight.toString()) + const height = parseInt(hashOrHeight.toString(), 10) const hash = await this.get('getblockhash', [height], { network, ttl: 300 }) // 5 minutes for block hashes - return await this.get('getblock', [hash, verbose], { network, ttl: 3600 }) // 1 hour for block data - } else { - return await this.get('getblock', [hashOrHeight, verbose], { network, ttl: 3600 }) // 1 hour for block data + return await this.get('getblock', [hash, verbose], { network, ttl: 3600 }) // 1 hour for block data } + return await this.get('getblock', [hashOrHeight, verbose], { network, ttl: 3600 }) // 1 hour for block data } /** @@ -329,37 +463,55 @@ export class BlockCache extends BlockchainCache { */ readonly maxPrevoutLookupsPerAddressPage = MAX_PREVOUT_LOOKUPS_PER_ADDRESS_PAGE + async getTransaction( + txid: string, + network: NetworkType, + verbose?: true, + options?: TransactionLookupOptions, + ): Promise + async getTransaction( + txid: string, + network: NetworkType, + verbose: false, + options?: TransactionLookupOptions, + ): Promise async getTransaction( txid: string, network: NetworkType, verbose: boolean = true, options: TransactionLookupOptions = {}, - ) { + ): Promise { assertValidNetwork(network) // Non-verbose callers just want the raw hex string; it never carries // confirmation state, so the plain cache path is correct for them. if (!verbose) { - return await this.get('getrawtransaction', [txid, verbose], { network, ttl: CONFIRMED_TX_TTL_SECONDS }) + return await this.get('getrawtransaction', [txid, verbose], { + network, + ttl: CONFIRMED_TX_TTL_SECONDS, + }) } - const db = await this.getDb() - const collection = db.collection('cache') + const collection = await this.cacheCollection() const cacheKey = this.getCacheKey('getrawtransaction', [txid, verbose], network) - let tx: (Record & RawTransactionConfirmationFields) | null = null + let tx: VerboseTransaction | null = null try { - const cached = await collection.findOne({ _id: cacheKey } as any) - if (cached && !this.isExpired(cached as any) && !this.isStaleUnconfirmed(cached as any)) { - tx = cached.data + const cached = await collection.findOne({ _id: cacheKey }) + if ( + cached && + !this.isExpired(cached) && + !this.isStaleUnconfirmed(cached) + ) { + tx = cached.data as VerboseTransaction } } catch (error) { - console.error(`Error reading cached transaction ${txid}:`, error) + logger.error(`Error reading cached transaction ${txid}:`, error) } if (!tx) { - tx = await this.fetchSingleFlight & RawTransactionConfirmationFields>( + tx = await this.fetchSingleFlight( cacheKey, 'getrawtransaction', [txid, verbose], @@ -383,19 +535,18 @@ export class BlockCache extends BlockchainCache { try { const now = Date.now() await collection.replaceOne( - { _id: cacheKey } as any, + { _id: cacheKey }, { - _id: cacheKey, data: tx, timestamp: now, ttl: ttlSeconds, expiresAt: new Date(now + ttlSeconds * 1000), network, - } as any, + }, { upsert: true }, ) } catch (error) { - console.error(`Error caching transaction ${txid}:`, error) + logger.error(`Error caching transaction ${txid}:`, error) } } @@ -424,15 +575,15 @@ export class BlockCache extends BlockchainCache { * `prevout`, so the endpoint never fails because a prevout was unavailable. */ private async enrichInputPrevouts( - tx: Record | null, + tx: VerboseTransaction | null, network: NetworkType, budget?: PrevoutLookupBudget, - ): Promise | null> { + ): Promise { if (!tx || !Array.isArray(tx.vin)) { return tx } - const vin = tx.vin as RawVin[] + const vin = tx.vin // Collect the distinct parent txids we need (skipping coinbase inputs), in // first-seen order, capped so a pathological many-input tx cannot fan out @@ -470,7 +621,7 @@ export class BlockCache extends BlockchainCache { ) parents.set(parentTxid, Array.isArray(parent?.vout) ? parent.vout : null) } catch (error) { - console.error(`Error resolving prevout parent ${parentTxid}:`, error) + logger.error(`Error resolving prevout parent ${parentTxid}:`, error) parents.set(parentTxid, null) } }), @@ -516,8 +667,12 @@ export class BlockCache extends BlockchainCache { * Confirmed entries (with a `blockhash`) are never considered stale here; their * volatile `confirmations` is recomputed live in {@link withLiveConfirmations}. */ - private isStaleUnconfirmed(cached: { data?: { blockhash?: string }; timestamp?: number }): boolean { - if (cached.data?.blockhash) { + private isStaleUnconfirmed(cached: { + data?: unknown + timestamp?: number + }): boolean { + const data = cached.data as { blockhash?: string } | undefined + if (data?.blockhash) { return false } const age = Date.now() - (cached.timestamp ?? 0) @@ -533,9 +688,9 @@ export class BlockCache extends BlockchainCache { * the 30s-cached block height, clamped to at least 1. */ private async withLiveConfirmations( - tx: (Record & RawTransactionConfirmationFields) | null, + tx: VerboseTransaction | null, network: NetworkType, - ): Promise | null> { + ): Promise { if (!tx) { return tx } @@ -565,7 +720,7 @@ export class BlockCache extends BlockchainCache { try { currentHeight = await this.getBlockCount(network) } catch (error) { - console.error('Error fetching block height for live confirmations:', error) + logger.error('Error fetching block height for live confirmations:', error) return { ...tx, blockheight, confirmations: typeof tx.confirmations === 'number' ? tx.confirmations : 1 } } @@ -581,10 +736,9 @@ export class BlockCache extends BlockchainCache { private async resolveBlockHeight(blockhash: string, network: NetworkType): Promise { try { const block = await this.getBlock(blockhash, network, true) - const height = (block as { height?: unknown } | null)?.height - return typeof height === 'number' ? height : null + return typeof block?.height === 'number' ? block.height : null } catch (error) { - console.error(`Error resolving height for block ${blockhash}:`, error) + logger.error(`Error resolving height for block ${blockhash}:`, error) return null } } @@ -594,15 +748,18 @@ export class BlockCache extends BlockchainCache { } async getNetworkInfo(network: NetworkType) { - return await this.get('getnetworkinfo', [], { network, ttl: 300 }) // 5 minutes for network info + return await this.get('getnetworkinfo', [], { network, ttl: 300 }) // 5 minutes for network info } async getMiningInfo(network: NetworkType) { - return await this.get('getmininginfo', [], { network, ttl: 60 }) // 1 minute for mining info + return await this.get('getmininginfo', [], { network, ttl: 60 }) // 1 minute for mining info } async validateAddress(address: string, network: NetworkType) { - const validation = await this.get('validateaddress', [address], { network, ttl: 86400 }) // 24 hours for address validation + const validation = await this.get('validateaddress', [address], { + network, + ttl: 86400, + }) // 24 hours for address validation return sanitizeAddressValidation(validation) } @@ -616,105 +773,116 @@ export class BlockCache extends BlockchainCache { * node and returns an empty array. We therefore call it with no filter to get * every masternode. A caller-supplied `filter` is forwarded only when non-empty. * - * The historical hang that motivated avoiding this call only affected - * /api/stats, which shared this cache key; /api/stats now uses the cheap - * {@link getMasternodeCount} instead, so the detailed list is safe to fetch here. + * Prefer {@link getMasternodeCount} for stats-only consumers; this list RPC is + * reserved for callers that need per-node rows (`?include=list`, MCP, etc.). */ - async getMasternodeList(network: NetworkType, filter: string = ''): Promise { - const params = filter ? [filter] : [] - return this.get('masternodelist', params, { network, ttl: 600 }) // 10 min TTL for masternode list + async getMasternodeList(network: NetworkType, filter: string = ''): Promise { + const params: RpcParam[] = filter ? [filter] : [] + return this.get('masternodelist', params, { network, ttl: 600 }) // 10 min TTL } /** * Cheap masternode tally via the `masternode count` RPC, which returns * `{ total, enabled, ... }` directly from the in-memory masternode manager. * - * This is what /api/stats uses for its masternode count: it is effectively free - * and never touches the heavier `masternodelist` array, keeping the stats - * endpoint fast and isolated from the list call's cost. + * This is what /api/stats and the default /api/masternodes path use: it is + * effectively free and never touches the heavier `masternodelist` array. */ async getMasternodeCount(network: NetworkType): Promise { try { return await this.get('masternode', ['count'], { network, ttl: 60 }) - } catch { + } catch (error) { // `masternode count` is unavailable on this node/network. + logger.debug(`masternode count unavailable on ${network}:`, error) return null } } - async getMempoolInfo(network: NetworkType): Promise { - return this.get('getmempoolinfo', [], { network, ttl: 60 }) // 1 minute TTL for mempool + async getMempoolInfo(network: NetworkType): Promise { + return this.get('getmempoolinfo', [], { network, ttl: 60 }) // 1 minute TTL for mempool } - async getStakingInfo(network: NetworkType): Promise { + async getStakingInfo(network: NetworkType): Promise { try { return await this.get('getstakinginfo', [], { network, ttl: 300 }) - } catch { + } catch (error) { // getstakinginfo does not exist on FairCoin v3.0.0 (PIVX-based) + logger.debug(`getstakinginfo unavailable on ${network}:`, error) return null } } - async getRawMempool(network: NetworkType): Promise { - return this.get('getrawmempool', [], { network, ttl: 30 }) // 30 second TTL for mempool transactions + async getRawMempool(network: NetworkType): Promise { + return this.get('getrawmempool', [], { network, ttl: 30 }) // 30 second TTL for mempool transactions } // Get recent blocks with caching - async getRecentBlocks(network: NetworkType, limit: number = 20, offset: number = 0): Promise { + async getRecentBlocks(network: NetworkType, limit: number = 20, offset: number = 0): Promise { assertValidNetwork(network) const safeOffset = Math.min(MAX_BLOCK_OFFSET, Math.max(0, Math.floor(offset))) const safeLimit = Math.max(1, Math.floor(limit)) - const db = await this.getDb() - const collection = db.collection('recent_blocks') + const collection = await this.recentBlocksCollection() const cacheKey = `${network}:recent:${safeLimit}:${safeOffset}` // Check cache first - const cached = await collection.findOne({ _id: cacheKey } as any) + const cached = await collection.findOne({ _id: cacheKey }) if (cached && Date.now() - cached.timestamp < RECENT_BLOCKS_TTL_MS) { return cached.blocks } // Single-flight: dedupe concurrent misses for the same window so N parallel - // requests don't each trigger up to `limit` serial RPC `getblock` calls. + // requests don't each trigger up to `limit` RPC `getblock` calls. const existing = this.inFlight.get(cacheKey) if (existing) { - return existing as Promise + return existing as Promise } - const fetchPromise = (async () => { + const fetchPromise = (async (): Promise => { try { const height = await this.getBlockCount(network) const startHeight = Math.max(0, height - safeOffset) - const blocks = [] - - for (let i = startHeight; i >= 0 && blocks.length < safeLimit; i--) { - try { - const block = await this.getBlock(i, network, true) - blocks.push({ - height: block.height, - hash: block.hash, - time: block.time, - nTx: block.nTx || block.tx?.length || 0, - size: block.size, - tx: block.tx || [] - }) - } catch (error) { - console.error(`Error fetching block ${i}:`, error) - } + const heights: number[] = [] + for (let i = startHeight; i >= 0 && heights.length < safeLimit; i--) { + heights.push(i) } + // Bounded parallel fetch: fill the window without serial RPC round-trips + // or an unbounded fan-out against the daemon. + const fetched = await mapWithConcurrency( + heights, + RECENT_BLOCKS_CONCURRENCY, + async (blockHeight) => { + try { + const block = await this.getBlock(blockHeight, network, true) + const summary: RecentBlockSummary = { + height: block.height, + hash: block.hash, + time: block.time, + nTx: block.nTx || block.tx?.length || 0, + size: block.size, + tx: block.tx || [], + } + return summary + } catch (error) { + logger.error(`Error fetching block ${blockHeight}:`, error) + return null + } + }, + ) + + const blocks = fetched.filter((block): block is RecentBlockSummary => block !== null) + // Cache the result. `expiresAt` powers the Mongo TTL index on recent_blocks. const cachedAt = Date.now() await collection.replaceOne( - { _id: cacheKey } as any, + { _id: cacheKey }, { - _id: cacheKey, blocks, timestamp: cachedAt, expiresAt: new Date(cachedAt + RECENT_BLOCKS_TTL_MS), - network - } as any, - { upsert: true } + network, + }, + { upsert: true }, ) return blocks @@ -735,6 +903,8 @@ export const blockCache = new BlockCache() // Auto cleanup expired entries every hour if (typeof global !== 'undefined') { setInterval(() => { - blockchainCache.clearExpired().catch(console.error) + blockchainCache.clearExpired().catch((error: unknown) => { + logger.error('Failed to clear expired cache entries:', error) + }) }, 3600000) // 1 hour } diff --git a/server/lib/db/connect.ts b/server/lib/db/connect.ts index e2db3dd..bcf9a54 100644 --- a/server/lib/db/connect.ts +++ b/server/lib/db/connect.ts @@ -1,17 +1,12 @@ import mongoose from 'mongoose' import { config } from 'dotenv' +import { getDefaultMongoUri, getMongoDatabaseName } from './name' +import { logger } from '../logger' // Load environment variables config() -const APP_NAME = 'explorer' - -function getDatabaseName(): string { - const env = process.env.NODE_ENV || 'development' - return `${APP_NAME}-${env}` -} - -const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/explorer-development' +const MONGODB_URI = process.env.MONGODB_URI || getDefaultMongoUri() if (!MONGODB_URI) { throw new Error('Please define the MONGODB_URI environment variable inside .env.local') @@ -41,7 +36,7 @@ async function connectToDatabase() { } if (!cached.promise) { - const dbName = getDatabaseName() + const dbName = getMongoDatabaseName(MONGODB_URI) const opts = { dbName, bufferCommands: false, @@ -55,7 +50,7 @@ async function connectToDatabase() { } cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongooseInstance) => { - console.log('Connected to MongoDB Atlas') + logger.info(`Connected to MongoDB (mongoose) database: ${dbName}`) return mongooseInstance }) } diff --git a/server/lib/db/models/StatPoint.ts b/server/lib/db/models/StatPoint.ts index 196b60d..af88a23 100644 --- a/server/lib/db/models/StatPoint.ts +++ b/server/lib/db/models/StatPoint.ts @@ -14,6 +14,10 @@ export interface IStatPoint extends Document { height: number; difficulty: number; connections: number; + /** Mempool size (pending tx count) at sample time. */ + mempoolSize: number; + /** Transaction count in the tip block at sample time. */ + lastBlockTxCount: number; timestamp: Date; } @@ -21,6 +25,8 @@ const StatPointSchema = new Schema({ height: { type: Number, required: true }, difficulty: { type: Number, required: true }, connections: { type: Number, required: true }, + mempoolSize: { type: Number, required: true, default: 0 }, + lastBlockTxCount: { type: Number, required: true, default: 0 }, // A unique index makes the per-window upsert idempotent: two concurrent ticks // (or a restart mid-window) collapse onto the same bucket instead of duplicating. timestamp: { type: Date, required: true, unique: true }, diff --git a/server/lib/db/name.test.ts b/server/lib/db/name.test.ts new file mode 100644 index 0000000..4a6740a --- /dev/null +++ b/server/lib/db/name.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { getMongoDatabaseName, DEFAULT_MONGO_DATABASE_NAME } from './name' + +describe('getMongoDatabaseName', () => { + const originalUri = process.env.MONGODB_URI + const originalDbName = process.env.MONGODB_DB_NAME + + afterEach(() => { + if (originalUri === undefined) delete process.env.MONGODB_URI + else process.env.MONGODB_URI = originalUri + if (originalDbName === undefined) delete process.env.MONGODB_DB_NAME + else process.env.MONGODB_DB_NAME = originalDbName + }) + + it('uses MONGODB_DB_NAME when set', () => { + process.env.MONGODB_DB_NAME = 'explicit-db' + expect(getMongoDatabaseName('mongodb://localhost:27017/uri-path')).toBe('explicit-db') + }) + + it('uses the URI path when MONGODB_DB_NAME is unset', () => { + delete process.env.MONGODB_DB_NAME + expect(getMongoDatabaseName('mongodb://localhost:27017/faircoin-explorer')).toBe( + 'faircoin-explorer', + ) + }) + + it('falls back to the default name when the URI has no path', () => { + delete process.env.MONGODB_DB_NAME + expect(getMongoDatabaseName('mongodb://localhost:27017')).toBe(DEFAULT_MONGO_DATABASE_NAME) + expect(getMongoDatabaseName('mongodb://localhost:27017/')).toBe(DEFAULT_MONGO_DATABASE_NAME) + }) +}) diff --git a/server/lib/db/name.ts b/server/lib/db/name.ts new file mode 100644 index 0000000..9b2e827 --- /dev/null +++ b/server/lib/db/name.ts @@ -0,0 +1,49 @@ +/** + * Single source of truth for the MongoDB database name used by both Mongoose + * (models / sync) and the native MongoClient (RPC cache). + * + * Strategy (in order): + * 1. Explicit `MONGODB_DB_NAME` when set + * 2. Path segment of `MONGODB_URI` (e.g. `.../faircoin-explorer`) + * 3. Default `faircoin-explorer` when the URI has no path + * + * Both clients MUST call this helper — never parse the URI independently or + * invent a second naming scheme (that caused the historical split-brain). + */ + +export const DEFAULT_MONGO_DATABASE_NAME = 'faircoin-explorer' + +const DEFAULT_MONGO_URI = `mongodb://localhost:27017/${DEFAULT_MONGO_DATABASE_NAME}` + +/** + * Resolve the MongoDB database name from env. Safe to call at module load or + * connection time; does not open a connection. + */ +export function getMongoDatabaseName( + uri: string = process.env.MONGODB_URI || DEFAULT_MONGO_URI, +): string { + const explicit = process.env.MONGODB_DB_NAME?.trim() + if (explicit) { + return explicit + } + + try { + const pathname = new URL(uri).pathname.replace(/^\//, '') + // Strip query-like leftovers if a non-standard URI sneaks in; pathname is + // normally just the db name (no slashes). + const dbName = pathname.split('/')[0]?.trim() + if (dbName) { + return dbName + } + } catch { + // Invalid URI — fall through to the default name. Connection will fail + // later with a clear driver error if the URI itself is unusable. + } + + return DEFAULT_MONGO_DATABASE_NAME +} + +/** Canonical default URI used when `MONGODB_URI` is unset. */ +export function getDefaultMongoUri(): string { + return DEFAULT_MONGO_URI +} diff --git a/server/lib/supply.test.ts b/server/lib/supply.test.ts index f109215..f410e93 100644 --- a/server/lib/supply.test.ts +++ b/server/lib/supply.test.ts @@ -7,7 +7,7 @@ import { HALVING_INTERVAL, MIN_REWARD, BLOCK_REWARD, -} from './supply' +} from '../../shared/supply' describe('computeCirculatingSupply', () => { it('returns 0 before the chain starts', () => { diff --git a/server/lib/supply.ts b/server/lib/supply.ts deleted file mode 100644 index c9a3e53..0000000 --- a/server/lib/supply.ts +++ /dev/null @@ -1,37 +0,0 @@ -// FairCoin supply economics (mirrors src/lib/supply.ts). -// Premine 5,000,000 FAIR on block 1; block reward 10 FAIR; halving every -// 525,600 blocks; minimum reward 1.25 FAIR; hard cap 33,000,000 FAIR. - -export const PREMINE = 5_000_000 -export const BLOCK_REWARD = 10 -export const HALVING_INTERVAL = 525_600 -export const MIN_REWARD = 1.25 -export const MAX_SUPPLY = 33_000_000 - -/** - * Compute the circulating supply at a given block height using the chain's - * halving schedule: block 1 carries the premine, every subsequent block mints - * the era reward (halved every HALVING_INTERVAL blocks, floored at MIN_REWARD). - */ -export function computeCirculatingSupply(blockHeight: number): number { - if (blockHeight <= 0) return 0 - let supply = PREMINE - if (blockHeight > 1) { - let remaining = blockHeight - 1 - let reward = BLOCK_REWARD - while (remaining > 0 && reward >= MIN_REWARD) { - const blocksInEra = Math.min(remaining, HALVING_INTERVAL) - supply += blocksInEra * reward - remaining -= blocksInEra - reward = Math.max(reward / 2, MIN_REWARD) - } - if (remaining > 0) supply += remaining * MIN_REWARD - } - return Math.min(supply, MAX_SUPPLY) -} - -/** Current block reward at the given height, considering halvings. */ -export function currentBlockReward(blockHeight: number): number { - const halvings = blockHeight > 0 ? Math.floor(blockHeight / HALVING_INTERVAL) : 0 - return Math.max(BLOCK_REWARD / 2 ** halvings, MIN_REWARD) -} diff --git a/server/lib/websocket-handler.ts b/server/lib/websocket-handler.ts index 684dbf5..7bd87b1 100644 --- a/server/lib/websocket-handler.ts +++ b/server/lib/websocket-handler.ts @@ -11,7 +11,7 @@ import { ErrorEvent, SubscribeEvent, UnsubscribeEvent -} from './websocket-types' +} from '../../shared/websocket-types' import { logger } from './logger' // Initialize WebSocket manager and blockchain monitor diff --git a/server/lib/websocket-manager.ts b/server/lib/websocket-manager.ts index 8cd080d..351d783 100644 --- a/server/lib/websocket-manager.ts +++ b/server/lib/websocket-manager.ts @@ -6,8 +6,7 @@ import { WebSocketEvent, WebSocketEventType, WebSocketManagerConfig, - ServerMessage -} from './websocket-types' +} from '../../shared/websocket-types' import { logger } from './logger' export class WebSocketManager { @@ -262,7 +261,7 @@ export class WebSocketManager { /** * Send message to connection */ - private sendToConnection(connection: ConnectionInfo, event: ServerMessage): boolean { + private sendToConnection(connection: ConnectionInfo, event: WebSocketEvent): boolean { try { const message = JSON.stringify(event) @@ -275,7 +274,7 @@ export class WebSocketManager { return false } catch (error) { - console.error(`[WebSocketManager] Error sending to connection ${connection.id}:`, error) + logger.error(`[WebSocketManager] Error sending to connection ${connection.id}:`, error) return false } } @@ -318,7 +317,7 @@ export class WebSocketManager { try { connection.socket.close() } catch (error) { - console.error(`[WebSocketManager] Error closing connection ${id}:`, error) + logger.error(`[WebSocketManager] Error closing connection ${id}:`, error) } this.unregister(id) } @@ -346,7 +345,7 @@ export class WebSocketManager { try { connection.socket.close() } catch (error) { - console.error(`[WebSocketManager] Error closing connection ${id}:`, error) + logger.error(`[WebSocketManager] Error closing connection ${id}:`, error) } }) diff --git a/server/mcp/server.ts b/server/mcp/server.ts index 18486f8..c265e6d 100644 --- a/server/mcp/server.ts +++ b/server/mcp/server.ts @@ -17,7 +17,7 @@ import { z } from "zod"; import { rpcWithNetwork, type NetworkType } from "@fairco.in/rpc-client"; import { blockCache, type MasternodeCount } from "../lib/cache"; import { parseNetwork, MAX_LIMIT, ValidationError } from "../lib/http"; -import { computeCirculatingSupply, currentBlockReward, MAX_SUPPLY } from "../lib/supply"; +import { computeCirculatingSupply, currentBlockReward, MAX_SUPPLY } from "../../shared/supply"; import { getPrice } from "../lib/price-service"; import { logger } from "../lib/logger"; import { registerWalletTools } from "./wallet-tools"; @@ -357,7 +357,7 @@ async function resolveSearch( if (/^\d+$/.test(query)) { const block = await blockCache.getBlock(parseInt(query, 10), network, true).catch(() => null); if (block) { - const summary = toBlockSummary(block as Record); + const summary = toBlockSummary({ ...block }); const value = String(summary.height ?? query); return { hits: [{ id: encodeId("block", value), title: `Block #${value}`, url: webUrl("block", value) }], @@ -371,7 +371,7 @@ async function resolveSearch( if (/^[0-9a-fA-F]{64}$/.test(query)) { const block = await blockCache.getBlock(query, network, true).catch(() => null); if (block) { - const summary = toBlockSummary(block as Record); + const summary = toBlockSummary({ ...block }); return { hits: [{ id: encodeId("block", query), title: `Block ${query}`, url: webUrl("block", query) }], record: { ...summary, network }, @@ -484,7 +484,7 @@ export function createFaircoinMcpServer(version: string): McpServer { let record: Record | null; if (kind === "block") { const block = await blockCache.getBlock(value, net, true).catch(() => null); - record = block ? { ...toBlockSummary(block as Record), network: net } : null; + record = block ? { ...toBlockSummary({ ...block }), network: net } : null; title = `Block ${value}`; } else if (kind === "tx") { const tx = await blockCache.getTransaction(value, net, true).catch(() => null); @@ -582,7 +582,7 @@ export function createFaircoinMcpServer(version: string): McpServer { if (!block) { throw new ValidationError(`Block '${hashOrHeight}' not found on ${net}.`); } - return jsonResult({ network: net, block: block as Record }); + return jsonResult({ network: net, block: { ...block } }); } catch (error) { return errorResult("get_block failed", error); } diff --git a/server/routes/address.ts b/server/routes/address.ts index b223db1..e1d3f70 100644 --- a/server/routes/address.ts +++ b/server/routes/address.ts @@ -216,6 +216,10 @@ router.get("/:address/txs", async (req: Request, res: Response) => { for (const txid of pageTxids) { try { const tx = await blockCache.getTransaction(txid, network, true, { prevoutLookupBudget }); + if (!tx) { + transactions.push({ txid, blockhash: null, blockHeight: null, confirmations: 0, time: 0, size: 0, amount: 0, type: "received" }); + continue; + } const vouts = (tx.vout ?? []) as TxVout[]; const vins = (tx.vin ?? []) as TxVin[]; diff --git a/server/routes/stats-history.ts b/server/routes/stats-history.ts index 69b3f81..75a46b2 100644 --- a/server/routes/stats-history.ts +++ b/server/routes/stats-history.ts @@ -35,6 +35,8 @@ interface StatSample { height: number; difficulty: number; connections: number; + mempoolSize: number; + lastBlockTxCount: number; } /** @@ -53,19 +55,35 @@ function currentSampleBucket(now: number): Date { * rather than failing the whole sample. */ async function loadStatSample(): Promise { - const [blockHeight, miningInfo, networkInfo] = await Promise.all([ + const [blockHeight, miningInfo, networkInfo, mempoolInfo] = await Promise.all([ blockCache.getBlockCount(SAMPLE_NETWORK).catch(() => 0), blockCache.getMiningInfo(SAMPLE_NETWORK).catch(() => null), blockCache.getNetworkInfo(SAMPLE_NETWORK).catch(() => null), + blockCache.getMempoolInfo(SAMPLE_NETWORK).catch(() => null), ]); const difficultyRaw = (miningInfo as Record | null)?.difficulty; const connectionsRaw = (networkInfo as Record | null)?.connections; + const mempoolSizeRaw = (mempoolInfo as Record | null)?.size; + + let lastBlockTxCount = 0; + if (typeof blockHeight === "number" && blockHeight > 0) { + const tip = await blockCache.getBlock(blockHeight, SAMPLE_NETWORK, true).catch(() => null); + const nTx = (tip as Record | null)?.nTx; + const tx = (tip as Record | null)?.tx; + if (typeof nTx === "number") { + lastBlockTxCount = nTx; + } else if (Array.isArray(tx)) { + lastBlockTxCount = tx.length; + } + } return { height: typeof blockHeight === "number" ? blockHeight : 0, difficulty: typeof difficultyRaw === "number" ? difficultyRaw : 0, connections: typeof connectionsRaw === "number" ? connectionsRaw : 0, + mempoolSize: typeof mempoolSizeRaw === "number" ? mempoolSizeRaw : 0, + lastBlockTxCount, }; } @@ -94,6 +112,8 @@ async function sampleAndStoreStats(): Promise { height: sample.height, difficulty: sample.difficulty, connections: sample.connections, + mempoolSize: sample.mempoolSize, + lastBlockTxCount: sample.lastBlockTxCount, }, }, { upsert: true }, @@ -132,6 +152,8 @@ interface StatHistoryPoint { height: number; difficulty: number; connections: number; + mempoolSize: number; + lastBlockTxCount: number; timestamp: string; } @@ -152,7 +174,16 @@ router.get("/", async (_req: Request, res: Response) => { const docs = await StatPoint.find({ timestamp: { $gte: cutoff } }) .sort({ timestamp: -1 }) .limit(HISTORY_MAX_POINTS) - .lean<{ height: number; difficulty: number; connections: number; timestamp: Date }[]>() + .lean< + { + height: number; + difficulty: number; + connections: number; + mempoolSize?: number; + lastBlockTxCount?: number; + timestamp: Date; + }[] + >() .exec(); // Fetched newest-first (to take the most recent N); reverse to oldest→newest @@ -161,6 +192,8 @@ router.get("/", async (_req: Request, res: Response) => { height: doc.height, difficulty: doc.difficulty, connections: doc.connections, + mempoolSize: typeof doc.mempoolSize === "number" ? doc.mempoolSize : 0, + lastBlockTxCount: typeof doc.lastBlockTxCount === "number" ? doc.lastBlockTxCount : 0, timestamp: doc.timestamp.toISOString(), })); diff --git a/server/routes/transactions.ts b/server/routes/transactions.ts new file mode 100644 index 0000000..67475c5 --- /dev/null +++ b/server/routes/transactions.ts @@ -0,0 +1,120 @@ +import { Router, type Request, type Response } from "express"; +import { rpcWithNetwork } from "@fairco.in/rpc-client"; +import { blockCache } from "../lib/cache"; +import { handleRouteError, parseLimit, parseNetwork, parseOffset } from "../lib/http"; + +const router = Router(); + +/** Max blocks scanned to assemble a recent-tx page (keeps RPC load bounded). */ +const MAX_BLOCKS_SCAN = 40; +/** Cap on mempool entries detailed for the unconfirmed prefix of the feed. */ +const MAX_MEMPOOL_DETAIL = 20; + +export interface RecentTransactionItem { + txid: string; + /** Null when the tx is still in the mempool. */ + blockHeight: number | null; + /** Unix seconds; mempool entry time or block time. */ + time: number; + /** True when the tx has not yet been included in a block. */ + unconfirmed: boolean; + size?: number; + fee?: number; +} + +/** + * GET /api/transactions?network=&limit=&offset=&includeMempool=1 + * + * Paginated recent-transaction feed derived from recent blocks (and optionally + * the mempool for the tip of the feed). Reuses the same recent-blocks cache the + * home dashboard uses so the UX stays consistent. + */ +router.get("/", async (req: Request, res: Response) => { + try { + const network = parseNetwork(req.query.network); + const limit = parseLimit(req.query.limit); + const offset = parseOffset(req.query.offset); + const includeMempool = + req.query.includeMempool === undefined || + req.query.includeMempool === "" || + req.query.includeMempool === "1" || + req.query.includeMempool === "true"; + + const mempoolItems: RecentTransactionItem[] = []; + if (includeMempool) { + try { + const rawMempool = await rpcWithNetwork("getrawmempool", [], network).catch( + () => [] as string[], + ); + const recent = rawMempool.slice(0, MAX_MEMPOOL_DETAIL); + for (const txid of recent) { + try { + const entry = await rpcWithNetwork>( + "getmempoolentry", + [txid], + network, + ); + mempoolItems.push({ + txid, + blockHeight: null, + time: Number(entry.time ?? Date.now() / 1000), + unconfirmed: true, + size: Number(entry.size ?? 0), + fee: Number(entry.fee ?? 0), + }); + } catch { + mempoolItems.push({ + txid, + blockHeight: null, + time: Math.floor(Date.now() / 1000), + unconfirmed: true, + }); + } + } + } catch (error: unknown) { + console.error("Error loading mempool for recent transactions:", error); + } + } + + // Fetch enough recent blocks to cover offset+limit after the mempool prefix. + const needed = offset + limit; + const blocksToFetch = Math.min( + MAX_BLOCKS_SCAN, + Math.max(10, Math.ceil(needed / 2) + 5), + ); + const blocks = await blockCache.getRecentBlocks(network, blocksToFetch, 0); + + const confirmed: RecentTransactionItem[] = []; + for (const block of blocks) { + const height = Number(block.height ?? 0); + const time = Number(block.time ?? 0); + const txids = Array.isArray(block.tx) ? (block.tx as string[]) : []; + for (const txid of txids) { + confirmed.push({ + txid, + blockHeight: height, + time, + unconfirmed: false, + }); + } + } + + const combined = [...mempoolItems, ...confirmed]; + const page = combined.slice(offset, offset + limit); + const height = await blockCache.getBlockCount(network).catch(() => 0); + + res.json({ + transactions: page, + total: combined.length, + offset, + limit, + height, + network, + hasMore: offset + limit < combined.length, + }); + } catch (error: unknown) { + handleRouteError(res, "Error fetching recent transactions", error); + } +}); + +export default router; diff --git a/server/tsconfig.json b/server/tsconfig.json index ab448c5..8cae61e 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -12,12 +12,13 @@ "skipLibCheck": true, "noEmit": true, "outDir": "./dist", - "rootDir": ".", + "rootDir": "..", "baseUrl": ".", "paths": { - "@/server/*": ["./*"] + "@/server/*": ["./*"], + "@shared/*": ["../shared/*"] } }, - "include": ["./**/*.ts"], + "include": ["./**/*.ts", "../shared/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/src/lib/supply.ts b/shared/supply.ts similarity index 80% rename from src/lib/supply.ts rename to shared/supply.ts index b01f5ae..aebd809 100644 --- a/src/lib/supply.ts +++ b/shared/supply.ts @@ -1,4 +1,4 @@ -// FairCoin supply economics (mirrors server/routes/stats.ts). +// FairCoin supply economics — single source for client and server. // Premine 5,000,000 FAIR on block 1; block reward 10 FAIR; halving every // 525,600 blocks; minimum reward 1.25 FAIR; hard cap 33,000,000 FAIR. @@ -26,8 +26,9 @@ export interface SupplyInfo { } /** - * Compute the circulating supply at a given block height using the same - * halving schedule the chain uses on the server. + * Compute the circulating supply at a given block height using the chain's + * halving schedule: block 1 carries the premine, every subsequent block mints + * the era reward (halved every HALVING_INTERVAL blocks, floored at MIN_REWARD). */ export function computeCirculatingSupply(blockHeight: number): number { if (blockHeight <= 0) return 0 @@ -46,6 +47,12 @@ export function computeCirculatingSupply(blockHeight: number): number { return Math.min(supply, MAX_SUPPLY) } +/** Current block reward at the given height, considering halvings. */ +export function currentBlockReward(blockHeight: number): number { + const halvings = blockHeight > 0 ? Math.floor(blockHeight / HALVING_INTERVAL) : 0 + return Math.max(BLOCK_REWARD / 2 ** halvings, MIN_REWARD) +} + /** Derive a full supply snapshot (circulating, halving progress, etc.). */ export function computeSupplyInfo(blockHeight: number): SupplyInfo { const circulating = computeCirculatingSupply(blockHeight) diff --git a/server/lib/websocket-types.ts b/shared/websocket-types.ts similarity index 94% rename from server/lib/websocket-types.ts rename to shared/websocket-types.ts index f410361..b5c05d2 100644 --- a/server/lib/websocket-types.ts +++ b/shared/websocket-types.ts @@ -1,4 +1,5 @@ // WebSocket Types and Interfaces for FairCoin Explorer +// Single source for client and server. export type NetworkType = 'mainnet' | 'testnet' @@ -19,7 +20,7 @@ export interface WebSocketEvent { type: WebSocketEventType network: NetworkType timestamp: number - data?: any + data?: unknown } // Block Data Interface @@ -131,7 +132,7 @@ export interface ErrorEvent extends WebSocketEvent { data: { code: string message: string - details?: any + details?: unknown } } @@ -151,10 +152,14 @@ export type WebSocketMessage = // Connection State export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error' -// Connection Metadata +// Connection Metadata (server-side; socket is the ws library instance) export interface ConnectionInfo { id: string - socket: WebSocket | any + socket: { + readyState: number + send: (data: string) => void + close: () => void + } network: NetworkType subscribedEvents: Set lastActivity: Date diff --git a/src/App.tsx b/src/App.tsx index 964a73e..067e770 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,9 @@ const StatsPage = lazy(() => import('./pages/stats')) const PeersPage = lazy(() => import('./pages/peers')) const FeeCalculatorPage = lazy(() => import('./pages/fee-calculator')) const AddressValidatorPage = lazy(() => import('./pages/address-validator')) +const BroadcastPage = lazy(() => import('./pages/broadcast')) +const ApiDocsPage = lazy(() => import('./pages/api-docs')) +const ChartsPage = lazy(() => import('./pages/charts')) const McpPage = lazy(() => import('./pages/mcp')) const BridgePage = lazy(() => import('./pages/bridge')) const NotFoundPage = lazy(() => import('./pages/not-found')) @@ -119,6 +122,14 @@ export default function App() { } /> + }> + + + } + /> } /> + }> + + + } + /> + }> + + + } + /> +
+ +
+

{t('addressInformation')}

+ {known ? ( + + + {known.label} + + ) : null}
@@ -166,15 +188,38 @@ function AddressTransactionsSection({ const total = data?.total ?? 0 const totalPages = Math.max(1, Math.ceil(total / TXS_PER_PAGE)) + const exportCsv = () => { + downloadCsv( + `faircoin-address-${address}-page-${page}.csv`, + ['txid', 'type', 'amount', 'confirmations', 'blockHeight', 'time'], + transactions.map((tx) => [ + tx.txid, + tx.type, + tx.amount, + tx.confirmations, + tx.blockHeight ?? '', + tx.time, + ]), + ) + } + return ( - {t('transactionsCount', { count: total })} - +
+ {transactions.length > 0 ? ( + + ) : null} + + {t('transactionsCount', { count: total })} + +
} > {isLoading ? ( @@ -286,7 +331,7 @@ function AddressTransactionRow({ function AddressSkeleton() { return ( -
+
diff --git a/src/components/address-validator-content.tsx b/src/components/address-validator-content.tsx index 2e37a44..80daafc 100644 --- a/src/components/address-validator-content.tsx +++ b/src/components/address-validator-content.tsx @@ -107,7 +107,7 @@ export function AddressValidatorContent() { result?.isValid === true && result.network !== currentNetwork return ( -
+
{ + try { + await navigator.clipboard.writeText(path) + toast.success(t('copied')) + } catch (error: unknown) { + console.error('Clipboard write failed:', error) + toast.error(t('copyFailed')) + } + } + + return ( +
+ + + +
+

{t('overviewBody')}

+

{t('networkNote')}

+

{t('rateLimitNote')}

+
+
+ + +
    + {ENDPOINTS.map((endpoint) => ( +
  • +
    +
    + + {endpoint.method} + + {endpoint.path} +
    +

    + {t(`endpoints.${endpoint.descriptionKey}`)} +

    +
    + +
  • + ))} +
+
+
+ ) +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index cd736b3..2a7c5c9 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -17,6 +17,10 @@ import { ChevronsLeft, ChevronsRight, ChevronRight, + ShieldCheck, + Radio, + BookOpen, + LineChart, type LucideIcon, } from "lucide-react" import { @@ -228,6 +232,7 @@ export function AppSidebar(props: React.ComponentProps) { const networkNav = [ { icon: BarChart3, label: t('stats'), to: '/stats' }, + { icon: LineChart, label: t('charts'), to: '/charts' }, { icon: Shield, label: t('masternodes'), to: '/masternodes' }, { icon: Clock, label: t('mempool'), to: '/mempool' }, { icon: Users, label: t('peers'), to: '/peers' }, @@ -237,6 +242,9 @@ export function AppSidebar(props: React.ComponentProps) { const toolsSubNav: ToolsSubItem[] = [ { icon: Calculator, label: t('feeCalculator'), to: '/tools/fee-calculator' }, + { icon: ShieldCheck, label: t('addressValidator'), to: '/tools/address-validator' }, + { icon: Radio, label: t('broadcast'), to: '/tools/broadcast' }, + { icon: BookOpen, label: t('apiDocs'), to: '/tools/api' }, { icon: Plug, label: t('mcp'), to: '/tools/mcp' }, ] diff --git a/src/components/block-content.tsx b/src/components/block-content.tsx index 0c1c2a5..0770d45 100644 --- a/src/components/block-content.tsx +++ b/src/components/block-content.tsx @@ -13,6 +13,7 @@ import { import { useTranslations } from '@/lib/i18n' import { useBlock } from '@/hooks/use-block' import { formatBytes, formatNumber } from '@/lib/format' +import { DetailBreadcrumbs } from '@/components/detail/detail-breadcrumbs' import { DetailHeader } from '@/components/detail/detail-header' import { SectionCard } from '@/components/detail/section-card' import { StatTile, StatTileGrid } from '@/components/detail/stat-tile' @@ -32,6 +33,7 @@ const PROGRESS_GRADIENT = 'linear-gradient(90deg, hsl(var(--primary)), hsl(var(- export function BlockContent({ hashOrHeight }: { hashOrHeight: string }) { const t = useTranslations('block') const common = useTranslations('common') + const nav = useTranslations('nav') const { data: block, isLoading, isError, error, refetch, isFetching } = useBlock(hashOrHeight) if (isLoading) { @@ -40,7 +42,13 @@ export function BlockContent({ hashOrHeight }: { hashOrHeight: string }) { if (isError || !block) { return ( -
+
+ +
+ +
diff --git a/src/components/blocks-content.tsx b/src/components/blocks-content.tsx index b2865a5..4ffff35 100644 --- a/src/components/blocks-content.tsx +++ b/src/components/blocks-content.tsx @@ -93,7 +93,7 @@ export function BlocksContent() { if (isError) { return ( -
+
+
-
- - {t('filter')} -
- {TIME_FILTERS.map(({ key, labelKey }) => ( - - ))} +
+
+ + {t('filter')} +
+ {TIME_FILTERS.map(({ key, labelKey }) => ( + + ))} +
+ {timeFilter !== 'all' || searchQuery.trim() ? ( +

{t('filterPageOnly')}

+ ) : null}
@@ -299,7 +305,7 @@ function BlockRow({ function BlocksSkeleton() { return ( -
+
diff --git a/src/components/bridge-content.tsx b/src/components/bridge-content.tsx index 9be3a01..852b48b 100644 --- a/src/components/bridge-content.tsx +++ b/src/components/bridge-content.tsx @@ -140,7 +140,7 @@ export function BridgeContent() { fraction === null ? null : `${(fraction * 100).toLocaleString('en-US', { maximumFractionDigits: 2 })}%` return ( -
+
MAX_TX_HEX_CHARS) return 'tooLarge' + return null +} + +export function BroadcastContent() { + const t = useTranslations('tools.broadcast') + const { currentNetwork } = useNetwork() + const [hex, setHex] = useState('') + const [state, setState] = useState({ status: 'idle' }) + + const validationKey = validateHex(hex) + const canSubmit = validationKey === null && state.status !== 'submitting' + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + const cleaned = hex.trim().replace(/\s+/g, '') + const errorKey = validateHex(cleaned) + if (errorKey) { + setState({ status: 'error', message: t(`errors.${errorKey}`) }) + return + } + + setState({ status: 'submitting' }) + try { + const response = await fetch(`/api/tx/broadcast?network=${currentNetwork}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ hex: cleaned }), + }) + const body = (await response.json()) as { txid?: string; error?: string } + if (!response.ok || !body.txid) { + const message = body.error ?? t('errors.rejected') + setState({ status: 'error', message }) + toast.error(message) + return + } + setState({ status: 'success', txid: body.txid }) + toast.success(t('successToast')) + } catch (error: unknown) { + console.error('Broadcast request failed:', error) + const message = t('errors.network') + setState({ status: 'error', message }) + toast.error(message) + } + } + + return ( +
+ + + +
void handleSubmit(event)} className="space-y-4"> +
+ +