@onekeyfe/hd-core 1.2.0-alpha.16 → 1.2.0-alpha.18

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 (125) hide show
  1. package/__tests__/DeviceEventRegistration.test.ts +3 -0
  2. package/__tests__/deviceFeaturesUtils.test.ts +27 -0
  3. package/__tests__/evmLedgerLegacySafety.test.ts +3 -0
  4. package/__tests__/homescreen.test.ts +24 -0
  5. package/__tests__/kaspaSignTransaction.test.ts +591 -0
  6. package/__tests__/protocol-v2.test.ts +1006 -204
  7. package/__tests__/protocolV2FileWrite.test.ts +28 -3
  8. package/__tests__/protocolV2UiInteraction.test.ts +266 -0
  9. package/dist/api/BaseMethod.d.ts +4 -0
  10. package/dist/api/BaseMethod.d.ts.map +1 -1
  11. package/dist/api/FirmwareUpdateV4.d.ts +3 -3
  12. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  13. package/dist/api/GetDeviceInfo.d.ts.map +1 -1
  14. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  15. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  16. package/dist/api/device/DeviceCancel.d.ts.map +1 -1
  17. package/dist/api/device/DeviceChangePin.d.ts.map +1 -1
  18. package/dist/api/device/DeviceLock.d.ts.map +1 -1
  19. package/dist/api/device/DeviceUnlock.d.ts.map +1 -1
  20. package/dist/api/device/DeviceWipe.d.ts.map +1 -1
  21. package/dist/api/helpers/protocolV2FileWrite.d.ts +1 -0
  22. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  23. package/dist/api/index.d.ts +1 -1
  24. package/dist/api/index.d.ts.map +1 -1
  25. package/dist/api/kaspa/KaspaSignTransaction.d.ts +5 -1
  26. package/dist/api/kaspa/KaspaSignTransaction.d.ts.map +1 -1
  27. package/dist/api/kaspa/helpers/TransferSerialize.d.ts.map +1 -1
  28. package/dist/api/protocol-v2/DeviceInfoGet.d.ts.map +1 -1
  29. package/dist/api/protocol-v2/DeviceSessionOpen.d.ts +28 -0
  30. package/dist/api/protocol-v2/DeviceSessionOpen.d.ts.map +1 -0
  31. package/dist/api/protocol-v2/DeviceSettingsPageShow.d.ts.map +1 -1
  32. package/dist/api/protocol-v2/DeviceSettingsSet.d.ts.map +1 -1
  33. package/dist/api/protocol-v2/DeviceStatusGet.d.ts.map +1 -1
  34. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  35. package/dist/core/deviceEventRegistration.d.ts +1 -0
  36. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  37. package/dist/core/index.d.ts.map +1 -1
  38. package/dist/device/Device.d.ts +3 -3
  39. package/dist/device/Device.d.ts.map +1 -1
  40. package/dist/device/DeviceCommands.d.ts +7 -5
  41. package/dist/device/DeviceCommands.d.ts.map +1 -1
  42. package/dist/device/DevicePool.d.ts +2 -2
  43. package/dist/device/DevicePool.d.ts.map +1 -1
  44. package/dist/deviceProfile/buildDeviceFeatures.d.ts +6 -2
  45. package/dist/deviceProfile/buildDeviceFeatures.d.ts.map +1 -1
  46. package/dist/deviceProfile/buildDeviceProfile.d.ts +3 -2
  47. package/dist/deviceProfile/buildDeviceProfile.d.ts.map +1 -1
  48. package/dist/events/device.d.ts +4 -0
  49. package/dist/events/device.d.ts.map +1 -1
  50. package/dist/events/ui-request.d.ts +18 -2
  51. package/dist/events/ui-request.d.ts.map +1 -1
  52. package/dist/index.d.ts +85 -15
  53. package/dist/index.js +1515 -554
  54. package/dist/protocols/protocol-v2/features.d.ts +6 -18
  55. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  56. package/dist/protocols/protocol-v2/index.d.ts +1 -1
  57. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  58. package/dist/protocols/protocol-v2/uiInteraction.d.ts +39 -0
  59. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -0
  60. package/dist/protocols/protocol-v2/unlockRetry.d.ts +5 -3
  61. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -1
  62. package/dist/protocols/protocol-v2/walletSession.d.ts +2 -1
  63. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  64. package/dist/types/api/getDeviceInfo.d.ts +2 -1
  65. package/dist/types/api/getDeviceInfo.d.ts.map +1 -1
  66. package/dist/types/api/index.d.ts +2 -2
  67. package/dist/types/api/index.d.ts.map +1 -1
  68. package/dist/types/api/kaspaSignTransaction.d.ts +27 -2
  69. package/dist/types/api/kaspaSignTransaction.d.ts.map +1 -1
  70. package/dist/types/api/protocolV2.d.ts +4 -2
  71. package/dist/types/api/protocolV2.d.ts.map +1 -1
  72. package/dist/types/device.d.ts +2 -1
  73. package/dist/types/device.d.ts.map +1 -1
  74. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  75. package/dist/utils/homescreen.d.ts.map +1 -1
  76. package/dist/utils/patch.d.ts +1 -1
  77. package/dist/utils/patch.d.ts.map +1 -1
  78. package/package.json +4 -4
  79. package/src/api/BaseMethod.ts +10 -2
  80. package/src/api/FirmwareUpdateV4.ts +27 -73
  81. package/src/api/GetDeviceInfo.ts +9 -2
  82. package/src/api/GetPassphraseState.ts +5 -2
  83. package/src/api/UploadPortfolio.ts +2 -0
  84. package/src/api/device/DeviceCancel.ts +1 -0
  85. package/src/api/device/DeviceChangePin.ts +23 -0
  86. package/src/api/device/DeviceLock.ts +1 -0
  87. package/src/api/device/DeviceUnlock.ts +9 -0
  88. package/src/api/device/DeviceWipe.ts +18 -0
  89. package/src/api/helpers/protocolV2FileWrite.ts +35 -11
  90. package/src/api/index.ts +1 -1
  91. package/src/api/kaspa/KaspaSignTransaction.ts +341 -22
  92. package/src/api/kaspa/helpers/TransferSerialize.ts +14 -0
  93. package/src/api/protocol-v2/DeviceInfoGet.ts +0 -1
  94. package/src/api/protocol-v2/DeviceSessionOpen.ts +100 -0
  95. package/src/api/protocol-v2/DeviceSettingsPageShow.ts +9 -1
  96. package/src/api/protocol-v2/DeviceSettingsSet.ts +11 -1
  97. package/src/api/protocol-v2/DeviceStatusGet.ts +1 -0
  98. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +2 -1
  99. package/src/core/deviceEventRegistration.ts +2 -0
  100. package/src/core/index.ts +36 -3
  101. package/src/data/messages/messages-protocol-v2.json +122 -23
  102. package/src/data/messages/messages.json +286 -1
  103. package/src/device/Device.ts +33 -41
  104. package/src/device/DeviceCommands.ts +22 -4
  105. package/src/device/DevicePool.ts +14 -6
  106. package/src/deviceProfile/buildDeviceFeatures.ts +21 -9
  107. package/src/deviceProfile/buildDeviceProfile.ts +27 -11
  108. package/src/events/device.ts +4 -0
  109. package/src/events/ui-request.ts +24 -2
  110. package/src/inject.ts +2 -2
  111. package/src/protocols/protocol-v2/features.ts +27 -20
  112. package/src/protocols/protocol-v2/index.ts +0 -1
  113. package/src/protocols/protocol-v2/uiInteraction.ts +209 -0
  114. package/src/protocols/protocol-v2/unlockRetry.ts +43 -3
  115. package/src/protocols/protocol-v2/walletSession.ts +122 -31
  116. package/src/types/api/getDeviceInfo.ts +6 -1
  117. package/src/types/api/index.ts +2 -2
  118. package/src/types/api/kaspaSignTransaction.ts +40 -2
  119. package/src/types/api/protocolV2.ts +5 -3
  120. package/src/types/device.ts +6 -1
  121. package/src/utils/deviceFeaturesUtils.ts +13 -0
  122. package/src/utils/homescreen.ts +10 -0
  123. package/dist/api/protocol-v2/DeviceSessionGet.d.ts +0 -6
  124. package/dist/api/protocol-v2/DeviceSessionGet.d.ts.map +0 -1
  125. package/src/api/protocol-v2/DeviceSessionGet.ts +0 -15
@@ -1,5 +1,5 @@
1
1
  import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
- import { DeviceRebootType } from '@onekeyfe/hd-transport';
2
+ import { DeviceRebootType, DeviceSettingsPage } from '@onekeyfe/hd-transport';
3
3
 
4
4
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
5
5
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -15,11 +15,16 @@ import DeviceGetFirmwareUpdateStatus from '../src/api/protocol-v2/DeviceGetFirmw
15
15
  import DeviceGetOnboardingStatus from '../src/api/protocol-v2/DeviceGetOnboardingStatus';
16
16
  import DeviceInfoGet from '../src/api/protocol-v2/DeviceInfoGet';
17
17
  import DeviceReboot from '../src/api/protocol-v2/DeviceReboot';
18
+ import DeviceCancel from '../src/api/device/DeviceCancel';
19
+ import DeviceChangePin from '../src/api/device/DeviceChangePin';
20
+ import DeviceLock from '../src/api/device/DeviceLock';
21
+ import DeviceUnlock from '../src/api/device/DeviceUnlock';
22
+ import DeviceWipe from '../src/api/device/DeviceWipe';
18
23
  import DeviceSettingsGet from '../src/api/protocol-v2/DeviceSettingsGet';
19
24
  import DeviceSettingsPageShow from '../src/api/protocol-v2/DeviceSettingsPageShow';
20
25
  import DeviceSettingsSet from '../src/api/protocol-v2/DeviceSettingsSet';
21
26
  import DeviceUploadWallpaper from '../src/api/protocol-v2/DeviceUploadWallpaper';
22
- import DeviceSessionGet from '../src/api/protocol-v2/DeviceSessionGet';
27
+ import DeviceSessionOpen from '../src/api/protocol-v2/DeviceSessionOpen';
23
28
  import DeviceStatusGet from '../src/api/protocol-v2/DeviceStatusGet';
24
29
  import FilesystemFormat from '../src/api/protocol-v2/FilesystemFormat';
25
30
  import FilesystemPermissionFix from '../src/api/protocol-v2/FilesystemPermissionFix';
@@ -53,16 +58,20 @@ import { getBitcoinForkVersionRange } from '../src/api/btc/helpers/versionLimit'
53
58
  import { DataManager } from '../src/data-manager';
54
59
  import { createCoreApi } from '../src/inject';
55
60
  import { Device, preloadSessionCache } from '../src/device/Device';
61
+ import { DevicePool } from '../src/device/DevicePool';
62
+ import { DEVICE } from '../src/events/device';
56
63
  import { UI_REQUEST } from '../src/events/ui-request';
57
64
  import {
58
65
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
59
66
  requestProtocolV2DeviceInfo,
67
+ requestProtocolV2DeviceStatus,
60
68
  } from '../src/protocols/protocol-v2/features';
61
69
  import {
62
70
  getProtocolV2WalletSession,
63
71
  refreshProtocolV2DeviceStatus,
64
72
  } from '../src/protocols/protocol-v2/walletSession';
65
73
  import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
74
+ import { BaseMethod } from '../src/api/BaseMethod';
66
75
  import {
67
76
  buildProfileFromProtocolV2,
68
77
  buildProtocolV1FeaturesPayload,
@@ -84,6 +93,7 @@ import {
84
93
 
85
94
  import type { DeviceCommands } from '../src/device/DeviceCommands';
86
95
  import type { Features } from '../src/types';
96
+ import type { DeviceStatus } from '@onekeyfe/hd-transport';
87
97
  import type { ProtocolV2DeviceInfo } from '../src/protocols/protocol-v2/features';
88
98
 
89
99
  jest.mock('../src/data/config', () => ({
@@ -118,11 +128,11 @@ describe('DeviceUploadWallpaper', () => {
118
128
  expect(method.requireProtocolV2).toBe(true);
119
129
  expect(method.unlockPolicy).toBe('retry-on-locked');
120
130
  expect(typedCall).toHaveBeenNthCalledWith(1, 'FilesystemDirMake', 'Success', {
121
- path: 'vol0:/wallpapers/user',
131
+ path: 'vol1:/wallpapers',
122
132
  });
123
133
  const fileWriteCall = typedCall.mock.calls.find(call => call[0] === 'FilesystemFileWrite');
124
134
  expect(fileWriteCall?.[2]).toMatchObject({
125
- file: { path: expect.stringMatching(/^vol0:\/wallpapers\/user\/wallpaper-[a-f0-9]+\.bin$/) },
135
+ file: { path: expect.stringMatching(/^vol1:\/wallpapers\/wallpaper-[a-f0-9]+\.bin$/) },
126
136
  overwrite: true,
127
137
  append: false,
128
138
  });
@@ -206,6 +216,9 @@ describe('UploadPortfolio', () => {
206
216
  const result = await method.run();
207
217
 
208
218
  expect(method.unlockPolicy).toBe('retry-on-locked');
219
+ expect(method.protocolV2UiMode).toBe('none');
220
+ expect(method.protocolV2UiInteraction).toBeUndefined();
221
+ expect(method.payload.emitProgress).toBe(false);
209
222
  expect(typedCall).toHaveBeenNthCalledWith(
210
223
  1,
211
224
  'FilesystemFileWrite',
@@ -305,8 +318,23 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
305
318
  if (range) return range;
306
319
  return getMethodVersionRange(d.features, fn);
307
320
  };
308
- d.updateProtocolV2Features ??= (deviceInfo?: ProtocolV2DeviceInfo) => {
309
- d.features = buildProtocolV2FeaturesPayload(deviceInfo, d.features);
321
+ d.updateProtocolV2Features ??= (
322
+ deviceInfo?: ProtocolV2DeviceInfo,
323
+ deviceStatus?: DeviceStatus
324
+ ) => {
325
+ d.features = buildProtocolV2FeaturesPayload({
326
+ deviceInfo,
327
+ deviceStatus: deviceStatus ?? deviceInfo?.status,
328
+ previous: d.features,
329
+ });
330
+ return d.features;
331
+ };
332
+ d.updateProtocolV2Status ??= (deviceStatus: DeviceStatus) => {
333
+ d.features = buildProtocolV2FeaturesPayload({
334
+ deviceInfo: d.features?.raw?.protocolV2DeviceInfo,
335
+ deviceStatus,
336
+ previous: d.features,
337
+ });
310
338
  return d.features;
311
339
  };
312
340
  return device;
@@ -314,7 +342,10 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
314
342
 
315
343
  // 直接复用生产映射函数,避免测试内副本与实现漂移(之前的手抄副本已缺失 se boot 字段)
316
344
  function normalizeProtocolV2Features(_descriptor: unknown, deviceInfo?: ProtocolV2DeviceInfo) {
317
- return buildProtocolV2FeaturesPayload(deviceInfo);
345
+ return buildProtocolV2FeaturesPayload({
346
+ deviceInfo,
347
+ deviceStatus: deviceInfo?.status,
348
+ });
318
349
  }
319
350
 
320
351
  const protocolV2BootloaderDeviceInfo: ProtocolV2DeviceInfo = {
@@ -337,10 +368,64 @@ async function requestProtocolV2Features({
337
368
  descriptor?: unknown;
338
369
  }) {
339
370
  const deviceInfo = await requestProtocolV2DeviceInfo({ commands });
340
- return normalizeProtocolV2Features(inputDescriptor ?? descriptor, deviceInfo);
371
+ const deviceStatus = await requestProtocolV2DeviceStatus({ commands });
372
+ return buildProtocolV2FeaturesPayload({ deviceInfo, deviceStatus });
341
373
  }
342
374
 
343
375
  describe('Protocol V2 feature adapter', () => {
376
+ test('treats passphrase status as unknown until the Protocol V2 device is unlocked', () => {
377
+ const features = buildProtocolV2FeaturesPayload({
378
+ deviceInfo: {
379
+ hw: { serial_no: 'P2-LOCKED' },
380
+ },
381
+ deviceStatus: {
382
+ unlocked: false,
383
+ passphrase_enabled: true,
384
+ },
385
+ });
386
+
387
+ expect(features.unlocked).toBe(false);
388
+ expect(features.passphraseProtection).toBeNull();
389
+ expect(features.passphrase_protection).toBeUndefined();
390
+ });
391
+
392
+ test('builds dynamic features from DeviceStatus when DeviceInfo has no nested status', () => {
393
+ const deviceInfo: ProtocolV2DeviceInfo = {
394
+ protocol_version: 1,
395
+ hw: { serial_no: 'P2-STATIC' },
396
+ fw: { application: { version: '1.0.0' } },
397
+ coprocessor: { bt_adv_name: 'Pro2 STATIC' },
398
+ };
399
+ const deviceStatus: DeviceStatus = {
400
+ device_id: 'P2-DYNAMIC',
401
+ unlocked: true,
402
+ init_states: true,
403
+ backup_required: false,
404
+ passphrase_enabled: true,
405
+ attach_to_pin_enabled: true,
406
+ unlocked_by_attach_to_pin: true,
407
+ };
408
+
409
+ const features = buildProtocolV2FeaturesPayload({
410
+ deviceInfo,
411
+ deviceStatus,
412
+ });
413
+
414
+ expect(features).toMatchObject({
415
+ deviceId: 'P2-DYNAMIC',
416
+ serialNo: 'P2-STATIC',
417
+ bleName: 'Pro2 STATIC',
418
+ initialized: true,
419
+ unlocked: true,
420
+ passphraseProtection: true,
421
+ attachToPinEnabled: true,
422
+ unlockedAttachPin: true,
423
+ });
424
+ expect(features.raw?.protocolV2DeviceInfo).toEqual(deviceInfo);
425
+ expect(features.raw?.protocolV2DeviceStatus).toEqual(deviceStatus);
426
+ expect(features.raw?.protocolV2DeviceInfo?.status).toBeUndefined();
427
+ });
428
+
344
429
  test('keeps legacy snake_case feature fields for existing SDK consumers', () => {
345
430
  const protocolV1 = buildProtocolV1FeaturesPayload({
346
431
  device_id: 'v1-device',
@@ -350,14 +435,16 @@ describe('Protocol V2 feature adapter', () => {
350
435
  unlocked: true,
351
436
  } as any);
352
437
  const protocolV2 = buildProtocolV2FeaturesPayload({
353
- hw: { serial_no: 'P2-001' },
354
- coprocessor: { bt_adv_name: 'Pro 2 BLE' },
355
- status: {
438
+ deviceInfo: {
439
+ hw: { serial_no: 'P2-001' },
440
+ coprocessor: { bt_adv_name: 'Pro 2 BLE' },
441
+ },
442
+ deviceStatus: {
356
443
  device_id: 'v2-device',
357
444
  unlocked: true,
358
445
  passphrase_enabled: true,
359
446
  },
360
- } as any);
447
+ });
361
448
 
362
449
  expect(protocolV1).toMatchObject({
363
450
  device_id: 'v1-device',
@@ -511,7 +598,7 @@ describe('Protocol V2 feature adapter', () => {
511
598
  expect(profile.versions.board).toBe('1.0.0');
512
599
  });
513
600
 
514
- test('uses DeviceSessionGet for Protocol V2 passphrase sessions', async () => {
601
+ test('keeps the Protocol V2 main wallet on the default empty-passphrase context', async () => {
515
602
  const features = normalizeProtocolV2Features(descriptor as any);
516
603
  features.firmwareVersion = '1.2.3';
517
604
  features.passphraseProtection = true;
@@ -527,34 +614,164 @@ describe('Protocol V2 feature adapter', () => {
527
614
  (device as any).features = features;
528
615
  (device as any).commands = commands;
529
616
 
530
- await getPassphraseState(device, {
531
- expectPassphraseState: 'state-1',
532
- });
533
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
534
-
535
617
  await expect(
536
618
  getPassphraseState(device, {
537
- expectPassphraseState: 'state-2',
538
- })
539
- ).rejects.toEqual(
540
- expect.objectContaining({
541
- errorCode: HardwareErrorCode.DeviceCheckPassphraseStateError,
619
+ onlyMainPin: true,
542
620
  })
621
+ ).resolves.toEqual({
622
+ passphraseState: undefined,
623
+ newSession: undefined,
624
+ unlockedAttachPin: false,
625
+ });
626
+ expect(typedCall).not.toHaveBeenCalled();
627
+ expect(device.getInternalState()).toBeUndefined();
628
+ });
629
+
630
+ test('uses the default main wallet context when Protocol V2 passphrase protection is disabled', async () => {
631
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
632
+ (device as any).features = normalizeProtocolV2Features(
633
+ { ...descriptor, protocolType: 'V2' } as any,
634
+ { status: { passphrase_enabled: false, unlocked: true } }
635
+ );
636
+ const typedCall = jest.fn();
637
+ (device as any).commands = { typedCall };
638
+
639
+ await expect(getProtocolV2WalletSession(device)).resolves.toEqual({
640
+ passphraseState: undefined,
641
+ newSession: undefined,
642
+ unlockedAttachPin: false,
643
+ });
644
+
645
+ expect(typedCall).not.toHaveBeenCalled();
646
+ });
647
+
648
+ test('unlocks before deciding that Protocol V2 uses the default main wallet context', async () => {
649
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
650
+ (device as any).features = normalizeProtocolV2Features(
651
+ { ...descriptor, protocolType: 'V2' } as any,
652
+ {
653
+ status: {
654
+ unlocked: false,
655
+ passphrase_enabled: true,
656
+ },
657
+ }
658
+ );
659
+ const typedCall = jest.fn();
660
+ const promptPassphrase = jest.fn();
661
+ const unlockDevice = jest.spyOn(device, 'unlockDevice').mockImplementation(() => {
662
+ (device as any).features = {
663
+ ...(device as any).features,
664
+ unlocked: true,
665
+ passphraseProtection: false,
666
+ };
667
+ return (device as any).features;
668
+ });
669
+ (device as any).commands = { typedCall, promptPassphrase };
670
+
671
+ await expect(getProtocolV2WalletSession(device)).resolves.toEqual({
672
+ passphraseState: undefined,
673
+ newSession: undefined,
674
+ unlockedAttachPin: false,
675
+ });
676
+
677
+ expect(unlockDevice).toHaveBeenCalledTimes(1);
678
+ expect(promptPassphrase).not.toHaveBeenCalled();
679
+ expect(typedCall).not.toHaveBeenCalled();
680
+ });
681
+
682
+ test('onlyMainPin takes precedence over a cached hidden-wallet state', async () => {
683
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
684
+ (device as any).features = normalizeProtocolV2Features(
685
+ { ...descriptor, protocolType: 'V2' } as any,
686
+ { status: { device_id: 'main-wallet-device', passphrase_enabled: true, unlocked: true } }
543
687
  );
544
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
688
+ device.passphraseState = 'hidden-state';
689
+ preloadSessionCache('main-wallet-device', 'hidden-state', 'hidden-session');
690
+ const typedCall = jest.fn();
691
+ (device as any).commands = { typedCall };
545
692
 
546
693
  await getPassphraseState(device, {
547
694
  onlyMainPin: true,
548
695
  });
549
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
696
+ expect(typedCall).not.toHaveBeenCalled();
697
+ expect(device.getInternalState()).toBe('hidden-session');
698
+ });
699
+
700
+ test.each([
701
+ {
702
+ name: 'Host Passphrase',
703
+ response: { passphrase: 'hidden\u00e9 secret' },
704
+ select: { host_passphrase: { passphrase: 'hidden\u0065\u0301 secret' } },
705
+ event: undefined,
706
+ },
707
+ {
708
+ name: '设备 Passphrase',
709
+ response: { passphraseOnDevice: true },
710
+ select: { passphrase_on_device: {} },
711
+ event: DEVICE.PASSPHRASE_ON_DEVICE,
712
+ },
713
+ {
714
+ name: 'Attach PIN',
715
+ response: { attachPinOnDevice: true },
716
+ select: { attach_pin_on_device: {} },
717
+ event: DEVICE.ATTACH_PIN_ON_DEVICE,
718
+ },
719
+ ])('maps $name selection to DeviceSessionOpen', async ({ response, select, event }) => {
720
+ const device = Device.fromDescriptor({
721
+ id: 'select-device',
722
+ path: 'select-path',
723
+ protocolType: 'V2',
724
+ } as any);
725
+ (device as any).features = normalizeProtocolV2Features(
726
+ { ...descriptor, protocolType: 'V2' } as any,
727
+ {
728
+ status: {
729
+ device_id: 'select-device-id',
730
+ unlocked: true,
731
+ passphrase_enabled: true,
732
+ attach_to_pin_enabled: true,
733
+ },
734
+ }
735
+ );
736
+ const typedCall = jest.fn().mockResolvedValue({
737
+ type: 'DeviceSession',
738
+ message: { session_id: 'hidden-session', btc_test_address: 'hidden-state' },
739
+ });
740
+ const promptPassphrase = jest.fn().mockResolvedValue(response);
741
+ (device as any).commands = { typedCall, promptPassphrase };
742
+ const emit = jest.spyOn(device, 'emit');
743
+
744
+ await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
745
+ newSession: 'hidden-session',
746
+ passphraseState: 'hidden-state',
747
+ });
748
+
749
+ expect(promptPassphrase).toHaveBeenCalledWith(
750
+ expect.objectContaining({
751
+ source: 'wallet-session-coordinator',
752
+ reason: 'open-wallet',
753
+ existsAttachPinUser: true,
754
+ }),
755
+ { cancelDeviceOnReject: false }
756
+ );
757
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
758
+ select,
759
+ });
760
+ if (event) {
761
+ expect(emit).toHaveBeenCalledWith(
762
+ event,
763
+ device,
764
+ expect.objectContaining({ source: 'wallet-session-coordinator' })
765
+ );
766
+ }
550
767
  });
551
768
 
552
- test('deviceStatusGet returns raw DeviceStatus without updating features', async () => {
769
+ test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
553
770
  const typedCall = jest.fn().mockResolvedValue({
554
771
  type: 'DeviceStatus',
555
772
  message: { device_id: 'device-1', unlocked: true },
556
773
  });
557
- const updateProtocolV2Features = jest.fn();
774
+ const updateProtocolV2Status = jest.fn();
558
775
  const method = new DeviceStatusGet({
559
776
  payload: { method: 'deviceStatusGet', connectId: 'connect-id' },
560
777
  });
@@ -562,18 +779,27 @@ describe('Protocol V2 feature adapter', () => {
562
779
  method.device = stubDevice({
563
780
  originalDescriptor: { ...descriptor, protocolType: 'V2' },
564
781
  commands: { typedCall },
565
- updateProtocolV2Features,
782
+ updateProtocolV2Status,
566
783
  }) as any;
567
784
 
568
785
  await expect(method.run()).resolves.toEqual({ device_id: 'device-1', unlocked: true });
569
786
  expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
570
- expect(updateProtocolV2Features).not.toHaveBeenCalled();
787
+ expect(updateProtocolV2Status).toHaveBeenCalledWith({
788
+ device_id: 'device-1',
789
+ unlocked: true,
790
+ });
571
791
  });
572
792
 
573
- test('deviceGetOnboardingStatus returns the real Protocol V2 onboarding stage', async () => {
793
+ test('deviceGetOnboardingStatus returns the current Protocol V2 onboarding status', async () => {
574
794
  const typedCall = jest.fn().mockResolvedValue({
575
795
  type: 'DevOnboardingStatus',
576
- message: { stage: 2, status_code: 3, detail_code: 4 },
796
+ message: {
797
+ step: 4,
798
+ phase: 4,
799
+ setup: { kind: 1, method: 0 },
800
+ pin_set: true,
801
+ wallet_initialized: false,
802
+ },
577
803
  });
578
804
  const method = new DeviceGetOnboardingStatus({
579
805
  payload: {
@@ -588,24 +814,27 @@ describe('Protocol V2 feature adapter', () => {
588
814
  }) as any;
589
815
 
590
816
  await expect(method.run()).resolves.toEqual({
591
- stage: 2,
592
- status_code: 3,
593
- detail_code: 4,
817
+ step: 4,
818
+ phase: 4,
819
+ setup: { kind: 1, method: 0 },
820
+ pin_set: true,
821
+ wallet_initialized: false,
594
822
  });
595
823
  expect(typedCall).toHaveBeenCalledWith('DevGetOnboardingStatus', 'DevOnboardingStatus', {});
596
824
  expect(method.requireProtocolV2).toBe(true);
597
825
  });
598
826
 
599
- test('deviceSessionGet sends an empty request and does not mutate wallet cache', async () => {
827
+ test('deviceSessionOpen sends the explicit wallet selection and does not mutate wallet cache', async () => {
600
828
  const typedCall = jest.fn().mockResolvedValue({
601
829
  type: 'DeviceSession',
602
830
  message: { session_id: 'new-session', btc_test_address: 'state-a' },
603
831
  });
604
832
  const updateInternalState = jest.fn();
605
- const method = new DeviceSessionGet({
833
+ const method = new DeviceSessionOpen({
606
834
  payload: {
607
- method: 'deviceSessionGet',
835
+ method: 'deviceSessionOpen',
608
836
  connectId: 'connect-id',
837
+ select: { host_passphrase: { passphrase: 'hidden secret' } },
609
838
  },
610
839
  });
611
840
  method.init();
@@ -619,17 +848,42 @@ describe('Protocol V2 feature adapter', () => {
619
848
  session_id: 'new-session',
620
849
  btc_test_address: 'state-a',
621
850
  });
622
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
851
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
852
+ select: { host_passphrase: { passphrase: 'hidden secret' } },
853
+ });
623
854
  expect(updateInternalState).not.toHaveBeenCalled();
624
855
  });
625
856
 
857
+ test.each([
858
+ { payload: {}, reason: 'missing mode' },
859
+ { payload: { resume: { session_id: '' } }, reason: 'empty resume session' },
860
+ { payload: { select: { wallet_type: 0 } }, reason: 'removed standard wallet selection' },
861
+ {
862
+ payload: { select: { host_passphrase: { passphrase: 'secret' }, passphrase_on_device: {} } },
863
+ reason: 'multiple hidden wallet access modes',
864
+ },
865
+ ])('rejects invalid deviceSessionOpen payload: $reason', ({ payload }) => {
866
+ const method = new DeviceSessionOpen({
867
+ payload: {
868
+ method: 'deviceSessionOpen',
869
+ connectId: 'connect-id',
870
+ ...payload,
871
+ } as any,
872
+ });
873
+
874
+ expect(() => method.init()).toThrow();
875
+ });
876
+
626
877
  test('routes raw status and session methods through CoreApi', async () => {
627
878
  const call = jest.fn().mockResolvedValue({ success: true, payload: {} });
628
879
  const api = createCoreApi(call as any);
629
880
 
630
881
  await api.deviceStatusGet('connect-id', { retryCount: 1 });
631
882
  await api.deviceGetOnboardingStatus('connect-id', { retryCount: 1 });
632
- await api.deviceSessionGet('connect-id', { retryCount: 1 });
883
+ await api.deviceSessionOpen('connect-id', {
884
+ retryCount: 1,
885
+ select: { passphrase_on_device: {} },
886
+ });
633
887
 
634
888
  expect(call).toHaveBeenNthCalledWith(1, {
635
889
  method: 'deviceStatusGet',
@@ -642,9 +896,10 @@ describe('Protocol V2 feature adapter', () => {
642
896
  retryCount: 1,
643
897
  });
644
898
  expect(call).toHaveBeenNthCalledWith(3, {
645
- method: 'deviceSessionGet',
899
+ method: 'deviceSessionOpen',
646
900
  connectId: 'connect-id',
647
901
  retryCount: 1,
902
+ select: { passphrase_on_device: {} },
648
903
  });
649
904
  });
650
905
 
@@ -677,8 +932,8 @@ describe('Protocol V2 feature adapter', () => {
677
932
  newSession: 'session-b',
678
933
  });
679
934
 
680
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
681
- session_id: 'session-a',
935
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
936
+ resume: { session_id: 'session-a' },
682
937
  });
683
938
  expect(device.getInternalState()).toBe('session-b');
684
939
  });
@@ -700,9 +955,9 @@ describe('Protocol V2 feature adapter', () => {
700
955
  });
701
956
  (device as any).commands = { typedCall };
702
957
 
703
- await getProtocolV2WalletSession(device);
958
+ await getProtocolV2WalletSession(device, { onlyMainPin: true });
704
959
 
705
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
960
+ expect(typedCall).not.toHaveBeenCalled();
706
961
  expect(device.getInternalState()).toBeUndefined();
707
962
  });
708
963
 
@@ -731,15 +986,24 @@ describe('Protocol V2 feature adapter', () => {
731
986
  type: 'DeviceSession',
732
987
  message: { session_id: 'session-b', btc_test_address: 'state-a' },
733
988
  });
734
- (device as any).commands = { typedCall };
989
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'state-a-secret' });
990
+ (device as any).commands = { typedCall, promptPassphrase };
735
991
 
736
992
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
737
993
  passphraseState: 'state-a',
738
994
  newSession: 'session-b',
739
995
  });
740
996
  expect(typedCall.mock.calls).toEqual([
741
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
742
- ['DeviceSessionGet', 'DeviceSession', {}],
997
+ ['DeviceSessionOpen', 'DeviceSession', { resume: { session_id: 'session-a' } }],
998
+ [
999
+ 'DeviceSessionOpen',
1000
+ 'DeviceSession',
1001
+ {
1002
+ select: {
1003
+ host_passphrase: { passphrase: 'state-a-secret' },
1004
+ },
1005
+ },
1006
+ ],
743
1007
  ]);
744
1008
  expect(device.getInternalState()).toBe('session-b');
745
1009
  });
@@ -763,10 +1027,23 @@ describe('Protocol V2 feature adapter', () => {
763
1027
  const typedCall = jest
764
1028
  .fn()
765
1029
  .mockRejectedValue(new Error('Failure_ProcessError,Failure_InvalidSession'));
766
- (device as any).commands = { typedCall };
1030
+ (device as any).commands = {
1031
+ typedCall,
1032
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'new-secret' }),
1033
+ };
767
1034
 
768
1035
  await expect(getProtocolV2WalletSession(device)).rejects.toThrow('Failure_InvalidSession');
769
- expect(typedCall.mock.calls).toEqual([['DeviceSessionGet', 'DeviceSession', {}]]);
1036
+ expect(typedCall.mock.calls).toEqual([
1037
+ [
1038
+ 'DeviceSessionOpen',
1039
+ 'DeviceSession',
1040
+ {
1041
+ select: {
1042
+ host_passphrase: { passphrase: 'new-secret' },
1043
+ },
1044
+ },
1045
+ ],
1046
+ ]);
770
1047
  });
771
1048
 
772
1049
  test('rejects a mismatched Pro2 wallet state and clears the selected cache entry', async () => {
@@ -804,7 +1081,7 @@ describe('Protocol V2 feature adapter', () => {
804
1081
  expect(device.getInternalState()).toBeUndefined();
805
1082
  });
806
1083
 
807
- test('unlocks and retries DeviceSessionGet when the Pro2 wallet session is locked', async () => {
1084
+ test('unlocks and retries DeviceSessionOpen when hidden-wallet selection is locked', async () => {
808
1085
  const device = Device.fromDescriptor({
809
1086
  id: 'cache-device-4',
810
1087
  path: 'cache-path-4',
@@ -812,7 +1089,7 @@ describe('Protocol V2 feature adapter', () => {
812
1089
  } as any);
813
1090
  (device as any).features = normalizeProtocolV2Features(
814
1091
  { ...descriptor, protocolType: 'V2' } as any,
815
- { status: { device_id: 'stable-device-4', unlocked: false } }
1092
+ { status: { device_id: 'stable-device-4', unlocked: true, passphrase_enabled: true } }
816
1093
  );
817
1094
  const lockedError = Object.assign(new Error('Device is locked'), {
818
1095
  errorCode: HardwareErrorCode.DeviceLocked,
@@ -823,7 +1100,10 @@ describe('Protocol V2 feature adapter', () => {
823
1100
  .mockResolvedValueOnce({
824
1101
  message: { session_id: 'session-after-unlock', btc_test_address: '' },
825
1102
  });
826
- (device as any).commands = { typedCall };
1103
+ (device as any).commands = {
1104
+ typedCall,
1105
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'hidden secret' }),
1106
+ };
827
1107
  const unlockDevice = jest.spyOn(device, 'unlockDevice').mockResolvedValue(undefined as any);
828
1108
 
829
1109
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
@@ -887,6 +1167,32 @@ describe('Protocol V2 feature adapter', () => {
887
1167
  expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
888
1168
  });
889
1169
 
1170
+ test('drops stale normal status when firmware reconnect supplies bootloader DeviceInfo', () => {
1171
+ const device = Device.fromDescriptor({
1172
+ id: 'firmware-reconnect-device',
1173
+ path: 'firmware-reconnect-path',
1174
+ protocolType: 'V2',
1175
+ } as any);
1176
+ (device as any).features = buildProtocolV2FeaturesPayload({
1177
+ deviceInfo: {
1178
+ protocol_version: 1,
1179
+ hw: { serial_no: 'PR9999999999' },
1180
+ fw: { application: { version: '1.0.0' } },
1181
+ },
1182
+ deviceStatus: {
1183
+ device_id: 'firmware-reconnect-id',
1184
+ init_states: true,
1185
+ unlocked: false,
1186
+ } as any,
1187
+ });
1188
+
1189
+ (device as any).updateProtocolV2Features(protocolV2BootloaderDeviceInfo, null);
1190
+
1191
+ expect(device.features?.mode).toBe('bootloader');
1192
+ expect(device.features?.bootloaderMode).toBe(true);
1193
+ expect(device.features?.raw?.protocolV2DeviceStatus).toBeUndefined();
1194
+ });
1195
+
890
1196
  test('returns passphrase state string for existing Pro devices', async () => {
891
1197
  const features = {
892
1198
  deviceId: 'pro-device-id',
@@ -914,7 +1220,10 @@ describe('Protocol V2 feature adapter', () => {
914
1220
  method.device = stubDevice({
915
1221
  originalDescriptor: { ...descriptor, protocolType: 'V1' },
916
1222
  features,
917
- commands: { typedCall },
1223
+ commands: {
1224
+ typedCall,
1225
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-pro2-secret' }),
1226
+ },
918
1227
  updateInternalState,
919
1228
  getCurrentDeviceId: () => 'pro-device-id',
920
1229
  getCurrentPassphraseProtection: () => true,
@@ -957,7 +1266,10 @@ describe('Protocol V2 feature adapter', () => {
957
1266
  method.device = stubDevice({
958
1267
  originalDescriptor: { ...descriptor, protocolType: 'V2' },
959
1268
  features,
960
- commands: { typedCall },
1269
+ commands: {
1270
+ typedCall,
1271
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-pro2-new-secret' }),
1272
+ },
961
1273
  updateInternalState,
962
1274
  getFeatures,
963
1275
  getCurrentDeviceType: () => 'pro2',
@@ -996,7 +1308,10 @@ describe('Protocol V2 feature adapter', () => {
996
1308
  method.device = stubDevice({
997
1309
  originalDescriptor: { ...descriptor, protocolType: 'V2' },
998
1310
  features,
999
- commands: { typedCall },
1311
+ commands: {
1312
+ typedCall,
1313
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-pro2-new-secret' }),
1314
+ },
1000
1315
  clearInternalState,
1001
1316
  updateInternalState,
1002
1317
  getCurrentDeviceId: () => 'pro2-device-id',
@@ -1032,7 +1347,10 @@ describe('Protocol V2 feature adapter', () => {
1032
1347
  }),
1033
1348
  unlocked: true,
1034
1349
  };
1035
- (device as any).commands = { typedCall };
1350
+ (device as any).commands = {
1351
+ typedCall,
1352
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-profile-secret' }),
1353
+ };
1036
1354
 
1037
1355
  await getPassphraseStateWithRefreshDeviceInfo(device);
1038
1356
 
@@ -1063,7 +1381,10 @@ describe('Protocol V2 feature adapter', () => {
1063
1381
  (device as any).features.firmwareVersion = '4.15.0';
1064
1382
  (device as any).features.passphraseProtection = true;
1065
1383
  (device as any).features.unlocked = true;
1066
- (device as any).commands = { typedCall };
1384
+ (device as any).commands = {
1385
+ typedCall,
1386
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-auto-secret' }),
1387
+ };
1067
1388
 
1068
1389
  await expect(getPassphraseStateWithRefreshDeviceInfo(device)).resolves.toMatchObject({
1069
1390
  passphraseState: 'state-auto',
@@ -1076,7 +1397,11 @@ describe('Protocol V2 feature adapter', () => {
1076
1397
  expect(device.getInternalState()).toBeUndefined();
1077
1398
  device.passphraseState = 'state-auto';
1078
1399
  expect(device.getInternalState()).toBe('session-auto');
1079
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
1400
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionOpen', 'DeviceSession', {
1401
+ select: {
1402
+ host_passphrase: { passphrase: 'state-auto-secret' },
1403
+ },
1404
+ });
1080
1405
  });
1081
1406
 
1082
1407
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -1095,6 +1420,7 @@ describe('Protocol V2 feature adapter', () => {
1095
1420
  } as any,
1096
1421
  {
1097
1422
  status: {
1423
+ unlocked: true,
1098
1424
  passphrase_enabled: false,
1099
1425
  },
1100
1426
  }
@@ -1107,13 +1433,13 @@ describe('Protocol V2 feature adapter', () => {
1107
1433
  getPassphraseStateWithRefreshDeviceInfo(device, { onlyMainPin: true })
1108
1434
  ).resolves.toMatchObject({
1109
1435
  passphraseState: undefined,
1110
- newSession: 'main-pin-session',
1436
+ newSession: undefined,
1111
1437
  });
1112
1438
 
1113
1439
  expect(device.features?.passphraseProtection).toBe(false);
1114
1440
  expect(device.features?.sessionId).toBeNull();
1115
1441
  expect(device.getInternalState()).toBeUndefined();
1116
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
1442
+ expect(typedCall).not.toHaveBeenCalled();
1117
1443
  });
1118
1444
 
1119
1445
  test('does not let skipPassphraseCheck hide Pro2 passphrase state mismatch', async () => {
@@ -1132,7 +1458,11 @@ describe('Protocol V2 feature adapter', () => {
1132
1458
  } as any);
1133
1459
  (device as any).features.firmwareVersion = '4.15.0';
1134
1460
  (device as any).features.passphraseProtection = true;
1135
- (device as any).commands = { typedCall };
1461
+ (device as any).features.unlocked = true;
1462
+ (device as any).commands = {
1463
+ typedCall,
1464
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'expected-secret' }),
1465
+ };
1136
1466
 
1137
1467
  await expect(device.checkPassphraseStateSafety('expected-state', false, true)).rejects.toEqual(
1138
1468
  expect.objectContaining({
@@ -1141,7 +1471,11 @@ describe('Protocol V2 feature adapter', () => {
1141
1471
  );
1142
1472
 
1143
1473
  expect(device.getInternalState()).toBeUndefined();
1144
- expect(typedCall).toHaveBeenNthCalledWith(1, 'DeviceSessionGet', 'DeviceSession', {});
1474
+ expect(typedCall).toHaveBeenNthCalledWith(1, 'DeviceSessionOpen', 'DeviceSession', {
1475
+ select: {
1476
+ host_passphrase: { passphrase: 'expected-secret' },
1477
+ },
1478
+ });
1145
1479
  });
1146
1480
 
1147
1481
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -1235,17 +1569,22 @@ describe('Protocol V2 feature adapter', () => {
1235
1569
 
1236
1570
  test('initializes Protocol V2 features from lightweight DeviceInfoGet', async () => {
1237
1571
  const commands = {
1238
- typedCall: jest.fn().mockResolvedValueOnce({
1239
- type: 'DeviceInfo',
1240
- message: {
1241
- hw: { serial_no: 'PR2SERIAL' },
1242
- status: {
1572
+ typedCall: jest
1573
+ .fn()
1574
+ .mockResolvedValueOnce({
1575
+ type: 'DeviceInfo',
1576
+ message: {
1577
+ hw: { serial_no: 'PR2SERIAL' },
1578
+ },
1579
+ })
1580
+ .mockResolvedValueOnce({
1581
+ type: 'DeviceStatus',
1582
+ message: {
1243
1583
  device_id: 'PRO2-STATUS-ID',
1244
1584
  init_states: true,
1245
1585
  passphrase_enabled: true,
1246
1586
  },
1247
- },
1248
- }),
1587
+ }),
1249
1588
  };
1250
1589
 
1251
1590
  const features = await requestProtocolV2Features({
@@ -1255,8 +1594,8 @@ describe('Protocol V2 feature adapter', () => {
1255
1594
 
1256
1595
  expect(features.deviceId).toBe('PRO2-STATUS-ID');
1257
1596
  expect(features.initialized).toBe(true);
1258
- expect(features.passphraseProtection).toBe(true);
1259
- expect(commands.typedCall).toHaveBeenCalledTimes(1);
1597
+ expect(features.passphraseProtection).toBeNull();
1598
+ expect(commands.typedCall).toHaveBeenCalledTimes(2);
1260
1599
  expect(commands.typedCall).toHaveBeenNthCalledWith(
1261
1600
  1,
1262
1601
  'DeviceInfoGet',
@@ -1266,7 +1605,6 @@ describe('Protocol V2 feature adapter', () => {
1266
1605
  hw: true,
1267
1606
  fw: true,
1268
1607
  coprocessor: true,
1269
- status: true,
1270
1608
  },
1271
1609
  types: {
1272
1610
  version: true,
@@ -1275,6 +1613,7 @@ describe('Protocol V2 feature adapter', () => {
1275
1613
  },
1276
1614
  { timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS }
1277
1615
  );
1616
+ expect(commands.typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
1278
1617
  });
1279
1618
 
1280
1619
  test('fails initialization when Protocol V2 DeviceInfoGet fails', async () => {
@@ -1291,29 +1630,34 @@ describe('Protocol V2 feature adapter', () => {
1291
1630
  });
1292
1631
 
1293
1632
  test('refreshes Protocol V2 basic device info with the lightweight request', async () => {
1294
- const typedCall = jest.fn().mockResolvedValueOnce({
1295
- type: 'DeviceInfo',
1296
- message: {
1297
- hw: { serial_no: 'PR2SERIAL' },
1298
- fw: {
1299
- application: {
1300
- version: '5.6.7',
1633
+ const typedCall = jest
1634
+ .fn()
1635
+ .mockResolvedValueOnce({
1636
+ type: 'DeviceInfo',
1637
+ message: {
1638
+ hw: { serial_no: 'PR2SERIAL' },
1639
+ fw: {
1640
+ application: {
1641
+ version: '5.6.7',
1642
+ },
1301
1643
  },
1302
- },
1303
- coprocessor: {
1304
- application: {
1305
- version: '8.9.10',
1644
+ coprocessor: {
1645
+ application: {
1646
+ version: '8.9.10',
1647
+ },
1648
+ bt_adv_name: 'Raw Pro2 BLE',
1306
1649
  },
1307
- bt_adv_name: 'Raw Pro2 BLE',
1308
1650
  },
1309
- status: {
1651
+ })
1652
+ .mockResolvedValueOnce({
1653
+ type: 'DeviceStatus',
1654
+ message: {
1310
1655
  device_id: 'PRO2-BASIC-ID',
1311
1656
  unlocked_by_attach_to_pin: true,
1312
1657
  init_states: true,
1313
1658
  passphrase_enabled: true,
1314
1659
  },
1315
- },
1316
- });
1660
+ });
1317
1661
  const method = new GetDeviceInfo({
1318
1662
  id: 1,
1319
1663
  payload: {
@@ -1347,7 +1691,6 @@ describe('Protocol V2 feature adapter', () => {
1347
1691
  hw: true,
1348
1692
  fw: true,
1349
1693
  coprocessor: true,
1350
- status: true,
1351
1694
  },
1352
1695
  types: {
1353
1696
  version: true,
@@ -1429,13 +1772,16 @@ describe('Protocol V2 feature adapter', () => {
1429
1772
  });
1430
1773
 
1431
1774
  test('reads full Protocol V2 device info only for verify scope', async () => {
1432
- const typedCall = jest.fn().mockResolvedValueOnce({
1433
- type: 'DeviceInfo',
1434
- message: {
1435
- hw: { serial_no: 'PR2SERIAL' },
1436
- status: { init_states: true },
1437
- },
1438
- });
1775
+ const typedCall = jest
1776
+ .fn()
1777
+ .mockResolvedValueOnce({
1778
+ type: 'DeviceInfo',
1779
+ message: { hw: { serial_no: 'PR2SERIAL' } },
1780
+ })
1781
+ .mockResolvedValueOnce({
1782
+ type: 'DeviceStatus',
1783
+ message: { init_states: true },
1784
+ });
1439
1785
  const method = new GetDeviceInfo({
1440
1786
  id: 1,
1441
1787
  payload: {
@@ -1465,7 +1811,6 @@ describe('Protocol V2 feature adapter', () => {
1465
1811
  se2: true,
1466
1812
  se3: true,
1467
1813
  se4: true,
1468
- status: true,
1469
1814
  },
1470
1815
  types: {
1471
1816
  version: true,
@@ -1479,21 +1824,26 @@ describe('Protocol V2 feature adapter', () => {
1479
1824
  });
1480
1825
 
1481
1826
  test('reads Protocol V2 SE version fields for versions scope', async () => {
1482
- const typedCall = jest.fn().mockResolvedValueOnce({
1483
- type: 'DeviceInfo',
1484
- message: {
1485
- hw: { serial_no: 'PR2SERIAL' },
1486
- se1: {
1487
- application: { version: '1.0.1' },
1488
- bootloader: { version: '1.0.0' },
1489
- },
1490
- se2: {
1491
- application: { version: '2.0.1' },
1492
- bootloader: { version: '2.0.0' },
1827
+ const typedCall = jest
1828
+ .fn()
1829
+ .mockResolvedValueOnce({
1830
+ type: 'DeviceInfo',
1831
+ message: {
1832
+ hw: { serial_no: 'PR2SERIAL' },
1833
+ se1: {
1834
+ application: { version: '1.0.1' },
1835
+ bootloader: { version: '1.0.0' },
1836
+ },
1837
+ se2: {
1838
+ application: { version: '2.0.1' },
1839
+ bootloader: { version: '2.0.0' },
1840
+ },
1493
1841
  },
1494
- status: { init_states: true },
1495
- },
1496
- });
1842
+ })
1843
+ .mockResolvedValueOnce({
1844
+ type: 'DeviceStatus',
1845
+ message: { init_states: true },
1846
+ });
1497
1847
  const method = new GetDeviceInfo({
1498
1848
  id: 1,
1499
1849
  payload: {
@@ -1522,7 +1872,6 @@ describe('Protocol V2 feature adapter', () => {
1522
1872
  se2: true,
1523
1873
  se3: true,
1524
1874
  se4: true,
1525
- status: true,
1526
1875
  },
1527
1876
  types: {
1528
1877
  version: true,
@@ -1698,7 +2047,7 @@ describe('Protocol V2 feature adapter', () => {
1698
2047
  expect(message).not.toHaveProperty('label');
1699
2048
  });
1700
2049
 
1701
- test('refreshes cached Protocol V2 features with a lightweight status request on later runs', async () => {
2050
+ test('reuses cached Protocol V2 features on later runs without polling device status', async () => {
1702
2051
  const device = Device.fromDescriptor({
1703
2052
  path: 'usb-path',
1704
2053
  protocolType: 'V2',
@@ -1710,21 +2059,14 @@ describe('Protocol V2 feature adapter', () => {
1710
2059
  message: {
1711
2060
  hw: { serial_no: 'PR2SERIAL' },
1712
2061
  fw: { application: { version: '1.2.3' } },
1713
- status: {
1714
- device_id: 'PRO2-REFRESH-ID',
1715
- passphrase_enabled: true,
1716
- },
1717
2062
  },
1718
2063
  })
1719
- // 第二次 run 的轻量 status 刷新:设备端 label / init_states 等可能已变化
1720
2064
  .mockResolvedValueOnce({
1721
- type: 'DeviceInfo',
2065
+ type: 'DeviceStatus',
1722
2066
  message: {
1723
- hw: { serial_no: 'PR2SERIAL' },
1724
- status: {
1725
- device_id: 'PRO2-REFRESH-ID',
1726
- passphrase_enabled: false,
1727
- },
2067
+ device_id: 'PRO2-REFRESH-ID',
2068
+ unlocked: true,
2069
+ passphrase_enabled: true,
1728
2070
  },
1729
2071
  });
1730
2072
 
@@ -1736,46 +2078,23 @@ describe('Protocol V2 feature adapter', () => {
1736
2078
  expect(device.features).toMatchObject({
1737
2079
  deviceId: 'PRO2-REFRESH-ID',
1738
2080
  firmwareVersion: '1.2.3',
1739
- passphraseProtection: false,
1740
- label: null,
1741
- });
1742
- expect((device as any).profile).toBeUndefined();
1743
- // status 字段被第二次刷新更新
1744
- expect(device.features?.passphraseProtection).toBe(false);
1745
- expect(device.features?.label).toBeNull();
1746
- // 轻量刷新不含 fw target,已有版本信息按字段级合并保留
1747
- expect(device.features?.firmwareVersion).toBe('1.2.3');
1748
- expect(typedCall).toHaveBeenCalledTimes(2);
1749
- expect(typedCall).toHaveBeenNthCalledWith(
1750
- 1,
1751
- 'DeviceInfoGet',
1752
- 'DeviceInfo',
1753
- {
1754
- targets: {
1755
- hw: true,
1756
- fw: true,
1757
- coprocessor: true,
1758
- status: true,
1759
- },
1760
- types: {
1761
- version: true,
1762
- specific: true,
1763
- },
1764
- },
1765
- {
1766
- timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
1767
- }
1768
- );
1769
- // 第二次为 status-only 轻量请求(hw/coprocessor 仅用于身份字段,避免顶层覆盖清空)
2081
+ passphraseProtection: true,
2082
+ label: null,
2083
+ });
2084
+ expect((device as any).profile).toBeUndefined();
2085
+ expect(device.features?.passphraseProtection).toBe(true);
2086
+ expect(device.features?.label).toBeNull();
2087
+ expect(device.features?.firmwareVersion).toBe('1.2.3');
2088
+ expect(typedCall).toHaveBeenCalledTimes(2);
1770
2089
  expect(typedCall).toHaveBeenNthCalledWith(
1771
- 2,
2090
+ 1,
1772
2091
  'DeviceInfoGet',
1773
2092
  'DeviceInfo',
1774
2093
  {
1775
2094
  targets: {
1776
2095
  hw: true,
2096
+ fw: true,
1777
2097
  coprocessor: true,
1778
- status: true,
1779
2098
  },
1780
2099
  types: {
1781
2100
  version: true,
@@ -1786,6 +2105,7 @@ describe('Protocol V2 feature adapter', () => {
1786
2105
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
1787
2106
  }
1788
2107
  );
2108
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
1789
2109
  });
1790
2110
 
1791
2111
  test('refreshes Protocol V2 features without falling back to V1 GetFeatures', async () => {
@@ -1800,16 +2120,22 @@ describe('Protocol V2 feature adapter', () => {
1800
2120
  message: {
1801
2121
  hw: { serial_no: 'PR2SERIAL' },
1802
2122
  fw: { application: { version: '1.2.3' } },
1803
- status: { init_states: true },
1804
2123
  },
1805
2124
  })
2125
+ .mockResolvedValueOnce({
2126
+ type: 'DeviceStatus',
2127
+ message: { init_states: true },
2128
+ })
1806
2129
  .mockResolvedValueOnce({
1807
2130
  type: 'DeviceInfo',
1808
2131
  message: {
1809
2132
  hw: { serial_no: 'PR2SERIAL' },
1810
2133
  fw: { application: { version: '1.2.4' } },
1811
- status: { init_states: true, passphrase_enabled: true },
1812
2134
  },
2135
+ })
2136
+ .mockResolvedValueOnce({
2137
+ type: 'DeviceStatus',
2138
+ message: { init_states: true, unlocked: true, passphrase_enabled: true },
1813
2139
  });
1814
2140
 
1815
2141
  (device as any).commands = { typedCall };
@@ -1828,9 +2154,9 @@ describe('Protocol V2 feature adapter', () => {
1828
2154
  firmwareVersion: '1.2.4',
1829
2155
  passphraseProtection: true,
1830
2156
  });
1831
- expect(typedCall).toHaveBeenCalledTimes(2);
2157
+ expect(typedCall).toHaveBeenCalledTimes(4);
1832
2158
  expect(typedCall).toHaveBeenNthCalledWith(
1833
- 2,
2159
+ 3,
1834
2160
  'DeviceInfoGet',
1835
2161
  'DeviceInfo',
1836
2162
  {
@@ -1838,7 +2164,6 @@ describe('Protocol V2 feature adapter', () => {
1838
2164
  hw: true,
1839
2165
  fw: true,
1840
2166
  coprocessor: true,
1841
- status: true,
1842
2167
  },
1843
2168
  types: {
1844
2169
  version: true,
@@ -1934,7 +2259,7 @@ describe('Protocol V2 feature adapter', () => {
1934
2259
  const features = await device.unlockDevice();
1935
2260
 
1936
2261
  expect(typedCall.mock.calls).toEqual([
1937
- ['DeviceSessionAskPin', 'Success'],
2262
+ ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
1938
2263
  ['DeviceStatusGet', 'DeviceStatus', {}],
1939
2264
  ]);
1940
2265
  expect(typedCall).not.toHaveBeenCalledWith('GetAddress', 'Address', expect.anything());
@@ -1946,7 +2271,7 @@ describe('Protocol V2 feature adapter', () => {
1946
2271
  unlockedAttachPin: true,
1947
2272
  passphraseProtection: true,
1948
2273
  });
1949
- expect(features.raw?.protocolV2DeviceInfo?.status).toMatchObject({
2274
+ expect(features.raw?.protocolV2DeviceStatus).toMatchObject({
1950
2275
  unlocked: true,
1951
2276
  unlocked_by_attach_to_pin: true,
1952
2277
  passphrase_enabled: true,
@@ -1987,7 +2312,7 @@ describe('Protocol V2 feature adapter', () => {
1987
2312
  await device.unlockDevice();
1988
2313
 
1989
2314
  expect(typedCall.mock.calls).toEqual([
1990
- ['DeviceSessionAskPin', 'Success'],
2315
+ ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
1991
2316
  ['DeviceStatusGet', 'DeviceStatus', {}],
1992
2317
  ]);
1993
2318
  expect((device as any).profile).toBeUndefined();
@@ -2016,9 +2341,11 @@ describe('Protocol V2 feature adapter', () => {
2016
2341
  })
2017
2342
  );
2018
2343
  expect(typedCall).toHaveBeenCalledTimes(1);
2019
- expect(typedCall.mock.calls).toEqual([['DeviceSessionAskPin', 'Success']]);
2344
+ expect(typedCall.mock.calls).toEqual([
2345
+ ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
2346
+ ]);
2020
2347
  expect(typedCall).not.toHaveBeenCalledWith(
2021
- 'DeviceSessionGet',
2348
+ 'DeviceSessionOpen',
2022
2349
  'DeviceSession',
2023
2350
  expect.anything()
2024
2351
  );
@@ -2506,7 +2833,6 @@ describe('Protocol V2 firmware update targets', () => {
2506
2833
  se2: true,
2507
2834
  se3: true,
2508
2835
  se4: true,
2509
- status: true,
2510
2836
  },
2511
2837
  types: {
2512
2838
  version: true,
@@ -2634,6 +2960,7 @@ describe('Protocol V2 firmware update targets', () => {
2634
2960
  });
2635
2961
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
2636
2962
  (method as any).device.getCommands = () => ({ typedCall });
2963
+ (method as any).protocolV2ExpectedDeviceId = '8693920D7CA90CEC4A88353D';
2637
2964
 
2638
2965
  await (method as any).waitForProtocolV2BootloaderMode(60 * 1000, 0);
2639
2966
 
@@ -2641,6 +2968,47 @@ describe('Protocol V2 firmware update targets', () => {
2641
2968
  expect(typedCall).toHaveBeenCalledTimes(1);
2642
2969
  });
2643
2970
 
2971
+ test('does not run generic initialize during Protocol V2 USB firmware reconnect', async () => {
2972
+ const method = new FirmwareUpdateV4({
2973
+ id: 1,
2974
+ payload: {
2975
+ method: 'firmwareUpdateV4',
2976
+ },
2977
+ });
2978
+ const commands = { disposed: true, mainId: '' };
2979
+ const initialize = jest.fn().mockResolvedValue(undefined);
2980
+ const device = stubDevice({
2981
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
2982
+ features: { bootloaderMode: false, capabilities: [] },
2983
+ deviceConnector: {
2984
+ enumerate: jest.fn().mockResolvedValue({
2985
+ descriptors: [{ id: 'usb-id', path: 'usb-path', protocolType: 'V2' }],
2986
+ }),
2987
+ },
2988
+ updateFromCache: jest.fn(),
2989
+ acquire: jest.fn().mockResolvedValue(undefined),
2990
+ initialize,
2991
+ commands,
2992
+ getCommands: () => commands,
2993
+ mainId: 'usb-path',
2994
+ });
2995
+ const cachedDevice = { getConnectId: () => 'usb-path' };
2996
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
2997
+ devices: {},
2998
+ deviceList: [cachedDevice],
2999
+ } as any);
3000
+ (method as any).device = device;
3001
+
3002
+ try {
3003
+ await (method as any).reconnectProtocolV2Device();
3004
+ } finally {
3005
+ getDevices.mockRestore();
3006
+ }
3007
+
3008
+ expect(device.acquire).toHaveBeenCalledWith('V2', { throwOnRunPromiseError: true });
3009
+ expect(initialize).not.toHaveBeenCalled();
3010
+ });
3011
+
2644
3012
  test('reboots Protocol V2 firmware flow back to normal after install status is finished', async () => {
2645
3013
  const method = new FirmwareUpdateV4({
2646
3014
  id: 1,
@@ -2754,7 +3122,7 @@ describe('Protocol V2 firmware update targets', () => {
2754
3122
  expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
2755
3123
  });
2756
3124
 
2757
- test('continues Protocol V2 install polling through temporary expected V2 probe failures', async () => {
3125
+ test('retries final device readiness through temporary Protocol V2 probe failures', async () => {
2758
3126
  const method = new FirmwareUpdateV4({
2759
3127
  id: 1,
2760
3128
  payload: {
@@ -2762,7 +3130,7 @@ describe('Protocol V2 firmware update targets', () => {
2762
3130
  },
2763
3131
  });
2764
3132
  const typedCall = jest.fn().mockImplementation((name: string) => {
2765
- if (name === 'DeviceFirmwareUpdateStatusGet') {
3133
+ if (name === 'DeviceStatusGet') {
2766
3134
  const callCount = typedCall.mock.calls.filter(call => call[0] === name).length;
2767
3135
  if (callCount === 1) {
2768
3136
  return Promise.reject(
@@ -2772,24 +3140,28 @@ describe('Protocol V2 firmware update targets', () => {
2772
3140
  );
2773
3141
  }
2774
3142
  return Promise.resolve({
2775
- type: 'DeviceFirmwareUpdateStatus',
2776
- message: {
2777
- records: [{ target_id: 6, status: 2 }],
2778
- },
3143
+ type: 'DeviceStatus',
3144
+ message: { device_id: 'PRO2-DEVICE-ID', init_states: true },
2779
3145
  });
2780
3146
  }
2781
3147
  if (name === 'DeviceInfoGet') {
2782
- return Promise.reject(new Error('DeviceInfo not ready'));
2783
- }
2784
- if (name === 'Ping') {
2785
- return Promise.resolve({ type: 'Success', message: { message: 'ready' } });
3148
+ return Promise.resolve({
3149
+ type: 'DeviceInfo',
3150
+ message: { hw: { serial_no: 'PR9999999999' } },
3151
+ });
2786
3152
  }
2787
3153
  return Promise.reject(new Error(`unexpected call ${name}`));
2788
3154
  });
2789
3155
  const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3156
+ const updateProtocolV2Features = jest.fn(() => ({
3157
+ deviceId: 'PRO2-DEVICE-ID',
3158
+ bootloaderMode: false,
3159
+ mode: 'normal',
3160
+ }));
2790
3161
 
2791
3162
  (method as any).device = stubDevice({
2792
3163
  getCommands: () => ({ typedCall }),
3164
+ updateProtocolV2Features,
2793
3165
  });
2794
3166
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
2795
3167
  method.postProgressMessage = jest.fn();
@@ -2798,16 +3170,67 @@ describe('Protocol V2 firmware update targets', () => {
2798
3170
  { target_id: 6, path: 'vol1:ble-firmware.bin' },
2799
3171
  ]);
2800
3172
 
2801
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3173
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
2802
3174
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
2803
- 'DeviceFirmwareUpdateStatusGet',
3175
+ 'DeviceStatusGet',
3176
+ 'DeviceStatusGet',
3177
+ 'DeviceInfoGet',
3178
+ ]);
3179
+ });
3180
+
3181
+ test('retries final device readiness after the USB handle is no longer acquired', async () => {
3182
+ const method = new FirmwareUpdateV4({
3183
+ id: 1,
3184
+ payload: {
3185
+ method: 'firmwareUpdateV4',
3186
+ },
3187
+ });
3188
+ const typedCall = jest.fn().mockImplementation((name: string) => {
3189
+ if (name === 'DeviceStatusGet') {
3190
+ const callCount = typedCall.mock.calls.filter(call => call[0] === name).length;
3191
+ if (callCount === 1) {
3192
+ return Promise.reject(new Error('Device not acquired: PR9999999999'));
3193
+ }
3194
+ return Promise.resolve({
3195
+ type: 'DeviceStatus',
3196
+ message: { device_id: 'PRO2-DEVICE-ID', init_states: true },
3197
+ });
3198
+ }
3199
+ if (name === 'DeviceInfoGet') {
3200
+ return Promise.resolve({
3201
+ type: 'DeviceInfo',
3202
+ message: { hw: { serial_no: 'PR9999999999' } },
3203
+ });
3204
+ }
3205
+ return Promise.reject(new Error(`unexpected call ${name}`));
3206
+ });
3207
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3208
+ const updateProtocolV2Features = jest.fn(() => ({
3209
+ deviceId: 'PRO2-DEVICE-ID',
3210
+ bootloaderMode: false,
3211
+ mode: 'normal',
3212
+ }));
3213
+
3214
+ (method as any).device = stubDevice({
3215
+ getCommands: () => ({ typedCall }),
3216
+ updateProtocolV2Features,
3217
+ });
3218
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3219
+ method.postProgressMessage = jest.fn();
3220
+
3221
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete([
3222
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
3223
+ ]);
3224
+
3225
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
3226
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3227
+ 'DeviceStatusGet',
3228
+ 'DeviceStatusGet',
2804
3229
  'DeviceInfoGet',
2805
- 'Ping',
2806
- 'DeviceFirmwareUpdateStatusGet',
2807
3230
  ]);
2808
3231
  });
2809
3232
 
2810
- test('treats Protocol V2 normal mode after reconnect as firmware update complete', async () => {
3233
+ test('treats DeviceStatusGet and DeviceInfoGet readiness as firmware update complete', async () => {
2811
3234
  const method = new FirmwareUpdateV4({
2812
3235
  id: 1,
2813
3236
  payload: {
@@ -2815,8 +3238,14 @@ describe('Protocol V2 firmware update targets', () => {
2815
3238
  },
2816
3239
  });
2817
3240
  const typedCall = jest.fn().mockImplementation((name: string) => {
2818
- if (name === 'DeviceFirmwareUpdateStatusGet') {
2819
- return Promise.reject(new Error('Device disconnected'));
3241
+ if (name === 'DeviceStatusGet') {
3242
+ return Promise.resolve({
3243
+ type: 'DeviceStatus',
3244
+ message: {
3245
+ device_id: 'PRO2-DEVICE-ID',
3246
+ init_states: true,
3247
+ },
3248
+ });
2820
3249
  }
2821
3250
  if (name === 'DeviceInfoGet') {
2822
3251
  return Promise.resolve({
@@ -2827,19 +3256,17 @@ describe('Protocol V2 firmware update targets', () => {
2827
3256
  bootloader: { version: '9.9.9' },
2828
3257
  application: { version: '9.9.9' },
2829
3258
  },
2830
- status: {
2831
- device_id: 'PRO2-DEVICE-ID',
2832
- init_states: true,
2833
- },
2834
3259
  },
2835
3260
  });
2836
3261
  }
2837
3262
  return Promise.reject(new Error(`unexpected call ${name}`));
2838
3263
  });
2839
3264
  const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
2840
- const updateProtocolV2Features = jest.fn((deviceInfo: ProtocolV2DeviceInfo) =>
2841
- normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, deviceInfo)
2842
- );
3265
+ const updateProtocolV2Features = jest.fn(() => ({
3266
+ deviceId: 'PRO2-DEVICE-ID',
3267
+ bootloaderMode: false,
3268
+ mode: 'normal',
3269
+ }));
2843
3270
 
2844
3271
  (method as any).device = stubDevice({
2845
3272
  getCommands: () => ({ typedCall }),
@@ -2849,18 +3276,35 @@ describe('Protocol V2 firmware update targets', () => {
2849
3276
  method.postProgressMessage = jest.fn();
2850
3277
 
2851
3278
  await expect(
2852
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
2853
- { target_id: 3, path: 'vol1:bootloader.bin' },
2854
- { target_id: 4, path: 'vol1:application_p1.bin' },
2855
- ])
3279
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(
3280
+ [
3281
+ { target_id: 3, path: 'vol1:bootloader.bin' },
3282
+ { target_id: 4, path: 'vol1:application_p1.bin' },
3283
+ ],
3284
+ {
3285
+ type: 'DeviceFirmwareUpdateStatus',
3286
+ message: {
3287
+ records: [
3288
+ { target_id: 3, status: 2 },
3289
+ { target_id: 4, status: 2 },
3290
+ ],
3291
+ },
3292
+ }
3293
+ )
2856
3294
  ).resolves.toBeUndefined();
2857
3295
 
2858
3296
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
2859
3297
  expect(updateProtocolV2Features).toHaveBeenCalledTimes(1);
2860
- expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3298
+ expect(updateProtocolV2Features).toHaveBeenCalledWith(
3299
+ expect.objectContaining({ hw: { serial_no: 'PR9999999999' } }),
3300
+ expect.objectContaining({ device_id: 'PRO2-DEVICE-ID' })
3301
+ );
3302
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceStatusGet', 'DeviceInfoGet']);
3303
+ expect(typedCall).not.toHaveBeenCalledWith(
2861
3304
  'DeviceFirmwareUpdateStatusGet',
2862
- 'DeviceInfoGet',
2863
- ]);
3305
+ expect.anything(),
3306
+ expect.anything()
3307
+ );
2864
3308
  });
2865
3309
 
2866
3310
  test('uses SDK decoded enum names for Protocol V2 install polling', () => {
@@ -3995,6 +4439,64 @@ describe('Protocol V2 firmware reconnect identity', () => {
3995
4439
  assertProtocolV2ReconnectIdentity('expected-device', 'expected-device')
3996
4440
  ).not.toThrow();
3997
4441
  });
4442
+
4443
+ test('allows bootloader reconnect without wallet device id after USB identity is selected', () => {
4444
+ expect(() =>
4445
+ assertProtocolV2ReconnectIdentity('expected-device', undefined, {
4446
+ allowMissingActual: true,
4447
+ })
4448
+ ).not.toThrow();
4449
+ expect(() =>
4450
+ assertProtocolV2ReconnectIdentity('expected-device', 'other-device', {
4451
+ allowMissingActual: true,
4452
+ })
4453
+ ).toThrow('identity mismatch');
4454
+ });
4455
+ });
4456
+
4457
+ describe('Protocol V2 explicit USB device selection', () => {
4458
+ beforeEach(() => {
4459
+ DevicePool.resetState();
4460
+ jest.restoreAllMocks();
4461
+ });
4462
+
4463
+ test('only initializes the descriptor matching an explicit connectId', async () => {
4464
+ const zeroDescriptor = {
4465
+ path: '000000000000000000000000',
4466
+ id: '000000000000000000000000',
4467
+ } as any;
4468
+ const pro2Descriptor = {
4469
+ path: 'PR9999999999',
4470
+ id: 'PR9999999999',
4471
+ } as any;
4472
+ const pro2Device = {
4473
+ getConnectId: () => 'PR9999999999',
4474
+ originalDescriptor: pro2Descriptor,
4475
+ updateDescriptor: jest.fn(),
4476
+ } as any;
4477
+ const zeroDevice = {
4478
+ getConnectId: () => '000000000000000000000000',
4479
+ originalDescriptor: zeroDescriptor,
4480
+ updateDescriptor: jest.fn(),
4481
+ } as any;
4482
+
4483
+ const createDevice = jest
4484
+ .spyOn(DevicePool as any, '_createDevice')
4485
+ .mockImplementation((descriptor: { path: string }) =>
4486
+ Promise.resolve(descriptor.path === pro2Descriptor.path ? pro2Device : zeroDevice)
4487
+ );
4488
+ const checkDevicePool = jest
4489
+ .spyOn(DevicePool as any, '_checkDevicePool')
4490
+ .mockResolvedValue(undefined);
4491
+
4492
+ const result = await DevicePool.getDevices([zeroDescriptor, pro2Descriptor], 'PR9999999999');
4493
+
4494
+ expect(createDevice).toHaveBeenCalledTimes(1);
4495
+ expect(createDevice).toHaveBeenCalledWith(pro2Descriptor, undefined);
4496
+ expect(checkDevicePool).toHaveBeenCalledWith(undefined, 'PR9999999999');
4497
+ expect(result.deviceList).toEqual([pro2Device]);
4498
+ expect(result.devices).toEqual({ PR9999999999: pro2Device });
4499
+ });
3998
4500
  });
3999
4501
 
4000
4502
  describe('Protocol V2 reboot methods', () => {
@@ -4024,10 +4526,43 @@ describe('Protocol V2 protected method execution', () => {
4024
4526
  errorCode: HardwareErrorCode.DeviceLocked,
4025
4527
  });
4026
4528
 
4529
+ test('uses locked retry by default for SDK business methods', () => {
4530
+ class TestBusinessMethod extends BaseMethod {
4531
+ init() {}
4532
+
4533
+ run() {
4534
+ return Promise.resolve({});
4535
+ }
4536
+ }
4537
+
4538
+ const method = new TestBusinessMethod({
4539
+ id: 1,
4540
+ payload: { method: 'testBusinessMethod' },
4541
+ } as any);
4542
+
4543
+ expect(method.unlockPolicy).toBe('retry-on-locked');
4544
+ });
4545
+
4546
+ test.each([
4547
+ ['deviceLock', DeviceLock],
4548
+ ['deviceUnlock', DeviceUnlock],
4549
+ ['deviceCancel', DeviceCancel],
4550
+ ])('does not auto-retry the %s control method', (_name, MethodClass) => {
4551
+ const method = new MethodClass({
4552
+ id: 1,
4553
+ payload: { method: _name },
4554
+ } as any);
4555
+ method.init();
4556
+
4557
+ expect(method.unlockPolicy).toBe('none');
4558
+ });
4559
+
4027
4560
  test('unlocks and retries an opted-in Protocol V2 method once', async () => {
4028
4561
  const calls: string[] = [];
4029
4562
  const method = {
4563
+ name: 'testBusinessMethod',
4030
4564
  unlockPolicy: 'retry-on-locked',
4565
+ protocolV2UiInteraction: { reason: 'settings-page' },
4031
4566
  run: jest
4032
4567
  .fn()
4033
4568
  .mockImplementationOnce(() => {
@@ -4046,11 +4581,87 @@ describe('Protocol V2 protected method execution', () => {
4046
4581
  return Promise.resolve();
4047
4582
  }),
4048
4583
  };
4584
+ const uiCoordinator = {
4585
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
4586
+ enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
4587
+ resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
4588
+ };
4589
+
4590
+ await expect(
4591
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
4592
+ ).resolves.toEqual({ message: 'ok' });
4593
+ expect(calls).toEqual([
4594
+ 'method-prompt',
4595
+ 'run-1',
4596
+ 'unlock-prompt',
4597
+ 'unlock',
4598
+ 'method-prompt',
4599
+ 'run-2',
4600
+ ]);
4601
+ });
4602
+
4603
+ test('unlocks before showing the method interaction when cached status is locked', async () => {
4604
+ const calls: string[] = [];
4605
+ const method = {
4606
+ name: 'deviceSettingsPageShow',
4607
+ unlockPolicy: 'retry-on-locked',
4608
+ protocolV2UiInteraction: { reason: 'settings-page' },
4609
+ run: jest.fn(() => {
4610
+ calls.push('run');
4611
+ return Promise.resolve({ message: 'ok' });
4612
+ }),
4613
+ };
4614
+ const device = {
4615
+ features: { unlocked: false },
4616
+ isProtocolV2: () => true,
4617
+ unlockDevice: jest.fn(() => {
4618
+ calls.push('unlock');
4619
+ return Promise.resolve();
4620
+ }),
4621
+ };
4622
+ const uiCoordinator = {
4623
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
4624
+ enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
4625
+ resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
4626
+ };
4627
+
4628
+ await expect(
4629
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
4630
+ ).resolves.toEqual({ message: 'ok' });
4631
+ expect(calls).toEqual(['unlock-prompt', 'unlock', 'method-prompt', 'run']);
4632
+ expect(method.run).toHaveBeenCalledTimes(1);
4633
+ expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
4634
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
4635
+ });
4636
+
4637
+ test('infers a signing interaction before running a Protocol V2 business method', async () => {
4638
+ const method = {
4639
+ name: 'evmSignMessage',
4640
+ params: { message: 'not-exposed-in-event' },
4641
+ payload: {},
4642
+ unlockPolicy: 'retry-on-locked',
4643
+ run: jest.fn().mockResolvedValue({ signature: 'test' }),
4644
+ };
4645
+ const device = {
4646
+ isProtocolV2: () => true,
4647
+ unlockDevice: jest.fn(),
4648
+ };
4649
+ const uiCoordinator = {
4650
+ enterMethodInteraction: jest.fn(),
4651
+ enterUnlockInteraction: jest.fn(),
4652
+ resumeMethodInteraction: jest.fn(),
4653
+ };
4654
+
4655
+ await runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any);
4049
4656
 
4050
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
4051
- message: 'ok',
4657
+ expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledWith({
4658
+ request: 'button',
4659
+ source: 'method-lifecycle',
4660
+ reason: 'signing-confirmation',
4661
+ completion: 'operation-completed',
4662
+ deviceOnly: true,
4663
+ operation: 'evmSignMessage',
4052
4664
  });
4053
- expect(calls).toEqual(['run-1', 'unlock', 'run-2']);
4054
4665
  });
4055
4666
 
4056
4667
  test('does not unlock a Protocol V1 device or a method without the policy', async () => {
@@ -4074,6 +4685,35 @@ describe('Protocol V2 protected method execution', () => {
4074
4685
  }
4075
4686
  });
4076
4687
 
4688
+ test('keeps auto unlock but suppresses all synthesized UI for eventless methods', async () => {
4689
+ const method = {
4690
+ name: 'uploadPortfolio',
4691
+ unlockPolicy: 'retry-on-locked',
4692
+ protocolV2UiMode: 'none',
4693
+ run: jest
4694
+ .fn()
4695
+ .mockRejectedValueOnce(deviceLockedError())
4696
+ .mockResolvedValueOnce({ message: 'ok' }),
4697
+ };
4698
+ const device = {
4699
+ isProtocolV2: () => true,
4700
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
4701
+ };
4702
+ const uiCoordinator = {
4703
+ enterMethodInteraction: jest.fn(),
4704
+ enterUnlockInteraction: jest.fn(),
4705
+ resumeMethodInteraction: jest.fn(),
4706
+ };
4707
+
4708
+ await expect(
4709
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
4710
+ ).resolves.toEqual({ message: 'ok' });
4711
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
4712
+ expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
4713
+ expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
4714
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
4715
+ });
4716
+
4077
4717
  test('does not retry when unlock fails or when the retry is still locked', async () => {
4078
4718
  const initialError = deviceLockedError();
4079
4719
  const unlockError = new Error('PIN cancelled');
@@ -4085,11 +4725,22 @@ describe('Protocol V2 protected method execution', () => {
4085
4725
  isProtocolV2: () => true,
4086
4726
  unlockDevice: jest.fn().mockRejectedValue(unlockError),
4087
4727
  };
4728
+ const unlockFailCoordinator = {
4729
+ enterMethodInteraction: jest.fn(),
4730
+ enterUnlockInteraction: jest.fn(),
4731
+ resumeMethodInteraction: jest.fn(),
4732
+ };
4088
4733
 
4089
4734
  await expect(
4090
- runMethodWithUnlockRetry(unlockFailMethod as any, unlockFailDevice as any)
4735
+ runMethodWithUnlockRetry(
4736
+ unlockFailMethod as any,
4737
+ unlockFailDevice as any,
4738
+ unlockFailCoordinator as any
4739
+ )
4091
4740
  ).rejects.toBe(unlockError);
4092
4741
  expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
4742
+ expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
4743
+ expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
4093
4744
 
4094
4745
  const retryError = deviceLockedError();
4095
4746
  const retryFailMethod = {
@@ -4100,16 +4751,121 @@ describe('Protocol V2 protected method execution', () => {
4100
4751
  isProtocolV2: () => true,
4101
4752
  unlockDevice: jest.fn().mockResolvedValue(undefined),
4102
4753
  };
4754
+ const retryFailCoordinator = {
4755
+ enterMethodInteraction: jest.fn(),
4756
+ enterUnlockInteraction: jest.fn(),
4757
+ resumeMethodInteraction: jest.fn(),
4758
+ };
4103
4759
 
4104
4760
  await expect(
4105
- runMethodWithUnlockRetry(retryFailMethod as any, retryFailDevice as any)
4761
+ runMethodWithUnlockRetry(
4762
+ retryFailMethod as any,
4763
+ retryFailDevice as any,
4764
+ retryFailCoordinator as any
4765
+ )
4106
4766
  ).rejects.toBe(retryError);
4107
4767
  expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
4108
4768
  expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
4769
+ expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
4770
+ expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
4109
4771
  });
4110
4772
  });
4111
4773
 
4112
4774
  describe('Protocol V2 current low-level methods', () => {
4775
+ test('routes device wipe to the Pro2 reset page until the operation completes', async () => {
4776
+ const v1TypedCall = jest.fn().mockResolvedValue({ message: { message: 'wiped' } });
4777
+ const v1Method = new DeviceWipe({ id: 1, payload: { method: 'deviceWipe' } });
4778
+ v1Method.init();
4779
+ (v1Method as any).device = stubDevice({
4780
+ isProtocolV2: () => false,
4781
+ commands: { typedCall: v1TypedCall },
4782
+ });
4783
+
4784
+ await v1Method.run();
4785
+ expect(v1TypedCall).toHaveBeenCalledWith('WipeDevice', 'Success');
4786
+
4787
+ const v2TypedCall = jest.fn().mockResolvedValue({ message: { message: 'accepted' } });
4788
+ const v2Method = new DeviceWipe({ id: 2, payload: { method: 'deviceWipe' } });
4789
+ v2Method.init();
4790
+ (v2Method as any).device = stubDevice({
4791
+ isProtocolV2: () => true,
4792
+ commands: { typedCall: v2TypedCall },
4793
+ });
4794
+
4795
+ await expect(v2Method.run()).resolves.toEqual({ message: 'accepted' });
4796
+ expect(v2TypedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
4797
+ page: DeviceSettingsPage.DeviceReset,
4798
+ });
4799
+ expect(v2Method.protocolV2UiInteraction).toEqual({
4800
+ request: 'button',
4801
+ source: 'method-lifecycle',
4802
+ reason: 'device-management',
4803
+ completion: 'operation-completed',
4804
+ deviceOnly: true,
4805
+ page: DeviceSettingsPage.DeviceReset,
4806
+ operation: 'wipe-device',
4807
+ });
4808
+ });
4809
+
4810
+ test('routes Change PIN by protocol and waits for the Pro2 operation result', async () => {
4811
+ const v1TypedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
4812
+ const v1Method = new DeviceChangePin({
4813
+ id: 1,
4814
+ payload: { method: 'deviceChangePin', remove: false },
4815
+ });
4816
+ v1Method.init();
4817
+ (v1Method as any).device = stubDevice({
4818
+ isProtocolV2: () => false,
4819
+ commands: { typedCall: v1TypedCall },
4820
+ });
4821
+
4822
+ await v1Method.run();
4823
+
4824
+ expect(v1TypedCall).toHaveBeenCalledWith('ChangePin', 'Success', { remove: false });
4825
+
4826
+ const v2TypedCall = jest.fn().mockResolvedValue({ message: { message: 'accepted' } });
4827
+ const v2Method = new DeviceChangePin({
4828
+ id: 2,
4829
+ payload: { method: 'deviceChangePin', remove: false },
4830
+ });
4831
+ v2Method.init();
4832
+ (v2Method as any).device = stubDevice({
4833
+ isProtocolV2: () => true,
4834
+ commands: { typedCall: v2TypedCall },
4835
+ });
4836
+
4837
+ await expect(v2Method.run()).resolves.toEqual({ message: 'accepted' });
4838
+ expect(v2TypedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
4839
+ page: DeviceSettingsPage.DevicePinChange,
4840
+ });
4841
+ expect(v2Method.protocolV2UiInteraction).toEqual({
4842
+ request: 'button',
4843
+ source: 'method-lifecycle',
4844
+ reason: 'change-pin',
4845
+ completion: 'operation-completed',
4846
+ deviceOnly: true,
4847
+ page: DeviceSettingsPage.DevicePinChange,
4848
+ });
4849
+ });
4850
+
4851
+ test('rejects removing a PIN through the Pro2 page-only Change PIN flow', async () => {
4852
+ const typedCall = jest.fn();
4853
+ const method = new DeviceChangePin({
4854
+ id: 1,
4855
+ payload: { method: 'deviceChangePin', remove: true },
4856
+ });
4857
+ method.init();
4858
+ (method as any).device = stubDevice({
4859
+ isProtocolV2: () => true,
4860
+ commands: { typedCall },
4861
+ });
4862
+
4863
+ await expect(method.run()).rejects.toMatchObject({
4864
+ errorCode: HardwareErrorCode.CallMethodInvalidParameter,
4865
+ });
4866
+ expect(typedCall).not.toHaveBeenCalled();
4867
+ });
4868
+
4113
4869
  test('sends ProtocolInfoRequest from protocolInfoRequest', async () => {
4114
4870
  const typedCall = jest.fn().mockResolvedValue({ message: { version: 1 } });
4115
4871
  const method = new ProtocolInfoRequest({
@@ -4200,6 +4956,44 @@ describe('Protocol V2 current low-level methods', () => {
4200
4956
  brightness: 80,
4201
4957
  },
4202
4958
  });
4959
+ expect(method.protocolV2UiInteraction).toEqual({
4960
+ request: 'button',
4961
+ source: 'method-lifecycle',
4962
+ reason: 'device-management',
4963
+ completion: 'operation-completed',
4964
+ deviceOnly: true,
4965
+ operation: 'change-label',
4966
+ });
4967
+ });
4968
+
4969
+ test('keeps silent Protocol V2 device settings eventless', () => {
4970
+ const method = new DeviceSettingsSet({
4971
+ id: 1,
4972
+ payload: {
4973
+ method: 'deviceSettingsSet',
4974
+ settings: { brightness: 80 },
4975
+ },
4976
+ });
4977
+ method.init();
4978
+
4979
+ expect(method.protocolV2UiInteraction).toBeUndefined();
4980
+ });
4981
+
4982
+ test('marks explicit Protocol V2 unlock as an on-device PIN interaction', () => {
4983
+ const method = new DeviceUnlock({
4984
+ id: 1,
4985
+ payload: { method: 'deviceUnlock' },
4986
+ });
4987
+ method.init();
4988
+
4989
+ expect(method.protocolV2UiInteraction).toEqual({
4990
+ request: 'pin',
4991
+ source: 'method-lifecycle',
4992
+ reason: 'device-unlock',
4993
+ completion: 'operation-completed',
4994
+ deviceOnly: true,
4995
+ operation: 'unlock-device',
4996
+ });
4203
4997
  });
4204
4998
 
4205
4999
  test('opens non-passphrase Protocol V2 settings pages', async () => {
@@ -4221,6 +5015,14 @@ describe('Protocol V2 current low-level methods', () => {
4221
5015
  page: 3,
4222
5016
  field_name: 'airgap_mode',
4223
5017
  });
5018
+ expect(method.protocolV2UiInteraction).toEqual({
5019
+ request: 'button',
5020
+ source: 'method-lifecycle',
5021
+ reason: 'settings-page',
5022
+ completion: 'operation-completed',
5023
+ deviceOnly: true,
5024
+ page: DeviceSettingsPage.DeviceAirgap,
5025
+ });
4224
5026
  });
4225
5027
 
4226
5028
  test('opens the Protocol V2 passphrase settings page', async () => {
@@ -4364,7 +5166,7 @@ describe('Protocol V2 raw device info method', () => {
4364
5166
  'DeviceInfoGet',
4365
5167
  'DeviceInfo',
4366
5168
  {
4367
- targets: { hw: true, fw: true, coprocessor: true, status: true },
5169
+ targets: { hw: true, fw: true, coprocessor: true },
4368
5170
  types: { version: true, specific: true },
4369
5171
  },
4370
5172
  { timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS }