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