walletpair-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/dist/ble/framing.d.ts +23 -0
  4. package/dist/ble/framing.d.ts.map +1 -0
  5. package/dist/ble/framing.js +83 -0
  6. package/dist/ble/framing.js.map +1 -0
  7. package/dist/ble/index.d.ts +9 -0
  8. package/dist/ble/index.d.ts.map +1 -0
  9. package/dist/ble/index.js +9 -0
  10. package/dist/ble/index.js.map +1 -0
  11. package/dist/ble/web-ble-transport.d.ts +29 -0
  12. package/dist/ble/web-ble-transport.d.ts.map +1 -0
  13. package/dist/ble/web-ble-transport.js +93 -0
  14. package/dist/ble/web-ble-transport.js.map +1 -0
  15. package/dist/crypto.d.ts +102 -0
  16. package/dist/crypto.d.ts.map +1 -0
  17. package/dist/crypto.js +279 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/dapp-session.d.ts +106 -0
  20. package/dist/dapp-session.d.ts.map +1 -0
  21. package/dist/dapp-session.js +918 -0
  22. package/dist/dapp-session.js.map +1 -0
  23. package/dist/emitter.d.ts +16 -0
  24. package/dist/emitter.d.ts.map +1 -0
  25. package/dist/emitter.js +41 -0
  26. package/dist/emitter.js.map +1 -0
  27. package/dist/evm/eip1193.d.ts +83 -0
  28. package/dist/evm/eip1193.d.ts.map +1 -0
  29. package/dist/evm/eip1193.js +270 -0
  30. package/dist/evm/eip1193.js.map +1 -0
  31. package/dist/evm/index.d.ts +8 -0
  32. package/dist/evm/index.d.ts.map +1 -0
  33. package/dist/evm/index.js +8 -0
  34. package/dist/evm/index.js.map +1 -0
  35. package/dist/evm/wagmi.d.ts +118 -0
  36. package/dist/evm/wagmi.d.ts.map +1 -0
  37. package/dist/evm/wagmi.js +205 -0
  38. package/dist/evm/wagmi.js.map +1 -0
  39. package/dist/index.d.ts +22 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +24 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/types.d.ts +225 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +31 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/wallet-session.d.ts +107 -0
  48. package/dist/wallet-session.d.ts.map +1 -0
  49. package/dist/wallet-session.js +794 -0
  50. package/dist/wallet-session.js.map +1 -0
  51. package/dist/ws-transport.d.ts +29 -0
  52. package/dist/ws-transport.d.ts.map +1 -0
  53. package/dist/ws-transport.js +79 -0
  54. package/dist/ws-transport.js.map +1 -0
  55. package/package.json +55 -0
  56. package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
  57. package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
  58. package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
  59. package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
  60. package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
  61. package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
  62. package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
  63. package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
  64. package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
  65. package/src/ble/framing.test.ts +196 -0
  66. package/src/ble/framing.ts +100 -0
  67. package/src/ble/index.ts +18 -0
  68. package/src/ble/web-ble-transport.test.ts +192 -0
  69. package/src/ble/web-ble-transport.ts +116 -0
  70. package/src/ble/web-bluetooth.d.ts +47 -0
  71. package/src/canonical-json.test.ts +612 -0
  72. package/src/crypto-directional.test.ts +263 -0
  73. package/src/crypto-hardening.test.ts +529 -0
  74. package/src/crypto.test.ts +635 -0
  75. package/src/crypto.ts +405 -0
  76. package/src/dapp-session.test.ts +647 -0
  77. package/src/dapp-session.ts +1004 -0
  78. package/src/emitter.test.ts +169 -0
  79. package/src/emitter.ts +45 -0
  80. package/src/evm/eip1193.test.ts +365 -0
  81. package/src/evm/eip1193.ts +346 -0
  82. package/src/evm/index.ts +19 -0
  83. package/src/evm/wagmi.test.ts +396 -0
  84. package/src/evm/wagmi.ts +321 -0
  85. package/src/index.ts +86 -0
  86. package/src/integration.test.ts +385 -0
  87. package/src/security.test.ts +430 -0
  88. package/src/sequence-validation.test.ts +1185 -0
  89. package/src/test-helpers.ts +216 -0
  90. package/src/types.test.ts +82 -0
  91. package/src/types.ts +305 -0
  92. package/src/wallet-session.test.ts +683 -0
  93. package/src/wallet-session.ts +922 -0
  94. package/src/ws-transport.test.ts +231 -0
  95. package/src/ws-transport.ts +92 -0
@@ -0,0 +1,794 @@
1
+ /**
2
+ * Wallet-side WalletPair session.
3
+ *
4
+ * Manages: parse URI → join → connected → handle requests → push events.
5
+ */
6
+ import { b64urlDecode, b64urlEncode, bytesToHex, canonicalJson, computeSessionFingerprint, computeSharedSecret, constantTimeEqual, deriveDirectionalSessionKeys, deriveJoinEncryptionKey, deriveSessionKey, generateX25519KeyPair, hexToBytes, parsePairingUri, sealJoin, sealPayload, sha256Hex, signSnapshot, unsealPayload, verifySnapshot, } from './crypto.js';
7
+ import { Emitter } from './emitter.js';
8
+ const BACKOFF = [1000, 2000, 5000, 10000, 30000];
9
+ const MAX_SEND_SEQ = 2 ** 31;
10
+ const MAX_PENDING_REQUESTS = 32; // §15 rule 11
11
+ const MAX_MESSAGE_BYTES = 65536; // §15 rule 10: 64 KB
12
+ const DEFAULT_SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours (§16 rule 16)
13
+ const IDEMPOTENCY_CACHE_LIMIT = 1024;
14
+ const IDEMPOTENCY_RESPONSE_LIMIT_BYTES = 16 * 1024;
15
+ const BROADCAST_CACHE_LIMIT = 256;
16
+ function isPromiseLike(value) {
17
+ return !!value && typeof value.then === 'function';
18
+ }
19
+ export class WalletSession extends Emitter {
20
+ phase = 'idle';
21
+ /** Channel ID (hex). Available after join. */
22
+ channelId = '';
23
+ /** 4-digit session fingerprint. Available after prepareJoin(). */
24
+ sessionFingerprint = '';
25
+ transport;
26
+ capabilities;
27
+ meta;
28
+ privKey;
29
+ pubKeyB64 = '';
30
+ remotePubKey = null;
31
+ sessionKey = null;
32
+ sendKey = null;
33
+ recvKey = null;
34
+ sendSeq = 0;
35
+ recvSeq = -1;
36
+ relayUrl = '';
37
+ dappName;
38
+ intentionalClose = false;
39
+ evtCounter = 0;
40
+ /** dApp-declared method scope from pairing URI (§9.1 / §8.1). */
41
+ dappDeclaredMethods;
42
+ /** dApp-declared chain scope from pairing URI (§9.1 / §8.1). */
43
+ dappDeclaredChains;
44
+ /** Effective capabilities after scope intersection (§8.1). */
45
+ effectiveCapabilities;
46
+ /** Session TTL in ms (§16 rule 16). */
47
+ sessionTtl;
48
+ sessionTtlTimer = null;
49
+ sessionStartTime = null;
50
+ reconnectTimer = null;
51
+ reconnectAttempt = 0;
52
+ pendingRequestRecords = new Map();
53
+ idempotencyCache = new Map();
54
+ broadcastResponseCache = new Map();
55
+ persistence;
56
+ constructor(options) {
57
+ super();
58
+ this.transport = options.transport;
59
+ this.capabilities = options.capabilities;
60
+ this.meta = options.meta;
61
+ this.sessionTtl = options.sessionTtl ?? DEFAULT_SESSION_TTL;
62
+ this.effectiveCapabilities = { ...options.capabilities };
63
+ this.persistence = options.persistence;
64
+ this.transport.onMessage((msg) => this.handleMessage(msg));
65
+ this.transport.onClose(() => this.handleTransportClose());
66
+ }
67
+ // -------------------------------------------------------------------------
68
+ // Public API
69
+ // -------------------------------------------------------------------------
70
+ /**
71
+ * Prepare to join a channel by parsing a pairing URI.
72
+ * Computes local keys and the session fingerprint without connecting.
73
+ * Returns the 4-digit session fingerprint for user verification.
74
+ */
75
+ prepareJoin(uri) {
76
+ const parsed = parsePairingUri(uri);
77
+ this.intentionalClose = false;
78
+ this.channelId = parsed.ch;
79
+ this.remotePubKey = b64urlDecode(parsed.pubkey);
80
+ this.relayUrl = parsed.relay ?? '';
81
+ this.dappName = parsed.name;
82
+ this.dappDeclaredMethods = parsed.methods;
83
+ this.dappDeclaredChains = parsed.chains;
84
+ this.sendSeq = 0;
85
+ this.recvSeq = -1;
86
+ this.sendKey = null;
87
+ this.recvKey = null;
88
+ this.pendingRequestRecords.clear();
89
+ this.idempotencyCache.clear();
90
+ this.broadcastResponseCache.clear();
91
+ // Compute effective capabilities via scope intersection (§8.1)
92
+ this.effectiveCapabilities = this.computeScopeIntersection();
93
+ const kp = generateX25519KeyPair();
94
+ this.privKey = kp.privateKey;
95
+ this.pubKeyB64 = kp.publicKeyB64;
96
+ // Derive root and directional traffic keys immediately.
97
+ const shared = computeSharedSecret(this.privKey, this.remotePubKey);
98
+ const rootKey = deriveSessionKey(shared, this.channelId);
99
+ // §20.7: erase shared_secret immediately after root_key derivation
100
+ shared.fill(0);
101
+ const context = this.sessionContext();
102
+ const keys = deriveDirectionalSessionKeys(rootKey, this.channelId, context);
103
+ this.sendKey = keys.walletToDappKey;
104
+ this.recvKey = keys.dappToWalletKey;
105
+ this.sessionFingerprint = computeSessionFingerprint(this.channelId, b64urlEncode(this.remotePubKey));
106
+ this.emit('sessionFingerprint', this.sessionFingerprint);
107
+ // Always derive join encryption key for sealed_join before erasing rootKey
108
+ this.sessionKey = deriveJoinEncryptionKey(rootKey, this.channelId);
109
+ // §20.7: erase root_key after all derivations complete
110
+ rootKey.fill(0);
111
+ keys.rootKey.fill(0);
112
+ keys.transcriptHash.fill(0);
113
+ return this.sessionFingerprint;
114
+ }
115
+ /**
116
+ * Send the join message after the user has verified the session fingerprint.
117
+ */
118
+ async confirmJoin() {
119
+ if (!this.channelId || !this.sendKey || !this.recvKey) {
120
+ throw new Error('Must call prepareJoin() first');
121
+ }
122
+ // Update transport URL if WebSocket, add ?ch= for CF Worker relay routing
123
+ const transportWithUrl = this.transport;
124
+ if (typeof transportWithUrl.setUrl === 'function') {
125
+ let url = this.relayUrl;
126
+ if (this.channelId && !url.includes('?ch=')) {
127
+ const sep = url.includes('?') ? '&' : '?';
128
+ url = `${url}${sep}ch=${this.channelId}`;
129
+ }
130
+ transportWithUrl.setUrl(url);
131
+ }
132
+ await this.transport.connect();
133
+ this.setPhase('waiting_accept');
134
+ await this.sendJoin();
135
+ }
136
+ /**
137
+ * Join a channel in one step (convenience method).
138
+ * Equivalent to prepareJoin() + confirmJoin().
139
+ */
140
+ async joinFromUri(uri) {
141
+ const code = this.prepareJoin(uri);
142
+ await this.confirmJoin();
143
+ return code;
144
+ }
145
+ /** Respond to a request with success. */
146
+ approve(requestId, result) {
147
+ const sent = this.sendResponse(requestId, true, result);
148
+ const afterSend = (ok) => {
149
+ if (ok)
150
+ this.cacheProcessedResponse(requestId, true, result);
151
+ return ok;
152
+ };
153
+ return isPromiseLike(sent) ? sent.then(afterSend) : afterSend(sent);
154
+ }
155
+ /** Respond to a request with rejection. */
156
+ reject(requestId, code = 'user_rejected', message = 'User rejected the request') {
157
+ const error = { code, message };
158
+ const sent = this.sendResponse(requestId, false, error);
159
+ const afterSend = (ok) => {
160
+ if (ok)
161
+ this.cacheProcessedResponse(requestId, false, error);
162
+ return ok;
163
+ };
164
+ return isPromiseLike(sent) ? sent.then(afterSend) : afterSend(sent);
165
+ }
166
+ /** Push an event to the dApp. */
167
+ pushEvent(event, data) {
168
+ if (this.phase !== 'connected' || !this.sendKey)
169
+ return false;
170
+ const seq = this.nextSendSeq();
171
+ const evtId = `evt-${++this.evtCounter}`;
172
+ const send = (reservedSeq) => {
173
+ if (reservedSeq == null || !this.sendKey)
174
+ return false;
175
+ // Privacy mode (§7.4): encrypt event name inside sealed payload
176
+ const sealedData = {
177
+ _event: event,
178
+ ...(data && typeof data === 'object' ? data : { _data: data }),
179
+ };
180
+ const hdr = { type: 'evt', from: this.pubKeyB64, id: evtId };
181
+ const sealed = sealPayload(this.sendKey, this.channelId, reservedSeq, sealedData, hdr);
182
+ this.sendRaw({
183
+ v: 1,
184
+ t: 'evt',
185
+ ch: this.channelId,
186
+ ts: Date.now(),
187
+ from: this.pubKeyB64,
188
+ body: { id: evtId, sealed },
189
+ });
190
+ return true;
191
+ };
192
+ return isPromiseLike(seq) ? seq.then(send) : send(seq);
193
+ }
194
+ /** Send ping. */
195
+ ping() {
196
+ if (this.phase !== 'connected')
197
+ return;
198
+ this.sendRaw({
199
+ v: 1,
200
+ t: 'ping',
201
+ ch: this.channelId,
202
+ ts: Date.now(),
203
+ from: this.pubKeyB64,
204
+ body: {},
205
+ });
206
+ }
207
+ /** Gracefully close. */
208
+ close(reason = 'normal') {
209
+ this.intentionalClose = true;
210
+ this.stopReconnect();
211
+ this.clearSessionTtl();
212
+ this.pendingRequestRecords.clear();
213
+ this.idempotencyCache.clear();
214
+ this.broadcastResponseCache.clear();
215
+ if (this.channelId) {
216
+ this.sendRaw({
217
+ v: 1,
218
+ t: 'close',
219
+ ch: this.channelId,
220
+ ts: Date.now(),
221
+ from: this.pubKeyB64,
222
+ body: { reason },
223
+ });
224
+ }
225
+ this.clearPersistence();
226
+ this.transport.disconnect();
227
+ this.setPhase('closed');
228
+ }
229
+ /** Destroy and release all resources. */
230
+ destroy() {
231
+ this.close();
232
+ this.removeAll();
233
+ // Wipe sensitive key material (§20.7)
234
+ if (this.privKey)
235
+ this.privKey.fill(0);
236
+ if (this.sessionKey)
237
+ this.sessionKey.fill(0);
238
+ if (this.sendKey)
239
+ this.sendKey.fill(0);
240
+ if (this.recvKey)
241
+ this.recvKey.fill(0);
242
+ this.pendingRequestRecords.clear();
243
+ this.idempotencyCache.clear();
244
+ this.broadcastResponseCache.clear();
245
+ this.sessionKey = null;
246
+ this.sendKey = null;
247
+ this.recvKey = null;
248
+ }
249
+ // -------------------------------------------------------------------------
250
+ // State serialization
251
+ // -------------------------------------------------------------------------
252
+ serialize() {
253
+ const json = JSON.stringify({
254
+ channelId: this.channelId,
255
+ privKey: bytesToHex(this.privKey),
256
+ pubKeyB64: this.pubKeyB64,
257
+ remotePubKeyB64: this.remotePubKey ? b64urlEncode(this.remotePubKey) : null,
258
+ sendKey: this.sendKey ? bytesToHex(this.sendKey) : null,
259
+ recvKey: this.recvKey ? bytesToHex(this.recvKey) : null,
260
+ sendSeq: this.sendSeq,
261
+ recvSeq: this.recvSeq,
262
+ relayUrl: this.relayUrl,
263
+ capabilities: this.capabilities,
264
+ meta: this.meta ?? null,
265
+ dappName: this.dappName ?? null,
266
+ sessionStartTime: this.sessionStartTime,
267
+ });
268
+ return this.sendKey ? signSnapshot(this.sendKey, json) : json;
269
+ }
270
+ restore(signed) {
271
+ try {
272
+ let json;
273
+ if (signed.length > 65 && signed[64] === '.') {
274
+ const candidateJson = signed.slice(65);
275
+ const d0 = JSON.parse(candidateJson);
276
+ if (!d0.sendKey)
277
+ return false;
278
+ const sendKey = hexToBytes(d0.sendKey);
279
+ const verified = verifySnapshot(sendKey, signed);
280
+ if (!verified)
281
+ return false;
282
+ json = verified;
283
+ }
284
+ else {
285
+ json = signed;
286
+ }
287
+ const d = JSON.parse(json);
288
+ if (!d.channelId || !d.privKey)
289
+ return false;
290
+ this.channelId = d.channelId;
291
+ this.privKey = hexToBytes(d.privKey);
292
+ this.pubKeyB64 = d.pubKeyB64;
293
+ this.remotePubKey = d.remotePubKeyB64 ? b64urlDecode(d.remotePubKeyB64) : null;
294
+ this.sendKey = d.sendKey ? hexToBytes(d.sendKey) : null;
295
+ this.recvKey = d.recvKey ? hexToBytes(d.recvKey) : null;
296
+ if (!this.sendKey || !this.recvKey)
297
+ return false;
298
+ this.sendSeq = d.sendSeq ?? 0;
299
+ this.recvSeq = d.recvSeq ?? -1;
300
+ this.relayUrl = d.relayUrl;
301
+ if ('capabilities' in d &&
302
+ canonicalJson(d.capabilities ?? null) !== canonicalJson(this.capabilities ?? null)) {
303
+ return false;
304
+ }
305
+ if ('meta' in d && canonicalJson(d.meta ?? null) !== canonicalJson(this.meta ?? null)) {
306
+ return false;
307
+ }
308
+ this.dappName = d.dappName ?? undefined;
309
+ this.sessionStartTime = d.sessionStartTime ?? null;
310
+ return true;
311
+ }
312
+ catch {
313
+ return false;
314
+ }
315
+ }
316
+ async restoreFromPersistence() {
317
+ if (!this.persistence?.load)
318
+ return false;
319
+ const json = await this.persistence.load();
320
+ return json ? this.restore(json) : false;
321
+ }
322
+ async reconnect() {
323
+ this.intentionalClose = false;
324
+ this.stopReconnect();
325
+ this.setPhase('disconnected');
326
+ this.reconnectAttempt = 0;
327
+ await this.doReconnectAttempt();
328
+ }
329
+ // -------------------------------------------------------------------------
330
+ // Internal: message handling
331
+ // -------------------------------------------------------------------------
332
+ handleMessage(msg) {
333
+ // §2: Peers MUST reject any peer-sent message where from equals "_adapter"
334
+ if (msg.t !== 'ready' && msg.t !== 'terminate' && msg.from === '_adapter') {
335
+ this.emit('error', new Error('Rejected message with spoofed _adapter from'));
336
+ return;
337
+ }
338
+ // §15 rule 12: reject unsupported protocol version
339
+ if (msg.v !== 1) {
340
+ this.close('unsupported_version');
341
+ return;
342
+ }
343
+ switch (msg.t) {
344
+ case 'ready': {
345
+ const readyBody = msg.body;
346
+ this.stopReconnect();
347
+ if (readyBody.state === 'connected') {
348
+ const expectedRemote = this.remotePubKey ? b64urlEncode(this.remotePubKey) : null;
349
+ if (!expectedRemote || readyBody.remote !== expectedRemote) {
350
+ this.emit('error', new Error('Connected remote does not match paired dApp'));
351
+ this.close();
352
+ break;
353
+ }
354
+ }
355
+ if (readyBody.state === 'waiting') {
356
+ this.setPhase('waiting_accept');
357
+ }
358
+ else if (readyBody.state === 'connected') {
359
+ this.setPhase('connected');
360
+ this.startSessionTtl();
361
+ this.persistSnapshotAsync();
362
+ }
363
+ break;
364
+ }
365
+ case 'req': {
366
+ const reqBody = msg.body;
367
+ if (this.remotePubKey && msg.from !== b64urlEncode(this.remotePubKey))
368
+ break;
369
+ // All requests MUST be sealed — reject unsealed requests to prevent
370
+ // method injection by a malicious relay.
371
+ if (!reqBody.sealed || !reqBody.id || !this.recvKey) {
372
+ if (reqBody.id) {
373
+ this.observeSend(this.reject(reqBody.id, 'protocol_error', 'Request must be encrypted'));
374
+ }
375
+ break;
376
+ }
377
+ const requestId = reqBody.id;
378
+ try {
379
+ // AAD: no method field — real method is inside sealed payload
380
+ const reqHdr = { type: 'req', from: msg.from, id: requestId };
381
+ const { seq, data, plaintext } = unsealPayload(this.recvKey, this.channelId, reqBody.sealed, reqHdr);
382
+ if (seq <= this.recvSeq)
383
+ break; // replay — silently drop
384
+ const prevRecvSeq = this.recvSeq;
385
+ this.recvSeq = seq;
386
+ const afterPersist = () => this.processRequest(requestId, data, plaintext);
387
+ const persisted = this.persistSnapshot();
388
+ if (isPromiseLike(persisted)) {
389
+ void persisted
390
+ .then(afterPersist)
391
+ .catch((e) => {
392
+ this.recvSeq = prevRecvSeq; // rollback on persist failure
393
+ this.emit('error', this.persistenceError(e));
394
+ });
395
+ }
396
+ else {
397
+ afterPersist();
398
+ }
399
+ }
400
+ catch {
401
+ this.observeSend(this.reject(requestId, 'decryption_failed', 'Failed to decrypt request'));
402
+ }
403
+ break;
404
+ }
405
+ case 'ping':
406
+ this.sendRaw({
407
+ v: 1,
408
+ t: 'pong',
409
+ ch: this.channelId,
410
+ ts: Date.now(),
411
+ from: this.pubKeyB64,
412
+ body: {},
413
+ });
414
+ break;
415
+ case 'pong':
416
+ break;
417
+ case 'close': {
418
+ if (this.phase !== 'disconnected') {
419
+ this.pendingRequestRecords.clear();
420
+ this.idempotencyCache.clear();
421
+ this.broadcastResponseCache.clear();
422
+ this.clearPersistence();
423
+ this.setPhase('closed');
424
+ this.intentionalClose = true;
425
+ }
426
+ break;
427
+ }
428
+ case 'terminate': {
429
+ const termBody = msg.body;
430
+ // Race condition: relay sends channel_not_found when we join during reconnect
431
+ if (termBody.reason === 'channel_not_found' &&
432
+ (this.phase === 'disconnected' || this.phase === 'waiting_accept')) {
433
+ this.transport.disconnect();
434
+ this.startReconnect();
435
+ break;
436
+ }
437
+ // Adapter-sent termination — treat like close
438
+ if (this.phase !== 'disconnected') {
439
+ this.pendingRequestRecords.clear();
440
+ this.idempotencyCache.clear();
441
+ this.broadcastResponseCache.clear();
442
+ this.clearPersistence();
443
+ this.setPhase('closed');
444
+ this.intentionalClose = true;
445
+ }
446
+ break;
447
+ }
448
+ }
449
+ }
450
+ // -------------------------------------------------------------------------
451
+ // Internal: responses and request idempotency
452
+ // -------------------------------------------------------------------------
453
+ processRequest(requestId, data, plaintext) {
454
+ // Extract _method from decrypted payload
455
+ if (!data || typeof data !== 'object') {
456
+ this.observeSend(this.reject(requestId, 'invalid_params', 'Request payload missing _method'));
457
+ return;
458
+ }
459
+ const payload = data;
460
+ if (typeof payload._method !== 'string' || payload._method.length === 0) {
461
+ this.observeSend(this.reject(requestId, 'invalid_params', 'Request payload missing _method'));
462
+ return;
463
+ }
464
+ const method = payload._method;
465
+ // §7.1 runtime enforcement: reject methods not in capabilities
466
+ if (!this.effectiveCapabilities.methods.includes(method)) {
467
+ this.observeSend(this.reject(requestId, 'unsupported_method', `Method "${method}" not in granted capabilities`));
468
+ return;
469
+ }
470
+ const { _method: _, ...rest } = payload;
471
+ const params = rest;
472
+ const paramsHash = sha256Hex(plaintext);
473
+ const cachedBroadcast = this.broadcastResponseCache.get(requestId);
474
+ if (cachedBroadcast) {
475
+ if (!constantTimeEqual(cachedBroadcast.paramsHash, paramsHash)) {
476
+ this.observeSend(this.reject(requestId, 'invalid_params', 'Duplicate request ID with different params'));
477
+ return;
478
+ }
479
+ this.observeSend(this.sendResponse(requestId, cachedBroadcast.ok, cachedBroadcast.data));
480
+ return;
481
+ }
482
+ const cached = this.idempotencyCache.get(requestId);
483
+ if (cached) {
484
+ if (!constantTimeEqual(cached.paramsHash, paramsHash)) {
485
+ this.observeSend(this.reject(requestId, 'invalid_params', 'Duplicate request ID with different params'));
486
+ return;
487
+ }
488
+ this.touchIdempotencyEntry(requestId, cached);
489
+ if (!cached.tooLarge) {
490
+ this.observeSend(this.sendResponse(requestId, cached.ok, cached.data));
491
+ return;
492
+ }
493
+ }
494
+ const pending = this.pendingRequestRecords.get(requestId);
495
+ if (pending) {
496
+ if (!constantTimeEqual(pending.paramsHash, paramsHash)) {
497
+ this.observeSend(this.reject(requestId, 'invalid_params', 'Duplicate request ID with different params'));
498
+ }
499
+ return;
500
+ }
501
+ // §15 rule 11: max 32 pending requests
502
+ if (this.pendingRequestRecords.size >= MAX_PENDING_REQUESTS) {
503
+ this.observeSend(this.reject(requestId, 'rate_limited', 'Too many pending requests'));
504
+ return;
505
+ }
506
+ this.pendingRequestRecords.set(requestId, { paramsHash, method });
507
+ this.emit('request', { id: requestId, method, params });
508
+ }
509
+ sendResponse(requestId, ok, data) {
510
+ if (!this.sendKey)
511
+ return false;
512
+ const seq = this.nextSendSeq();
513
+ const send = (reservedSeq) => {
514
+ if (reservedSeq == null || !this.sendKey)
515
+ return false;
516
+ // Per protocol §5.3: success = { _ok: true, _result: <result> }
517
+ // error = { _ok: false, code: "...", message: "..." }
518
+ const sealedPayload = ok
519
+ ? { _ok: true, _result: data }
520
+ : { _ok: false, ...data };
521
+ const hdr = { type: 'res', from: this.pubKeyB64, id: requestId };
522
+ const sealed = sealPayload(this.sendKey, this.channelId, reservedSeq, sealedPayload, hdr);
523
+ this.sendRaw({
524
+ v: 1,
525
+ t: 'res',
526
+ ch: this.channelId,
527
+ ts: Date.now(),
528
+ from: this.pubKeyB64,
529
+ body: { id: requestId, sealed },
530
+ });
531
+ return true;
532
+ };
533
+ return isPromiseLike(seq) ? seq.then(send) : send(seq);
534
+ }
535
+ cacheProcessedResponse(requestId, ok, data) {
536
+ const pending = this.pendingRequestRecords.get(requestId);
537
+ if (!pending)
538
+ return;
539
+ this.pendingRequestRecords.delete(requestId);
540
+ const serialized = JSON.stringify(data ?? null);
541
+ const tooLarge = new TextEncoder().encode(serialized).length > IDEMPOTENCY_RESPONSE_LIMIT_BYTES;
542
+ const entry = {
543
+ ...pending,
544
+ ok,
545
+ data: tooLarge ? null : data,
546
+ tooLarge,
547
+ };
548
+ this.idempotencyCache.set(requestId, entry);
549
+ this.evictIdempotencyCache();
550
+ if (pending.method === 'wallet_sendTransaction' && ok) {
551
+ this.broadcastResponseCache.set(requestId, {
552
+ ...pending,
553
+ ok,
554
+ data,
555
+ tooLarge: false,
556
+ });
557
+ this.evictBroadcastCache();
558
+ }
559
+ }
560
+ touchIdempotencyEntry(requestId, entry) {
561
+ this.idempotencyCache.delete(requestId);
562
+ this.idempotencyCache.set(requestId, entry);
563
+ }
564
+ evictIdempotencyCache() {
565
+ while (this.idempotencyCache.size > IDEMPOTENCY_CACHE_LIMIT) {
566
+ const oldest = this.idempotencyCache.keys().next().value;
567
+ if (!oldest)
568
+ return;
569
+ this.idempotencyCache.delete(oldest);
570
+ }
571
+ }
572
+ evictBroadcastCache() {
573
+ while (this.broadcastResponseCache.size > BROADCAST_CACHE_LIMIT) {
574
+ const oldest = this.broadcastResponseCache.keys().next().value;
575
+ if (!oldest)
576
+ return;
577
+ this.broadcastResponseCache.delete(oldest);
578
+ }
579
+ }
580
+ // -------------------------------------------------------------------------
581
+ // Internal: transport
582
+ // -------------------------------------------------------------------------
583
+ sendRaw(msg) {
584
+ // §15 rule 10: max 64 KB on the wire
585
+ const json = JSON.stringify(msg);
586
+ if (new TextEncoder().encode(json).length > MAX_MESSAGE_BYTES) {
587
+ this.emit('error', new Error('Message exceeds 64 KB limit'));
588
+ return;
589
+ }
590
+ this.transport.send(msg);
591
+ }
592
+ sendJoin() {
593
+ const body = {
594
+ sealed_join: null,
595
+ };
596
+ if (this.sessionKey) {
597
+ // Initial join: encrypt capabilities/meta in sealed_join
598
+ body.sealed_join = sealJoin(this.sessionKey, this.channelId, this.effectiveCapabilities, this.meta);
599
+ // §20.7: erase join_encryption_key after one-shot use
600
+ this.sessionKey.fill(0);
601
+ this.sessionKey = null;
602
+ }
603
+ // else: reconnect — sealed_join stays null (capabilities already negotiated)
604
+ const msg = {
605
+ v: 1,
606
+ t: 'join',
607
+ ch: this.channelId,
608
+ ts: Date.now(),
609
+ from: this.pubKeyB64,
610
+ body,
611
+ };
612
+ const send = () => this.sendRaw(msg);
613
+ const persisted = this.persistSnapshot();
614
+ if (isPromiseLike(persisted)) {
615
+ return persisted.then(send).catch((e) => {
616
+ throw this.persistenceError(e);
617
+ });
618
+ }
619
+ send();
620
+ }
621
+ sessionContext() {
622
+ return {
623
+ dappPubKeyB64: this.remotePubKey ? b64urlEncode(this.remotePubKey) : '',
624
+ walletPubKeyB64: this.pubKeyB64,
625
+ capabilities: this.effectiveCapabilities,
626
+ walletMeta: this.meta ?? null,
627
+ dappName: this.dappName,
628
+ };
629
+ }
630
+ /**
631
+ * Compute the intersection of wallet capabilities with dApp-declared
632
+ * scope from the pairing URI (§8.1).
633
+ */
634
+ computeScopeIntersection() {
635
+ const base = this.capabilities;
636
+ // §7.1: Wallet MUST check it can satisfy dApp's minimum requirements.
637
+ // Wallet MAY grant additional methods/chains beyond what was requested.
638
+ // We grant all wallet capabilities (not just the intersection).
639
+ if (this.dappDeclaredMethods?.length) {
640
+ const granted = new Set(base.methods);
641
+ const unsatisfied = this.dappDeclaredMethods.filter((m) => !granted.has(m));
642
+ if (unsatisfied.length > 0) {
643
+ // Wallet cannot satisfy dApp's requirements — emit warning but proceed
644
+ // (the dApp will check and close if needed)
645
+ }
646
+ }
647
+ if (this.dappDeclaredChains?.length) {
648
+ const granted = new Set(base.chains);
649
+ const unsatisfied = this.dappDeclaredChains.filter((c) => !granted.has(c));
650
+ if (unsatisfied.length > 0) {
651
+ // Same as above
652
+ }
653
+ }
654
+ const result = { methods: base.methods, events: base.events, chains: base.chains };
655
+ if (base.version != null)
656
+ result.version = base.version;
657
+ return result;
658
+ }
659
+ nextSendSeq() {
660
+ if (this.sendSeq >= MAX_SEND_SEQ) {
661
+ const error = new Error('Send sequence overflow/limit reached — session invalidated');
662
+ this.emit('error', error);
663
+ this.close();
664
+ return null;
665
+ }
666
+ const seq = this.sendSeq;
667
+ this.sendSeq += 1;
668
+ const persisted = this.persistSnapshot();
669
+ if (isPromiseLike(persisted)) {
670
+ return persisted
671
+ .then(() => seq)
672
+ .catch((e) => {
673
+ throw this.persistenceError(e);
674
+ });
675
+ }
676
+ return seq;
677
+ }
678
+ persistSnapshot() {
679
+ if (!this.persistence)
680
+ return;
681
+ return this.persistence.save(this.serialize());
682
+ }
683
+ persistSnapshotAsync() {
684
+ const persisted = this.persistSnapshot();
685
+ if (isPromiseLike(persisted)) {
686
+ void persisted.catch((e) => this.persistenceError(e));
687
+ }
688
+ }
689
+ clearPersistence() {
690
+ if (!this.persistence?.clear)
691
+ return;
692
+ const cleared = this.persistence.clear();
693
+ if (isPromiseLike(cleared)) {
694
+ void cleared.catch((e) => {
695
+ const err = e instanceof Error ? e : new Error(String(e));
696
+ this.emit('error', err);
697
+ });
698
+ }
699
+ }
700
+ observeSend(result) {
701
+ if (isPromiseLike(result)) {
702
+ void result.catch((e) => {
703
+ const err = e instanceof Error ? e : new Error(String(e));
704
+ this.emit('error', err);
705
+ });
706
+ }
707
+ }
708
+ persistenceError(error) {
709
+ const err = error instanceof Error ? error : new Error(String(error));
710
+ const wrapped = new Error(`Session persistence failed: ${err.message}`);
711
+ this.emit('error', wrapped);
712
+ this.close('protocol_error');
713
+ return wrapped;
714
+ }
715
+ handleTransportClose() {
716
+ if (this.intentionalClose || this.phase === 'closed')
717
+ return;
718
+ this.startReconnect();
719
+ }
720
+ // -------------------------------------------------------------------------
721
+ // Internal: reconnect
722
+ // -------------------------------------------------------------------------
723
+ startReconnect() {
724
+ this.setPhase('disconnected');
725
+ this.reconnectAttempt = 0;
726
+ this.scheduleReconnect();
727
+ }
728
+ scheduleReconnect() {
729
+ if (this.intentionalClose || this.phase === 'closed')
730
+ return;
731
+ const base = BACKOFF[Math.min(this.reconnectAttempt, BACKOFF.length - 1)] ?? 1000;
732
+ const delay = base + Math.floor(Math.random() * base * 0.3); // ±30% jitter
733
+ this.reconnectTimer = setTimeout(() => {
734
+ this.doReconnectAttempt();
735
+ this.reconnectAttempt++;
736
+ }, delay);
737
+ }
738
+ async doReconnectAttempt() {
739
+ if (this.intentionalClose || this.phase === 'closed')
740
+ return;
741
+ try {
742
+ // Re-set URL with ?ch= for CF Worker relay routing
743
+ const t = this.transport;
744
+ if (typeof t.setUrl === 'function' && this.channelId) {
745
+ let url = this.relayUrl;
746
+ if (!url.includes('?ch=')) {
747
+ const sep = url.includes('?') ? '&' : '?';
748
+ url = `${url}${sep}ch=${this.channelId}`;
749
+ }
750
+ t.setUrl(url);
751
+ }
752
+ await this.transport.connect();
753
+ this.setPhase('waiting_accept');
754
+ await this.sendJoin();
755
+ }
756
+ catch {
757
+ this.scheduleReconnect();
758
+ }
759
+ }
760
+ stopReconnect() {
761
+ if (this.reconnectTimer) {
762
+ clearTimeout(this.reconnectTimer);
763
+ this.reconnectTimer = null;
764
+ }
765
+ }
766
+ // -------------------------------------------------------------------------
767
+ // Internal: session TTL (§16 rule 16)
768
+ // -------------------------------------------------------------------------
769
+ startSessionTtl() {
770
+ this.clearSessionTtl();
771
+ if (this.sessionStartTime == null) {
772
+ this.sessionStartTime = Date.now();
773
+ }
774
+ const elapsed = Date.now() - this.sessionStartTime;
775
+ const remaining = Math.max(0, this.sessionTtl - elapsed);
776
+ this.sessionTtlTimer = setTimeout(() => {
777
+ this.emit('error', new Error('Session lifetime expired'));
778
+ this.close('timeout');
779
+ }, remaining);
780
+ }
781
+ clearSessionTtl() {
782
+ if (this.sessionTtlTimer) {
783
+ clearTimeout(this.sessionTtlTimer);
784
+ this.sessionTtlTimer = null;
785
+ }
786
+ }
787
+ setPhase(phase) {
788
+ if (this.phase === phase)
789
+ return;
790
+ this.phase = phase;
791
+ this.emit('phase', phase);
792
+ }
793
+ }
794
+ //# sourceMappingURL=wallet-session.js.map