@onekeyfe/hd-core 1.2.0-alpha.107 → 1.2.0-alpha.108

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 (57) hide show
  1. package/__tests__/DeviceCommands.test.ts +140 -26
  2. package/__tests__/base64Data.test.ts +70 -0
  3. package/__tests__/device-lifecycle-events.test.ts +113 -2
  4. package/__tests__/device-pool-state.test.ts +25 -0
  5. package/__tests__/deviceSettings.test.ts +0 -1
  6. package/__tests__/deviceUploadNft.test.ts +33 -4
  7. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +178 -0
  8. package/__tests__/get-device-state.test.ts +52 -13
  9. package/__tests__/logBlockEvent.test.ts +13 -1
  10. package/__tests__/protocol-v2.test.ts +762 -124
  11. package/__tests__/refresh-device-state.test.ts +3 -1
  12. package/__tests__/resourceBase64Boundary.test.ts +48 -0
  13. package/__tests__/ton-sign-message.test.ts +84 -0
  14. package/dist/api/FirmwareUpdateV4.d.ts +10 -3
  15. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  16. package/dist/api/UploadPortfolio.d.ts +1 -1
  17. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  18. package/dist/api/helpers/base64Data.d.ts +16 -0
  19. package/dist/api/helpers/base64Data.d.ts.map +1 -0
  20. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +2 -3
  21. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  22. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts +2 -3
  23. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  24. package/dist/api/ton/TonSignMessage.d.ts.map +1 -1
  25. package/dist/core/index.d.ts.map +1 -1
  26. package/dist/device/Device.d.ts +7 -2
  27. package/dist/device/Device.d.ts.map +1 -1
  28. package/dist/device/DeviceCommands.d.ts.map +1 -1
  29. package/dist/device/DevicePool.d.ts.map +1 -1
  30. package/dist/events/logBlockEvent.d.ts.map +1 -1
  31. package/dist/index.d.ts +11 -14
  32. package/dist/index.js +455 -200
  33. package/dist/protocols/protocol-v2/features.d.ts +9 -1
  34. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  35. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  36. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  37. package/dist/types/api/protocolV2.d.ts +1 -1
  38. package/dist/types/api/protocolV2.d.ts.map +1 -1
  39. package/dist/utils/pro2Nft.d.ts +7 -0
  40. package/dist/utils/pro2Nft.d.ts.map +1 -1
  41. package/package.json +6 -4
  42. package/src/api/FirmwareUpdateV4.ts +326 -174
  43. package/src/api/UploadPortfolio.ts +9 -2
  44. package/src/api/helpers/base64Data.ts +85 -0
  45. package/src/api/protocol-v2/DeviceUploadNft.ts +56 -8
  46. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +37 -18
  47. package/src/api/ton/TonSignMessage.ts +5 -3
  48. package/src/core/index.ts +6 -2
  49. package/src/device/Device.ts +53 -31
  50. package/src/device/DeviceCommands.ts +4 -14
  51. package/src/device/DevicePool.ts +6 -2
  52. package/src/events/logBlockEvent.ts +14 -1
  53. package/src/protocols/protocol-v2/features.ts +19 -2
  54. package/src/protocols/protocol-v2/index.ts +2 -0
  55. package/src/types/api/protocolV2.ts +1 -1
  56. package/src/utils/deviceSettings.ts +1 -1
  57. package/src/utils/pro2Nft.ts +31 -8
@@ -19,12 +19,12 @@ const createCommands = () => {
19
19
  };
20
20
 
21
21
  describe('DeviceCommands failure mapping', () => {
22
- it('logs passphrase call request and response without exposing the passphrase', async () => {
22
+ it('logs passphrase request and canonical response without exposing the passphrase', async () => {
23
23
  const commands = createCommands();
24
- const requestLog = getLogger(LoggerNames.DeviceCommands);
25
- const responseLog = getLogger(LoggerNames.Core);
26
- requestLog.messages.length = 0;
27
- responseLog.messages.length = 0;
24
+ const log = getLogger(LoggerNames.DeviceCommands);
25
+ const coreLog = getLogger(LoggerNames.Core);
26
+ log.messages.length = 0;
27
+ coreLog.messages.length = 0;
28
28
  commands.mainId = 'main-id';
29
29
  commands.transport = {
30
30
  call: jest.fn().mockResolvedValue({
@@ -36,13 +36,13 @@ describe('DeviceCommands failure mapping', () => {
36
36
  } as any;
37
37
 
38
38
  await expect(
39
- commands.call('DeviceSessionAskPassphrase', {
39
+ commands._commonCall('DeviceSessionAskPassphrase', {
40
40
  passphrase: 'hidden-wallet-secret',
41
41
  on_device: false,
42
42
  })
43
43
  ).resolves.toMatchObject({ type: 'Success' });
44
44
 
45
- expect(requestLog.messages.at(-1)?.message).toEqual([
45
+ expect(log.messages.at(-2)?.message).toEqual([
46
46
  '[DeviceCommands] [call] Sending',
47
47
  'DeviceSessionAskPassphrase',
48
48
  {
@@ -50,21 +50,69 @@ describe('DeviceCommands failure mapping', () => {
50
50
  on_device: false,
51
51
  },
52
52
  ]);
53
- expect(responseLog.messages.at(-1)?.message).toEqual([
54
- '[DeviceCommands] [call] Received',
55
- 'Success',
53
+ expect(log.messages.at(-1)?.message).toEqual([
54
+ '_filterCommonTypes: ',
55
+ {
56
+ request: 'DeviceSessionAskPassphrase',
57
+ response: {
58
+ type: 'Success',
59
+ message: {
60
+ message: 'Passphrase accepted',
61
+ },
62
+ },
63
+ },
64
+ ]);
65
+ expect(
66
+ coreLog.messages.some(entry => entry.message[0] === '[DeviceCommands] [call] Received')
67
+ ).toBe(false);
68
+ expect(JSON.stringify([...log.messages, ...coreLog.messages])).not.toContain(
69
+ 'hidden-wallet-secret'
70
+ );
71
+ });
72
+
73
+ it('redacts passphrases from transport errors logged through Core', async () => {
74
+ const commands = createCommands();
75
+ const log = getLogger(LoggerNames.DeviceCommands);
76
+ const coreLog = getLogger(LoggerNames.Core);
77
+ log.messages.length = 0;
78
+ coreLog.messages.length = 0;
79
+ const transportError = Object.assign(new Error('transport failed'), {
80
+ response: {
81
+ data: {
82
+ passphrase: 'hidden-wallet-secret',
83
+ },
84
+ },
85
+ });
86
+ commands.mainId = 'main-id';
87
+ commands.transport = {
88
+ call: jest.fn().mockRejectedValue(transportError),
89
+ } as any;
90
+
91
+ await expect(
92
+ commands._commonCall('DeviceSessionAskPassphrase', {
93
+ passphrase: 'hidden-wallet-secret',
94
+ on_device: false,
95
+ })
96
+ ).rejects.toBe(transportError);
97
+
98
+ expect(coreLog.messages.at(-1)?.message).toEqual([
99
+ '[DeviceCommands] [call] Received error',
56
100
  {
57
- message: 'Passphrase accepted',
101
+ request: 'DeviceSessionAskPassphrase',
102
+ errorCode: undefined,
103
+ response: {
104
+ passphrase: '[REDACTED]',
105
+ },
58
106
  },
59
107
  ]);
60
- expect(JSON.stringify([...requestLog.messages, ...responseLog.messages])).not.toContain(
108
+ expect(JSON.stringify([...log.messages, ...coreLog.messages])).not.toContain(
61
109
  'hidden-wallet-secret'
62
110
  );
63
111
  });
64
112
 
65
- it('logs DeviceStatus response fields without exposing the device ID', async () => {
113
+ it('logs canonical DeviceStatus response fields without exposing the device ID', async () => {
66
114
  const commands = createCommands();
67
- const log = getLogger(LoggerNames.Core);
115
+ const log = getLogger(LoggerNames.DeviceCommands);
68
116
  log.messages.length = 0;
69
117
  commands.mainId = 'main-id';
70
118
  commands.transport = {
@@ -82,22 +130,27 @@ describe('DeviceCommands failure mapping', () => {
82
130
  }),
83
131
  } as any;
84
132
 
85
- await expect(commands.call('DeviceStatusGet', {})).resolves.toMatchObject({
133
+ await expect(commands._commonCall('DeviceStatusGet', {})).resolves.toMatchObject({
86
134
  type: 'DeviceStatus',
87
135
  });
88
136
 
89
137
  const receivedLog = log.messages.at(-1)?.message;
90
138
  expect(receivedLog).toEqual([
91
- '[DeviceCommands] [call] Received',
92
- 'DeviceStatus',
139
+ '_filterCommonTypes: ',
93
140
  {
94
- device_id: '[REDACTED]',
95
- unlocked: true,
96
- init_states: true,
97
- backup_required: false,
98
- passphrase_enabled: true,
99
- attach_to_pin_enabled: false,
100
- unlocked_by_attach_to_pin: false,
141
+ request: 'DeviceStatusGet',
142
+ response: {
143
+ type: 'DeviceStatus',
144
+ message: {
145
+ device_id: '[REDACTED]',
146
+ unlocked: true,
147
+ init_states: true,
148
+ backup_required: false,
149
+ passphrase_enabled: true,
150
+ attach_to_pin_enabled: false,
151
+ unlocked_by_attach_to_pin: false,
152
+ },
153
+ },
101
154
  },
102
155
  ]);
103
156
  expect(JSON.stringify(receivedLog)).not.toContain('sensitive-device-id');
@@ -125,6 +178,59 @@ describe('DeviceCommands failure mapping', () => {
125
178
  expect(JSON.stringify(log.messages)).not.toContain('secret-wallet-address');
126
179
  });
127
180
 
181
+ it('logs the sanitized DeviceInfo response payload', async () => {
182
+ const commands = createCommands();
183
+ const log = getLogger(LoggerNames.DeviceCommands);
184
+ log.messages.length = 0;
185
+
186
+ await expect(
187
+ commands._filterCommonTypes(
188
+ {
189
+ type: 'DeviceInfo',
190
+ message: {
191
+ protocol_version: 2,
192
+ hw: {
193
+ Device_type: 7,
194
+ hardware_version: '1.0.0',
195
+ },
196
+ fw: {
197
+ application: {
198
+ version: '5.0.0',
199
+ build_id: '20260811',
200
+ hash: 'firmware-hash',
201
+ },
202
+ },
203
+ },
204
+ } as any,
205
+ 'DeviceInfoGet'
206
+ )
207
+ ).resolves.toMatchObject({ type: 'DeviceInfo' });
208
+
209
+ expect(log.messages.at(-1)?.message).toEqual([
210
+ '_filterCommonTypes: ',
211
+ {
212
+ request: 'DeviceInfoGet',
213
+ response: {
214
+ type: 'DeviceInfo',
215
+ message: {
216
+ protocol_version: 2,
217
+ hw: {
218
+ Device_type: 7,
219
+ hardware_version: '1.0.0',
220
+ },
221
+ fw: {
222
+ application: {
223
+ version: '5.0.0',
224
+ build_id: '20260811',
225
+ hash: 'firmware-hash',
226
+ },
227
+ },
228
+ },
229
+ },
230
+ },
231
+ ]);
232
+ });
233
+
128
234
  it('logs the sanitized DeviceFirmwareUpdateStatus response payload', async () => {
129
235
  const commands = createCommands();
130
236
  const log = getLogger(LoggerNames.DeviceCommands);
@@ -561,17 +667,22 @@ describe('DeviceCommands failure mapping', () => {
561
667
  });
562
668
 
563
669
  describe('DeviceCommands Protocol V2 interactive response compatibility', () => {
564
- it('still auto-acks ButtonRequest when no hardware UI listener is registered', async () => {
670
+ it('auto-acks Pro2 ButtonRequest and registers Cancel instead of Initialize', async () => {
565
671
  const commands = createCommands();
566
672
  const emit = jest.fn();
673
+ const cancelDevice = jest.spyOn(commands, 'cancelDevice').mockResolvedValue(undefined);
674
+ const cancelDeviceOnOneKeyDevice = jest
675
+ .spyOn(commands, 'cancelDeviceOnOneKeyDevice')
676
+ .mockResolvedValue(undefined);
567
677
  const commonCall = jest.fn().mockResolvedValue({
568
678
  type: 'Success',
569
679
  message: {},
570
680
  });
681
+ const setCancelableAction = jest.fn();
571
682
  commands.device = {
572
683
  ...commands.device,
573
684
  getCurrentDeviceType: jest.fn(() => 'pro2'),
574
- setCancelableAction: jest.fn(),
685
+ setCancelableAction,
575
686
  emit,
576
687
  listenerCount: jest.fn(() => 0),
577
688
  } as any;
@@ -593,6 +704,9 @@ describe('DeviceCommands Protocol V2 interactive response compatibility', () =>
593
704
  expect.objectContaining({ code: 'ButtonRequest_PinEntry' })
594
705
  );
595
706
  expect(commonCall).toHaveBeenCalledWith('ButtonAck', {}, undefined);
707
+ await setCancelableAction.mock.calls[0][0]();
708
+ expect(cancelDevice).toHaveBeenCalledTimes(1);
709
+ expect(cancelDeviceOnOneKeyDevice).not.toHaveBeenCalled();
596
710
  });
597
711
 
598
712
  it('rejects PassphraseRequest when the Pro2 UI listener is not registered', async () => {
@@ -0,0 +1,70 @@
1
+ import { Buffer } from 'buffer';
2
+
3
+ import { encode as encodeJpeg } from 'jpeg-js';
4
+
5
+ import { decodeCanonicalBase64, decodeJpegBase64ToRgba } from '../src/api/helpers/base64Data';
6
+
7
+ const createJpegBase64 = (width: number, height: number) => {
8
+ const rgba = new Uint8Array(width * height * 4).fill(0xff);
9
+ return encodeJpeg({ width, height, data: rgba }, 80).data.toString('base64');
10
+ };
11
+
12
+ describe('Base64 resource data', () => {
13
+ test('decodes canonical Base64 to a detached Uint8Array', () => {
14
+ const decoded = decodeCanonicalBase64({
15
+ value: 'AQID',
16
+ parameterName: 'packageBase64',
17
+ maxBytes: 3,
18
+ });
19
+
20
+ expect(decoded).toBeInstanceOf(Uint8Array);
21
+ expect(Array.from(decoded)).toEqual([1, 2, 3]);
22
+ });
23
+
24
+ test.each(['', 'not-base64', 'AB==', 'data:image/jpeg;base64,AQID'])(
25
+ 'rejects non-canonical Base64 input %p',
26
+ value => {
27
+ expect(() =>
28
+ decodeCanonicalBase64({ value, parameterName: 'data', maxBytes: 1024 })
29
+ ).toThrow();
30
+ }
31
+ );
32
+
33
+ test('rejects oversized Base64 before decoding', () => {
34
+ const value = Buffer.alloc(64 * 1024 + 17).toString('base64');
35
+ expect(() =>
36
+ decodeCanonicalBase64({ value, parameterName: 'data', maxBytes: 64 * 1024 })
37
+ ).toThrow('maximum supported size');
38
+ });
39
+
40
+ test('decodes and validates a JPEG with the expected dimensions', () => {
41
+ const decoded = decodeJpegBase64ToRgba({
42
+ jpegBase64: createJpegBase64(2, 1),
43
+ parameterName: 'jpegBase64',
44
+ expectedWidth: 2,
45
+ expectedHeight: 1,
46
+ });
47
+
48
+ expect(decoded).toMatchObject({ width: 2, height: 1 });
49
+ expect(decoded.data).toHaveLength(8);
50
+ });
51
+
52
+ test('rejects non-JPEG bytes and unexpected dimensions', () => {
53
+ expect(() =>
54
+ decodeJpegBase64ToRgba({
55
+ jpegBase64: 'AQID',
56
+ parameterName: 'jpegBase64',
57
+ expectedWidth: 2,
58
+ expectedHeight: 1,
59
+ })
60
+ ).toThrow('JPEG image');
61
+ expect(() =>
62
+ decodeJpegBase64ToRgba({
63
+ jpegBase64: createJpegBase64(2, 1),
64
+ parameterName: 'jpegBase64',
65
+ expectedWidth: 1,
66
+ expectedHeight: 1,
67
+ })
68
+ ).toThrow('1x1');
69
+ });
70
+ });
@@ -1,12 +1,14 @@
1
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
- import { TRANSPORT_EVENT } from '@onekeyfe/hd-transport';
1
+ import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { DeviceType, TRANSPORT_EVENT } from '@onekeyfe/hd-transport';
3
3
 
4
4
  import { initConnector, initCore } from '../src/core';
5
5
  import { DataManager } from '../src/data-manager';
6
6
  import TransportManager from '../src/data-manager/TransportManager';
7
7
  import { Device } from '../src/device/Device';
8
+ import { cancelDeviceInPrompt, cancelDeviceWithInitialize } from '../src/device/DeviceCommands';
8
9
  import { DevicePool } from '../src/device/DevicePool';
9
10
  import { CORE_EVENT, DEVICE, IFRAME } from '../src/events';
11
+ import { PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE } from '../src/protocols/protocol-v2';
10
12
 
11
13
  import type Core from '../src/core';
12
14
  import type { CoreMessage } from '../src/events';
@@ -271,6 +273,115 @@ describe('public device lifecycle events', () => {
271
273
  expect(device.isUsedHere()).toBe(false);
272
274
  });
273
275
 
276
+ test.each([
277
+ ['V2', 'Cancel', 'webusb'],
278
+ ['V2', 'Cancel', 'desktop-web-ble'],
279
+ ['V2', 'Cancel', 'react-native'],
280
+ ['V1', 'Initialize', 'webusb'],
281
+ ['V1', 'Cancel', 'desktop-web-ble'],
282
+ ] as const)(
283
+ 'sends Protocol %s %s without treating the acquired %s session as disconnected',
284
+ async (protocol, messageType, env) => {
285
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
286
+ const device = createInitializedDevice(protocol);
287
+ const post = jest.fn().mockResolvedValue(undefined);
288
+ const call = jest.fn().mockResolvedValue(undefined);
289
+ const cancel = jest.fn().mockResolvedValue(undefined);
290
+ device.originalDescriptor.session = device.mainId;
291
+ (device as unknown as { deviceAcquired: boolean }).deviceAcquired = true;
292
+ (device as any).commands = { transport: { post, call }, cancel };
293
+ device.setCancelableAction(() =>
294
+ messageType === 'Cancel'
295
+ ? cancelDeviceInPrompt(device, false)
296
+ : cancelDeviceWithInitialize(device)
297
+ );
298
+
299
+ await device.interruptionFromUser();
300
+
301
+ if (messageType === 'Cancel') {
302
+ expect(post).toHaveBeenCalledWith(device.mainId, 'Cancel', {});
303
+ expect(call).not.toHaveBeenCalled();
304
+ } else {
305
+ expect(call).toHaveBeenCalledWith(device.mainId, 'Initialize', {});
306
+ expect(post).not.toHaveBeenCalled();
307
+ }
308
+ expect(cancel).toHaveBeenCalledTimes(1);
309
+ expect(device.hasDeviceAcquire()).toBe(true);
310
+ }
311
+ );
312
+
313
+ test.each([
314
+ [EDeviceType.Pro2, 'webusb', false, DeviceType.PRO2],
315
+ [EDeviceType.Neo, 'webusb', false, DeviceType.NEO],
316
+ [EDeviceType.Pro2, 'desktop-web-ble', true, DeviceType.PRO2],
317
+ [EDeviceType.Neo, 'desktop-web-ble', true, DeviceType.NEO],
318
+ [EDeviceType.Pro2, 'react-native', true, DeviceType.PRO2],
319
+ [EDeviceType.Neo, 'react-native', true, DeviceType.NEO],
320
+ [EDeviceType.Pro2, 'lowlevel', false, DeviceType.PRO2],
321
+ ] as const)(
322
+ 'refreshes %s runtime state after acquiring a fresh %s session',
323
+ async (deviceType, env, isBle, protocolV2DeviceType) => {
324
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
325
+ const device = createInitializedDevice('V2');
326
+ const staleProtocolInfo = {
327
+ version: 1,
328
+ build_fingerprint: 'bootloader__1.0.0__abcdef0__PROD__RELEASE',
329
+ supported_messages: [],
330
+ };
331
+ const freshProtocolInfo = {
332
+ version: 1,
333
+ build_fingerprint: 'application__5.0.0__abcdef0__PROD__RELEASE',
334
+ supported_messages: [PROTOCOL_V2_DEVICE_STATUS_GET_MESSAGE_TYPE],
335
+ };
336
+ device.updateState(
337
+ {
338
+ identity: { deviceType },
339
+ status: { mode: 'bootloader' },
340
+ raw: { protocolV2ProtocolInfo: staleProtocolInfo },
341
+ },
342
+ 'initialize'
343
+ );
344
+ const acquire = jest
345
+ .fn()
346
+ .mockResolvedValue(isBle ? { uuid: 'fresh-session', protocolType: 'V2' } : 'fresh-session');
347
+ device.deviceConnector = { acquire } as never;
348
+
349
+ await device.acquire('V2', { throwOnRunPromiseError: true });
350
+ const typedCall = jest.fn().mockImplementation((requestType: string) => {
351
+ if (requestType === 'DeviceInfoGet') {
352
+ return {
353
+ message: {
354
+ protocol_version: 2,
355
+ hw: { Device_type: protocolV2DeviceType, serial_no: 'SERIAL-001' },
356
+ fw: { application: { version: '5.0.0' } },
357
+ },
358
+ };
359
+ }
360
+ if (requestType === 'ProtocolInfoRequest') {
361
+ return { message: freshProtocolInfo };
362
+ }
363
+ if (requestType === 'DeviceStatusGet') {
364
+ return {
365
+ message: { init_states: true, unlocked: true, device_id: 'wallet-device-id' },
366
+ };
367
+ }
368
+ throw new Error(`Unexpected request: ${requestType}`);
369
+ });
370
+ device.commands.typedCall = typedCall as never;
371
+
372
+ await device.initialize();
373
+
374
+ expect(typedCall.mock.calls.map(callArgs => callArgs[0])).toEqual([
375
+ 'DeviceInfoGet',
376
+ 'ProtocolInfoRequest',
377
+ 'DeviceStatusGet',
378
+ ]);
379
+ expect(device.state?.status.mode).toBe('normal');
380
+ expect(device.state?.identity.deviceType).toBe(deviceType);
381
+ expect(device.hasDeviceAcquire()).toBe(true);
382
+ }
383
+ );
384
+
274
385
  test('exposes the current transport usage state in public device snapshots', () => {
275
386
  const getSettings = jest
276
387
  .spyOn(DataManager, 'getSettings')
@@ -38,6 +38,31 @@ describe('DevicePool state lifecycle', () => {
38
38
  expect(getDeviceState).toHaveBeenNthCalledWith(2, { refreshSections: ['settings'] });
39
39
  });
40
40
 
41
+ test('refreshes Protocol V2 discovery state without legacy-specific options', async () => {
42
+ const getDeviceState = jest.fn().mockResolvedValue({ status: { mode: 'bootloader' } });
43
+ const run = jest.fn(async (callback: () => Promise<void>) => callback());
44
+ const device = {
45
+ isProtocolV2: () => true,
46
+ getDeviceState,
47
+ run,
48
+ } as any;
49
+
50
+ await (DevicePool as any)._refreshRuntimeState(device, {
51
+ refreshRuntimeState: true,
52
+ });
53
+
54
+ expect(run).toHaveBeenCalledWith(expect.any(Function), {
55
+ connectProtocol: undefined,
56
+ forceProtocolDetection: undefined,
57
+ });
58
+ expect(getDeviceState).toHaveBeenNthCalledWith(1, {
59
+ refreshSections: ['status'],
60
+ });
61
+ expect(getDeviceState).toHaveBeenNthCalledWith(2, {
62
+ refreshSections: ['settings'],
63
+ });
64
+ });
65
+
41
66
  test('actively re-detects a cached device when protocol detection is forced', async () => {
42
67
  const descriptor = { path: 'cached-path', protocolType: 'V1' } as any;
43
68
  const device = Device.fromDescriptor(descriptor);
@@ -67,7 +67,6 @@ describe('Pro 2 device settings options', () => {
67
67
  { label: '2 minutes', valueMs: 120_000 },
68
68
  { label: '5 minutes', valueMs: 300_000 },
69
69
  { label: '10 minutes', valueMs: 600_000 },
70
- { label: '30 minutes', valueMs: 1_800_000 },
71
70
  { label: 'Never', valueMs: PROTOCOL_V2_NEVER_TIMEOUT_MS },
72
71
  ]);
73
72
  });
@@ -1,4 +1,5 @@
1
1
  import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { encode as encodeJpeg } from 'jpeg-js';
2
3
 
3
4
  import DeviceUploadNft from '../src/api/protocol-v2/DeviceUploadNft';
4
5
 
@@ -13,6 +14,19 @@ const createRgba = (width: number, height: number) => {
13
14
  return data;
14
15
  };
15
16
 
17
+ const jpegBase64Cache = new Map<string, string>();
18
+
19
+ const createJpegBase64 = (width: number, height: number) => {
20
+ const key = `${width}x${height}`;
21
+ const cached = jpegBase64Cache.get(key);
22
+ if (cached) return cached;
23
+ const value = encodeJpeg({ width, height, data: createRgba(width, height) }, 80).data.toString(
24
+ 'base64'
25
+ );
26
+ jpegBase64Cache.set(key, value);
27
+ return value;
28
+ };
29
+
16
30
  const createMethod = ({
17
31
  typedCall,
18
32
  supportedMessages = [60802, 60805, 60808, 61500],
@@ -26,8 +40,8 @@ const createMethod = ({
26
40
  id: 1,
27
41
  payload: {
28
42
  method: 'deviceUploadNft',
29
- image: { width: 540, height: 540, rgba: createRgba(540, 540) },
30
- thumbnail: { width: 263, height: 263, rgba: createRgba(263, 263) },
43
+ imageJpegBase64: createJpegBase64(540, 540),
44
+ thumbnailJpegBase64: createJpegBase64(263, 263),
31
45
  title: 'CryptoPunk #3100',
32
46
  subtitle: 'CryptoPunks',
33
47
  timestampMs: 1_760_000_000_000,
@@ -86,8 +100,8 @@ describe('DeviceUploadNft', () => {
86
100
  id: 1,
87
101
  payload: {
88
102
  method: 'deviceUploadNft',
89
- image: { width: 540, height: 540, rgba: createRgba(540, 540) },
90
- thumbnail: { width: 263, height: 263, rgba: createRgba(263, 263) },
103
+ imageJpegBase64: createJpegBase64(540, 540),
104
+ thumbnailJpegBase64: createJpegBase64(263, 263),
91
105
  title: 'CryptoPunk #3100',
92
106
  subtitle: 'CryptoPunks',
93
107
  timestampMs: 1_760_000_000_000,
@@ -102,6 +116,21 @@ describe('DeviceUploadNft', () => {
102
116
  });
103
117
  });
104
118
 
119
+ test('rejects invalid image Base64 before device communication', () => {
120
+ const method = new DeviceUploadNft({
121
+ id: 1,
122
+ payload: {
123
+ method: 'deviceUploadNft',
124
+ imageJpegBase64: 'not-base64',
125
+ thumbnailJpegBase64: createJpegBase64(263, 263),
126
+ title: 'NFT',
127
+ subtitle: '',
128
+ },
129
+ });
130
+
131
+ expect(() => method.init()).toThrow('canonical Base64');
132
+ });
133
+
105
134
  test('uploads the triplet in order without creating the firmware-owned directory', async () => {
106
135
  const typedCall = jest.fn((request: string, _response: string, params: any) => {
107
136
  if (request === 'FilesystemPathInfoQuery') {