Ship Tier A Week 1–2: posture glossary, delivery mode, freshness UI, canonical tokens.
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 <cursoragent@cursor.com>
This commit is contained in:
78
backend/api/track1/bridge_mode.go
Normal file
78
backend/api/track1/bridge_mode.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
36
backend/api/track1/bridge_mode_test.go
Normal file
36
backend/api/track1/bridge_mode_test.go
Normal file
@@ -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"}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -75,6 +75,9 @@ export default function ActivityContextPanel({
|
||||
</Explain>
|
||||
</div>
|
||||
<EntityBadge label={resolveLabel(context.state)} tone={tone} />
|
||||
{context.head_is_idle && context.state === 'low' ? (
|
||||
<EntityBadge label="quiet chain" tone="info" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{compact ? (
|
||||
@@ -130,6 +133,11 @@ export default function ActivityContextPanel({
|
||||
Open last non-empty block →
|
||||
</Link>
|
||||
) : null}
|
||||
{context.block_gap_to_latest_transaction != null ? (
|
||||
<span>
|
||||
Block gap to latest visible transaction: {context.block_gap_to_latest_transaction.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
{context.latest_transaction_timestamp ? (
|
||||
<span>Latest visible transaction time: {formatTimestamp(context.latest_transaction_timestamp)}</span>
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<UiModeProvider>
|
||||
<PostureGlossaryProvider>
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<a
|
||||
href="#main-content"
|
||||
@@ -21,6 +23,7 @@ export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
<ExplorerAgentTool />
|
||||
<Footer />
|
||||
</div>
|
||||
</PostureGlossaryProvider>
|
||||
</UiModeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context, activityState)}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">{sourceLabel}</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -99,9 +118,9 @@ export default function FreshnessTrustNote({
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context, activityState)}</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{normalizeSentence(buildDetail(context, diagnosticExplanation))}.{' '}
|
||||
{normalizeSentence(buildDetail(context, diagnosticExplanation, activityState))}.{' '}
|
||||
{scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''}
|
||||
{normalizeSentence(sourceLabel)}.
|
||||
</div>
|
||||
|
||||
76
frontend/src/components/common/MissionDeliveryModePanel.tsx
Normal file
76
frontend/src/components/common/MissionDeliveryModePanel.tsx
Normal file
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<Card
|
||||
className={`border border-indigo-200 bg-indigo-50/60 dark:border-indigo-900/40 dark:bg-indigo-950/20${normalizedClassName}`}
|
||||
title={title}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={`mode ${mode.kind}`} tone={modeTone(mode.kind)} />
|
||||
{mode.updated_at ? (
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Updated {formatRelativeAge(mode.updated_at)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{mode.reason ? (
|
||||
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium text-gray-900 dark:text-white">Reason:</span> {humanizeKey(String(mode.reason))}
|
||||
</p>
|
||||
) : null}
|
||||
{mode.scope ? (
|
||||
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium text-gray-900 dark:text-white">Scope:</span> {humanizeKey(String(mode.scope))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/common/PostureBadge.tsx
Normal file
33
frontend/src/components/common/PostureBadge.tsx
Normal file
@@ -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 <EntityBadge label={label} tone={tone} className={className} />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTerm(termId)}
|
||||
title="Open posture glossary"
|
||||
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
>
|
||||
<EntityBadge label={label} tone={tone} className={className} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
82
frontend/src/components/common/PostureGlossaryProvider.tsx
Normal file
82
frontend/src/components/common/PostureGlossaryProvider.tsx
Normal file
@@ -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<PostureGlossaryContextValue | null>(null)
|
||||
|
||||
export function PostureGlossaryProvider({ children }: { children: ReactNode }) {
|
||||
const [activeTermId, setActiveTermId] = useState<PostureGlossaryTermId | null>(null)
|
||||
const activeTerm = useMemo(
|
||||
() => (activeTermId ? getPostureGlossaryTerm(activeTermId) ?? null : null),
|
||||
[activeTermId],
|
||||
)
|
||||
|
||||
const openTerm = useCallback((termId: PostureGlossaryTermId) => {
|
||||
setActiveTermId(termId)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
setActiveTermId(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PostureGlossaryContext.Provider value={{ openTerm, close }}>
|
||||
{children}
|
||||
{activeTerm ? (
|
||||
<div className="fixed inset-0 z-[80] flex items-end justify-center bg-black/45 p-4 sm:items-center" onClick={close}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="posture-glossary-title"
|
||||
className="max-h-[85vh] w-full max-w-xl overflow-y-auto rounded-2xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-950"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Posture glossary</p>
|
||||
<h2 id="posture-glossary-title" className="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{activeTerm.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-6 text-gray-700 dark:text-gray-300">{activeTerm.summary}</p>
|
||||
<div className="mt-4 rounded-xl border border-sky-200 bg-sky-50/70 p-4 text-sm leading-6 text-sky-950 dark:border-sky-900/50 dark:bg-sky-950/20 dark:text-sky-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-sky-700 dark:text-sky-300">Methodology</p>
|
||||
<p className="mt-2">{activeTerm.methodology}</p>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/docs/posture-glossary" className="font-medium text-primary-600 hover:underline" onClick={close}>
|
||||
Full glossary
|
||||
</Link>
|
||||
<Link href="/docs/gru" className="font-medium text-primary-600 hover:underline" onClick={close}>
|
||||
GRU guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</PostureGlossaryContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePostureGlossary() {
|
||||
const context = useContext(PostureGlossaryContext)
|
||||
if (!context) {
|
||||
throw new Error('usePostureGlossary must be used within PostureGlossaryProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Analytics delivery mode" />
|
||||
<SubsystemPosturePanel
|
||||
className="mt-3"
|
||||
subsystems={bridgeStatus?.data?.subsystems}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
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 } from '@/utils/explorerFreshness'
|
||||
import { bridgeRoutesApi, normalizeBridgeRouteEntries, type BridgeRoutesResponse } from '@/services/api/bridgeRoutes'
|
||||
@@ -340,6 +341,7 @@ export default function BridgeMonitoringPage({
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="Bridge relay posture is shown alongside the same explorer freshness model used on the homepage and core explorer routes"
|
||||
/>
|
||||
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Bridge delivery mode" />
|
||||
<SubsystemPosturePanel
|
||||
className="mt-3"
|
||||
subsystems={bridgeStatus?.data?.subsystems}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
@@ -796,12 +797,30 @@ export default function Home({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MissionDeliveryModePanel className="mt-1" mode={missionMode} title="Homepage delivery mode" />
|
||||
<ActivityContextPanel
|
||||
context={activityContext}
|
||||
title="Freshness Interpretation"
|
||||
compact
|
||||
/>
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel={
|
||||
mode === 'guided'
|
||||
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
|
||||
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
|
||||
}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatsDetailsExpanded((current) => !current)}
|
||||
className="text-sm font-semibold text-primary-600 hover:underline"
|
||||
>
|
||||
{statsDetailsExpanded ? 'Hide telemetry and freshness' : 'Show telemetry and freshness'}
|
||||
{statsDetailsExpanded ? 'Hide extended telemetry' : 'Show extended telemetry'}
|
||||
</button>
|
||||
|
||||
{statsDetailsExpanded ? (
|
||||
@@ -849,22 +868,6 @@ export default function Home({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ActivityContextPanel
|
||||
context={activityContext}
|
||||
title="Freshness Interpretation"
|
||||
compact
|
||||
/>
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel={
|
||||
mode === 'guided'
|
||||
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
|
||||
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
101
frontend/src/data/postureGlossary.ts
Normal file
101
frontend/src/data/postureGlossary.ts
Normal file
@@ -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<string, PostureGlossaryTermId> = {
|
||||
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)
|
||||
}
|
||||
@@ -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() {
|
||||
<span>{balance.token_symbol || balance.token_name || 'Token'}</span>
|
||||
)}
|
||||
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
|
||||
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
|
||||
{gruMetadata?.x402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruMetadata?.iso20022Ready ? <PostureBadge label="ISO-20022" tone="info" /> : null}
|
||||
</div>
|
||||
{balance.token_name && balance.token_symbol && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{balance.token_name}</div>
|
||||
@@ -430,9 +431,9 @@ export default function AddressDetailPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{transfer.token_symbol || transfer.token_name || 'Token'}</div>
|
||||
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
|
||||
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
|
||||
{gruMetadata?.transportActiveVersion ? <EntityBadge label={`cW public-network ${gruMetadata.transportActiveVersion}`} tone="warning" /> : null}
|
||||
{gruMetadata?.x402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruMetadata?.iso20022Ready ? <PostureBadge label="ISO-20022" tone="info" /> : null}
|
||||
{gruMetadata?.transportActiveVersion ? <PostureBadge label={`cW public-network ${gruMetadata.transportActiveVersion}`} tone="warning" /> : null}
|
||||
</div>
|
||||
{transfer.token_address && (
|
||||
<Link href={`/tokens/${transfer.token_address}`} className="text-primary-600 hover:underline">
|
||||
|
||||
@@ -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',
|
||||
|
||||
80
frontend/src/pages/docs/posture-glossary.tsx
Normal file
80
frontend/src/pages/docs/posture-glossary.tsx
Normal file
@@ -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 (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<PageIntro
|
||||
eyebrow="Explorer Documentation"
|
||||
title="Posture glossary"
|
||||
description="First-read explanations for institutional posture badges shown on token, address, transaction, and search surfaces."
|
||||
actions={[
|
||||
{ href: '/docs/gru', label: 'GRU guide' },
|
||||
{ href: '/tokens', label: 'Browse tokens' },
|
||||
{ href: '/search?q=cUSDC', label: 'Search cUSDC' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card title="How to use this glossary">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Click any posture badge on a live token, address, or transaction page to open the same definitions in a drawer.
|
||||
This page is the canonical long-form reference for audit and policy reviewers.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<PostureBadge label="GRU" tone="success" />
|
||||
<PostureBadge label="x402 ready" tone="info" />
|
||||
<PostureBadge label="ISO-20022" tone="info" />
|
||||
<PostureBadge label="forward canonical" tone="success" />
|
||||
<PostureBadge label="reference asset" tone="info" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{postureGlossaryTerms.map((term) => (
|
||||
<Card key={term.id} title={term.title}>
|
||||
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<p>{term.summary}</p>
|
||||
<div className="rounded-xl border border-sky-200 bg-sky-50/70 p-4 text-sky-950 dark:border-sky-900/50 dark:bg-sky-950/20 dark:text-sky-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-sky-700 dark:text-sky-300">Methodology</p>
|
||||
<p className="mt-2">{term.methodology}</p>
|
||||
</div>
|
||||
{term.id === 'transport-active' ? (
|
||||
<p>
|
||||
Planned v2 aliases: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">publicNetworkActive</code>
|
||||
{' '}and{' '}
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">livePublicNetworkAssets</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Card title="Related references">
|
||||
<ul className="list-disc space-y-2 pl-5 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<li>
|
||||
<Link href="/docs/gru" className="text-primary-600 hover:underline">
|
||||
GRU guide
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/docs/public-api-access" className="text-primary-600 hover:underline">
|
||||
Public API access
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Public machine config at{' '}
|
||||
<a href="/config/DUAL_CHAIN_TOKEN_LIST.tokenlist.json" className="text-primary-600 hover:underline">
|
||||
/config/DUAL_CHAIN_TOKEN_LIST.tokenlist.json
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 && <EntityBadge label={result.token_type} tone="warning" />}
|
||||
{result.is_curated_token && <EntityBadge label="listed" tone="success" />}
|
||||
{result.is_gru_token && <EntityBadge label="GRU" tone="success" />}
|
||||
{result.is_x402_ready && <EntityBadge label="x402 ready" tone="info" />}
|
||||
{result.is_x402_ready && <PostureBadge label="x402 ready" tone="info" />}
|
||||
{result.is_wrapped_transport && <EntityBadge label="cW public-network" tone="warning" />}
|
||||
{result.currency_code ? <EntityBadge label={result.currency_code} tone="neutral" /> : null}
|
||||
{result.match_reason ? <EntityBadge label={result.match_reason} tone="info" className="normal-case tracking-normal" /> : null}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label="GRU" tone="success" />
|
||||
{gruMetadata.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruMetadata.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
|
||||
{gruMetadata.x402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruMetadata.iso20022Ready ? <PostureBadge label="ISO-20022" tone="info" /> : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -358,6 +359,16 @@ export default function TokenDetailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{!provenance?.listed ? (
|
||||
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PostureBadge label="Non-canonical indexed token" tone="warning" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||
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.
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
<Card title="Token Overview">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Name">{token.name || provenance?.name || 'Unknown'}</DetailRow>
|
||||
@@ -451,9 +462,9 @@ export default function TokenDetailPage() {
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">x402 readiness</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<EntityBadge label={gruExplorerMetadata.x402Ready ? 'x402 ready' : 'x402 not ready'} tone={gruExplorerMetadata.x402Ready ? 'success' : 'warning'} />
|
||||
<PostureBadge label={gruExplorerMetadata.x402Ready ? 'x402 ready' : 'x402 not ready'} tone={gruExplorerMetadata.x402Ready ? 'success' : 'warning'} />
|
||||
{gruExplorerMetadata.x402PreferredVersion ? <EntityBadge label={`preferred ${gruExplorerMetadata.x402PreferredVersion}`} tone="info" /> : null}
|
||||
{gruExplorerMetadata.canonicalForwardVersion ? <EntityBadge label={`forward ${gruExplorerMetadata.canonicalForwardVersion}`} tone="success" /> : null}
|
||||
{gruExplorerMetadata.canonicalForwardVersion ? <PostureBadge label={`forward ${gruExplorerMetadata.canonicalForwardVersion}`} tone="success" /> : null}
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{gruExplorerMetadata.x402Ready
|
||||
@@ -464,7 +475,7 @@ export default function TokenDetailPage() {
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">ISO-20022 and governance</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<EntityBadge label={gruExplorerMetadata.iso20022Ready ? 'ISO-20022 aligned' : 'ISO-20022 unclear'} tone={gruExplorerMetadata.iso20022Ready ? 'success' : 'warning'} />
|
||||
<PostureBadge label={gruExplorerMetadata.iso20022Ready ? 'ISO-20022 aligned' : 'ISO-20022 unclear'} tone={gruExplorerMetadata.iso20022Ready ? 'success' : 'warning'} />
|
||||
{gruExplorerMetadata.currencyCode ? <EntityBadge label={gruExplorerMetadata.currencyCode} tone="neutral" /> : null}
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -473,6 +484,9 @@ export default function TokenDetailPage() {
|
||||
: 'The explorer does not currently have a strong ISO-20022 posture signal for this asset.'}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/docs/posture-glossary" className="text-primary-600 hover:underline">
|
||||
Posture glossary →
|
||||
</Link>
|
||||
<Link href="/docs/gru" className="text-primary-600 hover:underline">
|
||||
GRU guide →
|
||||
</Link>
|
||||
|
||||
@@ -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<TokenListToken[]>(initialCuratedTokens)
|
||||
const [indexedTokens, setIndexedTokens] = useState<IndexedTokenListItem[]>([])
|
||||
const [indexedLoading, setIndexedLoading] = useState(true)
|
||||
const [featuredMarkets, setFeaturedMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
|
||||
|
||||
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) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Card title="Indexed tokens (Blockscout)">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Canonical registry tokens appear first. Non-canonical indexed duplicates are labeled so operators can distinguish curated assets from stray contract listings.
|
||||
</p>
|
||||
{indexedLoading ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading indexed token feed…</p>
|
||||
) : sortedIndexedTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Indexed token feed is temporarily unavailable.</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{sortedIndexedTokens.map((token) => {
|
||||
const canonical = isCanonicalTokenAddress(token.address, canonicalAddressSet)
|
||||
return (
|
||||
<Link
|
||||
key={token.address}
|
||||
href={`/tokens/${token.address}`}
|
||||
className="flex flex-col gap-2 rounded-lg border border-gray-200 px-3 py-3 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{token.symbol || token.name || 'Token'}
|
||||
</span>
|
||||
{canonical ? (
|
||||
<EntityBadge label="canonical" tone="success" className="px-2 py-0.5 text-[11px]" />
|
||||
) : (
|
||||
<EntityBadge label="non-canonical indexed" tone="warning" className="px-2 py-0.5 text-[11px]" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-gray-600 dark:text-gray-400">{token.name || token.address}</p>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{token.holders != null ? `${token.holders.toLocaleString()} holders` : 'Holders unavailable'}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Card title="Common token searches">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{transfer.token_symbol || transfer.token_name || 'Token'}</div>
|
||||
{gruPosture?.isGru ? <EntityBadge label="GRU" tone="success" /> : null}
|
||||
{gruPosture?.isX402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruPosture?.isWrappedTransport ? <EntityBadge label="wrapped" tone="warning" /> : null}
|
||||
{gruPosture?.isX402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruPosture?.isWrappedTransport ? <PostureBadge label="wrapped" tone="warning" /> : null}
|
||||
</div>
|
||||
{transfer.token_address && (
|
||||
<Link href={`/tokens/${transfer.token_address}`} className="text-primary-600 hover:underline">
|
||||
|
||||
@@ -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: [] }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
47
frontend/src/utils/canonicalTokens.test.ts
Normal file
47
frontend/src/utils/canonicalTokens.test.ts
Normal file
@@ -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)')
|
||||
})
|
||||
})
|
||||
58
frontend/src/utils/canonicalTokens.ts
Normal file
58
frontend/src/utils/canonicalTokens.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { TokenListToken } from '@/services/api/config'
|
||||
|
||||
export const WETH9_CANONICAL_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
|
||||
|
||||
export const TOKEN_DISPLAY_OVERRIDES: Record<string, { name?: string; symbol?: string; decimals?: number }> = {
|
||||
[WETH9_CANONICAL_ADDRESS.toLowerCase()]: {
|
||||
name: 'Wrapped Ether (WETH9)',
|
||||
symbol: 'WETH9',
|
||||
decimals: 18,
|
||||
},
|
||||
}
|
||||
|
||||
export function buildCanonicalAddressSet(tokens: TokenListToken[]): Set<string> {
|
||||
const addresses = new Set<string>()
|
||||
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<string>): boolean {
|
||||
return canonicalSet.has(address.toLowerCase())
|
||||
}
|
||||
|
||||
export function applyTokenDisplayOverrides<T extends { address: string; name?: string; symbol?: string; decimals?: number }>(
|
||||
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<T extends { address: string; symbol?: string; name?: string }>(
|
||||
tokens: T[],
|
||||
canonicalSet: Set<string>,
|
||||
): 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' })
|
||||
})
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user