@onekeyfe/hd-core 1.2.0-alpha.10 → 1.2.0-alpha.12

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 (122) hide show
  1. package/__tests__/DeviceCommands.test.ts +25 -0
  2. package/__tests__/RequestQueue.test.ts +34 -0
  3. package/__tests__/pro2Wallpaper.test.ts +50 -0
  4. package/__tests__/protocol-v2.test.ts +511 -61
  5. package/__tests__/protocolV2FileWrite.test.ts +55 -0
  6. package/dist/api/BaseMethod.d.ts +4 -0
  7. package/dist/api/BaseMethod.d.ts.map +1 -1
  8. package/dist/api/FileWrite.d.ts.map +1 -1
  9. package/dist/api/FirmwareUpdateV4.d.ts +0 -4
  10. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  11. package/dist/api/GetPassphraseState.d.ts +1 -6
  12. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  13. package/dist/api/UploadPortfolio.d.ts +18 -0
  14. package/dist/api/UploadPortfolio.d.ts.map +1 -0
  15. package/dist/api/helpers/protocolV2FileWrite.d.ts +32 -0
  16. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -0
  17. package/dist/api/index.d.ts +6 -0
  18. package/dist/api/index.d.ts.map +1 -1
  19. package/dist/api/protocol-v2/DeviceGetOnboardingStatus.d.ts +6 -0
  20. package/dist/api/protocol-v2/DeviceGetOnboardingStatus.d.ts.map +1 -0
  21. package/dist/api/protocol-v2/DeviceSessionGet.d.ts +1 -4
  22. package/dist/api/protocol-v2/DeviceSessionGet.d.ts.map +1 -1
  23. package/dist/api/protocol-v2/DeviceSettingsGet.d.ts +6 -0
  24. package/dist/api/protocol-v2/DeviceSettingsGet.d.ts.map +1 -0
  25. package/dist/api/protocol-v2/DeviceSettingsPageShow.d.ts +16 -0
  26. package/dist/api/protocol-v2/DeviceSettingsPageShow.d.ts.map +1 -0
  27. package/dist/api/protocol-v2/DeviceSettingsSet.d.ts +12 -0
  28. package/dist/api/protocol-v2/DeviceSettingsSet.d.ts.map +1 -0
  29. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts +26 -0
  30. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -0
  31. package/dist/core/RequestQueue.d.ts +2 -0
  32. package/dist/core/RequestQueue.d.ts.map +1 -1
  33. package/dist/core/index.d.ts +1 -0
  34. package/dist/core/index.d.ts.map +1 -1
  35. package/dist/device/Device.d.ts.map +1 -1
  36. package/dist/device/DeviceCommands.d.ts +4 -4
  37. package/dist/device/DeviceCommands.d.ts.map +1 -1
  38. package/dist/deviceProfile/buildDeviceFeatures.d.ts +2 -2
  39. package/dist/deviceProfile/buildDeviceFeatures.d.ts.map +1 -1
  40. package/dist/deviceProfile/buildDeviceProfile.d.ts.map +1 -1
  41. package/dist/events/call.d.ts +7 -0
  42. package/dist/events/call.d.ts.map +1 -1
  43. package/dist/events/core.d.ts +2 -2
  44. package/dist/events/core.d.ts.map +1 -1
  45. package/dist/events/iframe.d.ts +1 -0
  46. package/dist/events/iframe.d.ts.map +1 -1
  47. package/dist/index.d.ts +80 -23
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +922 -472
  50. package/dist/inject.d.ts +3 -2
  51. package/dist/inject.d.ts.map +1 -1
  52. package/dist/lowLevelInject.d.ts +2 -1
  53. package/dist/lowLevelInject.d.ts.map +1 -1
  54. package/dist/protocols/protocol-v2/features.d.ts +1 -0
  55. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  56. package/dist/protocols/protocol-v2/index.d.ts +1 -1
  57. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  58. package/dist/protocols/protocol-v2/unlockRetry.d.ts +9 -0
  59. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -0
  60. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  61. package/dist/topLevelInject.d.ts.map +1 -1
  62. package/dist/types/api/firmwareUpdate.d.ts +2 -0
  63. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  64. package/dist/types/api/getDeviceInfo.d.ts +1 -1
  65. package/dist/types/api/getDeviceInfo.d.ts.map +1 -1
  66. package/dist/types/api/getPassphraseState.d.ts +2 -10
  67. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  68. package/dist/types/api/index.d.ts +9 -2
  69. package/dist/types/api/index.d.ts.map +1 -1
  70. package/dist/types/api/protocolV2.d.ts +20 -4
  71. package/dist/types/api/protocolV2.d.ts.map +1 -1
  72. package/dist/types/device.d.ts +1 -1
  73. package/dist/types/device.d.ts.map +1 -1
  74. package/dist/utils/deviceFeaturesUtils.d.ts +0 -2
  75. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  76. package/dist/utils/index.d.ts +2 -0
  77. package/dist/utils/index.d.ts.map +1 -1
  78. package/dist/utils/patch.d.ts +1 -1
  79. package/dist/utils/patch.d.ts.map +1 -1
  80. package/dist/utils/pro2Wallpaper.d.ts +12 -0
  81. package/dist/utils/pro2Wallpaper.d.ts.map +1 -0
  82. package/package.json +4 -4
  83. package/src/api/BaseMethod.ts +18 -0
  84. package/src/api/FileWrite.ts +18 -178
  85. package/src/api/FirmwareUpdateV4.ts +21 -91
  86. package/src/api/GetPassphraseState.ts +9 -18
  87. package/src/api/UploadPortfolio.ts +37 -0
  88. package/src/api/helpers/protocolV2FileWrite.ts +164 -0
  89. package/src/api/index.ts +6 -0
  90. package/src/api/protocol-v2/DeviceGetOnboardingStatus.ts +18 -0
  91. package/src/api/protocol-v2/DeviceSessionGet.ts +3 -14
  92. package/src/api/protocol-v2/DeviceSettingsGet.ts +16 -0
  93. package/src/api/protocol-v2/DeviceSettingsPageShow.ts +59 -0
  94. package/src/api/protocol-v2/DeviceSettingsSet.ts +39 -0
  95. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +124 -0
  96. package/src/core/RequestQueue.ts +19 -0
  97. package/src/core/index.ts +10 -1
  98. package/src/data/messages/messages-protocol-v2.json +92 -8
  99. package/src/device/Device.ts +17 -3
  100. package/src/device/DeviceCommands.ts +9 -3
  101. package/src/deviceProfile/buildDeviceFeatures.ts +20 -12
  102. package/src/deviceProfile/buildDeviceProfile.ts +8 -6
  103. package/src/events/call.ts +6 -0
  104. package/src/events/core.ts +2 -0
  105. package/src/events/iframe.ts +1 -0
  106. package/src/index.ts +4 -0
  107. package/src/inject.ts +15 -0
  108. package/src/lowLevelInject.ts +3 -0
  109. package/src/protocols/protocol-v2/features.ts +19 -1
  110. package/src/protocols/protocol-v2/index.ts +2 -0
  111. package/src/protocols/protocol-v2/unlockRetry.ts +46 -0
  112. package/src/protocols/protocol-v2/walletSession.ts +14 -5
  113. package/src/topLevelInject.ts +2 -0
  114. package/src/types/api/firmwareUpdate.ts +12 -0
  115. package/src/types/api/getDeviceInfo.ts +1 -1
  116. package/src/types/api/getPassphraseState.ts +2 -11
  117. package/src/types/api/index.ts +14 -1
  118. package/src/types/api/protocolV2.ts +52 -3
  119. package/src/types/device.ts +1 -0
  120. package/src/utils/deviceFeaturesUtils.ts +3 -8
  121. package/src/utils/index.ts +6 -0
  122. package/src/utils/pro2Wallpaper.ts +111 -0
@@ -7,12 +7,18 @@ import DnxSignTransaction from '../src/api/dynex/DnxSignTransaction';
7
7
  import DirList from '../src/api/DirList';
8
8
  import FileRead from '../src/api/FileRead';
9
9
  import FileWrite from '../src/api/FileWrite';
10
+ import UploadPortfolio from '../src/api/UploadPortfolio';
10
11
  import DeviceFactoryInfoGet from '../src/api/protocol-v2/DeviceFactoryInfoGet';
11
12
  import DeviceFactoryInfoSet from '../src/api/protocol-v2/DeviceFactoryInfoSet';
12
13
  import DeviceFirmwareUpdate from '../src/api/protocol-v2/DeviceFirmwareUpdate';
13
14
  import DeviceGetFirmwareUpdateStatus from '../src/api/protocol-v2/DeviceGetFirmwareUpdateStatus';
15
+ import DeviceGetOnboardingStatus from '../src/api/protocol-v2/DeviceGetOnboardingStatus';
14
16
  import DeviceInfoGet from '../src/api/protocol-v2/DeviceInfoGet';
15
17
  import DeviceReboot from '../src/api/protocol-v2/DeviceReboot';
18
+ import DeviceSettingsGet from '../src/api/protocol-v2/DeviceSettingsGet';
19
+ import DeviceSettingsPageShow from '../src/api/protocol-v2/DeviceSettingsPageShow';
20
+ import DeviceSettingsSet from '../src/api/protocol-v2/DeviceSettingsSet';
21
+ import DeviceUploadWallpaper from '../src/api/protocol-v2/DeviceUploadWallpaper';
16
22
  import DeviceSessionGet from '../src/api/protocol-v2/DeviceSessionGet';
17
23
  import DeviceStatusGet from '../src/api/protocol-v2/DeviceStatusGet';
18
24
  import FilesystemFormat from '../src/api/protocol-v2/FilesystemFormat';
@@ -56,6 +62,7 @@ import {
56
62
  getProtocolV2WalletSession,
57
63
  refreshProtocolV2DeviceStatus,
58
64
  } from '../src/protocols/protocol-v2/walletSession';
65
+ import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
59
66
  import { buildProfileFromProtocolV2, buildProtocolV2FeaturesPayload } from '../src/deviceProfile';
60
67
  import {
61
68
  getDeviceType,
@@ -80,6 +87,175 @@ jest.mock('../src/data/config', () => ({
80
87
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
81
88
  }));
82
89
 
90
+ describe('DeviceUploadWallpaper', () => {
91
+ test('encodes, uploads and applies a Pro2 wallpaper', async () => {
92
+ const rgba = new Uint8Array(604 * 1024 * 4).fill(255);
93
+ const typedCall = jest.fn().mockImplementation((request, _response, params) => {
94
+ if (request === 'FilesystemDirMake') return { message: { message: 'directory ready' } };
95
+ if (request === 'FilesystemFileWrite') {
96
+ const file = params.file as { data: Uint8Array; offset: number };
97
+ return { message: { processed_byte: file.offset + file.data.byteLength } };
98
+ }
99
+ if (request === 'DeviceSettingsSet') {
100
+ return { message: { message: 'wallpaper applied' } };
101
+ }
102
+ throw new Error(`Unexpected request: ${request}`);
103
+ });
104
+ const method = new DeviceUploadWallpaper({
105
+ id: 1,
106
+ payload: { method: 'deviceUploadWallpaper', width: 604, height: 1024, rgba },
107
+ });
108
+ (method as any).device = stubDevice({ commands: { typedCall } });
109
+
110
+ method.init();
111
+ const result = await method.run();
112
+
113
+ expect(method.requireProtocolV2).toBe(true);
114
+ expect(method.unlockPolicy).toBe('retry-on-locked');
115
+ expect(typedCall).toHaveBeenNthCalledWith(1, 'FilesystemDirMake', 'Success', {
116
+ path: 'vol0:/wallpapers/user',
117
+ });
118
+ const fileWriteCall = typedCall.mock.calls.find(call => call[0] === 'FilesystemFileWrite');
119
+ expect(fileWriteCall?.[2]).toMatchObject({
120
+ file: { path: expect.stringMatching(/^vol0:\/wallpapers\/user\/wallpaper-[a-f0-9]+\.bin$/) },
121
+ overwrite: true,
122
+ append: false,
123
+ });
124
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSettingsSet', 'Success', {
125
+ settings: { wallpaper_path: result.path },
126
+ });
127
+ expect(typedCall.mock.calls.some(call => call[0] === 'SetWallpaper')).toBe(false);
128
+ expect(result).toMatchObject({ colorFormat: 'RGB565', message: 'wallpaper applied' });
129
+ });
130
+
131
+ test('文件上传失败时不修改 wallpaper_path', async () => {
132
+ const typedCall = jest.fn().mockImplementation(request => {
133
+ if (request === 'FilesystemDirMake') return { message: {} };
134
+ if (request === 'FilesystemFileWrite') throw new Error('write failed');
135
+ return { message: {} };
136
+ });
137
+ const method = new DeviceUploadWallpaper({
138
+ id: 1,
139
+ payload: {
140
+ method: 'deviceUploadWallpaper',
141
+ width: 604,
142
+ height: 1024,
143
+ rgba: new Uint8Array(604 * 1024 * 4),
144
+ },
145
+ });
146
+ (method as any).device = stubDevice({ commands: { typedCall } });
147
+ method.init();
148
+
149
+ await expect(method.run()).rejects.toThrow('write failed');
150
+ expect(typedCall.mock.calls.some(call => call[0] === 'DeviceSettingsSet')).toBe(false);
151
+ });
152
+
153
+ test('rejects unsafe filenames before device communication', () => {
154
+ const method = new DeviceUploadWallpaper({
155
+ id: 1,
156
+ payload: {
157
+ method: 'deviceUploadWallpaper',
158
+ width: 604,
159
+ height: 1024,
160
+ rgba: new Uint8Array(604 * 1024 * 4),
161
+ fileName: '../wallpaper.bin',
162
+ },
163
+ });
164
+
165
+ expect(() => method.init()).toThrow('fileName');
166
+ });
167
+ });
168
+
169
+ describe('UploadPortfolio', () => {
170
+ test('stages the complete package before applying PortfolioUpdate', async () => {
171
+ const packageBytes = new Uint8Array([1, 2, 3]);
172
+ const typedCall = jest
173
+ .fn()
174
+ .mockResolvedValueOnce({ message: { processed_byte: 3 } })
175
+ .mockResolvedValueOnce({ message: { message: 'Portfolio updated' } });
176
+ const method = new UploadPortfolio({
177
+ id: 1,
178
+ payload: {
179
+ method: 'uploadPortfolio',
180
+ packageBytes,
181
+ },
182
+ });
183
+ (method as any).device = stubDevice({ commands: { typedCall } });
184
+ method.postMessage = jest.fn();
185
+
186
+ method.init();
187
+ const result = await method.run();
188
+
189
+ expect(typedCall).toHaveBeenNthCalledWith(
190
+ 1,
191
+ 'FilesystemFileWrite',
192
+ 'FilesystemFile',
193
+ {
194
+ file: {
195
+ path: 'vol1:/portfolio/portfolio.pfol.pending',
196
+ offset: 0,
197
+ total_size: 3,
198
+ data: packageBytes,
199
+ },
200
+ overwrite: true,
201
+ append: false,
202
+ ui_percentage: 100,
203
+ },
204
+ { timeoutMs: undefined }
205
+ );
206
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'PortfolioUpdate', 'Success', {});
207
+ expect(result).toMatchObject({
208
+ path: 'vol1:/portfolio/portfolio.pfol.pending',
209
+ processed_byte: 3,
210
+ portfolioUpdated: true,
211
+ });
212
+ });
213
+
214
+ test('does not apply PortfolioUpdate when staging fails', async () => {
215
+ const typedCall = jest.fn().mockRejectedValue(new Error('write failed'));
216
+ const method = new UploadPortfolio({
217
+ id: 1,
218
+ payload: {
219
+ method: 'uploadPortfolio',
220
+ packageBytes: new Uint8Array([1]),
221
+ },
222
+ });
223
+ (method as any).device = stubDevice({ commands: { typedCall } });
224
+
225
+ method.init();
226
+
227
+ await expect(method.run()).rejects.toThrow('write failed');
228
+ expect(typedCall).toHaveBeenCalledTimes(1);
229
+ });
230
+
231
+ test('stops after the acknowledged chunk when the operation is aborted', async () => {
232
+ const packageBytes = new Uint8Array(4001);
233
+ const abortController = new AbortController();
234
+ const typedCall = jest.fn().mockImplementationOnce(() => {
235
+ abortController.abort();
236
+ return Promise.resolve({ message: { processed_byte: 2048 } });
237
+ });
238
+ const method = new UploadPortfolio({
239
+ id: 1,
240
+ payload: {
241
+ method: 'uploadPortfolio',
242
+ packageBytes,
243
+ operationId: 'portfolio-1',
244
+ },
245
+ });
246
+ method.abortSignal = abortController.signal;
247
+ (method as any).device = stubDevice({ commands: { typedCall } });
248
+
249
+ method.init();
250
+
251
+ await expect(method.run()).rejects.toMatchObject({
252
+ errorCode: HardwareErrorCode.CallQueueActionCancelled,
253
+ });
254
+ expect(typedCall).toHaveBeenCalledTimes(1);
255
+ expect(typedCall).not.toHaveBeenCalledWith('PortfolioUpdate', 'Success', {});
256
+ });
257
+ });
258
+
83
259
  const descriptor = {
84
260
  id: 'ble-id',
85
261
  path: 'usb-path',
@@ -151,10 +327,16 @@ describe('Protocol V2 feature adapter', () => {
151
327
  serial_no: 'PR2SERIAL',
152
328
  },
153
329
  fw: {
154
- application_data: {
330
+ romloader: {
155
331
  version: '0.1.0',
332
+ build_id: 'rom-build',
156
333
  hash: [1, 2, 255],
157
334
  },
335
+ application_data: {
336
+ version: '9.8.7',
337
+ build_id: 'app-data-build',
338
+ hash: [9, 8, 7],
339
+ },
158
340
  bootloader: {
159
341
  version: '0.2.0',
160
342
  build_id: 'boot-build',
@@ -209,6 +391,8 @@ describe('Protocol V2 feature adapter', () => {
209
391
  expect(features.bootloaderVersion).toBe('0.2.0');
210
392
  expect(features.verify?.bootloaderBuildId).toBe('boot-build');
211
393
  expect(features.verify?.bootloaderHash).toBe('0a0b');
394
+ expect(features.boardVersion).toBe('0.1.0');
395
+ expect(features.verify?.boardBuildId).toBe('rom-build');
212
396
  expect(features.verify?.boardHash).toBe('0102ff');
213
397
  expect(features.bleName).toBe('Pro2 BLE');
214
398
  expect(features.bleVersion).toBe('4.5.6');
@@ -248,6 +432,32 @@ describe('Protocol V2 feature adapter', () => {
248
432
  expect(profile.status.bootloaderMode).toBe(true);
249
433
  });
250
434
 
435
+ test('marks current romloader-shaped Protocol V2 DeviceInfo as romloader mode', () => {
436
+ const deviceInfo = {
437
+ protocol_version: 1,
438
+ hw: {
439
+ serial_no: 'PR2ROM',
440
+ },
441
+ fw: {
442
+ romloader: {
443
+ version: '1.0.0',
444
+ },
445
+ bootloader: {
446
+ version: '2.0.0',
447
+ },
448
+ },
449
+ };
450
+ const features = normalizeProtocolV2Features(descriptor as any, deviceInfo);
451
+ const profile = buildProfileFromProtocolV2({ deviceInfo });
452
+
453
+ expect(features.mode).toBe('romloader');
454
+ expect(features.bootloaderMode).toBe(false);
455
+ expect(features.boardVersion).toBe('1.0.0');
456
+ expect(profile.status.mode).toBe('romloader');
457
+ expect(profile.status.bootloaderMode).toBe(false);
458
+ expect(profile.versions.board).toBe('1.0.0');
459
+ });
460
+
251
461
  test('uses DeviceSessionGet for Protocol V2 passphrase sessions', async () => {
252
462
  const features = normalizeProtocolV2Features(descriptor as any);
253
463
  features.firmwareVersion = '1.2.3';
@@ -272,7 +482,6 @@ describe('Protocol V2 feature adapter', () => {
272
482
  await expect(
273
483
  getPassphraseState(device, {
274
484
  expectPassphraseState: 'state-2',
275
- allowCreateAttachPin: true,
276
485
  })
277
486
  ).rejects.toEqual(
278
487
  expect.objectContaining({
@@ -308,7 +517,33 @@ describe('Protocol V2 feature adapter', () => {
308
517
  expect(updateProtocolV2Features).not.toHaveBeenCalled();
309
518
  });
310
519
 
311
- test('deviceSessionGet maps sessionId and does not mutate wallet cache', async () => {
520
+ test('deviceGetOnboardingStatus returns the real Protocol V2 onboarding stage', async () => {
521
+ const typedCall = jest.fn().mockResolvedValue({
522
+ type: 'DevOnboardingStatus',
523
+ message: { stage: 2, status_code: 3, detail_code: 4 },
524
+ });
525
+ const method = new DeviceGetOnboardingStatus({
526
+ payload: {
527
+ method: 'deviceGetOnboardingStatus',
528
+ connectId: 'connect-id',
529
+ },
530
+ });
531
+ method.init();
532
+ method.device = stubDevice({
533
+ originalDescriptor: { ...descriptor, protocolType: 'V2' },
534
+ commands: { typedCall },
535
+ }) as any;
536
+
537
+ await expect(method.run()).resolves.toEqual({
538
+ stage: 2,
539
+ status_code: 3,
540
+ detail_code: 4,
541
+ });
542
+ expect(typedCall).toHaveBeenCalledWith('DevGetOnboardingStatus', 'DevOnboardingStatus', {});
543
+ expect(method.requireProtocolV2).toBe(true);
544
+ });
545
+
546
+ test('deviceSessionGet sends an empty request and does not mutate wallet cache', async () => {
312
547
  const typedCall = jest.fn().mockResolvedValue({
313
548
  type: 'DeviceSession',
314
549
  message: { session_id: 'new-session', btc_test_address: 'state-a' },
@@ -318,7 +553,6 @@ describe('Protocol V2 feature adapter', () => {
318
553
  payload: {
319
554
  method: 'deviceSessionGet',
320
555
  connectId: 'connect-id',
321
- sessionId: 'cached-session',
322
556
  },
323
557
  });
324
558
  method.init();
@@ -332,9 +566,7 @@ describe('Protocol V2 feature adapter', () => {
332
566
  session_id: 'new-session',
333
567
  btc_test_address: 'state-a',
334
568
  });
335
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
336
- session_id: 'cached-session',
337
- });
569
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
338
570
  expect(updateInternalState).not.toHaveBeenCalled();
339
571
  });
340
572
 
@@ -343,7 +575,8 @@ describe('Protocol V2 feature adapter', () => {
343
575
  const api = createCoreApi(call as any);
344
576
 
345
577
  await api.deviceStatusGet('connect-id', { retryCount: 1 });
346
- await api.deviceSessionGet('connect-id', { sessionId: 'cached-session' });
578
+ await api.deviceGetOnboardingStatus('connect-id', { retryCount: 1 });
579
+ await api.deviceSessionGet('connect-id', { retryCount: 1 });
347
580
 
348
581
  expect(call).toHaveBeenNthCalledWith(1, {
349
582
  method: 'deviceStatusGet',
@@ -351,9 +584,14 @@ describe('Protocol V2 feature adapter', () => {
351
584
  retryCount: 1,
352
585
  });
353
586
  expect(call).toHaveBeenNthCalledWith(2, {
587
+ method: 'deviceGetOnboardingStatus',
588
+ connectId: 'connect-id',
589
+ retryCount: 1,
590
+ });
591
+ expect(call).toHaveBeenNthCalledWith(3, {
354
592
  method: 'deviceSessionGet',
355
593
  connectId: 'connect-id',
356
- sessionId: 'cached-session',
594
+ retryCount: 1,
357
595
  });
358
596
  });
359
597
 
@@ -415,7 +653,7 @@ describe('Protocol V2 feature adapter', () => {
415
653
  expect(device.getInternalState()).toBeUndefined();
416
654
  });
417
655
 
418
- test('clears the selected Pro2 cache entry after invalid session rejection', async () => {
656
+ test('retries without the selected Pro2 cache entry after invalid session rejection', async () => {
419
657
  const device = Device.fromDescriptor({
420
658
  id: 'cache-device-3',
421
659
  path: 'cache-path-3',
@@ -433,12 +671,49 @@ describe('Protocol V2 feature adapter', () => {
433
671
  );
434
672
  device.passphraseState = 'state-a';
435
673
  preloadSessionCache('stable-device-3', 'state-a', 'session-a');
436
- (device as any).commands = {
437
- typedCall: jest.fn().mockRejectedValue(new Error('Failure_InvalidSession,no error message')),
438
- };
674
+ const typedCall = jest
675
+ .fn()
676
+ .mockRejectedValueOnce(new Error('Failure_ProcessError,Failure_InvalidSession'))
677
+ .mockResolvedValueOnce({
678
+ type: 'DeviceSession',
679
+ message: { session_id: 'session-b', btc_test_address: 'state-a' },
680
+ });
681
+ (device as any).commands = { typedCall };
682
+
683
+ await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
684
+ passphraseState: 'state-a',
685
+ newSession: 'session-b',
686
+ });
687
+ expect(typedCall.mock.calls).toEqual([
688
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
689
+ ['DeviceSessionGet', 'DeviceSession', {}],
690
+ ]);
691
+ expect(device.getInternalState()).toBe('session-b');
692
+ });
693
+
694
+ test('does not retry an invalid Pro2 session when no cached session was sent', async () => {
695
+ const device = Device.fromDescriptor({
696
+ id: 'cache-device-no-session',
697
+ path: 'cache-path-no-session',
698
+ protocolType: 'V2',
699
+ } as any);
700
+ (device as any).features = normalizeProtocolV2Features(
701
+ { ...descriptor, protocolType: 'V2' } as any,
702
+ {
703
+ status: {
704
+ device_id: 'stable-device-no-session',
705
+ unlocked: true,
706
+ passphrase_enabled: true,
707
+ },
708
+ }
709
+ );
710
+ const typedCall = jest
711
+ .fn()
712
+ .mockRejectedValue(new Error('Failure_ProcessError,Failure_InvalidSession'));
713
+ (device as any).commands = { typedCall };
439
714
 
440
715
  await expect(getProtocolV2WalletSession(device)).rejects.toThrow('Failure_InvalidSession');
441
- expect(device.getInternalState()).toBeUndefined();
716
+ expect(typedCall.mock.calls).toEqual([['DeviceSessionGet', 'DeviceSession', {}]]);
442
717
  });
443
718
 
444
719
  test('rejects a mismatched Pro2 wallet state and clears the selected cache entry', async () => {
@@ -547,7 +822,7 @@ describe('Protocol V2 feature adapter', () => {
547
822
  expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
548
823
  });
549
824
 
550
- test('returns unified GetPassphraseState object payload for existing Pro devices', async () => {
825
+ test('returns passphrase state string for existing Pro devices', async () => {
551
826
  const features = {
552
827
  deviceId: 'pro-device-id',
553
828
  deviceType: 'pro',
@@ -580,12 +855,7 @@ describe('Protocol V2 feature adapter', () => {
580
855
  getCurrentPassphraseProtection: () => true,
581
856
  }) as any;
582
857
 
583
- await expect(method.run()).resolves.toEqual({
584
- passphraseState: 'state-pro',
585
- sessionId: 'session-pro',
586
- unlockedAttachPin: false,
587
- passphraseProtection: true,
588
- });
858
+ await expect(method.run()).resolves.toBe('state-pro');
589
859
  expect(updateInternalState).toHaveBeenCalledWith(
590
860
  true,
591
861
  'state-pro',
@@ -595,7 +865,7 @@ describe('Protocol V2 feature adapter', () => {
595
865
  );
596
866
  });
597
867
 
598
- test('uses features for GetPassphraseState response metadata', async () => {
868
+ test('returns passphrase state string for Pro2 devices', async () => {
599
869
  const features = {
600
870
  deviceId: null,
601
871
  deviceType: 'pro2',
@@ -630,12 +900,7 @@ describe('Protocol V2 feature adapter', () => {
630
900
  getCurrentPassphraseProtection: () => true,
631
901
  }) as any;
632
902
 
633
- await expect(method.run()).resolves.toEqual({
634
- passphraseState: 'state-pro2',
635
- sessionId: 'session-pro2',
636
- unlockedAttachPin: true,
637
- passphraseProtection: true,
638
- });
903
+ await expect(method.run()).resolves.toBe('state-pro2');
639
904
  expect(getFeatures).not.toHaveBeenCalled();
640
905
  });
641
906
 
@@ -673,12 +938,7 @@ describe('Protocol V2 feature adapter', () => {
673
938
  getCurrentPassphraseProtection: () => true,
674
939
  }) as any;
675
940
 
676
- await expect(method.run()).resolves.toEqual({
677
- passphraseState: 'state-pro2-new',
678
- sessionId: undefined,
679
- unlockedAttachPin: false,
680
- passphraseProtection: true,
681
- });
941
+ await expect(method.run()).resolves.toBe('state-pro2-new');
682
942
  expect(clearInternalState).toHaveBeenCalledTimes(1);
683
943
  expect(updateInternalState).toHaveBeenCalledWith(
684
944
  true,
@@ -1578,16 +1838,10 @@ describe('Protocol V2 feature adapter', () => {
1578
1838
  if (requestType === 'DeviceSessionAskPin') {
1579
1839
  return {
1580
1840
  type: 'DeviceSessionPinResult',
1581
- message: { unlocked: false, passphrase_protection: false },
1582
- };
1583
- }
1584
- if (requestType === 'DeviceStatusGet') {
1585
- return {
1586
- type: 'DeviceStatus',
1587
1841
  message: {
1588
- device_id: 'PRO2-DEVICE-ID',
1589
1842
  unlocked: true,
1590
- passphrase_enabled: true,
1843
+ unlocked_attach_pin: true,
1844
+ passphrase_protection: true,
1591
1845
  },
1592
1846
  };
1593
1847
  }
@@ -1608,22 +1862,24 @@ describe('Protocol V2 feature adapter', () => {
1608
1862
 
1609
1863
  const features = await device.unlockDevice();
1610
1864
 
1611
- expect(typedCall.mock.calls).toEqual([
1612
- ['DeviceSessionAskPin', 'DeviceSessionPinResult'],
1613
- ['DeviceStatusGet', 'DeviceStatus', {}],
1614
- ]);
1865
+ expect(typedCall.mock.calls).toEqual([['DeviceSessionAskPin', 'DeviceSessionPinResult']]);
1615
1866
  expect(typedCall).not.toHaveBeenCalledWith('GetAddress', 'Address', expect.anything());
1616
1867
  expect(typedCall).not.toHaveBeenCalledWith('GetFeatures', 'Features', {});
1617
1868
  expect(features).toMatchObject({
1618
1869
  deviceType: 'pro2',
1619
- deviceId: 'PRO2-DEVICE-ID',
1620
1870
  firmwareVersion: '1.2.3',
1621
1871
  unlocked: true,
1872
+ unlockedAttachPin: true,
1622
1873
  passphraseProtection: true,
1623
1874
  });
1875
+ expect(features.raw?.protocolV2DeviceInfo?.status).toMatchObject({
1876
+ unlocked: true,
1877
+ unlocked_by_attach_to_pin: true,
1878
+ passphrase_enabled: true,
1879
+ });
1624
1880
  });
1625
1881
 
1626
- test('syncs Protocol V2 features passphrase state from DeviceStatusGet after unlock', async () => {
1882
+ test('syncs Protocol V2 features passphrase state from DeviceSessionPinResult after unlock', async () => {
1627
1883
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1628
1884
  (device as any).features = normalizeProtocolV2Features(
1629
1885
  { ...descriptor, protocolType: 'V2' } as any,
@@ -1637,20 +1893,10 @@ describe('Protocol V2 feature adapter', () => {
1637
1893
  if (requestType === 'DeviceSessionAskPin') {
1638
1894
  return {
1639
1895
  type: 'DeviceSessionPinResult',
1640
- message: {
1641
- unlocked: false,
1642
- unlocked_attach_pin: false,
1643
- passphrase_protection: false,
1644
- },
1645
- };
1646
- }
1647
- if (requestType === 'DeviceStatusGet') {
1648
- return {
1649
- type: 'DeviceStatus',
1650
1896
  message: {
1651
1897
  unlocked: true,
1652
- unlocked_by_attach_to_pin: true,
1653
- passphrase_enabled: true,
1898
+ unlocked_attach_pin: true,
1899
+ passphrase_protection: true,
1654
1900
  },
1655
1901
  };
1656
1902
  }
@@ -1660,7 +1906,9 @@ describe('Protocol V2 feature adapter', () => {
1660
1906
 
1661
1907
  await device.unlockDevice();
1662
1908
 
1909
+ expect(typedCall.mock.calls).toEqual([['DeviceSessionAskPin', 'DeviceSessionPinResult']]);
1663
1910
  expect((device as any).profile).toBeUndefined();
1911
+ expect(device.features?.unlocked).toBe(true);
1664
1912
  expect(device.features?.passphraseProtection).toBe(true);
1665
1913
  expect(device.features?.unlockedAttachPin).toBe(true);
1666
1914
  });
@@ -2818,6 +3066,16 @@ describe('Protocol V2 firmware update targets', () => {
2818
3066
  payload: {
2819
3067
  method: 'firmwareUpdateV4',
2820
3068
  platform: 'web',
3069
+ targetsToUpdate: [
3070
+ 'boot',
3071
+ 'app_v1',
3072
+ 'app_v2',
3073
+ 'coprocessor',
3074
+ 'se01',
3075
+ 'se02',
3076
+ 'se03',
3077
+ 'se04',
3078
+ ],
2821
3079
  },
2822
3080
  });
2823
3081
  method.init();
@@ -2931,6 +3189,7 @@ describe('Protocol V2 firmware update targets', () => {
2931
3189
  payload: {
2932
3190
  method: 'firmwareUpdateV4',
2933
3191
  platform: 'web',
3192
+ targetsToUpdate: ['resource'],
2934
3193
  },
2935
3194
  });
2936
3195
  method.init();
@@ -2980,7 +3239,7 @@ describe('Protocol V2 firmware update targets', () => {
2980
3239
  getFirmwareLatestReleaseSpy.mockRestore();
2981
3240
  });
2982
3241
 
2983
- test('skips Pro2 firmware components when configured versions are already installed', async () => {
3242
+ test('does not download Pro2 firmware components that App did not select', async () => {
2984
3243
  const method = new FirmwareUpdateV4({
2985
3244
  id: 1,
2986
3245
  payload: {
@@ -3671,6 +3930,97 @@ describe('Protocol V2 reboot methods', () => {
3671
3930
  });
3672
3931
  });
3673
3932
 
3933
+ describe('Protocol V2 protected method execution', () => {
3934
+ const deviceLockedError = () =>
3935
+ Object.assign(new Error('Device locked'), {
3936
+ errorCode: HardwareErrorCode.DeviceLocked,
3937
+ });
3938
+
3939
+ test('unlocks and retries an opted-in Protocol V2 method once', async () => {
3940
+ const calls: string[] = [];
3941
+ const method = {
3942
+ unlockPolicy: 'retry-on-locked',
3943
+ run: jest
3944
+ .fn()
3945
+ .mockImplementationOnce(() => {
3946
+ calls.push('run-1');
3947
+ return Promise.reject(deviceLockedError());
3948
+ })
3949
+ .mockImplementationOnce(() => {
3950
+ calls.push('run-2');
3951
+ return Promise.resolve({ message: 'ok' });
3952
+ }),
3953
+ };
3954
+ const device = {
3955
+ isProtocolV2: () => true,
3956
+ unlockDevice: jest.fn(() => {
3957
+ calls.push('unlock');
3958
+ return Promise.resolve();
3959
+ }),
3960
+ };
3961
+
3962
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
3963
+ message: 'ok',
3964
+ });
3965
+ expect(calls).toEqual(['run-1', 'unlock', 'run-2']);
3966
+ });
3967
+
3968
+ test('does not unlock a Protocol V1 device or a method without the policy', async () => {
3969
+ for (const [isProtocolV2, unlockPolicy] of [
3970
+ [false, 'retry-on-locked'],
3971
+ [true, 'none'],
3972
+ ] as const) {
3973
+ const error = deviceLockedError();
3974
+ const method = {
3975
+ unlockPolicy,
3976
+ run: jest.fn().mockRejectedValue(error),
3977
+ };
3978
+ const device = {
3979
+ isProtocolV2: () => isProtocolV2,
3980
+ unlockDevice: jest.fn(),
3981
+ };
3982
+
3983
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
3984
+ expect(device.unlockDevice).not.toHaveBeenCalled();
3985
+ expect(method.run).toHaveBeenCalledTimes(1);
3986
+ }
3987
+ });
3988
+
3989
+ test('does not retry when unlock fails or when the retry is still locked', async () => {
3990
+ const initialError = deviceLockedError();
3991
+ const unlockError = new Error('PIN cancelled');
3992
+ const unlockFailMethod = {
3993
+ unlockPolicy: 'retry-on-locked',
3994
+ run: jest.fn().mockRejectedValue(initialError),
3995
+ };
3996
+ const unlockFailDevice = {
3997
+ isProtocolV2: () => true,
3998
+ unlockDevice: jest.fn().mockRejectedValue(unlockError),
3999
+ };
4000
+
4001
+ await expect(
4002
+ runMethodWithUnlockRetry(unlockFailMethod as any, unlockFailDevice as any)
4003
+ ).rejects.toBe(unlockError);
4004
+ expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
4005
+
4006
+ const retryError = deviceLockedError();
4007
+ const retryFailMethod = {
4008
+ unlockPolicy: 'retry-on-locked',
4009
+ run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
4010
+ };
4011
+ const retryFailDevice = {
4012
+ isProtocolV2: () => true,
4013
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
4014
+ };
4015
+
4016
+ await expect(
4017
+ runMethodWithUnlockRetry(retryFailMethod as any, retryFailDevice as any)
4018
+ ).rejects.toBe(retryError);
4019
+ expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
4020
+ expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
4021
+ });
4022
+ });
4023
+
3674
4024
  describe('Protocol V2 current low-level methods', () => {
3675
4025
  test('sends ProtocolInfoRequest from protocolInfoRequest', async () => {
3676
4026
  const typedCall = jest.fn().mockResolvedValue({ message: { version: 1 } });
@@ -3724,6 +4074,106 @@ describe('Protocol V2 current low-level methods', () => {
3724
4074
  expect(typedCall).toHaveBeenLastCalledWith('DeviceFactoryInfoGet', 'DeviceFactoryInfo', {});
3725
4075
  });
3726
4076
 
4077
+ test('gets Protocol V2 device settings', async () => {
4078
+ const typedCall = jest.fn().mockResolvedValue({ message: { brightness: 80 } });
4079
+ const method = new DeviceSettingsGet({
4080
+ id: 1,
4081
+ payload: { method: 'deviceSettingsGet' },
4082
+ });
4083
+ method.init();
4084
+ (method as any).device = stubDevice({ commands: { typedCall } });
4085
+
4086
+ await expect(method.run()).resolves.toEqual({ brightness: 80 });
4087
+ expect(typedCall).toHaveBeenCalledWith('DeviceSettingsGet', 'DeviceSettings', {});
4088
+ });
4089
+
4090
+ test('sets Protocol V2 device settings without passphrase fields', async () => {
4091
+ const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
4092
+ const method = new DeviceSettingsSet({
4093
+ id: 1,
4094
+ payload: {
4095
+ method: 'deviceSettingsSet',
4096
+ settings: {
4097
+ label: 'My Pro 2',
4098
+ brightness: 80,
4099
+ airgap_mode: true,
4100
+ passphrase_enable: true,
4101
+ },
4102
+ },
4103
+ });
4104
+ method.init();
4105
+ (method as any).device = stubDevice({ commands: { typedCall } });
4106
+
4107
+ await method.run();
4108
+
4109
+ expect(typedCall).toHaveBeenCalledWith('DeviceSettingsSet', 'Success', {
4110
+ settings: {
4111
+ label: 'My Pro 2',
4112
+ brightness: 80,
4113
+ },
4114
+ });
4115
+ });
4116
+
4117
+ test('opens non-passphrase Protocol V2 settings pages', async () => {
4118
+ const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
4119
+ const method = new DeviceSettingsPageShow({
4120
+ id: 1,
4121
+ payload: {
4122
+ method: 'deviceSettingsPageShow',
4123
+ page: 'DeviceAirgap',
4124
+ fieldName: 'airgap_mode',
4125
+ },
4126
+ });
4127
+ method.init();
4128
+ (method as any).device = stubDevice({ commands: { typedCall } });
4129
+
4130
+ await method.run();
4131
+
4132
+ expect(typedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
4133
+ page: 3,
4134
+ field_name: 'airgap_mode',
4135
+ });
4136
+ });
4137
+
4138
+ test('opens the Protocol V2 passphrase settings page', async () => {
4139
+ const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
4140
+ const method = new DeviceSettingsPageShow({
4141
+ id: 1,
4142
+ payload: {
4143
+ method: 'deviceSettingsPageShow',
4144
+ page: 'DevicePassphrase',
4145
+ },
4146
+ });
4147
+ method.init();
4148
+ (method as any).device = stubDevice({ commands: { typedCall } });
4149
+
4150
+ await method.run();
4151
+
4152
+ expect(typedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
4153
+ page: 2,
4154
+ field_name: undefined,
4155
+ });
4156
+ });
4157
+
4158
+ test('marks Protocol V2 settings methods for unlock-on-locked retry', () => {
4159
+ const methods = [
4160
+ new DeviceSettingsGet({ id: 1, payload: { method: 'deviceSettingsGet' } }),
4161
+ new DeviceSettingsSet({
4162
+ id: 2,
4163
+ payload: { method: 'deviceSettingsSet', settings: { brightness: 80 } },
4164
+ }),
4165
+ new DeviceSettingsPageShow({
4166
+ id: 3,
4167
+ payload: { method: 'deviceSettingsPageShow', page: 'DevicePassphrase' },
4168
+ }),
4169
+ ];
4170
+
4171
+ methods.forEach(method => {
4172
+ method.init();
4173
+ expect(method.unlockPolicy).toBe('retry-on-locked');
4174
+ });
4175
+ });
4176
+
3727
4177
  test('sends FilesystemPermissionFix from filesystemPermissionFix', async () => {
3728
4178
  const typedCall = jest.fn().mockResolvedValue({ message: {} });
3729
4179
  const method = new FilesystemPermissionFix({