diff --git a/backend/api/freshness/freshness.go b/backend/api/freshness/freshness.go
index fcdcfcd..e80ae67 100644
--- a/backend/api/freshness/freshness.go
+++ b/backend/api/freshness/freshness.go
@@ -72,6 +72,22 @@ type Snapshot struct {
LatestNonEmptyBlock Reference `json:"latest_non_empty_block"`
}
+type Diagnostics struct {
+ TxVisibilityState string `json:"tx_visibility_state"`
+ ActivityState string `json:"activity_state"`
+ Explanation string `json:"explanation,omitempty"`
+ TxLagBlocks *int64 `json:"tx_lag_blocks,omitempty"`
+ TxLagSeconds *int64 `json:"tx_lag_seconds,omitempty"`
+ RecentBlockSampleSize *int64 `json:"recent_block_sample_size,omitempty"`
+ RecentNonEmptyBlocks *int64 `json:"recent_non_empty_blocks,omitempty"`
+ RecentTransactions *int64 `json:"recent_transactions,omitempty"`
+ LatestNonEmptyFromBlockFeed Reference `json:"latest_non_empty_block_from_block_feed"`
+ Source Source `json:"source"`
+ Confidence Confidence `json:"confidence"`
+ Provenance Provenance `json:"provenance"`
+ Completeness Completeness `json:"completeness"`
+}
+
type SummaryCompleteness struct {
TransactionsFeed Completeness `json:"transactions_feed"`
BlocksFeed Completeness `json:"blocks_feed"`
@@ -163,6 +179,49 @@ func classifyMetricPresence[T comparable](value *T) Completeness {
return CompletenessComplete
}
+func classifyTxVisibilityState(age *int64) string {
+ if age == nil {
+ return "unavailable"
+ }
+ switch {
+ case *age <= 15*60:
+ return "current"
+ case *age <= 3*60*60:
+ return "lagging"
+ default:
+ return "stale"
+ }
+}
+
+func classifyActivityState(txVisibility string, txLagBlocks, recentTransactions, recentNonEmptyBlocks *int64) (string, string, Completeness) {
+ if txVisibility == "unavailable" {
+ if recentTransactions != nil && *recentTransactions > 0 {
+ return "limited_observability", "Recent blocks show on-chain transaction activity, but indexed transaction freshness is unavailable.", CompletenessPartial
+ }
+ return "limited_observability", "Transaction freshness is unavailable, and recent block activity is limited.", CompletenessUnavailable
+ }
+
+ if recentTransactions != nil && *recentTransactions > 0 {
+ if txLagBlocks != nil && *txLagBlocks > 32 {
+ return "fresh_head_stale_transaction_visibility", "Recent block activity is present closer to the head than the visible indexed transaction feed.", CompletenessPartial
+ }
+ if *recentTransactions <= 3 {
+ return "sparse_activity", "Recent blocks contain only a small amount of transaction activity.", CompletenessComplete
+ }
+ return "active", "Recent blocks contain visible transaction activity close to the head.", CompletenessComplete
+ }
+
+ if recentNonEmptyBlocks != nil && *recentNonEmptyBlocks == 0 {
+ return "quiet_chain", "Recent sampled head blocks are empty, which indicates a quiet chain rather than a broken explorer.", CompletenessComplete
+ }
+
+ if txLagBlocks != nil && *txLagBlocks > 32 {
+ return "fresh_head_stale_transaction_visibility", "The chain head is current, but the indexed transaction feed trails the current tip.", CompletenessPartial
+ }
+
+ return "sparse_activity", "Recent visible transaction activity is limited.", CompletenessComplete
+}
+
func BuildSnapshot(
ctx context.Context,
chainID int,
@@ -171,13 +230,22 @@ func BuildSnapshot(
now time.Time,
averageGasPrice *float64,
utilization *float64,
-) (Snapshot, SummaryCompleteness, Sampling, error) {
+) (Snapshot, SummaryCompleteness, Sampling, Diagnostics, error) {
snapshot := Snapshot{
ChainHead: unknownReference(ProvenanceRPC),
LatestIndexedBlock: unknownReference(ProvenanceExplorerIndex),
LatestIndexedTransaction: unknownReference(ProvenanceTxIndex),
LatestNonEmptyBlock: unknownReference(ProvenanceTxIndex),
}
+ diagnostics := Diagnostics{
+ TxVisibilityState: "unavailable",
+ ActivityState: "limited_observability",
+ LatestNonEmptyFromBlockFeed: unknownReference(ProvenanceExplorerIndex),
+ Source: SourceReported,
+ Confidence: ConfidenceMedium,
+ Provenance: ProvenanceComposite,
+ Completeness: CompletenessUnavailable,
+ }
issues := map[string]string{}
if probeHead != nil {
@@ -270,6 +338,84 @@ func BuildSnapshot(
issues["latest_non_empty_block"] = err.Error()
}
+ var latestBlockFeedNonEmptyNumber int64
+ var latestBlockFeedNonEmptyTime time.Time
+ if err := queryRow(ctx,
+ `SELECT b.number, b.timestamp
+ FROM blocks b
+ JOIN (
+ SELECT DISTINCT block_number
+ FROM transactions
+ WHERE block_number IS NOT NULL
+ ) tx_blocks
+ ON tx_blocks.block_number = b.number
+ ORDER BY b.number DESC
+ LIMIT 1`,
+ ).Scan(&latestBlockFeedNonEmptyNumber, &latestBlockFeedNonEmptyTime); err == nil {
+ timestamp := timePointer(latestBlockFeedNonEmptyTime)
+ ref := Reference{
+ BlockNumber: ptrInt64(latestBlockFeedNonEmptyNumber),
+ Timestamp: timestamp,
+ AgeSeconds: computeAge(timestamp, now),
+ Source: SourceDerived,
+ Confidence: ConfidenceMedium,
+ Provenance: ProvenanceComposite,
+ Completeness: snapshot.LatestIndexedTransaction.Completeness,
+ }
+ if snapshot.ChainHead.BlockNumber != nil {
+ distance := *snapshot.ChainHead.BlockNumber - latestBlockFeedNonEmptyNumber
+ if distance < 0 {
+ distance = 0
+ }
+ ref.DistanceFromHead = ptrInt64(distance)
+ }
+ diagnostics.LatestNonEmptyFromBlockFeed = ref
+ } else {
+ issues["latest_non_empty_block_from_block_feed"] = err.Error()
+ }
+
+ var recentBlockSampleSize, recentNonEmptyBlocks, recentTransactions int64
+ if err := queryRow(ctx,
+ `SELECT COUNT(*)::bigint,
+ COUNT(*) FILTER (WHERE COALESCE(tx_counts.tx_count, 0) > 0)::bigint,
+ COALESCE(SUM(COALESCE(tx_counts.tx_count, 0)), 0)::bigint
+ FROM (
+ SELECT number
+ FROM blocks
+ ORDER BY number DESC
+ LIMIT 128
+ ) recent_blocks
+ LEFT JOIN (
+ SELECT block_number, COUNT(*)::bigint AS tx_count
+ FROM transactions
+ WHERE block_number IS NOT NULL
+ GROUP BY block_number
+ ) tx_counts
+ ON tx_counts.block_number = recent_blocks.number`,
+ ).Scan(&recentBlockSampleSize, &recentNonEmptyBlocks, &recentTransactions); err == nil {
+ diagnostics.RecentBlockSampleSize = ptrInt64(recentBlockSampleSize)
+ diagnostics.RecentNonEmptyBlocks = ptrInt64(recentNonEmptyBlocks)
+ diagnostics.RecentTransactions = ptrInt64(recentTransactions)
+ } else {
+ issues["recent_block_activity"] = err.Error()
+ }
+
+ if snapshot.ChainHead.BlockNumber != nil && snapshot.LatestIndexedTransaction.BlockNumber != nil {
+ lag := *snapshot.ChainHead.BlockNumber - *snapshot.LatestIndexedTransaction.BlockNumber
+ if lag < 0 {
+ lag = 0
+ }
+ diagnostics.TxLagBlocks = ptrInt64(lag)
+ }
+ diagnostics.TxLagSeconds = snapshot.LatestIndexedTransaction.AgeSeconds
+ diagnostics.TxVisibilityState = classifyTxVisibilityState(snapshot.LatestIndexedTransaction.AgeSeconds)
+ diagnostics.ActivityState, diagnostics.Explanation, diagnostics.Completeness = classifyActivityState(
+ diagnostics.TxVisibilityState,
+ diagnostics.TxLagBlocks,
+ diagnostics.RecentTransactions,
+ diagnostics.RecentNonEmptyBlocks,
+ )
+
statsGeneratedAt := now.UTC().Format(time.RFC3339)
sampling := Sampling{
StatsGeneratedAt: ptrString(statsGeneratedAt),
@@ -289,7 +435,7 @@ func BuildSnapshot(
UtilizationMetric: classifyMetricPresence(utilization),
}
- return snapshot, completeness, sampling, nil
+ return snapshot, completeness, sampling, diagnostics, nil
}
func ProbeChainHead(ctx context.Context, rpcURL string) (*Reference, error) {
diff --git a/backend/api/freshness/freshness_test.go b/backend/api/freshness/freshness_test.go
index 386f6f2..55dabac 100644
--- a/backend/api/freshness/freshness_test.go
+++ b/backend/api/freshness/freshness_test.go
@@ -42,6 +42,19 @@ func TestBuildSnapshotHealthyState(t *testing.T) {
*dest[1].(*time.Time) = now.Add(-5 * time.Second)
return nil
}}
+ case 4:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 198
+ *dest[1].(*time.Time) = now.Add(-5 * time.Second)
+ return nil
+ }}
+ case 5:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 128
+ *dest[1].(*int64) = 12
+ *dest[2].(*int64) = 34
+ return nil
+ }}
default:
t.Fatalf("unexpected call %d", call)
return nil
@@ -63,13 +76,14 @@ func TestBuildSnapshotHealthyState(t *testing.T) {
}, nil
}
- snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
+ snapshot, completeness, sampling, diagnostics, 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)
+ require.Equal(t, "active", diagnostics.ActivityState)
}
func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
@@ -97,6 +111,19 @@ func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
*dest[1].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute))
return nil
}}
+ case 4:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 3875998
+ *dest[1].(*time.Time) = now.Add(-4 * time.Second)
+ return nil
+ }}
+ case 5:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 128
+ *dest[1].(*int64) = 3
+ *dest[2].(*int64) = 9
+ return nil
+ }}
default:
t.Fatalf("unexpected call %d", call)
return nil
@@ -118,11 +145,12 @@ func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
}, nil
}
- snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
+ snapshot, completeness, _, diagnostics, 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)
+ require.Equal(t, "fresh_head_stale_transaction_visibility", diagnostics.ActivityState)
}
func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
@@ -150,6 +178,19 @@ func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
*dest[1].(*time.Time) = now.Add(-512 * time.Second)
return nil
}}
+ case 4:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 3874902
+ *dest[1].(*time.Time) = now.Add(-512 * time.Second)
+ return nil
+ }}
+ case 5:
+ return fakeRow{scan: func(dest ...any) error {
+ *dest[0].(*int64) = 128
+ *dest[1].(*int64) = 0
+ *dest[2].(*int64) = 0
+ return nil
+ }}
default:
t.Fatalf("unexpected call %d", call)
return nil
@@ -171,10 +212,11 @@ func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
}, nil
}
- snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
+ snapshot, completeness, _, diagnostics, 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)
+ require.Equal(t, "quiet_chain", diagnostics.ActivityState)
}
func TestBuildSnapshotUnknownFieldsRemainNullSafe(t *testing.T) {
@@ -184,9 +226,10 @@ func TestBuildSnapshotUnknownFieldsRemainNullSafe(t *testing.T) {
}}
}
- snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, nil, time.Now().UTC(), nil, nil)
+ snapshot, completeness, sampling, diagnostics, 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)
+ require.Equal(t, "limited_observability", diagnostics.ActivityState)
}
diff --git a/backend/api/rest/stats.go b/backend/api/rest/stats.go
index 7c05fb8..4c0ce7e 100644
--- a/backend/api/rest/stats.go
+++ b/backend/api/rest/stats.go
@@ -25,6 +25,7 @@ type explorerStats struct {
Freshness freshness.Snapshot `json:"freshness"`
Completeness freshness.SummaryCompleteness `json:"completeness"`
Sampling freshness.Sampling `json:"sampling"`
+ Diagnostics freshness.Diagnostics `json:"diagnostics"`
}
type explorerGasPrices struct {
@@ -160,7 +161,7 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
}
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
- snapshot, completeness, sampling, err := freshness.BuildSnapshot(
+ snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
ctx,
chainID,
queryRow,
@@ -185,6 +186,7 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
stats.Freshness = snapshot
stats.Completeness = completeness
stats.Sampling = sampling
+ stats.Diagnostics = diagnostics
return stats, nil
}
diff --git a/backend/api/rest/stats_internal_test.go b/backend/api/rest/stats_internal_test.go
index 9342fbd..a36127a 100644
--- a/backend/api/rest/stats_internal_test.go
+++ b/backend/api/rest/stats_internal_test.go
@@ -76,6 +76,13 @@ func TestLoadExplorerStatsReturnsValues(t *testing.T) {
case 11:
*dest[0].(*int64) = 40
*dest[1].(*time.Time) = time.Now().Add(-5 * time.Second)
+ case 12:
+ *dest[0].(*int64) = 42
+ *dest[1].(*time.Time) = time.Now().Add(-3 * time.Second)
+ case 13:
+ *dest[0].(*int64) = 128
+ *dest[1].(*int64) = 10
+ *dest[2].(*int64) = 22
default:
t.Fatalf("unexpected query call %d", call)
}
@@ -102,6 +109,8 @@ func TestLoadExplorerStatsReturnsValues(t *testing.T) {
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, "active", stats.Diagnostics.ActivityState)
+ require.Equal(t, int64(4), *stats.Diagnostics.TxLagBlocks)
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)
diff --git a/backend/api/rest/track_routes.go b/backend/api/rest/track_routes.go
index dbb34b2..7c1dc30 100644
--- a/backend/api/rest/track_routes.go
+++ b/backend/api/rest/track_routes.go
@@ -50,12 +50,12 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
}
rpcGateway := gateway.NewRPCGateway(rpcURL, cache, rateLimiter)
- track1Server := track1.NewServer(rpcGateway, func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
+ track1Server := track1.NewServer(rpcGateway, func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error) {
if s.db == nil {
- return nil, nil, nil, nil
+ return nil, nil, nil, nil, nil
}
now := time.Now().UTC()
- snapshot, completeness, sampling, err := freshness.BuildSnapshot(
+ snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
ctx,
s.chainID,
s.db.QueryRow,
@@ -67,9 +67,9 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
nil,
)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, nil, err
}
- return &snapshot, &completeness, &sampling, nil
+ return &snapshot, &completeness, &sampling, &diagnostics, nil
})
// Track 1 routes (public, optional auth)
diff --git a/backend/api/track1/bridge_status_data.go b/backend/api/track1/bridge_status_data.go
index a06b27e..44273f6 100644
--- a/backend/api/track1/bridge_status_data.go
+++ b/backend/api/track1/bridge_status_data.go
@@ -132,7 +132,7 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
data["operator_verify"] = ov
}
if s.freshnessLoader != nil {
- if snapshot, completeness, sampling, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
+ if snapshot, completeness, sampling, diagnostics, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
subsystems := map[string]interface{}{
"rpc_head": map[string]interface{}{
"status": chainStatusFromProbe(p138),
@@ -194,6 +194,9 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
data["freshness"] = snapshot
data["subsystems"] = subsystems
data["sampling"] = sampling
+ if diagnostics != nil {
+ data["diagnostics"] = diagnostics
+ }
data["mode"] = map[string]interface{}{
"kind": modeKind,
"updated_at": now,
diff --git a/backend/api/track1/ccip_health_test.go b/backend/api/track1/ccip_health_test.go
index 06ea7f0..35b51de 100644
--- a/backend/api/track1/ccip_health_test.go
+++ b/backend/api/track1/ccip_health_test.go
@@ -147,7 +147,7 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
s := &Server{
- freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
+ freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error) {
now := time.Now().UTC().Format(time.RFC3339)
head := int64(16)
txBlock := int64(12)
@@ -187,6 +187,14 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
BlocksFeed: freshness.CompletenessComplete,
},
&freshness.Sampling{StatsGeneratedAt: &now},
+ &freshness.Diagnostics{
+ TxVisibilityState: "lagging",
+ ActivityState: "fresh_head_stale_transaction_visibility",
+ Source: freshness.SourceReported,
+ Confidence: freshness.ConfidenceMedium,
+ Provenance: freshness.ProvenanceComposite,
+ Completeness: freshness.CompletenessPartial,
+ },
nil
},
}
@@ -201,6 +209,7 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
require.True(t, ok)
require.Equal(t, true, probe["ok"])
require.Contains(t, got, "freshness")
+ require.Contains(t, got, "diagnostics")
require.Contains(t, got, "subsystems")
require.Contains(t, got, "mode")
}
@@ -245,8 +254,8 @@ func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
t.Setenv("CCIP_RELAY_HEALTH_URLS", "mainnet="+mainnet.URL+"/healthz,bsc="+bad.URL+"/healthz")
s := &Server{
- freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
- return nil, nil, nil, nil
+ freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error) {
+ return nil, nil, nil, nil, nil
},
}
got := s.BuildBridgeStatusData(context.Background())
diff --git a/backend/api/track1/endpoints.go b/backend/api/track1/endpoints.go
index 5c30420..917a83f 100644
--- a/backend/api/track1/endpoints.go
+++ b/backend/api/track1/endpoints.go
@@ -21,13 +21,13 @@ 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
- freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error)
+ freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error)
}
// NewServer creates a new Track 1 server
func NewServer(
rpcGateway *gateway.RPCGateway,
- freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error),
+ freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error),
) *Server {
return &Server{
rpcGateway: rpcGateway,
diff --git a/frontend/src/components/common/FreshnessTrustNote.tsx b/frontend/src/components/common/FreshnessTrustNote.tsx
index ec13848..5e8b87c 100644
--- a/frontend/src/components/common/FreshnessTrustNote.tsx
+++ b/frontend/src/components/common/FreshnessTrustNote.tsx
@@ -6,6 +6,7 @@ import {
summarizeFreshnessConfidence,
} from '@/utils/explorerFreshness'
import { formatRelativeAge } from '@/utils/format'
+import { useUiMode } from './UiModeContext'
function buildSummary(context: ChainActivityContext) {
if (context.transaction_visibility_unavailable) {
@@ -27,7 +28,11 @@ function buildSummary(context: ChainActivityContext) {
return 'Freshness context is based on the latest visible public explorer evidence.'
}
-function buildDetail(context: ChainActivityContext) {
+function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null) {
+ if (diagnosticExplanation) {
+ return diagnosticExplanation
+ }
+
if (context.transaction_visibility_unavailable) {
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
}
@@ -60,15 +65,38 @@ export default function FreshnessTrustNote({
scopeLabel?: string
className?: string
}) {
+ const { mode } = useUiMode()
const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus)
const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus)
+ const diagnosticExplanation = stats?.diagnostics?.explanation || bridgeStatus?.data?.diagnostics?.explanation || null
const normalizedClassName = className ? ` ${className}` : ''
+ if (mode === 'expert') {
+ return (
+
+
+
{buildSummary(context)}
+
{sourceLabel}
+
+
+ {confidenceBadges.map((badge) => (
+
+ {badge}
+
+ ))}
+
+
+ )
+ }
+
return (
{buildSummary(context)}
- {buildDetail(context)} {scopeLabel ? `${scopeLabel}. ` : ''}{sourceLabel}
+ {buildDetail(context, diagnosticExplanation)} {scopeLabel ? `${scopeLabel}. ` : ''}{sourceLabel}
{confidenceBadges.map((badge) => (
diff --git a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx
index 387ff75..f4efe58 100644
--- a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx
+++ b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx
@@ -133,6 +133,7 @@ export default function AnalyticsOperationsPage({
latestBlockNumber: stats?.latest_block ?? blocks[0]?.number ?? null,
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
+ diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
}),
[blocks, bridgeStatus, stats, transactions],
)
diff --git a/frontend/src/components/explorer/BridgeMonitoringPage.tsx b/frontend/src/components/explorer/BridgeMonitoringPage.tsx
index 4aff7e1..79dc35c 100644
--- a/frontend/src/components/explorer/BridgeMonitoringPage.tsx
+++ b/frontend/src/components/explorer/BridgeMonitoringPage.tsx
@@ -9,7 +9,12 @@ import {
type MissionControlRelayPayload,
type MissionControlRelaySnapshot,
} from '@/services/api/missionControl'
+import { statsApi, type ExplorerStats } from '@/services/api/stats'
import { explorerFeaturePages } from '@/data/explorerOperations'
+import { summarizeChainActivity } from '@/utils/activityContext'
+import ActivityContextPanel from '@/components/common/ActivityContextPanel'
+import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
+import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
type FeedState = 'connecting' | 'live' | 'fallback'
@@ -61,6 +66,9 @@ function relayPolicyCue(snapshot: MissionControlRelaySnapshot | null): string |
if (snapshot.last_error?.scope === 'bridge_inventory') {
return 'Queued release waiting on bridge inventory'
}
+ if (snapshot.last_error?.scope === 'bridge_inventory_probe') {
+ return 'Bridge inventory check is temporarily unavailable'
+ }
if (String(snapshot.status || '').toLowerCase() === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
return 'Delivery disabled by policy'
}
@@ -130,10 +138,13 @@ function ActionLink({
export default function BridgeMonitoringPage({
initialBridgeStatus = null,
+ initialStats = null,
}: {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
+ initialStats?: ExplorerStats | null
}) {
const [bridgeStatus, setBridgeStatus] = useState
(initialBridgeStatus)
+ const [stats, setStats] = useState(initialStats)
const [feedState, setFeedState] = useState(initialBridgeStatus ? 'fallback' : 'connecting')
const page = explorerFeaturePages.bridge
@@ -142,9 +153,15 @@ export default function BridgeMonitoringPage({
const loadSnapshot = async () => {
try {
- const snapshot = await missionControlApi.getBridgeStatus()
+ const [snapshot, latestStats] = await Promise.all([
+ missionControlApi.getBridgeStatus(),
+ statsApi.get().catch(() => null),
+ ])
if (!cancelled) {
setBridgeStatus(snapshot)
+ if (latestStats) {
+ setStats(latestStats)
+ }
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
@@ -178,6 +195,19 @@ export default function BridgeMonitoringPage({
}
}, [])
+ const activityContext = useMemo(
+ () =>
+ summarizeChainActivity({
+ blocks: [],
+ transactions: [],
+ latestBlockNumber: stats?.latest_block,
+ latestBlockTimestamp: null,
+ freshness: resolveEffectiveFreshness(stats, bridgeStatus),
+ diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
+ }),
+ [bridgeStatus, stats],
+ )
+
const relayLanes = useMemo((): RelayLaneCard[] => {
const relays = getMissionControlRelays(bridgeStatus)
if (!relays) return []
@@ -191,7 +221,12 @@ export default function BridgeMonitoringPage({
return {
key,
label: getMissionControlRelayLabel(key),
- status: snapshot?.last_error?.scope === 'bridge_inventory' ? 'underfunded' : status,
+ status:
+ snapshot?.last_error?.scope === 'bridge_inventory'
+ ? 'underfunded'
+ : snapshot?.last_error?.scope === 'bridge_inventory_probe'
+ ? 'warning'
+ : status,
profile: snapshot?.service?.profile || key,
sourceChain: snapshot?.source?.chain_name || 'Unknown',
destinationChain: snapshot?.destination?.chain_name || 'Unknown',
@@ -244,6 +279,17 @@ export default function BridgeMonitoringPage({
) : null}
+
+
diff --git a/frontend/src/components/explorer/LiquidityOperationsPage.tsx b/frontend/src/components/explorer/LiquidityOperationsPage.tsx
index 609dfcb..a45916f 100644
--- a/frontend/src/components/explorer/LiquidityOperationsPage.tsx
+++ b/frontend/src/components/explorer/LiquidityOperationsPage.tsx
@@ -15,6 +15,12 @@ import {
} from '@/services/api/liquidity'
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
+import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
+import { statsApi, type ExplorerStats } from '@/services/api/stats'
+import { summarizeChainActivity } from '@/utils/activityContext'
+import ActivityContextPanel from '@/components/common/ActivityContextPanel'
+import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
+import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import {
formatCurrency,
formatNumber,
@@ -43,6 +49,8 @@ interface LiquidityOperationsPageProps {
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
initialInternalPlan?: InternalExecutionPlanResponse | null
initialTokenPoolRecords?: TokenPoolRecord[]
+ initialStats?: ExplorerStats | null
+ initialBridgeStatus?: MissionControlBridgeStatusResponse | null
}
function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string {
@@ -55,12 +63,16 @@ export default function LiquidityOperationsPage({
initialPlannerCapabilities = null,
initialInternalPlan = null,
initialTokenPoolRecords = [],
+ initialStats = null,
+ initialBridgeStatus = null,
}: LiquidityOperationsPageProps) {
const [tokenList, setTokenList] = useState
(initialTokenList)
const [routeMatrix, setRouteMatrix] = useState(initialRouteMatrix)
const [plannerCapabilities, setPlannerCapabilities] = useState(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState(initialInternalPlan)
const [tokenPoolRecords, setTokenPoolRecords] = useState(initialTokenPoolRecords)
+ const [stats, setStats] = useState(initialStats)
+ const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus)
const [loadingError, setLoadingError] = useState(null)
const [copiedEndpoint, setCopiedEndpoint] = useState(null)
@@ -72,7 +84,9 @@ export default function LiquidityOperationsPage({
initialRouteMatrix &&
initialPlannerCapabilities &&
initialInternalPlan &&
- initialTokenPoolRecords.length > 0
+ initialTokenPoolRecords.length > 0 &&
+ initialStats &&
+ initialBridgeStatus
) {
return () => {
cancelled = true
@@ -80,12 +94,14 @@ export default function LiquidityOperationsPage({
}
const load = async () => {
- const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] =
+ const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] =
await Promise.allSettled([
configApi.getTokenList(),
routesApi.getRouteMatrix(),
plannerApi.getCapabilities(),
plannerApi.getInternalExecutionPlan(),
+ statsApi.get(),
+ missionControlApi.getBridgeStatus(),
])
if (cancelled) return
@@ -94,6 +110,8 @@ export default function LiquidityOperationsPage({
if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value)
if (plannerCapabilitiesResult.status === 'fulfilled') setPlannerCapabilities(plannerCapabilitiesResult.value)
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
+ if (statsResult.status === 'fulfilled') setStats(statsResult.value)
+ if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
if (tokenListResult.status === 'fulfilled') {
const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || [])
@@ -113,14 +131,10 @@ export default function LiquidityOperationsPage({
}
}
- const failedCount = [
- tokenListResult,
- routeMatrixResult,
- plannerCapabilitiesResult,
- planResult,
- ].filter((result) => result.status === 'rejected').length
+ const results = [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] as const
+ const failedCount = results.filter((result) => result.status === 'rejected').length
- if (failedCount === 4) {
+ if (failedCount === results.length) {
setLoadingError('Live liquidity data is temporarily unavailable from the public explorer APIs.')
}
}
@@ -137,9 +151,11 @@ export default function LiquidityOperationsPage({
cancelled = true
}
}, [
+ initialBridgeStatus,
initialInternalPlan,
initialPlannerCapabilities,
initialRouteMatrix,
+ initialStats,
initialTokenList,
initialTokenPoolRecords,
])
@@ -168,6 +184,18 @@ export default function LiquidityOperationsPage({
() => new Set(aggregatedPools.map((pool) => pool.dex).filter(Boolean)).size,
[aggregatedPools]
)
+ const activityContext = useMemo(
+ () =>
+ summarizeChainActivity({
+ blocks: [],
+ transactions: [],
+ latestBlockNumber: stats?.latest_block,
+ latestBlockTimestamp: null,
+ freshness: resolveEffectiveFreshness(stats, bridgeStatus),
+ diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
+ }),
+ [bridgeStatus, stats],
+ )
const insightLines = useMemo(
() => [
@@ -240,6 +268,17 @@ export default function LiquidityOperationsPage({
) : null}
+
+
diff --git a/frontend/src/components/explorer/OperationsHubPage.tsx b/frontend/src/components/explorer/OperationsHubPage.tsx
index 6d83655..3fff1fe 100644
--- a/frontend/src/components/explorer/OperationsHubPage.tsx
+++ b/frontend/src/components/explorer/OperationsHubPage.tsx
@@ -10,6 +10,7 @@ import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
+import { statsApi, type ExplorerStats } from '@/services/api/stats'
function relativeAge(isoString?: string): string {
if (!isoString) return 'Unknown'
@@ -56,6 +57,7 @@ interface OperationsHubPageProps {
initialNetworksConfig?: NetworksConfigResponse | null
initialTokenList?: TokenListResponse | null
initialCapabilities?: CapabilitiesResponse | null
+ initialStats?: ExplorerStats | null
}
export default function OperationsHubPage({
@@ -64,6 +66,7 @@ export default function OperationsHubPage({
initialNetworksConfig = null,
initialTokenList = null,
initialCapabilities = null,
+ initialStats = null,
}: OperationsHubPageProps) {
const { mode } = useUiMode()
const [bridgeStatus, setBridgeStatus] = useState
(initialBridgeStatus)
@@ -71,6 +74,7 @@ export default function OperationsHubPage({
const [networksConfig, setNetworksConfig] = useState(initialNetworksConfig)
const [tokenList, setTokenList] = useState(initialTokenList)
const [capabilities, setCapabilities] = useState(initialCapabilities)
+ const [stats, setStats] = useState(initialStats)
const [loadingError, setLoadingError] = useState(null)
const page = explorerFeaturePages.operations
@@ -78,13 +82,14 @@ export default function OperationsHubPage({
let cancelled = false
const load = async () => {
- const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult] =
+ const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult, statsResult] =
await Promise.allSettled([
missionControlApi.getBridgeStatus(),
routesApi.getRouteMatrix(),
configApi.getNetworks(),
configApi.getTokenList(),
configApi.getCapabilities(),
+ statsApi.get(),
])
if (cancelled) return
@@ -94,6 +99,7 @@ export default function OperationsHubPage({
if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value)
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value)
+ if (statsResult.status === 'fulfilled') setStats(statsResult.value)
const failedCount = [
bridgeResult,
@@ -101,9 +107,10 @@ export default function OperationsHubPage({
networksResult,
tokenListResult,
capabilitiesResult,
+ statsResult,
].filter((result) => result.status === 'rejected').length
- if (failedCount === 5) {
+ if (failedCount === 6) {
setLoadingError('Public explorer operations data is temporarily unavailable.')
}
}
@@ -153,9 +160,10 @@ export default function OperationsHubPage({
? Number(bridgeStatus.data.chains['138'].block_number)
: null,
latestBlockTimestamp: null,
- freshness: resolveEffectiveFreshness(null, bridgeStatus),
+ freshness: resolveEffectiveFreshness(stats, bridgeStatus),
+ diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
}),
- [bridgeStatus],
+ [bridgeStatus, stats],
)
return (
@@ -191,6 +199,7 @@ export default function OperationsHubPage({
diff --git a/frontend/src/components/explorer/RoutesMonitoringPage.tsx b/frontend/src/components/explorer/RoutesMonitoringPage.tsx
index b502724..9b1f3f8 100644
--- a/frontend/src/components/explorer/RoutesMonitoringPage.tsx
+++ b/frontend/src/components/explorer/RoutesMonitoringPage.tsx
@@ -9,11 +9,19 @@ import {
type RouteMatrixRoute,
type RouteMatrixResponse,
} from '@/services/api/routes'
+import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
+import { statsApi, type ExplorerStats } from '@/services/api/stats'
+import { summarizeChainActivity } from '@/utils/activityContext'
+import ActivityContextPanel from '@/components/common/ActivityContextPanel'
+import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
+import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
interface RoutesMonitoringPageProps {
initialRouteMatrix?: RouteMatrixResponse | null
initialNetworks?: ExplorerNetwork[]
initialPools?: MissionControlLiquidityPool[]
+ initialStats?: ExplorerStats | null
+ initialBridgeStatus?: MissionControlBridgeStatusResponse | null
}
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
@@ -90,10 +98,14 @@ export default function RoutesMonitoringPage({
initialRouteMatrix = null,
initialNetworks = [],
initialPools = [],
+ initialStats = null,
+ initialBridgeStatus = null,
}: RoutesMonitoringPageProps) {
const [routeMatrix, setRouteMatrix] = useState(initialRouteMatrix)
const [networks, setNetworks] = useState(initialNetworks)
const [pools, setPools] = useState(initialPools)
+ const [stats, setStats] = useState(initialStats)
+ const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus)
const [loadingError, setLoadingError] = useState(null)
const page = explorerFeaturePages.routes
@@ -101,10 +113,12 @@ export default function RoutesMonitoringPage({
let cancelled = false
const load = async () => {
- const [matrixResult, networksResult, poolsResult] = await Promise.allSettled([
+ const [matrixResult, networksResult, poolsResult, statsResult, bridgeResult] = await Promise.allSettled([
routesApi.getRouteMatrix(),
routesApi.getNetworks(),
routesApi.getTokenPools(canonicalLiquidityToken),
+ statsApi.get(),
+ missionControlApi.getBridgeStatus(),
])
if (cancelled) return
@@ -118,11 +132,19 @@ export default function RoutesMonitoringPage({
if (poolsResult.status === 'fulfilled') {
setPools(poolsResult.value.pools || [])
}
+ if (statsResult.status === 'fulfilled') {
+ setStats(statsResult.value)
+ }
+ if (bridgeResult.status === 'fulfilled') {
+ setBridgeStatus(bridgeResult.value)
+ }
if (
matrixResult.status === 'rejected' &&
networksResult.status === 'rejected' &&
- poolsResult.status === 'rejected'
+ poolsResult.status === 'rejected' &&
+ statsResult.status === 'rejected' &&
+ bridgeResult.status === 'rejected'
) {
setLoadingError('Live route inventory is temporarily unavailable.')
}
@@ -166,6 +188,18 @@ export default function RoutesMonitoringPage({
.filter((network) => [138, 1, 651940, 56, 43114].includes(network.chainIdDecimal || 0))
.sort((left, right) => (left.chainIdDecimal || 0) - (right.chainIdDecimal || 0))
}, [networks])
+ const activityContext = useMemo(
+ () =>
+ summarizeChainActivity({
+ blocks: [],
+ transactions: [],
+ latestBlockNumber: stats?.latest_block,
+ latestBlockTimestamp: null,
+ freshness: resolveEffectiveFreshness(stats, bridgeStatus),
+ diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
+ }),
+ [bridgeStatus, stats],
+ )
return (
@@ -195,6 +229,17 @@ export default function RoutesMonitoringPage({
) : null}
+
+
diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx
index bb67f1a..7af68f2 100644
--- a/frontend/src/components/home/HomePage.tsx
+++ b/frontend/src/components/home/HomePage.tsx
@@ -88,6 +88,10 @@ function formatGasPriceGwei(value: number) {
return `${value.toFixed(3)} gwei`
}
+function compactStatNote(guided: string, expert: string, mode: 'guided' | 'expert') {
+ return mode === 'guided' ? guided : expert
+}
+
export default function Home({
initialStats = null,
initialRecentBlocks = [],
@@ -311,6 +315,7 @@ export default function Home({
latestBlockNumber: latestBlock,
latestBlockTimestamp: recentBlocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
+ diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
})
const txCompleteness = stats?.completeness?.transactions_feed || bridgeStatus?.data?.subsystems?.tx_index?.completeness || null
const blockCompleteness = stats?.completeness?.blocks_feed || null
@@ -358,6 +363,56 @@ export default function Home({
const missionCollapsedSummary = relaySummary
? `${missionHeadline} · ${relayOperationalCount} operational`
: `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}`
+ const primaryMetricCards = [
+ {
+ label: 'Latest Block',
+ value: latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable',
+ note: activityContext.latest_block_timestamp
+ ? compactStatNote(
+ `Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`,
+ formatRelativeAge(activityContext.latest_block_timestamp),
+ mode,
+ )
+ : compactStatNote('Head freshness unavailable.', 'Unavailable', mode),
+ },
+ {
+ label: 'Total Blocks',
+ value: stats ? stats.total_blocks.toLocaleString() : 'Unavailable',
+ note: compactStatNote('Visible public explorer block count.', 'Explorer block count', mode),
+ },
+ {
+ label: 'Total Transactions',
+ value: stats ? stats.total_transactions.toLocaleString() : 'Unavailable',
+ note: compactStatNote('Visible indexed explorer transaction count.', 'Indexed tx count', mode),
+ },
+ {
+ label: 'Total Addresses',
+ value: stats ? stats.total_addresses.toLocaleString() : 'Unavailable',
+ note: compactStatNote('Current public explorer address count.', 'Address count', mode),
+ },
+ ]
+ const secondaryMetricCards = [
+ {
+ label: 'Avg Block Time',
+ value: avgBlockTimeSummary.value,
+ note: compactStatNote(avgBlockTimeSummary.note, averageBlockTimeSeconds != null ? 'Reported' : 'Unavailable', mode),
+ },
+ {
+ label: 'Avg Gas Price',
+ value: avgGasPriceSummary.value,
+ note: compactStatNote(avgGasPriceSummary.note, averageGasPriceGwei != null ? 'Reported' : 'Unavailable', mode),
+ },
+ {
+ label: 'Transactions Today',
+ value: transactionsTodaySummary.value,
+ note: compactStatNote(transactionsTodaySummary.note, transactionsToday != null ? 'Reported' : 'Unavailable', mode),
+ },
+ {
+ label: 'Network Utilization',
+ value: networkUtilizationSummary.value,
+ note: compactStatNote(networkUtilizationSummary.note, networkUtilization != null ? 'Latest stats sample' : 'Unavailable', mode),
+ },
+ ]
useEffect(() => {
setRelayPage(1)
@@ -617,64 +672,63 @@ export default function Home({
)}
{stats && (
-
-
- Latest Block
-
- {latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
+
+
+ {primaryMetricCards.map((card) => (
+
+ {card.label}
+ {card.value}
+ {card.note}
+
+ ))}
+
+
+ {mode === 'guided' ? (
+
+ {secondaryMetricCards.map((card) => (
+
+ {card.label}
+ {card.value}
+ {card.note}
+
+ ))}
-
- {activityContext.latest_block_timestamp
- ? `Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`
- : 'Head freshness unavailable.'}
-
-
-
- Total Blocks
- {stats.total_blocks.toLocaleString()}
- Visible public explorer block count.
-
-
- Total Transactions
- {stats.total_transactions.toLocaleString()}
- Latest visible tx {latestTransactionAgeLabel}.
-
-
- Total Addresses
- {stats.total_addresses.toLocaleString()}
- Current public explorer address count.
-
-
- Avg Block Time
- {avgBlockTimeSummary.value}
- {avgBlockTimeSummary.note}
-
-
- Avg Gas Price
- {avgGasPriceSummary.value}
- {avgGasPriceSummary.note}
-
-
- Transactions Today
- {transactionsTodaySummary.value}
- {transactionsTodaySummary.note}
-
-
- Network Utilization
- {networkUtilizationSummary.value}
- {networkUtilizationSummary.note}
-
+ ) : (
+
+
+
+
Telemetry Snapshot
+
+ Secondary public stats in a denser expert layout.
+
+
+
+ {secondaryMetricCards.map((card) => (
+
+
{card.label}
+
{card.value}
+
{card.note}
+
+ ))}
+
+
+
+ )}
)}
diff --git a/frontend/src/components/wallet/WalletPage.tsx b/frontend/src/components/wallet/WalletPage.tsx
index 8cc6c7f..562201e 100644
--- a/frontend/src/components/wallet/WalletPage.tsx
+++ b/frontend/src/components/wallet/WalletPage.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useState } from 'react'
import type {
CapabilitiesCatalog,
FetchMetadata,
@@ -7,6 +8,15 @@ import type {
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
import Link from 'next/link'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
+import { accessApi, type WalletAccessSession } from '@/services/api/access'
+import EntityBadge from '@/components/common/EntityBadge'
+import { addressesApi, type AddressInfo, type TransactionSummary } from '@/services/api/addresses'
+import {
+ isWatchlistEntry,
+ readWatchlistFromStorage,
+ toggleWatchlistEntry,
+ writeWatchlistToStorage,
+} from '@/utils/watchlist'
interface WalletPageProps {
initialNetworks?: NetworksCatalog | null
@@ -17,8 +27,111 @@ interface WalletPageProps {
initialCapabilitiesMeta?: FetchMetadata | null
}
+function shortAddress(value?: string | null): string {
+ if (!value) return 'Unknown'
+ if (value.length <= 14) return value
+ return `${value.slice(0, 6)}...${value.slice(-4)}`
+}
+
export default function WalletPage(props: WalletPageProps) {
const { mode } = useUiMode()
+ const [walletSession, setWalletSession] = useState
(null)
+ const [connectingWallet, setConnectingWallet] = useState(false)
+ const [walletError, setWalletError] = useState(null)
+ const [copiedAddress, setCopiedAddress] = useState(false)
+ const [watchlistEntries, setWatchlistEntries] = useState([])
+ const [addressInfo, setAddressInfo] = useState(null)
+ const [recentAddressTransactions, setRecentAddressTransactions] = useState([])
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+
+ const syncSession = () => {
+ setWalletSession(accessApi.getStoredWalletSession())
+ }
+
+ const syncWatchlist = () => {
+ setWatchlistEntries(readWatchlistFromStorage(window.localStorage))
+ }
+
+ syncSession()
+ syncWatchlist()
+ window.addEventListener('explorer-access-session-changed', syncSession)
+ window.addEventListener('storage', syncWatchlist)
+ return () => {
+ window.removeEventListener('explorer-access-session-changed', syncSession)
+ window.removeEventListener('storage', syncWatchlist)
+ }
+ }, [])
+
+ const handleConnectWallet = async () => {
+ setConnectingWallet(true)
+ setWalletError(null)
+ try {
+ const session = await accessApi.connectWalletSession()
+ setWalletSession(session)
+ } catch (error) {
+ setWalletError(error instanceof Error ? error.message : 'Wallet connection failed.')
+ } finally {
+ setConnectingWallet(false)
+ }
+ }
+
+ const handleDisconnectWallet = () => {
+ accessApi.clearSession()
+ accessApi.clearWalletSession()
+ setWalletSession(null)
+ }
+
+ const handleCopyAddress = async () => {
+ if (!walletSession?.address || typeof navigator === 'undefined' || !navigator.clipboard) return
+ await navigator.clipboard.writeText(walletSession.address)
+ setCopiedAddress(true)
+ window.setTimeout(() => setCopiedAddress(false), 1500)
+ }
+
+ const handleToggleWatchlist = () => {
+ if (!walletSession?.address || typeof window === 'undefined') return
+ const nextEntries = toggleWatchlistEntry(watchlistEntries, walletSession.address)
+ writeWatchlistToStorage(window.localStorage, nextEntries)
+ setWatchlistEntries(nextEntries)
+ }
+
+ const isSavedToWatchlist = walletSession?.address
+ ? isWatchlistEntry(watchlistEntries, walletSession.address)
+ : false
+
+ useEffect(() => {
+ let cancelled = false
+
+ if (!walletSession?.address) {
+ setAddressInfo(null)
+ setRecentAddressTransactions([])
+ return () => {
+ cancelled = true
+ }
+ }
+
+ Promise.all([
+ addressesApi.getSafe(138, walletSession.address),
+ addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3),
+ ])
+ .then(([infoResponse, transactionsResponse]) => {
+ if (cancelled) return
+ setAddressInfo(infoResponse.ok ? infoResponse.data : null)
+ setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
+ })
+ .catch(() => {
+ if (cancelled) return
+ setAddressInfo(null)
+ setRecentAddressTransactions([])
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [walletSession?.address])
+
return (
Wallet Tools
@@ -27,6 +140,189 @@ export default function WalletPage(props: WalletPageProps) {
? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.'
: 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'}
+
+
+
+
Wallet session
+
+ {walletSession
+ ? mode === 'guided'
+ ? 'This wallet is connected to the same account/access session used by the header. You can jump straight into your explorer address view or the access console from here.'
+ : 'Connected wallet session is active for explorer and access surfaces.'
+ : mode === 'guided'
+ ? 'Connect a browser wallet to make this page useful beyond setup: copy your address, open your on-explorer address page, and continue into the access console with the same session.'
+ : 'Connect a wallet to activate account-linked explorer actions.'}
+
+
+
+
+ {walletSession ? : null}
+
+
+
+
+
+
Current wallet
+
+ {walletSession ? shortAddress(walletSession.address) : 'No wallet connected'}
+
+
+ {walletSession?.address || 'Use Connect Wallet to start a browser-wallet session.'}
+
+ {walletSession?.expiresAt ? (
+
+ Session expires {new Date(walletSession.expiresAt).toLocaleString()}
+
+ ) : null}
+
+
+
+
Quick actions
+
+ {walletSession ? (
+ <>
+
+ {copiedAddress ? 'Address copied' : 'Copy address'}
+
+
+ Open address
+
+
+ Open access console
+
+
+ {isSavedToWatchlist ? 'Remove from watchlist' : 'Save to watchlist'}
+
+
+ Open watchlist
+
+
+ Disconnect wallet
+
+ >
+ ) : (
+ <>
+
+ {connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
+
+
+ Open access console
+
+ >
+ )}
+
+ {walletError ? (
+
{walletError}
+ ) : null}
+ {walletSession ? (
+
+ {isSavedToWatchlist
+ ? 'This wallet is already saved in the shared explorer watchlist.'
+ : 'Save this wallet into the shared explorer watchlist to revisit it from addresses and transaction workflows.'}
+
+ ) : null}
+
+
+
+ {walletSession ? (
+
+
+
+
+ Connected Address Snapshot
+
+
+ {mode === 'guided'
+ ? 'A quick explorer view of the connected wallet so you can jump from connection into browsing and monitoring.'
+ : 'Current explorer snapshot for the connected wallet.'}
+
+
+
+ Open full address page →
+
+
+
+
+
+
Transactions
+
+ {addressInfo ? addressInfo.transaction_count.toLocaleString() : 'Unknown'}
+
+
+
+
Token Holdings
+
+ {addressInfo ? addressInfo.token_count.toLocaleString() : 'Unknown'}
+
+
+
+
Address Type
+
+ {addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : 'Unknown'}
+
+
+
+
Recent Indexed Tx
+
+ {recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : 'None visible'}
+
+
+
+
+
+ {recentAddressTransactions.length === 0 ? (
+
+ No recent indexed transactions are currently visible for this connected wallet.
+
+ ) : (
+ recentAddressTransactions.map((transaction) => (
+
+
+ {transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
+
+
+ Block #{transaction.block_number.toLocaleString()}
+
+
+ ))
+ )}
+
+
+ ) : null}
+
diff --git a/frontend/src/pages/addresses/index.tsx b/frontend/src/pages/addresses/index.tsx
index a8cf480..d78c95c 100644
--- a/frontend/src/pages/addresses/index.tsx
+++ b/frontend/src/pages/addresses/index.tsx
@@ -69,6 +69,7 @@ export default function AddressesPage({
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
+ diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
}),
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions],
)
diff --git a/frontend/src/pages/analytics/index.tsx b/frontend/src/pages/analytics/index.tsx
index 179a041..b55f0ca 100644
--- a/frontend/src/pages/analytics/index.tsx
+++ b/frontend/src/pages/analytics/index.tsx
@@ -2,7 +2,6 @@ import type { GetServerSideProps } from 'next'
import AnalyticsOperationsPage from '@/components/explorer/AnalyticsOperationsPage'
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
import {
- normalizeExplorerStats,
normalizeTransactionTrend,
summarizeRecentTransactions,
type ExplorerRecentActivitySnapshot,
@@ -13,6 +12,7 @@ import type { Block } from '@/services/api/blocks'
import type { Transaction } from '@/services/api/transactions'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { fetchPublicJson } from '@/utils/publicExplorer'
+import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
interface AnalyticsPageProps {
initialStats: ExplorerStats | null
@@ -63,18 +63,17 @@ export default function AnalyticsPage(props: AnalyticsPageProps) {
export const getServerSideProps: GetServerSideProps = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
- const [statsResult, trendResult, activityResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
- fetchPublicJson('/api/v2/stats'),
+ const [truthContextResult, trendResult, activityResult, blocksResult, transactionsResult] = await Promise.allSettled([
+ fetchExplorerTruthContext(),
fetchPublicJson('/api/v2/stats/charts/transactions'),
fetchPublicJson('/api/v2/main-page/transactions'),
fetchPublicJson('/api/v2/blocks?page=1&page_size=5'),
fetchPublicJson('/api/v2/transactions?page=1&page_size=5'),
- fetchPublicJson('/explorer-api/v1/track1/bridge/status'),
])
return {
props: {
- initialStats: statsResult.status === 'fulfilled' ? normalizeExplorerStats(statsResult.value as never) : null,
+ initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null,
initialTransactionTrend:
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value as never) : [],
initialActivitySnapshot:
@@ -96,7 +95,7 @@ export const getServerSideProps: GetServerSideProps = async
)
: [],
initialBridgeStatus:
- bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null,
+ truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null,
},
}
}
diff --git a/frontend/src/pages/blocks/index.tsx b/frontend/src/pages/blocks/index.tsx
index 9937295..35b25ea 100644
--- a/frontend/src/pages/blocks/index.tsx
+++ b/frontend/src/pages/blocks/index.tsx
@@ -116,6 +116,7 @@ export default function BlocksPage({
latestBlockNumber: blocks[0]?.number ?? null,
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
+ diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
}),
[blocks, initialBridgeStatus, initialStats, recentTransactions],
)
diff --git a/frontend/src/pages/bridge/index.tsx b/frontend/src/pages/bridge/index.tsx
index 00c5fd3..8baf4a9 100644
--- a/frontend/src/pages/bridge/index.tsx
+++ b/frontend/src/pages/bridge/index.tsx
@@ -1,10 +1,12 @@
import type { GetStaticProps } from 'next'
import BridgeMonitoringPage from '@/components/explorer/BridgeMonitoringPage'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
-import { fetchPublicJson } from '@/utils/publicExplorer'
+import type { ExplorerStats } from '@/services/api/stats'
+import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
interface BridgePageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
+ initialStats: ExplorerStats | null
}
export default function BridgePage(props: BridgePageProps) {
@@ -12,13 +14,12 @@ export default function BridgePage(props: BridgePageProps) {
}
export const getStaticProps: GetStaticProps = async () => {
- const bridgeResult = await fetchPublicJson(
- '/explorer-api/v1/track1/bridge/status'
- ).catch(() => null)
+ const truthContext = await fetchExplorerTruthContext()
return {
props: {
- initialBridgeStatus: bridgeResult,
+ initialBridgeStatus: truthContext.initialBridgeStatus,
+ initialStats: truthContext.initialStats,
},
}
}
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 8c08dcd..dbe266f 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -2,7 +2,6 @@ import type { GetServerSideProps } from 'next'
import HomePage from '@/components/home/HomePage'
import { normalizeBlock } from '@/services/api/blockscout'
import {
- normalizeExplorerStats,
normalizeTransactionTrend,
summarizeRecentTransactions,
type ExplorerRecentActivitySnapshot,
@@ -17,6 +16,7 @@ import {
import type { Block } from '@/services/api/blocks'
import type { Transaction } from '@/services/api/transactions'
import { fetchPublicJson } from '@/utils/publicExplorer'
+import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
import { normalizeTransaction } from '@/services/api/blockscout'
interface IndexPageProps {
@@ -54,13 +54,8 @@ function serializeTransactions(transactions: Transaction[]): Transaction[] {
export const getServerSideProps: GetServerSideProps = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
- const [statsResult, blocksResult, transactionsResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([
- fetchPublicJson<{
- total_blocks?: number | string | null
- total_transactions?: number | string | null
- total_addresses?: number | string | null
- latest_block?: number | string | null
- }>('/api/v2/stats'),
+ const [truthContextResult, blocksResult, transactionsResult, trendResult, activityResult] = await Promise.allSettled([
+ fetchExplorerTruthContext(),
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'),
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'),
fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>(
@@ -74,12 +69,11 @@ export const getServerSideProps: GetServerSideProps = async () =
fee?: { value?: string | number | null } | string | null
}>
>('/api/v2/main-page/transactions'),
- fetchPublicJson('/explorer-api/v1/track1/bridge/status'),
])
return {
props: {
- initialStats: statsResult.status === 'fulfilled' ? normalizeExplorerStats(statsResult.value) : null,
+ initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null,
initialRecentBlocks:
blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)
? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId))
@@ -95,9 +89,11 @@ export const getServerSideProps: GetServerSideProps = async () =
initialActivitySnapshot:
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null,
initialBridgeStatus:
- bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null,
+ truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null,
initialRelaySummary:
- bridgeResult.status === 'fulfilled' ? summarizeMissionControlRelay(bridgeResult.value as never) : null,
+ truthContextResult.status === 'fulfilled' && truthContextResult.value.initialBridgeStatus
+ ? summarizeMissionControlRelay(truthContextResult.value.initialBridgeStatus as never)
+ : null,
},
}
}
diff --git a/frontend/src/pages/liquidity/index.tsx b/frontend/src/pages/liquidity/index.tsx
index db0ade0..2c41051 100644
--- a/frontend/src/pages/liquidity/index.tsx
+++ b/frontend/src/pages/liquidity/index.tsx
@@ -3,7 +3,10 @@ import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPa
import type { TokenListResponse } from '@/services/api/config'
import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from '@/services/api/planner'
import type { MissionControlLiquidityPool, RouteMatrixResponse } from '@/services/api/routes'
+import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
+import type { ExplorerStats } from '@/services/api/stats'
import { fetchPublicJson } from '@/utils/publicExplorer'
+import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
interface TokenPoolRecord {
symbol: string
@@ -16,6 +19,8 @@ interface LiquidityPageProps {
initialPlannerCapabilities: PlannerCapabilitiesResponse | null
initialInternalPlan: InternalExecutionPlanResponse | null
initialTokenPoolRecords: TokenPoolRecord[]
+ initialStats: ExplorerStats | null
+ initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
const featuredTokenSymbols = new Set(['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT'])
@@ -39,7 +44,7 @@ export default function LiquidityPage(props: LiquidityPageProps) {
}
export const getServerSideProps: GetServerSideProps = async () => {
- const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult] =
+ const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] =
await Promise.all([
fetchPublicJson('/api/config/token-list').catch(() => null),
fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
@@ -52,6 +57,7 @@ export const getServerSideProps: GetServerSideProps = async
tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
amountIn: '100000000000000000',
}).catch(() => null),
+ fetchExplorerTruthContext(),
])
const featuredTokens = (tokenListResult?.tokens || []).filter(
@@ -79,6 +85,8 @@ export const getServerSideProps: GetServerSideProps = async
initialPlannerCapabilities: plannerCapabilitiesResult,
initialInternalPlan: internalPlanResult,
initialTokenPoolRecords: tokenPoolsResults,
+ initialStats: truthContext.initialStats,
+ initialBridgeStatus: truthContext.initialBridgeStatus,
},
}
}
diff --git a/frontend/src/pages/operations/index.tsx b/frontend/src/pages/operations/index.tsx
index 3e69760..6ef20fc 100644
--- a/frontend/src/pages/operations/index.tsx
+++ b/frontend/src/pages/operations/index.tsx
@@ -3,7 +3,9 @@ import OperationsHubPage from '@/components/explorer/OperationsHubPage'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import type { RouteMatrixResponse } from '@/services/api/routes'
import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config'
+import type { ExplorerStats } from '@/services/api/stats'
import { fetchPublicJson } from '@/utils/publicExplorer'
+import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
interface OperationsPageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
@@ -11,6 +13,7 @@ interface OperationsPageProps {
initialNetworksConfig: NetworksConfigResponse | null
initialTokenList: TokenListResponse | null
initialCapabilities: CapabilitiesResponse | null
+ initialStats: ExplorerStats | null
}
export default function OperationsPage(props: OperationsPageProps) {
@@ -18,21 +21,22 @@ export default function OperationsPage(props: OperationsPageProps) {
}
export const getStaticProps: GetStaticProps = async () => {
- const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
- fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null),
+ const [routesResult, networksResult, tokenListResult, capabilitiesResult, truthContext] = await Promise.all([
fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson('/api/config/networks').catch(() => null),
fetchPublicJson('/api/config/token-list').catch(() => null),
fetchPublicJson('/api/config/capabilities').catch(() => null),
+ fetchExplorerTruthContext(),
])
return {
props: {
- initialBridgeStatus: bridgeResult,
+ initialBridgeStatus: truthContext.initialBridgeStatus,
initialRouteMatrix: routesResult,
initialNetworksConfig: networksResult,
initialTokenList: tokenListResult,
initialCapabilities: capabilitiesResult,
+ initialStats: truthContext.initialStats,
},
}
}
diff --git a/frontend/src/pages/routes/index.tsx b/frontend/src/pages/routes/index.tsx
index 82696c5..ecfdb88 100644
--- a/frontend/src/pages/routes/index.tsx
+++ b/frontend/src/pages/routes/index.tsx
@@ -1,6 +1,9 @@
import type { GetStaticProps } from 'next'
import RoutesMonitoringPage from '@/components/explorer/RoutesMonitoringPage'
+import type { ExplorerStats } from '@/services/api/stats'
+import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { fetchPublicJson } from '@/utils/publicExplorer'
+import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
import type {
ExplorerNetwork,
MissionControlLiquidityPool,
@@ -11,6 +14,8 @@ interface RoutesPageProps {
initialRouteMatrix: RouteMatrixResponse | null
initialNetworks: ExplorerNetwork[]
initialPools: MissionControlLiquidityPool[]
+ initialStats: ExplorerStats | null
+ initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
export default function RoutesPage(props: RoutesPageProps) {
@@ -19,12 +24,13 @@ export default function RoutesPage(props: RoutesPageProps) {
export const getStaticProps: GetStaticProps = async () => {
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
- const [matrixResult, networksResult, poolsResult] = await Promise.all([
+ const [matrixResult, networksResult, poolsResult, truthContext] = await Promise.all([
fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson<{ networks?: ExplorerNetwork[] }>('/token-aggregation/api/v1/networks').catch(() => null),
fetchPublicJson<{ pools?: MissionControlLiquidityPool[] }>(
`/token-aggregation/api/v1/tokens/${canonicalLiquidityToken}/pools`,
).catch(() => null),
+ fetchExplorerTruthContext(),
])
return {
@@ -32,6 +38,8 @@ export const getStaticProps: GetStaticProps = async () => {
initialRouteMatrix: matrixResult,
initialNetworks: networksResult?.networks || [],
initialPools: poolsResult?.pools || [],
+ initialStats: truthContext.initialStats,
+ initialBridgeStatus: truthContext.initialBridgeStatus,
},
revalidate: 60,
}
diff --git a/frontend/src/pages/transactions/index.tsx b/frontend/src/pages/transactions/index.tsx
index 63714d5..54bdc35 100644
--- a/frontend/src/pages/transactions/index.tsx
+++ b/frontend/src/pages/transactions/index.tsx
@@ -70,6 +70,7 @@ export default function TransactionsPage({
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
+ diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
}),
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, transactions],
)
diff --git a/frontend/src/pages/watchlist/index.tsx b/frontend/src/pages/watchlist/index.tsx
index 1d8a35f..ec97d2a 100644
--- a/frontend/src/pages/watchlist/index.tsx
+++ b/frontend/src/pages/watchlist/index.tsx
@@ -1,30 +1,134 @@
'use client'
import Link from 'next/link'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { Card, Address } from '@/libs/frontend-ui-primitives'
-import {
- readWatchlistFromStorage,
- writeWatchlistToStorage,
- sanitizeWatchlistEntries,
-} from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
+import { Explain, useUiMode } from '@/components/common/UiModeContext'
+import { accessApi, type WalletAccessSession } from '@/services/api/access'
+import { addressesApi, type AddressInfo, type TransactionSummary } from '@/services/api/addresses'
+import {
+ isWatchlistEntry,
+ normalizeWatchlistAddress,
+ readWatchlistFromStorage,
+ sanitizeWatchlistEntries,
+ toggleWatchlistEntry,
+ writeWatchlistToStorage,
+} from '@/utils/watchlist'
+
+type TrackedAddressSnapshot = {
+ address: string
+ info: AddressInfo | null
+ recentTransaction: TransactionSummary | null
+}
+
+function shortAddress(value?: string | null): string {
+ if (!value) return 'Unknown'
+ if (value.length <= 14) return value
+ return `${value.slice(0, 6)}...${value.slice(-4)}`
+}
export default function WatchlistPage() {
+ const { mode } = useUiMode()
const [entries, setEntries] = useState([])
+ const [walletSession, setWalletSession] = useState(null)
+ const [snapshots, setSnapshots] = useState>({})
+ const [loadingSnapshots, setLoadingSnapshots] = useState(false)
useEffect(() => {
- if (typeof window === 'undefined') {
- return
+ if (typeof window === 'undefined') return
+
+ const syncSession = () => setWalletSession(accessApi.getStoredWalletSession())
+ const syncWatchlist = () => {
+ try {
+ setEntries(readWatchlistFromStorage(window.localStorage))
+ } catch {
+ setEntries([])
+ }
}
- try {
- setEntries(readWatchlistFromStorage(window.localStorage))
- } catch {
- setEntries([])
+ syncSession()
+ syncWatchlist()
+ window.addEventListener('explorer-access-session-changed', syncSession)
+ window.addEventListener('storage', syncWatchlist)
+ return () => {
+ window.removeEventListener('explorer-access-session-changed', syncSession)
+ window.removeEventListener('storage', syncWatchlist)
}
}, [])
+ useEffect(() => {
+ let cancelled = false
+
+ if (entries.length === 0) {
+ setSnapshots({})
+ setLoadingSnapshots(false)
+ return () => {
+ cancelled = true
+ }
+ }
+
+ setLoadingSnapshots(true)
+ Promise.all(
+ entries.map(async (address) => {
+ const [infoResponse, transactionsResponse] = await Promise.all([
+ addressesApi.getSafe(138, address),
+ addressesApi.getTransactionsSafe(138, address, 1, 1),
+ ])
+
+ return {
+ address,
+ info: infoResponse.ok ? infoResponse.data : null,
+ recentTransaction: transactionsResponse.ok ? transactionsResponse.data[0] ?? null : null,
+ } satisfies TrackedAddressSnapshot
+ }),
+ )
+ .then((results) => {
+ if (cancelled) return
+ const next: Record = {}
+ for (const result of results) {
+ next[result.address.toLowerCase()] = result
+ }
+ setSnapshots(next)
+ })
+ .catch(() => {
+ if (cancelled) return
+ setSnapshots({})
+ })
+ .finally(() => {
+ if (cancelled) return
+ setLoadingSnapshots(false)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [entries])
+
+ const connectedWalletEntry = useMemo(
+ () => normalizeWatchlistAddress(walletSession?.address || ''),
+ [walletSession?.address],
+ )
+ const connectedWalletTracked = connectedWalletEntry
+ ? isWatchlistEntry(entries, connectedWalletEntry)
+ : false
+
+ const trackedSummaries = useMemo(() => {
+ const values = entries
+ .map((entry) => snapshots[entry.toLowerCase()])
+ .filter((value): value is TrackedAddressSnapshot => Boolean(value))
+
+ return {
+ contracts: values.filter((value) => value.info?.is_contract).length,
+ eoas: values.filter((value) => value.info && !value.info.is_contract).length,
+ withRecentTransactions: values.filter((value) => value.recentTransaction).length,
+ totalTransactions: values.reduce(
+ (sum, value) => sum + (value.info?.transaction_count || 0),
+ 0,
+ ),
+ }
+ }, [entries, snapshots])
+
const removeEntry = (address: string) => {
setEntries((current) => {
const next = current.filter((entry) => entry.toLowerCase() !== address.toLowerCase())
@@ -36,9 +140,7 @@ export default function WatchlistPage() {
}
const exportWatchlist = () => {
- if (entries.length === 0) {
- return
- }
+ if (entries.length === 0) return
try {
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
@@ -55,23 +157,36 @@ export default function WatchlistPage() {
const file = event.target.files?.[0]
if (!file) return
- file.text().then((text) => {
- try {
- const next = sanitizeWatchlistEntries(JSON.parse(text))
- setEntries(next)
- writeWatchlistToStorage(window.localStorage, next)
- } catch {}
- }).catch(() => {})
+ file.text()
+ .then((text) => {
+ try {
+ const next = sanitizeWatchlistEntries(JSON.parse(text))
+ setEntries(next)
+ writeWatchlistToStorage(window.localStorage, next)
+ } catch {}
+ })
+ .catch(() => {})
event.target.value = ''
}
+ const toggleConnectedWallet = () => {
+ if (!connectedWalletEntry || typeof window === 'undefined') return
+ const next = toggleWatchlistEntry(entries, connectedWalletEntry)
+ writeWatchlistToStorage(window.localStorage, next)
+ setEntries(next)
+ }
+
return (
+
+
+
+
Tracked addresses
+
{entries.length}
+
+
+
Recent indexed activity
+
{trackedSummaries.withRecentTransactions}
+
Entries with at least one visible recent transaction.
+
+
+
EOAs / contracts
+
+ {trackedSummaries.eoas} / {trackedSummaries.contracts}
+
+
+
+
Visible tx volume
+
+ {trackedSummaries.totalTransactions.toLocaleString()}
+
+
Aggregate indexed transaction count across tracked entries.
+
+
+
+ This view keeps tracked-address shortcuts and quick explorer evidence in one place so Guided mode can explain what each entity represents while Expert mode stays denser.
+
+
+
+
+ {walletSession ? (
+
+
+
+ {shortAddress(walletSession.address)}
+
+
+ {walletSession.address}
+
+
+ {connectedWalletTracked
+ ? 'This connected wallet is already part of your tracked entity set.'
+ : 'Add this connected wallet into the tracked entity set for faster monitoring and navigation.'}
+
+
+
+
+ {connectedWalletTracked ? 'Remove from watchlist' : 'Track connected wallet'}
+
+
+ Open address
+
+
+
+ ) : (
+
+ Connect a wallet from the wallet tools page to pin your own address into tracked workflows.
+
+ )}
+
+
- {entries.length === 0 ? 'No saved entries yet.' : `${entries.length} saved ${entries.length === 1 ? 'address' : 'addresses'}.`}
+ {entries.length === 0
+ ? 'No saved entries yet.'
+ : `${entries.length} saved ${entries.length === 1 ? 'address' : 'addresses'}.`}
@@ -113,20 +299,76 @@ export default function WatchlistPage() {
) : (
- {entries.map((entry) => (
-
-
-
-
-
removeEntry(entry)}
- className="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
+ {entries.map((entry) => {
+ const snapshot = snapshots[entry.toLowerCase()]
+ const info = snapshot?.info || null
+ const recentTransaction = snapshot?.recentTransaction || null
+
+ return (
+
- Remove
-
-
- ))}
+
+
+
+
+
+
+
+
Type
+
+ {info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? 'Loading…' : 'Unknown'}
+
+
+
+
Indexed txs
+
+ {info ? info.transaction_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'}
+
+
+
+
Token holdings
+
+ {info ? info.token_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'}
+
+
+
+
Recent visible tx
+
+ {recentTransaction ? `#${recentTransaction.block_number.toLocaleString()}` : loadingSnapshots ? 'Loading…' : 'None visible'}
+
+
+
+
+
+
+
+ Open address
+
+ {recentTransaction ? (
+
+ Latest tx
+
+ ) : null}
+ removeEntry(entry)}
+ className="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
+ >
+ Remove
+
+
+
+
+ )
+ })}
)}
diff --git a/frontend/src/services/api/missionControl.ts b/frontend/src/services/api/missionControl.ts
index 74415e6..220040b 100644
--- a/frontend/src/services/api/missionControl.ts
+++ b/frontend/src/services/api/missionControl.ts
@@ -1,4 +1,5 @@
import { getExplorerApiBase } from './blockscout'
+import type { ExplorerFreshnessDiagnostics } from './stats'
export interface MissionControlRelaySummary {
text: string
@@ -99,6 +100,7 @@ export interface MissionControlBridgeStatusResponse {
status?: string
checked_at?: string
freshness?: unknown
+ diagnostics?: ExplorerFreshnessDiagnostics | null
sampling?: {
stats_generated_at?: string | null
rpc_probe_at?: string | null
@@ -150,6 +152,11 @@ function describeRelayStatus(snapshot: MissionControlRelaySnapshot, status: stri
? 'underfunded queued release'
: 'underfunded release'
}
+ if (snapshot.last_error?.scope === 'bridge_inventory_probe') {
+ return snapshot.queue?.size && snapshot.queue.size > 0
+ ? 'inventory check deferred'
+ : 'inventory check pending'
+ }
if (status === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
return snapshot.queue?.size && snapshot.queue.size > 0 ? 'delivery paused (queueing)' : 'delivery paused'
}
diff --git a/frontend/src/services/api/stats.test.ts b/frontend/src/services/api/stats.test.ts
index 219e1eb..9eddb5c 100644
--- a/frontend/src/services/api/stats.test.ts
+++ b/frontend/src/services/api/stats.test.ts
@@ -26,6 +26,7 @@ describe('normalizeExplorerStats', () => {
freshness: null,
completeness: null,
sampling: null,
+ diagnostics: null,
})
})
@@ -48,6 +49,7 @@ describe('normalizeExplorerStats', () => {
freshness: null,
completeness: null,
sampling: null,
+ diagnostics: null,
})
})
diff --git a/frontend/src/services/api/stats.ts b/frontend/src/services/api/stats.ts
index a8edff6..ec8cb6e 100644
--- a/frontend/src/services/api/stats.ts
+++ b/frontend/src/services/api/stats.ts
@@ -12,6 +12,7 @@ export interface ExplorerStats {
freshness: ExplorerFreshnessSnapshot | null
completeness: ExplorerStatsCompleteness | null
sampling: ExplorerStatsSampling | null
+ diagnostics: ExplorerFreshnessDiagnostics | null
}
export interface ExplorerFreshnessReference {
@@ -33,6 +34,22 @@ export interface ExplorerFreshnessSnapshot {
latest_non_empty_block: ExplorerFreshnessReference
}
+export interface ExplorerFreshnessDiagnostics {
+ tx_visibility_state?: string | null
+ activity_state?: string | null
+ explanation?: string | null
+ tx_lag_blocks?: number | null
+ tx_lag_seconds?: number | null
+ recent_block_sample_size?: number | null
+ recent_non_empty_blocks?: number | null
+ recent_transactions?: number | null
+ latest_non_empty_block_from_block_feed?: ExplorerFreshnessReference | null
+ source?: string | null
+ confidence?: string | null
+ provenance?: string | null
+ completeness?: string | null
+}
+
export interface ExplorerStatsCompleteness {
transactions_feed?: string | null
blocks_feed?: string | null
@@ -87,6 +104,21 @@ interface RawExplorerStats {
} | null
completeness?: ExplorerStatsCompleteness | null
sampling?: ExplorerStatsSampling | null
+ diagnostics?: {
+ tx_visibility_state?: string | null
+ activity_state?: string | null
+ explanation?: string | null
+ tx_lag_blocks?: number | string | null
+ tx_lag_seconds?: number | string | null
+ recent_block_sample_size?: number | string | null
+ recent_non_empty_blocks?: number | string | null
+ recent_transactions?: number | string | null
+ latest_non_empty_block_from_block_feed?: RawExplorerFreshnessReference | null
+ source?: string | null
+ confidence?: string | null
+ provenance?: string | null
+ completeness?: string | null
+ } | null
}
interface RawExplorerFreshnessReference {
@@ -135,6 +167,34 @@ function normalizeFreshnessSnapshot(raw?: RawExplorerStats['freshness'] | null):
}
}
+function normalizeFreshnessDiagnostics(raw?: RawExplorerStats['diagnostics'] | null): ExplorerFreshnessDiagnostics | null {
+ if (!raw) return null
+ return {
+ tx_visibility_state: raw.tx_visibility_state || null,
+ activity_state: raw.activity_state || null,
+ explanation: raw.explanation || null,
+ tx_lag_blocks: raw.tx_lag_blocks == null || raw.tx_lag_blocks === '' ? null : toNumber(raw.tx_lag_blocks),
+ tx_lag_seconds: raw.tx_lag_seconds == null || raw.tx_lag_seconds === '' ? null : toNumber(raw.tx_lag_seconds),
+ recent_block_sample_size:
+ raw.recent_block_sample_size == null || raw.recent_block_sample_size === ''
+ ? null
+ : toNumber(raw.recent_block_sample_size),
+ recent_non_empty_blocks:
+ raw.recent_non_empty_blocks == null || raw.recent_non_empty_blocks === ''
+ ? null
+ : toNumber(raw.recent_non_empty_blocks),
+ recent_transactions:
+ raw.recent_transactions == null || raw.recent_transactions === ''
+ ? null
+ : toNumber(raw.recent_transactions),
+ latest_non_empty_block_from_block_feed: normalizeFreshnessReference(raw.latest_non_empty_block_from_block_feed),
+ source: raw.source || null,
+ confidence: raw.confidence || null,
+ provenance: raw.provenance || null,
+ completeness: raw.completeness || null,
+ }
+}
+
export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
const latestBlockValue = raw.latest_block
const averageBlockTimeValue = raw.average_block_time
@@ -169,6 +229,7 @@ export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
freshness: normalizeFreshnessSnapshot(raw.freshness),
completeness: raw.completeness || null,
sampling: raw.sampling || null,
+ diagnostics: normalizeFreshnessDiagnostics(raw.diagnostics),
}
}
diff --git a/frontend/src/utils/activityContext.ts b/frontend/src/utils/activityContext.ts
index 0c187be..dab0a00 100644
--- a/frontend/src/utils/activityContext.ts
+++ b/frontend/src/utils/activityContext.ts
@@ -1,6 +1,6 @@
import type { Block } from '@/services/api/blocks'
import type { Transaction } from '@/services/api/transactions'
-import type { ExplorerFreshnessSnapshot } from '@/services/api/stats'
+import type { ExplorerFreshnessDiagnostics, ExplorerFreshnessSnapshot } from '@/services/api/stats'
export type ChainActivityState = 'active' | 'low' | 'inactive' | 'unknown'
@@ -34,8 +34,10 @@ export function summarizeChainActivity(input: {
latestBlockNumber?: number | null
latestBlockTimestamp?: string | null
freshness?: ExplorerFreshnessSnapshot | null
+ diagnostics?: ExplorerFreshnessDiagnostics | null
}): ChainActivityContext {
const freshness = input.freshness || null
+ const diagnostics = input.diagnostics || null
const blocks = Array.isArray(input.blocks) ? input.blocks : []
const transactions = Array.isArray(input.transactions) ? input.transactions : []
@@ -55,9 +57,11 @@ export function summarizeChainActivity(input: {
transactions.find((transaction) => transaction.block_number === latestTransaction) ?? transactions[0] ?? null
const nonEmptyBlock =
+ diagnostics?.latest_non_empty_block_from_block_feed?.block_number ??
freshness?.latest_non_empty_block.block_number ??
sortDescending(blocks.filter((block) => block.transaction_count > 0).map((block) => block.number))[0] ?? latestTransaction
const nonEmptyBlockTimestamp =
+ diagnostics?.latest_non_empty_block_from_block_feed?.timestamp ??
freshness?.latest_non_empty_block.timestamp ??
blocks.find((block) => block.number === nonEmptyBlock)?.timestamp ??
latestTransactionRecord?.created_at ??
@@ -76,24 +80,39 @@ export function summarizeChainActivity(input: {
})()
const gap = freshness?.latest_non_empty_block.distance_from_head ??
+ diagnostics?.tx_lag_blocks ??
(latestBlock != null && latestTransaction != null
? Math.max(0, latestBlock - latestTransaction)
: null)
- const state: ChainActivityState =
- latestTransactionAgeSeconds == null
- ? 'unknown'
- : latestTransactionAgeSeconds <= 15 * 60
- ? 'active'
- : latestTransactionAgeSeconds <= 3 * 60 * 60
- ? 'low'
- : 'inactive'
+ const state: ChainActivityState = (() => {
+ switch (diagnostics?.activity_state) {
+ case 'active':
+ return 'active'
+ case 'sparse_activity':
+ case 'quiet_chain':
+ return 'low'
+ case 'fresh_head_stale_transaction_visibility':
+ return 'inactive'
+ case 'limited_observability':
+ return 'unknown'
+ default:
+ return latestTransactionAgeSeconds == null
+ ? 'unknown'
+ : latestTransactionAgeSeconds <= 15 * 60
+ ? 'active'
+ : latestTransactionAgeSeconds <= 3 * 60 * 60
+ ? 'low'
+ : 'inactive'
+ }
+ })()
const headIsIdle =
- gap != null &&
- gap > 0 &&
- latestTransactionAgeSeconds != null &&
- latestTransactionAgeSeconds > 0
+ diagnostics?.activity_state === 'quiet_chain' ||
+ (gap != null &&
+ gap > 0 &&
+ latestTransactionAgeSeconds != null &&
+ latestTransactionAgeSeconds > 0)
return {
latest_block_number: latestBlock,
diff --git a/frontend/src/utils/explorerFreshness.test.ts b/frontend/src/utils/explorerFreshness.test.ts
index 7628819..33ad2bd 100644
--- a/frontend/src/utils/explorerFreshness.test.ts
+++ b/frontend/src/utils/explorerFreshness.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { resolveEffectiveFreshness, summarizeFreshnessConfidence } from './explorerFreshness'
+import {
+ resolveEffectiveFreshness,
+ resolveFreshnessSourceLabel,
+ summarizeFreshnessConfidence,
+} from './explorerFreshness'
describe('resolveEffectiveFreshness', () => {
it('prefers stats freshness when it is present', () => {
@@ -129,4 +133,43 @@ describe('resolveEffectiveFreshness', () => {
'Feed: snapshot',
])
})
+
+ it('describes whether freshness comes from stats or mission-control fallback', () => {
+ expect(
+ resolveFreshnessSourceLabel(
+ {
+ total_blocks: 1,
+ total_transactions: 2,
+ total_addresses: 3,
+ latest_block: 4,
+ average_block_time_ms: null,
+ average_gas_price_gwei: null,
+ network_utilization_percentage: null,
+ transactions_today: null,
+ freshness: {
+ chain_head: { block_number: 10 },
+ latest_indexed_block: { block_number: 10 },
+ latest_indexed_transaction: { block_number: 9 },
+ latest_non_empty_block: { block_number: 9 },
+ },
+ completeness: null,
+ sampling: null,
+ },
+ null,
+ ),
+ ).toBe('Based on public stats and indexed explorer freshness.')
+
+ expect(
+ resolveFreshnessSourceLabel(
+ null,
+ {
+ data: {
+ freshness: {
+ chain_head: { block_number: 20 },
+ },
+ },
+ },
+ ),
+ ).toBe('Based on mission-control freshness and latest visible public data.')
+ })
})
diff --git a/frontend/src/utils/serverExplorerContext.ts b/frontend/src/utils/serverExplorerContext.ts
new file mode 100644
index 0000000..40459dc
--- /dev/null
+++ b/frontend/src/utils/serverExplorerContext.ts
@@ -0,0 +1,20 @@
+import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
+import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
+import { fetchPublicJson } from './publicExplorer'
+
+export interface ExplorerTruthContext {
+ initialStats: ExplorerStats | null
+ initialBridgeStatus: MissionControlBridgeStatusResponse | null
+}
+
+export async function fetchExplorerTruthContext(): Promise
{
+ const [statsResult, bridgeResult] = await Promise.all([
+ fetchPublicJson('/api/v2/stats').catch(() => null),
+ fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null),
+ ])
+
+ return {
+ initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
+ initialBridgeStatus: bridgeResult,
+ }
+}
diff --git a/frontend/src/utils/watchlist.test.ts b/frontend/src/utils/watchlist.test.ts
index 91ea6cd..8958629 100644
--- a/frontend/src/utils/watchlist.test.ts
+++ b/frontend/src/utils/watchlist.test.ts
@@ -4,6 +4,7 @@ import {
normalizeWatchlistAddress,
parseStoredWatchlist,
sanitizeWatchlistEntries,
+ toggleWatchlistEntry,
} from './watchlist'
describe('watchlist utils', () => {
@@ -39,4 +40,10 @@ describe('watchlist utils', () => {
isWatchlistEntry(entries, '0x1234567890123456789012345678901234567890'.toUpperCase()),
).toBe(true)
})
+
+ it('toggles watchlist entries on and off case-insensitively', () => {
+ const address = '0x1234567890123456789012345678901234567890'
+ expect(toggleWatchlistEntry([], address)).toEqual([address])
+ expect(toggleWatchlistEntry([address], address.toUpperCase())).toEqual([])
+ })
})
diff --git a/frontend/src/utils/watchlist.ts b/frontend/src/utils/watchlist.ts
index 9eca526..119db5e 100644
--- a/frontend/src/utils/watchlist.ts
+++ b/frontend/src/utils/watchlist.ts
@@ -71,3 +71,16 @@ export function isWatchlistEntry(entries: string[], address: string) {
return entries.some((entry) => entry.toLowerCase() === normalized.toLowerCase())
}
+
+export function toggleWatchlistEntry(entries: string[], address: string) {
+ const normalized = normalizeWatchlistAddress(address)
+ if (!normalized) {
+ return sanitizeWatchlistEntries(entries)
+ }
+
+ if (isWatchlistEntry(entries, normalized)) {
+ return entries.filter((entry) => entry.toLowerCase() !== normalized.toLowerCase())
+ }
+
+ return sanitizeWatchlistEntries([...entries, normalized])
+}