@onekeyfe/hd-core 1.2.2-alpha.105 → 1.2.2-alpha.106

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 (34) hide show
  1. package/README.md +1 -1
  2. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +2 -2
  3. package/__tests__/core-error-output.test.ts +92 -1
  4. package/__tests__/device-lifecycle-events.test.ts +313 -2
  5. package/__tests__/open-wallet-session-error-response.test.ts +2 -2
  6. package/__tests__/open-wallet-session.test.ts +8 -411
  7. package/__tests__/public-device-state-api.test.ts +2 -7
  8. package/dist/api/GetFeatures.d.ts.map +1 -1
  9. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  10. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  11. package/dist/core/RequestQueue.d.ts +1 -0
  12. package/dist/core/RequestQueue.d.ts.map +1 -1
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/index.d.ts.map +1 -1
  15. package/dist/device/Device.d.ts +2 -0
  16. package/dist/device/Device.d.ts.map +1 -1
  17. package/dist/index.d.ts +16 -18
  18. package/dist/index.js +156 -124
  19. package/dist/types/api/getFeatures.d.ts.map +1 -1
  20. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  21. package/dist/types/api/openWalletSession.d.ts +1 -10
  22. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  23. package/dist/types/params.d.ts.map +1 -1
  24. package/package.json +4 -4
  25. package/src/api/GetFeatures.ts +1 -0
  26. package/src/api/GetPassphraseState.ts +1 -0
  27. package/src/api/OpenWalletSession.ts +7 -77
  28. package/src/core/RequestQueue.ts +20 -0
  29. package/src/core/index.ts +95 -42
  30. package/src/device/Device.ts +51 -27
  31. package/src/types/api/getFeatures.ts +2 -1
  32. package/src/types/api/getPassphraseState.ts +2 -5
  33. package/src/types/api/openWalletSession.ts +7 -19
  34. package/src/types/params.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-core",
3
- "version": "1.2.2-alpha.105",
3
+ "version": "1.2.2-alpha.106",
4
4
  "description": "Core processes and APIs for communicating with OneKey hardware devices.",
5
5
  "author": "OneKey",
6
6
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
@@ -25,8 +25,8 @@
25
25
  "url": "https://github.com/OneKeyHQ/hardware-js-sdk/issues"
26
26
  },
27
27
  "dependencies": {
28
- "@onekeyfe/hd-shared": "1.2.2-alpha.105",
29
- "@onekeyfe/hd-transport": "1.2.2-alpha.105",
28
+ "@onekeyfe/hd-shared": "1.2.2-alpha.106",
29
+ "@onekeyfe/hd-transport": "1.2.2-alpha.106",
30
30
  "axios": "1.15.2",
31
31
  "bignumber.js": "^9.0.2",
32
32
  "buffer": "^6.0.3",
@@ -46,5 +46,5 @@
46
46
  "@types/w3c-web-usb": "^1.0.10",
47
47
  "@types/web-bluetooth": "^0.0.21"
48
48
  },
49
- "gitHead": "16dcd1b1a3d6687019947f22235522feb1d79095"
49
+ "gitHead": "e52ad091098aaab6b2845154457ace5e2f8c9098"
50
50
  }
@@ -4,6 +4,7 @@ import { UI_REQUEST } from '../constants/ui-request';
4
4
  import { projectFeatures } from '../device/DeviceStateProjector';
5
5
  import { BaseMethod } from './BaseMethod';
6
6
 
7
+ /** @deprecated Use GetDeviceState / HardwareSDK.getDeviceState. */
7
8
  export default class GetFeatures extends BaseMethod {
8
9
  init() {
9
10
  this.unlockPolicy = 'none';
@@ -6,6 +6,7 @@ import { refreshProtocolV2DeviceStatus } from '../protocols/protocol-v2/walletSe
6
6
  import { getPassphraseStateWithRefreshDeviceInfo } from '../utils/deviceFeaturesUtils';
7
7
  import { BaseMethod } from './BaseMethod';
8
8
 
9
+ /** @deprecated Use OpenWalletSession / HardwareSDK.openWalletSession. */
9
10
  export default class GetPassphraseState extends BaseMethod {
10
11
  getSupportedProtocols() {
11
12
  return ['V1', 'V2'] as const;
@@ -1,7 +1,6 @@
1
1
  import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
2
  import { DeviceSessionPinType } from '@onekeyfe/hd-transport';
3
3
 
4
- import { deviceWalletSessionStore } from '../device/DeviceWalletSessionStore';
5
4
  import { getProtocolV2WalletSession } from '../protocols/protocol-v2/walletSession';
6
5
  import { getPassphraseStateWithRefreshDeviceInfo } from '../utils/deviceFeaturesUtils';
7
6
  import { BaseMethod } from './BaseMethod';
@@ -13,16 +12,6 @@ import type {
13
12
  OpenWalletSessionPayload,
14
13
  } from '../types/api/openWalletSession';
15
14
 
16
- const requiredString = (value: unknown, name: string) => {
17
- if (value === undefined || value === null) {
18
- throw invalidParameter(`Missing required parameter: ${name}`);
19
- }
20
- if (typeof value !== 'string' || !value.trim()) {
21
- throw invalidParameter(`Parameter [${name}] must be a non-empty string.`);
22
- }
23
- return value.trim();
24
- };
25
-
26
15
  const wasResumed = (session: unknown) =>
27
16
  !!session &&
28
17
  typeof session === 'object' &&
@@ -53,34 +42,21 @@ const normalizeParams = (payload: Record<string, unknown>): OpenWalletSessionPar
53
42
  }
54
43
  if (
55
44
  payload.mode !== OpenWalletSessionMode.Standard &&
56
- payload.mode !== OpenWalletSessionMode.SelectHidden &&
57
- payload.mode !== OpenWalletSessionMode.ResumeHidden
45
+ payload.mode !== OpenWalletSessionMode.SelectHidden
58
46
  ) {
59
- throw invalidParameter(
60
- 'Parameter [mode] must be one of standard, select-hidden, or resume-hidden.'
61
- );
47
+ throw invalidParameter('Parameter [mode] must be one of standard or select-hidden.');
62
48
  }
63
49
  if (payload.useEmptyPassphrase !== undefined || payload.initSession !== undefined) {
64
50
  throw invalidParameter(
65
51
  'Legacy parameters [useEmptyPassphrase] and [initSession] are not supported by openWalletSession.'
66
52
  );
67
53
  }
68
- if (
69
- payload.mode === OpenWalletSessionMode.Standard ||
70
- payload.mode === OpenWalletSessionMode.SelectHidden
71
- ) {
72
- if (payload.deviceId !== undefined || payload.passphraseState !== undefined) {
73
- throw invalidParameter(
74
- 'Parameters [deviceId] and [passphraseState] are only allowed with mode [resume-hidden].'
75
- );
76
- }
77
- return { mode: payload.mode };
54
+ if (payload.deviceId !== undefined || payload.passphraseState !== undefined) {
55
+ throw invalidParameter(
56
+ 'Parameters [deviceId] and [passphraseState] are not supported by openWalletSession. Pass passphraseState on later address and signing calls.'
57
+ );
78
58
  }
79
- return {
80
- mode: OpenWalletSessionMode.ResumeHidden,
81
- deviceId: requiredString(payload.deviceId, 'deviceId'),
82
- passphraseState: requiredString(payload.passphraseState, 'passphraseState'),
83
- };
59
+ return { mode: payload.mode };
84
60
  };
85
61
 
86
62
  export default class OpenWalletSession extends BaseMethod<OpenWalletSessionParams> {
@@ -196,52 +172,6 @@ export default class OpenWalletSession extends BaseMethod<OpenWalletSessionParam
196
172
  };
197
173
  }
198
174
 
199
- if (this.params.mode === OpenWalletSessionMode.ResumeHidden) {
200
- if (isProtocolV2) {
201
- await ensureProtocolV2WalletStatus();
202
- const refreshedDeviceId = requireDeviceId();
203
- if (refreshedDeviceId !== this.params.deviceId) {
204
- deviceWalletSessionStore.delete(this.params.deviceId, this.params.passphraseState);
205
- throw ERRORS.TypedError(HardwareErrorCode.DeviceCheckDeviceIdError);
206
- }
207
- } else if (requireDeviceId() !== this.params.deviceId) {
208
- throw ERRORS.TypedError(HardwareErrorCode.DeviceCheckDeviceIdError);
209
- }
210
- this.device.passphraseState = this.params.passphraseState;
211
- const cachedSessionId = deviceWalletSessionStore.get(
212
- this.params.deviceId,
213
- this.params.passphraseState
214
- );
215
- if (!cachedSessionId && !isProtocolV2) {
216
- throw ERRORS.TypedError(HardwareErrorCode.WalletSessionInvalid);
217
- }
218
- if (!isProtocolV2) {
219
- await this.device.initialize({
220
- deviceId: this.params.deviceId,
221
- passphraseState: this.params.passphraseState,
222
- });
223
- }
224
- const session = isProtocolV2
225
- ? await getProtocolV2WalletSession(this.device, {
226
- expectedPassphraseState: this.params.passphraseState,
227
- })
228
- : await getPassphraseStateWithRefreshDeviceInfo(this.device, {
229
- expectPassphraseState: this.params.passphraseState,
230
- });
231
- const deviceId = requireDeviceId();
232
- if (session.passphraseState !== this.params.passphraseState) {
233
- this.device.clearInternalState();
234
- throw ERRORS.TypedError(HardwareErrorCode.DeviceCheckPassphraseStateError);
235
- }
236
- return {
237
- protocol,
238
- walletType: 'hidden',
239
- deviceId,
240
- ...requireHiddenWalletResponse(session),
241
- resumed: wasResumed(session) || (!isProtocolV2 && session.newSession === cachedSessionId),
242
- };
243
- }
244
-
245
175
  this.device.passphraseState = undefined;
246
176
  const walletStatus = await ensureProtocolV2WalletStatus();
247
177
  if (isProtocolV2 && walletStatus.status.passphraseProtection !== true) {
@@ -1,3 +1,5 @@
1
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
1
3
  import { LoggerNames, getLogger } from '../utils';
2
4
 
3
5
  import type { Deferred } from '@onekeyfe/hd-shared';
@@ -40,6 +42,24 @@ export default class RequestQueue {
40
42
  return this.requestQueue.get(requestId);
41
43
  }
42
44
 
45
+ public async waitForTask<T>(task: RequestTask, pending: () => Promise<T>): Promise<T> {
46
+ const signal = task.method.abortSignal;
47
+ const cancellationError = () => ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
48
+ if (signal?.aborted) throw cancellationError();
49
+ let onAbort: (() => void) | undefined;
50
+ try {
51
+ const cancelled = new Promise<never>((_, reject) => {
52
+ onAbort = () => reject(cancellationError());
53
+ signal?.addEventListener('abort', onAbort, { once: true });
54
+ });
55
+ const result = await Promise.race([pending(), cancelled]);
56
+ if (signal?.aborted) throw cancellationError();
57
+ return result;
58
+ } finally {
59
+ if (onAbort) signal?.removeEventListener('abort', onAbort);
60
+ }
61
+ }
62
+
43
63
  // 获取请求的AbortController
44
64
  public getAbortController(requestId: number) {
45
65
  return this.requestQueue.get(requestId)?.abortController;
package/src/core/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  isProtocolV2LinkDisabledError,
11
11
  } from '@onekeyfe/hd-transport';
12
12
  import {
13
+ EDeviceType,
13
14
  ERRORS,
14
15
  ERROR_CODES_REQUIRE_DISCONNECT,
15
16
  ERROR_CODES_REQUIRE_RELEASE,
@@ -84,6 +85,7 @@ import type { BaseMethod } from '../api/BaseMethod';
84
85
  const Log = getLogger(LoggerNames.Core);
85
86
  const PRE_INITIALIZE_TTL_MS = 60 * 1000;
86
87
  const PRE_PENDING_CALL_TIMEOUT_MS = 15 * 1000;
88
+ const PRO2_USB_SIGNING_COOLDOWN_MS = 1000;
87
89
 
88
90
  // Dedup/coalesce state for "pre-warm signal" methods (isPreWarmSignal),
89
91
  // keyed by getPreWarmKey(): coalesce in-flight, skip if warmed within TTL.
@@ -313,30 +315,44 @@ const waitForPendingPromise = async (
313
315
  if (pendingPromise) {
314
316
  Log.debug('pre pending call promise before call method, wait for it');
315
317
  let timer: ReturnType<typeof setTimeout> | undefined;
316
- let timedOut = false;
317
318
  try {
318
319
  await Promise.race([
319
320
  pendingPromise,
320
- new Promise<void>(resolve => {
321
+ new Promise<void>((_, reject) => {
321
322
  timer = setTimeout(() => {
322
- timedOut = true;
323
- resolve();
323
+ reject(
324
+ ERRORS.TypedError(
325
+ HardwareErrorCode.DeviceBusy,
326
+ 'Previous device cancellation is still draining'
327
+ )
328
+ );
324
329
  }, PRE_PENDING_CALL_TIMEOUT_MS);
325
330
  }),
326
331
  ]);
327
- } catch (error) {
328
- // Cancellation is best-effort; the transport teardown owns recovery.
332
+ // A deadline is not evidence that old I/O is safe to reuse. Keep the
333
+ // barrier on failure; a later call may proceed only after cleanup settles.
334
+ removePrePendingCallPromise?.(connectId, pendingPromise);
329
335
  } finally {
330
336
  if (timer) clearTimeout(timer);
331
- removePrePendingCallPromise?.(connectId, pendingPromise);
332
- }
333
- if (timedOut) {
334
- Log.warn('pre pending call promise timed out before call method', { connectId });
335
337
  }
336
338
  Log.debug('pre pending call promise before call method done');
337
339
  }
338
340
  };
339
341
 
342
+ export function getPostCallPendingPromise(method: BaseMethod, device: Device): Promise<void> {
343
+ const cleanupPromise = device.waitForRunCleanup();
344
+ const env = DataManager.getSettings('env');
345
+ const deviceType = device.getCurrentDeviceType();
346
+ const requiresSigningCooldown =
347
+ method.name.includes('Sign') &&
348
+ (deviceType === EDeviceType.Pro2 || deviceType === EDeviceType.Neo) &&
349
+ (DataManager.isBrowserWebUsb(env) || DataManager.isDesktopWebUsb(env) || env === 'node-usb');
350
+
351
+ return requiresSigningCooldown
352
+ ? cleanupPromise.then(() => wait(PRO2_USB_SIGNING_COOLDOWN_MS)).then(() => undefined)
353
+ : cleanupPromise;
354
+ }
355
+
340
356
  const onCallDevice = async (
341
357
  context: CoreContext,
342
358
  message: CoreMessage,
@@ -367,42 +383,38 @@ const onCallDevice = async (
367
383
  DevicePool.clearDeviceCache(method.payload.connectId);
368
384
  }
369
385
 
370
- // wait for previous callback tasks to complete (ensure device does not call concurrently)
371
- if (method.connectId) {
372
- await context.waitForCallbackTasks(method.connectId);
373
- }
374
-
375
- await waitForPendingPromise(
376
- method.connectId ?? '',
377
- getPrePendingCallPromise,
378
- removePrePendingCallPromise
379
- );
380
-
386
+ // Register before waiting so cancellation also covers queued requests.
381
387
  const task = requestQueue.createTask(method);
382
388
 
383
389
  // Pre-warm holds the device as a per-connectId callback task so a concurrent
384
390
  // real call waits (before ensureConnected) instead of racing its Initialize.
385
391
  // Only covers pre-warm -> real-call ordering; the reverse is fail-closed.
386
392
  let preWarmCallbackTask: Deferred<void> | undefined;
387
- if (method.isPreWarmSignal && method.connectId) {
388
- preWarmCallbackTask = createDeferred<void>();
389
- context.registerCallbackTask(method.connectId, preWarmCallbackTask);
390
- }
391
-
392
393
  let device: Device;
393
394
  try {
395
+ const connectId = method.connectId ?? '';
396
+ if (connectId) {
397
+ await requestQueue.waitForTask(task, () => context.waitForCallbackTasks(connectId));
398
+ }
399
+ await requestQueue.waitForTask(task, () =>
400
+ waitForPendingPromise(
401
+ method.connectId ?? '',
402
+ getPrePendingCallPromise,
403
+ removePrePendingCallPromise
404
+ )
405
+ );
406
+ if (method.isPreWarmSignal && method.connectId) {
407
+ preWarmCallbackTask = createDeferred<void>();
408
+ context.registerCallbackTask(method.connectId, preWarmCallbackTask);
409
+ }
394
410
  /**
395
411
  * Polling to ensure successful connection
396
412
  */
397
- const connectId = method.connectId ?? '';
398
413
  const pollingId = pollingManager.start(connectId);
399
- device = await ensureConnected(
400
- context,
401
- method,
402
- connectId,
403
- pollingId,
404
- task.abortController?.signal
405
- );
414
+ device = await ensureConnected(context, method, connectId, pollingId, method.abortSignal);
415
+ if (method.abortSignal?.aborted) {
416
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
417
+ }
406
418
  } catch (e) {
407
419
  preWarmCallbackTask?.resolve();
408
420
  Log.debug('ensureConnected error: ', e);
@@ -478,14 +490,19 @@ const onCallDevice = async (
478
490
 
479
491
  try {
480
492
  // Wait for any pending task except our own (self-wait would deadlock).
481
- if (method.connectId) {
482
- await context.waitForCallbackTasks(method.connectId, preWarmCallbackTask);
493
+ const { connectId } = method;
494
+ if (connectId) {
495
+ await requestQueue.waitForTask(task, () =>
496
+ context.waitForCallbackTasks(connectId, preWarmCallbackTask)
497
+ );
483
498
  }
484
499
 
485
- await waitForPendingPromise(
486
- method.connectId ?? '',
487
- getPrePendingCallPromise,
488
- removePrePendingCallPromise
500
+ await requestQueue.waitForTask(task, () =>
501
+ waitForPendingPromise(
502
+ method.connectId ?? '',
503
+ getPrePendingCallPromise,
504
+ removePrePendingCallPromise
505
+ )
489
506
  );
490
507
 
491
508
  const inner = async (): Promise<void> => {
@@ -690,6 +707,13 @@ const onCallDevice = async (
690
707
  },
691
708
  });
692
709
  messageResponse = createResponseMessage(method.responseID, true, response);
710
+ // Preserve the acknowledged result while the next call waits for cleanup and cooldown.
711
+ if (method.connectId) {
712
+ context.setPrePendingCallPromise(
713
+ method.connectId,
714
+ getPostCallPendingPromise(method, device)
715
+ );
716
+ }
693
717
  requestQueue.resolveRequest(method.responseID, messageResponse);
694
718
  completeMethodRequestContext(method);
695
719
  } catch (error) {
@@ -701,6 +725,12 @@ const onCallDevice = async (
701
725
  }
702
726
  Log.debug(`Call API - Inner Method Run Error`, error);
703
727
  messageResponse = createResponseMessage(method.responseID, false, { error });
728
+ if (method.connectId) {
729
+ context.setPrePendingCallPromise(
730
+ method.connectId,
731
+ getPostCallPendingPromise(method, device)
732
+ );
733
+ }
704
734
  requestQueue.resolveRequest(method.responseID, messageResponse);
705
735
  completeMethodRequestContext(method, error);
706
736
 
@@ -727,6 +757,9 @@ const onCallDevice = async (
727
757
  skipInitialize: canSkipInitialize(method, device),
728
758
  ...parseInitOptions(method),
729
759
  };
760
+ if (method.abortSignal?.aborted) {
761
+ throw ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled);
762
+ }
730
763
  const deviceRun = () => device.run(inner, runOptions);
731
764
  task.callPromise = createDeferred<any>(deviceRun);
732
765
 
@@ -747,6 +780,7 @@ const onCallDevice = async (
747
780
  );
748
781
  Log.debug('Call API - Run Error: ', error);
749
782
  completeMethodRequestContext(method, error);
783
+ return messageResponse;
750
784
  } finally {
751
785
  // Release the pre-warm callback task so the next real call can proceed.
752
786
  preWarmCallbackTask?.resolve();
@@ -1044,7 +1078,7 @@ async function connectDeviceForBle(
1044
1078
  retryCount = 0
1045
1079
  ) {
1046
1080
  try {
1047
- if (device.wasInterruptedByUser()) {
1081
+ if (abortSignal?.aborted || device.wasInterruptedByUser()) {
1048
1082
  throw ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
1049
1083
  }
1050
1084
  if (method.payload.forceProtocolDetection && device.hasDeviceAcquire()) {
@@ -1113,6 +1147,9 @@ async function connectDeviceForBle(
1113
1147
  DevicePool.emitter.emit(DEVICE.CONNECT, device);
1114
1148
  }
1115
1149
  } catch (err) {
1150
+ if (abortSignal?.aborted || device.wasInterruptedByUser()) {
1151
+ throw ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
1152
+ }
1116
1153
  const requiresColdReconnect = isMissingDetectedProtocolV2Error(method, err);
1117
1154
  // Device.run()'s REQUIRE_DISCONNECT handling never sees acquire/initialize
1118
1155
  // failures, so with keep-alive a wedged link would be reused by every retry
@@ -1286,6 +1323,8 @@ const ensureConnected = async (
1286
1323
  HardwareErrorCode.BleDeviceBondedCanceled,
1287
1324
  HardwareErrorCode.BleCharacteristicNotifyError,
1288
1325
  HardwareErrorCode.BleTimeoutError,
1326
+ // A transport setup reset must also stop this outer poll.
1327
+ HardwareErrorCode.PollingTimeout,
1289
1328
  HardwareErrorCode.BleWriteCharacteristicError,
1290
1329
  HardwareErrorCode.BleAlreadyConnected,
1291
1330
  HardwareErrorCode.FirmwareUpdateLimitOneDevice,
@@ -1296,6 +1335,10 @@ const ensureConnected = async (
1296
1335
  HardwareErrorCode.DeviceInterruptedFromUser,
1297
1336
  HardwareErrorCode.CallQueueActionCancelled,
1298
1337
  ].includes(error.errorCode) ||
1338
+ (env === 'react-native' &&
1339
+ [HardwareErrorCode.BleDeviceDisconnected, HardwareErrorCode.PollingTimeout].includes(
1340
+ error.errorCode
1341
+ )) ||
1299
1342
  isTerminalBleStaleBondError(error) ||
1300
1343
  isDeviceIdentityMismatchError(error)
1301
1344
  ) {
@@ -1774,7 +1817,17 @@ export default class Core extends EventEmitter {
1774
1817
  this.prePendingCallPromises.delete(connectId);
1775
1818
  return;
1776
1819
  }
1777
- this.prePendingCallPromises.set(connectId, promise);
1820
+ const previous = this.prePendingCallPromises.get(connectId);
1821
+ const cleanupPromise =
1822
+ previous && previous !== promise
1823
+ ? Promise.all([previous, promise]).then(() => undefined)
1824
+ : promise;
1825
+ this.prePendingCallPromises.set(connectId, cleanupPromise);
1826
+ // cancel() is fire-and-forget. Observe failures immediately, while
1827
+ // preserving the rejected barrier for the next caller's safety check.
1828
+ cleanupPromise.catch(error => {
1829
+ Log.warn('Device cancellation cleanup failed', { errorCode: error?.errorCode });
1830
+ });
1778
1831
  },
1779
1832
  removePrePendingCallPromise: (connectId: string, promise: Promise<void>) => {
1780
1833
  if (this.prePendingCallPromises.get(connectId) === promise) {
@@ -297,6 +297,8 @@ export class Device extends EventEmitter {
297
297
  /** Resolves only after the active run has completed its release path. */
298
298
  private runCleanupPromise?: Promise<void>;
299
299
 
300
+ private userInterruption?: { attempt: number; promise: Promise<void> };
301
+
300
302
  externalState: string[] = [];
301
303
 
302
304
  unavailableCapabilities: UnavailableCapabilities = {};
@@ -1462,6 +1464,10 @@ export class Device extends EventEmitter {
1462
1464
  this.invalidateProtocolV2RuntimeState();
1463
1465
  }
1464
1466
 
1467
+ waitForRunCleanup(): Promise<void> {
1468
+ return this.runCleanupPromise ?? Promise.resolve();
1469
+ }
1470
+
1465
1471
  async run(fn?: () => Promise<void>, options?: RunOptions) {
1466
1472
  if (this.runPromise) {
1467
1473
  await this.interruptionFromOutside();
@@ -1596,45 +1602,63 @@ export class Device extends EventEmitter {
1596
1602
  }
1597
1603
  }
1598
1604
 
1599
- async interruptionFromUser() {
1605
+ interruptionFromUser(): Promise<void> {
1606
+ const attempt = this.connectionAttempt;
1607
+ if (this.userInterruption?.attempt === attempt) {
1608
+ return this.userInterruption.promise;
1609
+ }
1600
1610
  const error = ERRORS.TypedError(HardwareErrorCode.DeviceInterruptedFromUser);
1601
- this.interruptedAttempt = this.connectionAttempt;
1611
+ this.interruptedAttempt = attempt;
1602
1612
  const cleanupPromise = this.runCleanupPromise;
1603
- const { cancelableAction } = this;
1604
- if (cancelableAction) {
1605
- await cancelableAction(error);
1606
- } else if (this.shouldSendFallbackProtocolCancel()) {
1607
- await this.commands?.cancelDevice?.().catch(cancelError => {
1608
- Log.debug('Protocol V2 fallback cancel error', cancelError);
1609
- });
1610
- } else if (!this.hasDeviceAcquire()) {
1611
- // Pairing / connect-native / probe: drop the physical link only.
1612
- // Never acquire or send protocol Cancel just to abort setup.
1613
- if (this.mainId && this.deviceConnector?.disconnect) {
1614
- await this.deviceConnector.disconnect(this.mainId).catch(disconnectError => {
1615
- Log.debug('Ignored disconnect during user cancel without acquire', disconnectError);
1616
- });
1613
+ const { cancelableAction, commands, runPromise, deviceConnector, mainId } = this;
1614
+ const sendFallbackCancel = this.shouldSendFallbackProtocolCancel();
1615
+ const acquired = this.hasDeviceAcquire();
1616
+ const promise = (async () => {
1617
+ try {
1618
+ if (cancelableAction) {
1619
+ await cancelableAction(error);
1620
+ } else if (sendFallbackCancel) {
1621
+ await commands?.cancelDevice?.().catch(cancelError => {
1622
+ Log.debug('Protocol V2 fallback cancel error', cancelError);
1623
+ });
1624
+ } else if (!acquired) {
1625
+ // Abort setup without acquiring a session just to send Cancel.
1626
+ if (mainId && deviceConnector?.disconnect) {
1627
+ await deviceConnector.disconnect(mainId);
1628
+ }
1629
+ if (this.connectionAttempt === attempt) this.markTransportDisconnected();
1630
+ }
1631
+ await commands?.cancel();
1632
+ } catch (cleanupError) {
1633
+ Log.warn('User cancellation cleanup failed; disconnecting device', cleanupError);
1634
+ if (mainId && deviceConnector?.disconnect) {
1635
+ await deviceConnector.disconnect(mainId).catch(disconnectError => {
1636
+ Log.debug('User cancellation fallback disconnect failed', disconnectError);
1637
+ });
1638
+ }
1639
+ if (this.connectionAttempt === attempt) this.markTransportDisconnected();
1640
+ } finally {
1641
+ runPromise?.reject(error);
1642
+ if (this.runPromise === runPromise) this.runPromise = null;
1643
+ await cleanupPromise?.catch(() => undefined);
1617
1644
  }
1618
- this.markTransportDisconnected();
1619
- }
1620
- await this.commands?.cancel();
1621
-
1622
- if (this.runPromise) {
1623
- this.runPromise.reject(error);
1624
- this.runPromise = null;
1625
- }
1626
- await cleanupPromise?.catch(() => undefined);
1645
+ })();
1646
+ // Keep the settled promise for this attempt so a duplicate close/finally
1647
+ // cannot send a second wire Cancel. A new attempt gets its own cleanup.
1648
+ this.userInterruption = { attempt, promise };
1649
+ return promise;
1627
1650
  }
1628
1651
 
1629
1652
  setCancelableAction(callback: (err?: Error) => Promise<unknown>) {
1630
- this.cancelableAction = (e?: Error) =>
1653
+ const action = (e?: Error) =>
1631
1654
  callback(e)
1632
1655
  .catch(e2 => {
1633
1656
  Log.debug('cancelableAction error', e2);
1634
1657
  })
1635
1658
  .finally(() => {
1636
- this.clearCancelableAction();
1659
+ if (this.cancelableAction === action) this.clearCancelableAction();
1637
1660
  });
1661
+ this.cancelableAction = action;
1638
1662
  }
1639
1663
 
1640
1664
  clearCancelableAction() {
@@ -2,6 +2,7 @@ import type { CommonParams, Response } from '../params';
2
2
  import type { Features } from '../device';
3
3
 
4
4
  /**
5
- * @deprecated Use `getDeviceState` for both Protocol V1 and Protocol V2 devices.
5
+ * @deprecated Use `getDeviceState` for both Protocol V1 and Protocol V2.
6
+ * Existing calls still work. Read `payload.identity.deviceId` from `getDeviceState`.
6
7
  */
7
8
  export declare function getFeatures(connectId?: string, params?: CommonParams): Response<Features>;
@@ -3,11 +3,8 @@ import type { CommonParams, Response } from '../params';
3
3
  export type GetPassphraseStateParams = CommonParams;
4
4
 
5
5
  /**
6
- * Compatibility wallet-state API for existing App integrations.
7
- *
8
- * Protocol V1 keeps its parameterless firmware flow. Protocol V2 maps the existing
9
- * `useEmptyPassphrase` and `initSession` intent to the device-only wallet-session flow.
10
- * New integrations should prefer `openWalletSession` for explicit wallet intent.
6
+ * @deprecated Use `openWalletSession` for both Protocol V1 and Protocol V2.
7
+ * Existing calls still work. Persist `deviceId + passphraseState`; do not persist firmware `session_id`.
11
8
  */
12
9
  export declare function getPassphraseState(
13
10
  connectId?: string,
@@ -3,28 +3,16 @@ import type { CommonParams, Response } from '../params';
3
3
  export const OpenWalletSessionMode = {
4
4
  Standard: 'standard',
5
5
  SelectHidden: 'select-hidden',
6
- ResumeHidden: 'resume-hidden',
7
6
  } as const;
8
7
 
9
8
  export type OpenWalletSessionModeValue =
10
9
  (typeof OpenWalletSessionMode)[keyof typeof OpenWalletSessionMode];
11
10
 
12
- export type OpenWalletSessionParams =
13
- | {
14
- mode: typeof OpenWalletSessionMode.Standard;
15
- deviceId?: never;
16
- passphraseState?: never;
17
- }
18
- | {
19
- mode: typeof OpenWalletSessionMode.SelectHidden;
20
- deviceId?: never;
21
- passphraseState?: never;
22
- }
23
- | {
24
- mode: typeof OpenWalletSessionMode.ResumeHidden;
25
- deviceId: string;
26
- passphraseState: string;
27
- };
11
+ export type OpenWalletSessionParams = {
12
+ mode: OpenWalletSessionModeValue;
13
+ deviceId?: never;
14
+ passphraseState?: never;
15
+ };
28
16
 
29
17
  type OpenWalletSessionPayloadBase = {
30
18
  protocol: 'V1' | 'V2';
@@ -45,8 +33,8 @@ export type OpenWalletSessionPayload = OpenWalletSessionPayloadBase &
45
33
  );
46
34
 
47
35
  /**
48
- * Opens the standard, hidden, or Attach-to-PIN wallet flow through a unified
49
- * Protocol V1/V2 API.
36
+ * Opens the standard or hidden wallet through a unified Protocol V1/V2 API.
37
+ * Resume a bound hidden wallet by passing passphraseState on later methods.
50
38
  */
51
39
  export declare function openWalletSession(
52
40
  connectId: string,
@@ -1,6 +1,13 @@
1
1
  import type { HardwareConnectProtocol } from '@onekeyfe/hd-shared';
2
2
 
3
3
  export interface CommonParams {
4
+ /**
5
+ * Keep the transport session after this call instead of releasing it.
6
+ * This is a Device.run() hold flag, not a wallet identity. It does not replace
7
+ * `useEmptyPassphrase`, `passphraseState`, or `openWalletSession`.
8
+ * USB multi-step flows and Electron BLE firmware windows still need this to
9
+ * avoid dropping the link between calls.
10
+ */
4
11
  keepSession?: boolean;
5
12
  /**
6
13
  * polling connect max retry count