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:
+87
@@ -0,0 +1,87 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
*.env
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# SSH keys (NEVER commit these!)
|
||||
*.key
|
||||
*.pem
|
||||
*_rsa
|
||||
*_rsa.pub
|
||||
*_ed25519
|
||||
*_ed25519.pub
|
||||
id_*
|
||||
|
||||
# Database
|
||||
*.sqlite
|
||||
*.db
|
||||
data/
|
||||
*.sql
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Test
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Backend data (rich list cache, etc.)
|
||||
backend/data/*.json
|
||||
backend/data/*.db
|
||||
|
||||
# Frontend production build
|
||||
frontend/build/
|
||||
frontend/dist/
|
||||
|
||||
# Certificates
|
||||
*.crt
|
||||
*.pem
|
||||
*.cer
|
||||
|
||||
# Secrets
|
||||
secrets/
|
||||
*.secret
|
||||
@@ -0,0 +1,702 @@
|
||||
# Mazacoin Explorer API Documentation
|
||||
|
||||
Complete API reference for the Mazacoin blockchain explorer backend.
|
||||
|
||||
**Base URL:** `https://maza.samiahmed7777.me/api`
|
||||
**Protocol:** REST + WebSocket
|
||||
**Format:** JSON
|
||||
**Authentication:** None (public read-only API)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Health & Status](#health--status)
|
||||
2. [Blockchain Data](#blockchain-data)
|
||||
3. [Network Information](#network-information)
|
||||
4. [Search](#search)
|
||||
5. [Rich List](#rich-list)
|
||||
6. [WebSocket Events](#websocket-events)
|
||||
7. [Error Responses](#error-responses)
|
||||
8. [Rate Limiting](#rate-limiting)
|
||||
|
||||
---
|
||||
|
||||
## Health & Status
|
||||
|
||||
### GET /api/health
|
||||
|
||||
Health check endpoint for monitoring.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"blockHeight": 4128056,
|
||||
"timestamp": "2026-03-09T05:49:12.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
- `200 OK` - Service is healthy
|
||||
- `503 Service Unavailable` - Service is degraded or unavailable
|
||||
|
||||
---
|
||||
|
||||
## Blockchain Data
|
||||
|
||||
### GET /api/blockcount
|
||||
|
||||
Get the current blockchain height.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"height": 4128056
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl https://maza.samiahmed7777.me/api/blockcount
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/block/:hashOrHeight
|
||||
|
||||
Get detailed information about a specific block.
|
||||
|
||||
**Parameters:**
|
||||
- `hashOrHeight` (string|number) - Block hash or block height
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"hash": "0000000000000a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5",
|
||||
"confirmations": 125,
|
||||
"height": 4128000,
|
||||
"version": 536870912,
|
||||
"versionHex": "20000000",
|
||||
"merkleroot": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2",
|
||||
"time": 1709964512,
|
||||
"mediantime": 1709963200,
|
||||
"nonce": 123456789,
|
||||
"bits": "1a0fffff",
|
||||
"difficulty": 1.234567,
|
||||
"chainwork": "000000000000000000000000000000000000000000000001234567890abcdef",
|
||||
"nTx": 3,
|
||||
"previousblockhash": "0000000000000b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b",
|
||||
"nextblockhash": "0000000000000c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c",
|
||||
"tx": [
|
||||
"tx1_hash",
|
||||
"tx2_hash",
|
||||
"tx3_hash"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# By height
|
||||
curl https://maza.samiahmed7777.me/api/block/4128000
|
||||
|
||||
# By hash
|
||||
curl https://maza.samiahmed7777.me/api/block/0000000000000a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/blocks/latest/:count?
|
||||
|
||||
Get the latest N blocks with summary information.
|
||||
|
||||
**Parameters:**
|
||||
- `count` (optional, number) - Number of blocks to return (default: 10, max: 100)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"height": 4128056,
|
||||
"hash": "0000000000000...",
|
||||
"time": 1709964512,
|
||||
"txCount": 3,
|
||||
"totalAmount": 152.5,
|
||||
"size": 1234,
|
||||
"difficulty": 1.234567
|
||||
},
|
||||
{
|
||||
"height": 4128055,
|
||||
"hash": "0000000000001...",
|
||||
"time": 1709964312,
|
||||
"txCount": 5,
|
||||
"totalAmount": 328.75,
|
||||
"size": 2345,
|
||||
"difficulty": 1.234567
|
||||
}
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `totalAmount` excludes coinbase (mining reward) transactions
|
||||
- Blocks are ordered newest first
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl https://maza.samiahmed7777.me/api/blocks/latest/20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/tx/:txid
|
||||
|
||||
Get detailed information about a transaction.
|
||||
|
||||
**Parameters:**
|
||||
- `txid` (string) - Transaction ID (hash)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"txid": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2",
|
||||
"hash": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2",
|
||||
"version": 1,
|
||||
"size": 225,
|
||||
"vsize": 225,
|
||||
"locktime": 0,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "prev_tx_hash",
|
||||
"vout": 0,
|
||||
"scriptSig": {
|
||||
"asm": "...",
|
||||
"hex": "..."
|
||||
},
|
||||
"sequence": 4294967295
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"value": 50.123456,
|
||||
"n": 0,
|
||||
"scriptPubKey": {
|
||||
"asm": "OP_DUP OP_HASH160 ... OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"hex": "76a914...",
|
||||
"type": "pubkeyhash",
|
||||
"addresses": [
|
||||
"MAddress1234567890abcdefghijk"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"value": 10.654321,
|
||||
"n": 1,
|
||||
"scriptPubKey": {
|
||||
"asm": "...",
|
||||
"hex": "...",
|
||||
"type": "pubkeyhash",
|
||||
"addresses": [
|
||||
"MAddress0987654321zyxwvutsr"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"hex": "...",
|
||||
"blockhash": "0000000000000...",
|
||||
"confirmations": 125,
|
||||
"time": 1709964512,
|
||||
"blocktime": 1709964512
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl https://maza.samiahmed7777.me/api/tx/1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/address/:address/balance
|
||||
|
||||
Get balance for a specific address (from rich list cache).
|
||||
|
||||
**Parameters:**
|
||||
- `address` (string) - Mazacoin address
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"address": "MCU8e7DdJ8D2on5fBQfb4jTn2qX4DGiikw",
|
||||
"balance": 128183.78143,
|
||||
"rank": 1,
|
||||
"lastSeen": 4078045,
|
||||
"percentOfSupply": 0.0512
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Only works for addresses in the top 1000 (rich list)
|
||||
- Balance is approximate (tracks outputs only, not spent inputs)
|
||||
- Returns 404 if address not in rich list
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl https://maza.samiahmed7777.me/api/address/MCU8e7DdJ8D2on5fBQfb4jTn2qX4DGiikw/balance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Network Information
|
||||
|
||||
### GET /api/stats
|
||||
|
||||
Get current network statistics.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"height": 4128056,
|
||||
"difficulty": 1.234567,
|
||||
"networkhashps": 12345678901234,
|
||||
"connections": 8,
|
||||
"version": 1000000,
|
||||
"subversion": "/Satoshi:0.10.0/",
|
||||
"protocolversion": 70002,
|
||||
"timeoffset": 0,
|
||||
"warnings": ""
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl https://maza.samiahmed7777.me/api/stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/peers
|
||||
|
||||
Get list of connected peer nodes (for node map).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"peers": [
|
||||
{
|
||||
"addr": "123.45.67.89:12835",
|
||||
"version": 70002,
|
||||
"subver": "/Satoshi:0.10.0/",
|
||||
"conntime": 1709960000,
|
||||
"synced_blocks": 4128056,
|
||||
"synced_headers": 4128056
|
||||
}
|
||||
],
|
||||
"count": 8
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl https://maza.samiahmed7777.me/api/peers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/nodes
|
||||
|
||||
Get geolocated peer nodes for the node map (includes geolocation data).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"total": 8,
|
||||
"geolocated": 8,
|
||||
"nodes": [
|
||||
{
|
||||
"ip": "123.45.67.89",
|
||||
"port": 12835,
|
||||
"country": "United States",
|
||||
"countryCode": "US",
|
||||
"region": "California",
|
||||
"city": "San Francisco",
|
||||
"lat": 37.7749,
|
||||
"lon": -122.4194,
|
||||
"version": "/Satoshi:0.10.0/",
|
||||
"conntime": 1709960000
|
||||
}
|
||||
],
|
||||
"countries": {
|
||||
"US": 2,
|
||||
"FI": 2,
|
||||
"NL": 1,
|
||||
"FR": 1,
|
||||
"BG": 1,
|
||||
"SG": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Includes geolocation for all active peers
|
||||
- `countries` object shows distribution
|
||||
- Geolocation happens server-side via ip-api.com
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl https://maza.samiahmed7777.me/api/nodes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/price
|
||||
|
||||
Get current MAZA price in BTC, LTC, and ETH.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"MAZA": {
|
||||
"BTC": 0.00000004,
|
||||
"LTC": 0.00000123,
|
||||
"ETH": 0.00000056
|
||||
},
|
||||
"timestamp": "2026-03-09T05:49:12.000Z",
|
||||
"sources": {
|
||||
"freiexchange": "https://freiexchange.com/market/MAZA/BTC",
|
||||
"coingecko": "https://www.coingecko.com/en/coins/litecoin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- MAZA/BTC from FreiExchange API
|
||||
- MAZA/LTC and MAZA/ETH calculated from CoinGecko BTC ratios
|
||||
- Cached for 60 seconds server-side
|
||||
- No USD price (design decision)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl https://maza.samiahmed7777.me/api/price
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Search
|
||||
|
||||
### GET /api/search/:query
|
||||
|
||||
Universal search for blocks, transactions, or addresses.
|
||||
|
||||
**Parameters:**
|
||||
- `query` (string) - Search term (block height, block hash, tx hash, or address)
|
||||
|
||||
**Response:**
|
||||
|
||||
**For block height:**
|
||||
```json
|
||||
{
|
||||
"type": "block",
|
||||
"result": { /* block object */ }
|
||||
}
|
||||
```
|
||||
|
||||
**For block hash:**
|
||||
```json
|
||||
{
|
||||
"type": "block",
|
||||
"result": { /* block object */ }
|
||||
}
|
||||
```
|
||||
|
||||
**For transaction:**
|
||||
```json
|
||||
{
|
||||
"type": "transaction",
|
||||
"result": { /* transaction object */ }
|
||||
}
|
||||
```
|
||||
|
||||
**For address:**
|
||||
```json
|
||||
{
|
||||
"type": "address",
|
||||
"result": {
|
||||
"address": "MAddress...",
|
||||
"balance": 123.45,
|
||||
"txCount": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Not found:**
|
||||
```json
|
||||
{
|
||||
"type": "unknown",
|
||||
"error": "Not found"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Search by block height
|
||||
curl https://maza.samiahmed7777.me/api/search/4128000
|
||||
|
||||
# Search by hash
|
||||
curl https://maza.samiahmed7777.me/api/search/0000000000000a1b2c3d...
|
||||
|
||||
# Search by address
|
||||
curl https://maza.samiahmed7777.me/api/search/MCU8e7DdJ8D2on5fBQfb4jTn2qX4DGiikw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rich List
|
||||
|
||||
### GET /api/richlist/:limit?
|
||||
|
||||
Get top addresses by balance.
|
||||
|
||||
**Parameters:**
|
||||
- `limit` (optional, number) - Number of addresses to return (default: 100, max: 1000)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"addresses": [
|
||||
{
|
||||
"rank": 1,
|
||||
"address": "MCU8e7DdJ8D2on5fBQfb4jTn2qX4DGiikw",
|
||||
"balance": 128183.78143,
|
||||
"lastSeen": 4078045,
|
||||
"percentOfSupply": 0.0512
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"address": "MMvvMybGw83fU1quLzCAHVYv7jE1dAm5QV",
|
||||
"balance": 15000.49,
|
||||
"lastSeen": 4077832,
|
||||
"percentOfSupply": 0.0060
|
||||
}
|
||||
],
|
||||
"lastScannedBlock": 4078232,
|
||||
"totalAddresses": 132,
|
||||
"isScanning": true,
|
||||
"blocksBehind": 49824
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Balances are approximate (tracks outputs only)
|
||||
- Scanner runs in background, updating every 5 minutes
|
||||
- `isScanning: true` means scanner is actively catching up
|
||||
- `percentOfSupply` calculated based on max supply (2.4 billion MAZA)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Get top 10
|
||||
curl https://maza.samiahmed7777.me/api/richlist/10
|
||||
|
||||
# Get top 100 (default)
|
||||
curl https://maza.samiahmed7777.me/api/richlist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
The explorer uses Socket.IO for real-time updates.
|
||||
|
||||
**Connection:**
|
||||
```javascript
|
||||
import io from 'socket.io-client';
|
||||
|
||||
const socket = io('https://maza.samiahmed7777.me');
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to explorer');
|
||||
});
|
||||
```
|
||||
|
||||
### Event: `block:new`
|
||||
|
||||
Emitted when a new block is mined.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"height": 4128057,
|
||||
"hash": "0000000000000...",
|
||||
"time": 1709964712,
|
||||
"txCount": 3,
|
||||
"difficulty": 1.234567
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
socket.on('block:new', (block) => {
|
||||
console.log('New block:', block.height);
|
||||
// Update UI with new block
|
||||
});
|
||||
```
|
||||
|
||||
### Event: `block:height`
|
||||
|
||||
Emitted periodically with current blockchain height.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"height": 4128057
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
socket.on('block:height', (data) => {
|
||||
console.log('Current height:', data.height);
|
||||
});
|
||||
```
|
||||
|
||||
### Subscribe to New Blocks
|
||||
|
||||
```javascript
|
||||
socket.emit('subscribe:blocks');
|
||||
|
||||
socket.on('block:new', (block) => {
|
||||
console.log('New block mined:', block);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All API errors return JSON with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Error message",
|
||||
"code": "ERROR_CODE",
|
||||
"details": { /* optional additional info */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Common Error Codes
|
||||
|
||||
| HTTP Status | Code | Description |
|
||||
|-------------|------|-------------|
|
||||
| 400 | `INVALID_PARAMETER` | Invalid request parameter |
|
||||
| 404 | `NOT_FOUND` | Block, transaction, or address not found |
|
||||
| 429 | `RATE_LIMIT_EXCEEDED` | Too many requests |
|
||||
| 500 | `INTERNAL_ERROR` | Server error |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | Mazacoin node is unreachable |
|
||||
|
||||
**Example Error Response:**
|
||||
```json
|
||||
{
|
||||
"error": "Block not found",
|
||||
"code": "NOT_FOUND",
|
||||
"details": {
|
||||
"query": "999999999"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
**Current Limits:**
|
||||
- **General API:** 100 requests per minute per IP
|
||||
- **Search endpoint:** 30 requests per minute per IP
|
||||
- **WebSocket connections:** 10 concurrent connections per IP
|
||||
|
||||
**Rate Limit Headers:**
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 95
|
||||
X-RateLimit-Reset: 1709964800
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded Response:**
|
||||
```json
|
||||
{
|
||||
"error": "Rate limit exceeded",
|
||||
"code": "RATE_LIMIT_EXCEEDED",
|
||||
"retryAfter": 60
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Caching
|
||||
|
||||
Cache responses client-side to reduce API calls:
|
||||
- Block data: Cache for 10+ minutes (blocks are immutable after confirmations)
|
||||
- Network stats: Cache for 30-60 seconds
|
||||
- Price data: Cache for 60 seconds (already cached server-side)
|
||||
|
||||
### Pagination
|
||||
|
||||
For large datasets (e.g., transaction history), use pagination:
|
||||
```
|
||||
/api/address/:address/txs?page=1&limit=50
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Always handle errors gracefully:
|
||||
```javascript
|
||||
try {
|
||||
const response = await fetch('/api/block/4128000');
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('API error:', error.message);
|
||||
}
|
||||
const data = await response.json();
|
||||
} catch (err) {
|
||||
console.error('Network error:', err);
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket Reconnection
|
||||
|
||||
Implement reconnection logic for WebSocket:
|
||||
```javascript
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected, attempting to reconnect...');
|
||||
setTimeout(() => socket.connect(), 5000);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.0.0 (2026-03-09)
|
||||
- Initial release
|
||||
- Block, transaction, and address lookup
|
||||
- Network statistics
|
||||
- Live node map with geolocation
|
||||
- Rich list (top 1000 addresses)
|
||||
- WebSocket real-time updates
|
||||
- Price ticker (BTC/LTC/ETH)
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For API issues or questions:
|
||||
- **GitHub Issues:** https://git.dashcaddy.net/sami/mazacoin-explorer/issues
|
||||
- **Documentation:** See `README.md` and `INSTALL.md`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** March 9, 2026
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
# Mazacoin Explorer - Installation Guide
|
||||
|
||||
Complete step-by-step instructions for deploying the Mazacoin blockchain explorer.
|
||||
|
||||
## Table of Contents
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [System Requirements](#system-requirements)
|
||||
3. [Mazacoin Node Setup](#mazacoin-node-setup)
|
||||
4. [SSH Access Configuration](#ssh-access-configuration)
|
||||
5. [Explorer Deployment](#explorer-deployment)
|
||||
6. [DNS & Reverse Proxy Setup](#dns--reverse-proxy-setup)
|
||||
7. [Verification](#verification)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Services
|
||||
- **Mazacoin full node** running with RPC enabled
|
||||
- **Docker** 20.10+ and **docker-compose** 1.29+
|
||||
- **SSH access** to the Mazacoin node (if remote)
|
||||
- **Domain name** with DNS configured
|
||||
- **Reverse proxy** (Caddy recommended) with TLS support
|
||||
|
||||
### Network Access
|
||||
- Mazacoin node must be accessible via SSH (if remote) or local network
|
||||
- Port 8080 available for the explorer frontend
|
||||
- Port 3000 available for the explorer backend API
|
||||
|
||||
---
|
||||
|
||||
## System Requirements
|
||||
|
||||
### Minimum Specs
|
||||
- **CPU:** 2 cores
|
||||
- **RAM:** 2 GB
|
||||
- **Disk:** 10 GB free space
|
||||
- **Network:** Stable internet connection
|
||||
|
||||
### Recommended Specs
|
||||
- **CPU:** 4+ cores
|
||||
- **RAM:** 4+ GB
|
||||
- **Disk:** 20+ GB SSD
|
||||
- **Network:** 100+ Mbps
|
||||
|
||||
---
|
||||
|
||||
## Mazacoin Node Setup
|
||||
|
||||
### Option 1: Windows Node (GUI Wallet)
|
||||
|
||||
1. **Download Mazacoin wallet** from https://mazacoin.org/downloads
|
||||
|
||||
2. **Install** to desired location (e.g., `E:\coins\MAZA`)
|
||||
|
||||
3. **Create RPC configuration file** at `E:\coins\MAZA\maza.conf`:
|
||||
```ini
|
||||
# RPC Settings
|
||||
server=1
|
||||
rpcuser=mazarpc
|
||||
rpcpassword=YOUR_SECURE_PASSWORD_HERE
|
||||
rpcallowip=127.0.0.1
|
||||
rpcport=12832
|
||||
|
||||
# Optional: Enable transaction index (required for full address lookup)
|
||||
txindex=1
|
||||
```
|
||||
|
||||
4. **Start wallet with RPC enabled:**
|
||||
```powershell
|
||||
E:\coins\MAZA\maza-qt.exe -server
|
||||
```
|
||||
|
||||
5. **Verify RPC works:**
|
||||
```powershell
|
||||
E:\coins\MAZA\daemon\maza-cli.exe -datadir=E:\coins\MAZA getblockcount
|
||||
```
|
||||
|
||||
### Option 2: Linux Node (Daemon)
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install build-essential libtool autotools-dev automake pkg-config \
|
||||
libssl-dev libevent-dev bsdmainutils libboost-all-dev
|
||||
```
|
||||
|
||||
2. **Download and compile Mazacoin:**
|
||||
```bash
|
||||
git clone https://github.com/MazaCoin/maza.git
|
||||
cd maza
|
||||
./autogen.sh
|
||||
./configure
|
||||
make
|
||||
sudo make install
|
||||
```
|
||||
|
||||
3. **Create config file** at `~/.maza/maza.conf`:
|
||||
```ini
|
||||
server=1
|
||||
rpcuser=mazarpc
|
||||
rpcpassword=YOUR_SECURE_PASSWORD_HERE
|
||||
rpcallowip=127.0.0.1
|
||||
rpcport=12832
|
||||
txindex=1
|
||||
daemon=1
|
||||
```
|
||||
|
||||
4. **Start daemon:**
|
||||
```bash
|
||||
mazacoind
|
||||
```
|
||||
|
||||
5. **Verify:**
|
||||
```bash
|
||||
maza-cli getblockcount
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSH Access Configuration
|
||||
|
||||
### If Mazacoin node is on a remote machine:
|
||||
|
||||
1. **Generate SSH key pair** on the explorer server:
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/mazacoin_node -C "mazacoin-explorer"
|
||||
```
|
||||
|
||||
2. **Copy public key to node:**
|
||||
```bash
|
||||
ssh-copy-id -i ~/.ssh/mazacoin_node.pub user@node-ip
|
||||
```
|
||||
|
||||
3. **Test connection:**
|
||||
```bash
|
||||
ssh -i ~/.ssh/mazacoin_node user@node-ip "hostname"
|
||||
```
|
||||
|
||||
4. **Update backend configuration** to use this key (see [Explorer Deployment](#explorer-deployment))
|
||||
|
||||
---
|
||||
|
||||
## Explorer Deployment
|
||||
|
||||
### Step 1: Clone Repository
|
||||
|
||||
```bash
|
||||
git clone https://git.dashcaddy.net/sami/mazacoin-explorer.git
|
||||
cd mazacoin-explorer
|
||||
```
|
||||
|
||||
### Step 2: Configure Backend
|
||||
|
||||
Edit `backend/.env` (create if it doesn't exist):
|
||||
|
||||
```env
|
||||
# Node Environment
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# Mazacoin Node Connection
|
||||
MAZA_HOST=100.85.236.10
|
||||
MAZA_USER=hello
|
||||
MAZA_CLI_PATH=E:\\coins\\MAZA\\daemon\\maza-cli.exe
|
||||
MAZA_DATADIR=E:\\coins\\MAZA
|
||||
SSH_KEY_PATH=/root/.ssh/krystie_to_sami_pc
|
||||
|
||||
# API Configuration
|
||||
CACHE_TTL=60
|
||||
MAX_BLOCKS_PER_REQUEST=100
|
||||
WEBSOCKET_POLL_INTERVAL=15000
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Use double backslashes `\\` for Windows paths in env files
|
||||
- Adjust `MAZA_HOST`, `MAZA_USER`, `SSH_KEY_PATH` to match your setup
|
||||
- If running node locally (not via SSH), set `MAZA_HOST=localhost`
|
||||
|
||||
### Step 3: Configure Frontend
|
||||
|
||||
Edit `frontend/.env`:
|
||||
|
||||
```env
|
||||
# API URL (leave empty for same-origin, or specify full URL)
|
||||
REACT_APP_API_URL=
|
||||
|
||||
# For development, use:
|
||||
# REACT_APP_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### Step 4: Build and Start Services
|
||||
|
||||
```bash
|
||||
# Build Docker images
|
||||
docker-compose build
|
||||
|
||||
# Start containers in detached mode
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### Step 5: Verify Services
|
||||
|
||||
```bash
|
||||
# Check container status
|
||||
docker-compose ps
|
||||
|
||||
# Test backend API
|
||||
curl http://localhost:3000/api/health
|
||||
|
||||
# Expected response:
|
||||
# {"status":"ok","blockHeight":4128056}
|
||||
|
||||
# Test frontend
|
||||
curl http://localhost:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DNS & Reverse Proxy Setup
|
||||
|
||||
### Option 1: Using DashCaddy (Automated)
|
||||
|
||||
If you have DashCaddy installed:
|
||||
|
||||
```bash
|
||||
# Run the deployment script
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create Caddy reverse proxy configuration
|
||||
- Set up automatic Let's Encrypt TLS
|
||||
- Configure DNS if using Technitium
|
||||
|
||||
### Option 2: Manual Caddy Configuration
|
||||
|
||||
Create `/etc/caddy/sites/maza.yourdomain.com`:
|
||||
|
||||
```caddy
|
||||
maza.yourdomain.com {
|
||||
reverse_proxy localhost:8080
|
||||
|
||||
# Optional: API sub-path
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
# TLS
|
||||
tls {
|
||||
protocols tls1.2 tls1.3
|
||||
}
|
||||
|
||||
# Headers
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy no-referrer-when-downgrade
|
||||
}
|
||||
|
||||
# Logging
|
||||
log {
|
||||
output file /var/log/caddy/maza.log
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reload Caddy:
|
||||
```bash
|
||||
systemctl reload caddy
|
||||
```
|
||||
|
||||
### Option 3: Nginx Configuration
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name maza.yourdomain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name maza.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# WebSocket support
|
||||
location /socket.io/ {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### 1. Check All Services Running
|
||||
|
||||
```bash
|
||||
# Docker containers
|
||||
docker ps --filter name=mazacoin
|
||||
|
||||
# Expected output:
|
||||
# mazacoin-backend Up X minutes
|
||||
# mazacoin-frontend Up X minutes
|
||||
```
|
||||
|
||||
### 2. Test API Endpoints
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl https://maza.yourdomain.com/api/health
|
||||
|
||||
# Block count
|
||||
curl https://maza.yourdomain.com/api/blockcount
|
||||
|
||||
# Latest blocks
|
||||
curl https://maza.yourdomain.com/api/blocks/latest/5
|
||||
|
||||
# Network stats
|
||||
curl https://maza.yourdomain.com/api/stats
|
||||
```
|
||||
|
||||
### 3. Test Frontend
|
||||
|
||||
Visit https://maza.yourdomain.com in your browser and verify:
|
||||
- ✅ Home page loads with latest blocks
|
||||
- ✅ Search works (try searching a block number)
|
||||
- ✅ Block detail page displays correctly
|
||||
- ✅ Node map shows active nodes
|
||||
- ✅ Network stats are populated
|
||||
|
||||
### 4. Test WebSocket
|
||||
|
||||
Open browser console on https://maza.yourdomain.com and check:
|
||||
```
|
||||
WebSocket connection to 'wss://maza.yourdomain.com/socket.io/' succeeded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend Won't Start
|
||||
|
||||
**Issue:** `Error: connect ECONNREFUSED` or SSH connection fails
|
||||
|
||||
**Solutions:**
|
||||
1. Verify Mazacoin node is running:
|
||||
```bash
|
||||
ssh -i ~/.ssh/mazacoin_node user@node-ip "tasklist | findstr maza" # Windows
|
||||
ssh -i ~/.ssh/mazacoin_node user@node-ip "pgrep -f mazacoin" # Linux
|
||||
```
|
||||
|
||||
2. Test RPC manually:
|
||||
```bash
|
||||
ssh -i ~/.ssh/mazacoin_node user@node-ip "maza-cli getblockcount"
|
||||
```
|
||||
|
||||
3. Check backend logs:
|
||||
```bash
|
||||
docker logs mazacoin-backend --tail 50
|
||||
```
|
||||
|
||||
### Frontend Shows "API Error"
|
||||
|
||||
**Issue:** Frontend can't reach backend API
|
||||
|
||||
**Solutions:**
|
||||
1. Check backend is responding:
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
2. Verify environment variable `REACT_APP_API_URL` is correct in frontend
|
||||
|
||||
3. Check CORS settings in backend if accessing from different domain
|
||||
|
||||
4. Review frontend logs:
|
||||
```bash
|
||||
docker logs mazacoin-frontend --tail 50
|
||||
```
|
||||
|
||||
### Node Map Not Showing Nodes
|
||||
|
||||
**Issue:** Map loads but shows 0 nodes
|
||||
|
||||
**Solutions:**
|
||||
1. Check if Mazacoin node has active peer connections:
|
||||
```bash
|
||||
curl http://localhost:3000/api/peers
|
||||
```
|
||||
|
||||
2. Verify IP geolocation API is accessible (rate limits?)
|
||||
|
||||
3. Check browser console for JavaScript errors
|
||||
|
||||
4. Wait a few minutes for initial geolocation to complete
|
||||
|
||||
### SSL Certificate Issues
|
||||
|
||||
**Issue:** "Your connection is not private" or certificate errors
|
||||
|
||||
**Solutions:**
|
||||
1. Verify DNS is pointing to correct IP:
|
||||
```bash
|
||||
dig maza.yourdomain.com
|
||||
```
|
||||
|
||||
2. Check Caddy logs:
|
||||
```bash
|
||||
journalctl -u caddy --no-pager -n 50
|
||||
```
|
||||
|
||||
3. Manually request certificate:
|
||||
```bash
|
||||
caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
4. Ensure ports 80 and 443 are open:
|
||||
```bash
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
```
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
**Issue:** Backend container using excessive RAM
|
||||
|
||||
**Solutions:**
|
||||
1. Limit Docker container memory:
|
||||
```yaml
|
||||
# In docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
2. Reduce cache size in backend config
|
||||
|
||||
3. Enable MongoDB for persistent caching instead of in-memory cache
|
||||
|
||||
### Slow Block Queries
|
||||
|
||||
**Issue:** Block detail pages take >5 seconds to load
|
||||
|
||||
**Solutions:**
|
||||
1. Check Mazacoin node responsiveness:
|
||||
```bash
|
||||
time ssh user@node "maza-cli getblock BLOCKHASH"
|
||||
```
|
||||
|
||||
2. Enable MongoDB caching (see backend README)
|
||||
|
||||
3. Reduce concurrent block fetches (edit `backend/src/rpc/MazacoinRPC.js`)
|
||||
|
||||
4. Consider running Mazacoin node on faster hardware
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Enable Caching
|
||||
|
||||
MongoDB caching can significantly improve performance:
|
||||
|
||||
1. Start MongoDB container:
|
||||
```bash
|
||||
docker run -d --name mongo \
|
||||
-p 27017:27017 \
|
||||
-v /data/mongo:/data/db \
|
||||
mongo:6
|
||||
```
|
||||
|
||||
2. Update backend `.env`:
|
||||
```env
|
||||
MONGO_URI=mongodb://localhost:27017/mazacoin
|
||||
ENABLE_DB_CACHE=true
|
||||
```
|
||||
|
||||
3. Restart backend:
|
||||
```bash
|
||||
docker-compose restart backend
|
||||
```
|
||||
|
||||
### CDN Integration
|
||||
|
||||
For faster static asset delivery:
|
||||
|
||||
1. Use Cloudflare in front of your domain
|
||||
2. Enable caching for `/static/*` paths
|
||||
3. Configure browser caching headers in Caddy
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
Set up automated monitoring:
|
||||
|
||||
```bash
|
||||
# Simple uptime check (add to cron)
|
||||
*/5 * * * * curl -sf https://maza.yourdomain.com/api/health || echo "Explorer down!" | mail -s "Alert" admin@yourdomain.com
|
||||
```
|
||||
|
||||
### Prometheus Metrics (Optional)
|
||||
|
||||
The backend can export Prometheus metrics:
|
||||
|
||||
1. Enable in backend config
|
||||
2. Add scrape target to Prometheus:
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'mazacoin-explorer'
|
||||
static_configs:
|
||||
- targets: ['localhost:3000']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
To update the explorer to a new version:
|
||||
|
||||
```bash
|
||||
cd mazacoin-explorer
|
||||
|
||||
# Pull latest code
|
||||
git pull origin main
|
||||
|
||||
# Rebuild images
|
||||
docker-compose build
|
||||
|
||||
# Restart services (zero-downtime)
|
||||
docker-compose up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup & Recovery
|
||||
|
||||
### Backup Configuration
|
||||
|
||||
```bash
|
||||
# Backup env files
|
||||
cp backend/.env backend/.env.backup
|
||||
cp frontend/.env frontend/.env.backup
|
||||
|
||||
# Backup Caddy config
|
||||
sudo cp /etc/caddy/sites/maza.yourdomain.com /etc/caddy/sites/maza.yourdomain.com.backup
|
||||
```
|
||||
|
||||
### Database Backup (if using MongoDB)
|
||||
|
||||
```bash
|
||||
docker exec mongo mongodump --out /backup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or contributions:
|
||||
- **Issues:** https://git.dashcaddy.net/sami/mazacoin-explorer/issues
|
||||
- **Docs:** See `README.md` and `TROUBLESHOOTING.md`
|
||||
- **Mazacoin Community:** https://mazacoin.org
|
||||
|
||||
---
|
||||
|
||||
**Installation complete! 🎉**
|
||||
|
||||
Your Mazacoin blockchain explorer should now be running at your configured domain.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sami Ahmed
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,281 @@
|
||||
# Mazacoin Blockchain Explorer
|
||||
|
||||
A full-featured blockchain explorer for Mazacoin (MAZA), featuring real-time block updates, transaction search, network statistics, and a live node map.
|
||||
|
||||
🌐 **Live at:** https://maza.samiahmed7777.me
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Block Explorer** - Search and view blocks by height or hash
|
||||
- ✅ **Transaction Viewer** - Detailed transaction information with inputs/outputs
|
||||
- ✅ **Address Lookup** - View address details with QR code generation
|
||||
- ✅ **Network Statistics** - Real-time network stats dashboard
|
||||
- ✅ **Live Node Map** - Interactive world map showing geo-located Mazacoin nodes
|
||||
- ✅ **Real-time Updates** - WebSocket integration for live block notifications
|
||||
- ✅ **Search** - Universal search for blocks, transactions, and addresses
|
||||
- ✅ **Dark Mode UI** - Professional blockchain explorer design
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- **Node.js** + Express
|
||||
- **MongoDB** (planned for caching)
|
||||
- **Socket.IO** for real-time WebSocket updates
|
||||
- **SSH** client for Mazacoin RPC connection
|
||||
- **Node-cache** for query caching
|
||||
|
||||
### Frontend
|
||||
- **React** 18 with React Router
|
||||
- **TailwindCSS** for styling
|
||||
- **Leaflet.js** for interactive maps
|
||||
- **Chart.js** for analytics (ready for expansion)
|
||||
- **Lucide React** for icons
|
||||
- **QRCode.react** for address QR codes
|
||||
|
||||
### Infrastructure
|
||||
- **Docker** + docker-compose
|
||||
- **Nginx** (frontend reverse proxy)
|
||||
- **Caddy** (main reverse proxy via DashCaddy)
|
||||
- **Tailscale** network for secure node access
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Mazacoin Node** running on `100.85.236.10` (Windows PC)
|
||||
- Wallet path: `E:\coins\MAZA`
|
||||
- CLI: `E:\coins\MAZA\daemon\maza-cli.exe`
|
||||
- SSH access configured with key at `~/.ssh/krystie_to_sami_pc`
|
||||
|
||||
2. **Docker** and **docker-compose** installed
|
||||
|
||||
3. **DashCaddy** running on DNS1 (100.71.97.12)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start the Mazacoin Node
|
||||
|
||||
On the Windows PC (100.85.236.10):
|
||||
|
||||
```powershell
|
||||
# Option 1: GUI with RPC enabled
|
||||
E:\coins\MAZA\maza-qt.exe -server
|
||||
|
||||
# Option 2: Check if daemon exists and use it
|
||||
E:\coins\MAZA\daemon\mazacoind.exe -daemon
|
||||
```
|
||||
|
||||
### 2. Build and Deploy
|
||||
|
||||
```bash
|
||||
cd /root/Projects/mazacoin-explorer
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
The deployment script will:
|
||||
- Build Docker images
|
||||
- Start frontend and backend containers
|
||||
- Configure Caddy reverse proxy
|
||||
- Make the explorer available at https://maza.samiahmed7777.me
|
||||
|
||||
### 3. Manual Deployment (Alternative)
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker-compose build
|
||||
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev # Uses nodemon for auto-restart
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm start # Runs on http://localhost:3001
|
||||
```
|
||||
|
||||
**Note:** Update `.env` or `src/config.js` to point to your local backend during development.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Blockchain Data
|
||||
- `GET /api/health` - Health check
|
||||
- `GET /api/blockcount` - Latest block height
|
||||
- `GET /api/block/:hashOrHeight` - Get block by hash or height
|
||||
- `GET /api/tx/:txid` - Get transaction by ID
|
||||
- `GET /api/blocks/latest/:count` - Get latest N blocks
|
||||
- `GET /api/search/:query` - Universal search
|
||||
|
||||
### Network Info
|
||||
- `GET /api/stats` - Network statistics
|
||||
- `GET /api/peers` - Connected peer nodes (for node map)
|
||||
|
||||
### WebSocket Events
|
||||
- `subscribe:blocks` - Subscribe to new block notifications
|
||||
- `block:new` - Receive new block events
|
||||
- `block:height` - Current block height
|
||||
|
||||
## Configuration
|
||||
|
||||
### Backend Environment Variables
|
||||
|
||||
Create `backend/.env`:
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
SSH_KEY_PATH=/root/.ssh/krystie_to_sami_pc
|
||||
```
|
||||
|
||||
### Frontend Environment Variables
|
||||
|
||||
Create `frontend/.env`:
|
||||
|
||||
```env
|
||||
REACT_APP_API_URL=https://maza.samiahmed7777.me
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ User Browser │
|
||||
└────────┬────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Caddy (DNS1) │ ← DashCaddy managed
|
||||
│ :443 → :8080 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │────▶│ Backend │
|
||||
│ (React/Nginx) │ │ (Node.js API) │
|
||||
│ Port 8080 │ │ Port 3000 │
|
||||
└─────────────────┘ └────────┬────────┘
|
||||
│ SSH
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Mazacoin Node │
|
||||
│ (sami-pc) │
|
||||
│ 100.85.236.10 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# Backend logs
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Frontend logs
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# All logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### Check Service Status
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
|
||||
```bash
|
||||
# Restart all
|
||||
docker-compose restart
|
||||
|
||||
# Restart specific service
|
||||
docker-compose restart backend
|
||||
docker-compose restart frontend
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend can't connect to Mazacoin node
|
||||
|
||||
1. Check if Mazacoin is running on sami-pc:
|
||||
```bash
|
||||
ssh -i ~/.ssh/krystie_to_sami_pc hello@100.85.236.10 "tasklist | findstr maza"
|
||||
```
|
||||
|
||||
2. Test RPC connection:
|
||||
```bash
|
||||
ssh -i ~/.ssh/krystie_to_sami_pc hello@100.85.236.10 "E:\coins\MAZA\daemon\maza-cli.exe getblockcount"
|
||||
```
|
||||
|
||||
3. Check backend logs:
|
||||
```bash
|
||||
docker-compose logs backend | grep -i error
|
||||
```
|
||||
|
||||
### Frontend shows "API Error"
|
||||
|
||||
1. Check if backend is running:
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
2. Check frontend logs for configuration issues:
|
||||
```bash
|
||||
docker-compose logs frontend
|
||||
```
|
||||
|
||||
### Node map not showing nodes
|
||||
|
||||
The node map requires:
|
||||
1. Mazacoin node to have active peer connections
|
||||
2. Internet access for IP geolocation API calls
|
||||
3. May take a few minutes to geolocate all peers
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] MongoDB integration for blockchain data caching
|
||||
- [ ] Full address balance lookup (requires txindex)
|
||||
- [ ] Transaction mempool viewer
|
||||
- [ ] Charts for hashrate/difficulty trends
|
||||
- [ ] Rich list (top addresses by balance)
|
||||
- [ ] Mining pool statistics
|
||||
- [ ] Block time analysis
|
||||
- [ ] API rate limiting
|
||||
- [ ] Prometheus metrics export
|
||||
- [ ] Mobile app (React Native)
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a private project for the Mazacoin community. For questions or suggestions, contact the maintainer.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details
|
||||
|
||||
## Credits
|
||||
|
||||
Built with ❤️ for the Mazacoin community
|
||||
|
||||
- **Mazacoin:** https://mazacoin.org
|
||||
- **Explorer:** https://maza.samiahmed7777.me
|
||||
|
||||
---
|
||||
|
||||
**Deployment Status:** ✅ Ready for deployment
|
||||
|
||||
**Last Updated:** March 6, 2026
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# Mazacoin Blockchain Explorer - Requirements
|
||||
|
||||
## Project Overview
|
||||
Build a full-featured blockchain explorer for Mazacoin (similar to blockchain.com or blockchair.com).
|
||||
|
||||
**Domain:** maza.samiahmed7777.me
|
||||
**Mazacoin Node:** Running on Windows PC at 100.85.236.10 (Tailscale), wallet at E:\coins\MAZA
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Block Explorer
|
||||
- Search by block height or hash
|
||||
- Display: height, hash, timestamp, size, transactions, miner address
|
||||
- Previous/next block navigation
|
||||
- Latest blocks feed (real-time)
|
||||
|
||||
### 2. Transaction Viewer
|
||||
- Search by transaction ID
|
||||
- Display: inputs, outputs, amounts, fees, confirmations
|
||||
- Visual input/output flow
|
||||
- Transaction status (confirmed/pending)
|
||||
|
||||
### 3. Address Lookup
|
||||
- Search by Mazacoin address
|
||||
- Balance display
|
||||
- Transaction history (paginated)
|
||||
- QR code generation for receiving
|
||||
- Total received/sent
|
||||
|
||||
### 4. Network Statistics Dashboard
|
||||
- Current block height
|
||||
- Network hashrate
|
||||
- Mining difficulty
|
||||
- Average block time
|
||||
- Total supply
|
||||
- Active addresses (24h)
|
||||
- Transactions per day
|
||||
- Mempool size
|
||||
|
||||
### 5. Live Node Map 🌍
|
||||
- Interactive world map (Leaflet.js)
|
||||
- Geo-located active Mazacoin nodes
|
||||
- Real-time updates via WebSocket
|
||||
- Show: location, IP (optional), version, uptime
|
||||
- Node count by country
|
||||
|
||||
### 6. Charts & Analytics
|
||||
- Hashrate over time (7d, 30d, all time)
|
||||
- Transactions per day
|
||||
- Difficulty adjustment history
|
||||
- Block time trends
|
||||
|
||||
### 7. Rich List
|
||||
- Top addresses by balance
|
||||
- Percentage of total supply
|
||||
- Privacy-friendly (optional anonymization)
|
||||
|
||||
### 8. Mining Stats
|
||||
- Latest mined blocks
|
||||
- Mining pool distribution (if detectable)
|
||||
- Average block rewards
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- **Language:** Node.js + Express
|
||||
- **Database:** MongoDB (for caching blockchain data)
|
||||
- **Blockchain:** Mazacoin node RPC via `maza-cli.exe`
|
||||
- **Geolocation:** ipapi.co or ip-api.com
|
||||
- **Real-time:** WebSocket (Socket.io)
|
||||
|
||||
### Frontend
|
||||
- **Framework:** React (or vanilla JS if simpler)
|
||||
- **Maps:** Leaflet.js
|
||||
- **Charts:** Chart.js or Recharts
|
||||
- **Styling:** TailwindCSS or similar
|
||||
- **Icons:** Lucide or Font Awesome
|
||||
|
||||
### Infrastructure
|
||||
- **Container:** Docker + docker-compose
|
||||
- **Deployment:** DashCaddy API (http://100.71.97.12:3001)
|
||||
- **Reverse Proxy:** Caddy (auto-configured via DashCaddy)
|
||||
- **Domain:** maza.samiahmed7777.me
|
||||
|
||||
## Mazacoin Node Connection
|
||||
|
||||
**Node Location:** Windows PC at 100.85.236.10 (SSH: hello@100.85.236.10, key at ~/.ssh/krystie_to_sami_pc)
|
||||
**Wallet Path:** E:\coins\MAZA
|
||||
**CLI:** E:\coins\MAZA\daemon\maza-cli.exe
|
||||
|
||||
**RPC Commands Available:**
|
||||
- `getblockcount` - Latest block height
|
||||
- `getblockhash <height>` - Get block hash
|
||||
- `getblock <hash>` - Get block details
|
||||
- `getrawtransaction <txid> 1` - Get transaction details
|
||||
- `getpeerinfo` - Get connected nodes
|
||||
- `getnetworkinfo` - Network stats
|
||||
- `getmininginfo` - Mining difficulty, hashrate
|
||||
- `getdifficulty` - Current difficulty
|
||||
|
||||
**Connection Method:**
|
||||
1. Start Mazacoin node: `E:\coins\MAZA\maza-qt.exe -server` (enables RPC)
|
||||
2. Or use daemon: Check if there's a mazacoind.exe in the daemon folder
|
||||
3. Configure RPC credentials in maza.conf (create if needed)
|
||||
4. Backend queries via SSH + maza-cli or direct RPC if port exposed
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mazacoin-explorer/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # REST endpoints
|
||||
│ │ ├── rpc/ # Mazacoin RPC client
|
||||
│ │ ├── db/ # MongoDB models
|
||||
│ │ ├── workers/ # Background sync workers
|
||||
│ │ └── websocket/ # Real-time updates
|
||||
│ ├── Dockerfile
|
||||
│ └── package.json
|
||||
├── frontend/
|
||||
│ ├── public/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── pages/ # Block, TX, Address pages
|
||||
│ │ ├── charts/ # Chart components
|
||||
│ │ └── map/ # Node map
|
||||
│ ├── Dockerfile
|
||||
│ └── package.json
|
||||
├── docker-compose.yml
|
||||
├── deploy.sh # DashCaddy deployment script
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
1. Build Docker images
|
||||
2. Push to registry or build on target
|
||||
3. Deploy via DashCaddy API:
|
||||
```bash
|
||||
curl -X POST http://100.71.97.12:3001/api/docker/deploy \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "mazacoin-explorer",
|
||||
"domain": "maza.samiahmed7777.me",
|
||||
"port": 3000,
|
||||
"env": {...}
|
||||
}'
|
||||
```
|
||||
|
||||
## Design Requirements
|
||||
- **Theme:** Dark mode by default (blockchain explorers are dark)
|
||||
- **Colors:** Use Mazacoin brand colors if available, or crypto-blue palette
|
||||
- **Responsive:** Mobile-friendly
|
||||
- **Performance:** Fast search, lazy loading for large datasets
|
||||
- **UX:** Clean, intuitive, minimal clicks to info
|
||||
|
||||
## Security Considerations
|
||||
- Rate limiting on API endpoints
|
||||
- Input validation (block heights, addresses, tx IDs)
|
||||
- No sensitive RPC credentials exposed
|
||||
- Optional: Cloudflare proxy for DDoS protection
|
||||
|
||||
## Testing
|
||||
- Unit tests for RPC client
|
||||
- Integration tests for API endpoints
|
||||
- E2E tests for critical user flows (search, view block, etc.)
|
||||
|
||||
## Timeline
|
||||
Build this incrementally:
|
||||
1. **Phase 1:** Backend RPC client + basic API (blocks, txs, addresses)
|
||||
2. **Phase 2:** Frontend (search, block/tx viewer)
|
||||
3. **Phase 3:** Network stats dashboard
|
||||
4. **Phase 4:** Live node map
|
||||
5. **Phase 5:** Charts + rich list
|
||||
|
||||
## Success Criteria
|
||||
- Can search and view any block, transaction, or address
|
||||
- Live node map updates in real-time
|
||||
- Network stats are accurate
|
||||
- Fast load times (<2s for most pages)
|
||||
- Deployed and accessible at maza.samiahmed7777.me
|
||||
|
||||
---
|
||||
|
||||
**Note:** User (Sami) has Mazacoin wallet but node is not currently running. You may need to coordinate with Krystie (me) to start the node or handle RPC connection setup.
|
||||
|
||||
When completely finished, run this command to notify me:
|
||||
openclaw system event --text "Done: Mazacoin explorer built and ready for deployment to maza.samiahmed7777.me" --mode now
|
||||
@@ -0,0 +1,45 @@
|
||||
# Mazacoin Explorer - Troubleshooting Log
|
||||
|
||||
## 2026-03-07: Whitescreen Issue
|
||||
|
||||
### Problem
|
||||
Explorer showing blank white screen after initial flash of content.
|
||||
|
||||
### Root Causes Found
|
||||
|
||||
1. **Missing SSH in Docker container**
|
||||
- Backend container (node:20-alpine) didn't include openssh-client
|
||||
- Fix: Added `RUN apk add --no-cache openssh-client` to Dockerfile
|
||||
|
||||
2. **Sequential SSH calls (30+ second load time)**
|
||||
- Original code fetched 10 blocks sequentially (~3 sec each = 30+ sec total)
|
||||
- Fix: Changed to parallel fetching with `Promise.all()`
|
||||
|
||||
3. **SSH connection limit exceeded**
|
||||
- Parallel fetching opened 10 simultaneous SSH connections
|
||||
- Windows SSH server rejected connections: "(SSH) Channel open failure"
|
||||
- Fix: Batched parallel requests (3 at a time) to stay under connection limit
|
||||
|
||||
### Final Solution
|
||||
|
||||
```javascript
|
||||
// /root/Projects/mazacoin-explorer/backend/src/server.js
|
||||
// Fetch blocks with limited concurrency (batches of 3)
|
||||
const BATCH_SIZE = 3;
|
||||
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) => {
|
||||
// fetch block...
|
||||
}));
|
||||
blocks.push(...batchResults.filter(b => b !== null));
|
||||
}
|
||||
```
|
||||
|
||||
### Current Performance
|
||||
- API response time: **~0.1 seconds** (was 30-45 seconds)
|
||||
- No SSH errors
|
||||
- Backend healthy and responsive
|
||||
|
||||
### Next Steps
|
||||
- Rebuilding frontend container (no-cache) to ensure clean build
|
||||
- Verify React app loads without JavaScript errors
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Mazacoin Explorer to maza.samiahmed7777.me"
|
||||
|
||||
# Configuration
|
||||
DOMAIN="maza.samiahmed7777.me"
|
||||
DASHCADDY_API="http://100.71.97.12:3001"
|
||||
PROJECT_DIR="/root/Projects/mazacoin-explorer"
|
||||
|
||||
# Build images
|
||||
echo "📦 Building Docker images..."
|
||||
cd "$PROJECT_DIR"
|
||||
docker compose build
|
||||
|
||||
# Stop existing containers
|
||||
echo "🛑 Stopping existing containers..."
|
||||
docker compose down || true
|
||||
|
||||
# Start containers
|
||||
echo "▶️ Starting containers..."
|
||||
docker compose up -d
|
||||
|
||||
# Wait for services to be healthy
|
||||
echo "⏳ Waiting for services to start..."
|
||||
sleep 10
|
||||
|
||||
# Check if services are running
|
||||
if ! docker ps | grep -q mazacoin-frontend; then
|
||||
echo "❌ Frontend container failed to start"
|
||||
docker compose logs frontend
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker ps | grep -q mazacoin-backend; then
|
||||
echo "❌ Backend container failed to start"
|
||||
docker compose logs backend
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configure Caddy reverse proxy via DashCaddy API
|
||||
echo "🔧 Configuring Caddy reverse proxy..."
|
||||
curl -X POST "$DASHCADDY_API/api/caddy/routes" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"domain\": \"$DOMAIN\",
|
||||
\"upstream\": \"localhost:8080\",
|
||||
\"type\": \"proxy\"
|
||||
}" || echo "⚠️ Warning: Failed to configure Caddy (may already be configured)"
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "🌐 Explorer available at: https://$DOMAIN"
|
||||
echo "📊 API endpoint: https://$DOMAIN/api"
|
||||
echo ""
|
||||
echo "📝 To view logs:"
|
||||
echo " docker compose logs -f backend"
|
||||
echo " docker compose logs -f frontend"
|
||||
echo ""
|
||||
echo "🔄 To restart:"
|
||||
echo " docker compose restart"
|
||||
echo ""
|
||||
echo "🛑 To stop:"
|
||||
echo " docker compose down"
|
||||
@@ -0,0 +1,37 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: mazacoin-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- mazacoin-net
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- SSH_KEY_PATH=/root/.ssh/krystie_to_sami_pc
|
||||
volumes:
|
||||
- /root/.ssh:/root/.ssh:ro
|
||||
- backend-logs:/app/logs
|
||||
- ./backend/data:/app/data
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: mazacoin-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- mazacoin-net
|
||||
|
||||
volumes:
|
||||
backend-logs:
|
||||
|
||||
networks:
|
||||
mazacoin-net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build app
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built files
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# Proxy WebSocket connections to backend
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# React Router support - all other requests go to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 10240;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "mazacoin-explorer-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"axios": "^1.6.0",
|
||||
"socket.io-client": "^4.6.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.294.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-scripts": "5.0.1",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 553 KiB |
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="description" content="Mazacoin blockchain explorer - explore blocks, transactions, and addresses" />
|
||||
<title>Mazacoin Explorer</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 553 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
@@ -0,0 +1,53 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-maza-gray rounded-lg shadow-lg p-6 mb-4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-maza-blue text-white hover:bg-blue-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-600 text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 focus:outline-none focus:border-maza-blue;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@apply bg-gradient-to-br from-maza-gray to-gray-800 rounded-lg p-4 shadow-lg;
|
||||
}
|
||||
|
||||
.hash {
|
||||
@apply font-mono text-sm break-all;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@apply flex items-center justify-center p-12;
|
||||
}
|
||||
|
||||
.error {
|
||||
@apply bg-red-900/30 border border-red-700 text-red-300 px-4 py-3 rounded-lg;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import Home from './pages/Home';
|
||||
import BlockDetail from './pages/BlockDetail';
|
||||
import TransactionDetail from './pages/TransactionDetail';
|
||||
import AddressDetail from './pages/AddressDetail';
|
||||
import NetworkStats from './pages/NetworkStats';
|
||||
import NodeMap from './pages/NodeMap';
|
||||
import RichList from './pages/RichList';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-maza-dark text-gray-100 flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-grow container mx-auto px-4 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/block/:hashOrHeight" element={<BlockDetail />} />
|
||||
<Route path="/tx/:txid" element={<TransactionDetail />} />
|
||||
<Route path="/address/:address" element={<AddressDetail />} />
|
||||
<Route path="/stats" element={<NetworkStats />} />
|
||||
<Route path="/nodes" element={<NodeMap />} />
|
||||
<Route path="/richlist" element={<RichList />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="bg-maza-gray border-t border-gray-700 py-6 mt-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center text-gray-400 mb-4">
|
||||
<p>Mazacoin Explorer © 2026 | Built for the Mazacoin community</p>
|
||||
<p className="text-sm mt-2">
|
||||
<a href="https://mazacoin.org" target="_blank" rel="noopener noreferrer" className="hover:text-maza-blue">
|
||||
mazacoin.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 text-gray-400 text-sm">
|
||||
<span>Provided by</span>
|
||||
<img
|
||||
src="/samiahmed77777-logo.png"
|
||||
alt="samiahmed77777"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Activity, MapPin, TrendingUp } from 'lucide-react';
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<header className="bg-maza-gray shadow-lg border-b border-gray-700">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center space-x-3">
|
||||
<img src="/maza-logo.png" alt="Mazacoin" className="w-8 h-8" />
|
||||
<span className="text-2xl font-bold">Mazacoin Explorer</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center space-x-6">
|
||||
<Link to="/" className="hover:text-maza-blue transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/stats" className="hover:text-maza-blue transition-colors flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
Network Stats
|
||||
</Link>
|
||||
<Link to="/nodes" className="hover:text-maza-blue transition-colors flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
Node Map
|
||||
</Link>
|
||||
<Link to="/richlist" className="hover:text-maza-blue transition-colors flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Rich List
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,212 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, LogarithmicScale, PointElement, LineElement, Title, Tooltip, Legend, TimeScale } from 'chart.js';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import axios from 'axios';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, LogarithmicScale, PointElement, LineElement, Title, Tooltip, Legend, TimeScale);
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function PriceChart({ currency = 'btc', timeframe = '30d' }) {
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTimeframe, setActiveTimeframe] = useState(timeframe);
|
||||
const [activeCurrency, setActiveCurrency] = useState(currency);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChartData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTimeframe, activeCurrency]);
|
||||
|
||||
const fetchChartData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`${API_URL}/api/price/history/${activeTimeframe}`);
|
||||
const data = response.data.data;
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
setChartData(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = data.map(d => new Date(d.timestamp * 1000));
|
||||
const prices = data.map(d => d[activeCurrency]);
|
||||
|
||||
setChartData({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: `MAZA/${activeCurrency.toUpperCase()} Price`,
|
||||
data: prices,
|
||||
borderColor: activeCurrency === 'btc' ? '#f59e0b' : activeCurrency === 'ltc' ? '#a855f7' : '#3b82f6',
|
||||
backgroundColor: activeCurrency === 'btc' ? '#f59e0b33' : activeCurrency === 'ltc' ? '#a855f733' : '#3b82f633',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: data.length < 50 ? 4 : 2,
|
||||
pointHoverRadius: 6
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching chart data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
labels: {
|
||||
color: '#9ca3af'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += context.parsed.y.toFixed(activeCurrency === 'eth' ? 10 : 8);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: activeTimeframe === '24h' ? 'hour' : activeTimeframe === '7d' ? 'day' : 'month',
|
||||
tooltipFormat: 'PPpp',
|
||||
displayFormats: {
|
||||
hour: 'MMM d, HH:mm',
|
||||
day: 'MMM d',
|
||||
month: 'MMM yyyy'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: '#1f2937'
|
||||
},
|
||||
ticks: {
|
||||
color: '#9ca3af'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'logarithmic',
|
||||
grid: {
|
||||
color: '#1f2937'
|
||||
},
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
callback: function(value) {
|
||||
return value.toFixed(activeCurrency === 'eth' ? 10 : 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
}
|
||||
};
|
||||
|
||||
const timeframes = [
|
||||
{ value: '24h', label: '24H' },
|
||||
{ value: '7d', label: '7D' },
|
||||
{ value: '30d', label: '30D' },
|
||||
{ value: '1y', label: '1Y' },
|
||||
{ value: 'all', label: 'ALL' }
|
||||
];
|
||||
|
||||
const currencies = [
|
||||
{ value: 'btc', label: 'BTC', color: 'text-yellow-400' },
|
||||
{ value: 'ltc', label: 'LTC', color: 'text-purple-400' },
|
||||
{ value: 'eth', label: 'ETH', color: 'text-blue-400' }
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="loading">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chartData) {
|
||||
return (
|
||||
<div className="card">
|
||||
<p className="text-gray-400 text-center py-8">Building price history... Check back soon!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold">Price Chart</h2>
|
||||
<div className="flex gap-4">
|
||||
{/* Currency selector */}
|
||||
<div className="flex gap-2">
|
||||
{currencies.map(curr => (
|
||||
<button
|
||||
key={curr.value}
|
||||
onClick={() => setActiveCurrency(curr.value)}
|
||||
className={`px-3 py-1 rounded-lg font-medium transition-colors ${
|
||||
activeCurrency === curr.value
|
||||
? `${curr.color} bg-gray-700`
|
||||
: 'text-gray-400 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{curr.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeframe selector */}
|
||||
<div className="flex gap-2">
|
||||
{timeframes.map(tf => (
|
||||
<button
|
||||
key={tf.value}
|
||||
onClick={() => setActiveTimeframe(tf.value)}
|
||||
className={`px-3 py-1 rounded-lg font-medium transition-colors ${
|
||||
activeTimeframe === tf.value
|
||||
? 'bg-maza-blue text-white'
|
||||
: 'text-gray-400 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tf.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-96">
|
||||
<Line data={chartData} options={options} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-500 text-center">
|
||||
Historical data from FreiExchange + CoinGecko. Data collection started March 2026.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PriceChart;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function SearchBar() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/search/${query.trim()}`);
|
||||
const { type, data } = response.data;
|
||||
|
||||
if (type === 'block') {
|
||||
navigate(`/block/${data.hash}`);
|
||||
} else if (type === 'transaction') {
|
||||
navigate(`/tx/${data.txid}`);
|
||||
} else if (type === 'address') {
|
||||
navigate(`/address/${data.address}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Not found. Try a block height, block hash, transaction ID, or address.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by block height, hash, transaction ID, or address..."
|
||||
className="input pr-12"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-maza-blue transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-maza-blue"></div>
|
||||
) : (
|
||||
<Search className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="error mt-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchBar;
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './App.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Copy, TrendingUp } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function AddressDetail() {
|
||||
const { address } = useParams();
|
||||
const [balanceData, setBalanceData] = useState(null);
|
||||
const [loadingBalance, setLoadingBalance] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBalance();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address]);
|
||||
|
||||
const fetchBalance = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/address/${address}/balance`);
|
||||
setBalanceData(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching balance:', error);
|
||||
} finally {
|
||||
setLoadingBalance(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(address);
|
||||
};
|
||||
|
||||
const formatBalance = (balance) => {
|
||||
return balance.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 8
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">Address Details</h1>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold mb-4">Address</h2>
|
||||
|
||||
<div className="bg-gray-800 p-4 rounded mb-4">
|
||||
<div className="hash text-lg break-all mb-2">{address}</div>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="btn btn-secondary text-sm flex items-center gap-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
Copy Address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Balance Info */}
|
||||
<div className="bg-gray-800 p-4 rounded mb-4">
|
||||
<h3 className="text-lg font-bold mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-yellow-400" />
|
||||
Balance
|
||||
</h3>
|
||||
|
||||
{loadingBalance ? (
|
||||
<div className="text-gray-400 text-sm">Loading balance...</div>
|
||||
) : balanceData?.balance !== null && balanceData?.balance !== undefined ? (
|
||||
<>
|
||||
<div className="text-3xl font-bold text-green-400 mb-2">
|
||||
{formatBalance(balanceData.balance)} MAZA
|
||||
</div>
|
||||
{balanceData.rank && (
|
||||
<div className="text-sm text-gray-400">
|
||||
Rank #{balanceData.rank} in rich list
|
||||
</div>
|
||||
)}
|
||||
{balanceData.lastSeen && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Last seen in block: {balanceData.lastSeen.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">
|
||||
{balanceData?.message || 'Balance data not available yet'}
|
||||
{balanceData?.lastScannedBlock && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Scanner progress: block {balanceData.lastScannedBlock.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-900/30 border border-blue-700 text-blue-300 p-4 rounded text-sm">
|
||||
<p className="mb-2">
|
||||
<strong>About Balance Data:</strong>
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>Balance is calculated by our blockchain scanner tracking all transaction outputs</li>
|
||||
<li>Scanner is running in the background and processes blocks continuously</li>
|
||||
<li>Top addresses appear in the rich list as they're discovered</li>
|
||||
<li>Full transaction history requires <code className="bg-gray-800 px-1 rounded">txindex=1</code> on the node</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="text-lg font-bold mb-4">QR Code</h3>
|
||||
<div className="bg-white p-4 rounded">
|
||||
<QRCodeSVG value={address} size={200} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold mb-4">Transaction History</h2>
|
||||
<div className="text-gray-400 text-center py-8">
|
||||
<p className="mb-2">Transaction history for addresses requires txindex to be enabled on the Mazacoin node.</p>
|
||||
<p className="text-sm">This feature will be available once the node is configured with txindex and fully synced.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddressDetail;
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, Clock, Hash, Layers } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function BlockDetail() {
|
||||
const { hashOrHeight } = useParams();
|
||||
const [block, setBlock] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchBlock();
|
||||
}, [hashOrHeight]);
|
||||
|
||||
const fetchBlock = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/block/${hashOrHeight}`);
|
||||
setBlock(response.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Block not found');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!block) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
to={`/block/${block.height - 1}`}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous Block
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<Layers className="w-8 h-8 text-maza-blue" />
|
||||
Block #{block.height}
|
||||
</h1>
|
||||
|
||||
<Link
|
||||
to={`/block/${block.height + 1}`}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
Next Block
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Block Details */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold mb-4">Block Information</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Height</div>
|
||||
<div className="text-lg font-bold">{block.height}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Timestamp</div>
|
||||
<div className="text-lg flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
{format(new Date(block.time * 1000), 'PPpp')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-gray-400 text-sm mb-1">Hash</div>
|
||||
<div className="hash text-lg bg-gray-800 p-3 rounded">{block.hash}</div>
|
||||
</div>
|
||||
|
||||
{block.previousblockhash && (
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-gray-400 text-sm mb-1">Previous Block Hash</div>
|
||||
<Link
|
||||
to={`/block/${block.previousblockhash}`}
|
||||
className="hash text-lg bg-gray-800 p-3 rounded hover:bg-gray-700 block transition-colors"
|
||||
>
|
||||
{block.previousblockhash}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{block.nextblockhash && (
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-gray-400 text-sm mb-1">Next Block Hash</div>
|
||||
<Link
|
||||
to={`/block/${block.nextblockhash}`}
|
||||
className="hash text-lg bg-gray-800 p-3 rounded hover:bg-gray-700 block transition-colors"
|
||||
>
|
||||
{block.nextblockhash}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Difficulty</div>
|
||||
<div className="text-lg">{block.difficulty?.toFixed(4)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Size</div>
|
||||
<div className="text-lg">{(block.size / 1024).toFixed(2)} KB</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Confirmations</div>
|
||||
<div className="text-lg text-green-400">{block.confirmations}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Version</div>
|
||||
<div className="text-lg">{block.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transactions */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center justify-between">
|
||||
<span>Transactions ({block.tx?.length || 0})</span>
|
||||
{block.totalAmount !== undefined && block.totalAmount > 0 && (
|
||||
<span className="text-lg text-green-400">
|
||||
Total: {block.totalAmount.toFixed(2)} MAZA
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{block.tx && block.tx.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{block.tx.map((txid, index) => (
|
||||
<Link
|
||||
key={txid}
|
||||
to={`/tx/${txid}`}
|
||||
className="block p-3 bg-gray-800 hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="text-gray-400 text-sm shrink-0">#{index}</div>
|
||||
<div className="hash flex-1 truncate">{txid}</div>
|
||||
</div>
|
||||
{index === 0 && (
|
||||
<div className="text-xs text-yellow-400 shrink-0">Coinbase</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-center py-8">
|
||||
No transactions in this block
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockDetail;
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Clock, Layers } from 'lucide-react';
|
||||
import SearchBar from '../components/SearchBar';
|
||||
import PriceChart from '../components/PriceChart';
|
||||
import axios from 'axios';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import io from 'socket.io-client';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function Home() {
|
||||
const [latestBlocks, setLatestBlocks] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [price, setPrice] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const socket = io(API_URL);
|
||||
socket.emit('subscribe:blocks');
|
||||
|
||||
socket.on('block:new', (block) => {
|
||||
setLatestBlocks(prev => [block, ...prev].slice(0, 10));
|
||||
});
|
||||
|
||||
return () => socket.disconnect();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [blocksRes, statsRes, priceRes] = await Promise.all([
|
||||
axios.get(`${API_URL}/api/blocks/latest/3`, { timeout: 30000 }),
|
||||
axios.get(`${API_URL}/api/stats`, { timeout: 30000 }),
|
||||
axios.get(`${API_URL}/api/price`, { timeout: 30000 })
|
||||
]);
|
||||
|
||||
// Ensure we got valid data
|
||||
if (Array.isArray(blocksRes.data)) {
|
||||
setLatestBlocks(blocksRes.data);
|
||||
}
|
||||
if (statsRes.data && typeof statsRes.data === 'object') {
|
||||
setStats(statsRes.data);
|
||||
}
|
||||
if (priceRes.data && typeof priceRes.data === 'object') {
|
||||
setPrice(priceRes.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
// Set empty/fallback data on error
|
||||
setLatestBlocks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center py-12">
|
||||
<h1 className="text-5xl font-bold mb-4">Mazacoin Blockchain Explorer</h1>
|
||||
<p className="text-xl text-gray-400 mb-8">
|
||||
Explore blocks, transactions, and addresses on the Mazacoin network
|
||||
</p>
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
{/* Network Stats */}
|
||||
{stats && stats.blockHeight !== undefined && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{price && price.btc !== undefined && (
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">MAZA Price</div>
|
||||
<div className="text-lg font-bold text-yellow-400">
|
||||
{price.btc?.toFixed(8) || '0.00000000'} BTC
|
||||
</div>
|
||||
<div className="text-sm font-bold text-purple-400">
|
||||
{price.ltc?.toFixed(8) || '0.00000000'} LTC
|
||||
</div>
|
||||
<div className="text-sm font-bold text-blue-400">
|
||||
{price.eth?.toFixed(10) || '0.0000000000'} ETH
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<a href="https://freiexchange.com/market/MAZA/BTC" target="_blank" rel="noreferrer" className="text-maza-blue hover:underline">FreiExchange</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Block Height</div>
|
||||
<div className="text-2xl font-bold text-maza-blue">
|
||||
{stats.blockHeight?.toLocaleString() || '0'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Difficulty</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.difficulty?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Network Hashrate</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{((stats.networkHashrate || 0) / 1000000).toFixed(2)} MH/s
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Connections</div>
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{stats.connections || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price Chart */}
|
||||
<PriceChart currency="btc" timeframe="all" />
|
||||
|
||||
{/* Latest Blocks */}
|
||||
<div className="card">
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2">
|
||||
<Layers className="w-6 h-6 text-maza-blue" />
|
||||
Latest Blocks
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{latestBlocks.map((block) => (
|
||||
<Link
|
||||
key={block.height}
|
||||
to={`/block/${block.height}`}
|
||||
className="block p-4 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-maza-blue/20 text-maza-blue px-3 py-1 rounded-lg font-bold">
|
||||
{block.height}
|
||||
</div>
|
||||
<div className="hash text-gray-400">
|
||||
{block.hash.substring(0, 16)}...
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatDistance(new Date(block.time * 1000), new Date(), { addSuffix: true })}
|
||||
</div>
|
||||
<div>
|
||||
{block.txCount} {block.txCount === 1 ? 'tx' : 'txs'}
|
||||
</div>
|
||||
{block.totalAmount !== undefined && block.totalAmount > 0 && (
|
||||
<div className="text-green-400 font-semibold">
|
||||
{block.totalAmount.toFixed(2)} MAZA
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Activity, Server, Zap, Clock } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function NetworkStats() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 30000); // Update every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/stats`);
|
||||
setStats(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return <div className="error">Failed to load network statistics</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<Activity className="w-8 h-8 text-maza-blue" />
|
||||
Network Statistics
|
||||
</h1>
|
||||
<div className="text-sm text-gray-400">
|
||||
Last updated: {new Date(stats.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-3 bg-maza-blue/20 rounded-lg">
|
||||
<Server className="w-6 h-6 text-maza-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm">Block Height</div>
|
||||
<div className="text-3xl font-bold">{stats.blockHeight?.toLocaleString() || '0'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-3 bg-purple-500/20 rounded-lg">
|
||||
<Zap className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm">Network Hashrate</div>
|
||||
<div className="text-3xl font-bold">
|
||||
{((stats.networkHashrate || 0) / 1000000).toFixed(2)} MH/s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-3 bg-green-500/20 rounded-lg">
|
||||
<Activity className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm">Active Connections</div>
|
||||
<div className="text-3xl font-bold text-green-400">{stats.connections}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-3 bg-yellow-500/20 rounded-lg">
|
||||
<Clock className="w-6 h-6 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm">Difficulty</div>
|
||||
<div className="text-2xl font-bold">{stats.difficulty?.toFixed(2) || '0.00'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="text-gray-400 text-sm mb-1">Protocol Version</div>
|
||||
<div className="text-2xl font-bold">{stats.protocolVersion}</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="text-gray-400 text-sm mb-1">Client Version</div>
|
||||
<div className="text-2xl font-bold">{stats.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold mb-4">About Mazacoin</h2>
|
||||
<div className="text-gray-300 space-y-2">
|
||||
<p>
|
||||
Mazacoin (MAZA) is a cryptocurrency designed for the Oglala Lakota Nation.
|
||||
It is based on the Bitcoin protocol and uses Proof-of-Work consensus.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
This explorer provides real-time blockchain data directly from Mazacoin nodes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NetworkStats;
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps';
|
||||
import { MapPin, Globe } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
// World map TopoJSON URL
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
function NodeMap() {
|
||||
const [nodes, setNodes] = useState([]);
|
||||
const [geoNodes, setGeoNodes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState({ total: 0, active: 0, countries: {} });
|
||||
const [price, setPrice] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
fetchPrice();
|
||||
const interval = setInterval(() => {
|
||||
fetchNodes();
|
||||
fetchPrice();
|
||||
}, 60000); // Update every minute
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchPrice = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/price`);
|
||||
setPrice(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching price:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNodes = async () => {
|
||||
try {
|
||||
// Fetch nodes with geolocation from backend API
|
||||
const response = await axios.get(`${API_URL}/api/nodes`);
|
||||
const data = response.data;
|
||||
|
||||
setNodes(data.nodes || []);
|
||||
setGeoNodes(data.nodes || []);
|
||||
setStats({
|
||||
total: data.total || 0,
|
||||
active: data.active || 0,
|
||||
countries: data.countries || {}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching nodes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<MapPin className="w-8 h-8 text-maza-blue" />
|
||||
Live Node Map
|
||||
</h1>
|
||||
<div className="text-sm text-gray-400">
|
||||
Showing all nodes discovered in the last 24 hours
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{price && price.btc !== undefined && (
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">MAZA Price</div>
|
||||
<div className="text-lg font-bold text-yellow-400">
|
||||
{price.btc?.toFixed(8) || '0.00000000'} BTC
|
||||
</div>
|
||||
<div className="text-sm font-bold text-purple-400">
|
||||
{price.ltc?.toFixed(8) || '0.00000000'} LTC
|
||||
</div>
|
||||
<div className="text-sm font-bold text-blue-400">
|
||||
{price.eth?.toFixed(10) || '0.0000000000'} ETH
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Nodes (24h)</div>
|
||||
<div className="text-3xl font-bold text-yellow-400">{stats.total}</div>
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
<span className="text-yellow-400 font-semibold">{stats.active} active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Mapped Nodes</div>
|
||||
<div className="text-3xl font-bold text-maza-blue">{geoNodes.length}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">IP Protocol</div>
|
||||
<div className="text-sm">
|
||||
<div className="text-lg font-bold text-blue-400">
|
||||
IPv4: {geoNodes.filter(n => n.ipVersion === 4).length}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-purple-400">
|
||||
IPv6: {geoNodes.filter(n => n.ipVersion === 6).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Countries</div>
|
||||
<div className="text-3xl font-bold">{Object.keys(stats.countries).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="card bg-gray-800/50 border border-gray-700">
|
||||
<div className="flex items-center justify-center gap-8 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-yellow-400 shadow-lg" style={{ filter: 'drop-shadow(0 0 8px rgba(251, 191, 36, 0.8))' }}></div>
|
||||
<span className="text-sm font-medium">Active Now ({geoNodes.filter(n => n.isActive).length})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-gray-500 opacity-60"></div>
|
||||
<span className="text-sm font-medium text-gray-400">Seen in Last 24h ({geoNodes.filter(n => !n.isActive).length})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="card">
|
||||
<div className="h-[600px] rounded-lg overflow-hidden bg-gray-900">
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 147
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
<ZoomableGroup>
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill="#1f2937"
|
||||
stroke="#374151"
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
hover: { fill: "#2d3748", outline: "none" },
|
||||
pressed: { outline: "none" },
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Geographies>
|
||||
|
||||
{geoNodes.map((node, index) => {
|
||||
const isActive = node.isActive;
|
||||
const markerColor = isActive ? '#fbbf24' : '#6b7280'; // yellow for active, gray for inactive
|
||||
const glowColor = isActive ? 'rgba(251, 191, 36, 0.8)' : 'rgba(107, 114, 128, 0.4)';
|
||||
|
||||
return (
|
||||
<Marker key={index} coordinates={[node.lon, node.lat]}>
|
||||
<g>
|
||||
<circle
|
||||
r={6}
|
||||
fill={markerColor}
|
||||
stroke={isActive ? '#f59e0b' : '#4b5563'}
|
||||
strokeWidth={2}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
filter: `drop-shadow(0 0 8px ${glowColor})`
|
||||
}}
|
||||
/>
|
||||
<circle
|
||||
r={12}
|
||||
fill={markerColor}
|
||||
fillOpacity={0.2}
|
||||
stroke="none"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
</g>
|
||||
<title>
|
||||
{isActive ? '🟡 ACTIVE' : '⚪ Seen in last 24h'}
|
||||
{'\n'}{node.city}, {node.country}
|
||||
{'\n'}IP: {node.ip || node.addr?.split(':')[0]} (IPv{node.ipVersion || '4'})
|
||||
{node.subver ? `\nClient: ${node.subver}` : ''}
|
||||
{node.firstSeen ? `\nFirst seen: ${new Date(node.firstSeen).toLocaleString()}` : ''}
|
||||
{node.lastSeen ? `\nLast seen: ${new Date(node.lastSeen).toLocaleString()}` : ''}
|
||||
</title>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
|
||||
{geoNodes.length === 0 && (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
<p>No nodes could be geolocated yet.</p>
|
||||
<p className="text-sm mt-2">Geolocation is in progress...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Country Distribution */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<Globe className="w-6 h-6" />
|
||||
Node Distribution by Country
|
||||
</h2>
|
||||
|
||||
{Object.keys(stats.countries).length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(stats.countries)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([country, count]) => (
|
||||
<div key={country} className="bg-gray-800 p-3 rounded">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">{country}</div>
|
||||
<div className="text-maza-blue font-bold">{count}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-center py-8">
|
||||
No country data available yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeMap;
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Trophy, TrendingUp, AlertCircle } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function RichList() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRichList();
|
||||
const interval = setInterval(fetchRichList, 60000); // Update every minute
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchRichList = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await axios.get(`${API_URL}/api/richlist/100`);
|
||||
setData(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching rich list:', error);
|
||||
setError('Failed to load rich list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatBalance = (balance) => {
|
||||
return balance.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 8
|
||||
});
|
||||
};
|
||||
|
||||
const formatBlockNumber = (block) => {
|
||||
return block ? block.toLocaleString() : 'Unknown';
|
||||
};
|
||||
|
||||
const getTotalBalance = () => {
|
||||
if (!data || !data.addresses) return 0;
|
||||
return data.addresses.reduce((sum, addr) => sum + addr.balance, 0);
|
||||
};
|
||||
|
||||
const getRankColor = (rank) => {
|
||||
if (rank === 1) return 'text-yellow-400'; // Gold
|
||||
if (rank === 2) return 'text-gray-300'; // Silver
|
||||
if (rank === 3) return 'text-yellow-600'; // Bronze
|
||||
return 'text-gray-400';
|
||||
};
|
||||
|
||||
const getRankIcon = (rank) => {
|
||||
if (rank <= 3) {
|
||||
return <Trophy className={`w-5 h-5 ${getRankColor(rank)}`} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<TrendingUp className="w-8 h-8 text-maza-blue" />
|
||||
Top 100 Rich List
|
||||
</h1>
|
||||
<div className="card bg-red-900/20 border border-red-500/50">
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<TrendingUp className="w-8 h-8 text-maza-blue" />
|
||||
Top 100 Rich List
|
||||
</h1>
|
||||
{data?.isScanning && (
|
||||
<div className="text-sm text-yellow-400 flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-yellow-400"></div>
|
||||
Scanning blockchain...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Total Addresses</div>
|
||||
<div className="text-3xl font-bold text-yellow-400">{data?.totalAddresses || 0}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Last Scanned Block</div>
|
||||
<div className="text-2xl font-bold text-maza-blue">{formatBlockNumber(data?.lastScannedBlock)}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Top 100 Total Balance</div>
|
||||
<div className="text-2xl font-bold text-green-400">{formatBalance(getTotalBalance())} MAZA</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="text-gray-400 text-sm mb-1">Status</div>
|
||||
<div className={`text-xl font-bold ${data?.isScanning ? 'text-yellow-400' : 'text-green-400'}`}>
|
||||
{data?.isScanning ? 'Scanning...' : 'Up to date'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="card bg-blue-900/20 border border-blue-500/50">
|
||||
<div className="flex items-start gap-3 text-blue-300 text-sm">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold mb-1">About the Rich List</p>
|
||||
<p>
|
||||
This list shows the top 100 addresses by balance. Balances are calculated by tracking
|
||||
transaction outputs as the blockchain is scanned. The scanner runs in the background
|
||||
and processes blocks incrementally. Large exchanges and mining pools typically appear
|
||||
at the top of this list.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rich List Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-800 border-b border-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Rank
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Balance (MAZA)
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Last Seen Block
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{data?.addresses?.map((addr) => (
|
||||
<tr
|
||||
key={addr.address}
|
||||
className="hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{getRankIcon(addr.rank)}
|
||||
<span className={`text-lg font-bold ${getRankColor(addr.rank)}`}>
|
||||
{addr.rank}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<a
|
||||
href={`/address/${addr.address}`}
|
||||
className="text-maza-blue hover:text-blue-300 font-mono text-sm break-all"
|
||||
>
|
||||
{addr.address}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<span className="text-green-400 font-bold">
|
||||
{formatBalance(addr.balance)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center text-gray-400 text-sm">
|
||||
{formatBlockNumber(addr.lastSeen)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!data?.addresses || data.addresses.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-gray-500" />
|
||||
<p className="text-lg mb-2">No data available yet</p>
|
||||
<p className="text-sm">
|
||||
The blockchain scanner is starting up. Please check back in a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RichList;
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ArrowRight, Clock } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
function TransactionDetail() {
|
||||
const { txid } = useParams();
|
||||
const [tx, setTx] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransaction();
|
||||
}, [txid]);
|
||||
|
||||
const fetchTransaction = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/api/tx/${txid}`);
|
||||
setTx(response.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Transaction not found');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
if (!tx) return null;
|
||||
|
||||
// Use backend-calculated totals if available, otherwise calculate
|
||||
const totalInput = tx.totalInput !== undefined ? tx.totalInput :
|
||||
tx.vin?.reduce((sum, input) => sum + (input.value || 0), 0) || 0;
|
||||
const totalOutput = tx.totalOutput !== undefined ? tx.totalOutput :
|
||||
tx.vout?.reduce((sum, output) => sum + (output.value || 0), 0) || 0;
|
||||
const fee = tx.fees !== undefined ? tx.fees : (totalInput - totalOutput);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">Transaction Details</h1>
|
||||
|
||||
{/* Transaction Info */}
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold mb-4">Transaction Information</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Transaction ID</div>
|
||||
<div className="hash text-lg bg-gray-800 p-3 rounded">{tx.txid}</div>
|
||||
</div>
|
||||
|
||||
{tx.blockhash && (
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Block Hash</div>
|
||||
<Link
|
||||
to={`/block/${tx.blockhash}`}
|
||||
className="hash text-lg bg-gray-800 p-3 rounded hover:bg-gray-700 block transition-colors"
|
||||
>
|
||||
{tx.blockhash}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{tx.confirmations !== undefined && (
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Confirmations</div>
|
||||
<div className="text-lg text-green-400">{tx.confirmations}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tx.time && (
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Time</div>
|
||||
<div className="text-sm flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{format(new Date(tx.time * 1000), 'PPpp')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tx.size && (
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Size</div>
|
||||
<div className="text-lg">{tx.size} bytes</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fee > 0 && (
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Fee</div>
|
||||
<div className="text-lg">{fee.toFixed(8)} MAZA</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inputs and Outputs */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Inputs */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold mb-4">
|
||||
Inputs ({tx.vin?.length || 0})
|
||||
</h3>
|
||||
|
||||
{tx.vin && tx.vin.length > 0 ? (
|
||||
<>
|
||||
<div className="space-y-3 mb-4">
|
||||
{tx.vin.map((input, index) => (
|
||||
<div key={index} className="bg-gray-800 p-3 rounded">
|
||||
{input.coinbase || input.isCoinbase ? (
|
||||
<div className="text-sm text-gray-400">Coinbase (Newly Generated Coins)</div>
|
||||
) : (
|
||||
<>
|
||||
{input.txid && (
|
||||
<Link
|
||||
to={`/tx/${input.txid}`}
|
||||
className="hash text-xs text-maza-blue hover:underline block mb-1"
|
||||
>
|
||||
{input.txid}:{input.vout}
|
||||
</Link>
|
||||
)}
|
||||
{input.value !== undefined && input.value !== null ? (
|
||||
<>
|
||||
<div className="text-lg font-bold">{input.value.toFixed(8)} MAZA</div>
|
||||
{input.address && input.address !== 'Unknown' && (
|
||||
<Link
|
||||
to={`/address/${input.address}`}
|
||||
className="hash text-xs text-maza-blue hover:underline block mt-1"
|
||||
>
|
||||
{input.address}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
) : input.error ? (
|
||||
<div className="text-xs text-yellow-400">{input.error}</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">Amount unknown</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{totalInput > 0 && (
|
||||
<div className="pt-3 border-t border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Total Input:</span>
|
||||
<span className="text-xl font-bold text-green-400">{totalInput.toFixed(8)} MAZA</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-400 text-center py-4">No inputs</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold mb-4">
|
||||
Outputs ({tx.vout?.length || 0})
|
||||
</h3>
|
||||
|
||||
{tx.vout && tx.vout.length > 0 ? (
|
||||
<>
|
||||
<div className="space-y-3 mb-4">
|
||||
{tx.vout.map((output, index) => (
|
||||
<div key={index} className="bg-gray-800 p-3 rounded">
|
||||
<div className="text-lg font-bold mb-1">{output.value.toFixed(8)} MAZA</div>
|
||||
{output.scriptPubKey?.addresses?.map((address, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/address/${address}`}
|
||||
className="hash text-xs text-maza-blue hover:underline block"
|
||||
>
|
||||
{address}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{totalOutput > 0 && (
|
||||
<div className="pt-3 border-t border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Total Output:</span>
|
||||
<span className="text-xl font-bold text-blue-400">{totalOutput.toFixed(8)} MAZA</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-400 text-center py-4">No outputs</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TransactionDetail;
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'maza-blue': '#1e40af',
|
||||
'maza-dark': '#0f172a',
|
||||
'maza-gray': '#1e293b',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user