- 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
480 lines
13 KiB
Go
480 lines
13 KiB
Go
package rest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
hexAddrRe = regexp.MustCompile(`(?i)^0x[0-9a-f]{40}$`)
|
|
hexTxRe = regexp.MustCompile(`(?i)^0x[0-9a-f]{64}$`)
|
|
)
|
|
|
|
type liquidityCacheEntry struct {
|
|
body []byte
|
|
until time.Time
|
|
ctype string
|
|
}
|
|
|
|
var liquidityPoolsCache sync.Map // string -> liquidityCacheEntry
|
|
|
|
var missionControlMetrics struct {
|
|
liquidityCacheHits uint64
|
|
liquidityCacheMisses uint64
|
|
liquidityUpstreamFailure uint64
|
|
bridgeTraceRequests uint64
|
|
bridgeTraceFailures uint64
|
|
}
|
|
|
|
func tokenAggregationBase() string {
|
|
for _, k := range []string{"TOKEN_AGGREGATION_BASE_URL", "TOKEN_AGGREGATION_URL"} {
|
|
if u := strings.TrimSpace(os.Getenv(k)); u != "" {
|
|
return strings.TrimRight(u, "/")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func blockscoutInternalBase() string {
|
|
u := strings.TrimSpace(os.Getenv("BLOCKSCOUT_INTERNAL_URL"))
|
|
if u == "" {
|
|
u = "http://127.0.0.1:4000"
|
|
}
|
|
return strings.TrimRight(u, "/")
|
|
}
|
|
|
|
func missionControlChainID() string {
|
|
if s := strings.TrimSpace(os.Getenv("CHAIN_ID")); s != "" {
|
|
return s
|
|
}
|
|
return "138"
|
|
}
|
|
|
|
func rpcURL() string {
|
|
if s := strings.TrimSpace(os.Getenv("RPC_URL")); s != "" {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// handleMissionControlLiquidityTokenPath serves GET .../mission-control/liquidity/token/{addr}/pools (cached proxy to token-aggregation).
|
|
func (s *Server) handleMissionControlLiquidityTokenPath(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w)
|
|
return
|
|
}
|
|
rest := strings.TrimPrefix(r.URL.Path, "/api/v1/mission-control/liquidity/token/")
|
|
rest = strings.Trim(rest, "/")
|
|
parts := strings.Split(rest, "/")
|
|
if len(parts) < 2 || parts[1] != "pools" {
|
|
writeError(w, http.StatusNotFound, "not_found", "expected /liquidity/token/{address}/pools")
|
|
return
|
|
}
|
|
addr := strings.TrimSpace(parts[0])
|
|
if !hexAddrRe.MatchString(addr) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid token address")
|
|
return
|
|
}
|
|
base := tokenAggregationBase()
|
|
if base == "" {
|
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "TOKEN_AGGREGATION_BASE_URL not configured")
|
|
return
|
|
}
|
|
chain := missionControlChainID()
|
|
cacheKey := strings.ToLower(addr) + "|" + chain
|
|
bypassCache := r.URL.Query().Get("refresh") == "1" ||
|
|
r.URL.Query().Get("noCache") == "1" ||
|
|
strings.Contains(strings.ToLower(r.Header.Get("Cache-Control")), "no-cache") ||
|
|
strings.Contains(strings.ToLower(r.Header.Get("Cache-Control")), "no-store")
|
|
if ent, ok := liquidityPoolsCache.Load(cacheKey); ok && !bypassCache {
|
|
e := ent.(liquidityCacheEntry)
|
|
if time.Now().Before(e.until) {
|
|
atomic.AddUint64(&missionControlMetrics.liquidityCacheHits, 1)
|
|
w.Header().Set("X-Mission-Control-Cache", "hit")
|
|
if e.ctype != "" {
|
|
w.Header().Set("Content-Type", e.ctype)
|
|
} else {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(e.body)
|
|
return
|
|
}
|
|
}
|
|
atomic.AddUint64(&missionControlMetrics.liquidityCacheMisses, 1)
|
|
if bypassCache {
|
|
w.Header().Set("X-Mission-Control-Cache", "bypass")
|
|
} else {
|
|
w.Header().Set("X-Mission-Control-Cache", "miss")
|
|
}
|
|
|
|
up, err := url.Parse(base + "/api/v1/tokens/" + url.PathEscape(addr) + "/pools")
|
|
if err != nil {
|
|
writeInternalError(w, "bad upstream URL")
|
|
return
|
|
}
|
|
q := up.Query()
|
|
q.Set("chainId", chain)
|
|
up.RawQuery = q.Encode()
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, up.String(), nil)
|
|
if err != nil {
|
|
writeInternalError(w, err.Error())
|
|
return
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1)
|
|
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss upstream_error=%v", strings.ToLower(addr), chain, err)
|
|
writeError(w, http.StatusBadGateway, "bad_gateway", err.Error())
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1)
|
|
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss read_error=%v", strings.ToLower(addr), chain, err)
|
|
writeError(w, http.StatusBadGateway, "bad_gateway", "read upstream body failed")
|
|
return
|
|
}
|
|
ctype := resp.Header.Get("Content-Type")
|
|
if ctype == "" {
|
|
ctype = "application/json"
|
|
}
|
|
if resp.StatusCode == http.StatusOK {
|
|
liquidityPoolsCache.Store(cacheKey, liquidityCacheEntry{
|
|
body: body,
|
|
until: time.Now().Add(30 * time.Second),
|
|
ctype: ctype,
|
|
})
|
|
cacheMode := "miss"
|
|
if bypassCache {
|
|
cacheMode = "bypass-refresh"
|
|
}
|
|
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=%s stored_ttl_sec=30", strings.ToLower(addr), chain, cacheMode)
|
|
} else {
|
|
atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1)
|
|
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss upstream_status=%d", strings.ToLower(addr), chain, resp.StatusCode)
|
|
}
|
|
w.Header().Set("Content-Type", ctype)
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
var (
|
|
registryOnce sync.Once
|
|
registryAddrToKey map[string]string
|
|
registryLoadErr error
|
|
)
|
|
|
|
func firstReadableFile(paths []string) ([]byte, string, error) {
|
|
for _, p := range paths {
|
|
if strings.TrimSpace(p) == "" {
|
|
continue
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err == nil && len(b) > 0 {
|
|
return b, p, nil
|
|
}
|
|
}
|
|
return nil, "", fmt.Errorf("no readable file found")
|
|
}
|
|
|
|
func loadAddressRegistry138() map[string]string {
|
|
registryOnce.Do(func() {
|
|
registryAddrToKey = make(map[string]string)
|
|
var masterPaths []string
|
|
if p := strings.TrimSpace(os.Getenv("SMART_CONTRACTS_MASTER_JSON")); p != "" {
|
|
masterPaths = append(masterPaths, p)
|
|
}
|
|
masterPaths = append(masterPaths,
|
|
"config/smart-contracts-master.json",
|
|
"../config/smart-contracts-master.json",
|
|
"../../config/smart-contracts-master.json",
|
|
)
|
|
raw, masterPath, _ := firstReadableFile(masterPaths)
|
|
if len(raw) == 0 {
|
|
registryLoadErr = fmt.Errorf("smart-contracts-master.json not found")
|
|
return
|
|
}
|
|
var root map[string]interface{}
|
|
if err := json.Unmarshal(raw, &root); err != nil {
|
|
registryLoadErr = err
|
|
return
|
|
}
|
|
chains, _ := root["chains"].(map[string]interface{})
|
|
c138, _ := chains["138"].(map[string]interface{})
|
|
contracts, _ := c138["contracts"].(map[string]interface{})
|
|
for k, v := range contracts {
|
|
s, ok := v.(string)
|
|
if !ok || !hexAddrRe.MatchString(s) {
|
|
continue
|
|
}
|
|
registryAddrToKey[strings.ToLower(s)] = k
|
|
}
|
|
|
|
var inventoryPaths []string
|
|
if p := strings.TrimSpace(os.Getenv("EXPLORER_ADDRESS_INVENTORY_FILE")); p != "" {
|
|
inventoryPaths = append(inventoryPaths, p)
|
|
}
|
|
if masterPath != "" {
|
|
inventoryPaths = append(inventoryPaths, filepath.Join(filepath.Dir(masterPath), "address-inventory.json"))
|
|
}
|
|
inventoryPaths = append(inventoryPaths,
|
|
"explorer-monorepo/config/address-inventory.json",
|
|
"config/address-inventory.json",
|
|
"../config/address-inventory.json",
|
|
"../../config/address-inventory.json",
|
|
)
|
|
inventoryRaw, _, invErr := firstReadableFile(inventoryPaths)
|
|
if invErr != nil || len(inventoryRaw) == 0 {
|
|
return
|
|
}
|
|
var inventoryRoot struct {
|
|
Inventory map[string]string `json:"inventory"`
|
|
}
|
|
if err := json.Unmarshal(inventoryRaw, &inventoryRoot); err != nil {
|
|
return
|
|
}
|
|
for k, v := range inventoryRoot.Inventory {
|
|
if !hexAddrRe.MatchString(v) {
|
|
continue
|
|
}
|
|
addr := strings.ToLower(v)
|
|
if _, exists := registryAddrToKey[addr]; exists {
|
|
continue
|
|
}
|
|
registryAddrToKey[addr] = k
|
|
}
|
|
})
|
|
return registryAddrToKey
|
|
}
|
|
|
|
func jsonStringField(m map[string]interface{}, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v, ok := m[k].(string); ok && v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractEthAddress(val interface{}) string {
|
|
switch t := val.(type) {
|
|
case string:
|
|
if hexAddrRe.MatchString(strings.TrimSpace(t)) {
|
|
return strings.ToLower(strings.TrimSpace(t))
|
|
}
|
|
case map[string]interface{}:
|
|
if h := jsonStringField(t, "hash", "address"); h != "" && hexAddrRe.MatchString(h) {
|
|
return strings.ToLower(h)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fetchBlockscoutTransaction(ctx context.Context, tx string) ([]byte, int, error) {
|
|
fetchURL := blockscoutInternalBase() + "/api/v2/transactions/" + url.PathEscape(tx)
|
|
timeouts := []time.Duration{15 * time.Second, 25 * time.Second}
|
|
var lastBody []byte
|
|
var lastStatus int
|
|
var lastErr error
|
|
|
|
for idx, timeout := range timeouts {
|
|
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
req, err := http.NewRequestWithContext(attemptCtx, http.MethodGet, fetchURL, nil)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, 0, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
cancel()
|
|
lastErr = err
|
|
if idx == len(timeouts)-1 {
|
|
return nil, 0, err
|
|
}
|
|
continue
|
|
}
|
|
|
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
resp.Body.Close()
|
|
cancel()
|
|
if readErr != nil {
|
|
lastErr = readErr
|
|
if idx == len(timeouts)-1 {
|
|
return nil, 0, readErr
|
|
}
|
|
continue
|
|
}
|
|
|
|
lastBody = body
|
|
lastStatus = resp.StatusCode
|
|
if resp.StatusCode == http.StatusOK {
|
|
return body, resp.StatusCode, nil
|
|
}
|
|
if resp.StatusCode < 500 || idx == len(timeouts)-1 {
|
|
return body, resp.StatusCode, nil
|
|
}
|
|
}
|
|
|
|
return lastBody, lastStatus, lastErr
|
|
}
|
|
|
|
func fetchTransactionViaRPC(ctx context.Context, tx string) (string, string, error) {
|
|
base := rpcURL()
|
|
if base == "" {
|
|
return "", "", fmt.Errorf("RPC_URL not configured")
|
|
}
|
|
|
|
payload, err := json.Marshal(map[string]interface{}{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "eth_getTransactionByHash",
|
|
"params": []interface{}{tx},
|
|
})
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", "", fmt.Errorf("rpc HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var rpcResp struct {
|
|
Result map[string]interface{} `json:"result"`
|
|
Error map[string]interface{} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(body, &rpcResp); err != nil {
|
|
return "", "", err
|
|
}
|
|
if rpcResp.Error != nil {
|
|
return "", "", fmt.Errorf("rpc error")
|
|
}
|
|
if rpcResp.Result == nil {
|
|
return "", "", fmt.Errorf("transaction not found")
|
|
}
|
|
|
|
fromAddr := extractEthAddress(jsonStringField(rpcResp.Result, "from"))
|
|
toAddr := extractEthAddress(jsonStringField(rpcResp.Result, "to"))
|
|
if fromAddr == "" && toAddr == "" {
|
|
return "", "", fmt.Errorf("transaction missing from/to")
|
|
}
|
|
return fromAddr, toAddr, nil
|
|
}
|
|
|
|
// HandleMissionControlBridgeTrace handles GET /api/v1/mission-control/bridge/trace?tx=0x...
|
|
func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddUint64(&missionControlMetrics.bridgeTraceRequests, 1)
|
|
if r.Method != http.MethodGet {
|
|
writeMethodNotAllowed(w)
|
|
return
|
|
}
|
|
tx := strings.TrimSpace(r.URL.Query().Get("tx"))
|
|
if tx == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "missing tx query parameter")
|
|
return
|
|
}
|
|
if !hexTxRe.MatchString(tx) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid transaction hash")
|
|
return
|
|
}
|
|
|
|
reg := loadAddressRegistry138()
|
|
publicBase := strings.TrimRight(strings.TrimSpace(os.Getenv("EXPLORER_PUBLIC_BASE")), "/")
|
|
if publicBase == "" {
|
|
publicBase = "https://explorer.d-bis.org"
|
|
}
|
|
|
|
fromAddr := ""
|
|
toAddr := ""
|
|
fromLabel := ""
|
|
toLabel := ""
|
|
source := "blockscout"
|
|
|
|
body, statusCode, err := fetchBlockscoutTransaction(r.Context(), tx)
|
|
if err == nil && statusCode == http.StatusOK {
|
|
var txDoc map[string]interface{}
|
|
if err := json.Unmarshal(body, &txDoc); err != nil {
|
|
err = fmt.Errorf("invalid blockscout JSON")
|
|
} else {
|
|
fromAddr = extractEthAddress(txDoc["from"])
|
|
toAddr = extractEthAddress(txDoc["to"])
|
|
}
|
|
}
|
|
|
|
if fromAddr == "" && toAddr == "" {
|
|
rpcFrom, rpcTo, rpcErr := fetchTransactionViaRPC(r.Context(), tx)
|
|
if rpcErr == nil {
|
|
fromAddr = rpcFrom
|
|
toAddr = rpcTo
|
|
source = "rpc_fallback"
|
|
} else {
|
|
atomic.AddUint64(&missionControlMetrics.bridgeTraceFailures, 1)
|
|
if err != nil {
|
|
log.Printf("mission_control bridge_trace tx=%s fetch_error=%v rpc_fallback_error=%v", strings.ToLower(tx), err, rpcErr)
|
|
writeError(w, http.StatusBadGateway, "bad_gateway", err.Error())
|
|
return
|
|
}
|
|
log.Printf("mission_control bridge_trace tx=%s upstream_status=%d rpc_fallback_error=%v", strings.ToLower(tx), statusCode, rpcErr)
|
|
writeError(w, http.StatusBadGateway, "blockscout_error",
|
|
fmt.Sprintf("blockscout HTTP %d", statusCode))
|
|
return
|
|
}
|
|
}
|
|
|
|
if fromAddr != "" {
|
|
fromLabel = reg[fromAddr]
|
|
}
|
|
if toAddr != "" {
|
|
toLabel = reg[toAddr]
|
|
}
|
|
|
|
out := map[string]interface{}{
|
|
"tx_hash": strings.ToLower(tx),
|
|
"from": fromAddr,
|
|
"from_registry": fromLabel,
|
|
"to": toAddr,
|
|
"to_registry": toLabel,
|
|
"blockscout_url": publicBase + "/tx/" + strings.ToLower(tx),
|
|
"source": source,
|
|
}
|
|
if registryLoadErr != nil && len(reg) == 0 {
|
|
out["registry_warning"] = registryLoadErr.Error()
|
|
}
|
|
log.Printf("mission_control bridge_trace tx=%s from=%s to=%s from_label=%s to_label=%s", strings.ToLower(tx), fromAddr, toAddr, fromLabel, toLabel)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"data": out})
|
|
}
|