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
415 lines
18 KiB
JavaScript
415 lines
18 KiB
JavaScript
(function() {
|
||
var C = window.ExplorerCommon;
|
||
|
||
var state = {
|
||
currentAddress: '',
|
||
currentPubKey: '',
|
||
nextOffset: 0,
|
||
hasMore: false,
|
||
limit: 50
|
||
};
|
||
|
||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||
|
||
function linkAddress(value, label) {
|
||
return '<a href="/address?address=' + encodeURIComponent(value) + '">' +
|
||
C.esc(label || C.shortAddress(value)) + '</a>';
|
||
}
|
||
|
||
function direction(tx, pubKey) {
|
||
if (tx.type === 'BLOCK_REWARD' || tx.type === 'HEARTBEAT') return { cls: 'recv', arrow: '↓' };
|
||
if (tx.type === 'RELAY_PROOF') return { cls: 'recv', arrow: '↓' };
|
||
if (tx.from === pubKey && (!tx.to || tx.to !== pubKey)) return { cls: 'sent', arrow: '↑' };
|
||
if (tx.to === pubKey && tx.from !== pubKey) return { cls: 'recv', arrow: '↓' };
|
||
return { cls: 'neutral', arrow: '·' };
|
||
}
|
||
|
||
/* ── Badge builder ───────────────────────────────────────────────────────── */
|
||
|
||
function badge(text, variant) {
|
||
return '<span class="addr-badge addr-badge-' + variant + '">' + C.esc(text) + '</span>';
|
||
}
|
||
|
||
/* ── Profile render ──────────────────────────────────────────────────────── */
|
||
|
||
function renderProfile(addrData, identData, nodeData) {
|
||
var pubKey = addrData.pub_key || '';
|
||
var address = addrData.address || '';
|
||
|
||
state.currentPubKey = pubKey;
|
||
|
||
// Avatar initials (first letter of nickname or first char of address)
|
||
var name = (identData && identData.nickname) || '';
|
||
var avatarChar = name ? name[0].toUpperCase() : (address ? address[2] || '◆' : '◆');
|
||
document.getElementById('addrAvatar').textContent = avatarChar;
|
||
|
||
// Name
|
||
document.getElementById('addrNickname').textContent = name || 'Unknown Wallet';
|
||
|
||
// Badges
|
||
var badges = '';
|
||
if (identData && identData.registered) badges += badge('Registered', 'ok');
|
||
if (nodeData && nodeData.blocks_produced > 0) badges += badge('Validator', 'accent');
|
||
if (nodeData && nodeData.relay_proofs > 0) badges += badge('Relay Node', 'relay');
|
||
document.getElementById('addrBadges').innerHTML = badges || badge('Unregistered', 'muted');
|
||
|
||
// Balance
|
||
document.getElementById('walletBalance').textContent = addrData.balance || C.toToken(addrData.balance_ut);
|
||
document.getElementById('walletBalanceSub').textContent = addrData.balance_ut != null
|
||
? addrData.balance_ut + ' µT' : '';
|
||
|
||
// Address field
|
||
document.getElementById('walletAddress').textContent = address;
|
||
document.getElementById('walletAddress').title = address;
|
||
|
||
// Public key field
|
||
document.getElementById('walletPubKey').textContent = pubKey;
|
||
document.getElementById('walletPubKey').title = pubKey;
|
||
|
||
// X25519 key
|
||
if (identData && identData.x25519_pub) {
|
||
document.getElementById('walletX25519').textContent = identData.x25519_pub;
|
||
document.getElementById('walletX25519').title = identData.x25519_pub;
|
||
document.getElementById('x25519Row').style.display = '';
|
||
} else {
|
||
document.getElementById('x25519Row').style.display = 'none';
|
||
}
|
||
|
||
// Node page link (if address is a node itself)
|
||
if (nodeData && (nodeData.blocks_produced > 0 || nodeData.relay_proofs > 0 || nodeData.heartbeats > 0)) {
|
||
var nodeLink = document.getElementById('nodePageLink');
|
||
nodeLink.href = '/node?node=' + encodeURIComponent(pubKey);
|
||
document.getElementById('nodeLinkRow').style.display = '';
|
||
} else {
|
||
document.getElementById('nodeLinkRow').style.display = 'none';
|
||
}
|
||
|
||
// Bound wallet (only for pure node addresses)
|
||
if (nodeData && nodeData.wallet_binding_address && nodeData.wallet_binding_address !== address) {
|
||
var wbl = document.getElementById('walletBindingLink');
|
||
wbl.href = '/address?address=' + encodeURIComponent(nodeData.wallet_binding_address);
|
||
wbl.textContent = nodeData.wallet_binding_address;
|
||
document.getElementById('walletBindingRow').style.display = '';
|
||
} else {
|
||
document.getElementById('walletBindingRow').style.display = 'none';
|
||
}
|
||
|
||
// Node stats panel
|
||
if (nodeData && (nodeData.blocks_produced > 0 || nodeData.relay_proofs > 0 || nodeData.heartbeats > 0)) {
|
||
document.getElementById('nodeBlocks').textContent = nodeData.blocks_produced || 0;
|
||
document.getElementById('nodeRelayProofs').textContent = nodeData.relay_proofs || 0;
|
||
document.getElementById('nodeRepScore').textContent = nodeData.reputation_score || 0;
|
||
document.getElementById('nodeRepRank').textContent = nodeData.reputation_rank || '—';
|
||
document.getElementById('nodeHeartbeats').textContent = nodeData.heartbeats || 0;
|
||
document.getElementById('nodeSlashes').textContent = nodeData.slash_count || 0;
|
||
document.getElementById('nodeRecentRewards').textContent = nodeData.recent_rewards || '—';
|
||
var window_ = nodeData.recent_window_blocks || 0;
|
||
var produced = nodeData.recent_blocks_produced || 0;
|
||
document.getElementById('nodeRecentWindow').textContent =
|
||
'last ' + window_ + ' blocks, produced ' + produced;
|
||
document.getElementById('nodeStatsPanel').style.display = '';
|
||
} else {
|
||
document.getElementById('nodeStatsPanel').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/* ── Transaction row builder ─────────────────────────────────────────────── */
|
||
|
||
function txTypeIcon(type) {
|
||
var icons = {
|
||
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 icons[type] || 'circle';
|
||
}
|
||
|
||
function appendTxRows(txs) {
|
||
var body = document.getElementById('walletTxBody');
|
||
for (var i = 0; i < txs.length; i++) {
|
||
var tx = txs[i];
|
||
var dir = direction(tx, state.currentPubKey);
|
||
|
||
var fromAddr = tx.from_addr || (tx.from ? C.shortAddress(tx.from) : '—');
|
||
var toAddr = tx.to_addr || (tx.to ? C.shortAddress(tx.to) : '—');
|
||
var fromCell = tx.from ? linkAddress(tx.from, tx.from_addr || C.shortAddress(tx.from)) : '<span class="text-muted">—</span>';
|
||
var toCell = tx.to ? linkAddress(tx.to, tx.to_addr || C.shortAddress(tx.to)) : '<span class="text-muted">—</span>';
|
||
|
||
var amount = Number(tx.amount_ut || 0);
|
||
var amountStr = amount > 0 ? C.toToken(amount) : '—';
|
||
var amountCls = dir.cls === 'recv' ? 'pos' : (dir.cls === 'sent' ? 'neg' : 'neutral');
|
||
var amountSign = dir.cls === 'recv' ? '+' : (dir.cls === 'sent' ? '−' : '');
|
||
|
||
var memo = tx.memo ? C.esc(tx.memo) : '';
|
||
var blockRef = tx.block_index != null
|
||
? '<span class="tx-block-ref">block #' + tx.block_index + '</span>'
|
||
: '';
|
||
|
||
var tr = document.createElement('tr');
|
||
tr.className = 'tx-row';
|
||
tr.dataset.txid = tx.id || '';
|
||
tr.innerHTML =
|
||
'<td title="' + C.esc(C.fmtTime(tx.time)) + '">' +
|
||
'<div class="tx-time">' + C.esc(C.timeAgo(tx.time)) + '</div>' +
|
||
'</td>' +
|
||
'<td>' +
|
||
'<div class="tx-type-cell">' +
|
||
'<span class="tx-type-icon tx-icon-' + C.esc(dir.cls) + '">' +
|
||
'<i data-lucide="' + txTypeIcon(tx.type) + '"></i>' +
|
||
'</span>' +
|
||
'<div>' +
|
||
'<div class="tx-type-name">' + C.esc(C.txLabel(tx.type)) + '</div>' +
|
||
'<div class="tx-type-raw">' + C.esc(tx.type || '') + '</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</td>' +
|
||
'<td>' +
|
||
'<div class="tx-route">' +
|
||
'<span class="tx-route-item">' + fromCell + '</span>' +
|
||
'<i data-lucide="arrow-right" class="tx-route-arrow"></i>' +
|
||
'<span class="tx-route-item">' + toCell + '</span>' +
|
||
'</div>' +
|
||
'</td>' +
|
||
'<td>' +
|
||
(memo ? '<div class="tx-memo-block">' + memo + '</div>' : '') +
|
||
blockRef +
|
||
'</td>' +
|
||
'<td style="text-align:right">' +
|
||
'<div class="tx-amount ' + amountCls + '">' + amountSign + C.esc(amountStr) + '</div>' +
|
||
(tx.fee_ut ? '<div class="tx-fee">fee ' + C.esc(C.toToken(tx.fee_ut)) + '</div>' : '') +
|
||
'</td>';
|
||
body.appendChild(tr);
|
||
}
|
||
C.refreshIcons();
|
||
}
|
||
|
||
/* ── Load wallet ─────────────────────────────────────────────────────────── */
|
||
|
||
function showEmpty() {
|
||
var es = document.getElementById('emptyState');
|
||
var mc = document.getElementById('mainContent');
|
||
if (es) es.style.display = '';
|
||
if (mc) mc.style.display = 'none';
|
||
}
|
||
|
||
function showError(msg) {
|
||
var es = document.getElementById('emptyState');
|
||
var mc = document.getElementById('mainContent');
|
||
var eb = document.getElementById('errorBanner');
|
||
var em = document.getElementById('errorMsg');
|
||
var ps = document.getElementById('profileSection');
|
||
if (es) es.style.display = 'none';
|
||
if (eb) { eb.style.display = ''; if (em) em.textContent = msg; }
|
||
if (ps) ps.style.display = 'none';
|
||
if (mc) mc.style.display = '';
|
||
C.refreshIcons();
|
||
}
|
||
|
||
async function loadWallet(address) {
|
||
if (!address) return;
|
||
state.currentAddress = address;
|
||
state.nextOffset = 0;
|
||
state.hasMore = false;
|
||
|
||
// Hide empty state, hide main, show loading
|
||
var es = document.getElementById('emptyState');
|
||
if (es) es.style.display = 'none';
|
||
document.getElementById('mainContent').style.display = 'none';
|
||
document.getElementById('errorBanner').style.display = 'none';
|
||
document.getElementById('profileSection').style.display = '';
|
||
document.getElementById('walletTxBody').innerHTML = '';
|
||
document.getElementById('walletTxCount').textContent = '';
|
||
C.setStatus('Loading…', 'warn');
|
||
|
||
try {
|
||
// Parallel fetch: address data + identity + node info
|
||
var results = await Promise.all([
|
||
C.fetchJSON('/api/address/' + encodeURIComponent(address) +
|
||
'?limit=' + state.limit + '&offset=0'),
|
||
C.fetchJSON('/api/identity/' + encodeURIComponent(address)).catch(function() { return null; }),
|
||
C.fetchJSON('/api/node/' + encodeURIComponent(address)).catch(function() { return null; }),
|
||
C.fetchJSON('/api/tokens').catch(function() { return null; }),
|
||
C.fetchJSON('/api/nfts/owner/' + encodeURIComponent(address)).catch(function() { return null; }),
|
||
]);
|
||
var addrData = results[0];
|
||
var identData = results[1];
|
||
var nodeData = results[2];
|
||
var tokensAll = results[3];
|
||
var nftsOwned = results[4];
|
||
|
||
renderProfile(addrData, identData, nodeData);
|
||
|
||
// ── Token balances ──────────────────────────────────────────────────
|
||
var pubKey = addrData.pub_key || address;
|
||
var allTokens = (tokensAll && Array.isArray(tokensAll.tokens)) ? tokensAll.tokens : [];
|
||
if (allTokens.length) {
|
||
var balFetches = allTokens.map(function(t) {
|
||
return C.fetchJSON('/api/tokens/' + t.token_id + '/balance/' + encodeURIComponent(pubKey))
|
||
.then(function(b) { return { token: t, balance: b && b.balance != null ? b.balance : 0 }; })
|
||
.catch(function() { return { token: t, balance: 0 }; });
|
||
});
|
||
var balResults = await Promise.all(balFetches);
|
||
var nonZero = balResults.filter(function(r) { return r.balance > 0; });
|
||
if (nonZero.length) {
|
||
var rows = '';
|
||
nonZero.forEach(function(r) {
|
||
var t = r.token;
|
||
var d = t.decimals || 0;
|
||
var sup = r.balance;
|
||
var whole = d > 0 ? Math.floor(sup / Math.pow(10, d)) : sup;
|
||
var frac = d > 0 ? sup % Math.pow(10, d) : 0;
|
||
var balFmt = whole.toLocaleString() +
|
||
(frac > 0 ? '.' + String(frac).padStart(d, '0').replace(/0+$/, '') : '');
|
||
rows += '<tr>' +
|
||
'<td><a href="/token?id=' + encodeURIComponent(t.token_id) + '" class="token-sym">' + C.esc(t.symbol) + '</a></td>' +
|
||
'<td>' + C.esc(t.name) + '</td>' +
|
||
'<td style="text-align:right" class="mono">' + C.esc(balFmt) + '</td>' +
|
||
'<td><a href="/token?id=' + encodeURIComponent(t.token_id) + '" class="pill-link" style="font-size:12px;padding:4px 10px">Details</a></td>' +
|
||
'</tr>';
|
||
});
|
||
document.getElementById('tokenBalBody').innerHTML = rows;
|
||
document.getElementById('tokenBalPanel').style.display = '';
|
||
}
|
||
}
|
||
|
||
// ── NFTs owned ──────────────────────────────────────────────────────
|
||
var ownedNFTs = (nftsOwned && Array.isArray(nftsOwned.nfts)) ? nftsOwned.nfts : [];
|
||
if (ownedNFTs.length) {
|
||
document.getElementById('nftOwnedCount').textContent = ownedNFTs.length + ' NFT' + (ownedNFTs.length !== 1 ? 's' : '');
|
||
var cards = '';
|
||
ownedNFTs.forEach(function(n) {
|
||
var imgHtml = (n.uri && /\.(png|jpg|jpeg|gif|svg|webp)/i.test(n.uri))
|
||
? '<img class="nft-card-img" src="' + C.esc(n.uri) + '" alt="" loading="lazy">'
|
||
: '<div class="nft-card-img nft-card-placeholder"><i data-lucide="image"></i></div>';
|
||
cards += '<a class="nft-card" href="/token?nft=' + encodeURIComponent(n.nft_id) + '">' +
|
||
imgHtml +
|
||
'<div class="nft-card-body">' +
|
||
'<div class="nft-card-name">' + C.esc(n.name) + '</div>' +
|
||
'<div class="nft-card-id mono">' + C.short(n.nft_id, 16) + '</div>' +
|
||
'</div>' +
|
||
'</a>';
|
||
});
|
||
document.getElementById('addrNFTGrid').innerHTML = cards;
|
||
document.getElementById('nftPanel').style.display = '';
|
||
}
|
||
|
||
var txs = addrData.transactions || [];
|
||
if (!txs.length) {
|
||
document.getElementById('walletTxBody').innerHTML =
|
||
'<tr><td colspan="5" class="tbl-empty">No transactions for this wallet.</td></tr>';
|
||
} else {
|
||
appendTxRows(txs);
|
||
}
|
||
|
||
state.nextOffset = addrData.next_offset || txs.length;
|
||
state.hasMore = !!addrData.has_more;
|
||
setLoadMoreVisible(state.hasMore);
|
||
|
||
var countText = txs.length + (state.hasMore ? '+' : '') + ' transaction' +
|
||
(txs.length !== 1 ? 's' : '');
|
||
document.getElementById('walletTxCount').textContent = countText;
|
||
|
||
// Update page title and URL
|
||
var displayName = (identData && identData.nickname) || addrData.address || address;
|
||
document.title = displayName + ' | DChain Explorer';
|
||
window.history.replaceState({}, '',
|
||
'/address?address=' + encodeURIComponent(addrData.pub_key || address));
|
||
|
||
document.getElementById('mainContent').style.display = '';
|
||
C.setStatus('', '');
|
||
C.refreshIcons();
|
||
|
||
} catch (e) {
|
||
showError(e.message || 'Unknown error');
|
||
C.setStatus('', '');
|
||
}
|
||
}
|
||
|
||
async function loadMore() {
|
||
if (!state.currentAddress || !state.hasMore) return;
|
||
C.setStatus('Loading more…', 'warn');
|
||
setLoadMoreVisible(false);
|
||
try {
|
||
var data = await C.fetchJSON('/api/address/' + encodeURIComponent(state.currentAddress) +
|
||
'?limit=' + state.limit + '&offset=' + state.nextOffset);
|
||
var txs = data.transactions || [];
|
||
appendTxRows(txs);
|
||
state.nextOffset = data.next_offset || (state.nextOffset + txs.length);
|
||
state.hasMore = !!data.has_more;
|
||
|
||
var prev = parseInt((document.getElementById('walletTxCount').textContent || '0'), 10) || 0;
|
||
var total = prev + txs.length;
|
||
document.getElementById('walletTxCount').textContent =
|
||
total + (state.hasMore ? '+' : '') + ' transactions';
|
||
setLoadMoreVisible(state.hasMore);
|
||
C.setStatus('', '');
|
||
} catch (e) {
|
||
C.setStatus('Load failed: ' + e.message, 'err');
|
||
setLoadMoreVisible(true);
|
||
}
|
||
}
|
||
|
||
function setLoadMoreVisible(v) {
|
||
var btn = document.getElementById('loadMoreBtn');
|
||
if (btn) btn.style.display = v ? '' : 'none';
|
||
}
|
||
|
||
/* ── Copy buttons ────────────────────────────────────────────────────────── */
|
||
|
||
document.addEventListener('click', function(e) {
|
||
var t = e.target;
|
||
if (!t) return;
|
||
|
||
// Copy button
|
||
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 || src.title || '').catch(function() {});
|
||
btn.classList.add('copy-btn-done');
|
||
setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// TX row click → tx detail page
|
||
var link = t.closest ? t.closest('a') : null;
|
||
if (link) return;
|
||
var row = t.closest ? t.closest('tr.tx-row') : null;
|
||
if (row && row.dataset && row.dataset.txid) {
|
||
window.location.href = '/tx?id=' + encodeURIComponent(row.dataset.txid);
|
||
}
|
||
});
|
||
|
||
/* ── Event wiring ────────────────────────────────────────────────────────── */
|
||
|
||
document.getElementById('addressBtn').addEventListener('click', function() {
|
||
var val = (document.getElementById('addressInput').value || '').trim();
|
||
if (val) loadWallet(val);
|
||
});
|
||
|
||
document.getElementById('addressInput').addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') document.getElementById('addressBtn').click();
|
||
});
|
||
|
||
document.getElementById('loadMoreBtn').addEventListener('click', loadMore);
|
||
|
||
/* ── Auto-load from URL param ────────────────────────────────────────────── */
|
||
|
||
var initial = C.q('address');
|
||
if (initial) {
|
||
document.getElementById('addressInput').value = initial;
|
||
loadWallet(initial);
|
||
} else {
|
||
showEmpty();
|
||
}
|
||
|
||
})();
|