File size: 8,260 Bytes
88d2f2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export function shortAddr(addr?: string | null, head = 6, tail = 4): string {
  if (!addr) return "β€”";
  if (addr.length <= head + tail + 2) return addr;
  return `${addr.slice(0, head)}…${addr.slice(-tail)}`;
}

/**
 * Format a reputation score for display.
 *
 * Backend stores reputation as a raw decimal in [0, 1]. The UI surfaces it
 * uniformly as a whole-number percent (e.g. ``0.85`` β†’ ``"85%"``) so the
 * leaderboard win-rate column and the bid/operator rep columns share one
 * convention. Pass ``rawDecimal=true`` only when the surrounding text is a
 * formula (e.g. ``max(reputation, 1.0)`` in the auction explainer) where the
 * 0–1 scale is mathematically required.
 *
 * NOTE (W14-D): The on-chain `ReputationRegistry.sol` EMA has a known unit-
 * scale bug β€” `_fillSignal` stays pinned at the 0.5 floor for any realistic
 * fee, so this value is currently *not* informative as a primary UX metric.
 * Operator/leaderboard surfaces have switched to `formatWinsBids()` as the
 * primary display; this helper still drives the auction-explainer formula
 * and any "advanced / on-chain raw" detail panels.
 */
export function formatReputation(
  value: number | null | undefined,
  options?: { rawDecimal?: boolean },
): string {
  if (value === null || value === undefined || Number.isNaN(value)) return "β€”";
  if (options?.rawDecimal) return value.toFixed(2);
  return `${Math.round(value * 100)}%`;
}

/**
 * Format a wins-over-bids ratio for display (W14-D primary UX metric).
 *
 * Returns a string like ``"12/47 Β· 26%"`` for `wins=12, totalBids=47`.
 * Falls back to ``"β€”"`` when either input is missing/NaN. When totalBids
 * is 0 we render ``"0/0"`` without a percent (no auctions entered).
 *
 * This is the demo-safe replacement for the on-chain EMA reputation badge:
 * the EMA suffers from a known `_fillSignal` unit-scale bug in
 * `ReputationRegistry.sol` (stuck at the 0.5 floor), so we lead with the
 * unambiguous, off-chain wins/bids count and relegate the raw EMA to an
 * "advanced" detail row with an explainer tooltip.
 */
export function formatWinsBids(
  wins: number | null | undefined,
  totalBids: number | null | undefined,
): string {
  if (
    wins === null ||
    wins === undefined ||
    Number.isNaN(wins) ||
    totalBids === null ||
    totalBids === undefined ||
    Number.isNaN(totalBids)
  ) {
    return "β€”";
  }
  if (totalBids === 0) return "0/0";
  const pct = Math.round((wins / totalBids) * 100);
  return `${wins}/${totalBids} Β· ${pct}%`;
}

export function formatUsd(value: number | null | undefined, fractionDigits = 2): string {
  if (value === null || value === undefined || Number.isNaN(value)) return "β€”";
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: fractionDigits,
    maximumFractionDigits: fractionDigits,
  }).format(value);
}

export function formatNumber(value: number | null | undefined, fractionDigits = 2): string {
  if (value === null || value === undefined || Number.isNaN(value)) return "β€”";
  return new Intl.NumberFormat("en-US", {
    minimumFractionDigits: fractionDigits,
    maximumFractionDigits: fractionDigits,
  }).format(value);
}

/**
 * Determine whether a raw `anchor.ipfsCid` / pipeline_trace_ipfs / reasoning_ipfs
 * value is a *real* IPFS content identifier we can route through a public gateway,
 * vs. a synthetic/mock provenance path emitted by the backend (e.g.
 * `ipfs://pipeline/qwen/59fac6348e57`). The latter must NOT render as a clickable
 * gateway link because the gateway will 404.
 *
 * Real CIDs:
 *   - v0:  `Qm` + 44 base58 chars
 *   - v1:  `bafy` + 55 base32 chars (most common; other multibase prefixes exist
 *          but in this codebase the live anchor flow emits `bafy…`)
 *
 * Anything else β€” including `ipfs://pipeline/...`, `ipfs://mock/...`,
 * `ipfs://synthetic/...`, or a bare placeholder string β€” should be displayed as
 * a muted, non-clickable provenance label.
 *
 * Returns `{ cid, isReal, gatewayUrl }` where:
 *   - `cid`        is the input with any `ipfs://` scheme prefix stripped
 *   - `isReal`     true if `cid` matches the v0/v1 CID shape above
 *   - `gatewayUrl` populated only when `isReal` is true
 */
export function classifyIpfsRef(raw?: string | null): {
  cid: string;
  isReal: boolean;
  gatewayUrl?: string;
} | null {
  if (!raw) return null;
  const cid = raw.replace(/^ipfs:\/\//, "");
  if (!cid) return null;
  const v0 = /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/;
  const v1 = /^bafy[a-zA-Z0-9]{55,}$/;
  // A bare CID has no path separators. Synthetic refs like
  // `pipeline/qwen/abc` contain slashes and must be rejected.
  const isReal = !cid.includes("/") && (v0.test(cid) || v1.test(cid));
  return {
    cid,
    isReal,
    gatewayUrl: isReal ? `https://ipfs.io/ipfs/${cid}` : undefined,
  };
}

/**
 * Detect a synthetic ("sim-prefix") Arc tx hash emitted by the backend in
 * mock mode (W5-A2). Real Arc testnet tx hashes are `0x` + 64 hex chars;
 * synthetic mock hashes always begin with the literal prefix `0xsim_`.
 *
 * The UI uses this gate at every external-explorer link site so a synthetic
 * hash is rendered as muted, non-clickable text rather than wrapped in an
 * `https://testnet.arcscan.app/tx/0xsim_…` link that would 404.
 */
export function isSimTxHash(h: string | null | undefined): boolean {
  return typeof h === "string" && h.toLowerCase().startsWith("0xsim_");
}

/**
 * Detect a synthetic Polymarket market_id emitted by the backend in mock /
 * dry-run mode. The backend uses two prefixes: `sim-` for fully simulated
 * markets and `dryrun-` for dry-run submissions. Either prefix means the
 * market does not exist on polymarket.com and the UI MUST NOT wrap it in an
 * external link.
 */
export function isSimPolymarketId(id: string | null | undefined): boolean {
  if (typeof id !== "string") return false;
  const lower = id.toLowerCase();
  return lower.startsWith("sim-") || lower.startsWith("dryrun-");
}

/**
 * Build a Polymarket market URL when, and only when, the supplied id is a
 * real (non-sim) market_id. Returns `null` for sim / dry-run ids so callers
 * can fall back to a muted, non-clickable text label.
 */
export function polymarketMarketUrl(id: string | null | undefined): string | null {
  if (!id || isSimPolymarketId(id)) return null;
  return `https://polymarket.com/market/${id}`;
}

/**
 * Validate a `market_url` field returned by the backend. The backend may
 * synthesise a `polymarket.com/market/sim-...` URL in mock mode; we don't
 * trust it blindly. Returns the URL only when it's a real polymarket URL
 * (i.e. the market_id segment does NOT start with `sim-` / `dryrun-`); for
 * any sim-prefixed URL, returns `null` so the UI renders muted text.
 */
export function safePolymarketUrl(url: string | null | undefined): string | null {
  if (typeof url !== "string" || !url) return null;
  const match = url.match(/\/market\/([^/?#]+)/);
  if (match && isSimPolymarketId(match[1])) return null;
  return url;
}

/**
 * Build an Arc testnet explorer URL for a tx hash when, and only when, the
 * hash is a *real* on-chain hash. Synthetic `0xsim_…` hashes return `null`
 * so callers can render a muted span instead of a broken external link.
 *
 * The base URL is the canonical Arc testnet explorer used by `arcTxUrl()`
 * in `ui/lib/api.ts`; we don't import from there to avoid a cycle and to
 * keep this helper self-contained for unit testing.
 */
export function arcscanTxUrl(h: string | null | undefined): string | null {
  if (!h || isSimTxHash(h)) return null;
  return `https://testnet.arcscan.app/tx/${h}`;
}

export function relativeTime(iso?: string | null): string {
  if (!iso) return "β€”";
  const then = new Date(iso).getTime();
  if (Number.isNaN(then)) return iso;
  const diffSec = Math.round((Date.now() - then) / 1000);
  if (diffSec < 60) return `${diffSec}s ago`;
  if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
  if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
  return `${Math.floor(diffSec / 86400)}d ago`;
}