@onekeyfe/hd-core 1.2.0-alpha.22 → 1.2.0-alpha.24

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 (151) hide show
  1. package/__tests__/DeviceCommands.test.ts +43 -8
  2. package/__tests__/connectSettings.test.ts +5 -47
  3. package/__tests__/device-lifecycle-events.test.ts +59 -0
  4. package/__tests__/device-settings.test.ts +1 -1
  5. package/__tests__/device-wallet-session-store.test.ts +41 -0
  6. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +30 -39
  7. package/__tests__/logBlockEvent.test.ts +17 -0
  8. package/__tests__/open-wallet-session.test.ts +233 -111
  9. package/__tests__/protocol-v2-ping.test.ts +28 -0
  10. package/__tests__/protocol-v2.test.ts +513 -313
  11. package/__tests__/public-pro2-api-boundary.test.ts +22 -0
  12. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  13. package/dist/api/FirmwareUpdateV2.d.ts +0 -11
  14. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  15. package/dist/api/FirmwareUpdateV3.d.ts +3 -7
  16. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  17. package/dist/api/FirmwareUpdateV4.d.ts +5 -21
  18. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  19. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  20. package/dist/api/OpenWalletSession.d.ts +1 -8
  21. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  22. package/dist/api/device/DeviceUpdateBootloader.d.ts +1 -5
  23. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  24. package/dist/api/device/DeviceWipe.d.ts.map +1 -1
  25. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +0 -7
  26. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  27. package/dist/api/firmware/getBinary.d.ts +0 -7
  28. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  29. package/dist/api/firmware/updateBootloader.d.ts +0 -2
  30. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  31. package/dist/api/firmware/uploadFirmware.d.ts +1 -9
  32. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  33. package/dist/api/index.d.ts +1 -0
  34. package/dist/api/index.d.ts.map +1 -1
  35. package/dist/api/protocol-v2/Ping.d.ts +2 -0
  36. package/dist/api/protocol-v2/Ping.d.ts.map +1 -1
  37. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  38. package/dist/core/index.d.ts.map +1 -1
  39. package/dist/data-manager/DataManager.d.ts +0 -1
  40. package/dist/data-manager/DataManager.d.ts.map +1 -1
  41. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  42. package/dist/device/Device.d.ts +3 -2
  43. package/dist/device/Device.d.ts.map +1 -1
  44. package/dist/device/DeviceCommands.d.ts.map +1 -1
  45. package/dist/events/device.d.ts +1 -0
  46. package/dist/events/device.d.ts.map +1 -1
  47. package/dist/events/logBlockEvent.d.ts.map +1 -1
  48. package/dist/events/ui-request.d.ts +1 -0
  49. package/dist/events/ui-request.d.ts.map +1 -1
  50. package/dist/index.d.ts +98 -278
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +876 -2647
  53. package/dist/inject.d.ts.map +1 -1
  54. package/dist/lowLevelInject.d.ts.map +1 -1
  55. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts.map +1 -1
  56. package/dist/protocols/protocol-v2/unlockRetry.d.ts +2 -3
  57. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -1
  58. package/dist/protocols/protocol-v2/walletSession.d.ts +12 -0
  59. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  60. package/dist/topLevelInject.d.ts.map +1 -1
  61. package/dist/types/api/checkAllFirmwareRelease.d.ts +0 -3
  62. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  63. package/dist/types/api/deviceUpdateBootloader.d.ts +0 -6
  64. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  65. package/dist/types/api/export.d.ts +1 -4
  66. package/dist/types/api/export.d.ts.map +1 -1
  67. package/dist/types/api/firmwareUpdate.d.ts +0 -69
  68. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  69. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  70. package/dist/types/api/index.d.ts +4 -10
  71. package/dist/types/api/index.d.ts.map +1 -1
  72. package/dist/types/api/openWalletSession.d.ts +0 -12
  73. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  74. package/dist/types/api/protocolV2.d.ts +4 -0
  75. package/dist/types/api/protocolV2.d.ts.map +1 -1
  76. package/dist/types/settings.d.ts +0 -13
  77. package/dist/types/settings.d.ts.map +1 -1
  78. package/dist/utils/deviceInfoUtils.d.ts.map +1 -1
  79. package/dist/utils/patch.d.ts +1 -1
  80. package/dist/utils/patch.d.ts.map +1 -1
  81. package/package.json +4 -4
  82. package/src/api/CheckAllFirmwareRelease.ts +3 -16
  83. package/src/api/FirmwareUpdateV2.ts +119 -284
  84. package/src/api/FirmwareUpdateV3.ts +55 -248
  85. package/src/api/FirmwareUpdateV4.ts +293 -751
  86. package/src/api/GetPassphraseState.ts +11 -5
  87. package/src/api/OpenWalletSession.ts +23 -86
  88. package/src/api/device/DeviceUpdateBootloader.ts +6 -114
  89. package/src/api/device/DeviceWipe.ts +2 -0
  90. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +0 -50
  91. package/src/api/firmware/updateBootloader.ts +4 -19
  92. package/src/api/firmware/uploadFirmware.ts +22 -140
  93. package/src/api/index.ts +1 -0
  94. package/src/api/protocol-v2/Ping.ts +32 -1
  95. package/src/core/index.ts +40 -7
  96. package/src/data/messages/messages-protocol-v2.json +49 -56
  97. package/src/data-manager/DataManager.ts +33 -33
  98. package/src/data-manager/connectSettings.ts +0 -11
  99. package/src/device/Device.ts +29 -8
  100. package/src/device/DeviceCommands.ts +43 -151
  101. package/src/events/device.ts +1 -0
  102. package/src/events/logBlockEvent.ts +1 -15
  103. package/src/events/ui-request.ts +1 -0
  104. package/src/index.ts +0 -5
  105. package/src/inject.ts +4 -22
  106. package/src/lowLevelInject.ts +1 -5
  107. package/src/protocols/protocol-v2/deviceSession.ts +1 -1
  108. package/src/protocols/protocol-v2/settingsUnlockPolicy.ts +0 -1
  109. package/src/protocols/protocol-v2/unlockRetry.ts +20 -3
  110. package/src/protocols/protocol-v2/walletSession.ts +57 -18
  111. package/src/topLevelInject.ts +1 -5
  112. package/src/types/api/checkAllFirmwareRelease.ts +0 -3
  113. package/src/types/api/deviceUpdateBootloader.ts +0 -6
  114. package/src/types/api/export.ts +0 -18
  115. package/src/types/api/firmwareUpdate.ts +0 -76
  116. package/src/types/api/getPassphraseState.ts +4 -3
  117. package/src/types/api/index.ts +3 -14
  118. package/src/types/api/openWalletSession.ts +0 -19
  119. package/src/types/api/protocolV2.ts +10 -0
  120. package/src/types/settings.ts +0 -13
  121. package/src/utils/deviceInfoUtils.ts +2 -3
  122. package/__tests__/firmware-update/firmware-artifact-source.test.ts +0 -119
  123. package/__tests__/firmware-update/firmware-host-binding.test.ts +0 -70
  124. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +0 -13
  125. package/__tests__/firmware-update/firmware-update-plan.test.ts +0 -326
  126. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +0 -200
  127. package/__tests__/firmware-update/firmware-upload-source.test.ts +0 -70
  128. package/dist/api/firmware/FirmwareArtifactSource.d.ts +0 -26
  129. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +0 -1
  130. package/dist/api/firmware/FirmwareHostBinding.d.ts +0 -6
  131. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +0 -1
  132. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +0 -3
  133. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +0 -1
  134. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +0 -24
  135. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +0 -1
  136. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +0 -25
  137. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +0 -1
  138. package/dist/types/api/firmwareUpdateCapabilities.d.ts +0 -9
  139. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +0 -1
  140. package/dist/types/api/firmwareUpdatePlan.d.ts +0 -27
  141. package/dist/types/api/firmwareUpdatePlan.d.ts.map +0 -1
  142. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +0 -42
  143. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +0 -1
  144. package/src/api/firmware/FirmwareArtifactSource.ts +0 -278
  145. package/src/api/firmware/FirmwareHostBinding.ts +0 -100
  146. package/src/api/firmware/FirmwareUpdateCapabilities.ts +0 -9
  147. package/src/api/firmware/FirmwareUpdatePlan.ts +0 -621
  148. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +0 -378
  149. package/src/types/api/firmwareUpdateCapabilities.ts +0 -9
  150. package/src/types/api/firmwareUpdatePlan.ts +0 -36
  151. package/src/types/api/firmwareUpdatePreparedPlan.ts +0 -53
@@ -1,5 +1,5 @@
1
- import { EFirmwareType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
- import { DeviceRebootType, DeviceSettingsPage } from '@onekeyfe/hd-transport';
1
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { DeviceRebootType, DeviceSessionPinType, 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';
@@ -83,7 +83,6 @@ import {
83
83
  isMethodVersionRangeUnsupported,
84
84
  } from '../src/utils';
85
85
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
86
- import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
87
86
  import {
88
87
  getPassphraseState,
89
88
  getPassphraseStateWithRefreshDeviceInfo,
@@ -98,6 +97,14 @@ jest.mock('../src/data/config', () => ({
98
97
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
99
98
  }));
100
99
 
100
+ const createWalletSessionTypedCall = (implementation = jest.fn()) =>
101
+ jest.fn((request: string, ...args: unknown[]) => {
102
+ if (request === 'ProtocolInfoRequest') {
103
+ return Promise.resolve({ message: { version: 2 } });
104
+ }
105
+ return implementation(request, ...args);
106
+ });
107
+
101
108
  describe('DeviceUploadWallpaper', () => {
102
109
  test('encodes, uploads and applies a Pro2 wallpaper', async () => {
103
110
  const rgba = new Uint8Array(604 * 1024 * 4).fill(255);
@@ -690,13 +697,15 @@ describe('Protocol V2 feature adapter', () => {
690
697
  const features = normalizeProtocolV2Features(descriptor as any);
691
698
  features.firmwareVersion = '1.2.3';
692
699
  features.passphraseProtection = true;
693
- const typedCall = jest.fn().mockResolvedValue({
694
- type: 'DeviceSession',
695
- message: {
696
- btc_test_address: 'state-1',
697
- session_id: 'session-1',
698
- },
699
- });
700
+ const typedCall = createWalletSessionTypedCall(
701
+ jest.fn().mockResolvedValue({
702
+ type: 'DeviceSession',
703
+ message: {
704
+ btc_test_address: 'state-1',
705
+ session_id: 'session-1',
706
+ },
707
+ })
708
+ );
700
709
  const commands = { typedCall } as unknown as DeviceCommands;
701
710
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
702
711
  (device as any).features = features;
@@ -724,13 +733,15 @@ describe('Protocol V2 feature adapter', () => {
724
733
  { ...descriptor, protocolType: 'V2' } as any,
725
734
  { status: { passphrase_enabled: false, unlocked: true } }
726
735
  );
727
- const typedCall = jest.fn().mockResolvedValue({
728
- type: 'DeviceSession',
729
- message: {
730
- btc_test_address: 'main-wallet-state',
731
- session_id: 'main-wallet-session',
732
- },
733
- });
736
+ const typedCall = createWalletSessionTypedCall(
737
+ jest.fn().mockResolvedValue({
738
+ type: 'DeviceSession',
739
+ message: {
740
+ btc_test_address: 'main-wallet-state',
741
+ session_id: 'main-wallet-session',
742
+ },
743
+ })
744
+ );
734
745
  const promptPassphrase = jest
735
746
  .fn()
736
747
  .mockResolvedValue({ passphrase: 'hidden-wallet-with-disabled-flag' });
@@ -800,17 +811,16 @@ describe('Protocol V2 feature adapter', () => {
800
811
  },
801
812
  }
802
813
  );
803
- const typedCall = jest.fn().mockImplementation((request: string) => {
804
- if (request === 'ProtocolInfoRequest') {
805
- return Promise.resolve({ message: { version: 2 } });
806
- }
807
- if (request === 'DeviceStatusGet') {
808
- return Promise.resolve({
809
- message: { passphrase_enabled: false, unlocked: true },
810
- });
811
- }
812
- throw new Error(`Unexpected request: ${request}`);
813
- });
814
+ const typedCall = createWalletSessionTypedCall(
815
+ jest.fn().mockImplementation((request: string) => {
816
+ if (request === 'DeviceStatusGet') {
817
+ return Promise.resolve({
818
+ message: { passphrase_enabled: false, unlocked: true },
819
+ });
820
+ }
821
+ throw new Error(`Unexpected request: ${request}`);
822
+ })
823
+ );
814
824
  const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'hidden-after-unlock' });
815
825
  const unlockDevice = jest.spyOn(device, 'unlockDevice').mockImplementation(() => {
816
826
  (device as any).features = {
@@ -844,13 +854,15 @@ describe('Protocol V2 feature adapter', () => {
844
854
  );
845
855
  device.passphraseState = 'hidden-state';
846
856
  preloadSessionCache('main-wallet-device', 'hidden-state', 'hidden-session');
847
- const typedCall = jest.fn().mockResolvedValue({
848
- type: 'DeviceSession',
849
- message: {
850
- btc_test_address: 'main-wallet-state',
851
- session_id: 'main-wallet-session',
852
- },
853
- });
857
+ const typedCall = createWalletSessionTypedCall(
858
+ jest.fn().mockResolvedValue({
859
+ type: 'DeviceSession',
860
+ message: {
861
+ btc_test_address: 'main-wallet-state',
862
+ session_id: 'main-wallet-session',
863
+ },
864
+ })
865
+ );
854
866
  (device as any).commands = { typedCall };
855
867
 
856
868
  await expect(
@@ -885,11 +897,13 @@ describe('Protocol V2 feature adapter', () => {
885
897
  },
886
898
  }
887
899
  );
888
- const typedCall = jest.fn().mockResolvedValue({
889
- type: 'DeviceSession',
890
- message: { session_id: 'hidden-session', btc_test_address: 'hidden-state' },
891
- });
892
- const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'hidden secret' });
900
+ const typedCall = createWalletSessionTypedCall(
901
+ jest.fn().mockResolvedValue({
902
+ type: 'DeviceSession',
903
+ message: { session_id: 'hidden-session', btc_test_address: 'hidden-state' },
904
+ })
905
+ );
906
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
893
907
  (device as any).commands = { typedCall, promptPassphrase };
894
908
 
895
909
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
@@ -898,9 +912,8 @@ describe('Protocol V2 feature adapter', () => {
898
912
  });
899
913
 
900
914
  expect(promptPassphrase).toHaveBeenCalled();
901
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
902
- select: { host_passphrase: { passphrase: 'hidden secret' } },
903
- });
915
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {});
916
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
904
917
  });
905
918
 
906
919
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -993,10 +1006,12 @@ describe('Protocol V2 feature adapter', () => {
993
1006
  );
994
1007
  device.passphraseState = 'state-a';
995
1008
  preloadSessionCache('stable-device-1', 'state-a', 'session-a');
996
- const typedCall = jest.fn().mockResolvedValue({
997
- type: 'DeviceSession',
998
- message: { session_id: 'session-b', btc_test_address: 'state-a' },
999
- });
1009
+ const typedCall = createWalletSessionTypedCall(
1010
+ jest.fn().mockResolvedValue({
1011
+ type: 'DeviceSession',
1012
+ message: { session_id: 'session-b', btc_test_address: 'state-a' },
1013
+ })
1014
+ );
1000
1015
  (device as any).commands = { typedCall };
1001
1016
 
1002
1017
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
@@ -1004,8 +1019,8 @@ describe('Protocol V2 feature adapter', () => {
1004
1019
  newSession: 'session-b',
1005
1020
  });
1006
1021
 
1007
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
1008
- resume: { session_id: 'session-a' },
1022
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1023
+ session_id: 'session-a',
1009
1024
  });
1010
1025
  expect(device.getInternalState()).toBe('session-b');
1011
1026
  });
@@ -1021,13 +1036,15 @@ describe('Protocol V2 feature adapter', () => {
1021
1036
  { status: { device_id: 'stable-device-2', unlocked: true } }
1022
1037
  );
1023
1038
  preloadSessionCache('stable-device-2', 'state-a', 'session-a');
1024
- const typedCall = jest.fn().mockResolvedValue({
1025
- type: 'DeviceSession',
1026
- message: {
1027
- session_id: 'main-session',
1028
- btc_test_address: 'main-wallet-state',
1029
- },
1030
- });
1039
+ const typedCall = createWalletSessionTypedCall(
1040
+ jest.fn().mockResolvedValue({
1041
+ type: 'DeviceSession',
1042
+ message: {
1043
+ session_id: 'main-session',
1044
+ btc_test_address: 'main-wallet-state',
1045
+ },
1046
+ })
1047
+ );
1031
1048
  (device as any).commands = { typedCall };
1032
1049
 
1033
1050
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
@@ -1056,14 +1073,13 @@ describe('Protocol V2 feature adapter', () => {
1056
1073
  );
1057
1074
  device.passphraseState = 'state-a';
1058
1075
  preloadSessionCache('stable-device-3', 'state-a', 'session-a');
1059
- const typedCall = jest
1060
- .fn()
1061
- .mockResolvedValueOnce({ message: { version: 2 } })
1062
- .mockRejectedValue(
1076
+ const typedCall = createWalletSessionTypedCall(
1077
+ jest.fn().mockRejectedValue(
1063
1078
  Object.assign(new Error('SE request failed'), {
1064
1079
  errorCode: HardwareErrorCode.RuntimeError,
1065
1080
  })
1066
- );
1081
+ )
1082
+ );
1067
1083
  const promptPassphrase = jest.fn();
1068
1084
  (device as any).commands = { typedCall, promptPassphrase };
1069
1085
 
@@ -1072,7 +1088,7 @@ describe('Protocol V2 feature adapter', () => {
1072
1088
  });
1073
1089
  expect(typedCall.mock.calls).toEqual([
1074
1090
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1075
- ['DeviceSessionOpen', 'DeviceSession', { resume: { session_id: 'session-a' } }],
1091
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1076
1092
  ]);
1077
1093
  expect(promptPassphrase).not.toHaveBeenCalled();
1078
1094
  expect(device.getInternalState()).toBeUndefined();
@@ -1094,17 +1110,16 @@ describe('Protocol V2 feature adapter', () => {
1094
1110
  },
1095
1111
  }
1096
1112
  );
1097
- const typedCall = jest
1098
- .fn()
1099
- .mockResolvedValueOnce({ message: { version: 2 } })
1100
- .mockRejectedValue(
1113
+ const typedCall = createWalletSessionTypedCall(
1114
+ jest.fn().mockRejectedValue(
1101
1115
  Object.assign(new Error('Invalid session'), {
1102
1116
  errorCode: HardwareErrorCode.WalletSessionInvalid,
1103
1117
  })
1104
- );
1118
+ )
1119
+ );
1105
1120
  (device as any).commands = {
1106
1121
  typedCall,
1107
- promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'new-secret' }),
1122
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1108
1123
  };
1109
1124
 
1110
1125
  await expect(getProtocolV2WalletSession(device)).rejects.toMatchObject({
@@ -1112,11 +1127,7 @@ describe('Protocol V2 feature adapter', () => {
1112
1127
  });
1113
1128
  expect(typedCall.mock.calls).toEqual([
1114
1129
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1115
- [
1116
- 'DeviceSessionOpen',
1117
- 'DeviceSession',
1118
- { select: { host_passphrase: { passphrase: 'new-secret' } } },
1119
- ],
1130
+ ['DeviceSessionAskPassphrase', 'Success', {}],
1120
1131
  ]);
1121
1132
  });
1122
1133
 
@@ -1139,10 +1150,12 @@ describe('Protocol V2 feature adapter', () => {
1139
1150
  device.passphraseState = 'state-a';
1140
1151
  preloadSessionCache('stable-device-mismatch', 'state-a', 'session-a');
1141
1152
  (device as any).commands = {
1142
- typedCall: jest.fn().mockResolvedValue({
1143
- type: 'DeviceSession',
1144
- message: { session_id: 'session-b', btc_test_address: 'state-b' },
1145
- }),
1153
+ typedCall: createWalletSessionTypedCall(
1154
+ jest.fn().mockResolvedValue({
1155
+ type: 'DeviceSession',
1156
+ message: { session_id: 'session-b', btc_test_address: 'state-b' },
1157
+ })
1158
+ ),
1146
1159
  };
1147
1160
 
1148
1161
  await expect(
@@ -1155,7 +1168,7 @@ describe('Protocol V2 feature adapter', () => {
1155
1168
  expect(device.getInternalState()).toBeUndefined();
1156
1169
  });
1157
1170
 
1158
- test('unlocks and retries DeviceSessionOpen when wallet selection is locked', async () => {
1171
+ test('unlocks and retries DeviceSessionAskPassphrase when wallet selection is locked', async () => {
1159
1172
  const device = Device.fromDescriptor({
1160
1173
  id: 'cache-device-4',
1161
1174
  path: 'cache-path-4',
@@ -1168,19 +1181,21 @@ describe('Protocol V2 feature adapter', () => {
1168
1181
  const lockedError = Object.assign(new Error('Device is locked'), {
1169
1182
  errorCode: HardwareErrorCode.DeviceLocked,
1170
1183
  });
1171
- const typedCall = jest
1172
- .fn()
1173
- .mockResolvedValueOnce({ message: { version: 2 } })
1174
- .mockRejectedValueOnce(lockedError)
1175
- .mockResolvedValueOnce({
1176
- message: {
1177
- session_id: 'session-after-unlock',
1178
- btc_test_address: 'state-after-unlock',
1179
- },
1180
- });
1184
+ const typedCall = createWalletSessionTypedCall(
1185
+ jest
1186
+ .fn()
1187
+ .mockRejectedValueOnce(lockedError)
1188
+ .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
1189
+ .mockResolvedValueOnce({
1190
+ message: {
1191
+ session_id: 'session-after-unlock',
1192
+ btc_test_address: 'state-after-unlock',
1193
+ },
1194
+ })
1195
+ );
1181
1196
  (device as any).commands = {
1182
1197
  typedCall,
1183
- promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'hidden secret' }),
1198
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1184
1199
  };
1185
1200
  const unlockDevice = jest.spyOn(device, 'unlockDevice').mockResolvedValue(undefined as any);
1186
1201
 
@@ -1189,7 +1204,7 @@ describe('Protocol V2 feature adapter', () => {
1189
1204
  passphraseState: 'state-after-unlock',
1190
1205
  });
1191
1206
  expect(unlockDevice).toHaveBeenCalledTimes(1);
1192
- expect(typedCall).toHaveBeenCalledTimes(3);
1207
+ expect(typedCall).toHaveBeenCalledTimes(4);
1193
1208
  });
1194
1209
 
1195
1210
  test('does not request a Pro2 wallet session before features are initialized', async () => {
@@ -1321,24 +1336,29 @@ describe('Protocol V2 feature adapter', () => {
1321
1336
  );
1322
1337
  });
1323
1338
 
1324
- test('does not expose getPassphraseState on Pro2 devices', async () => {
1339
+ test('maps the legacy getPassphraseState App flow to the Pro2 wallet session protocol', async () => {
1325
1340
  const features = {
1326
- deviceId: null,
1341
+ deviceId: 'pro2-device-id',
1327
1342
  deviceType: 'pro2',
1328
1343
  firmwareVersion: '4.15.0',
1329
1344
  passphraseProtection: true,
1330
1345
  sessionId: 'feature-session',
1331
1346
  unlockedAttachPin: true,
1332
1347
  };
1333
- const typedCall = jest.fn().mockResolvedValue({
1334
- type: 'DeviceSession',
1335
- message: {
1336
- btc_test_address: 'state-pro2',
1337
- session_id: 'session-pro2',
1338
- },
1339
- });
1348
+ const typedCall = jest
1349
+ .fn()
1350
+ .mockResolvedValueOnce({ message: { version: 2 } })
1351
+ .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
1352
+ .mockResolvedValueOnce({
1353
+ type: 'DeviceSession',
1354
+ message: {
1355
+ btc_test_address: 'state-pro2',
1356
+ session_id: 'session-pro2',
1357
+ },
1358
+ });
1340
1359
  const updateInternalState = jest.fn();
1341
1360
  const getFeatures = jest.fn().mockResolvedValue(features);
1361
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1342
1362
  const method = new GetPassphraseState({
1343
1363
  payload: {
1344
1364
  method: 'getPassphraseState',
@@ -1350,21 +1370,95 @@ describe('Protocol V2 feature adapter', () => {
1350
1370
  features,
1351
1371
  commands: {
1352
1372
  typedCall,
1353
- promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-pro2-new-secret' }),
1373
+ promptPassphrase,
1354
1374
  },
1375
+ emit: jest.fn(),
1355
1376
  updateInternalState,
1356
1377
  getFeatures,
1357
1378
  getCurrentDeviceType: () => 'pro2',
1358
- getCurrentDeviceId: () => undefined,
1379
+ getCurrentDeviceId: () => 'pro2-device-id',
1359
1380
  getCurrentPassphraseProtection: () => true,
1360
1381
  }) as any;
1361
1382
 
1362
- await expect(method.run()).rejects.toMatchObject({
1363
- errorCode: HardwareErrorCode.DeviceNotSupportMethod,
1364
- });
1383
+ await expect(method.run()).resolves.toBe('state-pro2');
1384
+ expect(promptPassphrase).toHaveBeenCalledWith(
1385
+ {
1386
+ existsAttachPinUser: false,
1387
+ source: 'wallet-session-coordinator',
1388
+ reason: 'open-wallet',
1389
+ },
1390
+ { cancelDeviceOnReject: false }
1391
+ );
1392
+ expect(updateInternalState).toHaveBeenCalledWith(
1393
+ true,
1394
+ 'state-pro2',
1395
+ 'pro2-device-id',
1396
+ 'session-pro2',
1397
+ null
1398
+ );
1399
+ expect(JSON.stringify(typedCall.mock.calls)).not.toContain('legacy app secret');
1365
1400
  expect(getFeatures).not.toHaveBeenCalled();
1366
1401
  });
1367
1402
 
1403
+ test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
1404
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1405
+ const typedCall = jest.fn().mockImplementation((request: string) => {
1406
+ if (request === 'ProtocolInfoRequest') {
1407
+ return Promise.resolve({ message: { version: 2 } });
1408
+ }
1409
+ if (request === 'DeviceSessionAskPassphrase') {
1410
+ return Promise.resolve({ message: { message: 'passphrase prepared' } });
1411
+ }
1412
+ if (request === 'DeviceSessionGet') {
1413
+ return Promise.resolve({
1414
+ type: 'DeviceSession',
1415
+ message: {
1416
+ btc_test_address: 'state-pro2-app',
1417
+ session_id: 'session-pro2-app',
1418
+ },
1419
+ });
1420
+ }
1421
+ throw new Error(`Unexpected request: ${request}`);
1422
+ });
1423
+
1424
+ (device as any).features = normalizeProtocolV2Features(
1425
+ { ...descriptor, protocolType: 'V2' } as any,
1426
+ {
1427
+ status: {
1428
+ device_id: 'pro2-app-device-id',
1429
+ unlocked: true,
1430
+ passphrase_enabled: true,
1431
+ },
1432
+ }
1433
+ );
1434
+ (device as any).commands = {
1435
+ typedCall,
1436
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1437
+ };
1438
+
1439
+ const method = new GetPassphraseState({
1440
+ payload: {
1441
+ method: 'getPassphraseState',
1442
+ connectId: 'connect-id',
1443
+ initSession: true,
1444
+ },
1445
+ });
1446
+ method.device = device;
1447
+
1448
+ await expect(method.run()).resolves.toBe('state-pro2-app');
1449
+
1450
+ await device.initialize({ passphraseState: 'state-pro2-app' });
1451
+ typedCall.mockClear();
1452
+
1453
+ await expect(device.checkPassphraseStateSafety('state-pro2-app', false, false)).resolves.toBe(
1454
+ true
1455
+ );
1456
+ expect(typedCall.mock.calls).toEqual([
1457
+ ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1458
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
1459
+ ]);
1460
+ });
1461
+
1368
1462
  test('uses the Pro2 standard wallet without passphraseState when passphrase is disabled', async () => {
1369
1463
  const features = {
1370
1464
  deviceId: 'pro2-device-id',
@@ -1373,12 +1467,13 @@ describe('Protocol V2 feature adapter', () => {
1373
1467
  passphraseProtection: false,
1374
1468
  unlocked: true,
1375
1469
  };
1376
- const typedCall = jest.fn();
1470
+ const typedCall = createWalletSessionTypedCall();
1377
1471
  const updateInternalState = jest.fn();
1378
1472
  const method = new GetPassphraseState({
1379
1473
  payload: {
1380
1474
  method: 'getPassphraseState',
1381
1475
  connectId: 'connect-id',
1476
+ useEmptyPassphrase: true,
1382
1477
  },
1383
1478
  });
1384
1479
  method.device = stubDevice({
@@ -1386,7 +1481,7 @@ describe('Protocol V2 feature adapter', () => {
1386
1481
  features,
1387
1482
  commands: {
1388
1483
  typedCall,
1389
- promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'mock-hidden-wallet' }),
1484
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1390
1485
  },
1391
1486
  updateInternalState,
1392
1487
  getCurrentDeviceType: () => 'pro2',
@@ -1394,10 +1489,10 @@ describe('Protocol V2 feature adapter', () => {
1394
1489
  getCurrentPassphraseProtection: () => false,
1395
1490
  }) as any;
1396
1491
 
1397
- await expect(method.run()).rejects.toMatchObject({
1398
- errorCode: HardwareErrorCode.DeviceNotSupportMethod,
1492
+ await expect(method.run()).resolves.toBeUndefined();
1493
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1494
+ eventless_wallet_session: true,
1399
1495
  });
1400
- expect(typedCall).not.toHaveBeenCalled();
1401
1496
  expect(updateInternalState).not.toHaveBeenCalled();
1402
1497
  });
1403
1498
 
@@ -1410,12 +1505,17 @@ describe('Protocol V2 feature adapter', () => {
1410
1505
  sessionId: 'old-feature-session',
1411
1506
  unlockedAttachPin: false,
1412
1507
  };
1413
- const typedCall = jest.fn().mockResolvedValue({
1414
- type: 'DeviceSession',
1415
- message: {
1416
- btc_test_address: 'state-pro2-new',
1417
- },
1418
- });
1508
+ const typedCall = jest
1509
+ .fn()
1510
+ .mockResolvedValueOnce({ message: { version: 2 } })
1511
+ .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
1512
+ .mockResolvedValueOnce({
1513
+ type: 'DeviceSession',
1514
+ message: {
1515
+ btc_test_address: 'state-pro2-new',
1516
+ session_id: 'session-pro2-new',
1517
+ },
1518
+ });
1419
1519
  const clearInternalState = jest.fn();
1420
1520
  const updateInternalState = jest.fn();
1421
1521
  const method = new GetPassphraseState({
@@ -1428,32 +1528,41 @@ describe('Protocol V2 feature adapter', () => {
1428
1528
  method.device = stubDevice({
1429
1529
  originalDescriptor: { ...descriptor, protocolType: 'V2' },
1430
1530
  features,
1531
+ passphraseState: 'state-pro2-old',
1431
1532
  commands: {
1432
1533
  typedCall,
1433
- promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-pro2-new-secret' }),
1534
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1434
1535
  },
1536
+ emit: jest.fn(),
1435
1537
  clearInternalState,
1436
1538
  updateInternalState,
1437
1539
  getCurrentDeviceId: () => 'pro2-device-id',
1438
1540
  getCurrentPassphraseProtection: () => true,
1439
1541
  }) as any;
1440
1542
 
1441
- await expect(method.run()).rejects.toMatchObject({
1442
- errorCode: HardwareErrorCode.DeviceNotSupportMethod,
1443
- });
1444
- expect(clearInternalState).not.toHaveBeenCalled();
1445
- expect(updateInternalState).not.toHaveBeenCalled();
1543
+ await expect(method.run()).resolves.toBe('state-pro2-new');
1544
+ expect(clearInternalState).toHaveBeenCalledTimes(1);
1545
+ expect((method.device as any).passphraseState).toBeUndefined();
1546
+ expect(updateInternalState).toHaveBeenCalledWith(
1547
+ true,
1548
+ 'state-pro2-new',
1549
+ 'pro2-device-id',
1550
+ 'session-pro2-new',
1551
+ null
1552
+ );
1446
1553
  });
1447
1554
 
1448
1555
  test('stores Pro2 passphrase session cache without synthetic device id', async () => {
1449
1556
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1450
- const typedCall = jest.fn().mockResolvedValue({
1451
- type: 'DeviceSession',
1452
- message: {
1453
- btc_test_address: 'state-profile',
1454
- session_id: 'session-profile',
1455
- },
1456
- });
1557
+ const typedCall = createWalletSessionTypedCall(
1558
+ jest.fn().mockResolvedValue({
1559
+ type: 'DeviceSession',
1560
+ message: {
1561
+ btc_test_address: 'state-profile',
1562
+ session_id: 'session-profile',
1563
+ },
1564
+ })
1565
+ );
1457
1566
 
1458
1567
  (device as any).features = {
1459
1568
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
@@ -1465,7 +1574,7 @@ describe('Protocol V2 feature adapter', () => {
1465
1574
  };
1466
1575
  (device as any).commands = {
1467
1576
  typedCall,
1468
- promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-profile-secret' }),
1577
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1469
1578
  };
1470
1579
 
1471
1580
  await getPassphraseStateWithRefreshDeviceInfo(device);
@@ -1477,13 +1586,15 @@ describe('Protocol V2 feature adapter', () => {
1477
1586
 
1478
1587
  test('stores Pro2 passphrase sessions without selecting them implicitly', async () => {
1479
1588
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1480
- const typedCall = jest.fn().mockResolvedValue({
1481
- type: 'DeviceSession',
1482
- message: {
1483
- btc_test_address: 'state-auto',
1484
- session_id: 'session-auto',
1485
- },
1486
- });
1589
+ const typedCall = createWalletSessionTypedCall(
1590
+ jest.fn().mockResolvedValue({
1591
+ type: 'DeviceSession',
1592
+ message: {
1593
+ btc_test_address: 'state-auto',
1594
+ session_id: 'session-auto',
1595
+ },
1596
+ })
1597
+ );
1487
1598
 
1488
1599
  (device as any).features = normalizeProtocolV2Features(
1489
1600
  {
@@ -1503,7 +1614,7 @@ describe('Protocol V2 feature adapter', () => {
1503
1614
  );
1504
1615
  (device as any).commands = {
1505
1616
  typedCall,
1506
- promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-auto-secret' }),
1617
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1507
1618
  };
1508
1619
 
1509
1620
  await expect(getPassphraseStateWithRefreshDeviceInfo(device)).resolves.toMatchObject({
@@ -1517,20 +1628,20 @@ describe('Protocol V2 feature adapter', () => {
1517
1628
  expect(device.getInternalState()).toBeUndefined();
1518
1629
  device.passphraseState = 'state-auto';
1519
1630
  expect(device.getInternalState()).toBe('session-auto');
1520
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionOpen', 'DeviceSession', {
1521
- select: { host_passphrase: { passphrase: 'state-auto-secret' } },
1522
- });
1631
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
1523
1632
  });
1524
1633
 
1525
1634
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
1526
1635
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1527
- const typedCall = jest.fn().mockResolvedValue({
1528
- type: 'DeviceSession',
1529
- message: {
1530
- session_id: 'main-pin-session',
1531
- btc_test_address: 'main-wallet-state',
1532
- },
1533
- });
1636
+ const typedCall = createWalletSessionTypedCall(
1637
+ jest.fn().mockResolvedValue({
1638
+ type: 'DeviceSession',
1639
+ message: {
1640
+ session_id: 'main-pin-session',
1641
+ btc_test_address: 'main-wallet-state',
1642
+ },
1643
+ })
1644
+ );
1534
1645
 
1535
1646
  (device as any).features = normalizeProtocolV2Features(
1536
1647
  {
@@ -1565,13 +1676,15 @@ describe('Protocol V2 feature adapter', () => {
1565
1676
 
1566
1677
  test('does not let skipPassphraseCheck hide Pro2 passphrase state mismatch', async () => {
1567
1678
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1568
- const typedCall = jest.fn().mockResolvedValue({
1569
- type: 'DeviceSession',
1570
- message: {
1571
- btc_test_address: 'wrong-state',
1572
- session_id: 'wrong-session',
1573
- },
1574
- });
1679
+ const typedCall = createWalletSessionTypedCall(
1680
+ jest.fn().mockResolvedValue({
1681
+ type: 'DeviceSession',
1682
+ message: {
1683
+ btc_test_address: 'wrong-state',
1684
+ session_id: 'wrong-session',
1685
+ },
1686
+ })
1687
+ );
1575
1688
 
1576
1689
  (device as any).features = normalizeProtocolV2Features({
1577
1690
  ...descriptor,
@@ -1582,7 +1695,7 @@ describe('Protocol V2 feature adapter', () => {
1582
1695
  (device as any).features.unlocked = true;
1583
1696
  (device as any).commands = {
1584
1697
  typedCall,
1585
- promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'expected-secret' }),
1698
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1586
1699
  };
1587
1700
 
1588
1701
  await expect(device.checkPassphraseStateSafety('expected-state', false, true)).rejects.toEqual(
@@ -1592,20 +1705,20 @@ describe('Protocol V2 feature adapter', () => {
1592
1705
  );
1593
1706
 
1594
1707
  expect(device.getInternalState()).toBeUndefined();
1595
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionOpen', 'DeviceSession', {
1596
- select: { host_passphrase: { passphrase: 'expected-secret' } },
1597
- });
1708
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {});
1598
1709
  });
1599
1710
 
1600
1711
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
1601
1712
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1602
- const typedCall = jest.fn().mockResolvedValue({
1603
- type: 'DeviceSession',
1604
- message: {
1605
- session_id: 'main-wallet-session',
1606
- btc_test_address: 'main-wallet-state',
1607
- },
1608
- });
1713
+ const typedCall = createWalletSessionTypedCall(
1714
+ jest.fn().mockResolvedValue({
1715
+ type: 'DeviceSession',
1716
+ message: {
1717
+ session_id: 'main-wallet-session',
1718
+ btc_test_address: 'main-wallet-state',
1719
+ },
1720
+ })
1721
+ );
1609
1722
 
1610
1723
  (device as any).features = normalizeProtocolV2Features(
1611
1724
  { ...descriptor, protocolType: 'V2' } as any,
@@ -1696,7 +1809,7 @@ describe('Protocol V2 feature adapter', () => {
1696
1809
  uuid: 'CACHED-SERIAL',
1697
1810
  deviceId: null,
1698
1811
  bleName: 'Cached BLE',
1699
- name: 'Cached BLE',
1812
+ name: 'Cached Label',
1700
1813
  label: 'Cached Label',
1701
1814
  deviceType: 'pro2',
1702
1815
  });
@@ -1705,6 +1818,39 @@ describe('Protocol V2 feature adapter', () => {
1705
1818
  expect(device.hasUsePassphrase()).toBe(true);
1706
1819
  });
1707
1820
 
1821
+ test('uses the BLE name as the Protocol V2 display label while preserving the raw label', () => {
1822
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1823
+ (device as any).features = {
1824
+ ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
1825
+ serialNo: 'PR9999999999',
1826
+ label: null,
1827
+ bleName: 'Pro2 6136',
1828
+ };
1829
+
1830
+ expect(device.toMessageObject()).toMatchObject({
1831
+ serialNo: 'PR9999999999',
1832
+ bleName: 'Pro2 6136',
1833
+ name: 'Pro2 6136',
1834
+ label: 'Pro2 6136',
1835
+ });
1836
+ expect(device.features?.label).toBeNull();
1837
+ });
1838
+
1839
+ test('prefers the real Protocol V2 label over the BLE name for display', () => {
1840
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1841
+ (device as any).features = {
1842
+ ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
1843
+ label: 'My Pro 2',
1844
+ bleName: 'Pro2 6136',
1845
+ };
1846
+
1847
+ expect(device.toMessageObject()).toMatchObject({
1848
+ bleName: 'Pro2 6136',
1849
+ name: 'My Pro 2',
1850
+ label: 'My Pro 2',
1851
+ });
1852
+ });
1853
+
1708
1854
  test('syncs Protocol V2 cached features without cached profile', () => {
1709
1855
  const cached = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1710
1856
  (cached as any).features = {
@@ -2166,7 +2312,7 @@ describe('Protocol V2 feature adapter', () => {
2166
2312
  await expect(method.run()).resolves.toEqual({ address: '0x0', signature: '0x1' });
2167
2313
  });
2168
2314
 
2169
- test('unlocks Protocol V2 devices via DeviceSessionAskPin regardless of Pro-series version gates', async () => {
2315
+ test('unlocks Protocol V2 devices with the main PIN regardless of Pro-series version gates', async () => {
2170
2316
  const device = Device.fromDescriptor({
2171
2317
  path: 'usb-path',
2172
2318
  protocolType: 'V2',
@@ -2206,7 +2352,7 @@ describe('Protocol V2 feature adapter', () => {
2206
2352
  const features = await device.unlockDevice();
2207
2353
 
2208
2354
  expect(typedCall.mock.calls).toEqual([
2209
- ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
2355
+ ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
2210
2356
  ['DeviceStatusGet', 'DeviceStatus', {}],
2211
2357
  ]);
2212
2358
  expect(typedCall).not.toHaveBeenCalledWith('GetAddress', 'Address', expect.anything());
@@ -2254,7 +2400,7 @@ describe('Protocol V2 feature adapter', () => {
2254
2400
  await device.unlockDevice();
2255
2401
 
2256
2402
  expect(typedCall.mock.calls).toEqual([
2257
- ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
2403
+ ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
2258
2404
  ['DeviceStatusGet', 'DeviceStatus', {}],
2259
2405
  ]);
2260
2406
  expect((device as any).profile).toBeUndefined();
@@ -2284,10 +2430,10 @@ describe('Protocol V2 feature adapter', () => {
2284
2430
  );
2285
2431
  expect(typedCall).toHaveBeenCalledTimes(1);
2286
2432
  expect(typedCall.mock.calls).toEqual([
2287
- ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
2433
+ ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
2288
2434
  ]);
2289
2435
  expect(typedCall).not.toHaveBeenCalledWith(
2290
- 'DeviceSessionOpen',
2436
+ 'DeviceSessionGet',
2291
2437
  'DeviceSession',
2292
2438
  expect.anything()
2293
2439
  );
@@ -2800,7 +2946,7 @@ describe('Protocol V2 firmware update targets', () => {
2800
2946
  });
2801
2947
  });
2802
2948
 
2803
- test('delegates Protocol V2 mode transition to the phase executor', async () => {
2949
+ test('enters Protocol V2 bootloader before upload', async () => {
2804
2950
  const method = new FirmwareUpdateV4({
2805
2951
  id: 1,
2806
2952
  payload: {
@@ -2835,7 +2981,7 @@ describe('Protocol V2 firmware update targets', () => {
2835
2981
 
2836
2982
  await method.run();
2837
2983
 
2838
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
2984
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
2839
2985
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
2840
2986
  fwBinaryMap: [
2841
2987
  {
@@ -3154,9 +3300,9 @@ describe('Protocol V2 firmware update targets', () => {
3154
3300
  const typedCall = jest
3155
3301
  .fn()
3156
3302
  .mockRejectedValue(
3157
- Object.assign(
3158
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
3159
- { name: 'NotFoundError' }
3303
+ new DOMException(
3304
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
3305
+ 'NotFoundError'
3160
3306
  )
3161
3307
  );
3162
3308
 
@@ -3244,7 +3390,7 @@ describe('Protocol V2 firmware update targets', () => {
3244
3390
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
3245
3391
  });
3246
3392
 
3247
- test('does not treat a missing firmware status handler as install completion', async () => {
3393
+ test('accepts a missing firmware status handler as an app reboot signal', async () => {
3248
3394
  const method = new FirmwareUpdateV4({
3249
3395
  id: 1,
3250
3396
  payload: {
@@ -3263,39 +3409,12 @@ describe('Protocol V2 firmware update targets', () => {
3263
3409
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
3264
3410
  method.postProgressMessage = jest.fn();
3265
3411
 
3266
- await expect(
3267
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
3268
- { target_id: 4, path: 'vol0:/application_p1.bin' },
3269
- ])
3270
- ).rejects.toThrow('Protocol V2 firmware install status is unavailable');
3271
-
3272
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3273
- expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3274
- 'DeviceFirmwareUpdateStatusGet',
3275
- 'DeviceInfoGet',
3412
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete([
3413
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
3276
3414
  ]);
3277
- });
3278
-
3279
- test('rejects a finished target whose payload version conflicts with the plan', () => {
3280
- const method = new FirmwareUpdateV4({
3281
- id: 1,
3282
- payload: {
3283
- method: 'firmwareUpdateV4',
3284
- platform: 'desktop',
3285
- expectedTargetVersions: {
3286
- app_v1: '2.0.0',
3287
- },
3288
- },
3289
- });
3290
- method.init();
3291
- method.postProgressMessage = jest.fn();
3292
3415
 
3293
- expect(() =>
3294
- (method as any).assertProtocolV2TargetStatus(
3295
- [{ target_id: 4, status: 2, payload_version: 0x010000 }],
3296
- new Set([4])
3297
- )
3298
- ).toThrow('expected 131072');
3416
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3417
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
3299
3418
  });
3300
3419
 
3301
3420
  test('polls target status after update ACK and finishes when all targets complete', async () => {
@@ -3474,14 +3593,10 @@ describe('Protocol V2 firmware update targets', () => {
3474
3593
  const writtenPaths: string[] = [];
3475
3594
  method.postTipMessage = jest.fn();
3476
3595
  method.postProgressMessage = jest.fn();
3477
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
3596
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
3478
3597
  writtenPaths.push(params.filePath);
3479
- return Number(params.processedSize ?? 0) + Number(params.source.size);
3598
+ return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
3480
3599
  });
3481
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
3482
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3483
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({});
3484
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
3485
3600
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
3486
3601
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
3487
3602
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -3521,12 +3636,10 @@ describe('Protocol V2 firmware update targets', () => {
3521
3636
  'vol0:/coprocessor.bin',
3522
3637
  1
3523
3638
  );
3524
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
3525
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
3526
- targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
3527
- });
3528
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
3639
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
3640
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
3529
3641
  targets: [
3642
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
3530
3643
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
3531
3644
  { target_id: 7, path: 'vol0:/se01.bin' },
3532
3645
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -3536,7 +3649,7 @@ describe('Protocol V2 firmware update targets', () => {
3536
3649
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
3537
3650
  });
3538
3651
 
3539
- test('announces separate resource and component transfer phases', async () => {
3652
+ test('announces one transfer when syncing resource bundles and staging firmware', async () => {
3540
3653
  const method = new FirmwareUpdateV4({
3541
3654
  id: 1,
3542
3655
  payload: {
@@ -3546,14 +3659,11 @@ describe('Protocol V2 firmware update targets', () => {
3546
3659
 
3547
3660
  method.postTipMessage = jest.fn();
3548
3661
  method.postProgressMessage = jest.fn();
3549
- (method as any).protocolV2SourceUpdateProcess = jest
3662
+ (method as any).protocolV2CommonUpdateProcess = jest
3550
3663
  .fn()
3551
3664
  .mockImplementation(params =>
3552
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
3665
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
3553
3666
  );
3554
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
3555
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3556
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
3557
3667
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
3558
3668
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
3559
3669
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -3573,22 +3683,21 @@ describe('Protocol V2 firmware update targets', () => {
3573
3683
  {
3574
3684
  fileName: 'application_p1.bin',
3575
3685
  binary: new Uint8Array([3]).buffer,
3576
- targetId: 4,
3686
+ targetId: 3,
3577
3687
  },
3578
3688
  ],
3579
3689
  });
3580
3690
 
3581
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
3691
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
3582
3692
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
3583
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
3584
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
3585
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
3693
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
3694
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
3586
3695
  1,
3587
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
3696
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
3588
3697
  );
3589
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
3698
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
3590
3699
  2,
3591
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
3700
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
3592
3701
  );
3593
3702
  });
3594
3703
 
@@ -3602,8 +3711,7 @@ describe('Protocol V2 firmware update targets', () => {
3602
3711
 
3603
3712
  method.postTipMessage = jest.fn();
3604
3713
  method.postProgressMessage = jest.fn();
3605
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
3606
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
3714
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
3607
3715
  (method as any).device = stubDevice({
3608
3716
  getCommands: () => ({
3609
3717
  typedCall: jest.fn().mockResolvedValue({
@@ -3649,14 +3757,11 @@ describe('Protocol V2 firmware update targets', () => {
3649
3757
 
3650
3758
  method.postTipMessage = jest.fn();
3651
3759
  method.postProgressMessage = jest.fn();
3652
- (method as any).protocolV2SourceUpdateProcess = jest
3760
+ (method as any).protocolV2CommonUpdateProcess = jest
3653
3761
  .fn()
3654
3762
  .mockImplementation(params =>
3655
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
3763
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
3656
3764
  );
3657
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
3658
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3659
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
3660
3765
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
3661
3766
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
3662
3767
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -3975,9 +4080,7 @@ describe('Protocol V2 firmware update targets', () => {
3975
4080
  order.push('bootloader');
3976
4081
  return Promise.resolve(true);
3977
4082
  });
3978
- const executeProtocolV2UpdateSpy = jest.spyOn(method as any, 'executeProtocolV2Update');
3979
- (method as any).executeProtocolV2TransferPhase = jest.fn().mockResolvedValue(undefined);
3980
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue(undefined);
4083
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3981
4084
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3982
4085
  (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
3983
4086
  bootloaderVersion: '1.0.0',
@@ -3989,7 +4092,7 @@ describe('Protocol V2 firmware update targets', () => {
3989
4092
  await method.run();
3990
4093
 
3991
4094
  expect(order).toEqual(['download', 'bootloader']);
3992
- expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
4095
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3993
4096
  bootloaderBinary: null,
3994
4097
  fwBinaryMap: [],
3995
4098
  resourceBundles: [
@@ -4002,7 +4105,6 @@ describe('Protocol V2 firmware update targets', () => {
4002
4105
  ],
4003
4106
  });
4004
4107
 
4005
- executeProtocolV2UpdateSpy.mockRestore();
4006
4108
  getSysResourceBinarySpy.mockRestore();
4007
4109
  getFirmwareLatestReleaseSpy.mockRestore();
4008
4110
  });
@@ -4291,10 +4393,7 @@ describe('Protocol V2 firmware update targets', () => {
4291
4393
 
4292
4394
  method.postTipMessage = jest.fn();
4293
4395
  method.postProgressMessage = jest.fn();
4294
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4295
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4296
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4297
- (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4396
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4298
4397
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
4299
4398
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
4300
4399
 
@@ -4310,7 +4409,7 @@ describe('Protocol V2 firmware update targets', () => {
4310
4409
  fwBinaryMap: [],
4311
4410
  });
4312
4411
 
4313
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
4412
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
4314
4413
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
4315
4414
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
4316
4415
  });
@@ -4341,19 +4440,12 @@ describe('Protocol V2 firmware update targets', () => {
4341
4440
  });
4342
4441
  method.postProgressMessage = jest.fn();
4343
4442
 
4344
- const source = await openFirmwareByteSource({
4345
- binary: new Uint8Array(4097).buffer,
4443
+ await (method as any).protocolV2CommonUpdateProcess({
4444
+ payload: new Uint8Array(4097).buffer,
4445
+ filePath: 'vol1:firmware.bin',
4446
+ processedSize: 0,
4447
+ totalSize: 4097,
4346
4448
  });
4347
- try {
4348
- await (method as any).protocolV2SourceUpdateProcess({
4349
- source,
4350
- filePath: 'vol1:firmware.bin',
4351
- processedSize: 0,
4352
- totalSize: 4097,
4353
- });
4354
- } finally {
4355
- await source?.close();
4356
- }
4357
4449
 
4358
4450
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
4359
4451
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -4404,16 +4496,12 @@ describe('Protocol V2 firmware update targets', () => {
4404
4496
  method.postProgressMessage = jest.fn();
4405
4497
 
4406
4498
  try {
4407
- const source = await openFirmwareByteSource({
4408
- binary: new Uint8Array(8001).buffer,
4409
- });
4410
- await (method as any).protocolV2SourceUpdateProcess({
4411
- source,
4499
+ await (method as any).protocolV2CommonUpdateProcess({
4500
+ payload: new Uint8Array(8001).buffer,
4412
4501
  filePath: 'vol0:/firmware.bin',
4413
4502
  processedSize: 0,
4414
4503
  totalSize: 8001,
4415
4504
  });
4416
- await source?.close();
4417
4505
  } finally {
4418
4506
  setTimeoutSpy.mockRestore();
4419
4507
  }
@@ -4445,21 +4533,16 @@ describe('Protocol V2 firmware update targets', () => {
4445
4533
  });
4446
4534
  method.postProgressMessage = jest.fn();
4447
4535
  method.postTipMessage = jest.fn();
4448
- (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
4449
4536
 
4450
- const source = await openFirmwareByteSource({
4451
- binary: new Uint8Array(4097).buffer,
4452
- });
4453
4537
  await expect(
4454
- (method as any).protocolV2SourceUpdateProcess({
4455
- source,
4538
+ (method as any).protocolV2WriteWholeFile({
4539
+ payload: new Uint8Array(4097).buffer,
4456
4540
  filePath: 'vol0:/firmware.bin',
4457
4541
  processedSize: 0,
4458
4542
  totalSize: 4097,
4459
4543
  })
4460
4544
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
4461
- await source?.close();
4462
- expect(typedCall).toHaveBeenCalledTimes(6);
4545
+ expect(typedCall).toHaveBeenCalledTimes(2);
4463
4546
  });
4464
4547
 
4465
4548
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -4492,19 +4575,12 @@ describe('Protocol V2 firmware update targets', () => {
4492
4575
  });
4493
4576
  method.postProgressMessage = jest.fn();
4494
4577
 
4495
- const source = await openFirmwareByteSource({
4496
- binary: new Uint8Array(1801).buffer,
4578
+ await (method as any).protocolV2CommonUpdateProcess({
4579
+ payload: new Uint8Array(1801).buffer,
4580
+ filePath: 'vol1:ble-firmware.bin',
4581
+ processedSize: 0,
4582
+ totalSize: 1801,
4497
4583
  });
4498
- try {
4499
- await (method as any).protocolV2SourceUpdateProcess({
4500
- source,
4501
- filePath: 'vol1:ble-firmware.bin',
4502
- processedSize: 0,
4503
- totalSize: 1801,
4504
- });
4505
- } finally {
4506
- await source?.close();
4507
- }
4508
4584
 
4509
4585
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
4510
4586
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
@@ -4621,9 +4697,9 @@ describe('Protocol V2 firmware update method', () => {
4621
4697
  const typedCall = jest
4622
4698
  .fn()
4623
4699
  .mockRejectedValue(
4624
- Object.assign(
4625
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4626
- { name: 'NotFoundError' }
4700
+ new DOMException(
4701
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4702
+ 'NotFoundError'
4627
4703
  )
4628
4704
  );
4629
4705
 
@@ -4788,33 +4864,24 @@ describe('Protocol V2 firmware update method', () => {
4788
4864
  });
4789
4865
 
4790
4866
  describe('Protocol V2 firmware reconnect identity', () => {
4791
- test('keeps the host plan identity as the V4 reconnect anchor', () => {
4792
- const method = new FirmwareUpdateV4({
4793
- id: 1,
4794
- payload: {
4795
- method: 'firmwareUpdateV4',
4796
- platform: 'native',
4797
- expectedDeviceId: 'persisted-device-id',
4798
- },
4799
- });
4800
-
4801
- method.init();
4802
-
4803
- expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
4804
- });
4805
-
4806
- test('requires the same physical serial before firmware transfer resumes', () => {
4867
+ test('rejects a different physical serial before firmware transfer resumes', () => {
4807
4868
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
4808
4869
  'identity mismatch'
4809
4870
  );
4810
- expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).toThrow(
4811
- 'identity unavailable'
4812
- );
4813
4871
  expect(() =>
4814
4872
  assertProtocolV2ReconnectIdentity('expected-serial', 'expected-serial')
4815
4873
  ).not.toThrow();
4874
+ });
4875
+
4876
+ test('fails closed when the physical serial is unavailable', () => {
4877
+ expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).toThrow(
4878
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
4879
+ );
4816
4880
  expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).toThrow(
4817
- 'identity unavailable'
4881
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
4882
+ );
4883
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).toThrow(
4884
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
4818
4885
  );
4819
4886
  });
4820
4887
 
@@ -4847,6 +4914,54 @@ describe('Protocol V2 firmware reconnect identity', () => {
4847
4914
  { timeoutMs: 5000 }
4848
4915
  );
4849
4916
  });
4917
+
4918
+ test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
4919
+ const method = new FirmwareUpdateV4({
4920
+ id: 1,
4921
+ payload: { method: 'firmwareUpdateV4' },
4922
+ });
4923
+ const typedCall = jest.fn().mockResolvedValue({
4924
+ type: 'DeviceInfo',
4925
+ message: { protocol_version: 1, hw: {} },
4926
+ });
4927
+ (method as any).device = stubDevice({
4928
+ getCommands: () => ({ typedCall }),
4929
+ });
4930
+
4931
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
4932
+ errorCode: HardwareErrorCode.DeviceNotFound,
4933
+ });
4934
+ });
4935
+
4936
+ test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
4937
+ const method = new FirmwareUpdateV4({
4938
+ id: 1,
4939
+ payload: {
4940
+ method: 'firmwareUpdateV4',
4941
+ platform: 'native',
4942
+ chunkSize: 1800,
4943
+ },
4944
+ });
4945
+ method.init();
4946
+ const readChunkLengths: number[] = [];
4947
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
4948
+ if (requestType === 'FilesystemPathInfoQuery') {
4949
+ return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
4950
+ }
4951
+ if (requestType === 'FilesystemFileRead') {
4952
+ readChunkLengths.push(request.chunk_len);
4953
+ return Promise.resolve({ message: { data: new Uint8Array(request.chunk_len) } });
4954
+ }
4955
+ throw new Error(`Unexpected request: ${requestType}`);
4956
+ });
4957
+ (method as any).device = stubDevice({
4958
+ getCommands: () => ({ typedCall }),
4959
+ });
4960
+
4961
+ await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
4962
+ expect(readChunkLengths.length).toBeGreaterThan(1);
4963
+ expect(Math.max(...readChunkLengths)).toBe(900);
4964
+ });
4850
4965
  });
4851
4966
 
4852
4967
  describe('Protocol V2 explicit USB device selection', () => {
@@ -5028,6 +5143,70 @@ describe('Protocol V2 protected method execution', () => {
5028
5143
  ]);
5029
5144
  });
5030
5145
 
5146
+ test('restores the expected hidden-wallet session before retrying after unlock', async () => {
5147
+ const calls: string[] = [];
5148
+ const method = {
5149
+ name: 'evmSignMessage',
5150
+ payload: { passphraseState: 'hidden-state' },
5151
+ useDevicePassphraseState: true,
5152
+ unlockPolicy: 'retry-on-locked',
5153
+ run: jest
5154
+ .fn()
5155
+ .mockImplementationOnce(() => {
5156
+ calls.push('run-1');
5157
+ return Promise.reject(deviceLockedError());
5158
+ })
5159
+ .mockImplementationOnce(() => {
5160
+ calls.push('run-2');
5161
+ return Promise.resolve({ message: 'ok' });
5162
+ }),
5163
+ };
5164
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
5165
+ if (requestType === 'ProtocolInfoRequest') {
5166
+ calls.push('negotiate-session');
5167
+ return Promise.resolve({ message: { version: 2 } });
5168
+ }
5169
+ if (requestType === 'DeviceSessionGet') {
5170
+ calls.push('resume-hidden-session');
5171
+ expect(request).toEqual({ session_id: 'hidden-session' });
5172
+ return Promise.resolve({
5173
+ message: {
5174
+ session_id: 'hidden-session',
5175
+ btc_test_address: 'hidden-state',
5176
+ },
5177
+ });
5178
+ }
5179
+ throw new Error(`Unexpected request: ${requestType}`);
5180
+ });
5181
+ const device = {
5182
+ features: { unlocked: true, passphraseProtection: true },
5183
+ passphraseState: 'hidden-state',
5184
+ commands: { typedCall },
5185
+ isProtocolV2: () => true,
5186
+ unlockDevice: jest.fn(() => {
5187
+ calls.push('unlock');
5188
+ return Promise.resolve();
5189
+ }),
5190
+ getCurrentPassphraseProtection: () => true,
5191
+ getInternalState: () => 'hidden-session',
5192
+ clearInternalState: jest.fn(),
5193
+ getCurrentDeviceId: () => 'wallet-device-id',
5194
+ updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
5195
+ };
5196
+
5197
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
5198
+ message: 'ok',
5199
+ });
5200
+ expect(calls).toEqual([
5201
+ 'run-1',
5202
+ 'unlock',
5203
+ 'negotiate-session',
5204
+ 'resume-hidden-session',
5205
+ 'validate-hidden-session',
5206
+ 'run-2',
5207
+ ]);
5208
+ });
5209
+
5031
5210
  test('unlocks before showing the method interaction when cached status is locked', async () => {
5032
5211
  const calls: string[] = [];
5033
5212
  const method = {
@@ -5218,22 +5397,27 @@ describe('Protocol V2 protected method execution', () => {
5218
5397
  describe('Protocol V2 current low-level methods', () => {
5219
5398
  test('routes device wipe to the Pro2 reset page until the operation completes', async () => {
5220
5399
  const v1TypedCall = jest.fn().mockResolvedValue({ message: { message: 'wiped' } });
5400
+ const v1InvalidateAfterWipe = jest.fn();
5221
5401
  const v1Method = new DeviceWipe({ id: 1, payload: { method: 'deviceWipe' } });
5222
5402
  v1Method.init();
5223
5403
  (v1Method as any).device = stubDevice({
5224
5404
  isProtocolV2: () => false,
5225
5405
  commands: { typedCall: v1TypedCall },
5406
+ invalidateAfterWipe: v1InvalidateAfterWipe,
5226
5407
  });
5227
5408
 
5228
5409
  await v1Method.run();
5229
5410
  expect(v1TypedCall).toHaveBeenCalledWith('WipeDevice', 'Success');
5411
+ expect(v1InvalidateAfterWipe).toHaveBeenCalledTimes(1);
5230
5412
 
5231
5413
  const v2TypedCall = jest.fn().mockResolvedValue({ message: { message: 'accepted' } });
5414
+ const v2InvalidateAfterWipe = jest.fn();
5232
5415
  const v2Method = new DeviceWipe({ id: 2, payload: { method: 'deviceWipe' } });
5233
5416
  v2Method.init();
5234
5417
  (v2Method as any).device = stubDevice({
5235
5418
  isProtocolV2: () => true,
5236
5419
  commands: { typedCall: v2TypedCall },
5420
+ invalidateAfterWipe: v2InvalidateAfterWipe,
5237
5421
  });
5238
5422
 
5239
5423
  await expect(v2Method.run()).resolves.toEqual({ message: 'accepted' });
@@ -5241,6 +5425,7 @@ describe('Protocol V2 current low-level methods', () => {
5241
5425
  expect(v2TypedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
5242
5426
  page: DeviceSettingsPage.DeviceReset,
5243
5427
  });
5428
+ expect(v2InvalidateAfterWipe).toHaveBeenCalledTimes(1);
5244
5429
  expect(v2Method.protocolV2UiInteraction).toEqual({
5245
5430
  request: 'button',
5246
5431
  source: 'method-lifecycle',
@@ -5252,6 +5437,21 @@ describe('Protocol V2 current low-level methods', () => {
5252
5437
  });
5253
5438
  });
5254
5439
 
5440
+ test('keeps the existing identity cache when device wipe fails', async () => {
5441
+ const wipeError = new Error('wipe failed');
5442
+ const invalidateAfterWipe = jest.fn();
5443
+ const method = new DeviceWipe({ id: 1, payload: { method: 'deviceWipe' } });
5444
+ method.init();
5445
+ (method as any).device = stubDevice({
5446
+ isProtocolV2: () => true,
5447
+ commands: { typedCall: jest.fn().mockRejectedValue(wipeError) },
5448
+ invalidateAfterWipe,
5449
+ });
5450
+
5451
+ await expect(method.run()).rejects.toBe(wipeError);
5452
+ expect(invalidateAfterWipe).not.toHaveBeenCalled();
5453
+ });
5454
+
5255
5455
  test('routes Change PIN by protocol and waits for the Pro2 operation result', async () => {
5256
5456
  const v1TypedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
5257
5457
  const v1Method = new DeviceChangePin({
@@ -5402,14 +5602,12 @@ describe('Protocol V2 current low-level methods', () => {
5402
5602
  });
5403
5603
 
5404
5604
  test.each([
5405
- ['label', { label: 'My Pro 2' }],
5406
5605
  ['language', { language: 'ja' }],
5407
5606
  ['brightness', { brightness: 80 }],
5408
5607
  ['haptic feedback', { hapticFeedback: true }],
5409
5608
  [
5410
5609
  'combined lock-free settings',
5411
5610
  {
5412
- label: 'My Pro 2',
5413
5611
  language: 'ja',
5414
5612
  brightness: 80,
5415
5613
  hapticFeedback: true,
@@ -5427,8 +5625,10 @@ describe('Protocol V2 current low-level methods', () => {
5427
5625
  });
5428
5626
 
5429
5627
  test.each([
5628
+ ['label', { label: 'My Pro 2' }],
5430
5629
  ['auto lock', { autoLockDelayMs: 60_000 }],
5431
5630
  ['auto shutdown', { autoShutdownDelayMs: 120_000 }],
5631
+ ['label mixed with lock-free settings', { label: 'My Pro 2', brightness: 80 }],
5432
5632
  ['mixed protected settings', { brightness: 80, autoLockDelayMs: 60_000 }],
5433
5633
  ['other protected settings', { usbLockEnabled: true }],
5434
5634
  ])('unlocks before changing unified Protocol V2 %s', (_name, settings) => {