@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,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* These tests target the mapping, not the markup.
|
|
3
|
+
*
|
|
4
|
+
* A snapshot of rendered HTML would pass just as happily with a fact painted
|
|
5
|
+
* in critical red, because a snapshot only asserts that nothing changed — not
|
|
6
|
+
* that what is there is right. Every assertion below is about a decision that,
|
|
7
|
+
* if made the other way, would break the rule the whole product rests on while
|
|
8
|
+
* leaving every classifier test green.
|
|
9
|
+
*
|
|
10
|
+
* All fixtures here are clearly-labelled examples, constructed in the test
|
|
11
|
+
* file. Nothing in the package itself ships example data.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
HISTORY_STATUS_MEANING, RISK_LEVEL_MEANING, RISK_LEVELS, unavailableAssessment,
|
|
17
|
+
type Assessment, type Evidence, type ObservedFact, type RiskLevel, type SignalStatus,
|
|
18
|
+
type TechnicalCapability, type ThreatSignal, type UnknownField,
|
|
19
|
+
} from '@decentrys/protect';
|
|
20
|
+
import { DEFAULT_COPY } from './copy';
|
|
21
|
+
import {
|
|
22
|
+
MIN_RAISING_CONFIDENCE, RISK_LEVEL_LABEL, approvalPresentation, assessmentSections,
|
|
23
|
+
availabilityPresentation, capabilityPresentation, componentRows, decisionPresentation,
|
|
24
|
+
factPresentation, formatConfidence, formatTimestamp, formatTokenAmount, historyPresentation,
|
|
25
|
+
hopPresentation, isUnlimitedAllowance, kindPresentation, levelPresentation, shortenAddress,
|
|
26
|
+
signalPresentation, unknownPresentation,
|
|
27
|
+
} from './presentation';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Example fixtures — illustrative data for the tests only.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const EXAMPLE_EVIDENCE = (analystVerified: boolean): Evidence => ({
|
|
34
|
+
id: 'ev_example_1',
|
|
35
|
+
type: 'BYTECODE_MATCH',
|
|
36
|
+
source: 'Decentrys',
|
|
37
|
+
observedAt: '2026-09-05T18:22:04.000Z',
|
|
38
|
+
confidence: 0.99,
|
|
39
|
+
analystVerified,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const EXAMPLE_SIGNAL = (overrides: Partial<ThreatSignal> = {}): ThreatSignal => ({
|
|
43
|
+
type: 'KNOWN_DRAINER_BYTECODE',
|
|
44
|
+
severity: 'CRITICAL',
|
|
45
|
+
confidence: 0.99,
|
|
46
|
+
explanation: 'Bytecode matches a confirmed drainer family.',
|
|
47
|
+
hops: 0,
|
|
48
|
+
evidence: [EXAMPLE_EVIDENCE(true)],
|
|
49
|
+
status: 'ACTIVE',
|
|
50
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
51
|
+
lastSeen: '2026-09-05T00:00:00.000Z',
|
|
52
|
+
...overrides,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const EXAMPLE_FACT: ObservedFact = {
|
|
56
|
+
type: 'CONTRACT_AGE_HOURS',
|
|
57
|
+
value: 72,
|
|
58
|
+
statement: 'Deployed 3 days ago.',
|
|
59
|
+
source: 'chain',
|
|
60
|
+
observedAt: '2026-09-05T12:00:00.000Z',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const EXAMPLE_CAPABILITY = (
|
|
64
|
+
severity: TechnicalCapability['severity'],
|
|
65
|
+
): TechnicalCapability => ({
|
|
66
|
+
type: 'MINT_AUTHORITY_ACTIVE',
|
|
67
|
+
severity,
|
|
68
|
+
statement: 'The owner can mint additional supply.',
|
|
69
|
+
grantedBy: 'mint(address,uint256)',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const EXAMPLE_UNKNOWN: UnknownField = {
|
|
73
|
+
field: 'sourceCode',
|
|
74
|
+
reason: 'UNKNOWN',
|
|
75
|
+
statement: 'Whether source code is published is not known.',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const EXAMPLE_ASSESSMENT = (overrides: Partial<Assessment> = {}): Assessment => ({
|
|
79
|
+
riskLevel: 'INFORMATIONAL',
|
|
80
|
+
confirmedMalicious: false,
|
|
81
|
+
confidence: 0.5,
|
|
82
|
+
historyStatus: 'LIMITED',
|
|
83
|
+
facts: [EXAMPLE_FACT],
|
|
84
|
+
capabilities: [EXAMPLE_CAPABILITY('INFO')],
|
|
85
|
+
threatSignals: [],
|
|
86
|
+
unknowns: [EXAMPLE_UNKNOWN],
|
|
87
|
+
components: {
|
|
88
|
+
technicalRisk: 4, behavioralRisk: 0, threatIntelligenceRisk: 0, historyConfidence: 25,
|
|
89
|
+
},
|
|
90
|
+
explanation: ['The owner can mint additional supply.'],
|
|
91
|
+
modelVersion: 'protect-1.0.0',
|
|
92
|
+
assessedAt: '2026-09-06T00:00:00.000Z',
|
|
93
|
+
...overrides,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
describe('risk level presentation', () => {
|
|
99
|
+
it('uses the exact labels already published to customers', () => {
|
|
100
|
+
expect(RISK_LEVEL_LABEL).toEqual({
|
|
101
|
+
NO_CRITICAL_RISK_DETECTED: 'No critical risk detected',
|
|
102
|
+
INFORMATIONAL: 'Informational',
|
|
103
|
+
CAUTION: 'Caution',
|
|
104
|
+
ELEVATED_RISK: 'Elevated risk',
|
|
105
|
+
HIGH_RISK: 'High risk',
|
|
106
|
+
CRITICAL_THREAT: 'Critical threat',
|
|
107
|
+
KNOWN_MALICIOUS: 'Known malicious',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('never invents a meaning — it renders the model’s own words', () => {
|
|
112
|
+
for (const level of RISK_LEVELS) {
|
|
113
|
+
expect(levelPresentation(level).meaning).toBe(RISK_LEVEL_MEANING[level]);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('gives the two non-danger levels a non-warning tone', () => {
|
|
118
|
+
// Nothing at these levels indicates danger, and a new project landing on
|
|
119
|
+
// INFORMATIONAL is the common case, not an exception.
|
|
120
|
+
expect(levelPresentation('NO_CRITICAL_RISK_DETECTED').tone).toBe('neutral');
|
|
121
|
+
expect(levelPresentation('INFORMATIONAL').tone).toBe('info');
|
|
122
|
+
for (const level of ['NO_CRITICAL_RISK_DETECTED', 'INFORMATIONAL'] as RiskLevel[]) {
|
|
123
|
+
expect(levelPresentation(level).evidenceBacked).toBe(false);
|
|
124
|
+
expect(levelPresentation(level).liveRegionRole).toBe('status');
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('interrupts a screen reader only from ELEVATED_RISK upward', () => {
|
|
129
|
+
expect(levelPresentation('CAUTION').liveRegionRole).toBe('status');
|
|
130
|
+
for (const level of ['ELEVATED_RISK', 'HIGH_RISK', 'CRITICAL_THREAT', 'KNOWN_MALICIOUS'] as RiskLevel[]) {
|
|
131
|
+
expect(levelPresentation(level).liveRegionRole).toBe('alert');
|
|
132
|
+
expect(levelPresentation(level).evidenceBacked).toBe(true);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('marks only KNOWN_MALICIOUS as accusatory', () => {
|
|
137
|
+
const accusatory = RISK_LEVELS.filter((level) => levelPresentation(level).accusatory);
|
|
138
|
+
expect(accusatory).toEqual(['KNOWN_MALICIOUS']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('gives every level a distinct glyph, so colour is never the only carrier', () => {
|
|
142
|
+
const glyphs = RISK_LEVELS.map((level) => levelPresentation(level).glyph);
|
|
143
|
+
expect(new Set(glyphs).size).toBe(RISK_LEVELS.length);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('lets an integrator relabel a level without touching its meaning', () => {
|
|
147
|
+
const p = levelPresentation('CAUTION', { labels: { CAUTION: 'Achtung' } });
|
|
148
|
+
expect(p.label).toBe('Achtung');
|
|
149
|
+
expect(p.meaning).toBe(RISK_LEVEL_MEANING.CAUTION);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('puts the meaning into the accessible name, not just the label', () => {
|
|
153
|
+
expect(levelPresentation('INFORMATIONAL').ariaLabel).toContain(RISK_LEVEL_MEANING.INFORMATIONAL);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('history presentation', () => {
|
|
158
|
+
it('is neutral for every status, including LIMITED and NONE', () => {
|
|
159
|
+
for (const status of ['ESTABLISHED', 'MODERATE', 'LIMITED', 'NONE'] as const) {
|
|
160
|
+
const h = historyPresentation(status);
|
|
161
|
+
expect(h.tone).toBe('neutral');
|
|
162
|
+
expect(h.isWarning).toBe(false);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('gives LIMITED exactly the same tone as ESTABLISHED', () => {
|
|
167
|
+
// The failure this guards against is subtle: an amber "limited history"
|
|
168
|
+
// chip beside a green "established" one is a risk signal wearing a fact's
|
|
169
|
+
// label, and it taxes every project deployed this week.
|
|
170
|
+
expect(historyPresentation('LIMITED').tone).toBe(historyPresentation('ESTABLISHED').tone);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('states that limited history is not a risk finding', () => {
|
|
174
|
+
expect(historyPresentation('LIMITED').statement).toBe(HISTORY_STATUS_MEANING.LIMITED);
|
|
175
|
+
expect(historyPresentation('LIMITED').statement).toContain('not a risk finding');
|
|
176
|
+
expect(historyPresentation('NONE').statement).toContain('not a risk finding');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('labels the statuses without implying anything about the subject', () => {
|
|
180
|
+
expect(historyPresentation('LIMITED').label).toBe('Limited history');
|
|
181
|
+
expect(historyPresentation('NONE').label).toBe('No history');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('facts, capabilities and signals are visually separate species', () => {
|
|
186
|
+
it('gives a fact the fact tone, never a severity tone', () => {
|
|
187
|
+
expect(factPresentation(EXAMPLE_FACT).tone).toBe('fact');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('keeps a capability in the capability tone at every severity', () => {
|
|
191
|
+
for (const severity of ['INFO', 'NOTABLE', 'SIGNIFICANT'] as const) {
|
|
192
|
+
const p = capabilityPresentation(EXAMPLE_CAPABILITY(severity));
|
|
193
|
+
expect(p.tone).toBe('capability');
|
|
194
|
+
}
|
|
195
|
+
// Severity is carried by weight, not by recolouring into the threat palette.
|
|
196
|
+
expect(capabilityPresentation(EXAMPLE_CAPABILITY('SIGNIFICANT')).emphasis).toBe('strong');
|
|
197
|
+
expect(capabilityPresentation(EXAMPLE_CAPABILITY('INFO')).emphasis).toBe('normal');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('keeps an unknown in the unknown tone', () => {
|
|
201
|
+
expect(unknownPresentation(EXAMPLE_UNKNOWN).tone).toBe('unknown');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('never puts a fact, capability or unknown on the risk tone ramp', () => {
|
|
205
|
+
const riskTones = new Set(['caution', 'elevated', 'high', 'critical']);
|
|
206
|
+
expect(riskTones.has(factPresentation(EXAMPLE_FACT).tone)).toBe(false);
|
|
207
|
+
expect(riskTones.has(capabilityPresentation(EXAMPLE_CAPABILITY('SIGNIFICANT')).tone)).toBe(false);
|
|
208
|
+
expect(riskTones.has(unknownPresentation(EXAMPLE_UNKNOWN).tone)).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('gives every kind a distinct word, glyph and rule style', () => {
|
|
212
|
+
const kinds = (['fact', 'capability', 'signal', 'unknown'] as const)
|
|
213
|
+
.map((kind) => kindPresentation(kind, DEFAULT_COPY));
|
|
214
|
+
expect(new Set(kinds.map((k) => k.label)).size).toBe(4);
|
|
215
|
+
expect(new Set(kinds.map((k) => k.glyph)).size).toBe(4);
|
|
216
|
+
// Glyph and rule together separate every pair even with no colour at all.
|
|
217
|
+
expect(new Set(kinds.map((k) => `${k.glyph}|${k.rule}|${k.ruleWidth}`)).size).toBe(4);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('threat signal presentation', () => {
|
|
222
|
+
it('gives a live, confident signal its severity tone', () => {
|
|
223
|
+
const p = signalPresentation(EXAMPLE_SIGNAL(), DEFAULT_COPY);
|
|
224
|
+
expect(p.tone).toBe('critical');
|
|
225
|
+
expect(p.raising).toBe(true);
|
|
226
|
+
expect(p.inertStatement).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('tones a stale signal as inactive and says it did not count', () => {
|
|
230
|
+
const p = signalPresentation(EXAMPLE_SIGNAL({ status: 'STALE' }), DEFAULT_COPY);
|
|
231
|
+
expect(p.tone).toBe('inactive');
|
|
232
|
+
expect(p.raising).toBe(false);
|
|
233
|
+
expect(p.inertStatement).toContain('stale');
|
|
234
|
+
expect(p.inertStatement).toContain('did not affect this assessment');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('does not paint a low-confidence signal in the colour of one that counted', () => {
|
|
238
|
+
const p = signalPresentation(
|
|
239
|
+
EXAMPLE_SIGNAL({ confidence: 0.3 }), DEFAULT_COPY,
|
|
240
|
+
);
|
|
241
|
+
expect(p.severity).toBe('CRITICAL');
|
|
242
|
+
expect(p.tone).toBe('inactive');
|
|
243
|
+
expect(p.raising).toBe(false);
|
|
244
|
+
expect(p.inertStatement).toContain('30%');
|
|
245
|
+
expect(p.inertStatement).toContain('too low to raise the risk level');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('treats the confidence floor as inclusive, matching the classifier', () => {
|
|
249
|
+
const at = signalPresentation(
|
|
250
|
+
EXAMPLE_SIGNAL({ confidence: MIN_RAISING_CONFIDENCE }), DEFAULT_COPY,
|
|
251
|
+
);
|
|
252
|
+
const below = signalPresentation(
|
|
253
|
+
EXAMPLE_SIGNAL({ confidence: MIN_RAISING_CONFIDENCE - 0.01 }), DEFAULT_COPY,
|
|
254
|
+
);
|
|
255
|
+
expect(at.raising).toBe(true);
|
|
256
|
+
expect(below.raising).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('reports confidence as a number, never rounded away to a word', () => {
|
|
260
|
+
expect(signalPresentation(EXAMPLE_SIGNAL({ confidence: 0.62 }), DEFAULT_COPY).confidenceLabel)
|
|
261
|
+
.toBe('62%');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('distinguishes an analyst-confirmed signal from an automated one', () => {
|
|
265
|
+
const confirmed = signalPresentation(EXAMPLE_SIGNAL(), DEFAULT_COPY);
|
|
266
|
+
const automated = signalPresentation(
|
|
267
|
+
EXAMPLE_SIGNAL({ evidence: [EXAMPLE_EVIDENCE(false)] }), DEFAULT_COPY,
|
|
268
|
+
);
|
|
269
|
+
expect(confirmed.analystConfirmed).toBe(true);
|
|
270
|
+
expect(confirmed.attribution).toBe(DEFAULT_COPY.analystConfirmed);
|
|
271
|
+
expect(automated.analystConfirmed).toBe(false);
|
|
272
|
+
expect(automated.attribution).toBe(DEFAULT_COPY.notAnalystConfirmed);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('labels every non-active status rather than dropping the signal', () => {
|
|
276
|
+
const statuses: SignalStatus[] = ['STALE', 'RESOLVED', 'DISPUTED_FACT', 'REMOVED'];
|
|
277
|
+
for (const status of statuses) {
|
|
278
|
+
const p = signalPresentation(EXAMPLE_SIGNAL({ status }), DEFAULT_COPY);
|
|
279
|
+
expect(p.statusLabel.length).toBeGreaterThan(0);
|
|
280
|
+
expect(p.inertStatement).toBeTruthy();
|
|
281
|
+
expect(p.explanation).toBe('Bytecode matches a confirmed drainer family.');
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('hop distance', () => {
|
|
287
|
+
it('describes zero hops as a direct observation', () => {
|
|
288
|
+
const h = hopPresentation(0);
|
|
289
|
+
expect(h.label).toBe('Direct');
|
|
290
|
+
expect(h.indirect).toBe(false);
|
|
291
|
+
expect(h.statement).not.toContain('inference');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('describes one hop as an inference about a counterparty', () => {
|
|
295
|
+
const h = hopPresentation(1);
|
|
296
|
+
expect(h.label).toBe('1 hop');
|
|
297
|
+
expect(h.indirect).toBe(true);
|
|
298
|
+
expect(h.statement).toContain('inference');
|
|
299
|
+
expect(h.statement).toContain('counterparty');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('names the distance when it is more than one', () => {
|
|
303
|
+
const h = hopPresentation(3);
|
|
304
|
+
expect(h.label).toBe('3 hops');
|
|
305
|
+
expect(h.indirect).toBe(true);
|
|
306
|
+
expect(h.statement).toContain('3 hops away');
|
|
307
|
+
expect(h.statement).toContain('3 steps removed');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('uses the caller’s noun for the subject', () => {
|
|
311
|
+
expect(hopPresentation(2, 'token').statement).toContain('this token');
|
|
312
|
+
expect(hopPresentation(0, 'contract').statement).toBe('Observed directly on this contract.');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('clamps a nonsensical hop count rather than rendering it', () => {
|
|
316
|
+
expect(hopPresentation(-4).label).toBe('Direct');
|
|
317
|
+
expect(hopPresentation(2.7).label).toBe('2 hops');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('threads hop wording through the signal presentation', () => {
|
|
321
|
+
const p = signalPresentation(EXAMPLE_SIGNAL({ hops: 2 }), DEFAULT_COPY, { subjectNoun: 'token' });
|
|
322
|
+
expect(p.indirect).toBe(true);
|
|
323
|
+
expect(p.hopLabel).toBe('2 hops');
|
|
324
|
+
expect(p.hopStatement).toContain('inference about a counterparty');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('assessment sections', () => {
|
|
329
|
+
it('never lets a fact reach the signal list, or a signal reach the facts', () => {
|
|
330
|
+
const assessment = EXAMPLE_ASSESSMENT({ threatSignals: [EXAMPLE_SIGNAL()] });
|
|
331
|
+
const s = assessmentSections(assessment, DEFAULT_COPY);
|
|
332
|
+
expect(s.facts).toEqual([EXAMPLE_FACT]);
|
|
333
|
+
expect(s.activeSignals).toHaveLength(1);
|
|
334
|
+
expect(s.facts.some((f) => 'severity' in f)).toBe(false);
|
|
335
|
+
expect([...s.activeSignals, ...s.inertSignals].some((sig) => 'statement' in sig)).toBe(false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('reports a signal that did not count instead of hiding it', () => {
|
|
339
|
+
const assessment = EXAMPLE_ASSESSMENT({
|
|
340
|
+
threatSignals: [EXAMPLE_SIGNAL({ confidence: 0.2 }), EXAMPLE_SIGNAL({ status: 'STALE' })],
|
|
341
|
+
});
|
|
342
|
+
const s = assessmentSections(assessment, DEFAULT_COPY);
|
|
343
|
+
expect(s.activeSignals).toHaveLength(0);
|
|
344
|
+
expect(s.inertSignals).toHaveLength(2);
|
|
345
|
+
expect(s.summary).toContain('2 threat signals');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('states emptiness rather than rendering a blank panel', () => {
|
|
349
|
+
const empty = EXAMPLE_ASSESSMENT({
|
|
350
|
+
facts: [], capabilities: [], threatSignals: [], unknowns: [],
|
|
351
|
+
});
|
|
352
|
+
const s = assessmentSections(empty, DEFAULT_COPY);
|
|
353
|
+
expect(s.isEmpty).toBe(true);
|
|
354
|
+
expect(s.emptyStatement).toContain(DEFAULT_COPY.nothingReturned);
|
|
355
|
+
expect(s.emptyStatement).toContain(DEFAULT_COPY.notAnAssurance);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('summarises what is present, with correct plurals', () => {
|
|
359
|
+
const s = assessmentSections(EXAMPLE_ASSESSMENT(), DEFAULT_COPY);
|
|
360
|
+
expect(s.summary).toBe('1 fact · 1 capability · 1 unknown');
|
|
361
|
+
const many = assessmentSections(
|
|
362
|
+
EXAMPLE_ASSESSMENT({
|
|
363
|
+
facts: [EXAMPLE_FACT, EXAMPLE_FACT],
|
|
364
|
+
capabilities: [EXAMPLE_CAPABILITY('INFO'), EXAMPLE_CAPABILITY('NOTABLE')],
|
|
365
|
+
unknowns: [],
|
|
366
|
+
}),
|
|
367
|
+
DEFAULT_COPY,
|
|
368
|
+
);
|
|
369
|
+
expect(many.summary).toBe('2 facts · 2 capabilities');
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('availability', () => {
|
|
374
|
+
it('renders our own outage neutrally, never as a finding', () => {
|
|
375
|
+
const a = availabilityPresentation(unavailableAssessment('warn', 'timeout'));
|
|
376
|
+
expect(a.unavailable).toBe(true);
|
|
377
|
+
expect(a.tone).toBe('neutral');
|
|
378
|
+
expect(a.statement).toContain('Nothing was checked');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('reports availability as fine when no provider unknown is present', () => {
|
|
382
|
+
expect(availabilityPresentation(EXAMPLE_ASSESSMENT()).unavailable).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('does not let an outage raise the level it renders', () => {
|
|
386
|
+
const assessment = unavailableAssessment('warn', 'timeout');
|
|
387
|
+
expect(levelPresentation(assessment.riskLevel).tone).toBe('neutral');
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe('policy decisions', () => {
|
|
392
|
+
it('never blocks the host UI, for any action including block', () => {
|
|
393
|
+
for (const action of ['allow', 'inform', 'warn', 'warn_strong', 'require_confirmation', 'block'] as const) {
|
|
394
|
+
expect(decisionPresentation({ action, reason: 'example' }).blocksUi).toBe(false);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('attributes a block to the integrator, not to Decentrys', () => {
|
|
399
|
+
const d = decisionPresentation({ action: 'block', reason: 'example' });
|
|
400
|
+
expect(d.statement).toContain('belongs to this application');
|
|
401
|
+
expect(d.statement).toContain('Decentrys reports evidence and does not stop anything');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('score components', () => {
|
|
406
|
+
it('labels history confidence as coverage and attaches its caveat', () => {
|
|
407
|
+
const rows = componentRows(EXAMPLE_ASSESSMENT().components, DEFAULT_COPY);
|
|
408
|
+
const coverage = rows.find((r) => r.key === 'historyConfidence');
|
|
409
|
+
expect(coverage?.label).toBe('Coverage');
|
|
410
|
+
expect(coverage?.isCoverage).toBe(true);
|
|
411
|
+
expect(coverage?.caveat).toBe(DEFAULT_COPY.coverageIsNotRisk);
|
|
412
|
+
expect(rows.filter((r) => r.isCoverage)).toHaveLength(1);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('approvals', () => {
|
|
417
|
+
it('recognises the ways an unlimited allowance is expressed', () => {
|
|
418
|
+
expect(isUnlimitedAllowance('unlimited')).toBe(true);
|
|
419
|
+
expect(isUnlimitedAllowance('UNLIMITED')).toBe(true);
|
|
420
|
+
expect(isUnlimitedAllowance((2n ** 256n - 1n).toString())).toBe(true);
|
|
421
|
+
expect(isUnlimitedAllowance((2n ** 255n).toString())).toBe(true);
|
|
422
|
+
expect(isUnlimitedAllowance('1000000')).toBe(false);
|
|
423
|
+
expect(isUnlimitedAllowance('not-a-number')).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('says unlimited approval requested, and does not accuse anyone', () => {
|
|
427
|
+
const p = approvalPresentation({
|
|
428
|
+
token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
|
429
|
+
symbol: 'USDC',
|
|
430
|
+
decimals: 6,
|
|
431
|
+
spender: '0x1111111254EEB25477B68fb85Ed929f73A960582',
|
|
432
|
+
amount: 'unlimited',
|
|
433
|
+
});
|
|
434
|
+
expect(p.unlimited).toBe(true);
|
|
435
|
+
expect(p.scopeLabel).toBe('Unlimited allowance');
|
|
436
|
+
expect(p.statement).toContain('unlimited amount of your USDC');
|
|
437
|
+
expect(p.statement).toContain('until it is revoked');
|
|
438
|
+
expect(p.emphasis).toBe('strong');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('renders an approval scope as a capability, never as a threat', () => {
|
|
442
|
+
const unlimited = approvalPresentation({
|
|
443
|
+
token: '0xtoken', spender: '0xspender', amount: 'unlimited',
|
|
444
|
+
});
|
|
445
|
+
const limited = approvalPresentation({
|
|
446
|
+
token: '0xtoken', spender: '0xspender', amount: '1000000', decimals: 6,
|
|
447
|
+
});
|
|
448
|
+
expect(unlimited.tone).toBe('capability');
|
|
449
|
+
expect(limited.tone).toBe('capability');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('formats a bounded allowance in whole tokens when decimals are known', () => {
|
|
453
|
+
const p = approvalPresentation({
|
|
454
|
+
token: '0xtoken', symbol: 'USDC', decimals: 6, spender: '0xspender', amount: '1234500000',
|
|
455
|
+
});
|
|
456
|
+
expect(p.unlimited).toBe(false);
|
|
457
|
+
expect(p.amountLabel).toBe('1,234.5 USDC');
|
|
458
|
+
expect(p.statement).toContain('up to 1,234.5 USDC');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('labels base units as base units rather than pretending they are tokens', () => {
|
|
462
|
+
const p = approvalPresentation({
|
|
463
|
+
token: '0xtoken', symbol: 'USDC', spender: '0xspender', amount: '1234500000',
|
|
464
|
+
});
|
|
465
|
+
expect(p.amountLabel).toBe('1,234,500,000 base units');
|
|
466
|
+
expect(p.statement).toContain('base units of USDC');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('says plainly when the amount could not be decoded', () => {
|
|
470
|
+
const p = approvalPresentation({ token: '0xtoken', spender: '0xspender' });
|
|
471
|
+
expect(p.amountKnown).toBe(false);
|
|
472
|
+
expect(p.statement).toContain('could not be decoded');
|
|
473
|
+
expect(p.statement).toContain('is not known');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('prefers a name the integrator already has for the spender', () => {
|
|
477
|
+
const p = approvalPresentation({
|
|
478
|
+
token: '0xtoken', spender: '0x1111111254EEB25477B68fb85Ed929f73A960582',
|
|
479
|
+
spenderLabel: 'Uniswap router', amount: 'unlimited',
|
|
480
|
+
});
|
|
481
|
+
expect(p.spenderName).toBe('Uniswap router');
|
|
482
|
+
expect(p.statement).toContain('Uniswap router');
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe('formatting', () => {
|
|
487
|
+
it('renders confidence as a clamped percentage', () => {
|
|
488
|
+
expect(formatConfidence(0)).toBe('0%');
|
|
489
|
+
expect(formatConfidence(0.505)).toBe('51%');
|
|
490
|
+
expect(formatConfidence(1)).toBe('100%');
|
|
491
|
+
expect(formatConfidence(1.4)).toBe('100%');
|
|
492
|
+
expect(formatConfidence(-0.2)).toBe('0%');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('renders timestamps identically in every runtime', () => {
|
|
496
|
+
expect(formatTimestamp('2026-09-05T18:22:04.000Z')).toBe('2026-09-05 18:22 UTC');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('passes an unparseable timestamp through rather than inventing one', () => {
|
|
500
|
+
expect(formatTimestamp('soon')).toBe('soon');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('formats token amounts in integer arithmetic, without precision loss', () => {
|
|
504
|
+
// A double cannot hold this exactly; the point of using BigInt is that a
|
|
505
|
+
// user is never shown a rounded version of a number they would act on.
|
|
506
|
+
expect(formatTokenAmount('123456789012345678901234567890', 18))
|
|
507
|
+
.toBe('123,456,789,012.34567890123456789');
|
|
508
|
+
expect(formatTokenAmount('1000000', 6)).toBe('1');
|
|
509
|
+
expect(formatTokenAmount('1500000', 6)).toBe('1.5');
|
|
510
|
+
expect(formatTokenAmount('0', 18)).toBe('0');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('returns an unparseable amount untouched', () => {
|
|
514
|
+
expect(formatTokenAmount('n/a', 18)).toBe('n/a');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('shortens an address without hiding either end', () => {
|
|
518
|
+
expect(shortenAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')).toBe('0xA0b8…eB48');
|
|
519
|
+
expect(shortenAddress('0xshort')).toBe('0xshort');
|
|
520
|
+
});
|
|
521
|
+
});
|