chore: initial commit for v0.0.1

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
This commit is contained in:
vsecoder
2026-04-17 14:16:44 +03:00
commit 7e7393e4f8
196 changed files with 55947 additions and 0 deletions

414
node/explorer/address.js Normal file
View File

@@ -0,0 +1,414 @@
(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();
}
})();