@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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/browser/decentrys-ui.js +1733 -0
- package/dist/browser/decentrys-ui.mjs +1681 -0
- package/dist/browser/react-dom-global.d.ts +6 -0
- package/dist/browser/react-dom-global.d.ts.map +1 -0
- package/dist/browser/react-dom-global.js +18 -0
- package/dist/browser/react-dom-global.js.map +1 -0
- package/dist/browser/react-global.d.ts +25 -0
- package/dist/browser/react-global.d.ts.map +1 -0
- package/dist/browser/react-global.js +39 -0
- package/dist/browser/react-global.js.map +1 -0
- package/dist/browser/react-jsx-runtime.d.ts +17 -0
- package/dist/browser/react-jsx-runtime.d.ts.map +1 -0
- package/dist/browser/react-jsx-runtime.js +27 -0
- package/dist/browser/react-jsx-runtime.js.map +1 -0
- package/dist/components/AddressRiskBadge.d.ts +39 -0
- package/dist/components/AddressRiskBadge.d.ts.map +1 -0
- package/dist/components/AddressRiskBadge.js +26 -0
- package/dist/components/AddressRiskBadge.js.map +1 -0
- package/dist/components/ApprovalWarning.d.ts +29 -0
- package/dist/components/ApprovalWarning.d.ts.map +1 -0
- package/dist/components/ApprovalWarning.js +18 -0
- package/dist/components/ApprovalWarning.js.map +1 -0
- package/dist/components/ContractFacts.d.ts +27 -0
- package/dist/components/ContractFacts.d.ts.map +1 -0
- package/dist/components/ContractFacts.js +15 -0
- package/dist/components/ContractFacts.js.map +1 -0
- package/dist/components/RiskDetailsModal.d.ts +35 -0
- package/dist/components/RiskDetailsModal.d.ts.map +1 -0
- package/dist/components/RiskDetailsModal.js +74 -0
- package/dist/components/RiskDetailsModal.js.map +1 -0
- package/dist/components/ThreatSignalList.d.ts +28 -0
- package/dist/components/ThreatSignalList.d.ts.map +1 -0
- package/dist/components/ThreatSignalList.js +28 -0
- package/dist/components/ThreatSignalList.js.map +1 -0
- package/dist/components/TokenSecurityPanel.d.ts +28 -0
- package/dist/components/TokenSecurityPanel.d.ts.map +1 -0
- package/dist/components/TokenSecurityPanel.js +26 -0
- package/dist/components/TokenSecurityPanel.js.map +1 -0
- package/dist/components/TransactionRiskBanner.d.ts +36 -0
- package/dist/components/TransactionRiskBanner.d.ts.map +1 -0
- package/dist/components/TransactionRiskBanner.js +20 -0
- package/dist/components/TransactionRiskBanner.js.map +1 -0
- package/dist/context.d.ts +51 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +47 -0
- package/dist/context.js.map +1 -0
- package/dist/copy.d.ts +72 -0
- package/dist/copy.d.ts.map +1 -0
- package/dist/copy.js +77 -0
- package/dist/copy.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +107 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation.d.ts +316 -0
- package/dist/presentation.d.ts.map +1 -0
- package/dist/presentation.js +533 -0
- package/dist/presentation.js.map +1 -0
- package/dist/primitives.d.ts +139 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/primitives.js +150 -0
- package/dist/primitives.js.map +1 -0
- package/dist/styles.d.ts +15 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +285 -0
- package/dist/styles.js.map +1 -0
- package/dist/theme.d.ts +52 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +90 -0
- package/dist/theme.js.map +1 -0
- package/package.json +70 -0
- package/src/browser/react-dom-global.ts +20 -0
- package/src/browser/react-global.ts +42 -0
- package/src/browser/react-jsx-runtime.ts +23 -0
- package/src/components/AddressRiskBadge.tsx +94 -0
- package/src/components/ApprovalWarning.tsx +144 -0
- package/src/components/ContractFacts.tsx +119 -0
- package/src/components/RiskDetailsModal.tsx +229 -0
- package/src/components/ThreatSignalList.tsx +76 -0
- package/src/components/TokenSecurityPanel.tsx +168 -0
- package/src/components/TransactionRiskBanner.tsx +123 -0
- package/src/context.tsx +89 -0
- package/src/copy.test.ts +214 -0
- package/src/copy.ts +151 -0
- package/src/index.ts +84 -0
- package/src/presentation.test.ts +521 -0
- package/src/presentation.ts +846 -0
- package/src/primitives.tsx +384 -0
- package/src/styles.ts +281 -0
- package/src/theme.test.ts +105 -0
- 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
|
+
}
|