Initial commit: Mazacoin blockchain explorer v1.0

- Full-featured blockchain explorer with React frontend + Node.js backend
- Features: block/tx/address lookup, live node map, price ticker, rich list
- Real-time updates via WebSocket
- Comprehensive documentation (README, INSTALL, API, TROUBLESHOOTING)
- Docker deployment with docker-compose
- Deployed at https://maza.samiahmed7777.me
This commit is contained in:
2026-03-09 06:52:46 +01:00
commit 906b1a502d
44 changed files with 5544 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-alpine
WORKDIR /app
# Install SSH client (needed for Mazacoin RPC over SSH)
RUN apk add --no-cache openssh-client
# Install dependencies
COPY package*.json ./
RUN npm install --production
# Copy source code
COPY . .
# Create logs directory
RUN mkdir -p logs
EXPOSE 3000
CMD ["node", "src/server.js"]
+27
View File
@@ -0,0 +1,27 @@
{
"name": "mazacoin-explorer-backend",
"version": "1.0.0",
"description": "Mazacoin blockchain explorer backend",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"mongoose": "^8.0.0",
"socket.io": "^4.6.0",
"axios": "^1.6.0",
"node-ssh": "^13.1.0",
"node-cache": "^5.1.2",
"dotenv": "^16.3.1",
"winston": "^3.11.0",
"sqlite3": "^5.1.6",
"better-sqlite3": "^9.2.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
const Database = require('better-sqlite3');
const path = require('path');
// Node.js v20 has native fetch
const dbPath = path.join(__dirname, '../data/price-history.db');
const db = new Database(dbPath);
// Ensure table exists
db.exec(`
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL UNIQUE,
price_btc REAL NOT NULL,
price_ltc REAL,
price_eth REAL,
volume_24h REAL,
high_24h REAL,
low_24h REAL,
source TEXT DEFAULT 'freiexchange',
created_at INTEGER DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON price_history(timestamp);
`);
async function collectPriceData() {
try {
console.log('[' + new Date().toISOString() + '] Fetching MAZA prices...');
// Fetch from FreiExchange
const freiResp = await fetch('https://api.freiexchange.com/public/ticker/MAZA');
const freiData = await freiResp.json();
// Fetch crypto ratios from CoinGecko
const geckoResp = await fetch(
'https://api.coingecko.com/api/v3/simple/price?ids=litecoin,ethereum&vs_currencies=btc',
{ headers: { 'User-Agent': 'Mazacoin Explorer/1.0 (https://maza.samiahmed7777.me)' } }
);
const geckoData = await geckoResp.json();
// Extract MAZA/BTC price from FreiExchange response
const mazaData = freiData.MAZA_BTC?.[0] || freiData.MAZA?.[0] || {};
const mazaBTC = parseFloat(mazaData.last || 0);
const volume24h = parseFloat(mazaData.volume24h_btc || 0);
const high24h = parseFloat(mazaData.high || 0);
const low24h = parseFloat(mazaData.low || 0);
// Calculate MAZA/LTC and MAZA/ETH
const ltcBTC = geckoData.litecoin?.btc || 0;
const ethBTC = geckoData.ethereum?.btc || 0;
const mazaLTC = ltcBTC > 0 ? mazaBTC / ltcBTC : null;
const mazaETH = ethBTC > 0 ? mazaBTC / ethBTC : null;
// Insert into database
const timestamp = Math.floor(Date.now() / 1000);
const stmt = db.prepare(`
INSERT OR REPLACE INTO price_history
(timestamp, price_btc, price_ltc, price_eth, volume_24h, high_24h, low_24h)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(timestamp, mazaBTC, mazaLTC, mazaETH, volume24h, high24h, low24h);
console.log('✅ Price saved:', {
timestamp: new Date(timestamp * 1000).toISOString(),
mazaBTC,
mazaLTC,
mazaETH,
volume24h
});
return { success: true, timestamp, mazaBTC, mazaLTC, mazaETH };
} catch (error) {
console.error('❌ Error collecting price:', error.message);
return { success: false, error: error.message };
} finally {
db.close();
}
}
// Run if called directly
if (require.main === module) {
collectPriceData().then(result => {
process.exit(result.success ? 0 : 1);
});
}
module.exports = collectPriceData;
+45
View File
@@ -0,0 +1,45 @@
const Database = require('better-sqlite3');
const path = require('path');
// Create/connect to database
const dbPath = path.join(__dirname, '../../data/price-history.db');
const db = new Database(dbPath);
// Create price_history table
db.exec(`
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL UNIQUE,
price_btc REAL NOT NULL,
price_ltc REAL,
price_eth REAL,
volume_24h REAL,
high_24h REAL,
low_24h REAL,
source TEXT DEFAULT 'freiexchange',
created_at INTEGER DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON price_history(timestamp);
CREATE INDEX IF NOT EXISTS idx_created_at ON price_history(created_at);
`);
console.log('✅ Price history database initialized');
console.log('📍 Database location:', dbPath);
// Test insert
const now = Math.floor(Date.now() / 1000);
const stmt = db.prepare(`
INSERT OR IGNORE INTO price_history
(timestamp, price_btc, price_ltc, price_eth, volume_24h)
VALUES (?, ?, ?, ?, ?)
`);
const testResult = stmt.run(now, 0.00000004, 0.0000025, 0.0000001, 100);
console.log('🧪 Test insert:', testResult.changes > 0 ? 'SUCCESS' : 'SKIPPED (duplicate)');
// Show latest entry
const latest = db.prepare('SELECT * FROM price_history ORDER BY timestamp DESC LIMIT 1').get();
console.log('📊 Latest entry:', latest);
db.close();
+116
View File
@@ -0,0 +1,116 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const DB_PATH = path.join(__dirname, '../../data/price_history.db');
const INIT_SQL = fs.readFileSync(path.join(__dirname, 'init.sql'), 'utf8');
// Ensure data directory exists
const dataDir = path.dirname(DB_PATH);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Initialize database
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('Error opening database:', err);
} else {
console.log('Connected to price history database');
// Run init SQL
db.exec(INIT_SQL, (err) => {
if (err) {
console.error('Error initializing database:', err);
} else {
console.log('Price history database initialized');
}
});
}
});
/**
* Log a price entry
*/
function logPrice(priceBtc, priceLtc, priceEth, volume24h, change24h, source = 'live') {
const timestamp = Math.floor(Date.now() / 1000);
return new Promise((resolve, reject) => {
db.run(
`INSERT OR REPLACE INTO price_history (timestamp, price_btc, price_ltc, price_eth, volume_24h, change_24h, source)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[timestamp, priceBtc, priceLtc, priceEth, volume24h, change24h, source],
function(err) {
if (err) reject(err);
else resolve({ id: this.lastID, timestamp });
}
);
});
}
/**
* Get price history for a timeframe
* @param {string} timeframe - '1h', '24h', '7d', '30d', '1y', 'all'
*/
function getHistory(timeframe = '24h') {
const now = Math.floor(Date.now() / 1000);
let since = 0;
switch(timeframe) {
case '1h':
since = now - (60 * 60);
break;
case '24h':
since = now - (24 * 60 * 60);
break;
case '7d':
since = now - (7 * 24 * 60 * 60);
break;
case '30d':
since = now - (30 * 24 * 60 * 60);
break;
case '1y':
since = now - (365 * 24 * 60 * 60);
break;
case 'all':
since = 0;
break;
default:
since = now - (24 * 60 * 60);
}
return new Promise((resolve, reject) => {
db.all(
`SELECT timestamp, price_btc, price_ltc, price_eth, volume_24h, change_24h, source
FROM price_history
WHERE timestamp >= ?
ORDER BY timestamp ASC`,
[since],
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
}
/**
* Get latest price entry
*/
function getLatest() {
return new Promise((resolve, reject) => {
db.get(
`SELECT * FROM price_history ORDER BY timestamp DESC LIMIT 1`,
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
}
module.exports = {
db,
logPrice,
getHistory,
getLatest
};
+132
View File
@@ -0,0 +1,132 @@
const fs = require('fs').promises;
const path = require('path');
const HISTORY_FILE = path.join(__dirname, '..', 'data', 'nodes-history.json');
const MAX_AGE_HOURS = 24;
class NodeHistory {
constructor() {
this.nodes = new Map();
this.initialized = false;
}
async init() {
if (this.initialized) return;
try {
// Ensure data directory exists
const dataDir = path.dirname(HISTORY_FILE);
await fs.mkdir(dataDir, { recursive: true });
// Load existing history
try {
const data = await fs.readFile(HISTORY_FILE, 'utf8');
const parsed = JSON.parse(data);
this.nodes = new Map(Object.entries(parsed));
} catch (err) {
// File doesn't exist or is invalid, start fresh
this.nodes = new Map();
}
this.initialized = true;
} catch (err) {
console.error('Error initializing node history:', err);
this.nodes = new Map();
this.initialized = true;
}
}
async updateNode(ip, geoData, isActive = true) {
await this.init();
const now = Date.now();
const existing = this.nodes.get(ip);
if (existing) {
// Update existing node
this.nodes.set(ip, {
...existing,
...geoData,
lastSeen: now,
isActive
});
} else {
// Add new node
this.nodes.set(ip, {
ip,
...geoData,
firstSeen: now,
lastSeen: now,
isActive
});
}
}
async getRecentNodes() {
await this.init();
const cutoff = Date.now() - (MAX_AGE_HOURS * 60 * 60 * 1000);
const recent = [];
// First, mark all as inactive
for (const [ip, node] of this.nodes.entries()) {
if (node.lastSeen >= cutoff) {
recent.push({
...node,
isActive: false // Will be updated to true for currently connected nodes
});
}
}
return recent;
}
async cleanup() {
await this.init();
const cutoff = Date.now() - (MAX_AGE_HOURS * 60 * 60 * 1000);
// Remove nodes older than 24 hours
for (const [ip, node] of this.nodes.entries()) {
if (node.lastSeen < cutoff) {
this.nodes.delete(ip);
}
}
}
async save() {
await this.init();
try {
const obj = Object.fromEntries(this.nodes);
await fs.writeFile(HISTORY_FILE, JSON.stringify(obj, null, 2), 'utf8');
} catch (err) {
console.error('Error saving node history:', err);
}
}
async getStats() {
await this.init();
const cutoff = Date.now() - (MAX_AGE_HOURS * 60 * 60 * 1000);
const recent = Array.from(this.nodes.values()).filter(n => n.lastSeen >= cutoff);
const active = recent.filter(n => n.isActive);
const countries = {};
recent.forEach(node => {
if (node.country) {
countries[node.country] = (countries[node.country] || 0) + 1;
}
});
return {
total: recent.length,
active: active.length,
countries,
nodes: recent
};
}
}
module.exports = new NodeHistory();
+309
View File
@@ -0,0 +1,309 @@
const fs = require('fs').promises;
const path = require('path');
const rpc = require('./rpc/MazacoinRPC');
const DATA_FILE = path.join(__dirname, '../data/rich-list.json');
const STATE_FILE = path.join(__dirname, '../data/rich-list-state.json');
// In-memory cache
let richList = [];
let balanceMap = new Map(); // Full balance map for all addresses during scanning
let txCache = new Map(); // Cache for transaction lookups (LRU, max 10000 entries)
let lastScannedBlock = 0;
let isScanning = false;
// Simple LRU cache management
const MAX_TX_CACHE_SIZE = 10000;
function addToTxCache(txid, tx) {
if (txCache.size >= MAX_TX_CACHE_SIZE) {
// Remove oldest entry (first key)
const firstKey = txCache.keys().next().value;
txCache.delete(firstKey);
}
txCache.set(txid, tx);
}
function getCachedTx(txid) {
return txCache.get(txid);
}
// Load data from disk
async function loadData() {
try {
// Ensure data directory exists
await fs.mkdir(path.dirname(DATA_FILE), { recursive: true });
// Load rich list
try {
const data = await fs.readFile(DATA_FILE, 'utf8');
richList = JSON.parse(data);
// Populate balance map from loaded data
balanceMap.clear();
richList.forEach(item => {
balanceMap.set(item.address, { balance: item.balance, lastSeen: item.lastSeen });
});
} catch (err) {
richList = [];
balanceMap.clear();
}
// Load state
try {
const state = await fs.readFile(STATE_FILE, 'utf8');
const parsed = JSON.parse(state);
lastScannedBlock = parsed.lastScannedBlock || 0;
} catch (err) {
// Start from genesis block (full blockchain scan)
lastScannedBlock = 0;
console.log(`[Rich List] Starting fresh from genesis block 0`);
}
console.log(`[Rich List] Loaded ${richList.length} addresses, last scanned block: ${lastScannedBlock}`);
} catch (error) {
console.error('[Rich List] Error loading data:', error);
}
}
// Save data to disk
async function saveData() {
try {
await fs.mkdir(path.dirname(DATA_FILE), { recursive: true });
// Save rich list (top 1000 to limit file size)
await fs.writeFile(DATA_FILE, JSON.stringify(richList.slice(0, 1000), null, 2));
// Save state (including cache stats)
await fs.writeFile(STATE_FILE, JSON.stringify({
lastScannedBlock,
lastUpdate: new Date().toISOString(),
totalAddresses: balanceMap.size,
txCacheSize: txCache.size
}, null, 2));
// Clear transaction cache if it's getting too large (keep most recent)
if (txCache.size > MAX_TX_CACHE_SIZE * 0.9) {
const entriesToKeep = Array.from(txCache.entries()).slice(-Math.floor(MAX_TX_CACHE_SIZE * 0.5));
txCache.clear();
entriesToKeep.forEach(([key, value]) => txCache.set(key, value));
}
} catch (error) {
console.error('[Rich List] Error saving data:', error);
}
}
// Update balances from a block
async function processBlock(blockHeight) {
try {
const blockHash = await rpc.getBlockHash(blockHeight);
const block = await rpc.getBlock(blockHash);
if (!block || !block.tx) {
return;
}
// Build a map of address => balance change for this block
const balanceChanges = new Map();
// Process all transactions in the block
for (const txid of block.tx) {
const tx = await rpc.getTransaction(txid);
if (!tx) continue;
// Cache current transaction for future lookups
addToTxCache(txid, tx);
// Process inputs (debits) - subtract spent amounts
// Skip coinbase transactions (no inputs)
if (tx.vin && tx.vin.length > 0 && !tx.vin[0].coinbase) {
for (const vin of tx.vin) {
try {
// Look up the previous transaction (check cache first)
let prevTx = getCachedTx(vin.txid);
if (!prevTx) {
prevTx = await rpc.getTransaction(vin.txid);
if (prevTx) {
addToTxCache(vin.txid, prevTx);
}
}
if (prevTx && prevTx.vout && prevTx.vout[vin.vout]) {
const spentOutput = prevTx.vout[vin.vout];
if (spentOutput.scriptPubKey && spentOutput.scriptPubKey.addresses && spentOutput.scriptPubKey.addresses.length > 0) {
const address = spentOutput.scriptPubKey.addresses[0];
const current = balanceChanges.get(address) || 0;
balanceChanges.set(address, current - spentOutput.value);
}
}
} catch (err) {
// Skip if we can't look up the previous transaction
// This might happen for very old transactions or edge cases
}
}
}
// Add outputs (credits to addresses)
if (tx.vout) {
for (const vout of tx.vout) {
if (vout.scriptPubKey && vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.length > 0) {
const address = vout.scriptPubKey.addresses[0];
const current = balanceChanges.get(address) || 0;
balanceChanges.set(address, current + vout.value);
}
}
}
}
// Update balance map with changes
balanceChanges.forEach((change, address) => {
const existing = balanceMap.get(address);
if (existing) {
existing.balance += change;
existing.lastSeen = blockHeight;
} else {
balanceMap.set(address, {
balance: change,
lastSeen: blockHeight
});
}
});
// Remove addresses with zero or negative balances (dust cleanup)
for (const [address, data] of balanceMap.entries()) {
if (data.balance <= 0.00000001) {
balanceMap.delete(address);
}
}
// Update richList from balanceMap (top 1000)
richList = Array.from(balanceMap.entries())
.map(([address, data]) => ({
address,
balance: data.balance,
lastSeen: data.lastSeen
}))
.sort((a, b) => b.balance - a.balance)
.slice(0, 1000);
lastScannedBlock = blockHeight;
} catch (error) {
console.error(`[Rich List] Error processing block ${blockHeight}:`, error.message);
}
}
// Scan blocks incrementally
async function scanBlocks(maxBlocks = 10000) {
if (isScanning) {
return;
}
isScanning = true;
const scanStartTime = Date.now();
try {
const currentHeight = await rpc.getBlockCount();
const startBlock = lastScannedBlock + 1;
const endBlock = Math.min(startBlock + maxBlocks - 1, currentHeight);
if (startBlock > currentHeight) {
console.log('[Rich List] Already up to date');
isScanning = false;
return;
}
const totalBlocks = endBlock - startBlock + 1;
const blocksRemaining = currentHeight - startBlock + 1;
console.log(`[Rich List] Scanning blocks ${startBlock} to ${endBlock}... (${blocksRemaining.toLocaleString()} blocks behind)`);
let lastProgressTime = Date.now();
let blocksProcessed = 0;
for (let height = startBlock; height <= endBlock; height++) {
await processBlock(height);
blocksProcessed++;
// Save progress every 50 blocks
if (height % 50 === 0) {
await saveData();
const elapsed = (Date.now() - scanStartTime) / 1000;
const blocksPerSec = blocksProcessed / elapsed;
const remainingBlocks = currentHeight - height;
const etaSeconds = remainingBlocks / blocksPerSec;
const etaDays = (etaSeconds / 86400).toFixed(1);
const cacheHitRate = txCache.size > 0 ? ((txCache.size / blocksProcessed) * 100).toFixed(1) : '0';
console.log(`[Rich List] Progress: ${height.toLocaleString()}/${currentHeight.toLocaleString()} | ${balanceMap.size} addresses | Top: ${richList[0]?.balance.toFixed(2) || 0} MAZA | Speed: ${blocksPerSec.toFixed(2)} blocks/sec | ETA: ${etaDays} days | TX cache: ${txCache.size}`);
}
}
await saveData();
const elapsed = (Date.now() - scanStartTime) / 1000;
console.log(`[Rich List] Scan complete: ${richList.length} addresses tracked in ${elapsed.toFixed(1)}s`);
} catch (error) {
console.error('[Rich List] Error during scan:', error);
} finally {
isScanning = false;
}
}
// Get top N addresses
function getTopAddresses(limit = 100) {
return {
addresses: richList.slice(0, limit).map((item, index) => ({
rank: index + 1,
address: item.address,
balance: item.balance,
lastSeen: item.lastSeen
})),
lastScannedBlock,
totalAddresses: richList.length,
isScanning
};
}
// Background updater
let updateInterval;
function startBackgroundUpdater(intervalMinutes = 5) {
if (updateInterval) {
clearInterval(updateInterval);
}
updateInterval = setInterval(async () => {
console.log('[Rich List] Starting background scan...');
await scanBlocks(50000);
}, intervalMinutes * 60 * 1000);
console.log(`[Rich List] Background updater started (every ${intervalMinutes} min, 50000 blocks per scan)`);
}
function stopBackgroundUpdater() {
if (updateInterval) {
clearInterval(updateInterval);
updateInterval = null;
}
}
// Initialize
async function initialize() {
await loadData();
// Do an initial scan if we're behind
const currentHeight = await rpc.getBlockCount();
if (currentHeight - lastScannedBlock > 100) {
console.log(`[Rich List] Behind by ${(currentHeight - lastScannedBlock).toLocaleString()} blocks, starting catch-up scan...`);
// Scan in large chunks - will continue in background
scanBlocks(50000); // Non-blocking - continues in background
}
// Start background updater
startBackgroundUpdater(5);
}
module.exports = {
initialize,
getTopAddresses,
scanBlocks,
startBackgroundUpdater,
stopBackgroundUpdater
};
+186
View File
@@ -0,0 +1,186 @@
const { NodeSSH } = require('node-ssh');
const NodeCache = require('node-cache');
const winston = require('winston');
// Logger
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/rpc.log' })
]
});
class MazacoinRPC {
constructor() {
this.ssh = new NodeSSH();
this.cache = new NodeCache({ stdTTL: 30 }); // 30 second cache
this.connected = false;
this.config = {
host: '100.85.236.10',
username: 'hello',
privateKeyPath: process.env.SSH_KEY_PATH || '/root/.ssh/krystie_to_sami_pc'
};
this.cliPath = 'E:\\coins\\MAZA\\daemon\\maza-cli.exe';
}
async connect() {
if (this.connected) {
// Test if connection is still alive
try {
await this.ssh.execCommand('echo test', { options: { timeout: 5000 } });
return; // Connection still good
} catch (error) {
logger.warn('SSH connection test failed, reconnecting...');
this.connected = false;
try {
this.ssh.dispose();
} catch (e) {
// Ignore disposal errors
}
this.ssh = new (require('node-ssh').NodeSSH)();
}
}
try {
await this.ssh.connect({
host: this.config.host,
username: this.config.username,
privateKeyPath: this.config.privateKeyPath,
keepaliveInterval: 10000, // Send keepalive every 10 seconds
keepaliveCountMax: 3
});
this.connected = true;
logger.info('Connected to Mazacoin node via SSH');
} catch (error) {
logger.error('SSH connection failed:', error);
this.connected = false;
throw new Error(`Failed to connect to Mazacoin node: ${error.message}`);
}
}
async execCommand(command, retries = 1) {
await this.connect();
const fullCommand = `powershell.exe -Command "${this.cliPath} -datadir=E:\\coins\\MAZA ${command}"`;
logger.debug(`Executing: ${fullCommand}`);
try {
const result = await this.ssh.execCommand(fullCommand, {
execOptions: { pty: false },
options: { timeout: 30000 } // 30 second timeout
});
if (result.code !== 0) {
throw new Error(`Command failed: ${result.stderr}`);
}
return result.stdout.trim();
} catch (error) {
// If connection error and we have retries left, reconnect and try again
if ((error.code === 'ECONNRESET' || error.message?.includes('Not connected')) && retries > 0) {
logger.warn(`Connection error, retrying... (${retries} attempts left)`);
this.connected = false;
return this.execCommand(command, retries - 1);
}
logger.error(`RPC command failed (${command}):`, error);
throw error;
}
}
async call(method, ...params) {
const cacheKey = `${method}:${params.join(':')}`;
// Check cache
const cached = this.cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const paramsStr = params.map(p => `"${p}"`).join(' ');
const command = params.length > 0 ? `${method} ${paramsStr}` : method;
const output = await this.execCommand(command);
let result;
try {
result = JSON.parse(output);
} catch {
result = output; // Return raw if not JSON
}
// Cache the result
this.cache.set(cacheKey, result);
return result;
}
// Blockchain methods
async getBlockCount() {
return parseInt(await this.call('getblockcount'));
}
async getBlockHash(height) {
return await this.call('getblockhash', height);
}
async getBlock(hashOrHeight) {
// If numeric, get hash first
if (typeof hashOrHeight === 'number' || /^\d+$/.test(hashOrHeight)) {
const hash = await this.getBlockHash(parseInt(hashOrHeight));
return await this.call('getblock', hash);
}
return await this.call('getblock', hashOrHeight);
}
async getTransaction(txid) {
return await this.call('getrawtransaction', txid, '1');
}
async getAddressBalance(address) {
// Note: This may require txindex enabled on the node
try {
return await this.call('getaddressbalance', `{"addresses":["${address}"]}`);
} catch (error) {
logger.warn(`getaddressbalance not available, falling back to manual calculation`);
return null;
}
}
async getPeerInfo() {
return await this.call('getpeerinfo');
}
async getNetworkInfo() {
return await this.call('getnetworkinfo');
}
async getMiningInfo() {
return await this.call('getmininginfo');
}
async getDifficulty() {
return parseFloat(await this.call('getdifficulty'));
}
async getConnectionCount() {
return parseInt(await this.call('getconnectioncount'));
}
disconnect() {
if (this.connected) {
this.ssh.dispose();
this.connected = false;
logger.info('Disconnected from Mazacoin node');
}
}
}
module.exports = new MazacoinRPC();
+686
View File
@@ -0,0 +1,686 @@
const express = require('express');
const cors = require('cors');
const http = require('http');
const { Server } = require('socket.io');
const winston = require('winston');
require('dotenv').config();
const rpc = require('./rpc/MazacoinRPC');
const priceHistory = require('./db/priceHistory');
const nodeHistory = require('./nodeHistory');
const richList = require('./richList');
// Logger
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.simple()
),
transports: [
new winston.transports.Console()
]
});
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*"
}
});
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Get latest block height
app.get('/api/blockcount', async (req, res) => {
try {
const count = await rpc.getBlockCount();
res.json({ height: count });
} catch (error) {
logger.error('Error getting block count:', error);
res.status(500).json({ error: error.message });
}
});
// Get block by hash or height
app.get('/api/block/:hashOrHeight', async (req, res) => {
try {
const { hashOrHeight } = req.params;
const block = await rpc.getBlock(hashOrHeight);
res.json(block);
} catch (error) {
logger.error(`Error getting block ${req.params.hashOrHeight}:`, error);
res.status(500).json({ error: error.message });
}
});
// Get transaction by txid (with input amounts)
app.get('/api/tx/:txid', async (req, res) => {
try {
const { txid } = req.params;
const tx = await rpc.getTransaction(txid);
// Enrich vin with amounts from previous transactions
if (tx.vin && Array.isArray(tx.vin)) {
const vinWithAmounts = await Promise.all(
tx.vin.map(async (input) => {
// Skip coinbase transactions (no previous tx)
if (input.coinbase) {
return { ...input, value: 0, isCoinbase: true };
}
try {
// Fetch the previous transaction
const prevTx = await rpc.getTransaction(input.txid);
// Get the output being spent (vout index)
const prevOut = prevTx.vout[input.vout];
return {
...input,
value: prevOut?.value || 0,
address: prevOut?.scriptPubKey?.addresses?.[0] || 'Unknown'
};
} catch (error) {
logger.warn(`Could not fetch input amount for ${input.txid}:`, error.message);
return { ...input, value: null, error: 'txindex may not be enabled' };
}
})
);
tx.vin = vinWithAmounts;
// Calculate total input value
tx.totalInput = vinWithAmounts
.filter(v => !v.isCoinbase && v.value !== null)
.reduce((sum, v) => sum + v.value, 0);
}
// Calculate total output value
if (tx.vout && Array.isArray(tx.vout)) {
tx.totalOutput = tx.vout.reduce((sum, v) => sum + (v.value || 0), 0);
}
// Calculate fees (input - output)
if (tx.totalInput !== undefined && tx.totalOutput !== undefined) {
tx.fees = tx.totalInput - tx.totalOutput;
}
res.json(tx);
} catch (error) {
logger.error(`Error getting transaction ${req.params.txid}:`, error);
res.status(500).json({ error: error.message });
}
});
// Get network stats
app.get('/api/stats', async (req, res) => {
try {
const [blockCount, difficulty, networkInfo, miningInfo, peerCount] = await Promise.all([
rpc.getBlockCount(),
rpc.getDifficulty(),
rpc.getNetworkInfo(),
rpc.getMiningInfo(),
rpc.getConnectionCount()
]);
res.json({
blockHeight: blockCount,
difficulty: difficulty,
connections: peerCount,
networkHashrate: miningInfo.networkhashps || 0,
version: networkInfo.version,
protocolVersion: networkInfo.protocolversion,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error getting stats:', error);
res.status(500).json({ error: error.message });
}
});
// Get peer info (for node map)
app.get('/api/peers', async (req, res) => {
try {
const peers = await rpc.getPeerInfo();
res.json(peers);
} catch (error) {
logger.error('Error getting peers:', error);
res.status(500).json({ error: error.message });
}
});
// Get peers with geolocation data (24-hour history)
app.get('/api/nodes', async (req, res) => {
try {
// Get current connected peers
const peers = await rpc.getPeerInfo();
// Helper to extract IP from addr (handles both IPv4 and IPv6)
const extractIP = (addr) => {
if (!addr) return null;
// IPv6 format: [2001:db8::1]:8333
const ipv6Match = addr.match(/^\[([^\]]+)\]/);
if (ipv6Match) return ipv6Match[1];
// IPv4 format: 192.168.1.1:8333
return addr.split(':')[0];
};
// Helper to check if IP is private/local
const isPrivateIP = (ip) => {
if (!ip) return true;
// IPv4 private ranges
if (ip.startsWith('127.') ||
ip.startsWith('192.168.') ||
ip.startsWith('10.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)) {
return true;
}
// IPv6 private/local ranges
const lower = ip.toLowerCase();
if (lower.startsWith('::1') || // localhost
lower.startsWith('fe80:') || // link-local
lower.startsWith('fc00:') || // unique local
lower.startsWith('fd00:')) { // unique local
return true;
}
return false;
};
// Extract unique public IPs from currently connected peers
const currentIPs = new Set(
peers
.map(p => extractIP(p.addr))
.filter(ip => ip && !isPrivateIP(ip))
);
// Cleanup old nodes (older than 24 hours)
await nodeHistory.cleanup();
// Get nodes from history (last 24 hours)
const historicalNodes = await nodeHistory.getRecentNodes();
// Find new IPs that need geolocation
const knownIPs = new Set(historicalNodes.map(n => n.ip));
const newIPs = Array.from(currentIPs).filter(ip => !knownIPs.has(ip));
// Geolocate new IPs
if (newIPs.length > 0) {
const axios = require('axios');
const batchResponse = await axios.post(
'http://ip-api.com/batch?fields=status,country,countryCode,city,lat,lon,query',
newIPs.slice(0, 100).map(ip => ({ query: ip })), // Limit to 100 IPs per batch
{
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
}
);
const geoResults = batchResponse.data;
for (const geo of geoResults) {
if (geo.status === 'success' && geo.lat && geo.lon) {
const isIPv6 = geo.query.includes(':');
await nodeHistory.updateNode(geo.query, {
lat: geo.lat,
lon: geo.lon,
city: geo.city,
country: geo.country,
countryCode: geo.countryCode,
ipVersion: isIPv6 ? 6 : 4
}, true);
}
}
}
// Update lastSeen for currently connected nodes
for (const ip of currentIPs) {
const existing = historicalNodes.find(n => n.ip === ip);
if (existing) {
await nodeHistory.updateNode(ip, {
lat: existing.lat,
lon: existing.lon,
city: existing.city,
country: existing.country,
countryCode: existing.countryCode,
ipVersion: existing.ipVersion
}, true);
}
}
// Mark nodes not currently connected as inactive
for (const node of historicalNodes) {
if (!currentIPs.has(node.ip)) {
await nodeHistory.updateNode(node.ip, {
lat: node.lat,
lon: node.lon,
city: node.city,
country: node.country,
countryCode: node.countryCode,
ipVersion: node.ipVersion
}, false);
}
}
// Save updated history
await nodeHistory.save();
// Get stats (all nodes from last 24 hours)
const stats = await nodeHistory.getStats();
res.json({
total: stats.total,
active: stats.active,
geolocated: stats.nodes.filter(n => n.lat && n.lon).length,
nodes: stats.nodes,
countries: stats.countries
});
} catch (error) {
logger.error('Error getting nodes with geolocation:', error);
res.status(500).json({ error: error.message });
}
});
// Get rich list (top addresses by balance)
app.get('/api/richlist/:limit?', async (req, res) => {
try {
const limit = parseInt(req.params.limit) || 100;
const data = richList.getTopAddresses(Math.min(limit, 1000)); // Max 1000
res.json(data);
} catch (error) {
logger.error('Error getting rich list:', error);
res.status(500).json({ error: error.message });
}
});
// Get address balance from rich list
app.get('/api/address/:address/balance', async (req, res) => {
try {
const { address } = req.params;
const data = richList.getTopAddresses(1000); // Get top 1000
const addressData = data.addresses.find(a => a.address === address);
if (addressData) {
res.json({
address,
balance: addressData.balance,
rank: addressData.rank,
lastSeen: addressData.lastSeen,
source: 'richlist'
});
} else {
res.json({
address,
balance: null,
message: 'Address not found in rich list. It may have low balance or scanner hasn\'t reached this address yet.',
lastScannedBlock: data.lastScannedBlock,
source: 'richlist'
});
}
} catch (error) {
logger.error('Error getting address balance:', error);
res.status(500).json({ error: error.message });
}
});
// Get latest blocks
app.get('/api/blocks/latest/:count?', async (req, res) => {
try {
const count = Math.min(parseInt(req.params.count) || 10, 50);
const currentHeight = await rpc.getBlockCount();
// Fetch blocks in batches of 5 (balance between speed and SSH connection limits)
const heights = Array.from({ length: count }, (_, i) => currentHeight - i).filter(h => h >= 0);
const blocks = [];
const BATCH_SIZE = 5;
for (let i = 0; i < heights.length; i += BATCH_SIZE) {
const batch = heights.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async (height) => {
try {
const block = await rpc.getBlock(height);
return {
height: block.height,
hash: block.hash,
time: block.time,
size: block.size,
txCount: block.tx ? block.tx.length : 0,
totalAmount: 0 // Skipped - would require additional SSH calls per tx
};
} catch (error) {
logger.warn(`Skipping block ${height}:`, error.message);
return null;
}
})
);
blocks.push(...batchResults.filter(b => b !== null));
}
res.json(blocks);
} catch (error) {
logger.error('Error getting latest blocks:', error);
res.status(500).json({ error: error.message });
}
});
// Get MAZA price in BTC, LTC, ETH (cached 60s)
let priceCache = { data: null, ts: 0 };
app.get("/api/price", async (req, res) => {
try {
if (Date.now() - priceCache.ts < 60000 && priceCache.data) {
return res.json(priceCache.data);
}
const https = require("https");
const fetch = (url) => new Promise((resolve, reject) => {
const options = {
headers: {
'User-Agent': 'Mazacoin Explorer/1.0 (https://maza.samiahmed7777.me)'
}
};
https.get(url, options, (r) => {
let d = "";
r.on("data", c => d += c);
r.on("end", () => resolve(JSON.parse(d)));
}).on("error", reject);
});
// Get MAZA/BTC from FreiExchange
const ticker = await fetch("https://api.freiexchange.com/public/ticker/MAZA");
const mazaBtc = ticker.MAZA_BTC ? ticker.MAZA_BTC[0] : null;
const mazaBtcPrice = mazaBtc ? parseFloat(mazaBtc.last) : 0;
// Get BTC/LTC and BTC/ETH ratios from CoinGecko
const cryptoRatios = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=litecoin,ethereum&vs_currencies=btc");
const ltcBtcRatio = cryptoRatios.litecoin ? cryptoRatios.litecoin.btc : 0;
const ethBtcRatio = cryptoRatios.ethereum ? cryptoRatios.ethereum.btc : 0;
// Calculate MAZA/LTC and MAZA/ETH
const mazaLtcPrice = ltcBtcRatio > 0 ? mazaBtcPrice / ltcBtcRatio : 0;
const mazaEthPrice = ethBtcRatio > 0 ? mazaBtcPrice / ethBtcRatio : 0;
const result = {
btc: mazaBtcPrice,
ltc: mazaLtcPrice,
eth: mazaEthPrice,
volume24h: mazaBtc ? parseFloat(mazaBtc.volume24h) : 0,
volume24hBtc: mazaBtc ? parseFloat(mazaBtc.volume24h_btc) : 0,
change24h: mazaBtc ? parseFloat(mazaBtc.percent_change_24h) : 0,
high24h: mazaBtc ? parseFloat(mazaBtc.high) : 0,
low24h: mazaBtc ? parseFloat(mazaBtc.low) : 0,
exchange: "FreiExchange + CoinGecko",
timestamp: new Date().toISOString()
};
// Log price to database (async, don't wait)
priceHistory.logPrice(
mazaBtcPrice,
mazaLtcPrice,
mazaEthPrice,
result.volume24h,
result.change24h,
'live'
).catch(err => logger.error('Error logging price to DB:', err));
priceCache = { data: result, ts: Date.now() };
res.json(result);
} catch (error) {
logger.error("Error fetching price:", error);
if (priceCache.data) return res.json(priceCache.data);
res.status(500).json({ error: error.message });
}
});
// Get price history for charts
app.get('/api/price/history/:timeframe?', async (req, res) => {
try {
const timeframe = req.params.timeframe || '24h';
const history = await priceHistory.getHistory(timeframe);
res.json({
timeframe,
data: history.map(row => ({
timestamp: row.timestamp,
time: new Date(row.timestamp * 1000).toISOString(),
btc: row.price_btc,
ltc: row.price_ltc,
eth: row.price_eth,
volume: row.volume_24h,
change: row.change_24h,
source: row.source
})),
count: history.length
});
} catch (error) {
logger.error('Error getting price history:', error);
res.status(500).json({ error: error.message });
}
});
// Search endpoint
app.get('/api/search/:query', async (req, res) => {
const { query } = req.params;
try {
// Try as block height
if (/^\d+$/.test(query)) {
const height = parseInt(query);
const currentHeight = await rpc.getBlockCount();
if (height <= currentHeight) {
const block = await rpc.getBlock(height);
return res.json({ type: 'block', data: block });
}
}
// Try as block hash (64 hex chars)
if (/^[0-9a-f]{64}$/i.test(query)) {
try {
const block = await rpc.getBlock(query);
return res.json({ type: 'block', data: block });
} catch {}
// Maybe it's a transaction
try {
const tx = await rpc.getTransaction(query);
return res.json({ type: 'transaction', data: tx });
} catch {}
}
// Try as Mazacoin address (starts with M)
if (query.startsWith('M')) {
// For now, return address placeholder
// Full implementation requires txindex
return res.json({
type: 'address',
data: {
address: query,
note: 'Address lookup requires full node with txindex enabled'
}
});
}
res.status(404).json({ error: 'Not found' });
} catch (error) {
logger.error(`Search error for ${query}:`, error);
res.status(500).json({ error: error.message });
}
});
// WebSocket for real-time updates
io.on('connection', (socket) => {
logger.info('Client connected to WebSocket');
socket.on('subscribe:blocks', async () => {
logger.info('Client subscribed to blocks');
// Send current block height
try {
const height = await rpc.getBlockCount();
socket.emit('block:height', { height });
} catch (error) {
logger.error('Error sending initial block height:', error);
}
});
socket.on('disconnect', () => {
logger.info('Client disconnected');
});
});
// Poll for new blocks and emit to clients
let lastBlockHeight = 0;
setInterval(async () => {
try {
const currentHeight = await rpc.getBlockCount();
if (currentHeight > lastBlockHeight) {
logger.info(`New block detected: ${currentHeight}`);
const block = await rpc.getBlock(currentHeight);
io.emit('block:new', {
height: currentHeight,
hash: block.hash,
time: block.time,
txCount: block.tx ? block.tx.length : 0
});
lastBlockHeight = currentHeight;
}
} catch (error) {
logger.error('Error polling for blocks:', error);
}
}, 15000); // Poll every 15 seconds
// Background node discovery - poll peers every 5 minutes to build 24h history
setInterval(async () => {
try {
const axios = require('axios');
const peers = await rpc.getPeerInfo();
const extractIP = (addr) => {
if (!addr) return null;
const ipv6Match = addr.match(/^\[([^\]]+)\]/);
if (ipv6Match) return ipv6Match[1];
return addr.split(':')[0];
};
const isPrivateIP = (ip) => {
if (!ip) return true;
if (ip.startsWith('127.') || ip.startsWith('192.168.') || ip.startsWith('10.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)) return true;
const lower = ip.toLowerCase();
if (lower.startsWith('::1') || lower.startsWith('fe80:') ||
lower.startsWith('fc00:') || lower.startsWith('fd00:')) return true;
return false;
};
const currentIPs = new Set(
peers.map(p => extractIP(p.addr)).filter(ip => ip && !isPrivateIP(ip))
);
// Check for new IPs not in history
const recentNodes = await nodeHistory.getRecentNodes();
const knownIPs = new Set(recentNodes.map(n => n.ip));
const newIPs = Array.from(currentIPs).filter(ip => !knownIPs.has(ip));
if (newIPs.length > 0) {
const batchResponse = await axios.post(
'http://ip-api.com/batch?fields=status,country,countryCode,city,lat,lon,query',
newIPs.slice(0, 100).map(ip => ({ query: ip })),
{ timeout: 10000, headers: { 'Content-Type': 'application/json' } }
);
for (const geo of batchResponse.data) {
if (geo.status === 'success' && geo.lat && geo.lon) {
await nodeHistory.updateNode(geo.query, {
lat: geo.lat, lon: geo.lon, city: geo.city,
country: geo.country, countryCode: geo.countryCode,
ipVersion: geo.query.includes(':') ? 6 : 4
}, true);
}
}
}
// Update lastSeen for known active nodes
for (const ip of currentIPs) {
const existing = recentNodes.find(n => n.ip === ip);
if (existing) {
await nodeHistory.updateNode(ip, {
lat: existing.lat, lon: existing.lon, city: existing.city,
country: existing.country, countryCode: existing.countryCode,
ipVersion: existing.ipVersion
}, true);
}
}
// Mark disconnected nodes as inactive
for (const node of recentNodes) {
if (!currentIPs.has(node.ip)) {
await nodeHistory.updateNode(node.ip, {
lat: node.lat, lon: node.lon, city: node.city,
country: node.country, countryCode: node.countryCode,
ipVersion: node.ipVersion
}, false);
}
}
await nodeHistory.cleanup();
await nodeHistory.save();
const stats = await nodeHistory.getStats();
logger.info(`Node discovery: ${stats.total} total (${stats.active} active) across ${Object.keys(stats.countries).length} countries`);
} catch (error) {
logger.error('Error in background node discovery:', error.message);
}
}, 1 * 60 * 1000); // Every 1 minute
// Initialize
async function init() {
// Start server immediately
server.listen(PORT, () => {
logger.info(`Mazacoin Explorer API running on port ${PORT}`);
});
try {
// Test RPC connection
const height = await rpc.getBlockCount();
logger.info(`Connected to Mazacoin node at block height: ${height}`);
lastBlockHeight = height;
// Initialize rich list scanner (in background, don't block)
richList.initialize().catch(err => {
logger.error('Rich list initialization error:', err);
});
} catch (error) {
logger.error('Failed to connect to Mazacoin node:', error);
logger.info('Server will start but RPC calls will fail until node is running');
}
}
init();
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down gracefully');
server.close(() => {
rpc.disconnect();
process.exit(0);
});
});