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,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
|