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

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 (166) hide show
  1. package/README.md +27 -0
  2. package/__tests__/DeviceCommands.test.ts +66 -13
  3. package/__tests__/allNetworkGetAddress-privacy-networks.test.ts +77 -0
  4. package/__tests__/allNetworkGetAddress-skippable.test.ts +41 -0
  5. package/__tests__/btc-derive-context-hash.test.ts +200 -0
  6. package/__tests__/core-dispose.test.ts +114 -1
  7. package/__tests__/core-error-output.test.ts +55 -3
  8. package/__tests__/device-connector-protocol.test.ts +5 -2
  9. package/__tests__/device-lifecycle-events.test.ts +191 -27
  10. package/__tests__/device-pool-state.test.ts +50 -0
  11. package/__tests__/firmware-update/firmware-artifact-source.test.ts +11 -0
  12. package/__tests__/logBlockEvent.test.ts +331 -9
  13. package/__tests__/logger.test.ts +18 -0
  14. package/__tests__/monero-get-address.test.ts +147 -0
  15. package/__tests__/monero-key-image-sync.test.ts +272 -0
  16. package/__tests__/monero-sign-transaction.test.ts +436 -0
  17. package/__tests__/protocol-v2-bootloader-mode.test.ts +43 -2
  18. package/__tests__/protocol-v2-key-export-confirmation.test.ts +75 -0
  19. package/__tests__/protocol-v2-resources.test.ts +14 -0
  20. package/__tests__/protocol-v2.test.ts +841 -46
  21. package/__tests__/protocolV2FileWrite.test.ts +454 -7
  22. package/__tests__/refresh-device-state.test.ts +19 -17
  23. package/__tests__/search-devices.test.ts +36 -0
  24. package/__tests__/sol-sign-offchain-message.test.ts +8 -0
  25. package/__tests__/usb-identity-probe.test.ts +358 -0
  26. package/__tests__/zcash-protobuf.test.ts +152 -0
  27. package/__tests__/zcash-sign-pczt.test.ts +525 -0
  28. package/dist/api/BaseMethod.d.ts.map +1 -1
  29. package/dist/api/FileWrite.d.ts.map +1 -1
  30. package/dist/api/FirmwareUpdateV4.d.ts +5 -0
  31. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  32. package/dist/api/GetDeviceState.d.ts.map +1 -1
  33. package/dist/api/SearchDevices.d.ts +21 -1
  34. package/dist/api/SearchDevices.d.ts.map +1 -1
  35. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts +3 -0
  36. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  37. package/dist/api/btc/BTCDeriveContextHash.d.ts +16 -0
  38. package/dist/api/btc/BTCDeriveContextHash.d.ts.map +1 -0
  39. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
  40. package/dist/api/helpers/pathUtils.d.ts +1 -0
  41. package/dist/api/helpers/pathUtils.d.ts.map +1 -1
  42. package/dist/api/helpers/protocolV2FileWrite.d.ts +5 -1
  43. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  44. package/dist/api/index.d.ts +8 -0
  45. package/dist/api/index.d.ts.map +1 -1
  46. package/dist/api/monero/MoneroGetAddress.d.ts +11 -0
  47. package/dist/api/monero/MoneroGetAddress.d.ts.map +1 -0
  48. package/dist/api/monero/MoneroGetWatchKey.d.ts +11 -0
  49. package/dist/api/monero/MoneroGetWatchKey.d.ts.map +1 -0
  50. package/dist/api/monero/MoneroKeyImageSync.d.ts +20 -0
  51. package/dist/api/monero/MoneroKeyImageSync.d.ts.map +1 -0
  52. package/dist/api/monero/MoneroSignTransaction.d.ts +17 -0
  53. package/dist/api/monero/MoneroSignTransaction.d.ts.map +1 -0
  54. package/dist/api/monero/helpers.d.ts +5 -0
  55. package/dist/api/monero/helpers.d.ts.map +1 -0
  56. package/dist/api/protocol-v2/DeviceReboot.d.ts.map +1 -1
  57. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  58. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  59. package/dist/api/zcash/ZcashGetAddress.d.ts +12 -0
  60. package/dist/api/zcash/ZcashGetAddress.d.ts.map +1 -0
  61. package/dist/api/zcash/ZcashGetFullViewingKey.d.ts +12 -0
  62. package/dist/api/zcash/ZcashGetFullViewingKey.d.ts.map +1 -0
  63. package/dist/api/zcash/ZcashSignPczt.d.ts +16 -0
  64. package/dist/api/zcash/ZcashSignPczt.d.ts.map +1 -0
  65. package/dist/api/zcash/helpers.d.ts +2 -0
  66. package/dist/api/zcash/helpers.d.ts.map +1 -0
  67. package/dist/core/index.d.ts +3 -2
  68. package/dist/core/index.d.ts.map +1 -1
  69. package/dist/device/Device.d.ts +3 -0
  70. package/dist/device/Device.d.ts.map +1 -1
  71. package/dist/device/DeviceCommands.d.ts +4 -4
  72. package/dist/device/DeviceCommands.d.ts.map +1 -1
  73. package/dist/device/DevicePool.d.ts.map +1 -1
  74. package/dist/events/logBlockEvent.d.ts +10 -0
  75. package/dist/events/logBlockEvent.d.ts.map +1 -1
  76. package/dist/events/ui-request.d.ts +2 -0
  77. package/dist/events/ui-request.d.ts.map +1 -1
  78. package/dist/index.d.ts +206 -7
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +4443 -2797
  81. package/dist/inject.d.ts.map +1 -1
  82. package/dist/protocols/protocol-v2/resources.d.ts +1 -0
  83. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  84. package/dist/protocols/protocol-v2/uiInteraction.d.ts +1 -0
  85. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  86. package/dist/types/api/allNetworkGetAddress.d.ts +4 -1
  87. package/dist/types/api/allNetworkGetAddress.d.ts.map +1 -1
  88. package/dist/types/api/btcDeriveContextHash.d.ts +10 -0
  89. package/dist/types/api/btcDeriveContextHash.d.ts.map +1 -0
  90. package/dist/types/api/export.d.ts +8 -0
  91. package/dist/types/api/export.d.ts.map +1 -1
  92. package/dist/types/api/index.d.ts +16 -0
  93. package/dist/types/api/index.d.ts.map +1 -1
  94. package/dist/types/api/moneroGetAddress.d.ts +15 -0
  95. package/dist/types/api/moneroGetAddress.d.ts.map +1 -0
  96. package/dist/types/api/moneroGetWatchKey.d.ts +15 -0
  97. package/dist/types/api/moneroGetWatchKey.d.ts.map +1 -0
  98. package/dist/types/api/moneroKeyImageSync.d.ts +24 -0
  99. package/dist/types/api/moneroKeyImageSync.d.ts.map +1 -0
  100. package/dist/types/api/moneroSignTransaction.d.ts +32 -0
  101. package/dist/types/api/moneroSignTransaction.d.ts.map +1 -0
  102. package/dist/types/api/zcashGetAddress.d.ts +20 -0
  103. package/dist/types/api/zcashGetAddress.d.ts.map +1 -0
  104. package/dist/types/api/zcashGetFullViewingKey.d.ts +13 -0
  105. package/dist/types/api/zcashGetFullViewingKey.d.ts.map +1 -0
  106. package/dist/types/api/zcashSignPczt.d.ts +9 -0
  107. package/dist/types/api/zcashSignPczt.d.ts.map +1 -0
  108. package/dist/types/device.d.ts +10 -0
  109. package/dist/types/device.d.ts.map +1 -1
  110. package/dist/types/params.d.ts +1 -0
  111. package/dist/types/params.d.ts.map +1 -1
  112. package/dist/utils/logger.d.ts.map +1 -1
  113. package/dist/utils/patch.d.ts +1 -1
  114. package/dist/utils/patch.d.ts.map +1 -1
  115. package/package.json +4 -4
  116. package/src/api/BaseMethod.ts +2 -0
  117. package/src/api/FileWrite.ts +7 -1
  118. package/src/api/FirmwareUpdateV4.ts +366 -99
  119. package/src/api/GetDeviceState.ts +2 -5
  120. package/src/api/SearchDevices.ts +172 -7
  121. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +46 -6
  122. package/src/api/btc/BTCDeriveContextHash.ts +90 -0
  123. package/src/api/firmware/FirmwareArtifactSource.ts +12 -3
  124. package/src/api/helpers/pathUtils.ts +23 -0
  125. package/src/api/helpers/protocolV2FileWrite.ts +382 -111
  126. package/src/api/index.ts +9 -0
  127. package/src/api/monero/MoneroGetAddress.ts +71 -0
  128. package/src/api/monero/MoneroGetWatchKey.ts +75 -0
  129. package/src/api/monero/MoneroKeyImageSync.ts +170 -0
  130. package/src/api/monero/MoneroSignTransaction.ts +319 -0
  131. package/src/api/monero/helpers.ts +28 -0
  132. package/src/api/protocol-v2/DeviceReboot.ts +2 -0
  133. package/src/api/protocol-v2/DeviceUploadNft.ts +6 -1
  134. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +15 -1
  135. package/src/api/zcash/ZcashGetAddress.ts +108 -0
  136. package/src/api/zcash/ZcashGetFullViewingKey.ts +78 -0
  137. package/src/api/zcash/ZcashSignPczt.ts +152 -0
  138. package/src/api/zcash/helpers.ts +16 -0
  139. package/src/core/index.ts +52 -24
  140. package/src/data/messages/messages-protocol-v2.json +152 -0
  141. package/src/data/messages/messages.json +51 -0
  142. package/src/device/Device.ts +32 -4
  143. package/src/device/DeviceCommands.ts +15 -1
  144. package/src/device/DeviceConnector.ts +4 -4
  145. package/src/device/DevicePool.ts +12 -2
  146. package/src/events/core.ts +2 -2
  147. package/src/events/logBlockEvent.ts +58 -3
  148. package/src/events/ui-request.ts +2 -0
  149. package/src/index.ts +1 -0
  150. package/src/inject.ts +17 -0
  151. package/src/protocols/protocol-v2/resources.ts +6 -1
  152. package/src/protocols/protocol-v2/uiInteraction.ts +1 -1
  153. package/src/types/api/allNetworkGetAddress.ts +8 -1
  154. package/src/types/api/btcDeriveContextHash.ts +15 -0
  155. package/src/types/api/export.ts +17 -0
  156. package/src/types/api/index.ts +24 -0
  157. package/src/types/api/moneroGetAddress.ts +27 -0
  158. package/src/types/api/moneroGetWatchKey.ts +28 -0
  159. package/src/types/api/moneroKeyImageSync.ts +45 -0
  160. package/src/types/api/moneroSignTransaction.ts +54 -0
  161. package/src/types/api/zcashGetAddress.ts +32 -0
  162. package/src/types/api/zcashGetFullViewingKey.ts +24 -0
  163. package/src/types/api/zcashSignPczt.ts +18 -0
  164. package/src/types/device.ts +18 -0
  165. package/src/types/params.ts +1 -1
  166. package/src/utils/logger.ts +4 -0
@@ -1,15 +1,462 @@
1
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
-
3
- import { writeProtocolV2File } from '../src/api/helpers/protocolV2FileWrite';
1
+ import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { DeviceSessionPinType } from '@onekeyfe/hd-transport';
3
+
4
+ import {
5
+ createProtocolV2AssetFileWriteRecovery,
6
+ type ProtocolV2FileWriteOptions,
7
+ writeProtocolV2File,
8
+ } from '../src/api/helpers/protocolV2FileWrite';
4
9
  import { DataManager } from '../src/data-manager';
5
10
  import { LoggerNames, getLogger } from '../src/utils/logger';
6
11
 
12
+ import type { DeviceCommands } from '../src/device/DeviceCommands';
13
+ import type { BaseMethod } from '../src/api/BaseMethod';
14
+ import type { MessageFromOneKey, TransportCallOptions } from '@onekeyfe/hd-transport';
15
+
7
16
  jest.mock('../src/data/config', () => ({
8
17
  getSDKVersion: jest.fn(() => '1.0.0'),
9
18
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
10
19
  }));
11
20
 
12
21
  describe('writeProtocolV2File', () => {
22
+ describe('Pro2 / Neo asset pipelines', () => {
23
+ type Request = {
24
+ file: { path: string; offset: number; total_size: number; data: Uint8Array };
25
+ overwrite: boolean;
26
+ append: boolean;
27
+ ui_percentage: number;
28
+ };
29
+
30
+ const createCommands = (deviceType = EDeviceType.Pro2) => {
31
+ const requests: Request[] = [];
32
+ const device = {
33
+ isProtocolV2: () => true,
34
+ getCurrentDeviceType: () => deviceType,
35
+ state: { status: { mode: 'normal' }, versions: { firmware: '1.0.3', bootloader: '1.0.0' } },
36
+ };
37
+ const typedCall = jest.fn(
38
+ async (
39
+ _name: string,
40
+ _response: string,
41
+ first: Request,
42
+ options?: TransportCallOptions
43
+ ) => {
44
+ let request: Request | undefined = first;
45
+ let response: MessageFromOneKey = { type: 'FilesystemFile', message: {} };
46
+ while (request) {
47
+ requests.push(request);
48
+ response = {
49
+ type: 'FilesystemFile',
50
+ message: {
51
+ ...request.file,
52
+ processed_byte: request.file.offset + request.file.data.byteLength,
53
+ },
54
+ };
55
+ options?.fileWritePipeline?.onResponse(response);
56
+ request = (await options?.fileWritePipeline?.next()) as Request | undefined;
57
+ }
58
+ return response;
59
+ }
60
+ );
61
+ const commands = { device, typedCall } as unknown as DeviceCommands;
62
+ return { commands, device, typedCall, requests };
63
+ };
64
+
65
+ beforeEach(() => {
66
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native');
67
+ jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(true);
68
+ });
69
+
70
+ afterEach(() => jest.restoreAllMocks());
71
+
72
+ test.each(
73
+ [EDeviceType.Pro2, EDeviceType.Neo].flatMap(deviceType =>
74
+ ['react-native', 'desktop-web-ble'].flatMap(env =>
75
+ [
76
+ 'vol1:/wallpapers/wallpaper.okpkg',
77
+ 'vol1:/nft/nft-deadbeef-1760000000000.okpkg',
78
+ 'vol1:/portfolio/portfolio.okpkg.pending',
79
+ ].map(path => ({ deviceType, env, path }))
80
+ )
81
+ )
82
+ )(
83
+ 'pipelines $deviceType $path on $env using application version',
84
+ async ({ deviceType, env, path }) => {
85
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
86
+ const { commands, typedCall, requests } = createCommands(deviceType);
87
+ const onProgress = jest.fn();
88
+
89
+ await expect(
90
+ writeProtocolV2File({
91
+ commands,
92
+ path,
93
+ data: new Uint8Array(3601),
94
+ overwrite: true,
95
+ bleChunkSizeLimit: 1960,
96
+ chunkSizeLimit: 4000,
97
+ onProgress,
98
+ })
99
+ ).resolves.toMatchObject({ processed_byte: 3601, chunks: 3 });
100
+
101
+ expect(typedCall).toHaveBeenCalledTimes(1);
102
+ expect(typedCall.mock.calls[0][3]).toMatchObject({
103
+ writeWithResponse: false,
104
+ fileWritePipeline: { next: expect.any(Function), onResponse: expect.any(Function) },
105
+ });
106
+ expect(requests.map(request => request.file.offset)).toEqual([0, 1800, 3600]);
107
+ expect(requests.map(request => request.file.data.byteLength)).toEqual([1800, 1800, 1]);
108
+ expect(requests.map(request => request.overwrite)).toEqual([true, false, false]);
109
+ expect(onProgress.mock.calls.map(([progress]) => progress.transferredBytes)).toEqual([
110
+ 1800, 3600, 3601,
111
+ ]);
112
+ }
113
+ );
114
+
115
+ test('does not replay a failed pipeline on the invalidated link', async () => {
116
+ const { commands, typedCall } = createCommands();
117
+ const timeout = new Error('BLE response timeout');
118
+ typedCall.mockRejectedValueOnce(timeout);
119
+
120
+ await expect(
121
+ writeProtocolV2File({
122
+ commands,
123
+ overwrite: true,
124
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
125
+ data: new Uint8Array(3601),
126
+ maxChunkRetries: 3,
127
+ })
128
+ ).rejects.toBe(timeout);
129
+ expect(typedCall).toHaveBeenCalledTimes(1);
130
+ });
131
+
132
+ test.each(
133
+ [EDeviceType.Pro2, EDeviceType.Neo].flatMap(deviceType =>
134
+ ['react-native', 'desktop-web-ble'].flatMap(env =>
135
+ [
136
+ 'vol1:/wallpapers/wallpaper.okpkg',
137
+ 'vol1:/nft/nft-deadbeef-1760000000000.okpkg',
138
+ 'vol1:/portfolio/portfolio.okpkg.pending',
139
+ ].map(path => ({ deviceType, env, path }))
140
+ )
141
+ )
142
+ )(
143
+ 'reconnects and restarts the dual-frame $deviceType $path upload on $env',
144
+ async ({ deviceType, env, path }) => {
145
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
146
+ const { commands, typedCall } = createCommands(deviceType);
147
+ const fresh = createCommands(deviceType);
148
+ const timeout = Object.assign(new Error('BLE response timeout'), {
149
+ errorCode: HardwareErrorCode.BleTimeoutError,
150
+ });
151
+ typedCall.mockImplementationOnce((_name, _response, first, options) => {
152
+ options?.fileWritePipeline?.onResponse({
153
+ type: 'FilesystemFile',
154
+ message: { ...first.file, processed_byte: 1800 },
155
+ });
156
+ return Promise.reject(timeout);
157
+ });
158
+ const recoverConnection = jest.fn().mockResolvedValue(fresh.commands);
159
+ const onProgress = jest.fn();
160
+ const metrics = jest.spyOn(getLogger(LoggerNames.Core), 'log');
161
+
162
+ await expect(
163
+ writeProtocolV2File({
164
+ commands,
165
+ overwrite: true,
166
+ path,
167
+ data: new Uint8Array(3601),
168
+ maxChunkRetries: 3,
169
+ recoverConnection,
170
+ onProgress,
171
+ })
172
+ ).resolves.toMatchObject({ processed_byte: 3601, chunks: 3 });
173
+
174
+ expect(typedCall).toHaveBeenCalledTimes(1);
175
+ expect(recoverConnection).toHaveBeenCalledTimes(1);
176
+ expect(metrics).toHaveBeenCalledWith(
177
+ expect.stringContaining(`status=failed path=${path} bytes=1800/3601`)
178
+ );
179
+ expect(metrics).toHaveBeenLastCalledWith(
180
+ expect.stringContaining('window=2 chunk=1800 retry=1 averageSpeed=')
181
+ );
182
+ expect(fresh.requests.map(request => request.file.offset)).toEqual([0, 1800, 3600]);
183
+ expect(fresh.requests.map(request => request.overwrite)).toEqual([true, false, false]);
184
+ expect(fresh.typedCall).toHaveBeenCalledTimes(1);
185
+ expect(fresh.typedCall.mock.calls[0][3]?.fileWritePipeline).toBeDefined();
186
+ expect(fresh.typedCall.mock.calls[0][3]?.timeoutMs).toBe(10_000);
187
+ expect(onProgress.mock.calls.map(([progress]) => progress.transferredBytes)).toEqual([
188
+ 1800, 0, 1800, 3600, 3601,
189
+ ]);
190
+ expect(onProgress.mock.calls[onProgress.mock.calls.length - 1][0]).toMatchObject({
191
+ retryCount: 1,
192
+ progress: 100,
193
+ });
194
+ }
195
+ );
196
+
197
+ test('bounds dual-frame whole-file recovery to three extra attempts', async () => {
198
+ const { commands, typedCall } = createCommands();
199
+ const timeout = Object.assign(new Error('BLE response timeout'), {
200
+ errorCode: HardwareErrorCode.BleTimeoutError,
201
+ });
202
+ typedCall.mockRejectedValue(timeout);
203
+ const recoverConnection = jest.fn().mockResolvedValue(commands);
204
+ await expect(
205
+ writeProtocolV2File({
206
+ commands,
207
+ overwrite: true,
208
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
209
+ data: new Uint8Array(3601),
210
+ maxChunkRetries: 3,
211
+ recoverConnection,
212
+ })
213
+ ).rejects.toBe(timeout);
214
+ expect(typedCall).toHaveBeenCalledTimes(4);
215
+ expect(recoverConnection).toHaveBeenCalledTimes(3);
216
+ expect(typedCall.mock.calls.every(call => !!call[3]?.fileWritePipeline)).toBe(true);
217
+ });
218
+
219
+ test('does not start a retry when cancelled during reconnect', async () => {
220
+ const { commands, typedCall } = createCommands();
221
+ const aborted = new Error('cancelled');
222
+ let cancelled = false;
223
+ typedCall.mockRejectedValueOnce(new Error('BLE response timeout'));
224
+ const recoverConnection = jest.fn(() => {
225
+ cancelled = true;
226
+ return Promise.resolve(commands);
227
+ });
228
+ await expect(
229
+ writeProtocolV2File({
230
+ commands,
231
+ overwrite: true,
232
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
233
+ data: new Uint8Array(3601),
234
+ maxChunkRetries: 3,
235
+ recoverConnection,
236
+ throwIfAborted: () => {
237
+ if (cancelled) throw aborted;
238
+ },
239
+ })
240
+ ).rejects.toBe(aborted);
241
+ expect(typedCall).toHaveBeenCalledTimes(1);
242
+ });
243
+
244
+ test('does not retry device rejections or callback failures', async () => {
245
+ const { commands, typedCall } = createCommands();
246
+ const failure = new Error('device rejected file');
247
+ typedCall.mockRejectedValueOnce(failure);
248
+ const recoverConnection = jest.fn();
249
+ await expect(
250
+ writeProtocolV2File({
251
+ commands,
252
+ overwrite: true,
253
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
254
+ data: new Uint8Array(3601),
255
+ maxChunkRetries: 3,
256
+ recoverConnection,
257
+ })
258
+ ).rejects.toBe(failure);
259
+ expect(recoverConnection).not.toHaveBeenCalled();
260
+ });
261
+
262
+ test.each(['different-device', ''])(
263
+ 'rejects reconnect identity %s before unlocking or writing',
264
+ async serialNo => {
265
+ const { commands, device, typedCall } = createCommands();
266
+ const state = {
267
+ ...device.state,
268
+ identity: { serialNo: 'test-device', deviceType: EDeviceType.Pro2 },
269
+ };
270
+ const acquire = jest.fn().mockResolvedValue(undefined);
271
+ const initialize = jest.fn(() => {
272
+ state.identity.serialNo = serialNo;
273
+ return Promise.resolve(undefined);
274
+ });
275
+ const method = {
276
+ device: { ...device, state, commands, acquire, initialize },
277
+ } as unknown as BaseMethod;
278
+ const recover = createProtocolV2AssetFileWriteRecovery(method);
279
+ await expect(recover()).rejects.toMatchObject({
280
+ errorCode: HardwareErrorCode.DeviceCheckDeviceIdError,
281
+ });
282
+ expect(acquire).toHaveBeenCalledWith('V2', { throwOnRunPromiseError: true });
283
+ expect(typedCall).not.toHaveBeenCalled();
284
+ }
285
+ );
286
+
287
+ test.each(['react-native', 'desktop-web-ble'])(
288
+ 'disconnects a cached %s link and refreshes unlock status after reconnect',
289
+ async env => {
290
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
291
+ const { commands, device, typedCall } = createCommands();
292
+ typedCall.mockResolvedValueOnce({ type: 'DeviceStatus', message: { unlocked: false } });
293
+ const unlockDevice = jest.fn().mockResolvedValue({ unlocked: true });
294
+ let nativeConnected = true;
295
+ const disconnect = jest.fn(() => {
296
+ nativeConnected = false;
297
+ return Promise.resolve();
298
+ });
299
+ const acquire = jest.fn(() => {
300
+ if (nativeConnected) {
301
+ return Promise.reject(new Error('reused the timed-out native connection'));
302
+ }
303
+ nativeConnected = true;
304
+ return Promise.resolve();
305
+ });
306
+ const method = {
307
+ name: 'deviceUploadWallpaper',
308
+ device: {
309
+ ...device,
310
+ state: {
311
+ ...device.state,
312
+ identity: { serialNo: 'test-device', deviceType: EDeviceType.Pro2 },
313
+ status: { mode: 'normal', initialized: true },
314
+ },
315
+ commands,
316
+ originalDescriptor: { id: 'ble-device' },
317
+ deviceConnector: { disconnect },
318
+ acquire,
319
+ initialize: jest.fn().mockResolvedValue(undefined),
320
+ updateProtocolV2Status: (status: unknown) => status,
321
+ unlockDevice,
322
+ toMessageObject: () => ({}),
323
+ },
324
+ unlockPolicy: 'unlock-before-run',
325
+ useDevicePassphraseState: false,
326
+ protocolV2PreUnlockPinType: DeviceSessionPinType.Any,
327
+ protocolV2UnlockContext: { preflightCompleted: true },
328
+ postMessage: jest.fn(),
329
+ payload: {},
330
+ } as unknown as BaseMethod;
331
+ await expect(createProtocolV2AssetFileWriteRecovery(method)()).resolves.toBe(commands);
332
+ expect(disconnect).toHaveBeenCalledWith('ble-device');
333
+ expect(acquire).toHaveBeenCalledWith('V2', { throwOnRunPromiseError: true });
334
+ expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
335
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, expect.any(Object));
336
+ }
337
+ );
338
+
339
+ const serialCases: {
340
+ name: string;
341
+ firmware?: string;
342
+ protocol?: boolean;
343
+ mode?: string;
344
+ deviceType?: EDeviceType;
345
+ env?: string;
346
+ options?: Partial<ProtocolV2FileWriteOptions>;
347
+ }[] = [
348
+ { name: 'old application with a new bootloader', firmware: '1.0.2' },
349
+ { name: 'unknown application', firmware: '' },
350
+ { name: 'invalid application version', firmware: 'unknown' },
351
+ { name: 'application prerelease', firmware: '1.0.3-rc.1' },
352
+ { name: 'Protocol V1', protocol: false },
353
+ { name: 'bootloader runtime', mode: 'bootloader' },
354
+ { name: 'another model', deviceType: EDeviceType.Pro },
355
+ { name: 'WebUSB', env: 'web' },
356
+ { name: 'LowLevel transport', env: 'lowlevel' },
357
+ { name: 'a generic file', options: { path: 'vol1:/other.bin' } },
358
+ { name: 'a partial file', options: { totalSize: 7200 } },
359
+ { name: 'non-overwriting writes', options: { overwrite: false } },
360
+ { name: 'append', options: { append: true } },
361
+ { name: 'offset writes', options: { offset: 1 } },
362
+ { name: 'explicit pacing', options: { paceMs: 1 } },
363
+ { name: 'acknowledged characteristic writes', options: { writeWithResponse: true } },
364
+ ];
365
+
366
+ test.each(serialCases)('keeps $name serial', async testCase => {
367
+ const { commands, device, typedCall } = createCommands(testCase.deviceType);
368
+ device.isProtocolV2 = () => testCase.protocol ?? true;
369
+ device.state.versions.firmware = testCase.firmware ?? '1.0.3';
370
+ device.state.versions.bootloader = '9.0.0';
371
+ device.state.status.mode = testCase.mode ?? 'normal';
372
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(testCase.env ?? 'react-native');
373
+
374
+ await writeProtocolV2File({
375
+ commands,
376
+ overwrite: true,
377
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
378
+ data: new Uint8Array(3601),
379
+ ...testCase.options,
380
+ });
381
+
382
+ expect(typedCall.mock.calls.every(call => !call[3]?.fileWritePipeline)).toBe(true);
383
+ });
384
+
385
+ test('drains an in-flight response on cancellation before rejecting', async () => {
386
+ const { commands, typedCall } = createCommands();
387
+ const aborted = new Error('upload cancelled');
388
+ let cancelled = false;
389
+ const drained = jest.fn();
390
+ const onProgress = jest.fn();
391
+ typedCall.mockImplementationOnce(async (_name, _response, first, options) => {
392
+ const pipeline = options?.fileWritePipeline;
393
+ if (!pipeline) throw new Error('Expected a file write pipeline');
394
+ const second = (await pipeline.next()) as Request;
395
+ cancelled = true;
396
+ expect(() =>
397
+ pipeline.onResponse({ type: 'FilesystemFile', message: { processed_byte: 1800 } })
398
+ ).not.toThrow();
399
+ await expect(pipeline.next()).resolves.toBeUndefined();
400
+ const response = { type: 'FilesystemFile', message: { processed_byte: 3600 } };
401
+ pipeline.onResponse(response);
402
+ drained(first, second);
403
+ return response;
404
+ });
405
+
406
+ await expect(
407
+ writeProtocolV2File({
408
+ commands,
409
+ overwrite: true,
410
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
411
+ data: new Uint8Array(5401),
412
+ throwIfAborted: () => {
413
+ if (cancelled) throw aborted;
414
+ },
415
+ onProgress,
416
+ })
417
+ ).rejects.toBe(aborted);
418
+ expect(drained).toHaveBeenCalledTimes(1);
419
+ expect(onProgress).not.toHaveBeenCalled();
420
+ expect(typedCall).toHaveBeenCalledTimes(1);
421
+ });
422
+
423
+ test('raises a progress callback error after stopping further chunks', async () => {
424
+ const { commands, typedCall, requests } = createCommands();
425
+ const callbackError = new Error('progress callback failed');
426
+
427
+ await expect(
428
+ writeProtocolV2File({
429
+ commands,
430
+ overwrite: true,
431
+ path: 'vol1:/portfolio/portfolio.okpkg.pending',
432
+ data: new Uint8Array(3601),
433
+ onProgress: () => {
434
+ throw callbackError;
435
+ },
436
+ })
437
+ ).rejects.toBe(callbackError);
438
+ expect(requests).toHaveLength(1);
439
+ expect(typedCall).toHaveBeenCalledTimes(1);
440
+ });
441
+
442
+ test('rejects a pipeline that returns without confirming every chunk', async () => {
443
+ const { commands, typedCall } = createCommands();
444
+ typedCall.mockResolvedValueOnce({
445
+ type: 'FilesystemFile',
446
+ message: { processed_byte: 3601 },
447
+ });
448
+ await expect(
449
+ writeProtocolV2File({
450
+ commands,
451
+ overwrite: true,
452
+ path: 'vol1:/wallpapers/wallpaper.okpkg',
453
+ data: new Uint8Array(3601),
454
+ })
455
+ ).rejects.toThrow('before the complete file was confirmed');
456
+ expect(typedCall).toHaveBeenCalledTimes(1);
457
+ });
458
+ });
459
+
13
460
  test('allows a verified caller-specific BLE chunk limit', async () => {
14
461
  const getSettingsSpy = jest
15
462
  .spyOn(DataManager, 'getSettings')
@@ -200,7 +647,7 @@ describe('writeProtocolV2File', () => {
200
647
  });
201
648
 
202
649
  expect(logSpy).toHaveBeenLastCalledWith(
203
- '[FileWrite] metrics transport=unknown status=completed path=vol0:/firmware.bin bytes=3/3 elapsed=0.05s speed=0.06 KiB/s hostWriteTotal=0.03s responseWaitTotal=0.03s measuredAttempts=1'
650
+ '[FileWrite] metrics transport=unknown status=completed path=vol0:/firmware.bin bytes=3/3 elapsed=0.05s speed=0.06 KiB/s hostWriteTotal=0.03s responseWaitTotal=0.03s measuredAttempts=1 window=1 chunk=4000 retry=0 averageSpeed=0.06 KiB/s'
204
651
  );
205
652
  } finally {
206
653
  dateNowSpy.mockRestore();
@@ -277,11 +724,11 @@ describe('writeProtocolV2File', () => {
277
724
  );
278
725
  expect(logSpy).toHaveBeenNthCalledWith(
279
726
  2,
280
- '[FileWrite] metrics transport=BLE status=progress path=vol1:/wallpapers/test.bin bytes=3600/5400 elapsed=10.00s speed=0.35 KiB/s'
727
+ '[FileWrite] metrics transport=BLE status=progress path=vol1:/wallpapers/test.bin bytes=3600/5400 elapsed=10.00s speed=0.35 KiB/s window=1 chunk=1800 retry=0 averageSpeed=0.35 KiB/s'
281
728
  );
282
729
  expect(logSpy).toHaveBeenNthCalledWith(
283
730
  3,
284
- '[FileWrite] metrics transport=BLE status=completed path=vol1:/wallpapers/test.bin bytes=5400/5400 elapsed=14.00s speed=0.44 KiB/s'
731
+ '[FileWrite] metrics transport=BLE status=completed path=vol1:/wallpapers/test.bin bytes=5400/5400 elapsed=14.00s speed=0.44 KiB/s window=1 chunk=1800 retry=0 averageSpeed=0.38 KiB/s'
285
732
  );
286
733
  } finally {
287
734
  dateNowSpy.mockRestore();
@@ -315,7 +762,7 @@ describe('writeProtocolV2File', () => {
315
762
  );
316
763
  expect(logSpy).toHaveBeenNthCalledWith(
317
764
  2,
318
- '[FileWrite] metrics transport=unknown status=failed path=vol1:/wallpapers/test.bin bytes=0/4000 elapsed=1.00s speed=0.00 KiB/s'
765
+ '[FileWrite] metrics transport=unknown status=failed path=vol1:/wallpapers/test.bin bytes=0/4000 elapsed=1.00s speed=0.00 KiB/s window=1 chunk=4000 retry=0 averageSpeed=0.00 KiB/s'
319
766
  );
320
767
  } finally {
321
768
  dateNowSpy.mockRestore();
@@ -73,24 +73,26 @@ describe('live device state reads', () => {
73
73
  }
74
74
  );
75
75
 
76
- test('rejects settings scope in loader mode after minimal mode detection', async () => {
77
- const getDeviceState = jest.fn().mockResolvedValue({ status: { mode: 'bootloader' } });
78
- const method = new GetDeviceState({
79
- id: 1,
80
- payload: { method: 'getDeviceState', scope: 'settings' },
81
- });
82
- method.init();
83
- (method as any).device = {
84
- isProtocolV2: () => true,
85
- getCurrentFirmwareType: () => 'universal',
86
- getDeviceState,
87
- };
76
+ test.each(['bootloader', 'romloader'] as const)(
77
+ 'reports %s mode for settings scope after minimal mode detection',
78
+ async mode => {
79
+ const getDeviceState = jest.fn().mockResolvedValue({ status: { mode } });
80
+ const method = new GetDeviceState({
81
+ id: 1,
82
+ payload: { method: 'getDeviceState', scope: 'settings' },
83
+ });
84
+ method.init();
85
+ (method as any).device = {
86
+ isProtocolV2: () => true,
87
+ getDeviceState,
88
+ };
88
89
 
89
- await expect(method.run()).rejects.toMatchObject({
90
- errorCode: HardwareErrorCode.DeviceNotSupportMethod,
91
- });
92
- expect(getDeviceState).toHaveBeenCalledWith({ refreshSections: ['status', 'settings'] });
93
- });
90
+ await expect(method.run()).rejects.toMatchObject({
91
+ errorCode: HardwareErrorCode.NotAllowInBootloaderMode,
92
+ });
93
+ expect(getDeviceState).toHaveBeenCalledWith({ refreshSections: ['status', 'settings'] });
94
+ }
95
+ );
94
96
 
95
97
  test('rejects an unknown scope', () => {
96
98
  const method = new GetDeviceState({
@@ -338,6 +338,42 @@ describe('SearchDevices', () => {
338
338
  }
339
339
  );
340
340
 
341
+ test('BLE discovery returns the advertised color letter without a device identity', async () => {
342
+ mockIsBleConnect.mockReturnValue(true);
343
+
344
+ const descriptor = {
345
+ id: 'ble-peripheral-id',
346
+ path: 'ble-peripheral-id',
347
+ name: 'Neo A1B2',
348
+ commType: 'ble',
349
+ bleColorCode: 'E',
350
+ };
351
+ const method = new SearchDevices({
352
+ id: 1,
353
+ payload: {
354
+ method: 'searchDevices',
355
+ },
356
+ } as any);
357
+ method.init();
358
+ method.connector = {
359
+ enumerate: jest.fn().mockResolvedValue({
360
+ descriptors: [descriptor],
361
+ }),
362
+ } as any;
363
+
364
+ await expect(method.run()).resolves.toEqual([
365
+ {
366
+ ...descriptor,
367
+ connectId: 'ble-peripheral-id',
368
+ serialNo: null,
369
+ uuid: '',
370
+ deviceId: null,
371
+ deviceType: 'neo',
372
+ bleColorCode: 'E',
373
+ },
374
+ ]);
375
+ });
376
+
341
377
  test.each([
342
378
  ['Pro2 A1B2 - Find My', undefined, 'Pro 2 A1B2'],
343
379
  ['Pro2 5E9D - Finde My', undefined, 'Pro 2 5E9D'],
@@ -54,6 +54,14 @@ describe('SolSignOffchainMessage', () => {
54
54
  });
55
55
  });
56
56
 
57
+ test('maps omitted required signers to an empty repeated field', () => {
58
+ const method = createMethod({ requiredSigners: undefined });
59
+
60
+ method.init();
61
+
62
+ expect(method.params.required_signers).toEqual([]);
63
+ });
64
+
57
65
  test.each([
58
66
  { requiredSigners: ['11'], error: '32-byte hex public key' },
59
67
  { requiredSigners: [signerB, signerA], error: 'strictly sorted and unique' },