@decentrys/protect 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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -0
  3. package/dist/browser/decentrys-protect.js +901 -0
  4. package/dist/browser/decentrys-protect.mjs +876 -0
  5. package/dist/cache.d.ts +41 -0
  6. package/dist/cache.d.ts.map +1 -0
  7. package/dist/cache.js +75 -0
  8. package/dist/cache.js.map +1 -0
  9. package/dist/classify.d.ts +58 -0
  10. package/dist/classify.d.ts.map +1 -0
  11. package/dist/classify.js +269 -0
  12. package/dist/classify.js.map +1 -0
  13. package/dist/client.d.ts +132 -0
  14. package/dist/client.d.ts.map +1 -0
  15. package/dist/client.js +307 -0
  16. package/dist/client.js.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +24 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/model.d.ts +156 -0
  22. package/dist/model.d.ts.map +1 -0
  23. package/dist/model.js +80 -0
  24. package/dist/model.js.map +1 -0
  25. package/dist/simulation.d.ts +57 -0
  26. package/dist/simulation.d.ts.map +1 -0
  27. package/dist/simulation.js +23 -0
  28. package/dist/simulation.js.map +1 -0
  29. package/dist/transport.d.ts +61 -0
  30. package/dist/transport.d.ts.map +1 -0
  31. package/dist/transport.js +151 -0
  32. package/dist/transport.js.map +1 -0
  33. package/dist/wire.d.ts +63 -0
  34. package/dist/wire.d.ts.map +1 -0
  35. package/dist/wire.js +274 -0
  36. package/dist/wire.js.map +1 -0
  37. package/package.json +64 -0
  38. package/src/cache.test.ts +67 -0
  39. package/src/cache.ts +87 -0
  40. package/src/classify.test.ts +294 -0
  41. package/src/classify.ts +323 -0
  42. package/src/client.test.ts +224 -0
  43. package/src/client.ts +420 -0
  44. package/src/index.ts +7 -0
  45. package/src/model.ts +237 -0
  46. package/src/simulation.ts +71 -0
  47. package/src/transport.test.ts +129 -0
  48. package/src/transport.ts +203 -0
  49. package/src/wire.test.ts +172 -0
  50. package/src/wire.ts +321 -0
@@ -0,0 +1,323 @@
1
+ import {
2
+ HISTORY_STATUS_MEANING, PROTECT_MODEL_VERSION, RISK_LEVEL_MEANING,
3
+ type Assessment, type HistoryStatus, type ObservedFact, type RiskLevel,
4
+ type SignalStatus, type TechnicalCapability, type ThreatSignal,
5
+ type UnknownField,
6
+ } from './model';
7
+
8
+ /**
9
+ * Classification.
10
+ *
11
+ * The whole point of this function is what it *refuses* to do. Age, audit
12
+ * status, liquidity, holder concentration, deployer history and anonymity are
13
+ * inputs to `facts` and to `historyConfidence` — and to nothing else. There is
14
+ * no path in this file by which any of them raises a risk level.
15
+ *
16
+ * Risk comes from two places only:
17
+ *
18
+ * - **Capabilities**, which can reach CAUTION. A contract that can mint
19
+ * unlimited supply deserves attention; it does not deserve an accusation.
20
+ * - **Threat signals**, which are the only way past CAUTION, and which
21
+ * require evidence.
22
+ *
23
+ * `KNOWN_MALICIOUS` additionally requires analyst-verified evidence, because
24
+ * it is the one output that is a public accusation about a third party.
25
+ */
26
+
27
+ /** Signals that are no longer live cannot raise a level. */
28
+ const RAISING_STATUSES: SignalStatus[] = ['ACTIVE'];
29
+
30
+ /**
31
+ * Below this, a signal is reported but cannot raise the level on its own.
32
+ *
33
+ * A 30%-confidence four-hop association is a real observation and belongs in
34
+ * the response. It is not grounds for telling a user their transaction is
35
+ * dangerous.
36
+ */
37
+ /**
38
+ * Exported so a renderer cannot drift from the classifier.
39
+ *
40
+ * `@decentrys/ui` decides whether to show a signal as one that counted, and
41
+ * it has to use the same floor the classifier used to decide whether it did.
42
+ * Two copies of this number that disagree would put a signal in the "did not
43
+ * raise the level" list while it was in fact raising it.
44
+ */
45
+ export const MIN_RAISING_CONFIDENCE = 0.5;
46
+
47
+ export interface ClassifyInput {
48
+ facts?: ObservedFact[];
49
+ capabilities?: TechnicalCapability[];
50
+ threatSignals?: ThreatSignal[];
51
+ unknowns?: UnknownField[];
52
+ historyStatus?: HistoryStatus;
53
+ /** 0–100. Coverage, not danger. */
54
+ historyConfidence?: number;
55
+ now?: Date;
56
+ }
57
+
58
+ export function classify(input: ClassifyInput): Assessment {
59
+ const now = input.now ?? new Date();
60
+ const facts = input.facts ?? [];
61
+ const capabilities = input.capabilities ?? [];
62
+ const unknowns = input.unknowns ?? [];
63
+ const historyStatus = input.historyStatus ?? 'LIMITED';
64
+
65
+ // Expiry is applied here rather than trusted from the caller, so a stale
66
+ // signal cannot keep raising a level because nobody re-ran a job.
67
+ const threatSignals = (input.threatSignals ?? []).map((signal) => applyDecay(signal, now));
68
+
69
+ const raising = threatSignals.filter(
70
+ (s) => RAISING_STATUSES.includes(s.status) && s.confidence >= MIN_RAISING_CONFIDENCE,
71
+ );
72
+
73
+ const explanation: string[] = [];
74
+ let level: RiskLevel = 'NO_CRITICAL_RISK_DETECTED';
75
+
76
+ // --- 1. Confirmed malicious -----------------------------------------------
77
+ // The only claim that accuses. It needs a human behind it.
78
+ const confirmed = raising.filter(
79
+ (s) => s.severity === 'CRITICAL' && s.evidence.some((e) => e.analystVerified),
80
+ );
81
+ const confirmedMalicious = confirmed.length > 0;
82
+
83
+ if (confirmedMalicious) {
84
+ level = 'KNOWN_MALICIOUS';
85
+ for (const signal of confirmed) {
86
+ explanation.push(`${signal.explanation} (verified by an analyst)`);
87
+ }
88
+ } else {
89
+ // --- 2. Threat signals --------------------------------------------------
90
+ const critical = raising.filter((s) => s.severity === 'CRITICAL');
91
+ const high = raising.filter((s) => s.severity === 'HIGH');
92
+ const medium = raising.filter((s) => s.severity === 'MEDIUM');
93
+
94
+ if (critical.length > 0) {
95
+ level = 'CRITICAL_THREAT';
96
+ for (const s of critical) explanation.push(s.explanation);
97
+ } else if (high.length > 0) {
98
+ level = high.length > 1 ? 'HIGH_RISK' : 'ELEVATED_RISK';
99
+ for (const s of high) explanation.push(s.explanation);
100
+ } else if (medium.length > 1) {
101
+ level = 'ELEVATED_RISK';
102
+ for (const s of medium) explanation.push(s.explanation);
103
+ } else if (medium.length === 1) {
104
+ level = 'CAUTION';
105
+ explanation.push(medium[0].explanation);
106
+ }
107
+
108
+ // --- 3. Capabilities ----------------------------------------------------
109
+ // Can reach CAUTION and no further. A capability is what the code can do,
110
+ // not proof that it will.
111
+ const significant = capabilities.filter((c) => c.severity === 'SIGNIFICANT');
112
+ if (significant.length > 0 && rank(level) < rank('CAUTION')) {
113
+ level = 'CAUTION';
114
+ for (const c of significant) explanation.push(c.statement);
115
+ } else if (rank(level) < rank('INFORMATIONAL') && capabilities.length > 0) {
116
+ level = 'INFORMATIONAL';
117
+ for (const c of capabilities.slice(0, 3)) explanation.push(c.statement);
118
+ }
119
+ }
120
+
121
+ // Signals that exist but did not raise the level are still reported, and the
122
+ // explanation says why they did not — silence would look like we missed them.
123
+ for (const signal of threatSignals) {
124
+ if (raising.includes(signal)) continue;
125
+ if (signal.status !== 'ACTIVE') {
126
+ explanation.push(
127
+ `${signal.explanation} — this signal is ${signal.status.toLowerCase()} and did not affect the assessment.`,
128
+ );
129
+ } else if (signal.confidence < MIN_RAISING_CONFIDENCE) {
130
+ explanation.push(
131
+ `${signal.explanation} — reported at ${Math.round(signal.confidence * 100)}% confidence, which is too low `
132
+ + 'to raise the risk level on its own.',
133
+ );
134
+ }
135
+ }
136
+
137
+ // --- 4. Facts worth showing make it informational -----------------------
138
+ // Independent of history. Promoting only for LIMITED history would have made
139
+ // the level depend on how new the subject is, which is precisely the
140
+ // behaviour this model exists to refuse — and a test caught it.
141
+ if (level === 'NO_CRITICAL_RISK_DETECTED' && facts.length > 0) {
142
+ level = 'INFORMATIONAL';
143
+ }
144
+
145
+ // Limited history is stated, never scored.
146
+ if (historyStatus === 'LIMITED' || historyStatus === 'NONE') {
147
+ explanation.push(HISTORY_STATUS_MEANING[historyStatus]);
148
+ }
149
+
150
+ if (unknowns.length > 0) {
151
+ explanation.push(
152
+ `${unknowns.length} ${unknowns.length === 1 ? 'attribute is' : 'attributes are'} unknown. `
153
+ + 'Unknown is reported as unknown; it does not contribute to risk.',
154
+ );
155
+ }
156
+
157
+ if (explanation.length === 0) {
158
+ explanation.push(RISK_LEVEL_MEANING[level]);
159
+ }
160
+
161
+ return {
162
+ riskLevel: level,
163
+ confirmedMalicious,
164
+ confidence: confidenceFor(level, raising, historyStatus),
165
+ historyStatus,
166
+ facts,
167
+ capabilities,
168
+ threatSignals,
169
+ unknowns,
170
+ components: {
171
+ technicalRisk: technicalRisk(capabilities),
172
+ behavioralRisk: behavioralRisk(raising),
173
+ threatIntelligenceRisk: threatIntelligenceRisk(raising),
174
+ // Coverage, reported separately so it cannot be summed into a risk total.
175
+ historyConfidence: input.historyConfidence ?? historyConfidenceFor(historyStatus),
176
+ },
177
+ explanation,
178
+ modelVersion: PROTECT_MODEL_VERSION,
179
+ assessedAt: now.toISOString(),
180
+ };
181
+ }
182
+
183
+ function rank(level: RiskLevel): number {
184
+ return [
185
+ 'NO_CRITICAL_RISK_DETECTED', 'INFORMATIONAL', 'CAUTION',
186
+ 'ELEVATED_RISK', 'HIGH_RISK', 'CRITICAL_THREAT', 'KNOWN_MALICIOUS',
187
+ ].indexOf(level);
188
+ }
189
+
190
+ /** Expire a signal whose window has passed, rather than trusting its status. */
191
+ function applyDecay(signal: ThreatSignal, now: Date): ThreatSignal {
192
+ if (signal.status !== 'ACTIVE') return signal;
193
+ if (!signal.expiresAt) return signal;
194
+ return Date.parse(signal.expiresAt) <= now.getTime()
195
+ ? { ...signal, status: 'STALE' }
196
+ : signal;
197
+ }
198
+
199
+ /**
200
+ * How sure we are of the *classification*.
201
+ *
202
+ * Bounded by the evidence behind it. A clean result on thin history is a
203
+ * low-confidence clean result, and saying so is the honest thing — but note it
204
+ * lowers confidence, not raises risk.
205
+ */
206
+ function confidenceFor(level: RiskLevel, raising: ThreatSignal[], history: HistoryStatus): number {
207
+ if (raising.length > 0) {
208
+ const best = Math.max(...raising.map((s) => s.confidence));
209
+ return Number(best.toFixed(2));
210
+ }
211
+ switch (history) {
212
+ case 'ESTABLISHED': return 0.85;
213
+ case 'MODERATE': return 0.7;
214
+ case 'LIMITED': return 0.5;
215
+ default: return 0.35;
216
+ }
217
+ }
218
+
219
+ /** Capabilities only. Contract age contributes nothing, by construction. */
220
+ function technicalRisk(capabilities: TechnicalCapability[]): number {
221
+ const weight = { INFO: 4, NOTABLE: 12, SIGNIFICANT: 25 } as const;
222
+ return Math.min(100, capabilities.reduce((sum, c) => sum + weight[c.severity], 0));
223
+ }
224
+
225
+ function behavioralRisk(raising: ThreatSignal[]): number {
226
+ const weight = { LOW: 5, MEDIUM: 20, HIGH: 40, CRITICAL: 70 } as const;
227
+ return Math.min(100, raising
228
+ .filter((s) => s.hops === 0)
229
+ .reduce((sum, s) => sum + weight[s.severity] * s.confidence, 0));
230
+ }
231
+
232
+ function threatIntelligenceRisk(raising: ThreatSignal[]): number {
233
+ const weight = { LOW: 5, MEDIUM: 15, HIGH: 35, CRITICAL: 60 } as const;
234
+ return Math.min(100, raising.reduce((sum, s) => {
235
+ // Each hop is another inference; weight decays accordingly.
236
+ const decay = Math.pow(0.6, Math.max(0, s.hops));
237
+ return sum + weight[s.severity] * s.confidence * decay;
238
+ }, 0));
239
+ }
240
+
241
+ function historyConfidenceFor(status: HistoryStatus): number {
242
+ switch (status) {
243
+ case 'ESTABLISHED': return 90;
244
+ case 'MODERATE': return 60;
245
+ case 'LIMITED': return 25;
246
+ default: return 5;
247
+ }
248
+ }
249
+
250
+ /**
251
+ * How an integrator turns an assessment into behaviour.
252
+ *
253
+ * The SDK never blocks on its own. Decentrys returns intelligence; the wallet
254
+ * or exchange decides policy, and a consumer wallet and an institutional
255
+ * custodian will reasonably decide differently.
256
+ */
257
+ export type PolicyAction = 'allow' | 'inform' | 'warn' | 'warn_strong' | 'require_confirmation' | 'block';
258
+
259
+ export type Policy = Partial<Record<RiskLevel, PolicyAction>>;
260
+
261
+ /**
262
+ * Transparency over blocking.
263
+ *
264
+ * The default blocks only what is confirmed malicious with analyst-verified
265
+ * evidence. Everything else informs or warns, leaving the decision with the
266
+ * person whose funds are at stake.
267
+ */
268
+ export const DEFAULT_POLICY: Required<Policy> = {
269
+ NO_CRITICAL_RISK_DETECTED: 'allow',
270
+ INFORMATIONAL: 'inform',
271
+ CAUTION: 'warn',
272
+ ELEVATED_RISK: 'warn_strong',
273
+ HIGH_RISK: 'require_confirmation',
274
+ CRITICAL_THREAT: 'require_confirmation',
275
+ KNOWN_MALICIOUS: 'block',
276
+ };
277
+
278
+ export function applyPolicy(assessment: Assessment, policy: Policy = {}): {
279
+ action: PolicyAction;
280
+ reason: string;
281
+ } {
282
+ const action = policy[assessment.riskLevel] ?? DEFAULT_POLICY[assessment.riskLevel];
283
+ return {
284
+ action,
285
+ reason: assessment.explanation[0] ?? RISK_LEVEL_MEANING[assessment.riskLevel],
286
+ };
287
+ }
288
+
289
+ /**
290
+ * What the SDK does when Decentrys is unreachable.
291
+ *
292
+ * A wallet must not become unusable because a security service is down.
293
+ * `warn` is the consumer default; an institution may choose `closed`.
294
+ */
295
+ export type FailMode = 'open' | 'warn' | 'closed';
296
+
297
+ export function unavailableAssessment(failMode: FailMode, reason: string, now = new Date()): Assessment {
298
+ return {
299
+ riskLevel: 'NO_CRITICAL_RISK_DETECTED',
300
+ confirmedMalicious: false,
301
+ confidence: 0,
302
+ historyStatus: 'NONE',
303
+ facts: [],
304
+ capabilities: [],
305
+ threatSignals: [],
306
+ unknowns: [{
307
+ field: 'assessment',
308
+ reason: 'PROVIDER_UNAVAILABLE',
309
+ statement: `Decentrys could not be reached: ${reason}. Nothing was checked.`,
310
+ }],
311
+ components: { technicalRisk: 0, behavioralRisk: 0, threatIntelligenceRisk: 0, historyConfidence: 0 },
312
+ explanation: [
313
+ `Decentrys could not be reached: ${reason}.`,
314
+ failMode === 'closed'
315
+ ? 'This deployment is configured to refuse unverified transactions.'
316
+ : failMode === 'warn'
317
+ ? 'No security check was performed. Proceed with the care you would use without any tool.'
318
+ : 'No security check was performed.',
319
+ ],
320
+ modelVersion: PROTECT_MODEL_VERSION,
321
+ assessedAt: now.toISOString(),
322
+ };
323
+ }
@@ -0,0 +1,224 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { Decentrys } from './client';
3
+ import { TransportError, type RequestOptions, type Transport } from './transport';
4
+
5
+ /** A transport whose every response the test controls. */
6
+ function stub(handler: (options: RequestOptions) => Promise<unknown>): Transport & { calls: RequestOptions[] } {
7
+ const calls: RequestOptions[] = [];
8
+ return {
9
+ calls,
10
+ request: <T>(options: RequestOptions): Promise<T> => {
11
+ calls.push(options);
12
+ return handler(options) as Promise<T>;
13
+ },
14
+ };
15
+ }
16
+
17
+ const CLEAN = { facts: [], capabilities: [], threatSignals: [], unknowns: [], historyStatus: 'LIMITED' };
18
+
19
+ function client(transport: Transport, config: Partial<ConstructorParameters<typeof Decentrys>[0]> = {}) {
20
+ return new Decentrys({ apiKey: 'test-key', transport, ...config });
21
+ }
22
+
23
+ describe('Decentrys — construction', () => {
24
+ it('refuses to construct without an API key, at wiring time', () => {
25
+ expect(() => new Decentrys({ apiKey: '' })).toThrow(/apiKey is required/);
26
+ });
27
+ });
28
+
29
+ describe('Decentrys — availability', () => {
30
+ /**
31
+ * The contract this whole class exists to keep. A wallet that throws an
32
+ * error dialog because a security service had a bad minute has made the
33
+ * user's day worse for no security benefit — and taught them to dismiss the
34
+ * dialog, which is the outcome the product exists to prevent.
35
+ */
36
+ it('never throws when Decentrys is unreachable', async () => {
37
+ const d = client(stub(() => Promise.reject(new TransportError('network', 'connection refused'))));
38
+
39
+ const result = await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
40
+
41
+ expect(result.assessment.riskLevel).toBe('NO_CRITICAL_RISK_DETECTED');
42
+ expect(result.assessment.confidence).toBe(0);
43
+ expect(result.assessment.unknowns[0]?.reason).toBe('PROVIDER_UNAVAILABLE');
44
+ expect(result.assessment.explanation[0]).toContain('connection refused');
45
+ });
46
+
47
+ it('says nothing was checked rather than reporting a clean result', async () => {
48
+ const d = client(stub(() => Promise.reject(new TransportError('timeout', 'no response'))));
49
+
50
+ const result = await d.assessTransaction({ chain: 'ethereum', from: '0x1', to: '0x2' });
51
+ expect(result.assessment.unknowns[0]?.statement).toContain('Nothing was checked');
52
+ });
53
+
54
+ it('warns by default when unavailable, and blocks only in closed mode', async () => {
55
+ const down = stub(() => Promise.reject(new TransportError('network', 'down')));
56
+
57
+ await expect(client(down).screenAddress({ chain: 'ethereum', address: '0xa' }))
58
+ .resolves.toMatchObject({ decision: { action: 'warn' } });
59
+
60
+ await expect(client(down, { failMode: 'open' }).screenAddress({ chain: 'ethereum', address: '0xa' }))
61
+ .resolves.toMatchObject({ decision: { action: 'warn' } });
62
+
63
+ await expect(client(down, { failMode: 'closed' }).screenAddress({ chain: 'ethereum', address: '0xa' }))
64
+ .resolves.toMatchObject({ decision: { action: 'block' } });
65
+ });
66
+
67
+ /**
68
+ * Unavailability must not be dressed up as a finding. `closed` stops the
69
+ * transaction on policy grounds; it does not invent a risk level to justify
70
+ * itself, and the assessment still says plainly that nothing was checked.
71
+ */
72
+ it('does not fabricate a risk level to justify blocking', async () => {
73
+ const d = client(stub(() => Promise.reject(new TransportError('network', 'down'))), { failMode: 'closed' });
74
+
75
+ const result = await d.screenAddress({ chain: 'ethereum', address: '0xa' });
76
+ expect(result.assessment.riskLevel).toBe('NO_CRITICAL_RISK_DETECTED');
77
+ expect(result.assessment.confirmedMalicious).toBe(false);
78
+ });
79
+
80
+ it('returns an unavailable simulation rather than an empty one', async () => {
81
+ const d = client(stub(() => Promise.reject(new TransportError('timeout', 'no response'))));
82
+
83
+ const sim = await d.simulateTransaction({ chain: 'ethereum', from: '0x1' });
84
+ expect(sim.outcome).toBe('UNAVAILABLE');
85
+ expect(sim.unavailableReason).toBe('no response');
86
+ });
87
+
88
+ it('says a transaction could not be decoded rather than inventing a summary', async () => {
89
+ const d = client(stub(() => Promise.reject(new TransportError('network', 'down'))));
90
+
91
+ const explained = await d.explainTransaction({ chain: 'ethereum', from: '0x1' });
92
+ expect(explained.summary).toContain('could not be decoded');
93
+ expect(explained.undecoded[0]).toContain('down');
94
+ });
95
+ });
96
+
97
+ describe('Decentrys — classification is local', () => {
98
+ it('classifies from evidence and ignores any level the server asserts', async () => {
99
+ const d = client(stub(() => Promise.resolve({
100
+ ...CLEAN,
101
+ // A server insisting on a verdict. The SDK does not read it.
102
+ riskLevel: 'KNOWN_MALICIOUS',
103
+ confirmedMalicious: true,
104
+ })));
105
+
106
+ const result = await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
107
+ expect(result.assessment.riskLevel).toBe('NO_CRITICAL_RISK_DETECTED');
108
+ expect(result.assessment.confirmedMalicious).toBe(false);
109
+ });
110
+
111
+ it('reports which coverage signals it refused', async () => {
112
+ const d = client(stub(() => Promise.resolve({
113
+ ...CLEAN,
114
+ threatSignals: [{
115
+ type: 'NEW_ADDRESS', severity: 'HIGH', confidence: 0.9,
116
+ explanation: 'Deployed today.', hops: 0, evidence: [],
117
+ status: 'ACTIVE', createdAt: '2026-09-01T00:00:00.000Z',
118
+ }],
119
+ })));
120
+
121
+ const result = await d.screenToken({ chain: 'ethereum', address: '0xtoken' });
122
+ expect(result.demotedSignals).toEqual(['NEW_ADDRESS']);
123
+ expect(result.assessment.riskLevel).toBe('INFORMATIONAL');
124
+ });
125
+
126
+ it('applies the integrator policy, not its own', async () => {
127
+ const evidence = {
128
+ ...CLEAN,
129
+ capabilities: [{ type: 'MINT_AUTHORITY', severity: 'SIGNIFICANT', statement: 'Supply can be increased.' }],
130
+ };
131
+
132
+ const permissive = await client(stub(() => Promise.resolve(evidence)))
133
+ .scanContract({ chain: 'ethereum', address: '0xc' });
134
+ expect(permissive.decision.action).toBe('warn');
135
+
136
+ const strict = await client(stub(() => Promise.resolve(evidence)), { policy: { CAUTION: 'block' } })
137
+ .scanContract({ chain: 'ethereum', address: '0xc' });
138
+ expect(strict.decision.action).toBe('block');
139
+ });
140
+ });
141
+
142
+ describe('Decentrys — caching', () => {
143
+ it('serves a repeated address lookup from memory', async () => {
144
+ const transport = stub(() => Promise.resolve(CLEAN));
145
+ const d = client(transport);
146
+
147
+ const first = await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
148
+ const second = await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
149
+
150
+ expect(first.cached).toBe(false);
151
+ expect(second.cached).toBe(true);
152
+ expect(transport.calls).toHaveLength(1);
153
+ });
154
+
155
+ it('does not share a cache entry across chains', async () => {
156
+ const transport = stub(() => Promise.resolve(CLEAN));
157
+ const d = client(transport);
158
+
159
+ await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
160
+ await d.screenAddress({ chain: 'polygon', address: '0xabc' });
161
+
162
+ expect(transport.calls).toHaveLength(2);
163
+ });
164
+
165
+ /**
166
+ * A transaction's assessment depends on its calldata and its moment, and an
167
+ * approval's on the allowance being granted. Caching either would answer a
168
+ * different question than the one asked.
169
+ */
170
+ it('never caches a transaction or an approval', async () => {
171
+ const transport = stub(() => Promise.resolve(CLEAN));
172
+ const d = client(transport);
173
+
174
+ await d.assessTransaction({ chain: 'ethereum', from: '0x1', to: '0x2', data: '0xa' });
175
+ await d.assessTransaction({ chain: 'ethereum', from: '0x1', to: '0x2', data: '0xa' });
176
+ await d.screenApproval({ chain: 'ethereum', owner: '0x1', spender: '0x2', token: '0x3' });
177
+ await d.screenApproval({ chain: 'ethereum', owner: '0x1', spender: '0x2', token: '0x3' });
178
+
179
+ expect(transport.calls).toHaveLength(4);
180
+ });
181
+
182
+ it('honours skipCache and clearCache', async () => {
183
+ const transport = stub(() => Promise.resolve(CLEAN));
184
+ const d = client(transport);
185
+
186
+ await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
187
+ await d.screenAddress({ chain: 'ethereum', address: '0xabc' }, { skipCache: true });
188
+ expect(transport.calls).toHaveLength(2);
189
+
190
+ d.clearCache();
191
+ await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
192
+ expect(transport.calls).toHaveLength(3);
193
+ });
194
+
195
+ it('does not cache an unavailable result', async () => {
196
+ const transport = stub(vi.fn()
197
+ .mockRejectedValueOnce(new TransportError('network', 'down'))
198
+ .mockResolvedValue(CLEAN));
199
+ const d = client(transport);
200
+
201
+ const down = await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
202
+ expect(down.assessment.confidence).toBe(0);
203
+
204
+ const recovered = await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
205
+ expect(recovered.cached).toBe(false);
206
+ expect(transport.calls).toHaveLength(2);
207
+ });
208
+ });
209
+
210
+ describe('Decentrys — request shape', () => {
211
+ it('marks lookups idempotent so the transport may retry them', async () => {
212
+ const transport = stub(() => Promise.resolve(CLEAN));
213
+ const d = client(transport);
214
+
215
+ await d.screenAddress({ chain: 'ethereum', address: '0xabc' });
216
+ expect(transport.calls[0]!.idempotent).toBe(true);
217
+ expect(transport.calls[0]!.path).toBe('/v1/protect/address');
218
+ });
219
+
220
+ it('returns no signals rather than throwing when signals cannot be fetched', async () => {
221
+ const d = client(stub(() => Promise.reject(new TransportError('network', 'down'))));
222
+ await expect(d.getThreatSignals({ chain: 'ethereum', address: '0xabc' })).resolves.toEqual([]);
223
+ });
224
+ });