kontext-sdk 0.2.0 → 0.2.5
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/README.md +1 -1
- package/dist/index.d.mts +1267 -24
- package/dist/index.d.ts +1267 -24
- package/dist/index.js +2834 -326
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2821 -325
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -4
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var crypto$1 = require('crypto');
|
|
4
|
-
var
|
|
5
|
-
var
|
|
4
|
+
var fs3 = require('fs');
|
|
5
|
+
var path3 = require('path');
|
|
6
6
|
|
|
7
7
|
function _interopNamespace(e) {
|
|
8
8
|
if (e && e.__esModule) return e;
|
|
@@ -22,8 +22,8 @@ function _interopNamespace(e) {
|
|
|
22
22
|
return Object.freeze(n);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
var
|
|
26
|
-
var
|
|
25
|
+
var fs3__namespace = /*#__PURE__*/_interopNamespace(fs3);
|
|
26
|
+
var path3__namespace = /*#__PURE__*/_interopNamespace(path3);
|
|
27
27
|
|
|
28
28
|
// src/types.ts
|
|
29
29
|
var KontextErrorCode = /* @__PURE__ */ ((KontextErrorCode2) => {
|
|
@@ -51,6 +51,8 @@ var KontextError = class extends Error {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
// src/store.ts
|
|
54
|
+
var DEFAULT_MAX_ENTRIES = 1e4;
|
|
55
|
+
var EVICTION_RATIO = 0.1;
|
|
54
56
|
var STORAGE_KEYS = {
|
|
55
57
|
actions: "kontext:actions",
|
|
56
58
|
transactions: "kontext:transactions",
|
|
@@ -63,6 +65,10 @@ var KontextStore = class {
|
|
|
63
65
|
tasks = /* @__PURE__ */ new Map();
|
|
64
66
|
anomalies = [];
|
|
65
67
|
storageAdapter = null;
|
|
68
|
+
maxEntries;
|
|
69
|
+
constructor(maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
70
|
+
this.maxEntries = maxEntries;
|
|
71
|
+
}
|
|
66
72
|
/**
|
|
67
73
|
* Attach a storage adapter for persistence.
|
|
68
74
|
*
|
|
@@ -86,14 +92,15 @@ var KontextStore = class {
|
|
|
86
92
|
*/
|
|
87
93
|
async flush() {
|
|
88
94
|
if (!this.storageAdapter) return;
|
|
95
|
+
const actionsSnapshot = [...this.actions];
|
|
96
|
+
const transactionsSnapshot = [...this.transactions];
|
|
97
|
+
const tasksSnapshot = Array.from(this.tasks.entries());
|
|
98
|
+
const anomaliesSnapshot = [...this.anomalies];
|
|
89
99
|
await Promise.all([
|
|
90
|
-
this.storageAdapter.save(STORAGE_KEYS.actions,
|
|
91
|
-
this.storageAdapter.save(STORAGE_KEYS.transactions,
|
|
92
|
-
this.storageAdapter.save(
|
|
93
|
-
|
|
94
|
-
Array.from(this.tasks.entries())
|
|
95
|
-
),
|
|
96
|
-
this.storageAdapter.save(STORAGE_KEYS.anomalies, this.anomalies)
|
|
100
|
+
this.storageAdapter.save(STORAGE_KEYS.actions, actionsSnapshot),
|
|
101
|
+
this.storageAdapter.save(STORAGE_KEYS.transactions, transactionsSnapshot),
|
|
102
|
+
this.storageAdapter.save(STORAGE_KEYS.tasks, tasksSnapshot),
|
|
103
|
+
this.storageAdapter.save(STORAGE_KEYS.anomalies, anomaliesSnapshot)
|
|
97
104
|
]);
|
|
98
105
|
}
|
|
99
106
|
/**
|
|
@@ -125,9 +132,12 @@ var KontextStore = class {
|
|
|
125
132
|
// --------------------------------------------------------------------------
|
|
126
133
|
// Actions
|
|
127
134
|
// --------------------------------------------------------------------------
|
|
128
|
-
/** Append an action log entry. */
|
|
135
|
+
/** Append an action log entry. Evicts oldest 10% when maxEntries is exceeded. */
|
|
129
136
|
addAction(action) {
|
|
130
137
|
this.actions.push(action);
|
|
138
|
+
if (this.actions.length > this.maxEntries) {
|
|
139
|
+
this.actions.splice(0, Math.ceil(this.maxEntries * EVICTION_RATIO));
|
|
140
|
+
}
|
|
131
141
|
}
|
|
132
142
|
/** Retrieve all action log entries. */
|
|
133
143
|
getActions() {
|
|
@@ -144,9 +154,12 @@ var KontextStore = class {
|
|
|
144
154
|
// --------------------------------------------------------------------------
|
|
145
155
|
// Transactions
|
|
146
156
|
// --------------------------------------------------------------------------
|
|
147
|
-
/** Append a transaction record. */
|
|
157
|
+
/** Append a transaction record. Evicts oldest 10% when maxEntries is exceeded. */
|
|
148
158
|
addTransaction(tx) {
|
|
149
159
|
this.transactions.push(tx);
|
|
160
|
+
if (this.transactions.length > this.maxEntries) {
|
|
161
|
+
this.transactions.splice(0, Math.ceil(this.maxEntries * EVICTION_RATIO));
|
|
162
|
+
}
|
|
150
163
|
}
|
|
151
164
|
/** Retrieve all transaction records. */
|
|
152
165
|
getTransactions() {
|
|
@@ -194,9 +207,12 @@ var KontextStore = class {
|
|
|
194
207
|
// --------------------------------------------------------------------------
|
|
195
208
|
// Anomalies
|
|
196
209
|
// --------------------------------------------------------------------------
|
|
197
|
-
/** Append an anomaly event. */
|
|
210
|
+
/** Append an anomaly event. Evicts oldest 10% when maxEntries is exceeded. */
|
|
198
211
|
addAnomaly(anomaly) {
|
|
199
212
|
this.anomalies.push(anomaly);
|
|
213
|
+
if (this.anomalies.length > this.maxEntries) {
|
|
214
|
+
this.anomalies.splice(0, Math.ceil(this.maxEntries * EVICTION_RATIO));
|
|
215
|
+
}
|
|
200
216
|
}
|
|
201
217
|
/** Retrieve all anomaly events. */
|
|
202
218
|
getAnomalies() {
|
|
@@ -397,7 +413,8 @@ var DigestChain = class {
|
|
|
397
413
|
*/
|
|
398
414
|
getPrecisionTimestamp() {
|
|
399
415
|
const hrtime = process.hrtime.bigint();
|
|
400
|
-
const
|
|
416
|
+
const delta = hrtime >= this.hrtimeBase ? hrtime - this.hrtimeBase : 0n;
|
|
417
|
+
const microseconds = Number(delta % 1000000n);
|
|
401
418
|
return {
|
|
402
419
|
iso: (/* @__PURE__ */ new Date()).toISOString(),
|
|
403
420
|
hrtime,
|
|
@@ -492,6 +509,12 @@ function toCsv(records) {
|
|
|
492
509
|
function clamp(value, min, max) {
|
|
493
510
|
return Math.min(Math.max(value, min), max);
|
|
494
511
|
}
|
|
512
|
+
var LOG_LEVEL_SEVERITY = {
|
|
513
|
+
debug: 0,
|
|
514
|
+
info: 1,
|
|
515
|
+
warn: 2,
|
|
516
|
+
error: 3
|
|
517
|
+
};
|
|
495
518
|
var ActionLogger = class {
|
|
496
519
|
config;
|
|
497
520
|
store;
|
|
@@ -501,6 +524,7 @@ var ActionLogger = class {
|
|
|
501
524
|
batchSize;
|
|
502
525
|
flushIntervalMs;
|
|
503
526
|
isCloudMode;
|
|
527
|
+
logLevel;
|
|
504
528
|
constructor(config, store) {
|
|
505
529
|
this.config = config;
|
|
506
530
|
this.store = store;
|
|
@@ -508,6 +532,7 @@ var ActionLogger = class {
|
|
|
508
532
|
this.batchSize = config.batchSize ?? 50;
|
|
509
533
|
this.flushIntervalMs = config.flushIntervalMs ?? 5e3;
|
|
510
534
|
this.isCloudMode = !!config.apiKey;
|
|
535
|
+
this.logLevel = config.logLevel ?? (config.debug ? "debug" : "warn");
|
|
511
536
|
this.flushTimer = setInterval(() => {
|
|
512
537
|
void this.flush();
|
|
513
538
|
}, this.flushIntervalMs);
|
|
@@ -550,9 +575,7 @@ var ActionLogger = class {
|
|
|
550
575
|
if (this.batch.length >= this.batchSize) {
|
|
551
576
|
await this.flush();
|
|
552
577
|
}
|
|
553
|
-
|
|
554
|
-
this.debugLog("Action logged", action);
|
|
555
|
-
}
|
|
578
|
+
this.emitLog("debug", "Action logged", action);
|
|
556
579
|
return action;
|
|
557
580
|
}
|
|
558
581
|
/**
|
|
@@ -604,9 +627,7 @@ var ActionLogger = class {
|
|
|
604
627
|
if (this.batch.length >= this.batchSize) {
|
|
605
628
|
await this.flush();
|
|
606
629
|
}
|
|
607
|
-
|
|
608
|
-
this.debugLog("Transaction logged", record);
|
|
609
|
-
}
|
|
630
|
+
this.emitLog("debug", "Transaction logged", record);
|
|
610
631
|
return record;
|
|
611
632
|
}
|
|
612
633
|
/**
|
|
@@ -708,21 +729,19 @@ var ActionLogger = class {
|
|
|
708
729
|
}
|
|
709
730
|
flushToFile(actions) {
|
|
710
731
|
const outputDir = this.config.localOutputDir ?? ".kontext";
|
|
711
|
-
const logDir =
|
|
732
|
+
const logDir = path3__namespace.join(outputDir, "logs");
|
|
712
733
|
try {
|
|
713
|
-
|
|
734
|
+
fs3__namespace.mkdirSync(logDir, { recursive: true });
|
|
714
735
|
const filename = `actions-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.jsonl`;
|
|
715
|
-
const filePath =
|
|
736
|
+
const filePath = path3__namespace.join(logDir, filename);
|
|
716
737
|
const lines = actions.map((a) => JSON.stringify(a)).join("\n") + "\n";
|
|
717
|
-
|
|
738
|
+
fs3__namespace.appendFileSync(filePath, lines, "utf-8");
|
|
718
739
|
} catch (error) {
|
|
719
|
-
|
|
720
|
-
this.debugLog("Failed to write log file", { error });
|
|
721
|
-
}
|
|
740
|
+
this.emitLog("warn", "Failed to write log file", { error });
|
|
722
741
|
}
|
|
723
742
|
}
|
|
724
743
|
async flushToApi(actions) {
|
|
725
|
-
const apiUrl = this.config.apiUrl ?? "https://
|
|
744
|
+
const apiUrl = this.config.apiUrl ?? process.env["KONTEXT_API_URL"] ?? "https://api.kontext.so";
|
|
726
745
|
try {
|
|
727
746
|
const response = await fetch(`${apiUrl}/v1/actions`, {
|
|
728
747
|
method: "POST",
|
|
@@ -742,15 +761,35 @@ var ActionLogger = class {
|
|
|
742
761
|
}
|
|
743
762
|
} catch (error) {
|
|
744
763
|
if (error instanceof KontextError) throw error;
|
|
745
|
-
|
|
746
|
-
this.debugLog("API flush failed, falling back to local file", { error });
|
|
747
|
-
}
|
|
764
|
+
this.emitLog("warn", "API flush failed, falling back to local file", { error });
|
|
748
765
|
this.flushToFile(actions);
|
|
749
766
|
}
|
|
750
767
|
}
|
|
751
|
-
|
|
768
|
+
/**
|
|
769
|
+
* Emit a log message at the specified severity level.
|
|
770
|
+
* Only outputs if the message level meets or exceeds the configured logLevel.
|
|
771
|
+
*/
|
|
772
|
+
emitLog(level, message, data) {
|
|
773
|
+
if (LOG_LEVEL_SEVERITY[level] < LOG_LEVEL_SEVERITY[this.logLevel]) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
752
776
|
const timestamp = now();
|
|
753
|
-
|
|
777
|
+
const formatted = `[Kontext ${timestamp}] ${message}`;
|
|
778
|
+
const payload = data ? JSON.stringify(data, null, 2) : "";
|
|
779
|
+
switch (level) {
|
|
780
|
+
case "debug":
|
|
781
|
+
console.debug(formatted, payload);
|
|
782
|
+
break;
|
|
783
|
+
case "info":
|
|
784
|
+
console.info(formatted, payload);
|
|
785
|
+
break;
|
|
786
|
+
case "warn":
|
|
787
|
+
console.warn(formatted, payload);
|
|
788
|
+
break;
|
|
789
|
+
case "error":
|
|
790
|
+
console.error(formatted, payload);
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
754
793
|
}
|
|
755
794
|
};
|
|
756
795
|
|
|
@@ -1478,6 +1517,18 @@ var AuditExporter = class {
|
|
|
1478
1517
|
};
|
|
1479
1518
|
|
|
1480
1519
|
// src/trust.ts
|
|
1520
|
+
var TRUST_LEVEL_VERIFIED = 90;
|
|
1521
|
+
var TRUST_LEVEL_HIGH = 70;
|
|
1522
|
+
var TRUST_LEVEL_MEDIUM = 50;
|
|
1523
|
+
var TRUST_LEVEL_LOW = 30;
|
|
1524
|
+
var RISK_FLAG_THRESHOLD = 60;
|
|
1525
|
+
var RISK_BLOCK_THRESHOLD = 80;
|
|
1526
|
+
var RISK_REVIEW_THRESHOLD = 50;
|
|
1527
|
+
var WEIGHT_HISTORY = 0.15;
|
|
1528
|
+
var WEIGHT_TASK_COMPLETION = 0.25;
|
|
1529
|
+
var WEIGHT_ANOMALY = 0.25;
|
|
1530
|
+
var WEIGHT_TX_CONSISTENCY = 0.2;
|
|
1531
|
+
var WEIGHT_COMPLIANCE = 0.15;
|
|
1481
1532
|
var TrustScorer = class {
|
|
1482
1533
|
config;
|
|
1483
1534
|
store;
|
|
@@ -1536,8 +1587,8 @@ var TrustScorer = class {
|
|
|
1536
1587
|
const totalScore = factors.reduce((sum, f) => sum + f.score, 0);
|
|
1537
1588
|
const riskScore = clamp(Math.round(totalScore / Math.max(factors.length, 1)), 0, 100);
|
|
1538
1589
|
const riskLevel = this.riskScoreToLevel(riskScore);
|
|
1539
|
-
const flagged = riskScore >=
|
|
1540
|
-
const recommendation = riskScore >=
|
|
1590
|
+
const flagged = riskScore >= RISK_FLAG_THRESHOLD;
|
|
1591
|
+
const recommendation = riskScore >= RISK_BLOCK_THRESHOLD ? "block" : riskScore >= RISK_REVIEW_THRESHOLD ? "review" : "approve";
|
|
1541
1592
|
return {
|
|
1542
1593
|
txHash: tx.txHash,
|
|
1543
1594
|
riskScore,
|
|
@@ -1552,17 +1603,34 @@ var TrustScorer = class {
|
|
|
1552
1603
|
// Agent trust factor computation
|
|
1553
1604
|
// --------------------------------------------------------------------------
|
|
1554
1605
|
computeAgentFactors(agentId) {
|
|
1555
|
-
const
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
return
|
|
1606
|
+
const data = {
|
|
1607
|
+
actions: this.store.getActionsByAgent(agentId),
|
|
1608
|
+
transactions: this.store.getTransactionsByAgent(agentId),
|
|
1609
|
+
tasks: this.store.queryTasks((t) => t.agentId === agentId),
|
|
1610
|
+
anomalies: this.store.queryAnomalies((a) => a.agentId === agentId)
|
|
1611
|
+
};
|
|
1612
|
+
return [
|
|
1613
|
+
this.computeHistoryDepthFactor(data),
|
|
1614
|
+
this.computeTaskCompletionFactor(data),
|
|
1615
|
+
this.computeAnomalyFrequencyFactor(data),
|
|
1616
|
+
this.computeTransactionConsistencyFactor(data),
|
|
1617
|
+
this.computeComplianceAdherenceFactor(data)
|
|
1618
|
+
];
|
|
1562
1619
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1620
|
+
/**
|
|
1621
|
+
* History depth factor: more recorded actions = more data to assess trust.
|
|
1622
|
+
*
|
|
1623
|
+
* Scoring curve (step function, not linear) prevents gaming by spamming
|
|
1624
|
+
* low-value actions — crossing each tier requires meaningfully more history:
|
|
1625
|
+
* - 0 actions → 10 (minimal trust, new agent)
|
|
1626
|
+
* - 1-4 → 30 (some activity but too little to draw conclusions)
|
|
1627
|
+
* - 5-19 → 50 (neutral, moderate activity)
|
|
1628
|
+
* - 20-49 → 70 (established agent with reasonable track record)
|
|
1629
|
+
* - 50-99 → 85 (well-established agent)
|
|
1630
|
+
* - 100+ → 95 (capped below 100 because history alone doesn't guarantee trust)
|
|
1631
|
+
*/
|
|
1632
|
+
computeHistoryDepthFactor(data) {
|
|
1633
|
+
const count = data.actions.length;
|
|
1566
1634
|
let score;
|
|
1567
1635
|
if (count === 0) score = 10;
|
|
1568
1636
|
else if (count < 5) score = 30;
|
|
@@ -1573,44 +1641,65 @@ var TrustScorer = class {
|
|
|
1573
1641
|
return {
|
|
1574
1642
|
name: "history_depth",
|
|
1575
1643
|
score,
|
|
1576
|
-
weight:
|
|
1644
|
+
weight: WEIGHT_HISTORY,
|
|
1577
1645
|
description: `Agent has ${count} recorded actions`
|
|
1578
1646
|
};
|
|
1579
1647
|
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1648
|
+
/**
|
|
1649
|
+
* Task completion factor: agents that confirm tasks build trust, failures erode it.
|
|
1650
|
+
*
|
|
1651
|
+
* Formula: score = (completionRate * 100) - (failureRate * 30)
|
|
1652
|
+
* - The 30x failure penalty means each failure costs 3x more than a confirmation gains.
|
|
1653
|
+
* This asymmetry reflects real-world trust: it takes many good actions to build trust
|
|
1654
|
+
* but only a few failures to lose it.
|
|
1655
|
+
* - Returns 50 (neutral) when no tasks exist, avoiding penalizing new agents.
|
|
1656
|
+
*/
|
|
1657
|
+
computeTaskCompletionFactor(data) {
|
|
1658
|
+
const totalTasks = data.tasks.length;
|
|
1583
1659
|
if (totalTasks === 0) {
|
|
1584
1660
|
return {
|
|
1585
1661
|
name: "task_completion",
|
|
1586
1662
|
score: 50,
|
|
1587
1663
|
// Neutral if no tasks
|
|
1588
|
-
weight:
|
|
1664
|
+
weight: WEIGHT_TASK_COMPLETION,
|
|
1589
1665
|
description: "No tasks recorded yet"
|
|
1590
1666
|
};
|
|
1591
1667
|
}
|
|
1592
|
-
const confirmed = tasks.filter((t) => t.status === "confirmed").length;
|
|
1593
|
-
const failed = tasks.filter((t) => t.status === "failed").length;
|
|
1668
|
+
const confirmed = data.tasks.filter((t) => t.status === "confirmed").length;
|
|
1669
|
+
const failed = data.tasks.filter((t) => t.status === "failed").length;
|
|
1594
1670
|
const completionRate = confirmed / totalTasks;
|
|
1595
1671
|
const failureRate = failed / totalTasks;
|
|
1596
1672
|
const score = Math.round(completionRate * 100 - failureRate * 30);
|
|
1597
1673
|
return {
|
|
1598
1674
|
name: "task_completion",
|
|
1599
1675
|
score: clamp(score, 0, 100),
|
|
1600
|
-
weight:
|
|
1676
|
+
weight: WEIGHT_TASK_COMPLETION,
|
|
1601
1677
|
description: `${confirmed}/${totalTasks} tasks confirmed (${Math.round(completionRate * 100)}% rate)`
|
|
1602
1678
|
};
|
|
1603
1679
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1680
|
+
/**
|
|
1681
|
+
* Anomaly frequency factor: fewer anomalies relative to total actions = higher trust.
|
|
1682
|
+
*
|
|
1683
|
+
* Uses anomaly rate (anomalies / actions) with step-function scoring:
|
|
1684
|
+
* - 0% → 100 (clean record)
|
|
1685
|
+
* - <1% → 90 (near-perfect, occasional false positive acceptable)
|
|
1686
|
+
* - <5% → 70 (some noise but generally clean)
|
|
1687
|
+
* - <10% → 50 (neutral, warrants monitoring)
|
|
1688
|
+
* - <25% → 30 (concerning pattern)
|
|
1689
|
+
* - 25%+ → 10 (severe anomaly rate)
|
|
1690
|
+
*
|
|
1691
|
+
* Critical anomalies incur a -15 point penalty each, high anomalies -8 each.
|
|
1692
|
+
* This severity weighting ensures a single critical anomaly (e.g., sanctions hit)
|
|
1693
|
+
* has outsized impact compared to multiple low-severity anomalies.
|
|
1694
|
+
*/
|
|
1695
|
+
computeAnomalyFrequencyFactor(data) {
|
|
1696
|
+
const anomalyCount = data.anomalies.length;
|
|
1697
|
+
const actionCount = data.actions.length;
|
|
1609
1698
|
if (actionCount === 0) {
|
|
1610
1699
|
return {
|
|
1611
1700
|
name: "anomaly_frequency",
|
|
1612
1701
|
score: 50,
|
|
1613
|
-
weight:
|
|
1702
|
+
weight: WEIGHT_ANOMALY,
|
|
1614
1703
|
description: "No actions recorded yet"
|
|
1615
1704
|
};
|
|
1616
1705
|
}
|
|
@@ -1622,23 +1711,37 @@ var TrustScorer = class {
|
|
|
1622
1711
|
else if (anomalyRate < 0.1) score = 50;
|
|
1623
1712
|
else if (anomalyRate < 0.25) score = 30;
|
|
1624
1713
|
else score = 10;
|
|
1625
|
-
const criticalCount = anomalies.filter((a) => a.severity === "critical").length;
|
|
1626
|
-
const highCount = anomalies.filter((a) => a.severity === "high").length;
|
|
1714
|
+
const criticalCount = data.anomalies.filter((a) => a.severity === "critical").length;
|
|
1715
|
+
const highCount = data.anomalies.filter((a) => a.severity === "high").length;
|
|
1627
1716
|
const penaltyFromSeverity = criticalCount * 15 + highCount * 8;
|
|
1628
1717
|
return {
|
|
1629
1718
|
name: "anomaly_frequency",
|
|
1630
1719
|
score: clamp(score - penaltyFromSeverity, 0, 100),
|
|
1631
|
-
weight:
|
|
1720
|
+
weight: WEIGHT_ANOMALY,
|
|
1632
1721
|
description: `${anomalyCount} anomalies across ${actionCount} actions (${Math.round(anomalyRate * 100)}% rate)`
|
|
1633
1722
|
};
|
|
1634
1723
|
}
|
|
1635
|
-
|
|
1636
|
-
|
|
1724
|
+
/**
|
|
1725
|
+
* Transaction consistency factor: stable spending patterns indicate legitimate usage.
|
|
1726
|
+
*
|
|
1727
|
+
* Uses the coefficient of variation (CV = stdDev / mean) to measure amount regularity:
|
|
1728
|
+
* - CV < 0.1 → 95 (extremely consistent, e.g., recurring payroll)
|
|
1729
|
+
* - CV < 0.3 → 80 (fairly consistent with some variance)
|
|
1730
|
+
* - CV < 0.5 → 65 (moderate variance, common for operational spending)
|
|
1731
|
+
* - CV < 1.0 → 45 (high variance, warrants attention)
|
|
1732
|
+
* - CV < 2.0 → 30 (very erratic, potential structuring)
|
|
1733
|
+
* - CV 2.0+ → 15 (extreme variance, strong structuring indicator)
|
|
1734
|
+
*
|
|
1735
|
+
* Also penalizes -15 points when >80% of destinations are unique with >5 txs,
|
|
1736
|
+
* which is a common money-laundering pattern (spray-and-pray distribution).
|
|
1737
|
+
*/
|
|
1738
|
+
computeTransactionConsistencyFactor(data) {
|
|
1739
|
+
const transactions = data.transactions;
|
|
1637
1740
|
if (transactions.length < 2) {
|
|
1638
1741
|
return {
|
|
1639
1742
|
name: "transaction_consistency",
|
|
1640
1743
|
score: 50,
|
|
1641
|
-
weight:
|
|
1744
|
+
weight: WEIGHT_TX_CONSISTENCY,
|
|
1642
1745
|
description: "Insufficient transaction history for consistency analysis"
|
|
1643
1746
|
};
|
|
1644
1747
|
}
|
|
@@ -1647,7 +1750,7 @@ var TrustScorer = class {
|
|
|
1647
1750
|
return {
|
|
1648
1751
|
name: "transaction_consistency",
|
|
1649
1752
|
score: 50,
|
|
1650
|
-
weight:
|
|
1753
|
+
weight: WEIGHT_TX_CONSISTENCY,
|
|
1651
1754
|
description: "Insufficient valid amounts for consistency analysis"
|
|
1652
1755
|
};
|
|
1653
1756
|
}
|
|
@@ -1670,13 +1773,25 @@ var TrustScorer = class {
|
|
|
1670
1773
|
return {
|
|
1671
1774
|
name: "transaction_consistency",
|
|
1672
1775
|
score: clamp(score, 0, 100),
|
|
1673
|
-
weight:
|
|
1776
|
+
weight: WEIGHT_TX_CONSISTENCY,
|
|
1674
1777
|
description: `CV=${cv.toFixed(2)}, ${destinations.size} unique destinations across ${transactions.length} transactions`
|
|
1675
1778
|
};
|
|
1676
1779
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1780
|
+
/**
|
|
1781
|
+
* Compliance adherence factor: agents that follow the task-confirm-evidence workflow
|
|
1782
|
+
* demonstrate higher operational integrity.
|
|
1783
|
+
*
|
|
1784
|
+
* Scoring:
|
|
1785
|
+
* - Base score: 50 (neutral)
|
|
1786
|
+
* - +30 max for evidence rate (confirmedTasksWithEvidence / confirmedTasks)
|
|
1787
|
+
* - +20 max for coverage rate (tasks / transactions, capped at 1.0)
|
|
1788
|
+
*
|
|
1789
|
+
* The rationale: tasks with evidence prove the agent completed auditable work.
|
|
1790
|
+
* Coverage rate measures what fraction of financial activity has corresponding
|
|
1791
|
+
* task tracking — higher coverage means better compliance discipline.
|
|
1792
|
+
*/
|
|
1793
|
+
computeComplianceAdherenceFactor(data) {
|
|
1794
|
+
const { tasks, transactions } = data;
|
|
1680
1795
|
const confirmedTasks = tasks.filter((t) => t.status === "confirmed");
|
|
1681
1796
|
const tasksWithEvidence = confirmedTasks.filter(
|
|
1682
1797
|
(t) => t.providedEvidence !== null && Object.keys(t.providedEvidence).length > 0
|
|
@@ -1693,7 +1808,7 @@ var TrustScorer = class {
|
|
|
1693
1808
|
return {
|
|
1694
1809
|
name: "compliance_adherence",
|
|
1695
1810
|
score: clamp(score, 0, 100),
|
|
1696
|
-
weight:
|
|
1811
|
+
weight: WEIGHT_COMPLIANCE,
|
|
1697
1812
|
description: `${tasksWithEvidence.length} tasks with evidence, ${transactions.length} total transactions`
|
|
1698
1813
|
};
|
|
1699
1814
|
}
|
|
@@ -1709,6 +1824,20 @@ var TrustScorer = class {
|
|
|
1709
1824
|
factors.push(this.computeRoundAmountRisk(tx));
|
|
1710
1825
|
return factors;
|
|
1711
1826
|
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Amount risk: higher transaction amounts carry inherently higher risk.
|
|
1829
|
+
*
|
|
1830
|
+
* Tiers are aligned with common fintech thresholds:
|
|
1831
|
+
* - <$100: near-zero risk (5), micro-transactions
|
|
1832
|
+
* - <$1K: low risk (15), typical consumer spending
|
|
1833
|
+
* - <$10K: moderate (30), approaches CTR reporting thresholds
|
|
1834
|
+
* - <$50K: elevated (55), large business transactions
|
|
1835
|
+
* - <$100K: high (75), requires enhanced due diligence
|
|
1836
|
+
* - $100K+: very high (95), institutional-scale transfers
|
|
1837
|
+
*
|
|
1838
|
+
* Additional +20 penalty when amount exceeds 5x the agent's historical average,
|
|
1839
|
+
* detecting sudden spending spikes that could indicate account compromise.
|
|
1840
|
+
*/
|
|
1712
1841
|
computeAmountRisk(tx) {
|
|
1713
1842
|
const amount = parseAmount(tx.amount);
|
|
1714
1843
|
if (isNaN(amount)) {
|
|
@@ -1787,6 +1916,21 @@ var TrustScorer = class {
|
|
|
1787
1916
|
description: `Agent anomaly rate: ${Math.round(anomalyRate * 100)}%`
|
|
1788
1917
|
};
|
|
1789
1918
|
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Round amount risk: round numbers are a structuring indicator in AML heuristics.
|
|
1921
|
+
*
|
|
1922
|
+
* Money launderers often transact in round amounts (e.g., $10,000 exactly) or
|
|
1923
|
+
* just under regulatory thresholds (e.g., $9,500 to avoid $10K CTR filing).
|
|
1924
|
+
*
|
|
1925
|
+
* Scoring:
|
|
1926
|
+
* - Non-round amounts: 5 (baseline, most legitimate transactions)
|
|
1927
|
+
* - Multiples of $1,000: 15 (mildly suspicious)
|
|
1928
|
+
* - Multiples of $10,000: 25 (more suspicious)
|
|
1929
|
+
* - Amounts $9,000-$10,000: +20 penalty (classic structuring band)
|
|
1930
|
+
*
|
|
1931
|
+
* This factor alone rarely triggers flagging — it contributes to the composite
|
|
1932
|
+
* risk score alongside amount, frequency, and destination analysis.
|
|
1933
|
+
*/
|
|
1790
1934
|
computeRoundAmountRisk(tx) {
|
|
1791
1935
|
const amount = parseAmount(tx.amount);
|
|
1792
1936
|
if (isNaN(amount)) {
|
|
@@ -1809,15 +1953,15 @@ var TrustScorer = class {
|
|
|
1809
1953
|
// Scoring helpers
|
|
1810
1954
|
// --------------------------------------------------------------------------
|
|
1811
1955
|
scoreToLevel(score) {
|
|
1812
|
-
if (score >=
|
|
1813
|
-
if (score >=
|
|
1814
|
-
if (score >=
|
|
1815
|
-
if (score >=
|
|
1956
|
+
if (score >= TRUST_LEVEL_VERIFIED) return "verified";
|
|
1957
|
+
if (score >= TRUST_LEVEL_HIGH) return "high";
|
|
1958
|
+
if (score >= TRUST_LEVEL_MEDIUM) return "medium";
|
|
1959
|
+
if (score >= TRUST_LEVEL_LOW) return "low";
|
|
1816
1960
|
return "untrusted";
|
|
1817
1961
|
}
|
|
1818
1962
|
riskScoreToLevel(score) {
|
|
1819
|
-
if (score >=
|
|
1820
|
-
if (score >=
|
|
1963
|
+
if (score >= RISK_BLOCK_THRESHOLD) return "critical";
|
|
1964
|
+
if (score >= RISK_FLAG_THRESHOLD) return "high";
|
|
1821
1965
|
if (score >= 35) return "medium";
|
|
1822
1966
|
return "low";
|
|
1823
1967
|
}
|
|
@@ -2185,13 +2329,1106 @@ var AnomalyDetector = class {
|
|
|
2185
2329
|
try {
|
|
2186
2330
|
cb(anomaly);
|
|
2187
2331
|
} catch (error) {
|
|
2188
|
-
|
|
2189
|
-
|
|
2332
|
+
console.warn(
|
|
2333
|
+
`[Kontext] Anomaly callback error for ${anomaly.type} (${anomaly.id}):`,
|
|
2334
|
+
error instanceof Error ? error.message : error
|
|
2335
|
+
);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
};
|
|
2340
|
+
|
|
2341
|
+
// src/integrations/ofac-sanctions.ts
|
|
2342
|
+
var SANCTIONED_ADDRESS_DATABASE = [
|
|
2343
|
+
// -------------------------------------------------------------------------
|
|
2344
|
+
// Lazarus Group / DPRK (North Korea) -- SDN Active
|
|
2345
|
+
// -------------------------------------------------------------------------
|
|
2346
|
+
{
|
|
2347
|
+
address: "0x098B716B8Aaf21512996dC57EB0615e2383E2f96",
|
|
2348
|
+
lists: ["SDN"],
|
|
2349
|
+
entityName: "Lazarus Group",
|
|
2350
|
+
entityType: "GROUP",
|
|
2351
|
+
dateAdded: "2022-04-14",
|
|
2352
|
+
dateRemoved: null,
|
|
2353
|
+
chains: ["ethereum"],
|
|
2354
|
+
notes: "Ronin Bridge hack - primary address"
|
|
2355
|
+
},
|
|
2356
|
+
{
|
|
2357
|
+
address: "0xa0e1c89Ef1a489c9C7dE96311eD5Ce5D32c20E4B",
|
|
2358
|
+
lists: ["SDN"],
|
|
2359
|
+
entityName: "Lazarus Group",
|
|
2360
|
+
entityType: "GROUP",
|
|
2361
|
+
dateAdded: "2022-04-14",
|
|
2362
|
+
dateRemoved: null,
|
|
2363
|
+
chains: ["ethereum"],
|
|
2364
|
+
notes: "Ronin Bridge hack - associated address"
|
|
2365
|
+
},
|
|
2366
|
+
{
|
|
2367
|
+
address: "0x3Cffd56B47B7b41c56258D9C7731ABaDc360E460",
|
|
2368
|
+
lists: ["SDN"],
|
|
2369
|
+
entityName: "Lazarus Group",
|
|
2370
|
+
entityType: "GROUP",
|
|
2371
|
+
dateAdded: "2022-04-14",
|
|
2372
|
+
dateRemoved: null,
|
|
2373
|
+
chains: ["ethereum"],
|
|
2374
|
+
notes: "Ronin Bridge hack - associated address"
|
|
2375
|
+
},
|
|
2376
|
+
{
|
|
2377
|
+
address: "0x53b6936513e738f44FB50d2b9476730C0Ab3Bfc1",
|
|
2378
|
+
lists: ["SDN"],
|
|
2379
|
+
entityName: "Lazarus Group",
|
|
2380
|
+
entityType: "GROUP",
|
|
2381
|
+
dateAdded: "2022-04-14",
|
|
2382
|
+
dateRemoved: null,
|
|
2383
|
+
chains: ["ethereum"],
|
|
2384
|
+
notes: "Ronin Bridge hack - associated address"
|
|
2385
|
+
},
|
|
2386
|
+
{
|
|
2387
|
+
address: "0x4F47Bc496083C727c5fbe3CE9CDf2B0f6496270c",
|
|
2388
|
+
lists: ["SDN"],
|
|
2389
|
+
entityName: "Lazarus Group",
|
|
2390
|
+
entityType: "GROUP",
|
|
2391
|
+
dateAdded: "2023-08-22",
|
|
2392
|
+
dateRemoved: null,
|
|
2393
|
+
chains: ["ethereum"],
|
|
2394
|
+
notes: "Harmony Horizon bridge hack - associated address"
|
|
2395
|
+
},
|
|
2396
|
+
{
|
|
2397
|
+
address: "0x0836222F2B2B24A3F36f98668Ed8F0B38D1a872f",
|
|
2398
|
+
lists: ["SDN"],
|
|
2399
|
+
entityName: "Lazarus Group",
|
|
2400
|
+
entityType: "GROUP",
|
|
2401
|
+
dateAdded: "2023-08-22",
|
|
2402
|
+
dateRemoved: null,
|
|
2403
|
+
chains: ["ethereum"],
|
|
2404
|
+
notes: "Additional Lazarus-attributed address"
|
|
2405
|
+
},
|
|
2406
|
+
// -------------------------------------------------------------------------
|
|
2407
|
+
// Roman Semenov (Tornado Cash developer) -- SDN Active
|
|
2408
|
+
// Remains personally sanctioned even after Tornado Cash delisting
|
|
2409
|
+
// -------------------------------------------------------------------------
|
|
2410
|
+
{
|
|
2411
|
+
address: "0xdcbEfFBECcE100cCE9E4b153C4e15cB885643193",
|
|
2412
|
+
lists: ["SDN"],
|
|
2413
|
+
entityName: "Roman Semenov",
|
|
2414
|
+
entityType: "INDIVIDUAL",
|
|
2415
|
+
dateAdded: "2022-08-08",
|
|
2416
|
+
dateRemoved: null,
|
|
2417
|
+
chains: ["ethereum"],
|
|
2418
|
+
notes: "Tornado Cash developer - personal wallet"
|
|
2419
|
+
},
|
|
2420
|
+
{
|
|
2421
|
+
address: "0x931546D9e66836AbF687d2bc64B30407bAc8C568",
|
|
2422
|
+
lists: ["SDN"],
|
|
2423
|
+
entityName: "Roman Semenov",
|
|
2424
|
+
entityType: "INDIVIDUAL",
|
|
2425
|
+
dateAdded: "2022-08-08",
|
|
2426
|
+
dateRemoved: null,
|
|
2427
|
+
chains: ["ethereum"],
|
|
2428
|
+
notes: "Tornado Cash developer - personal wallet"
|
|
2429
|
+
},
|
|
2430
|
+
{
|
|
2431
|
+
address: "0x43fa21d92141BA9db43052492E0DeEE5aa5f0A93",
|
|
2432
|
+
lists: ["SDN"],
|
|
2433
|
+
entityName: "Roman Semenov",
|
|
2434
|
+
entityType: "INDIVIDUAL",
|
|
2435
|
+
dateAdded: "2022-08-08",
|
|
2436
|
+
dateRemoved: null,
|
|
2437
|
+
chains: ["ethereum"],
|
|
2438
|
+
notes: "Tornado Cash developer - personal wallet"
|
|
2439
|
+
},
|
|
2440
|
+
// -------------------------------------------------------------------------
|
|
2441
|
+
// Garantex exchange -- SDN Active
|
|
2442
|
+
// -------------------------------------------------------------------------
|
|
2443
|
+
{
|
|
2444
|
+
address: "0x6F1cA141A28907F78Ebaa64f83E4AE6038d3cbe7",
|
|
2445
|
+
lists: ["SDN"],
|
|
2446
|
+
entityName: "Garantex",
|
|
2447
|
+
entityType: "EXCHANGE",
|
|
2448
|
+
dateAdded: "2022-04-05",
|
|
2449
|
+
dateRemoved: null,
|
|
2450
|
+
chains: ["ethereum"],
|
|
2451
|
+
notes: "Russia-based exchange sanctioned for facilitating ransomware payments"
|
|
2452
|
+
},
|
|
2453
|
+
{
|
|
2454
|
+
address: "0x2f389cE8bD8ff92De3402FFCe4691d17fC4f6535",
|
|
2455
|
+
lists: ["SDN"],
|
|
2456
|
+
entityName: "Garantex",
|
|
2457
|
+
entityType: "EXCHANGE",
|
|
2458
|
+
dateAdded: "2022-04-05",
|
|
2459
|
+
dateRemoved: null,
|
|
2460
|
+
chains: ["ethereum"],
|
|
2461
|
+
notes: "Garantex hot wallet"
|
|
2462
|
+
},
|
|
2463
|
+
{
|
|
2464
|
+
address: "0x19Aa5Fe80D33a56D56c78e82eA5E50E5d80b4Dff",
|
|
2465
|
+
lists: ["SDN"],
|
|
2466
|
+
entityName: "Garantex",
|
|
2467
|
+
entityType: "EXCHANGE",
|
|
2468
|
+
dateAdded: "2022-04-05",
|
|
2469
|
+
dateRemoved: null,
|
|
2470
|
+
chains: ["ethereum"],
|
|
2471
|
+
notes: "Garantex operational address"
|
|
2472
|
+
},
|
|
2473
|
+
// -------------------------------------------------------------------------
|
|
2474
|
+
// Blender.io -- SDN Active
|
|
2475
|
+
// -------------------------------------------------------------------------
|
|
2476
|
+
{
|
|
2477
|
+
address: "0x23773E65ed146A459791799d01336DB287f25334",
|
|
2478
|
+
lists: ["SDN"],
|
|
2479
|
+
entityName: "Blender.io",
|
|
2480
|
+
entityType: "MIXER",
|
|
2481
|
+
dateAdded: "2022-05-06",
|
|
2482
|
+
dateRemoved: null,
|
|
2483
|
+
chains: ["ethereum"],
|
|
2484
|
+
notes: "First mixer sanctioned by OFAC - used by Lazarus Group"
|
|
2485
|
+
},
|
|
2486
|
+
// -------------------------------------------------------------------------
|
|
2487
|
+
// Sinbad.io -- SDN Active
|
|
2488
|
+
// -------------------------------------------------------------------------
|
|
2489
|
+
{
|
|
2490
|
+
address: "0x722122dF12D4e14e13Ac3b6895a86e84145b6967",
|
|
2491
|
+
lists: ["SDN"],
|
|
2492
|
+
entityName: "Sinbad.io",
|
|
2493
|
+
entityType: "MIXER",
|
|
2494
|
+
dateAdded: "2023-11-29",
|
|
2495
|
+
dateRemoved: null,
|
|
2496
|
+
chains: ["ethereum"],
|
|
2497
|
+
notes: "Mixer sanctioned for North Korean money laundering. (Note: this address is commonly associated with Tornado Cash but was re-designated under Sinbad.io)"
|
|
2498
|
+
},
|
|
2499
|
+
// -------------------------------------------------------------------------
|
|
2500
|
+
// Tornado Cash smart contracts -- DELISTED (March 21, 2025)
|
|
2501
|
+
// Retained for risk scoring; interaction with formerly sanctioned
|
|
2502
|
+
// infrastructure is still a compliance risk indicator.
|
|
2503
|
+
// -------------------------------------------------------------------------
|
|
2504
|
+
{
|
|
2505
|
+
address: "0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b",
|
|
2506
|
+
lists: ["DELISTED"],
|
|
2507
|
+
entityName: "Tornado Cash",
|
|
2508
|
+
entityType: "PROTOCOL",
|
|
2509
|
+
dateAdded: "2022-08-08",
|
|
2510
|
+
dateRemoved: "2025-03-21",
|
|
2511
|
+
chains: ["ethereum"],
|
|
2512
|
+
notes: "Tornado Cash 1 ETH pool - DELISTED per Fifth Circuit ruling"
|
|
2513
|
+
},
|
|
2514
|
+
{
|
|
2515
|
+
address: "0xd96f2B1c14Db8458374d9Aca76E26c3D18364307",
|
|
2516
|
+
lists: ["DELISTED"],
|
|
2517
|
+
entityName: "Tornado Cash",
|
|
2518
|
+
entityType: "PROTOCOL",
|
|
2519
|
+
dateAdded: "2022-08-08",
|
|
2520
|
+
dateRemoved: "2025-03-21",
|
|
2521
|
+
chains: ["ethereum"],
|
|
2522
|
+
notes: "Tornado Cash 10 ETH pool - DELISTED"
|
|
2523
|
+
},
|
|
2524
|
+
{
|
|
2525
|
+
address: "0x4736dCf1b7A3d580672CcE6E7c65cd5cc9cFBfA9",
|
|
2526
|
+
lists: ["DELISTED"],
|
|
2527
|
+
entityName: "Tornado Cash",
|
|
2528
|
+
entityType: "PROTOCOL",
|
|
2529
|
+
dateAdded: "2022-08-08",
|
|
2530
|
+
dateRemoved: "2025-03-21",
|
|
2531
|
+
chains: ["ethereum"],
|
|
2532
|
+
notes: "Tornado Cash pool contract - DELISTED"
|
|
2533
|
+
},
|
|
2534
|
+
{
|
|
2535
|
+
address: "0xDD4c48C0B24039969fC16D1cdF626eaB821d3384",
|
|
2536
|
+
lists: ["DELISTED"],
|
|
2537
|
+
entityName: "Tornado Cash",
|
|
2538
|
+
entityType: "PROTOCOL",
|
|
2539
|
+
dateAdded: "2022-08-08",
|
|
2540
|
+
dateRemoved: "2025-03-21",
|
|
2541
|
+
chains: ["ethereum"],
|
|
2542
|
+
notes: "Tornado Cash 100 ETH pool - DELISTED"
|
|
2543
|
+
},
|
|
2544
|
+
{
|
|
2545
|
+
address: "0xd4B88Df4D29F5CedD6857912842cff3b20C8Cfa3",
|
|
2546
|
+
lists: ["DELISTED"],
|
|
2547
|
+
entityName: "Tornado Cash",
|
|
2548
|
+
entityType: "PROTOCOL",
|
|
2549
|
+
dateAdded: "2022-08-08",
|
|
2550
|
+
dateRemoved: "2025-03-21",
|
|
2551
|
+
chains: ["ethereum"],
|
|
2552
|
+
notes: "Tornado Cash 100 DAI pool - DELISTED"
|
|
2553
|
+
},
|
|
2554
|
+
{
|
|
2555
|
+
address: "0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF",
|
|
2556
|
+
lists: ["DELISTED"],
|
|
2557
|
+
entityName: "Tornado Cash",
|
|
2558
|
+
entityType: "PROTOCOL",
|
|
2559
|
+
dateAdded: "2022-08-08",
|
|
2560
|
+
dateRemoved: "2025-03-21",
|
|
2561
|
+
chains: ["ethereum"],
|
|
2562
|
+
notes: "Tornado Cash 10000 DAI pool - DELISTED"
|
|
2563
|
+
},
|
|
2564
|
+
{
|
|
2565
|
+
address: "0xA160cdAB225685dA1d56aa342Ad8841c3b53f291",
|
|
2566
|
+
lists: ["DELISTED"],
|
|
2567
|
+
entityName: "Tornado Cash",
|
|
2568
|
+
entityType: "PROTOCOL",
|
|
2569
|
+
dateAdded: "2022-08-08",
|
|
2570
|
+
dateRemoved: "2025-03-21",
|
|
2571
|
+
chains: ["ethereum"],
|
|
2572
|
+
notes: "Tornado Cash 100000 DAI pool - DELISTED"
|
|
2573
|
+
},
|
|
2574
|
+
{
|
|
2575
|
+
address: "0xFD8610d20aA15b7B2E3Be39B396a1bC3516c7144",
|
|
2576
|
+
lists: ["DELISTED"],
|
|
2577
|
+
entityName: "Tornado Cash",
|
|
2578
|
+
entityType: "PROTOCOL",
|
|
2579
|
+
dateAdded: "2022-08-08",
|
|
2580
|
+
dateRemoved: "2025-03-21",
|
|
2581
|
+
chains: ["ethereum"],
|
|
2582
|
+
notes: "Tornado Cash 1000 USDC pool - DELISTED"
|
|
2583
|
+
},
|
|
2584
|
+
{
|
|
2585
|
+
address: "0xF60dD140cFf0706bAE9Cd734Ac3683731B816EeD",
|
|
2586
|
+
lists: ["DELISTED"],
|
|
2587
|
+
entityName: "Tornado Cash",
|
|
2588
|
+
entityType: "PROTOCOL",
|
|
2589
|
+
dateAdded: "2022-11-08",
|
|
2590
|
+
dateRemoved: "2025-03-21",
|
|
2591
|
+
chains: ["ethereum"],
|
|
2592
|
+
notes: "Tornado Cash - added November 2022 update - DELISTED"
|
|
2593
|
+
},
|
|
2594
|
+
{
|
|
2595
|
+
address: "0x22aaA7720ddd5388A3c0A3333430953C68f1849b",
|
|
2596
|
+
lists: ["DELISTED"],
|
|
2597
|
+
entityName: "Tornado Cash",
|
|
2598
|
+
entityType: "PROTOCOL",
|
|
2599
|
+
dateAdded: "2022-08-08",
|
|
2600
|
+
dateRemoved: "2025-03-21",
|
|
2601
|
+
chains: ["ethereum"],
|
|
2602
|
+
notes: "Tornado Cash - DELISTED"
|
|
2603
|
+
},
|
|
2604
|
+
{
|
|
2605
|
+
address: "0xBA214C1c1928a32Bffe790263E38B4Af9bFCD659",
|
|
2606
|
+
lists: ["DELISTED"],
|
|
2607
|
+
entityName: "Tornado Cash",
|
|
2608
|
+
entityType: "PROTOCOL",
|
|
2609
|
+
dateAdded: "2022-08-08",
|
|
2610
|
+
dateRemoved: "2025-03-21",
|
|
2611
|
+
chains: ["ethereum"],
|
|
2612
|
+
notes: "Tornado Cash - DELISTED"
|
|
2613
|
+
},
|
|
2614
|
+
{
|
|
2615
|
+
address: "0xb1C8094B234DcE6e03f10a5b673c1d8C69739A00",
|
|
2616
|
+
lists: ["DELISTED"],
|
|
2617
|
+
entityName: "Tornado Cash",
|
|
2618
|
+
entityType: "PROTOCOL",
|
|
2619
|
+
dateAdded: "2022-08-08",
|
|
2620
|
+
dateRemoved: "2025-03-21",
|
|
2621
|
+
chains: ["ethereum"],
|
|
2622
|
+
notes: "Tornado Cash - DELISTED"
|
|
2623
|
+
},
|
|
2624
|
+
{
|
|
2625
|
+
address: "0x527653eA119F3E6a1F5BD18fbF4714081D7B31ce",
|
|
2626
|
+
lists: ["DELISTED"],
|
|
2627
|
+
entityName: "Tornado Cash",
|
|
2628
|
+
entityType: "PROTOCOL",
|
|
2629
|
+
dateAdded: "2022-08-08",
|
|
2630
|
+
dateRemoved: "2025-03-21",
|
|
2631
|
+
chains: ["ethereum"],
|
|
2632
|
+
notes: "Tornado Cash - DELISTED"
|
|
2633
|
+
},
|
|
2634
|
+
{
|
|
2635
|
+
address: "0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2",
|
|
2636
|
+
lists: ["DELISTED"],
|
|
2637
|
+
entityName: "Tornado Cash",
|
|
2638
|
+
entityType: "PROTOCOL",
|
|
2639
|
+
dateAdded: "2022-08-08",
|
|
2640
|
+
dateRemoved: "2025-03-21",
|
|
2641
|
+
chains: ["ethereum"],
|
|
2642
|
+
notes: "Tornado Cash Router - DELISTED"
|
|
2643
|
+
},
|
|
2644
|
+
{
|
|
2645
|
+
address: "0x8589427373D6D84E98730D7795D8f6f8731FDA16",
|
|
2646
|
+
lists: ["DELISTED"],
|
|
2647
|
+
entityName: "Tornado Cash",
|
|
2648
|
+
entityType: "PROTOCOL",
|
|
2649
|
+
dateAdded: "2022-08-08",
|
|
2650
|
+
dateRemoved: "2025-03-21",
|
|
2651
|
+
chains: ["ethereum"],
|
|
2652
|
+
notes: "Tornado Cash - DELISTED"
|
|
2653
|
+
},
|
|
2654
|
+
// -------------------------------------------------------------------------
|
|
2655
|
+
// Zedcex / Zedxion -- SDN Active (IRGC-linked)
|
|
2656
|
+
// First-ever designation of an IRGC-linked digital asset exchange (2024)
|
|
2657
|
+
// -------------------------------------------------------------------------
|
|
2658
|
+
{
|
|
2659
|
+
address: "0xaeAAc358560e11f52454D997AAFF2c5731B6f8a6",
|
|
2660
|
+
lists: ["SDN"],
|
|
2661
|
+
entityName: "Zedcex Exchange",
|
|
2662
|
+
entityType: "EXCHANGE",
|
|
2663
|
+
dateAdded: "2024-06-26",
|
|
2664
|
+
dateRemoved: null,
|
|
2665
|
+
chains: ["ethereum"],
|
|
2666
|
+
notes: "IRGC-linked digital asset exchange"
|
|
2667
|
+
},
|
|
2668
|
+
// -------------------------------------------------------------------------
|
|
2669
|
+
// Additional known sanctioned addresses from SDN list
|
|
2670
|
+
// -------------------------------------------------------------------------
|
|
2671
|
+
{
|
|
2672
|
+
address: "0x7F367cC41522cE07553e823bf3be79A889DEbe1B",
|
|
2673
|
+
lists: ["SDN"],
|
|
2674
|
+
entityName: "DPRK IT Workers",
|
|
2675
|
+
entityType: "GROUP",
|
|
2676
|
+
dateAdded: "2023-05-23",
|
|
2677
|
+
dateRemoved: null,
|
|
2678
|
+
chains: ["ethereum"],
|
|
2679
|
+
notes: "North Korean IT worker network generating revenue for WMD programs"
|
|
2680
|
+
},
|
|
2681
|
+
{
|
|
2682
|
+
address: "0x01e2919679362dFBC9ee1644Ba9C6da6D6245BB1",
|
|
2683
|
+
lists: ["SDN"],
|
|
2684
|
+
entityName: "DPRK Cyber Operations",
|
|
2685
|
+
entityType: "GROUP",
|
|
2686
|
+
dateAdded: "2023-04-24",
|
|
2687
|
+
dateRemoved: null,
|
|
2688
|
+
chains: ["ethereum"],
|
|
2689
|
+
notes: "DPRK-attributed cyber theft proceeds"
|
|
2690
|
+
},
|
|
2691
|
+
{
|
|
2692
|
+
address: "0xc455f7fd3e0e12afd51fba5c106909934d8a0e4a",
|
|
2693
|
+
lists: ["SDN"],
|
|
2694
|
+
entityName: "DPRK Cyber Operations",
|
|
2695
|
+
entityType: "GROUP",
|
|
2696
|
+
dateAdded: "2023-08-22",
|
|
2697
|
+
dateRemoved: null,
|
|
2698
|
+
chains: ["ethereum"],
|
|
2699
|
+
notes: "Stake.com hack proceeds - DPRK attributed"
|
|
2700
|
+
}
|
|
2701
|
+
];
|
|
2702
|
+
var SANCTIONED_JURISDICTIONS = [
|
|
2703
|
+
{
|
|
2704
|
+
code: "KP",
|
|
2705
|
+
name: "North Korea (DPRK)",
|
|
2706
|
+
sanctionsProgram: "North Korea Sanctions Regulations, 31 C.F.R. Part 510",
|
|
2707
|
+
riskLevel: "BLOCKED",
|
|
2708
|
+
comprehensive: true
|
|
2709
|
+
},
|
|
2710
|
+
{
|
|
2711
|
+
code: "IR",
|
|
2712
|
+
name: "Iran",
|
|
2713
|
+
sanctionsProgram: "Iranian Transactions and Sanctions Regulations, 31 C.F.R. Part 560",
|
|
2714
|
+
riskLevel: "BLOCKED",
|
|
2715
|
+
comprehensive: true
|
|
2716
|
+
},
|
|
2717
|
+
{
|
|
2718
|
+
code: "CU",
|
|
2719
|
+
name: "Cuba",
|
|
2720
|
+
sanctionsProgram: "Cuban Assets Control Regulations, 31 C.F.R. Part 515",
|
|
2721
|
+
riskLevel: "BLOCKED",
|
|
2722
|
+
comprehensive: true
|
|
2723
|
+
},
|
|
2724
|
+
{
|
|
2725
|
+
code: "SY",
|
|
2726
|
+
name: "Syria",
|
|
2727
|
+
sanctionsProgram: "Syrian Sanctions Regulations, 31 C.F.R. Part 542",
|
|
2728
|
+
riskLevel: "BLOCKED",
|
|
2729
|
+
comprehensive: true
|
|
2730
|
+
},
|
|
2731
|
+
{
|
|
2732
|
+
code: "RU_CRIMEA",
|
|
2733
|
+
name: "Crimea Region of Ukraine (Russian-occupied)",
|
|
2734
|
+
sanctionsProgram: "Ukraine-/Russia-Related Sanctions, E.O. 13685",
|
|
2735
|
+
riskLevel: "BLOCKED",
|
|
2736
|
+
comprehensive: true
|
|
2737
|
+
},
|
|
2738
|
+
{
|
|
2739
|
+
code: "RU_DNR",
|
|
2740
|
+
name: "Donetsk People's Republic (Russian-occupied Ukraine)",
|
|
2741
|
+
sanctionsProgram: "Ukraine-/Russia-Related Sanctions, E.O. 14065",
|
|
2742
|
+
riskLevel: "BLOCKED",
|
|
2743
|
+
comprehensive: true
|
|
2744
|
+
},
|
|
2745
|
+
{
|
|
2746
|
+
code: "RU_LNR",
|
|
2747
|
+
name: "Luhansk People's Republic (Russian-occupied Ukraine)",
|
|
2748
|
+
sanctionsProgram: "Ukraine-/Russia-Related Sanctions, E.O. 14065",
|
|
2749
|
+
riskLevel: "BLOCKED",
|
|
2750
|
+
comprehensive: true
|
|
2751
|
+
},
|
|
2752
|
+
{
|
|
2753
|
+
code: "RU_ZAPORIZHZHIA",
|
|
2754
|
+
name: "Zaporizhzhia Region (Russian-occupied Ukraine)",
|
|
2755
|
+
sanctionsProgram: "Ukraine-/Russia-Related Sanctions, E.O. 14065",
|
|
2756
|
+
riskLevel: "BLOCKED",
|
|
2757
|
+
comprehensive: true
|
|
2758
|
+
},
|
|
2759
|
+
{
|
|
2760
|
+
code: "RU_KHERSON",
|
|
2761
|
+
name: "Kherson Region (Russian-occupied Ukraine)",
|
|
2762
|
+
sanctionsProgram: "Ukraine-/Russia-Related Sanctions, E.O. 14065",
|
|
2763
|
+
riskLevel: "BLOCKED",
|
|
2764
|
+
comprehensive: true
|
|
2765
|
+
},
|
|
2766
|
+
{
|
|
2767
|
+
code: "BY",
|
|
2768
|
+
name: "Belarus",
|
|
2769
|
+
sanctionsProgram: "Belarus Sanctions Regulations, 31 C.F.R. Part 548",
|
|
2770
|
+
riskLevel: "HIGH",
|
|
2771
|
+
comprehensive: false
|
|
2772
|
+
},
|
|
2773
|
+
{
|
|
2774
|
+
code: "VE",
|
|
2775
|
+
name: "Venezuela",
|
|
2776
|
+
sanctionsProgram: "Venezuela Sanctions Regulations, 31 C.F.R. Part 591",
|
|
2777
|
+
riskLevel: "HIGH",
|
|
2778
|
+
comprehensive: false
|
|
2779
|
+
},
|
|
2780
|
+
{
|
|
2781
|
+
code: "MM",
|
|
2782
|
+
name: "Myanmar (Burma)",
|
|
2783
|
+
sanctionsProgram: "Burmese Sanctions Regulations, 31 C.F.R. Part 525",
|
|
2784
|
+
riskLevel: "MEDIUM",
|
|
2785
|
+
comprehensive: false
|
|
2786
|
+
},
|
|
2787
|
+
{
|
|
2788
|
+
code: "SD",
|
|
2789
|
+
name: "Sudan",
|
|
2790
|
+
sanctionsProgram: "Sudanese Sanctions Regulations, 31 C.F.R. Part 538",
|
|
2791
|
+
riskLevel: "HIGH",
|
|
2792
|
+
comprehensive: false
|
|
2793
|
+
},
|
|
2794
|
+
{
|
|
2795
|
+
code: "SO",
|
|
2796
|
+
name: "Somalia",
|
|
2797
|
+
sanctionsProgram: "Somalia Sanctions Regulations, 31 C.F.R. Part 551",
|
|
2798
|
+
riskLevel: "MEDIUM",
|
|
2799
|
+
comprehensive: false
|
|
2800
|
+
}
|
|
2801
|
+
];
|
|
2802
|
+
var SANCTIONED_ENTITIES = [
|
|
2803
|
+
{
|
|
2804
|
+
name: "Lazarus Group",
|
|
2805
|
+
aliases: ["HIDDEN COBRA", "Guardians of Peace", "APT38", "Bluenoroff", "Stardust Chollima", "TEMP.Hermit"],
|
|
2806
|
+
addresses: [
|
|
2807
|
+
"0x098B716B8Aaf21512996dC57EB0615e2383E2f96",
|
|
2808
|
+
"0xa0e1c89Ef1a489c9C7dE96311eD5Ce5D32c20E4B",
|
|
2809
|
+
"0x3Cffd56B47B7b41c56258D9C7731ABaDc360E460",
|
|
2810
|
+
"0x53b6936513e738f44FB50d2b9476730C0Ab3Bfc1",
|
|
2811
|
+
"0x4F47Bc496083C727c5fbe3CE9CDf2B0f6496270c"
|
|
2812
|
+
],
|
|
2813
|
+
list: "SDN"
|
|
2814
|
+
},
|
|
2815
|
+
{
|
|
2816
|
+
name: "Roman Semenov",
|
|
2817
|
+
aliases: ["Roman Storm", "Tornado Cash Developer"],
|
|
2818
|
+
addresses: [
|
|
2819
|
+
"0xdcbEfFBECcE100cCE9E4b153C4e15cB885643193",
|
|
2820
|
+
"0x931546D9e66836AbF687d2bc64B30407bAc8C568",
|
|
2821
|
+
"0x43fa21d92141BA9db43052492E0DeEE5aa5f0A93"
|
|
2822
|
+
],
|
|
2823
|
+
list: "SDN"
|
|
2824
|
+
},
|
|
2825
|
+
{
|
|
2826
|
+
name: "Garantex",
|
|
2827
|
+
aliases: ["Garantex Exchange", "garantex.io", "GARANTEX EUROPE OU"],
|
|
2828
|
+
addresses: [
|
|
2829
|
+
"0x6F1cA141A28907F78Ebaa64f83E4AE6038d3cbe7",
|
|
2830
|
+
"0x2f389cE8bD8ff92De3402FFCe4691d17fC4f6535",
|
|
2831
|
+
"0x19Aa5Fe80D33a56D56c78e82eA5E50E5d80b4Dff"
|
|
2832
|
+
],
|
|
2833
|
+
list: "SDN"
|
|
2834
|
+
},
|
|
2835
|
+
{
|
|
2836
|
+
name: "Blender.io",
|
|
2837
|
+
aliases: ["Blender", "Blender Mixer"],
|
|
2838
|
+
addresses: ["0x23773E65ed146A459791799d01336DB287f25334"],
|
|
2839
|
+
list: "SDN"
|
|
2840
|
+
},
|
|
2841
|
+
{
|
|
2842
|
+
name: "Tornado Cash",
|
|
2843
|
+
aliases: ["TornadoCash", "TC", "Tornado"],
|
|
2844
|
+
addresses: [
|
|
2845
|
+
"0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b",
|
|
2846
|
+
"0xd96f2B1c14Db8458374d9Aca76E26c3D18364307",
|
|
2847
|
+
"0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2"
|
|
2848
|
+
],
|
|
2849
|
+
list: "DELISTED"
|
|
2850
|
+
},
|
|
2851
|
+
{
|
|
2852
|
+
name: "Zedcex Exchange",
|
|
2853
|
+
aliases: ["Zedxion", "Zedcex"],
|
|
2854
|
+
addresses: ["0xaeAAc358560e11f52454D997AAFF2c5731B6f8a6"],
|
|
2855
|
+
list: "SDN"
|
|
2856
|
+
}
|
|
2857
|
+
];
|
|
2858
|
+
var activeAddressSet = /* @__PURE__ */ new Set();
|
|
2859
|
+
var allAddressSet = /* @__PURE__ */ new Set();
|
|
2860
|
+
var addressToEntry = /* @__PURE__ */ new Map();
|
|
2861
|
+
function rebuildIndexes() {
|
|
2862
|
+
activeAddressSet = /* @__PURE__ */ new Set();
|
|
2863
|
+
allAddressSet = /* @__PURE__ */ new Set();
|
|
2864
|
+
addressToEntry = /* @__PURE__ */ new Map();
|
|
2865
|
+
for (const entry of SANCTIONED_ADDRESS_DATABASE) {
|
|
2866
|
+
const lower = entry.address.toLowerCase();
|
|
2867
|
+
allAddressSet.add(lower);
|
|
2868
|
+
addressToEntry.set(lower, entry);
|
|
2869
|
+
const isActive = entry.dateRemoved === null;
|
|
2870
|
+
if (isActive) {
|
|
2871
|
+
activeAddressSet.add(lower);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
rebuildIndexes();
|
|
2876
|
+
var listMetadata = {
|
|
2877
|
+
lastUpdated: "2025-03-21",
|
|
2878
|
+
addressCount: SANCTIONED_ADDRESS_DATABASE.length,
|
|
2879
|
+
entityCount: SANCTIONED_ENTITIES.length,
|
|
2880
|
+
sourceUrl: "https://www.treasury.gov/ofac/downloads/sdnlist.txt",
|
|
2881
|
+
version: "2025-03-21-initial"
|
|
2882
|
+
};
|
|
2883
|
+
var OFACSanctionsScreener = class {
|
|
2884
|
+
// --------------------------------------------------------------------------
|
|
2885
|
+
// Core Address Screening
|
|
2886
|
+
// --------------------------------------------------------------------------
|
|
2887
|
+
/**
|
|
2888
|
+
* Check if an address is on an active sanctions list.
|
|
2889
|
+
* Returns true only for currently sanctioned (non-delisted) addresses.
|
|
2890
|
+
*/
|
|
2891
|
+
isActivelySanctioned(address) {
|
|
2892
|
+
return activeAddressSet.has(address.toLowerCase());
|
|
2893
|
+
}
|
|
2894
|
+
/**
|
|
2895
|
+
* Check if an address has ever appeared on any sanctions list,
|
|
2896
|
+
* including those that have been delisted.
|
|
2897
|
+
*/
|
|
2898
|
+
hasAnySanctionsHistory(address) {
|
|
2899
|
+
return allAddressSet.has(address.toLowerCase());
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* Get the full entry for a sanctioned address.
|
|
2903
|
+
*/
|
|
2904
|
+
getAddressEntry(address) {
|
|
2905
|
+
return addressToEntry.get(address.toLowerCase());
|
|
2906
|
+
}
|
|
2907
|
+
/**
|
|
2908
|
+
* Perform a comprehensive sanctions screening on an address.
|
|
2909
|
+
* Checks against all lists, evaluates jurisdiction, and computes risk score.
|
|
2910
|
+
*/
|
|
2911
|
+
screenAddress(address, context) {
|
|
2912
|
+
const lower = address.toLowerCase();
|
|
2913
|
+
const directMatches = [];
|
|
2914
|
+
const jurisdictionFlags = [];
|
|
2915
|
+
const patternFlags = [];
|
|
2916
|
+
const ownershipFlags = [];
|
|
2917
|
+
const recommendations = [];
|
|
2918
|
+
let riskScore = 0;
|
|
2919
|
+
const entry = addressToEntry.get(lower);
|
|
2920
|
+
if (entry) {
|
|
2921
|
+
const isActive = entry.dateRemoved === null;
|
|
2922
|
+
directMatches.push({
|
|
2923
|
+
matchedAddress: entry.address,
|
|
2924
|
+
list: entry.lists[0],
|
|
2925
|
+
entityName: entry.entityName,
|
|
2926
|
+
entityType: entry.entityType,
|
|
2927
|
+
confidence: 1,
|
|
2928
|
+
active: isActive
|
|
2929
|
+
});
|
|
2930
|
+
if (isActive) {
|
|
2931
|
+
riskScore = 100;
|
|
2932
|
+
recommendations.push(
|
|
2933
|
+
`BLOCK: Address ${address} is on the OFAC ${entry.lists.join(", ")} list as ${entry.entityName}. Transaction is prohibited under U.S. law.`
|
|
2934
|
+
);
|
|
2935
|
+
} else {
|
|
2936
|
+
riskScore = Math.max(riskScore, 45);
|
|
2937
|
+
recommendations.push(
|
|
2938
|
+
`CAUTION: Address ${address} was previously sanctioned as ${entry.entityName} (${entry.lists.join(", ")}). Delisted on ${entry.dateRemoved}. Enhanced due diligence recommended.`
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
if (context?.counterpartyAddress) {
|
|
2943
|
+
const cpLower = context.counterpartyAddress.toLowerCase();
|
|
2944
|
+
const cpEntry = addressToEntry.get(cpLower);
|
|
2945
|
+
if (cpEntry) {
|
|
2946
|
+
const cpActive = cpEntry.dateRemoved === null;
|
|
2947
|
+
directMatches.push({
|
|
2948
|
+
matchedAddress: cpEntry.address,
|
|
2949
|
+
list: cpEntry.lists[0],
|
|
2950
|
+
entityName: cpEntry.entityName,
|
|
2951
|
+
entityType: cpEntry.entityType,
|
|
2952
|
+
confidence: 1,
|
|
2953
|
+
active: cpActive
|
|
2954
|
+
});
|
|
2955
|
+
if (cpActive) {
|
|
2956
|
+
riskScore = 100;
|
|
2957
|
+
recommendations.push(
|
|
2958
|
+
`BLOCK: Counterparty address ${context.counterpartyAddress} is on the OFAC ${cpEntry.lists.join(", ")} list as ${cpEntry.entityName}.`
|
|
2959
|
+
);
|
|
2960
|
+
} else {
|
|
2961
|
+
riskScore = Math.max(riskScore, 45);
|
|
2962
|
+
recommendations.push(
|
|
2963
|
+
`CAUTION: Counterparty was previously sanctioned as ${cpEntry.entityName}.`
|
|
2964
|
+
);
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
if (context?.jurisdiction) {
|
|
2969
|
+
const jurisdictionInfo = SANCTIONED_JURISDICTIONS.find(
|
|
2970
|
+
(j) => j.code === context.jurisdiction
|
|
2971
|
+
);
|
|
2972
|
+
if (jurisdictionInfo) {
|
|
2973
|
+
jurisdictionFlags.push({
|
|
2974
|
+
jurisdiction: jurisdictionInfo.code,
|
|
2975
|
+
name: jurisdictionInfo.name,
|
|
2976
|
+
reason: `Transaction involves ${jurisdictionInfo.name}, sanctioned under ${jurisdictionInfo.sanctionsProgram}`,
|
|
2977
|
+
riskLevel: jurisdictionInfo.riskLevel
|
|
2978
|
+
});
|
|
2979
|
+
if (jurisdictionInfo.comprehensive) {
|
|
2980
|
+
riskScore = 100;
|
|
2981
|
+
recommendations.push(
|
|
2982
|
+
`BLOCK: Transaction involves comprehensively sanctioned jurisdiction: ${jurisdictionInfo.name}. All transactions are prohibited under ${jurisdictionInfo.sanctionsProgram}.`
|
|
2983
|
+
);
|
|
2984
|
+
} else {
|
|
2985
|
+
riskScore = Math.max(riskScore, 70);
|
|
2986
|
+
recommendations.push(
|
|
2987
|
+
`REVIEW: Transaction involves partially sanctioned jurisdiction: ${jurisdictionInfo.name}. Enhanced screening required per ${jurisdictionInfo.sanctionsProgram}.`
|
|
2988
|
+
);
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
let riskLevel;
|
|
2993
|
+
if (riskScore >= 100) {
|
|
2994
|
+
riskLevel = "BLOCKED";
|
|
2995
|
+
} else if (riskScore >= 70) {
|
|
2996
|
+
riskLevel = "SEVERE";
|
|
2997
|
+
} else if (riskScore >= 50) {
|
|
2998
|
+
riskLevel = "HIGH";
|
|
2999
|
+
} else if (riskScore >= 30) {
|
|
3000
|
+
riskLevel = "MEDIUM";
|
|
3001
|
+
} else if (riskScore > 0) {
|
|
3002
|
+
riskLevel = "LOW";
|
|
3003
|
+
} else {
|
|
3004
|
+
riskLevel = "NONE";
|
|
3005
|
+
}
|
|
3006
|
+
const sanctioned = directMatches.some((m) => m.active);
|
|
3007
|
+
if (recommendations.length === 0) {
|
|
3008
|
+
recommendations.push("Address passed all sanctions screening checks.");
|
|
3009
|
+
}
|
|
3010
|
+
return {
|
|
3011
|
+
sanctioned,
|
|
3012
|
+
riskLevel,
|
|
3013
|
+
riskScore: Math.min(riskScore, 100),
|
|
3014
|
+
address,
|
|
3015
|
+
directMatches,
|
|
3016
|
+
jurisdictionFlags,
|
|
3017
|
+
patternFlags,
|
|
3018
|
+
ownershipFlags,
|
|
3019
|
+
screenedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3020
|
+
listsChecked: ["SDN", "SSI", "CONSOLIDATED", "DELISTED"],
|
|
3021
|
+
recommendations
|
|
3022
|
+
};
|
|
3023
|
+
}
|
|
3024
|
+
// --------------------------------------------------------------------------
|
|
3025
|
+
// Jurisdictional Screening
|
|
3026
|
+
// --------------------------------------------------------------------------
|
|
3027
|
+
/**
|
|
3028
|
+
* Screen a jurisdiction for sanctions.
|
|
3029
|
+
*/
|
|
3030
|
+
screenJurisdiction(code) {
|
|
3031
|
+
const info = SANCTIONED_JURISDICTIONS.find(
|
|
3032
|
+
(j) => j.code === code || j.name.toLowerCase().includes(code.toLowerCase())
|
|
3033
|
+
);
|
|
3034
|
+
if (!info) return null;
|
|
3035
|
+
return {
|
|
3036
|
+
jurisdiction: info.code,
|
|
3037
|
+
name: info.name,
|
|
3038
|
+
reason: `Sanctioned under ${info.sanctionsProgram}`,
|
|
3039
|
+
riskLevel: info.riskLevel
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Get all sanctioned jurisdictions.
|
|
3044
|
+
*/
|
|
3045
|
+
getSanctionedJurisdictions() {
|
|
3046
|
+
return [...SANCTIONED_JURISDICTIONS];
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
3049
|
+
* Check if a jurisdiction has comprehensive sanctions (all transactions blocked).
|
|
3050
|
+
*/
|
|
3051
|
+
isComprehensiveSanctions(code) {
|
|
3052
|
+
const info = SANCTIONED_JURISDICTIONS.find((j) => j.code === code);
|
|
3053
|
+
return info?.comprehensive ?? false;
|
|
3054
|
+
}
|
|
3055
|
+
// --------------------------------------------------------------------------
|
|
3056
|
+
// Fuzzy Entity Name Matching
|
|
3057
|
+
// --------------------------------------------------------------------------
|
|
3058
|
+
/**
|
|
3059
|
+
* Search for sanctioned entities by name with fuzzy matching.
|
|
3060
|
+
* Uses normalized Levenshtein distance and alias matching.
|
|
3061
|
+
*
|
|
3062
|
+
* @param query - Name to search for
|
|
3063
|
+
* @param threshold - Minimum similarity score (0-1, default 0.6)
|
|
3064
|
+
* @returns Matching entities sorted by relevance
|
|
3065
|
+
*/
|
|
3066
|
+
searchEntityName(query, threshold = 0.6) {
|
|
3067
|
+
const queryLower = query.toLowerCase().trim();
|
|
3068
|
+
const results = [];
|
|
3069
|
+
for (const entity of SANCTIONED_ENTITIES) {
|
|
3070
|
+
const nameSim = computeSimilarity(queryLower, entity.name.toLowerCase());
|
|
3071
|
+
if (nameSim >= threshold) {
|
|
3072
|
+
results.push({ entity, similarity: nameSim, matchedOn: entity.name });
|
|
3073
|
+
continue;
|
|
3074
|
+
}
|
|
3075
|
+
let bestAliasSim = 0;
|
|
3076
|
+
let bestAlias = "";
|
|
3077
|
+
for (const alias of entity.aliases) {
|
|
3078
|
+
const aliasSim = computeSimilarity(queryLower, alias.toLowerCase());
|
|
3079
|
+
if (aliasSim > bestAliasSim) {
|
|
3080
|
+
bestAliasSim = aliasSim;
|
|
3081
|
+
bestAlias = alias;
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
if (bestAliasSim >= threshold) {
|
|
3085
|
+
results.push({ entity, similarity: bestAliasSim, matchedOn: bestAlias });
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
const allNames = [entity.name, ...entity.aliases];
|
|
3089
|
+
for (const name of allNames) {
|
|
3090
|
+
if (name.toLowerCase().includes(queryLower) || queryLower.includes(name.toLowerCase())) {
|
|
3091
|
+
results.push({ entity, similarity: 0.8, matchedOn: name });
|
|
3092
|
+
break;
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
results.sort((a, b) => b.similarity - a.similarity);
|
|
3097
|
+
return results;
|
|
3098
|
+
}
|
|
3099
|
+
// --------------------------------------------------------------------------
|
|
3100
|
+
// 50% Rule Screening
|
|
3101
|
+
// --------------------------------------------------------------------------
|
|
3102
|
+
/**
|
|
3103
|
+
* Check if an entity may be subject to the OFAC 50% Rule.
|
|
3104
|
+
*
|
|
3105
|
+
* Under the 50% Rule, entities owned 50% or more (individually or in
|
|
3106
|
+
* aggregate) by one or more sanctioned persons are themselves blocked,
|
|
3107
|
+
* even if not explicitly named on the SDN list.
|
|
3108
|
+
*
|
|
3109
|
+
* This method checks a provided ownership structure against known
|
|
3110
|
+
* sanctioned entities.
|
|
3111
|
+
*
|
|
3112
|
+
* @param ownershipEntries - Array of { ownerName, ownershipPercentage } objects
|
|
3113
|
+
* @returns Array of OwnershipFlag for any sanctioned owners
|
|
3114
|
+
*/
|
|
3115
|
+
checkFiftyPercentRule(entityName, ownershipEntries) {
|
|
3116
|
+
const flags = [];
|
|
3117
|
+
let totalSanctionedOwnership = 0;
|
|
3118
|
+
for (const owner of ownershipEntries) {
|
|
3119
|
+
const matches = this.searchEntityName(owner.ownerName, 0.7);
|
|
3120
|
+
if (matches.length > 0) {
|
|
3121
|
+
totalSanctionedOwnership += owner.ownershipPercentage;
|
|
3122
|
+
flags.push({
|
|
3123
|
+
entityName,
|
|
3124
|
+
sanctionedParent: matches[0].entity.name,
|
|
3125
|
+
ownershipPercentage: owner.ownershipPercentage,
|
|
3126
|
+
source: `Fuzzy match on "${owner.ownerName}" -> "${matches[0].matchedOn}" (${Math.round(matches[0].similarity * 100)}% match)`
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
if (totalSanctionedOwnership >= 50 && flags.length > 0) {
|
|
3131
|
+
flags.push({
|
|
3132
|
+
entityName,
|
|
3133
|
+
sanctionedParent: `Aggregate sanctioned ownership: ${totalSanctionedOwnership}%`,
|
|
3134
|
+
ownershipPercentage: totalSanctionedOwnership,
|
|
3135
|
+
source: "OFAC 50% Rule: Entity is considered blocked due to aggregate sanctioned ownership >= 50%"
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
return flags;
|
|
3139
|
+
}
|
|
3140
|
+
// --------------------------------------------------------------------------
|
|
3141
|
+
// Transaction Pattern Analysis
|
|
3142
|
+
// --------------------------------------------------------------------------
|
|
3143
|
+
/**
|
|
3144
|
+
* Analyze a set of transactions for patterns associated with sanctions evasion.
|
|
3145
|
+
*
|
|
3146
|
+
* Detects:
|
|
3147
|
+
* - Mixing/tumbling indicators (interaction with known mixers)
|
|
3148
|
+
* - Chain-hopping patterns (rapid cross-chain movements)
|
|
3149
|
+
* - Structuring (amounts just below reporting thresholds)
|
|
3150
|
+
* - Rapid fund movement through intermediaries
|
|
3151
|
+
* - Peeling chain patterns
|
|
3152
|
+
*/
|
|
3153
|
+
analyzeTransactionPatterns(transactions) {
|
|
3154
|
+
const flags = [];
|
|
3155
|
+
if (transactions.length === 0) return flags;
|
|
3156
|
+
const mixerFlag = this.detectMixingPattern(transactions);
|
|
3157
|
+
if (mixerFlag) flags.push(mixerFlag);
|
|
3158
|
+
const chainHopFlag = this.detectChainHopping(transactions);
|
|
3159
|
+
if (chainHopFlag) flags.push(chainHopFlag);
|
|
3160
|
+
const structuringFlag = this.detectStructuring(transactions);
|
|
3161
|
+
if (structuringFlag) flags.push(structuringFlag);
|
|
3162
|
+
const rapidFlag = this.detectRapidMovement(transactions);
|
|
3163
|
+
if (rapidFlag) flags.push(rapidFlag);
|
|
3164
|
+
const peelingFlag = this.detectPeelingChain(transactions);
|
|
3165
|
+
if (peelingFlag) flags.push(peelingFlag);
|
|
3166
|
+
return flags;
|
|
3167
|
+
}
|
|
3168
|
+
detectMixingPattern(txs) {
|
|
3169
|
+
const evidence = [];
|
|
3170
|
+
for (const tx of txs) {
|
|
3171
|
+
const fromEntry = addressToEntry.get(tx.from.toLowerCase());
|
|
3172
|
+
const toEntry = addressToEntry.get(tx.to.toLowerCase());
|
|
3173
|
+
if (fromEntry?.entityType === "MIXER") {
|
|
3174
|
+
evidence.push(`Received funds from known mixer: ${fromEntry.entityName} (tx: ${tx.txHash})`);
|
|
3175
|
+
}
|
|
3176
|
+
if (toEntry?.entityType === "MIXER") {
|
|
3177
|
+
evidence.push(`Sent funds to known mixer: ${toEntry.entityName} (tx: ${tx.txHash})`);
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
if (evidence.length > 0) {
|
|
3181
|
+
return {
|
|
3182
|
+
pattern: "MIXING",
|
|
3183
|
+
description: "Transaction history includes interaction with known mixing/tumbling services",
|
|
3184
|
+
severity: "HIGH",
|
|
3185
|
+
evidence
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
return null;
|
|
3189
|
+
}
|
|
3190
|
+
detectChainHopping(txs) {
|
|
3191
|
+
const chainTimestamps = /* @__PURE__ */ new Map();
|
|
3192
|
+
for (const tx of txs) {
|
|
3193
|
+
const timestamps = chainTimestamps.get(tx.chain) ?? [];
|
|
3194
|
+
timestamps.push(new Date(tx.timestamp).getTime());
|
|
3195
|
+
chainTimestamps.set(tx.chain, timestamps);
|
|
3196
|
+
}
|
|
3197
|
+
if (chainTimestamps.size < 2) return null;
|
|
3198
|
+
const allTimestamps = [];
|
|
3199
|
+
for (const [chain, times] of chainTimestamps) {
|
|
3200
|
+
for (const time of times) {
|
|
3201
|
+
allTimestamps.push({ chain, time });
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
allTimestamps.sort((a, b) => a.time - b.time);
|
|
3205
|
+
const evidence = [];
|
|
3206
|
+
const RAPID_WINDOW_MS = 10 * 60 * 1e3;
|
|
3207
|
+
for (let i = 1; i < allTimestamps.length; i++) {
|
|
3208
|
+
const prev = allTimestamps[i - 1];
|
|
3209
|
+
const curr = allTimestamps[i];
|
|
3210
|
+
if (prev && curr && prev.chain !== curr.chain) {
|
|
3211
|
+
const gap = curr.time - prev.time;
|
|
3212
|
+
if (gap < RAPID_WINDOW_MS) {
|
|
3213
|
+
evidence.push(
|
|
3214
|
+
`Cross-chain hop: ${prev.chain} -> ${curr.chain} within ${Math.round(gap / 1e3)}s`
|
|
3215
|
+
);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
if (evidence.length >= 2) {
|
|
3220
|
+
return {
|
|
3221
|
+
pattern: "CHAIN_HOPPING",
|
|
3222
|
+
description: "Rapid cross-chain fund movements detected, a pattern associated with sanctions evasion",
|
|
3223
|
+
severity: "MEDIUM",
|
|
3224
|
+
evidence
|
|
3225
|
+
};
|
|
3226
|
+
}
|
|
3227
|
+
return null;
|
|
3228
|
+
}
|
|
3229
|
+
detectStructuring(txs) {
|
|
3230
|
+
const REPORTING_THRESHOLD2 = 1e4;
|
|
3231
|
+
const STRUCTURING_BAND_LOW = REPORTING_THRESHOLD2 * 0.8;
|
|
3232
|
+
const STRUCTURING_BAND_HIGH = REPORTING_THRESHOLD2;
|
|
3233
|
+
const structuredTxs = txs.filter(
|
|
3234
|
+
(tx) => tx.amount >= STRUCTURING_BAND_LOW && tx.amount < STRUCTURING_BAND_HIGH
|
|
3235
|
+
);
|
|
3236
|
+
if (structuredTxs.length >= 3) {
|
|
3237
|
+
const evidence = structuredTxs.map(
|
|
3238
|
+
(tx) => `$${tx.amount.toFixed(2)} on ${tx.chain} (tx: ${tx.txHash})`
|
|
3239
|
+
);
|
|
3240
|
+
return {
|
|
3241
|
+
pattern: "STRUCTURING",
|
|
3242
|
+
description: `${structuredTxs.length} transactions clustered just below the $${REPORTING_THRESHOLD2} reporting threshold, a pattern consistent with structuring`,
|
|
3243
|
+
severity: "HIGH",
|
|
3244
|
+
evidence
|
|
3245
|
+
};
|
|
3246
|
+
}
|
|
3247
|
+
return null;
|
|
3248
|
+
}
|
|
3249
|
+
detectRapidMovement(txs) {
|
|
3250
|
+
const sorted = [...txs].sort(
|
|
3251
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
3252
|
+
);
|
|
3253
|
+
const RAPID_THRESHOLD_MS = 60 * 1e3;
|
|
3254
|
+
const evidence = [];
|
|
3255
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
3256
|
+
const prev = sorted[i - 1];
|
|
3257
|
+
const curr = sorted[i];
|
|
3258
|
+
if (prev && curr) {
|
|
3259
|
+
const gap = new Date(curr.timestamp).getTime() - new Date(prev.timestamp).getTime();
|
|
3260
|
+
if (gap < RAPID_THRESHOLD_MS && gap >= 0) {
|
|
3261
|
+
evidence.push(
|
|
3262
|
+
`${curr.from} -> ${curr.to}: $${curr.amount} within ${Math.round(gap / 1e3)}s of previous tx`
|
|
3263
|
+
);
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
if (evidence.length >= 3) {
|
|
3268
|
+
return {
|
|
3269
|
+
pattern: "RAPID_MOVEMENT",
|
|
3270
|
+
description: "Rapid succession of fund movements detected, consistent with layering/obfuscation",
|
|
3271
|
+
severity: "MEDIUM",
|
|
3272
|
+
evidence
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
return null;
|
|
3276
|
+
}
|
|
3277
|
+
detectPeelingChain(txs) {
|
|
3278
|
+
const sorted = [...txs].sort(
|
|
3279
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
3280
|
+
);
|
|
3281
|
+
const evidence = [];
|
|
3282
|
+
let consecutiveDecreases = 0;
|
|
3283
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
3284
|
+
const prev = sorted[i - 1];
|
|
3285
|
+
const curr = sorted[i];
|
|
3286
|
+
if (!prev || !curr) continue;
|
|
3287
|
+
if (curr.amount < prev.amount && curr.amount > 0) {
|
|
3288
|
+
const peeled = prev.amount - curr.amount;
|
|
3289
|
+
if (peeled < prev.amount * 0.3) {
|
|
3290
|
+
consecutiveDecreases++;
|
|
3291
|
+
evidence.push(
|
|
3292
|
+
`Peel: $${prev.amount.toFixed(2)} -> $${curr.amount.toFixed(2)} (peeled $${peeled.toFixed(2)})`
|
|
3293
|
+
);
|
|
2190
3294
|
}
|
|
3295
|
+
} else {
|
|
3296
|
+
consecutiveDecreases = 0;
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
if (consecutiveDecreases >= 3) {
|
|
3300
|
+
return {
|
|
3301
|
+
pattern: "PEELING_CHAIN",
|
|
3302
|
+
description: "Peeling chain pattern detected: sequential transactions with decreasing amounts to different addresses",
|
|
3303
|
+
severity: "MEDIUM",
|
|
3304
|
+
evidence
|
|
3305
|
+
};
|
|
3306
|
+
}
|
|
3307
|
+
return null;
|
|
3308
|
+
}
|
|
3309
|
+
// --------------------------------------------------------------------------
|
|
3310
|
+
// Sanctions List Management
|
|
3311
|
+
// --------------------------------------------------------------------------
|
|
3312
|
+
/**
|
|
3313
|
+
* Add new sanctioned addresses to the database at runtime.
|
|
3314
|
+
* Returns the count of newly added addresses.
|
|
3315
|
+
*/
|
|
3316
|
+
addAddresses(entries) {
|
|
3317
|
+
let added = 0;
|
|
3318
|
+
for (const entry of entries) {
|
|
3319
|
+
const lower = entry.address.toLowerCase();
|
|
3320
|
+
if (!allAddressSet.has(lower)) {
|
|
3321
|
+
SANCTIONED_ADDRESS_DATABASE.push(entry);
|
|
3322
|
+
added++;
|
|
2191
3323
|
}
|
|
2192
3324
|
}
|
|
3325
|
+
if (added > 0) {
|
|
3326
|
+
rebuildIndexes();
|
|
3327
|
+
listMetadata = {
|
|
3328
|
+
...listMetadata,
|
|
3329
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3330
|
+
addressCount: SANCTIONED_ADDRESS_DATABASE.length,
|
|
3331
|
+
version: `runtime-update-${Date.now()}`
|
|
3332
|
+
};
|
|
3333
|
+
}
|
|
3334
|
+
return added;
|
|
3335
|
+
}
|
|
3336
|
+
/**
|
|
3337
|
+
* Add new entity names for fuzzy matching.
|
|
3338
|
+
*/
|
|
3339
|
+
addEntities(entities) {
|
|
3340
|
+
let added = 0;
|
|
3341
|
+
for (const entity of entities) {
|
|
3342
|
+
const exists = SANCTIONED_ENTITIES.some(
|
|
3343
|
+
(e) => e.name.toLowerCase() === entity.name.toLowerCase()
|
|
3344
|
+
);
|
|
3345
|
+
if (!exists) {
|
|
3346
|
+
SANCTIONED_ENTITIES.push(entity);
|
|
3347
|
+
added++;
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
if (added > 0) {
|
|
3351
|
+
listMetadata = {
|
|
3352
|
+
...listMetadata,
|
|
3353
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3354
|
+
entityCount: SANCTIONED_ENTITIES.length,
|
|
3355
|
+
version: `runtime-entity-update-${Date.now()}`
|
|
3356
|
+
};
|
|
3357
|
+
}
|
|
3358
|
+
return added;
|
|
3359
|
+
}
|
|
3360
|
+
/**
|
|
3361
|
+
* Get current sanctions list metadata.
|
|
3362
|
+
*/
|
|
3363
|
+
getListMetadata() {
|
|
3364
|
+
return { ...listMetadata };
|
|
3365
|
+
}
|
|
3366
|
+
/**
|
|
3367
|
+
* Get all active sanctioned addresses (non-delisted).
|
|
3368
|
+
*/
|
|
3369
|
+
getActiveSanctionedAddresses() {
|
|
3370
|
+
return [...activeAddressSet];
|
|
3371
|
+
}
|
|
3372
|
+
/**
|
|
3373
|
+
* Get all sanctioned addresses including delisted.
|
|
3374
|
+
*/
|
|
3375
|
+
getAllAddresses() {
|
|
3376
|
+
return [...allAddressSet];
|
|
3377
|
+
}
|
|
3378
|
+
/**
|
|
3379
|
+
* Get the count of active sanctioned addresses.
|
|
3380
|
+
*/
|
|
3381
|
+
getActiveAddressCount() {
|
|
3382
|
+
return activeAddressSet.size;
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* Get the count of all addresses (including delisted).
|
|
3386
|
+
*/
|
|
3387
|
+
getTotalAddressCount() {
|
|
3388
|
+
return allAddressSet.size;
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* Get all entity names in the database.
|
|
3392
|
+
*/
|
|
3393
|
+
getEntities() {
|
|
3394
|
+
return [...SANCTIONED_ENTITIES];
|
|
2193
3395
|
}
|
|
2194
3396
|
};
|
|
3397
|
+
function computeSimilarity(a, b) {
|
|
3398
|
+
if (a === b) return 1;
|
|
3399
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
3400
|
+
const maxLen = Math.max(a.length, b.length);
|
|
3401
|
+
const distance = levenshteinDistance(a, b);
|
|
3402
|
+
return 1 - distance / maxLen;
|
|
3403
|
+
}
|
|
3404
|
+
function levenshteinDistance(a, b) {
|
|
3405
|
+
const m = a.length;
|
|
3406
|
+
const n = b.length;
|
|
3407
|
+
const prev = new Array(n + 1);
|
|
3408
|
+
const curr = new Array(n + 1);
|
|
3409
|
+
for (let j = 0; j <= n; j++) {
|
|
3410
|
+
prev[j] = j;
|
|
3411
|
+
}
|
|
3412
|
+
for (let i = 1; i <= m; i++) {
|
|
3413
|
+
curr[0] = i;
|
|
3414
|
+
for (let j = 1; j <= n; j++) {
|
|
3415
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
3416
|
+
curr[j] = Math.min(
|
|
3417
|
+
(prev[j] ?? 0) + 1,
|
|
3418
|
+
// deletion
|
|
3419
|
+
(curr[j - 1] ?? 0) + 1,
|
|
3420
|
+
// insertion
|
|
3421
|
+
(prev[j - 1] ?? 0) + cost
|
|
3422
|
+
// substitution
|
|
3423
|
+
);
|
|
3424
|
+
}
|
|
3425
|
+
for (let j = 0; j <= n; j++) {
|
|
3426
|
+
prev[j] = curr[j] ?? 0;
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
return prev[n] ?? 0;
|
|
3430
|
+
}
|
|
3431
|
+
var ofacScreener = new OFACSanctionsScreener();
|
|
2195
3432
|
|
|
2196
3433
|
// src/integrations/usdc.ts
|
|
2197
3434
|
var USDC_CONTRACTS = {
|
|
@@ -2204,7 +3441,33 @@ var USDC_CONTRACTS = {
|
|
|
2204
3441
|
arc: "0xa0c0000000000000000000000000000000000001"
|
|
2205
3442
|
};
|
|
2206
3443
|
var SANCTIONED_ADDRESSES = [
|
|
2207
|
-
//
|
|
3444
|
+
// --- ACTIVELY SANCTIONED (SDN) ---
|
|
3445
|
+
// Lazarus Group / DPRK (Ronin Bridge hack)
|
|
3446
|
+
"0x098B716B8Aaf21512996dC57EB0615e2383E2f96",
|
|
3447
|
+
"0xa0e1c89Ef1a489c9C7dE96311eD5Ce5D32c20E4B",
|
|
3448
|
+
"0x3Cffd56B47B7b41c56258D9C7731ABaDc360E460",
|
|
3449
|
+
"0x53b6936513e738f44FB50d2b9476730C0Ab3Bfc1",
|
|
3450
|
+
// Lazarus Group - Harmony/Stake.com hacks
|
|
3451
|
+
"0x4F47Bc496083C727c5fbe3CE9CDf2B0f6496270c",
|
|
3452
|
+
"0x0836222F2B2B24A3F36f98668Ed8F0B38D1a872f",
|
|
3453
|
+
// DPRK Cyber Operations
|
|
3454
|
+
"0x7F367cC41522cE07553e823bf3be79A889DEbe1B",
|
|
3455
|
+
"0x01e2919679362dFBC9ee1644Ba9C6da6D6245BB1",
|
|
3456
|
+
"0xc455f7fd3e0e12afd51fba5c106909934d8a0e4a",
|
|
3457
|
+
// Garantex exchange (sanctioned April 2022)
|
|
3458
|
+
"0x6F1cA141A28907F78Ebaa64f83E4AE6038d3cbe7",
|
|
3459
|
+
"0x2f389cE8bD8ff92De3402FFCe4691d17fC4f6535",
|
|
3460
|
+
"0x19Aa5Fe80D33a56D56c78e82eA5E50E5d80b4Dff",
|
|
3461
|
+
// Blender.io (sanctioned May 2022)
|
|
3462
|
+
"0x23773E65ed146A459791799d01336DB287f25334",
|
|
3463
|
+
// Roman Semenov (Tornado Cash developer - REMAINS sanctioned)
|
|
3464
|
+
"0xdcbEfFBECcE100cCE9E4b153C4e15cB885643193",
|
|
3465
|
+
"0x931546D9e66836AbF687d2bc64B30407bAc8C568",
|
|
3466
|
+
"0x43fa21d92141BA9db43052492E0DeEE5aa5f0A93",
|
|
3467
|
+
// Zedcex / Zedxion (IRGC-linked, sanctioned June 2024)
|
|
3468
|
+
"0xaeAAc358560e11f52454D997AAFF2c5731B6f8a6",
|
|
3469
|
+
// --- DELISTED (formerly sanctioned, retained for backward compat) ---
|
|
3470
|
+
// Tornado Cash contracts (sanctioned Aug 2022, DELISTED March 21, 2025)
|
|
2208
3471
|
"0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b",
|
|
2209
3472
|
"0xd96f2B1c14Db8458374d9Aca76E26c3D18364307",
|
|
2210
3473
|
"0x4736dCf1b7A3d580672CcE6E7c65cd5cc9cFBfA9",
|
|
@@ -2222,17 +3485,8 @@ var SANCTIONED_ADDRESSES = [
|
|
|
2222
3485
|
// Tornado Cash Router
|
|
2223
3486
|
"0x8589427373D6D84E98730D7795D8f6f8731FDA16",
|
|
2224
3487
|
// Tornado Cash
|
|
2225
|
-
"0x722122dF12D4e14e13Ac3b6895a86e84145b6967"
|
|
2226
|
-
// Tornado Cash
|
|
2227
|
-
// Lazarus Group / North Korea (Ronin Bridge hack)
|
|
2228
|
-
"0x098B716B8Aaf21512996dC57EB0615e2383E2f96",
|
|
2229
|
-
"0xa0e1c89Ef1a489c9C7dE96311eD5Ce5D32c20E4B",
|
|
2230
|
-
"0x3Cffd56B47B7b41c56258D9C7731ABaDc360E460",
|
|
2231
|
-
"0x53b6936513e738f44FB50d2b9476730C0Ab3Bfc1",
|
|
2232
|
-
// Garantex exchange (sanctioned April 2022)
|
|
2233
|
-
"0x6F1cA141A28907F78Ebaa64f83E4AE6038d3cbe7",
|
|
2234
|
-
// Blender.io
|
|
2235
|
-
"0x23773E65ed146A459791799d01336DB287f25334"
|
|
3488
|
+
"0x722122dF12D4e14e13Ac3b6895a86e84145b6967"
|
|
3489
|
+
// Tornado Cash / Sinbad.io
|
|
2236
3490
|
];
|
|
2237
3491
|
var SANCTIONED_SET = new Set(
|
|
2238
3492
|
SANCTIONED_ADDRESSES.map((addr) => addr.toLowerCase())
|
|
@@ -2354,6 +3608,72 @@ var UsdcCompliance = class _UsdcCompliance {
|
|
|
2354
3608
|
static getSupportedChains() {
|
|
2355
3609
|
return Object.keys(USDC_CONTRACTS);
|
|
2356
3610
|
}
|
|
3611
|
+
/**
|
|
3612
|
+
* Add new sanctioned addresses at runtime.
|
|
3613
|
+
* Normalizes all addresses to lowercase for consistent matching.
|
|
3614
|
+
* Skips addresses that are already in the list.
|
|
3615
|
+
*
|
|
3616
|
+
* @param addresses - Array of Ethereum addresses to add
|
|
3617
|
+
* @returns The count of newly added addresses (excluding duplicates)
|
|
3618
|
+
*/
|
|
3619
|
+
static addSanctionedAddresses(addresses) {
|
|
3620
|
+
let added = 0;
|
|
3621
|
+
for (const addr of addresses) {
|
|
3622
|
+
const lower = addr.toLowerCase();
|
|
3623
|
+
if (!SANCTIONED_SET.has(lower)) {
|
|
3624
|
+
SANCTIONED_ADDRESSES.push(addr);
|
|
3625
|
+
SANCTIONED_SET.add(lower);
|
|
3626
|
+
added++;
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
return added;
|
|
3630
|
+
}
|
|
3631
|
+
/**
|
|
3632
|
+
* Replace the entire sanctioned addresses list at runtime.
|
|
3633
|
+
* Clears existing entries and rebuilds from the provided array.
|
|
3634
|
+
*
|
|
3635
|
+
* @param addresses - The new complete list of sanctioned addresses
|
|
3636
|
+
*/
|
|
3637
|
+
static replaceSanctionedAddresses(addresses) {
|
|
3638
|
+
SANCTIONED_ADDRESSES.length = 0;
|
|
3639
|
+
SANCTIONED_ADDRESSES.push(...addresses);
|
|
3640
|
+
SANCTIONED_SET.clear();
|
|
3641
|
+
for (const addr of addresses) {
|
|
3642
|
+
SANCTIONED_SET.add(addr.toLowerCase());
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
/**
|
|
3646
|
+
* Get the current number of addresses in the sanctions list.
|
|
3647
|
+
*
|
|
3648
|
+
* @returns The size of the current sanctions list
|
|
3649
|
+
*/
|
|
3650
|
+
static getSanctionsListSize() {
|
|
3651
|
+
return SANCTIONED_SET.size;
|
|
3652
|
+
}
|
|
3653
|
+
/**
|
|
3654
|
+
* Perform comprehensive OFAC sanctions screening using the advanced
|
|
3655
|
+
* OFACSanctionsScreener. This goes beyond simple address matching to
|
|
3656
|
+
* include jurisdictional screening, delisted address detection, and
|
|
3657
|
+
* entity metadata.
|
|
3658
|
+
*
|
|
3659
|
+
* @param address - The address to screen
|
|
3660
|
+
* @param context - Optional context for enhanced screening
|
|
3661
|
+
* @returns ComprehensiveSanctionsResult with full screening details
|
|
3662
|
+
*/
|
|
3663
|
+
static screenComprehensive(address, context) {
|
|
3664
|
+
return ofacScreener.screenAddress(address, context);
|
|
3665
|
+
}
|
|
3666
|
+
/**
|
|
3667
|
+
* Check if an address is actively sanctioned (non-delisted).
|
|
3668
|
+
* Unlike isSanctioned(), this excludes addresses that have been removed
|
|
3669
|
+
* from the SDN list (e.g., Tornado Cash contracts post-March 2025).
|
|
3670
|
+
*
|
|
3671
|
+
* @param address - The address to check
|
|
3672
|
+
* @returns true if the address is on an active sanctions list
|
|
3673
|
+
*/
|
|
3674
|
+
static isActivelySanctioned(address) {
|
|
3675
|
+
return ofacScreener.isActivelySanctioned(address);
|
|
3676
|
+
}
|
|
2357
3677
|
// --------------------------------------------------------------------------
|
|
2358
3678
|
// Individual compliance checks
|
|
2359
3679
|
// --------------------------------------------------------------------------
|
|
@@ -2493,6 +3813,648 @@ var UsdcCompliance = class _UsdcCompliance {
|
|
|
2493
3813
|
return recommendations;
|
|
2494
3814
|
}
|
|
2495
3815
|
};
|
|
3816
|
+
|
|
3817
|
+
// src/plans.ts
|
|
3818
|
+
var PLAN_LIMITS = {
|
|
3819
|
+
free: 2e4,
|
|
3820
|
+
pro: 1e5,
|
|
3821
|
+
// per user/seat
|
|
3822
|
+
enterprise: Infinity
|
|
3823
|
+
};
|
|
3824
|
+
var DEFAULT_WARNING_THRESHOLD = 0.8;
|
|
3825
|
+
var THROTTLE_INTERVAL = 100;
|
|
3826
|
+
var PlanManager = class _PlanManager {
|
|
3827
|
+
tier;
|
|
3828
|
+
seats;
|
|
3829
|
+
eventCount;
|
|
3830
|
+
billingPeriodStart;
|
|
3831
|
+
warningThreshold;
|
|
3832
|
+
warningEmitted = false;
|
|
3833
|
+
limitEmitted = false;
|
|
3834
|
+
eventsSinceLimitWarning = 0;
|
|
3835
|
+
usageWarningCallbacks = [];
|
|
3836
|
+
limitReachedCallbacks = [];
|
|
3837
|
+
/** Custom upgrade URL, configurable via init */
|
|
3838
|
+
upgradeUrl = "https://kontext.so/upgrade";
|
|
3839
|
+
/** Enterprise contact URL */
|
|
3840
|
+
enterpriseContactUrl = "https://cal.com/vinnaray";
|
|
3841
|
+
constructor(tier = "free", billingPeriodStart, seats = 1) {
|
|
3842
|
+
this.tier = tier;
|
|
3843
|
+
this.seats = Math.max(1, Math.floor(seats));
|
|
3844
|
+
this.eventCount = 0;
|
|
3845
|
+
this.warningThreshold = DEFAULT_WARNING_THRESHOLD;
|
|
3846
|
+
this.billingPeriodStart = billingPeriodStart ?? _PlanManager.defaultBillingPeriodStart();
|
|
3847
|
+
}
|
|
3848
|
+
/**
|
|
3849
|
+
* Returns the 1st of the current month at midnight UTC.
|
|
3850
|
+
*/
|
|
3851
|
+
static defaultBillingPeriodStart() {
|
|
3852
|
+
const now2 = /* @__PURE__ */ new Date();
|
|
3853
|
+
return new Date(Date.UTC(now2.getUTCFullYear(), now2.getUTCMonth(), 1));
|
|
3854
|
+
}
|
|
3855
|
+
// --------------------------------------------------------------------------
|
|
3856
|
+
// Plan Info
|
|
3857
|
+
// --------------------------------------------------------------------------
|
|
3858
|
+
/** Get limits for a specific tier */
|
|
3859
|
+
static getPlanLimits(tier) {
|
|
3860
|
+
return { tier, eventLimit: PLAN_LIMITS[tier] };
|
|
3861
|
+
}
|
|
3862
|
+
/** Get the current plan tier */
|
|
3863
|
+
getTier() {
|
|
3864
|
+
return this.tier;
|
|
3865
|
+
}
|
|
3866
|
+
/** Get the event limit for the current plan (Pro is multiplied by seats) */
|
|
3867
|
+
getLimit() {
|
|
3868
|
+
const base = PLAN_LIMITS[this.tier];
|
|
3869
|
+
if (base === Infinity) return Infinity;
|
|
3870
|
+
if (this.tier === "pro") return base * this.seats;
|
|
3871
|
+
return base;
|
|
3872
|
+
}
|
|
3873
|
+
/** Get the current number of seats */
|
|
3874
|
+
getSeats() {
|
|
3875
|
+
return this.seats;
|
|
3876
|
+
}
|
|
3877
|
+
/** Update the number of seats (e.g., after Stripe subscription update) */
|
|
3878
|
+
setSeats(seats) {
|
|
3879
|
+
this.seats = Math.max(1, Math.floor(seats));
|
|
3880
|
+
this.warningEmitted = false;
|
|
3881
|
+
this.limitEmitted = false;
|
|
3882
|
+
this.eventsSinceLimitWarning = 0;
|
|
3883
|
+
}
|
|
3884
|
+
/** Get the current event count */
|
|
3885
|
+
getEventCount() {
|
|
3886
|
+
return this.eventCount;
|
|
3887
|
+
}
|
|
3888
|
+
/** Get the number of remaining events before the limit */
|
|
3889
|
+
getRemainingEvents() {
|
|
3890
|
+
const limit = this.getLimit();
|
|
3891
|
+
if (limit === Infinity) return Infinity;
|
|
3892
|
+
return Math.max(0, limit - this.eventCount);
|
|
3893
|
+
}
|
|
3894
|
+
/** Get the current usage as a percentage (0-100) */
|
|
3895
|
+
getUsagePercentage() {
|
|
3896
|
+
const limit = this.getLimit();
|
|
3897
|
+
if (limit === Infinity) return 0;
|
|
3898
|
+
return Math.min(100, this.eventCount / limit * 100);
|
|
3899
|
+
}
|
|
3900
|
+
/** Whether the event limit has been exceeded */
|
|
3901
|
+
isLimitExceeded() {
|
|
3902
|
+
const limit = this.getLimit();
|
|
3903
|
+
if (limit === Infinity) return false;
|
|
3904
|
+
return this.eventCount >= limit;
|
|
3905
|
+
}
|
|
3906
|
+
/** Get full usage object */
|
|
3907
|
+
getUsage() {
|
|
3908
|
+
return {
|
|
3909
|
+
plan: this.tier,
|
|
3910
|
+
seats: this.seats,
|
|
3911
|
+
eventCount: this.eventCount,
|
|
3912
|
+
limit: this.getLimit(),
|
|
3913
|
+
remainingEvents: this.getRemainingEvents(),
|
|
3914
|
+
usagePercentage: this.getUsagePercentage(),
|
|
3915
|
+
limitExceeded: this.isLimitExceeded()
|
|
3916
|
+
};
|
|
3917
|
+
}
|
|
3918
|
+
/** Get the billing period start date */
|
|
3919
|
+
getBillingPeriodStart() {
|
|
3920
|
+
return new Date(this.billingPeriodStart.getTime());
|
|
3921
|
+
}
|
|
3922
|
+
// --------------------------------------------------------------------------
|
|
3923
|
+
// Plan Management
|
|
3924
|
+
// --------------------------------------------------------------------------
|
|
3925
|
+
/**
|
|
3926
|
+
* Change the plan tier at runtime (e.g., after Stripe checkout succeeds).
|
|
3927
|
+
* This resets limit-related warning state since the new tier has different limits.
|
|
3928
|
+
*/
|
|
3929
|
+
setPlan(tier) {
|
|
3930
|
+
this.tier = tier;
|
|
3931
|
+
this.warningEmitted = false;
|
|
3932
|
+
this.limitEmitted = false;
|
|
3933
|
+
this.eventsSinceLimitWarning = 0;
|
|
3934
|
+
}
|
|
3935
|
+
/**
|
|
3936
|
+
* Reset the event count for a new billing period.
|
|
3937
|
+
* Updates the billing period start to the given date or 1st of current month.
|
|
3938
|
+
*/
|
|
3939
|
+
resetBillingPeriod(newStart) {
|
|
3940
|
+
this.eventCount = 0;
|
|
3941
|
+
this.billingPeriodStart = newStart ?? _PlanManager.defaultBillingPeriodStart();
|
|
3942
|
+
this.warningEmitted = false;
|
|
3943
|
+
this.limitEmitted = false;
|
|
3944
|
+
this.eventsSinceLimitWarning = 0;
|
|
3945
|
+
}
|
|
3946
|
+
/**
|
|
3947
|
+
* Check if the billing period should be reset (i.e., current date is past
|
|
3948
|
+
* the start of the next billing period). If so, auto-reset.
|
|
3949
|
+
*/
|
|
3950
|
+
checkBillingPeriodReset() {
|
|
3951
|
+
const now2 = /* @__PURE__ */ new Date();
|
|
3952
|
+
const nextPeriodStart = new Date(
|
|
3953
|
+
Date.UTC(
|
|
3954
|
+
this.billingPeriodStart.getUTCFullYear(),
|
|
3955
|
+
this.billingPeriodStart.getUTCMonth() + 1,
|
|
3956
|
+
1
|
|
3957
|
+
)
|
|
3958
|
+
);
|
|
3959
|
+
if (now2 >= nextPeriodStart) {
|
|
3960
|
+
this.resetBillingPeriod(
|
|
3961
|
+
new Date(Date.UTC(now2.getUTCFullYear(), now2.getUTCMonth(), 1))
|
|
3962
|
+
);
|
|
3963
|
+
return true;
|
|
3964
|
+
}
|
|
3965
|
+
return false;
|
|
3966
|
+
}
|
|
3967
|
+
// --------------------------------------------------------------------------
|
|
3968
|
+
// Event Tracking
|
|
3969
|
+
// --------------------------------------------------------------------------
|
|
3970
|
+
/**
|
|
3971
|
+
* Record an event and check thresholds.
|
|
3972
|
+
* Returns true if the event limit has been exceeded (soft limit).
|
|
3973
|
+
*/
|
|
3974
|
+
recordEvent() {
|
|
3975
|
+
this.checkBillingPeriodReset();
|
|
3976
|
+
this.eventCount++;
|
|
3977
|
+
const limit = this.getLimit();
|
|
3978
|
+
if (limit === Infinity) return false;
|
|
3979
|
+
const usagePercentage = this.getUsagePercentage();
|
|
3980
|
+
if (!this.warningEmitted && usagePercentage >= this.warningThreshold * 100) {
|
|
3981
|
+
this.warningEmitted = true;
|
|
3982
|
+
const event = this.createLimitEvent("warning");
|
|
3983
|
+
for (const cb of this.usageWarningCallbacks) {
|
|
3984
|
+
cb(event);
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
if (this.eventCount >= limit) {
|
|
3988
|
+
if (!this.limitEmitted) {
|
|
3989
|
+
this.limitEmitted = true;
|
|
3990
|
+
this.eventsSinceLimitWarning = 0;
|
|
3991
|
+
const event = this.createLimitEvent("limit_reached");
|
|
3992
|
+
for (const cb of this.limitReachedCallbacks) {
|
|
3993
|
+
cb(event);
|
|
3994
|
+
}
|
|
3995
|
+
this.logLimitMessage();
|
|
3996
|
+
} else {
|
|
3997
|
+
this.eventsSinceLimitWarning++;
|
|
3998
|
+
if (this.eventsSinceLimitWarning >= THROTTLE_INTERVAL) {
|
|
3999
|
+
this.eventsSinceLimitWarning = 0;
|
|
4000
|
+
const event = this.createLimitEvent("limit_reached");
|
|
4001
|
+
for (const cb of this.limitReachedCallbacks) {
|
|
4002
|
+
cb(event);
|
|
4003
|
+
}
|
|
4004
|
+
this.logLimitMessage();
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
return true;
|
|
4008
|
+
}
|
|
4009
|
+
return false;
|
|
4010
|
+
}
|
|
4011
|
+
// --------------------------------------------------------------------------
|
|
4012
|
+
// Callbacks
|
|
4013
|
+
// --------------------------------------------------------------------------
|
|
4014
|
+
/**
|
|
4015
|
+
* Register a callback for the 80% usage warning.
|
|
4016
|
+
* @returns Unsubscribe function
|
|
4017
|
+
*/
|
|
4018
|
+
onUsageWarning(callback) {
|
|
4019
|
+
this.usageWarningCallbacks.push(callback);
|
|
4020
|
+
return () => {
|
|
4021
|
+
const idx = this.usageWarningCallbacks.indexOf(callback);
|
|
4022
|
+
if (idx >= 0) this.usageWarningCallbacks.splice(idx, 1);
|
|
4023
|
+
};
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Register a callback for the limit reached event.
|
|
4027
|
+
* @returns Unsubscribe function
|
|
4028
|
+
*/
|
|
4029
|
+
onLimitReached(callback) {
|
|
4030
|
+
this.limitReachedCallbacks.push(callback);
|
|
4031
|
+
return () => {
|
|
4032
|
+
const idx = this.limitReachedCallbacks.indexOf(callback);
|
|
4033
|
+
if (idx >= 0) this.limitReachedCallbacks.splice(idx, 1);
|
|
4034
|
+
};
|
|
4035
|
+
}
|
|
4036
|
+
// --------------------------------------------------------------------------
|
|
4037
|
+
// Persistence
|
|
4038
|
+
// --------------------------------------------------------------------------
|
|
4039
|
+
/** Serialize state for storage */
|
|
4040
|
+
toJSON() {
|
|
4041
|
+
return {
|
|
4042
|
+
tier: this.tier,
|
|
4043
|
+
seats: this.seats,
|
|
4044
|
+
eventCount: this.eventCount,
|
|
4045
|
+
billingPeriodStart: this.billingPeriodStart.toISOString()
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
/** Restore state from storage */
|
|
4049
|
+
static fromJSON(data) {
|
|
4050
|
+
const manager = new _PlanManager(data.tier, new Date(data.billingPeriodStart), data.seats ?? 1);
|
|
4051
|
+
manager.eventCount = data.eventCount;
|
|
4052
|
+
return manager;
|
|
4053
|
+
}
|
|
4054
|
+
/** Set the event count directly (for restoring from storage) */
|
|
4055
|
+
setEventCount(count) {
|
|
4056
|
+
this.eventCount = count;
|
|
4057
|
+
}
|
|
4058
|
+
// --------------------------------------------------------------------------
|
|
4059
|
+
// Private
|
|
4060
|
+
// --------------------------------------------------------------------------
|
|
4061
|
+
createLimitEvent(type) {
|
|
4062
|
+
const message = type === "warning" ? `You've used ${this.getUsagePercentage().toFixed(0)}% of your ${this.tier} plan event limit (${this.eventCount}/${this.getLimit()}).` : `You've reached the ${this.getLimit().toLocaleString()} event limit on the ${this.tier === "free" ? "Free" : "Pro"} plan.`;
|
|
4063
|
+
return {
|
|
4064
|
+
type,
|
|
4065
|
+
plan: this.tier,
|
|
4066
|
+
eventCount: this.eventCount,
|
|
4067
|
+
limit: this.getLimit(),
|
|
4068
|
+
usagePercentage: this.getUsagePercentage(),
|
|
4069
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4070
|
+
message
|
|
4071
|
+
};
|
|
4072
|
+
}
|
|
4073
|
+
logLimitMessage() {
|
|
4074
|
+
if (this.tier === "free") {
|
|
4075
|
+
console.warn(
|
|
4076
|
+
`You've reached the 20,000 event limit on the Free plan. Upgrade to Pro for 100K events/user/mo and full compliance features \u2192 ${this.upgradeUrl}`
|
|
4077
|
+
);
|
|
4078
|
+
} else if (this.tier === "pro") {
|
|
4079
|
+
const effectiveLimit = (1e5 * this.seats).toLocaleString();
|
|
4080
|
+
console.warn(
|
|
4081
|
+
`You've reached the ${effectiveLimit} event limit on Pro (${this.seats} seat${this.seats !== 1 ? "s" : ""}). Add seats or contact us for Enterprise pricing \u2192 ${this.enterpriseContactUrl}`
|
|
4082
|
+
);
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
};
|
|
4086
|
+
var NoopExporter = class {
|
|
4087
|
+
async export(_events) {
|
|
4088
|
+
return { success: true, exportedCount: 0 };
|
|
4089
|
+
}
|
|
4090
|
+
async flush() {
|
|
4091
|
+
}
|
|
4092
|
+
async shutdown() {
|
|
4093
|
+
}
|
|
4094
|
+
};
|
|
4095
|
+
var ConsoleExporter = class {
|
|
4096
|
+
prefix;
|
|
4097
|
+
constructor(options) {
|
|
4098
|
+
this.prefix = options?.prefix ?? "[Kontext Export]";
|
|
4099
|
+
}
|
|
4100
|
+
async export(events) {
|
|
4101
|
+
for (const event of events) {
|
|
4102
|
+
console.log(`${this.prefix} ${JSON.stringify(event)}`);
|
|
4103
|
+
}
|
|
4104
|
+
return { success: true, exportedCount: events.length };
|
|
4105
|
+
}
|
|
4106
|
+
async flush() {
|
|
4107
|
+
}
|
|
4108
|
+
async shutdown() {
|
|
4109
|
+
}
|
|
4110
|
+
};
|
|
4111
|
+
var JsonFileExporter = class {
|
|
4112
|
+
outputDir;
|
|
4113
|
+
buffer = [];
|
|
4114
|
+
bufferSize;
|
|
4115
|
+
constructor(options) {
|
|
4116
|
+
this.outputDir = path3__namespace.resolve(options?.outputDir ?? ".kontext/exports");
|
|
4117
|
+
this.bufferSize = options?.bufferSize ?? 1;
|
|
4118
|
+
}
|
|
4119
|
+
async export(events) {
|
|
4120
|
+
this.buffer.push(...events);
|
|
4121
|
+
if (this.buffer.length >= this.bufferSize) {
|
|
4122
|
+
await this.flush();
|
|
4123
|
+
}
|
|
4124
|
+
return { success: true, exportedCount: events.length };
|
|
4125
|
+
}
|
|
4126
|
+
async flush() {
|
|
4127
|
+
if (this.buffer.length === 0) return;
|
|
4128
|
+
const toWrite = [...this.buffer];
|
|
4129
|
+
this.buffer = [];
|
|
4130
|
+
try {
|
|
4131
|
+
fs3__namespace.mkdirSync(this.outputDir, { recursive: true });
|
|
4132
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
4133
|
+
const filePath = path3__namespace.join(this.outputDir, `events-${date}.jsonl`);
|
|
4134
|
+
const lines = toWrite.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
4135
|
+
fs3__namespace.appendFileSync(filePath, lines, "utf-8");
|
|
4136
|
+
} catch (error) {
|
|
4137
|
+
console.warn("[Kontext JsonFileExporter] Failed to write events:", error);
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
async shutdown() {
|
|
4141
|
+
await this.flush();
|
|
4142
|
+
}
|
|
4143
|
+
/** Get the output directory path. */
|
|
4144
|
+
getOutputDir() {
|
|
4145
|
+
return this.outputDir;
|
|
4146
|
+
}
|
|
4147
|
+
};
|
|
4148
|
+
var HttpExporter = class {
|
|
4149
|
+
endpoint;
|
|
4150
|
+
headers;
|
|
4151
|
+
batchSize;
|
|
4152
|
+
timeoutMs;
|
|
4153
|
+
buffer = [];
|
|
4154
|
+
constructor(options) {
|
|
4155
|
+
this.endpoint = options.endpoint;
|
|
4156
|
+
this.headers = {
|
|
4157
|
+
"Content-Type": "application/json",
|
|
4158
|
+
...options.headers
|
|
4159
|
+
};
|
|
4160
|
+
this.batchSize = options.batchSize ?? 50;
|
|
4161
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
4162
|
+
}
|
|
4163
|
+
async export(events) {
|
|
4164
|
+
this.buffer.push(...events);
|
|
4165
|
+
if (this.buffer.length >= this.batchSize) {
|
|
4166
|
+
await this.flush();
|
|
4167
|
+
}
|
|
4168
|
+
return { success: true, exportedCount: events.length };
|
|
4169
|
+
}
|
|
4170
|
+
async flush() {
|
|
4171
|
+
if (this.buffer.length === 0) return;
|
|
4172
|
+
const toSend = [...this.buffer];
|
|
4173
|
+
this.buffer = [];
|
|
4174
|
+
try {
|
|
4175
|
+
const response = await fetch(this.endpoint, {
|
|
4176
|
+
method: "POST",
|
|
4177
|
+
headers: this.headers,
|
|
4178
|
+
body: JSON.stringify({ events: toSend }),
|
|
4179
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
4180
|
+
});
|
|
4181
|
+
if (!response.ok) {
|
|
4182
|
+
console.warn(
|
|
4183
|
+
`[Kontext HttpExporter] Export failed with status ${response.status}`
|
|
4184
|
+
);
|
|
4185
|
+
}
|
|
4186
|
+
} catch (error) {
|
|
4187
|
+
console.warn("[Kontext HttpExporter] Export failed:", error);
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
async shutdown() {
|
|
4191
|
+
await this.flush();
|
|
4192
|
+
}
|
|
4193
|
+
};
|
|
4194
|
+
var KontextCloudExporter = class {
|
|
4195
|
+
apiKey;
|
|
4196
|
+
projectId;
|
|
4197
|
+
apiUrl;
|
|
4198
|
+
batchSize;
|
|
4199
|
+
timeoutMs;
|
|
4200
|
+
retryAttempts;
|
|
4201
|
+
retryDelayMs;
|
|
4202
|
+
buffer = [];
|
|
4203
|
+
constructor(options) {
|
|
4204
|
+
if (!options.apiKey) {
|
|
4205
|
+
throw new Error("KontextCloudExporter requires an API key (Pro or Enterprise plan)");
|
|
4206
|
+
}
|
|
4207
|
+
this.apiKey = options.apiKey;
|
|
4208
|
+
this.projectId = options.projectId;
|
|
4209
|
+
this.apiUrl = options.apiUrl ?? "https://api.getkontext.com";
|
|
4210
|
+
this.batchSize = options.batchSize ?? 100;
|
|
4211
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
4212
|
+
this.retryAttempts = options.retryAttempts ?? 3;
|
|
4213
|
+
this.retryDelayMs = options.retryDelayMs ?? 1e3;
|
|
4214
|
+
}
|
|
4215
|
+
async export(events) {
|
|
4216
|
+
this.buffer.push(...events);
|
|
4217
|
+
if (this.buffer.length >= this.batchSize) {
|
|
4218
|
+
await this.flush();
|
|
4219
|
+
}
|
|
4220
|
+
return { success: true, exportedCount: events.length };
|
|
4221
|
+
}
|
|
4222
|
+
async flush() {
|
|
4223
|
+
if (this.buffer.length === 0) return;
|
|
4224
|
+
const toSend = [...this.buffer];
|
|
4225
|
+
this.buffer = [];
|
|
4226
|
+
let lastError;
|
|
4227
|
+
for (let attempt = 0; attempt < this.retryAttempts; attempt++) {
|
|
4228
|
+
try {
|
|
4229
|
+
const response = await fetch(`${this.apiUrl}/v1/ingest`, {
|
|
4230
|
+
method: "POST",
|
|
4231
|
+
headers: {
|
|
4232
|
+
"Content-Type": "application/json",
|
|
4233
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
4234
|
+
"X-Project-Id": this.projectId,
|
|
4235
|
+
"X-SDK-Version": "0.1.0"
|
|
4236
|
+
},
|
|
4237
|
+
body: JSON.stringify({
|
|
4238
|
+
events: toSend,
|
|
4239
|
+
metadata: {
|
|
4240
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4241
|
+
batchSize: toSend.length
|
|
4242
|
+
}
|
|
4243
|
+
}),
|
|
4244
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
4245
|
+
});
|
|
4246
|
+
if (response.ok) {
|
|
4247
|
+
return;
|
|
4248
|
+
}
|
|
4249
|
+
if (response.status >= 400 && response.status < 500) {
|
|
4250
|
+
console.warn(
|
|
4251
|
+
`[Kontext Cloud] Export rejected (${response.status}): ${await response.text()}`
|
|
4252
|
+
);
|
|
4253
|
+
return;
|
|
4254
|
+
}
|
|
4255
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
4256
|
+
} catch (error) {
|
|
4257
|
+
lastError = error;
|
|
4258
|
+
}
|
|
4259
|
+
if (attempt < this.retryAttempts - 1) {
|
|
4260
|
+
await new Promise(
|
|
4261
|
+
(resolve3) => setTimeout(resolve3, this.retryDelayMs * Math.pow(2, attempt))
|
|
4262
|
+
);
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
console.warn(
|
|
4266
|
+
`[Kontext Cloud] Export failed after ${this.retryAttempts} attempts:`,
|
|
4267
|
+
lastError
|
|
4268
|
+
);
|
|
4269
|
+
}
|
|
4270
|
+
async shutdown() {
|
|
4271
|
+
await this.flush();
|
|
4272
|
+
}
|
|
4273
|
+
};
|
|
4274
|
+
var MultiExporter = class {
|
|
4275
|
+
exporters;
|
|
4276
|
+
constructor(exporters) {
|
|
4277
|
+
this.exporters = exporters;
|
|
4278
|
+
}
|
|
4279
|
+
async export(events) {
|
|
4280
|
+
const results = await Promise.allSettled(
|
|
4281
|
+
this.exporters.map((e) => e.export(events))
|
|
4282
|
+
);
|
|
4283
|
+
const failures = results.filter((r) => r.status === "rejected");
|
|
4284
|
+
if (failures.length > 0) {
|
|
4285
|
+
return {
|
|
4286
|
+
success: false,
|
|
4287
|
+
exportedCount: events.length,
|
|
4288
|
+
error: `${failures.length}/${this.exporters.length} exporters failed`
|
|
4289
|
+
};
|
|
4290
|
+
}
|
|
4291
|
+
return { success: true, exportedCount: events.length };
|
|
4292
|
+
}
|
|
4293
|
+
async flush() {
|
|
4294
|
+
await Promise.allSettled(this.exporters.map((e) => e.flush()));
|
|
4295
|
+
}
|
|
4296
|
+
async shutdown() {
|
|
4297
|
+
await Promise.allSettled(this.exporters.map((e) => e.shutdown()));
|
|
4298
|
+
}
|
|
4299
|
+
};
|
|
4300
|
+
|
|
4301
|
+
// src/feature-flags.ts
|
|
4302
|
+
var DEFAULT_CACHE_TTL_MS = 3e5;
|
|
4303
|
+
var FeatureFlagManager = class {
|
|
4304
|
+
config;
|
|
4305
|
+
cacheTtlMs;
|
|
4306
|
+
defaultValue;
|
|
4307
|
+
cache = /* @__PURE__ */ new Map();
|
|
4308
|
+
refreshInFlight = false;
|
|
4309
|
+
constructor(config) {
|
|
4310
|
+
this.config = config;
|
|
4311
|
+
this.cacheTtlMs = config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
4312
|
+
this.defaultValue = config.defaultValue ?? false;
|
|
4313
|
+
}
|
|
4314
|
+
// --------------------------------------------------------------------------
|
|
4315
|
+
// Public API
|
|
4316
|
+
// --------------------------------------------------------------------------
|
|
4317
|
+
/**
|
|
4318
|
+
* Initialize the manager by fetching all flags from Firestore.
|
|
4319
|
+
* Call this once at startup to warm the cache.
|
|
4320
|
+
*/
|
|
4321
|
+
async init() {
|
|
4322
|
+
await this.refresh();
|
|
4323
|
+
}
|
|
4324
|
+
/**
|
|
4325
|
+
* Check if a flag is enabled for the given environment and plan.
|
|
4326
|
+
* Always synchronous — reads from cache.
|
|
4327
|
+
* Triggers a background refresh when the cached entry is stale.
|
|
4328
|
+
*/
|
|
4329
|
+
isEnabled(flagName, environment, plan) {
|
|
4330
|
+
const env = environment ?? this.config.environment;
|
|
4331
|
+
const tier = plan ?? this.config.plan;
|
|
4332
|
+
const entry = this.cache.get(flagName);
|
|
4333
|
+
if (!entry) {
|
|
4334
|
+
this.triggerBackgroundRefresh();
|
|
4335
|
+
return this.defaultValue;
|
|
4336
|
+
}
|
|
4337
|
+
if (Date.now() > entry.expiresAt) {
|
|
4338
|
+
this.triggerBackgroundRefresh();
|
|
4339
|
+
}
|
|
4340
|
+
const envTargeting = entry.flag.targeting[env];
|
|
4341
|
+
if (!envTargeting) return this.defaultValue;
|
|
4342
|
+
return envTargeting[tier] ?? this.defaultValue;
|
|
4343
|
+
}
|
|
4344
|
+
/**
|
|
4345
|
+
* Get a single flag by name (from cache).
|
|
4346
|
+
*/
|
|
4347
|
+
getFlag(flagName) {
|
|
4348
|
+
return this.cache.get(flagName)?.flag;
|
|
4349
|
+
}
|
|
4350
|
+
/**
|
|
4351
|
+
* Get all cached flags.
|
|
4352
|
+
*/
|
|
4353
|
+
getAllFlags() {
|
|
4354
|
+
return Array.from(this.cache.values()).map((e) => e.flag);
|
|
4355
|
+
}
|
|
4356
|
+
/**
|
|
4357
|
+
* Force-refresh all flags from Firestore.
|
|
4358
|
+
*/
|
|
4359
|
+
async refresh() {
|
|
4360
|
+
try {
|
|
4361
|
+
const docs = await this.fetchAllFromFirestore();
|
|
4362
|
+
const now2 = Date.now();
|
|
4363
|
+
for (const doc of docs) {
|
|
4364
|
+
const flag = parseFirestoreDocument(doc);
|
|
4365
|
+
if (!flag) continue;
|
|
4366
|
+
if (this.config.scope && flag.scope !== "all" && flag.scope !== this.config.scope) {
|
|
4367
|
+
continue;
|
|
4368
|
+
}
|
|
4369
|
+
this.cache.set(flag.name, {
|
|
4370
|
+
flag,
|
|
4371
|
+
expiresAt: now2 + this.cacheTtlMs
|
|
4372
|
+
});
|
|
4373
|
+
}
|
|
4374
|
+
} catch {
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4377
|
+
// --------------------------------------------------------------------------
|
|
4378
|
+
// Firestore REST API
|
|
4379
|
+
// --------------------------------------------------------------------------
|
|
4380
|
+
get firestoreBaseUrl() {
|
|
4381
|
+
return `https://firestore.googleapis.com/v1/projects/${this.config.gcpProjectId}/databases/(default)/documents`;
|
|
4382
|
+
}
|
|
4383
|
+
async fetchAllFromFirestore() {
|
|
4384
|
+
const url = `${this.firestoreBaseUrl}/feature-flags`;
|
|
4385
|
+
const headers = {};
|
|
4386
|
+
if (this.config.accessToken) {
|
|
4387
|
+
headers["Authorization"] = `Bearer ${this.config.accessToken}`;
|
|
4388
|
+
}
|
|
4389
|
+
const res = await fetch(url, { headers });
|
|
4390
|
+
if (!res.ok) {
|
|
4391
|
+
throw new Error(`Firestore fetch failed: ${res.status} ${res.statusText}`);
|
|
4392
|
+
}
|
|
4393
|
+
const body = await res.json();
|
|
4394
|
+
return body.documents ?? [];
|
|
4395
|
+
}
|
|
4396
|
+
async fetchOneFromFirestore(flagName) {
|
|
4397
|
+
const url = `${this.firestoreBaseUrl}/feature-flags/${flagName}`;
|
|
4398
|
+
const headers = {};
|
|
4399
|
+
if (this.config.accessToken) {
|
|
4400
|
+
headers["Authorization"] = `Bearer ${this.config.accessToken}`;
|
|
4401
|
+
}
|
|
4402
|
+
const res = await fetch(url, { headers });
|
|
4403
|
+
if (res.status === 404) return null;
|
|
4404
|
+
if (!res.ok) {
|
|
4405
|
+
throw new Error(`Firestore fetch failed: ${res.status} ${res.statusText}`);
|
|
4406
|
+
}
|
|
4407
|
+
return await res.json();
|
|
4408
|
+
}
|
|
4409
|
+
// --------------------------------------------------------------------------
|
|
4410
|
+
// Background Refresh
|
|
4411
|
+
// --------------------------------------------------------------------------
|
|
4412
|
+
triggerBackgroundRefresh() {
|
|
4413
|
+
if (this.refreshInFlight) return;
|
|
4414
|
+
this.refreshInFlight = true;
|
|
4415
|
+
this.refresh().finally(() => {
|
|
4416
|
+
this.refreshInFlight = false;
|
|
4417
|
+
});
|
|
4418
|
+
}
|
|
4419
|
+
};
|
|
4420
|
+
function parseFirestoreDocument(doc) {
|
|
4421
|
+
try {
|
|
4422
|
+
const fields = doc.fields;
|
|
4423
|
+
const name = extractDocumentId(doc.name);
|
|
4424
|
+
const description = fields["description"]?.stringValue ?? "";
|
|
4425
|
+
const scope = fields["scope"]?.stringValue ?? "all";
|
|
4426
|
+
const createdBy = fields["createdBy"]?.stringValue ?? "";
|
|
4427
|
+
const createdAt = fields["createdAt"]?.stringValue ?? doc.createTime;
|
|
4428
|
+
const updatedAt = fields["updatedAt"]?.stringValue ?? doc.updateTime;
|
|
4429
|
+
const targetingMap = fields["targeting"]?.mapValue?.fields;
|
|
4430
|
+
if (!targetingMap) return null;
|
|
4431
|
+
const targeting = parseTargeting(targetingMap);
|
|
4432
|
+
if (!targeting) return null;
|
|
4433
|
+
return { name, description, scope, targeting, createdAt, updatedAt, createdBy };
|
|
4434
|
+
} catch {
|
|
4435
|
+
return null;
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
4438
|
+
function parseTargeting(fields) {
|
|
4439
|
+
const dev = parsePlanTargeting(fields["development"]?.mapValue?.fields);
|
|
4440
|
+
const staging = parsePlanTargeting(fields["staging"]?.mapValue?.fields);
|
|
4441
|
+
const prod = parsePlanTargeting(fields["production"]?.mapValue?.fields);
|
|
4442
|
+
if (!dev || !staging || !prod) return null;
|
|
4443
|
+
return { development: dev, staging, production: prod };
|
|
4444
|
+
}
|
|
4445
|
+
function parsePlanTargeting(fields) {
|
|
4446
|
+
if (!fields) return null;
|
|
4447
|
+
return {
|
|
4448
|
+
free: fields["free"]?.booleanValue ?? false,
|
|
4449
|
+
pro: fields["pro"]?.booleanValue ?? false,
|
|
4450
|
+
enterprise: fields["enterprise"]?.booleanValue ?? false
|
|
4451
|
+
};
|
|
4452
|
+
}
|
|
4453
|
+
function extractDocumentId(resourceName) {
|
|
4454
|
+
const parts = resourceName.split("/");
|
|
4455
|
+
return parts[parts.length - 1] ?? resourceName;
|
|
4456
|
+
}
|
|
4457
|
+
var PLAN_STORAGE_KEY = "kontext:plan";
|
|
2496
4458
|
var Kontext = class _Kontext {
|
|
2497
4459
|
config;
|
|
2498
4460
|
store;
|
|
@@ -2502,18 +4464,34 @@ var Kontext = class _Kontext {
|
|
|
2502
4464
|
trustScorer;
|
|
2503
4465
|
anomalyDetector;
|
|
2504
4466
|
mode;
|
|
4467
|
+
planManager;
|
|
4468
|
+
exporter;
|
|
4469
|
+
featureFlagManager;
|
|
2505
4470
|
constructor(config) {
|
|
2506
4471
|
this.config = config;
|
|
2507
4472
|
this.mode = config.apiKey ? "cloud" : "local";
|
|
2508
4473
|
this.store = new KontextStore();
|
|
4474
|
+
if (config.metadataSchema && typeof config.metadataSchema.parse !== "function") {
|
|
4475
|
+
throw new KontextError(
|
|
4476
|
+
"INITIALIZATION_ERROR" /* INITIALIZATION_ERROR */,
|
|
4477
|
+
"metadataSchema must have a parse() method"
|
|
4478
|
+
);
|
|
4479
|
+
}
|
|
2509
4480
|
if (config.storage) {
|
|
2510
4481
|
this.store.setStorageAdapter(config.storage);
|
|
2511
4482
|
}
|
|
4483
|
+
const planTier = config.plan ?? "free";
|
|
4484
|
+
this.planManager = new PlanManager(planTier, void 0, config.seats ?? 1);
|
|
4485
|
+
if (config.upgradeUrl) {
|
|
4486
|
+
this.planManager.upgradeUrl = config.upgradeUrl;
|
|
4487
|
+
}
|
|
4488
|
+
this.exporter = config.exporter ?? new NoopExporter();
|
|
2512
4489
|
this.logger = new ActionLogger(config, this.store);
|
|
2513
4490
|
this.taskManager = new TaskManager(config, this.store);
|
|
2514
4491
|
this.auditExporter = new AuditExporter(config, this.store);
|
|
2515
4492
|
this.trustScorer = new TrustScorer(config, this.store);
|
|
2516
4493
|
this.anomalyDetector = new AnomalyDetector(config, this.store);
|
|
4494
|
+
this.featureFlagManager = config.featureFlags ? new FeatureFlagManager(config.featureFlags) : null;
|
|
2517
4495
|
}
|
|
2518
4496
|
/**
|
|
2519
4497
|
* Initialize the Kontext SDK.
|
|
@@ -2585,6 +4563,24 @@ var Kontext = class _Kontext {
|
|
|
2585
4563
|
};
|
|
2586
4564
|
}
|
|
2587
4565
|
// --------------------------------------------------------------------------
|
|
4566
|
+
// Internal Helpers
|
|
4567
|
+
// --------------------------------------------------------------------------
|
|
4568
|
+
/**
|
|
4569
|
+
* Validate metadata against the configured schema, if any.
|
|
4570
|
+
* No-op when metadataSchema is not configured.
|
|
4571
|
+
*/
|
|
4572
|
+
validateMetadata(metadata) {
|
|
4573
|
+
if (!metadata || !this.config.metadataSchema) return;
|
|
4574
|
+
try {
|
|
4575
|
+
this.config.metadataSchema.parse(metadata);
|
|
4576
|
+
} catch (err) {
|
|
4577
|
+
throw new KontextError(
|
|
4578
|
+
"VALIDATION_ERROR" /* VALIDATION_ERROR */,
|
|
4579
|
+
`Metadata validation failed: ${err instanceof Error ? err.message : String(err)}`
|
|
4580
|
+
);
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
// --------------------------------------------------------------------------
|
|
2588
4584
|
// Action Logging
|
|
2589
4585
|
// --------------------------------------------------------------------------
|
|
2590
4586
|
/**
|
|
@@ -2594,10 +4590,17 @@ var Kontext = class _Kontext {
|
|
|
2594
4590
|
* @returns The created action log entry
|
|
2595
4591
|
*/
|
|
2596
4592
|
async log(input) {
|
|
4593
|
+
this.validateMetadata(input.metadata);
|
|
2597
4594
|
const action = await this.logger.log(input);
|
|
4595
|
+
const limitExceeded = this.planManager.recordEvent();
|
|
4596
|
+
if (limitExceeded) {
|
|
4597
|
+
action.metadata = { ...action.metadata, limitExceeded: true };
|
|
4598
|
+
}
|
|
2598
4599
|
if (this.anomalyDetector.isEnabled()) {
|
|
2599
4600
|
this.anomalyDetector.evaluateAction(action);
|
|
2600
4601
|
}
|
|
4602
|
+
this.exporter.export([action]).catch(() => {
|
|
4603
|
+
});
|
|
2601
4604
|
return action;
|
|
2602
4605
|
}
|
|
2603
4606
|
/**
|
|
@@ -2607,10 +4610,17 @@ var Kontext = class _Kontext {
|
|
|
2607
4610
|
* @returns The created transaction record
|
|
2608
4611
|
*/
|
|
2609
4612
|
async logTransaction(input) {
|
|
4613
|
+
this.validateMetadata(input.metadata);
|
|
2610
4614
|
const record = await this.logger.logTransaction(input);
|
|
4615
|
+
const limitExceeded = this.planManager.recordEvent();
|
|
4616
|
+
if (limitExceeded) {
|
|
4617
|
+
record.metadata = { ...record.metadata, limitExceeded: true };
|
|
4618
|
+
}
|
|
2611
4619
|
if (this.anomalyDetector.isEnabled()) {
|
|
2612
4620
|
this.anomalyDetector.evaluateTransaction(record);
|
|
2613
4621
|
}
|
|
4622
|
+
this.exporter.export([record]).catch(() => {
|
|
4623
|
+
});
|
|
2614
4624
|
return record;
|
|
2615
4625
|
}
|
|
2616
4626
|
/**
|
|
@@ -2629,6 +4639,7 @@ var Kontext = class _Kontext {
|
|
|
2629
4639
|
async flush() {
|
|
2630
4640
|
await this.logger.flush();
|
|
2631
4641
|
await this.store.flush();
|
|
4642
|
+
await this.exporter.flush();
|
|
2632
4643
|
}
|
|
2633
4644
|
/**
|
|
2634
4645
|
* Restore state from the attached storage adapter.
|
|
@@ -2648,6 +4659,7 @@ var Kontext = class _Kontext {
|
|
|
2648
4659
|
* @returns The created task
|
|
2649
4660
|
*/
|
|
2650
4661
|
async createTask(input) {
|
|
4662
|
+
this.validateMetadata(input.metadata);
|
|
2651
4663
|
return this.taskManager.createTask(input);
|
|
2652
4664
|
}
|
|
2653
4665
|
/**
|
|
@@ -3033,13 +5045,110 @@ var Kontext = class _Kontext {
|
|
|
3033
5045
|
};
|
|
3034
5046
|
const hash = crypto$1.createHash("sha256");
|
|
3035
5047
|
hash.update(JSON.stringify(certificateContent));
|
|
3036
|
-
const
|
|
5048
|
+
const contentHash = hash.digest("hex");
|
|
3037
5049
|
return {
|
|
3038
5050
|
...certificateContent,
|
|
3039
|
-
|
|
5051
|
+
contentHash
|
|
3040
5052
|
};
|
|
3041
5053
|
}
|
|
3042
5054
|
// --------------------------------------------------------------------------
|
|
5055
|
+
// Plan & Usage Metering
|
|
5056
|
+
// --------------------------------------------------------------------------
|
|
5057
|
+
/**
|
|
5058
|
+
* Get current usage statistics for the plan.
|
|
5059
|
+
*
|
|
5060
|
+
* @returns Usage data including plan, event count, limits, and whether the limit is exceeded
|
|
5061
|
+
*/
|
|
5062
|
+
getUsage() {
|
|
5063
|
+
return this.planManager.getUsage();
|
|
5064
|
+
}
|
|
5065
|
+
/**
|
|
5066
|
+
* Change the plan tier at runtime (e.g., after Stripe checkout succeeds).
|
|
5067
|
+
*
|
|
5068
|
+
* @param tier - The new plan tier
|
|
5069
|
+
*/
|
|
5070
|
+
setPlan(tier) {
|
|
5071
|
+
this.planManager.setPlan(tier);
|
|
5072
|
+
}
|
|
5073
|
+
/**
|
|
5074
|
+
* Register a callback for when usage reaches 80% of the plan limit.
|
|
5075
|
+
*
|
|
5076
|
+
* @param callback - Function to call with the limit event
|
|
5077
|
+
* @returns Unsubscribe function
|
|
5078
|
+
*/
|
|
5079
|
+
onUsageWarning(callback) {
|
|
5080
|
+
return this.planManager.onUsageWarning(callback);
|
|
5081
|
+
}
|
|
5082
|
+
/**
|
|
5083
|
+
* Register a callback for when the plan event limit is reached.
|
|
5084
|
+
*
|
|
5085
|
+
* @param callback - Function to call with the limit event
|
|
5086
|
+
* @returns Unsubscribe function
|
|
5087
|
+
*/
|
|
5088
|
+
onLimitReached(callback) {
|
|
5089
|
+
return this.planManager.onLimitReached(callback);
|
|
5090
|
+
}
|
|
5091
|
+
/**
|
|
5092
|
+
* Get the URL for upgrading to the Pro plan.
|
|
5093
|
+
*
|
|
5094
|
+
* @returns The upgrade URL (configurable via init)
|
|
5095
|
+
*/
|
|
5096
|
+
getUpgradeUrl() {
|
|
5097
|
+
return this.planManager.upgradeUrl;
|
|
5098
|
+
}
|
|
5099
|
+
/**
|
|
5100
|
+
* Get the URL for contacting the team about Enterprise pricing.
|
|
5101
|
+
*
|
|
5102
|
+
* @returns The enterprise contact URL
|
|
5103
|
+
*/
|
|
5104
|
+
getEnterpriseContactUrl() {
|
|
5105
|
+
return this.planManager.enterpriseContactUrl;
|
|
5106
|
+
}
|
|
5107
|
+
/**
|
|
5108
|
+
* Persist plan metering state to the storage adapter.
|
|
5109
|
+
* Call this alongside flush() to persist event counts across restarts.
|
|
5110
|
+
*/
|
|
5111
|
+
async flushPlanState() {
|
|
5112
|
+
const adapter = this.store.getStorageAdapter();
|
|
5113
|
+
if (!adapter) return;
|
|
5114
|
+
await adapter.save(PLAN_STORAGE_KEY, this.planManager.toJSON());
|
|
5115
|
+
}
|
|
5116
|
+
/**
|
|
5117
|
+
* Restore plan metering state from the storage adapter.
|
|
5118
|
+
* Call this after restore() to reload event counts.
|
|
5119
|
+
*/
|
|
5120
|
+
async restorePlanState() {
|
|
5121
|
+
const adapter = this.store.getStorageAdapter();
|
|
5122
|
+
if (!adapter) return;
|
|
5123
|
+
const data = await adapter.load(PLAN_STORAGE_KEY);
|
|
5124
|
+
if (data && typeof data === "object" && data.tier && typeof data.eventCount === "number") {
|
|
5125
|
+
this.planManager.setPlan(data.tier);
|
|
5126
|
+
this.planManager.setEventCount(data.eventCount);
|
|
5127
|
+
if (data.billingPeriodStart) {
|
|
5128
|
+
this.planManager.resetBillingPeriod(new Date(data.billingPeriodStart));
|
|
5129
|
+
this.planManager.setEventCount(data.eventCount);
|
|
5130
|
+
}
|
|
5131
|
+
}
|
|
5132
|
+
}
|
|
5133
|
+
// --------------------------------------------------------------------------
|
|
5134
|
+
// Feature Flags
|
|
5135
|
+
// --------------------------------------------------------------------------
|
|
5136
|
+
/**
|
|
5137
|
+
* Check if a feature flag is enabled for the current environment and plan.
|
|
5138
|
+
* Returns `false` if feature flags are not configured.
|
|
5139
|
+
* Always synchronous — reads from in-memory cache.
|
|
5140
|
+
*/
|
|
5141
|
+
isFeatureEnabled(flagName, environment, plan) {
|
|
5142
|
+
if (!this.featureFlagManager) return false;
|
|
5143
|
+
return this.featureFlagManager.isEnabled(flagName, environment, plan);
|
|
5144
|
+
}
|
|
5145
|
+
/**
|
|
5146
|
+
* Get the underlying FeatureFlagManager (or null if not configured).
|
|
5147
|
+
*/
|
|
5148
|
+
getFeatureFlagManager() {
|
|
5149
|
+
return this.featureFlagManager;
|
|
5150
|
+
}
|
|
5151
|
+
// --------------------------------------------------------------------------
|
|
3043
5152
|
// Lifecycle
|
|
3044
5153
|
// --------------------------------------------------------------------------
|
|
3045
5154
|
/**
|
|
@@ -3047,6 +5156,7 @@ var Kontext = class _Kontext {
|
|
|
3047
5156
|
*/
|
|
3048
5157
|
async destroy() {
|
|
3049
5158
|
await this.logger.destroy();
|
|
5159
|
+
await this.exporter.shutdown();
|
|
3050
5160
|
}
|
|
3051
5161
|
};
|
|
3052
5162
|
var MemoryStorage = class {
|
|
@@ -3074,20 +5184,20 @@ var MemoryStorage = class {
|
|
|
3074
5184
|
var FileStorage = class {
|
|
3075
5185
|
baseDir;
|
|
3076
5186
|
constructor(baseDir) {
|
|
3077
|
-
this.baseDir =
|
|
5187
|
+
this.baseDir = path3__namespace.resolve(baseDir);
|
|
3078
5188
|
}
|
|
3079
5189
|
async save(key, data) {
|
|
3080
|
-
|
|
5190
|
+
fs3__namespace.mkdirSync(this.baseDir, { recursive: true });
|
|
3081
5191
|
const filePath = this.keyToPath(key);
|
|
3082
|
-
const dir =
|
|
3083
|
-
|
|
3084
|
-
|
|
5192
|
+
const dir = path3__namespace.dirname(filePath);
|
|
5193
|
+
fs3__namespace.mkdirSync(dir, { recursive: true });
|
|
5194
|
+
fs3__namespace.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
3085
5195
|
}
|
|
3086
5196
|
async load(key) {
|
|
3087
5197
|
const filePath = this.keyToPath(key);
|
|
3088
|
-
if (!
|
|
5198
|
+
if (!fs3__namespace.existsSync(filePath)) return null;
|
|
3089
5199
|
try {
|
|
3090
|
-
const raw =
|
|
5200
|
+
const raw = fs3__namespace.readFileSync(filePath, "utf-8");
|
|
3091
5201
|
return JSON.parse(raw);
|
|
3092
5202
|
} catch {
|
|
3093
5203
|
return null;
|
|
@@ -3095,12 +5205,12 @@ var FileStorage = class {
|
|
|
3095
5205
|
}
|
|
3096
5206
|
async delete(key) {
|
|
3097
5207
|
const filePath = this.keyToPath(key);
|
|
3098
|
-
if (
|
|
3099
|
-
|
|
5208
|
+
if (fs3__namespace.existsSync(filePath)) {
|
|
5209
|
+
fs3__namespace.unlinkSync(filePath);
|
|
3100
5210
|
}
|
|
3101
5211
|
}
|
|
3102
5212
|
async list(prefix) {
|
|
3103
|
-
if (!
|
|
5213
|
+
if (!fs3__namespace.existsSync(this.baseDir)) return [];
|
|
3104
5214
|
return this.listRecursive(this.baseDir, prefix);
|
|
3105
5215
|
}
|
|
3106
5216
|
/** Get the base directory path. */
|
|
@@ -3112,18 +5222,18 @@ var FileStorage = class {
|
|
|
3112
5222
|
// --------------------------------------------------------------------------
|
|
3113
5223
|
keyToPath(key) {
|
|
3114
5224
|
const safeName = key.replace(/[<>"|?*]/g, "_");
|
|
3115
|
-
return
|
|
5225
|
+
return path3__namespace.join(this.baseDir, `${safeName}.json`);
|
|
3116
5226
|
}
|
|
3117
5227
|
pathToKey(filePath) {
|
|
3118
|
-
const relative2 =
|
|
5228
|
+
const relative2 = path3__namespace.relative(this.baseDir, filePath);
|
|
3119
5229
|
return relative2.replace(/\.json$/, "");
|
|
3120
5230
|
}
|
|
3121
5231
|
listRecursive(dir, prefix) {
|
|
3122
5232
|
const keys = [];
|
|
3123
|
-
if (!
|
|
3124
|
-
const entries =
|
|
5233
|
+
if (!fs3__namespace.existsSync(dir)) return keys;
|
|
5234
|
+
const entries = fs3__namespace.readdirSync(dir, { withFileTypes: true });
|
|
3125
5235
|
for (const entry of entries) {
|
|
3126
|
-
const fullPath =
|
|
5236
|
+
const fullPath = path3__namespace.join(dir, entry.name);
|
|
3127
5237
|
if (entry.isDirectory()) {
|
|
3128
5238
|
keys.push(...this.listRecursive(fullPath, prefix));
|
|
3129
5239
|
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
@@ -3707,8 +5817,8 @@ var LiveCircleAdapter = class {
|
|
|
3707
5817
|
constructor(apiKey) {
|
|
3708
5818
|
this.apiKey = apiKey;
|
|
3709
5819
|
}
|
|
3710
|
-
async request(method,
|
|
3711
|
-
const response = await fetch(`${this.baseUrl}${
|
|
5820
|
+
async request(method, path4, body) {
|
|
5821
|
+
const response = await fetch(`${this.baseUrl}${path4}`, {
|
|
3712
5822
|
method,
|
|
3713
5823
|
headers: {
|
|
3714
5824
|
"Content-Type": "application/json",
|
|
@@ -4197,8 +6307,8 @@ var LiveComplianceAdapter = class {
|
|
|
4197
6307
|
constructor(apiKey) {
|
|
4198
6308
|
this.apiKey = apiKey;
|
|
4199
6309
|
}
|
|
4200
|
-
async request(method,
|
|
4201
|
-
const response = await fetch(`${this.baseUrl}${
|
|
6310
|
+
async request(method, path4, body) {
|
|
6311
|
+
const response = await fetch(`${this.baseUrl}${path4}`, {
|
|
4202
6312
|
method,
|
|
4203
6313
|
headers: {
|
|
4204
6314
|
"Content-Type": "application/json",
|
|
@@ -4565,8 +6675,8 @@ var LiveGasStationAdapter = class {
|
|
|
4565
6675
|
constructor(apiKey) {
|
|
4566
6676
|
this.apiKey = apiKey;
|
|
4567
6677
|
}
|
|
4568
|
-
async request(method,
|
|
4569
|
-
const response = await fetch(`${this.baseUrl}${
|
|
6678
|
+
async request(method, path4, body) {
|
|
6679
|
+
const response = await fetch(`${this.baseUrl}${path4}`, {
|
|
4570
6680
|
method,
|
|
4571
6681
|
headers: {
|
|
4572
6682
|
"Content-Type": "application/json",
|
|
@@ -4708,8 +6818,6 @@ var GasStationManager = class {
|
|
|
4708
6818
|
return NATIVE_TOKENS[chain] ?? "ETH";
|
|
4709
6819
|
}
|
|
4710
6820
|
};
|
|
4711
|
-
|
|
4712
|
-
// src/webhooks.ts
|
|
4713
6821
|
var DEFAULT_RETRY_CONFIG = {
|
|
4714
6822
|
maxRetries: 3,
|
|
4715
6823
|
baseDelayMs: 1e3,
|
|
@@ -4774,15 +6882,25 @@ var WebhookManager = class {
|
|
|
4774
6882
|
}
|
|
4775
6883
|
/**
|
|
4776
6884
|
* Get all registered webhooks.
|
|
6885
|
+
* Secrets are redacted to prevent accidental exposure in logs or API responses.
|
|
4777
6886
|
*/
|
|
4778
6887
|
getWebhooks() {
|
|
4779
|
-
return Array.from(this.webhooks.values())
|
|
6888
|
+
return Array.from(this.webhooks.values()).map((w) => ({
|
|
6889
|
+
...w,
|
|
6890
|
+
secret: w.secret ? "***REDACTED***" : w.secret
|
|
6891
|
+
}));
|
|
4780
6892
|
}
|
|
4781
6893
|
/**
|
|
4782
6894
|
* Get a specific webhook by ID.
|
|
6895
|
+
* The secret is redacted to prevent accidental exposure in logs or API responses.
|
|
4783
6896
|
*/
|
|
4784
6897
|
getWebhook(webhookId) {
|
|
4785
|
-
|
|
6898
|
+
const webhook = this.webhooks.get(webhookId);
|
|
6899
|
+
if (!webhook) return void 0;
|
|
6900
|
+
return {
|
|
6901
|
+
...webhook,
|
|
6902
|
+
secret: webhook.secret ? "***REDACTED***" : webhook.secret
|
|
6903
|
+
};
|
|
4786
6904
|
}
|
|
4787
6905
|
/**
|
|
4788
6906
|
* Get delivery results for a specific webhook or all webhooks.
|
|
@@ -4949,227 +7067,223 @@ var WebhookManager = class {
|
|
|
4949
7067
|
};
|
|
4950
7068
|
}
|
|
4951
7069
|
async computeSignature(payload, secret) {
|
|
4952
|
-
const
|
|
4953
|
-
const hmac = createHmac("sha256", secret);
|
|
7070
|
+
const hmac = crypto$1.createHmac("sha256", secret);
|
|
4954
7071
|
hmac.update(JSON.stringify(payload));
|
|
4955
7072
|
return hmac.digest("hex");
|
|
4956
7073
|
}
|
|
4957
7074
|
sleep(ms) {
|
|
4958
|
-
return new Promise((
|
|
7075
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
7076
|
+
}
|
|
7077
|
+
/**
|
|
7078
|
+
* Verify a webhook signature using constant-time comparison to prevent
|
|
7079
|
+
* timing attacks. Use this in your webhook handler to validate incoming
|
|
7080
|
+
* payloads from Kontext.
|
|
7081
|
+
*
|
|
7082
|
+
* @param payload - The raw JSON payload body (string)
|
|
7083
|
+
* @param signature - The signature from the X-Kontext-Signature header
|
|
7084
|
+
* @param secret - The webhook secret used during registration
|
|
7085
|
+
* @returns Whether the signature is valid
|
|
7086
|
+
*
|
|
7087
|
+
* @example
|
|
7088
|
+
* ```typescript
|
|
7089
|
+
* const isValid = WebhookManager.verifySignature(
|
|
7090
|
+
* req.body, // raw JSON string
|
|
7091
|
+
* req.headers['x-kontext-signature'],
|
|
7092
|
+
* 'my-webhook-secret',
|
|
7093
|
+
* );
|
|
7094
|
+
* if (!isValid) return res.status(401).send('Invalid signature');
|
|
7095
|
+
* ```
|
|
7096
|
+
*/
|
|
7097
|
+
static verifySignature(payload, signature, secret) {
|
|
7098
|
+
const hmac = crypto$1.createHmac("sha256", secret);
|
|
7099
|
+
hmac.update(payload);
|
|
7100
|
+
const expected = hmac.digest("hex");
|
|
7101
|
+
if (expected.length !== signature.length) {
|
|
7102
|
+
return false;
|
|
7103
|
+
}
|
|
7104
|
+
return crypto$1.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
|
|
4959
7105
|
}
|
|
4960
7106
|
};
|
|
4961
7107
|
|
|
4962
7108
|
// src/integrations/vercel-ai.ts
|
|
4963
7109
|
function kontextMiddleware(kontext, options) {
|
|
4964
|
-
const
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
7110
|
+
const cfg = {
|
|
7111
|
+
agentId: options?.agentId ?? "vercel-ai",
|
|
7112
|
+
logToolArgs: options?.logToolArgs ?? false,
|
|
7113
|
+
financialTools: options?.financialTools ?? [],
|
|
7114
|
+
defaultCurrency: options?.defaultCurrency ?? "USDC",
|
|
7115
|
+
trustThreshold: options?.trustThreshold,
|
|
7116
|
+
onBlocked: options?.onBlocked
|
|
7117
|
+
};
|
|
4970
7118
|
return {
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
if (trustThreshold !== void 0 && financialTools.includes(toolCall.toolName)) {
|
|
5028
|
-
const trustScore = await kontext.getTrustScore(agentId);
|
|
5029
|
-
if (trustScore.score < trustThreshold) {
|
|
5030
|
-
if (onBlocked) {
|
|
5031
|
-
onBlocked({ toolName: toolCall.toolName, args: toolCall.args }, `Trust score ${trustScore.score} below threshold ${trustThreshold}`);
|
|
5032
|
-
}
|
|
5033
|
-
await kontext.log({
|
|
5034
|
-
type: "ai_tool_blocked",
|
|
5035
|
-
description: `Financial tool "${toolCall.toolName}" blocked: trust score ${trustScore.score} below threshold ${trustThreshold}`,
|
|
5036
|
-
agentId,
|
|
5037
|
-
metadata: {
|
|
5038
|
-
toolName: toolCall.toolName,
|
|
5039
|
-
trustScore: trustScore.score,
|
|
5040
|
-
threshold: trustThreshold
|
|
5041
|
-
}
|
|
5042
|
-
});
|
|
5043
|
-
continue;
|
|
5044
|
-
}
|
|
5045
|
-
}
|
|
5046
|
-
await kontext.log({
|
|
5047
|
-
type: "ai_tool_call",
|
|
5048
|
-
description: `Tool call: ${toolCall.toolName}`,
|
|
5049
|
-
agentId,
|
|
5050
|
-
metadata: {
|
|
5051
|
-
toolName: toolCall.toolName,
|
|
5052
|
-
args: logToolArgs ? toolCall.args : "[redacted]",
|
|
5053
|
-
duration,
|
|
5054
|
-
model: modelInfo?.modelId ?? "unknown"
|
|
5055
|
-
}
|
|
7119
|
+
transformParams: (ctx) => logTransformParams(kontext, cfg, ctx),
|
|
7120
|
+
wrapGenerate: (ctx) => wrapGenerateWithAudit(kontext, cfg, ctx),
|
|
7121
|
+
wrapStream: (ctx) => wrapStreamWithAudit(kontext, cfg, ctx)
|
|
7122
|
+
};
|
|
7123
|
+
}
|
|
7124
|
+
async function logTransformParams(kontext, cfg, { params, type }) {
|
|
7125
|
+
const modelInfo = params["model"];
|
|
7126
|
+
const tools = params["tools"];
|
|
7127
|
+
await kontext.log({
|
|
7128
|
+
type: `ai_${type}`,
|
|
7129
|
+
description: `AI ${type} request to ${modelInfo?.modelId ?? "unknown"} model`,
|
|
7130
|
+
agentId: cfg.agentId,
|
|
7131
|
+
metadata: {
|
|
7132
|
+
model: modelInfo?.modelId ?? "unknown",
|
|
7133
|
+
toolCount: Array.isArray(tools) ? tools.length : 0,
|
|
7134
|
+
maxTokens: params["maxTokens"] ?? null,
|
|
7135
|
+
temperature: params["temperature"] ?? null,
|
|
7136
|
+
operationType: type
|
|
7137
|
+
}
|
|
7138
|
+
});
|
|
7139
|
+
return params;
|
|
7140
|
+
}
|
|
7141
|
+
async function wrapGenerateWithAudit(kontext, cfg, { doGenerate, params }) {
|
|
7142
|
+
const startTime = Date.now();
|
|
7143
|
+
await enforceAgentTrustThreshold(kontext, cfg);
|
|
7144
|
+
const result = await doGenerate();
|
|
7145
|
+
const duration = Date.now() - startTime;
|
|
7146
|
+
const modelId = extractModelId(params);
|
|
7147
|
+
const toolCalls = result["toolCalls"];
|
|
7148
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
7149
|
+
for (const toolCall of toolCalls) {
|
|
7150
|
+
await processToolCall(kontext, cfg, toolCall, duration, modelId);
|
|
7151
|
+
}
|
|
7152
|
+
}
|
|
7153
|
+
await logGenerateCompletion(kontext, cfg, result, duration, modelId, toolCalls?.length ?? 0);
|
|
7154
|
+
return result;
|
|
7155
|
+
}
|
|
7156
|
+
async function wrapStreamWithAudit(kontext, cfg, { doStream, params }) {
|
|
7157
|
+
const startTime = Date.now();
|
|
7158
|
+
const modelId = extractModelId(params);
|
|
7159
|
+
await kontext.log({
|
|
7160
|
+
type: "ai_stream_start",
|
|
7161
|
+
description: `AI stream started for model ${modelId}`,
|
|
7162
|
+
agentId: cfg.agentId,
|
|
7163
|
+
metadata: { model: modelId, operationType: "stream" }
|
|
7164
|
+
});
|
|
7165
|
+
const { stream, ...rest } = await doStream();
|
|
7166
|
+
const toolCallsInStream = [];
|
|
7167
|
+
const transformedStream = stream.pipeThrough(
|
|
7168
|
+
new TransformStream({
|
|
7169
|
+
transform(chunk, controller) {
|
|
7170
|
+
controller.enqueue(chunk);
|
|
7171
|
+
if (chunk["type"] === "tool-call") {
|
|
7172
|
+
toolCallsInStream.push({
|
|
7173
|
+
toolName: chunk["toolName"],
|
|
7174
|
+
args: chunk["args"]
|
|
5056
7175
|
});
|
|
5057
|
-
if (financialTools.includes(toolCall.toolName)) {
|
|
5058
|
-
const amount = extractAmount(toolCall.args);
|
|
5059
|
-
if (amount !== null) {
|
|
5060
|
-
await kontext.log({
|
|
5061
|
-
type: "ai_financial_tool_call",
|
|
5062
|
-
description: `Financial tool "${toolCall.toolName}" invoked with amount ${amount} ${defaultCurrency}`,
|
|
5063
|
-
agentId,
|
|
5064
|
-
metadata: {
|
|
5065
|
-
toolName: toolCall.toolName,
|
|
5066
|
-
amount: amount.toString(),
|
|
5067
|
-
currency: defaultCurrency,
|
|
5068
|
-
toolArgs: logToolArgs ? toolCall.args : "[redacted]"
|
|
5069
|
-
}
|
|
5070
|
-
});
|
|
5071
|
-
}
|
|
5072
|
-
}
|
|
5073
7176
|
}
|
|
7177
|
+
},
|
|
7178
|
+
async flush() {
|
|
7179
|
+
const duration = Date.now() - startTime;
|
|
7180
|
+
await logStreamToolCalls(kontext, cfg, toolCallsInStream, duration, modelId);
|
|
7181
|
+
await kontext.log({
|
|
7182
|
+
type: "ai_stream_complete",
|
|
7183
|
+
description: `AI stream completed in ${duration}ms with ${toolCallsInStream.length} tool call(s)`,
|
|
7184
|
+
agentId: cfg.agentId,
|
|
7185
|
+
metadata: { duration, toolCallCount: toolCallsInStream.length, model: modelId }
|
|
7186
|
+
});
|
|
5074
7187
|
}
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
}
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
const startTime = Date.now();
|
|
5103
|
-
const modelInfo = params["model"];
|
|
7188
|
+
})
|
|
7189
|
+
);
|
|
7190
|
+
return { stream: transformedStream, ...rest };
|
|
7191
|
+
}
|
|
7192
|
+
function extractModelId(params) {
|
|
7193
|
+
return params["model"]?.modelId ?? "unknown";
|
|
7194
|
+
}
|
|
7195
|
+
async function enforceAgentTrustThreshold(kontext, cfg) {
|
|
7196
|
+
if (cfg.trustThreshold === void 0) return;
|
|
7197
|
+
const trustScore = await kontext.getTrustScore(cfg.agentId);
|
|
7198
|
+
if (trustScore.score < cfg.trustThreshold) {
|
|
7199
|
+
await kontext.log({
|
|
7200
|
+
type: "ai_blocked",
|
|
7201
|
+
description: `AI generation blocked: agent trust score ${trustScore.score} below threshold ${cfg.trustThreshold}`,
|
|
7202
|
+
agentId: cfg.agentId,
|
|
7203
|
+
metadata: { trustScore: trustScore.score, trustLevel: trustScore.level, threshold: cfg.trustThreshold }
|
|
7204
|
+
});
|
|
7205
|
+
throw new Error(
|
|
7206
|
+
`Kontext: AI generation blocked. Agent "${cfg.agentId}" trust score (${trustScore.score}) is below the required threshold (${cfg.trustThreshold}).`
|
|
7207
|
+
);
|
|
7208
|
+
}
|
|
7209
|
+
}
|
|
7210
|
+
async function processToolCall(kontext, cfg, toolCall, duration, modelId) {
|
|
7211
|
+
if (cfg.trustThreshold !== void 0 && cfg.financialTools.includes(toolCall.toolName)) {
|
|
7212
|
+
const trustScore = await kontext.getTrustScore(cfg.agentId);
|
|
7213
|
+
if (trustScore.score < cfg.trustThreshold) {
|
|
7214
|
+
cfg.onBlocked?.({ toolName: toolCall.toolName, args: toolCall.args }, `Trust score ${trustScore.score} below threshold ${cfg.trustThreshold}`);
|
|
5104
7215
|
await kontext.log({
|
|
5105
|
-
type: "
|
|
5106
|
-
description: `
|
|
5107
|
-
agentId,
|
|
5108
|
-
metadata: {
|
|
5109
|
-
model: modelInfo?.modelId ?? "unknown",
|
|
5110
|
-
operationType: "stream"
|
|
5111
|
-
}
|
|
7216
|
+
type: "ai_tool_blocked",
|
|
7217
|
+
description: `Financial tool "${toolCall.toolName}" blocked: trust score ${trustScore.score} below threshold ${cfg.trustThreshold}`,
|
|
7218
|
+
agentId: cfg.agentId,
|
|
7219
|
+
metadata: { toolName: toolCall.toolName, trustScore: trustScore.score, threshold: cfg.trustThreshold }
|
|
5112
7220
|
});
|
|
5113
|
-
|
|
5114
|
-
const toolCallsInStream = [];
|
|
5115
|
-
const transformedStream = stream.pipeThrough(
|
|
5116
|
-
new TransformStream({
|
|
5117
|
-
transform(chunk, controller) {
|
|
5118
|
-
controller.enqueue(chunk);
|
|
5119
|
-
if (chunk["type"] === "tool-call") {
|
|
5120
|
-
const toolName = chunk["toolName"];
|
|
5121
|
-
const args = chunk["args"];
|
|
5122
|
-
toolCallsInStream.push({ toolName, args });
|
|
5123
|
-
}
|
|
5124
|
-
},
|
|
5125
|
-
async flush() {
|
|
5126
|
-
const duration = Date.now() - startTime;
|
|
5127
|
-
for (const toolCall of toolCallsInStream) {
|
|
5128
|
-
await kontext.log({
|
|
5129
|
-
type: "ai_tool_call",
|
|
5130
|
-
description: `Tool call (stream): ${toolCall.toolName}`,
|
|
5131
|
-
agentId,
|
|
5132
|
-
metadata: {
|
|
5133
|
-
toolName: toolCall.toolName,
|
|
5134
|
-
args: logToolArgs ? toolCall.args : "[redacted]",
|
|
5135
|
-
duration,
|
|
5136
|
-
model: modelInfo?.modelId ?? "unknown",
|
|
5137
|
-
source: "stream"
|
|
5138
|
-
}
|
|
5139
|
-
});
|
|
5140
|
-
if (financialTools.includes(toolCall.toolName)) {
|
|
5141
|
-
const amount = extractAmount(toolCall.args);
|
|
5142
|
-
if (amount !== null) {
|
|
5143
|
-
await kontext.log({
|
|
5144
|
-
type: "ai_financial_tool_call",
|
|
5145
|
-
description: `Financial tool "${toolCall.toolName}" invoked via stream with amount ${amount} ${defaultCurrency}`,
|
|
5146
|
-
agentId,
|
|
5147
|
-
metadata: {
|
|
5148
|
-
toolName: toolCall.toolName,
|
|
5149
|
-
amount: amount.toString(),
|
|
5150
|
-
currency: defaultCurrency,
|
|
5151
|
-
source: "stream"
|
|
5152
|
-
}
|
|
5153
|
-
});
|
|
5154
|
-
}
|
|
5155
|
-
}
|
|
5156
|
-
}
|
|
5157
|
-
await kontext.log({
|
|
5158
|
-
type: "ai_stream_complete",
|
|
5159
|
-
description: `AI stream completed in ${duration}ms with ${toolCallsInStream.length} tool call(s)`,
|
|
5160
|
-
agentId,
|
|
5161
|
-
metadata: {
|
|
5162
|
-
duration,
|
|
5163
|
-
toolCallCount: toolCallsInStream.length,
|
|
5164
|
-
model: modelInfo?.modelId ?? "unknown"
|
|
5165
|
-
}
|
|
5166
|
-
});
|
|
5167
|
-
}
|
|
5168
|
-
})
|
|
5169
|
-
);
|
|
5170
|
-
return { stream: transformedStream, ...rest };
|
|
7221
|
+
return;
|
|
5171
7222
|
}
|
|
5172
|
-
}
|
|
7223
|
+
}
|
|
7224
|
+
await kontext.log({
|
|
7225
|
+
type: "ai_tool_call",
|
|
7226
|
+
description: `Tool call: ${toolCall.toolName}`,
|
|
7227
|
+
agentId: cfg.agentId,
|
|
7228
|
+
metadata: {
|
|
7229
|
+
toolName: toolCall.toolName,
|
|
7230
|
+
args: cfg.logToolArgs ? toolCall.args : "[redacted]",
|
|
7231
|
+
duration,
|
|
7232
|
+
model: modelId
|
|
7233
|
+
}
|
|
7234
|
+
});
|
|
7235
|
+
await logFinancialToolCall(kontext, cfg, toolCall);
|
|
7236
|
+
}
|
|
7237
|
+
async function logFinancialToolCall(kontext, cfg, toolCall, source) {
|
|
7238
|
+
if (!cfg.financialTools.includes(toolCall.toolName)) return;
|
|
7239
|
+
const amount = extractAmount(toolCall.args);
|
|
7240
|
+
if (amount === null) return;
|
|
7241
|
+
await kontext.log({
|
|
7242
|
+
type: "ai_financial_tool_call",
|
|
7243
|
+
description: `Financial tool "${toolCall.toolName}" invoked${source ? ` via ${source}` : ""} with amount ${amount} ${cfg.defaultCurrency}`,
|
|
7244
|
+
agentId: cfg.agentId,
|
|
7245
|
+
metadata: {
|
|
7246
|
+
toolName: toolCall.toolName,
|
|
7247
|
+
amount: amount.toString(),
|
|
7248
|
+
currency: cfg.defaultCurrency,
|
|
7249
|
+
...cfg.logToolArgs ? { toolArgs: toolCall.args } : {},
|
|
7250
|
+
...source ? { source } : {}
|
|
7251
|
+
}
|
|
7252
|
+
});
|
|
7253
|
+
}
|
|
7254
|
+
async function logGenerateCompletion(kontext, cfg, result, duration, modelId, toolCallCount) {
|
|
7255
|
+
const usage = result["usage"];
|
|
7256
|
+
await kontext.log({
|
|
7257
|
+
type: "ai_response",
|
|
7258
|
+
description: `AI response completed in ${duration}ms`,
|
|
7259
|
+
agentId: cfg.agentId,
|
|
7260
|
+
metadata: {
|
|
7261
|
+
duration,
|
|
7262
|
+
toolCallCount,
|
|
7263
|
+
finishReason: result["finishReason"] ?? "unknown",
|
|
7264
|
+
promptTokens: usage?.promptTokens ?? null,
|
|
7265
|
+
completionTokens: usage?.completionTokens ?? null,
|
|
7266
|
+
totalTokens: usage?.totalTokens ?? null,
|
|
7267
|
+
model: modelId
|
|
7268
|
+
}
|
|
7269
|
+
});
|
|
7270
|
+
}
|
|
7271
|
+
async function logStreamToolCalls(kontext, cfg, toolCalls, duration, modelId) {
|
|
7272
|
+
for (const toolCall of toolCalls) {
|
|
7273
|
+
await kontext.log({
|
|
7274
|
+
type: "ai_tool_call",
|
|
7275
|
+
description: `Tool call (stream): ${toolCall.toolName}`,
|
|
7276
|
+
agentId: cfg.agentId,
|
|
7277
|
+
metadata: {
|
|
7278
|
+
toolName: toolCall.toolName,
|
|
7279
|
+
args: cfg.logToolArgs ? toolCall.args : "[redacted]",
|
|
7280
|
+
duration,
|
|
7281
|
+
model: modelId,
|
|
7282
|
+
source: "stream"
|
|
7283
|
+
}
|
|
7284
|
+
});
|
|
7285
|
+
await logFinancialToolCall(kontext, cfg, toolCall, "stream");
|
|
7286
|
+
}
|
|
5173
7287
|
}
|
|
5174
7288
|
function kontextWrapModel(model, kontext, options) {
|
|
5175
7289
|
const middleware = kontextMiddleware(kontext, options);
|
|
@@ -5222,10 +7336,18 @@ function createKontextAI(model, input) {
|
|
|
5222
7336
|
return { model: wrappedModel, kontext };
|
|
5223
7337
|
}
|
|
5224
7338
|
function withKontext(handler, options) {
|
|
7339
|
+
const resolvedProjectId = options?.projectId ?? process.env["KONTEXT_PROJECT_ID"];
|
|
7340
|
+
if (!resolvedProjectId) {
|
|
7341
|
+
throw new Error("Kontext: projectId is required. Provide it via options or set KONTEXT_PROJECT_ID env var.");
|
|
7342
|
+
}
|
|
7343
|
+
const resolvedApiKey = options?.apiKey ?? process.env["KONTEXT_API_KEY"];
|
|
7344
|
+
if (options?.apiKey !== void 0 && options.apiKey.trim() === "") {
|
|
7345
|
+
throw new Error("Kontext: apiKey was provided but is empty.");
|
|
7346
|
+
}
|
|
5225
7347
|
const kontext = Kontext.init({
|
|
5226
|
-
projectId:
|
|
7348
|
+
projectId: resolvedProjectId,
|
|
5227
7349
|
environment: options?.environment ?? (process.env["NODE_ENV"] === "production" ? "production" : "development"),
|
|
5228
|
-
apiKey:
|
|
7350
|
+
apiKey: resolvedApiKey,
|
|
5229
7351
|
debug: options?.debug
|
|
5230
7352
|
});
|
|
5231
7353
|
const agentId = options?.agentId ?? "nextjs-route";
|
|
@@ -5323,22 +7445,408 @@ function generateRequestId() {
|
|
|
5323
7445
|
return `req_${timestamp}-${random}`;
|
|
5324
7446
|
}
|
|
5325
7447
|
|
|
7448
|
+
// src/integrations/cftc-compliance.ts
|
|
7449
|
+
var CFTCCompliance = class {
|
|
7450
|
+
config;
|
|
7451
|
+
collateralValuations = [];
|
|
7452
|
+
segregationCalculations = [];
|
|
7453
|
+
incidents = [];
|
|
7454
|
+
constructor(config) {
|
|
7455
|
+
this.config = {
|
|
7456
|
+
minimumNonBtcEthHaircut: config?.minimumNonBtcEthHaircut ?? 0.2,
|
|
7457
|
+
enableHaircutValidation: config?.enableHaircutValidation ?? true,
|
|
7458
|
+
enableWeeklyReporting: config?.enableWeeklyReporting ?? false,
|
|
7459
|
+
reportingStartDate: config?.reportingStartDate
|
|
7460
|
+
};
|
|
7461
|
+
}
|
|
7462
|
+
// --------------------------------------------------------------------------
|
|
7463
|
+
// Collateral Valuation
|
|
7464
|
+
// --------------------------------------------------------------------------
|
|
7465
|
+
/**
|
|
7466
|
+
* Log a collateral valuation for a digital asset held as customer margin.
|
|
7467
|
+
*
|
|
7468
|
+
* Validates the haircut percentage against CFTC requirements when
|
|
7469
|
+
* `enableHaircutValidation` is true (default).
|
|
7470
|
+
*
|
|
7471
|
+
* @param input - Collateral valuation data (id and timestamp are auto-generated if omitted)
|
|
7472
|
+
* @returns The stored CollateralValuation record
|
|
7473
|
+
* @throws Error if haircut validation fails for the given asset type
|
|
7474
|
+
*/
|
|
7475
|
+
logCollateralValuation(input) {
|
|
7476
|
+
if (this.config.enableHaircutValidation) {
|
|
7477
|
+
const validation = this.validateHaircut(input.assetType, input.haircutPercentage);
|
|
7478
|
+
if (!validation.valid) {
|
|
7479
|
+
throw new Error(
|
|
7480
|
+
`Haircut validation failed: ${validation.message}`
|
|
7481
|
+
);
|
|
7482
|
+
}
|
|
7483
|
+
}
|
|
7484
|
+
const valuation = {
|
|
7485
|
+
id: input.id ?? generateId(),
|
|
7486
|
+
timestamp: input.timestamp ?? now(),
|
|
7487
|
+
accountClass: input.accountClass,
|
|
7488
|
+
assetType: input.assetType,
|
|
7489
|
+
assetSymbol: input.assetSymbol,
|
|
7490
|
+
quantity: input.quantity,
|
|
7491
|
+
marketValue: input.marketValue,
|
|
7492
|
+
haircutPercentage: input.haircutPercentage,
|
|
7493
|
+
haircutValue: input.haircutValue,
|
|
7494
|
+
netValue: input.netValue,
|
|
7495
|
+
valuationMethod: input.valuationMethod,
|
|
7496
|
+
agentId: input.agentId,
|
|
7497
|
+
...input.dcoReference !== void 0 && { dcoReference: input.dcoReference },
|
|
7498
|
+
...input.metadata !== void 0 && { metadata: input.metadata }
|
|
7499
|
+
};
|
|
7500
|
+
this.collateralValuations.push(valuation);
|
|
7501
|
+
return valuation;
|
|
7502
|
+
}
|
|
7503
|
+
// --------------------------------------------------------------------------
|
|
7504
|
+
// Segregation Calculations
|
|
7505
|
+
// --------------------------------------------------------------------------
|
|
7506
|
+
/**
|
|
7507
|
+
* Log a daily segregation calculation for a given account class.
|
|
7508
|
+
*
|
|
7509
|
+
* @param input - Segregation calculation data (id and timestamp are auto-generated if omitted)
|
|
7510
|
+
* @returns The stored SegregationCalculation record
|
|
7511
|
+
*/
|
|
7512
|
+
logSegregationCalculation(input) {
|
|
7513
|
+
const calculation = {
|
|
7514
|
+
id: input.id ?? generateId(),
|
|
7515
|
+
timestamp: input.timestamp ?? now(),
|
|
7516
|
+
accountClass: input.accountClass,
|
|
7517
|
+
totalCustomerFunds: input.totalCustomerFunds,
|
|
7518
|
+
requiredAmount: input.requiredAmount,
|
|
7519
|
+
excessDeficit: input.excessDeficit,
|
|
7520
|
+
digitalAssetBreakdown: input.digitalAssetBreakdown,
|
|
7521
|
+
residualInterest: input.residualInterest,
|
|
7522
|
+
agentId: input.agentId,
|
|
7523
|
+
...input.metadata !== void 0 && { metadata: input.metadata }
|
|
7524
|
+
};
|
|
7525
|
+
this.segregationCalculations.push(calculation);
|
|
7526
|
+
return calculation;
|
|
7527
|
+
}
|
|
7528
|
+
// --------------------------------------------------------------------------
|
|
7529
|
+
// Incident Reporting
|
|
7530
|
+
// --------------------------------------------------------------------------
|
|
7531
|
+
/**
|
|
7532
|
+
* Log an incident (cybersecurity, operational, system failure, or disruption).
|
|
7533
|
+
*
|
|
7534
|
+
* @param input - Incident data (id and timestamp are auto-generated if omitted)
|
|
7535
|
+
* @returns The stored IncidentReport record
|
|
7536
|
+
*/
|
|
7537
|
+
logIncident(input) {
|
|
7538
|
+
const incident = {
|
|
7539
|
+
id: input.id ?? generateId(),
|
|
7540
|
+
timestamp: input.timestamp ?? now(),
|
|
7541
|
+
severity: input.severity,
|
|
7542
|
+
incidentType: input.incidentType,
|
|
7543
|
+
description: input.description,
|
|
7544
|
+
affectedSystems: input.affectedSystems,
|
|
7545
|
+
resolutionStatus: input.resolutionStatus,
|
|
7546
|
+
agentId: input.agentId,
|
|
7547
|
+
...input.affectedCustomerCount !== void 0 && {
|
|
7548
|
+
affectedCustomerCount: input.affectedCustomerCount
|
|
7549
|
+
},
|
|
7550
|
+
...input.financialImpact !== void 0 && {
|
|
7551
|
+
financialImpact: input.financialImpact
|
|
7552
|
+
},
|
|
7553
|
+
...input.metadata !== void 0 && { metadata: input.metadata }
|
|
7554
|
+
};
|
|
7555
|
+
this.incidents.push(incident);
|
|
7556
|
+
return incident;
|
|
7557
|
+
}
|
|
7558
|
+
// --------------------------------------------------------------------------
|
|
7559
|
+
// Report Generation
|
|
7560
|
+
// --------------------------------------------------------------------------
|
|
7561
|
+
/**
|
|
7562
|
+
* Generate a weekly digital asset report aggregating collateral valuations
|
|
7563
|
+
* for a given account class and date range.
|
|
7564
|
+
*
|
|
7565
|
+
* @param accountClass - The CFTC account class to report on
|
|
7566
|
+
* @param periodStart - Start of the reporting period
|
|
7567
|
+
* @param periodEnd - End of the reporting period
|
|
7568
|
+
* @returns DigitalAssetReport with aggregated positions
|
|
7569
|
+
*/
|
|
7570
|
+
generateWeeklyDigitalAssetReport(accountClass, periodStart, periodEnd) {
|
|
7571
|
+
const filtered = this.collateralValuations.filter((v) => {
|
|
7572
|
+
const ts = new Date(v.timestamp);
|
|
7573
|
+
return v.accountClass === accountClass && ts >= periodStart && ts <= periodEnd;
|
|
7574
|
+
});
|
|
7575
|
+
const aggregationMap = /* @__PURE__ */ new Map();
|
|
7576
|
+
for (const v of filtered) {
|
|
7577
|
+
const key = `${v.assetType}:${v.assetSymbol}`;
|
|
7578
|
+
const existing = aggregationMap.get(key);
|
|
7579
|
+
if (existing) {
|
|
7580
|
+
existing.totalQuantity += v.quantity;
|
|
7581
|
+
existing.totalMarketValue += v.marketValue;
|
|
7582
|
+
existing.totalHaircutValue += v.haircutValue;
|
|
7583
|
+
existing.totalNetValue += v.netValue;
|
|
7584
|
+
} else {
|
|
7585
|
+
aggregationMap.set(key, {
|
|
7586
|
+
assetType: v.assetType,
|
|
7587
|
+
assetSymbol: v.assetSymbol,
|
|
7588
|
+
totalQuantity: v.quantity,
|
|
7589
|
+
totalMarketValue: v.marketValue,
|
|
7590
|
+
totalHaircutValue: v.haircutValue,
|
|
7591
|
+
totalNetValue: v.netValue
|
|
7592
|
+
});
|
|
7593
|
+
}
|
|
7594
|
+
}
|
|
7595
|
+
const assets = Array.from(aggregationMap.values());
|
|
7596
|
+
const totalMarketValue = assets.reduce((sum, a) => sum + a.totalMarketValue, 0);
|
|
7597
|
+
const totalNetValue = assets.reduce((sum, a) => sum + a.totalNetValue, 0);
|
|
7598
|
+
return {
|
|
7599
|
+
id: generateId(),
|
|
7600
|
+
reportDate: now().split("T")[0],
|
|
7601
|
+
reportPeriodStart: periodStart.toISOString(),
|
|
7602
|
+
reportPeriodEnd: periodEnd.toISOString(),
|
|
7603
|
+
accountClass,
|
|
7604
|
+
assets,
|
|
7605
|
+
totalMarketValue,
|
|
7606
|
+
totalNetValue,
|
|
7607
|
+
generatedAt: now()
|
|
7608
|
+
};
|
|
7609
|
+
}
|
|
7610
|
+
/**
|
|
7611
|
+
* Get the most recent segregation calculation for a given account class
|
|
7612
|
+
* and date.
|
|
7613
|
+
*
|
|
7614
|
+
* @param accountClass - The CFTC account class
|
|
7615
|
+
* @param date - The date to retrieve the calculation for
|
|
7616
|
+
* @returns The most recent SegregationCalculation for that day, or undefined
|
|
7617
|
+
*/
|
|
7618
|
+
generateDailySegregationReport(accountClass, date) {
|
|
7619
|
+
const dateStr = date.toISOString().split("T")[0];
|
|
7620
|
+
const matching = this.segregationCalculations.filter((c) => {
|
|
7621
|
+
const cDate = c.timestamp.split("T")[0];
|
|
7622
|
+
return c.accountClass === accountClass && cDate === dateStr;
|
|
7623
|
+
});
|
|
7624
|
+
if (matching.length === 0) return void 0;
|
|
7625
|
+
return matching[matching.length - 1];
|
|
7626
|
+
}
|
|
7627
|
+
// --------------------------------------------------------------------------
|
|
7628
|
+
// Query Methods
|
|
7629
|
+
// --------------------------------------------------------------------------
|
|
7630
|
+
/**
|
|
7631
|
+
* Query collateral valuations with optional filters.
|
|
7632
|
+
*
|
|
7633
|
+
* @param filters - Optional filter criteria
|
|
7634
|
+
* @returns Array of matching CollateralValuation records
|
|
7635
|
+
*/
|
|
7636
|
+
getCollateralValuations(filters) {
|
|
7637
|
+
if (!filters) return [...this.collateralValuations];
|
|
7638
|
+
return this.collateralValuations.filter((v) => {
|
|
7639
|
+
if (filters.accountClass && v.accountClass !== filters.accountClass) return false;
|
|
7640
|
+
if (filters.assetType && v.assetType !== filters.assetType) return false;
|
|
7641
|
+
if (filters.startDate) {
|
|
7642
|
+
const ts = new Date(v.timestamp);
|
|
7643
|
+
if (ts < filters.startDate) return false;
|
|
7644
|
+
}
|
|
7645
|
+
if (filters.endDate) {
|
|
7646
|
+
const ts = new Date(v.timestamp);
|
|
7647
|
+
if (ts > filters.endDate) return false;
|
|
7648
|
+
}
|
|
7649
|
+
return true;
|
|
7650
|
+
});
|
|
7651
|
+
}
|
|
7652
|
+
/**
|
|
7653
|
+
* Query incident reports with optional filters.
|
|
7654
|
+
*
|
|
7655
|
+
* @param filters - Optional filter criteria
|
|
7656
|
+
* @returns Array of matching IncidentReport records
|
|
7657
|
+
*/
|
|
7658
|
+
getIncidents(filters) {
|
|
7659
|
+
if (!filters) return [...this.incidents];
|
|
7660
|
+
return this.incidents.filter((i) => {
|
|
7661
|
+
if (filters.severity && i.severity !== filters.severity) return false;
|
|
7662
|
+
if (filters.status && i.resolutionStatus !== filters.status) return false;
|
|
7663
|
+
if (filters.startDate) {
|
|
7664
|
+
const ts = new Date(i.timestamp);
|
|
7665
|
+
if (ts < filters.startDate) return false;
|
|
7666
|
+
}
|
|
7667
|
+
if (filters.endDate) {
|
|
7668
|
+
const ts = new Date(i.timestamp);
|
|
7669
|
+
if (ts > filters.endDate) return false;
|
|
7670
|
+
}
|
|
7671
|
+
return true;
|
|
7672
|
+
});
|
|
7673
|
+
}
|
|
7674
|
+
/**
|
|
7675
|
+
* Query segregation calculations with optional filters.
|
|
7676
|
+
*
|
|
7677
|
+
* @param filters - Optional filter criteria
|
|
7678
|
+
* @returns Array of matching SegregationCalculation records
|
|
7679
|
+
*/
|
|
7680
|
+
getSegregationCalculations(filters) {
|
|
7681
|
+
if (!filters) return [...this.segregationCalculations];
|
|
7682
|
+
return this.segregationCalculations.filter((c) => {
|
|
7683
|
+
if (filters.accountClass && c.accountClass !== filters.accountClass) return false;
|
|
7684
|
+
if (filters.startDate) {
|
|
7685
|
+
const ts = new Date(c.timestamp);
|
|
7686
|
+
if (ts < filters.startDate) return false;
|
|
7687
|
+
}
|
|
7688
|
+
if (filters.endDate) {
|
|
7689
|
+
const ts = new Date(c.timestamp);
|
|
7690
|
+
if (ts > filters.endDate) return false;
|
|
7691
|
+
}
|
|
7692
|
+
return true;
|
|
7693
|
+
});
|
|
7694
|
+
}
|
|
7695
|
+
// --------------------------------------------------------------------------
|
|
7696
|
+
// Haircut Validation
|
|
7697
|
+
// --------------------------------------------------------------------------
|
|
7698
|
+
/**
|
|
7699
|
+
* Validate a haircut percentage against CFTC Letter 26-05 requirements.
|
|
7700
|
+
*
|
|
7701
|
+
* Rules:
|
|
7702
|
+
* - Payment stablecoins: no minimum haircut required
|
|
7703
|
+
* - BTC / ETH: no minimum (deferred to DCO haircut schedule)
|
|
7704
|
+
* - Other digital assets: minimum 20% haircut required
|
|
7705
|
+
*
|
|
7706
|
+
* @param assetType - The digital asset type
|
|
7707
|
+
* @param haircutPercentage - The proposed haircut (0.0 - 1.0)
|
|
7708
|
+
* @returns HaircutValidationResult with validity, minimum, and message
|
|
7709
|
+
*/
|
|
7710
|
+
validateHaircut(assetType, haircutPercentage) {
|
|
7711
|
+
switch (assetType) {
|
|
7712
|
+
case "payment_stablecoin":
|
|
7713
|
+
return {
|
|
7714
|
+
valid: true,
|
|
7715
|
+
minimumRequired: 0,
|
|
7716
|
+
message: "Payment stablecoins have no mandatory minimum haircut per CFTC Letter 26-05."
|
|
7717
|
+
};
|
|
7718
|
+
case "btc":
|
|
7719
|
+
return {
|
|
7720
|
+
valid: true,
|
|
7721
|
+
minimumRequired: 0,
|
|
7722
|
+
message: "BTC haircut deferred to DCO schedule per CFTC Letter 26-05."
|
|
7723
|
+
};
|
|
7724
|
+
case "eth":
|
|
7725
|
+
return {
|
|
7726
|
+
valid: true,
|
|
7727
|
+
minimumRequired: 0,
|
|
7728
|
+
message: "ETH haircut deferred to DCO schedule per CFTC Letter 26-05."
|
|
7729
|
+
};
|
|
7730
|
+
case "other_digital_asset": {
|
|
7731
|
+
const minimum = this.config.minimumNonBtcEthHaircut;
|
|
7732
|
+
const valid = haircutPercentage >= minimum;
|
|
7733
|
+
return {
|
|
7734
|
+
valid,
|
|
7735
|
+
minimumRequired: minimum,
|
|
7736
|
+
message: valid ? `Haircut of ${(haircutPercentage * 100).toFixed(1)}% meets the minimum ${(minimum * 100).toFixed(1)}% requirement for other digital assets.` : `Haircut of ${(haircutPercentage * 100).toFixed(1)}% is below the minimum ${(minimum * 100).toFixed(1)}% required for other digital assets per CFTC Letter 26-05.`
|
|
7737
|
+
};
|
|
7738
|
+
}
|
|
7739
|
+
}
|
|
7740
|
+
}
|
|
7741
|
+
// --------------------------------------------------------------------------
|
|
7742
|
+
// Export
|
|
7743
|
+
// --------------------------------------------------------------------------
|
|
7744
|
+
/**
|
|
7745
|
+
* Export CFTC compliance data in JSON or CSV format.
|
|
7746
|
+
*
|
|
7747
|
+
* @param options - Export options including format, report type, and filters
|
|
7748
|
+
* @returns Formatted string (JSON or CSV)
|
|
7749
|
+
*/
|
|
7750
|
+
exportCFTCReport(options) {
|
|
7751
|
+
let data;
|
|
7752
|
+
switch (options.reportType) {
|
|
7753
|
+
case "weekly_digital_assets": {
|
|
7754
|
+
const valuations = this.getCollateralValuations({
|
|
7755
|
+
accountClass: options.accountClass,
|
|
7756
|
+
startDate: options.startDate,
|
|
7757
|
+
endDate: options.endDate
|
|
7758
|
+
});
|
|
7759
|
+
data = valuations.map((v) => ({
|
|
7760
|
+
id: v.id,
|
|
7761
|
+
timestamp: v.timestamp,
|
|
7762
|
+
accountClass: v.accountClass,
|
|
7763
|
+
assetType: v.assetType,
|
|
7764
|
+
assetSymbol: v.assetSymbol,
|
|
7765
|
+
quantity: v.quantity,
|
|
7766
|
+
marketValue: v.marketValue,
|
|
7767
|
+
haircutPercentage: v.haircutPercentage,
|
|
7768
|
+
haircutValue: v.haircutValue,
|
|
7769
|
+
netValue: v.netValue,
|
|
7770
|
+
dcoReference: v.dcoReference ?? "",
|
|
7771
|
+
valuationMethod: v.valuationMethod,
|
|
7772
|
+
agentId: v.agentId
|
|
7773
|
+
}));
|
|
7774
|
+
break;
|
|
7775
|
+
}
|
|
7776
|
+
case "daily_segregation": {
|
|
7777
|
+
const calculations = this.getSegregationCalculations({
|
|
7778
|
+
accountClass: options.accountClass,
|
|
7779
|
+
startDate: options.startDate,
|
|
7780
|
+
endDate: options.endDate
|
|
7781
|
+
});
|
|
7782
|
+
data = calculations.map((c) => ({
|
|
7783
|
+
id: c.id,
|
|
7784
|
+
timestamp: c.timestamp,
|
|
7785
|
+
accountClass: c.accountClass,
|
|
7786
|
+
totalCustomerFunds: c.totalCustomerFunds,
|
|
7787
|
+
requiredAmount: c.requiredAmount,
|
|
7788
|
+
excessDeficit: c.excessDeficit,
|
|
7789
|
+
digitalAssetBreakdown: JSON.stringify(c.digitalAssetBreakdown),
|
|
7790
|
+
residualInterest: c.residualInterest,
|
|
7791
|
+
agentId: c.agentId
|
|
7792
|
+
}));
|
|
7793
|
+
break;
|
|
7794
|
+
}
|
|
7795
|
+
case "incidents": {
|
|
7796
|
+
const incidents = this.getIncidents({
|
|
7797
|
+
startDate: options.startDate,
|
|
7798
|
+
endDate: options.endDate
|
|
7799
|
+
});
|
|
7800
|
+
data = incidents.map((i) => ({
|
|
7801
|
+
id: i.id,
|
|
7802
|
+
timestamp: i.timestamp,
|
|
7803
|
+
severity: i.severity,
|
|
7804
|
+
incidentType: i.incidentType,
|
|
7805
|
+
description: i.description,
|
|
7806
|
+
affectedSystems: i.affectedSystems.join("; "),
|
|
7807
|
+
affectedCustomerCount: i.affectedCustomerCount ?? "",
|
|
7808
|
+
financialImpact: i.financialImpact ?? "",
|
|
7809
|
+
resolutionStatus: i.resolutionStatus,
|
|
7810
|
+
agentId: i.agentId
|
|
7811
|
+
}));
|
|
7812
|
+
break;
|
|
7813
|
+
}
|
|
7814
|
+
}
|
|
7815
|
+
if (options.format === "json") {
|
|
7816
|
+
return JSON.stringify(data, null, 2);
|
|
7817
|
+
}
|
|
7818
|
+
return toCsv(data);
|
|
7819
|
+
}
|
|
7820
|
+
};
|
|
7821
|
+
|
|
5326
7822
|
exports.CCTPTransferManager = CCTPTransferManager;
|
|
7823
|
+
exports.CFTCCompliance = CFTCCompliance;
|
|
5327
7824
|
exports.CircleComplianceEngine = CircleComplianceEngine;
|
|
5328
7825
|
exports.CircleWalletManager = CircleWalletManager;
|
|
7826
|
+
exports.ConsoleExporter = ConsoleExporter;
|
|
5329
7827
|
exports.DigestChain = DigestChain;
|
|
7828
|
+
exports.FeatureFlagManager = FeatureFlagManager;
|
|
5330
7829
|
exports.FileStorage = FileStorage;
|
|
5331
7830
|
exports.GasStationManager = GasStationManager;
|
|
7831
|
+
exports.HttpExporter = HttpExporter;
|
|
7832
|
+
exports.JsonFileExporter = JsonFileExporter;
|
|
5332
7833
|
exports.Kontext = Kontext;
|
|
7834
|
+
exports.KontextCloudExporter = KontextCloudExporter;
|
|
5333
7835
|
exports.KontextError = KontextError;
|
|
5334
7836
|
exports.KontextErrorCode = KontextErrorCode;
|
|
5335
7837
|
exports.MemoryStorage = MemoryStorage;
|
|
7838
|
+
exports.MultiExporter = MultiExporter;
|
|
7839
|
+
exports.NoopExporter = NoopExporter;
|
|
7840
|
+
exports.OFACSanctionsScreener = OFACSanctionsScreener;
|
|
7841
|
+
exports.PLAN_LIMITS = PLAN_LIMITS;
|
|
7842
|
+
exports.PlanManager = PlanManager;
|
|
5336
7843
|
exports.UsdcCompliance = UsdcCompliance;
|
|
5337
7844
|
exports.WebhookManager = WebhookManager;
|
|
5338
7845
|
exports.createKontextAI = createKontextAI;
|
|
5339
7846
|
exports.extractAmount = extractAmount;
|
|
5340
7847
|
exports.kontextMiddleware = kontextMiddleware;
|
|
5341
7848
|
exports.kontextWrapModel = kontextWrapModel;
|
|
7849
|
+
exports.ofacScreener = ofacScreener;
|
|
5342
7850
|
exports.verifyExportedChain = verifyExportedChain;
|
|
5343
7851
|
exports.withKontext = withKontext;
|
|
5344
7852
|
//# sourceMappingURL=index.js.map
|