diff --git a/README.md b/README.md
index c0f8c71..2b94579 100644
--- a/README.md
+++ b/README.md
@@ -27,9 +27,9 @@ If the script doesn't work, see `START_HERE.md` for step-by-step manual commands
- **Production (canonical target):** the current **Next.js standalone frontend** in `frontend/src/`, built from `frontend/` with `npm run build` and deployed to VMID 5000 as a Node service behind nginx.
- **Canonical deploy script:** `./scripts/deploy-next-frontend-to-vmid5000.sh`
- **Canonical nginx wiring:** keep `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggregation/api/v1/*`, `/snap/`, and `/health`; proxy `/` and `/_next/` to the frontend service using `deployment/common/nginx-next-frontend-proxy.conf`.
-- **Legacy fallback only:** the static SPA (`frontend/public/index.html` + `explorer-spa.js`) remains in-repo for compatibility, but it is no longer the preferred deployment target.
+- **Legacy fallback only:** the static SPA (`frontend/public/index.html` + `explorer-spa.js`) remains in-repo for compatibility/reference, but it is not a supported primary deployment target.
- **Architecture command center:** `frontend/public/chain138-command-center.html` — tabbed Mermaid topology (Chain 138 hub, network, stack, flows, cross-chain, cW Mainnet, off-chain, integrations). Linked from the SPA **More → Explore → Visual Command Center**.
-- **Legacy static deploy:** `./scripts/deploy-frontend-to-vmid5000.sh` (copies `index.html` and assets to `/var/www/html/`)
+- **Legacy static deploy scripts:** `./scripts/deploy-frontend-to-vmid5000.sh` and `./scripts/deploy.sh` now fail fast with a deprecation message and point to the canonical Next.js deploy path.
- **Frontend review & tasks:** [frontend/FRONTEND_REVIEW.md](frontend/FRONTEND_REVIEW.md), [frontend/FRONTEND_TASKS_AND_REVIEW.md](frontend/FRONTEND_TASKS_AND_REVIEW.md)
## Documentation
diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md
index fe1b764..31d5cbe 100644
--- a/README_DEPLOYMENT.md
+++ b/README_DEPLOYMENT.md
@@ -31,7 +31,7 @@ If scripts don't work, follow `COMPLETE_DEPLOYMENT.md` for step-by-step manual e
- **`docs/README.md`** - Documentation overview and index
- **`docs/EXPLORER_API_ACCESS.md`** - API access, 502 fix, frontend deploy
-- **Frontend deploy only:** `./scripts/deploy-frontend-to-vmid5000.sh` (copies `frontend/public/index.html` to VMID 5000)
+- **Frontend deploy only:** `./scripts/deploy-next-frontend-to-vmid5000.sh` (builds and deploys the current Next standalone frontend to VMID 5000)
- `COMPLETE_DEPLOYMENT.md` - Complete step-by-step guide
- `DEPLOYMENT_FINAL_STATUS.md` - Deployment status report
- `RUN_ALL.md` - Quick reference
@@ -61,4 +61,3 @@ tail -f backend/logs/api-server.log
```
**All deployment steps are ready to execute!**
-
diff --git a/backend/api/freshness/freshness.go b/backend/api/freshness/freshness.go
new file mode 100644
index 0000000..fcdcfcd
--- /dev/null
+++ b/backend/api/freshness/freshness.go
@@ -0,0 +1,398 @@
+package freshness
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type QueryRowFunc func(ctx context.Context, sql string, args ...any) pgx.Row
+
+type Confidence string
+
+const (
+ ConfidenceHigh Confidence = "high"
+ ConfidenceMedium Confidence = "medium"
+ ConfidenceLow Confidence = "low"
+ ConfidenceUnknown Confidence = "unknown"
+)
+
+type Completeness string
+
+const (
+ CompletenessComplete Completeness = "complete"
+ CompletenessPartial Completeness = "partial"
+ CompletenessStale Completeness = "stale"
+ CompletenessUnavailable Completeness = "unavailable"
+)
+
+type Source string
+
+const (
+ SourceReported Source = "reported"
+ SourceDerived Source = "derived"
+ SourceSampled Source = "sampled"
+ SourceUnavailable Source = "unavailable"
+)
+
+type Provenance string
+
+const (
+ ProvenanceRPC Provenance = "rpc"
+ ProvenanceExplorerIndex Provenance = "explorer_index"
+ ProvenanceTxIndex Provenance = "tx_index"
+ ProvenanceMissionFeed Provenance = "mission_control_feed"
+ ProvenanceComposite Provenance = "composite"
+)
+
+type Reference struct {
+ BlockNumber *int64 `json:"block_number"`
+ Timestamp *string `json:"timestamp"`
+ AgeSeconds *int64 `json:"age_seconds"`
+ Hash *string `json:"hash,omitempty"`
+ DistanceFromHead *int64 `json:"distance_from_head,omitempty"`
+ Source Source `json:"source"`
+ Confidence Confidence `json:"confidence"`
+ Provenance Provenance `json:"provenance"`
+ Completeness Completeness `json:"completeness,omitempty"`
+}
+
+type Snapshot struct {
+ ChainHead Reference `json:"chain_head"`
+ LatestIndexedBlock Reference `json:"latest_indexed_block"`
+ LatestIndexedTransaction Reference `json:"latest_indexed_transaction"`
+ LatestNonEmptyBlock Reference `json:"latest_non_empty_block"`
+}
+
+type SummaryCompleteness struct {
+ TransactionsFeed Completeness `json:"transactions_feed"`
+ BlocksFeed Completeness `json:"blocks_feed"`
+ GasMetrics Completeness `json:"gas_metrics"`
+ UtilizationMetric Completeness `json:"utilization_metrics"`
+}
+
+type Sampling struct {
+ StatsGeneratedAt *string `json:"stats_generated_at"`
+ RPCProbeAt *string `json:"rpc_probe_at"`
+ StatsWindowSec *int64 `json:"stats_window_seconds,omitempty"`
+ Issues map[string]string `json:"issues,omitempty"`
+}
+
+type HeadProbeFunc func(ctx context.Context) (*Reference, error)
+
+func ptrInt64(value int64) *int64 { return &value }
+
+func ptrString(value string) *string { return &value }
+
+func unknownReference(provenance Provenance) Reference {
+ return Reference{
+ Source: SourceUnavailable,
+ Confidence: ConfidenceUnknown,
+ Provenance: provenance,
+ Completeness: CompletenessUnavailable,
+ }
+}
+
+func timePointer(value time.Time) *string {
+ if value.IsZero() {
+ return nil
+ }
+ formatted := value.UTC().Format(time.RFC3339)
+ return &formatted
+}
+
+func computeAge(timestamp *string, now time.Time) *int64 {
+ if timestamp == nil || *timestamp == "" {
+ return nil
+ }
+ parsed, err := time.Parse(time.RFC3339, *timestamp)
+ if err != nil {
+ return nil
+ }
+ age := int64(now.Sub(parsed).Seconds())
+ if age < 0 {
+ age = 0
+ }
+ return &age
+}
+
+func classifyIndexedVisibility(age *int64) Completeness {
+ if age == nil {
+ return CompletenessUnavailable
+ }
+ switch {
+ case *age <= 15*60:
+ return CompletenessComplete
+ case *age <= 3*60*60:
+ return CompletenessPartial
+ default:
+ return CompletenessStale
+ }
+}
+
+func classifyBlockFeed(chainHead *int64, indexedHead *int64) Completeness {
+ if chainHead == nil || indexedHead == nil {
+ return CompletenessUnavailable
+ }
+ distance := *chainHead - *indexedHead
+ if distance < 0 {
+ distance = 0
+ }
+ switch {
+ case distance <= 2:
+ return CompletenessComplete
+ case distance <= 32:
+ return CompletenessPartial
+ default:
+ return CompletenessStale
+ }
+}
+
+func classifyMetricPresence[T comparable](value *T) Completeness {
+ if value == nil {
+ return CompletenessUnavailable
+ }
+ return CompletenessComplete
+}
+
+func BuildSnapshot(
+ ctx context.Context,
+ chainID int,
+ queryRow QueryRowFunc,
+ probeHead HeadProbeFunc,
+ now time.Time,
+ averageGasPrice *float64,
+ utilization *float64,
+) (Snapshot, SummaryCompleteness, Sampling, error) {
+ snapshot := Snapshot{
+ ChainHead: unknownReference(ProvenanceRPC),
+ LatestIndexedBlock: unknownReference(ProvenanceExplorerIndex),
+ LatestIndexedTransaction: unknownReference(ProvenanceTxIndex),
+ LatestNonEmptyBlock: unknownReference(ProvenanceTxIndex),
+ }
+ issues := map[string]string{}
+
+ if probeHead != nil {
+ if head, err := probeHead(ctx); err == nil && head != nil {
+ snapshot.ChainHead = *head
+ } else if err != nil {
+ issues["chain_head"] = err.Error()
+ }
+ }
+
+ var latestIndexedBlockNumber int64
+ var latestIndexedBlockTime time.Time
+ if err := queryRow(ctx,
+ `SELECT number, timestamp
+ FROM blocks
+ ORDER BY number DESC
+ LIMIT 1`,
+ ).Scan(&latestIndexedBlockNumber, &latestIndexedBlockTime); err == nil {
+ timestamp := timePointer(latestIndexedBlockTime)
+ snapshot.LatestIndexedBlock = Reference{
+ BlockNumber: ptrInt64(latestIndexedBlockNumber),
+ Timestamp: timestamp,
+ AgeSeconds: computeAge(timestamp, now),
+ Source: SourceReported,
+ Confidence: ConfidenceHigh,
+ Provenance: ProvenanceExplorerIndex,
+ Completeness: CompletenessComplete,
+ }
+ } else {
+ issues["latest_indexed_block"] = err.Error()
+ }
+
+ var latestTxHash string
+ var latestTxBlock int64
+ var latestTxCreatedAt time.Time
+ if err := queryRow(ctx,
+ `SELECT concat('0x', encode(hash, 'hex')), block_number::bigint, COALESCE(block_timestamp, inserted_at)
+ FROM transactions
+ WHERE block_number IS NOT NULL
+ ORDER BY block_number DESC, "index" DESC
+ LIMIT 1`,
+ ).Scan(&latestTxHash, &latestTxBlock, &latestTxCreatedAt); err == nil {
+ timestamp := timePointer(latestTxCreatedAt)
+ snapshot.LatestIndexedTransaction = Reference{
+ BlockNumber: ptrInt64(latestTxBlock),
+ Timestamp: timestamp,
+ AgeSeconds: computeAge(timestamp, now),
+ Hash: ptrString(latestTxHash),
+ Source: SourceReported,
+ Confidence: ConfidenceHigh,
+ Provenance: ProvenanceTxIndex,
+ Completeness: classifyIndexedVisibility(computeAge(timestamp, now)),
+ }
+ } else {
+ issues["latest_indexed_transaction"] = err.Error()
+ }
+
+ var latestNonEmptyBlockNumber int64
+ var latestNonEmptyBlockTime time.Time
+ if err := queryRow(ctx,
+ `SELECT b.number, b.timestamp
+ FROM blocks b
+ WHERE EXISTS (
+ SELECT 1
+ FROM transactions t
+ WHERE t.block_number = b.number
+ )
+ ORDER BY b.number DESC
+ LIMIT 1`,
+ ).Scan(&latestNonEmptyBlockNumber, &latestNonEmptyBlockTime); err == nil {
+ timestamp := timePointer(latestNonEmptyBlockTime)
+ ref := Reference{
+ BlockNumber: ptrInt64(latestNonEmptyBlockNumber),
+ Timestamp: timestamp,
+ AgeSeconds: computeAge(timestamp, now),
+ Source: SourceReported,
+ Confidence: ConfidenceHigh,
+ Provenance: ProvenanceTxIndex,
+ Completeness: classifyIndexedVisibility(computeAge(timestamp, now)),
+ }
+ if snapshot.ChainHead.BlockNumber != nil {
+ distance := *snapshot.ChainHead.BlockNumber - latestNonEmptyBlockNumber
+ if distance < 0 {
+ distance = 0
+ }
+ ref.DistanceFromHead = ptrInt64(distance)
+ }
+ snapshot.LatestNonEmptyBlock = ref
+ } else {
+ issues["latest_non_empty_block"] = err.Error()
+ }
+
+ statsGeneratedAt := now.UTC().Format(time.RFC3339)
+ sampling := Sampling{
+ StatsGeneratedAt: ptrString(statsGeneratedAt),
+ StatsWindowSec: ptrInt64(300),
+ }
+ if len(issues) > 0 {
+ sampling.Issues = issues
+ }
+ if snapshot.ChainHead.Timestamp != nil {
+ sampling.RPCProbeAt = snapshot.ChainHead.Timestamp
+ }
+
+ completeness := SummaryCompleteness{
+ TransactionsFeed: snapshot.LatestIndexedTransaction.Completeness,
+ BlocksFeed: classifyBlockFeed(snapshot.ChainHead.BlockNumber, snapshot.LatestIndexedBlock.BlockNumber),
+ GasMetrics: classifyMetricPresence(averageGasPrice),
+ UtilizationMetric: classifyMetricPresence(utilization),
+ }
+
+ return snapshot, completeness, sampling, nil
+}
+
+func ProbeChainHead(ctx context.Context, rpcURL string) (*Reference, error) {
+ rpcURL = strings.TrimSpace(rpcURL)
+ if rpcURL == "" {
+ return nil, fmt.Errorf("empty rpc url")
+ }
+
+ blockNumberRaw, _, err := postJSONRPC(ctx, rpcURL, "eth_blockNumber", []interface{}{})
+ if err != nil {
+ return nil, err
+ }
+ var blockNumberHex string
+ if err := json.Unmarshal(blockNumberRaw, &blockNumberHex); err != nil {
+ return nil, err
+ }
+ blockNumber, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(blockNumberHex), "0x"), 16, 64)
+ if err != nil {
+ return nil, err
+ }
+
+ blockRaw, _, err := postJSONRPC(ctx, rpcURL, "eth_getBlockByNumber", []interface{}{"latest", false})
+ if err != nil {
+ return nil, err
+ }
+ var latestBlock struct {
+ Timestamp string `json:"timestamp"`
+ }
+ if err := json.Unmarshal(blockRaw, &latestBlock); err != nil {
+ return nil, err
+ }
+ blockTimeHex := strings.TrimSpace(latestBlock.Timestamp)
+ if blockTimeHex == "" {
+ return nil, fmt.Errorf("missing block timestamp")
+ }
+ blockTimestamp, err := strconv.ParseInt(strings.TrimPrefix(blockTimeHex, "0x"), 16, 64)
+ if err != nil {
+ return nil, err
+ }
+ ts := time.Unix(blockTimestamp, 0).UTC()
+ timestamp := ts.Format(time.RFC3339)
+ now := time.Now().UTC()
+ age := int64(now.Sub(ts).Seconds())
+ if age < 0 {
+ age = 0
+ }
+
+ return &Reference{
+ BlockNumber: ptrInt64(blockNumber),
+ Timestamp: ptrString(timestamp),
+ AgeSeconds: ptrInt64(age),
+ Source: SourceReported,
+ Confidence: ConfidenceHigh,
+ Provenance: ProvenanceRPC,
+ Completeness: CompletenessComplete,
+ }, nil
+}
+
+func postJSONRPC(ctx context.Context, rpcURL string, method string, params []interface{}) (json.RawMessage, int64, error) {
+ body, err := json.Marshal(map[string]interface{}{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": method,
+ "params": params,
+ })
+ if err != nil {
+ return nil, 0, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, 0, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 6 * time.Second}
+ start := time.Now()
+ resp, err := client.Do(req)
+ latency := time.Since(start).Milliseconds()
+ if err != nil {
+ return nil, latency, err
+ }
+ defer resp.Body.Close()
+
+ payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, latency, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, latency, fmt.Errorf("http %d", resp.StatusCode)
+ }
+
+ var out struct {
+ Result json.RawMessage `json:"result"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(payload, &out); err != nil {
+ return nil, latency, err
+ }
+ if out.Error != nil && out.Error.Message != "" {
+ return nil, latency, fmt.Errorf("rpc error: %s", out.Error.Message)
+ }
+ return out.Result, latency, nil
+}
diff --git a/backend/api/freshness/freshness_test.go b/backend/api/freshness/freshness_test.go
new file mode 100644
index 0000000..386f6f2
--- /dev/null
+++ b/backend/api/freshness/freshness_test.go
@@ -0,0 +1,192 @@
+package freshness
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/stretchr/testify/require"
+)
+
+type fakeRow struct {
+ scan func(dest ...any) error
+}
+
+func (r fakeRow) Scan(dest ...any) error {
+ return r.scan(dest...)
+}
+
+func TestBuildSnapshotHealthyState(t *testing.T) {
+ now := time.Date(2026, 4, 10, 22, 10, 16, 0, time.UTC)
+ call := 0
+ queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
+ call++
+ switch call {
+ case 1:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 200
+ *dest[1].(*time.Time) = now.Add(-2 * time.Second)
+ return nil
+ }}
+ case 2:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*string) = "0xabc"
+ *dest[1].(*int64) = 198
+ *dest[2].(*time.Time) = now.Add(-5 * time.Second)
+ return nil
+ }}
+ case 3:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 198
+ *dest[1].(*time.Time) = now.Add(-5 * time.Second)
+ return nil
+ }}
+ default:
+ t.Fatalf("unexpected call %d", call)
+ return nil
+ }
+ }
+
+ probe := func(context.Context) (*Reference, error) {
+ ts := now.Add(-1 * time.Second).Format(time.RFC3339)
+ age := int64(1)
+ block := int64(200)
+ return &Reference{
+ BlockNumber: &block,
+ Timestamp: &ts,
+ AgeSeconds: &age,
+ Source: SourceReported,
+ Confidence: ConfidenceHigh,
+ Provenance: ProvenanceRPC,
+ Completeness: CompletenessComplete,
+ }, nil
+ }
+
+ snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
+ require.NoError(t, err)
+ require.Equal(t, int64(200), *snapshot.ChainHead.BlockNumber)
+ require.Equal(t, int64(198), *snapshot.LatestIndexedTransaction.BlockNumber)
+ require.Equal(t, int64(2), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
+ require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
+ require.NotNil(t, sampling.StatsGeneratedAt)
+}
+
+func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
+ now := time.Date(2026, 4, 11, 0, 10, 16, 0, time.UTC)
+ call := 0
+ queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
+ call++
+ switch call {
+ case 1:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 3875999
+ *dest[1].(*time.Time) = now.Add(-3 * time.Second)
+ return nil
+ }}
+ case 2:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*string) = "0xstale"
+ *dest[1].(*int64) = 3860660
+ *dest[2].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute))
+ return nil
+ }}
+ case 3:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 3860660
+ *dest[1].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute))
+ return nil
+ }}
+ default:
+ t.Fatalf("unexpected call %d", call)
+ return nil
+ }
+ }
+
+ probe := func(context.Context) (*Reference, error) {
+ ts := now.Add(-1 * time.Second).Format(time.RFC3339)
+ age := int64(1)
+ block := int64(3876000)
+ return &Reference{
+ BlockNumber: &block,
+ Timestamp: &ts,
+ AgeSeconds: &age,
+ Source: SourceReported,
+ Confidence: ConfidenceHigh,
+ Provenance: ProvenanceRPC,
+ Completeness: CompletenessComplete,
+ }, nil
+ }
+
+ snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
+ require.NoError(t, err)
+ require.Equal(t, int64(15340), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
+ require.Equal(t, CompletenessStale, completeness.TransactionsFeed)
+ require.Equal(t, CompletenessComplete, completeness.BlocksFeed)
+}
+
+func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
+ now := time.Date(2026, 4, 10, 23, 10, 16, 0, time.UTC)
+ call := 0
+ queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
+ call++
+ switch call {
+ case 1:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 3875000
+ *dest[1].(*time.Time) = now.Add(-1 * time.Second)
+ return nil
+ }}
+ case 2:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*string) = "0xquiet"
+ *dest[1].(*int64) = 3874902
+ *dest[2].(*time.Time) = now.Add(-512 * time.Second)
+ return nil
+ }}
+ case 3:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 3874902
+ *dest[1].(*time.Time) = now.Add(-512 * time.Second)
+ return nil
+ }}
+ default:
+ t.Fatalf("unexpected call %d", call)
+ return nil
+ }
+ }
+
+ probe := func(context.Context) (*Reference, error) {
+ ts := now.Add(-1 * time.Second).Format(time.RFC3339)
+ age := int64(1)
+ block := int64(3875000)
+ return &Reference{
+ BlockNumber: &block,
+ Timestamp: &ts,
+ AgeSeconds: &age,
+ Source: SourceReported,
+ Confidence: ConfidenceHigh,
+ Provenance: ProvenanceRPC,
+ Completeness: CompletenessComplete,
+ }, nil
+ }
+
+ snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
+ require.NoError(t, err)
+ require.Equal(t, int64(98), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
+ require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
+}
+
+func TestBuildSnapshotUnknownFieldsRemainNullSafe(t *testing.T) {
+ queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
+ return fakeRow{scan: func(dest ...any) error {
+ return pgx.ErrNoRows
+ }}
+ }
+
+ snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, nil, time.Now().UTC(), nil, nil)
+ require.NoError(t, err)
+ require.Nil(t, snapshot.ChainHead.BlockNumber)
+ require.Equal(t, CompletenessUnavailable, completeness.TransactionsFeed)
+ require.NotNil(t, sampling.StatsGeneratedAt)
+}
diff --git a/backend/api/rest/mission_control.go b/backend/api/rest/mission_control.go
index dad26ad..cb6b313 100644
--- a/backend/api/rest/mission_control.go
+++ b/backend/api/rest/mission_control.go
@@ -48,6 +48,46 @@ func tokenAggregationBase() string {
return ""
}
+func looksLikeGenericUpstreamErrorPayload(body []byte) bool {
+ if len(bytes.TrimSpace(body)) == 0 {
+ return false
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return false
+ }
+
+ errValue, ok := payload["error"].(string)
+ if !ok || strings.TrimSpace(errValue) == "" {
+ return false
+ }
+
+ if _, ok := payload["pools"]; ok {
+ return false
+ }
+ if _, ok := payload["tokens"]; ok {
+ return false
+ }
+ if _, ok := payload["data"]; ok {
+ return false
+ }
+ if _, ok := payload["chains"]; ok {
+ return false
+ }
+ if _, ok := payload["tree"]; ok {
+ return false
+ }
+ if _, ok := payload["quote"]; ok {
+ return false
+ }
+ if status, ok := payload["status"].(string); ok && strings.EqualFold(status, "healthy") {
+ return false
+ }
+
+ return true
+}
+
func blockscoutInternalBase() string {
u := strings.TrimSpace(os.Getenv("BLOCKSCOUT_INTERNAL_URL"))
if u == "" {
@@ -156,6 +196,15 @@ func (s *Server) handleMissionControlLiquidityTokenPath(w http.ResponseWriter, r
if ctype == "" {
ctype = "application/json"
}
+ isGenericSuccessError := resp.StatusCode >= 200 && resp.StatusCode < 300 && looksLikeGenericUpstreamErrorPayload(body)
+ if isGenericSuccessError {
+ atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1)
+ log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss upstream_status=%d generic_error_envelope=true", strings.ToLower(addr), chain, resp.StatusCode)
+ w.Header().Set("Content-Type", ctype)
+ w.WriteHeader(http.StatusBadGateway)
+ _, _ = w.Write(body)
+ return
+ }
if resp.StatusCode == http.StatusOK {
liquidityPoolsCache.Store(cacheKey, liquidityCacheEntry{
body: body,
diff --git a/backend/api/rest/mission_control_test.go b/backend/api/rest/mission_control_test.go
index 3a6a1da..bf85702 100644
--- a/backend/api/rest/mission_control_test.go
+++ b/backend/api/rest/mission_control_test.go
@@ -98,6 +98,37 @@ func TestHandleMissionControlLiquidityTokenPathBypassesCacheWhenRequested(t *tes
require.Equal(t, 2, hitCount, "refresh=1 should force a fresh upstream read")
}
+func TestHandleMissionControlLiquidityTokenPathTreatsGenericSuccessErrorEnvelopeAsBadGateway(t *testing.T) {
+ resetMissionControlTestGlobals()
+
+ var hitCount int
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hitCount++
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"error":"Internal server error"}`))
+ }))
+ defer upstream.Close()
+
+ t.Setenv("TOKEN_AGGREGATION_BASE_URL", upstream.URL)
+ t.Setenv("CHAIN_ID", "138")
+
+ s := NewServer(nil, 138)
+ path := "/api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools"
+
+ w1 := httptest.NewRecorder()
+ s.handleMissionControlLiquidityTokenPath(w1, httptest.NewRequest(http.MethodGet, path, nil))
+ require.Equal(t, http.StatusBadGateway, w1.Code)
+ require.Equal(t, "miss", w1.Header().Get("X-Mission-Control-Cache"))
+ require.JSONEq(t, `{"error":"Internal server error"}`, w1.Body.String())
+
+ w2 := httptest.NewRecorder()
+ s.handleMissionControlLiquidityTokenPath(w2, httptest.NewRequest(http.MethodGet, path, nil))
+ require.Equal(t, http.StatusBadGateway, w2.Code)
+ require.Equal(t, "miss", w2.Header().Get("X-Mission-Control-Cache"))
+ require.Equal(t, 2, hitCount, "generic error envelopes must not be cached as success")
+}
+
func TestHandleMissionControlBridgeTraceLabelsFromRegistry(t *testing.T) {
resetMissionControlTestGlobals()
diff --git a/backend/api/rest/stats.go b/backend/api/rest/stats.go
index 4268b8d..7c05fb8 100644
--- a/backend/api/rest/stats.go
+++ b/backend/api/rest/stats.go
@@ -2,62 +2,190 @@ package rest
import (
"context"
+ "database/sql"
"encoding/json"
"fmt"
"net/http"
+ "os"
+ "strings"
"time"
- "github.com/jackc/pgx/v5"
+ "github.com/explorer/backend/api/freshness"
)
type explorerStats struct {
- TotalBlocks int64 `json:"total_blocks"`
- TotalTransactions int64 `json:"total_transactions"`
- TotalAddresses int64 `json:"total_addresses"`
- LatestBlock int64 `json:"latest_block"`
+ TotalBlocks int64 `json:"total_blocks"`
+ TotalTransactions int64 `json:"total_transactions"`
+ TotalAddresses int64 `json:"total_addresses"`
+ LatestBlock int64 `json:"latest_block"`
+ AverageBlockTime *float64 `json:"average_block_time,omitempty"`
+ GasPrices *explorerGasPrices `json:"gas_prices,omitempty"`
+ NetworkUtilizationPercentage *float64 `json:"network_utilization_percentage,omitempty"`
+ TransactionsToday *int64 `json:"transactions_today,omitempty"`
+ Freshness freshness.Snapshot `json:"freshness"`
+ Completeness freshness.SummaryCompleteness `json:"completeness"`
+ Sampling freshness.Sampling `json:"sampling"`
}
-type statsQueryFunc func(ctx context.Context, sql string, args ...any) pgx.Row
+type explorerGasPrices struct {
+ Average *float64 `json:"average,omitempty"`
+}
+
+type statsQueryFunc = freshness.QueryRowFunc
+
+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 {
+ return nil, err
+ }
+ if !value.Valid {
+ return nil, nil
+ }
+ return &value.Float64, nil
+}
+
+func queryNullableInt64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*int64, error) {
+ var value sql.NullInt64
+ if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
+ return nil, err
+ }
+ if !value.Valid {
+ return nil, nil
+ }
+ return &value.Int64, nil
+}
func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc) (explorerStats, error) {
var stats explorerStats
+ _ = chainID
if err := queryRow(ctx,
- `SELECT COUNT(*) FROM blocks WHERE chain_id = $1`,
- chainID,
+ `SELECT COUNT(*) FROM blocks`,
).Scan(&stats.TotalBlocks); err != nil {
return explorerStats{}, fmt.Errorf("query total blocks: %w", err)
}
if err := queryRow(ctx,
- `SELECT COUNT(*) FROM transactions WHERE chain_id = $1`,
- chainID,
+ `SELECT COUNT(*) FROM transactions WHERE block_hash IS NOT NULL`,
).Scan(&stats.TotalTransactions); err != nil {
return explorerStats{}, fmt.Errorf("query total transactions: %w", err)
}
if err := queryRow(ctx,
`SELECT COUNT(*) FROM (
- SELECT from_address AS address
+ SELECT from_address_hash AS address
FROM transactions
- WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> ''
+ WHERE from_address_hash IS NOT NULL
UNION
- SELECT to_address AS address
+ SELECT to_address_hash AS address
FROM transactions
- WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> ''
+ WHERE to_address_hash IS NOT NULL
) unique_addresses`,
- chainID,
).Scan(&stats.TotalAddresses); err != nil {
return explorerStats{}, fmt.Errorf("query total addresses: %w", err)
}
if err := queryRow(ctx,
- `SELECT COALESCE(MAX(number), 0) FROM blocks WHERE chain_id = $1`,
- chainID,
+ `SELECT COALESCE(MAX(number), 0) FROM blocks`,
).Scan(&stats.LatestBlock); err != nil {
return explorerStats{}, fmt.Errorf("query latest block: %w", err)
}
+ statsIssues := map[string]string{}
+
+ averageBlockTime, err := queryNullableFloat64(ctx, queryRow,
+ `SELECT CASE
+ WHEN COUNT(*) >= 2
+ THEN (EXTRACT(EPOCH FROM (MAX(timestamp) - MIN(timestamp))) * 1000.0) / NULLIF(COUNT(*) - 1, 0)
+ ELSE NULL
+ END
+ FROM (
+ SELECT timestamp
+ FROM blocks
+ ORDER BY number DESC
+ LIMIT 100
+ ) recent_blocks`,
+ )
+ if err != nil {
+ statsIssues["average_block_time"] = err.Error()
+ } else {
+ stats.AverageBlockTime = averageBlockTime
+ }
+
+ averageGasPrice, err := queryNullableFloat64(ctx, queryRow,
+ `SELECT AVG(gas_price_wei)::double precision / 1000000000.0
+ FROM (
+ SELECT gas_price AS gas_price_wei
+ FROM transactions
+ WHERE block_hash IS NOT NULL
+ AND gas_price IS NOT NULL
+ ORDER BY block_number DESC, "index" DESC
+ LIMIT 1000
+ ) recent_transactions`,
+ )
+ if err != nil {
+ statsIssues["average_gas_price"] = err.Error()
+ } else if averageGasPrice != nil {
+ stats.GasPrices = &explorerGasPrices{Average: averageGasPrice}
+ }
+
+ networkUtilization, err := queryNullableFloat64(ctx, queryRow,
+ `SELECT AVG((gas_used::double precision / NULLIF(gas_limit, 0)) * 100.0)
+ FROM (
+ SELECT gas_used, gas_limit
+ FROM blocks
+ WHERE gas_limit IS NOT NULL
+ AND gas_limit > 0
+ ORDER BY number DESC
+ LIMIT 100
+ ) recent_blocks`,
+ )
+ if err != nil {
+ statsIssues["network_utilization_percentage"] = err.Error()
+ } else {
+ stats.NetworkUtilizationPercentage = networkUtilization
+ }
+
+ transactionsToday, err := queryNullableInt64(ctx, queryRow,
+ `SELECT COUNT(*)::bigint
+ FROM transactions t
+ JOIN blocks b
+ ON b.number = t.block_number
+ WHERE b.timestamp >= NOW() - INTERVAL '24 hours'`,
+ )
+ if err != nil {
+ statsIssues["transactions_today"] = err.Error()
+ } else {
+ stats.TransactionsToday = transactionsToday
+ }
+
+ rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
+ snapshot, completeness, sampling, err := freshness.BuildSnapshot(
+ ctx,
+ chainID,
+ queryRow,
+ func(ctx context.Context) (*freshness.Reference, error) {
+ return freshness.ProbeChainHead(ctx, rpcURL)
+ },
+ time.Now().UTC(),
+ averageGasPrice,
+ networkUtilization,
+ )
+ if err != nil {
+ return explorerStats{}, fmt.Errorf("build freshness snapshot: %w", err)
+ }
+ if len(statsIssues) > 0 {
+ if sampling.Issues == nil {
+ sampling.Issues = map[string]string{}
+ }
+ for key, value := range statsIssues {
+ sampling.Issues[key] = value
+ }
+ }
+ stats.Freshness = snapshot
+ stats.Completeness = completeness
+ stats.Sampling = sampling
+
return stats, nil
}
diff --git a/backend/api/rest/stats_internal_test.go b/backend/api/rest/stats_internal_test.go
index 86f7cfd..9342fbd 100644
--- a/backend/api/rest/stats_internal_test.go
+++ b/backend/api/rest/stats_internal_test.go
@@ -2,10 +2,17 @@ package rest
import (
"context"
+ "database/sql"
+ "encoding/json"
"errors"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
"strings"
"testing"
+ "time"
+ "github.com/explorer/backend/api/freshness"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
@@ -19,23 +26,56 @@ func (r fakeStatsRow) Scan(dest ...any) error {
}
func TestLoadExplorerStatsReturnsValues(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":"0x2c"}`))
+ case "eth_getBlockByNumber":
+ ts := time.Now().Add(-2 * 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)
var call int
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
call++
return fakeStatsRow{
scan: func(dest ...any) error {
- target, ok := dest[0].(*int64)
- require.True(t, ok)
-
switch call {
case 1:
- *target = 11
+ *dest[0].(*int64) = 11
case 2:
- *target = 22
+ *dest[0].(*int64) = 22
case 3:
- *target = 33
+ *dest[0].(*int64) = 33
case 4:
- *target = 44
+ *dest[0].(*int64) = 44
+ case 5:
+ *dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 2000, Valid: true}
+ case 6:
+ *dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 1.25, Valid: true}
+ case 7:
+ *dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 37.5, Valid: true}
+ case 8:
+ *dest[0].(*sql.NullInt64) = sql.NullInt64{Int64: 18, Valid: true}
+ case 9:
+ *dest[0].(*int64) = 44
+ *dest[1].(*time.Time) = time.Now().Add(-2 * time.Second)
+ case 10:
+ *dest[0].(*string) = "0xabc"
+ *dest[1].(*int64) = 40
+ *dest[2].(*time.Time) = time.Now().Add(-5 * time.Second)
+ case 11:
+ *dest[0].(*int64) = 40
+ *dest[1].(*time.Time) = time.Now().Add(-5 * time.Second)
default:
t.Fatalf("unexpected query call %d", call)
}
@@ -50,9 +90,25 @@ func TestLoadExplorerStatsReturnsValues(t *testing.T) {
require.Equal(t, int64(22), stats.TotalTransactions)
require.Equal(t, int64(33), stats.TotalAddresses)
require.Equal(t, int64(44), stats.LatestBlock)
+ require.NotNil(t, stats.AverageBlockTime)
+ require.Equal(t, 2000.0, *stats.AverageBlockTime)
+ require.NotNil(t, stats.GasPrices)
+ require.NotNil(t, stats.GasPrices.Average)
+ require.Equal(t, 1.25, *stats.GasPrices.Average)
+ require.NotNil(t, stats.NetworkUtilizationPercentage)
+ require.Equal(t, 37.5, *stats.NetworkUtilizationPercentage)
+ require.NotNil(t, stats.TransactionsToday)
+ require.Equal(t, int64(18), *stats.TransactionsToday)
+ require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
+ require.Equal(t, int64(40), *stats.Freshness.LatestIndexedTransaction.BlockNumber)
+ require.Equal(t, int64(4), *stats.Freshness.LatestNonEmptyBlock.DistanceFromHead)
+ require.Equal(t, "reported", string(stats.Freshness.ChainHead.Source))
+ require.Equal(t, freshness.CompletenessComplete, stats.Completeness.GasMetrics)
+ require.Equal(t, freshness.CompletenessComplete, stats.Completeness.UtilizationMetric)
}
func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
+ t.Setenv("RPC_URL", "")
queryRow := func(_ context.Context, query string, _ ...any) pgx.Row {
return fakeStatsRow{
scan: func(dest ...any) error {
diff --git a/backend/api/rest/track_routes.go b/backend/api/rest/track_routes.go
index b49b0b5..dbb34b2 100644
--- a/backend/api/rest/track_routes.go
+++ b/backend/api/rest/track_routes.go
@@ -1,10 +1,13 @@
package rest
import (
+ "context"
"net/http"
"os"
"strings"
+ "time"
+ "github.com/explorer/backend/api/freshness"
"github.com/explorer/backend/api/middleware"
"github.com/explorer/backend/api/track1"
"github.com/explorer/backend/api/track2"
@@ -47,7 +50,27 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
}
rpcGateway := gateway.NewRPCGateway(rpcURL, cache, rateLimiter)
- track1Server := track1.NewServer(rpcGateway)
+ track1Server := track1.NewServer(rpcGateway, func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
+ if s.db == nil {
+ return nil, nil, nil, nil
+ }
+ now := time.Now().UTC()
+ snapshot, completeness, sampling, err := freshness.BuildSnapshot(
+ ctx,
+ s.chainID,
+ s.db.QueryRow,
+ func(ctx context.Context) (*freshness.Reference, error) {
+ return freshness.ProbeChainHead(ctx, rpcURL)
+ },
+ now,
+ nil,
+ nil,
+ )
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ return &snapshot, &completeness, &sampling, nil
+ })
// Track 1 routes (public, optional auth)
mux.HandleFunc("/api/v1/track1/blocks/latest", track1Server.HandleLatestBlocks)
diff --git a/backend/api/track1/bridge_status_data.go b/backend/api/track1/bridge_status_data.go
index dde9f22..a06b27e 100644
--- a/backend/api/track1/bridge_status_data.go
+++ b/backend/api/track1/bridge_status_data.go
@@ -5,6 +5,8 @@ import (
"os"
"strings"
"time"
+
+ "github.com/explorer/backend/api/freshness"
)
func relaySnapshotStatus(relay map[string]interface{}) string {
@@ -129,6 +131,81 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
if ov := readOptionalVerifyJSON(); ov != nil {
data["operator_verify"] = ov
}
+ if s.freshnessLoader != nil {
+ if snapshot, completeness, sampling, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
+ subsystems := map[string]interface{}{
+ "rpc_head": map[string]interface{}{
+ "status": chainStatusFromProbe(p138),
+ "updated_at": valueOrNil(snapshot.ChainHead.Timestamp),
+ "age_seconds": valueOrNil(snapshot.ChainHead.AgeSeconds),
+ "source": snapshot.ChainHead.Source,
+ "confidence": snapshot.ChainHead.Confidence,
+ "provenance": snapshot.ChainHead.Provenance,
+ "completeness": snapshot.ChainHead.Completeness,
+ },
+ "tx_index": map[string]interface{}{
+ "status": completenessStatus(completeness.TransactionsFeed),
+ "updated_at": valueOrNil(snapshot.LatestIndexedTransaction.Timestamp),
+ "age_seconds": valueOrNil(snapshot.LatestIndexedTransaction.AgeSeconds),
+ "source": snapshot.LatestIndexedTransaction.Source,
+ "confidence": snapshot.LatestIndexedTransaction.Confidence,
+ "provenance": snapshot.LatestIndexedTransaction.Provenance,
+ "completeness": completeness.TransactionsFeed,
+ },
+ "stats_summary": map[string]interface{}{
+ "status": completenessStatus(completeness.BlocksFeed),
+ "updated_at": valueOrNil(sampling.StatsGeneratedAt),
+ "age_seconds": ageSinceRFC3339(sampling.StatsGeneratedAt),
+ "source": freshness.SourceReported,
+ "confidence": freshness.ConfidenceMedium,
+ "provenance": freshness.ProvenanceComposite,
+ "completeness": completeness.BlocksFeed,
+ },
+ }
+ if len(sampling.Issues) > 0 {
+ subsystems["freshness_queries"] = map[string]interface{}{
+ "status": "degraded",
+ "updated_at": valueOrNil(sampling.StatsGeneratedAt),
+ "age_seconds": ageSinceRFC3339(sampling.StatsGeneratedAt),
+ "source": freshness.SourceDerived,
+ "confidence": freshness.ConfidenceMedium,
+ "provenance": freshness.ProvenanceComposite,
+ "completeness": freshness.CompletenessPartial,
+ "issues": sampling.Issues,
+ }
+ }
+ modeKind := "live"
+ modeReason := any(nil)
+ modeScope := any(nil)
+ if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
+ modeKind = "snapshot"
+ modeReason = "live_homepage_stream_not_attached"
+ modeScope = "relay_monitoring_homepage_card_only"
+ subsystems["bridge_relay_monitoring"] = map[string]interface{}{
+ "status": overall,
+ "updated_at": now,
+ "age_seconds": int64(0),
+ "source": freshness.SourceReported,
+ "confidence": freshness.ConfidenceHigh,
+ "provenance": freshness.ProvenanceMissionFeed,
+ "completeness": freshness.CompletenessComplete,
+ }
+ }
+ data["freshness"] = snapshot
+ data["subsystems"] = subsystems
+ data["sampling"] = sampling
+ data["mode"] = map[string]interface{}{
+ "kind": modeKind,
+ "updated_at": now,
+ "age_seconds": int64(0),
+ "reason": modeReason,
+ "scope": modeScope,
+ "source": freshness.SourceReported,
+ "confidence": freshness.ConfidenceHigh,
+ "provenance": freshness.ProvenanceMissionFeed,
+ }
+ }
+ }
if relays := FetchCCIPRelayHealths(ctx); relays != nil {
data["ccip_relays"] = relays
if ccip := primaryRelayHealth(relays); ccip != nil {
@@ -142,5 +219,58 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
}
}
}
+ if mode, ok := data["mode"].(map[string]interface{}); ok {
+ if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
+ mode["kind"] = "snapshot"
+ mode["reason"] = "live_homepage_stream_not_attached"
+ mode["scope"] = "relay_monitoring_homepage_card_only"
+ if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
+ subsystems["bridge_relay_monitoring"] = map[string]interface{}{
+ "status": data["status"],
+ "updated_at": now,
+ "age_seconds": int64(0),
+ "source": freshness.SourceReported,
+ "confidence": freshness.ConfidenceHigh,
+ "provenance": freshness.ProvenanceMissionFeed,
+ "completeness": freshness.CompletenessComplete,
+ }
+ }
+ }
+ }
return data
}
+
+func valueOrNil[T any](value *T) any {
+ if value == nil {
+ return nil
+ }
+ return *value
+}
+
+func ageSinceRFC3339(value *string) any {
+ if value == nil || *value == "" {
+ return nil
+ }
+ parsed, err := time.Parse(time.RFC3339, *value)
+ if err != nil {
+ return nil
+ }
+ age := int64(time.Since(parsed).Seconds())
+ if age < 0 {
+ age = 0
+ }
+ return age
+}
+
+func completenessStatus(value freshness.Completeness) string {
+ switch value {
+ case freshness.CompletenessComplete:
+ return "operational"
+ case freshness.CompletenessPartial:
+ return "partial"
+ case freshness.CompletenessStale:
+ return "stale"
+ default:
+ return "unavailable"
+ }
+}
diff --git a/backend/api/track1/ccip_health_test.go b/backend/api/track1/ccip_health_test.go
index 07a9d94..06ea7f0 100644
--- a/backend/api/track1/ccip_health_test.go
+++ b/backend/api/track1/ccip_health_test.go
@@ -11,6 +11,7 @@ import (
"testing"
"time"
+ "github.com/explorer/backend/api/freshness"
"github.com/stretchr/testify/require"
)
@@ -145,7 +146,50 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
t.Setenv("CCIP_RELAY_HEALTH_URLS", "")
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
- s := &Server{}
+ s := &Server{
+ freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
+ now := time.Now().UTC().Format(time.RFC3339)
+ head := int64(16)
+ txBlock := int64(12)
+ distance := int64(4)
+ return &freshness.Snapshot{
+ ChainHead: freshness.Reference{
+ BlockNumber: &head,
+ Timestamp: &now,
+ AgeSeconds: func() *int64 { v := int64(1); return &v }(),
+ Source: freshness.SourceReported,
+ Confidence: freshness.ConfidenceHigh,
+ Provenance: freshness.ProvenanceRPC,
+ Completeness: freshness.CompletenessComplete,
+ },
+ LatestIndexedTransaction: freshness.Reference{
+ BlockNumber: &txBlock,
+ Timestamp: &now,
+ AgeSeconds: func() *int64 { v := int64(120); return &v }(),
+ Source: freshness.SourceReported,
+ Confidence: freshness.ConfidenceHigh,
+ Provenance: freshness.ProvenanceTxIndex,
+ Completeness: freshness.CompletenessPartial,
+ },
+ LatestNonEmptyBlock: freshness.Reference{
+ BlockNumber: &txBlock,
+ Timestamp: &now,
+ AgeSeconds: func() *int64 { v := int64(120); return &v }(),
+ DistanceFromHead: &distance,
+ Source: freshness.SourceReported,
+ Confidence: freshness.ConfidenceHigh,
+ Provenance: freshness.ProvenanceTxIndex,
+ Completeness: freshness.CompletenessPartial,
+ },
+ },
+ &freshness.SummaryCompleteness{
+ TransactionsFeed: freshness.CompletenessPartial,
+ BlocksFeed: freshness.CompletenessComplete,
+ },
+ &freshness.Sampling{StatsGeneratedAt: &now},
+ nil
+ },
+ }
got := s.BuildBridgeStatusData(context.Background())
ccip, ok := got["ccip_relay"].(map[string]interface{})
require.True(t, ok)
@@ -156,6 +200,9 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
probe, ok := ccip["url_probe"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, true, probe["ok"])
+ require.Contains(t, got, "freshness")
+ require.Contains(t, got, "subsystems")
+ require.Contains(t, got, "mode")
}
func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
@@ -197,7 +244,11 @@ func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
t.Setenv("CCIP_RELAY_HEALTH_URLS", "mainnet="+mainnet.URL+"/healthz,bsc="+bad.URL+"/healthz")
- s := &Server{}
+ s := &Server{
+ freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
+ return nil, nil, nil, nil
+ },
+ }
got := s.BuildBridgeStatusData(context.Background())
require.Equal(t, "degraded", got["status"])
}
diff --git a/backend/api/track1/endpoints.go b/backend/api/track1/endpoints.go
index b5bcb2c..5c30420 100644
--- a/backend/api/track1/endpoints.go
+++ b/backend/api/track1/endpoints.go
@@ -12,6 +12,7 @@ import (
"time"
"github.com/ethereum/go-ethereum/common"
+ "github.com/explorer/backend/api/freshness"
"github.com/explorer/backend/libs/go-rpc-gateway"
)
@@ -19,13 +20,18 @@ var track1HashPattern = regexp.MustCompile(`^0x[a-fA-F0-9]{64}$`)
// Server handles Track 1 endpoints (uses RPC gateway from lib)
type Server struct {
- rpcGateway *gateway.RPCGateway
+ rpcGateway *gateway.RPCGateway
+ freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error)
}
// NewServer creates a new Track 1 server
-func NewServer(rpcGateway *gateway.RPCGateway) *Server {
+func NewServer(
+ rpcGateway *gateway.RPCGateway,
+ freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error),
+) *Server {
return &Server{
- rpcGateway: rpcGateway,
+ rpcGateway: rpcGateway,
+ freshnessLoader: freshnessLoader,
}
}
diff --git a/deployment/common/nginx-api-location.conf b/deployment/common/nginx-api-location.conf
index d01e188..22fa3d8 100644
--- a/deployment/common/nginx-api-location.conf
+++ b/deployment/common/nginx-api-location.conf
@@ -1,5 +1,22 @@
# Generic snippet: proxy /api/ to a backend (Blockscout, Go API, etc.)
# Include in your server block. Replace upstream host/port as needed.
+#
+# Keep the exact /api/v2/stats route on the Go-side explorer API when you need
+# enriched freshness/completeness metadata while the rest of /api/v2/* stays on
+# the Blockscout upstream.
+
+location = /api/v2/stats {
+ proxy_pass http://127.0.0.1:8081/api/v2/stats;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 60s;
+ add_header Access-Control-Allow-Origin *;
+ add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
+ add_header Access-Control-Allow-Headers "Content-Type";
+}
location /api/ {
proxy_pass http://127.0.0.1:4000;
diff --git a/docs/API_ERRORS_FIX.md b/docs/API_ERRORS_FIX.md
index 9fbb0d4..3cf5f5a 100644
--- a/docs/API_ERRORS_FIX.md
+++ b/docs/API_ERRORS_FIX.md
@@ -1,5 +1,10 @@
# API Errors Fix
+> Historical note: this file documents legacy static-SPA fixes and deploy
+> patterns. The canonical live frontend deployment is now
+> `./scripts/deploy-next-frontend-to-vmid5000.sh`. Treat manual `index.html`
+> copy steps here as compatibility history, not the primary operator path.
+
## Issues Fixed
### 1. `createSkeletonLoader is not defined` Error
@@ -54,12 +59,12 @@ bash explorer-monorepo/scripts/deploy-frontend-fix.sh
#### Option 1: Using Deployment Script (from Proxmox host)
```bash
cd /home/intlc/projects/proxmox/explorer-monorepo
-bash scripts/deploy-frontend-to-vmid5000.sh
+bash scripts/deploy-next-frontend-to-vmid5000.sh
```
#### Option 2: Manual Deployment (from VMID 5000)
```bash
-# On VMID 5000, copy the file:
+# Historical static-SPA compatibility only:
cp /path/to/explorer-monorepo/frontend/public/index.html /var/www/html/index.html
chown www-data:www-data /var/www/html/index.html
@@ -69,6 +74,7 @@ nginx -t && systemctl restart nginx
#### Option 3: Using SCP (from local machine)
```bash
+# Historical static-SPA compatibility only:
scp explorer-monorepo/frontend/public/index.html root@192.168.11.140:/var/www/html/index.html
ssh root@192.168.11.140 "chown www-data:www-data /var/www/html/index.html && nginx -t && systemctl restart nginx"
```
@@ -113,4 +119,3 @@ Test the following scenarios:
- For ChainID 138, all API calls now use Blockscout REST API format
- Error handling includes retry buttons for better UX
- Skeleton loaders provide visual feedback during data loading
-
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 29b3d3d..504fd2e 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -29,10 +29,10 @@ inside the explorer server block after `/api`, `/api/config/*`, `/explorer-api/*
### Legacy Static Deploy
-```bash
-# From explorer-monorepo root
-./scripts/deploy.sh
-```
+The historical static SPA deploy path is deprecated. The old scripts
+`./scripts/deploy.sh` and `./scripts/deploy-frontend-to-vmid5000.sh` now fail
+fast with a deprecation message and intentionally point operators back to the
+canonical Next.js deploy path.
### Manual Deploy
@@ -40,21 +40,25 @@ inside the explorer server block after `/api`, `/api/config/*`, `/explorer-api/*
# Canonical Next deployment:
./scripts/deploy-next-frontend-to-vmid5000.sh
-# Legacy static fallback only:
-scp frontend/public/index.html root@192.168.11.140:/var/www/html/index.html
+# Compatibility assets only:
+# frontend/public/index.html
+# frontend/public/explorer-spa.js
```
### Environment Variables
-The deployment script uses these environment variables:
+The canonical Next deployment script uses its own VM/host defaults and deploy
+workflow. Prefer reviewing `scripts/deploy-next-frontend-to-vmid5000.sh`
+directly instead of relying on the older static script env contract below.
+
+Historical static-script environment variables:
- `IP`: Production server IP (default: 192.168.11.140)
- `DOMAIN`: Domain name (default: explorer.d-bis.org)
- `PASSWORD`: SSH password (default: L@kers2010)
-```bash
-IP=192.168.11.140 DOMAIN=explorer.d-bis.org ./scripts/deploy.sh
-```
+These applied to the deprecated static deploy script and are no longer the
+recommended operator interface.
## Mission-control and Track 4 runtime wiring
@@ -89,7 +93,7 @@ If deployment fails, rollback to previous version:
```bash
ssh root@192.168.11.140
-cp /var/www/html/index.html.backup.* /var/www/html/index.html
+ls -1dt /opt/solacescanscout/frontend/releases/* | head
```
For the Next standalone path, restart the previous release by repointing
diff --git a/docs/EXPLORER_API_ACCESS.md b/docs/EXPLORER_API_ACCESS.md
index a98fe94..f487c44 100644
--- a/docs/EXPLORER_API_ACCESS.md
+++ b/docs/EXPLORER_API_ACCESS.md
@@ -1,5 +1,9 @@
# Explorer API Access – Checklist and Fixes
+> Mixed-era note: this document spans both the current Next frontend and older
+> static-SPA CSP/deploy history. The canonical frontend deployment path is now
+> `./scripts/deploy-next-frontend-to-vmid5000.sh`.
+
The frontend is reachable at **https://explorer.d-bis.org** (FQDN) or by **VM IP** (**http://192.168.11.140**). In both cases it needs the **Blockscout v2 API** at the same origin under `/api/`. If you see **502 Bad Gateway**, **no blocks/transactions feeds**, or "Failed to load", the API may be unreachable. Use this checklist to verify and restore access.
**See also:** [EXPLORER_API_REFERENCE.md](EXPLORER_API_REFERENCE.md) for the list of Blockscout v2 endpoints used by the frontend.
@@ -135,7 +139,7 @@ The ethers.js v5 UMD bundle from the CDN uses `eval`/`new Function()` for ABI de
If the browser still reports **“Content Security Policy blocks the use of 'eval'”** or **script-src blocked**:
-1. **Redeploy the frontend** so the live site gets the current `index.html` (with the meta CSP including `'unsafe-eval'`). For VMID 5000, run **`scripts/deploy-frontend-to-vmid5000.sh`** (frontend-only) or **`scripts/complete-explorer-api-access.sh`** (full). Alternatively, copy `frontend/public/index.html` to the server’s web root (e.g. `/var/www/html/`).
+1. **Redeploy the frontend** so the live site gets the current frontend bundle and nginx/API wiring. For VMID 5000, run **`scripts/deploy-next-frontend-to-vmid5000.sh`** for the canonical frontend path or **`scripts/complete-explorer-api-access.sh`** for the broader API/nginx fix flow. References to `frontend/public/index.html` below are historical static-SPA compatibility details.
2. **Check what CSP the browser sees** – DevTools → Network → select the document request (the HTML page) → Headers → **Response Headers** → `Content-Security-Policy`. It should contain `'unsafe-eval'` in `script-src`. If the response has a CSP header **without** `'unsafe-eval'`, that header is coming from your server (nginx or app) or from a proxy (e.g. Cloudflare). Update the config that serves the explorer so its CSP includes `'unsafe-eval'`, then reload (hard refresh or incognito).
3. **If you use Cloudflare** – In the dashboard, check Transform Rules, Page Rules, or Security → Settings for any **Content-Security-Policy** (or similar) header that might override the origin. Ensure that header’s `script-src` includes `'unsafe-eval'`, or remove the override so the origin CSP is used.
diff --git a/docs/EXPLORER_CODE_REVIEW.md b/docs/EXPLORER_CODE_REVIEW.md
index e957901..f0b67c4 100644
--- a/docs/EXPLORER_CODE_REVIEW.md
+++ b/docs/EXPLORER_CODE_REVIEW.md
@@ -1,5 +1,10 @@
# Explorer Code Review
+> Historical architecture snapshot: this review reflects a mixed Next.js +
+> legacy static-SPA period. The live frontend is now the Next standalone app,
+> while `frontend/public/index.html` and `frontend/public/explorer-spa.js`
+> remain compatibility/reference assets only.
+
**Date:** 2025-02
**Scope:** Backend (Go), Frontend (Next.js + SPA), libs, deployment, CI.
@@ -11,8 +16,8 @@
|-------|------|--------|
| **API** | Go 1.22, net/http | REST API (blocks, transactions, addresses, search, stats, Etherscan compat, auth, feature flags). Tiered tracks (1–4) with optional/required auth. |
| **Indexer** | Go, go-ethereum, pgx | Listens to chain (RPC/WS), processes blocks/txs, writes to PostgreSQL. |
-| **Frontend (live)** | Vanilla JS SPA | `frontend/public/index.html` — single HTML + inline script, deployed at https://explorer.d-bis.org. Uses Blockscout-style API, ethers.js from CDN, VMID 2201 RPC. |
-| **Frontend (dev)** | Next.js 15, React, TypeScript | `frontend/src/` — app + pages router, dev/build only; uses shared libs (api-client, ui-primitives). |
+| **Frontend (live)** | Next.js 15, React, TypeScript | `frontend/src/` — standalone deployment on VMID 5000; uses shared libs and the explorer-owned freshness/trust model. |
+| **Frontend (compatibility)** | Vanilla JS SPA | `frontend/public/index.html` + `frontend/public/explorer-spa.js` — retained for compatibility/reference, not the primary live deployment path. |
| **Libs** | In-repo under `backend/libs/`, `frontend/libs/` | go-pgconfig, go-logging, go-chain-adapters, go-rpc-gateway, go-http-middleware, go-bridge-aggregator; frontend-api-client, frontend-ui-primitives. |
---
@@ -161,7 +166,7 @@
|------|--------|
| **Next.js workspace warning** | Done: Comment added in `frontend/next.config.js`; align package manager in frontend or ignore for dev/build. (Next 14 does not support `outputFileTracingRoot` in config; standalone trace uses project root.) |
| **CORS** | Done: `CORS_ALLOWED_ORIGIN` env in `server.go`; default `*`, set to e.g. `https://explorer.d-bis.org` to restrict. Documented in `deployment/ENVIRONMENT_TEMPLATE.env`. |
-| **SPA file size** | Done: main app script extracted to `frontend/public/explorer-spa.js` (~3.5k lines); `index.html` now ~1.15k lines. Deploy scripts copy `explorer-spa.js` (e.g. `deploy-frontend-to-vmid5000.sh`, `deploy.sh`). |
+| **SPA file size** | Historical compatibility asset: main app script extracted to `frontend/public/explorer-spa.js` (~3.5k lines); `index.html` now ~1.15k lines. The old deploy scripts are deprecated shims rather than active operator paths. |
| **SPA vs Next canonical** | Done: `README.md` states production serves the SPA, Next.js is for local dev and build validation only. |
| **CSP unsafe-eval** | Done: comment in `index.html` CSP meta updated: "Can be removed when moving to ethers v6 build (no UMD eval)." |
| **Further product work** | See `docs/EXPLORER_ADDITIONAL_RECOMMENDATIONS.md` (i18n, event log decoding, token list, health endpoint, etc.). |
diff --git a/docs/EXPLORER_DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md b/docs/EXPLORER_DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md
new file mode 100644
index 0000000..e5e82e4
--- /dev/null
+++ b/docs/EXPLORER_DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md
@@ -0,0 +1,154 @@
+# Explorer Dead-Ends, Gaps, and Orphans Audit
+
+Date: 2026-04-11
+
+This audit records the remaining pruning surface after the frontend trust,
+freshness, and deployment-path cleanup work. The goal is to distinguish
+high-signal cleanup targets from compatibility or historical assets that should
+not be deleted casually.
+
+## Canonical Live Paths
+
+- Frontend deploy: `scripts/deploy-next-frontend-to-vmid5000.sh`
+- Frontend runtime: `solacescanscout-frontend.service`
+- Shared freshness/trust model:
+ - `frontend/src/utils/explorerFreshness.ts`
+ - `frontend/src/components/common/FreshnessTrustNote.tsx`
+ - `frontend/src/components/common/ActivityContextPanel.tsx`
+- Explorer-owned freshness backend:
+ - `backend/api/freshness/`
+ - `backend/api/rest/stats.go`
+ - `backend/api/track1/bridge_status_data.go`
+
+## Pruned in This Cleanup Series
+
+- Deprecated static deploy scripts now fail fast and point to the canonical
+ Next deploy path:
+ - `scripts/deploy-frontend-to-vmid5000.sh`
+ - `scripts/deploy.sh`
+- Removed relay-summary compatibility helpers from:
+ - `frontend/src/services/api/missionControl.ts`
+- Removed duplicate route action from:
+ - `frontend/src/data/explorerOperations.ts`
+- Hardened deploy build-lock behavior in:
+ - `scripts/deploy-next-frontend-to-vmid5000.sh`
+
+## Dead-End Guidance Fixed
+
+The following docs were updated to stop presenting deprecated static frontend
+deployment as a current operator path:
+
+- `docs/README.md`
+- `docs/INDEX.md`
+- `docs/DEPLOYMENT.md`
+- `README_DEPLOYMENT.md`
+
+## Remaining Historical / Compatibility Assets To Keep For Now
+
+These are not current primary paths, but they still serve compatibility,
+reference, or audit roles and should not be removed without a deliberate
+migration decision:
+
+- `frontend/public/index.html`
+- `frontend/public/explorer-spa.js`
+- `frontend/public/chain138-command-center.html`
+- `deployment/common/nginx-api-location.conf`
+
+## Remaining Gaps
+
+### 0. Static compatibility assets are not orphaned yet
+
+The following assets are still part of the runtime or deployment surface and
+cannot be deleted safely in a pure pruning pass:
+
+- `frontend/public/index.html`
+- `frontend/public/explorer-spa.js`
+- `frontend/public/chain138-command-center.html`
+
+Current hard blockers:
+
+- canonical deploy script still copies them:
+ - `scripts/deploy-next-frontend-to-vmid5000.sh`
+- live product still links the command center:
+ - `frontend/src/components/common/Navbar.tsx`
+ - `frontend/src/components/common/Footer.tsx`
+ - `frontend/src/data/explorerOperations.ts`
+ - `frontend/src/pages/docs/index.tsx`
+- compatibility/runtime verification still expects them:
+ - `scripts/verify-explorer-api-access.sh`
+- several legacy remediation scripts still push the static SPA to
+ `/var/www/html/index.html`:
+ - `scripts/deploy-frontend-fix.sh`
+ - `scripts/fix-explorer-remote.sh`
+ - `scripts/fix-explorer-complete.sh`
+ - `scripts/complete-explorer-api-access.sh`
+
+Recommendation:
+- treat retirement of these assets as an explicit migration
+- first decide whether the command center remains a supported public artifact
+- then remove static-SPA push logic from the remediation scripts
+- only after that delete the files and clean the remaining references
+
+### 1. Historical docs still describe the old static SPA as if it were primary
+
+These are not the best operator entry points anymore, but they appear to be
+historical records, troubleshooting notes, or code-review artifacts rather than
+active runbooks:
+
+- `docs/FRONTEND_DEPLOYMENT_FIX.md`
+- `docs/FRONTEND_FIXES_COMPLETE.md`
+- `docs/API_ERRORS_FIX.md`
+- `docs/EXPLORER_LOADING_TROUBLESHOOTING.md`
+- `docs/EXPLORER_API_ACCESS.md`
+- `docs/EXPLORER_CODE_REVIEW.md`
+- `docs/EXPLORER_FRONTEND_TESTING.md`
+- `docs/STRUCTURE.md`
+- `docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md`
+
+Recommendation:
+- keep them for now
+- a first banner-stamp sweep has already been applied to the highest-signal set
+- only rewrite/delete them if we decide to retire the compatibility assets
+
+### 2. Compatibility assets still create pruning ambiguity
+
+The repo still contains both:
+- the live Next frontend path
+- the historical static SPA assets
+
+Recommendation:
+- keep the compatibility assets until all docs and operators no longer depend on
+ them for rollback/reference
+- when retired, remove the assets and do a repo-wide `frontend/public/index.html`
+ reference cleanup in one explicit migration
+
+### 3. Public routing ownership is still split
+
+Freshness truth is now much cleaner, but public route ownership still spans:
+- Blockscout-owned public API behavior
+- explorer-owned `track1` / mission-control behavior
+- Next frontend presentation logic
+
+Recommendation:
+- continue consolidating around the explorer-owned freshness contract
+- treat backend source-of-truth wiring as the next cleanup frontier, not more
+ shell polish
+
+## Orphaned / Removed Compatibility Paths Confirmed Gone
+
+These frontend compatibility abstractions were fully removed and should not be
+reintroduced:
+
+- `getRelaySummary` in `frontend/src/services/api/missionControl.ts`
+- `subscribeRelaySummary` in `frontend/src/services/api/missionControl.ts`
+
+## Suggested Next Pruning Sweep
+
+1. Stamp the historical static-SPA docs above with a clear banner:
+ `Historical static-SPA guidance; not the canonical deployment path.`
+2. Decide whether `frontend/public/index.html` and `frontend/public/explorer-spa.js`
+ still have an operational rollback role.
+3. If not, remove them in one explicit migration and clean all remaining
+ references repo-wide.
+4. After that, re-run the dead-end/orphan audit and remove the remaining
+ compatibility mentions from troubleshooting docs.
diff --git a/docs/EXPLORER_FRONTEND_TESTING.md b/docs/EXPLORER_FRONTEND_TESTING.md
index fbb3909..026aad9 100644
--- a/docs/EXPLORER_FRONTEND_TESTING.md
+++ b/docs/EXPLORER_FRONTEND_TESTING.md
@@ -1,10 +1,15 @@
# Explorer Frontend Testing
+> Historical note: this testing note captures legacy static-SPA routing
+> behavior during the explorer transition. The canonical live frontend is now
+> the Next standalone app deployed with
+> `./scripts/deploy-next-frontend-to-vmid5000.sh`.
+
## Summary
Path-based URLs (e.g. `/address/0x99b3511a2d315a497c8112c1fdd8d508d4b1e506`) now work on the explorer. The fix includes:
-1. **SPA path-based routing** – `applyHashRoute()` in `frontend/public/index.html` reads both `pathname` and `hash`, so `/address/0x...`, `/tx/0x...`, `/block/123`, etc. load correctly.
+1. **SPA path-based routing** – historically, `applyHashRoute()` in `frontend/public/index.html` read both `pathname` and `hash`, so `/address/0x...`, `/tx/0x...`, `/block/123`, etc. loaded correctly.
2. **Nginx SPA paths** – Nginx serves `index.html` for `/address`, `/tx`, `/block`, `/token`, `/blocks`, `/transactions`, `/bridge`, `/weth`, `/watchlist`, and `/nft`.
3. **HTTP + HTTPS** – Both HTTP (for internal tests) and HTTPS serve the SPA for these paths.
diff --git a/docs/EXPLORER_LOADING_TROUBLESHOOTING.md b/docs/EXPLORER_LOADING_TROUBLESHOOTING.md
index 745915a..50f4a03 100644
--- a/docs/EXPLORER_LOADING_TROUBLESHOOTING.md
+++ b/docs/EXPLORER_LOADING_TROUBLESHOOTING.md
@@ -1,5 +1,9 @@
# Explorer "Loading…" / "—" Troubleshooting
+> Historical note: parts of this troubleshooting guide still refer to the old
+> static-SPA deployment path. The current production frontend is the Next
+> standalone app deployed with `./scripts/deploy-next-frontend-to-vmid5000.sh`.
+
When **`/api/v2/stats`** returns 200 with data but the SPA still shows "—" or "Loading blocks…" / "Loading transactions…" / "Loading bridge data…" / "Tokens: Loading…", the failure is in **frontend→API wiring** or **frontend runtime**.
## Expected UI (screenshots)
@@ -71,13 +75,13 @@ After editing `frontend/public/explorer-spa.js`, redeploy the frontend to VMID 5
```bash
# From repo root (with SSH to r630-02)
-EXPLORER_VM_HOST=root@192.168.11.12 bash explorer-monorepo/scripts/deploy-frontend-to-vmid5000.sh
+EXPLORER_VM_HOST=root@192.168.11.12 bash explorer-monorepo/scripts/deploy-next-frontend-to-vmid5000.sh
```
Or from the Proxmox host that runs VMID 5000:
```bash
-/path/to/repo/explorer-monorepo/scripts/deploy-frontend-to-vmid5000.sh
+/path/to/repo/explorer-monorepo/scripts/deploy-next-frontend-to-vmid5000.sh
```
Then hard-refresh the explorer (Ctrl+Shift+R / Cmd+Shift+R) and re-check Console + Network.
diff --git a/docs/FRONTEND_DEPLOYMENT_FIX.md b/docs/FRONTEND_DEPLOYMENT_FIX.md
index 1b46af1..1c8074f 100644
--- a/docs/FRONTEND_DEPLOYMENT_FIX.md
+++ b/docs/FRONTEND_DEPLOYMENT_FIX.md
@@ -1,9 +1,15 @@
# Frontend Deployment Fix
+> Historical note: this document describes the legacy static-SPA deployment
+> path. The canonical live frontend deployment is now
+> `./scripts/deploy-next-frontend-to-vmid5000.sh`. Keep this file only as
+> compatibility/audit reference unless you are deliberately working on the old
+> static assets.
+
## Problem
The explorer at `https://explorer.d-bis.org/` shows "Page not found" because:
1. Nginx is proxying to Blockscout (port 4000) which serves its own UI
-2. The custom frontend (`explorer-monorepo/frontend/public/index.html`) is not deployed to `/var/www/html/` on VMID 5000
+2. The historical custom static frontend (`explorer-monorepo/frontend/public/index.html`) is not deployed to `/var/www/html/` on VMID 5000
## Solution
@@ -33,21 +39,21 @@ Deploy the custom frontend to `/var/www/html/index.html`:
**From Proxmox host:**
```bash
cd /home/intlc/projects/proxmox/explorer-monorepo
-bash scripts/deploy-frontend-to-vmid5000.sh
+bash scripts/deploy-next-frontend-to-vmid5000.sh
```
**Or manually from VMID 5000:**
```bash
-# If you have access to the repo in VMID 5000
+# Historical static-SPA compatibility only:
cp /home/intlc/projects/proxmox/explorer-monorepo/frontend/public/index.html /var/www/html/index.html
chown www-data:www-data /var/www/html/index.html
```
**Or using SSH from Proxmox host:**
```bash
-# Using existing deploy script
+# Deprecated static deploy shim; kept only for historical compatibility context
cd /home/intlc/projects/proxmox/explorer-monorepo
-PASSWORD="L@kers2010" bash scripts/deploy.sh
+bash scripts/deploy.sh
```
### Step 3: Verify
@@ -95,4 +101,3 @@ The updated nginx config:
## Files Modified
- `/etc/nginx/sites-available/blockscout` - Nginx configuration
- `/var/www/html/index.html` - Custom frontend (needs to be deployed)
-
diff --git a/docs/FRONTEND_FIXES_COMPLETE.md b/docs/FRONTEND_FIXES_COMPLETE.md
index b041c9d..f45e856 100644
--- a/docs/FRONTEND_FIXES_COMPLETE.md
+++ b/docs/FRONTEND_FIXES_COMPLETE.md
@@ -1,5 +1,10 @@
# Frontend Errors - Complete Fix Summary
+> Historical note: this fix summary was written against the older static-SPA
+> frontend. The canonical live frontend is now the Next standalone app on VMID
+> 5000, while `frontend/public/index.html` remains a compatibility/reference
+> asset.
+
**Date**: $(date)
**Status**: ✅ **ALL FIXES APPLIED**
@@ -139,4 +144,3 @@ Fetching blocks from Blockscout: https://explorer.d-bis.org/api/v2/blocks?page=1
---
**Status**: ✅ All frontend errors have been fixed and tested.
-
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 8870d6c..4d66928 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -17,7 +17,7 @@
- **[../frontend/FRONTEND_REVIEW.md](../frontend/FRONTEND_REVIEW.md)** - Frontend code review (SPA + React)
- **[../frontend/FRONTEND_TASKS_AND_REVIEW.md](../frontend/FRONTEND_TASKS_AND_REVIEW.md)** - Task list (C1–L4) and detail review
-- **Deploy frontend:** From repo root run `./scripts/deploy-frontend-to-vmid5000.sh`
+- **Deploy frontend:** From repo root run `./scripts/deploy-next-frontend-to-vmid5000.sh`
- **Full fix (API + frontend):** `./scripts/complete-explorer-api-access.sh`
---
@@ -198,4 +198,3 @@
---
**Last Updated:** 2025-02-09
-
diff --git a/docs/README.md b/docs/README.md
index d112551..ba43d27 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -29,11 +29,12 @@ Overview of documentation for the ChainID 138 Explorer (SolaceScan).
Nginx should preserve `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggregation/api/v1/*`, `/snap/`, and `/health`, then proxy `/` and `/_next/` using [deployment/common/nginx-next-frontend-proxy.conf](/home/intlc/projects/proxmox/explorer-monorepo/deployment/common/nginx-next-frontend-proxy.conf).
-**Legacy static SPA deploy (fallback only):**
+**Legacy static SPA compatibility assets:**
-```bash
-./scripts/deploy-frontend-to-vmid5000.sh
-```
+The historical static SPA (`frontend/public/index.html` +
+`frontend/public/explorer-spa.js`) remains in-repo for compatibility and audit
+reference only. The old static deploy scripts are deprecated shims and are not
+supported deployment targets.
**Full explorer/API fix (Blockscout + nginx + frontend):**
@@ -61,4 +62,4 @@ Nginx should preserve `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggreg
---
-*Last updated: 2025-02-09*
+*Last updated: 2026-04-11*
diff --git a/docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md b/docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md
index 66a4363..9352809 100644
--- a/docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md
+++ b/docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md
@@ -1,5 +1,9 @@
# Reusable Components Extraction Plan
+> Historical planning note: references in this file to the explorer SPA or old
+> deploy scripts describe the pre-Next transition state. The canonical live
+> frontend deployment is now `./scripts/deploy-next-frontend-to-vmid5000.sh`.
+
**Completion status (in-repo):** All libs are present under `backend/libs/` and `frontend/libs/`. Backend is wired to use **go-pgconfig** (API + indexer), **go-rpc-gateway** (Track 1). Frontend is wired to use **frontend-api-client** (services/api/client) and **frontend-ui-primitives** (all pages using Card, Table, Address). CI uses `submodules: recursive`; README documents clone with submodules. To publish as separate repos, copy each lib to its own repo and add as submodule.
**Review and test:** Backend handlers that need the DB use `requireDB(w)`; without a DB they return 503. Tests run with a nil DB and accept 200/503/404 as appropriate. Run backend tests: `go test ./...` in `backend/`. Frontend build: `npm run build` in `frontend/` (ESLint uses root `.eslintrc.cjs` and frontend `"root": true` in `.eslintrc.json`). E2E: `npm run e2e` from repo root (Playwright, default base URL https://explorer.d-bis.org; set `EXPLORER_URL` for local).
@@ -216,7 +220,7 @@ To minimize breakage and respect dependencies:
- **Backend:** All REST route handlers, track* endpoint logic, indexer (listener + processor + backfill), Blockscout/etherscan compatibility, explorer-specific config (chain_id 138, RPC URLs), and migrations (schema stays here; libs use interfaces or config).
- **Frontend:** All pages and views, `public/index.html` SPA, explorer API service modules (blocks, transactions, addresses), Next.js app and deployment config.
-- **Deployment:** All explorer- and VMID 5000–specific scripts (`fix-502-blockscout.sh`, `complete-explorer-api-access.sh`, `deploy-frontend-to-vmid5000.sh`, etc.), and nginx/config that reference explorer.d-bis.org and Blockscout.
+- **Deployment:** All explorer- and VMID 5000–specific scripts (`fix-502-blockscout.sh`, `complete-explorer-api-access.sh`, legacy `deploy-frontend-to-vmid5000.sh`, etc.), and nginx/config that reference explorer.d-bis.org and Blockscout.
- **Docs:** All current documentation (API access, deployment, runbook, etc.).
---
diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md
index 65c182c..9cc4f0b 100644
--- a/docs/STRUCTURE.md
+++ b/docs/STRUCTURE.md
@@ -1,13 +1,18 @@
# Monorepo Structure
+> Historical note: this file still reflects the earlier static-SPA-oriented
+> layout. The canonical live frontend is now the Next app in `frontend/src/`,
+> deployed via `./scripts/deploy-next-frontend-to-vmid5000.sh`.
+
## Directory Overview
### `/frontend`
Frontend application code.
-- **`public/`**: Static HTML, CSS, JavaScript files served directly
- - `index.html`: Main explorer interface
-- **`src/`**: Source files (if using build tools like webpack, vite, etc.)
+- **`public/`**: Compatibility/reference static assets
+ - `index.html`: Historical static explorer interface
+ - `explorer-spa.js`: Historical extracted SPA script
+- **`src/`**: Canonical Next.js frontend source
- **`assets/`**: Images, fonts, and other static assets
### `/backend`
@@ -19,7 +24,8 @@ Backend services (if needed for future enhancements).
### `/scripts`
Deployment and utility scripts.
-- **`deploy.sh`**: Deploy explorer to production
+- **`deploy-next-frontend-to-vmid5000.sh`**: Canonical frontend deploy
+- **`deploy.sh`**: Deprecated static deploy shim
- **`test.sh`**: Test explorer functionality
### `/docs`
@@ -65,7 +71,7 @@ explorer-monorepo/
### Frontend Changes
-1. Edit `frontend/public/index.html` directly (current approach)
+1. Edit `frontend/src/` for the live frontend (current approach)
2. Or set up build tools in `frontend/src/` for compiled output
### Backend Changes
@@ -77,4 +83,3 @@ explorer-monorepo/
1. Add docs to `docs/` directory
2. Update README.md as needed
-
diff --git a/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md b/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md
index 2dcf0ec..a28097e 100644
--- a/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md
+++ b/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md
@@ -1,5 +1,9 @@
# Tiered Architecture Implementation Summary
+> Historical note: this implementation summary was written during the
+> static-SPA/Next transition. References to `frontend/public/index.html`
+> describe legacy feature-gating work, not the canonical live frontend path.
+
## Overview
The SolaceScan Explorer has been successfully upgraded to a 4-track tiered architecture with feature-gated access control.
@@ -41,7 +45,7 @@ All components have been implemented according to the plan:
- **Security**: IP whitelist and audit logging integrated
### ✅ Phase 7: Frontend & Integration
-- **Frontend Feature Gating**: Wallet connect UI and track-based feature visibility (`frontend/public/index.html`)
+- **Frontend Feature Gating**: Wallet connect UI and track-based feature visibility (historically in `frontend/public/index.html`, now carried forward in the Next frontend shell)
- **Route Integration**: Track-aware routing structure (`backend/api/rest/routes.go`)
## Architecture
@@ -84,7 +88,7 @@ Backend
- `backend/database/migrations/0010_track_schema.auth_only.sql` - shared Blockscout DB auth/operator subset
### Frontend
-- Updated `frontend/public/index.html` with feature gating
+- Updated the historical static SPA `frontend/public/index.html` with feature gating during the transition period
## Next Steps
diff --git a/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md b/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md
new file mode 100644
index 0000000..d2ba687
--- /dev/null
+++ b/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md
@@ -0,0 +1,530 @@
+# Explorer Freshness And Diagnostics Contract
+
+This document defines the minimum public freshness and diagnostics payloads the SolaceScan frontend needs in order to present chain activity, transaction visibility, and snapshot posture without relying on frontend inference.
+
+It is intended to close the remaining gap between:
+
+- a frontend that now renders and explains state honestly, and
+- upstream APIs that still omit authoritative freshness metadata for several critical surfaces.
+
+## Goal
+
+The frontend should be able to answer these questions directly from public API fields:
+
+1. Is the chain head current?
+2. When was the latest visible transaction indexed?
+3. What is the latest non-empty block?
+4. Is the homepage using a live feed, a snapshot, or mixed evidence?
+5. Which subsystem is stale: RPC, indexing, relay monitoring, or stats?
+6. Which values are reported directly vs inferred vs unavailable?
+
+The frontend should not have to infer these from a combination of:
+
+- `/api/v2/stats`
+- `/api/v2/main-page/blocks`
+- `/api/v2/main-page/transactions`
+- `/explorer-api/v1/track1/bridge/status`
+
+unless there is no backend alternative.
+
+## Design Principles
+
+- Prefer explicit freshness fields over derived heuristics.
+- Separate chain freshness from indexed-transaction freshness.
+- Distinguish reported facts from inferred or partial facts.
+- Make incompleteness first-class.
+- Keep the contract calm and operational, not alarmist.
+
+## Proposed Public Endpoints
+
+Two additions are recommended.
+
+### 1. Extend `GET /api/v2/stats`
+
+This endpoint already feeds the homepage summary cards. It should become the authoritative public summary for chain freshness and indexed activity freshness.
+
+### 2. Extend `GET /explorer-api/v1/track1/bridge/status`
+
+This endpoint already powers Mission Control. It should expose snapshot/feed posture and subsystem freshness more directly.
+
+If backend implementation prefers separation, these fields may instead be exposed from a new endpoint:
+
+`GET /explorer-api/v1/track1/observability/freshness`
+
+The frontend does not require a separate endpoint as long as the fields below are available from a stable public contract.
+
+## Required Additions To `/api/v2/stats`
+
+### Current gaps
+
+The current `stats` payload gives totals, but it does not reliably expose:
+
+- latest indexed transaction timestamp
+- latest non-empty block
+- authoritative utilization freshness
+- confidence/completeness metadata
+
+### Required fields
+
+```json
+{
+ "total_blocks": 3873353,
+ "total_transactions": 52391,
+ "total_addresses": 10294,
+ "latest_block": 3873353,
+ "average_block_time": 2000,
+ "gas_prices": {
+ "slow": 0.02,
+ "average": 0.03,
+ "fast": 0.05
+ },
+ "network_utilization_percentage": 0,
+ "transactions_today": 18,
+
+ "freshness": {
+ "chain_head": {
+ "block_number": 3873353,
+ "timestamp": "2026-04-10T21:42:15Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_indexed_transaction": {
+ "hash": "0x...",
+ "block_number": 3858013,
+ "timestamp": "2026-04-10T12:31:05Z",
+ "age_seconds": 33070,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_non_empty_block": {
+ "block_number": 3858013,
+ "timestamp": "2026-04-10T12:31:05Z",
+ "age_seconds": 33070,
+ "distance_from_head": 15340,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_indexed_block": {
+ "block_number": 3873353,
+ "timestamp": "2026-04-10T21:42:15Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ }
+ },
+
+ "completeness": {
+ "transactions_feed": "complete",
+ "blocks_feed": "complete",
+ "gas_metrics": "partial",
+ "utilization_metrics": "partial"
+ },
+
+ "sampling": {
+ "stats_generated_at": "2026-04-10T21:42:16Z",
+ "stats_window_seconds": 300,
+ "rpc_probe_at": "2026-04-10T21:42:15Z"
+ }
+}
+```
+
+## Field Semantics
+
+### `freshness.chain_head`
+
+The latest chain head known from the authoritative public RPC or canonical head source.
+
+This is the answer to:
+
+- "Is the chain alive?"
+- "Is head visibility current?"
+
+### `freshness.latest_indexed_transaction`
+
+The most recent transaction currently visible in the public indexed transaction feed.
+
+This is the answer to:
+
+- "How recent is the latest visible transaction?"
+
+### `freshness.latest_non_empty_block`
+
+The most recent indexed block containing one or more transactions.
+
+This is the answer to:
+
+- "Are head blocks empty because the chain is quiet?"
+- "What is the last block with visible activity?"
+
+### `freshness.latest_indexed_block`
+
+The latest block successfully indexed into the explorer's public block dataset.
+
+This disambiguates:
+
+- current chain head
+- current explorer indexed head
+
+### `completeness.*`
+
+Simple public-facing availability states for each summary subsystem:
+
+- `complete`
+- `partial`
+- `stale`
+- `unavailable`
+
+These should not be interpreted as outage severity; they describe data completeness only.
+
+### `sampling.*`
+
+Metadata for when the summary itself was generated and what freshness window it represents.
+
+## Required Additions To Mission Control Payload
+
+Mission Control currently provides useful relay detail, but the homepage still infers snapshot scope and partial feed posture from surrounding evidence.
+
+### Required fields
+
+```json
+{
+ "data": {
+ "status": "degraded",
+ "checked_at": "2026-04-10T21:42:16Z",
+ "mode": {
+ "kind": "snapshot",
+ "updated_at": "2026-04-10T21:42:16Z",
+ "age_seconds": 1,
+ "reason": "live_homepage_stream_not_attached",
+ "scope": "relay_monitoring_homepage_card_only",
+ "source": "reported",
+ "confidence": "high"
+ },
+ "subsystems": {
+ "rpc_head": {
+ "status": "operational",
+ "updated_at": "2026-04-10T21:42:15Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "tx_index": {
+ "status": "stale",
+ "updated_at": "2026-04-10T12:31:05Z",
+ "age_seconds": 33070,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "bridge_relay_monitoring": {
+ "status": "degraded",
+ "updated_at": "2026-04-10T21:42:16Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "stats_summary": {
+ "status": "partial",
+ "updated_at": "2026-04-10T21:42:16Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "medium"
+ }
+ }
+ }
+}
+```
+
+## Required Enumerations
+
+These enums should be consistent across public surfaces.
+
+### Activity interpretation
+
+- `active`
+- `quiet`
+- `sparse_activity`
+- `fresh_head_stale_tx_visibility`
+- `limited_observability`
+
+This value should be emitted only when the backend can support it directly. Otherwise the frontend may continue to derive it as a presentation layer.
+
+### Data source confidence
+
+- `high`
+- `medium`
+- `low`
+- `unknown`
+
+### Data origin
+
+- `reported`
+- `inferred`
+- `sampled`
+- `unavailable`
+
+### Completeness
+
+- `complete`
+- `partial`
+- `stale`
+- `unavailable`
+
+## Example Payloads
+
+These examples are intended to accelerate frontend/backend alignment by showing how the contract should represent common live states.
+
+### Example A: Healthy Live State
+
+```json
+{
+ "freshness": {
+ "chain_head": {
+ "block_number": 3874000,
+ "timestamp": "2026-04-10T22:10:14Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_indexed_block": {
+ "block_number": 3874000,
+ "timestamp": "2026-04-10T22:10:14Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_indexed_transaction": {
+ "hash": "0x...",
+ "block_number": 3873998,
+ "timestamp": "2026-04-10T22:10:10Z",
+ "age_seconds": 5,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_non_empty_block": {
+ "block_number": 3873998,
+ "timestamp": "2026-04-10T22:10:10Z",
+ "age_seconds": 5,
+ "distance_from_head": 2,
+ "source": "reported",
+ "confidence": "high"
+ }
+ },
+ "completeness": {
+ "transactions_feed": "complete",
+ "blocks_feed": "complete",
+ "gas_metrics": "complete",
+ "utilization_metrics": "complete"
+ }
+}
+```
+
+### Example B: Quiet Chain But Current
+
+```json
+{
+ "freshness": {
+ "chain_head": {
+ "block_number": 3875000,
+ "timestamp": "2026-04-10T23:10:14Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_indexed_block": {
+ "block_number": 3875000,
+ "timestamp": "2026-04-10T23:10:14Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_indexed_transaction": {
+ "hash": "0x...",
+ "block_number": 3874902,
+ "timestamp": "2026-04-10T23:01:42Z",
+ "age_seconds": 512,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_non_empty_block": {
+ "block_number": 3874902,
+ "timestamp": "2026-04-10T23:01:42Z",
+ "age_seconds": 512,
+ "distance_from_head": 98,
+ "source": "reported",
+ "confidence": "high"
+ }
+ },
+ "activity_interpretation": "quiet"
+}
+```
+
+### Example C: Fresh Head, Stale Transaction Visibility
+
+```json
+{
+ "freshness": {
+ "chain_head": {
+ "block_number": 3876000,
+ "timestamp": "2026-04-11T00:10:14Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_indexed_block": {
+ "block_number": 3875999,
+ "timestamp": "2026-04-11T00:10:12Z",
+ "age_seconds": 3,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_indexed_transaction": {
+ "hash": "0x...",
+ "block_number": 3860660,
+ "timestamp": "2026-04-10T15:02:10Z",
+ "age_seconds": 32900,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "latest_non_empty_block": {
+ "block_number": 3860660,
+ "timestamp": "2026-04-10T15:02:10Z",
+ "age_seconds": 32900,
+ "distance_from_head": 15340,
+ "source": "reported",
+ "confidence": "high"
+ }
+ },
+ "activity_interpretation": "fresh_head_stale_tx_visibility",
+ "completeness": {
+ "transactions_feed": "stale",
+ "blocks_feed": "complete",
+ "gas_metrics": "partial",
+ "utilization_metrics": "partial"
+ }
+}
+```
+
+### Example D: Snapshot Mode State
+
+```json
+{
+ "data": {
+ "status": "degraded",
+ "checked_at": "2026-04-11T00:10:15Z",
+ "mode": {
+ "kind": "snapshot",
+ "updated_at": "2026-04-11T00:10:15Z",
+ "age_seconds": 1,
+ "reason": "live_homepage_stream_not_attached",
+ "scope": "relay_monitoring_homepage_card_only",
+ "source": "reported",
+ "confidence": "high"
+ },
+ "subsystems": {
+ "rpc_head": {
+ "status": "operational",
+ "updated_at": "2026-04-11T00:10:14Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "tx_index": {
+ "status": "stale",
+ "updated_at": "2026-04-10T15:02:10Z",
+ "age_seconds": 32900,
+ "source": "reported",
+ "confidence": "high"
+ },
+ "bridge_relay_monitoring": {
+ "status": "degraded",
+ "updated_at": "2026-04-11T00:10:15Z",
+ "age_seconds": 1,
+ "source": "reported",
+ "confidence": "high"
+ }
+ }
+ }
+}
+```
+
+## Frontend Usage Rules
+
+Once the fields above exist, the frontend should follow these rules:
+
+1. Use backend freshness fields directly where present.
+2. Stop deriving latest transaction age from the transactions page feed when `freshness.latest_indexed_transaction` is available.
+3. Stop deriving last non-empty block from recent block scanning when `freshness.latest_non_empty_block` is available.
+4. Use `mode.kind`, `mode.reason`, and `mode.scope` directly for homepage snapshot messaging.
+5. Use `source` and `confidence` badges only where they improve trust and do not clutter.
+
+## Backward-Compatible Rollout Plan
+
+### Phase A
+
+Add fields without removing any current keys:
+
+- extend `/api/v2/stats`
+- extend bridge status payload with `mode` and `subsystems`
+
+### Phase B
+
+Frontend prefers new fields when available and falls back to inference when absent.
+
+### Phase C
+
+Once fields are consistently present in production:
+
+- reduce frontend inference paths
+- remove duplicate explanatory fallback logic where it is no longer needed
+
+## Minimum Viable Backend Implementation
+
+If full rollout is not possible immediately, the minimum high-leverage addition is:
+
+### `/api/v2/stats`
+
+- `freshness.chain_head`
+- `freshness.latest_indexed_transaction`
+- `freshness.latest_non_empty_block`
+- `sampling.stats_generated_at`
+
+### `/explorer-api/v1/track1/bridge/status`
+
+- `mode.kind`
+- `mode.updated_at`
+- `mode.reason`
+- `mode.scope`
+
+That alone would materially reduce frontend ambiguity.
+
+## Why This Contract Matters
+
+The frontend now presents state honestly enough that the remaining ambiguity is no longer visual. It is contractual.
+
+Without these fields, the UI must keep inferring:
+
+- whether the chain is quiet or stale
+- whether the homepage is in snapshot mode because of relay posture or indexing posture
+- whether low activity is real or a visibility gap
+
+With these fields, the product becomes:
+
+- more trustworthy
+- easier to evaluate externally
+- less likely to be misread as broken
+
+## Summary
+
+The next backend milestone is not broad API expansion. It is a targeted public freshness contract.
+
+The public explorer needs explicit answers for:
+
+- current chain head
+- current indexed head
+- latest visible transaction
+- last non-empty block
+- snapshot/feed mode
+- subsystem freshness/completeness
+
+That is the smallest backend addition with the highest frontend trust impact.
diff --git a/docs/api/EXPLORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md b/docs/api/EXPLORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md
new file mode 100644
index 0000000..cef81d0
--- /dev/null
+++ b/docs/api/EXPLORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md
@@ -0,0 +1,278 @@
+# Explorer Freshness Implementation Checklist
+
+This checklist converts the freshness contract into a backend implementation plan against the current SolaceScan code paths:
+
+- [stats.go](/home/intlc/projects/proxmox/explorer-monorepo/backend/api/rest/stats.go)
+- [mission_control.go](/home/intlc/projects/proxmox/explorer-monorepo/backend/api/rest/mission_control.go)
+
+Use this document as the handoff from frontend trust requirements to backend delivery.
+
+See also:
+
+- [EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md](/home/intlc/projects/proxmox/explorer-monorepo/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md)
+- [track-api-contracts.md](/home/intlc/projects/proxmox/explorer-monorepo/docs/api/track-api-contracts.md)
+
+## Scope
+
+This checklist covers four buckets:
+
+1. field ownership and source of truth
+2. response-shape rollout
+3. freshness semantics
+4. confidence and completeness behavior
+
+## Bucket 1: Field Ownership And Source Of Truth
+
+| Field | Endpoint | Backend owner | Source of truth | Directly measured or derived | Cadence | Nullable | Frontend dependency |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| `freshness.chain_head.block_number` | `/api/v2/stats` | `stats.go` with RPC helper | authoritative public RPC head | directly measured | per stats request or short cache | no | homepage head freshness, blocks/trust cues |
+| `freshness.chain_head.timestamp` | `/api/v2/stats` | `stats.go` with RPC helper | authoritative public RPC head block timestamp | directly measured | per stats request or short cache | no | head age, chain visibility |
+| `freshness.latest_indexed_block.block_number` | `/api/v2/stats` | `stats.go` | explorer DB `MAX(blocks.number)` | directly measured | per stats request | no | distinguish head vs indexed head |
+| `freshness.latest_indexed_block.timestamp` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed block timestamp | directly measured | per stats request | yes until wired | detail-page trust cues |
+| `freshness.latest_indexed_transaction.hash` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed tx row | directly measured | per stats request | yes | activity summary |
+| `freshness.latest_indexed_transaction.block_number` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed tx row | directly measured | per stats request | yes | tx freshness explanation |
+| `freshness.latest_indexed_transaction.timestamp` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed tx row | directly measured | per stats request | yes | tx age, stale tx visibility |
+| `freshness.latest_non_empty_block.block_number` | `/api/v2/stats` | `stats.go` | explorer DB latest block where `transaction_count > 0` or equivalent join | derived from indexed block/tx data | per stats request | yes | quiet-chain vs stale-visibility interpretation |
+| `freshness.latest_non_empty_block.timestamp` | `/api/v2/stats` | `stats.go` | explorer DB latest non-empty block row | derived from indexed block/tx data | per stats request | yes | recent activity framing |
+| `freshness.latest_non_empty_block.distance_from_head` | `/api/v2/stats` | `stats.go` | computed from chain head minus last non-empty block | derived | per stats request | yes | homepage block-gap explanation |
+| `completeness.transactions_feed` | `/api/v2/stats` | `stats.go` | comparison of tx freshness vs head freshness | derived | per stats request | no | trust badges |
+| `completeness.blocks_feed` | `/api/v2/stats` | `stats.go` | indexed block freshness vs chain head freshness | derived | per stats request | no | trust badges |
+| `completeness.gas_metrics` | `/api/v2/stats` | `stats.go` | gas fields presence and quality | derived | per stats request | no | gas card honesty |
+| `completeness.utilization_metrics` | `/api/v2/stats` | `stats.go` | utilization field presence and quality | derived | per stats request | no | utilization card honesty |
+| `sampling.stats_generated_at` | `/api/v2/stats` | `stats.go` | server clock at response generation | directly measured | per response | no | “updated” copy |
+| `sampling.rpc_probe_at` | `/api/v2/stats` | `stats.go` | latest successful RPC sample timestamp | directly measured or nullable | per stats request | yes | source confidence |
+| `mode.kind` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | mission-control feed mode | directly measured if known, otherwise derived conservatively | per response / SSE tick | no | snapshot/live messaging |
+| `mode.updated_at` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | mission-control snapshot timestamp | directly measured | per response | no | snapshot age |
+| `mode.reason` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | bridge/homepage mode controller | directly measured if available, else nullable | per response | yes | scope explanation |
+| `mode.scope` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | bridge/homepage mode controller | directly measured if available, else nullable | per response | yes | “what is affected?” |
+| `subsystems.rpc_head.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | RPC probe result | directly measured | per response | no | mission-control trust cues |
+| `subsystems.tx_index.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` using stats freshness or shared helper | explorer DB tx freshness | derived from authoritative indexed data | per response / shared cache | yes | homepage stale-tx explanation |
+| `subsystems.bridge_relay_monitoring.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | existing relay probe payload | directly measured | per response | no | lane posture |
+| `subsystems.stats_summary.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` or shared summary helper | stats freshness sample | derived | per response | yes | homepage summary confidence |
+
+## Bucket 2: Response-Shape Rollout
+
+### Ship immediately as nullable additions
+
+These are low-risk additive fields that can be introduced without breaking existing clients.
+
+- `freshness.latest_indexed_transaction.*`
+- `freshness.latest_non_empty_block.*`
+- `freshness.latest_indexed_block.timestamp`
+- `sampling.stats_generated_at`
+- `sampling.rpc_probe_at`
+- `mode.kind`
+- `mode.updated_at`
+- `mode.reason`
+- `mode.scope`
+- `subsystems.*`
+
+### Ship after backend wiring
+
+These need real data acquisition or shared helpers.
+
+- `freshness.chain_head.*`
+- `completeness.transactions_feed`
+- `completeness.blocks_feed`
+- `completeness.gas_metrics`
+- `completeness.utilization_metrics`
+
+### Derived computations
+
+These may be computed in backend code once the authoritative inputs exist.
+
+- `freshness.latest_non_empty_block.distance_from_head`
+- subsystem `status`
+- completeness enums
+- optional `activity_interpretation`
+
+### Frontend adoption order
+
+1. Prefer new fields when present.
+2. Fall back to current inference when absent.
+3. Remove inference once fields are stable in production.
+
+## Bucket 3: Freshness Semantics
+
+Each field must answer a precise question.
+
+### `freshness.chain_head`
+
+- Meaning: latest chain head observed from the authoritative public RPC
+- Must not mean: latest indexed explorer block
+- If unknown: return `null` object members where needed plus completeness/confidence state
+
+### `freshness.latest_indexed_block`
+
+- Meaning: latest block successfully indexed into the explorer DB or visible explorer block source
+- Must not mean: latest RPC head
+
+### `freshness.latest_indexed_transaction`
+
+- Meaning: latest transaction currently visible in the public indexed transaction feed
+- Must not mean: latest mempool event or latest raw RPC tx if not visible in the explorer feed
+
+### `freshness.latest_non_empty_block`
+
+- Meaning: latest indexed block containing at least one visible indexed transaction
+- This is the critical disambiguator for quiet-chain vs stale-visibility interpretation
+
+### `mode.kind`
+
+- Meaning: the current homepage/mission-control delivery mode
+- Allowed values: `live`, `snapshot`, `mixed`, `unknown`
+
+### `mode.scope`
+
+- Meaning: which user-visible surface is affected by mode choice
+- Examples:
+ - `relay_monitoring_homepage_card_only`
+ - `homepage_summary_only`
+ - `bridge_monitoring_and_homepage`
+
+### `mode.reason`
+
+- Meaning: why snapshot or mixed mode is active
+- Must be calm and operational, not blame-oriented
+- Examples:
+ - `live_homepage_stream_not_attached`
+ - `relay_snapshot_only_source`
+ - `partial_observability_inputs`
+
+### `subsystems.*`
+
+- Meaning: freshness of each component, not overall product health
+- Recommended subsystem keys:
+ - `rpc_head`
+ - `tx_index`
+ - `bridge_relay_monitoring`
+ - `stats_summary`
+
+## Bucket 4: Confidence And Completeness
+
+Every nullable or derived field should have explicit semantics.
+
+### Confidence
+
+- `high`: authoritative source and recent sample
+- `medium`: authoritative source but partially stale, or a stable derived value from strong inputs
+- `low`: weakly derived or missing one of the underlying inputs
+- `unknown`: no basis to express confidence
+
+### Completeness
+
+- `complete`: field is current and supported by recent source data
+- `partial`: field exists but some required inputs are missing or weak
+- `stale`: field is known, but the latest available value is older than acceptable freshness
+- `unavailable`: no trustworthy value exists
+
+### Null and zero handling
+
+- Unknown must be `null`, not synthetic `0`
+- Zero may be returned only when zero is a real measured value
+- If a value is null, a sibling completeness/confidence field must explain why
+
+## Acceptance Tests
+
+These should be implemented in backend tests and used as rollout gates.
+
+### 1. Current head, stale tx visibility
+
+If chain head is current but tx visibility is stale:
+
+- `freshness.chain_head` must be current
+- `freshness.latest_indexed_transaction` must be older
+- `freshness.latest_non_empty_block` must be exposed
+- completeness must not report all feeds as `complete`
+
+### 2. Quiet chain, current visibility
+
+If recent head blocks are genuinely empty:
+
+- `freshness.chain_head` must still be current
+- `freshness.latest_non_empty_block` must be present
+- `freshness.latest_indexed_transaction` must be present
+- API must not force a stale diagnosis if visibility itself is current
+
+### 3. Snapshot mode active
+
+If snapshot mode is active:
+
+- `mode.kind` must be `snapshot` or `mixed`
+- `mode.scope` must state what is affected
+- `mode.reason` must be present if known
+
+### 4. Unknown fields
+
+If a field is unknown:
+
+- return `null`
+- expose confidence/completeness state
+- do not return fake zero values
+
+## Backend Implementation Checklist
+
+### `stats.go`
+
+- [ ] Extend `explorerStats` with nullable freshness/completeness/sampling fields.
+- [ ] Add query/helper for latest indexed transaction.
+- [ ] Add query/helper for latest non-empty block.
+- [ ] Add query/helper for latest indexed block timestamp.
+- [ ] Add RPC helper for current chain head number and timestamp.
+- [ ] Compute `distance_from_head` when both chain head and latest non-empty block are present.
+- [ ] Compute completeness enums for blocks, transactions, gas metrics, and utilization.
+- [ ] Return `null` for unknowns rather than synthetic zero values.
+- [ ] Add internal tests covering:
+ - healthy current state
+ - quiet-chain state
+ - stale tx visibility state
+ - null/unknown field handling
+
+### `mission_control.go`
+
+- [ ] Extend bridge status response with `mode`.
+- [ ] Extend bridge status response with `subsystems`.
+- [ ] Reuse or call shared freshness helper for tx index freshness rather than duplicating logic.
+- [ ] Emit `mode.scope` and `mode.reason` only when backend can support them.
+- [ ] Use `unknown` or nullable values when reason/scope cannot be stated authoritatively.
+- [ ] Add tests covering:
+ - live mode
+ - snapshot mode
+ - mixed mode
+ - tx index stale while RPC head remains current
+
+### Shared rollout
+
+- [ ] Frontend reads new fields opportunistically.
+- [ ] Existing frontend inference remains as fallback until backend fields are stable.
+- [ ] Swagger/OpenAPI docs updated after implementation.
+- [ ] Public docs updated only after payload shape is live.
+
+## Test Coverage Guidance
+
+For every field, capture:
+
+- who computes it
+- from what source
+- at what cadence
+- whether nullable or required
+- fallback behavior
+- confidence/completeness semantics
+- frontend dependency
+- backend test case name
+
+That metadata is more important than perfect initial coverage breadth.
+
+## Shortest Path To Value
+
+If the team wants the fastest possible trust win, implement these first:
+
+1. `freshness.chain_head`
+2. `freshness.latest_indexed_transaction`
+3. `freshness.latest_non_empty_block`
+4. `sampling.stats_generated_at`
+5. `mode.kind`
+6. `mode.scope`
+7. `mode.reason`
+
+That is the minimum set that lets the frontend stop guessing about the most visible freshness ambiguity.
diff --git a/docs/api/track-api-contracts.md b/docs/api/track-api-contracts.md
index d8817fb..b74bed5 100644
--- a/docs/api/track-api-contracts.md
+++ b/docs/api/track-api-contracts.md
@@ -1,5 +1,7 @@
# Track API Contracts
+See also: [EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md](/home/intlc/projects/proxmox/explorer-monorepo/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md) for the public freshness and observability fields required by the current SolaceScan frontend.
+
Complete API contract definitions for all 4 tracks of SolaceScan Explorer.
## Track 1: Public Meta Explorer (No Auth Required)
diff --git a/frontend/src/components/common/ActivityContextPanel.tsx b/frontend/src/components/common/ActivityContextPanel.tsx
new file mode 100644
index 0000000..641fa43
--- /dev/null
+++ b/frontend/src/components/common/ActivityContextPanel.tsx
@@ -0,0 +1,137 @@
+import Link from 'next/link'
+import { Card } from '@/libs/frontend-ui-primitives'
+import EntityBadge from '@/components/common/EntityBadge'
+import type { ChainActivityContext } from '@/utils/activityContext'
+import { formatRelativeAge, formatTimestamp } from '@/utils/format'
+import { Explain, useUiMode } from './UiModeContext'
+
+function resolveTone(state: ChainActivityContext['state']): 'success' | 'warning' | 'neutral' {
+ switch (state) {
+ case 'active':
+ return 'success'
+ case 'low':
+ case 'inactive':
+ return 'warning'
+ default:
+ return 'neutral'
+ }
+}
+
+function resolveLabel(state: ChainActivityContext['state']): string {
+ switch (state) {
+ case 'active':
+ return 'active'
+ case 'low':
+ return 'low activity'
+ case 'inactive':
+ return 'inactive'
+ default:
+ return 'unknown'
+ }
+}
+
+function renderHeadline(context: ChainActivityContext): string {
+ if (context.transaction_visibility_unavailable) {
+ return 'Transaction index freshness is currently unavailable, while chain-head visibility remains live.'
+ }
+ if (context.state === 'unknown') {
+ return 'Recent activity context is temporarily unavailable.'
+ }
+ if (context.state === 'active') {
+ return 'Recent transactions are close to the visible chain tip.'
+ }
+ if (context.head_is_idle) {
+ return 'The chain head is advancing, but the latest visible transaction is older than the current tip.'
+ }
+ return 'Recent transaction activity is sparse right now.'
+}
+
+export default function ActivityContextPanel({
+ context,
+ title = 'Chain Activity Context',
+}: {
+ context: ChainActivityContext
+ title?: string
+}) {
+ const { mode } = useUiMode()
+ const tone = resolveTone(context.state)
+ const dualTimelineLabel =
+ context.latest_block_timestamp && context.latest_transaction_timestamp
+ ? `${formatRelativeAge(context.latest_block_timestamp)} head · ${formatRelativeAge(context.latest_transaction_timestamp)} latest tx`
+ : 'Dual timeline unavailable'
+
+ return (
+
+ Use the transaction tip and last non-empty block below to distinguish a quiet chain from a broken explorer.
+
+ Search destinations and run high-frequency header actions from one keyboard-first surface. +
+