@onekeyfe/hd-core 1.2.8-alpha.2 → 1.2.8-alpha.21

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 (56) hide show
  1. package/README.md +27 -0
  2. package/__tests__/btc-derive-context-hash.test.ts +200 -0
  3. package/__tests__/core-dispose.test.ts +114 -1
  4. package/__tests__/core-error-output.test.ts +21 -0
  5. package/__tests__/device-connector-protocol.test.ts +5 -2
  6. package/__tests__/device-lifecycle-events.test.ts +73 -10
  7. package/__tests__/device-pool-state.test.ts +50 -0
  8. package/__tests__/protocol-v2-bootloader-mode.test.ts +12 -0
  9. package/__tests__/usb-identity-probe.test.ts +293 -0
  10. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  11. package/dist/api/SearchDevices.d.ts +21 -1
  12. package/dist/api/SearchDevices.d.ts.map +1 -1
  13. package/dist/api/btc/BTCDeriveContextHash.d.ts +16 -0
  14. package/dist/api/btc/BTCDeriveContextHash.d.ts.map +1 -0
  15. package/dist/api/index.d.ts +1 -0
  16. package/dist/api/index.d.ts.map +1 -1
  17. package/dist/api/protocol-v2/DeviceReboot.d.ts.map +1 -1
  18. package/dist/core/index.d.ts +3 -1
  19. package/dist/core/index.d.ts.map +1 -1
  20. package/dist/device/Device.d.ts +2 -0
  21. package/dist/device/Device.d.ts.map +1 -1
  22. package/dist/device/DeviceCommands.d.ts +4 -4
  23. package/dist/device/DevicePool.d.ts.map +1 -1
  24. package/dist/events/logBlockEvent.d.ts.map +1 -1
  25. package/dist/index.d.ts +47 -6
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2593 -2305
  28. package/dist/inject.d.ts.map +1 -1
  29. package/dist/types/api/btcDeriveContextHash.d.ts +10 -0
  30. package/dist/types/api/btcDeriveContextHash.d.ts.map +1 -0
  31. package/dist/types/api/export.d.ts +1 -0
  32. package/dist/types/api/export.d.ts.map +1 -1
  33. package/dist/types/api/index.d.ts +2 -0
  34. package/dist/types/api/index.d.ts.map +1 -1
  35. package/dist/types/device.d.ts +8 -0
  36. package/dist/types/device.d.ts.map +1 -1
  37. package/dist/utils/patch.d.ts +1 -1
  38. package/dist/utils/patch.d.ts.map +1 -1
  39. package/package.json +4 -4
  40. package/src/api/FirmwareUpdateV4.ts +4 -7
  41. package/src/api/SearchDevices.ts +163 -7
  42. package/src/api/btc/BTCDeriveContextHash.ts +90 -0
  43. package/src/api/index.ts +1 -0
  44. package/src/api/protocol-v2/DeviceReboot.ts +2 -0
  45. package/src/core/index.ts +46 -15
  46. package/src/data/messages/messages.json +51 -0
  47. package/src/device/Device.ts +10 -0
  48. package/src/device/DeviceConnector.ts +4 -4
  49. package/src/device/DevicePool.ts +12 -2
  50. package/src/events/logBlockEvent.ts +1 -0
  51. package/src/index.ts +1 -0
  52. package/src/inject.ts +2 -0
  53. package/src/types/api/btcDeriveContextHash.ts +15 -0
  54. package/src/types/api/export.ts +1 -0
  55. package/src/types/api/index.ts +2 -0
  56. package/src/types/device.ts +10 -0
package/README.md CHANGED
@@ -29,4 +29,31 @@ import Core from '@onekeyfe/hd-core';
29
29
  Documentation is available [Hardware SDK Getting Started](https://developer.onekey.so/en/hardware-sdk/getting-started)
30
30
 
31
31
  ## Examples
32
+
33
+ ### Babylon context hash
34
+
35
+ `btcDeriveContextHash(connectId, deviceId, params)` requires Classic 1s firmware
36
+ 3.21.0 or later and Protocol V1. It uses the existing wallet session and device
37
+ confirmation flow. Other device families and Protocol V2 are not supported.
38
+
39
+ ```typescript
40
+ const result = await HardwareSDK.btcDeriveContextHash(connectId, deviceId, {
41
+ path: "m/84'/0'/0'/0/0", // The currently selected Bitcoin key, not the IKM path.
42
+ appName: 'babylon-btc-vault',
43
+ context: 'deadbeef',
44
+ network: 'bitcoin-mainnet',
45
+ });
46
+ // result.payload.secret is a 32-byte value encoded as 64 lowercase hex characters.
47
+ ```
48
+
49
+ `appName` must contain 1–64 ASCII lowercase letters, digits or hyphens. `context`
50
+ must be lowercase hex without `0x`, containing 1–1024 decoded bytes. `network` is
51
+ required: `bitcoin-mainnet`, `bitcoin-testnet` (testnet3/testnet4), `bitcoin-signet`,
52
+ or `bitcoin-regtest`. The SDK infers the Bitcoin input script type from `path` and
53
+ encodes `appName` as ASCII bytes; the device performs the derivation internally.
54
+ Device rejection and unsupported firmware errors propagate through the normal
55
+ SDK response. Treat the returned secret as sensitive and do not log or persist it.
56
+
57
+ See the [Babylon specification](https://github.com/babylonlabs-io/babylon-toolkit/blob/main/docs/specs/derive-context-hash.md).
58
+
32
59
  // TODO: add example url
@@ -0,0 +1,200 @@
1
+ import { EDeviceType, EFirmwareType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
3
+ import BTCDeriveContextHash from '../src/api/btc/BTCDeriveContextHash';
4
+ import { findMethod } from '../src/api/utils';
5
+ import { getLogBlockLabel, getSafeLogPayload } from '../src/events/logBlockEvent';
6
+ import { createCoreApi } from '../src/inject';
7
+ import { Device } from '../src/device/Device';
8
+
9
+ import type { DeviceFirmwareRange } from '../src/types';
10
+
11
+ jest.mock('../src/data/config', () => ({
12
+ getSDKVersion: jest.fn(() => '1.0.0'),
13
+ DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
14
+ }));
15
+
16
+ const createMethod = (overrides: Record<string, unknown> = {}) =>
17
+ new BTCDeriveContextHash({
18
+ id: 1,
19
+ payload: {
20
+ method: 'btcDeriveContextHash',
21
+ path: "m/84'/0'/0'/0/0",
22
+ appName: 'test-app',
23
+ context: 'deadbeef',
24
+ network: 'bitcoin-mainnet',
25
+ ...overrides,
26
+ },
27
+ });
28
+
29
+ describe('btcDeriveContextHash', () => {
30
+ test('forwards the public API call through the existing dispatcher', async () => {
31
+ const call = jest
32
+ .fn()
33
+ .mockResolvedValue({ success: true, payload: { secret: '00'.repeat(32) } });
34
+ const api = createCoreApi(call);
35
+ const params = {
36
+ path: "m/84'/0'/0'/0/0",
37
+ appName: 'test-app',
38
+ context: 'deadbeef',
39
+ network: 'bitcoin-mainnet' as const,
40
+ };
41
+ await api.btcDeriveContextHash('connect-id', 'device-id', params);
42
+ expect(call).toHaveBeenCalledWith({
43
+ ...params,
44
+ connectId: 'connect-id',
45
+ deviceId: 'device-id',
46
+ method: 'btcDeriveContextHash',
47
+ });
48
+ });
49
+
50
+ test.each([
51
+ ['bitcoin-mainnet', 'BITCOIN_MAINNET'],
52
+ ['bitcoin-testnet', 'BITCOIN_TESTNET'],
53
+ ['bitcoin-signet', 'BITCOIN_SIGNET'],
54
+ ['bitcoin-regtest', 'BITCOIN_REGTEST'],
55
+ ])('maps %s and returns the device result', async (network, wireNetwork) => {
56
+ const method = createMethod({ network });
57
+ method.init();
58
+ const response = { secret: '00'.repeat(32) };
59
+ const typedCall = jest.fn().mockResolvedValue({ message: response });
60
+ method.device = { commands: { typedCall } } as unknown as Device;
61
+
62
+ await expect(method.run()).resolves.toBe(response);
63
+ expect(typedCall).toHaveBeenCalledWith(
64
+ 'BabylonDeriveContextHash',
65
+ 'BabylonDerivedContextHash',
66
+ {
67
+ address_n: [2147483732, 2147483648, 2147483648, 0, 0],
68
+ script_type: 'SPENDWITNESS',
69
+ app_name: '746573742d617070',
70
+ context: 'deadbeef',
71
+ network: wireNetwork,
72
+ }
73
+ );
74
+ });
75
+
76
+ test.each([
77
+ ["m/44'/0'/0'/0/0", 'SPENDADDRESS'],
78
+ ["m/49'/0'/0'/0/0", 'SPENDP2SHWITNESS'],
79
+ [[2147483734, 2147483648, 2147483648, 0, 0], 'SPENDTAPROOT'],
80
+ ])('uses the selected path script type for %s', (path, scriptType) => {
81
+ const method = createMethod({ path });
82
+ method.init();
83
+ expect(method.params.script_type).toBe(scriptType);
84
+ });
85
+
86
+ test('accepts the maximum app name and context sizes', () => {
87
+ const method = createMethod({ appName: 'a'.repeat(64), context: 'ab'.repeat(1024) });
88
+ expect(() => method.init()).not.toThrow();
89
+ expect(method.params.context).toHaveLength(2048);
90
+ });
91
+
92
+ test.each([
93
+ { appName: '' },
94
+ { appName: 'a'.repeat(65) },
95
+ { appName: 'Test-App' },
96
+ { appName: 'test_app' },
97
+ { appName: '你好' },
98
+ { appName: null },
99
+ { context: '' },
100
+ { context: 'abc' },
101
+ { context: 'DEADBEEF' },
102
+ { context: '0xdeadbeef' },
103
+ { context: 'gg' },
104
+ { context: 'ab'.repeat(1025) },
105
+ { context: null },
106
+ { network: '' },
107
+ { network: 'litecoin' },
108
+ { network: 'toString' },
109
+ { network: 1 },
110
+ { path: 'm' },
111
+ { path: "m/84x'/0'/0'/0/0" },
112
+ { path: "m/2147483648'/0'/0'/0/0" },
113
+ { path: [2147483732, 2147483648, 2147483648, 0, 0.5] },
114
+ { path: [2147483732, 2147483648, 2147483648, 0, 4294967296] },
115
+ ])('rejects invalid inputs before a device call: %j', overrides => {
116
+ expect(() => createMethod(overrides).init()).toThrow(
117
+ expect.objectContaining({ errorCode: HardwareErrorCode.CallMethodInvalidParameter })
118
+ );
119
+ });
120
+
121
+ test('rejects sparse numeric paths before a device call', () => {
122
+ const sparsePath = [2147483732, 2147483648, 2147483648, 0, 5];
123
+ delete sparsePath[3];
124
+
125
+ [Array<number>(3), sparsePath].forEach(path => {
126
+ expect(() => createMethod({ path }).init()).toThrow(
127
+ expect.objectContaining({ errorCode: HardwareErrorCode.CallMethodInvalidParameter })
128
+ );
129
+ });
130
+ });
131
+
132
+ test('registers the public method and restricts it to supported firmware and protocol', () => {
133
+ const method = findMethod({ id: 1, payload: { ...createMethod().payload } });
134
+ method.init();
135
+ expect(method).toBeInstanceOf(BTCDeriveContextHash);
136
+ expect(method.strictCheckDeviceSupport).toBe(true);
137
+ expect(() => method.assertProtocolSupported('V2', EFirmwareType.Universal)).toThrow(
138
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotSupportMethod })
139
+ );
140
+ });
141
+
142
+ test.each([EDeviceType.Classic1s, EDeviceType.ClassicPure])(
143
+ 'resolves the firmware requirement for %s through the device support check',
144
+ deviceType => {
145
+ const method = createMethod();
146
+ const device = Object.create(Device.prototype) as Device;
147
+ device.getCurrentDeviceType = jest.fn(() => deviceType);
148
+ const range: DeviceFirmwareRange = method.getVersionRange();
149
+
150
+ expect(device.getCurrentMethodVersionRange(type => range[type])).toEqual({ min: '3.21.0' });
151
+ expect(() => method.assertProtocolSupported('V1', EFirmwareType.Universal)).not.toThrow();
152
+ }
153
+ );
154
+
155
+ test.each([EDeviceType.Classic, EDeviceType.Mini, EDeviceType.Touch, EDeviceType.Pro])(
156
+ 'keeps %s outside the supported firmware range',
157
+ deviceType => {
158
+ const method = createMethod();
159
+ const device = Object.create(Device.prototype) as Device;
160
+ device.getCurrentDeviceType = jest.fn(() => deviceType);
161
+ const range: DeviceFirmwareRange = method.getVersionRange();
162
+
163
+ expect(device.getCurrentMethodVersionRange(type => range[type])).toBeUndefined();
164
+ }
165
+ );
166
+
167
+ test('propagates device rejection without returning a secret', async () => {
168
+ const method = createMethod();
169
+ method.init();
170
+ const error = new Error('User rejected');
171
+ method.device = {
172
+ commands: { typedCall: jest.fn().mockRejectedValue(error) },
173
+ } as unknown as Device;
174
+ await expect(method.run()).rejects.toBe(error);
175
+ });
176
+
177
+ test.each([undefined, '00', '00'.repeat(33)])(
178
+ 'rejects malformed device responses',
179
+ async secret => {
180
+ const method = createMethod();
181
+ method.init();
182
+ method.device = {
183
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { secret } }) },
184
+ } as unknown as Device;
185
+ await expect(method.run()).rejects.toMatchObject({
186
+ errorCode: HardwareErrorCode.CallMethodError,
187
+ });
188
+ }
189
+ );
190
+
191
+ test('blocks API request and response payloads in logs', () => {
192
+ const request = { payload: { method: 'btcDeriveContextHash' } };
193
+ const label = getLogBlockLabel(request);
194
+ expect(label).toBe('btcDeriveContextHash');
195
+ expect(getSafeLogPayload({ payload: { secret: '00'.repeat(32) } }, label)).toEqual({
196
+ method: 'btcDeriveContextHash',
197
+ payload: '[REDACTED]',
198
+ });
199
+ });
200
+ });
@@ -1,4 +1,7 @@
1
- import Core, { initConnector } from '../src/core';
1
+ import { HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
2
+
3
+ import Core, { init, initConnector, initCore, switchTransport } from '../src/core';
4
+ import DataManager from '../src/data-manager/DataManager';
2
5
  import TransportManager from '../src/data-manager/TransportManager';
3
6
  import { DevicePool } from '../src/device/DevicePool';
4
7
 
@@ -31,4 +34,114 @@ describe('Core.dispose', () => {
31
34
  expect(transportStop).toHaveBeenCalledTimes(1);
32
35
  expect(disposeDevicePool).toHaveBeenCalledTimes(1);
33
36
  });
37
+
38
+ test('disposal rejects a queued switch without recreating a transport or connector', async () => {
39
+ const previous = TransportManager.transport;
40
+ const { settings } = DataManager;
41
+ const active = { stop: jest.fn().mockResolvedValue(undefined) };
42
+ const replacement = jest.fn(() => ({ stop: jest.fn().mockResolvedValue(undefined) }));
43
+ TransportManager.transport = active as never;
44
+ const core = initCore();
45
+ initConnector();
46
+ const switching = switchTransport({ env: 'emulator', Transport: replacement });
47
+ const rejected = expect(switching).rejects.toMatchObject({
48
+ errorCode: HardwareErrorCode.NotInitialized,
49
+ });
50
+ try {
51
+ await core.dispose();
52
+ await rejected;
53
+ expect(replacement).not.toHaveBeenCalled();
54
+ expect(TransportManager.transport).toBe(active);
55
+ expect(DataManager.settings).toBe(settings);
56
+ expect(DevicePool.emitter.eventNames()).toHaveLength(0);
57
+ } finally {
58
+ await Promise.allSettled([switching]);
59
+ await core.dispose();
60
+ TransportManager.transport = previous;
61
+ DataManager.settings = settings;
62
+ }
63
+ });
64
+
65
+ test.each(['dispose/reinit', 'init', 'initCore'] as const)(
66
+ '%s invalidates running and queued switches without blocking a new lifecycle',
67
+ async lifecycle => {
68
+ const previous = TransportManager.transport;
69
+ const { settings } = DataManager;
70
+ const entered = createDeferred<void>();
71
+ const closing = createDeferred<void>();
72
+ const loading = createDeferred<void>();
73
+ const active = {
74
+ stop: jest.fn(() => {
75
+ entered.resolve();
76
+ return closing.promise;
77
+ }),
78
+ };
79
+ const intermediate = jest.fn(() => ({ stop: jest.fn().mockResolvedValue(undefined) }));
80
+ const stale = jest.fn(() => ({ stop: jest.fn().mockResolvedValue(undefined) }));
81
+ const fresh = { stop: jest.fn().mockResolvedValue(undefined) };
82
+ const current = { stop: jest.fn().mockResolvedValue(undefined) };
83
+ const Fresh = jest.fn(() => fresh);
84
+ jest.spyOn(DataManager, 'load').mockImplementation(async next => {
85
+ await loading.promise;
86
+ DataManager.settings = next;
87
+ });
88
+ TransportManager.transport = active as never;
89
+ let core = initCore();
90
+ initConnector();
91
+ const first = switchTransport({ env: 'emulator', Transport: intermediate });
92
+ await entered.promise;
93
+ const second = switchTransport({ env: 'webusb', Transport: stale });
94
+ const rejected = Promise.all([
95
+ expect(first).rejects.toMatchObject({ errorCode: HardwareErrorCode.NotInitialized }),
96
+ expect(second).rejects.toMatchObject({ errorCode: HardwareErrorCode.NotInitialized }),
97
+ ]);
98
+ rejected.catch(() => undefined);
99
+ let initializing: ReturnType<typeof init> | undefined;
100
+ try {
101
+ if (lifecycle === 'dispose/reinit') {
102
+ const disposing = core.dispose();
103
+ closing.resolve();
104
+ await disposing;
105
+ }
106
+ if (lifecycle === 'initCore') {
107
+ TransportManager.transport = fresh as never;
108
+ DataManager.settings = { ...settings, env: 'desktop-web-ble' };
109
+ core = initCore();
110
+ initConnector();
111
+ } else {
112
+ initializing = init({ ...settings, env: 'desktop-web-ble' }, Fresh);
113
+ if (lifecycle === 'init') {
114
+ closing.resolve();
115
+ await rejected;
116
+ }
117
+ loading.resolve();
118
+ const initialized = await initializing;
119
+ if (!initialized) throw new Error('Core initialization failed');
120
+ core = initialized;
121
+ }
122
+ await switchTransport({
123
+ env: 'emulator',
124
+ Transport: function Current() {
125
+ return current;
126
+ },
127
+ });
128
+ closing.resolve();
129
+ await rejected;
130
+ expect(intermediate).not.toHaveBeenCalled();
131
+ expect(stale).not.toHaveBeenCalled();
132
+ expect(TransportManager.transport).toBe(current);
133
+ expect(DataManager.getSettings('env')).toBe('emulator');
134
+ expect(fresh.stop).toHaveBeenCalledTimes(1);
135
+ expect(current.stop).not.toHaveBeenCalled();
136
+ } finally {
137
+ closing.resolve();
138
+ loading.resolve();
139
+ await initializing;
140
+ await Promise.allSettled([first, second, rejected]);
141
+ await core.dispose();
142
+ TransportManager.transport = previous;
143
+ DataManager.settings = settings;
144
+ }
145
+ }
146
+ );
34
147
  });
@@ -45,6 +45,23 @@ describe('Core 错误输出边界', () => {
45
45
  });
46
46
  });
47
47
 
48
+ test.each([
49
+ [HardwareErrorCode.TransportProtocolProbeFailed, { expectedProtocol: 'V1' }],
50
+ [
51
+ HardwareErrorCode.TransportProtocolMismatch,
52
+ { expectedProtocol: 'V2', detectedProtocol: 'V1' },
53
+ ],
54
+ ] as const)(
55
+ 'preserves protocol error %s and structured context across the public boundary',
56
+ (code, params) => {
57
+ const error = ERRORS.TypedError(code, undefined, params);
58
+ expect(JSON.parse(JSON.stringify(createErrorMessage(error)))).toEqual({
59
+ success: false,
60
+ payload: { code, error: error.message, params },
61
+ });
62
+ }
63
+ );
64
+
48
65
  test.each([
49
66
  ['webusb', false],
50
67
  ['desktop-webusb', false],
@@ -129,6 +146,8 @@ describe('Core 错误输出边界', () => {
129
146
  HardwareErrorCode.BleUnsupported,
130
147
  HardwareErrorCode.BridgeNeedsPermission,
131
148
  HardwareErrorCode.WebUsbDeviceAccessError,
149
+ HardwareErrorCode.TransportProtocolProbeFailed,
150
+ HardwareErrorCode.TransportProtocolMismatch,
132
151
  ])(
133
152
  'preserves terminal transport error %s in the public response without polling again',
134
153
  async errorCode => {
@@ -138,6 +157,8 @@ describe('Core 错误输出边界', () => {
138
157
  [
139
158
  HardwareErrorCode.BridgeNeedsPermission,
140
159
  HardwareErrorCode.WebUsbDeviceAccessError,
160
+ HardwareErrorCode.TransportProtocolProbeFailed,
161
+ HardwareErrorCode.TransportProtocolMismatch,
141
162
  ].includes(errorCode)
142
163
  ? ('desktop-webusb' as never)
143
164
  : ('react-native' as never)
@@ -75,8 +75,11 @@ describe('DeviceConnector protocol validation', () => {
75
75
  });
76
76
  const connector = new DeviceConnector();
77
77
 
78
- await expect(connector.acquire('classic-id', undefined, undefined, 'V2')).rejects.toThrow(
79
- 'Device protocol mismatch: expected V2, detected V1'
78
+ await expect(connector.acquire('classic-id', undefined, undefined, 'V2')).rejects.toMatchObject(
79
+ {
80
+ errorCode: HardwareErrorCode.TransportProtocolMismatch,
81
+ params: { expectedProtocol: 'V2', detectedProtocol: 'V1' },
82
+ }
80
83
  );
81
84
  });
82
85
 
@@ -363,11 +363,11 @@ describe('public device lifecycle events', () => {
363
363
 
364
364
  test('clears the cleanup barrier when its deadline expires', async () => {
365
365
  const realSetTimeout = setTimeout;
366
- jest
367
- .spyOn(global, 'setTimeout')
368
- .mockImplementation((callback, delay, ...args) =>
369
- realSetTimeout(callback, delay === 5_000 ? 0 : delay, ...args)
370
- );
366
+ const cleanupDeadline = createDeferred<() => void>();
367
+ jest.spyOn(global, 'setTimeout').mockImplementation((callback, delay, ...args) => {
368
+ if (delay === 5_000) cleanupDeadline.resolve(() => callback(...args));
369
+ return realSetTimeout(callback, delay, ...args);
370
+ });
371
371
  {
372
372
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
373
373
  const acquire = jest.spyOn(Device.prototype, 'acquire').mockResolvedValue(undefined);
@@ -381,13 +381,15 @@ describe('public device lifecycle events', () => {
381
381
  type: IFRAME.CALL,
382
382
  payload: { method: 'getDeviceState', connectId: 'draining-device', connectProtocol: 'V2' },
383
383
  } as CoreMessage);
384
- setImmediate(() => cancel(context, 'draining-device'));
384
+ const expireCleanupDeadline = await cleanupDeadline.promise;
385
+ cancel(context, 'draining-device');
386
+ expireCleanupDeadline();
385
387
  await expect(result).resolves.toBeDefined();
386
- gate.resolve();
387
388
  await new Promise(resolve => {
388
389
  setImmediate(resolve);
389
390
  });
390
391
  expect(context.getPrePendingCallPromise('draining-device')).toBeUndefined();
392
+ gate.resolve();
391
393
  }
392
394
  });
393
395
 
@@ -608,7 +610,9 @@ describe('public device lifecycle events', () => {
608
610
  );
609
611
 
610
612
  test.each([
611
- [HardwareErrorCode.RuntimeError, true],
613
+ [HardwareErrorCode.TransportProtocolProbeFailed, true],
614
+ [HardwareErrorCode.TransportProtocolMismatch, false],
615
+ [HardwareErrorCode.RuntimeError, false],
612
616
  [HardwareErrorCode.BleDeviceBondError, false],
613
617
  ] as const)(
614
618
  'retries a Protocol V2 probe mismatch with error code %s: %s',
@@ -616,8 +620,8 @@ describe('public device lifecycle events', () => {
616
620
  const method = { payload: { connectProtocol: 'V2' } } as never;
617
621
  const error = {
618
622
  errorCode,
619
- message:
620
- 'Device protocol mismatch: expected V2, but device did not respond to expected protocol',
623
+ message: 'diagnostic wording is not used for classification',
624
+ params: { expectedProtocol: 'V2' },
621
625
  };
622
626
 
623
627
  expect(isRetryableBleProtocolV2ProbeError(method, error)).toBe(expected);
@@ -627,6 +631,8 @@ describe('public device lifecycle events', () => {
627
631
  test.each([
628
632
  [HardwareErrorCode.BleConnectedError, true],
629
633
  [HardwareErrorCode.BleTimeoutError, true],
634
+ [HardwareErrorCode.TransportProtocolProbeFailed, true],
635
+ [HardwareErrorCode.TransportProtocolMismatch, false],
630
636
  [HardwareErrorCode.PollingTimeout, false],
631
637
  [HardwareErrorCode.BleDeviceBondError, false],
632
638
  ] as const)('retries a BLE connection error with error code %s: %s', (errorCode, expected) => {
@@ -639,6 +645,21 @@ describe('public device lifecycle events', () => {
639
645
  expect(isRetryableBleConnectionError(method, error)).toBe(expected);
640
646
  });
641
647
 
648
+ test('does not retry a failed strict V1 probe as a V2 probe or after user cancellation', () => {
649
+ const error = ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
650
+ expectedProtocol: 'V1',
651
+ });
652
+ const method = { payload: { connectProtocol: 'V2' } } as never;
653
+ expect(isRetryableBleProtocolV2ProbeError(method, error)).toBe(false);
654
+ expect(isRetryableBleConnectionError(method, error)).toBe(false);
655
+ expect(
656
+ isRetryableBleConnectionError(
657
+ { payload: {}, device: { wasInterruptedByUser: () => true } } as never,
658
+ ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed)
659
+ )
660
+ ).toBe(false);
661
+ });
662
+
642
663
  test('does not retry the desktop acquire deadline fallback', () => {
643
664
  const method = { payload: { connectProtocol: 'V2' } } as never;
644
665
  const error = {
@@ -712,6 +733,48 @@ describe('public device lifecycle events', () => {
712
733
  expect(() => JSON.stringify(disconnectMessage?.payload.device)).not.toThrow();
713
734
  });
714
735
 
736
+ test.each(['V1', 'V2'] as const)(
737
+ 'preserves the %s connection identity for a transport disconnect after wipe',
738
+ protocol => {
739
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('webusb' as never);
740
+ core = initCore();
741
+ initConnector();
742
+ const messages: CoreMessage[] = [];
743
+ core.on(CORE_EVENT, message => messages.push(message));
744
+ const device = createInitializedDevice(protocol);
745
+ const connectId = device.getConnectId();
746
+ DevicePool.devicesCache[connectId] = device;
747
+
748
+ device.invalidateAfterWipe();
749
+ expect(device.toMessageObject()).toBeNull();
750
+ DevicePool.emitter.emit(DEVICE.CONNECT, device);
751
+ expect(messages.some(message => message.type === DEVICE.CONNECT)).toBe(false);
752
+ DevicePool.emitter.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, { connectId });
753
+
754
+ const disconnects = messages.filter(message => message.type === DEVICE.DISCONNECT);
755
+ expect(disconnects).toHaveLength(1);
756
+ expect(disconnects[0].payload.device).toMatchObject({
757
+ connectId,
758
+ serialNo: 'SERIAL-001',
759
+ deviceId: 'wallet-device-id',
760
+ });
761
+ expect(() => JSON.stringify(disconnects[0].payload.device)).not.toThrow();
762
+
763
+ device.features = {
764
+ ...createInitializedDevice(protocol).features,
765
+ deviceId: 'new-device-id',
766
+ initialized: false,
767
+ mode: 'notInitialized',
768
+ } as never;
769
+ DevicePool.emitter.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, { connectId });
770
+ const nextDisconnects = messages.filter(message => message.type === DEVICE.DISCONNECT);
771
+ expect(nextDisconnects[1].payload.device).toMatchObject({
772
+ deviceId: 'new-device-id',
773
+ mode: 'notInitialized',
774
+ });
775
+ }
776
+ );
777
+
715
778
  test('treats an acquired BLE device as reusable until the transport disconnects', async () => {
716
779
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('desktop-web-ble' as never);
717
780
  const device = createInitializedDevice('V2');
@@ -85,6 +85,56 @@ describe('DevicePool state lifecycle', () => {
85
85
  expect(result.deviceList).toEqual([device]);
86
86
  });
87
87
 
88
+ test.each([
89
+ ['V1', undefined],
90
+ ['V1', 'wipe-path'],
91
+ ['V2', undefined],
92
+ ['V2', 'wipe-path'],
93
+ ] as const)('rediscovers a wiped %s device with connect id %s', async (protocol, connectId) => {
94
+ const descriptor = { path: 'wipe-path', protocolType: protocol } as never;
95
+ const device = Device.fromDescriptor(descriptor);
96
+ device.features = {
97
+ protocol,
98
+ deviceType: protocol === 'V2' ? 'pro2' : 'classic',
99
+ deviceId: 'old-device-id',
100
+ serialNo: 'wipe-path',
101
+ initialized: true,
102
+ mode: 'normal',
103
+ } as never;
104
+ DevicePool.devicesCache = { 'wipe-path': device };
105
+ device.invalidateAfterWipe();
106
+ expect(device.toMessageObject()).toBeNull();
107
+ const connect = jest.spyOn(device, 'connect').mockResolvedValue(true);
108
+ const initialize = jest.spyOn(device, 'initialize').mockImplementation(() => {
109
+ device.features = {
110
+ protocol,
111
+ deviceType: protocol === 'V2' ? 'pro2' : 'classic',
112
+ deviceId: 'new-device-id',
113
+ serialNo: 'wipe-path',
114
+ initialized: false,
115
+ mode: 'notInitialized',
116
+ } as never;
117
+ return Promise.resolve();
118
+ });
119
+ const release = jest.spyOn(device, 'release').mockResolvedValue(undefined);
120
+
121
+ try {
122
+ const result = await DevicePool.getDevices([descriptor], connectId);
123
+ expect(initialize).toHaveBeenCalledTimes(1);
124
+ expect(connect).toHaveBeenCalledTimes(1);
125
+ expect(release).toHaveBeenCalledTimes(1);
126
+ expect(result.deviceList[0].toMessageObject()).toMatchObject({
127
+ deviceId: 'new-device-id',
128
+ mode: 'notInitialized',
129
+ features: { initialized: false },
130
+ });
131
+ await DevicePool.getDevices([descriptor], connectId);
132
+ expect(initialize).toHaveBeenCalledTimes(1);
133
+ } finally {
134
+ jest.restoreAllMocks();
135
+ }
136
+ });
137
+
88
138
  test('keeps a discovered device when its Protocol V2 label cannot be read', async () => {
89
139
  const labelError = new Error('settings unavailable');
90
140
  const getDeviceState = jest
@@ -1,5 +1,6 @@
1
1
  import { EDeviceType } from '@onekeyfe/hd-shared';
2
2
 
3
+ import DeviceReboot from '../src/api/protocol-v2/DeviceReboot';
3
4
  import { Device } from '../src/device/Device';
4
5
  import { UI_REQUEST } from '../src/events/ui-request';
5
6
 
@@ -25,6 +26,17 @@ const createBootloaderDevice = (protocolType: 'V1' | 'V2') => {
25
26
  };
26
27
 
27
28
  describe('Pro2 bootloader mode', () => {
29
+ test('allows deviceReboot to return a Protocol V2 bootloader to normal mode', () => {
30
+ const device = createBootloaderDevice('V2');
31
+ const method = new DeviceReboot({
32
+ id: 1,
33
+ payload: { method: 'deviceReboot', rebootType: 'normal' },
34
+ });
35
+ method.init();
36
+
37
+ expect(device.hasUnexpectedMode(method.allowDeviceMode, method.requireDeviceMode)).toBeNull();
38
+ });
39
+
28
40
  test('blocks Protocol V2 methods that are not allowed in bootloader mode', () => {
29
41
  const device = createBootloaderDevice('V2');
30
42