@onekeyfe/hd-core 1.1.32 → 1.1.34-alpha.1

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.
@@ -31,6 +31,7 @@ import { DEVICE } from '../events';
31
31
 
32
32
  import type { Features, KnownDevice } from '../types';
33
33
  import type { Device } from '../device/Device';
34
+ import type { FirmwareBinary } from './firmware/getBinary';
34
35
 
35
36
  type Params = {
36
37
  binary?: ArrayBuffer;
@@ -43,6 +44,77 @@ type Params = {
43
44
 
44
45
  const Log = getLogger(LoggerNames.Method);
45
46
 
47
+ const FIRMWARE_DOWNLOAD_REQUEST_OPTIONS = {
48
+ connectTimeoutMs: 60_000,
49
+ readTimeoutMs: 60_000,
50
+ overallTimeoutMs: 180_000,
51
+ maxRetries: 2,
52
+ retryDelayMs: 500,
53
+ } as const;
54
+
55
+ const normalizeFirmwareBinary = (binary: unknown): FirmwareBinary | undefined => {
56
+ if (typeof binary !== 'object' || binary === null) {
57
+ return undefined;
58
+ }
59
+
60
+ const isNodeBuffer =
61
+ typeof Buffer !== 'undefined' &&
62
+ typeof Buffer.isBuffer === 'function' &&
63
+ Buffer.isBuffer(binary);
64
+ if (isNodeBuffer) {
65
+ return binary.byteLength > 0 ? binary : undefined;
66
+ }
67
+
68
+ if (typeof ArrayBuffer !== 'undefined' && binary instanceof ArrayBuffer) {
69
+ return binary.byteLength > 0 ? binary : undefined;
70
+ }
71
+
72
+ if (
73
+ typeof ArrayBuffer !== 'undefined' &&
74
+ typeof ArrayBuffer.isView === 'function' &&
75
+ ArrayBuffer.isView(binary)
76
+ ) {
77
+ if (binary.byteLength <= 0) {
78
+ return undefined;
79
+ }
80
+ const source = new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
81
+ const normalized = new Uint8Array(binary.byteLength);
82
+ normalized.set(source);
83
+ return normalized.buffer;
84
+ }
85
+
86
+ const customBuffer = binary as {
87
+ [index: number]: unknown;
88
+ byteLength?: unknown;
89
+ constructor?: {
90
+ isBuffer?: (value: unknown) => boolean;
91
+ };
92
+ length?: unknown;
93
+ };
94
+ if (
95
+ typeof customBuffer.constructor?.isBuffer !== 'function' ||
96
+ !customBuffer.constructor.isBuffer(binary) ||
97
+ typeof customBuffer.byteLength !== 'number' ||
98
+ !Number.isSafeInteger(customBuffer.byteLength) ||
99
+ customBuffer.byteLength <= 0 ||
100
+ typeof customBuffer.length !== 'number' ||
101
+ customBuffer.length !== customBuffer.byteLength
102
+ ) {
103
+ return undefined;
104
+ }
105
+
106
+ const { byteLength } = customBuffer;
107
+ const normalized = new Uint8Array(byteLength);
108
+ for (let index = 0; index < byteLength; index += 1) {
109
+ const { [index]: byte } = customBuffer;
110
+ if (typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255) {
111
+ return undefined;
112
+ }
113
+ normalized[index] = byte;
114
+ }
115
+ return normalized.buffer;
116
+ };
117
+
46
118
  export default class FirmwareUpdateV2 extends BaseMethod<Params> {
47
119
  checkPromise: Deferred<any> | null = null;
48
120
 
@@ -286,6 +358,48 @@ export default class FirmwareUpdateV2 extends BaseMethod<Params> {
286
358
 
287
359
  this.checkVersionForCopyTouchResource(features, firmwareType);
288
360
 
361
+ let preparedBinary: FirmwareBinary | undefined;
362
+ const acquireFirmwareBinary = async (): Promise<FirmwareBinary> => {
363
+ try {
364
+ if (preparedBinary) {
365
+ return preparedBinary;
366
+ }
367
+
368
+ if (params.binary !== undefined) {
369
+ preparedBinary = normalizeFirmwareBinary(params.binary);
370
+ if (!preparedBinary) {
371
+ throw new Error('firmware binary is empty or invalid');
372
+ }
373
+ return preparedBinary;
374
+ }
375
+
376
+ if (!device.features) {
377
+ throw ERRORS.TypedError(
378
+ HardwareErrorCode.RuntimeError,
379
+ 'no features found for this device'
380
+ );
381
+ }
382
+
383
+ this.postTipMessage('DownloadFirmware');
384
+ const firmware = await getBinary({
385
+ features: device.features,
386
+ version: params.version,
387
+ updateType: params.updateType,
388
+ isUpdateBootloader: params.isUpdateBootloader,
389
+ firmwareType,
390
+ requestOptions: FIRMWARE_DOWNLOAD_REQUEST_OPTIONS,
391
+ });
392
+ preparedBinary = normalizeFirmwareBinary(firmware.binary);
393
+ if (!preparedBinary) {
394
+ throw new Error('downloaded firmware binary is empty or invalid');
395
+ }
396
+ this.postTipMessage('DownloadFirmwareSuccess');
397
+ return preparedBinary;
398
+ } catch (err) {
399
+ throw ERRORS.TypedError(HardwareErrorCode.FirmwareUpdateDownloadFailed, err.message ?? err);
400
+ }
401
+ };
402
+
289
403
  if (!features?.bootloader_mode && features) {
290
404
  const uuid = getDeviceUUID(features);
291
405
  // should go to bootloader mode manually
@@ -319,6 +433,12 @@ export default class FirmwareUpdateV2 extends BaseMethod<Params> {
319
433
  // check if the device commands has been disposed
320
434
  this.device?.commands?.checkDisposed();
321
435
 
436
+ // A failed firmware download must leave the device in normal mode.
437
+ await acquireFirmwareBinary();
438
+
439
+ // The request may outlive the current transport command instance.
440
+ this.device?.commands?.checkDisposed();
441
+
322
442
  // auto go to bootloader mode
323
443
  try {
324
444
  this.postTipMessage('AutoRebootToBootloader');
@@ -357,33 +477,8 @@ export default class FirmwareUpdateV2 extends BaseMethod<Params> {
357
477
  }
358
478
  }
359
479
 
360
- let binary;
361
-
362
- try {
363
- if (params.binary) {
364
- binary = this.params.binary;
365
- } else {
366
- if (!device.features) {
367
- throw ERRORS.TypedError(
368
- HardwareErrorCode.RuntimeError,
369
- 'no features found for this device'
370
- );
371
- }
372
- this.postTipMessage('DownloadFirmware');
373
-
374
- const firmware = await getBinary({
375
- features: device.features,
376
- version: params.version,
377
- updateType: params.updateType,
378
- isUpdateBootloader: params.isUpdateBootloader,
379
- firmwareType,
380
- });
381
- binary = firmware.binary;
382
- this.postTipMessage('DownloadFirmwareSuccess');
383
- }
384
- } catch (err) {
385
- throw ERRORS.TypedError(HardwareErrorCode.FirmwareUpdateDownloadFailed, err.message ?? err);
386
- }
480
+ // Devices already in bootloader mode still acquire through the same helper.
481
+ const binary = await acquireFirmwareBinary();
387
482
 
388
483
  // check if the device commands has been disposed
389
484
  this.device?.commands?.checkDisposed();
@@ -7,9 +7,12 @@ import { findLatestRelease } from '../../utils/release';
7
7
  import { getFirmwareUpdateField } from '../../utils/deviceFeaturesUtils';
8
8
 
9
9
  import type { Features } from '../../types';
10
+ import type { HttpRequestOptions } from '../../utils/networkUtils';
10
11
  import type { EFirmwareType } from '@onekeyfe/hd-shared';
11
12
  import type { IFirmwareField } from '../../data-manager/DataManager';
12
13
 
14
+ export type FirmwareBinary = ArrayBuffer | Buffer;
15
+
13
16
  export interface GetInfoProps {
14
17
  features: Features;
15
18
  updateType: 'firmware' | 'ble';
@@ -20,6 +23,7 @@ export interface GetInfoProps {
20
23
 
21
24
  interface GetBinaryProps extends GetInfoProps {
22
25
  version?: number[];
26
+ requestOptions?: HttpRequestOptions;
23
27
  }
24
28
 
25
29
  export const getBinary = async ({
@@ -28,6 +32,7 @@ export const getBinary = async ({
28
32
  version,
29
33
  isUpdateBootloader,
30
34
  firmwareType,
35
+ requestOptions,
31
36
  }: GetBinaryProps) => {
32
37
  const releaseInfo = getInfo({
33
38
  features,
@@ -55,9 +60,9 @@ export const getBinary = async ({
55
60
  : isUpdateBootloader
56
61
  ? releaseInfo.bootloaderResource
57
62
  : releaseInfo.url;
58
- let fw;
63
+ let fw: FirmwareBinary;
59
64
  try {
60
- fw = await httpRequest(url, 'binary');
65
+ fw = await httpRequest(url, 'binary', requestOptions);
61
66
  } catch {
62
67
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Method_FirmwareUpdate_DownloadFailed');
63
68
  }
@@ -69,7 +74,7 @@ export const getBinary = async ({
69
74
  };
70
75
 
71
76
  export const getSysResourceBinary = async (url: string) => {
72
- let fw;
77
+ let fw: FirmwareBinary;
73
78
  try {
74
79
  fw = await httpRequest(url, 'binary');
75
80
  } catch {
@@ -476,7 +476,18 @@ export default class KaspaSignTransaction extends BaseMethod<KaspaSignTransactio
476
476
  'KaspaSignTransaction: device firmware uses the streaming protocol; every output requires address or addressN'
477
477
  );
478
478
  }
479
- return this.signTxStream(typedCall, response);
479
+ try {
480
+ return await this.signTxStream(typedCall, response);
481
+ } catch (error) {
482
+ // Device rejected our refTxs; the caller decides whether to sign without them.
483
+ if (
484
+ error instanceof HardwareError &&
485
+ String(error.message).toLowerCase().includes('previous transaction id mismatch')
486
+ ) {
487
+ throw ERRORS.TypedError(HardwareErrorCode.KaspaPrevTxIdMismatch, String(error.message));
488
+ }
489
+ throw error;
490
+ }
480
491
  }
481
492
 
482
493
  // Legacy answer to a streaming-only packet: no prehash material exists.
@@ -1,5 +1,8 @@
1
1
  import { httpRequest as browserHttpRequest } from './networkUtils';
2
2
 
3
- export const httpRequest = (url: string, type: string): any => browserHttpRequest(url, type);
3
+ import type { HttpRequestOptions } from './networkUtils';
4
+
5
+ export const httpRequest = (url: string, type: string, options?: HttpRequestOptions): any =>
6
+ browserHttpRequest(url, type, options);
4
7
 
5
8
  export const getTimeStamp = () => new Date().getTime();
@@ -1,25 +1,281 @@
1
- import axios from 'axios';
1
+ import axios, { type AxiosResponse } from 'axios';
2
2
 
3
- export const httpRequest = async (url: string, type = 'text') => {
3
+ export type HttpRequestOptions = {
4
+ /** Axios adapter-defined request timeout retained for existing opt-in callers. */
5
+ timeoutMs?: number;
6
+ /** Deadline for DNS, connection, TLS, redirects, and the first response-body progress. */
7
+ connectTimeoutMs?: number;
8
+ /** Maximum idle interval between response-body progress events. */
9
+ readTimeoutMs?: number;
10
+ /** Wall-clock deadline across all attempts and retry backoff. */
11
+ overallTimeoutMs?: number;
12
+ maxRetries?: number;
13
+ retryDelayMs?: number;
14
+ };
15
+
16
+ const MAX_RETRY_COUNT = 3;
17
+ const REQUEST_PHASE_TIMEOUT_CODES = {
18
+ connect: 'ECONNECTTIMEDOUT',
19
+ read: 'EREADTIMEDOUT',
20
+ } as const;
21
+ const RETRYABLE_HTTP_STATUSES = new Set([500, 502, 503, 504]);
22
+ const RETRYABLE_NETWORK_ERROR_CODES = new Set([
23
+ REQUEST_PHASE_TIMEOUT_CODES.connect,
24
+ REQUEST_PHASE_TIMEOUT_CODES.read,
25
+ 'ECONNABORTED',
26
+ 'ECONNREFUSED',
27
+ 'ECONNRESET',
28
+ 'EHOSTUNREACH',
29
+ 'ENETDOWN',
30
+ 'ENETUNREACH',
31
+ 'ENOTFOUND',
32
+ 'EAI_AGAIN',
33
+ 'ERR_NETWORK',
34
+ 'ETIMEDOUT',
35
+ ]);
36
+
37
+ const createAbortError = () => {
38
+ const error = new Error('httpRequest aborted');
39
+ error.name = 'AbortError';
40
+ return error;
41
+ };
42
+
43
+ const createOverallTimeoutError = (url: string, timeoutMs: number) => {
44
+ const error = new Error(`httpRequest overall timeout: ${url} ${timeoutMs}ms`);
45
+ error.name = 'HttpRequestOverallTimeoutError';
46
+ return error;
47
+ };
48
+
49
+ const createRequestPhaseTimeoutError = (
50
+ url: string,
51
+ phase: keyof typeof REQUEST_PHASE_TIMEOUT_CODES,
52
+ timeoutMs: number
53
+ ) => {
54
+ const error = new Error(`httpRequest ${phase} timeout: ${url} ${timeoutMs}ms`);
55
+ error.name = 'HttpRequestPhaseTimeoutError';
56
+ return Object.assign(error, {
57
+ code: REQUEST_PHASE_TIMEOUT_CODES[phase],
58
+ });
59
+ };
60
+
61
+ const waitForRetry = async (delayMs: number, attempt: number, signal?: AbortSignal) => {
62
+ const backoffMs = delayMs * 2 ** attempt;
63
+ if (backoffMs <= 0) {
64
+ return;
65
+ }
66
+ await new Promise<void>((resolve, reject) => {
67
+ const timeoutTimer = setTimeout(() => {
68
+ signal?.removeEventListener('abort', handleAbort);
69
+ resolve();
70
+ }, backoffMs);
71
+ const handleAbort = () => {
72
+ clearTimeout(timeoutTimer);
73
+ reject(createAbortError());
74
+ };
75
+ if (signal?.aborted) {
76
+ handleAbort();
77
+ return;
78
+ }
79
+ signal?.addEventListener('abort', handleAbort, { once: true });
80
+ });
81
+ };
82
+
83
+ const isRetryableRequestError = (error: unknown) => {
84
+ if (axios.isCancel(error)) {
85
+ return false;
86
+ }
87
+ if (
88
+ typeof error === 'object' &&
89
+ error !== null &&
90
+ 'code' in error &&
91
+ typeof error.code === 'string' &&
92
+ RETRYABLE_NETWORK_ERROR_CODES.has(error.code)
93
+ ) {
94
+ return true;
95
+ }
96
+ if (!axios.isAxiosError(error)) {
97
+ return false;
98
+ }
99
+ const status = error.response?.status;
100
+ if (status !== undefined) {
101
+ return RETRYABLE_HTTP_STATUSES.has(Number(status));
102
+ }
103
+ return false;
104
+ };
105
+
106
+ export const httpRequest = async <T = unknown>(
107
+ url: string,
108
+ type = 'text',
109
+ options: HttpRequestOptions = {}
110
+ ): Promise<T> => {
4
111
  const headers: any = {};
5
112
  if (url.indexOf('ngrok-free.app') > -1) {
6
113
  headers['ngrok-skip-browser-warning'] = true;
7
114
  }
8
- const response = await axios.request({
9
- url,
10
- withCredentials: false,
11
- responseType: type === 'binary' ? 'arraybuffer' : 'json',
12
- headers,
13
- });
14
115
 
15
- if (+response.status === 200) {
16
- if (type === 'json') {
17
- return response.data;
116
+ const timeoutMs =
117
+ Number.isSafeInteger(options.timeoutMs) && Number(options.timeoutMs) > 0
118
+ ? Number(options.timeoutMs)
119
+ : undefined;
120
+ const connectTimeoutMs =
121
+ Number.isSafeInteger(options.connectTimeoutMs) && Number(options.connectTimeoutMs) > 0
122
+ ? Number(options.connectTimeoutMs)
123
+ : undefined;
124
+ const readTimeoutMs =
125
+ Number.isSafeInteger(options.readTimeoutMs) && Number(options.readTimeoutMs) > 0
126
+ ? Number(options.readTimeoutMs)
127
+ : undefined;
128
+ const overallTimeoutMs =
129
+ Number.isSafeInteger(options.overallTimeoutMs) && Number(options.overallTimeoutMs) > 0
130
+ ? Number(options.overallTimeoutMs)
131
+ : undefined;
132
+ const maxRetries =
133
+ Number.isSafeInteger(options.maxRetries) && Number(options.maxRetries) > 0
134
+ ? Math.min(Number(options.maxRetries), MAX_RETRY_COUNT)
135
+ : 0;
136
+ const retryDelayMs =
137
+ Number.isSafeInteger(options.retryDelayMs) && Number(options.retryDelayMs) > 0
138
+ ? Number(options.retryDelayMs)
139
+ : 0;
140
+ const overallTimeoutError = overallTimeoutMs
141
+ ? createOverallTimeoutError(url, overallTimeoutMs)
142
+ : undefined;
143
+ const overallDeadlineAt = overallTimeoutMs ? Date.now() + overallTimeoutMs : undefined;
144
+ const assertWithinOverallDeadline = () => {
145
+ if (overallDeadlineAt !== undefined && overallTimeoutError && Date.now() >= overallDeadlineAt) {
146
+ throw overallTimeoutError;
147
+ }
148
+ };
149
+
150
+ const request = async (attempt: number, signal?: AbortSignal): Promise<T> => {
151
+ assertWithinOverallDeadline();
152
+ if (signal?.aborted) {
153
+ throw createAbortError();
154
+ }
155
+
156
+ let response: AxiosResponse<T> | undefined;
157
+ let requestError: unknown;
158
+ let requestFailed = false;
159
+ let phaseTimeoutError: ReturnType<typeof createRequestPhaseTimeoutError> | undefined;
160
+ let phaseTimeoutTimer: ReturnType<typeof setTimeout> | undefined;
161
+ const hasPhaseDeadline = Boolean(connectTimeoutMs || readTimeoutMs);
162
+ const attemptController = signal || hasPhaseDeadline ? new AbortController() : undefined;
163
+ let attemptSettled = false;
164
+ let lastProgressLoaded = 0;
165
+ const handleParentAbort = () => {
166
+ attemptController?.abort();
167
+ };
168
+ const clearPhaseTimeout = () => {
169
+ if (phaseTimeoutTimer) {
170
+ clearTimeout(phaseTimeoutTimer);
171
+ phaseTimeoutTimer = undefined;
172
+ }
173
+ };
174
+ const armPhaseTimeout = (
175
+ phase: keyof typeof REQUEST_PHASE_TIMEOUT_CODES,
176
+ phaseTimeoutMs: number | undefined
177
+ ) => {
178
+ clearPhaseTimeout();
179
+ if (!phaseTimeoutMs || !attemptController) {
180
+ return;
181
+ }
182
+ phaseTimeoutTimer = setTimeout(() => {
183
+ phaseTimeoutError = createRequestPhaseTimeoutError(url, phase, phaseTimeoutMs);
184
+ attemptController.abort();
185
+ }, phaseTimeoutMs);
186
+ };
187
+
188
+ if (signal) {
189
+ signal.addEventListener('abort', handleParentAbort, { once: true });
190
+ }
191
+ armPhaseTimeout('connect', connectTimeoutMs);
192
+
193
+ try {
194
+ response = await axios.request<T>({
195
+ url,
196
+ withCredentials: false,
197
+ responseType: type === 'binary' ? 'arraybuffer' : 'json',
198
+ headers,
199
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
200
+ ...(attemptController ? { signal: attemptController.signal } : {}),
201
+ ...(hasPhaseDeadline
202
+ ? {
203
+ adapter: ['xhr', 'http'],
204
+ onDownloadProgress: (progressEvent: { loaded: number }) => {
205
+ if (
206
+ attemptSettled ||
207
+ attemptController?.signal.aborted ||
208
+ !Number.isFinite(progressEvent.loaded) ||
209
+ progressEvent.loaded <= lastProgressLoaded
210
+ ) {
211
+ return;
212
+ }
213
+ lastProgressLoaded = progressEvent.loaded;
214
+ armPhaseTimeout('read', readTimeoutMs);
215
+ },
216
+ }
217
+ : {}),
218
+ });
219
+ if (phaseTimeoutError) {
220
+ requestFailed = true;
221
+ requestError = phaseTimeoutError;
222
+ response = undefined;
223
+ }
224
+ } catch (error) {
225
+ requestFailed = true;
226
+ requestError = phaseTimeoutError ?? (signal?.aborted ? createAbortError() : error);
227
+ } finally {
228
+ attemptSettled = true;
229
+ clearPhaseTimeout();
230
+ signal?.removeEventListener('abort', handleParentAbort);
231
+ }
232
+
233
+ assertWithinOverallDeadline();
234
+
235
+ if (requestFailed) {
236
+ if (attempt >= maxRetries || !isRetryableRequestError(requestError)) {
237
+ throw requestError;
238
+ }
239
+ await waitForRetry(retryDelayMs, attempt, signal);
240
+ assertWithinOverallDeadline();
241
+ return request(attempt + 1, signal);
18
242
  }
19
- if (type === 'binary') {
243
+
244
+ if (!response) {
245
+ throw new Error(`httpRequest completed without a response: ${url}`);
246
+ }
247
+
248
+ if (+response.status === 200) {
20
249
  return response.data;
21
250
  }
22
- return response.data;
251
+
252
+ if (RETRYABLE_HTTP_STATUSES.has(Number(response.status)) && attempt < maxRetries) {
253
+ await waitForRetry(retryDelayMs, attempt, signal);
254
+ assertWithinOverallDeadline();
255
+ return request(attempt + 1, signal);
256
+ }
257
+
258
+ throw new Error(`httpRequest error: ${url} ${response.statusText}`);
259
+ };
260
+
261
+ if (!overallTimeoutMs) {
262
+ return request(0);
263
+ }
264
+
265
+ const controller = new AbortController();
266
+ const requestPromise = request(0, controller.signal);
267
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
268
+ try {
269
+ return await new Promise<T>((resolve, reject) => {
270
+ timeoutTimer = setTimeout(() => {
271
+ reject(overallTimeoutError);
272
+ controller.abort();
273
+ }, overallTimeoutMs);
274
+ requestPromise.then(resolve, reject);
275
+ });
276
+ } finally {
277
+ if (timeoutTimer) {
278
+ clearTimeout(timeoutTimer);
279
+ }
23
280
  }
24
- throw new Error(`httpRequest error: ${url} ${response.statusText}`);
25
281
  };