@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,876 @@
1
+ // src/model.ts
2
+ var RISK_LEVELS = [
3
+ "NO_CRITICAL_RISK_DETECTED",
4
+ "INFORMATIONAL",
5
+ "CAUTION",
6
+ "ELEVATED_RISK",
7
+ "HIGH_RISK",
8
+ "CRITICAL_THREAT",
9
+ "KNOWN_MALICIOUS"
10
+ ];
11
+ var RISK_ORDER = {
12
+ NO_CRITICAL_RISK_DETECTED: 0,
13
+ INFORMATIONAL: 1,
14
+ CAUTION: 2,
15
+ ELEVATED_RISK: 3,
16
+ HIGH_RISK: 4,
17
+ CRITICAL_THREAT: 5,
18
+ KNOWN_MALICIOUS: 6
19
+ };
20
+ function isAtLeast(level, floor) {
21
+ return RISK_ORDER[level] >= RISK_ORDER[floor];
22
+ }
23
+ var RISK_LEVEL_MEANING = {
24
+ NO_CRITICAL_RISK_DETECTED: "No known critical threat evidence was identified. This is not an assurance of safety.",
25
+ INFORMATIONAL: "Facts worth knowing before proceeding. Nothing here indicates danger.",
26
+ CAUTION: "A security-sensitive capability or behaviour exists that deserves attention.",
27
+ ELEVATED_RISK: "Several meaningful risk signals are present together.",
28
+ HIGH_RISK: "Strong technical or behavioural evidence of significant danger.",
29
+ CRITICAL_THREAT: "Severe threat supported by concrete evidence.",
30
+ KNOWN_MALICIOUS: "Confirmed malicious infrastructure or behaviour, verified against evidence."
31
+ };
32
+ var HISTORY_STATUS_MEANING = {
33
+ ESTABLISHED: "Substantial on-chain history is available.",
34
+ MODERATE: "Some history is available.",
35
+ LIMITED: "Little history is available yet. This is normal for anything recently deployed and is not a risk finding.",
36
+ NONE: "No history is available. This is not a risk finding."
37
+ };
38
+ var PROTECT_MODEL_VERSION = "protect-1.0.0";
39
+
40
+ // src/classify.ts
41
+ var RAISING_STATUSES = ["ACTIVE"];
42
+ var MIN_RAISING_CONFIDENCE = 0.5;
43
+ function classify(input) {
44
+ const now = input.now ?? /* @__PURE__ */ new Date();
45
+ const facts = input.facts ?? [];
46
+ const capabilities = input.capabilities ?? [];
47
+ const unknowns = input.unknowns ?? [];
48
+ const historyStatus = input.historyStatus ?? "LIMITED";
49
+ const threatSignals = (input.threatSignals ?? []).map((signal) => applyDecay(signal, now));
50
+ const raising = threatSignals.filter(
51
+ (s) => RAISING_STATUSES.includes(s.status) && s.confidence >= MIN_RAISING_CONFIDENCE
52
+ );
53
+ const explanation = [];
54
+ let level = "NO_CRITICAL_RISK_DETECTED";
55
+ const confirmed = raising.filter(
56
+ (s) => s.severity === "CRITICAL" && s.evidence.some((e) => e.analystVerified)
57
+ );
58
+ const confirmedMalicious = confirmed.length > 0;
59
+ if (confirmedMalicious) {
60
+ level = "KNOWN_MALICIOUS";
61
+ for (const signal of confirmed) {
62
+ explanation.push(`${signal.explanation} (verified by an analyst)`);
63
+ }
64
+ } else {
65
+ const critical = raising.filter((s) => s.severity === "CRITICAL");
66
+ const high = raising.filter((s) => s.severity === "HIGH");
67
+ const medium = raising.filter((s) => s.severity === "MEDIUM");
68
+ if (critical.length > 0) {
69
+ level = "CRITICAL_THREAT";
70
+ for (const s of critical) explanation.push(s.explanation);
71
+ } else if (high.length > 0) {
72
+ level = high.length > 1 ? "HIGH_RISK" : "ELEVATED_RISK";
73
+ for (const s of high) explanation.push(s.explanation);
74
+ } else if (medium.length > 1) {
75
+ level = "ELEVATED_RISK";
76
+ for (const s of medium) explanation.push(s.explanation);
77
+ } else if (medium.length === 1) {
78
+ level = "CAUTION";
79
+ explanation.push(medium[0].explanation);
80
+ }
81
+ const significant = capabilities.filter((c) => c.severity === "SIGNIFICANT");
82
+ if (significant.length > 0 && rank(level) < rank("CAUTION")) {
83
+ level = "CAUTION";
84
+ for (const c of significant) explanation.push(c.statement);
85
+ } else if (rank(level) < rank("INFORMATIONAL") && capabilities.length > 0) {
86
+ level = "INFORMATIONAL";
87
+ for (const c of capabilities.slice(0, 3)) explanation.push(c.statement);
88
+ }
89
+ }
90
+ for (const signal of threatSignals) {
91
+ if (raising.includes(signal)) continue;
92
+ if (signal.status !== "ACTIVE") {
93
+ explanation.push(
94
+ `${signal.explanation} \u2014 this signal is ${signal.status.toLowerCase()} and did not affect the assessment.`
95
+ );
96
+ } else if (signal.confidence < MIN_RAISING_CONFIDENCE) {
97
+ explanation.push(
98
+ `${signal.explanation} \u2014 reported at ${Math.round(signal.confidence * 100)}% confidence, which is too low to raise the risk level on its own.`
99
+ );
100
+ }
101
+ }
102
+ if (level === "NO_CRITICAL_RISK_DETECTED" && facts.length > 0) {
103
+ level = "INFORMATIONAL";
104
+ }
105
+ if (historyStatus === "LIMITED" || historyStatus === "NONE") {
106
+ explanation.push(HISTORY_STATUS_MEANING[historyStatus]);
107
+ }
108
+ if (unknowns.length > 0) {
109
+ explanation.push(
110
+ `${unknowns.length} ${unknowns.length === 1 ? "attribute is" : "attributes are"} unknown. Unknown is reported as unknown; it does not contribute to risk.`
111
+ );
112
+ }
113
+ if (explanation.length === 0) {
114
+ explanation.push(RISK_LEVEL_MEANING[level]);
115
+ }
116
+ return {
117
+ riskLevel: level,
118
+ confirmedMalicious,
119
+ confidence: confidenceFor(level, raising, historyStatus),
120
+ historyStatus,
121
+ facts,
122
+ capabilities,
123
+ threatSignals,
124
+ unknowns,
125
+ components: {
126
+ technicalRisk: technicalRisk(capabilities),
127
+ behavioralRisk: behavioralRisk(raising),
128
+ threatIntelligenceRisk: threatIntelligenceRisk(raising),
129
+ // Coverage, reported separately so it cannot be summed into a risk total.
130
+ historyConfidence: input.historyConfidence ?? historyConfidenceFor(historyStatus)
131
+ },
132
+ explanation,
133
+ modelVersion: PROTECT_MODEL_VERSION,
134
+ assessedAt: now.toISOString()
135
+ };
136
+ }
137
+ function rank(level) {
138
+ return [
139
+ "NO_CRITICAL_RISK_DETECTED",
140
+ "INFORMATIONAL",
141
+ "CAUTION",
142
+ "ELEVATED_RISK",
143
+ "HIGH_RISK",
144
+ "CRITICAL_THREAT",
145
+ "KNOWN_MALICIOUS"
146
+ ].indexOf(level);
147
+ }
148
+ function applyDecay(signal, now) {
149
+ if (signal.status !== "ACTIVE") return signal;
150
+ if (!signal.expiresAt) return signal;
151
+ return Date.parse(signal.expiresAt) <= now.getTime() ? { ...signal, status: "STALE" } : signal;
152
+ }
153
+ function confidenceFor(level, raising, history) {
154
+ if (raising.length > 0) {
155
+ const best = Math.max(...raising.map((s) => s.confidence));
156
+ return Number(best.toFixed(2));
157
+ }
158
+ switch (history) {
159
+ case "ESTABLISHED":
160
+ return 0.85;
161
+ case "MODERATE":
162
+ return 0.7;
163
+ case "LIMITED":
164
+ return 0.5;
165
+ default:
166
+ return 0.35;
167
+ }
168
+ }
169
+ function technicalRisk(capabilities) {
170
+ const weight = { INFO: 4, NOTABLE: 12, SIGNIFICANT: 25 };
171
+ return Math.min(100, capabilities.reduce((sum, c) => sum + weight[c.severity], 0));
172
+ }
173
+ function behavioralRisk(raising) {
174
+ const weight = { LOW: 5, MEDIUM: 20, HIGH: 40, CRITICAL: 70 };
175
+ return Math.min(100, raising.filter((s) => s.hops === 0).reduce((sum, s) => sum + weight[s.severity] * s.confidence, 0));
176
+ }
177
+ function threatIntelligenceRisk(raising) {
178
+ const weight = { LOW: 5, MEDIUM: 15, HIGH: 35, CRITICAL: 60 };
179
+ return Math.min(100, raising.reduce((sum, s) => {
180
+ const decay = Math.pow(0.6, Math.max(0, s.hops));
181
+ return sum + weight[s.severity] * s.confidence * decay;
182
+ }, 0));
183
+ }
184
+ function historyConfidenceFor(status) {
185
+ switch (status) {
186
+ case "ESTABLISHED":
187
+ return 90;
188
+ case "MODERATE":
189
+ return 60;
190
+ case "LIMITED":
191
+ return 25;
192
+ default:
193
+ return 5;
194
+ }
195
+ }
196
+ var DEFAULT_POLICY = {
197
+ NO_CRITICAL_RISK_DETECTED: "allow",
198
+ INFORMATIONAL: "inform",
199
+ CAUTION: "warn",
200
+ ELEVATED_RISK: "warn_strong",
201
+ HIGH_RISK: "require_confirmation",
202
+ CRITICAL_THREAT: "require_confirmation",
203
+ KNOWN_MALICIOUS: "block"
204
+ };
205
+ function applyPolicy(assessment, policy = {}) {
206
+ const action = policy[assessment.riskLevel] ?? DEFAULT_POLICY[assessment.riskLevel];
207
+ return {
208
+ action,
209
+ reason: assessment.explanation[0] ?? RISK_LEVEL_MEANING[assessment.riskLevel]
210
+ };
211
+ }
212
+ function unavailableAssessment(failMode, reason, now = /* @__PURE__ */ new Date()) {
213
+ return {
214
+ riskLevel: "NO_CRITICAL_RISK_DETECTED",
215
+ confirmedMalicious: false,
216
+ confidence: 0,
217
+ historyStatus: "NONE",
218
+ facts: [],
219
+ capabilities: [],
220
+ threatSignals: [],
221
+ unknowns: [{
222
+ field: "assessment",
223
+ reason: "PROVIDER_UNAVAILABLE",
224
+ statement: `Decentrys could not be reached: ${reason}. Nothing was checked.`
225
+ }],
226
+ components: { technicalRisk: 0, behavioralRisk: 0, threatIntelligenceRisk: 0, historyConfidence: 0 },
227
+ explanation: [
228
+ `Decentrys could not be reached: ${reason}.`,
229
+ failMode === "closed" ? "This deployment is configured to refuse unverified transactions." : failMode === "warn" ? "No security check was performed. Proceed with the care you would use without any tool." : "No security check was performed."
230
+ ],
231
+ modelVersion: PROTECT_MODEL_VERSION,
232
+ assessedAt: now.toISOString()
233
+ };
234
+ }
235
+
236
+ // src/wire.ts
237
+ var COVERAGE_SIGNAL_TYPES = /* @__PURE__ */ new Set([
238
+ "NEW_ADDRESS",
239
+ "NEW_CONTRACT",
240
+ "NEW_DEPLOYMENT",
241
+ "LOW_ACTIVITY",
242
+ "LIMITED_HISTORY",
243
+ "NO_HISTORY",
244
+ "ESTABLISHED_HISTORY",
245
+ "UNVERIFIED_SOURCE",
246
+ "UNVERIFIED_CONTRACT",
247
+ "NO_AUDIT",
248
+ "UNAUDITED",
249
+ "ANONYMOUS_DEPLOYER",
250
+ "ANONYMOUS_TEAM",
251
+ "UNKNOWN_DEPLOYER",
252
+ "LOW_LIQUIDITY",
253
+ "THIN_LIQUIDITY",
254
+ "SMALL_MARKET_CAP",
255
+ "HOLDER_CONCENTRATION",
256
+ "LOW_HOLDER_COUNT",
257
+ "NOT_ON_TOKEN_LIST",
258
+ "UNKNOWN_TOKEN",
259
+ "NO_SOCIAL_PRESENCE"
260
+ ]);
261
+ var NO_HISTORY_TYPES = /* @__PURE__ */ new Set(["NO_HISTORY", "UNKNOWN_TOKEN"]);
262
+ function normalizeEvidence(raw, fallback) {
263
+ const body = isRecord(raw) ? raw : {};
264
+ const demotedSignals = [];
265
+ const facts = asArray(body.facts).map(toFact).filter(isPresent);
266
+ const capabilities = asArray(body.capabilities).map(toCapability).filter(isPresent);
267
+ const unknowns = asArray(body.unknowns).map(toUnknown).filter(isPresent);
268
+ const threatSignals = [];
269
+ let noHistoryObserved = false;
270
+ for (const item of asArray(body.threatSignals)) {
271
+ const signal = toSignal(item);
272
+ if (!signal) continue;
273
+ if (COVERAGE_SIGNAL_TYPES.has(signal.type)) {
274
+ demotedSignals.push(signal.type);
275
+ if (NO_HISTORY_TYPES.has(signal.type)) noHistoryObserved = true;
276
+ facts.push({
277
+ type: signal.type,
278
+ value: null,
279
+ statement: signal.explanation,
280
+ source: signal.evidence[0]?.source ?? "decentrys",
281
+ observedAt: signal.lastSeen
282
+ });
283
+ continue;
284
+ }
285
+ threatSignals.push(signal);
286
+ }
287
+ const declaredHistory = asHistoryStatus(body.historyStatus);
288
+ const historyStatus = declaredHistory ?? (noHistoryObserved ? "NONE" : "LIMITED");
289
+ return {
290
+ subject: toSubject(body.subject, fallback),
291
+ facts,
292
+ capabilities,
293
+ threatSignals,
294
+ unknowns,
295
+ historyStatus,
296
+ historyConfidence: asNumber(body.historyConfidence) ?? void 0,
297
+ producedAt: asString(body.producedAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
298
+ demotedSignals
299
+ };
300
+ }
301
+ function toSubject(raw, fallback) {
302
+ if (!isRecord(raw)) return fallback;
303
+ const kind = asString(raw.kind);
304
+ return {
305
+ kind: isSubjectKind(kind) ? kind : fallback.kind,
306
+ chain: asString(raw.chain) ?? fallback.chain,
307
+ identifier: asString(raw.identifier) ?? fallback.identifier
308
+ };
309
+ }
310
+ function toFact(raw) {
311
+ if (!isRecord(raw)) return null;
312
+ const type = asString(raw.type);
313
+ const statement = asString(raw.statement);
314
+ if (!type || !statement) return null;
315
+ return {
316
+ type,
317
+ value: asFactValue(raw.value),
318
+ statement,
319
+ source: asString(raw.source) ?? "decentrys",
320
+ observedAt: asString(raw.observedAt) ?? (/* @__PURE__ */ new Date()).toISOString()
321
+ };
322
+ }
323
+ function toCapability(raw) {
324
+ if (!isRecord(raw)) return null;
325
+ const type = asString(raw.type);
326
+ const statement = asString(raw.statement);
327
+ if (!type || !statement) return null;
328
+ const severity = asString(raw.severity);
329
+ return {
330
+ type,
331
+ // An unrecognised severity becomes INFO, the least consequential value.
332
+ // Guessing upward would let a typo raise someone's risk level.
333
+ severity: severity === "NOTABLE" || severity === "SIGNIFICANT" ? severity : "INFO",
334
+ statement,
335
+ grantedBy: asString(raw.grantedBy) ?? void 0
336
+ };
337
+ }
338
+ function toSignal(raw) {
339
+ if (!isRecord(raw)) return null;
340
+ const type = asString(raw.type);
341
+ const explanation = asString(raw.explanation);
342
+ if (!type || !explanation) return null;
343
+ const evidence = asArray(raw.evidence).map(toEvidence).filter(isPresent);
344
+ const createdAt = asString(raw.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString();
345
+ return {
346
+ type,
347
+ severity: asSeverity(raw.severity),
348
+ confidence: clamp01(asNumber(raw.confidence) ?? 0),
349
+ explanation,
350
+ // A missing hop count means we do not know how far away this is, and an
351
+ // unknown distance is an inference, not a direct observation.
352
+ hops: Math.max(0, Math.trunc(asNumber(raw.hops) ?? 1)),
353
+ evidence,
354
+ status: asSignalStatus(raw.status),
355
+ createdAt,
356
+ lastSeen: asString(raw.lastSeen) ?? createdAt,
357
+ expiresAt: asString(raw.expiresAt) ?? void 0
358
+ };
359
+ }
360
+ function toEvidence(raw) {
361
+ if (!isRecord(raw)) return null;
362
+ const id = asString(raw.id);
363
+ const type = asString(raw.type);
364
+ if (!id || !type) return null;
365
+ return {
366
+ id,
367
+ type,
368
+ source: asString(raw.source) ?? "decentrys",
369
+ chain: asString(raw.chain) ?? void 0,
370
+ txHash: asString(raw.txHash) ?? void 0,
371
+ contract: asString(raw.contract) ?? void 0,
372
+ address: asString(raw.address) ?? void 0,
373
+ observedAt: asString(raw.observedAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
374
+ confidence: clamp01(asNumber(raw.confidence) ?? 0),
375
+ // Defaults to false. `analystVerified` is what gates KNOWN_MALICIOUS, so
376
+ // an absent field must never be read as a human having checked.
377
+ analystVerified: raw.analystVerified === true,
378
+ metadata: isRecord(raw.metadata) ? raw.metadata : void 0
379
+ };
380
+ }
381
+ function toUnknown(raw) {
382
+ if (!isRecord(raw)) return null;
383
+ const field = asString(raw.field);
384
+ const statement = asString(raw.statement);
385
+ if (!field || !statement) return null;
386
+ const reason = asString(raw.reason);
387
+ const reasons = ["UNKNOWN", "INSUFFICIENT_DATA", "PROVIDER_UNAVAILABLE", "NOT_APPLICABLE"];
388
+ return {
389
+ field,
390
+ reason: reasons.includes(reason) ? reason : "UNKNOWN",
391
+ statement
392
+ };
393
+ }
394
+ function isRecord(value) {
395
+ return typeof value === "object" && value !== null && !Array.isArray(value);
396
+ }
397
+ function asArray(value) {
398
+ return Array.isArray(value) ? value : [];
399
+ }
400
+ function asString(value) {
401
+ return typeof value === "string" && value.length > 0 ? value : null;
402
+ }
403
+ function asNumber(value) {
404
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
405
+ }
406
+ function asFactValue(value) {
407
+ if (typeof value === "string" || typeof value === "boolean") return value;
408
+ if (typeof value === "number" && Number.isFinite(value)) return value;
409
+ return null;
410
+ }
411
+ function asSeverity(value) {
412
+ const severities = ["LOW", "MEDIUM", "HIGH", "CRITICAL"];
413
+ return severities.includes(value) ? value : "LOW";
414
+ }
415
+ function asSignalStatus(value) {
416
+ const statuses = ["ACTIVE", "STALE", "RESOLVED", "DISPUTED_FACT", "REMOVED"];
417
+ return statuses.includes(value) ? value : "ACTIVE";
418
+ }
419
+ function asHistoryStatus(value) {
420
+ const statuses = ["ESTABLISHED", "MODERATE", "LIMITED", "NONE"];
421
+ return statuses.includes(value) ? value : null;
422
+ }
423
+ function isSubjectKind(value) {
424
+ return value === "address" || value === "contract" || value === "token" || value === "transaction" || value === "approval" || value === "dapp";
425
+ }
426
+ function clamp01(value) {
427
+ return Math.min(1, Math.max(0, value));
428
+ }
429
+ function isPresent(value) {
430
+ return value !== null;
431
+ }
432
+
433
+ // src/simulation.ts
434
+ function unavailableSimulation(reason, now = /* @__PURE__ */ new Date()) {
435
+ return {
436
+ outcome: "UNAVAILABLE",
437
+ balanceChanges: [],
438
+ approvalChanges: [],
439
+ contractsCalled: [],
440
+ unavailableReason: reason,
441
+ simulatedAt: now.toISOString()
442
+ };
443
+ }
444
+
445
+ // src/transport.ts
446
+ var TransportError = class extends Error {
447
+ failure;
448
+ status;
449
+ constructor(failure, message, status) {
450
+ super(message);
451
+ this.name = "TransportError";
452
+ this.failure = failure;
453
+ this.status = status;
454
+ }
455
+ };
456
+ function failureForStatus(status) {
457
+ if (status === 401) return "unauthorized";
458
+ if (status === 403) return "forbidden";
459
+ if (status === 429) return "rate_limited";
460
+ if (status >= 400 && status < 500) return "invalid_request";
461
+ return "server_error";
462
+ }
463
+ var RETRYABLE = ["network", "timeout", "server_error", "rate_limited"];
464
+ var HttpTransport = class {
465
+ constructor(config) {
466
+ this.config = config;
467
+ }
468
+ config;
469
+ async request(options) {
470
+ const attempts = options.idempotent ? this.config.retries + 1 : 1;
471
+ let last = new TransportError("network", "No attempt was made.");
472
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
473
+ try {
474
+ return await this.attempt(options);
475
+ } catch (error) {
476
+ last = error instanceof TransportError ? error : new TransportError("network", error instanceof Error ? error.message : "Unknown error.");
477
+ if (!RETRYABLE.includes(last.failure)) throw last;
478
+ if (attempt === attempts - 1) throw last;
479
+ await delay(120 * (attempt + 1));
480
+ }
481
+ }
482
+ throw last;
483
+ }
484
+ async attempt(options) {
485
+ const controller = new AbortController();
486
+ const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
487
+ const onAbort = () => controller.abort();
488
+ options.signal?.addEventListener("abort", onAbort);
489
+ try {
490
+ const response = await this.config.fetch(joinUrl(this.config.baseUrl, options.path), {
491
+ method: "POST",
492
+ headers: {
493
+ "content-type": "application/json",
494
+ "x-api-key": this.config.apiKey,
495
+ "user-agent": this.config.userAgent
496
+ },
497
+ body: JSON.stringify(options.body ?? {}),
498
+ signal: controller.signal
499
+ });
500
+ const text = await response.text();
501
+ if (!response.ok) {
502
+ throw new TransportError(
503
+ failureForStatus(response.status),
504
+ messageFrom(text) ?? `Decentrys returned HTTP ${response.status}.`,
505
+ response.status
506
+ );
507
+ }
508
+ let parsed;
509
+ try {
510
+ parsed = JSON.parse(text);
511
+ } catch {
512
+ throw new TransportError("malformed_response", "The response was not valid JSON.");
513
+ }
514
+ const envelope = parsed;
515
+ return envelope && typeof envelope === "object" && "data" in envelope ? envelope.data : parsed;
516
+ } catch (error) {
517
+ if (error instanceof TransportError) throw error;
518
+ if (isAbort(error)) {
519
+ throw options.signal?.aborted ? new TransportError("network", "The request was cancelled by the caller.") : new TransportError("timeout", `No response within ${this.config.timeoutMs}ms.`);
520
+ }
521
+ throw new TransportError("network", error instanceof Error ? error.message : "Network request failed.");
522
+ } finally {
523
+ clearTimeout(timer);
524
+ options.signal?.removeEventListener("abort", onAbort);
525
+ }
526
+ }
527
+ };
528
+ function isAbort(error) {
529
+ return typeof error === "object" && error !== null && (error.name === "AbortError" || error.code === "ABORT_ERR");
530
+ }
531
+ function messageFrom(text) {
532
+ try {
533
+ const body = JSON.parse(text);
534
+ if (typeof body.message === "string" && body.message.trim()) return body.message;
535
+ if (typeof body.error === "string" && body.error.trim()) return body.error;
536
+ } catch {
537
+ }
538
+ return null;
539
+ }
540
+ function joinUrl(base, path) {
541
+ return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
542
+ }
543
+ function delay(ms) {
544
+ return new Promise((resolve) => {
545
+ setTimeout(resolve, ms);
546
+ });
547
+ }
548
+
549
+ // src/cache.ts
550
+ var TtlCache = class {
551
+ constructor(options) {
552
+ this.options = options;
553
+ this.now = options.now ?? (() => Date.now());
554
+ }
555
+ options;
556
+ entries = /* @__PURE__ */ new Map();
557
+ now;
558
+ get(key) {
559
+ const entry = this.entries.get(key);
560
+ if (!entry) return void 0;
561
+ if (entry.expiresAt <= this.now()) {
562
+ this.entries.delete(key);
563
+ return void 0;
564
+ }
565
+ this.entries.delete(key);
566
+ this.entries.set(key, entry);
567
+ return entry.value;
568
+ }
569
+ set(key, value, ttlMs = this.options.ttlMs) {
570
+ if (ttlMs <= 0 || this.options.maxEntries <= 0) return;
571
+ this.entries.delete(key);
572
+ this.entries.set(key, { value, expiresAt: this.now() + ttlMs });
573
+ while (this.entries.size > this.options.maxEntries) {
574
+ const oldest = this.entries.keys().next();
575
+ if (oldest.done) break;
576
+ this.entries.delete(oldest.value);
577
+ }
578
+ }
579
+ delete(key) {
580
+ this.entries.delete(key);
581
+ }
582
+ clear() {
583
+ this.entries.clear();
584
+ }
585
+ get size() {
586
+ return this.entries.size;
587
+ }
588
+ };
589
+ function cacheKey(kind, parts) {
590
+ return [kind, ...parts.map((p) => p === void 0 || p === null ? "" : String(p))].join("|");
591
+ }
592
+
593
+ // src/client.ts
594
+ var SDK_VERSION = "0.1.0";
595
+ var DEFAULT_BASE_URL = "https://api.decentrys.com";
596
+ var DEFAULT_TIMEOUT_MS = 4e3;
597
+ var DEFAULT_CACHE_TTL_MS = 12e4;
598
+ var DEFAULT_CACHE_ENTRIES = 500;
599
+ var Decentrys = class {
600
+ transport;
601
+ failMode;
602
+ policy;
603
+ cache;
604
+ constructor(config) {
605
+ if (!config.apiKey || !config.apiKey.trim()) {
606
+ throw new Error("Decentrys: an apiKey is required. Create one at https://decentrys.com/developers.");
607
+ }
608
+ this.failMode = config.failMode ?? "warn";
609
+ this.policy = config.policy ?? {};
610
+ this.cache = new TtlCache({
611
+ ttlMs: config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS,
612
+ maxEntries: config.cacheMaxEntries ?? DEFAULT_CACHE_ENTRIES
613
+ });
614
+ this.transport = config.transport ?? new HttpTransport({
615
+ baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
616
+ apiKey: config.apiKey,
617
+ timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
618
+ retries: config.retries ?? 1,
619
+ fetch: config.fetch ?? resolveFetch(),
620
+ userAgent: `decentrys-protect/${SDK_VERSION}`
621
+ });
622
+ }
623
+ // -------------------------------------------------------------------------
624
+ // Assessments
625
+ // -------------------------------------------------------------------------
626
+ /** Pre-sign analysis of a transaction the user is about to approve. */
627
+ async assessTransaction(tx, options = {}) {
628
+ return this.assess({
629
+ path: "/v1/protect/transaction",
630
+ body: tx,
631
+ subject: { kind: "transaction", chain: tx.chain, identifier: tx.to ?? tx.from },
632
+ // A transaction's assessment depends on its calldata and its moment.
633
+ // Caching one would serve a stale answer for a different transaction.
634
+ cacheable: false,
635
+ idempotent: true,
636
+ options
637
+ });
638
+ }
639
+ /** What a contract *can* do — capabilities, proxy status, admin controls. */
640
+ async scanContract(request, options = {}) {
641
+ return this.assess({
642
+ path: "/v1/protect/contract",
643
+ body: request,
644
+ subject: { kind: "contract", chain: request.chain, identifier: request.address },
645
+ cacheable: true,
646
+ idempotent: true,
647
+ options
648
+ });
649
+ }
650
+ async screenAddress(request, options = {}) {
651
+ return this.assess({
652
+ path: "/v1/protect/address",
653
+ body: request,
654
+ subject: { kind: "address", chain: request.chain, identifier: request.address },
655
+ cacheable: true,
656
+ idempotent: true,
657
+ options
658
+ });
659
+ }
660
+ async screenToken(request, options = {}) {
661
+ return this.assess({
662
+ path: "/v1/protect/token",
663
+ body: request,
664
+ subject: { kind: "token", chain: request.chain, identifier: request.address },
665
+ cacheable: true,
666
+ idempotent: true,
667
+ options
668
+ });
669
+ }
670
+ /**
671
+ * An approval is assessed on the spender and the allowance together.
672
+ *
673
+ * Not cached: the same spender with an unlimited allowance and with a
674
+ * one-off allowance are different decisions, and the amount is the part a
675
+ * user most needs told.
676
+ */
677
+ async screenApproval(request, options = {}) {
678
+ return this.assess({
679
+ path: "/v1/protect/approval",
680
+ body: request,
681
+ subject: { kind: "approval", chain: request.chain, identifier: request.spender },
682
+ cacheable: false,
683
+ idempotent: true,
684
+ options
685
+ });
686
+ }
687
+ async assessDapp(request, options = {}) {
688
+ return this.assess({
689
+ path: "/v1/protect/dapp",
690
+ body: request,
691
+ subject: { kind: "dapp", chain: request.chain ?? "multi", identifier: request.origin },
692
+ cacheable: true,
693
+ idempotent: true,
694
+ options
695
+ });
696
+ }
697
+ /**
698
+ * The threat signals on a subject, without a classification.
699
+ *
700
+ * For integrators building their own presentation. An empty array means no
701
+ * signals were found — which is not the same as safe, and the SDK will not
702
+ * pretend otherwise on their behalf.
703
+ */
704
+ async getThreatSignals(request, options = {}) {
705
+ try {
706
+ const raw = await this.transport.request({
707
+ path: "/v1/protect/signals",
708
+ body: request,
709
+ idempotent: true,
710
+ signal: options.signal
711
+ });
712
+ return normalizeEvidence(raw, {
713
+ kind: "address",
714
+ chain: request.chain,
715
+ identifier: request.address
716
+ }).threatSignals;
717
+ } catch {
718
+ return [];
719
+ }
720
+ }
721
+ // -------------------------------------------------------------------------
722
+ // Decoding and simulation
723
+ // -------------------------------------------------------------------------
724
+ /** What this transaction does, in the words a user would use. */
725
+ async explainTransaction(tx, options = {}) {
726
+ try {
727
+ const raw = await this.transport.request({
728
+ path: "/v1/protect/explain",
729
+ body: tx,
730
+ idempotent: true,
731
+ signal: options.signal
732
+ });
733
+ return {
734
+ summary: typeof raw?.summary === "string" && raw.summary ? raw.summary : "This transaction could not be decoded.",
735
+ actions: stringList(raw?.actions),
736
+ exposure: stringList(raw?.exposure),
737
+ undecoded: stringList(raw?.undecoded)
738
+ };
739
+ } catch (error) {
740
+ return {
741
+ summary: "This transaction could not be decoded.",
742
+ actions: [],
743
+ exposure: [],
744
+ undecoded: [`Decentrys could not be reached: ${describe(error)}.`]
745
+ };
746
+ }
747
+ }
748
+ /** Execute the transaction against a fork and report what would change. */
749
+ async simulateTransaction(tx, options = {}) {
750
+ try {
751
+ const raw = await this.transport.request({
752
+ path: "/v1/protect/simulate",
753
+ body: tx,
754
+ idempotent: true,
755
+ signal: options.signal
756
+ });
757
+ return normalizeSimulation(raw);
758
+ } catch (error) {
759
+ return unavailableSimulation(describe(error));
760
+ }
761
+ }
762
+ // -------------------------------------------------------------------------
763
+ // Cache control
764
+ // -------------------------------------------------------------------------
765
+ /** Drop cached evidence. Call after a user reports a stale result. */
766
+ clearCache() {
767
+ this.cache.clear();
768
+ }
769
+ // -------------------------------------------------------------------------
770
+ // Internals
771
+ // -------------------------------------------------------------------------
772
+ async assess(params) {
773
+ const key = params.cacheable ? cacheKey(params.subject.kind, [params.subject.chain, params.subject.identifier]) : null;
774
+ if (key && !params.options.skipCache) {
775
+ const hit = this.cache.get(key);
776
+ if (hit) return this.finish(hit, true);
777
+ }
778
+ let evidence;
779
+ try {
780
+ const raw = await this.transport.request({
781
+ path: params.path,
782
+ body: params.body,
783
+ idempotent: params.idempotent,
784
+ signal: params.options.signal
785
+ });
786
+ evidence = normalizeEvidence(raw, params.subject);
787
+ } catch (error) {
788
+ return {
789
+ subject: params.subject,
790
+ assessment: unavailableAssessment(this.failMode, describe(error)),
791
+ decision: {
792
+ // `closed` is the only mode where unavailability is itself a stop.
793
+ // The others must not fabricate a risk level to justify blocking.
794
+ action: this.failMode === "closed" ? "block" : "warn",
795
+ reason: `Decentrys could not be reached: ${describe(error)}.`
796
+ },
797
+ cached: false,
798
+ demotedSignals: []
799
+ };
800
+ }
801
+ if (key) this.cache.set(key, evidence);
802
+ return this.finish(evidence, false);
803
+ }
804
+ finish(evidence, cached) {
805
+ const assessment = classify({
806
+ facts: evidence.facts,
807
+ capabilities: evidence.capabilities,
808
+ threatSignals: evidence.threatSignals,
809
+ unknowns: evidence.unknowns,
810
+ historyStatus: evidence.historyStatus,
811
+ historyConfidence: evidence.historyConfidence
812
+ });
813
+ return {
814
+ subject: evidence.subject,
815
+ assessment,
816
+ decision: applyPolicy(assessment, this.policy),
817
+ cached,
818
+ demotedSignals: evidence.demotedSignals
819
+ };
820
+ }
821
+ };
822
+ function resolveFetch() {
823
+ const candidate = globalThis.fetch;
824
+ if (typeof candidate !== "function") {
825
+ throw new Error(
826
+ "Decentrys: no global fetch was found. Pass one via `new Decentrys({ fetch })` (Node 18+, modern browsers and React Native provide one)."
827
+ );
828
+ }
829
+ return candidate.bind(globalThis);
830
+ }
831
+ function describe(error) {
832
+ if (error instanceof TransportError) return error.message;
833
+ if (error instanceof Error) return error.message;
834
+ return "unknown error";
835
+ }
836
+ function stringList(value) {
837
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.length > 0) : [];
838
+ }
839
+ function normalizeSimulation(raw) {
840
+ const body = typeof raw === "object" && raw !== null ? raw : {};
841
+ const outcomes = ["SUCCESS", "REVERT", "NOT_SUPPORTED", "UNAVAILABLE"];
842
+ return {
843
+ // An unrecognised outcome is not a success. Defaulting the other way would
844
+ // let a malformed response read as "this transaction is fine".
845
+ outcome: outcomes.includes(body.outcome) ? body.outcome : "UNAVAILABLE",
846
+ revertReason: typeof body.revertReason === "string" ? body.revertReason : void 0,
847
+ balanceChanges: Array.isArray(body.balanceChanges) ? body.balanceChanges : [],
848
+ approvalChanges: Array.isArray(body.approvalChanges) ? body.approvalChanges : [],
849
+ contractsCalled: stringList(body.contractsCalled),
850
+ gasUsed: typeof body.gasUsed === "string" ? body.gasUsed : void 0,
851
+ unavailableReason: typeof body.unavailableReason === "string" ? body.unavailableReason : void 0,
852
+ simulatedAt: typeof body.simulatedAt === "string" ? body.simulatedAt : (/* @__PURE__ */ new Date()).toISOString()
853
+ };
854
+ }
855
+ export {
856
+ COVERAGE_SIGNAL_TYPES,
857
+ DEFAULT_POLICY,
858
+ Decentrys,
859
+ HISTORY_STATUS_MEANING,
860
+ HttpTransport,
861
+ MIN_RAISING_CONFIDENCE,
862
+ PROTECT_MODEL_VERSION,
863
+ RISK_LEVELS,
864
+ RISK_LEVEL_MEANING,
865
+ SDK_VERSION,
866
+ TransportError,
867
+ TtlCache,
868
+ applyPolicy,
869
+ cacheKey,
870
+ classify,
871
+ isAtLeast,
872
+ joinUrl,
873
+ normalizeEvidence,
874
+ unavailableAssessment,
875
+ unavailableSimulation
876
+ };