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:
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user