Files
dchain/node/api_chain_v2.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

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