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

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 (226) hide show
  1. package/__tests__/DeviceCommands.test.ts +87 -2
  2. package/__tests__/connectSettings.test.ts +47 -5
  3. package/__tests__/device-identity.test.ts +24 -2
  4. package/__tests__/device-lifecycle-events.test.ts +1 -1
  5. package/__tests__/device-settings.test.ts +111 -6
  6. package/__tests__/device-state-events.test.ts +3 -3
  7. package/__tests__/device-state-mapper.test.ts +15 -3
  8. package/__tests__/device-state-store.test.ts +1 -1
  9. package/__tests__/device-wallet-session-store.test.ts +111 -7
  10. package/__tests__/deviceSettings.test.ts +55 -15
  11. package/__tests__/evmLedgerLegacySafety.test.ts +6 -5
  12. package/__tests__/firmware-update/firmware-artifact-source.test.ts +119 -0
  13. package/__tests__/firmware-update/firmware-host-binding.test.ts +70 -0
  14. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +13 -0
  15. package/__tests__/firmware-update/firmware-update-plan.test.ts +326 -0
  16. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +200 -0
  17. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +39 -30
  18. package/__tests__/firmware-update/firmware-upload-source.test.ts +70 -0
  19. package/__tests__/get-device-state.test.ts +7 -0
  20. package/__tests__/open-wallet-session.test.ts +253 -13
  21. package/__tests__/pro2Wallpaper.test.ts +7 -18
  22. package/__tests__/protocol-v2-unlock-policy.test.ts +45 -0
  23. package/__tests__/protocol-v2.test.ts +439 -282
  24. package/__tests__/public-device-state-api.test.ts +79 -68
  25. package/__tests__/public-pro2-api-boundary.test.ts +22 -29
  26. package/dist/api/BaseMethod.d.ts +2 -4
  27. package/dist/api/BaseMethod.d.ts.map +1 -1
  28. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  29. package/dist/api/ClearSessionCache.d.ts.map +1 -1
  30. package/dist/api/FirmwareUpdate.d.ts.map +1 -1
  31. package/dist/api/FirmwareUpdateV2.d.ts +11 -0
  32. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  33. package/dist/api/FirmwareUpdateV3.d.ts +7 -3
  34. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  35. package/dist/api/FirmwareUpdateV4.d.ts +29 -10
  36. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  37. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  38. package/dist/api/UploadPortfolio.d.ts +0 -1
  39. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  40. package/dist/api/conflux/ConfluxGetAddress.d.ts +1 -0
  41. package/dist/api/conflux/ConfluxGetAddress.d.ts.map +1 -1
  42. package/dist/api/conflux/ConfluxSignMessage.d.ts +1 -0
  43. package/dist/api/conflux/ConfluxSignMessage.d.ts.map +1 -1
  44. package/dist/api/conflux/ConfluxSignMessageCIP23.d.ts +1 -0
  45. package/dist/api/conflux/ConfluxSignMessageCIP23.d.ts.map +1 -1
  46. package/dist/api/conflux/ConfluxSignTransaction.d.ts +1 -0
  47. package/dist/api/conflux/ConfluxSignTransaction.d.ts.map +1 -1
  48. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  49. package/dist/api/device/DeviceUpdateBootloader.d.ts +5 -1
  50. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  51. package/dist/api/firmware/FirmwareArtifactSource.d.ts +26 -0
  52. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -0
  53. package/dist/api/firmware/FirmwareHostBinding.d.ts +6 -0
  54. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -0
  55. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +7 -0
  56. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  57. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +3 -0
  58. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +1 -0
  59. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +24 -0
  60. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -0
  61. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +25 -0
  62. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -0
  63. package/dist/api/firmware/getBinary.d.ts +7 -0
  64. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  65. package/dist/api/firmware/updateBootloader.d.ts +2 -0
  66. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  67. package/dist/api/firmware/uploadFirmware.d.ts +9 -1
  68. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  69. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  70. package/dist/api/index.d.ts +0 -20
  71. package/dist/api/index.d.ts.map +1 -1
  72. package/dist/api/nexa/NexaSignTransaction.d.ts +1 -0
  73. package/dist/api/nexa/NexaSignTransaction.d.ts.map +1 -1
  74. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  75. package/dist/core/RequestQueue.d.ts +1 -19
  76. package/dist/core/RequestQueue.d.ts.map +1 -1
  77. package/dist/core/index.d.ts +0 -1
  78. package/dist/core/index.d.ts.map +1 -1
  79. package/dist/data-manager/DataManager.d.ts +1 -0
  80. package/dist/data-manager/DataManager.d.ts.map +1 -1
  81. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  82. package/dist/device/Device.d.ts +1 -1
  83. package/dist/device/Device.d.ts.map +1 -1
  84. package/dist/device/DeviceCommands.d.ts.map +1 -1
  85. package/dist/device/DeviceFeaturesState.d.ts +0 -1
  86. package/dist/device/DeviceFeaturesState.d.ts.map +1 -1
  87. package/dist/device/DeviceStateMapper.d.ts.map +1 -1
  88. package/dist/device/DeviceStateProjector.d.ts.map +1 -1
  89. package/dist/deviceProfile/buildDeviceFeatures.d.ts.map +1 -1
  90. package/dist/events/call.d.ts +0 -7
  91. package/dist/events/call.d.ts.map +1 -1
  92. package/dist/events/core.d.ts +2 -2
  93. package/dist/events/core.d.ts.map +1 -1
  94. package/dist/events/iframe.d.ts +0 -1
  95. package/dist/events/iframe.d.ts.map +1 -1
  96. package/dist/events/logBlockEvent.d.ts.map +1 -1
  97. package/dist/index.d.ts +320 -277
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +3210 -1856
  100. package/dist/inject.d.ts +2 -3
  101. package/dist/inject.d.ts.map +1 -1
  102. package/dist/lowLevelInject.d.ts +1 -2
  103. package/dist/lowLevelInject.d.ts.map +1 -1
  104. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts.map +1 -1
  105. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +4 -0
  106. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -0
  107. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -1
  108. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  109. package/dist/topLevelInject.d.ts.map +1 -1
  110. package/dist/types/api/checkAllFirmwareRelease.d.ts +3 -0
  111. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  112. package/dist/types/api/deviceSettings.d.ts +2 -2
  113. package/dist/types/api/deviceSettings.d.ts.map +1 -1
  114. package/dist/types/api/deviceUpdateBootloader.d.ts +6 -0
  115. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  116. package/dist/types/api/export.d.ts +4 -1
  117. package/dist/types/api/export.d.ts.map +1 -1
  118. package/dist/types/api/firmwareUpdate.d.ts +69 -0
  119. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  120. package/dist/types/api/firmwareUpdateCapabilities.d.ts +9 -0
  121. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +1 -0
  122. package/dist/types/api/firmwareUpdatePlan.d.ts +27 -0
  123. package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -0
  124. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +42 -0
  125. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +1 -0
  126. package/dist/types/api/index.d.ts +10 -23
  127. package/dist/types/api/index.d.ts.map +1 -1
  128. package/dist/types/api/openWalletSession.d.ts +1 -0
  129. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  130. package/dist/types/api/protocolV2.d.ts +3 -84
  131. package/dist/types/api/protocolV2.d.ts.map +1 -1
  132. package/dist/types/api/sessionCache.d.ts +4 -1
  133. package/dist/types/api/sessionCache.d.ts.map +1 -1
  134. package/dist/types/device.d.ts +4 -4
  135. package/dist/types/device.d.ts.map +1 -1
  136. package/dist/types/settings.d.ts +13 -0
  137. package/dist/types/settings.d.ts.map +1 -1
  138. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  139. package/dist/utils/deviceSettings.d.ts +36 -7
  140. package/dist/utils/deviceSettings.d.ts.map +1 -1
  141. package/dist/utils/index.d.ts +2 -1
  142. package/dist/utils/index.d.ts.map +1 -1
  143. package/dist/utils/patch.d.ts +1 -1
  144. package/dist/utils/patch.d.ts.map +1 -1
  145. package/package.json +4 -4
  146. package/src/api/BaseMethod.ts +11 -12
  147. package/src/api/CheckAllFirmwareRelease.ts +16 -3
  148. package/src/api/ClearSessionCache.ts +4 -0
  149. package/src/api/FirmwareUpdate.ts +8 -1
  150. package/src/api/FirmwareUpdateV2.ts +284 -119
  151. package/src/api/FirmwareUpdateV3.ts +248 -55
  152. package/src/api/FirmwareUpdateV4.ts +816 -328
  153. package/src/api/OpenWalletSession.ts +74 -25
  154. package/src/api/UploadPortfolio.ts +3 -13
  155. package/src/api/conflux/ConfluxGetAddress.ts +2 -0
  156. package/src/api/conflux/ConfluxSignMessage.ts +2 -0
  157. package/src/api/conflux/ConfluxSignMessageCIP23.ts +2 -0
  158. package/src/api/conflux/ConfluxSignTransaction.ts +2 -0
  159. package/src/api/device/DeviceChangePin.ts +1 -1
  160. package/src/api/device/DeviceSettings.ts +66 -5
  161. package/src/api/device/DeviceUpdateBootloader.ts +114 -6
  162. package/src/api/device/DeviceWipe.ts +1 -1
  163. package/src/api/firmware/FirmwareArtifactSource.ts +278 -0
  164. package/src/api/firmware/FirmwareHostBinding.ts +100 -0
  165. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +50 -0
  166. package/src/api/firmware/FirmwareUpdateCapabilities.ts +9 -0
  167. package/src/api/firmware/FirmwareUpdatePlan.ts +621 -0
  168. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +378 -0
  169. package/src/api/firmware/updateBootloader.ts +19 -4
  170. package/src/api/firmware/uploadFirmware.ts +140 -22
  171. package/src/api/helpers/protocolV2FileWrite.ts +6 -0
  172. package/src/api/index.ts +0 -21
  173. package/src/api/nexa/NexaSignTransaction.ts +2 -0
  174. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +1 -1
  175. package/src/core/RequestQueue.ts +4 -123
  176. package/src/core/index.ts +25 -31
  177. package/src/data/messages/messages-protocol-v2.json +77 -4
  178. package/src/data-manager/DataManager.ts +33 -33
  179. package/src/data-manager/connectSettings.ts +11 -0
  180. package/src/device/Device.ts +51 -61
  181. package/src/device/DeviceCommands.ts +58 -14
  182. package/src/device/DeviceFeaturesState.ts +0 -9
  183. package/src/device/DeviceStateMapper.ts +26 -8
  184. package/src/device/DeviceStateProjector.ts +7 -1
  185. package/src/deviceProfile/buildDeviceFeatures.ts +2 -1
  186. package/src/events/call.ts +0 -6
  187. package/src/events/core.ts +0 -2
  188. package/src/events/iframe.ts +0 -1
  189. package/src/events/logBlockEvent.ts +1 -0
  190. package/src/index.ts +5 -4
  191. package/src/inject.ts +22 -35
  192. package/src/lowLevelInject.ts +5 -4
  193. package/src/protocols/protocol-v2/deviceSession.ts +1 -1
  194. package/src/protocols/protocol-v2/settingsUnlockPolicy.ts +2 -2
  195. package/src/protocols/protocol-v2/unlockPolicy.ts +105 -0
  196. package/src/protocols/protocol-v2/unlockRetry.ts +1 -3
  197. package/src/protocols/protocol-v2/walletSession.ts +68 -12
  198. package/src/topLevelInject.ts +5 -3
  199. package/src/types/api/checkAllFirmwareRelease.ts +3 -0
  200. package/src/types/api/deviceSettings.ts +17 -2
  201. package/src/types/api/deviceUpdateBootloader.ts +6 -0
  202. package/src/types/api/export.ts +18 -0
  203. package/src/types/api/firmwareUpdate.ts +76 -0
  204. package/src/types/api/firmwareUpdateCapabilities.ts +9 -0
  205. package/src/types/api/firmwareUpdatePlan.ts +36 -0
  206. package/src/types/api/firmwareUpdatePreparedPlan.ts +53 -0
  207. package/src/types/api/index.ts +14 -41
  208. package/src/types/api/openWalletSession.ts +2 -0
  209. package/src/types/api/protocolV2.ts +3 -187
  210. package/src/types/api/sessionCache.ts +9 -4
  211. package/src/types/device.ts +5 -3
  212. package/src/types/settings.ts +13 -0
  213. package/src/utils/deviceFeaturesUtils.ts +5 -1
  214. package/src/utils/deviceSettings.ts +130 -56
  215. package/src/utils/index.ts +18 -1
  216. package/__tests__/RequestQueue.test.ts +0 -140
  217. package/__tests__/UploadPortfolio.test.ts +0 -46
  218. package/dist/api/protocol-v2/DeviceSettingsGet.d.ts +0 -6
  219. package/dist/api/protocol-v2/DeviceSettingsGet.d.ts.map +0 -1
  220. package/dist/api/protocol-v2/DeviceSettingsPageShow.d.ts +0 -16
  221. package/dist/api/protocol-v2/DeviceSettingsPageShow.d.ts.map +0 -1
  222. package/dist/api/protocol-v2/DeviceSettingsSet.d.ts +0 -12
  223. package/dist/api/protocol-v2/DeviceSettingsSet.d.ts.map +0 -1
  224. package/src/api/protocol-v2/DeviceSettingsGet.ts +0 -18
  225. package/src/api/protocol-v2/DeviceSettingsPageShow.ts +0 -70
  226. package/src/api/protocol-v2/DeviceSettingsSet.ts +0 -44
@@ -1,4 +1,4 @@
1
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { EFirmwareType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
2
  import { DeviceRebootType, DeviceSettingsPage } from '@onekeyfe/hd-transport';
3
3
 
4
4
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
@@ -21,9 +21,6 @@ import DeviceLock from '../src/api/device/DeviceLock';
21
21
  import DeviceSettings from '../src/api/device/DeviceSettings';
22
22
  import DeviceUnlock from '../src/api/device/DeviceUnlock';
23
23
  import DeviceWipe from '../src/api/device/DeviceWipe';
24
- import DeviceSettingsGet from '../src/api/protocol-v2/DeviceSettingsGet';
25
- import DeviceSettingsPageShow from '../src/api/protocol-v2/DeviceSettingsPageShow';
26
- import DeviceSettingsSet from '../src/api/protocol-v2/DeviceSettingsSet';
27
24
  import DeviceUploadWallpaper from '../src/api/protocol-v2/DeviceUploadWallpaper';
28
25
  import DeviceStatusGet from '../src/api/protocol-v2/DeviceStatusGet';
29
26
  import FilesystemFormat from '../src/api/protocol-v2/FilesystemFormat';
@@ -86,6 +83,7 @@ import {
86
83
  isMethodVersionRangeUnsupported,
87
84
  } from '../src/utils';
88
85
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
86
+ import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
89
87
  import {
90
88
  getPassphraseState,
91
89
  getPassphraseStateWithRefreshDeviceInfo,
@@ -125,7 +123,7 @@ describe('DeviceUploadWallpaper', () => {
125
123
  const result = await method.run();
126
124
 
127
125
  expect(method.requireProtocolV2).toBe(true);
128
- expect(method.unlockPolicy).toBe('retry-on-locked');
126
+ expect(method.unlockPolicy).toBe('unlock-before-run');
129
127
  expect(typedCall).toHaveBeenNthCalledWith(1, 'FilesystemDirMake', 'Success', {
130
128
  path: 'vol1:/wallpapers',
131
129
  });
@@ -214,9 +212,8 @@ describe('UploadPortfolio', () => {
214
212
  method.init();
215
213
  const result = await method.run();
216
214
 
217
- expect(method.unlockPolicy).toBe('retry-on-locked');
215
+ expect(method.unlockPolicy).toBe('unlock-before-run');
218
216
  expect(method.protocolV2UiMode).toBe('none');
219
- expect(method.executionPriority).toBe('background');
220
217
  expect(method.protocolV2UiInteraction).toBeUndefined();
221
218
  expect(method.payload.emitProgress).toBe(false);
222
219
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -234,15 +231,9 @@ describe('UploadPortfolio', () => {
234
231
  append: false,
235
232
  ui_percentage: 100,
236
233
  },
237
- { timeoutMs: 5_000 }
238
- );
239
- expect(typedCall).toHaveBeenNthCalledWith(
240
- 2,
241
- 'PortfolioUpdate',
242
- 'Success',
243
- {},
244
- { timeoutMs: 5_000 }
234
+ { timeoutMs: undefined }
245
235
  );
236
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'PortfolioUpdate', 'Success', {});
246
237
  expect(method.postMessage).not.toHaveBeenCalled();
247
238
  expect(result).toMatchObject({
248
239
  path: 'vol1:/portfolio/portfolio.okpkg.pending',
@@ -280,7 +271,6 @@ describe('UploadPortfolio', () => {
280
271
  payload: {
281
272
  method: 'uploadPortfolio',
282
273
  packageBytes,
283
- operationId: 'portfolio-1',
284
274
  },
285
275
  });
286
276
  method.abortSignal = abortController.signal;
@@ -722,7 +712,9 @@ describe('Protocol V2 feature adapter', () => {
722
712
  unlockedAttachPin: false,
723
713
  resumed: false,
724
714
  });
725
- expect(typedCall).not.toHaveBeenCalled();
715
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
716
+ eventless_wallet_session: true,
717
+ });
726
718
  expect(device.getInternalState()).toBeUndefined();
727
719
  });
728
720
 
@@ -732,7 +724,13 @@ describe('Protocol V2 feature adapter', () => {
732
724
  { ...descriptor, protocolType: 'V2' } as any,
733
725
  { status: { passphrase_enabled: false, unlocked: true } }
734
726
  );
735
- const typedCall = jest.fn();
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
734
  const promptPassphrase = jest
737
735
  .fn()
738
736
  .mockResolvedValue({ passphrase: 'hidden-wallet-with-disabled-flag' });
@@ -746,7 +744,9 @@ describe('Protocol V2 feature adapter', () => {
746
744
  });
747
745
 
748
746
  expect(promptPassphrase).not.toHaveBeenCalled();
749
- expect(typedCall).not.toHaveBeenCalled();
747
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
748
+ eventless_wallet_session: true,
749
+ });
750
750
  });
751
751
 
752
752
  test('preserves cached settings only while Protocol V2 physical identity stays the same', () => {
@@ -801,13 +801,8 @@ describe('Protocol V2 feature adapter', () => {
801
801
  }
802
802
  );
803
803
  const typedCall = jest.fn().mockImplementation((request: string) => {
804
- if (request === 'DeviceSessionGet') {
805
- return Promise.resolve({
806
- message: {
807
- btc_test_address: 'state-after-unlock',
808
- session_id: 'session-after-unlock',
809
- },
810
- });
804
+ if (request === 'ProtocolInfoRequest') {
805
+ return Promise.resolve({ message: { version: 2 } });
811
806
  }
812
807
  if (request === 'DeviceStatusGet') {
813
808
  return Promise.resolve({
@@ -836,7 +831,9 @@ describe('Protocol V2 feature adapter', () => {
836
831
 
837
832
  expect(unlockDevice).toHaveBeenCalledTimes(1);
838
833
  expect(promptPassphrase).not.toHaveBeenCalled();
839
- expect(typedCall).not.toHaveBeenCalled();
834
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
835
+ eventless_wallet_session: true,
836
+ });
840
837
  });
841
838
 
842
839
  test('onlyMainPin takes precedence over a cached hidden-wallet state', async () => {
@@ -847,14 +844,28 @@ describe('Protocol V2 feature adapter', () => {
847
844
  );
848
845
  device.passphraseState = 'hidden-state';
849
846
  preloadSessionCache('main-wallet-device', 'hidden-state', 'hidden-session');
850
- const typedCall = jest.fn();
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
+ });
851
854
  (device as any).commands = { typedCall };
852
855
 
853
- await getPassphraseState(device, {
854
- onlyMainPin: true,
856
+ await expect(
857
+ getPassphraseState(device, {
858
+ onlyMainPin: true,
859
+ })
860
+ ).resolves.toMatchObject({
861
+ passphraseState: undefined,
862
+ newSession: undefined,
855
863
  });
856
- expect(typedCall).not.toHaveBeenCalled();
857
- expect(device.getInternalState()).toBe('hidden-session');
864
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
865
+ eventless_wallet_session: true,
866
+ });
867
+ expect(device.passphraseState).toBeUndefined();
868
+ expect(device.getInternalState()).toBeUndefined();
858
869
  });
859
870
 
860
871
  test('gets the wallet session selected by the Protocol V2 device', async () => {
@@ -878,7 +889,7 @@ describe('Protocol V2 feature adapter', () => {
878
889
  type: 'DeviceSession',
879
890
  message: { session_id: 'hidden-session', btc_test_address: 'hidden-state' },
880
891
  });
881
- const promptPassphrase = jest.fn();
892
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'hidden secret' });
882
893
  (device as any).commands = { typedCall, promptPassphrase };
883
894
 
884
895
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
@@ -886,8 +897,10 @@ describe('Protocol V2 feature adapter', () => {
886
897
  passphraseState: 'hidden-state',
887
898
  });
888
899
 
889
- expect(promptPassphrase).not.toHaveBeenCalled();
890
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
900
+ expect(promptPassphrase).toHaveBeenCalled();
901
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
902
+ select: { host_passphrase: { passphrase: 'hidden secret' } },
903
+ });
891
904
  });
892
905
 
893
906
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -991,8 +1004,8 @@ describe('Protocol V2 feature adapter', () => {
991
1004
  newSession: 'session-b',
992
1005
  });
993
1006
 
994
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
995
- session_id: 'session-a',
1007
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
1008
+ resume: { session_id: 'session-a' },
996
1009
  });
997
1010
  expect(device.getInternalState()).toBe('session-b');
998
1011
  });
@@ -1010,13 +1023,18 @@ describe('Protocol V2 feature adapter', () => {
1010
1023
  preloadSessionCache('stable-device-2', 'state-a', 'session-a');
1011
1024
  const typedCall = jest.fn().mockResolvedValue({
1012
1025
  type: 'DeviceSession',
1013
- message: { session_id: 'main-session' },
1026
+ message: {
1027
+ session_id: 'main-session',
1028
+ btc_test_address: 'main-wallet-state',
1029
+ },
1014
1030
  });
1015
1031
  (device as any).commands = { typedCall };
1016
1032
 
1017
1033
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
1018
1034
 
1019
- expect(typedCall).not.toHaveBeenCalled();
1035
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1036
+ eventless_wallet_session: true,
1037
+ });
1020
1038
  expect(device.getInternalState()).toBeUndefined();
1021
1039
  });
1022
1040
 
@@ -1038,11 +1056,14 @@ describe('Protocol V2 feature adapter', () => {
1038
1056
  );
1039
1057
  device.passphraseState = 'state-a';
1040
1058
  preloadSessionCache('stable-device-3', 'state-a', 'session-a');
1041
- const typedCall = jest.fn().mockRejectedValue(
1042
- Object.assign(new Error('SE request failed'), {
1043
- errorCode: HardwareErrorCode.RuntimeError,
1044
- })
1045
- );
1059
+ const typedCall = jest
1060
+ .fn()
1061
+ .mockResolvedValueOnce({ message: { version: 2 } })
1062
+ .mockRejectedValue(
1063
+ Object.assign(new Error('SE request failed'), {
1064
+ errorCode: HardwareErrorCode.RuntimeError,
1065
+ })
1066
+ );
1046
1067
  const promptPassphrase = jest.fn();
1047
1068
  (device as any).commands = { typedCall, promptPassphrase };
1048
1069
 
@@ -1050,7 +1071,8 @@ describe('Protocol V2 feature adapter', () => {
1050
1071
  errorCode: HardwareErrorCode.RuntimeError,
1051
1072
  });
1052
1073
  expect(typedCall.mock.calls).toEqual([
1053
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1074
+ ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1075
+ ['DeviceSessionOpen', 'DeviceSession', { resume: { session_id: 'session-a' } }],
1054
1076
  ]);
1055
1077
  expect(promptPassphrase).not.toHaveBeenCalled();
1056
1078
  expect(device.getInternalState()).toBeUndefined();
@@ -1072,11 +1094,14 @@ describe('Protocol V2 feature adapter', () => {
1072
1094
  },
1073
1095
  }
1074
1096
  );
1075
- const typedCall = jest.fn().mockRejectedValue(
1076
- Object.assign(new Error('Invalid session'), {
1077
- errorCode: HardwareErrorCode.WalletSessionInvalid,
1078
- })
1079
- );
1097
+ const typedCall = jest
1098
+ .fn()
1099
+ .mockResolvedValueOnce({ message: { version: 2 } })
1100
+ .mockRejectedValue(
1101
+ Object.assign(new Error('Invalid session'), {
1102
+ errorCode: HardwareErrorCode.WalletSessionInvalid,
1103
+ })
1104
+ );
1080
1105
  (device as any).commands = {
1081
1106
  typedCall,
1082
1107
  promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'new-secret' }),
@@ -1085,7 +1110,14 @@ describe('Protocol V2 feature adapter', () => {
1085
1110
  await expect(getProtocolV2WalletSession(device)).rejects.toMatchObject({
1086
1111
  errorCode: HardwareErrorCode.WalletSessionInvalid,
1087
1112
  });
1088
- expect(typedCall.mock.calls).toEqual([['DeviceSessionGet', 'DeviceSession', {}]]);
1113
+ expect(typedCall.mock.calls).toEqual([
1114
+ ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1115
+ [
1116
+ 'DeviceSessionOpen',
1117
+ 'DeviceSession',
1118
+ { select: { host_passphrase: { passphrase: 'new-secret' } } },
1119
+ ],
1120
+ ]);
1089
1121
  });
1090
1122
 
1091
1123
  test('rejects a mismatched Pro2 wallet state and clears the selected cache entry', async () => {
@@ -1123,7 +1155,7 @@ describe('Protocol V2 feature adapter', () => {
1123
1155
  expect(device.getInternalState()).toBeUndefined();
1124
1156
  });
1125
1157
 
1126
- test('unlocks and retries DeviceSessionGet when wallet selection is locked', async () => {
1158
+ test('unlocks and retries DeviceSessionOpen when wallet selection is locked', async () => {
1127
1159
  const device = Device.fromDescriptor({
1128
1160
  id: 'cache-device-4',
1129
1161
  path: 'cache-path-4',
@@ -1138,6 +1170,7 @@ describe('Protocol V2 feature adapter', () => {
1138
1170
  });
1139
1171
  const typedCall = jest
1140
1172
  .fn()
1173
+ .mockResolvedValueOnce({ message: { version: 2 } })
1141
1174
  .mockRejectedValueOnce(lockedError)
1142
1175
  .mockResolvedValueOnce({
1143
1176
  message: {
@@ -1156,7 +1189,7 @@ describe('Protocol V2 feature adapter', () => {
1156
1189
  passphraseState: 'state-after-unlock',
1157
1190
  });
1158
1191
  expect(unlockDevice).toHaveBeenCalledTimes(1);
1159
- expect(typedCall).toHaveBeenCalledTimes(2);
1192
+ expect(typedCall).toHaveBeenCalledTimes(3);
1160
1193
  });
1161
1194
 
1162
1195
  test('does not request a Pro2 wallet session before features are initialized', async () => {
@@ -1273,6 +1306,7 @@ describe('Protocol V2 feature adapter', () => {
1273
1306
  promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'state-pro2-secret' }),
1274
1307
  },
1275
1308
  updateInternalState,
1309
+ getInternalState: () => 'feature-session',
1276
1310
  getCurrentDeviceId: () => 'pro-device-id',
1277
1311
  getCurrentPassphraseProtection: () => true,
1278
1312
  }) as any;
@@ -1483,7 +1517,9 @@ describe('Protocol V2 feature adapter', () => {
1483
1517
  expect(device.getInternalState()).toBeUndefined();
1484
1518
  device.passphraseState = 'state-auto';
1485
1519
  expect(device.getInternalState()).toBe('session-auto');
1486
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
1520
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionOpen', 'DeviceSession', {
1521
+ select: { host_passphrase: { passphrase: 'state-auto-secret' } },
1522
+ });
1487
1523
  });
1488
1524
 
1489
1525
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -1492,6 +1528,7 @@ describe('Protocol V2 feature adapter', () => {
1492
1528
  type: 'DeviceSession',
1493
1529
  message: {
1494
1530
  session_id: 'main-pin-session',
1531
+ btc_test_address: 'main-wallet-state',
1495
1532
  },
1496
1533
  });
1497
1534
 
@@ -1521,7 +1558,9 @@ describe('Protocol V2 feature adapter', () => {
1521
1558
  expect(device.features?.passphraseProtection).toBe(false);
1522
1559
  expect(device.features?.sessionId).toBeNull();
1523
1560
  expect(device.getInternalState()).toBeUndefined();
1524
- expect(typedCall).not.toHaveBeenCalled();
1561
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1562
+ eventless_wallet_session: true,
1563
+ });
1525
1564
  });
1526
1565
 
1527
1566
  test('does not let skipPassphraseCheck hide Pro2 passphrase state mismatch', async () => {
@@ -1553,12 +1592,20 @@ describe('Protocol V2 feature adapter', () => {
1553
1592
  );
1554
1593
 
1555
1594
  expect(device.getInternalState()).toBeUndefined();
1556
- expect(typedCall).toHaveBeenNthCalledWith(1, 'DeviceSessionGet', 'DeviceSession', {});
1595
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionOpen', 'DeviceSession', {
1596
+ select: { host_passphrase: { passphrase: 'expected-secret' } },
1597
+ });
1557
1598
  });
1558
1599
 
1559
1600
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
1560
1601
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1561
- const typedCall = jest.fn();
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
+ });
1562
1609
 
1563
1610
  (device as any).features = normalizeProtocolV2Features(
1564
1611
  { ...descriptor, protocolType: 'V2' } as any,
@@ -1578,7 +1625,10 @@ describe('Protocol V2 feature adapter', () => {
1578
1625
  await expect(
1579
1626
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
1580
1627
  ).resolves.toBe(true);
1581
- expect(typedCall).not.toHaveBeenCalled();
1628
+ expect(typedCall).toHaveBeenCalledTimes(1);
1629
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1630
+ eventless_wallet_session: true,
1631
+ });
1582
1632
  });
1583
1633
 
1584
1634
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2237,7 +2287,7 @@ describe('Protocol V2 feature adapter', () => {
2237
2287
  ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
2238
2288
  ]);
2239
2289
  expect(typedCall).not.toHaveBeenCalledWith(
2240
- 'DeviceSessionGet',
2290
+ 'DeviceSessionOpen',
2241
2291
  'DeviceSession',
2242
2292
  expect.anything()
2243
2293
  );
@@ -2680,6 +2730,7 @@ describe('Protocol V2 firmware update targets', () => {
2680
2730
  return Promise.resolve({
2681
2731
  type: 'DeviceInfo',
2682
2732
  message: {
2733
+ hw: { serial_no: 'BLE-PRO2-SERIAL' },
2683
2734
  fw: {
2684
2735
  bootloader: { version: '0.0.0' },
2685
2736
  application: { version: '0.0.0' },
@@ -2700,6 +2751,7 @@ describe('Protocol V2 firmware update targets', () => {
2700
2751
  });
2701
2752
  const commands = { typedCall };
2702
2753
 
2754
+ (method as any).protocolV2ExpectedSerialNumber = 'BLE-PRO2-SERIAL';
2703
2755
  (method as any).isBleReconnect = jest.fn(() => true);
2704
2756
  (method as any).device = stubDevice({
2705
2757
  originalDescriptor: { id: 'ble-id', path: 'ble-path', protocolType: 'V2' },
@@ -2748,7 +2800,7 @@ describe('Protocol V2 firmware update targets', () => {
2748
2800
  });
2749
2801
  });
2750
2802
 
2751
- test('enters Protocol V2 bootloader before upload', async () => {
2803
+ test('delegates Protocol V2 mode transition to the phase executor', async () => {
2752
2804
  const method = new FirmwareUpdateV4({
2753
2805
  id: 1,
2754
2806
  payload: {
@@ -2761,6 +2813,7 @@ describe('Protocol V2 firmware update targets', () => {
2761
2813
  originalDescriptor: { id: 'ble-id', path: 'ble-path', protocolType: 'V2' },
2762
2814
  features: { capabilities: [] },
2763
2815
  });
2816
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
2764
2817
  (method as any).prepareBootloaderBinary = jest.fn().mockReturnValue(null);
2765
2818
  (method as any).collectExplicitTargetBinaries = jest.fn().mockReturnValue([
2766
2819
  {
@@ -2782,7 +2835,7 @@ describe('Protocol V2 firmware update targets', () => {
2782
2835
 
2783
2836
  await method.run();
2784
2837
 
2785
- expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
2838
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
2786
2839
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
2787
2840
  fwBinaryMap: [
2788
2841
  {
@@ -2826,6 +2879,7 @@ describe('Protocol V2 firmware update targets', () => {
2826
2879
  });
2827
2880
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
2828
2881
  (method as any).device.getCommands = () => ({ typedCall });
2882
+ (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
2829
2883
  method.postTipMessage = jest.fn();
2830
2884
 
2831
2885
  await (method as any).enterProtocolV2BootloaderMode();
@@ -2891,7 +2945,7 @@ describe('Protocol V2 firmware update targets', () => {
2891
2945
  });
2892
2946
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
2893
2947
  (method as any).device.getCommands = () => ({ typedCall });
2894
- (method as any).protocolV2ExpectedDeviceId = '8693920D7CA90CEC4A88353D';
2948
+ (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
2895
2949
 
2896
2950
  await (method as any).waitForProtocolV2BootloaderMode(60 * 1000, 0);
2897
2951
 
@@ -3010,7 +3064,7 @@ describe('Protocol V2 firmware update targets', () => {
3010
3064
  fw: { application: { version: '1.0.0' } },
3011
3065
  },
3012
3066
  });
3013
- (method as any).protocolV2ExpectedDeviceId = 'expected-device';
3067
+ (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
3014
3068
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3015
3069
  (method as any).device = stubDevice({
3016
3070
  getCommands: () => ({ typedCall }),
@@ -3036,6 +3090,7 @@ describe('Protocol V2 firmware update targets', () => {
3036
3090
  });
3037
3091
  method.postTipMessage = jest.fn();
3038
3092
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3093
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
3039
3094
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
3040
3095
  message: 'Device rebooted successfully',
3041
3096
  });
@@ -3099,9 +3154,9 @@ describe('Protocol V2 firmware update targets', () => {
3099
3154
  const typedCall = jest
3100
3155
  .fn()
3101
3156
  .mockRejectedValue(
3102
- new DOMException(
3103
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
3104
- 'NotFoundError'
3157
+ Object.assign(
3158
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
3159
+ { name: 'NotFoundError' }
3105
3160
  )
3106
3161
  );
3107
3162
 
@@ -3172,6 +3227,7 @@ describe('Protocol V2 firmware update targets', () => {
3172
3227
  getCommands: () => ({ typedCall }),
3173
3228
  });
3174
3229
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3230
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
3175
3231
  method.postProgressMessage = jest.fn();
3176
3232
 
3177
3233
  await (method as any).waitForProtocolV2FirmwareUpdateComplete([
@@ -3188,7 +3244,7 @@ describe('Protocol V2 firmware update targets', () => {
3188
3244
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
3189
3245
  });
3190
3246
 
3191
- test('accepts a missing firmware status handler as an app reboot signal', async () => {
3247
+ test('does not treat a missing firmware status handler as install completion', async () => {
3192
3248
  const method = new FirmwareUpdateV4({
3193
3249
  id: 1,
3194
3250
  payload: {
@@ -3204,14 +3260,42 @@ describe('Protocol V2 firmware update targets', () => {
3204
3260
  getCommands: () => ({ typedCall }),
3205
3261
  });
3206
3262
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3263
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
3207
3264
  method.postProgressMessage = jest.fn();
3208
3265
 
3209
- await (method as any).waitForProtocolV2FirmwareUpdateComplete([
3210
- { target_id: 4, path: 'vol0:/application_p1.bin' },
3211
- ]);
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');
3212
3271
 
3213
3272
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3214
- expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
3273
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3274
+ 'DeviceFirmwareUpdateStatusGet',
3275
+ 'DeviceInfoGet',
3276
+ ]);
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
+
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');
3215
3299
  });
3216
3300
 
3217
3301
  test('polls target status after update ACK and finishes when all targets complete', async () => {
@@ -3236,6 +3320,7 @@ describe('Protocol V2 firmware update targets', () => {
3236
3320
  getCommands: () => ({ typedCall }),
3237
3321
  });
3238
3322
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3323
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
3239
3324
  method.postProgressMessage = jest.fn();
3240
3325
 
3241
3326
  await expect(
@@ -3353,6 +3438,7 @@ describe('Protocol V2 firmware update targets', () => {
3353
3438
  getCommands: () => ({ typedCall }),
3354
3439
  });
3355
3440
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3441
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
3356
3442
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(false);
3357
3443
 
3358
3444
  await expect(
@@ -3388,10 +3474,14 @@ describe('Protocol V2 firmware update targets', () => {
3388
3474
  const writtenPaths: string[] = [];
3389
3475
  method.postTipMessage = jest.fn();
3390
3476
  method.postProgressMessage = jest.fn();
3391
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
3477
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
3392
3478
  writtenPaths.push(params.filePath);
3393
- return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
3479
+ return Number(params.processedSize ?? 0) + Number(params.source.size);
3394
3480
  });
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({});
3395
3485
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
3396
3486
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
3397
3487
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -3431,10 +3521,12 @@ describe('Protocol V2 firmware update targets', () => {
3431
3521
  'vol0:/coprocessor.bin',
3432
3522
  1
3433
3523
  );
3434
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
3435
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
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, {
3436
3529
  targets: [
3437
- { target_id: 3, path: 'vol0:/bootloader.bin' },
3438
3530
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
3439
3531
  { target_id: 7, path: 'vol0:/se01.bin' },
3440
3532
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -3444,7 +3536,7 @@ describe('Protocol V2 firmware update targets', () => {
3444
3536
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
3445
3537
  });
3446
3538
 
3447
- test('announces one transfer when syncing resource bundles and staging firmware', async () => {
3539
+ test('announces separate resource and component transfer phases', async () => {
3448
3540
  const method = new FirmwareUpdateV4({
3449
3541
  id: 1,
3450
3542
  payload: {
@@ -3454,11 +3546,14 @@ describe('Protocol V2 firmware update targets', () => {
3454
3546
 
3455
3547
  method.postTipMessage = jest.fn();
3456
3548
  method.postProgressMessage = jest.fn();
3457
- (method as any).protocolV2CommonUpdateProcess = jest
3549
+ (method as any).protocolV2SourceUpdateProcess = jest
3458
3550
  .fn()
3459
3551
  .mockImplementation(params =>
3460
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
3552
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
3461
3553
  );
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({});
3462
3557
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
3463
3558
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
3464
3559
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -3478,21 +3573,22 @@ describe('Protocol V2 firmware update targets', () => {
3478
3573
  {
3479
3574
  fileName: 'application_p1.bin',
3480
3575
  binary: new Uint8Array([3]).buffer,
3481
- targetId: 3,
3576
+ targetId: 4,
3482
3577
  },
3483
3578
  ],
3484
3579
  });
3485
3580
 
3486
- expect(method.postTipMessage).toHaveBeenCalledTimes(2);
3581
+ expect(method.postTipMessage).toHaveBeenCalledTimes(3);
3487
3582
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
3488
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
3489
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
3583
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
3584
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
3585
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
3490
3586
  1,
3491
- expect.objectContaining({ processedSize: 0, totalSize: 3 })
3587
+ expect.objectContaining({ processedSize: 0, totalSize: 2 })
3492
3588
  );
3493
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
3589
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
3494
3590
  2,
3495
- expect.objectContaining({ processedSize: 2, totalSize: 3 })
3591
+ expect.objectContaining({ processedSize: 0, totalSize: 1 })
3496
3592
  );
3497
3593
  });
3498
3594
 
@@ -3506,7 +3602,8 @@ describe('Protocol V2 firmware update targets', () => {
3506
3602
 
3507
3603
  method.postTipMessage = jest.fn();
3508
3604
  method.postProgressMessage = jest.fn();
3509
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
3605
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
3606
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
3510
3607
  (method as any).device = stubDevice({
3511
3608
  getCommands: () => ({
3512
3609
  typedCall: jest.fn().mockResolvedValue({
@@ -3552,11 +3649,14 @@ describe('Protocol V2 firmware update targets', () => {
3552
3649
 
3553
3650
  method.postTipMessage = jest.fn();
3554
3651
  method.postProgressMessage = jest.fn();
3555
- (method as any).protocolV2CommonUpdateProcess = jest
3652
+ (method as any).protocolV2SourceUpdateProcess = jest
3556
3653
  .fn()
3557
3654
  .mockImplementation(params =>
3558
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
3655
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
3559
3656
  );
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({});
3560
3660
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
3561
3661
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
3562
3662
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -3736,7 +3836,7 @@ describe('Protocol V2 firmware update targets', () => {
3736
3836
  {
3737
3837
  name: 'images',
3738
3838
  url: 'https://example.com/images.okpkg',
3739
- devicePath: 'vol0:/resource/images/images.okpkg',
3839
+ devicePath: 'VOL0:/resource/images/images.okpkg',
3740
3840
  version: [2, 0, 0],
3741
3841
  payloadHash: 'ab'.repeat(64),
3742
3842
  headerHash: 'cd'.repeat(64),
@@ -3771,6 +3871,58 @@ describe('Protocol V2 firmware update targets', () => {
3771
3871
  getFirmwareLatestReleaseSpy.mockRestore();
3772
3872
  });
3773
3873
 
3874
+ test('rejects remote RESC bundle paths before download or bootloader entry', async () => {
3875
+ const method = new FirmwareUpdateV4({
3876
+ id: 1,
3877
+ payload: {
3878
+ method: 'firmwareUpdateV4',
3879
+ platform: 'web',
3880
+ targetsToUpdate: ['resource'],
3881
+ },
3882
+ });
3883
+ method.init();
3884
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3885
+
3886
+ const getSysResourceBinarySpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
3887
+ const getFirmwareLatestReleaseSpy = jest
3888
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
3889
+ .mockReturnValue({
3890
+ required: false,
3891
+ version: [1, 0, 0],
3892
+ url: '',
3893
+ resourceBundles: [
3894
+ {
3895
+ name: 'images',
3896
+ url: 'https://example.com/images.okpkg',
3897
+ devicePath: 'vol0:/resource/../images.okpkg',
3898
+ },
3899
+ ],
3900
+ fingerprint: '',
3901
+ changelog: {
3902
+ 'zh-CN': '',
3903
+ 'en-US': '',
3904
+ },
3905
+ });
3906
+
3907
+ (method as any).device = stubDevice({
3908
+ originalDescriptor: { protocolType: 'V2' },
3909
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
3910
+ });
3911
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
3912
+ bootloaderBinary: null,
3913
+ fwBinaryMap: [],
3914
+ });
3915
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
3916
+ method.postTipMessage = jest.fn();
3917
+
3918
+ await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
3919
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
3920
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3921
+
3922
+ getSysResourceBinarySpy.mockRestore();
3923
+ getFirmwareLatestReleaseSpy.mockRestore();
3924
+ });
3925
+
3774
3926
  test('downloads and validates remote RESC bundles before entering bootloader', async () => {
3775
3927
  const method = new FirmwareUpdateV4({
3776
3928
  id: 1,
@@ -3781,6 +3933,7 @@ describe('Protocol V2 firmware update targets', () => {
3781
3933
  },
3782
3934
  });
3783
3935
  method.init();
3936
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3784
3937
 
3785
3938
  const order: string[] = [];
3786
3939
  const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
@@ -3800,7 +3953,7 @@ describe('Protocol V2 firmware update targets', () => {
3800
3953
  {
3801
3954
  name: 'images',
3802
3955
  url: 'https://example.com/images.okpkg',
3803
- devicePath: 'vol0:/resource/images/images.okpkg',
3956
+ devicePath: ' VOL0:/resource/images/images.okpkg ',
3804
3957
  },
3805
3958
  ],
3806
3959
  fingerprint: '',
@@ -3822,7 +3975,9 @@ describe('Protocol V2 firmware update targets', () => {
3822
3975
  order.push('bootloader');
3823
3976
  return Promise.resolve(true);
3824
3977
  });
3825
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
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);
3826
3981
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3827
3982
  (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
3828
3983
  bootloaderVersion: '1.0.0',
@@ -3834,7 +3989,7 @@ describe('Protocol V2 firmware update targets', () => {
3834
3989
  await method.run();
3835
3990
 
3836
3991
  expect(order).toEqual(['download', 'bootloader']);
3837
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3992
+ expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
3838
3993
  bootloaderBinary: null,
3839
3994
  fwBinaryMap: [],
3840
3995
  resourceBundles: [
@@ -3847,6 +4002,7 @@ describe('Protocol V2 firmware update targets', () => {
3847
4002
  ],
3848
4003
  });
3849
4004
 
4005
+ executeProtocolV2UpdateSpy.mockRestore();
3850
4006
  getSysResourceBinarySpy.mockRestore();
3851
4007
  getFirmwareLatestReleaseSpy.mockRestore();
3852
4008
  });
@@ -3861,6 +4017,7 @@ describe('Protocol V2 firmware update targets', () => {
3861
4017
  },
3862
4018
  });
3863
4019
  method.init();
4020
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3864
4021
 
3865
4022
  const getSysResourceBinarySpy = jest
3866
4023
  .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
@@ -3972,6 +4129,7 @@ describe('Protocol V2 firmware update targets', () => {
3972
4129
  },
3973
4130
  });
3974
4131
  method.init();
4132
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3975
4133
 
3976
4134
  const remoteBinaries = {
3977
4135
  bootloaderBinary: new Uint8Array([1]).buffer,
@@ -4018,6 +4176,7 @@ describe('Protocol V2 firmware update targets', () => {
4018
4176
  },
4019
4177
  });
4020
4178
  method.init();
4179
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4021
4180
 
4022
4181
  (method as any).device = stubDevice({
4023
4182
  originalDescriptor: { protocolType: 'V2' },
@@ -4053,12 +4212,13 @@ describe('Protocol V2 firmware update targets', () => {
4053
4212
  resourceBundleFiles: [
4054
4213
  {
4055
4214
  binary: resourceBundle,
4056
- devicePath: 'vol0:/resource/images/images.okpkg',
4215
+ devicePath: ' VOL0:/resource/images/images.okpkg ',
4057
4216
  },
4058
4217
  ],
4059
4218
  },
4060
4219
  });
4061
4220
  method.init();
4221
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4062
4222
 
4063
4223
  (method as any).device = stubDevice({
4064
4224
  originalDescriptor: { protocolType: 'V2' },
@@ -4091,6 +4251,36 @@ describe('Protocol V2 firmware update targets', () => {
4091
4251
  );
4092
4252
  });
4093
4253
 
4254
+ test('rejects manual RESC bundle paths before bootloader entry', async () => {
4255
+ const method = new FirmwareUpdateV4({
4256
+ id: 1,
4257
+ payload: {
4258
+ method: 'firmwareUpdateV4',
4259
+ platform: 'web',
4260
+ resourceBundleFiles: [
4261
+ {
4262
+ binary: new Uint8Array([1, 2, 3]).buffer,
4263
+ devicePath: 'vol2:/resource/images/images.okpkg',
4264
+ },
4265
+ ],
4266
+ },
4267
+ });
4268
+ method.init();
4269
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4270
+
4271
+ (method as any).device = stubDevice({
4272
+ originalDescriptor: { protocolType: 'V2' },
4273
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
4274
+ });
4275
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
4276
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
4277
+ method.postTipMessage = jest.fn();
4278
+
4279
+ await expect(method.run()).rejects.toThrow('resourceBundleFiles[0].devicePath');
4280
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
4281
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
4282
+ });
4283
+
4094
4284
  test('syncs resource bundles without sending a firmware install request', async () => {
4095
4285
  const method = new FirmwareUpdateV4({
4096
4286
  id: 1,
@@ -4101,7 +4291,10 @@ describe('Protocol V2 firmware update targets', () => {
4101
4291
 
4102
4292
  method.postTipMessage = jest.fn();
4103
4293
  method.postProgressMessage = jest.fn();
4104
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
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);
4105
4298
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
4106
4299
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
4107
4300
 
@@ -4117,7 +4310,7 @@ describe('Protocol V2 firmware update targets', () => {
4117
4310
  fwBinaryMap: [],
4118
4311
  });
4119
4312
 
4120
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
4313
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
4121
4314
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
4122
4315
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
4123
4316
  });
@@ -4148,12 +4341,19 @@ describe('Protocol V2 firmware update targets', () => {
4148
4341
  });
4149
4342
  method.postProgressMessage = jest.fn();
4150
4343
 
4151
- await (method as any).protocolV2CommonUpdateProcess({
4152
- payload: new Uint8Array(4097).buffer,
4153
- filePath: 'vol1:firmware.bin',
4154
- processedSize: 0,
4155
- totalSize: 4097,
4344
+ const source = await openFirmwareByteSource({
4345
+ binary: new Uint8Array(4097).buffer,
4156
4346
  });
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
+ }
4157
4357
 
4158
4358
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
4159
4359
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -4204,12 +4404,16 @@ describe('Protocol V2 firmware update targets', () => {
4204
4404
  method.postProgressMessage = jest.fn();
4205
4405
 
4206
4406
  try {
4207
- await (method as any).protocolV2CommonUpdateProcess({
4208
- payload: new Uint8Array(8001).buffer,
4407
+ const source = await openFirmwareByteSource({
4408
+ binary: new Uint8Array(8001).buffer,
4409
+ });
4410
+ await (method as any).protocolV2SourceUpdateProcess({
4411
+ source,
4209
4412
  filePath: 'vol0:/firmware.bin',
4210
4413
  processedSize: 0,
4211
4414
  totalSize: 8001,
4212
4415
  });
4416
+ await source?.close();
4213
4417
  } finally {
4214
4418
  setTimeoutSpy.mockRestore();
4215
4419
  }
@@ -4241,16 +4445,21 @@ describe('Protocol V2 firmware update targets', () => {
4241
4445
  });
4242
4446
  method.postProgressMessage = jest.fn();
4243
4447
  method.postTipMessage = jest.fn();
4448
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
4244
4449
 
4450
+ const source = await openFirmwareByteSource({
4451
+ binary: new Uint8Array(4097).buffer,
4452
+ });
4245
4453
  await expect(
4246
- (method as any).protocolV2WriteWholeFile({
4247
- payload: new Uint8Array(4097).buffer,
4454
+ (method as any).protocolV2SourceUpdateProcess({
4455
+ source,
4248
4456
  filePath: 'vol0:/firmware.bin',
4249
4457
  processedSize: 0,
4250
4458
  totalSize: 4097,
4251
4459
  })
4252
4460
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
4253
- expect(typedCall).toHaveBeenCalledTimes(2);
4461
+ await source?.close();
4462
+ expect(typedCall).toHaveBeenCalledTimes(6);
4254
4463
  });
4255
4464
 
4256
4465
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -4283,12 +4492,19 @@ describe('Protocol V2 firmware update targets', () => {
4283
4492
  });
4284
4493
  method.postProgressMessage = jest.fn();
4285
4494
 
4286
- await (method as any).protocolV2CommonUpdateProcess({
4287
- payload: new Uint8Array(1801).buffer,
4288
- filePath: 'vol1:ble-firmware.bin',
4289
- processedSize: 0,
4290
- totalSize: 1801,
4495
+ const source = await openFirmwareByteSource({
4496
+ binary: new Uint8Array(1801).buffer,
4291
4497
  });
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
+ }
4292
4508
 
4293
4509
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
4294
4510
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
@@ -4405,9 +4621,9 @@ describe('Protocol V2 firmware update method', () => {
4405
4621
  const typedCall = jest
4406
4622
  .fn()
4407
4623
  .mockRejectedValue(
4408
- new DOMException(
4409
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4410
- 'NotFoundError'
4624
+ Object.assign(
4625
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4626
+ { name: 'NotFoundError' }
4411
4627
  )
4412
4628
  );
4413
4629
 
@@ -4572,29 +4788,64 @@ describe('Protocol V2 firmware update method', () => {
4572
4788
  });
4573
4789
 
4574
4790
  describe('Protocol V2 firmware reconnect identity', () => {
4575
- test('rejects a different device before firmware transfer resumes', () => {
4576
- expect(() => assertProtocolV2ReconnectIdentity('expected-device', 'other-device')).toThrow(
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', () => {
4807
+ expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
4577
4808
  'identity mismatch'
4578
4809
  );
4579
- expect(() => assertProtocolV2ReconnectIdentity('expected-device', undefined)).toThrow(
4810
+ expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).toThrow(
4580
4811
  'identity unavailable'
4581
4812
  );
4582
4813
  expect(() =>
4583
- assertProtocolV2ReconnectIdentity('expected-device', 'expected-device')
4814
+ assertProtocolV2ReconnectIdentity('expected-serial', 'expected-serial')
4584
4815
  ).not.toThrow();
4816
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).toThrow(
4817
+ 'identity unavailable'
4818
+ );
4585
4819
  });
4586
4820
 
4587
- test('allows bootloader reconnect without wallet device id after USB identity is selected', () => {
4588
- expect(() =>
4589
- assertProtocolV2ReconnectIdentity('expected-device', undefined, {
4590
- allowMissingActual: true,
4591
- })
4592
- ).not.toThrow();
4593
- expect(() =>
4594
- assertProtocolV2ReconnectIdentity('expected-device', 'other-device', {
4595
- allowMissingActual: true,
4596
- })
4597
- ).toThrow('identity mismatch');
4821
+ test('captures physical identity from active DeviceInfo instead of wallet deviceId', async () => {
4822
+ const method = new FirmwareUpdateV4({
4823
+ id: 1,
4824
+ payload: {
4825
+ method: 'firmwareUpdateV4',
4826
+ },
4827
+ });
4828
+ const typedCall = jest.fn().mockResolvedValue({
4829
+ type: 'DeviceInfo',
4830
+ message: {
4831
+ protocol_version: 1,
4832
+ hw: { serial_no: 'PRO2-PHYSICAL-1' },
4833
+ },
4834
+ });
4835
+ (method as any).device = stubDevice({
4836
+ features: { deviceId: 'wallet-derived-id' },
4837
+ getCommands: () => ({ typedCall }),
4838
+ });
4839
+
4840
+ await (method as any).captureProtocolV2PhysicalIdentity();
4841
+
4842
+ expect((method as any).protocolV2ExpectedSerialNumber).toBe('PRO2-PHYSICAL-1');
4843
+ expect(typedCall).toHaveBeenCalledWith(
4844
+ 'DeviceInfoGet',
4845
+ 'DeviceInfo',
4846
+ expect.objectContaining({ targets: expect.objectContaining({ hw: true }) }),
4847
+ { timeoutMs: 5000 }
4848
+ );
4598
4849
  });
4599
4850
  });
4600
4851
 
@@ -4672,7 +4923,7 @@ describe('Protocol V2 protected method execution', () => {
4672
4923
  errorCode: HardwareErrorCode.DeviceLocked,
4673
4924
  });
4674
4925
 
4675
- test('uses locked retry by default for SDK business methods', () => {
4926
+ test('does not replay SDK methods unless they are explicitly classified as retry-safe', () => {
4676
4927
  class TestBusinessMethod extends BaseMethod {
4677
4928
  init() {}
4678
4929
 
@@ -4686,7 +4937,7 @@ describe('Protocol V2 protected method execution', () => {
4686
4937
  payload: { method: 'testBusinessMethod' },
4687
4938
  } as any);
4688
4939
 
4689
- expect(method.unlockPolicy).toBe('retry-on-locked');
4940
+ expect(method.unlockPolicy).toBe('none');
4690
4941
  });
4691
4942
 
4692
4943
  test.each([
@@ -4724,7 +4975,6 @@ describe('Protocol V2 protected method execution', () => {
4724
4975
  test.each([
4725
4976
  ['deviceInfoGet', DeviceInfoGet],
4726
4977
  ['deviceStatusGet', DeviceStatusGet],
4727
- ['deviceSettingsGet', DeviceSettingsGet],
4728
4978
  ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
4729
4979
  const method = new MethodClass({
4730
4980
  id: 1,
@@ -4782,7 +5032,7 @@ describe('Protocol V2 protected method execution', () => {
4782
5032
  const calls: string[] = [];
4783
5033
  const method = {
4784
5034
  name: 'deviceSettingsPageShow',
4785
- unlockPolicy: 'retry-on-locked',
5035
+ unlockPolicy: 'unlock-before-run',
4786
5036
  protocolV2UiInteraction: { reason: 'settings-page' },
4787
5037
  run: jest.fn(() => {
4788
5038
  calls.push('run');
@@ -4866,14 +5116,12 @@ describe('Protocol V2 protected method execution', () => {
4866
5116
  test('keeps auto unlock but suppresses all synthesized UI for eventless methods', async () => {
4867
5117
  const method = {
4868
5118
  name: 'uploadPortfolio',
4869
- unlockPolicy: 'retry-on-locked',
5119
+ unlockPolicy: 'unlock-before-run',
4870
5120
  protocolV2UiMode: 'none',
4871
- run: jest
4872
- .fn()
4873
- .mockRejectedValueOnce(deviceLockedError())
4874
- .mockResolvedValueOnce({ message: 'ok' }),
5121
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
4875
5122
  };
4876
5123
  const device = {
5124
+ features: { unlocked: false },
4877
5125
  isProtocolV2: () => true,
4878
5126
  unlockDevice: jest.fn().mockResolvedValue(undefined),
4879
5127
  };
@@ -4892,6 +5140,24 @@ describe('Protocol V2 protected method execution', () => {
4892
5140
  expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
4893
5141
  });
4894
5142
 
5143
+ test('does not replay a state-changing method after a locked response', async () => {
5144
+ const error = deviceLockedError();
5145
+ const method = {
5146
+ name: 'uploadPortfolio',
5147
+ unlockPolicy: 'unlock-before-run',
5148
+ run: jest.fn().mockRejectedValue(error),
5149
+ };
5150
+ const device = {
5151
+ features: { unlocked: true },
5152
+ isProtocolV2: () => true,
5153
+ unlockDevice: jest.fn(),
5154
+ };
5155
+
5156
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
5157
+ expect(method.run).toHaveBeenCalledTimes(1);
5158
+ expect(device.unlockDevice).not.toHaveBeenCalled();
5159
+ });
5160
+
4895
5161
  test('does not retry when unlock fails or when the retry is still locked', async () => {
4896
5162
  const initialError = deviceLockedError();
4897
5163
  const unlockError = new Error('PIN cancelled');
@@ -4971,7 +5237,7 @@ describe('Protocol V2 current low-level methods', () => {
4971
5237
  });
4972
5238
 
4973
5239
  await expect(v2Method.run()).resolves.toEqual({ message: 'accepted' });
4974
- expect(v2Method.unlockPolicy).toBe('retry-on-locked');
5240
+ expect(v2Method.unlockPolicy).toBe('unlock-before-run');
4975
5241
  expect(v2TypedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
4976
5242
  page: DeviceSettingsPage.DeviceReset,
4977
5243
  });
@@ -5014,7 +5280,7 @@ describe('Protocol V2 current low-level methods', () => {
5014
5280
  });
5015
5281
 
5016
5282
  await expect(v2Method.run()).resolves.toEqual({ message: 'accepted' });
5017
- expect(v2Method.unlockPolicy).toBe('retry-on-locked');
5283
+ expect(v2Method.unlockPolicy).toBe('unlock-before-run');
5018
5284
  expect(v2TypedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
5019
5285
  page: DeviceSettingsPage.DevicePinChange,
5020
5286
  });
@@ -5098,67 +5364,6 @@ describe('Protocol V2 current low-level methods', () => {
5098
5364
  expect(typedCall).toHaveBeenLastCalledWith('DeviceFactoryInfoGet', 'DeviceFactoryInfo', {});
5099
5365
  });
5100
5366
 
5101
- test('gets Protocol V2 device settings', async () => {
5102
- const typedCall = jest.fn().mockResolvedValue({ message: { brightness: 80 } });
5103
- const method = new DeviceSettingsGet({
5104
- id: 1,
5105
- payload: { method: 'deviceSettingsGet' },
5106
- });
5107
- method.init();
5108
- (method as any).device = stubDevice({ commands: { typedCall } });
5109
-
5110
- await expect(method.run()).resolves.toEqual({ brightness: 80 });
5111
- expect(typedCall).toHaveBeenCalledWith('DeviceSettingsGet', 'DeviceSettings', {});
5112
- });
5113
-
5114
- test('sets Protocol V2 device settings without passphrase fields', async () => {
5115
- const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
5116
- const method = new DeviceSettingsSet({
5117
- id: 1,
5118
- payload: {
5119
- method: 'deviceSettingsSet',
5120
- settings: {
5121
- label: 'My Pro 2',
5122
- brightness: 80,
5123
- airgap_mode: true,
5124
- passphrase_enable: true,
5125
- },
5126
- },
5127
- });
5128
- method.init();
5129
- (method as any).device = stubDevice({ commands: { typedCall } });
5130
-
5131
- await method.run();
5132
-
5133
- expect(typedCall).toHaveBeenCalledWith('DeviceSettingsSet', 'Success', {
5134
- settings: {
5135
- label: 'My Pro 2',
5136
- brightness: 80,
5137
- },
5138
- });
5139
- expect(method.protocolV2UiInteraction).toEqual({
5140
- request: 'button',
5141
- source: 'method-lifecycle',
5142
- reason: 'device-management',
5143
- completion: 'operation-completed',
5144
- deviceOnly: true,
5145
- operation: 'change-label',
5146
- });
5147
- });
5148
-
5149
- test('keeps silent Protocol V2 device settings eventless', () => {
5150
- const method = new DeviceSettingsSet({
5151
- id: 1,
5152
- payload: {
5153
- method: 'deviceSettingsSet',
5154
- settings: { brightness: 80 },
5155
- },
5156
- });
5157
- method.init();
5158
-
5159
- expect(method.protocolV2UiInteraction).toBeUndefined();
5160
- });
5161
-
5162
5367
  test('marks explicit Protocol V2 unlock as an on-device PIN interaction', () => {
5163
5368
  const method = new DeviceUnlock({
5164
5369
  id: 1,
@@ -5176,92 +5381,44 @@ describe('Protocol V2 current low-level methods', () => {
5176
5381
  });
5177
5382
  });
5178
5383
 
5179
- test('opens non-passphrase Protocol V2 settings pages', async () => {
5180
- const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
5181
- const getDeviceState = jest.fn().mockResolvedValue({
5182
- settings: { airgapMode: true },
5183
- });
5184
- const method = new DeviceSettingsPageShow({
5185
- id: 1,
5186
- payload: {
5187
- method: 'deviceSettingsPageShow',
5188
- page: 'DeviceAirgap',
5189
- fieldName: 'airgap_mode',
5190
- },
5191
- });
5192
- method.init();
5193
- (method as any).device = stubDevice({ commands: { typedCall }, getDeviceState });
5194
-
5195
- await method.run();
5196
-
5197
- expect(typedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
5198
- page: 3,
5199
- field_name: 'airgap_mode',
5200
- });
5201
- expect(method.protocolV2UiInteraction).toEqual({
5202
- request: 'button',
5203
- source: 'method-lifecycle',
5204
- reason: 'settings-page',
5205
- completion: 'operation-completed',
5206
- deviceOnly: true,
5207
- page: DeviceSettingsPage.DeviceAirgap,
5208
- });
5209
- expect(getDeviceState).toHaveBeenCalledWith({ refreshSections: ['settings'] });
5210
- });
5211
-
5212
- test('opens the Protocol V2 passphrase settings page', async () => {
5213
- const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
5214
- const getDeviceState = jest.fn().mockResolvedValue({
5215
- status: { passphraseProtection: true },
5216
- });
5217
- const method = new DeviceSettingsPageShow({
5384
+ test('preserves the legacy Features response after explicit device unlock', async () => {
5385
+ const method = new DeviceUnlock({
5218
5386
  id: 1,
5219
- payload: {
5220
- method: 'deviceSettingsPageShow',
5221
- page: 'DevicePassphrase',
5222
- },
5387
+ payload: { method: 'deviceUnlock' },
5223
5388
  });
5224
5389
  method.init();
5225
- (method as any).device = stubDevice({ commands: { typedCall }, getDeviceState });
5226
-
5227
- await method.run();
5228
5390
 
5229
- expect(typedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
5230
- page: 2,
5231
- field_name: undefined,
5232
- });
5233
- expect(getDeviceState).toHaveBeenCalledWith({ refreshSections: ['status'] });
5234
- });
5235
-
5236
- test('does not unlock before reading Protocol V2 device settings', () => {
5237
- const method = new DeviceSettingsGet({
5238
- id: 1,
5239
- payload: { method: 'deviceSettingsGet' },
5240
- });
5241
-
5242
- method.init();
5391
+ const features = {
5392
+ protocol: 'V2',
5393
+ unlocked: true,
5394
+ sessionId: null,
5395
+ session_id: null,
5396
+ };
5397
+ const unlockDevice = jest.fn().mockResolvedValue(features);
5398
+ (method as any).device = { unlockDevice };
5243
5399
 
5244
- expect(method.unlockPolicy).toBe('none');
5400
+ await expect(method.run()).resolves.toBe(features);
5401
+ expect(unlockDevice).toHaveBeenCalledTimes(1);
5245
5402
  });
5246
5403
 
5247
5404
  test.each([
5248
5405
  ['label', { label: 'My Pro 2' }],
5249
- ['language', { language: 'ja-JP' }],
5406
+ ['language', { language: 'ja' }],
5250
5407
  ['brightness', { brightness: 80 }],
5251
- ['haptic feedback', { haptic_feedback: true }],
5408
+ ['haptic feedback', { hapticFeedback: true }],
5252
5409
  [
5253
5410
  'combined lock-free settings',
5254
5411
  {
5255
5412
  label: 'My Pro 2',
5256
- language: 'ja-JP',
5413
+ language: 'ja',
5257
5414
  brightness: 80,
5258
- haptic_feedback: true,
5415
+ hapticFeedback: true,
5259
5416
  },
5260
5417
  ],
5261
- ])('does not unlock before changing Protocol V2 %s', (_name, settings) => {
5262
- const method = new DeviceSettingsSet({
5418
+ ])('does not unlock before changing unified Protocol V2 %s', (_name, settings) => {
5419
+ const method = new DeviceSettings({
5263
5420
  id: 2,
5264
- payload: { method: 'deviceSettingsSet', settings },
5421
+ payload: { method: 'deviceSettings', ...settings },
5265
5422
  });
5266
5423
 
5267
5424
  method.init();
@@ -5270,30 +5427,30 @@ describe('Protocol V2 current low-level methods', () => {
5270
5427
  });
5271
5428
 
5272
5429
  test.each([
5273
- ['auto lock', { autolock_delay_ms: 60_000 }],
5274
- ['auto shutdown', { autoshutdown_delay_ms: 120_000 }],
5275
- ['mixed protected settings', { brightness: 80, autolock_delay_ms: 60_000 }],
5276
- ['other protected settings', { usb_lock_enable: true }],
5277
- ])('unlocks before changing Protocol V2 %s', (_name, settings) => {
5278
- const method = new DeviceSettingsSet({
5430
+ ['auto lock', { autoLockDelayMs: 60_000 }],
5431
+ ['auto shutdown', { autoShutdownDelayMs: 120_000 }],
5432
+ ['mixed protected settings', { brightness: 80, autoLockDelayMs: 60_000 }],
5433
+ ['other protected settings', { usbLockEnabled: true }],
5434
+ ])('unlocks before changing unified Protocol V2 %s', (_name, settings) => {
5435
+ const method = new DeviceSettings({
5279
5436
  id: 2,
5280
- payload: { method: 'deviceSettingsSet', settings },
5437
+ payload: { method: 'deviceSettings', ...settings },
5281
5438
  });
5282
5439
 
5283
5440
  method.init();
5284
5441
 
5285
- expect(method.unlockPolicy).toBe('retry-on-locked');
5442
+ expect(method.unlockPolicy).toBe('unlock-before-run');
5286
5443
  });
5287
5444
 
5288
- test('unlocks before opening Protocol V2 settings pages', () => {
5289
- const method = new DeviceSettingsPageShow({
5445
+ test('unlocks before opening Protocol V2 settings pages through the unified method', () => {
5446
+ const method = new DeviceSettings({
5290
5447
  id: 3,
5291
- payload: { method: 'deviceSettingsPageShow', page: 'DevicePassphrase' },
5448
+ payload: { method: 'deviceSettings', usePassphrase: true },
5292
5449
  });
5293
5450
 
5294
5451
  method.init();
5295
5452
 
5296
- expect(method.unlockPolicy).toBe('retry-on-locked');
5453
+ expect(method.unlockPolicy).toBe('unlock-before-run');
5297
5454
  });
5298
5455
 
5299
5456
  test('keeps firmwareUpdateV4 unlock-before-reboot behavior explicit', () => {
@@ -5304,7 +5461,7 @@ describe('Protocol V2 current low-level methods', () => {
5304
5461
 
5305
5462
  method.init();
5306
5463
 
5307
- expect(method.unlockPolicy).toBe('retry-on-locked');
5464
+ expect(method.unlockPolicy).toBe('unlock-before-run');
5308
5465
  });
5309
5466
 
5310
5467
  test('sends FilesystemPermissionFix from filesystemPermissionFix', async () => {