@decentrys/ui-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/browser/decentrys-ui.js +1733 -0
  4. package/dist/browser/decentrys-ui.mjs +1681 -0
  5. package/dist/browser/react-dom-global.d.ts +6 -0
  6. package/dist/browser/react-dom-global.d.ts.map +1 -0
  7. package/dist/browser/react-dom-global.js +18 -0
  8. package/dist/browser/react-dom-global.js.map +1 -0
  9. package/dist/browser/react-global.d.ts +25 -0
  10. package/dist/browser/react-global.d.ts.map +1 -0
  11. package/dist/browser/react-global.js +39 -0
  12. package/dist/browser/react-global.js.map +1 -0
  13. package/dist/browser/react-jsx-runtime.d.ts +17 -0
  14. package/dist/browser/react-jsx-runtime.d.ts.map +1 -0
  15. package/dist/browser/react-jsx-runtime.js +27 -0
  16. package/dist/browser/react-jsx-runtime.js.map +1 -0
  17. package/dist/components/AddressRiskBadge.d.ts +39 -0
  18. package/dist/components/AddressRiskBadge.d.ts.map +1 -0
  19. package/dist/components/AddressRiskBadge.js +26 -0
  20. package/dist/components/AddressRiskBadge.js.map +1 -0
  21. package/dist/components/ApprovalWarning.d.ts +29 -0
  22. package/dist/components/ApprovalWarning.d.ts.map +1 -0
  23. package/dist/components/ApprovalWarning.js +18 -0
  24. package/dist/components/ApprovalWarning.js.map +1 -0
  25. package/dist/components/ContractFacts.d.ts +27 -0
  26. package/dist/components/ContractFacts.d.ts.map +1 -0
  27. package/dist/components/ContractFacts.js +15 -0
  28. package/dist/components/ContractFacts.js.map +1 -0
  29. package/dist/components/RiskDetailsModal.d.ts +35 -0
  30. package/dist/components/RiskDetailsModal.d.ts.map +1 -0
  31. package/dist/components/RiskDetailsModal.js +74 -0
  32. package/dist/components/RiskDetailsModal.js.map +1 -0
  33. package/dist/components/ThreatSignalList.d.ts +28 -0
  34. package/dist/components/ThreatSignalList.d.ts.map +1 -0
  35. package/dist/components/ThreatSignalList.js +28 -0
  36. package/dist/components/ThreatSignalList.js.map +1 -0
  37. package/dist/components/TokenSecurityPanel.d.ts +28 -0
  38. package/dist/components/TokenSecurityPanel.d.ts.map +1 -0
  39. package/dist/components/TokenSecurityPanel.js +26 -0
  40. package/dist/components/TokenSecurityPanel.js.map +1 -0
  41. package/dist/components/TransactionRiskBanner.d.ts +36 -0
  42. package/dist/components/TransactionRiskBanner.d.ts.map +1 -0
  43. package/dist/components/TransactionRiskBanner.js +20 -0
  44. package/dist/components/TransactionRiskBanner.js.map +1 -0
  45. package/dist/context.d.ts +51 -0
  46. package/dist/context.d.ts.map +1 -0
  47. package/dist/context.js +47 -0
  48. package/dist/context.js.map +1 -0
  49. package/dist/copy.d.ts +72 -0
  50. package/dist/copy.d.ts.map +1 -0
  51. package/dist/copy.js +77 -0
  52. package/dist/copy.js.map +1 -0
  53. package/dist/index.d.ts +57 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +107 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/presentation.d.ts +316 -0
  58. package/dist/presentation.d.ts.map +1 -0
  59. package/dist/presentation.js +533 -0
  60. package/dist/presentation.js.map +1 -0
  61. package/dist/primitives.d.ts +139 -0
  62. package/dist/primitives.d.ts.map +1 -0
  63. package/dist/primitives.js +150 -0
  64. package/dist/primitives.js.map +1 -0
  65. package/dist/styles.d.ts +15 -0
  66. package/dist/styles.d.ts.map +1 -0
  67. package/dist/styles.js +285 -0
  68. package/dist/styles.js.map +1 -0
  69. package/dist/theme.d.ts +52 -0
  70. package/dist/theme.d.ts.map +1 -0
  71. package/dist/theme.js +90 -0
  72. package/dist/theme.js.map +1 -0
  73. package/package.json +70 -0
  74. package/src/browser/react-dom-global.ts +20 -0
  75. package/src/browser/react-global.ts +42 -0
  76. package/src/browser/react-jsx-runtime.ts +23 -0
  77. package/src/components/AddressRiskBadge.tsx +94 -0
  78. package/src/components/ApprovalWarning.tsx +144 -0
  79. package/src/components/ContractFacts.tsx +119 -0
  80. package/src/components/RiskDetailsModal.tsx +229 -0
  81. package/src/components/ThreatSignalList.tsx +76 -0
  82. package/src/components/TokenSecurityPanel.tsx +168 -0
  83. package/src/components/TransactionRiskBanner.tsx +123 -0
  84. package/src/context.tsx +89 -0
  85. package/src/copy.test.ts +214 -0
  86. package/src/copy.ts +151 -0
  87. package/src/index.ts +84 -0
  88. package/src/presentation.test.ts +521 -0
  89. package/src/presentation.ts +846 -0
  90. package/src/primitives.tsx +384 -0
  91. package/src/styles.ts +281 -0
  92. package/src/theme.test.ts +105 -0
  93. package/src/theme.ts +117 -0
@@ -0,0 +1,846 @@
1
+ /**
2
+ * Where the risk model becomes words a person reads.
3
+ *
4
+ * This file is pure. It takes an `Assessment` and returns descriptions —
5
+ * labels, tones, statements, aria strings — and renders nothing. That split is
6
+ * deliberate: the rule
7
+ *
8
+ * Unknown is neutral. Risk requires evidence. Malicious requires stronger
9
+ * evidence. Users should be shown why.
10
+ *
11
+ * is broken in *presentation*, not in markup. A component that paints
12
+ * "deployed 3 days ago" the same amber as "attributed to a drainer family" has
13
+ * defeated the entire model while passing every classifier test. So the
14
+ * decisions that could do that live here, in functions a test can call
15
+ * directly, rather than being spread across seven components where they can
16
+ * only be checked by eye.
17
+ *
18
+ * Four load-bearing choices:
19
+ *
20
+ * - **Kinds have their own tone families.** `fact`, `capability` and
21
+ * `unknown` are not points on the risk scale. They cannot be rendered in a
22
+ * severity colour because they are never handed one.
23
+ * - **`HistoryPresentation.tone` is the literal type `'neutral'` and
24
+ * `isWarning` is the literal type `false`.** Limited history being drawn as
25
+ * a warning is not a bug this codebase can have; it does not compile.
26
+ * - **A signal that did not raise the level is toned `inactive`.** A stale or
27
+ * low-confidence signal is still shown — hiding an observation is its own
28
+ * failure — but it is not shown in the colour of one that counted.
29
+ * - **Anything above zero hops says so in a sentence**, not just a badge,
30
+ * because a hop is an inference about a counterparty and a badge reading
31
+ * "2" does not tell anyone that.
32
+ */
33
+
34
+ import {
35
+ HISTORY_STATUS_MEANING,
36
+ MIN_RAISING_CONFIDENCE,
37
+ RISK_LEVEL_MEANING,
38
+ type Assessment,
39
+ type CapabilitySeverity,
40
+ type Evidence,
41
+ type HistoryStatus,
42
+ type ObservedFact,
43
+ type PolicyAction,
44
+ type RiskComponents,
45
+ type RiskLevel,
46
+ type SignalStatus,
47
+ type TechnicalCapability,
48
+ type ThreatSeverity,
49
+ type ThreatSignal,
50
+ type UnknownField,
51
+ type UnknownReason,
52
+ } from '@decentrys/protect';
53
+ import type { Tone } from './theme';
54
+ import type { UiCopy } from './copy';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Risk levels
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * The labels already published to customers on the marketing site.
62
+ *
63
+ * They are copied rather than invented so that a user who read the pricing
64
+ * page and a user looking at a wallet see the same seven words. Overridable
65
+ * per component for translation; the meanings come from the model itself.
66
+ */
67
+ export const RISK_LEVEL_LABEL: Record<RiskLevel, string> = {
68
+ NO_CRITICAL_RISK_DETECTED: 'No critical risk detected',
69
+ INFORMATIONAL: 'Informational',
70
+ CAUTION: 'Caution',
71
+ ELEVATED_RISK: 'Elevated risk',
72
+ HIGH_RISK: 'High risk',
73
+ CRITICAL_THREAT: 'Critical threat',
74
+ KNOWN_MALICIOUS: 'Known malicious',
75
+ };
76
+
77
+ /**
78
+ * A colour-independent ordinal marker.
79
+ *
80
+ * Colour must never be the only thing separating these levels — for a
81
+ * colour-blind user it would be the only thing, and then "informational" and
82
+ * "critical threat" are the same badge. The glyph escalates monotonically and
83
+ * `KNOWN_MALICIOUS`, the one level that accuses, gets a different mark
84
+ * entirely rather than one more exclamation point.
85
+ */
86
+ const LEVEL_GLYPH: Record<RiskLevel, string> = {
87
+ NO_CRITICAL_RISK_DETECTED: '·',
88
+ INFORMATIONAL: 'i',
89
+ CAUTION: '!',
90
+ ELEVATED_RISK: '!!',
91
+ HIGH_RISK: '!!!',
92
+ CRITICAL_THREAT: '!!!!',
93
+ KNOWN_MALICIOUS: '✕',
94
+ };
95
+
96
+ const LEVEL_TONE: Record<RiskLevel, Tone> = {
97
+ // Neither of these two may look like a warning. Nothing in them indicates
98
+ // danger, and a new project landing on INFORMATIONAL is the common case.
99
+ NO_CRITICAL_RISK_DETECTED: 'neutral',
100
+ INFORMATIONAL: 'info',
101
+ CAUTION: 'caution',
102
+ ELEVATED_RISK: 'elevated',
103
+ HIGH_RISK: 'high',
104
+ CRITICAL_THREAT: 'critical',
105
+ KNOWN_MALICIOUS: 'critical',
106
+ };
107
+
108
+ const LEVEL_ORDER: RiskLevel[] = [
109
+ 'NO_CRITICAL_RISK_DETECTED',
110
+ 'INFORMATIONAL',
111
+ 'CAUTION',
112
+ 'ELEVATED_RISK',
113
+ 'HIGH_RISK',
114
+ 'CRITICAL_THREAT',
115
+ 'KNOWN_MALICIOUS',
116
+ ];
117
+
118
+ export function levelRank(level: RiskLevel): number {
119
+ return LEVEL_ORDER.indexOf(level);
120
+ }
121
+
122
+ export interface LevelPresentation {
123
+ level: RiskLevel;
124
+ label: string;
125
+ /** The model's own words for what this level is allowed to mean. */
126
+ meaning: string;
127
+ tone: Tone;
128
+ glyph: string;
129
+ /**
130
+ * True from ELEVATED_RISK upward — the levels the classifier can only reach
131
+ * with threat-signal evidence. Drives whether a banner interrupts a screen
132
+ * reader, which nothing below that threshold has earned.
133
+ */
134
+ evidenceBacked: boolean;
135
+ /** Only KNOWN_MALICIOUS. The one output that accuses a third party. */
136
+ accusatory: boolean;
137
+ liveRegionRole: 'status' | 'alert';
138
+ ariaLabel: string;
139
+ }
140
+
141
+ export function levelPresentation(
142
+ level: RiskLevel,
143
+ options: { labels?: Partial<Record<RiskLevel, string>> } = {},
144
+ ): LevelPresentation {
145
+ const label = options.labels?.[level] ?? RISK_LEVEL_LABEL[level];
146
+ const meaning = RISK_LEVEL_MEANING[level];
147
+ const evidenceBacked = levelRank(level) >= levelRank('ELEVATED_RISK');
148
+ return {
149
+ level,
150
+ label,
151
+ meaning,
152
+ tone: LEVEL_TONE[level],
153
+ glyph: LEVEL_GLYPH[level],
154
+ evidenceBacked,
155
+ accusatory: level === 'KNOWN_MALICIOUS',
156
+ liveRegionRole: evidenceBacked ? 'alert' : 'status',
157
+ ariaLabel: `Decentrys assessment: ${label}. ${meaning}`,
158
+ };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Kinds — the separation that keeps a fact from reading as a threat
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export type EvidenceKind = 'fact' | 'capability' | 'signal' | 'unknown';
166
+
167
+ export interface KindPresentation {
168
+ kind: EvidenceKind;
169
+ /** Rendered as text on every row. Meaning never rests on colour alone. */
170
+ label: string;
171
+ /** A distinct *shape* per kind, legible without colour vision. */
172
+ glyph: string;
173
+ tone: Tone;
174
+ /** A second non-colour carrier: each kind has its own left-rule style. */
175
+ rule: 'solid' | 'dashed' | 'dotted';
176
+ ruleWidth: string;
177
+ }
178
+
179
+ export function kindPresentation(kind: EvidenceKind, copy: UiCopy): KindPresentation {
180
+ switch (kind) {
181
+ case 'fact':
182
+ return { kind, label: copy.factLabel, glyph: '·', tone: 'fact', rule: 'solid', ruleWidth: '2px' };
183
+ case 'capability':
184
+ return { kind, label: copy.capabilityLabel, glyph: '◇', tone: 'capability', rule: 'dashed', ruleWidth: '2px' };
185
+ case 'signal':
186
+ return { kind, label: copy.signalLabel, glyph: '▲', tone: 'critical', rule: 'solid', ruleWidth: '4px' };
187
+ case 'unknown':
188
+ return { kind, label: copy.unknownLabel, glyph: '?', tone: 'unknown', rule: 'dotted', ruleWidth: '2px' };
189
+ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Facts
194
+ // ---------------------------------------------------------------------------
195
+
196
+ export interface FactPresentation {
197
+ kind: 'fact';
198
+ statement: string;
199
+ source: string;
200
+ observedAt: string;
201
+ /** Always the fact tone. A fact has no severity to render. */
202
+ tone: 'fact';
203
+ }
204
+
205
+ export function factPresentation(fact: ObservedFact): FactPresentation {
206
+ return {
207
+ kind: 'fact',
208
+ statement: fact.statement,
209
+ source: fact.source,
210
+ observedAt: formatTimestamp(fact.observedAt),
211
+ tone: 'fact',
212
+ };
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Capabilities
217
+ // ---------------------------------------------------------------------------
218
+
219
+ const CAPABILITY_SEVERITY_LABEL: Record<CapabilitySeverity, string> = {
220
+ INFO: 'Standard',
221
+ NOTABLE: 'Notable',
222
+ SIGNIFICANT: 'Significant',
223
+ };
224
+
225
+ export interface CapabilityPresentation {
226
+ kind: 'capability';
227
+ statement: string;
228
+ severity: CapabilitySeverity;
229
+ severityLabel: string;
230
+ grantedBy?: string;
231
+ /**
232
+ * Always the capability tone, at every severity.
233
+ *
234
+ * A significant capability is emphasised with weight, not recoloured into
235
+ * the threat palette. "The owner can mint additional supply" is a true
236
+ * statement about code; painting it the same red as an analyst-verified
237
+ * drainer match tells a user the two are the same kind of thing.
238
+ */
239
+ tone: 'capability';
240
+ emphasis: 'normal' | 'strong';
241
+ }
242
+
243
+ export function capabilityPresentation(capability: TechnicalCapability): CapabilityPresentation {
244
+ return {
245
+ kind: 'capability',
246
+ statement: capability.statement,
247
+ severity: capability.severity,
248
+ severityLabel: CAPABILITY_SEVERITY_LABEL[capability.severity],
249
+ grantedBy: capability.grantedBy,
250
+ tone: 'capability',
251
+ emphasis: capability.severity === 'SIGNIFICANT' ? 'strong' : 'normal',
252
+ };
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Threat signals
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Re-exported from the classifier rather than redeclared.
261
+ *
262
+ * This is the floor the model itself uses to decide whether a signal raised
263
+ * the level. A second copy here that drifted would show a signal as "did not
264
+ * count" while it was in fact counting.
265
+ */
266
+ export { MIN_RAISING_CONFIDENCE };
267
+
268
+
269
+ const SEVERITY_LABEL: Record<ThreatSeverity, string> = {
270
+ LOW: 'Low',
271
+ MEDIUM: 'Medium',
272
+ HIGH: 'High',
273
+ CRITICAL: 'Critical',
274
+ };
275
+
276
+ const SEVERITY_TONE: Record<ThreatSeverity, Tone> = {
277
+ LOW: 'caution',
278
+ MEDIUM: 'elevated',
279
+ HIGH: 'high',
280
+ CRITICAL: 'critical',
281
+ };
282
+
283
+ const SIGNAL_STATUS_LABEL: Record<SignalStatus, string> = {
284
+ ACTIVE: 'Active',
285
+ STALE: 'Stale',
286
+ RESOLVED: 'Resolved',
287
+ DISPUTED_FACT: 'Disputed',
288
+ REMOVED: 'Withdrawn',
289
+ };
290
+
291
+ export interface SignalPresentation {
292
+ kind: 'signal';
293
+ explanation: string;
294
+ severity: ThreatSeverity;
295
+ severityLabel: string;
296
+ /**
297
+ * The severity tone only while the signal is actually counting. A stale or
298
+ * low-confidence signal is toned `inactive`, because showing it in critical
299
+ * red would tell a user something contributed to the assessment when the
300
+ * classifier explicitly excluded it.
301
+ */
302
+ tone: Tone;
303
+ status: SignalStatus;
304
+ statusLabel: string;
305
+ active: boolean;
306
+ raising: boolean;
307
+ /** Present whenever `raising` is false. Never left to inference. */
308
+ inertStatement?: string;
309
+ hops: number;
310
+ hopLabel: string;
311
+ hopStatement: string;
312
+ /** Any hop above zero is an inference about a counterparty. */
313
+ indirect: boolean;
314
+ confidence: number;
315
+ confidenceLabel: string;
316
+ analystConfirmed: boolean;
317
+ attribution: string;
318
+ evidenceCount: number;
319
+ ariaLabel: string;
320
+ }
321
+
322
+ export function signalPresentation(
323
+ signal: ThreatSignal,
324
+ copy: UiCopy,
325
+ options: { subjectNoun?: string } = {},
326
+ ): SignalPresentation {
327
+ const subject = options.subjectNoun ?? 'address';
328
+ const active = signal.status === 'ACTIVE';
329
+ const raising = active && signal.confidence >= MIN_RAISING_CONFIDENCE;
330
+ const statusLabel = SIGNAL_STATUS_LABEL[signal.status];
331
+ const analystConfirmed = signal.evidence.some((e) => e.analystVerified);
332
+
333
+ let inertStatement: string | undefined;
334
+ if (!active) {
335
+ inertStatement = `This signal is ${statusLabel.toLowerCase()} and did not affect this assessment.`;
336
+ } else if (!raising) {
337
+ inertStatement =
338
+ `Reported at ${formatConfidence(signal.confidence)} confidence, which is too low to raise the risk `
339
+ + 'level on its own. It is shown because it was observed, not because it counted.';
340
+ }
341
+
342
+ const hop = hopPresentation(signal.hops, subject);
343
+
344
+ return {
345
+ kind: 'signal',
346
+ explanation: signal.explanation,
347
+ severity: signal.severity,
348
+ severityLabel: SEVERITY_LABEL[signal.severity],
349
+ tone: raising ? SEVERITY_TONE[signal.severity] : 'inactive',
350
+ status: signal.status,
351
+ statusLabel,
352
+ active,
353
+ raising,
354
+ inertStatement,
355
+ hops: signal.hops,
356
+ hopLabel: hop.label,
357
+ hopStatement: hop.statement,
358
+ indirect: hop.indirect,
359
+ confidence: signal.confidence,
360
+ confidenceLabel: formatConfidence(signal.confidence),
361
+ analystConfirmed,
362
+ attribution: analystConfirmed ? copy.analystConfirmed : copy.notAnalystConfirmed,
363
+ evidenceCount: signal.evidence.length,
364
+ ariaLabel:
365
+ `${copy.signalLabel}, ${SEVERITY_LABEL[signal.severity].toLowerCase()} severity, `
366
+ + `${formatConfidence(signal.confidence)} confidence, ${hop.label.toLowerCase()}. ${signal.explanation}`,
367
+ };
368
+ }
369
+
370
+ export interface HopPresentation {
371
+ hops: number;
372
+ label: string;
373
+ statement: string;
374
+ indirect: boolean;
375
+ }
376
+
377
+ /**
378
+ * Hop distance, in a sentence.
379
+ *
380
+ * Zero hops is an observation about the subject. Everything else is an
381
+ * observation about somebody the subject transacted with, and the further out
382
+ * it goes the weaker the inference — a badge reading "3" communicates none of
383
+ * that, so the wording carries it instead.
384
+ */
385
+ export function hopPresentation(hops: number, subjectNoun = 'address'): HopPresentation {
386
+ const n = Math.max(0, Math.trunc(hops));
387
+ if (n === 0) {
388
+ return {
389
+ hops: 0,
390
+ label: 'Direct',
391
+ statement: `Observed directly on this ${subjectNoun}.`,
392
+ indirect: false,
393
+ };
394
+ }
395
+ if (n === 1) {
396
+ return {
397
+ hops: 1,
398
+ label: '1 hop',
399
+ statement:
400
+ `Observed one hop away. This is an inference about a counterparty of this ${subjectNoun}, `
401
+ + `not an observation about the ${subjectNoun} itself.`,
402
+ indirect: true,
403
+ };
404
+ }
405
+ return {
406
+ hops: n,
407
+ label: `${n} hops`,
408
+ statement:
409
+ `Observed ${n} hops away. This is an inference about a counterparty ${n} steps removed from this `
410
+ + `${subjectNoun}, not an observation about the ${subjectNoun} itself.`,
411
+ indirect: true,
412
+ };
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Evidence
417
+ // ---------------------------------------------------------------------------
418
+
419
+ export interface EvidencePresentation {
420
+ id: string;
421
+ type: string;
422
+ source: string;
423
+ observedAt: string;
424
+ confidenceLabel: string;
425
+ analystConfirmed: boolean;
426
+ attribution: string;
427
+ reference?: string;
428
+ }
429
+
430
+ export function evidencePresentation(evidence: Evidence, copy: UiCopy): EvidencePresentation {
431
+ return {
432
+ id: evidence.id,
433
+ type: evidence.type,
434
+ source: evidence.source,
435
+ observedAt: formatTimestamp(evidence.observedAt),
436
+ confidenceLabel: formatConfidence(evidence.confidence),
437
+ analystConfirmed: evidence.analystVerified,
438
+ attribution: evidence.analystVerified ? copy.analystConfirmed : copy.notAnalystConfirmed,
439
+ reference: evidence.txHash ?? evidence.contract ?? evidence.address,
440
+ };
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Unknowns
445
+ // ---------------------------------------------------------------------------
446
+
447
+ const UNKNOWN_REASON_LABEL: Record<UnknownReason, string> = {
448
+ UNKNOWN: 'Not known',
449
+ INSUFFICIENT_DATA: 'Not enough data',
450
+ PROVIDER_UNAVAILABLE: 'Could not be checked',
451
+ NOT_APPLICABLE: 'Not applicable',
452
+ };
453
+
454
+ export interface UnknownPresentation {
455
+ kind: 'unknown';
456
+ field: string;
457
+ statement: string;
458
+ reasonLabel: string;
459
+ /** Always the unknown tone. Unknown is neutral; it is not a finding. */
460
+ tone: 'unknown';
461
+ }
462
+
463
+ export function unknownPresentation(unknown: UnknownField): UnknownPresentation {
464
+ return {
465
+ kind: 'unknown',
466
+ field: unknown.field,
467
+ statement: unknown.statement,
468
+ reasonLabel: UNKNOWN_REASON_LABEL[unknown.reason],
469
+ tone: 'unknown',
470
+ };
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // History
475
+ // ---------------------------------------------------------------------------
476
+
477
+ const HISTORY_LABEL: Record<HistoryStatus, string> = {
478
+ ESTABLISHED: 'Established history',
479
+ MODERATE: 'Some history',
480
+ LIMITED: 'Limited history',
481
+ NONE: 'No history',
482
+ };
483
+
484
+ /**
485
+ * Note the literal types on `tone` and `isWarning`.
486
+ *
487
+ * `historyStatus: 'LIMITED'` is the correct, expected state for anything
488
+ * recently deployed, and the single most likely place for this product to
489
+ * betray its own rule. Typing the fields as literals means a future component
490
+ * cannot render history as a warning by mistake — the attempt does not
491
+ * typecheck, which is a stronger guarantee than a comment asking nicely.
492
+ */
493
+ export interface HistoryPresentation {
494
+ status: HistoryStatus;
495
+ label: string;
496
+ statement: string;
497
+ tone: 'neutral';
498
+ isWarning: false;
499
+ }
500
+
501
+ export function historyPresentation(status: HistoryStatus): HistoryPresentation {
502
+ return {
503
+ status,
504
+ label: HISTORY_LABEL[status],
505
+ statement: HISTORY_STATUS_MEANING[status],
506
+ tone: 'neutral',
507
+ isWarning: false,
508
+ };
509
+ }
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // Score components
513
+ // ---------------------------------------------------------------------------
514
+
515
+ export interface ComponentRow {
516
+ key: keyof RiskComponents;
517
+ label: string;
518
+ value: number;
519
+ /** True for `historyConfidence`, which is coverage rather than danger. */
520
+ isCoverage: boolean;
521
+ caveat?: string;
522
+ }
523
+
524
+ export function componentRows(components: RiskComponents, copy: UiCopy): ComponentRow[] {
525
+ return [
526
+ { key: 'technicalRisk', label: 'Technical', value: components.technicalRisk, isCoverage: false },
527
+ { key: 'behavioralRisk', label: 'Behavioural', value: components.behavioralRisk, isCoverage: false },
528
+ {
529
+ key: 'threatIntelligenceRisk',
530
+ label: 'Threat intelligence',
531
+ value: components.threatIntelligenceRisk,
532
+ isCoverage: false,
533
+ },
534
+ {
535
+ key: 'historyConfidence',
536
+ label: 'Coverage',
537
+ value: components.historyConfidence,
538
+ isCoverage: true,
539
+ caveat: copy.coverageIsNotRisk,
540
+ },
541
+ ];
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Sections
546
+ // ---------------------------------------------------------------------------
547
+
548
+ export interface AssessmentSections {
549
+ facts: ObservedFact[];
550
+ capabilities: TechnicalCapability[];
551
+ /** Signals the classifier counted. */
552
+ activeSignals: ThreatSignal[];
553
+ /** Stale, resolved, withdrawn or below the confidence floor. Still shown. */
554
+ inertSignals: ThreatSignal[];
555
+ unknowns: UnknownField[];
556
+ /** True when nothing at all came back — never rendered as a blank panel. */
557
+ isEmpty: boolean;
558
+ /** Present exactly when `isEmpty`. Says what the emptiness means. */
559
+ emptyStatement?: string;
560
+ summary: string;
561
+ }
562
+
563
+ /**
564
+ * Split an assessment into what gets rendered where.
565
+ *
566
+ * Facts and capabilities never enter the signal list and signals never enter
567
+ * the fact list; the separation is structural rather than a convention each
568
+ * component is trusted to follow. Coverage signals have already been demoted
569
+ * to facts by `normalizeEvidence` on the wire boundary, so anything arriving
570
+ * in `threatSignals` here genuinely claims to be one.
571
+ */
572
+ export function assessmentSections(assessment: Assessment, copy: UiCopy): AssessmentSections {
573
+ const facts = assessment.facts ?? [];
574
+ const capabilities = assessment.capabilities ?? [];
575
+ const signals = assessment.threatSignals ?? [];
576
+ const unknowns = assessment.unknowns ?? [];
577
+
578
+ const activeSignals = signals.filter(
579
+ (s) => s.status === 'ACTIVE' && s.confidence >= MIN_RAISING_CONFIDENCE,
580
+ );
581
+ const inertSignals = signals.filter((s) => !activeSignals.includes(s));
582
+
583
+ const isEmpty =
584
+ facts.length === 0 && capabilities.length === 0 && signals.length === 0 && unknowns.length === 0;
585
+
586
+ const parts: string[] = [];
587
+ if (facts.length) parts.push(`${facts.length} ${plural(facts.length, 'fact')}`);
588
+ if (capabilities.length) {
589
+ parts.push(`${capabilities.length} ${plural(capabilities.length, 'capability', 'capabilities')}`);
590
+ }
591
+ if (signals.length) parts.push(`${signals.length} ${plural(signals.length, 'threat signal')}`);
592
+ if (unknowns.length) parts.push(`${unknowns.length} ${plural(unknowns.length, 'unknown')}`);
593
+
594
+ return {
595
+ facts,
596
+ capabilities,
597
+ activeSignals,
598
+ inertSignals,
599
+ unknowns,
600
+ isEmpty,
601
+ emptyStatement: isEmpty ? `${copy.nothingReturned} ${copy.notAnAssurance}` : undefined,
602
+ summary: parts.length ? parts.join(' · ') : copy.nothingReturned,
603
+ };
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // Availability
608
+ // ---------------------------------------------------------------------------
609
+
610
+ export interface AvailabilityPresentation {
611
+ unavailable: boolean;
612
+ /** Present when unavailable. Says nothing was checked, and implies nothing. */
613
+ statement?: string;
614
+ /**
615
+ * Never a severity tone.
616
+ *
617
+ * Decentrys being unreachable is a fact about Decentrys, not a finding about
618
+ * the user's transaction. Rendering our own outage in red teaches people
619
+ * that red means nothing.
620
+ */
621
+ tone: 'neutral';
622
+ }
623
+
624
+ export function availabilityPresentation(assessment: Assessment): AvailabilityPresentation {
625
+ const outage = (assessment.unknowns ?? []).filter((u) => u.reason === 'PROVIDER_UNAVAILABLE');
626
+ if (outage.length === 0) return { unavailable: false, tone: 'neutral' };
627
+ return {
628
+ unavailable: true,
629
+ statement: outage.map((u) => u.statement).join(' '),
630
+ tone: 'neutral',
631
+ };
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Policy decision
636
+ // ---------------------------------------------------------------------------
637
+
638
+ const DECISION_LABEL: Record<PolicyAction, string> = {
639
+ allow: 'No action requested',
640
+ inform: 'For your information',
641
+ warn: 'Review before continuing',
642
+ warn_strong: 'Review carefully before continuing',
643
+ require_confirmation: 'Explicit confirmation requested',
644
+ block: 'This application will not continue',
645
+ };
646
+
647
+ const DECISION_STATEMENT: Record<PolicyAction, string> = {
648
+ allow: 'This application’s policy asks for nothing further.',
649
+ inform: 'Shown because this application’s policy asks that you see it.',
650
+ warn: 'This application’s policy asks you to read the detail before continuing.',
651
+ warn_strong: 'This application’s policy asks you to read the detail carefully before continuing.',
652
+ require_confirmation:
653
+ 'This application’s policy asks you to confirm explicitly before continuing.',
654
+ block:
655
+ 'This application’s policy stops here. That decision belongs to this application; Decentrys reports evidence and does not stop anything.',
656
+ };
657
+
658
+ export interface DecisionPresentation {
659
+ action: PolicyAction;
660
+ label: string;
661
+ statement: string;
662
+ reason: string;
663
+ /**
664
+ * These components never disable a host application's controls.
665
+ *
666
+ * Typed as the literal `false` so no component can be written that reads
667
+ * this field and switches a button off. Policy is the integrator's, and it
668
+ * is theirs to enforce in their own UI.
669
+ */
670
+ blocksUi: false;
671
+ }
672
+
673
+ export function decisionPresentation(
674
+ decision: { action: PolicyAction; reason: string },
675
+ ): DecisionPresentation {
676
+ return {
677
+ action: decision.action,
678
+ label: DECISION_LABEL[decision.action],
679
+ statement: DECISION_STATEMENT[decision.action],
680
+ reason: decision.reason,
681
+ blocksUi: false,
682
+ };
683
+ }
684
+
685
+ // ---------------------------------------------------------------------------
686
+ // Approvals
687
+ // ---------------------------------------------------------------------------
688
+
689
+ export interface ApprovalDetails {
690
+ token: string;
691
+ symbol?: string;
692
+ decimals?: number;
693
+ spender: string;
694
+ /** A name the integrator already has for the spender, if any. */
695
+ spenderLabel?: string;
696
+ /** Base units, or the literal string `unlimited`. */
697
+ amount?: string;
698
+ }
699
+
700
+ export interface ApprovalPresentation {
701
+ unlimited: boolean;
702
+ amountKnown: boolean;
703
+ amountLabel: string;
704
+ scopeLabel: string;
705
+ statement: string;
706
+ spenderName: string;
707
+ assetName: string;
708
+ /**
709
+ * The capability tone, never a threat tone.
710
+ *
711
+ * An unlimited allowance is a *scope*, and describing it accurately is the
712
+ * whole job: "unlimited approval requested" is useful, "scam detected" is
713
+ * both unsupported and the thing that trains people to click through.
714
+ */
715
+ tone: 'capability';
716
+ emphasis: 'normal' | 'strong';
717
+ }
718
+
719
+ export function approvalPresentation(details: ApprovalDetails): ApprovalPresentation {
720
+ const spenderName = details.spenderLabel?.trim() || shortenAddress(details.spender);
721
+ const assetName = details.symbol?.trim() || `tokens at ${shortenAddress(details.token)}`;
722
+
723
+ if (details.amount === undefined || details.amount === null || details.amount === '') {
724
+ return {
725
+ unlimited: false,
726
+ amountKnown: false,
727
+ amountLabel: 'Not decoded',
728
+ scopeLabel: 'Allowance amount not decoded',
729
+ statement:
730
+ `The allowance amount could not be decoded, so how much this grants ${spenderName} over your `
731
+ + `${assetName} is not known.`,
732
+ spenderName,
733
+ assetName,
734
+ tone: 'capability',
735
+ emphasis: 'strong',
736
+ };
737
+ }
738
+
739
+ if (isUnlimitedAllowance(details.amount)) {
740
+ return {
741
+ unlimited: true,
742
+ amountKnown: true,
743
+ amountLabel: 'Unlimited',
744
+ scopeLabel: 'Unlimited allowance',
745
+ statement:
746
+ `This grants ${spenderName} permission to spend an unlimited amount of your ${assetName}, `
747
+ + 'now and at any time in the future, until it is revoked.',
748
+ spenderName,
749
+ assetName,
750
+ tone: 'capability',
751
+ emphasis: 'strong',
752
+ };
753
+ }
754
+
755
+ // With no decimals supplied the figure is base units, and it is *labelled*
756
+ // as base units rather than silently presented as whole tokens — an
757
+ // allowance shown 10^18 times too small is worse than one not shown at all.
758
+ const baseUnits = details.decimals === undefined;
759
+ const figure = formatTokenAmount(details.amount, details.decimals);
760
+ const amountLabel = baseUnits ? `${figure} base units` : `${figure} ${assetName}`;
761
+ return {
762
+ unlimited: false,
763
+ amountKnown: true,
764
+ amountLabel,
765
+ scopeLabel: 'Limited allowance',
766
+ statement:
767
+ `This grants ${spenderName} permission to spend up to ${figure} `
768
+ + `${baseUnits ? `base units of ${assetName}` : assetName}, until it is revoked.`,
769
+ spenderName,
770
+ assetName,
771
+ tone: 'capability',
772
+ emphasis: 'normal',
773
+ };
774
+ }
775
+
776
+ /** ERC-20 approvals commonly use a sentinel near uint256 max to mean "no cap". */
777
+ export function isUnlimitedAllowance(amount: string): boolean {
778
+ if (amount.trim().toLowerCase() === 'unlimited') return true;
779
+ try {
780
+ return BigInt(amount) >= 1n << 255n;
781
+ } catch {
782
+ return false;
783
+ }
784
+ }
785
+
786
+ // ---------------------------------------------------------------------------
787
+ // Formatting
788
+ // ---------------------------------------------------------------------------
789
+
790
+ /** `0.5` becomes `50%`. Never rounded away to "high" or "low". */
791
+ export function formatConfidence(confidence: number): string {
792
+ const clamped = Math.min(1, Math.max(0, confidence));
793
+ return `${Math.round(clamped * 100)}%`;
794
+ }
795
+
796
+ /**
797
+ * A fixed UTC rendering rather than a locale one.
798
+ *
799
+ * Locale formatting differs between a browser, an extension service worker and
800
+ * a React Native runtime, and an integrator wanting their own format has the
801
+ * raw ISO string on the model. Predictable beats pretty here.
802
+ */
803
+ export function formatTimestamp(iso: string): string {
804
+ const ms = Date.parse(iso);
805
+ if (Number.isNaN(ms)) return iso;
806
+ return `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
807
+ }
808
+
809
+ export function shortenAddress(address: string, lead = 6, tail = 4): string {
810
+ if (address.length <= lead + tail + 1) return address;
811
+ return `${address.slice(0, lead)}…${address.slice(-tail)}`;
812
+ }
813
+
814
+ /**
815
+ * Base units to a human amount, in integer arithmetic.
816
+ *
817
+ * Token balances routinely exceed what a double can hold exactly, and a
818
+ * rounding artefact in an allowance figure is a number a user would act on.
819
+ * With no decimals supplied the raw base units are returned unscaled; the
820
+ * caller is responsible for saying so, and `approvalPresentation` does.
821
+ */
822
+ export function formatTokenAmount(amount: string, decimals?: number): string {
823
+ let value: bigint;
824
+ try {
825
+ value = BigInt(amount);
826
+ } catch {
827
+ return amount;
828
+ }
829
+ if (decimals === undefined || decimals < 0) return group(value.toString());
830
+ const negative = value < 0n;
831
+ const abs = negative ? -value : value;
832
+ const scale = 10n ** BigInt(decimals);
833
+ const whole = abs / scale;
834
+ const fraction = (abs % scale).toString().padStart(decimals, '0').replace(/0+$/, '');
835
+ const text = fraction ? `${group(whole.toString())}.${fraction}` : group(whole.toString());
836
+ return negative ? `-${text}` : text;
837
+ }
838
+
839
+ function group(digits: string): string {
840
+ return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
841
+ }
842
+
843
+ export function plural(count: number, singular: string, pluralForm?: string): string {
844
+ if (count === 1) return singular;
845
+ return pluralForm ?? `${singular}s`;
846
+ }