Connection Flow
Step-by-step BLE connection sequence from host scan to APDU-ready state.
Overview
Before any APDU command can be exchanged, the host app and IRON Vault must complete a fixed handshake sequence. This sequence is identical to what a real Ledger Nano X performs, because OKX Wallet and MetaMask both use @ledgerhq/hw-transport-web-ble under the hood — a library that expects this exact flow.
The entire handshake from scan to APDU-ready typically completes in under two seconds on a modern Android device.
Connection Sequence
Host App IRON Vault (Android)
| |
| 1. BLE scan (active scan) |
|<---- ADV_IND: service UUID ------------- | Advertising data
|<---- SCAN_RSP: "Nano X" ---------------- | Scan response
| |
| 2. GATT connect |
|---- CONNECT_REQ ---------------------> |
|<--- onConnectionStateChange(CONNECTED) - |
| |
| 3. Service & characteristic discovery |
|---- ATT_READ_BY_GROUP_TYPE -----------> |
|<--- Service: 13d63400-2c97-0004-0000-... |
|---- ATT_READ_BY_TYPE -----------------> |
|<--- Notify / Write / WriteCmd chars ---- |
| |
| 4. Enable CCCD notifications |
|---- Write 0x01 0x00 to CCCD (00002902) > |
|<--- GATT_SUCCESS ----------------------- | onDescriptorWriteRequest
| |
| 5. MTU negotiation |
|---- Host sends: 08 00 00 00 00 -------> | TAG_MTU frame
|<--- Device replies: 08 00 00 00 00 14 -- | 14 hex = 20 decimal (MTU payload)
| onMtuChanged fires; ATT_MTU agreed |
| Effective frame size = ATT_MTU - 3 |
| |
| 6. GET_VERSION |
|---- E0 01 00 00 00 ------------------> |
|<--- target_id + SE ver + MCU ver + 9000 |
| |
| 7. GET_APP_AND_VERSION |
|---- B0 01 00 00 00 ------------------> |
|<--- format + app name + version + 9000 - |
| |
| 8. OPEN_APP |
|---- E0 D8 00 00 08 "Ethereum" -------> | or "Solana", "Bitcoin"
|<--- 9000 ------------------------------ |
| |
| 9. APDU-ready |
|---- GET_ADDRESS / SIGN ... -----------> |Step-by-Step Breakdown
Step 1 — BLE Scan
The host performs an active BLE scan. IRON Vault's advertising data contains the Ledger service UUID (13d63400-2c97-0004-0000-4c6564676572); the scan response contains the device name "Nano X". The host library filters by service UUID, so the device is identified immediately.
Step 2 — GATT Connect
The host calls BluetoothGatt.connect(). On the Android peripheral side, BluetoothGattServerCallback.onConnectionStateChange() fires with newState = STATE_CONNECTED. The device records the connected BluetoothDevice reference for subsequent sendResponse() and notifyCharacteristicChanged() calls.
Step 3 — Service and Characteristic Discovery
The host calls BluetoothGatt.discoverServices(). Android returns the registered BluetoothGattService with its three characteristics (Notify, Write, WriteCmd) and the CCCD descriptor on the Notify characteristic.
Step 4 — Enable CCCD Notifications
The host writes 0x01 0x00 to the CCCD descriptor (00002902-0000-1000-8000-00805f9b34fb) on the Notify characteristic. The peripheral's onDescriptorWriteRequest() callback receives this, sends GATT_SUCCESS, and marks the connection as notification-ready. Without this step, all notifyCharacteristicChanged() calls are no-ops.
Step 5 — MTU Negotiation
MTU negotiation uses the TAG_MTU protocol tag (0x08) rather than the raw ATT MTU Exchange request. The host sends the 5-byte frame 08 00 00 00 00 over the Write or WriteCmd characteristic. The device responds via the Notify characteristic with 08 00 00 00 00 14, where 0x14 = 20 — the default MTU payload size (ATT_MTU 23 - 3 bytes ATT header).
onMtuChanged() may fire before or after this exchange depending on the host OS; the device uses the negotiated value to determine how many bytes fit in each APDU chunk.
Effective payload per frame:
- First APDU chunk:
ATT_MTU - 3 - 5 header bytes= 15 bytes at MTU 23 - Subsequent chunks:
ATT_MTU - 3 - 3 header bytes= 17 bytes at MTU 23
Step 6 — GET_VERSION
The host sends E0 01 00 00 00. The device responds with a fixed firmware version structure:
target_id (4 bytes) + SE_version_length (1) + SE_version + flags_length (1) + flags (4) + MCU_version_length (1) + MCU_version + 9000Example decoded response:
target_id = 33000004
SE version = "2.1.0"
flags = 00000000
MCU version= "1.13\0"Step 7 — GET_APP_AND_VERSION
The host sends B0 01 00 00 00 to determine which Ledger app is currently active. The device responds with the currently active chain's app name and version:
01 + name_length (1) + name + version_length (1) + version + flags_length (1) + flags (1) + 9000For example, when the Ethereum app is active: format=01 name="Ethereum" ver="1.10.3" flags=02.
Step 8 — OPEN_APP
The host sends E0 D8 00 00 <Lc> <AppName> to switch to the desired chain's app. Lc is the byte length of the ASCII app name. Common app names: "Ethereum", "Solana", "Bitcoin". The device responds 9000 and sets its internal active-app state accordingly.
Step 9 — APDU-Ready
The connection is fully initialized. The host now sends chain-specific APDU commands: GET_ADDRESS (E0 02 for ETH, E0 05 for SOL), SIGN (E0 04 for ETH, E0 06 for SOL), etc.
Connection State Lifecycle
DISCONNECTED
│
│ Host connects
▼
CONNECTED ──► (handshake steps 2–8)
│
│ Host disconnects / BLE link drops
▼
DISCONNECTED
│
│ Device restarts advertiser automatically
▼
ADVERTISING (waiting for next host)onConnectionStateChange() is the single callback for both connect and disconnect events. On disconnect the device must:
- Clear the stored
BluetoothDevicereference - Cancel any in-progress APDU reassembly buffer
- Call
BluetoothLeAdvertiser.startAdvertising()to resume advertising
Failing to restart advertising after disconnect is the most common integration bug — the device becomes invisible to the host after the first session.
How OKX and MetaMask Perform This Handshake
Both apps use @ledgerhq/hw-transport-web-ble (or its React Native counterpart @ledgerhq/react-native-hw-transport-ble). The library performs steps 1–8 automatically when TransportBLE.open(device) is called. From the app developer's perspective the sequence is opaque — the library surfaces a Transport object only after the full handshake succeeds.
MetaMask performs the OPEN_APP step for "Ethereum" unconditionally on connect. OKX first reads GET_APP_AND_VERSION and only sends OPEN_APP if the active app name does not match the desired chain — this means IRON Vault must track and respond to GET_APP_AND_VERSION accurately, not just return a fixed value.