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

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 (65) hide show
  1. package/__tests__/DeviceCommands.test.ts +84 -0
  2. package/__tests__/device-settings.test.ts +3 -0
  3. package/__tests__/device-wallet-session-store.test.ts +80 -7
  4. package/__tests__/evmSignTransaction.test.ts +69 -0
  5. package/__tests__/get-device-state.test.ts +78 -30
  6. package/__tests__/open-wallet-session.test.ts +186 -51
  7. package/__tests__/protocol-v2.test.ts +490 -112
  8. package/dist/api/FirmwareUpdateV4.d.ts +1 -0
  9. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  10. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  11. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  12. package/dist/api/evm/EVMSignTypedData.d.ts.map +1 -1
  13. package/dist/api/evm/protocol.d.ts +3 -0
  14. package/dist/api/evm/protocol.d.ts.map +1 -0
  15. package/dist/api/protocol-v2/DeviceGetOnboardingStatus.d.ts +1 -1
  16. package/dist/device/Device.d.ts +3 -2
  17. package/dist/device/Device.d.ts.map +1 -1
  18. package/dist/device/DeviceCommands.d.ts.map +1 -1
  19. package/dist/device/DeviceWalletSessionStore.d.ts +0 -1
  20. package/dist/device/DeviceWalletSessionStore.d.ts.map +1 -1
  21. package/dist/index.d.ts +8 -6
  22. package/dist/index.js +606 -641
  23. package/dist/protocols/protocol-v2/features.d.ts +17 -6
  24. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  25. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  26. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  27. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts +1 -0
  28. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts.map +1 -1
  29. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -1
  30. package/dist/protocols/protocol-v2/walletSession.d.ts +1 -0
  31. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  32. package/dist/types/api/openWalletSession.d.ts +1 -1
  33. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  34. package/dist/types/api/protocolV2.d.ts +2 -2
  35. package/dist/types/api/protocolV2.d.ts.map +1 -1
  36. package/dist/types/device.d.ts +2 -1
  37. package/dist/types/device.d.ts.map +1 -1
  38. package/dist/utils/patch.d.ts +1 -1
  39. package/dist/utils/patch.d.ts.map +1 -1
  40. package/package.json +4 -4
  41. package/src/api/FirmwareUpdateV4.ts +26 -8
  42. package/src/api/GetPassphraseState.ts +11 -0
  43. package/src/api/OpenWalletSession.ts +15 -7
  44. package/src/api/evm/EVMGetAddress.ts +2 -2
  45. package/src/api/evm/EVMGetPublicKey.ts +2 -2
  46. package/src/api/evm/EVMSignMessage.ts +2 -2
  47. package/src/api/evm/EVMSignTransaction.ts +2 -2
  48. package/src/api/evm/EVMSignTypedData.ts +35 -45
  49. package/src/api/evm/EVMVerifyMessage.ts +2 -2
  50. package/src/api/evm/protocol.ts +6 -0
  51. package/src/api/protocol-v2/DeviceGetOnboardingStatus.ts +2 -2
  52. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +1 -1
  53. package/src/data/messages/messages-protocol-v2.json +393 -485
  54. package/src/device/Device.ts +63 -37
  55. package/src/device/DeviceCommands.ts +12 -2
  56. package/src/device/DeviceWalletSessionStore.ts +0 -14
  57. package/src/deviceProfile/buildDeviceFeatures.ts +1 -1
  58. package/src/protocols/protocol-v2/features.ts +58 -16
  59. package/src/protocols/protocol-v2/index.ts +5 -0
  60. package/src/protocols/protocol-v2/settingsUnlockPolicy.ts +8 -3
  61. package/src/protocols/protocol-v2/unlockRetry.ts +18 -7
  62. package/src/protocols/protocol-v2/walletSession.ts +35 -35
  63. package/src/types/api/openWalletSession.ts +1 -1
  64. package/src/types/api/protocolV2.ts +2 -2
  65. package/src/types/device.ts +2 -0
@@ -64,11 +64,15 @@ import { DevicePool } from '../src/device/DevicePool';
64
64
  import { UI_REQUEST } from '../src/events/ui-request';
65
65
  import {
66
66
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
67
+ PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE,
67
68
  type ProtocolV2DeviceInfo,
68
69
  type ProtocolV2RuntimeMode,
69
70
  getProtocolV2RuntimeMode,
71
+ parseProtocolV2BuildFingerprint,
70
72
  requestProtocolV2DeviceInfo,
73
+ requestProtocolV2ProtocolInfo,
71
74
  requestProtocolV2DeviceStatus,
75
+ supportsProtocolV2Message,
72
76
  } from '../src/protocols/protocol-v2/features';
73
77
  import {
74
78
  getProtocolV2WalletSession,
@@ -95,7 +99,7 @@ import {
95
99
 
96
100
  import type { DeviceCommands } from '../src/device/DeviceCommands';
97
101
  import type { Features } from '../src/types';
98
- import type { DeviceStatus } from '@onekeyfe/hd-transport';
102
+ import type { DeviceStatus, ProtocolInfo } from '@onekeyfe/hd-transport';
99
103
 
100
104
  jest.mock('../src/data/config', () => ({
101
105
  getSDKVersion: jest.fn(() => '1.0.0'),
@@ -143,7 +147,7 @@ describe('DeviceUploadWallpaper', () => {
143
147
  const result = await method.run();
144
148
 
145
149
  expect(method.getSupportedProtocols()).toEqual(['V2']);
146
- expect(method.unlockPolicy).toBe('unlock-before-run');
150
+ expect(method.unlockPolicy).toBe('none');
147
151
  expect(typedCall).toHaveBeenNthCalledWith(1, 'FilesystemDirMake', 'Success', {
148
152
  path: 'vol1:/wallpapers',
149
153
  });
@@ -347,6 +351,24 @@ const descriptor = {
347
351
  path: 'usb-path',
348
352
  };
349
353
 
354
+ const protocolV2ApplicationInfo = {
355
+ version: 1,
356
+ build_fingerprint: 'application__1.2.3__abcdef0__PROD__RELEASE',
357
+ supported_messages: [PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE],
358
+ };
359
+
360
+ const protocolV2BootloaderInfo = {
361
+ version: 1,
362
+ build_fingerprint: 'bootloader__0.2.0__abcdef0__PROD__RELEASE',
363
+ supported_messages: [],
364
+ };
365
+
366
+ const protocolV2RomloaderInfo = {
367
+ version: 1,
368
+ build_fingerprint: 'romloader__1.0.0__abcdef0__PROD__RELEASE',
369
+ supported_messages: [],
370
+ };
371
+
350
372
  /**
351
373
  * Add Device protocol and getCurrent* accessors to plain-object stubs.
352
374
  * Defaults match Device.ts and never replace an existing member such as jest.fn().
@@ -356,6 +378,8 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
356
378
  d.updateState ??= jest.fn();
357
379
  d.markProtocolV2Reboot ??= jest.fn();
358
380
  d.isProtocolV2 ??= () => d.originalDescriptor?.protocolType === 'V2';
381
+ d.isBootloader ??= () => d.features?.mode === 'bootloader';
382
+ d.isRomloader ??= () => d.features?.mode === 'romloader';
359
383
  d.getProtocol ??= () => (d.isProtocolV2() ? 'V2' : 'V1');
360
384
  d.getCurrentDeviceType ??= () => getDeviceType(d.features);
361
385
  d.getCurrentFirmwareType ??= () => getFirmwareType(d.features);
@@ -375,7 +399,8 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
375
399
  d.updateProtocolV2Features ??= (
376
400
  deviceInfo?: ProtocolV2DeviceInfo,
377
401
  deviceStatus?: DeviceStatus,
378
- runtimeMode?: ProtocolV2RuntimeMode
402
+ runtimeMode?: ProtocolV2RuntimeMode,
403
+ protocolInfo?: ProtocolInfo
379
404
  ) => {
380
405
  d.features = buildProtocolV2FeaturesPayload({
381
406
  deviceInfo,
@@ -383,44 +408,35 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
383
408
  previous: d.features,
384
409
  runtimeMode: runtimeMode ?? (deviceStatus ? 'normal' : undefined),
385
410
  });
411
+ if (protocolInfo) {
412
+ d.features.raw = {
413
+ ...d.features.raw,
414
+ protocolV2ProtocolInfo: protocolInfo,
415
+ };
416
+ }
386
417
  return d.features;
387
418
  };
388
419
  d.probeProtocolV2RuntimeState ??= async (
389
420
  deviceInfo: ProtocolV2DeviceInfo,
390
421
  timeoutMs?: number
391
422
  ) => {
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
- });
423
+ const protocolInfo = await requestProtocolV2ProtocolInfo({
424
+ commands: d.getCommands(),
425
+ timeoutMs,
426
+ });
427
+ const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
428
+ if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
429
+ return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
430
+ }
431
+ if (!supportsProtocolV2Message(protocolInfo, PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE)) {
432
+ if (!runtimeMode) throw new Error('Unknown Protocol V2 runtime mode');
433
+ return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
423
434
  }
435
+ const deviceStatus = await requestProtocolV2DeviceStatus({
436
+ commands: d.getCommands(),
437
+ timeoutMs,
438
+ });
439
+ return d.updateProtocolV2Features(deviceInfo, deviceStatus, 'normal', protocolInfo);
424
440
  };
425
441
  d.updateProtocolV2Status ??= (deviceStatus: DeviceStatus) => {
426
442
  d.features = buildProtocolV2FeaturesPayload({
@@ -680,7 +696,7 @@ describe('Protocol V2 feature adapter', () => {
680
696
  expect(features.bleEnabled).toBeNull();
681
697
  });
682
698
 
683
- test('marks Protocol V2 DeviceInfo as bootloader when DeviceStatusGet fails', () => {
699
+ test('parses bootloader runtime mode from ProtocolInfo build fingerprint', () => {
684
700
  const deviceInfo = {
685
701
  protocol_version: 1,
686
702
  hw: {
@@ -693,10 +709,7 @@ describe('Protocol V2 feature adapter', () => {
693
709
  },
694
710
  se1: {},
695
711
  };
696
- const runtimeMode = getProtocolV2RuntimeMode({
697
- deviceInfo: deviceInfo as ProtocolV2DeviceInfo,
698
- deviceStatusAvailable: false,
699
- });
712
+ const runtimeMode = getProtocolV2RuntimeMode(protocolV2BootloaderInfo);
700
713
  const features = normalizeProtocolV2Features(
701
714
  descriptor as any,
702
715
  deviceInfo as ProtocolV2DeviceInfo,
@@ -718,7 +731,7 @@ describe('Protocol V2 feature adapter', () => {
718
731
  expect(features.bootloaderMode).toBe(false);
719
732
  });
720
733
 
721
- test('temporarily treats romloader metadata as bootloader when DeviceStatusGet fails', () => {
734
+ test('keeps romloader distinct from bootloader using ProtocolInfo build fingerprint', () => {
722
735
  const deviceInfo = {
723
736
  protocol_version: 1,
724
737
  hw: {
@@ -733,20 +746,28 @@ describe('Protocol V2 feature adapter', () => {
733
746
  },
734
747
  },
735
748
  };
736
- const runtimeMode = getProtocolV2RuntimeMode({
737
- deviceInfo: deviceInfo as ProtocolV2DeviceInfo,
738
- deviceStatusAvailable: false,
739
- });
749
+ const runtimeMode = getProtocolV2RuntimeMode(protocolV2RomloaderInfo);
740
750
  const features = normalizeProtocolV2Features(
741
751
  descriptor as any,
742
752
  deviceInfo as ProtocolV2DeviceInfo,
743
753
  runtimeMode
744
754
  );
745
- expect(features.mode).toBe('bootloader');
755
+ expect(features.mode).toBe('romloader');
746
756
  expect(features.bootloaderMode).toBe(true);
747
757
  expect(features.boardVersion).toBe('1.0.0');
748
758
  });
749
759
 
760
+ test('rejects malformed Protocol V2 build fingerprints', () => {
761
+ expect(parseProtocolV2BuildFingerprint('application__1.2.3')).toBeNull();
762
+ expect(
763
+ getProtocolV2RuntimeMode({
764
+ version: 1,
765
+ build_fingerprint: 'unknown__1.2.3__abcdef0__PROD__RELEASE',
766
+ supported_messages: [],
767
+ })
768
+ ).toBeUndefined();
769
+ });
770
+
750
771
  test('keeps the Protocol V2 main wallet on the default empty-passphrase context', async () => {
751
772
  const features = normalizeProtocolV2Features(descriptor as any);
752
773
  features.firmwareVersion = '1.2.3';
@@ -908,7 +929,7 @@ describe('Protocol V2 feature adapter', () => {
908
929
  expect(deviceWalletSessionStore.get(deviceId, 'hidden-state')).toBe('hidden-session');
909
930
  });
910
931
 
911
- test('opens Main PIN directly instead of resuming a standard session while locked', async () => {
932
+ test('propagates DeviceLocked instead of retrying a cached standard session', async () => {
912
933
  const deviceId = 'standard-locked-device';
913
934
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
914
935
  (device as any).features = normalizeProtocolV2Features(
@@ -950,14 +971,11 @@ describe('Protocol V2 feature adapter', () => {
950
971
  });
951
972
  (device as any).commands = { typedCall };
952
973
 
953
- await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).resolves.toMatchObject({
954
- newSession: 'cached-standard-session',
955
- resumed: true,
974
+ await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).rejects.toMatchObject({
975
+ errorCode: HardwareErrorCode.DeviceLocked,
956
976
  });
957
- expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(2);
958
- expect(deviceWalletSessionStore.getStandard(deviceId)?.sessionId).toBe(
959
- 'cached-standard-session'
960
- );
977
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
978
+ expect(deviceWalletSessionStore.getStandard(deviceId)).toBeUndefined();
961
979
  });
962
980
 
963
981
  test('recovers a mismatched cached standard wallet without deleting hidden sessions', async () => {
@@ -1083,7 +1101,7 @@ describe('Protocol V2 feature adapter', () => {
1083
1101
  });
1084
1102
  });
1085
1103
 
1086
- test('uses refreshed passphrase status after unlocking before selecting a Protocol V2 wallet', async () => {
1104
+ test('does not implicitly unlock inside the Protocol V2 wallet-session coordinator', async () => {
1087
1105
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1088
1106
  (device as any).features = normalizeProtocolV2Features(
1089
1107
  { ...descriptor, protocolType: 'V2' } as any,
@@ -1094,13 +1112,9 @@ describe('Protocol V2 feature adapter', () => {
1094
1112
  },
1095
1113
  }
1096
1114
  );
1097
- const lockedError = Object.assign(new Error('Device is locked'), {
1098
- errorCode: HardwareErrorCode.DeviceLocked,
1099
- });
1100
1115
  const typedCall = createWalletSessionTypedCall(
1101
1116
  jest
1102
1117
  .fn()
1103
- .mockRejectedValueOnce(lockedError)
1104
1118
  .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1105
1119
  .mockResolvedValueOnce({
1106
1120
  message: {
@@ -1127,7 +1141,7 @@ describe('Protocol V2 feature adapter', () => {
1127
1141
  resumed: false,
1128
1142
  });
1129
1143
 
1130
- expect(unlockDevice).toHaveBeenCalledTimes(1);
1144
+ expect(unlockDevice).not.toHaveBeenCalled();
1131
1145
  expect(promptPassphrase).toHaveBeenCalled();
1132
1146
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1133
1147
  eventless_wallet_session: true,
@@ -1199,7 +1213,7 @@ describe('Protocol V2 feature adapter', () => {
1199
1213
  throw new Error(`Unexpected request: ${request}`);
1200
1214
  })
1201
1215
  );
1202
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1216
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1203
1217
  (device as any).commands = { typedCall, promptPassphrase };
1204
1218
 
1205
1219
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
@@ -1209,7 +1223,8 @@ describe('Protocol V2 feature adapter', () => {
1209
1223
 
1210
1224
  expect(promptPassphrase).toHaveBeenCalled();
1211
1225
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
1212
- on_device: true,
1226
+ passphrase: 'host hidden wallet',
1227
+ on_device: false,
1213
1228
  });
1214
1229
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1215
1230
  });
@@ -1240,7 +1255,7 @@ describe('Protocol V2 feature adapter', () => {
1240
1255
 
1241
1256
  test('deviceGetOnboardingStatus returns the current Protocol V2 onboarding status', async () => {
1242
1257
  const typedCall = jest.fn().mockResolvedValue({
1243
- type: 'DevOnboardingStatus',
1258
+ type: 'OnboardingStatus',
1244
1259
  message: {
1245
1260
  step: 4,
1246
1261
  phase: 4,
@@ -1268,7 +1283,7 @@ describe('Protocol V2 feature adapter', () => {
1268
1283
  pin_set: true,
1269
1284
  wallet_initialized: false,
1270
1285
  });
1271
- expect(typedCall).toHaveBeenCalledWith('DevGetOnboardingStatus', 'DevOnboardingStatus', {});
1286
+ expect(typedCall).toHaveBeenCalledWith('OnboardingStatusGet', 'OnboardingStatus', {});
1272
1287
  expect(method.getSupportedProtocols()).toEqual(['V2']);
1273
1288
  });
1274
1289
 
@@ -1420,7 +1435,7 @@ describe('Protocol V2 feature adapter', () => {
1420
1435
  );
1421
1436
  (device as any).commands = {
1422
1437
  typedCall,
1423
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1438
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1424
1439
  };
1425
1440
 
1426
1441
  await expect(getProtocolV2WalletSession(device)).rejects.toMatchObject({
@@ -1428,7 +1443,11 @@ describe('Protocol V2 feature adapter', () => {
1428
1443
  });
1429
1444
  expect(typedCall.mock.calls).toEqual([
1430
1445
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1431
- ['DeviceSessionAskPassphrase', 'Success', { on_device: true }],
1446
+ [
1447
+ 'DeviceSessionAskPassphrase',
1448
+ 'Success',
1449
+ { passphrase: 'host hidden wallet', on_device: false },
1450
+ ],
1432
1451
  ['DeviceStatusGet', 'DeviceStatus', {}],
1433
1452
  ['DeviceSessionGet', 'DeviceSession', {}],
1434
1453
  ]);
@@ -1469,7 +1488,7 @@ describe('Protocol V2 feature adapter', () => {
1469
1488
  throw new Error(`Unexpected request: ${request}`);
1470
1489
  })
1471
1490
  );
1472
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1491
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1473
1492
  (device as any).commands = { typedCall, promptPassphrase };
1474
1493
 
1475
1494
  await expect(
@@ -1522,7 +1541,7 @@ describe('Protocol V2 feature adapter', () => {
1522
1541
  );
1523
1542
  (device as any).commands = {
1524
1543
  typedCall,
1525
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1544
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1526
1545
  };
1527
1546
 
1528
1547
  await expect(
@@ -1593,7 +1612,7 @@ describe('Protocol V2 feature adapter', () => {
1593
1612
  expect(device.getInternalState()).toBe('new-attach-session');
1594
1613
  });
1595
1614
 
1596
- test('unlocks and retries DeviceSessionAskPassphrase when wallet selection is locked', async () => {
1615
+ test('propagates DeviceLocked without retrying DeviceSessionAskPassphrase', async () => {
1597
1616
  const device = Device.fromDescriptor({
1598
1617
  id: 'cache-device-4',
1599
1618
  path: 'cache-path-4',
@@ -1620,16 +1639,61 @@ describe('Protocol V2 feature adapter', () => {
1620
1639
  );
1621
1640
  (device as any).commands = {
1622
1641
  typedCall,
1623
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1642
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1624
1643
  };
1625
1644
  const unlockDevice = jest.spyOn(device, 'unlockDevice').mockResolvedValue(undefined as any);
1626
1645
 
1646
+ await expect(getProtocolV2WalletSession(device)).rejects.toBe(lockedError);
1647
+ expect(unlockDevice).not.toHaveBeenCalled();
1648
+ expect(
1649
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
1650
+ ).toHaveLength(1);
1651
+ });
1652
+
1653
+ test('does not act on cached lock state inside the wallet-session coordinator', async () => {
1654
+ const calls: string[] = [];
1655
+ const device = Device.fromDescriptor({
1656
+ id: 'cache-device-pre-unlock',
1657
+ path: 'cache-path-pre-unlock',
1658
+ protocolType: 'V2',
1659
+ } as any);
1660
+ (device as any).features = normalizeProtocolV2Features(
1661
+ { ...descriptor, protocolType: 'V2' } as any,
1662
+ { status: { device_id: 'stable-device-pre-unlock', unlocked: false } }
1663
+ );
1664
+ const typedCall = createWalletSessionTypedCall(
1665
+ jest.fn((request: string) => {
1666
+ if (request === 'DeviceSessionAskPassphrase') {
1667
+ calls.push('ask-passphrase');
1668
+ return Promise.resolve({ message: { message: 'Passphrase prepared' } });
1669
+ }
1670
+ if (request === 'DeviceSessionGet') {
1671
+ calls.push('get-session');
1672
+ return Promise.resolve({
1673
+ message: {
1674
+ session_id: 'session-after-pre-unlock',
1675
+ btc_test_address: 'state-after-pre-unlock',
1676
+ },
1677
+ });
1678
+ }
1679
+ throw new Error(`Unexpected request: ${request}`);
1680
+ })
1681
+ );
1682
+ (device as any).commands = {
1683
+ typedCall,
1684
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1685
+ };
1686
+ const unlockDevice = jest.spyOn(device, 'unlockDevice');
1687
+
1627
1688
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
1628
- newSession: 'session-after-unlock',
1629
- passphraseState: 'state-after-unlock',
1689
+ newSession: 'session-after-pre-unlock',
1690
+ passphraseState: 'state-after-pre-unlock',
1630
1691
  });
1631
- expect(unlockDevice).toHaveBeenCalledTimes(1);
1632
- expect(typedCall).toHaveBeenCalledTimes(5);
1692
+ expect(unlockDevice).not.toHaveBeenCalled();
1693
+ expect(
1694
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
1695
+ ).toHaveLength(1);
1696
+ expect(calls).toEqual(['ask-passphrase', 'get-session']);
1633
1697
  });
1634
1698
 
1635
1699
  test('does not request a Pro2 wallet session before features are initialized', async () => {
@@ -1772,6 +1836,7 @@ describe('Protocol V2 feature adapter', () => {
1772
1836
  };
1773
1837
  const typedCall = jest
1774
1838
  .fn()
1839
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1775
1840
  .mockResolvedValueOnce({ message: { version: 2 } })
1776
1841
  .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1777
1842
  .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
@@ -1784,7 +1849,7 @@ describe('Protocol V2 feature adapter', () => {
1784
1849
  });
1785
1850
  const updateInternalState = jest.fn();
1786
1851
  const getFeatures = jest.fn().mockResolvedValue(features);
1787
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1852
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1788
1853
  const method = new GetPassphraseState({
1789
1854
  payload: {
1790
1855
  method: 'getPassphraseState',
@@ -1839,7 +1904,7 @@ describe('Protocol V2 feature adapter', () => {
1839
1904
  session_id: 'recovered-session',
1840
1905
  },
1841
1906
  });
1842
- const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1907
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
1843
1908
  const device = stubDevice({
1844
1909
  originalDescriptor: { ...descriptor, protocolType: 'V2' },
1845
1910
  passphraseState: 'expected-state',
@@ -1914,7 +1979,7 @@ describe('Protocol V2 feature adapter', () => {
1914
1979
  );
1915
1980
  (device as any).commands = {
1916
1981
  typedCall,
1917
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1982
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1918
1983
  };
1919
1984
 
1920
1985
  const method = new GetPassphraseState({
@@ -1970,7 +2035,7 @@ describe('Protocol V2 feature adapter', () => {
1970
2035
  features,
1971
2036
  commands: {
1972
2037
  typedCall,
1973
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2038
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
1974
2039
  },
1975
2040
  updateInternalState,
1976
2041
  getCurrentDeviceType: () => 'pro2',
@@ -1998,6 +2063,7 @@ describe('Protocol V2 feature adapter', () => {
1998
2063
  };
1999
2064
  const typedCall = jest
2000
2065
  .fn()
2066
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
2001
2067
  .mockResolvedValueOnce({ message: { version: 2 } })
2002
2068
  .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
2003
2069
  .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
@@ -2023,7 +2089,7 @@ describe('Protocol V2 feature adapter', () => {
2023
2089
  passphraseState: 'state-pro2-old',
2024
2090
  commands: {
2025
2091
  typedCall,
2026
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2092
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
2027
2093
  },
2028
2094
  emit: jest.fn(),
2029
2095
  clearInternalState,
@@ -2066,7 +2132,7 @@ describe('Protocol V2 feature adapter', () => {
2066
2132
  };
2067
2133
  (device as any).commands = {
2068
2134
  typedCall,
2069
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2135
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
2070
2136
  };
2071
2137
 
2072
2138
  await getPassphraseStateWithRefreshDeviceInfo(device);
@@ -2106,7 +2172,7 @@ describe('Protocol V2 feature adapter', () => {
2106
2172
  );
2107
2173
  (device as any).commands = {
2108
2174
  typedCall,
2109
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2175
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
2110
2176
  };
2111
2177
 
2112
2178
  await expect(getPassphraseStateWithRefreshDeviceInfo(device)).resolves.toMatchObject({
@@ -2121,7 +2187,8 @@ describe('Protocol V2 feature adapter', () => {
2121
2187
  device.passphraseState = 'state-auto';
2122
2188
  expect(device.getInternalState()).toBe('session-auto');
2123
2189
  expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2124
- on_device: true,
2190
+ passphrase: 'host hidden wallet',
2191
+ on_device: false,
2125
2192
  });
2126
2193
  expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2127
2194
  });
@@ -2191,7 +2258,7 @@ describe('Protocol V2 feature adapter', () => {
2191
2258
  (device as any).features.unlocked = true;
2192
2259
  (device as any).commands = {
2193
2260
  typedCall,
2194
- promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
2261
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
2195
2262
  };
2196
2263
 
2197
2264
  await expect(device.checkPassphraseStateSafety('expected-state', false, true)).rejects.toEqual(
@@ -2202,7 +2269,8 @@ describe('Protocol V2 feature adapter', () => {
2202
2269
 
2203
2270
  expect(device.getInternalState()).toBeUndefined();
2204
2271
  expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2205
- on_device: true,
2272
+ passphrase: 'host hidden wallet',
2273
+ on_device: false,
2206
2274
  });
2207
2275
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2208
2276
  expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
@@ -2564,6 +2632,10 @@ describe('Protocol V2 feature adapter', () => {
2564
2632
  fw: { application: { version: '1.2.3' } },
2565
2633
  },
2566
2634
  })
2635
+ .mockResolvedValueOnce({
2636
+ type: 'ProtocolInfo',
2637
+ message: protocolV2ApplicationInfo,
2638
+ })
2567
2639
  .mockResolvedValueOnce({
2568
2640
  type: 'DeviceStatus',
2569
2641
  message: { init_states: true, unlocked: true },
@@ -2584,7 +2656,7 @@ describe('Protocol V2 feature adapter', () => {
2584
2656
  expect(device.features?.passphraseProtection).toBeNull();
2585
2657
  expect(device.features?.label).toBeNull();
2586
2658
  expect(device.features?.firmwareVersion).toBe('1.2.3');
2587
- expect(typedCall).toHaveBeenCalledTimes(2);
2659
+ expect(typedCall).toHaveBeenCalledTimes(3);
2588
2660
  expect(typedCall).toHaveBeenNthCalledWith(
2589
2661
  1,
2590
2662
  'DeviceInfoGet',
@@ -2608,10 +2680,11 @@ describe('Protocol V2 feature adapter', () => {
2608
2680
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2609
2681
  }
2610
2682
  );
2611
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
2683
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2684
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2612
2685
  });
2613
2686
 
2614
- test('recognizes bootloader when DeviceStatusGet fails', async () => {
2687
+ test('recognizes bootloader from ProtocolInfo without calling DeviceStatusGet', async () => {
2615
2688
  const device = Device.fromDescriptor({
2616
2689
  path: 'usb-path',
2617
2690
  protocolType: 'V2',
@@ -2626,7 +2699,10 @@ describe('Protocol V2 feature adapter', () => {
2626
2699
  se1: {},
2627
2700
  },
2628
2701
  })
2629
- .mockRejectedValueOnce(new Error('DeviceStatusGet unsupported in bootloader'));
2702
+ .mockResolvedValueOnce({
2703
+ type: 'ProtocolInfo',
2704
+ message: protocolV2BootloaderInfo,
2705
+ });
2630
2706
 
2631
2707
  (device as any).commands = { typedCall };
2632
2708
 
@@ -2642,23 +2718,166 @@ describe('Protocol V2 feature adapter', () => {
2642
2718
  }),
2643
2719
  expect.anything()
2644
2720
  );
2645
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
2721
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2722
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2646
2723
  expect(device.features).toMatchObject({
2647
2724
  deviceId: null,
2648
2725
  bootloaderMode: true,
2726
+ mode: 'bootloader',
2649
2727
  initialized: null,
2650
2728
  });
2729
+ expect(device.isBootloader()).toBe(true);
2730
+ expect(device.isRomloader()).toBe(false);
2651
2731
  });
2652
2732
 
2653
- test('does not reinterpret a state update error as DeviceStatusGet failure', async () => {
2733
+ test('recognizes romloader from ProtocolInfo without calling DeviceStatusGet', async () => {
2654
2734
  const device = Device.fromDescriptor({
2655
2735
  path: 'usb-path',
2656
2736
  protocolType: 'V2',
2657
2737
  } as any);
2658
2738
  const typedCall = jest.fn().mockResolvedValue({
2659
- type: 'DeviceStatus',
2660
- message: { init_states: true, unlocked: true },
2739
+ type: 'ProtocolInfo',
2740
+ message: protocolV2RomloaderInfo,
2741
+ });
2742
+ (device as any).commands = { typedCall };
2743
+
2744
+ await device.probeProtocolV2RuntimeState({
2745
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2746
+ fw: { romloader: { version: '1.0.0' } },
2747
+ });
2748
+
2749
+ expect(typedCall).toHaveBeenCalledTimes(1);
2750
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2751
+ expect(device.features).toMatchObject({
2752
+ mode: 'romloader',
2753
+ bootloaderMode: true,
2754
+ initialized: null,
2755
+ });
2756
+ expect(device.isBootloader()).toBe(false);
2757
+ expect(device.isRomloader()).toBe(true);
2758
+ });
2759
+
2760
+ test('rejects Protocol V2 romloader mode for non-Pro2 devices', async () => {
2761
+ const device = Device.fromDescriptor({
2762
+ path: 'pro-v2-path',
2763
+ protocolType: 'V2',
2764
+ } as any);
2765
+ const typedCall = jest.fn().mockResolvedValue({
2766
+ type: 'ProtocolInfo',
2767
+ message: protocolV2RomloaderInfo,
2768
+ });
2769
+ (device as any).commands = { typedCall };
2770
+
2771
+ await expect(
2772
+ device.probeProtocolV2RuntimeState({
2773
+ hw: { Device_type: DeviceType.PRO, serial_no: 'PRO-SERIAL' },
2774
+ fw: { romloader: { version: '1.0.0' } },
2775
+ })
2776
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
2777
+
2778
+ expect(device.isRomloader()).not.toBe(true);
2779
+ expect(typedCall).toHaveBeenCalledTimes(1);
2780
+ });
2781
+
2782
+ test('keeps application mode without calling an unadvertised DeviceStatusGet', async () => {
2783
+ const device = Device.fromDescriptor({
2784
+ path: 'usb-path',
2785
+ protocolType: 'V2',
2786
+ } as any);
2787
+ const protocolInfo = {
2788
+ ...protocolV2ApplicationInfo,
2789
+ supported_messages: [],
2790
+ };
2791
+ const typedCall = jest.fn().mockResolvedValue({
2792
+ type: 'ProtocolInfo',
2793
+ message: protocolInfo,
2794
+ });
2795
+ (device as any).commands = { typedCall };
2796
+
2797
+ await device.probeProtocolV2RuntimeState({
2798
+ hw: { serial_no: 'PR2SERIAL' },
2799
+ fw: { application: { version: '1.2.3' } },
2800
+ });
2801
+
2802
+ expect(typedCall).toHaveBeenCalledTimes(1);
2803
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2804
+ expect(device.features).toMatchObject({
2805
+ mode: 'normal',
2806
+ bootloaderMode: false,
2807
+ initialized: null,
2808
+ });
2809
+ });
2810
+
2811
+ test('uses advertised DeviceStatusGet as compatibility fallback for an unknown fingerprint', async () => {
2812
+ const device = Device.fromDescriptor({
2813
+ path: 'usb-path',
2814
+ protocolType: 'V2',
2815
+ } as any);
2816
+ const protocolInfo = {
2817
+ ...protocolV2ApplicationInfo,
2818
+ build_fingerprint: 'legacy-fingerprint',
2819
+ };
2820
+ const typedCall = jest
2821
+ .fn()
2822
+ .mockResolvedValueOnce({ type: 'ProtocolInfo', message: protocolInfo })
2823
+ .mockResolvedValueOnce({
2824
+ type: 'DeviceStatus',
2825
+ message: { init_states: true, unlocked: true },
2826
+ });
2827
+ (device as any).commands = { typedCall };
2828
+
2829
+ await device.probeProtocolV2RuntimeState({
2830
+ hw: { serial_no: 'PR2SERIAL' },
2831
+ fw: { application: { version: '1.2.3' } },
2832
+ });
2833
+
2834
+ expect(typedCall).toHaveBeenCalledTimes(2);
2835
+ expect(device.features).toMatchObject({
2836
+ mode: 'normal',
2837
+ initialized: true,
2838
+ unlocked: true,
2839
+ });
2840
+ });
2841
+
2842
+ test('fails safely when runtime fingerprint and capabilities are both unknown', async () => {
2843
+ const device = Device.fromDescriptor({
2844
+ path: 'usb-path',
2845
+ protocolType: 'V2',
2846
+ } as any);
2847
+ const typedCall = jest.fn().mockResolvedValue({
2848
+ type: 'ProtocolInfo',
2849
+ message: {
2850
+ version: 1,
2851
+ build_fingerprint: 'legacy-fingerprint',
2852
+ supported_messages: [],
2853
+ },
2661
2854
  });
2855
+ (device as any).commands = { typedCall };
2856
+
2857
+ await expect(
2858
+ device.probeProtocolV2RuntimeState({
2859
+ hw: { serial_no: 'PR2SERIAL' },
2860
+ fw: { application: { version: '1.2.3' } },
2861
+ })
2862
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
2863
+ expect(typedCall).toHaveBeenCalledTimes(1);
2864
+ });
2865
+
2866
+ test('does not reinterpret a state update error as runtime detection failure', async () => {
2867
+ const device = Device.fromDescriptor({
2868
+ path: 'usb-path',
2869
+ protocolType: 'V2',
2870
+ } as any);
2871
+ const typedCall = jest
2872
+ .fn()
2873
+ .mockResolvedValueOnce({
2874
+ type: 'ProtocolInfo',
2875
+ message: protocolV2ApplicationInfo,
2876
+ })
2877
+ .mockResolvedValueOnce({
2878
+ type: 'DeviceStatus',
2879
+ message: { init_states: true, unlocked: true },
2880
+ });
2662
2881
  const updateProtocolV2Features = jest
2663
2882
  .spyOn(device, 'updateProtocolV2Features')
2664
2883
  .mockImplementation(() => {
@@ -2673,12 +2892,13 @@ describe('Protocol V2 feature adapter', () => {
2673
2892
  })
2674
2893
  ).rejects.toThrow('state update failed');
2675
2894
 
2676
- expect(typedCall).toHaveBeenCalledTimes(1);
2895
+ expect(typedCall).toHaveBeenCalledTimes(2);
2677
2896
  expect(updateProtocolV2Features).toHaveBeenCalledTimes(1);
2678
2897
  expect(updateProtocolV2Features).toHaveBeenCalledWith(
2679
2898
  expect.anything(),
2680
2899
  expect.objectContaining({ init_states: true }),
2681
- 'normal'
2900
+ 'normal',
2901
+ protocolV2ApplicationInfo
2682
2902
  );
2683
2903
  });
2684
2904
 
@@ -2696,6 +2916,10 @@ describe('Protocol V2 feature adapter', () => {
2696
2916
  fw: { application: { version: '1.2.3' } },
2697
2917
  },
2698
2918
  })
2919
+ .mockResolvedValueOnce({
2920
+ type: 'ProtocolInfo',
2921
+ message: protocolV2ApplicationInfo,
2922
+ })
2699
2923
  .mockResolvedValueOnce({
2700
2924
  type: 'DeviceStatus',
2701
2925
  message: { init_states: true, unlocked: true },
@@ -2707,6 +2931,10 @@ describe('Protocol V2 feature adapter', () => {
2707
2931
  fw: { application: { version: '1.2.4' } },
2708
2932
  },
2709
2933
  })
2934
+ .mockResolvedValueOnce({
2935
+ type: 'ProtocolInfo',
2936
+ message: protocolV2ApplicationInfo,
2937
+ })
2710
2938
  .mockResolvedValueOnce({
2711
2939
  type: 'DeviceStatus',
2712
2940
  message: { init_states: true, unlocked: true },
@@ -2728,9 +2956,9 @@ describe('Protocol V2 feature adapter', () => {
2728
2956
  firmwareVersion: '1.2.4',
2729
2957
  passphraseProtection: null,
2730
2958
  });
2731
- expect(typedCall).toHaveBeenCalledTimes(4);
2959
+ expect(typedCall).toHaveBeenCalledTimes(6);
2732
2960
  expect(typedCall).toHaveBeenNthCalledWith(
2733
- 3,
2961
+ 4,
2734
2962
  'DeviceInfoGet',
2735
2963
  'DeviceInfo',
2736
2964
  {
@@ -2755,14 +2983,23 @@ describe('Protocol V2 feature adapter', () => {
2755
2983
 
2756
2984
  test('keeps Protocol V2 features available for method internals such as evmSignTypedData', async () => {
2757
2985
  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
- });
2986
+ const typedCall = jest
2987
+ .fn()
2988
+ .mockResolvedValueOnce({
2989
+ type: 'DeviceInfo',
2990
+ message: {
2991
+ hw: { serial_no: 'PR2SERIAL' },
2992
+ fw: { application: { version: '1.2.4' } },
2993
+ },
2994
+ })
2995
+ .mockResolvedValueOnce({
2996
+ type: 'ProtocolInfo',
2997
+ message: protocolV2ApplicationInfo,
2998
+ })
2999
+ .mockResolvedValueOnce({
3000
+ type: 'DeviceStatus',
3001
+ message: { init_states: true, passphrase_enabled: true },
3002
+ });
2766
3003
  (device as any).commands = { typedCall };
2767
3004
 
2768
3005
  await device.initialize();
@@ -3362,6 +3599,12 @@ describe('Protocol V2 firmware update targets', () => {
3362
3599
  message: { init_states: true, unlocked: true },
3363
3600
  });
3364
3601
  }
3602
+ if (name === 'ProtocolInfoRequest') {
3603
+ return Promise.resolve({
3604
+ type: 'ProtocolInfo',
3605
+ message: protocolV2ApplicationInfo,
3606
+ });
3607
+ }
3365
3608
  return Promise.reject(new Error(`unexpected call ${name}`));
3366
3609
  });
3367
3610
  const commands = { typedCall };
@@ -3406,7 +3649,12 @@ describe('Protocol V2 firmware update targets', () => {
3406
3649
  },
3407
3650
  { timeoutMs: 5000 }
3408
3651
  );
3409
- expect(typedCall).toHaveBeenCalledTimes(2);
3652
+ expect(typedCall).toHaveBeenCalledTimes(3);
3653
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3654
+ 'DeviceInfoGet',
3655
+ 'ProtocolInfoRequest',
3656
+ 'DeviceStatusGet',
3657
+ ]);
3410
3658
  expect(typedCall).not.toHaveBeenCalledWith('Initialize', 'Features', {});
3411
3659
  expect(versions).toEqual({
3412
3660
  bootloaderVersion: '0.0.0',
@@ -3486,7 +3734,13 @@ describe('Protocol V2 firmware update targets', () => {
3486
3734
  message: protocolV2BootloaderDeviceInfo,
3487
3735
  });
3488
3736
  }
3489
- return Promise.reject(new Error('DeviceStatusGet unavailable in bootloader'));
3737
+ if (requestType === 'ProtocolInfoRequest') {
3738
+ return Promise.resolve({
3739
+ type: 'ProtocolInfo',
3740
+ message: protocolV2BootloaderInfo,
3741
+ });
3742
+ }
3743
+ return Promise.reject(new Error(`unexpected call ${requestType}`));
3490
3744
  });
3491
3745
  const reconnectProtocolV2Device = jest.fn().mockImplementation(() => {
3492
3746
  (method as any).device.isBootloader = () => true;
@@ -3513,10 +3767,17 @@ describe('Protocol V2 firmware update targets', () => {
3513
3767
  method: 'firmwareUpdateV4',
3514
3768
  },
3515
3769
  });
3770
+ const isRomloader = jest.fn(() => true);
3516
3771
  (method as any).device = stubDevice({
3517
3772
  originalDescriptor: { id: 'usb-id', path: 'romloader-path', protocolType: 'V2' },
3518
- features: { mode: 'romloader', bootloaderMode: false, capabilities: [] },
3773
+ features: {
3774
+ deviceType: 'pro2',
3775
+ mode: 'romloader',
3776
+ bootloaderMode: false,
3777
+ capabilities: [],
3778
+ },
3519
3779
  isBootloader: () => false,
3780
+ isRomloader,
3520
3781
  });
3521
3782
  (method as any).protocolV2Reboot = jest
3522
3783
  .fn()
@@ -3525,10 +3786,52 @@ describe('Protocol V2 firmware update targets', () => {
3525
3786
 
3526
3787
  await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
3527
3788
 
3789
+ expect(isRomloader).toHaveBeenCalledTimes(1);
3528
3790
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3529
3791
  expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
3530
3792
  });
3531
3793
 
3794
+ test('starts Protocol V2 firmware transfer directly from romloader mode', async () => {
3795
+ const method = new FirmwareUpdateV4({
3796
+ id: 1,
3797
+ payload: {
3798
+ method: 'firmwareUpdateV4',
3799
+ platform: 'web',
3800
+ coprocessorBinary: new Uint8Array([1]).buffer,
3801
+ },
3802
+ });
3803
+ method.init();
3804
+ (method as any).device = stubDevice({
3805
+ originalDescriptor: { id: 'usb-id', path: 'romloader-path', protocolType: 'V2' },
3806
+ features: {
3807
+ deviceType: 'pro2',
3808
+ firmwareVersion: '0.0.0',
3809
+ mode: 'romloader',
3810
+ bootloaderMode: true,
3811
+ capabilities: [],
3812
+ },
3813
+ });
3814
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
3815
+ (method as any).protocolV2Reboot = jest.fn();
3816
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3817
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3818
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
3819
+ bootloaderVersion: '1.0.0',
3820
+ bleVersion: '0.0.0',
3821
+ firmwareVersion: '1.0.0',
3822
+ });
3823
+ method.postTipMessage = jest.fn();
3824
+
3825
+ await method.run();
3826
+
3827
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3828
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3829
+ bootloaderBinary: null,
3830
+ fwBinaryMap: [{ fileName: 'coprocessor.bin', binary: expect.anything(), targetId: 6 }],
3831
+ });
3832
+ expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
3833
+ });
3834
+
3532
3835
  test('polls until Protocol V2 bootloader descriptor is ready after reboot', async () => {
3533
3836
  const method = new FirmwareUpdateV4({
3534
3837
  id: 1,
@@ -3548,7 +3851,13 @@ describe('Protocol V2 firmware update targets', () => {
3548
3851
  message: protocolV2BootloaderDeviceInfo,
3549
3852
  });
3550
3853
  }
3551
- return Promise.reject(new Error('DeviceStatusGet unavailable in bootloader'));
3854
+ if (requestType === 'ProtocolInfoRequest') {
3855
+ return Promise.resolve({
3856
+ type: 'ProtocolInfo',
3857
+ message: protocolV2BootloaderInfo,
3858
+ });
3859
+ }
3860
+ return Promise.reject(new Error(`unexpected call ${requestType}`));
3552
3861
  });
3553
3862
  const reconnectProtocolV2Device = jest
3554
3863
  .fn()
@@ -3565,8 +3874,11 @@ describe('Protocol V2 firmware update targets', () => {
3565
3874
  await (method as any).waitForProtocolV2BootloaderMode(60 * 1000, 0);
3566
3875
 
3567
3876
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
3568
- expect(typedCall).toHaveBeenCalledTimes(1);
3569
- expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
3877
+ expect(typedCall).toHaveBeenCalledTimes(2);
3878
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3879
+ 'DeviceInfoGet',
3880
+ 'ProtocolInfoRequest',
3881
+ ]);
3570
3882
  });
3571
3883
 
3572
3884
  test('does not run generic initialize during Protocol V2 USB firmware reconnect', async () => {
@@ -5933,7 +6245,7 @@ describe('Protocol V2 protected method execution', () => {
5933
6245
  expect(method.run).toHaveBeenCalledTimes(1);
5934
6246
  });
5935
6247
 
5936
- test('unlocks before showing the method interaction when cached status is locked', async () => {
6248
+ test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
5937
6249
  const calls: string[] = [];
5938
6250
  const method = {
5939
6251
  name: 'deviceSettingsPageShow',
@@ -5944,11 +6256,23 @@ describe('Protocol V2 protected method execution', () => {
5944
6256
  return Promise.resolve({ message: 'ok' });
5945
6257
  }),
5946
6258
  };
6259
+ const features = { unlocked: true };
5947
6260
  const device = {
5948
- features: { unlocked: false },
6261
+ features,
6262
+ commands: {
6263
+ typedCall: jest.fn(() => {
6264
+ calls.push('refresh-status');
6265
+ return Promise.resolve({ message: { unlocked: false } });
6266
+ }),
6267
+ },
5949
6268
  isProtocolV2: () => true,
6269
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6270
+ features.unlocked = status.unlocked ?? features.unlocked;
6271
+ return features;
6272
+ }),
5950
6273
  unlockDevice: jest.fn(() => {
5951
6274
  calls.push('unlock');
6275
+ features.unlocked = true;
5952
6276
  return Promise.resolve();
5953
6277
  }),
5954
6278
  };
@@ -5961,12 +6285,66 @@ describe('Protocol V2 protected method execution', () => {
5961
6285
  await expect(
5962
6286
  runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
5963
6287
  ).resolves.toEqual({ message: 'ok' });
5964
- expect(calls).toEqual(['unlock-prompt', 'unlock', 'method-prompt', 'run']);
6288
+ expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6289
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
5965
6290
  expect(method.run).toHaveBeenCalledTimes(1);
5966
6291
  expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
5967
6292
  expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
5968
6293
  });
5969
6294
 
6295
+ test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6296
+ const features = { unlocked: false };
6297
+ const method = {
6298
+ name: 'deviceSettings',
6299
+ unlockPolicy: 'unlock-before-run',
6300
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6301
+ };
6302
+ const device = {
6303
+ features,
6304
+ commands: {
6305
+ typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6306
+ },
6307
+ isProtocolV2: () => true,
6308
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6309
+ features.unlocked = status.unlocked ?? features.unlocked;
6310
+ return features;
6311
+ }),
6312
+ unlockDevice: jest.fn(),
6313
+ };
6314
+
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);
6320
+ });
6321
+
6322
+ test.each(['bootloader', 'romloader'] as const)(
6323
+ 'skips DeviceStatus and unlock in Protocol V2 %s mode',
6324
+ async mode => {
6325
+ const method = {
6326
+ name: 'firmwareUpdateV4',
6327
+ unlockPolicy: 'unlock-before-run',
6328
+ run: jest.fn().mockResolvedValue({ message: 'updating' }),
6329
+ };
6330
+ const device = {
6331
+ features: { unlocked: false },
6332
+ commands: { typedCall: jest.fn() },
6333
+ isProtocolV2: () => true,
6334
+ isBootloader: () => mode === 'bootloader',
6335
+ isRomloader: () => mode === 'romloader',
6336
+ unlockDevice: jest.fn(),
6337
+ };
6338
+
6339
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6340
+ message: 'updating',
6341
+ });
6342
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6343
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6344
+ expect(method.run).toHaveBeenCalledTimes(1);
6345
+ }
6346
+ );
6347
+
5970
6348
  test('infers a signing interaction before running a Protocol V2 business method', async () => {
5971
6349
  const method = {
5972
6350
  name: 'evmSignMessage',