@onekeyfe/hd-core 1.2.0-alpha.33 → 1.2.0-alpha.35

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 (108) hide show
  1. package/__tests__/DeviceCommands.test.ts +84 -0
  2. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +31 -0
  3. package/__tests__/core-dispose.test.ts +3 -3
  4. package/__tests__/device-lifecycle-events.test.ts +52 -0
  5. package/__tests__/device-pool-state.test.ts +14 -2
  6. package/__tests__/device-settings.test.ts +25 -0
  7. package/__tests__/device-state-mapper.test.ts +13 -2
  8. package/__tests__/device-state-projector.test.ts +2 -2
  9. package/__tests__/device-utils.test.ts +1 -0
  10. package/__tests__/device-wallet-session-store.test.ts +137 -17
  11. package/__tests__/evmSignTransaction.test.ts +69 -0
  12. package/__tests__/get-device-state.test.ts +78 -30
  13. package/__tests__/kaspaSignTransaction.test.ts +9 -0
  14. package/__tests__/known-device-public-types.type-test.ts +3 -0
  15. package/__tests__/logBlockEvent.test.ts +89 -5
  16. package/__tests__/open-wallet-session.test.ts +499 -60
  17. package/__tests__/pro2Wallpaper.test.ts +1 -1
  18. package/__tests__/protocol-v2.test.ts +1180 -169
  19. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  20. package/dist/api/FirmwareUpdateV4.d.ts +2 -0
  21. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  22. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  23. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  24. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  25. package/dist/api/evm/EVMSignTypedData.d.ts.map +1 -1
  26. package/dist/api/evm/protocol.d.ts +3 -0
  27. package/dist/api/evm/protocol.d.ts.map +1 -0
  28. package/dist/api/kaspa/KaspaSignTransaction.d.ts.map +1 -1
  29. package/dist/api/protocol-v2/DeviceGetOnboardingStatus.d.ts +1 -1
  30. package/dist/core/index.d.ts.map +1 -1
  31. package/dist/device/Device.d.ts +10 -4
  32. package/dist/device/Device.d.ts.map +1 -1
  33. package/dist/device/DeviceCommands.d.ts.map +1 -1
  34. package/dist/device/DeviceConnector.d.ts +1 -1
  35. package/dist/device/DeviceConnector.d.ts.map +1 -1
  36. package/dist/device/DevicePool.d.ts +1 -0
  37. package/dist/device/DevicePool.d.ts.map +1 -1
  38. package/dist/device/DeviceStateMapper.d.ts.map +1 -1
  39. package/dist/device/DeviceStateProjector.d.ts.map +1 -1
  40. package/dist/device/DeviceWalletSessionStore.d.ts +7 -0
  41. package/dist/device/DeviceWalletSessionStore.d.ts.map +1 -1
  42. package/dist/deviceProfile/buildDeviceFeatures.d.ts.map +1 -1
  43. package/dist/deviceProfile/protocolV2DeviceIdentity.d.ts +7 -0
  44. package/dist/deviceProfile/protocolV2DeviceIdentity.d.ts.map +1 -0
  45. package/dist/events/logBlockEvent.d.ts +2 -0
  46. package/dist/events/logBlockEvent.d.ts.map +1 -1
  47. package/dist/index.d.ts +25 -11
  48. package/dist/index.js +1037 -838
  49. package/dist/protocols/protocol-v2/features.d.ts +17 -6
  50. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  51. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  52. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  53. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts +1 -0
  54. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts.map +1 -1
  55. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -1
  56. package/dist/protocols/protocol-v2/walletSession.d.ts +1 -10
  57. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  58. package/dist/types/api/protocolV2.d.ts +2 -2
  59. package/dist/types/api/protocolV2.d.ts.map +1 -1
  60. package/dist/types/device.d.ts +6 -3
  61. package/dist/types/device.d.ts.map +1 -1
  62. package/dist/types/params.d.ts.map +1 -1
  63. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  64. package/dist/utils/deviceFeaturesUtils.d.ts +0 -5
  65. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  66. package/dist/utils/patch.d.ts +1 -1
  67. package/dist/utils/patch.d.ts.map +1 -1
  68. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  69. package/package.json +4 -4
  70. package/src/api/CheckAllFirmwareRelease.ts +11 -81
  71. package/src/api/FirmwareUpdateV4.ts +59 -25
  72. package/src/api/GetPassphraseState.ts +11 -2
  73. package/src/api/OpenWalletSession.ts +16 -8
  74. package/src/api/device/DeviceSettings.ts +8 -1
  75. package/src/api/evm/EVMGetAddress.ts +2 -2
  76. package/src/api/evm/EVMGetPublicKey.ts +2 -2
  77. package/src/api/evm/EVMSignMessage.ts +2 -2
  78. package/src/api/evm/EVMSignTransaction.ts +2 -2
  79. package/src/api/evm/EVMSignTypedData.ts +35 -45
  80. package/src/api/evm/EVMVerifyMessage.ts +2 -2
  81. package/src/api/evm/protocol.ts +6 -0
  82. package/src/api/kaspa/KaspaSignTransaction.ts +7 -0
  83. package/src/api/protocol-v2/DeviceGetOnboardingStatus.ts +2 -2
  84. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +1 -1
  85. package/src/core/index.ts +7 -5
  86. package/src/data/messages/messages-protocol-v2.json +393 -485
  87. package/src/device/Device.ts +98 -44
  88. package/src/device/DeviceCommands.ts +12 -2
  89. package/src/device/DeviceConnector.ts +10 -7
  90. package/src/device/DevicePool.ts +5 -3
  91. package/src/device/DeviceStateMapper.ts +5 -3
  92. package/src/device/DeviceStateProjector.ts +0 -7
  93. package/src/device/DeviceWalletSessionStore.ts +43 -4
  94. package/src/deviceProfile/buildDeviceFeatures.ts +11 -4
  95. package/src/deviceProfile/protocolV2DeviceIdentity.ts +32 -0
  96. package/src/events/logBlockEvent.ts +84 -1
  97. package/src/protocols/protocol-v2/deviceSession.ts +1 -1
  98. package/src/protocols/protocol-v2/features.ts +58 -16
  99. package/src/protocols/protocol-v2/index.ts +5 -0
  100. package/src/protocols/protocol-v2/settingsUnlockPolicy.ts +8 -3
  101. package/src/protocols/protocol-v2/unlockRetry.ts +18 -7
  102. package/src/protocols/protocol-v2/walletSession.ts +144 -62
  103. package/src/types/api/protocolV2.ts +2 -2
  104. package/src/types/device.ts +8 -2
  105. package/src/types/params.ts +2 -1
  106. package/src/utils/deviceFeaturesCompat.ts +8 -1
  107. package/src/utils/pro2Wallpaper.ts +3 -9
  108. package/tsconfig.type-tests.json +5 -1
@@ -1,5 +1,10 @@
1
1
  import { EFirmwareType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
- import { DeviceRebootType, DeviceSessionPinType, DeviceSettingsPage } from '@onekeyfe/hd-transport';
2
+ import {
3
+ DeviceRebootType,
4
+ DeviceSessionPinType,
5
+ DeviceSettingsPage,
6
+ DeviceType,
7
+ } from '@onekeyfe/hd-transport';
3
8
 
4
9
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
5
10
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -54,15 +59,20 @@ import { getBitcoinForkSupportedProtocols } from '../src/api/btc/helpers/version
54
59
  import { DataManager } from '../src/data-manager';
55
60
  import { createCoreApi } from '../src/inject';
56
61
  import { Device, preloadSessionCache } from '../src/device/Device';
62
+ import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
57
63
  import { DevicePool } from '../src/device/DevicePool';
58
64
  import { UI_REQUEST } from '../src/events/ui-request';
59
65
  import {
60
66
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
67
+ PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE,
61
68
  type ProtocolV2DeviceInfo,
62
69
  type ProtocolV2RuntimeMode,
63
70
  getProtocolV2RuntimeMode,
71
+ parseProtocolV2BuildFingerprint,
64
72
  requestProtocolV2DeviceInfo,
73
+ requestProtocolV2ProtocolInfo,
65
74
  requestProtocolV2DeviceStatus,
75
+ supportsProtocolV2Message,
66
76
  } from '../src/protocols/protocol-v2/features';
67
77
  import {
68
78
  getProtocolV2WalletSession,
@@ -89,18 +99,26 @@ import {
89
99
 
90
100
  import type { DeviceCommands } from '../src/device/DeviceCommands';
91
101
  import type { Features } from '../src/types';
92
- import type { DeviceStatus } from '@onekeyfe/hd-transport';
102
+ import type { DeviceStatus, ProtocolInfo } from '@onekeyfe/hd-transport';
93
103
 
94
104
  jest.mock('../src/data/config', () => ({
95
105
  getSDKVersion: jest.fn(() => '1.0.0'),
96
106
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
97
107
  }));
98
108
 
99
- const createWalletSessionTypedCall = (implementation = jest.fn()) =>
109
+ const createWalletSessionTypedCall = (
110
+ implementation = jest.fn(),
111
+ status: Partial<DeviceStatus> = { passphrase_enabled: true }
112
+ ) =>
100
113
  jest.fn((request: string, ...args: unknown[]) => {
101
114
  if (request === 'ProtocolInfoRequest') {
102
115
  return Promise.resolve({ message: { version: 2 } });
103
116
  }
117
+ if (request === 'DeviceStatusGet') {
118
+ return Promise.resolve({
119
+ message: { unlocked: true, unlocked_attach_pin: false, ...status },
120
+ });
121
+ }
104
122
  return implementation(request, ...args);
105
123
  });
106
124
 
@@ -129,7 +147,7 @@ describe('DeviceUploadWallpaper', () => {
129
147
  const result = await method.run();
130
148
 
131
149
  expect(method.getSupportedProtocols()).toEqual(['V2']);
132
- expect(method.unlockPolicy).toBe('unlock-before-run');
150
+ expect(method.unlockPolicy).toBe('none');
133
151
  expect(typedCall).toHaveBeenNthCalledWith(1, 'FilesystemDirMake', 'Success', {
134
152
  path: 'vol1:/wallpapers',
135
153
  });
@@ -333,6 +351,24 @@ const descriptor = {
333
351
  path: 'usb-path',
334
352
  };
335
353
 
354
+ const protocolV2ApplicationInfo = {
355
+ version: 1,
356
+ build_fingerprint: 'application__1.2.3__abcdef0__PROD__RELEASE',
357
+ supported_messages: [PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE],
358
+ };
359
+
360
+ const protocolV2BootloaderInfo = {
361
+ version: 1,
362
+ build_fingerprint: 'bootloader__0.2.0__abcdef0__PROD__RELEASE',
363
+ supported_messages: [],
364
+ };
365
+
366
+ const protocolV2RomloaderInfo = {
367
+ version: 1,
368
+ build_fingerprint: 'romloader__1.0.0__abcdef0__PROD__RELEASE',
369
+ supported_messages: [],
370
+ };
371
+
336
372
  /**
337
373
  * Add Device protocol and getCurrent* accessors to plain-object stubs.
338
374
  * Defaults match Device.ts and never replace an existing member such as jest.fn().
@@ -342,6 +378,8 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
342
378
  d.updateState ??= jest.fn();
343
379
  d.markProtocolV2Reboot ??= jest.fn();
344
380
  d.isProtocolV2 ??= () => d.originalDescriptor?.protocolType === 'V2';
381
+ d.isBootloader ??= () => d.features?.mode === 'bootloader';
382
+ d.isRomloader ??= () => d.features?.mode === 'romloader';
345
383
  d.getProtocol ??= () => (d.isProtocolV2() ? 'V2' : 'V1');
346
384
  d.getCurrentDeviceType ??= () => getDeviceType(d.features);
347
385
  d.getCurrentFirmwareType ??= () => getFirmwareType(d.features);
@@ -361,7 +399,8 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
361
399
  d.updateProtocolV2Features ??= (
362
400
  deviceInfo?: ProtocolV2DeviceInfo,
363
401
  deviceStatus?: DeviceStatus,
364
- runtimeMode?: ProtocolV2RuntimeMode
402
+ runtimeMode?: ProtocolV2RuntimeMode,
403
+ protocolInfo?: ProtocolInfo
365
404
  ) => {
366
405
  d.features = buildProtocolV2FeaturesPayload({
367
406
  deviceInfo,
@@ -369,44 +408,35 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
369
408
  previous: d.features,
370
409
  runtimeMode: runtimeMode ?? (deviceStatus ? 'normal' : undefined),
371
410
  });
411
+ if (protocolInfo) {
412
+ d.features.raw = {
413
+ ...d.features.raw,
414
+ protocolV2ProtocolInfo: protocolInfo,
415
+ };
416
+ }
372
417
  return d.features;
373
418
  };
374
419
  d.probeProtocolV2RuntimeState ??= async (
375
420
  deviceInfo: ProtocolV2DeviceInfo,
376
421
  timeoutMs?: number
377
422
  ) => {
378
- try {
379
- const response = await d
380
- .getCommands()
381
- .typedCall(
382
- 'DeviceStatusGet',
383
- 'DeviceStatus',
384
- {},
385
- timeoutMs === undefined ? undefined : { timeoutMs }
386
- );
387
- const features = d.updateProtocolV2Features(deviceInfo, response.message, 'normal');
388
- return (
389
- features ??
390
- buildProtocolV2FeaturesPayload({
391
- deviceInfo,
392
- deviceStatus: response.message,
393
- previous: d.features,
394
- runtimeMode: 'normal',
395
- })
396
- );
397
- } catch {
398
- const runtimeMode = getProtocolV2RuntimeMode({
399
- deviceInfo,
400
- deviceStatusAvailable: false,
401
- });
402
- const features = d.updateProtocolV2Features(deviceInfo, null, runtimeMode);
403
- if (features?.mode === runtimeMode) return features;
404
- return buildProtocolV2FeaturesPayload({
405
- deviceInfo,
406
- previous: d.features,
407
- runtimeMode,
408
- });
423
+ const protocolInfo = await requestProtocolV2ProtocolInfo({
424
+ commands: d.getCommands(),
425
+ timeoutMs,
426
+ });
427
+ const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
428
+ if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
429
+ return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
430
+ }
431
+ if (!supportsProtocolV2Message(protocolInfo, PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE)) {
432
+ if (!runtimeMode) throw new Error('Unknown Protocol V2 runtime mode');
433
+ return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
409
434
  }
435
+ const deviceStatus = await requestProtocolV2DeviceStatus({
436
+ commands: d.getCommands(),
437
+ timeoutMs,
438
+ });
439
+ return d.updateProtocolV2Features(deviceInfo, deviceStatus, 'normal', protocolInfo);
410
440
  };
411
441
  d.updateProtocolV2Status ??= (deviceStatus: DeviceStatus) => {
412
442
  d.features = buildProtocolV2FeaturesPayload({
@@ -464,6 +494,10 @@ async function requestProtocolV2Features({
464
494
  }
465
495
 
466
496
  describe('Protocol V2 feature adapter', () => {
497
+ beforeEach(() => {
498
+ deviceWalletSessionStore.clear();
499
+ });
500
+
467
501
  test('treats passphrase status as unknown until the Protocol V2 device is unlocked', () => {
468
502
  const features = buildProtocolV2FeaturesPayload({
469
503
  deviceInfo: {
@@ -575,6 +609,7 @@ describe('Protocol V2 feature adapter', () => {
575
609
  const features = normalizeProtocolV2Features(descriptor as any, {
576
610
  protocol_version: 1,
577
611
  hw: {
612
+ Device_type: DeviceType.PRO2,
578
613
  serial_no: 'PR2SERIAL',
579
614
  },
580
615
  fw: {
@@ -661,7 +696,7 @@ describe('Protocol V2 feature adapter', () => {
661
696
  expect(features.bleEnabled).toBeNull();
662
697
  });
663
698
 
664
- test('marks Protocol V2 DeviceInfo as bootloader when DeviceStatusGet fails', () => {
699
+ test('parses bootloader runtime mode from ProtocolInfo build fingerprint', () => {
665
700
  const deviceInfo = {
666
701
  protocol_version: 1,
667
702
  hw: {
@@ -674,10 +709,7 @@ describe('Protocol V2 feature adapter', () => {
674
709
  },
675
710
  se1: {},
676
711
  };
677
- const runtimeMode = getProtocolV2RuntimeMode({
678
- deviceInfo: deviceInfo as ProtocolV2DeviceInfo,
679
- deviceStatusAvailable: false,
680
- });
712
+ const runtimeMode = getProtocolV2RuntimeMode(protocolV2BootloaderInfo);
681
713
  const features = normalizeProtocolV2Features(
682
714
  descriptor as any,
683
715
  deviceInfo as ProtocolV2DeviceInfo,
@@ -699,7 +731,7 @@ describe('Protocol V2 feature adapter', () => {
699
731
  expect(features.bootloaderMode).toBe(false);
700
732
  });
701
733
 
702
- test('temporarily treats romloader metadata as bootloader when DeviceStatusGet fails', () => {
734
+ test('keeps romloader distinct from bootloader using ProtocolInfo build fingerprint', () => {
703
735
  const deviceInfo = {
704
736
  protocol_version: 1,
705
737
  hw: {
@@ -714,31 +746,50 @@ describe('Protocol V2 feature adapter', () => {
714
746
  },
715
747
  },
716
748
  };
717
- const runtimeMode = getProtocolV2RuntimeMode({
718
- deviceInfo: deviceInfo as ProtocolV2DeviceInfo,
719
- deviceStatusAvailable: false,
720
- });
749
+ const runtimeMode = getProtocolV2RuntimeMode(protocolV2RomloaderInfo);
721
750
  const features = normalizeProtocolV2Features(
722
751
  descriptor as any,
723
752
  deviceInfo as ProtocolV2DeviceInfo,
724
753
  runtimeMode
725
754
  );
726
- expect(features.mode).toBe('bootloader');
755
+ expect(features.mode).toBe('romloader');
727
756
  expect(features.bootloaderMode).toBe(true);
728
757
  expect(features.boardVersion).toBe('1.0.0');
729
758
  });
730
759
 
760
+ test('rejects malformed Protocol V2 build fingerprints', () => {
761
+ expect(parseProtocolV2BuildFingerprint('application__1.2.3')).toBeNull();
762
+ expect(
763
+ getProtocolV2RuntimeMode({
764
+ version: 1,
765
+ build_fingerprint: 'unknown__1.2.3__abcdef0__PROD__RELEASE',
766
+ supported_messages: [],
767
+ })
768
+ ).toBeUndefined();
769
+ });
770
+
731
771
  test('keeps the Protocol V2 main wallet on the default empty-passphrase context', async () => {
732
772
  const features = normalizeProtocolV2Features(descriptor as any);
733
773
  features.firmwareVersion = '1.2.3';
734
774
  features.passphraseProtection = true;
735
775
  const typedCall = createWalletSessionTypedCall(
736
- jest.fn().mockResolvedValue({
737
- type: 'DeviceSession',
738
- message: {
739
- btc_test_address: 'state-1',
740
- session_id: 'session-1',
741
- },
776
+ jest.fn((request: string) => {
777
+ if (request === 'DeviceSessionAskPin') {
778
+ return { type: 'Success', message: { message: 'PIN verified' } };
779
+ }
780
+ if (request === 'DeviceStatusGet') {
781
+ return { type: 'DeviceStatus', message: { unlocked: true } };
782
+ }
783
+ if (request === 'DeviceSessionGet') {
784
+ return {
785
+ type: 'DeviceSession',
786
+ message: {
787
+ btc_test_address: 'state-1',
788
+ session_id: 'session-1',
789
+ },
790
+ };
791
+ }
792
+ throw new Error(`Unexpected request: ${request}`);
742
793
  })
743
794
  );
744
795
  const commands = { typedCall } as unknown as DeviceCommands;
@@ -751,18 +802,233 @@ describe('Protocol V2 feature adapter', () => {
751
802
  onlyMainPin: true,
752
803
  })
753
804
  ).resolves.toEqual({
754
- passphraseState: undefined,
755
- newSession: undefined,
756
- unlockedAttachPin: false,
805
+ passphraseState: 'state-1',
806
+ newSession: 'session-1',
807
+ unlockedAttachPin: undefined,
757
808
  resumed: false,
758
809
  });
759
810
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
760
811
  eventless_wallet_session: true,
761
812
  });
762
- expect(device.getInternalState()).toBeUndefined();
813
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
814
+ type: DeviceSessionPinType.Main,
815
+ });
816
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
817
+ device.passphraseState = 'state-1';
818
+ expect(device.getInternalState()).toBe('session-1');
819
+ });
820
+
821
+ test('reuses the cached Protocol V2 standard wallet instead of asking Main PIN again', async () => {
822
+ const deviceId = 'standard-reuse-device';
823
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
824
+ (device as any).features = normalizeProtocolV2Features(
825
+ { ...descriptor, protocolType: 'V2' } as any,
826
+ {
827
+ status: {
828
+ device_id: deviceId,
829
+ unlocked: true,
830
+ passphrase_enabled: true,
831
+ },
832
+ }
833
+ );
834
+
835
+ const typedCall = jest.fn((request: string) => {
836
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
837
+ if (request === 'DeviceSessionAskPin') {
838
+ return { type: 'Success', message: { message: 'PIN verified' } };
839
+ }
840
+ if (request === 'DeviceStatusGet') {
841
+ return {
842
+ message: {
843
+ device_id: deviceId,
844
+ unlocked: true,
845
+ passphrase_enabled: true,
846
+ },
847
+ };
848
+ }
849
+ if (request === 'DeviceSessionGet') {
850
+ return {
851
+ message: {
852
+ btc_test_address: 'standard-state',
853
+ session_id: 'standard-session',
854
+ },
855
+ };
856
+ }
857
+ throw new Error(`Unexpected request: ${request}`);
858
+ });
859
+ (device as any).commands = { typedCall };
860
+
861
+ await getProtocolV2WalletSession(device, { onlyMainPin: true });
862
+ await getProtocolV2WalletSession(device, { onlyMainPin: true });
863
+
864
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
865
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
866
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
867
+ session_id: 'standard-session',
868
+ });
869
+ expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
870
+ passphraseState: 'standard-state',
871
+ sessionId: 'standard-session',
872
+ });
873
+ });
874
+
875
+ test('reopens the Protocol V2 standard wallet when the cached session resolves to another wallet', async () => {
876
+ const deviceId = 'standard-invalid-device';
877
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
878
+ (device as any).features = normalizeProtocolV2Features(
879
+ { ...descriptor, protocolType: 'V2' } as any,
880
+ {
881
+ status: {
882
+ device_id: deviceId,
883
+ unlocked: true,
884
+ passphrase_enabled: true,
885
+ },
886
+ }
887
+ );
888
+ deviceWalletSessionStore.set(deviceId, 'hidden-state', 'hidden-session');
889
+ deviceWalletSessionStore.setStandard(deviceId, 'old-standard-state', 'old-standard-session');
890
+
891
+ const typedCall = jest.fn((request: string, _response: string, params: any) => {
892
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
893
+ if (request === 'DeviceSessionGet' && params.session_id) {
894
+ return {
895
+ message: {
896
+ btc_test_address: 'hidden-state',
897
+ session_id: 'hidden-session',
898
+ },
899
+ };
900
+ }
901
+ if (request === 'DeviceSessionGet') {
902
+ return {
903
+ message: {
904
+ btc_test_address: 'old-standard-state',
905
+ session_id: 'new-standard-session',
906
+ },
907
+ };
908
+ }
909
+ if (request === 'DeviceSessionAskPin') {
910
+ return { type: 'Success', message: { message: 'PIN verified' } };
911
+ }
912
+ if (request === 'DeviceStatusGet') {
913
+ return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
914
+ }
915
+ throw new Error(`Unexpected request: ${request}`);
916
+ });
917
+ (device as any).commands = { typedCall };
918
+
919
+ await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).resolves.toMatchObject({
920
+ passphraseState: 'old-standard-state',
921
+ newSession: 'new-standard-session',
922
+ resumed: false,
923
+ });
924
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
925
+ expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
926
+ passphraseState: 'old-standard-state',
927
+ sessionId: 'new-standard-session',
928
+ });
929
+ expect(deviceWalletSessionStore.get(deviceId, 'hidden-state')).toBe('hidden-session');
763
930
  });
764
931
 
765
- test('keeps Protocol V2 on the standard wallet when passphrase protection is disabled', async () => {
932
+ test('propagates DeviceLocked instead of retrying a cached standard session', async () => {
933
+ const deviceId = 'standard-locked-device';
934
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
935
+ (device as any).features = normalizeProtocolV2Features(
936
+ { ...descriptor, protocolType: 'V2' } as any,
937
+ {
938
+ status: {
939
+ device_id: deviceId,
940
+ unlocked: false,
941
+ passphrase_enabled: true,
942
+ },
943
+ }
944
+ );
945
+ deviceWalletSessionStore.setStandard(deviceId, 'standard-state', 'cached-standard-session');
946
+
947
+ let sessionGetCalls = 0;
948
+ const typedCall = jest.fn((request: string) => {
949
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
950
+ if (request === 'DeviceSessionAskPin') {
951
+ return { type: 'Success', message: { message: 'PIN verified' } };
952
+ }
953
+ if (request === 'DeviceStatusGet') {
954
+ return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
955
+ }
956
+ if (request === 'DeviceSessionGet') {
957
+ sessionGetCalls += 1;
958
+ if (sessionGetCalls === 1) {
959
+ throw Object.assign(new Error('Device is locked'), {
960
+ errorCode: HardwareErrorCode.DeviceLocked,
961
+ });
962
+ }
963
+ return {
964
+ message: {
965
+ btc_test_address: 'standard-state',
966
+ session_id: 'cached-standard-session',
967
+ },
968
+ };
969
+ }
970
+ throw new Error(`Unexpected request: ${request}`);
971
+ });
972
+ (device as any).commands = { typedCall };
973
+
974
+ await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).rejects.toMatchObject({
975
+ errorCode: HardwareErrorCode.DeviceLocked,
976
+ });
977
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
978
+ expect(deviceWalletSessionStore.getStandard(deviceId)).toBeUndefined();
979
+ });
980
+
981
+ test('recovers a mismatched cached standard wallet without deleting hidden sessions', async () => {
982
+ const deviceId = 'standard-mismatch-device';
983
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
984
+ (device as any).features = normalizeProtocolV2Features(
985
+ { ...descriptor, protocolType: 'V2' } as any,
986
+ {
987
+ status: {
988
+ device_id: deviceId,
989
+ unlocked: true,
990
+ passphrase_enabled: true,
991
+ },
992
+ }
993
+ );
994
+ deviceWalletSessionStore.set(deviceId, 'hidden-state', 'hidden-session');
995
+ deviceWalletSessionStore.setStandard(deviceId, 'standard-state', 'standard-session');
996
+ let sessionGetCalls = 0;
997
+ const typedCall = jest.fn((request: string) => {
998
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
999
+ if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1000
+ if (request === 'DeviceStatusGet') {
1001
+ return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
1002
+ }
1003
+ if (request === 'DeviceSessionGet') {
1004
+ sessionGetCalls += 1;
1005
+ return {
1006
+ message: {
1007
+ btc_test_address: sessionGetCalls === 1 ? 'unexpected-wallet-state' : 'standard-state',
1008
+ session_id: sessionGetCalls === 1 ? 'unexpected-session' : 'renewed-standard-session',
1009
+ },
1010
+ };
1011
+ }
1012
+ throw new Error(`Unexpected request: ${request}`);
1013
+ });
1014
+ (device as any).commands = { typedCall };
1015
+
1016
+ await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).resolves.toMatchObject({
1017
+ passphraseState: 'standard-state',
1018
+ newSession: 'renewed-standard-session',
1019
+ resumed: false,
1020
+ });
1021
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
1022
+ type: DeviceSessionPinType.Main,
1023
+ });
1024
+ expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
1025
+ passphraseState: 'standard-state',
1026
+ sessionId: 'renewed-standard-session',
1027
+ });
1028
+ expect(deviceWalletSessionStore.get(deviceId, 'hidden-state')).toBe('hidden-session');
1029
+ });
1030
+
1031
+ test('selects a Protocol V2 wallet explicitly even when passphrase protection was disabled', async () => {
766
1032
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
767
1033
  (device as any).features = normalizeProtocolV2Features(
768
1034
  { ...descriptor, protocolType: 'V2' } as any,
@@ -783,13 +1049,13 @@ describe('Protocol V2 feature adapter', () => {
783
1049
  (device as any).commands = { typedCall, promptPassphrase };
784
1050
 
785
1051
  await expect(getProtocolV2WalletSession(device)).resolves.toEqual({
786
- passphraseState: undefined,
787
- newSession: undefined,
788
- unlockedAttachPin: false,
1052
+ passphraseState: 'main-wallet-state',
1053
+ newSession: 'main-wallet-session',
1054
+ unlockedAttachPin: undefined,
789
1055
  resumed: false,
790
1056
  });
791
1057
 
792
- expect(promptPassphrase).not.toHaveBeenCalled();
1058
+ expect(promptPassphrase).toHaveBeenCalled();
793
1059
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
794
1060
  eventless_wallet_session: true,
795
1061
  });
@@ -835,7 +1101,7 @@ describe('Protocol V2 feature adapter', () => {
835
1101
  });
836
1102
  });
837
1103
 
838
- test('uses refreshed passphrase status after unlocking before selecting a Protocol V2 wallet', async () => {
1104
+ test('does not implicitly unlock inside the Protocol V2 wallet-session coordinator', async () => {
839
1105
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
840
1106
  (device as any).features = normalizeProtocolV2Features(
841
1107
  { ...descriptor, protocolType: 'V2' } as any,
@@ -847,14 +1113,15 @@ describe('Protocol V2 feature adapter', () => {
847
1113
  }
848
1114
  );
849
1115
  const typedCall = createWalletSessionTypedCall(
850
- jest.fn().mockImplementation((request: string) => {
851
- if (request === 'DeviceStatusGet') {
852
- return Promise.resolve({
853
- message: { passphrase_enabled: false, unlocked: true },
854
- });
855
- }
856
- throw new Error(`Unexpected request: ${request}`);
857
- })
1116
+ jest
1117
+ .fn()
1118
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1119
+ .mockResolvedValueOnce({
1120
+ message: {
1121
+ btc_test_address: 'hidden-after-unlock-state',
1122
+ session_id: 'hidden-after-unlock-session',
1123
+ },
1124
+ })
858
1125
  );
859
1126
  const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'hidden-after-unlock' });
860
1127
  const unlockDevice = jest.spyOn(device, 'unlockDevice').mockImplementation(() => {
@@ -868,14 +1135,14 @@ describe('Protocol V2 feature adapter', () => {
868
1135
  (device as any).commands = { typedCall, promptPassphrase };
869
1136
 
870
1137
  await expect(getProtocolV2WalletSession(device)).resolves.toEqual({
871
- passphraseState: undefined,
872
- newSession: undefined,
873
- unlockedAttachPin: false,
1138
+ passphraseState: 'hidden-after-unlock-state',
1139
+ newSession: 'hidden-after-unlock-session',
1140
+ unlockedAttachPin: undefined,
874
1141
  resumed: false,
875
1142
  });
876
1143
 
877
- expect(unlockDevice).toHaveBeenCalledTimes(1);
878
- expect(promptPassphrase).not.toHaveBeenCalled();
1144
+ expect(unlockDevice).not.toHaveBeenCalled();
1145
+ expect(promptPassphrase).toHaveBeenCalled();
879
1146
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
880
1147
  eventless_wallet_session: true,
881
1148
  });
@@ -905,8 +1172,8 @@ describe('Protocol V2 feature adapter', () => {
905
1172
  onlyMainPin: true,
906
1173
  })
907
1174
  ).resolves.toMatchObject({
908
- passphraseState: undefined,
909
- newSession: undefined,
1175
+ passphraseState: 'main-wallet-state',
1176
+ newSession: 'main-wallet-session',
910
1177
  });
911
1178
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
912
1179
  eventless_wallet_session: true,
@@ -933,12 +1200,20 @@ describe('Protocol V2 feature adapter', () => {
933
1200
  }
934
1201
  );
935
1202
  const typedCall = createWalletSessionTypedCall(
936
- jest.fn().mockResolvedValue({
937
- type: 'DeviceSession',
938
- message: { session_id: 'hidden-session', btc_test_address: 'hidden-state' },
1203
+ jest.fn((request: string) => {
1204
+ if (request === 'DeviceSessionAskPassphrase') {
1205
+ return { type: 'Success', message: { message: 'Passphrase prepared' } };
1206
+ }
1207
+ if (request === 'DeviceSessionGet') {
1208
+ return {
1209
+ type: 'DeviceSession',
1210
+ message: { session_id: 'hidden-session', btc_test_address: 'hidden-state' },
1211
+ };
1212
+ }
1213
+ throw new Error(`Unexpected request: ${request}`);
939
1214
  })
940
1215
  );
941
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1216
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
942
1217
  (device as any).commands = { typedCall, promptPassphrase };
943
1218
 
944
1219
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
@@ -948,7 +1223,8 @@ describe('Protocol V2 feature adapter', () => {
948
1223
 
949
1224
  expect(promptPassphrase).toHaveBeenCalled();
950
1225
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
951
- on_device: true,
1226
+ passphrase: 'host hidden wallet',
1227
+ on_device: false,
952
1228
  });
953
1229
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
954
1230
  });
@@ -979,7 +1255,7 @@ describe('Protocol V2 feature adapter', () => {
979
1255
 
980
1256
  test('deviceGetOnboardingStatus returns the current Protocol V2 onboarding status', async () => {
981
1257
  const typedCall = jest.fn().mockResolvedValue({
982
- type: 'DevOnboardingStatus',
1258
+ type: 'OnboardingStatus',
983
1259
  message: {
984
1260
  step: 4,
985
1261
  phase: 4,
@@ -1007,7 +1283,7 @@ describe('Protocol V2 feature adapter', () => {
1007
1283
  pin_set: true,
1008
1284
  wallet_initialized: false,
1009
1285
  });
1010
- expect(typedCall).toHaveBeenCalledWith('DevGetOnboardingStatus', 'DevOnboardingStatus', {});
1286
+ expect(typedCall).toHaveBeenCalledWith('OnboardingStatusGet', 'OnboardingStatus', {});
1011
1287
  expect(method.getSupportedProtocols()).toEqual(['V2']);
1012
1288
  });
1013
1289
 
@@ -1148,15 +1424,18 @@ describe('Protocol V2 feature adapter', () => {
1148
1424
  }
1149
1425
  );
1150
1426
  const typedCall = createWalletSessionTypedCall(
1151
- jest.fn().mockRejectedValue(
1152
- Object.assign(new Error('Invalid session'), {
1153
- errorCode: HardwareErrorCode.WalletSessionInvalid,
1154
- })
1155
- )
1427
+ jest
1428
+ .fn()
1429
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1430
+ .mockRejectedValueOnce(
1431
+ Object.assign(new Error('Invalid session'), {
1432
+ errorCode: HardwareErrorCode.WalletSessionInvalid,
1433
+ })
1434
+ )
1156
1435
  );
1157
1436
  (device as any).commands = {
1158
1437
  typedCall,
1159
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1438
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1160
1439
  };
1161
1440
 
1162
1441
  await expect(getProtocolV2WalletSession(device)).rejects.toMatchObject({
@@ -1164,11 +1443,17 @@ describe('Protocol V2 feature adapter', () => {
1164
1443
  });
1165
1444
  expect(typedCall.mock.calls).toEqual([
1166
1445
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1167
- ['DeviceSessionAskPassphrase', 'Success', { on_device: true }],
1446
+ [
1447
+ 'DeviceSessionAskPassphrase',
1448
+ 'Success',
1449
+ { passphrase: 'host hidden wallet', on_device: false },
1450
+ ],
1451
+ ['DeviceStatusGet', 'DeviceStatus', {}],
1452
+ ['DeviceSessionGet', 'DeviceSession', {}],
1168
1453
  ]);
1169
1454
  });
1170
1455
 
1171
- test('rejects a mismatched Pro2 wallet state and clears the selected cache entry', async () => {
1456
+ test('reselects a hidden wallet when the cached Pro2 session resolves to another wallet', async () => {
1172
1457
  const device = Device.fromDescriptor({
1173
1458
  id: 'cache-device-mismatch',
1174
1459
  path: 'cache-path-mismatch',
@@ -1186,26 +1471,148 @@ describe('Protocol V2 feature adapter', () => {
1186
1471
  );
1187
1472
  device.passphraseState = 'state-a';
1188
1473
  preloadSessionCache('stable-device-mismatch', 'state-a', 'session-a');
1189
- (device as any).commands = {
1190
- typedCall: createWalletSessionTypedCall(
1191
- jest.fn().mockResolvedValue({
1192
- type: 'DeviceSession',
1193
- message: { session_id: 'session-b', btc_test_address: 'state-b' },
1194
- })
1195
- ),
1196
- };
1474
+ let sessionGetCalls = 0;
1475
+ const typedCall = createWalletSessionTypedCall(
1476
+ jest.fn((request: string) => {
1477
+ if (request === 'DeviceSessionAskPassphrase') return { message: { message: 'prepared' } };
1478
+ if (request === 'DeviceSessionGet') {
1479
+ sessionGetCalls += 1;
1480
+ return {
1481
+ type: 'DeviceSession',
1482
+ message:
1483
+ sessionGetCalls === 1
1484
+ ? { session_id: 'session-b', btc_test_address: 'state-b' }
1485
+ : { session_id: 'session-a-new', btc_test_address: 'state-a' },
1486
+ };
1487
+ }
1488
+ throw new Error(`Unexpected request: ${request}`);
1489
+ })
1490
+ );
1491
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1492
+ (device as any).commands = { typedCall, promptPassphrase };
1197
1493
 
1198
1494
  await expect(
1199
1495
  getPassphraseStateWithRefreshDeviceInfo(device, { expectPassphraseState: 'state-a' })
1200
- ).rejects.toEqual(
1496
+ ).resolves.toMatchObject({
1497
+ passphraseState: 'state-a',
1498
+ newSession: 'session-a-new',
1499
+ resumed: false,
1500
+ });
1501
+ expect(promptPassphrase).toHaveBeenCalledWith(
1201
1502
  expect.objectContaining({
1202
- errorCode: HardwareErrorCode.DeviceCheckPassphraseStateError,
1503
+ source: 'wallet-session-coordinator',
1504
+ reason: 'session-recovery',
1505
+ expectedPassphraseState: 'state-a',
1506
+ }),
1507
+ { cancelDeviceOnReject: false }
1508
+ );
1509
+ expect(device.getInternalState()).toBe('session-a-new');
1510
+ });
1511
+
1512
+ test('rejects a hidden wallet when one recovery still returns the wrong state', async () => {
1513
+ const device = Device.fromDescriptor({
1514
+ id: 'cache-device-final-mismatch',
1515
+ path: 'cache-path-final-mismatch',
1516
+ protocolType: 'V2',
1517
+ } as any);
1518
+ (device as any).features = normalizeProtocolV2Features(
1519
+ { ...descriptor, protocolType: 'V2' } as any,
1520
+ {
1521
+ status: {
1522
+ device_id: 'stable-device-final-mismatch',
1523
+ unlocked: true,
1524
+ passphrase_enabled: true,
1525
+ },
1526
+ }
1527
+ );
1528
+ device.passphraseState = 'state-a';
1529
+ preloadSessionCache('stable-device-final-mismatch', 'state-a', 'session-a');
1530
+ const typedCall = createWalletSessionTypedCall(
1531
+ jest.fn((request: string) => {
1532
+ if (request === 'DeviceSessionAskPassphrase') return { message: { message: 'prepared' } };
1533
+ if (request === 'DeviceSessionGet') {
1534
+ return {
1535
+ type: 'DeviceSession',
1536
+ message: { session_id: 'wrong-session', btc_test_address: 'wrong-state' },
1537
+ };
1538
+ }
1539
+ throw new Error(`Unexpected request: ${request}`);
1203
1540
  })
1204
1541
  );
1542
+ (device as any).commands = {
1543
+ typedCall,
1544
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1545
+ };
1546
+
1547
+ await expect(
1548
+ getPassphraseStateWithRefreshDeviceInfo(device, { expectPassphraseState: 'state-a' })
1549
+ ).rejects.toMatchObject({
1550
+ errorCode: HardwareErrorCode.DeviceCheckPassphraseStateError,
1551
+ });
1205
1552
  expect(device.getInternalState()).toBeUndefined();
1206
1553
  });
1207
1554
 
1208
- test('unlocks and retries DeviceSessionAskPassphrase when wallet selection is locked', async () => {
1555
+ test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1556
+ const device = Device.fromDescriptor({
1557
+ id: 'cache-device-attach-recovery',
1558
+ path: 'cache-path-attach-recovery',
1559
+ protocolType: 'V2',
1560
+ } as any);
1561
+ (device as any).features = normalizeProtocolV2Features(
1562
+ { ...descriptor, protocolType: 'V2' } as any,
1563
+ {
1564
+ status: {
1565
+ device_id: 'stable-device-attach-recovery',
1566
+ unlocked: true,
1567
+ passphrase_enabled: true,
1568
+ attach_to_pin_enabled: true,
1569
+ },
1570
+ }
1571
+ );
1572
+ device.passphraseState = 'attach-state';
1573
+ preloadSessionCache('stable-device-attach-recovery', 'attach-state', 'expired-attach-session');
1574
+ let sessionGetCalls = 0;
1575
+ const typedCall = createWalletSessionTypedCall(
1576
+ jest.fn((request: string) => {
1577
+ if (request === 'DeviceSessionGet') {
1578
+ sessionGetCalls += 1;
1579
+ return {
1580
+ type: 'DeviceSession',
1581
+ message:
1582
+ sessionGetCalls === 1
1583
+ ? { session_id: 'current-standard-session', btc_test_address: 'standard-state' }
1584
+ : { session_id: 'new-attach-session', btc_test_address: 'attach-state' },
1585
+ };
1586
+ }
1587
+ throw new Error(`Unexpected request: ${request}`);
1588
+ })
1589
+ );
1590
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1591
+ (device as any).commands = { typedCall, promptPassphrase };
1592
+ const unlockDevice = jest
1593
+ .spyOn(device, 'unlockDevice')
1594
+ .mockResolvedValue(device.features as any);
1595
+
1596
+ await expect(
1597
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'attach-state' })
1598
+ ).resolves.toMatchObject({
1599
+ passphraseState: 'attach-state',
1600
+ newSession: 'new-attach-session',
1601
+ resumed: false,
1602
+ });
1603
+ expect(promptPassphrase).toHaveBeenCalledWith(
1604
+ expect.objectContaining({
1605
+ reason: 'session-recovery',
1606
+ expectedPassphraseState: 'attach-state',
1607
+ existsAttachPinUser: true,
1608
+ }),
1609
+ { cancelDeviceOnReject: false }
1610
+ );
1611
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1612
+ expect(device.getInternalState()).toBe('new-attach-session');
1613
+ });
1614
+
1615
+ test('propagates DeviceLocked without retrying DeviceSessionAskPassphrase', async () => {
1209
1616
  const device = Device.fromDescriptor({
1210
1617
  id: 'cache-device-4',
1211
1618
  path: 'cache-path-4',
@@ -1222,7 +1629,7 @@ describe('Protocol V2 feature adapter', () => {
1222
1629
  jest
1223
1630
  .fn()
1224
1631
  .mockRejectedValueOnce(lockedError)
1225
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
1632
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1226
1633
  .mockResolvedValueOnce({
1227
1634
  message: {
1228
1635
  session_id: 'session-after-unlock',
@@ -1232,16 +1639,61 @@ describe('Protocol V2 feature adapter', () => {
1232
1639
  );
1233
1640
  (device as any).commands = {
1234
1641
  typedCall,
1235
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1642
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1236
1643
  };
1237
1644
  const unlockDevice = jest.spyOn(device, 'unlockDevice').mockResolvedValue(undefined as any);
1238
1645
 
1646
+ await expect(getProtocolV2WalletSession(device)).rejects.toBe(lockedError);
1647
+ expect(unlockDevice).not.toHaveBeenCalled();
1648
+ expect(
1649
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
1650
+ ).toHaveLength(1);
1651
+ });
1652
+
1653
+ test('does not act on cached lock state inside the wallet-session coordinator', async () => {
1654
+ const calls: string[] = [];
1655
+ const device = Device.fromDescriptor({
1656
+ id: 'cache-device-pre-unlock',
1657
+ path: 'cache-path-pre-unlock',
1658
+ protocolType: 'V2',
1659
+ } as any);
1660
+ (device as any).features = normalizeProtocolV2Features(
1661
+ { ...descriptor, protocolType: 'V2' } as any,
1662
+ { status: { device_id: 'stable-device-pre-unlock', unlocked: false } }
1663
+ );
1664
+ const typedCall = createWalletSessionTypedCall(
1665
+ jest.fn((request: string) => {
1666
+ if (request === 'DeviceSessionAskPassphrase') {
1667
+ calls.push('ask-passphrase');
1668
+ return Promise.resolve({ message: { message: 'Passphrase prepared' } });
1669
+ }
1670
+ if (request === 'DeviceSessionGet') {
1671
+ calls.push('get-session');
1672
+ return Promise.resolve({
1673
+ message: {
1674
+ session_id: 'session-after-pre-unlock',
1675
+ btc_test_address: 'state-after-pre-unlock',
1676
+ },
1677
+ });
1678
+ }
1679
+ throw new Error(`Unexpected request: ${request}`);
1680
+ })
1681
+ );
1682
+ (device as any).commands = {
1683
+ typedCall,
1684
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1685
+ };
1686
+ const unlockDevice = jest.spyOn(device, 'unlockDevice');
1687
+
1239
1688
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
1240
- newSession: 'session-after-unlock',
1241
- passphraseState: 'state-after-unlock',
1689
+ newSession: 'session-after-pre-unlock',
1690
+ passphraseState: 'state-after-pre-unlock',
1242
1691
  });
1243
- expect(unlockDevice).toHaveBeenCalledTimes(1);
1244
- expect(typedCall).toHaveBeenCalledTimes(4);
1692
+ expect(unlockDevice).not.toHaveBeenCalled();
1693
+ expect(
1694
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
1695
+ ).toHaveLength(1);
1696
+ expect(calls).toEqual(['ask-passphrase', 'get-session']);
1245
1697
  });
1246
1698
 
1247
1699
  test('does not request a Pro2 wallet session before features are initialized', async () => {
@@ -1384,8 +1836,10 @@ describe('Protocol V2 feature adapter', () => {
1384
1836
  };
1385
1837
  const typedCall = jest
1386
1838
  .fn()
1839
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1387
1840
  .mockResolvedValueOnce({ message: { version: 2 } })
1388
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
1841
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1842
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1389
1843
  .mockResolvedValueOnce({
1390
1844
  type: 'DeviceSession',
1391
1845
  message: {
@@ -1395,7 +1849,7 @@ describe('Protocol V2 feature adapter', () => {
1395
1849
  });
1396
1850
  const updateInternalState = jest.fn();
1397
1851
  const getFeatures = jest.fn().mockResolvedValue(features);
1398
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1852
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1399
1853
  const method = new GetPassphraseState({
1400
1854
  payload: {
1401
1855
  method: 'getPassphraseState',
@@ -1438,6 +1892,51 @@ describe('Protocol V2 feature adapter', () => {
1438
1892
  expect(getFeatures).not.toHaveBeenCalled();
1439
1893
  });
1440
1894
 
1895
+ test('marks an interactive cache miss as a wallet-session recovery', async () => {
1896
+ const typedCall = jest
1897
+ .fn()
1898
+ .mockResolvedValueOnce({ message: { version: 2 } })
1899
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1900
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1901
+ .mockResolvedValueOnce({
1902
+ message: {
1903
+ btc_test_address: 'expected-state',
1904
+ session_id: 'recovered-session',
1905
+ },
1906
+ });
1907
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1908
+ const device = stubDevice({
1909
+ originalDescriptor: { ...descriptor, protocolType: 'V2' },
1910
+ passphraseState: 'expected-state',
1911
+ features: {
1912
+ unlocked: true,
1913
+ passphraseProtection: true,
1914
+ attachToPinEnabled: false,
1915
+ },
1916
+ commands: { typedCall, promptPassphrase },
1917
+ getInternalState: jest.fn().mockReturnValue(undefined),
1918
+ getCurrentDeviceId: () => 'pro2-device-id',
1919
+ getCurrentPassphraseProtection: () => true,
1920
+ updateInternalState: jest.fn(),
1921
+ clearInternalState: jest.fn(),
1922
+ emit: jest.fn(),
1923
+ }) as any;
1924
+
1925
+ await expect(
1926
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1927
+ ).resolves.toMatchObject({ passphraseState: 'expected-state' });
1928
+ expect(promptPassphrase).toHaveBeenCalledWith(
1929
+ {
1930
+ existsAttachPinUser: false,
1931
+ deviceOnly: false,
1932
+ source: 'wallet-session-coordinator',
1933
+ reason: 'session-recovery',
1934
+ expectedPassphraseState: 'expected-state',
1935
+ },
1936
+ { cancelDeviceOnReject: false }
1937
+ );
1938
+ });
1939
+
1441
1940
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
1442
1941
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1443
1942
  const typedCall = jest.fn().mockImplementation((request: string) => {
@@ -1445,7 +1944,16 @@ describe('Protocol V2 feature adapter', () => {
1445
1944
  return Promise.resolve({ message: { version: 2 } });
1446
1945
  }
1447
1946
  if (request === 'DeviceSessionAskPassphrase') {
1448
- return Promise.resolve({ message: { message: 'passphrase prepared' } });
1947
+ return Promise.resolve({
1948
+ type: 'Success',
1949
+ message: { message: 'Passphrase prepared' },
1950
+ });
1951
+ }
1952
+ if (request === 'DeviceStatusGet') {
1953
+ return Promise.resolve({
1954
+ type: 'DeviceStatus',
1955
+ message: { unlocked: true, unlocked_attach_pin: false, passphrase_enabled: true },
1956
+ });
1449
1957
  }
1450
1958
  if (request === 'DeviceSessionGet') {
1451
1959
  return Promise.resolve({
@@ -1471,7 +1979,7 @@ describe('Protocol V2 feature adapter', () => {
1471
1979
  );
1472
1980
  (device as any).commands = {
1473
1981
  typedCall,
1474
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1982
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1475
1983
  };
1476
1984
 
1477
1985
  const method = new GetPassphraseState({
@@ -1497,7 +2005,7 @@ describe('Protocol V2 feature adapter', () => {
1497
2005
  ]);
1498
2006
  });
1499
2007
 
1500
- test('uses the Pro2 standard wallet without passphraseState when passphrase is disabled', async () => {
2008
+ test('returns the Pro2 standard-wallet passphraseState when passphrase is disabled', async () => {
1501
2009
  const features = {
1502
2010
  deviceId: 'pro2-device-id',
1503
2011
  deviceType: 'pro2',
@@ -1505,7 +2013,15 @@ describe('Protocol V2 feature adapter', () => {
1505
2013
  passphraseProtection: false,
1506
2014
  unlocked: true,
1507
2015
  };
1508
- const typedCall = createWalletSessionTypedCall();
2016
+ const typedCall = createWalletSessionTypedCall(
2017
+ jest.fn().mockResolvedValue({
2018
+ type: 'DeviceSession',
2019
+ message: {
2020
+ btc_test_address: 'main-wallet-state',
2021
+ session_id: 'main-wallet-session',
2022
+ },
2023
+ })
2024
+ );
1509
2025
  const updateInternalState = jest.fn();
1510
2026
  const method = new GetPassphraseState({
1511
2027
  payload: {
@@ -1514,24 +2030,26 @@ describe('Protocol V2 feature adapter', () => {
1514
2030
  useEmptyPassphrase: true,
1515
2031
  },
1516
2032
  });
1517
- method.device = stubDevice({
2033
+ const device = stubDevice({
1518
2034
  originalDescriptor: { ...descriptor, protocolType: 'V2' },
1519
2035
  features,
1520
2036
  commands: {
1521
2037
  typedCall,
1522
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2038
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1523
2039
  },
1524
2040
  updateInternalState,
1525
2041
  getCurrentDeviceType: () => 'pro2',
1526
2042
  getCurrentDeviceId: () => 'pro2-device-id',
1527
2043
  getCurrentPassphraseProtection: () => false,
1528
2044
  }) as any;
2045
+ device.unlockDevice = jest.fn().mockResolvedValue(features);
2046
+ method.device = device;
1529
2047
 
1530
- await expect(method.run()).resolves.toBeUndefined();
2048
+ await expect(method.run()).resolves.toBe('main-wallet-state');
1531
2049
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1532
2050
  eventless_wallet_session: true,
1533
2051
  });
1534
- expect(updateInternalState).not.toHaveBeenCalled();
2052
+ expect(updateInternalState).toHaveBeenCalled();
1535
2053
  });
1536
2054
 
1537
2055
  test('honors initSession when getting Pro2 passphrase state', async () => {
@@ -1545,8 +2063,10 @@ describe('Protocol V2 feature adapter', () => {
1545
2063
  };
1546
2064
  const typedCall = jest
1547
2065
  .fn()
2066
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1548
2067
  .mockResolvedValueOnce({ message: { version: 2 } })
1549
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
2068
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
2069
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1550
2070
  .mockResolvedValueOnce({
1551
2071
  type: 'DeviceSession',
1552
2072
  message: {
@@ -1569,7 +2089,7 @@ describe('Protocol V2 feature adapter', () => {
1569
2089
  passphraseState: 'state-pro2-old',
1570
2090
  commands: {
1571
2091
  typedCall,
1572
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2092
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1573
2093
  },
1574
2094
  emit: jest.fn(),
1575
2095
  clearInternalState,
@@ -1612,7 +2132,7 @@ describe('Protocol V2 feature adapter', () => {
1612
2132
  };
1613
2133
  (device as any).commands = {
1614
2134
  typedCall,
1615
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2135
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1616
2136
  };
1617
2137
 
1618
2138
  await getPassphraseStateWithRefreshDeviceInfo(device);
@@ -1652,7 +2172,7 @@ describe('Protocol V2 feature adapter', () => {
1652
2172
  );
1653
2173
  (device as any).commands = {
1654
2174
  typedCall,
1655
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2175
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1656
2176
  };
1657
2177
 
1658
2178
  await expect(getPassphraseStateWithRefreshDeviceInfo(device)).resolves.toMatchObject({
@@ -1666,6 +2186,10 @@ describe('Protocol V2 feature adapter', () => {
1666
2186
  expect(device.getInternalState()).toBeUndefined();
1667
2187
  device.passphraseState = 'state-auto';
1668
2188
  expect(device.getInternalState()).toBe('session-auto');
2189
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2190
+ passphrase: 'host hidden wallet',
2191
+ on_device: false,
2192
+ });
1669
2193
  expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
1670
2194
  });
1671
2195
 
@@ -1678,7 +2202,8 @@ describe('Protocol V2 feature adapter', () => {
1678
2202
  session_id: 'main-pin-session',
1679
2203
  btc_test_address: 'main-wallet-state',
1680
2204
  },
1681
- })
2205
+ }),
2206
+ { passphrase_enabled: false }
1682
2207
  );
1683
2208
 
1684
2209
  (device as any).features = normalizeProtocolV2Features(
@@ -1700,8 +2225,8 @@ describe('Protocol V2 feature adapter', () => {
1700
2225
  await expect(
1701
2226
  getPassphraseStateWithRefreshDeviceInfo(device, { onlyMainPin: true })
1702
2227
  ).resolves.toMatchObject({
1703
- passphraseState: undefined,
1704
- newSession: undefined,
2228
+ passphraseState: 'main-wallet-state',
2229
+ newSession: 'main-pin-session',
1705
2230
  });
1706
2231
 
1707
2232
  expect(device.features?.passphraseProtection).toBe(false);
@@ -1733,7 +2258,7 @@ describe('Protocol V2 feature adapter', () => {
1733
2258
  (device as any).features.unlocked = true;
1734
2259
  (device as any).commands = {
1735
2260
  typedCall,
1736
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2261
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1737
2262
  };
1738
2263
 
1739
2264
  await expect(device.checkPassphraseStateSafety('expected-state', false, true)).rejects.toEqual(
@@ -1744,8 +2269,11 @@ describe('Protocol V2 feature adapter', () => {
1744
2269
 
1745
2270
  expect(device.getInternalState()).toBeUndefined();
1746
2271
  expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
1747
- on_device: true,
2272
+ passphrase: 'host hidden wallet',
2273
+ on_device: false,
1748
2274
  });
2275
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2276
+ expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
1749
2277
  });
1750
2278
 
1751
2279
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -1778,7 +2306,7 @@ describe('Protocol V2 feature adapter', () => {
1778
2306
  await expect(
1779
2307
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
1780
2308
  ).resolves.toBe(true);
1781
- expect(typedCall).toHaveBeenCalledTimes(1);
2309
+ expect(typedCall).toHaveBeenCalledTimes(4);
1782
2310
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1783
2311
  eventless_wallet_session: true,
1784
2312
  });
@@ -1836,7 +2364,9 @@ describe('Protocol V2 feature adapter', () => {
1836
2364
  test('uses Protocol V2 features directly when profile is absent', () => {
1837
2365
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1838
2366
  (device as any).features = {
1839
- ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
2367
+ ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2368
+ hw: { Device_type: DeviceType.PRO2 },
2369
+ }),
1840
2370
  serialNo: 'CACHED-SERIAL',
1841
2371
  label: 'Cached Label',
1842
2372
  bleName: 'Cached BLE',
@@ -1845,6 +2375,7 @@ describe('Protocol V2 feature adapter', () => {
1845
2375
 
1846
2376
  const message = device.toMessageObject();
1847
2377
  expect(message).toMatchObject({
2378
+ connectProtocol: 'V2',
1848
2379
  serialNo: 'CACHED-SERIAL',
1849
2380
  uuid: 'CACHED-SERIAL',
1850
2381
  deviceId: null,
@@ -1916,7 +2447,7 @@ describe('Protocol V2 feature adapter', () => {
1916
2447
  typedCall: jest.fn().mockResolvedValueOnce({
1917
2448
  type: 'DeviceInfo',
1918
2449
  message: {
1919
- hw: { serial_no: 'PR2SERIAL' },
2450
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
1920
2451
  fw: { application: { version: '1.2.3' } },
1921
2452
  },
1922
2453
  }),
@@ -2097,10 +2628,14 @@ describe('Protocol V2 feature adapter', () => {
2097
2628
  .mockResolvedValueOnce({
2098
2629
  type: 'DeviceInfo',
2099
2630
  message: {
2100
- hw: { serial_no: 'PR2SERIAL' },
2631
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2101
2632
  fw: { application: { version: '1.2.3' } },
2102
2633
  },
2103
2634
  })
2635
+ .mockResolvedValueOnce({
2636
+ type: 'ProtocolInfo',
2637
+ message: protocolV2ApplicationInfo,
2638
+ })
2104
2639
  .mockResolvedValueOnce({
2105
2640
  type: 'DeviceStatus',
2106
2641
  message: { init_states: true, unlocked: true },
@@ -2121,7 +2656,7 @@ describe('Protocol V2 feature adapter', () => {
2121
2656
  expect(device.features?.passphraseProtection).toBeNull();
2122
2657
  expect(device.features?.label).toBeNull();
2123
2658
  expect(device.features?.firmwareVersion).toBe('1.2.3');
2124
- expect(typedCall).toHaveBeenCalledTimes(2);
2659
+ expect(typedCall).toHaveBeenCalledTimes(3);
2125
2660
  expect(typedCall).toHaveBeenNthCalledWith(
2126
2661
  1,
2127
2662
  'DeviceInfoGet',
@@ -2145,10 +2680,11 @@ describe('Protocol V2 feature adapter', () => {
2145
2680
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2146
2681
  }
2147
2682
  );
2148
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
2683
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2684
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2149
2685
  });
2150
2686
 
2151
- test('recognizes bootloader when DeviceStatusGet fails', async () => {
2687
+ test('recognizes bootloader from ProtocolInfo without calling DeviceStatusGet', async () => {
2152
2688
  const device = Device.fromDescriptor({
2153
2689
  path: 'usb-path',
2154
2690
  protocolType: 'V2',
@@ -2163,7 +2699,10 @@ describe('Protocol V2 feature adapter', () => {
2163
2699
  se1: {},
2164
2700
  },
2165
2701
  })
2166
- .mockRejectedValueOnce(new Error('DeviceStatusGet unsupported in bootloader'));
2702
+ .mockResolvedValueOnce({
2703
+ type: 'ProtocolInfo',
2704
+ message: protocolV2BootloaderInfo,
2705
+ });
2167
2706
 
2168
2707
  (device as any).commands = { typedCall };
2169
2708
 
@@ -2179,23 +2718,166 @@ describe('Protocol V2 feature adapter', () => {
2179
2718
  }),
2180
2719
  expect.anything()
2181
2720
  );
2182
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
2721
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2722
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2183
2723
  expect(device.features).toMatchObject({
2184
2724
  deviceId: null,
2185
2725
  bootloaderMode: true,
2726
+ mode: 'bootloader',
2186
2727
  initialized: null,
2187
2728
  });
2729
+ expect(device.isBootloader()).toBe(true);
2730
+ expect(device.isRomloader()).toBe(false);
2188
2731
  });
2189
2732
 
2190
- test('does not reinterpret a state update error as DeviceStatusGet failure', async () => {
2733
+ test('recognizes romloader from ProtocolInfo without calling DeviceStatusGet', async () => {
2191
2734
  const device = Device.fromDescriptor({
2192
2735
  path: 'usb-path',
2193
2736
  protocolType: 'V2',
2194
2737
  } as any);
2195
2738
  const typedCall = jest.fn().mockResolvedValue({
2196
- type: 'DeviceStatus',
2197
- message: { init_states: true, unlocked: true },
2739
+ type: 'ProtocolInfo',
2740
+ message: protocolV2RomloaderInfo,
2741
+ });
2742
+ (device as any).commands = { typedCall };
2743
+
2744
+ await device.probeProtocolV2RuntimeState({
2745
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2746
+ fw: { romloader: { version: '1.0.0' } },
2747
+ });
2748
+
2749
+ expect(typedCall).toHaveBeenCalledTimes(1);
2750
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2751
+ expect(device.features).toMatchObject({
2752
+ mode: 'romloader',
2753
+ bootloaderMode: true,
2754
+ initialized: null,
2755
+ });
2756
+ expect(device.isBootloader()).toBe(false);
2757
+ expect(device.isRomloader()).toBe(true);
2758
+ });
2759
+
2760
+ test('rejects Protocol V2 romloader mode for non-Pro2 devices', async () => {
2761
+ const device = Device.fromDescriptor({
2762
+ path: 'pro-v2-path',
2763
+ protocolType: 'V2',
2764
+ } as any);
2765
+ const typedCall = jest.fn().mockResolvedValue({
2766
+ type: 'ProtocolInfo',
2767
+ message: protocolV2RomloaderInfo,
2198
2768
  });
2769
+ (device as any).commands = { typedCall };
2770
+
2771
+ await expect(
2772
+ device.probeProtocolV2RuntimeState({
2773
+ hw: { Device_type: DeviceType.PRO, serial_no: 'PRO-SERIAL' },
2774
+ fw: { romloader: { version: '1.0.0' } },
2775
+ })
2776
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
2777
+
2778
+ expect(device.isRomloader()).not.toBe(true);
2779
+ expect(typedCall).toHaveBeenCalledTimes(1);
2780
+ });
2781
+
2782
+ test('keeps application mode without calling an unadvertised DeviceStatusGet', async () => {
2783
+ const device = Device.fromDescriptor({
2784
+ path: 'usb-path',
2785
+ protocolType: 'V2',
2786
+ } as any);
2787
+ const protocolInfo = {
2788
+ ...protocolV2ApplicationInfo,
2789
+ supported_messages: [],
2790
+ };
2791
+ const typedCall = jest.fn().mockResolvedValue({
2792
+ type: 'ProtocolInfo',
2793
+ message: protocolInfo,
2794
+ });
2795
+ (device as any).commands = { typedCall };
2796
+
2797
+ await device.probeProtocolV2RuntimeState({
2798
+ hw: { serial_no: 'PR2SERIAL' },
2799
+ fw: { application: { version: '1.2.3' } },
2800
+ });
2801
+
2802
+ expect(typedCall).toHaveBeenCalledTimes(1);
2803
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2804
+ expect(device.features).toMatchObject({
2805
+ mode: 'normal',
2806
+ bootloaderMode: false,
2807
+ initialized: null,
2808
+ });
2809
+ });
2810
+
2811
+ test('uses advertised DeviceStatusGet as compatibility fallback for an unknown fingerprint', async () => {
2812
+ const device = Device.fromDescriptor({
2813
+ path: 'usb-path',
2814
+ protocolType: 'V2',
2815
+ } as any);
2816
+ const protocolInfo = {
2817
+ ...protocolV2ApplicationInfo,
2818
+ build_fingerprint: 'legacy-fingerprint',
2819
+ };
2820
+ const typedCall = jest
2821
+ .fn()
2822
+ .mockResolvedValueOnce({ type: 'ProtocolInfo', message: protocolInfo })
2823
+ .mockResolvedValueOnce({
2824
+ type: 'DeviceStatus',
2825
+ message: { init_states: true, unlocked: true },
2826
+ });
2827
+ (device as any).commands = { typedCall };
2828
+
2829
+ await device.probeProtocolV2RuntimeState({
2830
+ hw: { serial_no: 'PR2SERIAL' },
2831
+ fw: { application: { version: '1.2.3' } },
2832
+ });
2833
+
2834
+ expect(typedCall).toHaveBeenCalledTimes(2);
2835
+ expect(device.features).toMatchObject({
2836
+ mode: 'normal',
2837
+ initialized: true,
2838
+ unlocked: true,
2839
+ });
2840
+ });
2841
+
2842
+ test('fails safely when runtime fingerprint and capabilities are both unknown', async () => {
2843
+ const device = Device.fromDescriptor({
2844
+ path: 'usb-path',
2845
+ protocolType: 'V2',
2846
+ } as any);
2847
+ const typedCall = jest.fn().mockResolvedValue({
2848
+ type: 'ProtocolInfo',
2849
+ message: {
2850
+ version: 1,
2851
+ build_fingerprint: 'legacy-fingerprint',
2852
+ supported_messages: [],
2853
+ },
2854
+ });
2855
+ (device as any).commands = { typedCall };
2856
+
2857
+ await expect(
2858
+ device.probeProtocolV2RuntimeState({
2859
+ hw: { serial_no: 'PR2SERIAL' },
2860
+ fw: { application: { version: '1.2.3' } },
2861
+ })
2862
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
2863
+ expect(typedCall).toHaveBeenCalledTimes(1);
2864
+ });
2865
+
2866
+ test('does not reinterpret a state update error as runtime detection failure', async () => {
2867
+ const device = Device.fromDescriptor({
2868
+ path: 'usb-path',
2869
+ protocolType: 'V2',
2870
+ } as any);
2871
+ const typedCall = jest
2872
+ .fn()
2873
+ .mockResolvedValueOnce({
2874
+ type: 'ProtocolInfo',
2875
+ message: protocolV2ApplicationInfo,
2876
+ })
2877
+ .mockResolvedValueOnce({
2878
+ type: 'DeviceStatus',
2879
+ message: { init_states: true, unlocked: true },
2880
+ });
2199
2881
  const updateProtocolV2Features = jest
2200
2882
  .spyOn(device, 'updateProtocolV2Features')
2201
2883
  .mockImplementation(() => {
@@ -2210,12 +2892,13 @@ describe('Protocol V2 feature adapter', () => {
2210
2892
  })
2211
2893
  ).rejects.toThrow('state update failed');
2212
2894
 
2213
- expect(typedCall).toHaveBeenCalledTimes(1);
2895
+ expect(typedCall).toHaveBeenCalledTimes(2);
2214
2896
  expect(updateProtocolV2Features).toHaveBeenCalledTimes(1);
2215
2897
  expect(updateProtocolV2Features).toHaveBeenCalledWith(
2216
2898
  expect.anything(),
2217
2899
  expect.objectContaining({ init_states: true }),
2218
- 'normal'
2900
+ 'normal',
2901
+ protocolV2ApplicationInfo
2219
2902
  );
2220
2903
  });
2221
2904
 
@@ -2229,10 +2912,14 @@ describe('Protocol V2 feature adapter', () => {
2229
2912
  .mockResolvedValueOnce({
2230
2913
  type: 'DeviceInfo',
2231
2914
  message: {
2232
- hw: { serial_no: 'PR2SERIAL' },
2915
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2233
2916
  fw: { application: { version: '1.2.3' } },
2234
2917
  },
2235
2918
  })
2919
+ .mockResolvedValueOnce({
2920
+ type: 'ProtocolInfo',
2921
+ message: protocolV2ApplicationInfo,
2922
+ })
2236
2923
  .mockResolvedValueOnce({
2237
2924
  type: 'DeviceStatus',
2238
2925
  message: { init_states: true, unlocked: true },
@@ -2240,10 +2927,14 @@ describe('Protocol V2 feature adapter', () => {
2240
2927
  .mockResolvedValueOnce({
2241
2928
  type: 'DeviceInfo',
2242
2929
  message: {
2243
- hw: { serial_no: 'PR2SERIAL' },
2930
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2244
2931
  fw: { application: { version: '1.2.4' } },
2245
2932
  },
2246
2933
  })
2934
+ .mockResolvedValueOnce({
2935
+ type: 'ProtocolInfo',
2936
+ message: protocolV2ApplicationInfo,
2937
+ })
2247
2938
  .mockResolvedValueOnce({
2248
2939
  type: 'DeviceStatus',
2249
2940
  message: { init_states: true, unlocked: true },
@@ -2265,9 +2956,9 @@ describe('Protocol V2 feature adapter', () => {
2265
2956
  firmwareVersion: '1.2.4',
2266
2957
  passphraseProtection: null,
2267
2958
  });
2268
- expect(typedCall).toHaveBeenCalledTimes(4);
2959
+ expect(typedCall).toHaveBeenCalledTimes(6);
2269
2960
  expect(typedCall).toHaveBeenNthCalledWith(
2270
- 3,
2961
+ 4,
2271
2962
  'DeviceInfoGet',
2272
2963
  'DeviceInfo',
2273
2964
  {
@@ -2292,14 +2983,23 @@ describe('Protocol V2 feature adapter', () => {
2292
2983
 
2293
2984
  test('keeps Protocol V2 features available for method internals such as evmSignTypedData', async () => {
2294
2985
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2295
- const typedCall = jest.fn().mockResolvedValueOnce({
2296
- type: 'DeviceInfo',
2297
- message: {
2298
- hw: { serial_no: 'PR2SERIAL' },
2299
- fw: { application: { version: '1.2.4' } },
2300
- status: { init_states: true, passphrase_enabled: true },
2301
- },
2302
- });
2986
+ const typedCall = jest
2987
+ .fn()
2988
+ .mockResolvedValueOnce({
2989
+ type: 'DeviceInfo',
2990
+ message: {
2991
+ hw: { serial_no: 'PR2SERIAL' },
2992
+ fw: { application: { version: '1.2.4' } },
2993
+ },
2994
+ })
2995
+ .mockResolvedValueOnce({
2996
+ type: 'ProtocolInfo',
2997
+ message: protocolV2ApplicationInfo,
2998
+ })
2999
+ .mockResolvedValueOnce({
3000
+ type: 'DeviceStatus',
3001
+ message: { init_states: true, passphrase_enabled: true },
3002
+ });
2303
3003
  (device as any).commands = { typedCall };
2304
3004
 
2305
3005
  await device.initialize();
@@ -2343,7 +3043,7 @@ describe('Protocol V2 feature adapter', () => {
2343
3043
  if (requestType === 'DeviceSessionAskPin') {
2344
3044
  return {
2345
3045
  type: 'Success',
2346
- message: { message: 'ok' },
3046
+ message: { message: 'PIN verified' },
2347
3047
  };
2348
3048
  }
2349
3049
  if (requestType === 'DeviceStatusGet') {
@@ -2365,7 +3065,7 @@ describe('Protocol V2 feature adapter', () => {
2365
3065
  (device as any).features = normalizeProtocolV2Features(
2366
3066
  { ...descriptor, protocolType: 'V2' } as any,
2367
3067
  {
2368
- hw: { serial_no: 'PR2SERIAL' },
3068
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2369
3069
  fw: { application: { version: '1.2.3' } },
2370
3070
  status: { init_states: true },
2371
3071
  }
@@ -2400,7 +3100,7 @@ describe('Protocol V2 feature adapter', () => {
2400
3100
  if (requestType === 'DeviceSessionAskPin') {
2401
3101
  return {
2402
3102
  type: 'Success',
2403
- message: { message: 'ok' },
3103
+ message: { message: 'PIN verified' },
2404
3104
  };
2405
3105
  }
2406
3106
  if (requestType === 'DeviceStatusGet') {
@@ -2899,6 +3599,12 @@ describe('Protocol V2 firmware update targets', () => {
2899
3599
  message: { init_states: true, unlocked: true },
2900
3600
  });
2901
3601
  }
3602
+ if (name === 'ProtocolInfoRequest') {
3603
+ return Promise.resolve({
3604
+ type: 'ProtocolInfo',
3605
+ message: protocolV2ApplicationInfo,
3606
+ });
3607
+ }
2902
3608
  return Promise.reject(new Error(`unexpected call ${name}`));
2903
3609
  });
2904
3610
  const commands = { typedCall };
@@ -2943,7 +3649,12 @@ describe('Protocol V2 firmware update targets', () => {
2943
3649
  },
2944
3650
  { timeoutMs: 5000 }
2945
3651
  );
2946
- expect(typedCall).toHaveBeenCalledTimes(2);
3652
+ expect(typedCall).toHaveBeenCalledTimes(3);
3653
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3654
+ 'DeviceInfoGet',
3655
+ 'ProtocolInfoRequest',
3656
+ 'DeviceStatusGet',
3657
+ ]);
2947
3658
  expect(typedCall).not.toHaveBeenCalledWith('Initialize', 'Features', {});
2948
3659
  expect(versions).toEqual({
2949
3660
  bootloaderVersion: '0.0.0',
@@ -3023,7 +3734,13 @@ describe('Protocol V2 firmware update targets', () => {
3023
3734
  message: protocolV2BootloaderDeviceInfo,
3024
3735
  });
3025
3736
  }
3026
- return Promise.reject(new Error('DeviceStatusGet unavailable in bootloader'));
3737
+ if (requestType === 'ProtocolInfoRequest') {
3738
+ return Promise.resolve({
3739
+ type: 'ProtocolInfo',
3740
+ message: protocolV2BootloaderInfo,
3741
+ });
3742
+ }
3743
+ return Promise.reject(new Error(`unexpected call ${requestType}`));
3027
3744
  });
3028
3745
  const reconnectProtocolV2Device = jest.fn().mockImplementation(() => {
3029
3746
  (method as any).device.isBootloader = () => true;
@@ -3050,10 +3767,17 @@ describe('Protocol V2 firmware update targets', () => {
3050
3767
  method: 'firmwareUpdateV4',
3051
3768
  },
3052
3769
  });
3770
+ const isRomloader = jest.fn(() => true);
3053
3771
  (method as any).device = stubDevice({
3054
3772
  originalDescriptor: { id: 'usb-id', path: 'romloader-path', protocolType: 'V2' },
3055
- features: { mode: 'romloader', bootloaderMode: false, capabilities: [] },
3773
+ features: {
3774
+ deviceType: 'pro2',
3775
+ mode: 'romloader',
3776
+ bootloaderMode: false,
3777
+ capabilities: [],
3778
+ },
3056
3779
  isBootloader: () => false,
3780
+ isRomloader,
3057
3781
  });
3058
3782
  (method as any).protocolV2Reboot = jest
3059
3783
  .fn()
@@ -3062,7 +3786,49 @@ describe('Protocol V2 firmware update targets', () => {
3062
3786
 
3063
3787
  await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
3064
3788
 
3789
+ expect(isRomloader).toHaveBeenCalledTimes(1);
3790
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3791
+ expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
3792
+ });
3793
+
3794
+ test('starts Protocol V2 firmware transfer directly from romloader mode', async () => {
3795
+ const method = new FirmwareUpdateV4({
3796
+ id: 1,
3797
+ payload: {
3798
+ method: 'firmwareUpdateV4',
3799
+ platform: 'web',
3800
+ coprocessorBinary: new Uint8Array([1]).buffer,
3801
+ },
3802
+ });
3803
+ method.init();
3804
+ (method as any).device = stubDevice({
3805
+ originalDescriptor: { id: 'usb-id', path: 'romloader-path', protocolType: 'V2' },
3806
+ features: {
3807
+ deviceType: 'pro2',
3808
+ firmwareVersion: '0.0.0',
3809
+ mode: 'romloader',
3810
+ bootloaderMode: true,
3811
+ capabilities: [],
3812
+ },
3813
+ });
3814
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3815
+ (method as any).protocolV2Reboot = jest.fn();
3816
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3817
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3818
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
3819
+ bootloaderVersion: '1.0.0',
3820
+ bleVersion: '0.0.0',
3821
+ firmwareVersion: '1.0.0',
3822
+ });
3823
+ method.postTipMessage = jest.fn();
3824
+
3825
+ await method.run();
3826
+
3065
3827
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3828
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3829
+ bootloaderBinary: null,
3830
+ fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
3831
+ });
3066
3832
  expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
3067
3833
  });
3068
3834
 
@@ -3085,7 +3851,13 @@ describe('Protocol V2 firmware update targets', () => {
3085
3851
  message: protocolV2BootloaderDeviceInfo,
3086
3852
  });
3087
3853
  }
3088
- return Promise.reject(new Error('DeviceStatusGet unavailable in bootloader'));
3854
+ if (requestType === 'ProtocolInfoRequest') {
3855
+ return Promise.resolve({
3856
+ type: 'ProtocolInfo',
3857
+ message: protocolV2BootloaderInfo,
3858
+ });
3859
+ }
3860
+ return Promise.reject(new Error(`unexpected call ${requestType}`));
3089
3861
  });
3090
3862
  const reconnectProtocolV2Device = jest
3091
3863
  .fn()
@@ -3102,8 +3874,11 @@ describe('Protocol V2 firmware update targets', () => {
3102
3874
  await (method as any).waitForProtocolV2BootloaderMode(60 * 1000, 0);
3103
3875
 
3104
3876
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
3105
- expect(typedCall).toHaveBeenCalledTimes(1);
3106
- expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
3877
+ expect(typedCall).toHaveBeenCalledTimes(2);
3878
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3879
+ 'DeviceInfoGet',
3880
+ 'ProtocolInfoRequest',
3881
+ ]);
3107
3882
  });
3108
3883
 
3109
3884
  test('does not run generic initialize during Protocol V2 USB firmware reconnect', async () => {
@@ -3396,7 +4171,7 @@ describe('Protocol V2 firmware update targets', () => {
3396
4171
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
3397
4172
  });
3398
4173
 
3399
- test('accepts a missing firmware status handler as an app reboot signal', async () => {
4174
+ test('accepts a missing firmware status handler only after confirming normal mode', async () => {
3400
4175
  const method = new FirmwareUpdateV4({
3401
4176
  id: 1,
3402
4177
  payload: {
@@ -3412,7 +4187,9 @@ describe('Protocol V2 firmware update targets', () => {
3412
4187
  getCommands: () => ({ typedCall }),
3413
4188
  });
3414
4189
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3415
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4190
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4191
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4192
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
3416
4193
  method.postProgressMessage = jest.fn();
3417
4194
 
3418
4195
  await (method as any).waitForProtocolV2FirmwareUpdateComplete([
@@ -3421,6 +4198,70 @@ describe('Protocol V2 firmware update targets', () => {
3421
4198
 
3422
4199
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3423
4200
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4201
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4202
+ });
4203
+
4204
+ test.each([
4205
+ [{ mode: 'normal', bootloaderMode: false }, true],
4206
+ [{ mode: 'bootloader', bootloaderMode: true }, false],
4207
+ ])('derives firmware completion from the runtime-state probe: %o', async (features, expected) => {
4208
+ const method = new FirmwareUpdateV4({
4209
+ id: 1,
4210
+ payload: {
4211
+ method: 'firmwareUpdateV4',
4212
+ },
4213
+ });
4214
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4215
+ const probeProtocolV2RuntimeState = jest.fn().mockResolvedValue(features);
4216
+ (method as any).device = stubDevice({ probeProtocolV2RuntimeState });
4217
+
4218
+ await expect((method as any).probeProtocolV2NormalMode(deviceInfo)).resolves.toBe(expected);
4219
+ expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000);
4220
+ });
4221
+
4222
+ test('keeps polling when the firmware status handler is missing in loader mode', async () => {
4223
+ const method = new FirmwareUpdateV4({
4224
+ id: 1,
4225
+ payload: {
4226
+ method: 'firmwareUpdateV4',
4227
+ },
4228
+ });
4229
+ const typedCall = jest
4230
+ .fn()
4231
+ .mockRejectedValueOnce(new Error('Failure: Handler not registered for this message'))
4232
+ .mockResolvedValueOnce({
4233
+ type: 'DeviceFirmwareUpdateStatus',
4234
+ message: { records: [{ target_id: 4, status: 2 }] },
4235
+ });
4236
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4237
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
4238
+ callback: () => void
4239
+ ) => {
4240
+ callback();
4241
+ return 0 as any;
4242
+ }) as typeof setTimeout);
4243
+
4244
+ (method as any).device = stubDevice({
4245
+ getCommands: () => ({ typedCall }),
4246
+ });
4247
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4248
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4249
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(false);
4250
+ method.postProgressMessage = jest.fn();
4251
+
4252
+ try {
4253
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete([
4254
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
4255
+ ]);
4256
+ } finally {
4257
+ setTimeoutSpy.mockRestore();
4258
+ }
4259
+
4260
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4261
+ 'DeviceFirmwareUpdateStatusGet',
4262
+ 'DeviceFirmwareUpdateStatusGet',
4263
+ ]);
4264
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
3424
4265
  });
3425
4266
 
3426
4267
  test('polls target status after update ACK and finishes when all targets complete', async () => {
@@ -3926,6 +4767,62 @@ describe('Protocol V2 firmware update targets', () => {
3926
4767
  getFirmwareLatestReleaseSpy.mockRestore();
3927
4768
  });
3928
4769
 
4770
+ test('ignores an unsupported remote ROMLOADER component when installing selected targets', async () => {
4771
+ const method = new FirmwareUpdateV4({
4772
+ id: 1,
4773
+ payload: {
4774
+ method: 'firmwareUpdateV4',
4775
+ platform: 'web',
4776
+ targetsToUpdate: ['app_v1'],
4777
+ },
4778
+ });
4779
+ method.init();
4780
+ const applicationBinary = new Uint8Array([1]).buffer;
4781
+ const downloadRemoteComponent = jest.fn().mockResolvedValue({
4782
+ fileName: 'application_p1.bin',
4783
+ binary: applicationBinary,
4784
+ targetId: 4,
4785
+ kind: 'firmware',
4786
+ });
4787
+ (method as any).downloadRemoteProtocolV2Component = downloadRemoteComponent;
4788
+ const getFirmwareLatestReleaseSpy = jest
4789
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
4790
+ .mockReturnValue({
4791
+ required: false,
4792
+ version: [1, 0, 0],
4793
+ url: '',
4794
+ installOrder: ['romloader', 'applicationP1'],
4795
+ components: {
4796
+ romloader: {
4797
+ target: 'ROMLOADER',
4798
+ url: 'https://example.com/romloader.bin',
4799
+ },
4800
+ applicationP1: {
4801
+ target: 'APPLICATION_P1',
4802
+ url: 'https://example.com/application-p1.bin',
4803
+ },
4804
+ },
4805
+ fingerprint: '',
4806
+ changelog: { 'zh-CN': '', 'en-US': '' },
4807
+ });
4808
+
4809
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
4810
+ deviceType: 'pro2',
4811
+ firmwareVersion: '0.0.0',
4812
+ });
4813
+
4814
+ expect(downloadRemoteComponent).toHaveBeenCalledTimes(1);
4815
+ expect(downloadRemoteComponent).toHaveBeenCalledWith(
4816
+ 'applicationP1',
4817
+ expect.objectContaining({ target: 'APPLICATION_P1' })
4818
+ );
4819
+ expect(remoteBinaries.fwBinaryMap).toEqual([
4820
+ { fileName: 'application_p1.bin', binary: applicationBinary, targetId: 4 },
4821
+ ]);
4822
+
4823
+ getFirmwareLatestReleaseSpy.mockRestore();
4824
+ });
4825
+
3929
4826
  test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
3930
4827
  const method = new FirmwareUpdateV4({
3931
4828
  id: 1,
@@ -4496,8 +5393,14 @@ describe('Protocol V2 firmware update targets', () => {
4496
5393
  return 0 as any;
4497
5394
  }) as typeof setTimeout);
4498
5395
 
5396
+ const initialize = jest.fn().mockResolvedValue(undefined);
4499
5397
  (method as any).device = stubDevice({
4500
5398
  getCommands: () => ({ typedCall }),
5399
+ initialize,
5400
+ });
5401
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5402
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue({
5403
+ hw: { serial_no: 'PRO2-PHYSICAL-1' },
4501
5404
  });
4502
5405
  method.postProgressMessage = jest.fn();
4503
5406
 
@@ -4513,6 +5416,9 @@ describe('Protocol V2 firmware update targets', () => {
4513
5416
  }
4514
5417
 
4515
5418
  expect(writeOffsets).toEqual([0, 4000, 0, 4000, 8000]);
5419
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
5420
+ expect((method as any).verifyProtocolV2ReconnectIdentity).toHaveBeenCalledTimes(1);
5421
+ expect(initialize).toHaveBeenCalledTimes(1);
4516
5422
  });
4517
5423
 
4518
5424
  test('rejects a chunk-relative processed_byte during firmware staging', async () => {
@@ -5213,6 +6119,45 @@ describe('Protocol V2 protected method execution', () => {
5213
6119
  ]);
5214
6120
  });
5215
6121
 
6122
+ test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
6123
+ const initialError = deviceLockedError();
6124
+ const restoreError = new Error('Invalid wallet session');
6125
+ const method = {
6126
+ name: 'evmSignMessage',
6127
+ payload: { passphraseState: 'hidden-state' },
6128
+ useDevicePassphraseState: true,
6129
+ unlockPolicy: 'retry-on-locked',
6130
+ run: jest.fn().mockRejectedValueOnce(initialError),
6131
+ };
6132
+ const typedCall = jest.fn((requestType: string) => {
6133
+ if (requestType === 'ProtocolInfoRequest') {
6134
+ return Promise.resolve({ message: { version: 2 } });
6135
+ }
6136
+ if (requestType === 'DeviceSessionGet') {
6137
+ return Promise.reject(restoreError);
6138
+ }
6139
+ throw new Error(`Unexpected request: ${requestType}`);
6140
+ });
6141
+ const device = {
6142
+ features: { unlocked: true, passphraseProtection: true },
6143
+ passphraseState: 'hidden-state',
6144
+ commands: { typedCall },
6145
+ isProtocolV2: () => true,
6146
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6147
+ getCurrentPassphraseProtection: () => true,
6148
+ getInternalState: () => 'hidden-session',
6149
+ clearInternalState: jest.fn(),
6150
+ getCurrentDeviceId: () => 'wallet-device-id',
6151
+ updateInternalState: jest.fn(),
6152
+ };
6153
+
6154
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
6155
+ expect(method.run).toHaveBeenCalledTimes(1);
6156
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6157
+ expect(device.clearInternalState).toHaveBeenCalledTimes(1);
6158
+ expect(device.updateInternalState).not.toHaveBeenCalled();
6159
+ });
6160
+
5216
6161
  test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
5217
6162
  const calls: string[] = [];
5218
6163
  const method = {
@@ -5300,7 +6245,7 @@ describe('Protocol V2 protected method execution', () => {
5300
6245
  expect(method.run).toHaveBeenCalledTimes(1);
5301
6246
  });
5302
6247
 
5303
- test('unlocks before showing the method interaction when cached status is locked', async () => {
6248
+ test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
5304
6249
  const calls: string[] = [];
5305
6250
  const method = {
5306
6251
  name: 'deviceSettingsPageShow',
@@ -5311,11 +6256,23 @@ describe('Protocol V2 protected method execution', () => {
5311
6256
  return Promise.resolve({ message: 'ok' });
5312
6257
  }),
5313
6258
  };
6259
+ const features = { unlocked: true };
5314
6260
  const device = {
5315
- features: { unlocked: false },
6261
+ features,
6262
+ commands: {
6263
+ typedCall: jest.fn(() => {
6264
+ calls.push('refresh-status');
6265
+ return Promise.resolve({ message: { unlocked: false } });
6266
+ }),
6267
+ },
5316
6268
  isProtocolV2: () => true,
6269
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6270
+ features.unlocked = status.unlocked ?? features.unlocked;
6271
+ return features;
6272
+ }),
5317
6273
  unlockDevice: jest.fn(() => {
5318
6274
  calls.push('unlock');
6275
+ features.unlocked = true;
5319
6276
  return Promise.resolve();
5320
6277
  }),
5321
6278
  };
@@ -5328,12 +6285,66 @@ describe('Protocol V2 protected method execution', () => {
5328
6285
  await expect(
5329
6286
  runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
5330
6287
  ).resolves.toEqual({ message: 'ok' });
5331
- expect(calls).toEqual(['unlock-prompt', 'unlock', 'method-prompt', 'run']);
6288
+ expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6289
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
5332
6290
  expect(method.run).toHaveBeenCalledTimes(1);
5333
6291
  expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
5334
6292
  expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
5335
6293
  });
5336
6294
 
6295
+ test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6296
+ const features = { unlocked: false };
6297
+ const method = {
6298
+ name: 'deviceSettings',
6299
+ unlockPolicy: 'unlock-before-run',
6300
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6301
+ };
6302
+ const device = {
6303
+ features,
6304
+ commands: {
6305
+ typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6306
+ },
6307
+ isProtocolV2: () => true,
6308
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6309
+ features.unlocked = status.unlocked ?? features.unlocked;
6310
+ return features;
6311
+ }),
6312
+ unlockDevice: jest.fn(),
6313
+ };
6314
+
6315
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6316
+ message: 'ok',
6317
+ });
6318
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6319
+ expect(method.run).toHaveBeenCalledTimes(1);
6320
+ });
6321
+
6322
+ test.each(['bootloader', 'romloader'] as const)(
6323
+ 'skips DeviceStatus and unlock in Protocol V2 %s mode',
6324
+ async mode => {
6325
+ const method = {
6326
+ name: 'firmwareUpdateV4',
6327
+ unlockPolicy: 'unlock-before-run',
6328
+ run: jest.fn().mockResolvedValue({ message: 'updating' }),
6329
+ };
6330
+ const device = {
6331
+ features: { unlocked: false },
6332
+ commands: { typedCall: jest.fn() },
6333
+ isProtocolV2: () => true,
6334
+ isBootloader: () => mode === 'bootloader',
6335
+ isRomloader: () => mode === 'romloader',
6336
+ unlockDevice: jest.fn(),
6337
+ };
6338
+
6339
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6340
+ message: 'updating',
6341
+ });
6342
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6343
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6344
+ expect(method.run).toHaveBeenCalledTimes(1);
6345
+ }
6346
+ );
6347
+
5337
6348
  test('infers a signing interaction before running a Protocol V2 business method', async () => {
5338
6349
  const method = {
5339
6350
  name: 'evmSignMessage',