@solana-mobile/mobile-wallet-adapter-protocol 0.0.1-alpha.6 → 0.9.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.
@@ -1,62 +1,38 @@
1
- class SolanaMobileWalletAdapterSecureContextRequiredError extends Error {
2
- constructor() {
3
- super('The mobile wallet adapter protocol must be used in a secure context (`https`).');
4
- this.name = 'SolanaMobileWalletAdapterSecureContextRequiredError';
5
- }
6
- }
7
- class SolanaMobileWalletAdapterForbiddenWalletBaseURLError extends Error {
8
- constructor() {
9
- super('Base URLs supplied by wallets must be valid `https` URLs');
10
- this.name = 'SolanaMobileWalletAdapterForbiddenWalletBaseURLError';
11
- }
12
- }
13
- class SolanaMobileWalletAdapterWalletNotInstalledError extends Error {
14
- constructor() {
15
- super(`Found no installed wallet that supports the mobile wallet protocol.`);
16
- this.name = 'SolanaMobileWalletAdapterWalletNotInstalledError';
17
- }
18
- }
19
- class SolanaMobileWalletAdapterProtocolSessionEstablishmentError extends Error {
20
- constructor(port) {
21
- super(`Failed to connect to the wallet websocket on port ${port}.`);
22
- this.name = 'SolanaMobileWalletAdapterProtocolSessionEstablishmentError';
23
- }
24
- }
25
- class SolanaMobileWalletAdapterProtocolAssociationPortOutOfRangeError extends Error {
26
- constructor(port) {
27
- super(`Association port number must be between 49152 and 65535. ${port} given.`);
28
- this.name = 'SolanaMobileWalletAdapterProtocolAssociationPortOutOfRangeError';
29
- }
30
- }
31
- class SolanaMobileWalletAdapterProtocolSessionClosedError extends Error {
32
- constructor(code, reason) {
33
- super(`The wallet session dropped unexpectedly (${code}: ${reason}).`);
34
- this.name = 'SolanaMobileWalletAdapterProtocolSessionClosedError';
35
- }
36
- }
37
- class SolanaMobileWalletAdapterProtocolReauthorizeError extends Error {
38
- constructor() {
39
- super('The auth token provided has gone stale and needs reauthorization.');
40
- this.name = 'SolanaMobileWalletAdapterProtocolReauthorizeError';
1
+ // Typescript `enums` thwart tree-shaking. See https://bargsten.org/jsts/enums/
2
+ const SolanaMobileWalletAdapterErrorCode = {
3
+ ERROR_ASSOCIATION_PORT_OUT_OF_RANGE: 'ERROR_ASSOCIATION_PORT_OUT_OF_RANGE',
4
+ ERROR_FORBIDDEN_WALLET_BASE_URL: 'ERROR_FORBIDDEN_WALLET_BASE_URL',
5
+ ERROR_SECURE_CONTEXT_REQUIRED: 'ERROR_SECURE_CONTEXT_REQUIRED',
6
+ ERROR_SESSION_CLOSED: 'ERROR_SESSION_CLOSED',
7
+ ERROR_WALLET_NOT_FOUND: 'ERROR_WALLET_NOT_FOUND',
8
+ };
9
+ class SolanaMobileWalletAdapterError extends Error {
10
+ constructor(...args) {
11
+ const [code, message, data] = args;
12
+ super(message);
13
+ this.code = code;
14
+ this.data = data;
15
+ this.name = 'SolanaMobileWalletAdapterError';
41
16
  }
42
17
  }
43
18
  // Typescript `enums` thwart tree-shaking. See https://bargsten.org/jsts/enums/
44
- const SolanaMobileWalletAdapterProtocolError = {
45
- ERROR_REAUTHORIZE: -1,
46
- ERROR_AUTHORIZATION_FAILED: -2,
47
- ERROR_INVALID_PAYLOAD: -3,
48
- ERROR_NOT_SIGNED: -4,
49
- ERROR_NOT_COMMITTED: -5,
19
+ const SolanaMobileWalletAdapterProtocolErrorCode = {
20
+ // Keep these in sync with `mobilewalletadapter/common/ProtocolContract.java`.
21
+ ERROR_AUTHORIZATION_FAILED: -1,
22
+ ERROR_INVALID_PAYLOADS: -2,
23
+ ERROR_NOT_SIGNED: -3,
24
+ ERROR_NOT_SUBMITTED: -4,
25
+ ERROR_TOO_MANY_PAYLOADS: -5,
50
26
  ERROR_ATTEST_ORIGIN_ANDROID: -100,
51
27
  };
52
- class SolanaMobileWalletAdapterProtocolJsonRpcError extends Error {
28
+ class SolanaMobileWalletAdapterProtocolError extends Error {
53
29
  constructor(...args) {
54
30
  const [jsonRpcMessageId, code, message, data] = args;
55
31
  super(message);
56
32
  this.code = code;
57
33
  this.data = data;
58
34
  this.jsonRpcMessageId = jsonRpcMessageId;
59
- this.name = 'SolanaNativeWalletAdapterJsonRpcError';
35
+ this.name = 'SolanaMobileWalletAdapterProtocolError';
60
36
  }
61
37
  }
62
38
 
@@ -96,6 +72,17 @@ function createHelloReq(ecdhPublicKey, associationKeypairPrivateKey) {
96
72
  });
97
73
  }
98
74
 
75
+ const SEQUENCE_NUMBER_BYTES = 4;
76
+ function createSequenceNumberVector(sequenceNumber) {
77
+ if (sequenceNumber >= 4294967296) {
78
+ throw new Error('Outbound sequence number overflow. The maximum sequence number is 32-bytes.');
79
+ }
80
+ const byteArray = new ArrayBuffer(SEQUENCE_NUMBER_BYTES);
81
+ const view = new DataView(byteArray);
82
+ view.setUint32(0, sequenceNumber, /* littleEndian */ false);
83
+ return new Uint8Array(byteArray);
84
+ }
85
+
99
86
  function generateAssociationKeypair() {
100
87
  return __awaiter(this, void 0, void 0, function* () {
101
88
  return yield crypto.subtle.generateKey({
@@ -118,30 +105,34 @@ const INITIALIZATION_VECTOR_BYTES = 12;
118
105
  function encryptJsonRpcMessage(jsonRpcMessage, sharedSecret) {
119
106
  return __awaiter(this, void 0, void 0, function* () {
120
107
  const plaintext = JSON.stringify(jsonRpcMessage);
108
+ const sequenceNumberVector = createSequenceNumberVector(jsonRpcMessage.id);
121
109
  const initializationVector = new Uint8Array(INITIALIZATION_VECTOR_BYTES);
122
110
  crypto.getRandomValues(initializationVector);
123
- const ciphertext = yield crypto.subtle.encrypt(getAlgorithmParams(initializationVector), sharedSecret, Buffer.from(plaintext));
124
- const response = new Uint8Array(initializationVector.byteLength + ciphertext.byteLength);
125
- response.set(new Uint8Array(initializationVector), 0);
126
- response.set(new Uint8Array(ciphertext), initializationVector.byteLength);
111
+ const ciphertext = yield crypto.subtle.encrypt(getAlgorithmParams(sequenceNumberVector, initializationVector), sharedSecret, Buffer.from(plaintext));
112
+ const response = new Uint8Array(sequenceNumberVector.byteLength + initializationVector.byteLength + ciphertext.byteLength);
113
+ response.set(new Uint8Array(sequenceNumberVector), 0);
114
+ response.set(new Uint8Array(initializationVector), sequenceNumberVector.byteLength);
115
+ response.set(new Uint8Array(ciphertext), sequenceNumberVector.byteLength + initializationVector.byteLength);
127
116
  return response;
128
117
  });
129
118
  }
130
119
  function decryptJsonRpcMessage(message, sharedSecret) {
131
120
  return __awaiter(this, void 0, void 0, function* () {
132
- const initializationVector = message.slice(0, INITIALIZATION_VECTOR_BYTES);
133
- const ciphertext = message.slice(INITIALIZATION_VECTOR_BYTES);
134
- const plaintextBuffer = yield crypto.subtle.decrypt(getAlgorithmParams(initializationVector), sharedSecret, ciphertext);
121
+ const sequenceNumberVector = message.slice(0, SEQUENCE_NUMBER_BYTES);
122
+ const initializationVector = message.slice(SEQUENCE_NUMBER_BYTES, SEQUENCE_NUMBER_BYTES + INITIALIZATION_VECTOR_BYTES);
123
+ const ciphertext = message.slice(SEQUENCE_NUMBER_BYTES + INITIALIZATION_VECTOR_BYTES);
124
+ const plaintextBuffer = yield crypto.subtle.decrypt(getAlgorithmParams(sequenceNumberVector, initializationVector), sharedSecret, ciphertext);
135
125
  const plaintext = getUtf8Decoder().decode(plaintextBuffer);
136
126
  const jsonRpcMessage = JSON.parse(plaintext);
137
127
  if (Object.hasOwnProperty.call(jsonRpcMessage, 'error')) {
138
- throw new SolanaMobileWalletAdapterProtocolJsonRpcError(jsonRpcMessage.id, jsonRpcMessage.error.code, jsonRpcMessage.error.message);
128
+ throw new SolanaMobileWalletAdapterProtocolError(jsonRpcMessage.id, jsonRpcMessage.error.code, jsonRpcMessage.error.message);
139
129
  }
140
130
  return jsonRpcMessage;
141
131
  });
142
132
  }
143
- function getAlgorithmParams(initializationVector) {
133
+ function getAlgorithmParams(sequenceNumber, initializationVector) {
144
134
  return {
135
+ additionalData: sequenceNumber,
145
136
  iv: initializationVector,
146
137
  name: 'AES-GCM',
147
138
  tagLength: 128, // 16 byte tag => 128 bits
@@ -179,7 +170,7 @@ function getRandomAssociationPort() {
179
170
  }
180
171
  function assertAssociationPort(port) {
181
172
  if (port < 49152 || port > 65535) {
182
- throw new SolanaMobileWalletAdapterProtocolAssociationPortOutOfRangeError(port);
173
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_ASSOCIATION_PORT_OUT_OF_RANGE, `Association port number must be between 49152 and 65535. ${port} given.`, { port });
183
174
  }
184
175
  return port;
185
176
  }
@@ -219,7 +210,7 @@ function getIntentURL(methodPathname, intentUrlBase) {
219
210
  }
220
211
  catch (_a) { } // eslint-disable-line no-empty
221
212
  if ((baseUrl === null || baseUrl === void 0 ? void 0 : baseUrl.protocol) !== 'https:') {
222
- throw new SolanaMobileWalletAdapterForbiddenWalletBaseURLError();
213
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_FORBIDDEN_WALLET_BASE_URL, 'Base URLs supplied by wallets must be valid `https` URLs');
223
214
  }
224
215
  }
225
216
  baseUrl || (baseUrl = new URL(`${INTENT_NAME}:/`));
@@ -314,7 +305,7 @@ function startSession(associationPublicKey, associationURLBase) {
314
305
  }
315
306
  }
316
307
  catch (e) {
317
- throw new SolanaMobileWalletAdapterWalletNotInstalledError();
308
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_WALLET_NOT_FOUND, 'Found no installed wallet that supports the mobile wallet protocol.');
318
309
  }
319
310
  }
320
311
  return randomAssociationPort;
@@ -322,32 +313,56 @@ function startSession(associationPublicKey, associationURLBase) {
322
313
  }
323
314
 
324
315
  const WEBSOCKET_CONNECTION_CONFIG = {
325
- maxAttempts: 34,
326
316
  /**
327
317
  * 300 milliseconds is a generally accepted threshold for what someone
328
318
  * would consider an acceptable response time for a user interface
329
- * after having performed a low-attention tapping task. We set the
319
+ * after having performed a low-attention tapping task. We set the initial
330
320
  * interval at which we wait for the wallet to set up the websocket at
331
- * half this, as per the Nyquist frequency.
321
+ * half this, as per the Nyquist frequency, with a progressive backoff
322
+ * sequence from there. The total wait time is 30s, which allows for the
323
+ * user to be presented with a disambiguation dialog, select a wallet, and
324
+ * for the wallet app to subsequently start.
332
325
  */
333
- retryDelayMs: 150,
326
+ retryDelayScheduleMs: [150, 150, 200, 500, 500, 750, 750, 1000],
327
+ timeoutMs: 30000,
334
328
  };
335
329
  const WEBSOCKET_PROTOCOL = 'com.solana.mobilewalletadapter.v1';
336
330
  function assertSecureContext() {
337
331
  if (typeof window === 'undefined' || window.isSecureContext !== true) {
338
- throw new SolanaMobileWalletAdapterSecureContextRequiredError();
332
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_SECURE_CONTEXT_REQUIRED, 'The mobile wallet adapter protocol must be used in a secure context (`https`).');
339
333
  }
340
334
  }
335
+ function assertSecureEndpointSpecificURI(walletUriBase) {
336
+ let url;
337
+ try {
338
+ url = new URL(walletUriBase);
339
+ }
340
+ catch (_a) {
341
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_FORBIDDEN_WALLET_BASE_URL, 'Invalid base URL supplied by wallet');
342
+ }
343
+ if (url.protocol !== 'https:') {
344
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_FORBIDDEN_WALLET_BASE_URL, 'Base URLs supplied by wallets must be valid `https` URLs');
345
+ }
346
+ }
347
+ function getSequenceNumberFromByteArray(byteArray) {
348
+ const view = new DataView(byteArray);
349
+ return view.getUint32(0, /* littleEndian */ false);
350
+ }
341
351
  function transact(callback, config) {
342
352
  return __awaiter(this, void 0, void 0, function* () {
343
353
  assertSecureContext();
344
354
  const associationKeypair = yield generateAssociationKeypair();
345
355
  const sessionPort = yield startSession(associationKeypair.publicKey, config === null || config === void 0 ? void 0 : config.baseUri);
346
356
  const websocketURL = `ws://localhost:${sessionPort}/solana-wallet`;
357
+ let connectionStartTime;
358
+ const getNextRetryDelayMs = (() => {
359
+ const schedule = [...WEBSOCKET_CONNECTION_CONFIG.retryDelayScheduleMs];
360
+ return () => (schedule.length > 1 ? schedule.shift() : schedule[0]);
361
+ })();
347
362
  let nextJsonRpcMessageId = 1;
363
+ let lastKnownInboundSequenceNumber = 0;
348
364
  let state = { __type: 'disconnected' };
349
365
  return new Promise((resolve, reject) => {
350
- let attempts = 0;
351
366
  let socket;
352
367
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
368
  const jsonRpcResponsePromises = {};
@@ -372,18 +387,19 @@ function transact(callback, config) {
372
387
  state = { __type: 'disconnected' };
373
388
  }
374
389
  else {
375
- reject(new SolanaMobileWalletAdapterProtocolSessionClosedError(evt.code, evt.reason));
390
+ reject(new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_SESSION_CLOSED, `The wallet session dropped unexpectedly (${evt.code}: ${evt.reason}).`, { closeEvent: evt }));
376
391
  }
377
392
  disposeSocket();
378
393
  };
379
394
  const handleError = (_evt) => __awaiter(this, void 0, void 0, function* () {
380
395
  disposeSocket();
381
- if (++attempts >= WEBSOCKET_CONNECTION_CONFIG.maxAttempts) {
382
- reject(new SolanaMobileWalletAdapterProtocolSessionEstablishmentError(sessionPort));
396
+ if (Date.now() - connectionStartTime >= WEBSOCKET_CONNECTION_CONFIG.timeoutMs) {
397
+ reject(new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_WALLET_NOT_FOUND, `Failed to connect to the wallet websocket on port ${sessionPort}.`));
383
398
  }
384
399
  else {
385
400
  yield new Promise((resolve) => {
386
- retryWaitTimeoutId = window.setTimeout(resolve, WEBSOCKET_CONNECTION_CONFIG.retryDelayMs);
401
+ const retryDelayMs = getNextRetryDelayMs();
402
+ retryWaitTimeoutId = window.setTimeout(resolve, retryDelayMs);
387
403
  });
388
404
  attemptSocketConnection();
389
405
  }
@@ -393,13 +409,19 @@ function transact(callback, config) {
393
409
  switch (state.__type) {
394
410
  case 'connected':
395
411
  try {
412
+ const sequenceNumberVector = responseBuffer.slice(0, SEQUENCE_NUMBER_BYTES);
413
+ const sequenceNumber = getSequenceNumberFromByteArray(sequenceNumberVector);
414
+ if (sequenceNumber <= lastKnownInboundSequenceNumber) {
415
+ throw new Error('Encrypted message has invalid sequence number');
416
+ }
417
+ lastKnownInboundSequenceNumber = sequenceNumber;
396
418
  const jsonRpcMessage = yield decryptJsonRpcMessage(responseBuffer, state.sharedSecret);
397
419
  const responsePromise = jsonRpcResponsePromises[jsonRpcMessage.id];
398
420
  delete jsonRpcResponsePromises[jsonRpcMessage.id];
399
421
  responsePromise.resolve(jsonRpcMessage.result);
400
422
  }
401
423
  catch (e) {
402
- if (e instanceof SolanaMobileWalletAdapterProtocolJsonRpcError) {
424
+ if (e instanceof SolanaMobileWalletAdapterProtocolError) {
403
425
  const responsePromise = jsonRpcResponsePromises[e.jsonRpcMessageId];
404
426
  delete jsonRpcResponsePromises[e.jsonRpcMessageId];
405
427
  responsePromise.reject(e);
@@ -429,7 +451,28 @@ function transact(callback, config) {
429
451
  params,
430
452
  }, sharedSecret));
431
453
  return new Promise((resolve, reject) => {
432
- jsonRpcResponsePromises[id] = { resolve, reject };
454
+ jsonRpcResponsePromises[id] = {
455
+ resolve(result) {
456
+ switch (p) {
457
+ case 'authorize':
458
+ case 'reauthorize': {
459
+ const { wallet_uri_base } = result;
460
+ if (wallet_uri_base != null) {
461
+ try {
462
+ assertSecureEndpointSpecificURI(wallet_uri_base);
463
+ }
464
+ catch (e) {
465
+ reject(e);
466
+ return;
467
+ }
468
+ }
469
+ break;
470
+ }
471
+ }
472
+ resolve(result);
473
+ },
474
+ reject,
475
+ };
433
476
  });
434
477
  });
435
478
  };
@@ -464,6 +507,9 @@ function transact(callback, config) {
464
507
  disposeSocket();
465
508
  }
466
509
  state = { __type: 'connecting', associationKeypair };
510
+ if (connectionStartTime === undefined) {
511
+ connectionStartTime = Date.now();
512
+ }
467
513
  socket = new WebSocket(websocketURL, [WEBSOCKET_PROTOCOL]);
468
514
  socket.addEventListener('open', handleOpen);
469
515
  socket.addEventListener('close', handleClose);
@@ -482,4 +528,4 @@ function transact(callback, config) {
482
528
  });
483
529
  }
484
530
 
485
- export { SolanaMobileWalletAdapterForbiddenWalletBaseURLError, SolanaMobileWalletAdapterProtocolAssociationPortOutOfRangeError, SolanaMobileWalletAdapterProtocolError, SolanaMobileWalletAdapterProtocolJsonRpcError, SolanaMobileWalletAdapterProtocolReauthorizeError, SolanaMobileWalletAdapterProtocolSessionClosedError, SolanaMobileWalletAdapterProtocolSessionEstablishmentError, SolanaMobileWalletAdapterSecureContextRequiredError, SolanaMobileWalletAdapterWalletNotInstalledError, transact };
531
+ export { SolanaMobileWalletAdapterError, SolanaMobileWalletAdapterErrorCode, SolanaMobileWalletAdapterProtocolError, SolanaMobileWalletAdapterProtocolErrorCode, transact };
package/lib/esm/index.mjs CHANGED
@@ -1,62 +1,38 @@
1
- class SolanaMobileWalletAdapterSecureContextRequiredError extends Error {
2
- constructor() {
3
- super('The mobile wallet adapter protocol must be used in a secure context (`https`).');
4
- this.name = 'SolanaMobileWalletAdapterSecureContextRequiredError';
5
- }
6
- }
7
- class SolanaMobileWalletAdapterForbiddenWalletBaseURLError extends Error {
8
- constructor() {
9
- super('Base URLs supplied by wallets must be valid `https` URLs');
10
- this.name = 'SolanaMobileWalletAdapterForbiddenWalletBaseURLError';
11
- }
12
- }
13
- class SolanaMobileWalletAdapterWalletNotInstalledError extends Error {
14
- constructor() {
15
- super(`Found no installed wallet that supports the mobile wallet protocol.`);
16
- this.name = 'SolanaMobileWalletAdapterWalletNotInstalledError';
17
- }
18
- }
19
- class SolanaMobileWalletAdapterProtocolSessionEstablishmentError extends Error {
20
- constructor(port) {
21
- super(`Failed to connect to the wallet websocket on port ${port}.`);
22
- this.name = 'SolanaMobileWalletAdapterProtocolSessionEstablishmentError';
23
- }
24
- }
25
- class SolanaMobileWalletAdapterProtocolAssociationPortOutOfRangeError extends Error {
26
- constructor(port) {
27
- super(`Association port number must be between 49152 and 65535. ${port} given.`);
28
- this.name = 'SolanaMobileWalletAdapterProtocolAssociationPortOutOfRangeError';
29
- }
30
- }
31
- class SolanaMobileWalletAdapterProtocolSessionClosedError extends Error {
32
- constructor(code, reason) {
33
- super(`The wallet session dropped unexpectedly (${code}: ${reason}).`);
34
- this.name = 'SolanaMobileWalletAdapterProtocolSessionClosedError';
35
- }
36
- }
37
- class SolanaMobileWalletAdapterProtocolReauthorizeError extends Error {
38
- constructor() {
39
- super('The auth token provided has gone stale and needs reauthorization.');
40
- this.name = 'SolanaMobileWalletAdapterProtocolReauthorizeError';
1
+ // Typescript `enums` thwart tree-shaking. See https://bargsten.org/jsts/enums/
2
+ const SolanaMobileWalletAdapterErrorCode = {
3
+ ERROR_ASSOCIATION_PORT_OUT_OF_RANGE: 'ERROR_ASSOCIATION_PORT_OUT_OF_RANGE',
4
+ ERROR_FORBIDDEN_WALLET_BASE_URL: 'ERROR_FORBIDDEN_WALLET_BASE_URL',
5
+ ERROR_SECURE_CONTEXT_REQUIRED: 'ERROR_SECURE_CONTEXT_REQUIRED',
6
+ ERROR_SESSION_CLOSED: 'ERROR_SESSION_CLOSED',
7
+ ERROR_WALLET_NOT_FOUND: 'ERROR_WALLET_NOT_FOUND',
8
+ };
9
+ class SolanaMobileWalletAdapterError extends Error {
10
+ constructor(...args) {
11
+ const [code, message, data] = args;
12
+ super(message);
13
+ this.code = code;
14
+ this.data = data;
15
+ this.name = 'SolanaMobileWalletAdapterError';
41
16
  }
42
17
  }
43
18
  // Typescript `enums` thwart tree-shaking. See https://bargsten.org/jsts/enums/
44
- const SolanaMobileWalletAdapterProtocolError = {
45
- ERROR_REAUTHORIZE: -1,
46
- ERROR_AUTHORIZATION_FAILED: -2,
47
- ERROR_INVALID_PAYLOAD: -3,
48
- ERROR_NOT_SIGNED: -4,
49
- ERROR_NOT_COMMITTED: -5,
19
+ const SolanaMobileWalletAdapterProtocolErrorCode = {
20
+ // Keep these in sync with `mobilewalletadapter/common/ProtocolContract.java`.
21
+ ERROR_AUTHORIZATION_FAILED: -1,
22
+ ERROR_INVALID_PAYLOADS: -2,
23
+ ERROR_NOT_SIGNED: -3,
24
+ ERROR_NOT_SUBMITTED: -4,
25
+ ERROR_TOO_MANY_PAYLOADS: -5,
50
26
  ERROR_ATTEST_ORIGIN_ANDROID: -100,
51
27
  };
52
- class SolanaMobileWalletAdapterProtocolJsonRpcError extends Error {
28
+ class SolanaMobileWalletAdapterProtocolError extends Error {
53
29
  constructor(...args) {
54
30
  const [jsonRpcMessageId, code, message, data] = args;
55
31
  super(message);
56
32
  this.code = code;
57
33
  this.data = data;
58
34
  this.jsonRpcMessageId = jsonRpcMessageId;
59
- this.name = 'SolanaNativeWalletAdapterJsonRpcError';
35
+ this.name = 'SolanaMobileWalletAdapterProtocolError';
60
36
  }
61
37
  }
62
38
 
@@ -96,6 +72,17 @@ function createHelloReq(ecdhPublicKey, associationKeypairPrivateKey) {
96
72
  });
97
73
  }
98
74
 
75
+ const SEQUENCE_NUMBER_BYTES = 4;
76
+ function createSequenceNumberVector(sequenceNumber) {
77
+ if (sequenceNumber >= 4294967296) {
78
+ throw new Error('Outbound sequence number overflow. The maximum sequence number is 32-bytes.');
79
+ }
80
+ const byteArray = new ArrayBuffer(SEQUENCE_NUMBER_BYTES);
81
+ const view = new DataView(byteArray);
82
+ view.setUint32(0, sequenceNumber, /* littleEndian */ false);
83
+ return new Uint8Array(byteArray);
84
+ }
85
+
99
86
  function generateAssociationKeypair() {
100
87
  return __awaiter(this, void 0, void 0, function* () {
101
88
  return yield crypto.subtle.generateKey({
@@ -118,30 +105,34 @@ const INITIALIZATION_VECTOR_BYTES = 12;
118
105
  function encryptJsonRpcMessage(jsonRpcMessage, sharedSecret) {
119
106
  return __awaiter(this, void 0, void 0, function* () {
120
107
  const plaintext = JSON.stringify(jsonRpcMessage);
108
+ const sequenceNumberVector = createSequenceNumberVector(jsonRpcMessage.id);
121
109
  const initializationVector = new Uint8Array(INITIALIZATION_VECTOR_BYTES);
122
110
  crypto.getRandomValues(initializationVector);
123
- const ciphertext = yield crypto.subtle.encrypt(getAlgorithmParams(initializationVector), sharedSecret, Buffer.from(plaintext));
124
- const response = new Uint8Array(initializationVector.byteLength + ciphertext.byteLength);
125
- response.set(new Uint8Array(initializationVector), 0);
126
- response.set(new Uint8Array(ciphertext), initializationVector.byteLength);
111
+ const ciphertext = yield crypto.subtle.encrypt(getAlgorithmParams(sequenceNumberVector, initializationVector), sharedSecret, Buffer.from(plaintext));
112
+ const response = new Uint8Array(sequenceNumberVector.byteLength + initializationVector.byteLength + ciphertext.byteLength);
113
+ response.set(new Uint8Array(sequenceNumberVector), 0);
114
+ response.set(new Uint8Array(initializationVector), sequenceNumberVector.byteLength);
115
+ response.set(new Uint8Array(ciphertext), sequenceNumberVector.byteLength + initializationVector.byteLength);
127
116
  return response;
128
117
  });
129
118
  }
130
119
  function decryptJsonRpcMessage(message, sharedSecret) {
131
120
  return __awaiter(this, void 0, void 0, function* () {
132
- const initializationVector = message.slice(0, INITIALIZATION_VECTOR_BYTES);
133
- const ciphertext = message.slice(INITIALIZATION_VECTOR_BYTES);
134
- const plaintextBuffer = yield crypto.subtle.decrypt(getAlgorithmParams(initializationVector), sharedSecret, ciphertext);
121
+ const sequenceNumberVector = message.slice(0, SEQUENCE_NUMBER_BYTES);
122
+ const initializationVector = message.slice(SEQUENCE_NUMBER_BYTES, SEQUENCE_NUMBER_BYTES + INITIALIZATION_VECTOR_BYTES);
123
+ const ciphertext = message.slice(SEQUENCE_NUMBER_BYTES + INITIALIZATION_VECTOR_BYTES);
124
+ const plaintextBuffer = yield crypto.subtle.decrypt(getAlgorithmParams(sequenceNumberVector, initializationVector), sharedSecret, ciphertext);
135
125
  const plaintext = getUtf8Decoder().decode(plaintextBuffer);
136
126
  const jsonRpcMessage = JSON.parse(plaintext);
137
127
  if (Object.hasOwnProperty.call(jsonRpcMessage, 'error')) {
138
- throw new SolanaMobileWalletAdapterProtocolJsonRpcError(jsonRpcMessage.id, jsonRpcMessage.error.code, jsonRpcMessage.error.message);
128
+ throw new SolanaMobileWalletAdapterProtocolError(jsonRpcMessage.id, jsonRpcMessage.error.code, jsonRpcMessage.error.message);
139
129
  }
140
130
  return jsonRpcMessage;
141
131
  });
142
132
  }
143
- function getAlgorithmParams(initializationVector) {
133
+ function getAlgorithmParams(sequenceNumber, initializationVector) {
144
134
  return {
135
+ additionalData: sequenceNumber,
145
136
  iv: initializationVector,
146
137
  name: 'AES-GCM',
147
138
  tagLength: 128, // 16 byte tag => 128 bits
@@ -179,7 +170,7 @@ function getRandomAssociationPort() {
179
170
  }
180
171
  function assertAssociationPort(port) {
181
172
  if (port < 49152 || port > 65535) {
182
- throw new SolanaMobileWalletAdapterProtocolAssociationPortOutOfRangeError(port);
173
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_ASSOCIATION_PORT_OUT_OF_RANGE, `Association port number must be between 49152 and 65535. ${port} given.`, { port });
183
174
  }
184
175
  return port;
185
176
  }
@@ -219,7 +210,7 @@ function getIntentURL(methodPathname, intentUrlBase) {
219
210
  }
220
211
  catch (_a) { } // eslint-disable-line no-empty
221
212
  if ((baseUrl === null || baseUrl === void 0 ? void 0 : baseUrl.protocol) !== 'https:') {
222
- throw new SolanaMobileWalletAdapterForbiddenWalletBaseURLError();
213
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_FORBIDDEN_WALLET_BASE_URL, 'Base URLs supplied by wallets must be valid `https` URLs');
223
214
  }
224
215
  }
225
216
  baseUrl || (baseUrl = new URL(`${INTENT_NAME}:/`));
@@ -314,7 +305,7 @@ function startSession(associationPublicKey, associationURLBase) {
314
305
  }
315
306
  }
316
307
  catch (e) {
317
- throw new SolanaMobileWalletAdapterWalletNotInstalledError();
308
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_WALLET_NOT_FOUND, 'Found no installed wallet that supports the mobile wallet protocol.');
318
309
  }
319
310
  }
320
311
  return randomAssociationPort;
@@ -322,32 +313,56 @@ function startSession(associationPublicKey, associationURLBase) {
322
313
  }
323
314
 
324
315
  const WEBSOCKET_CONNECTION_CONFIG = {
325
- maxAttempts: 34,
326
316
  /**
327
317
  * 300 milliseconds is a generally accepted threshold for what someone
328
318
  * would consider an acceptable response time for a user interface
329
- * after having performed a low-attention tapping task. We set the
319
+ * after having performed a low-attention tapping task. We set the initial
330
320
  * interval at which we wait for the wallet to set up the websocket at
331
- * half this, as per the Nyquist frequency.
321
+ * half this, as per the Nyquist frequency, with a progressive backoff
322
+ * sequence from there. The total wait time is 30s, which allows for the
323
+ * user to be presented with a disambiguation dialog, select a wallet, and
324
+ * for the wallet app to subsequently start.
332
325
  */
333
- retryDelayMs: 150,
326
+ retryDelayScheduleMs: [150, 150, 200, 500, 500, 750, 750, 1000],
327
+ timeoutMs: 30000,
334
328
  };
335
329
  const WEBSOCKET_PROTOCOL = 'com.solana.mobilewalletadapter.v1';
336
330
  function assertSecureContext() {
337
331
  if (typeof window === 'undefined' || window.isSecureContext !== true) {
338
- throw new SolanaMobileWalletAdapterSecureContextRequiredError();
332
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_SECURE_CONTEXT_REQUIRED, 'The mobile wallet adapter protocol must be used in a secure context (`https`).');
339
333
  }
340
334
  }
335
+ function assertSecureEndpointSpecificURI(walletUriBase) {
336
+ let url;
337
+ try {
338
+ url = new URL(walletUriBase);
339
+ }
340
+ catch (_a) {
341
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_FORBIDDEN_WALLET_BASE_URL, 'Invalid base URL supplied by wallet');
342
+ }
343
+ if (url.protocol !== 'https:') {
344
+ throw new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_FORBIDDEN_WALLET_BASE_URL, 'Base URLs supplied by wallets must be valid `https` URLs');
345
+ }
346
+ }
347
+ function getSequenceNumberFromByteArray(byteArray) {
348
+ const view = new DataView(byteArray);
349
+ return view.getUint32(0, /* littleEndian */ false);
350
+ }
341
351
  function transact(callback, config) {
342
352
  return __awaiter(this, void 0, void 0, function* () {
343
353
  assertSecureContext();
344
354
  const associationKeypair = yield generateAssociationKeypair();
345
355
  const sessionPort = yield startSession(associationKeypair.publicKey, config === null || config === void 0 ? void 0 : config.baseUri);
346
356
  const websocketURL = `ws://localhost:${sessionPort}/solana-wallet`;
357
+ let connectionStartTime;
358
+ const getNextRetryDelayMs = (() => {
359
+ const schedule = [...WEBSOCKET_CONNECTION_CONFIG.retryDelayScheduleMs];
360
+ return () => (schedule.length > 1 ? schedule.shift() : schedule[0]);
361
+ })();
347
362
  let nextJsonRpcMessageId = 1;
363
+ let lastKnownInboundSequenceNumber = 0;
348
364
  let state = { __type: 'disconnected' };
349
365
  return new Promise((resolve, reject) => {
350
- let attempts = 0;
351
366
  let socket;
352
367
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
368
  const jsonRpcResponsePromises = {};
@@ -372,18 +387,19 @@ function transact(callback, config) {
372
387
  state = { __type: 'disconnected' };
373
388
  }
374
389
  else {
375
- reject(new SolanaMobileWalletAdapterProtocolSessionClosedError(evt.code, evt.reason));
390
+ reject(new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_SESSION_CLOSED, `The wallet session dropped unexpectedly (${evt.code}: ${evt.reason}).`, { closeEvent: evt }));
376
391
  }
377
392
  disposeSocket();
378
393
  };
379
394
  const handleError = (_evt) => __awaiter(this, void 0, void 0, function* () {
380
395
  disposeSocket();
381
- if (++attempts >= WEBSOCKET_CONNECTION_CONFIG.maxAttempts) {
382
- reject(new SolanaMobileWalletAdapterProtocolSessionEstablishmentError(sessionPort));
396
+ if (Date.now() - connectionStartTime >= WEBSOCKET_CONNECTION_CONFIG.timeoutMs) {
397
+ reject(new SolanaMobileWalletAdapterError(SolanaMobileWalletAdapterErrorCode.ERROR_WALLET_NOT_FOUND, `Failed to connect to the wallet websocket on port ${sessionPort}.`));
383
398
  }
384
399
  else {
385
400
  yield new Promise((resolve) => {
386
- retryWaitTimeoutId = window.setTimeout(resolve, WEBSOCKET_CONNECTION_CONFIG.retryDelayMs);
401
+ const retryDelayMs = getNextRetryDelayMs();
402
+ retryWaitTimeoutId = window.setTimeout(resolve, retryDelayMs);
387
403
  });
388
404
  attemptSocketConnection();
389
405
  }
@@ -393,13 +409,19 @@ function transact(callback, config) {
393
409
  switch (state.__type) {
394
410
  case 'connected':
395
411
  try {
412
+ const sequenceNumberVector = responseBuffer.slice(0, SEQUENCE_NUMBER_BYTES);
413
+ const sequenceNumber = getSequenceNumberFromByteArray(sequenceNumberVector);
414
+ if (sequenceNumber <= lastKnownInboundSequenceNumber) {
415
+ throw new Error('Encrypted message has invalid sequence number');
416
+ }
417
+ lastKnownInboundSequenceNumber = sequenceNumber;
396
418
  const jsonRpcMessage = yield decryptJsonRpcMessage(responseBuffer, state.sharedSecret);
397
419
  const responsePromise = jsonRpcResponsePromises[jsonRpcMessage.id];
398
420
  delete jsonRpcResponsePromises[jsonRpcMessage.id];
399
421
  responsePromise.resolve(jsonRpcMessage.result);
400
422
  }
401
423
  catch (e) {
402
- if (e instanceof SolanaMobileWalletAdapterProtocolJsonRpcError) {
424
+ if (e instanceof SolanaMobileWalletAdapterProtocolError) {
403
425
  const responsePromise = jsonRpcResponsePromises[e.jsonRpcMessageId];
404
426
  delete jsonRpcResponsePromises[e.jsonRpcMessageId];
405
427
  responsePromise.reject(e);
@@ -429,7 +451,28 @@ function transact(callback, config) {
429
451
  params,
430
452
  }, sharedSecret));
431
453
  return new Promise((resolve, reject) => {
432
- jsonRpcResponsePromises[id] = { resolve, reject };
454
+ jsonRpcResponsePromises[id] = {
455
+ resolve(result) {
456
+ switch (p) {
457
+ case 'authorize':
458
+ case 'reauthorize': {
459
+ const { wallet_uri_base } = result;
460
+ if (wallet_uri_base != null) {
461
+ try {
462
+ assertSecureEndpointSpecificURI(wallet_uri_base);
463
+ }
464
+ catch (e) {
465
+ reject(e);
466
+ return;
467
+ }
468
+ }
469
+ break;
470
+ }
471
+ }
472
+ resolve(result);
473
+ },
474
+ reject,
475
+ };
433
476
  });
434
477
  });
435
478
  };
@@ -464,6 +507,9 @@ function transact(callback, config) {
464
507
  disposeSocket();
465
508
  }
466
509
  state = { __type: 'connecting', associationKeypair };
510
+ if (connectionStartTime === undefined) {
511
+ connectionStartTime = Date.now();
512
+ }
467
513
  socket = new WebSocket(websocketURL, [WEBSOCKET_PROTOCOL]);
468
514
  socket.addEventListener('open', handleOpen);
469
515
  socket.addEventListener('close', handleClose);
@@ -482,4 +528,4 @@ function transact(callback, config) {
482
528
  });
483
529
  }
484
530
 
485
- export { SolanaMobileWalletAdapterForbiddenWalletBaseURLError, SolanaMobileWalletAdapterProtocolAssociationPortOutOfRangeError, SolanaMobileWalletAdapterProtocolError, SolanaMobileWalletAdapterProtocolJsonRpcError, SolanaMobileWalletAdapterProtocolReauthorizeError, SolanaMobileWalletAdapterProtocolSessionClosedError, SolanaMobileWalletAdapterProtocolSessionEstablishmentError, SolanaMobileWalletAdapterSecureContextRequiredError, SolanaMobileWalletAdapterWalletNotInstalledError, transact };
531
+ export { SolanaMobileWalletAdapterError, SolanaMobileWalletAdapterErrorCode, SolanaMobileWalletAdapterProtocolError, SolanaMobileWalletAdapterProtocolErrorCode, transact };