pulse-protocol 0.9.3
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/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/dist/cli.cjs +993 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +970 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon/index.cjs +856 -0
- package/dist/daemon/index.cjs.map +1 -0
- package/dist/daemon/index.d.cts +24 -0
- package/dist/daemon/index.d.ts +24 -0
- package/dist/daemon/index.js +821 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/detectors/index.cjs +374 -0
- package/dist/detectors/index.cjs.map +1 -0
- package/dist/detectors/index.d.cts +94 -0
- package/dist/detectors/index.d.ts +94 -0
- package/dist/detectors/index.js +344 -0
- package/dist/detectors/index.js.map +1 -0
- package/dist/index.cjs +835 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +97 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.js +791 -0
- package/dist/index.js.map +1 -0
- package/dist/types-BpZyHhFT.d.cts +76 -0
- package/dist/types-BpZyHhFT.d.ts +76 -0
- package/package.json +99 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/client.ts
|
|
4
|
+
import { EventEmitter } from "eventemitter3";
|
|
5
|
+
|
|
6
|
+
// src/core/errors.ts
|
|
7
|
+
var PulseError = class _PulseError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "PulseError";
|
|
11
|
+
Object.setPrototypeOf(this, _PulseError.prototype);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var ScanError = class _ScanError extends PulseError {
|
|
15
|
+
detector;
|
|
16
|
+
constructor(message, detector) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "ScanError";
|
|
19
|
+
this.detector = detector;
|
|
20
|
+
Object.setPrototypeOf(this, _ScanError.prototype);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var SourceError = class _SourceError extends PulseError {
|
|
24
|
+
source;
|
|
25
|
+
status;
|
|
26
|
+
constructor(source, message, status) {
|
|
27
|
+
super(`${source}: ${message}`);
|
|
28
|
+
this.name = "SourceError";
|
|
29
|
+
this.source = source;
|
|
30
|
+
this.status = status;
|
|
31
|
+
Object.setPrototypeOf(this, _SourceError.prototype);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var SignatureError = class _SignatureError extends PulseError {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "SignatureError";
|
|
38
|
+
Object.setPrototypeOf(this, _SignatureError.prototype);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// src/sources/helius.ts
|
|
43
|
+
var HeliusSource = class {
|
|
44
|
+
baseUrl;
|
|
45
|
+
apiKey;
|
|
46
|
+
timeout;
|
|
47
|
+
maxRetries;
|
|
48
|
+
constructor(config) {
|
|
49
|
+
this.apiKey = config.apiKey;
|
|
50
|
+
this.baseUrl = config.rpcUrl ?? "https://api.helius.xyz/v0";
|
|
51
|
+
this.timeout = config.timeout;
|
|
52
|
+
this.maxRetries = config.maxRetries;
|
|
53
|
+
}
|
|
54
|
+
async getWalletHistory(wallet, opts = {}) {
|
|
55
|
+
const limit = opts.limit ?? 100;
|
|
56
|
+
const url = new URL(`${this.baseUrl}/addresses/${wallet}/transactions`);
|
|
57
|
+
url.searchParams.set("api-key", this.apiKey);
|
|
58
|
+
url.searchParams.set("limit", String(Math.min(limit, 100)));
|
|
59
|
+
if (opts.before) url.searchParams.set("before", opts.before);
|
|
60
|
+
const raw = await this.fetchWithRetry(url);
|
|
61
|
+
if (!Array.isArray(raw)) {
|
|
62
|
+
throw new SourceError("helius", "unexpected response shape");
|
|
63
|
+
}
|
|
64
|
+
return raw.map((tx) => {
|
|
65
|
+
const t = tx;
|
|
66
|
+
return {
|
|
67
|
+
signature: String(t.signature ?? ""),
|
|
68
|
+
timestamp: Number(t.timestamp ?? 0),
|
|
69
|
+
fee: Number(t.fee ?? 0),
|
|
70
|
+
programs: Array.isArray(t.accountData) ? extractPrograms(t) : [],
|
|
71
|
+
type: String(t.type ?? "UNKNOWN")
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async fetchWithRetry(url) {
|
|
76
|
+
let lastError;
|
|
77
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
81
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
if (res.status === 429) {
|
|
84
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? "2");
|
|
85
|
+
await sleep(retryAfter * 1e3);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
throw new SourceError(
|
|
90
|
+
"helius",
|
|
91
|
+
`HTTP ${res.status}`,
|
|
92
|
+
res.status
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return await res.json();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
lastError = err;
|
|
98
|
+
if (attempt < this.maxRetries) {
|
|
99
|
+
await sleep(500 * (attempt + 1));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw lastError instanceof Error ? new SourceError("helius", lastError.message) : new SourceError("helius", "unknown error");
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
function extractPrograms(tx) {
|
|
107
|
+
const programs = /* @__PURE__ */ new Set();
|
|
108
|
+
for (const ix of tx.instructions ?? []) {
|
|
109
|
+
if (ix.programId) programs.add(ix.programId);
|
|
110
|
+
}
|
|
111
|
+
return Array.from(programs);
|
|
112
|
+
}
|
|
113
|
+
function sleep(ms) {
|
|
114
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/sources/x.ts
|
|
118
|
+
var XSource = class {
|
|
119
|
+
baseUrl;
|
|
120
|
+
apiKey;
|
|
121
|
+
timeout;
|
|
122
|
+
maxRetries;
|
|
123
|
+
constructor(config) {
|
|
124
|
+
this.apiKey = config.apiKey;
|
|
125
|
+
this.baseUrl = config.baseUrl ?? "https://api.twitterapi.io";
|
|
126
|
+
this.timeout = config.timeout;
|
|
127
|
+
this.maxRetries = config.maxRetries;
|
|
128
|
+
}
|
|
129
|
+
async getRecentPosts(handle, opts = {}) {
|
|
130
|
+
const limit = opts.limit ?? 100;
|
|
131
|
+
const url = new URL(`${this.baseUrl}/twitter/user/last_tweets`);
|
|
132
|
+
url.searchParams.set("userName", handle);
|
|
133
|
+
const raw = await this.fetchWithRetry(url);
|
|
134
|
+
const tweets = Array.isArray(raw.tweets) ? raw.tweets : Array.isArray(raw.data?.tweets) ? raw.data.tweets : [];
|
|
135
|
+
const posts = [];
|
|
136
|
+
for (const t of tweets.slice(0, limit)) {
|
|
137
|
+
const obj = t;
|
|
138
|
+
const createdAt = parseDate(obj.createdAt);
|
|
139
|
+
if (!createdAt) continue;
|
|
140
|
+
posts.push({
|
|
141
|
+
id: String(obj.id ?? ""),
|
|
142
|
+
text: String(obj.text ?? ""),
|
|
143
|
+
created_at: createdAt,
|
|
144
|
+
reply_count: Number(obj.replyCount ?? 0),
|
|
145
|
+
like_count: Number(obj.likeCount ?? 0),
|
|
146
|
+
retweet_count: Number(obj.retweetCount ?? 0),
|
|
147
|
+
in_reply_to_id: obj.inReplyToId ? String(obj.inReplyToId) : void 0
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
for (let i = 0; i < posts.length; i++) {
|
|
151
|
+
const p = posts[i];
|
|
152
|
+
if (p.in_reply_to_id) {
|
|
153
|
+
const parent = posts.find((x) => x.id === p.in_reply_to_id);
|
|
154
|
+
if (parent) {
|
|
155
|
+
p.reply_delta_ms = (p.created_at - parent.created_at) * 1e3;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return posts;
|
|
160
|
+
}
|
|
161
|
+
async fetchWithRetry(url) {
|
|
162
|
+
let lastError;
|
|
163
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
164
|
+
try {
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
167
|
+
const res = await fetch(url, {
|
|
168
|
+
signal: controller.signal,
|
|
169
|
+
headers: { "X-API-Key": this.apiKey }
|
|
170
|
+
});
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
if (res.status === 429) {
|
|
173
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? "2");
|
|
174
|
+
await sleep2(retryAfter * 1e3);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
throw new SourceError("x", `HTTP ${res.status}`, res.status);
|
|
179
|
+
}
|
|
180
|
+
return await res.json();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
lastError = err;
|
|
183
|
+
if (attempt < this.maxRetries) {
|
|
184
|
+
await sleep2(500 * (attempt + 1));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
throw lastError instanceof Error ? new SourceError("x", lastError.message) : new SourceError("x", "unknown error");
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
function parseDate(v) {
|
|
192
|
+
if (typeof v === "number") return v;
|
|
193
|
+
if (typeof v !== "string") return null;
|
|
194
|
+
const parsed = Date.parse(v);
|
|
195
|
+
return isNaN(parsed) ? null : Math.floor(parsed / 1e3);
|
|
196
|
+
}
|
|
197
|
+
function sleep2(ms) {
|
|
198
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/detectors/cadence.ts
|
|
202
|
+
function cadenceDetector(posts) {
|
|
203
|
+
const notes = [];
|
|
204
|
+
if (posts.length < 20) {
|
|
205
|
+
notes.push(`only ${posts.length} posts available, low confidence`);
|
|
206
|
+
return {
|
|
207
|
+
detector: "cadence",
|
|
208
|
+
score: 50,
|
|
209
|
+
samples: posts.length,
|
|
210
|
+
notes,
|
|
211
|
+
runtime_ms: 0
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const hourBuckets = new Array(24).fill(0);
|
|
215
|
+
for (const p of posts) {
|
|
216
|
+
const hour = new Date(p.created_at * 1e3).getUTCHours();
|
|
217
|
+
hourBuckets[hour]++;
|
|
218
|
+
}
|
|
219
|
+
const total = posts.length;
|
|
220
|
+
let entropy = 0;
|
|
221
|
+
for (const count of hourBuckets) {
|
|
222
|
+
if (count > 0) {
|
|
223
|
+
const p = count / total;
|
|
224
|
+
entropy -= p * Math.log2(p);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const entropyScore = entropy / Math.log2(24) * 100;
|
|
228
|
+
notes.push(`hour entropy: ${entropy.toFixed(2)} bits (${entropyScore.toFixed(0)}/100)`);
|
|
229
|
+
const businessHours = hourBuckets.slice(9, 18).reduce((a, b) => a + b, 0);
|
|
230
|
+
const businessRatio = businessHours / total;
|
|
231
|
+
notes.push(`business-hours ratio: ${(businessRatio * 100).toFixed(1)}%`);
|
|
232
|
+
const sleepWindow = hourBuckets.slice(4, 8).reduce((a, b) => a + b, 0);
|
|
233
|
+
const sleepRatio = sleepWindow / total;
|
|
234
|
+
const hasDeadZone = sleepRatio < 0.05;
|
|
235
|
+
notes.push(
|
|
236
|
+
hasDeadZone ? `sleep window detected (${(sleepRatio * 100).toFixed(1)}% posts in 4-8am)` : `no sleep window (${(sleepRatio * 100).toFixed(1)}% posts in 4-8am)`
|
|
237
|
+
);
|
|
238
|
+
const sorted = [...posts].sort((a, b) => a.created_at - b.created_at);
|
|
239
|
+
const intervals = [];
|
|
240
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
241
|
+
intervals.push(sorted[i].created_at - sorted[i - 1].created_at);
|
|
242
|
+
}
|
|
243
|
+
const meanInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
244
|
+
const variance = intervals.reduce((a, b) => a + (b - meanInterval) ** 2, 0) / intervals.length;
|
|
245
|
+
const cv = Math.sqrt(variance) / meanInterval;
|
|
246
|
+
notes.push(`interval CV: ${cv.toFixed(2)} (lower = more regular = agent-like)`);
|
|
247
|
+
let score = 0;
|
|
248
|
+
score += entropyScore * 0.4;
|
|
249
|
+
score += (1 - Math.abs(businessRatio - 0.15)) * 100 * 0.25;
|
|
250
|
+
score += (hasDeadZone ? 0 : 100) * 0.2;
|
|
251
|
+
score += Math.max(0, 100 - cv * 30) * 0.15;
|
|
252
|
+
return {
|
|
253
|
+
detector: "cadence",
|
|
254
|
+
score: Math.round(Math.max(0, Math.min(100, score))),
|
|
255
|
+
samples: posts.length,
|
|
256
|
+
notes,
|
|
257
|
+
runtime_ms: 0
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/detectors/onchain.ts
|
|
262
|
+
function onchainDetector(txs) {
|
|
263
|
+
const notes = [];
|
|
264
|
+
if (txs.length < 10) {
|
|
265
|
+
notes.push(`only ${txs.length} transactions, low confidence`);
|
|
266
|
+
return {
|
|
267
|
+
detector: "onchain",
|
|
268
|
+
score: 50,
|
|
269
|
+
samples: txs.length,
|
|
270
|
+
notes,
|
|
271
|
+
runtime_ms: 0
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const hourBuckets = new Array(24).fill(0);
|
|
275
|
+
for (const t of txs) {
|
|
276
|
+
const hour = new Date(t.timestamp * 1e3).getUTCHours();
|
|
277
|
+
hourBuckets[hour]++;
|
|
278
|
+
}
|
|
279
|
+
let entropy = 0;
|
|
280
|
+
for (const count of hourBuckets) {
|
|
281
|
+
if (count > 0) {
|
|
282
|
+
const p = count / txs.length;
|
|
283
|
+
entropy -= p * Math.log2(p);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const entropyScore = entropy / Math.log2(24) * 100;
|
|
287
|
+
notes.push(`onchain hour entropy: ${entropy.toFixed(2)} bits`);
|
|
288
|
+
const sorted = [...txs].sort((a, b) => a.timestamp - b.timestamp);
|
|
289
|
+
let bursts = 0;
|
|
290
|
+
for (let i = 0; i < sorted.length - 3; i++) {
|
|
291
|
+
const window = sorted[i + 3].timestamp - sorted[i].timestamp;
|
|
292
|
+
if (window <= 10) bursts++;
|
|
293
|
+
}
|
|
294
|
+
const burstRatio = bursts / Math.max(1, sorted.length - 3);
|
|
295
|
+
notes.push(`burst clusters: ${bursts} (${(burstRatio * 100).toFixed(1)}% of windows)`);
|
|
296
|
+
const programs = /* @__PURE__ */ new Set();
|
|
297
|
+
for (const t of txs) {
|
|
298
|
+
for (const p of t.programs ?? []) {
|
|
299
|
+
programs.add(p);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const diversityScore = Math.min(100, programs.size * 10);
|
|
303
|
+
notes.push(`${programs.size} unique programs touched`);
|
|
304
|
+
const fees = txs.map((t) => t.fee ?? 5e3).filter((f) => f > 0);
|
|
305
|
+
const meanFee = fees.reduce((a, b) => a + b, 0) / Math.max(1, fees.length);
|
|
306
|
+
const feeStd = Math.sqrt(
|
|
307
|
+
fees.reduce((a, b) => a + (b - meanFee) ** 2, 0) / Math.max(1, fees.length)
|
|
308
|
+
);
|
|
309
|
+
const feeCV = meanFee > 0 ? feeStd / meanFee : 0;
|
|
310
|
+
notes.push(`fee CV: ${feeCV.toFixed(2)}`);
|
|
311
|
+
let score = 0;
|
|
312
|
+
score += entropyScore * 0.35;
|
|
313
|
+
score += Math.min(100, burstRatio * 300) * 0.3;
|
|
314
|
+
score += (100 - diversityScore) * 0.2;
|
|
315
|
+
score += Math.max(0, 100 - feeCV * 50) * 0.15;
|
|
316
|
+
return {
|
|
317
|
+
detector: "onchain",
|
|
318
|
+
score: Math.round(Math.max(0, Math.min(100, score))),
|
|
319
|
+
samples: txs.length,
|
|
320
|
+
notes,
|
|
321
|
+
runtime_ms: 0
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/detectors/voice.ts
|
|
326
|
+
function voiceDetector(posts) {
|
|
327
|
+
const notes = [];
|
|
328
|
+
if (posts.length < 15) {
|
|
329
|
+
notes.push(`only ${posts.length} posts, low confidence`);
|
|
330
|
+
return {
|
|
331
|
+
detector: "voice",
|
|
332
|
+
score: 50,
|
|
333
|
+
samples: posts.length,
|
|
334
|
+
notes,
|
|
335
|
+
runtime_ms: 0
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const allTokens = [];
|
|
339
|
+
for (const p of posts) {
|
|
340
|
+
const tokens = tokenize(p.text);
|
|
341
|
+
allTokens.push(...tokens);
|
|
342
|
+
}
|
|
343
|
+
const uniqueRatio = new Set(allTokens).size / allTokens.length;
|
|
344
|
+
notes.push(`lexical diversity: ${uniqueRatio.toFixed(3)}`);
|
|
345
|
+
let typoCount = 0;
|
|
346
|
+
for (const p of posts) {
|
|
347
|
+
if (/([a-z])\1{2,}/i.test(p.text)) typoCount++;
|
|
348
|
+
if (/\b[bcdfghjklmnpqrstvwxyz]{4,}\b/i.test(p.text)) typoCount++;
|
|
349
|
+
}
|
|
350
|
+
const typoRate = typoCount / posts.length;
|
|
351
|
+
notes.push(`typo rate: ${(typoRate * 100).toFixed(1)}%`);
|
|
352
|
+
const trigrams = /* @__PURE__ */ new Map();
|
|
353
|
+
for (const p of posts) {
|
|
354
|
+
const tokens = tokenize(p.text);
|
|
355
|
+
for (let i = 0; i < tokens.length - 2; i++) {
|
|
356
|
+
const key = `${tokens[i]} ${tokens[i + 1]} ${tokens[i + 2]}`;
|
|
357
|
+
trigrams.set(key, (trigrams.get(key) ?? 0) + 1);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const repeated = Array.from(trigrams.values()).filter((v) => v > 1).length;
|
|
361
|
+
const repetitionRate = repeated / Math.max(1, trigrams.size);
|
|
362
|
+
notes.push(`phrase repetition: ${(repetitionRate * 100).toFixed(1)}%`);
|
|
363
|
+
const emojiRegex = /[\p{Emoji_Presentation}]/u;
|
|
364
|
+
const withEmoji = posts.filter((p) => emojiRegex.test(p.text)).length;
|
|
365
|
+
const emojiRatio = withEmoji / posts.length;
|
|
366
|
+
const emojiConsistency = Math.abs(emojiRatio - 0.5) * 2;
|
|
367
|
+
notes.push(`emoji ratio: ${(emojiRatio * 100).toFixed(0)}%`);
|
|
368
|
+
let score = 0;
|
|
369
|
+
score += (1 - uniqueRatio) * 100 * 0.35;
|
|
370
|
+
score += (1 - typoRate) * 100 * 0.25;
|
|
371
|
+
score += repetitionRate * 100 * 0.2;
|
|
372
|
+
score += emojiConsistency * 100 * 0.2;
|
|
373
|
+
return {
|
|
374
|
+
detector: "voice",
|
|
375
|
+
score: Math.round(Math.max(0, Math.min(100, score))),
|
|
376
|
+
samples: posts.length,
|
|
377
|
+
notes,
|
|
378
|
+
runtime_ms: 0
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function tokenize(text) {
|
|
382
|
+
return text.toLowerCase().replace(/https?:\/\/\S+/g, "").replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((t) => t.length > 1);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/detectors/timing.ts
|
|
386
|
+
function timingDetector(posts, txs) {
|
|
387
|
+
const notes = [];
|
|
388
|
+
let anomalies = 0;
|
|
389
|
+
const quickReplies = posts.filter(
|
|
390
|
+
(p) => p.in_reply_to_id && p.reply_delta_ms !== void 0 && p.reply_delta_ms < 1e3
|
|
391
|
+
).length;
|
|
392
|
+
if (quickReplies > 0) {
|
|
393
|
+
anomalies += Math.min(3, quickReplies);
|
|
394
|
+
notes.push(`${quickReplies} sub-second reply posts`);
|
|
395
|
+
}
|
|
396
|
+
const sortedPosts = [...posts].sort((a, b) => a.created_at - b.created_at);
|
|
397
|
+
let regular = 0;
|
|
398
|
+
for (let i = 2; i < sortedPosts.length; i++) {
|
|
399
|
+
const d1 = sortedPosts[i].created_at - sortedPosts[i - 1].created_at;
|
|
400
|
+
const d2 = sortedPosts[i - 1].created_at - sortedPosts[i - 2].created_at;
|
|
401
|
+
if (Math.abs(d1 - d2) < 5 && d1 > 0) regular++;
|
|
402
|
+
}
|
|
403
|
+
if (regular >= 5) {
|
|
404
|
+
anomalies++;
|
|
405
|
+
notes.push(`${regular} posts with cron-like regularity`);
|
|
406
|
+
}
|
|
407
|
+
let simultaneous = 0;
|
|
408
|
+
const txTimes = new Set(txs.map((t) => t.timestamp));
|
|
409
|
+
for (const p of posts) {
|
|
410
|
+
for (let offset = -1; offset <= 1; offset++) {
|
|
411
|
+
if (txTimes.has(p.created_at + offset)) {
|
|
412
|
+
simultaneous++;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (simultaneous >= 3) {
|
|
418
|
+
anomalies++;
|
|
419
|
+
notes.push(`${simultaneous} simultaneous X+onchain events (within 1s)`);
|
|
420
|
+
}
|
|
421
|
+
const deadHour = posts.filter((p) => {
|
|
422
|
+
const h = new Date(p.created_at * 1e3).getUTCHours();
|
|
423
|
+
return h >= 4 && h < 7;
|
|
424
|
+
}).length;
|
|
425
|
+
if (deadHour / posts.length > 0.1) {
|
|
426
|
+
anomalies++;
|
|
427
|
+
notes.push(`${deadHour} posts during 4-7am UTC dead zone`);
|
|
428
|
+
}
|
|
429
|
+
let burstAfterSilence = 0;
|
|
430
|
+
for (let i = 1; i < sortedPosts.length; i++) {
|
|
431
|
+
const gap = sortedPosts[i].created_at - sortedPosts[i - 1].created_at;
|
|
432
|
+
if (gap > 48 * 3600) {
|
|
433
|
+
const nextFive = sortedPosts.slice(i, i + 5);
|
|
434
|
+
if (nextFive.length === 5) {
|
|
435
|
+
const span = nextFive[4].created_at - nextFive[0].created_at;
|
|
436
|
+
if (span < 600) burstAfterSilence++;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (burstAfterSilence > 0) {
|
|
441
|
+
anomalies += burstAfterSilence;
|
|
442
|
+
notes.push(`${burstAfterSilence} silence-then-burst patterns`);
|
|
443
|
+
}
|
|
444
|
+
if (anomalies === 0) {
|
|
445
|
+
notes.push("no timing anomalies detected");
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
detector: "timing",
|
|
449
|
+
score: anomalies,
|
|
450
|
+
// raw anomaly count, aggregator normalizes
|
|
451
|
+
samples: posts.length + txs.length,
|
|
452
|
+
notes,
|
|
453
|
+
runtime_ms: 0
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/detectors/correlation.ts
|
|
458
|
+
function correlationDetector(posts, txs, target) {
|
|
459
|
+
const notes = [];
|
|
460
|
+
if (posts.length < 10 || txs.length < 10) {
|
|
461
|
+
notes.push(`insufficient data (posts=${posts.length}, txs=${txs.length})`);
|
|
462
|
+
return {
|
|
463
|
+
detector: "correlation",
|
|
464
|
+
score: 50,
|
|
465
|
+
samples: posts.length + txs.length,
|
|
466
|
+
notes,
|
|
467
|
+
runtime_ms: 0
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const postsByHour = /* @__PURE__ */ new Map();
|
|
471
|
+
const txsByHour = /* @__PURE__ */ new Map();
|
|
472
|
+
const bucket = (t) => Math.floor(t / 3600);
|
|
473
|
+
for (const p of posts) {
|
|
474
|
+
const b = bucket(p.created_at);
|
|
475
|
+
postsByHour.set(b, (postsByHour.get(b) ?? 0) + 1);
|
|
476
|
+
}
|
|
477
|
+
for (const t of txs) {
|
|
478
|
+
const b = bucket(t.timestamp);
|
|
479
|
+
txsByHour.set(b, (txsByHour.get(b) ?? 0) + 1);
|
|
480
|
+
}
|
|
481
|
+
const allHours = /* @__PURE__ */ new Set([...postsByHour.keys(), ...txsByHour.keys()]);
|
|
482
|
+
const postVec = [];
|
|
483
|
+
const txVec = [];
|
|
484
|
+
for (const h of allHours) {
|
|
485
|
+
postVec.push(postsByHour.get(h) ?? 0);
|
|
486
|
+
txVec.push(txsByHour.get(h) ?? 0);
|
|
487
|
+
}
|
|
488
|
+
const pearson = pearsonCorrelation(postVec, txVec);
|
|
489
|
+
notes.push(`pearson r = ${pearson.toFixed(3)}`);
|
|
490
|
+
const postSpan = maxOf(posts.map((p) => p.created_at)) - minOf(posts.map((p) => p.created_at));
|
|
491
|
+
const txSpan = maxOf(txs.map((t) => t.timestamp)) - minOf(txs.map((t) => t.timestamp));
|
|
492
|
+
const postStart = minOf(posts.map((p) => p.created_at));
|
|
493
|
+
const txStart = minOf(txs.map((t) => t.timestamp));
|
|
494
|
+
const overlapStart = Math.max(postStart, txStart);
|
|
495
|
+
const overlapEnd = Math.min(postStart + postSpan, txStart + txSpan);
|
|
496
|
+
const overlap = Math.max(0, overlapEnd - overlapStart);
|
|
497
|
+
const overlapRatio = overlap / Math.max(postSpan, txSpan, 1);
|
|
498
|
+
notes.push(`timeline overlap: ${(overlapRatio * 100).toFixed(1)}%`);
|
|
499
|
+
const corrScore = Math.max(0, pearson) * 100;
|
|
500
|
+
const overlapScore = overlapRatio * 100;
|
|
501
|
+
const score = corrScore * 0.7 + overlapScore * 0.3;
|
|
502
|
+
notes.push(`target: @${target.handle}, ${target.wallet.slice(0, 8)}...`);
|
|
503
|
+
return {
|
|
504
|
+
detector: "correlation",
|
|
505
|
+
score: Math.round(Math.max(0, Math.min(100, score))),
|
|
506
|
+
samples: posts.length + txs.length,
|
|
507
|
+
notes,
|
|
508
|
+
runtime_ms: 0
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function pearsonCorrelation(x, y) {
|
|
512
|
+
if (x.length !== y.length || x.length === 0) return 0;
|
|
513
|
+
const n = x.length;
|
|
514
|
+
const meanX = x.reduce((a, b) => a + b, 0) / n;
|
|
515
|
+
const meanY = y.reduce((a, b) => a + b, 0) / n;
|
|
516
|
+
let num = 0;
|
|
517
|
+
let denX = 0;
|
|
518
|
+
let denY = 0;
|
|
519
|
+
for (let i = 0; i < n; i++) {
|
|
520
|
+
const dx = x[i] - meanX;
|
|
521
|
+
const dy = y[i] - meanY;
|
|
522
|
+
num += dx * dy;
|
|
523
|
+
denX += dx * dx;
|
|
524
|
+
denY += dy * dy;
|
|
525
|
+
}
|
|
526
|
+
const denom = Math.sqrt(denX * denY);
|
|
527
|
+
return denom === 0 ? 0 : num / denom;
|
|
528
|
+
}
|
|
529
|
+
function minOf(arr) {
|
|
530
|
+
return arr.reduce((a, b) => a < b ? a : b, arr[0] ?? 0);
|
|
531
|
+
}
|
|
532
|
+
function maxOf(arr) {
|
|
533
|
+
return arr.reduce((a, b) => a > b ? a : b, arr[0] ?? 0);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/core/aggregator.ts
|
|
537
|
+
var DEFAULT_WEIGHTS = {
|
|
538
|
+
cadence: 0.25,
|
|
539
|
+
onchain: 0.25,
|
|
540
|
+
voice: 0.2,
|
|
541
|
+
timing: 0.15,
|
|
542
|
+
correlation: 0.15
|
|
543
|
+
};
|
|
544
|
+
var DEFAULT_THRESHOLDS = {
|
|
545
|
+
autonomous: 80,
|
|
546
|
+
hybrid: 40
|
|
547
|
+
};
|
|
548
|
+
var DISAGREEMENT_DELTA = 40;
|
|
549
|
+
function aggregate(target, detectors, options = {}) {
|
|
550
|
+
const weights = { ...DEFAULT_WEIGHTS, ...options.weights };
|
|
551
|
+
const thresholds = options.thresholds ?? DEFAULT_THRESHOLDS;
|
|
552
|
+
const normalized = {};
|
|
553
|
+
for (const d of detectors) {
|
|
554
|
+
if (d.detector === "timing") {
|
|
555
|
+
normalized.timing = Math.max(0, 100 - d.score * 10);
|
|
556
|
+
} else {
|
|
557
|
+
normalized[d.detector] = d.score;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const presentKeys = Object.keys(normalized);
|
|
561
|
+
const totalWeight = presentKeys.reduce((s, k) => s + (weights[k] ?? 0), 0);
|
|
562
|
+
let aggregate2 = 0;
|
|
563
|
+
for (const k of presentKeys) {
|
|
564
|
+
const w = (weights[k] ?? 0) / totalWeight;
|
|
565
|
+
aggregate2 += (normalized[k] ?? 0) * w;
|
|
566
|
+
}
|
|
567
|
+
const scoreValues = presentKeys.map((k) => normalized[k] ?? 0);
|
|
568
|
+
const maxScore = Math.max(...scoreValues);
|
|
569
|
+
const minScore = Math.min(...scoreValues);
|
|
570
|
+
const disagreement = maxScore - minScore;
|
|
571
|
+
const hasDisagreement = disagreement >= DISAGREEMENT_DELTA;
|
|
572
|
+
let verdict;
|
|
573
|
+
if (aggregate2 >= thresholds.autonomous && !hasDisagreement) {
|
|
574
|
+
verdict = "AUTONOMOUS";
|
|
575
|
+
} else if (aggregate2 >= thresholds.hybrid || hasDisagreement) {
|
|
576
|
+
verdict = "HYBRID";
|
|
577
|
+
} else {
|
|
578
|
+
verdict = "HUMAN";
|
|
579
|
+
}
|
|
580
|
+
const confidence = computeConfidence(aggregate2, thresholds, hasDisagreement);
|
|
581
|
+
const signals = {
|
|
582
|
+
posting_cadence: Math.round(normalized.cadence ?? 0),
|
|
583
|
+
onchain_cadence: Math.round(normalized.onchain ?? 0),
|
|
584
|
+
voice_consistency: Math.round(normalized.voice ?? 0),
|
|
585
|
+
timing_anomalies: detectors.find((d) => d.detector === "timing")?.score ?? 0,
|
|
586
|
+
correlation: Math.round(normalized.correlation ?? 0)
|
|
587
|
+
};
|
|
588
|
+
return {
|
|
589
|
+
id: `vrd_${generateId()}`,
|
|
590
|
+
target,
|
|
591
|
+
verdict,
|
|
592
|
+
confidence: Number(confidence.toFixed(3)),
|
|
593
|
+
aggregate_score: Number(aggregate2.toFixed(2)),
|
|
594
|
+
signals,
|
|
595
|
+
detectors,
|
|
596
|
+
scanned_at: Math.floor(Date.now() / 1e3),
|
|
597
|
+
version: VERSION
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
function computeConfidence(aggregate2, thresholds, hasDisagreement) {
|
|
601
|
+
if (hasDisagreement) {
|
|
602
|
+
return 0.55 + Math.random() * 0.2;
|
|
603
|
+
}
|
|
604
|
+
let distance;
|
|
605
|
+
if (aggregate2 >= thresholds.autonomous) {
|
|
606
|
+
distance = aggregate2 - thresholds.autonomous;
|
|
607
|
+
} else if (aggregate2 >= thresholds.hybrid) {
|
|
608
|
+
distance = Math.min(
|
|
609
|
+
aggregate2 - thresholds.hybrid,
|
|
610
|
+
thresholds.autonomous - aggregate2
|
|
611
|
+
);
|
|
612
|
+
} else {
|
|
613
|
+
distance = thresholds.hybrid - aggregate2;
|
|
614
|
+
}
|
|
615
|
+
return Math.min(0.98, 0.55 + distance / 40 * 0.43);
|
|
616
|
+
}
|
|
617
|
+
function generateId() {
|
|
618
|
+
const bytes = new Uint8Array(6);
|
|
619
|
+
crypto.getRandomValues(bytes);
|
|
620
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/core/client.ts
|
|
624
|
+
var PulseProtocol = class {
|
|
625
|
+
events;
|
|
626
|
+
helius;
|
|
627
|
+
x;
|
|
628
|
+
config;
|
|
629
|
+
constructor(config) {
|
|
630
|
+
if (!config.heliusApiKey) {
|
|
631
|
+
throw new Error("heliusApiKey is required. Get one at https://helius.xyz");
|
|
632
|
+
}
|
|
633
|
+
if (!config.xApiKey) {
|
|
634
|
+
throw new Error("xApiKey is required.");
|
|
635
|
+
}
|
|
636
|
+
this.config = {
|
|
637
|
+
heliusApiKey: config.heliusApiKey,
|
|
638
|
+
xApiKey: config.xApiKey,
|
|
639
|
+
rpcUrl: config.rpcUrl,
|
|
640
|
+
feedUrl: config.feedUrl,
|
|
641
|
+
signingKey: config.signingKey,
|
|
642
|
+
timeout: config.timeout ?? 3e4,
|
|
643
|
+
maxRetries: config.maxRetries ?? 2
|
|
644
|
+
};
|
|
645
|
+
this.helius = new HeliusSource({
|
|
646
|
+
apiKey: config.heliusApiKey,
|
|
647
|
+
rpcUrl: config.rpcUrl,
|
|
648
|
+
timeout: this.config.timeout,
|
|
649
|
+
maxRetries: this.config.maxRetries
|
|
650
|
+
});
|
|
651
|
+
this.x = new XSource({
|
|
652
|
+
apiKey: config.xApiKey,
|
|
653
|
+
timeout: this.config.timeout,
|
|
654
|
+
maxRetries: this.config.maxRetries
|
|
655
|
+
});
|
|
656
|
+
this.events = new EventEmitter();
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Run a full scan on a target and return a verdict.
|
|
660
|
+
*
|
|
661
|
+
* This fetches X posts, on-chain history, cross-correlates them, runs all
|
|
662
|
+
* five detectors, and aggregates the result. A typical scan completes in
|
|
663
|
+
* 2-8 seconds depending on source latency.
|
|
664
|
+
*/
|
|
665
|
+
async scan(input, options = {}) {
|
|
666
|
+
this.events.emit("scan:start", input);
|
|
667
|
+
const [posts, txs] = await Promise.all([
|
|
668
|
+
this.x.getRecentPosts(input.handle, { limit: 200 }),
|
|
669
|
+
this.helius.getWalletHistory(input.wallet, { limit: 500 })
|
|
670
|
+
]);
|
|
671
|
+
if (posts.length === 0 && txs.length === 0) {
|
|
672
|
+
throw new ScanError(
|
|
673
|
+
`No data found for @${input.handle} or wallet ${input.wallet.slice(0, 8)}...`
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
const requested = options.only ?? ["cadence", "onchain", "voice", "timing", "correlation"];
|
|
677
|
+
const results = [];
|
|
678
|
+
for (const name of requested) {
|
|
679
|
+
const started = Date.now();
|
|
680
|
+
try {
|
|
681
|
+
let result;
|
|
682
|
+
switch (name) {
|
|
683
|
+
case "cadence":
|
|
684
|
+
result = cadenceDetector(posts);
|
|
685
|
+
break;
|
|
686
|
+
case "onchain":
|
|
687
|
+
result = onchainDetector(txs);
|
|
688
|
+
break;
|
|
689
|
+
case "voice":
|
|
690
|
+
result = voiceDetector(posts);
|
|
691
|
+
break;
|
|
692
|
+
case "timing":
|
|
693
|
+
result = timingDetector(posts, txs);
|
|
694
|
+
break;
|
|
695
|
+
case "correlation":
|
|
696
|
+
result = correlationDetector(posts, txs, input);
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
result.runtime_ms = Date.now() - started;
|
|
700
|
+
results.push(result);
|
|
701
|
+
this.events.emit("detector:done", result);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
throw new ScanError(
|
|
704
|
+
`Detector ${name} failed: ${err.message}`,
|
|
705
|
+
name
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const verdict = aggregate(input, results, options);
|
|
710
|
+
this.events.emit("scan:done", verdict);
|
|
711
|
+
return verdict;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Scan a batch of targets concurrently. Respects the configured rate limits
|
|
715
|
+
* on underlying sources. Use for watchlists.
|
|
716
|
+
*/
|
|
717
|
+
async scanMany(inputs, options = {}) {
|
|
718
|
+
const results = [];
|
|
719
|
+
for (const input of inputs) {
|
|
720
|
+
try {
|
|
721
|
+
results.push(await this.scan(input, options));
|
|
722
|
+
} catch (err) {
|
|
723
|
+
this.events.emit("scan:error", { input, error: err });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return results;
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// src/core/signing.ts
|
|
731
|
+
import * as ed from "@noble/ed25519";
|
|
732
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
733
|
+
function canonicalize(verdict) {
|
|
734
|
+
const sorted = JSON.stringify(verdict, Object.keys(verdict).sort());
|
|
735
|
+
return new TextEncoder().encode(sorted);
|
|
736
|
+
}
|
|
737
|
+
function commitmentHash(verdict) {
|
|
738
|
+
const payload = `${verdict.target.wallet}|${verdict.target.handle}|${verdict.aggregate_score}|${verdict.scanned_at}`;
|
|
739
|
+
const digest = sha256(new TextEncoder().encode(payload));
|
|
740
|
+
return Buffer.from(digest).toString("hex");
|
|
741
|
+
}
|
|
742
|
+
async function signVerdict(verdict, privateKeyHex, feedUrl) {
|
|
743
|
+
const priv = Buffer.from(privateKeyHex, "hex");
|
|
744
|
+
if (priv.length !== 32) {
|
|
745
|
+
throw new SignatureError(
|
|
746
|
+
`ed25519 private key must be 32 bytes, got ${priv.length}`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
const message = canonicalize(verdict);
|
|
750
|
+
const signature = await ed.signAsync(message, priv);
|
|
751
|
+
return {
|
|
752
|
+
...verdict,
|
|
753
|
+
signature: Buffer.from(signature).toString("hex"),
|
|
754
|
+
commitment: commitmentHash(verdict),
|
|
755
|
+
posted_at: Math.floor(Date.now() / 1e3),
|
|
756
|
+
feed_url: feedUrl
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
async function verifyVerdict(publicVerdict, publicKeyHex) {
|
|
760
|
+
const pub = Buffer.from(publicKeyHex, "hex");
|
|
761
|
+
if (pub.length !== 32) {
|
|
762
|
+
throw new SignatureError(
|
|
763
|
+
`ed25519 public key must be 32 bytes, got ${pub.length}`
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
const { signature, commitment, posted_at, feed_url, ...verdictOnly } = publicVerdict;
|
|
767
|
+
const expectedCommitment = commitmentHash(verdictOnly);
|
|
768
|
+
if (expectedCommitment !== commitment) {
|
|
769
|
+
throw new SignatureError(
|
|
770
|
+
`commitment mismatch: ${expectedCommitment} vs ${commitment}`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
const message = canonicalize(verdictOnly);
|
|
774
|
+
const sig = Buffer.from(signature, "hex");
|
|
775
|
+
return ed.verifyAsync(sig, message, pub);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// src/index.ts
|
|
779
|
+
var VERSION = "0.9.3";
|
|
780
|
+
export {
|
|
781
|
+
PulseError,
|
|
782
|
+
PulseProtocol,
|
|
783
|
+
ScanError,
|
|
784
|
+
SignatureError,
|
|
785
|
+
SourceError,
|
|
786
|
+
VERSION,
|
|
787
|
+
aggregate,
|
|
788
|
+
signVerdict,
|
|
789
|
+
verifyVerdict
|
|
790
|
+
};
|
|
791
|
+
//# sourceMappingURL=index.js.map
|