@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.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/browser/decentrys-protect.js +901 -0
- package/dist/browser/decentrys-protect.mjs +876 -0
- package/dist/cache.d.ts +41 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +75 -0
- package/dist/cache.js.map +1 -0
- package/dist/classify.d.ts +58 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/classify.js +269 -0
- package/dist/classify.js.map +1 -0
- package/dist/client.d.ts +132 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +307 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/model.d.ts +156 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +80 -0
- package/dist/model.js.map +1 -0
- package/dist/simulation.d.ts +57 -0
- package/dist/simulation.d.ts.map +1 -0
- package/dist/simulation.js +23 -0
- package/dist/simulation.js.map +1 -0
- package/dist/transport.d.ts +61 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +151 -0
- package/dist/transport.js.map +1 -0
- package/dist/wire.d.ts +63 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +274 -0
- package/dist/wire.js.map +1 -0
- package/package.json +64 -0
- package/src/cache.test.ts +67 -0
- package/src/cache.ts +87 -0
- package/src/classify.test.ts +294 -0
- package/src/classify.ts +323 -0
- package/src/client.test.ts +224 -0
- package/src/client.ts +420 -0
- package/src/index.ts +7 -0
- package/src/model.ts +237 -0
- package/src/simulation.ts +71 -0
- package/src/transport.test.ts +129 -0
- package/src/transport.ts +203 -0
- package/src/wire.test.ts +172 -0
- package/src/wire.ts +321 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client an integrator installs.
|
|
3
|
+
*
|
|
4
|
+
* Its contract is narrow and absolute: **every method returns, and none of
|
|
5
|
+
* them throws.** This code sits between a user and a signing screen. A wallet
|
|
6
|
+
* that shows an error dialog because a security service had a bad minute has
|
|
7
|
+
* made the user's day worse for no security benefit, and the user learns to
|
|
8
|
+
* dismiss the dialog — which is the outcome the whole product exists to avoid.
|
|
9
|
+
*
|
|
10
|
+
* So an unreachable Decentrys produces an assessment that says exactly that,
|
|
11
|
+
* in `unknowns` and in `explanation`, and the configured `failMode` decides
|
|
12
|
+
* what the integrator does about it. Nothing is invented, and nothing is
|
|
13
|
+
* silently reported as clean.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type Assessment, type ThreatSignal } from './model';
|
|
17
|
+
import { type FailMode, type Policy, type PolicyAction, applyPolicy, classify, unavailableAssessment } from './classify';
|
|
18
|
+
import { HttpTransport, type FetchLike, type Transport, TransportError } from './transport';
|
|
19
|
+
import { TtlCache, cacheKey } from './cache';
|
|
20
|
+
import { type NormalizedEvidence, type SubjectRef, normalizeEvidence } from './wire';
|
|
21
|
+
import {
|
|
22
|
+
type SimulationResult, type TransactionExplanation, unavailableSimulation,
|
|
23
|
+
} from './simulation';
|
|
24
|
+
|
|
25
|
+
export const SDK_VERSION = '0.1.0';
|
|
26
|
+
|
|
27
|
+
const DEFAULT_BASE_URL = 'https://api.decentrys.com';
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 4_000;
|
|
29
|
+
const DEFAULT_CACHE_TTL_MS = 120_000;
|
|
30
|
+
const DEFAULT_CACHE_ENTRIES = 500;
|
|
31
|
+
|
|
32
|
+
export interface DecentrysConfig {
|
|
33
|
+
apiKey: string;
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
/**
|
|
36
|
+
* What the integrator does when Decentrys is unreachable. `warn` is the
|
|
37
|
+
* consumer default; `closed` is for deployments that would rather stop.
|
|
38
|
+
*/
|
|
39
|
+
failMode?: FailMode;
|
|
40
|
+
/**
|
|
41
|
+
* A deadline, not a target. Exceeding it returns an unavailable assessment
|
|
42
|
+
* rather than leaving the user on a spinner holding a signature.
|
|
43
|
+
*/
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
/** Retries apply to idempotent lookups only. */
|
|
46
|
+
retries?: number;
|
|
47
|
+
policy?: Policy;
|
|
48
|
+
cacheTtlMs?: number;
|
|
49
|
+
cacheMaxEntries?: number;
|
|
50
|
+
/** Injected so the SDK works in a browser, an extension, RN and Node alike. */
|
|
51
|
+
fetch?: FetchLike;
|
|
52
|
+
/** For tests and for integrators who route through their own gateway. */
|
|
53
|
+
transport?: Transport;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TransactionRequest {
|
|
57
|
+
chain: string;
|
|
58
|
+
from: string;
|
|
59
|
+
to?: string;
|
|
60
|
+
/** Base units, as a string. Numbers lose precision at these magnitudes. */
|
|
61
|
+
value?: string;
|
|
62
|
+
/** EVM calldata. */
|
|
63
|
+
data?: string;
|
|
64
|
+
/** Non-EVM families carry their serialized payload here instead. */
|
|
65
|
+
raw?: string;
|
|
66
|
+
/** The site requesting the signature, when there is one. */
|
|
67
|
+
origin?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AddressRequest {
|
|
71
|
+
chain: string;
|
|
72
|
+
address: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ApprovalRequest {
|
|
76
|
+
chain: string;
|
|
77
|
+
owner: string;
|
|
78
|
+
spender: string;
|
|
79
|
+
token: string;
|
|
80
|
+
/** Base units, or `unlimited`. */
|
|
81
|
+
amount?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface DappRequest {
|
|
85
|
+
origin: string;
|
|
86
|
+
chain?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ProtectResult {
|
|
90
|
+
subject: SubjectRef;
|
|
91
|
+
assessment: Assessment;
|
|
92
|
+
/** What the integrator's policy says to do. Advice — never enforced here. */
|
|
93
|
+
decision: { action: PolicyAction; reason: string };
|
|
94
|
+
/** True when served from the local cache rather than the network. */
|
|
95
|
+
cached: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Coverage signals the SDK refused to treat as risk. Surfaced rather than
|
|
98
|
+
* hidden: an integrator should be able to see the rule being applied.
|
|
99
|
+
*/
|
|
100
|
+
demotedSignals: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class Decentrys {
|
|
104
|
+
private readonly transport: Transport;
|
|
105
|
+
private readonly failMode: FailMode;
|
|
106
|
+
private readonly policy: Policy;
|
|
107
|
+
private readonly cache: TtlCache<NormalizedEvidence>;
|
|
108
|
+
|
|
109
|
+
constructor(config: DecentrysConfig) {
|
|
110
|
+
if (!config.apiKey || !config.apiKey.trim()) {
|
|
111
|
+
// Thrown at construction, which is a developer error at wiring time —
|
|
112
|
+
// as opposed to anything at call time, which is a user's transaction.
|
|
113
|
+
throw new Error('Decentrys: an apiKey is required. Create one at https://decentrys.com/developers.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.failMode = config.failMode ?? 'warn';
|
|
117
|
+
this.policy = config.policy ?? {};
|
|
118
|
+
this.cache = new TtlCache<NormalizedEvidence>({
|
|
119
|
+
ttlMs: config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS,
|
|
120
|
+
maxEntries: config.cacheMaxEntries ?? DEFAULT_CACHE_ENTRIES,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
this.transport = config.transport ?? new HttpTransport({
|
|
124
|
+
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
|
|
125
|
+
apiKey: config.apiKey,
|
|
126
|
+
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
127
|
+
retries: config.retries ?? 1,
|
|
128
|
+
fetch: config.fetch ?? resolveFetch(),
|
|
129
|
+
userAgent: `decentrys-protect/${SDK_VERSION}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// -------------------------------------------------------------------------
|
|
134
|
+
// Assessments
|
|
135
|
+
// -------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** Pre-sign analysis of a transaction the user is about to approve. */
|
|
138
|
+
async assessTransaction(tx: TransactionRequest, options: CallOptions = {}): Promise<ProtectResult> {
|
|
139
|
+
return this.assess({
|
|
140
|
+
path: '/v1/protect/transaction',
|
|
141
|
+
body: tx,
|
|
142
|
+
subject: { kind: 'transaction', chain: tx.chain, identifier: tx.to ?? tx.from },
|
|
143
|
+
// A transaction's assessment depends on its calldata and its moment.
|
|
144
|
+
// Caching one would serve a stale answer for a different transaction.
|
|
145
|
+
cacheable: false,
|
|
146
|
+
idempotent: true,
|
|
147
|
+
options,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What a contract *can* do — capabilities, proxy status, admin controls. */
|
|
152
|
+
async scanContract(request: AddressRequest, options: CallOptions = {}): Promise<ProtectResult> {
|
|
153
|
+
return this.assess({
|
|
154
|
+
path: '/v1/protect/contract',
|
|
155
|
+
body: request,
|
|
156
|
+
subject: { kind: 'contract', chain: request.chain, identifier: request.address },
|
|
157
|
+
cacheable: true,
|
|
158
|
+
idempotent: true,
|
|
159
|
+
options,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async screenAddress(request: AddressRequest, options: CallOptions = {}): Promise<ProtectResult> {
|
|
164
|
+
return this.assess({
|
|
165
|
+
path: '/v1/protect/address',
|
|
166
|
+
body: request,
|
|
167
|
+
subject: { kind: 'address', chain: request.chain, identifier: request.address },
|
|
168
|
+
cacheable: true,
|
|
169
|
+
idempotent: true,
|
|
170
|
+
options,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async screenToken(request: AddressRequest, options: CallOptions = {}): Promise<ProtectResult> {
|
|
175
|
+
return this.assess({
|
|
176
|
+
path: '/v1/protect/token',
|
|
177
|
+
body: request,
|
|
178
|
+
subject: { kind: 'token', chain: request.chain, identifier: request.address },
|
|
179
|
+
cacheable: true,
|
|
180
|
+
idempotent: true,
|
|
181
|
+
options,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* An approval is assessed on the spender and the allowance together.
|
|
187
|
+
*
|
|
188
|
+
* Not cached: the same spender with an unlimited allowance and with a
|
|
189
|
+
* one-off allowance are different decisions, and the amount is the part a
|
|
190
|
+
* user most needs told.
|
|
191
|
+
*/
|
|
192
|
+
async screenApproval(request: ApprovalRequest, options: CallOptions = {}): Promise<ProtectResult> {
|
|
193
|
+
return this.assess({
|
|
194
|
+
path: '/v1/protect/approval',
|
|
195
|
+
body: request,
|
|
196
|
+
subject: { kind: 'approval', chain: request.chain, identifier: request.spender },
|
|
197
|
+
cacheable: false,
|
|
198
|
+
idempotent: true,
|
|
199
|
+
options,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async assessDapp(request: DappRequest, options: CallOptions = {}): Promise<ProtectResult> {
|
|
204
|
+
return this.assess({
|
|
205
|
+
path: '/v1/protect/dapp',
|
|
206
|
+
body: request,
|
|
207
|
+
subject: { kind: 'dapp', chain: request.chain ?? 'multi', identifier: request.origin },
|
|
208
|
+
cacheable: true,
|
|
209
|
+
idempotent: true,
|
|
210
|
+
options,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The threat signals on a subject, without a classification.
|
|
216
|
+
*
|
|
217
|
+
* For integrators building their own presentation. An empty array means no
|
|
218
|
+
* signals were found — which is not the same as safe, and the SDK will not
|
|
219
|
+
* pretend otherwise on their behalf.
|
|
220
|
+
*/
|
|
221
|
+
async getThreatSignals(request: AddressRequest, options: CallOptions = {}): Promise<ThreatSignal[]> {
|
|
222
|
+
try {
|
|
223
|
+
const raw = await this.transport.request<unknown>({
|
|
224
|
+
path: '/v1/protect/signals',
|
|
225
|
+
body: request,
|
|
226
|
+
idempotent: true,
|
|
227
|
+
signal: options.signal,
|
|
228
|
+
});
|
|
229
|
+
return normalizeEvidence(raw, {
|
|
230
|
+
kind: 'address', chain: request.chain, identifier: request.address,
|
|
231
|
+
}).threatSignals;
|
|
232
|
+
} catch {
|
|
233
|
+
// No signals is the only honest answer when we could not look. The
|
|
234
|
+
// caller distinguishes it from a real empty result by asking for a full
|
|
235
|
+
// assessment, whose `unknowns` say so explicitly.
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// -------------------------------------------------------------------------
|
|
241
|
+
// Decoding and simulation
|
|
242
|
+
// -------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/** What this transaction does, in the words a user would use. */
|
|
245
|
+
async explainTransaction(
|
|
246
|
+
tx: TransactionRequest, options: CallOptions = {},
|
|
247
|
+
): Promise<TransactionExplanation> {
|
|
248
|
+
try {
|
|
249
|
+
const raw = await this.transport.request<Partial<TransactionExplanation>>({
|
|
250
|
+
path: '/v1/protect/explain',
|
|
251
|
+
body: tx,
|
|
252
|
+
idempotent: true,
|
|
253
|
+
signal: options.signal,
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
summary: typeof raw?.summary === 'string' && raw.summary
|
|
257
|
+
? raw.summary
|
|
258
|
+
: 'This transaction could not be decoded.',
|
|
259
|
+
actions: stringList(raw?.actions),
|
|
260
|
+
exposure: stringList(raw?.exposure),
|
|
261
|
+
undecoded: stringList(raw?.undecoded),
|
|
262
|
+
};
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return {
|
|
265
|
+
summary: 'This transaction could not be decoded.',
|
|
266
|
+
actions: [],
|
|
267
|
+
exposure: [],
|
|
268
|
+
undecoded: [`Decentrys could not be reached: ${describe(error)}.`],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Execute the transaction against a fork and report what would change. */
|
|
274
|
+
async simulateTransaction(
|
|
275
|
+
tx: TransactionRequest, options: CallOptions = {},
|
|
276
|
+
): Promise<SimulationResult> {
|
|
277
|
+
try {
|
|
278
|
+
const raw = await this.transport.request<SimulationResult>({
|
|
279
|
+
path: '/v1/protect/simulate',
|
|
280
|
+
body: tx,
|
|
281
|
+
idempotent: true,
|
|
282
|
+
signal: options.signal,
|
|
283
|
+
});
|
|
284
|
+
return normalizeSimulation(raw);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return unavailableSimulation(describe(error));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// -------------------------------------------------------------------------
|
|
291
|
+
// Cache control
|
|
292
|
+
// -------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
/** Drop cached evidence. Call after a user reports a stale result. */
|
|
295
|
+
clearCache(): void {
|
|
296
|
+
this.cache.clear();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
// Internals
|
|
301
|
+
// -------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
private async assess(params: {
|
|
304
|
+
path: string;
|
|
305
|
+
body: unknown;
|
|
306
|
+
subject: SubjectRef;
|
|
307
|
+
cacheable: boolean;
|
|
308
|
+
idempotent: boolean;
|
|
309
|
+
options: CallOptions;
|
|
310
|
+
}): Promise<ProtectResult> {
|
|
311
|
+
const key = params.cacheable
|
|
312
|
+
? cacheKey(params.subject.kind, [params.subject.chain, params.subject.identifier])
|
|
313
|
+
: null;
|
|
314
|
+
|
|
315
|
+
if (key && !params.options.skipCache) {
|
|
316
|
+
const hit = this.cache.get(key);
|
|
317
|
+
if (hit) return this.finish(hit, true);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let evidence: NormalizedEvidence;
|
|
321
|
+
try {
|
|
322
|
+
const raw = await this.transport.request<unknown>({
|
|
323
|
+
path: params.path,
|
|
324
|
+
body: params.body,
|
|
325
|
+
idempotent: params.idempotent,
|
|
326
|
+
signal: params.options.signal,
|
|
327
|
+
});
|
|
328
|
+
evidence = normalizeEvidence(raw, params.subject);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
return {
|
|
331
|
+
subject: params.subject,
|
|
332
|
+
assessment: unavailableAssessment(this.failMode, describe(error)),
|
|
333
|
+
decision: {
|
|
334
|
+
// `closed` is the only mode where unavailability is itself a stop.
|
|
335
|
+
// The others must not fabricate a risk level to justify blocking.
|
|
336
|
+
action: this.failMode === 'closed' ? 'block' : 'warn',
|
|
337
|
+
reason: `Decentrys could not be reached: ${describe(error)}.`,
|
|
338
|
+
},
|
|
339
|
+
cached: false,
|
|
340
|
+
demotedSignals: [],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (key) this.cache.set(key, evidence);
|
|
345
|
+
return this.finish(evidence, false);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private finish(evidence: NormalizedEvidence, cached: boolean): ProtectResult {
|
|
349
|
+
// Classification happens here, on the client, from evidence — not read
|
|
350
|
+
// off a server response. That is what makes the rule auditable by the
|
|
351
|
+
// integrator rather than a promise made in a marketing page.
|
|
352
|
+
const assessment = classify({
|
|
353
|
+
facts: evidence.facts,
|
|
354
|
+
capabilities: evidence.capabilities,
|
|
355
|
+
threatSignals: evidence.threatSignals,
|
|
356
|
+
unknowns: evidence.unknowns,
|
|
357
|
+
historyStatus: evidence.historyStatus,
|
|
358
|
+
historyConfidence: evidence.historyConfidence,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
subject: evidence.subject,
|
|
363
|
+
assessment,
|
|
364
|
+
decision: applyPolicy(assessment, this.policy),
|
|
365
|
+
cached,
|
|
366
|
+
demotedSignals: evidence.demotedSignals,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface CallOptions {
|
|
372
|
+
signal?: AbortSignal;
|
|
373
|
+
/** Bypass the cache for this call. The result still populates it. */
|
|
374
|
+
skipCache?: boolean;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Helpers
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
function resolveFetch(): FetchLike {
|
|
382
|
+
const candidate = (globalThis as { fetch?: unknown }).fetch;
|
|
383
|
+
if (typeof candidate !== 'function') {
|
|
384
|
+
throw new Error(
|
|
385
|
+
'Decentrys: no global fetch was found. Pass one via `new Decentrys({ fetch })` '
|
|
386
|
+
+ '(Node 18+, modern browsers and React Native provide one).',
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return candidate.bind(globalThis) as FetchLike;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function describe(error: unknown): string {
|
|
393
|
+
if (error instanceof TransportError) return error.message;
|
|
394
|
+
if (error instanceof Error) return error.message;
|
|
395
|
+
return 'unknown error';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function stringList(value: unknown): string[] {
|
|
399
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.length > 0) : [];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function normalizeSimulation(raw: unknown): SimulationResult {
|
|
403
|
+
const body = (typeof raw === 'object' && raw !== null ? raw : {}) as Partial<SimulationResult>;
|
|
404
|
+
const outcomes: SimulationResult['outcome'][] = ['SUCCESS', 'REVERT', 'NOT_SUPPORTED', 'UNAVAILABLE'];
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
// An unrecognised outcome is not a success. Defaulting the other way would
|
|
408
|
+
// let a malformed response read as "this transaction is fine".
|
|
409
|
+
outcome: outcomes.includes(body.outcome as SimulationResult['outcome'])
|
|
410
|
+
? (body.outcome as SimulationResult['outcome'])
|
|
411
|
+
: 'UNAVAILABLE',
|
|
412
|
+
revertReason: typeof body.revertReason === 'string' ? body.revertReason : undefined,
|
|
413
|
+
balanceChanges: Array.isArray(body.balanceChanges) ? body.balanceChanges : [],
|
|
414
|
+
approvalChanges: Array.isArray(body.approvalChanges) ? body.approvalChanges : [],
|
|
415
|
+
contractsCalled: stringList(body.contractsCalled),
|
|
416
|
+
gasUsed: typeof body.gasUsed === 'string' ? body.gasUsed : undefined,
|
|
417
|
+
unavailableReason: typeof body.unavailableReason === 'string' ? body.unavailableReason : undefined,
|
|
418
|
+
simulatedAt: typeof body.simulatedAt === 'string' ? body.simulatedAt : new Date().toISOString(),
|
|
419
|
+
};
|
|
420
|
+
}
|
package/src/index.ts
ADDED
package/src/model.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Decentrys risk model.
|
|
3
|
+
*
|
|
4
|
+
* One rule governs this file, and every other decision follows from it:
|
|
5
|
+
*
|
|
6
|
+
* **LACK OF EVIDENCE IS NOT EVIDENCE OF MALICE.**
|
|
7
|
+
*
|
|
8
|
+
* A contract deployed two hours ago by an anonymous wallet with no audit and
|
|
9
|
+
* thin liquidity is *unknown*, not dangerous. The industry habit of scoring
|
|
10
|
+
* those facts as risk produces a system that protects incumbents and taxes
|
|
11
|
+
* every new project — which is a gatekeeping product, not a security one.
|
|
12
|
+
*
|
|
13
|
+
* The model therefore separates four things that are usually collapsed:
|
|
14
|
+
*
|
|
15
|
+
* - **Facts** — directly verifiable, carrying no accusation.
|
|
16
|
+
* - **Capabilities** — what the code *can* do. A mint authority is a
|
|
17
|
+
* capability, not a vulnerability; it becomes one when behaviour or context
|
|
18
|
+
* shows it being abused.
|
|
19
|
+
* - **Threat signals** — require actual technical or behavioural evidence.
|
|
20
|
+
* These, and only these, can raise a risk level.
|
|
21
|
+
* - **Unknowns** — stated as unknown. Never silently converted to risk.
|
|
22
|
+
*
|
|
23
|
+
* `historyConfidence` exists to express how much Decentrys knows. It is
|
|
24
|
+
* deliberately not part of the risk computation: it is a measure of our
|
|
25
|
+
* coverage, not of the subject's danger.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Risk levels
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deliberately not SAFE/SCAM.
|
|
34
|
+
*
|
|
35
|
+
* "Safe" is a claim no analysis can support, and "scam" is an accusation that
|
|
36
|
+
* needs evidence. The scale runs from "we found no critical threat evidence"
|
|
37
|
+
* to "confirmed malicious infrastructure", and every step above CAUTION
|
|
38
|
+
* requires a threat signal to reach it.
|
|
39
|
+
*/
|
|
40
|
+
export const RISK_LEVELS = [
|
|
41
|
+
'NO_CRITICAL_RISK_DETECTED',
|
|
42
|
+
'INFORMATIONAL',
|
|
43
|
+
'CAUTION',
|
|
44
|
+
'ELEVATED_RISK',
|
|
45
|
+
'HIGH_RISK',
|
|
46
|
+
'CRITICAL_THREAT',
|
|
47
|
+
'KNOWN_MALICIOUS',
|
|
48
|
+
] as const;
|
|
49
|
+
export type RiskLevel = (typeof RISK_LEVELS)[number];
|
|
50
|
+
|
|
51
|
+
const RISK_ORDER: Record<RiskLevel, number> = {
|
|
52
|
+
NO_CRITICAL_RISK_DETECTED: 0,
|
|
53
|
+
INFORMATIONAL: 1,
|
|
54
|
+
CAUTION: 2,
|
|
55
|
+
ELEVATED_RISK: 3,
|
|
56
|
+
HIGH_RISK: 4,
|
|
57
|
+
CRITICAL_THREAT: 5,
|
|
58
|
+
KNOWN_MALICIOUS: 6,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function isAtLeast(level: RiskLevel, floor: RiskLevel): boolean {
|
|
62
|
+
return RISK_ORDER[level] >= RISK_ORDER[floor];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What each level is allowed to mean, in the words an integrator may show. */
|
|
66
|
+
export const RISK_LEVEL_MEANING: Record<RiskLevel, string> = {
|
|
67
|
+
NO_CRITICAL_RISK_DETECTED:
|
|
68
|
+
'No known critical threat evidence was identified. This is not an assurance of safety.',
|
|
69
|
+
INFORMATIONAL:
|
|
70
|
+
'Facts worth knowing before proceeding. Nothing here indicates danger.',
|
|
71
|
+
CAUTION:
|
|
72
|
+
'A security-sensitive capability or behaviour exists that deserves attention.',
|
|
73
|
+
ELEVATED_RISK:
|
|
74
|
+
'Several meaningful risk signals are present together.',
|
|
75
|
+
HIGH_RISK:
|
|
76
|
+
'Strong technical or behavioural evidence of significant danger.',
|
|
77
|
+
CRITICAL_THREAT:
|
|
78
|
+
'Severe threat supported by concrete evidence.',
|
|
79
|
+
KNOWN_MALICIOUS:
|
|
80
|
+
'Confirmed malicious infrastructure or behaviour, verified against evidence.',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Observed facts
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export type FactValue = string | number | boolean | null;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Something directly verifiable.
|
|
91
|
+
*
|
|
92
|
+
* Facts carry no severity. "Deployed two hours ago" and "mint authority
|
|
93
|
+
* present" are both simply true; what they mean is the integrator's call and,
|
|
94
|
+
* where it matters, the threat signals' job to establish.
|
|
95
|
+
*/
|
|
96
|
+
export interface ObservedFact {
|
|
97
|
+
type: string;
|
|
98
|
+
value: FactValue;
|
|
99
|
+
/** Plain language, shown to a user as-is. Never accusatory. */
|
|
100
|
+
statement: string;
|
|
101
|
+
/** Where the fact came from, so it can be checked. */
|
|
102
|
+
source: string;
|
|
103
|
+
observedAt: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Capabilities
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `INFO` is the default and the common case.
|
|
112
|
+
*
|
|
113
|
+
* A capability's severity describes how much power it confers, not how likely
|
|
114
|
+
* it is to be abused. Nothing here alone can push a subject past CAUTION.
|
|
115
|
+
*/
|
|
116
|
+
export type CapabilitySeverity = 'INFO' | 'NOTABLE' | 'SIGNIFICANT';
|
|
117
|
+
|
|
118
|
+
export interface TechnicalCapability {
|
|
119
|
+
type: string;
|
|
120
|
+
severity: CapabilitySeverity;
|
|
121
|
+
/** What the contract *can* do, phrased as capability, never as accusation. */
|
|
122
|
+
statement: string;
|
|
123
|
+
/** The function, role or storage slot that grants it. */
|
|
124
|
+
grantedBy?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Evidence
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export interface Evidence {
|
|
132
|
+
id: string;
|
|
133
|
+
type: string;
|
|
134
|
+
source: string;
|
|
135
|
+
chain?: string;
|
|
136
|
+
txHash?: string;
|
|
137
|
+
contract?: string;
|
|
138
|
+
address?: string;
|
|
139
|
+
observedAt: string;
|
|
140
|
+
confidence: number;
|
|
141
|
+
/** True when a human analyst confirmed it. Required for KNOWN_MALICIOUS. */
|
|
142
|
+
analystVerified: boolean;
|
|
143
|
+
metadata?: Record<string, unknown>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Signals decay.
|
|
148
|
+
*
|
|
149
|
+
* A low-confidence association from three years ago must not poison an address
|
|
150
|
+
* permanently. A signal that is STALE still appears — suppressing it would
|
|
151
|
+
* hide a real observation — but it cannot raise the risk level.
|
|
152
|
+
*/
|
|
153
|
+
export type SignalStatus = 'ACTIVE' | 'STALE' | 'RESOLVED' | 'DISPUTED_FACT' | 'REMOVED';
|
|
154
|
+
|
|
155
|
+
export type ThreatSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
|
156
|
+
|
|
157
|
+
export interface ThreatSignal {
|
|
158
|
+
type: string;
|
|
159
|
+
severity: ThreatSeverity;
|
|
160
|
+
/** 0–1. Presented alongside the signal, never rounded away. */
|
|
161
|
+
confidence: number;
|
|
162
|
+
/** Why this signal exists, in language a user can act on. */
|
|
163
|
+
explanation: string;
|
|
164
|
+
/** Hops from the subject. 0 is direct; anything above is inference. */
|
|
165
|
+
hops: number;
|
|
166
|
+
evidence: Evidence[];
|
|
167
|
+
status: SignalStatus;
|
|
168
|
+
createdAt: string;
|
|
169
|
+
lastSeen: string;
|
|
170
|
+
expiresAt?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Unknowns
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export type UnknownReason = 'UNKNOWN' | 'INSUFFICIENT_DATA' | 'PROVIDER_UNAVAILABLE' | 'NOT_APPLICABLE';
|
|
178
|
+
|
|
179
|
+
export interface UnknownField {
|
|
180
|
+
field: string;
|
|
181
|
+
reason: UnknownReason;
|
|
182
|
+
/** Says plainly that we do not know, rather than implying anything. */
|
|
183
|
+
statement: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* How much Decentrys knows about a subject — never how dangerous it is.
|
|
188
|
+
*
|
|
189
|
+
* `LIMITED` is the correct and expected state for anything new, and it must
|
|
190
|
+
* never be rendered as a warning.
|
|
191
|
+
*/
|
|
192
|
+
export type HistoryStatus = 'ESTABLISHED' | 'MODERATE' | 'LIMITED' | 'NONE';
|
|
193
|
+
|
|
194
|
+
export const HISTORY_STATUS_MEANING: Record<HistoryStatus, string> = {
|
|
195
|
+
ESTABLISHED: 'Substantial on-chain history is available.',
|
|
196
|
+
MODERATE: 'Some history is available.',
|
|
197
|
+
LIMITED: 'Little history is available yet. This is normal for anything recently deployed and is not a risk finding.',
|
|
198
|
+
NONE: 'No history is available. This is not a risk finding.',
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Assessment
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
export interface RiskComponents {
|
|
206
|
+
/** From capabilities the code genuinely holds. Never inflated by age. */
|
|
207
|
+
technicalRisk: number;
|
|
208
|
+
/** From observed behaviour. */
|
|
209
|
+
behavioralRisk: number;
|
|
210
|
+
/** From confirmed threat intelligence. */
|
|
211
|
+
threatIntelligenceRisk: number;
|
|
212
|
+
/**
|
|
213
|
+
* How much we know, 0–100. **Not a danger score.** A low value means thin
|
|
214
|
+
* coverage on our side, and it is reported separately so no integrator can
|
|
215
|
+
* accidentally add it to a risk total.
|
|
216
|
+
*/
|
|
217
|
+
historyConfidence: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface Assessment {
|
|
221
|
+
riskLevel: RiskLevel;
|
|
222
|
+
/** True only with analyst-verified evidence. Never inferred. */
|
|
223
|
+
confirmedMalicious: boolean;
|
|
224
|
+
confidence: number;
|
|
225
|
+
historyStatus: HistoryStatus;
|
|
226
|
+
facts: ObservedFact[];
|
|
227
|
+
capabilities: TechnicalCapability[];
|
|
228
|
+
threatSignals: ThreatSignal[];
|
|
229
|
+
unknowns: UnknownField[];
|
|
230
|
+
components: RiskComponents;
|
|
231
|
+
/** Why this level was reached. Never empty — no black-box classifications. */
|
|
232
|
+
explanation: string[];
|
|
233
|
+
modelVersion: string;
|
|
234
|
+
assessedAt: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const PROTECT_MODEL_VERSION = 'protect-1.0.0';
|