@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
package/src/wire.ts ADDED
@@ -0,0 +1,321 @@
1
+ /**
2
+ * The wire contract, and the boundary that defends the rule.
3
+ *
4
+ * Decentrys sends **evidence**, not a verdict. The SDK classifies locally with
5
+ * `classify()`. That is not a stylistic choice — it has two consequences that
6
+ * matter:
7
+ *
8
+ * - An integrator can audit exactly why a level was reached, in code they can
9
+ * read, without trusting a remote score.
10
+ * - The rule is enforced on the client, where it cannot be undone by a server
11
+ * change, a proxy, or a future endpoint written by someone who has not read
12
+ * the model.
13
+ *
14
+ * That last point is concrete rather than theoretical. Decentrys also operates
15
+ * an AML engine for exchanges and custodians, and in *that* product a new
16
+ * address legitimately raises a score — money-laundering typologies genuinely
17
+ * care about freshly created accounts, and regulators expect it to be flagged.
18
+ * The same signal reaching a consumer wallet unchanged would tax every new
19
+ * project on earth. So the boundary below refuses coverage signals no matter
20
+ * who sends them, and turns them into what they actually are: history.
21
+ */
22
+
23
+ import type {
24
+ Evidence, FactValue, HistoryStatus, ObservedFact, SignalStatus, TechnicalCapability,
25
+ ThreatSeverity, ThreatSignal, UnknownField, UnknownReason,
26
+ } from './model';
27
+
28
+ export type SubjectKind = 'address' | 'contract' | 'token' | 'transaction' | 'approval' | 'dapp';
29
+
30
+ export interface SubjectRef {
31
+ kind: SubjectKind;
32
+ chain: string;
33
+ identifier: string;
34
+ }
35
+
36
+ /** What every Protect endpoint returns. Evidence in, classification local. */
37
+ export interface EvidencePayload {
38
+ subject: SubjectRef;
39
+ facts: ObservedFact[];
40
+ capabilities: TechnicalCapability[];
41
+ threatSignals: ThreatSignal[];
42
+ unknowns: UnknownField[];
43
+ historyStatus: HistoryStatus;
44
+ historyConfidence?: number;
45
+ producedAt: string;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Coverage signals — never threat signals
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Signal types that describe how much *we* know, not how dangerous a subject
54
+ * is. If one arrives as a threat signal it is demoted to a fact and its
55
+ * explanation is preserved, so nothing is hidden — it simply stops being able
56
+ * to raise a risk level.
57
+ *
58
+ * Every entry here is a real signal name used somewhere in the industry, and
59
+ * several are emitted by Decentrys' own AML engine.
60
+ */
61
+ export const COVERAGE_SIGNAL_TYPES = new Set([
62
+ 'NEW_ADDRESS',
63
+ 'NEW_CONTRACT',
64
+ 'NEW_DEPLOYMENT',
65
+ 'LOW_ACTIVITY',
66
+ 'LIMITED_HISTORY',
67
+ 'NO_HISTORY',
68
+ 'ESTABLISHED_HISTORY',
69
+ 'UNVERIFIED_SOURCE',
70
+ 'UNVERIFIED_CONTRACT',
71
+ 'NO_AUDIT',
72
+ 'UNAUDITED',
73
+ 'ANONYMOUS_DEPLOYER',
74
+ 'ANONYMOUS_TEAM',
75
+ 'UNKNOWN_DEPLOYER',
76
+ 'LOW_LIQUIDITY',
77
+ 'THIN_LIQUIDITY',
78
+ 'SMALL_MARKET_CAP',
79
+ 'HOLDER_CONCENTRATION',
80
+ 'LOW_HOLDER_COUNT',
81
+ 'NOT_ON_TOKEN_LIST',
82
+ 'UNKNOWN_TOKEN',
83
+ 'NO_SOCIAL_PRESENCE',
84
+ ]);
85
+
86
+ /**
87
+ * The coverage signals that say there is *no* record at all, as opposed to a
88
+ * thin one. Used only when the server sent no `historyStatus`: without this,
89
+ * demoting the signal would throw away the one thing it told us, and a subject
90
+ * we know nothing about would be reported as merely thinly covered.
91
+ */
92
+ const NO_HISTORY_TYPES = new Set(['NO_HISTORY', 'UNKNOWN_TOKEN']);
93
+
94
+ export interface NormalizedEvidence extends EvidencePayload {
95
+ /** Signals demoted at the boundary, kept so an integrator can see it happen. */
96
+ demotedSignals: string[];
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Defensive normalization
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Coerce an untrusted payload into the model's shape.
105
+ *
106
+ * A wallet must not crash because a field arrived as a number instead of a
107
+ * string, or because a proxy inserted something. Anything unusable is dropped
108
+ * rather than guessed at, and a dropped item is simply absent — never
109
+ * converted into a finding.
110
+ */
111
+ export function normalizeEvidence(raw: unknown, fallback: SubjectRef): NormalizedEvidence {
112
+ const body = isRecord(raw) ? raw : {};
113
+ const demotedSignals: string[] = [];
114
+
115
+ const facts = asArray(body.facts).map(toFact).filter(isPresent);
116
+ const capabilities = asArray(body.capabilities).map(toCapability).filter(isPresent);
117
+ const unknowns = asArray(body.unknowns).map(toUnknown).filter(isPresent);
118
+
119
+ const threatSignals: ThreatSignal[] = [];
120
+ let noHistoryObserved = false;
121
+
122
+ for (const item of asArray(body.threatSignals)) {
123
+ const signal = toSignal(item);
124
+ if (!signal) continue;
125
+
126
+ if (COVERAGE_SIGNAL_TYPES.has(signal.type)) {
127
+ demotedSignals.push(signal.type);
128
+ if (NO_HISTORY_TYPES.has(signal.type)) noHistoryObserved = true;
129
+ facts.push({
130
+ type: signal.type,
131
+ value: null,
132
+ statement: signal.explanation,
133
+ source: signal.evidence[0]?.source ?? 'decentrys',
134
+ observedAt: signal.lastSeen,
135
+ });
136
+ continue;
137
+ }
138
+
139
+ threatSignals.push(signal);
140
+ }
141
+
142
+ // LIMITED is the neutral default, and deliberately so: it is the correct
143
+ // state for anything recently deployed and carries no accusation.
144
+ const declaredHistory = asHistoryStatus(body.historyStatus);
145
+ const historyStatus: HistoryStatus = declaredHistory ?? (noHistoryObserved ? 'NONE' : 'LIMITED');
146
+
147
+ return {
148
+ subject: toSubject(body.subject, fallback),
149
+ facts,
150
+ capabilities,
151
+ threatSignals,
152
+ unknowns,
153
+ historyStatus,
154
+ historyConfidence: asNumber(body.historyConfidence) ?? undefined,
155
+ producedAt: asString(body.producedAt) ?? new Date().toISOString(),
156
+ demotedSignals,
157
+ };
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Field coercion
162
+ // ---------------------------------------------------------------------------
163
+
164
+ function toSubject(raw: unknown, fallback: SubjectRef): SubjectRef {
165
+ if (!isRecord(raw)) return fallback;
166
+ const kind = asString(raw.kind);
167
+ return {
168
+ kind: isSubjectKind(kind) ? kind : fallback.kind,
169
+ chain: asString(raw.chain) ?? fallback.chain,
170
+ identifier: asString(raw.identifier) ?? fallback.identifier,
171
+ };
172
+ }
173
+
174
+ function toFact(raw: unknown): ObservedFact | null {
175
+ if (!isRecord(raw)) return null;
176
+ const type = asString(raw.type);
177
+ const statement = asString(raw.statement);
178
+ if (!type || !statement) return null;
179
+
180
+ return {
181
+ type,
182
+ value: asFactValue(raw.value),
183
+ statement,
184
+ source: asString(raw.source) ?? 'decentrys',
185
+ observedAt: asString(raw.observedAt) ?? new Date().toISOString(),
186
+ };
187
+ }
188
+
189
+ function toCapability(raw: unknown): TechnicalCapability | null {
190
+ if (!isRecord(raw)) return null;
191
+ const type = asString(raw.type);
192
+ const statement = asString(raw.statement);
193
+ if (!type || !statement) return null;
194
+
195
+ const severity = asString(raw.severity);
196
+ return {
197
+ type,
198
+ // An unrecognised severity becomes INFO, the least consequential value.
199
+ // Guessing upward would let a typo raise someone's risk level.
200
+ severity: severity === 'NOTABLE' || severity === 'SIGNIFICANT' ? severity : 'INFO',
201
+ statement,
202
+ grantedBy: asString(raw.grantedBy) ?? undefined,
203
+ };
204
+ }
205
+
206
+ function toSignal(raw: unknown): ThreatSignal | null {
207
+ if (!isRecord(raw)) return null;
208
+ const type = asString(raw.type);
209
+ const explanation = asString(raw.explanation);
210
+ if (!type || !explanation) return null;
211
+
212
+ const evidence = asArray(raw.evidence).map(toEvidence).filter(isPresent);
213
+ const createdAt = asString(raw.createdAt) ?? new Date().toISOString();
214
+
215
+ return {
216
+ type,
217
+ severity: asSeverity(raw.severity),
218
+ confidence: clamp01(asNumber(raw.confidence) ?? 0),
219
+ explanation,
220
+ // A missing hop count means we do not know how far away this is, and an
221
+ // unknown distance is an inference, not a direct observation.
222
+ hops: Math.max(0, Math.trunc(asNumber(raw.hops) ?? 1)),
223
+ evidence,
224
+ status: asSignalStatus(raw.status),
225
+ createdAt,
226
+ lastSeen: asString(raw.lastSeen) ?? createdAt,
227
+ expiresAt: asString(raw.expiresAt) ?? undefined,
228
+ };
229
+ }
230
+
231
+ function toEvidence(raw: unknown): Evidence | null {
232
+ if (!isRecord(raw)) return null;
233
+ const id = asString(raw.id);
234
+ const type = asString(raw.type);
235
+ if (!id || !type) return null;
236
+
237
+ return {
238
+ id,
239
+ type,
240
+ source: asString(raw.source) ?? 'decentrys',
241
+ chain: asString(raw.chain) ?? undefined,
242
+ txHash: asString(raw.txHash) ?? undefined,
243
+ contract: asString(raw.contract) ?? undefined,
244
+ address: asString(raw.address) ?? undefined,
245
+ observedAt: asString(raw.observedAt) ?? new Date().toISOString(),
246
+ confidence: clamp01(asNumber(raw.confidence) ?? 0),
247
+ // Defaults to false. `analystVerified` is what gates KNOWN_MALICIOUS, so
248
+ // an absent field must never be read as a human having checked.
249
+ analystVerified: raw.analystVerified === true,
250
+ metadata: isRecord(raw.metadata) ? raw.metadata : undefined,
251
+ };
252
+ }
253
+
254
+ function toUnknown(raw: unknown): UnknownField | null {
255
+ if (!isRecord(raw)) return null;
256
+ const field = asString(raw.field);
257
+ const statement = asString(raw.statement);
258
+ if (!field || !statement) return null;
259
+
260
+ const reason = asString(raw.reason);
261
+ const reasons: UnknownReason[] = ['UNKNOWN', 'INSUFFICIENT_DATA', 'PROVIDER_UNAVAILABLE', 'NOT_APPLICABLE'];
262
+ return {
263
+ field,
264
+ reason: reasons.includes(reason as UnknownReason) ? (reason as UnknownReason) : 'UNKNOWN',
265
+ statement,
266
+ };
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Primitives
271
+ // ---------------------------------------------------------------------------
272
+
273
+ function isRecord(value: unknown): value is Record<string, unknown> {
274
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
275
+ }
276
+
277
+ function asArray(value: unknown): unknown[] {
278
+ return Array.isArray(value) ? value : [];
279
+ }
280
+
281
+ function asString(value: unknown): string | null {
282
+ return typeof value === 'string' && value.length > 0 ? value : null;
283
+ }
284
+
285
+ function asNumber(value: unknown): number | null {
286
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
287
+ }
288
+
289
+ function asFactValue(value: unknown): FactValue {
290
+ if (typeof value === 'string' || typeof value === 'boolean') return value;
291
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
292
+ return null;
293
+ }
294
+
295
+ function asSeverity(value: unknown): ThreatSeverity {
296
+ const severities: ThreatSeverity[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
297
+ return severities.includes(value as ThreatSeverity) ? (value as ThreatSeverity) : 'LOW';
298
+ }
299
+
300
+ function asSignalStatus(value: unknown): SignalStatus {
301
+ const statuses: SignalStatus[] = ['ACTIVE', 'STALE', 'RESOLVED', 'DISPUTED_FACT', 'REMOVED'];
302
+ return statuses.includes(value as SignalStatus) ? (value as SignalStatus) : 'ACTIVE';
303
+ }
304
+
305
+ function asHistoryStatus(value: unknown): HistoryStatus | null {
306
+ const statuses: HistoryStatus[] = ['ESTABLISHED', 'MODERATE', 'LIMITED', 'NONE'];
307
+ return statuses.includes(value as HistoryStatus) ? (value as HistoryStatus) : null;
308
+ }
309
+
310
+ function isSubjectKind(value: string | null): value is SubjectKind {
311
+ return value === 'address' || value === 'contract' || value === 'token'
312
+ || value === 'transaction' || value === 'approval' || value === 'dapp';
313
+ }
314
+
315
+ function clamp01(value: number): number {
316
+ return Math.min(1, Math.max(0, value));
317
+ }
318
+
319
+ function isPresent<T>(value: T | null): value is T {
320
+ return value !== null;
321
+ }