@onekeyfe/hd-core 1.2.2-alpha.5 → 1.2.2-alpha.7

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.
@@ -8,6 +8,7 @@ import {
8
8
  isProtocolV2PeerRemovedPairingError,
9
9
  isRetryableBleConnectionError,
10
10
  isRetryableBleProtocolV2ProbeError,
11
+ resolveBleConnectProtocol,
11
12
  } from '../src/core';
12
13
  import { DataManager } from '../src/data-manager';
13
14
  import TransportManager from '../src/data-manager/TransportManager';
@@ -71,6 +72,19 @@ describe('public device lifecycle events', () => {
71
72
  jest.restoreAllMocks();
72
73
  });
73
74
 
75
+ test('prefers Protocol V2 only when the method contract is explicitly V2-only', () => {
76
+ const createMethod = (protocols: readonly ('V1' | 'V2')[], connectProtocol?: 'V1' | 'V2') =>
77
+ ({
78
+ payload: { connectProtocol },
79
+ getSupportedProtocols: () => protocols,
80
+ } as never);
81
+
82
+ expect(resolveBleConnectProtocol(createMethod(['V2']))).toBe('V2');
83
+ expect(resolveBleConnectProtocol(createMethod(['V1']))).toBeUndefined();
84
+ expect(resolveBleConnectProtocol(createMethod(['V1', 'V2']))).toBeUndefined();
85
+ expect(resolveBleConnectProtocol(createMethod(['V2'], 'V1'))).toBe('V1');
86
+ });
87
+
74
88
  test('registers the shared device lifecycle listeners exactly once', async () => {
75
89
  jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
76
90
  core = initCore();
@@ -11,6 +11,13 @@ import {
11
11
  getProtocolV2WalletSession,
12
12
  } from '../src/protocols/protocol-v2/walletSession';
13
13
 
14
+ const STANDARD_SEED_DOMAINS = [DeviceSessionSeedDomain.SeedDomain_Standard];
15
+ const CARDANO_SEED_DOMAINS = [
16
+ DeviceSessionSeedDomain.SeedDomain_Standard,
17
+ DeviceSessionSeedDomain.SeedDomain_Cardano,
18
+ ];
19
+ const standardSessionGet = {};
20
+
14
21
  jest.mock('../src/data/config', () => ({
15
22
  getSDKVersion: jest.fn(() => '1.0.0'),
16
23
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
@@ -144,7 +151,7 @@ describe('openWalletSession', () => {
144
151
  on_device: false,
145
152
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
146
153
  });
147
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
154
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
148
155
  });
149
156
 
150
157
  test('selects the Main PIN before opening the standard wallet when passphrase is disabled', async () => {
@@ -176,7 +183,7 @@ describe('openWalletSession', () => {
176
183
  'Success',
177
184
  expect.anything()
178
185
  );
179
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
186
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
180
187
  });
181
188
 
182
189
  test('reuses a Main PIN selected by the current preflight when passphrase is disabled', async () => {
@@ -202,7 +209,7 @@ describe('openWalletSession', () => {
202
209
  });
203
210
 
204
211
  expect(device.unlockDevice).not.toHaveBeenCalled();
205
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
212
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
206
213
  });
207
214
 
208
215
  test('does not treat an Attach PIN DeviceStatus as the Main wallet', async () => {
@@ -331,7 +338,12 @@ describe('openWalletSession', () => {
331
338
  on_device: false,
332
339
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
333
340
  });
334
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
341
+ expect(typedCall).toHaveBeenNthCalledWith(
342
+ 3,
343
+ 'DeviceSessionGet',
344
+ 'DeviceSession',
345
+ standardSessionGet
346
+ );
335
347
  });
336
348
 
337
349
  test('keeps Legacy Protocol V1 getPassphraseState parameterless', async () => {
@@ -450,7 +462,12 @@ describe('openWalletSession', () => {
450
462
  on_device: false,
451
463
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
452
464
  });
453
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
465
+ expect(typedCall).toHaveBeenNthCalledWith(
466
+ 3,
467
+ 'DeviceSessionGet',
468
+ 'DeviceSession',
469
+ standardSessionGet
470
+ );
454
471
  });
455
472
 
456
473
  test('refreshes Pro2 status and unlocks before selecting a hidden wallet when locked', async () => {
@@ -724,7 +741,11 @@ describe('openWalletSession', () => {
724
741
  });
725
742
  expect(promptPassphrase).not.toHaveBeenCalled();
726
743
  expect(device.unlockDevice).not.toHaveBeenCalled();
727
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
744
+ expect(device.commands.typedCall).toHaveBeenCalledWith(
745
+ 'DeviceSessionGet',
746
+ 'DeviceSession',
747
+ standardSessionGet
748
+ );
728
749
  expect(device.commands.typedCall).not.toHaveBeenCalledWith(
729
750
  'DeviceSessionAskPassphrase',
730
751
  'Success',
@@ -822,7 +843,7 @@ describe('openWalletSession', () => {
822
843
  on_device: false,
823
844
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
824
845
  });
825
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
846
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
826
847
  expect(promptPassphrase).toHaveBeenCalled();
827
848
  expect(deviceWalletSessionStore.get('device-1', 'new-hidden-state')).toBe('new-hidden-session');
828
849
  });
@@ -894,6 +915,7 @@ describe('openWalletSession', () => {
894
915
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
895
916
  session_id: 'known-session',
896
917
  btc_test_address: 'hidden-state',
918
+ ...standardSessionGet,
897
919
  });
898
920
  expect(promptPassphrase).not.toHaveBeenCalled();
899
921
  });
@@ -1078,7 +1100,7 @@ describe('openWalletSession', () => {
1078
1100
  on_device: false,
1079
1101
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
1080
1102
  });
1081
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1103
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
1082
1104
  expect(promptPassphrase).not.toHaveBeenCalled();
1083
1105
  expect(device.passphraseState).toBeUndefined();
1084
1106
  });
@@ -1247,7 +1269,7 @@ describe('openWalletSession', () => {
1247
1269
  reason: 'open-wallet',
1248
1270
  deviceOnly: true,
1249
1271
  });
1250
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1272
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
1251
1273
  });
1252
1274
 
1253
1275
  test('selects a hidden wallet without exposing the internal device session', async () => {
@@ -1282,7 +1304,7 @@ describe('openWalletSession', () => {
1282
1304
  on_device: false,
1283
1305
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
1284
1306
  });
1285
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1307
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
1286
1308
  expect(promptPassphrase).toHaveBeenCalled();
1287
1309
  expect(device.passphraseState).toBeUndefined();
1288
1310
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('hidden-session');
@@ -1343,7 +1365,7 @@ describe('openWalletSession', () => {
1343
1365
  on_device: false,
1344
1366
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
1345
1367
  });
1346
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1368
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
1347
1369
  });
1348
1370
 
1349
1371
  test('selects an on-device passphrase wallet with an explicit on_device request', async () => {
@@ -1390,7 +1412,12 @@ describe('openWalletSession', () => {
1390
1412
  on_device: true,
1391
1413
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
1392
1414
  });
1393
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
1415
+ expect(typedCall).toHaveBeenNthCalledWith(
1416
+ 3,
1417
+ 'DeviceSessionGet',
1418
+ 'DeviceSession',
1419
+ standardSessionGet
1420
+ );
1394
1421
  expect(device.createProtocolV2UiPhaseMetadata).toHaveBeenNthCalledWith(
1395
1422
  2,
1396
1423
  'passphrase-on-device',
@@ -1443,7 +1470,12 @@ describe('openWalletSession', () => {
1443
1470
  on_device: false,
1444
1471
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
1445
1472
  });
1446
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
1473
+ expect(typedCall).toHaveBeenNthCalledWith(
1474
+ 3,
1475
+ 'DeviceSessionGet',
1476
+ 'DeviceSession',
1477
+ standardSessionGet
1478
+ );
1447
1479
  });
1448
1480
 
1449
1481
  test('normalizes a Unicode Host passphrase before sending it to Pro2 firmware', async () => {
@@ -1525,7 +1557,7 @@ describe('openWalletSession', () => {
1525
1557
  expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin, {
1526
1558
  emitUiEvent: false,
1527
1559
  });
1528
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1560
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
1529
1561
  });
1530
1562
 
1531
1563
  test('uses the complete Attach PIN wire flow without a main PIN unlock', async () => {
@@ -1554,7 +1586,7 @@ describe('openWalletSession', () => {
1554
1586
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1555
1587
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.AttachToPin }],
1556
1588
  ['DeviceStatusGet', 'DeviceStatus', {}],
1557
- ['DeviceSessionGet', 'DeviceSession', {}],
1589
+ ['DeviceSessionGet', 'DeviceSession', standardSessionGet],
1558
1590
  ]);
1559
1591
  expect(device.commands.typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
1560
1592
  type: DeviceSessionPinType.Main,
@@ -1766,6 +1798,7 @@ describe('openWalletSession', () => {
1766
1798
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1767
1799
  session_id: 'known-session',
1768
1800
  btc_test_address: 'hidden-state',
1801
+ ...standardSessionGet,
1769
1802
  });
1770
1803
  expect(promptPassphrase).not.toHaveBeenCalled();
1771
1804
  });
@@ -1825,6 +1858,7 @@ describe('openWalletSession', () => {
1825
1858
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1826
1859
  session_id: 'known-session',
1827
1860
  btc_test_address: 'hidden-state',
1861
+ ...standardSessionGet,
1828
1862
  });
1829
1863
  const sessionGetCall = typedCall.mock.calls.findIndex(
1830
1864
  ([requestName]) => requestName === 'DeviceSessionGet'
@@ -1922,6 +1956,7 @@ describe('openWalletSession', () => {
1922
1956
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1923
1957
  session_id: 'expired-session',
1924
1958
  btc_test_address: 'hidden-state',
1959
+ ...standardSessionGet,
1925
1960
  });
1926
1961
  expect(promptPassphrase).toHaveBeenCalledTimes(1);
1927
1962
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('renewed-session');
@@ -1959,6 +1994,7 @@ describe('openWalletSession', () => {
1959
1994
  });
1960
1995
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1961
1996
  btc_test_address: 'hidden-state',
1997
+ ...standardSessionGet,
1962
1998
  });
1963
1999
  expect(promptPassphrase).not.toHaveBeenCalled();
1964
2000
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('new-session');
@@ -2000,7 +2036,11 @@ describe('openWalletSession', () => {
2000
2036
  on_device: false,
2001
2037
  seed_domains: [DeviceSessionSeedDomain.SeedDomain_Standard],
2002
2038
  });
2003
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2039
+ expect(typedCall).toHaveBeenCalledWith(
2040
+ 'DeviceSessionGet',
2041
+ 'DeviceSession',
2042
+ standardSessionGet
2043
+ );
2004
2044
  }
2005
2045
  );
2006
2046
 
@@ -2043,11 +2083,9 @@ describe('openWalletSession', () => {
2043
2083
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2044
2084
  passphrase: 'host hidden wallet',
2045
2085
  on_device: false,
2046
- seed_domains: [
2047
- DeviceSessionSeedDomain.SeedDomain_Standard,
2048
- DeviceSessionSeedDomain.SeedDomain_Cardano,
2049
- ],
2086
+ seed_domains: CARDANO_SEED_DOMAINS,
2050
2087
  });
2088
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', standardSessionGet);
2051
2089
  });
2052
2090
 
2053
2091
  test('asks Cardano seed domains after Get reports a Standard-only session', async () => {
@@ -2106,6 +2144,266 @@ describe('openWalletSession', () => {
2106
2144
  });
2107
2145
  });
2108
2146
 
2147
+ test('uses empty AskPassphrase to add Cardano on an Attach PIN session', async () => {
2148
+ const typedCall = jest.fn((request: string) => {
2149
+ if (request === 'ProtocolInfoRequest') {
2150
+ return { message: { version: 2 } };
2151
+ }
2152
+ if (request === 'DeviceSessionAskPassphrase') {
2153
+ return { message: {} };
2154
+ }
2155
+ if (request === 'DeviceSessionGet') {
2156
+ const askedCardano = typedCall.mock.calls.some(
2157
+ call =>
2158
+ call[0] === 'DeviceSessionAskPassphrase' &&
2159
+ Array.isArray(call[2]?.seed_domains) &&
2160
+ call[2].seed_domains.includes(DeviceSessionSeedDomain.SeedDomain_Cardano)
2161
+ );
2162
+ return {
2163
+ message: {
2164
+ btc_test_address: 'attach-state',
2165
+ session_id: 'attach-session',
2166
+ seed_domains: askedCardano ? CARDANO_SEED_DOMAINS : STANDARD_SEED_DOMAINS,
2167
+ },
2168
+ };
2169
+ }
2170
+ throw new Error(`Unexpected request: ${request}`);
2171
+ });
2172
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'should-not-ask' });
2173
+ const device = createDevice({
2174
+ typedCall,
2175
+ promptPassphrase,
2176
+ unlockedAttachPin: true,
2177
+ });
2178
+
2179
+ await expect(
2180
+ getProtocolV2WalletSession(device as any, {
2181
+ readCurrentAttachPinSession: true,
2182
+ deriveCardano: true,
2183
+ })
2184
+ ).resolves.toMatchObject({
2185
+ passphraseState: 'attach-state',
2186
+ newSession: 'attach-session',
2187
+ unlockedAttachPin: true,
2188
+ });
2189
+ expect(promptPassphrase).not.toHaveBeenCalled();
2190
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2191
+ passphrase: '',
2192
+ on_device: false,
2193
+ seed_domains: CARDANO_SEED_DOMAINS,
2194
+ });
2195
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2196
+ expect(
2197
+ typedCall.mock.calls
2198
+ .filter(call => call[0] === 'DeviceSessionGet')
2199
+ .every(call => !('seed_domains' in (call[2] ?? {})))
2200
+ ).toBe(true);
2201
+ });
2202
+
2203
+ test('uses empty AskPassphrase after selecting Attach PIN for Cardano intent', async () => {
2204
+ const typedCall = jest.fn((request: string) => {
2205
+ if (request === 'ProtocolInfoRequest') {
2206
+ return { message: { version: 2 } };
2207
+ }
2208
+ if (request === 'DeviceSessionAskPassphrase') {
2209
+ return { message: {} };
2210
+ }
2211
+ if (request === 'DeviceSessionGet') {
2212
+ return {
2213
+ message: {
2214
+ btc_test_address: 'attach-state',
2215
+ session_id: 'attach-session',
2216
+ seed_domains: CARDANO_SEED_DOMAINS,
2217
+ },
2218
+ };
2219
+ }
2220
+ throw new Error(`Unexpected request: ${request}`);
2221
+ });
2222
+ const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
2223
+ const device = createDevice({ typedCall, promptPassphrase });
2224
+ device.features.attachToPinEnabled = true;
2225
+
2226
+ await expect(
2227
+ getProtocolV2WalletSession(device as any, {
2228
+ forceWalletSelection: true,
2229
+ deriveCardano: true,
2230
+ })
2231
+ ).resolves.toMatchObject({
2232
+ passphraseState: 'attach-state',
2233
+ newSession: 'attach-session',
2234
+ unlockedAttachPin: true,
2235
+ });
2236
+ expect(promptPassphrase).toHaveBeenCalledTimes(1);
2237
+ expect(device.unlockDevice).toHaveBeenCalledWith(
2238
+ DeviceSessionPinType.AttachToPin,
2239
+ expect.objectContaining({ emitUiEvent: false })
2240
+ );
2241
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2242
+ passphrase: '',
2243
+ on_device: false,
2244
+ seed_domains: CARDANO_SEED_DOMAINS,
2245
+ });
2246
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2247
+ expect(
2248
+ typedCall.mock.calls
2249
+ .filter(call => call[0] === 'DeviceSessionGet')
2250
+ .every(call => !('seed_domains' in (call[2] ?? {})))
2251
+ ).toBe(true);
2252
+ });
2253
+
2254
+ test('still locks before a passphrase picker after Attach PIN Cardano empty Ask', async () => {
2255
+ const typedCall = jest.fn((request: string) => {
2256
+ if (request === 'ProtocolInfoRequest') {
2257
+ return { message: { version: 2 } };
2258
+ }
2259
+ if (request === 'DeviceSessionAskPassphrase') {
2260
+ return { message: {} };
2261
+ }
2262
+ if (request === 'DeviceSessionGet') {
2263
+ return {
2264
+ message: {
2265
+ btc_test_address: 'attach-state',
2266
+ session_id: 'attach-session',
2267
+ seed_domains: CARDANO_SEED_DOMAINS,
2268
+ },
2269
+ };
2270
+ }
2271
+ throw new Error(`Unexpected request: ${request}`);
2272
+ });
2273
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'should-not-ask' });
2274
+ const device = createDevice({
2275
+ typedCall,
2276
+ promptPassphrase,
2277
+ unlockedAttachPin: true,
2278
+ });
2279
+
2280
+ await getProtocolV2WalletSession(device as any, {
2281
+ readCurrentAttachPinSession: true,
2282
+ deriveCardano: true,
2283
+ });
2284
+ await expect(
2285
+ getProtocolV2WalletSession(device as any, { forceWalletSelection: true })
2286
+ ).rejects.toMatchObject({
2287
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
2288
+ });
2289
+ expect(device.lockDevice).toHaveBeenCalled();
2290
+ expect(promptPassphrase).not.toHaveBeenCalled();
2291
+ });
2292
+
2293
+ test('uses a second Get for Cardano when passphrase protection is off', async () => {
2294
+ const typedCall = jest.fn((request: string) => {
2295
+ if (request === 'ProtocolInfoRequest') {
2296
+ return { message: { version: 2 } };
2297
+ }
2298
+ if (request === 'DeviceSessionGet') {
2299
+ const getCount = typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet').length;
2300
+ return {
2301
+ message: {
2302
+ btc_test_address: 'standard-state',
2303
+ session_id: 'standard-session',
2304
+ seed_domains: getCount > 1 ? CARDANO_SEED_DOMAINS : STANDARD_SEED_DOMAINS,
2305
+ },
2306
+ };
2307
+ }
2308
+ throw new Error(`Unexpected request: ${request}`);
2309
+ });
2310
+ const device = createDevice({
2311
+ typedCall,
2312
+ passphraseProtection: false,
2313
+ });
2314
+
2315
+ await expect(
2316
+ getProtocolV2WalletSession(device as any, {
2317
+ onlyMainPin: true,
2318
+ deriveCardano: true,
2319
+ })
2320
+ ).resolves.toMatchObject({
2321
+ passphraseState: 'standard-state',
2322
+ newSession: 'standard-session',
2323
+ });
2324
+ expect(typedCall).not.toHaveBeenCalledWith(
2325
+ 'DeviceSessionAskPassphrase',
2326
+ 'Success',
2327
+ expect.anything()
2328
+ );
2329
+ expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(2);
2330
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2331
+ });
2332
+
2333
+ test('fails closed when Cardano fallback still lacks Cardano', async () => {
2334
+ const typedCall = jest.fn((request: string) => {
2335
+ if (request === 'ProtocolInfoRequest') {
2336
+ return { message: { version: 2 } };
2337
+ }
2338
+ if (request === 'DeviceSessionAskPassphrase') {
2339
+ return { message: {} };
2340
+ }
2341
+ if (request === 'DeviceSessionGet') {
2342
+ return {
2343
+ message: {
2344
+ btc_test_address: 'hidden-state',
2345
+ session_id: 'hidden-session',
2346
+ seed_domains: STANDARD_SEED_DOMAINS,
2347
+ },
2348
+ };
2349
+ }
2350
+ throw new Error(`Unexpected request: ${request}`);
2351
+ });
2352
+ const device = createDevice({
2353
+ typedCall,
2354
+ promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' }),
2355
+ });
2356
+ deviceWalletSessionStore.set('device-1', 'hidden-state', 'cached-session');
2357
+
2358
+ await expect(
2359
+ getProtocolV2WalletSession(device as any, {
2360
+ expectedPassphraseState: 'hidden-state',
2361
+ deriveCardano: true,
2362
+ })
2363
+ ).rejects.toMatchObject({
2364
+ errorCode: HardwareErrorCode.WalletSessionInvalid,
2365
+ });
2366
+ expect(device.clearInternalState).toHaveBeenCalled();
2367
+ });
2368
+
2369
+ test('locks before selecting a passphrase wallet from an Attach PIN session', async () => {
2370
+ const typedCall = jest.fn((request: string) => {
2371
+ if (request === 'ProtocolInfoRequest') {
2372
+ return { message: { version: 2 } };
2373
+ }
2374
+ if (request === 'DeviceStatusGet') {
2375
+ return {
2376
+ message: {
2377
+ device_id: 'device-1',
2378
+ unlocked: true,
2379
+ passphrase_enabled: true,
2380
+ unlocked_by_attach_to_pin: true,
2381
+ },
2382
+ };
2383
+ }
2384
+ throw new Error(`Unexpected request: ${request}`);
2385
+ });
2386
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'should-not-ask' });
2387
+ const device = createDevice({
2388
+ typedCall,
2389
+ promptPassphrase,
2390
+ unlockedAttachPin: true,
2391
+ });
2392
+
2393
+ await expect(
2394
+ getProtocolV2WalletSession(device as any, { forceWalletSelection: true })
2395
+ ).rejects.toMatchObject({
2396
+ errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
2397
+ });
2398
+ expect(device.lockDevice).toHaveBeenCalled();
2399
+ expect(promptPassphrase).not.toHaveBeenCalled();
2400
+ expect(typedCall).not.toHaveBeenCalledWith(
2401
+ 'DeviceSessionAskPassphrase',
2402
+ 'Success',
2403
+ expect.anything()
2404
+ );
2405
+ });
2406
+
2109
2407
  test('asks Standard-only seed domains when deriveCardano is false', async () => {
2110
2408
  const typedCall = jest.fn((request: string) => {
2111
2409
  if (request === 'ProtocolInfoRequest') {
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-bitwise -- LZ4 decoding and deterministic test data generation require bitwise operations. */
1
2
  import { sha3_512 } from '@noble/hashes/sha3';
2
3
 
3
4
  import {
@@ -5,6 +6,82 @@ import {
5
6
  supportsPro2HostAssetPackage,
6
7
  } from '../src/utils/pro2HostAssetPackage';
7
8
 
9
+ const decodeRawLz4Block = (compressed: Uint8Array, expectedLength: number) => {
10
+ const output = new Uint8Array(expectedLength);
11
+ let inputOffset = 0;
12
+ let outputOffset = 0;
13
+
14
+ const readLength = (initialLength: number) => {
15
+ let length = initialLength;
16
+ if (length === 15) {
17
+ let extension = 255;
18
+ while (extension === 255) {
19
+ extension = compressed[inputOffset];
20
+ inputOffset += 1;
21
+ length += extension;
22
+ }
23
+ }
24
+ return length;
25
+ };
26
+
27
+ while (inputOffset < compressed.byteLength) {
28
+ const token = compressed[inputOffset];
29
+ inputOffset += 1;
30
+ const literalLength = readLength(token >>> 4);
31
+ output.set(compressed.subarray(inputOffset, inputOffset + literalLength), outputOffset);
32
+ inputOffset += literalLength;
33
+ outputOffset += literalLength;
34
+ if (inputOffset >= compressed.byteLength) break;
35
+
36
+ const matchOffset = compressed[inputOffset] | (compressed[inputOffset + 1] << 8);
37
+ inputOffset += 2;
38
+ const matchLength = readLength(token & 0x0f) + 4;
39
+ for (let index = 0; index < matchLength; index += 1) {
40
+ output[outputOffset] = output[outputOffset - matchOffset];
41
+ outputOffset += 1;
42
+ }
43
+ }
44
+
45
+ expect(outputOffset).toBe(expectedLength);
46
+ return output;
47
+ };
48
+
49
+ const decodeFirstPackageEntry = (packageData: Uint8Array, rawLength: number) => {
50
+ const containerHeaderSize = 0x5f90;
51
+ const archive = packageData.subarray(containerHeaderSize);
52
+ const archiveView = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
53
+ const compressedOffset = archiveView.getUint32(42 + 0x100, true);
54
+ const compressed = archive.subarray(compressedOffset);
55
+ const compressedView = new DataView(
56
+ compressed.buffer,
57
+ compressed.byteOffset,
58
+ compressed.byteLength
59
+ );
60
+ const blockCount = compressedView.getUint16(0, true);
61
+ const blockSize = 1 << compressedView.getUint16(2, true);
62
+ let blockOffset = 8 + blockCount * 4;
63
+ const decodedBlocks: Uint8Array[] = [];
64
+
65
+ for (let index = 0; index < blockCount; index += 1) {
66
+ const compressedLength = compressedView.getUint32(8 + index * 4, true);
67
+ const expectedLength = Math.min(blockSize, rawLength - index * blockSize);
68
+ decodedBlocks.push(
69
+ decodeRawLz4Block(
70
+ compressed.subarray(blockOffset, blockOffset + compressedLength),
71
+ expectedLength
72
+ )
73
+ );
74
+ blockOffset += compressedLength;
75
+ }
76
+
77
+ const decoded = new Uint8Array(rawLength);
78
+ decodedBlocks.reduce((offset, block) => {
79
+ decoded.set(block, offset);
80
+ return offset + block.byteLength;
81
+ }, 0);
82
+ return decoded;
83
+ };
84
+
8
85
  describe('Pro2 host asset package', () => {
9
86
  test('builds the unsigned RESOURCE container and LZ4-blocked archive expected by firmware', () => {
10
87
  const raw = new TextEncoder().encode('123456789');
@@ -40,12 +117,42 @@ describe('Pro2 host asset package', () => {
40
117
 
41
118
  const compressedOffset = archiveView.getUint32(42 + 0x100, true);
42
119
  expect(archiveView.getUint16(compressedOffset, true)).toBe(1);
43
- expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(12);
120
+ expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(14);
44
121
  expect(archiveView.getUint32(compressedOffset + 4, true)).toBe(0);
45
122
  expect(archiveView.getUint32(compressedOffset + 8, true)).toBe(10);
46
123
  expect(payload.subarray(compressedOffset + 12)).toEqual(new Uint8Array([0x90, ...raw]));
47
124
  });
48
125
 
126
+ test('round-trips multi-block data byte-for-byte with best-match compression', () => {
127
+ const raw = Uint8Array.from({ length: 16_384 * 3 + 137 }, (_, index) => {
128
+ const column = index % 604;
129
+ const row = Math.floor(index / 604);
130
+ return (column * 31 + row * 17) & 0xff;
131
+ });
132
+
133
+ const packageData = buildPro2HostAssetPackage([{ name: 'wallpaper.bin', data: raw }]);
134
+
135
+ expect(decodeFirstPackageEntry(packageData, raw.byteLength)).toEqual(raw);
136
+ });
137
+
138
+ test('falls back to 8 KiB blocks when a compressed 16 KiB block exceeds firmware capacity', () => {
139
+ let state = 0x12345678;
140
+ const raw = Uint8Array.from({ length: 16_384 }, () => {
141
+ state ^= state << 13;
142
+ state ^= state >>> 17;
143
+ state ^= state << 5;
144
+ return state & 0xff;
145
+ });
146
+
147
+ const packageData = buildPro2HostAssetPackage([{ name: 'wallpaper.bin', data: raw }]);
148
+ const archive = packageData.subarray(0x5f90);
149
+ const archiveView = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
150
+ const compressedOffset = archiveView.getUint32(42 + 0x100, true);
151
+
152
+ expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(13);
153
+ expect(decodeFirstPackageEntry(packageData, raw.byteLength)).toEqual(raw);
154
+ });
155
+
49
156
  test.each([
50
157
  ['1.0.0', false],
51
158
  ['1.0.1-beta.1', false],