@onekeyfe/hd-core 1.2.0-alpha.33 → 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 +331 -27
  14. package/__tests__/pro2Wallpaper.test.ts +1 -1
  15. package/__tests__/protocol-v2.test.ts +707 -74
  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 +455 -221
  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 +115 -33
  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
@@ -3,6 +3,7 @@ import { DeviceSessionPinType } from '@onekeyfe/hd-transport';
3
3
 
4
4
  import GetPassphraseState from '../src/api/GetPassphraseState';
5
5
  import OpenWalletSession from '../src/api/OpenWalletSession';
6
+ import { Device } from '../src/device/Device';
6
7
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
7
8
 
8
9
  jest.mock('../src/data/config', () => ({
@@ -26,7 +27,22 @@ const createDevice = ({
26
27
  passphraseProtection,
27
28
  attachToPinEnabled: false,
28
29
  },
29
- commands: { typedCall, promptPassphrase },
30
+ commands: {
31
+ typedCall: jest.fn((request: string, ...args: unknown[]) => {
32
+ if (request === 'DeviceStatusGet') {
33
+ return {
34
+ message: {
35
+ device_id: 'device-1',
36
+ unlocked: true,
37
+ unlocked_attach_pin: false,
38
+ passphrase_enabled: passphraseProtection,
39
+ },
40
+ };
41
+ }
42
+ return typedCall(request, ...args);
43
+ }),
44
+ promptPassphrase,
45
+ },
30
46
  getDeviceState: jest.fn().mockResolvedValue({
31
47
  identity: { deviceId: 'device-1' },
32
48
  status: { passphraseProtection },
@@ -47,6 +63,12 @@ const createDevice = ({
47
63
  }),
48
64
  lockDevice: jest.fn(),
49
65
  unlockDevice: jest.fn(),
66
+ updateProtocolV2Status: jest.fn((status: Record<string, unknown>) => {
67
+ device.features.unlocked = status.unlocked ?? device.features.unlocked;
68
+ device.features.unlockedAttachPin =
69
+ status.unlocked_attach_pin ?? device.features.unlockedAttachPin;
70
+ return device.features;
71
+ }),
50
72
  initialize: jest.fn(),
51
73
  emit: jest.fn(),
52
74
  isProtocolV2: () => true,
@@ -79,7 +101,7 @@ describe('openWalletSession', () => {
79
101
  const typedCall = jest
80
102
  .fn()
81
103
  .mockResolvedValueOnce({ message: { version: 2 } })
82
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
104
+ .mockResolvedValueOnce({ message: {} })
83
105
  .mockResolvedValueOnce({
84
106
  message: {
85
107
  btc_test_address: 'pro2-wallet-state',
@@ -186,7 +208,7 @@ describe('openWalletSession', () => {
186
208
  protocol: 'V1',
187
209
  walletType: 'standard',
188
210
  deviceId: 'device-1',
189
- passphraseState: null,
211
+ passphraseState: 'main-wallet-state',
190
212
  resumed: false,
191
213
  });
192
214
  expect(method.payload.useEmptyPassphrase).toBe(true);
@@ -200,7 +222,7 @@ describe('openWalletSession', () => {
200
222
  const typedCall = jest
201
223
  .fn()
202
224
  .mockResolvedValueOnce({ message: { version: 2 } })
203
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
225
+ .mockResolvedValueOnce({ message: {} })
204
226
  .mockResolvedValueOnce({
205
227
  message: {
206
228
  btc_test_address: 'hidden-state',
@@ -234,7 +256,7 @@ describe('openWalletSession', () => {
234
256
  const typedCall = jest
235
257
  .fn()
236
258
  .mockResolvedValueOnce({ message: { version: 2 } })
237
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
259
+ .mockResolvedValueOnce({ message: {} })
238
260
  .mockResolvedValueOnce({
239
261
  message: {
240
262
  btc_test_address: 'hidden-state-after-unlock',
@@ -267,7 +289,7 @@ describe('openWalletSession', () => {
267
289
  passphraseState: 'hidden-state-after-unlock',
268
290
  resumed: false,
269
291
  });
270
- expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main);
292
+ expect(device.unlockDevice).not.toHaveBeenCalled();
271
293
  expect(promptPassphrase).toHaveBeenCalledTimes(1);
272
294
  });
273
295
 
@@ -275,7 +297,7 @@ describe('openWalletSession', () => {
275
297
  const typedCall = jest
276
298
  .fn()
277
299
  .mockResolvedValueOnce({ message: { version: 2 } })
278
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
300
+ .mockResolvedValueOnce({ message: {} })
279
301
  .mockResolvedValueOnce({
280
302
  message: {
281
303
  btc_test_address: 'new-hidden-state',
@@ -315,7 +337,7 @@ describe('openWalletSession', () => {
315
337
  const typedCall = jest
316
338
  .fn()
317
339
  .mockResolvedValueOnce({ message: { version: 2 } })
318
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
340
+ .mockResolvedValueOnce({ message: {} })
319
341
  .mockResolvedValueOnce({
320
342
  message: {
321
343
  btc_test_address: 'current-device-state',
@@ -484,8 +506,56 @@ describe('openWalletSession', () => {
484
506
  });
485
507
  });
486
508
 
509
+ test('rejects a Protocol V1 resume when the device returns another hidden wallet', async () => {
510
+ const typedCall = jest.fn().mockResolvedValue({
511
+ type: 'PassphraseState',
512
+ message: {
513
+ passphrase_state: 'other-hidden-state',
514
+ session_id: 'other-session',
515
+ unlocked_attach_pin: false,
516
+ },
517
+ });
518
+ const method = new OpenWalletSession({
519
+ payload: {
520
+ method: 'openWalletSession',
521
+ connectId: 'connect-id',
522
+ mode: 'resume-hidden',
523
+ deviceId: 'device-1',
524
+ passphraseState: 'hidden-state',
525
+ },
526
+ });
527
+ method.init();
528
+ deviceWalletSessionStore.set('device-1', 'hidden-state', 'known-session');
529
+ const device = createDevice({ typedCall });
530
+ device.isProtocolV2 = () => false;
531
+ device.features = {
532
+ ...device.features,
533
+ deviceId: 'device-1',
534
+ unlocked: true,
535
+ sessionId: null,
536
+ };
537
+ device.getCurrentFirmwareVersionString = () => '4.15.0';
538
+ device.getCurrentDeviceType = () => EDeviceType.Pro;
539
+ device.getFeatures = jest.fn();
540
+ method.device = device as any;
541
+
542
+ await expect(method.run()).rejects.toMatchObject({
543
+ errorCode: HardwareErrorCode.DeviceCheckPassphraseStateError,
544
+ });
545
+ expect(device.clearInternalState).toHaveBeenCalledTimes(1);
546
+ expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBeUndefined();
547
+ });
548
+
487
549
  test('opens the standard wallet without selecting a hidden wallet', async () => {
488
- const typedCall = jest.fn().mockResolvedValue({ message: { version: 2 } });
550
+ const typedCall = jest
551
+ .fn()
552
+ .mockResolvedValueOnce({ message: { version: 2 } })
553
+ .mockResolvedValueOnce({
554
+ message: {
555
+ btc_test_address: 'main-wallet-state',
556
+ session_id: 'main-wallet-session',
557
+ },
558
+ });
489
559
  const promptPassphrase = jest.fn();
490
560
  const method = new OpenWalletSession({
491
561
  payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'standard' },
@@ -499,19 +569,29 @@ describe('openWalletSession', () => {
499
569
  protocol: 'V2',
500
570
  walletType: 'standard',
501
571
  deviceId: 'device-1',
502
- passphraseState: null,
572
+ passphraseState: 'main-wallet-state',
503
573
  resumed: false,
504
574
  });
505
- expect(typedCall).toHaveBeenCalledTimes(1);
575
+ expect(typedCall).toHaveBeenCalledTimes(2);
506
576
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
507
577
  eventless_wallet_session: true,
508
578
  });
579
+ expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main);
580
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
509
581
  expect(promptPassphrase).not.toHaveBeenCalled();
510
582
  expect(device.passphraseState).toBeUndefined();
511
583
  });
512
584
 
513
585
  test('opens a locked Protocol V2 standard wallet after deviceId becomes available', async () => {
514
- const typedCall = jest.fn().mockResolvedValue({ message: { version: 2 } });
586
+ const typedCall = jest
587
+ .fn()
588
+ .mockResolvedValueOnce({ message: { version: 2 } })
589
+ .mockResolvedValueOnce({
590
+ message: {
591
+ btc_test_address: 'main-wallet-state',
592
+ session_id: 'main-wallet-session',
593
+ },
594
+ });
515
595
  const method = new OpenWalletSession({
516
596
  payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'standard' },
517
597
  });
@@ -538,21 +618,21 @@ describe('openWalletSession', () => {
538
618
  protocol: 'V2',
539
619
  walletType: 'standard',
540
620
  deviceId: 'device-1',
541
- passphraseState: null,
621
+ passphraseState: 'main-wallet-state',
542
622
  resumed: false,
543
623
  });
544
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
624
+ expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main);
545
625
  expect(device.getDeviceState).toHaveBeenCalledTimes(2);
546
626
  });
547
627
 
548
- test('rejects a standard-wallet request when Protocol V2 is unlocked by Attach PIN', async () => {
628
+ test('switches from Attach PIN to Main PIN before opening the standard wallet', async () => {
549
629
  const typedCall = jest
550
630
  .fn()
551
631
  .mockResolvedValueOnce({ message: { version: 2 } })
552
632
  .mockResolvedValueOnce({
553
633
  message: {
554
- btc_test_address: 'attach-wallet-state',
555
- session_id: 'attach-wallet-session',
634
+ btc_test_address: 'main-wallet-state',
635
+ session_id: 'main-wallet-session',
556
636
  },
557
637
  });
558
638
  const method = new OpenWalletSession({
@@ -561,20 +641,25 @@ describe('openWalletSession', () => {
561
641
  method.init();
562
642
  const device = createDevice({ typedCall });
563
643
  device.features.unlockedAttachPin = true;
644
+ device.unlockDevice = jest.fn().mockImplementation(() => {
645
+ device.features.unlockedAttachPin = false;
646
+ return Promise.resolve(device.features);
647
+ });
564
648
  method.device = device as any;
565
649
 
566
- await expect(method.run()).rejects.toMatchObject({
567
- errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
650
+ await expect(method.run()).resolves.toMatchObject({
651
+ walletType: 'standard',
652
+ passphraseState: 'main-wallet-state',
568
653
  });
569
- expect(device.lockDevice).toHaveBeenCalled();
570
- expect(device.clearInternalState).toHaveBeenCalled();
654
+ expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main);
655
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
571
656
  });
572
657
 
573
658
  test('selects a hidden wallet without exposing the internal device session', async () => {
574
659
  const typedCall = jest
575
660
  .fn()
576
661
  .mockResolvedValueOnce({ message: { version: 2 } })
577
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
662
+ .mockResolvedValueOnce({ message: {} })
578
663
  .mockResolvedValueOnce({
579
664
  message: {
580
665
  btc_test_address: 'hidden-state',
@@ -606,11 +691,11 @@ describe('openWalletSession', () => {
606
691
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('hidden-session');
607
692
  });
608
693
 
609
- test('selects on-device passphrase entry before getting the prepared session', async () => {
694
+ test('gets the prepared session after on-device passphrase entry', async () => {
610
695
  const typedCall = jest
611
696
  .fn()
612
697
  .mockResolvedValueOnce({ message: { version: 2 } })
613
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
698
+ .mockResolvedValueOnce({ message: {} })
614
699
  .mockResolvedValueOnce({
615
700
  message: { btc_test_address: 'device-state', session_id: 'device-session' },
616
701
  });
@@ -635,7 +720,7 @@ describe('openWalletSession', () => {
635
720
  const typedCall = jest
636
721
  .fn()
637
722
  .mockResolvedValueOnce({ message: { version: 2 } })
638
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
723
+ .mockResolvedValueOnce({ message: {} })
639
724
  .mockResolvedValueOnce({
640
725
  message: { btc_test_address: 'host-state', session_id: 'host-session' },
641
726
  });
@@ -670,6 +755,52 @@ describe('openWalletSession', () => {
670
755
  passphrase: 'host hidden wallet',
671
756
  on_device: false,
672
757
  });
758
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
759
+ });
760
+
761
+ test('normalizes a Unicode Host passphrase before sending it to Pro2 firmware', async () => {
762
+ const typedCall = jest
763
+ .fn()
764
+ .mockResolvedValueOnce({ message: { version: 2 } })
765
+ .mockResolvedValueOnce({ message: {} })
766
+ .mockResolvedValueOnce({
767
+ message: { btc_test_address: 'host-state', session_id: 'host-session' },
768
+ });
769
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'caf\u00e9' });
770
+ const method = new OpenWalletSession({
771
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
772
+ });
773
+ method.init();
774
+ method.device = createDevice({ typedCall, promptPassphrase }) as any;
775
+
776
+ await expect(method.run()).resolves.toMatchObject({ passphraseState: 'host-state' });
777
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
778
+ passphrase: 'cafe\u0301',
779
+ on_device: false,
780
+ });
781
+ });
782
+
783
+ test('accepts a Host passphrase at the 50-byte firmware boundary', async () => {
784
+ const passphrase = 'a'.repeat(50);
785
+ const typedCall = jest
786
+ .fn()
787
+ .mockResolvedValueOnce({ message: { version: 2 } })
788
+ .mockResolvedValueOnce({ message: {} })
789
+ .mockResolvedValueOnce({
790
+ message: { btc_test_address: 'host-state', session_id: 'host-session' },
791
+ });
792
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase });
793
+ const method = new OpenWalletSession({
794
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
795
+ });
796
+ method.init();
797
+ method.device = createDevice({ typedCall, promptPassphrase }) as any;
798
+
799
+ await expect(method.run()).resolves.toMatchObject({ passphraseState: 'host-state' });
800
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
801
+ passphrase,
802
+ on_device: false,
803
+ });
673
804
  });
674
805
 
675
806
  test('selects Attach PIN only when the device reports an existing binding', async () => {
@@ -705,6 +836,179 @@ describe('openWalletSession', () => {
705
836
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
706
837
  });
707
838
 
839
+ test('uses the complete Attach PIN wire flow without a main PIN unlock', async () => {
840
+ const typedCall = jest
841
+ .fn()
842
+ .mockResolvedValueOnce({ message: { version: 2 } })
843
+ .mockResolvedValueOnce({ message: { message: 'PIN verified' } })
844
+ .mockResolvedValueOnce({
845
+ message: { btc_test_address: 'attach-state', session_id: 'attach-session' },
846
+ });
847
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
848
+ const method = new OpenWalletSession({
849
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
850
+ });
851
+ method.init();
852
+ const device = createDevice({ typedCall, promptPassphrase });
853
+ device.features.attachToPinEnabled = true;
854
+ device.unlockDevice = Device.prototype.unlockDevice.bind(device);
855
+ method.device = device as any;
856
+
857
+ await expect(method.run()).resolves.toMatchObject({
858
+ walletType: 'hidden',
859
+ passphraseState: 'attach-state',
860
+ });
861
+ expect(device.commands.typedCall.mock.calls).toEqual([
862
+ ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
863
+ ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.AttachToPin }],
864
+ ['DeviceStatusGet', 'DeviceStatus', {}],
865
+ ['DeviceSessionGet', 'DeviceSession', {}],
866
+ ]);
867
+ expect(device.commands.typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
868
+ type: DeviceSessionPinType.Main,
869
+ });
870
+ });
871
+
872
+ test.each([
873
+ ['no selection', {}],
874
+ ['an empty Host passphrase', { passphrase: '' }],
875
+ ['more than one selection', { passphrase: 'host hidden wallet', passphraseOnDevice: true }],
876
+ ])('rejects %s before sending a wallet selection command', async (_name, response) => {
877
+ const typedCall = jest.fn().mockResolvedValueOnce({ message: { version: 2 } });
878
+ const promptPassphrase = jest.fn().mockResolvedValue(response);
879
+ const method = new OpenWalletSession({
880
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
881
+ });
882
+ method.init();
883
+ method.device = createDevice({ typedCall, promptPassphrase }) as any;
884
+
885
+ await expect(method.run()).rejects.toMatchObject({
886
+ errorCode: HardwareErrorCode.RuntimeError,
887
+ message: 'Wallet selection must contain exactly one passphrase access mode.',
888
+ });
889
+ expect(typedCall).toHaveBeenCalledTimes(1);
890
+ });
891
+
892
+ test('stops before sending a wallet command when the App cancels selection', async () => {
893
+ const cancellation = new Error('Wallet selection cancelled');
894
+ const typedCall = jest.fn().mockResolvedValueOnce({ message: { version: 2 } });
895
+ const promptPassphrase = jest.fn().mockRejectedValue(cancellation);
896
+ const method = new OpenWalletSession({
897
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
898
+ });
899
+ method.init();
900
+ method.device = createDevice({ typedCall, promptPassphrase }) as any;
901
+
902
+ await expect(method.run()).rejects.toBe(cancellation);
903
+ expect(typedCall).toHaveBeenCalledTimes(1);
904
+ });
905
+
906
+ test.each(['User cancelled', 'Device disconnected'])(
907
+ 'does not issue a resume request after the wallet selection reports %s',
908
+ async message => {
909
+ const typedCall = jest
910
+ .fn()
911
+ .mockResolvedValueOnce({ message: { version: 2 } })
912
+ .mockRejectedValueOnce(new Error(message));
913
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
914
+ const method = new OpenWalletSession({
915
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
916
+ });
917
+ method.init();
918
+ method.device = createDevice({ typedCall, promptPassphrase }) as any;
919
+
920
+ await expect(method.run()).rejects.toThrow(message);
921
+ expect(typedCall).toHaveBeenCalledTimes(2);
922
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
923
+ }
924
+ );
925
+
926
+ test.each([
927
+ ['a NUL byte', 'hidden\0wallet'],
928
+ ['more than 50 UTF-8 bytes', 'a'.repeat(51)],
929
+ ['more than 50 UTF-8 bytes after NFKD normalization', '\u00e9'.repeat(17)],
930
+ ['an unpaired UTF-16 surrogate', '\ud800'],
931
+ ])('rejects a Host passphrase containing %s', async (_name, passphrase) => {
932
+ const typedCall = jest.fn().mockResolvedValueOnce({ message: { version: 2 } });
933
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase });
934
+ const method = new OpenWalletSession({
935
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
936
+ });
937
+ method.init();
938
+ method.device = createDevice({ typedCall, promptPassphrase }) as any;
939
+
940
+ await expect(method.run()).rejects.toMatchObject({
941
+ errorCode: HardwareErrorCode.RuntimeError,
942
+ message: 'Host passphrase must contain 1 to 50 valid UTF-8 bytes without NUL.',
943
+ });
944
+ expect(typedCall).toHaveBeenCalledTimes(1);
945
+ });
946
+
947
+ test('rejects an unavailable Attach PIN selection before asking the device for a PIN', async () => {
948
+ const typedCall = jest.fn().mockResolvedValueOnce({ message: { version: 2 } });
949
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
950
+ const method = new OpenWalletSession({
951
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
952
+ });
953
+ method.init();
954
+ const device = createDevice({ typedCall, promptPassphrase });
955
+ device.features.attachToPinEnabled = false;
956
+ method.device = device as any;
957
+
958
+ await expect(method.run()).rejects.toMatchObject({
959
+ errorCode: HardwareErrorCode.RuntimeError,
960
+ message: 'Attach PIN wallet selection is unavailable on this device.',
961
+ });
962
+ expect(device.unlockDevice).not.toHaveBeenCalled();
963
+ expect(typedCall).toHaveBeenCalledTimes(1);
964
+ });
965
+
966
+ test('unlocks and retries hidden-wallet preparation when the device is locked', async () => {
967
+ const lockedError = { errorCode: HardwareErrorCode.DeviceLocked };
968
+ const typedCall = jest
969
+ .fn()
970
+ .mockResolvedValueOnce({ message: { version: 2 } })
971
+ .mockRejectedValueOnce(lockedError)
972
+ .mockResolvedValueOnce({ message: {} })
973
+ .mockResolvedValueOnce({
974
+ message: { btc_test_address: 'hidden-state', session_id: 'hidden-session' },
975
+ });
976
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
977
+ const method = new OpenWalletSession({
978
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
979
+ });
980
+ method.init();
981
+ const device = createDevice({ typedCall, promptPassphrase });
982
+ method.device = device as any;
983
+
984
+ await expect(method.run()).resolves.toMatchObject({
985
+ walletType: 'hidden',
986
+ passphraseState: 'hidden-state',
987
+ });
988
+ expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main);
989
+ expect(typedCall).toHaveBeenCalledTimes(4);
990
+ });
991
+
992
+ test('does not fall back when wallet preparation is rejected', async () => {
993
+ const invalidSessionError = { errorCode: HardwareErrorCode.WalletSessionInvalid };
994
+ const typedCall = jest
995
+ .fn()
996
+ .mockResolvedValueOnce({ message: { version: 2 } })
997
+ .mockRejectedValueOnce(invalidSessionError);
998
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
999
+ const method = new OpenWalletSession({
1000
+ payload: { method: 'openWalletSession', connectId: 'connect-id', mode: 'select-hidden' },
1001
+ });
1002
+ method.init();
1003
+ const device = createDevice({ typedCall, promptPassphrase });
1004
+ method.device = device as any;
1005
+
1006
+ await expect(method.run()).rejects.toBe(invalidSessionError);
1007
+ expect(promptPassphrase).toHaveBeenCalledTimes(1);
1008
+ expect(typedCall).toHaveBeenCalledTimes(2);
1009
+ expect(device.updateInternalState).not.toHaveBeenCalled();
1010
+ });
1011
+
708
1012
  test.each([
709
1013
  {
710
1014
  missingField: 'session_id',
@@ -720,7 +1024,7 @@ describe('openWalletSession', () => {
720
1024
  const typedCall = jest
721
1025
  .fn()
722
1026
  .mockResolvedValueOnce({ message: { version: 2 } })
723
- .mockResolvedValueOnce({ message: { message: 'passphrase prepared' } })
1027
+ .mockResolvedValueOnce({ message: {} })
724
1028
  .mockResolvedValueOnce({ message });
725
1029
  const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
726
1030
  const method = new OpenWalletSession({
@@ -732,7 +1036,7 @@ describe('openWalletSession', () => {
732
1036
 
733
1037
  await expect(method.run()).rejects.toMatchObject({
734
1038
  errorCode: HardwareErrorCode.RuntimeError,
735
- message: 'DeviceSessionGet returned an incomplete DeviceSession response.',
1039
+ message: 'Device returned an incomplete DeviceSession response.',
736
1040
  });
737
1041
  expect(device.clearInternalState).toHaveBeenCalled();
738
1042
  expect(device.updateInternalState).not.toHaveBeenCalled();
@@ -12,7 +12,7 @@ describe('encodePro2Wallpaper', () => {
12
12
  expect(Array.from(result.data.slice(0, 12))).toEqual([
13
13
  0x19, 0x12, 0, 0, 2, 0, 1, 0, 4, 0, 0, 0,
14
14
  ]);
15
- expect(Array.from(result.data.slice(12))).toEqual([0x00, 0xf0, 0xc0, 0x07]);
15
+ expect(Array.from(result.data.slice(12))).toEqual([0x00, 0xf8, 0xe0, 0x07]);
16
16
  });
17
17
 
18
18
  test('encodes transparent pixels as RGB565A8 with alpha plane after RGB data', () => {