From 0c869f79306dc8a0094eca82161e9617bef329fd Mon Sep 17 00:00:00 2001 From: defiQUG Date: Sun, 12 Apr 2026 18:22:08 -0700 Subject: [PATCH] feat(freshness): enhance diagnostics and update snapshot structure - Introduced a new Diagnostics struct to capture transaction visibility state and activity state. - Updated BuildSnapshot function to return diagnostics alongside snapshot, completeness, and sampling. - Enhanced test cases to validate the new diagnostics data. - Updated frontend components to utilize the new diagnostics information for improved user feedback on freshness context. This change improves the observability of transaction activity and enhances the user experience by providing clearer insights into the freshness of data. --- backend/api/freshness/freshness.go | 150 ++++++++- backend/api/freshness/freshness_test.go | 51 ++- backend/api/rest/stats.go | 4 +- backend/api/rest/stats_internal_test.go | 9 + backend/api/rest/track_routes.go | 10 +- backend/api/track1/bridge_status_data.go | 5 +- backend/api/track1/ccip_health_test.go | 15 +- backend/api/track1/endpoints.go | 4 +- .../components/common/FreshnessTrustNote.tsx | 32 +- .../explorer/AnalyticsOperationsPage.tsx | 1 + .../explorer/BridgeMonitoringPage.tsx | 50 ++- .../explorer/LiquidityOperationsPage.tsx | 57 +++- .../components/explorer/OperationsHubPage.tsx | 17 +- .../explorer/RoutesMonitoringPage.tsx | 49 ++- frontend/src/components/home/HomePage.tsx | 150 ++++++--- frontend/src/components/wallet/WalletPage.tsx | 296 ++++++++++++++++ frontend/src/pages/addresses/index.tsx | 1 + frontend/src/pages/analytics/index.tsx | 11 +- frontend/src/pages/blocks/index.tsx | 1 + frontend/src/pages/bridge/index.tsx | 11 +- frontend/src/pages/index.tsx | 20 +- frontend/src/pages/liquidity/index.tsx | 10 +- frontend/src/pages/operations/index.tsx | 10 +- frontend/src/pages/routes/index.tsx | 10 +- frontend/src/pages/transactions/index.tsx | 1 + frontend/src/pages/watchlist/index.tsx | 318 +++++++++++++++--- frontend/src/services/api/missionControl.ts | 7 + frontend/src/services/api/stats.test.ts | 2 + frontend/src/services/api/stats.ts | 61 ++++ frontend/src/utils/activityContext.ts | 45 ++- frontend/src/utils/explorerFreshness.test.ts | 45 ++- frontend/src/utils/serverExplorerContext.ts | 20 ++ frontend/src/utils/watchlist.test.ts | 7 + frontend/src/utils/watchlist.ts | 13 + 34 files changed, 1328 insertions(+), 165 deletions(-) create mode 100644 frontend/src/utils/serverExplorerContext.ts 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 ? ( + <> + + + Open address + + + Open access console + + + + Open watchlist + + + + ) : ( + <> + + + 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.'} +
+
+
+ + + 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'}.`}

) : (
- {entries.map((entry) => ( -
- -
- - -
- ))} +
+
+ +
+ +
+
+
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} + +
+
+
+ ) + })}
)}
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]) +}