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

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 (75) hide show
  1. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +31 -0
  2. package/__tests__/core-dispose.test.ts +3 -3
  3. package/__tests__/device-lifecycle-events.test.ts +52 -0
  4. package/__tests__/device-pool-state.test.ts +14 -2
  5. package/__tests__/device-settings.test.ts +22 -0
  6. package/__tests__/device-state-mapper.test.ts +13 -2
  7. package/__tests__/device-state-projector.test.ts +2 -2
  8. package/__tests__/device-utils.test.ts +1 -0
  9. package/__tests__/device-wallet-session-store.test.ts +64 -17
  10. package/__tests__/kaspaSignTransaction.test.ts +9 -0
  11. package/__tests__/known-device-public-types.type-test.ts +3 -0
  12. package/__tests__/logBlockEvent.test.ts +89 -5
  13. package/__tests__/open-wallet-session.test.ts +334 -30
  14. package/__tests__/pro2Wallpaper.test.ts +1 -1
  15. package/__tests__/protocol-v2.test.ts +708 -75
  16. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  17. package/dist/api/FirmwareUpdateV4.d.ts +1 -0
  18. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  19. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  20. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  21. package/dist/api/kaspa/KaspaSignTransaction.d.ts.map +1 -1
  22. package/dist/core/index.d.ts.map +1 -1
  23. package/dist/device/Device.d.ts +7 -2
  24. package/dist/device/Device.d.ts.map +1 -1
  25. package/dist/device/DeviceConnector.d.ts +1 -1
  26. package/dist/device/DeviceConnector.d.ts.map +1 -1
  27. package/dist/device/DevicePool.d.ts +1 -0
  28. package/dist/device/DevicePool.d.ts.map +1 -1
  29. package/dist/device/DeviceStateMapper.d.ts.map +1 -1
  30. package/dist/device/DeviceStateProjector.d.ts.map +1 -1
  31. package/dist/device/DeviceWalletSessionStore.d.ts +8 -0
  32. package/dist/device/DeviceWalletSessionStore.d.ts.map +1 -1
  33. package/dist/deviceProfile/buildDeviceFeatures.d.ts.map +1 -1
  34. package/dist/deviceProfile/protocolV2DeviceIdentity.d.ts +7 -0
  35. package/dist/deviceProfile/protocolV2DeviceIdentity.d.ts.map +1 -0
  36. package/dist/events/logBlockEvent.d.ts +2 -0
  37. package/dist/events/logBlockEvent.d.ts.map +1 -1
  38. package/dist/index.d.ts +19 -7
  39. package/dist/index.js +456 -222
  40. package/dist/protocols/protocol-v2/walletSession.d.ts +0 -10
  41. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  42. package/dist/types/api/openWalletSession.d.ts +1 -1
  43. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  44. package/dist/types/device.d.ts +4 -2
  45. package/dist/types/device.d.ts.map +1 -1
  46. package/dist/types/params.d.ts.map +1 -1
  47. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  48. package/dist/utils/deviceFeaturesUtils.d.ts +0 -5
  49. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  50. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  51. package/package.json +4 -4
  52. package/src/api/CheckAllFirmwareRelease.ts +11 -81
  53. package/src/api/FirmwareUpdateV4.ts +33 -17
  54. package/src/api/GetPassphraseState.ts +1 -3
  55. package/src/api/OpenWalletSession.ts +3 -3
  56. package/src/api/device/DeviceSettings.ts +8 -1
  57. package/src/api/kaspa/KaspaSignTransaction.ts +7 -0
  58. package/src/core/index.ts +7 -5
  59. package/src/device/Device.ts +37 -9
  60. package/src/device/DeviceConnector.ts +10 -7
  61. package/src/device/DevicePool.ts +5 -3
  62. package/src/device/DeviceStateMapper.ts +5 -3
  63. package/src/device/DeviceStateProjector.ts +0 -7
  64. package/src/device/DeviceWalletSessionStore.ts +57 -4
  65. package/src/deviceProfile/buildDeviceFeatures.ts +10 -3
  66. package/src/deviceProfile/protocolV2DeviceIdentity.ts +32 -0
  67. package/src/events/logBlockEvent.ts +84 -1
  68. package/src/protocols/protocol-v2/deviceSession.ts +1 -1
  69. package/src/protocols/protocol-v2/walletSession.ts +116 -34
  70. package/src/types/api/openWalletSession.ts +1 -1
  71. package/src/types/device.ts +6 -2
  72. package/src/types/params.ts +2 -1
  73. package/src/utils/deviceFeaturesCompat.ts +8 -1
  74. package/src/utils/pro2Wallpaper.ts +3 -9
  75. package/tsconfig.type-tests.json +5 -1
@@ -1,5 +1,10 @@
1
1
  import { EFirmwareType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
- import { DeviceRebootType, DeviceSessionPinType, DeviceSettingsPage } from '@onekeyfe/hd-transport';
2
+ import {
3
+ DeviceRebootType,
4
+ DeviceSessionPinType,
5
+ DeviceSettingsPage,
6
+ DeviceType,
7
+ } from '@onekeyfe/hd-transport';
3
8
 
4
9
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
5
10
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -54,6 +59,7 @@ import { getBitcoinForkSupportedProtocols } from '../src/api/btc/helpers/version
54
59
  import { DataManager } from '../src/data-manager';
55
60
  import { createCoreApi } from '../src/inject';
56
61
  import { Device, preloadSessionCache } from '../src/device/Device';
62
+ import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
57
63
  import { DevicePool } from '../src/device/DevicePool';
58
64
  import { UI_REQUEST } from '../src/events/ui-request';
59
65
  import {
@@ -96,11 +102,19 @@ jest.mock('../src/data/config', () => ({
96
102
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
97
103
  }));
98
104
 
99
- const createWalletSessionTypedCall = (implementation = jest.fn()) =>
105
+ const createWalletSessionTypedCall = (
106
+ implementation = jest.fn(),
107
+ status: Partial<DeviceStatus> = { passphrase_enabled: true }
108
+ ) =>
100
109
  jest.fn((request: string, ...args: unknown[]) => {
101
110
  if (request === 'ProtocolInfoRequest') {
102
111
  return Promise.resolve({ message: { version: 2 } });
103
112
  }
113
+ if (request === 'DeviceStatusGet') {
114
+ return Promise.resolve({
115
+ message: { unlocked: true, unlocked_attach_pin: false, ...status },
116
+ });
117
+ }
104
118
  return implementation(request, ...args);
105
119
  });
106
120
 
@@ -464,6 +478,10 @@ async function requestProtocolV2Features({
464
478
  }
465
479
 
466
480
  describe('Protocol V2 feature adapter', () => {
481
+ beforeEach(() => {
482
+ deviceWalletSessionStore.clear();
483
+ });
484
+
467
485
  test('treats passphrase status as unknown until the Protocol V2 device is unlocked', () => {
468
486
  const features = buildProtocolV2FeaturesPayload({
469
487
  deviceInfo: {
@@ -575,6 +593,7 @@ describe('Protocol V2 feature adapter', () => {
575
593
  const features = normalizeProtocolV2Features(descriptor as any, {
576
594
  protocol_version: 1,
577
595
  hw: {
596
+ Device_type: DeviceType.PRO2,
578
597
  serial_no: 'PR2SERIAL',
579
598
  },
580
599
  fw: {
@@ -733,12 +752,23 @@ describe('Protocol V2 feature adapter', () => {
733
752
  features.firmwareVersion = '1.2.3';
734
753
  features.passphraseProtection = true;
735
754
  const typedCall = createWalletSessionTypedCall(
736
- jest.fn().mockResolvedValue({
737
- type: 'DeviceSession',
738
- message: {
739
- btc_test_address: 'state-1',
740
- session_id: 'session-1',
741
- },
755
+ jest.fn((request: string) => {
756
+ if (request === 'DeviceSessionAskPin') {
757
+ return { type: 'Success', message: { message: 'PIN verified' } };
758
+ }
759
+ if (request === 'DeviceStatusGet') {
760
+ return { type: 'DeviceStatus', message: { unlocked: true } };
761
+ }
762
+ if (request === 'DeviceSessionGet') {
763
+ return {
764
+ type: 'DeviceSession',
765
+ message: {
766
+ btc_test_address: 'state-1',
767
+ session_id: 'session-1',
768
+ },
769
+ };
770
+ }
771
+ throw new Error(`Unexpected request: ${request}`);
742
772
  })
743
773
  );
744
774
  const commands = { typedCall } as unknown as DeviceCommands;
@@ -751,18 +781,236 @@ describe('Protocol V2 feature adapter', () => {
751
781
  onlyMainPin: true,
752
782
  })
753
783
  ).resolves.toEqual({
754
- passphraseState: undefined,
755
- newSession: undefined,
756
- unlockedAttachPin: false,
784
+ passphraseState: 'state-1',
785
+ newSession: 'session-1',
786
+ unlockedAttachPin: undefined,
757
787
  resumed: false,
758
788
  });
759
789
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
760
790
  eventless_wallet_session: true,
761
791
  });
762
- expect(device.getInternalState()).toBeUndefined();
792
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
793
+ type: DeviceSessionPinType.Main,
794
+ });
795
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
796
+ device.passphraseState = 'state-1';
797
+ expect(device.getInternalState()).toBe('session-1');
763
798
  });
764
799
 
765
- test('keeps Protocol V2 on the standard wallet when passphrase protection is disabled', async () => {
800
+ test('reuses the cached Protocol V2 standard wallet instead of asking Main PIN again', async () => {
801
+ const deviceId = 'standard-reuse-device';
802
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
803
+ (device as any).features = normalizeProtocolV2Features(
804
+ { ...descriptor, protocolType: 'V2' } as any,
805
+ {
806
+ status: {
807
+ device_id: deviceId,
808
+ unlocked: true,
809
+ passphrase_enabled: true,
810
+ },
811
+ }
812
+ );
813
+
814
+ const typedCall = jest.fn((request: string) => {
815
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
816
+ if (request === 'DeviceSessionAskPin') {
817
+ return { type: 'Success', message: { message: 'PIN verified' } };
818
+ }
819
+ if (request === 'DeviceStatusGet') {
820
+ return {
821
+ message: {
822
+ device_id: deviceId,
823
+ unlocked: true,
824
+ passphrase_enabled: true,
825
+ },
826
+ };
827
+ }
828
+ if (request === 'DeviceSessionGet') {
829
+ return {
830
+ message: {
831
+ btc_test_address: 'standard-state',
832
+ session_id: 'standard-session',
833
+ },
834
+ };
835
+ }
836
+ throw new Error(`Unexpected request: ${request}`);
837
+ });
838
+ (device as any).commands = { typedCall };
839
+
840
+ await getProtocolV2WalletSession(device, { onlyMainPin: true });
841
+ await getProtocolV2WalletSession(device, { onlyMainPin: true });
842
+
843
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
844
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
845
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
846
+ session_id: 'standard-session',
847
+ });
848
+ expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
849
+ passphraseState: 'standard-state',
850
+ sessionId: 'standard-session',
851
+ });
852
+ });
853
+
854
+ test('reopens the Protocol V2 standard wallet when the cached session resolves to another wallet', async () => {
855
+ const deviceId = 'standard-invalid-device';
856
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
857
+ (device as any).features = normalizeProtocolV2Features(
858
+ { ...descriptor, protocolType: 'V2' } as any,
859
+ {
860
+ status: {
861
+ device_id: deviceId,
862
+ unlocked: true,
863
+ passphrase_enabled: true,
864
+ },
865
+ }
866
+ );
867
+ deviceWalletSessionStore.set(deviceId, 'hidden-state', 'hidden-session');
868
+ deviceWalletSessionStore.setStandard(deviceId, 'old-standard-state', 'old-standard-session');
869
+
870
+ const typedCall = jest.fn((request: string, _response: string, params: any) => {
871
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
872
+ if (request === 'DeviceSessionGet' && params.session_id) {
873
+ return {
874
+ message: {
875
+ btc_test_address: 'hidden-state',
876
+ session_id: 'hidden-session',
877
+ },
878
+ };
879
+ }
880
+ if (request === 'DeviceSessionGet') {
881
+ return {
882
+ message: {
883
+ btc_test_address: 'old-standard-state',
884
+ session_id: 'new-standard-session',
885
+ },
886
+ };
887
+ }
888
+ if (request === 'DeviceSessionAskPin') {
889
+ return { type: 'Success', message: { message: 'PIN verified' } };
890
+ }
891
+ if (request === 'DeviceStatusGet') {
892
+ return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
893
+ }
894
+ throw new Error(`Unexpected request: ${request}`);
895
+ });
896
+ (device as any).commands = { typedCall };
897
+
898
+ await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).resolves.toMatchObject({
899
+ passphraseState: 'old-standard-state',
900
+ newSession: 'new-standard-session',
901
+ resumed: false,
902
+ });
903
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
904
+ expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
905
+ passphraseState: 'old-standard-state',
906
+ sessionId: 'new-standard-session',
907
+ });
908
+ expect(deviceWalletSessionStore.get(deviceId, 'hidden-state')).toBe('hidden-session');
909
+ });
910
+
911
+ test('opens Main PIN directly instead of resuming a standard session while locked', async () => {
912
+ const deviceId = 'standard-locked-device';
913
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
914
+ (device as any).features = normalizeProtocolV2Features(
915
+ { ...descriptor, protocolType: 'V2' } as any,
916
+ {
917
+ status: {
918
+ device_id: deviceId,
919
+ unlocked: false,
920
+ passphrase_enabled: true,
921
+ },
922
+ }
923
+ );
924
+ deviceWalletSessionStore.setStandard(deviceId, 'standard-state', 'cached-standard-session');
925
+
926
+ let sessionGetCalls = 0;
927
+ const typedCall = jest.fn((request: string) => {
928
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
929
+ if (request === 'DeviceSessionAskPin') {
930
+ return { type: 'Success', message: { message: 'PIN verified' } };
931
+ }
932
+ if (request === 'DeviceStatusGet') {
933
+ return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
934
+ }
935
+ if (request === 'DeviceSessionGet') {
936
+ sessionGetCalls += 1;
937
+ if (sessionGetCalls === 1) {
938
+ throw Object.assign(new Error('Device is locked'), {
939
+ errorCode: HardwareErrorCode.DeviceLocked,
940
+ });
941
+ }
942
+ return {
943
+ message: {
944
+ btc_test_address: 'standard-state',
945
+ session_id: 'cached-standard-session',
946
+ },
947
+ };
948
+ }
949
+ throw new Error(`Unexpected request: ${request}`);
950
+ });
951
+ (device as any).commands = { typedCall };
952
+
953
+ await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).resolves.toMatchObject({
954
+ newSession: 'cached-standard-session',
955
+ resumed: true,
956
+ });
957
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(2);
958
+ expect(deviceWalletSessionStore.getStandard(deviceId)?.sessionId).toBe(
959
+ 'cached-standard-session'
960
+ );
961
+ });
962
+
963
+ test('recovers a mismatched cached standard wallet without deleting hidden sessions', async () => {
964
+ const deviceId = 'standard-mismatch-device';
965
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
966
+ (device as any).features = normalizeProtocolV2Features(
967
+ { ...descriptor, protocolType: 'V2' } as any,
968
+ {
969
+ status: {
970
+ device_id: deviceId,
971
+ unlocked: true,
972
+ passphrase_enabled: true,
973
+ },
974
+ }
975
+ );
976
+ deviceWalletSessionStore.set(deviceId, 'hidden-state', 'hidden-session');
977
+ deviceWalletSessionStore.setStandard(deviceId, 'standard-state', 'standard-session');
978
+ let sessionGetCalls = 0;
979
+ const typedCall = jest.fn((request: string) => {
980
+ if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
981
+ if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
982
+ if (request === 'DeviceStatusGet') {
983
+ return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
984
+ }
985
+ if (request === 'DeviceSessionGet') {
986
+ sessionGetCalls += 1;
987
+ return {
988
+ message: {
989
+ btc_test_address: sessionGetCalls === 1 ? 'unexpected-wallet-state' : 'standard-state',
990
+ session_id: sessionGetCalls === 1 ? 'unexpected-session' : 'renewed-standard-session',
991
+ },
992
+ };
993
+ }
994
+ throw new Error(`Unexpected request: ${request}`);
995
+ });
996
+ (device as any).commands = { typedCall };
997
+
998
+ await expect(getProtocolV2WalletSession(device, { onlyMainPin: true })).resolves.toMatchObject({
999
+ passphraseState: 'standard-state',
1000
+ newSession: 'renewed-standard-session',
1001
+ resumed: false,
1002
+ });
1003
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
1004
+ type: DeviceSessionPinType.Main,
1005
+ });
1006
+ expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
1007
+ passphraseState: 'standard-state',
1008
+ sessionId: 'renewed-standard-session',
1009
+ });
1010
+ expect(deviceWalletSessionStore.get(deviceId, 'hidden-state')).toBe('hidden-session');
1011
+ });
1012
+
1013
+ test('selects a Protocol V2 wallet explicitly even when passphrase protection was disabled', async () => {
766
1014
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
767
1015
  (device as any).features = normalizeProtocolV2Features(
768
1016
  { ...descriptor, protocolType: 'V2' } as any,
@@ -783,13 +1031,13 @@ describe('Protocol V2 feature adapter', () => {
783
1031
  (device as any).commands = { typedCall, promptPassphrase };
784
1032
 
785
1033
  await expect(getProtocolV2WalletSession(device)).resolves.toEqual({
786
- passphraseState: undefined,
787
- newSession: undefined,
788
- unlockedAttachPin: false,
1034
+ passphraseState: 'main-wallet-state',
1035
+ newSession: 'main-wallet-session',
1036
+ unlockedAttachPin: undefined,
789
1037
  resumed: false,
790
1038
  });
791
1039
 
792
- expect(promptPassphrase).not.toHaveBeenCalled();
1040
+ expect(promptPassphrase).toHaveBeenCalled();
793
1041
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
794
1042
  eventless_wallet_session: true,
795
1043
  });
@@ -846,15 +1094,20 @@ describe('Protocol V2 feature adapter', () => {
846
1094
  },
847
1095
  }
848
1096
  );
1097
+ const lockedError = Object.assign(new Error('Device is locked'), {
1098
+ errorCode: HardwareErrorCode.DeviceLocked,
1099
+ });
849
1100
  const typedCall = createWalletSessionTypedCall(
850
- jest.fn().mockImplementation((request: string) => {
851
- if (request === 'DeviceStatusGet') {
852
- return Promise.resolve({
853
- message: { passphrase_enabled: false, unlocked: true },
854
- });
855
- }
856
- throw new Error(`Unexpected request: ${request}`);
857
- })
1101
+ jest
1102
+ .fn()
1103
+ .mockRejectedValueOnce(lockedError)
1104
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1105
+ .mockResolvedValueOnce({
1106
+ message: {
1107
+ btc_test_address: 'hidden-after-unlock-state',
1108
+ session_id: 'hidden-after-unlock-session',
1109
+ },
1110
+ })
858
1111
  );
859
1112
  const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'hidden-after-unlock' });
860
1113
  const unlockDevice = jest.spyOn(device, 'unlockDevice').mockImplementation(() => {
@@ -868,14 +1121,14 @@ describe('Protocol V2 feature adapter', () => {
868
1121
  (device as any).commands = { typedCall, promptPassphrase };
869
1122
 
870
1123
  await expect(getProtocolV2WalletSession(device)).resolves.toEqual({
871
- passphraseState: undefined,
872
- newSession: undefined,
873
- unlockedAttachPin: false,
1124
+ passphraseState: 'hidden-after-unlock-state',
1125
+ newSession: 'hidden-after-unlock-session',
1126
+ unlockedAttachPin: undefined,
874
1127
  resumed: false,
875
1128
  });
876
1129
 
877
1130
  expect(unlockDevice).toHaveBeenCalledTimes(1);
878
- expect(promptPassphrase).not.toHaveBeenCalled();
1131
+ expect(promptPassphrase).toHaveBeenCalled();
879
1132
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
880
1133
  eventless_wallet_session: true,
881
1134
  });
@@ -905,8 +1158,8 @@ describe('Protocol V2 feature adapter', () => {
905
1158
  onlyMainPin: true,
906
1159
  })
907
1160
  ).resolves.toMatchObject({
908
- passphraseState: undefined,
909
- newSession: undefined,
1161
+ passphraseState: 'main-wallet-state',
1162
+ newSession: 'main-wallet-session',
910
1163
  });
911
1164
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
912
1165
  eventless_wallet_session: true,
@@ -933,9 +1186,17 @@ describe('Protocol V2 feature adapter', () => {
933
1186
  }
934
1187
  );
935
1188
  const typedCall = createWalletSessionTypedCall(
936
- jest.fn().mockResolvedValue({
937
- type: 'DeviceSession',
938
- message: { session_id: 'hidden-session', btc_test_address: 'hidden-state' },
1189
+ jest.fn((request: string) => {
1190
+ if (request === 'DeviceSessionAskPassphrase') {
1191
+ return { type: 'Success', message: { message: 'Passphrase prepared' } };
1192
+ }
1193
+ if (request === 'DeviceSessionGet') {
1194
+ return {
1195
+ type: 'DeviceSession',
1196
+ message: { session_id: 'hidden-session', btc_test_address: 'hidden-state' },
1197
+ };
1198
+ }
1199
+ throw new Error(`Unexpected request: ${request}`);
939
1200
  })
940
1201
  );
941
1202
  const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
@@ -1148,11 +1409,14 @@ describe('Protocol V2 feature adapter', () => {
1148
1409
  }
1149
1410
  );
1150
1411
  const typedCall = createWalletSessionTypedCall(
1151
- jest.fn().mockRejectedValue(
1152
- Object.assign(new Error('Invalid session'), {
1153
- errorCode: HardwareErrorCode.WalletSessionInvalid,
1154
- })
1155
- )
1412
+ jest
1413
+ .fn()
1414
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1415
+ .mockRejectedValueOnce(
1416
+ Object.assign(new Error('Invalid session'), {
1417
+ errorCode: HardwareErrorCode.WalletSessionInvalid,
1418
+ })
1419
+ )
1156
1420
  );
1157
1421
  (device as any).commands = {
1158
1422
  typedCall,
@@ -1165,10 +1429,12 @@ describe('Protocol V2 feature adapter', () => {
1165
1429
  expect(typedCall.mock.calls).toEqual([
1166
1430
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1167
1431
  ['DeviceSessionAskPassphrase', 'Success', { on_device: true }],
1432
+ ['DeviceStatusGet', 'DeviceStatus', {}],
1433
+ ['DeviceSessionGet', 'DeviceSession', {}],
1168
1434
  ]);
1169
1435
  });
1170
1436
 
1171
- test('rejects a mismatched Pro2 wallet state and clears the selected cache entry', async () => {
1437
+ test('reselects a hidden wallet when the cached Pro2 session resolves to another wallet', async () => {
1172
1438
  const device = Device.fromDescriptor({
1173
1439
  id: 'cache-device-mismatch',
1174
1440
  path: 'cache-path-mismatch',
@@ -1186,25 +1452,147 @@ describe('Protocol V2 feature adapter', () => {
1186
1452
  );
1187
1453
  device.passphraseState = 'state-a';
1188
1454
  preloadSessionCache('stable-device-mismatch', 'state-a', 'session-a');
1189
- (device as any).commands = {
1190
- typedCall: createWalletSessionTypedCall(
1191
- jest.fn().mockResolvedValue({
1192
- type: 'DeviceSession',
1193
- message: { session_id: 'session-b', btc_test_address: 'state-b' },
1194
- })
1195
- ),
1196
- };
1455
+ let sessionGetCalls = 0;
1456
+ const typedCall = createWalletSessionTypedCall(
1457
+ jest.fn((request: string) => {
1458
+ if (request === 'DeviceSessionAskPassphrase') return { message: { message: 'prepared' } };
1459
+ if (request === 'DeviceSessionGet') {
1460
+ sessionGetCalls += 1;
1461
+ return {
1462
+ type: 'DeviceSession',
1463
+ message:
1464
+ sessionGetCalls === 1
1465
+ ? { session_id: 'session-b', btc_test_address: 'state-b' }
1466
+ : { session_id: 'session-a-new', btc_test_address: 'state-a' },
1467
+ };
1468
+ }
1469
+ throw new Error(`Unexpected request: ${request}`);
1470
+ })
1471
+ );
1472
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1473
+ (device as any).commands = { typedCall, promptPassphrase };
1197
1474
 
1198
1475
  await expect(
1199
1476
  getPassphraseStateWithRefreshDeviceInfo(device, { expectPassphraseState: 'state-a' })
1200
- ).rejects.toEqual(
1477
+ ).resolves.toMatchObject({
1478
+ passphraseState: 'state-a',
1479
+ newSession: 'session-a-new',
1480
+ resumed: false,
1481
+ });
1482
+ expect(promptPassphrase).toHaveBeenCalledWith(
1201
1483
  expect.objectContaining({
1202
- errorCode: HardwareErrorCode.DeviceCheckPassphraseStateError,
1484
+ source: 'wallet-session-coordinator',
1485
+ reason: 'session-recovery',
1486
+ expectedPassphraseState: 'state-a',
1487
+ }),
1488
+ { cancelDeviceOnReject: false }
1489
+ );
1490
+ expect(device.getInternalState()).toBe('session-a-new');
1491
+ });
1492
+
1493
+ test('rejects a hidden wallet when one recovery still returns the wrong state', async () => {
1494
+ const device = Device.fromDescriptor({
1495
+ id: 'cache-device-final-mismatch',
1496
+ path: 'cache-path-final-mismatch',
1497
+ protocolType: 'V2',
1498
+ } as any);
1499
+ (device as any).features = normalizeProtocolV2Features(
1500
+ { ...descriptor, protocolType: 'V2' } as any,
1501
+ {
1502
+ status: {
1503
+ device_id: 'stable-device-final-mismatch',
1504
+ unlocked: true,
1505
+ passphrase_enabled: true,
1506
+ },
1507
+ }
1508
+ );
1509
+ device.passphraseState = 'state-a';
1510
+ preloadSessionCache('stable-device-final-mismatch', 'state-a', 'session-a');
1511
+ const typedCall = createWalletSessionTypedCall(
1512
+ jest.fn((request: string) => {
1513
+ if (request === 'DeviceSessionAskPassphrase') return { message: { message: 'prepared' } };
1514
+ if (request === 'DeviceSessionGet') {
1515
+ return {
1516
+ type: 'DeviceSession',
1517
+ message: { session_id: 'wrong-session', btc_test_address: 'wrong-state' },
1518
+ };
1519
+ }
1520
+ throw new Error(`Unexpected request: ${request}`);
1203
1521
  })
1204
1522
  );
1523
+ (device as any).commands = {
1524
+ typedCall,
1525
+ promptPassphrase: jest.fn().mockResolvedValue({ passphraseOnDevice: true }),
1526
+ };
1527
+
1528
+ await expect(
1529
+ getPassphraseStateWithRefreshDeviceInfo(device, { expectPassphraseState: 'state-a' })
1530
+ ).rejects.toMatchObject({
1531
+ errorCode: HardwareErrorCode.DeviceCheckPassphraseStateError,
1532
+ });
1205
1533
  expect(device.getInternalState()).toBeUndefined();
1206
1534
  });
1207
1535
 
1536
+ test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1537
+ const device = Device.fromDescriptor({
1538
+ id: 'cache-device-attach-recovery',
1539
+ path: 'cache-path-attach-recovery',
1540
+ protocolType: 'V2',
1541
+ } as any);
1542
+ (device as any).features = normalizeProtocolV2Features(
1543
+ { ...descriptor, protocolType: 'V2' } as any,
1544
+ {
1545
+ status: {
1546
+ device_id: 'stable-device-attach-recovery',
1547
+ unlocked: true,
1548
+ passphrase_enabled: true,
1549
+ attach_to_pin_enabled: true,
1550
+ },
1551
+ }
1552
+ );
1553
+ device.passphraseState = 'attach-state';
1554
+ preloadSessionCache('stable-device-attach-recovery', 'attach-state', 'expired-attach-session');
1555
+ let sessionGetCalls = 0;
1556
+ const typedCall = createWalletSessionTypedCall(
1557
+ jest.fn((request: string) => {
1558
+ if (request === 'DeviceSessionGet') {
1559
+ sessionGetCalls += 1;
1560
+ return {
1561
+ type: 'DeviceSession',
1562
+ message:
1563
+ sessionGetCalls === 1
1564
+ ? { session_id: 'current-standard-session', btc_test_address: 'standard-state' }
1565
+ : { session_id: 'new-attach-session', btc_test_address: 'attach-state' },
1566
+ };
1567
+ }
1568
+ throw new Error(`Unexpected request: ${request}`);
1569
+ })
1570
+ );
1571
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1572
+ (device as any).commands = { typedCall, promptPassphrase };
1573
+ const unlockDevice = jest
1574
+ .spyOn(device, 'unlockDevice')
1575
+ .mockResolvedValue(device.features as any);
1576
+
1577
+ await expect(
1578
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'attach-state' })
1579
+ ).resolves.toMatchObject({
1580
+ passphraseState: 'attach-state',
1581
+ newSession: 'new-attach-session',
1582
+ resumed: false,
1583
+ });
1584
+ expect(promptPassphrase).toHaveBeenCalledWith(
1585
+ expect.objectContaining({
1586
+ reason: 'session-recovery',
1587
+ expectedPassphraseState: 'attach-state',
1588
+ existsAttachPinUser: true,
1589
+ }),
1590
+ { cancelDeviceOnReject: false }
1591
+ );
1592
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1593
+ expect(device.getInternalState()).toBe('new-attach-session');
1594
+ });
1595
+
1208
1596
  test('unlocks and retries DeviceSessionAskPassphrase when wallet selection is locked', async () => {
1209
1597
  const device = Device.fromDescriptor({
1210
1598
  id: 'cache-device-4',
@@ -1222,7 +1610,7 @@ describe('Protocol V2 feature adapter', () => {
1222
1610
  jest
1223
1611
  .fn()
1224
1612
  .mockRejectedValueOnce(lockedError)
1225
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
1613
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1226
1614
  .mockResolvedValueOnce({
1227
1615
  message: {
1228
1616
  session_id: 'session-after-unlock',
@@ -1241,7 +1629,7 @@ describe('Protocol V2 feature adapter', () => {
1241
1629
  passphraseState: 'state-after-unlock',
1242
1630
  });
1243
1631
  expect(unlockDevice).toHaveBeenCalledTimes(1);
1244
- expect(typedCall).toHaveBeenCalledTimes(4);
1632
+ expect(typedCall).toHaveBeenCalledTimes(5);
1245
1633
  });
1246
1634
 
1247
1635
  test('does not request a Pro2 wallet session before features are initialized', async () => {
@@ -1385,7 +1773,8 @@ describe('Protocol V2 feature adapter', () => {
1385
1773
  const typedCall = jest
1386
1774
  .fn()
1387
1775
  .mockResolvedValueOnce({ message: { version: 2 } })
1388
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
1776
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1777
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1389
1778
  .mockResolvedValueOnce({
1390
1779
  type: 'DeviceSession',
1391
1780
  message: {
@@ -1421,7 +1810,7 @@ describe('Protocol V2 feature adapter', () => {
1421
1810
  expect(promptPassphrase).toHaveBeenCalledWith(
1422
1811
  {
1423
1812
  existsAttachPinUser: false,
1424
- deviceOnly: true,
1813
+ deviceOnly: false,
1425
1814
  source: 'wallet-session-coordinator',
1426
1815
  reason: 'open-wallet',
1427
1816
  },
@@ -1438,6 +1827,51 @@ describe('Protocol V2 feature adapter', () => {
1438
1827
  expect(getFeatures).not.toHaveBeenCalled();
1439
1828
  });
1440
1829
 
1830
+ test('marks an interactive cache miss as a wallet-session recovery', async () => {
1831
+ const typedCall = jest
1832
+ .fn()
1833
+ .mockResolvedValueOnce({ message: { version: 2 } })
1834
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1835
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1836
+ .mockResolvedValueOnce({
1837
+ message: {
1838
+ btc_test_address: 'expected-state',
1839
+ session_id: 'recovered-session',
1840
+ },
1841
+ });
1842
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1843
+ const device = stubDevice({
1844
+ originalDescriptor: { ...descriptor, protocolType: 'V2' },
1845
+ passphraseState: 'expected-state',
1846
+ features: {
1847
+ unlocked: true,
1848
+ passphraseProtection: true,
1849
+ attachToPinEnabled: false,
1850
+ },
1851
+ commands: { typedCall, promptPassphrase },
1852
+ getInternalState: jest.fn().mockReturnValue(undefined),
1853
+ getCurrentDeviceId: () => 'pro2-device-id',
1854
+ getCurrentPassphraseProtection: () => true,
1855
+ updateInternalState: jest.fn(),
1856
+ clearInternalState: jest.fn(),
1857
+ emit: jest.fn(),
1858
+ }) as any;
1859
+
1860
+ await expect(
1861
+ getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1862
+ ).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
+ );
1873
+ });
1874
+
1441
1875
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
1442
1876
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1443
1877
  const typedCall = jest.fn().mockImplementation((request: string) => {
@@ -1445,7 +1879,16 @@ describe('Protocol V2 feature adapter', () => {
1445
1879
  return Promise.resolve({ message: { version: 2 } });
1446
1880
  }
1447
1881
  if (request === 'DeviceSessionAskPassphrase') {
1448
- return Promise.resolve({ message: { message: 'passphrase prepared' } });
1882
+ return Promise.resolve({
1883
+ type: 'Success',
1884
+ message: { message: 'Passphrase prepared' },
1885
+ });
1886
+ }
1887
+ if (request === 'DeviceStatusGet') {
1888
+ return Promise.resolve({
1889
+ type: 'DeviceStatus',
1890
+ message: { unlocked: true, unlocked_attach_pin: false, passphrase_enabled: true },
1891
+ });
1449
1892
  }
1450
1893
  if (request === 'DeviceSessionGet') {
1451
1894
  return Promise.resolve({
@@ -1497,7 +1940,7 @@ describe('Protocol V2 feature adapter', () => {
1497
1940
  ]);
1498
1941
  });
1499
1942
 
1500
- test('uses the Pro2 standard wallet without passphraseState when passphrase is disabled', async () => {
1943
+ test('returns the Pro2 standard-wallet passphraseState when passphrase is disabled', async () => {
1501
1944
  const features = {
1502
1945
  deviceId: 'pro2-device-id',
1503
1946
  deviceType: 'pro2',
@@ -1505,7 +1948,15 @@ describe('Protocol V2 feature adapter', () => {
1505
1948
  passphraseProtection: false,
1506
1949
  unlocked: true,
1507
1950
  };
1508
- const typedCall = createWalletSessionTypedCall();
1951
+ const typedCall = createWalletSessionTypedCall(
1952
+ jest.fn().mockResolvedValue({
1953
+ type: 'DeviceSession',
1954
+ message: {
1955
+ btc_test_address: 'main-wallet-state',
1956
+ session_id: 'main-wallet-session',
1957
+ },
1958
+ })
1959
+ );
1509
1960
  const updateInternalState = jest.fn();
1510
1961
  const method = new GetPassphraseState({
1511
1962
  payload: {
@@ -1514,7 +1965,7 @@ describe('Protocol V2 feature adapter', () => {
1514
1965
  useEmptyPassphrase: true,
1515
1966
  },
1516
1967
  });
1517
- method.device = stubDevice({
1968
+ const device = stubDevice({
1518
1969
  originalDescriptor: { ...descriptor, protocolType: 'V2' },
1519
1970
  features,
1520
1971
  commands: {
@@ -1526,12 +1977,14 @@ describe('Protocol V2 feature adapter', () => {
1526
1977
  getCurrentDeviceId: () => 'pro2-device-id',
1527
1978
  getCurrentPassphraseProtection: () => false,
1528
1979
  }) as any;
1980
+ device.unlockDevice = jest.fn().mockResolvedValue(features);
1981
+ method.device = device;
1529
1982
 
1530
- await expect(method.run()).resolves.toBeUndefined();
1983
+ await expect(method.run()).resolves.toBe('main-wallet-state');
1531
1984
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1532
1985
  eventless_wallet_session: true,
1533
1986
  });
1534
- expect(updateInternalState).not.toHaveBeenCalled();
1987
+ expect(updateInternalState).toHaveBeenCalled();
1535
1988
  });
1536
1989
 
1537
1990
  test('honors initSession when getting Pro2 passphrase state', async () => {
@@ -1546,7 +1999,8 @@ describe('Protocol V2 feature adapter', () => {
1546
1999
  const typedCall = jest
1547
2000
  .fn()
1548
2001
  .mockResolvedValueOnce({ message: { version: 2 } })
1549
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
2002
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
2003
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1550
2004
  .mockResolvedValueOnce({
1551
2005
  type: 'DeviceSession',
1552
2006
  message: {
@@ -1666,6 +2120,9 @@ describe('Protocol V2 feature adapter', () => {
1666
2120
  expect(device.getInternalState()).toBeUndefined();
1667
2121
  device.passphraseState = 'state-auto';
1668
2122
  expect(device.getInternalState()).toBe('session-auto');
2123
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2124
+ on_device: true,
2125
+ });
1669
2126
  expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
1670
2127
  });
1671
2128
 
@@ -1678,7 +2135,8 @@ describe('Protocol V2 feature adapter', () => {
1678
2135
  session_id: 'main-pin-session',
1679
2136
  btc_test_address: 'main-wallet-state',
1680
2137
  },
1681
- })
2138
+ }),
2139
+ { passphrase_enabled: false }
1682
2140
  );
1683
2141
 
1684
2142
  (device as any).features = normalizeProtocolV2Features(
@@ -1700,8 +2158,8 @@ describe('Protocol V2 feature adapter', () => {
1700
2158
  await expect(
1701
2159
  getPassphraseStateWithRefreshDeviceInfo(device, { onlyMainPin: true })
1702
2160
  ).resolves.toMatchObject({
1703
- passphraseState: undefined,
1704
- newSession: undefined,
2161
+ passphraseState: 'main-wallet-state',
2162
+ newSession: 'main-pin-session',
1705
2163
  });
1706
2164
 
1707
2165
  expect(device.features?.passphraseProtection).toBe(false);
@@ -1746,6 +2204,8 @@ describe('Protocol V2 feature adapter', () => {
1746
2204
  expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
1747
2205
  on_device: true,
1748
2206
  });
2207
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2208
+ expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
1749
2209
  });
1750
2210
 
1751
2211
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -1778,7 +2238,7 @@ describe('Protocol V2 feature adapter', () => {
1778
2238
  await expect(
1779
2239
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
1780
2240
  ).resolves.toBe(true);
1781
- expect(typedCall).toHaveBeenCalledTimes(1);
2241
+ expect(typedCall).toHaveBeenCalledTimes(4);
1782
2242
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
1783
2243
  eventless_wallet_session: true,
1784
2244
  });
@@ -1836,7 +2296,9 @@ describe('Protocol V2 feature adapter', () => {
1836
2296
  test('uses Protocol V2 features directly when profile is absent', () => {
1837
2297
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1838
2298
  (device as any).features = {
1839
- ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
2299
+ ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2300
+ hw: { Device_type: DeviceType.PRO2 },
2301
+ }),
1840
2302
  serialNo: 'CACHED-SERIAL',
1841
2303
  label: 'Cached Label',
1842
2304
  bleName: 'Cached BLE',
@@ -1845,6 +2307,7 @@ describe('Protocol V2 feature adapter', () => {
1845
2307
 
1846
2308
  const message = device.toMessageObject();
1847
2309
  expect(message).toMatchObject({
2310
+ connectProtocol: 'V2',
1848
2311
  serialNo: 'CACHED-SERIAL',
1849
2312
  uuid: 'CACHED-SERIAL',
1850
2313
  deviceId: null,
@@ -1916,7 +2379,7 @@ describe('Protocol V2 feature adapter', () => {
1916
2379
  typedCall: jest.fn().mockResolvedValueOnce({
1917
2380
  type: 'DeviceInfo',
1918
2381
  message: {
1919
- hw: { serial_no: 'PR2SERIAL' },
2382
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
1920
2383
  fw: { application: { version: '1.2.3' } },
1921
2384
  },
1922
2385
  }),
@@ -2097,7 +2560,7 @@ describe('Protocol V2 feature adapter', () => {
2097
2560
  .mockResolvedValueOnce({
2098
2561
  type: 'DeviceInfo',
2099
2562
  message: {
2100
- hw: { serial_no: 'PR2SERIAL' },
2563
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2101
2564
  fw: { application: { version: '1.2.3' } },
2102
2565
  },
2103
2566
  })
@@ -2229,7 +2692,7 @@ describe('Protocol V2 feature adapter', () => {
2229
2692
  .mockResolvedValueOnce({
2230
2693
  type: 'DeviceInfo',
2231
2694
  message: {
2232
- hw: { serial_no: 'PR2SERIAL' },
2695
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2233
2696
  fw: { application: { version: '1.2.3' } },
2234
2697
  },
2235
2698
  })
@@ -2240,7 +2703,7 @@ describe('Protocol V2 feature adapter', () => {
2240
2703
  .mockResolvedValueOnce({
2241
2704
  type: 'DeviceInfo',
2242
2705
  message: {
2243
- hw: { serial_no: 'PR2SERIAL' },
2706
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2244
2707
  fw: { application: { version: '1.2.4' } },
2245
2708
  },
2246
2709
  })
@@ -2343,7 +2806,7 @@ describe('Protocol V2 feature adapter', () => {
2343
2806
  if (requestType === 'DeviceSessionAskPin') {
2344
2807
  return {
2345
2808
  type: 'Success',
2346
- message: { message: 'ok' },
2809
+ message: { message: 'PIN verified' },
2347
2810
  };
2348
2811
  }
2349
2812
  if (requestType === 'DeviceStatusGet') {
@@ -2365,7 +2828,7 @@ describe('Protocol V2 feature adapter', () => {
2365
2828
  (device as any).features = normalizeProtocolV2Features(
2366
2829
  { ...descriptor, protocolType: 'V2' } as any,
2367
2830
  {
2368
- hw: { serial_no: 'PR2SERIAL' },
2831
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2369
2832
  fw: { application: { version: '1.2.3' } },
2370
2833
  status: { init_states: true },
2371
2834
  }
@@ -2400,7 +2863,7 @@ describe('Protocol V2 feature adapter', () => {
2400
2863
  if (requestType === 'DeviceSessionAskPin') {
2401
2864
  return {
2402
2865
  type: 'Success',
2403
- message: { message: 'ok' },
2866
+ message: { message: 'PIN verified' },
2404
2867
  };
2405
2868
  }
2406
2869
  if (requestType === 'DeviceStatusGet') {
@@ -3396,7 +3859,7 @@ describe('Protocol V2 firmware update targets', () => {
3396
3859
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
3397
3860
  });
3398
3861
 
3399
- test('accepts a missing firmware status handler as an app reboot signal', async () => {
3862
+ test('accepts a missing firmware status handler only after confirming normal mode', async () => {
3400
3863
  const method = new FirmwareUpdateV4({
3401
3864
  id: 1,
3402
3865
  payload: {
@@ -3412,7 +3875,9 @@ describe('Protocol V2 firmware update targets', () => {
3412
3875
  getCommands: () => ({ typedCall }),
3413
3876
  });
3414
3877
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3415
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
3878
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
3879
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
3880
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
3416
3881
  method.postProgressMessage = jest.fn();
3417
3882
 
3418
3883
  await (method as any).waitForProtocolV2FirmwareUpdateComplete([
@@ -3421,6 +3886,70 @@ describe('Protocol V2 firmware update targets', () => {
3421
3886
 
3422
3887
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3423
3888
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
3889
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
3890
+ });
3891
+
3892
+ test.each([
3893
+ [{ mode: 'normal', bootloaderMode: false }, true],
3894
+ [{ mode: 'bootloader', bootloaderMode: true }, false],
3895
+ ])('derives firmware completion from the runtime-state probe: %o', async (features, expected) => {
3896
+ const method = new FirmwareUpdateV4({
3897
+ id: 1,
3898
+ payload: {
3899
+ method: 'firmwareUpdateV4',
3900
+ },
3901
+ });
3902
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
3903
+ const probeProtocolV2RuntimeState = jest.fn().mockResolvedValue(features);
3904
+ (method as any).device = stubDevice({ probeProtocolV2RuntimeState });
3905
+
3906
+ await expect((method as any).probeProtocolV2NormalMode(deviceInfo)).resolves.toBe(expected);
3907
+ expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000);
3908
+ });
3909
+
3910
+ test('keeps polling when the firmware status handler is missing in loader mode', async () => {
3911
+ const method = new FirmwareUpdateV4({
3912
+ id: 1,
3913
+ payload: {
3914
+ method: 'firmwareUpdateV4',
3915
+ },
3916
+ });
3917
+ const typedCall = jest
3918
+ .fn()
3919
+ .mockRejectedValueOnce(new Error('Failure: Handler not registered for this message'))
3920
+ .mockResolvedValueOnce({
3921
+ type: 'DeviceFirmwareUpdateStatus',
3922
+ message: { records: [{ target_id: 4, status: 2 }] },
3923
+ });
3924
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
3925
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
3926
+ callback: () => void
3927
+ ) => {
3928
+ callback();
3929
+ return 0 as any;
3930
+ }) as typeof setTimeout);
3931
+
3932
+ (method as any).device = stubDevice({
3933
+ getCommands: () => ({ typedCall }),
3934
+ });
3935
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3936
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
3937
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(false);
3938
+ method.postProgressMessage = jest.fn();
3939
+
3940
+ try {
3941
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete([
3942
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
3943
+ ]);
3944
+ } finally {
3945
+ setTimeoutSpy.mockRestore();
3946
+ }
3947
+
3948
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3949
+ 'DeviceFirmwareUpdateStatusGet',
3950
+ 'DeviceFirmwareUpdateStatusGet',
3951
+ ]);
3952
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
3424
3953
  });
3425
3954
 
3426
3955
  test('polls target status after update ACK and finishes when all targets complete', async () => {
@@ -3926,6 +4455,62 @@ describe('Protocol V2 firmware update targets', () => {
3926
4455
  getFirmwareLatestReleaseSpy.mockRestore();
3927
4456
  });
3928
4457
 
4458
+ test('ignores an unsupported remote ROMLOADER component when installing selected targets', async () => {
4459
+ const method = new FirmwareUpdateV4({
4460
+ id: 1,
4461
+ payload: {
4462
+ method: 'firmwareUpdateV4',
4463
+ platform: 'web',
4464
+ targetsToUpdate: ['app_v1'],
4465
+ },
4466
+ });
4467
+ method.init();
4468
+ const applicationBinary = new Uint8Array([1]).buffer;
4469
+ const downloadRemoteComponent = jest.fn().mockResolvedValue({
4470
+ fileName: 'application_p1.bin',
4471
+ binary: applicationBinary,
4472
+ targetId: 4,
4473
+ kind: 'firmware',
4474
+ });
4475
+ (method as any).downloadRemoteProtocolV2Component = downloadRemoteComponent;
4476
+ const getFirmwareLatestReleaseSpy = jest
4477
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
4478
+ .mockReturnValue({
4479
+ required: false,
4480
+ version: [1, 0, 0],
4481
+ url: '',
4482
+ installOrder: ['romloader', 'applicationP1'],
4483
+ components: {
4484
+ romloader: {
4485
+ target: 'ROMLOADER',
4486
+ url: 'https://example.com/romloader.bin',
4487
+ },
4488
+ applicationP1: {
4489
+ target: 'APPLICATION_P1',
4490
+ url: 'https://example.com/application-p1.bin',
4491
+ },
4492
+ },
4493
+ fingerprint: '',
4494
+ changelog: { 'zh-CN': '', 'en-US': '' },
4495
+ });
4496
+
4497
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
4498
+ deviceType: 'pro2',
4499
+ firmwareVersion: '0.0.0',
4500
+ });
4501
+
4502
+ expect(downloadRemoteComponent).toHaveBeenCalledTimes(1);
4503
+ expect(downloadRemoteComponent).toHaveBeenCalledWith(
4504
+ 'applicationP1',
4505
+ expect.objectContaining({ target: 'APPLICATION_P1' })
4506
+ );
4507
+ expect(remoteBinaries.fwBinaryMap).toEqual([
4508
+ { fileName: 'application_p1.bin', binary: applicationBinary, targetId: 4 },
4509
+ ]);
4510
+
4511
+ getFirmwareLatestReleaseSpy.mockRestore();
4512
+ });
4513
+
3929
4514
  test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
3930
4515
  const method = new FirmwareUpdateV4({
3931
4516
  id: 1,
@@ -4496,8 +5081,14 @@ describe('Protocol V2 firmware update targets', () => {
4496
5081
  return 0 as any;
4497
5082
  }) as typeof setTimeout);
4498
5083
 
5084
+ const initialize = jest.fn().mockResolvedValue(undefined);
4499
5085
  (method as any).device = stubDevice({
4500
5086
  getCommands: () => ({ typedCall }),
5087
+ initialize,
5088
+ });
5089
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5090
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue({
5091
+ hw: { serial_no: 'PRO2-PHYSICAL-1' },
4501
5092
  });
4502
5093
  method.postProgressMessage = jest.fn();
4503
5094
 
@@ -4513,6 +5104,9 @@ describe('Protocol V2 firmware update targets', () => {
4513
5104
  }
4514
5105
 
4515
5106
  expect(writeOffsets).toEqual([0, 4000, 0, 4000, 8000]);
5107
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
5108
+ expect((method as any).verifyProtocolV2ReconnectIdentity).toHaveBeenCalledTimes(1);
5109
+ expect(initialize).toHaveBeenCalledTimes(1);
4516
5110
  });
4517
5111
 
4518
5112
  test('rejects a chunk-relative processed_byte during firmware staging', async () => {
@@ -5213,6 +5807,45 @@ describe('Protocol V2 protected method execution', () => {
5213
5807
  ]);
5214
5808
  });
5215
5809
 
5810
+ test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
5811
+ const initialError = deviceLockedError();
5812
+ const restoreError = new Error('Invalid wallet session');
5813
+ const method = {
5814
+ name: 'evmSignMessage',
5815
+ payload: { passphraseState: 'hidden-state' },
5816
+ useDevicePassphraseState: true,
5817
+ unlockPolicy: 'retry-on-locked',
5818
+ run: jest.fn().mockRejectedValueOnce(initialError),
5819
+ };
5820
+ const typedCall = jest.fn((requestType: string) => {
5821
+ if (requestType === 'ProtocolInfoRequest') {
5822
+ return Promise.resolve({ message: { version: 2 } });
5823
+ }
5824
+ if (requestType === 'DeviceSessionGet') {
5825
+ return Promise.reject(restoreError);
5826
+ }
5827
+ throw new Error(`Unexpected request: ${requestType}`);
5828
+ });
5829
+ const device = {
5830
+ features: { unlocked: true, passphraseProtection: true },
5831
+ passphraseState: 'hidden-state',
5832
+ commands: { typedCall },
5833
+ isProtocolV2: () => true,
5834
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
5835
+ getCurrentPassphraseProtection: () => true,
5836
+ getInternalState: () => 'hidden-session',
5837
+ clearInternalState: jest.fn(),
5838
+ getCurrentDeviceId: () => 'wallet-device-id',
5839
+ updateInternalState: jest.fn(),
5840
+ };
5841
+
5842
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
5843
+ expect(method.run).toHaveBeenCalledTimes(1);
5844
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
5845
+ expect(device.clearInternalState).toHaveBeenCalledTimes(1);
5846
+ expect(device.updateInternalState).not.toHaveBeenCalled();
5847
+ });
5848
+
5216
5849
  test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
5217
5850
  const calls: string[] = [];
5218
5851
  const method = {