From 249715430e0bfccf63092e91fa1310c4dc7a52e6 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:40:00 -0400 Subject: [PATCH] fix(offline): a wallet created in this session builds its Bluetooth transfer stack Startup init builds the BluetoothManager and injects the frame coordinator and transport adapter into the bilateral handler only when an identity already exists. Wallet creation installed the full router but not that stack, so a phone that created its wallet in this app session paired over Bluetooth (a different path) yet answered every offline send with "the BLE stack is not live yet: Ble transport adapter not injected yet" until the app restarted. Seen on the rig on 2026-09-27; a restart was the only way through. The Android block moves out of init into build_ble_stack_for_identity(), which init still calls at the same point, and system.createGenesisV2 calls it once the new identity's router is up. A failure there rolls the genesis back like the router install before it does. cargo ndk -t arm64-v8a check -p dsm_sdk --features jni,bluetooth: clean (the code is Android-only). Android clippy reports no finding in either file. make lint: passed. --- .../dsm_sdk/src/handlers/system_routes.rs | 11 + .../dsm_sdk/src/init.rs | 329 +++++++++--------- 2 files changed, 181 insertions(+), 159 deletions(-) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/system_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/system_routes.rs index 21fa74e96..c69f10898 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/system_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/system_routes.rs @@ -175,6 +175,17 @@ pub(crate) fn handle_create_genesis_v2_query(q: AppQuery) -> AppResult { } } + // THE BLUETOOTH TRANSFER STACK for the new identity, once its router is up. Startup builds it only when an + // identity already exists, so without this a wallet created in this session paired over + // Bluetooth but refused every offline send ("the BLE stack is not live yet") until the + // app restarted. + #[cfg(all(target_os = "android", feature = "bluetooth"))] + if let Err(e) = crate::init::build_ble_stack_for_identity() { + return fail_rolled_back(format!( + "system.createGenesisV2: the Bluetooth transfer stack could not be built: {e}" + )); + } + // 5c. IDENTITY PUBLICATION, driven in THIS session: this device's own // directory entry, read back from the network's pinned set // (`identity_publication`). The row FIRST, so a crash between here and diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/init.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/init.rs index 7832ae320..532d63a22 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/init.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/init.rs @@ -496,181 +496,192 @@ pub fn init_dsm_sdk(cfg: &SdkConfig) -> Result<(), String> { log::info!("[SDK Init] Core handlers (Unilateral, Bilateral, Recovery) installed successfully"); - // 6) BLE (Android only). BLE init can be deferred if identity is not ready, but the core - // handlers above must remain installed so bootstrap queries work before genesis. + // 6) BLE (Android only): this identity's Bluetooth transfer stack. Deferred until an + // identity exists; the core handlers above stay installed so bootstrap queries work before + // genesis, and wallet creation builds it once the identity is there. #[cfg(all(target_os = "android", feature = "bluetooth"))] - { - // Create and register BluetoothManager using AppState identity. - // Identity MUST be available - this is called post-genesis only. - use tokio::sync::RwLock as TokioRwLock; - use dsm::core::{ - contact_manager::DsmContactManager, - bilateral_transaction_manager::BilateralTransactionManager, - }; - - let (dev, gen) = match ( - crate::sdk::app_state::AppState::get_device_id(), - crate::sdk::app_state::AppState::get_genesis_hash(), - ) { - (Some(d), Some(g)) => (d, g), - (None, ..) | (.., None) => { - // Identity not ready: no BLE stack yet; the SDK still answers - // bootstrap queries. The init after genesis builds the stack. - log::warn!( - "[SDK Init] identity not ready (device_id/genesis missing): no BLE stack yet" - ); - return Ok(()); - } - }; + build_ble_stack_for_identity()?; - let mut dev_fixed = [0u8; 32]; - let mut gen_fixed = [0u8; 32]; - if dev.len() != 32 || gen.len() != 32 { - log::error!("[SDK Init] device_id and genesis_hash must be exactly 32 bytes"); - return Err("device_id and genesis_hash must be exactly 32 bytes".to_string()); - } - dev_fixed.copy_from_slice(&dev); - gen_fixed.copy_from_slice(&gen); - - // Backfill Device Tree root (§2.3) for existing identities created before this was - // persisted at genesis time. The root of a single-device tree is deterministic from - // dev_fixed, so it is always safe to recompute and overwrite. - // Without the root no receipt is built: the producer checks each receipt's device - // proof against it, so every bilateral step would be refused. - { - let root = dsm::common::device_tree::DeviceTree::single(dev_fixed).root(); - crate::sdk::app_state::AppState::set_device_tree_root(root) - .map_err(|e| format!("persist the device tree root: {e}"))?; - log::info!( - "[SDK Init] Device tree root computed and persisted (dev={})", - crate::util::text_id::encode_base32_crockford(&dev_fixed) + Ok(()) +} + +/// Build this identity's Bluetooth transfer stack: the BluetoothManager for the identity in +/// AppState, the frame coordinator and transport adapter injected into the bilateral handler, +/// the owed-frame driver, and the persisted contacts loaded into the manager. +/// +/// Startup init runs it when an identity already exists. Wallet creation runs it once the new +/// identity is installed: without it a device that created its wallet in this session paired +/// over Bluetooth but refused every offline send ("the BLE stack is not live yet") until the +/// app restarted. With no identity yet it builds nothing and says so. +#[cfg(all(target_os = "android", feature = "bluetooth"))] +pub(crate) fn build_ble_stack_for_identity() -> Result<(), String> { + // Create and register BluetoothManager using AppState identity. + // Identity MUST be available - this is called post-genesis only. + use tokio::sync::RwLock as TokioRwLock; + use dsm::core::{ + contact_manager::DsmContactManager, + bilateral_transaction_manager::BilateralTransactionManager, + }; + + let (dev, gen) = match ( + crate::sdk::app_state::AppState::get_device_id(), + crate::sdk::app_state::AppState::get_genesis_hash(), + ) { + (Some(d), Some(g)) => (d, g), + (None, ..) | (.., None) => { + // Identity not ready: no BLE stack yet; the SDK still answers + // bootstrap queries. The init after genesis builds the stack. + log::warn!( + "[SDK Init] identity not ready (device_id/genesis missing): no BLE stack yet" ); + return Ok(()); } + }; - let contact_manager = DsmContactManager::new(dev_fixed); - - // Genesis v2: the device signing keypair is the AK keypair derived deterministically - // from the BIP39 wallet seed (mnemonic.to_seed) — byte-identical to what - // create_genesis_v2 registered. No DBRW / device secret. The wallet seed is the - // unlocked-session secret; it is cached at unlock (RecoverySDK::derive_and_cache_key) - // and sealed at rest, so on a cold start we first try the hardware seed vault before - // demanding the mnemonic again. Either way init fails without it, and the failure is - // the session's fatal error. A vault that cannot be read is that failure in its own - // words: reporting it as "not unlocked" sent a device whose store refused its schema - // looking for its mnemonic. - if crate::sdk::recovery_sdk::RecoverySDK::get_cached_wallet_seed().is_none() { - match crate::sdk::recovery_sdk::RecoverySDK::load_and_cache_wallet_seed() { - Ok(true) => log::info!("[SDK Init] Wallet seed unsealed from vault (cold start)"), - Ok(false) => { - log::info!("[SDK Init] No sealed wallet seed — mnemonic unlock required") - } - Err(e) => return Err(format!("the sealed wallet seed could not be read: {e}")), - } - } - let wallet_seed = crate::sdk::recovery_sdk::RecoverySDK::get_cached_wallet_seed() - .ok_or_else(|| { - "wallet seed not unlocked: cache the mnemonic (RecoverySDK::derive_and_cache_key) \ - before initializing wallet/signing" - .to_string() - })?; - let keypair = derive_device_signing_keypair(&wallet_seed, &gen_fixed) - .map_err(|e| format!("device signing keypair derivation failed: {e}"))?; + let mut dev_fixed = [0u8; 32]; + let mut gen_fixed = [0u8; 32]; + if dev.len() != 32 || gen.len() != 32 { + log::error!("[SDK Init] device_id and genesis_hash must be exactly 32 bytes"); + return Err("device_id and genesis_hash must be exactly 32 bytes".to_string()); + } + dev_fixed.copy_from_slice(&dev); + gen_fixed.copy_from_slice(&gen); + + // Backfill Device Tree root (§2.3) for existing identities created before this was + // persisted at genesis time. The root of a single-device tree is deterministic from + // dev_fixed, so it is always safe to recompute and overwrite. + // Without the root no receipt is built: the producer checks each receipt's device + // proof against it, so every bilateral step would be refused. + { + let root = dsm::common::device_tree::DeviceTree::single(dev_fixed).root(); + crate::sdk::app_state::AppState::set_device_tree_root(root) + .map_err(|e| format!("persist the device tree root: {e}"))?; log::info!( - "[SDK Init] Derived signing keypair, pubkey_len={}", - keypair.public_key.len() + "[SDK Init] Device tree root computed and persisted (dev={})", + crate::util::text_id::encode_base32_crockford(&dev_fixed) ); + } - // The AK the identity holds is the one the wallet derives: genesis - // installed it, and nothing else writes it. - let stored_pk = crate::sdk::app_state::AppState::get_public_key() - .ok_or_else(|| "the identity holds no signing key".to_string())?; - if stored_pk != keypair.public_key { - return Err( - "the identity's signing key is not the one this wallet derives".to_string(), - ); + let contact_manager = DsmContactManager::new(dev_fixed); + + // Genesis v2: the device signing keypair is the AK keypair derived deterministically + // from the BIP39 wallet seed (mnemonic.to_seed) — byte-identical to what + // create_genesis_v2 registered. No DBRW / device secret. The wallet seed is the + // unlocked-session secret; it is cached at unlock (RecoverySDK::derive_and_cache_key) + // and sealed at rest, so on a cold start we first try the hardware seed vault before + // demanding the mnemonic again. Either way init fails without it, and the failure is + // the session's fatal error. A vault that cannot be read is that failure in its own + // words: reporting it as "not unlocked" sent a device whose store refused its schema + // looking for its mnemonic. + if crate::sdk::recovery_sdk::RecoverySDK::get_cached_wallet_seed().is_none() { + match crate::sdk::recovery_sdk::RecoverySDK::load_and_cache_wallet_seed() { + Ok(true) => log::info!("[SDK Init] Wallet seed unsealed from vault (cold start)"), + Ok(false) => { + log::info!("[SDK Init] No sealed wallet seed — mnemonic unlock required") + } + Err(e) => return Err(format!("the sealed wallet seed could not be read: {e}")), } - - // The process's one BLE stack: reused when init runs again for this - // identity, built only when none is live for it. - let manager_arc = crate::bluetooth::bluetooth_manager_for(dev_fixed, || { - let chain_tip_store = - std::sync::Arc::new(crate::sdk::chain_tip_store::SqliteChainTipStore::new()); - let manager = BilateralTransactionManager::new( - contact_manager, - keypair, - dev_fixed, - gen_fixed, - chain_tip_store, - ); - Ok(crate::bluetooth::BluetoothManager::new( - dev_fixed, - std::sync::Arc::new(TokioRwLock::new(manager)), - )) + } + let wallet_seed = + crate::sdk::recovery_sdk::RecoverySDK::get_cached_wallet_seed().ok_or_else(|| { + "wallet seed not unlocked: cache the mnemonic (RecoverySDK::derive_and_cache_key) \ + before initializing wallet/signing" + .to_string() })?; - log::info!("[SDK Init] the BLE stack is live"); - - // Inject BLE frame coordinator into BiImpl so offline sends dispatch over BLE. - // Use a separate thread with its own runtime to avoid "Cannot start a runtime - // within a runtime" when init_dsm_sdk is called from an async context (e.g. - // the `system.createGenesisV2` route's block_on future). - let coordinator = manager_arc.frame_coordinator().clone(); - let transport_adapter = manager_arc.transport_adapter().clone(); - let ble_inject_result = std::thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("ble coordinator runtime: {e}"))?; - rt.block_on(async move { - crate::bridge::inject_ble_coordinator(coordinator).await?; - crate::bridge::inject_ble_transport_adapter(transport_adapter).await - }) + let keypair = derive_device_signing_keypair(&wallet_seed, &gen_fixed) + .map_err(|e| format!("device signing keypair derivation failed: {e}"))?; + log::info!( + "[SDK Init] Derived signing keypair, pubkey_len={}", + keypair.public_key.len() + ); + + // The AK the identity holds is the one the wallet derives: genesis + // installed it, and nothing else writes it. + let stored_pk = crate::sdk::app_state::AppState::get_public_key() + .ok_or_else(|| "the identity holds no signing key".to_string())?; + if stored_pk != keypair.public_key { + return Err("the identity's signing key is not the one this wallet derives".to_string()); + } + + // The process's one BLE stack: reused when init runs again for this + // identity, built only when none is live for it. + let manager_arc = crate::bluetooth::bluetooth_manager_for(dev_fixed, || { + let chain_tip_store = + std::sync::Arc::new(crate::sdk::chain_tip_store::SqliteChainTipStore::new()); + let manager = BilateralTransactionManager::new( + contact_manager, + keypair, + dev_fixed, + gen_fixed, + chain_tip_store, + ); + Ok(crate::bluetooth::BluetoothManager::new( + dev_fixed, + std::sync::Arc::new(TokioRwLock::new(manager)), + )) + })?; + log::info!("[SDK Init] the BLE stack is live"); + + // Inject BLE frame coordinator into BiImpl so offline sends dispatch over BLE. + // Use a separate thread with its own runtime to avoid "Cannot start a runtime + // within a runtime" when init_dsm_sdk is called from an async context (e.g. + // the `system.createGenesisV2` route's block_on future). + let coordinator = manager_arc.frame_coordinator().clone(); + let transport_adapter = manager_arc.transport_adapter().clone(); + let ble_inject_result = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("ble coordinator runtime: {e}"))?; + rt.block_on(async move { + crate::bridge::inject_ble_coordinator(coordinator).await?; + crate::bridge::inject_ble_transport_adapter(transport_adapter).await }) - .join(); - match ble_inject_result { - Ok(Ok(())) => { - log::info!( - "[SDK Init] BLE coordinator and transport adapter injected into bilateral handler" - ); - crate::bluetooth::owed_frame_driver::start(); - } - Ok(Err(e)) => return Err(format!("BLE injection failed: {e}")), - Err(panic) => return Err(format!("BLE injection thread panicked: {panic:?}")), + }) + .join(); + match ble_inject_result { + Ok(Ok(())) => { + log::info!( + "[SDK Init] BLE coordinator and transport adapter injected into bilateral handler" + ); + crate::bluetooth::owed_frame_driver::start(); } + Ok(Err(e)) => return Err(format!("BLE injection failed: {e}")), + Err(panic) => return Err(format!("BLE injection thread panicked: {panic:?}")), + } - // Load the persisted contacts into the BluetoothManager before the - // first BLE prepare can arrive; otherwise the handler rejects a known - // sender. A thread with its own runtime, because init can run inside - // a runtime (JNI). - let manager_for_sync = manager_arc.clone(); - let synced = std::thread::spawn(move || -> Result { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("contact sync runtime: {e}"))?; - rt.block_on(async move { - let contacts = crate::storage::client_db::get_all_contacts() - .map_err(|e| format!("load contacts: {e}"))?; - let count = contacts.len(); - for record in contacts { - let contact = record.to_verified_contact().map_err(|e| e.to_string())?; - manager_for_sync - .add_verified_contact(contact) - .await - .map_err(|e| format!("contact {}: {e}", record.alias))?; - } - Ok(count) - }) - }) - .join(); - match synced { - Ok(Ok(count)) => { - log::info!("[SDK Init] {count} contacts synced to the BluetoothManager") + // Load the persisted contacts into the BluetoothManager before the + // first BLE prepare can arrive; otherwise the handler rejects a known + // sender. A thread with its own runtime, because init can run inside + // a runtime (JNI). + let manager_for_sync = manager_arc.clone(); + let synced = std::thread::spawn(move || -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("contact sync runtime: {e}"))?; + rt.block_on(async move { + let contacts = crate::storage::client_db::get_all_contacts() + .map_err(|e| format!("load contacts: {e}"))?; + let count = contacts.len(); + for record in contacts { + let contact = record.to_verified_contact().map_err(|e| e.to_string())?; + manager_for_sync + .add_verified_contact(contact) + .await + .map_err(|e| format!("contact {}: {e}", record.alias))?; } - Ok(Err(e)) => return Err(format!("contact sync to the BluetoothManager: {e}")), - Err(panic) => return Err(format!("contact sync thread panicked: {panic:?}")), + Ok(count) + }) + }) + .join(); + match synced { + Ok(Ok(count)) => { + log::info!("[SDK Init] {count} contacts synced to the BluetoothManager") } + Ok(Err(e)) => return Err(format!("contact sync to the BluetoothManager: {e}")), + Err(panic) => return Err(format!("contact sync thread panicked: {panic:?}")), } - Ok(()) }