diff --git a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/AndroidLayerProofTest.kt b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/AndroidLayerProofTest.kt index 159edef6d..176718f7f 100644 --- a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/AndroidLayerProofTest.kt +++ b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/AndroidLayerProofTest.kt @@ -748,11 +748,13 @@ class AndroidLayerProofTest { @Test fun t64_error_wrongPayloadSize_forAcceptBilateral() { - // acceptBilateralByCommitment expects exactly 32 bytes - val wrongSize = ByteArray(16) - val resp = callBridgeMethod("acceptBilateralByCommitment", wrongSize) - // Should return success with empty data (validation rejects < 32 bytes) - assertTrue("Must not crash", resp.first) + // acceptBilateralByCommitment takes exactly 32 bytes. Anything else is + // the dispatcher's error carrying the arm's reason, never a success. + val resp = callBridgeMethod("acceptBilateralByCommitment", ByteArray(16)) + assertFalse("A 16-byte commitment is not accepted", resp.first) + val error = BridgeEnvelopeCodec.decodeBridgeRpcError(resp.second) + assertNotNull("The error decodes", error) + assertTrue(error!!.message, error.message.contains("expected 32 bytes, got 16")) } @Test diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt index 7081c8bc2..b0badb0e5 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt @@ -60,8 +60,6 @@ object Unified { // ---------- Protobuf-only externals ---------- @Keep @JvmStatic fun initSdk(baseDir: String): Boolean = UnifiedNativeApi.initSdk(baseDir) - @Keep @JvmStatic fun initSdkV3(baseDir: String): ByteArray = - UnifiedNativeApi.initSdkV3(baseDir) @Keep @JvmStatic fun initStorageBaseDir(path: ByteArray) { UnifiedNativeApi.initStorageBaseDir(path) } diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt index e09fd6949..17b9c3d49 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt @@ -48,7 +48,6 @@ internal object UnifiedNativeApi { } @Keep @JvmStatic external fun initSdk(baseDir: String): Boolean - @Keep @JvmStatic external fun initSdkV3(baseDir: String): ByteArray @Keep @JvmStatic external fun initStorageBaseDir(path: ByteArray) @Keep @JvmStatic external fun initDsmSdk(configPath: String) @Keep @JvmStatic external fun dispatchStartup(requestBytes: ByteArray): ByteArray diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt index f66dbde0a..71da46cba 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt @@ -1613,8 +1613,13 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { } Log.i(tag, "initDsmAndSignalReady: Calling initSdk...") - Unified.initSdk(baseDir) - Log.i(tag, "initDsmAndSignalReady: SDK initialized; switching to UI thread...") + // A failed startup is Rust's to report: it records the reason as + // the session's fatal error, and the page shows it. + if (Unified.initSdk(baseDir)) { + Log.i(tag, "initDsmAndSignalReady: SDK initialized") + } else { + Log.e(tag, "initDsmAndSignalReady: SDK initialization failed; the session carries Rust's reason") + } Log.i(tag, "initDsmAndSignalReady: event-driven bridge mode enabled; will rely on dsm-bridge-ready signal") try { diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeLoggerTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeLoggerTest.kt index dc29592f9..643ca3bd0 100644 --- a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeLoggerTest.kt +++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeLoggerTest.kt @@ -2,6 +2,7 @@ package com.dsm.wallet.bridge import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -82,21 +83,30 @@ class BridgeLoggerTest { assertTrue("Log should contain BRIDGE prefix", content.contains("BRIDGE:")) } + // A bridge payload can be the mnemonic, and the log is a file on disk: + // it names the payload's size and never a byte of it. @Test - fun logBridgeCall_shortPayload_notTruncated() { - val payload = byteArrayOf(0x01, 0x02, 0x03) - val b32 = BridgeEncoding.base32CrockfordEncode(payload) + fun logBridgeCall_namesThePayloadSize_neverItsBytes() { + val payload = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05) BridgeLogger.logBridgeCall("m", payload, null, null) val content = String(BridgeLogger.readLogBytes(), Charsets.UTF_8) - assertTrue("Short payload b32 should appear in full", content.contains(b32)) + assertTrue("The size is logged", content.contains("payload=5b")) + assertFalse( + "No byte of the payload is logged", + content.contains(BridgeEncoding.base32CrockfordEncode(payload)) + ) } @Test - fun logBridgeCall_longPayload_truncated() { + fun logBridgeCall_longPayload_notEvenAPrefix() { val payload = ByteArray(100) { it.toByte() } BridgeLogger.logBridgeCall("m", payload, null, null) val content = String(BridgeLogger.readLogBytes(), Charsets.UTF_8) - assertTrue("Long payload should be truncated with ...", content.contains("...")) + assertTrue("The size is logged", content.contains("payload=100b")) + assertFalse( + "Not even its first five bytes", + content.contains(BridgeEncoding.base32CrockfordEncode(payload.copyOfRange(0, 5))) + ) } @Test diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/bridge.rs b/dsm_client/deterministic_state_machine/dsm/src/core/bridge.rs index e148e6907..f21acc248 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/bridge.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/bridge.rs @@ -648,16 +648,6 @@ pub fn handle_envelope_universal(env_bytes: &[u8]) -> Vec { }) } - // Init/status messages are produced by the SDK/JNI surfaces and should not be routed - // through the core universal handler as "requests". - Some(gp::envelope::Payload::InitFailed(_)) => gp::envelope::Payload::Error(gp::Error { - code: 409, - message: "InitFailed should not be sent as a request".to_string(), - context: vec![], - source_tag: 10, - is_recoverable: false, - debug_b32: "".to_string(), - }), // NEW: Explicit guard for genesis-created responses (SDK-only) Some(gp::envelope::Payload::GenesisCreatedResponse(_)) => { diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/smt/fold.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/smt/fold.rs index fea3c3bc7..bbab904e6 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/smt/fold.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/smt/fold.rs @@ -32,11 +32,6 @@ impl SmtHashes for EconomicHashes { } } -/// Fold a core's entries into its pre- and post-roots. -pub fn batch_fold(entries: &[FoldEntry]) -> Result { - batch_fold::batch_fold::(entries) -} - /// Fold against a claimed pre-root, returning the post-root. pub fn verify_batch(pre_root: &[u8; 32], entries: &[FoldEntry]) -> Result<[u8; 32], FoldError> { batch_fold::verify_batch::(pre_root, entries) @@ -49,6 +44,12 @@ mod tests { use crate::economic::tree::{empty_economic_root, root_from_path, EconomicSmt}; use proptest::prelude::*; + /// The one fold over the economic tree's hashes: what `verify_batch` + /// runs, with its pre-root returned instead of checked. + fn batch_fold(entries: &[FoldEntry]) -> Result { + batch_fold::batch_fold::(entries) + } + fn key(i: u64) -> [u8; 32] { let mut k = [0u8; 32]; let mut x = i.wrapping_add(1).wrapping_mul(0x9E37_79B9_7F4A_7C15); diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/smt/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/smt/mod.rs index 0831eed07..5907bfd23 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/smt/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/smt/mod.rs @@ -23,4 +23,4 @@ //! decides no canonicality, because `advance_resolved` already did. pub mod fold; -pub use fold::{batch_fold, verify_batch, FoldEntry, FoldError, Folded}; +pub use fold::{verify_batch, FoldEntry, FoldError, Folded}; diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs index aca4385f6..33527797a 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs @@ -1896,7 +1896,12 @@ pub(crate) mod fixtures { //! conformance tests (R7): a real `P(E)`, its `E`, a precommit whose legs //! derive from it, and the acquired evidence. use super::*; - use crate::sofi::smt::batch_fold; + /// The one fold over the economic tree's hashes, as `verify_batch` runs it. + fn batch_fold( + entries: &[crate::sofi::smt::FoldEntry], + ) -> Result { + crate::merkle::batch_fold::batch_fold::(entries) + } use crate::economic::tree::EconomicSmt; use crate::sofi::wire::{PreEClosureIndex, PrecommitLeg, SofiSetupBody, ValidationRef}; @@ -2472,7 +2477,12 @@ mod tests { use super::fixtures::*; use super::*; use crate::economic::tree::{EconomicSmt, ECONOMIC_SMT_HEIGHT}; - use crate::sofi::smt::batch_fold; + /// The one fold over the economic tree's hashes, as `verify_batch` runs it. + fn batch_fold( + entries: &[crate::sofi::smt::FoldEntry], + ) -> Result { + crate::merkle::batch_fold::batch_fold::(entries) + } use crate::sofi::wire::{PreEClosureIndex, PrecommitLeg}; #[test] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs index 97a478dde..f12c04afd 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs @@ -733,14 +733,14 @@ async fn bearer_pair() -> ( Pair, OfflineDevice, OfflineDevice, - crate::test_support::appliance::HostAppliance, + crate::test_support::appliance::InstalledAppliance, Operation, ) { let pair = Pair::boot(100, 0).await; let a = OfflineDevice::new(&pair.a); let b = OfflineDevice::new(&pair.b); - let appliance = crate::test_support::appliance::HostAppliance::birth(&pair.a, [0xC4; 32], 16); - appliance.install(); + let appliance = + crate::test_support::appliance::HostAppliance::birth(&pair.a, [0xC4; 32], 16).install(); a.device.enter(); let loaded = a .device diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bridge/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bridge/mod.rs index 7a6c41499..e7e5cf1a6 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bridge/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bridge/mod.rs @@ -386,6 +386,14 @@ pub fn install_anchor_appliance_factory(factory: AnchorApplianceFactory) { } } +/// Remove the installed factory: the process has no appliance again. +#[cfg(test)] +pub(crate) fn uninstall_anchor_appliance_factory() { + if let Ok(mut g) = ANCHOR_APPLIANCE_FACTORY.write() { + *g = None; + } +} + #[must_use] pub fn anchor_appliance_factory() -> Option { ANCHOR_APPLIANCE_FACTORY.read().ok()?.clone() diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs index 62deb8160..62eebbc89 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs @@ -258,6 +258,7 @@ fn initialize_sdk_core() -> Result, pb::Error> { match crate::runtime::get_runtime().block_on(crate::init_dsm_sdk()) { Ok(()) => { crate::sdk::session_manager::set_sdk_ready(true); + crate::sdk::session_manager::clear_startup_failure(); // Resume any identity whose publication never reached quorum. // Publication is a precondition of "identity created", so a device @@ -273,10 +274,9 @@ fn initialize_sdk_core() -> Result, pb::Error> { } Err(e) => { crate::sdk::session_manager::set_sdk_ready(false); - Err(ingress_error( - ERROR_CODE_NOT_READY, - format!("startup: init_dsm_sdk failed: {e}"), - )) + let message = format!("startup: init_dsm_sdk failed: {e}"); + crate::sdk::session_manager::record_startup_failure(&message); + Err(ingress_error(ERROR_CODE_NOT_READY, message)) } } } @@ -977,6 +977,60 @@ mod tests { drop(fleet); } + /// A phone an older build provisioned keeps its store at that build's + /// schema, and this build refuses it: beta does not migrate. Startup fails + /// in the store's own words and that is the session's error, so the page + /// leaves "starting runtime" and says what to do. The nodes run, the device + /// is created as wallet creation creates it, and its store is left as the + /// older build left it: stamped 24, without the table 25 added. + #[test] + #[serial] + fn a_store_at_an_older_schema_fails_startup_as_the_sessions_error() { + let fleet = fleet(); + let _identity = economic_fixtures::local_device(0x12).0; + { + let store = crate::storage::client_db::get_connection().expect("the store"); + let conn = store.lock().expect("the store lock"); + conn.execute_batch("DROP TABLE history_repair_queue; PRAGMA user_version = 24;") + .expect("leave the store as schema 24 left it"); + } + // The process that provisioned it is gone. + crate::storage::client_db::close_database_for_tests(); + crate::sdk::app_state::AppState::reset_memory_for_testing(); + fresh_process(); + crate::sdk::session_manager::clear_fatal_error_and_snapshot().expect("clear"); + + let start = || { + dispatch_startup(StartupRequest { + operation: Some(startup_request::Operation::InitializeSdk( + pb::InitializeSdkOp {}, + )), + }) + }; + let snapshot = || { + crate::sdk::session_manager::SESSION_MANAGER + .lock() + .unwrap_or_else(|p| p.into_inner()) + .compute_snapshot() + }; + let error = expect_startup_error(start()); + assert!( + error.message.contains("SCHEMA RESET REQUIRED"), + "{}", + error.message + ); + assert_eq!(snapshot().phase, "error"); + assert_eq!(snapshot().fatal_error, error.message); + + // The remedy the refusal names, a wiped store: startup then succeeds + // and takes its own failure back. + crate::storage::client_db::reset_database_for_tests(); + expect_startup_ok(start()); + assert_eq!(snapshot().fatal_error, ""); + assert_ne!(snapshot().phase, "error"); + drop(fleet); + } + #[test] #[serial] fn initialize_identity_context_sets_identity_and_router() { 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 9ed787c8f..7832ae320 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/init.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/init.rs @@ -554,16 +554,17 @@ pub fn init_dsm_sdk(cfg: &SdkConfig) -> Result<(), String> { // 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. A missing/auth-gated bundle fails closed โ†’ locked UI. + // 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) => { - log::warn!("[SDK Init] Seed vault unlock failed ({e}) โ€” mnemonic 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() diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/unified_protobuf_bridge.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/unified_protobuf_bridge.rs index 349708be4..e005095df 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/unified_protobuf_bridge.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/unified_protobuf_bridge.rs @@ -385,75 +385,6 @@ pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_getAllBalance ) } -/// Protobuf-first init entrypoint. -#[no_mangle] -pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_initSdkV3( - env: jni::sys::JNIEnv, - _class: jni::sys::jclass, - jbase: jni::sys::jstring, -) -> jni::sys::jbyteArray { - crate::jni::bridge_utils::jni_catch_unwind_jbytearray( - "initSdkV3", - std::panic::AssertUnwindSafe(|| { - let mut env = match unsafe { env_from(env) } { - Some(e) => e, - None => return std::ptr::null_mut(), - }; - let jbase = unsafe { jstr_from(jbase) }; - let base: String = match env.get_string(&jbase) { - Ok(s) => s.into(), - Err(e) => { - log::error!("initSdkV3: failed to read baseDir: {}", e); - String::new() - } - }; - - let respond = - |payload: pb::envelope::Payload, env: &mut JNIEnv| -> jni::sys::jbyteArray { - framed_payload_byte_array(env, payload).into_raw() - }; - - if base.is_empty() { - SDK_READY.store(false, Ordering::SeqCst); - return respond( - pb::envelope::Payload::InitFailed(pb::InitFailed { - reason: pb::init_failed::Reason::InvalidInput as i32, - message: "baseDir is empty".to_string(), - }), - &mut env, - ); - } - - if let Err(error) = route_startup_via_ingress( - pb::startup_request::Operation::SetStorageBaseDir(pb::SetStorageBaseDirOp { - path_utf8: base.clone(), - }), - ) { - SDK_READY.store(false, Ordering::SeqCst); - return respond( - pb::envelope::Payload::InitFailed(pb::InitFailed { - reason: pb::init_failed::Reason::PlatformContextMissing as i32, - message: format!("failed to set storage base dir: {}", error.message), - }), - &mut env, - ); - } - - // Genesis v2: no C-DBRW binding key to gate init on. The device signing key is - // re-derived from the unlocked wallet seed on demand; signing operations fail - // closed individually when the wallet is locked. - SDK_READY.store(true, Ordering::SeqCst); - respond( - pb::envelope::Payload::AppStateResponse(pb::AppStateResponse { - key: "sdk.init".to_string(), - value: Some("ok".to_string()), - }), - &mut env, - ) - }), - ) -} - /// Remove a contact by contact_id. /// Returns 1 on success, 0 on failure. #[no_mangle] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/lib.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/lib.rs index 5b75f8405..681736fb7 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/lib.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/lib.rs @@ -184,6 +184,18 @@ pub async fn init_dsm_sdk() -> Result<(), dsm::types::error::DsmError> { let base = ensure_storage_base_dir()?; log::info!("DSM storage base: {base:?}"); + // The client store is a precondition of everything the SDK does, so it is + // opened here. A store this build refuses (a schema an older build left; + // beta does not migrate) fails startup in the store's own words. Unopened, + // the refusal surfaced later through whatever read the store first: on a + // phone that was the sealed-seed read, which reported a locked wallet. + crate::storage::client_db::init_database().map_err(|e| { + dsm::types::error::DsmError::storage( + format!("the client store: {e}"), + None::, + ) + })?; + // Load strict network config (or hermetic test config), // then install the multi-node registry. let cfg = crate::network::NetworkConfigLoader::load_env_config()?; diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/session_manager.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/session_manager.rs index 3d5f79c47..cd84e713b 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/session_manager.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/session_manager.rs @@ -49,6 +49,30 @@ pub fn set_sdk_ready(ready: bool) { log::info!("session_manager::set_sdk_ready: SDK_READY={}", ready); } +/// Record a failed startup as the session's fatal error. +/// +/// The page learns the phase from the session. Without this a failed startup +/// left the phase at `runtime_loading`, and the screen said the runtime was +/// starting while nothing was: a device whose store refused its schema sat on +/// that screen for good, and only the device log said why. This reads nothing +/// from the store, because the store refusing is one of the failures it records. +pub fn record_startup_failure(message: &str) { + let mut mgr = SESSION_MANAGER.lock().unwrap_or_else(|p| p.into_inner()); + mgr.fatal_error = Some(message.to_string()); + mgr.fatal_is_startup_failure = true; + log::error!("session_manager::record_startup_failure: {message}"); +} + +/// Startup succeeded: a failure an earlier attempt recorded is no longer +/// true, so it goes. A fatal error anyone else reported stays. +pub fn clear_startup_failure() { + let mut mgr = SESSION_MANAGER.lock().unwrap_or_else(|p| p.into_inner()); + if mgr.fatal_is_startup_failure { + mgr.fatal_error = None; + mgr.fatal_is_startup_failure = false; + } +} + /// Process-global session manager instance. pub static SESSION_MANAGER: Lazy> = Lazy::new(|| Mutex::new(SessionManager::default())); @@ -82,6 +106,9 @@ pub struct SessionManager { pub lock_method: String, pub lock_on_pause: bool, pub fatal_error: Option, + /// Whether `fatal_error` is a failed startup, which a later successful + /// startup takes back. An error anyone else reported is theirs to clear. + pub fatal_is_startup_failure: bool, pub wallet_refresh_hint: u64, pub hardware: HardwareFacts, pub lock_state_initialized: bool, @@ -95,6 +122,7 @@ impl Default for SessionManager { lock_method: "none".to_string(), lock_on_pause: true, fatal_error: None, + fatal_is_startup_failure: false, wallet_refresh_hint: 0, hardware: HardwareFacts::default(), lock_state_initialized: false, @@ -440,6 +468,7 @@ pub fn set_fatal_error_and_snapshot(message: &str) -> Result, String> { mgr.sync_lock_config_from_app_state() .map_err(|e| format!("session lock settings: {e}"))?; mgr.fatal_error = Some(message.to_string()); + mgr.fatal_is_startup_failure = false; log::error!("session_manager::set_fatal_error: {message}"); Ok(envelope_wrap_snapshot(mgr.compute_snapshot())) } @@ -450,6 +479,7 @@ pub fn clear_fatal_error_and_snapshot() -> Result, String> { mgr.sync_lock_config_from_app_state() .map_err(|e| format!("session lock settings: {e}"))?; mgr.fatal_error = None; + mgr.fatal_is_startup_failure = false; log::info!("session_manager::clear_fatal_error"); Ok(envelope_wrap_snapshot(mgr.compute_snapshot())) } @@ -487,6 +517,35 @@ mod tests { IDENTITY_PUBLISHED.store(true, Ordering::SeqCst); } + /// A failed startup is the session's fatal error until a startup + /// succeeds; an error someone else reported outlives a later success. + #[test] + #[serial_test::serial] + fn a_startup_failure_is_the_sessions_error_until_a_startup_succeeds() { + setup_test_env(); + clear_fatal_error_and_snapshot().expect("clear"); + let snapshot = || { + SESSION_MANAGER + .lock() + .unwrap_or_else(|p| p.into_inner()) + .compute_snapshot() + }; + record_startup_failure("startup: init_dsm_sdk failed: the store refused"); + assert_eq!(snapshot().phase, "error"); + assert_eq!( + snapshot().fatal_error, + "startup: init_dsm_sdk failed: the store refused" + ); + clear_startup_failure(); + assert_eq!(snapshot().phase, "runtime_loading"); + assert_eq!(snapshot().fatal_error, ""); + + set_fatal_error_and_snapshot("configuration file not found").expect("set"); + clear_startup_failure(); + assert_eq!(snapshot().fatal_error, "configuration file not found"); + clear_fatal_error_and_snapshot().expect("clear"); + } + #[test] #[serial_test::serial] fn default_phase_is_runtime_loading() { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs index 03cb23589..d8f539da3 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs @@ -217,6 +217,17 @@ pub fn is_database_initialized() -> bool { /// Serializes with `init_database` via `TEST_DB_LIFECYCLE_LOCK`, so an /// `init_database` never observes a dropped connection with the old /// generation still current. +/// Drop this process's connection to the store and leave the file as it is: +/// what a process exit does. The next `get_connection()` opens the file again +/// through `init_database`, schema check included. +#[cfg(test)] +pub(crate) fn close_database_for_tests() { + let _lifecycle = TEST_DB_LIFECYCLE_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + *DB_CONNECTION.write().unwrap_or_else(|e| e.into_inner()) = None; +} + #[cfg(any(test, feature = "test-utils"))] #[allow(clippy::panic)] // a reset that fails leaves the next test on stale rows; it must stop pub fn reset_database_for_tests() { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/appliance.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/appliance.rs index 816785de3..149e6d3c4 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/appliance.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/appliance.rs @@ -139,12 +139,15 @@ impl HostAppliance { } /// Install this appliance as the process's anchor appliance: every - /// `CoreSDK` that attaches one from now on attaches this one. - pub fn install(&self) { + /// `CoreSDK` that attaches one attaches this one, until the returned + /// handle drops and the process has none again. + #[must_use = "the appliance stays installed only while the handle lives"] + pub fn install(self) -> InstalledAppliance { let appliance = self.clone(); crate::bridge::install_anchor_appliance_factory(Arc::new(move || { Ok(Box::new(appliance.clone()) as Box) })); + InstalledAppliance { appliance: self } } fn with(&self, f: impl FnOnce(&mut Appliance) -> R) -> R { @@ -152,6 +155,27 @@ impl HostAppliance { } } +/// An appliance installed as the process's anchor appliance; dropping it +/// uninstalls it. An installation that outlived its test leaked into every +/// later test in the process: the offline-cash gate tests, which assert what a +/// device with no appliance is refused, found this one attached. +pub struct InstalledAppliance { + appliance: HostAppliance, +} + +impl std::ops::Deref for InstalledAppliance { + type Target = HostAppliance; + fn deref(&self) -> &HostAppliance { + &self.appliance + } +} + +impl Drop for InstalledAppliance { + fn drop(&mut self) { + crate::bridge::uninstall_anchor_appliance_factory(); + } +} + impl AnchorAppliance for HostAppliance { fn status(&mut self) -> Result { Ok(self.with(|a| ApplianceStatus { diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts index 4d14546ba..0632bd990 100644 --- a/dsm_client/frontend/src/proto/dsm_app_pb.ts +++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts @@ -16720,14 +16720,6 @@ export class Envelope extends Message { */ value: DsmBtMessage; case: "dsmBtMessage"; - } | { - /** - * SDK init/status responses - * - * @generated from field: dsm.InitFailed init_failed = 31; - */ - value: InitFailed; - case: "initFailed"; } | { /** * BLE events @@ -17279,7 +17271,6 @@ export class Envelope extends Message { { no: 27, name: "bilateral_accept_response", kind: "message", T: BilateralAcceptResponse, oneof: "payload" }, { no: 28, name: "bilateral_commit_response", kind: "message", T: BilateralCommitResponse, oneof: "payload" }, { no: 29, name: "dsm_bt_message", kind: "message", T: DsmBtMessage, oneof: "payload" }, - { no: 31, name: "init_failed", kind: "message", T: InitFailed, oneof: "payload" }, { no: 32, name: "ble_event", kind: "message", T: BleEvent, oneof: "payload" }, { no: 34, name: "balances_list_response", kind: "message", T: BalancesListResponse, oneof: "payload" }, { no: 35, name: "storage_sync_response", kind: "message", T: StorageSyncResponse, oneof: "payload" }, @@ -18233,79 +18224,6 @@ export class SofiRelayResponse extends Message { } } -/** - * ===================== SDK INIT / STATUS ===================== - * Returned when the app attempts to mark the SDK/wallet "initialized" but a - * mandatory prerequisite is missing. - * - * @generated from message dsm.InitFailed - */ -export class InitFailed extends Message { - /** - * @generated from field: dsm.InitFailed.Reason reason = 1; - */ - reason = InitFailed_Reason.REASON_UNSPECIFIED; - - /** - * @generated from field: string message = 2; - */ - message = ""; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "dsm.InitFailed"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "reason", kind: "enum", T: proto3.getEnumType(InitFailed_Reason) }, - { no: 2, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): InitFailed { - return new InitFailed().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): InitFailed { - return new InitFailed().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): InitFailed { - return new InitFailed().fromJsonString(jsonString, options); - } - - static equals(a: InitFailed | PlainMessage | undefined, b: InitFailed | PlainMessage | undefined): boolean { - return proto3.util.equals(InitFailed, a, b); - } -} - -/** - * @generated from enum dsm.InitFailed.Reason - */ -export enum InitFailed_Reason { - /** - * @generated from enum value: REASON_UNSPECIFIED = 0; - */ - REASON_UNSPECIFIED = 0, - - /** - * @generated from enum value: PLATFORM_CONTEXT_MISSING = 2; - */ - PLATFORM_CONTEXT_MISSING = 2, - - /** - * @generated from enum value: INVALID_INPUT = 3; - */ - INVALID_INPUT = 3, -} -// Retrieve enum metadata with: proto3.getEnumType(InitFailed_Reason) -proto3.util.setEnumType(InitFailed_Reason, "dsm.InitFailed.Reason", [ - { no: 0, name: "REASON_UNSPECIFIED" }, - { no: 2, name: "PLATFORM_CONTEXT_MISSING" }, - { no: 3, name: "INVALID_INPUT" }, -]); - /** * @generated from message dsm.ArchitectureInfoProto */ diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index d9ae3e7c7..67b206afd 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -2552,8 +2552,6 @@ message Envelope { BilateralCommitResponse bilateral_commit_response = 28; DsmBtMessage dsm_bt_message = 29; - // SDK init/status responses - InitFailed init_failed = 31; // BLE events BleEvent ble_event = 32; @@ -2698,6 +2696,8 @@ message Envelope { // 43 was secondary_device_response and 107 device_tree_snapshot_response: // no route produced either (the device tree store is not built). reserved 43, 107; + // 31 was init_failed: only a startup export no caller used produced it. + reserved 31; } // ===================== Sealed spool payloads (DSM Amendment A7) ===================== @@ -2842,19 +2842,6 @@ message SofiRelayResponse { // persisted proposal by `commitment`, overlays it onto the A-side receipt it froze // at send, verifies against that proposal's CANONICAL pair, then finalizes. -// ===================== SDK INIT / STATUS ===================== -// Returned when the app attempts to mark the SDK/wallet "initialized" but a -// mandatory prerequisite is missing. -message InitFailed { - enum Reason { - REASON_UNSPECIFIED = 0; - PLATFORM_CONTEXT_MISSING = 2; - INVALID_INPUT = 3; - } - Reason reason = 1; - string message = 2; -} - // ============ RECOVERY OPERATIONS (binary IDs) ============== message ArchitectureInfoProto { diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 22353d916..c8dae3df0 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1363,6 +1363,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | B-F16 | `dsm_client/frontend/public/index.html` (battery LED) | Defaults to full and reads a `window.DSMBridge.getBatteryStatus` nothing installs. | Resolved: without the Battery API the LED is unlit and unclassed, and the phantom native fallback is deleted; the markup no longer starts as `full`. | | B-C13 | `CORE/recovery/tombstone.rs` (`TombstoneReceipt.old_counter`); `init_*` no-ops | Signs a counter DSM does not keep; initialisers that initialise nothing. | Open (recovery boundary) | | B-C11 | `CORE/recovery/succession_proof.rs` (`verify`) | Assumes a receipt verification nothing performs (ยง6.35 link). | Open (recovery boundary) | +| B-START | `SDK/ingress.rs` (`initialize_sdk_core`), `SDK/lib.rs`, `SDK/init.rs` (seed vault), `KT/ui/MainActivity.kt`, `SDK/jni/unified_protobuf_bridge.rs` (`initSdkV3`) | A failed startup left the session at `runtime_loading`, so the page said "STARTING RUNTIME / WARMING UP BRIDGE" for good: both appliance phones sat there on 2026-09-27 with stores an older build left at schema 24, and only logcat named the refusal. Kotlin logged "SDK initialized" after `initSdk` returned false. On Android the store's refusal first surfaced through the sealed-seed read, which was downgraded to a warning and re-reported as "wallet seed not unlocked"; on the shared path nothing opened the store at startup. `initSdkV3`, with no caller, declared the SDK ready after setting only the storage directory, and `InitFailed` existed only for it. | Resolved (`fix/startup-reports-the-sdk-refusal`): startup opens the client store and fails in its words; a failed startup is recorded as the session's fatal error (phase `error`, the page shows it) and a later successful one takes it back; a seed vault that cannot be read fails init as itself; Kotlin logs the failure as a failure; `initSdkV3`, its declarations and `InitFailed` are deleted (Envelope field 31 reserved). Test on the running fleet: `a_store_at_an_older_schema_fails_startup_as_the_sessions_error` (the store left as schema 24 left it); mutations: the recording dropped, and the store open dropped, each red. | **C. Test seams and development hooks in shipped code**