@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Decentrys Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @decentrys/dri-sdk
|
|
2
|
+
|
|
3
|
+
Digital Recovery Intelligence: fund tracing, attribution, intervention points
|
|
4
|
+
and evidence packages.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @decentrys/dri-sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What this is not
|
|
13
|
+
|
|
14
|
+
Decentrys **never takes custody of assets**, and does not freeze, seize,
|
|
15
|
+
transmit or return anything. It is not a law firm, a law enforcement agency or
|
|
16
|
+
a licensed recovery agent, and nothing it returns is legal advice.
|
|
17
|
+
|
|
18
|
+
The **Recovery Index is an analytical estimate** produced by a versioned model
|
|
19
|
+
from the evidence in a case. It is not a prediction, a guarantee, or a promised
|
|
20
|
+
recovery rate. Every response carries that disclaimer, and the client throws
|
|
21
|
+
`MissingDisclosureError` if one ever arrives without it.
|
|
22
|
+
|
|
23
|
+
## Use
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const investigation = await dri.createInvestigation({ ... });
|
|
27
|
+
await dri.traceFunds(investigation.id); // one address, one hop
|
|
28
|
+
const points = await dri.identifyInterventionPoints(investigation.id);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Intervention points separate `CUSTODIAL` from `CROSS_CHAIN`: a custodian holds
|
|
32
|
+
the value, a bridge operator holds records of value that has moved on.
|
|
33
|
+
Collapsing them sends legal budget after a paper trail.
|
|
34
|
+
|
|
35
|
+
Attribution beyond a mixer is inference and is labelled as inference. An
|
|
36
|
+
indirect connection is an observation about a counterparty, never an
|
|
37
|
+
accusation about a person.
|
|
38
|
+
|
|
39
|
+
**Secret keys only** — an investigation names third parties.
|
|
40
|
+
|
|
41
|
+
## Licence
|
|
42
|
+
|
|
43
|
+
MIT © Decentrys Labs
|
|
44
|
+
|
|
45
|
+
## Links
|
|
46
|
+
|
|
47
|
+
- [decentrys.com](https://decentrys.com) · [SDK overview](https://decentrys.com/sdk) · [Developer API](https://decentrys.com/developers)
|
|
48
|
+
- Source: [github.com/teamdecentrys-byte/Decentrys](https://github.com/teamdecentrys-byte/Decentrys)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var DecentrysDri = (() => {
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var index_exports = {};
|
|
23
|
+
__export(index_exports, {
|
|
24
|
+
DecentrysDri: () => DecentrysDri,
|
|
25
|
+
DriError: () => DriError,
|
|
26
|
+
MissingDisclosureError: () => MissingDisclosureError,
|
|
27
|
+
RECOVERY_BANDS: () => RECOVERY_BANDS,
|
|
28
|
+
SDK_VERSION: () => SDK_VERSION,
|
|
29
|
+
assertAnalyticalDisclosure: () => assertAnalyticalDisclosure,
|
|
30
|
+
custodialPoints: () => custodialPoints,
|
|
31
|
+
inferredPoints: () => inferredPoints
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// src/model.ts
|
|
35
|
+
var RECOVERY_BANDS = ["HIGH", "MODERATE", "LOW", "VERY_LOW"];
|
|
36
|
+
var MissingDisclosureError = class extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "MissingDisclosureError";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
function assertAnalyticalDisclosure(index) {
|
|
43
|
+
if (index.analyticalOnly !== true) {
|
|
44
|
+
throw new MissingDisclosureError(
|
|
45
|
+
"This Recovery Index is not marked analytical-only. The Recovery Index is an analytical estimate and never a predicted recovery rate; refusing to hand back a score that does not say so."
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (typeof index.disclaimer !== "string" || index.disclaimer.trim().length === 0) {
|
|
49
|
+
throw new MissingDisclosureError(
|
|
50
|
+
"This Recovery Index arrived without its disclaimer. The disclaimer travels with the score by design, because a score shown without it is read as a promise of recovery."
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return index;
|
|
54
|
+
}
|
|
55
|
+
function custodialPoints(analysis) {
|
|
56
|
+
return analysis.interventionPoints.filter((point) => point.kind === "CUSTODIAL");
|
|
57
|
+
}
|
|
58
|
+
function inferredPoints(analysis) {
|
|
59
|
+
return analysis.interventionPoints.filter((point) => !point.observedPath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/client.ts
|
|
63
|
+
var SDK_VERSION = "0.1.0";
|
|
64
|
+
var DEFAULT_BASE_URL = "https://api.decentrys.com";
|
|
65
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
66
|
+
var DriError = class extends Error {
|
|
67
|
+
constructor(status, message, code) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.status = status;
|
|
70
|
+
this.code = code;
|
|
71
|
+
this.name = "DriError";
|
|
72
|
+
}
|
|
73
|
+
status;
|
|
74
|
+
code;
|
|
75
|
+
};
|
|
76
|
+
var DecentrysDri = class {
|
|
77
|
+
baseUrl;
|
|
78
|
+
apiKey;
|
|
79
|
+
timeoutMs;
|
|
80
|
+
fetchImpl;
|
|
81
|
+
constructor(config) {
|
|
82
|
+
if (!config.apiKey?.trim()) {
|
|
83
|
+
throw new Error("DRI: an apiKey is required. Create one at https://decentrys.com/developers.");
|
|
84
|
+
}
|
|
85
|
+
if (config.apiKey.startsWith("dk_pub_")) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"DRI: that is a publishable key. An investigation names third parties, and that must never run behind a credential shipped inside a client where anyone who downloads it can read it. Use a secret key, server-side."
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
this.apiKey = config.apiKey;
|
|
91
|
+
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
92
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
93
|
+
this.fetchImpl = config.fetch ?? resolveFetch();
|
|
94
|
+
}
|
|
95
|
+
// --- Investigations ------------------------------------------------------
|
|
96
|
+
/**
|
|
97
|
+
* Open an investigation seeded on one address.
|
|
98
|
+
*
|
|
99
|
+
* Nothing is traced yet. The seed node exists so the graph has a root before
|
|
100
|
+
* any chain call is made, which means a case can be opened and worked on
|
|
101
|
+
* even while a provider is unreachable.
|
|
102
|
+
*/
|
|
103
|
+
createInvestigation(input) {
|
|
104
|
+
return this.request("POST", "/v1/dri/investigations", {
|
|
105
|
+
title: input.title,
|
|
106
|
+
chain: input.chain,
|
|
107
|
+
address: input.address,
|
|
108
|
+
...input.incidentId ? { incidentId: input.incidentId } : {},
|
|
109
|
+
...input.maxHops === void 0 ? {} : { maxHops: input.maxHops }
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
listInvestigations(limit) {
|
|
113
|
+
const query = limit === void 0 ? "" : `?limit=${encodeURIComponent(String(limit))}`;
|
|
114
|
+
return this.request(
|
|
115
|
+
"GET",
|
|
116
|
+
`/v1/dri/investigations${query}`
|
|
117
|
+
).then((body) => body.investigations);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Follow the value one address further.
|
|
121
|
+
*
|
|
122
|
+
* One address, one hop, per call — and that is a deliberate limit rather
|
|
123
|
+
* than an unfinished feature. The public endpoints a trace reads are rate
|
|
124
|
+
* limited, and an unattended crawl to four hops exhausts them; the result
|
|
125
|
+
* then reads as "the funds vanished" rather than "we ran out of quota",
|
|
126
|
+
* which on this product is the worst possible way to be wrong. Call it in a
|
|
127
|
+
* loop if you want depth, and read `coverage.truncated` on each result.
|
|
128
|
+
*/
|
|
129
|
+
traceFunds(input) {
|
|
130
|
+
return this.request("POST", `/v1/dri/investigations/${encode(input.investigationId)}/trace`, {
|
|
131
|
+
...input.fromNodeId ? { fromNodeId: input.fromNodeId } : {}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** The graph as it stands: every address reached, and how it was reached. */
|
|
135
|
+
getFlowGraph(investigationId) {
|
|
136
|
+
return this.request("GET", `/v1/dri/investigations/${encode(investigationId)}`);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Where a third party could act, and where attribution stops.
|
|
140
|
+
*
|
|
141
|
+
* Read both halves. An intervention point is a party who holds value and can
|
|
142
|
+
* be contacted, served or subpoenaed — whether it does anything is a matter
|
|
143
|
+
* for it, its regulators and any legal process, never for Decentrys. An
|
|
144
|
+
* attribution limit is where the trail stops being provable, and a case that
|
|
145
|
+
* reaches one has established an answer rather than failed.
|
|
146
|
+
*
|
|
147
|
+
* Every point carries `observedPath`. False means the connection to this
|
|
148
|
+
* case runs through mixing or privacy infrastructure and is therefore
|
|
149
|
+
* inference — an observation about a counterparty, never an accusation about
|
|
150
|
+
* a person. `inferredPoints()` isolates them.
|
|
151
|
+
*/
|
|
152
|
+
identifyInterventionPoints(investigationId) {
|
|
153
|
+
return this.request(
|
|
154
|
+
"GET",
|
|
155
|
+
`/v1/dri/investigations/${encode(investigationId)}/intervention-points`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* What happened on this case, oldest first, with the acting party on every
|
|
160
|
+
* entry.
|
|
161
|
+
*
|
|
162
|
+
* Merges the recovery record in when the incident behind the investigation
|
|
163
|
+
* has a case. Entries attributed to anyone other than Decentrys are recorded
|
|
164
|
+
* as reported by that party — Decentrys did not perform them and does not
|
|
165
|
+
* verify them.
|
|
166
|
+
*/
|
|
167
|
+
getInvestigationTimeline(investigationId, limit) {
|
|
168
|
+
const query = limit === void 0 ? "" : `?limit=${encodeURIComponent(String(limit))}`;
|
|
169
|
+
return this.request(
|
|
170
|
+
"GET",
|
|
171
|
+
`/v1/dri/investigations/${encode(investigationId)}/timeline${query}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
// --- Recovery cases ------------------------------------------------------
|
|
175
|
+
/**
|
|
176
|
+
* The recovery cases of your organisation.
|
|
177
|
+
*
|
|
178
|
+
* A case is opened as part of an engagement, against a declared incident and
|
|
179
|
+
* a stated loss; the tranche ledger behind it is analyst work, so it is not
|
|
180
|
+
* something an API call brings into existence. This is how you find the
|
|
181
|
+
* `recoveryCaseId` that `getRecoveryIndex` and `generateEvidencePackage`
|
|
182
|
+
* take — and `getFlowGraph` reports it too, for an investigation attached to
|
|
183
|
+
* one.
|
|
184
|
+
*/
|
|
185
|
+
listCases(limit) {
|
|
186
|
+
const query = limit === void 0 ? "" : `?limit=${encodeURIComponent(String(limit))}`;
|
|
187
|
+
return this.request("GET", `/v1/dri/cases${query}`).then((body) => body.cases);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The Recovery Index for a case.
|
|
191
|
+
*
|
|
192
|
+
* An **analytical estimate** of the current evidentiary and custodial
|
|
193
|
+
* position, produced by a versioned model from the evidence recorded in the
|
|
194
|
+
* case. It is not a probability, not a forecast, and not a representation
|
|
195
|
+
* that any value will be recovered. `modelVersion` and `inputsHash` come
|
|
196
|
+
* back with it so any figure shown to a client can be reproduced exactly
|
|
197
|
+
* from the record it was computed from.
|
|
198
|
+
*
|
|
199
|
+
* The disclaimer travels in the payload, and this method **refuses a
|
|
200
|
+
* response that does not carry it** rather than returning a bare number.
|
|
201
|
+
* That check is deliberately duplicated from the server: an integrator's
|
|
202
|
+
* dashboard renders whatever arrives, and "58 — MODERATE" beside the word
|
|
203
|
+
* recovery is read as a rate by everyone who sees it. If the caveat ever
|
|
204
|
+
* stops arriving, failing here is the only outcome that keeps the score
|
|
205
|
+
* honest.
|
|
206
|
+
*/
|
|
207
|
+
getRecoveryIndex(recoveryCaseId) {
|
|
208
|
+
return this.request("GET", `/v1/dri/cases/${encode(recoveryCaseId)}/recovery-index`).then(assertAnalyticalDisclosure);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Prepare an evidence package for a recipient.
|
|
212
|
+
*
|
|
213
|
+
* **Prepared, not sent.** The package goes to you and, at your direction, to
|
|
214
|
+
* your counsel, your insurer or a law-enforcement agency. Decentrys does not
|
|
215
|
+
* contact counterparties on your behalf unless instructed, and recording
|
|
216
|
+
* that a package was transmitted is a separate act that names who sent it.
|
|
217
|
+
*
|
|
218
|
+
* The document is frozen and hashed at generation, because third parties
|
|
219
|
+
* make freezing decisions on the strength of these and one that could be
|
|
220
|
+
* regenerated differently afterwards would be worth nothing. Quote
|
|
221
|
+
* `contentHash` back to a recipient so they can verify what they hold.
|
|
222
|
+
*/
|
|
223
|
+
generateEvidencePackage(input) {
|
|
224
|
+
return this.request(
|
|
225
|
+
"POST",
|
|
226
|
+
`/v1/dri/cases/${encode(input.recoveryCaseId)}/evidence-package`,
|
|
227
|
+
{
|
|
228
|
+
recipientType: input.recipientType,
|
|
229
|
+
recipientName: input.recipientName,
|
|
230
|
+
...input.recoveryLeadId ? { recoveryLeadId: input.recoveryLeadId } : {}
|
|
231
|
+
}
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
// --- transport -----------------------------------------------------------
|
|
235
|
+
async request(method, path, body) {
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
238
|
+
try {
|
|
239
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
240
|
+
method,
|
|
241
|
+
headers: {
|
|
242
|
+
"content-type": "application/json",
|
|
243
|
+
"x-api-key": this.apiKey,
|
|
244
|
+
"user-agent": `decentrys-dri/${SDK_VERSION}`
|
|
245
|
+
},
|
|
246
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
247
|
+
signal: controller.signal
|
|
248
|
+
});
|
|
249
|
+
const text = await response.text();
|
|
250
|
+
if (!response.ok) {
|
|
251
|
+
const parsed2 = safeParse(text);
|
|
252
|
+
throw new DriError(
|
|
253
|
+
response.status,
|
|
254
|
+
typeof parsed2?.message === "string" ? parsed2.message : `Decentrys returned HTTP ${response.status}.`,
|
|
255
|
+
typeof parsed2?.code === "string" ? parsed2.code : void 0
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
if (!text) return void 0;
|
|
259
|
+
const parsed = safeParse(text);
|
|
260
|
+
if (parsed === null) throw new DriError(response.status, "The response was not valid JSON.");
|
|
261
|
+
return "data" in parsed ? parsed.data : parsed;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error instanceof DriError) throw error;
|
|
264
|
+
if (controller.signal.aborted) throw new DriError(0, `No response within ${this.timeoutMs}ms.`);
|
|
265
|
+
throw new DriError(0, error instanceof Error ? error.message : "Request failed.");
|
|
266
|
+
} finally {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
function encode(segment) {
|
|
272
|
+
return encodeURIComponent(segment);
|
|
273
|
+
}
|
|
274
|
+
function safeParse(text) {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(text);
|
|
277
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function resolveFetch() {
|
|
283
|
+
const candidate = globalThis.fetch;
|
|
284
|
+
if (typeof candidate !== "function") {
|
|
285
|
+
throw new Error("DRI: no global fetch was found. Pass one via `new DecentrysDri({ fetch })`.");
|
|
286
|
+
}
|
|
287
|
+
return candidate.bind(globalThis);
|
|
288
|
+
}
|
|
289
|
+
return __toCommonJS(index_exports);
|
|
290
|
+
})();
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// src/model.ts
|
|
2
|
+
var RECOVERY_BANDS = ["HIGH", "MODERATE", "LOW", "VERY_LOW"];
|
|
3
|
+
var MissingDisclosureError = class extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "MissingDisclosureError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
function assertAnalyticalDisclosure(index) {
|
|
10
|
+
if (index.analyticalOnly !== true) {
|
|
11
|
+
throw new MissingDisclosureError(
|
|
12
|
+
"This Recovery Index is not marked analytical-only. The Recovery Index is an analytical estimate and never a predicted recovery rate; refusing to hand back a score that does not say so."
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
if (typeof index.disclaimer !== "string" || index.disclaimer.trim().length === 0) {
|
|
16
|
+
throw new MissingDisclosureError(
|
|
17
|
+
"This Recovery Index arrived without its disclaimer. The disclaimer travels with the score by design, because a score shown without it is read as a promise of recovery."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return index;
|
|
21
|
+
}
|
|
22
|
+
function custodialPoints(analysis) {
|
|
23
|
+
return analysis.interventionPoints.filter((point) => point.kind === "CUSTODIAL");
|
|
24
|
+
}
|
|
25
|
+
function inferredPoints(analysis) {
|
|
26
|
+
return analysis.interventionPoints.filter((point) => !point.observedPath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/client.ts
|
|
30
|
+
var SDK_VERSION = "0.1.0";
|
|
31
|
+
var DEFAULT_BASE_URL = "https://api.decentrys.com";
|
|
32
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
33
|
+
var DriError = class extends Error {
|
|
34
|
+
constructor(status, message, code) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.name = "DriError";
|
|
39
|
+
}
|
|
40
|
+
status;
|
|
41
|
+
code;
|
|
42
|
+
};
|
|
43
|
+
var DecentrysDri = class {
|
|
44
|
+
baseUrl;
|
|
45
|
+
apiKey;
|
|
46
|
+
timeoutMs;
|
|
47
|
+
fetchImpl;
|
|
48
|
+
constructor(config) {
|
|
49
|
+
if (!config.apiKey?.trim()) {
|
|
50
|
+
throw new Error("DRI: an apiKey is required. Create one at https://decentrys.com/developers.");
|
|
51
|
+
}
|
|
52
|
+
if (config.apiKey.startsWith("dk_pub_")) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"DRI: that is a publishable key. An investigation names third parties, and that must never run behind a credential shipped inside a client where anyone who downloads it can read it. Use a secret key, server-side."
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
this.apiKey = config.apiKey;
|
|
58
|
+
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
59
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
60
|
+
this.fetchImpl = config.fetch ?? resolveFetch();
|
|
61
|
+
}
|
|
62
|
+
// --- Investigations ------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* Open an investigation seeded on one address.
|
|
65
|
+
*
|
|
66
|
+
* Nothing is traced yet. The seed node exists so the graph has a root before
|
|
67
|
+
* any chain call is made, which means a case can be opened and worked on
|
|
68
|
+
* even while a provider is unreachable.
|
|
69
|
+
*/
|
|
70
|
+
createInvestigation(input) {
|
|
71
|
+
return this.request("POST", "/v1/dri/investigations", {
|
|
72
|
+
title: input.title,
|
|
73
|
+
chain: input.chain,
|
|
74
|
+
address: input.address,
|
|
75
|
+
...input.incidentId ? { incidentId: input.incidentId } : {},
|
|
76
|
+
...input.maxHops === void 0 ? {} : { maxHops: input.maxHops }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
listInvestigations(limit) {
|
|
80
|
+
const query = limit === void 0 ? "" : `?limit=${encodeURIComponent(String(limit))}`;
|
|
81
|
+
return this.request(
|
|
82
|
+
"GET",
|
|
83
|
+
`/v1/dri/investigations${query}`
|
|
84
|
+
).then((body) => body.investigations);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Follow the value one address further.
|
|
88
|
+
*
|
|
89
|
+
* One address, one hop, per call — and that is a deliberate limit rather
|
|
90
|
+
* than an unfinished feature. The public endpoints a trace reads are rate
|
|
91
|
+
* limited, and an unattended crawl to four hops exhausts them; the result
|
|
92
|
+
* then reads as "the funds vanished" rather than "we ran out of quota",
|
|
93
|
+
* which on this product is the worst possible way to be wrong. Call it in a
|
|
94
|
+
* loop if you want depth, and read `coverage.truncated` on each result.
|
|
95
|
+
*/
|
|
96
|
+
traceFunds(input) {
|
|
97
|
+
return this.request("POST", `/v1/dri/investigations/${encode(input.investigationId)}/trace`, {
|
|
98
|
+
...input.fromNodeId ? { fromNodeId: input.fromNodeId } : {}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** The graph as it stands: every address reached, and how it was reached. */
|
|
102
|
+
getFlowGraph(investigationId) {
|
|
103
|
+
return this.request("GET", `/v1/dri/investigations/${encode(investigationId)}`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Where a third party could act, and where attribution stops.
|
|
107
|
+
*
|
|
108
|
+
* Read both halves. An intervention point is a party who holds value and can
|
|
109
|
+
* be contacted, served or subpoenaed — whether it does anything is a matter
|
|
110
|
+
* for it, its regulators and any legal process, never for Decentrys. An
|
|
111
|
+
* attribution limit is where the trail stops being provable, and a case that
|
|
112
|
+
* reaches one has established an answer rather than failed.
|
|
113
|
+
*
|
|
114
|
+
* Every point carries `observedPath`. False means the connection to this
|
|
115
|
+
* case runs through mixing or privacy infrastructure and is therefore
|
|
116
|
+
* inference — an observation about a counterparty, never an accusation about
|
|
117
|
+
* a person. `inferredPoints()` isolates them.
|
|
118
|
+
*/
|
|
119
|
+
identifyInterventionPoints(investigationId) {
|
|
120
|
+
return this.request(
|
|
121
|
+
"GET",
|
|
122
|
+
`/v1/dri/investigations/${encode(investigationId)}/intervention-points`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* What happened on this case, oldest first, with the acting party on every
|
|
127
|
+
* entry.
|
|
128
|
+
*
|
|
129
|
+
* Merges the recovery record in when the incident behind the investigation
|
|
130
|
+
* has a case. Entries attributed to anyone other than Decentrys are recorded
|
|
131
|
+
* as reported by that party — Decentrys did not perform them and does not
|
|
132
|
+
* verify them.
|
|
133
|
+
*/
|
|
134
|
+
getInvestigationTimeline(investigationId, limit) {
|
|
135
|
+
const query = limit === void 0 ? "" : `?limit=${encodeURIComponent(String(limit))}`;
|
|
136
|
+
return this.request(
|
|
137
|
+
"GET",
|
|
138
|
+
`/v1/dri/investigations/${encode(investigationId)}/timeline${query}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
// --- Recovery cases ------------------------------------------------------
|
|
142
|
+
/**
|
|
143
|
+
* The recovery cases of your organisation.
|
|
144
|
+
*
|
|
145
|
+
* A case is opened as part of an engagement, against a declared incident and
|
|
146
|
+
* a stated loss; the tranche ledger behind it is analyst work, so it is not
|
|
147
|
+
* something an API call brings into existence. This is how you find the
|
|
148
|
+
* `recoveryCaseId` that `getRecoveryIndex` and `generateEvidencePackage`
|
|
149
|
+
* take — and `getFlowGraph` reports it too, for an investigation attached to
|
|
150
|
+
* one.
|
|
151
|
+
*/
|
|
152
|
+
listCases(limit) {
|
|
153
|
+
const query = limit === void 0 ? "" : `?limit=${encodeURIComponent(String(limit))}`;
|
|
154
|
+
return this.request("GET", `/v1/dri/cases${query}`).then((body) => body.cases);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The Recovery Index for a case.
|
|
158
|
+
*
|
|
159
|
+
* An **analytical estimate** of the current evidentiary and custodial
|
|
160
|
+
* position, produced by a versioned model from the evidence recorded in the
|
|
161
|
+
* case. It is not a probability, not a forecast, and not a representation
|
|
162
|
+
* that any value will be recovered. `modelVersion` and `inputsHash` come
|
|
163
|
+
* back with it so any figure shown to a client can be reproduced exactly
|
|
164
|
+
* from the record it was computed from.
|
|
165
|
+
*
|
|
166
|
+
* The disclaimer travels in the payload, and this method **refuses a
|
|
167
|
+
* response that does not carry it** rather than returning a bare number.
|
|
168
|
+
* That check is deliberately duplicated from the server: an integrator's
|
|
169
|
+
* dashboard renders whatever arrives, and "58 — MODERATE" beside the word
|
|
170
|
+
* recovery is read as a rate by everyone who sees it. If the caveat ever
|
|
171
|
+
* stops arriving, failing here is the only outcome that keeps the score
|
|
172
|
+
* honest.
|
|
173
|
+
*/
|
|
174
|
+
getRecoveryIndex(recoveryCaseId) {
|
|
175
|
+
return this.request("GET", `/v1/dri/cases/${encode(recoveryCaseId)}/recovery-index`).then(assertAnalyticalDisclosure);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Prepare an evidence package for a recipient.
|
|
179
|
+
*
|
|
180
|
+
* **Prepared, not sent.** The package goes to you and, at your direction, to
|
|
181
|
+
* your counsel, your insurer or a law-enforcement agency. Decentrys does not
|
|
182
|
+
* contact counterparties on your behalf unless instructed, and recording
|
|
183
|
+
* that a package was transmitted is a separate act that names who sent it.
|
|
184
|
+
*
|
|
185
|
+
* The document is frozen and hashed at generation, because third parties
|
|
186
|
+
* make freezing decisions on the strength of these and one that could be
|
|
187
|
+
* regenerated differently afterwards would be worth nothing. Quote
|
|
188
|
+
* `contentHash` back to a recipient so they can verify what they hold.
|
|
189
|
+
*/
|
|
190
|
+
generateEvidencePackage(input) {
|
|
191
|
+
return this.request(
|
|
192
|
+
"POST",
|
|
193
|
+
`/v1/dri/cases/${encode(input.recoveryCaseId)}/evidence-package`,
|
|
194
|
+
{
|
|
195
|
+
recipientType: input.recipientType,
|
|
196
|
+
recipientName: input.recipientName,
|
|
197
|
+
...input.recoveryLeadId ? { recoveryLeadId: input.recoveryLeadId } : {}
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
// --- transport -----------------------------------------------------------
|
|
202
|
+
async request(method, path, body) {
|
|
203
|
+
const controller = new AbortController();
|
|
204
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
205
|
+
try {
|
|
206
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
207
|
+
method,
|
|
208
|
+
headers: {
|
|
209
|
+
"content-type": "application/json",
|
|
210
|
+
"x-api-key": this.apiKey,
|
|
211
|
+
"user-agent": `decentrys-dri/${SDK_VERSION}`
|
|
212
|
+
},
|
|
213
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
214
|
+
signal: controller.signal
|
|
215
|
+
});
|
|
216
|
+
const text = await response.text();
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
const parsed2 = safeParse(text);
|
|
219
|
+
throw new DriError(
|
|
220
|
+
response.status,
|
|
221
|
+
typeof parsed2?.message === "string" ? parsed2.message : `Decentrys returned HTTP ${response.status}.`,
|
|
222
|
+
typeof parsed2?.code === "string" ? parsed2.code : void 0
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (!text) return void 0;
|
|
226
|
+
const parsed = safeParse(text);
|
|
227
|
+
if (parsed === null) throw new DriError(response.status, "The response was not valid JSON.");
|
|
228
|
+
return "data" in parsed ? parsed.data : parsed;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof DriError) throw error;
|
|
231
|
+
if (controller.signal.aborted) throw new DriError(0, `No response within ${this.timeoutMs}ms.`);
|
|
232
|
+
throw new DriError(0, error instanceof Error ? error.message : "Request failed.");
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
function encode(segment) {
|
|
239
|
+
return encodeURIComponent(segment);
|
|
240
|
+
}
|
|
241
|
+
function safeParse(text) {
|
|
242
|
+
try {
|
|
243
|
+
const parsed = JSON.parse(text);
|
|
244
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function resolveFetch() {
|
|
250
|
+
const candidate = globalThis.fetch;
|
|
251
|
+
if (typeof candidate !== "function") {
|
|
252
|
+
throw new Error("DRI: no global fetch was found. Pass one via `new DecentrysDri({ fetch })`.");
|
|
253
|
+
}
|
|
254
|
+
return candidate.bind(globalThis);
|
|
255
|
+
}
|
|
256
|
+
export {
|
|
257
|
+
DecentrysDri,
|
|
258
|
+
DriError,
|
|
259
|
+
MissingDisclosureError,
|
|
260
|
+
RECOVERY_BANDS,
|
|
261
|
+
SDK_VERSION,
|
|
262
|
+
assertAnalyticalDisclosure,
|
|
263
|
+
custodialPoints,
|
|
264
|
+
inferredPoints
|
|
265
|
+
};
|