- Backend REST/gateway/track routes, analytics, Blockscout proxy paths. - Frontend wallet and liquidity surfaces; MetaMask token list alignment. - Deployment docs, verification scripts, address inventory updates. Check: go build ./... under backend/ (pass). Made-with: Cursor
299 lines
7.2 KiB
Go
299 lines
7.2 KiB
Go
package rest
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// handleEtherscanAPI handles GET /api?module=...&action=...
|
|
// This provides Etherscan-compatible API endpoints
|
|
func (s *Server) handleEtherscanAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w)
|
|
return
|
|
}
|
|
if !s.requireDB(w) {
|
|
return
|
|
}
|
|
|
|
module := r.URL.Query().Get("module")
|
|
action := r.URL.Query().Get("action")
|
|
|
|
// Etherscan-compatible response structure
|
|
type EtherscanResponse struct {
|
|
Status string `json:"status"`
|
|
Message string `json:"message"`
|
|
Result interface{} `json:"result"`
|
|
}
|
|
|
|
// Validate required parameters
|
|
if module == "" || action == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
response := EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Params 'module' and 'action' are required parameters",
|
|
Result: nil,
|
|
}
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
var response EtherscanResponse
|
|
|
|
switch module {
|
|
case "block":
|
|
switch action {
|
|
case "eth_block_number":
|
|
// Get latest block number
|
|
var blockNumber int64
|
|
err := s.db.QueryRow(ctx,
|
|
`SELECT MAX(number) FROM blocks WHERE chain_id = $1`,
|
|
s.chainID,
|
|
).Scan(&blockNumber)
|
|
if err != nil {
|
|
response = EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Error",
|
|
Result: "0x0",
|
|
}
|
|
} else {
|
|
response = EtherscanResponse{
|
|
Status: "1",
|
|
Message: "OK",
|
|
Result: fmt.Sprintf("0x%x", blockNumber),
|
|
}
|
|
}
|
|
|
|
case "eth_get_block_by_number":
|
|
tag := r.URL.Query().Get("tag")
|
|
boolean := r.URL.Query().Get("boolean") == "true"
|
|
|
|
// Parse block number from tag (can be "latest", "0x...", or decimal)
|
|
var blockNumber int64
|
|
if tag == "latest" {
|
|
err := s.db.QueryRow(ctx,
|
|
`SELECT MAX(number) FROM blocks WHERE chain_id = $1`,
|
|
s.chainID,
|
|
).Scan(&blockNumber)
|
|
if err != nil {
|
|
response = EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Error",
|
|
Result: nil,
|
|
}
|
|
break
|
|
}
|
|
} else if len(tag) > 2 && tag[:2] == "0x" {
|
|
// Hex format
|
|
parsed, err := strconv.ParseInt(tag[2:], 16, 64)
|
|
if err != nil {
|
|
response = EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Invalid block number",
|
|
Result: nil,
|
|
}
|
|
break
|
|
}
|
|
blockNumber = parsed
|
|
} else {
|
|
// Decimal format
|
|
parsed, err := strconv.ParseInt(tag, 10, 64)
|
|
if err != nil {
|
|
response = EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Invalid block number",
|
|
Result: nil,
|
|
}
|
|
break
|
|
}
|
|
blockNumber = parsed
|
|
}
|
|
|
|
// Get block data
|
|
var hash, parentHash, miner string
|
|
var timestamp time.Time
|
|
var transactionCount int
|
|
var gasUsed, gasLimit int64
|
|
var transactions interface{}
|
|
|
|
query := `
|
|
SELECT hash, parent_hash, timestamp, miner, transaction_count, gas_used, gas_limit
|
|
FROM blocks
|
|
WHERE chain_id = $1 AND number = $2
|
|
`
|
|
|
|
err := s.db.QueryRow(ctx, query, s.chainID, blockNumber).Scan(
|
|
&hash, &parentHash, ×tamp, &miner, &transactionCount, &gasUsed, &gasLimit,
|
|
)
|
|
if err != nil {
|
|
response = EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Block not found",
|
|
Result: nil,
|
|
}
|
|
break
|
|
}
|
|
|
|
if boolean {
|
|
txObjects, err := s.loadEtherscanBlockTransactions(ctx, blockNumber)
|
|
if err != nil {
|
|
response = EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Error",
|
|
Result: nil,
|
|
}
|
|
break
|
|
}
|
|
transactions = txObjects
|
|
} else {
|
|
txHashes, err := s.loadEtherscanBlockTransactionHashes(ctx, blockNumber)
|
|
if err != nil {
|
|
response = EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Error",
|
|
Result: nil,
|
|
}
|
|
break
|
|
}
|
|
transactions = txHashes
|
|
}
|
|
|
|
blockResult := map[string]interface{}{
|
|
"number": fmt.Sprintf("0x%x", blockNumber),
|
|
"hash": hash,
|
|
"parentHash": parentHash,
|
|
"timestamp": fmt.Sprintf("0x%x", timestamp.Unix()),
|
|
"miner": miner,
|
|
"transactions": transactions,
|
|
"transactionCount": fmt.Sprintf("0x%x", transactionCount),
|
|
"gasUsed": fmt.Sprintf("0x%x", gasUsed),
|
|
"gasLimit": fmt.Sprintf("0x%x", gasLimit),
|
|
}
|
|
|
|
response = EtherscanResponse{
|
|
Status: "1",
|
|
Message: "OK",
|
|
Result: blockResult,
|
|
}
|
|
|
|
default:
|
|
response = EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Invalid action",
|
|
Result: nil,
|
|
}
|
|
}
|
|
|
|
default:
|
|
response = EtherscanResponse{
|
|
Status: "0",
|
|
Message: "Invalid module",
|
|
Result: nil,
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (s *Server) loadEtherscanBlockTransactionHashes(ctx context.Context, blockNumber int64) ([]string, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT hash
|
|
FROM transactions
|
|
WHERE chain_id = $1 AND block_number = $2
|
|
ORDER BY transaction_index
|
|
`, s.chainID, blockNumber)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
hashes := make([]string, 0)
|
|
for rows.Next() {
|
|
var txHash string
|
|
if err := rows.Scan(&txHash); err != nil {
|
|
return nil, err
|
|
}
|
|
hashes = append(hashes, txHash)
|
|
}
|
|
|
|
return hashes, rows.Err()
|
|
}
|
|
|
|
func (s *Server) loadEtherscanBlockTransactions(ctx context.Context, blockNumber int64) ([]map[string]interface{}, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT hash, block_hash, transaction_index, from_address, to_address, value::text,
|
|
COALESCE(gas_price, 0), gas_limit, nonce, COALESCE(input_data, '')
|
|
FROM transactions
|
|
WHERE chain_id = $1 AND block_number = $2
|
|
ORDER BY transaction_index
|
|
`, s.chainID, blockNumber)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
transactions := make([]map[string]interface{}, 0)
|
|
for rows.Next() {
|
|
var hash, blockHash, fromAddress, value, inputData string
|
|
var toAddress sql.NullString
|
|
var transactionIndex int
|
|
var gasPrice, gasLimit, nonce int64
|
|
if err := rows.Scan(&hash, &blockHash, &transactionIndex, &fromAddress, &toAddress, &value, &gasPrice, &gasLimit, &nonce, &inputData); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tx := map[string]interface{}{
|
|
"hash": hash,
|
|
"blockHash": blockHash,
|
|
"blockNumber": fmt.Sprintf("0x%x", blockNumber),
|
|
"transactionIndex": fmt.Sprintf("0x%x", transactionIndex),
|
|
"from": fromAddress,
|
|
"value": decimalStringToHex(value),
|
|
"gasPrice": fmt.Sprintf("0x%x", gasPrice),
|
|
"gas": fmt.Sprintf("0x%x", gasLimit),
|
|
"nonce": fmt.Sprintf("0x%x", nonce),
|
|
"input": normalizeHexInput(inputData),
|
|
}
|
|
if toAddress.Valid && toAddress.String != "" {
|
|
tx["to"] = toAddress.String
|
|
} else {
|
|
tx["to"] = nil
|
|
}
|
|
|
|
transactions = append(transactions, tx)
|
|
}
|
|
|
|
return transactions, rows.Err()
|
|
}
|
|
|
|
func decimalStringToHex(value string) string {
|
|
parsed, ok := new(big.Int).SetString(value, 10)
|
|
if !ok {
|
|
return "0x0"
|
|
}
|
|
return "0x" + parsed.Text(16)
|
|
}
|
|
|
|
func normalizeHexInput(input string) string {
|
|
trimmed := strings.TrimSpace(input)
|
|
if trimmed == "" {
|
|
return "0x"
|
|
}
|
|
if strings.HasPrefix(trimmed, "0x") {
|
|
return trimmed
|
|
}
|
|
return "0x" + trimmed
|
|
}
|