@onekeyfe/hd-core 1.2.0-alpha.134 → 1.2.0-alpha.135

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 (31) hide show
  1. package/__tests__/DeviceCommands.test.ts +8 -1
  2. package/__tests__/device-lifecycle-events.test.ts +95 -2
  3. package/__tests__/device-settings.test.ts +167 -9
  4. package/__tests__/device-wallet-session-store.test.ts +21 -79
  5. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +37 -1
  6. package/__tests__/protocol-v2-unlock-policy.test.ts +59 -0
  7. package/__tests__/protocol-v2.test.ts +86 -21
  8. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  9. package/dist/api/device/DeviceChangePin.d.ts.map +1 -1
  10. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  11. package/dist/api/device/DeviceVerify.d.ts +1 -0
  12. package/dist/api/device/DeviceVerify.d.ts.map +1 -1
  13. package/dist/api/device/DeviceWipe.d.ts.map +1 -1
  14. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  15. package/dist/core/index.d.ts +3 -1
  16. package/dist/core/index.d.ts.map +1 -1
  17. package/dist/device/Device.d.ts +2 -0
  18. package/dist/device/Device.d.ts.map +1 -1
  19. package/dist/device/DeviceCommands.d.ts.map +1 -1
  20. package/dist/index.d.ts +4 -1
  21. package/dist/index.js +296 -247
  22. package/package.json +4 -4
  23. package/src/api/FirmwareUpdateV4.ts +25 -68
  24. package/src/api/device/DeviceChangePin.ts +4 -1
  25. package/src/api/device/DeviceSettings.ts +2 -16
  26. package/src/api/device/DeviceVerify.ts +9 -0
  27. package/src/api/device/DeviceWipe.ts +4 -1
  28. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +11 -0
  29. package/src/core/index.ts +67 -25
  30. package/src/device/Device.ts +45 -42
  31. package/src/device/DeviceCommands.ts +15 -2
@@ -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();
@@ -800,7 +800,13 @@ describe('DeviceCommands cancellation', () => {
800
800
  try {
801
801
  const commands = createCommands();
802
802
  commands.disposed = false;
803
+ commands.mainId = 'main-id';
803
804
  commands.callPromise = new Promise(() => {});
805
+ const disconnect = jest.fn().mockResolvedValue(undefined);
806
+ commands.transport = {
807
+ name: 'ReactNativeBleTransport',
808
+ disconnect,
809
+ } as any;
804
810
  const dispose = jest.fn().mockResolvedValue(undefined);
805
811
  commands.dispose = dispose;
806
812
 
@@ -810,6 +816,7 @@ describe('DeviceCommands cancellation', () => {
810
816
 
811
817
  await expect(cancellation).resolves.toBeUndefined();
812
818
  expect(dispose).toHaveBeenCalledWith(true);
819
+ expect(disconnect).toHaveBeenCalledWith('main-id');
813
820
  expect(commands.callPromise).toBeUndefined();
814
821
  } finally {
815
822
  jest.useRealTimers();
@@ -1,7 +1,7 @@
1
- import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { EDeviceType, ERRORS, HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
2
2
  import { DeviceType, TRANSPORT_EVENT } from '@onekeyfe/hd-transport';
3
3
 
4
- import { initConnector, initCore } from '../src/core';
4
+ import { initConnector, initCore, isMissingDetectedProtocolV2Error } from '../src/core';
5
5
  import { DataManager } from '../src/data-manager';
6
6
  import TransportManager from '../src/data-manager/TransportManager';
7
7
  import { Device } from '../src/device/Device';
@@ -82,6 +82,23 @@ describe('public device lifecycle events', () => {
82
82
  expect(DevicePool.emitter.listenerCount(DEVICE.DISCONNECT)).toBe(1);
83
83
  });
84
84
 
85
+ test('isolates pending cancellation cleanup by connect id', () => {
86
+ core = initCore();
87
+ const context = (core as any).getCoreContext();
88
+ const firstCleanup = createDeferred<void>();
89
+ const replacementCleanup = createDeferred<void>();
90
+
91
+ context.setPrePendingCallPromise('device-a', firstCleanup.promise);
92
+
93
+ expect(context.getPrePendingCallPromise('device-a')).toBe(firstCleanup.promise);
94
+ expect(context.getPrePendingCallPromise('device-b')).toBeUndefined();
95
+
96
+ context.setPrePendingCallPromise('device-a', replacementCleanup.promise);
97
+ context.removePrePendingCallPromise('device-a', firstCleanup.promise);
98
+
99
+ expect(context.getPrePendingCallPromise('device-a')).toBe(replacementCleanup.promise);
100
+ });
101
+
85
102
  test('keeps shared device lifecycle listeners across a device cache reset', () => {
86
103
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
87
104
  core = initCore();
@@ -234,6 +251,22 @@ describe('public device lifecycle events', () => {
234
251
  expect(device.getProtocol()).toBe('V2');
235
252
  });
236
253
 
254
+ test.each([
255
+ ['V2', true],
256
+ ['V1', false],
257
+ ] as const)(
258
+ 'retries a missing detected protocol only for an explicit Protocol %s connection',
259
+ (connectProtocol, expected) => {
260
+ const method = { payload: { connectProtocol } } as never;
261
+ const error = {
262
+ errorCode: HardwareErrorCode.RuntimeError,
263
+ message: 'Device protocol has not been detected for ble-id',
264
+ };
265
+
266
+ expect(isMissingDetectedProtocolV2Error(method, error)).toBe(expected);
267
+ }
268
+ );
269
+
237
270
  test('converts an internal transport disconnect into a public KnownDevice snapshot', () => {
238
271
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
239
272
  core = initCore();
@@ -310,6 +343,66 @@ describe('public device lifecycle events', () => {
310
343
  }
311
344
  );
312
345
 
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;
361
+
362
+ await device.interruptionFromUser();
363
+
364
+ expect(cancelDevice).toHaveBeenCalledTimes(1);
365
+ expect(post).toHaveBeenCalledWith(device.mainId, 'Cancel', {});
366
+ expect(cancel).toHaveBeenCalledTimes(1);
367
+ }
368
+ );
369
+
370
+ test('waits for the canceled run to finish releasing before cancellation completes', async () => {
371
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
372
+ const device = createInitializedDevice('V2');
373
+ const operation = createDeferred<void>();
374
+ const releaseGate = createDeferred<void>();
375
+ const cancelError = ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
376
+ const release = jest.spyOn(device, 'release').mockImplementation(() => releaseGate.promise);
377
+ device.commands = {
378
+ disposed: false,
379
+ cancel: jest.fn(() => {
380
+ operation.reject(cancelError);
381
+ return Promise.resolve();
382
+ }),
383
+ } as never;
384
+ (device as unknown as { deviceAcquired: boolean }).deviceAcquired = true;
385
+
386
+ const runResult = device.run(() => operation.promise).catch(error => error);
387
+ const cancellation = device.interruptionFromUser();
388
+ let cancellationCompleted = false;
389
+ cancellation.then(() => {
390
+ cancellationCompleted = true;
391
+ });
392
+
393
+ await new Promise(resolve => {
394
+ setImmediate(resolve);
395
+ });
396
+ expect(release).toHaveBeenCalledTimes(1);
397
+ expect(cancellationCompleted).toBe(false);
398
+
399
+ releaseGate.resolve();
400
+ await cancellation;
401
+ await expect(runResult).resolves.toMatchObject({
402
+ errorCode: HardwareErrorCode.DeviceInterruptedFromUser,
403
+ });
404
+ });
405
+
313
406
  test.each([
314
407
  [EDeviceType.Pro2, 'webusb', false, DeviceType.PRO2],
315
408
  [EDeviceType.Neo, 'webusb', false, DeviceType.NEO],
@@ -2,6 +2,9 @@ import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
2
  import { DeviceSettingsPage } from '@onekeyfe/hd-transport';
3
3
 
4
4
  import DeviceSettings from '../src/api/device/DeviceSettings';
5
+ import { Device } from '../src/device/Device';
6
+ import { DEVICE } from '../src/events';
7
+ import { PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE } from '../src/protocols/protocol-v2';
5
8
  import { DEVICE_SETTINGS_NEVER_TIMEOUT_MS } from '../src/utils/deviceSettings';
6
9
 
7
10
  import type { Features } from '../src/types';
@@ -21,6 +24,9 @@ function createDevice({ protocol }: { protocol: 'V1' | 'V2' }) {
21
24
  const typedCall = jest.fn().mockResolvedValue({ message: { message: 'Success' } });
22
25
  const updateState = jest.fn();
23
26
  const getDeviceState = jest.fn();
27
+ const refreshProtocolV2SettingsAfterMutation = jest.fn(() =>
28
+ getDeviceState({ refreshSections: ['status', 'settings'] })
29
+ );
24
30
  return {
25
31
  device: {
26
32
  features,
@@ -29,6 +35,7 @@ function createDevice({ protocol }: { protocol: 'V1' | 'V2' }) {
29
35
  commands: { typedCall },
30
36
  updateState,
31
37
  getDeviceState,
38
+ refreshProtocolV2SettingsAfterMutation,
32
39
  },
33
40
  typedCall,
34
41
  updateState,
@@ -181,7 +188,98 @@ describe('DeviceSettings protocol routing', () => {
181
188
  expect(updateState).not.toHaveBeenCalled();
182
189
  });
183
190
 
184
- it('uses the Pro2 passphrase page as a device-side toggle and verifies the target state', async () => {
191
+ it('reconciles stale passphrase and label after an unrelated Protocol V2 setting change', async () => {
192
+ const typedCall = jest.fn().mockImplementation((requestType: string) => {
193
+ if (requestType === 'DeviceSettingsSet') {
194
+ return { message: { message: 'Success' } };
195
+ }
196
+ if (requestType === 'DeviceStatusGet') {
197
+ return {
198
+ message: {
199
+ init_states: true,
200
+ unlocked: false,
201
+ passphrase_enabled: false,
202
+ },
203
+ };
204
+ }
205
+ if (requestType === 'DeviceSettingsGet') {
206
+ return {
207
+ message: {
208
+ label: 'Current Label',
209
+ brightness: 80,
210
+ passphrase_enable: false,
211
+ },
212
+ };
213
+ }
214
+ throw new Error(`Unexpected request: ${requestType}`);
215
+ });
216
+ const device = Device.fromDescriptor({
217
+ id: 'pro2',
218
+ path: 'pro2',
219
+ protocolType: 'V2',
220
+ } as never);
221
+ (device as any).commands = { typedCall };
222
+ device.updateState(
223
+ {
224
+ protocol: 'V2',
225
+ identity: {
226
+ deviceType: EDeviceType.Pro2,
227
+ label: 'Stale Label',
228
+ },
229
+ status: {
230
+ mode: 'normal',
231
+ unlocked: false,
232
+ passphraseProtection: true,
233
+ },
234
+ settings: { brightness: 20 },
235
+ raw: {
236
+ protocolV2ProtocolInfo: {
237
+ version: 1,
238
+ build_fingerprint: 'application__5.0.0__abcdef0__PROD__RELEASE',
239
+ supported_messages: [PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE],
240
+ },
241
+ },
242
+ },
243
+ 'initialize'
244
+ );
245
+ const onState = jest.fn();
246
+ device.on(DEVICE.STATE, onState);
247
+ const method = new DeviceSettings({
248
+ id: 3,
249
+ payload: {
250
+ method: 'deviceSettings',
251
+ brightness: 80,
252
+ },
253
+ });
254
+ method.init();
255
+ (method as any).device = device;
256
+
257
+ await expect(method.run()).resolves.toEqual({ message: 'Success' });
258
+
259
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
260
+ 'DeviceSettingsSet',
261
+ 'DeviceStatusGet',
262
+ 'DeviceSettingsGet',
263
+ ]);
264
+ expect(device.state).toMatchObject({
265
+ identity: { label: 'Current Label' },
266
+ status: { passphraseProtection: false },
267
+ settings: { brightness: 80 },
268
+ });
269
+ expect(onState).toHaveBeenLastCalledWith(
270
+ device,
271
+ expect.objectContaining({
272
+ source: 'settings-read',
273
+ state: expect.objectContaining({
274
+ identity: expect.objectContaining({ label: 'Current Label' }),
275
+ status: expect.objectContaining({ passphraseProtection: false }),
276
+ settings: expect.objectContaining({ brightness: 80 }),
277
+ }),
278
+ })
279
+ );
280
+ });
281
+
282
+ it('uses the Pro2 passphrase page and refreshes device state after the toggle', async () => {
185
283
  const { device, typedCall, getDeviceState } = createDevice({ protocol: 'V2' });
186
284
  getDeviceState
187
285
  .mockResolvedValueOnce({
@@ -263,8 +361,8 @@ describe('DeviceSettings protocol routing', () => {
263
361
  expect(getDeviceState).toHaveBeenCalledWith({ refreshSections: ['settings'] });
264
362
  });
265
363
 
266
- it('rejects a successful Pro2 page response when the hardware did not reach the target', async () => {
267
- const { device, getDeviceState } = createDevice({ protocol: 'V2' });
364
+ it('does not overwrite Pro2 passphrase state when refreshed state does not match', async () => {
365
+ const { device, getDeviceState, updateState } = createDevice({ protocol: 'V2' });
268
366
  getDeviceState.mockResolvedValue({
269
367
  status: { passphraseProtection: false },
270
368
  });
@@ -278,14 +376,12 @@ describe('DeviceSettings protocol routing', () => {
278
376
  method.init();
279
377
  (method as any).device = device;
280
378
 
281
- await expect(method.run()).rejects.toMatchObject({
282
- errorCode: HardwareErrorCode.RuntimeError,
283
- message: 'Protocol V2 passphrase setting did not reach the requested value.',
284
- });
379
+ await expect(method.run()).resolves.toEqual({ message: 'Success' });
380
+ expect(updateState).not.toHaveBeenCalled();
285
381
  });
286
382
 
287
- it('accepts a locked Pro2 as confirmation after disabling passphrase', async () => {
288
- const { device, getDeviceState } = createDevice({ protocol: 'V2' });
383
+ it('keeps the previous Pro2 passphrase state when refreshed state is unavailable after locking', async () => {
384
+ const { device, getDeviceState, updateState } = createDevice({ protocol: 'V2' });
289
385
  getDeviceState
290
386
  .mockResolvedValueOnce({
291
387
  status: { unlocked: true, passphraseProtection: true },
@@ -304,6 +400,68 @@ describe('DeviceSettings protocol routing', () => {
304
400
  (method as any).device = device;
305
401
 
306
402
  await expect(method.run()).resolves.toEqual({ message: 'Success' });
403
+ expect(updateState).not.toHaveBeenCalled();
404
+ });
405
+
406
+ it('preserves confirmed Pro2 passphrase state when the post-toggle status is locked', async () => {
407
+ let statusReadCount = 0;
408
+ const typedCall = jest.fn().mockImplementation((requestType: string) => {
409
+ if (requestType === 'DeviceStatusGet') {
410
+ statusReadCount += 1;
411
+ return {
412
+ message:
413
+ statusReadCount === 1
414
+ ? { init_states: true, unlocked: true, passphrase_enabled: true }
415
+ : { init_states: true, unlocked: false, passphrase_enabled: false },
416
+ };
417
+ }
418
+ if (requestType === 'DeviceSettingsPageShow') {
419
+ return { message: { message: 'Success' } };
420
+ }
421
+ if (requestType === 'DeviceSettingsGet') {
422
+ return { message: { label: 'Pro 2' } };
423
+ }
424
+ throw new Error(`Unexpected request: ${requestType}`);
425
+ });
426
+ const device = Device.fromDescriptor({
427
+ id: 'pro2-passphrase',
428
+ path: 'pro2-passphrase',
429
+ protocolType: 'V2',
430
+ } as never);
431
+ (device as any).commands = { typedCall };
432
+ device.updateState(
433
+ {
434
+ protocol: 'V2',
435
+ identity: { deviceType: EDeviceType.Pro2 },
436
+ status: {
437
+ mode: 'normal',
438
+ unlocked: true,
439
+ passphraseProtection: true,
440
+ },
441
+ raw: {
442
+ protocolV2ProtocolInfo: {
443
+ version: 1,
444
+ supported_messages: [PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE],
445
+ },
446
+ },
447
+ },
448
+ 'initialize'
449
+ );
450
+ const method = new DeviceSettings({
451
+ id: 6,
452
+ payload: {
453
+ method: 'deviceSettings',
454
+ usePassphrase: false,
455
+ },
456
+ });
457
+ method.init();
458
+ (method as any).device = device;
459
+
460
+ await expect(method.run()).resolves.toEqual({ message: 'Success' });
461
+ expect(device.state?.status).toMatchObject({
462
+ unlocked: false,
463
+ passphraseProtection: true,
464
+ });
307
465
  });
308
466
 
309
467
  it('keeps Protocol V1 passphrase settings on ApplySettings', async () => {
@@ -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,8 +1,12 @@
1
1
  import { 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';
7
11
  import { runMethodWithUnlockPolicy } from '../src/protocols/protocol-v2/unlockPolicyRunner';
8
12
 
@@ -191,6 +195,61 @@ describe('Protocol V2 unlock semantics', () => {
191
195
  expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, expect.any(Object));
192
196
  });
193
197
 
198
+ test.each([
199
+ [
200
+ 'PIN changes',
201
+ () =>
202
+ new DeviceChangePin({
203
+ id: 1,
204
+ payload: { method: 'deviceChangePin', remove: false },
205
+ }),
206
+ ],
207
+ ['device wipe', () => new DeviceWipe({ id: 1, payload: { method: 'deviceWipe' } })],
208
+ [
209
+ 'firmware updates',
210
+ () =>
211
+ new FirmwareUpdateV4({
212
+ id: 1,
213
+ payload: { method: 'firmwareUpdateV4', platform: 'desktop' } as any,
214
+ }),
215
+ ],
216
+ [
217
+ 'genuine-device verification',
218
+ () =>
219
+ new DeviceVerify({
220
+ id: 1,
221
+ payload: { method: 'deviceVerify', dataHex: '00' },
222
+ }),
223
+ ],
224
+ ])('allows either PIN type when pre-unlocking %s', async (_name, createMethod) => {
225
+ const method = createMethod();
226
+ method.init();
227
+ const features = { unlocked: false };
228
+ const device = {
229
+ features,
230
+ commands: {
231
+ typedCall: jest.fn().mockResolvedValue({ message: { unlocked: false } }),
232
+ },
233
+ isProtocolV2: () => true,
234
+ isBootloader: () => false,
235
+ isRomloader: () => false,
236
+ updateProtocolV2Status: jest.fn(() => features),
237
+ unlockDevice: jest.fn().mockImplementation(() => {
238
+ features.unlocked = true;
239
+ return Promise.resolve(features);
240
+ }),
241
+ };
242
+ method.run = jest.fn().mockResolvedValue({ message: 'ok' });
243
+
244
+ await expect(runMethodWithUnlockPolicy(method, device as any)).resolves.toEqual({
245
+ message: 'ok',
246
+ });
247
+
248
+ expect(method.unlockPolicy).toBe('unlock-before-run');
249
+ expect(method.getSupportedProtocols()).toContain('V2');
250
+ expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, expect.any(Object));
251
+ });
252
+
194
253
  test('pre-unlocks a locked standard wallet before wallet-session preparation', async () => {
195
254
  const calls: string[] = [];
196
255
  const features = {