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

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 +161 -21
  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 +71 -13
  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 +174 -45
  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 +84 -24
  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,104 @@ describe('openWalletSession', () => {
95
98
  deviceWalletSessionStore.clear();
96
99
  });
97
100
 
101
+ test('selects the standard wallet explicitly when passphrase is enabled', async () => {
102
+ const typedCall = jest.fn((request: string) => {
103
+ if (request === 'ProtocolInfoRequest') {
104
+ return { message: { version: 2 } };
105
+ }
106
+ if (request === 'DeviceSessionAskPassphrase') {
107
+ return { message: {} };
108
+ }
109
+ if (request === 'DeviceSessionGet') {
110
+ return {
111
+ message: {
112
+ btc_test_address: 'standard-state',
113
+ session_id: 'standard-session',
114
+ },
115
+ };
116
+ }
117
+ throw new Error(`Unexpected request: ${request}`);
118
+ });
119
+ const device = createDevice({ typedCall });
120
+
121
+ await expect(
122
+ getProtocolV2WalletSession(device as any, {
123
+ onlyMainPin: true,
124
+ })
125
+ ).resolves.toMatchObject({
126
+ passphraseState: 'standard-state',
127
+ newSession: 'standard-session',
128
+ });
129
+
130
+ expect(device.unlockDevice).not.toHaveBeenCalled();
131
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
132
+ passphrase: '',
133
+ on_device: false,
134
+ });
135
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
136
+ seed_domains: [],
137
+ });
138
+ });
139
+
140
+ test('opens the standard wallet directly when passphrase is disabled', async () => {
141
+ const typedCall = jest.fn((request: string) => {
142
+ if (request === 'ProtocolInfoRequest') {
143
+ return { message: { version: 2 } };
144
+ }
145
+ if (request === 'DeviceSessionGet') {
146
+ return {
147
+ message: {
148
+ btc_test_address: 'standard-state',
149
+ session_id: 'standard-session',
150
+ },
151
+ };
152
+ }
153
+ throw new Error(`Unexpected request: ${request}`);
154
+ });
155
+ const device = createDevice({ passphraseProtection: false, typedCall });
156
+
157
+ await getProtocolV2WalletSession(device as any, { onlyMainPin: true });
158
+
159
+ expect(typedCall).not.toHaveBeenCalledWith(
160
+ 'DeviceSessionAskPassphrase',
161
+ 'Success',
162
+ expect.anything()
163
+ );
164
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
165
+ seed_domains: [],
166
+ });
167
+ });
168
+
169
+ test('does not treat an Attach PIN DeviceStatus as the Main wallet', async () => {
170
+ const typedCall = jest.fn((request: string) => {
171
+ if (request === 'ProtocolInfoRequest') {
172
+ return { message: { version: 2 } };
173
+ }
174
+ if (request === 'DeviceSessionAskPassphrase') {
175
+ return { message: {} };
176
+ }
177
+ if (request === 'DeviceSessionGet') {
178
+ return {
179
+ message: {
180
+ btc_test_address: 'standard-state',
181
+ session_id: 'standard-session',
182
+ },
183
+ };
184
+ }
185
+ throw new Error(`Unexpected request: ${request}`);
186
+ });
187
+ const device = createDevice({ typedCall });
188
+ device.features.unlockedAttachPin = true;
189
+
190
+ await getProtocolV2WalletSession(device as any, { onlyMainPin: true });
191
+
192
+ expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
193
+ source: 'wallet-session-coordinator',
194
+ reason: 'open-wallet',
195
+ deviceOnly: true,
196
+ });
197
+ });
198
+
98
199
  test('unlocks a locked Protocol V2 device before restoring a wallet session', async () => {
99
200
  const device = {
100
201
  commands: {
@@ -190,7 +291,9 @@ describe('openWalletSession', () => {
190
291
  passphrase: 'host hidden wallet',
191
292
  on_device: false,
192
293
  });
193
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
294
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {
295
+ seed_domains: [],
296
+ });
194
297
  });
195
298
 
196
299
  test('keeps Legacy Protocol V1 getPassphraseState parameterless', async () => {
@@ -308,7 +411,9 @@ describe('openWalletSession', () => {
308
411
  passphrase: 'host hidden wallet',
309
412
  on_device: false,
310
413
  });
311
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
414
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {
415
+ seed_domains: [],
416
+ });
312
417
  });
313
418
 
314
419
  test('refreshes Pro2 status and unlocks before selecting a hidden wallet when locked', async () => {
@@ -573,7 +678,9 @@ describe('openWalletSession', () => {
573
678
  passphrase: 'host hidden wallet',
574
679
  on_device: false,
575
680
  });
576
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
681
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
682
+ seed_domains: [],
683
+ });
577
684
  expect(promptPassphrase).toHaveBeenCalled();
578
685
  expect(deviceWalletSessionStore.get('device-1', 'new-hidden-state')).toBe('new-hidden-session');
579
686
  });
@@ -644,6 +751,8 @@ describe('openWalletSession', () => {
644
751
  });
645
752
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
646
753
  session_id: 'known-session',
754
+ btc_test_address: 'hidden-state',
755
+ seed_domains: [],
647
756
  });
648
757
  expect(promptPassphrase).not.toHaveBeenCalled();
649
758
  });
@@ -795,6 +904,7 @@ describe('openWalletSession', () => {
795
904
  const typedCall = jest
796
905
  .fn()
797
906
  .mockResolvedValueOnce({ message: { version: 2 } })
907
+ .mockResolvedValueOnce({ message: {} })
798
908
  .mockResolvedValueOnce({
799
909
  message: {
800
910
  btc_test_address: 'main-wallet-state',
@@ -817,16 +927,18 @@ describe('openWalletSession', () => {
817
927
  passphraseState: null,
818
928
  resumed: false,
819
929
  });
820
- expect(typedCall).toHaveBeenCalledTimes(2);
930
+ expect(typedCall).toHaveBeenCalledTimes(3);
821
931
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
822
932
  eventless_wallet_session: true,
823
933
  });
824
- expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
825
- source: 'wallet-session-coordinator',
826
- reason: 'open-wallet',
827
- deviceOnly: true,
934
+ expect(device.unlockDevice).not.toHaveBeenCalled();
935
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
936
+ passphrase: '',
937
+ on_device: false,
938
+ });
939
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
940
+ seed_domains: [],
828
941
  });
829
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
830
942
  expect(promptPassphrase).not.toHaveBeenCalled();
831
943
  expect(device.passphraseState).toBeUndefined();
832
944
  });
@@ -835,6 +947,7 @@ describe('openWalletSession', () => {
835
947
  const typedCall = jest
836
948
  .fn()
837
949
  .mockResolvedValueOnce({ message: { version: 2 } })
950
+ .mockResolvedValueOnce({ message: {} })
838
951
  .mockResolvedValueOnce({
839
952
  message: {
840
953
  btc_test_address: 'main-wallet-state',
@@ -886,6 +999,7 @@ describe('openWalletSession', () => {
886
999
  const typedCall = jest
887
1000
  .fn()
888
1001
  .mockResolvedValueOnce({ message: { version: 2 } })
1002
+ .mockResolvedValueOnce({ message: {} })
889
1003
  .mockResolvedValueOnce({
890
1004
  message: {
891
1005
  btc_test_address: 'main-wallet-state',
@@ -931,7 +1045,9 @@ describe('openWalletSession', () => {
931
1045
  reason: 'open-wallet',
932
1046
  deviceOnly: true,
933
1047
  });
934
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1048
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1049
+ seed_domains: [],
1050
+ });
935
1051
  });
936
1052
 
937
1053
  test('selects a hidden wallet without exposing the internal device session', async () => {
@@ -965,7 +1081,9 @@ describe('openWalletSession', () => {
965
1081
  passphrase: 'host hidden wallet',
966
1082
  on_device: false,
967
1083
  });
968
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1084
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1085
+ seed_domains: [],
1086
+ });
969
1087
  expect(promptPassphrase).toHaveBeenCalled();
970
1088
  expect(device.passphraseState).toBeUndefined();
971
1089
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('hidden-session');
@@ -994,7 +1112,9 @@ describe('openWalletSession', () => {
994
1112
  passphrase: 'host hidden wallet',
995
1113
  on_device: false,
996
1114
  });
997
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1115
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1116
+ seed_domains: [],
1117
+ });
998
1118
  });
999
1119
 
1000
1120
  test('selects an on-device passphrase wallet with an explicit on_device request', async () => {
@@ -1019,7 +1139,9 @@ describe('openWalletSession', () => {
1019
1139
  expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
1020
1140
  on_device: true,
1021
1141
  });
1022
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
1142
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {
1143
+ seed_domains: [],
1144
+ });
1023
1145
  });
1024
1146
 
1025
1147
  test('forwards a host passphrase to Pro2 firmware', async () => {
@@ -1061,7 +1183,9 @@ describe('openWalletSession', () => {
1061
1183
  passphrase: 'host hidden wallet',
1062
1184
  on_device: false,
1063
1185
  });
1064
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {});
1186
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceSessionGet', 'DeviceSession', {
1187
+ seed_domains: [],
1188
+ });
1065
1189
  });
1066
1190
 
1067
1191
  test('normalizes a Unicode Host passphrase before sending it to Pro2 firmware', async () => {
@@ -1141,7 +1265,9 @@ describe('openWalletSession', () => {
1141
1265
  expect(device.unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin, {
1142
1266
  emitUiEvent: false,
1143
1267
  });
1144
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1268
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1269
+ seed_domains: [],
1270
+ });
1145
1271
  });
1146
1272
 
1147
1273
  test('uses the complete Attach PIN wire flow without a main PIN unlock', async () => {
@@ -1170,7 +1296,7 @@ describe('openWalletSession', () => {
1170
1296
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1171
1297
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.AttachToPin }],
1172
1298
  ['DeviceStatusGet', 'DeviceStatus', {}],
1173
- ['DeviceSessionGet', 'DeviceSession', {}],
1299
+ ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1174
1300
  ]);
1175
1301
  expect(device.commands.typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
1176
1302
  type: DeviceSessionPinType.Main,
@@ -1227,7 +1353,7 @@ describe('openWalletSession', () => {
1227
1353
 
1228
1354
  await expect(method.run()).rejects.toThrow(message);
1229
1355
  expect(typedCall).toHaveBeenCalledTimes(2);
1230
- expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1356
+ expect(typedCall.mock.calls.some(([name]) => name === 'DeviceSessionGet')).toBe(false);
1231
1357
  }
1232
1358
  );
1233
1359
 
@@ -1381,6 +1507,8 @@ describe('openWalletSession', () => {
1381
1507
  });
1382
1508
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1383
1509
  session_id: 'known-session',
1510
+ btc_test_address: 'hidden-state',
1511
+ seed_domains: [],
1384
1512
  });
1385
1513
  expect(promptPassphrase).not.toHaveBeenCalled();
1386
1514
  });
@@ -1439,6 +1567,8 @@ describe('openWalletSession', () => {
1439
1567
  expect(device.getDeviceState).toHaveBeenCalledTimes(2);
1440
1568
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1441
1569
  session_id: 'known-session',
1570
+ btc_test_address: 'hidden-state',
1571
+ seed_domains: [],
1442
1572
  });
1443
1573
  const sessionGetCall = typedCall.mock.calls.findIndex(
1444
1574
  ([requestName]) => requestName === 'DeviceSessionGet'
@@ -1535,6 +1665,8 @@ describe('openWalletSession', () => {
1535
1665
  });
1536
1666
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1537
1667
  session_id: 'expired-session',
1668
+ btc_test_address: 'hidden-state',
1669
+ seed_domains: [],
1538
1670
  });
1539
1671
  expect(promptPassphrase).toHaveBeenCalledTimes(1);
1540
1672
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('renewed-session');
@@ -1570,7 +1702,10 @@ describe('openWalletSession', () => {
1570
1702
  passphraseState: 'hidden-state',
1571
1703
  resumed: false,
1572
1704
  });
1573
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1705
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1706
+ btc_test_address: 'hidden-state',
1707
+ seed_domains: [],
1708
+ });
1574
1709
  expect(promptPassphrase).not.toHaveBeenCalled();
1575
1710
  expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('new-session');
1576
1711
  });
@@ -1604,6 +1739,11 @@ describe('openWalletSession', () => {
1604
1739
  walletType: 'hidden',
1605
1740
  passphraseState: 'hidden-state',
1606
1741
  });
1607
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1742
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1743
+ seed_domains: [
1744
+ DeviceSessionSeedDomain.SeedDomain_Standard,
1745
+ ...(deriveCardano ? [DeviceSessionSeedDomain.SeedDomain_Cardano] : []),
1746
+ ],
1747
+ });
1608
1748
  });
1609
1749
  });
@@ -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 = {