Files
dchain/node/api_explorer.go
vsecoder 7e7393e4f8 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
2026-04-17 14:16:44 +03:00

554 lines
16 KiB
Go

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"})
}
}