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