@decentrys/dri-sdk 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 +48 -0
- package/dist/browser/decentrys-dri.js +290 -0
- package/dist/browser/decentrys-dri.mjs +265 -0
- package/dist/client.d.ts +183 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +269 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/model.d.ts +354 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +104 -0
- package/dist/model.js.map +1 -0
- package/package.json +61 -0
- package/src/client.test.ts +224 -0
- package/src/client.ts +343 -0
- package/src/index.ts +2 -0
- package/src/model.test.ts +109 -0
- package/src/model.ts +421 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { DecentrysDri, DriError, type FetchLike } from './client';
|
|
3
|
+
import { MissingDisclosureError, type RecoveryIndex } from './model';
|
|
4
|
+
|
|
5
|
+
function ok(body: unknown): ReturnType<FetchLike> {
|
|
6
|
+
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(body)) });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function client(fetchImpl: FetchLike) {
|
|
10
|
+
return new DecentrysDri({ apiKey: 'dk_live_test', fetch: fetchImpl });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const INDEX: RecoveryIndex = {
|
|
14
|
+
recoveryCaseId: 'case-1',
|
|
15
|
+
caseRef: 'REC-2026-0001',
|
|
16
|
+
incidentRef: 'INC-2026-0007',
|
|
17
|
+
score: 58,
|
|
18
|
+
band: 'MODERATE',
|
|
19
|
+
bandMeaning: 'Value is traceable and attributed, but sits in non-custodial wallets.',
|
|
20
|
+
factors: [{ factor: 'baseline', contribution: 30, note: 'Neutral starting position.' }],
|
|
21
|
+
modelVersion: 'recovery-outlook-1.0.0',
|
|
22
|
+
inputsHash: 'abc123',
|
|
23
|
+
computedAt: '2026-02-01T00:00:00.000Z',
|
|
24
|
+
funnel: {
|
|
25
|
+
totalLossUsd: 1_000_000, byState: {}, locatedUsd: 400_000, atInterventionPointUsd: 250_000,
|
|
26
|
+
freezeRequestedUsd: 0, frozenUsd: 0, recoveredUsd: 0, obfuscatedUsd: 0, lostUsd: 0,
|
|
27
|
+
untracedUsd: 0, probabilisticUsd: 0, accountedUsd: 1_000_000, unaccountedUsd: 0, reconciles: true,
|
|
28
|
+
},
|
|
29
|
+
analyticalOnly: true,
|
|
30
|
+
disclaimer: 'The Recovery Outlook is an analytical indicator. It is not a probability and not a forecast.',
|
|
31
|
+
notes: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('construction', () => {
|
|
35
|
+
it('requires a key', () => {
|
|
36
|
+
expect(() => new DecentrysDri({ apiKey: '' })).toThrow(/apiKey is required/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The single most important line in this file.
|
|
41
|
+
*
|
|
42
|
+
* An investigation names third parties who are, so far as the record shows,
|
|
43
|
+
* uninvolved businesses. A publishable key is readable by anyone who
|
|
44
|
+
* downloads the artifact carrying it, so accepting one here would mean
|
|
45
|
+
* anybody who unzipped an app could start opening cases that name people.
|
|
46
|
+
* The server refuses it too — `dri:investigate` is not a publishable scope —
|
|
47
|
+
* but a clear failure at construction beats a 403 three weeks in.
|
|
48
|
+
*/
|
|
49
|
+
it('refuses a publishable key outright', () => {
|
|
50
|
+
expect(() => new DecentrysDri({ apiKey: 'dk_pub_live_abc' }))
|
|
51
|
+
.toThrow(/publishable key/);
|
|
52
|
+
expect(() => new DecentrysDri({ apiKey: 'dk_pub_live_abc' }))
|
|
53
|
+
.toThrow(/names third parties/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The contract, and why it is the opposite of Protect's.
|
|
59
|
+
*
|
|
60
|
+
* Protect never throws: a wallet must stay usable when Decentrys is down.
|
|
61
|
+
* Nothing here is on a signing path. A trace that silently returned an empty
|
|
62
|
+
* graph when the service was unreachable would tell a responder the value had
|
|
63
|
+
* vanished at exactly the moment it was still moving — and nothing about that
|
|
64
|
+
* failure would be visible.
|
|
65
|
+
*/
|
|
66
|
+
describe('failure behaviour', () => {
|
|
67
|
+
it('throws rather than returning an empty graph when unreachable', async () => {
|
|
68
|
+
const dri = client(() => Promise.reject(new Error('connection refused')));
|
|
69
|
+
await expect(dri.getFlowGraph('inv-1')).rejects.toBeInstanceOf(DriError);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('surfaces the API’s own message and code', async () => {
|
|
73
|
+
const dri = client(() => Promise.resolve({
|
|
74
|
+
ok: false, status: 403,
|
|
75
|
+
text: () => Promise.resolve(JSON.stringify({ code: 'FORBIDDEN', message: 'API key scope: dri:investigate' })),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
const error = await dri.getFlowGraph('inv-1').catch((e: unknown) => e);
|
|
79
|
+
expect((error as DriError).message).toBe('API key scope: dri:investigate');
|
|
80
|
+
expect((error as DriError).code).toBe('FORBIDDEN');
|
|
81
|
+
expect((error as DriError).status).toBe(403);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('gives up at the deadline instead of hanging a caller’s process', async () => {
|
|
85
|
+
const hang: FetchLike = (_u, init) => new Promise((_res, rej) => {
|
|
86
|
+
init.signal?.addEventListener('abort', () => rej(Object.assign(new Error('aborted'), { name: 'AbortError' })));
|
|
87
|
+
});
|
|
88
|
+
const dri = new DecentrysDri({ apiKey: 'dk_live_test', timeoutMs: 20, fetch: hang });
|
|
89
|
+
|
|
90
|
+
const error = await dri.traceFunds({ investigationId: 'inv-1' }).catch((e: unknown) => e);
|
|
91
|
+
expect((error as DriError).message).toContain('No response within');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Recovery Index', () => {
|
|
96
|
+
/**
|
|
97
|
+
* The requirement Stage 8 exists to satisfy, enforced at the boundary.
|
|
98
|
+
*
|
|
99
|
+
* The server already checks this. Duplicating it here is the point: an
|
|
100
|
+
* integrator's dashboard renders whatever arrives, and "58 — MODERATE"
|
|
101
|
+
* beside the word recovery is read as a rate by everyone who sees it. A
|
|
102
|
+
* proxy stripping fields, an older deployment or a hand-rolled response must
|
|
103
|
+
* not be able to turn an analytical estimate into a bare number.
|
|
104
|
+
*/
|
|
105
|
+
it('refuses a score that arrives without its disclaimer', async () => {
|
|
106
|
+
const dri = client(() => ok({ data: { ...INDEX, disclaimer: '' } }));
|
|
107
|
+
|
|
108
|
+
await expect(dri.getRecoveryIndex('case-1')).rejects.toBeInstanceOf(MissingDisclosureError);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('refuses a score that is not marked analytical-only', async () => {
|
|
112
|
+
const dri = client(() => ok({ data: { ...INDEX, analyticalOnly: false } }));
|
|
113
|
+
|
|
114
|
+
await expect(dri.getRecoveryIndex('case-1')).rejects.toBeInstanceOf(MissingDisclosureError);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A disclosure failure is not a transport failure. A caller that wraps DRI
|
|
119
|
+
* calls in a retry-on-DriError loop would otherwise treat a stripped
|
|
120
|
+
* disclaimer as a blip and retry it forever, never surfacing the one problem
|
|
121
|
+
* that actually needed a human.
|
|
122
|
+
*/
|
|
123
|
+
it('does not disguise a missing disclaimer as a network error', async () => {
|
|
124
|
+
const dri = client(() => ok({ data: { ...INDEX, disclaimer: ' ' } }));
|
|
125
|
+
|
|
126
|
+
const error = await dri.getRecoveryIndex('case-1').catch((e: unknown) => e);
|
|
127
|
+
expect(error).not.toBeInstanceOf(DriError);
|
|
128
|
+
expect((error as Error).name).toBe('MissingDisclosureError');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns the score with its model version and inputs hash, so a figure is reproducible', async () => {
|
|
132
|
+
const dri = client(() => ok({ data: INDEX }));
|
|
133
|
+
|
|
134
|
+
const index = await dri.getRecoveryIndex('case-1');
|
|
135
|
+
expect(index.score).toBe(58);
|
|
136
|
+
expect(index.modelVersion).toBe('recovery-outlook-1.0.0');
|
|
137
|
+
expect(index.inputsHash).toBe('abc123');
|
|
138
|
+
expect(index.disclaimer).toContain('not a probability');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('request shape', () => {
|
|
143
|
+
it('keeps the key in a header, never in the URL', async () => {
|
|
144
|
+
const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => ok({ data: {} }));
|
|
145
|
+
await client(fetchImpl).getFlowGraph('inv-1');
|
|
146
|
+
|
|
147
|
+
const [url, init] = fetchImpl.mock.calls[0]!;
|
|
148
|
+
expect(url).not.toContain('dk_live_test');
|
|
149
|
+
expect(init.headers['x-api-key']).toBe('dk_live_test');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A case reference or an identifier goes into a path segment. Left unencoded
|
|
154
|
+
* a stray slash would silently address a different route — on this API,
|
|
155
|
+
* plausibly a different customer's.
|
|
156
|
+
*/
|
|
157
|
+
it('encodes identifiers into the path', async () => {
|
|
158
|
+
const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => ok({ data: INDEX }));
|
|
159
|
+
await client(fetchImpl).getRecoveryIndex('case/../other');
|
|
160
|
+
|
|
161
|
+
expect(fetchImpl.mock.calls[0]![0]).toBe(
|
|
162
|
+
'https://api.decentrys.com/v1/dri/cases/case%2F..%2Fother/recovery-index',
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('omits fromNodeId entirely when none is given, so the server picks the frontier', async () => {
|
|
167
|
+
const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => ok({ data: {} }));
|
|
168
|
+
await client(fetchImpl).traceFunds({ investigationId: 'inv-1' });
|
|
169
|
+
|
|
170
|
+
const [url, init] = fetchImpl.mock.calls[0]!;
|
|
171
|
+
expect(url).toContain('/v1/dri/investigations/inv-1/trace');
|
|
172
|
+
expect(JSON.parse(init.body!)).toEqual({});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('sends the seed as chain and address on creation', async () => {
|
|
176
|
+
const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => ok({ data: {} }));
|
|
177
|
+
await client(fetchImpl).createInvestigation({
|
|
178
|
+
title: 'Bridge drain', chain: 'ethereum', address: '0xseed', maxHops: 3,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const body = JSON.parse(fetchImpl.mock.calls[0]![1].body!) as Record<string, unknown>;
|
|
182
|
+
expect(body).toEqual({ title: 'Bridge drain', chain: 'ethereum', address: '0xseed', maxHops: 3 });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The list endpoints wrap their rows, and a caller wants the rows. Unwrapping
|
|
187
|
+
* here rather than in every call site is what keeps `listCases()` usable as
|
|
188
|
+
* the way to find a `recoveryCaseId`.
|
|
189
|
+
*/
|
|
190
|
+
it('unwraps list responses to the rows themselves', async () => {
|
|
191
|
+
const dri = client(() => ok({ data: { cases: [{ caseRef: 'REC-2026-0001' }] } }));
|
|
192
|
+
const cases = await dri.listCases();
|
|
193
|
+
expect(cases).toHaveLength(1);
|
|
194
|
+
expect(cases[0]!.caseRef).toBe('REC-2026-0001');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* There is no method here that freezes, seizes, holds, transmits or returns
|
|
200
|
+
* anything, and there must never be one. Decentrys never takes custody:
|
|
201
|
+
* recovered value moves between the parties entitled to it and never through
|
|
202
|
+
* us, so a method that appeared to do otherwise would be a claim the company
|
|
203
|
+
* cannot make — and an SDK surface is exactly where somebody would add one for
|
|
204
|
+
* convenience.
|
|
205
|
+
*/
|
|
206
|
+
describe('what this client cannot do', () => {
|
|
207
|
+
it('exposes no method that acts on assets', () => {
|
|
208
|
+
const methods = Object.getOwnPropertyNames(DecentrysDri.prototype);
|
|
209
|
+
for (const name of methods) {
|
|
210
|
+
expect(name, name).not.toMatch(/freeze|seize|recover(?!yIndex)|refund|return|withdraw|custody|transmit/i);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('exposes exactly the seven documented capabilities plus the two lookups', () => {
|
|
215
|
+
const methods = Object.getOwnPropertyNames(DecentrysDri.prototype)
|
|
216
|
+
.filter((name) => name !== 'constructor' && !name.startsWith('request'));
|
|
217
|
+
|
|
218
|
+
expect(new Set(methods)).toEqual(new Set([
|
|
219
|
+
'createInvestigation', 'traceFunds', 'getFlowGraph', 'identifyInterventionPoints',
|
|
220
|
+
'getRecoveryIndex', 'generateEvidencePackage', 'getInvestigationTimeline',
|
|
221
|
+
'listInvestigations', 'listCases',
|
|
222
|
+
]));
|
|
223
|
+
});
|
|
224
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DRI client — Digital Recovery Intelligence.
|
|
3
|
+
*
|
|
4
|
+
* Fund tracing, attribution, intervention-point identification and evidence
|
|
5
|
+
* packages, for the organisation whose value was taken.
|
|
6
|
+
*
|
|
7
|
+
* **This client throws**, like Sentinel and Risk and unlike Protect. Protect
|
|
8
|
+
* sits between a user and a signing screen, so a bad minute must never cost
|
|
9
|
+
* someone their transaction. Nothing here is on a signing path: a trace that
|
|
10
|
+
* quietly returned an empty graph when the service was unreachable would tell
|
|
11
|
+
* a responder the value had vanished at exactly the moment it was still
|
|
12
|
+
* moving, and that failure is invisible by construction. Better a loud error.
|
|
13
|
+
*
|
|
14
|
+
* **A publishable key is refused at construction.** An investigation names
|
|
15
|
+
* third parties who are, so far as the record shows, uninvolved businesses.
|
|
16
|
+
* That must never run behind a credential shipped inside a client artifact,
|
|
17
|
+
* where anyone who downloads it can read it and start naming people. The
|
|
18
|
+
* server refuses one too — `dri:investigate` is not a publishable scope and
|
|
19
|
+
* `/v1/dri/` is not a publishable path — but a clear error here beats a 403
|
|
20
|
+
* three weeks into an integration.
|
|
21
|
+
*
|
|
22
|
+
* What this client cannot do, at all, and by design:
|
|
23
|
+
*
|
|
24
|
+
* - It cannot freeze, seize, hold, transmit or return anything. Decentrys
|
|
25
|
+
* never takes custody of recovered value; it moves between the parties
|
|
26
|
+
* entitled to it and never through us.
|
|
27
|
+
* - It cannot record that a custodian, an issuer, a court or an agency acted.
|
|
28
|
+
* Those states name the party who acted and are written by staff, because
|
|
29
|
+
* an audit trail that says Decentrys did something it did not is worse than
|
|
30
|
+
* no trail.
|
|
31
|
+
* - It cannot give legal advice. Decentrys is not a law firm, a
|
|
32
|
+
* law-enforcement agency or a licensed recovery agent.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
assertAnalyticalDisclosure,
|
|
37
|
+
type EvidencePackage, type EvidenceRecipientType, type FlowGraph, type InterventionAnalysis,
|
|
38
|
+
type InvestigationSummary, type InvestigationTimeline, type RecoveryCaseSummary,
|
|
39
|
+
type RecoveryIndex, type TraceResult,
|
|
40
|
+
} from './model';
|
|
41
|
+
|
|
42
|
+
export const SDK_VERSION = '0.1.0';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_BASE_URL = 'https://api.decentrys.com';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ten seconds, as elsewhere. A trace expands one address against rate-limited
|
|
48
|
+
* public endpoints, so it is the slowest call here by some margin — but a
|
|
49
|
+
* client that waits indefinitely is a client that hangs, and the graph is
|
|
50
|
+
* persisted server-side either way, so a timed-out trace is re-readable rather
|
|
51
|
+
* than lost.
|
|
52
|
+
*/
|
|
53
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
export type FetchLike = (
|
|
56
|
+
url: string,
|
|
57
|
+
init: { method: string; headers: Record<string, string>; body?: string; signal?: AbortSignal },
|
|
58
|
+
) => Promise<{ ok: boolean; status: number; text: () => Promise<string> }>;
|
|
59
|
+
|
|
60
|
+
export class DriError extends Error {
|
|
61
|
+
constructor(readonly status: number, message: string, readonly code?: string) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = 'DriError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DriConfig {
|
|
68
|
+
apiKey: string;
|
|
69
|
+
baseUrl?: string;
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
fetch?: FetchLike;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CreateInvestigationInput {
|
|
75
|
+
/** What this case is, in a form a reader will recognise months later. */
|
|
76
|
+
title: string;
|
|
77
|
+
chain: string;
|
|
78
|
+
/** The seed: the victim contract, or the first attacker wallet. */
|
|
79
|
+
address: string;
|
|
80
|
+
/** Links the case to a declared incident of your own organisation. */
|
|
81
|
+
incidentId?: string;
|
|
82
|
+
/** 1-4. The graph will not be expanded past this distance from the seed. */
|
|
83
|
+
maxHops?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface TraceFundsInput {
|
|
87
|
+
investigationId: string;
|
|
88
|
+
/**
|
|
89
|
+
* Which address to follow. Omit to take the shallowest one nobody has looked
|
|
90
|
+
* past yet, which is the ordering that keeps a graph anchored in what was
|
|
91
|
+
* directly traced.
|
|
92
|
+
*/
|
|
93
|
+
fromNodeId?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface EvidencePackageInput {
|
|
97
|
+
recoveryCaseId: string;
|
|
98
|
+
recipientType: EvidenceRecipientType;
|
|
99
|
+
recipientName: string;
|
|
100
|
+
/** Narrows the package to one identified destination, when there is one. */
|
|
101
|
+
recoveryLeadId?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class DecentrysDri {
|
|
105
|
+
private readonly baseUrl: string;
|
|
106
|
+
private readonly apiKey: string;
|
|
107
|
+
private readonly timeoutMs: number;
|
|
108
|
+
private readonly fetchImpl: FetchLike;
|
|
109
|
+
|
|
110
|
+
constructor(config: DriConfig) {
|
|
111
|
+
if (!config.apiKey?.trim()) {
|
|
112
|
+
throw new Error('DRI: an apiKey is required. Create one at https://decentrys.com/developers.');
|
|
113
|
+
}
|
|
114
|
+
if (config.apiKey.startsWith('dk_pub_')) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'DRI: that is a publishable key. An investigation names third parties, and that must never run behind a '
|
|
117
|
+
+ 'credential shipped inside a client where anyone who downloads it can read it. Use a secret key, '
|
|
118
|
+
+ 'server-side.',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.apiKey = config.apiKey;
|
|
123
|
+
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
124
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
125
|
+
this.fetchImpl = config.fetch ?? resolveFetch();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Investigations ------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Open an investigation seeded on one address.
|
|
132
|
+
*
|
|
133
|
+
* Nothing is traced yet. The seed node exists so the graph has a root before
|
|
134
|
+
* any chain call is made, which means a case can be opened and worked on
|
|
135
|
+
* even while a provider is unreachable.
|
|
136
|
+
*/
|
|
137
|
+
createInvestigation(input: CreateInvestigationInput): Promise<FlowGraph> {
|
|
138
|
+
return this.request<FlowGraph>('POST', '/v1/dri/investigations', {
|
|
139
|
+
title: input.title,
|
|
140
|
+
chain: input.chain,
|
|
141
|
+
address: input.address,
|
|
142
|
+
...(input.incidentId ? { incidentId: input.incidentId } : {}),
|
|
143
|
+
...(input.maxHops === undefined ? {} : { maxHops: input.maxHops }),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
listInvestigations(limit?: number): Promise<InvestigationSummary[]> {
|
|
148
|
+
const query = limit === undefined ? '' : `?limit=${encodeURIComponent(String(limit))}`;
|
|
149
|
+
return this.request<{ investigations: InvestigationSummary[] }>(
|
|
150
|
+
'GET', `/v1/dri/investigations${query}`,
|
|
151
|
+
).then((body) => body.investigations);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Follow the value one address further.
|
|
156
|
+
*
|
|
157
|
+
* One address, one hop, per call — and that is a deliberate limit rather
|
|
158
|
+
* than an unfinished feature. The public endpoints a trace reads are rate
|
|
159
|
+
* limited, and an unattended crawl to four hops exhausts them; the result
|
|
160
|
+
* then reads as "the funds vanished" rather than "we ran out of quota",
|
|
161
|
+
* which on this product is the worst possible way to be wrong. Call it in a
|
|
162
|
+
* loop if you want depth, and read `coverage.truncated` on each result.
|
|
163
|
+
*/
|
|
164
|
+
traceFunds(input: TraceFundsInput): Promise<TraceResult> {
|
|
165
|
+
return this.request<TraceResult>('POST', `/v1/dri/investigations/${encode(input.investigationId)}/trace`, {
|
|
166
|
+
...(input.fromNodeId ? { fromNodeId: input.fromNodeId } : {}),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The graph as it stands: every address reached, and how it was reached. */
|
|
171
|
+
getFlowGraph(investigationId: string): Promise<FlowGraph> {
|
|
172
|
+
return this.request<FlowGraph>('GET', `/v1/dri/investigations/${encode(investigationId)}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Where a third party could act, and where attribution stops.
|
|
177
|
+
*
|
|
178
|
+
* Read both halves. An intervention point is a party who holds value and can
|
|
179
|
+
* be contacted, served or subpoenaed — whether it does anything is a matter
|
|
180
|
+
* for it, its regulators and any legal process, never for Decentrys. An
|
|
181
|
+
* attribution limit is where the trail stops being provable, and a case that
|
|
182
|
+
* reaches one has established an answer rather than failed.
|
|
183
|
+
*
|
|
184
|
+
* Every point carries `observedPath`. False means the connection to this
|
|
185
|
+
* case runs through mixing or privacy infrastructure and is therefore
|
|
186
|
+
* inference — an observation about a counterparty, never an accusation about
|
|
187
|
+
* a person. `inferredPoints()` isolates them.
|
|
188
|
+
*/
|
|
189
|
+
identifyInterventionPoints(investigationId: string): Promise<InterventionAnalysis> {
|
|
190
|
+
return this.request<InterventionAnalysis>(
|
|
191
|
+
'GET', `/v1/dri/investigations/${encode(investigationId)}/intervention-points`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What happened on this case, oldest first, with the acting party on every
|
|
197
|
+
* entry.
|
|
198
|
+
*
|
|
199
|
+
* Merges the recovery record in when the incident behind the investigation
|
|
200
|
+
* has a case. Entries attributed to anyone other than Decentrys are recorded
|
|
201
|
+
* as reported by that party — Decentrys did not perform them and does not
|
|
202
|
+
* verify them.
|
|
203
|
+
*/
|
|
204
|
+
getInvestigationTimeline(investigationId: string, limit?: number): Promise<InvestigationTimeline> {
|
|
205
|
+
const query = limit === undefined ? '' : `?limit=${encodeURIComponent(String(limit))}`;
|
|
206
|
+
return this.request<InvestigationTimeline>(
|
|
207
|
+
'GET', `/v1/dri/investigations/${encode(investigationId)}/timeline${query}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Recovery cases ------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The recovery cases of your organisation.
|
|
215
|
+
*
|
|
216
|
+
* A case is opened as part of an engagement, against a declared incident and
|
|
217
|
+
* a stated loss; the tranche ledger behind it is analyst work, so it is not
|
|
218
|
+
* something an API call brings into existence. This is how you find the
|
|
219
|
+
* `recoveryCaseId` that `getRecoveryIndex` and `generateEvidencePackage`
|
|
220
|
+
* take — and `getFlowGraph` reports it too, for an investigation attached to
|
|
221
|
+
* one.
|
|
222
|
+
*/
|
|
223
|
+
listCases(limit?: number): Promise<RecoveryCaseSummary[]> {
|
|
224
|
+
const query = limit === undefined ? '' : `?limit=${encodeURIComponent(String(limit))}`;
|
|
225
|
+
return this.request<{ cases: RecoveryCaseSummary[] }>('GET', `/v1/dri/cases${query}`)
|
|
226
|
+
.then((body) => body.cases);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The Recovery Index for a case.
|
|
231
|
+
*
|
|
232
|
+
* An **analytical estimate** of the current evidentiary and custodial
|
|
233
|
+
* position, produced by a versioned model from the evidence recorded in the
|
|
234
|
+
* case. It is not a probability, not a forecast, and not a representation
|
|
235
|
+
* that any value will be recovered. `modelVersion` and `inputsHash` come
|
|
236
|
+
* back with it so any figure shown to a client can be reproduced exactly
|
|
237
|
+
* from the record it was computed from.
|
|
238
|
+
*
|
|
239
|
+
* The disclaimer travels in the payload, and this method **refuses a
|
|
240
|
+
* response that does not carry it** rather than returning a bare number.
|
|
241
|
+
* That check is deliberately duplicated from the server: an integrator's
|
|
242
|
+
* dashboard renders whatever arrives, and "58 — MODERATE" beside the word
|
|
243
|
+
* recovery is read as a rate by everyone who sees it. If the caveat ever
|
|
244
|
+
* stops arriving, failing here is the only outcome that keeps the score
|
|
245
|
+
* honest.
|
|
246
|
+
*/
|
|
247
|
+
getRecoveryIndex(recoveryCaseId: string): Promise<RecoveryIndex> {
|
|
248
|
+
return this.request<RecoveryIndex>('GET', `/v1/dri/cases/${encode(recoveryCaseId)}/recovery-index`)
|
|
249
|
+
.then(assertAnalyticalDisclosure);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Prepare an evidence package for a recipient.
|
|
254
|
+
*
|
|
255
|
+
* **Prepared, not sent.** The package goes to you and, at your direction, to
|
|
256
|
+
* your counsel, your insurer or a law-enforcement agency. Decentrys does not
|
|
257
|
+
* contact counterparties on your behalf unless instructed, and recording
|
|
258
|
+
* that a package was transmitted is a separate act that names who sent it.
|
|
259
|
+
*
|
|
260
|
+
* The document is frozen and hashed at generation, because third parties
|
|
261
|
+
* make freezing decisions on the strength of these and one that could be
|
|
262
|
+
* regenerated differently afterwards would be worth nothing. Quote
|
|
263
|
+
* `contentHash` back to a recipient so they can verify what they hold.
|
|
264
|
+
*/
|
|
265
|
+
generateEvidencePackage(input: EvidencePackageInput): Promise<EvidencePackage> {
|
|
266
|
+
return this.request<EvidencePackage>(
|
|
267
|
+
'POST', `/v1/dri/cases/${encode(input.recoveryCaseId)}/evidence-package`,
|
|
268
|
+
{
|
|
269
|
+
recipientType: input.recipientType,
|
|
270
|
+
recipientName: input.recipientName,
|
|
271
|
+
...(input.recoveryLeadId ? { recoveryLeadId: input.recoveryLeadId } : {}),
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// --- transport -----------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
279
|
+
const controller = new AbortController();
|
|
280
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
284
|
+
method,
|
|
285
|
+
headers: {
|
|
286
|
+
'content-type': 'application/json',
|
|
287
|
+
'x-api-key': this.apiKey,
|
|
288
|
+
'user-agent': `decentrys-dri/${SDK_VERSION}`,
|
|
289
|
+
},
|
|
290
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
291
|
+
signal: controller.signal,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const text = await response.text();
|
|
295
|
+
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
const parsed = safeParse(text);
|
|
298
|
+
throw new DriError(
|
|
299
|
+
response.status,
|
|
300
|
+
typeof parsed?.message === 'string' ? parsed.message : `Decentrys returned HTTP ${response.status}.`,
|
|
301
|
+
typeof parsed?.code === 'string' ? parsed.code : undefined,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!text) return undefined as T;
|
|
306
|
+
|
|
307
|
+
const parsed = safeParse(text);
|
|
308
|
+
if (parsed === null) throw new DriError(response.status, 'The response was not valid JSON.');
|
|
309
|
+
return ('data' in parsed ? parsed.data : parsed) as T;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (error instanceof DriError) throw error;
|
|
312
|
+
// A disclosure failure is not a transport failure and must not be
|
|
313
|
+
// rewritten as one — it is thrown by `assertAnalyticalDisclosure`
|
|
314
|
+
// downstream of this promise, but a caller wrapping the whole call in a
|
|
315
|
+
// retry would otherwise treat it as a blip and try again forever.
|
|
316
|
+
if (controller.signal.aborted) throw new DriError(0, `No response within ${this.timeoutMs}ms.`);
|
|
317
|
+
throw new DriError(0, error instanceof Error ? error.message : 'Request failed.');
|
|
318
|
+
} finally {
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function encode(segment: string): string {
|
|
325
|
+
return encodeURIComponent(segment);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function safeParse(text: string): Record<string, unknown> | null {
|
|
329
|
+
try {
|
|
330
|
+
const parsed = JSON.parse(text) as unknown;
|
|
331
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function resolveFetch(): FetchLike {
|
|
338
|
+
const candidate = (globalThis as { fetch?: unknown }).fetch;
|
|
339
|
+
if (typeof candidate !== 'function') {
|
|
340
|
+
throw new Error('DRI: no global fetch was found. Pass one via `new DecentrysDri({ fetch })`.');
|
|
341
|
+
}
|
|
342
|
+
return candidate.bind(globalThis) as FetchLike;
|
|
343
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
assertAnalyticalDisclosure, custodialPoints, inferredPoints, MissingDisclosureError,
|
|
4
|
+
type InterventionAnalysis, type InterventionPoint, type RecoveryIndex,
|
|
5
|
+
} from './model';
|
|
6
|
+
|
|
7
|
+
function index(overrides: Partial<RecoveryIndex> = {}): RecoveryIndex {
|
|
8
|
+
return {
|
|
9
|
+
recoveryCaseId: 'case-1', caseRef: 'REC-2026-0001', incidentRef: 'INC-2026-0007',
|
|
10
|
+
score: 58, band: 'MODERATE', bandMeaning: 'Value is traceable but sits in non-custodial wallets.',
|
|
11
|
+
factors: [], modelVersion: 'recovery-outlook-1.0.0', inputsHash: 'abc',
|
|
12
|
+
computedAt: '2026-02-01T00:00:00.000Z',
|
|
13
|
+
funnel: {
|
|
14
|
+
totalLossUsd: 0, byState: {}, locatedUsd: 0, atInterventionPointUsd: 0, freezeRequestedUsd: 0,
|
|
15
|
+
frozenUsd: 0, recoveredUsd: 0, obfuscatedUsd: 0, lostUsd: 0, untracedUsd: 0,
|
|
16
|
+
probabilisticUsd: 0, accountedUsd: 0, unaccountedUsd: 0, reconciles: true,
|
|
17
|
+
},
|
|
18
|
+
analyticalOnly: true,
|
|
19
|
+
disclaimer: 'An analytical indicator. Not a probability and not a forecast.',
|
|
20
|
+
notes: [],
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The Recovery Index is the one number in this product that can be
|
|
27
|
+
* misunderstood into a promise. Everything else describes what was observed;
|
|
28
|
+
* this one describes a position, and a position stated without its caveat is
|
|
29
|
+
* heard as a forecast — by a board, by an insurer, and eventually by whoever
|
|
30
|
+
* is deciding whether Decentrys said something it should not have.
|
|
31
|
+
*/
|
|
32
|
+
describe('assertAnalyticalDisclosure', () => {
|
|
33
|
+
it('passes a score that carries both the flag and the text', () => {
|
|
34
|
+
expect(assertAnalyticalDisclosure(index()).score).toBe(58);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('refuses a score with an empty disclaimer', () => {
|
|
38
|
+
expect(() => assertAnalyticalDisclosure(index({ disclaimer: ' ' })))
|
|
39
|
+
.toThrow(MissingDisclosureError);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The flag exists because an integrator's code cannot branch on a paragraph.
|
|
44
|
+
* A payload that carries the prose but not the flag has lost the half that
|
|
45
|
+
* software can act on, so it fails the same way.
|
|
46
|
+
*/
|
|
47
|
+
it('refuses a score that is not machine-readably analytical-only', () => {
|
|
48
|
+
const forged = index({ analyticalOnly: false as unknown as true });
|
|
49
|
+
expect(() => assertAnalyticalDisclosure(forged)).toThrow(/not marked analytical-only/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('explains why, rather than just failing', () => {
|
|
53
|
+
const error = (() => {
|
|
54
|
+
try { assertAnalyticalDisclosure(index({ disclaimer: '' })); return null; } catch (e) { return e as Error; }
|
|
55
|
+
})();
|
|
56
|
+
expect(error!.message).toMatch(/read as a promise of recovery/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function point(overrides: Partial<InterventionPoint>): InterventionPoint {
|
|
61
|
+
return {
|
|
62
|
+
nodeId: 'n', chain: 'ethereum', address: '0x1', nodeType: 'EXCHANGE', kind: 'CUSTODIAL',
|
|
63
|
+
label: null, entityName: null, hop: 1, observedPath: true, expanded: false, basis: 'x',
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function analysis(points: InterventionPoint[]): InterventionAnalysis {
|
|
69
|
+
return {
|
|
70
|
+
investigationId: 'inv-1', caseRef: 'INV-2026-0001',
|
|
71
|
+
interventionPoints: points, attributionLimits: [], unexplored: [], notes: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('reading an intervention analysis', () => {
|
|
76
|
+
/**
|
|
77
|
+
* A custodian holds the value; a bridge operator holds records of value that
|
|
78
|
+
* has already moved on. Writing this filter by hand at each call site is
|
|
79
|
+
* exactly where a bridge quietly gets served to counsel as somewhere to send
|
|
80
|
+
* a freeze request.
|
|
81
|
+
*/
|
|
82
|
+
it('separates parties that hold value from parties that only have records', () => {
|
|
83
|
+
const result = custodialPoints(analysis([
|
|
84
|
+
point({ nodeId: 'cex', kind: 'CUSTODIAL' }),
|
|
85
|
+
point({ nodeId: 'bridge', kind: 'CROSS_CHAIN', nodeType: 'BRIDGE' }),
|
|
86
|
+
]));
|
|
87
|
+
|
|
88
|
+
expect(result.map((p) => p.nodeId)).toEqual(['cex']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A list forwarded to counsel must not silently mix destinations that were
|
|
93
|
+
* traced with destinations that were reasoned to. `inferredPoints` makes the
|
|
94
|
+
* second set something a caller has to decide about.
|
|
95
|
+
*/
|
|
96
|
+
it('isolates the points whose connection to the case is inference', () => {
|
|
97
|
+
const result = inferredPoints(analysis([
|
|
98
|
+
point({ nodeId: 'traced', observedPath: true }),
|
|
99
|
+
point({ nodeId: 'post-mixer', observedPath: false }),
|
|
100
|
+
]));
|
|
101
|
+
|
|
102
|
+
expect(result.map((p) => p.nodeId)).toEqual(['post-mixer']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns nothing rather than everything when a graph has no intervention points', () => {
|
|
106
|
+
expect(custodialPoints(analysis([]))).toEqual([]);
|
|
107
|
+
expect(inferredPoints(analysis([]))).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|