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

367
node/api_chain_v2.go Normal file
View File

@@ -0,0 +1,367 @@
package node
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"go-blockchain/blockchain"
"go-blockchain/wallet"
)
// V2ChainTx is a chain-native transaction representation for /v2/chain endpoints.
type V2ChainTx struct {
ID string `json:"id"`
Type blockchain.EventType `json:"type"`
Memo string `json:"memo,omitempty"`
From string `json:"from,omitempty"`
FromAddr string `json:"from_addr,omitempty"`
To string `json:"to,omitempty"`
ToAddr string `json:"to_addr,omitempty"`
AmountUT uint64 `json:"amount_ut"`
FeeUT uint64 `json:"fee_ut"`
BlockIndex uint64 `json:"block_index"`
BlockHash string `json:"block_hash,omitempty"`
Time string `json:"time"`
}
func asV2ChainTx(rec *blockchain.TxRecord) V2ChainTx {
tx := rec.Tx
out := V2ChainTx{
ID: tx.ID,
Type: tx.Type,
Memo: txMemo(tx),
From: tx.From,
To: tx.To,
AmountUT: tx.Amount,
FeeUT: tx.Fee,
BlockIndex: rec.BlockIndex,
BlockHash: rec.BlockHash,
Time: rec.BlockTime.UTC().Format("2006-01-02T15:04:05Z"),
}
if tx.From != "" {
out.FromAddr = wallet.PubKeyToAddress(tx.From)
}
if tx.To != "" {
out.ToAddr = wallet.PubKeyToAddress(tx.To)
}
return out
}
func apiV2ChainAccountTransactions(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
path := strings.TrimPrefix(r.URL.Path, "/v2/chain/accounts/")
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[1] != "transactions" {
http.NotFound(w, r)
return
}
pubKey, err := resolveAccountID(q, parts[0])
if err != nil {
jsonErr(w, err, 404)
return
}
afterBlock, err := queryUint64Optional(r, "after_block")
if err != nil {
jsonErr(w, err, 400)
return
}
beforeBlock, err := queryUint64Optional(r, "before_block")
if err != nil {
jsonErr(w, err, 400)
return
}
limit := queryInt(r, "limit", 100)
if limit > 1000 {
limit = 1000
}
order := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("order")))
if order == "" {
order = "desc"
}
if order != "desc" && order != "asc" {
jsonErr(w, fmt.Errorf("invalid order: %s", order), 400)
return
}
// Fetch enough records to fill the filtered result.
// If block filters are provided, over-fetch to avoid missing results.
fetchLimit := limit
if afterBlock != nil || beforeBlock != nil {
fetchLimit = 1000
}
recs, err := q.TxsByAddress(pubKey, fetchLimit, 0)
if err != nil {
jsonErr(w, err, 500)
return
}
filtered := make([]*blockchain.TxRecord, 0, len(recs))
for _, rec := range recs {
if afterBlock != nil && rec.BlockIndex <= *afterBlock {
continue
}
if beforeBlock != nil && rec.BlockIndex >= *beforeBlock {
continue
}
filtered = append(filtered, rec)
}
sort.Slice(filtered, func(i, j int) bool {
if filtered[i].BlockIndex == filtered[j].BlockIndex {
if order == "asc" {
return filtered[i].BlockTime.Before(filtered[j].BlockTime)
}
return filtered[i].BlockTime.After(filtered[j].BlockTime)
}
if order == "asc" {
return filtered[i].BlockIndex < filtered[j].BlockIndex
}
return filtered[i].BlockIndex > filtered[j].BlockIndex
})
if len(filtered) > limit {
filtered = filtered[:limit]
}
items := make([]V2ChainTx, len(filtered))
for i := range filtered {
items[i] = asV2ChainTx(filtered[i])
}
jsonOK(w, map[string]any{
"account_id": pubKey,
"account_addr": wallet.PubKeyToAddress(pubKey),
"count": len(items),
"transactions": items,
"order": order,
"limit_applied": limit,
})
}
}
func apiV2ChainTxByID(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
txID := strings.TrimPrefix(r.URL.Path, "/v2/chain/transactions/")
txID = strings.TrimSuffix(txID, "/")
if txID == "" || strings.Contains(txID, "/") {
jsonErr(w, fmt.Errorf("transaction id required"), 400)
return
}
rec, err := q.GetTx(txID)
if err != nil {
jsonErr(w, err, 500)
return
}
if rec == nil {
jsonErr(w, fmt.Errorf("transaction not found"), 404)
return
}
payload, payloadHex := decodeTxPayload(rec.Tx.Payload)
jsonOK(w, map[string]any{
"tx": asV2ChainTx(rec),
"payload": payload,
"payload_hex": payloadHex,
"signature_hex": fmt.Sprintf("%x", rec.Tx.Signature),
})
}
}
type v2ChainSubmitReq struct {
Tx *blockchain.Transaction `json:"tx,omitempty"`
SignedTx string `json:"signed_tx,omitempty"` // json/base64/hex envelope
}
type v2ChainDraftReq struct {
From string `json:"from"`
To string `json:"to"`
AmountUT uint64 `json:"amount_ut"`
Memo string `json:"memo,omitempty"`
FeeUT uint64 `json:"fee_ut,omitempty"`
}
func buildTransferDraft(req v2ChainDraftReq) (*blockchain.Transaction, error) {
from := strings.TrimSpace(req.From)
to := strings.TrimSpace(req.To)
if from == "" || to == "" {
return nil, fmt.Errorf("from and to are required")
}
if req.AmountUT == 0 {
return nil, fmt.Errorf("amount_ut must be > 0")
}
fee := req.FeeUT
if fee == 0 {
fee = blockchain.MinFee
}
memo := strings.TrimSpace(req.Memo)
payload, err := json.Marshal(blockchain.TransferPayload{Memo: memo})
if err != nil {
return nil, err
}
now := time.Now().UTC()
return &blockchain.Transaction{
ID: fmt.Sprintf("tx-%d", now.UnixNano()),
Type: blockchain.EventTransfer,
From: from,
To: to,
Amount: req.AmountUT,
Fee: fee,
Memo: memo,
Payload: payload,
Timestamp: now,
}, nil
}
func applyTransferDefaults(tx *blockchain.Transaction) error {
if tx == nil {
return fmt.Errorf("transaction is nil")
}
if tx.Type != blockchain.EventTransfer {
return nil
}
if tx.ID == "" {
tx.ID = fmt.Sprintf("tx-%d", time.Now().UTC().UnixNano())
}
if tx.Timestamp.IsZero() {
tx.Timestamp = time.Now().UTC()
}
if tx.Fee == 0 {
tx.Fee = blockchain.MinFee
}
if len(tx.Payload) == 0 {
payload, err := json.Marshal(blockchain.TransferPayload{Memo: tx.Memo})
if err != nil {
return err
}
tx.Payload = payload
}
return nil
}
func apiV2ChainDraftTx() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
bodyBytes, err := ioReadAll(w, r)
if err != nil {
jsonErr(w, err, 400)
return
}
var req v2ChainDraftReq
if err := json.Unmarshal(bodyBytes, &req); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
return
}
tx, err := buildTransferDraft(req)
if err != nil {
jsonErr(w, err, 400)
return
}
signTx := *tx
signTx.Signature = nil
signBytes, _ := json.Marshal(&signTx)
jsonOK(w, map[string]any{
"tx": tx,
"sign_bytes_hex": fmt.Sprintf("%x", signBytes),
"sign_bytes_base64": base64.StdEncoding.EncodeToString(signBytes),
"note": "Sign sign_bytes with sender private key and submit signed tx to POST /v2/chain/transactions.",
})
}
}
func apiV2ChainSendTx(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
var req v2ChainSubmitReq
bodyBytes, err := ioReadAll(w, r)
if err != nil {
jsonErr(w, err, 400)
return
}
if err := json.Unmarshal(bodyBytes, &req); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
return
}
var tx *blockchain.Transaction
if strings.TrimSpace(req.SignedTx) != "" {
tx, err = decodeTransactionEnvelope(req.SignedTx)
if err != nil {
jsonErr(w, err, 400)
return
}
}
if tx == nil {
tx = req.Tx
}
if tx == nil {
// Also allow direct transaction JSON as request body.
var direct blockchain.Transaction
if err := json.Unmarshal(bodyBytes, &direct); err == nil && direct.ID != "" {
tx = &direct
}
}
if tx == nil {
jsonErr(w, fmt.Errorf("missing tx in request body"), 400)
return
}
if err := applyTransferDefaults(tx); err != nil {
jsonErr(w, err, 400)
return
}
if len(tx.Signature) == 0 {
jsonErr(w, fmt.Errorf("signature is required; use /v2/chain/transactions/draft to prepare tx"), 400)
return
}
if err := ValidateTxTimestamp(tx); err != nil {
jsonErr(w, fmt.Errorf("bad timestamp: %w", err), 400)
return
}
if err := verifyTransactionSignature(tx); err != nil {
jsonErr(w, err, 400)
return
}
if err := q.SubmitTx(tx); err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]any{
"status": "accepted",
"id": tx.ID,
})
}
}
func ioReadAll(w http.ResponseWriter, r *http.Request) ([]byte, error) {
if r.Body == nil {
return nil, fmt.Errorf("empty request body")
}
defer r.Body.Close()
const max = 2 << 20 // 2 MiB
return io.ReadAll(http.MaxBytesReader(w, r.Body, max))
}

102
node/api_channels.go Normal file
View File

@@ -0,0 +1,102 @@
// Package node — channel endpoints.
//
// `/api/channels/:id/members` returns every Ed25519 pubkey registered as a
// channel member together with their current X25519 pubkey (from the
// identity registry). Clients sealing a message to a channel iterate this
// list and call relay.Seal once per recipient — that's the "fan-out"
// group-messaging model (R1 in the roadmap).
//
// Why enrich with X25519 here rather than making the client do it?
// - One HTTP round trip vs N. At 10+ members the latency difference is
// significant over mobile networks.
// - The server already holds the identity state; no extra DB hops.
// - Clients get a stable, already-joined view — if a member hasn't
// published an X25519 key yet, we return them with `x25519_pub_key=""`
// so the caller knows to skip or retry later.
package node
import (
"fmt"
"net/http"
"strings"
"go-blockchain/blockchain"
"go-blockchain/wallet"
)
func registerChannelAPI(mux *http.ServeMux, q ExplorerQuery) {
// GET /api/channels/{id} → channel metadata
// GET /api/channels/{id}/members → enriched member list
//
// One HandleFunc deals with both by sniffing the path suffix.
mux.HandleFunc("/api/channels/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/channels/")
path = strings.Trim(path, "/")
if path == "" {
jsonErr(w, fmt.Errorf("channel id required"), 400)
return
}
switch {
case strings.HasSuffix(path, "/members"):
id := strings.TrimSuffix(path, "/members")
handleChannelMembers(w, q, id)
default:
handleChannelInfo(w, q, path)
}
})
}
func handleChannelInfo(w http.ResponseWriter, q ExplorerQuery, channelID string) {
if q.GetChannel == nil {
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
return
}
ch, err := q.GetChannel(channelID)
if err != nil {
jsonErr(w, err, 500)
return
}
if ch == nil {
jsonErr(w, fmt.Errorf("channel %s not found", channelID), 404)
return
}
jsonOK(w, ch)
}
func handleChannelMembers(w http.ResponseWriter, q ExplorerQuery, channelID string) {
if q.GetChannelMembers == nil {
jsonErr(w, fmt.Errorf("channel queries not configured"), 503)
return
}
pubs, err := q.GetChannelMembers(channelID)
if err != nil {
jsonErr(w, err, 500)
return
}
out := make([]blockchain.ChannelMember, 0, len(pubs))
for _, pub := range pubs {
member := blockchain.ChannelMember{
PubKey: pub,
Address: wallet.PubKeyToAddress(pub),
}
// Best-effort X25519 lookup — skip silently on miss so a member
// who hasn't published their identity yet doesn't prevent the
// whole list from returning. The sender will just skip them on
// fan-out and retry later (after that member does register).
if q.IdentityInfo != nil {
if info, err := q.IdentityInfo(pub); err == nil && info != nil {
member.X25519PubKey = info.X25519Pub
}
}
out = append(out, member)
}
jsonOK(w, map[string]any{
"channel_id": channelID,
"count": len(out),
"members": out,
})
}

181
node/api_common.go Normal file
View File

@@ -0,0 +1,181 @@
package node
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"go-blockchain/blockchain"
"go-blockchain/identity"
)
func jsonOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
_ = json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, err error, code int) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}
func queryInt(r *http.Request, key string, def int) int {
s := r.URL.Query().Get(key)
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil || n <= 0 {
return def
}
return n
}
// queryIntMin0 parses a query param as a non-negative integer; returns 0 if absent or invalid.
func queryIntMin0(r *http.Request, key string) int {
s := r.URL.Query().Get(key)
if s == "" {
return 0
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return 0
}
return n
}
func queryUint64Optional(r *http.Request, key string) (*uint64, error) {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return nil, nil
}
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid %s: %s", key, raw)
}
return &n, nil
}
func resolveAccountID(q ExplorerQuery, accountID string) (string, error) {
if accountID == "" {
return "", fmt.Errorf("account id required")
}
if strings.HasPrefix(accountID, "DC") {
pubKey, err := q.AddressToPubKey(accountID)
if err != nil {
return "", err
}
if pubKey == "" {
return "", fmt.Errorf("account not found")
}
return pubKey, nil
}
return accountID, nil
}
func verifyTransactionSignature(tx *blockchain.Transaction) error {
if tx == nil {
return fmt.Errorf("transaction is nil")
}
return identity.VerifyTx(tx)
}
func decodeTransactionEnvelope(raw string) (*blockchain.Transaction, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("empty transaction envelope")
}
tryDecodeJSON := func(data []byte) (*blockchain.Transaction, error) {
var tx blockchain.Transaction
if err := json.Unmarshal(data, &tx); err != nil {
return nil, err
}
if tx.ID == "" || tx.From == "" || tx.Type == "" {
return nil, fmt.Errorf("invalid tx payload")
}
return &tx, nil
}
if strings.HasPrefix(raw, "{") {
return tryDecodeJSON([]byte(raw))
}
base64Decoders := []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
base64.URLEncoding,
base64.RawURLEncoding,
}
for _, enc := range base64Decoders {
if b, err := enc.DecodeString(raw); err == nil {
if tx, txErr := tryDecodeJSON(b); txErr == nil {
return tx, nil
}
}
}
if b, err := hex.DecodeString(raw); err == nil {
if tx, txErr := tryDecodeJSON(b); txErr == nil {
return tx, nil
}
}
return nil, fmt.Errorf("failed to decode transaction envelope")
}
func txMemo(tx *blockchain.Transaction) string {
if tx == nil {
return ""
}
if memo := strings.TrimSpace(tx.Memo); memo != "" {
return memo
}
switch tx.Type {
case blockchain.EventTransfer:
var p blockchain.TransferPayload
if err := json.Unmarshal(tx.Payload, &p); err == nil {
return strings.TrimSpace(p.Memo)
}
case blockchain.EventBlockReward:
return blockRewardReason(tx.Payload)
case blockchain.EventRelayProof:
return "Relay delivery fee"
case blockchain.EventHeartbeat:
return "Liveness heartbeat"
case blockchain.EventRegisterRelay:
return "Register relay service"
case blockchain.EventBindWallet:
return "Bind payout wallet"
}
return ""
}
func blockRewardReason(payload []byte) string {
var p blockchain.BlockRewardPayload
if err := json.Unmarshal(payload, &p); err != nil {
return "Block fees"
}
if p.FeeReward == 0 && p.TotalReward > 0 {
return "Genesis allocation"
}
return "Block fees collected"
}
func decodeTxPayload(payload []byte) (any, string) {
if len(payload) == 0 {
return nil, ""
}
var decoded any
if err := json.Unmarshal(payload, &decoded); err == nil {
return decoded, ""
}
return nil, hex.EncodeToString(payload)
}

199
node/api_contract.go Normal file
View File

@@ -0,0 +1,199 @@
package node
import (
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"go-blockchain/blockchain"
)
// registerContractAPI mounts the contract API routes on mux.
//
// GET /api/contracts — list all deployed contracts
// GET /api/contracts/{contractID} — contract metadata (no WASM bytes)
// GET /api/contracts/{contractID}/state/{key} — raw state value, base64-encoded
// GET /api/contracts/{contractID}/logs — recent log entries (newest first)
func registerContractAPI(mux *http.ServeMux, q ExplorerQuery) {
// Exact match for list endpoint (no trailing slash)
mux.HandleFunc("/api/contracts", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, errorf("method not allowed"), 405)
return
}
if q.GetContracts == nil {
jsonErr(w, errorf("contract queries not available"), 503)
return
}
contracts, err := q.GetContracts()
if err != nil {
jsonErr(w, err, 500)
return
}
if contracts == nil {
contracts = []blockchain.ContractRecord{}
}
jsonOK(w, map[string]any{
"count": len(contracts),
"contracts": contracts,
})
})
mux.HandleFunc("/api/contracts/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, errorf("method not allowed"), 405)
return
}
// Path segments after /api/contracts/:
// "<id>" → contract info
// "<id>/state/<k>" → state value
// "<id>/logs" → log entries
path := strings.TrimPrefix(r.URL.Path, "/api/contracts/")
path = strings.Trim(path, "/")
switch {
case strings.Contains(path, "/state/"):
parts := strings.SplitN(path, "/state/", 2)
contractID := parts[0]
if contractID == "" {
jsonErr(w, errorf("contract_id required"), 400)
return
}
handleContractState(w, q, contractID, parts[1])
case strings.HasSuffix(path, "/logs"):
contractID := strings.TrimSuffix(path, "/logs")
if contractID == "" {
jsonErr(w, errorf("contract_id required"), 400)
return
}
handleContractLogs(w, r, q, contractID)
default:
contractID := path
if contractID == "" {
jsonErr(w, errorf("contract_id required"), 400)
return
}
handleContractInfo(w, q, contractID)
}
})
}
func handleContractInfo(w http.ResponseWriter, q ExplorerQuery, contractID string) {
// Check native contracts first — they aren't stored as WASM ContractRecord
// in BadgerDB but are valid targets for CALL_CONTRACT and have an ABI.
if q.NativeContracts != nil {
for _, nc := range q.NativeContracts() {
if nc.ContractID == contractID {
jsonOK(w, map[string]any{
"contract_id": nc.ContractID,
"deployer_pub": "",
"deployed_at": uint64(0), // native contracts exist from genesis
"abi_json": nc.ABIJson,
"wasm_size": 0,
"native": true,
})
return
}
}
}
if q.GetContract == nil {
jsonErr(w, errorf("contract queries not available"), 503)
return
}
rec, err := q.GetContract(contractID)
if err != nil {
jsonErr(w, err, 500)
return
}
if rec == nil {
jsonErr(w, errorf("contract %s not found", contractID), 404)
return
}
// Omit raw WASM bytes from the API response; expose only metadata.
jsonOK(w, map[string]any{
"contract_id": rec.ContractID,
"deployer_pub": rec.DeployerPub,
"deployed_at": rec.DeployedAt,
"abi_json": rec.ABIJson,
"wasm_size": len(rec.WASMBytes),
"native": false,
})
}
func handleContractState(w http.ResponseWriter, q ExplorerQuery, contractID, key string) {
if q.GetContractState == nil {
jsonErr(w, errorf("contract state queries not available"), 503)
return
}
val, err := q.GetContractState(contractID, key)
if err != nil {
jsonErr(w, err, 500)
return
}
if val == nil {
jsonOK(w, map[string]any{
"contract_id": contractID,
"key": key,
"value_b64": nil,
"value_hex": nil,
})
return
}
jsonOK(w, map[string]any{
"contract_id": contractID,
"key": key,
"value_b64": base64.StdEncoding.EncodeToString(val),
"value_hex": hexEncode(val),
"value_u64": decodeU64(val), // convenience: big-endian uint64 if len==8
})
}
func handleContractLogs(w http.ResponseWriter, r *http.Request, q ExplorerQuery, contractID string) {
if q.GetContractLogs == nil {
jsonErr(w, errorf("contract log queries not available"), 503)
return
}
limit := 50
if s := r.URL.Query().Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 {
limit = n
}
}
entries, err := q.GetContractLogs(contractID, limit)
if err != nil {
jsonErr(w, err, 500)
return
}
if entries == nil {
entries = []blockchain.ContractLogEntry{}
}
jsonOK(w, map[string]any{
"contract_id": contractID,
"count": len(entries),
"logs": entries,
})
}
// errorf is a helper to create a formatted error.
func errorf(format string, args ...any) error {
if len(args) == 0 {
return fmt.Errorf("%s", format)
}
return fmt.Errorf(format, args...)
}
func hexEncode(b []byte) string { return hex.EncodeToString(b) }
func decodeU64(b []byte) *uint64 {
if len(b) != 8 {
return nil
}
v := binary.BigEndian.Uint64(b)
return &v
}

553
node/api_explorer.go Normal file
View File

@@ -0,0 +1,553 @@
package node
import (
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"go-blockchain/blockchain"
"go-blockchain/economy"
"go-blockchain/wallet"
)
type txListEntry struct {
ID string `json:"id"`
Type blockchain.EventType `json:"type"`
Memo string `json:"memo,omitempty"`
From string `json:"from"`
FromAddr string `json:"from_addr,omitempty"`
To string `json:"to,omitempty"`
ToAddr string `json:"to_addr,omitempty"`
Amount uint64 `json:"amount_ut"`
AmountDisp string `json:"amount"`
Fee uint64 `json:"fee_ut"`
FeeDisp string `json:"fee"`
Time string `json:"time"`
BlockIndex uint64 `json:"block_index"`
BlockHash string `json:"block_hash,omitempty"`
}
func asTxListEntry(rec *blockchain.TxRecord) txListEntry {
tx := rec.Tx
out := txListEntry{
ID: tx.ID,
Type: tx.Type,
Memo: txMemo(tx),
From: tx.From,
To: tx.To,
Amount: tx.Amount,
AmountDisp: economy.FormatTokens(tx.Amount),
Fee: tx.Fee,
FeeDisp: economy.FormatTokens(tx.Fee),
Time: tx.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
BlockIndex: rec.BlockIndex,
BlockHash: rec.BlockHash,
}
if tx.From != "" {
out.FromAddr = wallet.PubKeyToAddress(tx.From)
}
if tx.To != "" {
out.ToAddr = wallet.PubKeyToAddress(tx.To)
}
return out
}
func apiNetStats(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := q.NetStats()
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, stats)
}
}
func apiRecentBlocks(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 20)
blocks, err := q.RecentBlocks(limit)
if err != nil {
jsonErr(w, err, 500)
return
}
type blockSummary struct {
Index uint64 `json:"index"`
Hash string `json:"hash"`
Time string `json:"time"`
Validator string `json:"validator"`
TxCount int `json:"tx_count"`
TotalFees uint64 `json:"total_fees_ut"`
}
out := make([]blockSummary, len(blocks))
for i, b := range blocks {
out[i] = blockSummary{
Index: b.Index,
Hash: b.HashHex(),
Time: b.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
Validator: wallet.PubKeyToAddress(b.Validator),
TxCount: len(b.Transactions),
TotalFees: b.TotalFees,
}
}
jsonOK(w, out)
}
}
func apiRecentTxs(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 20)
recs, err := q.RecentTxs(limit)
if err != nil {
jsonErr(w, err, 500)
return
}
out := make([]txListEntry, len(recs))
for i := range recs {
out[i] = asTxListEntry(recs[i])
}
jsonOK(w, out)
}
}
func apiBlock(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idxStr := strings.TrimPrefix(r.URL.Path, "/api/block/")
idx, err := strconv.ParseUint(idxStr, 10, 64)
if err != nil {
jsonErr(w, fmt.Errorf("invalid block index: %s", idxStr), 400)
return
}
b, err := q.GetBlock(idx)
if err != nil {
jsonErr(w, err, 404)
return
}
type txSummary struct {
ID string `json:"id"`
Type blockchain.EventType `json:"type"`
Memo string `json:"memo,omitempty"`
From string `json:"from"`
To string `json:"to,omitempty"`
Amount uint64 `json:"amount_ut,omitempty"`
Fee uint64 `json:"fee_ut"`
}
type blockDetail struct {
Index uint64 `json:"index"`
Hash string `json:"hash"`
PrevHash string `json:"prev_hash"`
Time string `json:"time"`
Validator string `json:"validator"`
ValidatorAddr string `json:"validator_addr"`
TxCount int `json:"tx_count"`
TotalFees uint64 `json:"total_fees_ut"`
Transactions []txSummary `json:"transactions"`
}
txs := make([]txSummary, len(b.Transactions))
for i, tx := range b.Transactions {
txs[i] = txSummary{
ID: tx.ID,
Type: tx.Type,
Memo: txMemo(tx),
From: tx.From,
To: tx.To,
Amount: tx.Amount,
Fee: tx.Fee,
}
}
jsonOK(w, blockDetail{
Index: b.Index,
Hash: b.HashHex(),
PrevHash: fmt.Sprintf("%x", b.PrevHash),
Time: b.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
Validator: b.Validator,
ValidatorAddr: wallet.PubKeyToAddress(b.Validator),
TxCount: len(b.Transactions),
TotalFees: b.TotalFees,
Transactions: txs,
})
}
}
func apiTxByID(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
txID := strings.TrimPrefix(r.URL.Path, "/api/tx/")
if txID == "" {
jsonErr(w, fmt.Errorf("tx id required"), 400)
return
}
rec, err := q.GetTx(txID)
if err != nil {
jsonErr(w, err, 500)
return
}
if rec == nil {
jsonErr(w, fmt.Errorf("transaction not found"), 404)
return
}
type txDetail struct {
ID string `json:"id"`
Type blockchain.EventType `json:"type"`
Memo string `json:"memo,omitempty"`
From string `json:"from"`
FromAddr string `json:"from_addr,omitempty"`
To string `json:"to,omitempty"`
ToAddr string `json:"to_addr,omitempty"`
Amount uint64 `json:"amount_ut"`
AmountDisp string `json:"amount"`
Fee uint64 `json:"fee_ut"`
FeeDisp string `json:"fee"`
Time string `json:"time"`
BlockIndex uint64 `json:"block_index"`
BlockHash string `json:"block_hash"`
BlockTime string `json:"block_time"`
GasUsed uint64 `json:"gas_used,omitempty"`
Payload any `json:"payload,omitempty"`
PayloadHex string `json:"payload_hex,omitempty"`
SignatureHex string `json:"signature_hex,omitempty"`
}
tx := rec.Tx
payload, payloadHex := decodeTxPayload(tx.Payload)
out := txDetail{
ID: tx.ID,
Type: tx.Type,
Memo: txMemo(tx),
From: tx.From,
To: tx.To,
Amount: tx.Amount,
AmountDisp: economy.FormatTokens(tx.Amount),
Fee: tx.Fee,
FeeDisp: economy.FormatTokens(tx.Fee),
Time: tx.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
BlockIndex: rec.BlockIndex,
BlockHash: rec.BlockHash,
BlockTime: rec.BlockTime.UTC().Format("2006-01-02T15:04:05Z"),
GasUsed: rec.GasUsed,
Payload: payload,
PayloadHex: payloadHex,
SignatureHex: hex.EncodeToString(tx.Signature),
}
if tx.From != "" {
out.FromAddr = wallet.PubKeyToAddress(tx.From)
}
if tx.To != "" {
out.ToAddr = wallet.PubKeyToAddress(tx.To)
}
jsonOK(w, out)
}
}
func apiAddress(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
addr := strings.TrimPrefix(r.URL.Path, "/api/address/")
if addr == "" {
jsonErr(w, fmt.Errorf("address required"), 400)
return
}
pubKey, err := resolveAccountID(q, addr)
if err != nil {
jsonErr(w, err, 404)
return
}
limit := queryInt(r, "limit", 50)
offset := queryIntMin0(r, "offset")
bal, err := q.Balance(pubKey)
if err != nil {
jsonErr(w, err, 500)
return
}
txs, err := q.TxsByAddress(pubKey, limit, offset)
if err != nil {
jsonErr(w, err, 500)
return
}
type txEntry struct {
ID string `json:"id"`
Type blockchain.EventType `json:"type"`
Memo string `json:"memo,omitempty"`
From string `json:"from"`
FromAddr string `json:"from_addr,omitempty"`
To string `json:"to,omitempty"`
ToAddr string `json:"to_addr,omitempty"`
Amount uint64 `json:"amount_ut"`
AmountDisp string `json:"amount"`
Fee uint64 `json:"fee_ut"`
Time string `json:"time"`
BlockIndex uint64 `json:"block_index"`
}
entries := make([]txEntry, len(txs))
for i, rec := range txs {
tx := rec.Tx
entries[i] = txEntry{
ID: tx.ID,
Type: tx.Type,
Memo: txMemo(tx),
From: tx.From,
To: tx.To,
Amount: tx.Amount,
AmountDisp: economy.FormatTokens(tx.Amount),
Fee: tx.Fee,
Time: tx.Timestamp.UTC().Format("2006-01-02T15:04:05Z"),
BlockIndex: rec.BlockIndex,
}
if tx.From != "" {
entries[i].FromAddr = wallet.PubKeyToAddress(tx.From)
}
if tx.To != "" {
entries[i].ToAddr = wallet.PubKeyToAddress(tx.To)
}
}
type addrResp struct {
Address string `json:"address"`
PubKey string `json:"pub_key"`
BalanceMicroT uint64 `json:"balance_ut"`
Balance string `json:"balance"`
TxCount int `json:"tx_count"`
Offset int `json:"offset"`
Limit int `json:"limit"`
HasMore bool `json:"has_more"`
NextOffset int `json:"next_offset"`
Transactions []txEntry `json:"transactions"`
}
hasMore := len(entries) == limit
jsonOK(w, addrResp{
Address: wallet.PubKeyToAddress(pubKey),
PubKey: pubKey,
BalanceMicroT: bal,
Balance: economy.FormatTokens(bal),
TxCount: len(entries),
Offset: offset,
Limit: limit,
HasMore: hasMore,
NextOffset: offset + len(entries),
Transactions: entries,
})
}
}
func apiNode(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
input := strings.TrimPrefix(r.URL.Path, "/api/node/")
if input == "" {
jsonErr(w, fmt.Errorf("node id required"), 400)
return
}
pubKey, err := resolveAccountID(q, input)
if err != nil {
jsonErr(w, err, 404)
return
}
rep, err := q.Reputation(pubKey)
if err != nil {
jsonErr(w, err, 500)
return
}
nodeBal, err := q.Balance(pubKey)
if err != nil {
jsonErr(w, err, 500)
return
}
walletPubKey, err := q.WalletBinding(pubKey)
if err != nil {
jsonErr(w, err, 500)
return
}
var walletAddr string
var walletBalance uint64
if walletPubKey != "" {
walletAddr = wallet.PubKeyToAddress(walletPubKey)
walletBalance, _ = q.Balance(walletPubKey)
}
window := queryInt(r, "window", 200)
recentBlocks, err := q.RecentBlocks(window)
if err != nil {
jsonErr(w, err, 500)
return
}
recentProduced := 0
var recentRewardsUT uint64
for _, b := range recentBlocks {
if b.Validator != pubKey {
continue
}
recentProduced++
recentRewardsUT += b.TotalFees
}
type nodeResp struct {
QueryInput string `json:"query_input"`
PubKey string `json:"pub_key"`
Address string `json:"address"`
NodeBalanceUT uint64 `json:"node_balance_ut"`
NodeBalance string `json:"node_balance"`
WalletBindingPubKey string `json:"wallet_binding_pub_key,omitempty"`
WalletBindingAddress string `json:"wallet_binding_address,omitempty"`
WalletBindingBalanceUT uint64 `json:"wallet_binding_balance_ut,omitempty"`
WalletBindingBalance string `json:"wallet_binding_balance,omitempty"`
ReputationScore int64 `json:"reputation_score"`
ReputationRank string `json:"reputation_rank"`
BlocksProduced uint64 `json:"blocks_produced"`
RelayProofs uint64 `json:"relay_proofs"`
SlashCount uint64 `json:"slash_count"`
Heartbeats uint64 `json:"heartbeats"`
LifetimeBaseRewardUT uint64 `json:"lifetime_base_reward_ut"`
LifetimeBaseReward string `json:"lifetime_base_reward"`
RecentWindowBlocks int `json:"recent_window_blocks"`
RecentBlocksProduced int `json:"recent_blocks_produced"`
RecentRewardsUT uint64 `json:"recent_rewards_ut"`
RecentRewards string `json:"recent_rewards"`
}
jsonOK(w, nodeResp{
QueryInput: input,
PubKey: pubKey,
Address: wallet.PubKeyToAddress(pubKey),
NodeBalanceUT: nodeBal,
NodeBalance: economy.FormatTokens(nodeBal),
WalletBindingPubKey: walletPubKey,
WalletBindingAddress: walletAddr,
WalletBindingBalanceUT: walletBalance,
WalletBindingBalance: economy.FormatTokens(walletBalance),
ReputationScore: rep.Score,
ReputationRank: rep.Rank(),
BlocksProduced: rep.BlocksProduced,
RelayProofs: rep.RelayProofs,
SlashCount: rep.SlashCount,
Heartbeats: rep.Heartbeats,
LifetimeBaseRewardUT: 0,
LifetimeBaseReward: economy.FormatTokens(0),
RecentWindowBlocks: len(recentBlocks),
RecentBlocksProduced: recentProduced,
RecentRewardsUT: recentRewardsUT,
RecentRewards: economy.FormatTokens(recentRewardsUT),
})
}
}
func apiRelays(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if q.RegisteredRelays == nil {
jsonOK(w, []any{})
return
}
relays, err := q.RegisteredRelays()
if err != nil {
jsonErr(w, err, 500)
return
}
if relays == nil {
relays = []blockchain.RegisteredRelayInfo{}
}
jsonOK(w, relays)
}
}
func apiValidators(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if q.ValidatorSet == nil {
jsonOK(w, []any{})
return
}
validators, err := q.ValidatorSet()
if err != nil {
jsonErr(w, err, 500)
return
}
type validatorEntry struct {
PubKey string `json:"pub_key"`
Address string `json:"address"`
Staked uint64 `json:"staked_ut,omitempty"`
}
out := make([]validatorEntry, len(validators))
for i, pk := range validators {
var staked uint64
if q.Stake != nil {
staked, _ = q.Stake(pk)
}
out[i] = validatorEntry{
PubKey: pk,
Address: wallet.PubKeyToAddress(pk),
Staked: staked,
}
}
jsonOK(w, map[string]any{
"count": len(out),
"validators": out,
})
}
}
func apiIdentity(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
input := strings.TrimPrefix(r.URL.Path, "/api/identity/")
if input == "" {
jsonErr(w, fmt.Errorf("pubkey or address required"), 400)
return
}
if q.IdentityInfo == nil {
jsonErr(w, fmt.Errorf("identity lookup not available"), 503)
return
}
// Resolve DC address → pubkey if needed.
pubKey, err := resolveAccountID(q, input)
if err != nil {
jsonErr(w, err, 404)
return
}
info, err := q.IdentityInfo(pubKey)
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, info)
}
}
func apiSubmitTx(q ExplorerQuery) http.HandlerFunc {
// The returned handler is wrapped in withSubmitTxGuards() by the caller:
// body size is capped at MaxTxRequestBytes and per-IP rate limiting is
// applied upstream (see api_guards.go). This function therefore only
// handles the semantics — shape, signature, timestamp window, dispatch.
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
var tx blockchain.Transaction
if err := json.NewDecoder(r.Body).Decode(&tx); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
return
}
// Reject txs with an obviously-bad clock value before we even verify
// the signature — cheaper failure, and cuts a replay window against
// long-rotated dedup caches.
if err := ValidateTxTimestamp(&tx); err != nil {
MetricTxSubmitRejected.Inc()
jsonErr(w, fmt.Errorf("bad timestamp: %w", err), 400)
return
}
if err := verifyTransactionSignature(&tx); err != nil {
MetricTxSubmitRejected.Inc()
jsonErr(w, err, 400)
return
}
if err := q.SubmitTx(&tx); err != nil {
MetricTxSubmitRejected.Inc()
jsonErr(w, err, 500)
return
}
MetricTxSubmitAccepted.Inc()
jsonOK(w, map[string]string{"id": tx.ID, "status": "accepted"})
}
}

299
node/api_guards.go Normal file
View File

@@ -0,0 +1,299 @@
// Package node — HTTP-level guards: body-size limits, timestamp windows, and
// a tiny per-IP token-bucket rate limiter.
//
// These are intentionally lightweight, dependency-free, and fail open if
// misconfigured. They do not replace proper production fronting (reverse
// proxy with rate-limit module), but they close the most obvious abuse
// vectors when the node is exposed directly.
package node
import (
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"go-blockchain/blockchain"
)
// ─── Limits ──────────────────────────────────────────────────────────────────
// MaxTxRequestBytes caps the size of a single POST /api/tx body. A signed
// transaction with a modest payload sits well under 16 KiB; we allow 64 KiB
// to accommodate small WASM deploys submitted by trusted signers.
//
// Note: DEPLOY_CONTRACT with a large WASM binary uses the same endpoint and
// therefore the same cap. If you deploy bigger contracts, raise this cap on
// the nodes that accept deploys, or (better) add a dedicated upload route.
const MaxTxRequestBytes int64 = 64 * 1024
// TxTimestampSkew is the maximum accepted deviation between a transaction's
// declared timestamp and the node's current wall clock. Transactions outside
// the window are rejected at the API layer to harden against replay + clock
// skew attacks (we do not retain old txIDs forever, so a very old but
// otherwise valid tx could otherwise slip in after its dedup entry rotates).
const TxTimestampSkew = 1 * time.Hour
// ValidateTxTimestamp returns an error if tx.Timestamp is further than
// TxTimestampSkew from now. The zero-time is also rejected.
//
// Exported so the WS gateway (and other in-process callers) can reuse the
// same validation as the HTTP /api/tx path without importing the file-local
// helper.
func ValidateTxTimestamp(tx *blockchain.Transaction) error {
if tx.Timestamp.IsZero() {
return fmt.Errorf("timestamp is required")
}
now := time.Now().UTC()
delta := tx.Timestamp.Sub(now)
if delta < 0 {
delta = -delta
}
if delta > TxTimestampSkew {
return fmt.Errorf("timestamp %s is outside ±%s window of current time %s",
tx.Timestamp.Format(time.RFC3339), TxTimestampSkew, now.Format(time.RFC3339))
}
return nil
}
// ─── Rate limiter ────────────────────────────────────────────────────────────
// ipRateLimiter is a best-effort token bucket keyed by source IP. Buckets are
// created lazily and garbage-collected by a background sweep.
type ipRateLimiter struct {
rate float64 // tokens added per second
burst float64 // bucket capacity
sweepEvery time.Duration // how often to GC inactive buckets
inactiveTTL time.Duration // drop buckets idle longer than this
mu sync.Mutex
buckets map[string]*bucket
stopCh chan struct{}
}
type bucket struct {
tokens float64
lastSeen time.Time
}
// newIPRateLimiter constructs a limiter: each IP gets `burst` tokens that refill
// at `rate` per second.
func newIPRateLimiter(rate, burst float64) *ipRateLimiter {
l := &ipRateLimiter{
rate: rate,
burst: burst,
sweepEvery: 2 * time.Minute,
inactiveTTL: 10 * time.Minute,
buckets: make(map[string]*bucket),
stopCh: make(chan struct{}),
}
go l.sweepLoop()
return l
}
// Allow deducts one token for the given IP; returns false if the bucket is empty.
func (l *ipRateLimiter) Allow(ip string) bool {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
b, ok := l.buckets[ip]
if !ok {
b = &bucket{tokens: l.burst, lastSeen: now}
l.buckets[ip] = b
}
elapsed := now.Sub(b.lastSeen).Seconds()
b.tokens += elapsed * l.rate
if b.tokens > l.burst {
b.tokens = l.burst
}
b.lastSeen = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func (l *ipRateLimiter) sweepLoop() {
tk := time.NewTicker(l.sweepEvery)
defer tk.Stop()
for {
select {
case <-l.stopCh:
return
case now := <-tk.C:
l.mu.Lock()
for ip, b := range l.buckets {
if now.Sub(b.lastSeen) > l.inactiveTTL {
delete(l.buckets, ip)
}
}
l.mu.Unlock()
}
}
}
// clientIP extracts the best-effort originating IP. X-Forwarded-For is
// respected but only the leftmost entry is trusted; do not rely on this
// behind untrusted proxies (configure a real reverse proxy for production).
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}
// Package-level singletons (best-effort; DoS hardening is a defense-in-depth
// measure, not a primary security boundary).
var (
// Allow up to 10 tx submissions/s per IP with a burst of 20.
submitTxLimiter = newIPRateLimiter(10, 20)
// Inbox / contacts polls: allow 20/s per IP with a burst of 40.
readLimiter = newIPRateLimiter(20, 40)
)
// withSubmitTxGuards composes size + rate-limit protection around apiSubmitTx.
// It rejects oversize bodies and flooding IPs before any JSON decoding happens,
// so an attacker cannot consume CPU decoding 10 MB of nothing.
func withSubmitTxGuards(inner http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
ip := clientIP(r)
if !submitTxLimiter.Allow(ip) {
w.Header().Set("Retry-After", "2")
jsonErr(w, fmt.Errorf("rate limit exceeded"), http.StatusTooManyRequests)
return
}
r.Body = http.MaxBytesReader(w, r.Body, MaxTxRequestBytes)
}
inner.ServeHTTP(w, r)
}
}
// withReadLimit is the equivalent for inbox / contacts read endpoints. It only
// applies rate limiting (no body size cap needed for GETs).
func withReadLimit(inner http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !readLimiter.Allow(clientIP(r)) {
w.Header().Set("Retry-After", "1")
jsonErr(w, fmt.Errorf("rate limit exceeded"), http.StatusTooManyRequests)
return
}
inner.ServeHTTP(w, r)
}
}
// ── Access-token gating ──────────────────────────────────────────────────────
//
// Single-node operators often want their node "private" — only they (and
// apps they explicitly configure) can submit transactions or read state
// through its HTTP API. Two flavours:
//
// 1. Semi-public: anyone can GET chain state (netstats, blocks, txs),
// but only clients with a valid Bearer token can POST /api/tx or
// send WS `submit_tx`. Default when `--api-token` is set.
//
// 2. Fully private: EVERY endpoint requires the token. Use `--api-private`
// together with `--api-token`. Good for a personal node whose data
// the operator considers sensitive (e.g. who they're chatting with).
//
// Both modes gate via a shared secret (HTTP `Authorization: Bearer <token>`
// or WS hello-time check). There's no multi-user access control here —
// operators wanting role-based auth should front the node with their
// usual reverse-proxy auth (mTLS, OAuth, basicauth). This is the
// "just keep randos off my box" level of access control.
var (
// accessToken is the shared secret required by gated endpoints.
// Empty = gating disabled.
accessToken string
// accessPrivate gates READ endpoints too, not just write. Only
// applies when accessToken is non-empty.
accessPrivate bool
)
// SetAPIAccess configures the token + private-mode flags. Called once at
// node startup. Pass empty token to disable gating entirely (public node).
func SetAPIAccess(token string, private bool) {
accessToken = strings.TrimSpace(token)
accessPrivate = private
}
// checkAccessToken returns nil if the request carries the configured
// Bearer token, or an error describing the problem. When accessToken
// is empty (public node) it always returns nil.
//
// Accepts `Authorization: Bearer <token>` or the `?token=<token>` query
// parameter — the latter lets operators open `https://node.example.com/
// api/netstats?token=...` directly in a browser while testing a private
// node without needing custom headers.
func checkAccessToken(r *http.Request) error {
if accessToken == "" {
return nil
}
// Header takes precedence.
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
if strings.TrimPrefix(auth, "Bearer ") == accessToken {
return nil
}
return fmt.Errorf("invalid bearer token")
}
if q := r.URL.Query().Get("token"); q != "" {
if q == accessToken {
return nil
}
return fmt.Errorf("invalid ?token= query param")
}
return fmt.Errorf("missing bearer token; pass Authorization: Bearer <token> or ?token=...")
}
// withWriteTokenGuard wraps a write handler (submit tx, etc.) so it
// requires the access token whenever one is configured. Independent of
// accessPrivate — writes are ALWAYS gated when a token is set.
func withWriteTokenGuard(inner http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := checkAccessToken(r); err != nil {
w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`)
jsonErr(w, err, http.StatusUnauthorized)
return
}
inner.ServeHTTP(w, r)
}
}
// withReadTokenGuard gates a read endpoint. Only enforced when
// accessPrivate is true; otherwise falls through to the inner handler
// so public nodes keep working as before.
func withReadTokenGuard(inner http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if accessToken != "" && accessPrivate {
if err := checkAccessToken(r); err != nil {
w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`)
jsonErr(w, err, http.StatusUnauthorized)
return
}
}
inner.ServeHTTP(w, r)
}
}
// AccessTokenForWS returns the current token + private-mode flag so the
// WS hub can apply the same policy to websocket submit_tx ops and, when
// private, to connection upgrades themselves.
func AccessTokenForWS() (token string, private bool) {
return accessToken, accessPrivate
}

162
node/api_onboarding.go Normal file
View File

@@ -0,0 +1,162 @@
// Package node — node-onboarding API routes.
//
// These endpoints let a brand-new node (or a client) discover enough state
// about an existing DChain network to bootstrap itself, without requiring
// the operator to hand-copy validator keys, contract IDs, or peer multiaddrs.
//
// Endpoints:
//
// GET /api/peers → live libp2p peers of this node
// GET /api/network-info → genesis hash + chain id + validators + peers + well-known contracts
//
// Design rationale:
// - /api/peers returns libp2p multiaddrs that include /p2p/<id>. A joiner
// can pass any of these to its own `--peers` flag and immediately dial
// into the DHT/gossipsub mesh.
// - /api/network-info is a ONE-SHOT bootstrap payload. Instead of curling
// six different endpoints, an operator points their new node at a seed
// node's HTTP and pulls everything they need. Fields are optional where
// not applicable so partial responses work even on trimmed-down nodes
// (e.g. non-validator observers).
package node
import (
"encoding/json"
"fmt"
"net/http"
)
func registerOnboardingAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/peers", apiPeers(q))
mux.HandleFunc("/api/network-info", apiNetworkInfo(q))
}
// apiPeers — GET /api/peers
//
// Returns this node's current view of connected libp2p peers. Empty list is
// a valid response (node is isolated). 503 if the node was built without p2p
// wiring (rare — mostly tests).
func apiPeers(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if q.ConnectedPeers == nil {
jsonErr(w, fmt.Errorf("p2p not configured on this node"), 503)
return
}
peers := q.ConnectedPeers()
if peers == nil {
peers = []ConnectedPeerRef{}
}
jsonOK(w, map[string]any{
"count": len(peers),
"peers": peers,
})
}
}
// apiNetworkInfo — GET /api/network-info
//
// One-shot bootstrap payload for new joiners. Returns:
// - chain_id — stable network identifier (from ChainID())
// - genesis_hash — hex hash of block 0; joiners MUST verify a local replay matches this
// - genesis_validator — pubkey of the node that created block 0
// - tip_height — current committed height (lock-free read)
// - validators — active validator set (pubkey hex)
// - peers — live libp2p peers (for --peers bootstrap list)
// - contracts — well-known contracts by ABI name (same as /api/well-known-contracts)
// - stats — a snapshot of NetStats for a quick sanity check
//
// Any field may be omitted if its query func is nil, so the endpoint
// degrades gracefully on slimmed-down builds.
func apiNetworkInfo(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
out := map[string]any{}
// --- chain_id ---
if q.ChainID != nil {
out["chain_id"] = q.ChainID()
}
// --- genesis block ---
if q.GetBlock != nil {
if g, err := q.GetBlock(0); err == nil && g != nil {
out["genesis_hash"] = g.HashHex()
out["genesis_validator"] = g.Validator
out["genesis_time"] = g.Timestamp.UTC().Format("2006-01-02T15:04:05Z")
}
}
// --- current tip + aggregate stats ---
if q.NetStats != nil {
if s, err := q.NetStats(); err == nil {
out["tip_height"] = s.TotalBlocks
out["stats"] = s
}
}
// --- active validators ---
if q.ValidatorSet != nil {
if vs, err := q.ValidatorSet(); err == nil {
if vs == nil {
vs = []string{}
}
out["validators"] = vs
}
}
// --- live peers ---
if q.ConnectedPeers != nil {
peers := q.ConnectedPeers()
if peers == nil {
peers = []ConnectedPeerRef{}
}
out["peers"] = peers
}
// --- well-known contracts (reuse registerWellKnownAPI's logic) ---
if q.GetContracts != nil {
out["contracts"] = collectWellKnownContracts(q)
}
jsonOK(w, out)
}
}
// collectWellKnownContracts is the same reduction used by /api/well-known-contracts
// but inlined here so /api/network-info is a single HTTP round-trip for joiners.
func collectWellKnownContracts(q ExplorerQuery) map[string]WellKnownContract {
out := map[string]WellKnownContract{}
all, err := q.GetContracts()
if err != nil {
return out
}
for _, rec := range all {
if rec.ABIJson == "" {
continue
}
var abi abiHeader
if err := json.Unmarshal([]byte(rec.ABIJson), &abi); err != nil {
continue
}
if abi.Contract == "" {
continue
}
existing, ok := out[abi.Contract]
if !ok || rec.DeployedAt < existing.DeployedAt {
out[abi.Contract] = WellKnownContract{
ContractID: rec.ContractID,
Name: abi.Contract,
Version: abi.Version,
DeployedAt: rec.DeployedAt,
}
}
}
return out
}

320
node/api_relay.go Normal file
View File

@@ -0,0 +1,320 @@
package node
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"go-blockchain/blockchain"
"go-blockchain/relay"
)
// RelayConfig holds dependencies for the relay HTTP API.
type RelayConfig struct {
Mailbox *relay.Mailbox
// Send seals a message for recipientX25519PubHex and broadcasts it.
// Returns the envelope ID. nil disables POST /relay/send.
Send func(recipientPubHex string, msg []byte) (string, error)
// Broadcast publishes a pre-sealed Envelope on gossipsub and stores it in the mailbox.
// nil disables POST /relay/broadcast.
Broadcast func(env *relay.Envelope) error
// ContactRequests returns incoming contact records for the given Ed25519 pubkey.
ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error)
}
// registerRelayRoutes wires relay mailbox endpoints onto mux.
//
// POST /relay/send {recipient_pub, msg_b64}
// POST /relay/broadcast {envelope: <Envelope JSON>}
// GET /relay/inbox ?pub=<x25519hex>[&since=<unix_ts>][&limit=N]
// GET /relay/inbox/count ?pub=<x25519hex>
// DELETE /relay/inbox/{envID} ?pub=<x25519hex>
// GET /relay/contacts ?pub=<ed25519hex>
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
mux.HandleFunc("/relay/send", relaySend(rc))
mux.HandleFunc("/relay/broadcast", relayBroadcast(rc))
mux.HandleFunc("/relay/inbox/count", relayInboxCount(rc))
mux.HandleFunc("/relay/inbox/", relayInboxDelete(rc))
mux.HandleFunc("/relay/inbox", relayInboxList(rc))
mux.HandleFunc("/relay/contacts", relayContacts(rc))
}
// relayInboxList handles GET /relay/inbox?pub=<hex>[&since=<ts>][&limit=N]
func relayInboxList(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
pub := r.URL.Query().Get("pub")
if pub == "" {
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
return
}
since := int64(0)
if s := r.URL.Query().Get("since"); s != "" {
if v, err := parseInt64(s); err == nil && v > 0 {
since = v
}
}
limit := queryIntMin0(r, "limit")
if limit == 0 {
limit = 50
}
envelopes, err := rc.Mailbox.List(pub, since, limit)
if err != nil {
jsonErr(w, err, 500)
return
}
type item struct {
ID string `json:"id"`
SenderPub string `json:"sender_pub"`
RecipientPub string `json:"recipient_pub"`
FeeUT uint64 `json:"fee_ut,omitempty"`
SentAt int64 `json:"sent_at"`
SentAtHuman string `json:"sent_at_human"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
}
out := make([]item, 0, len(envelopes))
for _, env := range envelopes {
out = append(out, item{
ID: env.ID,
SenderPub: env.SenderPub,
RecipientPub: env.RecipientPub,
FeeUT: env.FeeUT,
SentAt: env.SentAt,
SentAtHuman: time.Unix(env.SentAt, 0).UTC().Format(time.RFC3339),
Nonce: env.Nonce,
Ciphertext: env.Ciphertext,
})
}
hasMore := len(out) == limit
jsonOK(w, map[string]any{
"pub": pub,
"count": len(out),
"has_more": hasMore,
"items": out,
})
}
}
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<hex>
func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
if r.Method == http.MethodGet {
relayInboxList(rc)(w, r)
return
}
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
envID := strings.TrimPrefix(r.URL.Path, "/relay/inbox/")
if envID == "" {
jsonErr(w, fmt.Errorf("envelope ID required in path"), 400)
return
}
pub := r.URL.Query().Get("pub")
if pub == "" {
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
return
}
if err := rc.Mailbox.Delete(pub, envID); err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]string{"id": envID, "status": "deleted"})
}
}
// relayInboxCount handles GET /relay/inbox/count?pub=<hex>
func relayInboxCount(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
pub := r.URL.Query().Get("pub")
if pub == "" {
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
return
}
count, err := rc.Mailbox.Count(pub)
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]any{"pub": pub, "count": count})
}
}
// relaySend handles POST /relay/send
//
// Request body:
//
// {
// "recipient_pub": "<hex X25519 pub key>",
// "msg_b64": "<base64-encoded plaintext>",
// }
//
// The relay node seals the message using its own X25519 keypair and broadcasts
// it on the relay gossipsub topic. No on-chain fee is attached — delivery is
// free for light clients using this endpoint.
func relaySend(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if rc.Send == nil {
jsonErr(w, fmt.Errorf("relay send not available on this node"), 503)
return
}
var req struct {
RecipientPub string `json:"recipient_pub"`
MsgB64 string `json:"msg_b64"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
return
}
if req.RecipientPub == "" {
jsonErr(w, fmt.Errorf("recipient_pub is required"), 400)
return
}
if req.MsgB64 == "" {
jsonErr(w, fmt.Errorf("msg_b64 is required"), 400)
return
}
msg, err := decodeBase64(req.MsgB64)
if err != nil {
jsonErr(w, fmt.Errorf("msg_b64: %w", err), 400)
return
}
if len(msg) == 0 {
jsonErr(w, fmt.Errorf("msg_b64: empty message"), 400)
return
}
envID, err := rc.Send(req.RecipientPub, msg)
if err != nil {
jsonErr(w, fmt.Errorf("send failed: %w", err), 500)
return
}
jsonOK(w, map[string]string{
"id": envID,
"recipient_pub": req.RecipientPub,
"status": "sent",
})
}
}
// decodeBase64 accepts both standard and URL-safe base64.
func decodeBase64(s string) ([]byte, error) {
// Try URL-safe first (no padding required), then standard.
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return b, nil
}
return base64.StdEncoding.DecodeString(s)
}
// relayBroadcast handles POST /relay/broadcast
//
// Request body: {"envelope": <relay.Envelope JSON>}
//
// Light clients use this to publish pre-sealed envelopes without a direct
// libp2p connection. The relay node stores it in the mailbox and gossips it.
func relayBroadcast(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if rc.Broadcast == nil {
jsonErr(w, fmt.Errorf("relay broadcast not available on this node"), 503)
return
}
var req struct {
Envelope *relay.Envelope `json:"envelope"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400)
return
}
if req.Envelope == nil {
jsonErr(w, fmt.Errorf("envelope is required"), 400)
return
}
if req.Envelope.ID == "" {
jsonErr(w, fmt.Errorf("envelope.id is required"), 400)
return
}
if len(req.Envelope.Ciphertext) == 0 {
jsonErr(w, fmt.Errorf("envelope.ciphertext is required"), 400)
return
}
if err := rc.Broadcast(req.Envelope); err != nil {
jsonErr(w, fmt.Errorf("broadcast failed: %w", err), 500)
return
}
jsonOK(w, map[string]string{
"id": req.Envelope.ID,
"status": "broadcast",
})
}
}
// relayContacts handles GET /relay/contacts?pub=<ed25519hex>
//
// Returns all incoming contact requests for the given Ed25519 public key.
func relayContacts(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if rc.ContactRequests == nil {
jsonErr(w, fmt.Errorf("contacts not available on this node"), 503)
return
}
pub := r.URL.Query().Get("pub")
if pub == "" {
jsonErr(w, fmt.Errorf("pub parameter required"), 400)
return
}
contacts, err := rc.ContactRequests(pub)
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]any{
"pub": pub,
"count": len(contacts),
"contacts": contacts,
})
}
}
// parseInt64 parses a string as int64.
func parseInt64(s string) (int64, error) {
var v int64
if err := json.Unmarshal([]byte(s), &v); err != nil {
return 0, err
}
return v, nil
}

270
node/api_routes.go Normal file
View File

@@ -0,0 +1,270 @@
// Package node - chain explorer HTTP API and minimal web UI.
package node
import (
"net/http"
"go-blockchain/blockchain"
)
// ConnectedPeerRef is an entry returned by /api/peers — one currently-connected
// libp2p peer. Mirrors p2p.ConnectedConnectedPeerRef but kept in the node package
// so api routes don't pull the p2p package directly.
type ConnectedPeerRef struct {
ID string `json:"id"`
Addrs []string `json:"addrs"`
// Version is the peer's last-seen version announce (from gossipsub topic
// dchain/version/v1). Empty when the peer hasn't announced yet — either
// it's running an older binary without gossip, or it hasn't reached its
// first publish tick (up to 60s after connect).
Version *PeerVersionRef `json:"version,omitempty"`
}
// PeerVersionRef mirrors p2p.PeerVersion in a package-local type so
// api_routes doesn't import p2p directly.
type PeerVersionRef struct {
Tag string `json:"tag"`
Commit string `json:"commit"`
ProtocolVersion int `json:"protocol_version"`
Timestamp int64 `json:"timestamp"`
ReceivedAt string `json:"received_at,omitempty"`
}
// NativeContractInfo is the shape ExplorerQuery.NativeContracts returns.
// Passed from main.go (which has the blockchain package imported) to the
// well-known endpoint below so it can merge native contracts with WASM ones.
type NativeContractInfo struct {
ContractID string
ABIJson string
}
// ExplorerQuery holds all functions the explorer API needs to read chain state
// and accept new transactions.
type ExplorerQuery struct {
GetBlock func(index uint64) (*blockchain.Block, error)
GetTx func(txID string) (*blockchain.TxRecord, error)
AddressToPubKey func(addr string) (string, error)
Balance func(pubKey string) (uint64, error)
Reputation func(pubKey string) (blockchain.RepStats, error)
WalletBinding func(pubKey string) (string, error)
TxsByAddress func(pubKey string, limit, offset int) ([]*blockchain.TxRecord, error)
RecentBlocks func(limit int) ([]*blockchain.Block, error)
RecentTxs func(limit int) ([]*blockchain.TxRecord, error)
NetStats func() (blockchain.NetStats, error)
RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error)
IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error)
ValidatorSet func() ([]string, error)
SubmitTx func(tx *blockchain.Transaction) error
// ConnectedPeers (optional) returns the local libp2p view of currently
// connected peers. Used by /api/peers and /api/network-info so new nodes
// can bootstrap from any existing peer's view of the network. May be nil
// if the node binary is built without p2p (tests).
ConnectedPeers func() []ConnectedPeerRef
// ChainID (optional) returns a stable identifier for this chain so a
// joiner can sanity-check it's syncing from the right network.
ChainID func() string
// NativeContracts (optional) returns the list of built-in Go contracts
// registered on this node. These appear in /api/well-known-contracts
// alongside WASM contracts, so the client doesn't need to distinguish.
NativeContracts func() []NativeContractInfo
GetContract func(contractID string) (*blockchain.ContractRecord, error)
GetContracts func() ([]blockchain.ContractRecord, error)
GetContractState func(contractID, key string) ([]byte, error)
GetContractLogs func(contractID string, limit int) ([]blockchain.ContractLogEntry, error)
Stake func(pubKey string) (uint64, error)
GetToken func(tokenID string) (*blockchain.TokenRecord, error)
GetTokens func() ([]blockchain.TokenRecord, error)
TokenBalance func(tokenID, pubKey string) (uint64, error)
GetNFT func(nftID string) (*blockchain.NFTRecord, error)
GetNFTs func() ([]blockchain.NFTRecord, error)
NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error)
// Channel group-messaging lookups (R1). GetChannel returns metadata;
// GetChannelMembers returns the Ed25519 pubkey of every current member.
// Both may be nil on nodes that don't expose channel state (tests).
GetChannel func(channelID string) (*blockchain.CreateChannelPayload, error)
GetChannelMembers func(channelID string) ([]string, error)
// Events is the SSE hub for the live event stream. Optional — if nil the
// /api/events endpoint returns 501 Not Implemented.
Events *SSEHub
// WS is the websocket hub for low-latency push to mobile/desktop clients.
// Optional — if nil the /api/ws endpoint returns 501.
WS *WSHub
}
// ExplorerRouteFlags toggles the optional HTML frontend surfaces. API
// endpoints (/api/*) always register — these flags only affect static pages.
type ExplorerRouteFlags struct {
// DisableUI suppresses the embedded block explorer at `/`, `/address`,
// `/tx`, `/node`, `/relays`, `/validators`, `/contract`, `/tokens`,
// `/token`, and their `/assets/explorer/*.js|css` dependencies. Useful
// for JSON-API-only deployments (headless nodes, mobile-backend nodes).
DisableUI bool
// DisableSwagger suppresses `/swagger` and `/swagger/openapi.json`.
// Useful for hardened private deployments where even API documentation
// shouldn't be exposed. Does NOT affect the JSON API itself.
DisableSwagger bool
}
// RegisterExplorerRoutes adds all explorer API, chain API and docs routes to mux.
// The variadic flags parameter is optional — passing none (or an empty struct)
// registers the full surface (UI + Swagger + JSON API) for backwards compatibility.
func RegisterExplorerRoutes(mux *http.ServeMux, q ExplorerQuery, flags ...ExplorerRouteFlags) {
var f ExplorerRouteFlags
if len(flags) > 0 {
f = flags[0]
}
if !f.DisableUI {
registerExplorerPages(mux)
}
registerExplorerAPI(mux, q)
registerChainAPI(mux, q)
registerContractAPI(mux, q)
registerWellKnownAPI(mux, q)
registerWellKnownVersionAPI(mux, q)
registerUpdateCheckAPI(mux, q)
registerOnboardingAPI(mux, q)
registerTokenAPI(mux, q)
registerChannelAPI(mux, q)
if !f.DisableSwagger {
registerSwaggerRoutes(mux)
}
}
// RegisterRelayRoutes adds relay mailbox HTTP endpoints to mux.
// Call this after RegisterExplorerRoutes with the relay config.
func RegisterRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
if rc.Mailbox == nil {
return
}
registerRelayRoutes(mux, rc)
}
func registerExplorerPages(mux *http.ServeMux) {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
serveExplorerIndex(w, r)
})
mux.HandleFunc("/address", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/address" {
http.NotFound(w, r)
return
}
serveExplorerAddressPage(w, r)
})
mux.HandleFunc("/tx", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tx" {
http.NotFound(w, r)
return
}
serveExplorerTxPage(w, r)
})
mux.HandleFunc("/node", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/node" {
http.NotFound(w, r)
return
}
serveExplorerNodePage(w, r)
})
mux.HandleFunc("/relays", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/relays" {
http.NotFound(w, r)
return
}
serveExplorerRelaysPage(w, r)
})
mux.HandleFunc("/validators", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/validators" {
http.NotFound(w, r)
return
}
serveExplorerValidatorsPage(w, r)
})
mux.HandleFunc("/assets/explorer/style.css", serveExplorerCSS)
mux.HandleFunc("/assets/explorer/common.js", serveExplorerCommonJS)
mux.HandleFunc("/assets/explorer/app.js", serveExplorerJS)
mux.HandleFunc("/assets/explorer/address.js", serveExplorerAddressJS)
mux.HandleFunc("/assets/explorer/tx.js", serveExplorerTxJS)
mux.HandleFunc("/assets/explorer/node.js", serveExplorerNodeJS)
mux.HandleFunc("/assets/explorer/relays.js", serveExplorerRelaysJS)
mux.HandleFunc("/assets/explorer/validators.js", serveExplorerValidatorsJS)
mux.HandleFunc("/contract", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/contract" {
http.NotFound(w, r)
return
}
serveExplorerContractPage(w, r)
})
mux.HandleFunc("/assets/explorer/contract.js", serveExplorerContractJS)
mux.HandleFunc("/tokens", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tokens" {
http.NotFound(w, r)
return
}
serveExplorerTokensPage(w, r)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/token" {
http.NotFound(w, r)
return
}
serveExplorerTokenPage(w, r)
})
mux.HandleFunc("/assets/explorer/tokens.js", serveExplorerTokensJS)
mux.HandleFunc("/assets/explorer/token.js", serveExplorerTokenJS)
}
func registerExplorerAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/netstats", apiNetStats(q))
mux.HandleFunc("/api/blocks", apiRecentBlocks(q))
mux.HandleFunc("/api/txs/recent", apiRecentTxs(q)) // GET /api/txs/recent?limit=20
mux.HandleFunc("/api/block/", apiBlock(q)) // GET /api/block/{index}
mux.HandleFunc("/api/tx/", apiTxByID(q)) // GET /api/tx/{txid}
mux.HandleFunc("/api/address/", apiAddress(q)) // GET /api/address/{addr}
mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...}
mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays
mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr}
mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators
mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate)
// Live event stream (SSE) — GET /api/events
mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
if q.Events == nil {
http.Error(w, "event stream not available", http.StatusNotImplemented)
return
}
q.Events.ServeHTTP(w, r)
})
// WebSocket gateway — GET /api/ws (upgrades to ws://). Low-latency push
// for clients that would otherwise poll balance/inbox/contacts.
mux.HandleFunc("/api/ws", func(w http.ResponseWriter, r *http.Request) {
if q.WS == nil {
http.Error(w, "websocket not available", http.StatusNotImplemented)
return
}
q.WS.ServeHTTP(w, r)
})
// Prometheus exposition endpoint — scraped by a Prometheus server. Has
// no auth; operators running this in public should put the node behind
// a reverse proxy that restricts /metrics to trusted scrapers only.
mux.HandleFunc("/metrics", metricsHandler)
}
func registerTokenAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/tokens", apiTokens(q)) // GET /api/tokens
mux.HandleFunc("/api/tokens/", apiTokenByID(q)) // GET /api/tokens/{id} and /api/tokens/{id}/balance/{pubkey}
mux.HandleFunc("/api/nfts", apiNFTs(q)) // GET /api/nfts
mux.HandleFunc("/api/nfts/", apiNFTByID(q)) // GET /api/nfts/{id} and /api/nfts/owner/{pubkey}
}
func registerChainAPI(mux *http.ServeMux, q ExplorerQuery) {
// Similar to blockchain APIs, but only with features supported by this chain.
mux.HandleFunc("/v2/chain/accounts/", apiV2ChainAccountTransactions(q)) // GET /v2/chain/accounts/{account_id}/transactions
mux.HandleFunc("/v2/chain/transactions/", apiV2ChainTxByID(q)) // GET /v2/chain/transactions/{tx_id}
mux.HandleFunc("/v2/chain/transactions/draft", apiV2ChainDraftTx()) // POST /v2/chain/transactions/draft
mux.HandleFunc("/v2/chain/transactions", withWriteTokenGuard(withSubmitTxGuards(apiV2ChainSendTx(q)))) // POST /v2/chain/transactions (body size + rate limit + optional token gate)
}

183
node/api_tokens.go Normal file
View File

@@ -0,0 +1,183 @@
package node
import (
"fmt"
"net/http"
"strings"
"go-blockchain/blockchain"
"go-blockchain/wallet"
)
// GET /api/nfts — list all NFTs.
func apiNFTs(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if q.GetNFTs == nil {
jsonErr(w, fmt.Errorf("NFT query not available"), 503)
return
}
nfts, err := q.GetNFTs()
if err != nil {
jsonErr(w, err, 500)
return
}
if nfts == nil {
nfts = []blockchain.NFTRecord{}
}
jsonOK(w, map[string]any{
"count": len(nfts),
"nfts": nfts,
})
}
}
// GET /api/nfts/{id} — single NFT metadata
// GET /api/nfts/owner/{pubkey} — NFTs owned by address
func apiNFTByID(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/nfts/")
parts := strings.SplitN(path, "/", 2)
// /api/nfts/owner/{pubkey}
if parts[0] == "owner" {
if len(parts) < 2 || parts[1] == "" {
jsonErr(w, fmt.Errorf("pubkey required"), 400)
return
}
pubKey := parts[1]
if strings.HasPrefix(pubKey, "DC") && q.AddressToPubKey != nil {
if pk, err := q.AddressToPubKey(pubKey); err == nil && pk != "" {
pubKey = pk
}
}
if q.NFTsByOwner == nil {
jsonErr(w, fmt.Errorf("NFT query not available"), 503)
return
}
nfts, err := q.NFTsByOwner(pubKey)
if err != nil {
jsonErr(w, err, 500)
return
}
if nfts == nil {
nfts = []blockchain.NFTRecord{}
}
jsonOK(w, map[string]any{"count": len(nfts), "nfts": nfts})
return
}
// /api/nfts/{id}
nftID := parts[0]
if nftID == "" {
jsonErr(w, fmt.Errorf("NFT ID required"), 400)
return
}
if q.GetNFT == nil {
jsonErr(w, fmt.Errorf("NFT query not available"), 503)
return
}
rec, err := q.GetNFT(nftID)
if err != nil {
jsonErr(w, err, 500)
return
}
if rec == nil {
jsonErr(w, fmt.Errorf("NFT %s not found", nftID), 404)
return
}
// Attach owner address for convenience.
ownerAddr := ""
if rec.Owner != "" {
ownerAddr = wallet.PubKeyToAddress(rec.Owner)
}
jsonOK(w, map[string]any{
"nft": rec,
"owner_address": ownerAddr,
})
}
}
// GET /api/tokens — list all issued tokens.
func apiTokens(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if q.GetTokens == nil {
jsonErr(w, fmt.Errorf("token query not available"), 503)
return
}
tokens, err := q.GetTokens()
if err != nil {
jsonErr(w, err, 500)
return
}
if tokens == nil {
tokens = []blockchain.TokenRecord{}
}
jsonOK(w, map[string]any{
"count": len(tokens),
"tokens": tokens,
})
}
}
// GET /api/tokens/{id} — token metadata
// GET /api/tokens/{id}/balance/{pub} — token balance for a public key or DC address
func apiTokenByID(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/tokens/")
parts := strings.SplitN(path, "/", 3)
tokenID := parts[0]
if tokenID == "" {
jsonErr(w, fmt.Errorf("token ID required"), 400)
return
}
// /api/tokens/{id}/balance/{pub}
if len(parts) == 3 && parts[1] == "balance" {
pubOrAddr := parts[2]
if pubOrAddr == "" {
jsonErr(w, fmt.Errorf("pubkey or address required"), 400)
return
}
// Resolve DC address to pubkey if needed.
pubKey := pubOrAddr
if strings.HasPrefix(pubOrAddr, "DC") && q.AddressToPubKey != nil {
if pk, err := q.AddressToPubKey(pubOrAddr); err == nil && pk != "" {
pubKey = pk
}
}
if q.TokenBalance == nil {
jsonErr(w, fmt.Errorf("token balance query not available"), 503)
return
}
bal, err := q.TokenBalance(tokenID, pubKey)
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]any{
"token_id": tokenID,
"pub_key": pubKey,
"address": wallet.PubKeyToAddress(pubKey),
"balance": bal,
})
return
}
// /api/tokens/{id}
if q.GetToken == nil {
jsonErr(w, fmt.Errorf("token query not available"), 503)
return
}
rec, err := q.GetToken(tokenID)
if err != nil {
jsonErr(w, err, 500)
return
}
if rec == nil {
jsonErr(w, fmt.Errorf("token %s not found", tokenID), 404)
return
}
jsonOK(w, rec)
}
}

201
node/api_update_check.go Normal file
View File

@@ -0,0 +1,201 @@
// Package node — /api/update-check endpoint.
//
// What this does
// ──────────────
// Polls the configured release source (typically a Gitea `/api/v1/repos/
// {owner}/{repo}/releases/latest` URL) and compares its answer with the
// binary's own build-time version. Returns:
//
// {
// "current": { "tag": "v0.5.0", "commit": "abc1234", "date": "..." },
// "latest": { "tag": "v0.5.1", "commit": "def5678", "url": "...", "published_at": "..." },
// "update_available": true,
// "checked_at": "2026-04-17T09:45:12Z"
// }
//
// When unconfigured (empty DCHAIN_UPDATE_SOURCE_URL), responds 503 with a
// hint pointing at the env var. Never blocks for more than ~5 seconds
// (HTTP timeout); upstream failures are logged and surfaced as 502.
//
// Cache
// ─────
// Hammering a public Gitea instance every time an operator curls this
// endpoint would be rude. We cache the latest-release lookup for 15 minutes
// in memory. The update script in deploy/single/update.sh honors this cache
// by reading /api/update-check once per run — so a typical hourly timer +
// 15-min cache means at most 4 upstream hits per node per hour, usually 1.
//
// Configuration
// ─────────────
// DCHAIN_UPDATE_SOURCE_URL — full URL of the Gitea latest-release API.
// Example:
// https://gitea.example.com/api/v1/repos/dchain/dchain/releases/latest
// DCHAIN_UPDATE_SOURCE_TOKEN — optional Gitea PAT for private repos.
//
// These are read by cmd/node/main.go and handed to SetUpdateSource below.
package node
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"go-blockchain/node/version"
)
const (
updateCacheTTL = 15 * time.Minute
updateTimeout = 5 * time.Second
)
var (
updateMu sync.RWMutex
updateSourceURL string
updateSourceToken string
updateCache *updateCheckResponse
updateCacheAt time.Time
)
// SetUpdateSource configures where /api/update-check should poll. Called
// once at node startup from cmd/node/main.go after flag parsing. Empty url
// leaves the endpoint in "unconfigured" mode (503).
func SetUpdateSource(url, token string) {
updateMu.Lock()
defer updateMu.Unlock()
updateSourceURL = url
updateSourceToken = token
// Clear cache on config change (mostly a test-time concern).
updateCache = nil
updateCacheAt = time.Time{}
}
// giteaRelease is the subset of the Gitea release JSON we care about.
// Gitea's schema is a superset of GitHub's, so the same shape works for
// github.com/api/v3 too — operators can point at either.
type giteaRelease struct {
TagName string `json:"tag_name"`
TargetCommit string `json:"target_commitish"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
type updateCheckResponse struct {
Current map[string]string `json:"current"`
Latest *latestRef `json:"latest,omitempty"`
UpdateAvailable bool `json:"update_available"`
CheckedAt string `json:"checked_at"`
Source string `json:"source,omitempty"`
}
type latestRef struct {
Tag string `json:"tag"`
Commit string `json:"commit,omitempty"`
URL string `json:"url,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
}
func registerUpdateCheckAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/update-check", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
updateMu.RLock()
src := updateSourceURL
tok := updateSourceToken
cached := updateCache
cachedAt := updateCacheAt
updateMu.RUnlock()
if src == "" {
jsonErr(w,
fmt.Errorf("update source not configured — set DCHAIN_UPDATE_SOURCE_URL to a Gitea /api/v1/repos/{owner}/{repo}/releases/latest URL"),
http.StatusServiceUnavailable)
return
}
// Serve fresh cache without hitting upstream.
if cached != nil && time.Since(cachedAt) < updateCacheTTL {
jsonOK(w, cached)
return
}
// Fetch upstream.
latest, err := fetchLatestRelease(r.Context(), src, tok)
if err != nil {
jsonErr(w, fmt.Errorf("upstream check failed: %w", err), http.StatusBadGateway)
return
}
resp := &updateCheckResponse{
Current: version.Info(),
CheckedAt: time.Now().UTC().Format(time.RFC3339),
Source: src,
}
if latest != nil {
resp.Latest = &latestRef{
Tag: latest.TagName,
Commit: latest.TargetCommit,
URL: latest.HTMLURL,
PublishedAt: latest.PublishedAt,
}
resp.UpdateAvailable = latest.TagName != "" &&
latest.TagName != version.Tag &&
!latest.Draft &&
!latest.Prerelease
}
updateMu.Lock()
updateCache = resp
updateCacheAt = time.Now()
updateMu.Unlock()
jsonOK(w, resp)
})
}
// fetchLatestRelease performs the actual HTTP call with a short timeout.
// Returns nil, nil when the upstream replies 404 (no releases yet) — this
// is not an error condition, the operator just hasn't published anything
// yet. All other non-2xx are returned as errors.
func fetchLatestRelease(ctx interface{ Deadline() (time.Time, bool) }, url, token string) (*giteaRelease, error) {
// We only use ctx for cancellation type-matching; the actual deadline
// comes from updateTimeout below. Tests pass a context.Background().
_ = ctx
client := &http.Client{Timeout: updateTimeout}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "dchain-node/"+version.Tag)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil // no releases yet
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(b))
}
var rel giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &rel, nil
}

116
node/api_well_known.go Normal file
View File

@@ -0,0 +1,116 @@
// Package node — /api/well-known-contracts endpoint.
//
// This endpoint lets a freshly-launched client auto-discover the canonical
// contract IDs for system services (username registry, governance, …) without
// the user having to paste contract IDs into settings by hand.
//
// Discovery strategy:
//
// 1. List all deployed contracts via q.GetContracts().
// 2. For each contract, parse its ABI JSON and pull the "contract" name field.
// 3. For each distinct name, keep the **earliest-deployed** record — this is
// the canonical one. Operators who want to override this (e.g. migrate to
// a new registry) will add pinning support via config later; for the MVP
// "earliest wins" matches what all nodes see because the chain is ordered.
//
// The response shape is stable JSON so the client can rely on it:
//
// {
// "count": 3,
// "contracts": {
// "username_registry": { "contract_id": "…", "name": "username_registry", "version": "1.0.0", "deployed_at": 42 },
// "governance": { "contract_id": "…", "name": "governance", "version": "0.9.0", "deployed_at": 50 },
// …
// }
// }
package node
import (
"encoding/json"
"fmt"
"net/http"
)
// WellKnownContract is the per-entry payload returned in /api/well-known-contracts.
type WellKnownContract struct {
ContractID string `json:"contract_id"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
DeployedAt uint64 `json:"deployed_at"`
}
// abiHeader is the minimal subset of a contract's ABI JSON we need to look at.
type abiHeader struct {
Contract string `json:"contract"`
Version string `json:"version"`
}
func registerWellKnownAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/well-known-contracts", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
if q.GetContracts == nil {
jsonErr(w, fmt.Errorf("contract queries not available on this node"), 503)
return
}
all, err := q.GetContracts()
if err != nil {
jsonErr(w, err, 500)
return
}
out := map[string]WellKnownContract{}
// WASM contracts (stored as ContractRecord in BadgerDB).
for _, rec := range all {
if rec.ABIJson == "" {
continue
}
var abi abiHeader
if err := json.Unmarshal([]byte(rec.ABIJson), &abi); err != nil {
continue
}
if abi.Contract == "" {
continue
}
existing, ok := out[abi.Contract]
if !ok || rec.DeployedAt < existing.DeployedAt {
out[abi.Contract] = WellKnownContract{
ContractID: rec.ContractID,
Name: abi.Contract,
Version: abi.Version,
DeployedAt: rec.DeployedAt,
}
}
}
// Native (in-process Go) contracts. These always win over WASM
// equivalents of the same ABI name — the native implementation is
// authoritative because every node runs identical Go code, while a
// WASM copy might drift (different build, different bytecode).
if q.NativeContracts != nil {
for _, nc := range q.NativeContracts() {
var abi abiHeader
if err := json.Unmarshal([]byte(nc.ABIJson), &abi); err != nil {
continue
}
if abi.Contract == "" {
continue
}
out[abi.Contract] = WellKnownContract{
ContractID: nc.ContractID,
Name: abi.Contract,
Version: abi.Version,
DeployedAt: 0, // native contracts exist from block 0
}
}
}
jsonOK(w, map[string]any{
"count": len(out),
"contracts": out,
})
})
}

View File

@@ -0,0 +1,93 @@
// Package node — /api/well-known-version endpoint.
//
// Clients hit this to feature-detect the node they're talking to, without
// hardcoding "node >= 0.5 supports channels" into every screen. The response
// lists three coordinates a client cares about:
//
// - node_version — human-readable build tag (ldflags-injectable)
// - protocol_version — integer bumped only on wire-protocol breaking changes
// - features — stable string tags for "this binary implements X"
//
// Feature tags are ADDITIVE — once a tag ships in a release, it keeps being
// returned forever (even if the implementation moves around internally). The
// client uses them as "is this feature here or not?", not "what version is
// this feature at?". Versioning a feature is done by shipping a new tag
// (e.g. "channels_v2" alongside "channels_v1" for a deprecation window).
//
// Response shape:
//
// {
// "node_version": "0.5.0-dev",
// "protocol_version": 1,
// "features": [
// "channels_v1",
// "fan_out",
// "native_username_registry",
// "ws_submit_tx",
// "access_token"
// ],
// "chain_id": "dchain-ddb9a7e37fc8"
// }
package node
import (
"fmt"
"net/http"
"sort"
"go-blockchain/node/version"
)
// ProtocolVersion is bumped only when the wire-protocol changes in a way
// that a client compiled against version N cannot talk to a node at
// version N+1 (or vice versa) without updating. Adding new optional fields,
// new EventTypes, new WS ops, new HTTP endpoints — none of those bump this.
//
// Bumping this means a coordinated release: every client and every node
// operator must update before the old version stops working.
const ProtocolVersion = 1
// nodeFeatures is the baked-in list of feature tags this binary implements.
// Append-only. When you add a new tag, add it here AND document what it means
// so clients can feature-detect reliably.
//
// Naming convention: snake_case, versioned suffix for anything that might get
// a breaking successor (e.g. `channels_v1`, not `channels`).
var nodeFeatures = []string{
"access_token", // DCHAIN_API_TOKEN gating on writes (+ optional reads)
"channels_v1", // /api/channels/:id + /members with X25519 enrichment
"chain_id", // /api/network-info returns chain_id
"contract_logs", // /api/contract/:id/logs endpoint
"fan_out", // client-side per-recipient envelope sealing
"identity_registry", // /api/identity/:pub returns X25519 pub + relay hints
"native_username_registry", // native:username_registry contract
"onboarding_api", // /api/network-info for joiner bootstrap
"payment_channels", // off-chain payment channel open/close
"relay_mailbox", // /relay/send + /relay/inbox
"ws_submit_tx", // WebSocket submit_tx op
}
func registerWellKnownVersionAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/well-known-version", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, fmt.Errorf("method not allowed"), 405)
return
}
// Return a copy so callers can't mutate the shared slice.
feats := make([]string, len(nodeFeatures))
copy(feats, nodeFeatures)
sort.Strings(feats)
resp := map[string]any{
"node_version": version.Tag,
"build": version.Info(),
"protocol_version": ProtocolVersion,
"features": feats,
}
// Include chain_id if the node exposes it (same helper as network-info).
if q.ChainID != nil {
resp["chain_id"] = q.ChainID()
}
jsonOK(w, resp)
})
}

119
node/events.go Normal file
View File

@@ -0,0 +1,119 @@
// Package node — unified event bus for SSE, WebSocket, and any future
// subscriber of block / tx / contract-log / inbox events.
//
// Before this file, emit code was duplicated at every commit callsite:
//
// go sseHub.EmitBlockWithTxs(b)
// go wsHub.EmitBlockWithTxs(b)
// go emitContractLogs(sseHub, wsHub, chain, b)
//
// With the bus, callers do one thing:
//
// go bus.EmitBlockWithTxs(b)
//
// Adding a new subscriber (metrics sampler, WAL replicator, IPFS mirror…)
// means registering once at startup — no edits at every call site.
package node
import (
"encoding/json"
"go-blockchain/blockchain"
)
// EventConsumer is what the bus calls for each event. Implementations are
// registered once at startup via Bus.Register; fanout happens inside
// Emit* methods.
//
// Methods may be called from multiple goroutines concurrently — consumers
// must be safe for concurrent use.
type EventConsumer interface {
OnBlock(*blockchain.Block)
OnTx(*blockchain.Transaction)
OnContractLog(blockchain.ContractLogEntry)
OnInbox(recipientX25519 string, summary json.RawMessage)
}
// EventBus fans events out to every registered consumer. Zero value is a
// valid empty bus (Emit* are no-ops until someone Register()s).
type EventBus struct {
consumers []EventConsumer
}
// NewEventBus returns a fresh bus with no consumers.
func NewEventBus() *EventBus { return &EventBus{} }
// Register appends a consumer. Not thread-safe — call once at startup
// before any Emit* is invoked.
func (b *EventBus) Register(c EventConsumer) {
b.consumers = append(b.consumers, c)
}
// EmitBlock notifies every consumer of a freshly-committed block.
// Does NOT iterate transactions — use EmitBlockWithTxs for that.
func (b *EventBus) EmitBlock(blk *blockchain.Block) {
for _, c := range b.consumers {
c.OnBlock(blk)
}
}
// EmitTx notifies every consumer of a single committed transaction.
// Synthetic BLOCK_REWARD records are skipped by the implementations that
// care (SSE already filters); the bus itself doesn't second-guess.
func (b *EventBus) EmitTx(tx *blockchain.Transaction) {
for _, c := range b.consumers {
c.OnTx(tx)
}
}
// EmitContractLog notifies every consumer of a contract log entry.
func (b *EventBus) EmitContractLog(entry blockchain.ContractLogEntry) {
for _, c := range b.consumers {
c.OnContractLog(entry)
}
}
// EmitInbox notifies every consumer of a new relay envelope stored for
// the given recipient. Summary is the minimal JSON the WS gateway ships
// to subscribers so the client can refresh on push instead of polling.
func (b *EventBus) EmitInbox(recipientX25519 string, summary json.RawMessage) {
for _, c := range b.consumers {
c.OnInbox(recipientX25519, summary)
}
}
// EmitBlockWithTxs is the common path invoked on commit: one block +
// every tx in it, so each consumer can index/fan out appropriately.
func (b *EventBus) EmitBlockWithTxs(blk *blockchain.Block) {
b.EmitBlock(blk)
for _, tx := range blk.Transactions {
b.EmitTx(tx)
}
}
// ─── Adapter: wrap the existing SSEHub in an EventConsumer ──────────────────
type sseEventAdapter struct{ h *SSEHub }
func (a sseEventAdapter) OnBlock(b *blockchain.Block) { a.h.EmitBlock(b) }
func (a sseEventAdapter) OnTx(tx *blockchain.Transaction) { a.h.EmitTx(tx) }
func (a sseEventAdapter) OnContractLog(e blockchain.ContractLogEntry) { a.h.EmitContractLog(e) }
// SSE has no inbox topic today — the existing hub doesn't expose one. The
// adapter silently drops it; when we add an inbox SSE event, this is the
// one place that needs an update.
func (a sseEventAdapter) OnInbox(string, json.RawMessage) {}
// WrapSSE converts an SSEHub into an EventConsumer for the bus.
func WrapSSE(h *SSEHub) EventConsumer { return sseEventAdapter{h} }
// ─── Adapter: wrap the WSHub ─────────────────────────────────────────────────
type wsEventAdapter struct{ h *WSHub }
func (a wsEventAdapter) OnBlock(b *blockchain.Block) { a.h.EmitBlock(b) }
func (a wsEventAdapter) OnTx(tx *blockchain.Transaction) { a.h.EmitTx(tx) }
func (a wsEventAdapter) OnContractLog(e blockchain.ContractLogEntry) { a.h.EmitContractLog(e) }
func (a wsEventAdapter) OnInbox(to string, sum json.RawMessage) { a.h.EmitInbox(to, sum) }
// WrapWS converts a WSHub into an EventConsumer for the bus.
func WrapWS(h *WSHub) EventConsumer { return wsEventAdapter{h} }

224
node/explorer/address.html Normal file
View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wallet | DChain Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/explorer/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script defer src="/assets/explorer/common.js"></script>
<script defer src="/assets/explorer/address.js"></script>
</head>
<body>
<!-- top nav -->
<header class="topnav">
<div class="topnav-inner">
<a class="topnav-brand" href="/">
<span class="brand-gem"></span>
<span class="brand-name">DChain</span>
</a>
<nav class="topnav-links">
<a href="/contract">Contracts</a>
<a href="/tokens">Tokens</a>
<a href="/validators">Validators</a>
<a href="/relays">Relay Nodes</a>
</nav>
</div>
</header>
<!-- search bar (collapsed under nav) -->
<div class="addr-searchbar">
<div class="addr-searchbar-inner">
<div class="hero-search" style="max-width:640px">
<i data-lucide="search" class="hero-search-icon"></i>
<input id="addressInput" type="text" placeholder="Enter address (DC…) or public key…">
<button id="addressBtn" class="btn-hero">Load</button>
</div>
<div id="status" class="hero-status"></div>
</div>
</div>
<!-- empty state (shown when no address loaded, toggled by JS) -->
<div id="emptyState" class="page-body" style="display:none">
<div class="addr-empty-state">
<div class="addr-empty-icon"><i data-lucide="wallet"></i></div>
<div class="addr-empty-title">Search for a wallet</div>
<div class="addr-empty-sub">Enter a DC address (e.g. <span class="mono" style="color:var(--accent)">DC1abc…</span>) or a 64-character hex public key above to view balance and transaction history.</div>
</div>
</div>
<main class="page-body" id="mainContent" style="display:none">
<!-- ── Error banner (hidden unless load failed) ──────────────────────── -->
<div id="errorBanner" class="addr-error-banner" style="display:none">
<div class="addr-error-icon"><i data-lucide="alert-circle"></i></div>
<div class="addr-error-body">
<div class="addr-error-title">Could not load wallet</div>
<div class="addr-error-msg" id="errorMsg">Unknown error</div>
</div>
</div>
<!-- ── Profile card (hidden on error) ───────────────────────────────── -->
<div id="profileSection">
<div class="addr-profile-card">
<!-- left: avatar + name + badges -->
<div class="addr-profile-left">
<div class="addr-avatar" id="addrAvatar"></div>
<div class="addr-profile-info">
<div class="addr-name" id="addrNickname">Unknown Wallet</div>
<div class="addr-badges" id="addrBadges"><!-- filled by JS --></div>
</div>
</div>
<!-- right: balance -->
<div class="addr-profile-right">
<div class="addr-bal-label">Balance</div>
<div class="addr-bal-val" id="walletBalance"></div>
<div class="addr-bal-sub" id="walletBalanceSub"></div>
</div>
</div>
<!-- ── Identity details grid ─────────────────────────────────────────── -->
<div class="panel addr-detail-panel">
<div class="addr-kv-list">
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="at-sign"></i> Address</div>
<div class="addr-kv-val">
<span class="mono" id="walletAddress"></span>
<button class="copy-btn" data-copy-id="walletAddress" title="Copy"><i data-lucide="copy"></i></button>
</div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="key-round"></i> Public Key</div>
<div class="addr-kv-val">
<span class="mono addr-pubkey-text" id="walletPubKey"></span>
<button class="copy-btn" data-copy-id="walletPubKey" title="Copy"><i data-lucide="copy"></i></button>
</div>
</div>
<div class="addr-kv-row" id="x25519Row" style="display:none">
<div class="addr-kv-key"><i data-lucide="message-square-lock"></i> Messaging Key (X25519)</div>
<div class="addr-kv-val">
<span class="mono addr-pubkey-text" id="walletX25519"></span>
<button class="copy-btn" data-copy-id="walletX25519" title="Copy"><i data-lucide="copy"></i></button>
</div>
</div>
<div class="addr-kv-row" id="nodeLinkRow" style="display:none">
<div class="addr-kv-key"><i data-lucide="server"></i> Node Page</div>
<div class="addr-kv-val"><a id="nodePageLink" href="#">View node stats →</a></div>
</div>
<div class="addr-kv-row" id="walletBindingRow" style="display:none">
<div class="addr-kv-key"><i data-lucide="wallet"></i> Bound Wallet</div>
<div class="addr-kv-val"><a id="walletBindingLink" href="#"></a></div>
</div>
</div>
</div>
<!-- ── Node stats (shown only for validators / relay nodes) ─────────── -->
<div class="panel addr-node-panel" id="nodeStatsPanel" style="display:none">
<h2><i data-lucide="server"></i> Node Stats</h2>
<div class="addr-node-grid">
<div class="addr-node-cell">
<div class="addr-node-label">Blocks Produced</div>
<div class="addr-node-val" id="nodeBlocks"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Relay Proofs</div>
<div class="addr-node-val" id="nodeRelayProofs"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Reputation</div>
<div class="addr-node-val" id="nodeRepScore"></div>
<div class="addr-node-sub" id="nodeRepRank"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Heartbeats</div>
<div class="addr-node-val" id="nodeHeartbeats"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Slashes</div>
<div class="addr-node-val" id="nodeSlashes"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Recent Rewards</div>
<div class="addr-node-val" id="nodeRecentRewards"></div>
<div class="addr-node-sub" id="nodeRecentWindow"></div>
</div>
</div>
</div>
<!-- ── Token Balances ───────────────────────────────────────────────── -->
<div class="panel" id="tokenBalPanel" style="display:none">
<div class="addr-hist-head">
<h2><i data-lucide="coins"></i> Token Balances</h2>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Symbol</th>
<th>Name</th>
<th style="text-align:right">Balance</th>
<th style="width:90px"></th>
</tr>
</thead>
<tbody id="tokenBalBody"></tbody>
</table>
</div>
</div>
<!-- ── NFT Collection ────────────────────────────────────────────────── -->
<div class="panel" id="nftPanel" style="display:none">
<div class="addr-hist-head">
<h2><i data-lucide="image"></i> NFTs</h2>
<span class="addr-hist-count" id="nftOwnedCount"></span>
</div>
<div id="addrNFTGrid" class="nft-grid" style="padding:1rem"></div>
</div>
<!-- ── Transaction History ───────────────────────────────────────────── -->
<div class="panel">
<div class="addr-hist-head">
<h2><i data-lucide="history"></i> History</h2>
<span class="addr-hist-count" id="walletTxCount"></span>
</div>
<div class="table-wrap">
<table id="walletTxTable">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>From → To</th>
<th>Memo / Block</th>
<th style="text-align:right">Amount</th>
</tr>
</thead>
<tbody id="walletTxBody">
<tr><td colspan="5" class="tbl-empty">No transactions yet.</td></tr>
</tbody>
</table>
</div>
<div class="addr-load-more">
<button id="loadMoreBtn" class="btn" style="display:none">
<i data-lucide="chevron-down"></i> Load more
</button>
</div>
</div>
</div><!-- /#profileSection -->
</main>
</body>
</html>

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();
}
})();

484
node/explorer/app.js Normal file
View File

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

197
node/explorer/common.js Normal file
View File

@@ -0,0 +1,197 @@
(function() {
function esc(v) {
return String(v === undefined || v === null ? '' : v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function short(v, n) {
if (!v) return '-';
return v.length > n ? v.slice(0, n) + '...' : v;
}
function fmtTime(iso) {
if (!iso) return '-';
return iso.replace('T', ' ').replace('Z', ' UTC');
}
function timeAgo(iso) {
if (!iso) return '-';
var now = Date.now();
var ts = new Date(iso).getTime();
if (!isFinite(ts)) return '-';
var diffSec = Math.max(0, Math.floor((now - ts) / 1000));
if (diffSec < 10) return 'a few seconds ago';
if (diffSec < 60) return diffSec + ' seconds ago';
var diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return diffMin === 1 ? 'a minute ago' : diffMin + ' minutes ago';
var diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return diffHour === 1 ? 'an hour ago' : diffHour + ' hours ago';
var diffDay = Math.floor(diffHour / 24);
if (diffDay === 1) return 'yesterday';
if (diffDay < 30) return diffDay + ' days ago';
var diffMonth = Math.floor(diffDay / 30);
if (diffMonth < 12) return diffMonth === 1 ? 'a month ago' : diffMonth + ' months ago';
var diffYear = Math.floor(diffMonth / 12);
return diffYear === 1 ? 'a year ago' : diffYear + ' years ago';
}
function shortAddress(addr) {
if (!addr) return '-';
if (addr.length <= 9) return addr;
return addr.slice(0, 3) + '...' + addr.slice(-3);
}
function txLabel(eventType) {
var map = {
TRANSFER: 'Transfer',
REGISTER_KEY: 'Register',
CREATE_CHANNEL: 'Create Channel',
ADD_MEMBER: 'Add Member',
OPEN_PAY_CHAN: 'Open Channel',
CLOSE_PAY_CHAN: 'Close Channel',
RELAY_PROOF: 'Relay Proof',
BIND_WALLET: 'Bind Wallet',
SLASH: 'Slash',
HEARTBEAT: 'Heartbeat',
BLOCK_REWARD: 'Reward'
};
return map[eventType] || eventType || 'Transaction';
}
function toToken(micro) {
if (micro === undefined || micro === null) return '-';
var n = Number(micro);
if (isNaN(n)) return String(micro);
return (n / 1000000).toFixed(6) + ' T';
}
// Compact token display: 21000000 T → "21M T", 1234 T → "1.23k T"
function _fmtNum(n) {
if (n >= 100) return Math.round(n).toString();
if (n >= 10) return n.toFixed(1).replace(/\.0$/, '');
return n.toFixed(2).replace(/\.?0+$/, '');
}
function toTokenShort(micro) {
if (micro === undefined || micro === null) return '-';
var t = Number(micro) / 1000000;
if (isNaN(t)) return String(micro);
if (t >= 1e9) return _fmtNum(t / 1e9) + 'B T';
if (t >= 1e6) return _fmtNum(t / 1e6) + 'M T';
if (t >= 1e3) return _fmtNum(t / 1e3) + 'k T';
if (t >= 0.01) return _fmtNum(t) + ' T';
var ut = Number(micro);
if (ut >= 1000) return _fmtNum(ut / 1000) + 'k µT';
return ut + ' µT';
}
function isPubKey(s) {
return /^[0-9a-fA-F]{64}$/.test(s || '');
}
function setStatus(text, cls) {
var el = document.getElementById('status');
if (!el) return;
el.className = 'status' + (cls ? ' ' + cls : '');
el.textContent = text;
}
async function fetchJSON(url) {
var resp = await fetch(url);
var json = await resp.json().catch(function() { return {}; });
if (!resp.ok) {
throw new Error(json && json.error ? json.error : 'request failed');
}
if (json && json.error) {
throw new Error(json.error);
}
return json;
}
function q(name) {
return new URLSearchParams(window.location.search).get(name) || '';
}
function navAddress(address) {
window.location.href = '/address?address=' + encodeURIComponent(address);
}
function navTx(id) {
window.location.href = '/tx?id=' + encodeURIComponent(id);
}
function navNode(node) {
window.location.href = '/node?node=' + encodeURIComponent(node);
}
function refreshIcons() {
if (window.lucide && typeof window.lucide.createIcons === 'function') {
window.lucide.createIcons();
}
}
// ── SSE live event stream ─────────────────────────────────────────────────
//
// connectSSE(handlers) opens a connection to GET /api/events and dispatches
// events to the supplied handler map, e.g.:
//
// C.connectSSE({
// block: function(data) { ... },
// tx: function(data) { ... },
// contract_log: function(data) { ... },
// connected: function() { /* SSE connection established */ },
// error: function() { /* connection lost */ },
// });
//
// Returns the EventSource instance so the caller can close it if needed.
function connectSSE(handlers) {
if (!window.EventSource) return null; // browser doesn't support SSE
var es = new EventSource('/api/events');
es.addEventListener('open', function() {
if (handlers.connected) handlers.connected();
});
es.addEventListener('error', function() {
if (handlers.error) handlers.error();
});
['block', 'tx', 'contract_log'].forEach(function(type) {
if (!handlers[type]) return;
es.addEventListener(type, function(e) {
try {
var data = JSON.parse(e.data);
handlers[type](data);
} catch (_) {}
});
});
return es;
}
window.ExplorerCommon = {
esc: esc,
short: short,
fmtTime: fmtTime,
timeAgo: timeAgo,
shortAddress: shortAddress,
txLabel: txLabel,
toToken: toToken,
toTokenShort: toTokenShort,
isPubKey: isPubKey,
setStatus: setStatus,
fetchJSON: fetchJSON,
q: q,
navAddress: navAddress,
navTx: navTx,
navNode: navNode,
refreshIcons: refreshIcons,
connectSSE: connectSSE
};
refreshIcons();
})();

222
node/explorer/contract.html Normal file
View File

@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contract | DChain Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/explorer/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script defer src="/assets/explorer/common.js"></script>
<script defer src="/assets/explorer/contract.js"></script>
</head>
<body>
<header class="topnav">
<div class="topnav-inner">
<a class="topnav-brand" href="/">
<span class="brand-gem"></span>
<span class="brand-name">DChain</span>
</a>
<nav class="topnav-links">
<a href="/">Explorer</a>
<a href="/tokens">Tokens</a>
<a href="/contract" style="color:var(--text)">Contracts</a>
<a href="/validators">Validators</a>
<a href="/relays">Relay Nodes</a>
</nav>
</div>
</header>
<!-- search bar strip -->
<div class="addr-searchbar">
<div class="addr-searchbar-inner">
<div class="hero-search" style="max-width:640px">
<i data-lucide="search" class="hero-search-icon"></i>
<input id="contractInput" type="text" placeholder="Contract ID (hex)…">
<button id="contractBtn" class="btn-hero">Load</button>
</div>
<div id="status" class="hero-status"></div>
</div>
</div>
<!-- ── Contracts list (shown when no contract is selected) ──────────────── -->
<main class="page-body" id="contractsList">
<div class="panel">
<div class="panel-header">
<span><i data-lucide="code-2" style="width:16px;height:16px;vertical-align:middle;margin-right:6px"></i>Deployed Contracts</span>
<span id="contractsCount" class="text-muted" style="font-size:0.85rem"></span>
</div>
<div id="contractsEmpty" style="padding:2rem 1.25rem;color:var(--muted);font-size:0.9rem;display:none">
No contracts deployed yet.
</div>
<div class="table-wrap" id="contractsTableWrap" style="display:none">
<table>
<thead>
<tr>
<th>Contract ID</th>
<th>Deployer</th>
<th style="width:7rem">Block</th>
<th style="width:6rem">WASM</th>
</tr>
</thead>
<tbody id="contractsBody"></tbody>
</table>
</div>
<div id="contractsLoading" style="padding:2rem 1.25rem;color:var(--muted);font-size:0.9rem">
Loading…
</div>
</div>
</main>
<main class="page-body" id="mainContent" style="display:none">
<!-- ── Banner ──────────────────────────────────────────────────────────── -->
<div class="tx-banner tx-banner-ok" id="contractBanner">
<div class="tx-banner-left">
<div class="tx-banner-icon tx-banner-ok">
<i data-lucide="code-2"></i>
</div>
<div class="tx-banner-body">
<div class="tx-banner-title">Smart Contract</div>
<div class="tx-banner-desc mono" id="bannerContractId"></div>
</div>
</div>
<div class="tx-banner-time" id="bannerDeployedAt"></div>
</div>
<!-- ── Main panel ───────────────────────────────────────────────────────── -->
<div class="panel tx-overview-panel">
<!-- tab row -->
<div class="tx-tabs">
<button class="tx-tab tx-tab-active" id="tabOverview">Overview</button>
<button class="tx-tab" id="tabState">State</button>
<button class="tx-tab" id="tabLogs">Logs</button>
<button class="tx-tab" id="tabRaw">Raw JSON</button>
</div>
<!-- ── Overview ── -->
<div id="paneOverview">
<div class="addr-kv-list">
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="hash"></i> Contract ID</div>
<div class="addr-kv-val">
<span class="mono" id="contractId"></span>
<button class="copy-btn" data-copy-id="contractId" title="Copy"><i data-lucide="copy"></i></button>
</div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="user"></i> Deployer</div>
<div class="addr-kv-val">
<span id="contractDeployer"></span>
<button class="copy-btn" data-copy-id="contractDeployerRaw" title="Copy pubkey"><i data-lucide="copy"></i></button>
<span id="contractDeployerRaw" style="display:none"></span>
</div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="layers-3"></i> Deployed at block</div>
<div class="addr-kv-val"><span id="contractDeployedBlock"></span></div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="package"></i> WASM size</div>
<div class="addr-kv-val"><span id="contractWasmSize"></span></div>
</div>
</div>
<!-- ABI methods -->
<div id="abiSection" style="display:none">
<div style="padding: 0 1.25rem 0.5rem; font-size:0.8rem; font-weight:600; text-transform:uppercase; letter-spacing:0.06em; color:var(--muted)">
<i data-lucide="braces" style="width:12px;height:12px;vertical-align:middle"></i> ABI Methods
</div>
<div class="table-wrap" style="margin: 0 0 1rem">
<table>
<thead><tr><th>Method</th><th>Arguments</th></tr></thead>
<tbody id="abiMethodsBody"></tbody>
</table>
</div>
</div>
</div>
<!-- ── State browser ── -->
<div id="paneState" style="display:none">
<div style="padding:1rem 1.25rem 0.5rem">
<div class="hero-search" style="max-width:480px">
<i data-lucide="key" class="hero-search-icon"></i>
<input id="stateKeyInput" type="text" placeholder="State key (e.g. counter)…">
<button id="stateLoadBtn" class="btn-hero">Query</button>
</div>
</div>
<div id="stateResult" style="display:none;padding:0 1.25rem 1rem">
<div class="addr-kv-list" style="margin-top:0.75rem">
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="key"></i> Key</div>
<div class="addr-kv-val"><span class="mono" id="stateKey"></span></div>
</div>
<div class="addr-kv-row" id="stateU64Row" style="display:none">
<div class="addr-kv-key"><i data-lucide="hash"></i> uint64</div>
<div class="addr-kv-val"><span class="mono" id="stateU64"></span></div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="binary"></i> hex</div>
<div class="addr-kv-val">
<span class="mono addr-pubkey-text" id="stateHex"></span>
<button class="copy-btn" data-copy-id="stateHex" title="Copy"><i data-lucide="copy"></i></button>
</div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="file-text"></i> base64</div>
<div class="addr-kv-val">
<span class="mono addr-pubkey-text" id="stateB64"></span>
<button class="copy-btn" data-copy-id="stateB64" title="Copy"><i data-lucide="copy"></i></button>
</div>
</div>
<div class="addr-kv-row" id="stateNullRow" style="display:none">
<div class="addr-kv-key"><i data-lucide="circle-off"></i> Value</div>
<div class="addr-kv-val"><span class="text-muted">null (key not set)</span></div>
</div>
</div>
</div>
<div id="stateEmpty" style="padding:1.5rem 1.25rem; color:var(--muted); font-size:0.9rem">
Enter a state key and click Query.
</div>
</div>
<!-- ── Logs ── -->
<div id="paneLogs" style="display:none">
<div id="logsEmpty" style="padding:1.5rem 1.25rem; color:var(--muted); font-size:0.9rem">
No log entries yet. Logs are written by <code>env.log()</code> calls inside the contract.
</div>
<div class="table-wrap" id="logsTableWrap" style="display:none">
<table>
<thead>
<tr>
<th style="width:6rem">Block</th>
<th style="width:8rem">Tx</th>
<th style="width:3rem">#</th>
<th>Message</th>
</tr>
</thead>
<tbody id="logsBody"></tbody>
</table>
</div>
</div>
<!-- ── Raw JSON ── -->
<div id="paneRaw" style="display:none">
<pre id="contractRaw" class="raw tx-raw-pre">No data.</pre>
</div>
</div>
</main>
</body>
</html>

295
node/explorer/contract.js Normal file
View File

@@ -0,0 +1,295 @@
(function() {
var C = window.ExplorerCommon;
var currentContractID = '';
/* ── Tab switching ───────────────────────────────────────────────────────── */
function switchTab(active) {
var tabs = ['Overview', 'State', 'Logs', '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';
});
// Lazy-load logs when tab is opened
if (active === 'Logs' && currentContractID) {
loadLogs(currentContractID);
}
}
document.getElementById('tabOverview').addEventListener('click', function() { switchTab('Overview'); });
document.getElementById('tabState').addEventListener('click', function() { switchTab('State'); });
document.getElementById('tabLogs').addEventListener('click', function() { switchTab('Logs'); });
document.getElementById('tabRaw').addEventListener('click', function() { switchTab('Raw'); });
/* ── ABI rendering ───────────────────────────────────────────────────────── */
function renderABI(abiJson) {
var abi = null;
try {
abi = typeof abiJson === 'string' ? JSON.parse(abiJson) : abiJson;
} catch (e) { return; }
if (!abi || !Array.isArray(abi.methods) || abi.methods.length === 0) return;
var tbody = document.getElementById('abiMethodsBody');
tbody.innerHTML = '';
abi.methods.forEach(function(m) {
var args = Array.isArray(m.args) && m.args.length > 0
? m.args.map(function(a) { return C.esc(a.name || '') + ': ' + C.esc(a.type || '?'); }).join(', ')
: '<span class="text-muted">none</span>';
var tr = document.createElement('tr');
tr.innerHTML =
'<td><span class="tx-type-badge">' + C.esc(m.name || '?') + '</span></td>' +
'<td class="mono" style="font-size:0.82rem">' + args + '</td>';
tbody.appendChild(tr);
});
document.getElementById('abiSection').style.display = '';
}
/* ── Main render ─────────────────────────────────────────────────────────── */
function renderContract(contract) {
document.title = 'Contract ' + C.short(contract.contract_id, 16) + ' | DChain Explorer';
currentContractID = contract.contract_id;
// Banner
document.getElementById('bannerContractId').textContent = contract.contract_id || '—';
document.getElementById('bannerDeployedAt').textContent =
contract.deployed_at != null ? 'Block #' + contract.deployed_at : '—';
// Overview fields
document.getElementById('contractId').textContent = contract.contract_id || '—';
if (contract.deployer_pub) {
document.getElementById('contractDeployer').innerHTML =
'<a href="/address?address=' + encodeURIComponent(contract.deployer_pub) + '">' +
C.esc(C.shortAddress(contract.deployer_pub)) + '</a>';
document.getElementById('contractDeployerRaw').textContent = contract.deployer_pub;
} else {
document.getElementById('contractDeployer').textContent = '—';
}
document.getElementById('contractDeployedBlock').textContent =
contract.deployed_at != null ? '#' + contract.deployed_at : '—';
document.getElementById('contractWasmSize').textContent =
contract.wasm_size != null ? contract.wasm_size.toLocaleString() + ' bytes' : '—';
// ABI
if (contract.abi_json) {
renderABI(contract.abi_json);
}
// Raw JSON
document.getElementById('contractRaw').textContent = JSON.stringify(contract, null, 2);
document.getElementById('mainContent').style.display = '';
C.refreshIcons();
}
/* ── Contracts list ──────────────────────────────────────────────────────── */
async function loadContractsList() {
try {
var data = await C.fetchJSON('/api/contracts');
var contracts = data.contracts || [];
var loading = document.getElementById('contractsLoading');
var empty = document.getElementById('contractsEmpty');
var wrap = document.getElementById('contractsTableWrap');
var count = document.getElementById('contractsCount');
if (loading) loading.style.display = 'none';
count.textContent = contracts.length + ' contract' + (contracts.length !== 1 ? 's' : '');
if (contracts.length === 0) {
empty.style.display = '';
return;
}
var tbody = document.getElementById('contractsBody');
tbody.innerHTML = '';
contracts.forEach(function(c) {
var tr = document.createElement('tr');
var idShort = C.short(c.contract_id, 20);
var deployer = c.deployer_pub ? C.shortAddress(c.deployer_pub) : '—';
var deployerHref = c.deployer_pub
? '<a href="/address?address=' + encodeURIComponent(c.deployer_pub) + '" class="mono" style="font-size:0.82rem">' + C.esc(deployer) + '</a>'
: '<span class="text-muted">—</span>';
tr.innerHTML =
'<td><a href="/contract?id=' + encodeURIComponent(c.contract_id) + '" class="mono" style="font-size:0.82rem">' + C.esc(idShort) + '</a></td>' +
'<td>' + deployerHref + '</td>' +
'<td class="text-muted">' + (c.deployed_at != null ? '#' + c.deployed_at : '—') + '</td>' +
'<td class="text-muted" style="font-size:0.8rem">' + (c.wasm_size != null ? c.wasm_size.toLocaleString() + ' B' : '—') + '</td>';
tbody.appendChild(tr);
});
wrap.style.display = '';
C.refreshIcons();
} catch (e) {
var loading = document.getElementById('contractsLoading');
if (loading) loading.textContent = 'Failed to load contracts: ' + e.message;
}
}
/* ── Load contract ───────────────────────────────────────────────────────── */
async function loadContract(id) {
if (!id) return;
C.setStatus('Loading…', 'warn');
document.getElementById('contractsList').style.display = 'none';
document.getElementById('mainContent').style.display = 'none';
document.getElementById('abiSection').style.display = 'none';
switchTab('Overview');
try {
var contract = await C.fetchJSON('/api/contracts/' + encodeURIComponent(id));
renderContract(contract);
window.history.replaceState({}, '', '/contract?id=' + encodeURIComponent(id));
C.setStatus('', '');
} catch (e) {
C.setStatus('Contract not found: ' + e.message, 'err');
document.getElementById('contractsList').style.display = '';
}
}
/* ── State browser ───────────────────────────────────────────────────────── */
async function queryState(key) {
if (!currentContractID || !key) return;
C.setStatus('Querying state…', 'warn');
document.getElementById('stateResult').style.display = 'none';
document.getElementById('stateEmpty').style.display = 'none';
try {
var data = await C.fetchJSON(
'/api/contracts/' + encodeURIComponent(currentContractID) +
'/state/' + encodeURIComponent(key)
);
C.setStatus('', '');
document.getElementById('stateKey').textContent = data.key || key;
if (data.value_hex === null || data.value_hex === undefined) {
// Key not set
document.getElementById('stateU64Row').style.display = 'none';
document.getElementById('stateHex').textContent = '—';
document.getElementById('stateB64').textContent = '—';
document.getElementById('stateNullRow').style.display = '';
} else {
document.getElementById('stateNullRow').style.display = 'none';
document.getElementById('stateHex').textContent = data.value_hex || '—';
document.getElementById('stateB64').textContent = data.value_b64 || '—';
if (data.value_u64 !== null && data.value_u64 !== undefined) {
document.getElementById('stateU64').textContent = data.value_u64.toLocaleString();
document.getElementById('stateU64Row').style.display = '';
} else {
document.getElementById('stateU64Row').style.display = 'none';
}
}
document.getElementById('stateResult').style.display = '';
C.refreshIcons();
} catch (e) {
C.setStatus('State query failed: ' + e.message, 'err');
document.getElementById('stateEmpty').style.display = '';
}
}
document.getElementById('stateLoadBtn').addEventListener('click', function() {
var key = (document.getElementById('stateKeyInput').value || '').trim();
if (key) queryState(key);
});
document.getElementById('stateKeyInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') document.getElementById('stateLoadBtn').click();
});
/* ── Logs ────────────────────────────────────────────────────────────────── */
var logsLoaded = false;
function makeLogRow(entry) {
var tr = document.createElement('tr');
var txShort = entry.tx_id ? C.short(entry.tx_id, 12) : '—';
var txLink = entry.tx_id
? '<a href="/tx?id=' + encodeURIComponent(entry.tx_id) + '" class="mono" style="font-size:0.8rem">' + C.esc(txShort) + '</a>'
: '<span class="text-muted">—</span>';
tr.innerHTML =
'<td><a href="/?block=' + entry.block_height + '">#' + entry.block_height + '</a></td>' +
'<td>' + txLink + '</td>' +
'<td class="text-muted" style="font-size:0.8rem">' + entry.seq + '</td>' +
'<td class="mono" style="font-size:0.85rem;word-break:break-all">' + C.esc(entry.message) + '</td>';
return tr;
}
async function loadLogs(contractID) {
if (logsLoaded) return;
logsLoaded = true;
try {
var data = await C.fetchJSON('/api/contracts/' + encodeURIComponent(contractID) + '/logs?limit=100');
var logs = data.logs || [];
if (logs.length === 0) {
document.getElementById('logsEmpty').style.display = '';
document.getElementById('logsTableWrap').style.display = 'none';
return;
}
var tbody = document.getElementById('logsBody');
tbody.innerHTML = '';
logs.forEach(function(entry) { tbody.appendChild(makeLogRow(entry)); });
document.getElementById('logsEmpty').style.display = 'none';
document.getElementById('logsTableWrap').style.display = '';
} catch (e) {
document.getElementById('logsEmpty').textContent = 'Failed to load logs: ' + e.message;
document.getElementById('logsEmpty').style.display = '';
}
}
// SSE — prepend live contract_log entries for the currently viewed contract.
C.connectSSE({
contract_log: function(entry) {
if (!currentContractID || entry.contract_id !== currentContractID) return;
var tbody = document.getElementById('logsBody');
if (!tbody) return;
tbody.insertBefore(makeLogRow(entry), tbody.firstChild);
document.getElementById('logsEmpty').style.display = 'none';
document.getElementById('logsTableWrap').style.display = '';
}
});
/* ── 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('contractBtn').addEventListener('click', function() {
var val = (document.getElementById('contractInput').value || '').trim();
if (val) {
logsLoaded = false;
loadContract(val);
}
});
document.getElementById('contractInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') document.getElementById('contractBtn').click();
});
var initial = C.q('id');
if (initial) {
document.getElementById('contractInput').value = initial;
loadContract(initial);
} else {
loadContractsList();
}
})();

166
node/explorer/index.html Normal file
View File

@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DChain Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/explorer/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script defer src="/assets/explorer/common.js"></script>
<script defer src="/assets/explorer/app.js"></script>
</head>
<body>
<!-- top nav -->
<header class="topnav">
<div class="topnav-inner">
<a class="topnav-brand" href="/">
<span class="brand-gem"></span>
<span class="brand-name">DChain</span>
</a>
<nav class="topnav-links">
<a href="/contract">Contracts</a>
<a href="/tokens">Tokens</a>
<a href="/validators">Validators</a>
<a href="/relays">Relay Nodes</a>
</nav>
</div>
</header>
<!-- hero -->
<section class="hero">
<div class="hero-inner">
<h1 class="hero-title">
<span class="hero-gem"></span>
DChain Explorer
</h1>
<p class="hero-sub">Ed25519 blockchain · PBFT consensus · NaCl E2E messaging</p>
<div class="hero-search">
<i data-lucide="search" class="hero-search-icon"></i>
<input id="searchInput" type="text"
placeholder="Search by address, public key, tx id or block number…">
<button id="searchBtn" class="btn-hero">Search</button>
</div>
<div class="hero-actions">
<a href="/validators" class="pill-link"><i data-lucide="shield-check"></i> Validators</a>
<a href="/relays" class="pill-link"><i data-lucide="radio"></i> Relay Nodes</a>
<button id="openNodeBtn" class="pill-link pill-btn"><i data-lucide="server"></i> Node Stats</button>
</div>
<div id="status" class="hero-status">Loading…</div>
</div>
</section>
<!-- stats strip (Tonviewer-style) -->
<section class="stats-strip">
<div class="strip-inner">
<div class="strip-cell strip-cell-chart">
<div class="strip-label">Total Supply</div>
<div class="strip-val" id="sSupply"></div>
<canvas id="supplyChart" class="strip-chart"></canvas>
</div>
<div class="strip-divider"></div>
<div class="strip-cell">
<div class="strip-label">Chain Height</div>
<div class="strip-val mono" id="sHeight"></div>
<div class="strip-sub" id="sLastTime"></div>
</div>
<div class="strip-divider"></div>
<div class="strip-cell">
<div class="strip-label">Avg Block Time</div>
<div class="strip-val" id="sBlockTime"></div>
<div class="strip-sub">recent window</div>
</div>
<div class="strip-divider"></div>
<div class="strip-cell strip-cell-chart">
<div class="strip-label">Current TPS</div>
<div class="strip-val" id="sTps"></div>
<canvas id="tpsChart" class="strip-chart"></canvas>
</div>
</div>
</section>
<!-- main content -->
<main class="page-body">
<!-- stat cards -->
<div class="cards-row">
<div class="card">
<div class="card-key"><i data-lucide="layers-3"></i> Last Block</div>
<div class="card-val mono" id="sLastBlock"></div>
<div class="card-sub mono" id="sLastHash"></div>
</div>
<div class="card">
<div class="card-key"><i data-lucide="arrow-left-right"></i> Total Transactions</div>
<div class="card-val" id="sTxs"></div>
<div class="card-sub" id="sTransfers"></div>
</div>
<div class="card">
<div class="card-key"><i data-lucide="zap"></i> Relay Proofs</div>
<div class="card-val" id="sRelayProofs"></div>
<div class="card-sub" id="sRelayCount"></div>
</div>
<div class="card">
<div class="card-key"><i data-lucide="shield-check"></i> Validators</div>
<div class="card-val" id="sValidators"></div>
<div class="card-sub" id="sTpsWindow"></div>
</div>
</div>
<!-- charts row -->
<div class="charts-row">
<div class="panel">
<h2><i data-lucide="bar-chart-3"></i> Transactions per Block</h2>
<div class="chart-wrap"><canvas id="txChart"></canvas></div>
</div>
<div class="panel">
<h2><i data-lucide="line-chart"></i> Block Interval (sec)</h2>
<div class="chart-wrap"><canvas id="intervalChart"></canvas></div>
</div>
</div>
<!-- recent blocks -->
<div class="panel">
<h2><i data-lucide="blocks"></i> Recent Blocks</h2>
<div class="table-wrap">
<table id="blocksTable">
<thead>
<tr>
<th>Block</th>
<th>Hash</th>
<th>Validator</th>
<th>Txs</th>
<th>Fees</th>
<th>Time</th>
</tr>
</thead>
<tbody id="blocksBody">
<tr><td colspan="6" class="tbl-empty">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- block json viewer (search result) -->
<div class="panel" id="blockDetailPanel" style="display:none">
<h2><i data-lucide="file-json"></i> Block Details</h2>
<pre id="blockRaw" class="raw"></pre>
</div>
</main>
</body>
</html>

146
node/explorer/node.html Normal file
View File

@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Node | DChain Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/explorer/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script defer src="/assets/explorer/common.js"></script>
<script defer src="/assets/explorer/node.js"></script>
</head>
<body>
<header class="topnav">
<div class="topnav-inner">
<a class="topnav-brand" href="/">
<span class="brand-gem"></span>
<span class="brand-name">DChain</span>
</a>
<nav class="topnav-links">
<a href="/contract">Contracts</a>
<a href="/tokens">Tokens</a>
<a href="/validators">Validators</a>
<a href="/relays">Relay Nodes</a>
</nav>
</div>
</header>
<!-- search bar strip -->
<div class="addr-searchbar">
<div class="addr-searchbar-inner">
<div class="hero-search" style="max-width:640px">
<i data-lucide="search" class="hero-search-icon"></i>
<input id="nodeInput" type="text" placeholder="Node public key or DC address…">
<button id="nodeBtn" class="btn-hero">Load</button>
</div>
<div id="status" class="hero-status"></div>
</div>
</div>
<main class="page-body" id="mainContent" style="display:none">
<!-- ── Node profile card ──────────────────────────────────────────────── -->
<div class="addr-profile-card">
<div class="addr-profile-left">
<div class="addr-avatar" style="background:linear-gradient(135deg,#1a3a3a 0%,#0d2030 100%)">
<i data-lucide="server" style="width:22px;height:22px;color:var(--ok)"></i>
</div>
<div class="addr-profile-info">
<div class="addr-name" id="nodeNickname">Node</div>
<div class="addr-badges" id="nodeBadges"></div>
<div class="mono" style="font-size:12px;color:var(--muted);margin-top:4px" id="nodeAddressShort"></div>
</div>
</div>
<div class="addr-profile-right">
<div class="addr-bal-label">Node Balance</div>
<div class="addr-bal-val" id="nodeBalanceVal"></div>
<div class="addr-bal-sub" id="nodeReputationRank"></div>
</div>
</div>
<!-- ── Identity / binding details ────────────────────────────────────── -->
<div class="panel addr-detail-panel">
<div class="addr-kv-list">
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="at-sign"></i> Node Address</div>
<div class="addr-kv-val">
<a class="mono" id="nodeAddress" href="#"></a>
<button class="copy-btn" data-copy-id="nodeAddressRaw" title="Copy"><i data-lucide="copy"></i></button>
<span id="nodeAddressRaw" style="display:none"></span>
</div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="key-round"></i> Public Key</div>
<div class="addr-kv-val">
<span class="mono addr-pubkey-text" id="nodePubKey"></span>
<button class="copy-btn" data-copy-id="nodePubKey" title="Copy"><i data-lucide="copy"></i></button>
</div>
</div>
<div class="addr-kv-row" id="bindingRow" style="display:none">
<div class="addr-kv-key"><i data-lucide="wallet"></i> Bound Wallet</div>
<div class="addr-kv-val">
<a id="bindingLink" href="#"></a>
<span class="text-muted" style="margin-left:8px;font-size:12px" id="bindingBalance"></span>
</div>
</div>
</div>
</div>
<!-- ── Stats grid ─────────────────────────────────────────────────────── -->
<div class="panel addr-node-panel">
<h2><i data-lucide="bar-chart-3"></i> Performance</h2>
<div class="addr-node-grid" style="grid-template-columns:repeat(4,minmax(0,1fr))">
<div class="addr-node-cell">
<div class="addr-node-label">Reputation Score</div>
<div class="addr-node-val" id="repScore"></div>
<div class="addr-node-sub" id="repRank"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Blocks Produced</div>
<div class="addr-node-val" id="repBlocks"></div>
<div class="addr-node-sub" id="recentProduced"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Relay Proofs</div>
<div class="addr-node-val" id="repRelay"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Heartbeats</div>
<div class="addr-node-val" id="repHeartbeats"></div>
<div class="addr-node-sub" id="repSlash" style="color:var(--err)"></div>
</div>
</div>
</div>
<!-- ── Rewards ────────────────────────────────────────────────────────── -->
<div class="panel addr-node-panel">
<h2><i data-lucide="coins"></i> Rewards</h2>
<div class="addr-node-grid" style="grid-template-columns:repeat(3,minmax(0,1fr))">
<div class="addr-node-cell">
<div class="addr-node-label">Recent Rewards</div>
<div class="addr-node-val" id="recentRewards"></div>
<div class="addr-node-sub" id="windowBlocks"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Lifetime Base Reward</div>
<div class="addr-node-val" id="lifetimeReward"></div>
</div>
<div class="addr-node-cell">
<div class="addr-node-label">Node Balance</div>
<div class="addr-node-val" id="nodeBalance"></div>
</div>
</div>
</div>
</main>
</body>
</html>

110
node/explorer/node.js Normal file
View File

@@ -0,0 +1,110 @@
(function() {
var C = window.ExplorerCommon;
function badge(text, variant) {
return '<span class="addr-badge addr-badge-' + variant + '">' + C.esc(text) + '</span>';
}
function renderNode(data) {
document.title = 'Node ' + C.short(data.address || data.pub_key || '', 16) + ' | DChain Explorer';
// Profile card
document.getElementById('nodeNickname').textContent = data.address ? C.short(data.address, 24) : 'Node';
document.getElementById('nodeAddressShort').textContent = data.pub_key ? C.short(data.pub_key, 32) : '—';
document.getElementById('nodeBalanceVal').textContent = data.node_balance || C.toToken(data.node_balance_ut || 0);
document.getElementById('nodeReputationRank').textContent = 'Rank: ' + (data.reputation_rank || '—');
// Badges
var badges = '';
if (data.blocks_produced > 0) badges += badge('Validator', 'accent');
if (data.relay_proofs > 0) badges += badge('Relay Node', 'relay');
if (!badges) badges = badge('Observer', 'muted');
document.getElementById('nodeBadges').innerHTML = badges;
// Address field
var addrEl = document.getElementById('nodeAddress');
addrEl.textContent = data.address || '—';
addrEl.href = data.pub_key ? '/address?address=' + encodeURIComponent(data.pub_key) : '#';
document.getElementById('nodeAddressRaw').textContent = data.address || '';
// PubKey field
document.getElementById('nodePubKey').textContent = data.pub_key || '—';
// Wallet binding
if (data.wallet_binding_address) {
var link = document.getElementById('bindingLink');
link.textContent = data.wallet_binding_address;
link.href = '/address?address=' + encodeURIComponent(data.wallet_binding_pub_key || data.wallet_binding_address);
document.getElementById('bindingBalance').textContent = data.wallet_binding_balance || '—';
document.getElementById('bindingRow').style.display = '';
} else {
document.getElementById('bindingRow').style.display = 'none';
}
// Stats
document.getElementById('repScore').textContent = String(data.reputation_score || 0);
document.getElementById('repRank').textContent = data.reputation_rank || '—';
document.getElementById('repBlocks').textContent = String(data.blocks_produced || 0);
var rw = data.recent_window_blocks || 0;
var rp = data.recent_blocks_produced || 0;
document.getElementById('recentProduced').textContent = 'last ' + rw + ' blocks: ' + rp;
document.getElementById('repRelay').textContent = String(data.relay_proofs || 0);
document.getElementById('repHeartbeats').textContent = String(data.heartbeats || 0);
var slash = data.slash_count || 0;
document.getElementById('repSlash').textContent = slash > 0 ? slash + ' slashes' : '';
// Rewards
document.getElementById('recentRewards').textContent = data.recent_rewards || C.toToken(data.recent_rewards_ut || 0);
document.getElementById('windowBlocks').textContent = 'window: last ' + rw + ' blocks';
document.getElementById('lifetimeReward').textContent = data.lifetime_base_reward || C.toToken(0);
document.getElementById('nodeBalance').textContent = data.node_balance || C.toToken(data.node_balance_ut || 0);
document.getElementById('mainContent').style.display = '';
C.refreshIcons();
}
async function loadNode(nodeID) {
if (!nodeID) return;
C.setStatus('Loading…', 'warn');
document.getElementById('mainContent').style.display = 'none';
try {
var data = await C.fetchJSON('/api/node/' + encodeURIComponent(nodeID) + '?window=300');
renderNode(data);
window.history.replaceState({}, '', '/node?node=' + encodeURIComponent(nodeID));
C.setStatus('', '');
} catch (e) {
C.setStatus('Load failed: ' + e.message, 'err');
}
}
/* ── 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('nodeBtn').addEventListener('click', function() {
var val = (document.getElementById('nodeInput').value || '').trim();
if (val) loadNode(val);
});
document.getElementById('nodeInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') document.getElementById('nodeBtn').click();
});
var initial = C.q('node') || C.q('pk');
if (initial) {
document.getElementById('nodeInput').value = initial;
loadNode(initial);
}
})();

78
node/explorer/relays.html Normal file
View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Relay Nodes | DChain Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/explorer/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script defer src="/assets/explorer/common.js"></script>
<script defer src="/assets/explorer/relays.js"></script>
</head>
<body>
<header class="topnav">
<div class="topnav-inner">
<a class="topnav-brand" href="/">
<span class="brand-gem"></span>
<span class="brand-name">DChain</span>
</a>
<nav class="topnav-links">
<a href="/contract">Contracts</a>
<a href="/tokens">Tokens</a>
<a href="/validators">Validators</a>
<a href="/relays" style="color:var(--text)">Relay Nodes</a>
</nav>
</div>
</header>
<main class="page-body">
<!-- page header -->
<div class="list-page-header">
<div class="list-page-header-left">
<div class="list-page-icon list-page-icon-relay"><i data-lucide="radio"></i></div>
<div>
<h1 class="list-page-title">Relay Nodes</h1>
<p class="list-page-sub">
Nodes registered on-chain as NaCl E2E relay service providers.
</p>
</div>
</div>
<div class="list-page-header-right">
<div class="list-page-stat">
<div class="list-page-stat-val" id="relayCount"></div>
<div class="list-page-stat-label">Relays</div>
</div>
<button id="refreshBtn" class="pill-btn"><i data-lucide="refresh-cw"></i> Refresh</button>
</div>
</div>
<div id="status" class="hero-status" style="margin-bottom:12px"></div>
<div class="panel" style="padding:0">
<div class="table-wrap">
<table id="relayTable">
<thead>
<tr>
<th style="width:36px">#</th>
<th>Node Address</th>
<th>X25519 Relay Key</th>
<th>Fee / msg</th>
<th>Multiaddr</th>
<th style="width:100px">Actions</th>
</tr>
</thead>
<tbody id="relayBody">
<tr><td colspan="6" class="tbl-empty">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>

68
node/explorer/relays.js Normal file
View File

@@ -0,0 +1,68 @@
(function() {
var C = window.ExplorerCommon;
function fmtFee(ut) {
if (!ut) return '<span class="text-muted">Free</span>';
if (ut < 1000) return ut + ' µT';
return C.toToken(ut);
}
async function loadRelays() {
C.setStatus('Loading…', 'warn');
try {
var data = await C.fetchJSON('/api/relays');
var relays = Array.isArray(data) ? data : [];
document.getElementById('relayCount').textContent = relays.length;
var tbody = document.getElementById('relayBody');
if (!relays.length) {
tbody.innerHTML = '<tr><td colspan="6" class="tbl-empty">No relay nodes registered yet.</td></tr>';
C.setStatus('No relay nodes found.', 'warn');
return;
}
var rows = '';
relays.forEach(function(info, i) {
var pubKey = info.pub_key || '';
var addr = info.address || '—';
var x25519 = (info.relay && info.relay.x25519_pub_key) || '—';
var feeUT = (info.relay && info.relay.fee_per_msg_ut) || 0;
var multiaddr = (info.relay && info.relay.multiaddr) || '';
rows +=
'<tr>' +
'<td class="text-muted" style="font-size:12px">' + (i + 1) + '</td>' +
'<td>' +
(pubKey
? '<a class="mono" href="/address?address=' + encodeURIComponent(pubKey) + '">' + C.esc(addr) + '</a>'
: C.esc(addr)) +
'</td>' +
'<td class="mono" style="font-size:11px;color:var(--muted)">' +
C.esc(C.short(x25519, 28)) +
'</td>' +
'<td>' + fmtFee(feeUT) + '</td>' +
'<td class="mono" style="font-size:11px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis">' +
(multiaddr ? C.esc(multiaddr) : '<span class="text-muted">—</span>') +
'</td>' +
'<td>' +
(pubKey
? '<a href="/node?node=' + encodeURIComponent(pubKey) + '" class="pill-link" style="font-size:12px;padding:4px 10px">' +
'<i data-lucide="server"></i> Node' +
'</a>'
: '') +
'</td>' +
'</tr>';
});
tbody.innerHTML = rows;
C.setStatus(relays.length + ' relay node' + (relays.length !== 1 ? 's' : '') + ' registered.', 'ok');
C.refreshIcons();
} catch (e) {
C.setStatus('Load failed: ' + e.message, 'err');
}
}
document.getElementById('refreshBtn').addEventListener('click', loadRelays);
loadRelays();
})();

1704
node/explorer/style.css Normal file

File diff suppressed because it is too large Load Diff

89
node/explorer/token.html Normal file
View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Token | DChain Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/explorer/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script defer src="/assets/explorer/common.js"></script>
<script defer src="/assets/explorer/token.js"></script>
</head>
<body>
<header class="topnav">
<div class="topnav-inner">
<a class="topnav-brand" href="/">
<span class="brand-gem"></span>
<span class="brand-name">DChain</span>
</a>
<nav class="topnav-links">
<a href="/contract">Contracts</a>
<a href="/tokens" style="color:var(--text)">Tokens</a>
<a href="/validators">Validators</a>
<a href="/relays">Relay Nodes</a>
</nav>
</div>
</header>
<div class="addr-searchbar">
<div class="addr-searchbar-inner">
<div id="status" class="hero-status"></div>
</div>
</div>
<main class="page-body" id="mainContent" style="display:none">
<!-- ── Banner ─────────────────────────────────────────────────────────── -->
<div class="tx-banner tx-banner-ok" id="banner">
<div class="tx-banner-left">
<div class="tx-banner-icon tx-banner-ok" id="bannerIcon">
<i data-lucide="coins"></i>
</div>
<div class="tx-banner-body">
<div class="tx-banner-title" id="bannerType">Token</div>
<div class="tx-banner-desc" id="bannerName"></div>
</div>
</div>
<div class="tx-banner-time" id="bannerBlock">block —</div>
</div>
<!-- NFT image (shown only for NFTs with image URI) -->
<div id="nftImageWrap" style="display:none;text-align:center;margin-bottom:1rem">
<img id="nftImage" style="max-width:340px;border-radius:14px;border:1px solid var(--line)" src="" alt="">
</div>
<!-- ── Detail panel ───────────────────────────────────────────────────── -->
<div class="panel tx-overview-panel">
<div class="tx-tabs">
<button class="tx-tab tx-tab-active" id="tabOverview">Overview</button>
<button class="tx-tab" id="tabRaw" style="display:none">Raw JSON</button>
</div>
<div id="paneOverview">
<div class="addr-kv-list" id="kvList">
<!-- filled by JS -->
</div>
<!-- Attributes (NFT) -->
<div id="attrsSection" style="display:none;padding:0 1.25rem 1rem">
<div style="font-size:0.8rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">
<i data-lucide="tag" style="width:12px;height:12px;vertical-align:middle"></i> Attributes
</div>
<div id="attrsGrid" class="attrs-grid"></div>
</div>
</div>
<div id="paneRaw" style="display:none">
<pre id="rawJSON" class="raw tx-raw-pre">No data.</pre>
</div>
</div>
</main>
</body>
</html>

180
node/explorer/token.js Normal file
View File

@@ -0,0 +1,180 @@
(function() {
var C = window.ExplorerCommon;
var params = new URLSearchParams(window.location.search);
var tokenID = params.get('id');
var nftID = params.get('nft');
function kv(icon, label, valHtml) {
return '<div class="addr-kv-row">' +
'<div class="addr-kv-key"><i data-lucide="' + icon + '"></i> ' + label + '</div>' +
'<div class="addr-kv-val">' + valHtml + '</div>' +
'</div>';
}
function copyBtn(id) {
return '<button class="copy-btn" data-copy-id="' + id + '" title="Copy"><i data-lucide="copy"></i></button>';
}
function hiddenSpan(id, val) {
return '<span id="' + id + '" style="display:none">' + C.esc(val) + '</span>';
}
/* ── Fungible token ──────────────────────────────────────────────────────── */
async function loadToken() {
C.setStatus('Loading token…', 'warn');
try {
var data = await C.fetchJSON('/api/tokens/' + tokenID);
if (!data || !data.token_id) { C.setStatus('Token not found.', 'err'); return; }
document.title = data.symbol + ' | DChain Explorer';
document.getElementById('bannerType').textContent = 'Fungible Token';
document.getElementById('bannerName').textContent = data.name + ' (' + data.symbol + ')';
document.getElementById('bannerBlock').textContent = 'block ' + (data.issued_at || 0);
document.getElementById('bannerIcon').querySelector('i').setAttribute('data-lucide', 'coins');
var d = data.decimals || 0;
var supplyFmt = formatSupply(data.total_supply || 0, d);
var rows = '';
rows += kv('hash', 'Token ID',
'<span class="mono addr-pubkey-text" id="tid">' + C.esc(data.token_id) + '</span>' + copyBtn('tid'));
rows += kv('type', 'Symbol',
'<span class="token-sym">' + C.esc(data.symbol) + '</span>');
rows += kv('tag', 'Name', C.esc(data.name));
rows += kv('layers-3', 'Decimals', '<span class="mono">' + d + '</span>');
rows += kv('bar-chart-2', 'Total Supply',
'<span class="mono">' + C.esc(supplyFmt) + '</span>');
rows += kv('user', 'Issuer',
'<a href="/address?address=' + encodeURIComponent(data.issuer) + '" class="mono">' +
C.short(data.issuer, 20) +
'</a>' +
hiddenSpan('issuerRaw', data.issuer) + copyBtn('issuerRaw'));
rows += kv('blocks', 'Issued at block',
'<span class="mono">' + (data.issued_at || 0) + '</span>');
document.getElementById('kvList').innerHTML = rows;
document.getElementById('rawJSON').textContent = JSON.stringify(data, null, 2);
document.getElementById('tabRaw').style.display = '';
document.getElementById('mainContent').style.display = '';
C.setStatus('', '');
C.refreshIcons();
C.wireClipboard();
} catch(e) {
C.setStatus('Load failed: ' + e.message, 'err');
}
}
/* ── NFT ─────────────────────────────────────────────────────────────────── */
async function loadNFT() {
C.setStatus('Loading NFT…', 'warn');
try {
var data = await C.fetchJSON('/api/nfts/' + nftID);
var n = data && data.nft ? data.nft : data;
if (!n || !n.nft_id) { C.setStatus('NFT not found.', 'err'); return; }
document.title = n.name + ' | DChain Explorer';
document.getElementById('bannerType').textContent = n.burned ? 'NFT (Burned)' : 'NFT';
document.getElementById('bannerName').textContent = n.name;
document.getElementById('bannerBlock').textContent = 'minted block ' + (n.minted_at || 0);
document.getElementById('bannerIcon').querySelector('i').setAttribute('data-lucide', 'image');
if (n.burned) document.getElementById('banner').classList.add('tx-banner-err');
// Show image if URI looks like an image
if (n.uri && /\.(png|jpg|jpeg|gif|svg|webp)/i.test(n.uri)) {
document.getElementById('nftImage').src = n.uri;
document.getElementById('nftImageWrap').style.display = '';
}
var ownerAddr = data.owner_address || '';
var rows = '';
rows += kv('hash', 'NFT ID',
'<span class="mono addr-pubkey-text" id="nid">' + C.esc(n.nft_id) + '</span>' + copyBtn('nid'));
rows += kv('tag', 'Name', C.esc(n.name));
if (n.description)
rows += kv('file-text', 'Description', C.esc(n.description));
if (n.uri)
rows += kv('link', 'Metadata URI',
'<a href="' + C.esc(n.uri) + '" target="_blank" class="mono" style="word-break:break-all">' + C.esc(n.uri) + '</a>');
if (!n.burned && n.owner) {
rows += kv('user', 'Owner',
'<a href="/address?address=' + encodeURIComponent(n.owner) + '" class="mono">' +
C.short(ownerAddr || n.owner, 20) +
'</a>' +
hiddenSpan('ownerRaw', n.owner) + copyBtn('ownerRaw'));
} else if (n.burned) {
rows += kv('flame', 'Status', '<span style="color:var(--err)">Burned</span>');
}
rows += kv('user-check', 'Issuer',
'<a href="/address?address=' + encodeURIComponent(n.issuer) + '" class="mono">' +
C.short(n.issuer, 20) +
'</a>');
rows += kv('blocks', 'Minted at block',
'<span class="mono">' + (n.minted_at || 0) + '</span>');
document.getElementById('kvList').innerHTML = rows;
// Attributes
if (n.attributes) {
try {
var attrs = JSON.parse(n.attributes);
var keys = Object.keys(attrs);
if (keys.length) {
var html = '';
keys.forEach(function(k) {
html += '<div class="attr-chip"><div class="attr-chip-key">' + C.esc(k) + '</div>' +
'<div class="attr-chip-val">' + C.esc(String(attrs[k])) + '</div></div>';
});
document.getElementById('attrsGrid').innerHTML = html;
document.getElementById('attrsSection').style.display = '';
}
} catch(_) {}
}
document.getElementById('rawJSON').textContent = JSON.stringify(data, null, 2);
document.getElementById('tabRaw').style.display = '';
document.getElementById('mainContent').style.display = '';
C.setStatus('', '');
C.refreshIcons();
C.wireClipboard();
} catch(e) {
C.setStatus('Load failed: ' + e.message, 'err');
}
}
/* ── Helpers ─────────────────────────────────────────────────────────────── */
function formatSupply(supply, decimals) {
if (decimals === 0) return supply.toLocaleString();
var d = Math.pow(10, decimals);
var whole = Math.floor(supply / d);
var frac = supply % d;
if (frac === 0) return whole.toLocaleString();
return whole.toLocaleString() + '.' + String(frac).padStart(decimals, '0').replace(/0+$/, '');
}
/* ── Tabs ────────────────────────────────────────────────────────────────── */
document.getElementById('tabOverview').addEventListener('click', function() {
document.getElementById('paneOverview').style.display = '';
document.getElementById('paneRaw').style.display = 'none';
document.getElementById('tabOverview').classList.add('tx-tab-active');
document.getElementById('tabRaw').classList.remove('tx-tab-active');
});
document.getElementById('tabRaw').addEventListener('click', function() {
document.getElementById('paneOverview').style.display = 'none';
document.getElementById('paneRaw').style.display = '';
document.getElementById('tabRaw').classList.add('tx-tab-active');
document.getElementById('tabOverview').classList.remove('tx-tab-active');
});
/* ── Boot ────────────────────────────────────────────────────────────────── */
if (nftID) loadNFT();
else if (tokenID) loadToken();
else C.setStatus('No token or NFT ID provided.', 'err');
})();

100
node/explorer/tokens.html Normal file
View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tokens & NFTs | DChain Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/explorer/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script defer src="/assets/explorer/common.js"></script>
<script defer src="/assets/explorer/tokens.js"></script>
</head>
<body>
<header class="topnav">
<div class="topnav-inner">
<a class="topnav-brand" href="/">
<span class="brand-gem"></span>
<span class="brand-name">DChain</span>
</a>
<nav class="topnav-links">
<a href="/contract">Contracts</a>
<a href="/tokens" style="color:var(--text)">Tokens</a>
<a href="/validators">Validators</a>
<a href="/relays">Relay Nodes</a>
</nav>
</div>
</header>
<main class="page-body">
<!-- page header -->
<div class="list-page-header">
<div class="list-page-header-left">
<div class="list-page-icon list-page-icon-token"><i data-lucide="coins"></i></div>
<div>
<h1 class="list-page-title">Tokens &amp; NFTs</h1>
<p class="list-page-sub">
Fungible tokens issued on-chain and non-fungible token collections.
</p>
</div>
</div>
<div class="list-page-header-right">
<div class="list-page-stat">
<div class="list-page-stat-val" id="tokenCount"></div>
<div class="list-page-stat-label">Fungible</div>
</div>
<div class="list-page-stat">
<div class="list-page-stat-val" id="nftCount"></div>
<div class="list-page-stat-label">NFTs</div>
</div>
<button id="refreshBtn" class="pill-btn"><i data-lucide="refresh-cw"></i> Refresh</button>
</div>
</div>
<div id="status" class="hero-status" style="margin-bottom:12px"></div>
<!-- Tabs -->
<div class="tx-tabs" style="margin-bottom:12px;background:var(--surface);border-radius:10px;padding:0 1rem">
<button class="tx-tab tx-tab-active" id="tabFungible">Fungible Tokens</button>
<button class="tx-tab" id="tabNFT">NFTs</button>
</div>
<!-- Fungible tokens table -->
<div id="paneFungible">
<div class="panel" style="padding:0">
<div class="table-wrap">
<table id="tokenTable">
<thead>
<tr>
<th style="width:36px">#</th>
<th>Symbol</th>
<th>Name</th>
<th style="width:80px">Decimals</th>
<th>Total Supply</th>
<th>Issuer</th>
<th style="width:90px">Block</th>
</tr>
</thead>
<tbody id="tokenBody">
<tr><td colspan="7" class="tbl-empty">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- NFTs grid -->
<div id="paneNFT" style="display:none">
<div id="nftEmpty" class="panel" style="display:none;padding:2rem;text-align:center;color:var(--muted)">
No NFTs minted yet.
</div>
<div id="nftGrid" class="nft-grid"></div>
</div>
</main>
</body>
</html>

121
node/explorer/tokens.js Normal file
View File

@@ -0,0 +1,121 @@
(function() {
var C = window.ExplorerCommon;
var state = { tab: 'fungible' };
function switchTab(name) {
state.tab = name;
document.getElementById('paneFungible').style.display = name === 'fungible' ? '' : 'none';
document.getElementById('paneNFT').style.display = name === 'nft' ? '' : 'none';
document.getElementById('tabFungible').className = 'tx-tab' + (name === 'fungible' ? ' tx-tab-active' : '');
document.getElementById('tabNFT').className = 'tx-tab' + (name === 'nft' ? ' tx-tab-active' : '');
}
/* ── Fungible tokens ─────────────────────────────────────────────────────── */
function formatSupply(supply, decimals) {
if (decimals === 0) return supply.toLocaleString();
var d = Math.pow(10, decimals);
var whole = Math.floor(supply / d);
var frac = supply % d;
if (frac === 0) return whole.toLocaleString();
return whole.toLocaleString() + '.' + String(frac).padStart(decimals, '0').replace(/0+$/, '');
}
async function loadTokens() {
C.setStatus('Loading…', 'warn');
try {
var data = await C.fetchJSON('/api/tokens');
var tokens = (data && Array.isArray(data.tokens)) ? data.tokens : [];
document.getElementById('tokenCount').textContent = tokens.length;
var tbody = document.getElementById('tokenBody');
if (!tokens.length) {
tbody.innerHTML = '<tr><td colspan="7" class="tbl-empty">No fungible tokens issued yet.</td></tr>';
} else {
var rows = '';
tokens.forEach(function(t, i) {
var supply = formatSupply(t.total_supply || 0, t.decimals || 0);
rows +=
'<tr>' +
'<td class="text-muted" style="font-size:12px">' + (i+1) + '</td>' +
'<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 class="mono text-muted">' + (t.decimals || 0) + '</td>' +
'<td class="mono">' + C.esc(supply) + '</td>' +
'<td>' +
'<a href="/address?address=' + encodeURIComponent(t.issuer) + '" class="mono" style="font-size:12px">' +
C.short(t.issuer, 20) +
'</a>' +
'</td>' +
'<td class="mono text-muted" style="font-size:12px">' + (t.issued_at || 0) + '</td>' +
'</tr>';
});
tbody.innerHTML = rows;
}
C.refreshIcons();
} catch(e) {
C.setStatus('Load failed: ' + e.message, 'err');
}
}
/* ── NFTs ────────────────────────────────────────────────────────────────── */
async function loadNFTs() {
try {
var data = await C.fetchJSON('/api/nfts');
var nfts = (data && Array.isArray(data.nfts)) ? data.nfts : [];
document.getElementById('nftCount').textContent = nfts.length;
var grid = document.getElementById('nftGrid');
var empty = document.getElementById('nftEmpty');
if (!nfts.length) {
grid.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
var cards = '';
nfts.forEach(function(n) {
var burned = n.burned ? ' nft-card-burned' : '';
var imgHtml = '';
if (n.uri && /\.(png|jpg|jpeg|gif|svg|webp)/i.test(n.uri)) {
imgHtml = '<img class="nft-card-img" src="' + C.esc(n.uri) + '" alt="" loading="lazy">';
} else {
imgHtml = '<div class="nft-card-img nft-card-placeholder"><i data-lucide="image"></i></div>';
}
cards +=
'<a class="nft-card' + burned + '" href="/token?nft=' + encodeURIComponent(n.nft_id) + '">' +
imgHtml +
'<div class="nft-card-body">' +
'<div class="nft-card-name">' + C.esc(n.name) + (n.burned ? ' <span class="badge-burned">BURNED</span>' : '') + '</div>' +
'<div class="nft-card-id mono">' + C.short(n.nft_id, 16) + '</div>' +
(n.owner ? '<div class="nft-card-owner text-muted">Owner: ' + C.short(n.owner, 16) + '</div>' : '') +
'</div>' +
'</a>';
});
grid.innerHTML = cards;
C.refreshIcons();
} catch(e) {
document.getElementById('nftCount').textContent = '?';
}
}
/* ── Boot ────────────────────────────────────────────────────────────────── */
document.getElementById('tabFungible').addEventListener('click', function() { switchTab('fungible'); });
document.getElementById('tabNFT').addEventListener('click', function() { switchTab('nft'); });
document.getElementById('refreshBtn').addEventListener('click', function() { loadTokens(); loadNFTs(); });
// Check URL hash for tab.
if (window.location.hash === '#nft') switchTab('nft');
Promise.all([loadTokens(), loadNFTs()]).then(function() {
C.setStatus('', '');
});
})();

186
node/explorer/tx.html Normal file
View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Transaction | DChain Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/explorer/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script defer src="/assets/explorer/common.js"></script>
<script defer src="/assets/explorer/tx.js"></script>
</head>
<body>
<!-- top nav -->
<header class="topnav">
<div class="topnav-inner">
<a class="topnav-brand" href="/">
<span class="brand-gem"></span>
<span class="brand-name">DChain</span>
</a>
<nav class="topnav-links">
<a href="/contract">Contracts</a>
<a href="/tokens">Tokens</a>
<a href="/validators">Validators</a>
<a href="/relays">Relay Nodes</a>
</nav>
</div>
</header>
<!-- search bar strip -->
<div class="addr-searchbar">
<div class="addr-searchbar-inner">
<div class="hero-search" style="max-width:640px">
<i data-lucide="search" class="hero-search-icon"></i>
<input id="txInput" type="text" placeholder="Transaction ID…">
<button id="txBtn" class="btn-hero">Load</button>
</div>
<div id="status" class="hero-status"></div>
</div>
</div>
<main class="page-body" id="mainContent" style="display:none">
<!-- ── Status banner ────────────────────────────────────────────────── -->
<div class="tx-banner" id="txBanner">
<div class="tx-banner-left">
<div class="tx-banner-icon" id="txBannerIcon">
<i data-lucide="check-circle-2"></i>
</div>
<div class="tx-banner-body">
<div class="tx-banner-title" id="txBannerTitle">Confirmed transaction</div>
<div class="tx-banner-desc" id="txBannerDesc"></div>
</div>
</div>
<div class="tx-banner-time" id="txBannerTime"></div>
</div>
<!-- ── Overview table ────────────────────────────────────────────────── -->
<div class="panel tx-overview-panel">
<!-- tab row -->
<div class="tx-tabs">
<button class="tx-tab tx-tab-active" id="tabOverview">Overview</button>
<button class="tx-tab" id="tabRaw">Raw JSON</button>
</div>
<!-- ── overview content ── -->
<div id="paneOverview">
<!-- action table header -->
<div class="tx-action-table">
<div class="tx-action-hdr">
<span>Action</span>
<span>Route</span>
<span>Payload / Memo</span>
<span style="text-align:right">Value</span>
</div>
<div class="tx-action-row" id="txActionRow">
<!-- filled by JS -->
</div>
</div>
<!-- route flow diagram -->
<div class="tx-flow" id="txFlow">
<!-- filled by JS -->
</div>
</div>
<!-- ── raw JSON content ── -->
<div id="paneRaw" style="display:none">
<pre id="txRaw" class="raw tx-raw-pre">No data.</pre>
</div>
</div>
<!-- ── Technical details ─────────────────────────────────────────────── -->
<div class="panel addr-detail-panel" id="txDetailPanel">
<div class="addr-kv-list">
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="hash"></i> Transaction ID</div>
<div class="addr-kv-val">
<span class="mono" id="txId"></span>
<button class="copy-btn" data-copy-id="txId" title="Copy"><i data-lucide="copy"></i></button>
</div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="tag"></i> Type</div>
<div class="addr-kv-val"><span id="txType"></span></div>
</div>
<div class="addr-kv-row" id="txMemoRow" style="display:none">
<div class="addr-kv-key"><i data-lucide="message-square"></i> Memo</div>
<div class="addr-kv-val"><span id="txMemo"></span></div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="arrow-right-from-line"></i> From</div>
<div class="addr-kv-val">
<span id="txFrom"></span>
<button class="copy-btn" data-copy-id="txFromRaw" title="Copy pubkey"><i data-lucide="copy"></i></button>
<span id="txFromRaw" style="display:none"></span>
</div>
</div>
<div class="addr-kv-row" id="txToRow" style="display:none">
<div class="addr-kv-key"><i data-lucide="arrow-right-to-line"></i> To</div>
<div class="addr-kv-val">
<span id="txTo"></span>
<button class="copy-btn" data-copy-id="txToRaw" title="Copy pubkey"><i data-lucide="copy"></i></button>
<span id="txToRaw" style="display:none"></span>
</div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="coins"></i> Amount</div>
<div class="addr-kv-val"><span class="tx-amount-val" id="txAmount"></span></div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="receipt"></i> Fee</div>
<div class="addr-kv-val"><span id="txFee"></span></div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="layers-3"></i> Block</div>
<div class="addr-kv-val">
<a id="txBlockLink" href="#"></a>
<span class="tx-block-hash" id="txBlockHash"></span>
</div>
</div>
<div class="addr-kv-row">
<div class="addr-kv-key"><i data-lucide="clock"></i> Time</div>
<div class="addr-kv-val">
<span id="txTime"></span>
<span class="tx-time-ago" id="txTimeAgo"></span>
</div>
</div>
<div class="addr-kv-row" id="txSigRow">
<div class="addr-kv-key"><i data-lucide="shield-check"></i> Signature</div>
<div class="addr-kv-val">
<span class="mono addr-pubkey-text" id="txSig"></span>
<button class="copy-btn" data-copy-id="txSig" title="Copy"><i data-lucide="copy"></i></button>
</div>
</div>
</div>
</div>
<!-- ── Payload details (shown when payload is a structured object) ───── -->
<div class="panel" id="txPayloadPanel" style="display:none">
<h2><i data-lucide="braces"></i> Payload</h2>
<pre id="txPayloadPre" class="raw tx-raw-pre"></pre>
</div>
</main>
</body>
</html>

334
node/explorer/tx.js Normal file
View File

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

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Validators | DChain Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/explorer/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<script defer src="/assets/explorer/common.js"></script>
<script defer src="/assets/explorer/validators.js"></script>
</head>
<body>
<header class="topnav">
<div class="topnav-inner">
<a class="topnav-brand" href="/">
<span class="brand-gem"></span>
<span class="brand-name">DChain</span>
</a>
<nav class="topnav-links">
<a href="/contract">Contracts</a>
<a href="/tokens">Tokens</a>
<a href="/validators" style="color:var(--text)">Validators</a>
<a href="/relays">Relay Nodes</a>
</nav>
</div>
</header>
<main class="page-body">
<!-- page header -->
<div class="list-page-header">
<div class="list-page-header-left">
<div class="list-page-icon list-page-icon-val"><i data-lucide="shield-check"></i></div>
<div>
<h1 class="list-page-title">Validator Set</h1>
<p class="list-page-sub">
Active validators participating in PBFT consensus.
Quorum requires ⌊2/3 · N⌋ + 1 votes.
</p>
</div>
</div>
<div class="list-page-header-right">
<div class="list-page-stat">
<div class="list-page-stat-val" id="valCount"></div>
<div class="list-page-stat-label">Validators</div>
</div>
<div class="list-page-stat">
<div class="list-page-stat-val" id="quorumCount"></div>
<div class="list-page-stat-label">Quorum</div>
</div>
<button id="refreshBtn" class="pill-btn"><i data-lucide="refresh-cw"></i> Refresh</button>
</div>
</div>
<div id="status" class="hero-status" style="margin-bottom:12px"></div>
<div class="panel" style="padding:0">
<div class="table-wrap">
<table id="valTable">
<thead>
<tr>
<th style="width:36px">#</th>
<th>Address</th>
<th>Public Key</th>
<th style="width:140px">Actions</th>
</tr>
</thead>
<tbody id="valBody">
<tr><td colspan="4" class="tbl-empty">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,58 @@
(function() {
var C = window.ExplorerCommon;
async function loadValidators() {
C.setStatus('Loading…', 'warn');
try {
var data = await C.fetchJSON('/api/validators');
var validators = (data && Array.isArray(data.validators)) ? data.validators : [];
var quorum = validators.length > 0 ? Math.floor(2 * validators.length / 3) + 1 : 0;
document.getElementById('valCount').textContent = validators.length;
document.getElementById('quorumCount').textContent = quorum || '—';
var tbody = document.getElementById('valBody');
if (!validators.length) {
tbody.innerHTML = '<tr><td colspan="4" class="tbl-empty">No validators found.</td></tr>';
C.setStatus('No validators registered.', 'warn');
return;
}
var rows = '';
validators.forEach(function(v, i) {
var addr = v.address || '—';
var pubKey = v.pub_key || '—';
rows +=
'<tr>' +
'<td class="text-muted" style="font-size:12px">' + (i + 1) + '</td>' +
'<td>' +
'<a class="mono" href="/address?address=' + encodeURIComponent(pubKey) + '">' +
C.esc(addr) +
'</a>' +
'</td>' +
'<td class="mono" style="color:var(--muted);font-size:12px">' +
C.esc(C.short(pubKey, 32)) +
'</td>' +
'<td>' +
'<a href="/node?node=' + encodeURIComponent(pubKey) + '" class="pill-link" style="font-size:12px;padding:4px 10px">' +
'<i data-lucide="server"></i> Node' +
'</a>' +
'</td>' +
'</tr>';
});
tbody.innerHTML = rows;
C.setStatus(
validators.length + ' validator' + (validators.length !== 1 ? 's' : '') +
' · quorum: ' + quorum,
'ok'
);
C.refreshIcons();
} catch (e) {
C.setStatus('Load failed: ' + e.message, 'err');
}
}
document.getElementById('refreshBtn').addEventListener('click', loadValidators);
loadValidators();
})();

166
node/explorer_assets.go Normal file
View File

@@ -0,0 +1,166 @@
package node
import (
_ "embed"
"net/http"
)
//go:embed explorer/index.html
var explorerIndexHTML string
//go:embed explorer/address.html
var explorerAddressHTML string
//go:embed explorer/tx.html
var explorerTxHTML string
//go:embed explorer/node.html
var explorerNodeHTML string
//go:embed explorer/style.css
var explorerStyleCSS string
//go:embed explorer/common.js
var explorerCommonJS string
//go:embed explorer/app.js
var explorerAppJS string
//go:embed explorer/address.js
var explorerAddressJS string
//go:embed explorer/tx.js
var explorerTxJS string
//go:embed explorer/node.js
var explorerNodeJS string
//go:embed explorer/relays.html
var explorerRelaysHTML string
//go:embed explorer/relays.js
var explorerRelaysJS string
//go:embed explorer/validators.html
var explorerValidatorsHTML string
//go:embed explorer/validators.js
var explorerValidatorsJS string
//go:embed explorer/contract.html
var explorerContractHTML string
//go:embed explorer/contract.js
var explorerContractJS string
//go:embed explorer/tokens.html
var explorerTokensHTML string
//go:embed explorer/tokens.js
var explorerTokensJS string
//go:embed explorer/token.html
var explorerTokenHTML string
//go:embed explorer/token.js
var explorerTokenJS string
func serveExplorerIndex(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(explorerIndexHTML))
}
func serveExplorerAddressPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(explorerAddressHTML))
}
func serveExplorerTxPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(explorerTxHTML))
}
func serveExplorerNodePage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(explorerNodeHTML))
}
func serveExplorerCSS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
_, _ = w.Write([]byte(explorerStyleCSS))
}
func serveExplorerCommonJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerCommonJS))
}
func serveExplorerJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerAppJS))
}
func serveExplorerAddressJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerAddressJS))
}
func serveExplorerTxJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerTxJS))
}
func serveExplorerNodeJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerNodeJS))
}
func serveExplorerRelaysPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(explorerRelaysHTML))
}
func serveExplorerRelaysJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerRelaysJS))
}
func serveExplorerValidatorsPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(explorerValidatorsHTML))
}
func serveExplorerValidatorsJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerValidatorsJS))
}
func serveExplorerContractPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(explorerContractHTML))
}
func serveExplorerContractJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerContractJS))
}
func serveExplorerTokensPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(explorerTokensHTML))
}
func serveExplorerTokensJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerTokensJS))
}
func serveExplorerTokenPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(explorerTokenHTML))
}
func serveExplorerTokenJS(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(explorerTokenJS))
}

232
node/metrics.go Normal file
View File

@@ -0,0 +1,232 @@
// Package node — minimal Prometheus-format metrics.
//
// We expose counters, gauges, and histograms in the Prometheus text
// exposition format on GET /metrics. A full-blown prometheus/client_golang
// dependency would pull 10+ transitive modules; for our needs (a handful of
// metrics + stable output format) a ~200 LOC in-tree implementation is
// enough, with zero extra build surface.
//
// Concurrency: all metric types are safe for concurrent Inc/Add/Observe.
// Registration happens at init time and is not reentrant.
package node
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
)
// ─── registry ────────────────────────────────────────────────────────────────
// metric is the internal interface every exposed metric implements.
type metric interface {
// write emits the metric's lines to w in Prometheus text format.
write(w *strings.Builder)
}
type metricRegistry struct {
mu sync.RWMutex
entries []metric
}
var defaultRegistry = &metricRegistry{}
func (r *metricRegistry) register(m metric) {
r.mu.Lock()
r.entries = append(r.entries, m)
r.mu.Unlock()
}
// metricsHandler writes Prometheus exposition format for all registered metrics.
func metricsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var sb strings.Builder
defaultRegistry.mu.RLock()
for _, m := range defaultRegistry.entries {
m.write(&sb)
}
defaultRegistry.mu.RUnlock()
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
_, _ = w.Write([]byte(sb.String()))
}
// ─── counter ─────────────────────────────────────────────────────────────────
// MetricCounter is a monotonically increasing value. Typical use: number of
// blocks committed, number of rejected txs.
type MetricCounter struct {
name, help string
v atomic.Uint64
}
// NewCounter registers and returns a new counter.
func NewCounter(name, help string) *MetricCounter {
c := &MetricCounter{name: name, help: help}
defaultRegistry.register(c)
return c
}
// Inc adds 1 to the counter.
func (c *MetricCounter) Inc() { c.v.Add(1) }
// Add adds n to the counter (must be ≥ 0 — counters are monotonic).
func (c *MetricCounter) Add(n uint64) { c.v.Add(n) }
func (c *MetricCounter) write(sb *strings.Builder) {
fmt.Fprintf(sb, "# HELP %s %s\n# TYPE %s counter\n%s %d\n",
c.name, c.help, c.name, c.name, c.v.Load())
}
// ─── gauge ───────────────────────────────────────────────────────────────────
// MetricGauge is a value that can go up or down. Typical use: current
// mempool size, active websocket connections, tip height.
type MetricGauge struct {
name, help string
v atomic.Int64
fn func() int64 // optional live provider; if set, v is unused
}
// NewGauge registers a gauge backed by an atomic Int64.
func NewGauge(name, help string) *MetricGauge {
g := &MetricGauge{name: name, help: help}
defaultRegistry.register(g)
return g
}
// NewGaugeFunc registers a gauge whose value is fetched on scrape. Useful
// when the source of truth is some other subsystem (chain.TipIndex, peer
// count, etc.) and we don't want a separate mirror variable.
func NewGaugeFunc(name, help string, fn func() int64) *MetricGauge {
g := &MetricGauge{name: name, help: help, fn: fn}
defaultRegistry.register(g)
return g
}
// Set overrides the stored value. Only meaningful for non-fn gauges.
func (g *MetricGauge) Set(v int64) { g.v.Store(v) }
// Inc / Dec convenience for bookkeeping gauges.
func (g *MetricGauge) Inc() { g.v.Add(1) }
func (g *MetricGauge) Dec() { g.v.Add(-1) }
func (g *MetricGauge) write(sb *strings.Builder) {
v := g.v.Load()
if g.fn != nil {
v = g.fn()
}
fmt.Fprintf(sb, "# HELP %s %s\n# TYPE %s gauge\n%s %d\n",
g.name, g.help, g.name, g.name, v)
}
// ─── histogram ───────────────────────────────────────────────────────────────
// MetricHistogram is a fixed-bucket latency histogram. Typical use: time to
// commit a block, time to apply a tx. We use user-supplied buckets (upper
// bounds in seconds) and track sum + count alongside for Prometheus-standard
// output.
type MetricHistogram struct {
name, help string
buckets []float64
counts []atomic.Uint64
inf atomic.Uint64
sum atomic.Uint64 // sum in microseconds to avoid floats
count atomic.Uint64
}
// NewHistogram registers a histogram with explicit bucket upper bounds (s).
// Buckets must be strictly increasing. The implicit +Inf bucket is added
// automatically per Prometheus spec.
func NewHistogram(name, help string, buckets []float64) *MetricHistogram {
sorted := make([]float64, len(buckets))
copy(sorted, buckets)
sort.Float64s(sorted)
h := &MetricHistogram{
name: name, help: help,
buckets: sorted,
counts: make([]atomic.Uint64, len(sorted)),
}
defaultRegistry.register(h)
return h
}
// Observe records a single sample (duration in seconds).
func (h *MetricHistogram) Observe(seconds float64) {
// Record in every bucket whose upper bound ≥ sample.
for i, b := range h.buckets {
if seconds <= b {
h.counts[i].Add(1)
}
}
h.inf.Add(1)
h.sum.Add(uint64(seconds * 1_000_000)) // µs resolution
h.count.Add(1)
}
func (h *MetricHistogram) write(sb *strings.Builder) {
fmt.Fprintf(sb, "# HELP %s %s\n# TYPE %s histogram\n", h.name, h.help, h.name)
for i, b := range h.buckets {
fmt.Fprintf(sb, `%s_bucket{le="%s"} %d`+"\n",
h.name, strconv.FormatFloat(b, 'g', -1, 64), h.counts[i].Load())
}
fmt.Fprintf(sb, `%s_bucket{le="+Inf"} %d`+"\n", h.name, h.inf.Load())
fmt.Fprintf(sb, "%s_sum %f\n", h.name, float64(h.sum.Load())/1_000_000)
fmt.Fprintf(sb, "%s_count %d\n", h.name, h.count.Load())
}
// ─── registered metrics (called from main.go) ────────────────────────────────
//
// Keeping these as package-level vars lets callers just do
// `MetricBlocksTotal.Inc()` instead of threading a registry through every
// component. Names follow Prometheus naming conventions:
// <namespace>_<subsystem>_<metric>_<unit>
var (
MetricBlocksTotal = NewCounter(
"dchain_blocks_total",
"Total number of blocks committed by this node",
)
MetricTxsTotal = NewCounter(
"dchain_txs_total",
"Total number of transactions included in committed blocks",
)
MetricTxSubmitAccepted = NewCounter(
"dchain_tx_submit_accepted_total",
"Transactions accepted into the mempool via /api/tx or WS submit_tx",
)
MetricTxSubmitRejected = NewCounter(
"dchain_tx_submit_rejected_total",
"Transactions rejected at API validation (bad sig, timestamp, etc.)",
)
MetricWSConnections = NewGauge(
"dchain_ws_connections",
"Currently open websocket connections on this node",
)
MetricPeers = NewGauge(
"dchain_peer_count",
"Currently connected libp2p peers",
)
// Block-commit latency — how long AddBlock takes end-to-end. Catches
// slow contract calls before they reach the freeze threshold.
MetricBlockCommitSeconds = NewHistogram(
"dchain_block_commit_seconds",
"Wall-clock seconds spent inside chain.AddBlock",
[]float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10, 30},
)
// Worst validator liveness in the current set — how many seqNums have
// passed without a commit vote from the most-delinquent validator.
// Alert on this staying > 20 for more than a few minutes; that
// validator is either down, partitioned, or wedged.
MetricMaxMissedBlocks = NewGauge(
"dchain_max_missed_blocks",
"Highest missed-block count among current validators (0 if all healthy)",
)
)

186
node/sse.go Normal file
View File

@@ -0,0 +1,186 @@
// Package node — Server-Sent Events hub for the block explorer.
//
// Clients connect to GET /api/events and receive a real-time stream of:
//
// event: block — every committed block
// event: tx — every confirmed transaction (synthetic BLOCK_REWARD excluded)
// event: contract_log — every log entry written by a smart contract
//
// The stream uses the standard text/event-stream format so the browser's
// native EventSource API works without any library.
package node
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"go-blockchain/blockchain"
)
// ── event payload types ──────────────────────────────────────────────────────
// SSEBlockEvent is emitted when a block is committed.
type SSEBlockEvent struct {
Index uint64 `json:"index"`
Hash string `json:"hash"`
TxCount int `json:"tx_count"`
Validator string `json:"validator"`
Timestamp string `json:"timestamp"`
}
// SSETxEvent is emitted for each confirmed transaction.
type SSETxEvent struct {
ID string `json:"id"`
TxType blockchain.EventType `json:"tx_type"`
From string `json:"from"`
To string `json:"to,omitempty"`
Amount uint64 `json:"amount,omitempty"`
Fee uint64 `json:"fee,omitempty"`
}
// SSEContractLogEvent is emitted each time a contract calls env.log().
type SSEContractLogEvent = blockchain.ContractLogEntry
// ── hub ───────────────────────────────────────────────────────────────────────
// SSEHub manages all active SSE client connections.
// It is safe for concurrent use from multiple goroutines.
type SSEHub struct {
mu sync.RWMutex
clients map[chan string]struct{}
}
// NewSSEHub returns an initialised hub ready to accept connections.
func NewSSEHub() *SSEHub {
return &SSEHub{clients: make(map[chan string]struct{})}
}
// Clients returns the current number of connected SSE clients.
func (h *SSEHub) Clients() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}
func (h *SSEHub) subscribe() chan string {
ch := make(chan string, 64) // buffered: drop events for slow clients
h.mu.Lock()
h.clients[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *SSEHub) unsubscribe(ch chan string) {
h.mu.Lock()
delete(h.clients, ch)
h.mu.Unlock()
close(ch)
}
// emit serialises data and broadcasts an SSE message to all subscribers.
func (h *SSEHub) emit(eventName string, data any) {
payload, err := json.Marshal(data)
if err != nil {
return
}
// SSE wire format: "event: <name>\ndata: <json>\n\n"
msg := fmt.Sprintf("event: %s\ndata: %s\n\n", eventName, payload)
h.mu.RLock()
for ch := range h.clients {
select {
case ch <- msg:
default: // drop silently — client is too slow
}
}
h.mu.RUnlock()
}
// ── public emit methods ───────────────────────────────────────────────────────
// EmitBlock broadcasts a "block" event for the committed block b.
func (h *SSEHub) EmitBlock(b *blockchain.Block) {
h.emit("block", SSEBlockEvent{
Index: b.Index,
Hash: b.HashHex(),
TxCount: len(b.Transactions),
Validator: b.Validator,
Timestamp: b.Timestamp.UTC().Format(time.RFC3339),
})
}
// EmitTx broadcasts a "tx" event for each confirmed transaction.
// Synthetic BLOCK_REWARD records are skipped.
func (h *SSEHub) EmitTx(tx *blockchain.Transaction) {
if tx.Type == blockchain.EventBlockReward {
return
}
h.emit("tx", SSETxEvent{
ID: tx.ID,
TxType: tx.Type,
From: tx.From,
To: tx.To,
Amount: tx.Amount,
Fee: tx.Fee,
})
}
// EmitContractLog broadcasts a "contract_log" event.
func (h *SSEHub) EmitContractLog(entry blockchain.ContractLogEntry) {
h.emit("contract_log", entry)
}
// EmitBlockWithTxs calls EmitBlock then EmitTx for each transaction in b.
func (h *SSEHub) EmitBlockWithTxs(b *blockchain.Block) {
h.EmitBlock(b)
for _, tx := range b.Transactions {
h.EmitTx(tx)
}
}
// ── HTTP handler ──────────────────────────────────────────────────────────────
// ServeHTTP handles GET /api/events.
// The response is a text/event-stream that streams block, tx, and
// contract_log events until the client disconnects.
func (h *SSEHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported by this server", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // tell nginx not to buffer
ch := h.subscribe()
defer h.unsubscribe(ch)
// Opening comment — confirms the connection to the client.
fmt.Fprintf(w, ": connected to DChain event stream\n\n")
flusher.Flush()
// Keepalive comments every 20 s prevent proxy/load-balancer timeouts.
keepalive := time.NewTicker(20 * time.Second)
defer keepalive.Stop()
for {
select {
case msg, ok := <-ch:
if !ok {
return
}
fmt.Fprint(w, msg)
flusher.Flush()
case <-keepalive.C:
fmt.Fprintf(w, ": keepalive\n\n")
flusher.Flush()
case <-r.Context().Done():
return
}
}
}

316
node/stats.go Normal file
View File

@@ -0,0 +1,316 @@
// Package node provides runtime statistics tracking for a validator node.
// Stats are accumulated with atomic counters (lock-free hot path) and
// exposed as a JSON HTTP endpoint on /stats and per-peer on /stats/peers.
package node
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/libp2p/go-libp2p/core/peer"
"go-blockchain/blockchain"
"go-blockchain/economy"
"go-blockchain/wallet"
)
// Tracker accumulates statistics for the running node.
// All counter fields are accessed atomically — safe from any goroutine.
type Tracker struct {
startTime time.Time
// Consensus counters
BlocksProposed atomic.Int64
BlocksCommitted atomic.Int64
ViewChanges atomic.Int64
VotesCast atomic.Int64 // PREPARE + COMMIT votes sent
// Network counters
ConsensusMsgsSent atomic.Int64
ConsensusMsgsRecv atomic.Int64
BlocksGossipSent atomic.Int64
BlocksGossipRecv atomic.Int64
TxsGossipSent atomic.Int64
TxsGossipRecv atomic.Int64
BlocksSynced atomic.Int64 // downloaded via sync protocol
// Per-peer routing stats
peersMu sync.RWMutex
peers map[peer.ID]*PeerStats
}
// PeerStats tracks per-peer message counters.
type PeerStats struct {
PeerID peer.ID `json:"peer_id"`
ConnectedAt time.Time `json:"connected_at"`
MsgsSent atomic.Int64
MsgsRecv atomic.Int64
BlocksSent atomic.Int64
BlocksRecv atomic.Int64
SyncRequests atomic.Int64 // times we synced blocks from this peer
}
// NewTracker creates a new Tracker with start time set to now.
func NewTracker() *Tracker {
return &Tracker{
startTime: time.Now(),
peers: make(map[peer.ID]*PeerStats),
}
}
// PeerConnected registers a new peer connection.
func (t *Tracker) PeerConnected(id peer.ID) {
t.peersMu.Lock()
defer t.peersMu.Unlock()
if _, ok := t.peers[id]; !ok {
t.peers[id] = &PeerStats{PeerID: id, ConnectedAt: time.Now()}
}
}
// PeerDisconnected removes a peer (keeps the slot so history is visible).
func (t *Tracker) PeerDisconnected(id peer.ID) {
// intentionally kept — routing history is useful even after disconnect
}
// peer returns (or lazily creates) the PeerStats for id.
func (t *Tracker) peer(id peer.ID) *PeerStats {
t.peersMu.RLock()
ps, ok := t.peers[id]
t.peersMu.RUnlock()
if ok {
return ps
}
t.peersMu.Lock()
defer t.peersMu.Unlock()
if ps, ok = t.peers[id]; ok {
return ps
}
ps = &PeerStats{PeerID: id, ConnectedAt: time.Now()}
t.peers[id] = ps
return ps
}
// RecordConsensusSent increments consensus message sent counters for a peer.
func (t *Tracker) RecordConsensusSent(to peer.ID) {
t.ConsensusMsgsSent.Add(1)
t.peer(to).MsgsSent.Add(1)
}
// RecordConsensusRecv increments consensus message received counters.
func (t *Tracker) RecordConsensusRecv(from peer.ID) {
t.ConsensusMsgsRecv.Add(1)
t.peer(from).MsgsRecv.Add(1)
}
// RecordBlockGossipSent records a block broadcast to a peer.
func (t *Tracker) RecordBlockGossipSent() {
t.BlocksGossipSent.Add(1)
}
// RecordBlockGossipRecv records receiving a block via gossip.
func (t *Tracker) RecordBlockGossipRecv(from peer.ID) {
t.BlocksGossipRecv.Add(1)
t.peer(from).BlocksRecv.Add(1)
}
// RecordSyncFrom records blocks downloaded from a peer via the sync protocol.
func (t *Tracker) RecordSyncFrom(from peer.ID, count int) {
t.BlocksSynced.Add(int64(count))
t.peer(from).SyncRequests.Add(1)
}
// UptimeSeconds returns seconds since the tracker was created.
func (t *Tracker) UptimeSeconds() int64 {
return int64(time.Since(t.startTime).Seconds())
}
// --- HTTP API ---
// StatsResponse is the full JSON payload returned by /stats.
type StatsResponse struct {
Node NodeInfo `json:"node"`
Chain ChainInfo `json:"chain"`
Consensus ConsensusInfo `json:"consensus"`
Network NetworkInfo `json:"network"`
Economy EconomyInfo `json:"economy"`
Reputation RepInfo `json:"reputation"`
Peers []PeerInfo `json:"peers"`
}
type NodeInfo struct {
PubKey string `json:"pub_key"`
Address string `json:"address"`
PeerID string `json:"peer_id"`
UptimeSecs int64 `json:"uptime_secs"`
WalletBinding string `json:"wallet_binding,omitempty"` // DC address if bound
}
type ChainInfo struct {
Height uint64 `json:"height"`
TipHash string `json:"tip_hash"`
TipTime string `json:"tip_time,omitempty"`
}
type ConsensusInfo struct {
BlocksProposed int64 `json:"blocks_proposed"`
BlocksCommitted int64 `json:"blocks_committed"`
ViewChanges int64 `json:"view_changes"`
VotesCast int64 `json:"votes_cast"`
}
type NetworkInfo struct {
PeersConnected int `json:"peers_connected"`
ConsensusMsgsSent int64 `json:"consensus_msgs_sent"`
ConsensusMsgsRecv int64 `json:"consensus_msgs_recv"`
BlocksGossipSent int64 `json:"blocks_gossip_sent"`
BlocksGossipRecv int64 `json:"blocks_gossip_recv"`
TxsGossipSent int64 `json:"txs_gossip_sent"`
TxsGossipRecv int64 `json:"txs_gossip_recv"`
BlocksSynced int64 `json:"blocks_synced"`
}
type EconomyInfo struct {
BalanceMicroT uint64 `json:"balance_ut"`
BalanceDisplay string `json:"balance"`
WalletBalanceMicroT uint64 `json:"wallet_balance_ut,omitempty"`
WalletBalance string `json:"wallet_balance,omitempty"`
}
type RepInfo struct {
Score int64 `json:"score"`
BlocksProduced uint64 `json:"blocks_produced"`
RelayProofs uint64 `json:"relay_proofs"`
SlashCount uint64 `json:"slash_count"`
Heartbeats uint64 `json:"heartbeats"`
Rank string `json:"rank"`
}
type PeerInfo struct {
PeerID string `json:"peer_id"`
ConnectedAt string `json:"connected_at"`
MsgsSent int64 `json:"msgs_sent"`
MsgsRecv int64 `json:"msgs_recv"`
BlocksSent int64 `json:"blocks_sent"`
BlocksRecv int64 `json:"blocks_recv"`
SyncRequests int64 `json:"sync_requests"`
}
// QueryFunc is called by the HTTP handler to fetch live chain/wallet state.
type QueryFunc struct {
PubKey func() string
PeerID func() string
PeersCount func() int
ChainTip func() *blockchain.Block
Balance func(pubKey string) uint64
WalletBinding func(pubKey string) string // returns wallet DC address or ""
Reputation func(pubKey string) blockchain.RepStats
}
// ServeHTTP returns a mux with /stats and /health, plus any extra routes registered via fns.
func (t *Tracker) ServeHTTP(q QueryFunc, fns ...func(*http.ServeMux)) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/stats", func(w http.ResponseWriter, r *http.Request) {
pubKey := q.PubKey()
tip := q.ChainTip()
var height uint64
var tipHash, tipTime string
if tip != nil {
height = tip.Index
tipHash = tip.HashHex()
tipTime = tip.Timestamp.Format(time.RFC3339)
}
bal := q.Balance(pubKey)
rep := q.Reputation(pubKey)
walletBinding := q.WalletBinding(pubKey)
t.peersMu.RLock()
var peerInfos []PeerInfo
for _, ps := range t.peers {
peerInfos = append(peerInfos, PeerInfo{
PeerID: ps.PeerID.String(),
ConnectedAt: ps.ConnectedAt.Format(time.RFC3339),
MsgsSent: ps.MsgsSent.Load(),
MsgsRecv: ps.MsgsRecv.Load(),
BlocksSent: ps.BlocksSent.Load(),
BlocksRecv: ps.BlocksRecv.Load(),
SyncRequests: ps.SyncRequests.Load(),
})
}
t.peersMu.RUnlock()
resp := StatsResponse{
Node: NodeInfo{
PubKey: pubKey,
Address: wallet.PubKeyToAddress(pubKey),
PeerID: q.PeerID(),
UptimeSecs: t.UptimeSeconds(),
WalletBinding: walletBinding,
},
Chain: ChainInfo{
Height: height,
TipHash: tipHash,
TipTime: tipTime,
},
Consensus: ConsensusInfo{
BlocksProposed: t.BlocksProposed.Load(),
BlocksCommitted: t.BlocksCommitted.Load(),
ViewChanges: t.ViewChanges.Load(),
VotesCast: t.VotesCast.Load(),
},
Network: NetworkInfo{
PeersConnected: q.PeersCount(),
ConsensusMsgsSent: t.ConsensusMsgsSent.Load(),
ConsensusMsgsRecv: t.ConsensusMsgsRecv.Load(),
BlocksGossipSent: t.BlocksGossipSent.Load(),
BlocksGossipRecv: t.BlocksGossipRecv.Load(),
TxsGossipSent: t.TxsGossipSent.Load(),
TxsGossipRecv: t.TxsGossipRecv.Load(),
BlocksSynced: t.BlocksSynced.Load(),
},
Economy: EconomyInfo{
BalanceMicroT: bal,
BalanceDisplay: economy.FormatTokens(bal),
},
Reputation: RepInfo{
Score: rep.Score,
BlocksProduced: rep.BlocksProduced,
RelayProofs: rep.RelayProofs,
SlashCount: rep.SlashCount,
Heartbeats: rep.Heartbeats,
Rank: rep.Rank(),
},
Peers: peerInfos,
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(resp); err != nil {
http.Error(w, `{"error":"failed to encode response"}`, http.StatusInternalServerError)
}
})
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"ok","uptime_secs":%d}`, t.UptimeSeconds())
})
for _, fn := range fns {
fn(mux)
}
return mux
}
// ListenAndServe starts the HTTP stats server on addr (e.g. ":8080").
func (t *Tracker) ListenAndServe(addr string, q QueryFunc, fns ...func(*http.ServeMux)) error {
handler := t.ServeHTTP(q, fns...)
return http.ListenAndServe(addr, handler)
}

23
node/swagger/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DChain API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.ui = SwaggerUIBundle({
url: "/swagger/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
docExpansion: "list",
defaultModelsExpandDepth: 1
});
</script>
</body>
</html>

727
node/swagger/openapi.json Normal file
View File

@@ -0,0 +1,727 @@
{
"openapi": "3.0.3",
"info": {
"title": "DChain Node API",
"version": "0.4.0",
"description": "API for reading blockchain state, submitting transactions, and using the relay mailbox. All token amounts are in micro-tokens (µT). 1 T = 1 000 000 µT."
},
"servers": [{ "url": "/" }],
"tags": [
{ "name": "chain", "description": "V2 chain-compatible endpoints" },
{ "name": "explorer", "description": "Block explorer read API" },
{ "name": "relay", "description": "Encrypted relay mailbox" }
],
"paths": {
"/api/netstats": {
"get": {
"tags": ["explorer"],
"summary": "Network statistics",
"responses": {
"200": {
"description": "Aggregate chain stats",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/NetStats" } } }
}
}
}
},
"/api/blocks": {
"get": {
"tags": ["explorer"],
"summary": "Recent blocks",
"parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } }
],
"responses": {
"200": {
"description": "Array of recent block summaries",
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/BlockSummary" } } } }
}
}
}
},
"/api/block/{index}": {
"get": {
"tags": ["explorer"],
"summary": "Block by index",
"parameters": [
{ "name": "index", "in": "path", "required": true, "schema": { "type": "integer", "format": "uint64" } }
],
"responses": {
"200": { "description": "Block detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BlockDetail" } } } },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/txs/recent": {
"get": {
"tags": ["explorer"],
"summary": "Recent transactions",
"parameters": [
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } }
],
"responses": {
"200": {
"description": "Recent transactions",
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TxListEntry" } } } }
}
}
}
},
"/api/tx/{txid}": {
"get": {
"tags": ["explorer"],
"summary": "Transaction by ID",
"parameters": [
{ "name": "txid", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Transaction detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxDetail" } } } },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/tx": {
"post": {
"tags": ["explorer"],
"summary": "Submit a signed transaction",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Transaction" } } }
},
"responses": {
"200": { "description": "Accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubmitTransactionResponse" } } } },
"400": { "$ref": "#/components/responses/Error" },
"500": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/address/{addr}": {
"get": {
"tags": ["explorer"],
"summary": "Address balance and transactions",
"description": "Accepts a DC... wallet address or hex Ed25519 public key.",
"parameters": [
{ "name": "addr", "in": "path", "required": true, "schema": { "type": "string" }, "description": "DC... address or hex pub key" },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } },
{ "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } }
],
"responses": {
"200": { "description": "Address detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddressDetail" } } } },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/node/{pubkey}": {
"get": {
"tags": ["explorer"],
"summary": "Node reputation and stats",
"parameters": [
{ "name": "pubkey", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Hex Ed25519 pub key or DC... address" },
{ "name": "window", "in": "query", "schema": { "type": "integer", "default": 200 }, "description": "Number of recent blocks to scan for rewards" }
],
"responses": {
"200": { "description": "Node stats", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NodeStats" } } } },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/relays": {
"get": {
"tags": ["explorer"],
"summary": "Registered relay nodes",
"description": "Returns all nodes that have submitted an REGISTER_RELAY transaction.",
"responses": {
"200": {
"description": "List of relay nodes",
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/RegisteredRelayInfo" } } } }
}
}
}
},
"/api/validators": {
"get": {
"tags": ["explorer"],
"summary": "Active validator set",
"description": "Returns the current on-chain validator set. The set changes via ADD_VALIDATOR / REMOVE_VALIDATOR transactions.",
"responses": {
"200": {
"description": "Active validators",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"count": { "type": "integer" },
"validators": {
"type": "array",
"items": {
"type": "object",
"properties": {
"pub_key": { "type": "string" },
"address": { "type": "string" }
}
}
}
}
}
}
}
}
}
}
},
"/api/identity/{pubkey}": {
"get": {
"tags": ["explorer"],
"summary": "Identity info (Ed25519 + X25519 keys)",
"description": "Returns identity info for a pub key or DC address. x25519_pub is populated only if the identity has submitted a REGISTER_KEY transaction with an X25519 key.",
"parameters": [
{ "name": "pubkey", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Hex Ed25519 pub key or DC... address" }
],
"responses": {
"200": { "description": "Identity info", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityInfo" } } } },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/relay/send": {
"post": {
"tags": ["relay"],
"summary": "Send an encrypted message via the relay node",
"description": "The relay node seals the message using its own X25519 keypair (sender = relay node) and broadcasts it on gossipsub. No on-chain fee is attached.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["recipient_pub", "msg_b64"],
"properties": {
"recipient_pub": { "type": "string", "description": "Hex X25519 public key of the recipient" },
"msg_b64": { "type": "string", "description": "Base64-encoded plaintext message" }
}
}
}
}
},
"responses": {
"200": {
"description": "Message sent",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Envelope ID" },
"recipient_pub": { "type": "string" },
"status": { "type": "string", "example": "sent" }
}
}
}
}
},
"400": { "$ref": "#/components/responses/Error" },
"503": { "$ref": "#/components/responses/Error" }
}
}
},
"/relay/broadcast": {
"post": {
"tags": ["relay"],
"summary": "Broadcast a pre-sealed envelope",
"description": "Light clients that seal their own NaCl-box envelopes use this to publish without a direct libp2p connection. The node stores it in the mailbox and gossips it.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["envelope"],
"properties": {
"envelope": { "$ref": "#/components/schemas/Envelope" }
}
}
}
}
},
"responses": {
"200": {
"description": "Broadcast accepted",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"status": { "type": "string", "example": "broadcast" }
}
}
}
}
},
"400": { "$ref": "#/components/responses/Error" },
"503": { "$ref": "#/components/responses/Error" }
}
}
},
"/relay/inbox": {
"get": {
"tags": ["relay"],
"summary": "List inbox envelopes",
"description": "Returns envelopes stored for the given X25519 public key. Envelopes remain encrypted — the relay cannot read them.",
"parameters": [
{ "name": "pub", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Hex X25519 public key of the recipient" },
{ "name": "since", "in": "query", "schema": { "type": "integer", "format": "int64" }, "description": "Unix timestamp — return only messages after this time" },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } }
],
"responses": {
"200": {
"description": "Inbox contents",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"pub": { "type": "string" },
"count": { "type": "integer" },
"has_more": { "type": "boolean" },
"items": { "type": "array", "items": { "$ref": "#/components/schemas/InboxItem" } }
}
}
}
}
},
"400": { "$ref": "#/components/responses/Error" }
}
}
},
"/relay/inbox/count": {
"get": {
"tags": ["relay"],
"summary": "Count inbox envelopes",
"parameters": [
{ "name": "pub", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "Envelope count",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"pub": { "type": "string" },
"count": { "type": "integer" }
}
}
}
}
}
}
}
},
"/relay/inbox/{envID}": {
"delete": {
"tags": ["relay"],
"summary": "Delete an envelope from the inbox",
"parameters": [
{ "name": "envID", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "pub", "in": "query", "required": true, "schema": { "type": "string" }, "description": "X25519 pub key of the inbox owner" }
],
"responses": {
"200": {
"description": "Deleted",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"status": { "type": "string", "example": "deleted" }
}
}
}
}
},
"400": { "$ref": "#/components/responses/Error" }
}
}
},
"/relay/contacts": {
"get": {
"tags": ["relay"],
"summary": "Incoming contact requests",
"description": "Returns all CONTACT_REQUEST records for the given Ed25519 pub key, including pending, accepted, and blocked.",
"parameters": [
{ "name": "pub", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Hex Ed25519 pub key" }
],
"responses": {
"200": {
"description": "Contact list",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"pub": { "type": "string" },
"count": { "type": "integer" },
"contacts": { "type": "array", "items": { "$ref": "#/components/schemas/ContactInfo" } }
}
}
}
}
},
"400": { "$ref": "#/components/responses/Error" }
}
}
},
"/v2/chain/accounts/{account_id}/transactions": {
"get": {
"tags": ["chain"],
"summary": "Account transactions",
"parameters": [
{ "name": "account_id", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 100, "minimum": 1, "maximum": 1000 } },
{ "name": "order", "in": "query", "schema": { "type": "string", "enum": ["desc", "asc"], "default": "desc" } },
{ "name": "after_block", "in": "query", "schema": { "type": "integer", "format": "uint64" } },
{ "name": "before_block", "in": "query", "schema": { "type": "integer", "format": "uint64" } }
],
"responses": {
"200": { "description": "Transactions", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChainTransactionsResponse" } } } },
"400": { "$ref": "#/components/responses/Error" },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/v2/chain/transactions/{tx_id}": {
"get": {
"tags": ["chain"],
"summary": "Transaction by ID",
"parameters": [
{ "name": "tx_id", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Transaction detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChainTransactionDetailResponse" } } } },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/v2/chain/transactions": {
"post": {
"tags": ["chain"],
"summary": "Submit transaction",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubmitTransactionRequest" } } }
},
"responses": {
"200": { "description": "Accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubmitTransactionResponse" } } } },
"400": { "$ref": "#/components/responses/Error" },
"500": { "$ref": "#/components/responses/Error" }
}
}
},
"/v2/chain/transactions/draft": {
"post": {
"tags": ["chain"],
"summary": "Build unsigned TRANSFER draft",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/DraftTransactionRequest" } } }
},
"responses": {
"200": { "description": "Draft", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DraftTransactionResponse" } } } },
"400": { "$ref": "#/components/responses/Error" }
}
}
}
},
"components": {
"responses": {
"Error": {
"description": "Error response",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
}
},
"schemas": {
"ErrorResponse": {
"type": "object",
"properties": { "error": { "type": "string" } },
"required": ["error"]
},
"NetStats": {
"type": "object",
"properties": {
"height": { "type": "integer" },
"total_txs": { "type": "integer" },
"total_transfers": { "type": "integer" },
"total_relay_proofs": { "type": "integer" },
"avg_block_time_ms": { "type": "number" },
"tps": { "type": "number" },
"total_supply_ut": { "type": "integer", "format": "uint64" }
}
},
"BlockSummary": {
"type": "object",
"properties": {
"index": { "type": "integer", "format": "uint64" },
"hash": { "type": "string" },
"time": { "type": "string", "format": "date-time" },
"validator": { "type": "string", "description": "DC... address" },
"tx_count": { "type": "integer" },
"total_fees_ut": { "type": "integer", "format": "uint64" }
}
},
"BlockDetail": {
"allOf": [
{ "$ref": "#/components/schemas/BlockSummary" },
{
"type": "object",
"properties": {
"prev_hash": { "type": "string" },
"validator_addr": { "type": "string" },
"transactions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"type": { "type": "string" },
"from": { "type": "string" },
"to": { "type": "string" },
"amount_ut": { "type": "integer", "format": "uint64" },
"fee_ut": { "type": "integer", "format": "uint64" }
}
}
}
}
}
]
},
"TxListEntry": {
"type": "object",
"properties": {
"id": { "type": "string" },
"type": { "type": "string", "enum": ["TRANSFER","REGISTER_KEY","RELAY_PROOF","REGISTER_RELAY","CONTACT_REQUEST","ACCEPT_CONTACT","BLOCK_CONTACT","ADD_VALIDATOR","REMOVE_VALIDATOR","HEARTBEAT","BIND_WALLET","SLASH","OPEN_PAY_CHAN","CLOSE_PAY_CHAN","BLOCK_REWARD"] },
"from": { "type": "string" },
"from_addr": { "type": "string" },
"to": { "type": "string" },
"to_addr": { "type": "string" },
"amount_ut": { "type": "integer", "format": "uint64" },
"amount": { "type": "string", "description": "Human-readable token amount" },
"fee_ut": { "type": "integer", "format": "uint64" },
"fee": { "type": "string" },
"time": { "type": "string", "format": "date-time" },
"block_index": { "type": "integer", "format": "uint64" },
"block_hash": { "type": "string" }
}
},
"TxDetail": {
"allOf": [
{ "$ref": "#/components/schemas/TxListEntry" },
{
"type": "object",
"properties": {
"block_time": { "type": "string", "format": "date-time" },
"payload": { "description": "Decoded payload JSON (type-specific)" },
"payload_hex": { "type": "string" },
"signature_hex": { "type": "string" }
}
}
]
},
"AddressDetail": {
"type": "object",
"properties": {
"address": { "type": "string" },
"pub_key": { "type": "string" },
"balance_ut": { "type": "integer", "format": "uint64" },
"balance": { "type": "string" },
"tx_count": { "type": "integer" },
"offset": { "type": "integer" },
"limit": { "type": "integer" },
"has_more": { "type": "boolean" },
"next_offset": { "type": "integer" },
"transactions": { "type": "array", "items": { "$ref": "#/components/schemas/TxListEntry" } }
}
},
"NodeStats": {
"type": "object",
"properties": {
"pub_key": { "type": "string" },
"address": { "type": "string" },
"node_balance_ut": { "type": "integer", "format": "uint64" },
"node_balance": { "type": "string" },
"wallet_binding_address": { "type": "string" },
"wallet_binding_balance_ut": { "type": "integer", "format": "uint64" },
"reputation_score": { "type": "integer", "format": "int64" },
"reputation_rank": { "type": "string", "enum": ["Observer","Active","Trusted","Validator"] },
"blocks_produced": { "type": "integer", "format": "uint64" },
"relay_proofs": { "type": "integer", "format": "uint64" },
"slash_count": { "type": "integer", "format": "uint64" },
"heartbeats": { "type": "integer", "format": "uint64" },
"recent_window_blocks": { "type": "integer" },
"recent_blocks_produced": { "type": "integer" },
"recent_rewards_ut": { "type": "integer", "format": "uint64" },
"recent_rewards": { "type": "string" }
}
},
"IdentityInfo": {
"type": "object",
"properties": {
"pub_key": { "type": "string", "description": "Hex Ed25519 public key" },
"address": { "type": "string", "description": "DC... wallet address" },
"x25519_pub": { "type": "string", "description": "Hex Curve25519 public key for E2E encryption; empty if not published" },
"nickname": { "type": "string" },
"registered": { "type": "boolean", "description": "true if REGISTER_KEY tx was committed" }
}
},
"RegisteredRelayInfo": {
"type": "object",
"properties": {
"pub_key": { "type": "string" },
"address": { "type": "string" },
"relay": {
"type": "object",
"properties": {
"x25519_pub_key": { "type": "string" },
"fee_per_msg_ut": { "type": "integer", "format": "uint64" },
"multiaddr": { "type": "string" }
}
}
}
},
"Envelope": {
"type": "object",
"description": "NaCl-box sealed message envelope. Only the holder of the recipient's X25519 private key can decrypt it.",
"required": ["id", "recipient_pub", "sender_pub", "nonce", "ciphertext"],
"properties": {
"id": { "type": "string", "description": "Hex SHA-256[:16] of nonce||ciphertext" },
"recipient_pub": { "type": "string", "description": "Hex X25519 public key of the recipient" },
"sender_pub": { "type": "string", "description": "Hex X25519 public key of the sender" },
"sender_ed25519_pub": { "type": "string", "description": "Sender's Ed25519 pub key for on-chain fee claims" },
"fee_ut": { "type": "integer", "format": "uint64", "description": "Delivery fee µT (0 = free)" },
"fee_sig": { "type": "string", "format": "byte", "description": "Ed25519 sig over FeeAuthBytes(id, fee_ut)" },
"nonce": { "type": "string", "format": "byte", "description": "24-byte NaCl nonce (base64)" },
"ciphertext": { "type": "string", "format": "byte", "description": "NaCl box ciphertext (base64)" },
"sent_at": { "type": "integer", "format": "int64", "description": "Unix timestamp" }
}
},
"InboxItem": {
"type": "object",
"properties": {
"id": { "type": "string" },
"sender_pub": { "type": "string" },
"recipient_pub": { "type": "string" },
"fee_ut": { "type": "integer", "format": "uint64" },
"sent_at": { "type": "integer", "format": "int64" },
"sent_at_human": { "type": "string", "format": "date-time" },
"nonce": { "type": "string", "format": "byte" },
"ciphertext": { "type": "string", "format": "byte" }
}
},
"ContactInfo": {
"type": "object",
"properties": {
"requester_pub": { "type": "string" },
"requester_addr": { "type": "string" },
"status": { "type": "string", "enum": ["pending", "accepted", "blocked"] },
"intro": { "type": "string", "description": "Optional plaintext intro (≤ 280 chars)" },
"fee_ut": { "type": "integer", "format": "uint64" },
"tx_id": { "type": "string" },
"created_at": { "type": "integer", "format": "int64" }
}
},
"Transaction": {
"type": "object",
"description": "Signed blockchain transaction. Sign the canonical JSON of the object with Signature set to null, using Ed25519.",
"required": ["type", "from"],
"properties": {
"id": { "type": "string" },
"type": { "type": "string", "enum": ["TRANSFER","REGISTER_KEY","RELAY_PROOF","REGISTER_RELAY","CONTACT_REQUEST","ACCEPT_CONTACT","BLOCK_CONTACT","ADD_VALIDATOR","REMOVE_VALIDATOR","HEARTBEAT","BIND_WALLET","SLASH","OPEN_PAY_CHAN","CLOSE_PAY_CHAN"] },
"from": { "type": "string", "description": "Hex Ed25519 pub key of the signer" },
"to": { "type": "string", "description": "Hex Ed25519 pub key of the recipient (if applicable)" },
"amount": { "type": "integer", "format": "uint64", "description": "µT to transfer (TRANSFER, CONTACT_REQUEST)" },
"fee": { "type": "integer", "format": "uint64", "description": "µT fee to block validator (min 1000)" },
"memo": { "type": "string" },
"payload": { "type": "string", "format": "byte", "description": "Base64 JSON payload (type-specific)" },
"signature": { "type": "string", "format": "byte", "description": "Ed25519 signature over canonical bytes" },
"timestamp": { "type": "string", "format": "date-time" }
}
},
"SubmitTransactionRequest": {
"type": "object",
"properties": {
"tx": { "$ref": "#/components/schemas/Transaction" },
"signed_tx": { "type": "string", "description": "Signed transaction as JSON/base64/hex string" }
}
},
"DraftTransactionRequest": {
"type": "object",
"required": ["from", "to", "amount_ut"],
"properties": {
"from": { "type": "string" },
"to": { "type": "string" },
"amount_ut": { "type": "integer", "format": "uint64" },
"memo": { "type": "string" },
"fee_ut": { "type": "integer", "format": "uint64", "description": "Optional; defaults to MinFee (1000 µT)" }
}
},
"DraftTransactionResponse": {
"type": "object",
"properties": {
"tx": { "$ref": "#/components/schemas/Transaction" },
"sign_bytes_hex": { "type": "string" },
"sign_bytes_base64": { "type": "string" },
"note": { "type": "string" }
},
"required": ["tx", "sign_bytes_hex", "sign_bytes_base64"]
},
"SubmitTransactionResponse": {
"type": "object",
"properties": {
"status": { "type": "string" },
"id": { "type": "string" }
},
"required": ["status", "id"]
},
"ChainTx": {
"type": "object",
"properties": {
"id": { "type": "string" },
"type": { "type": "string" },
"memo": { "type": "string" },
"from": { "type": "string" },
"from_addr": { "type": "string" },
"to": { "type": "string" },
"to_addr": { "type": "string" },
"amount_ut": { "type": "integer", "format": "uint64" },
"fee_ut": { "type": "integer", "format": "uint64" },
"block_index": { "type": "integer", "format": "uint64" },
"block_hash": { "type": "string" },
"time": { "type": "string", "format": "date-time" }
}
},
"ChainTransactionsResponse": {
"type": "object",
"properties": {
"account_id": { "type": "string" },
"account_addr": { "type": "string" },
"count": { "type": "integer" },
"order": { "type": "string" },
"limit_applied": { "type": "integer" },
"transactions": { "type": "array", "items": { "$ref": "#/components/schemas/ChainTx" } }
}
},
"ChainTransactionDetailResponse": {
"type": "object",
"properties": {
"tx": { "$ref": "#/components/schemas/ChainTx" },
"payload": {},
"payload_hex": { "type": "string" },
"signature_hex": { "type": "string" }
}
}
}
}
}

32
node/swagger_assets.go Normal file
View File

@@ -0,0 +1,32 @@
package node
import (
_ "embed"
"net/http"
)
//go:embed swagger/openapi.json
var swaggerOpenAPI string
//go:embed swagger/index.html
var swaggerIndexHTML string
func registerSwaggerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/swagger/openapi.json", serveSwaggerOpenAPI)
mux.HandleFunc("/swagger", serveSwaggerUI)
mux.HandleFunc("/swagger/", serveSwaggerUI)
}
func serveSwaggerOpenAPI(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(swaggerOpenAPI))
}
func serveSwaggerUI(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/swagger" && r.URL.Path != "/swagger/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(swaggerIndexHTML))
}

70
node/version/version.go Normal file
View File

@@ -0,0 +1,70 @@
// Package version carries build-time identity for the node binary.
//
// All four variables below are overridable at link time via -ldflags -X. The
// canonical build command is:
//
// VERSION_TAG=$(git describe --tags --always --dirty)
// VERSION_COMMIT=$(git rev-parse HEAD)
// VERSION_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
// VERSION_DIRTY=$(git diff --quiet HEAD -- 2>/dev/null && echo false || echo true)
//
// go build -ldflags "\
// -X go-blockchain/node/version.Tag=$VERSION_TAG \
// -X go-blockchain/node/version.Commit=$VERSION_COMMIT \
// -X go-blockchain/node/version.Date=$VERSION_DATE \
// -X go-blockchain/node/version.Dirty=$VERSION_DIRTY" ./cmd/node
//
// Both Dockerfile and Dockerfile.slim run this at image build time (see ARG
// VERSION_* + RUN go build … -ldflags lines). A naked `go build ./...`
// without ldflags falls back to the zero defaults — that's fine for local
// dev, just not shipped to users.
//
// Protocol versions come from a different source: see node.ProtocolVersion,
// which is a compile-time const that only bumps on wire-protocol breaking
// changes. Protocol version can be the same across many build tags.
package version
// Tag is a human-readable version label, typically `git describe --tags
// --always --dirty`. Examples: "v0.5.1", "v0.5.1-3-gabc1234", "abc1234-dirty".
// "dev" is the fallback when built without ldflags.
var Tag = "dev"
// Commit is the full 40-char git SHA of the HEAD at build time. "none" when
// unset. Useful for precise bisect / release-channel lookups even when Tag
// is a non-unique label like "dev".
var Commit = "none"
// Date is the build timestamp in RFC3339 (UTC). "unknown" when unset.
// Exposed in /api/well-known-version so operators can see how stale a
// deployed binary is without `docker exec`.
var Date = "unknown"
// Dirty is the string "true" if the working tree had uncommitted changes at
// build time, "false" otherwise. Always a string (not bool) because ldflags
// can only inject strings — parse with == "true" at call sites.
var Dirty = "false"
// String returns a one-line summary suitable for `node --version` output.
// Format: "dchain-node <tag> (commit=<short> date=<date> dirty=<bool>)".
func String() string {
short := Commit
if len(short) > 8 {
short = short[:8]
}
return "dchain-node " + Tag + " (commit=" + short + " date=" + Date + " dirty=" + Dirty + ")"
}
// Info returns the version fields as a map, suitable for JSON embedding in
// /api/well-known-version and similar introspection endpoints.
func Info() map[string]string {
return map[string]string{
"tag": Tag,
"commit": Commit,
"date": Date,
"dirty": Dirty,
}
}
// IsDirty reports whether the build was from an unclean working tree.
// Convenience over parsing Dirty == "true" at call sites.
func IsDirty() bool { return Dirty == "true" }

696
node/ws.go Normal file
View File

@@ -0,0 +1,696 @@
// Package node — WebSocket gateway.
//
// Clients connect to GET /api/ws and maintain a persistent bidirectional
// connection. The gateway eliminates HTTP polling for balance, messages,
// and contact requests by pushing events as soon as they are committed.
//
// Protocol (JSON, one frame per line):
//
// Client → Server:
//
// { "op": "subscribe", "topic": "tx" }
// { "op": "subscribe", "topic": "blocks" }
// { "op": "subscribe", "topic": "addr:<hex_pubkey>" } // txs involving this address
// { "op": "unsubscribe", "topic": "..." }
// { "op": "ping" }
//
// Server → Client:
//
// { "event": "hello", "chain_id": "dchain-...", "tip_height": 1234 }
// { "event": "block", "data": { index, hash, tx_count, validator, timestamp } }
// { "event": "tx", "data": { id, tx_type, from, to, amount, fee } }
// { "event": "contract_log", "data": { ... } }
// { "event": "pong" }
// { "event": "error", "msg": "..." }
// { "event": "subscribed", "topic": "..." }
//
// Design notes:
// - Each connection has a bounded outbox (64 frames). If the client is
// slower than the producer, oldest frames are dropped and a
// `{"event":"lag"}` notice is sent so the UI can trigger a resync.
// - Subscriptions are per-connection and kept in memory only. Reconnection
// requires re-subscribing; this is cheap because the client's Zustand
// store remembers what it needs.
// - The hub reuses the same event sources as the SSE hub so both transports
// stay in sync; any caller that emits to SSE also emits here.
package node
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"go-blockchain/blockchain"
)
// ── wire types ───────────────────────────────────────────────────────────────
type wsClientCmd struct {
Op string `json:"op"`
Topic string `json:"topic,omitempty"`
// submit_tx fields — carries a full signed transaction + a client-assigned
// request id so the ack frame can be correlated on the client.
Tx json.RawMessage `json:"tx,omitempty"`
ID string `json:"id,omitempty"`
// auth fields — client proves ownership of an Ed25519 pubkey by signing
// the server-supplied nonce (sent in the hello frame as auth_nonce).
PubKey string `json:"pubkey,omitempty"`
Signature string `json:"sig,omitempty"`
// typing fields — `to` is the recipient's X25519 pubkey (same key used
// for the inbox topic). Server fans out to subscribers of
// `typing:<to>` so the recipient's client can show an indicator.
// Purely ephemeral: never stored, never gossiped across nodes.
To string `json:"to,omitempty"`
}
type wsServerFrame struct {
Event string `json:"event"`
Data json.RawMessage `json:"data,omitempty"`
Topic string `json:"topic,omitempty"`
Msg string `json:"msg,omitempty"`
ChainID string `json:"chain_id,omitempty"`
TipHeight uint64 `json:"tip_height,omitempty"`
// Sent with the hello frame. The client signs it with their Ed25519
// private key and replies via the `auth` op; the server binds the
// connection to the authenticated pubkey for scoped subscriptions.
AuthNonce string `json:"auth_nonce,omitempty"`
// submit_ack fields.
ID string `json:"id,omitempty"`
Status string `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
}
// WSSubmitTxHandler is the hook the hub calls on receiving a `submit_tx` op.
// Implementations should do the same validation as the HTTP /api/tx handler
// (timestamp window + signature verify + mempool add) and return an error on
// rejection. A nil error means the tx has been accepted into the mempool.
type WSSubmitTxHandler func(txJSON []byte) (txID string, err error)
// ── hub ──────────────────────────────────────────────────────────────────────
// X25519ForPubFn, if set, lets the hub map an authenticated Ed25519 pubkey
// to the X25519 key the same identity uses for relay encryption. This lets
// the auth'd client subscribe to their own inbox without revealing that
// mapping to unauthenticated clients. Returns ("", nil) if unknown.
type X25519ForPubFn func(ed25519PubHex string) (x25519PubHex string, err error)
// WSHub tracks every open websocket connection and fans out events based on
// per-connection topic filters.
type WSHub struct {
mu sync.RWMutex
clients map[*wsClient]struct{}
upgrader websocket.Upgrader
chainID func() string
tip func() uint64
// submitTx is the optional handler for `submit_tx` client ops. If nil,
// the hub replies with an error so clients know to fall back to HTTP.
submitTx WSSubmitTxHandler
// x25519For maps Ed25519 pubkey → X25519 pubkey (from the identity
// registry) so the hub can validate `inbox:<x25519>` subscriptions
// against the authenticated identity. Optional — unset disables
// inbox auth (subscribe just requires auth but not identity lookup).
x25519For X25519ForPubFn
// Per-IP connection counter. Guards against a single host opening
// unbounded sockets (memory exhaustion, descriptor exhaustion).
// Counter mutates on connect/disconnect; protected by perIPMu rather
// than the hub's main mu so fanout isn't blocked by bookkeeping.
perIPMu sync.Mutex
perIP map[string]int
maxPerIP int
}
// WSMaxConnectionsPerIP caps concurrent websocket connections from one IP.
// Chosen to comfortably fit a power user with multiple devices (phone, web,
// load-test script) while still bounding a DoS. Override via SetMaxPerIP
// on the hub if a specific deployment needs different limits.
const WSMaxConnectionsPerIP = 10
// WSMaxSubsPerConnection caps subscriptions per single connection. A real
// client needs 3-5 topics (addr, inbox, blocks). 32 is generous headroom
// without letting one conn hold thousands of topic entries.
const WSMaxSubsPerConnection = 32
// NewWSHub constructs a hub. `chainID` and `tip` are optional snapshot
// functions used only for the hello frame.
func NewWSHub(chainID func() string, tip func() uint64) *WSHub {
return &WSHub{
clients: make(map[*wsClient]struct{}),
upgrader: websocket.Upgrader{
// The node may sit behind a reverse proxy; allow cross-origin
// upgrades. Rate limiting happens separately via api_guards.
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 4 * 1024,
WriteBufferSize: 4 * 1024,
},
chainID: chainID,
tip: tip,
perIP: make(map[string]int),
maxPerIP: WSMaxConnectionsPerIP,
}
}
// SetMaxPerIP overrides the default per-IP connection cap. Must be called
// before Upgrade starts accepting — otherwise racy with new connections.
func (h *WSHub) SetMaxPerIP(n int) {
if n <= 0 {
return
}
h.perIPMu.Lock()
h.maxPerIP = n
h.perIPMu.Unlock()
}
// SetSubmitTxHandler installs the handler for `submit_tx` ops. Pass nil to
// disable WS submission (clients will need to keep using HTTP POST /api/tx).
func (h *WSHub) SetSubmitTxHandler(fn WSSubmitTxHandler) {
h.submitTx = fn
}
// SetX25519ForPub installs the Ed25519→X25519 lookup used to validate
// inbox subscriptions post-auth. Pass nil to disable the check.
func (h *WSHub) SetX25519ForPub(fn X25519ForPubFn) {
h.x25519For = fn
}
// Clients returns the number of active websocket connections.
func (h *WSHub) Clients() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}
// ServeHTTP handles GET /api/ws and upgrades to a websocket.
func (h *WSHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
// Access-token gating (if configured). Private nodes require the token
// for the upgrade itself. Public-with-token-for-writes nodes check it
// here too — tokenOK is stored on the client so submit_tx can rely on
// the upgrade-time decision without re-reading headers per op.
tokenOK := true
if tok, _ := AccessTokenForWS(); tok != "" {
if err := checkAccessToken(r); err != nil {
if _, private := AccessTokenForWS(); private {
w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`)
http.Error(w, "ws: "+err.Error(), http.StatusUnauthorized)
return
}
// Public-with-token mode: upgrade allowed but write ops gated.
tokenOK = false
}
}
// Per-IP quota check. Reject BEFORE upgrade so we never hold an open
// socket for a client we're going to kick. 429 Too Many Requests is the
// closest status code — TCP is fine, they just opened too many.
h.perIPMu.Lock()
if h.perIP[ip] >= h.maxPerIP {
h.perIPMu.Unlock()
w.Header().Set("Retry-After", "30")
http.Error(w, fmt.Sprintf("too many websocket connections from %s (max %d)", ip, h.maxPerIP), http.StatusTooManyRequests)
return
}
h.perIP[ip]++
h.perIPMu.Unlock()
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
// Upgrade already wrote an HTTP error response. Release the reservation.
h.perIPMu.Lock()
h.perIP[ip]--
if h.perIP[ip] <= 0 {
delete(h.perIP, ip)
}
h.perIPMu.Unlock()
return
}
// Generate a fresh 32-byte nonce per connection. Client signs this
// with its Ed25519 private key to prove identity; binding is per-
// connection (reconnect → new nonce → new auth).
nonceBytes := make([]byte, 32)
if _, err := rand.Read(nonceBytes); err != nil {
conn.Close()
return
}
nonce := hex.EncodeToString(nonceBytes)
c := &wsClient{
conn: conn,
send: make(chan []byte, 64),
subs: make(map[string]struct{}),
authNonce: nonce,
remoteIP: ip,
tokenOK: tokenOK,
}
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
MetricWSConnections.Inc()
// Send hello with chain metadata so the client can show connection state
// and verify it's on the expected chain.
var chainID string
var tip uint64
if h.chainID != nil {
chainID = h.chainID()
}
if h.tip != nil {
tip = h.tip()
}
helloBytes, _ := json.Marshal(wsServerFrame{
Event: "hello",
ChainID: chainID,
TipHeight: tip,
AuthNonce: nonce,
})
select {
case c.send <- helloBytes:
default:
}
// Reader + writer goroutines. When either returns, the other is signalled
// to shut down by closing the send channel.
go h.writeLoop(c)
h.readLoop(c) // blocks until the connection closes
}
func (h *WSHub) removeClient(c *wsClient) {
h.mu.Lock()
wasRegistered := false
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send)
wasRegistered = true
}
h.mu.Unlock()
if wasRegistered {
MetricWSConnections.Dec()
}
// Release the per-IP reservation so the client can reconnect without
// being rejected. Missing-or-zero counters are silently no-op'd.
if c.remoteIP != "" {
h.perIPMu.Lock()
if h.perIP[c.remoteIP] > 0 {
h.perIP[c.remoteIP]--
if h.perIP[c.remoteIP] == 0 {
delete(h.perIP, c.remoteIP)
}
}
h.perIPMu.Unlock()
}
_ = c.conn.Close()
}
// readLoop processes control frames + parses JSON commands from the client.
func (h *WSHub) readLoop(c *wsClient) {
defer h.removeClient(c)
c.conn.SetReadLimit(16 * 1024) // reject oversized frames
_ = c.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
return nil
})
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
return
}
var cmd wsClientCmd
if err := json.Unmarshal(data, &cmd); err != nil {
c.sendFrame(wsServerFrame{Event: "error", Msg: "invalid JSON"})
continue
}
switch cmd.Op {
case "auth":
// Verify signature over the connection's auth_nonce. On success,
// bind this connection to the declared pubkey; scoped subs are
// limited to topics this identity owns.
pub, err := hex.DecodeString(cmd.PubKey)
if err != nil || len(pub) != ed25519.PublicKeySize {
c.sendFrame(wsServerFrame{Event: "error", Msg: "auth: invalid pubkey"})
continue
}
sig, err := hex.DecodeString(cmd.Signature)
if err != nil || len(sig) != ed25519.SignatureSize {
c.sendFrame(wsServerFrame{Event: "error", Msg: "auth: invalid signature"})
continue
}
if !ed25519.Verify(ed25519.PublicKey(pub), []byte(c.authNonce), sig) {
c.sendFrame(wsServerFrame{Event: "error", Msg: "auth: signature mismatch"})
continue
}
var x25519 string
if h.x25519For != nil {
x25519, _ = h.x25519For(cmd.PubKey) // non-fatal on error
}
c.mu.Lock()
c.authPubKey = cmd.PubKey
c.authX25519 = x25519
c.mu.Unlock()
c.sendFrame(wsServerFrame{Event: "subscribed", Topic: "auth:" + cmd.PubKey[:12] + "…"})
case "subscribe":
topic := strings.TrimSpace(cmd.Topic)
if topic == "" {
c.sendFrame(wsServerFrame{Event: "error", Msg: "topic required"})
continue
}
// Scoped topics require matching auth. Unauthenticated clients
// get global streams (`blocks`, `tx`, `contract_log`, …) only.
if err := h.authorizeSubscribe(c, topic); err != nil {
c.sendFrame(wsServerFrame{Event: "error", Msg: "forbidden: " + err.Error()})
continue
}
c.mu.Lock()
if len(c.subs) >= WSMaxSubsPerConnection {
c.mu.Unlock()
c.sendFrame(wsServerFrame{Event: "error", Msg: fmt.Sprintf("subscription limit exceeded (%d)", WSMaxSubsPerConnection)})
continue
}
c.subs[topic] = struct{}{}
c.mu.Unlock()
c.sendFrame(wsServerFrame{Event: "subscribed", Topic: topic})
case "unsubscribe":
c.mu.Lock()
delete(c.subs, cmd.Topic)
c.mu.Unlock()
case "ping":
c.sendFrame(wsServerFrame{Event: "pong"})
case "typing":
// Ephemeral signal: A is typing to B. Requires auth so the
// "from" claim is verifiable; anon clients can't spoof
// typing indicators for other identities.
c.mu.RLock()
fromX := c.authX25519
c.mu.RUnlock()
to := strings.TrimSpace(cmd.To)
if fromX == "" || to == "" {
// Silently drop — no need to error, typing is best-effort.
continue
}
data, _ := json.Marshal(map[string]string{
"from": fromX,
"to": to,
})
h.fanout(wsServerFrame{Event: "typing", Data: data},
[]string{"typing:" + to})
case "submit_tx":
// Low-latency transaction submission over the existing WS
// connection. Avoids the HTTP round-trip and delivers a
// submit_ack correlated by the client-supplied id so callers
// don't have to poll for inclusion status.
c.mu.RLock()
tokOK := c.tokenOK
c.mu.RUnlock()
if !tokOK {
c.sendFrame(wsServerFrame{
Event: "submit_ack",
ID: cmd.ID,
Status: "rejected",
Reason: "submit requires access token; pass ?token= at /api/ws upgrade",
})
continue
}
if h.submitTx == nil {
c.sendFrame(wsServerFrame{
Event: "submit_ack",
ID: cmd.ID,
Status: "rejected",
Reason: "submit_tx over WS not available on this node",
})
continue
}
if len(cmd.Tx) == 0 {
c.sendFrame(wsServerFrame{
Event: "submit_ack", ID: cmd.ID,
Status: "rejected", Reason: "missing tx",
})
continue
}
txID, err := h.submitTx(cmd.Tx)
if err != nil {
c.sendFrame(wsServerFrame{
Event: "submit_ack", ID: cmd.ID,
Status: "rejected", Reason: err.Error(),
})
continue
}
// Echo back the server-assigned tx id for confirmation. The
// client already knows it (it generated it), but this lets
// proxies / middleware log a proper request/response pair.
c.sendFrame(wsServerFrame{
Event: "submit_ack", ID: cmd.ID,
Status: "accepted", Msg: txID,
})
default:
c.sendFrame(wsServerFrame{Event: "error", Msg: "unknown op: " + cmd.Op})
}
}
}
// writeLoop pumps outbound frames and sends periodic pings.
func (h *WSHub) writeLoop(c *wsClient) {
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
for {
select {
case msg, ok := <-c.send:
if !ok {
return
}
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ping.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// authorizeSubscribe gates which topics a connection is allowed to join.
//
// Rules:
// - Global topics (blocks, tx, contract_log, contract:*, inbox) are
// open to anyone — `tx` leaks only public envelope fields, and the
// whole-`inbox` firehose is encrypted per-recipient anyway.
// - Scoped topics addressed at a specific identity require the client
// to have authenticated as that identity:
// addr:<ed25519_pub> — only the owning Ed25519 pubkey
// inbox:<x25519_pub> — only the identity whose registered X25519
// key equals this (looked up via x25519For)
//
// Without this check any curl client could subscribe to any address and
// watch incoming transactions in real time — a significant metadata leak.
func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error {
// Open topics — always allowed.
switch topic {
case "blocks", "tx", "contract_log", "inbox", "$system":
return nil
}
if strings.HasPrefix(topic, "contract:") {
return nil // contract-wide log streams are public
}
c.mu.RLock()
authed := c.authPubKey
authX := c.authX25519
c.mu.RUnlock()
if strings.HasPrefix(topic, "addr:") {
if authed == "" {
return fmt.Errorf("addr:* requires auth")
}
want := strings.TrimPrefix(topic, "addr:")
if want != authed {
return fmt.Errorf("addr:* only for your own pubkey")
}
return nil
}
if strings.HasPrefix(topic, "inbox:") {
if authed == "" {
return fmt.Errorf("inbox:* requires auth")
}
// If we have an x25519 mapping, enforce it; otherwise accept
// (best-effort — identity may not be registered yet).
if authX != "" {
want := strings.TrimPrefix(topic, "inbox:")
if want != authX {
return fmt.Errorf("inbox:* only for your own x25519")
}
}
return nil
}
if strings.HasPrefix(topic, "typing:") {
// Same rule as inbox: you can only listen for "who's typing to ME".
if authed == "" {
return fmt.Errorf("typing:* requires auth")
}
if authX != "" {
want := strings.TrimPrefix(topic, "typing:")
if want != authX {
return fmt.Errorf("typing:* only for your own x25519")
}
}
return nil
}
// Unknown scoped form — default-deny.
return fmt.Errorf("topic %q not recognised", topic)
}
// fanout sends frame to every client subscribed to any of the given topics.
func (h *WSHub) fanout(frame wsServerFrame, topics []string) {
buf, err := json.Marshal(frame)
if err != nil {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
c.mu.RLock()
matched := false
for _, t := range topics {
if _, ok := c.subs[t]; ok {
matched = true
break
}
}
c.mu.RUnlock()
if !matched {
continue
}
select {
case c.send <- buf:
default:
// Outbox full — drop and notify once.
lagFrame, _ := json.Marshal(wsServerFrame{Event: "lag"})
select {
case c.send <- lagFrame:
default:
}
}
}
}
// ── public emit methods ───────────────────────────────────────────────────────
// EmitBlock notifies subscribers of the `blocks` topic.
func (h *WSHub) EmitBlock(b *blockchain.Block) {
data, _ := json.Marshal(SSEBlockEvent{
Index: b.Index,
Hash: b.HashHex(),
TxCount: len(b.Transactions),
Validator: b.Validator,
Timestamp: b.Timestamp.UTC().Format(time.RFC3339),
})
h.fanout(wsServerFrame{Event: "block", Data: data}, []string{"blocks"})
}
// EmitTx notifies:
// - `tx` topic (firehose)
// - `addr:<from>` topic
// - `addr:<to>` topic (if distinct from from)
//
// Synthetic BLOCK_REWARD transactions use `addr:<to>` only (the validator).
func (h *WSHub) EmitTx(tx *blockchain.Transaction) {
data, _ := json.Marshal(SSETxEvent{
ID: tx.ID,
TxType: tx.Type,
From: tx.From,
To: tx.To,
Amount: tx.Amount,
Fee: tx.Fee,
})
topics := []string{"tx"}
if tx.From != "" {
topics = append(topics, "addr:"+tx.From)
}
if tx.To != "" && tx.To != tx.From {
topics = append(topics, "addr:"+tx.To)
}
h.fanout(wsServerFrame{Event: "tx", Data: data}, topics)
}
// EmitContractLog fans out to the `contract_log` and `contract:<id>` topics.
func (h *WSHub) EmitContractLog(entry blockchain.ContractLogEntry) {
data, _ := json.Marshal(entry)
topics := []string{"contract_log", "contract:" + entry.ContractID}
h.fanout(wsServerFrame{Event: "contract_log", Data: data}, topics)
}
// EmitInbox pushes a relay envelope summary to subscribers of the recipient's
// inbox topic. `envelope` is the full relay.Envelope but we only serialise a
// minimal shape here — the client can refetch from /api/relay/inbox for the
// ciphertext if it missed a frame.
//
// Called from relay.Mailbox.onStore (wired in cmd/node/main.go). Avoids
// importing the relay package here to keep the hub dependency-light.
func (h *WSHub) EmitInbox(recipientX25519 string, envelopeSummary any) {
data, err := json.Marshal(envelopeSummary)
if err != nil {
return
}
topics := []string{"inbox", "inbox:" + recipientX25519}
h.fanout(wsServerFrame{Event: "inbox", Data: data}, topics)
}
// EmitBlockWithTxs is the matching convenience method of SSEHub.
func (h *WSHub) EmitBlockWithTxs(b *blockchain.Block) {
h.EmitBlock(b)
for _, tx := range b.Transactions {
h.EmitTx(tx)
}
}
// ── per-client bookkeeping ───────────────────────────────────────────────────
type wsClient struct {
conn *websocket.Conn
send chan []byte
mu sync.RWMutex
subs map[string]struct{}
// auth state. authNonce is set when the connection opens; the client
// signs it and sends via the `auth` op. On success we store the
// pubkey and (if available) the matching X25519 key so scoped
// subscriptions can be validated without a DB lookup on each op.
authNonce string
authPubKey string // Ed25519 pubkey hex, empty = unauthenticated
authX25519 string // X25519 pubkey hex, empty if not looked up
// remoteIP is stored so removeClient can decrement the per-IP counter.
remoteIP string
// tokenOK is set at upgrade time: true if the connection passed the
// access-token check (or no token was required). submit_tx ops are
// rejected when tokenOK is false — matches the HTTP POST /api/tx
// behaviour so a private node's write surface is uniform across
// transports.
tokenOK bool
}
func (c *wsClient) sendFrame(f wsServerFrame) {
buf, err := json.Marshal(f)
if err != nil {
return
}
select {
case c.send <- buf:
default:
// Client outbox full; drop. The lag indicator in fanout handles
// recovery notifications; control-plane frames (errors/acks) from
// readLoop are best-effort.
_ = fmt.Sprint // silence unused import on trimmed builds
}
}