@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulation and plain-language decoding.
|
|
3
|
+
*
|
|
4
|
+
* These answer a different question from risk. "Is this dangerous?" is the
|
|
5
|
+
* classifier's job; "what does this transaction actually do?" is this one's,
|
|
6
|
+
* and for most users it is the more useful of the two. A person who can read
|
|
7
|
+
* "this grants unlimited spending of your USDC to an address you have never
|
|
8
|
+
* interacted with" does not need to be told a score.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface BalanceChange {
|
|
12
|
+
/** Whose balance moves. Usually the signer, sometimes a contract. */
|
|
13
|
+
address: string;
|
|
14
|
+
asset: string;
|
|
15
|
+
symbol?: string;
|
|
16
|
+
decimals?: number;
|
|
17
|
+
/** Signed, base units. Negative leaves the address. */
|
|
18
|
+
delta: string;
|
|
19
|
+
usdValue?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ApprovalChange {
|
|
23
|
+
owner: string;
|
|
24
|
+
spender: string;
|
|
25
|
+
token: string;
|
|
26
|
+
symbol?: string;
|
|
27
|
+
/** Base units, or `unlimited`. */
|
|
28
|
+
amount: string;
|
|
29
|
+
unlimited: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SimulationOutcome = 'SUCCESS' | 'REVERT' | 'NOT_SUPPORTED' | 'UNAVAILABLE';
|
|
33
|
+
|
|
34
|
+
export interface SimulationResult {
|
|
35
|
+
outcome: SimulationOutcome;
|
|
36
|
+
/** Present when the outcome is REVERT and the chain reported a reason. */
|
|
37
|
+
revertReason?: string;
|
|
38
|
+
balanceChanges: BalanceChange[];
|
|
39
|
+
approvalChanges: ApprovalChange[];
|
|
40
|
+
/** Contracts the transaction touches, in call order. */
|
|
41
|
+
contractsCalled: string[];
|
|
42
|
+
gasUsed?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Why simulation could not run, when it could not. Stated rather than
|
|
45
|
+
* silently returning an empty result that reads like "nothing happens".
|
|
46
|
+
*/
|
|
47
|
+
unavailableReason?: string;
|
|
48
|
+
simulatedAt: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TransactionExplanation {
|
|
52
|
+
/** One sentence, in the words a user would use. */
|
|
53
|
+
summary: string;
|
|
54
|
+
/** Each distinct thing the transaction does, in order. */
|
|
55
|
+
actions: string[];
|
|
56
|
+
/** What the signer gives up if this is not what they intended. */
|
|
57
|
+
exposure: string[];
|
|
58
|
+
/** Anything the decoder could not resolve, stated as unresolved. */
|
|
59
|
+
undecoded: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function unavailableSimulation(reason: string, now = new Date()): SimulationResult {
|
|
63
|
+
return {
|
|
64
|
+
outcome: 'UNAVAILABLE',
|
|
65
|
+
balanceChanges: [],
|
|
66
|
+
approvalChanges: [],
|
|
67
|
+
contractsCalled: [],
|
|
68
|
+
unavailableReason: reason,
|
|
69
|
+
simulatedAt: now.toISOString(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { HttpTransport, TransportError, joinUrl, type FetchLike } from './transport';
|
|
3
|
+
|
|
4
|
+
function transport(fetchImpl: FetchLike, overrides: Partial<{ timeoutMs: number; retries: number }> = {}) {
|
|
5
|
+
return new HttpTransport({
|
|
6
|
+
baseUrl: 'https://api.test/',
|
|
7
|
+
apiKey: 'key',
|
|
8
|
+
timeoutMs: overrides.timeoutMs ?? 1_000,
|
|
9
|
+
retries: overrides.retries ?? 1,
|
|
10
|
+
fetch: fetchImpl,
|
|
11
|
+
userAgent: 'test',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ok(body: unknown): ReturnType<FetchLike> {
|
|
16
|
+
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(body)) });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fail(status: number, body: unknown = {}): ReturnType<FetchLike> {
|
|
20
|
+
return Promise.resolve({ ok: false, status, text: () => Promise.resolve(JSON.stringify(body)) });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('HttpTransport', () => {
|
|
24
|
+
it('unwraps the platform envelope', async () => {
|
|
25
|
+
const t = transport(() => ok({ data: { subject: 'x' } }));
|
|
26
|
+
await expect(t.request({ path: '/p' })).resolves.toEqual({ subject: 'x' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('accepts a bare body, for integrators behind a proxy that unwraps', async () => {
|
|
30
|
+
const t = transport(() => ok({ subject: 'x' }));
|
|
31
|
+
await expect(t.request({ path: '/p' })).resolves.toEqual({ subject: 'x' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('retries an idempotent request once on a server error', async () => {
|
|
35
|
+
const fetchImpl = vi.fn<FetchLike>()
|
|
36
|
+
.mockImplementationOnce(() => fail(503))
|
|
37
|
+
.mockImplementationOnce(() => ok({ data: 'recovered' }));
|
|
38
|
+
|
|
39
|
+
await expect(transport(fetchImpl).request({ path: '/p', idempotent: true })).resolves.toBe('recovered');
|
|
40
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not retry a non-idempotent request', async () => {
|
|
44
|
+
const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => fail(503));
|
|
45
|
+
|
|
46
|
+
await expect(transport(fetchImpl).request({ path: '/p' })).rejects.toBeInstanceOf(TransportError);
|
|
47
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A 4xx will say the same thing the second time. Retrying it only makes the
|
|
52
|
+
* user wait longer for the same failure.
|
|
53
|
+
*/
|
|
54
|
+
it('never retries a 4xx, even when idempotent', async () => {
|
|
55
|
+
const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => fail(401, { message: 'Invalid API key.' }));
|
|
56
|
+
|
|
57
|
+
const error = await transport(fetchImpl)
|
|
58
|
+
.request({ path: '/p', idempotent: true })
|
|
59
|
+
.catch((e: unknown) => e);
|
|
60
|
+
|
|
61
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(error).toBeInstanceOf(TransportError);
|
|
63
|
+
expect((error as TransportError).failure).toBe('unauthorized');
|
|
64
|
+
expect((error as TransportError).message).toBe('Invalid API key.');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('reports a rate limit as retryable', async () => {
|
|
68
|
+
const fetchImpl = vi.fn<FetchLike>()
|
|
69
|
+
.mockImplementationOnce(() => fail(429))
|
|
70
|
+
.mockImplementationOnce(() => ok({ data: 'ok' }));
|
|
71
|
+
|
|
72
|
+
await expect(transport(fetchImpl).request({ path: '/p', idempotent: true })).resolves.toBe('ok');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('gives up at the deadline rather than hanging a signing screen', async () => {
|
|
76
|
+
const fetchImpl: FetchLike = (_url, init) => new Promise((_resolve, reject) => {
|
|
77
|
+
init.signal?.addEventListener('abort', () => {
|
|
78
|
+
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const error = await transport(fetchImpl, { timeoutMs: 20, retries: 0 })
|
|
83
|
+
.request({ path: '/p' })
|
|
84
|
+
.catch((e: unknown) => e);
|
|
85
|
+
|
|
86
|
+
expect((error as TransportError).failure).toBe('timeout');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('distinguishes a caller cancellation from a timeout', async () => {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const fetchImpl: FetchLike = (_url, init) => new Promise((_resolve, reject) => {
|
|
92
|
+
init.signal?.addEventListener('abort', () => {
|
|
93
|
+
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
|
94
|
+
});
|
|
95
|
+
controller.abort();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const error = await transport(fetchImpl, { retries: 0 })
|
|
99
|
+
.request({ path: '/p', signal: controller.signal })
|
|
100
|
+
.catch((e: unknown) => e);
|
|
101
|
+
|
|
102
|
+
expect((error as TransportError).failure).toBe('network');
|
|
103
|
+
expect((error as TransportError).message).toContain('cancelled');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('treats an unparseable body as malformed, not as an empty result', async () => {
|
|
107
|
+
const t = transport(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('<html>502</html>') }));
|
|
108
|
+
|
|
109
|
+
const error = await t.request({ path: '/p' }).catch((e: unknown) => e);
|
|
110
|
+
expect((error as TransportError).failure).toBe('malformed_response');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('sends the API key as a header and never in the URL', async () => {
|
|
114
|
+
const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => ok({ data: null }));
|
|
115
|
+
await transport(fetchImpl).request({ path: 'p' });
|
|
116
|
+
|
|
117
|
+
const [url, init] = fetchImpl.mock.calls[0]!;
|
|
118
|
+
expect(url).toBe('https://api.test/p');
|
|
119
|
+
expect(url).not.toContain('key');
|
|
120
|
+
expect(init.headers['x-api-key']).toBe('key');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('joinUrl', () => {
|
|
125
|
+
it('collapses slashes from either side', () => {
|
|
126
|
+
expect(joinUrl('https://a.test/', '/b')).toBe('https://a.test/b');
|
|
127
|
+
expect(joinUrl('https://a.test', 'b')).toBe('https://a.test/b');
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport for the Protect SDK.
|
|
3
|
+
*
|
|
4
|
+
* Three properties matter more than anything else here, because this code runs
|
|
5
|
+
* inside a wallet on the path to signing a transaction:
|
|
6
|
+
*
|
|
7
|
+
* 1. **It always returns.** A hung request is worse than a failed one — the
|
|
8
|
+
* user is staring at a spinner holding a signature. Every request carries
|
|
9
|
+
* a deadline it cannot exceed.
|
|
10
|
+
* 2. **It never retries a write.** Retrying an idempotent lookup is free;
|
|
11
|
+
* retrying anything else risks doing the thing twice.
|
|
12
|
+
* 3. **It has no ambient dependencies.** `fetch` is injected rather than
|
|
13
|
+
* reached for, so the same code runs in a browser extension, React Native,
|
|
14
|
+
* a Node service and a test without a global to patch.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type FetchLike = (
|
|
18
|
+
url: string,
|
|
19
|
+
init: {
|
|
20
|
+
method: string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
body?: string;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
},
|
|
25
|
+
) => Promise<{
|
|
26
|
+
ok: boolean;
|
|
27
|
+
status: number;
|
|
28
|
+
text: () => Promise<string>;
|
|
29
|
+
}>;
|
|
30
|
+
|
|
31
|
+
export type TransportFailure =
|
|
32
|
+
| 'timeout'
|
|
33
|
+
| 'network'
|
|
34
|
+
| 'unauthorized'
|
|
35
|
+
| 'forbidden'
|
|
36
|
+
| 'rate_limited'
|
|
37
|
+
| 'invalid_request'
|
|
38
|
+
| 'server_error'
|
|
39
|
+
| 'malformed_response';
|
|
40
|
+
|
|
41
|
+
export class TransportError extends Error {
|
|
42
|
+
readonly failure: TransportFailure;
|
|
43
|
+
readonly status?: number;
|
|
44
|
+
|
|
45
|
+
constructor(failure: TransportFailure, message: string, status?: number) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'TransportError';
|
|
48
|
+
this.failure = failure;
|
|
49
|
+
this.status = status;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RequestOptions {
|
|
54
|
+
path: string;
|
|
55
|
+
body?: unknown;
|
|
56
|
+
/**
|
|
57
|
+
* Safe to repeat. Only idempotent requests are retried; everything else
|
|
58
|
+
* fails on the first attempt rather than risking a duplicate.
|
|
59
|
+
*/
|
|
60
|
+
idempotent?: boolean;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TransportConfig {
|
|
65
|
+
baseUrl: string;
|
|
66
|
+
apiKey: string;
|
|
67
|
+
timeoutMs: number;
|
|
68
|
+
/** Retries for idempotent requests only. 0 disables retrying entirely. */
|
|
69
|
+
retries: number;
|
|
70
|
+
fetch: FetchLike;
|
|
71
|
+
userAgent: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Transport {
|
|
75
|
+
request<T>(options: RequestOptions): Promise<T>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 4xx that will never succeed on a repeat. Retrying these only adds latency. */
|
|
79
|
+
function failureForStatus(status: number): TransportFailure {
|
|
80
|
+
if (status === 401) return 'unauthorized';
|
|
81
|
+
if (status === 403) return 'forbidden';
|
|
82
|
+
if (status === 429) return 'rate_limited';
|
|
83
|
+
if (status >= 400 && status < 500) return 'invalid_request';
|
|
84
|
+
return 'server_error';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const RETRYABLE: TransportFailure[] = ['network', 'timeout', 'server_error', 'rate_limited'];
|
|
88
|
+
|
|
89
|
+
export class HttpTransport implements Transport {
|
|
90
|
+
constructor(private readonly config: TransportConfig) {}
|
|
91
|
+
|
|
92
|
+
async request<T>(options: RequestOptions): Promise<T> {
|
|
93
|
+
const attempts = options.idempotent ? this.config.retries + 1 : 1;
|
|
94
|
+
let last: TransportError = new TransportError('network', 'No attempt was made.');
|
|
95
|
+
|
|
96
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
97
|
+
try {
|
|
98
|
+
return await this.attempt<T>(options);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
last = error instanceof TransportError
|
|
101
|
+
? error
|
|
102
|
+
: new TransportError('network', error instanceof Error ? error.message : 'Unknown error.');
|
|
103
|
+
|
|
104
|
+
if (!RETRYABLE.includes(last.failure)) throw last;
|
|
105
|
+
if (attempt === attempts - 1) throw last;
|
|
106
|
+
|
|
107
|
+
// A short, bounded backoff. Anything longer defeats the point: the
|
|
108
|
+
// user is waiting on a signing screen, not a batch job.
|
|
109
|
+
await delay(120 * (attempt + 1));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
throw last;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async attempt<T>(options: RequestOptions): Promise<T> {
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
119
|
+
|
|
120
|
+
// The caller's own cancellation (the user navigated away, the popup
|
|
121
|
+
// closed) has to reach the request too, or it keeps running after the
|
|
122
|
+
// screen that wanted it is gone.
|
|
123
|
+
const onAbort = () => controller.abort();
|
|
124
|
+
options.signal?.addEventListener('abort', onAbort);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const response = await this.config.fetch(joinUrl(this.config.baseUrl, options.path), {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: {
|
|
130
|
+
'content-type': 'application/json',
|
|
131
|
+
'x-api-key': this.config.apiKey,
|
|
132
|
+
'user-agent': this.config.userAgent,
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify(options.body ?? {}),
|
|
135
|
+
signal: controller.signal,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const text = await response.text();
|
|
139
|
+
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new TransportError(
|
|
142
|
+
failureForStatus(response.status),
|
|
143
|
+
messageFrom(text) ?? `Decentrys returned HTTP ${response.status}.`,
|
|
144
|
+
response.status,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let parsed: unknown;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(text) as unknown;
|
|
151
|
+
} catch {
|
|
152
|
+
throw new TransportError('malformed_response', 'The response was not valid JSON.');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The platform wraps every successful body as `{ data: ... }`. Accepting
|
|
156
|
+
// a bare body too keeps the SDK usable against a proxy that unwraps it.
|
|
157
|
+
const envelope = parsed as { data?: unknown };
|
|
158
|
+
return (envelope && typeof envelope === 'object' && 'data' in envelope
|
|
159
|
+
? envelope.data
|
|
160
|
+
: parsed) as T;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof TransportError) throw error;
|
|
163
|
+
if (isAbort(error)) {
|
|
164
|
+
throw options.signal?.aborted
|
|
165
|
+
? new TransportError('network', 'The request was cancelled by the caller.')
|
|
166
|
+
: new TransportError('timeout', `No response within ${this.config.timeoutMs}ms.`);
|
|
167
|
+
}
|
|
168
|
+
throw new TransportError('network', error instanceof Error ? error.message : 'Network request failed.');
|
|
169
|
+
} finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isAbort(error: unknown): boolean {
|
|
177
|
+
return (
|
|
178
|
+
typeof error === 'object'
|
|
179
|
+
&& error !== null
|
|
180
|
+
&& ((error as { name?: string }).name === 'AbortError'
|
|
181
|
+
|| (error as { code?: string }).code === 'ABORT_ERR')
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Surface the API's own message when it sent one; it is better than ours. */
|
|
186
|
+
function messageFrom(text: string): string | null {
|
|
187
|
+
try {
|
|
188
|
+
const body = JSON.parse(text) as { message?: unknown; error?: unknown };
|
|
189
|
+
if (typeof body.message === 'string' && body.message.trim()) return body.message;
|
|
190
|
+
if (typeof body.error === 'string' && body.error.trim()) return body.error;
|
|
191
|
+
} catch {
|
|
192
|
+
// Not JSON. Nothing to surface.
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function joinUrl(base: string, path: string): string {
|
|
198
|
+
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function delay(ms: number): Promise<void> {
|
|
202
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
203
|
+
}
|
package/src/wire.test.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { normalizeEvidence } from './wire';
|
|
3
|
+
import { classify } from './classify';
|
|
4
|
+
import type { SubjectRef } from './wire';
|
|
5
|
+
|
|
6
|
+
const SUBJECT: SubjectRef = { kind: 'address', chain: 'ethereum', identifier: '0xabc' };
|
|
7
|
+
|
|
8
|
+
describe('normalizeEvidence — the coverage boundary', () => {
|
|
9
|
+
/**
|
|
10
|
+
* The case this boundary exists for.
|
|
11
|
+
*
|
|
12
|
+
* Decentrys' own AML engine emits NEW_ADDRESS with a positive weight, which
|
|
13
|
+
* is correct for a custodian screening for laundering typologies and wrong
|
|
14
|
+
* for a consumer wallet. If that signal reached the classifier unchanged,
|
|
15
|
+
* every newly deployed project on earth would be taxed for being new.
|
|
16
|
+
*/
|
|
17
|
+
it('demotes NEW_ADDRESS to a fact and refuses to let it raise a level', () => {
|
|
18
|
+
const evidence = normalizeEvidence({
|
|
19
|
+
threatSignals: [{
|
|
20
|
+
type: 'NEW_ADDRESS',
|
|
21
|
+
severity: 'MEDIUM',
|
|
22
|
+
confidence: 0.9,
|
|
23
|
+
explanation: 'Address first seen 2 day(s) ago with 3 transactions.',
|
|
24
|
+
hops: 0,
|
|
25
|
+
evidence: [],
|
|
26
|
+
status: 'ACTIVE',
|
|
27
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
28
|
+
}],
|
|
29
|
+
}, SUBJECT);
|
|
30
|
+
|
|
31
|
+
expect(evidence.threatSignals).toHaveLength(0);
|
|
32
|
+
expect(evidence.demotedSignals).toEqual(['NEW_ADDRESS']);
|
|
33
|
+
|
|
34
|
+
// Nothing is hidden — the explanation survives as a fact.
|
|
35
|
+
expect(evidence.facts[0]?.statement).toContain('first seen 2 day(s) ago');
|
|
36
|
+
|
|
37
|
+
const assessment = classify(evidence);
|
|
38
|
+
expect(assessment.riskLevel).toBe('INFORMATIONAL');
|
|
39
|
+
expect(assessment.components.behavioralRisk).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
'NO_AUDIT', 'ANONYMOUS_DEPLOYER', 'LOW_LIQUIDITY',
|
|
44
|
+
'HOLDER_CONCENTRATION', 'UNVERIFIED_SOURCE', 'NOT_ON_TOKEN_LIST',
|
|
45
|
+
])('demotes %s however severe the sender claimed it was', (type) => {
|
|
46
|
+
const evidence = normalizeEvidence({
|
|
47
|
+
threatSignals: [{
|
|
48
|
+
type,
|
|
49
|
+
severity: 'CRITICAL',
|
|
50
|
+
confidence: 1,
|
|
51
|
+
explanation: `${type} observed.`,
|
|
52
|
+
hops: 0,
|
|
53
|
+
evidence: [],
|
|
54
|
+
status: 'ACTIVE',
|
|
55
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
56
|
+
}],
|
|
57
|
+
}, SUBJECT);
|
|
58
|
+
|
|
59
|
+
expect(evidence.threatSignals).toHaveLength(0);
|
|
60
|
+
expect(classify(evidence).riskLevel).toBe('INFORMATIONAL');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('lets a genuine threat signal through untouched', () => {
|
|
64
|
+
const evidence = normalizeEvidence({
|
|
65
|
+
threatSignals: [{
|
|
66
|
+
type: 'DRAINER_INFRASTRUCTURE',
|
|
67
|
+
severity: 'CRITICAL',
|
|
68
|
+
confidence: 0.9,
|
|
69
|
+
explanation: 'This contract matches deployed wallet-drainer bytecode.',
|
|
70
|
+
hops: 0,
|
|
71
|
+
evidence: [{
|
|
72
|
+
id: 'ev-1', type: 'BYTECODE_MATCH', source: 'decentrys',
|
|
73
|
+
observedAt: '2026-09-01T00:00:00.000Z', confidence: 0.9, analystVerified: true,
|
|
74
|
+
}],
|
|
75
|
+
status: 'ACTIVE',
|
|
76
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
77
|
+
}],
|
|
78
|
+
}, SUBJECT);
|
|
79
|
+
|
|
80
|
+
expect(evidence.threatSignals).toHaveLength(1);
|
|
81
|
+
expect(evidence.demotedSignals).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('normalizeEvidence — defensive coercion', () => {
|
|
86
|
+
it('survives a payload that is not an object at all', () => {
|
|
87
|
+
const evidence = normalizeEvidence('nonsense', SUBJECT);
|
|
88
|
+
expect(evidence.subject).toEqual(SUBJECT);
|
|
89
|
+
expect(evidence.threatSignals).toHaveLength(0);
|
|
90
|
+
expect(classify(evidence).riskLevel).toBe('NO_CRITICAL_RISK_DETECTED');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('drops entries missing the fields that give them meaning', () => {
|
|
94
|
+
const evidence = normalizeEvidence({
|
|
95
|
+
facts: [{ type: 'deployed' }, null, 42, { type: 'age', statement: 'Deployed 3 days ago.' }],
|
|
96
|
+
threatSignals: [{ severity: 'CRITICAL' }],
|
|
97
|
+
capabilities: [{ type: 'mint' }],
|
|
98
|
+
}, SUBJECT);
|
|
99
|
+
|
|
100
|
+
expect(evidence.facts).toHaveLength(1);
|
|
101
|
+
expect(evidence.threatSignals).toHaveLength(0);
|
|
102
|
+
expect(evidence.capabilities).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `analystVerified` is the single gate on KNOWN_MALICIOUS — the one output
|
|
107
|
+
* that is a public accusation. An absent field must never read as a human
|
|
108
|
+
* having checked.
|
|
109
|
+
*/
|
|
110
|
+
it('treats absent analystVerified as false', () => {
|
|
111
|
+
const evidence = normalizeEvidence({
|
|
112
|
+
threatSignals: [{
|
|
113
|
+
type: 'CONFIRMED_DRAINER', severity: 'CRITICAL', confidence: 1,
|
|
114
|
+
explanation: 'Drainer.', hops: 0, status: 'ACTIVE',
|
|
115
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
116
|
+
evidence: [{ id: 'e', type: 'REPORT', confidence: 1 }],
|
|
117
|
+
}],
|
|
118
|
+
}, SUBJECT);
|
|
119
|
+
|
|
120
|
+
expect(evidence.threatSignals[0]!.evidence[0]!.analystVerified).toBe(false);
|
|
121
|
+
expect(classify(evidence).confirmedMalicious).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('clamps a confidence sent outside 0–1', () => {
|
|
125
|
+
const evidence = normalizeEvidence({
|
|
126
|
+
threatSignals: [{
|
|
127
|
+
type: 'X', severity: 'HIGH', confidence: 7, explanation: 'x', hops: 0,
|
|
128
|
+
evidence: [], status: 'ACTIVE', createdAt: '2026-09-01T00:00:00.000Z',
|
|
129
|
+
}],
|
|
130
|
+
}, SUBJECT);
|
|
131
|
+
|
|
132
|
+
expect(evidence.threatSignals[0]!.confidence).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* An unknown distance is an inference, not a direct observation, so a
|
|
137
|
+
* missing hop count must not default to 0 — that would present a remote
|
|
138
|
+
* association as something seen on the subject itself.
|
|
139
|
+
*/
|
|
140
|
+
it('defaults a missing hop count to an inference, not a direct observation', () => {
|
|
141
|
+
const evidence = normalizeEvidence({
|
|
142
|
+
threatSignals: [{
|
|
143
|
+
type: 'X', severity: 'HIGH', confidence: 0.8, explanation: 'x',
|
|
144
|
+
evidence: [], status: 'ACTIVE', createdAt: '2026-09-01T00:00:00.000Z',
|
|
145
|
+
}],
|
|
146
|
+
}, SUBJECT);
|
|
147
|
+
|
|
148
|
+
expect(evidence.threatSignals[0]!.hops).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('defaults an unrecognised capability severity down, never up', () => {
|
|
152
|
+
const evidence = normalizeEvidence({
|
|
153
|
+
capabilities: [{ type: 'mint', severity: 'APOCALYPTIC', statement: 'Can mint.' }],
|
|
154
|
+
}, SUBJECT);
|
|
155
|
+
|
|
156
|
+
expect(evidence.capabilities[0]!.severity).toBe('INFO');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('reports NO_HISTORY as none and everything else as limited', () => {
|
|
160
|
+
const none = normalizeEvidence({
|
|
161
|
+
threatSignals: [{
|
|
162
|
+
type: 'NO_HISTORY', severity: 'LOW', confidence: 0.5,
|
|
163
|
+
explanation: 'Nothing on chain.', hops: 0, evidence: [],
|
|
164
|
+
status: 'ACTIVE', createdAt: '2026-09-01T00:00:00.000Z',
|
|
165
|
+
}],
|
|
166
|
+
}, SUBJECT);
|
|
167
|
+
expect(none.historyStatus).toBe('NONE');
|
|
168
|
+
|
|
169
|
+
expect(normalizeEvidence({}, SUBJECT).historyStatus).toBe('LIMITED');
|
|
170
|
+
expect(normalizeEvidence({ historyStatus: 'ESTABLISHED' }, SUBJECT).historyStatus).toBe('ESTABLISHED');
|
|
171
|
+
});
|
|
172
|
+
});
|