From 203a54a13f7b5a47aa3822d332d93270567cace2 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:38:53 -0400 Subject: [PATCH] feat(ci): the bridge's two sides must agree on every RPC name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing checked that the frontend and Kotlin agreed on the method names a BridgeRpcRequest carries. The frontend sent names Kotlin never handled — `hasIdentityDirect`, for months, answered by the unknown-method arm and wrapped in a default that read as a measurement — and Kotlin kept eight arms nothing sent: processEnvelopeV3, getWalletHistoryStrict, getSigningPublicKeyBin, getPersistedGenesisEnvelope, getGenesisHashBin, getDeviceIdBin, startNativeQrScanner, hasNativeQrScanner. The same pattern surfaced four times in the frontend sweep (#1009). `ci/bridge_rpc_names.py` reads the names the frontend's production sources and `public/index.html` send (string literals to callBin, sendBridgeRequestBytes, buildBridgeRequest, callBoundaryMethod, callBridgeMethod, encodeBridgeRequest, and an upper-case constant resolved in its file — unresolved fails) and the string arms of Kotlin's `handleBinaryRpcInternal`, and requires the two sets to be equal, both ways, with no allowlist. It runs in the purity step, the Frontend job and the Android Unit Tests job, so a change on either side selects it. The eight dead arms are deleted, with the getWalletHistoryStrict and getSigningPublicKeyBin Kotlin functions, externals and Rust JNI exports that only they reached, and the prefs reader only one of them used; the fuzz test's method list names live methods. Negative controls, each failing the gate naming the offender: a phantom frontend name (exit 1), a dead Kotlin arm (exit 1), an unresolvable constant (exit 2). Verified: the gate at 22 sent = 22 handled, make lint, cargo ndk check of the jni feature for arm64-v8a, gradle compile of main, androidTest and unit-test sources, and the purity, flow, scan, bridge and safety gates. --- .github/workflows/ci.yml | 8 + ci/bridge_rpc_names.py | 145 ++++++++++++++++++ .../wallet/bridge/SinglePathWebViewBridge.kt | 116 +------------- .../java/com/dsm/wallet/bridge/Unified.kt | 13 -- .../com/dsm/wallet/bridge/UnifiedNativeApi.kt | 2 - .../bridge/SinglePathWebViewBridgeFuzzTest.kt | 10 +- .../src/jni/unified_protobuf_bridge.rs | 109 ------------- specs/requirements/CONFORMANCE_GAPS.md | 3 +- 8 files changed, 167 insertions(+), 239 deletions(-) create mode 100644 ci/bridge_rpc_names.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8a90641e..d3119056f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,7 @@ jobs: bash scripts/flow_mapping_assertions.sh bash scripts/check_forbidden_symbols.sh bash scripts/ci_scan.sh + python3 ci/bridge_rpc_names.py - name: Production safety lints run: bash ci/production_safety_checks.sh @@ -490,6 +491,7 @@ jobs: bash scripts/flow_assertions.sh bash scripts/flow_mapping_assertions.sh bash scripts/ci_scan.sh + python3 ci/bridge_rpc_names.py - name: Lint run: npm run lint @@ -523,6 +525,12 @@ jobs: steps: - uses: actions/checkout@v7 + # The bridge RPC names Kotlin handles must be the names the frontend sends; + # this job is what a Kotlin-only change selects. + - name: Bridge RPC name gate + working-directory: . + run: python3 ci/bridge_rpc_names.py + - name: Set up JDK 17 uses: actions/setup-java@v5 with: diff --git a/ci/bridge_rpc_names.py b/ci/bridge_rpc_names.py new file mode 100644 index 000000000..1a0d2834d --- /dev/null +++ b/ci/bridge_rpc_names.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# ci/bridge_rpc_names.py: every bridge RPC name the frontend sends is one Kotlin +# handles, and every name Kotlin handles is one the frontend sends. +# +# The bridge between the WebView and Kotlin is a method name on a +# BridgeRpcRequest. Nothing checked that the two sides agreed: `hasIdentityDirect` +# was sent for months and answered by the unknown-method arm, and Kotlin kept +# eight arms nothing sent (2026-09-26, #1009 and its follow-up). A name only one +# side knows is a fake by construction — the frontend wraps it in a default that +# downstream code reads as a measurement, or Kotlin keeps a path nothing +# exercises. +# +# Sent names are read from the frontend's production sources and from the bridge +# object `public/index.html` installs: a string literal passed to callBin, +# sendBridgeRequestBytes, buildBridgeRequest, callBoundaryMethod, +# callBridgeMethod or encodeBridgeRequest, and an upper-case identifier passed to +# callBin, resolved from a `const NAME = '…'` in the same file (unresolved: fail). +# Handled names are the string arms of the `when (method)` inside +# SinglePathWebViewBridge.handleBinaryRpcInternal. +# +# Exit 0 only when both sets are non-empty and equal. There is no allowlist: a +# name one side must stop using is removed from that side. + +import os +import re +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +FRONTEND_SRC = os.path.join(ROOT, "dsm_client", "frontend", "src") +INDEX_HTML = os.path.join(ROOT, "dsm_client", "frontend", "public", "index.html") +KOTLIN_BRIDGE = os.path.join( + ROOT, "dsm_client", "android", "app", "src", "main", "java", "com", "dsm", "wallet", + "bridge", "SinglePathWebViewBridge.kt", +) + +CALL_RE = re.compile( + r"\b(?:callBin|sendBridgeRequestBytes|buildBridgeRequest|callBoundaryMethod)\(\s*[\"']([A-Za-z0-9_]+)[\"']" +) +CALL_IDENT_RE = re.compile(r"\bcallBin\(\s*([A-Z][A-Z0-9_]+)\b") +HTML_RE = re.compile(r"\b(?:callBridgeMethod|encodeBridgeRequest)\(\s*[\"']([A-Za-z0-9_]+)[\"']") +ARM_RE = re.compile(r"^\s*\"([A-Za-z0-9_]+)\"\s*->", re.M) + + +def fail(msg): + print(f"[bridge-rpc-names] FAIL: {msg}") + + +def frontend_sources(): + for dirpath, dirnames, filenames in os.walk(FRONTEND_SRC): + if "__tests__" in dirpath or os.sep + "proto" in dirpath[len(FRONTEND_SRC):]: + continue + for name in filenames: + if not name.endswith((".ts", ".tsx")): + continue + if ".test." in name or name == "setupTests.ts" or name.endswith(".d.ts"): + continue + yield os.path.join(dirpath, name) + + +def sent_names(): + """{name: [where, ...]}; exits 2 on an identifier that resolves to nothing.""" + sent = {} + unresolved = [] + for path in frontend_sources(): + text = open(path, encoding="utf-8").read() + rel = os.path.relpath(path, ROOT) + for m in CALL_RE.finditer(text): + sent.setdefault(m.group(1), []).append(f"{rel}:{text.count(chr(10), 0, m.start()) + 1}") + for m in CALL_IDENT_RE.finditer(text): + const = re.search(r"\bconst\s+" + m.group(1) + r"\s*=\s*[\"']([A-Za-z0-9_]+)[\"']", text) + where = f"{rel}:{text.count(chr(10), 0, m.start()) + 1}" + if const: + sent.setdefault(const.group(1), []).append(where) + else: + unresolved.append(f"{where} ({m.group(1)})") + html = open(INDEX_HTML, encoding="utf-8").read() + for m in HTML_RE.finditer(html): + sent.setdefault(m.group(1), []).append( + f"{os.path.relpath(INDEX_HTML, ROOT)}:{html.count(chr(10), 0, m.start()) + 1}" + ) + if unresolved: + fail("callBin called with an identifier no `const NAME = '…'` in its file names:") + for u in unresolved: + print(f" {u}") + sys.exit(2) + return sent + + +def handled_names(): + text = open(KOTLIN_BRIDGE, encoding="utf-8").read() + fn = text.find("fun handleBinaryRpcInternal(") + if fn < 0: + fail(f"{os.path.relpath(KOTLIN_BRIDGE, ROOT)}: handleBinaryRpcInternal not found") + sys.exit(2) + when = text.find("when (method) {", fn) + if when < 0: + fail("handleBinaryRpcInternal has no `when (method) {` block") + sys.exit(2) + i = text.index("{", when) + depth = 0 + j = i + while j < len(text): + c = text[j] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + break + j += 1 + block = text[i:j + 1] + handled = {} + for m in ARM_RE.finditer(block): + handled.setdefault(m.group(1), []).append( + f"{os.path.relpath(KOTLIN_BRIDGE, ROOT)}:{text.count(chr(10), 0, i + m.start()) + 1}" + ) + return handled + + +def main(): + sent = sent_names() + handled = handled_names() + if not sent or not handled: + fail(f"a scan that finds nothing is not a scan (sent={len(sent)}, handled={len(handled)})") + return 2 + status = 0 + unhandled = sorted(set(sent) - set(handled)) + if unhandled: + fail("the frontend sends bridge RPC names Kotlin does not handle:") + for name in unhandled: + print(f" {name}: " + ", ".join(sent[name])) + status = 1 + unsent = sorted(set(handled) - set(sent)) + if unsent: + fail("Kotlin handles bridge RPC names the frontend never sends (dead arms):") + for name in unsent: + print(f" {name}: " + ", ".join(handled[name])) + status = 1 + if status == 0: + print(f"[bridge-rpc-names] OK: {len(sent)} names sent, {len(handled)} handled, the same set") + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt index cc4106b09..14845aac9 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt @@ -69,18 +69,6 @@ class SinglePathWebViewBridge(private val context: Context) { - private fun readPersistedBytesOrEmpty(p: SharedPreferences, key: String): ByteArray { - val s = p.getString(key, null) - if (s.isNullOrBlank()) return ByteArray(0) - return try { - BridgeEncoding.base32CrockfordDecode(s) - } catch (_: Throwable) { - ByteArray(0) - } - } - - - // Enhanced error handling with specific error codes private const val ERROR_BRIDGE_NOT_INITIALIZED = 1 private const val ERROR_INVALID_PAYLOAD = 2 @@ -266,73 +254,10 @@ class SinglePathWebViewBridge(private val context: Context) { // --- Native QR scanner (Android ML Kit / camera activity) --- // JS expects a 1-byte boolean response for availability. // Launch result is delivered via CustomEvent("dsm-event") topic "qr_scan_result". - "hasNativeQrScanner" -> { - try { - // If the activity exists, we treat native scanning as available. - // (Camera permission flow is handled by the activity itself.) - val pm = inst.context.packageManager - val intent = android.content.Intent(inst.context, com.dsm.wallet.ui.QrScannerActivity::class.java) - val resolved = intent.resolveActivity(pm) != null - byteArrayOf(if (resolved) 1 else 0) - } catch (e: Throwable) { - Log.w(TAG, "hasNativeQrScanner: failed to resolve activity", e) - byteArrayOf(0) - } - } - - "startNativeQrScanner" -> { - try { - // Prefer launching through the active MainActivity so the result callback can - // dispatch back into the WebView as a dsm-event. - val act = com.dsm.wallet.ui.MainActivity.getActiveInstance() - if (act != null) { - act.runOnUiThread { - try { - act.launchNativeQrScanner { qrText: String? -> - // Dispatch via JS evaluation (topic: qr_scan_result) - act.dispatchQrScanResult(qrText) - } - } catch (e: Throwable) { - Log.w(TAG, "startNativeQrScanner: inner exception", e) - act.dispatchQrScanResult(null) - } - } - } - } catch (e: Throwable) { - Log.w(TAG, "startNativeQrScanner: failed to launch scanner", e) - } - // Empty response is fine; result comes via event. - ByteArray(0) - } - - // device_id bytes via JNI → Rust (Invariant #7: spine path, not prefs). - "getDeviceIdBin" -> { - try { - Unified.getDeviceIdBin() - } catch (_: Throwable) { - ByteArray(0) - } - } - - // genesis_hash bytes via JNI → Rust (Invariant #7: spine path, not prefs). - "getGenesisHashBin" -> { - try { - Unified.getGenesisHashBin() - } catch (_: Throwable) { - ByteArray(0) - } - } - - // signing public key bytes (JNI). Returns empty if not available. - "getSigningPublicKeyBin" -> { - try { - Unified.getSigningPublicKeyBin() - } catch (_: Throwable) { - ByteArray(0) - } - } - - // Canonical mnemonic-rooted Genesis v2 (whitepaper §2.5): generate a mnemonic for + // device_id bytes via JNI → Rust (Invariant #7: spine path, not prefs). + // genesis_hash bytes via JNI → Rust (Invariant #7: spine path, not prefs). + // signing public key bytes (JNI). Returns empty if not available. + // Canonical mnemonic-rooted Genesis v2 (whitepaper §2.5): generate a mnemonic for // backup, then create the wallet from it. No silicon enrollment, no random entropy. "generateMnemonic" -> { inst.generateMnemonic() @@ -354,23 +279,9 @@ class SinglePathWebViewBridge(private val context: Context) { } // strict wallet history (JNI). Returns FramedEnvelopeV3 bytes or empty on error. - "getWalletHistoryStrict" -> { - try { - Unified.getWalletHistoryStrict() - } catch (t: Throwable) { - Log.w(TAG, "getWalletHistoryStrict failed", t) - ByteArray(0) - } - } - - // genesis_envelope bytes (prefs-only). Used for cold-start rehydration. + // genesis_envelope bytes (prefs-only). Used for cold-start rehydration. // Returns empty if not present. - "getPersistedGenesisEnvelope" -> { - val p = inst.prefs() - readPersistedBytesOrEmpty(p, KEY_GENESIS_ENVELOPE) - } - - // Resolve BLE address from native mapping (bytes-only). + // Resolve BLE address from native mapping (bytes-only). // Payload: 32-byte device_id. Response: UTF-8 address bytes or empty. "resolveBleAddressForDeviceId" -> { if (payload.size != 32) return ByteArray(0) @@ -584,20 +495,7 @@ class SinglePathWebViewBridge(private val context: Context) { } // Generic Envelope v3 processing (online transfers, DBRW export, etc.) - "processEnvelopeV3" -> { - try { - val result = Unified.processEnvelopeV3(payload) - // State may have mutated — refresh NFC capsule if backup enabled. - // Rust decides whether to actually create one (no-op if disabled). - try { UnifiedNativeApi.maybeRefreshNfcCapsule() } catch (_: Throwable) {} - result - } catch (t: Throwable) { - Log.w(TAG, "processEnvelopeV3 failed", t) - ByteArray(0) - } - } - - else -> throw IllegalArgumentException("Unknown binary RPC method: $method") + else -> throw IllegalArgumentException("Unknown binary RPC method: $method") } } 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 2c656366d..10e78c993 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 @@ -145,12 +145,6 @@ object Unified { @Keep @JvmStatic fun getAllBalancesStrict(): ByteArray = UnifiedNativeApi.getAllBalancesStrict() - /** - * Fetch wallet history (strict, protobuf-encoded). - * Returns: ByteArray (protobuf-encoded WalletHistoryResponse) - */ - @Keep @JvmStatic fun getWalletHistoryStrict(): ByteArray = - UnifiedNativeApi.getWalletHistoryStrict() // BLE bilateral operations @@ -490,13 +484,6 @@ object Unified { @Keep @JvmStatic fun onAppBackgrounded(): Boolean = try { UnifiedNativeApi.onAppBackgrounded() } catch (_: Throwable) { false } @Keep @JvmStatic fun getGenesisHashBin(): ByteArray = UnifiedNativeApi.getGenesisHashBin() - /** - * Get the local signing public key (64 bytes for SPHINCS+ SPX256s). - * Used for bilateral transaction verification and QR code generation. - * @return 64-byte signing public key or empty array if not initialized - */ - @Keep @JvmStatic fun getSigningPublicKeyBin(): ByteArray = - UnifiedNativeApi.getSigningPublicKeyBin() /** * Get the current BLE MAC address for a device_id by searching identity cache. * @param deviceId Raw 32-byte device ID 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 c92d9b1a6..79eb49d11 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 @@ -59,7 +59,6 @@ internal object UnifiedNativeApi { @Keep @JvmStatic external fun processEnvelopeV3(envelope: ByteArray): ByteArray @Keep @JvmStatic external fun processEnvelopeV3WithAddress(envelope: ByteArray, deviceAddress: String): ByteArray @Keep @JvmStatic external fun getAllBalancesStrict(): ByteArray - @Keep @JvmStatic external fun getWalletHistoryStrict(): ByteArray @Keep @JvmStatic external fun ensureAppRouterInstalled(): Boolean @Keep @JvmStatic external fun getAppRouterStatus(): Int @Keep @JvmStatic external fun computeB0xAddress(genesis: ByteArray, deviceId: ByteArray, tip: ByteArray): String @@ -111,7 +110,6 @@ internal object UnifiedNativeApi { */ @Keep @JvmStatic external fun onAppBackgrounded(): Boolean @Keep @JvmStatic external fun getGenesisHashBin(): ByteArray - @Keep @JvmStatic external fun getSigningPublicKeyBin(): ByteArray @Keep @JvmStatic external fun resolveBleAddressForDeviceIdBin(deviceId: ByteArray): ByteArray @Keep @JvmStatic external fun resolvePeerIdentityForBleAddressBin(address: String): ByteArray @Keep @JvmStatic external fun isRejectEnvelope(envelopeBytes: ByteArray): ByteArray diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt index 8d0767e23..d7cfd5920 100644 --- a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt +++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt @@ -23,9 +23,9 @@ class SinglePathWebViewBridgeFuzzTest { @Test fun testFuzzBridgePayloads() { val methods = listOf( - "hasNativeQrScanner", - "getDeviceIdBin", - "getSigningPublicKeyBin", + "getTransportHeadersV3Bin", + "getArchitectureInfo", + "getDiagnosticsLog", "getPersistedDeviceId", "getPersistedGenesisHash", "getBluetoothStatus", @@ -37,7 +37,7 @@ class SinglePathWebViewBridgeFuzzTest { "resolveBleAddressForDeviceId", "initiateBleContactPairing", "getTransportHeadersV3Bin", - "processEnvelopeV3", + "acceptBilateralByCommitment", "unknownMethod" // Test unknown methods too ) @@ -289,7 +289,7 @@ class SinglePathWebViewBridgeFuzzTest { @Test fun testValidPayloadsStillWork() { // Test methods that should work with empty payloads - val emptyMethods = listOf("hasNativeQrScanner", "getDeviceIdBin") + val emptyMethods = listOf("getTransportHeadersV3Bin", "getPreference") emptyMethods.forEach { method -> val result = SinglePathWebViewBridge.handleBinaryRpc(method, ByteArray(0)) 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 b50d19e82..797a960e1 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 @@ -456,64 +456,6 @@ pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_initSdkV3( ) } -#[no_mangle] -pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_getWalletHistoryStrict( - env: jni::sys::JNIEnv, - _clazz: jni::sys::jclass, -) -> jni::sys::jbyteArray { - crate::jni::bridge_utils::jni_catch_unwind_jbytearray( - "getWalletHistoryStrict", - std::panic::AssertUnwindSafe(|| { - let mut env = match unsafe { env_from(env) } { - Some(e) => e, - None => return std::ptr::null_mut(), - }; - - let respond_envelope = - |payload: pb::envelope::Payload, env: &mut JNIEnv| -> jni::sys::jbyteArray { - framed_payload_byte_array(env, payload).into_raw() - }; - - let respond_error = |env: &mut JNIEnv, code: u32, msg: &str| -> jni::sys::jbyteArray { - let envelope = crate::jni::helpers::encode_error_transport(code, msg); - let mut out = Vec::new(); - out.push(0x03); - envelope.encode(&mut out).unwrap_or_default(); - env.byte_array_from_slice(&out) - .map(|a| a.into_raw()) - .unwrap_or_else(|_| empty_byte_array_or_empty(env).into_raw()) - }; - - if !SDK_READY.load(Ordering::SeqCst) { - return respond_error( - &mut env, - helpers::JniErrorCode::RuntimeError as u32, - "SDK not ready", - ); - } - - let result = crate::bridge::get_wallet_history_strict(); - match result { - Ok(history) => { - // Encode as the canonical WalletHistoryResponse payload - respond_envelope( - pb::envelope::Payload::WalletHistoryResponse(history), - &mut env, - ) - } - Err(e) => { - log::error!("getWalletHistoryStrict: failed: {}", e); - respond_error( - &mut env, - helpers::JniErrorCode::BridgeCallFailed as u32, - &format!("get_wallet_history_strict failed: {}", e), - ) - } - } - }), - ) -} - /// Remove a contact by contact_id. /// Returns 1 on success, 0 on failure. #[no_mangle] @@ -995,57 +937,6 @@ pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_getGenesisHas ) } -/// Returns the local signing public key as raw bytes (64 bytes for SPHINCS+) when available. -/// -/// Kotlin expects this exact symbol for `Unified.getSigningPublicKeyBin()`. -/// If identity has not been created yet, returns an empty byte array. -#[no_mangle] -pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_getSigningPublicKeyBin( - env: jni::sys::JNIEnv, - _clazz: jni::sys::jclass, -) -> jni::sys::jbyteArray { - crate::jni::bridge_utils::jni_catch_unwind_jbytearray( - "getSigningPublicKeyBin", - std::panic::AssertUnwindSafe(|| { - crate::logging::init_android_device_logging(); - - let env = match unsafe { env_from(env) } { - Some(e) => e, - None => return std::ptr::null_mut(), - }; - - // Identity can be asked for before startup has set the storage base dir (an - // Android lifecycle callback, a restarted background service): answer "not - // available" instead of reaching AppState's missing-base-dir panic. - match crate::sdk::app_state::AppState::readable() - .then(crate::sdk::app_state::AppState::get_public_key) - .flatten() - { - Some(pk) => { - log::info!("getSigningPublicKeyBin: returning {} bytes", pk.len()); - env.byte_array_from_slice(&pk) - .map(|a| a.into_raw()) - .unwrap_or_else(|e| { - log::error!( - "getSigningPublicKeyBin: failed to create jbyteArray: {}", - e - ); - env.new_byte_array(0) - .map(|a| a.into_raw()) - .unwrap_or(std::ptr::null_mut()) - }) - } - None => { - log::warn!("getSigningPublicKeyBin: no public key available"); - env.new_byte_array(0) - .map(|a| a.into_raw()) - .unwrap_or(std::ptr::null_mut()) - } - } - }), - ) -} - /// The error code of a framed or bare Error envelope, or `None` — see /// `crate::envelope::transport::error_code_of_transport_bytes` for why the /// frame byte must be stripped here. diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 5005a048e..e7e4d6367 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1013,6 +1013,7 @@ Owner request: integrate the frontend with the storage nodes properly, working f | frontend · hooks/useDiagnostics.ts; components/DiagnosticsOverlay.tsx; dsm/WebViewBridge/diagnostics.ts, transportCore.ts (`maybeThrowOnEmpty`, `__lastBridgeError`), strictQueries.ts, bilateral.ts, genesis.ts; dsm/BridgeGate.ts; dsm/bridgeTypes.ts; services/telemetry.ts; Kotlin · BridgeDiagnosticsHandler.kt, Unified.kt, UnifiedNativeDiagnostics.kt | The diagnostics report was mostly invented. `getArchitectureInfo` answered `UNKNOWN`/`unavailable`/`Architecture check error` for an empty, failed or partial answer, and Kotlin's handler answered the same fabricated proto when its own checker threw. `runNativeBridgeSelfTest`, `getRouterStatusBridge` and `computeB0xAddressBridge` called methods `index.html` never installs on `window.DsmBridge`, so the report's `selfTest=` line read `method_missing` on every device and `BridgeGate`'s router gate never ran (it detected the missing method and declared the gate "unsupported", then detected a "unit stub bridge" shape on the same path); `maybeThrowOnEmpty` probed a `lastError` method that does not exist either, so it passed empty answers through. `AndroidBridgeV3` declared all of these. The identity lines read `device_id_bytes`/`genesis_hash_bytes` from Kotlin's SharedPreferences copy and `DSM_ENV_CONFIG_PATH` from a preference nothing sets. The last bridge error lived in a window global with two writers of two shapes, which the overlay read at render time; the env-config help text in another. `telemetry.sendDiagnostics` swallowed every failure (the hook then toasted "saved") behind three fallback transports, and `exportDiagnosticsReport` answered an unread log as an empty one. Kotlin's `runNativeBridgeSelfTest` pushed empty envelopes through `processEnvelopeV3`/`processBleChunk` and was reachable from nothing. | Every line of the report is a measurement or names the failure of measuring it: the native session phase, the identity from Rust's transport headers (`getIdentity`), the architecture check as the native checker answered it or `arch=not measured: `, and the errors this session saw; the bundle's log section says `not read: ` when the log was not read. `getArchitectureInfo` refuses a status the checker cannot measure; Kotlin's handler lets its failure reach the RPC wrapper. The phantom helpers, `maybeThrowOnEmpty`, the window globals, the router gate and stub detection, the phantom declarations, telemetry's fallbacks and swallow, `hasUserConsentFromPrefs` and Kotlin's self-test are deleted; `BridgeGate` gates on the bridge object alone, which is all it ever did. | | frontend · dsm/policies.ts · `createToken`, `addTokenByAnchor`, `burnToken`, `forgetToken`, `getTokenCreationFeeEra`; components/TokenCreationDialog.tsx; components/screens/AccountsScreen.tsx; `proto` · `TokenCreateResponse`; `dsm_sdk` · handlers/token_routes.rs | `createToken` filled what the dialog did not give — an empty ticker and alias, 0 decimals, empty description and icon — and sent it, where Rust refuses an empty ticker or alias but takes 0 decimals as a choice; `burnToken` and `forgetToken` sent `""` for a missing token id. `addTokenByAnchor` reported the adopted token's ticker by stripping "Added " off Rust's message — the wire carried the ticker only as prose — and the screen showed its anchor from whichever balance row it found, or `""`. `getTokenCreationFeeEra` answered `undefined` for a refused or malformed answer, which the dialog shows as "…" for ever, indistinguishable from a query still running. | The requests carry what the user entered, as entered; Rust trims, uppercases and refuses. `TokenCreateResponse` carries `ticker` (field 5); Rust fills it at creation, at a repeated creation and at adoption, and the frontend reads the ticker, the token id and the 32-byte anchor from the answer's fields, refusing a success answer that lacks them. The fee query's failure is the failure, and the dialog shows `not available: `. The added-token panel shows the anchor Rust re-derived and answered. | | frontend · components/screens/SettingsMainScreen.tsx; hooks/useGenesisFlow.ts; dsm/WebViewBridge/genesis.ts; `proto` · `WalletCreateGenesisV2Request.locale`, `GenesisCreated.locale`; `dsm_sdk` · handlers/system_routes.rs; Kotlin · SinglePathWebViewBridge.kt, BridgeIdentityHandler.kt | The settings screen showed a failed `recovery.status` read as `NOT SET` — "Not configured. Add a mnemonic before this phone can arm a recovery capsule.", the status of a device with no backup at all — by rendering an all-false status in the failure's place, and logged nothing for a failed developer-mode preference read. `_onSetupRing`, reachable from nothing, probed two client methods that exist nowhere (`nfcReadRingId`, `nfcRegisterRingId`) and fell back to `window.prompt` for a ring id "provided by NFC". Genesis creation sent a `locale` — `navigator.language`, or `en-US` when the browser has none — that Kotlin threaded into the request and Rust only echoed back in `GenesisCreated.locale`; nothing consumed it. | A status that could not be read is shown as `NOT READ` / `Status not read: `, with the auto-backup toggle withheld until a status is held; the preference failure is logged; the ring setup and its phantom methods are deleted. `locale` is retired on both messages and removed from Rust, Kotlin (including the androidTest `AndroidLayerProofTest`, which passed the device locale) and the frontend: the genesis request carries the mnemonic alone. | +| `ci` · bridge_rpc_names.py (new); `.github/workflows/ci.yml`; Kotlin · SinglePathWebViewBridge.kt, Unified.kt, UnifiedNativeApi.kt; `dsm_sdk` · jni/unified_protobuf_bridge.rs | Nothing checked that the bridge's two sides agreed on the RPC method names a `BridgeRpcRequest` carries: the frontend sent names Kotlin never handled (`hasIdentityDirect`, for months, answered by the unknown-method arm) and Kotlin kept eight arms nothing sent — `processEnvelopeV3`, `getWalletHistoryStrict`, `getSigningPublicKeyBin`, `getPersistedGenesisEnvelope`, `getGenesisHashBin`, `getDeviceIdBin`, `startNativeQrScanner`, `hasNativeQrScanner` — two of them the only callers of their JNI exports. | `ci/bridge_rpc_names.py`: the names the frontend's production sources and `index.html` send (string literals, and a constant resolved in its file; unresolved fails) must equal the string arms of Kotlin's `handleBinaryRpcInternal`, both ways, no allowlist; it runs in the purity step, the frontend job and the Android unit-test job, so any side's change selects it. The eight dead arms are deleted, with the `getWalletHistoryStrict` and `getSigningPublicKeyBin` Kotlin functions, externals and Rust JNI exports that only they reached, and the prefs reader only one of them used. Negative controls: a phantom frontend name, a dead Kotlin arm and an unresolvable constant each fail the gate naming the offender. | Tests: `dsm_sdk::handlers::storage_routes::tests::storage_status_reports_the_pinned_set_and_each_members_own_answer` (the router's answer over real nodes on Postgres; then one member stops serving), `dsm_sdk::sdk::storage_node_sdk::tests::a_members_latest_bytecommit_is_its_own_or_there_is_none`, `dsm_sdk::storage::client_db::tests::a_database_that_does_not_exist_has_no_size`; frontend `dsm/__tests__/storage.test.ts` and `components/storage/__tests__/StorageNodePanels.test.tsx`. Mutation controls, each red on its named test: another member's ByteCommit accepted as this member's (`a_members_latest_bytecommit_is_its_own_or_there_is_none`); a missing database file reported as 0 bytes (`a_database_that_does_not_exist_has_no_size`); a member that did not answer reported as "no cycle" (`storage_status_reports_the_pinned_set_and_each_members_own_answer`); the frontend inventing an answer for a member that carries none (`a member that carries no answer is refused, never given one`); every member counted as answering (`shows the set and counts only the members that gave an answer`). @@ -1039,7 +1040,7 @@ Tests for policy publication: `dsm_sdk::handlers::token_routes::tests::bytes_tha - frontend · AccountsScreen `CPTA_INFO`: the ERA entry's type ("DJTE emission token"), anchor formula and "PROTOCOL-DEFINED" anchor id are copy in the screen, not facts Rust reports (the anchor Rust reports is shown beside them), and whether a token is protocol-defined — which withholds burn — is decided by its ticker. The dBTC entry is not touched: Bitcoin is parked. - `dsm_sdk` · `faucet.claim`: writes no history row, so a claim never appears in the wallet's history (the reserved `TX_TYPE_FAUCET` had no writer). - frontend · services/recovery/nfcRecoveryService.ts `getNfcBackupStatus`: reads `recovery.status` as `key=value` text inside an `AppStateResponse` and fills a missing `capsule_count`/`last_capsule_index` with 0 — a text protocol on a DSM path. Recovery is a dependency boundary this round; the route and its reader change together when the recovery specification is in scope. -- Kotlin · `SinglePathWebViewBridge.handleBinaryRpcInternal`: no gate proves that every bridge RPC name the frontend sends is one Kotlin matches. `hasIdentityDirect` was sent for months and answered by the unknown-method arm. +- frontend · dsm/WebViewBridge/transportCore.ts, NativeBoundaryBridge.ts, NativeHostBridge.ts, BridgeGate.ts, services/headerService.ts: every transport path branches on `window.DsmBridge.__callBin`, a method only the jest stub (`setupTests.ts`, 20 test files) installs; production has `sendMessageBin`, whose path frames responses and waits for `dsm-bridge-ready` under a 2.5 s timer. The test bridge should speak the production interface, and the readiness wait should be event-driven; `ci/bridge_rpc_names.py` will then also check the bridge object's methods against what `index.html` installs. - Kotlin · androidTest `AndroidLayerProofTest.claimFaucet`: hand-encodes the ArgPack's `schema_hash` as 32 zero bytes, where the frontend sends none, and swallows the claim's failure. ### 6.30 The SoFi verdict is Core's: facts built from reads, the ladder inside the advance (`fix/sofi-verdict-core-resolver`, 2026-09-26)