📝 Description
While the Ledger device's own OS UI is in the foreground (I reproduced with the Control Center open on a Nano X), BOLOS answers every APDU with status word 0x6901. This happens under the 0xb0 (BOLOS) CLA as much as 0xe0, so the refusal sits above the application layer — GetAppAndVersionCommand and GetOsVersionCommand both get 0x6901.
0x6901 is not in GLOBAL_ERRORS (GlobalCommandError.ts maps only 5515 / 5501 / 5502 / 5223 / 6e00 / 6d00), so it becomes an UnknownDeviceExchangeError. Two things follow, and the combination is what makes this hard to handle as a consumer:
1. OpenAppDeviceAction fails at its first step and never sends OpenApp.
Its first state is GetDeviceStatus, which goes through WaitForAppAndVersionDeviceAction → GetAppAndVersionCommand. That command result is an error, the machine transitions straight to Error, and 0xe0d8 (OpenApp) is never sent. The device therefore never displays the "Open <app>?" prompt, and from the user's point of view tapping "connect" simply does nothing.
Compare with a locked device (0x5515): that code is mapped, WaitForAppAndVersionDeviceAction.isLocked recognises it, and the action requests UserInteractionRequired.UnlockDevice and keeps polling until unlockTimeout. That path recovers gracefully. The only difference is whether the status word is in the table.
2. deviceStatus keeps reporting CONNECTED, so consumers cannot detect the state either.
DeviceSession.sendApdu dispatches on transport success without looking at the status word beyond one check:
.ifRight((response: ApduResponse) => {
if (CommandUtils.isLockedDeviceResponse(response)) {
dispatch({ eventName: SessionEvents.DEVICE_STATE_UPDATE_LOCKED });
} else {
dispatch({ eventName: SessionEvents.DEVICE_STATE_UPDATE_CONNECTED }); // <- 0x6901 lands here
}
})
.ifLeft(() => dispatch({ eventName: SessionEvents.DEVICE_STATE_UPDATE_UNKNOWN }));
isLockedDeviceResponse matches 5515 / 6982 / 5303, so 0x6901 is classified as "connected". Meanwhile DeviceSessionStateHandler._parseDeviceStatus returns null for the failed command result and only emits a debug log ("Error while parsing APDU response"), so currentApp is never updated and stays undefined.
Net effect: a session that refuses every command reports deviceStatus: CONNECTED with currentApp: undefined, and there is no observable signal a consumer can gate on.
3. The status word is not recoverable from the error message.
UnknownDeviceExchangeError.message is the fixed string "Unexpected device exchange error happened.". The status word survives only on originalError.errorCode, because GlobalCommandErrorHandler passes { message: "UnknownError", errorCode } as the original error. So classifying this in consumer code requires reaching into error.originalError.errorCode, which is not part of the public typed surface.
🙁 Actual Behavior
With the Control Center open on the device, OpenAppDeviceAction errors out at GetDeviceStatus without ever sending OpenApp. The device shows no prompt. The consumer receives:
[DMK] [sendCommand] getAppAndVersion
[DMK] [will send APDU] => b001000000
[DMK] [exchange] <= 6901
[DMK] Error while parsing APDU response
[DMK] [XStateDeviceAction] State: GetDeviceStatus
[DMK] [exchange] <= 6901
[DMK] [XStateDeviceAction] State: Error
→ UnknownDeviceExchangeError: "Unexpected device exchange error happened." / originalError { message: "UnknownError", errorCode: "6901" }
GetOsVersionCommand behaves the same way in this state:
[DMK] [will send APDU] => e001000000
[DMK] [exchange] <= 6901
Throughout, getDeviceSessionState() reports deviceStatus: CONNECTED.
For contrast, on the same device on the dashboard the same APDUs succeed:
=> b001000000 <= 0105424f4c4f5305322e372e319000 (BOLOS 2.7.1)
=> e001000000 <= 3300000405322e372e3104e6...9000 (getOsVersion OK)
And when the device is locked, the mapped path produces a clean, recoverable result:
=> b001000000 <= 5515 → DeviceLockedError, UnlockDevice interaction
🤩 Expected Behavior
Either of the following would be enough to make this handleable; the second is what I would hope for:
-
Map 0x6901 in GLOBAL_ERRORS with its own tag (e.g. DeviceNotInDashboardError / DeviceUiBusyError), so consumers get a typed error instead of UnknownDeviceExchangeError.
-
Treat it in WaitForAppAndVersionDeviceAction the way 0x5515 is treated — keep polling within unlockTimeout and surface a new UserInteractionRequired value (something like "return-to-dashboard"). OpenAppDeviceAction would then recover on its own the moment the user dismisses the OS screen, exactly as it already does after a PIN unlock, instead of failing and requiring the user to re-trigger the whole flow.
Additionally, and independently of 0x6901: it would help to have the status word on the error's own public surface rather than only on originalError.errorCode, so unmapped codes can be classified without reaching into an untyped field.
🎬 Steps to Reproduce
- Connect a Nano X and open a device session (
dmk.connect()), device unlocked and on the dashboard.
- On the device, open the Control Center so its OS UI is in the foreground.
- Run
dmk.executeDeviceAction({ sessionId, deviceAction: new OpenAppDeviceAction({ input: { appName: "Ethereum" } }) }).
- Observe: the action reaches
DeviceActionStatus.Error with UnknownDeviceExchangeError, no 0xe0d8 is ever sent, the device never prompts, and getDeviceSessionState() reported deviceStatus: CONNECTED the whole time.
A plain dmk.sendCommand({ sessionId, command: new GetAppAndVersionCommand() }) in the same device state is enough to see the <= 6901 exchange and the resulting UnknownDeviceExchangeError.
🧑💻 Context
We are integrating DMK to talk to Ledger devices from a mobile app. Users reasonably leave the device on whatever screen it was last on, and the Control Center is one tap away from the dashboard, so this state is easy to land in accidentally.
Because deviceStatus says CONNECTED and the error carries no usable message, there was no way to tell the user what was wrong — "connect" appeared to do nothing at all, with no prompt on the device and a generic "Unexpected device exchange error happened." in the app. We ended up matching on error.originalError.errorCode === "6901" to produce an actionable message ("return to the dashboard on your device"), which works but depends on an internal shape we would rather not rely on.
Note on our setup, for completeness: we run DMK with a custom transport (BLE, bridged out to the platform's own Bluetooth stack). The failure is above the transport, though — the transport delivers the 6901 response correctly, and the same status word appears in the [exchange] <= log line. The behaviour follows purely from the status-word tables in GlobalCommandError.ts / CommandUtils.isLockedDeviceResponse, which are transport-independent.
I verified the whole area is unchanged on main as of today, so this is not something already fixed and pending release.
One small related inconsistency noticed while reading that code, in case it is useful: CommandUtils.isLockedDeviceResponse matches 6982, but 6982 is not in GLOBAL_ERRORS. A device answering 6982 therefore moves the session to LOCKED while the command result is an UnknownDeviceExchangeError — the two tables disagree about the same status word.
🖥️ Your Environment
- DMK Version: 1.8.0
- Ledger Device Model: Nano X
- Ledger Device OS Version: 2.7.1
- Connection Method: Bluetooth (custom transport bridging to the platform Bluetooth stack)
- Application Name and Version: device-state dependent, not app specific — reproduced from the dashboard with
OpenAppDeviceAction targeting Ethereum 1.22.2; the same 6901 is returned with the Tron app 0.7.5 open
- Browser Name and Version: n/a (DMK runs in a WebView, not a browser page)
- Operating System and Version: iOS (physical device)
- Project Link: n/a (closed source)
📝 Description
While the Ledger device's own OS UI is in the foreground (I reproduced with the Control Center open on a Nano X), BOLOS answers every APDU with status word
0x6901. This happens under the0xb0(BOLOS) CLA as much as0xe0, so the refusal sits above the application layer —GetAppAndVersionCommandandGetOsVersionCommandboth get0x6901.0x6901is not inGLOBAL_ERRORS(GlobalCommandError.tsmaps only5515 / 5501 / 5502 / 5223 / 6e00 / 6d00), so it becomes anUnknownDeviceExchangeError. Two things follow, and the combination is what makes this hard to handle as a consumer:1.
OpenAppDeviceActionfails at its first step and never sendsOpenApp.Its first state is
GetDeviceStatus, which goes throughWaitForAppAndVersionDeviceAction→GetAppAndVersionCommand. That command result is an error, the machine transitions straight toError, and0xe0d8(OpenApp) is never sent. The device therefore never displays the "Open <app>?" prompt, and from the user's point of view tapping "connect" simply does nothing.Compare with a locked device (
0x5515): that code is mapped,WaitForAppAndVersionDeviceAction.isLockedrecognises it, and the action requestsUserInteractionRequired.UnlockDeviceand keeps polling untilunlockTimeout. That path recovers gracefully. The only difference is whether the status word is in the table.2.
deviceStatuskeeps reportingCONNECTED, so consumers cannot detect the state either.DeviceSession.sendApdudispatches on transport success without looking at the status word beyond one check:isLockedDeviceResponsematches5515 / 6982 / 5303, so0x6901is classified as "connected". MeanwhileDeviceSessionStateHandler._parseDeviceStatusreturnsnullfor the failed command result and only emits a debug log ("Error while parsing APDU response"), socurrentAppis never updated and staysundefined.Net effect: a session that refuses every command reports
deviceStatus: CONNECTEDwithcurrentApp: undefined, and there is no observable signal a consumer can gate on.3. The status word is not recoverable from the error message.
UnknownDeviceExchangeError.messageis the fixed string"Unexpected device exchange error happened.". The status word survives only onoriginalError.errorCode, becauseGlobalCommandErrorHandlerpasses{ message: "UnknownError", errorCode }as the original error. So classifying this in consumer code requires reaching intoerror.originalError.errorCode, which is not part of the public typed surface.🙁 Actual Behavior
With the Control Center open on the device,
OpenAppDeviceActionerrors out atGetDeviceStatuswithout ever sendingOpenApp. The device shows no prompt. The consumer receives:GetOsVersionCommandbehaves the same way in this state:Throughout,
getDeviceSessionState()reportsdeviceStatus: CONNECTED.For contrast, on the same device on the dashboard the same APDUs succeed:
And when the device is locked, the mapped path produces a clean, recoverable result:
🤩 Expected Behavior
Either of the following would be enough to make this handleable; the second is what I would hope for:
Map
0x6901inGLOBAL_ERRORSwith its own tag (e.g.DeviceNotInDashboardError/DeviceUiBusyError), so consumers get a typed error instead ofUnknownDeviceExchangeError.Treat it in
WaitForAppAndVersionDeviceActionthe way0x5515is treated — keep polling withinunlockTimeoutand surface a newUserInteractionRequiredvalue (something like"return-to-dashboard").OpenAppDeviceActionwould then recover on its own the moment the user dismisses the OS screen, exactly as it already does after a PIN unlock, instead of failing and requiring the user to re-trigger the whole flow.Additionally, and independently of
0x6901: it would help to have the status word on the error's own public surface rather than only onoriginalError.errorCode, so unmapped codes can be classified without reaching into an untyped field.🎬 Steps to Reproduce
dmk.connect()), device unlocked and on the dashboard.dmk.executeDeviceAction({ sessionId, deviceAction: new OpenAppDeviceAction({ input: { appName: "Ethereum" } }) }).DeviceActionStatus.ErrorwithUnknownDeviceExchangeError, no0xe0d8is ever sent, the device never prompts, andgetDeviceSessionState()reporteddeviceStatus: CONNECTEDthe whole time.A plain
dmk.sendCommand({ sessionId, command: new GetAppAndVersionCommand() })in the same device state is enough to see the<= 6901exchange and the resultingUnknownDeviceExchangeError.🧑💻 Context
We are integrating DMK to talk to Ledger devices from a mobile app. Users reasonably leave the device on whatever screen it was last on, and the Control Center is one tap away from the dashboard, so this state is easy to land in accidentally.
Because
deviceStatussaysCONNECTEDand the error carries no usable message, there was no way to tell the user what was wrong — "connect" appeared to do nothing at all, with no prompt on the device and a generic "Unexpected device exchange error happened." in the app. We ended up matching onerror.originalError.errorCode === "6901"to produce an actionable message ("return to the dashboard on your device"), which works but depends on an internal shape we would rather not rely on.Note on our setup, for completeness: we run DMK with a custom transport (BLE, bridged out to the platform's own Bluetooth stack). The failure is above the transport, though — the transport delivers the
6901response correctly, and the same status word appears in the[exchange] <=log line. The behaviour follows purely from the status-word tables inGlobalCommandError.ts/CommandUtils.isLockedDeviceResponse, which are transport-independent.I verified the whole area is unchanged on
mainas of today, so this is not something already fixed and pending release.One small related inconsistency noticed while reading that code, in case it is useful:
CommandUtils.isLockedDeviceResponsematches6982, but6982is not inGLOBAL_ERRORS. A device answering6982therefore moves the session toLOCKEDwhile the command result is anUnknownDeviceExchangeError— the two tables disagree about the same status word.🖥️ Your Environment
OpenAppDeviceActiontargeting Ethereum 1.22.2; the same6901is returned with the Tron app 0.7.5 open