This module provides Trezor hardware wallet integration for bitkit-core, supporting both USB and Bluetooth (BLE) connections on mobile (Android/iOS) and desktop platforms.
┌─────────────────────────────────────────────────────────────────────────────┐
│ bitkit-android │
├─────────────────────────────────────────────────────────────────────────────┤
│ TrezorTestView.kt TrezorViewModel.kt TrezorRepo.kt TrezorService.kt│
│ (UI) (State) (State Mgmt) (FFI Bridge) │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ UniFFI
┌──────────────────────────────────▼──────────────────────────────────────────┐
│ bitkit-core │
├─────────────────────────────────────────────────────────────────────────────┤
│ lib.rs (exports) ←→ TrezorManager ←→ TrezorTransportCallback (trait) │
│ │ ▲ │
│ │ │ implemented by │
│ ▼ │ native layer │
│ ConnectedDevice │ │
└──────────────────────────────┬────────────────────────┼─────────────────────┘
│ │
┌──────────────────────────────▼────────────────────────┼─────────────────────┐
│ trezor-connect-rs │
├─────────────────────────────────────────────────────────────────────────────┤
│ CallbackTransport ←→ THP Protocol ←→ TransportCallback (trait) │
│ │ │ │
│ │ ┌──────┴──────┐ │
│ │ │ Handshake │ │
│ │ │ Pairing │ │
│ │ │ Encryption │ │
│ │ └─────────────┘ │
│ ▼ │
│ Protocol V1 (USB) / THP (BLE) │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────────────────┐
│ Native Layer (Kotlin/Swift) │
├─────────────────────────────────────────────────────────────────────────────┤
│ TrezorTransport.kt implements TrezorTransportCallback │
│ │ │
│ ├── USB: Android USB Host API │
│ └── BLE: Android Bluetooth API (CoreBluetooth on iOS) │
└─────────────────────────────────────────────────────────────────────────────┘
FFI-compatible types exposed via UniFFI:
TrezorDeviceInfo- Device metadata (id, transport type, name, model)TrezorFeatures- Device capabilities and stateTrezorGetAddressParams/TrezorAddressResponse- Address derivationTrezorSignMessageParams/TrezorSignedMessageResponse- Message signingTrezorVerifyMessageParams- Signature verificationTrezorScriptType- Bitcoin script types (P2PKH, P2SH-P2WPKH, P2WPKH, P2TR)
Error types with UniFFI support:
TrezorError- Main error enum with variants for transport, connection, device errors
Helper that maps generic AccountType to Trezor's TrezorScriptType for transaction signing.
Blockchain query functions (account info, address info) and transaction composition now live in
the generic onchain module.
account_type_to_script_type(account_type)- MapsAccountTypetoTrezorScriptTypefor signing inputs
Core TrezorManager struct:
- Manages device lifecycle (scan, connect, disconnect)
- Stores connected
ConnectedDevicefor session reuse - Bridges between UniFFI callbacks and trezor-connect-rs library
// Callback trait - implemented by native layer
#[uniffi::export(with_foreign)]
pub trait TrezorTransportCallback: Send + Sync {
fn enumerate_devices(&self) -> Vec<NativeDeviceInfo>;
fn open_device(&self, path: String) -> TrezorTransportWriteResult;
fn close_device(&self, path: String) -> TrezorTransportWriteResult;
fn read_chunk(&self, path: String) -> TrezorTransportReadResult;
fn write_chunk(&self, path: String, data: Vec<u8>) -> TrezorTransportWriteResult;
fn get_chunk_size(&self, path: String) -> u32;
fn get_pairing_code(&self) -> String;
fn save_thp_credential(&self, device_id: String, credential_json: String) -> bool;
fn load_thp_credential(&self, device_id: String) -> Option<String>;
}
// Main API functions
pub async fn trezor_initialize(credential_path: Option<String>) -> Result<(), TrezorError>;
pub async fn trezor_scan() -> Result<Vec<TrezorDeviceInfo>, TrezorError>;
pub async fn trezor_connect(device_id: String) -> Result<TrezorFeatures, TrezorError>;
pub async fn trezor_get_address(params: TrezorGetAddressParams) -> Result<TrezorAddressResponse, TrezorError>;
pub async fn trezor_sign_message(params: TrezorSignMessageParams) -> Result<TrezorSignedMessageResponse, TrezorError>;
pub async fn trezor_verify_message(params: TrezorVerifyMessageParams) -> Result<bool, TrezorError>;
pub async fn trezor_disconnect() -> Result<(), TrezorError>;App Start
│
▼
trezor_set_transport_callback(callback) ← Native layer provides I/O implementation
│
▼
trezor_initialize(credential_path) ← Validates callback is set
trezor_scan()
│
▼
callback.enumerate_devices() ← Native scans USB + BLE
│
▼
Returns Vec<TrezorDeviceInfo> ← Device list with transport type
trezor_connect(device_id)
│
▼
callback.open_device(path) ← Native opens USB/BLE connection
│
▼
CallbackTransport::new(adapter) ← Create transport wrapper
│
▼
transport.acquire(path, None) ← Acquire session
│ │
│ ┌──────────────────┴──────────────────┐
│ │ USB Path │
│ │ Protocol V1 (unencrypted chunks) │
│ └─────────────────────────────────────┘
│ │
│ ┌──────────────────┴──────────────────┐
│ │ BLE Path │
│ │ │
│ │ 1. Channel Allocation │
│ │ 2. THP Handshake │
│ │ - Load stored credentials? │
│ │ - If yes: try_to_unlock=true │
│ │ - If no: fresh pairing │
│ │ 3. Pairing (if needed) │
│ │ - callback.get_pairing_code() │
│ │ - User enters 6-digit code │
│ │ 4. Save credentials │
│ │ - callback.save_thp_credential() │
│ │ 5. Encrypted channel established │
│ └─────────────────────────────────────┘
│
▼
ConnectedDevice::new(info, transport, session)
│
▼
device.initialize() ← Get device features
│
▼
Store in TrezorManager.connected_device ← REUSED for all operations
trezor_get_address(params)
│
▼
TrezorManager.connected_device.lock() ← Get stored device
│
▼
device.get_address(params) ← Uses existing encrypted channel
│
▼
Return address ← No new handshake needed!
Important: The ConnectedDevice is stored and reused for all operations. This is critical for BLE - creating a new transport for each operation would trigger a fresh THP handshake and pairing prompt every time.
trezor_disconnect()
│
▼
device.disconnect() ← Release session
│
▼
callback.close_device(path) ← Native closes connection
│
▼
Clear TrezorManager.connected_device ← Ready for new connection
Trezor Host Protocol provides encrypted communication over BLE:
Host Trezor
│ │
│──── Channel Allocation Request ───────▶│
│◀─── Channel Allocation Response ───────│
│ │
│──── Handshake Init (ephemeral key) ───▶│
│◀─── Handshake Init Response ───────────│
│ │
│──── Handshake Completion ─────────────▶│
│◀─── Handshake Completion Response ─────│
│ │
│ [If pairing needed] │
│◀─── Pairing Request (code on screen) ──│
│──── Pairing Code (user enters) ───────▶│
│◀─── Credential Response ───────────────│
│ │
│ [Encrypted channel ready] │
│◀════ Encrypted Messages ══════════════▶│
After successful pairing, credentials are saved:
{
"host_static_key": "hex-encoded-32-bytes",
"trezor_static_public_key": "hex-encoded-32-bytes",
"credential": "hex-encoded-credential-token"
}On reconnection:
load_thp_credential(device_id)retrieves stored JSON- Handshake uses
try_to_unlock=truewith stored credential - If valid, pairing dialog is skipped
Implements TrezorTransportCallback:
@Singleton
class TrezorTransport @Inject constructor(
private val context: Context,
private val bleManager: TrezorBleManager,
private val usbManager: TrezorUsbManager,
) : TrezorTransportCallback {
// Device enumeration
override fun enumerateDevices(): List<NativeDeviceInfo>
// Connection management
override fun openDevice(path: String): TrezorTransportWriteResult
override fun closeDevice(path: String): TrezorTransportWriteResult
// Raw I/O (64-byte USB chunks, 244-byte BLE chunks)
override fun readChunk(path: String): TrezorTransportReadResult
override fun writeChunk(path: String, data: ByteArray): TrezorTransportWriteResult
override fun getChunkSize(path: String): UInt
// Pairing UI
override fun getPairingCode(): String // Blocks, shows dialog, returns code
// Credential storage
override fun saveThpCredential(deviceId: String, credentialJson: String): Boolean
override fun loadThpCredential(deviceId: String): String?
}Suspending wrapper for FFI calls:
@Singleton
class TrezorService @Inject constructor(private val transport: TrezorTransport) {
suspend fun initialize(credentialPath: String?) = ServiceQueue.CORE.background { ... }
suspend fun scan(): List<TrezorDeviceInfo> = ServiceQueue.CORE.background { ... }
suspend fun connect(deviceId: String): TrezorFeatures = ServiceQueue.CORE.background { ... }
suspend fun getAddress(...): TrezorAddressResponse = ServiceQueue.CORE.background { ... }
suspend fun signMessage(...): TrezorSignedMessageResponse = ServiceQueue.CORE.background { ... }
suspend fun verifyMessage(...): Boolean = ServiceQueue.CORE.background { ... }
suspend fun disconnect() = ServiceQueue.CORE.background { ... }
}State management with Kotlin Flow:
data class TrezorState(
val isInitialized: Boolean = false,
val isScanning: Boolean = false,
val isConnecting: Boolean = false,
val devices: List<TrezorDeviceInfo> = emptyList(),
val connectedDevice: TrezorFeatures? = null,
val lastAddress: TrezorAddressResponse? = null,
val error: String? = null,
)
@Singleton
class TrezorRepo @Inject constructor(...) {
private val _state = MutableStateFlow(TrezorState())
val state = _state.asStateFlow()
val needsPairingCode = trezorTransport.needsPairingCode // For UI dialog
suspend fun initialize(): Result<Unit>
suspend fun scan(): Result<List<TrezorDeviceInfo>>
suspend fun connect(deviceId: String): Result<TrezorFeatures>
suspend fun getAddress(...): Result<TrezorAddressResponse>
// ...
}UI state and actions:
@HiltViewModel
class TrezorViewModel @Inject constructor(
private val trezorRepo: TrezorRepo,
) : ViewModel() {
val trezorState = trezorRepo.state.stateIn(...)
val needsPairingCode = trezorRepo.needsPairingCode.stateIn(...)
fun initialize() { ... }
fun scan() { ... }
fun connect() { ... }
fun getAddress(showOnTrezor: Boolean) { ... }
fun signMessage() { ... }
fun verifyMessage() { ... }
fun disconnect() { ... }
fun submitPairingCode(code: String) { ... }
fun cancelPairingCode() { ... }
}Service UUID: 8c000001-a59b-4d58-a9ad-073df69fa1b1
Read Characteristic: 8c000002-a59b-4d58-a9ad-073df69fa1b1
Write Characteristic: 8c000003-a59b-4d58-a9ad-073df69fa1b1
Chunk Size: 244 bytes
Cause: Creating new CallbackTransport for each operation instead of reusing ConnectedDevice.
Fix: Store ConnectedDevice after connect() and reuse for all operations.
Cause: Multi-chunk responses not fully received. Fix: Loop reading until complete message assembled (check continuation bit in THP header).
Cause: Credentials not saved after pairing or not loaded before handshake.
Fix: Ensure save_thp_credential called after successful pairing, load_thp_credential called before handshake.
cd bitkit-core
./build_android.sh
cd ../bitkit-android
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home ./gradlew assembleDevDebugcd bitkit-core
cargo build
cargo run --bin uniffi-bindgen generate \
--library target/debug/libbitkitcore.dylib \
--language kotlin \
--out-dir bindings/android/lib/src/main/kotlinUse TrezorTestView.kt in the app (dev builds only) to test:
- Initialize - Sets up transport callback
- Scan - Discovers USB and BLE devices
- Connect - Opens connection, triggers pairing for BLE
- Get Address - Derives Bitcoin address
- Sign Message - Signs with device confirmation
- Verify Message - Verifies signature on device
- Disconnect - Closes connection