@onekeyfe/hd-core 1.2.2-alpha.100 → 1.2.2-alpha.3

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 (69) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +20 -4
  2. package/__tests__/DeviceCommands.test.ts +13 -0
  3. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +0 -2
  4. package/__tests__/check-firmware-release-protocol-v2.test.ts +0 -2
  5. package/__tests__/device-lifecycle-events.test.ts +19 -0
  6. package/__tests__/deviceUploadNft.test.ts +68 -0
  7. package/__tests__/evmSignTypedData.test.ts +42 -0
  8. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +825 -12
  9. package/__tests__/open-wallet-session.test.ts +144 -37
  10. package/__tests__/pro2HostAssetPackage.test.ts +58 -0
  11. package/__tests__/protocol-v2-resources.test.ts +7 -4
  12. package/__tests__/protocol-v2-unlock-policy.test.ts +35 -1
  13. package/__tests__/protocol-v2.test.ts +720 -51
  14. package/dist/api/FirmwareUpdateV4.d.ts +4 -0
  15. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  16. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  17. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  18. package/dist/api/evm/EVMSignTypedData.d.ts.map +1 -1
  19. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +2 -2
  20. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  21. package/dist/api/firmware/getBinary.d.ts +2 -2
  22. package/dist/api/protocol-v2/DeviceGetFirmwareUpdateStatus.d.ts.map +1 -1
  23. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  24. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  25. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  26. package/dist/core/index.d.ts +1 -0
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/device/Device.d.ts +1 -1
  29. package/dist/device/Device.d.ts.map +1 -1
  30. package/dist/device/DeviceConnector.d.ts +1 -1
  31. package/dist/device/DeviceConnector.d.ts.map +1 -1
  32. package/dist/events/ui-request.d.ts +3 -0
  33. package/dist/events/ui-request.d.ts.map +1 -1
  34. package/dist/index.d.ts +21 -5
  35. package/dist/index.js +2288 -1439
  36. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  37. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +2 -0
  38. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -1
  39. package/dist/protocols/protocol-v2/walletSession.d.ts +3 -0
  40. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  41. package/dist/types/settings.d.ts +2 -2
  42. package/dist/types/settings.d.ts.map +1 -1
  43. package/dist/utils/deviceFeaturesUtils.d.ts +3 -0
  44. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  45. package/dist/utils/patch.d.ts +1 -1
  46. package/dist/utils/patch.d.ts.map +1 -1
  47. package/dist/utils/pro2HostAssetPackage.d.ts +8 -0
  48. package/dist/utils/pro2HostAssetPackage.d.ts.map +1 -0
  49. package/package.json +4 -4
  50. package/src/api/FirmwareUpdateV4.ts +434 -75
  51. package/src/api/OpenWalletSession.ts +39 -9
  52. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +5 -2
  53. package/src/api/evm/EVMSignTypedData.ts +1 -0
  54. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +9 -3
  55. package/src/api/protocol-v2/DeviceGetFirmwareUpdateStatus.ts +7 -1
  56. package/src/api/protocol-v2/DeviceUploadNft.ts +31 -8
  57. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +27 -8
  58. package/src/api/protocol-v2/helpers.ts +8 -0
  59. package/src/core/index.ts +130 -14
  60. package/src/data/messages/messages-protocol-v2.json +1541 -1332
  61. package/src/device/Device.ts +3 -1
  62. package/src/device/DeviceConnector.ts +7 -3
  63. package/src/events/ui-request.ts +3 -0
  64. package/src/protocols/protocol-v2/resources.ts +9 -1
  65. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +8 -1
  66. package/src/protocols/protocol-v2/walletSession.ts +88 -50
  67. package/src/types/settings.ts +4 -2
  68. package/src/utils/deviceFeaturesUtils.ts +4 -0
  69. package/src/utils/pro2HostAssetPackage.ts +354 -0
@@ -29,6 +29,12 @@ const wasResumed = (session: unknown) =>
29
29
  'resumed' in session &&
30
30
  (session as { resumed?: unknown }).resumed === true;
31
31
 
32
+ const wasWalletStatusRefreshed = (session: unknown) =>
33
+ !!session &&
34
+ typeof session === 'object' &&
35
+ 'walletStatusRefreshed' in session &&
36
+ (session as { walletStatusRefreshed?: unknown }).walletStatusRefreshed === true;
37
+
32
38
  const requireHiddenWalletResponse = (session: { passphraseState?: string }) => {
33
39
  if (!session.passphraseState) {
34
40
  throw ERRORS.TypedError(
@@ -87,12 +93,20 @@ export default class OpenWalletSession extends BaseMethod<OpenWalletSessionParam
87
93
  this.unlockPolicy = 'unlock-before-run';
88
94
  this.skipForceUpdateCheck = true;
89
95
  this.params = normalizeParams(this.payload as unknown as Record<string, unknown>);
96
+ this.protocolV2PreUnlockPinType =
97
+ this.params.mode === OpenWalletSessionMode.SelectHidden
98
+ ? DeviceSessionPinType.Any
99
+ : DeviceSessionPinType.Main;
90
100
  this.payload.useEmptyPassphrase = this.params.mode === OpenWalletSessionMode.Standard;
91
101
  }
92
102
 
93
103
  async run(): Promise<OpenWalletSessionPayload> {
94
104
  const isProtocolV2 = this.device.isProtocolV2();
95
- let state = await this.device.getDeviceState({ refreshSections: ['status'] });
105
+ const reusePreflightStatus =
106
+ isProtocolV2 && this.protocolV2UnlockContext?.preflightStatusRefreshed === true;
107
+ let state = reusePreflightStatus
108
+ ? await this.device.getDeviceState()
109
+ : await this.device.getDeviceState({ refreshSections: ['status'] });
96
110
  let currentDeviceId = state.identity.deviceId;
97
111
  const hasAuthoritativeProtocolV2WalletStatus = (candidate: typeof state) =>
98
112
  candidate.status.unlocked === true &&
@@ -115,14 +129,25 @@ export default class OpenWalletSession extends BaseMethod<OpenWalletSessionParam
115
129
  currentDeviceId = state.identity.deviceId;
116
130
  return state;
117
131
  };
132
+ const resolveProtocolV2DeviceStateAfterSession = async (session: unknown) => {
133
+ if (!wasWalletStatusRefreshed(session)) {
134
+ return refreshProtocolV2DeviceState();
135
+ }
136
+ state = await this.device.getDeviceState();
137
+ currentDeviceId = state.identity.deviceId;
138
+ return state;
139
+ };
118
140
  const ensureProtocolV2WalletStatus = async () => {
119
141
  if (isProtocolV2 && !hasAuthoritativeProtocolV2WalletStatus(state)) {
120
- await this.device.unlockDevice(DeviceSessionPinType.Main, {
121
- source: 'unlock-coordinator',
122
- reason: 'device-locked',
123
- deviceOnly: true,
124
- method: 'openWalletSession',
125
- });
142
+ await this.device.unlockDevice(
143
+ this.protocolV2PreUnlockPinType ?? DeviceSessionPinType.Main,
144
+ {
145
+ source: 'unlock-coordinator',
146
+ reason: 'device-locked',
147
+ deviceOnly: true,
148
+ method: 'openWalletSession',
149
+ }
150
+ );
126
151
  requireAuthoritativeProtocolV2WalletStatus(await refreshProtocolV2DeviceState());
127
152
  }
128
153
  return state;
@@ -139,13 +164,16 @@ export default class OpenWalletSession extends BaseMethod<OpenWalletSessionParam
139
164
  selectMainWalletBeforeRestore:
140
165
  !hasAuthoritativeProtocolV2WalletStatus(state) ||
141
166
  state.status.unlockedAttachPin === true,
167
+ mainPinSelected: this.protocolV2UnlockContext?.preflightMainPinSelected,
142
168
  })
143
169
  : await getPassphraseStateWithRefreshDeviceInfo(this.device, {
144
170
  onlyMainPin: true,
145
171
  initSession: this.payload.initSession,
146
172
  });
147
173
  const refreshedState = isProtocolV2
148
- ? requireAuthoritativeProtocolV2WalletStatus(await refreshProtocolV2DeviceState())
174
+ ? requireAuthoritativeProtocolV2WalletStatus(
175
+ await resolveProtocolV2DeviceStateAfterSession(session)
176
+ )
149
177
  : state;
150
178
  const deviceId = requireDeviceId();
151
179
  if (
@@ -233,7 +261,9 @@ export default class OpenWalletSession extends BaseMethod<OpenWalletSessionParam
233
261
  })
234
262
  : await getPassphraseStateWithRefreshDeviceInfo(this.device, { initSession: true });
235
263
  const refreshedState = isProtocolV2
236
- ? requireAuthoritativeProtocolV2WalletStatus(await refreshProtocolV2DeviceState())
264
+ ? requireAuthoritativeProtocolV2WalletStatus(
265
+ await resolveProtocolV2DeviceStateAfterSession(session)
266
+ )
237
267
  : state;
238
268
  const deviceId = requireDeviceId();
239
269
  if (isProtocolV2 && refreshedState.status.passphraseProtection !== true) {
@@ -393,14 +393,17 @@ export default abstract class AllNetworkGetAddressBase extends BaseMethod<
393
393
  // the root fingerprint, so each nested chain method must resume the
394
394
  // requested standard or hidden wallet before sending its device command.
395
395
  const useEmptyPassphrase = this.payload.useEmptyPassphrase === true;
396
- const deriveCardano = method.name.startsWith('cardano');
396
+ // false would AskPassphrase with [Standard] only and permanently drop Cardano
397
+ // from a reused session. Omit the flag for non-Cardano nested methods.
398
+ const deriveCardano = method.name.startsWith('cardano') ? true : undefined;
397
399
  const shouldResumeWalletSession = useEmptyPassphrase || !!this.payload.passphraseState;
398
400
  if (this.device.isProtocolV2() && shouldResumeWalletSession) {
399
401
  const passphraseStateSafety = await this.device.checkPassphraseStateSafety(
400
402
  this.payload.passphraseState,
401
403
  useEmptyPassphrase,
402
404
  this.payload.skipPassphraseCheck,
403
- deriveCardano
405
+ deriveCardano,
406
+ this.protocolV2UnlockContext?.preflightMainPinSelected
404
407
  );
405
408
  if (!passphraseStateSafety) {
406
409
  throw ERRORS.TypedError(HardwareErrorCode.DeviceCheckPassphraseStateError);
@@ -360,6 +360,7 @@ export default class EVMSignTypedData extends BaseMethod<EVMSignTypedDataParams>
360
360
 
361
361
  const supportBiggerData =
362
362
  DeviceModelToTypes.model_classic1s.includes(currentDeviceType) ||
363
+ DeviceModelToTypes.model_pro2.includes(currentDeviceType) ||
363
364
  (DeviceModelToTypes.model_touch.includes(currentDeviceType) &&
364
365
  semver.gte(currentVersion, supportBiggerDataVersion));
365
366
 
@@ -41,9 +41,15 @@ const FIRMWARE_UPDATE_CONFIRM = 'Firmware install confirmed';
41
41
  */
42
42
  export const BOOTLOADER_POLL_INITIALIZE_TIMEOUT_MS = 5000;
43
43
 
44
- type FirmwareTransferMetrics = Pick<
44
+ type FirmwareProgressMetadata = Pick<
45
45
  FirmwareProgress['payload'],
46
- 'transferredBytes' | 'totalBytes' | 'rateBytesPerSecond' | 'elapsedMs'
46
+ | 'transferredBytes'
47
+ | 'totalBytes'
48
+ | 'rateBytesPerSecond'
49
+ | 'elapsedMs'
50
+ | 'installTargetId'
51
+ | 'installPhase'
52
+ | 'installPhaseProgress'
47
53
  >;
48
54
 
49
55
  const isDeviceDisconnectedError = (error: unknown) => {
@@ -103,7 +109,7 @@ export class FirmwareUpdateBaseMethod<Params> extends BaseMethod<Params> {
103
109
  postProgressMessage = (
104
110
  progress: number,
105
111
  progressType: IFirmwareUpdateProgressType,
106
- metrics?: FirmwareTransferMetrics
112
+ metrics?: FirmwareProgressMetadata
107
113
  ) => {
108
114
  this.postMessage(
109
115
  createUiMessage(UI_REQUEST.FIRMWARE_PROGRESS, {
@@ -4,7 +4,13 @@ import { invalidParameter } from '../helpers/filesystemValidation';
4
4
  import type { DeviceFirmwareUpdateStatusGetParams } from './helpers';
5
5
  import type { DeviceFirmwareUpdateRecordFields } from '@onekeyfe/hd-transport';
6
6
 
7
- const DEVICE_FIRMWARE_UPDATE_STATUS_FIELDS = ['status', 'payload_version', 'path'] as const;
7
+ const DEVICE_FIRMWARE_UPDATE_STATUS_FIELDS = [
8
+ 'status',
9
+ 'progress_percent',
10
+ 'phase_info',
11
+ 'payload_version',
12
+ 'path',
13
+ ] as const;
8
14
 
9
15
  function normalizeStatusFields(
10
16
  fields: DeviceFirmwareUpdateStatusGetParams['fields']
@@ -20,6 +20,10 @@ import {
20
20
  getCompletePro2NftBasenames,
21
21
  } from '../../utils/pro2Nft';
22
22
  import { encodePro2Image } from '../../utils/pro2Wallpaper';
23
+ import {
24
+ buildPro2HostAssetPackage,
25
+ supportsPro2HostAssetPackage,
26
+ } from '../../utils/pro2HostAssetPackage';
23
27
  import { BaseMethod } from '../BaseMethod';
24
28
  import { decodeJpegBase64ToRgba } from '../helpers/base64Data';
25
29
  import { invalidParameter } from '../helpers/filesystemValidation';
@@ -38,8 +42,11 @@ export type DeviceUploadNftParams = {
38
42
 
39
43
  export type DeviceUploadNftResponse = {
40
44
  basename: string;
45
+ /** Final image path after firmware extracts a host-asset package. */
41
46
  imagePath: string;
47
+ /** Final thumbnail path after firmware extracts a host-asset package. */
42
48
  thumbnailPath: string;
49
+ /** Final metadata path after firmware extracts a host-asset package. */
43
50
  metadataPath: string;
44
51
  totalSize: number;
45
52
  nftUpdated: true;
@@ -138,7 +145,7 @@ export default class DeviceUploadNft extends BaseMethod<DeviceUploadNftParams> {
138
145
  this.useDevicePassphraseState = false;
139
146
  }
140
147
 
141
- private async assertCapabilities() {
148
+ private async assertCapabilities(useHostAssetPackage: boolean) {
142
149
  const protocolInfo = await this.device.ensureProtocolV2RuntimeContext();
143
150
  const hasFileWrite = supportsProtocolV2Message(
144
151
  protocolInfo,
@@ -150,7 +157,7 @@ export default class DeviceUploadNft extends BaseMethod<DeviceUploadNftParams> {
150
157
  );
151
158
  const hasDirList = supportsProtocolV2Message(protocolInfo, FILESYSTEM_DIR_LIST_MESSAGE_TYPE);
152
159
  const hasNftUpdate = supportsProtocolV2Message(protocolInfo, NFT_UPDATE_MESSAGE_TYPE);
153
- if (!hasFileWrite || !hasPathInfo || !hasDirList || !hasNftUpdate) {
160
+ if (!hasFileWrite || !hasNftUpdate || (!useHostAssetPackage && (!hasPathInfo || !hasDirList))) {
154
161
  throw createDeviceNotSupportMethodError(this.name, this.device.getCurrentFirmwareType());
155
162
  }
156
163
  }
@@ -192,16 +199,32 @@ export default class DeviceUploadNft extends BaseMethod<DeviceUploadNftParams> {
192
199
  const { bundle } = this;
193
200
  if (!bundle) throw invalidParameter('NFT data has not been initialized.');
194
201
 
195
- await this.assertCapabilities();
202
+ const useHostAssetPackage = supportsPro2HostAssetPackage(
203
+ this.device.state?.versions.firmware ?? undefined
204
+ );
205
+ await this.assertCapabilities(useHostAssetPackage);
196
206
  this.throwIfAborted();
197
- await this.assertStorageCapacity(bundle.basename);
207
+ if (!useHostAssetPackage) await this.assertStorageCapacity(bundle.basename);
198
208
  this.throwIfAborted();
199
209
 
200
- const files = [
210
+ const extractedFiles = [
201
211
  { path: `${PRO2_NFT_DIRECTORY}/${bundle.basename}.bin`, data: bundle.image },
202
212
  { path: `${PRO2_NFT_DIRECTORY}/${bundle.basename}_m.bin`, data: bundle.thumbnail },
203
213
  { path: `${PRO2_NFT_DIRECTORY}/${bundle.basename}.json`, data: bundle.metadata },
204
214
  ];
215
+ const files = useHostAssetPackage
216
+ ? [
217
+ {
218
+ path: `${PRO2_NFT_DIRECTORY}/${bundle.basename}.okpkg`,
219
+ data: buildPro2HostAssetPackage(
220
+ extractedFiles.map(file => ({
221
+ name: file.path.slice(`${PRO2_NFT_DIRECTORY}/`.length),
222
+ data: file.data,
223
+ }))
224
+ ),
225
+ },
226
+ ]
227
+ : extractedFiles;
205
228
  const totalSize = files.reduce((sum, file) => sum + file.data.byteLength, 0);
206
229
  let transferredBeforeFile = 0;
207
230
 
@@ -238,9 +261,9 @@ export default class DeviceUploadNft extends BaseMethod<DeviceUploadNftParams> {
238
261
  const response = await this.updateNft(bundle.basename);
239
262
  return {
240
263
  basename: bundle.basename,
241
- imagePath: files[0].path,
242
- thumbnailPath: files[1].path,
243
- metadataPath: files[2].path,
264
+ imagePath: extractedFiles[0].path,
265
+ thumbnailPath: extractedFiles[1].path,
266
+ metadataPath: extractedFiles[2].path,
244
267
  totalSize,
245
268
  nftUpdated: true,
246
269
  message: response.message?.message,
@@ -16,14 +16,26 @@ import {
16
16
  type Pro2WallpaperColorFormat,
17
17
  encodePro2Wallpaper,
18
18
  } from '../../utils/pro2Wallpaper';
19
+ import {
20
+ buildPro2HostAssetPackage,
21
+ supportsPro2HostAssetPackage,
22
+ } from '../../utils/pro2HostAssetPackage';
19
23
 
20
24
  export type DeviceUploadWallpaperParams = {
21
25
  jpegBase64: string;
26
+ /**
27
+ * Legacy firmware uses this name for the uploaded `.bin` file. Firmware
28
+ * 1.0.1+ always consumes the fixed `wallpaper.okpkg` package path.
29
+ */
22
30
  fileName?: string;
23
31
  chunkSize?: number;
24
32
  };
25
33
 
26
34
  export type DeviceUploadWallpaperResponse = {
35
+ /**
36
+ * Filesystem path sent to `DeviceSettingsSet`. On firmware 1.0.1+ this is
37
+ * the temporary package path; firmware extracts and persists wallpaper.bin.
38
+ */
27
39
  path: string;
28
40
  size: number;
29
41
  colorFormat: Pro2WallpaperColorFormat;
@@ -31,6 +43,8 @@ export type DeviceUploadWallpaperResponse = {
31
43
  };
32
44
 
33
45
  const WALLPAPER_DIRECTORY = 'vol1:/wallpapers';
46
+ const WALLPAPER_PACKAGE_PATH = `${WALLPAPER_DIRECTORY}/wallpaper.okpkg`;
47
+ const WALLPAPER_PACKAGE_ENTRY = 'wallpaper.bin';
34
48
  const SAFE_FILE_NAME = /^[A-Za-z0-9_-]+(?:\.bin)?$/;
35
49
  const DEVICE_SETTINGS_SET_MESSAGE_TYPE = 60412;
36
50
  const FILESYSTEM_FILE_WRITE_MESSAGE_TYPE = 60805;
@@ -116,16 +130,14 @@ export default class DeviceUploadWallpaper extends BaseMethod<DeviceUploadWallpa
116
130
  this.directoryReady = true;
117
131
  }
118
132
 
119
- private async upload() {
133
+ private async upload(path: string, data: Uint8Array) {
120
134
  if (this.uploaded) return;
121
- const { encoded } = this;
122
- if (!encoded) throw invalidParameter('Wallpaper data has not been initialized.');
123
135
 
124
136
  await writeProtocolV2File({
125
137
  commands: this.device.commands,
126
- path: this.path,
127
- data: encoded.data,
128
- totalSize: encoded.data.byteLength,
138
+ path,
139
+ data,
140
+ totalSize: data.byteLength,
129
141
  chunkSize: this.params.chunkSize,
130
142
  maxChunkRetries: 3,
131
143
  overwrite: true,
@@ -145,7 +157,14 @@ export default class DeviceUploadWallpaper extends BaseMethod<DeviceUploadWallpa
145
157
  if (!encoded) throw invalidParameter('Wallpaper data has not been initialized.');
146
158
  await this.assertCapabilities();
147
159
  await this.ensureDirectory();
148
- await this.upload();
160
+ const useHostAssetPackage = supportsPro2HostAssetPackage(
161
+ this.device.state?.versions.firmware ?? undefined
162
+ );
163
+ const data = useHostAssetPackage
164
+ ? buildPro2HostAssetPackage([{ name: WALLPAPER_PACKAGE_ENTRY, data: encoded.data }])
165
+ : encoded.data;
166
+ if (useHostAssetPackage) this.path = WALLPAPER_PACKAGE_PATH;
167
+ await this.upload(this.path, data);
149
168
  const response = await this.device.commands.typedCall('DeviceSettingsSet', 'Success', {
150
169
  settings: { wallpaper_path: this.path },
151
170
  });
@@ -160,7 +179,7 @@ export default class DeviceUploadWallpaper extends BaseMethod<DeviceUploadWallpa
160
179
  }
161
180
  return {
162
181
  path: this.path,
163
- size: encoded.data.byteLength,
182
+ size: data.byteLength,
164
183
  colorFormat: encoded.colorFormat,
165
184
  message: response.message?.message,
166
185
  };
@@ -1,4 +1,5 @@
1
1
  import { DeviceRebootType } from '@onekeyfe/hd-transport';
2
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
3
 
3
4
  import { invalidParameter, validateNonEmptyString } from '../helpers/filesystemValidation';
4
5
  import { ProtocolV2FirmwareTargetType } from '../../protocols/protocol-v2/firmware';
@@ -103,6 +104,13 @@ export const getProtocolV2UnknownErrorText = (error: unknown) => {
103
104
  };
104
105
 
105
106
  export const isProtocolV2DeviceDisconnectedError = (error: unknown) => {
107
+ const errorCode =
108
+ error && typeof error === 'object'
109
+ ? (error as { errorCode?: number; code?: number }).errorCode ??
110
+ (error as { errorCode?: number; code?: number }).code
111
+ : undefined;
112
+ if (errorCode === HardwareErrorCode.BleDeviceDisconnected) return true;
113
+
106
114
  const message = getProtocolV2UnknownErrorText(error).toLowerCase();
107
115
  const compactMessage = message.replace(/\s+/g, '');
108
116
  return (
package/src/core/index.ts CHANGED
@@ -92,7 +92,7 @@ const preWarmDoneAt = new Map<string, number>();
92
92
 
93
93
  export type CoreContext = ReturnType<Core['getCoreContext']>;
94
94
 
95
- function hasDeriveCardano(method: BaseMethod): boolean {
95
+ function resolveDeriveCardano(method: BaseMethod): boolean | undefined {
96
96
  if (
97
97
  method.name.startsWith('allNetworkGetAddress') &&
98
98
  method.payload &&
@@ -102,15 +102,20 @@ function hasDeriveCardano(method: BaseMethod): boolean {
102
102
  ) {
103
103
  return true;
104
104
  }
105
-
106
- return method.name.startsWith('cardano') || method.payload?.deriveCardano;
105
+ if (method.name.startsWith('cardano')) {
106
+ return true;
107
+ }
108
+ if (typeof method.payload?.deriveCardano === 'boolean') {
109
+ return method.payload.deriveCardano;
110
+ }
111
+ return undefined;
107
112
  }
108
113
 
109
114
  const parseInitOptions = (method?: BaseMethod): InitOptions => ({
110
115
  initSession: method?.payload.initSession,
111
116
  passphraseState: method?.payload.useEmptyPassphrase ? undefined : method?.payload.passphraseState,
112
117
  deviceId: method?.payload.deviceId,
113
- deriveCardano: method && hasDeriveCardano(method),
118
+ deriveCardano: method ? resolveDeriveCardano(method) : undefined,
114
119
  connectProtocol: method?.payload.connectProtocol,
115
120
  forceProtocolDetection: method?.payload.forceProtocolDetection,
116
121
  protocolV2DeviceInfoTimeoutMs: method?.payload.protocolV2DeviceInfoTimeoutMs,
@@ -415,6 +420,13 @@ const onCallDevice = async (
415
420
  if (method.payload?.onlyConnectBleDevice) {
416
421
  preWarmCallbackTask?.resolve();
417
422
  Log.debug('Call API - only connect ble device: ', device?.mainId);
423
+ // This early return bypasses the normal-path bookkeeping at the end of the
424
+ // call. Without it the task leaks and haunts every later queue snapshot
425
+ // and cancel sweep (field log: a completed task lingered for 6 minutes),
426
+ // and the request stays in the active maps, so repeated preconnects pile
427
+ // up phantom work in diagnostics.
428
+ completeMethodRequestContext(method);
429
+ requestQueue.releaseTask(method.responseID);
418
430
  return createResponseMessage(method.responseID, true, null);
419
431
  }
420
432
 
@@ -644,7 +656,8 @@ const onCallDevice = async (
644
656
  method.payload?.passphraseState,
645
657
  method.payload?.useEmptyPassphrase,
646
658
  method.payload?.skipPassphraseCheck,
647
- hasDeriveCardano(method)
659
+ resolveDeriveCardano(method),
660
+ method.protocolV2UnlockContext?.preflightMainPinSelected
648
661
  );
649
662
 
650
663
  // Double check, handles the special case of Touch/Pro
@@ -950,11 +963,72 @@ export function isMissingDetectedProtocolV2Error(method: BaseMethod, error: unkn
950
963
  );
951
964
  }
952
965
 
966
+ export function isProtocolV2PeerRemovedPairingError(method: BaseMethod, error: unknown) {
967
+ return (
968
+ method.payload.connectProtocol === 'V2' &&
969
+ (error as { errorCode?: unknown })?.errorCode ===
970
+ HardwareErrorCode.BlePeerRemovedPairingInformation
971
+ );
972
+ }
973
+
953
974
  /**
954
975
  * If the Bluetooth connection times out, retry up to 6 times
955
976
  * @param retryCount - Current retry count (default 0)
956
977
  */
957
- async function connectDeviceForBle(method: BaseMethod, device: Device, retryCount = 0) {
978
+ // device.acquire awaits a transport reply with no deadline of its own; a
979
+ // transport that never settles (field case: Electron main lost an IPC reply,
980
+ // "reply was never sent" after 5 minutes) hangs the call forever and cancel()
981
+ // only takes effect at poll checkpoints. Race acquire against a deadline and
982
+ // the caller's abort signal so the hang is bounded and cancel is immediate.
983
+ const BLE_ACQUIRE_DEADLINE_MS = 60 * 1000;
984
+
985
+ function raceBleAcquire<T>(acquirePromise: Promise<T>, abortSignal?: AbortSignal): Promise<T> {
986
+ return new Promise<T>((resolve, reject) => {
987
+ let settled = false;
988
+ const settle = (fn: () => void) => {
989
+ if (settled) return;
990
+ settled = true;
991
+ clearTimeout(deadline);
992
+ abortSignal?.removeEventListener('abort', onAbort);
993
+ fn();
994
+ };
995
+ const onAbort = () =>
996
+ settle(() => reject(ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled)));
997
+ const deadline = setTimeout(
998
+ () =>
999
+ settle(() =>
1000
+ reject(
1001
+ ERRORS.TypedError(
1002
+ HardwareErrorCode.BleTimeoutError,
1003
+ `BLE acquire exceeded ${BLE_ACQUIRE_DEADLINE_MS}ms deadline`
1004
+ )
1005
+ )
1006
+ ),
1007
+ BLE_ACQUIRE_DEADLINE_MS
1008
+ );
1009
+ // Attach before any early return so a late settlement of acquirePromise
1010
+ // is always consumed — an abort or deadline must never leave the acquire
1011
+ // rejection unhandled.
1012
+ acquirePromise.then(
1013
+ value => settle(() => resolve(value)),
1014
+ error => settle(() => reject(error))
1015
+ );
1016
+ if (abortSignal) {
1017
+ if (abortSignal.aborted) {
1018
+ onAbort();
1019
+ return;
1020
+ }
1021
+ abortSignal.addEventListener('abort', onAbort);
1022
+ }
1023
+ });
1024
+ }
1025
+
1026
+ async function connectDeviceForBle(
1027
+ method: BaseMethod,
1028
+ device: Device,
1029
+ abortSignal?: AbortSignal,
1030
+ retryCount = 0
1031
+ ) {
958
1032
  try {
959
1033
  if (device.wasInterruptedByUser()) {
960
1034
  throw ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
@@ -968,9 +1042,43 @@ async function connectDeviceForBle(method: BaseMethod, device: Device, retryCoun
968
1042
  !device.commands ||
969
1043
  device.commands.disposed;
970
1044
  if (shouldAcquire) {
971
- await device.acquire(method.payload.connectProtocol, {
972
- forceProtocolDetection: method.payload.forceProtocolDetection,
973
- });
1045
+ // The deadline/abort guards are scoped to the desktop electron
1046
+ // transport: its IPC acquire is the only path with a proven
1047
+ // never-settling failure mode, while react-native/lowlevel acquire may
1048
+ // legitimately block on a user-driven system bonding prompt for longer
1049
+ // than any sane deadline. Other envs keep the plain acquire unchanged.
1050
+ const useAcquireGuards = DataManager.getSettings('env') === 'desktop-web-ble';
1051
+ // A cancel landing during the retry backoff must not start a new acquire.
1052
+ if (useAcquireGuards && abortSignal?.aborted) {
1053
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
1054
+ }
1055
+ if (!useAcquireGuards) {
1056
+ await device.acquire(method.payload.connectProtocol, {
1057
+ forceProtocolDetection: method.payload.forceProtocolDetection,
1058
+ });
1059
+ } else {
1060
+ try {
1061
+ await raceBleAcquire(
1062
+ device.acquire(method.payload.connectProtocol, {
1063
+ forceProtocolDetection: method.payload.forceProtocolDetection,
1064
+ }),
1065
+ abortSignal
1066
+ );
1067
+ } catch (err) {
1068
+ // A deadline hit means the transport is wedged mid-acquire; drop the
1069
+ // link before the retry so it cold-connects instead of stacking a
1070
+ // second connect onto the half-open one.
1071
+ if (
1072
+ err.errorCode === HardwareErrorCode.BleTimeoutError &&
1073
+ device.mainId &&
1074
+ device.deviceConnector
1075
+ ) {
1076
+ await device.deviceConnector.disconnect(device.mainId).catch(() => undefined);
1077
+ device.markTransportDisconnected();
1078
+ }
1079
+ throw err;
1080
+ }
1081
+ }
974
1082
  }
975
1083
  if (method.payload?.onlyConnectBleDevice) {
976
1084
  if (shouldAcquire) {
@@ -1010,7 +1118,7 @@ async function connectDeviceForBle(method: BaseMethod, device: Device, retryCoun
1010
1118
  const nextRetry = retryCount + 1;
1011
1119
  Log.debug(`Bluetooth connection will retry, retry count: ${nextRetry}`);
1012
1120
  await wait(3000);
1013
- await connectDeviceForBle(method, device, nextRetry);
1121
+ await connectDeviceForBle(method, device, abortSignal, nextRetry);
1014
1122
  } else {
1015
1123
  throw err;
1016
1124
  }
@@ -1120,7 +1228,7 @@ const ensureConnected = async (
1120
1228
  if (tryCount === 1) {
1121
1229
  device.beginConnectionAttempt();
1122
1230
  }
1123
- await connectDeviceForBle(method, device);
1231
+ await connectDeviceForBle(method, device, abortSignal);
1124
1232
  }
1125
1233
  resolve(device);
1126
1234
  return;
@@ -1164,7 +1272,8 @@ const ensureConnected = async (
1164
1272
  HardwareErrorCode.BridgeNeedsPermission,
1165
1273
  HardwareErrorCode.DeviceInterruptedFromUser,
1166
1274
  HardwareErrorCode.CallQueueActionCancelled,
1167
- ].includes(error.errorCode)
1275
+ ].includes(error.errorCode) ||
1276
+ isProtocolV2PeerRemovedPairingError(method, error)
1168
1277
  ) {
1169
1278
  reject(error);
1170
1279
  return;
@@ -1220,6 +1329,9 @@ export const cancel = (context: CoreContext, connectId?: string) => {
1220
1329
  ','
1221
1330
  )}`
1222
1331
  );
1332
+ // Abort before rejecting: rejectRequest releases the task and would make
1333
+ // its AbortController unreachable to an in-flight method loop.
1334
+ requestQueue.abortRequestsByConnectId(connectId);
1223
1335
  const canceledDevices: Device[] = [];
1224
1336
  const interruptDevice = (device: Device | undefined, deviceConnectId: string) => {
1225
1337
  if (!device || canceledDevices.includes(device)) {
@@ -1244,7 +1356,6 @@ export const cancel = (context: CoreContext, connectId?: string) => {
1244
1356
  }
1245
1357
  }
1246
1358
  interruptDevice(deviceCacheMap.get(connectId), connectId);
1247
- requestQueue.abortRequestsByConnectId(connectId);
1248
1359
  pollingManager.stop(connectId);
1249
1360
  } catch (e) {
1250
1361
  Log.error('Cancel API Error: ', e);
@@ -1253,6 +1364,8 @@ export const cancel = (context: CoreContext, connectId?: string) => {
1253
1364
  const env = DataManager.getSettings('env');
1254
1365
  if (DataManager.isBleConnect(env)) {
1255
1366
  Log.debug('Cancel Api all _deviceList: ');
1367
+ // Keep method abort signals observable until every active task is rejected.
1368
+ requestQueue.abortAllRequests();
1256
1369
  const canceledDevices: Device[] = [];
1257
1370
  const interruptDevice = (device?: Device) => {
1258
1371
  if (!device || canceledDevices.includes(device)) {
@@ -1277,7 +1390,6 @@ export const cancel = (context: CoreContext, connectId?: string) => {
1277
1390
  }
1278
1391
  }
1279
1392
  deviceCacheMap.forEach(interruptDevice);
1280
- requestQueue.abortAllRequests();
1281
1393
  pollingManager.stopAll();
1282
1394
  } else {
1283
1395
  _deviceList?.allDevices().forEach(device => {
@@ -1328,6 +1440,10 @@ const checkPassphraseEnableState = (method: BaseMethod, features?: Features) =>
1328
1440
  const shouldCheckPassphraseState = (method: BaseMethod, device: Device) => {
1329
1441
  if (!method.useDevicePassphraseState) return false;
1330
1442
 
1443
+ if (device.isProtocolV2() && method.payload?.useEmptyPassphrase === true) {
1444
+ return true;
1445
+ }
1446
+
1331
1447
  return device.hasUsePassphrase();
1332
1448
  };
1333
1449