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:
367
node/api_chain_v2.go
Normal file
367
node/api_chain_v2.go
Normal 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
102
node/api_channels.go
Normal 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
181
node/api_common.go
Normal 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
199
node/api_contract.go
Normal 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
553
node/api_explorer.go
Normal 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
299
node/api_guards.go
Normal 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
162
node/api_onboarding.go
Normal 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
320
node/api_relay.go
Normal 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
270
node/api_routes.go
Normal 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
183
node/api_tokens.go
Normal 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
201
node/api_update_check.go
Normal 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
116
node/api_well_known.go
Normal 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,
|
||||
})
|
||||
})
|
||||
}
|
||||
93
node/api_well_known_version.go
Normal file
93
node/api_well_known_version.go
Normal 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
119
node/events.go
Normal 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
224
node/explorer/address.html
Normal 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
414
node/explorer/address.js
Normal 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
484
node/explorer/app.js
Normal 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
197
node/explorer/common.js
Normal file
@@ -0,0 +1,197 @@
|
||||
(function() {
|
||||
function esc(v) {
|
||||
return String(v === undefined || v === null ? '' : v)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
222
node/explorer/contract.html
Normal 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
295
node/explorer/contract.js
Normal 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
166
node/explorer/index.html
Normal 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
146
node/explorer/node.html
Normal 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
110
node/explorer/node.js
Normal 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
78
node/explorer/relays.html
Normal 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
68
node/explorer/relays.js
Normal 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
1704
node/explorer/style.css
Normal file
File diff suppressed because it is too large
Load Diff
89
node/explorer/token.html
Normal file
89
node/explorer/token.html
Normal 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
180
node/explorer/token.js
Normal 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
100
node/explorer/tokens.html
Normal 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 & 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
121
node/explorer/tokens.js
Normal 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
186
node/explorer/tx.html
Normal 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
334
node/explorer/tx.js
Normal 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);
|
||||
}
|
||||
|
||||
})();
|
||||
81
node/explorer/validators.html
Normal file
81
node/explorer/validators.html
Normal 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>
|
||||
58
node/explorer/validators.js
Normal file
58
node/explorer/validators.js
Normal 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
166
node/explorer_assets.go
Normal 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
232
node/metrics.go
Normal 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
186
node/sse.go
Normal 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
316
node/stats.go
Normal 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
23
node/swagger/index.html
Normal 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
727
node/swagger/openapi.json
Normal 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
32
node/swagger_assets.go
Normal 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
70
node/version/version.go
Normal 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
696
node/ws.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user