@onekeyfe/hd-transport 1.2.0-alpha.21 → 1.2.0-alpha.22

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.
@@ -482,9 +482,9 @@
482
482
  "MessageType_DeviceStatus": 60603,
483
483
  "MessageType_DevGetOnboardingStatus": 60604,
484
484
  "MessageType_DevOnboardingStatus": 60605,
485
- "MessageType_DeviceSessionGet": 60606,
486
485
  "MessageType_DeviceSession": 60607,
487
486
  "MessageType_DeviceSessionAskPin": 60608,
487
+ "MessageType_DeviceSessionOpen": 60609,
488
488
  "MessageType_FilesystemPermissionFix": 60800,
489
489
  "MessageType_FilesystemPathInfo": 60801,
490
490
  "MessageType_FilesystemPathInfoQuery": 60802,
@@ -506,7 +506,8 @@
506
506
  [90, 92],
507
507
  [114, 122],
508
508
  [300, 304],
509
- [309, 312]
509
+ [309, 312],
510
+ [60606, 60606]
510
511
  ]
511
512
  },
512
513
  "AlephiumGetAddress": {
@@ -11744,7 +11745,15 @@
11744
11745
  }
11745
11746
  },
11746
11747
  "ProtocolInfoRequest": {
11747
- "fields": {}
11748
+ "fields": {
11749
+ "eventless_wallet_session": {
11750
+ "type": "bool",
11751
+ "id": 1,
11752
+ "options": {
11753
+ "default": false
11754
+ }
11755
+ }
11756
+ }
11748
11757
  },
11749
11758
  "ProtocolInfo": {
11750
11759
  "fields": {
@@ -12427,14 +12436,78 @@
12427
12436
  }
12428
12437
  }
12429
12438
  },
12430
- "DeviceSessionGet": {
12439
+ "DeviceSessionErrorCode": {
12440
+ "values": {
12441
+ "DeviceSessionError_None": 0,
12442
+ "DeviceSessionError_UserCancelled": 1,
12443
+ "DeviceSessionError_InvalidSession": 2,
12444
+ "DeviceSessionError_AttachPinUnavailable": 3,
12445
+ "DeviceSessionError_PassphraseDisabled": 4,
12446
+ "DeviceSessionError_Busy": 5
12447
+ }
12448
+ },
12449
+ "DeviceSessionResume": {
12431
12450
  "fields": {
12432
12451
  "session_id": {
12452
+ "rule": "required",
12433
12453
  "type": "bytes",
12434
12454
  "id": 1
12435
12455
  }
12436
12456
  }
12437
12457
  },
12458
+ "DeviceSessionHostPassphrase": {
12459
+ "fields": {
12460
+ "passphrase": {
12461
+ "rule": "required",
12462
+ "type": "string",
12463
+ "id": 1
12464
+ }
12465
+ }
12466
+ },
12467
+ "DeviceSessionPassphraseOnDevice": {
12468
+ "fields": {}
12469
+ },
12470
+ "DeviceSessionAttachPinOnDevice": {
12471
+ "fields": {}
12472
+ },
12473
+ "DeviceSessionSelect": {
12474
+ "oneofs": {
12475
+ "access": {
12476
+ "oneof": ["host_passphrase", "passphrase_on_device", "attach_pin_on_device"]
12477
+ }
12478
+ },
12479
+ "fields": {
12480
+ "host_passphrase": {
12481
+ "type": "DeviceSessionHostPassphrase",
12482
+ "id": 1
12483
+ },
12484
+ "passphrase_on_device": {
12485
+ "type": "DeviceSessionPassphraseOnDevice",
12486
+ "id": 2
12487
+ },
12488
+ "attach_pin_on_device": {
12489
+ "type": "DeviceSessionAttachPinOnDevice",
12490
+ "id": 3
12491
+ }
12492
+ }
12493
+ },
12494
+ "DeviceSessionOpen": {
12495
+ "oneofs": {
12496
+ "mode": {
12497
+ "oneof": ["resume", "select"]
12498
+ }
12499
+ },
12500
+ "fields": {
12501
+ "resume": {
12502
+ "type": "DeviceSessionResume",
12503
+ "id": 1
12504
+ },
12505
+ "select": {
12506
+ "type": "DeviceSessionSelect",
12507
+ "id": 2
12508
+ }
12509
+ }
12510
+ },
12438
12511
  "DeviceSession": {
12439
12512
  "fields": {
12440
12513
  "session_id": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport",
3
- "version": "1.2.0-alpha.21",
3
+ "version": "1.2.0-alpha.22",
4
4
  "description": "Transport layer abstractions and utilities for OneKey hardware SDK.",
5
5
  "author": "OneKey",
6
6
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
@@ -28,5 +28,5 @@
28
28
  "long": "^4.0.0",
29
29
  "protobufjs": "^6.11.2"
30
30
  },
31
- "gitHead": "efe594367fb3b196a5126baee7e2aba3e94986cd"
31
+ "gitHead": "ce94a5ebbe141b0c15db7c68d08085ca1ddb0166"
32
32
  }
@@ -69,6 +69,9 @@ const ENUM_KEYS = [
69
69
  'ProtocolV2Capability',
70
70
  ];
71
71
 
72
+ // Safety checks are accepted as both protobuf enum names and numeric legacy values.
73
+ const ENUM_KEYS_WITH_NUMERIC_VALUES = ['SafetyCheckLevel'];
74
+
72
75
  const parseEnumTypescript = (itemName, item) => {
73
76
  const value = [];
74
77
  const IS_KEY = ENUM_KEYS.includes(itemName);
@@ -87,7 +90,10 @@ const parseEnumTypescript = (itemName, item) => {
87
90
  value.push('}');
88
91
 
89
92
  if (IS_KEY) {
90
- value.push(`export type ${itemName} = keyof typeof Enum_${itemName};`);
93
+ const numericValue = ENUM_KEYS_WITH_NUMERIC_VALUES.includes(itemName)
94
+ ? ` | Enum_${itemName}`
95
+ : '';
96
+ value.push(`export type ${itemName} = keyof typeof Enum_${itemName}${numericValue};`);
91
97
  }
92
98
  // empty line
93
99
  value.push('');
package/src/constants.ts CHANGED
@@ -48,3 +48,11 @@ export const PROTOCOL_V2_CHANNEL_SOCKET = 2;
48
48
 
49
49
  /** packet_src for protobuf commands; firmware routes zero to the protobuf dispatcher. */
50
50
  export const PROTOCOL_V2_PACKET_SRC_COMMAND = 0;
51
+
52
+ /**
53
+ * Shared upper bound for a Protocol V2 request without a method-specific timeout.
54
+ * Interactive and signing calls may wait on the device UI, so keep this longer than
55
+ * ordinary transport timeouts while still preventing a stalled link from blocking
56
+ * the per-device queue forever.
57
+ */
58
+ export const PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS = 5 * 60 * 1000;
@@ -6,7 +6,12 @@ import { decodeMessage as decodeV1Message } from './v1/receive';
6
6
  import { createMessageFromName, createMessageFromType } from '../serialization/protobuf/messages';
7
7
  import { encode as encodeProtobuf } from '../serialization/protobuf/encode';
8
8
  import { decode as decodeProtobuf } from '../serialization/protobuf/decode';
9
- import { decodeFrame as decodeV2Frame, encodeProtobufFrame, isAckFrame } from './v2';
9
+ import {
10
+ decodeFrame as decodeV2Frame,
11
+ encodeProtobufFrame,
12
+ inspectFrameHeader,
13
+ isAckFrame,
14
+ } from './v2';
10
15
 
11
16
  import type { Root } from 'protobufjs/light';
12
17
 
@@ -71,15 +76,19 @@ export const ProtocolV1 = {
71
76
 
72
77
  export const ProtocolV2 = {
73
78
  isAckFrame,
79
+ inspectFrameHeader,
74
80
 
75
81
  inspectFrame(schemas: ProtocolV2Schemas, frame: Uint8Array) {
76
- const { messageTypeId, pbPayload, seq } = decodeV2Frame(frame);
82
+ const { messageTypeId, pbPayload, seq, router, packetSrc, dataType } = decodeV2Frame(frame);
77
83
  const { messageName } = createProtocolV2MessageFromType(messageTypeId, schemas);
78
84
  return {
79
85
  messageName,
80
86
  messageTypeId,
81
87
  pbPayload,
82
88
  seq,
89
+ router,
90
+ packetSrc,
91
+ dataType,
83
92
  type: messageName,
84
93
  };
85
94
  },
@@ -1,4 +1,9 @@
1
- import { PROTO_DATA_TYPE_ACK, PROTO_HEAD_CRC_SIZE, PROTO_HEAD_SOF } from './constants';
1
+ import {
2
+ PROTO_DATA_TYPE_ACK,
3
+ PROTO_DATA_TYPE_PACKET,
4
+ PROTO_HEAD_CRC_SIZE,
5
+ PROTO_HEAD_SOF,
6
+ } from './constants';
2
7
  import { crc8 } from './crc8';
3
8
 
4
9
  export interface ProtoV2Frame {
@@ -8,9 +13,23 @@ export interface ProtoV2Frame {
8
13
  pbPayload: Uint8Array;
9
14
  /** Sequence number from the frame header */
10
15
  seq: number;
16
+ /** Routing channel from the frame header */
17
+ router: number;
18
+ /** Packet source from the frame header */
19
+ packetSrc: number;
20
+ /** Packet or ACK discriminator from the frame header */
21
+ dataType: number;
11
22
  }
12
23
 
13
- function validateFrame(data: Uint8Array): number {
24
+ export type ProtoV2FrameHeader = {
25
+ frameLen: number;
26
+ router: number;
27
+ packetSrc: number;
28
+ dataType: number;
29
+ seq: number;
30
+ };
31
+
32
+ export function inspectFrameHeader(data: Uint8Array): ProtoV2FrameHeader {
14
33
  if (data.length < PROTO_HEAD_CRC_SIZE) {
15
34
  throw new Error(`Protocol V2 frame too short: ${data.length} bytes`);
16
35
  }
@@ -23,8 +42,10 @@ function validateFrame(data: Uint8Array): number {
23
42
 
24
43
  const frameLen = data[1] + data[2] * 256;
25
44
 
26
- if (data.length < frameLen) {
27
- throw new Error(`Frame truncated: expected ${frameLen} bytes, got ${data.length}`);
45
+ if (data.length !== frameLen) {
46
+ throw new Error(
47
+ `Protocol V2 frame length mismatch: expected ${frameLen} bytes, got ${data.length}`
48
+ );
28
49
  }
29
50
 
30
51
  const expectedHeaderCrc = crc8(data, 3);
@@ -45,7 +66,22 @@ function validateFrame(data: Uint8Array): number {
45
66
  );
46
67
  }
47
68
 
48
- return frameLen;
69
+ // eslint-disable-next-line no-bitwise
70
+ const dataType = data[5] & 0x03;
71
+ // eslint-disable-next-line no-bitwise
72
+ const packetSrc = (data[5] >> 2) & 0x0f;
73
+ const seq = data[6];
74
+ if (seq === 0) {
75
+ throw new Error('Invalid Protocol V2 sequence: 0 is reserved');
76
+ }
77
+
78
+ return {
79
+ frameLen,
80
+ router: data[4],
81
+ packetSrc,
82
+ dataType,
83
+ seq,
84
+ };
49
85
  }
50
86
 
51
87
  export function isAckFrame(data: Uint8Array): boolean {
@@ -54,9 +90,9 @@ export function isAckFrame(data: Uint8Array): boolean {
54
90
  return false;
55
91
  }
56
92
 
57
- const frameLen = validateFrame(data);
58
- if (frameLen !== PROTO_HEAD_CRC_SIZE) {
59
- throw new Error(`Invalid Protocol V2 ACK frame length: ${frameLen}`);
93
+ const header = inspectFrameHeader(data);
94
+ if (header.frameLen !== PROTO_HEAD_CRC_SIZE) {
95
+ throw new Error(`Invalid Protocol V2 ACK frame length: ${header.frameLen}`);
60
96
  }
61
97
  return true;
62
98
  }
@@ -72,9 +108,10 @@ export function isAckFrame(data: Uint8Array): boolean {
72
108
  * Returns the decoded messageTypeId, raw protobuf payload, and sequence number.
73
109
  */
74
110
  export function decodeFrame(data: Uint8Array): ProtoV2Frame {
75
- const frameLen = validateFrame(data);
76
-
77
- const seq = data[6];
111
+ const { frameLen, router, packetSrc, dataType, seq } = inspectFrameHeader(data);
112
+ if (dataType !== PROTO_DATA_TYPE_PACKET) {
113
+ throw new Error(`Invalid Protocol V2 data type: expected packet, got ${dataType}`);
114
+ }
78
115
  // Payload spans bytes 7 to frameLen-2 (inclusive), excluding final CRC byte
79
116
  const payloadData = data.slice(7, frameLen - 1);
80
117
 
@@ -85,5 +122,5 @@ export function decodeFrame(data: Uint8Array): ProtoV2Frame {
85
122
  const messageTypeId = payloadData[0] + payloadData[1] * 256;
86
123
  const pbPayload = payloadData.slice(2);
87
124
 
88
- return { messageTypeId, pbPayload, seq };
125
+ return { messageTypeId, pbPayload, seq, router, packetSrc, dataType };
89
126
  }
@@ -0,0 +1,23 @@
1
+ export type ProtocolV2LinkErrorCode =
2
+ | 'response-timeout'
3
+ | 'router'
4
+ | 'packet-source'
5
+ | 'ack-sequence'
6
+ | 'response-sequence'
7
+ | 'frame';
8
+
9
+ export class ProtocolV2LinkError extends Error {
10
+ readonly code: ProtocolV2LinkErrorCode;
11
+
12
+ readonly cause?: unknown;
13
+
14
+ constructor(code: ProtocolV2LinkErrorCode, message: string, cause?: unknown) {
15
+ super(message);
16
+ this.name = 'ProtocolV2LinkError';
17
+ this.code = code;
18
+ this.cause = cause;
19
+ }
20
+ }
21
+
22
+ export const isProtocolV2LinkError = (error: unknown): error is ProtocolV2LinkError =>
23
+ error instanceof ProtocolV2LinkError;
@@ -1,6 +1,7 @@
1
1
  import { PROTOCOL_V2_FRAME_MAX_BYTES } from '../../constants';
2
2
  import { PROTO_HEAD_CRC_SIZE, PROTO_HEAD_SOF, PROTO_PRE_HEAD_SIZE } from './constants';
3
3
  import { crc8 } from './crc8';
4
+ import { ProtocolV2LinkError } from './errors';
4
5
 
5
6
  export function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
6
7
  const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
@@ -58,7 +59,7 @@ export class ProtocolV2FrameAssembler {
58
59
 
59
60
  if (this.buffer[0] !== PROTO_HEAD_SOF) {
60
61
  this.reset();
61
- throw new Error('Invalid Protocol V2 SOF');
62
+ throw new ProtocolV2LinkError('frame', 'Invalid Protocol V2 SOF');
62
63
  }
63
64
 
64
65
  const expectedLen = this.buffer[1] + this.buffer[2] * 256;
@@ -67,11 +68,11 @@ export class ProtocolV2FrameAssembler {
67
68
  // complete frame: without resetting, this poison prefix would stay in
68
69
  // the buffer forever and deadlock the caller's drain loop.
69
70
  this.reset();
70
- throw new Error(`Protocol V2 frame length too small: ${expectedLen}`);
71
+ throw new ProtocolV2LinkError('frame', `Protocol V2 frame length too small: ${expectedLen}`);
71
72
  }
72
73
  if (expectedLen > this.maxFrameBytes) {
73
74
  this.reset();
74
- throw new Error(`Protocol V2 frame too large: ${expectedLen}`);
75
+ throw new ProtocolV2LinkError('frame', `Protocol V2 frame too large: ${expectedLen}`);
75
76
  }
76
77
 
77
78
  if (this.buffer.length < PROTO_PRE_HEAD_SIZE) return undefined;
@@ -82,7 +83,8 @@ export class ProtocolV2FrameAssembler {
82
83
  const expectedHeaderCrc = crc8(this.buffer, 3);
83
84
  if (this.buffer[3] !== expectedHeaderCrc) {
84
85
  this.reset();
85
- throw new Error(
86
+ throw new ProtocolV2LinkError(
87
+ 'frame',
86
88
  `Protocol V2 header CRC mismatch: expected 0x${expectedHeaderCrc
87
89
  .toString(16)
88
90
  .padStart(2, '0')}`
@@ -2,4 +2,5 @@ export * from './constants';
2
2
  export * from './crc8';
3
3
  export * from './encode';
4
4
  export * from './decode';
5
+ export * from './errors';
5
6
  export * from './frame-assembler';
@@ -1,6 +1,10 @@
1
- import { PROTOCOL_V2_PACKET_SRC_COMMAND } from '../../constants';
1
+ import {
2
+ PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
3
+ PROTOCOL_V2_PACKET_SRC_COMMAND,
4
+ } from '../../constants';
2
5
  import { ProtocolV2FrameAssembler, concatUint8Arrays } from './frame-assembler';
3
6
  import { ProtocolV2SequenceCursor } from './sequence-cursor';
7
+ import { ProtocolV2LinkError } from './errors';
4
8
  import { ProtocolV2 } from '..';
5
9
  import * as check from '../../utils/highlevel-checks';
6
10
  import { LogBlockCommand } from '../../utils/logBlockCommand';
@@ -8,6 +12,8 @@ import { LogBlockCommand } from '../../utils/logBlockCommand';
8
12
  import type { Root } from 'protobufjs/light';
9
13
  import type { MessageFromOneKey } from '../../types';
10
14
 
15
+ export * from './errors';
16
+
11
17
  export type ProtocolV2Schemas = {
12
18
  protocolV1: Root;
13
19
  protocolV2: Root;
@@ -18,6 +24,7 @@ export type ProtocolV2CallContext = {
18
24
  timeoutMs?: number;
19
25
  highVolume: boolean;
20
26
  generation: number;
27
+ signal: AbortSignal;
21
28
  };
22
29
 
23
30
  type ProtocolLogger = {
@@ -114,11 +121,13 @@ export async function withProtocolTimeout<T>(
114
121
  promise: Promise<T>,
115
122
  timeoutMs: number | undefined,
116
123
  createTimeoutError: () => Error,
117
- onTimeout?: () => void
124
+ onTimeout?: () => void,
125
+ abortSignal?: AbortSignal
118
126
  ): Promise<T> {
119
127
  if (!timeoutMs) return promise;
120
128
 
121
129
  let timer: ReturnType<typeof setTimeout> | undefined;
130
+ let abortHandler: (() => void) | undefined;
122
131
  try {
123
132
  return await Promise.race([
124
133
  promise,
@@ -130,9 +139,26 @@ export async function withProtocolTimeout<T>(
130
139
  reject(createTimeoutError());
131
140
  }, timeoutMs);
132
141
  }),
142
+ ...(abortSignal
143
+ ? [
144
+ new Promise<never>((_, reject) => {
145
+ abortHandler = () => {
146
+ reject(new Error('Protocol V2 operation aborted'));
147
+ };
148
+ if (abortSignal.aborted) {
149
+ abortHandler();
150
+ } else {
151
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
152
+ }
153
+ }),
154
+ ]
155
+ : []),
133
156
  ]);
134
157
  } finally {
135
158
  if (timer) clearTimeout(timer);
159
+ if (abortHandler) {
160
+ abortSignal?.removeEventListener('abort', abortHandler);
161
+ }
136
162
  }
137
163
  }
138
164
 
@@ -145,6 +171,8 @@ export class ProtocolV2Session {
145
171
  // in-flight calls on the same session would steal each other's responses.
146
172
  private pendingCall: Promise<unknown> = Promise.resolve();
147
173
 
174
+ private lastResponseSequence?: number;
175
+
148
176
  constructor(options: ProtocolV2SessionOptions) {
149
177
  this.options = options;
150
178
  this.sequenceCursor = options.sequenceCursor ?? new ProtocolV2SequenceCursor();
@@ -163,7 +191,7 @@ export class ProtocolV2Session {
163
191
  return result;
164
192
  }
165
193
 
166
- private async executeCall(
194
+ private executeCall(
167
195
  name: string,
168
196
  data: Record<string, unknown>,
169
197
  callOptions: ProtocolV2CallOptions
@@ -181,78 +209,105 @@ export class ProtocolV2Session {
181
209
  createTimeoutError,
182
210
  generation = 0,
183
211
  } = this.options;
212
+ const timeoutMs =
213
+ callOptions.timeoutMs && callOptions.timeoutMs > 0
214
+ ? callOptions.timeoutMs
215
+ : PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS;
216
+ const abortController = new AbortController();
184
217
 
185
218
  const shouldReduceDebug = shouldReduceProtocolV2Debug(name);
186
- const callContext: ProtocolV2CallContext = {
219
+ const baseCallContext: ProtocolV2CallContext = {
187
220
  messageName: name,
188
- timeoutMs: callOptions.timeoutMs,
221
+ timeoutMs,
189
222
  highVolume: shouldReduceDebug,
190
223
  generation,
224
+ signal: abortController.signal,
191
225
  };
192
- await prepareCall?.(callContext);
193
- const protoSeq = this.sequenceCursor.next();
194
- const frame = ProtocolV2.encodeFrame(schemas, name, data, {
195
- packetSrc,
196
- router,
197
- seq: protoSeq,
198
- });
199
- // Suppress Protocol V2 TX frame logs to avoid excessive transport diagnostics.
200
- /*
201
- const txMetadata = ProtocolV2.inspectFrame(schemas, frame);
202
-
203
- if (!shouldReduceDebug) {
204
- logger?.debug?.(`[${logPrefix}] TX`, {
205
- method: name,
206
- type: name,
207
- typeId: txMetadata.messageTypeId,
208
- seq: txMetadata.seq,
209
- bytes: frame.length,
210
- });
211
- }
212
- */
213
226
 
214
- if (maxFrameBytes !== undefined && frame.length > maxFrameBytes) {
215
- throw new Error(
216
- `Protocol V2 frame too large for transport: ${frame.length} > ${maxFrameBytes}`
217
- );
218
- }
227
+ const runCall = async (): Promise<MessageFromOneKey> => {
228
+ await prepareCall?.(baseCallContext);
229
+ const protoSeq = this.sequenceCursor.next();
230
+ const frame = ProtocolV2.encodeFrame(schemas, name, data, {
231
+ packetSrc,
232
+ router,
233
+ seq: protoSeq,
234
+ });
219
235
 
220
- await writeFrame(frame, callContext);
236
+ if (maxFrameBytes !== undefined && frame.length > maxFrameBytes) {
237
+ throw new Error(
238
+ `Protocol V2 frame too large for transport: ${frame.length} > ${maxFrameBytes}`
239
+ );
240
+ }
221
241
 
222
- // Cancellation flag for the read loop: when the response timeout fires,
223
- // Promise.race alone would leave this loop running as a zombie that keeps
224
- // consuming frames meant for the next call. The timeout callback flips the
225
- // flag so the loop exits and discards any late frame.
226
- const cancellation = { cancelled: false };
242
+ await writeFrame(frame, baseCallContext);
227
243
 
228
- const readResponse = async (): Promise<MessageFromOneKey> => {
229
244
  // Some Protocol V2 operations emit progress notifications before the
230
245
  // terminal response. Consume those frames here so callers still see a
231
246
  // request/terminal-response shaped API.
232
- while (!cancellation.cancelled) {
233
- const rxFrame = await readFrame(callContext);
234
- if (cancellation.cancelled) {
235
- // Timed out while waiting: drop the late frame and stop reading.
236
- break;
247
+ while (!abortController.signal.aborted) {
248
+ const rxFrame = await readFrame(baseCallContext);
249
+ if (abortController.signal.aborted) break;
250
+
251
+ let header: ReturnType<typeof ProtocolV2.inspectFrameHeader>;
252
+ try {
253
+ header = ProtocolV2.inspectFrameHeader(rxFrame);
254
+ } catch (cause) {
255
+ throw new ProtocolV2LinkError(
256
+ 'frame',
257
+ `Protocol V2 frame validation failed: ${getErrorMessage(cause)}`,
258
+ cause
259
+ );
260
+ }
261
+ if (header.router !== router) {
262
+ throw new ProtocolV2LinkError(
263
+ 'router',
264
+ `Protocol V2 router mismatch: expected ${router}, got ${header.router}`
265
+ );
266
+ }
267
+ if (header.packetSrc !== packetSrc) {
268
+ throw new ProtocolV2LinkError(
269
+ 'packet-source',
270
+ `Protocol V2 packet source mismatch: expected ${packetSrc}, got ${header.packetSrc}`
271
+ );
237
272
  }
238
- const isAck = ProtocolV2.isAckFrame(rxFrame);
239
- if (!isAck) {
240
- // Suppress Protocol V2 RX frame logs to avoid excessive transport diagnostics.
241
- /*
242
- const rxMetadata = ProtocolV2.inspectFrame(schemas, rxFrame);
243
-
244
- if (!shouldReduceDebug) {
245
- logger?.debug?.(`[${logPrefix}] RX`, {
246
- method: name,
247
- type: rxMetadata.type,
248
- typeId: rxMetadata.messageTypeId,
249
- seq: rxMetadata.seq,
250
- bytes: rxFrame.length,
251
- });
252
- }
253
- */
254
273
 
255
- const decoded = ProtocolV2.decodeFrame(schemas, rxFrame);
274
+ let isAck: boolean;
275
+ try {
276
+ isAck = ProtocolV2.isAckFrame(rxFrame);
277
+ } catch (cause) {
278
+ throw new ProtocolV2LinkError(
279
+ 'frame',
280
+ `Protocol V2 ACK validation failed: ${getErrorMessage(cause)}`,
281
+ cause
282
+ );
283
+ }
284
+ if (isAck) {
285
+ if (header.seq !== protoSeq) {
286
+ throw new ProtocolV2LinkError(
287
+ 'ack-sequence',
288
+ `Protocol V2 ACK sequence mismatch: expected ${protoSeq}, got ${header.seq}`
289
+ );
290
+ }
291
+ } else {
292
+ let decoded: ReturnType<typeof ProtocolV2.decodeFrame>;
293
+ try {
294
+ decoded = ProtocolV2.decodeFrame(schemas, rxFrame);
295
+ } catch (cause) {
296
+ throw new ProtocolV2LinkError(
297
+ 'frame',
298
+ `Protocol V2 frame decode failed: ${getErrorMessage(cause)}`,
299
+ cause
300
+ );
301
+ }
302
+ // Firmware owns one TX sequence across all channels and packet sources,
303
+ // so frames routed elsewhere create valid gaps in this session.
304
+ if (this.lastResponseSequence === decoded.seq) {
305
+ throw new ProtocolV2LinkError(
306
+ 'response-sequence',
307
+ `Protocol V2 duplicate response sequence: ${decoded.seq}`
308
+ );
309
+ }
310
+ this.lastResponseSequence = decoded.seq;
256
311
 
257
312
  const response = check.call(decoded);
258
313
  if (callOptions.intermediateTypes?.includes(response.type)) {
@@ -268,20 +323,19 @@ export class ProtocolV2Session {
268
323
  }
269
324
  }
270
325
  }
271
- // Only reachable after cancellation; the outer promise has already been
272
- // rejected by the timeout, so this rejection is consumed by the race.
326
+
273
327
  throw new Error(`Protocol V2 read loop cancelled after timeout for ${name}`);
274
328
  };
275
329
 
276
330
  return withProtocolTimeout(
277
- readResponse(),
278
- callOptions.timeoutMs,
331
+ runCall(),
332
+ timeoutMs,
279
333
  () =>
280
334
  createTimeoutError
281
- ? createTimeoutError(name, callOptions.timeoutMs ?? 0)
282
- : new Error(`Protocol V2 response timeout after ${callOptions.timeoutMs}ms for ${name}`),
335
+ ? createTimeoutError(name, timeoutMs)
336
+ : new Error(`Protocol V2 response timeout after ${timeoutMs}ms for ${name}`),
283
337
  () => {
284
- cancellation.cancelled = true;
338
+ abortController.abort();
285
339
  }
286
340
  );
287
341
  }
@@ -312,7 +366,10 @@ export async function probeProtocolV2({
312
366
  const response = await call(
313
367
  'Ping',
314
368
  { message: 'protocol-v2-probe' },
315
- { timeoutMs, expectedTypes: ['Success'] }
369
+ {
370
+ timeoutMs,
371
+ expectedTypes: ['Success'],
372
+ }
316
373
  );
317
374
  if (response.type === 'Success') {
318
375
  return true;