DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
485 lines
18 KiB
JavaScript
485 lines
18 KiB
JavaScript
(function() {
|
|
var C = window.ExplorerCommon;
|
|
|
|
/* ── State ──────────────────────────────────────────────────────────────── */
|
|
var state = {
|
|
txSeries: [], // tx counts per recent block (oldest → newest)
|
|
intervalSeries: [], // block intervals in seconds
|
|
supplyHistory: [], // cumulative tx count (proxy sparkline for supply)
|
|
tpsSeries: [], // same as txSeries, used for strip micro-bar
|
|
};
|
|
|
|
/* ── Address / Block links ──────────────────────────────────────────────── */
|
|
function linkAddress(value, label) {
|
|
return '<a href="/address?address=' + encodeURIComponent(value) + '">' +
|
|
C.esc(label || value) + '</a>';
|
|
}
|
|
|
|
function linkBlock(index) {
|
|
return '<a href="#" class="block-link" data-index="' + C.esc(index) + '">#' +
|
|
C.esc(index) + '</a>';
|
|
}
|
|
|
|
/* ── Micro chart helpers (strip sparklines) ─────────────────────────────── */
|
|
|
|
function resizeMicro(canvas) {
|
|
var dpr = window.devicePixelRatio || 1;
|
|
var w = canvas.offsetWidth || 80;
|
|
var h = canvas.offsetHeight || 36;
|
|
canvas.width = Math.max(1, Math.floor(w * dpr));
|
|
canvas.height = Math.max(1, Math.floor(h * dpr));
|
|
var ctx = canvas.getContext('2d');
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
return { ctx: ctx, w: w, h: h };
|
|
}
|
|
|
|
/** Tiny bar chart for the TPS strip cell */
|
|
function drawMicroBars(canvas, values, color) {
|
|
if (!canvas) return;
|
|
var r = resizeMicro(canvas);
|
|
var ctx = r.ctx; var w = r.w; var h = r.h;
|
|
ctx.clearRect(0, 0, w, h);
|
|
if (!values || !values.length) return;
|
|
var n = Math.min(values.length, 24);
|
|
var vs = values.slice(-n);
|
|
var max = Math.max.apply(null, vs.concat([1]));
|
|
var gap = 2;
|
|
var bw = Math.max(2, (w - gap * (n + 1)) / n);
|
|
ctx.fillStyle = color;
|
|
for (var i = 0; i < vs.length; i++) {
|
|
var bh = Math.max(2, (vs[i] / max) * (h - 4));
|
|
var x = gap + i * (bw + gap);
|
|
var y = h - bh;
|
|
ctx.fillRect(x, y, bw, bh);
|
|
}
|
|
}
|
|
|
|
/** Tiny sparkline with gradient fill for the Supply strip cell */
|
|
function drawSparkline(canvas, values, color) {
|
|
if (!canvas) return;
|
|
var r = resizeMicro(canvas);
|
|
var ctx = r.ctx; var w = r.w; var h = r.h;
|
|
ctx.clearRect(0, 0, w, h);
|
|
if (!values || values.length < 2) {
|
|
// draw a flat line when no data
|
|
ctx.strokeStyle = color + '55';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(2, h / 2);
|
|
ctx.lineTo(w - 2, h / 2);
|
|
ctx.stroke();
|
|
return;
|
|
}
|
|
var n = Math.min(values.length, 24);
|
|
var vs = values.slice(-n);
|
|
var mn = Math.min.apply(null, vs);
|
|
var mx = Math.max.apply(null, vs);
|
|
if (mn === mx) { mn = mn - 1; mx = mx + 1; }
|
|
var span = mx - mn;
|
|
var pad = 3;
|
|
|
|
var pts = [];
|
|
for (var i = 0; i < vs.length; i++) {
|
|
var x = pad + (i / (vs.length - 1)) * (w - pad * 2);
|
|
var y = (h - pad) - ((vs[i] - mn) / span) * (h - pad * 2);
|
|
pts.push([x, y]);
|
|
}
|
|
|
|
// gradient fill
|
|
var grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
grad.addColorStop(0, color + '40');
|
|
grad.addColorStop(1, color + '05');
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0][0], pts[0][1]);
|
|
for (var j = 1; j < pts.length; j++) ctx.lineTo(pts[j][0], pts[j][1]);
|
|
ctx.lineTo(pts[pts.length - 1][0], h);
|
|
ctx.lineTo(pts[0][0], h);
|
|
ctx.closePath();
|
|
ctx.fillStyle = grad;
|
|
ctx.fill();
|
|
|
|
// line
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0][0], pts[0][1]);
|
|
for (var k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.stroke();
|
|
}
|
|
|
|
/* ── Full-size chart helpers (main chart panels) ────────────────────────── */
|
|
|
|
function resizeCanvas(canvas) {
|
|
var dpr = window.devicePixelRatio || 1;
|
|
var w = canvas.clientWidth;
|
|
var h = canvas.clientHeight;
|
|
canvas.width = Math.max(1, Math.floor(w * dpr));
|
|
canvas.height = Math.max(1, Math.floor(h * dpr));
|
|
var ctx = canvas.getContext('2d');
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
return ctx;
|
|
}
|
|
|
|
function chartLayout(width, height) {
|
|
return {
|
|
left: 44,
|
|
right: 10,
|
|
top: 10,
|
|
bottom: 26,
|
|
plotW: Math.max(10, width - 54),
|
|
plotH: Math.max(10, height - 36)
|
|
};
|
|
}
|
|
|
|
function fmtTick(v) {
|
|
if (Math.abs(v) >= 1000) return String(Math.round(v));
|
|
if (Math.abs(v) >= 10) return (Math.round(v * 10) / 10).toFixed(1);
|
|
return (Math.round(v * 100) / 100).toFixed(2);
|
|
}
|
|
|
|
function drawAxes(ctx, w, h, b, minY, maxY) {
|
|
ctx.strokeStyle = '#334155';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(b.left, b.top); ctx.lineTo(b.left, h - b.bottom); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(b.left, h - b.bottom); ctx.lineTo(w - b.right, h - b.bottom); ctx.stroke();
|
|
ctx.fillStyle = '#94a3b8';
|
|
ctx.font = '11px "Inter", sans-serif';
|
|
for (var i = 0; i <= 4; i++) {
|
|
var ratio = i / 4;
|
|
var y = b.top + b.plotH * ratio;
|
|
var val = maxY - (maxY - minY) * ratio;
|
|
ctx.beginPath(); ctx.moveTo(b.left - 4, y); ctx.lineTo(b.left, y); ctx.stroke();
|
|
ctx.fillText(fmtTick(val), 4, y + 3);
|
|
}
|
|
ctx.fillText('oldest', b.left, h - 8);
|
|
ctx.fillText('newest', w - b.right - 36, h - 8);
|
|
}
|
|
|
|
function drawBars(canvas, values, color) {
|
|
if (!canvas) return;
|
|
var ctx = resizeCanvas(canvas);
|
|
var w = canvas.clientWidth; var h = canvas.clientHeight;
|
|
ctx.clearRect(0, 0, w, h);
|
|
var b = chartLayout(w, h);
|
|
var max = Math.max.apply(null, (values || []).concat([1]));
|
|
drawAxes(ctx, w, h, b, 0, max);
|
|
if (!values || !values.length) return;
|
|
var gap = 5;
|
|
var bw = Math.max(2, (b.plotW - gap * (values.length + 1)) / values.length);
|
|
ctx.fillStyle = color;
|
|
for (var i = 0; i < values.length; i++) {
|
|
var bh = max > 0 ? (values[i] / max) * b.plotH : 2;
|
|
var x = b.left + gap + i * (bw + gap);
|
|
var y = b.top + b.plotH - bh;
|
|
ctx.fillRect(x, y, bw, bh);
|
|
}
|
|
}
|
|
|
|
function drawLine(canvas, values, color) {
|
|
if (!canvas) return;
|
|
var ctx = resizeCanvas(canvas);
|
|
var w = canvas.clientWidth; var h = canvas.clientHeight;
|
|
ctx.clearRect(0, 0, w, h);
|
|
var b = chartLayout(w, h);
|
|
if (!values || !values.length) { drawAxes(ctx, w, h, b, 0, 1); return; }
|
|
var mn = Math.min.apply(null, values);
|
|
var mx = Math.max.apply(null, values);
|
|
if (mn === mx) { mn = mn - 1; mx = mx + 1; }
|
|
drawAxes(ctx, w, h, b, mn, mx);
|
|
var span = mx - mn;
|
|
ctx.beginPath();
|
|
for (var i = 0; i < values.length; i++) {
|
|
var x = values.length === 1 ? b.left + b.plotW / 2
|
|
: b.left + (i * b.plotW) / (values.length - 1);
|
|
var ratio = span === 0 ? 0.5 : (values[i] - mn) / span;
|
|
var y = b.top + b.plotH - ratio * b.plotH;
|
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
}
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
}
|
|
|
|
function renderCharts() {
|
|
drawBars(document.getElementById('txChart'), state.txSeries, '#2563eb');
|
|
drawLine(document.getElementById('intervalChart'), state.intervalSeries, '#0f766e');
|
|
}
|
|
|
|
function renderStripCharts() {
|
|
drawSparkline(document.getElementById('supplyChart'), state.supplyHistory, '#7db5ff');
|
|
drawMicroBars(document.getElementById('tpsChart'), state.tpsSeries, '#41c98a');
|
|
}
|
|
|
|
/* ── renderStats ────────────────────────────────────────────────────────── */
|
|
|
|
function renderStats(net, blocks) {
|
|
// Supply strip
|
|
document.getElementById('sSupply').textContent = C.toTokenShort(net.total_supply);
|
|
|
|
// Height + last block time
|
|
document.getElementById('sHeight').textContent = Number(net.total_blocks || 0);
|
|
if (blocks.length) {
|
|
document.getElementById('sLastTime').textContent = C.timeAgo(blocks[0].time);
|
|
} else {
|
|
document.getElementById('sLastTime').textContent = '—';
|
|
}
|
|
|
|
if (!blocks.length) {
|
|
document.getElementById('sTps').textContent = '—';
|
|
document.getElementById('sBlockTime').textContent = '—';
|
|
document.getElementById('sLastBlock').textContent = '—';
|
|
document.getElementById('sLastHash').textContent = '—';
|
|
document.getElementById('sTxs').textContent = net.total_txs || 0;
|
|
document.getElementById('sTransfers').textContent = 'Transfers: ' + (net.total_transfers || 0);
|
|
document.getElementById('sRelayProofs').textContent = net.total_relay_proofs || 0;
|
|
document.getElementById('sRelayCount').textContent = (net.relay_count || 0) + ' relay nodes';
|
|
document.getElementById('sValidators').textContent = net.validator_count || 0;
|
|
document.getElementById('sTpsWindow').textContent = 'no data yet';
|
|
state.txSeries = []; state.intervalSeries = []; state.supplyHistory = []; state.tpsSeries = [];
|
|
renderCharts();
|
|
renderStripCharts();
|
|
return;
|
|
}
|
|
|
|
// Newest block
|
|
var newest = blocks[0];
|
|
document.getElementById('sLastBlock').textContent = '#' + newest.index;
|
|
document.getElementById('sLastHash').textContent = C.short(newest.hash, 24);
|
|
|
|
// TPS over window
|
|
var totalTx = 0;
|
|
for (var i = 0; i < blocks.length; i++) totalTx += Number(blocks[i].tx_count || 0);
|
|
var t0 = new Date(blocks[blocks.length - 1].time).getTime();
|
|
var t1 = new Date(blocks[0].time).getTime();
|
|
var secs = Math.max(1, (t1 - t0) / 1000);
|
|
var tps = (totalTx / secs).toFixed(2);
|
|
document.getElementById('sTps').textContent = tps;
|
|
document.getElementById('sTpsWindow').textContent = 'window: ~' + Math.round(secs) + ' sec';
|
|
|
|
// Avg block interval
|
|
var asc = blocks.slice().reverse();
|
|
var intervals = [];
|
|
for (var j = 1; j < asc.length; j++) {
|
|
var prev = new Date(asc[j - 1].time).getTime();
|
|
var cur = new Date(asc[j].time).getTime();
|
|
intervals.push(Math.max(0, (cur - prev) / 1000));
|
|
}
|
|
var avg = 0;
|
|
if (intervals.length) {
|
|
var sum = 0;
|
|
for (var k = 0; k < intervals.length; k++) sum += intervals[k];
|
|
avg = sum / intervals.length;
|
|
}
|
|
document.getElementById('sBlockTime').textContent = avg ? avg.toFixed(2) + 's' : '—';
|
|
|
|
// Card stats
|
|
document.getElementById('sTxs').textContent = net.total_txs || 0;
|
|
document.getElementById('sTransfers').textContent = 'Transfers: ' + (net.total_transfers || 0);
|
|
document.getElementById('sRelayProofs').textContent = net.total_relay_proofs || 0;
|
|
document.getElementById('sRelayCount').textContent = (net.relay_count || 0) + ' relay nodes';
|
|
document.getElementById('sValidators').textContent = net.validator_count || 0;
|
|
|
|
// Series for charts
|
|
state.txSeries = asc.map(function(b) { return Number(b.tx_count || 0); });
|
|
state.intervalSeries = intervals;
|
|
|
|
// Cumulative tx sparkline (supply proxy — shows chain activity growth)
|
|
var cum = 0;
|
|
state.supplyHistory = asc.map(function(b) { cum += Number(b.tx_count || 0); return cum; });
|
|
state.tpsSeries = state.txSeries;
|
|
|
|
renderCharts();
|
|
renderStripCharts();
|
|
}
|
|
|
|
/* ── renderRecentBlocks ─────────────────────────────────────────────────── */
|
|
|
|
function renderRecentBlocks(blocks) {
|
|
var tbody = document.getElementById('blocksBody');
|
|
if (!tbody) return;
|
|
if (!blocks || !blocks.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="tbl-empty">No blocks yet.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
var rows = '';
|
|
var shown = blocks.slice(0, 20);
|
|
for (var i = 0; i < shown.length; i++) {
|
|
var b = shown[i];
|
|
var val = b.validator || b.proposer || '';
|
|
var valShort = val ? C.shortAddress(val) : '—';
|
|
var valCell = val
|
|
? '<a href="/address?address=' + encodeURIComponent(val) + '" title="' + C.esc(val) + '">' + C.esc(valShort) + '</a>'
|
|
: '—';
|
|
var fees = b.total_fees_ut != null ? C.toToken(Number(b.total_fees_ut)) : '—';
|
|
rows +=
|
|
'<tr>' +
|
|
'<td>' + linkBlock(b.index) + '</td>' +
|
|
'<td class="mono">' + C.esc(C.short(b.hash, 20)) + '</td>' +
|
|
'<td class="mono">' + valCell + '</td>' +
|
|
'<td>' + C.esc(b.tx_count || 0) + '</td>' +
|
|
'<td>' + C.esc(fees) + '</td>' +
|
|
'<td title="' + C.esc(C.fmtTime(b.time)) + '">' + C.esc(C.timeAgo(b.time)) + '</td>' +
|
|
'</tr>';
|
|
}
|
|
tbody.innerHTML = rows;
|
|
C.refreshIcons();
|
|
}
|
|
|
|
/* ── Block detail panel ─────────────────────────────────────────────────── */
|
|
|
|
function showBlockPanel(data) {
|
|
var panel = document.getElementById('blockDetailPanel');
|
|
var raw = document.getElementById('blockRaw');
|
|
if (!panel || !raw) return;
|
|
raw.textContent = JSON.stringify(data, null, 2);
|
|
panel.style.display = '';
|
|
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function hideBlockPanel() {
|
|
var panel = document.getElementById('blockDetailPanel');
|
|
if (panel) panel.style.display = 'none';
|
|
}
|
|
|
|
async function loadBlock(index) {
|
|
C.setStatus('Loading block #' + index + '…', 'warn');
|
|
try {
|
|
var b = await C.fetchJSON('/api/block/' + encodeURIComponent(index));
|
|
showBlockPanel(b);
|
|
C.setStatus('Block #' + index + ' loaded.', 'ok');
|
|
} catch (e) {
|
|
C.setStatus('Block load failed: ' + e.message, 'err');
|
|
}
|
|
}
|
|
|
|
/* ── Main refresh ───────────────────────────────────────────────────────── */
|
|
|
|
async function refreshDashboard() {
|
|
try {
|
|
var data = await Promise.all([
|
|
C.fetchJSON('/api/netstats'),
|
|
C.fetchJSON('/api/blocks?limit=36'),
|
|
]);
|
|
renderStats(data[0] || {}, data[1] || []);
|
|
renderRecentBlocks(data[1] || []);
|
|
C.setStatus('Updated at ' + new Date().toLocaleTimeString(), 'ok');
|
|
C.refreshIcons();
|
|
} catch (e) {
|
|
C.setStatus('Refresh failed: ' + e.message, 'err');
|
|
}
|
|
}
|
|
|
|
/* ── Search ─────────────────────────────────────────────────────────────── */
|
|
|
|
function handleSearch() {
|
|
var raw = (document.getElementById('searchInput').value || '').trim();
|
|
if (!raw) return;
|
|
|
|
// Pure integer → block lookup
|
|
if (/^\d+$/.test(raw)) {
|
|
loadBlock(raw);
|
|
return;
|
|
}
|
|
|
|
// Hex tx id (32 bytes = 64 chars) → tx page
|
|
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
|
C.navAddress(raw);
|
|
return;
|
|
}
|
|
|
|
// Short hex (16 chars) or partial → try as tx id
|
|
if (/^[0-9a-fA-F]{16,63}$/.test(raw)) {
|
|
C.navTx(raw);
|
|
return;
|
|
}
|
|
|
|
// DC address or any remaining string → address page
|
|
C.navAddress(raw);
|
|
}
|
|
|
|
/* ── Event wiring ───────────────────────────────────────────────────────── */
|
|
|
|
var searchBtn = document.getElementById('searchBtn');
|
|
if (searchBtn) searchBtn.addEventListener('click', handleSearch);
|
|
|
|
var openNodeBtn = document.getElementById('openNodeBtn');
|
|
if (openNodeBtn) {
|
|
openNodeBtn.addEventListener('click', function() {
|
|
var raw = (document.getElementById('searchInput').value || '').trim();
|
|
if (raw && C.isPubKey(raw)) {
|
|
window.location.href = '/node?node=' + encodeURIComponent(raw);
|
|
} else {
|
|
window.location.href = '/validators';
|
|
}
|
|
});
|
|
}
|
|
|
|
var searchInput = document.getElementById('searchInput');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') handleSearch();
|
|
});
|
|
}
|
|
|
|
// Block link clicks inside the blocks table
|
|
document.addEventListener('click', function(e) {
|
|
var t = e.target;
|
|
if (!t) return;
|
|
var link = t.closest ? t.closest('a.block-link') : null;
|
|
if (link) {
|
|
e.preventDefault();
|
|
loadBlock(link.dataset.index);
|
|
return;
|
|
}
|
|
});
|
|
|
|
// Redraw on resize (both full-size and strip charts)
|
|
window.addEventListener('resize', function() {
|
|
renderCharts();
|
|
renderStripCharts();
|
|
});
|
|
|
|
/* ── Boot ───────────────────────────────────────────────────────────────── */
|
|
|
|
// Polling fallback — active when SSE is unavailable or disconnected.
|
|
var pollTimer = null;
|
|
|
|
function startPolling() {
|
|
if (pollTimer) return;
|
|
pollTimer = setInterval(refreshDashboard, 10000);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
}
|
|
|
|
var bootPromise = refreshDashboard();
|
|
|
|
// SSE live feed — stop polling while connected; resume on disconnect.
|
|
var sseConn = C.connectSSE({
|
|
connected: function() {
|
|
stopPolling();
|
|
C.setStatus('● LIVE', 'ok');
|
|
},
|
|
block: function(/*ev*/) {
|
|
// A new block was committed — refresh everything immediately.
|
|
refreshDashboard();
|
|
},
|
|
error: function() {
|
|
startPolling();
|
|
C.setStatus('Offline — polling every 10s', 'warn');
|
|
}
|
|
});
|
|
|
|
// If SSE is not supported by the browser, fall back to polling immediately.
|
|
if (!sseConn) startPolling();
|
|
|
|
// Handle /?block=N — navigated here from tx page block link
|
|
var blockParam = C.q('block');
|
|
if (blockParam) {
|
|
bootPromise.then(function() { loadBlock(blockParam); });
|
|
}
|
|
|
|
})();
|