@onekeyfe/hd-core 1.2.0-alpha.41 → 1.2.0-alpha.42

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 (200) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +78 -0
  2. package/__tests__/DeviceEventRegistration.test.ts +6 -0
  3. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +14 -1
  4. package/__tests__/connectSettings.test.ts +5 -47
  5. package/__tests__/deviceUploadNft.test.ts +187 -0
  6. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +31 -59
  7. package/__tests__/get-device-state.test.ts +132 -12
  8. package/__tests__/homescreen.test.ts +41 -1
  9. package/__tests__/logBlockEvent.test.ts +1 -1
  10. package/__tests__/open-wallet-session.test.ts +259 -18
  11. package/__tests__/pro2Nft.test.ts +108 -0
  12. package/__tests__/protocol-v2-resources.test.ts +204 -0
  13. package/__tests__/protocol-v2-ui-lifecycle.test.ts +56 -0
  14. package/__tests__/protocol-v2-unlock-policy.test.ts +86 -35
  15. package/__tests__/protocol-v2.test.ts +526 -955
  16. package/__tests__/public-pro2-api-boundary.test.ts +1 -0
  17. package/__tests__/uiProgressThrottle.test.ts +74 -0
  18. package/dist/api/BaseMethod.d.ts +3 -1
  19. package/dist/api/BaseMethod.d.ts.map +1 -1
  20. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  21. package/dist/api/FirmwareUpdateV2.d.ts +0 -11
  22. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  23. package/dist/api/FirmwareUpdateV3.d.ts +3 -7
  24. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  25. package/dist/api/FirmwareUpdateV4.d.ts +6 -25
  26. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  27. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  28. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  29. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  30. package/dist/api/device/DeviceUpdateBootloader.d.ts +1 -5
  31. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  32. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +0 -8
  33. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  34. package/dist/api/firmware/getBinary.d.ts +0 -7
  35. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  36. package/dist/api/firmware/updateBootloader.d.ts +0 -2
  37. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  38. package/dist/api/firmware/uploadFirmware.d.ts +1 -9
  39. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  40. package/dist/api/helpers/protocolV2FileWrite.d.ts +2 -0
  41. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  42. package/dist/api/index.d.ts +1 -0
  43. package/dist/api/index.d.ts.map +1 -1
  44. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +30 -0
  45. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -0
  46. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  47. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  48. package/dist/core/deviceEventRegistration.d.ts +2 -0
  49. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  50. package/dist/core/index.d.ts.map +1 -1
  51. package/dist/data-manager/DataManager.d.ts +3 -2
  52. package/dist/data-manager/DataManager.d.ts.map +1 -1
  53. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  54. package/dist/device/Device.d.ts +22 -4
  55. package/dist/device/Device.d.ts.map +1 -1
  56. package/dist/events/device.d.ts +4 -0
  57. package/dist/events/device.d.ts.map +1 -1
  58. package/dist/events/logBlockEvent.d.ts.map +1 -1
  59. package/dist/events/ui-request.d.ts +27 -2
  60. package/dist/events/ui-request.d.ts.map +1 -1
  61. package/dist/index.d.ts +110 -182
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1919 -3336
  64. package/dist/inject.d.ts.map +1 -1
  65. package/dist/lowLevelInject.d.ts.map +1 -1
  66. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  67. package/dist/protocols/protocol-v2/resources.d.ts +30 -0
  68. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -0
  69. package/dist/protocols/protocol-v2/uiInteraction.d.ts +3 -3
  70. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  71. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +1 -3
  72. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  73. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +22 -0
  74. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -0
  75. package/dist/protocols/protocol-v2/walletSession.d.ts +4 -1
  76. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  77. package/dist/topLevelInject.d.ts.map +1 -1
  78. package/dist/types/api/checkAllFirmwareRelease.d.ts +1 -4
  79. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  80. package/dist/types/api/deviceUpdateBootloader.d.ts +0 -6
  81. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  82. package/dist/types/api/export.d.ts +1 -4
  83. package/dist/types/api/export.d.ts.map +1 -1
  84. package/dist/types/api/firmwareUpdate.d.ts +0 -69
  85. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  86. package/dist/types/api/index.d.ts +3 -10
  87. package/dist/types/api/index.d.ts.map +1 -1
  88. package/dist/types/api/protocolV2.d.ts +3 -0
  89. package/dist/types/api/protocolV2.d.ts.map +1 -1
  90. package/dist/types/settings.d.ts +12 -13
  91. package/dist/types/settings.d.ts.map +1 -1
  92. package/dist/utils/deviceFeaturesUtils.d.ts +2 -0
  93. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  94. package/dist/utils/homescreen.d.ts +4 -0
  95. package/dist/utils/homescreen.d.ts.map +1 -1
  96. package/dist/utils/index.d.ts +1 -1
  97. package/dist/utils/index.d.ts.map +1 -1
  98. package/dist/utils/patch.d.ts +1 -1
  99. package/dist/utils/patch.d.ts.map +1 -1
  100. package/dist/utils/pro2Nft.d.ts +29 -0
  101. package/dist/utils/pro2Nft.d.ts.map +1 -0
  102. package/dist/utils/pro2Wallpaper.d.ts +10 -0
  103. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  104. package/dist/utils/uiProgressThrottle.d.ts +3 -0
  105. package/dist/utils/uiProgressThrottle.d.ts.map +1 -0
  106. package/package.json +4 -4
  107. package/src/api/BaseMethod.ts +8 -10
  108. package/src/api/CheckAllFirmwareRelease.ts +40 -53
  109. package/src/api/FirmwareUpdateV2.ts +120 -298
  110. package/src/api/FirmwareUpdateV3.ts +57 -263
  111. package/src/api/FirmwareUpdateV4.ts +307 -912
  112. package/src/api/GetPassphraseState.ts +7 -1
  113. package/src/api/OpenWalletSession.ts +54 -17
  114. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +32 -11
  115. package/src/api/device/DeviceUnlock.ts +1 -1
  116. package/src/api/device/DeviceUpdateBootloader.ts +6 -122
  117. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +0 -57
  118. package/src/api/firmware/updateBootloader.ts +4 -19
  119. package/src/api/firmware/uploadFirmware.ts +22 -140
  120. package/src/api/helpers/protocolV2FileWrite.ts +11 -4
  121. package/src/api/index.ts +1 -0
  122. package/src/api/protocol-v2/DeviceUploadNft.ts +166 -0
  123. package/src/api/protocol-v2/ProtocolInfoRequest.ts +3 -1
  124. package/src/core/deviceEventRegistration.ts +4 -0
  125. package/src/core/index.ts +118 -75
  126. package/src/data/messages/messages-protocol-v2.json +53 -0
  127. package/src/data-manager/DataManager.ts +63 -38
  128. package/src/data-manager/connectSettings.ts +0 -11
  129. package/src/device/Device.ts +193 -21
  130. package/src/events/device.ts +4 -0
  131. package/src/events/logBlockEvent.ts +1 -0
  132. package/src/events/ui-request.ts +32 -2
  133. package/src/index.ts +0 -5
  134. package/src/inject.ts +3 -22
  135. package/src/lowLevelInject.ts +1 -5
  136. package/src/protocols/protocol-v2/features.ts +9 -2
  137. package/src/protocols/protocol-v2/resources.ts +233 -0
  138. package/src/protocols/protocol-v2/uiInteraction.ts +31 -5
  139. package/src/protocols/protocol-v2/unlockPolicy.ts +1 -105
  140. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +117 -0
  141. package/src/protocols/protocol-v2/walletSession.ts +93 -18
  142. package/src/topLevelInject.ts +1 -5
  143. package/src/types/api/checkAllFirmwareRelease.ts +1 -4
  144. package/src/types/api/deviceUpdateBootloader.ts +0 -6
  145. package/src/types/api/export.ts +0 -18
  146. package/src/types/api/firmwareUpdate.ts +0 -76
  147. package/src/types/api/index.ts +2 -14
  148. package/src/types/api/protocolV2.ts +13 -0
  149. package/src/types/settings.ts +26 -13
  150. package/src/utils/deviceFeaturesUtils.ts +4 -0
  151. package/src/utils/homescreen.ts +32 -6
  152. package/src/utils/index.ts +6 -1
  153. package/src/utils/pro2Nft.ts +91 -0
  154. package/src/utils/pro2Wallpaper.ts +35 -8
  155. package/src/utils/uiProgressThrottle.ts +63 -0
  156. package/__tests__/firmware-update/check-all-firmware-release.test.ts +0 -175
  157. package/__tests__/firmware-update/firmware-artifact-source.test.ts +0 -119
  158. package/__tests__/firmware-update/firmware-host-binding.test.ts +0 -70
  159. package/__tests__/firmware-update/firmware-preparation-error.test.ts +0 -27
  160. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +0 -13
  161. package/__tests__/firmware-update/firmware-update-plan.test.ts +0 -593
  162. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +0 -213
  163. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +0 -58
  164. package/__tests__/firmware-update/firmware-upload-source.test.ts +0 -70
  165. package/dist/api/firmware/FirmwareArtifactSource.d.ts +0 -25
  166. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +0 -1
  167. package/dist/api/firmware/FirmwareHostBinding.d.ts +0 -6
  168. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +0 -1
  169. package/dist/api/firmware/FirmwarePreparationError.d.ts +0 -3
  170. package/dist/api/firmware/FirmwarePreparationError.d.ts.map +0 -1
  171. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +0 -3
  172. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +0 -1
  173. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +0 -25
  174. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +0 -1
  175. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +0 -30
  176. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +0 -1
  177. package/dist/api/firmware/progressThrottle.d.ts +0 -3
  178. package/dist/api/firmware/progressThrottle.d.ts.map +0 -1
  179. package/dist/protocols/protocol-v2/lockedError.d.ts +0 -2
  180. package/dist/protocols/protocol-v2/lockedError.d.ts.map +0 -1
  181. package/dist/protocols/protocol-v2/unlockRetry.d.ts +0 -10
  182. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +0 -1
  183. package/dist/types/api/firmwareUpdateCapabilities.d.ts +0 -9
  184. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +0 -1
  185. package/dist/types/api/firmwareUpdatePlan.d.ts +0 -28
  186. package/dist/types/api/firmwareUpdatePlan.d.ts.map +0 -1
  187. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +0 -42
  188. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +0 -1
  189. package/src/api/firmware/FirmwareArtifactSource.ts +0 -278
  190. package/src/api/firmware/FirmwareHostBinding.ts +0 -100
  191. package/src/api/firmware/FirmwarePreparationError.ts +0 -12
  192. package/src/api/firmware/FirmwareUpdateCapabilities.ts +0 -9
  193. package/src/api/firmware/FirmwareUpdatePlan.ts +0 -745
  194. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +0 -392
  195. package/src/api/firmware/progressThrottle.ts +0 -44
  196. package/src/protocols/protocol-v2/lockedError.ts +0 -10
  197. package/src/protocols/protocol-v2/unlockRetry.ts +0 -109
  198. package/src/types/api/firmwareUpdateCapabilities.ts +0 -9
  199. package/src/types/api/firmwareUpdatePlan.ts +0 -38
  200. package/src/types/api/firmwareUpdatePreparedPlan.ts +0 -53
@@ -61,6 +61,7 @@ import { createCoreApi } from '../src/inject';
61
61
  import { Device, preloadSessionCache } from '../src/device/Device';
62
62
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
63
63
  import { DevicePool } from '../src/device/DevicePool';
64
+ import { DEVICE } from '../src/events';
64
65
  import { UI_REQUEST } from '../src/events/ui-request';
65
66
  import {
66
67
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
@@ -78,7 +79,10 @@ import {
78
79
  getProtocolV2WalletSession,
79
80
  refreshProtocolV2DeviceStatus,
80
81
  } from '../src/protocols/protocol-v2/walletSession';
81
- import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
82
+ import {
83
+ createProtocolV2UnlockContext,
84
+ runMethodWithUnlockPolicy,
85
+ } from '../src/protocols/protocol-v2/unlockPolicyRunner';
82
86
  import { BaseMethod } from '../src/api/BaseMethod';
83
87
  import {
84
88
  buildProtocolV1FeaturesPayload,
@@ -92,7 +96,6 @@ import {
92
96
  getMethodVersionRange,
93
97
  } from '../src/utils';
94
98
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
95
- import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
96
99
  import {
97
100
  getPassphraseState,
98
101
  getPassphraseStateWithRefreshDeviceInfo,
@@ -241,7 +244,7 @@ describe('UploadPortfolio', () => {
241
244
  (method as any).device = device;
242
245
 
243
246
  method.init();
244
- await runMethodWithUnlockRetry(method, device as any);
247
+ await runMethodWithUnlockPolicy(method, device as any);
245
248
 
246
249
  expect(unlockDevice).not.toHaveBeenCalled();
247
250
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -417,14 +420,16 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
417
420
  }
418
421
  return d.features;
419
422
  };
423
+ d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
424
+ requestProtocolV2ProtocolInfo({
425
+ commands: d.getCommands?.() ?? d.commands,
426
+ timeoutMs,
427
+ });
420
428
  d.probeProtocolV2RuntimeState ??= async (
421
429
  deviceInfo: ProtocolV2DeviceInfo,
422
430
  timeoutMs?: number
423
431
  ) => {
424
- const protocolInfo = await requestProtocolV2ProtocolInfo({
425
- commands: d.getCommands(),
426
- timeoutMs,
427
- });
432
+ const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
428
433
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
429
434
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
430
435
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -805,7 +810,7 @@ describe('Protocol V2 feature adapter', () => {
805
810
  ).resolves.toEqual({
806
811
  passphraseState: 'state-1',
807
812
  newSession: 'session-1',
808
- unlockedAttachPin: undefined,
813
+ unlockedAttachPin: false,
809
814
  resumed: false,
810
815
  });
811
816
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -1553,6 +1558,70 @@ describe('Protocol V2 feature adapter', () => {
1553
1558
  expect(device.getInternalState()).toBeUndefined();
1554
1559
  });
1555
1560
 
1561
+ test.each([false, true])(
1562
+ 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1563
+ async lockFails => {
1564
+ const deviceId = 'attach-mismatch-device';
1565
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1566
+ (device as any).features = normalizeProtocolV2Features(
1567
+ { ...descriptor, protocolType: 'V2' } as any,
1568
+ {
1569
+ status: {
1570
+ device_id: deviceId,
1571
+ unlocked: true,
1572
+ passphrase_enabled: true,
1573
+ attach_to_pin_enabled: true,
1574
+ unlocked_by_attach_to_pin: false,
1575
+ },
1576
+ }
1577
+ );
1578
+ device.passphraseState = 'expected-state';
1579
+
1580
+ const typedCall = jest.fn((request: string) => {
1581
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1582
+ if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1583
+ if (request === 'DeviceStatusGet') {
1584
+ return {
1585
+ message: {
1586
+ device_id: deviceId,
1587
+ unlocked: true,
1588
+ passphrase_enabled: true,
1589
+ attach_to_pin_enabled: true,
1590
+ unlocked_by_attach_to_pin: true,
1591
+ },
1592
+ };
1593
+ }
1594
+ if (request === 'DeviceSessionGet') {
1595
+ return {
1596
+ message: {
1597
+ btc_test_address: 'wrong-attach-state',
1598
+ session_id: 'wrong-attach-session',
1599
+ },
1600
+ };
1601
+ }
1602
+ if (request === 'LockDevice') {
1603
+ if (lockFails) throw new Error('Lock unsupported');
1604
+ return { message: { message: 'Device locked' } };
1605
+ }
1606
+ throw new Error(`Unexpected request: ${request}`);
1607
+ });
1608
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1609
+ (device as any).commands = { typedCall, promptPassphrase };
1610
+
1611
+ await expect(
1612
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1613
+ ).rejects.toMatchObject({
1614
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1615
+ });
1616
+
1617
+ expect(promptPassphrase).not.toHaveBeenCalled();
1618
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1619
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1620
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1621
+ expect(device.getInternalState()).toBeUndefined();
1622
+ }
1623
+ );
1624
+
1556
1625
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1557
1626
  const device = Device.fromDescriptor({
1558
1627
  id: 'cache-device-attach-recovery',
@@ -1609,7 +1678,17 @@ describe('Protocol V2 feature adapter', () => {
1609
1678
  }),
1610
1679
  { cancelDeviceOnReject: false }
1611
1680
  );
1612
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1681
+ expect(unlockDevice).toHaveBeenCalledWith(
1682
+ DeviceSessionPinType.AttachToPin,
1683
+ expect.objectContaining({
1684
+ emitUiEvent: false,
1685
+ interaction: expect.objectContaining({
1686
+ phase: 'pin',
1687
+ transition: 'start',
1688
+ protocol: 'V2',
1689
+ }),
1690
+ })
1691
+ );
1613
1692
  expect(device.getInternalState()).toBe('new-attach-session');
1614
1693
  });
1615
1694
 
@@ -1893,12 +1972,10 @@ describe('Protocol V2 feature adapter', () => {
1893
1972
  expect(getFeatures).not.toHaveBeenCalled();
1894
1973
  });
1895
1974
 
1896
- test('marks an interactive cache miss as a wallet-session recovery', async () => {
1975
+ test('uses the cached wallet address when the local session is missing', async () => {
1897
1976
  const typedCall = jest
1898
1977
  .fn()
1899
1978
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
- .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
- .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1902
1979
  .mockResolvedValueOnce({
1903
1980
  message: {
1904
1981
  btc_test_address: 'expected-state',
@@ -1926,16 +2003,8 @@ describe('Protocol V2 feature adapter', () => {
1926
2003
  await expect(
1927
2004
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1928
2005
  ).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
- );
2006
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2007
+ expect(promptPassphrase).not.toHaveBeenCalled();
1939
2008
  });
1940
2009
 
1941
2010
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2001,7 +2070,6 @@ describe('Protocol V2 feature adapter', () => {
2001
2070
  true
2002
2071
  );
2003
2072
  expect(typedCall.mock.calls).toEqual([
2004
- ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2005
2073
  ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2006
2074
  ]);
2007
2075
  });
@@ -2269,12 +2337,13 @@ describe('Protocol V2 feature adapter', () => {
2269
2337
  );
2270
2338
 
2271
2339
  expect(device.getInternalState()).toBeUndefined();
2272
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2340
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2341
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2273
2342
  passphrase: 'host hidden wallet',
2274
2343
  on_device: false,
2275
2344
  });
2276
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
- expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2345
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2346
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2278
2347
  });
2279
2348
 
2280
2349
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2311,6 +2380,9 @@ describe('Protocol V2 feature adapter', () => {
2311
2380
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2312
2381
  eventless_wallet_session: true,
2313
2382
  });
2383
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
2384
+ type: DeviceSessionPinType.Main,
2385
+ });
2314
2386
  });
2315
2387
 
2316
2388
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2681,7 +2753,9 @@ describe('Protocol V2 feature adapter', () => {
2681
2753
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2682
2754
  }
2683
2755
  );
2684
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2756
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2757
+ eventless_wallet_session: true,
2758
+ });
2685
2759
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2686
2760
  });
2687
2761
 
@@ -2719,7 +2793,9 @@ describe('Protocol V2 feature adapter', () => {
2719
2793
  }),
2720
2794
  expect.anything()
2721
2795
  );
2722
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2796
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2797
+ eventless_wallet_session: true,
2798
+ });
2723
2799
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2724
2800
  expect(device.features).toMatchObject({
2725
2801
  deviceId: null,
@@ -2985,10 +3061,6 @@ describe('Protocol V2 feature adapter', () => {
2985
3061
  fw: { application: { version: '1.2.4' } },
2986
3062
  },
2987
3063
  })
2988
- .mockResolvedValueOnce({
2989
- type: 'ProtocolInfo',
2990
- message: protocolV2ApplicationInfo,
2991
- })
2992
3064
  .mockResolvedValueOnce({
2993
3065
  type: 'DeviceStatus',
2994
3066
  message: { init_states: true, unlocked: true },
@@ -3010,7 +3082,7 @@ describe('Protocol V2 feature adapter', () => {
3010
3082
  firmwareVersion: '1.2.4',
3011
3083
  passphraseProtection: null,
3012
3084
  });
3013
- expect(typedCall).toHaveBeenCalledTimes(6);
3085
+ expect(typedCall).toHaveBeenCalledTimes(5);
3014
3086
  expect(typedCall).toHaveBeenNthCalledWith(
3015
3087
  4,
3016
3088
  'DeviceInfoGet',
@@ -3124,9 +3196,23 @@ describe('Protocol V2 feature adapter', () => {
3124
3196
  status: { init_states: true },
3125
3197
  }
3126
3198
  );
3199
+ const pinOnDevice = jest.fn();
3200
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3127
3201
 
3128
3202
  const features = await device.unlockDevice();
3129
3203
 
3204
+ expect(pinOnDevice).toHaveBeenCalledWith(
3205
+ device,
3206
+ DeviceSessionPinType.Main,
3207
+ expect.objectContaining({
3208
+ source: 'unlock-coordinator',
3209
+ reason: 'device-unlock',
3210
+ deviceOnly: true,
3211
+ })
3212
+ );
3213
+ expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3214
+ typedCall.mock.invocationCallOrder[0]
3215
+ );
3130
3216
  expect(typedCall.mock.calls).toEqual([
3131
3217
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3132
3218
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3172,9 +3258,12 @@ describe('Protocol V2 feature adapter', () => {
3172
3258
  throw new Error(`Unexpected request: ${requestType}`);
3173
3259
  });
3174
3260
  (device as any).commands = { typedCall };
3261
+ const pinOnDevice = jest.fn();
3262
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3175
3263
 
3176
- await device.unlockDevice();
3264
+ await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3177
3265
 
3266
+ expect(pinOnDevice).not.toHaveBeenCalled();
3178
3267
  expect(typedCall.mock.calls).toEqual([
3179
3268
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3180
3269
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3577,6 +3666,15 @@ describe('API compatibility handling', () => {
3577
3666
 
3578
3667
  describe('Protocol V2 firmware update targets', () => {
3579
3668
  const OKPP_HEADER_SIZE = 0x52a0;
3669
+ let forceReloadDataSpy: jest.SpyInstance<Promise<void>, []>;
3670
+
3671
+ beforeEach(() => {
3672
+ forceReloadDataSpy = jest.spyOn(DataManager, 'forceReloadData').mockResolvedValue(undefined);
3673
+ });
3674
+
3675
+ afterEach(() => {
3676
+ jest.restoreAllMocks();
3677
+ });
3580
3678
 
3581
3679
  const buildOkppHeader = ({
3582
3680
  type = 'RESC',
@@ -3717,7 +3815,7 @@ describe('Protocol V2 firmware update targets', () => {
3717
3815
  });
3718
3816
  });
3719
3817
 
3720
- test('delegates Protocol V2 mode transition to the phase executor', async () => {
3818
+ test('enters Protocol V2 bootloader before upload', async () => {
3721
3819
  const method = new FirmwareUpdateV4({
3722
3820
  id: 1,
3723
3821
  payload: {
@@ -3752,7 +3850,7 @@ describe('Protocol V2 firmware update targets', () => {
3752
3850
 
3753
3851
  await method.run();
3754
3852
 
3755
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3853
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
3756
3854
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3757
3855
  fwBinaryMap: [
3758
3856
  {
@@ -3866,6 +3964,9 @@ describe('Protocol V2 firmware update targets', () => {
3866
3964
  },
3867
3965
  });
3868
3966
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3967
+ const prepareResourceBundles = jest
3968
+ .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
3969
+ .mockResolvedValue(undefined);
3869
3970
  (method as any).protocolV2Reboot = jest.fn();
3870
3971
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3871
3972
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -3878,6 +3979,7 @@ describe('Protocol V2 firmware update targets', () => {
3878
3979
 
3879
3980
  await method.run();
3880
3981
 
3982
+ expect(prepareResourceBundles).toHaveBeenCalledWith(true);
3881
3983
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3882
3984
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3883
3985
  bootloaderBinary: null,
@@ -4135,9 +4237,9 @@ describe('Protocol V2 firmware update targets', () => {
4135
4237
  const typedCall = jest
4136
4238
  .fn()
4137
4239
  .mockRejectedValue(
4138
- Object.assign(
4139
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4140
- { name: 'NotFoundError' }
4240
+ new DOMException(
4241
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4242
+ 'NotFoundError'
4141
4243
  )
4142
4244
  );
4143
4245
 
@@ -4318,77 +4420,6 @@ describe('Protocol V2 firmware update targets', () => {
4318
4420
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4319
4421
  });
4320
4422
 
4321
- test('rejects a finished target whose payload version conflicts with the plan', () => {
4322
- const method = new FirmwareUpdateV4({
4323
- id: 1,
4324
- payload: {
4325
- method: 'firmwareUpdateV4',
4326
- platform: 'desktop',
4327
- expectedTargetVersions: {
4328
- app_v1: '2.0.0',
4329
- },
4330
- },
4331
- });
4332
- method.init();
4333
- method.postProgressMessage = jest.fn();
4334
-
4335
- expect(() =>
4336
- (method as any).assertProtocolV2TargetStatus(
4337
- [{ target_id: 4, status: 2, payload_version: 0x010000 }],
4338
- new Set([4])
4339
- )
4340
- ).toThrow('expected 131072');
4341
- });
4342
-
4343
- test('does not fail a completed multi-app install when final app versions are unobservable', () => {
4344
- const method = new FirmwareUpdateV4({
4345
- id: 1,
4346
- payload: {
4347
- method: 'firmwareUpdateV4',
4348
- platform: 'desktop',
4349
- targetsToUpdate: ['app_v1', 'app_v2'],
4350
- expectedTargetVersions: {
4351
- app_v1: '1.0.0',
4352
- app_v2: '2.0.0',
4353
- },
4354
- },
4355
- });
4356
- method.init();
4357
- (method as any).protocolV2LatestFinalFeatures = {
4358
- major_version: 3,
4359
- minor_version: 0,
4360
- patch_version: 0,
4361
- };
4362
- (method as any).protocolV2FinalStatusVerified = true;
4363
-
4364
- expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
4365
- });
4366
-
4367
- test('rejects unobservable final versions after the status endpoint fallback', () => {
4368
- const method = new FirmwareUpdateV4({
4369
- id: 1,
4370
- payload: {
4371
- method: 'firmwareUpdateV4',
4372
- platform: 'desktop',
4373
- targetsToUpdate: ['app_v1', 'app_v2'],
4374
- expectedTargetVersions: {
4375
- app_v1: '1.0.0',
4376
- app_v2: '2.0.0',
4377
- },
4378
- },
4379
- });
4380
- method.init();
4381
- (method as any).protocolV2LatestFinalFeatures = {
4382
- major_version: 3,
4383
- minor_version: 0,
4384
- patch_version: 0,
4385
- };
4386
-
4387
- expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
4388
- 'has no observable final version after status fallback'
4389
- );
4390
- });
4391
-
4392
4423
  test('polls target status after update ACK and finishes when all targets complete', async () => {
4393
4424
  const method = new FirmwareUpdateV4({
4394
4425
  id: 1,
@@ -4465,7 +4496,7 @@ describe('Protocol V2 firmware update targets', () => {
4465
4496
  ).toBe(true);
4466
4497
 
4467
4498
  (method.postProgressMessage as jest.Mock).mockClear();
4468
- expect(() =>
4499
+ expect(
4469
4500
  (method as any).assertProtocolV2TargetStatus(
4470
4501
  [
4471
4502
  { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
@@ -4473,8 +4504,8 @@ describe('Protocol V2 firmware update targets', () => {
4473
4504
  ],
4474
4505
  new Set([4, 10])
4475
4506
  )
4476
- ).toThrow('Protocol V2 install status conflicts with target 4');
4477
- expect(method.postProgressMessage).not.toHaveBeenCalled();
4507
+ ).toBe(false);
4508
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4478
4509
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
4479
4510
 
4480
4511
  expect(
@@ -4578,14 +4609,10 @@ describe('Protocol V2 firmware update targets', () => {
4578
4609
  const writtenPaths: string[] = [];
4579
4610
  method.postTipMessage = jest.fn();
4580
4611
  method.postProgressMessage = jest.fn();
4581
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
4612
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
4582
4613
  writtenPaths.push(params.filePath);
4583
- return Number(params.processedSize ?? 0) + Number(params.source.size);
4614
+ return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
4584
4615
  });
4585
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4586
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4587
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({});
4588
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4589
4616
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4590
4617
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4591
4618
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4625,12 +4652,10 @@ describe('Protocol V2 firmware update targets', () => {
4625
4652
  'vol0:/coprocessor.bin',
4626
4653
  1
4627
4654
  );
4628
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
4629
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
4630
- targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
4631
- });
4632
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
4655
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
4656
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
4633
4657
  targets: [
4658
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
4634
4659
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
4635
4660
  { target_id: 7, path: 'vol0:/se01.bin' },
4636
4661
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -4640,7 +4665,7 @@ describe('Protocol V2 firmware update targets', () => {
4640
4665
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
4641
4666
  });
4642
4667
 
4643
- test('announces separate resource and component transfer phases', async () => {
4668
+ test('announces one transfer when syncing resource bundles and staging firmware', async () => {
4644
4669
  const method = new FirmwareUpdateV4({
4645
4670
  id: 1,
4646
4671
  payload: {
@@ -4650,14 +4675,11 @@ describe('Protocol V2 firmware update targets', () => {
4650
4675
 
4651
4676
  method.postTipMessage = jest.fn();
4652
4677
  method.postProgressMessage = jest.fn();
4653
- (method as any).protocolV2SourceUpdateProcess = jest
4678
+ (method as any).protocolV2CommonUpdateProcess = jest
4654
4679
  .fn()
4655
4680
  .mockImplementation(params =>
4656
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
4681
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4657
4682
  );
4658
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4659
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4660
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4661
4683
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4662
4684
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4663
4685
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4677,22 +4699,21 @@ describe('Protocol V2 firmware update targets', () => {
4677
4699
  {
4678
4700
  fileName: 'application_p1.bin',
4679
4701
  binary: new Uint8Array([3]).buffer,
4680
- targetId: 4,
4702
+ targetId: 3,
4681
4703
  },
4682
4704
  ],
4683
4705
  });
4684
4706
 
4685
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
4707
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
4686
4708
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
4687
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
4688
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
4689
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
4709
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
4710
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4690
4711
  1,
4691
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
4712
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
4692
4713
  );
4693
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
4714
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4694
4715
  2,
4695
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
4716
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
4696
4717
  );
4697
4718
  });
4698
4719
 
@@ -4706,8 +4727,7 @@ describe('Protocol V2 firmware update targets', () => {
4706
4727
 
4707
4728
  method.postTipMessage = jest.fn();
4708
4729
  method.postProgressMessage = jest.fn();
4709
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4710
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4730
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4711
4731
  (method as any).device = stubDevice({
4712
4732
  getCommands: () => ({
4713
4733
  typedCall: jest.fn().mockResolvedValue({
@@ -4753,14 +4773,11 @@ describe('Protocol V2 firmware update targets', () => {
4753
4773
 
4754
4774
  method.postTipMessage = jest.fn();
4755
4775
  method.postProgressMessage = jest.fn();
4756
- (method as any).protocolV2SourceUpdateProcess = jest
4776
+ (method as any).protocolV2CommonUpdateProcess = jest
4757
4777
  .fn()
4758
4778
  .mockImplementation(params =>
4759
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
4779
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4760
4780
  );
4761
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4762
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4763
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
4764
4781
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4765
4782
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4766
4783
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4975,33 +4992,42 @@ describe('Protocol V2 firmware update targets', () => {
4975
4992
  getFirmwareLatestReleaseSpy.mockRestore();
4976
4993
  });
4977
4994
 
4978
- test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
4995
+ test('does not download Pro2 firmware components that App did not select', async () => {
4979
4996
  const method = new FirmwareUpdateV4({
4980
4997
  id: 1,
4981
4998
  payload: {
4982
4999
  method: 'firmwareUpdateV4',
4983
5000
  platform: 'web',
4984
- targetsToUpdate: ['resource'],
4985
5001
  },
4986
5002
  });
4987
5003
  method.init();
4988
5004
 
5005
+ const getSysResourceBinarySpy = jest
5006
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5007
+ .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
4989
5008
  const getFirmwareLatestReleaseSpy = jest
4990
5009
  .spyOn(DataManager, 'getFirmwareLatestRelease')
4991
5010
  .mockReturnValue({
4992
5011
  required: false,
4993
5012
  version: [1, 0, 0],
4994
- url: '',
4995
- resourceBundles: [
4996
- {
4997
- name: 'images',
4998
- url: 'https://example.com/images.okpkg',
4999
- devicePath: 'VOL0:/resource/images/images.okpkg',
5000
- version: [2, 0, 0],
5001
- payloadHash: 'ab'.repeat(64),
5002
- headerHash: 'cd'.repeat(64),
5013
+ url: 'https://example.com/applicationP1.pp.bin',
5014
+ bootloaderVersion: [1, 0, 0],
5015
+ upgradeType: 'payload-package-set',
5016
+ installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5017
+ components: {
5018
+ bootloader: {
5019
+ target: 'BOOTLOADER',
5020
+ url: 'https://example.com/bootloader.pp.bin',
5003
5021
  },
5004
- ],
5022
+ applicationP1: {
5023
+ target: 'APPLICATION_P1',
5024
+ url: 'https://example.com/applicationP1.pp.bin',
5025
+ },
5026
+ applicationP2: {
5027
+ target: 'APPLICATION_P2',
5028
+ url: 'https://example.com/applicationP2.pp.bin',
5029
+ },
5030
+ },
5005
5031
  fingerprint: '',
5006
5032
  changelog: {
5007
5033
  'zh-CN': '',
@@ -5009,399 +5035,94 @@ describe('Protocol V2 firmware update targets', () => {
5009
5035
  },
5010
5036
  });
5011
5037
 
5012
- const bundles = (method as any).prepareProtocolV2ResourceBundles('universal', {
5038
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5013
5039
  deviceType: 'pro2',
5014
5040
  firmwareVersion: '1.0.0',
5041
+ bootloaderVersion: '1.0.0',
5015
5042
  capabilities: [],
5016
5043
  });
5017
5044
 
5018
- expect(bundles).toEqual([
5019
- {
5020
- name: 'images',
5021
- binary: expect.any(ArrayBuffer),
5022
- devicePath: 'vol0:/resource/images/images.okpkg',
5023
- url: 'https://example.com/images.okpkg',
5024
- version: [2, 0, 0],
5025
- payloadHash: 'ab'.repeat(64),
5026
- headerHash: 'cd'.repeat(64),
5027
- },
5028
- ]);
5029
- expect(bundles[0].binary.byteLength).toBe(0);
5045
+ expect(remoteBinaries).toEqual({
5046
+ bootloaderBinary: null,
5047
+ fwBinaryMap: [],
5048
+ installItems: [],
5049
+ });
5050
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5030
5051
 
5052
+ getSysResourceBinarySpy.mockRestore();
5031
5053
  getFirmwareLatestReleaseSpy.mockRestore();
5032
5054
  });
5033
5055
 
5034
- test('rejects remote RESC bundle paths before download or bootloader entry', async () => {
5056
+ test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5035
5057
  const method = new FirmwareUpdateV4({
5036
5058
  id: 1,
5037
5059
  payload: {
5038
5060
  method: 'firmwareUpdateV4',
5039
5061
  platform: 'web',
5040
- targetsToUpdate: ['resource'],
5041
5062
  },
5042
5063
  });
5043
5064
  method.init();
5044
5065
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5045
5066
 
5046
- const getSysResourceBinarySpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
5047
- const getFirmwareLatestReleaseSpy = jest
5048
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5049
- .mockReturnValue({
5050
- required: false,
5051
- version: [1, 0, 0],
5052
- url: '',
5053
- resourceBundles: [
5054
- {
5055
- name: 'images',
5056
- url: 'https://example.com/images.okpkg',
5057
- devicePath: 'vol0:/resource/../images.okpkg',
5058
- },
5059
- ],
5060
- fingerprint: '',
5061
- changelog: {
5062
- 'zh-CN': '',
5063
- 'en-US': '',
5067
+ const remoteBinaries = {
5068
+ bootloaderBinary: new Uint8Array([1]).buffer,
5069
+ fwBinaryMap: [
5070
+ {
5071
+ fileName: 'application_p1.bin',
5072
+ binary: new Uint8Array([2]).buffer,
5073
+ targetId: 3,
5064
5074
  },
5065
- });
5075
+ ],
5076
+ };
5066
5077
 
5067
5078
  (method as any).device = stubDevice({
5068
5079
  originalDescriptor: { protocolType: 'V2' },
5069
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5080
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5070
5081
  });
5071
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5072
- bootloaderBinary: null,
5073
- fwBinaryMap: [],
5082
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5083
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5084
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5085
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5086
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5087
+ bootloaderVersion: '1.0.0',
5088
+ bleVersion: '0.0.0',
5089
+ firmwareVersion: '1.0.0',
5074
5090
  });
5075
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
5076
5091
  method.postTipMessage = jest.fn();
5077
5092
 
5078
- await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
5079
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5080
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5093
+ await method.run();
5081
5094
 
5082
- getSysResourceBinarySpy.mockRestore();
5083
- getFirmwareLatestReleaseSpy.mockRestore();
5095
+ expect(forceReloadDataSpy).toHaveBeenCalledTimes(1);
5096
+ expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5097
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5098
+ bootloaderBinary: remoteBinaries.bootloaderBinary,
5099
+ fwBinaryMap: remoteBinaries.fwBinaryMap,
5100
+ });
5084
5101
  });
5085
5102
 
5086
- test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
5103
+ test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
5087
5104
  const method = new FirmwareUpdateV4({
5088
5105
  id: 1,
5089
5106
  payload: {
5090
5107
  method: 'firmwareUpdateV4',
5108
+ platform: 'web',
5091
5109
  },
5092
5110
  });
5093
- const openPreparedSource = jest.fn();
5094
- (method as any).params = {
5095
- targetsToUpdate: ['resource'],
5096
- resourceBundleArtifacts: [
5097
- {
5098
- name: 'images',
5099
- artifact: {
5100
- artifactRef: `fw:${'a'.repeat(64)}`,
5101
- size: 1,
5102
- sha256: 'a'.repeat(64),
5103
- },
5104
- },
5105
- ],
5106
- };
5107
- (method as any).openProtocolV2PreparedSource = openPreparedSource;
5108
- const getFirmwareLatestReleaseSpy = jest
5109
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5110
- .mockReturnValue({
5111
- required: false,
5112
- version: [1, 0, 0],
5113
- url: '',
5114
- resourceBundles: [
5115
- {
5116
- name: 'images',
5117
- url: 'https://example.com/images.okpkg',
5118
- devicePath: 'vol0:/resource/../images.okpkg',
5119
- },
5120
- ],
5121
- fingerprint: '',
5122
- changelog: {
5123
- 'zh-CN': '',
5124
- 'en-US': '',
5125
- },
5126
- });
5111
+ method.init();
5112
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5113
+ (method as any).device = stubDevice({
5114
+ originalDescriptor: { protocolType: 'V2' },
5115
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5116
+ });
5117
+ forceReloadDataSpy.mockRejectedValue(new Error('latest config unavailable'));
5118
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5119
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5120
+ method.postTipMessage = jest.fn();
5127
5121
 
5128
- await expect(
5129
- (method as any).prepareProtocolV2ResourceSources('universal', {
5130
- deviceType: 'pro2',
5131
- })
5132
- ).rejects.toThrow('resourceBundles[].devicePath');
5133
- expect(openPreparedSource).not.toHaveBeenCalled();
5134
-
5135
- getFirmwareLatestReleaseSpy.mockRestore();
5136
- });
5137
-
5138
- test('downloads and validates remote RESC bundles before entering bootloader', async () => {
5139
- const method = new FirmwareUpdateV4({
5140
- id: 1,
5141
- payload: {
5142
- method: 'firmwareUpdateV4',
5143
- platform: 'web',
5144
- targetsToUpdate: ['resource'],
5145
- },
5146
- });
5147
- method.init();
5148
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5149
-
5150
- const order: string[] = [];
5151
- const resourceBinary = buildOkppHeader().buffer as ArrayBuffer;
5152
- const getSysResourceBinarySpy = jest
5153
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5154
- .mockImplementation(() => {
5155
- order.push('download');
5156
- return Promise.resolve({ binary: resourceBinary });
5157
- });
5158
- const getFirmwareLatestReleaseSpy = jest
5159
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5160
- .mockReturnValue({
5161
- required: false,
5162
- version: [1, 0, 0],
5163
- url: '',
5164
- resourceBundles: [
5165
- {
5166
- name: 'images',
5167
- url: 'https://example.com/images.okpkg',
5168
- devicePath: ' VOL0:/resource/images/images.okpkg ',
5169
- },
5170
- ],
5171
- fingerprint: '',
5172
- changelog: {
5173
- 'zh-CN': '',
5174
- 'en-US': '',
5175
- },
5176
- });
5177
-
5178
- (method as any).device = stubDevice({
5179
- originalDescriptor: { protocolType: 'V2' },
5180
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5181
- });
5182
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5183
- bootloaderBinary: null,
5184
- fwBinaryMap: [],
5185
- });
5186
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
5187
- order.push('bootloader');
5188
- return Promise.resolve(true);
5189
- });
5190
- const executeProtocolV2UpdateSpy = jest.spyOn(method as any, 'executeProtocolV2Update');
5191
- (method as any).executeProtocolV2TransferPhase = jest.fn().mockResolvedValue(undefined);
5192
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue(undefined);
5193
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5194
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5195
- bootloaderVersion: '1.0.0',
5196
- bleVersion: '0.0.0',
5197
- firmwareVersion: '1.0.0',
5198
- });
5199
- method.postTipMessage = jest.fn();
5200
-
5201
- await method.run();
5202
-
5203
- expect(order).toEqual(['download', 'bootloader']);
5204
- expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
5205
- bootloaderBinary: null,
5206
- fwBinaryMap: [],
5207
- resourceBundles: [
5208
- {
5209
- name: 'images',
5210
- binary: resourceBinary,
5211
- devicePath: 'vol0:/resource/images/images.okpkg',
5212
- url: 'https://example.com/images.okpkg',
5213
- },
5214
- ],
5215
- });
5216
-
5217
- executeProtocolV2UpdateSpy.mockRestore();
5218
- getSysResourceBinarySpy.mockRestore();
5219
- getFirmwareLatestReleaseSpy.mockRestore();
5220
- });
5221
-
5222
- test('rejects a remote RESC bundle without an OKPP header when metadata is absent', async () => {
5223
- const method = new FirmwareUpdateV4({
5224
- id: 1,
5225
- payload: {
5226
- method: 'firmwareUpdateV4',
5227
- },
5228
- });
5229
- method.init();
5230
- const getSysResourceBinarySpy = jest
5231
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5232
- .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5233
-
5234
- await expect(
5235
- (method as any).materializeProtocolV2ResourceBundles([
5236
- {
5237
- name: 'images',
5238
- binary: new ArrayBuffer(0),
5239
- devicePath: 'vol0:/resource/images/images.okpkg',
5240
- url: 'https://example.com/images.okpkg',
5241
- },
5242
- ])
5243
- ).rejects.toThrow('Invalid Protocol V2 RESC bundle header: images');
5244
-
5245
- getSysResourceBinarySpy.mockRestore();
5246
- });
5247
-
5248
- test('rejects an empty remote RESC bundle before entering bootloader', async () => {
5249
- const method = new FirmwareUpdateV4({
5250
- id: 1,
5251
- payload: {
5252
- method: 'firmwareUpdateV4',
5253
- platform: 'web',
5254
- targetsToUpdate: ['resource'],
5255
- },
5256
- });
5257
- method.init();
5258
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5259
-
5260
- const getSysResourceBinarySpy = jest
5261
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5262
- .mockResolvedValue({ binary: new ArrayBuffer(0) });
5263
- const getFirmwareLatestReleaseSpy = jest
5264
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5265
- .mockReturnValue({
5266
- required: false,
5267
- version: [1, 0, 0],
5268
- url: '',
5269
- resourceBundles: [
5270
- {
5271
- name: 'images',
5272
- url: 'https://example.com/images.okpkg',
5273
- devicePath: 'vol0:/resource/images/images.okpkg',
5274
- },
5275
- ],
5276
- fingerprint: '',
5277
- changelog: {
5278
- 'zh-CN': '',
5279
- 'en-US': '',
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();
5291
- method.postTipMessage = jest.fn();
5122
+ await expect(method.run()).rejects.toThrow('latest config unavailable');
5292
5123
 
5293
- await expect(method.run()).rejects.toThrow('Protocol V2 RESC bundle is empty: images');
5124
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5294
5125
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5295
-
5296
- getSysResourceBinarySpy.mockRestore();
5297
- getFirmwareLatestReleaseSpy.mockRestore();
5298
- });
5299
-
5300
- test('does not download Pro2 firmware components that App did not select', async () => {
5301
- const method = new FirmwareUpdateV4({
5302
- id: 1,
5303
- payload: {
5304
- method: 'firmwareUpdateV4',
5305
- platform: 'web',
5306
- },
5307
- });
5308
- method.init();
5309
-
5310
- const getSysResourceBinarySpy = jest
5311
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5312
- .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5313
- const getFirmwareLatestReleaseSpy = jest
5314
- .spyOn(DataManager, 'getFirmwareLatestRelease')
5315
- .mockReturnValue({
5316
- required: false,
5317
- version: [1, 0, 0],
5318
- url: 'https://example.com/applicationP1.pp.bin',
5319
- bootloaderVersion: [1, 0, 0],
5320
- upgradeType: 'payload-package-set',
5321
- installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5322
- components: {
5323
- bootloader: {
5324
- target: 'BOOTLOADER',
5325
- url: 'https://example.com/bootloader.pp.bin',
5326
- },
5327
- applicationP1: {
5328
- target: 'APPLICATION_P1',
5329
- url: 'https://example.com/applicationP1.pp.bin',
5330
- },
5331
- applicationP2: {
5332
- target: 'APPLICATION_P2',
5333
- url: 'https://example.com/applicationP2.pp.bin',
5334
- },
5335
- },
5336
- fingerprint: '',
5337
- changelog: {
5338
- 'zh-CN': '',
5339
- 'en-US': '',
5340
- },
5341
- });
5342
-
5343
- const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5344
- deviceType: 'pro2',
5345
- firmwareVersion: '1.0.0',
5346
- bootloaderVersion: '1.0.0',
5347
- capabilities: [],
5348
- });
5349
-
5350
- expect(remoteBinaries).toEqual({
5351
- bootloaderBinary: null,
5352
- fwBinaryMap: [],
5353
- installItems: [],
5354
- });
5355
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5356
-
5357
- getSysResourceBinarySpy.mockRestore();
5358
- getFirmwareLatestReleaseSpy.mockRestore();
5359
- });
5360
-
5361
- test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5362
- const method = new FirmwareUpdateV4({
5363
- id: 1,
5364
- payload: {
5365
- method: 'firmwareUpdateV4',
5366
- platform: 'web',
5367
- },
5368
- });
5369
- method.init();
5370
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5371
-
5372
- const remoteBinaries = {
5373
- bootloaderBinary: new Uint8Array([1]).buffer,
5374
- fwBinaryMap: [
5375
- {
5376
- fileName: 'application_p1.bin',
5377
- binary: new Uint8Array([2]).buffer,
5378
- targetId: 3,
5379
- },
5380
- ],
5381
- };
5382
-
5383
- (method as any).device = stubDevice({
5384
- originalDescriptor: { protocolType: 'V2' },
5385
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5386
- });
5387
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5388
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5389
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5390
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5391
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5392
- bootloaderVersion: '1.0.0',
5393
- bleVersion: '0.0.0',
5394
- firmwareVersion: '1.0.0',
5395
- });
5396
- method.postTipMessage = jest.fn();
5397
-
5398
- await method.run();
5399
-
5400
- expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5401
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5402
- bootloaderBinary: remoteBinaries.bootloaderBinary,
5403
- fwBinaryMap: remoteBinaries.fwBinaryMap,
5404
- });
5405
5126
  });
5406
5127
 
5407
5128
  test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
@@ -5529,10 +5250,7 @@ describe('Protocol V2 firmware update targets', () => {
5529
5250
 
5530
5251
  method.postTipMessage = jest.fn();
5531
5252
  method.postProgressMessage = jest.fn();
5532
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
5533
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5534
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5535
- (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5253
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
5536
5254
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
5537
5255
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
5538
5256
 
@@ -5548,7 +5266,7 @@ describe('Protocol V2 firmware update targets', () => {
5548
5266
  fwBinaryMap: [],
5549
5267
  });
5550
5268
 
5551
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
5269
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
5552
5270
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
5553
5271
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
5554
5272
  });
@@ -5579,19 +5297,12 @@ describe('Protocol V2 firmware update targets', () => {
5579
5297
  });
5580
5298
  method.postProgressMessage = jest.fn();
5581
5299
 
5582
- const source = await openFirmwareByteSource({
5583
- binary: new Uint8Array(4097).buffer,
5300
+ await (method as any).protocolV2CommonUpdateProcess({
5301
+ payload: new Uint8Array(4097).buffer,
5302
+ filePath: 'vol1:firmware.bin',
5303
+ processedSize: 0,
5304
+ totalSize: 4097,
5584
5305
  });
5585
- try {
5586
- await (method as any).protocolV2SourceUpdateProcess({
5587
- source,
5588
- filePath: 'vol1:firmware.bin',
5589
- processedSize: 0,
5590
- totalSize: 4097,
5591
- });
5592
- } finally {
5593
- await source?.close();
5594
- }
5595
5306
 
5596
5307
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5597
5308
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -5648,16 +5359,12 @@ describe('Protocol V2 firmware update targets', () => {
5648
5359
  method.postProgressMessage = jest.fn();
5649
5360
 
5650
5361
  try {
5651
- const source = await openFirmwareByteSource({
5652
- binary: new Uint8Array(8001).buffer,
5653
- });
5654
- await (method as any).protocolV2SourceUpdateProcess({
5655
- source,
5362
+ await (method as any).protocolV2CommonUpdateProcess({
5363
+ payload: new Uint8Array(8001).buffer,
5656
5364
  filePath: 'vol0:/firmware.bin',
5657
5365
  processedSize: 0,
5658
5366
  totalSize: 8001,
5659
5367
  });
5660
- await source?.close();
5661
5368
  } finally {
5662
5369
  setTimeoutSpy.mockRestore();
5663
5370
  }
@@ -5692,21 +5399,16 @@ describe('Protocol V2 firmware update targets', () => {
5692
5399
  });
5693
5400
  method.postProgressMessage = jest.fn();
5694
5401
  method.postTipMessage = jest.fn();
5695
- (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
5696
5402
 
5697
- const source = await openFirmwareByteSource({
5698
- binary: new Uint8Array(4097).buffer,
5699
- });
5700
5403
  await expect(
5701
- (method as any).protocolV2SourceUpdateProcess({
5702
- source,
5404
+ (method as any).protocolV2WriteWholeFile({
5405
+ payload: new Uint8Array(4097).buffer,
5703
5406
  filePath: 'vol0:/firmware.bin',
5704
5407
  processedSize: 0,
5705
5408
  totalSize: 4097,
5706
5409
  })
5707
5410
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
5708
- await source?.close();
5709
- expect(typedCall).toHaveBeenCalledTimes(6);
5411
+ expect(typedCall).toHaveBeenCalledTimes(2);
5710
5412
  });
5711
5413
 
5712
5414
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -5739,19 +5441,12 @@ describe('Protocol V2 firmware update targets', () => {
5739
5441
  });
5740
5442
  method.postProgressMessage = jest.fn();
5741
5443
 
5742
- const source = await openFirmwareByteSource({
5743
- binary: new Uint8Array(1801).buffer,
5444
+ await (method as any).protocolV2CommonUpdateProcess({
5445
+ payload: new Uint8Array(1801).buffer,
5446
+ filePath: 'vol1:ble-firmware.bin',
5447
+ processedSize: 0,
5448
+ totalSize: 1801,
5744
5449
  });
5745
- try {
5746
- await (method as any).protocolV2SourceUpdateProcess({
5747
- source,
5748
- filePath: 'vol1:ble-firmware.bin',
5749
- processedSize: 0,
5750
- totalSize: 1801,
5751
- });
5752
- } finally {
5753
- await source?.close();
5754
- }
5755
5450
 
5756
5451
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5757
5452
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
@@ -5868,9 +5563,9 @@ describe('Protocol V2 firmware update method', () => {
5868
5563
  const typedCall = jest
5869
5564
  .fn()
5870
5565
  .mockRejectedValue(
5871
- Object.assign(
5872
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
5873
- { name: 'NotFoundError' }
5566
+ new DOMException(
5567
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
5568
+ 'NotFoundError'
5874
5569
  )
5875
5570
  );
5876
5571
 
@@ -6035,21 +5730,6 @@ describe('Protocol V2 firmware update method', () => {
6035
5730
  });
6036
5731
 
6037
5732
  describe('Protocol V2 firmware reconnect identity', () => {
6038
- test('keeps the host plan identity as the V4 reconnect anchor', () => {
6039
- const method = new FirmwareUpdateV4({
6040
- id: 1,
6041
- payload: {
6042
- method: 'firmwareUpdateV4',
6043
- platform: 'native',
6044
- expectedDeviceId: 'persisted-device-id',
6045
- },
6046
- });
6047
-
6048
- method.init();
6049
-
6050
- expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
6051
- });
6052
-
6053
5733
  test('rejects a different physical serial before firmware transfer resumes', () => {
6054
5734
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
6055
5735
  'identity mismatch'
@@ -6119,34 +5799,131 @@ describe('Protocol V2 firmware reconnect identity', () => {
6119
5799
  });
6120
5800
  });
6121
5801
 
6122
- test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
5802
+ test('uses ResourceInventory instead of host-side FileRead for resource comparison', async () => {
6123
5803
  const method = new FirmwareUpdateV4({
6124
5804
  id: 1,
6125
5805
  payload: {
6126
5806
  method: 'firmwareUpdateV4',
6127
- platform: 'native',
6128
- chunkSize: 1800,
5807
+ platform: 'web',
5808
+ targetsToUpdate: ['resource'],
6129
5809
  },
6130
5810
  });
6131
5811
  method.init();
6132
- const readChunkLengths: number[] = [];
6133
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6134
- if (requestType === 'FilesystemPathInfoQuery') {
6135
- return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
6136
- }
6137
- if (requestType === 'FilesystemFileRead') {
6138
- readChunkLengths.push(request.chunk_len);
6139
- return Promise.resolve({ message: { data: new Uint8Array(request.chunk_len) } });
6140
- }
6141
- throw new Error(`Unexpected request: ${requestType}`);
5812
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
5813
+ (type, index) => ({
5814
+ type,
5815
+ url: `https://example.com/${type}.okpkg`,
5816
+ size: index + 1,
5817
+ fileHash: 'a'.repeat(64),
5818
+ headerHash: index.toString(16).padStart(128, '0'),
5819
+ })
5820
+ );
5821
+ const typedCall = jest.fn().mockResolvedValue({
5822
+ message: {
5823
+ items: stable.map(item => ({
5824
+ type: item.type.toUpperCase(),
5825
+ size: item.size,
5826
+ header_hash: item.headerHash,
5827
+ })),
5828
+ },
6142
5829
  });
6143
5830
  (method as any).device = stubDevice({
6144
5831
  getCommands: () => ({ typedCall }),
6145
5832
  });
5833
+ const resourcesSpy = jest
5834
+ .spyOn(DataManager, 'getProtocolV2Resources')
5835
+ .mockReturnValue(stable as any);
5836
+ (method as any).downloadProtocolV2Resource = jest.fn();
5837
+
5838
+ await expect((method as any).prepareProtocolV2ResourceBundles(false)).resolves.toEqual([]);
5839
+ expect(typedCall).toHaveBeenCalledWith(
5840
+ 'ResourceInventoryGet',
5841
+ 'ResourceInventory',
5842
+ {},
5843
+ { timeoutMs: 5000 }
5844
+ );
5845
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(false);
5846
+ expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
5847
+ resourcesSpy.mockRestore();
5848
+ });
5849
+
5850
+ test('downloads only changed resources in Application mode', async () => {
5851
+ const method = new FirmwareUpdateV4({
5852
+ id: 1,
5853
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
5854
+ });
5855
+ method.init();
5856
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
5857
+ (type, index) => ({
5858
+ type,
5859
+ url: `https://example.com/${type}.okpkg`,
5860
+ size: index + 1,
5861
+ fileHash: 'a'.repeat(64),
5862
+ headerHash: index.toString(16).padStart(128, '0'),
5863
+ })
5864
+ );
5865
+ const typedCall = jest.fn().mockResolvedValue({
5866
+ message: {
5867
+ items: stable.slice(1).map(item => ({
5868
+ type: item.type.toUpperCase(),
5869
+ size: item.size,
5870
+ header_hash: item.headerHash,
5871
+ })),
5872
+ },
5873
+ });
5874
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
5875
+ const resourcesSpy = jest
5876
+ .spyOn(DataManager, 'getProtocolV2Resources')
5877
+ .mockReturnValue(stable as any);
5878
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
5879
+ Promise.resolve({
5880
+ name: `${resource.type}.okpkg`,
5881
+ binary: new ArrayBuffer(resource.size),
5882
+ devicePath: `vol0:/${resource.type}.okpkg`,
5883
+ })
5884
+ );
5885
+
5886
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles(false);
5887
+
5888
+ expect(bundles).toHaveLength(1);
5889
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
5890
+ resourcesSpy.mockRestore();
5891
+ });
5892
+
5893
+ test('downloads all six resources in Bootloader recovery without inventory RPC', async () => {
5894
+ const method = new FirmwareUpdateV4({
5895
+ id: 1,
5896
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
5897
+ });
5898
+ method.init();
5899
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
5900
+ (type, index) => ({
5901
+ type,
5902
+ url: `https://example.com/${type}.okpkg`,
5903
+ size: index + 1,
5904
+ fileHash: 'a'.repeat(64),
5905
+ headerHash: index.toString(16).padStart(128, '0'),
5906
+ })
5907
+ );
5908
+ const typedCall = jest.fn();
5909
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
5910
+ const resourcesSpy = jest
5911
+ .spyOn(DataManager, 'getProtocolV2Resources')
5912
+ .mockReturnValue(stable as any);
5913
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
5914
+ Promise.resolve({
5915
+ name: `${resource.type}.okpkg`,
5916
+ binary: new ArrayBuffer(resource.size),
5917
+ devicePath: `vol0:/${resource.type}.okpkg`,
5918
+ })
5919
+ );
5920
+
5921
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles(true);
6146
5922
 
6147
- await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
6148
- expect(readChunkLengths.length).toBeGreaterThan(1);
6149
- expect(Math.max(...readChunkLengths)).toBe(900);
5923
+ expect(bundles).toHaveLength(6);
5924
+ expect(typedCall).not.toHaveBeenCalled();
5925
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledTimes(6);
5926
+ resourcesSpy.mockRestore();
6150
5927
  });
6151
5928
  });
6152
5929
 
@@ -6286,311 +6063,230 @@ describe('Protocol V2 protected method execution', () => {
6286
6063
  expect(method.unlockPolicy).toBe('none');
6287
6064
  });
6288
6065
 
6289
- test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6066
+ test('checks fresh status for a wallet business method without a name allowlist', async () => {
6290
6067
  const calls: string[] = [];
6068
+ const features = { unlocked: true };
6291
6069
  const method = {
6292
- name: 'testBusinessMethod',
6293
- unlockPolicy: 'retry-on-locked',
6294
- protocolV2UiInteraction: { reason: 'settings-page' },
6295
- run: jest
6296
- .fn()
6297
- .mockImplementationOnce(() => {
6298
- calls.push('run-1');
6299
- return Promise.reject(deviceLockedError());
6300
- })
6301
- .mockImplementationOnce(() => {
6302
- calls.push('run-2');
6303
- return Promise.resolve({ message: 'ok' });
6304
- }),
6070
+ name: 'confluxSignMessageCIP23',
6071
+ unlockPolicy: 'none',
6072
+ useDevicePassphraseState: true,
6073
+ protocolV2UiInteraction: { reason: 'address-confirmation' },
6074
+ run: jest.fn(() => {
6075
+ calls.push('run');
6076
+ return Promise.resolve({ address: '0x1' });
6077
+ }),
6305
6078
  };
6306
6079
  const device = {
6080
+ features,
6081
+ commands: {
6082
+ typedCall: jest.fn(() => {
6083
+ calls.push('status');
6084
+ return Promise.resolve({ message: { unlocked: true } });
6085
+ }),
6086
+ },
6307
6087
  isProtocolV2: () => true,
6308
- unlockDevice: jest.fn(() => {
6309
- calls.push('unlock');
6310
- return Promise.resolve();
6088
+ isBootloader: () => false,
6089
+ isRomloader: () => false,
6090
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6091
+ features.unlocked = status.unlocked === true;
6092
+ return features;
6311
6093
  }),
6094
+ unlockDevice: jest.fn(),
6312
6095
  };
6313
6096
  const uiCoordinator = {
6314
6097
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6315
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6316
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6098
+ enterUnlockInteraction: jest.fn(),
6317
6099
  };
6318
6100
 
6319
6101
  await expect(
6320
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6321
- ).resolves.toEqual({ message: 'ok' });
6322
- expect(calls).toEqual([
6323
- 'method-prompt',
6324
- 'run-1',
6325
- 'unlock-prompt',
6326
- 'unlock',
6327
- 'method-prompt',
6328
- 'run-2',
6329
- ]);
6102
+ runMethodWithUnlockPolicy(method as any, device as any, {
6103
+ uiCoordinator: uiCoordinator as any,
6104
+ })
6105
+ ).resolves.toEqual({ address: '0x1' });
6106
+
6107
+ expect(calls).toEqual(['status', 'method-prompt', 'run']);
6108
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6109
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6110
+ expect(method.run).toHaveBeenCalledTimes(1);
6330
6111
  });
6331
6112
 
6332
- test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6113
+ test('validates the fresh device identity before starting unlock', async () => {
6333
6114
  const calls: string[] = [];
6115
+ const identityError = new Error('Unexpected device');
6334
6116
  const method = {
6335
6117
  name: 'evmSignMessage',
6336
- payload: { passphraseState: 'hidden-state' },
6118
+ unlockPolicy: 'none',
6337
6119
  useDevicePassphraseState: true,
6338
- unlockPolicy: 'retry-on-locked',
6339
- run: jest
6340
- .fn()
6341
- .mockImplementationOnce(() => {
6342
- calls.push('run-1');
6343
- return Promise.reject(deviceLockedError());
6344
- })
6345
- .mockImplementationOnce(() => {
6346
- calls.push('run-2');
6347
- return Promise.resolve({ message: 'ok' });
6348
- }),
6120
+ run: jest.fn(),
6349
6121
  };
6350
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6351
- if (requestType === 'ProtocolInfoRequest') {
6352
- calls.push('negotiate-session');
6353
- return Promise.resolve({ message: { version: 2 } });
6354
- }
6355
- if (requestType === 'DeviceSessionGet') {
6356
- calls.push('resume-hidden-session');
6357
- expect(request).toEqual({ session_id: 'hidden-session' });
6358
- return Promise.resolve({
6359
- message: {
6360
- session_id: 'hidden-session',
6361
- btc_test_address: 'hidden-state',
6362
- },
6363
- });
6364
- }
6365
- throw new Error(`Unexpected request: ${requestType}`);
6366
- });
6122
+ const features = { unlocked: false };
6367
6123
  const device = {
6368
- features: { unlocked: true, passphraseProtection: true },
6369
- passphraseState: 'hidden-state',
6370
- commands: { typedCall },
6124
+ features,
6125
+ commands: {
6126
+ typedCall: jest.fn(() => {
6127
+ calls.push('status');
6128
+ return Promise.resolve({ message: { unlocked: false } });
6129
+ }),
6130
+ },
6371
6131
  isProtocolV2: () => true,
6372
- unlockDevice: jest.fn(() => {
6373
- calls.push('unlock');
6374
- return Promise.resolve();
6375
- }),
6376
- getCurrentPassphraseProtection: () => true,
6377
- getInternalState: () => 'hidden-session',
6378
- clearInternalState: jest.fn(),
6379
- getCurrentDeviceId: () => 'wallet-device-id',
6380
- updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6132
+ isBootloader: () => false,
6133
+ isRomloader: () => false,
6134
+ updateProtocolV2Status: jest.fn(() => features),
6135
+ unlockDevice: jest.fn(),
6381
6136
  };
6382
6137
 
6383
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6384
- message: 'ok',
6385
- });
6386
- expect(calls).toEqual([
6387
- 'run-1',
6388
- 'unlock',
6389
- 'negotiate-session',
6390
- 'resume-hidden-session',
6391
- 'validate-hidden-session',
6392
- 'run-2',
6393
- ]);
6394
- });
6395
-
6396
- test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
6397
- const initialError = deviceLockedError();
6398
- const restoreError = new Error('Invalid wallet session');
6399
- const method = {
6400
- name: 'evmSignMessage',
6401
- payload: { passphraseState: 'hidden-state' },
6402
- useDevicePassphraseState: true,
6403
- unlockPolicy: 'retry-on-locked',
6404
- run: jest.fn().mockRejectedValueOnce(initialError),
6405
- };
6406
- const typedCall = jest.fn((requestType: string) => {
6407
- if (requestType === 'ProtocolInfoRequest') {
6408
- return Promise.resolve({ message: { version: 2 } });
6409
- }
6410
- if (requestType === 'DeviceSessionGet') {
6411
- return Promise.reject(restoreError);
6412
- }
6413
- throw new Error(`Unexpected request: ${requestType}`);
6414
- });
6415
- const device = {
6416
- features: { unlocked: true, passphraseProtection: true },
6417
- passphraseState: 'hidden-state',
6418
- commands: { typedCall },
6419
- isProtocolV2: () => true,
6420
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6421
- getCurrentPassphraseProtection: () => true,
6422
- getInternalState: () => 'hidden-session',
6423
- clearInternalState: jest.fn(),
6424
- getCurrentDeviceId: () => 'wallet-device-id',
6425
- updateInternalState: jest.fn(),
6426
- };
6138
+ await expect(
6139
+ runMethodWithUnlockPolicy(method as any, device as any, {
6140
+ afterStatusBeforeUnlock: () => {
6141
+ calls.push('identity');
6142
+ throw identityError;
6143
+ },
6144
+ })
6145
+ ).rejects.toBe(identityError);
6427
6146
 
6428
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
6429
- expect(method.run).toHaveBeenCalledTimes(1);
6430
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6431
- expect(device.clearInternalState).toHaveBeenCalledTimes(1);
6432
- expect(device.updateInternalState).not.toHaveBeenCalled();
6147
+ expect(calls).toEqual(['status', 'identity']);
6148
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6149
+ expect(method.run).not.toHaveBeenCalled();
6433
6150
  });
6434
6151
 
6435
- test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6152
+ test('unlocks before business and never replays the callback', async () => {
6436
6153
  const calls: string[] = [];
6154
+ const features = { unlocked: true };
6437
6155
  const method = {
6438
6156
  name: 'evmSignMessage',
6439
- payload: { passphraseState: 'hidden-state' },
6440
- useDevicePassphraseState: true,
6441
- unlockPolicy: 'retry-on-locked',
6157
+ unlockPolicy: 'unlock-before-run',
6158
+ protocolV2UiInteraction: { reason: 'signing-confirmation' },
6442
6159
  run: jest.fn(() => {
6443
6160
  calls.push('run');
6444
- return Promise.resolve({ message: 'ok' });
6161
+ return Promise.resolve({ signature: 'signed' });
6445
6162
  }),
6446
6163
  };
6447
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6448
- if (requestType === 'ProtocolInfoRequest') {
6449
- calls.push('negotiate-session');
6450
- return Promise.resolve({ message: { version: 2 } });
6451
- }
6452
- if (requestType === 'DeviceSessionGet') {
6453
- calls.push('resume-hidden-session');
6454
- expect(request).toEqual({ session_id: 'hidden-session' });
6455
- return Promise.resolve({
6456
- message: {
6457
- session_id: 'hidden-session',
6458
- btc_test_address: 'hidden-state',
6459
- },
6460
- });
6461
- }
6462
- throw new Error(`Unexpected request: ${requestType}`);
6463
- });
6464
- const features = {
6465
- unlocked: false,
6466
- unlockedAttachPin: true,
6467
- passphraseProtection: true,
6468
- };
6469
6164
  const device = {
6470
6165
  features,
6471
- passphraseState: 'hidden-state',
6472
- commands: { typedCall },
6166
+ commands: {
6167
+ typedCall: jest.fn(() => {
6168
+ calls.push('status');
6169
+ return Promise.resolve({ message: { unlocked: false } });
6170
+ }),
6171
+ },
6473
6172
  isProtocolV2: () => true,
6173
+ isBootloader: () => false,
6174
+ isRomloader: () => false,
6175
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6176
+ features.unlocked = status.unlocked === true;
6177
+ return features;
6178
+ }),
6474
6179
  unlockDevice: jest.fn(() => {
6475
- calls.push('unlock-main');
6180
+ calls.push('unlock');
6476
6181
  features.unlocked = true;
6477
- features.unlockedAttachPin = false;
6478
- return Promise.resolve();
6182
+ return Promise.resolve(features);
6183
+ }),
6184
+ };
6185
+ const uiCoordinator = {
6186
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6187
+ enterUnlockInteraction: jest.fn(() => {
6188
+ calls.push('unlock-prompt');
6189
+ return undefined;
6479
6190
  }),
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')),
6485
6191
  };
6486
6192
 
6487
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6488
- message: 'ok',
6489
- });
6490
- expect(device.unlockDevice).toHaveBeenCalledWith();
6193
+ await expect(
6194
+ runMethodWithUnlockPolicy(method as any, device as any, {
6195
+ uiCoordinator: uiCoordinator as any,
6196
+ prepare: () => {
6197
+ calls.push('wallet-session');
6198
+ return Promise.resolve();
6199
+ },
6200
+ })
6201
+ ).resolves.toEqual({ signature: 'signed' });
6202
+
6491
6203
  expect(calls).toEqual([
6492
- 'unlock-main',
6493
- 'negotiate-session',
6494
- 'resume-hidden-session',
6495
- 'validate-hidden-session',
6204
+ 'status',
6205
+ 'unlock-prompt',
6206
+ 'unlock',
6207
+ 'wallet-session',
6208
+ 'method-prompt',
6496
6209
  'run',
6497
6210
  ]);
6211
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6212
+ expect(method.run).toHaveBeenCalledTimes(1);
6498
6213
  });
6499
6214
 
6500
- test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6215
+ test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6501
6216
  const method = {
6502
6217
  name: 'evmGetAddress',
6503
- payload: { useEmptyPassphrase: true },
6504
- useDevicePassphraseState: true,
6505
- unlockPolicy: 'retry-on-locked',
6506
- run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6218
+ unlockPolicy: 'unlock-before-run',
6219
+ run: jest.fn(),
6507
6220
  };
6508
6221
  const device = {
6509
- features: { unlocked: false },
6510
- passphraseState: 'stale-hidden-state',
6222
+ features: { unlocked: undefined },
6223
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6511
6224
  isProtocolV2: () => true,
6512
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6225
+ isBootloader: () => false,
6226
+ isRomloader: () => false,
6227
+ updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6228
+ unlockDevice: jest.fn(),
6513
6229
  };
6514
6230
 
6515
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6516
- address: 'standard-wallet-address',
6231
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
6232
+ errorCode: HardwareErrorCode.RuntimeError,
6517
6233
  });
6518
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6519
- expect(method.run).toHaveBeenCalledTimes(1);
6234
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6235
+ expect(method.run).not.toHaveBeenCalled();
6520
6236
  });
6521
6237
 
6522
- test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6523
- const calls: string[] = [];
6238
+ test('returns a business DeviceLocked error without unlocking or replaying', async () => {
6239
+ const error = deviceLockedError();
6524
6240
  const method = {
6525
- name: 'deviceSettingsPageShow',
6241
+ name: 'evmSignMessage',
6526
6242
  unlockPolicy: 'unlock-before-run',
6527
- protocolV2UiInteraction: { reason: 'settings-page' },
6528
- run: jest.fn(() => {
6529
- calls.push('run');
6530
- return Promise.resolve({ message: 'ok' });
6531
- }),
6243
+ run: jest.fn().mockRejectedValue(error),
6532
6244
  };
6533
6245
  const features = { unlocked: true };
6534
6246
  const device = {
6535
6247
  features,
6536
- commands: {
6537
- typedCall: jest.fn(() => {
6538
- calls.push('refresh-status');
6539
- return Promise.resolve({ message: { unlocked: false } });
6540
- }),
6541
- },
6248
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6542
6249
  isProtocolV2: () => true,
6543
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6544
- features.unlocked = status.unlocked ?? features.unlocked;
6545
- return features;
6546
- }),
6547
- unlockDevice: jest.fn(() => {
6548
- calls.push('unlock');
6549
- features.unlocked = true;
6550
- return Promise.resolve();
6551
- }),
6552
- };
6553
- const uiCoordinator = {
6554
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6555
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6556
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6250
+ isBootloader: () => false,
6251
+ isRomloader: () => false,
6252
+ updateProtocolV2Status: jest.fn(() => features),
6253
+ unlockDevice: jest.fn(),
6557
6254
  };
6558
6255
 
6559
- await expect(
6560
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6561
- ).resolves.toEqual({ message: 'ok' });
6562
- expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6563
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6256
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
6257
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6564
6258
  expect(method.run).toHaveBeenCalledTimes(1);
6565
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6566
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6567
6259
  });
6568
6260
 
6569
- test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6570
- const features = { unlocked: false };
6571
- const method = {
6572
- name: 'deviceSettings',
6261
+ test('reuses one lightweight preflight context across nested protected methods', async () => {
6262
+ const features = { unlocked: true };
6263
+ const context = createProtocolV2UnlockContext();
6264
+ const firstMethod = {
6265
+ name: 'allNetworkGetAddress',
6266
+ unlockPolicy: 'unlock-before-run',
6267
+ run: jest.fn().mockResolvedValue({ root: true }),
6268
+ };
6269
+ const nestedMethod = {
6270
+ name: 'evmGetAddress',
6573
6271
  unlockPolicy: 'unlock-before-run',
6574
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6272
+ run: jest.fn().mockResolvedValue({ address: '0x1' }),
6575
6273
  };
6576
6274
  const device = {
6577
6275
  features,
6578
- commands: {
6579
- typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6580
- },
6276
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6581
6277
  isProtocolV2: () => true,
6582
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6583
- features.unlocked = status.unlocked ?? features.unlocked;
6584
- return features;
6585
- }),
6278
+ isBootloader: () => false,
6279
+ isRomloader: () => false,
6280
+ updateProtocolV2Status: jest.fn(() => features),
6586
6281
  unlockDevice: jest.fn(),
6587
6282
  };
6588
6283
 
6589
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6590
- message: 'ok',
6591
- });
6592
- expect(device.unlockDevice).not.toHaveBeenCalled();
6593
- expect(method.run).toHaveBeenCalledTimes(1);
6284
+ await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
6285
+ await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
6286
+
6287
+ expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
6288
+ expect(firstMethod.run).toHaveBeenCalledTimes(1);
6289
+ expect(nestedMethod.run).toHaveBeenCalledTimes(1);
6594
6290
  });
6595
6291
 
6596
6292
  test.each(['bootloader', 'romloader'] as const)(
@@ -6610,7 +6306,7 @@ describe('Protocol V2 protected method execution', () => {
6610
6306
  unlockDevice: jest.fn(),
6611
6307
  };
6612
6308
 
6613
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6309
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6614
6310
  message: 'updating',
6615
6311
  });
6616
6312
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6619,157 +6315,30 @@ describe('Protocol V2 protected method execution', () => {
6619
6315
  }
6620
6316
  );
6621
6317
 
6622
- test('infers a signing interaction before running a Protocol V2 business method', async () => {
6623
- const method = {
6624
- name: 'evmSignMessage',
6625
- params: { message: 'not-exposed-in-event' },
6626
- payload: {},
6627
- unlockPolicy: 'retry-on-locked',
6628
- run: jest.fn().mockResolvedValue({ signature: 'test' }),
6629
- };
6630
- const device = {
6631
- isProtocolV2: () => true,
6632
- unlockDevice: jest.fn(),
6633
- };
6634
- const uiCoordinator = {
6635
- enterMethodInteraction: jest.fn(),
6636
- enterUnlockInteraction: jest.fn(),
6637
- resumeMethodInteraction: jest.fn(),
6638
- };
6639
-
6640
- await runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any);
6641
-
6642
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledWith({
6643
- request: 'button',
6644
- source: 'method-lifecycle',
6645
- reason: 'signing-confirmation',
6646
- completion: 'operation-completed',
6647
- deviceOnly: true,
6648
- operation: 'evmSignMessage',
6649
- });
6650
- });
6651
-
6652
- test('does not unlock a Protocol V1 device or a method without the policy', async () => {
6318
+ test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6653
6319
  for (const [isProtocolV2, unlockPolicy] of [
6654
- [false, 'retry-on-locked'],
6320
+ [false, 'unlock-before-run'],
6655
6321
  [true, 'none'],
6656
6322
  ] as const) {
6657
- const error = deviceLockedError();
6658
6323
  const method = {
6659
6324
  unlockPolicy,
6660
- run: jest.fn().mockRejectedValue(error),
6325
+ useDevicePassphraseState: false,
6326
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6661
6327
  };
6662
6328
  const device = {
6329
+ commands: { typedCall: jest.fn() },
6663
6330
  isProtocolV2: () => isProtocolV2,
6664
6331
  unlockDevice: jest.fn(),
6665
6332
  };
6666
6333
 
6667
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6334
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6335
+ message: 'ok',
6336
+ });
6337
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6668
6338
  expect(device.unlockDevice).not.toHaveBeenCalled();
6669
6339
  expect(method.run).toHaveBeenCalledTimes(1);
6670
6340
  }
6671
6341
  });
6672
-
6673
- test('runs lock-free eventless methods without unlocking or synthesized UI', async () => {
6674
- const method = {
6675
- name: 'uploadPortfolio',
6676
- unlockPolicy: 'none',
6677
- protocolV2UiMode: 'none',
6678
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6679
- };
6680
- const device = {
6681
- features: { unlocked: false },
6682
- isProtocolV2: () => true,
6683
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6684
- };
6685
- const uiCoordinator = {
6686
- enterMethodInteraction: jest.fn(),
6687
- enterUnlockInteraction: jest.fn(),
6688
- resumeMethodInteraction: jest.fn(),
6689
- };
6690
-
6691
- await expect(
6692
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6693
- ).resolves.toEqual({ message: 'ok' });
6694
- expect(device.unlockDevice).not.toHaveBeenCalled();
6695
- expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6696
- expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6697
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6698
- });
6699
-
6700
- test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6701
- const error = deviceLockedError();
6702
- const method = {
6703
- name: 'uploadPortfolio',
6704
- unlockPolicy: 'none',
6705
- run: jest.fn().mockRejectedValue(error),
6706
- };
6707
- const device = {
6708
- features: { unlocked: true },
6709
- isProtocolV2: () => true,
6710
- unlockDevice: jest.fn(),
6711
- };
6712
-
6713
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6714
- expect(method.run).toHaveBeenCalledTimes(1);
6715
- expect(device.unlockDevice).not.toHaveBeenCalled();
6716
- });
6717
-
6718
- test('does not retry when unlock fails or when the retry is still locked', async () => {
6719
- const initialError = deviceLockedError();
6720
- const unlockError = new Error('PIN cancelled');
6721
- const unlockFailMethod = {
6722
- unlockPolicy: 'retry-on-locked',
6723
- run: jest.fn().mockRejectedValue(initialError),
6724
- };
6725
- const unlockFailDevice = {
6726
- isProtocolV2: () => true,
6727
- unlockDevice: jest.fn().mockRejectedValue(unlockError),
6728
- };
6729
- const unlockFailCoordinator = {
6730
- enterMethodInteraction: jest.fn(),
6731
- enterUnlockInteraction: jest.fn(),
6732
- resumeMethodInteraction: jest.fn(),
6733
- };
6734
-
6735
- await expect(
6736
- runMethodWithUnlockRetry(
6737
- unlockFailMethod as any,
6738
- unlockFailDevice as any,
6739
- unlockFailCoordinator as any
6740
- )
6741
- ).rejects.toBe(unlockError);
6742
- expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
6743
- expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6744
- expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6745
-
6746
- const retryError = deviceLockedError();
6747
- const retryFailMethod = {
6748
- unlockPolicy: 'retry-on-locked',
6749
- run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
6750
- };
6751
- const retryFailDevice = {
6752
- isProtocolV2: () => true,
6753
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6754
- };
6755
- const retryFailCoordinator = {
6756
- enterMethodInteraction: jest.fn(),
6757
- enterUnlockInteraction: jest.fn(),
6758
- resumeMethodInteraction: jest.fn(),
6759
- };
6760
-
6761
- await expect(
6762
- runMethodWithUnlockRetry(
6763
- retryFailMethod as any,
6764
- retryFailDevice as any,
6765
- retryFailCoordinator as any
6766
- )
6767
- ).rejects.toBe(retryError);
6768
- expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
6769
- expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
6770
- expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6771
- expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
6772
- });
6773
6342
  });
6774
6343
 
6775
6344
  describe('Protocol V2 current low-level methods', () => {
@@ -6901,7 +6470,9 @@ describe('Protocol V2 current low-level methods', () => {
6901
6470
 
6902
6471
  await method.run();
6903
6472
 
6904
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
6473
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
6474
+ eventless_wallet_session: true,
6475
+ });
6905
6476
  });
6906
6477
 
6907
6478
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {