@onekeyfe/hd-core 1.2.0-alpha.67 → 1.2.0-alpha.68

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 (254) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +313 -0
  2. package/__tests__/DeviceCommands.test.ts +59 -13
  3. package/__tests__/DeviceEventRegistration.test.ts +6 -0
  4. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +198 -3
  5. package/__tests__/core-initialization.test.ts +23 -0
  6. package/__tests__/device-connector-protocol.test.ts +81 -0
  7. package/__tests__/device-lifecycle-events.test.ts +105 -3
  8. package/__tests__/device-pool-state.test.ts +22 -0
  9. package/__tests__/device-settings.test.ts +39 -18
  10. package/__tests__/device-state-contract.test.ts +1 -0
  11. package/__tests__/device-state-mapper.test.ts +13 -1
  12. package/__tests__/device-utils.test.ts +48 -1
  13. package/__tests__/deviceSettings.test.ts +11 -1
  14. package/__tests__/deviceUploadNft.test.ts +316 -0
  15. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +5 -0
  16. package/__tests__/get-device-state.test.ts +132 -12
  17. package/__tests__/homescreen.test.ts +41 -1
  18. package/__tests__/kaspa-use-tweak-pro2.test.ts +2 -2
  19. package/__tests__/kaspaSignTransaction.test.ts +3 -26
  20. package/__tests__/logBlockEvent.test.ts +1 -1
  21. package/__tests__/method-protocol-support.test.ts +19 -0
  22. package/__tests__/open-wallet-session.test.ts +568 -37
  23. package/__tests__/pro2Nft.test.ts +108 -0
  24. package/__tests__/protocol-binding.test.ts +89 -0
  25. package/__tests__/protocol-v2-resources.test.ts +340 -0
  26. package/__tests__/protocol-v2-ui-lifecycle.test.ts +91 -0
  27. package/__tests__/protocol-v2-unlock-policy.test.ts +300 -37
  28. package/__tests__/protocol-v2.test.ts +1449 -867
  29. package/__tests__/protocolV2FileWrite.test.ts +152 -0
  30. package/__tests__/protocolV2UiInteraction.test.ts +63 -0
  31. package/__tests__/public-pro2-api-boundary.test.ts +1 -0
  32. package/__tests__/search-devices.test.ts +12 -3
  33. package/__tests__/tron-sign-message-init.test.ts +2 -2
  34. package/__tests__/uiProgressThrottle.test.ts +74 -0
  35. package/__tests__/uiPromiseRegistry.test.ts +115 -0
  36. package/dist/api/BaseMethod.d.ts +3 -1
  37. package/dist/api/BaseMethod.d.ts.map +1 -1
  38. package/dist/api/CheckAllFirmwareRelease.d.ts +2 -1
  39. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  40. package/dist/api/DetectDeviceConnectProtocol.d.ts +7 -0
  41. package/dist/api/DetectDeviceConnectProtocol.d.ts.map +1 -0
  42. package/dist/api/FirmwareUpdate.d.ts.map +1 -1
  43. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  44. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  45. package/dist/api/FirmwareUpdateV4.d.ts +11 -4
  46. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  47. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  48. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  49. package/dist/api/SearchDevices.d.ts.map +1 -1
  50. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  51. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  52. package/dist/api/device/DeviceUnlock.d.ts +2 -2
  53. package/dist/api/device/DeviceUnlock.d.ts.map +1 -1
  54. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +4 -3
  55. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  56. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  57. package/dist/api/helpers/protocolV2FileWrite.d.ts +9 -0
  58. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  59. package/dist/api/index.d.ts +2 -0
  60. package/dist/api/index.d.ts.map +1 -1
  61. package/dist/api/kaspa/KaspaGetAddress.d.ts +1 -1
  62. package/dist/api/kaspa/KaspaSignTransaction.d.ts +1 -1
  63. package/dist/api/kaspa/KaspaSignTransaction.d.ts.map +1 -1
  64. package/dist/api/polkadot/PolkadotGetAddress.d.ts +1 -1
  65. package/dist/api/polkadot/networks.d.ts +1 -1
  66. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +31 -0
  67. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -0
  68. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  69. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  70. package/dist/api/solana/SolSignMessage.d.ts +1 -1
  71. package/dist/api/solana/SolSignOffchainMessage.d.ts +1 -1
  72. package/dist/api/solana/SolSignTransaction.d.ts +2 -2
  73. package/dist/api/sui/SuiGetAddress.d.ts +1 -1
  74. package/dist/api/sui/SuiGetPublicKey.d.ts +1 -1
  75. package/dist/api/sui/SuiSignMessage.d.ts +1 -1
  76. package/dist/api/sui/SuiSignTransaction.d.ts +1 -1
  77. package/dist/api/ton/TonGetAddress.d.ts +1 -1
  78. package/dist/api/ton/TonSignMessage.d.ts +1 -1
  79. package/dist/api/ton/TonSignProof.d.ts +1 -1
  80. package/dist/api/tron/TronSignMessage.d.ts +2 -2
  81. package/dist/api/tron/TronSignTransaction.d.ts +1 -1
  82. package/dist/core/deviceEventRegistration.d.ts +2 -0
  83. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  84. package/dist/core/index.d.ts.map +1 -1
  85. package/dist/core/uiPromiseRegistry.d.ts +6 -0
  86. package/dist/core/uiPromiseRegistry.d.ts.map +1 -0
  87. package/dist/data-manager/DataManager.d.ts +9 -3
  88. package/dist/data-manager/DataManager.d.ts.map +1 -1
  89. package/dist/data-manager/TransportManager.d.ts.map +1 -1
  90. package/dist/device/Device.d.ts +29 -5
  91. package/dist/device/Device.d.ts.map +1 -1
  92. package/dist/device/DeviceCommands.d.ts +4 -4
  93. package/dist/device/DeviceCommands.d.ts.map +1 -1
  94. package/dist/device/DeviceConnector.d.ts +2 -1
  95. package/dist/device/DeviceConnector.d.ts.map +1 -1
  96. package/dist/device/DevicePool.d.ts.map +1 -1
  97. package/dist/deviceProfile/protocolV2DeviceIdentity.d.ts.map +1 -1
  98. package/dist/events/device.d.ts +4 -0
  99. package/dist/events/device.d.ts.map +1 -1
  100. package/dist/events/logBlockEvent.d.ts.map +1 -1
  101. package/dist/events/ui-promise.d.ts +4 -2
  102. package/dist/events/ui-promise.d.ts.map +1 -1
  103. package/dist/events/ui-request.d.ts +31 -2
  104. package/dist/events/ui-request.d.ts.map +1 -1
  105. package/dist/events/ui-response.d.ts +10 -2
  106. package/dist/events/ui-response.d.ts.map +1 -1
  107. package/dist/index.d.ts +243 -69
  108. package/dist/index.js +2563 -1217
  109. package/dist/inject.d.ts +6 -1
  110. package/dist/inject.d.ts.map +1 -1
  111. package/dist/lowLevelInject.d.ts.map +1 -1
  112. package/dist/protocols/protocol-v2/features.d.ts +0 -4
  113. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  114. package/dist/protocols/protocol-v2/firmware.d.ts.map +1 -1
  115. package/dist/protocols/protocol-v2/resources.d.ts +30 -0
  116. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -0
  117. package/dist/protocols/protocol-v2/uiInteraction.d.ts +7 -4
  118. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  119. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +1 -3
  120. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  121. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +22 -0
  122. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -0
  123. package/dist/protocols/protocol-v2/walletSession.d.ts +6 -1
  124. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  125. package/dist/topLevelInject.d.ts.map +1 -1
  126. package/dist/types/api/checkAllFirmwareRelease.d.ts +3 -1
  127. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  128. package/dist/types/api/detectDeviceConnectProtocol.d.ts +4 -0
  129. package/dist/types/api/detectDeviceConnectProtocol.d.ts.map +1 -0
  130. package/dist/types/api/deviceUnlock.d.ts +5 -1
  131. package/dist/types/api/deviceUnlock.d.ts.map +1 -1
  132. package/dist/types/api/export.d.ts +1 -0
  133. package/dist/types/api/export.d.ts.map +1 -1
  134. package/dist/types/api/firmwareUpdate.d.ts +4 -2
  135. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  136. package/dist/types/api/index.d.ts +6 -1
  137. package/dist/types/api/index.d.ts.map +1 -1
  138. package/dist/types/api/protocolV2.d.ts +3 -0
  139. package/dist/types/api/protocolV2.d.ts.map +1 -1
  140. package/dist/types/device.d.ts +2 -2
  141. package/dist/types/device.d.ts.map +1 -1
  142. package/dist/types/params.d.ts +1 -0
  143. package/dist/types/params.d.ts.map +1 -1
  144. package/dist/types/settings.d.ts +38 -8
  145. package/dist/types/settings.d.ts.map +1 -1
  146. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  147. package/dist/utils/deviceFeaturesUtils.d.ts +4 -0
  148. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  149. package/dist/utils/deviceInfoUtils.d.ts.map +1 -1
  150. package/dist/utils/deviceSettings.d.ts.map +1 -1
  151. package/dist/utils/homescreen.d.ts +4 -0
  152. package/dist/utils/homescreen.d.ts.map +1 -1
  153. package/dist/utils/index.d.ts +1 -1
  154. package/dist/utils/index.d.ts.map +1 -1
  155. package/dist/utils/patch.d.ts +1 -1
  156. package/dist/utils/patch.d.ts.map +1 -1
  157. package/dist/utils/pro2Nft.d.ts +31 -0
  158. package/dist/utils/pro2Nft.d.ts.map +1 -0
  159. package/dist/utils/pro2Wallpaper.d.ts +10 -0
  160. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  161. package/dist/utils/uiProgressThrottle.d.ts +3 -0
  162. package/dist/utils/uiProgressThrottle.d.ts.map +1 -0
  163. package/package.json +4 -4
  164. package/src/api/BaseMethod.ts +8 -10
  165. package/src/api/CheckAllFirmwareRelease.ts +61 -11
  166. package/src/api/DetectDeviceConnectProtocol.ts +18 -0
  167. package/src/api/FirmwareUpdate.ts +6 -2
  168. package/src/api/FirmwareUpdateV2.ts +5 -2
  169. package/src/api/FirmwareUpdateV3.ts +6 -2
  170. package/src/api/FirmwareUpdateV4.ts +463 -258
  171. package/src/api/GetPassphraseState.ts +7 -1
  172. package/src/api/OpenWalletSession.ts +71 -17
  173. package/src/api/SearchDevices.ts +3 -1
  174. package/src/api/allnetwork/AllNetworkGetAddress.ts +21 -21
  175. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +36 -11
  176. package/src/api/device/DeviceSettings.ts +5 -4
  177. package/src/api/device/DeviceUnlock.ts +8 -3
  178. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +27 -12
  179. package/src/api/firmware/uploadFirmware.ts +17 -4
  180. package/src/api/helpers/protocolV2FileWrite.ts +202 -63
  181. package/src/api/index.ts +2 -0
  182. package/src/api/kaspa/KaspaGetAddress.ts +1 -1
  183. package/src/api/kaspa/KaspaSignTransaction.ts +2 -13
  184. package/src/api/polkadot/networks.ts +3 -3
  185. package/src/api/protocol-v2/DeviceUploadNft.ts +197 -0
  186. package/src/api/protocol-v2/ProtocolInfoRequest.ts +3 -1
  187. package/src/api/protocol-v2/helpers.ts +1 -1
  188. package/src/api/solana/SolSignMessage.ts +1 -1
  189. package/src/api/solana/SolSignOffchainMessage.ts +1 -1
  190. package/src/api/solana/SolSignTransaction.ts +2 -2
  191. package/src/api/sui/SuiGetAddress.ts +1 -1
  192. package/src/api/sui/SuiGetPublicKey.ts +1 -1
  193. package/src/api/sui/SuiSignMessage.ts +1 -1
  194. package/src/api/sui/SuiSignTransaction.ts +1 -1
  195. package/src/api/ton/TonGetAddress.ts +1 -1
  196. package/src/api/ton/TonSignMessage.ts +1 -1
  197. package/src/api/ton/TonSignProof.ts +1 -1
  198. package/src/api/tron/TronSignMessage.ts +2 -2
  199. package/src/api/tron/TronSignTransaction.ts +1 -1
  200. package/src/core/deviceEventRegistration.ts +4 -0
  201. package/src/core/index.ts +213 -111
  202. package/src/core/uiPromiseRegistry.ts +63 -0
  203. package/src/data/messages/messages-protocol-v2.json +25 -0
  204. package/src/data-manager/DataManager.ts +88 -11
  205. package/src/data-manager/TransportManager.ts +6 -0
  206. package/src/device/Device.ts +273 -43
  207. package/src/device/DeviceCommands.ts +31 -4
  208. package/src/device/DeviceConnector.ts +33 -13
  209. package/src/device/DevicePool.ts +21 -7
  210. package/src/deviceProfile/buildDeviceFeatures.ts +2 -2
  211. package/src/deviceProfile/protocolV2DeviceIdentity.ts +3 -0
  212. package/src/events/device.ts +4 -0
  213. package/src/events/logBlockEvent.ts +1 -0
  214. package/src/events/ui-promise.ts +4 -2
  215. package/src/events/ui-request.ts +36 -2
  216. package/src/events/ui-response.ts +12 -2
  217. package/src/inject.ts +58 -2
  218. package/src/lowLevelInject.ts +6 -3
  219. package/src/protocols/protocol-v2/features.ts +10 -7
  220. package/src/protocols/protocol-v2/firmware.ts +2 -0
  221. package/src/protocols/protocol-v2/resources.ts +390 -0
  222. package/src/protocols/protocol-v2/uiInteraction.ts +46 -6
  223. package/src/protocols/protocol-v2/unlockPolicy.ts +1 -105
  224. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +131 -0
  225. package/src/protocols/protocol-v2/walletSession.ts +214 -36
  226. package/src/topLevelInject.ts +6 -3
  227. package/src/types/api/checkAllFirmwareRelease.ts +3 -1
  228. package/src/types/api/detectDeviceConnectProtocol.ts +6 -0
  229. package/src/types/api/deviceUnlock.ts +12 -1
  230. package/src/types/api/export.ts +1 -0
  231. package/src/types/api/firmwareUpdate.ts +6 -3
  232. package/src/types/api/index.ts +13 -0
  233. package/src/types/api/protocolV2.ts +13 -0
  234. package/src/types/device.ts +15 -3
  235. package/src/types/params.ts +7 -1
  236. package/src/types/settings.ts +60 -9
  237. package/src/utils/deviceFeaturesCompat.ts +5 -0
  238. package/src/utils/deviceFeaturesUtils.ts +14 -3
  239. package/src/utils/deviceInfoUtils.ts +2 -0
  240. package/src/utils/deviceSettings.ts +3 -0
  241. package/src/utils/homescreen.ts +32 -6
  242. package/src/utils/index.ts +6 -1
  243. package/src/utils/pro2Nft.ts +142 -0
  244. package/src/utils/pro2Wallpaper.ts +35 -8
  245. package/src/utils/uiProgressThrottle.ts +63 -0
  246. package/dist/api/firmware/progressThrottle.d.ts +0 -3
  247. package/dist/api/firmware/progressThrottle.d.ts.map +0 -1
  248. package/dist/protocols/protocol-v2/lockedError.d.ts +0 -2
  249. package/dist/protocols/protocol-v2/lockedError.d.ts.map +0 -1
  250. package/dist/protocols/protocol-v2/unlockRetry.d.ts +0 -10
  251. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +0 -1
  252. package/src/api/firmware/progressThrottle.ts +0 -44
  253. package/src/protocols/protocol-v2/lockedError.ts +0 -10
  254. package/src/protocols/protocol-v2/unlockRetry.ts +0 -109
@@ -1,10 +1,11 @@
1
- import { EFirmwareType, HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
2
  import {
3
3
  DeviceRebootType,
4
4
  DeviceSessionPinType,
5
5
  DeviceSettingsPage,
6
6
  DeviceType,
7
7
  } from '@onekeyfe/hd-transport';
8
+ import { sha256 } from '@noble/hashes/sha256';
8
9
 
9
10
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
10
11
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -61,6 +62,7 @@ import { createCoreApi } from '../src/inject';
61
62
  import { Device, preloadSessionCache } from '../src/device/Device';
62
63
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
63
64
  import { DevicePool } from '../src/device/DevicePool';
65
+ import { DEVICE } from '../src/events';
64
66
  import { UI_REQUEST } from '../src/events/ui-request';
65
67
  import {
66
68
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
@@ -74,11 +76,15 @@ import {
74
76
  requestProtocolV2ProtocolInfo,
75
77
  supportsProtocolV2Message,
76
78
  } from '../src/protocols/protocol-v2/features';
79
+ import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
77
80
  import {
78
81
  getProtocolV2WalletSession,
79
82
  refreshProtocolV2DeviceStatus,
80
83
  } from '../src/protocols/protocol-v2/walletSession';
81
- import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
84
+ import {
85
+ createProtocolV2UnlockContext,
86
+ runMethodWithUnlockPolicy,
87
+ } from '../src/protocols/protocol-v2/unlockPolicyRunner';
82
88
  import { BaseMethod } from '../src/api/BaseMethod';
83
89
  import {
84
90
  buildProtocolV1FeaturesPayload,
@@ -241,7 +247,7 @@ describe('UploadPortfolio', () => {
241
247
  (method as any).device = device;
242
248
 
243
249
  method.init();
244
- await runMethodWithUnlockRetry(method, device as any);
250
+ await runMethodWithUnlockPolicy(method, device as any);
245
251
 
246
252
  expect(unlockDevice).not.toHaveBeenCalled();
247
253
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -417,14 +423,16 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
417
423
  }
418
424
  return d.features;
419
425
  };
426
+ d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
427
+ requestProtocolV2ProtocolInfo({
428
+ commands: d.getCommands?.() ?? d.commands,
429
+ timeoutMs,
430
+ });
420
431
  d.probeProtocolV2RuntimeState ??= async (
421
432
  deviceInfo: ProtocolV2DeviceInfo,
422
433
  timeoutMs?: number
423
434
  ) => {
424
- const protocolInfo = await requestProtocolV2ProtocolInfo({
425
- commands: d.getCommands(),
426
- timeoutMs,
427
- });
435
+ const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
428
436
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
429
437
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
430
438
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -582,7 +590,7 @@ describe('Protocol V2 feature adapter', () => {
582
590
  } as any);
583
591
  const protocolV2 = buildProtocolV2FeaturesPayload({
584
592
  deviceInfo: {
585
- hw: { serial_no: 'P2-001' },
593
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'P2-001' },
586
594
  coprocessor: { bt_adv_name: 'Pro 2 BLE' },
587
595
  },
588
596
  deviceStatus: {
@@ -781,6 +789,9 @@ describe('Protocol V2 feature adapter', () => {
781
789
  if (request === 'DeviceStatusGet') {
782
790
  return { type: 'DeviceStatus', message: { unlocked: true } };
783
791
  }
792
+ if (request === 'DeviceSessionAskPassphrase') {
793
+ return { type: 'Success', message: {} };
794
+ }
784
795
  if (request === 'DeviceSessionGet') {
785
796
  return {
786
797
  type: 'DeviceSession',
@@ -805,7 +816,7 @@ describe('Protocol V2 feature adapter', () => {
805
816
  ).resolves.toEqual({
806
817
  passphraseState: 'state-1',
807
818
  newSession: 'session-1',
808
- unlockedAttachPin: undefined,
819
+ unlockedAttachPin: false,
809
820
  resumed: false,
810
821
  });
811
822
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -814,7 +825,13 @@ describe('Protocol V2 feature adapter', () => {
814
825
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
815
826
  type: DeviceSessionPinType.Main,
816
827
  });
817
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
828
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
829
+ passphrase: '',
830
+ on_device: false,
831
+ });
832
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
833
+ seed_domains: [],
834
+ });
818
835
  device.passphraseState = 'state-1';
819
836
  expect(device.getInternalState()).toBe('session-1');
820
837
  });
@@ -847,6 +864,7 @@ describe('Protocol V2 feature adapter', () => {
847
864
  },
848
865
  };
849
866
  }
867
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
850
868
  if (request === 'DeviceSessionGet') {
851
869
  return {
852
870
  message: {
@@ -863,9 +881,16 @@ describe('Protocol V2 feature adapter', () => {
863
881
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
864
882
 
865
883
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
866
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
884
+ expect(
885
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
886
+ ).toHaveLength(1);
887
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
888
+ seed_domains: [],
889
+ });
867
890
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
868
891
  session_id: 'standard-session',
892
+ btc_test_address: 'standard-state',
893
+ seed_domains: [],
869
894
  });
870
895
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
871
896
  passphraseState: 'standard-state',
@@ -907,6 +932,7 @@ describe('Protocol V2 feature adapter', () => {
907
932
  },
908
933
  };
909
934
  }
935
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
910
936
  if (request === 'DeviceSessionAskPin') {
911
937
  return { type: 'Success', message: { message: 'PIN verified' } };
912
938
  }
@@ -1001,6 +1027,7 @@ describe('Protocol V2 feature adapter', () => {
1001
1027
  if (request === 'DeviceStatusGet') {
1002
1028
  return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
1003
1029
  }
1030
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
1004
1031
  if (request === 'DeviceSessionGet') {
1005
1032
  sessionGetCalls += 1;
1006
1033
  return {
@@ -1227,7 +1254,9 @@ describe('Protocol V2 feature adapter', () => {
1227
1254
  passphrase: 'host hidden wallet',
1228
1255
  on_device: false,
1229
1256
  });
1230
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1257
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1258
+ seed_domains: [],
1259
+ });
1231
1260
  });
1232
1261
 
1233
1262
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1335,6 +1364,8 @@ describe('Protocol V2 feature adapter', () => {
1335
1364
 
1336
1365
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1337
1366
  session_id: 'session-a',
1367
+ btc_test_address: 'state-a',
1368
+ seed_domains: [],
1338
1369
  });
1339
1370
  expect(device.getInternalState()).toBe('session-b');
1340
1371
  });
@@ -1402,7 +1433,15 @@ describe('Protocol V2 feature adapter', () => {
1402
1433
  });
1403
1434
  expect(typedCall.mock.calls).toEqual([
1404
1435
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1405
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1436
+ [
1437
+ 'DeviceSessionGet',
1438
+ 'DeviceSession',
1439
+ {
1440
+ session_id: 'session-a',
1441
+ btc_test_address: 'state-a',
1442
+ seed_domains: [],
1443
+ },
1444
+ ],
1406
1445
  ]);
1407
1446
  expect(promptPassphrase).not.toHaveBeenCalled();
1408
1447
  expect(device.getInternalState()).toBeUndefined();
@@ -1450,7 +1489,7 @@ describe('Protocol V2 feature adapter', () => {
1450
1489
  { passphrase: 'host hidden wallet', on_device: false },
1451
1490
  ],
1452
1491
  ['DeviceStatusGet', 'DeviceStatus', {}],
1453
- ['DeviceSessionGet', 'DeviceSession', {}],
1492
+ ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1454
1493
  ]);
1455
1494
  });
1456
1495
 
@@ -1553,6 +1592,73 @@ describe('Protocol V2 feature adapter', () => {
1553
1592
  expect(device.getInternalState()).toBeUndefined();
1554
1593
  });
1555
1594
 
1595
+ test.each([false, true])(
1596
+ 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1597
+ async lockFails => {
1598
+ const deviceId = 'attach-mismatch-device';
1599
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1600
+ (device as any).features = normalizeProtocolV2Features(
1601
+ { ...descriptor, protocolType: 'V2' } as any,
1602
+ {
1603
+ status: {
1604
+ device_id: deviceId,
1605
+ unlocked: true,
1606
+ passphrase_enabled: true,
1607
+ attach_to_pin_enabled: true,
1608
+ unlocked_by_attach_to_pin: false,
1609
+ },
1610
+ }
1611
+ );
1612
+ device.passphraseState = 'expected-state';
1613
+
1614
+ const typedCall = jest.fn((request: string) => {
1615
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1616
+ if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1617
+ if (request === 'DeviceStatusGet') {
1618
+ return {
1619
+ message: {
1620
+ device_id: deviceId,
1621
+ unlocked: true,
1622
+ passphrase_enabled: true,
1623
+ attach_to_pin_enabled: true,
1624
+ unlocked_by_attach_to_pin: true,
1625
+ },
1626
+ };
1627
+ }
1628
+ if (request === 'DeviceSessionGet') {
1629
+ return {
1630
+ message: {
1631
+ btc_test_address: 'wrong-attach-state',
1632
+ session_id: 'wrong-attach-session',
1633
+ },
1634
+ };
1635
+ }
1636
+ if (request === 'LockDevice') {
1637
+ if (lockFails) throw new Error('Lock unsupported');
1638
+ return { message: { message: 'Device locked' } };
1639
+ }
1640
+ throw new Error(`Unexpected request: ${request}`);
1641
+ });
1642
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1643
+ (device as any).commands = { typedCall, promptPassphrase };
1644
+
1645
+ await expect(
1646
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1647
+ ).rejects.toMatchObject({
1648
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1649
+ });
1650
+
1651
+ expect(promptPassphrase).not.toHaveBeenCalled();
1652
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1653
+ btc_test_address: 'expected-state',
1654
+ seed_domains: [],
1655
+ });
1656
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1657
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1658
+ expect(device.getInternalState()).toBeUndefined();
1659
+ }
1660
+ );
1661
+
1556
1662
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1557
1663
  const device = Device.fromDescriptor({
1558
1664
  id: 'cache-device-attach-recovery',
@@ -1609,7 +1715,17 @@ describe('Protocol V2 feature adapter', () => {
1609
1715
  }),
1610
1716
  { cancelDeviceOnReject: false }
1611
1717
  );
1612
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1718
+ expect(unlockDevice).toHaveBeenCalledWith(
1719
+ DeviceSessionPinType.AttachToPin,
1720
+ expect.objectContaining({
1721
+ emitUiEvent: false,
1722
+ interaction: expect.objectContaining({
1723
+ phase: 'pin',
1724
+ transition: 'start',
1725
+ protocol: 'V2',
1726
+ }),
1727
+ })
1728
+ );
1613
1729
  expect(device.getInternalState()).toBe('new-attach-session');
1614
1730
  });
1615
1731
 
@@ -1893,12 +2009,10 @@ describe('Protocol V2 feature adapter', () => {
1893
2009
  expect(getFeatures).not.toHaveBeenCalled();
1894
2010
  });
1895
2011
 
1896
- test('marks an interactive cache miss as a wallet-session recovery', async () => {
2012
+ test('uses the cached wallet address when the local session is missing', async () => {
1897
2013
  const typedCall = jest
1898
2014
  .fn()
1899
2015
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
- .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
- .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1902
2016
  .mockResolvedValueOnce({
1903
2017
  message: {
1904
2018
  btc_test_address: 'expected-state',
@@ -1926,16 +2040,11 @@ describe('Protocol V2 feature adapter', () => {
1926
2040
  await expect(
1927
2041
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1928
2042
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
1929
- expect(promptPassphrase).toHaveBeenCalledWith(
1930
- {
1931
- existsAttachPinUser: false,
1932
- deviceOnly: false,
1933
- source: 'wallet-session-coordinator',
1934
- reason: 'session-recovery',
1935
- expectedPassphraseState: 'expected-state',
1936
- },
1937
- { cancelDeviceOnReject: false }
1938
- );
2043
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2044
+ btc_test_address: 'expected-state',
2045
+ seed_domains: [],
2046
+ });
2047
+ expect(promptPassphrase).not.toHaveBeenCalled();
1939
2048
  });
1940
2049
 
1941
2050
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2001,8 +2110,15 @@ describe('Protocol V2 feature adapter', () => {
2001
2110
  true
2002
2111
  );
2003
2112
  expect(typedCall.mock.calls).toEqual([
2004
- ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2005
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2113
+ [
2114
+ 'DeviceSessionGet',
2115
+ 'DeviceSession',
2116
+ {
2117
+ session_id: 'session-pro2-app',
2118
+ btc_test_address: 'state-pro2-app',
2119
+ seed_domains: [],
2120
+ },
2121
+ ],
2006
2122
  ]);
2007
2123
  });
2008
2124
 
@@ -2191,7 +2307,9 @@ describe('Protocol V2 feature adapter', () => {
2191
2307
  passphrase: 'host hidden wallet',
2192
2308
  on_device: false,
2193
2309
  });
2194
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2310
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2311
+ seed_domains: [],
2312
+ });
2195
2313
  });
2196
2314
 
2197
2315
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2269,12 +2387,83 @@ describe('Protocol V2 feature adapter', () => {
2269
2387
  );
2270
2388
 
2271
2389
  expect(device.getInternalState()).toBeUndefined();
2272
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2390
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2391
+ btc_test_address: 'expected-state',
2392
+ seed_domains: [],
2393
+ });
2394
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2273
2395
  passphrase: 'host hidden wallet',
2274
2396
  on_device: false,
2275
2397
  });
2276
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
- expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2398
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2399
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2400
+ seed_domains: [],
2401
+ });
2402
+ });
2403
+
2404
+ test('fails closed instead of switching to Main PIN during a standard-wallet safety check', async () => {
2405
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2406
+ const typedCall = jest.fn((request: string) => {
2407
+ if (request === 'ProtocolInfoRequest') {
2408
+ return { message: { version: 2 } };
2409
+ }
2410
+ if (request === 'DeviceStatusGet') {
2411
+ return {
2412
+ message: {
2413
+ device_id: 'attach-wallet-device',
2414
+ unlocked: true,
2415
+ passphrase_enabled: true,
2416
+ attach_to_pin_enabled: true,
2417
+ unlocked_by_attach_to_pin: true,
2418
+ },
2419
+ };
2420
+ }
2421
+ if (request === 'DeviceSessionAskPin' || request === 'DeviceSessionAskPassphrase') {
2422
+ return { message: { message: 'Wallet selected' } };
2423
+ }
2424
+ if (request === 'DeviceSessionGet') {
2425
+ return {
2426
+ message: {
2427
+ session_id: 'main-wallet-session',
2428
+ btc_test_address: 'main-wallet-state',
2429
+ },
2430
+ };
2431
+ }
2432
+ if (request === 'LockDevice') {
2433
+ return { message: { message: 'Device locked' } };
2434
+ }
2435
+ throw new Error(`Unexpected request: ${request}`);
2436
+ });
2437
+
2438
+ (device as any).features = normalizeProtocolV2Features(
2439
+ { ...descriptor, protocolType: 'V2' } as any,
2440
+ {
2441
+ status: {
2442
+ device_id: 'attach-wallet-device',
2443
+ unlocked: true,
2444
+ passphrase_enabled: true,
2445
+ attach_to_pin_enabled: true,
2446
+ // 模拟缓存滞后:设备状态刷新后才确认当前由 Attach PIN 解锁。
2447
+ unlocked_by_attach_to_pin: false,
2448
+ },
2449
+ }
2450
+ );
2451
+ (device as any).commands = { typedCall };
2452
+ const clearInternalState = jest.spyOn(device, 'clearInternalState');
2453
+
2454
+ await expect(device.checkPassphraseStateSafety(undefined, true, false)).rejects.toMatchObject({
2455
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
2456
+ });
2457
+
2458
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2459
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
2460
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2461
+ expect(typedCall).not.toHaveBeenCalledWith(
2462
+ 'DeviceSessionAskPassphrase',
2463
+ 'Success',
2464
+ expect.anything()
2465
+ );
2466
+ expect(clearInternalState).toHaveBeenCalledTimes(1);
2278
2467
  });
2279
2468
 
2280
2469
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2307,10 +2496,22 @@ describe('Protocol V2 feature adapter', () => {
2307
2496
  await expect(
2308
2497
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
2309
2498
  ).resolves.toBe(true);
2310
- expect(typedCall).toHaveBeenCalledTimes(4);
2499
+ expect(typedCall).toHaveBeenCalledTimes(5);
2311
2500
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2312
2501
  eventless_wallet_session: true,
2313
2502
  });
2503
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2504
+ seed_domains: [],
2505
+ });
2506
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2507
+ passphrase: '',
2508
+ on_device: false,
2509
+ });
2510
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2511
+ expect(typedCall.mock.calls.filter(([request]) => request === 'DeviceStatusGet')).toHaveLength(
2512
+ 2
2513
+ );
2514
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2314
2515
  });
2315
2516
 
2316
2517
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2381,7 +2582,7 @@ describe('Protocol V2 feature adapter', () => {
2381
2582
  uuid: 'CACHED-SERIAL',
2382
2583
  deviceId: null,
2383
2584
  bleName: 'Cached BLE',
2384
- name: 'Cached Label',
2585
+ name: 'Cached BLE',
2385
2586
  label: 'Cached Label',
2386
2587
  deviceType: 'pro2',
2387
2588
  });
@@ -2408,7 +2609,7 @@ describe('Protocol V2 feature adapter', () => {
2408
2609
  expect(device.features?.label).toBeNull();
2409
2610
  });
2410
2611
 
2411
- test('prefers the real Protocol V2 label over the BLE name for display', () => {
2612
+ test('uses the BLE name for discovery while preserving the Protocol V2 label', () => {
2412
2613
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2413
2614
  (device as any).features = {
2414
2615
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
@@ -2416,9 +2617,10 @@ describe('Protocol V2 feature adapter', () => {
2416
2617
  bleName: 'Pro2 6136',
2417
2618
  };
2418
2619
 
2620
+ expect(device.getCurrentDisplayName()).toBe('My Pro 2');
2419
2621
  expect(device.toMessageObject()).toMatchObject({
2420
2622
  bleName: 'Pro2 6136',
2421
- name: 'My Pro 2',
2623
+ name: 'Pro2 6136',
2422
2624
  label: 'My Pro 2',
2423
2625
  });
2424
2626
  });
@@ -2472,10 +2674,6 @@ describe('Protocol V2 feature adapter', () => {
2472
2674
  hw: true,
2473
2675
  fw: true,
2474
2676
  coprocessor: true,
2475
- se1: true,
2476
- se2: true,
2477
- se3: true,
2478
- se4: true,
2479
2677
  },
2480
2678
  types: {
2481
2679
  version: true,
@@ -2499,8 +2697,10 @@ describe('Protocol V2 feature adapter', () => {
2499
2697
  ).rejects.toThrow('DeviceInfo not supported');
2500
2698
  });
2501
2699
 
2502
- test('does not inherit Pro or Pro model fallback ranges for Protocol V2 devices', () => {
2503
- const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any);
2700
+ test('does not inherit Pro or model_touch ranges for Protocol V2 devices', () => {
2701
+ const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2702
+ hw: { Device_type: DeviceType.PRO2 },
2703
+ });
2504
2704
  const checkedTypes: string[] = [];
2505
2705
 
2506
2706
  const versionRange = getMethodVersionRange(features, type => {
@@ -2512,11 +2712,13 @@ describe('Protocol V2 feature adapter', () => {
2512
2712
  });
2513
2713
 
2514
2714
  expect(versionRange).toBeUndefined();
2515
- expect(checkedTypes).toEqual(['pro2']);
2715
+ expect(checkedTypes).toEqual(['pro2', 'model_pro2']);
2516
2716
  });
2517
2717
 
2518
2718
  test('uses firmware-v1 as the Pro2 remote firmware config field', () => {
2519
- const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any);
2719
+ const features = normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2720
+ hw: { Device_type: DeviceType.PRO2 },
2721
+ });
2520
2722
 
2521
2723
  expect(
2522
2724
  getFirmwareUpdateField({
@@ -2667,10 +2869,6 @@ describe('Protocol V2 feature adapter', () => {
2667
2869
  hw: true,
2668
2870
  fw: true,
2669
2871
  coprocessor: true,
2670
- se1: true,
2671
- se2: true,
2672
- se3: true,
2673
- se4: true,
2674
2872
  },
2675
2873
  types: {
2676
2874
  version: true,
@@ -2681,7 +2879,9 @@ describe('Protocol V2 feature adapter', () => {
2681
2879
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2682
2880
  }
2683
2881
  );
2684
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2882
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2883
+ eventless_wallet_session: true,
2884
+ });
2685
2885
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2686
2886
  });
2687
2887
 
@@ -2719,7 +2919,9 @@ describe('Protocol V2 feature adapter', () => {
2719
2919
  }),
2720
2920
  expect.anything()
2721
2921
  );
2722
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2922
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2923
+ eventless_wallet_session: true,
2924
+ });
2723
2925
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2724
2926
  expect(device.features).toMatchObject({
2725
2927
  deviceId: null,
@@ -2985,10 +3187,6 @@ describe('Protocol V2 feature adapter', () => {
2985
3187
  fw: { application: { version: '1.2.4' } },
2986
3188
  },
2987
3189
  })
2988
- .mockResolvedValueOnce({
2989
- type: 'ProtocolInfo',
2990
- message: protocolV2ApplicationInfo,
2991
- })
2992
3190
  .mockResolvedValueOnce({
2993
3191
  type: 'DeviceStatus',
2994
3192
  message: { init_states: true, unlocked: true },
@@ -3010,7 +3208,7 @@ describe('Protocol V2 feature adapter', () => {
3010
3208
  firmwareVersion: '1.2.4',
3011
3209
  passphraseProtection: null,
3012
3210
  });
3013
- expect(typedCall).toHaveBeenCalledTimes(6);
3211
+ expect(typedCall).toHaveBeenCalledTimes(5);
3014
3212
  expect(typedCall).toHaveBeenNthCalledWith(
3015
3213
  4,
3016
3214
  'DeviceInfoGet',
@@ -3020,10 +3218,6 @@ describe('Protocol V2 feature adapter', () => {
3020
3218
  hw: true,
3021
3219
  fw: true,
3022
3220
  coprocessor: true,
3023
- se1: true,
3024
- se2: true,
3025
- se3: true,
3026
- se4: true,
3027
3221
  },
3028
3222
  types: {
3029
3223
  version: true,
@@ -3124,9 +3318,23 @@ describe('Protocol V2 feature adapter', () => {
3124
3318
  status: { init_states: true },
3125
3319
  }
3126
3320
  );
3321
+ const pinOnDevice = jest.fn();
3322
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3127
3323
 
3128
3324
  const features = await device.unlockDevice();
3129
3325
 
3326
+ expect(pinOnDevice).toHaveBeenCalledWith(
3327
+ device,
3328
+ DeviceSessionPinType.Main,
3329
+ expect.objectContaining({
3330
+ source: 'unlock-coordinator',
3331
+ reason: 'device-unlock',
3332
+ deviceOnly: true,
3333
+ })
3334
+ );
3335
+ expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3336
+ typedCall.mock.invocationCallOrder[0]
3337
+ );
3130
3338
  expect(typedCall.mock.calls).toEqual([
3131
3339
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3132
3340
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3172,9 +3380,12 @@ describe('Protocol V2 feature adapter', () => {
3172
3380
  throw new Error(`Unexpected request: ${requestType}`);
3173
3381
  });
3174
3382
  (device as any).commands = { typedCall };
3383
+ const pinOnDevice = jest.fn();
3384
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3175
3385
 
3176
- await device.unlockDevice();
3386
+ await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3177
3387
 
3388
+ expect(pinOnDevice).not.toHaveBeenCalled();
3178
3389
  expect(typedCall.mock.calls).toEqual([
3179
3390
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3180
3391
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3417,71 +3628,77 @@ describe('API compatibility handling', () => {
3417
3628
  });
3418
3629
  });
3419
3630
 
3420
- test('allows Pro2 Solana signing methods through Protocol V2 version checks', () => {
3421
- const features = {
3422
- deviceType: 'pro2',
3423
- } as Features;
3631
+ test.each(['pro2', 'neo'] as const)(
3632
+ 'allows %s Solana signing methods through Protocol V2 version checks',
3633
+ deviceType => {
3634
+ const features = {
3635
+ deviceType,
3636
+ } as Features;
3424
3637
 
3425
- const solSignMessageMethod = new SolSignMessage({
3426
- id: 1,
3427
- payload: {
3428
- method: 'solSignMessage',
3429
- path: "m/44'/501'/0'/0'",
3430
- messageHex: '48656c6c6f',
3431
- },
3432
- });
3433
- const solSignOffchainMessageMethod = new SolSignOffchainMessage({
3434
- id: 2,
3435
- payload: {
3436
- method: 'solSignOffchainMessage',
3437
- path: "m/44'/501'/0'/0'",
3438
- messageHex: '48656c6c6f',
3439
- },
3440
- });
3441
- const solSignTransactionMethod = new SolSignTransaction({
3442
- id: 3,
3443
- payload: {
3444
- method: 'solSignTransaction',
3445
- path: "m/44'/501'/0'/0'",
3446
- rawTx: '00',
3447
- },
3448
- });
3449
- const solSignVersionedTransactionMethod = new SolSignTransaction({
3450
- id: 4,
3451
- payload: {
3452
- method: 'solSignTransaction',
3453
- path: "m/44'/501'/0'/0'",
3454
- rawTx: '80',
3455
- },
3456
- });
3638
+ const solSignMessageMethod = new SolSignMessage({
3639
+ id: 1,
3640
+ payload: {
3641
+ method: 'solSignMessage',
3642
+ path: "m/44'/501'/0'/0'",
3643
+ messageHex: '48656c6c6f',
3644
+ },
3645
+ });
3646
+ const solSignOffchainMessageMethod = new SolSignOffchainMessage({
3647
+ id: 2,
3648
+ payload: {
3649
+ method: 'solSignOffchainMessage',
3650
+ path: "m/44'/501'/0'/0'",
3651
+ messageHex: '48656c6c6f',
3652
+ },
3653
+ });
3654
+ const solSignTransactionMethod = new SolSignTransaction({
3655
+ id: 3,
3656
+ payload: {
3657
+ method: 'solSignTransaction',
3658
+ path: "m/44'/501'/0'/0'",
3659
+ rawTx: '00',
3660
+ },
3661
+ });
3662
+ const solSignVersionedTransactionMethod = new SolSignTransaction({
3663
+ id: 4,
3664
+ payload: {
3665
+ method: 'solSignTransaction',
3666
+ path: "m/44'/501'/0'/0'",
3667
+ rawTx: '80',
3668
+ },
3669
+ });
3457
3670
 
3458
- solSignTransactionMethod.init();
3459
- solSignVersionedTransactionMethod.init();
3671
+ solSignTransactionMethod.init();
3672
+ solSignVersionedTransactionMethod.init();
3460
3673
 
3461
- expect(
3462
- getMethodVersionRange(features, type => solSignMessageMethod.getVersionRange()[type])
3463
- ).toEqual({
3464
- min: '0.0.0',
3465
- });
3466
- expect(
3467
- getMethodVersionRange(features, type => solSignOffchainMessageMethod.getVersionRange()[type])
3468
- ).toEqual({
3469
- min: '0.0.0',
3470
- });
3471
- expect(
3472
- getMethodVersionRange(features, type => solSignTransactionMethod.getVersionRange()[type])
3473
- ).toEqual({
3474
- min: '0.0.0',
3475
- });
3476
- expect(
3477
- getMethodVersionRange(
3478
- features,
3479
- type => solSignVersionedTransactionMethod.getVersionRange()[type]
3480
- )
3481
- ).toEqual({
3482
- min: '0.0.0',
3483
- });
3484
- });
3674
+ expect(
3675
+ getMethodVersionRange(features, type => solSignMessageMethod.getVersionRange()[type])
3676
+ ).toEqual({
3677
+ min: '0.0.0',
3678
+ });
3679
+ expect(
3680
+ getMethodVersionRange(
3681
+ features,
3682
+ type => solSignOffchainMessageMethod.getVersionRange()[type]
3683
+ )
3684
+ ).toEqual({
3685
+ min: '0.0.0',
3686
+ });
3687
+ expect(
3688
+ getMethodVersionRange(features, type => solSignTransactionMethod.getVersionRange()[type])
3689
+ ).toEqual({
3690
+ min: '0.0.0',
3691
+ });
3692
+ expect(
3693
+ getMethodVersionRange(
3694
+ features,
3695
+ type => solSignVersionedTransactionMethod.getVersionRange()[type]
3696
+ )
3697
+ ).toEqual({
3698
+ min: '0.0.0',
3699
+ });
3700
+ }
3701
+ );
3485
3702
 
3486
3703
  test('uses chunk transfer for large Sui transactions on Protocol V2', async () => {
3487
3704
  const rawTx = '0x'.concat('ab'.repeat(5000));
@@ -3576,39 +3793,15 @@ describe('API compatibility handling', () => {
3576
3793
  });
3577
3794
 
3578
3795
  describe('Protocol V2 firmware update targets', () => {
3579
- const OKPP_HEADER_SIZE = 0x52a0;
3580
-
3581
- const buildOkppHeader = ({
3582
- type = 'RESC',
3583
- version = [1, 0, 0],
3584
- payloadHash = 'ab'.repeat(64),
3585
- headerHash = 'cd'.repeat(64),
3586
- }: {
3587
- type?: string;
3588
- version?: [number, number, number];
3589
- payloadHash?: string;
3590
- headerHash?: string;
3591
- } = {}) => {
3592
- const header = new Uint8Array(OKPP_HEADER_SIZE);
3593
- const view = new DataView(header.buffer);
3594
- 'OKPP'.split('').forEach((char, index) => {
3595
- header[index] = char.charCodeAt(0);
3596
- });
3597
- view.setUint32(0x04, 1, true);
3598
- type.split('').forEach((char, index) => {
3599
- header[0x08 + index] = char.charCodeAt(0);
3600
- });
3601
- view.setUint32(0x0c, OKPP_HEADER_SIZE, true);
3602
- view.setUint32(0x10, version[0] * 0x10000 + version[1] * 0x100 + version[2], true);
3603
- const writeHex = (offset: number, hex: string) => {
3604
- for (let i = 0; i < hex.length / 2; i++) {
3605
- header[offset + i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
3606
- }
3607
- };
3608
- writeHex(0x200, payloadHash);
3609
- writeHex(0x240, headerHash);
3610
- return header;
3611
- };
3796
+ let forceReloadDataSpy: jest.SpyInstance;
3797
+
3798
+ beforeEach(() => {
3799
+ forceReloadDataSpy = jest.spyOn(DataManager, 'forceReloadData').mockResolvedValue(undefined);
3800
+ });
3801
+
3802
+ afterEach(() => {
3803
+ jest.restoreAllMocks();
3804
+ });
3612
3805
 
3613
3806
  test('keeps Protocol V2 firmware updates off the firmwareUpdateV3 path', () => {
3614
3807
  const method = new FirmwareUpdateV3({
@@ -3701,7 +3894,7 @@ describe('Protocol V2 firmware update targets', () => {
3701
3894
  specific: true,
3702
3895
  },
3703
3896
  },
3704
- { timeoutMs: 5000 }
3897
+ { timeoutMs: 30000 }
3705
3898
  );
3706
3899
  expect(typedCall).toHaveBeenCalledTimes(3);
3707
3900
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
@@ -3765,18 +3958,84 @@ describe('Protocol V2 firmware update targets', () => {
3765
3958
  });
3766
3959
  });
3767
3960
 
3768
- test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
3961
+ test('enters Protocol V2 bootloader before reading and downloading remote resources', async () => {
3769
3962
  const method = new FirmwareUpdateV4({
3770
3963
  id: 1,
3771
3964
  payload: {
3772
3965
  method: 'firmwareUpdateV4',
3966
+ platform: 'web',
3967
+ targetsToUpdate: ['resource'],
3773
3968
  },
3774
3969
  });
3970
+ method.init();
3971
+
3775
3972
  (method as any).device = stubDevice({
3776
- originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
3777
- features: { bootloader_mode: false, capabilities: [] },
3778
- isBootloader: () => false,
3779
- });
3973
+ originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
3974
+ features: {
3975
+ deviceType: 'pro2',
3976
+ firmwareVersion: '1.0.0',
3977
+ mode: 'normal',
3978
+ bootloaderMode: false,
3979
+ capabilities: [],
3980
+ },
3981
+ isBootloader: () => false,
3982
+ isRomloader: () => false,
3983
+ });
3984
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3985
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
3986
+ bootloaderBinary: null,
3987
+ fwBinaryMap: [],
3988
+ installItems: [],
3989
+ });
3990
+
3991
+ const order: string[] = [];
3992
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
3993
+ order.push('enter-bootloader');
3994
+ return Promise.resolve(true);
3995
+ });
3996
+ (method as any).prepareProtocolV2ResourceBundles = jest.fn().mockImplementation(() => {
3997
+ order.push('prepare-resources');
3998
+ return Promise.resolve([
3999
+ {
4000
+ name: 'images.okpkg',
4001
+ binary: new Uint8Array([1]).buffer,
4002
+ devicePath: 'vol0:/bundles/images/images.okpkg',
4003
+ },
4004
+ ]);
4005
+ });
4006
+ (method as any).executeProtocolV2Update = jest.fn().mockImplementation(() => {
4007
+ order.push('execute-update');
4008
+ return Promise.resolve();
4009
+ });
4010
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4011
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
4012
+ bootloaderVersion: '1.0.0',
4013
+ bleVersion: '1.0.0',
4014
+ firmwareVersion: '1.0.0',
4015
+ });
4016
+ method.postTipMessage = jest.fn();
4017
+
4018
+ await method.run();
4019
+
4020
+ expect(order).toEqual(['enter-bootloader', 'prepare-resources', 'execute-update']);
4021
+ });
4022
+
4023
+ test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
4024
+ const method = new FirmwareUpdateV4({
4025
+ id: 1,
4026
+ payload: {
4027
+ method: 'firmwareUpdateV4',
4028
+ },
4029
+ });
4030
+ (method as any).device = stubDevice({
4031
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4032
+ features: { bootloader_mode: false, capabilities: [] },
4033
+ isBootloader: () => false,
4034
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4035
+ mode: 'bootloader',
4036
+ bootloaderMode: true,
4037
+ }),
4038
+ });
3780
4039
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
3781
4040
  message: 'Device rebooted successfully',
3782
4041
  });
@@ -3803,6 +4062,7 @@ describe('Protocol V2 firmware update targets', () => {
3803
4062
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3804
4063
  (method as any).device.getCommands = () => ({ typedCall });
3805
4064
  (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4065
+ (method as any).protocolV2ExpectedPath = 'usb-path';
3806
4066
  method.postTipMessage = jest.fn();
3807
4067
 
3808
4068
  await (method as any).enterProtocolV2BootloaderMode();
@@ -3814,6 +4074,66 @@ describe('Protocol V2 firmware update targets', () => {
3814
4074
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3815
4075
  });
3816
4076
 
4077
+ test('announces bootloader success only after Protocol V2 bootloader mode is confirmed', async () => {
4078
+ const method = new FirmwareUpdateV4({
4079
+ id: 1,
4080
+ payload: {
4081
+ method: 'firmwareUpdateV4',
4082
+ },
4083
+ });
4084
+ (method as any).device = stubDevice({
4085
+ features: { bootloader_mode: false, capabilities: [] },
4086
+ isBootloader: () => false,
4087
+ isRomloader: () => false,
4088
+ });
4089
+ (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4090
+ message: 'Device rebooted successfully',
4091
+ });
4092
+
4093
+ const tipMessages: string[] = [];
4094
+ method.postTipMessage = jest.fn((message: string) => {
4095
+ tipMessages.push(message);
4096
+ });
4097
+ let successWasAnnouncedBeforeConfirmation = false;
4098
+ (method as any).waitForProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
4099
+ successWasAnnouncedBeforeConfirmation = tipMessages.includes('GoToBootloaderSuccess');
4100
+ return Promise.resolve({ mode: 'bootloader' });
4101
+ });
4102
+
4103
+ await (method as any).enterProtocolV2BootloaderMode();
4104
+
4105
+ expect(successWasAnnouncedBeforeConfirmation).toBe(false);
4106
+ expect(tipMessages).toEqual(['AutoRebootToBootloader', 'GoToBootloaderSuccess']);
4107
+ });
4108
+
4109
+ test('keeps Protocol V2 bootloader active before firmware transfer', async () => {
4110
+ const method = new FirmwareUpdateV4({
4111
+ id: 1,
4112
+ payload: {
4113
+ method: 'firmwareUpdateV4',
4114
+ },
4115
+ });
4116
+ (method as any).device = stubDevice({
4117
+ originalDescriptor: { id: 'usb-id', path: 'bootloader-path', protocolType: 'V2' },
4118
+ features: {
4119
+ deviceType: 'pro2',
4120
+ mode: 'bootloader',
4121
+ bootloaderMode: true,
4122
+ capabilities: [],
4123
+ },
4124
+ isBootloader: () => true,
4125
+ isRomloader: () => false,
4126
+ });
4127
+ (method as any).protocolV2Reboot = jest.fn();
4128
+ method.postTipMessage = jest.fn();
4129
+
4130
+ await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
4131
+
4132
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4133
+ expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
4134
+ expect(method.postTipMessage).toHaveBeenCalledWith('GoToBootloaderSuccess');
4135
+ });
4136
+
3817
4137
  test('keeps Protocol V2 romloader active before firmware transfer', async () => {
3818
4138
  const method = new FirmwareUpdateV4({
3819
4139
  id: 1,
@@ -3866,6 +4186,9 @@ describe('Protocol V2 firmware update targets', () => {
3866
4186
  },
3867
4187
  });
3868
4188
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4189
+ const prepareResourceBundles = jest
4190
+ .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4191
+ .mockResolvedValue(undefined);
3869
4192
  (method as any).protocolV2Reboot = jest.fn();
3870
4193
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3871
4194
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -3878,6 +4201,7 @@ describe('Protocol V2 firmware update targets', () => {
3878
4201
 
3879
4202
  await method.run();
3880
4203
 
4204
+ expect(prepareResourceBundles).not.toHaveBeenCalled();
3881
4205
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3882
4206
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3883
4207
  bootloaderBinary: null,
@@ -3897,6 +4221,10 @@ describe('Protocol V2 firmware update targets', () => {
3897
4221
  originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
3898
4222
  features: { bootloader_mode: false, capabilities: [] },
3899
4223
  isBootloader: () => false,
4224
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4225
+ mode: 'bootloader',
4226
+ bootloaderMode: true,
4227
+ }),
3900
4228
  });
3901
4229
  const typedCall = jest.fn().mockImplementation((requestType: string) => {
3902
4230
  if (requestType === 'DeviceInfoGet') {
@@ -3924,15 +4252,13 @@ describe('Protocol V2 firmware update targets', () => {
3924
4252
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3925
4253
  (method as any).device.getCommands = () => ({ typedCall });
3926
4254
  (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4255
+ (method as any).protocolV2ExpectedPath = 'app-path';
3927
4256
 
3928
4257
  await (method as any).waitForProtocolV2BootloaderMode(60 * 1000, 0);
3929
4258
 
3930
4259
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
3931
- expect(typedCall).toHaveBeenCalledTimes(2);
3932
- expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3933
- 'DeviceInfoGet',
3934
- 'ProtocolInfoRequest',
3935
- ]);
4260
+ expect(typedCall).toHaveBeenCalledTimes(1);
4261
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
3936
4262
  });
3937
4263
 
3938
4264
  test('does not run generic initialize during Protocol V2 USB firmware reconnect', async () => {
@@ -3960,12 +4286,16 @@ describe('Protocol V2 firmware update targets', () => {
3960
4286
  getCommands: () => commands,
3961
4287
  mainId: 'usb-path',
3962
4288
  });
3963
- const cachedDevice = { getConnectId: () => 'usb-path' };
4289
+ const cachedDevice = {
4290
+ getConnectId: () => 'usb-path',
4291
+ getCurrentSerialNo: () => '',
4292
+ };
3964
4293
  const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
3965
4294
  devices: {},
3966
4295
  deviceList: [cachedDevice],
3967
4296
  } as any);
3968
4297
  (method as any).device = device;
4298
+ (method as any).protocolV2ExpectedPath = 'usb-path';
3969
4299
 
3970
4300
  try {
3971
4301
  await (method as any).reconnectProtocolV2Device();
@@ -3977,6 +4307,184 @@ describe('Protocol V2 firmware update targets', () => {
3977
4307
  expect(initialize).not.toHaveBeenCalled();
3978
4308
  });
3979
4309
 
4310
+ test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
4311
+ const method = new FirmwareUpdateV4({
4312
+ id: 1,
4313
+ payload: {
4314
+ method: 'firmwareUpdateV4',
4315
+ },
4316
+ });
4317
+ const commands = { disposed: true, mainId: '' };
4318
+ const updateFromCache = jest.fn();
4319
+ const device = stubDevice({
4320
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4321
+ deviceConnector: {
4322
+ enumerate: jest.fn().mockResolvedValue({
4323
+ descriptors: [
4324
+ { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4325
+ { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4326
+ ],
4327
+ }),
4328
+ },
4329
+ updateFromCache,
4330
+ hasDeviceAcquire: jest.fn(() => false),
4331
+ acquire: jest.fn().mockResolvedValue(undefined),
4332
+ commands,
4333
+ getCommands: () => commands,
4334
+ mainId: 'old-usb-path',
4335
+ });
4336
+ const otherDevice = {
4337
+ getConnectId: () => 'usb-a',
4338
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4339
+ };
4340
+ const expectedDevice = {
4341
+ getConnectId: () => 'usb-b',
4342
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4343
+ };
4344
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4345
+ devices: {},
4346
+ deviceList: [otherDevice, expectedDevice],
4347
+ } as any);
4348
+ (method as any).device = device;
4349
+ (method as any).protocolV2ExpectedPath = 'usb-path';
4350
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4351
+
4352
+ try {
4353
+ await (method as any).reconnectProtocolV2Device();
4354
+ } finally {
4355
+ getDevices.mockRestore();
4356
+ }
4357
+
4358
+ expect(updateFromCache).toHaveBeenCalledWith(expectedDevice);
4359
+ });
4360
+
4361
+ test('rejects ambiguous Protocol V2 USB reconnect candidates without an identity match', async () => {
4362
+ const method = new FirmwareUpdateV4({
4363
+ id: 1,
4364
+ payload: {
4365
+ method: 'firmwareUpdateV4',
4366
+ },
4367
+ });
4368
+ const updateFromCache = jest.fn();
4369
+ const device = stubDevice({
4370
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4371
+ deviceConnector: {
4372
+ enumerate: jest.fn().mockResolvedValue({
4373
+ descriptors: [
4374
+ { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4375
+ { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4376
+ ],
4377
+ }),
4378
+ },
4379
+ updateFromCache,
4380
+ });
4381
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4382
+ devices: {},
4383
+ deviceList: [
4384
+ {
4385
+ getConnectId: () => 'usb-a',
4386
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4387
+ },
4388
+ {
4389
+ getConnectId: () => 'usb-b',
4390
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4391
+ },
4392
+ ],
4393
+ } as any);
4394
+ (method as any).device = device;
4395
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-C';
4396
+
4397
+ try {
4398
+ await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4399
+ } finally {
4400
+ getDevices.mockRestore();
4401
+ }
4402
+
4403
+ expect(updateFromCache).not.toHaveBeenCalled();
4404
+ });
4405
+
4406
+ test('allows a Protocol V2 USB reconnect candidate without a serial only when its path matches', async () => {
4407
+ const method = new FirmwareUpdateV4({
4408
+ id: 1,
4409
+ payload: {
4410
+ method: 'firmwareUpdateV4',
4411
+ },
4412
+ });
4413
+ const commands = { disposed: true, mainId: '' };
4414
+ const updateFromCache = jest.fn();
4415
+ const device = stubDevice({
4416
+ originalDescriptor: { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4417
+ deviceConnector: {
4418
+ enumerate: jest.fn().mockResolvedValue({
4419
+ descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4420
+ }),
4421
+ },
4422
+ updateFromCache,
4423
+ hasDeviceAcquire: jest.fn(() => false),
4424
+ acquire: jest.fn().mockResolvedValue(undefined),
4425
+ commands,
4426
+ getCommands: () => commands,
4427
+ mainId: 'old-usb-path',
4428
+ });
4429
+ const candidate = {
4430
+ getConnectId: () => 'usb-a',
4431
+ getCurrentSerialNo: () => '',
4432
+ };
4433
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4434
+ devices: {},
4435
+ deviceList: [candidate],
4436
+ } as any);
4437
+ (method as any).device = device;
4438
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-A';
4439
+ (method as any).protocolV2ExpectedPath = 'usb-a';
4440
+
4441
+ try {
4442
+ await (method as any).reconnectProtocolV2Device();
4443
+ } finally {
4444
+ getDevices.mockRestore();
4445
+ }
4446
+
4447
+ expect(updateFromCache).toHaveBeenCalledWith(candidate);
4448
+ });
4449
+
4450
+ test('rejects a single Protocol V2 USB reconnect candidate with a different physical serial', async () => {
4451
+ const method = new FirmwareUpdateV4({
4452
+ id: 1,
4453
+ payload: {
4454
+ method: 'firmwareUpdateV4',
4455
+ },
4456
+ });
4457
+ const updateFromCache = jest.fn();
4458
+ const device = stubDevice({
4459
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4460
+ deviceConnector: {
4461
+ enumerate: jest.fn().mockResolvedValue({
4462
+ descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4463
+ }),
4464
+ },
4465
+ updateFromCache,
4466
+ });
4467
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4468
+ devices: {},
4469
+ deviceList: [
4470
+ {
4471
+ getConnectId: () => 'usb-a',
4472
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4473
+ },
4474
+ ],
4475
+ } as any);
4476
+ (method as any).device = device;
4477
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4478
+
4479
+ try {
4480
+ await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4481
+ } finally {
4482
+ getDevices.mockRestore();
4483
+ }
4484
+
4485
+ expect(updateFromCache).not.toHaveBeenCalled();
4486
+ });
4487
+
3980
4488
  test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
3981
4489
  const method = new FirmwareUpdateV4({
3982
4490
  id: 1,
@@ -4018,6 +4526,7 @@ describe('Protocol V2 firmware update targets', () => {
4018
4526
  deviceList: [{ getConnectId: () => 'usb-path' }],
4019
4527
  } as any);
4020
4528
  (method as any).device = device;
4529
+ (method as any).protocolV2ExpectedPath = 'usb-path';
4021
4530
 
4022
4531
  try {
4023
4532
  await (method as any).reconnectProtocolV2Device();
@@ -4048,6 +4557,7 @@ describe('Protocol V2 firmware update targets', () => {
4048
4557
  (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
4049
4558
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4050
4559
  (method as any).device = stubDevice({
4560
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4051
4561
  getCommands: () => ({ typedCall }),
4052
4562
  probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4053
4563
  deviceId: 'changed-device',
@@ -4071,7 +4581,9 @@ describe('Protocol V2 firmware update targets', () => {
4071
4581
  });
4072
4582
  method.postTipMessage = jest.fn();
4073
4583
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4074
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4584
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4585
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4586
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(false);
4075
4587
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4076
4588
  message: 'Device rebooted successfully',
4077
4589
  });
@@ -4080,9 +4592,29 @@ describe('Protocol V2 firmware update targets', () => {
4080
4592
 
4081
4593
  expect(method.postTipMessage).not.toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
4082
4594
  expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4595
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4083
4596
  expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Normal);
4084
4597
  });
4085
4598
 
4599
+ test('skips a second Normal reboot after Pro2 already returned to App mode', async () => {
4600
+ const method = new FirmwareUpdateV4({
4601
+ id: 1,
4602
+ payload: {
4603
+ method: 'firmwareUpdateV4',
4604
+ },
4605
+ });
4606
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4607
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4608
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4609
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4610
+ (method as any).protocolV2Reboot = jest.fn();
4611
+
4612
+ await (method as any).exitProtocolV2BootloaderToNormal();
4613
+
4614
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4615
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4616
+ });
4617
+
4086
4618
  test('treats iOS BLE RxError 6 during Protocol V2 reboot as expected disconnect', async () => {
4087
4619
  const method = new FirmwareUpdateV4({
4088
4620
  id: 1,
@@ -4125,6 +4657,27 @@ describe('Protocol V2 firmware update targets', () => {
4125
4657
  });
4126
4658
  });
4127
4659
 
4660
+ test('treats Android BLE transport release during Protocol V2 reboot as expected', async () => {
4661
+ const method = new FirmwareUpdateV4({
4662
+ id: 1,
4663
+ payload: {
4664
+ method: 'firmwareUpdateV4',
4665
+ },
4666
+ });
4667
+ const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
4668
+
4669
+ (method as any).device = stubDevice({
4670
+ getCommands: () => ({ typedCall }),
4671
+ });
4672
+
4673
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Bootloader)).resolves.toEqual({
4674
+ message: 'Device rebooted successfully',
4675
+ });
4676
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
4677
+ DeviceRebootType.Bootloader
4678
+ );
4679
+ });
4680
+
4128
4681
  test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
4129
4682
  const method = new FirmwareUpdateV4({
4130
4683
  id: 1,
@@ -4215,7 +4768,7 @@ describe('Protocol V2 firmware update targets', () => {
4215
4768
  { target_id: 4, path: 'vol0:/application_p1.bin' },
4216
4769
  ]);
4217
4770
 
4218
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
4771
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
4219
4772
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4220
4773
  'DeviceFirmwareUpdateStatusGet',
4221
4774
  'DeviceFirmwareUpdateStatusGet',
@@ -4253,6 +4806,46 @@ describe('Protocol V2 firmware update targets', () => {
4253
4806
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4254
4807
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4255
4808
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4809
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4810
+ });
4811
+
4812
+ test('accepts an empty firmware status dump only after confirming normal mode', async () => {
4813
+ const method = new FirmwareUpdateV4({
4814
+ id: 1,
4815
+ payload: {
4816
+ method: 'firmwareUpdateV4',
4817
+ },
4818
+ });
4819
+ const typedCall = jest.fn().mockResolvedValue({
4820
+ type: 'DeviceFirmwareUpdateStatus',
4821
+ message: { records: [] },
4822
+ });
4823
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4824
+ let now = 0;
4825
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
4826
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
4827
+ now += 31 * 1000;
4828
+ callback();
4829
+ return 0 as any;
4830
+ }) as typeof setTimeout);
4831
+
4832
+ (method as any).device = stubDevice({
4833
+ getCommands: () => ({ typedCall }),
4834
+ });
4835
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4836
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4837
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4838
+ method.postProgressMessage = jest.fn();
4839
+
4840
+ await expect(
4841
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
4842
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
4843
+ ])
4844
+ ).resolves.toBeUndefined();
4845
+
4846
+ expect(typedCall).toHaveBeenCalledTimes(1);
4847
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4848
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4256
4849
  });
4257
4850
 
4258
4851
  test.each([
@@ -4535,6 +5128,151 @@ describe('Protocol V2 firmware update targets', () => {
4535
5128
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4536
5129
  });
4537
5130
 
5131
+ test('fails clearly when a requested target stays absent from the full status dump', async () => {
5132
+ const method = new FirmwareUpdateV4({
5133
+ id: 1,
5134
+ payload: {
5135
+ method: 'firmwareUpdateV4',
5136
+ },
5137
+ });
5138
+ const typedCall = jest.fn().mockResolvedValue({
5139
+ type: 'DeviceFirmwareUpdateStatus',
5140
+ message: {
5141
+ records: [{ target_id: 4, status: 2 }],
5142
+ },
5143
+ });
5144
+ let now = 0;
5145
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
5146
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5147
+ now += 31 * 1000;
5148
+ callback();
5149
+ return 0 as any;
5150
+ }) as typeof setTimeout);
5151
+
5152
+ (method as any).device = stubDevice({
5153
+ getCommands: () => ({ typedCall }),
5154
+ });
5155
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5156
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5157
+ method.postProgressMessage = jest.fn();
5158
+
5159
+ await expect(
5160
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5161
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
5162
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5163
+ ])
5164
+ ).rejects.toMatchObject({
5165
+ errorCode: HardwareErrorCode.FirmwareError,
5166
+ message: expect.stringContaining('targetIds=3'),
5167
+ });
5168
+ expect(typedCall).toHaveBeenCalledTimes(2);
5169
+ });
5170
+
5171
+ test('resets the missing-record grace period after a Pro2 reboot interruption', async () => {
5172
+ const method = new FirmwareUpdateV4({
5173
+ id: 1,
5174
+ payload: {
5175
+ method: 'firmwareUpdateV4',
5176
+ },
5177
+ });
5178
+ const typedCall = jest
5179
+ .fn()
5180
+ .mockResolvedValueOnce({
5181
+ type: 'DeviceFirmwareUpdateStatus',
5182
+ message: { records: [{ target_id: 4, status: 2 }] },
5183
+ })
5184
+ .mockRejectedValueOnce(new Error('Pro2 is rebooting'))
5185
+ .mockResolvedValueOnce({
5186
+ type: 'DeviceFirmwareUpdateStatus',
5187
+ message: { records: [{ target_id: 4, status: 2 }] },
5188
+ })
5189
+ .mockResolvedValueOnce({
5190
+ type: 'DeviceFirmwareUpdateStatus',
5191
+ message: {
5192
+ records: [
5193
+ { target_id: 3, status: 2 },
5194
+ { target_id: 4, status: 2 },
5195
+ ],
5196
+ },
5197
+ });
5198
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5199
+ let now = 0;
5200
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
5201
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5202
+ now += 31 * 1000;
5203
+ callback();
5204
+ return 0 as any;
5205
+ }) as typeof setTimeout);
5206
+
5207
+ (method as any).device = stubDevice({
5208
+ getCommands: () => ({ typedCall }),
5209
+ });
5210
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5211
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5212
+ method.postProgressMessage = jest.fn();
5213
+
5214
+ await expect(
5215
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5216
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
5217
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5218
+ ])
5219
+ ).resolves.toBeUndefined();
5220
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
5221
+ expect(typedCall).toHaveBeenCalledTimes(4);
5222
+ });
5223
+
5224
+ test('uses an eight-minute Pro2 install status window', async () => {
5225
+ const method = new FirmwareUpdateV4({
5226
+ id: 1,
5227
+ payload: {
5228
+ method: 'firmwareUpdateV4',
5229
+ },
5230
+ });
5231
+ jest
5232
+ .spyOn(Date, 'now')
5233
+ .mockReturnValueOnce(0)
5234
+ .mockReturnValue(8 * 60 * 1000);
5235
+
5236
+ await expect(
5237
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5238
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5239
+ ])
5240
+ ).rejects.toThrow('within 480s');
5241
+ });
5242
+
5243
+ test('uses a 90-second Pro2 bootloader reconnect window', async () => {
5244
+ const method = new FirmwareUpdateV4({
5245
+ id: 1,
5246
+ payload: {
5247
+ method: 'firmwareUpdateV4',
5248
+ },
5249
+ });
5250
+ jest
5251
+ .spyOn(Date, 'now')
5252
+ .mockReturnValueOnce(0)
5253
+ .mockReturnValue(90 * 1000);
5254
+
5255
+ await expect((method as any).waitForProtocolV2BootloaderMode()).rejects.toThrow('within 90s');
5256
+ });
5257
+
5258
+ test('uses a three-minute Pro2 final reconnect window', async () => {
5259
+ const method = new FirmwareUpdateV4({
5260
+ id: 1,
5261
+ payload: {
5262
+ method: 'firmwareUpdateV4',
5263
+ },
5264
+ });
5265
+ const waitForProtocolV2ReconnectAndFeatures = jest
5266
+ .fn()
5267
+ .mockRejectedValue(new Error('stop after capturing timeout'));
5268
+ (method as any).waitForProtocolV2ReconnectAndFeatures = waitForProtocolV2ReconnectAndFeatures;
5269
+
5270
+ await expect((method as any).waitForProtocolV2FinalFeatures()).rejects.toThrow(
5271
+ 'stop after capturing timeout'
5272
+ );
5273
+ expect(waitForProtocolV2ReconnectAndFeatures).toHaveBeenCalledWith(3 * 60 * 1000);
5274
+ });
5275
+
4538
5276
  test('uses SDK decoded enum names for Protocol V2 install polling', () => {
4539
5277
  const method = new FirmwareUpdateV4({
4540
5278
  id: 1,
@@ -4667,7 +5405,7 @@ describe('Protocol V2 firmware update targets', () => {
4667
5405
  path: true,
4668
5406
  },
4669
5407
  },
4670
- { timeoutMs: 5000 }
5408
+ { timeoutMs: 15000 }
4671
5409
  );
4672
5410
  });
4673
5411
 
@@ -5079,33 +5817,42 @@ describe('Protocol V2 firmware update targets', () => {
5079
5817
  getFirmwareLatestReleaseSpy.mockRestore();
5080
5818
  });
5081
5819
 
5082
- test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
5820
+ test('does not download Pro2 firmware components that App did not select', async () => {
5083
5821
  const method = new FirmwareUpdateV4({
5084
5822
  id: 1,
5085
5823
  payload: {
5086
5824
  method: 'firmwareUpdateV4',
5087
5825
  platform: 'web',
5088
- targetsToUpdate: ['resource'],
5089
5826
  },
5090
5827
  });
5091
5828
  method.init();
5092
5829
 
5830
+ const getSysResourceBinarySpy = jest
5831
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5832
+ .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5093
5833
  const getFirmwareLatestReleaseSpy = jest
5094
5834
  .spyOn(DataManager, 'getFirmwareLatestRelease')
5095
5835
  .mockReturnValue({
5096
5836
  required: false,
5097
5837
  version: [1, 0, 0],
5098
- url: '',
5099
- resourceBundles: [
5100
- {
5101
- name: 'images',
5102
- url: 'https://example.com/images.okpkg',
5103
- devicePath: 'VOL0:/resource/images/images.okpkg',
5104
- version: [2, 0, 0],
5105
- payloadHash: 'ab'.repeat(64),
5106
- headerHash: 'cd'.repeat(64),
5838
+ url: 'https://example.com/applicationP1.pp.bin',
5839
+ bootloaderVersion: [1, 0, 0],
5840
+ upgradeType: 'payload-package-set',
5841
+ installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5842
+ components: {
5843
+ bootloader: {
5844
+ target: 'BOOTLOADER',
5845
+ url: 'https://example.com/bootloader.pp.bin',
5107
5846
  },
5108
- ],
5847
+ applicationP1: {
5848
+ target: 'APPLICATION_P1',
5849
+ url: 'https://example.com/applicationP1.pp.bin',
5850
+ },
5851
+ applicationP2: {
5852
+ target: 'APPLICATION_P2',
5853
+ url: 'https://example.com/applicationP2.pp.bin',
5854
+ },
5855
+ },
5109
5856
  fingerprint: '',
5110
5857
  changelog: {
5111
5858
  'zh-CN': '',
@@ -5113,78 +5860,69 @@ describe('Protocol V2 firmware update targets', () => {
5113
5860
  },
5114
5861
  });
5115
5862
 
5116
- const bundles = (method as any).prepareProtocolV2ResourceBundles('universal', {
5863
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5117
5864
  deviceType: 'pro2',
5118
5865
  firmwareVersion: '1.0.0',
5866
+ bootloaderVersion: '1.0.0',
5119
5867
  capabilities: [],
5120
5868
  });
5121
5869
 
5122
- expect(bundles).toEqual([
5123
- {
5124
- name: 'images',
5125
- binary: expect.any(ArrayBuffer),
5126
- devicePath: 'vol0:/resource/images/images.okpkg',
5127
- url: 'https://example.com/images.okpkg',
5128
- version: [2, 0, 0],
5129
- payloadHash: 'ab'.repeat(64),
5130
- headerHash: 'cd'.repeat(64),
5131
- },
5132
- ]);
5133
- expect(bundles[0].binary.byteLength).toBe(0);
5870
+ expect(remoteBinaries).toEqual({
5871
+ bootloaderBinary: null,
5872
+ fwBinaryMap: [],
5873
+ installItems: [],
5874
+ });
5875
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5134
5876
 
5877
+ getSysResourceBinarySpy.mockRestore();
5135
5878
  getFirmwareLatestReleaseSpy.mockRestore();
5136
5879
  });
5137
5880
 
5138
- test('rejects remote RESC bundle paths before download or bootloader entry', async () => {
5881
+ test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5139
5882
  const method = new FirmwareUpdateV4({
5140
5883
  id: 1,
5141
5884
  payload: {
5142
5885
  method: 'firmwareUpdateV4',
5143
5886
  platform: 'web',
5144
- targetsToUpdate: ['resource'],
5145
5887
  },
5146
5888
  });
5147
5889
  method.init();
5148
5890
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5149
5891
 
5150
- const getSysResourceBinarySpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
5151
- const getFirmwareLatestReleaseSpy = jest
5152
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5153
- .mockReturnValue({
5154
- required: false,
5155
- version: [1, 0, 0],
5156
- url: '',
5157
- resourceBundles: [
5158
- {
5159
- name: 'images',
5160
- url: 'https://example.com/images.okpkg',
5161
- devicePath: 'vol0:/resource/../images.okpkg',
5162
- },
5163
- ],
5164
- fingerprint: '',
5165
- changelog: {
5166
- 'zh-CN': '',
5167
- 'en-US': '',
5892
+ const remoteBinaries = {
5893
+ bootloaderBinary: new Uint8Array([1]).buffer,
5894
+ fwBinaryMap: [
5895
+ {
5896
+ fileName: 'application_p1.bin',
5897
+ binary: new Uint8Array([2]).buffer,
5898
+ targetId: 3,
5168
5899
  },
5169
- });
5900
+ ],
5901
+ };
5170
5902
 
5171
5903
  (method as any).device = stubDevice({
5172
5904
  originalDescriptor: { protocolType: 'V2' },
5173
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5905
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5174
5906
  });
5175
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5176
- bootloaderBinary: null,
5177
- fwBinaryMap: [],
5907
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5908
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5909
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5910
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5911
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5912
+ bootloaderVersion: '1.0.0',
5913
+ bleVersion: '0.0.0',
5914
+ firmwareVersion: '1.0.0',
5178
5915
  });
5179
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
5180
5916
  method.postTipMessage = jest.fn();
5181
5917
 
5182
- await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
5183
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5184
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5918
+ await method.run();
5185
5919
 
5186
- getSysResourceBinarySpy.mockRestore();
5187
- getFirmwareLatestReleaseSpy.mockRestore();
5920
+ expect(forceReloadDataSpy).toHaveBeenCalledTimes(1);
5921
+ expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5922
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5923
+ bootloaderBinary: remoteBinaries.bootloaderBinary,
5924
+ fwBinaryMap: remoteBinaries.fwBinaryMap,
5925
+ });
5188
5926
  });
5189
5927
 
5190
5928
  test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
@@ -5239,273 +5977,34 @@ describe('Protocol V2 firmware update targets', () => {
5239
5977
  getFirmwareLatestReleaseSpy.mockRestore();
5240
5978
  });
5241
5979
 
5242
- test('downloads and validates remote RESC bundles before entering bootloader', async () => {
5980
+ test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
5243
5981
  const method = new FirmwareUpdateV4({
5244
5982
  id: 1,
5245
5983
  payload: {
5246
5984
  method: 'firmwareUpdateV4',
5247
5985
  platform: 'web',
5248
- targetsToUpdate: ['resource'],
5249
5986
  },
5250
5987
  });
5251
5988
  method.init();
5252
5989
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5253
-
5254
- const order: string[] = [];
5255
- const resourceBinary = buildOkppHeader().buffer as ArrayBuffer;
5256
- const getSysResourceBinarySpy = jest
5257
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5258
- .mockImplementation(() => {
5259
- order.push('download');
5260
- return Promise.resolve({ binary: resourceBinary });
5261
- });
5262
- const getFirmwareLatestReleaseSpy = jest
5263
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5264
- .mockReturnValue({
5265
- required: false,
5266
- version: [1, 0, 0],
5267
- url: '',
5268
- resourceBundles: [
5269
- {
5270
- name: 'images',
5271
- url: 'https://example.com/images.okpkg',
5272
- devicePath: ' VOL0:/resource/images/images.okpkg ',
5273
- },
5274
- ],
5275
- fingerprint: '',
5276
- changelog: {
5277
- 'zh-CN': '',
5278
- 'en-US': '',
5279
- },
5280
- });
5281
-
5282
- (method as any).device = stubDevice({
5283
- originalDescriptor: { protocolType: 'V2' },
5284
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5285
- });
5286
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5287
- bootloaderBinary: null,
5288
- fwBinaryMap: [],
5289
- });
5290
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
5291
- order.push('bootloader');
5292
- return Promise.resolve(true);
5293
- });
5294
- const executeProtocolV2UpdateSpy = jest.spyOn(method as any, 'executeProtocolV2Update');
5295
- (method as any).executeProtocolV2TransferPhase = jest.fn().mockResolvedValue(undefined);
5296
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue(undefined);
5297
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5298
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5299
- bootloaderVersion: '1.0.0',
5300
- bleVersion: '0.0.0',
5301
- firmwareVersion: '1.0.0',
5302
- });
5303
- method.postTipMessage = jest.fn();
5304
-
5305
- await method.run();
5306
-
5307
- expect(order).toEqual(['download', 'bootloader']);
5308
- expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
5309
- bootloaderBinary: null,
5310
- fwBinaryMap: [],
5311
- resourceBundles: [
5312
- {
5313
- name: 'images',
5314
- binary: resourceBinary,
5315
- devicePath: 'vol0:/resource/images/images.okpkg',
5316
- url: 'https://example.com/images.okpkg',
5317
- },
5318
- ],
5319
- });
5320
-
5321
- executeProtocolV2UpdateSpy.mockRestore();
5322
- getSysResourceBinarySpy.mockRestore();
5323
- getFirmwareLatestReleaseSpy.mockRestore();
5324
- });
5325
-
5326
- test('rejects a remote RESC bundle without an OKPP header when metadata is absent', async () => {
5327
- const method = new FirmwareUpdateV4({
5328
- id: 1,
5329
- payload: {
5330
- method: 'firmwareUpdateV4',
5331
- },
5332
- });
5333
- method.init();
5334
- const getSysResourceBinarySpy = jest
5335
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5336
- .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5337
-
5338
- await expect(
5339
- (method as any).materializeProtocolV2ResourceBundles([
5340
- {
5341
- name: 'images',
5342
- binary: new ArrayBuffer(0),
5343
- devicePath: 'vol0:/resource/images/images.okpkg',
5344
- url: 'https://example.com/images.okpkg',
5345
- },
5346
- ])
5347
- ).rejects.toThrow('Invalid Protocol V2 RESC bundle header: images');
5348
-
5349
- getSysResourceBinarySpy.mockRestore();
5350
- });
5351
-
5352
- test('rejects an empty remote RESC bundle before entering bootloader', async () => {
5353
- const method = new FirmwareUpdateV4({
5354
- id: 1,
5355
- payload: {
5356
- method: 'firmwareUpdateV4',
5357
- platform: 'web',
5358
- targetsToUpdate: ['resource'],
5359
- },
5360
- });
5361
- method.init();
5362
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5363
-
5364
- const getSysResourceBinarySpy = jest
5365
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5366
- .mockResolvedValue({ binary: new ArrayBuffer(0) });
5367
- const getFirmwareLatestReleaseSpy = jest
5368
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5369
- .mockReturnValue({
5370
- required: false,
5371
- version: [1, 0, 0],
5372
- url: '',
5373
- resourceBundles: [
5374
- {
5375
- name: 'images',
5376
- url: 'https://example.com/images.okpkg',
5377
- devicePath: 'vol0:/resource/images/images.okpkg',
5378
- },
5379
- ],
5380
- fingerprint: '',
5381
- changelog: {
5382
- 'zh-CN': '',
5383
- 'en-US': '',
5384
- },
5385
- });
5386
5990
  (method as any).device = stubDevice({
5387
5991
  originalDescriptor: { protocolType: 'V2' },
5388
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5389
- });
5390
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5391
- bootloaderBinary: null,
5392
- fwBinaryMap: [],
5992
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5393
5993
  });
5994
+ forceReloadDataSpy.mockRejectedValue(
5995
+ ERRORS.TypedError(HardwareErrorCode.NetworkError, 'latest config unavailable')
5996
+ );
5997
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5394
5998
  (method as any).enterProtocolV2BootloaderMode = jest.fn();
5395
5999
  method.postTipMessage = jest.fn();
5396
6000
 
5397
- await expect(method.run()).rejects.toThrow('Protocol V2 RESC bundle is empty: images');
5398
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5399
-
5400
- getSysResourceBinarySpy.mockRestore();
5401
- getFirmwareLatestReleaseSpy.mockRestore();
5402
- });
5403
-
5404
- test('does not download Pro2 firmware components that App did not select', async () => {
5405
- const method = new FirmwareUpdateV4({
5406
- id: 1,
5407
- payload: {
5408
- method: 'firmwareUpdateV4',
5409
- platform: 'web',
5410
- },
5411
- });
5412
- method.init();
5413
-
5414
- const getSysResourceBinarySpy = jest
5415
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5416
- .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5417
- const getFirmwareLatestReleaseSpy = jest
5418
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5419
- .mockReturnValue({
5420
- required: false,
5421
- version: [1, 0, 0],
5422
- url: 'https://example.com/applicationP1.pp.bin',
5423
- bootloaderVersion: [1, 0, 0],
5424
- upgradeType: 'payload-package-set',
5425
- installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5426
- components: {
5427
- bootloader: {
5428
- target: 'BOOTLOADER',
5429
- url: 'https://example.com/bootloader.pp.bin',
5430
- },
5431
- applicationP1: {
5432
- target: 'APPLICATION_P1',
5433
- url: 'https://example.com/applicationP1.pp.bin',
5434
- },
5435
- applicationP2: {
5436
- target: 'APPLICATION_P2',
5437
- url: 'https://example.com/applicationP2.pp.bin',
5438
- },
5439
- },
5440
- fingerprint: '',
5441
- changelog: {
5442
- 'zh-CN': '',
5443
- 'en-US': '',
5444
- },
5445
- });
5446
-
5447
- const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5448
- deviceType: 'pro2',
5449
- firmwareVersion: '1.0.0',
5450
- bootloaderVersion: '1.0.0',
5451
- capabilities: [],
5452
- });
5453
-
5454
- expect(remoteBinaries).toEqual({
5455
- bootloaderBinary: null,
5456
- fwBinaryMap: [],
5457
- installItems: [],
5458
- });
5459
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5460
-
5461
- getSysResourceBinarySpy.mockRestore();
5462
- getFirmwareLatestReleaseSpy.mockRestore();
5463
- });
5464
-
5465
- test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5466
- const method = new FirmwareUpdateV4({
5467
- id: 1,
5468
- payload: {
5469
- method: 'firmwareUpdateV4',
5470
- platform: 'web',
5471
- },
5472
- });
5473
- method.init();
5474
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5475
-
5476
- const remoteBinaries = {
5477
- bootloaderBinary: new Uint8Array([1]).buffer,
5478
- fwBinaryMap: [
5479
- {
5480
- fileName: 'application_p1.bin',
5481
- binary: new Uint8Array([2]).buffer,
5482
- targetId: 3,
5483
- },
5484
- ],
5485
- };
5486
-
5487
- (method as any).device = stubDevice({
5488
- originalDescriptor: { protocolType: 'V2' },
5489
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5490
- });
5491
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5492
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5493
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5494
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5495
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5496
- bootloaderVersion: '1.0.0',
5497
- bleVersion: '0.0.0',
5498
- firmwareVersion: '1.0.0',
6001
+ await expect(method.run()).rejects.toMatchObject({
6002
+ errorCode: HardwareErrorCode.NetworkError,
6003
+ message: 'latest config unavailable',
5499
6004
  });
5500
- method.postTipMessage = jest.fn();
5501
-
5502
- await method.run();
5503
6005
 
5504
- expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5505
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5506
- bootloaderBinary: remoteBinaries.bootloaderBinary,
5507
- fwBinaryMap: remoteBinaries.fwBinaryMap,
5508
- });
6006
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
6007
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5509
6008
  });
5510
6009
 
5511
6010
  test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
@@ -5544,14 +6043,14 @@ describe('Protocol V2 firmware update targets', () => {
5544
6043
  });
5545
6044
  });
5546
6045
 
5547
- test('treats manual resource bundles as explicit payload without remote firmware auto-fill', async () => {
6046
+ test('treats manual resource files as explicit payload without remote firmware auto-fill', async () => {
5548
6047
  const resourceBundle = new Uint8Array([1, 2, 3]).buffer;
5549
6048
  const method = new FirmwareUpdateV4({
5550
6049
  id: 1,
5551
6050
  payload: {
5552
6051
  method: 'firmwareUpdateV4',
5553
6052
  platform: 'web',
5554
- resourceBundleFiles: [
6053
+ resourceFiles: [
5555
6054
  {
5556
6055
  binary: resourceBundle,
5557
6056
  devicePath: ' VOL0:/resource/images/images.okpkg ',
@@ -5599,7 +6098,7 @@ describe('Protocol V2 firmware update targets', () => {
5599
6098
  payload: {
5600
6099
  method: 'firmwareUpdateV4',
5601
6100
  platform: 'web',
5602
- resourceBundleFiles: [
6101
+ resourceFiles: [
5603
6102
  {
5604
6103
  binary: new Uint8Array([1, 2, 3]).buffer,
5605
6104
  devicePath: 'vol2:/resource/images/images.okpkg',
@@ -5618,7 +6117,7 @@ describe('Protocol V2 firmware update targets', () => {
5618
6117
  (method as any).enterProtocolV2BootloaderMode = jest.fn();
5619
6118
  method.postTipMessage = jest.fn();
5620
6119
 
5621
- await expect(method.run()).rejects.toThrow('resourceBundleFiles[0].devicePath');
6120
+ await expect(method.run()).rejects.toThrow('resourceFiles[0].devicePath');
5622
6121
  expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5623
6122
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5624
6123
  });
@@ -5680,8 +6179,10 @@ describe('Protocol V2 firmware update targets', () => {
5680
6179
 
5681
6180
  (method as any).device = stubDevice({
5682
6181
  getCommands: () => ({ typedCall }),
6182
+ getCurrentDeviceType: () => 'pro2',
6183
+ toMessageObject: () => ({ connectId: 'firmware-device' }),
5683
6184
  });
5684
- method.postProgressMessage = jest.fn();
6185
+ method.postMessage = jest.fn();
5685
6186
 
5686
6187
  const source = await openFirmwareByteSource({
5687
6188
  binary: new Uint8Array(4097).buffer,
@@ -5703,6 +6204,17 @@ describe('Protocol V2 firmware update targets', () => {
5703
6204
  expect(writePayloads.map(payload => payload.overwrite)).toEqual([true, false]);
5704
6205
  expect(writePayloads.every(payload => payload.append === false)).toBe(true);
5705
6206
  expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
6207
+ expect(method.postMessage).toHaveBeenLastCalledWith({
6208
+ event: 'UI_EVENT',
6209
+ type: UI_REQUEST.FIRMWARE_PROGRESS,
6210
+ payload: expect.objectContaining({
6211
+ progress: 99,
6212
+ progressType: 'transferData',
6213
+ transferredBytes: 4097,
6214
+ totalBytes: 4097,
6215
+ elapsedMs: expect.any(Number),
6216
+ }),
6217
+ });
5706
6218
  });
5707
6219
 
5708
6220
  test('restarts the whole file from offset zero after an ambiguous chunk write failure', async () => {
@@ -5861,6 +6373,10 @@ describe('Protocol V2 firmware update targets', () => {
5861
6373
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
5862
6374
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1800, 1]);
5863
6375
  expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
6376
+ expect(typedCall.mock.calls.map(call => call[3])).toEqual([
6377
+ { writeWithResponse: true },
6378
+ { writeWithResponse: true },
6379
+ ]);
5864
6380
  });
5865
6381
 
5866
6382
  test('ends device confirmation and starts install progress only after Protocol V2 ACK', async () => {
@@ -6028,7 +6544,7 @@ describe('Protocol V2 firmware update method', () => {
6028
6544
  expect(typedCall).not.toHaveBeenCalled();
6029
6545
  });
6030
6546
 
6031
- test.each([0, 2])(
6547
+ test.each([0, 1, 2])(
6032
6548
  'rejects firmware target %s because the Pro2 bootloader cannot install it',
6033
6549
  async targetId => {
6034
6550
  const typedCall = jest.fn();
@@ -6061,8 +6577,8 @@ describe('Protocol V2 firmware update method', () => {
6061
6577
  targets: [
6062
6578
  {
6063
6579
  target_id: undefined,
6064
- targetId: 1,
6065
- path: 'vol0:resource.crate.okpkg',
6580
+ targetId: 3,
6581
+ path: 'vol0:bootloader.okpkg',
6066
6582
  },
6067
6583
  ],
6068
6584
  } as any,
@@ -6074,7 +6590,7 @@ describe('Protocol V2 firmware update method', () => {
6074
6590
 
6075
6591
  await method.run();
6076
6592
  expect(typedCall.mock.calls[0][2]).toEqual({
6077
- targets: [{ target_id: 1, path: 'vol0:resource.crate.okpkg' }],
6593
+ targets: [{ target_id: 3, path: 'vol0:bootloader.okpkg' }],
6078
6594
  });
6079
6595
  });
6080
6596
 
@@ -6139,19 +6655,77 @@ describe('Protocol V2 firmware update method', () => {
6139
6655
  });
6140
6656
 
6141
6657
  describe('Protocol V2 firmware reconnect identity', () => {
6142
- test('keeps the host plan identity as the V4 reconnect anchor', () => {
6143
- const method = new FirmwareUpdateV4({
6144
- id: 1,
6145
- payload: {
6146
- method: 'firmwareUpdateV4',
6147
- platform: 'native',
6148
- expectedDeviceId: 'persisted-device-id',
6149
- },
6150
- });
6151
-
6152
- method.init();
6153
-
6154
- expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
6658
+ const createResourceFilesystemTypedCall = (
6659
+ stable: Array<{ type: string; size: number; headerHash: string }>,
6660
+ missingTypes: string[] = []
6661
+ ) => {
6662
+ const missing = new Set(missingTypes);
6663
+ const resourceByPath = new Map(
6664
+ stable.map(resource => [
6665
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
6666
+ resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
6667
+ ],
6668
+ resource,
6669
+ ])
6670
+ );
6671
+ return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
6672
+ if (requestType === 'ResourceInventoryGet') {
6673
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
6674
+ }
6675
+ if (requestType === 'FilesystemPathInfoQuery') {
6676
+ const resource = resourceByPath.get(payload.path);
6677
+ const exists = Boolean(resource && !missing.has(resource.type));
6678
+ return {
6679
+ message: {
6680
+ exist: exists,
6681
+ directory: false,
6682
+ size: exists ? resource?.size : 0,
6683
+ },
6684
+ };
6685
+ }
6686
+ if (requestType === 'FilesystemFileRead') {
6687
+ const resource = resourceByPath.get(payload.file.path);
6688
+ if (!resource || missing.has(resource.type)) throw new Error('missing resource');
6689
+ const header = new Uint8Array(0x52a0);
6690
+ const view = new DataView(header.buffer);
6691
+ 'OKPP'.split('').forEach((char, index) => {
6692
+ header[index] = char.charCodeAt(0);
6693
+ });
6694
+ 'RESC'.split('').forEach((char, index) => {
6695
+ header[0x08 + index] = char.charCodeAt(0);
6696
+ });
6697
+ view.setUint32(0x0c, header.byteLength, true);
6698
+ for (let index = 0; index < resource.headerHash.length / 2; index++) {
6699
+ header[0x240 + index] = Number.parseInt(
6700
+ resource.headerHash.slice(index * 2, index * 2 + 2),
6701
+ 16
6702
+ );
6703
+ }
6704
+ const offset = Number(payload.file.offset);
6705
+ const chunkLength = Number(payload.chunk_len);
6706
+ return {
6707
+ message: {
6708
+ data: header.slice(offset, offset + chunkLength),
6709
+ },
6710
+ };
6711
+ }
6712
+ throw new Error(`Unexpected request: ${requestType}`);
6713
+ });
6714
+ };
6715
+
6716
+ test('keeps the host plan identity as the V4 reconnect anchor', () => {
6717
+ const method = new FirmwareUpdateV4({
6718
+ id: 1,
6719
+ payload: {
6720
+ method: 'firmwareUpdateV4',
6721
+ platform: 'native',
6722
+ expectedDeviceId: 'persisted-device-id',
6723
+ },
6724
+ });
6725
+
6726
+ method.init();
6727
+
6728
+ expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
6155
6729
  });
6156
6730
 
6157
6731
  test('rejects a different physical serial before firmware transfer resumes', () => {
@@ -6252,6 +6826,197 @@ describe('Protocol V2 firmware reconnect identity', () => {
6252
6826
  expect(readChunkLengths.length).toBeGreaterThan(1);
6253
6827
  expect(Math.max(...readChunkLengths)).toBe(900);
6254
6828
  });
6829
+
6830
+ test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
6831
+ const method = new FirmwareUpdateV4({
6832
+ id: 1,
6833
+ payload: {
6834
+ method: 'firmwareUpdateV4',
6835
+ platform: 'web',
6836
+ targetsToUpdate: ['resource'],
6837
+ },
6838
+ });
6839
+ method.init();
6840
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6841
+ (type, index) => ({
6842
+ type,
6843
+ url: `https://example.com/${type}.okpkg`,
6844
+ size: 0x52a0 + index + 1,
6845
+ fileHash: 'a'.repeat(64),
6846
+ headerHash: index.toString(16).padStart(128, '0'),
6847
+ })
6848
+ );
6849
+ const typedCall = createResourceFilesystemTypedCall(stable);
6850
+ (method as any).device = stubDevice({
6851
+ getCommands: () => ({ typedCall }),
6852
+ getCurrentDeviceType: () => 'pro2',
6853
+ });
6854
+ const resourcesSpy = jest
6855
+ .spyOn(DataManager, 'getProtocolV2Resources')
6856
+ .mockReturnValue(stable as any);
6857
+ (method as any).downloadProtocolV2Resource = jest.fn();
6858
+
6859
+ await expect((method as any).prepareProtocolV2ResourceBundles()).resolves.toEqual([]);
6860
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6861
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
6862
+ expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
6863
+ resourcesSpy.mockRestore();
6864
+ });
6865
+
6866
+ test('downloads only changed resources from loader inventory', async () => {
6867
+ const method = new FirmwareUpdateV4({
6868
+ id: 1,
6869
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6870
+ });
6871
+ method.init();
6872
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6873
+ (type, index) => ({
6874
+ type,
6875
+ url: `https://example.com/${type}.okpkg`,
6876
+ size: 0x52a0 + index + 1,
6877
+ fileHash: 'a'.repeat(64),
6878
+ headerHash: index.toString(16).padStart(128, '0'),
6879
+ })
6880
+ );
6881
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6882
+ (method as any).device = stubDevice({
6883
+ getCommands: () => ({ typedCall }),
6884
+ getCurrentDeviceType: () => 'pro2',
6885
+ });
6886
+ const resourcesSpy = jest
6887
+ .spyOn(DataManager, 'getProtocolV2Resources')
6888
+ .mockReturnValue(stable as any);
6889
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6890
+ Promise.resolve({
6891
+ name: `${resource.type}.okpkg`,
6892
+ binary: new ArrayBuffer(resource.size),
6893
+ devicePath: `vol0:/${resource.type}.okpkg`,
6894
+ })
6895
+ );
6896
+
6897
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6898
+
6899
+ expect(bundles).toHaveLength(1);
6900
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6901
+ resourcesSpy.mockRestore();
6902
+ });
6903
+
6904
+ test('uses filesystem inventory for incremental Bootloader recovery', async () => {
6905
+ const method = new FirmwareUpdateV4({
6906
+ id: 1,
6907
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6908
+ });
6909
+ method.init();
6910
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6911
+ (type, index) => ({
6912
+ type,
6913
+ url: `https://example.com/${type}.okpkg`,
6914
+ size: 0x52a0 + index + 1,
6915
+ fileHash: 'a'.repeat(64),
6916
+ headerHash: index.toString(16).padStart(128, '0'),
6917
+ })
6918
+ );
6919
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6920
+ (method as any).device = stubDevice({
6921
+ getCommands: () => ({ typedCall }),
6922
+ getCurrentDeviceType: () => 'pro2',
6923
+ });
6924
+ const resourcesSpy = jest
6925
+ .spyOn(DataManager, 'getProtocolV2Resources')
6926
+ .mockReturnValue(stable as any);
6927
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6928
+ Promise.resolve({
6929
+ name: `${resource.type}.okpkg`,
6930
+ binary: new ArrayBuffer(resource.size),
6931
+ devicePath: `vol0:/${resource.type}.okpkg`,
6932
+ })
6933
+ );
6934
+
6935
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6936
+
6937
+ expect(bundles).toHaveLength(1);
6938
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6939
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
6940
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6941
+ resourcesSpy.mockRestore();
6942
+ });
6943
+
6944
+ test('does not resolve boot resources unless the optional target is selected', async () => {
6945
+ const method = new FirmwareUpdateV4({
6946
+ id: 1,
6947
+ payload: { method: 'firmwareUpdateV4', platform: 'web' },
6948
+ });
6949
+ method.init();
6950
+ const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6951
+ const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
6952
+
6953
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
6954
+
6955
+ expect(configSpy).not.toHaveBeenCalled();
6956
+ expect(downloadSpy).not.toHaveBeenCalled();
6957
+ });
6958
+
6959
+ test('downloads and maps selected boot resources as direct RES files', async () => {
6960
+ const bytes = new Uint8Array([1, 2, 3, 4]);
6961
+ const binary = bytes.buffer as ArrayBuffer;
6962
+ const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
6963
+ const resource = {
6964
+ required: false as const,
6965
+ target: 'RES' as const,
6966
+ files: [
6967
+ {
6968
+ name: 'bootloader_crest.bin',
6969
+ url: 'https://example.com/bootloader_crest.bin',
6970
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
6971
+ size: bytes.byteLength,
6972
+ fileHash,
6973
+ },
6974
+ ],
6975
+ };
6976
+ const method = new FirmwareUpdateV4({
6977
+ id: 1,
6978
+ payload: {
6979
+ method: 'firmwareUpdateV4',
6980
+ platform: 'web',
6981
+ targetsToUpdate: ['boot_resources'],
6982
+ },
6983
+ });
6984
+ method.init();
6985
+ (method as any).device = stubDevice({ getCurrentDeviceType: () => 'pro2' });
6986
+ jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
6987
+ jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
6988
+
6989
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual([
6990
+ {
6991
+ name: 'bootloader_crest.bin',
6992
+ binary,
6993
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
6994
+ },
6995
+ ]);
6996
+ });
6997
+
6998
+ test('rejects a manually supplied resource file when its manifest hash does not match', () => {
6999
+ const method = new FirmwareUpdateV4({
7000
+ id: 1,
7001
+ payload: {
7002
+ method: 'firmwareUpdateV4',
7003
+ platform: 'web',
7004
+ resourceFiles: [
7005
+ {
7006
+ binary: new Uint8Array([1, 2, 3]).buffer,
7007
+ devicePath: 'vol0:/assets/loaders/boot.staging/graphics/bootloader_crest.bin',
7008
+ size: 3,
7009
+ fileHash: '00'.repeat(32),
7010
+ },
7011
+ ],
7012
+ },
7013
+ });
7014
+ method.init();
7015
+
7016
+ expect(() => (method as any).prepareExplicitProtocolV2ResourceFiles()).toThrow(
7017
+ 'SHA-256 mismatch'
7018
+ );
7019
+ });
6255
7020
  });
6256
7021
 
6257
7022
  describe('Protocol V2 explicit USB device selection', () => {
@@ -6390,311 +7155,230 @@ describe('Protocol V2 protected method execution', () => {
6390
7155
  expect(method.unlockPolicy).toBe('none');
6391
7156
  });
6392
7157
 
6393
- test('unlocks and retries an opted-in Protocol V2 method once', async () => {
7158
+ test('checks fresh status for a wallet business method without a name allowlist', async () => {
6394
7159
  const calls: string[] = [];
7160
+ const features = { unlocked: true };
6395
7161
  const method = {
6396
- name: 'testBusinessMethod',
6397
- unlockPolicy: 'retry-on-locked',
6398
- protocolV2UiInteraction: { reason: 'settings-page' },
6399
- run: jest
6400
- .fn()
6401
- .mockImplementationOnce(() => {
6402
- calls.push('run-1');
6403
- return Promise.reject(deviceLockedError());
6404
- })
6405
- .mockImplementationOnce(() => {
6406
- calls.push('run-2');
6407
- return Promise.resolve({ message: 'ok' });
6408
- }),
7162
+ name: 'confluxSignMessageCIP23',
7163
+ unlockPolicy: 'none',
7164
+ useDevicePassphraseState: true,
7165
+ protocolV2UiInteraction: { reason: 'address-confirmation' },
7166
+ run: jest.fn(() => {
7167
+ calls.push('run');
7168
+ return Promise.resolve({ address: '0x1' });
7169
+ }),
6409
7170
  };
6410
7171
  const device = {
7172
+ features,
7173
+ commands: {
7174
+ typedCall: jest.fn(() => {
7175
+ calls.push('status');
7176
+ return Promise.resolve({ message: { unlocked: true } });
7177
+ }),
7178
+ },
6411
7179
  isProtocolV2: () => true,
6412
- unlockDevice: jest.fn(() => {
6413
- calls.push('unlock');
6414
- return Promise.resolve();
7180
+ isBootloader: () => false,
7181
+ isRomloader: () => false,
7182
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
7183
+ features.unlocked = status.unlocked === true;
7184
+ return features;
6415
7185
  }),
7186
+ unlockDevice: jest.fn(),
6416
7187
  };
6417
7188
  const uiCoordinator = {
6418
7189
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6419
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6420
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
7190
+ enterUnlockInteraction: jest.fn(),
6421
7191
  };
6422
7192
 
6423
7193
  await expect(
6424
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6425
- ).resolves.toEqual({ message: 'ok' });
6426
- expect(calls).toEqual([
6427
- 'method-prompt',
6428
- 'run-1',
6429
- 'unlock-prompt',
6430
- 'unlock',
6431
- 'method-prompt',
6432
- 'run-2',
6433
- ]);
7194
+ runMethodWithUnlockPolicy(method as any, device as any, {
7195
+ uiCoordinator: uiCoordinator as any,
7196
+ })
7197
+ ).resolves.toEqual({ address: '0x1' });
7198
+
7199
+ expect(calls).toEqual(['status', 'method-prompt', 'run']);
7200
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
7201
+ expect(device.unlockDevice).not.toHaveBeenCalled();
7202
+ expect(method.run).toHaveBeenCalledTimes(1);
6434
7203
  });
6435
7204
 
6436
- test('restores the expected hidden-wallet session before retrying after unlock', async () => {
7205
+ test('validates the fresh device identity before starting unlock', async () => {
6437
7206
  const calls: string[] = [];
7207
+ const identityError = new Error('Unexpected device');
6438
7208
  const method = {
6439
7209
  name: 'evmSignMessage',
6440
- payload: { passphraseState: 'hidden-state' },
7210
+ unlockPolicy: 'none',
6441
7211
  useDevicePassphraseState: true,
6442
- unlockPolicy: 'retry-on-locked',
6443
- run: jest
6444
- .fn()
6445
- .mockImplementationOnce(() => {
6446
- calls.push('run-1');
6447
- return Promise.reject(deviceLockedError());
6448
- })
6449
- .mockImplementationOnce(() => {
6450
- calls.push('run-2');
6451
- return Promise.resolve({ message: 'ok' });
6452
- }),
7212
+ run: jest.fn(),
6453
7213
  };
6454
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6455
- if (requestType === 'ProtocolInfoRequest') {
6456
- calls.push('negotiate-session');
6457
- return Promise.resolve({ message: { version: 2 } });
6458
- }
6459
- if (requestType === 'DeviceSessionGet') {
6460
- calls.push('resume-hidden-session');
6461
- expect(request).toEqual({ session_id: 'hidden-session' });
6462
- return Promise.resolve({
6463
- message: {
6464
- session_id: 'hidden-session',
6465
- btc_test_address: 'hidden-state',
6466
- },
6467
- });
6468
- }
6469
- throw new Error(`Unexpected request: ${requestType}`);
6470
- });
7214
+ const features = { unlocked: false };
6471
7215
  const device = {
6472
- features: { unlocked: true, passphraseProtection: true },
6473
- passphraseState: 'hidden-state',
6474
- commands: { typedCall },
7216
+ features,
7217
+ commands: {
7218
+ typedCall: jest.fn(() => {
7219
+ calls.push('status');
7220
+ return Promise.resolve({ message: { unlocked: false } });
7221
+ }),
7222
+ },
6475
7223
  isProtocolV2: () => true,
6476
- unlockDevice: jest.fn(() => {
6477
- calls.push('unlock');
6478
- return Promise.resolve();
6479
- }),
6480
- getCurrentPassphraseProtection: () => true,
6481
- getInternalState: () => 'hidden-session',
6482
- clearInternalState: jest.fn(),
6483
- getCurrentDeviceId: () => 'wallet-device-id',
6484
- updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
7224
+ isBootloader: () => false,
7225
+ isRomloader: () => false,
7226
+ updateProtocolV2Status: jest.fn(() => features),
7227
+ unlockDevice: jest.fn(),
6485
7228
  };
6486
7229
 
6487
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6488
- message: 'ok',
6489
- });
6490
- expect(calls).toEqual([
6491
- 'run-1',
6492
- 'unlock',
6493
- 'negotiate-session',
6494
- 'resume-hidden-session',
6495
- 'validate-hidden-session',
6496
- 'run-2',
6497
- ]);
6498
- });
6499
-
6500
- test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
6501
- const initialError = deviceLockedError();
6502
- const restoreError = new Error('Invalid wallet session');
6503
- const method = {
6504
- name: 'evmSignMessage',
6505
- payload: { passphraseState: 'hidden-state' },
6506
- useDevicePassphraseState: true,
6507
- unlockPolicy: 'retry-on-locked',
6508
- run: jest.fn().mockRejectedValueOnce(initialError),
6509
- };
6510
- const typedCall = jest.fn((requestType: string) => {
6511
- if (requestType === 'ProtocolInfoRequest') {
6512
- return Promise.resolve({ message: { version: 2 } });
6513
- }
6514
- if (requestType === 'DeviceSessionGet') {
6515
- return Promise.reject(restoreError);
6516
- }
6517
- throw new Error(`Unexpected request: ${requestType}`);
6518
- });
6519
- const device = {
6520
- features: { unlocked: true, passphraseProtection: true },
6521
- passphraseState: 'hidden-state',
6522
- commands: { typedCall },
6523
- isProtocolV2: () => true,
6524
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6525
- getCurrentPassphraseProtection: () => true,
6526
- getInternalState: () => 'hidden-session',
6527
- clearInternalState: jest.fn(),
6528
- getCurrentDeviceId: () => 'wallet-device-id',
6529
- updateInternalState: jest.fn(),
6530
- };
7230
+ await expect(
7231
+ runMethodWithUnlockPolicy(method as any, device as any, {
7232
+ afterStatusBeforeUnlock: () => {
7233
+ calls.push('identity');
7234
+ throw identityError;
7235
+ },
7236
+ })
7237
+ ).rejects.toBe(identityError);
6531
7238
 
6532
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
6533
- expect(method.run).toHaveBeenCalledTimes(1);
6534
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6535
- expect(device.clearInternalState).toHaveBeenCalledTimes(1);
6536
- expect(device.updateInternalState).not.toHaveBeenCalled();
7239
+ expect(calls).toEqual(['status', 'identity']);
7240
+ expect(device.unlockDevice).not.toHaveBeenCalled();
7241
+ expect(method.run).not.toHaveBeenCalled();
6537
7242
  });
6538
7243
 
6539
- test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
7244
+ test('unlocks before business and never replays the callback', async () => {
6540
7245
  const calls: string[] = [];
7246
+ const features = { unlocked: true };
6541
7247
  const method = {
6542
7248
  name: 'evmSignMessage',
6543
- payload: { passphraseState: 'hidden-state' },
6544
- useDevicePassphraseState: true,
6545
- unlockPolicy: 'retry-on-locked',
7249
+ unlockPolicy: 'unlock-before-run',
7250
+ protocolV2UiInteraction: { reason: 'signing-confirmation' },
6546
7251
  run: jest.fn(() => {
6547
7252
  calls.push('run');
6548
- return Promise.resolve({ message: 'ok' });
7253
+ return Promise.resolve({ signature: 'signed' });
6549
7254
  }),
6550
7255
  };
6551
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6552
- if (requestType === 'ProtocolInfoRequest') {
6553
- calls.push('negotiate-session');
6554
- return Promise.resolve({ message: { version: 2 } });
6555
- }
6556
- if (requestType === 'DeviceSessionGet') {
6557
- calls.push('resume-hidden-session');
6558
- expect(request).toEqual({ session_id: 'hidden-session' });
6559
- return Promise.resolve({
6560
- message: {
6561
- session_id: 'hidden-session',
6562
- btc_test_address: 'hidden-state',
6563
- },
6564
- });
6565
- }
6566
- throw new Error(`Unexpected request: ${requestType}`);
6567
- });
6568
- const features = {
6569
- unlocked: false,
6570
- unlockedAttachPin: true,
6571
- passphraseProtection: true,
6572
- };
6573
7256
  const device = {
6574
7257
  features,
6575
- passphraseState: 'hidden-state',
6576
- commands: { typedCall },
7258
+ commands: {
7259
+ typedCall: jest.fn(() => {
7260
+ calls.push('status');
7261
+ return Promise.resolve({ message: { unlocked: false } });
7262
+ }),
7263
+ },
6577
7264
  isProtocolV2: () => true,
7265
+ isBootloader: () => false,
7266
+ isRomloader: () => false,
7267
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
7268
+ features.unlocked = status.unlocked === true;
7269
+ return features;
7270
+ }),
6578
7271
  unlockDevice: jest.fn(() => {
6579
- calls.push('unlock-main');
7272
+ calls.push('unlock');
6580
7273
  features.unlocked = true;
6581
- features.unlockedAttachPin = false;
6582
- return Promise.resolve();
7274
+ return Promise.resolve(features);
7275
+ }),
7276
+ };
7277
+ const uiCoordinator = {
7278
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
7279
+ enterUnlockInteraction: jest.fn(() => {
7280
+ calls.push('unlock-prompt');
7281
+ return undefined;
6583
7282
  }),
6584
- getCurrentPassphraseProtection: () => true,
6585
- getInternalState: () => 'hidden-session',
6586
- clearInternalState: jest.fn(),
6587
- getCurrentDeviceId: () => 'wallet-device-id',
6588
- updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6589
7283
  };
6590
7284
 
6591
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6592
- message: 'ok',
6593
- });
6594
- expect(device.unlockDevice).toHaveBeenCalledWith();
7285
+ await expect(
7286
+ runMethodWithUnlockPolicy(method as any, device as any, {
7287
+ uiCoordinator: uiCoordinator as any,
7288
+ prepare: () => {
7289
+ calls.push('wallet-session');
7290
+ return Promise.resolve();
7291
+ },
7292
+ })
7293
+ ).resolves.toEqual({ signature: 'signed' });
7294
+
6595
7295
  expect(calls).toEqual([
6596
- 'unlock-main',
6597
- 'negotiate-session',
6598
- 'resume-hidden-session',
6599
- 'validate-hidden-session',
7296
+ 'status',
7297
+ 'unlock-prompt',
7298
+ 'unlock',
7299
+ 'wallet-session',
7300
+ 'method-prompt',
6600
7301
  'run',
6601
7302
  ]);
7303
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
7304
+ expect(method.run).toHaveBeenCalledTimes(1);
6602
7305
  });
6603
7306
 
6604
- test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
7307
+ test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6605
7308
  const method = {
6606
7309
  name: 'evmGetAddress',
6607
- payload: { useEmptyPassphrase: true },
6608
- useDevicePassphraseState: true,
6609
- unlockPolicy: 'retry-on-locked',
6610
- run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
7310
+ unlockPolicy: 'unlock-before-run',
7311
+ run: jest.fn(),
6611
7312
  };
6612
7313
  const device = {
6613
- features: { unlocked: false },
6614
- passphraseState: 'stale-hidden-state',
7314
+ features: { unlocked: undefined },
7315
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6615
7316
  isProtocolV2: () => true,
6616
- unlockDevice: jest.fn().mockResolvedValue(undefined),
7317
+ isBootloader: () => false,
7318
+ isRomloader: () => false,
7319
+ updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
7320
+ unlockDevice: jest.fn(),
6617
7321
  };
6618
7322
 
6619
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6620
- address: 'standard-wallet-address',
7323
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
7324
+ errorCode: HardwareErrorCode.RuntimeError,
6621
7325
  });
6622
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6623
- expect(method.run).toHaveBeenCalledTimes(1);
7326
+ expect(device.unlockDevice).not.toHaveBeenCalled();
7327
+ expect(method.run).not.toHaveBeenCalled();
6624
7328
  });
6625
7329
 
6626
- test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6627
- const calls: string[] = [];
7330
+ test('returns a business DeviceLocked error without unlocking or replaying', async () => {
7331
+ const error = deviceLockedError();
6628
7332
  const method = {
6629
- name: 'deviceSettingsPageShow',
7333
+ name: 'evmSignMessage',
6630
7334
  unlockPolicy: 'unlock-before-run',
6631
- protocolV2UiInteraction: { reason: 'settings-page' },
6632
- run: jest.fn(() => {
6633
- calls.push('run');
6634
- return Promise.resolve({ message: 'ok' });
6635
- }),
7335
+ run: jest.fn().mockRejectedValue(error),
6636
7336
  };
6637
7337
  const features = { unlocked: true };
6638
7338
  const device = {
6639
7339
  features,
6640
- commands: {
6641
- typedCall: jest.fn(() => {
6642
- calls.push('refresh-status');
6643
- return Promise.resolve({ message: { unlocked: false } });
6644
- }),
6645
- },
7340
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6646
7341
  isProtocolV2: () => true,
6647
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6648
- features.unlocked = status.unlocked ?? features.unlocked;
6649
- return features;
6650
- }),
6651
- unlockDevice: jest.fn(() => {
6652
- calls.push('unlock');
6653
- features.unlocked = true;
6654
- return Promise.resolve();
6655
- }),
6656
- };
6657
- const uiCoordinator = {
6658
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6659
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6660
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
7342
+ isBootloader: () => false,
7343
+ isRomloader: () => false,
7344
+ updateProtocolV2Status: jest.fn(() => features),
7345
+ unlockDevice: jest.fn(),
6661
7346
  };
6662
7347
 
6663
- await expect(
6664
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6665
- ).resolves.toEqual({ message: 'ok' });
6666
- expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6667
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
7348
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
7349
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6668
7350
  expect(method.run).toHaveBeenCalledTimes(1);
6669
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6670
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6671
7351
  });
6672
7352
 
6673
- test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6674
- const features = { unlocked: false };
6675
- const method = {
6676
- name: 'deviceSettings',
7353
+ test('reuses one lightweight preflight context across nested protected methods', async () => {
7354
+ const features = { unlocked: true };
7355
+ const context = createProtocolV2UnlockContext();
7356
+ const firstMethod = {
7357
+ name: 'allNetworkGetAddress',
7358
+ unlockPolicy: 'unlock-before-run',
7359
+ run: jest.fn().mockResolvedValue({ root: true }),
7360
+ };
7361
+ const nestedMethod = {
7362
+ name: 'evmGetAddress',
6677
7363
  unlockPolicy: 'unlock-before-run',
6678
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
7364
+ run: jest.fn().mockResolvedValue({ address: '0x1' }),
6679
7365
  };
6680
7366
  const device = {
6681
7367
  features,
6682
- commands: {
6683
- typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6684
- },
7368
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6685
7369
  isProtocolV2: () => true,
6686
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6687
- features.unlocked = status.unlocked ?? features.unlocked;
6688
- return features;
6689
- }),
7370
+ isBootloader: () => false,
7371
+ isRomloader: () => false,
7372
+ updateProtocolV2Status: jest.fn(() => features),
6690
7373
  unlockDevice: jest.fn(),
6691
7374
  };
6692
7375
 
6693
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6694
- message: 'ok',
6695
- });
6696
- expect(device.unlockDevice).not.toHaveBeenCalled();
6697
- expect(method.run).toHaveBeenCalledTimes(1);
7376
+ await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
7377
+ await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
7378
+
7379
+ expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
7380
+ expect(firstMethod.run).toHaveBeenCalledTimes(1);
7381
+ expect(nestedMethod.run).toHaveBeenCalledTimes(1);
6698
7382
  });
6699
7383
 
6700
7384
  test.each(['bootloader', 'romloader'] as const)(
@@ -6714,7 +7398,7 @@ describe('Protocol V2 protected method execution', () => {
6714
7398
  unlockDevice: jest.fn(),
6715
7399
  };
6716
7400
 
6717
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
7401
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6718
7402
  message: 'updating',
6719
7403
  });
6720
7404
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6723,157 +7407,30 @@ describe('Protocol V2 protected method execution', () => {
6723
7407
  }
6724
7408
  );
6725
7409
 
6726
- test('infers a signing interaction before running a Protocol V2 business method', async () => {
6727
- const method = {
6728
- name: 'evmSignMessage',
6729
- params: { message: 'not-exposed-in-event' },
6730
- payload: {},
6731
- unlockPolicy: 'retry-on-locked',
6732
- run: jest.fn().mockResolvedValue({ signature: 'test' }),
6733
- };
6734
- const device = {
6735
- isProtocolV2: () => true,
6736
- unlockDevice: jest.fn(),
6737
- };
6738
- const uiCoordinator = {
6739
- enterMethodInteraction: jest.fn(),
6740
- enterUnlockInteraction: jest.fn(),
6741
- resumeMethodInteraction: jest.fn(),
6742
- };
6743
-
6744
- await runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any);
6745
-
6746
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledWith({
6747
- request: 'button',
6748
- source: 'method-lifecycle',
6749
- reason: 'signing-confirmation',
6750
- completion: 'operation-completed',
6751
- deviceOnly: true,
6752
- operation: 'evmSignMessage',
6753
- });
6754
- });
6755
-
6756
- test('does not unlock a Protocol V1 device or a method without the policy', async () => {
7410
+ test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6757
7411
  for (const [isProtocolV2, unlockPolicy] of [
6758
- [false, 'retry-on-locked'],
7412
+ [false, 'unlock-before-run'],
6759
7413
  [true, 'none'],
6760
7414
  ] as const) {
6761
- const error = deviceLockedError();
6762
7415
  const method = {
6763
7416
  unlockPolicy,
6764
- run: jest.fn().mockRejectedValue(error),
7417
+ useDevicePassphraseState: false,
7418
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6765
7419
  };
6766
7420
  const device = {
7421
+ commands: { typedCall: jest.fn() },
6767
7422
  isProtocolV2: () => isProtocolV2,
6768
7423
  unlockDevice: jest.fn(),
6769
7424
  };
6770
7425
 
6771
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
7426
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
7427
+ message: 'ok',
7428
+ });
7429
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6772
7430
  expect(device.unlockDevice).not.toHaveBeenCalled();
6773
7431
  expect(method.run).toHaveBeenCalledTimes(1);
6774
7432
  }
6775
7433
  });
6776
-
6777
- test('runs lock-free eventless methods without unlocking or synthesized UI', async () => {
6778
- const method = {
6779
- name: 'uploadPortfolio',
6780
- unlockPolicy: 'none',
6781
- protocolV2UiMode: 'none',
6782
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6783
- };
6784
- const device = {
6785
- features: { unlocked: false },
6786
- isProtocolV2: () => true,
6787
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6788
- };
6789
- const uiCoordinator = {
6790
- enterMethodInteraction: jest.fn(),
6791
- enterUnlockInteraction: jest.fn(),
6792
- resumeMethodInteraction: jest.fn(),
6793
- };
6794
-
6795
- await expect(
6796
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6797
- ).resolves.toEqual({ message: 'ok' });
6798
- expect(device.unlockDevice).not.toHaveBeenCalled();
6799
- expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6800
- expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6801
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6802
- });
6803
-
6804
- test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6805
- const error = deviceLockedError();
6806
- const method = {
6807
- name: 'uploadPortfolio',
6808
- unlockPolicy: 'none',
6809
- run: jest.fn().mockRejectedValue(error),
6810
- };
6811
- const device = {
6812
- features: { unlocked: true },
6813
- isProtocolV2: () => true,
6814
- unlockDevice: jest.fn(),
6815
- };
6816
-
6817
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6818
- expect(method.run).toHaveBeenCalledTimes(1);
6819
- expect(device.unlockDevice).not.toHaveBeenCalled();
6820
- });
6821
-
6822
- test('does not retry when unlock fails or when the retry is still locked', async () => {
6823
- const initialError = deviceLockedError();
6824
- const unlockError = new Error('PIN cancelled');
6825
- const unlockFailMethod = {
6826
- unlockPolicy: 'retry-on-locked',
6827
- run: jest.fn().mockRejectedValue(initialError),
6828
- };
6829
- const unlockFailDevice = {
6830
- isProtocolV2: () => true,
6831
- unlockDevice: jest.fn().mockRejectedValue(unlockError),
6832
- };
6833
- const unlockFailCoordinator = {
6834
- enterMethodInteraction: jest.fn(),
6835
- enterUnlockInteraction: jest.fn(),
6836
- resumeMethodInteraction: jest.fn(),
6837
- };
6838
-
6839
- await expect(
6840
- runMethodWithUnlockRetry(
6841
- unlockFailMethod as any,
6842
- unlockFailDevice as any,
6843
- unlockFailCoordinator as any
6844
- )
6845
- ).rejects.toBe(unlockError);
6846
- expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
6847
- expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6848
- expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6849
-
6850
- const retryError = deviceLockedError();
6851
- const retryFailMethod = {
6852
- unlockPolicy: 'retry-on-locked',
6853
- run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
6854
- };
6855
- const retryFailDevice = {
6856
- isProtocolV2: () => true,
6857
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6858
- };
6859
- const retryFailCoordinator = {
6860
- enterMethodInteraction: jest.fn(),
6861
- enterUnlockInteraction: jest.fn(),
6862
- resumeMethodInteraction: jest.fn(),
6863
- };
6864
-
6865
- await expect(
6866
- runMethodWithUnlockRetry(
6867
- retryFailMethod as any,
6868
- retryFailDevice as any,
6869
- retryFailCoordinator as any
6870
- )
6871
- ).rejects.toBe(retryError);
6872
- expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
6873
- expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
6874
- expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6875
- expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
6876
- });
6877
7434
  });
6878
7435
 
6879
7436
  describe('Protocol V2 current low-level methods', () => {
@@ -7005,7 +7562,9 @@ describe('Protocol V2 current low-level methods', () => {
7005
7562
 
7006
7563
  await method.run();
7007
7564
 
7008
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
7565
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
7566
+ eventless_wallet_session: true,
7567
+ });
7009
7568
  });
7010
7569
 
7011
7570
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {
@@ -7081,6 +7640,29 @@ describe('Protocol V2 current low-level methods', () => {
7081
7640
 
7082
7641
  await expect(method.run()).resolves.toBe(features);
7083
7642
  expect(unlockDevice).toHaveBeenCalledTimes(1);
7643
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
7644
+ emitUiEvent: false,
7645
+ });
7646
+ });
7647
+
7648
+ test('forwards an explicit PIN type when unlocking a Protocol V2 device', async () => {
7649
+ const method = new DeviceUnlock({
7650
+ id: 1,
7651
+ payload: {
7652
+ method: 'deviceUnlock',
7653
+ pinType: DeviceSessionPinType.Any,
7654
+ },
7655
+ });
7656
+ method.init();
7657
+
7658
+ const unlockDevice = jest.fn().mockResolvedValue({ unlocked: true });
7659
+ (method as any).device = { unlockDevice };
7660
+
7661
+ await method.run();
7662
+
7663
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, {
7664
+ emitUiEvent: false,
7665
+ });
7084
7666
  });
7085
7667
 
7086
7668
  test.each([