-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsession-manager.js
More file actions
399 lines (354 loc) · 13.9 KB
/
session-manager.js
File metadata and controls
399 lines (354 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
/**
* Sentinel dVPN SDK — Session Manager
*
* Reusable session management class ported from the Node Tester's battle-tested
* implementation. Provides:
* - Paginated session map (all active sessions for a wallet)
* - Session reuse (find existing session for a node)
* - Credential cache (disk-persistent WG keys / V2Ray UUIDs)
* - Session poisoning (track failed handshakes)
* - Duplicate payment guard (prevent double-paying in a run)
*
* Usage:
* import { SessionManager } from './session-manager.js';
* const mgr = new SessionManager('https://lcd.sentinel.co', 'sent1...');
* await mgr.buildSessionMap();
* const sid = await mgr.findExistingSession('sentnode1...');
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
import path from 'path';
import os from 'os';
import { ChainError, ErrorCodes } from './errors.js';
import { DEFAULT_LCD } from './defaults.js';
import { querySessions } from './chain/queries.js';
import { loadPoisonedKeys, savePoisonedKeys } from './state.js';
// ─── Constants ───────────────────────────────────────────────────────────────
const STATE_DIR = path.join(os.homedir(), '.sentinel-sdk');
const CRED_FILE = path.join(STATE_DIR, 'session-credentials.json');
/** Default session map TTL: 5 minutes */
const DEFAULT_MAP_TTL = 5 * 60 * 1000;
// ─── SessionManager Class ────────────────────────────────────────────────────
/**
* Manages session state for a wallet: session map, credential cache,
* poisoning, and duplicate payment tracking.
*
* @example
* const mgr = new SessionManager('https://lcd.sentinel.co', 'sent1abc...');
* await mgr.buildSessionMap();
* const sid = await mgr.findExistingSession('sentnode1xyz...');
* if (sid) console.log(`Reuse session ${sid}`);
*/
export class SessionManager {
/**
* @param {string} lcdUrl - LCD endpoint URL (falls back to DEFAULT_LCD)
* @param {string} walletAddress - Wallet address (sent1...)
* @param {object} [options]
* @param {number} [options.mapTtl=300000] - Session map cache TTL in ms (default 5 min)
* @param {string} [options.credentialPath] - Custom path for credential cache file
* @param {Function} [options.logger] - Optional logger function (msg) => void
*/
constructor(lcdUrl, walletAddress, options = {}) {
this._lcdUrl = lcdUrl || DEFAULT_LCD;
this._walletAddress = walletAddress;
this._mapTtl = options.mapTtl ?? DEFAULT_MAP_TTL;
this._credPath = options.credentialPath || CRED_FILE;
this._logger = options.logger || null;
/** @type {Map<string, {sessionId: bigint, maxBytes: number, usedBytes: number}>|null} */
this._sessionMap = null;
this._sessionMapAt = 0;
/** @type {Set<string>} Poisoned session keys: "nodeAddr:sessionId" */
this._poisoned = new Set(loadPoisonedKeys());
/** @type {Set<string>} Nodes paid this run */
this._paidNodes = new Set();
/** @type {object|null} In-memory credential cache (lazy-loaded from disk) */
this._credentials = null;
}
// ─── Session Map ─────────────────────────────────────────────────────────
/**
* Fetch ALL active sessions for the wallet with full pagination.
* Builds a Map<nodeAddr, {sessionId, maxBytes, usedBytes}> for O(1) lookups.
* Skips exhausted sessions (used >= max), wrong-wallet sessions, and poisoned sessions.
*
* @param {string} [walletAddress] - Override wallet address (default: constructor value)
* @returns {Promise<Map<string, {sessionId: bigint, maxBytes: number, usedBytes: number}>>}
*/
async buildSessionMap(walletAddress) {
const addr = walletAddress || this._walletAddress;
if (!addr) {
throw new ChainError(
ErrorCodes.INVALID_OPTIONS,
'buildSessionMap requires a wallet address',
);
}
const map = new Map();
let items;
try {
// RPC-first via chain/queries.js — returns flattened sessions
const result = await querySessions(addr, this._lcdUrl, { status: '1' });
items = result.items || [];
} catch (err) {
throw new ChainError(
ErrorCodes.LCD_ERROR,
`Failed to build session map: ${err.message}`,
{ walletAddress: addr, original: err.message },
);
}
for (const s of items) {
// querySessions returns flat sessions (base_session unwrapped)
const bs = s.base_session || s;
const nodeAddr = bs.node_address || bs.node;
if (!nodeAddr) continue;
const acct = bs.acc_address || bs.address;
if (acct && acct !== addr) continue;
// RPC returns status as number (1=active), LCD as string
if (bs.status && bs.status !== 'active' && bs.status !== 1) continue;
const maxBytes = parseInt(bs.max_bytes || '0');
const used = parseInt(bs.download_bytes || '0') + parseInt(bs.upload_bytes || '0');
if (maxBytes > 0 && used >= maxBytes) continue;
const sid = BigInt(bs.id);
if (this.isPoisoned(nodeAddr, String(sid))) continue;
// Keep the session with the most remaining bandwidth per node
const existing = map.get(nodeAddr);
if (!existing || (maxBytes - used) > (existing.maxBytes - existing.usedBytes)) {
map.set(nodeAddr, { sessionId: sid, maxBytes, usedBytes: used });
}
}
this._sessionMap = map;
this._sessionMapAt = Date.now();
this._log(`Session map: ${map.size} reusable sessions (${items.length} fetched)`);
return map;
}
/**
* Find an existing reusable session for a node.
* Auto-refreshes the session map if stale or missing.
*
* @param {string} nodeAddr - Node address (sentnode1...)
* @returns {Promise<bigint|null>} Session ID or null
*/
async findExistingSession(nodeAddr) {
try {
const now = Date.now();
if (!this._sessionMap || now - this._sessionMapAt > this._mapTtl) {
await this.buildSessionMap();
}
const entry = this._sessionMap?.get(nodeAddr);
return entry ? entry.sessionId : null;
} catch (err) {
if (err?.name !== 'AbortError' && !/timeout|ECONNREFUSED|ENOTFOUND/i.test(err?.message || '')) {
this._log(`findExistingSession error: ${err.message}`);
}
return null;
}
}
/**
* Invalidate the session map cache, forcing a full refetch on next access.
*/
invalidateSessionMap() {
this._sessionMap = null;
this._sessionMapAt = 0;
}
/**
* Manually add a session to the map (e.g. after batch payment creates new sessions).
*
* @param {string} nodeAddr - Node address
* @param {bigint} sessionId - Session ID
* @param {number} [maxBytes=1000000000] - Max bytes (default 1 GB)
*/
addToSessionMap(nodeAddr, sessionId, maxBytes = 1_000_000_000) {
if (!this._sessionMap) this._sessionMap = new Map();
this._sessionMap.set(nodeAddr, { sessionId, maxBytes, usedBytes: 0 });
}
/**
* Get the current session map (may be null if never built).
*
* @returns {Map<string, {sessionId: bigint, maxBytes: number, usedBytes: number}>|null}
*/
getSessionMap() {
return this._sessionMap;
}
// ─── Credential Cache (disk-persistent) ──────────────────────────────────
/**
* Save handshake credentials for a node (WG keys, V2Ray UUID, etc.).
* Persists to disk at ~/.sentinel-sdk/session-credentials.json.
*
* @param {string} nodeAddr - Node address (sentnode1...)
* @param {object} data - Credential data to save
*/
saveCredential(nodeAddr, data) {
this._loadCredentials();
this._credentials[nodeAddr] = { ...data, savedAt: new Date().toISOString() };
this._writeCredentials();
}
/**
* Get cached credentials for a node.
*
* @param {string} nodeAddr - Node address
* @returns {object|null} Credential data or null
*/
getCredential(nodeAddr) {
this._loadCredentials();
return this._credentials[nodeAddr] || null;
}
/**
* Clear cached credentials for a node.
*
* @param {string} nodeAddr - Node address
*/
clearCredential(nodeAddr) {
this._loadCredentials();
delete this._credentials[nodeAddr];
this._writeCredentials();
}
/**
* Clear all cached credentials.
*/
clearAllCredentials() {
this._credentials = {};
this._writeCredentials();
}
/** @private Load credential store from disk (lazy, once). */
_loadCredentials() {
if (this._credentials !== null) return;
try {
if (existsSync(this._credPath)) {
this._credentials = JSON.parse(readFileSync(this._credPath, 'utf8'));
} else {
this._credentials = {};
}
} catch {
this._credentials = {};
}
}
/** @private Write credential store to disk with atomic rename. */
_writeCredentials() {
try {
mkdirSync(path.dirname(this._credPath), { recursive: true, mode: 0o700 });
const tmp = this._credPath + '.tmp';
writeFileSync(tmp, JSON.stringify(this._credentials, null, 2), { encoding: 'utf8', mode: 0o600 });
renameSync(tmp, this._credPath);
} catch (err) {
this._log(`Failed to write credentials: ${err.message}`);
}
}
// ─── Session Poisoning ───────────────────────────────────────────────────
/**
* Mark a session as poisoned (failed handshake — should not be reused).
*
* @param {string} nodeAddr - Node address
* @param {string|bigint} sessionId - Session ID
*/
markPoisoned(nodeAddr, sessionId) {
this._poisoned.add(`${nodeAddr}:${sessionId}`);
savePoisonedKeys([...this._poisoned]);
}
/**
* Check if a session is poisoned.
*
* @param {string} nodeAddr - Node address
* @param {string|bigint} sessionId - Session ID
* @returns {boolean}
*/
isPoisoned(nodeAddr, sessionId) {
return this._poisoned.has(`${nodeAddr}:${sessionId}`);
}
/**
* Clear all poisoned session markers.
*/
clearPoisonedSessions() {
this._poisoned.clear();
savePoisonedKeys([]);
}
// ─── Duplicate Payment Guard ─────────────────────────────────────────────
/**
* Mark a node as paid in this run (prevents double-paying).
*
* @param {string} nodeAddr - Node address
*/
markPaid(nodeAddr) {
this._paidNodes.add(nodeAddr);
}
/**
* Check if a node has been paid in this run.
*
* @param {string} nodeAddr - Node address
* @returns {boolean}
*/
isPaid(nodeAddr) {
return this._paidNodes.has(nodeAddr);
}
/**
* Clear all paid node markers (e.g. at start of a new scan run).
*/
clearPaidNodes() {
this._paidNodes.clear();
}
// ─── Internals ───────────────────────────────────────────────────────────
/** @private Log a message if a logger is configured. */
_log(msg) {
if (this._logger) this._logger(msg);
}
}
// ─── Multi-message tx session extraction ─────────────────────────────────────
/**
* Extract session IDs keyed by node_address from a multi-message transaction.
*
* Background: a single tx can carry up to N MsgStartSession messages. The chain
* emits a `session_id` per session, but on most chain heights the events do NOT
* include `node_address` alongside the `session_id`. Confirmed empirically
* 2026-03-23. Naively pairing events to messages by index causes address
* mismatch, so any session_id without a colocated node_address is reported as
* an "orphan" — the caller MUST resolve it to a node by querying the chain.
*
* Returns a Map with two non-enumerable hint fields attached:
* - `_orphanIds` Array<bigint> session IDs needing chain lookup
* - `_needsChainLookup` boolean true if any expected node is unmapped
*
* @param {{ events?: Array }} txResult - Tx broadcast result (RPC or LCD shape)
* @param {string[]} [nodeAddrs] - Expected node addresses (used to compute
* `_needsChainLookup`). If omitted, the flag is true whenever orphans exist.
* @returns {Map<string, bigint> & { _orphanIds: bigint[], _needsChainLookup: boolean }}
*
* @example
* const map = extractSessionMap(txResult, batch.map(b => b.node.address));
* if (map._needsChainLookup) {
* for (const sid of map._orphanIds) {
* const session = await querySessionById(client, sid);
* map.set(session.nodeAddress, sid);
* }
* }
*/
export function extractSessionMap(txResult, nodeAddrs) {
const map = new Map();
const orphanIds = [];
for (const event of (txResult?.events || [])) {
if (!/session/i.test(event.type)) continue;
let sessionId = null;
let nodeAddr = null;
for (const attr of (event.attributes || [])) {
const k = typeof attr.key === 'string'
? attr.key
: Buffer.from(attr.key, 'base64').toString('utf8');
const rawV = typeof attr.value === 'string'
? attr.value
: Buffer.from(attr.value, 'base64').toString('utf8');
const clean = rawV.replace(/"/g, '');
if (k === 'session_id' || k === 'id') {
try {
const id = BigInt(clean);
if (id > 0n) sessionId = id;
} catch { /* not a numeric id; skip */ }
}
if (k === 'node_address') nodeAddr = clean;
}
if (sessionId && nodeAddr) {
map.set(nodeAddr, sessionId);
} else if (sessionId) {
orphanIds.push(sessionId);
}
}
// Chain events NEVER include node_address on most heights. Caller resolves.
map._orphanIds = orphanIds;
map._needsChainLookup = orphanIds.length > 0
&& map.size < (nodeAddrs?.length || Number.POSITIVE_INFINITY);
return map;
}