@onekeyfe/hd-core 1.2.0-alpha.42 → 1.2.0-alpha.43

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 (32) hide show
  1. package/__tests__/deviceUploadNft.test.ts +87 -2
  2. package/__tests__/open-wallet-session.test.ts +114 -20
  3. package/__tests__/protocol-v2-ui-lifecycle.test.ts +35 -0
  4. package/__tests__/protocol-v2-unlock-policy.test.ts +110 -0
  5. package/__tests__/protocol-v2.test.ts +54 -14
  6. package/__tests__/protocolV2UiInteraction.test.ts +63 -0
  7. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +1 -0
  8. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/device/Device.d.ts +3 -1
  11. package/dist/device/Device.d.ts.map +1 -1
  12. package/dist/device/DeviceCommands.d.ts +4 -4
  13. package/dist/events/ui-request.d.ts +1 -0
  14. package/dist/events/ui-request.d.ts.map +1 -1
  15. package/dist/index.d.ts +6 -3
  16. package/dist/index.js +157 -38
  17. package/dist/protocols/protocol-v2/uiInteraction.d.ts +4 -1
  18. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  19. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -1
  20. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  21. package/dist/utils/pro2Nft.d.ts +2 -0
  22. package/dist/utils/pro2Nft.d.ts.map +1 -1
  23. package/package.json +4 -4
  24. package/src/api/protocol-v2/DeviceUploadNft.ts +25 -2
  25. package/src/core/index.ts +18 -2
  26. package/src/data/messages/messages-protocol-v2.json +15 -0
  27. package/src/device/Device.ts +15 -5
  28. package/src/events/ui-request.ts +1 -0
  29. package/src/protocols/protocol-v2/uiInteraction.ts +20 -6
  30. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +8 -1
  31. package/src/protocols/protocol-v2/walletSession.ts +66 -17
  32. package/src/utils/pro2Nft.ts +51 -0
@@ -1,3 +1,5 @@
1
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
1
3
  import DeviceUploadNft from '../src/api/protocol-v2/DeviceUploadNft';
2
4
 
3
5
  jest.mock('../src/data/config', () => ({
@@ -13,7 +15,7 @@ const createRgba = (width: number, height: number) => {
13
15
 
14
16
  const createMethod = ({
15
17
  typedCall,
16
- supportedMessages = [60805, 61500],
18
+ supportedMessages = [60805, 60808, 61500],
17
19
  useFullBundle = false,
18
20
  }: {
19
21
  typedCall: jest.Mock;
@@ -67,9 +69,23 @@ const fileWriteSuccess = (params: { file: { offset: number; data: Uint8Array } }
67
69
  },
68
70
  });
69
71
 
72
+ const nftFileList = (count: number, basenameAt?: number) =>
73
+ Array.from({ length: count }, (_, index) => {
74
+ const basename =
75
+ index === basenameAt
76
+ ? 'nft-deadbeef-1760000000000'
77
+ : `nft-${index.toString(16).padStart(8, '0')}-${1760000000001 + index}`;
78
+ return [`${basename}.bin`, `${basename}_m.bin`, `${basename}.json`];
79
+ })
80
+ .flat()
81
+ .join('\n');
82
+
70
83
  describe('DeviceUploadNft', () => {
71
84
  test('uploads the triplet in order without creating the firmware-owned directory', async () => {
72
85
  const typedCall = jest.fn((request: string, _response: string, params: any) => {
86
+ if (request === 'FilesystemDirList') {
87
+ return { message: { path: 'vol1:/nft', child_files: '' } };
88
+ }
73
89
  if (request === 'FilesystemFileWrite') return fileWriteSuccess(params);
74
90
  if (request === 'NftUpdate') return { message: { message: 'NFT updated' } };
75
91
  throw new Error(`Unexpected request: ${request}`);
@@ -79,7 +95,14 @@ describe('DeviceUploadNft', () => {
79
95
  const result = await method.run();
80
96
 
81
97
  const requests = typedCall.mock.calls.map(call => call[0]);
82
- expect(requests[0]).toBe('FilesystemFileWrite');
98
+ expect(requests[0]).toBe('FilesystemDirList');
99
+ expect(typedCall).toHaveBeenNthCalledWith(
100
+ 1,
101
+ 'FilesystemDirList',
102
+ 'FilesystemDir',
103
+ { path: 'vol1:/nft', depth: 1 },
104
+ { timeoutMs: 15_000 }
105
+ );
83
106
  expect(requests).not.toContain('FilesystemDirMake');
84
107
  expect(requests.at(-1)).toBe('NftUpdate');
85
108
  const fileWrites = typedCall.mock.calls.filter(call => call[0] === 'FilesystemFileWrite');
@@ -122,6 +145,9 @@ describe('DeviceUploadNft', () => {
122
145
  });
123
146
  let updateCalls = 0;
124
147
  const typedCall = jest.fn((request: string, _response: string, params: any) => {
148
+ if (request === 'FilesystemDirList') {
149
+ return { message: { path: 'vol1:/nft', child_files: '' } };
150
+ }
125
151
  if (request === 'FilesystemFileWrite') return fileWriteSuccess(params);
126
152
  if (request === 'NftUpdate') {
127
153
  updateCalls += 1;
@@ -141,6 +167,9 @@ describe('DeviceUploadNft', () => {
141
167
 
142
168
  test('does not retry a non-timeout NftUpdate failure', async () => {
143
169
  const typedCall = jest.fn((request: string, _response: string, params: any) => {
170
+ if (request === 'FilesystemDirList') {
171
+ return { message: { path: 'vol1:/nft', child_files: '' } };
172
+ }
144
173
  if (request === 'FilesystemFileWrite') return fileWriteSuccess(params);
145
174
  if (request === 'NftUpdate') throw new Error('Invalid NFT metadata');
146
175
  throw new Error(`Unexpected request: ${request}`);
@@ -156,6 +185,9 @@ describe('DeviceUploadNft', () => {
156
185
  code: 'response-timeout',
157
186
  });
158
187
  const typedCall = jest.fn((request: string, _response: string, params: any) => {
188
+ if (request === 'FilesystemDirList') {
189
+ return { message: { path: 'vol1:/nft', child_files: '' } };
190
+ }
159
191
  if (request === 'FilesystemFileWrite') return fileWriteSuccess(params);
160
192
  if (request === 'NftUpdate') throw timeout;
161
193
  throw new Error(`Unexpected request: ${request}`);
@@ -168,6 +200,9 @@ describe('DeviceUploadNft', () => {
168
200
 
169
201
  test('does not publish when a file write fails', async () => {
170
202
  const typedCall = jest.fn((request: string) => {
203
+ if (request === 'FilesystemDirList') {
204
+ return { message: { path: 'vol1:/nft', child_files: '' } };
205
+ }
171
206
  if (request === 'FilesystemFileWrite') throw new Error('Filesystem full');
172
207
  throw new Error(`Unexpected request: ${request}`);
173
208
  });
@@ -184,4 +219,54 @@ describe('DeviceUploadNft', () => {
184
219
  await expect(method.run()).rejects.toBeDefined();
185
220
  expect(typedCall).not.toHaveBeenCalled();
186
221
  });
222
+
223
+ test('rejects a new NFT before writing when ten complete NFT bundles exist', async () => {
224
+ const typedCall = jest.fn((request: string) => {
225
+ if (request === 'FilesystemDirList') {
226
+ return { message: { path: 'vol1:/nft', child_files: nftFileList(10) } };
227
+ }
228
+ throw new Error(`Unexpected request: ${request}`);
229
+ });
230
+ const method = createMethod({ typedCall });
231
+
232
+ await expect(method.run()).rejects.toMatchObject({
233
+ errorCode: HardwareErrorCode.NftStorageLimitReached,
234
+ params: { count: 10, limit: 10 },
235
+ });
236
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual(['FilesystemDirList']);
237
+ });
238
+
239
+ test('allows an idempotent retry for a basename already counted at the limit', async () => {
240
+ const typedCall = jest.fn((request: string, _response: string, params: any) => {
241
+ if (request === 'FilesystemDirList') {
242
+ return { message: { path: 'vol1:/nft', child_files: nftFileList(10, 0) } };
243
+ }
244
+ if (request === 'FilesystemFileWrite') return fileWriteSuccess(params);
245
+ if (request === 'NftUpdate') return { message: {} };
246
+ throw new Error(`Unexpected request: ${request}`);
247
+ });
248
+ const method = createMethod({ typedCall });
249
+
250
+ await expect(method.run()).resolves.toMatchObject({ nftUpdated: true });
251
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileWrite')).toBe(true);
252
+ });
253
+
254
+ test('does not count unrelated or incomplete files as stored NFTs', async () => {
255
+ const typedCall = jest.fn((request: string, _response: string, params: any) => {
256
+ if (request === 'FilesystemDirList') {
257
+ return {
258
+ message: {
259
+ path: 'vol1:/nft',
260
+ child_files: `${nftFileList(9)}\nnft-ffffffff-1760000009999.json\nnotes.txt`,
261
+ },
262
+ };
263
+ }
264
+ if (request === 'FilesystemFileWrite') return fileWriteSuccess(params);
265
+ if (request === 'NftUpdate') return { message: {} };
266
+ throw new Error(`Unexpected request: ${request}`);
267
+ });
268
+ const method = createMethod({ typedCall });
269
+
270
+ await expect(method.run()).resolves.toMatchObject({ nftUpdated: true });
271
+ });
187
272
  });
@@ -1,11 +1,14 @@
1
1
  import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
- import { DeviceSessionPinType } from '@onekeyfe/hd-transport';
2
+ import { DeviceSessionPinType, DeviceSessionSeedDomain } from '@onekeyfe/hd-transport';
3
3
 
4
4
  import GetPassphraseState from '../src/api/GetPassphraseState';
5
5
  import OpenWalletSession from '../src/api/OpenWalletSession';
6
6
  import { Device } from '../src/device/Device';
7
7
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
8
- import { ensureProtocolV2WalletSessionUnlocked } from '../src/protocols/protocol-v2/walletSession';
8
+ import {
9
+ ensureProtocolV2WalletSessionUnlocked,
10
+ getProtocolV2WalletSession,
11
+ } from '../src/protocols/protocol-v2/walletSession';
9
12
 
10
13
  jest.mock('../src/data/config', () => ({
11
14
  getSDKVersion: jest.fn(() => '1.0.0'),
@@ -95,6 +98,65 @@ describe('openWalletSession', () => {
95
98
  deviceWalletSessionStore.clear();
96
99
  });
97
100
 
101
+ test('reuses the authoritative Main wallet DeviceStatus', async () => {
102
+ const typedCall = jest.fn((request: string) => {
103
+ if (request === 'ProtocolInfoRequest') {
104
+ return { message: { version: 2 } };
105
+ }
106
+ if (request === 'DeviceSessionGet') {
107
+ return {
108
+ message: {
109
+ btc_test_address: 'standard-state',
110
+ session_id: 'standard-session',
111
+ },
112
+ };
113
+ }
114
+ throw new Error(`Unexpected request: ${request}`);
115
+ });
116
+ const device = createDevice({ typedCall });
117
+
118
+ await expect(
119
+ getProtocolV2WalletSession(device as any, {
120
+ onlyMainPin: true,
121
+ })
122
+ ).resolves.toMatchObject({
123
+ passphraseState: 'standard-state',
124
+ newSession: 'standard-session',
125
+ });
126
+
127
+ expect(device.unlockDevice).not.toHaveBeenCalled();
128
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
129
+ seed_domains: [],
130
+ });
131
+ });
132
+
133
+ test('does not treat an Attach PIN DeviceStatus as the Main wallet', async () => {
134
+ const typedCall = jest.fn((request: string) => {
135
+ if (request === 'ProtocolInfoRequest') {
136
+ return { message: { version: 2 } };
137
+ }
138
+ if (request === 'DeviceSessionGet') {
139
+ return {
140
+ message: {
141
+ btc_test_address: 'standard-state',
142
+ session_id: 'standard-session',
143
+ },
144
+ };
145
+ }
146
+ throw new Error(`Unexpected request: ${request}`);
147
+ });
148
+ const device = createDevice({ typedCall });
149
+ device.features.unlockedAttachPin = true;
150
+
151
+ await getProtocolV2WalletSession(device as any, { onlyMainPin: true });
152
+
153
+ expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
154
+ source: 'wallet-session-coordinator',
155
+ reason: 'open-wallet',
156
+ deviceOnly: true,
157
+ });
158
+ });
159
+
98
160
  test('unlocks a locked Protocol V2 device before restoring a wallet session', async () => {
99
161
  const device = {
100
162
  commands: {
@@ -190,7 +252,9 @@ describe('openWalletSession', () => {
190
252
  passphrase: 'host hidden wallet',
191
253
  on_device: false,
192
254
  });
193
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
255
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {
256
+ seed_domains: [],
257
+ });
194
258
  });
195
259
 
196
260
  test('keeps Legacy Protocol V1 getPassphraseState parameterless', async () => {
@@ -308,7 +372,9 @@ describe('openWalletSession', () => {
308
372
  passphrase: 'host hidden wallet',
309
373
  on_device: false,
310
374
  });
311
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
375
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {
376
+ seed_domains: [],
377
+ });
312
378
  });
313
379
 
314
380
  test('refreshes Pro2 status and unlocks before selecting a hidden wallet when locked', async () => {
@@ -573,7 +639,9 @@ describe('openWalletSession', () => {
573
639
  passphrase: 'host hidden wallet',
574
640
  on_device: false,
575
641
  });
576
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
642
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
643
+ seed_domains: [],
644
+ });
577
645
  expect(promptPassphrase).toHaveBeenCalled();
578
646
  expect(deviceWalletSessionStore.get('device-1', 'new-hidden-state')).toBe('new-hidden-session');
579
647
  });
@@ -644,6 +712,8 @@ describe('openWalletSession', () => {
644
712
  });
645
713
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
646
714
  session_id: 'known-session',
715
+ btc_test_address: 'hidden-state',
716
+ seed_domains: [],
647
717
  });
648
718
  expect(promptPassphrase).not.toHaveBeenCalled();
649
719
  });
@@ -821,12 +891,10 @@ describe('openWalletSession', () => {
821
891
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
822
892
  eventless_wallet_session: true,
823
893
  });
824
- expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
825
- source: 'wallet-session-coordinator',
826
- reason: 'open-wallet',
827
- deviceOnly: true,
894
+ expect(device.unlockDevice).not.toHaveBeenCalled();
895
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
896
+ seed_domains: [],
828
897
  });
829
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
830
898
  expect(promptPassphrase).not.toHaveBeenCalled();
831
899
  expect(device.passphraseState).toBeUndefined();
832
900
  });
@@ -931,7 +999,9 @@ describe('openWalletSession', () => {
931
999
  reason: 'open-wallet',
932
1000
  deviceOnly: true,
933
1001
  });
934
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1002
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1003
+ seed_domains: [],
1004
+ });
935
1005
  });
936
1006
 
937
1007
  test('selects a hidden wallet without exposing the internal device session', async () => {
@@ -965,7 +1035,9 @@ describe('openWalletSession', () => {
965
1035
  passphrase: 'host hidden wallet',
966
1036
  on_device: false,
967
1037
  });
968
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1038
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1039
+ seed_domains: [],
1040
+ });
969
1041
  expect(promptPassphrase).toHaveBeenCalled();
970
1042
  expect(device.passphraseState).toBeUndefined();
971
1043
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('hidden-session');
@@ -994,7 +1066,9 @@ describe('openWalletSession', () => {
994
1066
  passphrase: 'host hidden wallet',
995
1067
  on_device: false,
996
1068
  });
997
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1069
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1070
+ seed_domains: [],
1071
+ });
998
1072
  });
999
1073
 
1000
1074
  test('selects an on-device passphrase wallet with an explicit on_device request', async () => {
@@ -1019,7 +1093,9 @@ describe('openWalletSession', () => {
1019
1093
  expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
1020
1094
  on_device: true,
1021
1095
  });
1022
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
1096
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {
1097
+ seed_domains: [],
1098
+ });
1023
1099
  });
1024
1100
 
1025
1101
  test('forwards a host passphrase to Pro2 firmware', async () => {
@@ -1061,7 +1137,9 @@ describe('openWalletSession', () => {
1061
1137
  passphrase: 'host hidden wallet',
1062
1138
  on_device: false,
1063
1139
  });
1064
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
1140
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {
1141
+ seed_domains: [],
1142
+ });
1065
1143
  });
1066
1144
 
1067
1145
  test('normalizes a Unicode Host passphrase before sending it to Pro2 firmware', async () => {
@@ -1141,7 +1219,9 @@ describe('openWalletSession', () => {
1141
1219
  expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin, {
1142
1220
  emitUiEvent: false,
1143
1221
  });
1144
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1222
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1223
+ seed_domains: [],
1224
+ });
1145
1225
  });
1146
1226
 
1147
1227
  test('uses the complete Attach PIN wire flow without a main PIN unlock', async () => {
@@ -1170,7 +1250,7 @@ describe('openWalletSession', () => {
1170
1250
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1171
1251
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.AttachToPin }],
1172
1252
  ['DeviceStatusGet', 'DeviceStatus', {}],
1173
- ['DeviceSessionGet', 'DeviceSession', {}],
1253
+ ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1174
1254
  ]);
1175
1255
  expect(device.commands.typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
1176
1256
  type: DeviceSessionPinType.Main,
@@ -1227,7 +1307,7 @@ describe('openWalletSession', () => {
1227
1307
 
1228
1308
  await expect(method.run()).rejects.toThrow(message);
1229
1309
  expect(typedCall).toHaveBeenCalledTimes(2);
1230
- expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1310
+ expect(typedCall.mock.calls.some(([name]) => name === 'DeviceSessionGet')).toBe(false);
1231
1311
  }
1232
1312
  );
1233
1313
 
@@ -1381,6 +1461,8 @@ describe('openWalletSession', () => {
1381
1461
  });
1382
1462
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1383
1463
  session_id: 'known-session',
1464
+ btc_test_address: 'hidden-state',
1465
+ seed_domains: [],
1384
1466
  });
1385
1467
  expect(promptPassphrase).not.toHaveBeenCalled();
1386
1468
  });
@@ -1439,6 +1521,8 @@ describe('openWalletSession', () => {
1439
1521
  expect(device.getDeviceState).toHaveBeenCalledTimes(2);
1440
1522
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1441
1523
  session_id: 'known-session',
1524
+ btc_test_address: 'hidden-state',
1525
+ seed_domains: [],
1442
1526
  });
1443
1527
  const sessionGetCall = typedCall.mock.calls.findIndex(
1444
1528
  ([requestName]) => requestName === 'DeviceSessionGet'
@@ -1535,6 +1619,8 @@ describe('openWalletSession', () => {
1535
1619
  });
1536
1620
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1537
1621
  session_id: 'expired-session',
1622
+ btc_test_address: 'hidden-state',
1623
+ seed_domains: [],
1538
1624
  });
1539
1625
  expect(promptPassphrase).toHaveBeenCalledTimes(1);
1540
1626
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('renewed-session');
@@ -1570,7 +1656,10 @@ describe('openWalletSession', () => {
1570
1656
  passphraseState: 'hidden-state',
1571
1657
  resumed: false,
1572
1658
  });
1573
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1659
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1660
+ btc_test_address: 'hidden-state',
1661
+ seed_domains: [],
1662
+ });
1574
1663
  expect(promptPassphrase).not.toHaveBeenCalled();
1575
1664
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('new-session');
1576
1665
  });
@@ -1604,6 +1693,11 @@ describe('openWalletSession', () => {
1604
1693
  walletType: 'hidden',
1605
1694
  passphraseState: 'hidden-state',
1606
1695
  });
1607
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1696
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1697
+ seed_domains: [
1698
+ DeviceSessionSeedDomain.SeedDomain_Standard,
1699
+ ...(deriveCardano ? [DeviceSessionSeedDomain.SeedDomain_Cardano] : []),
1700
+ ],
1701
+ });
1608
1702
  });
1609
1703
  });
@@ -9,6 +9,41 @@ jest.mock('../src/data/config', () => ({
9
9
  }));
10
10
 
11
11
  describe('Protocol V2 UI interaction lifecycle', () => {
12
+ test('synthesizes finish metadata for a progress-only operation', () => {
13
+ const device = new Device({ id: 'connect-progress' } as any);
14
+ device.isProtocolV2 = jest.fn(() => true);
15
+ device.beginProtocolV2UiInteraction();
16
+
17
+ const metadata = device.finishProtocolV2UiInteraction('succeeded', {
18
+ ensureMetadata: true,
19
+ });
20
+
21
+ expect(metadata).toMatchObject({
22
+ phase: 'processing',
23
+ transition: 'finish',
24
+ outcome: 'succeeded',
25
+ protocol: 'V2',
26
+ });
27
+ });
28
+
29
+ test('keeps finish metadata after the Protocol V2 transport is released', () => {
30
+ const device = new Device({ id: 'connect-released' } as any);
31
+ const isProtocolV2 = jest.fn().mockReturnValueOnce(true).mockReturnValue(false);
32
+ device.isProtocolV2 = isProtocolV2;
33
+ device.beginProtocolV2UiInteraction();
34
+
35
+ const metadata = device.finishProtocolV2UiInteraction('succeeded', {
36
+ ensureMetadata: true,
37
+ });
38
+
39
+ expect(metadata).toMatchObject({
40
+ phase: 'processing',
41
+ transition: 'finish',
42
+ outcome: 'succeeded',
43
+ protocol: 'V2',
44
+ });
45
+ });
46
+
12
47
  test('emits a matching PIN phase completion after DeviceSessionAskPin succeeds', async () => {
13
48
  const device = new Device({ id: 'connect-1' } as any);
14
49
  device.isProtocolV2 = jest.fn(() => true);
@@ -61,6 +61,116 @@ describe('Protocol V2 unlock semantics', () => {
61
61
  expect(method.run).toHaveBeenCalledTimes(1);
62
62
  });
63
63
 
64
+ test('lets the standard wallet session own the Main PIN unlock', async () => {
65
+ const calls: string[] = [];
66
+ const features = {
67
+ unlocked: false,
68
+ passphraseProtection: true,
69
+ };
70
+ const method = {
71
+ name: 'btcGetPublicKey',
72
+ unlockPolicy: 'none',
73
+ useDevicePassphraseState: true,
74
+ payload: { useEmptyPassphrase: true },
75
+ run: jest.fn().mockImplementation(() => {
76
+ calls.push('run');
77
+ return Promise.resolve({ message: 'ok' });
78
+ }),
79
+ };
80
+ const device = {
81
+ features,
82
+ commands: {
83
+ typedCall: jest.fn().mockImplementation(() => {
84
+ calls.push('status');
85
+ return Promise.resolve({
86
+ message: {
87
+ unlocked: false,
88
+ passphraseProtection: true,
89
+ },
90
+ });
91
+ }),
92
+ },
93
+ isProtocolV2: () => true,
94
+ isBootloader: () => false,
95
+ isRomloader: () => false,
96
+ updateProtocolV2Status: jest.fn(() => features),
97
+ unlockDevice: jest.fn().mockImplementation(() => {
98
+ calls.push('wallet-session-unlock');
99
+ features.unlocked = true;
100
+ return Promise.resolve(features);
101
+ }),
102
+ };
103
+
104
+ await expect(
105
+ runMethodWithUnlockPolicy(method as any, device as any, {
106
+ prepare: async () => {
107
+ await device.unlockDevice();
108
+ },
109
+ })
110
+ ).resolves.toEqual({ message: 'ok' });
111
+
112
+ expect(calls).toEqual(['status', 'wallet-session-unlock', 'run']);
113
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ test('reuses a Main PIN selected by pre-unlock when locked status hides passphrase state', async () => {
117
+ const calls: string[] = [];
118
+ const features: {
119
+ unlocked: boolean;
120
+ passphraseProtection?: boolean;
121
+ unlockedAttachPin?: boolean;
122
+ } = {
123
+ unlocked: false,
124
+ };
125
+ const method: any = {
126
+ name: 'btcGetPublicKey',
127
+ unlockPolicy: 'none',
128
+ useDevicePassphraseState: true,
129
+ payload: { useEmptyPassphrase: true },
130
+ run: jest.fn().mockImplementation(() => {
131
+ calls.push('run');
132
+ return Promise.resolve({ message: 'ok' });
133
+ }),
134
+ };
135
+ const device = {
136
+ features,
137
+ commands: {
138
+ typedCall: jest.fn().mockImplementation(() => {
139
+ calls.push('status');
140
+ return Promise.resolve({
141
+ message: {
142
+ unlocked: false,
143
+ },
144
+ });
145
+ }),
146
+ },
147
+ isProtocolV2: () => true,
148
+ isBootloader: () => false,
149
+ isRomloader: () => false,
150
+ updateProtocolV2Status: jest.fn(() => features),
151
+ unlockDevice: jest.fn().mockImplementation(() => {
152
+ calls.push('main-pin');
153
+ features.unlocked = true;
154
+ features.passphraseProtection = true;
155
+ features.unlockedAttachPin = false;
156
+ return Promise.resolve(features);
157
+ }),
158
+ };
159
+
160
+ await expect(
161
+ runMethodWithUnlockPolicy(method, device as any, {
162
+ prepare: async () => {
163
+ if (!(features.unlocked && features.unlockedAttachPin === false)) {
164
+ await device.unlockDevice();
165
+ }
166
+ },
167
+ })
168
+ ).resolves.toEqual({ message: 'ok' });
169
+
170
+ expect(calls).toEqual(['status', 'main-pin', 'run']);
171
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
172
+ });
173
+
64
174
  test('runs fresh-status validation before starting unlock', async () => {
65
175
  const identityError = new Error('Unexpected device');
66
176
  const method = {