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
335 lines
13 KiB
JavaScript
335 lines
13 KiB
JavaScript
(function() {
|
|
var C = window.ExplorerCommon;
|
|
|
|
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
|
|
|
function linkAddress(pubkey, addrDisplay) {
|
|
var label = addrDisplay || C.shortAddress(pubkey);
|
|
return '<a href="/address?address=' + encodeURIComponent(pubkey) + '">' + C.esc(label) + '</a>';
|
|
}
|
|
|
|
function txTypeIcon(type) {
|
|
var map = {
|
|
TRANSFER: 'arrow-left-right',
|
|
REGISTER_KEY: 'user-check',
|
|
RELAY_PROOF: 'zap',
|
|
HEARTBEAT: 'activity',
|
|
BLOCK_REWARD: 'layers-3',
|
|
BIND_WALLET: 'link',
|
|
REGISTER_RELAY: 'radio',
|
|
SLASH: 'alert-triangle',
|
|
ADD_VALIDATOR: 'shield-plus',
|
|
REMOVE_VALIDATOR: 'shield-minus',
|
|
};
|
|
return map[type] || 'circle';
|
|
}
|
|
|
|
// Color class for the banner icon based on tx type
|
|
function txBannerClass(type) {
|
|
var pos = { BLOCK_REWARD: 1, RELAY_PROOF: 1, HEARTBEAT: 1, REGISTER_KEY: 1, REGISTER_RELAY: 1 };
|
|
var warn = { SLASH: 1, REMOVE_VALIDATOR: 1 };
|
|
if (warn[type]) return 'tx-banner-warn';
|
|
if (pos[type]) return 'tx-banner-ok';
|
|
return 'tx-banner-ok'; // default — all confirmed txs are "ok"
|
|
}
|
|
|
|
// One-line human description of the tx
|
|
function txDescription(tx) {
|
|
var fromAddr = tx.from_addr ? C.shortAddress(tx.from_addr) : (tx.from ? C.shortAddress(tx.from) : '?');
|
|
var toAddr = tx.to_addr ? C.shortAddress(tx.to_addr) : (tx.to ? C.shortAddress(tx.to) : '');
|
|
var amt = tx.amount ? tx.amount : C.toToken(tx.amount_ut || 0);
|
|
|
|
switch (String(tx.type)) {
|
|
case 'TRANSFER':
|
|
return fromAddr + ' sent ' + amt + (toAddr ? ' to ' + toAddr : '');
|
|
case 'BLOCK_REWARD':
|
|
return 'Block #' + tx.block_index + ' fee reward' + (toAddr ? ' to ' + toAddr : '');
|
|
case 'RELAY_PROOF':
|
|
return fromAddr + ' submitted relay proof, earned ' + amt;
|
|
case 'HEARTBEAT':
|
|
return fromAddr + ' submitted heartbeat';
|
|
case 'REGISTER_KEY': {
|
|
var nick = '';
|
|
if (tx.payload && tx.payload.nickname) nick = ' as "' + tx.payload.nickname + '"';
|
|
return fromAddr + ' registered identity' + nick;
|
|
}
|
|
case 'REGISTER_RELAY':
|
|
return fromAddr + ' registered as relay node';
|
|
case 'BIND_WALLET':
|
|
return fromAddr + ' bound wallet' + (toAddr ? ' → ' + toAddr : '');
|
|
case 'ADD_VALIDATOR':
|
|
return fromAddr + ' added ' + (toAddr || '?') + ' to validator set';
|
|
case 'REMOVE_VALIDATOR':
|
|
return fromAddr + ' removed ' + (toAddr || '?') + ' from validator set';
|
|
case 'SLASH':
|
|
return fromAddr + ' slashed ' + (toAddr || '?');
|
|
default:
|
|
return C.txLabel(tx.type) + (fromAddr ? ' by ' + fromAddr : '');
|
|
}
|
|
}
|
|
|
|
/* ── Flow diagram builder ─────────────────────────────────────────────── */
|
|
|
|
function buildFlowDiagram(tx) {
|
|
var fromPub = tx.from || '';
|
|
var fromAddr = tx.from_addr || (fromPub ? C.shortAddress(fromPub) : '');
|
|
var toPub = tx.to || '';
|
|
var toAddr = tx.to_addr || (toPub ? C.shortAddress(toPub) : '');
|
|
var amt = tx.amount ? tx.amount : (tx.amount_ut ? C.toToken(tx.amount_ut) : '');
|
|
var memo = tx.memo || '';
|
|
|
|
// Nodes
|
|
var fromNode = '';
|
|
var toNode = '';
|
|
|
|
if (fromPub) {
|
|
fromNode =
|
|
'<div class="tx-flow-node">' +
|
|
'<div class="tx-flow-bubble tx-flow-from">' +
|
|
(fromAddr ? fromAddr[0].toUpperCase() : '?') +
|
|
'</div>' +
|
|
'<div class="tx-flow-node-label">' +
|
|
(fromPub ? '<a href="/address?address=' + encodeURIComponent(fromPub) + '">' + C.esc(fromAddr || C.shortAddress(fromPub)) + '</a>' : '—') +
|
|
'</div>' +
|
|
'<div class="tx-flow-node-sub">Sender</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
if (toPub) {
|
|
toNode =
|
|
'<div class="tx-flow-node">' +
|
|
'<div class="tx-flow-bubble tx-flow-to">' +
|
|
(toAddr ? toAddr[0].toUpperCase() : '?') +
|
|
'</div>' +
|
|
'<div class="tx-flow-node-label">' +
|
|
'<a href="/address?address=' + encodeURIComponent(toPub) + '">' + C.esc(toAddr || C.shortAddress(toPub)) + '</a>' +
|
|
'</div>' +
|
|
'<div class="tx-flow-node-sub">Recipient</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
// No route for system txs with no To
|
|
if (!fromPub && !toPub) return '';
|
|
|
|
var arrowLabel = amt || C.txLabel(tx.type);
|
|
var arrowSub = memo || '';
|
|
|
|
var arrow =
|
|
'<div class="tx-flow-arrow">' +
|
|
'<div class="tx-flow-arrow-label">' + C.esc(arrowLabel) + '</div>' +
|
|
'<div class="tx-flow-arrow-line">' +
|
|
'<div class="tx-flow-arrow-track"></div>' +
|
|
'<i data-lucide="chevron-right" class="tx-flow-arrow-tip"></i>' +
|
|
'</div>' +
|
|
(arrowSub ? '<div class="tx-flow-arrow-sub">' + C.esc(arrowSub) + '</div>' : '') +
|
|
'</div>';
|
|
|
|
if (fromNode && toNode) {
|
|
return fromNode + arrow + toNode;
|
|
}
|
|
if (fromNode) return fromNode;
|
|
return toNode;
|
|
}
|
|
|
|
/* ── Main render ─────────────────────────────────────────────────────────── */
|
|
|
|
function renderTx(tx) {
|
|
// Page title
|
|
document.title = C.txLabel(tx.type) + ' ' + C.short(tx.id, 12) + ' | DChain Explorer';
|
|
|
|
// ── Banner
|
|
var bannerIcon = document.getElementById('txBannerIcon');
|
|
bannerIcon.innerHTML = '<i data-lucide="' + txTypeIcon(tx.type) + '"></i>';
|
|
bannerIcon.className = 'tx-banner-icon ' + txBannerClass(tx.type);
|
|
document.getElementById('txBannerTitle').textContent = 'Confirmed · ' + C.txLabel(tx.type);
|
|
document.getElementById('txBannerDesc').textContent = txDescription(tx);
|
|
document.getElementById('txBannerTime').textContent =
|
|
C.fmtTime(tx.time) + ' (' + C.timeAgo(tx.time) + ')';
|
|
|
|
// ── Overview action table row
|
|
var fromAddr = tx.from_addr || (tx.from ? C.shortAddress(tx.from) : '—');
|
|
var toAddr = tx.to_addr || (tx.to ? C.shortAddress(tx.to) : '');
|
|
var routeHtml = tx.from
|
|
? ('<span class="tx-route-item">' + (tx.from ? linkAddress(tx.from, fromAddr) : '—') + '</span>' +
|
|
(tx.to ? '<i data-lucide="arrow-right" class="tx-route-arrow"></i>' +
|
|
'<span class="tx-route-item">' + linkAddress(tx.to, toAddr) + '</span>' : ''))
|
|
: '—';
|
|
var payloadNote = tx.memo || (tx.payload && tx.payload.nickname ? 'nickname: ' + tx.payload.nickname : '') || '—';
|
|
var amtHtml = tx.amount_ut
|
|
? '<span class="tx-overview-amt">' + C.esc(tx.amount || C.toToken(tx.amount_ut)) + '</span>'
|
|
: '<span class="text-muted">—</span>';
|
|
|
|
document.getElementById('txActionRow').innerHTML =
|
|
'<div class="tx-action-cell-action">' +
|
|
'<i data-lucide="' + txTypeIcon(tx.type) + '" class="tx-action-icon"></i>' +
|
|
'<span>' + C.esc(C.txLabel(tx.type)) + '</span>' +
|
|
'</div>' +
|
|
'<div class="tx-action-cell-route tx-route">' + routeHtml + '</div>' +
|
|
'<div class="tx-action-cell-payload">' + C.esc(payloadNote) + '</div>' +
|
|
'<div class="tx-action-cell-value">' + amtHtml + '</div>';
|
|
|
|
// ── Flow diagram
|
|
var flowHtml = buildFlowDiagram(tx);
|
|
var flowEl = document.getElementById('txFlow');
|
|
if (flowHtml) {
|
|
flowEl.innerHTML = flowHtml;
|
|
flowEl.style.display = '';
|
|
} else {
|
|
flowEl.style.display = 'none';
|
|
}
|
|
|
|
// ── Details panel
|
|
// TX ID
|
|
document.getElementById('txId').textContent = tx.id || '—';
|
|
|
|
// Type with badge
|
|
document.getElementById('txType').innerHTML =
|
|
'<span class="tx-type-badge">' + C.esc(tx.type || '—') + '</span>' +
|
|
' <span class="text-muted">— ' + C.esc(C.txLabel(tx.type)) + '</span>';
|
|
|
|
// Memo
|
|
if (tx.memo) {
|
|
document.getElementById('txMemo').textContent = tx.memo;
|
|
document.getElementById('txMemoRow').style.display = '';
|
|
} else {
|
|
document.getElementById('txMemoRow').style.display = 'none';
|
|
}
|
|
|
|
// From
|
|
if (tx.from) {
|
|
document.getElementById('txFrom').innerHTML = linkAddress(tx.from, tx.from_addr || tx.from);
|
|
document.getElementById('txFromRaw').textContent = tx.from;
|
|
} else {
|
|
document.getElementById('txFrom').textContent = '—';
|
|
}
|
|
|
|
// To
|
|
if (tx.to) {
|
|
document.getElementById('txTo').innerHTML = linkAddress(tx.to, tx.to_addr || tx.to);
|
|
document.getElementById('txToRaw').textContent = tx.to;
|
|
document.getElementById('txToRow').style.display = '';
|
|
} else {
|
|
document.getElementById('txToRow').style.display = 'none';
|
|
}
|
|
|
|
// Amount
|
|
var amountText = tx.amount || C.toToken(tx.amount_ut || 0);
|
|
document.getElementById('txAmount').textContent = amountText;
|
|
document.getElementById('txAmount').className = 'tx-amount-val' +
|
|
(tx.amount_ut > 0 ? ' pos' : '');
|
|
|
|
// Fee
|
|
document.getElementById('txFee').textContent = tx.fee || C.toToken(tx.fee_ut || 0);
|
|
|
|
// Block
|
|
var blockLink = document.getElementById('txBlockLink');
|
|
if (tx.block_index !== undefined) {
|
|
blockLink.textContent = '#' + tx.block_index;
|
|
blockLink.href = '#';
|
|
blockLink.onclick = function(e) { e.preventDefault(); window.location.href = '/?block=' + tx.block_index; };
|
|
} else {
|
|
blockLink.textContent = '—';
|
|
}
|
|
document.getElementById('txBlockHash').textContent = tx.block_hash
|
|
? ' ' + C.short(tx.block_hash, 20)
|
|
: '';
|
|
|
|
// Time
|
|
document.getElementById('txTime').textContent = C.fmtTime(tx.time);
|
|
document.getElementById('txTimeAgo').textContent = tx.time ? '(' + C.timeAgo(tx.time) + ')' : '';
|
|
|
|
// Signature
|
|
if (tx.signature_hex) {
|
|
document.getElementById('txSig').textContent = tx.signature_hex;
|
|
document.getElementById('txSigRow').style.display = '';
|
|
} else {
|
|
document.getElementById('txSigRow').style.display = 'none';
|
|
}
|
|
|
|
// ── Payload panel (only for rich payloads)
|
|
var shouldShowPayload = tx.payload &&
|
|
typeof tx.payload === 'object' &&
|
|
tx.type !== 'TRANSFER'; // memo already shown for transfers
|
|
if (shouldShowPayload) {
|
|
document.getElementById('txPayloadPre').textContent = JSON.stringify(tx.payload, null, 2);
|
|
document.getElementById('txPayloadPanel').style.display = '';
|
|
} else {
|
|
document.getElementById('txPayloadPanel').style.display = 'none';
|
|
}
|
|
|
|
// ── Raw JSON
|
|
document.getElementById('txRaw').textContent = JSON.stringify(tx, null, 2);
|
|
|
|
// Show content
|
|
document.getElementById('mainContent').style.display = '';
|
|
C.refreshIcons();
|
|
}
|
|
|
|
/* ── Load ────────────────────────────────────────────────────────────────── */
|
|
|
|
async function loadTx(id) {
|
|
if (!id) return;
|
|
C.setStatus('Loading…', 'warn');
|
|
document.getElementById('mainContent').style.display = 'none';
|
|
try {
|
|
var tx = await C.fetchJSON('/api/tx/' + encodeURIComponent(id));
|
|
renderTx(tx);
|
|
window.history.replaceState({}, '', '/tx?id=' + encodeURIComponent(id));
|
|
C.setStatus('', '');
|
|
} catch (e) {
|
|
C.setStatus('Load failed: ' + e.message, 'err');
|
|
}
|
|
}
|
|
|
|
/* ── Tab switching ───────────────────────────────────────────────────────── */
|
|
|
|
function switchTab(active) {
|
|
var tabs = ['Overview', 'Raw'];
|
|
tabs.forEach(function(name) {
|
|
var btn = document.getElementById('tab' + name);
|
|
var pane = document.getElementById('pane' + name);
|
|
if (!btn || !pane) return;
|
|
var isActive = (name === active);
|
|
btn.className = 'tx-tab' + (isActive ? ' tx-tab-active' : '');
|
|
pane.style.display = isActive ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
document.getElementById('tabOverview').addEventListener('click', function() { switchTab('Overview'); });
|
|
document.getElementById('tabRaw').addEventListener('click', function() { switchTab('Raw'); });
|
|
|
|
/* ── Copy buttons ────────────────────────────────────────────────────────── */
|
|
|
|
document.addEventListener('click', function(e) {
|
|
var t = e.target;
|
|
if (!t) return;
|
|
var btn = t.closest ? t.closest('.copy-btn') : null;
|
|
if (btn) {
|
|
var src = document.getElementById(btn.dataset.copyId);
|
|
if (src) {
|
|
navigator.clipboard.writeText(src.textContent || '').catch(function() {});
|
|
btn.classList.add('copy-btn-done');
|
|
setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200);
|
|
}
|
|
}
|
|
});
|
|
|
|
/* ── Wiring ──────────────────────────────────────────────────────────────── */
|
|
|
|
document.getElementById('txBtn').addEventListener('click', function() {
|
|
var val = (document.getElementById('txInput').value || '').trim();
|
|
if (val) loadTx(val);
|
|
});
|
|
|
|
document.getElementById('txInput').addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') document.getElementById('txBtn').click();
|
|
});
|
|
|
|
var initial = C.q('id');
|
|
if (initial) {
|
|
document.getElementById('txInput').value = initial;
|
|
loadTx(initial);
|
|
}
|
|
|
|
})();
|