@onekeyfe/hd-core 1.2.0-alpha.133-ok59992 → 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.
@@ -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 () => {
@@ -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 = {