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]) +}