diff --git a/.env.example b/.env.example deleted file mode 100644 index eef6885..0000000 --- a/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -# DedooExplorer Configuration -# Copy this file to .env and customize for your coin - -# Server -PORT=3001 - -# Electrs API -ELECTRS_API=http://127.0.0.1:50010 - -# Explorer Branding -EXPLORER_NAME=Junkcoin Explorer -COIN_NAME=Junkcoin -COIN_TICKER=JKC -COIN_TAGLINE=A peer to peer meme currency serving the internet community - -# Logo (path relative to /public or absolute URL) -LOGO_URL=/img/dedoo-logo.svg - -# Social Media Links (leave empty to hide) -WEBSITE_URL=https://junk-coin.com/ -GITHUB_URL=https://github.com/junkcoin-Foundation/junkcoin-core -TELEGRAM_URL=https://t.me/junkcoin2014 -TWITTER_URL=https://x.com/junkcoin_jkc -DISCORD_URL= - -# Mining/Consensus -ALGORITHM=Scrypt -DIFF_ADJUSTMENT=Custom -BLOCK_TIME=60 diff --git a/package-lock.json b/package-lock.json index ada6c25..50b01a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -784,9 +784,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/public/css/style.css b/public/css/style.css index 14394b8..cf42bdb 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -155,8 +155,9 @@ a:hover { margin: 0 auto; padding: 0 var(--spacing-lg); display: flex; + flex-wrap: wrap; align-items: center; - gap: var(--spacing-lg); + gap: var(--spacing-md); } .nav-brand { @@ -185,8 +186,9 @@ a:hover { } .search-form { - flex: 1; - max-width: 500px; + flex: 1 1 280px; + min-width: 0; + max-width: 420px; position: relative; } @@ -221,7 +223,33 @@ a:hover { .nav-links { display: flex; + flex-wrap: wrap; gap: var(--spacing-xs); + flex: 1 1 auto; + min-width: 0; +} + +.nav-toggle { + display: none; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + background: var(--bg-input); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.nav-toggle i { + font-size: 1.25rem; +} + +.nav-toggle:hover { + border-color: var(--primary); + color: var(--primary); } .nav-link { @@ -250,6 +278,187 @@ a:hover { font-size: 1rem; } +.nav-backdrop { + display: none; +} + +.nav-modal-panel { + display: none; +} + +.theme-toggle { + min-width: 48px; + height: 48px; + border-radius: var(--radius-lg); + padding: 0 calc(var(--spacing-md) + 2px); +} + +.theme-toggle i { + font-size: 1.25rem; +} + +@media (max-width: 768px) { + .nav-container { + flex-wrap: wrap; + gap: var(--spacing-sm); + } + + .search-form { + order: 3; + flex: 1 1 100%; + max-width: 100%; + margin-top: var(--spacing-sm); + } + + .nav-toggle { + display: inline-flex; + margin-left: auto; + } + + .nav-toggle[aria-expanded="true"] i.fa-bars { + display: none; + } + + .nav-toggle[aria-expanded="true"] i.fa-times { + display: inline-block; + } + + .nav-toggle i.fa-times { + display: none; + } + + .theme-toggle { + margin-left: var(--spacing-xs); + } + + /* Hide desktop nav links on mobile */ + .nav-links-desktop { + display: none !important; + } + + /* Hamburger modal backdrop (mobile only) */ + .nav-backdrop { + display: none; + pointer-events: none; + position: fixed; + inset: 0; + z-index: 999; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + opacity: 0; + transition: opacity 0.2s ease; + } + + .nav-backdrop.open { + display: block; + opacity: 1; + pointer-events: auto; + } + + /* Hamburger modal panel - fixed height, inner scroll */ + .nav-modal-panel { + position: fixed; + top: 0; + left: var(--spacing-md); + right: var(--spacing-md); + z-index: 1000; + max-height: 88vh; + margin-top: 60px; + display: flex; + flex-direction: column; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; + transform: translateY(-12px); + opacity: 0; + visibility: hidden; + transition: transform 0.25s ease, opacity 0.25s ease, visibility 0.25s ease; + } + + .nav-modal-panel.open { + display: flex; + transform: translateY(0); + opacity: 1; + visibility: visible; + } + + .nav-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border-color); + background: var(--bg-card); + flex-shrink: 0; + } + + .nav-modal-title { + font-weight: 600; + font-size: 1.125rem; + color: var(--text-primary); + } + + .nav-modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + border-radius: var(--radius-md); + background: var(--bg-input); + color: var(--text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; + } + + .nav-modal-close:hover { + background: var(--border-light); + color: var(--text-primary); + } + + .nav-modal-close i { + font-size: 1.25rem; + } + + /* Scrollable list - this div scrolls, not the panel */ + .nav-links-scroll { + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + padding: var(--spacing-sm) var(--spacing-md) var(--spacing-lg); + min-height: 0; + flex: 1 1 auto; + } + + .nav-modal-panel .nav-link { + width: 100%; + justify-content: flex-start; + color: var(--text-primary); + font-size: 1rem; + padding: 14px 16px; + border-radius: var(--radius-md); + margin-bottom: var(--spacing-xs); + } + + .nav-modal-panel .nav-link:last-child { + margin-bottom: 0; + } + + .nav-modal-panel .nav-link span { + display: inline-block; + } + + .nav-modal-panel .nav-link i { + width: 24px; + text-align: center; + margin-right: var(--spacing-sm); + } +} + /* ============ MAIN CONTENT ============ */ .main-content { flex: 1; @@ -363,6 +572,12 @@ a:hover { color: var(--primary); } +.stat-note { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 4px; +} + /* ============ CONTENT CARDS ============ */ .content-card { background: var(--bg-card); @@ -438,203 +653,765 @@ a:hover { border-collapse: collapse; } -.data-table th { - text-align: left; - padding: var(--spacing-md) var(--spacing-lg); - font-size: 0.75rem; +.data-table th { + text-align: left; + padding: var(--spacing-md) var(--spacing-lg); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.5px; + color: var(--text-secondary); + text-transform: uppercase; + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid var(--border-color); +} + +.data-table td { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border-color); + font-size: 0.9rem; +} + +.data-table tbody tr { + transition: background var(--transition-fast); +} + +.data-table tbody tr:hover { + background: var(--bg-card-hover); +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.clickable-row { + cursor: pointer; +} + +.text-muted { + color: var(--text-muted); +} + +.text-center { + text-align: center; +} + +/* Hash Values */ +.hash-value { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.85rem; +} + +.hash-full { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.85rem; + word-break: break-all; + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); +} + +.block-height { + color: var(--primary); + font-weight: 600; +} + +.copy-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.copy-btn:hover { + color: var(--primary); + background: rgba(245, 166, 35, 0.1); +} + +.time-cell { + display: flex; + flex-direction: column; +} + +.time-ago { + color: var(--text-primary); +} + +.time-full { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* ============ BADGES ============ */ +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-md); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.badge-confirmed { + background: rgba(76, 217, 100, 0.15); + color: var(--success); +} + +.badge-pending { + background: rgba(245, 166, 35, 0.15); + color: var(--primary); +} + +.badge-info { + background: rgba(90, 200, 250, 0.15); + color: var(--info); +} + +.stat-card-success { + border-left: 3px solid var(--success); +} + +.stat-card-warning { + border-left: 3px solid var(--warning); +} + +.success-row { + background: rgba(76, 217, 100, 0.05); +} + +.warning-row { + background: rgba(245, 166, 35, 0.05); +} + +/* ============ PAGINATION ============ */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-lg); + border-top: 1px solid var(--border-color); +} + +.page-btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 10px 16px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; + transition: all var(--transition-fast); +} + +.page-btn:hover:not(.disabled) { + background: var(--bg-card-hover); + border-color: var(--primary); + color: var(--primary); +} + +.page-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.page-numbers { + display: flex; + gap: var(--spacing-xs); +} + +.page-num { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + transition: all var(--transition-fast); +} + +.page-num:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.page-num.active { + background: var(--primary); + color: #000; + font-weight: 600; +} + +.page-ellipsis { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.page-info { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.pagination-footer { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + flex-wrap: wrap; + padding: var(--spacing-lg); + border-top: 1px solid var(--border-color); +} + +.pagination-footer .page-btn.active { + background: var(--primary); + color: #000; + border-color: var(--primary); +} + +.pagination-bar { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + flex-wrap: wrap; + padding: var(--spacing-lg); + border-top: 1px solid var(--border-color); +} + +.pagination-bar .pagination-info { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.pagination-bar .pagination-btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 8px 14px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 0.875rem; + text-decoration: none; + transition: all var(--transition-fast); +} + +.pagination-bar .pagination-btn:hover { + background: var(--bg-card-hover); + border-color: var(--primary); + color: var(--primary); +} + +.pagination-bar .pagination-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* Coin Flow page */ +.coin-flow-page .flow-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.coin-flow-page .flow-item { + padding: var(--spacing-md) var(--spacing-lg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-card); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.coin-flow-page .flow-item:hover { + background: var(--bg-card-hover); + border-color: var(--border-light); +} + +.coin-flow-page .flow-meta { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); + font-size: 0.875rem; + color: var(--text-secondary); +} + +.coin-flow-page .flow-time { + font-variant-numeric: tabular-nums; +} + +.coin-flow-page .flow-block { + color: var(--primary); + text-decoration: none; +} + +.coin-flow-page .flow-block:hover { + text-decoration: underline; +} + +.coin-flow-page .flow-arrow-row { + display: flex; + align-items: flex-start; + gap: var(--spacing-md); + flex-wrap: wrap; +} + +.coin-flow-page .flow-from, +.coin-flow-page .flow-to { + flex: 1; + min-width: 140px; +} + +.coin-flow-page .flow-label { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 4px; +} + +.coin-flow-page .flow-addresses { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; +} + +.coin-flow-page .flow-addr { + font-family: var(--font-mono, monospace); + font-size: 0.875rem; + color: var(--primary); + text-decoration: none; +} + +.coin-flow-page .flow-addr:hover { + text-decoration: underline; +} + +.coin-flow-page .flow-addr.coinbase { + color: var(--success); +} + +.coin-flow-page .flow-amount { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.coin-flow-page .flow-arrow { + flex-shrink: 0; + padding-top: 1.25rem; + color: var(--text-muted); +} + +.coin-flow-page .flow-total { + margin-top: var(--spacing-sm); + font-size: 0.9rem; +} + +.coin-flow-page .flow-fee { + margin-left: var(--spacing-md); + font-size: 0.8rem; + color: var(--text-muted); +} + +.coin-flow-page .flow-txid { + margin-top: 6px; + font-size: 0.8rem; + display: flex; + align-items: center; + gap: 6px; +} + +.coin-flow-page .flow-txid .hash-value { + color: var(--text-secondary); + text-decoration: none; +} + +.coin-flow-page .flow-txid .hash-value:hover { + color: var(--primary); +} + +.coin-flow-page .empty-state { + padding: var(--spacing-xl); + text-align: center; + color: var(--text-muted); +} + +.page-footer-note { + font-size: 0.875rem; + color: var(--text-muted); + margin-top: var(--spacing-md); +} + +.page-footer-note a { + color: var(--primary); +} + +.text-warning { + color: var(--warning); +} + +.rank-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + padding: 4px 8px; + background: var(--bg-input); + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 0.875rem; + color: var(--primary); +} + +.rank-badge.rank-1 { background: rgba(255, 193, 7, 0.2); color: #ffc107; } +.rank-badge.rank-2 { background: rgba(158, 158, 158, 0.25); color: #bdbdbd; } +.rank-badge.rank-3 { background: rgba(205, 127, 50, 0.25); color: #cd7f32; } + +.card-footer-note { + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--border-color); + font-size: 0.9rem; +} + +.card-footer-note a { + color: var(--primary); + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); +} + +.alert { + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius-md); + margin: var(--spacing-md) 0; + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); +} + +.alert i { + flex-shrink: 0; + margin-top: 2px; +} + +.alert code { + background: var(--bg-input); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 0.85em; +} + +.alert-warning { + background: rgba(245, 166, 35, 0.12); + border: 1px solid rgba(245, 166, 35, 0.35); + color: var(--text-primary); +} + +/* Glossary & FAQ */ +.glossary-list.detail-grid { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.glossary-term { + padding: var(--spacing-md) 0; + border-bottom: 1px solid var(--border-color); +} + +.glossary-term:last-child { + border-bottom: none; +} + +.term-name { + display: block; + font-weight: 600; + color: var(--primary); + margin-bottom: var(--spacing-xs); + font-size: 1rem; +} + +.term-desc { + color: var(--text-secondary); + line-height: 1.6; + font-size: 0.95rem; +} + +.faq-list { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.faq-item { + padding-bottom: var(--spacing-lg); + border-bottom: 1px solid var(--border-color); +} + +.faq-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.faq-q { + font-size: 1rem; font-weight: 600; - letter-spacing: 0.5px; - color: var(--text-secondary); - text-transform: uppercase; - background: rgba(0, 0, 0, 0.3); - border-bottom: 1px solid var(--border-color); + color: var(--text-primary); + margin-bottom: var(--spacing-sm); } -.data-table td { - padding: var(--spacing-md) var(--spacing-lg); - border-bottom: 1px solid var(--border-color); - font-size: 0.9rem; +.faq-a { + color: var(--text-secondary); + line-height: 1.6; + margin: 0; + font-size: 0.95rem; } -.data-table tbody tr { - transition: background var(--transition-fast); +.faq-a a, +.term-desc a { + color: var(--primary); } -.data-table tbody tr:hover { - background: var(--bg-card-hover); +.faq-a code, +.term-desc code { + background: var(--bg-input); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 0.9em; } -.data-table tbody tr:last-child td { - border-bottom: none; +.balance-value { + font-weight: 500; + font-variant-numeric: tabular-nums; } -.clickable-row { - cursor: pointer; +.balance-value .currency { + color: var(--text-secondary); + font-size: 0.85em; + margin-left: 4px; } -.text-muted { +.empty-state { + text-align: center; + padding: var(--spacing-xl) !important; color: var(--text-muted); } -.text-center { - text-align: center; +.empty-state i { + font-size: 2.5rem; + margin-bottom: var(--spacing-md); + opacity: 0.5; } -/* Hash Values */ -.hash-value { - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; - font-size: 0.85rem; +.empty-state p { + margin: 0; } -.hash-full { - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; - font-size: 0.85rem; - word-break: break-all; +/* ============ API DOCS ============ */ +.api-page .api-docs-card { + max-width: 900px; +} + +.api-base-url { display: flex; - align-items: flex-start; + align-items: center; gap: var(--spacing-sm); + padding: var(--spacing-md); + background: var(--bg-input); + border-radius: var(--radius-md); + font-family: monospace; + font-size: 0.9rem; + word-break: break-all; } -.block-height { - color: var(--primary); - font-weight: 600; +.api-base-url code { + flex: 1; } -.copy-btn { - background: transparent; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 4px; - border-radius: var(--radius-sm); - transition: all var(--transition-fast); - display: inline-flex; - align-items: center; - justify-content: center; +.api-section { + margin-top: var(--spacing-xl); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--border-color); } -.copy-btn:hover { - color: var(--primary); - background: rgba(245, 166, 35, 0.1); +.api-section:first-of-type { + margin-top: 0; + padding-top: 0; + border-top: none; } -.time-cell { +.api-section h3 { + font-size: 1.1rem; + margin-bottom: var(--spacing-md); + color: var(--primary); display: flex; - flex-direction: column; -} - -.time-ago { - color: var(--text-primary); + align-items: center; + gap: var(--spacing-sm); } -.time-full { - font-size: 0.75rem; - color: var(--text-muted); +.api-endpoint { + margin-bottom: var(--spacing-lg); + padding: var(--spacing-md); + background: var(--bg-input); + border-radius: var(--radius-md); + border-left: 3px solid var(--border-color); } -/* ============ BADGES ============ */ -.badge { - display: inline-flex; - align-items: center; - padding: 4px 10px; - border-radius: var(--radius-md); +.api-endpoint .method { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: 600; - text-transform: uppercase; + margin-right: var(--spacing-sm); } -.badge-confirmed { - background: rgba(76, 217, 100, 0.15); +.api-endpoint .method.get { + background: rgba(74, 200, 100, 0.2); color: var(--success); } -.badge-pending { - background: rgba(245, 166, 35, 0.15); +.api-endpoint .method.post { + background: rgba(90, 200, 250, 0.2); + color: var(--info); +} + +.api-endpoint code { + font-size: 0.9rem; color: var(--primary); } -/* ============ PAGINATION ============ */ -.pagination { +.api-endpoint p { + margin: var(--spacing-sm) 0; + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.api-endpoint p code { + background: var(--bg-card); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 0.8rem; +} + +.api-example { display: flex; - justify-content: center; align-items: center; - gap: var(--spacing-md); - padding: var(--spacing-lg); - border-top: 1px solid var(--border-color); + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--bg-card); + border-radius: var(--radius-sm); + font-size: 0.8rem; + overflow-x: auto; } -.page-btn { - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); - padding: 10px 16px; - background: var(--bg-input); +.api-example code { + flex: 1; + color: var(--text-primary); + word-break: break-all; +} + +.copy-btn-sm { + flex-shrink: 0; + padding: 4px 8px; + background: transparent; border: 1px solid var(--border-color); - border-radius: var(--radius-md); + border-radius: var(--radius-sm); color: var(--text-secondary); - font-size: 0.875rem; - font-weight: 500; + cursor: pointer; transition: all var(--transition-fast); } -.page-btn:hover:not(.disabled) { - background: var(--bg-card-hover); +.copy-btn-sm:hover { border-color: var(--primary); color: var(--primary); } -.page-btn.disabled { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; +.api-footer { + margin-top: var(--spacing-xl); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--border-color); + font-size: 0.875rem; + color: var(--text-muted); } -.page-numbers { - display: flex; - gap: var(--spacing-xs); +.api-footer a { + color: var(--primary); } -.page-num { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; +.supply-progress { + width: 100%; + height: 10px; border-radius: var(--radius-md); - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - transition: all var(--transition-fast); -} - -.page-num:hover { - background: var(--bg-card-hover); - color: var(--text-primary); + background: var(--bg-input); + overflow: hidden; + margin: 8px 0; } -.page-num.active { +.supply-progress-inner { + height: 100%; background: var(--primary); - color: #000; - font-weight: 600; + width: 0; + transition: width var(--transition-normal); } -.page-ellipsis { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-muted); +.supply-progress-inner.secondary { + background: var(--info); } -.page-info { - font-size: 0.875rem; +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.75rem; + background: var(--bg-input); color: var(--text-secondary); } +.badge-in { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.75rem; + background: rgba(76, 217, 100, 0.2); + color: var(--success); +} + +.badge-out { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.75rem; + background: rgba(90, 200, 250, 0.2); + color: var(--info); +} + /* ============ CHARTS ============ */ .chart-container { padding: var(--spacing-lg); @@ -835,6 +1612,79 @@ a:hover { margin-top: 4px; } +/* Transaction technical details (scripts, witness, raw) */ +.tx-technical { + margin-top: var(--spacing-md); + padding-top: var(--spacing-md); + border-top: 1px solid var(--border-color); + font-size: 0.8rem; +} + +.tx-technical-row { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: var(--spacing-sm); +} + +.tx-technical-row:last-child { + margin-bottom: 0; +} + +.tx-technical-label { + color: var(--text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.tx-technical-value { + color: var(--text-secondary); + word-break: break-all; +} + +.tx-technical-value.mono-wrap { + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 0.78rem; + line-height: 1.4; +} + +.tx-technical-value code, +.hex-value { + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 0.78rem; + background: var(--bg-input); + padding: 2px 6px; + border-radius: var(--radius-sm); +} + +.witness-item { + margin-bottom: 4px; + font-size: 0.78rem; +} + +.witness-index { + display: inline-block; + min-width: 20px; + color: var(--text-muted); + margin-right: 6px; +} + +.raw-hex-block { + margin: 0; + padding: var(--spacing-md); + background: var(--bg-input); + border-radius: var(--radius-md); + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 0.75rem; + line-height: 1.5; + word-break: break-all; + white-space: pre-wrap; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + /* ============ ADDRESS PAGE ============ */ .address-card { padding: var(--spacing-lg); @@ -1057,7 +1907,7 @@ a:hover { } @media (max-width: 768px) { - .nav-links span { + .nav-links-desktop span { display: none; } @@ -1303,9 +2153,11 @@ a:hover { background: rgba(245, 166, 35, 0.1); } -.page-btn-sm:disabled { +.page-btn-sm:disabled, +.page-btn-sm.disabled { opacity: 0.3; cursor: not-allowed; + pointer-events: none; } /* ============ INFINITE SCROLL ============ */ diff --git a/public/img/wojak-logo (2).png b/public/img/wojak-logo (2).png new file mode 100644 index 0000000..08334eb Binary files /dev/null and b/public/img/wojak-logo (2).png differ diff --git a/public/img/wojak-logo.svg b/public/img/wojak-logo.svg new file mode 100644 index 0000000..640e737 --- /dev/null +++ b/public/img/wojak-logo.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js index 4233852..6b0e2e9 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -85,6 +85,7 @@ function formatBytes(bytes) { // Format hashrate function formatHashrate(hashrate) { + if (hashrate == null || !Number.isFinite(hashrate) || hashrate < 0) return '0 H/s'; if (hashrate >= 1e18) return (hashrate / 1e18).toFixed(2) + ' EH/s'; if (hashrate >= 1e15) return (hashrate / 1e15).toFixed(2) + ' PH/s'; if (hashrate >= 1e12) return (hashrate / 1e12).toFixed(2) + ' TH/s'; @@ -149,6 +150,43 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Mobile nav toggle - hamburger modal (scrollable list) + const navToggle = document.querySelector('.nav-toggle'); + const navModal = document.getElementById('nav-links'); // .nav-modal-panel + const navBackdrop = document.getElementById('nav-backdrop'); + const navCloseBtn = document.getElementById('nav-close-btn'); + + function closeNavModal() { + if (navModal) navModal.classList.remove('open'); + if (navBackdrop) navBackdrop.classList.remove('open'); + if (navToggle) navToggle.setAttribute('aria-expanded', 'false'); + document.body.style.overflow = ''; + } + + if (navToggle && navModal) { + navToggle.addEventListener('click', () => { + const isOpen = navModal.classList.toggle('open'); + if (navBackdrop) navBackdrop.classList.toggle('open', isOpen); + navToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + document.body.style.overflow = isOpen ? 'hidden' : ''; + }); + } + + if (navBackdrop) navBackdrop.addEventListener('click', closeNavModal); + if (navCloseBtn) navCloseBtn.addEventListener('click', closeNavModal); + + if (navModal) { + navModal.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', closeNavModal); + }); + } + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && navModal && navModal.classList.contains('open')) { + closeNavModal(); + } + }); + // Start auto-refresh on dashboard startAutoRefresh(); diff --git a/server.js b/server.js index 8a1edc9..bc94d3c 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,11 @@ const app = express(); const config = { port: process.env.PORT || 3000, electrsApi: process.env.ELECTRS_API || 'http://127.0.0.1:50010', + indexerApi: process.env.INDEXER_API || 'http://127.0.0.1:3070', + // RPC for accurate hashrate (getmininginfo networkhashps) + rpcUrl: process.env.RPC_URL || '', + rpcUser: process.env.RPC_USER || '', + rpcPassword: process.env.RPC_PASSWORD || '', explorerName: process.env.EXPLORER_NAME || 'DedooExplorer', coinName: process.env.COIN_NAME || 'Coin', coinTicker: process.env.COIN_TICKER || 'COIN', @@ -38,6 +43,54 @@ app.set('views', path.join(__dirname, 'views')); // Static files app.use(express.static(path.join(__dirname, 'public'))); +// Favicon - serve logo to avoid 404 +app.get('/favicon.ico', (req, res) => res.redirect(301, '/img/wojak-logo.svg')); + +// Rich List (register early to avoid 404) +app.get('/richlist', async (req, res) => { + const limit = Math.min(parseInt(req.query.limit) || 100, 500); + let holders = []; + let totalHolders = 0; + let indexerError = null; + try { + let holdersData; + try { + holdersData = await indexerApiCall(`/holders/top/${limit}`); + } catch (e1) { + try { + holdersData = await indexerApiCall(`/holders?page=1&limit=${limit}`); + } catch (e2) { + indexerError = e2?.message || e1?.message || 'Indexer unavailable'; + } + } + if (holdersData && holdersData.holders) { + holders = holdersData.holders.map((h, i) => ({ ...h, position: i + 1 })); + totalHolders = holdersData.total != null ? holdersData.total : holders.length; + } + } catch (e) { + indexerError = e.message || 'Failed to load holders'; + } + let totalSupply = null; + try { + const supplyData = await apiCall('/blockchain/getsupply').catch(() => null); + if (supplyData) totalSupply = supplyData.total_amount_float ?? supplyData.total_amount ?? null; + } catch (_) {} + try { + return res.render('richlist', { + title: 'Rich List', + holders, + totalHolders, + totalSupply, + limit: holders.length, + indexerError, + page: 'richlist' + }); + } catch (err) { + console.error('Rich list render error:', err); + return res.status(500).render('error', { title: 'Error', message: 'Failed to render rich list', error: err.message, page: 'error' }); + } +}); + // Make config available to all views app.locals.config = config; @@ -75,6 +128,7 @@ const formatDate = (timestamp) => { }; const formatHashrate = (hashrate) => { + if (hashrate == null || !Number.isFinite(hashrate) || hashrate < 0) return '0 H/s'; if (hashrate >= 1e18) return (hashrate / 1e18).toFixed(2) + ' EH/s'; if (hashrate >= 1e15) return (hashrate / 1e15).toFixed(2) + ' PH/s'; if (hashrate >= 1e12) return (hashrate / 1e12).toFixed(2) + ' TH/s'; @@ -92,6 +146,27 @@ const formatDifficulty = (diff) => { return diff.toFixed(2); }; +const formatUptime = (seconds) => { + if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—'; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + const parts = []; + if (d > 0) parts.push(d + 'd'); + if (h > 0) parts.push(h + 'h'); + if (m > 0) parts.push(m + 'm'); + if (s > 0 || parts.length === 0) parts.push(s + 's'); + return parts.join(' '); +}; + +const formatRate = (bytesPerSec) => { + if (bytesPerSec == null || !Number.isFinite(bytesPerSec) || bytesPerSec < 0) return '—'; + if (bytesPerSec < 1 && bytesPerSec > 0) return bytesPerSec.toFixed(2) + ' B/s'; + if (bytesPerSec < 1024) return Math.round(bytesPerSec) + ' B/s'; + return formatBytes(bytesPerSec) + '/s'; +}; + // Make helpers available to all views app.locals.formatHash = formatHash; app.locals.formatNumber = formatNumber; @@ -100,6 +175,8 @@ app.locals.formatTimeAgo = formatTimeAgo; app.locals.formatDate = formatDate; app.locals.formatHashrate = formatHashrate; app.locals.formatDifficulty = formatDifficulty; +app.locals.formatUptime = formatUptime; +app.locals.formatRate = formatRate; // API proxy helper const apiCall = async (endpoint) => { @@ -112,36 +189,93 @@ const apiCall = async (endpoint) => { } }; +// Indexer API helper (holders, etc.) +const indexerApiCall = async (endpoint) => { + try { + const response = await axios.get(`${config.indexerApi}${endpoint}`, { timeout: 10000 }); + return response.data; + } catch (error) { + console.error(`Indexer API Error for ${endpoint}:`, error.message); + throw error; + } +}; + +// RPC call helper (for getmininginfo networkhashps - accurate hashrate) +const rpcCall = async (method, params = []) => { + if (!config.rpcUrl || !config.rpcUser || !config.rpcPassword) return null; + try { + const response = await axios.post(config.rpcUrl, { + jsonrpc: '1.0', + id: 'explorer', + method, + params + }, { + timeout: 5000, + auth: { username: config.rpcUser, password: config.rpcPassword }, + headers: { 'Content-Type': 'text/plain;' } + }); + return response.data?.error ? null : response.data?.result; + } catch (error) { + console.error(`RPC Error ${method}:`, error.message); + return null; + } +}; + +// Get hashrate: prefer RPC networkhashps, fallback to block-based estimate +const getHashrate = async (blocks) => { + const miningInfo = await rpcCall('getmininginfo'); + if (miningInfo != null && Number.isFinite(miningInfo.networkhashps) && miningInfo.networkhashps > 0) { + return miningInfo.networkhashps; + } + // Fallback: estimate from difficulty and avg block time + const blockTimeSec = blocks.length > 1 + ? Math.round((blocks[0].timestamp - blocks[blocks.length - 1].timestamp) / (blocks.length - 1)) + : config.blockTime; + const avgBlockTime = blockTimeSec > 0 ? blockTimeSec : config.blockTime; + const latestDifficulty = blocks[0]?.difficulty || 0; + return latestDifficulty > 0 && avgBlockTime > 0 + ? (latestDifficulty * Math.pow(2, 32)) / avgBlockTime + : 0; +}; + // ============ PAGES ============ // Dashboard app.get('/', async (req, res) => { try { - const [blocks, tipHeight, mempool, supplyData] = await Promise.all([ + const [blocks, tipHeight, mempoolStats, mempoolRecent, supplyData] = await Promise.all([ apiCall('/blocks'), apiCall('/blocks/tip/height'), - apiCall('/mempool/recent').catch(() => []), - apiCall('/blockchain/getsupply').catch(() => ({ total_amount_float: 0 })) + apiCall('/mempool').catch(() => ({ count: 0, vsize: 0, total_fee: 0 })), + apiCall('/mempool/recent').catch(() => []) ]); + const supplyRes = await apiCall('/blockchain/getsupply').catch(() => ({ total_amount_float: 0 })); + const supplyDataRes = supplyRes && typeof supplyRes === 'object' ? supplyRes : { total_amount_float: 0 }; + + // Mempool count: use /mempool count (accurate), fallback to recent list length + const mempoolCount = typeof mempoolStats?.count === 'number' + ? mempoolStats.count + : (Array.isArray(mempoolRecent) ? mempoolRecent.length : 0); // Calculate stats from recent blocks - const avgBlockTime = blocks.length > 1 + const blockTimeSec = blocks.length > 1 ? Math.round((blocks[0].timestamp - blocks[blocks.length - 1].timestamp) / (blocks.length - 1)) - : 120; - - // Estimate hashrate from difficulty (rough estimate) + : config.blockTime; + const avgBlockTime = blockTimeSec > 0 ? blockTimeSec : config.blockTime; const latestDifficulty = blocks[0]?.difficulty || 0; - const hashrate = (latestDifficulty * Math.pow(2, 32)) / avgBlockTime; + + // Hashrate: prefer RPC networkhashps (accurate), fallback to block-based estimate + const hashrate = await getHashrate(blocks); res.render('index', { title: 'Dashboard', blocks: blocks.slice(0, 15), tipHeight, - mempoolCount: mempool.length, + mempoolCount, difficulty: latestDifficulty, avgBlockTime, hashrate, - supply: supplyData.total_amount_float || 0, + supply: supplyDataRes.total_amount_float || supplyDataRes.total_amount || 0, page: 'dashboard' }); } catch (error) { @@ -247,11 +381,70 @@ app.get('/transactions', async (req, res) => { } }); +// Mempool page: stats + paginated pending txs with full detail (from electrs /mempool/txids + /tx/:id) +const MEMPOOL_PAGE_SIZE = 25; +const MEMPOOL_FETCH_CONCURRENCY = 10; + +async function fetchMempoolTxDetails(electrsApi, txids) { + const results = []; + for (let i = 0; i < txids.length; i += MEMPOOL_FETCH_CONCURRENCY) { + const chunk = txids.slice(i, i + MEMPOOL_FETCH_CONCURRENCY); + const batch = await Promise.all( + chunk.map((txid) => + axios.get(`${electrsApi}/tx/${txid}`, { timeout: 8000 }).then((r) => r.data).catch(() => null) + ) + ); + results.push(...batch); + } + return results.filter(Boolean).map((tx) => { + const value = (tx.vout || []).reduce((s, o) => s + (Number(o.value) || 0), 0); + const vsize = tx.weight != null ? Math.ceil(Number(tx.weight) / 4) : null; + return { + txid: tx.txid, + fee: tx.fee != null ? Number(tx.fee) : 0, + vsize: vsize || 0, + value, + }; + }); +} + +app.get('/mempool', async (req, res) => { + try { + const startIndex = Math.max(0, parseInt(req.query.start_index, 10) || 0); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || MEMPOOL_PAGE_SIZE)); + + const [stats, txidsPayload] = await Promise.all([ + apiCall('/mempool').catch(() => ({ count: 0, vsize: 0, total_fee: 0, fee_histogram: [] })), + apiCall(`/mempool/txids?start_index=${startIndex}&limit=${limit}`).catch(() => ({ txids: [], total: 0, start_index: 0, limit: 0 })), + ]); + + const txids = txidsPayload.txids || []; + const total = typeof txidsPayload.total === 'number' ? txidsPayload.total : txids.length; + const pending = txids.length > 0 ? await fetchMempoolTxDetails(ELECTRS_API, txids) : []; + + res.render('mempool', { + title: 'Mempool', + stats, + feeHistogram: stats.fee_histogram || [], + pending, + total, + startIndex, + limit, + page: 'mempool', + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load mempool', error: error.message, page: 'error' }); + } +}); + // Transaction detail app.get('/tx/:txid', async (req, res) => { try { const { txid } = req.params; - const tx = await apiCall(`/tx/${txid}`); + const [tx, rawHex] = await Promise.all([ + apiCall(`/tx/${txid}`), + axios.get(`${ELECTRS_API}/tx/${txid}/hex`, { timeout: 5000 }).then(r => r.data).catch(() => null) + ]); // Calculate totals let totalInput = 0, totalOutput = 0; @@ -264,11 +457,24 @@ app.get('/tx/:txid', async (req, res) => { totalOutput += vout.value || 0; }); + // Derived fields for extreme detail view + const vsize = tx.weight != null ? Math.ceil(Number(tx.weight) / 4) : (tx.size || 0); + const feeRateSatPerVb = tx.fee != null && vsize ? (Number(tx.fee) / vsize).toFixed(2) : null; + const locktimeInterpretation = tx.locktime != null + ? (tx.locktime < 500000000 + ? `Block height ${formatNumber(tx.locktime)}` + : `Unix time ${tx.locktime} (${new Date(tx.locktime * 1000).toISOString()} UTC)`) + : null; + res.render('transaction', { title: `Transaction ${formatHash(txid)}`, tx, totalInput, totalOutput, + rawHex: typeof rawHex === 'string' ? rawHex : null, + vsize, + feeRateSatPerVb, + locktimeInterpretation, page: 'transactions' }); } catch (error) { @@ -333,6 +539,255 @@ app.get('/address/:address', async (req, res) => { } }); +// Nodes page +app.get('/nodes', async (req, res) => { + try { + const networkData = await indexerApiCall('/network'); + res.render('nodes', { + title: 'Nodes', + network: networkData, + page: 'nodes' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load node info', error: error.message, page: 'error' }); + } +}); + +// Chain Tips / Forks page +app.get('/chain-tips', async (req, res) => { + try { + const chainTips = await rpcCall('getchaintips'); + const tipHeight = await apiCall('/blocks/tip/height'); + + res.render('chain-tips', { + title: 'Chain Tips', + chainTips: chainTips || [], + tipHeight, + page: 'chain-tips' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load chain tips', error: error.message, page: 'error' }); + } +}); + +// Reorg History page +app.get('/reorgs', async (req, res) => { + try { + const chainTips = await rpcCall('getchaintips'); + const tipHeight = await apiCall('/blocks/tip/height'); + const blocks = await apiCall('/blocks'); + + // Analyze chain tips to identify potential reorgs + const reorgs = []; + if (chainTips && Array.isArray(chainTips)) { + chainTips.forEach(tip => { + if (tip.status === 'valid-fork' || tip.status === 'valid-headers') { + reorgs.push({ + height: tip.height, + hash: tip.hash, + branchlen: tip.branchlen || 0, + status: tip.status, + forkHeight: tip.height - (tip.branchlen || 0) + }); + } + }); + } + + res.render('reorgs', { + title: 'Reorg History', + reorgs: reorgs.sort((a, b) => b.height - a.height), + tipHeight, + page: 'reorgs' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load reorg history', error: error.message, page: 'error' }); + } +}); + +// Orphaned Blocks page +app.get('/orphans', async (req, res) => { + try { + const chainTips = await rpcCall('getchaintips'); + const tipHeight = await apiCall('/blocks/tip/height'); + + // Find orphaned blocks (valid-fork, valid-headers that aren't active) + const orphans = []; + if (chainTips && Array.isArray(chainTips)) { + chainTips.forEach(tip => { + if (tip.status === 'valid-fork' || tip.status === 'valid-headers') { + orphans.push({ + height: tip.height, + hash: tip.hash, + branchlen: tip.branchlen || 0, + status: tip.status + }); + } + }); + } + + res.render('orphans', { + title: 'Orphaned Blocks', + orphans: orphans.sort((a, b) => b.height - a.height), + tipHeight, + page: 'orphans' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load orphaned blocks', error: error.message, page: 'error' }); + } +}); + +// Chain Health page +app.get('/chain-health', async (req, res) => { + try { + const [blockchainInfo, networkInfo, miningInfo, tipHeight] = await Promise.all([ + rpcCall('getblockchaininfo'), + rpcCall('getnetworkinfo'), + rpcCall('getmininginfo'), + apiCall('/blocks/tip/height') + ]); + + const syncProgress = blockchainInfo?.verificationprogress || 0; + const blocksBehind = blockchainInfo ? (blockchainInfo.headers - blockchainInfo.blocks) : 0; + const isSynced = blocksBehind === 0 && syncProgress >= 0.999; + + res.render('chain-health', { + title: 'Chain Health', + blockchainInfo: blockchainInfo || {}, + networkInfo: networkInfo || {}, + miningInfo: miningInfo || {}, + tipHeight, + syncProgress: (syncProgress * 100).toFixed(2), + blocksBehind, + isSynced, + page: 'chain-health' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load chain health', error: error.message, page: 'error' }); + } +}); + +// Network Traffic page (detailed network: bandwidth, rates, connection breakdown, fees — no peer list; see Nodes for peers) +app.get('/network-traffic', async (req, res) => { + try { + const [netTotals, networkInfo, mempoolStats] = await Promise.all([ + rpcCall('getnettotals'), + rpcCall('getnetworkinfo'), + apiCall('/mempool').catch(() => ({ count: 0, vsize: 0, total_fee: 0 })) + ]); + + const totals = netTotals || {}; + const recv = totals.totalbytesrecv != null ? Number(totals.totalbytesrecv) : 0; + const sent = totals.totalbytessent != null ? Number(totals.totalbytessent) : 0; + let rawTime = totals.timemillis != null ? Number(totals.timemillis) : 0; + // Some nodes return timemillis in microseconds; if value is huge, treat as microseconds + const uptimeSec = rawTime >= 1e12 ? rawTime / 1e6 : (rawTime > 0 ? rawTime / 1000 : 0); + const recvRate = uptimeSec > 0 ? recv / uptimeSec : 0; + const sentRate = uptimeSec > 0 ? sent / uptimeSec : 0; + // Cap absurd uptime display (e.g. > 365 days show as-is; if calculation would show 10000+ days, treat as unknown) + const maxReasonableDays = 365 * 10; + const uptimeValid = uptimeSec > 0 && uptimeSec < maxReasonableDays * 86400; + + res.render('network-traffic', { + title: 'Network Traffic', + netTotals: totals, + networkInfo: networkInfo || {}, + recvRateFormatted: formatRate(recvRate), + sentRateFormatted: formatRate(sentRate), + uptimeSec, + uptimeValid, + mempoolStats: mempoolStats || { count: 0, vsize: 0, total_fee: 0 }, + page: 'network-traffic' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load network traffic', error: error.message, page: 'error' }); + } +}); + +// Difficulty History page +app.get('/difficulty', async (req, res) => { + try { + const tipHeight = await apiCall('/blocks/tip/height'); + const currentHeight = typeof tipHeight === 'number' ? tipHeight : parseInt(tipHeight || '0', 10); + + // Fetch blocks for difficulty history (last 100 blocks, sample every 10th) + const difficultyHistory = []; + const sampleInterval = 10; + const maxBlocks = 100; + + for (let i = 0; i < maxBlocks; i += sampleInterval) { + const height = currentHeight - i; + if (height < 0) break; + + try { + const blockHash = await apiCall(`/block-height/${height}`); + const block = await apiCall(`/block/${blockHash}`); + if (block && block.difficulty) { + difficultyHistory.push({ + height: block.height, + timestamp: block.timestamp, + difficulty: block.difficulty, + date: new Date(block.timestamp * 1000).toLocaleDateString() + }); + } + } catch (e) { + // Skip if block not found + continue; + } + } + + difficultyHistory.reverse(); // Oldest first + + res.render('difficulty', { + title: 'Difficulty History', + difficultyHistory, + tipHeight: currentHeight, + page: 'difficulty' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load difficulty history', error: error.message, page: 'error' }); + } +}); + +// API documentation page +app.get('/api-docs', (req, res) => { + const baseUrl = `${req.protocol}://${req.get('host')}/api`; + res.render('api', { + title: 'API', + baseUrl, + page: 'api' + }); +}); + +// Glossary / FAQ page (static content) +app.get('/glossary', (req, res) => { + res.render('glossary', { + title: 'Glossary & FAQ', + page: 'glossary' + }); +}); + +// Holders page +app.get('/holders', async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 50; + + const holdersData = await indexerApiCall(`/holders?page=${page}&limit=${limit}`); + + res.render('holders', { + title: 'Holders', + holders: holdersData.holders || [], + currentPage: holdersData.page || page, + limit: holdersData.limit || limit, + total: holdersData.total || 0, + totalPages: holdersData.total_pages || 1, + page: 'holders' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load holders', error: error.message, page: 'error' }); + } +}); + // Statistics page app.get('/statistics', async (req, res) => { try { @@ -342,12 +797,14 @@ app.get('/statistics', async (req, res) => { ]); // Calculate stats - const avgBlockTime = blocks.length > 1 + const blockTimeSec = blocks.length > 1 ? Math.round((blocks[0].timestamp - blocks[blocks.length - 1].timestamp) / (blocks.length - 1)) - : 120; - + : config.blockTime; + const avgBlockTime = blockTimeSec > 0 ? blockTimeSec : config.blockTime; const latestDifficulty = blocks[0]?.difficulty || 0; - const hashrate = (latestDifficulty * Math.pow(2, 32)) / avgBlockTime; + + // Hashrate: prefer RPC networkhashps (accurate), fallback to block-based estimate + const hashrate = await getHashrate(blocks); // Get daily tx counts (simplified - from recent blocks) const dailyStats = blocks.map(b => ({ @@ -370,6 +827,307 @@ app.get('/statistics', async (req, res) => { } }); +// Emission / halving page +app.get('/emission', async (req, res) => { + try { + const [tipHeight, supplyData] = await Promise.all([ + apiCall('/blocks/tip/height'), + apiCall('/blockchain/getsupply').catch(() => null), + ]); + + const HALVING_INTERVAL = 210000; // from wojakcore chainparams + const INITIAL_SUBSIDY = 100; // WOJAK per block + const blockTimeSec = config.blockTime || 120; + + const maxEpochs = 15; + let reward = INITIAL_SUBSIDY; + let cumulative = 0; + const epochs = []; + + for (let epoch = 0; epoch < maxEpochs && reward > 0; epoch++) { + const startHeight = epoch * HALVING_INTERVAL; + const endHeight = (epoch + 1) * HALVING_INTERVAL - 1; + const blocksInEpoch = HALVING_INTERVAL; + const coinsThisEpoch = reward * blocksInEpoch; + cumulative += coinsThisEpoch; + + epochs.push({ + epoch, + reward, + startHeight, + endHeight, + blocksInEpoch, + coinsThisEpoch, + cumulativeSupply: cumulative, + }); + + reward = reward / 2; + } + + const currentHeight = typeof tipHeight === 'number' ? tipHeight : parseInt(tipHeight || '0', 10); + const currentEpochIndex = Math.floor(currentHeight / HALVING_INTERVAL); + const currentEpoch = epochs[Math.min(currentEpochIndex, epochs.length - 1)]; + const currentReward = currentEpoch ? currentEpoch.reward : 0; + + const nextHalvingHeight = (currentEpochIndex + 1) * HALVING_INTERVAL; + const blocksToHalving = nextHalvingHeight > currentHeight ? (nextHalvingHeight - currentHeight) : 0; + const secondsToHalving = blocksToHalving * blockTimeSec; + const estimatedHalvingDate = blocksToHalving > 0 + ? new Date(Date.now() + secondsToHalving * 1000).toLocaleString() + : null; + + // WojakCoin MAX_MONEY (from wojakcore src/amount.h): 44,210,526 * COIN + const theoreticalMaxSupply = 44210526; + const actualSupply = supplyData && (supplyData.total_amount_float || supplyData.total_amount); + + res.render('emission', { + title: 'Emission', + currentHeight, + currentReward, + nextHalvingHeight, + blocksToHalving, + estimatedHalvingDate, + theoreticalMaxSupply, + actualSupply, + epochs, + halvingInterval: HALVING_INTERVAL, + page: 'emission', + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load emission data', error: error.message, page: 'error' }); + } +}); + +// Coin flow: money moving from address to address +const COIN_FLOW_PAGE_SIZE = 30; +const COIN_FLOW_BATCH = 8; + +app.get('/coin-flow', async (req, res) => { + try { + const page = Math.max(0, parseInt(req.query.page) || 0); + const blocks = await apiCall('/blocks'); + const txids = []; + const minTxids = (page + 1) * COIN_FLOW_PAGE_SIZE; + for (const block of blocks.slice(0, 20)) { + try { + const txs = await apiCall(`/block/${block.id}/txs/0`); + const ids = Array.isArray(txs) ? txs.map(t => t.txid || t) : []; + ids.forEach(id => txids.push({ txid: id, blockHeight: block.height, blockTime: block.timestamp })); + if (txids.length >= minTxids) break; + } catch (e) { + continue; + } + } + const start = page * COIN_FLOW_PAGE_SIZE; + const pageTxids = txids.slice(start, start + COIN_FLOW_PAGE_SIZE); + const flows = []; + for (let i = 0; i < pageTxids.length; i += COIN_FLOW_BATCH) { + const batch = pageTxids.slice(i, i + COIN_FLOW_BATCH); + const fullTxs = await Promise.all( + batch.map(({ txid }) => apiCall(`/tx/${txid}`).catch(() => null)) + ); + fullTxs.forEach((tx, idx) => { + if (!tx) return; + const meta = pageTxids[i + idx]; + const from = []; + const to = []; + (tx.vin || []).forEach(vin => { + if (vin.is_coinbase) { + from.push({ address: 'Coinbase (new coins)', value: 0, coinbase: true }); + } else if (vin.prevout) { + const addr = vin.prevout.scriptpubkey_address || null; + const val = vin.prevout.value != null ? vin.prevout.value : 0; + if (addr) from.push({ address: addr, value: val, coinbase: false }); + } + }); + (tx.vout || []).forEach(vout => { + const addr = vout.scriptpubkey_address || (vout.scriptpubkey && vout.scriptpubkey.address) || null; + const val = vout.value != null ? vout.value : 0; + if (addr) to.push({ address: addr, value: val }); + }); + flows.push({ + txid: tx.txid, + blockHeight: meta.blockHeight, + blockTime: meta.blockTime, + from, + to, + fee: tx.fee != null ? tx.fee : null, + status: tx.status + }); + }); + } + const totalPages = Math.ceil(txids.length / COIN_FLOW_PAGE_SIZE) || 1; + res.render('coin-flow', { + title: 'Coin Flow', + flows, + total: txids.length, + currentPage: page, + totalPages, + pageSize: COIN_FLOW_PAGE_SIZE, + page: 'coin-flow' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load coin flow', error: error.message, page: 'error' }); + } +}); + +// Redirect old supply-flow URL to coin-flow (address-to-address flow) +app.get('/supply-flow', (req, res) => res.redirect(301, '/coin-flow')); + +// Legacy supply-over-time page (kept for possible future use; not linked in nav) +app.get('/supply-flow-chart', async (req, res) => { + try { + const [tipHeight, supplyData, indexerSupply] = await Promise.all([ + apiCall('/blocks/tip/height'), + apiCall('/blockchain/getsupply').catch(() => ({ total_amount_float: 0 })), + indexerApiCall('/block/circulating-supply').catch(() => null) + ]); + + // Get circulating supply (prefer indexer, fallback to electrs) + const circulatingSupply = indexerSupply != null ? indexerSupply.circulatingSupply : (supplyData.total_amount_float || 0); + + // Calculate some historical points (simplified - could be enhanced) + const currentReward = config.blockTime ? config.blockTime / 60 : 0; // blocks per hour + const dailyBlocks = 24 * 60 / (config.blockTime || 120); // blocks per day + const dailySupplyIncrease = dailyBlocks * 50; // 50 WJK per block (current reward) + + // Generate projection data (next 365 days) + const projectionDays = 365; + const projectionData = []; + for (let i = 0; i < projectionDays; i += 30) { // Monthly points + const supply = circulatingSupply + (i * dailySupplyIncrease); + projectionData.push({ + days: i, + supply: supply, + date: new Date(Date.now() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + }); + } + + res.render('supply-flow', { + title: 'Supply Flow', + tipHeight, + circulatingSupply, + dailySupplyIncrease, + projectionData, + page: 'supply-flow' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load supply flow data', error: error.message, page: 'error' }); + } +}); + +// Supply dashboard page +app.get('/supply', async (req, res) => { + try { + const [tipHeight, supplyData, circData] = await Promise.all([ + apiCall('/blocks/tip/height'), + apiCall('/blockchain/getsupply').catch(() => null), + indexerApiCall('/block/circulating-supply').catch(() => null), + ]); + + const HALVING_INTERVAL = 210000; + const INITIAL_SUBSIDY = 100; + const blockTimeSec = config.blockTime || 120; + const MAX_SUPPLY = 44210526; // from wojakcore MAX_MONEY/COIN + + const currentHeight = typeof tipHeight === 'number' ? tipHeight : parseInt(tipHeight || '0', 10); + const totalSupply = supplyData && (supplyData.total_amount_float || supplyData.total_amount) || null; + // Prefer on-chain total for circulating when available so they match + const circulatingSupply = totalSupply != null + ? totalSupply + : (circData && circData.circulatingSupply) || null; + + const epochIndex = currentHeight > 0 ? Math.floor(currentHeight / HALVING_INTERVAL) : 0; + const currentReward = INITIAL_SUBSIDY / Math.pow(2, epochIndex); + const blocksPerDay = 86400 / blockTimeSec; + const dailyIssuance = currentReward * blocksPerDay; + const yearlyIssuance = dailyIssuance * 365; + + const mintedPct = totalSupply ? (totalSupply / MAX_SUPPLY) * 100 : null; + const circPct = circulatingSupply ? (circulatingSupply / MAX_SUPPLY) * 100 : null; + + res.render('supply', { + title: 'Supply', + currentHeight, + totalSupply, + circulatingSupply, + maxSupply: MAX_SUPPLY, + mintedPct, + circPct, + currentReward, + dailyIssuance, + yearlyIssuance, + blocksPerDay, + page: 'supply', + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load supply dashboard', error: error.message, page: 'error' }); + } +}); + +// Mining stats page +app.get('/mining', async (req, res) => { + try { + const [blocks, miningInfo] = await Promise.all([ + apiCall('/blocks'), + rpcCall('getmininginfo') + ]); + const recentBlocks = (blocks || []).slice(0, 15); + + const miningBlocks = []; + for (const block of recentBlocks) { + try { + const txs = await apiCall(`/block/${block.id}/txs/0`); + const cbTx = Array.isArray(txs) && txs.length > 0 ? txs[0] : null; + if (!cbTx || !cbTx.vout || !cbTx.vout.length) continue; + + const minerAddress = cbTx.vout[0].scriptpubkey_address || 'Unknown'; + const rewardSats = (cbTx.vout || []).reduce((sum, v) => sum + (v.value || 0), 0); + const reward = rewardSats / 1e8; + + miningBlocks.push({ + height: block.height, + hash: block.id, + timestamp: block.timestamp, + txCount: block.tx_count, + difficulty: block.difficulty, + minerAddress, + reward, + }); + } catch (e) { + continue; + } + } + + const minerMap = {}; + miningBlocks.forEach((b) => { + const key = b.minerAddress || 'Unknown'; + if (!minerMap[key]) { + minerMap[key] = { minerAddress: key, blocks: 0, totalReward: 0 }; + } + minerMap[key].blocks += 1; + minerMap[key].totalReward += b.reward || 0; + }); + + const miners = Object.values(minerMap).sort((a, b) => b.blocks - a.blocks); + const totalBlocks = miningBlocks.length || 1; + const hashrate = miningInfo?.networkhashps != null ? miningInfo.networkhashps : 0; + const difficulty = miningInfo?.difficulty ?? (recentBlocks[0]?.difficulty) ?? 0; + + res.render('mining', { + title: 'Mining Stats', + blocks: miningBlocks, + miners, + totalBlocks, + hashrate, + difficulty, + page: 'mining', + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load mining stats', error: error.message, page: 'error' }); + } +}); + // Search handler app.get('/search', async (req, res) => { const query = req.query.q?.trim(); diff --git a/views/api.ejs b/views/api.ejs new file mode 100644 index 0000000..4a98440 --- /dev/null +++ b/views/api.ejs @@ -0,0 +1,210 @@ +<%- include('partials/header') %> + +
+ + +
+
+

Base URL

+
+ <%= baseUrl %> + +
+
+ + +
+

Blocks

+
+ GET + /blocks +

10 newest blocks. Add /:start_height for blocks starting at height.

+
+ curl <%= baseUrl %>/blocks + +
+
+
+ GET + /blocks/tip/height +

Current block height.

+
+ curl <%= baseUrl %>/blocks/tip/height + +
+
+
+ GET + /blocks/tip/hash +

Current block hash.

+
+ curl <%= baseUrl %>/blocks/tip/hash + +
+
+
+ GET + /block/:hash +

Block info by hash.

+
+ curl <%= baseUrl %>/block/<hash> + +
+
+
+ GET + /block/:hash/txs/:start_index +

Transactions in block (25 per page, start_index must be multiple of 25).

+
+ curl <%= baseUrl %>/block/<hash>/txs/0 + +
+
+
+ GET + /block-height/:height +

Block hash at given height.

+
+ curl <%= baseUrl %>/block-height/15800 + +
+
+
+ + +
+

Transactions

+
+ GET + /tx/:txid +

Transaction details.

+
+ curl <%= baseUrl %>/tx/<txid> + +
+
+
+ GET + /tx/:txid/status +

Transaction confirmation status.

+
+ curl <%= baseUrl %>/tx/<txid>/status + +
+
+
+ GET + /tx/:txid/hex +

Raw transaction hex.

+
+
+ GET + /tx/:txid/outspends +

Spending status of all outputs.

+
+
+ POST + /tx +

Broadcast raw transaction (hex in body).

+
+ curl -X POST <%= baseUrl %>/tx -d "02000000..." + +
+
+
+ + +
+

Addresses

+
+ GET + /address/:address +

Address stats (chain_stats, mempool_stats).

+
+ curl <%= baseUrl %>/address/<address> + +
+
+
+ GET + /address/:address/txs +

Transaction history (50 mempool + 25 confirmed). Add ?start_index=N&limit=25 for pagination.

+
+ curl "<%= baseUrl %>/address/<address>/txs?start_index=0&limit=25" + +
+
+
+ GET + /address/:address/utxo +

Unspent outputs. Add ?start_index=N&limit=25 for pagination.

+
+ curl "<%= baseUrl %>/address/<address>/utxo?start_index=0&limit=25" + +
+
+
+ + +
+

Mempool

+
+ GET + /mempool +

Mempool stats (count, vsize, total_fee, fee_histogram).

+
+ curl <%= baseUrl %>/mempool + +
+
+
+ GET + /mempool/recent +

Last 10 transactions to enter mempool.

+
+ curl <%= baseUrl %>/mempool/recent + +
+
+
+ GET + /mempool/txids +

All txids in mempool.

+
+
+ + +
+

Fee & Supply

+
+ GET + /fee-estimates +

Fee estimates by confirmation target (sat/vB).

+
+ curl <%= baseUrl %>/fee-estimates + +
+
+
+ GET + /blockchain/getsupply +

Total supply (if supported by backend).

+
+ curl <%= baseUrl %>/blockchain/getsupply + +
+
+
+ + +
+
+ +<%- include('partials/footer') %> diff --git a/views/chain-health.ejs b/views/chain-health.ejs new file mode 100644 index 0000000..f83ca25 --- /dev/null +++ b/views/chain-health.ejs @@ -0,0 +1,186 @@ +<%- include('partials/header') %> + +
+ + + +
+
+
+ SYNC STATUS + +
+
+ <%= isSynced ? 'Synced' : 'Syncing' %> +
+
+
+
+ VERIFICATION PROGRESS + +
+
<%= syncProgress %>%
+
+
+
+ BLOCKS BEHIND + +
+
<%= formatNumber(blocksBehind) %>
+
+
+
+ CURRENT HEIGHT + +
+
<%= formatNumber(blockchainInfo.blocks || tipHeight) %>
+
+
+ + +
+
+

Blockchain Information

+
+
+
+ Chain + <%= blockchainInfo.chain || 'main' %> +
+
+ Blocks + <%= formatNumber(blockchainInfo.blocks || 0) %> +
+
+ Headers + <%= formatNumber(blockchainInfo.headers || 0) %> +
+ +
+ Difficulty + <%= formatDifficulty(blockchainInfo.difficulty || 0) %> +
+
+ Median Time + <%= blockchainInfo.mediantime ? formatDate(blockchainInfo.mediantime) : '--' %> +
+
+ Verification Progress + <%= syncProgress %>% +
+
+ Chainwork + <%= blockchainInfo.chainwork || '--' %> +
+
+ Pruned + <%= blockchainInfo.pruned ? 'Yes' : 'No' %> +
+
+
+ + +
+
+

Network Information

+
+
+
+ Version + <%= networkInfo.version || '--' %> +
+
+ Subversion + <%= networkInfo.subversion || '--' %> +
+
+ Connections + <%= formatNumber(networkInfo.connections || 0) %> +
+
+ Network Active + <%= networkInfo.networkactive ? 'Yes' : 'No' %> +
+
+ Relay Fee + <%= networkInfo.relayfee ? (networkInfo.relayfee * 1e8).toFixed(0) + ' sat/kB' : '--' %> +
+
+ Incremental Fee + <%= networkInfo.incrementalfee ? (networkInfo.incrementalfee * 1e8).toFixed(0) + ' sat/kB' : '--' %> +
+
+
+ + + <% if (miningInfo && Object.keys(miningInfo).length > 0) { %> +
+
+

Mining Information

+
+
+
+ Network Hashrate + <%= formatHashrate(miningInfo.networkhashps || 0) %> +
+
+ Difficulty + <%= formatDifficulty(miningInfo.difficulty || 0) %> +
+
+ Blocks + <%= formatNumber(miningInfo.blocks || 0) %> +
+
+
+ <% } %> + + + <% if (blockchainInfo.softforks && blockchainInfo.softforks.length > 0) { %> +
+
+

Soft Forks

+
+
+ + + + + + + + + + + + <% blockchainInfo.softforks.forEach(function(fork) { %> + + + + + + + + <% }); %> + +
IDVersionStatusFoundRequired
<%= fork.id %><%= fork.version %> + + <%= fork.enforce && fork.enforce.status ? 'Enforced' : 'Pending' %> + + <%= fork.enforce ? fork.enforce.found : '--' %> / <%= fork.enforce ? fork.enforce.window : '--' %><%= fork.enforce ? fork.enforce.required : '--' %>
+
+
+ <% } %> +
+ +<%- include('partials/footer') %> diff --git a/views/chain-tips.ejs b/views/chain-tips.ejs new file mode 100644 index 0000000..d881a6d --- /dev/null +++ b/views/chain-tips.ejs @@ -0,0 +1,112 @@ +<%- include('partials/header') %> + +
+ + + +
+
+
+ ACTIVE TIPS + +
+
<%= formatNumber(chainTips.length) %>
+
+
+
+ MAIN CHAIN HEIGHT + +
+
<%= formatNumber(tipHeight) %>
+
+
+
+ FORKED CHAINS + +
+
+ <%= formatNumber(chainTips.filter(function(t) { return t.status === 'valid-fork' || t.status === 'valid-headers'; }).length) %> +
+
+
+ + +
+
+

Chain Tips

+
+
+ + + + + + + + + + + + <% if (!chainTips || chainTips.length === 0) { %> + + + + <% } else { %> + <% chainTips.sort(function(a, b) { return b.height - a.height; }).forEach(function(tip) { %> + + + + + + + + <% }); %> + <% } %> + +
HeightHashBranch LengthStatusFork Height
+ +

No active forks detected. Chain is healthy.

+
<%= formatNumber(tip.height) %> + + <%= formatHash(tip.hash, 20) %> + + <%= formatNumber(tip.branchlen || 0) %> + + <%= tip.status %> + + + <%= tip.branchlen ? formatNumber(tip.height - (tip.branchlen || 0)) : '--' %> +
+
+
+ + +
+
+

About Chain Tips

+
+
+
+ Active + The main chain tip (longest chain) +
+
+ Valid Fork + A valid chain fork that may cause a reorg +
+
+ Valid Headers + Chain with valid headers but incomplete blocks +
+
+ Branch Length + Number of blocks in this fork branch +
+
+
+
+ +<%- include('partials/footer') %> diff --git a/views/coin-flow.ejs b/views/coin-flow.ejs new file mode 100644 index 0000000..87414c8 --- /dev/null +++ b/views/coin-flow.ejs @@ -0,0 +1,94 @@ +<%- include('partials/header') %> + +
+ + +
+
+

Recent Transfers

+ + <%= typeof total === 'number' ? formatNumber(total) : '0' %> transactions · page <%= (currentPage || 0) + 1 %> of <%= totalPages || 1 %> + +
+ +
+ <% if (!flows || flows.length === 0) { %> +

No transactions to show. Try again later.

+ <% } else { %> + <% flows.forEach(function(flow) { + const totalOut = (flow.to || []).reduce(function(s, o) { return s + (o.value || 0); }, 0); + %> +
+
+ + <%= flow.blockTime ? formatDate(flow.blockTime) : 'Pending' %> + + <% if (flow.blockHeight != null) { %> + #<%= formatNumber(flow.blockHeight) %> + <% } %> +
+
+
+ From +
+ <% (flow.from || []).forEach(function(f) { %> + <% if (f.coinbase) { %> + Coinbase + <% } else { %> + <%= formatHash(f.address, 16) %> + <%= f.value != null ? (f.value / 100000000).toFixed(8) : '0' %> <%= config.coinTicker %> + <% } %> + <% }); %> + <% if ((flow.from || []).length === 0) { %><% } %> +
+
+
+
+ To +
+ <% (flow.to || []).forEach(function(t) { %> + <%= formatHash(t.address, 16) %> + <%= t.value != null ? (t.value / 100000000).toFixed(8) : '0' %> <%= config.coinTicker %> + <% }); %> + <% if ((flow.to || []).length === 0) { %><% } %> +
+
+
+
+ <%= (totalOut / 100000000).toFixed(8) %> <%= config.coinTicker %> + <% if (flow.fee != null) { %> + Fee: <%= (flow.fee / 100000000).toFixed(8) %> <%= config.coinTicker %> + <% } %> +
+ +
+ <% }); %> + <% } %> +
+ + <% if (totalPages > 1) { %> +
+ <% const prevPage = (currentPage || 0) - 1; const nextPage = (currentPage || 0) + 1; %> + <% if (prevPage >= 0) { %> + Previous + <% } else { %> + Previous + <% } %> + Page <%= (currentPage || 0) + 1 %> of <%= totalPages %> + <% if (nextPage < totalPages) { %> + Next + <% } else { %> + Next + <% } %> +
+ <% } %> +
+
+ +<%- include('partials/footer') %> diff --git a/views/difficulty.ejs b/views/difficulty.ejs new file mode 100644 index 0000000..39c5242 --- /dev/null +++ b/views/difficulty.ejs @@ -0,0 +1,192 @@ +<%- include('partials/header') %> + +
+ + + +
+
+
+ CURRENT DIFFICULTY + +
+
+ <%= difficultyHistory.length > 0 ? formatDifficulty(difficultyHistory[difficultyHistory.length - 1].difficulty) : '--' %> +
+
+
+
+ MIN DIFFICULTY + +
+
+ <%= difficultyHistory.length > 0 ? formatDifficulty(Math.min(...difficultyHistory.map(d => d.difficulty))) : '--' %> +
+
+
+
+ MAX DIFFICULTY + +
+
+ <%= difficultyHistory.length > 0 ? formatDifficulty(Math.max(...difficultyHistory.map(d => d.difficulty))) : '--' %> +
+
+
+
+ SAMPLES + +
+
<%= formatNumber(difficultyHistory.length) %>
+
+
+ + +
+
+

Difficulty Over Time

+
+
+ +
+
+ + +
+
+

Difficulty History

+
+
+ + + + + + + + + + + <% if (!difficultyHistory || difficultyHistory.length === 0) { %> + + + + <% } else { %> + <% difficultyHistory.slice().reverse().forEach(function(entry, index) { %> + + + + + + + <% }); %> + <% } %> + +
HeightDateDifficultyChange
+ +

No difficulty history available.

+
<%= formatNumber(entry.height) %><%= formatDate(entry.timestamp) %><%= formatDifficulty(entry.difficulty) %> + <% if (index > 0) { + const prev = difficultyHistory[difficultyHistory.length - index]; + const change = ((entry.difficulty - prev.difficulty) / prev.difficulty * 100).toFixed(2); + %> + + <%= change > 0 ? '+' : '' %><%= change %>% + + <% } else { %> + -- + <% } %> +
+
+
+ + +
+
+

About Difficulty

+
+
+
+ What is difficulty? + Difficulty measures how hard it is to find a valid block. It is derived from the block header target: lower target means higher difficulty and more hashes needed on average. +
+
+ Difficulty adjustment + <%= config.coinName %> uses <%= config.diffAdjustment %>. The network periodically recalculates difficulty so that block times stay near the target (<%= config.blockTime %> seconds). +
+
+ Hashrate and difficulty + Expected hashes per block ≈ difficulty × 232 (for <%= config.algorithm %>). Network hashrate (H/s) ≈ (difficulty × 232) ÷ block time. +
+
+ Why it changes + If blocks are found too quickly, difficulty increases. If too slowly, it decreases. This keeps issuance and security predictable. +
+
+ Chart & table + The chart and table above show difficulty at sampled block heights. Use the Mining page for current hashrate and miner distribution. +
+
+
+
+ + + +<%- include('partials/footer') %> diff --git a/views/emission.ejs b/views/emission.ejs new file mode 100644 index 0000000..00d83d2 --- /dev/null +++ b/views/emission.ejs @@ -0,0 +1,113 @@ +<%- include('partials/header') %> + +
+ + + +
+
+
+ CURRENT HEIGHT + +
+
<%= formatNumber(currentHeight) %>
+
+ +
+
+ CURRENT BLOCK REWARD + +
+
+ <%= currentReward.toFixed(2) %> + <%= config.coinTicker %> +
+
+ +
+
+ NEXT HALVING AT + +
+
+ <%= formatNumber(nextHalvingHeight) %> +
+ +
+ +
+
+ SUPPLY + +
+
+ <% if (actualSupply) { %> + <%= formatNumber(Math.round(actualSupply)) %> + <%= config.coinTicker %> + <% } else { %> + N/A + <% } %> +
+ +
+
+ + +
+
+

Halving Schedule (interval <%= formatNumber(halvingInterval) %> blocks)

+
+
+ + + + + + + + + + + + + <% if (!epochs || epochs.length === 0) { %> + + + + <% } else { %> + <% epochs.forEach(function(e) { %> + + + + + + + + + <% }); %> + <% } %> + +
EpochReward (per block)Block RangeBlocksCoins This EpochCumulative Supply
+ +

No halving schedule data.

+
#<%= e.epoch %><%= e.reward.toFixed(2) %> <%= config.coinTicker %><%= formatNumber(e.startHeight) %> - <%= formatNumber(e.endHeight) %><%= formatNumber(e.blocksInEpoch) %><%= formatNumber(Math.round(e.coinsThisEpoch)) %> <%= config.coinTicker %><%= formatNumber(Math.round(e.cumulativeSupply)) %> <%= config.coinTicker %>
+
+
+
+ +<%- include('partials/footer') %> + diff --git a/views/glossary.ejs b/views/glossary.ejs new file mode 100644 index 0000000..a80b112 --- /dev/null +++ b/views/glossary.ejs @@ -0,0 +1,104 @@ +<%- include('partials/header') %> + +
+ + + +
+
+

Glossary

+
+
+
+ Address + A string (e.g. starting with a letter or number) that represents a destination for <%= config.coinTicker %>. You share your address to receive funds. Never share your private key. +
+
+ Block + A batch of transactions and metadata (timestamp, previous block hash, nonce, etc.) secured by proof of work. Blocks are chained together to form the blockchain. +
+
+ Block height + The number of blocks from the genesis (first) block. Height 0 is the genesis block; the latest block has the highest height. +
+
+ Block reward + New <%= config.coinTicker %> created in each block and paid to the miner via the coinbase transaction. This reward halves at fixed intervals (halving). +
+
+ Confirmation + A transaction has one confirmation when it is in the latest block, two when another block is mined on top, and so on. More confirmations mean stronger assurance the transaction is final. +
+
+ Difficulty + A measure of how hard it is to find a valid block. The network adjusts difficulty so that blocks are found roughly every target interval (e.g. <%= config.blockTime %> seconds). +
+
+ Halving + A scheduled event that cuts the block reward in half. It happens at fixed block intervals (e.g. every 210,000 blocks) to control inflation. +
+
+ Hashrate + The total number of hash attempts per second by the network (e.g. TH/s). Higher hashrate means more security and more mining power competing for blocks. +
+
+ Mempool + The set of unconfirmed transactions that nodes have received and are waiting to be included in a block. Miners typically choose transactions from the mempool when building blocks. +
+
+ Miner + A participant who runs hardware (or software) to solve proof-of-work puzzles. The first to find a valid block receives the block reward and transaction fees. +
+
+ Reorganization (reorg) + When the network abandons the previous tip and adopts a longer or heavier chain. Some blocks that were considered “confirmed” can become invalid. Small reorgs (e.g. 1–2 blocks) are rare but possible. +
+
+ Transaction (tx) + A signed message that moves <%= config.coinTicker %> from one or more inputs (previous outputs) to one or more outputs (addresses and amounts). Stored in blocks after being confirmed. +
+
+ UTXO + Unspent Transaction Output. The blockchain tracks outputs that have not yet been spent. Your “balance” is the sum of UTXOs that your addresses can spend. +
+
+
+ + +
+
+

Frequently Asked Questions

+
+
+
+

How do I find my transaction?

+

Use the search bar at the top: paste your transaction ID (txid) or address. You can also go to Transactions to browse recent and mempool transactions.

+
+
+

What does “unconfirmed” mean?

+

The transaction is in the mempool but not yet included in a block. After it is mined into a block, it becomes confirmed. More blocks on top of it mean more confirmations.

+
+
+

How is the rich list / balance calculated?

+

The Rich List and address balances are based on the sum of UTXOs (unspent outputs) that belong to each address. They require the indexer to be running.

+
+
+

Where does the hashrate come from?

+

The explorer gets network hashrate from the node’s getmininginfo RPC (networkhashps). It reflects the estimated total hashing power securing the chain.

+
+
+

What are chain tips and reorgs?

+

Chain tips are the ends of known chains (including the main chain and any forks). A reorg happens when the main chain switches to a different tip, undoing some recent blocks. See Chain Tips and Reorgs for details.

+
+
+

Can I use the explorer API?

+

Yes. The explorer proxies blockchain data; see API for endpoints. Typical usage: blocks, transactions, addresses, and mempool.

+
+
+
+
+ +<%- include('partials/footer') %> diff --git a/views/holders.ejs b/views/holders.ejs new file mode 100644 index 0000000..07dce56 --- /dev/null +++ b/views/holders.ejs @@ -0,0 +1,107 @@ +<%- include('partials/header') %> + +
+ + +
+
+

Holder List

+
+ + <% if (total === 0) { %>No holders<% } else { %>Showing <%= (currentPage - 1) * limit + 1 %>-<%= Math.min(currentPage * limit, total) %> of <%= formatNumber(total) %> holders<% } %> + +
+ > + + + Page <%= currentPage %> of <%= totalPages %> + = totalPages ? 'onclick="return false;"' : '' %>> + + +
+
+
+ +
+ + + + + + + + + + + <% if (holders.length === 0) { %> + + + + <% } else { %> + <% holders.forEach(function(holder) { %> + + + + + + + <% }); %> + <% } %> + +
#AddressBalanceLast Seen
+ +

No holders data available. Ensure the indexer is running.

+
<%= holder.position || '--' %> + + <%= formatHash(holder.address, 20) %> + + + + + <%= (holder.balance || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 8 }) %> + <%= config.coinTicker %> + + + <% if (holder.last_seen) { %> +
+ <%= formatTimeAgo(holder.last_seen) %> + <%= formatDate(holder.last_seen) %> +
+ <% } else { %> + -- + <% } %> +
+
+ + <% if (totalPages > 1) { %> + + <% } %> +
+
+ +<%- include('partials/footer') %> diff --git a/views/index.ejs b/views/index.ejs index 35cfca5..0e7df3c 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -137,7 +137,7 @@ + + + + + + <%- include('partials/header', { page: 'supply-flow' }) %> + +
+ + + +
+
+
+ +
+
+
<%= circulatingSupply.toLocaleString() %>
+
Current Circulating Supply
+
+
+ +
+
+ +
+
+
<%= tipHeight.toLocaleString() %>
+
Current Block Height
+
+
+ +
+
+ +
+
+
<%= Math.round(144 * 365.25) %>
+
Blocks per Year
+
+
+ +
+
+ +
+
+
<%= dailySupplyIncrease.toLocaleString() %>
+
Daily Supply Increase
+
+
+
+ + +
+
+

+ + Supply Growth Projection +

+

Historical data combined with future projections

+
+
+ +
+
+ + +
+
+

+ + Monthly Projections +

+

Detailed breakdown of supply growth over the next year

+
+
+
+ + + + + + + + + + + <% projectionData.forEach((row, index) => { %> + + + + + + + <% }); %> + +
DaysDateProjected SupplyMonthly Increase
<%= row.days %><%= row.date %><%= row.supply.toLocaleString() %><%= index > 0 ? (row.supply - projectionData[index-1].supply).toLocaleString() : '0' %>
+
+
+
+
+ + <%- include('partials/footer') %> + + + + \ No newline at end of file diff --git a/views/supply.ejs b/views/supply.ejs new file mode 100644 index 0000000..292dd72 --- /dev/null +++ b/views/supply.ejs @@ -0,0 +1,140 @@ +<%- include('partials/header') %> + +
+ + + +
+
+
+ CURRENT HEIGHT + +
+
<%= formatNumber(currentHeight) %>
+
+ +
+
+ TOTAL SUPPLY (on-chain) + +
+
+ <% if (totalSupply) { %> + <%= formatNumber(Math.round(totalSupply)) %> + <%= config.coinTicker %> + <% } else { %> + N/A + <% } %> +
+
+ +
+
+ CIRCULATING SUPPLY (indexer) + +
+
+ <% if (circulatingSupply) { %> + <%= formatNumber(Math.round(circulatingSupply)) %> + <%= config.coinTicker %> + <% } else { %> + N/A + <% } %> +
+
+ +
+
+ MAX SUPPLY + +
+
+ <%= formatNumber(maxSupply) %> + <%= config.coinTicker %> +
+
+
+ + +
+
+

Supply Progress

+
+
+
+
+ Minted vs Max Supply +
+
+
+ + <% if (mintedPct != null && totalSupply) { %> + <%= mintedPct.toFixed(2) %>% minted + (~<%= formatNumber(Math.round(totalSupply)) %> / <%= formatNumber(maxSupply) %> + <%= config.coinTicker %>) + <% } else { %> + N/A + <% } %> + +
+ +
+ Circulating vs Max Supply +
+
+
+ + <% if (circPct != null && circulatingSupply) { %> + <%= circPct.toFixed(2) %>% circulating + (~<%= formatNumber(Math.round(circulatingSupply)) %> / <%= formatNumber(maxSupply) %> + <%= config.coinTicker %>) + <% } else { %> + N/A + <% } %> + +
+
+
+
+ + +
+
+

Issuance (approximate)

+
+
+
+ Current Block Reward + + <%= currentReward.toFixed(2) %> <%= config.coinTicker %> / block + +
+
+ Blocks per Day + + ~<%= Math.round(blocksPerDay) %> blocks/day + +
+
+ Daily Issuance + + ~<%= formatNumber(Math.round(dailyIssuance)) %> <%= config.coinTicker %> / day + +
+
+ Yearly Issuance + + ~<%= formatNumber(Math.round(yearlyIssuance)) %> <%= config.coinTicker %> / year + +
+
+
+
+ +<%- include('partials/footer') %> + diff --git a/views/transaction.ejs b/views/transaction.ejs index e47c0be..0720495 100644 --- a/views/transaction.ejs +++ b/views/transaction.ejs @@ -81,10 +81,10 @@ -
+
Locktime - <%= tx.locktime %> + <%= tx.locktime %><% if (locktimeInterpretation) { %> (<%= locktimeInterpretation %>)<% } %>
@@ -101,6 +101,19 @@ <%= tx.fee && tx.size ? (tx.fee / tx.size).toFixed(2) : '0' %> sat/B
+ + <% if (typeof vsize === 'number') { %> +
+ Virtual Size + <%= formatNumber(vsize) %> vB +
+ <% } %> + <% if (feeRateSatPerVb) { %> +
+ Fee Rate (sat/vB) + <%= feeRateSatPerVb %> sat/vB +
+ <% } %> @@ -116,10 +129,15 @@
- <% tx.vin.forEach(function(vin, index) { %> -
-
#<%= index %> -
+ <% tx.vin.forEach(function(vin, index) { + var seqNum = vin.sequence != null ? (typeof vin.sequence === 'number' ? vin.sequence : parseInt(vin.sequence, 16)) : null; + var isRBF = seqNum != null && seqNum < 0xfffffffe; + var scriptsigHex = vin.scriptsig != null ? (typeof vin.scriptsig === 'string' ? vin.scriptsig : (vin.scriptsig.hex || '')) : ''; + var scriptsigAsm = vin.scriptsig_asm || ''; + var witness = vin.witness || []; + %> +
+
#<%= index %>
<% if (vin.is_coinbase) { %>
@@ -136,18 +154,48 @@ <% } %>
- <%= vin.prevout && vin.prevout.value ? (vin.prevout.value / - 100000000).toFixed(8) : '0.00000000' %> <%= config.coinTicker %> + <%= vin.prevout && vin.prevout.value ? (vin.prevout.value / 100000000).toFixed(8) : '0.00000000' %> <%= config.coinTicker %>
+ +
+ <% if (seqNum != null) { %> +
+ Sequence + <%= vin.sequence %> <% if (isRBF) { %>RBF<% } %> +
+ <% } %> + <% if (scriptsigAsm) { %> +
+ ScriptSig (asm) + <%= scriptsigAsm %> +
+ <% } %> + <% if (scriptsigHex) { %> +
+ ScriptSig (hex) + <%= scriptsigHex %> +
+ <% } %> + <% if (witness.length > 0) { %> +
+ Witness (<%= witness.length %> stack item(s)) +
+ <% witness.forEach(function(w, i) { %> +
<%= i %> <%= w %>
+ <% }); %> +
+
+ <% } %> +
<% } %>
- <% }); %> + <% }); %>
@@ -165,10 +213,13 @@
- <% tx.vout.forEach(function(vout, index) { %> -
-
#<%= index %> -
+ <% tx.vout.forEach(function(vout, index) { + var spkHex = vout.scriptpubkey != null ? (typeof vout.scriptpubkey === 'string' ? vout.scriptpubkey : (vout.scriptpubkey.hex || '')) : ''; + var spkAsm = vout.scriptpubkey_asm || ''; + var spkType = vout.scriptpubkey_type || 'unknown'; + %> +
+
#<%= index %>
<% if (vout.scriptpubkey_address) { %> @@ -176,20 +227,50 @@ <%= formatHash(vout.scriptpubkey_address, 24) %> <% } else { %> - - <%= vout.scriptpubkey_type || 'Unknown' %> - - <% } %> + <%= spkType %> + <% } %>
<%= vout.value ? (vout.value / 100000000).toFixed(8) : '0.00000000' %> <%= config.coinTicker %>
+ +
+
+ Type + <%= spkType %> +
+ <% if (spkAsm) { %> +
+ ScriptPubKey (asm) + <%= spkAsm %> +
+ <% } %> + <% if (spkHex) { %> +
+ ScriptPubKey (hex) + <%= spkHex %> +
+ <% } %> +
- <% }); %> + <% }); %>
+ + + <% if (rawHex) { %> +
+
+

Raw Transaction

+ +
+
<%= rawHex %>
+
+ <% } %> <%- include('partials/footer') %> \ No newline at end of file