@onekeyfe/hd-core 1.2.2-alpha.8 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +1 -1
  2. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +503 -10
  3. package/__tests__/DeviceCommands.test.ts +254 -1
  4. package/__tests__/core-error-output.test.ts +169 -1
  5. package/__tests__/device-lifecycle-events.test.ts +432 -15
  6. package/__tests__/logBlockEvent.test.ts +45 -122
  7. package/__tests__/open-wallet-session-error-response.test.ts +2 -2
  8. package/__tests__/open-wallet-session.test.ts +94 -363
  9. package/__tests__/protocol-v2.test.ts +132 -2
  10. package/__tests__/public-device-state-api.test.ts +2 -7
  11. package/__tests__/search-devices.test.ts +196 -8
  12. package/__tests__/sol-sign-offchain-message.test.ts +64 -0
  13. package/dist/api/FirmwareUpdateV4.d.ts +1 -0
  14. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  15. package/dist/api/GetFeatures.d.ts.map +1 -1
  16. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  17. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  18. package/dist/api/SearchDevices.d.ts +2 -15
  19. package/dist/api/SearchDevices.d.ts.map +1 -1
  20. package/dist/api/UploadPortfolio.d.ts +1 -0
  21. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  22. package/dist/api/allnetwork/AllNetworkGetAddress.d.ts +2 -0
  23. package/dist/api/allnetwork/AllNetworkGetAddress.d.ts.map +1 -1
  24. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts +7 -1
  25. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  26. package/dist/api/allnetwork/AllNetworkGetAddressByLoop.d.ts.map +1 -1
  27. package/dist/api/device/DeviceVerify.d.ts.map +1 -1
  28. package/dist/api/solana/SolSignOffchainMessage.d.ts.map +1 -1
  29. package/dist/core/RequestQueue.d.ts +3 -0
  30. package/dist/core/RequestQueue.d.ts.map +1 -1
  31. package/dist/core/index.d.ts +3 -1
  32. package/dist/core/index.d.ts.map +1 -1
  33. package/dist/core/uiPromiseRegistry.d.ts +1 -1
  34. package/dist/data-manager/TransportManager.d.ts +2 -0
  35. package/dist/data-manager/TransportManager.d.ts.map +1 -1
  36. package/dist/device/Device.d.ts +2 -0
  37. package/dist/device/Device.d.ts.map +1 -1
  38. package/dist/device/DeviceCommands.d.ts +5 -4
  39. package/dist/device/DeviceCommands.d.ts.map +1 -1
  40. package/dist/events/logBlockEvent.d.ts.map +1 -1
  41. package/dist/index.d.ts +35 -30
  42. package/dist/index.js +620 -325
  43. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  44. package/dist/types/api/getFeatures.d.ts.map +1 -1
  45. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  46. package/dist/types/api/openWalletSession.d.ts +1 -10
  47. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  48. package/dist/types/api/protocolV2.d.ts +3 -4
  49. package/dist/types/api/protocolV2.d.ts.map +1 -1
  50. package/dist/types/api/solSignOffchainMessage.d.ts +1 -0
  51. package/dist/types/api/solSignOffchainMessage.d.ts.map +1 -1
  52. package/dist/types/params.d.ts.map +1 -1
  53. package/dist/utils/patch.d.ts +1 -1
  54. package/dist/utils/patch.d.ts.map +1 -1
  55. package/package.json +4 -4
  56. package/src/api/FirmwareUpdateV4.ts +9 -4
  57. package/src/api/GetFeatures.ts +1 -0
  58. package/src/api/GetPassphraseState.ts +1 -0
  59. package/src/api/OpenWalletSession.ts +7 -77
  60. package/src/api/SearchDevices.ts +121 -27
  61. package/src/api/UploadPortfolio.ts +5 -4
  62. package/src/api/allnetwork/AllNetworkGetAddress.ts +79 -45
  63. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +95 -24
  64. package/src/api/allnetwork/AllNetworkGetAddressByLoop.ts +3 -0
  65. package/src/api/device/DeviceVerify.ts +8 -0
  66. package/src/api/solana/SolSignOffchainMessage.ts +44 -4
  67. package/src/core/RequestQueue.ts +36 -1
  68. package/src/core/index.ts +178 -63
  69. package/src/data/messages/messages-protocol-v2.json +49 -33
  70. package/src/data/messages/messages.json +8 -5
  71. package/src/data-manager/TransportManager.ts +14 -3
  72. package/src/device/Device.ts +58 -27
  73. package/src/device/DeviceCommands.ts +29 -2
  74. package/src/events/logBlockEvent.ts +6 -75
  75. package/src/protocols/protocol-v2/walletSession.ts +18 -2
  76. package/src/types/api/getFeatures.ts +2 -1
  77. package/src/types/api/getPassphraseState.ts +2 -5
  78. package/src/types/api/openWalletSession.ts +7 -19
  79. package/src/types/api/protocolV2.ts +3 -4
  80. package/src/types/api/solSignOffchainMessage.ts +2 -0
  81. package/src/types/params.ts +7 -0
@@ -2,21 +2,30 @@ import { EDeviceType, ERRORS, HardwareErrorCode, createDeferred } from '@onekeyf
2
2
  import { DeviceType, TRANSPORT_EVENT } from '@onekeyfe/hd-transport';
3
3
 
4
4
  import {
5
+ cancel,
6
+ getPostCallPendingPromise,
5
7
  initConnector,
6
8
  initCore,
9
+ isDeviceIdentityMismatchError,
7
10
  isMissingDetectedProtocolV2Error,
8
- isProtocolV2PeerRemovedPairingError,
9
11
  isRetryableBleConnectionError,
10
12
  isRetryableBleProtocolV2ProbeError,
13
+ isTerminalBleStaleBondError,
11
14
  resolveBleConnectProtocol,
12
15
  } from '../src/core';
13
16
  import { DataManager } from '../src/data-manager';
17
+ import GetDeviceState from '../src/api/GetDeviceState';
18
+ import DeviceVerify from '../src/api/device/DeviceVerify';
14
19
  import TransportManager from '../src/data-manager/TransportManager';
15
20
  import { Device } from '../src/device/Device';
16
21
  import { cancelDeviceInPrompt, cancelDeviceWithInitialize } from '../src/device/DeviceCommands';
17
22
  import { DevicePool } from '../src/device/DevicePool';
18
23
  import { CORE_EVENT, DEVICE, IFRAME } from '../src/events';
19
24
  import { PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE } from '../src/protocols/protocol-v2';
25
+ import {
26
+ ProtocolV2UiInteractionCoordinator,
27
+ resolveProtocolV2UiInteraction,
28
+ } from '../src/protocols/protocol-v2/uiInteraction';
20
29
 
21
30
  import type Core from '../src/core';
22
31
  import type { CoreMessage } from '../src/events';
@@ -72,6 +81,104 @@ describe('public device lifecycle events', () => {
72
81
  jest.restoreAllMocks();
73
82
  });
74
83
 
84
+ test.each(['V1', 'V2'] as const)(
85
+ 'preserves the %s acknowledged result and waits for release before the next acquire',
86
+ async protocol => {
87
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
88
+ jest.spyOn(DataManager, 'checkAndReloadData').mockResolvedValue(undefined);
89
+ jest.spyOn(TransportManager, 'configure').mockResolvedValue(undefined);
90
+ const device = createInitializedDevice(protocol);
91
+ jest.spyOn(Device, 'fromDescriptor').mockReturnValue(device);
92
+ const acquire = jest.spyOn(device, 'acquire').mockResolvedValue(undefined);
93
+ jest.spyOn(device, 'initialize').mockResolvedValue(undefined);
94
+ jest.spyOn(device, 'hasUnexpectedMode').mockReturnValue(undefined);
95
+ jest.spyOn(device, 'getCurrentMethodVersionRange').mockReturnValue({} as never);
96
+ device.commands = { disposed: false, dispose: jest.fn(), checkDisposed: jest.fn() } as never;
97
+ const methodRun = jest.spyOn(GetDeviceState.prototype, 'run').mockResolvedValue({} as never);
98
+ const releaseGate = createDeferred<void>();
99
+ const release = jest.spyOn(device, 'release').mockReturnValue(releaseGate.promise);
100
+ core = initCore();
101
+ const call = (id: number) =>
102
+ core!.handleMessage({
103
+ id,
104
+ event: IFRAME.CALL,
105
+ type: IFRAME.CALL,
106
+ payload: {
107
+ method: 'getDeviceState',
108
+ connectId: 'release-device',
109
+ connectProtocol: protocol,
110
+ },
111
+ } as CoreMessage);
112
+ await expect(call(301)).resolves.toMatchObject({ success: true });
113
+ const nextCall = call(302);
114
+ await new Promise(resolve => {
115
+ setImmediate(resolve);
116
+ });
117
+ try {
118
+ expect(methodRun).toHaveBeenCalledTimes(1);
119
+ expect(release).toHaveBeenCalledTimes(1);
120
+ expect(acquire).toHaveBeenCalledTimes(1);
121
+ } finally {
122
+ releaseGate.resolve();
123
+ }
124
+ await expect(nextCall).resolves.toMatchObject({ success: true });
125
+ expect(acquire).toHaveBeenCalledTimes(2);
126
+ }
127
+ );
128
+
129
+ test.each([
130
+ ['webusb', EDeviceType.Pro2],
131
+ ['desktop-webusb', EDeviceType.Pro2],
132
+ ['node-usb', EDeviceType.Neo],
133
+ ] as const)('waits one second after %s signing on %s', async (env, deviceType) => {
134
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
135
+ const cooldown = createDeferred<void>();
136
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback, delay) => {
137
+ cooldown.promise.then(() => callback());
138
+ return 0 as never;
139
+ });
140
+ const cleanup = createDeferred<void>();
141
+ const pending = getPostCallPendingPromise(
142
+ { name: 'evmSignTransaction' } as never,
143
+ {
144
+ getCurrentDeviceType: () => deviceType,
145
+ waitForRunCleanup: () => cleanup.promise,
146
+ } as never
147
+ );
148
+ let settled = false;
149
+ pending.then(() => {
150
+ settled = true;
151
+ });
152
+
153
+ cleanup.resolve();
154
+ await Promise.resolve();
155
+ expect(settled).toBe(false);
156
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
157
+ cooldown.resolve();
158
+ await pending;
159
+ expect(settled).toBe(true);
160
+ });
161
+
162
+ test.each([
163
+ ['react-native', EDeviceType.Pro2, 'evmSignTransaction'],
164
+ ['webusb', EDeviceType.Classic, 'evmSignTransaction'],
165
+ ['webusb', EDeviceType.Pro2, 'evmGetAddress'],
166
+ ] as const)('does not add a signing cooldown for %s, %s, %s', async (env, deviceType, name) => {
167
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
168
+ const cleanup = createDeferred<void>();
169
+ const pending = getPostCallPendingPromise(
170
+ { name } as never,
171
+ {
172
+ getCurrentDeviceType: () => deviceType,
173
+ waitForRunCleanup: () => cleanup.promise,
174
+ } as never
175
+ );
176
+
177
+ expect(pending).toBe(cleanup.promise);
178
+ cleanup.resolve();
179
+ await expect(pending).resolves.toBeUndefined();
180
+ });
181
+
75
182
  test('prefers Protocol V2 only when the method contract is explicitly V2-only', () => {
76
183
  const createMethod = (protocols: readonly ('V1' | 'V2')[], connectProtocol?: 'V1' | 'V2') =>
77
184
  ({
@@ -103,7 +210,7 @@ describe('public device lifecycle events', () => {
103
210
  expect(DevicePool.emitter.listenerCount(DEVICE.DISCONNECT)).toBe(1);
104
211
  });
105
212
 
106
- test('isolates pending cancellation cleanup by connect id', () => {
213
+ test('isolates pending cancellation cleanup by connect id and waits for every cleanup', async () => {
107
214
  core = initCore();
108
215
  const context = (core as any).getCoreContext();
109
216
  const firstCleanup = createDeferred<void>();
@@ -115,9 +222,220 @@ describe('public device lifecycle events', () => {
115
222
  expect(context.getPrePendingCallPromise('device-b')).toBeUndefined();
116
223
 
117
224
  context.setPrePendingCallPromise('device-a', replacementCleanup.promise);
225
+ const combined = context.getPrePendingCallPromise('device-a');
118
226
  context.removePrePendingCallPromise('device-a', firstCleanup.promise);
119
227
 
120
- expect(context.getPrePendingCallPromise('device-a')).toBe(replacementCleanup.promise);
228
+ expect(context.getPrePendingCallPromise('device-a')).toBe(combined);
229
+ let drained = false;
230
+ combined.then(() => {
231
+ drained = true;
232
+ });
233
+ replacementCleanup.resolve();
234
+ await Promise.resolve();
235
+ expect(drained).toBe(false);
236
+ firstCleanup.resolve();
237
+ await combined;
238
+ expect(drained).toBe(true);
239
+ });
240
+
241
+ test('cancels only requests associated with the requested connect id', () => {
242
+ core = initCore();
243
+ const context = (core as any).getCoreContext();
244
+ const deviceA = {
245
+ mainId: 'transport-session-a',
246
+ getConnectId: jest.fn(() => 'serial-a'),
247
+ interruptionFromUser: jest.fn().mockResolvedValue(undefined),
248
+ };
249
+ const deviceB = {
250
+ mainId: 'device-b',
251
+ getConnectId: jest.fn(() => 'serial-b'),
252
+ interruptionFromUser: jest.fn().mockResolvedValue(undefined),
253
+ };
254
+ const taskA = context.requestQueue.createTask({
255
+ responseID: 101,
256
+ connectId: '',
257
+ device: deviceA,
258
+ } as never);
259
+ const taskB = context.requestQueue.createTask({
260
+ responseID: 102,
261
+ connectId: 'device-b',
262
+ device: deviceB,
263
+ } as never);
264
+ const signalA = taskA.abortController?.signal;
265
+ const signalB = taskB.abortController?.signal;
266
+
267
+ cancel(context, 'serial-a');
268
+
269
+ expect(signalA?.aborted).toBe(true);
270
+ expect(context.requestQueue.getTask(taskA.id)).toBeUndefined();
271
+ expect(signalB?.aborted).toBe(false);
272
+ expect(context.requestQueue.getTask(taskB.id)).toBe(taskB);
273
+ expect(deviceA.interruptionFromUser).toHaveBeenCalledTimes(1);
274
+ expect(deviceB.interruptionFromUser).not.toHaveBeenCalled();
275
+ });
276
+
277
+ test.each(['callback', 'cleanup'] as const)(
278
+ 'cancels a request waiting for %s before acquire',
279
+ async phase => {
280
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
281
+ const acquire = jest.spyOn(Device.prototype, 'acquire').mockResolvedValue(undefined);
282
+ core = initCore();
283
+ const context = (core as any).getCoreContext();
284
+ const gate = createDeferred<void>();
285
+ const connectId = `queued-${phase}`;
286
+ if (phase === 'callback') context.registerCallbackTask(connectId, gate);
287
+ else context.setPrePendingCallPromise(connectId, gate.promise);
288
+ const result = core.handleMessage({
289
+ id: 201,
290
+ event: IFRAME.CALL,
291
+ type: IFRAME.CALL,
292
+ payload: { method: 'getDeviceState', connectId, connectProtocol: 'V2' },
293
+ } as CoreMessage);
294
+ await new Promise(resolve => {
295
+ setImmediate(resolve);
296
+ });
297
+ expect(context.requestQueue.getRequestTasksIdByConnectId(connectId)).toHaveLength(1);
298
+ cancel(context, connectId);
299
+ await expect(result).resolves.toMatchObject({
300
+ success: false,
301
+ payload: { code: HardwareErrorCode.CallQueueActionCancelled },
302
+ });
303
+ gate.resolve();
304
+ await new Promise(resolve => {
305
+ setImmediate(resolve);
306
+ });
307
+ expect(acquire).not.toHaveBeenCalled();
308
+ expect(context.requestQueue.getRequestTasksId()).toHaveLength(0);
309
+ }
310
+ );
311
+
312
+ test.each([
313
+ ['V1', 'callback'],
314
+ ['V1', 'cleanup'],
315
+ ['V2', 'callback'],
316
+ ['V2', 'cleanup'],
317
+ ] as const)(
318
+ 'does not run a cancelled %s request waiting for %s after acquire',
319
+ async (protocol, phase) => {
320
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
321
+ jest.spyOn(DataManager, 'checkAndReloadData').mockResolvedValue(undefined);
322
+ jest.spyOn(TransportManager, 'configure').mockResolvedValue(undefined);
323
+ const device = createInitializedDevice(protocol);
324
+ jest.spyOn(Device, 'fromDescriptor').mockReturnValue(device);
325
+ const acquire = jest.spyOn(device, 'acquire').mockResolvedValue(undefined);
326
+ jest.spyOn(device, 'hasUnexpectedMode').mockReturnValue(undefined);
327
+ jest.spyOn(device, 'getCurrentMethodVersionRange').mockReturnValue({} as never);
328
+ device.commands = { disposed: false, dispose: jest.fn(), checkDisposed: jest.fn() } as never;
329
+ const deviceRun = jest.spyOn(device, 'run');
330
+ core = initCore();
331
+ const context = (core as any).getCoreContext();
332
+ const gate = createDeferred<void>();
333
+ const connectId = `post-acquire-${protocol}-${phase}`;
334
+ jest.spyOn(device, 'initialize').mockImplementation(() => {
335
+ if (phase === 'callback') context.registerCallbackTask(connectId, gate);
336
+ else context.setPrePendingCallPromise(connectId, gate.promise);
337
+ return Promise.resolve();
338
+ });
339
+
340
+ const result = core.handleMessage({
341
+ id: 203,
342
+ event: IFRAME.CALL,
343
+ type: IFRAME.CALL,
344
+ payload: { method: 'getDeviceState', connectId, connectProtocol: protocol },
345
+ } as CoreMessage);
346
+ await new Promise(resolve => {
347
+ setImmediate(resolve);
348
+ });
349
+ expect(acquire).toHaveBeenCalledTimes(1);
350
+ expect(deviceRun).not.toHaveBeenCalled();
351
+
352
+ cancel(context, connectId);
353
+ await expect(result).resolves.toMatchObject({
354
+ success: false,
355
+ payload: { code: HardwareErrorCode.CallQueueActionCancelled },
356
+ });
357
+ gate.resolve();
358
+ await new Promise(resolve => {
359
+ setImmediate(resolve);
360
+ });
361
+ expect(deviceRun).not.toHaveBeenCalled();
362
+ }
363
+ );
364
+
365
+ test('clears the cleanup barrier when its deadline expires', async () => {
366
+ const realSetTimeout = setTimeout;
367
+ jest
368
+ .spyOn(global, 'setTimeout')
369
+ .mockImplementation((callback, delay, ...args) =>
370
+ realSetTimeout(callback, delay === 5_000 ? 0 : delay, ...args)
371
+ );
372
+ {
373
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
374
+ const acquire = jest.spyOn(Device.prototype, 'acquire').mockResolvedValue(undefined);
375
+ core = initCore();
376
+ const context = (core as any).getCoreContext();
377
+ const gate = createDeferred<void>();
378
+ context.setPrePendingCallPromise('draining-device', gate.promise);
379
+ const result = core.handleMessage({
380
+ id: 202,
381
+ event: IFRAME.CALL,
382
+ type: IFRAME.CALL,
383
+ payload: { method: 'getDeviceState', connectId: 'draining-device', connectProtocol: 'V2' },
384
+ } as CoreMessage);
385
+ setImmediate(() => cancel(context, 'draining-device'));
386
+ await expect(result).resolves.toBeDefined();
387
+ gate.resolve();
388
+ await new Promise(resolve => {
389
+ setImmediate(resolve);
390
+ });
391
+ expect(context.getPrePendingCallPromise('draining-device')).toBeUndefined();
392
+ }
393
+ });
394
+
395
+ test.each(['V1', 'V2'] as const)(
396
+ 'keeps repeated and late %s cancellation scoped to the old commands',
397
+ async protocol => {
398
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
399
+ const device = createInitializedDevice(protocol);
400
+ const gate = createDeferred<void>();
401
+ const oldCancel = jest.fn().mockResolvedValue(undefined);
402
+ device.commands = { cancel: oldCancel } as never;
403
+ device.setCancelableAction(() => gate.promise);
404
+ const first = device.interruptionFromUser();
405
+ expect(device.interruptionFromUser()).toBe(first);
406
+ device.beginConnectionAttempt();
407
+ const newCancel = jest.fn();
408
+ const newRun = createDeferred<void>();
409
+ const rejected = jest.spyOn(newRun, 'reject');
410
+ device.commands = { cancel: newCancel } as never;
411
+ device.runPromise = newRun;
412
+ const newAction = jest.fn().mockResolvedValue(undefined);
413
+ device.setCancelableAction(newAction);
414
+ gate.resolve();
415
+ await first;
416
+ expect(oldCancel).toHaveBeenCalledTimes(1);
417
+ expect(newCancel).not.toHaveBeenCalled();
418
+ expect(rejected).not.toHaveBeenCalled();
419
+ newRun.promise.catch(() => undefined);
420
+ await device.interruptionFromUser();
421
+ expect(newAction).toHaveBeenCalledTimes(1);
422
+ }
423
+ );
424
+
425
+ test('aborts every request before cancel-all rejects WebUSB tasks', () => {
426
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('webusb' as never);
427
+ core = initCore();
428
+ const context = (core as any).getCoreContext();
429
+ const task = context.requestQueue.createTask({
430
+ responseID: 103,
431
+ connectId: 'webusb-device',
432
+ } as never);
433
+ const signal = task.abortController?.signal;
434
+
435
+ cancel(context);
436
+
437
+ expect(signal?.aborted).toBe(true);
438
+ expect(context.requestQueue.getTask(task.id)).toBeUndefined();
121
439
  });
122
440
 
123
441
  test('keeps shared device lifecycle listeners across a device cache reset', () => {
@@ -314,6 +632,7 @@ describe('public device lifecycle events', () => {
314
632
  [HardwareErrorCode.PollingTimeout, false],
315
633
  [HardwareErrorCode.BleDeviceBondError, false],
316
634
  [HardwareErrorCode.BlePeerRemovedPairingInformation, false],
635
+ [HardwareErrorCode.BleBondInvalid, false],
317
636
  ] as const)('retries a BLE connection error with error code %s: %s', (errorCode, expected) => {
318
637
  const method = { payload: { connectProtocol: 'V2' } } as never;
319
638
  const error = {
@@ -324,22 +643,68 @@ describe('public device lifecycle events', () => {
324
643
  expect(isRetryableBleConnectionError(method, error)).toBe(expected);
325
644
  });
326
645
 
327
- test.each([
328
- ['V2', true],
329
- ['V1', false],
330
- [undefined, false],
331
- ] as const)(
332
- 'treats peer-removed pairing as terminal only for Protocol %s: %s',
333
- (connectProtocol, expected) => {
334
- const method = { payload: { connectProtocol } } as never;
335
- const error = {
336
- errorCode: HardwareErrorCode.BlePeerRemovedPairingInformation,
337
- };
646
+ test('does not retry the desktop acquire deadline fallback', () => {
647
+ const method = { payload: { connectProtocol: 'V2' } } as never;
648
+ const error = {
649
+ errorCode: HardwareErrorCode.BleTimeoutError,
650
+ params: { acquireDeadlineExceeded: true },
651
+ };
652
+
653
+ expect(isRetryableBleConnectionError(method, error)).toBe(false);
654
+ });
655
+
656
+ test.each(['V1', 'V2'] as const)(
657
+ 'stops the outer BLE poll after a manager reset for Protocol %s',
658
+ async connectProtocol => {
659
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
660
+ jest.spyOn(TransportManager, 'configure').mockResolvedValue(undefined);
661
+ const acquire = jest
662
+ .spyOn(Device.prototype, 'acquire')
663
+ .mockRejectedValue(
664
+ ERRORS.TypedError(HardwareErrorCode.PollingTimeout, 'BLE setup wedged repeatedly')
665
+ );
666
+ core = initCore();
667
+ const response = await core.handleMessage({
668
+ id: `manager-reset-${connectProtocol}`,
669
+ event: IFRAME.CALL,
670
+ type: IFRAME.CALL,
671
+ payload: {
672
+ method: 'getDeviceState',
673
+ connectId: `manager-reset-device-${connectProtocol}`,
674
+ connectProtocol,
675
+ retryCount: 1,
676
+ pollIntervalTime: 1,
677
+ },
678
+ } as CoreMessage);
338
679
 
339
- expect(isProtocolV2PeerRemovedPairingError(method, error)).toBe(expected);
680
+ expect(response).toMatchObject({
681
+ success: false,
682
+ payload: { code: HardwareErrorCode.PollingTimeout },
683
+ });
684
+ expect(acquire).toHaveBeenCalledTimes(1);
340
685
  }
341
686
  );
342
687
 
688
+ test.each([
689
+ [HardwareErrorCode.BleDeviceBondError, true],
690
+ [HardwareErrorCode.BlePeerRemovedPairingInformation, true],
691
+ [HardwareErrorCode.BleBondInvalid, true],
692
+ [HardwareErrorCode.DeviceNotFound, false],
693
+ ] as const)('treats BLE stale-bond error code %s as terminal: %s', (errorCode, expected) => {
694
+ const error = {
695
+ errorCode,
696
+ };
697
+
698
+ expect(isTerminalBleStaleBondError(error)).toBe(expected);
699
+ });
700
+
701
+ test.each([
702
+ [HardwareErrorCode.DeviceCheckDeviceIdError, true],
703
+ [HardwareErrorCode.DeviceNotFound, false],
704
+ ] as const)('treats device identity error code %s as a mismatch: %s', (errorCode, expected) => {
705
+ expect(isDeviceIdentityMismatchError({ errorCode })).toBe(expected);
706
+ });
707
+
343
708
  test('converts an internal transport disconnect into a public KnownDevice snapshot', () => {
344
709
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
345
710
  core = initCore();
@@ -416,6 +781,38 @@ describe('public device lifecycle events', () => {
416
781
  }
417
782
  );
418
783
 
784
+ test.each(['react-native', 'desktop-web-ble', 'webusb', 'desktop-webusb'] as const)(
785
+ 'sends Cancel once for device verification on an already unlocked Protocol V2 %s device',
786
+ async env => {
787
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
788
+ const device = createInitializedDevice('V2');
789
+ device.originalDescriptor.session = device.mainId;
790
+ jest.spyOn(device, 'hasDeviceAcquire').mockReturnValue(true);
791
+ const post = jest.fn().mockResolvedValue(undefined);
792
+ const cancel = jest.fn().mockResolvedValue(undefined);
793
+ device.commands = {
794
+ transport: { post },
795
+ cancelDevice: () => cancelDeviceInPrompt(device, false),
796
+ cancel,
797
+ } as never;
798
+ const method = new DeviceVerify({
799
+ payload: { method: 'deviceVerify', dataHex: '00' },
800
+ });
801
+ method.init();
802
+ const postMessage = jest.fn();
803
+ const coordinator = new ProtocolV2UiInteractionCoordinator(device, postMessage);
804
+ device.beginProtocolV2UiInteraction();
805
+ coordinator.enterMethodInteraction(resolveProtocolV2UiInteraction(method));
806
+
807
+ await device.interruptionFromUser();
808
+ await device.interruptionFromUser();
809
+
810
+ expect(post).toHaveBeenCalledTimes(1);
811
+ expect(post).toHaveBeenCalledWith(device.mainId, 'Cancel', {});
812
+ expect(cancel).toHaveBeenCalledTimes(1);
813
+ }
814
+ );
815
+
419
816
  test.each(['react-native', 'webusb', 'desktop-webusb'] as const)(
420
817
  'sends a fallback Cancel for an acquired Protocol V2 %s call with an open UI',
421
818
  async env => {
@@ -492,6 +889,26 @@ describe('public device lifecycle events', () => {
492
889
  expect(cancel).toHaveBeenCalledTimes(1);
493
890
  });
494
891
 
892
+ test.each(['V1', 'V2'] as const)(
893
+ 'disconnects and resolves when Protocol %s cancellation cleanup fails',
894
+ async protocol => {
895
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
896
+ const device = createInitializedDevice(protocol);
897
+ const cleanupError = new Error('cancel cleanup failed');
898
+ const cancel = jest.fn().mockRejectedValue(cleanupError);
899
+ const disconnect = jest.fn().mockResolvedValue(undefined);
900
+ device.deviceConnector = { disconnect } as never;
901
+ device.commands = { cancel } as never;
902
+ (device as unknown as { deviceAcquired: boolean }).deviceAcquired = true;
903
+
904
+ await expect(device.interruptionFromUser()).resolves.toBeUndefined();
905
+
906
+ expect(cancel).toHaveBeenCalledTimes(1);
907
+ expect(disconnect).toHaveBeenCalledWith(device.mainId);
908
+ expect(device.hasDeviceAcquire()).toBe(false);
909
+ }
910
+ );
911
+
495
912
  test('does not finish acquire after the user already cancelled', async () => {
496
913
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
497
914
  const device = createInitializedDevice('V2');