@onekeyfe/hd-core 1.2.0-alpha.34 → 1.2.0-alpha.36

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 (91) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +74 -0
  2. package/__tests__/DeviceCommands.test.ts +84 -0
  3. package/__tests__/DeviceEventRegistration.test.ts +6 -0
  4. package/__tests__/device-settings.test.ts +3 -0
  5. package/__tests__/device-wallet-session-store.test.ts +80 -7
  6. package/__tests__/evmSignTransaction.test.ts +69 -0
  7. package/__tests__/get-device-state.test.ts +180 -40
  8. package/__tests__/open-wallet-session.test.ts +314 -55
  9. package/__tests__/protocol-v2-ui-lifecycle.test.ts +56 -0
  10. package/__tests__/protocol-v2.test.ts +768 -139
  11. package/dist/api/FirmwareUpdateV4.d.ts +1 -0
  12. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  13. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  14. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  15. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  16. package/dist/api/evm/EVMSignTypedData.d.ts.map +1 -1
  17. package/dist/api/evm/protocol.d.ts +3 -0
  18. package/dist/api/evm/protocol.d.ts.map +1 -0
  19. package/dist/api/protocol-v2/DeviceGetOnboardingStatus.d.ts +1 -1
  20. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  21. package/dist/core/deviceEventRegistration.d.ts +2 -0
  22. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  23. package/dist/core/index.d.ts.map +1 -1
  24. package/dist/device/Device.d.ts +25 -6
  25. package/dist/device/Device.d.ts.map +1 -1
  26. package/dist/device/DeviceCommands.d.ts.map +1 -1
  27. package/dist/device/DeviceWalletSessionStore.d.ts +0 -1
  28. package/dist/device/DeviceWalletSessionStore.d.ts.map +1 -1
  29. package/dist/events/device.d.ts +4 -0
  30. package/dist/events/device.d.ts.map +1 -1
  31. package/dist/events/ui-request.d.ts +27 -2
  32. package/dist/events/ui-request.d.ts.map +1 -1
  33. package/dist/index.d.ts +63 -12
  34. package/dist/index.js +1164 -904
  35. package/dist/protocols/protocol-v2/features.d.ts +17 -6
  36. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  37. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  38. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  39. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts +1 -0
  40. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts.map +1 -1
  41. package/dist/protocols/protocol-v2/uiInteraction.d.ts +3 -3
  42. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  43. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -1
  44. package/dist/protocols/protocol-v2/walletSession.d.ts +5 -1
  45. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  46. package/dist/types/api/openWalletSession.d.ts +1 -1
  47. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  48. package/dist/types/api/protocolV2.d.ts +2 -2
  49. package/dist/types/api/protocolV2.d.ts.map +1 -1
  50. package/dist/types/device.d.ts +2 -1
  51. package/dist/types/device.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/patch.d.ts +1 -1
  55. package/dist/utils/patch.d.ts.map +1 -1
  56. package/package.json +4 -4
  57. package/src/api/BaseMethod.ts +1 -1
  58. package/src/api/FirmwareUpdateV4.ts +39 -14
  59. package/src/api/GetPassphraseState.ts +17 -0
  60. package/src/api/OpenWalletSession.ts +31 -8
  61. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +24 -2
  62. package/src/api/device/DeviceUnlock.ts +1 -1
  63. package/src/api/evm/EVMGetAddress.ts +2 -2
  64. package/src/api/evm/EVMGetPublicKey.ts +2 -2
  65. package/src/api/evm/EVMSignMessage.ts +2 -2
  66. package/src/api/evm/EVMSignTransaction.ts +2 -2
  67. package/src/api/evm/EVMSignTypedData.ts +35 -45
  68. package/src/api/evm/EVMVerifyMessage.ts +2 -2
  69. package/src/api/evm/protocol.ts +6 -0
  70. package/src/api/protocol-v2/DeviceGetOnboardingStatus.ts +2 -2
  71. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +1 -1
  72. package/src/api/protocol-v2/ProtocolInfoRequest.ts +3 -1
  73. package/src/core/deviceEventRegistration.ts +4 -0
  74. package/src/core/index.ts +46 -4
  75. package/src/data/messages/messages-protocol-v2.json +408 -485
  76. package/src/device/Device.ts +266 -43
  77. package/src/device/DeviceCommands.ts +12 -2
  78. package/src/device/DeviceWalletSessionStore.ts +0 -14
  79. package/src/deviceProfile/buildDeviceFeatures.ts +1 -1
  80. package/src/events/device.ts +4 -0
  81. package/src/events/ui-request.ts +32 -2
  82. package/src/protocols/protocol-v2/features.ts +65 -16
  83. package/src/protocols/protocol-v2/index.ts +5 -0
  84. package/src/protocols/protocol-v2/settingsUnlockPolicy.ts +8 -3
  85. package/src/protocols/protocol-v2/uiInteraction.ts +31 -5
  86. package/src/protocols/protocol-v2/unlockRetry.ts +51 -18
  87. package/src/protocols/protocol-v2/walletSession.ts +172 -64
  88. package/src/types/api/openWalletSession.ts +1 -1
  89. package/src/types/api/protocolV2.ts +2 -2
  90. package/src/types/device.ts +2 -0
  91. package/src/utils/deviceFeaturesUtils.ts +4 -0
@@ -61,14 +61,19 @@ 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,
68
+ PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE,
67
69
  type ProtocolV2DeviceInfo,
68
70
  type ProtocolV2RuntimeMode,
69
71
  getProtocolV2RuntimeMode,
72
+ parseProtocolV2BuildFingerprint,
70
73
  requestProtocolV2DeviceInfo,
71
74
  requestProtocolV2DeviceStatus,
75
+ requestProtocolV2ProtocolInfo,
76
+ supportsProtocolV2Message,
72
77
  } from '../src/protocols/protocol-v2/features';
73
78
  import {
74
79
  getProtocolV2WalletSession,
@@ -95,7 +100,7 @@ import {
95
100
 
96
101
  import type { DeviceCommands } from '../src/device/DeviceCommands';
97
102
  import type { Features } from '../src/types';
98
- import type { DeviceStatus } from '@onekeyfe/hd-transport';
103
+ import type { DeviceStatus, ProtocolInfo } from '@onekeyfe/hd-transport';
99
104
 
100
105
  jest.mock('../src/data/config', () => ({
101
106
  getSDKVersion: jest.fn(() => '1.0.0'),
@@ -143,7 +148,7 @@ describe('DeviceUploadWallpaper', () => {
143
148
  const result = await method.run();
144
149
 
145
150
  expect(method.getSupportedProtocols()).toEqual(['V2']);
146
- expect(method.unlockPolicy).toBe('unlock-before-run');
151
+ expect(method.unlockPolicy).toBe('none');
147
152
  expect(typedCall).toHaveBeenNthCalledWith(1, 'FilesystemDirMake', 'Success', {
148
153
  path: 'vol1:/wallpapers',
149
154
  });
@@ -347,6 +352,24 @@ const descriptor = {
347
352
  path: 'usb-path',
348
353
  };
349
354
 
355
+ const protocolV2ApplicationInfo = {
356
+ version: 1,
357
+ build_fingerprint: 'application__1.2.3__abcdef0__PROD__RELEASE',
358
+ supported_messages: [PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE],
359
+ };
360
+
361
+ const protocolV2BootloaderInfo = {
362
+ version: 1,
363
+ build_fingerprint: 'bootloader__0.2.0__abcdef0__PROD__RELEASE',
364
+ supported_messages: [],
365
+ };
366
+
367
+ const protocolV2RomloaderInfo = {
368
+ version: 1,
369
+ build_fingerprint: 'romloader__1.0.0__abcdef0__PROD__RELEASE',
370
+ supported_messages: [],
371
+ };
372
+
350
373
  /**
351
374
  * Add Device protocol and getCurrent* accessors to plain-object stubs.
352
375
  * Defaults match Device.ts and never replace an existing member such as jest.fn().
@@ -356,6 +379,8 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
356
379
  d.updateState ??= jest.fn();
357
380
  d.markProtocolV2Reboot ??= jest.fn();
358
381
  d.isProtocolV2 ??= () => d.originalDescriptor?.protocolType === 'V2';
382
+ d.isBootloader ??= () => d.features?.mode === 'bootloader';
383
+ d.isRomloader ??= () => d.features?.mode === 'romloader';
359
384
  d.getProtocol ??= () => (d.isProtocolV2() ? 'V2' : 'V1');
360
385
  d.getCurrentDeviceType ??= () => getDeviceType(d.features);
361
386
  d.getCurrentFirmwareType ??= () => getFirmwareType(d.features);
@@ -375,7 +400,8 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
375
400
  d.updateProtocolV2Features ??= (
376
401
  deviceInfo?: ProtocolV2DeviceInfo,
377
402
  deviceStatus?: DeviceStatus,
378
- runtimeMode?: ProtocolV2RuntimeMode
403
+ runtimeMode?: ProtocolV2RuntimeMode,
404
+ protocolInfo?: ProtocolInfo
379
405
  ) => {
380
406
  d.features = buildProtocolV2FeaturesPayload({
381
407
  deviceInfo,
@@ -383,44 +409,37 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
383
409
  previous: d.features,
384
410
  runtimeMode: runtimeMode ?? (deviceStatus ? 'normal' : undefined),
385
411
  });
412
+ if (protocolInfo) {
413
+ d.features.raw = {
414
+ ...d.features.raw,
415
+ protocolV2ProtocolInfo: protocolInfo,
416
+ };
417
+ }
386
418
  return d.features;
387
419
  };
420
+ d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
421
+ requestProtocolV2ProtocolInfo({
422
+ commands: d.getCommands?.() ?? d.commands,
423
+ timeoutMs,
424
+ });
388
425
  d.probeProtocolV2RuntimeState ??= async (
389
426
  deviceInfo: ProtocolV2DeviceInfo,
390
427
  timeoutMs?: number
391
428
  ) => {
392
- try {
393
- const response = await d
394
- .getCommands()
395
- .typedCall(
396
- 'DeviceStatusGet',
397
- 'DeviceStatus',
398
- {},
399
- timeoutMs === undefined ? undefined : { timeoutMs }
400
- );
401
- const features = d.updateProtocolV2Features(deviceInfo, response.message, 'normal');
402
- return (
403
- features ??
404
- buildProtocolV2FeaturesPayload({
405
- deviceInfo,
406
- deviceStatus: response.message,
407
- previous: d.features,
408
- runtimeMode: 'normal',
409
- })
410
- );
411
- } catch {
412
- const runtimeMode = getProtocolV2RuntimeMode({
413
- deviceInfo,
414
- deviceStatusAvailable: false,
415
- });
416
- const features = d.updateProtocolV2Features(deviceInfo, null, runtimeMode);
417
- if (features?.mode === runtimeMode) return features;
418
- return buildProtocolV2FeaturesPayload({
419
- deviceInfo,
420
- previous: d.features,
421
- runtimeMode,
422
- });
429
+ const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
430
+ const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
431
+ if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
432
+ return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
433
+ }
434
+ if (!supportsProtocolV2Message(protocolInfo, PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE)) {
435
+ if (!runtimeMode) throw new Error('Unknown Protocol V2 runtime mode');
436
+ return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
423
437
  }
438
+ const deviceStatus = await requestProtocolV2DeviceStatus({
439
+ commands: d.getCommands(),
440
+ timeoutMs,
441
+ });
442
+ return d.updateProtocolV2Features(deviceInfo, deviceStatus, 'normal', protocolInfo);
424
443
  };
425
444
  d.updateProtocolV2Status ??= (deviceStatus: DeviceStatus) => {
426
445
  d.features = buildProtocolV2FeaturesPayload({
@@ -680,7 +699,7 @@ describe('Protocol V2 feature adapter', () => {
680
699
  expect(features.bleEnabled).toBeNull();
681
700
  });
682
701
 
683
- test('marks Protocol V2 DeviceInfo as bootloader when DeviceStatusGet fails', () => {
702
+ test('parses bootloader runtime mode from ProtocolInfo build fingerprint', () => {
684
703
  const deviceInfo = {
685
704
  protocol_version: 1,
686
705
  hw: {
@@ -693,10 +712,7 @@ describe('Protocol V2 feature adapter', () => {
693
712
  },
694
713
  se1: {},
695
714
  };
696
- const runtimeMode = getProtocolV2RuntimeMode({
697
- deviceInfo: deviceInfo as ProtocolV2DeviceInfo,
698
- deviceStatusAvailable: false,
699
- });
715
+ const runtimeMode = getProtocolV2RuntimeMode(protocolV2BootloaderInfo);
700
716
  const features = normalizeProtocolV2Features(
701
717
  descriptor as any,
702
718
  deviceInfo as ProtocolV2DeviceInfo,
@@ -718,7 +734,7 @@ describe('Protocol V2 feature adapter', () => {
718
734
  expect(features.bootloaderMode).toBe(false);
719
735
  });
720
736
 
721
- test('temporarily treats romloader metadata as bootloader when DeviceStatusGet fails', () => {
737
+ test('keeps romloader distinct from bootloader using ProtocolInfo build fingerprint', () => {
722
738
  const deviceInfo = {
723
739
  protocol_version: 1,
724
740
  hw: {
@@ -733,20 +749,28 @@ describe('Protocol V2 feature adapter', () => {
733
749
  },
734
750
  },
735
751
  };
736
- const runtimeMode = getProtocolV2RuntimeMode({
737
- deviceInfo: deviceInfo as ProtocolV2DeviceInfo,
738
- deviceStatusAvailable: false,
739
- });
752
+ const runtimeMode = getProtocolV2RuntimeMode(protocolV2RomloaderInfo);
740
753
  const features = normalizeProtocolV2Features(
741
754
  descriptor as any,
742
755
  deviceInfo as ProtocolV2DeviceInfo,
743
756
  runtimeMode
744
757
  );
745
- expect(features.mode).toBe('bootloader');
758
+ expect(features.mode).toBe('romloader');
746
759
  expect(features.bootloaderMode).toBe(true);
747
760
  expect(features.boardVersion).toBe('1.0.0');
748
761
  });
749
762
 
763
+ test('rejects malformed Protocol V2 build fingerprints', () => {
764
+ expect(parseProtocolV2BuildFingerprint('application__1.2.3')).toBeNull();
765
+ expect(
766
+ getProtocolV2RuntimeMode({
767
+ version: 1,
768
+ build_fingerprint: 'unknown__1.2.3__abcdef0__PROD__RELEASE',
769
+ supported_messages: [],
770
+ })
771
+ ).toBeUndefined();
772
+ });
773
+
750
774
  test('keeps the Protocol V2 main wallet on the default empty-passphrase context', async () => {
751
775
  const features = normalizeProtocolV2Features(descriptor as any);
752
776
  features.firmwareVersion = '1.2.3';
@@ -783,7 +807,7 @@ describe('Protocol V2 feature adapter', () => {
783
807
  ).resolves.toEqual({
784
808
  passphraseState: 'state-1',
785
809
  newSession: 'session-1',
786
- unlockedAttachPin: undefined,
810
+ unlockedAttachPin: false,
787
811
  resumed: false,
788
812
  });
789
813
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -844,6 +868,7 @@ describe('Protocol V2 feature adapter', () => {
844
868
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
845
869
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
846
870
  session_id: 'standard-session',
871
+ btc_test_address: 'standard-state',
847
872
  });
848
873
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
849
874
  passphraseState: 'standard-state',
@@ -908,7 +933,7 @@ describe('Protocol V2 feature adapter', () => {
908
933
  expect(deviceWalletSessionStore.get(deviceId, 'hidden-state')).toBe('hidden-session');
909
934
  });
910
935
 
911
- test('opens Main PIN directly instead of resuming a standard session while locked', async () => {
936
+ test('propagates DeviceLocked instead of retrying a cached standard session', async () => {
912
937
  const deviceId = 'standard-locked-device';
913
938
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
914
939
  (device as any).features = normalizeProtocolV2Features(
@@ -950,14 +975,11 @@ describe('Protocol V2 feature adapter', () => {
950
975
  });
951
976
  (device as any).commands = { typedCall };
952
977
 
953
- await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).resolves.toMatchObject({
954
- newSession: 'cached-standard-session',
955
- resumed: true,
978
+ await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).rejects.toMatchObject({
979
+ errorCode: HardwareErrorCode.DeviceLocked,
956
980
  });
957
- expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(2);
958
- expect(deviceWalletSessionStore.getStandard(deviceId)?.sessionId).toBe(
959
- 'cached-standard-session'
960
- );
981
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
982
+ expect(deviceWalletSessionStore.getStandard(deviceId)).toBeUndefined();
961
983
  });
962
984
 
963
985
  test('recovers a mismatched cached standard wallet without deleting hidden sessions', async () => {
@@ -1083,7 +1105,7 @@ describe('Protocol V2 feature adapter', () => {
1083
1105
  });
1084
1106
  });
1085
1107
 
1086
- test('uses refreshed passphrase status after unlocking before selecting a Protocol V2 wallet', async () => {
1108
+ test('does not implicitly unlock inside the Protocol V2 wallet-session coordinator', async () => {
1087
1109
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1088
1110
  (device as any).features = normalizeProtocolV2Features(
1089
1111
  { ...descriptor, protocolType: 'V2' } as any,
@@ -1094,13 +1116,9 @@ describe('Protocol V2 feature adapter', () => {
1094
1116
  },
1095
1117
  }
1096
1118
  );
1097
- const lockedError = Object.assign(new Error('Device is locked'), {
1098
- errorCode: HardwareErrorCode.DeviceLocked,
1099
- });
1100
1119
  const typedCall = createWalletSessionTypedCall(
1101
1120
  jest
1102
1121
  .fn()
1103
- .mockRejectedValueOnce(lockedError)
1104
1122
  .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1105
1123
  .mockResolvedValueOnce({
1106
1124
  message: {
@@ -1127,7 +1145,7 @@ describe('Protocol V2 feature adapter', () => {
1127
1145
  resumed: false,
1128
1146
  });
1129
1147
 
1130
- expect(unlockDevice).toHaveBeenCalledTimes(1);
1148
+ expect(unlockDevice).not.toHaveBeenCalled();
1131
1149
  expect(promptPassphrase).toHaveBeenCalled();
1132
1150
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1133
1151
  eventless_wallet_session: true,
@@ -1199,7 +1217,7 @@ describe('Protocol V2 feature adapter', () => {
1199
1217
  throw new Error(`Unexpected request: ${request}`);
1200
1218
  })
1201
1219
  );
1202
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1220
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1203
1221
  (device as any).commands = { typedCall, promptPassphrase };
1204
1222
 
1205
1223
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
@@ -1209,7 +1227,8 @@ describe('Protocol V2 feature adapter', () => {
1209
1227
 
1210
1228
  expect(promptPassphrase).toHaveBeenCalled();
1211
1229
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
1212
- on_device: true,
1230
+ passphrase: 'host hidden wallet',
1231
+ on_device: false,
1213
1232
  });
1214
1233
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1215
1234
  });
@@ -1240,7 +1259,7 @@ describe('Protocol V2 feature adapter', () => {
1240
1259
 
1241
1260
  test('deviceGetOnboardingStatus returns the current Protocol V2 onboarding status', async () => {
1242
1261
  const typedCall = jest.fn().mockResolvedValue({
1243
- type: 'DevOnboardingStatus',
1262
+ type: 'OnboardingStatus',
1244
1263
  message: {
1245
1264
  step: 4,
1246
1265
  phase: 4,
@@ -1268,7 +1287,7 @@ describe('Protocol V2 feature adapter', () => {
1268
1287
  pin_set: true,
1269
1288
  wallet_initialized: false,
1270
1289
  });
1271
- expect(typedCall).toHaveBeenCalledWith('DevGetOnboardingStatus', 'DevOnboardingStatus', {});
1290
+ expect(typedCall).toHaveBeenCalledWith('OnboardingStatusGet', 'OnboardingStatus', {});
1272
1291
  expect(method.getSupportedProtocols()).toEqual(['V2']);
1273
1292
  });
1274
1293
 
@@ -1319,6 +1338,7 @@ describe('Protocol V2 feature adapter', () => {
1319
1338
 
1320
1339
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1321
1340
  session_id: 'session-a',
1341
+ btc_test_address: 'state-a',
1322
1342
  });
1323
1343
  expect(device.getInternalState()).toBe('session-b');
1324
1344
  });
@@ -1386,7 +1406,11 @@ describe('Protocol V2 feature adapter', () => {
1386
1406
  });
1387
1407
  expect(typedCall.mock.calls).toEqual([
1388
1408
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1389
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1409
+ [
1410
+ 'DeviceSessionGet',
1411
+ 'DeviceSession',
1412
+ { session_id: 'session-a', btc_test_address: 'state-a' },
1413
+ ],
1390
1414
  ]);
1391
1415
  expect(promptPassphrase).not.toHaveBeenCalled();
1392
1416
  expect(device.getInternalState()).toBeUndefined();
@@ -1420,7 +1444,7 @@ describe('Protocol V2 feature adapter', () => {
1420
1444
  );
1421
1445
  (device as any).commands = {
1422
1446
  typedCall,
1423
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1447
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1424
1448
  };
1425
1449
 
1426
1450
  await expect(getProtocolV2WalletSession(device)).rejects.toMatchObject({
@@ -1428,7 +1452,11 @@ describe('Protocol V2 feature adapter', () => {
1428
1452
  });
1429
1453
  expect(typedCall.mock.calls).toEqual([
1430
1454
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1431
- ['DeviceSessionAskPassphrase', 'Success', { on_device: true }],
1455
+ [
1456
+ 'DeviceSessionAskPassphrase',
1457
+ 'Success',
1458
+ { passphrase: 'host hidden wallet', on_device: false },
1459
+ ],
1432
1460
  ['DeviceStatusGet', 'DeviceStatus', {}],
1433
1461
  ['DeviceSessionGet', 'DeviceSession', {}],
1434
1462
  ]);
@@ -1469,7 +1497,7 @@ describe('Protocol V2 feature adapter', () => {
1469
1497
  throw new Error(`Unexpected request: ${request}`);
1470
1498
  })
1471
1499
  );
1472
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1500
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1473
1501
  (device as any).commands = { typedCall, promptPassphrase };
1474
1502
 
1475
1503
  await expect(
@@ -1522,7 +1550,7 @@ describe('Protocol V2 feature adapter', () => {
1522
1550
  );
1523
1551
  (device as any).commands = {
1524
1552
  typedCall,
1525
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1553
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1526
1554
  };
1527
1555
 
1528
1556
  await expect(
@@ -1533,6 +1561,72 @@ describe('Protocol V2 feature adapter', () => {
1533
1561
  expect(device.getInternalState()).toBeUndefined();
1534
1562
  });
1535
1563
 
1564
+ test.each([false, true])(
1565
+ 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1566
+ async lockFails => {
1567
+ const deviceId = 'attach-mismatch-device';
1568
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1569
+ (device as any).features = normalizeProtocolV2Features(
1570
+ { ...descriptor, protocolType: 'V2' } as any,
1571
+ {
1572
+ status: {
1573
+ device_id: deviceId,
1574
+ unlocked: true,
1575
+ passphrase_enabled: true,
1576
+ attach_to_pin_enabled: true,
1577
+ unlocked_by_attach_to_pin: false,
1578
+ },
1579
+ }
1580
+ );
1581
+ device.passphraseState = 'expected-state';
1582
+
1583
+ const typedCall = jest.fn((request: string) => {
1584
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1585
+ if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1586
+ if (request === 'DeviceStatusGet') {
1587
+ return {
1588
+ message: {
1589
+ device_id: deviceId,
1590
+ unlocked: true,
1591
+ passphrase_enabled: true,
1592
+ attach_to_pin_enabled: true,
1593
+ unlocked_by_attach_to_pin: true,
1594
+ },
1595
+ };
1596
+ }
1597
+ if (request === 'DeviceSessionGet') {
1598
+ return {
1599
+ message: {
1600
+ btc_test_address: 'wrong-attach-state',
1601
+ session_id: 'wrong-attach-session',
1602
+ },
1603
+ };
1604
+ }
1605
+ if (request === 'LockDevice') {
1606
+ if (lockFails) throw new Error('Lock unsupported');
1607
+ return { message: { message: 'Device locked' } };
1608
+ }
1609
+ throw new Error(`Unexpected request: ${request}`);
1610
+ });
1611
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1612
+ (device as any).commands = { typedCall, promptPassphrase };
1613
+
1614
+ await expect(
1615
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1616
+ ).rejects.toMatchObject({
1617
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1618
+ });
1619
+
1620
+ expect(promptPassphrase).not.toHaveBeenCalled();
1621
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1622
+ btc_test_address: 'expected-state',
1623
+ });
1624
+ expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1625
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1626
+ expect(device.getInternalState()).toBeUndefined();
1627
+ }
1628
+ );
1629
+
1536
1630
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1537
1631
  const device = Device.fromDescriptor({
1538
1632
  id: 'cache-device-attach-recovery',
@@ -1589,11 +1683,21 @@ describe('Protocol V2 feature adapter', () => {
1589
1683
  }),
1590
1684
  { cancelDeviceOnReject: false }
1591
1685
  );
1592
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1686
+ expect(unlockDevice).toHaveBeenCalledWith(
1687
+ DeviceSessionPinType.AttachToPin,
1688
+ expect.objectContaining({
1689
+ emitUiEvent: false,
1690
+ interaction: expect.objectContaining({
1691
+ phase: 'pin',
1692
+ transition: 'start',
1693
+ protocol: 'V2',
1694
+ }),
1695
+ })
1696
+ );
1593
1697
  expect(device.getInternalState()).toBe('new-attach-session');
1594
1698
  });
1595
1699
 
1596
- test('unlocks and retries DeviceSessionAskPassphrase when wallet selection is locked', async () => {
1700
+ test('propagates DeviceLocked without retrying DeviceSessionAskPassphrase', async () => {
1597
1701
  const device = Device.fromDescriptor({
1598
1702
  id: 'cache-device-4',
1599
1703
  path: 'cache-path-4',
@@ -1620,16 +1724,61 @@ describe('Protocol V2 feature adapter', () => {
1620
1724
  );
1621
1725
  (device as any).commands = {
1622
1726
  typedCall,
1623
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1727
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1624
1728
  };
1625
1729
  const unlockDevice = jest.spyOn(device, 'unlockDevice').mockResolvedValue(undefined as any);
1626
1730
 
1731
+ await expect(getProtocolV2WalletSession(device)).rejects.toBe(lockedError);
1732
+ expect(unlockDevice).not.toHaveBeenCalled();
1733
+ expect(
1734
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
1735
+ ).toHaveLength(1);
1736
+ });
1737
+
1738
+ test('does not act on cached lock state inside the wallet-session coordinator', async () => {
1739
+ const calls: string[] = [];
1740
+ const device = Device.fromDescriptor({
1741
+ id: 'cache-device-pre-unlock',
1742
+ path: 'cache-path-pre-unlock',
1743
+ protocolType: 'V2',
1744
+ } as any);
1745
+ (device as any).features = normalizeProtocolV2Features(
1746
+ { ...descriptor, protocolType: 'V2' } as any,
1747
+ { status: { device_id: 'stable-device-pre-unlock', unlocked: false } }
1748
+ );
1749
+ const typedCall = createWalletSessionTypedCall(
1750
+ jest.fn((request: string) => {
1751
+ if (request === 'DeviceSessionAskPassphrase') {
1752
+ calls.push('ask-passphrase');
1753
+ return Promise.resolve({ message: { message: 'Passphrase prepared' } });
1754
+ }
1755
+ if (request === 'DeviceSessionGet') {
1756
+ calls.push('get-session');
1757
+ return Promise.resolve({
1758
+ message: {
1759
+ session_id: 'session-after-pre-unlock',
1760
+ btc_test_address: 'state-after-pre-unlock',
1761
+ },
1762
+ });
1763
+ }
1764
+ throw new Error(`Unexpected request: ${request}`);
1765
+ })
1766
+ );
1767
+ (device as any).commands = {
1768
+ typedCall,
1769
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1770
+ };
1771
+ const unlockDevice = jest.spyOn(device, 'unlockDevice');
1772
+
1627
1773
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
1628
- newSession: 'session-after-unlock',
1629
- passphraseState: 'state-after-unlock',
1774
+ newSession: 'session-after-pre-unlock',
1775
+ passphraseState: 'state-after-pre-unlock',
1630
1776
  });
1631
- expect(unlockDevice).toHaveBeenCalledTimes(1);
1632
- expect(typedCall).toHaveBeenCalledTimes(5);
1777
+ expect(unlockDevice).not.toHaveBeenCalled();
1778
+ expect(
1779
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
1780
+ ).toHaveLength(1);
1781
+ expect(calls).toEqual(['ask-passphrase', 'get-session']);
1633
1782
  });
1634
1783
 
1635
1784
  test('does not request a Pro2 wallet session before features are initialized', async () => {
@@ -1772,6 +1921,7 @@ describe('Protocol V2 feature adapter', () => {
1772
1921
  };
1773
1922
  const typedCall = jest
1774
1923
  .fn()
1924
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1775
1925
  .mockResolvedValueOnce({ message: { version: 2 } })
1776
1926
  .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1777
1927
  .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
@@ -1784,7 +1934,7 @@ describe('Protocol V2 feature adapter', () => {
1784
1934
  });
1785
1935
  const updateInternalState = jest.fn();
1786
1936
  const getFeatures = jest.fn().mockResolvedValue(features);
1787
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1937
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1788
1938
  const method = new GetPassphraseState({
1789
1939
  payload: {
1790
1940
  method: 'getPassphraseState',
@@ -1827,19 +1977,17 @@ describe('Protocol V2 feature adapter', () => {
1827
1977
  expect(getFeatures).not.toHaveBeenCalled();
1828
1978
  });
1829
1979
 
1830
- test('marks an interactive cache miss as a wallet-session recovery', async () => {
1980
+ test('uses the cached wallet address when the local session is missing', async () => {
1831
1981
  const typedCall = jest
1832
1982
  .fn()
1833
1983
  .mockResolvedValueOnce({ message: { version: 2 } })
1834
- .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1835
- .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1836
1984
  .mockResolvedValueOnce({
1837
1985
  message: {
1838
1986
  btc_test_address: 'expected-state',
1839
1987
  session_id: 'recovered-session',
1840
1988
  },
1841
1989
  });
1842
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1990
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1843
1991
  const device = stubDevice({
1844
1992
  originalDescriptor: { ...descriptor, protocolType: 'V2' },
1845
1993
  passphraseState: 'expected-state',
@@ -1860,16 +2008,10 @@ describe('Protocol V2 feature adapter', () => {
1860
2008
  await expect(
1861
2009
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1862
2010
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
1863
- expect(promptPassphrase).toHaveBeenCalledWith(
1864
- {
1865
- existsAttachPinUser: false,
1866
- deviceOnly: false,
1867
- source: 'wallet-session-coordinator',
1868
- reason: 'session-recovery',
1869
- expectedPassphraseState: 'expected-state',
1870
- },
1871
- { cancelDeviceOnReject: false }
1872
- );
2011
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2012
+ btc_test_address: 'expected-state',
2013
+ });
2014
+ expect(promptPassphrase).not.toHaveBeenCalled();
1873
2015
  });
1874
2016
 
1875
2017
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -1914,7 +2056,7 @@ describe('Protocol V2 feature adapter', () => {
1914
2056
  );
1915
2057
  (device as any).commands = {
1916
2058
  typedCall,
1917
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2059
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1918
2060
  };
1919
2061
 
1920
2062
  const method = new GetPassphraseState({
@@ -1935,8 +2077,11 @@ describe('Protocol V2 feature adapter', () => {
1935
2077
  true
1936
2078
  );
1937
2079
  expect(typedCall.mock.calls).toEqual([
1938
- ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1939
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2080
+ [
2081
+ 'DeviceSessionGet',
2082
+ 'DeviceSession',
2083
+ { session_id: 'session-pro2-app', btc_test_address: 'state-pro2-app' },
2084
+ ],
1940
2085
  ]);
1941
2086
  });
1942
2087
 
@@ -1970,7 +2115,7 @@ describe('Protocol V2 feature adapter', () => {
1970
2115
  features,
1971
2116
  commands: {
1972
2117
  typedCall,
1973
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2118
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1974
2119
  },
1975
2120
  updateInternalState,
1976
2121
  getCurrentDeviceType: () => 'pro2',
@@ -1998,6 +2143,7 @@ describe('Protocol V2 feature adapter', () => {
1998
2143
  };
1999
2144
  const typedCall = jest
2000
2145
  .fn()
2146
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
2001
2147
  .mockResolvedValueOnce({ message: { version: 2 } })
2002
2148
  .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
2003
2149
  .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
@@ -2023,7 +2169,7 @@ describe('Protocol V2 feature adapter', () => {
2023
2169
  passphraseState: 'state-pro2-old',
2024
2170
  commands: {
2025
2171
  typedCall,
2026
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2172
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
2027
2173
  },
2028
2174
  emit: jest.fn(),
2029
2175
  clearInternalState,
@@ -2066,7 +2212,7 @@ describe('Protocol V2 feature adapter', () => {
2066
2212
  };
2067
2213
  (device as any).commands = {
2068
2214
  typedCall,
2069
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2215
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
2070
2216
  };
2071
2217
 
2072
2218
  await getPassphraseStateWithRefreshDeviceInfo(device);
@@ -2106,7 +2252,7 @@ describe('Protocol V2 feature adapter', () => {
2106
2252
  );
2107
2253
  (device as any).commands = {
2108
2254
  typedCall,
2109
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2255
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
2110
2256
  };
2111
2257
 
2112
2258
  await expect(getPassphraseStateWithRefreshDeviceInfo(device)).resolves.toMatchObject({
@@ -2121,7 +2267,8 @@ describe('Protocol V2 feature adapter', () => {
2121
2267
  device.passphraseState = 'state-auto';
2122
2268
  expect(device.getInternalState()).toBe('session-auto');
2123
2269
  expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2124
- on_device: true,
2270
+ passphrase: 'host hidden wallet',
2271
+ on_device: false,
2125
2272
  });
2126
2273
  expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2127
2274
  });
@@ -2191,7 +2338,7 @@ describe('Protocol V2 feature adapter', () => {
2191
2338
  (device as any).features.unlocked = true;
2192
2339
  (device as any).commands = {
2193
2340
  typedCall,
2194
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2341
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
2195
2342
  };
2196
2343
 
2197
2344
  await expect(device.checkPassphraseStateSafety('expected-state', false, true)).rejects.toEqual(
@@ -2201,11 +2348,15 @@ describe('Protocol V2 feature adapter', () => {
2201
2348
  );
2202
2349
 
2203
2350
  expect(device.getInternalState()).toBeUndefined();
2204
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2205
- on_device: true,
2351
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2352
+ btc_test_address: 'expected-state',
2206
2353
  });
2207
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2208
- expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2354
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2355
+ passphrase: 'host hidden wallet',
2356
+ on_device: false,
2357
+ });
2358
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2359
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2209
2360
  });
2210
2361
 
2211
2362
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2242,6 +2393,9 @@ describe('Protocol V2 feature adapter', () => {
2242
2393
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2243
2394
  eventless_wallet_session: true,
2244
2395
  });
2396
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
2397
+ type: DeviceSessionPinType.Main,
2398
+ });
2245
2399
  });
2246
2400
 
2247
2401
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2564,6 +2718,10 @@ describe('Protocol V2 feature adapter', () => {
2564
2718
  fw: { application: { version: '1.2.3' } },
2565
2719
  },
2566
2720
  })
2721
+ .mockResolvedValueOnce({
2722
+ type: 'ProtocolInfo',
2723
+ message: protocolV2ApplicationInfo,
2724
+ })
2567
2725
  .mockResolvedValueOnce({
2568
2726
  type: 'DeviceStatus',
2569
2727
  message: { init_states: true, unlocked: true },
@@ -2584,7 +2742,7 @@ describe('Protocol V2 feature adapter', () => {
2584
2742
  expect(device.features?.passphraseProtection).toBeNull();
2585
2743
  expect(device.features?.label).toBeNull();
2586
2744
  expect(device.features?.firmwareVersion).toBe('1.2.3');
2587
- expect(typedCall).toHaveBeenCalledTimes(2);
2745
+ expect(typedCall).toHaveBeenCalledTimes(3);
2588
2746
  expect(typedCall).toHaveBeenNthCalledWith(
2589
2747
  1,
2590
2748
  'DeviceInfoGet',
@@ -2608,10 +2766,13 @@ describe('Protocol V2 feature adapter', () => {
2608
2766
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2609
2767
  }
2610
2768
  );
2611
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
2769
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2770
+ eventless_wallet_session: true,
2771
+ });
2772
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2612
2773
  });
2613
2774
 
2614
- test('recognizes bootloader when DeviceStatusGet fails', async () => {
2775
+ test('recognizes bootloader from ProtocolInfo without calling DeviceStatusGet', async () => {
2615
2776
  const device = Device.fromDescriptor({
2616
2777
  path: 'usb-path',
2617
2778
  protocolType: 'V2',
@@ -2626,7 +2787,10 @@ describe('Protocol V2 feature adapter', () => {
2626
2787
  se1: {},
2627
2788
  },
2628
2789
  })
2629
- .mockRejectedValueOnce(new Error('DeviceStatusGet unsupported in bootloader'));
2790
+ .mockResolvedValueOnce({
2791
+ type: 'ProtocolInfo',
2792
+ message: protocolV2BootloaderInfo,
2793
+ });
2630
2794
 
2631
2795
  (device as any).commands = { typedCall };
2632
2796
 
@@ -2642,23 +2806,221 @@ describe('Protocol V2 feature adapter', () => {
2642
2806
  }),
2643
2807
  expect.anything()
2644
2808
  );
2645
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
2809
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2810
+ eventless_wallet_session: true,
2811
+ });
2812
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2646
2813
  expect(device.features).toMatchObject({
2647
2814
  deviceId: null,
2648
2815
  bootloaderMode: true,
2816
+ mode: 'bootloader',
2649
2817
  initialized: null,
2650
2818
  });
2819
+ expect(device.isBootloader()).toBe(true);
2820
+ expect(device.isRomloader()).toBe(false);
2651
2821
  });
2652
2822
 
2653
- test('does not reinterpret a state update error as DeviceStatusGet failure', async () => {
2823
+ test('recognizes romloader from ProtocolInfo without calling DeviceStatusGet', async () => {
2654
2824
  const device = Device.fromDescriptor({
2655
2825
  path: 'usb-path',
2656
2826
  protocolType: 'V2',
2657
2827
  } as any);
2658
2828
  const typedCall = jest.fn().mockResolvedValue({
2659
- type: 'DeviceStatus',
2660
- message: { init_states: true, unlocked: true },
2829
+ type: 'ProtocolInfo',
2830
+ message: protocolV2RomloaderInfo,
2831
+ });
2832
+ (device as any).commands = { typedCall };
2833
+
2834
+ await device.probeProtocolV2RuntimeState({
2835
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2836
+ fw: { romloader: { version: '1.0.0' } },
2837
+ });
2838
+
2839
+ expect(typedCall).toHaveBeenCalledTimes(1);
2840
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2841
+ expect(device.features).toMatchObject({
2842
+ mode: 'romloader',
2843
+ bootloaderMode: true,
2844
+ initialized: null,
2845
+ });
2846
+ expect(device.isBootloader()).toBe(false);
2847
+ expect(device.isRomloader()).toBe(true);
2848
+ });
2849
+
2850
+ test('rejects Protocol V2 romloader mode for non-Pro2 devices', async () => {
2851
+ const device = Device.fromDescriptor({
2852
+ path: 'pro-v2-path',
2853
+ protocolType: 'V2',
2854
+ } as any);
2855
+ const typedCall = jest.fn().mockResolvedValue({
2856
+ type: 'ProtocolInfo',
2857
+ message: protocolV2RomloaderInfo,
2858
+ });
2859
+ (device as any).commands = { typedCall };
2860
+
2861
+ await expect(
2862
+ device.probeProtocolV2RuntimeState({
2863
+ hw: { Device_type: DeviceType.PRO, serial_no: 'PRO-SERIAL' },
2864
+ fw: { romloader: { version: '1.0.0' } },
2865
+ })
2866
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
2867
+
2868
+ expect(device.isRomloader()).not.toBe(true);
2869
+ expect(typedCall).toHaveBeenCalledTimes(1);
2870
+ });
2871
+
2872
+ test('keeps application mode without calling an unadvertised DeviceStatusGet', async () => {
2873
+ const device = Device.fromDescriptor({
2874
+ path: 'usb-path',
2875
+ protocolType: 'V2',
2876
+ } as any);
2877
+ const protocolInfo = {
2878
+ ...protocolV2ApplicationInfo,
2879
+ supported_messages: [],
2880
+ };
2881
+ const typedCall = jest.fn().mockResolvedValue({
2882
+ type: 'ProtocolInfo',
2883
+ message: protocolInfo,
2884
+ });
2885
+ (device as any).commands = { typedCall };
2886
+
2887
+ await device.probeProtocolV2RuntimeState({
2888
+ hw: { serial_no: 'PR2SERIAL' },
2889
+ fw: { application: { version: '1.2.3' } },
2890
+ });
2891
+
2892
+ expect(typedCall).toHaveBeenCalledTimes(1);
2893
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2894
+ expect(device.features).toMatchObject({
2895
+ mode: 'normal',
2896
+ bootloaderMode: false,
2897
+ initialized: null,
2898
+ });
2899
+ });
2900
+
2901
+ test('uses advertised DeviceStatusGet as compatibility fallback for an unknown fingerprint', async () => {
2902
+ const device = Device.fromDescriptor({
2903
+ path: 'usb-path',
2904
+ protocolType: 'V2',
2905
+ } as any);
2906
+ const protocolInfo = {
2907
+ ...protocolV2ApplicationInfo,
2908
+ build_fingerprint: 'legacy-fingerprint',
2909
+ };
2910
+ const typedCall = jest
2911
+ .fn()
2912
+ .mockResolvedValueOnce({ type: 'ProtocolInfo', message: protocolInfo })
2913
+ .mockResolvedValueOnce({
2914
+ type: 'DeviceStatus',
2915
+ message: { init_states: true, unlocked: true },
2916
+ });
2917
+ (device as any).commands = { typedCall };
2918
+
2919
+ await device.probeProtocolV2RuntimeState({
2920
+ hw: { serial_no: 'PR2SERIAL' },
2921
+ fw: { application: { version: '1.2.3' } },
2922
+ });
2923
+
2924
+ expect(typedCall).toHaveBeenCalledTimes(2);
2925
+ expect(device.features).toMatchObject({
2926
+ mode: 'normal',
2927
+ initialized: true,
2928
+ unlocked: true,
2929
+ });
2930
+ });
2931
+
2932
+ test('maps an explicit unsupported DeviceStatusGet response to bootloader for an unknown fingerprint', async () => {
2933
+ const device = Device.fromDescriptor({
2934
+ path: 'usb-path',
2935
+ protocolType: 'V2',
2936
+ } as any);
2937
+ const protocolInfo = {
2938
+ ...protocolV2ApplicationInfo,
2939
+ build_fingerprint: 'legacy-fingerprint',
2940
+ };
2941
+ const typedCall = jest
2942
+ .fn()
2943
+ .mockResolvedValueOnce({ type: 'ProtocolInfo', message: protocolInfo })
2944
+ .mockRejectedValueOnce(new Error('Failure_UnexpectedMessage,Unknown message'));
2945
+ (device as any).commands = { typedCall };
2946
+
2947
+ await device.probeProtocolV2RuntimeState({
2948
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2949
+ fw: { bootloader: { version: '0.2.0' } },
2950
+ });
2951
+
2952
+ expect(device.features).toMatchObject({
2953
+ mode: 'bootloader',
2954
+ bootloaderMode: true,
2955
+ initialized: null,
2661
2956
  });
2957
+ });
2958
+
2959
+ test('propagates DeviceStatusGet link failures instead of mapping them to bootloader', async () => {
2960
+ const device = Device.fromDescriptor({
2961
+ path: 'usb-path',
2962
+ protocolType: 'V2',
2963
+ } as any);
2964
+ const protocolInfo = {
2965
+ ...protocolV2ApplicationInfo,
2966
+ build_fingerprint: 'legacy-fingerprint',
2967
+ };
2968
+ const linkError = new Error('Protocol V2 CRC mismatch');
2969
+ const typedCall = jest
2970
+ .fn()
2971
+ .mockResolvedValueOnce({ type: 'ProtocolInfo', message: protocolInfo })
2972
+ .mockRejectedValueOnce(linkError);
2973
+ (device as any).commands = { typedCall };
2974
+
2975
+ await expect(
2976
+ device.probeProtocolV2RuntimeState({
2977
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2978
+ fw: { application: { version: '1.2.3' } },
2979
+ })
2980
+ ).rejects.toBe(linkError);
2981
+
2982
+ expect(device.features).toBeUndefined();
2983
+ });
2984
+
2985
+ test('fails safely when runtime fingerprint and capabilities are both unknown', async () => {
2986
+ const device = Device.fromDescriptor({
2987
+ path: 'usb-path',
2988
+ protocolType: 'V2',
2989
+ } as any);
2990
+ const typedCall = jest.fn().mockResolvedValue({
2991
+ type: 'ProtocolInfo',
2992
+ message: {
2993
+ version: 1,
2994
+ build_fingerprint: 'legacy-fingerprint',
2995
+ supported_messages: [],
2996
+ },
2997
+ });
2998
+ (device as any).commands = { typedCall };
2999
+
3000
+ await expect(
3001
+ device.probeProtocolV2RuntimeState({
3002
+ hw: { serial_no: 'PR2SERIAL' },
3003
+ fw: { application: { version: '1.2.3' } },
3004
+ })
3005
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
3006
+ expect(typedCall).toHaveBeenCalledTimes(1);
3007
+ });
3008
+
3009
+ test('does not reinterpret a state update error as runtime detection failure', async () => {
3010
+ const device = Device.fromDescriptor({
3011
+ path: 'usb-path',
3012
+ protocolType: 'V2',
3013
+ } as any);
3014
+ const typedCall = jest
3015
+ .fn()
3016
+ .mockResolvedValueOnce({
3017
+ type: 'ProtocolInfo',
3018
+ message: protocolV2ApplicationInfo,
3019
+ })
3020
+ .mockResolvedValueOnce({
3021
+ type: 'DeviceStatus',
3022
+ message: { init_states: true, unlocked: true },
3023
+ });
2662
3024
  const updateProtocolV2Features = jest
2663
3025
  .spyOn(device, 'updateProtocolV2Features')
2664
3026
  .mockImplementation(() => {
@@ -2673,12 +3035,13 @@ describe('Protocol V2 feature adapter', () => {
2673
3035
  })
2674
3036
  ).rejects.toThrow('state update failed');
2675
3037
 
2676
- expect(typedCall).toHaveBeenCalledTimes(1);
3038
+ expect(typedCall).toHaveBeenCalledTimes(2);
2677
3039
  expect(updateProtocolV2Features).toHaveBeenCalledTimes(1);
2678
3040
  expect(updateProtocolV2Features).toHaveBeenCalledWith(
2679
3041
  expect.anything(),
2680
3042
  expect.objectContaining({ init_states: true }),
2681
- 'normal'
3043
+ 'normal',
3044
+ protocolV2ApplicationInfo
2682
3045
  );
2683
3046
  });
2684
3047
 
@@ -2696,6 +3059,10 @@ describe('Protocol V2 feature adapter', () => {
2696
3059
  fw: { application: { version: '1.2.3' } },
2697
3060
  },
2698
3061
  })
3062
+ .mockResolvedValueOnce({
3063
+ type: 'ProtocolInfo',
3064
+ message: protocolV2ApplicationInfo,
3065
+ })
2699
3066
  .mockResolvedValueOnce({
2700
3067
  type: 'DeviceStatus',
2701
3068
  message: { init_states: true, unlocked: true },
@@ -2728,9 +3095,9 @@ describe('Protocol V2 feature adapter', () => {
2728
3095
  firmwareVersion: '1.2.4',
2729
3096
  passphraseProtection: null,
2730
3097
  });
2731
- expect(typedCall).toHaveBeenCalledTimes(4);
3098
+ expect(typedCall).toHaveBeenCalledTimes(5);
2732
3099
  expect(typedCall).toHaveBeenNthCalledWith(
2733
- 3,
3100
+ 4,
2734
3101
  'DeviceInfoGet',
2735
3102
  'DeviceInfo',
2736
3103
  {
@@ -2755,14 +3122,23 @@ describe('Protocol V2 feature adapter', () => {
2755
3122
 
2756
3123
  test('keeps Protocol V2 features available for method internals such as evmSignTypedData', async () => {
2757
3124
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2758
- const typedCall = jest.fn().mockResolvedValueOnce({
2759
- type: 'DeviceInfo',
2760
- message: {
2761
- hw: { serial_no: 'PR2SERIAL' },
2762
- fw: { application: { version: '1.2.4' } },
2763
- status: { init_states: true, passphrase_enabled: true },
2764
- },
2765
- });
3125
+ const typedCall = jest
3126
+ .fn()
3127
+ .mockResolvedValueOnce({
3128
+ type: 'DeviceInfo',
3129
+ message: {
3130
+ hw: { serial_no: 'PR2SERIAL' },
3131
+ fw: { application: { version: '1.2.4' } },
3132
+ },
3133
+ })
3134
+ .mockResolvedValueOnce({
3135
+ type: 'ProtocolInfo',
3136
+ message: protocolV2ApplicationInfo,
3137
+ })
3138
+ .mockResolvedValueOnce({
3139
+ type: 'DeviceStatus',
3140
+ message: { init_states: true, passphrase_enabled: true },
3141
+ });
2766
3142
  (device as any).commands = { typedCall };
2767
3143
 
2768
3144
  await device.initialize();
@@ -2833,9 +3209,23 @@ describe('Protocol V2 feature adapter', () => {
2833
3209
  status: { init_states: true },
2834
3210
  }
2835
3211
  );
3212
+ const pinOnDevice = jest.fn();
3213
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
2836
3214
 
2837
3215
  const features = await device.unlockDevice();
2838
3216
 
3217
+ expect(pinOnDevice).toHaveBeenCalledWith(
3218
+ device,
3219
+ DeviceSessionPinType.Main,
3220
+ expect.objectContaining({
3221
+ source: 'unlock-coordinator',
3222
+ reason: 'device-unlock',
3223
+ deviceOnly: true,
3224
+ })
3225
+ );
3226
+ expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3227
+ typedCall.mock.invocationCallOrder[0]
3228
+ );
2839
3229
  expect(typedCall.mock.calls).toEqual([
2840
3230
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
2841
3231
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -2881,9 +3271,12 @@ describe('Protocol V2 feature adapter', () => {
2881
3271
  throw new Error(`Unexpected request: ${requestType}`);
2882
3272
  });
2883
3273
  (device as any).commands = { typedCall };
3274
+ const pinOnDevice = jest.fn();
3275
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
2884
3276
 
2885
- await device.unlockDevice();
3277
+ await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
2886
3278
 
3279
+ expect(pinOnDevice).not.toHaveBeenCalled();
2887
3280
  expect(typedCall.mock.calls).toEqual([
2888
3281
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
2889
3282
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3362,6 +3755,12 @@ describe('Protocol V2 firmware update targets', () => {
3362
3755
  message: { init_states: true, unlocked: true },
3363
3756
  });
3364
3757
  }
3758
+ if (name === 'ProtocolInfoRequest') {
3759
+ return Promise.resolve({
3760
+ type: 'ProtocolInfo',
3761
+ message: protocolV2ApplicationInfo,
3762
+ });
3763
+ }
3365
3764
  return Promise.reject(new Error(`unexpected call ${name}`));
3366
3765
  });
3367
3766
  const commands = { typedCall };
@@ -3406,7 +3805,12 @@ describe('Protocol V2 firmware update targets', () => {
3406
3805
  },
3407
3806
  { timeoutMs: 5000 }
3408
3807
  );
3409
- expect(typedCall).toHaveBeenCalledTimes(2);
3808
+ expect(typedCall).toHaveBeenCalledTimes(3);
3809
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3810
+ 'DeviceInfoGet',
3811
+ 'ProtocolInfoRequest',
3812
+ 'DeviceStatusGet',
3813
+ ]);
3410
3814
  expect(typedCall).not.toHaveBeenCalledWith('Initialize', 'Features', {});
3411
3815
  expect(versions).toEqual({
3412
3816
  bootloaderVersion: '0.0.0',
@@ -3486,7 +3890,13 @@ describe('Protocol V2 firmware update targets', () => {
3486
3890
  message: protocolV2BootloaderDeviceInfo,
3487
3891
  });
3488
3892
  }
3489
- return Promise.reject(new Error('DeviceStatusGet unavailable in bootloader'));
3893
+ if (requestType === 'ProtocolInfoRequest') {
3894
+ return Promise.resolve({
3895
+ type: 'ProtocolInfo',
3896
+ message: protocolV2BootloaderInfo,
3897
+ });
3898
+ }
3899
+ return Promise.reject(new Error(`unexpected call ${requestType}`));
3490
3900
  });
3491
3901
  const reconnectProtocolV2Device = jest.fn().mockImplementation(() => {
3492
3902
  (method as any).device.isBootloader = () => true;
@@ -3513,10 +3923,17 @@ describe('Protocol V2 firmware update targets', () => {
3513
3923
  method: 'firmwareUpdateV4',
3514
3924
  },
3515
3925
  });
3926
+ const isRomloader = jest.fn(() => true);
3516
3927
  (method as any).device = stubDevice({
3517
3928
  originalDescriptor: { id: 'usb-id', path: 'romloader-path', protocolType: 'V2' },
3518
- features: { mode: 'romloader', bootloaderMode: false, capabilities: [] },
3929
+ features: {
3930
+ deviceType: 'pro2',
3931
+ mode: 'romloader',
3932
+ bootloaderMode: false,
3933
+ capabilities: [],
3934
+ },
3519
3935
  isBootloader: () => false,
3936
+ isRomloader,
3520
3937
  });
3521
3938
  (method as any).protocolV2Reboot = jest
3522
3939
  .fn()
@@ -3525,10 +3942,52 @@ describe('Protocol V2 firmware update targets', () => {
3525
3942
 
3526
3943
  await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
3527
3944
 
3945
+ expect(isRomloader).toHaveBeenCalledTimes(1);
3528
3946
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3529
3947
  expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
3530
3948
  });
3531
3949
 
3950
+ test('starts Protocol V2 firmware transfer directly from romloader mode', async () => {
3951
+ const method = new FirmwareUpdateV4({
3952
+ id: 1,
3953
+ payload: {
3954
+ method: 'firmwareUpdateV4',
3955
+ platform: 'web',
3956
+ coprocessorBinary: new Uint8Array([1]).buffer,
3957
+ },
3958
+ });
3959
+ method.init();
3960
+ (method as any).device = stubDevice({
3961
+ originalDescriptor: { id: 'usb-id', path: 'romloader-path', protocolType: 'V2' },
3962
+ features: {
3963
+ deviceType: 'pro2',
3964
+ firmwareVersion: '0.0.0',
3965
+ mode: 'romloader',
3966
+ bootloaderMode: true,
3967
+ capabilities: [],
3968
+ },
3969
+ });
3970
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3971
+ (method as any).protocolV2Reboot = jest.fn();
3972
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3973
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3974
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
3975
+ bootloaderVersion: '1.0.0',
3976
+ bleVersion: '0.0.0',
3977
+ firmwareVersion: '1.0.0',
3978
+ });
3979
+ method.postTipMessage = jest.fn();
3980
+
3981
+ await method.run();
3982
+
3983
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3984
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3985
+ bootloaderBinary: null,
3986
+ fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
3987
+ });
3988
+ expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
3989
+ });
3990
+
3532
3991
  test('polls until Protocol V2 bootloader descriptor is ready after reboot', async () => {
3533
3992
  const method = new FirmwareUpdateV4({
3534
3993
  id: 1,
@@ -3548,7 +4007,13 @@ describe('Protocol V2 firmware update targets', () => {
3548
4007
  message: protocolV2BootloaderDeviceInfo,
3549
4008
  });
3550
4009
  }
3551
- return Promise.reject(new Error('DeviceStatusGet unavailable in bootloader'));
4010
+ if (requestType === 'ProtocolInfoRequest') {
4011
+ return Promise.resolve({
4012
+ type: 'ProtocolInfo',
4013
+ message: protocolV2BootloaderInfo,
4014
+ });
4015
+ }
4016
+ return Promise.reject(new Error(`unexpected call ${requestType}`));
3552
4017
  });
3553
4018
  const reconnectProtocolV2Device = jest
3554
4019
  .fn()
@@ -3565,8 +4030,11 @@ describe('Protocol V2 firmware update targets', () => {
3565
4030
  await (method as any).waitForProtocolV2BootloaderMode(60 * 1000, 0);
3566
4031
 
3567
4032
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
3568
- expect(typedCall).toHaveBeenCalledTimes(1);
3569
- expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
4033
+ expect(typedCall).toHaveBeenCalledTimes(2);
4034
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4035
+ 'DeviceInfoGet',
4036
+ 'ProtocolInfoRequest',
4037
+ ]);
3570
4038
  });
3571
4039
 
3572
4040
  test('does not run generic initialize during Protocol V2 USB firmware reconnect', async () => {
@@ -4027,6 +4495,19 @@ describe('Protocol V2 firmware update targets', () => {
4027
4495
  )
4028
4496
  ).toBe(true);
4029
4497
 
4498
+ (method.postProgressMessage as jest.Mock).mockClear();
4499
+ expect(
4500
+ (method as any).assertProtocolV2TargetStatus(
4501
+ [
4502
+ { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
4503
+ { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
4504
+ ],
4505
+ new Set([4, 10])
4506
+ )
4507
+ ).toBe(false);
4508
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4509
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
4510
+
4030
4511
  expect(
4031
4512
  (method as any).assertProtocolV2TargetStatus([{ target_id: 4, status: 1 }], expectedTargetIds)
4032
4513
  ).toBe(false);
@@ -4632,7 +5113,7 @@ describe('Protocol V2 firmware update targets', () => {
4632
5113
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4633
5114
 
4634
5115
  const order: string[] = [];
4635
- const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
5116
+ const resourceBinary = buildOkppHeader().buffer as ArrayBuffer;
4636
5117
  const getSysResourceBinarySpy = jest
4637
5118
  .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
4638
5119
  .mockImplementation(() => {
@@ -4700,6 +5181,31 @@ describe('Protocol V2 firmware update targets', () => {
4700
5181
  getFirmwareLatestReleaseSpy.mockRestore();
4701
5182
  });
4702
5183
 
5184
+ test('rejects a remote RESC bundle without an OKPP header when metadata is absent', async () => {
5185
+ const method = new FirmwareUpdateV4({
5186
+ id: 1,
5187
+ payload: {
5188
+ method: 'firmwareUpdateV4',
5189
+ },
5190
+ });
5191
+ const getSysResourceBinarySpy = jest
5192
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5193
+ .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5194
+
5195
+ await expect(
5196
+ (method as any).prefetchProtocolV2ResourceBundles([
5197
+ {
5198
+ name: 'images',
5199
+ binary: new ArrayBuffer(0),
5200
+ devicePath: 'vol0:/resource/images/images.okpkg',
5201
+ url: 'https://example.com/images.okpkg',
5202
+ },
5203
+ ])
5204
+ ).rejects.toThrow('Invalid Protocol V2 RESC bundle header: images');
5205
+
5206
+ getSysResourceBinarySpy.mockRestore();
5207
+ });
5208
+
4703
5209
  test('rejects an empty remote RESC bundle before entering bootloader', async () => {
4704
5210
  const method = new FirmwareUpdateV4({
4705
5211
  id: 1,
@@ -5768,7 +6274,10 @@ describe('Protocol V2 protected method execution', () => {
5768
6274
  }
5769
6275
  if (requestType === 'DeviceSessionGet') {
5770
6276
  calls.push('resume-hidden-session');
5771
- expect(request).toEqual({ session_id: 'hidden-session' });
6277
+ expect(request).toEqual({
6278
+ session_id: 'hidden-session',
6279
+ btc_test_address: 'hidden-state',
6280
+ });
5772
6281
  return Promise.resolve({
5773
6282
  message: {
5774
6283
  session_id: 'hidden-session',
@@ -5782,6 +6291,10 @@ describe('Protocol V2 protected method execution', () => {
5782
6291
  features: { unlocked: true, passphraseProtection: true },
5783
6292
  passphraseState: 'hidden-state',
5784
6293
  commands: { typedCall },
6294
+ ensureProtocolV2RuntimeContext: () =>
6295
+ typedCall('ProtocolInfoRequest', 'ProtocolInfo', {
6296
+ eventless_wallet_session: true,
6297
+ }).then(response => response.message),
5785
6298
  isProtocolV2: () => true,
5786
6299
  unlockDevice: jest.fn(() => {
5787
6300
  calls.push('unlock');
@@ -5830,6 +6343,10 @@ describe('Protocol V2 protected method execution', () => {
5830
6343
  features: { unlocked: true, passphraseProtection: true },
5831
6344
  passphraseState: 'hidden-state',
5832
6345
  commands: { typedCall },
6346
+ ensureProtocolV2RuntimeContext: () =>
6347
+ typedCall('ProtocolInfoRequest', 'ProtocolInfo', {
6348
+ eventless_wallet_session: true,
6349
+ }).then(response => response.message),
5833
6350
  isProtocolV2: () => true,
5834
6351
  unlockDevice: jest.fn().mockResolvedValue(undefined),
5835
6352
  getCurrentPassphraseProtection: () => true,
@@ -5865,7 +6382,10 @@ describe('Protocol V2 protected method execution', () => {
5865
6382
  }
5866
6383
  if (requestType === 'DeviceSessionGet') {
5867
6384
  calls.push('resume-hidden-session');
5868
- expect(request).toEqual({ session_id: 'hidden-session' });
6385
+ expect(request).toEqual({
6386
+ session_id: 'hidden-session',
6387
+ btc_test_address: 'hidden-state',
6388
+ });
5869
6389
  return Promise.resolve({
5870
6390
  message: {
5871
6391
  session_id: 'hidden-session',
@@ -5884,6 +6404,10 @@ describe('Protocol V2 protected method execution', () => {
5884
6404
  features,
5885
6405
  passphraseState: 'hidden-state',
5886
6406
  commands: { typedCall },
6407
+ ensureProtocolV2RuntimeContext: () =>
6408
+ typedCall('ProtocolInfoRequest', 'ProtocolInfo', {
6409
+ eventless_wallet_session: true,
6410
+ }).then(response => response.message),
5887
6411
  isProtocolV2: () => true,
5888
6412
  unlockDevice: jest.fn(() => {
5889
6413
  calls.push('unlock-main');
@@ -5901,7 +6425,12 @@ describe('Protocol V2 protected method execution', () => {
5901
6425
  await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
5902
6426
  message: 'ok',
5903
6427
  });
5904
- expect(device.unlockDevice).toHaveBeenCalledWith();
6428
+ expect(device.unlockDevice).toHaveBeenCalledWith(undefined, {
6429
+ source: 'unlock-coordinator',
6430
+ reason: 'device-locked',
6431
+ deviceOnly: true,
6432
+ method: 'evmSignMessage',
6433
+ });
5905
6434
  expect(calls).toEqual([
5906
6435
  'unlock-main',
5907
6436
  'negotiate-session',
@@ -5933,7 +6462,7 @@ describe('Protocol V2 protected method execution', () => {
5933
6462
  expect(method.run).toHaveBeenCalledTimes(1);
5934
6463
  });
5935
6464
 
5936
- test('unlocks before showing the method interaction when cached status is locked', async () => {
6465
+ test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
5937
6466
  const calls: string[] = [];
5938
6467
  const method = {
5939
6468
  name: 'deviceSettingsPageShow',
@@ -5944,11 +6473,23 @@ describe('Protocol V2 protected method execution', () => {
5944
6473
  return Promise.resolve({ message: 'ok' });
5945
6474
  }),
5946
6475
  };
6476
+ const features = { unlocked: true };
5947
6477
  const device = {
5948
- features: { unlocked: false },
6478
+ features,
6479
+ commands: {
6480
+ typedCall: jest.fn(() => {
6481
+ calls.push('refresh-status');
6482
+ return Promise.resolve({ message: { unlocked: false } });
6483
+ }),
6484
+ },
5949
6485
  isProtocolV2: () => true,
6486
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6487
+ features.unlocked = status.unlocked ?? features.unlocked;
6488
+ return features;
6489
+ }),
5950
6490
  unlockDevice: jest.fn(() => {
5951
6491
  calls.push('unlock');
6492
+ features.unlocked = true;
5952
6493
  return Promise.resolve();
5953
6494
  }),
5954
6495
  };
@@ -5961,12 +6502,66 @@ describe('Protocol V2 protected method execution', () => {
5961
6502
  await expect(
5962
6503
  runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
5963
6504
  ).resolves.toEqual({ message: 'ok' });
5964
- expect(calls).toEqual(['unlock-prompt', 'unlock', 'method-prompt', 'run']);
6505
+ expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6506
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
5965
6507
  expect(method.run).toHaveBeenCalledTimes(1);
5966
6508
  expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
5967
6509
  expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
5968
6510
  });
5969
6511
 
6512
+ test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6513
+ const features = { unlocked: false };
6514
+ const method = {
6515
+ name: 'deviceSettings',
6516
+ unlockPolicy: 'unlock-before-run',
6517
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6518
+ };
6519
+ const device = {
6520
+ features,
6521
+ commands: {
6522
+ typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6523
+ },
6524
+ isProtocolV2: () => true,
6525
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6526
+ features.unlocked = status.unlocked ?? features.unlocked;
6527
+ return features;
6528
+ }),
6529
+ unlockDevice: jest.fn(),
6530
+ };
6531
+
6532
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6533
+ message: 'ok',
6534
+ });
6535
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6536
+ expect(method.run).toHaveBeenCalledTimes(1);
6537
+ });
6538
+
6539
+ test.each(['bootloader', 'romloader'] as const)(
6540
+ 'skips DeviceStatus and unlock in Protocol V2 %s mode',
6541
+ async mode => {
6542
+ const method = {
6543
+ name: 'firmwareUpdateV4',
6544
+ unlockPolicy: 'unlock-before-run',
6545
+ run: jest.fn().mockResolvedValue({ message: 'updating' }),
6546
+ };
6547
+ const device = {
6548
+ features: { unlocked: false },
6549
+ commands: { typedCall: jest.fn() },
6550
+ isProtocolV2: () => true,
6551
+ isBootloader: () => mode === 'bootloader',
6552
+ isRomloader: () => mode === 'romloader',
6553
+ unlockDevice: jest.fn(),
6554
+ };
6555
+
6556
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6557
+ message: 'updating',
6558
+ });
6559
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6560
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6561
+ expect(method.run).toHaveBeenCalledTimes(1);
6562
+ }
6563
+ );
6564
+
5970
6565
  test('infers a signing interaction before running a Protocol V2 business method', async () => {
5971
6566
  const method = {
5972
6567
  name: 'evmSignMessage',
@@ -6045,6 +6640,38 @@ describe('Protocol V2 protected method execution', () => {
6045
6640
  expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6046
6641
  });
6047
6642
 
6643
+ test('keeps the PIN prompt when an eventless method actually unlocks the device', async () => {
6644
+ const lockedError = deviceLockedError();
6645
+ const method = {
6646
+ name: 'backgroundRead',
6647
+ unlockPolicy: 'retry-on-locked',
6648
+ protocolV2UiMode: 'none',
6649
+ run: jest.fn().mockRejectedValueOnce(lockedError).mockResolvedValueOnce({ message: 'ok' }),
6650
+ };
6651
+ const device = {
6652
+ isProtocolV2: () => true,
6653
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6654
+ };
6655
+ const uiCoordinator = {
6656
+ enterMethodInteraction: jest.fn(),
6657
+ enterUnlockInteraction: jest.fn(),
6658
+ resumeMethodInteraction: jest.fn(),
6659
+ };
6660
+
6661
+ await expect(
6662
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6663
+ ).resolves.toEqual({ message: 'ok' });
6664
+ expect(device.unlockDevice).toHaveBeenCalledWith(undefined, {
6665
+ source: 'unlock-coordinator',
6666
+ reason: 'device-locked',
6667
+ deviceOnly: true,
6668
+ method: 'backgroundRead',
6669
+ });
6670
+ expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6671
+ expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6672
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6673
+ });
6674
+
6048
6675
  test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6049
6676
  const error = deviceLockedError();
6050
6677
  const method = {
@@ -6249,7 +6876,9 @@ describe('Protocol V2 current low-level methods', () => {
6249
6876
 
6250
6877
  await method.run();
6251
6878
 
6252
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
6879
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
6880
+ eventless_wallet_session: true,
6881
+ });
6253
6882
  });
6254
6883
 
6255
6884
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {