From 763ca75c21f671c293df855b29ef13dab90e18f5 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Sat, 23 May 2026 03:48:22 -0700 Subject: [PATCH] =?UTF-8?q?Ship=20Tier=20A=20Week=201=E2=80=932:=20posture?= =?UTF-8?q?=20glossary,=20delivery=20mode,=20freshness=20UI,=20canonical?= =?UTF-8?q?=20tokens.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose mission-control mode on home/bridge/analytics, quiet-chain freshness copy, and a canonical-first indexed token list with WETH9 metadata override and non-canonical warnings. Co-authored-by: Cursor --- backend/api/track1/bridge_mode.go | 78 ++++++++++++++ backend/api/track1/bridge_mode_test.go | 36 +++++++ backend/api/track1/bridge_status_data.go | 52 ++++----- backend/api/track1/ccip_health_test.go | 5 + .../common/ActivityContextPanel.tsx | 8 ++ .../src/components/common/ExplorerChrome.tsx | 3 + .../components/common/FreshnessTrustNote.tsx | 31 ++++-- .../common/MissionDeliveryModePanel.tsx | 76 +++++++++++++ .../src/components/common/PostureBadge.tsx | 33 ++++++ .../common/PostureGlossaryProvider.tsx | 82 ++++++++++++++ .../explorer/AnalyticsOperationsPage.tsx | 2 + .../explorer/BridgeMonitoringPage.tsx | 2 + frontend/src/components/home/HomePage.tsx | 37 ++++--- frontend/src/data/postureGlossary.ts | 101 ++++++++++++++++++ frontend/src/pages/addresses/[address].tsx | 11 +- frontend/src/pages/docs/index.tsx | 5 + frontend/src/pages/docs/posture-glossary.tsx | 80 ++++++++++++++ frontend/src/pages/search/index.tsx | 3 +- frontend/src/pages/tokens/[address].tsx | 24 ++++- frontend/src/pages/tokens/index.tsx | 79 +++++++++++++- frontend/src/pages/transactions/[hash].tsx | 5 +- frontend/src/services/api/tokens.ts | 73 ++++++++++++- frontend/src/utils/canonicalTokens.test.ts | 47 ++++++++ frontend/src/utils/canonicalTokens.ts | 58 ++++++++++ scripts/e2e-sprint-smoke.spec.ts | 10 ++ 25 files changed, 873 insertions(+), 68 deletions(-) create mode 100644 backend/api/track1/bridge_mode.go create mode 100644 backend/api/track1/bridge_mode_test.go create mode 100644 frontend/src/components/common/MissionDeliveryModePanel.tsx create mode 100644 frontend/src/components/common/PostureBadge.tsx create mode 100644 frontend/src/components/common/PostureGlossaryProvider.tsx create mode 100644 frontend/src/data/postureGlossary.ts create mode 100644 frontend/src/pages/docs/posture-glossary.tsx create mode 100644 frontend/src/utils/canonicalTokens.test.ts create mode 100644 frontend/src/utils/canonicalTokens.ts diff --git a/backend/api/track1/bridge_mode.go b/backend/api/track1/bridge_mode.go new file mode 100644 index 0000000..61cf2ae --- /dev/null +++ b/backend/api/track1/bridge_mode.go @@ -0,0 +1,78 @@ +package track1 + +import ( + "strings" + + "github.com/explorer/backend/api/freshness" +) + +type bridgeDeliveryMode struct { + Kind string + Reason any + Scope any +} + +func resolveBridgeDeliveryMode(hasRelays bool, diagnostics *freshness.Diagnostics, txFeed freshness.Completeness) bridgeDeliveryMode { + if !hasRelays { + if diagnostics != nil && isStaleTransactionVisibility(diagnostics) { + return bridgeDeliveryMode{ + Kind: "mixed", + Reason: "partial_observability_inputs", + Scope: "homepage_summary_only", + } + } + return bridgeDeliveryMode{ + Kind: "live", + Reason: nil, + Scope: nil, + } + } + + if diagnostics != nil && isStaleTransactionVisibility(diagnostics) { + return bridgeDeliveryMode{ + Kind: "mixed", + Reason: "relay_snapshot_only_source", + Scope: "bridge_monitoring_and_homepage", + } + } + + if txFeed == freshness.CompletenessPartial || txFeed == freshness.CompletenessStale { + return bridgeDeliveryMode{ + Kind: "mixed", + Reason: "partial_observability_inputs", + Scope: "bridge_monitoring_and_homepage", + } + } + + return bridgeDeliveryMode{ + Kind: "snapshot", + Reason: "live_homepage_stream_not_attached", + Scope: "relay_monitoring_homepage_card_only", + } +} + +func isStaleTransactionVisibility(diagnostics *freshness.Diagnostics) bool { + if diagnostics == nil { + return false + } + state := strings.ToLower(strings.TrimSpace(diagnostics.ActivityState)) + switch state { + case "fresh_head_stale_transaction_visibility", "lagging", "stale_transaction_visibility": + return true + default: + return strings.Contains(state, "stale") && strings.Contains(state, "transaction") + } +} + +func buildBridgeModePayload(now string, resolved bridgeDeliveryMode) map[string]interface{} { + return map[string]interface{}{ + "kind": resolved.Kind, + "updated_at": now, + "age_seconds": int64(0), + "reason": resolved.Reason, + "scope": resolved.Scope, + "source": freshness.SourceReported, + "confidence": freshness.ConfidenceHigh, + "provenance": freshness.ProvenanceMissionFeed, + } +} diff --git a/backend/api/track1/bridge_mode_test.go b/backend/api/track1/bridge_mode_test.go new file mode 100644 index 0000000..f6d7b3f --- /dev/null +++ b/backend/api/track1/bridge_mode_test.go @@ -0,0 +1,36 @@ +package track1 + +import ( + "testing" + + "github.com/explorer/backend/api/freshness" + "github.com/stretchr/testify/require" +) + +func TestResolveBridgeDeliveryModeLiveWithoutRelays(t *testing.T) { + got := resolveBridgeDeliveryMode(false, nil, freshness.CompletenessComplete) + require.Equal(t, "live", got.Kind) + require.Nil(t, got.Reason) +} + +func TestResolveBridgeDeliveryModeSnapshotWithRelays(t *testing.T) { + got := resolveBridgeDeliveryMode(true, nil, freshness.CompletenessComplete) + require.Equal(t, "snapshot", got.Kind) + require.Equal(t, "live_homepage_stream_not_attached", got.Reason) + require.Equal(t, "relay_monitoring_homepage_card_only", got.Scope) +} + +func TestResolveBridgeDeliveryModeMixedWhenTransactionVisibilityStale(t *testing.T) { + diagnostics := &freshness.Diagnostics{ + ActivityState: "fresh_head_stale_transaction_visibility", + } + got := resolveBridgeDeliveryMode(true, diagnostics, freshness.CompletenessPartial) + require.Equal(t, "mixed", got.Kind) + require.Equal(t, "relay_snapshot_only_source", got.Reason) + require.Equal(t, "bridge_monitoring_and_homepage", got.Scope) +} + +func TestIsStaleTransactionVisibility(t *testing.T) { + require.True(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "fresh_head_stale_transaction_visibility"})) + require.False(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "healthy"})) +} diff --git a/backend/api/track1/bridge_status_data.go b/backend/api/track1/bridge_status_data.go index 44273f6..d66f9e0 100644 --- a/backend/api/track1/bridge_status_data.go +++ b/backend/api/track1/bridge_status_data.go @@ -133,6 +133,8 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface } if s.freshnessLoader != nil { if snapshot, completeness, sampling, diagnostics, err := s.freshnessLoader(ctx); err == nil && snapshot != nil { + txFeed := completeness.TransactionsFeed + resolvedMode := resolveBridgeDeliveryMode(false, diagnostics, txFeed) subsystems := map[string]interface{}{ "rpc_head": map[string]interface{}{ "status": chainStatusFromProbe(p138), @@ -174,39 +176,13 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface "issues": sampling.Issues, } } - modeKind := "live" - modeReason := any(nil) - modeScope := any(nil) - if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 { - modeKind = "snapshot" - modeReason = "live_homepage_stream_not_attached" - modeScope = "relay_monitoring_homepage_card_only" - subsystems["bridge_relay_monitoring"] = map[string]interface{}{ - "status": overall, - "updated_at": now, - "age_seconds": int64(0), - "source": freshness.SourceReported, - "confidence": freshness.ConfidenceHigh, - "provenance": freshness.ProvenanceMissionFeed, - "completeness": freshness.CompletenessComplete, - } - } data["freshness"] = snapshot data["subsystems"] = subsystems data["sampling"] = sampling if diagnostics != nil { data["diagnostics"] = diagnostics } - data["mode"] = map[string]interface{}{ - "kind": modeKind, - "updated_at": now, - "age_seconds": int64(0), - "reason": modeReason, - "scope": modeScope, - "source": freshness.SourceReported, - "confidence": freshness.ConfidenceHigh, - "provenance": freshness.ProvenanceMissionFeed, - } + data["mode"] = buildBridgeModePayload(now, resolvedMode) } } if relays := FetchCCIPRelayHealths(ctx); relays != nil { @@ -224,9 +200,22 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface } if mode, ok := data["mode"].(map[string]interface{}); ok { if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 { - mode["kind"] = "snapshot" - mode["reason"] = "live_homepage_stream_not_attached" - mode["scope"] = "relay_monitoring_homepage_card_only" + var diagnostics *freshness.Diagnostics + if diag, ok := data["diagnostics"].(*freshness.Diagnostics); ok { + diagnostics = diag + } + txFeed := freshness.CompletenessUnavailable + if subsystems, ok := data["subsystems"].(map[string]interface{}); ok { + if txIndex, ok := subsystems["tx_index"].(map[string]interface{}); ok { + if feed, ok := txIndex["completeness"].(freshness.Completeness); ok { + txFeed = feed + } + } + } + resolved := resolveBridgeDeliveryMode(true, diagnostics, txFeed) + mode["kind"] = resolved.Kind + mode["reason"] = resolved.Reason + mode["scope"] = resolved.Scope if subsystems, ok := data["subsystems"].(map[string]interface{}); ok { subsystems["bridge_relay_monitoring"] = map[string]interface{}{ "status": data["status"], @@ -239,6 +228,9 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface } } } + } else if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 { + resolved := resolveBridgeDeliveryMode(true, nil, freshness.CompletenessUnavailable) + data["mode"] = buildBridgeModePayload(now, resolved) } return data } diff --git a/backend/api/track1/ccip_health_test.go b/backend/api/track1/ccip_health_test.go index 35b51de..ca249e9 100644 --- a/backend/api/track1/ccip_health_test.go +++ b/backend/api/track1/ccip_health_test.go @@ -212,6 +212,11 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) { require.Contains(t, got, "diagnostics") require.Contains(t, got, "subsystems") require.Contains(t, got, "mode") + mode, ok := got["mode"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "mixed", mode["kind"]) + require.Equal(t, "relay_snapshot_only_source", mode["reason"]) + require.Equal(t, "bridge_monitoring_and_homepage", mode["scope"]) } func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) { diff --git a/frontend/src/components/common/ActivityContextPanel.tsx b/frontend/src/components/common/ActivityContextPanel.tsx index 09f6e92..ff6c867 100644 --- a/frontend/src/components/common/ActivityContextPanel.tsx +++ b/frontend/src/components/common/ActivityContextPanel.tsx @@ -75,6 +75,9 @@ export default function ActivityContextPanel({ + {context.head_is_idle && context.state === 'low' ? ( + + ) : null} {compact ? ( @@ -130,6 +133,11 @@ export default function ActivityContextPanel({ Open last non-empty block → ) : null} + {context.block_gap_to_latest_transaction != null ? ( + + Block gap to latest visible transaction: {context.block_gap_to_latest_transaction.toLocaleString()} + + ) : null} {context.latest_transaction_timestamp ? ( Latest visible transaction time: {formatTimestamp(context.latest_transaction_timestamp)} ) : null} diff --git a/frontend/src/components/common/ExplorerChrome.tsx b/frontend/src/components/common/ExplorerChrome.tsx index ca92c6b..df1c9bc 100644 --- a/frontend/src/components/common/ExplorerChrome.tsx +++ b/frontend/src/components/common/ExplorerChrome.tsx @@ -3,10 +3,12 @@ import Navbar from './Navbar' import Footer from './Footer' import ExplorerAgentTool from './ExplorerAgentTool' import { UiModeProvider } from './UiModeContext' +import { PostureGlossaryProvider } from './PostureGlossaryProvider' export default function ExplorerChrome({ children }: { children: ReactNode }) { return ( + + ) } diff --git a/frontend/src/components/common/FreshnessTrustNote.tsx b/frontend/src/components/common/FreshnessTrustNote.tsx index 99c496e..596953f 100644 --- a/frontend/src/components/common/FreshnessTrustNote.tsx +++ b/frontend/src/components/common/FreshnessTrustNote.tsx @@ -8,11 +8,15 @@ import { import { formatRelativeAge } from '@/utils/format' import { useUiMode } from './UiModeContext' -function buildSummary(context: ChainActivityContext) { +function buildSummary(context: ChainActivityContext, activityState?: string | null) { if (context.transaction_visibility_unavailable) { return 'Chain-head visibility is current, while transaction freshness is currently unavailable.' } + if (activityState === 'quiet_chain' || (context.head_is_idle && context.state === 'low')) { + return 'The chain head is current, but recent head blocks are quiet — this is normal low-activity visibility, not a broken index.' + } + if (context.state === 'active') { return 'Chain head and latest indexed transactions are closely aligned.' } @@ -28,11 +32,21 @@ function buildSummary(context: ChainActivityContext) { return 'Freshness context is based on the latest visible public explorer evidence.' } -function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null) { +function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null, activityState?: string | null) { if (diagnosticExplanation) { return diagnosticExplanation } + if (activityState === 'quiet_chain') { + const latestNonEmptyBlock = + context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown' + const blockGap = + context.block_gap_to_latest_transaction != null + ? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks` + : 'unknown' + return `Quiet-chain signal: head blocks may be empty while the chain remains current. Block gap to latest visible transaction: ${blockGap}. Last non-empty block: ${latestNonEmptyBlock}.` + } + if (context.transaction_visibility_unavailable) { return 'Use chain-head visibility and the last non-empty block as the current trust anchors.' } @@ -40,9 +54,13 @@ function buildDetail(context: ChainActivityContext, diagnosticExplanation?: stri const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp) const latestNonEmptyBlock = context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown' + const blockGap = + context.block_gap_to_latest_transaction != null + ? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks` + : null if (context.head_is_idle) { - return `Latest visible transaction: ${latestTxAge}. Last non-empty block: ${latestNonEmptyBlock}.` + return `Latest visible transaction: ${latestTxAge}. Block gap: ${blockGap || 'unknown'}. Last non-empty block: ${latestNonEmptyBlock}.` } if (context.state === 'active') { @@ -74,13 +92,14 @@ export default function FreshnessTrustNote({ const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus) const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus) const diagnosticExplanation = stats?.diagnostics?.explanation || bridgeStatus?.data?.diagnostics?.explanation || null + const activityState = stats?.diagnostics?.activity_state || bridgeStatus?.data?.diagnostics?.activity_state || null const normalizedClassName = className ? ` ${className}` : '' if (mode === 'expert') { return (
-
{buildSummary(context)}
+
{buildSummary(context, activityState)}
{sourceLabel}
@@ -99,9 +118,9 @@ export default function FreshnessTrustNote({ return (
-
{buildSummary(context)}
+
{buildSummary(context, activityState)}
- {normalizeSentence(buildDetail(context, diagnosticExplanation))}.{' '} + {normalizeSentence(buildDetail(context, diagnosticExplanation, activityState))}.{' '} {scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''} {normalizeSentence(sourceLabel)}.
diff --git a/frontend/src/components/common/MissionDeliveryModePanel.tsx b/frontend/src/components/common/MissionDeliveryModePanel.tsx new file mode 100644 index 0000000..37d9c91 --- /dev/null +++ b/frontend/src/components/common/MissionDeliveryModePanel.tsx @@ -0,0 +1,76 @@ +'use client' + +import { Card } from '@/libs/frontend-ui-primitives' +import EntityBadge from '@/components/common/EntityBadge' +import type { MissionControlMode } from '@/services/api/missionControl' +import { formatRelativeAge } from '@/utils/format' + +const REASON_LABELS: Record = { + live_homepage_stream_not_attached: 'Live homepage stream is not attached; relay posture uses snapshot polling.', + relay_snapshot_only_source: 'Relay monitoring uses snapshot sources while other explorer feeds remain live.', + partial_observability_inputs: 'Some freshness inputs are partial, so posture is reported conservatively.', +} + +const SCOPE_LABELS: Record = { + relay_monitoring_homepage_card_only: 'Affects relay monitoring and the homepage mission card only.', + bridge_monitoring_and_homepage: 'Affects bridge monitoring and homepage summary surfaces.', + homepage_summary_only: 'Affects homepage summary messaging only.', +} + +function humanizeKey(value?: string | null): string { + if (!value) return 'Not specified' + return SCOPE_LABELS[value] || REASON_LABELS[value] || value.replaceAll('_', ' ') +} + +function modeTone(kind?: string | null): 'success' | 'warning' | 'info' | 'neutral' { + switch (String(kind || '').toLowerCase()) { + case 'live': + return 'success' + case 'mixed': + return 'warning' + case 'snapshot': + return 'info' + default: + return 'neutral' + } +} + +export default function MissionDeliveryModePanel({ + mode, + title = 'Delivery mode', + className = '', +}: { + mode?: MissionControlMode | null + title?: string + className?: string +}) { + if (!mode?.kind) return null + + const normalizedClassName = className ? ` ${className}` : '' + + return ( + +
+
+ + {mode.updated_at ? ( + Updated {formatRelativeAge(mode.updated_at)} + ) : null} +
+ {mode.reason ? ( +

+ Reason: {humanizeKey(String(mode.reason))} +

+ ) : null} + {mode.scope ? ( +

+ Scope: {humanizeKey(String(mode.scope))} +

+ ) : null} +
+
+ ) +} diff --git a/frontend/src/components/common/PostureBadge.tsx b/frontend/src/components/common/PostureBadge.tsx new file mode 100644 index 0000000..909e646 --- /dev/null +++ b/frontend/src/components/common/PostureBadge.tsx @@ -0,0 +1,33 @@ +'use client' + +import EntityBadge from '@/components/common/EntityBadge' +import { usePostureGlossary } from '@/components/common/PostureGlossaryProvider' +import { resolvePostureTermId } from '@/data/postureGlossary' + +export default function PostureBadge({ + label, + tone, + className, +}: { + label: string + tone?: 'neutral' | 'success' | 'warning' | 'info' + className?: string +}) { + const { openTerm } = usePostureGlossary() + const termId = resolvePostureTermId(label) + + if (!termId) { + return + } + + return ( + + ) +} diff --git a/frontend/src/components/common/PostureGlossaryProvider.tsx b/frontend/src/components/common/PostureGlossaryProvider.tsx new file mode 100644 index 0000000..e5cfa14 --- /dev/null +++ b/frontend/src/components/common/PostureGlossaryProvider.tsx @@ -0,0 +1,82 @@ +'use client' + +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react' +import Link from 'next/link' +import { getPostureGlossaryTerm, type PostureGlossaryTermId } from '@/data/postureGlossary' + +interface PostureGlossaryContextValue { + openTerm: (termId: PostureGlossaryTermId) => void + close: () => void +} + +const PostureGlossaryContext = createContext(null) + +export function PostureGlossaryProvider({ children }: { children: ReactNode }) { + const [activeTermId, setActiveTermId] = useState(null) + const activeTerm = useMemo( + () => (activeTermId ? getPostureGlossaryTerm(activeTermId) ?? null : null), + [activeTermId], + ) + + const openTerm = useCallback((termId: PostureGlossaryTermId) => { + setActiveTermId(termId) + }, []) + + const close = useCallback(() => { + setActiveTermId(null) + }, []) + + return ( + + {children} + {activeTerm ? ( +
+
event.stopPropagation()} + > +
+
+

Posture glossary

+

+ {activeTerm.title} +

+
+ +
+

{activeTerm.summary}

+
+

Methodology

+

{activeTerm.methodology}

+
+
+ + Full glossary + + + GRU guide + +
+
+
+ ) : null} +
+ ) +} + +export function usePostureGlossary() { + const context = useContext(PostureGlossaryContext) + if (!context) { + throw new Error('usePostureGlossary must be used within PostureGlossaryProvider') + } + return context +} diff --git a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx index c8a7e04..512d448 100644 --- a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx +++ b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx @@ -19,6 +19,7 @@ import { formatWeiAsEth } from '@/utils/format' import { summarizeChainActivity } from '@/utils/activityContext' import ActivityContextPanel from '@/components/common/ActivityContextPanel' import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel' import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel' import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness' import OperationsPageShell, { @@ -156,6 +157,7 @@ export default function AnalyticsOperationsPage({ bridgeStatus={bridgeStatus} scopeLabel="This page combines public stats, recent block samples, and indexed transactions." /> + + + + + + {statsDetailsExpanded ? ( @@ -849,22 +868,6 @@ export default function Home({ )} - - ) : null}
diff --git a/frontend/src/data/postureGlossary.ts b/frontend/src/data/postureGlossary.ts new file mode 100644 index 0000000..55b6d55 --- /dev/null +++ b/frontend/src/data/postureGlossary.ts @@ -0,0 +1,101 @@ +export type PostureGlossaryTermId = + | 'x402' + | 'iso20022' + | 'forward-canonical' + | 'reference-asset' + | 'cw-public-network' + | 'transport-active' + | 'gru' + +export interface PostureGlossaryTerm { + id: PostureGlossaryTermId + title: string + shortLabel: string + summary: string + methodology: string +} + +export const postureGlossaryTerms: PostureGlossaryTerm[] = [ + { + id: 'gru', + title: 'GRU instrument', + shortLabel: 'GRU', + summary: 'A Global Reserve Unit instrument issued under DBIS taxonomy on Chain 138 or represented on public networks.', + methodology: + 'The explorer marks GRU when token metadata, registry tags, or curated catalog entries align with the GRU transport and compliance model documented in the GRU guide.', + }, + { + id: 'x402', + title: 'x402 readiness', + shortLabel: 'x402 ready', + summary: 'The token surface exposes the typed-data and domain metadata commonly required for HTTP-native payment authorization flows.', + methodology: + 'Readiness is inferred from detected signing surfaces (EIP-712 domain, ERC-5267, and ERC-2612 or ERC-3009). It describes technical capability — not a guarantee that a live x402 merchant endpoint exists.', + }, + { + id: 'iso20022', + title: 'ISO-20022 alignment', + shortLabel: 'ISO-20022', + summary: 'The asset is modeled as part of the ISO-20022-aligned settlement and reporting posture for institutional messaging.', + methodology: + 'This badge reflects GRU metadata and governance expectations around supervised disclosure — not a claim that every transfer is already formatted as an ISO-20022 message on-chain.', + }, + { + id: 'forward-canonical', + title: 'Forward-canonical posture', + shortLabel: 'forward canonical', + summary: 'The asset version is the forward-looking canonical representation operators should wire for new integrations.', + methodology: + 'Used when a token family has legacy or parallel deployments. Prefer forward-canonical addresses for routers, wallets, and explorer deep links unless a migration note says otherwise.', + }, + { + id: 'reference-asset', + title: 'Reference asset', + shortLabel: 'reference asset', + summary: 'A non-GRU mirrored or externally issued asset shown for routing, liquidity, or price context.', + methodology: + 'Reference assets help explain cross-chain routes and pool composition. They are not implied to be DBIS-issued compliant instruments unless separately tagged.', + }, + { + id: 'cw-public-network', + title: 'cW public-network representation', + shortLabel: 'cW public-network', + summary: 'A wrapped GRU instrument activated on a public network (for example mainnet cWUSDC) while Chain 138 remains the program ledger.', + methodology: + 'Public-network overlays use cW* naming. Liquidity and bridge lanes may reference these addresses even when the canonical compliant token lives on Chain 138.', + }, + { + id: 'transport-active', + title: 'transportActive (config compatibility)', + shortLabel: 'transportActive', + summary: 'Legacy JSON key indicating whether a public-network transport overlay is active in published `/config` manifests.', + methodology: + 'Machine consumers should treat this as a v1 compatibility field. A future v2 schema will expose `publicNetworkActive` aliases before old keys are removed.', + }, +] + +const labelToTermId: Record = { + gru: 'gru', + 'x402 ready': 'x402', + 'x402 not ready': 'x402', + 'iso-20022': 'iso20022', + 'iso-20022 aligned': 'iso20022', + 'iso-20022 unclear': 'iso20022', + 'forward canonical': 'forward-canonical', + 'reference asset': 'reference-asset', + wrapped: 'cw-public-network', + transportactive: 'transport-active', +} + +export function resolvePostureTermId(label: string): PostureGlossaryTermId | null { + const normalized = label.trim().toLowerCase() + if (labelToTermId[normalized]) return labelToTermId[normalized] + if (normalized.startsWith('cw public-network')) return 'cw-public-network' + if (normalized.startsWith('forward ')) return 'forward-canonical' + if (normalized.includes('transportactive') || normalized.includes('transport active')) return 'transport-active' + return null +} + +export function getPostureGlossaryTerm(id: PostureGlossaryTermId): PostureGlossaryTerm | undefined { + return postureGlossaryTerms.find((term) => term.id === id) +} diff --git a/frontend/src/pages/addresses/[address].tsx b/frontend/src/pages/addresses/[address].tsx index 9a2eebe..13b0b49 100644 --- a/frontend/src/pages/addresses/[address].tsx +++ b/frontend/src/pages/addresses/[address].tsx @@ -21,6 +21,7 @@ import { import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format' import { DetailRow } from '@/components/common/DetailRow' import EntityBadge from '@/components/common/EntityBadge' +import PostureBadge from '@/components/common/PostureBadge' import { isWatchlistEntry, readWatchlistFromStorage, @@ -377,8 +378,8 @@ export default function AddressDetailPage() { {balance.token_symbol || balance.token_name || 'Token'} )} {gruMetadata ? : null} - {gruMetadata?.x402Ready ? : null} - {gruMetadata?.iso20022Ready ? : null} + {gruMetadata?.x402Ready ? : null} + {gruMetadata?.iso20022Ready ? : null}
{balance.token_name && balance.token_symbol && (
{balance.token_name}
@@ -430,9 +431,9 @@ export default function AddressDetailPage() {
{transfer.token_symbol || transfer.token_name || 'Token'}
{gruMetadata ? : null} - {gruMetadata?.x402Ready ? : null} - {gruMetadata?.iso20022Ready ? : null} - {gruMetadata?.transportActiveVersion ? : null} + {gruMetadata?.x402Ready ? : null} + {gruMetadata?.iso20022Ready ? : null} + {gruMetadata?.transportActiveVersion ? : null}
{transfer.token_address && ( diff --git a/frontend/src/pages/docs/index.tsx b/frontend/src/pages/docs/index.tsx index 3a28d14..ab8ddc2 100644 --- a/frontend/src/pages/docs/index.tsx +++ b/frontend/src/pages/docs/index.tsx @@ -5,6 +5,11 @@ import { Card } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' const docsCards = [ + { + title: 'Posture glossary', + href: '/docs/posture-glossary', + description: 'First-read definitions for x402, ISO-20022, forward-canonical, cW public-network, and related explorer badges.', + }, { title: 'Public API access', href: '/docs/public-api-access', diff --git a/frontend/src/pages/docs/posture-glossary.tsx b/frontend/src/pages/docs/posture-glossary.tsx new file mode 100644 index 0000000..0e54760 --- /dev/null +++ b/frontend/src/pages/docs/posture-glossary.tsx @@ -0,0 +1,80 @@ +'use client' + +import Link from 'next/link' +import { Card } from '@/libs/frontend-ui-primitives' +import PageIntro from '@/components/common/PageIntro' +import PostureBadge from '@/components/common/PostureBadge' +import { postureGlossaryTerms } from '@/data/postureGlossary' + +export default function PostureGlossaryDocsPage() { + return ( +
+ ) +} diff --git a/frontend/src/pages/search/index.tsx b/frontend/src/pages/search/index.tsx index 25a05ce..7852b90 100644 --- a/frontend/src/pages/search/index.tsx +++ b/frontend/src/pages/search/index.tsx @@ -7,6 +7,7 @@ import type { TokenListToken } from '@/services/api/config' import { tokensApi } from '@/services/api/tokens' import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' import EntityBadge from '@/components/common/EntityBadge' +import PostureBadge from '@/components/common/PostureBadge' import { inferDirectSearchTarget, inferTokenSearchTarget, @@ -394,7 +395,7 @@ export default function SearchPage({ {result.token_type && } {result.is_curated_token && } {result.is_gru_token && } - {result.is_x402_ready && } + {result.is_x402_ready && } {result.is_wrapped_transport && } {result.currency_code ? : null} {result.match_reason ? : null} diff --git a/frontend/src/pages/tokens/[address].tsx b/frontend/src/pages/tokens/[address].tsx index 47420f0..61e0329 100644 --- a/frontend/src/pages/tokens/[address].tsx +++ b/frontend/src/pages/tokens/[address].tsx @@ -10,6 +10,7 @@ import type { MissionControlLiquidityPool } from '@/services/api/routes' import PageIntro from '@/components/common/PageIntro' import { DetailRow } from '@/components/common/DetailRow' import EntityBadge from '@/components/common/EntityBadge' +import PostureBadge from '@/components/common/PostureBadge' import GruStandardsCard from '@/components/common/GruStandardsCard' import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' @@ -260,8 +261,8 @@ export default function TokenDetailPage() { return (
- {gruMetadata.x402Ready ? : null} - {gruMetadata.iso20022Ready ? : null} + {gruMetadata.x402Ready ? : null} + {gruMetadata.iso20022Ready ? : null}
) }, @@ -358,6 +359,16 @@ export default function TokenDetailPage() {
) : (
+ {!provenance?.listed ? ( + +
+ +
+

+ This contract is indexed by Blockscout but is not in the curated Chain 138 token registry. Prefer canonical addresses from the token index for trading, liquidity, and bridge routing. +

+
+ ) : null}
{token.name || provenance?.name || 'Unknown'} @@ -451,9 +462,9 @@ export default function TokenDetailPage() {
x402 readiness
- + {gruExplorerMetadata.x402PreferredVersion ? : null} - {gruExplorerMetadata.canonicalForwardVersion ? : null} + {gruExplorerMetadata.canonicalForwardVersion ? : null}
{gruExplorerMetadata.x402Ready @@ -464,7 +475,7 @@ export default function TokenDetailPage() {
ISO-20022 and governance
- + {gruExplorerMetadata.currencyCode ? : null}
@@ -473,6 +484,9 @@ export default function TokenDetailPage() { : 'The explorer does not currently have a strong ISO-20022 posture signal for this asset.'}
+ + Posture glossary → + GRU guide → diff --git a/frontend/src/pages/tokens/index.tsx b/frontend/src/pages/tokens/index.tsx index f97fb9f..2c7715c 100644 --- a/frontend/src/pages/tokens/index.tsx +++ b/frontend/src/pages/tokens/index.tsx @@ -6,8 +6,13 @@ import { Card } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' import EntityBadge from '@/components/common/EntityBadge' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' -import { tokensApi } from '@/services/api/tokens' +import { tokensApi, type IndexedTokenListItem } from '@/services/api/tokens' import type { TokenListToken } from '@/services/api/config' +import { + buildCanonicalAddressSet, + isCanonicalTokenAddress, + sortIndexedTokensCanonicalFirst, +} from '@/utils/canonicalTokens' import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' import { fetchTokenListForSurface, TOKEN_LIST_SURFACE_LABELS } from '@/services/api/tokenListSurfaces' import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens' @@ -56,6 +61,8 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { const router = useRouter() const [query, setQuery] = useState('') const [curatedTokens, setCuratedTokens] = useState(initialCuratedTokens) + const [indexedTokens, setIndexedTokens] = useState([]) + const [indexedLoading, setIndexedLoading] = useState(true) const [featuredMarkets, setFeaturedMarkets] = useState>({}) const handleSubmit = (event: React.FormEvent) => { @@ -90,6 +97,33 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { [curatedTokens], ) + const canonicalAddressSet = useMemo(() => buildCanonicalAddressSet(curatedTokens), [curatedTokens]) + + const sortedIndexedTokens = useMemo( + () => sortIndexedTokensCanonicalFirst(indexedTokens, canonicalAddressSet), + [canonicalAddressSet, indexedTokens], + ) + + useEffect(() => { + let active = true + setIndexedLoading(true) + + tokensApi.listIndexedSafe(1, 50).then(({ ok, data }) => { + if (!active) return + setIndexedTokens(ok ? data : []) + setIndexedLoading(false) + }).catch(() => { + if (active) { + setIndexedTokens([]) + setIndexedLoading(false) + } + }) + + return () => { + active = false + } + }, []) + useEffect(() => { let active = true @@ -197,6 +231,49 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
+
+ +

+ Canonical registry tokens appear first. Non-canonical indexed duplicates are labeled so operators can distinguish curated assets from stray contract listings. +

+ {indexedLoading ? ( +

Loading indexed token feed…

+ ) : sortedIndexedTokens.length === 0 ? ( +

Indexed token feed is temporarily unavailable.

+ ) : ( +
+ {sortedIndexedTokens.map((token) => { + const canonical = isCanonicalTokenAddress(token.address, canonicalAddressSet) + return ( + +
+
+ + {token.symbol || token.name || 'Token'} + + {canonical ? ( + + ) : ( + + )} +
+

{token.name || token.address}

+
+
+ {token.holders != null ? `${token.holders.toLocaleString()} holders` : 'Holders unavailable'} +
+ + ) + })} +
+ )} +
+
+
diff --git a/frontend/src/pages/transactions/[hash].tsx b/frontend/src/pages/transactions/[hash].tsx index bfbd0af..13fd8ca 100644 --- a/frontend/src/pages/transactions/[hash].tsx +++ b/frontend/src/pages/transactions/[hash].tsx @@ -14,6 +14,7 @@ import { import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format' import { DetailRow } from '@/components/common/DetailRow' import EntityBadge from '@/components/common/EntityBadge' +import PostureBadge from '@/components/common/PostureBadge' import PageIntro from '@/components/common/PageIntro' import PaginationControls from '@/components/common/PaginationControls' import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs' @@ -228,8 +229,8 @@ export default function TransactionDetailPage() {
{transfer.token_symbol || transfer.token_name || 'Token'}
{gruPosture?.isGru ? : null} - {gruPosture?.isX402Ready ? : null} - {gruPosture?.isWrappedTransport ? : null} + {gruPosture?.isX402Ready ? : null} + {gruPosture?.isWrappedTransport ? : null}
{transfer.token_address && ( diff --git a/frontend/src/services/api/tokens.ts b/frontend/src/services/api/tokens.ts index 682092e..b998d00 100644 --- a/frontend/src/services/api/tokens.ts +++ b/frontend/src/services/api/tokens.ts @@ -7,8 +7,20 @@ import { mergeTokenListLookups, type TokenListSurface, } from './tokenListSurfaces' +import { applyTokenDisplayOverrides } from '@/utils/canonicalTokens' import type { AddressTokenTransfer } from './addresses' +export interface IndexedTokenListItem { + address: string + name?: string + symbol?: string + decimals: number + type?: string + holders?: number + exchange_rate?: string | number | null + is_canonical?: boolean +} + export interface TokenProfile { address: string name?: string @@ -76,6 +88,32 @@ function normalizeTokenProfile(raw: { } } +function normalizeIndexedToken(raw: { + address?: string | null + address_hash?: string | null + name?: string | null + symbol?: string | null + decimals?: string | number | null + type?: string | null + holders?: string | number | null + exchange_rate?: string | number | null +}): IndexedTokenListItem | null { + const address = raw.address || raw.address_hash || '' + if (!address) { + return null + } + + return applyTokenDisplayOverrides({ + address, + name: raw.name || undefined, + symbol: raw.symbol || undefined, + decimals: Number(raw.decimals || 0), + type: raw.type || undefined, + holders: raw.holders != null ? Number(raw.holders) : undefined, + exchange_rate: raw.exchange_rate ?? null, + }) +} + function computeMarketCap(totalSupply: string | undefined, decimals: number, priceUsd: number | undefined): number | null { if (!totalSupply || priceUsd == null || !Number.isFinite(priceUsd)) { return null @@ -187,8 +225,9 @@ export const tokensApi = { const aggregationToken = aggregationResult.status === 'fulfilled' && aggregationResult.value.ok ? aggregationResult.value.data : null const merged = mergeTokenProfileWithAggregation(blockscoutToken, aggregationToken) + const displayReady = merged ? applyTokenDisplayOverrides(merged) : null - return { ok: merged != null, data: merged } + return { ok: displayReady != null, data: displayReady } } catch { return { ok: false, data: null } } @@ -318,4 +357,36 @@ export const tokensApi = { return { ok: false, data: [] } } }, + + listIndexedSafe: async ( + page = 1, + pageSize = 50, + ): Promise<{ ok: boolean; data: IndexedTokenListItem[] }> => { + try { + const params = new URLSearchParams({ + page: page.toString(), + items_count: pageSize.toString(), + }) + const raw = await fetchBlockscoutJson<{ items?: Array<{ + address?: string | null + address_hash?: string | null + name?: string | null + symbol?: string | null + decimals?: string | number | null + type?: string | null + holders?: string | number | null + exchange_rate?: string | number | null + }> }>(`/api/v2/tokens?${params.toString()}`) + + const data = Array.isArray(raw.items) + ? raw.items + .map((item) => normalizeIndexedToken(item)) + .filter((item): item is IndexedTokenListItem => item != null) + : [] + + return { ok: true, data } + } catch { + return { ok: false, data: [] } + } + }, } diff --git a/frontend/src/utils/canonicalTokens.test.ts b/frontend/src/utils/canonicalTokens.test.ts new file mode 100644 index 0000000..ad6ce07 --- /dev/null +++ b/frontend/src/utils/canonicalTokens.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + applyTokenDisplayOverrides, + buildCanonicalAddressSet, + isCanonicalTokenAddress, + sortIndexedTokensCanonicalFirst, + WETH9_CANONICAL_ADDRESS, +} from './canonicalTokens' + +describe('canonicalTokens', () => { + it('builds a lowercase canonical address set', () => { + const set = buildCanonicalAddressSet([ + { chainId: 138, address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22', symbol: 'cUSDT' }, + ]) + + expect(isCanonicalTokenAddress('0x93e66202a11b1772e55407b32b44e5cd8eda7f22', set)).toBe(true) + expect(isCanonicalTokenAddress('0x0000000000000000000000000000000000000001', set)).toBe(false) + }) + + it('sorts canonical tokens before non-canonical entries', () => { + const canonicalSet = buildCanonicalAddressSet([ + { chainId: 138, address: WETH9_CANONICAL_ADDRESS, symbol: 'WETH9' }, + ]) + + const sorted = sortIndexedTokensCanonicalFirst( + [ + { address: '0x0000000000000000000000000000000000000001', symbol: 'AAA' }, + { address: WETH9_CANONICAL_ADDRESS, symbol: 'WETH9' }, + ], + canonicalSet, + ) + + expect(sorted[0]?.address).toBe(WETH9_CANONICAL_ADDRESS) + }) + + it('applies WETH9 metadata override', () => { + const token = applyTokenDisplayOverrides({ + address: WETH9_CANONICAL_ADDRESS, + name: null as unknown as string, + symbol: 'WETH', + decimals: 18, + }) + + expect(token.symbol).toBe('WETH9') + expect(token.name).toBe('Wrapped Ether (WETH9)') + }) +}) diff --git a/frontend/src/utils/canonicalTokens.ts b/frontend/src/utils/canonicalTokens.ts new file mode 100644 index 0000000..6997122 --- /dev/null +++ b/frontend/src/utils/canonicalTokens.ts @@ -0,0 +1,58 @@ +import type { TokenListToken } from '@/services/api/config' + +export const WETH9_CANONICAL_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + +export const TOKEN_DISPLAY_OVERRIDES: Record = { + [WETH9_CANONICAL_ADDRESS.toLowerCase()]: { + name: 'Wrapped Ether (WETH9)', + symbol: 'WETH9', + decimals: 18, + }, +} + +export function buildCanonicalAddressSet(tokens: TokenListToken[]): Set { + const addresses = new Set() + for (const token of tokens) { + if (typeof token.address === 'string' && token.address.trim().length > 0) { + addresses.add(token.address.toLowerCase()) + } + } + return addresses +} + +export function isCanonicalTokenAddress(address: string, canonicalSet: Set): boolean { + return canonicalSet.has(address.toLowerCase()) +} + +export function applyTokenDisplayOverrides( + token: T, +): T { + const override = TOKEN_DISPLAY_OVERRIDES[token.address.toLowerCase()] + if (!override) { + return token + } + + return { + ...token, + name: override.name || token.name, + symbol: override.symbol || token.symbol, + decimals: override.decimals ?? token.decimals, + } +} + +export function sortIndexedTokensCanonicalFirst( + tokens: T[], + canonicalSet: Set, +): T[] { + return [...tokens].sort((left, right) => { + const leftCanonical = isCanonicalTokenAddress(left.address, canonicalSet) + const rightCanonical = isCanonicalTokenAddress(right.address, canonicalSet) + if (leftCanonical !== rightCanonical) { + return leftCanonical ? -1 : 1 + } + + const leftLabel = left.symbol || left.name || left.address + const rightLabel = right.symbol || right.name || right.address + return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: 'base' }) + }) +} diff --git a/scripts/e2e-sprint-smoke.spec.ts b/scripts/e2e-sprint-smoke.spec.ts index 9647c7b..cfd08ba 100644 --- a/scripts/e2e-sprint-smoke.spec.ts +++ b/scripts/e2e-sprint-smoke.spec.ts @@ -8,6 +8,7 @@ test.describe('Explorer sprint smoke', () => { await page.goto(`${EXPLORER_URL}/`, { waitUntil: 'domcontentloaded', timeout: 20000 }) await expect(page.getByText(/Network overview/i)).toBeVisible({ timeout: 10000 }) await expect(page.getByRole('heading', { name: /Recent Transactions/i })).toBeVisible({ timeout: 10000 }) + await expect(page.getByText(/Freshness Interpretation/i).first()).toBeVisible({ timeout: 10000 }) }) test('wallet page loads', async ({ page }) => { @@ -24,6 +25,7 @@ test.describe('Explorer sprint smoke', () => { await page.goto(`${EXPLORER_URL}/tokens`, { waitUntil: 'domcontentloaded', timeout: 20000 }) await expect(page.getByRole('heading', { name: /^Tokens$/i })).toBeVisible({ timeout: 10000 }) await expect(page.getByText(/Canonical Chain 138 trading set/i).first()).toBeVisible({ timeout: 10000 }) + await expect(page.getByText(/Indexed tokens \(Blockscout\)/i).first()).toBeVisible({ timeout: 10000 }) }) test('canonical cUSDT token detail loads', async ({ page }) => { @@ -42,6 +44,13 @@ test.describe('Explorer sprint smoke', () => { await expect(page.getByRole('heading', { name: /Bridge & Relay Monitoring/i })).toBeVisible({ timeout: 15000 }) await expect(page.getByText(/CCIP route catalog/i).first()).toBeVisible({ timeout: 15000 }) await expect(page.getByText(/Wemix/i).first()).toBeVisible({ timeout: 15000 }) + await expect(page.getByText(/Bridge Freshness Context/i).first()).toBeVisible({ timeout: 10000 }) + }) + + test('posture glossary doc page loads', async ({ page }) => { + await page.goto(`${EXPLORER_URL}/docs/posture-glossary`, { waitUntil: 'domcontentloaded', timeout: 30000 }) + await expect(page.getByRole('heading', { name: /Posture glossary/i })).toBeVisible({ timeout: 15000 }) + await expect(page.getByText(/x402 readiness/i).first()).toBeVisible({ timeout: 10000 }) }) test('public API access doc page loads', async ({ page }) => { @@ -87,6 +96,7 @@ test.describe('Explorer sprint smoke', () => { await page.goto(`${EXPLORER_URL}/analytics`, { waitUntil: 'domcontentloaded', timeout: 30000 }) await expect(page.getByRole('heading', { name: /Analytics & Network Activity/i })).toBeVisible({ timeout: 15000 }) await expect(page.getByText(/Track 3 public surface/i).first()).toBeVisible({ timeout: 10000 }) + await expect(page.getByText(/Analytics Freshness Context/i).first()).toBeVisible({ timeout: 10000 }) }) test('operator page shows track 4 surface note', async ({ page }) => {