@mnemopay/sdk 0.3.0 → 0.4.1
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 +2 -2
- package/dist/fraud-ml.d.ts +174 -0
- package/dist/fraud-ml.d.ts.map +1 -0
- package/dist/fraud-ml.js +544 -0
- package/dist/fraud-ml.js.map +1 -0
- package/dist/fraud.d.ts +212 -0
- package/dist/fraud.d.ts.map +1 -0
- package/dist/fraud.js +633 -0
- package/dist/fraud.js.map +1 -0
- package/dist/index.d.ts +20 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +128 -18
- package/dist/index.js.map +1 -1
- package/dist/langgraph/tools.d.ts +2 -2
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +78 -4
- package/dist/mcp/server.js.map +1 -1
- package/package.json +3 -3
package/dist/fraud.js
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MnemoPay Fraud Guard — velocity checks, anomaly detection, risk scoring,
|
|
4
|
+
* dispute resolution, and platform fee collection.
|
|
5
|
+
*
|
|
6
|
+
* Plugs into MnemoPayLite.charge/settle/refund to enforce security rules
|
|
7
|
+
* before any money moves.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.RateLimiter = exports.DEFAULT_RATE_LIMIT = exports.FraudGuard = exports.DEFAULT_FRAUD_CONFIG = void 0;
|
|
11
|
+
const fraud_ml_js_1 = require("./fraud-ml.js");
|
|
12
|
+
exports.DEFAULT_FRAUD_CONFIG = {
|
|
13
|
+
platformFeeRate: 0.03,
|
|
14
|
+
maxChargesPerMinute: 5,
|
|
15
|
+
maxChargesPerHour: 30,
|
|
16
|
+
maxChargesPerDay: 100,
|
|
17
|
+
maxDailyVolume: 5000,
|
|
18
|
+
anomalyStdDevThreshold: 2.5,
|
|
19
|
+
settlementHoldMinutes: 30,
|
|
20
|
+
disputeWindowMinutes: 1440,
|
|
21
|
+
blockThreshold: 0.75,
|
|
22
|
+
flagThreshold: 0.45,
|
|
23
|
+
minAccountAgeMinutes: 0,
|
|
24
|
+
maxPendingTransactions: 10,
|
|
25
|
+
enableGeoCheck: true,
|
|
26
|
+
blockedCountries: [],
|
|
27
|
+
ml: false,
|
|
28
|
+
};
|
|
29
|
+
// ─── Fraud Guard ────────────────────────────────────────────────────────────
|
|
30
|
+
class FraudGuard {
|
|
31
|
+
config;
|
|
32
|
+
/** Sliding window of recent charges per agent */
|
|
33
|
+
chargeHistory = new Map();
|
|
34
|
+
/** Running stats per agent: sum, sumSq, count for online std-dev */
|
|
35
|
+
agentStats = new Map();
|
|
36
|
+
/** Active disputes */
|
|
37
|
+
disputes = new Map();
|
|
38
|
+
/** Platform fee ledger */
|
|
39
|
+
feeLedger = [];
|
|
40
|
+
/** Total platform fees collected */
|
|
41
|
+
_platformFeesCollected = 0;
|
|
42
|
+
/** Known IPs per agent for consistency checks */
|
|
43
|
+
agentIps = new Map();
|
|
44
|
+
/** Flagged agents (soft block — allowed but monitored) */
|
|
45
|
+
flaggedAgents = new Set();
|
|
46
|
+
/** Hard-blocked agents */
|
|
47
|
+
blockedAgents = new Set();
|
|
48
|
+
/** ML anomaly detection — only loaded when ml: true */
|
|
49
|
+
isolationForest;
|
|
50
|
+
/** Transaction graph — only loaded when ml: true */
|
|
51
|
+
transactionGraph;
|
|
52
|
+
/** Behavioral fingerprinting — only loaded when ml: true */
|
|
53
|
+
behaviorProfile;
|
|
54
|
+
constructor(config) {
|
|
55
|
+
this.config = { ...exports.DEFAULT_FRAUD_CONFIG, ...config };
|
|
56
|
+
if (this.config.ml) {
|
|
57
|
+
this.isolationForest = new fraud_ml_js_1.IsolationForest();
|
|
58
|
+
this.transactionGraph = new fraud_ml_js_1.TransactionGraph();
|
|
59
|
+
this.behaviorProfile = new fraud_ml_js_1.BehaviorProfile();
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
this.isolationForest = null;
|
|
63
|
+
this.transactionGraph = null;
|
|
64
|
+
this.behaviorProfile = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ── Risk Assessment ────────────────────────────────────────────────────
|
|
68
|
+
/**
|
|
69
|
+
* Assess risk for a charge attempt. Returns a RiskAssessment
|
|
70
|
+
* that includes whether the charge should proceed.
|
|
71
|
+
*/
|
|
72
|
+
assessCharge(agentId, amount, reputation, accountCreatedAt, pendingCount, ctx) {
|
|
73
|
+
const signals = [];
|
|
74
|
+
// 0. Hard block check
|
|
75
|
+
if (this.blockedAgents.has(agentId)) {
|
|
76
|
+
return {
|
|
77
|
+
score: 1.0,
|
|
78
|
+
level: "blocked",
|
|
79
|
+
signals: [{ type: "agent_blocked", severity: "critical", description: "Agent is blocked", weight: 1.0 }],
|
|
80
|
+
allowed: false,
|
|
81
|
+
reason: "Agent has been blocked due to fraud violations",
|
|
82
|
+
flagged: false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// 1. Velocity checks
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const history = this.chargeHistory.get(agentId) || [];
|
|
88
|
+
const chargesLastMinute = history.filter((e) => now - e.timestamp < 60_000).length;
|
|
89
|
+
if (chargesLastMinute >= this.config.maxChargesPerMinute) {
|
|
90
|
+
signals.push({
|
|
91
|
+
type: "velocity_burst",
|
|
92
|
+
severity: "high",
|
|
93
|
+
description: `${chargesLastMinute} charges in last minute (limit: ${this.config.maxChargesPerMinute})`,
|
|
94
|
+
weight: 0.8,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const chargesLastHour = history.filter((e) => now - e.timestamp < 3_600_000).length;
|
|
98
|
+
if (chargesLastHour >= this.config.maxChargesPerHour) {
|
|
99
|
+
signals.push({
|
|
100
|
+
type: "velocity_hourly",
|
|
101
|
+
severity: "high",
|
|
102
|
+
description: `${chargesLastHour} charges in last hour (limit: ${this.config.maxChargesPerHour})`,
|
|
103
|
+
weight: 0.7,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const chargesLastDay = history.filter((e) => now - e.timestamp < 86_400_000).length;
|
|
107
|
+
if (chargesLastDay >= this.config.maxChargesPerDay) {
|
|
108
|
+
signals.push({
|
|
109
|
+
type: "velocity_daily",
|
|
110
|
+
severity: "medium",
|
|
111
|
+
description: `${chargesLastDay} charges in last 24h (limit: ${this.config.maxChargesPerDay})`,
|
|
112
|
+
weight: 0.6,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// 2. Daily volume check
|
|
116
|
+
const dayVolume = history
|
|
117
|
+
.filter((e) => now - e.timestamp < 86_400_000)
|
|
118
|
+
.reduce((sum, e) => sum + e.amount, 0);
|
|
119
|
+
if (dayVolume + amount > this.config.maxDailyVolume) {
|
|
120
|
+
signals.push({
|
|
121
|
+
type: "volume_limit",
|
|
122
|
+
severity: "high",
|
|
123
|
+
description: `Daily volume $${(dayVolume + amount).toFixed(2)} exceeds limit $${this.config.maxDailyVolume}`,
|
|
124
|
+
weight: 0.7,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// 3. Amount anomaly detection
|
|
128
|
+
const stats = this.agentStats.get(agentId);
|
|
129
|
+
// ML Isolation Forest (only when ml: true)
|
|
130
|
+
if (this.isolationForest) {
|
|
131
|
+
const iforestScore = this.isolationForest.score([
|
|
132
|
+
amount, new Date().getHours(), 0, history.filter((e) => now - e.timestamp < 600_000).length,
|
|
133
|
+
stats ? stats.sum / Math.max(stats.count, 1) : 0, 0, pendingCount, reputation,
|
|
134
|
+
]);
|
|
135
|
+
if (iforestScore >= 0 && iforestScore > 0.65) {
|
|
136
|
+
signals.push({
|
|
137
|
+
type: "ml_anomaly",
|
|
138
|
+
severity: iforestScore > 0.8 ? "high" : "medium",
|
|
139
|
+
description: `ML anomaly score ${iforestScore.toFixed(2)} (Isolation Forest)`,
|
|
140
|
+
weight: Math.min(iforestScore * 0.9, 0.85),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// z-score (always runs — lightweight, no ML dependency)
|
|
145
|
+
if (stats && stats.count >= 5) {
|
|
146
|
+
const mean = stats.sum / stats.count;
|
|
147
|
+
const variance = stats.sumSq / stats.count - mean * mean;
|
|
148
|
+
const stdDev = Math.sqrt(Math.max(variance, 0));
|
|
149
|
+
if (stdDev > 0) {
|
|
150
|
+
const zScore = (amount - mean) / stdDev;
|
|
151
|
+
if (zScore > this.config.anomalyStdDevThreshold) {
|
|
152
|
+
signals.push({
|
|
153
|
+
type: "amount_anomaly",
|
|
154
|
+
severity: "medium",
|
|
155
|
+
description: `Amount $${amount.toFixed(2)} is ${zScore.toFixed(1)} std devs above mean $${mean.toFixed(2)}`,
|
|
156
|
+
weight: Math.min(0.3 + zScore * 0.1, 0.8),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// 3b. Behavioral drift detection (only when ml: true)
|
|
162
|
+
if (this.behaviorProfile) {
|
|
163
|
+
const driftSignals = this.behaviorProfile.detectDrift(agentId, amount);
|
|
164
|
+
for (const ds of driftSignals) {
|
|
165
|
+
signals.push({
|
|
166
|
+
type: `drift:${ds.type}`,
|
|
167
|
+
severity: ds.severity > 0.6 ? "high" : ds.severity > 0.3 ? "medium" : "low",
|
|
168
|
+
description: ds.description,
|
|
169
|
+
weight: ds.severity,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// 4. New agent high charge
|
|
174
|
+
const accountAgeMinutes = (now - accountCreatedAt.getTime()) / 60_000;
|
|
175
|
+
if (accountAgeMinutes < 60 && amount > 50) {
|
|
176
|
+
signals.push({
|
|
177
|
+
type: "new_agent_high_charge",
|
|
178
|
+
severity: "medium",
|
|
179
|
+
description: `Agent is ${Math.round(accountAgeMinutes)}min old, charging $${amount.toFixed(2)}`,
|
|
180
|
+
weight: 0.4,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// 5. Minimum account age
|
|
184
|
+
if (accountAgeMinutes < this.config.minAccountAgeMinutes) {
|
|
185
|
+
signals.push({
|
|
186
|
+
type: "account_too_new",
|
|
187
|
+
severity: "high",
|
|
188
|
+
description: `Account age ${Math.round(accountAgeMinutes)}min < required ${this.config.minAccountAgeMinutes}min`,
|
|
189
|
+
weight: 0.9,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
// 6. Too many pending transactions
|
|
193
|
+
if (pendingCount >= this.config.maxPendingTransactions) {
|
|
194
|
+
signals.push({
|
|
195
|
+
type: "pending_overflow",
|
|
196
|
+
severity: "medium",
|
|
197
|
+
description: `${pendingCount} pending transactions (limit: ${this.config.maxPendingTransactions})`,
|
|
198
|
+
weight: 0.5,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// 7. Low reputation + high amount
|
|
202
|
+
if (reputation < 0.3 && amount > 100) {
|
|
203
|
+
signals.push({
|
|
204
|
+
type: "low_rep_high_charge",
|
|
205
|
+
severity: "high",
|
|
206
|
+
description: `Low reputation (${reputation.toFixed(2)}) attempting $${amount.toFixed(2)} charge`,
|
|
207
|
+
weight: 0.6,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// 8. Escalation pattern — progressively increasing amounts
|
|
211
|
+
const recentAmounts = history
|
|
212
|
+
.filter((e) => now - e.timestamp < 3_600_000)
|
|
213
|
+
.map((e) => e.amount);
|
|
214
|
+
if (recentAmounts.length >= 3) {
|
|
215
|
+
let escalating = true;
|
|
216
|
+
for (let i = 1; i < recentAmounts.length; i++) {
|
|
217
|
+
if (recentAmounts[i] <= recentAmounts[i - 1]) {
|
|
218
|
+
escalating = false;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (escalating && amount > recentAmounts[recentAmounts.length - 1]) {
|
|
223
|
+
signals.push({
|
|
224
|
+
type: "escalation_pattern",
|
|
225
|
+
severity: "medium",
|
|
226
|
+
description: `Escalating charge pattern detected: ${recentAmounts.map((a) => `$${a.toFixed(0)}`).join(" → ")} → $${amount.toFixed(0)}`,
|
|
227
|
+
weight: 0.4,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// 9. IP/geo checks
|
|
232
|
+
if (ctx?.country && this.config.blockedCountries.includes(ctx.country)) {
|
|
233
|
+
signals.push({
|
|
234
|
+
type: "blocked_country",
|
|
235
|
+
severity: "critical",
|
|
236
|
+
description: `Request from blocked country: ${ctx.country}`,
|
|
237
|
+
weight: 0.9,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (ctx?.ip) {
|
|
241
|
+
const knownIps = this.agentIps.get(agentId);
|
|
242
|
+
if (knownIps && knownIps.size > 0 && !knownIps.has(ctx.ip)) {
|
|
243
|
+
// New IP for this agent
|
|
244
|
+
if (knownIps.size >= 5) {
|
|
245
|
+
signals.push({
|
|
246
|
+
type: "ip_hopping",
|
|
247
|
+
severity: "medium",
|
|
248
|
+
description: `Agent using ${knownIps.size + 1}th unique IP`,
|
|
249
|
+
weight: 0.3,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// 10. Rapid charge-settle cycle detection
|
|
255
|
+
// (checked from history: if last N transactions were all settled within seconds)
|
|
256
|
+
const recentCompleted = history
|
|
257
|
+
.filter((e) => now - e.timestamp < 600_000) // last 10 min
|
|
258
|
+
.length;
|
|
259
|
+
if (recentCompleted >= 5 && chargesLastMinute >= 3) {
|
|
260
|
+
signals.push({
|
|
261
|
+
type: "rapid_cycle",
|
|
262
|
+
severity: "high",
|
|
263
|
+
description: "Rapid charge-settle cycling detected",
|
|
264
|
+
weight: 0.6,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// ── Compute composite score ─────────────────────────────────────────
|
|
268
|
+
const score = signals.length === 0
|
|
269
|
+
? 0
|
|
270
|
+
: Math.min(signals.reduce((sum, s) => sum + s.weight, 0) /
|
|
271
|
+
Math.max(signals.length, 1),
|
|
272
|
+
// Also take the max single signal weight — one critical signal can block
|
|
273
|
+
Math.max(...signals.map((s) => s.weight)), 1.0);
|
|
274
|
+
// Use maximum of weighted average and max single signal
|
|
275
|
+
const compositeScore = Math.min(Math.max(signals.reduce((sum, s) => sum + s.weight, 0) / Math.max(signals.length * 0.7, 1), signals.length > 0 ? Math.max(...signals.map((s) => s.weight)) * 0.85 : 0), 1.0);
|
|
276
|
+
const level = compositeScore >= this.config.blockThreshold
|
|
277
|
+
? "blocked"
|
|
278
|
+
: compositeScore >= this.config.flagThreshold
|
|
279
|
+
? "high"
|
|
280
|
+
: compositeScore >= 0.25
|
|
281
|
+
? "medium"
|
|
282
|
+
: compositeScore > 0.1
|
|
283
|
+
? "low"
|
|
284
|
+
: "safe";
|
|
285
|
+
const allowed = compositeScore < this.config.blockThreshold;
|
|
286
|
+
const flagged = compositeScore >= this.config.flagThreshold && allowed;
|
|
287
|
+
if (flagged)
|
|
288
|
+
this.flaggedAgents.add(agentId);
|
|
289
|
+
return {
|
|
290
|
+
score: Math.round(compositeScore * 100) / 100,
|
|
291
|
+
level,
|
|
292
|
+
signals,
|
|
293
|
+
allowed,
|
|
294
|
+
reason: allowed ? undefined : `Blocked: risk score ${compositeScore.toFixed(2)} exceeds threshold ${this.config.blockThreshold}`,
|
|
295
|
+
flagged,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Record a successful charge for velocity tracking and stats.
|
|
300
|
+
* Call AFTER charge is approved.
|
|
301
|
+
*/
|
|
302
|
+
recordCharge(agentId, amount, ctx) {
|
|
303
|
+
const now = Date.now();
|
|
304
|
+
// Update charge history
|
|
305
|
+
const history = this.chargeHistory.get(agentId) || [];
|
|
306
|
+
history.push({ timestamp: now, amount, agentId });
|
|
307
|
+
// Keep only last 48 hours
|
|
308
|
+
const cutoff = now - 48 * 3_600_000;
|
|
309
|
+
const filtered = history.filter((e) => e.timestamp > cutoff);
|
|
310
|
+
this.chargeHistory.set(agentId, filtered);
|
|
311
|
+
// Update running statistics (Welford's online algorithm)
|
|
312
|
+
const stats = this.agentStats.get(agentId) || { sum: 0, sumSq: 0, count: 0 };
|
|
313
|
+
stats.sum += amount;
|
|
314
|
+
stats.sumSq += amount * amount;
|
|
315
|
+
stats.count++;
|
|
316
|
+
this.agentStats.set(agentId, stats);
|
|
317
|
+
// Track IP
|
|
318
|
+
if (ctx?.ip) {
|
|
319
|
+
const ips = this.agentIps.get(agentId) || new Set();
|
|
320
|
+
ips.add(ctx.ip);
|
|
321
|
+
this.agentIps.set(agentId, ips);
|
|
322
|
+
}
|
|
323
|
+
// Feed ML systems (only when ml: true)
|
|
324
|
+
if (this.isolationForest) {
|
|
325
|
+
const recent10 = filtered.filter((e) => now - e.timestamp < 600_000);
|
|
326
|
+
const avgRecent = recent10.length > 0 ? recent10.reduce((s, e) => s + e.amount, 0) / recent10.length : 0;
|
|
327
|
+
const stdRecent = recent10.length > 1
|
|
328
|
+
? Math.sqrt(recent10.reduce((s, e) => s + (e.amount - avgRecent) ** 2, 0) / recent10.length)
|
|
329
|
+
: 0;
|
|
330
|
+
this.isolationForest.addSample([
|
|
331
|
+
amount, new Date().getHours(), 0, recent10.length,
|
|
332
|
+
avgRecent, stdRecent, 0, 0.5,
|
|
333
|
+
]);
|
|
334
|
+
}
|
|
335
|
+
if (this.behaviorProfile)
|
|
336
|
+
this.behaviorProfile.recordEvent(agentId, "charge", amount);
|
|
337
|
+
if (this.transactionGraph && ctx?.ip)
|
|
338
|
+
this.transactionGraph.registerAgent(agentId, ctx.ip);
|
|
339
|
+
}
|
|
340
|
+
/** Record a non-payment event (memory ops) for behavioral profiling */
|
|
341
|
+
recordEvent(agentId, type) {
|
|
342
|
+
if (this.behaviorProfile)
|
|
343
|
+
this.behaviorProfile.recordEvent(agentId, type);
|
|
344
|
+
}
|
|
345
|
+
/** Record a transaction between agents for graph analysis */
|
|
346
|
+
recordTransfer(fromAgent, toAgent, amount, txId) {
|
|
347
|
+
if (this.transactionGraph)
|
|
348
|
+
this.transactionGraph.addTransaction(fromAgent, toAgent, amount, txId);
|
|
349
|
+
}
|
|
350
|
+
/** Run collusion detection across the transaction graph (requires ml: true) */
|
|
351
|
+
detectCollusion() {
|
|
352
|
+
if (!this.transactionGraph)
|
|
353
|
+
return [];
|
|
354
|
+
return this.transactionGraph.detectAll();
|
|
355
|
+
}
|
|
356
|
+
/** Get an agent's behavioral baseline (requires ml: true) */
|
|
357
|
+
getAgentBaseline(agentId) {
|
|
358
|
+
if (!this.behaviorProfile)
|
|
359
|
+
return undefined;
|
|
360
|
+
return this.behaviorProfile.getBaseline(agentId);
|
|
361
|
+
}
|
|
362
|
+
// ── Platform Fee ──────────────────────────────────────────────────────
|
|
363
|
+
/**
|
|
364
|
+
* Calculate and record platform fee for a settlement.
|
|
365
|
+
* Returns { grossAmount, feeAmount, netAmount }.
|
|
366
|
+
*/
|
|
367
|
+
applyPlatformFee(txId, agentId, grossAmount) {
|
|
368
|
+
const feeAmount = Math.round(grossAmount * this.config.platformFeeRate * 100) / 100;
|
|
369
|
+
const netAmount = Math.round((grossAmount - feeAmount) * 100) / 100;
|
|
370
|
+
const record = {
|
|
371
|
+
txId,
|
|
372
|
+
agentId,
|
|
373
|
+
grossAmount,
|
|
374
|
+
feeRate: this.config.platformFeeRate,
|
|
375
|
+
feeAmount,
|
|
376
|
+
netAmount,
|
|
377
|
+
createdAt: new Date(),
|
|
378
|
+
};
|
|
379
|
+
this.feeLedger.push(record);
|
|
380
|
+
this._platformFeesCollected += feeAmount;
|
|
381
|
+
return record;
|
|
382
|
+
}
|
|
383
|
+
/** Total platform fees collected */
|
|
384
|
+
get platformFeesCollected() {
|
|
385
|
+
return Math.round(this._platformFeesCollected * 100) / 100;
|
|
386
|
+
}
|
|
387
|
+
/** Get platform fee ledger */
|
|
388
|
+
getFeeLedger(limit = 50) {
|
|
389
|
+
return this.feeLedger.slice(-limit);
|
|
390
|
+
}
|
|
391
|
+
// ── Dispute Resolution ────────────────────────────────────────────────
|
|
392
|
+
/**
|
|
393
|
+
* File a dispute against a settled transaction.
|
|
394
|
+
* Returns the dispute if within the dispute window.
|
|
395
|
+
*/
|
|
396
|
+
fileDispute(txId, agentId, reason, txCompletedAt, evidence) {
|
|
397
|
+
// Check dispute window
|
|
398
|
+
const minutesSinceSettlement = (Date.now() - txCompletedAt.getTime()) / 60_000;
|
|
399
|
+
if (minutesSinceSettlement > this.config.disputeWindowMinutes) {
|
|
400
|
+
throw new Error(`Dispute window expired. Transaction settled ${Math.round(minutesSinceSettlement)}min ago ` +
|
|
401
|
+
`(window: ${this.config.disputeWindowMinutes}min)`);
|
|
402
|
+
}
|
|
403
|
+
// Check for duplicate dispute
|
|
404
|
+
for (const d of this.disputes.values()) {
|
|
405
|
+
if (d.txId === txId && d.status === "open") {
|
|
406
|
+
throw new Error(`Active dispute already exists for transaction ${txId}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const dispute = {
|
|
410
|
+
id: crypto.randomUUID(),
|
|
411
|
+
txId,
|
|
412
|
+
agentId,
|
|
413
|
+
reason,
|
|
414
|
+
status: "open",
|
|
415
|
+
createdAt: new Date(),
|
|
416
|
+
evidence: evidence || [],
|
|
417
|
+
};
|
|
418
|
+
this.disputes.set(dispute.id, dispute);
|
|
419
|
+
return dispute;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Resolve a dispute. Either refund the transaction or uphold it.
|
|
423
|
+
*/
|
|
424
|
+
resolveDispute(disputeId, outcome) {
|
|
425
|
+
const dispute = this.disputes.get(disputeId);
|
|
426
|
+
if (!dispute)
|
|
427
|
+
throw new Error(`Dispute ${disputeId} not found`);
|
|
428
|
+
if (dispute.status !== "open")
|
|
429
|
+
throw new Error(`Dispute ${disputeId} is already ${dispute.status}`);
|
|
430
|
+
dispute.status = outcome === "refund" ? "resolved_refunded" : "resolved_upheld";
|
|
431
|
+
dispute.resolvedAt = new Date();
|
|
432
|
+
// If refund outcome, flag agent for review
|
|
433
|
+
if (outcome === "refund") {
|
|
434
|
+
this.flaggedAgents.add(dispute.agentId);
|
|
435
|
+
}
|
|
436
|
+
return dispute;
|
|
437
|
+
}
|
|
438
|
+
/** Get all disputes for an agent */
|
|
439
|
+
getDisputes(agentId) {
|
|
440
|
+
const all = Array.from(this.disputes.values());
|
|
441
|
+
if (agentId)
|
|
442
|
+
return all.filter((d) => d.agentId === agentId);
|
|
443
|
+
return all;
|
|
444
|
+
}
|
|
445
|
+
/** Get a specific dispute */
|
|
446
|
+
getDispute(disputeId) {
|
|
447
|
+
return this.disputes.get(disputeId);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Check if a transaction is within the settlement hold period.
|
|
451
|
+
* Returns true if the transaction is still held and cannot be withdrawn.
|
|
452
|
+
*/
|
|
453
|
+
isWithinHoldPeriod(settledAt) {
|
|
454
|
+
const minutesSince = (Date.now() - settledAt.getTime()) / 60_000;
|
|
455
|
+
return minutesSince < this.config.settlementHoldMinutes;
|
|
456
|
+
}
|
|
457
|
+
/** Check if a transaction is still within the dispute window */
|
|
458
|
+
isWithinDisputeWindow(settledAt) {
|
|
459
|
+
const minutesSince = (Date.now() - settledAt.getTime()) / 60_000;
|
|
460
|
+
return minutesSince < this.config.disputeWindowMinutes;
|
|
461
|
+
}
|
|
462
|
+
// ── Agent Management ──────────────────────────────────────────────────
|
|
463
|
+
/** Block an agent from making any charges */
|
|
464
|
+
blockAgent(agentId) {
|
|
465
|
+
this.blockedAgents.add(agentId);
|
|
466
|
+
}
|
|
467
|
+
/** Unblock a previously blocked agent */
|
|
468
|
+
unblockAgent(agentId) {
|
|
469
|
+
this.blockedAgents.delete(agentId);
|
|
470
|
+
}
|
|
471
|
+
/** Check if an agent is blocked */
|
|
472
|
+
isBlocked(agentId) {
|
|
473
|
+
return this.blockedAgents.has(agentId);
|
|
474
|
+
}
|
|
475
|
+
/** Check if an agent is flagged */
|
|
476
|
+
isFlagged(agentId) {
|
|
477
|
+
return this.flaggedAgents.has(agentId);
|
|
478
|
+
}
|
|
479
|
+
/** Get fraud stats summary */
|
|
480
|
+
stats() {
|
|
481
|
+
return {
|
|
482
|
+
totalChargesTracked: Array.from(this.chargeHistory.values()).reduce((sum, h) => sum + h.length, 0),
|
|
483
|
+
agentsTracked: this.chargeHistory.size,
|
|
484
|
+
agentsFlagged: this.flaggedAgents.size,
|
|
485
|
+
agentsBlocked: this.blockedAgents.size,
|
|
486
|
+
openDisputes: Array.from(this.disputes.values()).filter((d) => d.status === "open").length,
|
|
487
|
+
platformFeesCollected: this.platformFeesCollected,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
// ── Serialization (for persistence) ───────────────────────────────────
|
|
491
|
+
serialize() {
|
|
492
|
+
return JSON.stringify({
|
|
493
|
+
chargeHistory: Array.from(this.chargeHistory.entries()),
|
|
494
|
+
agentStats: Array.from(this.agentStats.entries()),
|
|
495
|
+
disputes: Array.from(this.disputes.entries()).map(([k, v]) => [k, { ...v, createdAt: v.createdAt.toISOString(), resolvedAt: v.resolvedAt?.toISOString() }]),
|
|
496
|
+
feeLedger: this.feeLedger.map((f) => ({ ...f, createdAt: f.createdAt.toISOString() })),
|
|
497
|
+
platformFeesCollected: this._platformFeesCollected,
|
|
498
|
+
agentIps: Array.from(this.agentIps.entries()).map(([k, v]) => [k, Array.from(v)]),
|
|
499
|
+
flaggedAgents: Array.from(this.flaggedAgents),
|
|
500
|
+
blockedAgents: Array.from(this.blockedAgents),
|
|
501
|
+
isolationForest: this.isolationForest?.serialize() ?? null,
|
|
502
|
+
transactionGraph: this.transactionGraph?.serialize() ?? null,
|
|
503
|
+
behaviorProfile: this.behaviorProfile?.serialize() ?? null,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
static deserialize(json, config) {
|
|
507
|
+
const guard = new FraudGuard(config);
|
|
508
|
+
try {
|
|
509
|
+
const data = JSON.parse(json);
|
|
510
|
+
if (data.chargeHistory) {
|
|
511
|
+
guard.chargeHistory = new Map(data.chargeHistory);
|
|
512
|
+
}
|
|
513
|
+
if (data.agentStats) {
|
|
514
|
+
guard.agentStats = new Map(data.agentStats);
|
|
515
|
+
}
|
|
516
|
+
if (data.disputes) {
|
|
517
|
+
guard.disputes = new Map(data.disputes.map(([k, v]) => [
|
|
518
|
+
k,
|
|
519
|
+
{ ...v, createdAt: new Date(v.createdAt), resolvedAt: v.resolvedAt ? new Date(v.resolvedAt) : undefined },
|
|
520
|
+
]));
|
|
521
|
+
}
|
|
522
|
+
if (data.feeLedger) {
|
|
523
|
+
guard.feeLedger = data.feeLedger.map((f) => ({ ...f, createdAt: new Date(f.createdAt) }));
|
|
524
|
+
}
|
|
525
|
+
if (data.platformFeesCollected !== undefined) {
|
|
526
|
+
guard._platformFeesCollected = data.platformFeesCollected;
|
|
527
|
+
}
|
|
528
|
+
if (data.agentIps) {
|
|
529
|
+
guard.agentIps = new Map(data.agentIps.map(([k, v]) => [k, new Set(v)]));
|
|
530
|
+
}
|
|
531
|
+
if (data.flaggedAgents) {
|
|
532
|
+
guard.flaggedAgents = new Set(data.flaggedAgents);
|
|
533
|
+
}
|
|
534
|
+
if (data.blockedAgents) {
|
|
535
|
+
guard.blockedAgents = new Set(data.blockedAgents);
|
|
536
|
+
}
|
|
537
|
+
if (guard.config.ml && data.isolationForest) {
|
|
538
|
+
guard.isolationForest = fraud_ml_js_1.IsolationForest.deserialize(data.isolationForest);
|
|
539
|
+
}
|
|
540
|
+
if (guard.config.ml && data.transactionGraph) {
|
|
541
|
+
guard.transactionGraph = fraud_ml_js_1.TransactionGraph.deserialize(data.transactionGraph);
|
|
542
|
+
}
|
|
543
|
+
if (guard.config.ml && data.behaviorProfile) {
|
|
544
|
+
guard.behaviorProfile = fraud_ml_js_1.BehaviorProfile.deserialize(data.behaviorProfile);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// Return fresh guard if deserialization fails
|
|
549
|
+
}
|
|
550
|
+
return guard;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
exports.FraudGuard = FraudGuard;
|
|
554
|
+
exports.DEFAULT_RATE_LIMIT = {
|
|
555
|
+
maxRequests: 60,
|
|
556
|
+
windowMs: 60_000,
|
|
557
|
+
maxPaymentRequests: 10,
|
|
558
|
+
paymentWindowMs: 60_000,
|
|
559
|
+
};
|
|
560
|
+
class RateLimiter {
|
|
561
|
+
config;
|
|
562
|
+
/** IP → timestamps of recent requests */
|
|
563
|
+
requests = new Map();
|
|
564
|
+
/** IP → timestamps of recent payment operations */
|
|
565
|
+
paymentRequests = new Map();
|
|
566
|
+
constructor(config) {
|
|
567
|
+
this.config = { ...exports.DEFAULT_RATE_LIMIT, ...config };
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Check if a request should be allowed.
|
|
571
|
+
* Returns { allowed, remaining, retryAfterMs? }
|
|
572
|
+
*/
|
|
573
|
+
check(key, isPayment = false) {
|
|
574
|
+
const now = Date.now();
|
|
575
|
+
// General rate limit
|
|
576
|
+
const reqs = this.requests.get(key) || [];
|
|
577
|
+
const windowStart = now - this.config.windowMs;
|
|
578
|
+
const recentReqs = reqs.filter((t) => t > windowStart);
|
|
579
|
+
this.requests.set(key, recentReqs);
|
|
580
|
+
if (recentReqs.length >= this.config.maxRequests) {
|
|
581
|
+
const oldestInWindow = recentReqs[0];
|
|
582
|
+
return {
|
|
583
|
+
allowed: false,
|
|
584
|
+
remaining: 0,
|
|
585
|
+
retryAfterMs: oldestInWindow + this.config.windowMs - now,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
// Payment-specific rate limit
|
|
589
|
+
if (isPayment) {
|
|
590
|
+
const payReqs = this.paymentRequests.get(key) || [];
|
|
591
|
+
const payWindowStart = now - this.config.paymentWindowMs;
|
|
592
|
+
const recentPayReqs = payReqs.filter((t) => t > payWindowStart);
|
|
593
|
+
this.paymentRequests.set(key, recentPayReqs);
|
|
594
|
+
if (recentPayReqs.length >= this.config.maxPaymentRequests) {
|
|
595
|
+
const oldestPay = recentPayReqs[0];
|
|
596
|
+
return {
|
|
597
|
+
allowed: false,
|
|
598
|
+
remaining: 0,
|
|
599
|
+
retryAfterMs: oldestPay + this.config.paymentWindowMs - now,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
recentPayReqs.push(now);
|
|
603
|
+
this.paymentRequests.set(key, recentPayReqs);
|
|
604
|
+
}
|
|
605
|
+
recentReqs.push(now);
|
|
606
|
+
this.requests.set(key, recentReqs);
|
|
607
|
+
const remaining = isPayment
|
|
608
|
+
? Math.min(this.config.maxRequests - recentReqs.length, this.config.maxPaymentRequests - (this.paymentRequests.get(key)?.length || 0))
|
|
609
|
+
: this.config.maxRequests - recentReqs.length;
|
|
610
|
+
return { allowed: true, remaining };
|
|
611
|
+
}
|
|
612
|
+
/** Clean up old entries (call periodically) */
|
|
613
|
+
cleanup() {
|
|
614
|
+
const now = Date.now();
|
|
615
|
+
const cutoff = now - Math.max(this.config.windowMs, this.config.paymentWindowMs) * 2;
|
|
616
|
+
for (const [key, reqs] of this.requests) {
|
|
617
|
+
const filtered = reqs.filter((t) => t > cutoff);
|
|
618
|
+
if (filtered.length === 0)
|
|
619
|
+
this.requests.delete(key);
|
|
620
|
+
else
|
|
621
|
+
this.requests.set(key, filtered);
|
|
622
|
+
}
|
|
623
|
+
for (const [key, reqs] of this.paymentRequests) {
|
|
624
|
+
const filtered = reqs.filter((t) => t > cutoff);
|
|
625
|
+
if (filtered.length === 0)
|
|
626
|
+
this.paymentRequests.delete(key);
|
|
627
|
+
else
|
|
628
|
+
this.paymentRequests.set(key, filtered);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
exports.RateLimiter = RateLimiter;
|
|
633
|
+
//# sourceMappingURL=fraud.js.map
|