feat: explorer API, wallet, CCIP scripts, and config refresh
- 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
This commit is contained in:
479
backend/api/rest/mission_control.go
Normal file
479
backend/api/rest/mission_control.go
Normal file
@@ -0,0 +1,479 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user