feat(explorer): dynamic feeds, wallet SSR alignment, and detail pagination

Align wallet SSR with report token-list, dedupe featured v1 tokens, refresh home and wallet snapshots on a 60s cadence, and drive vanilla SPA chain add/watch from API metadata. Add shared pagination/tabs for address, token, and transaction pages, extend token aggregation helpers, and harden stats API with tests and health checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-05-22 17:58:27 -07:00
parent ca1394c579
commit 4b747f0309
23 changed files with 1030 additions and 166 deletions

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/explorer/backend/api/freshness"
"github.com/jackc/pgx/v5"
)
type explorerStats struct {
@@ -34,6 +35,14 @@ type explorerGasPrices struct {
type statsQueryFunc = freshness.QueryRowFunc
type statsErrorRow struct {
err error
}
func (r statsErrorRow) Scan(dest ...any) error {
return r.err
}
func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) {
var value sql.NullFloat64
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
@@ -191,23 +200,72 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
return stats, nil
}
func loadExplorerStatsFallback(ctx context.Context, chainID int, cause error) explorerStats {
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
now := time.Now().UTC()
queryErr := fmt.Errorf("blockscout database unavailable")
if cause != nil {
queryErr = cause
}
queryRow := func(context.Context, string, ...any) pgx.Row {
return statsErrorRow{err: queryErr}
}
snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
ctx,
chainID,
queryRow,
func(ctx context.Context) (*freshness.Reference, error) {
return freshness.ProbeChainHead(ctx, rpcURL)
},
now,
nil,
nil,
)
if err != nil {
if sampling.Issues == nil {
sampling.Issues = map[string]string{}
}
sampling.Issues["fallback_freshness"] = err.Error()
}
if sampling.Issues == nil {
sampling.Issues = map[string]string{}
}
if cause != nil {
sampling.Issues["stats_database"] = cause.Error()
}
stats := explorerStats{
Freshness: snapshot,
Completeness: completeness,
Sampling: sampling,
Diagnostics: diagnostics,
}
if snapshot.ChainHead.BlockNumber != nil {
stats.LatestBlock = *snapshot.ChainHead.BlockNumber
}
return stats
}
// handleStats handles GET /api/v2/stats
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
if !s.requireDB(w) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable")
return
var stats explorerStats
if s.db == nil {
stats = loadExplorerStatsFallback(ctx, s.chainID, fmt.Errorf("database pool is not configured"))
} else {
var err error
stats, err = loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
if err != nil {
stats = loadExplorerStatsFallback(ctx, s.chainID, err)
}
}
w.Header().Set("Content-Type", "application/json")

View File

@@ -136,3 +136,33 @@ func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "query total transactions")
}
func TestLoadExplorerStatsFallbackUsesRPCHead(t *testing.T) {
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Method string `json:"method"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
w.Header().Set("Content-Type", "application/json")
switch req.Method {
case "eth_blockNumber":
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x4d2"}`))
case "eth_getBlockByNumber":
ts := time.Now().Add(-3 * time.Second).Unix()
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`))
default:
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
}
}))
defer rpc.Close()
t.Setenv("RPC_URL", rpc.URL)
stats := loadExplorerStatsFallback(context.Background(), 138, errors.New("database down"))
require.Equal(t, int64(1234), stats.LatestBlock)
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
require.Equal(t, int64(1234), *stats.Freshness.ChainHead.BlockNumber)
require.Equal(t, freshness.CompletenessUnavailable, stats.Completeness.TransactionsFeed)
require.Contains(t, stats.Sampling.Issues, "stats_database")
require.Contains(t, stats.Sampling.Issues["latest_indexed_block"], "database down")
}