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.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/ble/framing.d.ts +23 -0
- package/dist/ble/framing.d.ts.map +1 -0
- package/dist/ble/framing.js +83 -0
- package/dist/ble/framing.js.map +1 -0
- package/dist/ble/index.d.ts +9 -0
- package/dist/ble/index.d.ts.map +1 -0
- package/dist/ble/index.js +9 -0
- package/dist/ble/index.js.map +1 -0
- package/dist/ble/web-ble-transport.d.ts +29 -0
- package/dist/ble/web-ble-transport.d.ts.map +1 -0
- package/dist/ble/web-ble-transport.js +93 -0
- package/dist/ble/web-ble-transport.js.map +1 -0
- package/dist/crypto.d.ts +102 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +279 -0
- package/dist/crypto.js.map +1 -0
- package/dist/dapp-session.d.ts +106 -0
- package/dist/dapp-session.d.ts.map +1 -0
- package/dist/dapp-session.js +918 -0
- package/dist/dapp-session.js.map +1 -0
- package/dist/emitter.d.ts +16 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +41 -0
- package/dist/emitter.js.map +1 -0
- package/dist/evm/eip1193.d.ts +83 -0
- package/dist/evm/eip1193.d.ts.map +1 -0
- package/dist/evm/eip1193.js +270 -0
- package/dist/evm/eip1193.js.map +1 -0
- package/dist/evm/index.d.ts +8 -0
- package/dist/evm/index.d.ts.map +1 -0
- package/dist/evm/index.js +8 -0
- package/dist/evm/index.js.map +1 -0
- package/dist/evm/wagmi.d.ts +118 -0
- package/dist/evm/wagmi.d.ts.map +1 -0
- package/dist/evm/wagmi.js +205 -0
- package/dist/evm/wagmi.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +31 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet-session.d.ts +107 -0
- package/dist/wallet-session.d.ts.map +1 -0
- package/dist/wallet-session.js +794 -0
- package/dist/wallet-session.js.map +1 -0
- package/dist/ws-transport.d.ts +29 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +79 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +55 -0
- package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
- package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
- package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
- package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
- package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
- package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
- package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
- package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
- package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
- package/src/ble/framing.test.ts +196 -0
- package/src/ble/framing.ts +100 -0
- package/src/ble/index.ts +18 -0
- package/src/ble/web-ble-transport.test.ts +192 -0
- package/src/ble/web-ble-transport.ts +116 -0
- package/src/ble/web-bluetooth.d.ts +47 -0
- package/src/canonical-json.test.ts +612 -0
- package/src/crypto-directional.test.ts +263 -0
- package/src/crypto-hardening.test.ts +529 -0
- package/src/crypto.test.ts +635 -0
- package/src/crypto.ts +405 -0
- package/src/dapp-session.test.ts +647 -0
- package/src/dapp-session.ts +1004 -0
- package/src/emitter.test.ts +169 -0
- package/src/emitter.ts +45 -0
- package/src/evm/eip1193.test.ts +365 -0
- package/src/evm/eip1193.ts +346 -0
- package/src/evm/index.ts +19 -0
- package/src/evm/wagmi.test.ts +396 -0
- package/src/evm/wagmi.ts +321 -0
- package/src/index.ts +86 -0
- package/src/integration.test.ts +385 -0
- package/src/security.test.ts +430 -0
- package/src/sequence-validation.test.ts +1185 -0
- package/src/test-helpers.ts +216 -0
- package/src/types.test.ts +82 -0
- package/src/types.ts +305 -0
- package/src/wallet-session.test.ts +683 -0
- package/src/wallet-session.ts +922 -0
- package/src/ws-transport.test.ts +231 -0
- 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
|
+
}
|