Files
explorer-monorepo/backend/api/rest/stats_coin_price.go
defiQUG b87ebee6a1
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 20s
Validate Explorer / frontend (push) Failing after 24s
Validate Explorer / smoke-e2e (push) Has been skipped
feat(explorer): dual-chain wallet metadata, native coin pricing, and UI refresh.
Add Chain 138 wallet network metadata and stats coin-price enrichment; sync frontend explorer SPA, command center, and address/token pages with backend config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 16:16:17 -07:00

151 lines
3.4 KiB
Go

package rest
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
chain138WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
)
type blockscoutCoinStats struct {
CoinPrice *string `json:"coin_price"`
CoinImage *string `json:"coin_image"`
}
type tokenAggregationTokenResponse struct {
Token struct {
Market struct {
PriceUsd *float64 `json:"priceUsd"`
} `json:"market"`
} `json:"token"`
}
func enrichNativeCoinMarket(ctx context.Context, stats *explorerStats) {
if stats.CoinPrice != nil && strings.TrimSpace(*stats.CoinPrice) != "" {
return
}
// Prefer on-chain token-aggregation WETH/ETH oracle over Blockscout CoinGecko on Chain 138.
if price := fetchTokenAggregationEthUsd(ctx); price != "" {
stats.CoinPrice = &price
if image := fetchBlockscoutCoinImage(ctx); image != "" {
stats.CoinImage = &image
}
return
}
bsURL := strings.TrimSpace(os.Getenv("BLOCKSCOUT_STATS_URL"))
if bsURL == "" {
bsURL = "http://127.0.0.1:4000/api/v2/stats"
}
if price, image := fetchBlockscoutCoinMarket(ctx, bsURL); price != "" {
stats.CoinPrice = &price
if image != "" {
stats.CoinImage = &image
}
}
}
func fetchBlockscoutCoinImage(ctx context.Context) string {
bsURL := strings.TrimSpace(os.Getenv("BLOCKSCOUT_STATS_URL"))
if bsURL == "" {
bsURL = "http://127.0.0.1:4000/api/v2/stats"
}
_, image := fetchBlockscoutCoinMarket(ctx, bsURL)
return image
}
func fetchBlockscoutCoinMarket(ctx context.Context, url string) (price string, image string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", ""
}
client := &http.Client{Timeout: 4 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", ""
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", ""
}
var payload blockscoutCoinStats
if err := json.Unmarshal(body, &payload); err != nil {
return "", ""
}
if payload.CoinPrice != nil {
price = strings.TrimSpace(*payload.CoinPrice)
}
if payload.CoinImage != nil {
image = strings.TrimSpace(*payload.CoinImage)
}
return price, image
}
func tokenAggregationAPIV1Base() string {
base := strings.TrimRight(strings.TrimSpace(firstNonEmptyEnv(
"TOKEN_AGGREGATION_API_BASE",
"TOKEN_AGGREGATION_URL",
"TOKEN_AGGREGATION_BASE_URL",
)), "/")
if base == "" {
return "http://127.0.0.1:3001/api/v1"
}
if strings.HasSuffix(base, "/api/v1") {
return base
}
return base + "/api/v1"
}
func fetchTokenAggregationEthUsd(ctx context.Context) string {
url := fmt.Sprintf("%s/tokens/%s?chainId=138", tokenAggregationAPIV1Base(), chain138WETHAddress)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ""
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return ""
}
var payload tokenAggregationTokenResponse
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
if payload.Token.Market.PriceUsd == nil || *payload.Token.Market.PriceUsd <= 0 {
return ""
}
return formatUsdPrice(*payload.Token.Market.PriceUsd)
}
func formatUsdPrice(value float64) string {
return fmt.Sprintf("%.2f", value)
}