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))
|
||||
}
|
||||
Reference in New Issue
Block a user