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
368 lines
9.2 KiB
Go
368 lines
9.2 KiB
Go
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))
|
|
}
|