@onekeyfe/hd-core 1.2.0-alpha.35 → 1.2.0-alpha.37

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 (100) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +78 -0
  2. package/__tests__/DeviceEventRegistration.test.ts +6 -0
  3. package/__tests__/deviceUploadNft.test.ts +187 -0
  4. package/__tests__/get-device-state.test.ts +104 -12
  5. package/__tests__/homescreen.test.ts +41 -1
  6. package/__tests__/logBlockEvent.test.ts +1 -1
  7. package/__tests__/open-wallet-session.test.ts +259 -18
  8. package/__tests__/pro2Nft.test.ts +108 -0
  9. package/__tests__/protocol-v2-ui-lifecycle.test.ts +56 -0
  10. package/__tests__/protocol-v2-unlock-policy.test.ts +86 -35
  11. package/__tests__/protocol-v2.test.ts +377 -402
  12. package/__tests__/public-pro2-api-boundary.test.ts +1 -0
  13. package/dist/api/BaseMethod.d.ts +3 -1
  14. package/dist/api/BaseMethod.d.ts.map +1 -1
  15. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  16. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  17. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  18. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  19. package/dist/api/helpers/protocolV2FileWrite.d.ts +2 -0
  20. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  21. package/dist/api/index.d.ts +1 -0
  22. package/dist/api/index.d.ts.map +1 -1
  23. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +30 -0
  24. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -0
  25. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  26. package/dist/core/deviceEventRegistration.d.ts +2 -0
  27. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  28. package/dist/core/index.d.ts.map +1 -1
  29. package/dist/device/Device.d.ts +22 -4
  30. package/dist/device/Device.d.ts.map +1 -1
  31. package/dist/events/device.d.ts +4 -0
  32. package/dist/events/device.d.ts.map +1 -1
  33. package/dist/events/logBlockEvent.d.ts.map +1 -1
  34. package/dist/events/ui-request.d.ts +27 -2
  35. package/dist/events/ui-request.d.ts.map +1 -1
  36. package/dist/index.d.ts +88 -7
  37. package/dist/index.js +937 -604
  38. package/dist/inject.d.ts.map +1 -1
  39. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  40. package/dist/protocols/protocol-v2/uiInteraction.d.ts +3 -3
  41. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  42. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +1 -3
  43. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  44. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +22 -0
  45. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -0
  46. package/dist/protocols/protocol-v2/walletSession.d.ts +4 -1
  47. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  48. package/dist/types/api/index.d.ts +2 -1
  49. package/dist/types/api/index.d.ts.map +1 -1
  50. package/dist/types/api/protocolV2.d.ts +3 -0
  51. package/dist/types/api/protocolV2.d.ts.map +1 -1
  52. package/dist/utils/deviceFeaturesUtils.d.ts +2 -0
  53. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  54. package/dist/utils/homescreen.d.ts +4 -0
  55. package/dist/utils/homescreen.d.ts.map +1 -1
  56. package/dist/utils/index.d.ts +1 -1
  57. package/dist/utils/index.d.ts.map +1 -1
  58. package/dist/utils/patch.d.ts +1 -1
  59. package/dist/utils/patch.d.ts.map +1 -1
  60. package/dist/utils/pro2Nft.d.ts +29 -0
  61. package/dist/utils/pro2Nft.d.ts.map +1 -0
  62. package/dist/utils/pro2Wallpaper.d.ts +10 -0
  63. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  64. package/package.json +4 -4
  65. package/src/api/BaseMethod.ts +8 -10
  66. package/src/api/FirmwareUpdateV4.ts +13 -6
  67. package/src/api/GetPassphraseState.ts +7 -1
  68. package/src/api/OpenWalletSession.ts +54 -17
  69. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +32 -11
  70. package/src/api/device/DeviceUnlock.ts +1 -1
  71. package/src/api/helpers/protocolV2FileWrite.ts +11 -4
  72. package/src/api/index.ts +1 -0
  73. package/src/api/protocol-v2/DeviceUploadNft.ts +166 -0
  74. package/src/api/protocol-v2/ProtocolInfoRequest.ts +3 -1
  75. package/src/core/deviceEventRegistration.ts +4 -0
  76. package/src/core/index.ts +111 -74
  77. package/src/data/messages/messages-protocol-v2.json +10 -0
  78. package/src/device/Device.ts +221 -24
  79. package/src/events/device.ts +4 -0
  80. package/src/events/logBlockEvent.ts +1 -0
  81. package/src/events/ui-request.ts +32 -2
  82. package/src/inject.ts +1 -0
  83. package/src/protocols/protocol-v2/features.ts +9 -2
  84. package/src/protocols/protocol-v2/uiInteraction.ts +31 -5
  85. package/src/protocols/protocol-v2/unlockPolicy.ts +1 -105
  86. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +117 -0
  87. package/src/protocols/protocol-v2/walletSession.ts +93 -18
  88. package/src/types/api/index.ts +2 -0
  89. package/src/types/api/protocolV2.ts +13 -0
  90. package/src/utils/deviceFeaturesUtils.ts +4 -0
  91. package/src/utils/homescreen.ts +32 -6
  92. package/src/utils/index.ts +6 -1
  93. package/src/utils/pro2Nft.ts +91 -0
  94. package/src/utils/pro2Wallpaper.ts +35 -8
  95. package/dist/protocols/protocol-v2/lockedError.d.ts +0 -2
  96. package/dist/protocols/protocol-v2/lockedError.d.ts.map +0 -1
  97. package/dist/protocols/protocol-v2/unlockRetry.d.ts +0 -10
  98. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +0 -1
  99. package/src/protocols/protocol-v2/lockedError.ts +0 -10
  100. package/src/protocols/protocol-v2/unlockRetry.ts +0 -109
@@ -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,
@@ -70,15 +71,18 @@ import {
70
71
  getProtocolV2RuntimeMode,
71
72
  parseProtocolV2BuildFingerprint,
72
73
  requestProtocolV2DeviceInfo,
73
- requestProtocolV2ProtocolInfo,
74
74
  requestProtocolV2DeviceStatus,
75
+ requestProtocolV2ProtocolInfo,
75
76
  supportsProtocolV2Message,
76
77
  } from '../src/protocols/protocol-v2/features';
77
78
  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,
@@ -240,7 +244,7 @@ describe('UploadPortfolio', () => {
240
244
  (method as any).device = device;
241
245
 
242
246
  method.init();
243
- await runMethodWithUnlockRetry(method, device as any);
247
+ await runMethodWithUnlockPolicy(method, device as any);
244
248
 
245
249
  expect(unlockDevice).not.toHaveBeenCalled();
246
250
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -416,14 +420,16 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
416
420
  }
417
421
  return d.features;
418
422
  };
423
+ d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
424
+ requestProtocolV2ProtocolInfo({
425
+ commands: d.getCommands?.() ?? d.commands,
426
+ timeoutMs,
427
+ });
419
428
  d.probeProtocolV2RuntimeState ??= async (
420
429
  deviceInfo: ProtocolV2DeviceInfo,
421
430
  timeoutMs?: number
422
431
  ) => {
423
- const protocolInfo = await requestProtocolV2ProtocolInfo({
424
- commands: d.getCommands(),
425
- timeoutMs,
426
- });
432
+ const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
427
433
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
428
434
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
429
435
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -804,7 +810,7 @@ describe('Protocol V2 feature adapter', () => {
804
810
  ).resolves.toEqual({
805
811
  passphraseState: 'state-1',
806
812
  newSession: 'session-1',
807
- unlockedAttachPin: undefined,
813
+ unlockedAttachPin: false,
808
814
  resumed: false,
809
815
  });
810
816
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -1552,6 +1558,70 @@ describe('Protocol V2 feature adapter', () => {
1552
1558
  expect(device.getInternalState()).toBeUndefined();
1553
1559
  });
1554
1560
 
1561
+ test.each([false, true])(
1562
+ 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1563
+ async lockFails => {
1564
+ const deviceId = 'attach-mismatch-device';
1565
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1566
+ (device as any).features = normalizeProtocolV2Features(
1567
+ { ...descriptor, protocolType: 'V2' } as any,
1568
+ {
1569
+ status: {
1570
+ device_id: deviceId,
1571
+ unlocked: true,
1572
+ passphrase_enabled: true,
1573
+ attach_to_pin_enabled: true,
1574
+ unlocked_by_attach_to_pin: false,
1575
+ },
1576
+ }
1577
+ );
1578
+ device.passphraseState = 'expected-state';
1579
+
1580
+ const typedCall = jest.fn((request: string) => {
1581
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1582
+ if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1583
+ if (request === 'DeviceStatusGet') {
1584
+ return {
1585
+ message: {
1586
+ device_id: deviceId,
1587
+ unlocked: true,
1588
+ passphrase_enabled: true,
1589
+ attach_to_pin_enabled: true,
1590
+ unlocked_by_attach_to_pin: true,
1591
+ },
1592
+ };
1593
+ }
1594
+ if (request === 'DeviceSessionGet') {
1595
+ return {
1596
+ message: {
1597
+ btc_test_address: 'wrong-attach-state',
1598
+ session_id: 'wrong-attach-session',
1599
+ },
1600
+ };
1601
+ }
1602
+ if (request === 'LockDevice') {
1603
+ if (lockFails) throw new Error('Lock unsupported');
1604
+ return { message: { message: 'Device locked' } };
1605
+ }
1606
+ throw new Error(`Unexpected request: ${request}`);
1607
+ });
1608
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1609
+ (device as any).commands = { typedCall, promptPassphrase };
1610
+
1611
+ await expect(
1612
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1613
+ ).rejects.toMatchObject({
1614
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1615
+ });
1616
+
1617
+ expect(promptPassphrase).not.toHaveBeenCalled();
1618
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1619
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1620
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1621
+ expect(device.getInternalState()).toBeUndefined();
1622
+ }
1623
+ );
1624
+
1555
1625
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1556
1626
  const device = Device.fromDescriptor({
1557
1627
  id: 'cache-device-attach-recovery',
@@ -1608,7 +1678,17 @@ describe('Protocol V2 feature adapter', () => {
1608
1678
  }),
1609
1679
  { cancelDeviceOnReject: false }
1610
1680
  );
1611
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1681
+ expect(unlockDevice).toHaveBeenCalledWith(
1682
+ DeviceSessionPinType.AttachToPin,
1683
+ expect.objectContaining({
1684
+ emitUiEvent: false,
1685
+ interaction: expect.objectContaining({
1686
+ phase: 'pin',
1687
+ transition: 'start',
1688
+ protocol: 'V2',
1689
+ }),
1690
+ })
1691
+ );
1612
1692
  expect(device.getInternalState()).toBe('new-attach-session');
1613
1693
  });
1614
1694
 
@@ -1892,12 +1972,10 @@ describe('Protocol V2 feature adapter', () => {
1892
1972
  expect(getFeatures).not.toHaveBeenCalled();
1893
1973
  });
1894
1974
 
1895
- test('marks an interactive cache miss as a wallet-session recovery', async () => {
1975
+ test('uses the cached wallet address when the local session is missing', async () => {
1896
1976
  const typedCall = jest
1897
1977
  .fn()
1898
1978
  .mockResolvedValueOnce({ message: { version: 2 } })
1899
- .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1900
- .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1901
1979
  .mockResolvedValueOnce({
1902
1980
  message: {
1903
1981
  btc_test_address: 'expected-state',
@@ -1925,16 +2003,8 @@ describe('Protocol V2 feature adapter', () => {
1925
2003
  await expect(
1926
2004
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1927
2005
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
1928
- expect(promptPassphrase).toHaveBeenCalledWith(
1929
- {
1930
- existsAttachPinUser: false,
1931
- deviceOnly: false,
1932
- source: 'wallet-session-coordinator',
1933
- reason: 'session-recovery',
1934
- expectedPassphraseState: 'expected-state',
1935
- },
1936
- { cancelDeviceOnReject: false }
1937
- );
2006
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2007
+ expect(promptPassphrase).not.toHaveBeenCalled();
1938
2008
  });
1939
2009
 
1940
2010
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2000,7 +2070,6 @@ describe('Protocol V2 feature adapter', () => {
2000
2070
  true
2001
2071
  );
2002
2072
  expect(typedCall.mock.calls).toEqual([
2003
- ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2004
2073
  ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2005
2074
  ]);
2006
2075
  });
@@ -2268,12 +2337,13 @@ describe('Protocol V2 feature adapter', () => {
2268
2337
  );
2269
2338
 
2270
2339
  expect(device.getInternalState()).toBeUndefined();
2271
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2340
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2341
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2272
2342
  passphrase: 'host hidden wallet',
2273
2343
  on_device: false,
2274
2344
  });
2275
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2276
- expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2345
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2346
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2277
2347
  });
2278
2348
 
2279
2349
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2310,6 +2380,9 @@ describe('Protocol V2 feature adapter', () => {
2310
2380
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2311
2381
  eventless_wallet_session: true,
2312
2382
  });
2383
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
2384
+ type: DeviceSessionPinType.Main,
2385
+ });
2313
2386
  });
2314
2387
 
2315
2388
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2680,7 +2753,9 @@ describe('Protocol V2 feature adapter', () => {
2680
2753
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2681
2754
  }
2682
2755
  );
2683
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2756
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2757
+ eventless_wallet_session: true,
2758
+ });
2684
2759
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2685
2760
  });
2686
2761
 
@@ -2718,7 +2793,9 @@ describe('Protocol V2 feature adapter', () => {
2718
2793
  }),
2719
2794
  expect.anything()
2720
2795
  );
2721
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2796
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2797
+ eventless_wallet_session: true,
2798
+ });
2722
2799
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2723
2800
  expect(device.features).toMatchObject({
2724
2801
  deviceId: null,
@@ -2839,6 +2916,59 @@ describe('Protocol V2 feature adapter', () => {
2839
2916
  });
2840
2917
  });
2841
2918
 
2919
+ test('maps an explicit unsupported DeviceStatusGet response to bootloader for an unknown fingerprint', async () => {
2920
+ const device = Device.fromDescriptor({
2921
+ path: 'usb-path',
2922
+ protocolType: 'V2',
2923
+ } as any);
2924
+ const protocolInfo = {
2925
+ ...protocolV2ApplicationInfo,
2926
+ build_fingerprint: 'legacy-fingerprint',
2927
+ };
2928
+ const typedCall = jest
2929
+ .fn()
2930
+ .mockResolvedValueOnce({ type: 'ProtocolInfo', message: protocolInfo })
2931
+ .mockRejectedValueOnce(new Error('Failure_UnexpectedMessage,Unknown message'));
2932
+ (device as any).commands = { typedCall };
2933
+
2934
+ await device.probeProtocolV2RuntimeState({
2935
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2936
+ fw: { bootloader: { version: '0.2.0' } },
2937
+ });
2938
+
2939
+ expect(device.features).toMatchObject({
2940
+ mode: 'bootloader',
2941
+ bootloaderMode: true,
2942
+ initialized: null,
2943
+ });
2944
+ });
2945
+
2946
+ test('propagates DeviceStatusGet link failures instead of mapping them to bootloader', async () => {
2947
+ const device = Device.fromDescriptor({
2948
+ path: 'usb-path',
2949
+ protocolType: 'V2',
2950
+ } as any);
2951
+ const protocolInfo = {
2952
+ ...protocolV2ApplicationInfo,
2953
+ build_fingerprint: 'legacy-fingerprint',
2954
+ };
2955
+ const linkError = new Error('Protocol V2 CRC mismatch');
2956
+ const typedCall = jest
2957
+ .fn()
2958
+ .mockResolvedValueOnce({ type: 'ProtocolInfo', message: protocolInfo })
2959
+ .mockRejectedValueOnce(linkError);
2960
+ (device as any).commands = { typedCall };
2961
+
2962
+ await expect(
2963
+ device.probeProtocolV2RuntimeState({
2964
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2965
+ fw: { application: { version: '1.2.3' } },
2966
+ })
2967
+ ).rejects.toBe(linkError);
2968
+
2969
+ expect(device.features).toBeUndefined();
2970
+ });
2971
+
2842
2972
  test('fails safely when runtime fingerprint and capabilities are both unknown', async () => {
2843
2973
  const device = Device.fromDescriptor({
2844
2974
  path: 'usb-path',
@@ -2931,10 +3061,6 @@ describe('Protocol V2 feature adapter', () => {
2931
3061
  fw: { application: { version: '1.2.4' } },
2932
3062
  },
2933
3063
  })
2934
- .mockResolvedValueOnce({
2935
- type: 'ProtocolInfo',
2936
- message: protocolV2ApplicationInfo,
2937
- })
2938
3064
  .mockResolvedValueOnce({
2939
3065
  type: 'DeviceStatus',
2940
3066
  message: { init_states: true, unlocked: true },
@@ -2956,7 +3082,7 @@ describe('Protocol V2 feature adapter', () => {
2956
3082
  firmwareVersion: '1.2.4',
2957
3083
  passphraseProtection: null,
2958
3084
  });
2959
- expect(typedCall).toHaveBeenCalledTimes(6);
3085
+ expect(typedCall).toHaveBeenCalledTimes(5);
2960
3086
  expect(typedCall).toHaveBeenNthCalledWith(
2961
3087
  4,
2962
3088
  'DeviceInfoGet',
@@ -3070,9 +3196,23 @@ describe('Protocol V2 feature adapter', () => {
3070
3196
  status: { init_states: true },
3071
3197
  }
3072
3198
  );
3199
+ const pinOnDevice = jest.fn();
3200
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3073
3201
 
3074
3202
  const features = await device.unlockDevice();
3075
3203
 
3204
+ expect(pinOnDevice).toHaveBeenCalledWith(
3205
+ device,
3206
+ DeviceSessionPinType.Main,
3207
+ expect.objectContaining({
3208
+ source: 'unlock-coordinator',
3209
+ reason: 'device-unlock',
3210
+ deviceOnly: true,
3211
+ })
3212
+ );
3213
+ expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3214
+ typedCall.mock.invocationCallOrder[0]
3215
+ );
3076
3216
  expect(typedCall.mock.calls).toEqual([
3077
3217
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3078
3218
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3118,9 +3258,12 @@ describe('Protocol V2 feature adapter', () => {
3118
3258
  throw new Error(`Unexpected request: ${requestType}`);
3119
3259
  });
3120
3260
  (device as any).commands = { typedCall };
3261
+ const pinOnDevice = jest.fn();
3262
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3121
3263
 
3122
- await device.unlockDevice();
3264
+ await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3123
3265
 
3266
+ expect(pinOnDevice).not.toHaveBeenCalled();
3124
3267
  expect(typedCall.mock.calls).toEqual([
3125
3268
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3126
3269
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -4339,6 +4482,19 @@ describe('Protocol V2 firmware update targets', () => {
4339
4482
  )
4340
4483
  ).toBe(true);
4341
4484
 
4485
+ (method.postProgressMessage as jest.Mock).mockClear();
4486
+ expect(
4487
+ (method as any).assertProtocolV2TargetStatus(
4488
+ [
4489
+ { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
4490
+ { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
4491
+ ],
4492
+ new Set([4, 10])
4493
+ )
4494
+ ).toBe(false);
4495
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4496
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
4497
+
4342
4498
  expect(
4343
4499
  (method as any).assertProtocolV2TargetStatus([{ target_id: 4, status: 1 }], expectedTargetIds)
4344
4500
  ).toBe(false);
@@ -4944,7 +5100,7 @@ describe('Protocol V2 firmware update targets', () => {
4944
5100
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4945
5101
 
4946
5102
  const order: string[] = [];
4947
- const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
5103
+ const resourceBinary = buildOkppHeader().buffer as ArrayBuffer;
4948
5104
  const getSysResourceBinarySpy = jest
4949
5105
  .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
4950
5106
  .mockImplementation(() => {
@@ -5012,6 +5168,31 @@ describe('Protocol V2 firmware update targets', () => {
5012
5168
  getFirmwareLatestReleaseSpy.mockRestore();
5013
5169
  });
5014
5170
 
5171
+ test('rejects a remote RESC bundle without an OKPP header when metadata is absent', async () => {
5172
+ const method = new FirmwareUpdateV4({
5173
+ id: 1,
5174
+ payload: {
5175
+ method: 'firmwareUpdateV4',
5176
+ },
5177
+ });
5178
+ const getSysResourceBinarySpy = jest
5179
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5180
+ .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5181
+
5182
+ await expect(
5183
+ (method as any).prefetchProtocolV2ResourceBundles([
5184
+ {
5185
+ name: 'images',
5186
+ binary: new ArrayBuffer(0),
5187
+ devicePath: 'vol0:/resource/images/images.okpkg',
5188
+ url: 'https://example.com/images.okpkg',
5189
+ },
5190
+ ])
5191
+ ).rejects.toThrow('Invalid Protocol V2 RESC bundle header: images');
5192
+
5193
+ getSysResourceBinarySpy.mockRestore();
5194
+ });
5195
+
5015
5196
  test('rejects an empty remote RESC bundle before entering bootloader', async () => {
5016
5197
  const method = new FirmwareUpdateV4({
5017
5198
  id: 1,
@@ -6012,311 +6193,230 @@ describe('Protocol V2 protected method execution', () => {
6012
6193
  expect(method.unlockPolicy).toBe('none');
6013
6194
  });
6014
6195
 
6015
- test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6196
+ test('checks fresh status for a wallet business method without a name allowlist', async () => {
6016
6197
  const calls: string[] = [];
6198
+ const features = { unlocked: true };
6017
6199
  const method = {
6018
- name: 'testBusinessMethod',
6019
- unlockPolicy: 'retry-on-locked',
6020
- protocolV2UiInteraction: { reason: 'settings-page' },
6021
- run: jest
6022
- .fn()
6023
- .mockImplementationOnce(() => {
6024
- calls.push('run-1');
6025
- return Promise.reject(deviceLockedError());
6026
- })
6027
- .mockImplementationOnce(() => {
6028
- calls.push('run-2');
6029
- return Promise.resolve({ message: 'ok' });
6030
- }),
6200
+ name: 'confluxSignMessageCIP23',
6201
+ unlockPolicy: 'none',
6202
+ useDevicePassphraseState: true,
6203
+ protocolV2UiInteraction: { reason: 'address-confirmation' },
6204
+ run: jest.fn(() => {
6205
+ calls.push('run');
6206
+ return Promise.resolve({ address: '0x1' });
6207
+ }),
6031
6208
  };
6032
6209
  const device = {
6210
+ features,
6211
+ commands: {
6212
+ typedCall: jest.fn(() => {
6213
+ calls.push('status');
6214
+ return Promise.resolve({ message: { unlocked: true } });
6215
+ }),
6216
+ },
6033
6217
  isProtocolV2: () => true,
6034
- unlockDevice: jest.fn(() => {
6035
- calls.push('unlock');
6036
- return Promise.resolve();
6218
+ isBootloader: () => false,
6219
+ isRomloader: () => false,
6220
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6221
+ features.unlocked = status.unlocked === true;
6222
+ return features;
6037
6223
  }),
6224
+ unlockDevice: jest.fn(),
6038
6225
  };
6039
6226
  const uiCoordinator = {
6040
6227
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6041
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6042
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6228
+ enterUnlockInteraction: jest.fn(),
6043
6229
  };
6044
6230
 
6045
6231
  await expect(
6046
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6047
- ).resolves.toEqual({ message: 'ok' });
6048
- expect(calls).toEqual([
6049
- 'method-prompt',
6050
- 'run-1',
6051
- 'unlock-prompt',
6052
- 'unlock',
6053
- 'method-prompt',
6054
- 'run-2',
6055
- ]);
6232
+ runMethodWithUnlockPolicy(method as any, device as any, {
6233
+ uiCoordinator: uiCoordinator as any,
6234
+ })
6235
+ ).resolves.toEqual({ address: '0x1' });
6236
+
6237
+ expect(calls).toEqual(['status', 'method-prompt', 'run']);
6238
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6239
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6240
+ expect(method.run).toHaveBeenCalledTimes(1);
6056
6241
  });
6057
6242
 
6058
- test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6243
+ test('validates the fresh device identity before starting unlock', async () => {
6059
6244
  const calls: string[] = [];
6245
+ const identityError = new Error('Unexpected device');
6060
6246
  const method = {
6061
6247
  name: 'evmSignMessage',
6062
- payload: { passphraseState: 'hidden-state' },
6248
+ unlockPolicy: 'none',
6063
6249
  useDevicePassphraseState: true,
6064
- unlockPolicy: 'retry-on-locked',
6065
- run: jest
6066
- .fn()
6067
- .mockImplementationOnce(() => {
6068
- calls.push('run-1');
6069
- return Promise.reject(deviceLockedError());
6070
- })
6071
- .mockImplementationOnce(() => {
6072
- calls.push('run-2');
6073
- return Promise.resolve({ message: 'ok' });
6074
- }),
6250
+ run: jest.fn(),
6075
6251
  };
6076
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6077
- if (requestType === 'ProtocolInfoRequest') {
6078
- calls.push('negotiate-session');
6079
- return Promise.resolve({ message: { version: 2 } });
6080
- }
6081
- if (requestType === 'DeviceSessionGet') {
6082
- calls.push('resume-hidden-session');
6083
- expect(request).toEqual({ session_id: 'hidden-session' });
6084
- return Promise.resolve({
6085
- message: {
6086
- session_id: 'hidden-session',
6087
- btc_test_address: 'hidden-state',
6088
- },
6089
- });
6090
- }
6091
- throw new Error(`Unexpected request: ${requestType}`);
6092
- });
6252
+ const features = { unlocked: false };
6093
6253
  const device = {
6094
- features: { unlocked: true, passphraseProtection: true },
6095
- passphraseState: 'hidden-state',
6096
- commands: { typedCall },
6254
+ features,
6255
+ commands: {
6256
+ typedCall: jest.fn(() => {
6257
+ calls.push('status');
6258
+ return Promise.resolve({ message: { unlocked: false } });
6259
+ }),
6260
+ },
6097
6261
  isProtocolV2: () => true,
6098
- unlockDevice: jest.fn(() => {
6099
- calls.push('unlock');
6100
- return Promise.resolve();
6101
- }),
6102
- getCurrentPassphraseProtection: () => true,
6103
- getInternalState: () => 'hidden-session',
6104
- clearInternalState: jest.fn(),
6105
- getCurrentDeviceId: () => 'wallet-device-id',
6106
- updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6262
+ isBootloader: () => false,
6263
+ isRomloader: () => false,
6264
+ updateProtocolV2Status: jest.fn(() => features),
6265
+ unlockDevice: jest.fn(),
6107
6266
  };
6108
6267
 
6109
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6110
- message: 'ok',
6111
- });
6112
- expect(calls).toEqual([
6113
- 'run-1',
6114
- 'unlock',
6115
- 'negotiate-session',
6116
- 'resume-hidden-session',
6117
- 'validate-hidden-session',
6118
- 'run-2',
6119
- ]);
6120
- });
6121
-
6122
- test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
6123
- const initialError = deviceLockedError();
6124
- const restoreError = new Error('Invalid wallet session');
6125
- const method = {
6126
- name: 'evmSignMessage',
6127
- payload: { passphraseState: 'hidden-state' },
6128
- useDevicePassphraseState: true,
6129
- unlockPolicy: 'retry-on-locked',
6130
- run: jest.fn().mockRejectedValueOnce(initialError),
6131
- };
6132
- const typedCall = jest.fn((requestType: string) => {
6133
- if (requestType === 'ProtocolInfoRequest') {
6134
- return Promise.resolve({ message: { version: 2 } });
6135
- }
6136
- if (requestType === 'DeviceSessionGet') {
6137
- return Promise.reject(restoreError);
6138
- }
6139
- throw new Error(`Unexpected request: ${requestType}`);
6140
- });
6141
- const device = {
6142
- features: { unlocked: true, passphraseProtection: true },
6143
- passphraseState: 'hidden-state',
6144
- commands: { typedCall },
6145
- isProtocolV2: () => true,
6146
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6147
- getCurrentPassphraseProtection: () => true,
6148
- getInternalState: () => 'hidden-session',
6149
- clearInternalState: jest.fn(),
6150
- getCurrentDeviceId: () => 'wallet-device-id',
6151
- updateInternalState: jest.fn(),
6152
- };
6268
+ await expect(
6269
+ runMethodWithUnlockPolicy(method as any, device as any, {
6270
+ afterStatusBeforeUnlock: () => {
6271
+ calls.push('identity');
6272
+ throw identityError;
6273
+ },
6274
+ })
6275
+ ).rejects.toBe(identityError);
6153
6276
 
6154
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
6155
- expect(method.run).toHaveBeenCalledTimes(1);
6156
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6157
- expect(device.clearInternalState).toHaveBeenCalledTimes(1);
6158
- expect(device.updateInternalState).not.toHaveBeenCalled();
6277
+ expect(calls).toEqual(['status', 'identity']);
6278
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6279
+ expect(method.run).not.toHaveBeenCalled();
6159
6280
  });
6160
6281
 
6161
- test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6282
+ test('unlocks before business and never replays the callback', async () => {
6162
6283
  const calls: string[] = [];
6284
+ const features = { unlocked: true };
6163
6285
  const method = {
6164
6286
  name: 'evmSignMessage',
6165
- payload: { passphraseState: 'hidden-state' },
6166
- useDevicePassphraseState: true,
6167
- unlockPolicy: 'retry-on-locked',
6287
+ unlockPolicy: 'unlock-before-run',
6288
+ protocolV2UiInteraction: { reason: 'signing-confirmation' },
6168
6289
  run: jest.fn(() => {
6169
6290
  calls.push('run');
6170
- return Promise.resolve({ message: 'ok' });
6291
+ return Promise.resolve({ signature: 'signed' });
6171
6292
  }),
6172
6293
  };
6173
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6174
- if (requestType === 'ProtocolInfoRequest') {
6175
- calls.push('negotiate-session');
6176
- return Promise.resolve({ message: { version: 2 } });
6177
- }
6178
- if (requestType === 'DeviceSessionGet') {
6179
- calls.push('resume-hidden-session');
6180
- expect(request).toEqual({ session_id: 'hidden-session' });
6181
- return Promise.resolve({
6182
- message: {
6183
- session_id: 'hidden-session',
6184
- btc_test_address: 'hidden-state',
6185
- },
6186
- });
6187
- }
6188
- throw new Error(`Unexpected request: ${requestType}`);
6189
- });
6190
- const features = {
6191
- unlocked: false,
6192
- unlockedAttachPin: true,
6193
- passphraseProtection: true,
6194
- };
6195
6294
  const device = {
6196
6295
  features,
6197
- passphraseState: 'hidden-state',
6198
- commands: { typedCall },
6296
+ commands: {
6297
+ typedCall: jest.fn(() => {
6298
+ calls.push('status');
6299
+ return Promise.resolve({ message: { unlocked: false } });
6300
+ }),
6301
+ },
6199
6302
  isProtocolV2: () => true,
6303
+ isBootloader: () => false,
6304
+ isRomloader: () => false,
6305
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6306
+ features.unlocked = status.unlocked === true;
6307
+ return features;
6308
+ }),
6200
6309
  unlockDevice: jest.fn(() => {
6201
- calls.push('unlock-main');
6310
+ calls.push('unlock');
6202
6311
  features.unlocked = true;
6203
- features.unlockedAttachPin = false;
6204
- return Promise.resolve();
6312
+ return Promise.resolve(features);
6313
+ }),
6314
+ };
6315
+ const uiCoordinator = {
6316
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6317
+ enterUnlockInteraction: jest.fn(() => {
6318
+ calls.push('unlock-prompt');
6319
+ return undefined;
6205
6320
  }),
6206
- getCurrentPassphraseProtection: () => true,
6207
- getInternalState: () => 'hidden-session',
6208
- clearInternalState: jest.fn(),
6209
- getCurrentDeviceId: () => 'wallet-device-id',
6210
- updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6211
6321
  };
6212
6322
 
6213
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6214
- message: 'ok',
6215
- });
6216
- expect(device.unlockDevice).toHaveBeenCalledWith();
6323
+ await expect(
6324
+ runMethodWithUnlockPolicy(method as any, device as any, {
6325
+ uiCoordinator: uiCoordinator as any,
6326
+ prepare: () => {
6327
+ calls.push('wallet-session');
6328
+ return Promise.resolve();
6329
+ },
6330
+ })
6331
+ ).resolves.toEqual({ signature: 'signed' });
6332
+
6217
6333
  expect(calls).toEqual([
6218
- 'unlock-main',
6219
- 'negotiate-session',
6220
- 'resume-hidden-session',
6221
- 'validate-hidden-session',
6334
+ 'status',
6335
+ 'unlock-prompt',
6336
+ 'unlock',
6337
+ 'wallet-session',
6338
+ 'method-prompt',
6222
6339
  'run',
6223
6340
  ]);
6341
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6342
+ expect(method.run).toHaveBeenCalledTimes(1);
6224
6343
  });
6225
6344
 
6226
- test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6345
+ test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6227
6346
  const method = {
6228
6347
  name: 'evmGetAddress',
6229
- payload: { useEmptyPassphrase: true },
6230
- useDevicePassphraseState: true,
6231
- unlockPolicy: 'retry-on-locked',
6232
- run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6348
+ unlockPolicy: 'unlock-before-run',
6349
+ run: jest.fn(),
6233
6350
  };
6234
6351
  const device = {
6235
- features: { unlocked: false },
6236
- passphraseState: 'stale-hidden-state',
6352
+ features: { unlocked: undefined },
6353
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6237
6354
  isProtocolV2: () => true,
6238
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6355
+ isBootloader: () => false,
6356
+ isRomloader: () => false,
6357
+ updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6358
+ unlockDevice: jest.fn(),
6239
6359
  };
6240
6360
 
6241
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6242
- address: 'standard-wallet-address',
6361
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
6362
+ errorCode: HardwareErrorCode.RuntimeError,
6243
6363
  });
6244
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6245
- expect(method.run).toHaveBeenCalledTimes(1);
6364
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6365
+ expect(method.run).not.toHaveBeenCalled();
6246
6366
  });
6247
6367
 
6248
- test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6249
- const calls: string[] = [];
6368
+ test('returns a business DeviceLocked error without unlocking or replaying', async () => {
6369
+ const error = deviceLockedError();
6250
6370
  const method = {
6251
- name: 'deviceSettingsPageShow',
6371
+ name: 'evmSignMessage',
6252
6372
  unlockPolicy: 'unlock-before-run',
6253
- protocolV2UiInteraction: { reason: 'settings-page' },
6254
- run: jest.fn(() => {
6255
- calls.push('run');
6256
- return Promise.resolve({ message: 'ok' });
6257
- }),
6373
+ run: jest.fn().mockRejectedValue(error),
6258
6374
  };
6259
6375
  const features = { unlocked: true };
6260
6376
  const device = {
6261
6377
  features,
6262
- commands: {
6263
- typedCall: jest.fn(() => {
6264
- calls.push('refresh-status');
6265
- return Promise.resolve({ message: { unlocked: false } });
6266
- }),
6267
- },
6378
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6268
6379
  isProtocolV2: () => true,
6269
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6270
- features.unlocked = status.unlocked ?? features.unlocked;
6271
- return features;
6272
- }),
6273
- unlockDevice: jest.fn(() => {
6274
- calls.push('unlock');
6275
- features.unlocked = true;
6276
- return Promise.resolve();
6277
- }),
6278
- };
6279
- const uiCoordinator = {
6280
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6281
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6282
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6380
+ isBootloader: () => false,
6381
+ isRomloader: () => false,
6382
+ updateProtocolV2Status: jest.fn(() => features),
6383
+ unlockDevice: jest.fn(),
6283
6384
  };
6284
6385
 
6285
- await expect(
6286
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6287
- ).resolves.toEqual({ message: 'ok' });
6288
- expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6289
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6386
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
6387
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6290
6388
  expect(method.run).toHaveBeenCalledTimes(1);
6291
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6292
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6293
6389
  });
6294
6390
 
6295
- test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6296
- const features = { unlocked: false };
6297
- const method = {
6298
- name: 'deviceSettings',
6391
+ test('reuses one lightweight preflight context across nested protected methods', async () => {
6392
+ const features = { unlocked: true };
6393
+ const context = createProtocolV2UnlockContext();
6394
+ const firstMethod = {
6395
+ name: 'allNetworkGetAddress',
6299
6396
  unlockPolicy: 'unlock-before-run',
6300
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6397
+ run: jest.fn().mockResolvedValue({ root: true }),
6398
+ };
6399
+ const nestedMethod = {
6400
+ name: 'evmGetAddress',
6401
+ unlockPolicy: 'unlock-before-run',
6402
+ run: jest.fn().mockResolvedValue({ address: '0x1' }),
6301
6403
  };
6302
6404
  const device = {
6303
6405
  features,
6304
- commands: {
6305
- typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6306
- },
6406
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6307
6407
  isProtocolV2: () => true,
6308
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6309
- features.unlocked = status.unlocked ?? features.unlocked;
6310
- return features;
6311
- }),
6408
+ isBootloader: () => false,
6409
+ isRomloader: () => false,
6410
+ updateProtocolV2Status: jest.fn(() => features),
6312
6411
  unlockDevice: jest.fn(),
6313
6412
  };
6314
6413
 
6315
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6316
- message: 'ok',
6317
- });
6318
- expect(device.unlockDevice).not.toHaveBeenCalled();
6319
- expect(method.run).toHaveBeenCalledTimes(1);
6414
+ await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
6415
+ await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
6416
+
6417
+ expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
6418
+ expect(firstMethod.run).toHaveBeenCalledTimes(1);
6419
+ expect(nestedMethod.run).toHaveBeenCalledTimes(1);
6320
6420
  });
6321
6421
 
6322
6422
  test.each(['bootloader', 'romloader'] as const)(
@@ -6336,7 +6436,7 @@ describe('Protocol V2 protected method execution', () => {
6336
6436
  unlockDevice: jest.fn(),
6337
6437
  };
6338
6438
 
6339
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6439
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6340
6440
  message: 'updating',
6341
6441
  });
6342
6442
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6345,157 +6445,30 @@ describe('Protocol V2 protected method execution', () => {
6345
6445
  }
6346
6446
  );
6347
6447
 
6348
- test('infers a signing interaction before running a Protocol V2 business method', async () => {
6349
- const method = {
6350
- name: 'evmSignMessage',
6351
- params: { message: 'not-exposed-in-event' },
6352
- payload: {},
6353
- unlockPolicy: 'retry-on-locked',
6354
- run: jest.fn().mockResolvedValue({ signature: 'test' }),
6355
- };
6356
- const device = {
6357
- isProtocolV2: () => true,
6358
- unlockDevice: jest.fn(),
6359
- };
6360
- const uiCoordinator = {
6361
- enterMethodInteraction: jest.fn(),
6362
- enterUnlockInteraction: jest.fn(),
6363
- resumeMethodInteraction: jest.fn(),
6364
- };
6365
-
6366
- await runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any);
6367
-
6368
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledWith({
6369
- request: 'button',
6370
- source: 'method-lifecycle',
6371
- reason: 'signing-confirmation',
6372
- completion: 'operation-completed',
6373
- deviceOnly: true,
6374
- operation: 'evmSignMessage',
6375
- });
6376
- });
6377
-
6378
- test('does not unlock a Protocol V1 device or a method without the policy', async () => {
6448
+ test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6379
6449
  for (const [isProtocolV2, unlockPolicy] of [
6380
- [false, 'retry-on-locked'],
6450
+ [false, 'unlock-before-run'],
6381
6451
  [true, 'none'],
6382
6452
  ] as const) {
6383
- const error = deviceLockedError();
6384
6453
  const method = {
6385
6454
  unlockPolicy,
6386
- run: jest.fn().mockRejectedValue(error),
6455
+ useDevicePassphraseState: false,
6456
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6387
6457
  };
6388
6458
  const device = {
6459
+ commands: { typedCall: jest.fn() },
6389
6460
  isProtocolV2: () => isProtocolV2,
6390
6461
  unlockDevice: jest.fn(),
6391
6462
  };
6392
6463
 
6393
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6464
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6465
+ message: 'ok',
6466
+ });
6467
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6394
6468
  expect(device.unlockDevice).not.toHaveBeenCalled();
6395
6469
  expect(method.run).toHaveBeenCalledTimes(1);
6396
6470
  }
6397
6471
  });
6398
-
6399
- test('runs lock-free eventless methods without unlocking or synthesized UI', async () => {
6400
- const method = {
6401
- name: 'uploadPortfolio',
6402
- unlockPolicy: 'none',
6403
- protocolV2UiMode: 'none',
6404
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6405
- };
6406
- const device = {
6407
- features: { unlocked: false },
6408
- isProtocolV2: () => true,
6409
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6410
- };
6411
- const uiCoordinator = {
6412
- enterMethodInteraction: jest.fn(),
6413
- enterUnlockInteraction: jest.fn(),
6414
- resumeMethodInteraction: jest.fn(),
6415
- };
6416
-
6417
- await expect(
6418
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6419
- ).resolves.toEqual({ message: 'ok' });
6420
- expect(device.unlockDevice).not.toHaveBeenCalled();
6421
- expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6422
- expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6423
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6424
- });
6425
-
6426
- test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6427
- const error = deviceLockedError();
6428
- const method = {
6429
- name: 'uploadPortfolio',
6430
- unlockPolicy: 'none',
6431
- run: jest.fn().mockRejectedValue(error),
6432
- };
6433
- const device = {
6434
- features: { unlocked: true },
6435
- isProtocolV2: () => true,
6436
- unlockDevice: jest.fn(),
6437
- };
6438
-
6439
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6440
- expect(method.run).toHaveBeenCalledTimes(1);
6441
- expect(device.unlockDevice).not.toHaveBeenCalled();
6442
- });
6443
-
6444
- test('does not retry when unlock fails or when the retry is still locked', async () => {
6445
- const initialError = deviceLockedError();
6446
- const unlockError = new Error('PIN cancelled');
6447
- const unlockFailMethod = {
6448
- unlockPolicy: 'retry-on-locked',
6449
- run: jest.fn().mockRejectedValue(initialError),
6450
- };
6451
- const unlockFailDevice = {
6452
- isProtocolV2: () => true,
6453
- unlockDevice: jest.fn().mockRejectedValue(unlockError),
6454
- };
6455
- const unlockFailCoordinator = {
6456
- enterMethodInteraction: jest.fn(),
6457
- enterUnlockInteraction: jest.fn(),
6458
- resumeMethodInteraction: jest.fn(),
6459
- };
6460
-
6461
- await expect(
6462
- runMethodWithUnlockRetry(
6463
- unlockFailMethod as any,
6464
- unlockFailDevice as any,
6465
- unlockFailCoordinator as any
6466
- )
6467
- ).rejects.toBe(unlockError);
6468
- expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
6469
- expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6470
- expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6471
-
6472
- const retryError = deviceLockedError();
6473
- const retryFailMethod = {
6474
- unlockPolicy: 'retry-on-locked',
6475
- run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
6476
- };
6477
- const retryFailDevice = {
6478
- isProtocolV2: () => true,
6479
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6480
- };
6481
- const retryFailCoordinator = {
6482
- enterMethodInteraction: jest.fn(),
6483
- enterUnlockInteraction: jest.fn(),
6484
- resumeMethodInteraction: jest.fn(),
6485
- };
6486
-
6487
- await expect(
6488
- runMethodWithUnlockRetry(
6489
- retryFailMethod as any,
6490
- retryFailDevice as any,
6491
- retryFailCoordinator as any
6492
- )
6493
- ).rejects.toBe(retryError);
6494
- expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
6495
- expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
6496
- expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6497
- expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
6498
- });
6499
6472
  });
6500
6473
 
6501
6474
  describe('Protocol V2 current low-level methods', () => {
@@ -6627,7 +6600,9 @@ describe('Protocol V2 current low-level methods', () => {
6627
6600
 
6628
6601
  await method.run();
6629
6602
 
6630
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
6603
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
6604
+ eventless_wallet_session: true,
6605
+ });
6631
6606
  });
6632
6607
 
6633
6608
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {