@onekeyfe/hd-core 1.2.0-alpha.136 → 1.2.0-alpha.140

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 (30) hide show
  1. package/__tests__/DeviceCommands.test.ts +1 -1
  2. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +48 -0
  3. package/__tests__/device-lifecycle-events.test.ts +21 -17
  4. package/__tests__/device-settings.test.ts +44 -8
  5. package/__tests__/device-state-mapper.test.ts +38 -0
  6. package/__tests__/device-wallet-session-store.test.ts +21 -79
  7. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +37 -1
  8. package/__tests__/protocol-v2-unlock-policy.test.ts +97 -1
  9. package/__tests__/protocol-v2.test.ts +102 -21
  10. package/dist/api/FirmwareUpdateV4.d.ts +1 -1
  11. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  12. package/dist/api/device/DeviceChangePin.d.ts.map +1 -1
  13. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  14. package/dist/api/device/DeviceVerify.d.ts +1 -0
  15. package/dist/api/device/DeviceVerify.d.ts.map +1 -1
  16. package/dist/api/device/DeviceWipe.d.ts.map +1 -1
  17. package/dist/api/firmware/protocolV2Release.d.ts.map +1 -1
  18. package/dist/device/Device.d.ts.map +1 -1
  19. package/dist/device/DeviceStateMapper.d.ts.map +1 -1
  20. package/dist/index.js +93 -82
  21. package/package.json +4 -4
  22. package/src/api/FirmwareUpdateV4.ts +52 -78
  23. package/src/api/device/DeviceChangePin.ts +4 -1
  24. package/src/api/device/DeviceSettings.ts +13 -0
  25. package/src/api/device/DeviceVerify.ts +25 -0
  26. package/src/api/device/DeviceWipe.ts +4 -1
  27. package/src/api/firmware/protocolV2Release.ts +7 -2
  28. package/src/device/Device.ts +17 -42
  29. package/src/device/DeviceCommands.ts +1 -1
  30. package/src/device/DeviceStateMapper.ts +11 -7
@@ -485,7 +485,7 @@ describe('DeviceCommands failure mapping', () => {
485
485
  });
486
486
  });
487
487
 
488
- it.each(['Cancelled on device', 'Confirm dismissed'])(
488
+ it.each(['Cancelled on device', 'Confirm dismissed', 'Update cancelled'])(
489
489
  'maps legacy Protocol V2 cancellation message "%s" without a subcode',
490
490
  async message => {
491
491
  const commands = createCommands();
@@ -620,6 +620,54 @@ describe('checkAllFirmwareRelease Protocol V2 support', () => {
620
620
  });
621
621
  });
622
622
 
623
+ test('selects the Protocol V2 resource archive using the DeviceState identity', async () => {
624
+ const releaseWithResources: IFirmwareReleaseInfo = {
625
+ ...release,
626
+ resources: { source: resourceSource },
627
+ };
628
+ const method = new CheckAllFirmwareRelease({
629
+ id: 1,
630
+ payload: {
631
+ method: 'checkAllFirmwareRelease',
632
+ platform: 'desktop',
633
+ protocolV2ForceUpdateTargets: ['resource'],
634
+ },
635
+ });
636
+ method.init();
637
+ method.device = {
638
+ isProtocolV2: () => true,
639
+ features: {
640
+ firmwareVersion: '1.0.0',
641
+ serialNo: 'device-without-model-prefix',
642
+ },
643
+ getDeviceState: jest.fn().mockResolvedValue({
644
+ identity: { deviceType: 'pro2', firmwareType: EFirmwareType.Universal },
645
+ status: { mode: 'normal' },
646
+ versions: currentVersions,
647
+ }),
648
+ } as unknown as CheckAllFirmwareRelease['device'];
649
+ const getLatestReleaseSpy = jest
650
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
651
+ .mockImplementation(features =>
652
+ features.deviceType === 'pro2' ? releaseWithResources : undefined
653
+ );
654
+
655
+ await expect(method.run()).resolves.toMatchObject({
656
+ deviceType: 'pro2',
657
+ resourceArchive: resourceSource,
658
+ targetsToUpdate: expect.arrayContaining(['resource']),
659
+ firmwareUpdatePlan: {
660
+ executor: 'v4',
661
+ deviceModel: 'pro2',
662
+ targetsToUpdate: expect.arrayContaining(['resource']),
663
+ },
664
+ });
665
+ expect(getLatestReleaseSpy).toHaveBeenCalledWith(
666
+ expect.objectContaining({ deviceType: 'pro2' }),
667
+ EFirmwareType.Universal
668
+ );
669
+ });
670
+
623
671
  test.each(['resource', 'boot_resources'] as const)(
624
672
  'allows an explicitly forced Protocol V2 resource-only target through %s',
625
673
  async forceTarget => {
@@ -343,25 +343,29 @@ describe('public device lifecycle events', () => {
343
343
  }
344
344
  );
345
345
 
346
- test('sends a fallback Cancel for an acquired Protocol V2 BLE call without a prompt callback', async () => {
347
- jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
348
- const device = createInitializedDevice('V2');
349
- const post = jest.fn().mockResolvedValue(undefined);
350
- const cancelDevice = jest.fn(() => cancelDeviceInPrompt(device, false));
351
- const cancel = jest.fn().mockResolvedValue(undefined);
352
- (device as unknown as { deviceAcquired: boolean }).deviceAcquired = true;
353
- device.commands = {
354
- transport: { post },
355
- cancelDevice,
356
- cancel,
357
- } as never;
346
+ test.each(['react-native', 'webusb', 'desktop-webusb'] as const)(
347
+ 'sends a fallback Cancel for an acquired Protocol V2 %s call without a prompt callback',
348
+ async env => {
349
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
350
+ const device = createInitializedDevice('V2');
351
+ const post = jest.fn().mockResolvedValue(undefined);
352
+ const cancelDevice = jest.fn(() => cancelDeviceInPrompt(device, false));
353
+ const cancel = jest.fn().mockResolvedValue(undefined);
354
+ device.originalDescriptor.session = device.mainId;
355
+ (device as unknown as { deviceAcquired: boolean }).deviceAcquired = true;
356
+ device.commands = {
357
+ transport: { post },
358
+ cancelDevice,
359
+ cancel,
360
+ } as never;
358
361
 
359
- await device.interruptionFromUser();
362
+ await device.interruptionFromUser();
360
363
 
361
- expect(cancelDevice).toHaveBeenCalledTimes(1);
362
- expect(post).toHaveBeenCalledWith(device.mainId, 'Cancel', {});
363
- expect(cancel).toHaveBeenCalledTimes(1);
364
- });
364
+ expect(cancelDevice).toHaveBeenCalledTimes(1);
365
+ expect(post).toHaveBeenCalledWith(device.mainId, 'Cancel', {});
366
+ expect(cancel).toHaveBeenCalledTimes(1);
367
+ }
368
+ );
365
369
 
366
370
  test('waits for the canceled run to finish releasing before cancellation completes', async () => {
367
371
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
@@ -361,7 +361,7 @@ describe('DeviceSettings protocol routing', () => {
361
361
  expect(getDeviceState).toHaveBeenCalledWith({ refreshSections: ['settings'] });
362
362
  });
363
363
 
364
- it('does not overwrite Pro2 passphrase state when refreshed state does not match', async () => {
364
+ it('persists a confirmed Pro2 passphrase enable when the refreshed state is unavailable', async () => {
365
365
  const { device, getDeviceState, updateState } = createDevice({ protocol: 'V2' });
366
366
  getDeviceState.mockResolvedValue({
367
367
  status: { passphraseProtection: false },
@@ -377,10 +377,13 @@ describe('DeviceSettings protocol routing', () => {
377
377
  (method as any).device = device;
378
378
 
379
379
  await expect(method.run()).resolves.toEqual({ message: 'Success' });
380
- expect(updateState).not.toHaveBeenCalled();
380
+ expect(updateState).toHaveBeenCalledWith(
381
+ { status: { passphraseProtection: true } },
382
+ 'settings-write'
383
+ );
381
384
  });
382
385
 
383
- it('keeps the previous Pro2 passphrase state when refreshed state is unavailable after locking', async () => {
386
+ it('persists a confirmed Pro2 passphrase disable when refreshed state is unavailable after locking', async () => {
384
387
  const { device, getDeviceState, updateState } = createDevice({ protocol: 'V2' });
385
388
  getDeviceState
386
389
  .mockResolvedValueOnce({
@@ -400,10 +403,18 @@ describe('DeviceSettings protocol routing', () => {
400
403
  (method as any).device = device;
401
404
 
402
405
  await expect(method.run()).resolves.toEqual({ message: 'Success' });
403
- expect(updateState).not.toHaveBeenCalled();
406
+ expect(updateState).toHaveBeenCalledWith(
407
+ {
408
+ status: {
409
+ passphraseProtection: false,
410
+ unlockedAttachPin: false,
411
+ },
412
+ },
413
+ 'settings-write'
414
+ );
404
415
  });
405
416
 
406
- it('preserves confirmed Pro2 passphrase state when the post-toggle status is locked', async () => {
417
+ it('persists a confirmed Pro2 passphrase disable when the Attach PIN session locks', async () => {
407
418
  let statusReadCount = 0;
408
419
  const typedCall = jest.fn().mockImplementation((requestType: string) => {
409
420
  if (requestType === 'DeviceStatusGet') {
@@ -411,8 +422,13 @@ describe('DeviceSettings protocol routing', () => {
411
422
  return {
412
423
  message:
413
424
  statusReadCount === 1
414
- ? { init_states: true, unlocked: true, passphrase_enabled: true }
415
- : { init_states: true, unlocked: false, passphrase_enabled: false },
425
+ ? {
426
+ init_states: true,
427
+ unlocked: true,
428
+ passphrase_enabled: true,
429
+ unlocked_by_attach_to_pin: true,
430
+ }
431
+ : { init_states: true, unlocked: false },
416
432
  };
417
433
  }
418
434
  if (requestType === 'DeviceSettingsPageShow') {
@@ -437,6 +453,7 @@ describe('DeviceSettings protocol routing', () => {
437
453
  mode: 'normal',
438
454
  unlocked: true,
439
455
  passphraseProtection: true,
456
+ unlockedAttachPin: true,
440
457
  },
441
458
  raw: {
442
459
  protocolV2ProtocolInfo: {
@@ -456,12 +473,31 @@ describe('DeviceSettings protocol routing', () => {
456
473
  });
457
474
  method.init();
458
475
  (method as any).device = device;
476
+ const onState = jest.fn();
477
+ device.on(DEVICE.STATE, onState);
459
478
 
460
479
  await expect(method.run()).resolves.toEqual({ message: 'Success' });
461
480
  expect(device.state?.status).toMatchObject({
462
481
  unlocked: false,
463
- passphraseProtection: true,
482
+ passphraseProtection: false,
483
+ unlockedAttachPin: false,
464
484
  });
485
+ expect(onState).toHaveBeenCalledWith(
486
+ device,
487
+ expect.objectContaining({
488
+ source: 'settings-write',
489
+ changedKeys: expect.arrayContaining([
490
+ 'status.passphraseProtection',
491
+ 'status.unlockedAttachPin',
492
+ ]),
493
+ state: expect.objectContaining({
494
+ status: expect.objectContaining({
495
+ passphraseProtection: false,
496
+ unlockedAttachPin: false,
497
+ }),
498
+ }),
499
+ })
500
+ );
465
501
  });
466
502
 
467
503
  it('keeps Protocol V1 passphrase settings on ApplySettings', async () => {
@@ -250,6 +250,8 @@ describe('DeviceStateMapper', () => {
250
250
  init_states: true,
251
251
  unlocked: false,
252
252
  passphrase_enabled: true,
253
+ attach_to_pin_enabled: true,
254
+ unlocked_by_attach_to_pin: true,
253
255
  backup_required: true,
254
256
  } as DeviceStatus);
255
257
 
@@ -261,6 +263,26 @@ describe('DeviceStateMapper', () => {
261
263
  backupRequired: true,
262
264
  });
263
265
  expect(locked.status).not.toHaveProperty('passphraseProtection');
266
+ expect(locked.status).not.toHaveProperty('attachToPinEnabled');
267
+ expect(locked.status).not.toHaveProperty('unlockedAttachPin');
268
+ });
269
+
270
+ test('omits an unavailable attach-to-PIN status after an unlocked query failure', () => {
271
+ const status = mapProtocolV2DeviceStatusToState({
272
+ init_states: true,
273
+ unlocked: true,
274
+ passphrase_enabled: true,
275
+ attach_to_pin_enabled: null,
276
+ unlocked_by_attach_to_pin: false,
277
+ backup_required: false,
278
+ } as DeviceStatus);
279
+
280
+ expect(status.status).toMatchObject({
281
+ unlocked: true,
282
+ passphraseProtection: true,
283
+ unlockedAttachPin: false,
284
+ });
285
+ expect(status.status).not.toHaveProperty('attachToPinEnabled');
264
286
  });
265
287
 
266
288
  test('maps common settings into identity, status and settings sections', () => {
@@ -288,10 +310,26 @@ describe('DeviceStateMapper', () => {
288
310
  language: 'en-Latn-US',
289
311
  brightness: 80,
290
312
  autolock_delay_ms: 30_000,
313
+ passphrase_enable: null,
291
314
  } as DeviceSettings)
292
315
  ).toEqual({
293
316
  identity: { label: 'Pro2' },
294
317
  settings: { language: 'en', brightness: 80, autoLockDelayMs: 30_000 },
295
318
  });
296
319
  });
320
+
321
+ test('omits private Protocol V2 settings that are unavailable while locked', () => {
322
+ expect(
323
+ mapDeviceSettingsToState({
324
+ brightness: 80,
325
+ passphrase_enable: null,
326
+ fido_enabled: null,
327
+ autolock_delay_ms: null,
328
+ autoshutdown_delay_ms: null,
329
+ label: null,
330
+ } as DeviceSettings)
331
+ ).toEqual({
332
+ settings: { brightness: 80 },
333
+ });
334
+ });
297
335
  });
@@ -352,7 +352,7 @@ describe('Protocol V1 wallet identity initialization', () => {
352
352
  jest.restoreAllMocks();
353
353
  });
354
354
 
355
- test('purges cached sessions and rejects when the live device identity changes', async () => {
355
+ test('verifies the live device id without resetting the cached wallet session', async () => {
356
356
  const device = Device.fromDescriptor({ id: 'connect-b', path: 'connect-b' } as never);
357
357
  device.features = {
358
358
  protocol: 'V1',
@@ -363,7 +363,7 @@ describe('Protocol V1 wallet identity initialization', () => {
363
363
  deviceWalletSessionStore.set('cached-device-a', 'hidden-a', 'session-a');
364
364
  const typedCall = jest.fn().mockResolvedValue({
365
365
  type: 'Features',
366
- message: { device_id: 'live-device-b', session_id: 'wrong-device-session' },
366
+ message: { device_id: 'live-device-b' },
367
367
  });
368
368
  device.commands = { typedCall } as never;
369
369
  jest.spyOn(TransportManager, 'reconfigure').mockResolvedValue(undefined);
@@ -371,76 +371,11 @@ describe('Protocol V1 wallet identity initialization', () => {
371
371
  await expect(
372
372
  device.initialize({ deviceId: 'cached-device-a', passphraseState: 'hidden-a' })
373
373
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceCheckDeviceIdError });
374
- // Identity is validated from the single Initialize round trip; no separate
375
- // GetFeatures preflight.
376
374
  expect(typedCall).toHaveBeenCalledTimes(1);
377
- expect(typedCall).toHaveBeenCalledWith(
378
- 'Initialize',
379
- 'Features',
380
- expect.objectContaining({ passphrase_state: 'hidden-a' }),
381
- expect.any(Object)
382
- );
383
- // Identity change purges the previous device's cached sessions (pre-existing
384
- // reconcileDeviceIdentity behavior — never carry sessions across devices)…
385
- expect(deviceWalletSessionStore.get('cached-device-a', 'hidden-a')).toBeUndefined();
386
- // …and the session the mismatched Initialize cached under the wrong
387
- // device's identity is dropped as well.
388
- expect(deviceWalletSessionStore.get('live-device-b', 'hidden-a')).toBeUndefined();
375
+ expect(typedCall).toHaveBeenCalledWith('GetFeatures', 'Features', {});
389
376
  });
390
377
 
391
- test('drops the wrong-device session even when reconfigure fails after Initialize', async () => {
392
- const device = Device.fromDescriptor({ id: 'connect-b', path: 'connect-b' } as never);
393
- device.features = {
394
- protocol: 'V1',
395
- deviceId: 'cached-device-a',
396
- unlocked: true,
397
- passphraseProtection: true,
398
- } as never;
399
- deviceWalletSessionStore.set('cached-device-a', 'hidden-a', 'session-a');
400
- const typedCall = jest.fn().mockResolvedValue({
401
- type: 'Features',
402
- message: { device_id: 'live-device-b', session_id: 'wrong-device-session' },
403
- });
404
- device.commands = { typedCall } as never;
405
- // Pre-encode resync succeeds; the post-response reconfigure inside
406
- // callInitialize rejects — the session write has already happened by then.
407
- jest
408
- .spyOn(TransportManager, 'reconfigure')
409
- .mockResolvedValueOnce(undefined)
410
- .mockRejectedValueOnce(new Error('configure failed'));
411
-
412
- await expect(
413
- device.initialize({ deviceId: 'cached-device-a', passphraseState: 'hidden-a' })
414
- ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceCheckDeviceIdError });
415
- // The wrong-device session must not survive the error path either.
416
- expect(deviceWalletSessionStore.get('live-device-b', 'hidden-a')).toBeUndefined();
417
- });
418
-
419
- test('re-syncs the V1 message schema for this device before encoding Initialize', async () => {
420
- const device = Device.fromDescriptor({ id: 'connect-a', path: 'connect-a' } as never);
421
- device.features = {
422
- protocol: 'V1',
423
- deviceId: 'device-a',
424
- unlocked: true,
425
- passphraseProtection: true,
426
- } as never;
427
- const typedCall = jest.fn().mockResolvedValue({
428
- type: 'Features',
429
- message: { device_id: 'device-a' },
430
- });
431
- device.commands = { typedCall } as never;
432
- const reconfigure = jest.spyOn(TransportManager, 'reconfigure').mockResolvedValue(undefined);
433
-
434
- await device.initialize({ deviceId: 'device-a', passphraseState: 'hidden-a' });
435
-
436
- // A stale process-global schema (from another device) would silently strip
437
- // passphrase_state from the wire message, so the resync must come first.
438
- expect(reconfigure.mock.invocationCallOrder[0]).toBeLessThan(
439
- typedCall.mock.invocationCallOrder[0]
440
- );
441
- });
442
-
443
- test('resumes a cached V1 wallet with a single identity-validated Initialize', async () => {
378
+ test('resumes a cached V1 wallet after a non-destructive live identity read', async () => {
444
379
  const device = Device.fromDescriptor({ id: 'connect-a', path: 'connect-a' } as never);
445
380
  device.features = {
446
381
  protocol: 'V1',
@@ -449,17 +384,22 @@ describe('Protocol V1 wallet identity initialization', () => {
449
384
  passphraseProtection: true,
450
385
  } as never;
451
386
  deviceWalletSessionStore.set('device-a', 'hidden-a', 'session-a');
452
- const typedCall = jest.fn().mockResolvedValueOnce({
453
- type: 'Features',
454
- message: { device_id: 'device-a', session_id: 'session-a' },
455
- });
387
+ const typedCall = jest
388
+ .fn()
389
+ .mockResolvedValueOnce({ type: 'Features', message: { device_id: 'device-a' } })
390
+ .mockResolvedValueOnce({
391
+ type: 'Features',
392
+ message: { device_id: 'device-a', session_id: 'session-a' },
393
+ });
456
394
  device.commands = { typedCall } as never;
457
395
  jest.spyOn(TransportManager, 'reconfigure').mockResolvedValue(undefined);
458
396
 
459
397
  await device.initialize({ deviceId: 'device-a', passphraseState: 'hidden-a' });
460
398
 
461
- expect(typedCall).toHaveBeenCalledTimes(1);
462
- expect(typedCall).toHaveBeenCalledWith(
399
+ expect(typedCall).toHaveBeenCalledTimes(2);
400
+ expect(typedCall).toHaveBeenNthCalledWith(1, 'GetFeatures', 'Features', {});
401
+ expect(typedCall).toHaveBeenNthCalledWith(
402
+ 2,
463
403
  'Initialize',
464
404
  'Features',
465
405
  expect.objectContaining({
@@ -468,10 +408,9 @@ describe('Protocol V1 wallet identity initialization', () => {
468
408
  }),
469
409
  expect.any(Object)
470
410
  );
471
- expect(deviceWalletSessionStore.get('device-a', 'hidden-a')).toBe('session-a');
472
411
  });
473
412
 
474
- test('selects the standard V1 wallet with a single identity-validated Initialize', async () => {
413
+ test('selects the standard V1 wallet after a non-destructive live identity read', async () => {
475
414
  const device = Device.fromDescriptor({ id: 'connect-a', path: 'connect-a' } as never);
476
415
  device.features = {
477
416
  protocol: 'V1',
@@ -481,14 +420,17 @@ describe('Protocol V1 wallet identity initialization', () => {
481
420
  } as never;
482
421
  const typedCall = jest
483
422
  .fn()
423
+ .mockResolvedValueOnce({ type: 'Features', message: { device_id: 'device-a' } })
484
424
  .mockResolvedValueOnce({ type: 'Features', message: { device_id: 'device-a' } });
485
425
  device.commands = { typedCall } as never;
486
426
  jest.spyOn(TransportManager, 'reconfigure').mockResolvedValue(undefined);
487
427
 
488
428
  await device.initialize({ deviceId: 'device-a' });
489
429
 
490
- expect(typedCall).toHaveBeenCalledTimes(1);
491
- expect(typedCall).toHaveBeenCalledWith(
430
+ expect(typedCall).toHaveBeenCalledTimes(2);
431
+ expect(typedCall).toHaveBeenNthCalledWith(1, 'GetFeatures', 'Features', {});
432
+ expect(typedCall).toHaveBeenNthCalledWith(
433
+ 2,
492
434
  'Initialize',
493
435
  'Features',
494
436
  {
@@ -1,4 +1,4 @@
1
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
2
 
3
3
  import FirmwareUpdateV4 from '../../src/api/FirmwareUpdateV4';
4
4
 
@@ -127,4 +127,40 @@ describe('FirmwareUpdateV4 install polling', () => {
127
127
  errorCode: HardwareErrorCode.CallQueueActionCancelled,
128
128
  });
129
129
  });
130
+
131
+ test('stops polling when the device cancels firmware installation', async () => {
132
+ const method = new FirmwareUpdateV4({
133
+ id: 1,
134
+ payload: {
135
+ method: 'firmwareUpdateV4',
136
+ connectId: 'pro2-ble',
137
+ },
138
+ });
139
+ const typedCall = jest
140
+ .fn()
141
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.ActionCancelled));
142
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
143
+
144
+ method.device = {
145
+ getCommands: () => ({ typedCall }),
146
+ } as unknown as Device;
147
+ const firmwareUpdate = method as unknown as {
148
+ waitForProtocolV2FirmwareUpdateComplete: (
149
+ targets: Array<{ target_id: number; path: string }>
150
+ ) => Promise<void>;
151
+ reconnectProtocolV2Device: () => Promise<void>;
152
+ };
153
+ firmwareUpdate.reconnectProtocolV2Device = reconnectProtocolV2Device;
154
+
155
+ await expect(
156
+ firmwareUpdate.waitForProtocolV2FirmwareUpdateComplete([
157
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
158
+ ])
159
+ ).rejects.toMatchObject({
160
+ errorCode: HardwareErrorCode.ActionCancelled,
161
+ });
162
+
163
+ expect(typedCall).toHaveBeenCalledTimes(1);
164
+ expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
165
+ });
130
166
  });
@@ -1,9 +1,14 @@
1
- import { DeviceSessionPinType } from '@onekeyfe/hd-transport';
1
+ import TransportUtils, { DeviceSessionPinType } from '@onekeyfe/hd-transport';
2
2
 
3
3
  import ConfluxSignMessageCIP23 from '../src/api/conflux/ConfluxSignMessageCIP23';
4
+ import DeviceChangePin from '../src/api/device/DeviceChangePin';
4
5
  import DeviceLock from '../src/api/device/DeviceLock';
5
6
  import DeviceSettings from '../src/api/device/DeviceSettings';
7
+ import DeviceVerify from '../src/api/device/DeviceVerify';
8
+ import DeviceWipe from '../src/api/device/DeviceWipe';
9
+ import FirmwareUpdateV4 from '../src/api/FirmwareUpdateV4';
6
10
  import OpenWalletSession from '../src/api/OpenWalletSession';
11
+ import DataManager from '../src/data-manager/DataManager';
7
12
  import { runMethodWithUnlockPolicy } from '../src/protocols/protocol-v2/unlockPolicyRunner';
8
13
 
9
14
  jest.mock('../src/data/config', () => ({
@@ -12,6 +17,42 @@ jest.mock('../src/data/config', () => ({
12
17
  }));
13
18
 
14
19
  describe('Protocol V2 unlock semantics', () => {
20
+ test('serializes genuine-device verification with Protocol V2 certificate messages', async () => {
21
+ const method = new DeviceVerify({
22
+ id: 1,
23
+ payload: { method: 'deviceVerify', dataHex: 'aabb' },
24
+ });
25
+ method.init();
26
+ const protocolV2Messages = TransportUtils.parseConfigure(
27
+ DataManager.getProtobufMessages('v2Schema')
28
+ );
29
+ const typedCall = jest.fn(
30
+ (requestType: string, responseType: string, payload: Record<string, unknown> = {}) => {
31
+ const request = TransportUtils.createMessageFromName(protocolV2Messages, requestType);
32
+ request.Message.encode(request.Message.create(payload)).finish();
33
+ TransportUtils.createMessageFromName(protocolV2Messages, responseType);
34
+ if (requestType === 'DeviceCertificateSign') {
35
+ return Promise.resolve({ message: { data: 'signature' } });
36
+ }
37
+ return Promise.resolve({ message: { cert_and_pubkey: 'certificate' } });
38
+ }
39
+ );
40
+ method.device = {
41
+ getCurrentDeviceType: () => 'pro2',
42
+ isProtocolV2: () => true,
43
+ commands: { typedCall },
44
+ } as any;
45
+
46
+ await expect(method.run()).resolves.toEqual({
47
+ cert: 'certificate',
48
+ signature: 'signature',
49
+ });
50
+ expect(typedCall.mock.calls.map(call => call.slice(0, 2))).toEqual([
51
+ ['DeviceCertificateSign', 'DeviceCertificateSignature'],
52
+ ['DeviceCertificateRead', 'DeviceCertificate'],
53
+ ]);
54
+ });
55
+
15
56
  test('wallet business methods inherit the wallet-session unlock requirement', () => {
16
57
  const method = new ConfluxSignMessageCIP23({
17
58
  id: 1,
@@ -191,6 +232,61 @@ describe('Protocol V2 unlock semantics', () => {
191
232
  expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, expect.any(Object));
192
233
  });
193
234
 
235
+ test.each([
236
+ [
237
+ 'PIN changes',
238
+ () =>
239
+ new DeviceChangePin({
240
+ id: 1,
241
+ payload: { method: 'deviceChangePin', remove: false },
242
+ }),
243
+ ],
244
+ ['device wipe', () => new DeviceWipe({ id: 1, payload: { method: 'deviceWipe' } })],
245
+ [
246
+ 'firmware updates',
247
+ () =>
248
+ new FirmwareUpdateV4({
249
+ id: 1,
250
+ payload: { method: 'firmwareUpdateV4', platform: 'desktop' } as any,
251
+ }),
252
+ ],
253
+ [
254
+ 'genuine-device verification',
255
+ () =>
256
+ new DeviceVerify({
257
+ id: 1,
258
+ payload: { method: 'deviceVerify', dataHex: '00' },
259
+ }),
260
+ ],
261
+ ])('allows either PIN type when pre-unlocking %s', async (_name, createMethod) => {
262
+ const method = createMethod();
263
+ method.init();
264
+ const features = { unlocked: false };
265
+ const device = {
266
+ features,
267
+ commands: {
268
+ typedCall: jest.fn().mockResolvedValue({ message: { unlocked: false } }),
269
+ },
270
+ isProtocolV2: () => true,
271
+ isBootloader: () => false,
272
+ isRomloader: () => false,
273
+ updateProtocolV2Status: jest.fn(() => features),
274
+ unlockDevice: jest.fn().mockImplementation(() => {
275
+ features.unlocked = true;
276
+ return Promise.resolve(features);
277
+ }),
278
+ };
279
+ method.run = jest.fn().mockResolvedValue({ message: 'ok' });
280
+
281
+ await expect(runMethodWithUnlockPolicy(method, device as any)).resolves.toEqual({
282
+ message: 'ok',
283
+ });
284
+
285
+ expect(method.unlockPolicy).toBe('unlock-before-run');
286
+ expect(method.getSupportedProtocols()).toContain('V2');
287
+ expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, expect.any(Object));
288
+ });
289
+
194
290
  test('pre-unlocks a locked standard wallet before wallet-session preparation', async () => {
195
291
  const calls: string[] = [];
196
292
  const features = {