@onekeyfe/hd-core 1.2.0-alpha.36 → 1.2.0-alpha.39

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 (79) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +5 -1
  2. package/__tests__/deviceUploadNft.test.ts +187 -0
  3. package/__tests__/homescreen.test.ts +41 -1
  4. package/__tests__/logBlockEvent.test.ts +1 -1
  5. package/__tests__/open-wallet-session.test.ts +161 -44
  6. package/__tests__/pro2Nft.test.ts +108 -0
  7. package/__tests__/protocol-v2-unlock-policy.test.ts +86 -35
  8. package/__tests__/protocol-v2.test.ts +168 -444
  9. package/__tests__/public-pro2-api-boundary.test.ts +1 -0
  10. package/__tests__/uiProgressThrottle.test.ts +74 -0
  11. package/dist/api/BaseMethod.d.ts +3 -1
  12. package/dist/api/BaseMethod.d.ts.map +1 -1
  13. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  14. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  15. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +0 -1
  16. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  17. package/dist/api/helpers/protocolV2FileWrite.d.ts +2 -0
  18. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  19. package/dist/api/index.d.ts +1 -0
  20. package/dist/api/index.d.ts.map +1 -1
  21. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +30 -0
  22. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -0
  23. package/dist/core/index.d.ts.map +1 -1
  24. package/dist/events/logBlockEvent.d.ts.map +1 -1
  25. package/dist/index.d.ts +34 -2
  26. package/dist/index.js +544 -496
  27. package/dist/inject.d.ts.map +1 -1
  28. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +1 -3
  29. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  30. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +22 -0
  31. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -0
  32. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  33. package/dist/types/api/index.d.ts +2 -1
  34. package/dist/types/api/index.d.ts.map +1 -1
  35. package/dist/types/api/protocolV2.d.ts +3 -0
  36. package/dist/types/api/protocolV2.d.ts.map +1 -1
  37. package/dist/utils/homescreen.d.ts +4 -0
  38. package/dist/utils/homescreen.d.ts.map +1 -1
  39. package/dist/utils/index.d.ts +1 -1
  40. package/dist/utils/index.d.ts.map +1 -1
  41. package/dist/utils/patch.d.ts +1 -1
  42. package/dist/utils/patch.d.ts.map +1 -1
  43. package/dist/utils/pro2Nft.d.ts +29 -0
  44. package/dist/utils/pro2Nft.d.ts.map +1 -0
  45. package/dist/utils/pro2Wallpaper.d.ts +10 -0
  46. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  47. package/dist/utils/uiProgressThrottle.d.ts +3 -0
  48. package/dist/utils/uiProgressThrottle.d.ts.map +1 -0
  49. package/package.json +4 -4
  50. package/src/api/BaseMethod.ts +7 -9
  51. package/src/api/OpenWalletSession.ts +37 -15
  52. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +29 -30
  53. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +0 -7
  54. package/src/api/helpers/protocolV2FileWrite.ts +11 -4
  55. package/src/api/index.ts +1 -0
  56. package/src/api/protocol-v2/DeviceUploadNft.ts +166 -0
  57. package/src/core/index.ts +83 -82
  58. package/src/data/messages/messages-protocol-v2.json +10 -15
  59. package/src/events/logBlockEvent.ts +1 -0
  60. package/src/inject.ts +1 -0
  61. package/src/protocols/protocol-v2/unlockPolicy.ts +1 -105
  62. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +117 -0
  63. package/src/protocols/protocol-v2/walletSession.ts +18 -51
  64. package/src/types/api/index.ts +2 -0
  65. package/src/types/api/protocolV2.ts +13 -0
  66. package/src/utils/homescreen.ts +32 -6
  67. package/src/utils/index.ts +6 -1
  68. package/src/utils/pro2Nft.ts +91 -0
  69. package/src/utils/pro2Wallpaper.ts +35 -8
  70. package/src/utils/uiProgressThrottle.ts +63 -0
  71. package/dist/api/firmware/progressThrottle.d.ts +0 -3
  72. package/dist/api/firmware/progressThrottle.d.ts.map +0 -1
  73. package/dist/protocols/protocol-v2/lockedError.d.ts +0 -2
  74. package/dist/protocols/protocol-v2/lockedError.d.ts.map +0 -1
  75. package/dist/protocols/protocol-v2/unlockRetry.d.ts +0 -10
  76. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +0 -1
  77. package/src/api/firmware/progressThrottle.ts +0 -44
  78. package/src/protocols/protocol-v2/lockedError.ts +0 -10
  79. package/src/protocols/protocol-v2/unlockRetry.ts +0 -131
@@ -79,7 +79,10 @@ import {
79
79
  getProtocolV2WalletSession,
80
80
  refreshProtocolV2DeviceStatus,
81
81
  } from '../src/protocols/protocol-v2/walletSession';
82
- import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
82
+ import {
83
+ createProtocolV2UnlockContext,
84
+ runMethodWithUnlockPolicy,
85
+ } from '../src/protocols/protocol-v2/unlockPolicyRunner';
83
86
  import { BaseMethod } from '../src/api/BaseMethod';
84
87
  import {
85
88
  buildProtocolV1FeaturesPayload,
@@ -241,7 +244,7 @@ describe('UploadPortfolio', () => {
241
244
  (method as any).device = device;
242
245
 
243
246
  method.init();
244
- await runMethodWithUnlockRetry(method, device as any);
247
+ await runMethodWithUnlockPolicy(method, device as any);
245
248
 
246
249
  expect(unlockDevice).not.toHaveBeenCalled();
247
250
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -868,7 +871,6 @@ describe('Protocol V2 feature adapter', () => {
868
871
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
869
872
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
870
873
  session_id: 'standard-session',
871
- btc_test_address: 'standard-state',
872
874
  });
873
875
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
874
876
  passphraseState: 'standard-state',
@@ -1338,7 +1340,6 @@ describe('Protocol V2 feature adapter', () => {
1338
1340
 
1339
1341
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1340
1342
  session_id: 'session-a',
1341
- btc_test_address: 'state-a',
1342
1343
  });
1343
1344
  expect(device.getInternalState()).toBe('session-b');
1344
1345
  });
@@ -1406,11 +1407,7 @@ describe('Protocol V2 feature adapter', () => {
1406
1407
  });
1407
1408
  expect(typedCall.mock.calls).toEqual([
1408
1409
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1409
- [
1410
- 'DeviceSessionGet',
1411
- 'DeviceSession',
1412
- { session_id: 'session-a', btc_test_address: 'state-a' },
1413
- ],
1410
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1414
1411
  ]);
1415
1412
  expect(promptPassphrase).not.toHaveBeenCalled();
1416
1413
  expect(device.getInternalState()).toBeUndefined();
@@ -1618,9 +1615,7 @@ describe('Protocol V2 feature adapter', () => {
1618
1615
  });
1619
1616
 
1620
1617
  expect(promptPassphrase).not.toHaveBeenCalled();
1621
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1622
- btc_test_address: 'expected-state',
1623
- });
1618
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1624
1619
  expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1625
1620
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1626
1621
  expect(device.getInternalState()).toBeUndefined();
@@ -2008,9 +2003,7 @@ describe('Protocol V2 feature adapter', () => {
2008
2003
  await expect(
2009
2004
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
2010
2005
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
2011
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2012
- btc_test_address: 'expected-state',
2013
- });
2006
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2014
2007
  expect(promptPassphrase).not.toHaveBeenCalled();
2015
2008
  });
2016
2009
 
@@ -2077,11 +2070,7 @@ describe('Protocol V2 feature adapter', () => {
2077
2070
  true
2078
2071
  );
2079
2072
  expect(typedCall.mock.calls).toEqual([
2080
- [
2081
- 'DeviceSessionGet',
2082
- 'DeviceSession',
2083
- { session_id: 'session-pro2-app', btc_test_address: 'state-pro2-app' },
2084
- ],
2073
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2085
2074
  ]);
2086
2075
  });
2087
2076
 
@@ -2348,9 +2337,7 @@ describe('Protocol V2 feature adapter', () => {
2348
2337
  );
2349
2338
 
2350
2339
  expect(device.getInternalState()).toBeUndefined();
2351
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2352
- btc_test_address: 'expected-state',
2353
- });
2340
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
2354
2341
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2355
2342
  passphrase: 'host hidden wallet',
2356
2343
  on_device: false,
@@ -6206,334 +6193,230 @@ describe('Protocol V2 protected method execution', () => {
6206
6193
  expect(method.unlockPolicy).toBe('none');
6207
6194
  });
6208
6195
 
6209
- test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6196
+ test('checks fresh status for a wallet business method without a name allowlist', async () => {
6210
6197
  const calls: string[] = [];
6198
+ const features = { unlocked: true };
6211
6199
  const method = {
6212
- name: 'testBusinessMethod',
6213
- unlockPolicy: 'retry-on-locked',
6214
- protocolV2UiInteraction: { reason: 'settings-page' },
6215
- run: jest
6216
- .fn()
6217
- .mockImplementationOnce(() => {
6218
- calls.push('run-1');
6219
- return Promise.reject(deviceLockedError());
6220
- })
6221
- .mockImplementationOnce(() => {
6222
- calls.push('run-2');
6223
- return Promise.resolve({ message: 'ok' });
6224
- }),
6200
+ name: 'confluxSignMessageCIP23',
6201
+ unlockPolicy: 'none',
6202
+ useDevicePassphraseState: true,
6203
+ protocolV2UiInteraction: { reason: 'address-confirmation' },
6204
+ run: jest.fn(() => {
6205
+ calls.push('run');
6206
+ return Promise.resolve({ address: '0x1' });
6207
+ }),
6225
6208
  };
6226
6209
  const device = {
6210
+ features,
6211
+ commands: {
6212
+ typedCall: jest.fn(() => {
6213
+ calls.push('status');
6214
+ return Promise.resolve({ message: { unlocked: true } });
6215
+ }),
6216
+ },
6227
6217
  isProtocolV2: () => true,
6228
- unlockDevice: jest.fn(() => {
6229
- calls.push('unlock');
6230
- return Promise.resolve();
6218
+ isBootloader: () => false,
6219
+ isRomloader: () => false,
6220
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6221
+ features.unlocked = status.unlocked === true;
6222
+ return features;
6231
6223
  }),
6224
+ unlockDevice: jest.fn(),
6232
6225
  };
6233
6226
  const uiCoordinator = {
6234
6227
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6235
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6236
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6228
+ enterUnlockInteraction: jest.fn(),
6237
6229
  };
6238
6230
 
6239
6231
  await expect(
6240
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6241
- ).resolves.toEqual({ message: 'ok' });
6242
- expect(calls).toEqual([
6243
- 'method-prompt',
6244
- 'run-1',
6245
- 'unlock-prompt',
6246
- 'unlock',
6247
- 'method-prompt',
6248
- 'run-2',
6249
- ]);
6232
+ runMethodWithUnlockPolicy(method as any, device as any, {
6233
+ uiCoordinator: uiCoordinator as any,
6234
+ })
6235
+ ).resolves.toEqual({ address: '0x1' });
6236
+
6237
+ expect(calls).toEqual(['status', 'method-prompt', 'run']);
6238
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6239
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6240
+ expect(method.run).toHaveBeenCalledTimes(1);
6250
6241
  });
6251
6242
 
6252
- test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6243
+ test('validates the fresh device identity before starting unlock', async () => {
6253
6244
  const calls: string[] = [];
6245
+ const identityError = new Error('Unexpected device');
6254
6246
  const method = {
6255
6247
  name: 'evmSignMessage',
6256
- payload: { passphraseState: 'hidden-state' },
6248
+ unlockPolicy: 'none',
6257
6249
  useDevicePassphraseState: true,
6258
- unlockPolicy: 'retry-on-locked',
6259
- run: jest
6260
- .fn()
6261
- .mockImplementationOnce(() => {
6262
- calls.push('run-1');
6263
- return Promise.reject(deviceLockedError());
6264
- })
6265
- .mockImplementationOnce(() => {
6266
- calls.push('run-2');
6267
- return Promise.resolve({ message: 'ok' });
6268
- }),
6250
+ run: jest.fn(),
6269
6251
  };
6270
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6271
- if (requestType === 'ProtocolInfoRequest') {
6272
- calls.push('negotiate-session');
6273
- return Promise.resolve({ message: { version: 2 } });
6274
- }
6275
- if (requestType === 'DeviceSessionGet') {
6276
- calls.push('resume-hidden-session');
6277
- expect(request).toEqual({
6278
- session_id: 'hidden-session',
6279
- btc_test_address: 'hidden-state',
6280
- });
6281
- return Promise.resolve({
6282
- message: {
6283
- session_id: 'hidden-session',
6284
- btc_test_address: 'hidden-state',
6285
- },
6286
- });
6287
- }
6288
- throw new Error(`Unexpected request: ${requestType}`);
6289
- });
6252
+ const features = { unlocked: false };
6290
6253
  const device = {
6291
- features: { unlocked: true, passphraseProtection: true },
6292
- passphraseState: 'hidden-state',
6293
- commands: { typedCall },
6294
- ensureProtocolV2RuntimeContext: () =>
6295
- typedCall('ProtocolInfoRequest', 'ProtocolInfo', {
6296
- eventless_wallet_session: true,
6297
- }).then(response => response.message),
6254
+ features,
6255
+ commands: {
6256
+ typedCall: jest.fn(() => {
6257
+ calls.push('status');
6258
+ return Promise.resolve({ message: { unlocked: false } });
6259
+ }),
6260
+ },
6298
6261
  isProtocolV2: () => true,
6299
- unlockDevice: jest.fn(() => {
6300
- calls.push('unlock');
6301
- return Promise.resolve();
6302
- }),
6303
- getCurrentPassphraseProtection: () => true,
6304
- getInternalState: () => 'hidden-session',
6305
- clearInternalState: jest.fn(),
6306
- getCurrentDeviceId: () => 'wallet-device-id',
6307
- updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6262
+ isBootloader: () => false,
6263
+ isRomloader: () => false,
6264
+ updateProtocolV2Status: jest.fn(() => features),
6265
+ unlockDevice: jest.fn(),
6308
6266
  };
6309
6267
 
6310
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6311
- message: 'ok',
6312
- });
6313
- expect(calls).toEqual([
6314
- 'run-1',
6315
- 'unlock',
6316
- 'negotiate-session',
6317
- 'resume-hidden-session',
6318
- 'validate-hidden-session',
6319
- 'run-2',
6320
- ]);
6321
- });
6322
-
6323
- test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
6324
- const initialError = deviceLockedError();
6325
- const restoreError = new Error('Invalid wallet session');
6326
- const method = {
6327
- name: 'evmSignMessage',
6328
- payload: { passphraseState: 'hidden-state' },
6329
- useDevicePassphraseState: true,
6330
- unlockPolicy: 'retry-on-locked',
6331
- run: jest.fn().mockRejectedValueOnce(initialError),
6332
- };
6333
- const typedCall = jest.fn((requestType: string) => {
6334
- if (requestType === 'ProtocolInfoRequest') {
6335
- return Promise.resolve({ message: { version: 2 } });
6336
- }
6337
- if (requestType === 'DeviceSessionGet') {
6338
- return Promise.reject(restoreError);
6339
- }
6340
- throw new Error(`Unexpected request: ${requestType}`);
6341
- });
6342
- const device = {
6343
- features: { unlocked: true, passphraseProtection: true },
6344
- passphraseState: 'hidden-state',
6345
- commands: { typedCall },
6346
- ensureProtocolV2RuntimeContext: () =>
6347
- typedCall('ProtocolInfoRequest', 'ProtocolInfo', {
6348
- eventless_wallet_session: true,
6349
- }).then(response => response.message),
6350
- isProtocolV2: () => true,
6351
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6352
- getCurrentPassphraseProtection: () => true,
6353
- getInternalState: () => 'hidden-session',
6354
- clearInternalState: jest.fn(),
6355
- getCurrentDeviceId: () => 'wallet-device-id',
6356
- updateInternalState: jest.fn(),
6357
- };
6268
+ await expect(
6269
+ runMethodWithUnlockPolicy(method as any, device as any, {
6270
+ afterStatusBeforeUnlock: () => {
6271
+ calls.push('identity');
6272
+ throw identityError;
6273
+ },
6274
+ })
6275
+ ).rejects.toBe(identityError);
6358
6276
 
6359
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
6360
- expect(method.run).toHaveBeenCalledTimes(1);
6361
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6362
- expect(device.clearInternalState).toHaveBeenCalledTimes(1);
6363
- expect(device.updateInternalState).not.toHaveBeenCalled();
6277
+ expect(calls).toEqual(['status', 'identity']);
6278
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6279
+ expect(method.run).not.toHaveBeenCalled();
6364
6280
  });
6365
6281
 
6366
- test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6282
+ test('unlocks before business and never replays the callback', async () => {
6367
6283
  const calls: string[] = [];
6284
+ const features = { unlocked: true };
6368
6285
  const method = {
6369
6286
  name: 'evmSignMessage',
6370
- payload: { passphraseState: 'hidden-state' },
6371
- useDevicePassphraseState: true,
6372
- unlockPolicy: 'retry-on-locked',
6287
+ unlockPolicy: 'unlock-before-run',
6288
+ protocolV2UiInteraction: { reason: 'signing-confirmation' },
6373
6289
  run: jest.fn(() => {
6374
6290
  calls.push('run');
6375
- return Promise.resolve({ message: 'ok' });
6291
+ return Promise.resolve({ signature: 'signed' });
6376
6292
  }),
6377
6293
  };
6378
- const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6379
- if (requestType === 'ProtocolInfoRequest') {
6380
- calls.push('negotiate-session');
6381
- return Promise.resolve({ message: { version: 2 } });
6382
- }
6383
- if (requestType === 'DeviceSessionGet') {
6384
- calls.push('resume-hidden-session');
6385
- expect(request).toEqual({
6386
- session_id: 'hidden-session',
6387
- btc_test_address: 'hidden-state',
6388
- });
6389
- return Promise.resolve({
6390
- message: {
6391
- session_id: 'hidden-session',
6392
- btc_test_address: 'hidden-state',
6393
- },
6394
- });
6395
- }
6396
- throw new Error(`Unexpected request: ${requestType}`);
6397
- });
6398
- const features = {
6399
- unlocked: false,
6400
- unlockedAttachPin: true,
6401
- passphraseProtection: true,
6402
- };
6403
6294
  const device = {
6404
6295
  features,
6405
- passphraseState: 'hidden-state',
6406
- commands: { typedCall },
6407
- ensureProtocolV2RuntimeContext: () =>
6408
- typedCall('ProtocolInfoRequest', 'ProtocolInfo', {
6409
- eventless_wallet_session: true,
6410
- }).then(response => response.message),
6296
+ commands: {
6297
+ typedCall: jest.fn(() => {
6298
+ calls.push('status');
6299
+ return Promise.resolve({ message: { unlocked: false } });
6300
+ }),
6301
+ },
6411
6302
  isProtocolV2: () => true,
6303
+ isBootloader: () => false,
6304
+ isRomloader: () => false,
6305
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6306
+ features.unlocked = status.unlocked === true;
6307
+ return features;
6308
+ }),
6412
6309
  unlockDevice: jest.fn(() => {
6413
- calls.push('unlock-main');
6310
+ calls.push('unlock');
6414
6311
  features.unlocked = true;
6415
- features.unlockedAttachPin = false;
6416
- return Promise.resolve();
6312
+ return Promise.resolve(features);
6313
+ }),
6314
+ };
6315
+ const uiCoordinator = {
6316
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6317
+ enterUnlockInteraction: jest.fn(() => {
6318
+ calls.push('unlock-prompt');
6319
+ return undefined;
6417
6320
  }),
6418
- getCurrentPassphraseProtection: () => true,
6419
- getInternalState: () => 'hidden-session',
6420
- clearInternalState: jest.fn(),
6421
- getCurrentDeviceId: () => 'wallet-device-id',
6422
- updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6423
6321
  };
6424
6322
 
6425
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6426
- message: 'ok',
6427
- });
6428
- expect(device.unlockDevice).toHaveBeenCalledWith(undefined, {
6429
- source: 'unlock-coordinator',
6430
- reason: 'device-locked',
6431
- deviceOnly: true,
6432
- method: 'evmSignMessage',
6433
- });
6323
+ await expect(
6324
+ runMethodWithUnlockPolicy(method as any, device as any, {
6325
+ uiCoordinator: uiCoordinator as any,
6326
+ prepare: () => {
6327
+ calls.push('wallet-session');
6328
+ return Promise.resolve();
6329
+ },
6330
+ })
6331
+ ).resolves.toEqual({ signature: 'signed' });
6332
+
6434
6333
  expect(calls).toEqual([
6435
- 'unlock-main',
6436
- 'negotiate-session',
6437
- 'resume-hidden-session',
6438
- 'validate-hidden-session',
6334
+ 'status',
6335
+ 'unlock-prompt',
6336
+ 'unlock',
6337
+ 'wallet-session',
6338
+ 'method-prompt',
6439
6339
  'run',
6440
6340
  ]);
6341
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6342
+ expect(method.run).toHaveBeenCalledTimes(1);
6441
6343
  });
6442
6344
 
6443
- test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6345
+ test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6444
6346
  const method = {
6445
6347
  name: 'evmGetAddress',
6446
- payload: { useEmptyPassphrase: true },
6447
- useDevicePassphraseState: true,
6448
- unlockPolicy: 'retry-on-locked',
6449
- run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6348
+ unlockPolicy: 'unlock-before-run',
6349
+ run: jest.fn(),
6450
6350
  };
6451
6351
  const device = {
6452
- features: { unlocked: false },
6453
- passphraseState: 'stale-hidden-state',
6352
+ features: { unlocked: undefined },
6353
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6454
6354
  isProtocolV2: () => true,
6455
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6355
+ isBootloader: () => false,
6356
+ isRomloader: () => false,
6357
+ updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6358
+ unlockDevice: jest.fn(),
6456
6359
  };
6457
6360
 
6458
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6459
- address: 'standard-wallet-address',
6361
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
6362
+ errorCode: HardwareErrorCode.RuntimeError,
6460
6363
  });
6461
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6462
- expect(method.run).toHaveBeenCalledTimes(1);
6364
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6365
+ expect(method.run).not.toHaveBeenCalled();
6463
6366
  });
6464
6367
 
6465
- test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6466
- const calls: string[] = [];
6368
+ test('returns a business DeviceLocked error without unlocking or replaying', async () => {
6369
+ const error = deviceLockedError();
6467
6370
  const method = {
6468
- name: 'deviceSettingsPageShow',
6371
+ name: 'evmSignMessage',
6469
6372
  unlockPolicy: 'unlock-before-run',
6470
- protocolV2UiInteraction: { reason: 'settings-page' },
6471
- run: jest.fn(() => {
6472
- calls.push('run');
6473
- return Promise.resolve({ message: 'ok' });
6474
- }),
6373
+ run: jest.fn().mockRejectedValue(error),
6475
6374
  };
6476
6375
  const features = { unlocked: true };
6477
6376
  const device = {
6478
6377
  features,
6479
- commands: {
6480
- typedCall: jest.fn(() => {
6481
- calls.push('refresh-status');
6482
- return Promise.resolve({ message: { unlocked: false } });
6483
- }),
6484
- },
6378
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6485
6379
  isProtocolV2: () => true,
6486
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6487
- features.unlocked = status.unlocked ?? features.unlocked;
6488
- return features;
6489
- }),
6490
- unlockDevice: jest.fn(() => {
6491
- calls.push('unlock');
6492
- features.unlocked = true;
6493
- return Promise.resolve();
6494
- }),
6495
- };
6496
- const uiCoordinator = {
6497
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6498
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6499
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6380
+ isBootloader: () => false,
6381
+ isRomloader: () => false,
6382
+ updateProtocolV2Status: jest.fn(() => features),
6383
+ unlockDevice: jest.fn(),
6500
6384
  };
6501
6385
 
6502
- await expect(
6503
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6504
- ).resolves.toEqual({ message: 'ok' });
6505
- expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6506
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6386
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
6387
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6507
6388
  expect(method.run).toHaveBeenCalledTimes(1);
6508
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6509
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6510
6389
  });
6511
6390
 
6512
- test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6513
- const features = { unlocked: false };
6514
- const method = {
6515
- name: 'deviceSettings',
6391
+ test('reuses one lightweight preflight context across nested protected methods', async () => {
6392
+ const features = { unlocked: true };
6393
+ const context = createProtocolV2UnlockContext();
6394
+ const firstMethod = {
6395
+ name: 'allNetworkGetAddress',
6516
6396
  unlockPolicy: 'unlock-before-run',
6517
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6397
+ run: jest.fn().mockResolvedValue({ root: true }),
6398
+ };
6399
+ const nestedMethod = {
6400
+ name: 'evmGetAddress',
6401
+ unlockPolicy: 'unlock-before-run',
6402
+ run: jest.fn().mockResolvedValue({ address: '0x1' }),
6518
6403
  };
6519
6404
  const device = {
6520
6405
  features,
6521
- commands: {
6522
- typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6523
- },
6406
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6524
6407
  isProtocolV2: () => true,
6525
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6526
- features.unlocked = status.unlocked ?? features.unlocked;
6527
- return features;
6528
- }),
6408
+ isBootloader: () => false,
6409
+ isRomloader: () => false,
6410
+ updateProtocolV2Status: jest.fn(() => features),
6529
6411
  unlockDevice: jest.fn(),
6530
6412
  };
6531
6413
 
6532
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6533
- message: 'ok',
6534
- });
6535
- expect(device.unlockDevice).not.toHaveBeenCalled();
6536
- expect(method.run).toHaveBeenCalledTimes(1);
6414
+ await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
6415
+ await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
6416
+
6417
+ expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
6418
+ expect(firstMethod.run).toHaveBeenCalledTimes(1);
6419
+ expect(nestedMethod.run).toHaveBeenCalledTimes(1);
6537
6420
  });
6538
6421
 
6539
6422
  test.each(['bootloader', 'romloader'] as const)(
@@ -6553,7 +6436,7 @@ describe('Protocol V2 protected method execution', () => {
6553
6436
  unlockDevice: jest.fn(),
6554
6437
  };
6555
6438
 
6556
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6439
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6557
6440
  message: 'updating',
6558
6441
  });
6559
6442
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6562,189 +6445,30 @@ describe('Protocol V2 protected method execution', () => {
6562
6445
  }
6563
6446
  );
6564
6447
 
6565
- test('infers a signing interaction before running a Protocol V2 business method', async () => {
6566
- const method = {
6567
- name: 'evmSignMessage',
6568
- params: { message: 'not-exposed-in-event' },
6569
- payload: {},
6570
- unlockPolicy: 'retry-on-locked',
6571
- run: jest.fn().mockResolvedValue({ signature: 'test' }),
6572
- };
6573
- const device = {
6574
- isProtocolV2: () => true,
6575
- unlockDevice: jest.fn(),
6576
- };
6577
- const uiCoordinator = {
6578
- enterMethodInteraction: jest.fn(),
6579
- enterUnlockInteraction: jest.fn(),
6580
- resumeMethodInteraction: jest.fn(),
6581
- };
6582
-
6583
- await runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any);
6584
-
6585
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledWith({
6586
- request: 'button',
6587
- source: 'method-lifecycle',
6588
- reason: 'signing-confirmation',
6589
- completion: 'operation-completed',
6590
- deviceOnly: true,
6591
- operation: 'evmSignMessage',
6592
- });
6593
- });
6594
-
6595
- test('does not unlock a Protocol V1 device or a method without the policy', async () => {
6448
+ test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6596
6449
  for (const [isProtocolV2, unlockPolicy] of [
6597
- [false, 'retry-on-locked'],
6450
+ [false, 'unlock-before-run'],
6598
6451
  [true, 'none'],
6599
6452
  ] as const) {
6600
- const error = deviceLockedError();
6601
6453
  const method = {
6602
6454
  unlockPolicy,
6603
- run: jest.fn().mockRejectedValue(error),
6455
+ useDevicePassphraseState: false,
6456
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6604
6457
  };
6605
6458
  const device = {
6459
+ commands: { typedCall: jest.fn() },
6606
6460
  isProtocolV2: () => isProtocolV2,
6607
6461
  unlockDevice: jest.fn(),
6608
6462
  };
6609
6463
 
6610
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6464
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6465
+ message: 'ok',
6466
+ });
6467
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6611
6468
  expect(device.unlockDevice).not.toHaveBeenCalled();
6612
6469
  expect(method.run).toHaveBeenCalledTimes(1);
6613
6470
  }
6614
6471
  });
6615
-
6616
- test('runs lock-free eventless methods without unlocking or synthesized UI', async () => {
6617
- const method = {
6618
- name: 'uploadPortfolio',
6619
- unlockPolicy: 'none',
6620
- protocolV2UiMode: 'none',
6621
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6622
- };
6623
- const device = {
6624
- features: { unlocked: false },
6625
- isProtocolV2: () => true,
6626
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6627
- };
6628
- const uiCoordinator = {
6629
- enterMethodInteraction: jest.fn(),
6630
- enterUnlockInteraction: jest.fn(),
6631
- resumeMethodInteraction: jest.fn(),
6632
- };
6633
-
6634
- await expect(
6635
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6636
- ).resolves.toEqual({ message: 'ok' });
6637
- expect(device.unlockDevice).not.toHaveBeenCalled();
6638
- expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6639
- expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6640
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6641
- });
6642
-
6643
- test('keeps the PIN prompt when an eventless method actually unlocks the device', async () => {
6644
- const lockedError = deviceLockedError();
6645
- const method = {
6646
- name: 'backgroundRead',
6647
- unlockPolicy: 'retry-on-locked',
6648
- protocolV2UiMode: 'none',
6649
- run: jest.fn().mockRejectedValueOnce(lockedError).mockResolvedValueOnce({ message: 'ok' }),
6650
- };
6651
- const device = {
6652
- isProtocolV2: () => true,
6653
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6654
- };
6655
- const uiCoordinator = {
6656
- enterMethodInteraction: jest.fn(),
6657
- enterUnlockInteraction: jest.fn(),
6658
- resumeMethodInteraction: jest.fn(),
6659
- };
6660
-
6661
- await expect(
6662
- runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6663
- ).resolves.toEqual({ message: 'ok' });
6664
- expect(device.unlockDevice).toHaveBeenCalledWith(undefined, {
6665
- source: 'unlock-coordinator',
6666
- reason: 'device-locked',
6667
- deviceOnly: true,
6668
- method: 'backgroundRead',
6669
- });
6670
- expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6671
- expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6672
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6673
- });
6674
-
6675
- test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6676
- const error = deviceLockedError();
6677
- const method = {
6678
- name: 'uploadPortfolio',
6679
- unlockPolicy: 'none',
6680
- run: jest.fn().mockRejectedValue(error),
6681
- };
6682
- const device = {
6683
- features: { unlocked: true },
6684
- isProtocolV2: () => true,
6685
- unlockDevice: jest.fn(),
6686
- };
6687
-
6688
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6689
- expect(method.run).toHaveBeenCalledTimes(1);
6690
- expect(device.unlockDevice).not.toHaveBeenCalled();
6691
- });
6692
-
6693
- test('does not retry when unlock fails or when the retry is still locked', async () => {
6694
- const initialError = deviceLockedError();
6695
- const unlockError = new Error('PIN cancelled');
6696
- const unlockFailMethod = {
6697
- unlockPolicy: 'retry-on-locked',
6698
- run: jest.fn().mockRejectedValue(initialError),
6699
- };
6700
- const unlockFailDevice = {
6701
- isProtocolV2: () => true,
6702
- unlockDevice: jest.fn().mockRejectedValue(unlockError),
6703
- };
6704
- const unlockFailCoordinator = {
6705
- enterMethodInteraction: jest.fn(),
6706
- enterUnlockInteraction: jest.fn(),
6707
- resumeMethodInteraction: jest.fn(),
6708
- };
6709
-
6710
- await expect(
6711
- runMethodWithUnlockRetry(
6712
- unlockFailMethod as any,
6713
- unlockFailDevice as any,
6714
- unlockFailCoordinator as any
6715
- )
6716
- ).rejects.toBe(unlockError);
6717
- expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
6718
- expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6719
- expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6720
-
6721
- const retryError = deviceLockedError();
6722
- const retryFailMethod = {
6723
- unlockPolicy: 'retry-on-locked',
6724
- run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
6725
- };
6726
- const retryFailDevice = {
6727
- isProtocolV2: () => true,
6728
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6729
- };
6730
- const retryFailCoordinator = {
6731
- enterMethodInteraction: jest.fn(),
6732
- enterUnlockInteraction: jest.fn(),
6733
- resumeMethodInteraction: jest.fn(),
6734
- };
6735
-
6736
- await expect(
6737
- runMethodWithUnlockRetry(
6738
- retryFailMethod as any,
6739
- retryFailDevice as any,
6740
- retryFailCoordinator as any
6741
- )
6742
- ).rejects.toBe(retryError);
6743
- expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
6744
- expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
6745
- expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6746
- expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
6747
- });
6748
6472
  });
6749
6473
 
6750
6474
  describe('Protocol V2 current low-level methods', () => {