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

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 (202) 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 +272 -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 +366 -31
  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 +91 -0
  14. package/__tests__/protocol-v2-unlock-policy.test.ts +196 -35
  15. package/__tests__/protocol-v2.test.ts +568 -957
  16. package/__tests__/protocolV2UiInteraction.test.ts +63 -0
  17. package/__tests__/public-pro2-api-boundary.test.ts +1 -0
  18. package/__tests__/uiProgressThrottle.test.ts +74 -0
  19. package/dist/api/BaseMethod.d.ts +3 -1
  20. package/dist/api/BaseMethod.d.ts.map +1 -1
  21. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  22. package/dist/api/FirmwareUpdateV2.d.ts +0 -11
  23. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  24. package/dist/api/FirmwareUpdateV3.d.ts +3 -7
  25. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  26. package/dist/api/FirmwareUpdateV4.d.ts +6 -25
  27. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  28. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  29. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  30. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  31. package/dist/api/device/DeviceUpdateBootloader.d.ts +1 -5
  32. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  33. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +0 -8
  34. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  35. package/dist/api/firmware/getBinary.d.ts +0 -7
  36. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  37. package/dist/api/firmware/updateBootloader.d.ts +0 -2
  38. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  39. package/dist/api/firmware/uploadFirmware.d.ts +1 -9
  40. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  41. package/dist/api/helpers/protocolV2FileWrite.d.ts +2 -0
  42. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  43. package/dist/api/index.d.ts +1 -0
  44. package/dist/api/index.d.ts.map +1 -1
  45. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +31 -0
  46. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -0
  47. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  48. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  49. package/dist/core/deviceEventRegistration.d.ts +2 -0
  50. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  51. package/dist/core/index.d.ts.map +1 -1
  52. package/dist/data-manager/DataManager.d.ts +3 -2
  53. package/dist/data-manager/DataManager.d.ts.map +1 -1
  54. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  55. package/dist/device/Device.d.ts +24 -4
  56. package/dist/device/Device.d.ts.map +1 -1
  57. package/dist/device/DeviceCommands.d.ts +4 -4
  58. package/dist/events/device.d.ts +4 -0
  59. package/dist/events/device.d.ts.map +1 -1
  60. package/dist/events/logBlockEvent.d.ts.map +1 -1
  61. package/dist/events/ui-request.d.ts +28 -2
  62. package/dist/events/ui-request.d.ts.map +1 -1
  63. package/dist/index.d.ts +115 -184
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +2051 -3349
  66. package/dist/inject.d.ts.map +1 -1
  67. package/dist/lowLevelInject.d.ts.map +1 -1
  68. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  69. package/dist/protocols/protocol-v2/resources.d.ts +30 -0
  70. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -0
  71. package/dist/protocols/protocol-v2/uiInteraction.d.ts +7 -4
  72. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  73. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +1 -3
  74. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  75. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +22 -0
  76. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -0
  77. package/dist/protocols/protocol-v2/walletSession.d.ts +4 -1
  78. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  79. package/dist/topLevelInject.d.ts.map +1 -1
  80. package/dist/types/api/checkAllFirmwareRelease.d.ts +1 -4
  81. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  82. package/dist/types/api/deviceUpdateBootloader.d.ts +0 -6
  83. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  84. package/dist/types/api/export.d.ts +1 -4
  85. package/dist/types/api/export.d.ts.map +1 -1
  86. package/dist/types/api/firmwareUpdate.d.ts +0 -69
  87. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  88. package/dist/types/api/index.d.ts +3 -10
  89. package/dist/types/api/index.d.ts.map +1 -1
  90. package/dist/types/api/protocolV2.d.ts +3 -0
  91. package/dist/types/api/protocolV2.d.ts.map +1 -1
  92. package/dist/types/settings.d.ts +12 -13
  93. package/dist/types/settings.d.ts.map +1 -1
  94. package/dist/utils/deviceFeaturesUtils.d.ts +2 -0
  95. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  96. package/dist/utils/homescreen.d.ts +4 -0
  97. package/dist/utils/homescreen.d.ts.map +1 -1
  98. package/dist/utils/index.d.ts +1 -1
  99. package/dist/utils/index.d.ts.map +1 -1
  100. package/dist/utils/patch.d.ts +1 -1
  101. package/dist/utils/patch.d.ts.map +1 -1
  102. package/dist/utils/pro2Nft.d.ts +31 -0
  103. package/dist/utils/pro2Nft.d.ts.map +1 -0
  104. package/dist/utils/pro2Wallpaper.d.ts +10 -0
  105. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  106. package/dist/utils/uiProgressThrottle.d.ts +3 -0
  107. package/dist/utils/uiProgressThrottle.d.ts.map +1 -0
  108. package/package.json +4 -4
  109. package/src/api/BaseMethod.ts +8 -10
  110. package/src/api/CheckAllFirmwareRelease.ts +40 -53
  111. package/src/api/FirmwareUpdateV2.ts +120 -298
  112. package/src/api/FirmwareUpdateV3.ts +57 -263
  113. package/src/api/FirmwareUpdateV4.ts +307 -912
  114. package/src/api/GetPassphraseState.ts +7 -1
  115. package/src/api/OpenWalletSession.ts +54 -17
  116. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +32 -11
  117. package/src/api/device/DeviceUnlock.ts +1 -1
  118. package/src/api/device/DeviceUpdateBootloader.ts +6 -122
  119. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +0 -57
  120. package/src/api/firmware/updateBootloader.ts +4 -19
  121. package/src/api/firmware/uploadFirmware.ts +22 -140
  122. package/src/api/helpers/protocolV2FileWrite.ts +11 -4
  123. package/src/api/index.ts +1 -0
  124. package/src/api/protocol-v2/DeviceUploadNft.ts +189 -0
  125. package/src/api/protocol-v2/ProtocolInfoRequest.ts +3 -1
  126. package/src/core/deviceEventRegistration.ts +4 -0
  127. package/src/core/index.ts +136 -77
  128. package/src/data/messages/messages-protocol-v2.json +68 -0
  129. package/src/data-manager/DataManager.ts +63 -38
  130. package/src/data-manager/connectSettings.ts +0 -11
  131. package/src/device/Device.ts +203 -21
  132. package/src/events/device.ts +4 -0
  133. package/src/events/logBlockEvent.ts +1 -0
  134. package/src/events/ui-request.ts +33 -2
  135. package/src/index.ts +0 -5
  136. package/src/inject.ts +3 -22
  137. package/src/lowLevelInject.ts +1 -5
  138. package/src/protocols/protocol-v2/features.ts +9 -2
  139. package/src/protocols/protocol-v2/resources.ts +233 -0
  140. package/src/protocols/protocol-v2/uiInteraction.ts +46 -6
  141. package/src/protocols/protocol-v2/unlockPolicy.ts +1 -105
  142. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +124 -0
  143. package/src/protocols/protocol-v2/walletSession.ts +154 -30
  144. package/src/topLevelInject.ts +1 -5
  145. package/src/types/api/checkAllFirmwareRelease.ts +1 -4
  146. package/src/types/api/deviceUpdateBootloader.ts +0 -6
  147. package/src/types/api/export.ts +0 -18
  148. package/src/types/api/firmwareUpdate.ts +0 -76
  149. package/src/types/api/index.ts +2 -14
  150. package/src/types/api/protocolV2.ts +13 -0
  151. package/src/types/settings.ts +26 -13
  152. package/src/utils/deviceFeaturesUtils.ts +4 -0
  153. package/src/utils/homescreen.ts +32 -6
  154. package/src/utils/index.ts +6 -1
  155. package/src/utils/pro2Nft.ts +142 -0
  156. package/src/utils/pro2Wallpaper.ts +35 -8
  157. package/src/utils/uiProgressThrottle.ts +63 -0
  158. package/__tests__/firmware-update/check-all-firmware-release.test.ts +0 -175
  159. package/__tests__/firmware-update/firmware-artifact-source.test.ts +0 -119
  160. package/__tests__/firmware-update/firmware-host-binding.test.ts +0 -70
  161. package/__tests__/firmware-update/firmware-preparation-error.test.ts +0 -27
  162. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +0 -13
  163. package/__tests__/firmware-update/firmware-update-plan.test.ts +0 -593
  164. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +0 -213
  165. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +0 -58
  166. package/__tests__/firmware-update/firmware-upload-source.test.ts +0 -70
  167. package/dist/api/firmware/FirmwareArtifactSource.d.ts +0 -25
  168. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +0 -1
  169. package/dist/api/firmware/FirmwareHostBinding.d.ts +0 -6
  170. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +0 -1
  171. package/dist/api/firmware/FirmwarePreparationError.d.ts +0 -3
  172. package/dist/api/firmware/FirmwarePreparationError.d.ts.map +0 -1
  173. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +0 -3
  174. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +0 -1
  175. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +0 -25
  176. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +0 -1
  177. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +0 -30
  178. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +0 -1
  179. package/dist/api/firmware/progressThrottle.d.ts +0 -3
  180. package/dist/api/firmware/progressThrottle.d.ts.map +0 -1
  181. package/dist/protocols/protocol-v2/lockedError.d.ts +0 -2
  182. package/dist/protocols/protocol-v2/lockedError.d.ts.map +0 -1
  183. package/dist/protocols/protocol-v2/unlockRetry.d.ts +0 -10
  184. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +0 -1
  185. package/dist/types/api/firmwareUpdateCapabilities.d.ts +0 -9
  186. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +0 -1
  187. package/dist/types/api/firmwareUpdatePlan.d.ts +0 -28
  188. package/dist/types/api/firmwareUpdatePlan.d.ts.map +0 -1
  189. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +0 -42
  190. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +0 -1
  191. package/src/api/firmware/FirmwareArtifactSource.ts +0 -278
  192. package/src/api/firmware/FirmwareHostBinding.ts +0 -100
  193. package/src/api/firmware/FirmwarePreparationError.ts +0 -12
  194. package/src/api/firmware/FirmwareUpdateCapabilities.ts +0 -9
  195. package/src/api/firmware/FirmwareUpdatePlan.ts +0 -745
  196. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +0 -392
  197. package/src/api/firmware/progressThrottle.ts +0 -44
  198. package/src/protocols/protocol-v2/lockedError.ts +0 -10
  199. package/src/protocols/protocol-v2/unlockRetry.ts +0 -109
  200. package/src/types/api/firmwareUpdateCapabilities.ts +0 -9
  201. package/src/types/api/firmwareUpdatePlan.ts +0 -38
  202. 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', {
@@ -814,7 +819,9 @@ describe('Protocol V2 feature adapter', () => {
814
819
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
815
820
  type: DeviceSessionPinType.Main,
816
821
  });
817
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
822
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
823
+ seed_domains: [],
824
+ });
818
825
  device.passphraseState = 'state-1';
819
826
  expect(device.getInternalState()).toBe('session-1');
820
827
  });
@@ -863,9 +870,13 @@ describe('Protocol V2 feature adapter', () => {
863
870
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
864
871
 
865
872
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
866
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
873
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
874
+ seed_domains: [],
875
+ });
867
876
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
868
877
  session_id: 'standard-session',
878
+ btc_test_address: 'standard-state',
879
+ seed_domains: [],
869
880
  });
870
881
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
871
882
  passphraseState: 'standard-state',
@@ -1227,7 +1238,9 @@ describe('Protocol V2 feature adapter', () => {
1227
1238
  passphrase: 'host hidden wallet',
1228
1239
  on_device: false,
1229
1240
  });
1230
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1241
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1242
+ seed_domains: [],
1243
+ });
1231
1244
  });
1232
1245
 
1233
1246
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1335,6 +1348,8 @@ describe('Protocol V2 feature adapter', () => {
1335
1348
 
1336
1349
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1337
1350
  session_id: 'session-a',
1351
+ btc_test_address: 'state-a',
1352
+ seed_domains: [],
1338
1353
  });
1339
1354
  expect(device.getInternalState()).toBe('session-b');
1340
1355
  });
@@ -1402,7 +1417,15 @@ describe('Protocol V2 feature adapter', () => {
1402
1417
  });
1403
1418
  expect(typedCall.mock.calls).toEqual([
1404
1419
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1405
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1420
+ [
1421
+ 'DeviceSessionGet',
1422
+ 'DeviceSession',
1423
+ {
1424
+ session_id: 'session-a',
1425
+ btc_test_address: 'state-a',
1426
+ seed_domains: [],
1427
+ },
1428
+ ],
1406
1429
  ]);
1407
1430
  expect(promptPassphrase).not.toHaveBeenCalled();
1408
1431
  expect(device.getInternalState()).toBeUndefined();
@@ -1450,7 +1473,7 @@ describe('Protocol V2 feature adapter', () => {
1450
1473
  { passphrase: 'host hidden wallet', on_device: false },
1451
1474
  ],
1452
1475
  ['DeviceStatusGet', 'DeviceStatus', {}],
1453
- ['DeviceSessionGet', 'DeviceSession', {}],
1476
+ ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1454
1477
  ]);
1455
1478
  });
1456
1479
 
@@ -1553,6 +1576,73 @@ describe('Protocol V2 feature adapter', () => {
1553
1576
  expect(device.getInternalState()).toBeUndefined();
1554
1577
  });
1555
1578
 
1579
+ test.each([false, true])(
1580
+ 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1581
+ async lockFails => {
1582
+ const deviceId = 'attach-mismatch-device';
1583
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1584
+ (device as any).features = normalizeProtocolV2Features(
1585
+ { ...descriptor, protocolType: 'V2' } as any,
1586
+ {
1587
+ status: {
1588
+ device_id: deviceId,
1589
+ unlocked: true,
1590
+ passphrase_enabled: true,
1591
+ attach_to_pin_enabled: true,
1592
+ unlocked_by_attach_to_pin: false,
1593
+ },
1594
+ }
1595
+ );
1596
+ device.passphraseState = 'expected-state';
1597
+
1598
+ const typedCall = jest.fn((request: string) => {
1599
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1600
+ if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1601
+ if (request === 'DeviceStatusGet') {
1602
+ return {
1603
+ message: {
1604
+ device_id: deviceId,
1605
+ unlocked: true,
1606
+ passphrase_enabled: true,
1607
+ attach_to_pin_enabled: true,
1608
+ unlocked_by_attach_to_pin: true,
1609
+ },
1610
+ };
1611
+ }
1612
+ if (request === 'DeviceSessionGet') {
1613
+ return {
1614
+ message: {
1615
+ btc_test_address: 'wrong-attach-state',
1616
+ session_id: 'wrong-attach-session',
1617
+ },
1618
+ };
1619
+ }
1620
+ if (request === 'LockDevice') {
1621
+ if (lockFails) throw new Error('Lock unsupported');
1622
+ return { message: { message: 'Device locked' } };
1623
+ }
1624
+ throw new Error(`Unexpected request: ${request}`);
1625
+ });
1626
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1627
+ (device as any).commands = { typedCall, promptPassphrase };
1628
+
1629
+ await expect(
1630
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1631
+ ).rejects.toMatchObject({
1632
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1633
+ });
1634
+
1635
+ expect(promptPassphrase).not.toHaveBeenCalled();
1636
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1637
+ btc_test_address: 'expected-state',
1638
+ seed_domains: [],
1639
+ });
1640
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1641
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1642
+ expect(device.getInternalState()).toBeUndefined();
1643
+ }
1644
+ );
1645
+
1556
1646
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1557
1647
  const device = Device.fromDescriptor({
1558
1648
  id: 'cache-device-attach-recovery',
@@ -1609,7 +1699,17 @@ describe('Protocol V2 feature adapter', () => {
1609
1699
  }),
1610
1700
  { cancelDeviceOnReject: false }
1611
1701
  );
1612
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1702
+ expect(unlockDevice).toHaveBeenCalledWith(
1703
+ DeviceSessionPinType.AttachToPin,
1704
+ expect.objectContaining({
1705
+ emitUiEvent: false,
1706
+ interaction: expect.objectContaining({
1707
+ phase: 'pin',
1708
+ transition: 'start',
1709
+ protocol: 'V2',
1710
+ }),
1711
+ })
1712
+ );
1613
1713
  expect(device.getInternalState()).toBe('new-attach-session');
1614
1714
  });
1615
1715
 
@@ -1893,12 +1993,10 @@ describe('Protocol V2 feature adapter', () => {
1893
1993
  expect(getFeatures).not.toHaveBeenCalled();
1894
1994
  });
1895
1995
 
1896
- test('marks an interactive cache miss as a wallet-session recovery', async () => {
1996
+ test('uses the cached wallet address when the local session is missing', async () => {
1897
1997
  const typedCall = jest
1898
1998
  .fn()
1899
1999
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
- .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
- .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1902
2000
  .mockResolvedValueOnce({
1903
2001
  message: {
1904
2002
  btc_test_address: 'expected-state',
@@ -1926,16 +2024,11 @@ describe('Protocol V2 feature adapter', () => {
1926
2024
  await expect(
1927
2025
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1928
2026
  ).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
- );
2027
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2028
+ btc_test_address: 'expected-state',
2029
+ seed_domains: [],
2030
+ });
2031
+ expect(promptPassphrase).not.toHaveBeenCalled();
1939
2032
  });
1940
2033
 
1941
2034
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2001,8 +2094,15 @@ describe('Protocol V2 feature adapter', () => {
2001
2094
  true
2002
2095
  );
2003
2096
  expect(typedCall.mock.calls).toEqual([
2004
- ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2005
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2097
+ [
2098
+ 'DeviceSessionGet',
2099
+ 'DeviceSession',
2100
+ {
2101
+ session_id: 'session-pro2-app',
2102
+ btc_test_address: 'state-pro2-app',
2103
+ seed_domains: [],
2104
+ },
2105
+ ],
2006
2106
  ]);
2007
2107
  });
2008
2108
 
@@ -2191,7 +2291,9 @@ describe('Protocol V2 feature adapter', () => {
2191
2291
  passphrase: 'host hidden wallet',
2192
2292
  on_device: false,
2193
2293
  });
2194
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2294
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2295
+ seed_domains: [],
2296
+ });
2195
2297
  });
2196
2298
 
2197
2299
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2269,12 +2371,18 @@ describe('Protocol V2 feature adapter', () => {
2269
2371
  );
2270
2372
 
2271
2373
  expect(device.getInternalState()).toBeUndefined();
2272
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2374
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2375
+ btc_test_address: 'expected-state',
2376
+ seed_domains: [],
2377
+ });
2378
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2273
2379
  passphrase: 'host hidden wallet',
2274
2380
  on_device: false,
2275
2381
  });
2276
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
- expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2382
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2383
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2384
+ seed_domains: [],
2385
+ });
2278
2386
  });
2279
2387
 
2280
2388
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2307,10 +2415,14 @@ describe('Protocol V2 feature adapter', () => {
2307
2415
  await expect(
2308
2416
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
2309
2417
  ).resolves.toBe(true);
2310
- expect(typedCall).toHaveBeenCalledTimes(4);
2418
+ expect(typedCall).toHaveBeenCalledTimes(2);
2311
2419
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2312
2420
  eventless_wallet_session: true,
2313
2421
  });
2422
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2423
+ seed_domains: [],
2424
+ });
2425
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2314
2426
  });
2315
2427
 
2316
2428
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2681,7 +2793,9 @@ describe('Protocol V2 feature adapter', () => {
2681
2793
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2682
2794
  }
2683
2795
  );
2684
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2796
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2797
+ eventless_wallet_session: true,
2798
+ });
2685
2799
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2686
2800
  });
2687
2801
 
@@ -2719,7 +2833,9 @@ describe('Protocol V2 feature adapter', () => {
2719
2833
  }),
2720
2834
  expect.anything()
2721
2835
  );
2722
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2836
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2837
+ eventless_wallet_session: true,
2838
+ });
2723
2839
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2724
2840
  expect(device.features).toMatchObject({
2725
2841
  deviceId: null,
@@ -2985,10 +3101,6 @@ describe('Protocol V2 feature adapter', () => {
2985
3101
  fw: { application: { version: '1.2.4' } },
2986
3102
  },
2987
3103
  })
2988
- .mockResolvedValueOnce({
2989
- type: 'ProtocolInfo',
2990
- message: protocolV2ApplicationInfo,
2991
- })
2992
3104
  .mockResolvedValueOnce({
2993
3105
  type: 'DeviceStatus',
2994
3106
  message: { init_states: true, unlocked: true },
@@ -3010,7 +3122,7 @@ describe('Protocol V2 feature adapter', () => {
3010
3122
  firmwareVersion: '1.2.4',
3011
3123
  passphraseProtection: null,
3012
3124
  });
3013
- expect(typedCall).toHaveBeenCalledTimes(6);
3125
+ expect(typedCall).toHaveBeenCalledTimes(5);
3014
3126
  expect(typedCall).toHaveBeenNthCalledWith(
3015
3127
  4,
3016
3128
  'DeviceInfoGet',
@@ -3124,9 +3236,23 @@ describe('Protocol V2 feature adapter', () => {
3124
3236
  status: { init_states: true },
3125
3237
  }
3126
3238
  );
3239
+ const pinOnDevice = jest.fn();
3240
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3127
3241
 
3128
3242
  const features = await device.unlockDevice();
3129
3243
 
3244
+ expect(pinOnDevice).toHaveBeenCalledWith(
3245
+ device,
3246
+ DeviceSessionPinType.Main,
3247
+ expect.objectContaining({
3248
+ source: 'unlock-coordinator',
3249
+ reason: 'device-unlock',
3250
+ deviceOnly: true,
3251
+ })
3252
+ );
3253
+ expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3254
+ typedCall.mock.invocationCallOrder[0]
3255
+ );
3130
3256
  expect(typedCall.mock.calls).toEqual([
3131
3257
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3132
3258
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3172,9 +3298,12 @@ describe('Protocol V2 feature adapter', () => {
3172
3298
  throw new Error(`Unexpected request: ${requestType}`);
3173
3299
  });
3174
3300
  (device as any).commands = { typedCall };
3301
+ const pinOnDevice = jest.fn();
3302
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3175
3303
 
3176
- await device.unlockDevice();
3304
+ await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3177
3305
 
3306
+ expect(pinOnDevice).not.toHaveBeenCalled();
3178
3307
  expect(typedCall.mock.calls).toEqual([
3179
3308
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3180
3309
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3577,6 +3706,15 @@ describe('API compatibility handling', () => {
3577
3706
 
3578
3707
  describe('Protocol V2 firmware update targets', () => {
3579
3708
  const OKPP_HEADER_SIZE = 0x52a0;
3709
+ let forceReloadDataSpy: jest.SpyInstance<Promise<void>, []>;
3710
+
3711
+ beforeEach(() => {
3712
+ forceReloadDataSpy = jest.spyOn(DataManager, 'forceReloadData').mockResolvedValue(undefined);
3713
+ });
3714
+
3715
+ afterEach(() => {
3716
+ jest.restoreAllMocks();
3717
+ });
3580
3718
 
3581
3719
  const buildOkppHeader = ({
3582
3720
  type = 'RESC',
@@ -3717,7 +3855,7 @@ describe('Protocol V2 firmware update targets', () => {
3717
3855
  });
3718
3856
  });
3719
3857
 
3720
- test('delegates Protocol V2 mode transition to the phase executor', async () => {
3858
+ test('enters Protocol V2 bootloader before upload', async () => {
3721
3859
  const method = new FirmwareUpdateV4({
3722
3860
  id: 1,
3723
3861
  payload: {
@@ -3752,7 +3890,7 @@ describe('Protocol V2 firmware update targets', () => {
3752
3890
 
3753
3891
  await method.run();
3754
3892
 
3755
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3893
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
3756
3894
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3757
3895
  fwBinaryMap: [
3758
3896
  {
@@ -3866,6 +4004,9 @@ describe('Protocol V2 firmware update targets', () => {
3866
4004
  },
3867
4005
  });
3868
4006
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4007
+ const prepareResourceBundles = jest
4008
+ .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4009
+ .mockResolvedValue(undefined);
3869
4010
  (method as any).protocolV2Reboot = jest.fn();
3870
4011
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3871
4012
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -3878,6 +4019,7 @@ describe('Protocol V2 firmware update targets', () => {
3878
4019
 
3879
4020
  await method.run();
3880
4021
 
4022
+ expect(prepareResourceBundles).toHaveBeenCalledWith(true);
3881
4023
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3882
4024
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3883
4025
  bootloaderBinary: null,
@@ -4135,9 +4277,9 @@ describe('Protocol V2 firmware update targets', () => {
4135
4277
  const typedCall = jest
4136
4278
  .fn()
4137
4279
  .mockRejectedValue(
4138
- Object.assign(
4139
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4140
- { name: 'NotFoundError' }
4280
+ new DOMException(
4281
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4282
+ 'NotFoundError'
4141
4283
  )
4142
4284
  );
4143
4285
 
@@ -4318,77 +4460,6 @@ describe('Protocol V2 firmware update targets', () => {
4318
4460
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4319
4461
  });
4320
4462
 
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
4463
  test('polls target status after update ACK and finishes when all targets complete', async () => {
4393
4464
  const method = new FirmwareUpdateV4({
4394
4465
  id: 1,
@@ -4465,7 +4536,7 @@ describe('Protocol V2 firmware update targets', () => {
4465
4536
  ).toBe(true);
4466
4537
 
4467
4538
  (method.postProgressMessage as jest.Mock).mockClear();
4468
- expect(() =>
4539
+ expect(
4469
4540
  (method as any).assertProtocolV2TargetStatus(
4470
4541
  [
4471
4542
  { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
@@ -4473,8 +4544,8 @@ describe('Protocol V2 firmware update targets', () => {
4473
4544
  ],
4474
4545
  new Set([4, 10])
4475
4546
  )
4476
- ).toThrow('Protocol V2 install status conflicts with target 4');
4477
- expect(method.postProgressMessage).not.toHaveBeenCalled();
4547
+ ).toBe(false);
4548
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4478
4549
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
4479
4550
 
4480
4551
  expect(
@@ -4578,14 +4649,10 @@ describe('Protocol V2 firmware update targets', () => {
4578
4649
  const writtenPaths: string[] = [];
4579
4650
  method.postTipMessage = jest.fn();
4580
4651
  method.postProgressMessage = jest.fn();
4581
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
4652
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
4582
4653
  writtenPaths.push(params.filePath);
4583
- return Number(params.processedSize ?? 0) + Number(params.source.size);
4654
+ return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
4584
4655
  });
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
4656
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4590
4657
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4591
4658
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4625,12 +4692,10 @@ describe('Protocol V2 firmware update targets', () => {
4625
4692
  'vol0:/coprocessor.bin',
4626
4693
  1
4627
4694
  );
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, {
4695
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
4696
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
4633
4697
  targets: [
4698
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
4634
4699
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
4635
4700
  { target_id: 7, path: 'vol0:/se01.bin' },
4636
4701
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -4640,7 +4705,7 @@ describe('Protocol V2 firmware update targets', () => {
4640
4705
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
4641
4706
  });
4642
4707
 
4643
- test('announces separate resource and component transfer phases', async () => {
4708
+ test('announces one transfer when syncing resource bundles and staging firmware', async () => {
4644
4709
  const method = new FirmwareUpdateV4({
4645
4710
  id: 1,
4646
4711
  payload: {
@@ -4650,14 +4715,11 @@ describe('Protocol V2 firmware update targets', () => {
4650
4715
 
4651
4716
  method.postTipMessage = jest.fn();
4652
4717
  method.postProgressMessage = jest.fn();
4653
- (method as any).protocolV2SourceUpdateProcess = jest
4718
+ (method as any).protocolV2CommonUpdateProcess = jest
4654
4719
  .fn()
4655
4720
  .mockImplementation(params =>
4656
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
4721
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4657
4722
  );
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
4723
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4662
4724
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4663
4725
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4677,22 +4739,21 @@ describe('Protocol V2 firmware update targets', () => {
4677
4739
  {
4678
4740
  fileName: 'application_p1.bin',
4679
4741
  binary: new Uint8Array([3]).buffer,
4680
- targetId: 4,
4742
+ targetId: 3,
4681
4743
  },
4682
4744
  ],
4683
4745
  });
4684
4746
 
4685
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
4747
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
4686
4748
  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(
4749
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
4750
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4690
4751
  1,
4691
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
4752
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
4692
4753
  );
4693
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
4754
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4694
4755
  2,
4695
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
4756
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
4696
4757
  );
4697
4758
  });
4698
4759
 
@@ -4706,8 +4767,7 @@ describe('Protocol V2 firmware update targets', () => {
4706
4767
 
4707
4768
  method.postTipMessage = jest.fn();
4708
4769
  method.postProgressMessage = jest.fn();
4709
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4710
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4770
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4711
4771
  (method as any).device = stubDevice({
4712
4772
  getCommands: () => ({
4713
4773
  typedCall: jest.fn().mockResolvedValue({
@@ -4753,14 +4813,11 @@ describe('Protocol V2 firmware update targets', () => {
4753
4813
 
4754
4814
  method.postTipMessage = jest.fn();
4755
4815
  method.postProgressMessage = jest.fn();
4756
- (method as any).protocolV2SourceUpdateProcess = jest
4816
+ (method as any).protocolV2CommonUpdateProcess = jest
4757
4817
  .fn()
4758
4818
  .mockImplementation(params =>
4759
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
4819
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4760
4820
  );
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
4821
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4765
4822
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4766
4823
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4975,33 +5032,42 @@ describe('Protocol V2 firmware update targets', () => {
4975
5032
  getFirmwareLatestReleaseSpy.mockRestore();
4976
5033
  });
4977
5034
 
4978
- test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
5035
+ test('does not download Pro2 firmware components that App did not select', async () => {
4979
5036
  const method = new FirmwareUpdateV4({
4980
5037
  id: 1,
4981
5038
  payload: {
4982
5039
  method: 'firmwareUpdateV4',
4983
5040
  platform: 'web',
4984
- targetsToUpdate: ['resource'],
4985
5041
  },
4986
5042
  });
4987
5043
  method.init();
4988
5044
 
5045
+ const getSysResourceBinarySpy = jest
5046
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5047
+ .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
4989
5048
  const getFirmwareLatestReleaseSpy = jest
4990
5049
  .spyOn(DataManager, 'getFirmwareLatestRelease')
4991
5050
  .mockReturnValue({
4992
5051
  required: false,
4993
5052
  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),
5053
+ url: 'https://example.com/applicationP1.pp.bin',
5054
+ bootloaderVersion: [1, 0, 0],
5055
+ upgradeType: 'payload-package-set',
5056
+ installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5057
+ components: {
5058
+ bootloader: {
5059
+ target: 'BOOTLOADER',
5060
+ url: 'https://example.com/bootloader.pp.bin',
5003
5061
  },
5004
- ],
5062
+ applicationP1: {
5063
+ target: 'APPLICATION_P1',
5064
+ url: 'https://example.com/applicationP1.pp.bin',
5065
+ },
5066
+ applicationP2: {
5067
+ target: 'APPLICATION_P2',
5068
+ url: 'https://example.com/applicationP2.pp.bin',
5069
+ },
5070
+ },
5005
5071
  fingerprint: '',
5006
5072
  changelog: {
5007
5073
  'zh-CN': '',
@@ -5009,399 +5075,94 @@ describe('Protocol V2 firmware update targets', () => {
5009
5075
  },
5010
5076
  });
5011
5077
 
5012
- const bundles = (method as any).prepareProtocolV2ResourceBundles('universal', {
5078
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5013
5079
  deviceType: 'pro2',
5014
5080
  firmwareVersion: '1.0.0',
5081
+ bootloaderVersion: '1.0.0',
5015
5082
  capabilities: [],
5016
5083
  });
5017
5084
 
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);
5085
+ expect(remoteBinaries).toEqual({
5086
+ bootloaderBinary: null,
5087
+ fwBinaryMap: [],
5088
+ installItems: [],
5089
+ });
5090
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5030
5091
 
5092
+ getSysResourceBinarySpy.mockRestore();
5031
5093
  getFirmwareLatestReleaseSpy.mockRestore();
5032
5094
  });
5033
5095
 
5034
- test('rejects remote RESC bundle paths before download or bootloader entry', async () => {
5096
+ test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5035
5097
  const method = new FirmwareUpdateV4({
5036
5098
  id: 1,
5037
5099
  payload: {
5038
5100
  method: 'firmwareUpdateV4',
5039
5101
  platform: 'web',
5040
- targetsToUpdate: ['resource'],
5041
5102
  },
5042
5103
  });
5043
5104
  method.init();
5044
5105
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5045
5106
 
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': '',
5107
+ const remoteBinaries = {
5108
+ bootloaderBinary: new Uint8Array([1]).buffer,
5109
+ fwBinaryMap: [
5110
+ {
5111
+ fileName: 'application_p1.bin',
5112
+ binary: new Uint8Array([2]).buffer,
5113
+ targetId: 3,
5064
5114
  },
5065
- });
5115
+ ],
5116
+ };
5066
5117
 
5067
5118
  (method as any).device = stubDevice({
5068
5119
  originalDescriptor: { protocolType: 'V2' },
5069
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5120
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5070
5121
  });
5071
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5072
- bootloaderBinary: null,
5073
- fwBinaryMap: [],
5122
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5123
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5124
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5125
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5126
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5127
+ bootloaderVersion: '1.0.0',
5128
+ bleVersion: '0.0.0',
5129
+ firmwareVersion: '1.0.0',
5074
5130
  });
5075
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
5076
5131
  method.postTipMessage = jest.fn();
5077
5132
 
5078
- await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
5079
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5080
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5133
+ await method.run();
5081
5134
 
5082
- getSysResourceBinarySpy.mockRestore();
5083
- getFirmwareLatestReleaseSpy.mockRestore();
5135
+ expect(forceReloadDataSpy).toHaveBeenCalledTimes(1);
5136
+ expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5137
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5138
+ bootloaderBinary: remoteBinaries.bootloaderBinary,
5139
+ fwBinaryMap: remoteBinaries.fwBinaryMap,
5140
+ });
5084
5141
  });
5085
5142
 
5086
- test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
5143
+ test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
5087
5144
  const method = new FirmwareUpdateV4({
5088
5145
  id: 1,
5089
5146
  payload: {
5090
5147
  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
-
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();
5292
-
5293
- await expect(method.run()).rejects.toThrow('Protocol V2 RESC bundle is empty: images');
5294
- 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',
5148
+ platform: 'web',
5367
5149
  },
5368
5150
  });
5369
5151
  method.init();
5370
5152
  (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
5153
  (method as any).device = stubDevice({
5384
5154
  originalDescriptor: { protocolType: 'V2' },
5385
5155
  features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5386
5156
  });
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
- });
5157
+ forceReloadDataSpy.mockRejectedValue(new Error('latest config unavailable'));
5158
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5159
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5396
5160
  method.postTipMessage = jest.fn();
5397
5161
 
5398
- await method.run();
5162
+ await expect(method.run()).rejects.toThrow('latest config unavailable');
5399
5163
 
5400
- expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5401
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5402
- bootloaderBinary: remoteBinaries.bootloaderBinary,
5403
- fwBinaryMap: remoteBinaries.fwBinaryMap,
5404
- });
5164
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5165
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5405
5166
  });
5406
5167
 
5407
5168
  test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
@@ -5529,10 +5290,7 @@ describe('Protocol V2 firmware update targets', () => {
5529
5290
 
5530
5291
  method.postTipMessage = jest.fn();
5531
5292
  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);
5293
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
5536
5294
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
5537
5295
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
5538
5296
 
@@ -5548,7 +5306,7 @@ describe('Protocol V2 firmware update targets', () => {
5548
5306
  fwBinaryMap: [],
5549
5307
  });
5550
5308
 
5551
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
5309
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
5552
5310
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
5553
5311
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
5554
5312
  });
@@ -5579,19 +5337,12 @@ describe('Protocol V2 firmware update targets', () => {
5579
5337
  });
5580
5338
  method.postProgressMessage = jest.fn();
5581
5339
 
5582
- const source = await openFirmwareByteSource({
5583
- binary: new Uint8Array(4097).buffer,
5340
+ await (method as any).protocolV2CommonUpdateProcess({
5341
+ payload: new Uint8Array(4097).buffer,
5342
+ filePath: 'vol1:firmware.bin',
5343
+ processedSize: 0,
5344
+ totalSize: 4097,
5584
5345
  });
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
5346
 
5596
5347
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5597
5348
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -5648,16 +5399,12 @@ describe('Protocol V2 firmware update targets', () => {
5648
5399
  method.postProgressMessage = jest.fn();
5649
5400
 
5650
5401
  try {
5651
- const source = await openFirmwareByteSource({
5652
- binary: new Uint8Array(8001).buffer,
5653
- });
5654
- await (method as any).protocolV2SourceUpdateProcess({
5655
- source,
5402
+ await (method as any).protocolV2CommonUpdateProcess({
5403
+ payload: new Uint8Array(8001).buffer,
5656
5404
  filePath: 'vol0:/firmware.bin',
5657
5405
  processedSize: 0,
5658
5406
  totalSize: 8001,
5659
5407
  });
5660
- await source?.close();
5661
5408
  } finally {
5662
5409
  setTimeoutSpy.mockRestore();
5663
5410
  }
@@ -5692,21 +5439,16 @@ describe('Protocol V2 firmware update targets', () => {
5692
5439
  });
5693
5440
  method.postProgressMessage = jest.fn();
5694
5441
  method.postTipMessage = jest.fn();
5695
- (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
5696
5442
 
5697
- const source = await openFirmwareByteSource({
5698
- binary: new Uint8Array(4097).buffer,
5699
- });
5700
5443
  await expect(
5701
- (method as any).protocolV2SourceUpdateProcess({
5702
- source,
5444
+ (method as any).protocolV2WriteWholeFile({
5445
+ payload: new Uint8Array(4097).buffer,
5703
5446
  filePath: 'vol0:/firmware.bin',
5704
5447
  processedSize: 0,
5705
5448
  totalSize: 4097,
5706
5449
  })
5707
5450
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
5708
- await source?.close();
5709
- expect(typedCall).toHaveBeenCalledTimes(6);
5451
+ expect(typedCall).toHaveBeenCalledTimes(2);
5710
5452
  });
5711
5453
 
5712
5454
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -5739,19 +5481,12 @@ describe('Protocol V2 firmware update targets', () => {
5739
5481
  });
5740
5482
  method.postProgressMessage = jest.fn();
5741
5483
 
5742
- const source = await openFirmwareByteSource({
5743
- binary: new Uint8Array(1801).buffer,
5484
+ await (method as any).protocolV2CommonUpdateProcess({
5485
+ payload: new Uint8Array(1801).buffer,
5486
+ filePath: 'vol1:ble-firmware.bin',
5487
+ processedSize: 0,
5488
+ totalSize: 1801,
5744
5489
  });
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
5490
 
5756
5491
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5757
5492
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
@@ -5868,9 +5603,9 @@ describe('Protocol V2 firmware update method', () => {
5868
5603
  const typedCall = jest
5869
5604
  .fn()
5870
5605
  .mockRejectedValue(
5871
- Object.assign(
5872
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
5873
- { name: 'NotFoundError' }
5606
+ new DOMException(
5607
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
5608
+ 'NotFoundError'
5874
5609
  )
5875
5610
  );
5876
5611
 
@@ -6035,21 +5770,6 @@ describe('Protocol V2 firmware update method', () => {
6035
5770
  });
6036
5771
 
6037
5772
  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
5773
  test('rejects a different physical serial before firmware transfer resumes', () => {
6054
5774
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
6055
5775
  'identity mismatch'
@@ -6119,34 +5839,131 @@ describe('Protocol V2 firmware reconnect identity', () => {
6119
5839
  });
6120
5840
  });
6121
5841
 
6122
- test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
5842
+ test('uses ResourceInventory instead of host-side FileRead for resource comparison', async () => {
6123
5843
  const method = new FirmwareUpdateV4({
6124
5844
  id: 1,
6125
5845
  payload: {
6126
5846
  method: 'firmwareUpdateV4',
6127
- platform: 'native',
6128
- chunkSize: 1800,
5847
+ platform: 'web',
5848
+ targetsToUpdate: ['resource'],
6129
5849
  },
6130
5850
  });
6131
5851
  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}`);
5852
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
5853
+ (type, index) => ({
5854
+ type,
5855
+ url: `https://example.com/${type}.okpkg`,
5856
+ size: index + 1,
5857
+ fileHash: 'a'.repeat(64),
5858
+ headerHash: index.toString(16).padStart(128, '0'),
5859
+ })
5860
+ );
5861
+ const typedCall = jest.fn().mockResolvedValue({
5862
+ message: {
5863
+ items: stable.map(item => ({
5864
+ type: item.type.toUpperCase(),
5865
+ size: item.size,
5866
+ header_hash: item.headerHash,
5867
+ })),
5868
+ },
6142
5869
  });
6143
5870
  (method as any).device = stubDevice({
6144
5871
  getCommands: () => ({ typedCall }),
6145
5872
  });
5873
+ const resourcesSpy = jest
5874
+ .spyOn(DataManager, 'getProtocolV2Resources')
5875
+ .mockReturnValue(stable as any);
5876
+ (method as any).downloadProtocolV2Resource = jest.fn();
5877
+
5878
+ await expect((method as any).prepareProtocolV2ResourceBundles(false)).resolves.toEqual([]);
5879
+ expect(typedCall).toHaveBeenCalledWith(
5880
+ 'ResourceInventoryGet',
5881
+ 'ResourceInventory',
5882
+ {},
5883
+ { timeoutMs: 5000 }
5884
+ );
5885
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(false);
5886
+ expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
5887
+ resourcesSpy.mockRestore();
5888
+ });
5889
+
5890
+ test('downloads only changed resources in Application mode', async () => {
5891
+ const method = new FirmwareUpdateV4({
5892
+ id: 1,
5893
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
5894
+ });
5895
+ method.init();
5896
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
5897
+ (type, index) => ({
5898
+ type,
5899
+ url: `https://example.com/${type}.okpkg`,
5900
+ size: index + 1,
5901
+ fileHash: 'a'.repeat(64),
5902
+ headerHash: index.toString(16).padStart(128, '0'),
5903
+ })
5904
+ );
5905
+ const typedCall = jest.fn().mockResolvedValue({
5906
+ message: {
5907
+ items: stable.slice(1).map(item => ({
5908
+ type: item.type.toUpperCase(),
5909
+ size: item.size,
5910
+ header_hash: item.headerHash,
5911
+ })),
5912
+ },
5913
+ });
5914
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
5915
+ const resourcesSpy = jest
5916
+ .spyOn(DataManager, 'getProtocolV2Resources')
5917
+ .mockReturnValue(stable as any);
5918
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
5919
+ Promise.resolve({
5920
+ name: `${resource.type}.okpkg`,
5921
+ binary: new ArrayBuffer(resource.size),
5922
+ devicePath: `vol0:/${resource.type}.okpkg`,
5923
+ })
5924
+ );
5925
+
5926
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles(false);
5927
+
5928
+ expect(bundles).toHaveLength(1);
5929
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
5930
+ resourcesSpy.mockRestore();
5931
+ });
5932
+
5933
+ test('downloads all six resources in Bootloader recovery without inventory RPC', async () => {
5934
+ const method = new FirmwareUpdateV4({
5935
+ id: 1,
5936
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
5937
+ });
5938
+ method.init();
5939
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
5940
+ (type, index) => ({
5941
+ type,
5942
+ url: `https://example.com/${type}.okpkg`,
5943
+ size: index + 1,
5944
+ fileHash: 'a'.repeat(64),
5945
+ headerHash: index.toString(16).padStart(128, '0'),
5946
+ })
5947
+ );
5948
+ const typedCall = jest.fn();
5949
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
5950
+ const resourcesSpy = jest
5951
+ .spyOn(DataManager, 'getProtocolV2Resources')
5952
+ .mockReturnValue(stable as any);
5953
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
5954
+ Promise.resolve({
5955
+ name: `${resource.type}.okpkg`,
5956
+ binary: new ArrayBuffer(resource.size),
5957
+ devicePath: `vol0:/${resource.type}.okpkg`,
5958
+ })
5959
+ );
5960
+
5961
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles(true);
6146
5962
 
6147
- await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
6148
- expect(readChunkLengths.length).toBeGreaterThan(1);
6149
- expect(Math.max(...readChunkLengths)).toBe(900);
5963
+ expect(bundles).toHaveLength(6);
5964
+ expect(typedCall).not.toHaveBeenCalled();
5965
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledTimes(6);
5966
+ resourcesSpy.mockRestore();
6150
5967
  });
6151
5968
  });
6152
5969
 
@@ -6286,311 +6103,230 @@ describe('Protocol V2 protected method execution', () => {
6286
6103
  expect(method.unlockPolicy).toBe('none');
6287
6104
  });
6288
6105
 
6289
- test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6106
+ test('checks fresh status for a wallet business method without a name allowlist', async () => {
6290
6107
  const calls: string[] = [];
6108
+ const features = { unlocked: true };
6291
6109
  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
- }),
6110
+ name: 'confluxSignMessageCIP23',
6111
+ unlockPolicy: 'none',
6112
+ useDevicePassphraseState: true,
6113
+ protocolV2UiInteraction: { reason: 'address-confirmation' },
6114
+ run: jest.fn(() => {
6115
+ calls.push('run');
6116
+ return Promise.resolve({ address: '0x1' });
6117
+ }),
6305
6118
  };
6306
6119
  const device = {
6120
+ features,
6121
+ commands: {
6122
+ typedCall: jest.fn(() => {
6123
+ calls.push('status');
6124
+ return Promise.resolve({ message: { unlocked: true } });
6125
+ }),
6126
+ },
6307
6127
  isProtocolV2: () => true,
6308
- unlockDevice: jest.fn(() => {
6309
- calls.push('unlock');
6310
- return Promise.resolve();
6128
+ isBootloader: () => false,
6129
+ isRomloader: () => false,
6130
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6131
+ features.unlocked = status.unlocked === true;
6132
+ return features;
6311
6133
  }),
6134
+ unlockDevice: jest.fn(),
6312
6135
  };
6313
6136
  const uiCoordinator = {
6314
6137
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6315
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6316
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6138
+ enterUnlockInteraction: jest.fn(),
6317
6139
  };
6318
6140
 
6319
6141
  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
- ]);
6142
+ runMethodWithUnlockPolicy(method as any, device as any, {
6143
+ uiCoordinator: uiCoordinator as any,
6144
+ })
6145
+ ).resolves.toEqual({ address: '0x1' });
6146
+
6147
+ expect(calls).toEqual(['status', 'method-prompt', 'run']);
6148
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6149
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6150
+ expect(method.run).toHaveBeenCalledTimes(1);
6330
6151
  });
6331
6152
 
6332
- test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6153
+ test('validates the fresh device identity before starting unlock', async () => {
6333
6154
  const calls: string[] = [];
6155
+ const identityError = new Error('Unexpected device');
6334
6156
  const method = {
6335
6157
  name: 'evmSignMessage',
6336
- payload: { passphraseState: 'hidden-state' },
6158
+ unlockPolicy: 'none',
6337
6159
  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
- }),
6160
+ run: jest.fn(),
6349
6161
  };
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
- });
6162
+ const features = { unlocked: false };
6367
6163
  const device = {
6368
- features: { unlocked: true, passphraseProtection: true },
6369
- passphraseState: 'hidden-state',
6370
- commands: { typedCall },
6164
+ features,
6165
+ commands: {
6166
+ typedCall: jest.fn(() => {
6167
+ calls.push('status');
6168
+ return Promise.resolve({ message: { unlocked: false } });
6169
+ }),
6170
+ },
6371
6171
  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')),
6172
+ isBootloader: () => false,
6173
+ isRomloader: () => false,
6174
+ updateProtocolV2Status: jest.fn(() => features),
6175
+ unlockDevice: jest.fn(),
6381
6176
  };
6382
6177
 
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
- };
6178
+ await expect(
6179
+ runMethodWithUnlockPolicy(method as any, device as any, {
6180
+ afterStatusBeforeUnlock: () => {
6181
+ calls.push('identity');
6182
+ throw identityError;
6183
+ },
6184
+ })
6185
+ ).rejects.toBe(identityError);
6427
6186
 
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();
6187
+ expect(calls).toEqual(['status', 'identity']);
6188
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6189
+ expect(method.run).not.toHaveBeenCalled();
6433
6190
  });
6434
6191
 
6435
- test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6192
+ test('unlocks before business and never replays the callback', async () => {
6436
6193
  const calls: string[] = [];
6194
+ const features = { unlocked: true };
6437
6195
  const method = {
6438
6196
  name: 'evmSignMessage',
6439
- payload: { passphraseState: 'hidden-state' },
6440
- useDevicePassphraseState: true,
6441
- unlockPolicy: 'retry-on-locked',
6197
+ unlockPolicy: 'unlock-before-run',
6198
+ protocolV2UiInteraction: { reason: 'signing-confirmation' },
6442
6199
  run: jest.fn(() => {
6443
6200
  calls.push('run');
6444
- return Promise.resolve({ message: 'ok' });
6201
+ return Promise.resolve({ signature: 'signed' });
6445
6202
  }),
6446
6203
  };
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
6204
  const device = {
6470
6205
  features,
6471
- passphraseState: 'hidden-state',
6472
- commands: { typedCall },
6206
+ commands: {
6207
+ typedCall: jest.fn(() => {
6208
+ calls.push('status');
6209
+ return Promise.resolve({ message: { unlocked: false } });
6210
+ }),
6211
+ },
6473
6212
  isProtocolV2: () => true,
6213
+ isBootloader: () => false,
6214
+ isRomloader: () => false,
6215
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6216
+ features.unlocked = status.unlocked === true;
6217
+ return features;
6218
+ }),
6474
6219
  unlockDevice: jest.fn(() => {
6475
- calls.push('unlock-main');
6220
+ calls.push('unlock');
6476
6221
  features.unlocked = true;
6477
- features.unlockedAttachPin = false;
6478
- return Promise.resolve();
6222
+ return Promise.resolve(features);
6223
+ }),
6224
+ };
6225
+ const uiCoordinator = {
6226
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6227
+ enterUnlockInteraction: jest.fn(() => {
6228
+ calls.push('unlock-prompt');
6229
+ return undefined;
6479
6230
  }),
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
6231
  };
6486
6232
 
6487
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6488
- message: 'ok',
6489
- });
6490
- expect(device.unlockDevice).toHaveBeenCalledWith();
6233
+ await expect(
6234
+ runMethodWithUnlockPolicy(method as any, device as any, {
6235
+ uiCoordinator: uiCoordinator as any,
6236
+ prepare: () => {
6237
+ calls.push('wallet-session');
6238
+ return Promise.resolve();
6239
+ },
6240
+ })
6241
+ ).resolves.toEqual({ signature: 'signed' });
6242
+
6491
6243
  expect(calls).toEqual([
6492
- 'unlock-main',
6493
- 'negotiate-session',
6494
- 'resume-hidden-session',
6495
- 'validate-hidden-session',
6244
+ 'status',
6245
+ 'unlock-prompt',
6246
+ 'unlock',
6247
+ 'wallet-session',
6248
+ 'method-prompt',
6496
6249
  'run',
6497
6250
  ]);
6251
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6252
+ expect(method.run).toHaveBeenCalledTimes(1);
6498
6253
  });
6499
6254
 
6500
- test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6255
+ test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6501
6256
  const method = {
6502
6257
  name: 'evmGetAddress',
6503
- payload: { useEmptyPassphrase: true },
6504
- useDevicePassphraseState: true,
6505
- unlockPolicy: 'retry-on-locked',
6506
- run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6258
+ unlockPolicy: 'unlock-before-run',
6259
+ run: jest.fn(),
6507
6260
  };
6508
6261
  const device = {
6509
- features: { unlocked: false },
6510
- passphraseState: 'stale-hidden-state',
6262
+ features: { unlocked: undefined },
6263
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6511
6264
  isProtocolV2: () => true,
6512
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6265
+ isBootloader: () => false,
6266
+ isRomloader: () => false,
6267
+ updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6268
+ unlockDevice: jest.fn(),
6513
6269
  };
6514
6270
 
6515
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6516
- address: 'standard-wallet-address',
6271
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
6272
+ errorCode: HardwareErrorCode.RuntimeError,
6517
6273
  });
6518
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6519
- expect(method.run).toHaveBeenCalledTimes(1);
6274
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6275
+ expect(method.run).not.toHaveBeenCalled();
6520
6276
  });
6521
6277
 
6522
- test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6523
- const calls: string[] = [];
6278
+ test('returns a business DeviceLocked error without unlocking or replaying', async () => {
6279
+ const error = deviceLockedError();
6524
6280
  const method = {
6525
- name: 'deviceSettingsPageShow',
6281
+ name: 'evmSignMessage',
6526
6282
  unlockPolicy: 'unlock-before-run',
6527
- protocolV2UiInteraction: { reason: 'settings-page' },
6528
- run: jest.fn(() => {
6529
- calls.push('run');
6530
- return Promise.resolve({ message: 'ok' });
6531
- }),
6283
+ run: jest.fn().mockRejectedValue(error),
6532
6284
  };
6533
6285
  const features = { unlocked: true };
6534
6286
  const device = {
6535
6287
  features,
6536
- commands: {
6537
- typedCall: jest.fn(() => {
6538
- calls.push('refresh-status');
6539
- return Promise.resolve({ message: { unlocked: false } });
6540
- }),
6541
- },
6288
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6542
6289
  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')),
6290
+ isBootloader: () => false,
6291
+ isRomloader: () => false,
6292
+ updateProtocolV2Status: jest.fn(() => features),
6293
+ unlockDevice: jest.fn(),
6557
6294
  };
6558
6295
 
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', {});
6296
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
6297
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6564
6298
  expect(method.run).toHaveBeenCalledTimes(1);
6565
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6566
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6567
6299
  });
6568
6300
 
6569
- test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6570
- const features = { unlocked: false };
6571
- const method = {
6572
- name: 'deviceSettings',
6301
+ test('reuses one lightweight preflight context across nested protected methods', async () => {
6302
+ const features = { unlocked: true };
6303
+ const context = createProtocolV2UnlockContext();
6304
+ const firstMethod = {
6305
+ name: 'allNetworkGetAddress',
6573
6306
  unlockPolicy: 'unlock-before-run',
6574
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6307
+ run: jest.fn().mockResolvedValue({ root: true }),
6308
+ };
6309
+ const nestedMethod = {
6310
+ name: 'evmGetAddress',
6311
+ unlockPolicy: 'unlock-before-run',
6312
+ run: jest.fn().mockResolvedValue({ address: '0x1' }),
6575
6313
  };
6576
6314
  const device = {
6577
6315
  features,
6578
- commands: {
6579
- typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6580
- },
6316
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6581
6317
  isProtocolV2: () => true,
6582
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6583
- features.unlocked = status.unlocked ?? features.unlocked;
6584
- return features;
6585
- }),
6318
+ isBootloader: () => false,
6319
+ isRomloader: () => false,
6320
+ updateProtocolV2Status: jest.fn(() => features),
6586
6321
  unlockDevice: jest.fn(),
6587
6322
  };
6588
6323
 
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);
6324
+ await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
6325
+ await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
6326
+
6327
+ expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
6328
+ expect(firstMethod.run).toHaveBeenCalledTimes(1);
6329
+ expect(nestedMethod.run).toHaveBeenCalledTimes(1);
6594
6330
  });
6595
6331
 
6596
6332
  test.each(['bootloader', 'romloader'] as const)(
@@ -6610,7 +6346,7 @@ describe('Protocol V2 protected method execution', () => {
6610
6346
  unlockDevice: jest.fn(),
6611
6347
  };
6612
6348
 
6613
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6349
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6614
6350
  message: 'updating',
6615
6351
  });
6616
6352
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6619,157 +6355,30 @@ describe('Protocol V2 protected method execution', () => {
6619
6355
  }
6620
6356
  );
6621
6357
 
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 () => {
6358
+ test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6653
6359
  for (const [isProtocolV2, unlockPolicy] of [
6654
- [false, 'retry-on-locked'],
6360
+ [false, 'unlock-before-run'],
6655
6361
  [true, 'none'],
6656
6362
  ] as const) {
6657
- const error = deviceLockedError();
6658
6363
  const method = {
6659
6364
  unlockPolicy,
6660
- run: jest.fn().mockRejectedValue(error),
6365
+ useDevicePassphraseState: false,
6366
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6661
6367
  };
6662
6368
  const device = {
6369
+ commands: { typedCall: jest.fn() },
6663
6370
  isProtocolV2: () => isProtocolV2,
6664
6371
  unlockDevice: jest.fn(),
6665
6372
  };
6666
6373
 
6667
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6374
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6375
+ message: 'ok',
6376
+ });
6377
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6668
6378
  expect(device.unlockDevice).not.toHaveBeenCalled();
6669
6379
  expect(method.run).toHaveBeenCalledTimes(1);
6670
6380
  }
6671
6381
  });
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
6382
  });
6774
6383
 
6775
6384
  describe('Protocol V2 current low-level methods', () => {
@@ -6901,7 +6510,9 @@ describe('Protocol V2 current low-level methods', () => {
6901
6510
 
6902
6511
  await method.run();
6903
6512
 
6904
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
6513
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
6514
+ eventless_wallet_session: true,
6515
+ });
6905
6516
  });
6906
6517
 
6907
6518
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {