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

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 (195) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +0 -78
  2. package/__tests__/DeviceEventRegistration.test.ts +0 -6
  3. package/__tests__/connectSettings.test.ts +47 -5
  4. package/__tests__/firmware-update/check-all-firmware-release.test.ts +175 -0
  5. package/__tests__/firmware-update/firmware-artifact-source.test.ts +119 -0
  6. package/__tests__/firmware-update/firmware-host-binding.test.ts +70 -0
  7. package/__tests__/firmware-update/firmware-preparation-error.test.ts +27 -0
  8. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +13 -0
  9. package/__tests__/firmware-update/firmware-update-plan.test.ts +593 -0
  10. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +213 -0
  11. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +59 -31
  12. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +58 -0
  13. package/__tests__/firmware-update/firmware-upload-source.test.ts +70 -0
  14. package/__tests__/get-device-state.test.ts +12 -132
  15. package/__tests__/homescreen.test.ts +1 -41
  16. package/__tests__/logBlockEvent.test.ts +1 -1
  17. package/__tests__/open-wallet-session.test.ts +18 -259
  18. package/__tests__/protocol-v2-unlock-policy.test.ts +35 -86
  19. package/__tests__/protocol-v2.test.ts +632 -333
  20. package/__tests__/public-pro2-api-boundary.test.ts +0 -1
  21. package/dist/api/BaseMethod.d.ts +1 -3
  22. package/dist/api/BaseMethod.d.ts.map +1 -1
  23. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  24. package/dist/api/FirmwareUpdateV2.d.ts +11 -0
  25. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  26. package/dist/api/FirmwareUpdateV3.d.ts +7 -3
  27. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  28. package/dist/api/FirmwareUpdateV4.d.ts +22 -5
  29. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  30. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  31. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  32. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  33. package/dist/api/device/DeviceUpdateBootloader.d.ts +5 -1
  34. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  35. package/dist/api/firmware/FirmwareArtifactSource.d.ts +25 -0
  36. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -0
  37. package/dist/api/firmware/FirmwareHostBinding.d.ts +6 -0
  38. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -0
  39. package/dist/api/firmware/FirmwarePreparationError.d.ts +3 -0
  40. package/dist/api/firmware/FirmwarePreparationError.d.ts.map +1 -0
  41. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +8 -0
  42. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  43. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +3 -0
  44. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +1 -0
  45. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +25 -0
  46. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -0
  47. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +30 -0
  48. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -0
  49. package/dist/api/firmware/getBinary.d.ts +7 -0
  50. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  51. package/dist/api/firmware/progressThrottle.d.ts +3 -0
  52. package/dist/api/firmware/progressThrottle.d.ts.map +1 -0
  53. package/dist/api/firmware/updateBootloader.d.ts +2 -0
  54. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  55. package/dist/api/firmware/uploadFirmware.d.ts +9 -1
  56. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  57. package/dist/api/helpers/protocolV2FileWrite.d.ts +0 -2
  58. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  59. package/dist/api/index.d.ts +0 -1
  60. package/dist/api/index.d.ts.map +1 -1
  61. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  62. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  63. package/dist/core/deviceEventRegistration.d.ts +0 -2
  64. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  65. package/dist/core/index.d.ts.map +1 -1
  66. package/dist/data-manager/DataManager.d.ts +1 -0
  67. package/dist/data-manager/DataManager.d.ts.map +1 -1
  68. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  69. package/dist/device/Device.d.ts +4 -22
  70. package/dist/device/Device.d.ts.map +1 -1
  71. package/dist/events/device.d.ts +0 -4
  72. package/dist/events/device.d.ts.map +1 -1
  73. package/dist/events/logBlockEvent.d.ts.map +1 -1
  74. package/dist/events/ui-request.d.ts +2 -27
  75. package/dist/events/ui-request.d.ts.map +1 -1
  76. package/dist/index.d.ts +181 -88
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +3162 -1614
  79. package/dist/inject.d.ts.map +1 -1
  80. package/dist/lowLevelInject.d.ts.map +1 -1
  81. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  82. package/dist/protocols/protocol-v2/lockedError.d.ts +2 -0
  83. package/dist/protocols/protocol-v2/lockedError.d.ts.map +1 -0
  84. package/dist/protocols/protocol-v2/uiInteraction.d.ts +3 -3
  85. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  86. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +3 -1
  87. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  88. package/dist/protocols/protocol-v2/unlockRetry.d.ts +10 -0
  89. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -0
  90. package/dist/protocols/protocol-v2/walletSession.d.ts +1 -4
  91. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  92. package/dist/topLevelInject.d.ts.map +1 -1
  93. package/dist/types/api/checkAllFirmwareRelease.d.ts +4 -0
  94. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  95. package/dist/types/api/deviceUpdateBootloader.d.ts +6 -0
  96. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  97. package/dist/types/api/export.d.ts +4 -1
  98. package/dist/types/api/export.d.ts.map +1 -1
  99. package/dist/types/api/firmwareUpdate.d.ts +69 -0
  100. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  101. package/dist/types/api/firmwareUpdateCapabilities.d.ts +9 -0
  102. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +1 -0
  103. package/dist/types/api/firmwareUpdatePlan.d.ts +28 -0
  104. package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -0
  105. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +42 -0
  106. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +1 -0
  107. package/dist/types/api/index.d.ts +10 -3
  108. package/dist/types/api/index.d.ts.map +1 -1
  109. package/dist/types/api/protocolV2.d.ts +0 -3
  110. package/dist/types/api/protocolV2.d.ts.map +1 -1
  111. package/dist/types/settings.d.ts +13 -0
  112. package/dist/types/settings.d.ts.map +1 -1
  113. package/dist/utils/deviceFeaturesUtils.d.ts +0 -2
  114. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  115. package/dist/utils/homescreen.d.ts +0 -4
  116. package/dist/utils/homescreen.d.ts.map +1 -1
  117. package/dist/utils/index.d.ts +1 -1
  118. package/dist/utils/index.d.ts.map +1 -1
  119. package/dist/utils/patch.d.ts +1 -1
  120. package/dist/utils/patch.d.ts.map +1 -1
  121. package/dist/utils/pro2Wallpaper.d.ts +0 -10
  122. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  123. package/package.json +4 -4
  124. package/src/api/BaseMethod.ts +10 -8
  125. package/src/api/CheckAllFirmwareRelease.ts +51 -6
  126. package/src/api/FirmwareUpdateV2.ts +298 -120
  127. package/src/api/FirmwareUpdateV3.ts +263 -57
  128. package/src/api/FirmwareUpdateV4.ts +748 -281
  129. package/src/api/GetPassphraseState.ts +1 -7
  130. package/src/api/OpenWalletSession.ts +17 -54
  131. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +11 -32
  132. package/src/api/device/DeviceUnlock.ts +1 -1
  133. package/src/api/device/DeviceUpdateBootloader.ts +122 -6
  134. package/src/api/firmware/FirmwareArtifactSource.ts +278 -0
  135. package/src/api/firmware/FirmwareHostBinding.ts +100 -0
  136. package/src/api/firmware/FirmwarePreparationError.ts +12 -0
  137. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +57 -0
  138. package/src/api/firmware/FirmwareUpdateCapabilities.ts +9 -0
  139. package/src/api/firmware/FirmwareUpdatePlan.ts +745 -0
  140. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +392 -0
  141. package/src/api/firmware/progressThrottle.ts +44 -0
  142. package/src/api/firmware/updateBootloader.ts +19 -4
  143. package/src/api/firmware/uploadFirmware.ts +140 -22
  144. package/src/api/helpers/protocolV2FileWrite.ts +4 -11
  145. package/src/api/index.ts +0 -1
  146. package/src/api/protocol-v2/ProtocolInfoRequest.ts +1 -3
  147. package/src/core/deviceEventRegistration.ts +0 -4
  148. package/src/core/index.ts +75 -118
  149. package/src/data/messages/messages-protocol-v2.json +0 -10
  150. package/src/data-manager/DataManager.ts +33 -33
  151. package/src/data-manager/connectSettings.ts +11 -0
  152. package/src/device/Device.ts +21 -193
  153. package/src/events/device.ts +0 -4
  154. package/src/events/logBlockEvent.ts +0 -1
  155. package/src/events/ui-request.ts +2 -32
  156. package/src/index.ts +5 -0
  157. package/src/inject.ts +22 -3
  158. package/src/lowLevelInject.ts +5 -1
  159. package/src/protocols/protocol-v2/features.ts +2 -9
  160. package/src/protocols/protocol-v2/lockedError.ts +10 -0
  161. package/src/protocols/protocol-v2/uiInteraction.ts +5 -31
  162. package/src/protocols/protocol-v2/unlockPolicy.ts +105 -1
  163. package/src/protocols/protocol-v2/unlockRetry.ts +109 -0
  164. package/src/protocols/protocol-v2/walletSession.ts +18 -93
  165. package/src/topLevelInject.ts +5 -1
  166. package/src/types/api/checkAllFirmwareRelease.ts +4 -0
  167. package/src/types/api/deviceUpdateBootloader.ts +6 -0
  168. package/src/types/api/export.ts +18 -0
  169. package/src/types/api/firmwareUpdate.ts +76 -0
  170. package/src/types/api/firmwareUpdateCapabilities.ts +9 -0
  171. package/src/types/api/firmwareUpdatePlan.ts +38 -0
  172. package/src/types/api/firmwareUpdatePreparedPlan.ts +53 -0
  173. package/src/types/api/index.ts +14 -2
  174. package/src/types/api/protocolV2.ts +0 -13
  175. package/src/types/settings.ts +13 -0
  176. package/src/utils/deviceFeaturesUtils.ts +0 -4
  177. package/src/utils/homescreen.ts +6 -32
  178. package/src/utils/index.ts +1 -6
  179. package/src/utils/pro2Wallpaper.ts +8 -35
  180. package/__tests__/deviceUploadNft.test.ts +0 -187
  181. package/__tests__/pro2Nft.test.ts +0 -108
  182. package/__tests__/protocol-v2-ui-lifecycle.test.ts +0 -56
  183. package/__tests__/uiProgressThrottle.test.ts +0 -74
  184. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +0 -30
  185. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +0 -1
  186. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +0 -22
  187. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +0 -1
  188. package/dist/utils/pro2Nft.d.ts +0 -29
  189. package/dist/utils/pro2Nft.d.ts.map +0 -1
  190. package/dist/utils/uiProgressThrottle.d.ts +0 -3
  191. package/dist/utils/uiProgressThrottle.d.ts.map +0 -1
  192. package/src/api/protocol-v2/DeviceUploadNft.ts +0 -166
  193. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +0 -117
  194. package/src/utils/pro2Nft.ts +0 -91
  195. package/src/utils/uiProgressThrottle.ts +0 -63
@@ -61,7 +61,6 @@ 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';
65
64
  import { UI_REQUEST } from '../src/events/ui-request';
66
65
  import {
67
66
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
@@ -79,10 +78,7 @@ import {
79
78
  getProtocolV2WalletSession,
80
79
  refreshProtocolV2DeviceStatus,
81
80
  } from '../src/protocols/protocol-v2/walletSession';
82
- import {
83
- createProtocolV2UnlockContext,
84
- runMethodWithUnlockPolicy,
85
- } from '../src/protocols/protocol-v2/unlockPolicyRunner';
81
+ import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
86
82
  import { BaseMethod } from '../src/api/BaseMethod';
87
83
  import {
88
84
  buildProtocolV1FeaturesPayload,
@@ -96,6 +92,7 @@ import {
96
92
  getMethodVersionRange,
97
93
  } from '../src/utils';
98
94
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
95
+ import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
99
96
  import {
100
97
  getPassphraseState,
101
98
  getPassphraseStateWithRefreshDeviceInfo,
@@ -244,7 +241,7 @@ describe('UploadPortfolio', () => {
244
241
  (method as any).device = device;
245
242
 
246
243
  method.init();
247
- await runMethodWithUnlockPolicy(method, device as any);
244
+ await runMethodWithUnlockRetry(method, device as any);
248
245
 
249
246
  expect(unlockDevice).not.toHaveBeenCalled();
250
247
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -420,16 +417,14 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
420
417
  }
421
418
  return d.features;
422
419
  };
423
- d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
424
- requestProtocolV2ProtocolInfo({
425
- commands: d.getCommands?.() ?? d.commands,
426
- timeoutMs,
427
- });
428
420
  d.probeProtocolV2RuntimeState ??= async (
429
421
  deviceInfo: ProtocolV2DeviceInfo,
430
422
  timeoutMs?: number
431
423
  ) => {
432
- const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
424
+ const protocolInfo = await requestProtocolV2ProtocolInfo({
425
+ commands: d.getCommands(),
426
+ timeoutMs,
427
+ });
433
428
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
434
429
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
435
430
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -810,7 +805,7 @@ describe('Protocol V2 feature adapter', () => {
810
805
  ).resolves.toEqual({
811
806
  passphraseState: 'state-1',
812
807
  newSession: 'session-1',
813
- unlockedAttachPin: false,
808
+ unlockedAttachPin: undefined,
814
809
  resumed: false,
815
810
  });
816
811
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -1558,70 +1553,6 @@ describe('Protocol V2 feature adapter', () => {
1558
1553
  expect(device.getInternalState()).toBeUndefined();
1559
1554
  });
1560
1555
 
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
-
1625
1556
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1626
1557
  const device = Device.fromDescriptor({
1627
1558
  id: 'cache-device-attach-recovery',
@@ -1678,17 +1609,7 @@ describe('Protocol V2 feature adapter', () => {
1678
1609
  }),
1679
1610
  { cancelDeviceOnReject: false }
1680
1611
  );
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
- );
1612
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1692
1613
  expect(device.getInternalState()).toBe('new-attach-session');
1693
1614
  });
1694
1615
 
@@ -1972,10 +1893,12 @@ describe('Protocol V2 feature adapter', () => {
1972
1893
  expect(getFeatures).not.toHaveBeenCalled();
1973
1894
  });
1974
1895
 
1975
- test('uses the cached wallet address when the local session is missing', async () => {
1896
+ test('marks an interactive cache miss as a wallet-session recovery', async () => {
1976
1897
  const typedCall = jest
1977
1898
  .fn()
1978
1899
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1979
1902
  .mockResolvedValueOnce({
1980
1903
  message: {
1981
1904
  btc_test_address: 'expected-state',
@@ -2003,8 +1926,16 @@ describe('Protocol V2 feature adapter', () => {
2003
1926
  await expect(
2004
1927
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
2005
1928
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
2006
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2007
- expect(promptPassphrase).not.toHaveBeenCalled();
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
+ );
2008
1939
  });
2009
1940
 
2010
1941
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2070,6 +2001,7 @@ describe('Protocol V2 feature adapter', () => {
2070
2001
  true
2071
2002
  );
2072
2003
  expect(typedCall.mock.calls).toEqual([
2004
+ ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2073
2005
  ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2074
2006
  ]);
2075
2007
  });
@@ -2337,13 +2269,12 @@ describe('Protocol V2 feature adapter', () => {
2337
2269
  );
2338
2270
 
2339
2271
  expect(device.getInternalState()).toBeUndefined();
2340
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2341
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2272
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2342
2273
  passphrase: 'host hidden wallet',
2343
2274
  on_device: false,
2344
2275
  });
2345
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2346
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2276
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
+ expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2347
2278
  });
2348
2279
 
2349
2280
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2380,9 +2311,6 @@ describe('Protocol V2 feature adapter', () => {
2380
2311
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2381
2312
  eventless_wallet_session: true,
2382
2313
  });
2383
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
2384
- type: DeviceSessionPinType.Main,
2385
- });
2386
2314
  });
2387
2315
 
2388
2316
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2753,9 +2681,7 @@ describe('Protocol V2 feature adapter', () => {
2753
2681
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2754
2682
  }
2755
2683
  );
2756
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2757
- eventless_wallet_session: true,
2758
- });
2684
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2759
2685
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2760
2686
  });
2761
2687
 
@@ -2793,9 +2719,7 @@ describe('Protocol V2 feature adapter', () => {
2793
2719
  }),
2794
2720
  expect.anything()
2795
2721
  );
2796
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2797
- eventless_wallet_session: true,
2798
- });
2722
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2799
2723
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2800
2724
  expect(device.features).toMatchObject({
2801
2725
  deviceId: null,
@@ -3061,6 +2985,10 @@ describe('Protocol V2 feature adapter', () => {
3061
2985
  fw: { application: { version: '1.2.4' } },
3062
2986
  },
3063
2987
  })
2988
+ .mockResolvedValueOnce({
2989
+ type: 'ProtocolInfo',
2990
+ message: protocolV2ApplicationInfo,
2991
+ })
3064
2992
  .mockResolvedValueOnce({
3065
2993
  type: 'DeviceStatus',
3066
2994
  message: { init_states: true, unlocked: true },
@@ -3082,7 +3010,7 @@ describe('Protocol V2 feature adapter', () => {
3082
3010
  firmwareVersion: '1.2.4',
3083
3011
  passphraseProtection: null,
3084
3012
  });
3085
- expect(typedCall).toHaveBeenCalledTimes(5);
3013
+ expect(typedCall).toHaveBeenCalledTimes(6);
3086
3014
  expect(typedCall).toHaveBeenNthCalledWith(
3087
3015
  4,
3088
3016
  'DeviceInfoGet',
@@ -3196,23 +3124,9 @@ describe('Protocol V2 feature adapter', () => {
3196
3124
  status: { init_states: true },
3197
3125
  }
3198
3126
  );
3199
- const pinOnDevice = jest.fn();
3200
- device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3201
3127
 
3202
3128
  const features = await device.unlockDevice();
3203
3129
 
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
- );
3216
3130
  expect(typedCall.mock.calls).toEqual([
3217
3131
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3218
3132
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3258,12 +3172,9 @@ describe('Protocol V2 feature adapter', () => {
3258
3172
  throw new Error(`Unexpected request: ${requestType}`);
3259
3173
  });
3260
3174
  (device as any).commands = { typedCall };
3261
- const pinOnDevice = jest.fn();
3262
- device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3263
3175
 
3264
- await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3176
+ await device.unlockDevice();
3265
3177
 
3266
- expect(pinOnDevice).not.toHaveBeenCalled();
3267
3178
  expect(typedCall.mock.calls).toEqual([
3268
3179
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3269
3180
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3806,7 +3717,7 @@ describe('Protocol V2 firmware update targets', () => {
3806
3717
  });
3807
3718
  });
3808
3719
 
3809
- test('enters Protocol V2 bootloader before upload', async () => {
3720
+ test('delegates Protocol V2 mode transition to the phase executor', async () => {
3810
3721
  const method = new FirmwareUpdateV4({
3811
3722
  id: 1,
3812
3723
  payload: {
@@ -3841,7 +3752,7 @@ describe('Protocol V2 firmware update targets', () => {
3841
3752
 
3842
3753
  await method.run();
3843
3754
 
3844
- expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
3755
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3845
3756
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3846
3757
  fwBinaryMap: [
3847
3758
  {
@@ -4224,9 +4135,9 @@ describe('Protocol V2 firmware update targets', () => {
4224
4135
  const typedCall = jest
4225
4136
  .fn()
4226
4137
  .mockRejectedValue(
4227
- new DOMException(
4228
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4229
- 'NotFoundError'
4138
+ Object.assign(
4139
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4140
+ { name: 'NotFoundError' }
4230
4141
  )
4231
4142
  );
4232
4143
 
@@ -4407,6 +4318,77 @@ describe('Protocol V2 firmware update targets', () => {
4407
4318
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4408
4319
  });
4409
4320
 
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
+
4410
4392
  test('polls target status after update ACK and finishes when all targets complete', async () => {
4411
4393
  const method = new FirmwareUpdateV4({
4412
4394
  id: 1,
@@ -4483,7 +4465,7 @@ describe('Protocol V2 firmware update targets', () => {
4483
4465
  ).toBe(true);
4484
4466
 
4485
4467
  (method.postProgressMessage as jest.Mock).mockClear();
4486
- expect(
4468
+ expect(() =>
4487
4469
  (method as any).assertProtocolV2TargetStatus(
4488
4470
  [
4489
4471
  { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
@@ -4491,8 +4473,8 @@ describe('Protocol V2 firmware update targets', () => {
4491
4473
  ],
4492
4474
  new Set([4, 10])
4493
4475
  )
4494
- ).toBe(false);
4495
- expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4476
+ ).toThrow('Protocol V2 install status conflicts with target 4');
4477
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
4496
4478
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
4497
4479
 
4498
4480
  expect(
@@ -4596,10 +4578,14 @@ describe('Protocol V2 firmware update targets', () => {
4596
4578
  const writtenPaths: string[] = [];
4597
4579
  method.postTipMessage = jest.fn();
4598
4580
  method.postProgressMessage = jest.fn();
4599
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
4581
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
4600
4582
  writtenPaths.push(params.filePath);
4601
- return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
4583
+ return Number(params.processedSize ?? 0) + Number(params.source.size);
4602
4584
  });
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({});
4603
4589
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4604
4590
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4605
4591
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4639,10 +4625,12 @@ describe('Protocol V2 firmware update targets', () => {
4639
4625
  'vol0:/coprocessor.bin',
4640
4626
  1
4641
4627
  );
4642
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
4643
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
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, {
4644
4633
  targets: [
4645
- { target_id: 3, path: 'vol0:/bootloader.bin' },
4646
4634
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
4647
4635
  { target_id: 7, path: 'vol0:/se01.bin' },
4648
4636
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -4652,7 +4640,7 @@ describe('Protocol V2 firmware update targets', () => {
4652
4640
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
4653
4641
  });
4654
4642
 
4655
- test('announces one transfer when syncing resource bundles and staging firmware', async () => {
4643
+ test('announces separate resource and component transfer phases', async () => {
4656
4644
  const method = new FirmwareUpdateV4({
4657
4645
  id: 1,
4658
4646
  payload: {
@@ -4662,11 +4650,14 @@ describe('Protocol V2 firmware update targets', () => {
4662
4650
 
4663
4651
  method.postTipMessage = jest.fn();
4664
4652
  method.postProgressMessage = jest.fn();
4665
- (method as any).protocolV2CommonUpdateProcess = jest
4653
+ (method as any).protocolV2SourceUpdateProcess = jest
4666
4654
  .fn()
4667
4655
  .mockImplementation(params =>
4668
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4656
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
4669
4657
  );
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({});
4670
4661
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4671
4662
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4672
4663
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4686,21 +4677,22 @@ describe('Protocol V2 firmware update targets', () => {
4686
4677
  {
4687
4678
  fileName: 'application_p1.bin',
4688
4679
  binary: new Uint8Array([3]).buffer,
4689
- targetId: 3,
4680
+ targetId: 4,
4690
4681
  },
4691
4682
  ],
4692
4683
  });
4693
4684
 
4694
- expect(method.postTipMessage).toHaveBeenCalledTimes(2);
4685
+ expect(method.postTipMessage).toHaveBeenCalledTimes(3);
4695
4686
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
4696
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
4697
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4687
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
4688
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
4689
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
4698
4690
  1,
4699
- expect.objectContaining({ processedSize: 0, totalSize: 3 })
4691
+ expect.objectContaining({ processedSize: 0, totalSize: 2 })
4700
4692
  );
4701
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4693
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
4702
4694
  2,
4703
- expect.objectContaining({ processedSize: 2, totalSize: 3 })
4695
+ expect.objectContaining({ processedSize: 0, totalSize: 1 })
4704
4696
  );
4705
4697
  });
4706
4698
 
@@ -4714,7 +4706,8 @@ describe('Protocol V2 firmware update targets', () => {
4714
4706
 
4715
4707
  method.postTipMessage = jest.fn();
4716
4708
  method.postProgressMessage = jest.fn();
4717
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4709
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4710
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4718
4711
  (method as any).device = stubDevice({
4719
4712
  getCommands: () => ({
4720
4713
  typedCall: jest.fn().mockResolvedValue({
@@ -4760,11 +4753,14 @@ describe('Protocol V2 firmware update targets', () => {
4760
4753
 
4761
4754
  method.postTipMessage = jest.fn();
4762
4755
  method.postProgressMessage = jest.fn();
4763
- (method as any).protocolV2CommonUpdateProcess = jest
4756
+ (method as any).protocolV2SourceUpdateProcess = jest
4764
4757
  .fn()
4765
4758
  .mockImplementation(params =>
4766
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4759
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
4767
4760
  );
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({});
4768
4764
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4769
4765
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4770
4766
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5087,6 +5083,58 @@ describe('Protocol V2 firmware update targets', () => {
5087
5083
  getFirmwareLatestReleaseSpy.mockRestore();
5088
5084
  });
5089
5085
 
5086
+ test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
5087
+ const method = new FirmwareUpdateV4({
5088
+ id: 1,
5089
+ payload: {
5090
+ method: 'firmwareUpdateV4',
5091
+ },
5092
+ });
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
+ });
5127
+
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
+
5090
5138
  test('downloads and validates remote RESC bundles before entering bootloader', async () => {
5091
5139
  const method = new FirmwareUpdateV4({
5092
5140
  id: 1,
@@ -5139,7 +5187,9 @@ describe('Protocol V2 firmware update targets', () => {
5139
5187
  order.push('bootloader');
5140
5188
  return Promise.resolve(true);
5141
5189
  });
5142
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
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);
5143
5193
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5144
5194
  (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5145
5195
  bootloaderVersion: '1.0.0',
@@ -5151,7 +5201,7 @@ describe('Protocol V2 firmware update targets', () => {
5151
5201
  await method.run();
5152
5202
 
5153
5203
  expect(order).toEqual(['download', 'bootloader']);
5154
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5204
+ expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
5155
5205
  bootloaderBinary: null,
5156
5206
  fwBinaryMap: [],
5157
5207
  resourceBundles: [
@@ -5164,6 +5214,7 @@ describe('Protocol V2 firmware update targets', () => {
5164
5214
  ],
5165
5215
  });
5166
5216
 
5217
+ executeProtocolV2UpdateSpy.mockRestore();
5167
5218
  getSysResourceBinarySpy.mockRestore();
5168
5219
  getFirmwareLatestReleaseSpy.mockRestore();
5169
5220
  });
@@ -5175,12 +5226,13 @@ describe('Protocol V2 firmware update targets', () => {
5175
5226
  method: 'firmwareUpdateV4',
5176
5227
  },
5177
5228
  });
5229
+ method.init();
5178
5230
  const getSysResourceBinarySpy = jest
5179
5231
  .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5180
5232
  .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5181
5233
 
5182
5234
  await expect(
5183
- (method as any).prefetchProtocolV2ResourceBundles([
5235
+ (method as any).materializeProtocolV2ResourceBundles([
5184
5236
  {
5185
5237
  name: 'images',
5186
5238
  binary: new ArrayBuffer(0),
@@ -5477,7 +5529,10 @@ describe('Protocol V2 firmware update targets', () => {
5477
5529
 
5478
5530
  method.postTipMessage = jest.fn();
5479
5531
  method.postProgressMessage = jest.fn();
5480
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
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);
5481
5536
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
5482
5537
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
5483
5538
 
@@ -5493,7 +5548,7 @@ describe('Protocol V2 firmware update targets', () => {
5493
5548
  fwBinaryMap: [],
5494
5549
  });
5495
5550
 
5496
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
5551
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
5497
5552
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
5498
5553
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
5499
5554
  });
@@ -5524,12 +5579,19 @@ describe('Protocol V2 firmware update targets', () => {
5524
5579
  });
5525
5580
  method.postProgressMessage = jest.fn();
5526
5581
 
5527
- await (method as any).protocolV2CommonUpdateProcess({
5528
- payload: new Uint8Array(4097).buffer,
5529
- filePath: 'vol1:firmware.bin',
5530
- processedSize: 0,
5531
- totalSize: 4097,
5582
+ const source = await openFirmwareByteSource({
5583
+ binary: new Uint8Array(4097).buffer,
5532
5584
  });
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
+ }
5533
5595
 
5534
5596
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5535
5597
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -5586,12 +5648,16 @@ describe('Protocol V2 firmware update targets', () => {
5586
5648
  method.postProgressMessage = jest.fn();
5587
5649
 
5588
5650
  try {
5589
- await (method as any).protocolV2CommonUpdateProcess({
5590
- payload: new Uint8Array(8001).buffer,
5651
+ const source = await openFirmwareByteSource({
5652
+ binary: new Uint8Array(8001).buffer,
5653
+ });
5654
+ await (method as any).protocolV2SourceUpdateProcess({
5655
+ source,
5591
5656
  filePath: 'vol0:/firmware.bin',
5592
5657
  processedSize: 0,
5593
5658
  totalSize: 8001,
5594
5659
  });
5660
+ await source?.close();
5595
5661
  } finally {
5596
5662
  setTimeoutSpy.mockRestore();
5597
5663
  }
@@ -5626,16 +5692,21 @@ describe('Protocol V2 firmware update targets', () => {
5626
5692
  });
5627
5693
  method.postProgressMessage = jest.fn();
5628
5694
  method.postTipMessage = jest.fn();
5695
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
5629
5696
 
5697
+ const source = await openFirmwareByteSource({
5698
+ binary: new Uint8Array(4097).buffer,
5699
+ });
5630
5700
  await expect(
5631
- (method as any).protocolV2WriteWholeFile({
5632
- payload: new Uint8Array(4097).buffer,
5701
+ (method as any).protocolV2SourceUpdateProcess({
5702
+ source,
5633
5703
  filePath: 'vol0:/firmware.bin',
5634
5704
  processedSize: 0,
5635
5705
  totalSize: 4097,
5636
5706
  })
5637
5707
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
5638
- expect(typedCall).toHaveBeenCalledTimes(2);
5708
+ await source?.close();
5709
+ expect(typedCall).toHaveBeenCalledTimes(6);
5639
5710
  });
5640
5711
 
5641
5712
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -5668,12 +5739,19 @@ describe('Protocol V2 firmware update targets', () => {
5668
5739
  });
5669
5740
  method.postProgressMessage = jest.fn();
5670
5741
 
5671
- await (method as any).protocolV2CommonUpdateProcess({
5672
- payload: new Uint8Array(1801).buffer,
5673
- filePath: 'vol1:ble-firmware.bin',
5674
- processedSize: 0,
5675
- totalSize: 1801,
5742
+ const source = await openFirmwareByteSource({
5743
+ binary: new Uint8Array(1801).buffer,
5676
5744
  });
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
+ }
5677
5755
 
5678
5756
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5679
5757
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
@@ -5790,9 +5868,9 @@ describe('Protocol V2 firmware update method', () => {
5790
5868
  const typedCall = jest
5791
5869
  .fn()
5792
5870
  .mockRejectedValue(
5793
- new DOMException(
5794
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
5795
- 'NotFoundError'
5871
+ Object.assign(
5872
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
5873
+ { name: 'NotFoundError' }
5796
5874
  )
5797
5875
  );
5798
5876
 
@@ -5957,6 +6035,21 @@ describe('Protocol V2 firmware update method', () => {
5957
6035
  });
5958
6036
 
5959
6037
  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
+
5960
6053
  test('rejects a different physical serial before firmware transfer resumes', () => {
5961
6054
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
5962
6055
  'identity mismatch'
@@ -6193,230 +6286,311 @@ describe('Protocol V2 protected method execution', () => {
6193
6286
  expect(method.unlockPolicy).toBe('none');
6194
6287
  });
6195
6288
 
6196
- test('checks fresh status for a wallet business method without a name allowlist', async () => {
6289
+ test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6197
6290
  const calls: string[] = [];
6198
- const features = { unlocked: true };
6199
6291
  const method = {
6200
- name: 'confluxSignMessageCIP23',
6201
- unlockPolicy: 'none',
6202
- useDevicePassphraseState: true,
6203
- protocolV2UiInteraction: { reason: 'address-confirmation' },
6204
- run: jest.fn(() => {
6205
- calls.push('run');
6206
- return Promise.resolve({ address: '0x1' });
6207
- }),
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
+ }),
6208
6305
  };
6209
6306
  const device = {
6210
- features,
6211
- commands: {
6212
- typedCall: jest.fn(() => {
6213
- calls.push('status');
6214
- return Promise.resolve({ message: { unlocked: true } });
6215
- }),
6216
- },
6217
6307
  isProtocolV2: () => true,
6218
- isBootloader: () => false,
6219
- isRomloader: () => false,
6220
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6221
- features.unlocked = status.unlocked === true;
6222
- return features;
6308
+ unlockDevice: jest.fn(() => {
6309
+ calls.push('unlock');
6310
+ return Promise.resolve();
6223
6311
  }),
6224
- unlockDevice: jest.fn(),
6225
6312
  };
6226
6313
  const uiCoordinator = {
6227
6314
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6228
- enterUnlockInteraction: jest.fn(),
6315
+ enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6316
+ resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6229
6317
  };
6230
6318
 
6231
6319
  await expect(
6232
- runMethodWithUnlockPolicy(method as any, device as any, {
6233
- uiCoordinator: uiCoordinator as any,
6234
- })
6235
- ).resolves.toEqual({ address: '0x1' });
6236
-
6237
- expect(calls).toEqual(['status', 'method-prompt', 'run']);
6238
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6239
- expect(device.unlockDevice).not.toHaveBeenCalled();
6240
- expect(method.run).toHaveBeenCalledTimes(1);
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
+ ]);
6241
6330
  });
6242
6331
 
6243
- test('validates the fresh device identity before starting unlock', async () => {
6332
+ test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6244
6333
  const calls: string[] = [];
6245
- const identityError = new Error('Unexpected device');
6246
6334
  const method = {
6247
6335
  name: 'evmSignMessage',
6248
- unlockPolicy: 'none',
6336
+ payload: { passphraseState: 'hidden-state' },
6249
6337
  useDevicePassphraseState: true,
6250
- run: jest.fn(),
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
+ }),
6251
6349
  };
6252
- const features = { unlocked: false };
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
+ });
6253
6367
  const device = {
6254
- features,
6255
- commands: {
6256
- typedCall: jest.fn(() => {
6257
- calls.push('status');
6258
- return Promise.resolve({ message: { unlocked: false } });
6259
- }),
6260
- },
6368
+ features: { unlocked: true, passphraseProtection: true },
6369
+ passphraseState: 'hidden-state',
6370
+ commands: { typedCall },
6261
6371
  isProtocolV2: () => true,
6262
- isBootloader: () => false,
6263
- isRomloader: () => false,
6264
- updateProtocolV2Status: jest.fn(() => features),
6265
- unlockDevice: jest.fn(),
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')),
6266
6381
  };
6267
6382
 
6268
- await expect(
6269
- runMethodWithUnlockPolicy(method as any, device as any, {
6270
- afterStatusBeforeUnlock: () => {
6271
- calls.push('identity');
6272
- throw identityError;
6273
- },
6274
- })
6275
- ).rejects.toBe(identityError);
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
+ });
6276
6395
 
6277
- expect(calls).toEqual(['status', 'identity']);
6278
- expect(device.unlockDevice).not.toHaveBeenCalled();
6279
- expect(method.run).not.toHaveBeenCalled();
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
+ };
6427
+
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();
6280
6433
  });
6281
6434
 
6282
- test('unlocks before business and never replays the callback', async () => {
6435
+ test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6283
6436
  const calls: string[] = [];
6284
- const features = { unlocked: true };
6285
6437
  const method = {
6286
6438
  name: 'evmSignMessage',
6287
- unlockPolicy: 'unlock-before-run',
6288
- protocolV2UiInteraction: { reason: 'signing-confirmation' },
6439
+ payload: { passphraseState: 'hidden-state' },
6440
+ useDevicePassphraseState: true,
6441
+ unlockPolicy: 'retry-on-locked',
6289
6442
  run: jest.fn(() => {
6290
6443
  calls.push('run');
6291
- return Promise.resolve({ signature: 'signed' });
6444
+ return Promise.resolve({ message: 'ok' });
6292
6445
  }),
6293
6446
  };
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
+ };
6294
6469
  const device = {
6295
6470
  features,
6296
- commands: {
6297
- typedCall: jest.fn(() => {
6298
- calls.push('status');
6299
- return Promise.resolve({ message: { unlocked: false } });
6300
- }),
6301
- },
6471
+ passphraseState: 'hidden-state',
6472
+ commands: { typedCall },
6302
6473
  isProtocolV2: () => true,
6303
- isBootloader: () => false,
6304
- isRomloader: () => false,
6305
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6306
- features.unlocked = status.unlocked === true;
6307
- return features;
6308
- }),
6309
6474
  unlockDevice: jest.fn(() => {
6310
- calls.push('unlock');
6475
+ calls.push('unlock-main');
6311
6476
  features.unlocked = true;
6312
- return Promise.resolve(features);
6313
- }),
6314
- };
6315
- const uiCoordinator = {
6316
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6317
- enterUnlockInteraction: jest.fn(() => {
6318
- calls.push('unlock-prompt');
6319
- return undefined;
6477
+ features.unlockedAttachPin = false;
6478
+ return Promise.resolve();
6320
6479
  }),
6480
+ getCurrentPassphraseProtection: () => true,
6481
+ getInternalState: () => 'hidden-session',
6482
+ clearInternalState: jest.fn(),
6483
+ getCurrentDeviceId: () => 'wallet-device-id',
6484
+ updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6321
6485
  };
6322
6486
 
6323
- await expect(
6324
- runMethodWithUnlockPolicy(method as any, device as any, {
6325
- uiCoordinator: uiCoordinator as any,
6326
- prepare: () => {
6327
- calls.push('wallet-session');
6328
- return Promise.resolve();
6329
- },
6330
- })
6331
- ).resolves.toEqual({ signature: 'signed' });
6332
-
6487
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6488
+ message: 'ok',
6489
+ });
6490
+ expect(device.unlockDevice).toHaveBeenCalledWith();
6333
6491
  expect(calls).toEqual([
6334
- 'status',
6335
- 'unlock-prompt',
6336
- 'unlock',
6337
- 'wallet-session',
6338
- 'method-prompt',
6492
+ 'unlock-main',
6493
+ 'negotiate-session',
6494
+ 'resume-hidden-session',
6495
+ 'validate-hidden-session',
6339
6496
  'run',
6340
6497
  ]);
6341
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6342
- expect(method.run).toHaveBeenCalledTimes(1);
6343
6498
  });
6344
6499
 
6345
- test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6500
+ test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6346
6501
  const method = {
6347
6502
  name: 'evmGetAddress',
6348
- unlockPolicy: 'unlock-before-run',
6349
- run: jest.fn(),
6503
+ payload: { useEmptyPassphrase: true },
6504
+ useDevicePassphraseState: true,
6505
+ unlockPolicy: 'retry-on-locked',
6506
+ run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6350
6507
  };
6351
6508
  const device = {
6352
- features: { unlocked: undefined },
6353
- commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6509
+ features: { unlocked: false },
6510
+ passphraseState: 'stale-hidden-state',
6354
6511
  isProtocolV2: () => true,
6355
- isBootloader: () => false,
6356
- isRomloader: () => false,
6357
- updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6358
- unlockDevice: jest.fn(),
6512
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6359
6513
  };
6360
6514
 
6361
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
6362
- errorCode: HardwareErrorCode.RuntimeError,
6515
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6516
+ address: 'standard-wallet-address',
6363
6517
  });
6364
- expect(device.unlockDevice).not.toHaveBeenCalled();
6365
- expect(method.run).not.toHaveBeenCalled();
6518
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6519
+ expect(method.run).toHaveBeenCalledTimes(1);
6366
6520
  });
6367
6521
 
6368
- test('returns a business DeviceLocked error without unlocking or replaying', async () => {
6369
- const error = deviceLockedError();
6522
+ test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6523
+ const calls: string[] = [];
6370
6524
  const method = {
6371
- name: 'evmSignMessage',
6525
+ name: 'deviceSettingsPageShow',
6372
6526
  unlockPolicy: 'unlock-before-run',
6373
- run: jest.fn().mockRejectedValue(error),
6527
+ protocolV2UiInteraction: { reason: 'settings-page' },
6528
+ run: jest.fn(() => {
6529
+ calls.push('run');
6530
+ return Promise.resolve({ message: 'ok' });
6531
+ }),
6374
6532
  };
6375
6533
  const features = { unlocked: true };
6376
6534
  const device = {
6377
6535
  features,
6378
- commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6536
+ commands: {
6537
+ typedCall: jest.fn(() => {
6538
+ calls.push('refresh-status');
6539
+ return Promise.resolve({ message: { unlocked: false } });
6540
+ }),
6541
+ },
6379
6542
  isProtocolV2: () => true,
6380
- isBootloader: () => false,
6381
- isRomloader: () => false,
6382
- updateProtocolV2Status: jest.fn(() => features),
6383
- unlockDevice: jest.fn(),
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')),
6384
6557
  };
6385
6558
 
6386
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
6387
- expect(device.unlockDevice).not.toHaveBeenCalled();
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', {});
6388
6564
  expect(method.run).toHaveBeenCalledTimes(1);
6565
+ expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6566
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6389
6567
  });
6390
6568
 
6391
- test('reuses one lightweight preflight context across nested protected methods', async () => {
6392
- const features = { unlocked: true };
6393
- const context = createProtocolV2UnlockContext();
6394
- const firstMethod = {
6395
- name: 'allNetworkGetAddress',
6396
- unlockPolicy: 'unlock-before-run',
6397
- run: jest.fn().mockResolvedValue({ root: true }),
6398
- };
6399
- const nestedMethod = {
6400
- name: 'evmGetAddress',
6569
+ test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6570
+ const features = { unlocked: false };
6571
+ const method = {
6572
+ name: 'deviceSettings',
6401
6573
  unlockPolicy: 'unlock-before-run',
6402
- run: jest.fn().mockResolvedValue({ address: '0x1' }),
6574
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6403
6575
  };
6404
6576
  const device = {
6405
6577
  features,
6406
- commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6578
+ commands: {
6579
+ typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6580
+ },
6407
6581
  isProtocolV2: () => true,
6408
- isBootloader: () => false,
6409
- isRomloader: () => false,
6410
- updateProtocolV2Status: jest.fn(() => features),
6582
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6583
+ features.unlocked = status.unlocked ?? features.unlocked;
6584
+ return features;
6585
+ }),
6411
6586
  unlockDevice: jest.fn(),
6412
6587
  };
6413
6588
 
6414
- await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
6415
- await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
6416
-
6417
- expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
6418
- expect(firstMethod.run).toHaveBeenCalledTimes(1);
6419
- expect(nestedMethod.run).toHaveBeenCalledTimes(1);
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);
6420
6594
  });
6421
6595
 
6422
6596
  test.each(['bootloader', 'romloader'] as const)(
@@ -6436,7 +6610,7 @@ describe('Protocol V2 protected method execution', () => {
6436
6610
  unlockDevice: jest.fn(),
6437
6611
  };
6438
6612
 
6439
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6613
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6440
6614
  message: 'updating',
6441
6615
  });
6442
6616
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6445,30 +6619,157 @@ describe('Protocol V2 protected method execution', () => {
6445
6619
  }
6446
6620
  );
6447
6621
 
6448
- test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
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 () => {
6449
6653
  for (const [isProtocolV2, unlockPolicy] of [
6450
- [false, 'unlock-before-run'],
6654
+ [false, 'retry-on-locked'],
6451
6655
  [true, 'none'],
6452
6656
  ] as const) {
6657
+ const error = deviceLockedError();
6453
6658
  const method = {
6454
6659
  unlockPolicy,
6455
- useDevicePassphraseState: false,
6456
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6660
+ run: jest.fn().mockRejectedValue(error),
6457
6661
  };
6458
6662
  const device = {
6459
- commands: { typedCall: jest.fn() },
6460
6663
  isProtocolV2: () => isProtocolV2,
6461
6664
  unlockDevice: jest.fn(),
6462
6665
  };
6463
6666
 
6464
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6465
- message: 'ok',
6466
- });
6467
- expect(device.commands.typedCall).not.toHaveBeenCalled();
6667
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6468
6668
  expect(device.unlockDevice).not.toHaveBeenCalled();
6469
6669
  expect(method.run).toHaveBeenCalledTimes(1);
6470
6670
  }
6471
6671
  });
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
+ });
6472
6773
  });
6473
6774
 
6474
6775
  describe('Protocol V2 current low-level methods', () => {
@@ -6600,9 +6901,7 @@ describe('Protocol V2 current low-level methods', () => {
6600
6901
 
6601
6902
  await method.run();
6602
6903
 
6603
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
6604
- eventless_wallet_session: true,
6605
- });
6904
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
6606
6905
  });
6607
6906
 
6608
6907
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {