diff --git a/ci/bridge_rpc_names.py b/ci/bridge_rpc_names.py index 88fe3eaa8..ee8964c04 100644 --- a/ci/bridge_rpc_names.py +++ b/ci/bridge_rpc_names.py @@ -27,6 +27,19 @@ # only names Kotlin handles: an arm for a method that does not exist is a # stub of nothing, and two outlived the methods they stubbed. # +# The instrumented suite drives the real bridge on a device, so every name it +# sends must be one Kotlin handles. #1012 deleted five arms the frontend no +# longer sent; the instrumented proof still called them and the managed-device +# job stayed red on main from that merge on, because no gate read androidTest. +# A name is read where the suite names it: the first argument of +# callBridgeMethod, encodeBridgeRpcRequest or handleBinaryRpc*, or a method +# field encoded by hand (`encodeLengthDelimitedField(1, "…".toByteArray`). +# The one exception is the unknown-method probe: the suite binds its name to +# UNHANDLED_METHOD and sends it through one of those calls, and that name +# must be one Kotlin does NOT handle, or the probe tests nothing. A probe the +# gate cannot find, a binding it cannot read and a probe the suite never sends +# all fail. +# # Exit 0 only when every set is non-empty and each pair is equal. There is no # allowlist: a name one side must stop using is removed from that side. @@ -42,6 +55,7 @@ ROOT, "dsm_client", "android", "app", "src", "main", "java", "com", "dsm", "wallet", "bridge", "SinglePathWebViewBridge.kt", ) +ANDROID_TEST = os.path.join(ROOT, "dsm_client", "android", "app", "src", "androidTest") CALL_RE = re.compile( r"\b(?:callBin|sendBridgeRequestBytes|buildBridgeRequest|callBoundaryMethod)\(\s*[\"']([A-Za-z0-9_]+)[\"']" @@ -178,6 +192,17 @@ def production_names_callbin(): STUB_ARM_RE = re.compile(r"(? 0) + // The frontend's identity read (diagnostics.ts getDeviceIdBinBridgeAsync): + // the device id and the genesis hash come from the transport headers. + val headers = transportHeaders() + assertEquals("Device ID must be 32 bytes", 32, headers.deviceId.size()) + assertFalse("Device ID must not be all zeros", headers.deviceId.toByteArray().all { it == 0.toByte() }) + assertEquals("Genesis hash must be 32 bytes", 32, headers.genesisHash.size()) + assertFalse("Genesis hash must not be all zeros", headers.genesisHash.toByteArray().all { it == 0.toByte() }) } @Test fun t25_method_getAllBalancesStrict() { ensureGenesis() - claimFaucet() val resp = callBridgeMethod("getAllBalancesStrict", ByteArray(0)) assertTrue("Must be success", resp.first) @@ -342,18 +355,30 @@ class AndroidLayerProofTest { } @Test - fun t26_method_getWalletHistoryStrict() { + fun t26_method_nativeBoundaryIngress_routerQuery_walletHistory() { ensureGenesis() - val resp = callBridgeMethod("getWalletHistoryStrict", ByteArray(0)) - assertTrue("Must be success", resp.first) - // History may be empty if no transactions, but must not crash + val requestBytes = encodeBridgeRpcRequest( + "nativeBoundaryIngress", + buildRouterQueryIngressRequest("wallet.history", walletHistoryArgs()) + ) + val framedResp = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(300L, requestBytes)) + assertTrue("Must get response", framedResp.size > 8) + assertEquals("Message ID must match", 300L, readMessageId(framedResp)) + + val (isSuccess, data) = BridgeEnvelopeCodec.parseEnvelopeResponse(framedResp.copyOfRange(8, framedResp.size)) + assertTrue("nativeBoundaryIngress(routerQuery wallet.history) must succeed", isSuccess) + // A fresh wallet has no rows; the answer is still a history, not an error. + assertEquals( + "wallet.history answers a WalletHistoryResponse", + Envelope.PayloadCase.WALLET_HISTORY_RESPONSE, + decodeFramedEnvelope(okBytes(IngressResponse.parseFrom(data), "wallet.history")).payloadCase + ) } @Test fun t27_method_nativeBoundaryIngress_routerQuery_balanceList() { ensureGenesis() - claimFaucet() val requestBytes = encodeBridgeRpcRequest( "nativeBoundaryIngress", @@ -367,13 +392,10 @@ class AndroidLayerProofTest { val respBody = framedResp.copyOfRange(8, framedResp.size) val (isSuccess, data) = BridgeEnvelopeCodec.parseEnvelopeResponse(respBody) assertTrue("nativeBoundaryIngress(routerQuery balance.list) must succeed", isSuccess) - val ingressResponse = IngressResponse.parseFrom(data) - assertEquals( - "Ingress response should carry ok bytes", - IngressResponse.ResultCase.OK_BYTES, - ingressResponse.resultCase + assertTrue( + "Router query response payload must be non-empty", + okBytes(IngressResponse.parseFrom(data), "balance.list").isNotEmpty() ) - assertTrue("Router query response payload must be non-empty", !ingressResponse.okBytes.isEmpty) } @Test @@ -389,8 +411,7 @@ class AndroidLayerProofTest { assertEquals("Message ID must match", 200L, readMessageId(setResp)) val (isSuccess, data) = BridgeEnvelopeCodec.parseEnvelopeResponse(setResp.copyOfRange(8, setResp.size)) assertTrue("session.lock invoke must succeed", isSuccess) - val ingressResponse = IngressResponse.parseFrom(data) - assertEquals(IngressResponse.ResultCase.OK_BYTES, ingressResponse.resultCase) + okBytes(IngressResponse.parseFrom(data), "session.lock") } @Test @@ -440,11 +461,8 @@ class AndroidLayerProofTest { baos.write(encodeVarint32(reasonBytes.size)) baos.write(reasonBytes) - // Wrap in BridgeRpcRequest field 11 (bilateralPayload) - val bilateralPayload = baos.toByteArray() - val methodField = encodeLengthDelimitedField(1, "rejectBilateralByCommitment".toByteArray(Charsets.UTF_8)) - val payloadField = encodeLengthDelimitedField(11, bilateralPayload) - val requestBytes = methodField + payloadField + // BridgeRpcRequest field 11 (bilateral) + val requestBytes = encodeBridgeRpcRequest("rejectBilateralByCommitment", 11, baos.toByteArray()) val framedResp = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(300L, requestBytes)) assertTrue("Must get response", framedResp.size > 8) @@ -455,24 +473,6 @@ class AndroidLayerProofTest { assertTrue("rejectBilateralByCommitment must not crash (success response)", isSuccess) } - @Test - fun t32_method_getSigningPublicKeyBin() { - ensureGenesis() - - val resp = callBridgeMethod("getSigningPublicKeyBin", ByteArray(0)) - assertTrue("Must be success", resp.first) - // Key may be 32 or 33 bytes depending on key type, or empty if not available - } - - @Test - fun t34_method_getPersistedGenesisEnvelope() { - ensureGenesis() - - val resp = callBridgeMethod("getPersistedGenesisEnvelope", ByteArray(0)) - assertTrue("Must be success", resp.first) - assertTrue("Genesis envelope must be non-empty", resp.second.isNotEmpty()) - } - // ========================================================================= // SECTION 4: Full Frame Round-trip (JS-identical bytes) // @@ -480,15 +480,18 @@ class AndroidLayerProofTest { // MessagePort protocol works end-to-end through the Kotlin layer. // ========================================================================= - // Claims the faucet from the live storage fleet, so it runs only on real hardware. + // Claims ERA through faucet.claim, which needs the storage set the network pins. It is + // excluded from the emulator run; ensureGenesis installs the loopback test config, under + // which the claim is refused (CONFORMANCE_GAPS §6.29, Open). @RealHardware @Test fun t40_fullFrame_identityCheckAndBalanceFetch() { ensureGenesis() - claimFaucet() + val claim = claimFaucet() + assertTrue("the faucet must release ERA", claim.tokensReceived > 0L) // Step 1: Identity check (same bytes JS would send) - val identityReq = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0)) + val identityReq = encodeBridgeRpcRequest("getTransportHeadersV3Bin", ByteArray(0)) val identityFramed = prependMessageId(1001L, identityReq) val identityResp = MainActivity.processBridgeRequestForTest(ctx, identityFramed) @@ -497,7 +500,7 @@ class AndroidLayerProofTest { identityResp.copyOfRange(8, identityResp.size) ) assertTrue("Identity must succeed", idOk) - assertEquals("An identity has a 32-byte device id", 32, idData.size) + assertEquals("An identity has a 32-byte device id", 32, Headers.parseFrom(idData).deviceId.size()) // Step 2: Fetch balances (same bytes JS would send) val balReq = encodeBridgeRpcRequest("getAllBalancesStrict", ByteArray(0)) @@ -534,22 +537,6 @@ class AndroidLayerProofTest { assertTrue("ERA balance must be positive after faucet", eraBalance > 0L) } - @Test - fun t42_fullFrame_headersContainDeviceId() { - ensureGenesis() - - val deviceIdResp = callBridgeMethod("getDeviceIdBin", ByteArray(0)) - val deviceId = deviceIdResp.second - - val headersResp = callBridgeMethod("getTransportHeadersV3Bin", ByteArray(0)) - val headers = headersResp.second - - assertTrue("Headers must be non-empty", headers.isNotEmpty()) - // The device ID should appear somewhere in the headers protobuf - // (as a bytes field). Check that the headers size suggests real data. - assertTrue("Headers must be larger than 32 bytes (contains deviceId + other fields)", headers.size > 32) - } - // ========================================================================= // SECTION 5: Thread Safety // @@ -566,13 +553,16 @@ class AndroidLayerProofTest { val latch = CountDownLatch(threadCount) val errors = AtomicInteger(0) val successes = AtomicInteger(0) + val deviceIds = ConcurrentHashMap.newKeySet() for (i in 0 until threadCount) { Thread { try { barrier.await() // All threads start simultaneously - val resp = callBridgeMethod("getDeviceIdBin", ByteArray(0)) - if (resp.first && resp.second.size == 32 && resp.second.any { it != 0.toByte() }) { + val resp = callBridgeMethod("getTransportHeadersV3Bin", ByteArray(0)) + val deviceId = if (resp.first) Headers.parseFrom(resp.second).deviceId else ByteString.EMPTY + if (deviceId.size() == 32 && deviceId.toByteArray().any { it != 0.toByte() }) { + deviceIds.add(deviceId) successes.incrementAndGet() } else { errors.incrementAndGet() @@ -588,12 +578,12 @@ class AndroidLayerProofTest { latch.await() assertEquals("No errors in concurrent calls", 0, errors.get()) assertEquals("All threads must succeed", threadCount, successes.get()) + assertEquals("Every thread must read the same device id", 1, deviceIds.size) } @Test fun t51_threadSafety_concurrentBalanceFetches() { ensureGenesis() - claimFaucet() val threadCount = 8 val barrier = CyclicBarrier(threadCount) @@ -628,25 +618,34 @@ class AndroidLayerProofTest { fun t52_threadSafety_mixedMethodsConcurrent() { ensureGenesis() - val methods = listOf( - "getDeviceIdBin" to ByteArray(0), - "getGenesisHashBin" to ByteArray(0), - "getSigningPublicKeyBin" to ByteArray(0), - "getTransportHeadersV3Bin" to ByteArray(0), - "getAllBalancesStrict" to ByteArray(0), + val calls: List<() -> Pair> = listOf( + { callBridgeMethod("getTransportHeadersV3Bin", ByteArray(0)) }, + { callBridgeMethod("getAllBalancesStrict", ByteArray(0)) }, + { + callBridgeMethod( + "nativeBoundaryIngress", + buildRouterQueryIngressRequest("balance.list", ByteArray(0)) + ) + }, + { + callBridgeMethod( + "nativeBoundaryIngress", + buildRouterQueryIngressRequest("wallet.history", walletHistoryArgs()) + ) + }, ) - val threadCount = methods.size * 2 + val threadCount = calls.size * 2 val barrier = CyclicBarrier(threadCount) val latch = CountDownLatch(threadCount) val errors = AtomicInteger(0) for (i in 0 until threadCount) { - val (method, payload) = methods[i % methods.size] + val call = calls[i % calls.size] Thread { try { barrier.await() - val resp = callBridgeMethod(method, payload) + val resp = call() if (!resp.first) errors.incrementAndGet() } catch (t: Throwable) { errors.incrementAndGet() @@ -674,7 +673,7 @@ class AndroidLayerProofTest { try { barrier.await() val msgId = (1000L + i) - val reqBytes = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0)) + val reqBytes = encodeBridgeRpcRequest("getTransportHeadersV3Bin", ByteArray(0)) val framedReq = prependMessageId(msgId, reqBytes) val framedResp = MainActivity.processBridgeRequestForTest(ctx, framedReq) @@ -707,7 +706,7 @@ class AndroidLayerProofTest { @Test fun t60_error_unknownMethod() { - val requestBytes = encodeBridgeRpcRequest("nonExistentMethod", ByteArray(0)) + val requestBytes = encodeBridgeRpcRequest(UNHANDLED_METHOD, ByteArray(0)) val framedResp = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(1L, requestBytes)) assertTrue("Must get response for unknown method", framedResp.size > 8) @@ -776,9 +775,9 @@ class AndroidLayerProofTest { MainActivity.processBridgeRequestForTest(ctx, prependMessageId(1L, garbage)) // Then: send valid request — bridge must still work - val resp = callBridgeMethod("getDeviceIdBin", ByteArray(0)) + val resp = callBridgeMethod("getTransportHeadersV3Bin", ByteArray(0)) assertTrue("Bridge must work after error", resp.first) - assertEquals("Identity must still exist", 32, resp.second.size) + assertEquals("Identity must still exist", 32, Headers.parseFrom(resp.second).deviceId.size()) } @Test @@ -789,8 +788,8 @@ class AndroidLayerProofTest { var successCount = 0 for (i in 0 until 100) { try { - val resp = callBridgeMethod("getDeviceIdBin", ByteArray(0)) - if (resp.first && resp.second.size == 32) successCount++ + val resp = callBridgeMethod("getTransportHeadersV3Bin", ByteArray(0)) + if (resp.first && Headers.parseFrom(resp.second).deviceId.size() == 32) successCount++ } catch (_: Throwable) { // count as failure } @@ -834,30 +833,63 @@ class AndroidLayerProofTest { genesisCreated = true } - private fun claimFaucet() { - val deviceId = SinglePathWebViewBridge.handleBinaryRpcRaw("getDeviceIdBin", ByteArray(0)) - if (deviceId.size != 32) return - - try { - // Hand-encode protobuf wire format (no generated proto classes needed): - // FaucetClaimRequest { bytes device_id = 1 } - val faucetClaimReqBytes = encodeLengthDelimitedField(1, deviceId) - - // ArgPack { Hash32 schema_hash = 1; Codec codec = 2; bytes body = 3 } - // Hash32 { bytes v = 1 } → 32 zero bytes - val hash32Bytes = encodeLengthDelimitedField(1, ByteArray(32)) - val argPackBytes = encodeLengthDelimitedField(1, hash32Bytes) + // schema_hash - byteArrayOf(0x10, 0x01) + // codec = CODEC_PROTO (1) - encodeLengthDelimitedField(3, faucetClaimReqBytes) // body - - val requestBytes = encodeBridgeRpcRequest( - "nativeBoundaryIngress", - buildRouterInvokeIngressRequest("faucet.claim", argPackBytes) - ) - MainActivity.processBridgeRequestForTest(ctx, prependMessageId(9999L, requestBytes)) - } catch (_: Throwable) { - // Faucet may fail (already claimed, etc.) — don't block tests + /** The transport headers `getTransportHeadersV3Bin` answers: the frontend's identity read. */ + private fun transportHeaders(): Headers { + val resp = callBridgeMethod("getTransportHeadersV3Bin", ByteArray(0)) + assertTrue("getTransportHeadersV3Bin must succeed", resp.first) + return Headers.parseFrom(resp.second) + } + + /** `wallet.history`'s argument as the frontend sends it (strictQueries.ts): limit and offset, u64 LE. */ + private fun walletHistoryArgs(): ByteArray = + ArgPack.newBuilder() + .setCodec(Codec.CODEC_PROTO) + .setBody(ByteString.copyFrom(ByteArray(16))) + .build() + .toByteArray() + + /** The bytes a routed call answered; a refusal fails the test with Rust's reason. */ + private fun okBytes(response: IngressResponse, route: String): ByteArray { + if (response.resultCase == IngressResponse.ResultCase.ERROR) { + fail("$route refused: ${response.error.message}") } + assertEquals("$route answers bytes", IngressResponse.ResultCase.OK_BYTES, response.resultCase) + return response.okBytes.toByteArray() + } + + /** A router answer: `0x03` then an Envelope v3. */ + private fun decodeFramedEnvelope(bytes: ByteArray): Envelope { + assertTrue("A framed Envelope v3 starts with 0x03", bytes.isNotEmpty() && bytes[0] == 0x03.toByte()) + return Envelope.parseFrom(bytes.copyOfRange(1, bytes.size)) + } + + /** + * `faucet.claim` exactly as the frontend sends it (transactions.ts `claimFaucet`): this + * device's id from the transport headers, in an ArgPack with no schema hash. Rust answers a + * refusal as an ingress error, and the test fails with Rust's reason; otherwise returns the + * release Rust reports. + */ + private fun claimFaucet(): FaucetClaimResponse { + val request = FaucetClaimRequest.newBuilder().setDeviceId(transportHeaders().deviceId).build() + val argPack = ArgPack.newBuilder() + .setCodec(Codec.CODEC_PROTO) + .setBody(request.toByteString()) + .build() + val requestBytes = encodeBridgeRpcRequest( + "nativeBoundaryIngress", + buildRouterInvokeIngressRequest("faucet.claim", argPack.toByteArray()) + ) + val framedResp = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(9999L, requestBytes)) + assertTrue("faucet.claim must answer", framedResp.size > 8) + val (isSuccess, data) = BridgeEnvelopeCodec.parseEnvelopeResponse(framedResp.copyOfRange(8, framedResp.size)) + assertTrue("faucet.claim must reach the router", isSuccess) + val envelope = decodeFramedEnvelope(okBytes(IngressResponse.parseFrom(data), "faucet.claim")) + assertEquals( + "faucet.claim answers a FaucetClaimResponse", + Envelope.PayloadCase.FAUCET_CLAIM_RESPONSE, + envelope.payloadCase + ) + return envelope.faucetClaimResponse } /** @@ -876,6 +908,11 @@ class AndroidLayerProofTest { * Encode a BridgeRpcRequest with just method and empty/simple payload. * field 1 = method (string), field 2 = empty_payload (for empty payload methods) */ + /** A BridgeRpcRequest whose payload is the message [payload] at oneof field [payloadField]. */ + private fun encodeBridgeRpcRequest(method: String, payloadField: Int, payload: ByteArray): ByteArray = + encodeLengthDelimitedField(1, method.toByteArray(Charsets.UTF_8)) + + encodeLengthDelimitedField(payloadField, payload) + private fun encodeBridgeRpcRequest(method: String, payload: ByteArray): ByteArray { val methodField = encodeLengthDelimitedField(1, method.toByteArray(Charsets.UTF_8)) if (payload.isEmpty()) { diff --git a/dsm_client/android/app/src/main/AndroidManifest.xml b/dsm_client/android/app/src/main/AndroidManifest.xml index e9dbc699b..962463a88 100644 --- a/dsm_client/android/app/src/main/AndroidManifest.xml +++ b/dsm_client/android/app/src/main/AndroidManifest.xml @@ -157,13 +157,6 @@ - - - diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeBleHandler.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeBleHandler.kt index e9a9e7066..17fdedd77 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeBleHandler.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeBleHandler.kt @@ -2,9 +2,6 @@ package com.dsm.wallet.bridge -import android.util.Log -import com.dsm.wallet.bridge.ble.BleCoordinator - internal object BridgeBleHandler { fun requestBlePermissions() { @@ -23,45 +20,4 @@ internal object BridgeBleHandler { // ignore } } - - fun setBleIdentityForAdvertising(payload: ByteArray, logTag: String): ByteArray { - // Payload is protobuf-encoded BleIdentityCharValue from Rust's - // encodeIdentityCharValue. Decode the proto fields. - val genesisHash: ByteArray - val deviceId: ByteArray - try { - val parsed = dsm.types.proto.BleIdentityCharValue.parseFrom(payload) - genesisHash = parsed.genesisHash.toByteArray() - deviceId = parsed.deviceId.toByteArray() - if (genesisHash.size != 32 || deviceId.size != 32) { - Log.e(logTag, "setBleIdentityForAdvertising: proto field sizes wrong genesis=${genesisHash.size} device=${deviceId.size}") - return ByteArray(0) - } - } catch (e: Exception) { - Log.e(logTag, "setBleIdentityForAdvertising: failed to decode ${payload.size} bytes: ${e.message}") - return ByteArray(0) - } - - try { - val ctx = com.dsm.wallet.ui.MainActivity.getActiveInstance()?.baseContext - if (ctx == null) { - Log.w(logTag, "setBleIdentityForAdvertising: no active MainActivity") - return ByteArray(0) - } - val bleService = BleCoordinator.getInstance(ctx) - - val gattReady = bleService.ensureGattServerStarted() - if (!gattReady) { - Log.w(logTag, "setBleIdentityForAdvertising: GATT server not ready (permissions not granted yet)") - bleService.setIdentityValue(genesisHash, deviceId) - return ByteArray(0) - } - - bleService.setIdentityValue(genesisHash, deviceId) - Log.i(logTag, "setBleIdentityForAdvertising: identity injected into BLE (genesis=${genesisHash.size}B, deviceId=${deviceId.size}B)") - } catch (t: Throwable) { - Log.w(logTag, "setBleIdentityForAdvertising failed", t) - } - return ByteArray(0) - } } diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt index 16341fd4f..5a1a19c15 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt @@ -445,7 +445,6 @@ internal object BridgeEnvelopeCodec { 6 -> parseAppRouterPayload(bytes) 8 -> parseSingleBytesPayload(bytes) 9 -> parseSingleBytesPayload(bytes) - 10 -> parseBleIdentityPayload(bytes) 11 -> parseBilateralPayload(bytes) else -> ByteArray(0) } @@ -505,11 +504,6 @@ internal object BridgeEnvelopeCodec { return parseBytesPayload(bytes) } - private fun parseBleIdentityPayload(bytes: ByteArray): ByteArray { - // Keep canonical protobuf bytes for downstream typed decoders. - return bytes - } - private fun parseBilateralPayload(bytes: ByteArray): ByteArray { // Keep canonical protobuf bytes for downstream typed decoders. return bytes diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeIdentityHandler.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeIdentityHandler.kt index cca8489a7..510eaa8f2 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeIdentityHandler.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeIdentityHandler.kt @@ -203,6 +203,10 @@ internal object BridgeIdentityHandler { // The Rust route already initialized the SDK context (wallet unlocked this session). sdkContextInitialized.set(true) Log.i(logTag, "createGenesisV2: identity persisted + SDK context initialized") + // The appliance has an identity now; advertising follows it. + com.dsm.wallet.ui.MainActivity.getActiveInstance()?.let { act -> + act.runOnUiThread { act.startBleForIdentity() } + } envelopeBytes } catch (t: Throwable) { Log.e(logTag, "createGenesisV2 failed", t) diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/NativeHostBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/NativeHostBridge.kt index 7fb15f422..77058ae4f 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/NativeHostBridge.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/NativeHostBridge.kt @@ -10,7 +10,6 @@ import android.util.Log import androidx.core.content.ContextCompat import com.google.protobuf.ByteString import com.google.protobuf.InvalidProtocolBufferException -import com.dsm.wallet.bridge.ble.BleCoordinator import com.dsm.wallet.ui.MainActivity import dsm.types.proto.BiometricAuthorizePayload import dsm.types.proto.HostPermissionsRequestPayload @@ -82,10 +81,6 @@ internal object NativeHostBridge { .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_CAPABILITIES_GET) .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_START_SCAN) .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_STOP_SCAN) - .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START) - .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP) - .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START) - .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP) .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START) .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_STOP) .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_PERMISSIONS_REQUEST) @@ -116,47 +111,6 @@ internal object NativeHostBridge { okAck() } - NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START -> { - val act = MainActivity.getActiveInstance() - ?: return errorResponse(503, "BLE unavailable: no active activity") - val ctx = act.baseContext - val ok = BleCoordinator.getInstance(ctx).startScanning() - Log.i(logTag, "host_control.ble.scan.start: result=$ok") - act.runOnUiThread { act.publishCurrentSessionState("host_control.ble.scan.start") } - okAck(ok) - } - - NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP -> { - val act = MainActivity.getActiveInstance() - ?: return errorResponse(503, "BLE unavailable: no active activity") - BleCoordinator.getInstance(act.baseContext).stopScanning() - Log.i(logTag, "host_control.ble.scan.stop") - act.runOnUiThread { act.publishCurrentSessionState("host_control.ble.scan.stop") } - okAck() - } - - NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START -> { - val act = MainActivity.getActiveInstance() - ?: return errorResponse(503, "BLE unavailable: no active activity") - val ok = BleCoordinator.getInstance(act.baseContext).startAdvertising() - if (ok) { - act.setBleAdvertisingDesired(true) - } - Log.i(logTag, "host_control.ble.advertise.start: result=$ok") - act.runOnUiThread { act.publishCurrentSessionState("host_control.ble.advertise.start") } - okAck(ok) - } - - NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP -> { - val act = MainActivity.getActiveInstance() - ?: return errorResponse(503, "BLE unavailable: no active activity") - BleCoordinator.getInstance(act.baseContext).stopAdvertising() - act.setBleAdvertisingDesired(false) - Log.i(logTag, "host_control.ble.advertise.stop") - act.runOnUiThread { act.publishCurrentSessionState("host_control.ble.advertise.stop") } - okAck() - } - NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START -> { val act = MainActivity.getActiveInstance() ?: return errorResponse(503, "NFC unavailable: no active activity") 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 4b49ad073..1e6f94f98 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 @@ -279,32 +279,6 @@ class SinglePathWebViewBridge(private val context: Context) { } } - // strict wallet history (JNI). Returns FramedEnvelopeV3 bytes or empty on error. - // genesis_envelope bytes (prefs-only). Used for cold-start rehydration. - // Returns empty if not present. - // 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) - UnifiedContactBridge.resolveBleAddressForDeviceIdBin(payload) - } - - "readPeerRelationshipStatus" -> { - val bleAddress = payload.toString(Charsets.UTF_8).trim() - if (bleAddress.isEmpty()) { - ByteArray(0) - } else { - try { - BleCoordinator.getInstance(inst.context) - .readPeerRelationshipStatus(bleAddress) - ?: ByteArray(0) - } catch (t: Throwable) { - Log.w(TAG, "readPeerRelationshipStatus failed for $bleAddress", t) - ByteArray(0) - } - } - } - // Diagnostics: append raw payload to persisted bridge log "diagnosticsLog" -> { BridgeLogger.logDiagnosticsPayload(payload) @@ -370,48 +344,6 @@ class SinglePathWebViewBridge(private val context: Context) { } } - // Rust-driven pairing orchestration: scan all unpaired contacts automatically - "startPairingAll" -> { - // Invariant #7: identity check via JNI → Rust, not prefs side channel. - // BLE identity publication requires BOTH device_id and genesis_hash. - val hasIdentity = try { - Unified.getDeviceIdBin().size == 32 && Unified.getGenesisHashBin().size == 32 - } catch (_: Throwable) { false } - if (!hasIdentity) { - Log.w(TAG, "startPairingAll: identity not ready, aborting") - return ByteArray(0) - } - // Ensure BLE permissions are granted before starting the loop - BridgeBleHandler.requestBlePermissions() - // Ensure BleCoordinator is initialized before Rust calls startBlePairing* - try { - val ctx = com.dsm.wallet.ui.MainActivity.getActiveInstance()?.applicationContext - if (ctx != null) { - BleCoordinator.getInstance(ctx) - Log.i(TAG, "startPairingAll: BleCoordinator ensured") - } else { - Log.w(TAG, "startPairingAll: no context for BleCoordinator init") - } - } catch (t: Throwable) { - Log.w(TAG, "startPairingAll: BleCoordinator init failed", t) - } - try { - Unified.startPairingAll() - } catch (t: Throwable) { - Log.w(TAG, "startPairingAll failed", t) - } - ByteArray(0) - } - - "stopPairingAll" -> { - try { - Unified.stopPairingAll() - } catch (t: Throwable) { - Log.w(TAG, "stopPairingAll failed", t) - } - ByteArray(0) - } - "requestBlePermissions" -> { BridgeBleHandler.requestBlePermissions() ByteArray(0) @@ -469,28 +401,6 @@ class SinglePathWebViewBridge(private val context: Context) { } } - "setBleIdentityForAdvertising" -> { - val parsed = try { - dsm.types.proto.BleIdentityPayload.parseFrom(payload) - } catch (e: com.google.protobuf.InvalidProtocolBufferException) { - Log.w(TAG, "setBleIdentityForAdvertising: invalid payload: ${e.message}") - return ByteArray(0) - } - val genesisHash = parsed.genesisHash.toByteArray() - val deviceId = parsed.deviceId.toByteArray() - if (genesisHash.size != 32 || deviceId.size != 32) { - Log.w(TAG, "setBleIdentityForAdvertising: invalid field lengths genesis=${genesisHash.size} device=${deviceId.size}") - return ByteArray(0) - } - // Kotlin MUST NOT concatenate raw bytes — encodeIdentityCharValue is the canonical encoder. - val out = Unified.encodeIdentityCharValue(genesisHash, deviceId) - if (out.isEmpty()) { - Log.w(TAG, "setBleIdentityForAdvertising: encodeIdentityCharValue returned empty") - return ByteArray(0) - } - BridgeBleHandler.setBleIdentityForAdvertising(out, TAG) - } - // Generic Envelope v3 processing (online transfers, DBRW export, etc.) 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 10e78c993..481deaaf7 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 @@ -27,14 +27,13 @@ import androidx.annotation.Keep // - All crypto (SPHINCS+, ML-KEM-768, DBRW) handled in Rust beneath. // // DOMAIN GROUPS: -// Identity: recordPeerIdentity // Protocol: processEnvelopeV3, processEnvelopeV3WithAddress // Shared boundary: dispatchStartup, dispatchIngress // Bilateral: acceptBilateralByCommitment, ... // BLE: initBleCoordinator, processBleChunk, chunkEnvelopeForBle, ... // Contacts: removeContact, hasContactForDeviceId // -// Full method list: See UnifiedNativeApi.kt for all 87+ external declarations. +// Full method list: UnifiedNativeApi.kt holds every external declaration. // ============================================================================ /** @@ -45,23 +44,6 @@ import androidx.annotation.Keep */ object Unified { - /** - * Called when a peer's identity (genesis hash + device ID) is read from BLE GATT. - * This should be bridged to Rust/JS as needed. - */ - @Keep - @JvmStatic - fun recordPeerIdentity(address: String, identity: ByteArray) { - UnifiedNativeApi.recordPeerIdentity(address, identity) - } - - @Keep - @JvmStatic - fun onPeerIdentityReceived(address: String, identity: ByteArray) { - // Forward to native layer to maintain device_id -> BLE address mapping (no hex at app layer) - recordPeerIdentity(address, identity) - } - init { // Load the native library with JNI exports. // The `Unified_*` JNI surface is implemented in the Rust SDK shared library. @@ -209,13 +191,6 @@ object Unified { return UnifiedBleBridge.stopBlePairingScan() } - /** - * Stop BLE advertising. Called by Rust pairing loop on exit to prevent lingering advertise. - */ - @Keep @JvmStatic fun stopBlePairingAdvertise(): Boolean { - return UnifiedBleBridge.stopBlePairingAdvertise() - } - // ---------- Event notifications ---------- @Keep @JvmStatic fun bleNotifyConnectionState(address: String, connected: Boolean) { UnifiedNativeApi.bleNotifyConnectionState(address, connected) @@ -252,15 +227,7 @@ object Unified { @Keep @JvmStatic fun notifyBleIdentityObserved(address: String, genesisHash: ByteArray, deviceId: ByteArray) { UnifiedNativeApi.notifyBleIdentityObserved(address, genesisHash, deviceId) } - - /** - * Check if there are any contacts that are not yet BLE-capable (need pairing). - * Used to determine if persistent BLE scanning should be active. - * Returns true if there are unpaired contacts, false if all contacts are BleCapable. - */ - @Keep @JvmStatic fun hasUnpairedContacts(): Boolean = UnifiedNativeApi.hasUnpairedContacts() - @Keep @JvmStatic fun onDeviceConnected(address: String) { UnifiedBleEvents.onDeviceConnected(address) } @@ -321,13 +288,6 @@ object Unified { @Keep @JvmStatic fun encodeIdentityCharValue(genesisHash: ByteArray, deviceId: ByteArray): ByteArray = UnifiedNativeApi.encodeIdentityCharValue(genesisHash, deviceId) - /** - * Encode the local relationship send-status protobuf for the connected BLE peer. - * Rust owns the relationship-readiness logic; Kotlin relays the raw bytes. - */ - @Keep @JvmStatic fun getRelationshipStatusCharValue(bleAddress: String): ByteArray = - UnifiedNativeApi.getRelationshipStatusCharValue(bleAddress) - /** * Process raw protobuf bytes read from the GATT identity characteristic. * Rust decodes BleIdentityCharValue, dispatches identity events, and returns @@ -484,13 +444,6 @@ object Unified { @Keep @JvmStatic fun onAppBackgrounded(): Boolean = try { UnifiedNativeApi.onAppBackgrounded() } catch (_: Throwable) { false } @Keep @JvmStatic fun getGenesisHashBin(): ByteArray = UnifiedNativeApi.getGenesisHashBin() - /** - * Get the current BLE MAC address for a device_id by searching identity cache. - * @param deviceId Raw 32-byte device ID - * @return UTF-8 BLE MAC address bytes or empty array if not found/connected - */ - @Keep @JvmStatic fun resolveBleAddressForDeviceIdBin(deviceId: ByteArray): ByteArray = - UnifiedNativeApi.resolveBleAddressForDeviceIdBin(deviceId) /** * Resolve the persisted peer identity for a BLE address. * Returns 64 bytes ordered as [device_id(32)][genesis_hash(32)], or empty if unknown. @@ -569,24 +522,6 @@ object Unified { // `cdbrw.measure_trust` router query publishes a live CdbrwTrustSnapshot // with the same data, and frontend/UI consume that directly. - // ---------- BLE pairing orchestration (Rust-driven loop) ---------- - - /** - * Start the Rust pairing orchestrator loop that scans all unpaired contacts - * and drives BLE pairing automatically. Fire-and-forget — status updates are - * delivered via PairingStatusUpdate BleEvent envelopes through the event bus. - */ - @Keep @JvmStatic fun startPairingAll() { - UnifiedNativeApi.startPairingAll() - } - - /** - * Signal the Rust pairing orchestrator to stop its loop at the next cycle boundary. - */ - @Keep @JvmStatic fun stopPairingAll() { - UnifiedNativeApi.stopPairingAll() - } - @Keep @JvmStatic fun onConnectionFailed(address: String, reason: String) { UnifiedBleEvents.onConnectionFailed(address, reason) } diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedBleBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedBleBridge.kt index 612b0b128..5243063d2 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedBleBridge.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedBleBridge.kt @@ -93,25 +93,12 @@ internal object UnifiedBleBridge { } } - private fun publishLocalIdentityIfAvailable(svc: BleCoordinator): Boolean { - try { - val deviceIdBytes = try { Unified.getDeviceIdBin() } catch (_: Throwable) { byteArrayOf() } - val genesisHashBytes = try { Unified.getGenesisHashBin() } catch (_: Throwable) { byteArrayOf() } - if (deviceIdBytes.size == 32 && genesisHashBytes.size == 32) { - svc.setIdentityValue(genesisHashBytes, deviceIdBytes) - Log.i("UnifiedBleBridge", "publishLocalIdentityIfAvailable: local BLE identity published to GATT") - return true - } else { - Log.w( - "UnifiedBleBridge", - "publishLocalIdentityIfAvailable: identity bytes unavailable (genesis=${genesisHashBytes.size}, device=${deviceIdBytes.size})" - ) - return false - } - } catch (t: Throwable) { - Log.w("UnifiedBleBridge", "publishLocalIdentityIfAvailable failed", t) - return false - } + // The GATT server reads the identity from Rust when a peer asks for it; + // advertising without one would answer every identity read with a failure. + private fun localIdentityAvailable(): Boolean = try { + Unified.getDeviceIdBin().size == 32 && Unified.getGenesisHashBin().size == 32 + } catch (_: Throwable) { + false } fun initBleCoordinator( @@ -136,7 +123,7 @@ internal object UnifiedBleBridge { fun startBlePairingAdvertise(): Boolean { val svc = bleCoordinator ?: return false return try { - if (!publishLocalIdentityIfAvailable(svc)) { + if (!localIdentityAvailable()) { Log.w("UnifiedBleBridge", "startBlePairingAdvertise: refusing to advertise without local identity") return false } @@ -154,10 +141,6 @@ internal object UnifiedBleBridge { return try { svc.stopScanning() } catch (_: Throwable) { false } } - fun stopBlePairingAdvertise(): Boolean { - val svc = bleCoordinator ?: return false - return try { svc.stopAdvertising() } catch (_: Throwable) { false } - } fun requestGattWriteChunks(deviceAddress: String, chunks: Array): Boolean { val svc = bleCoordinator ?: return false @@ -241,7 +224,6 @@ internal object UnifiedBleBridge { Log.i("BleTransferTrace", "requestGattWriteChunks routing: no route -> $effectiveAddr (on-demand connect)") Log.i("UnifiedBleBridge", "requestGattWriteChunks: no route for $effectiveAddr — on-demand connect") svc.ensureGattServerStarted() - publishLocalIdentityIfAvailable(svc) svc.startAdvertising() runBlocking { try { diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedContactBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedContactBridge.kt index 9a03306ba..7277ef197 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedContactBridge.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedContactBridge.kt @@ -4,10 +4,6 @@ package com.dsm.wallet.bridge internal object UnifiedContactBridge { - fun resolveBleAddressForDeviceIdBin(deviceId: ByteArray): ByteArray { - return try { Unified.resolveBleAddressForDeviceIdBin(deviceId) } catch (_: Throwable) { ByteArray(0) } - } - fun resolvePeerIdentityForBleAddressBin(address: String): ByteArray { return try { Unified.resolvePeerIdentityForBleAddressBin(address) } catch (_: Throwable) { ByteArray(0) } } @@ -19,12 +15,4 @@ internal object UnifiedContactBridge { fun hasContactForDeviceId(deviceId: ByteArray): Boolean { return try { Unified.hasContactForDeviceId(deviceId) } catch (_: Throwable) { false } } - - fun isBleAddressPaired(address: String): Boolean { - return try { Unified.isBleAddressPaired(address) } catch (_: Throwable) { false } - } - - fun hasUnpairedContacts(): Boolean { - return try { Unified.hasUnpairedContacts() } catch (_: Throwable) { false } - } } 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 79eb49d11..526784b4d 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 @@ -47,7 +47,6 @@ internal object UnifiedNativeApi { } } - @Keep @JvmStatic external fun recordPeerIdentity(address: String, identity: ByteArray) @Keep @JvmStatic external fun initSdk(baseDir: String): Boolean @Keep @JvmStatic external fun initSdkV3(baseDir: String): ByteArray @Keep @JvmStatic external fun initStorageBaseDir(path: ByteArray) @@ -72,7 +71,6 @@ internal object UnifiedNativeApi { @Keep @JvmStatic external fun isBleAddressPaired(address: String): Boolean @Keep @JvmStatic external fun isCommitEnvelope(envelope: ByteArray): Boolean @Keep @JvmStatic external fun notifyBleIdentityObserved(address: String, genesisHash: ByteArray, deviceId: ByteArray) - @Keep @JvmStatic external fun hasUnpairedContacts(): Boolean @Keep @JvmStatic external fun createTransactionErrorEnvelope(address: String, code: Int, message: String): ByteArray? @Keep @JvmStatic external fun removeContact(contactId: String): Byte @Keep @JvmStatic external fun isBleCoordinatorReady(): Boolean @@ -110,7 +108,6 @@ internal object UnifiedNativeApi { */ @Keep @JvmStatic external fun onAppBackgrounded(): Boolean @Keep @JvmStatic external fun getGenesisHashBin(): 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 @Keep @JvmStatic external fun isErrorEnvelope(envelopeBytes: ByteArray): Int @@ -127,9 +124,6 @@ internal object UnifiedNativeApi { // Kotlin MUST NOT concatenate raw bytes — this is the canonical encoder. @Keep @JvmStatic external fun encodeIdentityCharValue(genesisHash: ByteArray, deviceId: ByteArray): ByteArray - // Encode the local relationship send-status protobuf for a connected BLE peer. - @Keep @JvmStatic external fun getRelationshipStatusCharValue(bleAddress: String): ByteArray - // Process raw protobuf bytes read from GATT identity characteristic. // Decodes BleIdentityCharValue, dispatches identity events, returns BleGattIdentityReadResult. // Kotlin MUST NOT split or interpret the raw bytes. @@ -148,8 +142,6 @@ internal object UnifiedNativeApi { @Keep @JvmStatic external fun createNfcBackupWrittenEnvelope(): ByteArray // BLE pairing orchestration (Rust-driven loop) - @Keep @JvmStatic external fun startPairingAll() - @Keep @JvmStatic external fun stopPairingAll() // Session state — Rust owns session computation, Kotlin relays bytes to WebView @Keep @JvmStatic external fun getSessionSnapshot(): ByteArray diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleConstants.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleConstants.kt index 7bcadd27f..186e46736 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleConstants.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleConstants.kt @@ -15,7 +15,6 @@ object BleConstants { val TX_REQUEST_UUID: UUID = UUID.fromString("8e7f0002-7c07-4f3f-9b32-7bf3ba6c2a01") val TX_RESPONSE_UUID: UUID = UUID.fromString("8e7f0003-7c07-4f3f-9b32-7bf3ba6c2a01") val IDENTITY_UUID: UUID = UUID.fromString("8e7f00ff-7c07-4f3f-9b32-7bf3ba6c2a01") - val RELATIONSHIP_STATUS_UUID: UUID = UUID.fromString("8e7f00fc-7c07-4f3f-9b32-7bf3ba6c2a01") val PAIRING_UUID: UUID = UUID.fromString("8e7f00fe-7c07-4f3f-9b32-7bf3ba6c2a01") val PAIRING_ACK_UUID: UUID = UUID.fromString("8e7f00fd-7c07-4f3f-9b32-7bf3ba6c2a01") diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt index ec916778f..2ba08570e 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeoutOrNull /** * Public BLE Coordinator facade. @@ -129,7 +128,6 @@ class BleCoordinator private constructor(private val context: Context) : BleScan companion object { /** Max time to wait for GATT connection readiness (connect + discover + MTU). */ private const val CONNECT_READY_TIMEOUT_MS = 12_000L - private const val RELATIONSHIP_STATUS_READ_TIMEOUT_MS = 4_000L private const val MAX_PENDING_PAIRING_CONFIRMS = 8 private var instance: BleCoordinator? = null @@ -307,94 +305,6 @@ class BleCoordinator private constructor(private val context: Context) : BleScan fun isAdvertising(): Boolean = advertiser.isAdvertising() - /** - * Set the current session mode. - */ - fun setSessionMode(mode: BleSessionMode) { - runOperation(BleOpLane.LIFECYCLE) { - scanner.setSessionMode(mode) - // Update session mode logic here if needed for other components - } - } - - /** - * Read peer identity information. - */ - fun readPeerIdentity(deviceAddress: String): Boolean { - return runOperationBool(BleOpLane.PAIRING) { - val session = getOrCreateSession(deviceAddress) - session.readIdentity() - // For now, just start the operation - result will be handled asynchronously - true - } - } - - fun readPeerRelationshipStatus(deviceAddress: String): ByteArray? = runBlocking { - val connected = withTimeoutOrNull(CONNECT_READY_TIMEOUT_MS + 2_000L) { - connectToDevice(deviceAddress).await() - } ?: false - if (!connected) { - return@runBlocking null - } - - val deferred = CompletableDeferred() - val started = runOperationBool(BleOpLane.PAIRING) { - val resolved = resolveSession(deviceAddress) - val peer = resolved?.first ?: peers[deviceAddress] - val session = peer?.gattClientSession - if (peer == null || session == null || !peer.isConnected) { - deferred.complete(null) - return@runOperationBool false - } - peer.relationshipStatusReadResult?.cancel() - peer.relationshipStatusReadResult = deferred - if (!session.readRelationshipStatus()) { - peer.relationshipStatusReadResult = null - deferred.complete(null) - return@runOperationBool false - } - true - } - if (!started) { - return@runBlocking null - } - - withTimeoutOrNull(RELATIONSHIP_STATUS_READ_TIMEOUT_MS) { - deferred.await() - } - } - - /** - * Set local identity value for GATT server. - */ - fun setIdentityValue(genesisHash: ByteArray, deviceId: ByteArray) { - runOperation(BleOpLane.LIFECYCLE) { - gattServer.setIdentityValue(genesisHash, deviceId) - } - } - - /** - * Ensure BLE is ready to receive bilateral transfers: GATT server running - * and advertising active. Called by the frontend wallet screen lifecycle - * via the native host boundary, and also called internally by - * connectToDevice as a safety net. - */ - fun ensureBleReady(): Boolean { - val gattReady = runOperationBool(BleOpLane.LIFECYCLE) { - if (!gattServer.isReady()) { - gattServer.ensureStarted() - } - gattServer.isReady() - } - // Delegate to the public advertising entry point so that - // permissions and error handling are consistent. - val advertisingReady = startAdvertising() - if (!advertisingReady) { - Log.w("BleCoordinator", "ensureBleReady: startAdvertising returned false") - } - return gattReady && advertisingReady - } - /** * Ensure GATT server is started. */ @@ -662,7 +572,6 @@ class BleCoordinator private constructor(private val context: Context) : BleScan is BleSessionEvent.TransactionWriteCompleted, is BleSessionEvent.ResponseReceived -> BleOpLane.TRANSFER is BleSessionEvent.IdentityReadCompleted, - is BleSessionEvent.RelationshipStatusReadCompleted, is BleSessionEvent.MtuNegotiated, is BleSessionEvent.PairingAckReceived, is BleSessionEvent.PairingConfirmWritten -> BleOpLane.PAIRING @@ -909,10 +818,6 @@ class BleCoordinator private constructor(private val context: Context) : BleScan resumePairingScan(event.deviceAddress, "identity_read_failed") } } - is BleSessionEvent.RelationshipStatusReadCompleted -> { - peer.relationshipStatusReadResult?.complete(event.data) - peer.relationshipStatusReadResult = null - } is BleSessionEvent.TransactionWriteCompleted -> { val currentTx = peer.currentTransaction if (currentTx != null) { diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleScanner.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleScanner.kt index b117faa27..c07ae7550 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleScanner.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleScanner.kt @@ -28,7 +28,6 @@ class BleScanner(private val context: Context) { private val scanning = AtomicBoolean(false) private var bluetoothLeScanner: BluetoothLeScanner? = null - private var currentSessionMode: BleSessionMode = BleSessionMode.IDLE private var callback: Callback? = null fun setCallback(callback: Callback) { @@ -74,10 +73,6 @@ class BleScanner(private val context: Context) { } } - fun setSessionMode(mode: BleSessionMode) { - currentSessionMode = mode - } - /** * Start BLE scanning. * @@ -127,7 +122,7 @@ class BleScanner(private val context: Context) { bluetoothLeScanner?.startScan(filters, settings, scanCallback) scanning.set(true) val modeLabel = if (lowLatency) "LOW_LATENCY" else "BALANCED" - Log.i("BleScanner", "BLE scan started ($modeLabel), mode: $currentSessionMode") + Log.i("BleScanner", "BLE scan started ($modeLabel)") true } catch (t: Throwable) { Log.e("BleScanner", "Failed to start scan", t) diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionEvent.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionEvent.kt index ca1af2b4c..e81b4d06b 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionEvent.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionEvent.kt @@ -14,7 +14,6 @@ sealed class BleSessionEvent { data class MtuNegotiated(override val deviceAddress: String, val mtu: Int) : BleSessionEvent() data class ServiceDiscoveryCompleted(override val deviceAddress: String, val success: Boolean) : BleSessionEvent() data class IdentityReadCompleted(override val deviceAddress: String, val data: ByteArray?) : BleSessionEvent() - data class RelationshipStatusReadCompleted(override val deviceAddress: String, val data: ByteArray?) : BleSessionEvent() data class TransactionWriteCompleted(override val deviceAddress: String, val success: Boolean) : BleSessionEvent() data class ResponseReceived(override val deviceAddress: String, val data: ByteArray) : BleSessionEvent() /** Advertiser confirmed it processed our identity — bilateral pairing can complete. */ diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionMode.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionMode.kt deleted file mode 100644 index b8d0a1ae5..000000000 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionMode.kt +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package com.dsm.wallet.bridge.ble - -/** - * BLE session modes for coordinating scanning and advertising behavior. - */ -enum class BleSessionMode { - IDLE, - AWAITING_PEER_FOR_CONTACT, - AWAITING_PEER_FOR_TRANSFER -} \ No newline at end of file diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattClientSession.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattClientSession.kt index cf7c00602..4f88dae8d 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattClientSession.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattClientSession.kt @@ -67,7 +67,6 @@ class GattClientSession( private var requestCharacteristic: BluetoothGattCharacteristic? = null private var responseCharacteristic: BluetoothGattCharacteristic? = null private var identityCharacteristic: BluetoothGattCharacteristic? = null - private var relationshipStatusCharacteristic: BluetoothGattCharacteristic? = null private var pairingCharacteristic: BluetoothGattCharacteristic? = null private var pairingAckCharacteristic: BluetoothGattCharacteristic? = null @@ -317,7 +316,6 @@ class GattClientSession( requestCharacteristic = service.getCharacteristic(BleConstants.TX_REQUEST_UUID) responseCharacteristic = service.getCharacteristic(BleConstants.TX_RESPONSE_UUID) identityCharacteristic = service.getCharacteristic(BleConstants.IDENTITY_UUID) - relationshipStatusCharacteristic = service.getCharacteristic(BleConstants.RELATIONSHIP_STATUS_UUID) pairingCharacteristic = service.getCharacteristic(BleConstants.PAIRING_UUID) pairingAckCharacteristic = service.getCharacteristic(BleConstants.PAIRING_ACK_UUID) @@ -441,14 +439,6 @@ class GattClientSession( emitEvent(BleSessionEvent.ErrorOccurred(deviceAddress, BleErrorCategory.CHARACTERISTIC_READ_FAILED, "identity_read", status)) } } - BleConstants.RELATIONSHIP_STATUS_UUID -> { - if (status == BluetoothGatt.GATT_SUCCESS) { - emitEvent(BleSessionEvent.RelationshipStatusReadCompleted(deviceAddress, characteristic.value)) - } else { - emitEvent(BleSessionEvent.RelationshipStatusReadCompleted(deviceAddress, null)) - emitEvent(BleSessionEvent.ErrorOccurred(deviceAddress, BleErrorCategory.CHARACTERISTIC_READ_FAILED, "relationship_status_read", status)) - } - } } // GATT op completed — drain next queued op. drainNextGattOp() @@ -650,7 +640,6 @@ class GattClientSession( requestCharacteristic = null responseCharacteristic = null identityCharacteristic = null - relationshipStatusCharacteristic = null pairingCharacteristic = null pairingAckCharacteristic = null txResponseSubscribed = false @@ -842,34 +831,6 @@ class GattClientSession( } } - /** - * Initiate relationship-status read operation. - * Result is communicated via RelationshipStatusReadCompleted event. - */ - fun readRelationshipStatus(): Boolean { - val char = relationshipStatusCharacteristic - if (char == null) { - diagnostics.recordError(BleErrorCategory.CHARACTERISTIC_READ_FAILED, "relationship_status_no_char") - emitEvent(BleSessionEvent.ErrorOccurred(deviceAddress, BleErrorCategory.CHARACTERISTIC_READ_FAILED, "relationship_status_no_char")) - return false - } - - return enqueueGattOp { - try { - bluetoothGatt?.readCharacteristic(char) == true - } catch (e: SecurityException) { - Log.e("GattClientSession", "Security exception reading relationship-status characteristic for $deviceAddress", e) - BleCoordinator.getInstance(context).let { coordinator -> - coordinator.permissionsGate.recordPermissionFailure() - coordinator.callback?.onBlePermissionError("Bluetooth connection permission required") - } - diagnostics.recordError(BleErrorCategory.PERMISSION_DENIED, "relationship_status_read") - emitEvent(BleSessionEvent.ErrorOccurred(deviceAddress, BleErrorCategory.PERMISSION_DENIED, "relationship_status_read")) - false - } - } - } - /** * Send transaction data. * Result is communicated via TransactionWriteCompleted event. diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt index 89ea29eb7..ee4844c21 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt @@ -78,7 +78,6 @@ class GattServerHost(private val context: Context) { private val servicesReady = AtomicBoolean(false) private val serviceRegistrationInProgress = AtomicBoolean(false) @Volatile private var serviceReadyDeferred: CompletableDeferred? = null - @Volatile private var identityValue: ByteArray? = null // Write buffers for handling chunked writes private val pendingTxWriteBuffers = ConcurrentHashMap() @@ -128,7 +127,6 @@ class GattServerHost(private val context: Context) { val success = status == BluetoothGatt.GATT_SUCCESS if (success) { servicesReady.set(true) - updateIdentityCharacteristic() Log.i("GattServerHost", "GATT service registered via onServiceAdded callback") } else { servicesReady.set(false) @@ -159,9 +157,6 @@ class GattServerHost(private val context: Context) { BleConstants.IDENTITY_UUID -> { handleIdentityRead(device, requestId, offset) } - BleConstants.RELATIONSHIP_STATUS_UUID -> { - handleRelationshipStatusRead(device, requestId, offset) - } else -> { try { gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, 0, null) @@ -343,16 +338,6 @@ class GattServerHost(private val context: Context) { fun isReady(): Boolean = gattServer.get() != null && servicesReady.get() - /** Non-suspend version: triggers service setup if needed but does not await the callback. */ - fun ensureStartedNonBlocking() { - if (!BleCoordinator.getInstance(context).permissionsGate.hasConnectPermission()) return - if (gattServer.get() == null) openGattServer() - val server = gattServer.get() ?: return - if (!servicesReady.get() && !serviceRegistrationInProgress.get()) { - setupGattService(server) - } - } - fun stop() { try { gattServer.get()?.close() @@ -366,29 +351,6 @@ class GattServerHost(private val context: Context) { Log.i("GattServerHost", "GATT server stopped") } - fun getIdentityValue(): ByteArray? = identityValue?.clone() - - fun setIdentityValue(genesisHash: ByteArray, deviceId: ByteArray) { - if (genesisHash.size != 32 || deviceId.size != 32) { - Log.w("GattServerHost", "Invalid identity value lengths") - return - } - - // Encode identity as protobuf BleIdentityCharValue via Rust. - // Kotlin MUST NOT concatenate raw bytes — Rust is the canonical encoder. - val encoded = com.dsm.wallet.bridge.Unified.encodeIdentityCharValue(genesisHash, deviceId) - if (encoded.isEmpty()) { - Log.e("GattServerHost", "encodeIdentityCharValue returned empty — identity not set") - return - } - identityValue = encoded - Log.i("GattServerHost", "Identity value set (proto-encoded, ${identityValue?.size} bytes)") - - // Trigger GATT server setup if needed (non-blocking — doesn't await callback) - ensureStartedNonBlocking() - updateIdentityCharacteristic() - } - /** * Check if the given address is a device connected to our GATT server. * These are devices that initiated a GATT client connection to us (we are their server). @@ -684,13 +646,6 @@ class GattServerHost(private val context: Context) { ) service.addCharacteristic(identityChar) - val relationshipStatusChar = BluetoothGattCharacteristic( - BleConstants.RELATIONSHIP_STATUS_UUID, - BluetoothGattCharacteristic.PROPERTY_READ, - BluetoothGattCharacteristic.PERMISSION_READ - ) - service.addCharacteristic(relationshipStatusChar) - // TX Request characteristic (write-only) val txRequestChar = BluetoothGattCharacteristic( BleConstants.TX_REQUEST_UUID, @@ -750,24 +705,12 @@ class GattServerHost(private val context: Context) { } } - private fun updateIdentityCharacteristic() { - val server = gattServer.get() ?: return - val service = server.getService(BleConstants.DSM_SERVICE_UUID_V2) ?: return - val identityChar = service.getCharacteristic(BleConstants.IDENTITY_UUID) ?: return - - identityValue?.let { value -> - @Suppress("DEPRECATION") - identityChar.setValue(value) - Log.d("GattServerHost", "Identity characteristic updated") - } - } - private fun handleIdentityRead(device: BluetoothDevice, requestId: Int, offset: Int) { - val value = identityValue - // Null identity (identity not yet published) and out-of-range offset are distinct - // error conditions requiring different GATT status codes so the client can distinguish them. + val value = localIdentityCharValue() + // No identity (pre-genesis) and an out-of-range offset are distinct error + // conditions with different GATT status codes, so the client can tell them apart. if (value == null) { - Log.w("GattServerHost", "Identity read for ${device.address}: identity not yet set") + Log.w("GattServerHost", "Identity read for ${device.address}: no local identity") try { gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, 0, null) } catch (e: SecurityException) { @@ -813,54 +756,26 @@ class GattServerHost(private val context: Context) { } } - private fun handleRelationshipStatusRead(device: BluetoothDevice, requestId: Int, offset: Int) { - val value = try { - com.dsm.wallet.bridge.Unified.getRelationshipStatusCharValue(device.address) - } catch (t: Throwable) { - Log.w("GattServerHost", "Relationship-status read failed for ${device.address}", t) - ByteArray(0) - } - - if (value.isEmpty()) { - try { - gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, 0, null) - } catch (e: SecurityException) { - Log.e("GattServerHost", "Security exception sending response to ${device.address}", e) - BleCoordinator.getInstance(context).let { coordinator -> - coordinator.permissionsGate.recordPermissionFailure() - coordinator.callback?.onBlePermissionError("Bluetooth connection permission required") - } - } - return - } - if (offset >= value.size) { - try { - gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_INVALID_OFFSET, 0, null) - } catch (e: SecurityException) { - Log.e("GattServerHost", "Security exception sending response to ${device.address}", e) - BleCoordinator.getInstance(context).let { coordinator -> - coordinator.permissionsGate.recordPermissionFailure() - coordinator.callback?.onBlePermissionError("Bluetooth connection permission required") - } - } - return - } - - val chunk = if (offset + BleConstants.MTU_SIZE > value.size) { - value.copyOfRange(offset, value.size) + /** + * The identity a peer reads, from Rust at the moment it asks: the appliance's + * own genesis and device id, encoded by Rust's canonical encoder. Nothing + * pushes it here — the frontend used to hand these bytes to the BLE layer, + * and a value set by a caller can be stale, or not the appliance's at all. + * Encoding is deterministic, so a long read's chunks agree. + */ + private fun localIdentityCharValue(): ByteArray? = try { + val genesisHash = com.dsm.wallet.bridge.Unified.getGenesisHashBin() + val deviceId = com.dsm.wallet.bridge.Unified.getDeviceIdBin() + if (genesisHash.size == 32 && deviceId.size == 32) { + // Kotlin MUST NOT concatenate raw bytes — Rust is the canonical encoder. + com.dsm.wallet.bridge.Unified.encodeIdentityCharValue(genesisHash, deviceId) + .takeIf { it.isNotEmpty() } } else { - value.copyOfRange(offset, offset + BleConstants.MTU_SIZE) - } - - try { - gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, chunk) - } catch (e: SecurityException) { - Log.e("GattServerHost", "Security exception sending response to ${device.address}", e) - BleCoordinator.getInstance(context).let { coordinator -> - coordinator.permissionsGate.recordPermissionFailure() - coordinator.callback?.onBlePermissionError("Bluetooth connection permission required") - } + null } + } catch (t: Throwable) { + Log.w("GattServerHost", "Local identity read failed", t) + null } private fun handleTxWrite( diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/PeerSession.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/PeerSession.kt index 10d9e4b84..3bed6180b 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/PeerSession.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/PeerSession.kt @@ -51,7 +51,6 @@ data class PeerSession( var currentTransaction: BleOutboxItem? = null, var identityExchangeInProgress: Boolean = false, var pairingInProgress: Boolean = false, - @Transient var relationshipStatusReadResult: CompletableDeferred? = null, // ── Connection lifecycle (was pendingConnectionAddresses + polling loop) ─ // When non-null, a connect is in flight. Completed by handleSessionEvent @@ -108,8 +107,6 @@ data class PeerSession( currentTransaction = null identityExchangeInProgress = false pairingInProgress = false - relationshipStatusReadResult?.cancel() - relationshipStatusReadResult = null connectResult?.complete(false) connectResult = null pendingPairingConfirm = null diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/debug/PairingTestActivity.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/debug/PairingTestActivity.kt deleted file mode 100644 index 6e991d7c1..000000000 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/debug/PairingTestActivity.kt +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package com.dsm.wallet.debug - -import android.annotation.SuppressLint -import android.os.Bundle -import android.util.Log -import android.widget.Button -import androidx.appcompat.app.AppCompatActivity -import com.dsm.wallet.bridge.ble.BleCoordinator - -class PairingTestActivity : AppCompatActivity() { - private val tag = "PairingTestActivity" - @SuppressLint("SetTextI18n") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - // Simple programmatic UI to avoid XML file changes - val startAdvertBtn = Button(this).apply { text = "Start Advertise For Pairing" } - val startScanBtn = Button(this).apply { text = "Start Scan For Pairing" } - val stopAdvertBtn = Button(this).apply { text = "Stop Advertising" } - val stopScanBtn = Button(this).apply { text = "Stop Scanning" } - - val layout = androidx.appcompat.widget.LinearLayoutCompat(this).apply { - orientation = androidx.appcompat.widget.LinearLayoutCompat.VERTICAL - addView(startAdvertBtn) - addView(startScanBtn) - addView(stopAdvertBtn) - addView(stopScanBtn) - } - setContentView(layout) - - val bleService = BleCoordinator.getInstance(applicationContext) - - startAdvertBtn.setOnClickListener { - Log.i(tag, "Requesting startAdvertising()") - val ok = try { bleService.startAdvertising() } catch (t: Throwable) { Log.e(tag, "startAdvertising threw", t); false } - Log.i(tag, "startAdvertising returned: $ok") - } - - startScanBtn.setOnClickListener { - Log.i(tag, "Requesting startScanning()") - val ok = try { bleService.startScanning() } catch (t: Throwable) { Log.e(tag, "startScanning threw", t); false } - Log.i(tag, "startScanning returned: $ok") - } - - stopAdvertBtn.setOnClickListener { - Log.i(tag, "Requesting stopAdvertising()") - val ok = try { bleService.stopAdvertising() } catch (t: Throwable) { Log.e(tag, "stopAdvertising threw", t); false } - Log.i(tag, "stopAdvertising returned: $ok") - } - - stopScanBtn.setOnClickListener { - Log.i(tag, "Requesting stopScanning()") - val ok = try { bleService.stopScanning() } catch (t: Throwable) { Log.e(tag, "stopScanning threw", t); false } - Log.i(tag, "stopScanning returned: $ok") - } - } -} diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/service/BleBackgroundService.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/service/BleBackgroundService.kt index 6ffa9abb9..cb7965041 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/service/BleBackgroundService.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/service/BleBackgroundService.kt @@ -7,8 +7,11 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.bluetooth.BluetoothAdapter +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.ServiceInfo import android.os.Build import android.os.Binder @@ -16,20 +19,29 @@ import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import com.dsm.wallet.R +import com.dsm.wallet.bridge.Unified import com.dsm.wallet.bridge.ble.BleCoordinator import com.dsm.wallet.ui.MainActivity +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors /** * Foreground service that keeps BLE advertising and GATT server running * in the background for offline bilateral transfers. - * + * * This service ensures that: * 1. BLE advertising remains active so peers can discover this device * 2. GATT server stays registered to receive incoming connections * 3. Persistent connections to paired devices are maintained - * + * * Without this, offline transfers would fail when the app is backgrounded * because Android kills BLE advertising/GATT when apps lose foreground status. + * + * It is the one owner of advertising. Advertising follows the identity: a + * appliance Rust knows advertises, so a peer can find it for a transfer or a + * pairing; an appliance without one does not. The screen the user is on has no + * say — the frontend used to start advertising on the wallet screen and stop + * it on leaving, and on a cold start nothing else ever started it. */ class BleBackgroundService : Service() { @@ -60,10 +72,22 @@ class BleBackgroundService : Service() { } private var bleCoordinator: BleCoordinator? = null - private var isAdvertising = false - private var advertisingDesired = false private val binder = LocalBinder() + // The coordinator's lifecycle operations block their caller (up to 15 s); + // they run here, one at a time and in order, never on the main thread. + private val lifecycle: ExecutorService = + Executors.newSingleThreadExecutor { r -> Thread(r, "ble-advertising") } + + // Bluetooth turned back on: advertising is due again if the identity is. + private val adapterStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + if (state == BluetoothAdapter.STATE_ON) refreshAdvertising() + } + } + inner class LocalBinder : Binder() { fun getService(): BleBackgroundService = this@BleBackgroundService } @@ -89,23 +113,35 @@ class BleBackgroundService : Service() { // Initialize BLE coordinator bleCoordinator = BleCoordinator.getInstance(applicationContext) + + val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(adapterStateReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(adapterStateReceiver, filter) + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.i(TAG, "BLE background service started (idle until explicitly requested)") - // BLE advertising is NOT started here. It only starts when the UI explicitly - // requests host-control advertising through the native host boundary. + Log.i(TAG, "BLE background service started") + refreshAdvertising() return START_STICKY } override fun onDestroy() { Log.i(TAG, "BLE background service destroyed") - advertisingDesired = false - applyAdvertisingState() - - // Cleanup timeout jobs to prevent resource leaks - bleCoordinator?.cleanup() - + try { + unregisterReceiver(adapterStateReceiver) + } catch (_: IllegalArgumentException) { + // Not registered. + } + val ble = bleCoordinator + lifecycle.execute { + ble?.stopAdvertising() + // Cleanup timeout jobs to prevent resource leaks + ble?.cleanup() + } + lifecycle.shutdown() super.onDestroy() } @@ -113,88 +149,69 @@ class BleBackgroundService : Service() { return binder } - @Synchronized - fun setAdvertisingDesired(desired: Boolean) { - advertisingDesired = desired - applyAdvertisingState() - } - - fun ensureGattServerStarted(): Boolean { - return bleCoordinator?.ensureGattServerStarted() ?: false - } - - fun closeStaleGattSessions() { - bleCoordinator?.closeStaleGattSessions() - } - - fun startScanning(): Boolean { - return bleCoordinator?.startScanning() ?: false - } - - fun stopScanning(): Boolean { - return bleCoordinator?.stopScanning() ?: false + /** + * Bring advertising in line with the identity, from Rust: the GATT server + * up and advertising on when there is one, advertising off when there is + * not. Called on every event that can change the answer — the service + * starting, the activity resuming or binding, native init finding the + * identity, a Bluetooth permission granted, the adapter turned on. + * Idempotent: advertising already on stays on. + */ + fun refreshAdvertising() { + val ble = bleCoordinator ?: return + onLifecycleThread("refreshAdvertising") { + if (!localIdentityAvailable()) { + if (ble.isAdvertising()) ble.stopAdvertising() + Log.i(TAG, "refreshAdvertising: no local identity; not advertising") + return@onLifecycleThread + } + val gattOk = ble.ensureGattServerStarted() + val advertising = gattOk && ble.startAdvertising() + Log.i(TAG, "refreshAdvertising: GATT=$gattOk advertising=$advertising") + } } - fun isScanningActive(): Boolean { - return try { - bleCoordinator?.isScanning() ?: false - } catch (_: Throwable) { - false - } + /** What the radio is doing, for the session snapshot Rust publishes. */ + fun isScanningActive(): Boolean = try { + bleCoordinator?.isScanning() ?: false + } catch (_: Throwable) { + false } - fun isAdvertisingActive(): Boolean { - return try { - bleCoordinator?.isAdvertising() ?: isAdvertising - } catch (_: Throwable) { - isAdvertising - } + fun isAdvertisingActive(): Boolean = try { + bleCoordinator?.isAdvertising() ?: false + } catch (_: Throwable) { + false } - fun setIdentityValue(genesisHash: ByteArray, deviceId: ByteArray) { - bleCoordinator?.setIdentityValue(genesisHash, deviceId) + /** Stale GATT sessions from before a pause, closed off the main thread. */ + fun closeStaleGattSessions() { + val ble = bleCoordinator ?: return + onLifecycleThread("closeStaleGattSessions") { ble.closeStaleGattSessions() } } - @Synchronized - private fun applyAdvertisingState() { - bleCoordinator?.let { ble -> - if (advertisingDesired && !isAdvertising) { - var hasIdentity = false + // A caller holding this service after it was destroyed gets a log line, + // not a RejectedExecutionException on its own (main) thread. + private fun onLifecycleThread(what: String, block: () -> Unit) { + try { + lifecycle.execute { try { - val deviceIdBytes = try { com.dsm.wallet.bridge.Unified.getDeviceIdBin() } catch (_: Throwable) { byteArrayOf() } - val genesisHashBytes = try { com.dsm.wallet.bridge.Unified.getGenesisHashBin() } catch (_: Throwable) { byteArrayOf() } - if (deviceIdBytes.size == 32 && genesisHashBytes.size == 32) { - ble.setIdentityValue(genesisHashBytes, deviceIdBytes) - hasIdentity = true - Log.i(TAG, "applyAdvertisingState: local BLE identity published before advertise") - } else { - Log.w(TAG, "applyAdvertisingState: local identity unavailable before advertise (genesis=${genesisHashBytes.size}, device=${deviceIdBytes.size})") - } + block() } catch (t: Throwable) { - Log.w(TAG, "applyAdvertisingState: failed to publish local identity before advertise", t) - } - if (!hasIdentity) { - Log.w(TAG, "BLE advertising requested but local identity is unavailable") - return - } - val gattOk = ble.ensureGattServerStarted() - if (gattOk) { - ble.startAdvertising() - isAdvertising = true - Log.i(TAG, "BLE advertising started in background") - } else { - Log.w(TAG, "BLE advertising requested but GATT not ready") + Log.w(TAG, "$what failed", t) } - } else if (!advertisingDesired && isAdvertising) { - ble.stopAdvertising() - isAdvertising = false - Log.i(TAG, "BLE advertising stopped") - } else { - // No-op: advertising state already matches desired state. } + } catch (_: java.util.concurrent.RejectedExecutionException) { + Log.w(TAG, "$what: the service is destroyed") } } + private fun localIdentityAvailable(): Boolean = try { + Unified.getDeviceIdBin().size == 32 && Unified.getGenesisHashBin().size == 32 + } catch (_: Throwable) { + false + } + private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( 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 9cc737f56..a8febd909 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 @@ -149,6 +149,8 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { bleBackgroundService = binder?.getService() bleServiceBound = bleBackgroundService != null Log.i(tag, "BLE service bound: $bleServiceBound") + // A resume or init that ran before the bind had no service to ask. + bleBackgroundService?.refreshAdvertising() } override fun onServiceDisconnected(name: ComponentName?) { @@ -797,8 +799,28 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { } } - fun setBleAdvertisingDesired(desired: Boolean) { - bleBackgroundService?.setAdvertisingDesired(desired) + /** + * The appliance has an identity: the BLE foreground service runs (it survives + * activity lifecycle transitions) and brings the GATT server and advertising + * up on its own thread; a service already running is asked again, since the + * identity may only now exist. Called on the UI thread (context + * requirement) at init when the identity is read and when genesis creates it. + */ + fun startBleForIdentity() { + // Rust starts pairing on the next session facts, and pairing drives the + // radio through the coordinator: it exists before those facts go out. + try { + BleCoordinator.getInstance(applicationContext) + } catch (t: Throwable) { + Log.w(tag, "startBleForIdentity: BLE coordinator init failed", t) + } + try { + BleBackgroundService.start(this) + Log.i(tag, "startBleForIdentity: BLE foreground service started") + } catch (t: Throwable) { + Log.w(tag, "startBleForIdentity: BLE foreground service start failed", t) + } + bleBackgroundService?.refreshAdvertising() } private fun setSessionFatalError(message: String?) { @@ -821,7 +843,6 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { } } - private fun bleCoordinator(): BleCoordinator = BleCoordinator.getInstance(applicationContext) // The WebView external-host allowlist lives at file scope below so that @@ -1341,24 +1362,16 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { if (hasIdentityViaRust()) { invokeNativeRouterInvoke("inbox.resume") } - // Only restart BLE after genesis — during genesis the device is busy and - // BLE scanning/advertising wastes resources and causes errors. + // Stale GATT sessions from before the pause are closed (a peer's RPA + // may have rotated); advertising follows the identity. Pre-genesis + // there is no identity and nothing to restart. An unbound service + // refreshes when it binds. if (hasIdentityViaRust()) { - try { - val svc = bleBackgroundService - if (svc != null) { - svc.closeStaleGattSessions() - val gattOk = svc.ensureGattServerStarted() - svc.setAdvertisingDesired(true) - Log.i(tag, "onResume: BLE restart — stale sessions closed, GATT=$gattOk advertising=desired") - } else { - Log.w(tag, "onResume: BLE service not bound yet, GATT restart deferred") - } - } catch (t: Throwable) { - Log.w(tag, "onResume: BLE restart failed: ${t.message}") + val svc = bleBackgroundService + if (svc != null) { + svc.closeStaleGattSessions() + svc.refreshAdvertising() } - } else { - Log.d(tag, "onResume: skipping BLE restart (no identity yet, pre-genesis)") } // Suppress Android's system NFC popup while the app is in foreground. @@ -1536,19 +1549,8 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { if (blePermsGranted) { Log.i(tag, "BLE permissions granted") - // Start the background service so it's bound and ready, but only - // initialize GATT/advertising after genesis (hasIdentityViaRust). - try { - val svc = bleBackgroundService - if (hasIdentityViaRust()) { - val gattResult = svc?.ensureGattServerStarted() ?: false - Log.i(tag, "Bluetooth permissions granted: GATT server ensure-start result=$gattResult") - } else { - Log.d(tag, "Bluetooth permissions granted: deferring GATT start until after genesis") - } - } catch (t: Throwable) { - Log.e(tag, "Failed to reinitialize BLE after permissions granted", t) - } + // The radio can now do what the identity asks of it. + bleBackgroundService?.refreshAdvertising() } else { Log.w(tag, "BLE permissions not granted: $grants") } @@ -1813,32 +1815,9 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { } if (capturedDeviceId.size == 32 && capturedGenesis.size == 32) { - // Start BLE as a foreground service so it survives activity - // lifecycle transitions. Must happen on the UI thread (context - // requirement) BEFORE the background GATT init thread. - try { - BleBackgroundService.start(this@MainActivity) - Log.i(tag, "initDsmAndSignalReady: BLE foreground service started") - } catch (t: Throwable) { - Log.w(tag, "initDsmAndSignalReady: BLE foreground service start failed", t) - } - - // GATT server init + identity write are synchronous Bluetooth - // framework calls (100-500ms). Run on a background thread to - // avoid blocking the UI thread on slower chipsets (MediaTek). - Thread { - try { - val coordinator = bleCoordinator() - val gattReady = coordinator.ensureGattServerStarted() - Log.i(tag, "initDsmAndSignalReady: GATT server ensure-started: $gattReady") - coordinator.setIdentityValue(capturedGenesis, capturedDeviceId) - Log.i(tag, "initDsmAndSignalReady: BLE identity set (genesis + deviceId)") - } catch (t: Throwable) { - Log.w(tag, "initDsmAndSignalReady: GATT/identity setup failed", t) - } - }.start() + startBleForIdentity() } else { - Log.i(tag, "initDsmAndSignalReady: BLE identity not yet present in persisted bytes; skipping setIdentityValue") + Log.i(tag, "initDsmAndSignalReady: no identity yet; BLE stays down until genesis") } publishSessionState("initComplete") } catch (t: Throwable) { @@ -1872,19 +1851,13 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { val allGranted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } if (!allGranted) { Log.w(tag, "Bluetooth permissions not granted") - dispatchNativeHostEventOnUi( - NativeHostEventKind.NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS, - byteArrayOf(0x00), - ) } else { - Log.i(tag, "Bluetooth permissions granted, notifying WebView") - // BLE ops are NOT auto-started here. The UI must explicitly request - // scanning/advertising via the native host boundary. - dispatchNativeHostEventOnUi( - NativeHostEventKind.NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS, - byteArrayOf(0x01), - ) + // The radio can now do what the identity asks of it. The UI is + // not asked to start anything: advertising is native policy. + Log.i(tag, "Bluetooth permissions granted") + bleBackgroundService?.refreshAdvertising() } + // The permission facts reach the UI in the session snapshot. publishSessionState("runtimePermissions") } } 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 8c6d93bbf..52c836d62 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 @@ -33,7 +33,6 @@ class SinglePathWebViewBridgeFuzzTest { "setPreference", "nativeBoundaryIngress", "nativeHostRequest", - "resolveBleAddressForDeviceId", "initiateBleContactPairing", "getTransportHeadersV3Bin", "acceptBilateralByCommitment", @@ -152,7 +151,7 @@ class SinglePathWebViewBridgeFuzzTest { payloads.add(byteArrayOf(0x00, 0x00, 0x00, 0x05, 0x41, 0x42, 0x43)) // methodLen=5 but truncated payloads.add(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())) // huge method length - // For resolveBleAddressForDeviceId: wrong sizes + // Ids of the wrong size payloads.add(ByteArray(31)) // 31 bytes instead of 32 payloads.add(ByteArray(33)) // 33 bytes instead of 32 payloads.add(ByteArray(32) { 0x00 }) // 32 zero bytes diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_ble_handler.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_ble_handler.rs index 5af571b1a..cbabc136f 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_ble_handler.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_ble_handler.rs @@ -16,9 +16,6 @@ use log::{debug, info, warn, error}; use prost::Message; use tokio::sync::RwLock; -#[cfg(all(target_os = "android", feature = "jni"))] -use crate::jni::state::DEVICE_ID_TO_ADDR; - // Re-export types from bilateral_session so existing import paths still work. pub use super::bilateral_session::{ BilateralBleSession, BilateralEventCallback, BilateralPhase, BilateralSettlementDelegate, @@ -842,7 +839,6 @@ impl BilateralBleHandler { } drop(door); - // Build prepare request with BLE address lookup let expected_counterparty_state_hash = { let m = self.bilateral_tx_manager.read().await; m.get_chain_tip_for(&counterparty_device_id) @@ -853,52 +849,6 @@ impl BilateralBleHandler { })? }; - // Look up BLE address from contact or in-memory map - let ble_address = { - let m = self.bilateral_tx_manager.read().await; - if let Some(contact) = m.get_contact(&counterparty_device_id) { - if let Some(addr) = &contact.ble_address { - addr.clone() - } else { - // Contact exists but no BLE address persisted - // Check in-memory map and persist if found - #[cfg(all(target_os = "android", feature = "jni"))] - { - if let Ok(map) = DEVICE_ID_TO_ADDR.try_lock() { - if let Some(addr) = map.get(&counterparty_device_id) { - // Persist it to the contact (transport state only). - if let Err(e) = crate::storage::client_db::update_contact_ble_status( - &counterparty_device_id, - None, - Some(addr), - ) { - warn!( - "[BLE_HANDLER] BLE address {} not persisted for the contact: {}", - addr, e - ); - } - addr.clone() - } else { - warn!("[BLE_HANDLER] No BLE address found for counterparty device (contact exists but no address persisted or in map)"); - String::new() - } - } else { - warn!("[BLE_HANDLER] DEVICE_ID_TO_ADDR lock contended, no BLE address found for counterparty device"); - String::new() - } - } - #[cfg(not(all(target_os = "android", feature = "jni")))] - { - warn!("[BLE_HANDLER] No BLE address found for counterparty device (contact exists but no address persisted)"); - String::new() - } - } - } else { - warn!("[BLE_HANDLER] No contact found for counterparty device"); - String::new() - } - }; - // Get sender's signing public key for inclusion in prepare request let sender_signing_public_key = { let m = self.bilateral_tx_manager.read().await; @@ -914,20 +864,12 @@ impl BilateralBleHandler { expected_counterparty_state_hash: Some(generated::Hash32 { v: expected_counterparty_state_hash.to_vec(), }), - ble_address, // Include sender identity for relationship establishment sender_signing_public_key, sender_device_id: self.device_id.to_vec(), sender_genesis_hash: Some(generated::Hash32 { v: local_genesis_hash.to_vec(), }), - // transfer_amount and token_id_hint are UI-only hints; protocol - // correctness is carried entirely by operation_data. The transport - // layer does not extract token-specific fields from the Operation. - transfer_amount: 0, - token_id_hint: String::new(), - memo_hint: String::new(), - transfer_amount_display: String::new(), sender_kyber_public_key, sender_kyber_binding_sig, // σ_A over the commitment: the receiver puts to its user only a @@ -4496,14 +4438,9 @@ mod tests { operation_data: online_tier_transfer(device_id).to_bytes(), expected_genesis_hash: None, expected_counterparty_state_hash: None, - ble_address: String::new(), sender_signing_public_key: vec![0; 64], sender_device_id: sender.to_vec(), sender_genesis_hash: None, - transfer_amount: 0, - token_id_hint: String::new(), - memo_hint: String::new(), - transfer_amount_display: String::new(), sender_kyber_public_key: vec![], sender_kyber_binding_sig: vec![], sender_signature: vec![], @@ -4608,16 +4545,11 @@ mod tests { expected_counterparty_state_hash: Some(generated::Hash32 { v: cached_tip.to_vec(), }), - ble_address: String::new(), sender_signing_public_key: sender_keys.public_key().to_vec(), sender_device_id: sender.to_vec(), sender_genesis_hash: Some(generated::Hash32 { v: sender_genesis.to_vec(), }), - transfer_amount: 0, - token_id_hint: String::new(), - memo_hint: String::new(), - transfer_amount_display: String::new(), sender_kyber_public_key: kyber_pk, sender_kyber_binding_sig: binding_sig, sender_signature: sender_keys diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_envelope.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_envelope.rs index 6b64160da..7d0899e67 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_envelope.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_envelope.rs @@ -247,14 +247,9 @@ mod tests { operation_data: vec![2; 16], expected_genesis_hash: None, expected_counterparty_state_hash: None, - ble_address: String::new(), sender_signing_public_key: vec![0; 64], sender_device_id: vec![0; 32], sender_genesis_hash: None, - transfer_amount: 0, - token_id_hint: String::new(), - memo_hint: String::new(), - transfer_amount_display: String::new(), sender_signature: vec![], sender_kyber_public_key: vec![], sender_kyber_binding_sig: vec![], @@ -305,14 +300,9 @@ mod tests { operation_data: vec![2; 16], expected_genesis_hash: None, expected_counterparty_state_hash: None, - ble_address: String::new(), sender_signing_public_key: vec![0; 64], sender_device_id: vec![0; 32], sender_genesis_hash: None, - transfer_amount: 0, - token_id_hint: String::new(), - memo_hint: String::new(), - transfer_amount_display: String::new(), sender_signature: vec![], sender_kyber_public_key: vec![], sender_kyber_binding_sig: vec![], diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/mod.rs index e9a2e98aa..00e7deebc 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/mod.rs @@ -17,6 +17,7 @@ pub mod frame_classify; #[cfg(test)] mod offline_step_tests; pub mod pairing_orchestrator; +pub mod peer_address; // Re-export bilateral transaction components pub use bilateral_ble_handler::{ @@ -39,6 +40,44 @@ use dsm::types::error::DsmError; use std::sync::Arc; use std::sync::RwLock; +/// Pairing follows the session (`SessionManager::pairing_may_run`): the loop +/// runs while the app is in the foreground with Bluetooth on and permitted and +/// an identity to pair as, and ends by itself once no contact is unpaired; it +/// stops when the session says it may not run. Host builds have no BLE to pair +/// over. +pub fn pairing_follows(may_run: bool) { + #[cfg(all(target_os = "android", feature = "jni"))] + { + let orchestrator = get_pairing_orchestrator(); + if may_run { + if !orchestrator.is_loop_running() { + crate::runtime::get_runtime().spawn(async move { + orchestrator.start_pairing_all_unpaired().await; + }); + } + } else if orchestrator.is_loop_running() { + orchestrator.stop_pairing_loop(); + } + } + #[cfg(not(all(target_os = "android", feature = "jni")))] + let _ = may_run; +} + +/// A contact was added: it is paired now if the session lets pairing run. A +/// running loop is woken to take it up; otherwise one is started. +pub fn contact_added() { + #[cfg(all(target_os = "android", feature = "jni"))] + { + if !crate::sdk::session_manager::pairing_may_run_now() { + return; + } + let orchestrator = get_pairing_orchestrator(); + crate::runtime::get_runtime().spawn(async move { + orchestrator.start_pairing_all_unpaired().await; + }); + } +} + /// Global pairing orchestrator static PAIRING_ORCHESTRATOR: RwLock>> = RwLock::new(None); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/pairing_orchestrator.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/pairing_orchestrator.rs index f32686066..496db4a76 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/pairing_orchestrator.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/pairing_orchestrator.rs @@ -316,6 +316,13 @@ impl PairingOrchestrator { "[PairingOrchestrator] Genesis hash mismatch for {:02x}{:02x}... (identity_observed)", peer_device_id[0], peer_device_id[1] ); + self.emit_pairing_status( + &peer_device_id, + "failed", + "Genesis hash mismatch", + Some(&ble_address), + ) + .await; return Err("Genesis hash mismatch".to_string()); } } else { @@ -371,6 +378,8 @@ impl PairingOrchestrator { drop(sessions); self.signal_state_change(); + self.emit_pairing_status(&peer_device_id, "connected", "", Some(&ble_address)) + .await; Ok(()) } @@ -458,28 +467,46 @@ impl PairingOrchestrator { return Err(format!("Unexpected state for confirm: {:?}", session.state)); } - let ble_address = session.ble_address.clone(); let chain_tip = session.peer_chain_tip.clone(); // NOW persist ble_address to SQLite — the scanner has confirmed receipt. - match crate::storage::client_db::update_contact_ble_status( - &peer_device_id, - chain_tip.as_deref(), - ble_address.as_deref(), - ) { - Ok(()) => { - log::info!( - "[PairingOrchestrator] Contact BLE status persisted on confirm for {:02x}{:02x}...", - peer_device_id[0], peer_device_id[1] - ); - } - Err(e) => { + // The session completes only once the address is stored: the stored + // address is what makes the contact paired, and a complete session is + // not retried. + let stored = match session.ble_address.clone() { + Some(address) => crate::storage::client_db::update_contact_ble_status( + &peer_device_id, + chain_tip.as_deref(), + Some(&address), + ) + .map(|()| address) + .map_err(|e| format!("the paired address was not stored: {e}")), + None => Err("no address was seen for the peer".to_string()), + }; + let ble_address = match stored { + Ok(address) => address, + Err(reason) => { + session.state = PairingState::Failed(reason.clone()); + session.last_activity = Instant::now(); + drop(sessions); + self.signal_state_change(); log::warn!( - "[PairingOrchestrator] update_contact_ble_status failed on confirm for {:02x}{:02x}...: {}", - peer_device_id[0], peer_device_id[1], e + "[PairingOrchestrator] confirm for {:02x}{:02x}... does not complete: {}", + peer_device_id[0], + peer_device_id[1], + reason ); + self.emit_pairing_status(&peer_device_id, "failed", &reason, None) + .await; + return Err(reason); } - } + }; + log::info!( + "[PairingOrchestrator] Contact BLE status persisted on confirm for {:02x}{:02x}... ({})", + peer_device_id[0], + peer_device_id[1], + ble_address + ); session.state = PairingState::Complete; session.last_activity = Instant::now(); @@ -614,7 +641,7 @@ impl PairingOrchestrator { ) -> Result<(), String> { // Locate the peer_device_id for the ConfirmSent session, then drop the lock // before calling notify_pairing_complete to avoid a potential deadlock. - let (peer_device_id, chain_tip) = { + let (peer_device_id, outcome) = { let mut sessions = self.sessions.write().await; let (&peer_device_id, session) = sessions @@ -630,47 +657,47 @@ impl PairingOrchestrator { ) })?; - let chain_tip = session.peer_chain_tip.clone(); - // Persist ble_address — BlePairingConfirm was delivered to the advertiser. - match crate::storage::client_db::update_contact_ble_status( + // The session completes only once the address is stored: the stored + // address is what makes the contact paired, and a complete session is + // not retried. + let outcome = crate::storage::client_db::update_contact_ble_status( &peer_device_id, - chain_tip.as_deref(), + session.peer_chain_tip.as_deref(), Some(ble_address), - ) { + ) + .map_err(|e| format!("the paired address was not stored: {e}")); + match &outcome { Ok(()) => { + session.state = PairingState::Complete; log::info!( - "[PairingOrchestrator] ble_address persisted on scanner finalize for {:02x}{:02x}...", + "[PairingOrchestrator] Pairing complete (scanner, finalized) for {:02x}{:02x}...", peer_device_id[0], peer_device_id[1] ); } - Err(e) => { + Err(reason) => { + session.state = PairingState::Failed(reason.clone()); log::warn!( - "[PairingOrchestrator] update_contact_ble_status failed on scanner finalize for {:02x}{:02x}...: {}", + "[PairingOrchestrator] scanner finalize for {:02x}{:02x}... does not complete: {}", peer_device_id[0], peer_device_id[1], - e + reason ); } } - - session.state = PairingState::Complete; session.last_activity = Instant::now(); - log::info!( - "[PairingOrchestrator] Pairing complete (scanner, finalized) for {:02x}{:02x}...", - peer_device_id[0], - peer_device_id[1] - ); - - (peer_device_id, chain_tip) + (peer_device_id, outcome) }; // sessions write-lock released here self.signal_state_change(); - let _ = chain_tip; // suppress unused warning if notify path not compiled in - let _ = peer_device_id; // suppress unused warning on non-android/non-jni targets + if let Err(reason) = outcome { + self.emit_pairing_status(&peer_device_id, "failed", &reason, Some(ble_address)) + .await; + return Err(reason); + } // Emit frontend notification; best-effort #[cfg(all(target_os = "android", feature = "jni"))] @@ -699,7 +726,7 @@ impl PairingOrchestrator { /// does not need to be re-paired just because the transport layer disconnected. pub async fn handle_peer_disconnected(&self, ble_address: &str) { let mut sessions = self.sessions.write().await; - let mut reset_count = 0usize; + let mut reset = Vec::new(); for session in sessions.values_mut() { if session.ble_address.as_deref() == Some(ble_address) { match &session.state { @@ -712,7 +739,6 @@ impl PairingOrchestrator { _ => { let old_state = format!("{:?}", session.state); session.state = PairingState::Failed("BLE link dropped".to_string()); - session.state = PairingState::Failed("BLE link dropped".to_string()); session.last_activity = Instant::now(); log::info!( "[PairingOrchestrator] Peer {} disconnected — reset pairing session {:02x}{:02x}... ({} → Failed)", @@ -721,17 +747,21 @@ impl PairingOrchestrator { session.contact_device_id[1], old_state, ); - reset_count += 1; + reset.push(session.contact_device_id); } } } } drop(sessions); - if reset_count > 0 { + if !reset.is_empty() { // Wake the pairing loop so it retries immediately instead of waiting // for the next organic state-change notification. self.signal_state_change(); } + for device_id in &reset { + self.emit_pairing_status(device_id, "failed", "BLE link dropped", Some(ble_address)) + .await; + } } /// Stop the pairing loop. Safe to call even if no loop is running. @@ -751,7 +781,8 @@ impl PairingOrchestrator { /// 5. Waits for actual pairing state changes or a transport retry timeout /// 6. Loops until all contacts are paired or stop_pairing_loop() is called /// - /// Designed to be spawned on the tokio runtime (fire-and-forget from JNI). + /// Spawned on the tokio runtime when the session lets pairing run + /// (`bluetooth::pairing_follows`, `bluetooth::contact_added`). pub async fn start_pairing_all_unpaired(self: Arc) { // Reset stop flag first, then atomically claim the loop self.loop_stop.store(false, Ordering::SeqCst); @@ -883,8 +914,8 @@ impl PairingOrchestrator { // Clear any Failed or stale session so initiate_pairing() inserts a // fresh one. A Failed session for a contact that is still unpaired - // (ble_address absent in SQLite) must be retried on the next - // startPairingAll call. A stale in-progress session means the GATT + // (ble_address absent in SQLite) is retried on this pass. A + // stale in-progress session means the GATT // connection dropped mid-handshake without transitioning to Failed. { let mut sessions = self.sessions.write().await; @@ -934,8 +965,9 @@ impl PairingOrchestrator { let _ = tokio::time::timeout(PAIRING_LOOP_WAKE_TIMEOUT, state_changed).await; } - // Stop BLE radios on loop exit to prevent lingering scan/advertise that - // causes "stuck scanning" when the peer has already completed pairing. + // Stop the pairing scan on loop exit so it does not linger ("stuck + // scanning") once the peer has completed pairing. Advertising follows + // the identity and is not the loop's. let _ = self.stop_ble_discovery().await; self.loop_running.store(false, Ordering::SeqCst); @@ -1115,9 +1147,12 @@ impl PairingOrchestrator { Ok(()) } - /// Stop BLE scan and advertise via JNI. + /// Stop the scan pairing started, via JNI. /// Called when the pairing loop exits to prevent lingering radio activity - /// that causes "stuck scanning" after pairing completes. + /// that causes "stuck scanning" after pairing completes. Advertising is not + /// pairing's to stop: it follows the appliance's identity (the Android BLE + /// service owns it), and an appliance that stopped advertising here could not + /// be found for an offline transfer by the contact it had just paired with. #[cfg(all(target_os = "android", feature = "jni"))] async fn stop_ble_discovery(&self) -> Result<(), String> { use crate::jni::jni_common::{find_class_with_app_loader, get_java_vm_borrowed}; @@ -1131,11 +1166,9 @@ impl PairingOrchestrator { let class = find_class_with_app_loader(&mut env, "com/dsm/wallet/bridge/Unified") .map_err(|e| format!("Failed to find Unified class: {e:?}"))?; - // Stop both scan and advertise — we don't know which role we were playing let _ = env.call_static_method(&class, "stopBlePairingScan", "()Z", &[]); - let _ = env.call_static_method(&class, "stopBlePairingAdvertise", "()Z", &[]); - log::info!("[PairingOrchestrator] stop_ble_discovery: stopped scan and advertise"); + log::info!("[PairingOrchestrator] stop_ble_discovery: stopped scan"); Ok(()) } @@ -1215,6 +1248,33 @@ impl PairingOrchestrator { } } +/// Where BLE pairing with a contact stands, for the contact list: paired once +/// the contact holds the address pairing confirmed (a session completes only +/// once that address is stored); otherwise the phase of its pairing session, +/// or idle when there is none. +pub fn contact_pairing_phase( + holds_address: bool, + session: Option<&PairingState>, +) -> dsm::types::proto::ContactPairingPhase { + use dsm::types::proto::ContactPairingPhase as Phase; + if holds_address { + return Phase::Paired; + } + match session { + None => Phase::Idle, + Some(PairingState::WaitingForConnection) => Phase::Searching, + Some( + PairingState::ReadingIdentity + | PairingState::ExchangingChainTips + | PairingState::AwaitingConfirm + | PairingState::ConfirmSent + | PairingState::UpdatingStatus, + ) => Phase::Connected, + Some(PairingState::Complete) => Phase::Paired, + Some(PairingState::Failed(_)) => Phase::Retrying, + } +} + #[cfg(test)] #[allow(clippy::disallowed_methods)] mod tests { @@ -1270,13 +1330,12 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_initiate_pairing_creates_session() { - // Initialize fresh in-memory DB (serialized to avoid OnceCell races) + // Initialize fresh in-memory DB (serialized to avoid OnceCell races), + // in this test's own storage directory. + crate::economic_fixtures::use_test_storage_dir(); client_db::reset_database_for_tests(); client_db::init_database().expect("init db"); - // Initialize environment for AppState - crate::economic_fixtures::use_test_storage_dir(); - // Ensure device ID is available using idempotent bootstrap crate::sdk::app_state::AppState::set_identity_info_if_empty( vec![0xAA; 32], @@ -1319,6 +1378,7 @@ mod tests { #[serial_test::serial] async fn test_identity_observed_success_updates_status() { // Initialize fresh in-memory DB (serialized to avoid OnceCell races) + crate::economic_fixtures::use_test_storage_dir(); client_db::reset_database_for_tests(); client_db::init_database().expect("init db"); @@ -1398,6 +1458,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_identity_observed_genesis_mismatch_fails() { + crate::economic_fixtures::use_test_storage_dir(); client_db::reset_database_for_tests(); client_db::init_database().expect("init db"); @@ -1446,4 +1507,161 @@ mod tests { assert_eq!(contact.status, "Created"); assert_eq!(contact.ble_address, None); } + + fn store_peer(device_id: [u8; 32]) { + client_db::store_contact(&ContactRecord { + contact_id: format!("ct-{:02x}", device_id[0]), + device_id: device_id.to_vec(), + alias: "peer".to_string(), + genesis_hash: vec![0x55; 32], + current_chain_tip: Some(vec![0x70; 32]), + verified: true, + verification_proof: None, + metadata: HashMap::new(), + ble_address: None, + status: "Created".to_string(), + needs_online_reconcile: false, + public_key: vec![0u8; 32], + kyber_public_key: vec![0x4B; 1184], + previous_chain_tip: None, + }) + .expect("store contact"); + } + + async fn session_in( + orchestrator: &PairingOrchestrator, + device_id: [u8; 32], + state: PairingState, + ble_address: &str, + ) { + orchestrator.sessions.write().await.insert( + device_id, + PairingSession { + contact_device_id: device_id, + state, + ble_address: Some(ble_address.to_string()), + peer_genesis_hash: None, + peer_chain_tip: None, + last_activity: Instant::now(), + }, + ); + } + + /// A pairing completes only once the contact's address is stored: the + /// stored address is what makes the contact paired, and a complete session + /// is never retried. The advertiser's confirm used to complete, and report + /// the contact paired, when the store failed. + #[tokio::test] + #[serial_test::serial] + async fn a_confirm_that_cannot_store_the_address_does_not_complete() { + crate::economic_fixtures::use_test_storage_dir(); + client_db::reset_database_for_tests(); + client_db::init_database().expect("init db"); + let orchestrator = PairingOrchestrator::new(); + let peer = [0x71u8; 32]; + + // No contact to store the address on. + session_in( + &orchestrator, + peer, + PairingState::AwaitingConfirm, + "AA:00:00:00:00:71", + ) + .await; + let refused = orchestrator + .handle_pairing_confirm(peer) + .await + .expect_err("the address was not stored"); + assert!(refused.contains("not stored"), "{refused}"); + assert!(matches!( + orchestrator.get_session_status(&peer).await, + Some(PairingState::Failed(_)) + )); + + store_peer(peer); + session_in( + &orchestrator, + peer, + PairingState::AwaitingConfirm, + "AA:00:00:00:00:71", + ) + .await; + orchestrator + .handle_pairing_confirm(peer) + .await + .expect("completes"); + assert_eq!( + orchestrator.get_session_status(&peer).await, + Some(PairingState::Complete) + ); + let contact = client_db::get_contact_by_device_id(&peer) + .expect("read") + .expect("contact"); + assert_eq!(contact.ble_address.as_deref(), Some("AA:00:00:00:00:71")); + } + + /// As the advertiser's confirm: the scanner's finalize completes only once + /// the address is stored. + #[tokio::test] + #[serial_test::serial] + async fn a_scanner_finalize_that_cannot_store_the_address_does_not_complete() { + crate::economic_fixtures::use_test_storage_dir(); + client_db::reset_database_for_tests(); + client_db::init_database().expect("init db"); + let orchestrator = PairingOrchestrator::new(); + let peer = [0x72u8; 32]; + let address = "AA:00:00:00:00:72"; + + session_in(&orchestrator, peer, PairingState::ConfirmSent, address).await; + let refused = orchestrator + .finalize_scanner_pairing_by_address(address) + .await + .expect_err("the address was not stored"); + assert!(refused.contains("not stored"), "{refused}"); + assert!(matches!( + orchestrator.get_session_status(&peer).await, + Some(PairingState::Failed(_)) + )); + + store_peer(peer); + session_in(&orchestrator, peer, PairingState::ConfirmSent, address).await; + orchestrator + .finalize_scanner_pairing_by_address(address) + .await + .expect("completes"); + assert_eq!( + orchestrator.get_session_status(&peer).await, + Some(PairingState::Complete) + ); + } + + /// The phase the contact list states: paired once the address is stored, + /// else the session's phase, else idle. + #[test] + fn a_contacts_pairing_phase_is_the_sdks_own() { + use dsm::types::proto::ContactPairingPhase as Phase; + assert_eq!(contact_pairing_phase(true, None), Phase::Paired); + assert_eq!(contact_pairing_phase(false, None), Phase::Idle); + assert_eq!( + contact_pairing_phase(false, Some(&PairingState::WaitingForConnection)), + Phase::Searching + ); + for state in [ + PairingState::ReadingIdentity, + PairingState::ExchangingChainTips, + PairingState::AwaitingConfirm, + PairingState::ConfirmSent, + PairingState::UpdatingStatus, + ] { + assert_eq!(contact_pairing_phase(false, Some(&state)), Phase::Connected); + } + assert_eq!( + contact_pairing_phase(false, Some(&PairingState::Failed("link".into()))), + Phase::Retrying + ); + assert_eq!( + contact_pairing_phase(false, Some(&PairingState::Complete)), + Phase::Paired + ); + } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/peer_address.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/peer_address.rs new file mode 100644 index 000000000..2f1e55a91 --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/peer_address.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Where a contact's appliance is over BLE, for an offline send to it. +//! +//! Two sources, in this order. The address the contact holds: persisted once +//! pairing confirmed it, and again whenever the contact's identity is seen at +//! a new one. Else the address its identity was seen at this session, before +//! pairing persisted one. Only a contact's identity, checked against the +//! contact's genesis, records an address here. BLE addresses rotate; the +//! native dispatch matches an address to the appliance's current one by the +//! identity it was seen with. + +use std::collections::HashMap; +use std::sync::Mutex; + +use dsm::types::error::DsmError; +use once_cell::sync::Lazy; + +/// The addresses contacts' identities were seen at this session. +static SEEN_THIS_SESSION: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +/// Record that the identity of the contact `device_id` was seen at `address` +/// this session. An empty address records nothing. +pub fn record_sighting(device_id: &[u8; 32], address: &str) { + if address.is_empty() { + return; + } + let mut seen = SEEN_THIS_SESSION + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = seen.insert(*device_id, address.to_string()); + if previous.as_deref() != Some(address) { + log::info!( + "[peer_address] {:02x}{:02x}... seen at {} (previously {:?})", + device_id[0], + device_id[1], + address, + previous + ); + } +} + +fn seen_this_session(device_id: &[u8; 32]) -> Option { + SEEN_THIS_SESSION + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(device_id) + .cloned() +} + +/// Where an offline send to `device_id` goes: the address its contact holds, +/// else the one its identity was seen at this session. `None` when the appliances +/// have not met over BLE. +pub fn counterparty_address(device_id: &[u8; 32]) -> Result, DsmError> { + let held = crate::storage::client_db::get_contact_by_device_id(device_id) + .map_err(|e| { + DsmError::storage( + format!("the counterparty's contact: {e}"), + None::, + ) + })? + .and_then(|contact| contact.ble_address) + .filter(|address| !address.is_empty()); + Ok(held.or_else(|| seen_this_session(device_id))) +} + +#[cfg(test)] +mod tests { + use super::{counterparty_address, record_sighting}; + use crate::storage::client_db::{store_contact, update_contact_ble_status, ContactRecord}; + use serial_test::serial; + + fn init_test_db() { + crate::economic_fixtures::use_test_storage_dir(); + crate::storage::client_db::reset_database_for_tests(); + crate::storage::client_db::init_database().expect("init db"); + } + + fn add_contact(device_id: [u8; 32]) { + store_contact(&ContactRecord { + contact_id: crate::util::text_id::encode_base32_crockford(&device_id), + device_id: device_id.to_vec(), + alias: "peer".to_string(), + genesis_hash: vec![0xAA; 32], + public_key: vec![0xBB; 64], + kyber_public_key: vec![0x4B; 1184], + current_chain_tip: Some(vec![0x70; 32]), + verified: true, + verification_proof: None, + metadata: std::collections::HashMap::new(), + ble_address: None, + status: "OnlineCapable".to_string(), + needs_online_reconcile: false, + previous_chain_tip: None, + }) + .expect("store the contact"); + } + + /// The address the contact holds wins over an address its identity was + /// seen at this session: pairing confirmed it, and every later sighting at + /// a new address rewrites it. + #[test] + #[serial] + fn a_send_goes_to_the_address_the_contact_holds() { + init_test_db(); + let device_id = [0x61; 32]; + add_contact(device_id); + record_sighting(&device_id, "11:11:11:11:11:11"); + update_contact_ble_status(&device_id, None, Some("22:22:22:22:22:22")) + .expect("persist the paired address"); + + assert_eq!( + counterparty_address(&device_id) + .expect("resolve") + .as_deref(), + Some("22:22:22:22:22:22") + ); + } + + /// Before pairing has persisted an address, a send goes to the one the + /// contact's identity was seen at this session. + #[test] + #[serial] + fn before_pairing_persists_one_the_sighting_is_the_address() { + init_test_db(); + let device_id = [0x62; 32]; + add_contact(device_id); + assert_eq!(counterparty_address(&device_id).expect("resolve"), None); + + record_sighting(&device_id, "33:33:33:33:33:33"); + assert_eq!( + counterparty_address(&device_id) + .expect("resolve") + .as_deref(), + Some("33:33:33:33:33:33") + ); + } + + /// An appliance never seen over BLE has no address, and an empty sighting + /// records none. + #[test] + #[serial] + fn an_appliance_never_seen_has_no_address() { + init_test_db(); + let device_id = [0x63; 32]; + add_contact(device_id); + record_sighting(&device_id, ""); + assert_eq!(counterparty_address(&device_id).expect("resolve"), None); + assert_eq!(counterparty_address(&[0x64; 32]).expect("resolve"), None); + } +} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/contacts_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/contacts_routes.rs index 26da56638..c807034e5 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/contacts_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/contacts_routes.rs @@ -85,6 +85,8 @@ impl AppRouterImpl { } } } + // Pairing takes the new contact up now, if the session lets it run. + crate::bluetooth::contact_added(); pack_envelope_ok(generated::envelope::Payload::ContactAddResponse(added)) } @@ -110,6 +112,13 @@ impl AppRouterImpl { }; let mut item = contact_add_response(contact); item.send_status = Some(derive_local_send_status_for_contact(&record)); + let session = crate::bluetooth::get_pairing_orchestrator() + .get_session_status(&contact.device_id) + .await; + item.pairing = crate::bluetooth::pairing_orchestrator::contact_pairing_phase( + record.ble_address.as_deref().is_some_and(|a| !a.is_empty()), + session.as_ref(), + ) as i32; items.push(item); } let reply = generated::ContactsListResponse { contacts: items }; @@ -279,6 +288,7 @@ mod tests { ble_address: "AA:BB:CC:DD:EE:FF".into(), signing_public_key: vec![0x88; 64], send_status: None, + pairing: generated::ContactPairingPhase::Paired as i32, }; let bytes = resp.encode_to_vec(); @@ -290,3 +300,67 @@ mod tests { assert_eq!(decoded.ble_address, "AA:BB:CC:DD:EE:FF"); } } + +#[cfg(test)] +mod pairing_phase_tests { + use crate::bridge::{AppQuery, AppRouter}; + use dsm::types::proto as generated; + use dsm::types::proto::ContactPairingPhase as Phase; + + async fn pairing_with( + device: &crate::test_support::two_device::TestDevice, + peer: [u8; 32], + ) -> Phase { + device.enter(); + let answer = device + .router() + .query(AppQuery { + path: "contacts.list".to_string(), + params: Vec::new(), + }) + .await; + assert!(answer.success, "contacts.list: {:?}", answer.error_message); + let env = crate::handlers::response_helpers::decode_local_envelope(&answer.data) + .expect("a local answer"); + let Some(generated::envelope::Payload::ContactsListResponse(list)) = env.payload else { + panic!("contacts.list answered {:?}", env.payload); + }; + let contact = list + .contacts + .iter() + .find(|c| c.device_id == peer.to_vec()) + .expect("the peer is listed"); + Phase::try_from(contact.pairing).expect("a named phase") + } + + /// The contact list states where BLE pairing with each contact stands, as + /// the SDK's pairing loop has it. The contacts screen used to infer it from + /// raw radio events, and showed "Paired!" when an appliance's identity was read. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[serial_test::serial] + async fn the_contact_list_states_where_pairing_stands() { + let pair = crate::test_support::two_device::Pair::boot(0, 0).await; + let (a, b) = (&pair.a, &pair.b); + let orchestrator = crate::bluetooth::get_pairing_orchestrator(); + + assert_eq!(pairing_with(a, b.device_id).await, Phase::Idle); + + a.enter(); + orchestrator + .initiate_pairing(b.device_id) + .await + .expect("a session for B"); + assert_eq!(pairing_with(a, b.device_id).await, Phase::Searching); + + a.enter(); + crate::storage::client_db::update_contact_ble_status( + &b.device_id, + None, + Some("AA:BB:CC:DD:EE:0B"), + ) + .expect("pairing stores the address"); + assert_eq!(pairing_with(a, b.device_id).await, Phase::Paired); + + orchestrator.cancel_pairing(&b.device_id).await; + } +} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs index 1b1e728df..531d65677 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs @@ -770,20 +770,14 @@ impl AppRouterImpl { if arg_pack.codec != generated::Codec::Proto as i32 { return err("wallet.sendOffline: ArgPack.codec must be PROTO".into()); } - let req = match generated::BilateralPrepareRequest::decode(&*arg_pack.body) { + let req = match generated::OfflineTransferRequest::decode(&*arg_pack.body) { Ok(r) => r, Err(e) => { return err(format!( - "wallet.sendOffline: decode BilateralPrepareRequest failed: {e}" + "wallet.sendOffline: decode OfflineTransferRequest failed: {e}" )) } }; - if req.counterparty_device_id.len() != 32 { - return err( - "wallet.sendOffline: counterparty_device_id must be 32 bytes".into(), - ); - } - let counterparty_device_id: [u8; 32] = match req.counterparty_device_id[..] .try_into() { @@ -794,26 +788,20 @@ impl AppRouterImpl { ) } }; - let ble_address = if !req.ble_address.trim().is_empty() { - req.ble_address.trim().to_string() - } else { - match crate::storage::client_db::get_contact_by_device_id( - &req.counterparty_device_id, - ) { - Ok(Some(contact)) => contact.ble_address.unwrap_or_default(), - Ok(None) => String::new(), - Err(e) => { - return err(format!( - "wallet.sendOffline: failed to resolve counterparty contact: {e}" - )) - } + // Where the counterparty's appliance is over BLE is the SDK's to know: + // the address its contact holds, else the one its identity was + // seen at this session. + let ble_address = match crate::bluetooth::peer_address::counterparty_address( + &counterparty_device_id, + ) { + Ok(Some(address)) => address, + Ok(None) => { + return err("wallet.sendOffline: no BLE address is known for the \ + counterparty: the appliances have not met over BLE" + .into()) } + Err(e) => return err(format!("wallet.sendOffline: {e}")), }; - if ble_address.is_empty() { - return err( - "wallet.sendOffline: ble_address unavailable for counterparty".into(), - ); - } let send_status = self .calibrate_local_relationship_send_status(&counterparty_device_id) @@ -830,52 +818,38 @@ impl AppRouterImpl { return err(format!("wallet.sendOffline: {message}")); } - let operation_bytes = if req.operation_data.is_empty() { - // The token is named exactly: an omitted token is not ERA. - let token_id = canonicalize_token_id(&req.token_id_hint); - if token_id.is_empty() { - return err("wallet.sendOffline: the request names no token".into()); - } - let transfer_amount = if req.transfer_amount_display.trim().is_empty() { - req.transfer_amount - } else { - let decimals = match token_decimals(&token_id) { - Ok(d) => d, - Err(e) => return err(format!("wallet.sendOffline: {e}")), - }; - match parse_display_amount_to_base_units( - &req.transfer_amount_display, - decimals, - ) { - Ok(amount) => amount, - Err(e) => { - return err(format!( - "wallet.sendOffline: invalid display amount: {e}" - )) - } - } - }; - let policy_commit = match self - .core_sdk - .resolve_policy_commit_strict(token_id.as_bytes()) - { - Ok(pc) => pc, - Err(e) => { - return err(format!( - "wallet.sendOffline: policy_commit resolve failed: {e}" - )) - } + // The token is named exactly: an omitted token is not ERA. + let token_id = canonicalize_token_id(&req.token_id); + if token_id.is_empty() { + return err("wallet.sendOffline: the request names no token".into()); + } + let decimals = match token_decimals(&token_id) { + Ok(d) => d, + Err(e) => return err(format!("wallet.sendOffline: {e}")), + }; + let transfer_amount = + match parse_display_amount_to_base_units(&req.amount, decimals) { + Ok(amount) => amount, + Err(e) => return err(format!("wallet.sendOffline: invalid amount: {e}")), }; - encode_offline_transfer_operation_canonical( - &counterparty_device_id, - transfer_amount, - &token_id, - req.memo_hint.trim(), - &policy_commit, - ) - } else { - req.operation_data.clone() + let policy_commit = match self + .core_sdk + .resolve_policy_commit_strict(token_id.as_bytes()) + { + Ok(pc) => pc, + Err(e) => { + return err(format!( + "wallet.sendOffline: policy_commit resolve failed: {e}" + )) + } }; + let operation_bytes = encode_offline_transfer_operation_canonical( + &counterparty_device_id, + transfer_amount, + &token_id, + req.memo.trim(), + &policy_commit, + ); let operation = match dsm::types::operations::Operation::from_bytes(&operation_bytes) { Ok(op) => op, @@ -1663,3 +1637,84 @@ mod history_tests { ); } } + +#[cfg(test)] +mod send_offline_tests { + use crate::bridge::{AppInvoke, AppRouter}; + use crate::handlers::app_router_impl::AppRouterImpl; + use crate::storage::client_db::{store_contact, update_contact_ble_status, ContactRecord}; + use dsm::types::proto as generated; + use prost::Message; + + /// `wallet.sendOffline` as the frontend asks for it: the user's intent. + async fn send_offline(router: &AppRouterImpl, counterparty: [u8; 32]) -> Result<(), String> { + let answer = router + .invoke(AppInvoke { + method: "wallet.sendOffline".to_string(), + args: generated::ArgPack { + codec: generated::Codec::Proto as i32, + body: generated::OfflineTransferRequest { + counterparty_device_id: counterparty.to_vec(), + token_id: "ERA".to_string(), + amount: "1".to_string(), + memo: String::new(), + } + .encode_to_vec(), + ..Default::default() + } + .encode_to_vec(), + }) + .await; + if answer.success { + Ok(()) + } else { + Err(answer.error_message.unwrap_or_default()) + } + } + + /// Where the counterparty's appliance is over BLE is the SDK's to know; the + /// request names no address. A send to a contact whose appliance the SDK has + /// not met is refused, saying so; once the contact holds an address, the + /// send goes past that refusal. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[serial_test::serial] + async fn an_offline_send_goes_where_the_sdk_has_seen_the_appliance() { + let device = crate::test_support::one_device::Device::start(0x75).await; + let peer = [0x76u8; 32]; + store_contact(&ContactRecord { + contact_id: crate::util::text_id::encode_base32_crockford(&peer), + device_id: peer.to_vec(), + alias: "peer".to_string(), + genesis_hash: vec![0x77; 32], + public_key: vec![0x78; 64], + kyber_public_key: vec![0x4B; 1184], + current_chain_tip: Some(vec![0x79; 32]), + verified: true, + verification_proof: None, + metadata: std::collections::HashMap::new(), + ble_address: None, + status: "OnlineCapable".to_string(), + needs_online_reconcile: false, + previous_chain_tip: None, + }) + .expect("store the contact"); + + let refused = send_offline(&device.router, peer) + .await + .expect_err("the appliances have not met"); + assert!( + refused.contains("no BLE address is known for the counterparty"), + "{refused}" + ); + + update_contact_ble_status(&peer, None, Some("AA:BB:CC:DD:EE:FF")) + .expect("pairing persists the address"); + // A host build has no BLE: the send stops only at the dispatch, past + // the address, the relationship's send status, the token, the amount + // and its policy. + assert_eq!( + send_offline(&device.router, peer).await, + Err("wallet.sendOffline is only available on Android BLE builds".to_string()) + ); + } +} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/ble_events.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/ble_events.rs index 48d683abd..6ca8fb100 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/ble_events.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/ble_events.rs @@ -17,17 +17,6 @@ use std::collections::HashMap; use std::sync::Mutex; use once_cell::sync::Lazy; -fn pb_send_status_from_router_status( - status: dsm::types::proto::RelationshipSendStatus, -) -> pb::RelationshipSendStatus { - pb::RelationshipSendStatus { - send_ready: status.send_ready, - send_check_state: status.send_check_state, - send_block_reason: status.send_block_reason, - send_block_message: status.send_block_message, - } -} - /// Convert raw JNIEnv pointer to safe wrapper. /// Returns None on failure instead of aborting the process. #[inline] @@ -858,12 +847,13 @@ fn process_deferred_identity( return; } - // 2. Register in-memory BLE address mapping (for routing), but do NOT persist - // ble_address to SQLite yet. The ble_address column is the sentinel that controls - // the pairing loop's exit condition — writing it before the scanner confirms - // receipt of our ACK breaks atomicity (advertiser exits loop, scanner never paired). - // Persistence happens in handle_pairing_confirm after the scanner's round-trip. - super::state::register_ble_address_mapping(&device_id, &sender_address); + // 2. Record where the contact's identity was seen this session, but do NOT + // persist ble_address to SQLite yet. The ble_address column is the sentinel that + // controls the pairing loop's exit condition — writing it before the scanner + // confirms receipt of our ACK breaks atomicity (advertiser exits loop, scanner + // never paired). Persistence happens in handle_pairing_confirm after the + // scanner's round-trip. + crate::bluetooth::peer_address::record_sighting(&device_id, &sender_address); // 3. Dispatch identity event to WebView via JNI callback (background thread) dispatch_identity_to_webview(&sender_address, &genesis_hash, &device_id); @@ -1056,82 +1046,6 @@ pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_encodeIdentit ) } -/// Encode the local relationship send-status as a protobuf GATT characteristic value. -#[no_mangle] -pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_getRelationshipStatusCharValue( - env: jni::sys::JNIEnv, - _clazz: jni::sys::jclass, - ble_address_jstr: jni::sys::jstring, -) -> jni::sys::jbyteArray { - crate::jni::bridge_utils::jni_catch_unwind_jbytearray( - "getRelationshipStatusCharValue", - std::panic::AssertUnwindSafe(|| { - let mut env = match unsafe { env_from(env) } { - Some(e) => e, - None => return std::ptr::null_mut(), - }; - let address_jstring = unsafe { jstr_from(ble_address_jstr) }; - let ble_address: String = match env.get_string(&address_jstring) { - Ok(s) => s.into(), - Err(e) => { - log::error!( - "getRelationshipStatusCharValue: JNI address extraction failed: {e}" - ); - return empty(&mut env); - } - }; - - let contact = match crate::storage::client_db::get_contact_by_ble_address(&ble_address) - { - Ok(Some(contact)) => contact, - Ok(None) => { - log::warn!( - "getRelationshipStatusCharValue: no contact mapped to BLE address {}", - ble_address - ); - return empty(&mut env); - } - Err(e) => { - log::error!( - "getRelationshipStatusCharValue: failed to load contact for {}: {}", - ble_address, - e - ); - return empty(&mut env); - } - }; - - if contact.device_id.len() != 32 { - log::error!( - "getRelationshipStatusCharValue: contact device_id has invalid length {}", - contact.device_id.len() - ); - return empty(&mut env); - } - - let send_status = - crate::handlers::relationship_status::derive_local_send_status_for_contact( - &contact, - ); - let char_value = pb::BleRelationshipStatusCharValue { - counterparty_device_id: contact.device_id.clone(), - send_status: Some(pb_send_status_from_router_status(send_status)), - }; - - let encoded = char_value.encode_to_vec(); - match env.byte_array_from_slice(&encoded) { - Ok(arr) => arr.into_raw(), - Err(e) => { - log::error!( - "getRelationshipStatusCharValue: JNI byte_array_from_slice failed: {e}" - ); - empty(&mut env) - } - } - }), - ) -} - /// Process raw protobuf bytes read from the GATT identity characteristic. /// /// This is the canonical path for handling identity reads on the client (scanner) side. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/helpers.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/helpers.rs index ad0d220fe..e67f15923 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/helpers.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/helpers.rs @@ -232,7 +232,7 @@ pub extern "C" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_resolvePeerIdentit let mut device_id = [0u8; 32]; device_id.copy_from_slice(&contact.device_id); - crate::jni::state::register_ble_address_mapping(&device_id, &address); + crate::bluetooth::peer_address::record_sighting(&device_id, &address); let mut out = Vec::with_capacity(64); out.extend_from_slice(&contact.device_id); @@ -571,61 +571,3 @@ pub extern "C" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_notifyBleIdentityO }), ); } - -#[cfg(target_os = "android")] -#[no_mangle] -pub extern "C" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_hasUnpairedContacts( - _env: jni::JNIEnv, - _class: jni::objects::JClass, -) -> jni::sys::jboolean { - crate::jni::bridge_utils::jni_catch_unwind_jboolean( - "hasUnpairedContacts", - std::panic::AssertUnwindSafe(|| { - use jni::sys::{JNI_FALSE, JNI_TRUE}; - - let has_unpaired = crate::storage::client_db::has_unpaired_contacts(); - - if has_unpaired { - log::debug!( - "[JNI] hasUnpairedContacts: true - persistent scanning should be active" - ); - JNI_TRUE - } else { - log::debug!("[JNI] hasUnpairedContacts: false - can stop persistent scanning"); - JNI_FALSE - } - }), - ) -} - -/// Start the pairing loop for all unpaired contacts. -/// Spawns on the tokio runtime (fire-and-forget). The loop runs until all contacts are -/// paired or stopPairingAll() is called. -#[cfg(target_os = "android")] -#[no_mangle] -pub extern "C" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_startPairingAll( - _env: jni::JNIEnv, - _class: jni::objects::JClass, -) { - log::info!("[JNI] startPairingAll invoked"); - let orchestrator = crate::bluetooth::get_pairing_orchestrator(); - if orchestrator.is_loop_running() { - log::info!("[JNI] startPairingAll: loop already running, ignoring"); - return; - } - crate::runtime::get_runtime().spawn(async move { - orchestrator.start_pairing_all_unpaired().await; - }); -} - -/// Stop the pairing loop. Safe to call even if no loop is running. -#[cfg(target_os = "android")] -#[no_mangle] -pub extern "C" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_stopPairingAll( - _env: jni::JNIEnv, - _class: jni::objects::JClass, -) { - log::info!("[JNI] stopPairingAll invoked"); - let orchestrator = crate::bluetooth::get_pairing_orchestrator(); - orchestrator.stop_pairing_loop(); -} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/state.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/state.rs index 3fc178a77..94e54bef4 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/state.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/state.rs @@ -2,67 +2,12 @@ //! # JNI Global State //! -//! Process-global atomic flags and state slots shared across all JNI entry -//! points. BLE address resolution map, bilateral poll flag, and hex parsing. -//! -//! `SDK_READY` has moved to `sdk::session_manager` (always compiled, not cfg-gated). +//! Process-global flags shared across JNI entry points: whether the bilateral +//! poll has started. Where a contact's appliance is over BLE is +//! `crate::bluetooth::peer_address`; `SDK_READY` is `sdk::session_manager`. -use std::sync::Mutex; -use std::collections::HashMap; #[cfg(all(target_os = "android", feature = "bluetooth"))] use std::sync::atomic::AtomicBool; -use once_cell::sync::Lazy; #[cfg(all(target_os = "android", feature = "bluetooth"))] pub static BILATERAL_INIT_POLL_STARTED: AtomicBool = AtomicBool::new(false); - -pub static DEVICE_ID_TO_ADDR: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); - -/// Resolve a peer's current BLE address from its device_id (the reverse of -/// [`register_ble_address_mapping`]). Used by the Path-B relay round-trip to address the sender. -/// Returns `None` if the peer has no observed address yet (relay then fails closed). -pub fn resolve_ble_address(device_id: &[u8; 32]) -> Option { - match DEVICE_ID_TO_ADDR.lock() { - Ok(map) => map.get(device_id).cloned(), - Err(poisoned) => poisoned.into_inner().get(device_id).cloned(), - } -} - -/// Register a BLE address mapping for a device_id in the in-memory resolution map. -/// Called from BLE pairing flow (ble_events.rs) and on every reconnect identity -/// observation so the map tracks the peer's current RPA. -/// -/// Uses `lock()` (blocking) instead of `try_lock()` — a silently dropped -/// registration causes the bilateral send to use a stale address, which is -/// worse than a brief wait on lock contention. -pub fn register_ble_address_mapping(device_id: &[u8; 32], address: &str) { - if address.is_empty() { - return; - } - match DEVICE_ID_TO_ADDR.lock() { - Ok(mut map) => { - let prev = map.insert(*device_id, address.to_string()); - if prev.as_deref() != Some(address) { - log::info!( - "register_ble_address_mapping: {:02x}{:02x}... -> {} (prev={:?})", - device_id[0], - device_id[1], - address, - prev, - ); - } - } - Err(poisoned) => { - // Mutex poisoned by a prior panic — recover and update anyway. - let mut map = poisoned.into_inner(); - map.insert(*device_id, address.to_string()); - log::warn!( - "register_ble_address_mapping: recovered poisoned lock, {:02x}{:02x}... -> {}", - device_id[0], - device_id[1], - address, - ); - } - } -} 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 797a960e1..7a907e6d1 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 @@ -49,7 +49,6 @@ use prost::Message; use std::sync::atomic::Ordering; use crate::storage::client_db::get_contact_by_device_id; use crate::sdk::session_manager::SDK_READY; -use crate::jni::state::register_ble_address_mapping; #[cfg(all(target_os = "android", feature = "bluetooth"))] use crate::jni::state::BILATERAL_INIT_POLL_STARTED; @@ -66,7 +65,6 @@ use crate::bluetooth::frame_classify::{ ble_frame_needs_chunking, detect_ble_frame_type_from_bytes, strip_envelope_v3_framing, }; #[cfg(all(target_os = "android", feature = "bluetooth"))] -use crate::jni::state::DEVICE_ID_TO_ADDR; #[cfg(all(target_os = "android", feature = "bluetooth"))] #[cfg(all(target_os = "android", feature = "bluetooth"))] use jni::objects::{JObject, JValue}; @@ -1186,8 +1184,8 @@ pub(crate) fn handle_ble_identity_observed_from_envelope( ) { log::warn!("identity_observed: BLE address not persisted: {e}"); } - // Register in in-memory resolution map - register_ble_address_mapping(&device_id, &address); + // Where the contact's identity was seen this session. + crate::bluetooth::peer_address::record_sighting(&device_id, &address); // Verify persistence match get_contact_by_device_id(&device_id) { Ok(Some(re_read)) if re_read.ble_address.as_ref() == Some(&address) => { @@ -3046,114 +3044,6 @@ pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_cancelBilater Unified init/status + header fetch (stable surface for Activity gating) ============================================================================= */ -/// Record peer identity mapping: address -> device_id (last 32 bytes of identity payload) -/// identity can be 64 bytes (genesis_hash||device_id) or 32 bytes (device_id only) -#[no_mangle] -#[cfg(all(target_os = "android", feature = "bluetooth"))] -pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_recordPeerIdentity( - env: jni::sys::JNIEnv, - _clazz: jni::sys::jclass, - address: jni::sys::jstring, - identity: jni::sys::jbyteArray, -) { - crate::jni::bridge_utils::jni_catch_unwind_void( - "recordPeerIdentity", - std::panic::AssertUnwindSafe(|| { - let mut env = match unsafe { env_from(env) } { - Some(e) => e, - None => return, - }; - let jaddr = unsafe { jstr_from(address) }; - let addr: String = match env.get_string(&jaddr) { - Ok(s) => s.into(), - Err(_) => return, - }; - let jba = unsafe { jba_from(identity) }; - let id_bytes = match env.convert_byte_array(&jba) { - Ok(v) => v, - Err(_) => return, - }; - let dev_key: [u8; 32] = if id_bytes.len() >= 32 { - let mut key = [0u8; 32]; - key.copy_from_slice(&id_bytes[id_bytes.len() - 32..]); - key - } else { - return; - }; - if !addr.is_empty() { - if let Ok(mut map) = DEVICE_ID_TO_ADDR.try_lock() { - map.insert(dev_key, addr); - } else { - log::warn!("DEVICE_ID_TO_ADDR lock contention, skipping"); - } - } - }), - ) -} - -/// Resolve current BLE address for a given raw 32-byte device ID. -/// Returns UTF-8 BLE MAC address bytes or empty array. -#[no_mangle] -#[cfg(all(target_os = "android", feature = "bluetooth"))] -pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_resolveBleAddressForDeviceIdBin( - env: jni::sys::JNIEnv, - _clazz: jni::sys::jclass, - device_id: jni::sys::jbyteArray, -) -> jni::sys::jbyteArray { - crate::jni::bridge_utils::jni_catch_unwind_jbytearray( - "resolveBleAddressForDeviceIdBin", - std::panic::AssertUnwindSafe(|| { - let mut env = match unsafe { env_from(env) } { - Some(e) => e, - None => return std::ptr::null_mut(), - }; - let jba = unsafe { jba_from(device_id) }; - let id_bytes = match env.convert_byte_array(&jba) { - Ok(v) => v, - Err(_) => return empty_byte_array_or_empty(&mut env).into_raw(), - }; - if id_bytes.len() != 32 { - return empty_byte_array_or_empty(&mut env).into_raw(); - } - let mut dev_key = [0u8; 32]; - dev_key.copy_from_slice(&id_bytes); - - let addr = DEVICE_ID_TO_ADDR - .try_lock() - .ok() - .and_then(|map| map.get(&dev_key).cloned()) - .unwrap_or_default(); - - // Cache miss: resolve from the persisted contact record and repopulate the map. - let final_addr = if addr.is_empty() { - match crate::storage::client_db::get_contact_by_device_id(&dev_key) { - Ok(Some(contact)) if contact.ble_address.is_some() => { - let resolved = contact.ble_address.expect("guarded by is_some()"); - if let Ok(mut map) = DEVICE_ID_TO_ADDR.try_lock() { - map.insert(dev_key, resolved.clone()); - } else { - log::warn!("DEVICE_ID_TO_ADDR lock contention, skipping cache insert"); - } - log::info!( - "resolveBleAddressForDeviceIdBin: hydrated persisted BLE address {:02x}{:02x}... -> {}", - dev_key[0], dev_key[1], resolved - ); - resolved - } - _ => String::new(), - } - } else { - addr - }; - - let addr_bytes = final_addr.as_bytes(); - env.byte_array_from_slice(addr_bytes) - .map(|a| a.into_raw()) - .unwrap_or_else(|_| empty_byte_array_or_empty(&mut env).into_raw()) - }), - ) -} - /// Create a transaction error envelope for BLE operations /// Returns protobuf-encoded envelope with Error payload #[no_mangle] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/contact_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/contact_sdk.rs index ab39df67f..2cb361c0e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/contact_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/contact_sdk.rs @@ -222,6 +222,9 @@ pub fn contact_add_response( &contact.device_id, ), ), + // Where pairing stands is the contact list's to state (`contacts.list`), + // from the pairing loop's sessions; an add reply states none. + pairing: pb::ContactPairingPhase::Unspecified as i32, } } 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 08f849c4c..3d5f79c47 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 @@ -343,6 +343,16 @@ impl SessionManager { Ok(()) } + /// Whether the BLE pairing loop may run: the app is in the foreground, + /// Bluetooth is on and permitted, and there is an identity to pair as. + /// The loop itself ends once no contact is left unpaired. + pub fn pairing_may_run(&self, has_identity: bool) -> bool { + self.hardware.app_foreground + && self.hardware.ble_enabled + && self.hardware.ble_permissions + && has_identity + } + /// Build the full `AppSessionStateProto` snapshot. /// Reads from existing Rust truth on every call — no caching of projection inputs. pub fn compute_snapshot(&self) -> generated::AppSessionStateProto { @@ -407,7 +417,20 @@ pub fn update_hardware_and_snapshot(facts_bytes: &[u8]) -> Result, Strin .map_err(|e| format!("session lock settings: {e}"))?; mgr.apply_hardware_facts(&facts) .map_err(|e| format!("session lock: {e}"))?; - Ok(envelope_wrap_snapshot(mgr.compute_snapshot())) + let snapshot = envelope_wrap_snapshot(mgr.compute_snapshot()); + let pairing_may_run = mgr.pairing_may_run(AppState::get_has_identity()); + drop(mgr); + // Pairing follows the session, as the lock does. + crate::bluetooth::pairing_follows(pairing_may_run); + Ok(snapshot) +} + +/// Whether the BLE pairing loop may run, on the session's last facts. +pub fn pairing_may_run_now() -> bool { + SESSION_MANAGER + .lock() + .unwrap_or_else(|p| p.into_inner()) + .pairing_may_run(AppState::get_has_identity()) } /// Set a fatal error on the session manager and return envelope-wrapped snapshot bytes. @@ -624,6 +647,38 @@ mod tests { assert!(!ble.scanning); } + /// Pairing runs only while the app is in the foreground with Bluetooth + /// on and permitted and an identity to pair as: the contacts screen used + /// to start it when it saw an unpaired contact and stop it when it + /// unmounted. + #[test] + fn pairing_runs_only_in_the_foreground_with_bluetooth_on_permitted_and_an_identity() { + let facts = + |app_foreground, ble_enabled, ble_permissions| generated::SessionHardwareFactsProto { + app_foreground, + ble_enabled, + ble_permissions, + ..Default::default() + }; + let mut mgr = SessionManager::default(); + mgr.apply_hardware_facts(&facts(true, true, true)) + .expect("facts"); + assert!(mgr.pairing_may_run(true)); + assert!(!mgr.pairing_may_run(false), "no identity to pair as"); + for (foreground, on, permitted) in [ + (false, true, true), + (true, false, true), + (true, true, false), + ] { + mgr.apply_hardware_facts(&facts(foreground, on, permitted)) + .expect("facts"); + assert!( + !mgr.pairing_may_run(true), + "foreground={foreground} on={on} permitted={permitted}" + ); + } + } + #[test] #[serial_test::serial] fn sync_lock_config_reads_native_prefs() { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/contacts.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/contacts.rs index 481dc9ebc..7e1a86125 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/contacts.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/contacts.rs @@ -792,38 +792,6 @@ pub fn get_local_bilateral_chain_tip(device_id: &[u8]) -> Result bool { - let binding = match get_connection() { - Ok(b) => b, - Err(e) => { - log::error!( - "[client_db] has_unpaired_contacts: failed to get connection: {}", - e - ); - return false; - } - }; - let conn = binding.lock().unwrap_or_else(|poisoned| { - log::warn!("DB lock poisoned, recovering"); - poisoned.into_inner() - }); - - let result: Result = conn.query_row( - "SELECT COUNT(*) FROM contacts WHERE status != 'BleCapable' OR status IS NULL", - [], - |row| row.get(0), - ); - - match result { - Ok(count) => count > 0, - Err(e) => { - log::warn!("[client_db] has_unpaired_contacts: query failed: {}", e); - false - } - } -} - /// Remove a contact by its contact_id. Returns Ok(true) if a row was deleted, Ok(false) if not found. pub fn remove_contact(contact_id: &str) -> Result { let binding = get_connection()?; diff --git a/dsm_client/frontend/src/components/screens/ContactsTabScreen.tsx b/dsm_client/frontend/src/components/screens/ContactsTabScreen.tsx index 5ae81fd27..68c46c5b4 100644 --- a/dsm_client/frontend/src/components/screens/ContactsTabScreen.tsx +++ b/dsm_client/frontend/src/components/screens/ContactsTabScreen.tsx @@ -9,7 +9,6 @@ import QRCodeScannerPanel from '../qr/QRCodeScannerPanel'; import MyContactInfoPanel from '../contacts/MyContactInfoPanel'; import { useContacts } from '../../contexts/ContactsContext'; import { useTransactions } from '../../hooks/useTransactions'; -import { startPairingAll, stopPairingAll } from '../../dsm/WebViewBridge'; import { bridgeEvents } from '../../bridge/bridgeEvents'; import StitchedReceiptDetails from '../receipts/StitchedReceiptDetails'; import { useDpadNav } from '../../hooks/useDpadNav'; @@ -65,10 +64,6 @@ const ContactsTabScreen: React.FC = ({ eraTokenSrc = 'images/logos/era_to const [error] = useState(null); const [loadingMessage] = useState('Loading contacts...'); - // BLE discovery status: tracks real connection progress - type BleStatus = 'idle' | 'scanning' | 'found' | 'connected' | 'paired'; - const [bleStatus, setBleStatus] = useState('idle'); - // Debounce ref to prevent rapid refresh calls const refreshPendingRef = useRef(false); @@ -146,106 +141,34 @@ const ContactsTabScreen: React.FC = ({ eraTokenSrc = 'images/logos/era_to }); }); + // A pairing moved on: the list states where it stands now. + const offPairingStatus = bridgeEvents.on('ble.pairingStatus', () => { + if (refreshPendingRef.current) return; + refreshPendingRef.current = true; + queueMicrotask(() => { + refreshPendingRef.current = false; + void load('pairing-status'); + }); + }); + return () => { offBleMapped(); offBleUpdated(); + offPairingStatus(); }; }, [load]); - // Reactive BLE status: driven by actual BLE events, not timers. - // scanning → found → connected → paired → idle - useEffect(() => { - const hasUnpairedContacts = contacts.some(c => !c.bleAddress); - if (!hasUnpairedContacts) { - // All contacts paired or none have deviceId — go idle (skip if already paired/idle) - if (bleStatus !== 'idle') { - setBleStatus('idle'); - } - return; - } - // We have unpaired contacts — start at "scanning" if idle - if (bleStatus === 'idle') { - setBleStatus('scanning'); - } - }, [contacts, bleStatus]); - - // Listen for BLE lifecycle events to advance status - useEffect(() => { - const offFound = bridgeEvents.on('ble.deviceFound', () => { - setBleStatus(prev => (prev === 'scanning' || prev === 'idle') ? 'found' : prev); - }); - const offConnected = bridgeEvents.on('ble.deviceConnected', () => { - setBleStatus(prev => (prev !== 'paired' && prev !== 'idle') ? 'connected' : prev); - }); - const offMapped = bridgeEvents.on('contact.bleMapped', () => { - setBleStatus('paired'); - }); - const offScanStarted = bridgeEvents.on('ble.scanStarted', () => { - setBleStatus(prev => prev === 'idle' ? 'scanning' : prev); - }); - const offDisconnected = bridgeEvents.on('ble.deviceDisconnected', () => { - // Regress to scanning if we lost connection before pairing - setBleStatus(prev => (prev === 'connected' || prev === 'found') ? 'scanning' : prev); - }); - const offFailed = bridgeEvents.on('ble.connectionFailed', () => { - setBleStatus(prev => (prev === 'connected' || prev === 'found') ? 'scanning' : prev); - }); - - return () => { - offFound(); offConnected(); offMapped(); - offScanStarted(); offDisconnected(); offFailed(); - }; - }, []); - - - // Rust-driven BLE pairing: trigger when unpaired contacts appear. - // Track the count of unpaired contacts so we only call startPairingAll when - // new unpaired contacts are detected (avoids stop/start thrashing on every refresh). - const prevUnpairedCountRef = useRef(0); - useEffect(() => { - const unpairedCount = contacts.filter(c => !c.bleAddress).length; - if (unpairedCount > 0 && unpairedCount > prevUnpairedCountRef.current) { - if (CONTACTS_DEBUG) console.log(`[ContactsTab] ${unpairedCount} unpaired contacts detected, starting pairing orchestrator`); - void startPairingAll().catch(e => - console.warn('[ContactsTab] startPairingAll failed:', e) - ); - } - prevUnpairedCountRef.current = unpairedCount; - }, [contacts]); - - // Stop pairing on unmount - useEffect(() => { - return () => { - void stopPairingAll().catch(() => {}); - }; - }, []); - - // Listen for Rust pairing status events to advance BLE status indicator - useEffect(() => { - const offPairingStatus = bridgeEvents.on('ble.pairingStatus', (evt) => { - if (CONTACTS_DEBUG) console.log('[ContactsTab] ble.pairingStatus:', evt.status, evt.message); - switch (evt.status) { - case 'scanning': - setBleStatus(prev => prev === 'idle' ? 'scanning' : prev); - break; - case 'found': - setBleStatus(prev => (prev === 'scanning' || prev === 'idle') ? 'found' : prev); - break; - case 'connected': - setBleStatus(prev => (prev !== 'paired') ? 'connected' : prev); - break; - case 'paired': - setBleStatus('paired'); - break; - case 'failed': - case 'timeout': - // Regress to scanning to show we're retrying - setBleStatus(prev => (prev !== 'paired') ? 'scanning' : prev); - break; - } - }); - return () => { offPairingStatus(); }; - }, []); + // When pairing runs is Rust's: while the app is in the foreground with + // Bluetooth on and permitted, until no contact is left unpaired. Where it + // stands is Rust's too: each contact carries its phase from the pairing + // loop, and the line shows the furthest a pairing has got. This screen used + // to start and stop pairing itself, and to infer its progress from raw radio + // events, showing "Paired!" when an appliance's identity was read. + const pairingLine: 'connected' | 'searching' | null = contacts.some((c) => c.pairing === 'connected') + ? 'connected' + : contacts.some((c) => c.pairing === 'searching' || c.pairing === 'retrying') + ? 'searching' + : null; // Only show loading overlay on cold start when there are truly no contacts yet. // Contact-add refreshes are too fast for an overlay — it just flickers. @@ -375,20 +298,18 @@ const ContactsTabScreen: React.FC = ({ eraTokenSrc = 'images/logos/era_to )} - {/* BLE status indicator - reactive to actual BLE events */} - {bleStatus !== 'idle' && contacts.length > 0 && ( + {/* Where pairing stands, as Rust states it on each contact */} + {pairingLine && (
= ({ eraTokenSrc = 'images/logos/era_to fontSize: 9, fontFamily: "'Press Start 2P', monospace", letterSpacing: '1px', - color: bleStatus === 'paired' ? 'var(--accent)' : 'var(--text)', + color: 'var(--text)', marginBottom: 4, }}> - {bleStatus === 'scanning' && 'Scanning for Peers'} - {bleStatus === 'found' && 'Peer Found'} - {bleStatus === 'connected' && 'Connected'} - {bleStatus === 'paired' && 'Paired!'} + {pairingLine === 'searching' && 'Scanning for Peers'} + {pairingLine === 'connected' && 'Connected'}
- {bleStatus === 'scanning' && 'Keep both devices on this screen'} - {bleStatus === 'found' && 'Establishing connection...'} - {bleStatus === 'connected' && 'Exchanging identity...'} - {bleStatus === 'paired' && 'Contact linked successfully'} + {pairingLine === 'searching' && 'Keep the app open on both appliances, near each other'} + {pairingLine === 'connected' && 'Exchanging identity...'}
@@ -518,7 +435,7 @@ const ContactsTabScreen: React.FC = ({ eraTokenSrc = 'images/logos/era_to boxSizing: 'border-box', }}>
- {c.bleAddress ? 'BLE PAIRED' : c.genesisVerifiedOnline ? 'VERIFIED' : 'NOT VERIFIED'} + {c.pairing === 'paired' ? 'BLE PAIRED' : c.genesisVerifiedOnline ? 'VERIFIED' : 'NOT VERIFIED'}
diff --git a/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx b/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx index 3dc67681a..b3c90633f 100644 --- a/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx +++ b/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx @@ -7,9 +7,6 @@ import SendTab from './wallet/SendTab'; import HistoryTab from './wallet/HistoryTab'; import InboxOverlay from './wallet/InboxOverlay'; import BitcoinTapTab from './bitcoin/BitcoinTapTab'; -import { ensureBleAdvertisingIfContacts } from '../../contexts/ContactsContext'; -import { stopBleAdvertisingViaRouter } from '../../dsm/WebViewBridge'; -import { bridgeEvents } from '../../bridge/bridgeEvents'; import { Notice, ScreenFrame, ScreenTabs } from '../common/ScreenFrame'; import '../../styles/EnhancedWallet.css'; @@ -46,29 +43,6 @@ const EnhancedWalletScreen: React.FC = ({ btcLogoSrc, return () => window.removeEventListener('resize', measure); }, []); - // ── BLE lifecycle: wallet screen visible = BLE advertising active ── - // Both parties must be on the wallet screen for bilateral transfers. - // On mount: start GATT server + advertising via protobuf bridge. - // On unmount or app backgrounded: stop advertising. - // On app foregrounded: re-ensure advertising. - useEffect(() => { - void ensureBleAdvertisingIfContacts(); - - const handleVisibility = (ev: { state: DocumentVisibilityState }) => { - if (ev.state === 'visible') { - void ensureBleAdvertisingIfContacts(); - } else { - void stopBleAdvertisingViaRouter(); - } - }; - const off = bridgeEvents.on('visibility.change', handleVisibility); - - return () => { - off(); - void stopBleAdvertisingViaRouter(); - }; - }, []); - const [activeTab, setActiveTab] = useState(initialTab || 'overview'); // A tab is a new page: it opens at the top, not wherever the last one was scrolled to. diff --git a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.contactRow.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.contactRow.test.tsx index c2ba1f92f..9174f203b 100644 --- a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.contactRow.test.tsx +++ b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.contactRow.test.tsx @@ -20,11 +20,6 @@ jest.mock('../../../contexts/ContactsContext', () => ({ useContacts: () => ({ contacts: mockContacts, refreshContacts: async () => {}, isLoading: false }), })); -jest.mock('../../../dsm/WebViewBridge', () => ({ - startPairingAll: jest.fn().mockResolvedValue(undefined), - stopPairingAll: jest.fn().mockResolvedValue(undefined), -})); - describe('ContactsTabScreen contact row', () => { beforeEach(() => { (globalThis as any).requestAnimationFrame = () => 0; diff --git a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairing.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairing.test.tsx new file mode 100644 index 000000000..c17a243e3 --- /dev/null +++ b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairing.test.tsx @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +//! When pairing runs is Rust's: while the app is in the foreground with +//! Bluetooth on and permitted, until no contact is left unpaired. The screen +//! used to start the pairing loop when it saw an unpaired contact, and stop it +//! when it unmounted. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import * as pb from '../../../proto/dsm_app_pb'; +import ContactsTabScreen from '../ContactsTabScreen'; + +jest.mock('../../../utils/identity', () => ({ + hasIdentity: jest.fn().mockResolvedValue(false), +})); + +jest.mock('../../../hooks/useTransactions', () => ({ + useTransactions: () => ({ transactions: [], refresh: jest.fn() }), +})); + +const mockContacts: any[] = []; +jest.mock('../../../contexts/ContactsContext', () => ({ + useContacts: () => ({ contacts: mockContacts, refreshContacts: async () => {}, isLoading: false }), +})); + +test('the contacts screen asks for nothing but reads, with a contact unpaired and as it unmounts', async () => { + mockContacts.push( + { alias: 'paired', deviceId: 'PA1RED', genesisHash: 'GENES1S', signingPublicKey: 'KEY1', genesisVerifiedOnline: true, bleAddress: 'AA:BB:CC:DD:EE:FF' }, + { alias: 'unpaired', deviceId: 'UNPA1RED', genesisHash: 'GENES2S', signingPublicKey: 'KEY2', genesisVerifiedOnline: true }, + ); + (globalThis as any).requestAnimationFrame = () => 0; + const bridge = (window as any).DsmBridge; + const answer = bridge.sendMessageBin; + const requests: string[] = []; + bridge.sendMessageBin = (bytes: Uint8Array) => { + const req = pb.BridgeRpcRequest.fromBinary(bytes); + let what = req.method; + if (req.method === 'nativeBoundaryIngress' && req.payload.case === 'bytes') { + what += `:${String(pb.IngressRequest.fromBinary(req.payload.value.data).operation.case)}`; + } + requests.push(what); + return answer(bytes); + }; + try { + let rendered: ReturnType | undefined; + await act(async () => { + rendered = render(); + await Promise.resolve(); + }); + await act(async () => { + rendered!.unmount(); + // Whatever the unmount started has reached the port by now. + await new Promise((r) => setTimeout(r, 20)); + }); + } finally { + bridge.sendMessageBin = answer; + } + expect(requests.filter((r) => r !== 'nativeBoundaryIngress:routerQuery')).toEqual([]); +}); diff --git a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx deleted file mode 100644 index 561ba3534..000000000 --- a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -import React from 'react'; -import { act, render } from '@testing-library/react'; - -import ContactsTabScreen from '../ContactsTabScreen'; -import { encodeBase32Crockford32 } from '../../../utils/textId'; - -jest.mock('../../../utils/identity', () => ({ - hasIdentity: jest.fn().mockResolvedValue(false), -})); - -jest.mock('../../../hooks/useTransactions', () => ({ - useTransactions: () => ({ - transactions: [], - refresh: jest.fn(), - }), -})); - -declare const describe: any; -declare const it: any; -declare const expect: any; - -// Mock ContactsContext to provide exactly what ContactsTabScreen uses. -jest.mock('../../../contexts/ContactsContext', () => { - return { - useContacts: () => { - // A deterministic 32-byte device id (base32 string). - const device_id = new Uint8Array(32); - for (let i = 0; i < device_id.length; i++) device_id[i] = i & 0xff; - const deviceIdB32 = encodeBase32Crockford32(device_id); - - return { - contacts: [ - { - alias: 'peer', - deviceId: deviceIdB32, - genesisHash: encodeBase32Crockford32(new Uint8Array(32)), - signingPublicKey: encodeBase32Crockford32(new Uint8Array(32).fill(7)), - genesisVerifiedOnline: true, - bleAddress: 'AA:BB:CC:DD:EE:FF', - }, - ], - refreshContacts: async () => {}, - isLoading: false, - }; - }, - }; -}); - -// Mock the Rust-driven pairing bridge calls -jest.mock('../../../dsm/WebViewBridge', () => ({ - startPairingAll: jest.fn().mockResolvedValue(undefined), - stopPairingAll: jest.fn().mockResolvedValue(undefined), -})); - -describe('ContactsTabScreen BLE pairing', () => { - it('does not call startPairingAll when all contacts are already paired', async () => { - const { startPairingAll } = require('../../../dsm/WebViewBridge'); - - (globalThis as any).window = (globalThis as any).window || {}; - (globalThis as any).window.DsmBridge = { - sendMessageBin: async () => new Uint8Array(0), - }; - (globalThis as any).requestAnimationFrame = () => 0; - - // Sanity check encoder remains stable (used for UI/display). - const expectedB32 = encodeBase32Crockford32(new Uint8Array(Array.from({ length: 32 }, (_, i) => i & 0xff))); - expect(expectedB32).toMatch(/^[0-9A-Z]+$/); - - await act(async () => { - render(); - await Promise.resolve(); - }); - - // Contact already has bleAddress, so startPairingAll should NOT be called. - expect(startPairingAll).not.toHaveBeenCalled(); - }); -}); diff --git a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingLine.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingLine.test.tsx new file mode 100644 index 000000000..203ac2750 --- /dev/null +++ b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingLine.test.tsx @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +//! The pairing line states where pairing stands as Rust states it on each +//! contact. The screen used to infer it from raw radio events, and showed +//! "Paired!" when an appliance's identity was read, before pairing had completed. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import ContactsTabScreen from '../ContactsTabScreen'; +import { bridgeEvents } from '../../../bridge/bridgeEvents'; + +jest.mock('../../../utils/identity', () => ({ + hasIdentity: jest.fn().mockResolvedValue(false), +})); + +jest.mock('../../../hooks/useTransactions', () => ({ + useTransactions: () => ({ transactions: [], refresh: jest.fn() }), +})); + +const mockContacts: any[] = []; +jest.mock('../../../contexts/ContactsContext', () => ({ + useContacts: () => ({ contacts: mockContacts, refreshContacts: async () => {}, isLoading: false }), +})); + +function contact(alias: string, pairing: string, bleAddress?: string) { + return { alias, deviceId: `${alias.toUpperCase()}1D`, genesisHash: 'GENES1S', signingPublicKey: 'KEY', genesisVerifiedOnline: true, pairing, bleAddress }; +} + +async function mount() { + (globalThis as any).requestAnimationFrame = () => 0; + await act(async () => { + render(); + await Promise.resolve(); + }); +} + +describe('ContactsTabScreen pairing line', () => { + beforeEach(() => { + mockContacts.length = 0; + }); + + it('states the furthest a pairing has got, and raw radio events change nothing', async () => { + mockContacts.push(contact('ann', 'paired', 'AA:BB:CC:DD:EE:01'), contact('bob', 'connected'), contact('cy', 'searching')); + await mount(); + expect(screen.getByText('Connected')).toBeTruthy(); + + await act(async () => { + bridgeEvents.emit('ble.deviceFound', { address: 'AA:BB:CC:DD:EE:02', name: 'x', rssi: -40 }); + bridgeEvents.emit('ble.deviceDisconnected', { address: 'AA:BB:CC:DD:EE:02' }); + bridgeEvents.emit('contact.bleMapped', { address: 'AA:BB:CC:DD:EE:02', deviceId: 'BOB1D' }); + }); + expect(screen.getByText('Connected')).toBeTruthy(); + expect(screen.queryByText('Paired!')).toBeNull(); + expect(screen.queryByText('Peer Found')).toBeNull(); + }); + + it('shows searching for a contact Rust is still looking for, or retrying', async () => { + mockContacts.push(contact('dee', 'retrying')); + await mount(); + expect(screen.getByText('Scanning for Peers')).toBeTruthy(); + }); + + it('shows no line when no pairing is under way', async () => { + mockContacts.push(contact('eve', 'paired', 'AA:BB:CC:DD:EE:05'), contact('fay', 'idle')); + await mount(); + expect(screen.queryByText('Scanning for Peers')).toBeNull(); + expect(screen.queryByText('Connected')).toBeNull(); + fireEvent.click(screen.getByText('eve')); + expect(screen.getByText('BLE PAIRED')).toBeTruthy(); + }); +}); diff --git a/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx index b9aec62f8..97a90b1e9 100644 --- a/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx +++ b/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx @@ -11,6 +11,7 @@ import { UXProvider } from '../../../contexts/UXContext'; import { WalletProvider } from '../../../contexts/WalletContext'; import { walletStore } from '../../../stores/walletStore'; import { contactsStore } from '../../../stores/contactsStore'; +import * as pb from '../../../proto/dsm_app_pb'; /** * The screen as the app mounts it: inside the wallet provider, whose store is @@ -126,7 +127,6 @@ describe('EnhancedWalletScreen event-driven refresh', () => { .fn() .mockResolvedValue([{ tokenId: 'ROOT', symbol: 'ERA', baseUnits: 100n, displayAmount: '100', decimals: 0 }]); (dsmClient.getWalletHistory as any) = jest.fn().mockResolvedValue({ transactions: [] }); - (dsmClient.resolveBleAddressForContact as any) = jest.fn().mockResolvedValue(contact.bleAddress); (dsmClient.sendOfflineTransfer as any) = jest.fn().mockResolvedValue({ success: true }); await renderWallet(); @@ -156,10 +156,11 @@ describe('EnhancedWalletScreen event-driven refresh', () => { tokenId: 'ROOT', to: encodeBase32Crockford(contact.deviceId), amount: '1', - bleAddress: contact.bleAddress, }) ); }); + // Where the recipient's appliance is over BLE is Rust's to know: the screen names no address. + expect((dsmClient.sendOfflineTransfer as jest.Mock).mock.calls[0][0]).not.toHaveProperty('bleAddress'); }); test('online sender updates visible balance in the UI after send completes', async () => { @@ -209,7 +210,6 @@ describe('EnhancedWalletScreen event-driven refresh', () => { (dsmClient.getAllBalances as any) = jest.fn().mockImplementation(async () => balancesState); (dsmClient.getWalletHistory as any) = jest.fn().mockImplementation(async () => ({ transactions: historyState })); - (dsmClient.resolveBleAddressForContact as any) = jest.fn().mockResolvedValue(contact.bleAddress); (dsmClient.sendOfflineTransfer as any) = jest.fn().mockImplementation(async () => { balancesState = [{ tokenId: 'ROOT', symbol: 'ERA', baseUnits: 55n, displayAmount: '55', decimals: 0 }]; historyState = [{ txId: 'tx-offline-sender', txHash: 'TXOFFLINESENDERHASH', txType: 'bilateral_offline', type: 'offline', amount: -25n, displayAmount: '-25', tokenId: 'ERA', recipient: 'Receiver', status: 'confirmed', fromDeviceId: 'FROM', toDeviceId: 'TO', receiptVerified: false }]; @@ -243,7 +243,6 @@ describe('EnhancedWalletScreen event-driven refresh', () => { tokenId: 'ROOT', to: encodeBase32Crockford(contact.deviceId), amount: '25', - bleAddress: contact.bleAddress, }) ); expect(screen.queryByRole('heading', { name: 'Send Transaction' })).not.toBeInTheDocument(); @@ -436,4 +435,35 @@ describe('EnhancedWalletScreen event-driven refresh', () => { expect(opened).toHaveBeenLastCalledWith({ open: false }); off(); }); + + // The radio is native's: the appliance advertises while it has an identity, + // and an offline send connects to its peer itself. The screen used to start + // advertising when it mounted or became visible, and stop it when hidden and + // when it unmounted, so an appliance on any other screen could not be reached. + test('the wallet screen makes no radio request as it mounts, hides, shows and unmounts', async () => { + installStandardWalletMocks([contactDto('Peer', 0x0a, 'AA:BB:CC:DD:EE:FF')]); + (dsmClient.getAllBalances as any) = jest.fn().mockResolvedValue([]); + (dsmClient.getWalletHistory as any) = jest.fn().mockResolvedValue({ transactions: [] }); + const bridge = (window as any).DsmBridge; + const answer = bridge.sendMessageBin; + const methods: string[] = []; + bridge.sendMessageBin = (bytes: Uint8Array) => { + methods.push(pb.BridgeRpcRequest.fromBinary(bytes).method); + return answer(bytes); + }; + try { + const { unmount } = await renderWallet(); + await waitFor(() => expect(screen.getByText('DSM Wallet')).toBeInTheDocument()); + await act(async () => { + bridgeEvents.emit('visibility.change', { state: 'hidden' }); + bridgeEvents.emit('visibility.change', { state: 'visible' }); + }); + unmount(); + // Whatever the lifecycle started has reached the port by now. + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + } finally { + bridge.sendMessageBin = answer; + } + expect(methods).not.toContain('nativeHostRequest'); + }); }); diff --git a/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx b/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx index 6ee83f7c7..1ff592f43 100644 --- a/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx +++ b/dsm_client/frontend/src/components/screens/wallet/SendTab.tsx @@ -104,20 +104,16 @@ function SendTabInner({ const tokenId = selectedSendBalance.tokenId; if (txMode === 'offline') { - const bleAddr = await dsmClient.resolveBleAddressForContact(contact); - if (!bleAddr || typeof bleAddr !== 'string' || bleAddr.length === 0) { - throw new Error('Offline transfer requires a BLE address for the recipient'); - } - + // Where the recipient's appliance is over BLE is Rust's to know; an appliance + // it has not met is its refusal, in its words. const res = await dsmClient.sendOfflineTransfer({ tokenId, to: sendForm.selectedContactKey, amount: sendForm.amount.trim(), memo: sendForm.note || undefined, - bleAddress: bleAddr, }); if (res.open) { - // Not finished and not failed: the step is open on both phones and + // Not finished and not failed: the step is open on both appliances and // completes when they are together again. The form is done with it. fx.play({ anim: 'trace', @@ -201,7 +197,7 @@ function SendTabInner({ How to send

Online goes through the storage nodes. The recipient does not need to be nearby or awake; it lands in their inbox.

-

Offline goes phone to phone over Bluetooth. Both phones must be next to each other with Bluetooth on, and both must be on the wallet screen.

+

Offline goes appliance to appliance over Bluetooth. Both appliances must be next to each other with Bluetooth on, and the recipient accepts it in the app.

@@ -210,7 +206,7 @@ function SendTabInner({
{txMode === 'offline' && ( - Offline needs Bluetooth. Both phones next to each other, Bluetooth on. + Offline needs Bluetooth. Both appliances next to each other, Bluetooth on. )}
diff --git a/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offline.test.tsx b/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offline.test.tsx index c83fecfd2..922c3529b 100644 --- a/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offline.test.tsx +++ b/dsm_client/frontend/src/components/screens/wallet/__tests__/SendTab.offline.test.tsx @@ -2,7 +2,7 @@ //! An offline send ends one of three ways, and the screen says which. //! //! When the screen stopped waiting, it used to report "did not complete in -//! time" as a failed send, while the step stayed open on both phones and +//! time" as a failed send, while the step stayed open on both appliances and //! completed when they met again: a lost link fails no step. import React from 'react'; @@ -16,7 +16,6 @@ jest.mock('../../../fx/FxProvider', () => ({ jest.mock('../../../../services/dsmClient', () => ({ dsmClient: { - resolveBleAddressForContact: jest.fn().mockResolvedValue('AA:BB:CC:DD:EE:FF'), sendOfflineTransfer: jest.fn(), sendOnlineTransferSmart: jest.fn(), }, @@ -70,6 +69,21 @@ describe('SendTab offline outcome', () => { ); }); + // Where the recipient's appliance is over BLE is Rust's to know. The form used + // to resolve an address itself and refuse a contact it found none for, + // before Rust was asked. + it('asks Rust to send and names no address itself; an appliance Rust has not met is its refusal', async () => { + const refusal = 'wallet.sendOffline: no BLE address is known for the counterparty: the appliances have not met over BLE'; + (dsmClient.sendOfflineTransfer as jest.Mock).mockResolvedValue({ accepted: false, result: refusal }); + const setError = jest.fn(); + + sendOffline({ setError, onSendComplete: jest.fn() }); + + await waitFor(() => expect(setError).toHaveBeenCalledWith(refusal)); + const [params] = (dsmClient.sendOfflineTransfer as jest.Mock).mock.calls.at(-1); + expect(params).toEqual({ tokenId: 'RIGB', to: D3.deviceId, amount: '5', memo: undefined }); + }); + it("reports a refused send as failed, in the SDK's words", async () => { (dsmClient.sendOfflineTransfer as jest.Mock).mockResolvedValue({ accepted: false, diff --git a/dsm_client/frontend/src/components/tour/practiceMode.ts b/dsm_client/frontend/src/components/tour/practiceMode.ts index 672fef30a..47b6ff606 100644 --- a/dsm_client/frontend/src/components/tour/practiceMode.ts +++ b/dsm_client/frontend/src/components/tour/practiceMode.ts @@ -61,6 +61,8 @@ function freshState(): PracticeState { deviceId: practiceId('PRACT1CEA11CE'), genesisHash: practiceId('PRACT1CEA11CEGENES1S'), signingPublicKey: practiceId('PRACT1CEA11CEKEY'), + // Practice contacts are never paired over BLE. + pairing: 'idle', genesisVerifiedOnline: true, sendReady: true, sendCheckState: 'ready', @@ -154,7 +156,6 @@ function simulations(state: PracticeState, emit: (event: PracticeEvent) => void) getAllBalances: async () => state.balances.map((b) => ({ ...b })), getContacts: async () => ({ contacts: state.contacts.map((c) => ({ ...c })) }), getWalletHistory: async () => ({ transactions: [...state.history] }), - resolveBleAddressForContact: async () => undefined, sendOnlineTransferSmart: async (recipientAlias: string, scaledAmountStr: string | number | bigint, memo?: string, tokenId?: string) => { await pause(700); // As Rust answers: a send that names no token is refused, never sent as ERA. @@ -195,6 +196,7 @@ function simulations(state: PracticeState, emit: (event: PracticeEvent) => void) genesisHash: typeof input.genesisHash === 'string' ? input.genesisHash : practiceId('PRACT1CEGENES1S'), deviceId: typeof input.deviceId === 'string' ? input.deviceId : practiceId('PRACT1CEDEV1CE'), signingPublicKey: practiceId('PRACT1CEKEY'), + pairing: 'idle', genesisVerifiedOnline: true, sendReady: true, sendCheckState: 'ready', diff --git a/dsm_client/frontend/src/contexts/ContactsContext.tsx b/dsm_client/frontend/src/contexts/ContactsContext.tsx index 734939b3e..12eff8aed 100644 --- a/dsm_client/frontend/src/contexts/ContactsContext.tsx +++ b/dsm_client/frontend/src/contexts/ContactsContext.tsx @@ -5,11 +5,6 @@ import React, { createContext, useContext, useEffect, useMemo } from 'react'; import { useBridgeEvent } from '@/hooks/useBridgeEvents'; import { hasIdentity } from '../utils/identity'; import { contactsStore, useContactsStore } from '../stores/contactsStore'; -import { - setBleIdentityForAdvertising, - startBleAdvertisingViaRouter, -} from '../dsm/WebViewBridge'; -import { getHeaders } from '../dsm/identity'; import type { AddContactResult, ContactCard } from '../dsm/types'; import type { DomainContact } from '../domain/types'; @@ -38,46 +33,17 @@ const defaultValue: ContactsContextValue = { export const ContactsContext = createContext(defaultValue); -/** - * Ensure BLE advertising is active so peers can initiate bilateral transfers. - * Called by EnhancedWalletScreen on mount/visibility change, and by - * ContactsProvider on identity.ready and contact.bleMapped events. - */ -export async function ensureBleAdvertisingIfContacts(): Promise { - try { - const contacts = contactsStore.getSnapshot().contacts; - const hasBleContacts = contacts.some((c: any) => c.bleAddress); - if (!hasBleContacts) return; - - // §2.3-2.4: Device is bound to genesis via DevID ∈ R_G. - // Fetch the real genesis hash — never advertise all-zeros. - const headers = await getHeaders(); - const devId = headers.deviceId; - const genesisHash = headers.genesisHash; - if (!devId || devId.length !== 32) return; - if (!genesisHash || genesisHash.length !== 32) return; - - await setBleIdentityForAdvertising(new Uint8Array(genesisHash), new Uint8Array(devId)); - await startBleAdvertisingViaRouter(); - } catch { - // Best-effort — don't block contacts flow if BLE advertising fails - } -} - export function ContactsProvider({ children }: { children: React.ReactNode }) { const state = useContactsStore(); useBridgeEvent('contact.bleMapped', (detail) => { contactsStore.handleBleMapped(detail); - void ensureBleAdvertisingIfContacts(); }, []); useBridgeEvent('contact.bleUpdated', contactsStore.handleBleUpdated, []); + // The list Rust holds once there is an identity. Whether the radio + // advertises is native policy (it follows the identity), not the screen's. useBridgeEvent('identity.ready', () => { - // After identity is ready, refresh contacts then start advertising - // so peers can discover us for bilateral transfers. - void contactsStore.refreshContacts().then(() => { - void ensureBleAdvertisingIfContacts(); - }); + void contactsStore.refreshContacts(); }, []); useEffect(() => { diff --git a/dsm_client/frontend/src/contexts/__tests__/ContactsContext.radio.test.tsx b/dsm_client/frontend/src/contexts/__tests__/ContactsContext.radio.test.tsx new file mode 100644 index 000000000..03a8d5b5f --- /dev/null +++ b/dsm_client/frontend/src/contexts/__tests__/ContactsContext.radio.test.tsx @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// Whether the radio advertises is native policy: it follows the appliance's +// identity. The contacts provider used to set the advertised identity and +// start advertising when the identity became ready and when a contact's BLE +// address was learned. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import * as pb from '../../proto/dsm_app_pb'; +import { ContactsProvider } from '../ContactsContext'; +import { dsmClient } from '../../services/dsmClient'; +import { bridgeEvents } from '../../bridge/bridgeEvents'; + +test('identity readiness and a learned BLE address reach the contact list, not the radio', async () => { + const peer = { + alias: 'Peer', + deviceId: new Uint8Array(32).fill(0x0a), + genesisHash: new Uint8Array(32).fill(0x0b), + publicKey: new Uint8Array(64).fill(0x0c), + genesisVerifiedOnline: true, + bleAddress: 'AA:BB:CC:DD:EE:FF', + }; + (dsmClient.getContacts as any) = jest.fn().mockResolvedValue({ contacts: [peer] }); + const bridge = (window as any).DsmBridge; + const answer = bridge.sendMessageBin; + const methods: string[] = []; + bridge.sendMessageBin = (bytes: Uint8Array) => { + methods.push(pb.BridgeRpcRequest.fromBinary(bytes).method); + return answer(bytes); + }; + try { + render(
); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + const before = (dsmClient.getContacts as jest.Mock).mock.calls.length; + + await act(async () => { + bridgeEvents.emit('identity.ready', undefined); + }); + // The provider answers readiness by reading the list Rust holds. + await waitFor(() => expect((dsmClient.getContacts as jest.Mock).mock.calls.length).toBeGreaterThan(before)); + + await act(async () => { + bridgeEvents.emit('contact.bleMapped', { address: peer.bleAddress }); + }); + // Whatever either event started has reached the port by now. + await act(async () => { await new Promise((r) => setTimeout(r, 200)); }); + } finally { + bridge.sendMessageBin = answer; + } + expect(methods).not.toContain('nativeHostRequest'); + expect(methods).not.toContain('setBleIdentityForAdvertising'); +}); diff --git a/dsm_client/frontend/src/domain/__tests__/mappers.test.ts b/dsm_client/frontend/src/domain/__tests__/mappers.test.ts index c906c4a71..8574a50d6 100644 --- a/dsm_client/frontend/src/domain/__tests__/mappers.test.ts +++ b/dsm_client/frontend/src/domain/__tests__/mappers.test.ts @@ -1,25 +1,29 @@ // SPDX-License-Identifier: Apache-2.0 import { + mapContactList, mapTransactions, - normalizeBleAddress, } from '../mappers'; import { TransactionInfo, TransactionType } from '../../proto/dsm_app_pb'; import { toBase32Crockford } from '../../dsm/decoding'; describe('domain mappers', () => { - describe('normalizeBleAddress', () => { - it('uppercases colon-separated MAC', () => { - expect(normalizeBleAddress('aa:bb:cc:dd:ee:ff')).toBe('AA:BB:CC:DD:EE:FF'); + describe('mapContactList', () => { + const contact = (bleAddress?: string) => ({ + alias: 'peer', + deviceId: new Uint8Array(32).fill(1), + genesisHash: new Uint8Array(32).fill(2), + publicKey: new Uint8Array(64).fill(3), + genesisVerifiedOnline: true, + bleAddress, + pairing: bleAddress ? 'paired' as const : 'idle' as const, }); - it('formats 12 hex chars without colons', () => { - expect(normalizeBleAddress('aabbccddeeff')).toBe('AA:BB:CC:DD:EE:FF'); - }); - - it('returns undefined for invalid input', () => { - expect(normalizeBleAddress('')).toBeUndefined(); - expect(normalizeBleAddress('not-mac')).toBeUndefined(); - expect(normalizeBleAddress(undefined)).toBeUndefined(); + // The address is Rust's: the contact carries it as Rust holds it, and none + // where Rust holds none. The mapper used to reformat it, drop one that was + // not MAC-shaped, and fill a missing one from addresses resolved this session. + it('carries the BLE address Rust holds, as it holds it, and none where it holds none', () => { + expect(mapContactList([contact('aa:bb:cc:dd:ee:ff')])[0].bleAddress).toBe('aa:bb:cc:dd:ee:ff'); + expect(mapContactList([contact(undefined)])[0].bleAddress).toBeUndefined(); }); }); diff --git a/dsm_client/frontend/src/domain/mappers.ts b/dsm_client/frontend/src/domain/mappers.ts index fc46ced55..d7fc8d9d7 100644 --- a/dsm_client/frontend/src/domain/mappers.ts +++ b/dsm_client/frontend/src/domain/mappers.ts @@ -19,21 +19,6 @@ import type { DomainTxType, } from './types'; -export function normalizeBleAddress(input?: string): string | undefined { - if (typeof input !== 'string') return undefined; - const s = input.trim(); - if (!s) return undefined; - // eslint-disable-next-line security/detect-unsafe-regex - if (/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/.test(s)) return s.toUpperCase(); - // eslint-disable-next-line security/detect-unsafe-regex - if (/^[0-9a-fA-F]{12}$/.test(s)) { - const parts: string[] = []; - for (let i = 0; i < 12; i += 2) parts.push(s.slice(i, i + 2)); - return parts.join(':').toUpperCase(); - } - return undefined; -} - function mapSendCheckState(value: unknown): DomainRelationshipSendCheckState | undefined { switch (value) { case RelationshipSendCheckState.CHECKING: @@ -79,22 +64,17 @@ export function mapRelationshipSendStatus(status: any): DomainRelationshipSendSt }; } -export function mapContactList(list: BilateralRelationshipDTO[], bleSnapshot?: { deviceIds: Record; genesis: Record }): DomainContact[] { - const snapshot = bleSnapshot || { deviceIds: {}, genesis: {} }; +export function mapContactList(list: BilateralRelationshipDTO[]): DomainContact[] { return list.map((c) => { - const deviceId = toBase32Crockford(c.deviceId); - const genesisHash = toBase32Crockford(c.genesisHash); const sendStatus = mapRelationshipSendStatus(c.sendStatus); - // The address Rust holds for the contact, else one the native side - // resolved for its device this session (dsm/resolution.ts). - const directBle = normalizeBleAddress(c.bleAddress ?? ''); - const mappedBle = directBle || snapshot.deviceIds[deviceId] || snapshot.genesis[genesisHash] || undefined; return { alias: c.alias, - deviceId, - genesisHash, + deviceId: toBase32Crockford(c.deviceId), + genesisHash: toBase32Crockford(c.genesisHash), chainTip: c.chainTip ? toBase32Crockford(c.chainTip) : undefined, - bleAddress: mappedBle, + // The address Rust holds for the contact: pairing confirmed it. + bleAddress: c.bleAddress, + pairing: c.pairing, genesisVerifiedOnline: c.genesisVerifiedOnline, signingPublicKey: toBase32Crockford(c.publicKey), sendReady: sendStatus?.sendReady, diff --git a/dsm_client/frontend/src/domain/types.ts b/dsm_client/frontend/src/domain/types.ts index 9a8c10080..28b21f508 100644 --- a/dsm_client/frontend/src/domain/types.ts +++ b/dsm_client/frontend/src/domain/types.ts @@ -21,12 +21,16 @@ export type DomainRelationshipSendStatus = { sendBlockMessage?: string; }; +/** Where BLE pairing with a contact stands, as Rust's pairing loop has it. */ +export type ContactPairing = 'paired' | 'idle' | 'searching' | 'connected' | 'retrying'; + export type DomainContact = { alias: string; deviceId: string; genesisHash: string; chainTip?: string; bleAddress?: string; + pairing: ContactPairing; genesisVerifiedOnline: boolean; signingPublicKey: string; // base32 Crockford encoded sendReady?: boolean; diff --git a/dsm_client/frontend/src/dsm/EventBridge.ts b/dsm_client/frontend/src/dsm/EventBridge.ts index 23e79c792..2cc5e6b41 100644 --- a/dsm_client/frontend/src/dsm/EventBridge.ts +++ b/dsm_client/frontend/src/dsm/EventBridge.ts @@ -286,16 +286,6 @@ export function initializeEventBridge(): void { return; } - if (topic === 'bluetooth-permissions') { - // Payload: [0x01] = granted, [0x00] = denied - try { - const granted = bytes.length > 0 && bytes[0] === 0x01; - window.dispatchEvent(new CustomEvent('bluetooth-permissions', { detail: { granted } })); - } catch {} - emit(topic, bytes); - return; - } - if (topic === 'ble-dev-automation') { // Payload: UTF-8 "ok:advertising=true,scanning=true" or "error:reason" try { diff --git a/dsm_client/frontend/src/dsm/NativeHostBridge.ts b/dsm_client/frontend/src/dsm/NativeHostBridge.ts index 8b73129dd..8cb392ccf 100644 --- a/dsm_client/frontend/src/dsm/NativeHostBridge.ts +++ b/dsm_client/frontend/src/dsm/NativeHostBridge.ts @@ -6,7 +6,7 @@ import { bridgeEvents } from '../bridge/bridgeEvents'; import logger from '../utils/logger'; import type { AndroidBridgeV3 } from './bridgeTypes'; import { bridgeGate } from './BridgeGate'; -import { BiometricAuthorizeResult, NativeHostAck, NativeHostEvent, NativeHostEventKind, NativeHostRequest, NativeHostRequestKind, NativeHostResponse, NfcTagWritePayload, NfcTagWriteResult, QrScanResultPayload } from '../proto/dsm_app_pb'; +import { BiometricAuthorizeResult, NativeHostEvent, NativeHostEventKind, NativeHostRequest, NativeHostRequestKind, NativeHostResponse, NfcTagWritePayload, NfcTagWriteResult, QrScanResultPayload } from '../proto/dsm_app_pb'; function mustBridge(): AndroidBridgeV3 { const bridge = getBridgeInstance(); @@ -76,24 +76,6 @@ export async function startNativeQrScan(): Promise { await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_QR_START_SCAN)); } -export async function startBleScanHost(): Promise { - await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_BLE_SCAN_START)); -} - -export async function stopBleScanHost(): Promise { - await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_BLE_SCAN_STOP)); -} - -export async function startBleAdvertisingHost(): Promise { - const bytes = await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_BLE_ADVERTISE_START)); - return NativeHostAck.fromBinary(bytes); -} - -export async function stopBleAdvertisingHost(): Promise { - const bytes = await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_BLE_ADVERTISE_STOP)); - return NativeHostAck.fromBinary(bytes); -} - export async function startNfcReaderHost(): Promise { await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_NFC_READER_START)); } @@ -128,8 +110,6 @@ export function decodeNativeHostEventToLegacyTopic(eventBytes: Uint8Array): { to return null; } } - case NativeHostEventKind.BLUETOOTH_PERMISSIONS: - return { topic: 'bluetooth-permissions', payload: event.payload }; case NativeHostEventKind.BIOMETRIC_RESULT: { try { const payload = BiometricAuthorizeResult.fromBinary(event.payload); diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts b/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts index 2da09f905..52741c83d 100644 --- a/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts +++ b/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts @@ -1,17 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -// BLE-related transport: pairing orchestrator, advertising, scanning, identity -// injection, and bilateral offline send. +// BLE-related bridge calls the screens make: permissions and Bluetooth +// settings. When the radio advertises and scans, and when pairing runs, is +// native policy; nothing here starts or stops either. -import { bridgeGate } from "../BridgeGate"; -import { BleIdentityPayload } from "../../proto/dsm_app_pb"; -import { - startBleAdvertisingHost, - startBleScanHost, - stopBleAdvertisingHost, - stopBleScanHost, -} from "../NativeHostBridge"; import { callBin } from "./transportCore"; -import { log } from "./log"; export async function requestBlePermissions(): Promise { await callBin("requestBlePermissions", new Uint8Array(0)); @@ -21,102 +13,3 @@ export async function openBluetoothSettings(): Promise { await callBin("openBluetoothSettings", new Uint8Array(0)); } -/** - * Start the Rust-driven pairing orchestrator loop. Status updates arrive via - * the 'ble.pairingStatus' bridgeEvents topic. - */ -export async function startPairingAll(): Promise { - try { - await callBin("startPairingAll", new Uint8Array(0)); - } catch (e) { - log.warn("[BLE] startPairingAll failed:", e); - } -} - -export async function stopPairingAll(): Promise { - try { - await callBin("stopPairingAll", new Uint8Array(0)); - } catch (e) { - log.warn("[BLE] stopPairingAll failed:", e); - } -} - -export async function resolveBleAddressForDeviceIdBridge( - deviceId: Uint8Array -): Promise { - const bytes = deviceId instanceof Uint8Array ? deviceId : new Uint8Array(0); - if (bytes.length !== 32) return undefined; - const resp = await callBin("resolveBleAddressForDeviceId", bytes); - if (!resp || resp.length === 0) return undefined; - const s = new TextDecoder().decode(resp).trim(); - return s || undefined; -} - -export async function readPeerRelationshipStatusBridge(bleAddress: string): Promise { - const normalized = String(bleAddress ?? "").trim(); - if (!normalized) return new Uint8Array(0); - return bridgeGate.enqueue(() => - callBin("readPeerRelationshipStatus", new TextEncoder().encode(normalized)) - ); -} - -export async function startBleScanViaRouter(): Promise { - await startBleScanHost(); -} - -export async function stopBleScanViaRouter(): Promise { - await stopBleScanHost(); -} - -export async function startBleAdvertisingViaRouter(): Promise<{ - success: boolean; - error?: { message?: string }; -}> { - try { - const ack = await startBleAdvertisingHost(); - return { success: Boolean(ack.success) }; - } catch (e) { - return { - success: false, - error: { message: e instanceof Error ? e.message : "device.ble.advertise.start failed" }, - }; - } -} - -export async function stopBleAdvertisingViaRouter(): Promise<{ - success: boolean; - error?: { message?: string }; -}> { - try { - const ack = await stopBleAdvertisingHost(); - return { success: Boolean(ack.success) }; - } catch (e) { - return { - success: false, - error: { message: e instanceof Error ? e.message : "device.ble.advertise.stop failed" }, - }; - } -} - -/** - * Inject genesis + device_id into native BLE layer to enable advertising after - * genesis creation. - */ -export async function setBleIdentityForAdvertising( - genesisHash: Uint8Array, - deviceId: Uint8Array -): Promise { - if (genesisHash.length !== 32) { - throw new Error("setBleIdentityForAdvertising: genesis_hash must be 32 bytes"); - } - if (deviceId.length !== 32) { - throw new Error("setBleIdentityForAdvertising: device_id must be 32 bytes"); - } - - const req = new BleIdentityPayload({ - genesisHash: new Uint8Array(genesisHash), - deviceId: new Uint8Array(deviceId), - }); - - await bridgeGate.enqueue(() => callBin("setBleIdentityForAdvertising", req.toBinary())); -} diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/index.ts b/dsm_client/frontend/src/dsm/WebViewBridge/index.ts index a206de892..823d05033 100644 --- a/dsm_client/frontend/src/dsm/WebViewBridge/index.ts +++ b/dsm_client/frontend/src/dsm/WebViewBridge/index.ts @@ -36,16 +36,7 @@ export const { export const { openBluetoothSettings, - readPeerRelationshipStatusBridge, requestBlePermissions, - resolveBleAddressForDeviceIdBridge, - setBleIdentityForAdvertising, - startBleAdvertisingViaRouter, - startBleScanViaRouter, - startPairingAll, - stopBleAdvertisingViaRouter, - stopBleScanViaRouter, - stopPairingAll, } = ble; export const { diff --git a/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts b/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts deleted file mode 100644 index b58804cd0..000000000 --- a/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import { encodeBase32Crockford32 as base32CrockfordEncode32 } from '../../utils/textId'; -// Tests for dynamic BLE identity mapping / resolveBleAddressForContact -import { dsmClient } from '../index'; -const enc = new TextEncoder(); - -// Helper to wrap response in DSM_BRIDGE format with BridgeRpcResponse -function createDsmBridgeSuccessResponse(data: Uint8Array): Uint8Array { - return (global as any).createDsmBridgeSuccessResponse(data); -} - -function mkBytes(seed: number): Uint8Array { - const b = new Uint8Array(32); - for (let i = 0; i < 32; i++) b[i] = (seed + i) & 0xff; - return b; -} - -describe('resolveBleAddressForContact', () => { - beforeEach(() => { - (globalThis as any).window = (globalThis as any).window || {}; - }); - - it('returns undefined when no mapping or stored address', async () => { - (globalThis as any).window.DsmBridge = { - __binary: true, - sendMessageBin: async () => createDsmBridgeSuccessResponse(new Uint8Array(0)), - }; - const contact = { alias: 'A', deviceId: mkBytes(1), genesisHash: mkBytes(2) }; - await expect(dsmClient.resolveBleAddressForContact?.(contact as any)).resolves.toBeUndefined(); - }); - - it('uses stored ble_address directly', async () => { - (globalThis as any).window.DsmBridge = { - __binary: true, - sendMessageBin: async () => new Uint8Array(0), - }; - const contact = { alias: 'B', deviceId: mkBytes(3), genesisHash: mkBytes(4), bleAddress: '11:22:33:44:55:66' }; - await expect(dsmClient.resolveBleAddressForContact?.(contact as any)).resolves.toBe('11:22:33:44:55:66'); - }); - - it('resolves via native lookup when no stored ble_address', async () => { - const devId = mkBytes(10); - const genesis = mkBytes(11); - const address = 'AA:BB:CC:DD:EE:FF'; - (globalThis as any).window.DsmBridge = { - __binary: true, - sendMessageBin: async (_reqBytes: Uint8Array) => { - return createDsmBridgeSuccessResponse(new Uint8Array(Array.from(enc.encode(address)))); - }, - }; - - const contact = { alias: 'Peer', deviceId: devId, genesisHash: genesis }; - const resolved = await dsmClient.resolveBleAddressForContact?.(contact as any); - expect(resolved).toBe(address); - - // Snapshot should surface mapping (in-memory only) - const devHex = base32CrockfordEncode32(devId); - const ghHex = base32CrockfordEncode32(genesis); - const snap = dsmClient.getBleIdentitySnapshot?.(); - expect(snap?.deviceIds?.[devHex]).toBe(address); - expect(snap?.genesis?.[ghHex]).toBe(address); - }); - - it('resolves via native lookup when device_id is Base32 string', async () => { - const devId = mkBytes(12); - const genesis = mkBytes(13); - const devIdB32 = base32CrockfordEncode32(devId); - const genesisB32 = base32CrockfordEncode32(genesis); - const address = 'AB:CD:EF:12:34:56'; - - (globalThis as any).window.DsmBridge = { - __binary: true, - sendMessageBin: async (reqBytes: Uint8Array) => { - const pb = require('../../proto/dsm_app_pb'); - const req = pb.BridgeRpcRequest.fromBinary(reqBytes); - const method = req.method || ''; - const payload = req.payload?.case === 'bytes' ? req.payload.value.data : new Uint8Array(0); - if (method === 'resolveBleAddressForDeviceId') { - if (payload.length === devId.length && payload.every((b: number, i: number) => b === devId[i])) { - return createDsmBridgeSuccessResponse(new Uint8Array(Array.from(enc.encode(address)))); - } - } - return createDsmBridgeSuccessResponse(new Uint8Array(0)); - }, - }; - - const contact = { alias: 'PeerB32', deviceId: devIdB32, genesisHash: genesisB32 }; - const resolved = await dsmClient.resolveBleAddressForContact?.(contact as any); - expect(resolved).toBe(address); - - const snap = dsmClient.getBleIdentitySnapshot?.(); - expect(snap?.deviceIds?.[devIdB32]).toBe(address); - expect(snap?.genesis?.[genesisB32]).toBe(address); - }); -}); diff --git a/dsm_client/frontend/src/dsm/__tests__/blePairingRequestNormalization.test.ts b/dsm_client/frontend/src/dsm/__tests__/blePairingRequestNormalization.test.ts deleted file mode 100644 index 4608b7654..000000000 --- a/dsm_client/frontend/src/dsm/__tests__/blePairingRequestNormalization.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-env jest */ -/// -export {}; -// Declare globals for type-check environments lacking jest types -declare const describe: any; // provided by jest at runtime -declare const it: any; // provided by jest at runtime -declare const expect: any; // provided by jest at runtime -// Tests that resolveBleAddressForContact normalizes MAC addresses. -import { dsmClient } from '../index'; - -function mkBytes(seed: number): Uint8Array { - const b = new Uint8Array(32); - for (let i = 0; i < 32; i++) b[i] = (seed + i) & 0xff; - return b; -} - -describe('BlePairingRequest normalization & mapping', () => { - it('normalizes lowercase colon MAC', async () => { - const devId = mkBytes(60); - const genesis = mkBytes(61); - const rawAddress = 'aa:bb:cc:dd:ee:ff'; // lower-case; should normalize to upper-case - (globalThis as any).window = (globalThis as any).window || {}; - (globalThis as any).window.DsmBridge = { __binary: true, sendMessageBin: async () => new Uint8Array(0) }; - - const contact = { alias: 'PeerLC', deviceId: devId, genesisHash: genesis, bleAddress: rawAddress }; - const resolved = await dsmClient.resolveBleAddressForContact?.(contact as any); - expect(resolved).toBe('AA:BB:CC:DD:EE:FF'); - }); - - it('normalizes contiguous hex MAC (no alias)', async () => { - const devId = mkBytes(70); - const genesis = mkBytes(71); - const rawAddress = '112233445566'; // contiguous hex - (globalThis as any).window.DsmBridge = { __binary: true, sendMessageBin: async () => new Uint8Array(0) }; - - const contact = { alias: 'PeerHex', deviceId: devId, genesisHash: genesis, bleAddress: rawAddress }; - const resolved = await dsmClient.resolveBleAddressForContact?.(contact as any); - expect(resolved).toBe('11:22:33:44:55:66'); - }); -}); diff --git a/dsm_client/frontend/src/dsm/__tests__/contacts.test.ts b/dsm_client/frontend/src/dsm/__tests__/contacts.test.ts index c4a25373b..d7150eb4a 100644 --- a/dsm_client/frontend/src/dsm/__tests__/contacts.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/contacts.test.ts @@ -47,6 +47,7 @@ describe('contacts.ts', () => { signingPublicKey: signingPk as any, genesisHash: { v: gh } as any, bleAddress: 'AA:BB:CC:DD:EE:FF', + pairing: pb.ContactPairingPhase.PAIRED, genesisVerifiedOnline: true, }), ], @@ -62,6 +63,7 @@ describe('contacts.ts', () => { expect(result.contacts[0].deviceId).toEqual(deviceId); expect(result.contacts[0].publicKey).toEqual(signingPk); expect(result.contacts[0].bleAddress).toBe('AA:BB:CC:DD:EE:FF'); + expect(result.contacts[0].pairing).toBe('paired'); expect(result.contacts[0].genesisVerifiedOnline).toBe(true); }); @@ -91,6 +93,7 @@ describe('contacts.ts', () => { alias: 'Alice', signingPublicKey: new Uint8Array(64).fill(0x02), genesisHash: { v: new Uint8Array(32).fill(0x03) }, + pairing: pb.ContactPairingPhase.IDLE, }; (getContactsStrictBridge as jest.Mock).mockResolvedValue(answer(new pb.ContactAddResponse({}))); @@ -110,6 +113,40 @@ describe('contacts.ts', () => { answer(new pb.ContactAddResponse({ ...complete, alias: '' } as any)), ); await expect(getContacts()).rejects.toThrow(/STRICT.*without its alias/); + + // Where pairing stands is Rust's to state; a phase the wire does not + // name is not read as any other. + (getContactsStrictBridge as jest.Mock).mockResolvedValue( + answer(new pb.ContactAddResponse({ ...complete, pairing: pb.ContactPairingPhase.UNSPECIFIED } as any)), + ); + await expect(getContacts()).rejects.toThrow(/STRICT.*pairing phase the wire does not name/); + }); + + test('each contact carries the pairing phase Rust states', async () => { + const phases: Array<[pb.ContactPairingPhase, string]> = [ + [pb.ContactPairingPhase.PAIRED, 'paired'], + [pb.ContactPairingPhase.IDLE, 'idle'], + [pb.ContactPairingPhase.SEARCHING, 'searching'], + [pb.ContactPairingPhase.CONNECTED, 'connected'], + [pb.ContactPairingPhase.RETRYING, 'retrying'], + ]; + (getContactsStrictBridge as jest.Mock).mockResolvedValue(frameEnvelope(new pb.Envelope({ + version: 3, + payload: { + case: 'contactsListResponse', + value: new pb.ContactsListResponse({ + contacts: phases.map(([pairing], i) => new pb.ContactAddResponse({ + deviceId: new Uint8Array(32).fill(i + 1) as any, + alias: `c${i}`, + signingPublicKey: new Uint8Array(64).fill(2) as any, + genesisHash: { v: new Uint8Array(32).fill(3) } as any, + pairing, + })), + }), + }, + }))); + const result = await getContacts(); + expect(result.contacts.map((c) => c.pairing)).toEqual(phases.map(([, name]) => name)); }); test('throws on empty response bytes', async () => { @@ -163,6 +200,7 @@ describe('contacts.ts', () => { signingPublicKey: new Uint8Array(64).fill(0x05) as any, genesisHash: { v: new Uint8Array(32).fill(0x06) } as any, chainTip: { v: tipHash } as any, + pairing: pb.ContactPairingPhase.IDLE, }), ], }), diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts new file mode 100644 index 000000000..3f7d36829 --- /dev/null +++ b/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// The frontend makes no radio decisions. An offline send is one router call — +// `wallet.sendOffline` — and the native side advertises, scans and connects as +// the dispatch needs. The send used to set the advertised identity, start +// advertising and scanning through host requests, and sleep 1.5 s first, +// swallowing every failure. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as pb from '../../proto/dsm_app_pb'; +import { emit, initializeEventBridge } from '../EventBridge'; +import { offlineSend } from '../transactions'; +import { encodeBase32Crockford } from '../../utils/textId'; + +const COMMITMENT = new Uint8Array(32).fill(0x5c); +const PEER = new Uint8Array(32).fill(9); + +function framed(envelope: pb.Envelope): Uint8Array { + const bytes = envelope.toBinary(); + const out = new Uint8Array(1 + bytes.length); + out[0] = 0x03; + out.set(bytes, 1); + return out; +} + +/** What the page sent over the port: each RPC's method, and each router call's name. */ +function recordingBridge() { + const methods: string[] = []; + const routerCalls: string[] = []; + (window as any).DsmBridge = { + __binary: true, + sendMessageBin: async (reqBytes: Uint8Array) => { + const req = pb.BridgeRpcRequest.fromBinary(reqBytes); + methods.push(req.method); + if (req.method !== 'nativeBoundaryIngress') { + return (global as any).createDsmBridgeErrorResponse(`no ${req.method} here`); + } + const payload = req.payload.case === 'bytes' ? req.payload.value.data : new Uint8Array(0); + const op = pb.IngressRequest.fromBinary(payload).operation; + const name = op.case === 'routerInvoke' || op.case === 'routerQuery' ? op.value.method : String(op.case); + routerCalls.push(name); + const answer = name === 'wallet.sendOffline' + ? framed(new pb.Envelope({ + version: 3, + payload: { + case: 'bilateralPrepareResponse', + value: new pb.BilateralPrepareResponse({ commitmentHash: new pb.Hash32({ v: COMMITMENT }) }), + }, + })) + : new Uint8Array(0); + return (global as any).createDsmBridgeSuccessResponse( + new pb.IngressResponse({ result: { case: 'okBytes', value: answer } }).toBinary(), + ); + }, + }; + return { methods, routerCalls }; +} + +describe('offline send: the radio is native’s', () => { + beforeEach(() => { + initializeEventBridge(); + }); + + test('an offline send asks for the send and nothing else — no host request, no identity relay, no wait', async () => { + const seen = recordingBridge(); + + const pending = offlineSend({ to: encodeBase32Crockford(PEER), amount: '1', tokenId: 'ERA' } as any); + // The send reaches Rust at once: nothing is awaited on a timer first. + for (let i = 0; i < 10 && !seen.routerCalls.includes('wallet.sendOffline'); i++) { + await Promise.resolve(); + } + expect(seen.routerCalls).toEqual(['wallet.sendOffline']); + + // Rust announces the completed transfer; the send finishes on it. + const note = new pb.BilateralEventNotification({ + eventType: pb.BilateralEventType.BILATERAL_EVENT_TRANSFER_COMPLETE, + commitmentHash: COMMITMENT, + counterpartyDeviceId: PEER, + status: 'completed', + }); + emit('bilateral.event', new Uint8Array(note.toBinary())); + await expect(pending).resolves.toEqual(expect.objectContaining({ accepted: true })); + // Whatever the send started has reached the port by now. + await new Promise((r) => setTimeout(r, 0)); + + // Every request was a router call through the ingress boundary: no + // nativeHostRequest (advertise / scan) and no setBleIdentityForAdvertising. + expect(new Set(seen.methods)).toEqual(new Set(['nativeBoundaryIngress'])); + }); +}); diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts index 924c6981d..a1e44612e 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts @@ -64,7 +64,9 @@ describe('offlineSend', () => { warnSpy.mockRestore(); }); - test('delegates missing BLE address resolution to wallet.sendOffline', async () => { + // The send is what the user asked for, byte for byte, and nothing else: + // where the counterparty's appliance is over BLE is Rust's to know. + test('an offline send reaches wallet.sendOffline as what the user asked for, and nothing else', async () => { const to = new Uint8Array(32).fill(0x22); const commitmentHash = new Uint8Array(32).fill(0x99); @@ -72,12 +74,12 @@ describe('offlineSend', () => { const { route, args } = decodeRouterInvoke(reqBytes); expect(route).toBe('wallet.sendOffline'); const argPack = pb.ArgPack.fromBinary(args); - const request = pb.BilateralPrepareRequest.fromBinary(argPack.body); - expect(request.counterpartyDeviceId).toEqual(to); - expect(request.transferAmountDisplay).toBe('1'); - expect(request.tokenIdHint).toBe('ERA'); - expect(request.memoHint).toBe(''); - expect(request.bleAddress).toBe(''); + expect(argPack.body).toEqual(new pb.OfflineTransferRequest({ + counterpartyDeviceId: to, + tokenId: 'ERA', + amount: '1', + memo: '', + }).toBinary()); return prepareResponseBytes(commitmentHash); }; @@ -93,29 +95,43 @@ describe('offlineSend', () => { await expect(promise).resolves.toEqual(expect.objectContaining({ accepted: true })); }); - test('passes provided BLE address to wallet.sendOffline', async () => { - const to = new Uint8Array(32).fill(0x33); - const bleAddress = 'AA:BB:CC:DD:EE:FF'; - const commitmentHash = new Uint8Array(32).fill(0x55); + // A transport error is liveness, never a failed transfer: the frame Kotlin + // raises for a failed connection names no step and may be about another + // peer. The send used to report any such frame as its own failure. + test("a BLE transport error fails no send; the send ends on Rust's word", async () => { + const to = new Uint8Array(32).fill(0x66); + const commitmentHash = new Uint8Array(32).fill(0x67); (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { - const { route, args } = decodeRouterInvoke(reqBytes); + const { route } = decodeRouterInvoke(reqBytes); expect(route).toBe('wallet.sendOffline'); - const request = pb.BilateralPrepareRequest.fromBinary(pb.ArgPack.fromBinary(args).body); - expect(request.counterpartyDeviceId).toEqual(to); - expect(request.bleAddress).toBe(bleAddress); return prepareResponseBytes(commitmentHash); }; - const promise = dsm.offlineSend({ to, amount: 7n, tokenId: 'ERA', bleAddress }); + let settled = false; + const promise = dsm.offlineSend({ to, amount: 1n, tokenId: 'ERA' }); + void promise.then(() => { settled = true; }); await new Promise((resolve) => setTimeout(resolve, 0)); + + emit('ble.envelope.bin', frameEnvelope(new pb.Envelope({ + version: 3, + payload: { + case: 'dsmBtMessage', + value: new pb.DsmBtMessage({ + messageType: pb.BtMessageType.BTMSG_TYPE_ERROR, + payload: new Uint8Array(new pb.BleTransactionError({ errorCode: 133, message: 'connect failed' }).toBinary()), + }), + }, + }))); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(settled).toBe(false); + emit('bilateral.event', new pb.BilateralEventNotification({ eventType: pb.BilateralEventType.BILATERAL_EVENT_TRANSFER_COMPLETE, commitmentHash, status: 'completed', message: 'done', }).toBinary()); - await expect(promise).resolves.toEqual(expect.objectContaining({ accepted: true })); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts index 440c218db..c20b38546 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts @@ -47,9 +47,8 @@ describe('offline transfer sender/recipient consistency through WebView bridge', // The token is forwarded exactly as chosen: Rust canonicalizes it // (`canonicalize_token_id`) and refuses one that names nothing. - test('an offline send reaches wallet.sendOffline with the token and memo hints as given', async () => { + test('an offline send reaches wallet.sendOffline with the token and memo as given', async () => { const to = new Uint8Array(32).fill(0xcc); - const bleAddress = 'AA:BB:CC:DD:EE:FF'; const commitmentHash = new Uint8Array(32).fill(0x77); (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { @@ -57,12 +56,11 @@ describe('offline transfer sender/recipient consistency through WebView bridge', expect(route).toBe('wallet.sendOffline'); const argPack = pb.ArgPack.fromBinary(args); - const prepare = pb.BilateralPrepareRequest.fromBinary(argPack.body); - expect(prepare.counterpartyDeviceId).toEqual(to); - expect(prepare.transferAmountDisplay).toBe('5'); - expect(prepare.bleAddress).toBe(bleAddress); - expect(prepare.tokenIdHint).toBe('DBTC'); - expect(prepare.memoHint).toBe('hi'); + const request = pb.OfflineTransferRequest.fromBinary(argPack.body); + expect(request.counterpartyDeviceId).toEqual(to); + expect(request.amount).toBe('5'); + expect(request.tokenId).toBe('DBTC'); + expect(request.memo).toBe('hi'); const env = new pb.Envelope({ version: 3, @@ -76,7 +74,7 @@ describe('offline transfer sender/recipient consistency through WebView bridge', return wrapSuccessEnvelope(frameEnvelope(env)); }; - const promise = dsm.offlineSend({ to, amount: 5n, tokenId: 'DBTC', bleAddress, memo: 'hi' } as any); + const promise = dsm.offlineSend({ to, amount: 5n, tokenId: 'DBTC', memo: 'hi' }); await new Promise((resolve) => setTimeout(resolve, 0)); emit('bilateral.event', new pb.BilateralEventNotification({ eventType: pb.BilateralEventType.BILATERAL_EVENT_TRANSFER_COMPLETE, @@ -88,16 +86,14 @@ describe('offline transfer sender/recipient consistency through WebView bridge', await expect(promise).resolves.toEqual(expect.objectContaining({ accepted: true })); }); - test('dBTC lowercase canonical token hint stays canonical', async () => { + test('dBTC lowercase canonical token stays canonical', async () => { const to = new Uint8Array(32).fill(0xdd); - const bleAddress = 'AA:BB:CC:DD:EE:11'; const commitmentHash = new Uint8Array(32).fill(0x33); (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { args } = decodeRouterInvoke(reqBytes); const argPack = pb.ArgPack.fromBinary(args); - const prepare = pb.BilateralPrepareRequest.fromBinary(argPack.body); - expect(prepare.tokenIdHint).toBe('dBTC'); + expect(pb.OfflineTransferRequest.fromBinary(argPack.body).tokenId).toBe('dBTC'); const env = new pb.Envelope({ version: 3, @@ -111,7 +107,7 @@ describe('offline transfer sender/recipient consistency through WebView bridge', return wrapSuccessEnvelope(frameEnvelope(env)); }; - const promise = dsm.offlineSend({ to, amount: 7n, tokenId: 'dBTC', bleAddress, memo: 'ok' } as any); + const promise = dsm.offlineSend({ to, amount: 7n, tokenId: 'dBTC', memo: 'ok' }); await new Promise((resolve) => setTimeout(resolve, 0)); emit('bilateral.event', new pb.BilateralEventNotification({ eventType: pb.BilateralEventType.BILATERAL_EVENT_TRANSFER_COMPLETE, diff --git a/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts b/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts index b1b2315ba..c6ad2ae6e 100644 --- a/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts @@ -3,11 +3,9 @@ import { createGenesisViaRouter, rejectBilateralByCommitmentBridge, - setBleIdentityForAdvertising, } from "../WebViewBridge"; import { BilateralPayload, - BleIdentityPayload, BridgeRpcRequest, BridgeRpcResponse, Envelope, @@ -78,27 +76,6 @@ describe("protobuf-only bridge payloads", () => { // No silicon / no random entropy: the mnemonic is the sole genesis root. }); - test("setBleIdentityForAdvertising sends BleIdentityPayload", async () => { - let seenMethod = ""; - let seenPayload: Uint8Array | undefined; - - setupBridge((req) => { - seenMethod = req.method; - seenPayload = req.payload.case === "bytes" ? req.payload.value.data : new Uint8Array(0); - }); - - const genesis = new Uint8Array(32).fill(0xaa); - const deviceId = new Uint8Array(32).fill(0xbb); - await setBleIdentityForAdvertising(genesis, deviceId); - - expect(seenMethod).toBe("setBleIdentityForAdvertising"); - expect(seenPayload).toBeInstanceOf(Uint8Array); - - const decoded = BleIdentityPayload.fromBinary(seenPayload as Uint8Array); - expect(decoded.genesisHash).toEqual(genesis); - expect(decoded.deviceId).toEqual(deviceId); - }); - test("rejectBilateralByCommitmentBridge sends BilateralPayload", async () => { let seenMethod = ""; let seenPayload: BilateralPayload | undefined; diff --git a/dsm_client/frontend/src/dsm/__tests__/resolution.test.ts b/dsm_client/frontend/src/dsm/__tests__/resolution.test.ts deleted file mode 100644 index ece48ed09..000000000 --- a/dsm_client/frontend/src/dsm/__tests__/resolution.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import { encodeBase32Crockford } from '../../utils/textId'; - -jest.mock('../../bridge/bridgeEvents', () => ({ - bridgeEvents: { emit: jest.fn(), on: jest.fn(() => jest.fn()) }, -})); -jest.mock('../WebViewBridge', () => ({ - resolveBleAddressForDeviceIdBridge: jest.fn(), -})); - -import { normalizeBleAddress } from '../resolution'; - -describe('normalizeBleAddress', () => { - it('returns uppercase colon-separated form for valid colon address', () => { - expect(normalizeBleAddress('aa:bb:cc:dd:ee:ff')).toBe('AA:BB:CC:DD:EE:FF'); - }); - - it('passes through already-uppercase colon address', () => { - expect(normalizeBleAddress('11:22:33:44:55:66')).toBe('11:22:33:44:55:66'); - }); - - it('converts contiguous 12-hex to colon-separated uppercase', () => { - expect(normalizeBleAddress('aabbccddeeff')).toBe('AA:BB:CC:DD:EE:FF'); - }); - - it('handles uppercase contiguous hex', () => { - expect(normalizeBleAddress('AABBCCDDEEFF')).toBe('AA:BB:CC:DD:EE:FF'); - }); - - it('handles mixed-case contiguous hex', () => { - expect(normalizeBleAddress('aAbBcCdDeEfF')).toBe('AA:BB:CC:DD:EE:FF'); - }); - - it('returns undefined for empty string', () => { - expect(normalizeBleAddress('')).toBeUndefined(); - }); - - it('returns undefined for whitespace-only string', () => { - expect(normalizeBleAddress(' ')).toBeUndefined(); - }); - - it('returns undefined for non-string input', () => { - expect(normalizeBleAddress(123 as unknown as string)).toBeUndefined(); - }); - - it('returns undefined for too-short hex', () => { - expect(normalizeBleAddress('aabbcc')).toBeUndefined(); - }); - - it('returns undefined for too-long hex', () => { - expect(normalizeBleAddress('aabbccddeeff00')).toBeUndefined(); - }); - - it('returns undefined for partial colon format', () => { - expect(normalizeBleAddress('AA:BB:CC')).toBeUndefined(); - }); - - it('returns undefined for invalid hex characters', () => { - expect(normalizeBleAddress('GG:HH:II:JJ:KK:LL')).toBeUndefined(); - }); - - it('trims leading/trailing whitespace', () => { - expect(normalizeBleAddress(' aa:bb:cc:dd:ee:ff ')).toBe('AA:BB:CC:DD:EE:FF'); - }); - - it('returns undefined for colon format with wrong byte count', () => { - expect(normalizeBleAddress('AA:BB:CC:DD:EE')).toBeUndefined(); - }); - - it('returns undefined for colon format with extra byte', () => { - expect(normalizeBleAddress('AA:BB:CC:DD:EE:FF:00')).toBeUndefined(); - }); -}); - -describe('BLE identity cache operations', () => { - // The cache is module state that production never clears; each test gets - // its own copy of the module rather than a setter that existed for tests. - let r: typeof import('../resolution'); - - beforeEach(() => { - jest.isolateModules(() => { - r = require('../resolution'); - }); - }); - - it('starts with an empty snapshot', () => { - const snap = r.getBleIdentitySnapshot(); - expect(snap.deviceIds).toEqual({}); - expect(snap.genesis).toEqual({}); - }); - - it('persistBleMapping stores deviceId mapping', () => { - const devId = new Uint8Array(32).fill(0x01); - r.persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:FF', deviceId: devId }); - const snap = r.getBleIdentitySnapshot(); - const key = encodeBase32Crockford(devId); - expect(snap.deviceIds[key]).toBe('AA:BB:CC:DD:EE:FF'); - }); - - it('persistBleMapping stores genesisHash mapping', () => { - const gen = new Uint8Array(32).fill(0x02); - r.persistBleMapping({ bleAddress: 'aabbccddeeff', genesisHash: gen }); - const snap = r.getBleIdentitySnapshot(); - const key = encodeBase32Crockford(gen); - expect(snap.genesis[key]).toBe('AA:BB:CC:DD:EE:FF'); - }); - - it('persistBleMapping accepts string deviceIdStr', () => { - r.persistBleMapping({ bleAddress: '11:22:33:44:55:66', deviceIdStr: 'MYDEVICE' }); - const snap = r.getBleIdentitySnapshot(); - expect(snap.deviceIds['MYDEVICE']).toBe('11:22:33:44:55:66'); - }); - - it('persistBleMapping ignores invalid bleAddress', () => { - const devId = new Uint8Array(32).fill(0x03); - r.persistBleMapping({ bleAddress: 'invalid', deviceId: devId }); - const snap = r.getBleIdentitySnapshot(); - expect(Object.keys(snap.deviceIds).length).toBe(0); - }); - - it('persistBleMapping ignores empty Uint8Array deviceId', () => { - r.persistBleMapping({ bleAddress: 'AA:BB:CC:DD:EE:FF', deviceId: new Uint8Array(0) }); - const snap = r.getBleIdentitySnapshot(); - expect(Object.keys(snap.deviceIds).length).toBe(0); - }); -}); diff --git a/dsm_client/frontend/src/dsm/contacts.ts b/dsm_client/frontend/src/dsm/contacts.ts index ee9337b48..fcd335798 100644 --- a/dsm_client/frontend/src/dsm/contacts.ts +++ b/dsm_client/frontend/src/dsm/contacts.ts @@ -9,6 +9,15 @@ import { requestBlePermissions as bridgeRequestBlePermissions, } from './WebViewBridge'; import { ContactsList, AddContactArgs, AddContactResult, BilateralRelationshipDTO, ContactCard } from './types'; +import type { ContactPairing } from '../domain/types'; + +const PAIRING: Partial> = { + [pb.ContactPairingPhase.PAIRED]: 'paired', + [pb.ContactPairingPhase.IDLE]: 'idle', + [pb.ContactPairingPhase.SEARCHING]: 'searching', + [pb.ContactPairingPhase.CONNECTED]: 'connected', + [pb.ContactPairingPhase.RETRYING]: 'retrying', +}; /** A contact as contacts.list states it; a contact missing what Rust always writes is refused. */ function mapContactToDTO(c: pb.ContactAddResponse): BilateralRelationshipDTO { @@ -29,6 +38,10 @@ function mapContactToDTO(c: pb.ContactAddResponse): BilateralRelationshipDTO { if (chainTip !== undefined && chainTip.length !== 32) { throw new Error(`STRICT: contacts.list answered a contact with a ${chainTip.length}-byte tip`); } + const pairing = PAIRING[c.pairing]; + if (!pairing) { + throw new Error(`STRICT: contacts.list answered a contact in a pairing phase the wire does not name (${c.pairing})`); + } return { deviceId: c.deviceId, publicKey: c.signingPublicKey, @@ -37,6 +50,7 @@ function mapContactToDTO(c: pb.ContactAddResponse): BilateralRelationshipDTO { chainTip, // The wire's empty string is "no address". bleAddress: c.bleAddress || undefined, + pairing, genesisVerifiedOnline: c.genesisVerifiedOnline, sendStatus: c.sendStatus, }; diff --git a/dsm_client/frontend/src/dsm/index.ts b/dsm_client/frontend/src/dsm/index.ts index e5fec1d14..dcc73caea 100644 --- a/dsm_client/frontend/src/dsm/index.ts +++ b/dsm_client/frontend/src/dsm/index.ts @@ -9,7 +9,6 @@ // // MODULE MAP (kept in sync with the `export * from './'` lines below): // types — TypeScript types for State, Token, Policy, etc. -// resolution — Name/address resolution // identity — Device identity, genesis, pairing // contacts — Contact management (device IDs, metadata) // wallet — Balance queries, transaction history @@ -26,7 +25,7 @@ // CURATED FLAT NAMESPACE EXPORT (`dsmClient`): // `dsmClient` exposes a curated, object-style API combining the modules // that need name-collision-free access: identity, contacts, wallet, -// policies, dlv, storage, transactions, diagnostics, resolution. +// policies, dlv, storage, transactions, diagnostics. // It intentionally OMITS `types` (too generic to flatten safely) and the // bridge-helper re-exports above (imported by name). // @@ -37,9 +36,6 @@ // Export core types export * from './types'; -// Export resolution logic -export * from './resolution'; - // Export domain-specific logic export * from './identity'; export * from './contacts'; @@ -67,7 +63,6 @@ import * as Policies from './policies'; import * as Storage from './storage'; import * as Transactions from './transactions'; import * as Diagnostics from './diagnostics'; -import * as Resolution from './resolution'; // Flat namespace export for consumers that prefer object-style access. export const dsmClient = { @@ -78,5 +73,4 @@ export const dsmClient = { ...Storage, ...Transactions, ...Diagnostics, - ...Resolution, }; diff --git a/dsm_client/frontend/src/dsm/resolution.ts b/dsm_client/frontend/src/dsm/resolution.ts deleted file mode 100644 index df3c97844..000000000 --- a/dsm_client/frontend/src/dsm/resolution.ts +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { encodeBase32Crockford, decodeBase32Crockford } from '../utils/textId'; -import { bridgeEvents } from '../bridge/bridgeEvents'; -import { resolveBleAddressForDeviceIdBridge } from './WebViewBridge'; - - - -// BLE identity mapping cache -const bleIdentityMap = { - byDeviceId: new Map(), - byGenesis: new Map(), -}; - -function base32Key32(bytes: Uint8Array): string { - return encodeBase32Crockford(bytes); -} - -export function normalizeBleAddress(input: string): string | undefined { - if (typeof input !== 'string') return undefined; - const s = input.trim(); - if (!s) return undefined; - // Already a colon-separated 6-byte form. - // eslint-disable-next-line security/detect-unsafe-regex - if (/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/.test(s)) { - return s.toUpperCase(); - } - // Contiguous 12-hex form. - if (/^[0-9a-fA-F]{12}$/.test(s)) { - const parts: string[] = []; - for (let i = 0; i < 12; i += 2) parts.push(s.slice(i, i + 2)); - return parts.join(':').toUpperCase(); - } - return undefined; -} - -export function persistBleMapping(args: { - bleAddress: string; - deviceId?: Uint8Array; - genesisHash?: Uint8Array; - deviceIdStr?: string; - genesisHashStr?: string; -}): void { - const norm = normalizeBleAddress(args.bleAddress); - if (!norm) return; - const dev = args.deviceId instanceof Uint8Array && args.deviceId.length ? args.deviceId : undefined; - const gen = args.genesisHash instanceof Uint8Array && args.genesisHash.length ? args.genesisHash : undefined; - - let devKey: string | undefined = args.deviceIdStr && String(args.deviceIdStr); - let genKey: string | undefined = args.genesisHashStr && String(args.genesisHashStr); - if (!devKey && dev) { - devKey = base32Key32(dev); - } - if (!genKey && gen) { - genKey = base32Key32(gen); - } - if (devKey) { - bleIdentityMap.byDeviceId.set(String(devKey), norm); - } - if (genKey) { - bleIdentityMap.byGenesis.set(String(genKey), norm); - } - - // Emit deterministic mapping event for UI (Base32 Crockford only). - try { - bridgeEvents.emit('contact.bleMapped', { - address: norm, - deviceId: devKey, - genesisHash: genKey, - }); - } catch {} -} - -export function getBleIdentitySnapshot(): { deviceIds: Record; genesis: Record } { - const deviceIds: Record = {}; - const genesis: Record = {}; - for (const [k, v] of bleIdentityMap.byDeviceId.entries()) deviceIds[k] = v; - for (const [k, v] of bleIdentityMap.byGenesis.entries()) genesis[k] = v; - return { deviceIds, genesis }; -} - -// Strict version: single deterministic resolution path -export async function resolveBleAddressForContact(contact: any): Promise { - if (!contact) return undefined; - - const deviceField = contact.deviceId; - const genesisField = contact.genesisHash; - - // 1) Stored directly on contact - const rawAddr = contact.bleAddress; - - const direct = normalizeBleAddress(String(rawAddr || '')); - if (direct) { - persistBleMapping({ - bleAddress: direct, - deviceId: deviceField instanceof Uint8Array ? deviceField : undefined, - genesisHash: genesisField instanceof Uint8Array ? genesisField : undefined, - deviceIdStr: typeof deviceField === 'string' ? deviceField : undefined, - genesisHashStr: typeof genesisField === 'string' ? genesisField : undefined, - }); - return direct; - } - - // 2) Resolve via Bridge - const devBytes: Uint8Array | undefined = (() => { - if (deviceField instanceof Uint8Array) return deviceField; - if (typeof deviceField === 'string') { - try { - return decodeBase32Crockford(deviceField); - } catch { - return undefined; - } - } - return undefined; - })(); - - if (!devBytes || devBytes.length !== 32) return undefined; - - const cached = bleIdentityMap.byDeviceId.get(base32Key32(devBytes)); - if (cached) return cached; - - const nativeAddr = await resolveBleAddressForDeviceIdBridge(devBytes); - const norm = normalizeBleAddress(String(nativeAddr || '')); - if (norm) { - persistBleMapping({ - bleAddress: norm, - deviceId: devBytes, - genesisHash: genesisField instanceof Uint8Array ? genesisField : undefined, - genesisHashStr: typeof genesisField === 'string' ? genesisField : undefined, - }); - return norm; - } - return undefined; -} diff --git a/dsm_client/frontend/src/dsm/transactions.ts b/dsm_client/frontend/src/dsm/transactions.ts index 83897f3ba..0d4f48464 100644 --- a/dsm_client/frontend/src/dsm/transactions.ts +++ b/dsm_client/frontend/src/dsm/transactions.ts @@ -11,17 +11,11 @@ import { cancelBilateralByCommitmentBridge, rejectBilateralByCommitmentBridge, getPendingBilateralListStrictBridge, - setBleIdentityForAdvertising, - startBleAdvertisingViaRouter, - startBleScanViaRouter, - readPeerRelationshipStatusBridge, } from './WebViewBridge'; import { on as eventBridgeOn } from './EventBridge'; import { emitBilateralCommitted } from './events'; import { bridgeEvents } from '../bridge/bridgeEvents'; -import { getHeaders } from './identity'; -import { normalizeBleAddress } from './resolution'; import logger from '../utils/logger'; import { GenericTransaction, GenericTxResponse } from './types'; @@ -57,18 +51,6 @@ function schedulePostAcceptRefreshes(): void { requestAnimationFrame(tick); } -export async function readPeerRelationshipStatus( - bleAddress: string, -): Promise { - const normalized = normalizeBleAddress(bleAddress); - if (!normalized) return null; - const bytes = await readPeerRelationshipStatusBridge(normalized); - if (!(bytes instanceof Uint8Array) || bytes.length === 0) { - return null; - } - return pb.BleRelationshipStatusCharValue.fromBinary(bytes); -} - export async function sendOnlineTransferSmart( alias: string, amount: string | number | bigint, @@ -136,24 +118,19 @@ export async function offlineSend(transfer: GenericTransaction): Promise {}); if (resolvePromise) resolvePromise(res); }; @@ -221,7 +195,7 @@ export async function offlineSend(transfer: GenericTransaction): Promise { - try { - const bleEnv = decodeFramedEnvelopeV3(payload as Uint8Array); - const p2: any = bleEnv?.payload ?? bleEnv; - const btMsg = (p2?.case === 'dsmBtMessage' ? p2.value : p2?.dsmBtMessage) as pb.DsmBtMessage | undefined; - if (!btMsg || btMsg.messageType !== pb.BtMessageType.BTMSG_TYPE_ERROR) return; - const err = pb.BleTransactionError.fromBinary(btMsg.payload); - const msg = err?.message || 'BLE transaction error'; - finish({ accepted: false, result: msg }); - } catch { /* ignore */ } - }); - - // --- Ensure BLE advertising + scanning so the receiver can connect back --- - // §2.3-2.4: advertise real genesis hash, not zeros. - try { - const headers = await getHeaders(); - const devId = headers.deviceId; - const genesisHash = headers.genesisHash; - if (devId && devId.length === 32 && genesisHash && genesisHash.length === 32) { - await setBleIdentityForAdvertising(new Uint8Array(genesisHash), new Uint8Array(devId)); - await startBleAdvertisingViaRouter(); - } - await startBleScanViaRouter(); - // Brief pause for BLE stack to settle and peer to discover us - await new Promise(r => setTimeout(r, 1500)); - } catch { - // Best-effort — proceed with send even if BLE priming fails - } + // A BLE transport error is not this send's failure: Kotlin raises one for + // any failed connection, to any peer, and a lost link fails no step. The + // send ends on Rust's word — its events, or its pending list. - // --- Delegate native authoring + BLE dispatch to wallet.sendOffline --- + // --- Native authoring + BLE dispatch: wallet.sendOffline --- + // The radio is native's: it advertises while the appliance has an identity, + // and the dispatch connects (scanning for the peer as it needs) itself. + // This used to set the advertised identity, start advertising and + // scanning, and sleep 1.5 s first, swallowing every failure. const respBytes = await routerInvokeBin('wallet.sendOffline', new Uint8Array(argPack.toBinary())); if (!respBytes || respBytes.length === 0) { finish({ accepted: false, result: 'offlineSend: empty response from bridge' }); @@ -367,14 +320,12 @@ export async function sendOfflineTransfer(params: { to: string | Uint8Array; amount: string | number | bigint; memo?: string; - bleAddress?: string; }): Promise { return offlineSend({ tokenId: params.tokenId, to: params.to, amount: params.amount, memo: params.memo, - bleAddress: params.bleAddress, }); } diff --git a/dsm_client/frontend/src/dsm/types.ts b/dsm_client/frontend/src/dsm/types.ts index ba60f2d3a..ca61a91d6 100644 --- a/dsm_client/frontend/src/dsm/types.ts +++ b/dsm_client/frontend/src/dsm/types.ts @@ -2,6 +2,7 @@ // Lightweight shared types for DSM UI flows and events import * as pb from '../proto/dsm_app_pb'; +import type { ContactPairing } from '../domain/types'; /** * A contact as `contacts.list` states it (pb-aligned, binary). Rust writes the @@ -15,6 +16,7 @@ export interface BilateralRelationshipDTO { /** The relationship's tip, once it has one. */ chainTip?: Uint8Array; // 32 bytes bleAddress?: string; // BLE MAC address for offline bilateral transfers + pairing: ContactPairing; // where BLE pairing stands, as Rust's pairing loop has it genesisVerifiedOnline: boolean; // genesis hash verified via storage node sendStatus?: pb.RelationshipSendStatus; } @@ -27,7 +29,6 @@ export type GenericTransaction = { to: Uint8Array | string; amount: string | number | bigint; memo?: string; - bleAddress?: string; }; /** UI-level response shape returned by offlineSend. */ diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts index 729ce1beb..419b848fa 100644 --- a/dsm_client/frontend/src/proto/dsm_app_pb.ts +++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts @@ -838,6 +838,62 @@ proto3.util.setEnumType(ValueCapabilityV1, "dsm.ValueCapabilityV1", [ { no: 3, name: "VALUE_CAPABILITY_V1_UNKNOWN" }, ]); +/** + * Where BLE pairing with a contact stands, as the SDK's pairing loop has it. + * + * @generated from enum dsm.ContactPairingPhase + */ +export enum ContactPairingPhase { + /** + * @generated from enum value: CONTACT_PAIRING_PHASE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * The contact holds the BLE address pairing confirmed. + * + * @generated from enum value: CONTACT_PAIRING_PHASE_PAIRED = 1; + */ + PAIRED = 1, + + /** + * Not paired, and no pairing session is under way. + * + * @generated from enum value: CONTACT_PAIRING_PHASE_IDLE = 2; + */ + IDLE = 2, + + /** + * A pairing session waits for the appliance to be seen over BLE. + * + * @generated from enum value: CONTACT_PAIRING_PHASE_SEARCHING = 3; + */ + SEARCHING = 3, + + /** + * The appliance was seen; the pairing exchange is under way. + * + * @generated from enum value: CONTACT_PAIRING_PHASE_CONNECTED = 4; + */ + CONNECTED = 4, + + /** + * The last attempt did not complete; the pairing loop tries again. + * + * @generated from enum value: CONTACT_PAIRING_PHASE_RETRYING = 5; + */ + RETRYING = 5, +} +// Retrieve enum metadata with: proto3.getEnumType(ContactPairingPhase) +proto3.util.setEnumType(ContactPairingPhase, "dsm.ContactPairingPhase", [ + { no: 0, name: "CONTACT_PAIRING_PHASE_UNSPECIFIED" }, + { no: 1, name: "CONTACT_PAIRING_PHASE_PAIRED" }, + { no: 2, name: "CONTACT_PAIRING_PHASE_IDLE" }, + { no: 3, name: "CONTACT_PAIRING_PHASE_SEARCHING" }, + { no: 4, name: "CONTACT_PAIRING_PHASE_CONNECTED" }, + { no: 5, name: "CONTACT_PAIRING_PHASE_RETRYING" }, +]); + /** * @generated from enum dsm.SdkEventKind */ @@ -976,26 +1032,6 @@ export enum NativeHostRequestKind { */ HOST_CONTROL_QR_STOP_SCAN = 3, - /** - * @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START = 4; - */ - HOST_CONTROL_BLE_SCAN_START = 4, - - /** - * @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP = 5; - */ - HOST_CONTROL_BLE_SCAN_STOP = 5, - - /** - * @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START = 6; - */ - HOST_CONTROL_BLE_ADVERTISE_START = 6, - - /** - * @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP = 7; - */ - HOST_CONTROL_BLE_ADVERTISE_STOP = 7, - /** * @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START = 8; */ @@ -1034,10 +1070,6 @@ proto3.util.setEnumType(NativeHostRequestKind, "dsm.NativeHostRequestKind", [ { no: 1, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_CAPABILITIES_GET" }, { no: 2, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_START_SCAN" }, { no: 3, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_STOP_SCAN" }, - { no: 4, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START" }, - { no: 5, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP" }, - { no: 6, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START" }, - { no: 7, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP" }, { no: 8, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START" }, { no: 9, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_STOP" }, { no: 10, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_PERMISSIONS_REQUEST" }, @@ -1060,11 +1092,6 @@ export enum NativeHostEventKind { */ QR_SCAN_RESULT = 1, - /** - * @generated from enum value: NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS = 2; - */ - BLUETOOTH_PERMISSIONS = 2, - /** * @generated from enum value: NATIVE_HOST_EVENT_KIND_BIOMETRIC_RESULT = 3; */ @@ -1089,7 +1116,6 @@ export enum NativeHostEventKind { proto3.util.setEnumType(NativeHostEventKind, "dsm.NativeHostEventKind", [ { no: 0, name: "NATIVE_HOST_EVENT_KIND_UNSPECIFIED" }, { no: 1, name: "NATIVE_HOST_EVENT_KIND_QR_SCAN_RESULT" }, - { no: 2, name: "NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS" }, { no: 3, name: "NATIVE_HOST_EVENT_KIND_BIOMETRIC_RESULT" }, { no: 4, name: "NATIVE_HOST_EVENT_KIND_NFC_TAG_READ" }, { no: 5, name: "NATIVE_HOST_EVENT_KIND_NFC_TAG_WRITE" }, @@ -10869,13 +10895,6 @@ export class BilateralPrepareRequest extends Message { */ expectedCounterpartyStateHash?: Hash32; - /** - * BLE MAC address of recipient device - * - * @generated from field: string ble_address = 6; - */ - bleAddress = ""; - /** * Sender's SPHINCS+ SPX256s public key for offline verification * @@ -10897,37 +10916,6 @@ export class BilateralPrepareRequest extends Message { */ senderGenesisHash?: Hash32; - /** - * Transfer intent fields. Rust builds canonical operation_data from these - * when operation_data is empty. - * - * amount in token base units - * - * @generated from field: uint64 transfer_amount = 11; - */ - transferAmount = protoInt64.zero; - - /** - * token ID (e.g. "ERA") - * - * @generated from field: string token_id_hint = 12; - */ - tokenIdHint = ""; - - /** - * optional transfer memo - * - * @generated from field: string memo_hint = 13; - */ - memoHint = ""; - - /** - * decimal display amount; backend scales via token decimals - * - * @generated from field: string transfer_amount_display = 14; - */ - transferAmountDisplay = ""; - /** * Sender's ML-KEM-768 encapsulation key (1184 bytes). The device Kyber keypair is * derived from the wallet master secret ("DSM/kyber\0"). The receiver refuses the @@ -10968,14 +10956,9 @@ export class BilateralPrepareRequest extends Message { { no: 2, name: "operation_data", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, { no: 4, name: "expected_genesis_hash", kind: "message", T: Hash32 }, { no: 5, name: "expected_counterparty_state_hash", kind: "message", T: Hash32 }, - { no: 6, name: "ble_address", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 7, name: "sender_signing_public_key", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, { no: 8, name: "sender_device_id", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, { no: 9, name: "sender_genesis_hash", kind: "message", T: Hash32 }, - { no: 11, name: "transfer_amount", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, - { no: 12, name: "token_id_hint", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 13, name: "memo_hint", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 14, name: "transfer_amount_display", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 16, name: "sender_kyber_public_key", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, { no: 17, name: "sender_kyber_binding_sig", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, { no: 18, name: "sender_signature", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, @@ -13226,52 +13209,6 @@ export class BleGattIdentityReadResult extends Message { - /** - * @generated from field: bytes counterparty_device_id = 1; - */ - counterpartyDeviceId = new Uint8Array(0); - - /** - * @generated from field: dsm.RelationshipSendStatus send_status = 2; - */ - sendStatus?: RelationshipSendStatus; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "dsm.BleRelationshipStatusCharValue"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "counterparty_device_id", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, - { no: 2, name: "send_status", kind: "message", T: RelationshipSendStatus }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): BleRelationshipStatusCharValue { - return new BleRelationshipStatusCharValue().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): BleRelationshipStatusCharValue { - return new BleRelationshipStatusCharValue().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): BleRelationshipStatusCharValue { - return new BleRelationshipStatusCharValue().fromJsonString(jsonString, options); - } - - static equals(a: BleRelationshipStatusCharValue | PlainMessage | undefined, b: BleRelationshipStatusCharValue | PlainMessage | undefined): boolean { - return proto3.util.equals(BleRelationshipStatusCharValue, a, b); - } -} - /** * Response from processIncomingBleData JNI call. * Rust decides internally whether to route as chunk or Envelope v3, runs any @@ -20134,6 +20071,13 @@ export class ContactAddResponse extends Message { */ sendStatus?: RelationshipSendStatus; + /** + * Where BLE pairing with the contact stands; set on contacts.list. + * + * @generated from field: dsm.ContactPairingPhase pairing = 14; + */ + pairing = ContactPairingPhase.UNSPECIFIED; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -20152,6 +20096,7 @@ export class ContactAddResponse extends Message { { no: 11, name: "ble_address", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 12, name: "signing_public_key", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, { no: 13, name: "send_status", kind: "message", T: RelationshipSendStatus }, + { no: 14, name: "pairing", kind: "enum", T: proto3.getEnumType(ContactPairingPhase) }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ContactAddResponse { @@ -20741,6 +20686,69 @@ export class OnlineTransferSmartRequest extends Message { + /** + * @generated from field: bytes counterparty_device_id = 1; + */ + counterpartyDeviceId = new Uint8Array(0); + + /** + * as the user chose it; the SDK canonicalizes it and refuses none + * + * @generated from field: string token_id = 2; + */ + tokenId = ""; + + /** + * decimal, as the user typed it; the SDK scales it by the token's decimals + * + * @generated from field: string amount = 3; + */ + amount = ""; + + /** + * @generated from field: string memo = 4; + */ + memo = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "dsm.OfflineTransferRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "counterparty_device_id", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + { no: 2, name: "token_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "amount", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "memo", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): OfflineTransferRequest { + return new OfflineTransferRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): OfflineTransferRequest { + return new OfflineTransferRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): OfflineTransferRequest { + return new OfflineTransferRequest().fromJsonString(jsonString, options); + } + + static equals(a: OfflineTransferRequest | PlainMessage | undefined, b: OfflineTransferRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(OfflineTransferRequest, a, b); + } +} + /** * @generated from message dsm.OnlineTransferResponse */ @@ -22964,12 +22972,6 @@ export class BridgeRpcRequest extends Message { */ value: BleAddressPayload; case: "bleAddress"; - } | { - /** - * @generated from field: dsm.BleIdentityPayload ble_identity = 10; - */ - value: BleIdentityPayload; - case: "bleIdentity"; } | { /** * @generated from field: dsm.BilateralPayload bilateral = 11; @@ -22994,7 +22996,6 @@ export class BridgeRpcRequest extends Message { { no: 6, name: "app_router", kind: "message", T: AppRouterPayload, oneof: "payload" }, { no: 8, name: "ble_contact", kind: "message", T: BleContactPayload, oneof: "payload" }, { no: 9, name: "ble_address", kind: "message", T: BleAddressPayload, oneof: "payload" }, - { no: 10, name: "ble_identity", kind: "message", T: BleIdentityPayload, oneof: "payload" }, { no: 11, name: "bilateral", kind: "message", T: BilateralPayload, oneof: "payload" }, ]); @@ -23332,49 +23333,6 @@ export class BleAddressPayload extends Message { } } -/** - * @generated from message dsm.BleIdentityPayload - */ -export class BleIdentityPayload extends Message { - /** - * @generated from field: bytes genesis_hash = 1; - */ - genesisHash = new Uint8Array(0); - - /** - * @generated from field: bytes device_id = 2; - */ - deviceId = new Uint8Array(0); - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "dsm.BleIdentityPayload"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "genesis_hash", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, - { no: 2, name: "device_id", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): BleIdentityPayload { - return new BleIdentityPayload().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): BleIdentityPayload { - return new BleIdentityPayload().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): BleIdentityPayload { - return new BleIdentityPayload().fromJsonString(jsonString, options); - } - - static equals(a: BleIdentityPayload | PlainMessage | undefined, b: BleIdentityPayload | PlainMessage | undefined): boolean { - return proto3.util.equals(BleIdentityPayload, a, b); - } -} - /** * @generated from message dsm.BilateralPayload */ diff --git a/dsm_client/frontend/src/stores/contactsStore.ts b/dsm_client/frontend/src/stores/contactsStore.ts index 4b6c72734..1630347ac 100644 --- a/dsm_client/frontend/src/stores/contactsStore.ts +++ b/dsm_client/frontend/src/stores/contactsStore.ts @@ -88,10 +88,8 @@ class ContactsStore { const data = await awaitWithFrameBudget(dsmClient.getContacts()); // Rust's list as it stands, in the one contact shape every screen reads - // — each contact with its send-readiness, and the BLE address Rust holds - // or the native side resolved for its device this session. An address - // Rust no longer holds is not kept. - const contacts = mapContactList(data.contacts, dsmClient.getBleIdentitySnapshot()); + // — each contact with its send-readiness and the BLE address Rust holds. + const contacts = mapContactList(data.contacts); if (seq === this.refreshSeq) { this.setState({ contacts }); diff --git a/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts b/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts index 2c979a083..f2e6b25f9 100644 --- a/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts +++ b/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts @@ -90,6 +90,7 @@ describe('E2E: Offline BLE exchange -> wallet refresh', () => { genesisHash: new pb.Hash32({ v: BOB_GENESIS } as any), chainTip: new pb.Hash32({ v: BOB_TIP } as any), bleAddress: 'AA:BB:CC:DD:EE:FF', + pairing: pb.ContactPairingPhase.PAIRED, }, ], } as any); @@ -133,7 +134,7 @@ describe('E2E: Offline BLE exchange -> wallet refresh', () => { }; // Start offline send - const offlineSendPromise = offlineSend({ to: base32CrockfordEncode(recipient), amount: '1', tokenId: 'ERA', bleAddress: 'AA:BB:CC:DD:EE:FF' }); + const offlineSendPromise = offlineSend({ to: base32CrockfordEncode(recipient), amount: '1', tokenId: 'ERA' }); // Emit TRANSFER_COMPLETE event to resolve offlineSend await new Promise((r) => setTimeout(r, 0)); diff --git a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts index 3de8c1ed1..14d1bb94d 100644 --- a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts +++ b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts @@ -98,6 +98,7 @@ function makeContactsFramedEnvelope(bleAddress?: string): Uint8Array { genesisHash: new pb.Hash32({ v: COUNTERPARTY_GENESIS }), chainTip: new pb.Hash32({ v: COUNTERPARTY_TIP }), bleAddress: bleAddress || 'AA:BB:CC:DD:EE:FF', + pairing: pb.ContactPairingPhase.PAIRED, } as any); const resp = new pb.ContactsListResponse({ contacts: [contact] }); const env = new pb.Envelope({ @@ -179,10 +180,6 @@ function installBridge(opts?: { contactBleAddress?: string }) { return wrapSuccess(new Uint8Array(0)); } - if (method === 'resolveBleAddressForDeviceId') { - return wrapSuccess(new TextEncoder().encode('AA:BB:CC:DD:EE:FF')); - } - if (method === 'nativeBoundaryIngress') { const ingress = pb.IngressRequest.fromBinary(payload); if (ingress.operation.case === 'routerQuery') { @@ -443,7 +440,6 @@ describe('Offline Transfer — Full Cycle', () => { to: DEVICE_B, amount: BigInt(10000 + testIndex), tokenId: 'ERA', - bleAddress: 'AA:BB:CC:DD:EE:FF', } as any); // Let offlineSend register event listeners (async bridge calls) @@ -462,14 +458,13 @@ describe('Offline Transfer — Full Cycle', () => { expect(res.accepted).toBe(true); }); - test('CRITICAL: BilateralPrepareRequest fields sent to bridge are correct', async () => { - // Verifies the TS-side fields in the BilateralPrepareRequest that offlineSend - // constructs and sends via routerInvokeBin('wallet.sendOffline', ArgPack). - // The Rust layer adds senderSigningPublicKey/senderDeviceId/senderGenesisHash - // before BLE transmission — those are NOT set by the TS side. - let capturedPrepReq: pb.BilateralPrepareRequest | null = null; + test('CRITICAL: the offline send request carries what the user asked for', async () => { + // offlineSend sends the user's intent via routerInvokeBin('wallet.sendOffline', + // ArgPack): the counterparty, token, amount and memo. Rust resolves where the + // counterparty's appliance is and authors the prepare it sends over BLE. + let capturedPrepReq: pb.OfflineTransferRequest | null = null; - // Intercept nativeBoundaryIngress to capture the ArgPack → BilateralPrepareRequest + // Intercept nativeBoundaryIngress to capture the ArgPack → OfflineTransferRequest const origCallBin = (global as any).window.DsmBridge.sendMessageBin; (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { method, payload } = decodeBridgeReq(reqBytes); @@ -478,7 +473,7 @@ describe('Offline Transfer — Full Cycle', () => { if (ingress.operationCase === 'routerInvoke' && ingress.method === 'wallet.sendOffline') { try { const argPack = pb.ArgPack.fromBinary(ingress.args); - capturedPrepReq = pb.BilateralPrepareRequest.fromBinary(argPack.body); + capturedPrepReq = pb.OfflineTransferRequest.fromBinary(argPack.body); } catch { // fall through } @@ -491,7 +486,6 @@ describe('Offline Transfer — Full Cycle', () => { to: DEVICE_B, amount: BigInt(11000 + testIndex), tokenId: 'ERA', - bleAddress: 'AA:BB:CC:DD:EE:FF', } as any); await new Promise(r => setTimeout(r, 100)); @@ -504,12 +498,12 @@ describe('Offline Transfer — Full Cycle', () => { const res = await promise; expect(res.accepted).toBe(true); - // Verify the BilateralPrepareRequest that TS sends to the Rust layer + // Verify the OfflineTransferRequest that TS sends to the Rust layer expect(capturedPrepReq).not.toBeNull(); expect(capturedPrepReq!.counterpartyDeviceId).toHaveLength(32); expect(capturedPrepReq!.counterpartyDeviceId[0]).toBe(0xBB); // matches DEVICE_B - expect(capturedPrepReq!.bleAddress).toBe('AA:BB:CC:DD:EE:FF'); - expect(capturedPrepReq!.transferAmountDisplay).toBe(String(11000 + testIndex)); + expect(capturedPrepReq!.tokenId).toBe('ERA'); + expect(capturedPrepReq!.amount).toBe(String(11000 + testIndex)); }); test('BILATERAL_EVENT_REJECTED event → accepted=false', async () => { @@ -517,7 +511,6 @@ describe('Offline Transfer — Full Cycle', () => { to: DEVICE_B, amount: BigInt(12000 + testIndex), tokenId: 'ERA', - bleAddress: 'AA:BB:CC:DD:EE:FF', } as any); await new Promise(r => setTimeout(r, 100)); @@ -556,7 +549,6 @@ describe('Offline Transfer — Full Cycle', () => { to: DEVICE_B, amount: BigInt(13000 + testIndex), tokenId: 'ERA', - bleAddress: 'AA:BB:CC:DD:EE:FF', } as any); await new Promise(r => setTimeout(r, 100)); @@ -574,44 +566,24 @@ describe('Offline Transfer — Full Cycle', () => { expect(String(res.result)).toMatch(/failed/i); }); - test('missing BLE address with no resolution → error', async () => { - // Override bridge: when wallet.sendOffline is called with an empty bleAddress, - // the Rust layer rejects with a bilateralPrepareReject error. - const origCallBin = (global as any).window.DsmBridge.sendMessageBin; - (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { - const { method, payload } = decodeBridgeReq(reqBytes); - if (method === 'nativeBoundaryIngress') { - const ingress = decodeIngressReq(payload); - if (ingress.operationCase === 'routerInvoke' && ingress.method === 'wallet.sendOffline') { - // Check if bleAddress is empty in the request - try { - const argPack = pb.ArgPack.fromBinary(ingress.args); - const prep = pb.BilateralPrepareRequest.fromBinary(argPack.body); - if (!prep.bleAddress) { - const reject = new pb.BilateralPrepareReject({ reason: 'bleAddress unavailable' } as any); - const env = new pb.Envelope({ - version: 3, - payload: { case: 'bilateralPrepareReject', value: reject }, - } as any); - return wrapIngressOk(frameEnvelope(env)); - } - } catch { - // ignore, let original handler run - } - } - } - return origCallBin(reqBytes); - }; + // Where the counterparty's appliance is over BLE is Rust's to know. An appliance it + // has not met is its refusal, shown in its words; the frontend neither + // resolves an address nor refuses first. + test('a send to an appliance Rust has not met over BLE is Rust\'s refusal, in its words', async () => { + const refusal = 'wallet.sendOffline: no BLE address is known for the counterparty: the appliances have not met over BLE'; + bilateralResponseOverride = () => frameEnvelope(new pb.Envelope({ + version: 3, + payload: { case: 'error', value: new pb.Error({ code: 1, message: refusal }) }, + } as any)); const res = await dsm.offlineSend({ to: DEVICE_B, amount: BigInt(14000 + testIndex), tokenId: 'ERA', - // no bleAddress provided - } as any); + }); expect(res.accepted).toBe(false); - expect(String(res.result)).toMatch(/ble|unavailable|rejected/i); + expect(res.result).toBe(`offlineSend: ${refusal}`); }, 15000); }); @@ -626,7 +598,6 @@ describe('Offline Transfer — Proto Constraints', () => { operationData: new Uint8Array(100) as any, expectedGenesisHash: new pb.Hash32({ v: COUNTERPARTY_GENESIS } as any), expectedCounterpartyStateHash: new pb.Hash32({ v: COUNTERPARTY_TIP } as any), - bleAddress: 'AA:BB:CC:DD:EE:FF', senderSigningPublicKey: SIGNING_KEY as any, senderDeviceId: DEVICE_A as any, senderGenesisHash: new pb.Hash32({ v: GENESIS_A } as any), @@ -646,8 +617,6 @@ describe('Offline Transfer — Proto Constraints', () => { expect(decoded.expectedGenesisHash?.v).toHaveLength(32); expect(decoded.expectedCounterpartyStateHash?.v).toHaveLength(32); expect(decoded.senderGenesisHash?.v).toHaveLength(32); - // BLE address - expect(decoded.bleAddress).toBe('AA:BB:CC:DD:EE:FF'); }); test('canonical encoding is deterministic (same input = same bytes)', () => { @@ -702,7 +671,6 @@ describe('Offline Transfer — Timeout & Event Matching', () => { to: DEVICE_B, amount: BigInt(15000 + testIndex), tokenId: 'ERA', - bleAddress: 'AA:BB:CC:DD:EE:FF', } as any); void promise.then(() => { settled = true; }); @@ -736,7 +704,6 @@ describe('Offline Transfer — Timeout & Event Matching', () => { to: DEVICE_B, amount: BigInt(18000 + testIndex), tokenId: 'ERA', - bleAddress: 'AA:BB:CC:DD:EE:FF', } as any); // The pending list never names the step as ended, past every poll. await jest.advanceTimersByTimeAsync(1_500 + 3_000 * 41); @@ -750,7 +717,7 @@ describe('Offline Transfer — Timeout & Event Matching', () => { }); test('a send that names no token reaches Rust naming none, and Rust refuses it', async () => { - let captured: pb.BilateralPrepareRequest | null = null; + let captured: pb.OfflineTransferRequest | null = null; bilateralResponseOverride = () => frameEnvelope(new pb.Envelope({ version: 3, payload: { case: 'error', value: new pb.Error({ code: 1, message: 'wallet.sendOffline: the request names no token' }) }, @@ -761,7 +728,7 @@ describe('Offline Transfer — Timeout & Event Matching', () => { if (method === 'nativeBoundaryIngress') { const ingress = decodeIngressReq(payload); if (ingress.operationCase === 'routerInvoke' && ingress.method === 'wallet.sendOffline') { - captured = pb.BilateralPrepareRequest.fromBinary(pb.ArgPack.fromBinary(ingress.args).body); + captured = pb.OfflineTransferRequest.fromBinary(pb.ArgPack.fromBinary(ingress.args).body); } } return origCallBin(reqBytes); @@ -771,11 +738,10 @@ describe('Offline Transfer — Timeout & Event Matching', () => { to: DEVICE_B, amount: BigInt(19000 + testIndex), tokenId: '', - bleAddress: 'AA:BB:CC:DD:EE:FF', } as any); expect(captured).not.toBeNull(); - expect(captured!.tokenIdHint).toBe(''); + expect(captured!.tokenId).toBe(''); expect(res.accepted).toBe(false); expect(String(res.result)).toContain('names no token'); }); @@ -785,7 +751,6 @@ describe('Offline Transfer — Timeout & Event Matching', () => { to: DEVICE_B, amount: BigInt(16000 + testIndex), tokenId: 'ERA', - bleAddress: 'AA:BB:CC:DD:EE:FF', } as any); // Let async bridge calls complete diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index ae61e3858..239545551 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -1622,19 +1622,21 @@ message BilateralPrepareRequest { reserved "validity_iterations"; Hash32 expected_genesis_hash = 4; Hash32 expected_counterparty_state_hash = 5; - string ble_address = 6; // BLE MAC address of recipient device + // The recipient's BLE address, which no receiver read: the sender's SDK + // resolves where a prepare goes. + reserved 6; + reserved "ble_address"; bytes sender_signing_public_key = 7 [(dsm_fixed_len)=64]; // Sender's SPHINCS+ SPX256s public key for offline verification bytes sender_device_id = 8 [(dsm_fixed_len)=32]; // Sender's device ID for relationship binding Hash32 sender_genesis_hash = 9; // Sender's genesis hash for relationship anchor // The relationship tip travels once, as expected_counterparty_state_hash. reserved 10; reserved "sender_chain_tip"; - // Transfer intent fields. Rust builds canonical operation_data from these - // when operation_data is empty. - uint64 transfer_amount = 11; // amount in token base units - string token_id_hint = 12; // token ID (e.g. "ERA") - string memo_hint = 13; // optional transfer memo - string transfer_amount_display = 14; // decimal display amount; backend scales via token decimals + // The send's intent, which reached the SDK on this message: it travels as + // OfflineTransferRequest, and the prepare carries the operation the SDK + // authored from it. + reserved 11 to 14; + reserved "transfer_amount", "token_id_hint", "memo_hint", "transfer_amount_display"; reserved 15; // removed: legacy Safe-7 sender_anchor_identity (fused anchor is pinned out-of-band) // Sender's ML-KEM-768 encapsulation key (1184 bytes). The device Kyber keypair is // derived from the wallet master secret ("DSM/kyber\0"). The receiver refuses the @@ -2055,13 +2057,6 @@ message BleGattIdentityReadResult { bytes peer_genesis_hash = 5 [(dsm_fixed_len)=32]; // Peer's genesisHash } -// Protobuf value carried on the BLE relationship-status GATT characteristic. -// The advertiser computes the status in Rust for the connected peer relationship. -message BleRelationshipStatusCharValue { - bytes counterparty_device_id = 1 [(dsm_fixed_len)=32]; - RelationshipSendStatus send_status = 2; -} - // Response from processIncomingBleData JNI call. // Rust decides internally whether to route as chunk or Envelope v3, runs any // frame-type detection, and returns pre-chunked follow-up bytes for Kotlin to @@ -3104,6 +3099,23 @@ message ContactAddResponse { string ble_address = 11; // BLE MAC address for offline bilateral transfers bytes signing_public_key = 12 [(dsm_fixed_len)=64]; // SPHINCS+ SPX256s public key for bilateral verification RelationshipSendStatus send_status = 13; + // Where BLE pairing with the contact stands; set on contacts.list. + ContactPairingPhase pairing = 14; +} + +// Where BLE pairing with a contact stands, as the SDK's pairing loop has it. +enum ContactPairingPhase { + CONTACT_PAIRING_PHASE_UNSPECIFIED = 0; + // The contact holds the BLE address pairing confirmed. + CONTACT_PAIRING_PHASE_PAIRED = 1; + // Not paired, and no pairing session is under way. + CONTACT_PAIRING_PHASE_IDLE = 2; + // A pairing session waits for the appliance to be seen over BLE. + CONTACT_PAIRING_PHASE_SEARCHING = 3; + // The appliance was seen; the pairing exchange is under way. + CONTACT_PAIRING_PHASE_CONNECTED = 4; + // The last attempt did not complete; the pairing loop tries again. + CONTACT_PAIRING_PHASE_RETRYING = 5; } // ======================== Faucet Operations ========================= @@ -3260,6 +3272,16 @@ message OnlineTransferSmartRequest { string memo = 4; } +// wallet.sendOffline: what the user asked for, and nothing the SDK works out +// itself. The SDK resolves where the counterparty's appliance is over BLE, the +// token's decimals and policy, and authors the operation. +message OfflineTransferRequest { + bytes counterparty_device_id = 1 [(dsm_fixed_len)=32]; + string token_id = 2; // as the user chose it; the SDK canonicalizes it and refuses none + string amount = 3; // decimal, as the user typed it; the SDK scales it by the token's decimals + string memo = 4; +} + message OnlineTransferResponse { bool success = 1; Hash32 transaction_hash = 2; @@ -3689,9 +3711,13 @@ message BridgeRpcRequest { AppRouterPayload app_router = 6; BleContactPayload ble_contact = 8; BleAddressPayload ble_address = 9; - BleIdentityPayload ble_identity = 10; BilateralPayload bilateral = 11; } + // The frontend handed the appliance's own identity to the BLE layer to + // advertise. The identity is Rust's: the GATT server reads it from Rust + // when a peer asks for it. + reserved 10; + reserved "ble_identity"; } message BridgeRpcResponse { @@ -3729,11 +3755,6 @@ message BleAddressPayload { bytes device_id = 1 [(dsm_fixed_len)=32]; } -message BleIdentityPayload { - bytes genesis_hash = 1 [(dsm_fixed_len)=32]; - bytes device_id = 2 [(dsm_fixed_len)=32]; -} - message BilateralPayload { bytes commitment = 1 [(dsm_fixed_len)=32]; optional string reason = 2 [(dsm_max_len)=1024]; // for reject operations @@ -3901,10 +3922,14 @@ enum NativeHostRequestKind { NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_CAPABILITIES_GET = 1; NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_START_SCAN = 2; NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_STOP_SCAN = 3; - NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START = 4; - NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP = 5; - NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START = 6; - NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP = 7; + // When the radio scans and advertises is native policy, not a frontend + // request: advertising follows the identity, scanning follows a send or a + // pairing that needs it. + reserved 4 to 7; + reserved "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START", + "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP", + "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START", + "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP"; NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START = 8; NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_STOP = 9; NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_PERMISSIONS_REQUEST = 10; @@ -3942,7 +3967,10 @@ message NativeHostResponse { enum NativeHostEventKind { NATIVE_HOST_EVENT_KIND_UNSPECIFIED = 0; NATIVE_HOST_EVENT_KIND_QR_SCAN_RESULT = 1; - NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS = 2; + // A Bluetooth permission result is native's to act on (it starts the radio + // the policy wants); the frontend had nothing listening for it. + reserved 2; + reserved "NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS"; NATIVE_HOST_EVENT_KIND_BIOMETRIC_RESULT = 3; NATIVE_HOST_EVENT_KIND_NFC_TAG_READ = 4; NATIVE_HOST_EVENT_KIND_NFC_TAG_WRITE = 5; diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index d5d7a2356..bb1c657a4 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1003,7 +1003,7 @@ Owner request: integrate the frontend with the storage nodes properly, working f | frontend · dsm/wallet.ts · `getAllBalances`; dsm/types.ts · `TokenBalanceView`; domain/mappers.ts · `mapBalanceList`, `toBigint`; domain/types.ts · `DomainBalance` | The decoder named a row with no token "ERA" and gave it ERA's symbol and name, read an absent display amount as "" and absent decimals and amount as 0, and carried the amount twice (base units as text beside `baseUnits`) and the symbol twice. A second mapper rewrote any token id holding a space or a hyphen to ERA — moving that token's balance under ERA's name — and read an unparseable amount as 0. | One strict decoder: a row without the token, symbol, name or display amount Rust writes on every row (`balance.list` enriches each one) is refused by name; the canonical id, anchor, fingerprint and icon are carried when Rust names them and absent otherwise. `mapBalanceList`, `toBigint` and `DomainBalance` deleted. | | frontend · services/dsmClient.ts · `DsmClient`; services/tokenService.ts; dsm/wallet.ts · `getWalletBalance`, `getTokens`, `getToken`; utils/tokenMeta.ts · `presentDisplayAmount`, `presentSignedDisplayAmount` | `services/dsmClient.ts` exported a 400-line `DsmClient` class and re-exported the flat client from `dsm/index`; every screen imported the flat client and only the class's own test constructed the class, so its identity gate and its mapping — and the tests of them — covered code production never ran. `tokenService` was imported by its test alone and answered a token Rust did not list with a balance of 0; `getWalletBalance` answered "0" with no balances; `getTokens` and `getToken` named a symbol-less token ERA. `presentDisplayAmount` printed bare base units where Rust sent no display form — for a 2-decimal token, 100000 where Rust means 1000.00. | Deleted; the module is the re-export. The transfer dialog prints Rust's display form or the base units named as such, and does not call a token the event leaves unnamed ERA or upper-case the one it names. | | frontend · wallet/SendTab.tsx, wallet/OverviewTab.tsx, wallet/hooks/useWalletScreenData.ts, AccountsScreen.tsx, stores/walletStore.ts, contexts/WalletContext.tsx | With no balances the send form offered ERA at a balance of 0, a row Rust never reported, and its labels, confirmation and send fell back to ERA; screens fell back from the symbol to the ticker to ERA and from the display amount to base units or "0". The accounts screen's CPTA panel stated ERA's decimals as 2 from a table in the screen, while Rust renders ERA whole (Core: `decimals = 0`), and decided "zero" by comparing display strings. The wallet store typed its rows as bigint balances while holding strings, read an unparseable amount as 0, keyed an unnamed token "UNKNOWN", and called any balance failure an ERA failure. | Every screen shows Rust's symbol and display amount; with no balances the send form says so and cannot send. The panel's decimals are Rust's for the token, and zero is the base units being zero. The store holds the rows `getAllBalances` returns and detects credits on their base units. | -| frontend · dsm/transactions.ts · `canonicalizeTransferTokenId`, `offlineSend`, `sendOfflineTransfer`; wallet/SendTab.tsx; tour/practiceMode.ts | The frontend canonicalized the token of every send and named an omitted one ERA before Rust saw the request, undoing Rust's refusal of a send that names no token (§6.28). When the screen stopped waiting for an offline step, after about two minutes, it reported "did not complete in time" as a failed send, while the step stayed open on both phones and completed when they met again (#1000). The practice mode answered sends in shapes the real calls do not (`success` where the real offline send answers `accepted`), so the send screen read both, and it too named an omitted token ERA. | The token reaches Rust exactly as the user chose it; Rust canonicalizes it and refuses one that names nothing. The screen reports an open step as open ("Not finished yet", a neutral notice), never as a failure; it is under Pending transfers, where its proposer may cancel it while unconfirmed. The send screen reads the one response shape, and the practice mode answers in the real shapes and refuses a send naming no token as Rust does. | +| frontend · dsm/transactions.ts · `canonicalizeTransferTokenId`, `offlineSend`, `sendOfflineTransfer`; wallet/SendTab.tsx; tour/practiceMode.ts | The frontend canonicalized the token of every send and named an omitted one ERA before Rust saw the request, undoing Rust's refusal of a send that names no token (§6.28). When the screen stopped waiting for an offline step, after about two minutes, it reported "did not complete in time" as a failed send, while the step stayed open on both appliances and completed when they met again (#1000). The practice mode answered sends in shapes the real calls do not (`success` where the real offline send answers `accepted`), so the send screen read both, and it too named an omitted token ERA. | The token reaches Rust exactly as the user chose it; Rust canonicalizes it and refuses one that names nothing. The screen reports an open step as open ("Not finished yet", a neutral notice), never as a failure; it is under Pending transfers, where its proposer may cancel it while unconfirmed. The send screen reads the one response shape, and the practice mode answers in the real shapes and refuses a send naming no token as Rust does. | | frontend · dsm/transactions.ts · `sendOnlineTransfer` | A `wallet.send` wrapper no screen called (the send screen uses `wallet.sendSmart`); it sent an empty signature and nonce and named an omitted token ERA. Its tests — and two that compared an object the test built with itself — covered a path the UI never takes. | Deleted with those tests. The E2E proof's response cases (success, inner failure, error envelope, wrong payload) run against `wallet.sendSmart`, and `sendOnlineTransferSmart` has tests that call it. The Rust route stays; the SDK uses it. | | frontend · DevPolicyScreen.tsx, dsm/policies.ts · `publishTokenPolicy`; `dsm_sdk` · handlers/token_routes.rs · `tokens.publishPolicy` | The screen told a developer to paste a `CanonicalPolicy`, pointed at a helper script that does not exist, and offered a hardcoded example granting mint to `dev@example.invalid`; `publishTokenPolicy` "validated" by decoding the bytes as `TokenPolicyV3` and re-encoding them — which checks nothing and rewrites the bytes, so another policy's bytes were published under another anchor — and documented a local fallback. Rust published any bytes under a policy anchor, never asking Core's parser. An orphaned doc comment still described a mint. | Rust refuses bytes Core's one policy parser does not accept, before anything is kept or sent; the frontend sends the pasted bytes exactly and shows Rust's answer. The screen asks for `TokenPolicyV3` bytes; the example, its button and the instructions are deleted, and the copy says a token's whole supply exists at creation. | | frontend · dsm/transactions.ts · `claimFaucet`, `claimTestnetFaucet`, `getLocalDeviceId`, `getLocalDeviceIdAsync`, `getLocalSigningPublicKey`, `getLocalSigningPublicKeyAsync`; `EraFaucetScreen`; AccountsScreen faucet tab; tour/practiceMode.ts | `claimFaucet` took a policy id it ignored, answered "Faucet claim ok" in place of Rust's message and a constant `humanScaled: true`, reported a received count of 0 on every failure and debug bytes invented on an exception; the faucet tab passed the first balance's token id (or "era") to it and composed its own "Claimed N ERA". `EraFaucetScreen`, rendered nowhere, was the only caller of a second claim function. `getLocalSigningPublicKey` always answered an empty key, and the device-id helpers turned an error into empty bytes; none had a caller that survived. | One `claimFaucet()`: this device claims for itself (Rust refuses a request naming another device) and the tab shows Rust's own words — what it released, or its refusal. The unreachable screen, the second claim function and the helpers are deleted; the practice mode answers in the real shape. | @@ -1030,6 +1030,13 @@ Owner request: integrate the frontend with the storage nodes properly, working f | frontend · stores/contactsStore.ts (`mapContacts`), contexts/ContactsContext.tsx (`Contact`), components/screens/ContactsTabScreen.tsx, wallet/hooks/useWalletScreenData.ts | Two mappers read Rust's contact list into two shapes: the contacts store's `Contact` (`id`, `publicKey`, `isVerified`, the raw BLE address) for the contacts screen, and `mapContactList`'s `DomainContact` (the signing key, `genesisVerifiedOnline`, the normalised BLE address or the one the native side resolved this session, and the send-readiness Rust reports) for the send tab — so the wallet screen kept its own copy of the contacts, re-read from Rust beside the store's. | One shape, `DomainContact`, from the one mapper, in the contacts store; `Contact` and `mapContacts` are deleted; the contacts screen reads `deviceId`, `signingPublicKey` and `genesisVerifiedOnline`; the wallet screen reads the store's contacts and re-reads nothing. A manual refresh reloads both stores. | | frontend · bridge/nativeBridgeAdapter.ts, bridge/bridgeEvents.ts, dsm/EventBridge.ts, dsm/identity.ts (`getIdentity`'s wake-up), dsm/events.ts (`DSM_WALLET_REFRESH_EVENT`), hooks/useWalletSync.ts, contexts/WalletContext.tsx, contexts/ContactsContext.tsx, components/common/LoadingSpinner.tsx | The adapter re-emitted seven DOM events on the bus that nothing ever dispatched (`dsm-history-updated`, `dsm-balances-updated`, `dsm-wallet-send-committed`, `dsm-contact-added`, `DSM_PORT_TX`, `DSM_PORT_RX`, `DSM_UI_TICK`), and `useWalletSync`, the contacts provider and the loading spinner subscribed to the bus events they would have produced — reloads and an activity indicator that could never fire. The native lifecycle topics reached the bus through DOM hops: `dsm-identity-ready` was dispatched on `document`, re-emitted by the adapter, and listened for by `getIdentity`'s early wake-up on `window`, where it never arrived; `dsm-wallet-refresh` and `dsm-env-config-error` likewise went DOM → adapter → bus. `session.state` and `bilateral.event` were also fanned out as DOM events with no listener. | The seven hops, their bus event types, `useWalletSync` (its one live subscription, `identity.ready`, is the wallet provider's own), the contacts provider's `contact.added` subscription and the spinner's activity effect are deleted. The event bridge emits `identity.ready`, `wallet.refresh` (`native`) and `env.config.error` on the bus directly; the adapter keeps only `visibilitychange`; `getIdentity` wakes on the bus event, which now reaches it; `DSM_WALLET_REFRESH_EVENT` and the two listener-less fan-outs are deleted. | | frontend · hooks/useBridgeEvents.ts (`useBridgeEvent`), bridge/bridgeEvents.ts, contexts/UXContext.tsx, contexts/BleContext.tsx, dsm/EventBridge.ts | `useBridgeEvent` took any string, so subscriptions to events nothing can emit compiled: two toasts (`ble.permission.recovery.needed`, `ble.features.disabled`) and a `ble.features.enabled` handler — the last not even a bus event — which together drove a `bleFeaturesDisabled` flag that gated every BLE call and could never be set. Two BLE advertising events were emitted with no consumer left, and `nfc.writeStarted` had neither. | `useBridgeEvent`'s name is `keyof BridgeEventMap`: a subscription to a name the bus does not carry does not compile. The dead subscriptions, the flag and its gate, the two emits and the three event types are deleted — and with the gate, `BleContext` itself: a provider whose context nothing consumed since `useBle` went (its scan state and four no-op calls), mounted in `App` for nothing. `wallet.exitCompleted`, subscribed to in three places and emitted by nothing, is Bitcoin's exit flow and is not touched. | +| Kotlin · service/BleBackgroundService.kt, ui/MainActivity.kt, bridge/NativeHostBridge.kt, bridge/ble/GattServerHost.kt, bridge/ble/BleCoordinator.kt, bridge/UnifiedBleBridge.kt, bridge/BridgeBleHandler.kt, bridge/BridgeEnvelopeCodec.kt, bridge/SinglePathWebViewBridge.kt, debug/PairingTestActivity.kt; `dsm_sdk` · bluetooth/pairing_orchestrator.rs `stop_ble_discovery`; `proto` · `BridgeRpcRequest.ble_identity`, `BleIdentityPayload`, `NativeHostRequestKind` 4–7, `NativeHostEventKind` 2; frontend · dsm/transactions.ts `offlineSend`, contexts/ContactsContext.tsx, EnhancedWalletScreen.tsx, dsm/WebViewBridge/ble.ts, dsm/NativeHostBridge.ts, dsm/EventBridge.ts | The frontend ran the radio. Before each offline send it set the identity the GATT server would serve, started advertising and scanning through host requests and slept 1.5 s, swallowing every failure, and restarted advertising when the send finished. The contacts provider set the identity and started advertising on identity readiness and on each learned BLE address — only once some contact already had an address. The wallet screen started advertising on mount and when shown, and stopped it when hidden and on unmount, so an appliance on any other screen, or with no BLE contact yet, could not be reached. Native never started advertising on a cold start: `onResume` ran before the identity loaded, and a permission grant was handed to the UI as the `BLUETOOTH_PERMISSIONS` host event. The pairing loop stopped advertising when it ended. The GATT identity characteristic served whatever the frontend had pushed last. A debug activity with start and stop advertising buttons shipped in the production manifest. | Advertising follows the identity, and native owns it: the BLE service advertises whenever the appliance has an identity, derived again on the service's own worker thread when the service starts, when it binds, when the activity resumes, once genesis or init has produced the identity, when a Bluetooth permission is granted and when the adapter turns on; it stops only with the service. The GATT server reads the identity characteristic from Rust at read time. The frontend sends no radio request: `offlineSend` is one `wallet.sendOffline` call, whose dispatch connects to the peer and scans as it needs; the screen and the contacts provider render and refresh. Deleted: the four BLE host controls (`NativeHostRequestKind` 4–7 reserved), `NativeHostEventKind.BLUETOOTH_PERMISSIONS` (2 reserved), `BridgeRpcRequest.ble_identity` (10 reserved) with `BleIdentityPayload` and the `setBleIdentityForAdvertising` RPC, `stopBlePairingAdvertise`, `BleCoordinator.setIdentityValue` and the caller-less `ensureBleReady`, the GATT host's pushed identity value, and `PairingTestActivity`. The pairing loop stops only its scan. The native lifecycle is compile-checked, not run: a device run is what shows advertising begins on a cold start, after genesis, on a permission grant and on adapter-on. | +| Kotlin · bridge/ble/GattServerHost.kt, GattClientSession.kt, BleCoordinator.kt, PeerSession.kt, BleSessionEvent.kt, BleConstants.kt, BleScanner.kt, BleSessionMode.kt, bridge/SinglePathWebViewBridge.kt, Unified.kt, UnifiedNativeApi.kt; `dsm_sdk` · jni/ble_events.rs `getRelationshipStatusCharValue`; `proto` · `BleRelationshipStatusCharValue`; frontend · dsm/transactions.ts `readPeerRelationshipStatus`, dsm/WebViewBridge/ble.ts | A relationship-status characteristic on the GATT service answered any connected peer with this appliance's send status for the contact at that address, and a client read of it ran from the frontend's `readPeerRelationshipStatus` through a bridge RPC and a blocking coordinator call. Nothing called the frontend function, so the server served a value no client read. `BleCoordinator.readPeerIdentity` answered true whatever happened, beside a comment saying "for now", and had no caller; `setSessionMode` had none either, so the scanner's session mode was always idle and read only by a log line. | Deleted end to end: the characteristic and its UUID, the client read with its event and pending-read slot, the bridge RPC, the JNI export and the message; `readPeerIdentity`; `setSessionMode` with `BleSessionMode`. A relationship's send status is where Rust already reports it, on the contact list. | +| frontend · dsm/transactions.ts `offlineSend`, components/screens/wallet/SendTab.tsx, dsm/resolution.ts, domain/mappers.ts `mapContactList`, stores/contactsStore.ts, dsm/WebViewBridge/ble.ts; Kotlin · bridge/SinglePathWebViewBridge.kt, UnifiedContactBridge.kt, Unified.kt, UnifiedNativeApi.kt; `dsm_sdk` · handlers/wallet_routes.rs `wallet.sendOffline`, bluetooth/bilateral_ble_handler.rs (the prepare), jni/state.rs, jni/unified_protobuf_bridge.rs; `proto` · `BilateralPrepareRequest` 6 and 11–14, `OfflineTransferRequest` | The frontend chose where an offline send went. The send form resolved the recipient's BLE address — the one the contact showed, else a cache of its own, else a bridge round trip to a native map — and refused a contact it found none for before Rust was asked. `offlineSend` sent the address inside a `BilateralPrepareRequest`, the appliance-to-appliance prepare, whose intent fields (11–14) existed only for this route and whose `ble_address` (6) no receiver read; the route also took a caller-authored operation (`operation_data`) in place of authoring one. The contacts mapper filled a missing address from the frontend's cache and dropped one that was not MAC-shaped. The prepare builder looked an address up only to fill field 6, and when the contact had none it wrote the session's address into the contact — the mark that ends the pairing loop — outside the pairing confirm. `recordPeerIdentity` wrote into the session map the last 32 bytes of any identity payload, unverified, and nothing called it. | `wallet.sendOffline` takes `OfflineTransferRequest`: the counterparty, token, amount and memo, as the user gave them. The SDK resolves the address (`bluetooth::peer_address`): the one the contact holds, else the one its identity, checked against the contact's genesis, was seen at this session; an appliance it has not met is refused, saying so. It authors the operation from the intent, and no caller supplies one. `BilateralPrepareRequest` 6 and 11–14 are reserved and the builder looks up no address. The session map moved from `jni::state` to `bluetooth::peer_address`, host-compiled and tested. Deleted: the frontend resolver module with its cache and normalizer, the `resolveBleAddressForDeviceId` RPC with its Kotlin arm, wrapper and JNI export, `recordPeerIdentity`, and `resolve_ble_address`, which nothing called. The contact carries the address Rust holds, as Rust holds it. | +| frontend · components/screens/ContactsTabScreen.tsx, dsm/WebViewBridge/ble.ts; Kotlin · bridge/SinglePathWebViewBridge.kt, Unified.kt, UnifiedNativeApi.kt, UnifiedContactBridge.kt; `dsm_sdk` · sdk/session_manager.rs, bluetooth/mod.rs, handlers/contacts_routes.rs, jni/helpers.rs, storage/client_db/contacts.rs `has_unpaired_contacts` | The contacts screen ran BLE pairing: it started the loop when it counted more unpaired contacts than before and stopped it when it unmounted, so pairing ran only while that screen was open. Kotlin's start arm asked for Bluetooth permissions and created the BLE coordinator on the way. `hasUnpairedContacts` answered false when the database could not be read, and nothing called it. | Pairing follows the session, as the lock does: Rust starts the loop when the facts Kotlin reports say the app is in the foreground with Bluetooth on and permitted and there is an identity to pair as (`SessionManager::pairing_may_run`), and stops it when they say otherwise; the loop still ends by itself once no contact is unpaired. Adding a contact wakes or starts the loop when the session lets it run. Kotlin creates the BLE coordinator the loop drives when the identity's BLE service starts, before the facts that let pairing run go out, as the start arm did. Deleted: the screen's start and stop, the `startPairingAll` and `stopPairingAll` RPCs with their Kotlin arms, wrappers and JNI exports, and the `hasUnpairedContacts` chain down to its query. The start and stop are compile-checked Android code, not run: a device run is what shows pairing starts in the foreground and stops in the background. | +| frontend · dsm/transactions.ts `offlineSend` | A BLE transaction error frame ended the send in flight as failed. Kotlin raises that frame for any failed connection to any address — a failed identity read, a failed GATT connect, a failed connection-state call — so a pairing attempt with another appliance could fail a send, and a lost link, which is liveness, was reported as a failed transfer while the step stayed open. | The listener is deleted: the send ends on Rust's word, its events or its pending list, and a screen that stops waiting reports the step open. | +| `dsm_sdk` · bluetooth/pairing_orchestrator.rs (`handle_pairing_confirm`, `finalize_scanner_pairing_by_address`, `handle_identity_observed`, `handle_peer_disconnected`), handlers/contacts_routes.rs `contacts.list`, sdk/contact_sdk.rs; `proto` · `ContactAddResponse.pairing`, `ContactPairingPhase`; frontend · components/screens/ContactsTabScreen.tsx, dsm/contacts.ts, domain/mappers.ts | Both pairing completions marked the session Complete, and told the screen the contact was paired, when storing its address failed. The loop never revisits a Complete session, so such a contact stayed unpaired until the process restarted — among them an appliance paired before it was added as a contact, whose store fails for want of the contact. The loop reported only "scanning", so the contacts screen inferred progress from raw radio events about any appliance: "Peer Found" on any DSM advertisement, and "Paired!" when an appliance's identity was read, before pairing had completed. A dropped link set the session's state twice. | A session completes only once the address is stored; a failed store fails the session, and the loop retries it. The loop reports each transition, and the contact list states each contact's phase from the loop's sessions — paired, idle, searching, connected or retrying (`ContactPairingPhase`; the frontend refuses a phase the wire does not name). The screen's line renders those phases and no longer listens to raw radio events; a pairing event re-reads the list. The orchestrator's database tests set their own storage directory: they passed only when an earlier test had set one. | +| Kotlin · androidTest `AndroidLayerProofTest`; `ci/bridge_rpc_names.py` | #1012 deleted the bridge arms nothing in the frontend sent — `getDeviceIdBin`, `getGenesisHashBin`, `getSigningPublicKeyBin`, `getPersistedGenesisEnvelope`, `getWalletHistoryStrict` — and the instrumented proof still called them: nine of its tests got the unknown-method answer, and the managed-device job was red on `main` from that merge on (green at #1011). No gate read androidTest, so nothing named the cause. The deleted arms were the suite's only route to the JNI identity exports `Unified.getDeviceIdBin`/`getGenesisHashBin`, which BLE still reads (the GATT identity characteristic, the advertising gate); no test ever compared them with the transport headers the frontend reads. `claimFaucet` read the device id through a deleted arm and returned before claiming; before #1012 it sent a zero `schema_hash` and swallowed the claim's failure. | The proof reads what the frontend reads: the device id and genesis hash decoded from the transport headers, history through the `wallet.history` route, framing and concurrency over `getTransportHeadersV3Bin`; t50 also requires every thread to read the same device id. t22 calls the JNI identity exports directly and requires them to equal the headers' device id and genesis hash. t32 and t34 go with their arms, and nothing reads either value through the bridge; t42 goes, since it fetched `getDeviceIdBin` and never compared it (its one assertion was the headers' size). A routed call that Rust refuses fails with Rust's reason. The emulator tests that called `claimFaucet` read without it; the real-hardware test sends `faucet.claim` as the frontend does and requires a release. The gate now reads androidTest: every bridge name the instrumented suite sends — through the request encoders or a method field encoded by hand — must be one Kotlin handles, and the unknown-method probe (`UNHANDLED_METHOD`) must be readable, sent, and one Kotlin does not handle. | 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`). @@ -1069,16 +1076,35 @@ Tests for the event plumbing: frontend `tests/E2E.uiCoordination.test.tsx` · `R Tests for typed subscriptions: none added — a type. Compile control: a `useBridgeEvent('nothing.emits', …)` added to a provider fails `tsc`, naming the file. +Tests for the radio: frontend `dsm/__tests__/offlineSend.radio.test.ts` · `an offline send asks for the send and nothing else — no host request, no identity relay, no wait`; `components/screens/__tests__/EnhancedWalletScreen.events.test.tsx` · `the wallet screen makes no radio request as it mounts, hides, shows and unmounts`; `contexts/__tests__/ContactsContext.radio.test.tsx` · `identity readiness and a learned BLE address reach the contact list, not the radio`. Mutation controls, each red on its named test: the awaited scan start with its failure swallowed; the 1.5 s settle; the re-advertise when a send finishes; the screen's advertising lifecycle; advertising on identity readiness; the identity relay on a learned address. The native side has no unit test: its lifecycle is Android framework callbacks, and a seam to drive them would be a test path in the production service. + +Tests for the dead BLE surface: none added — deletions; the Kotlin main, androidTest and unit-test compiles, the Android check of the SDK, the frontend suite and every gate ran on the result. Gate control: `ci/bridge_rpc_names.py` refuses the Kotlin `readPeerRelationshipStatus` arm left behind once the frontend no longer sends it, naming it. + +Tests for the send's address: `dsm_sdk::bluetooth::peer_address::tests::a_send_goes_to_the_address_the_contact_holds`, `before_pairing_persists_one_the_sighting_is_the_address`, `an_appliance_never_seen_has_no_address`; `dsm_sdk::handlers::wallet_routes::send_offline_tests::an_offline_send_goes_where_the_sdk_has_seen_the_appliance` (refused before the contact holds an address; after, a host build stops only at the BLE dispatch); frontend `dsm/__tests__/offlineSend.test.ts` · `an offline send reaches wallet.sendOffline as what the user asked for, and nothing else` (the request's exact bytes); `components/screens/wallet/__tests__/SendTab.offline.test.tsx` · `asks Rust to send and names no address itself; an appliance Rust has not met is its refusal`; `domain/__tests__/mappers.test.ts` · `carries the BLE address Rust holds, as it holds it, and none where it holds none`; `tests/E2E.transferProof.test.ts` · `a send to an appliance Rust has not met over BLE is Rust's refusal, in its words`. Mutation controls, each red on its named test: the contact's address ignored; the session's address preferred over the contact's; the route sending to an address it made up; the form refusing a contact that shows no address; the mapper filling a missing address; the mapper reformatting one. + +Tests for pairing: `dsm_sdk::sdk::session_manager::tests::pairing_runs_only_in_the_foreground_with_bluetooth_on_permitted_and_an_identity`; frontend `components/screens/__tests__/ContactsTabScreen.pairing.test.tsx` · `the contacts screen asks for nothing but reads, with a contact unpaired and as it unmounts`. Mutation controls, each red on its named test: the decision without the Bluetooth permission; the decision without the foreground; the screen starting pairing again and stopping it on unmount. + +Tests for transport errors: frontend `dsm/__tests__/offlineSend.test.ts` · `a BLE transport error fails no send; the send ends on Rust's word`. Mutation control, red on its named test: the listener restored. + +Tests for pairing status: `dsm_sdk::bluetooth::pairing_orchestrator::tests::a_confirm_that_cannot_store_the_address_does_not_complete`, `a_scanner_finalize_that_cannot_store_the_address_does_not_complete`, `a_contacts_pairing_phase_is_the_sdks_own`; `dsm_sdk::handlers::contacts_routes::pairing_phase_tests::the_contact_list_states_where_pairing_stands` (two appliances on the fleet: idle, then searching, then paired); frontend `dsm/__tests__/contacts.test.ts` · `each contact carries the pairing phase Rust states`, `a contact without what Rust always writes is refused, never filled in` (a phase the wire does not name); `components/screens/__tests__/ContactsTabScreen.pairingLine.test.tsx`. Mutation controls, each red on its named test: either completion completing when the store fails; a retrying session stated as idle; the list stating no session; an unnamed phase read as idle; the line inferring "Paired!" from a raw event; a retrying contact shown as nothing. + +Tests for the instrumented proof: `AndroidLayerProofTest` on the managed Pixel 6 API 34 emulator, locally and in CI's `Android Instrumented Tests (managed device)`: 40 tests, 0 failures (`@RealHardware` excluded, as in CI). Controls, each run and restored: t22 comparing the device id with the genesis hash — red ("arrays first differed at element [0]"); t40 run on the emulator — red with Rust's reason ("faucet.claim refused: … member `instrumented-node-1` has a register_incarnation that is not Base32-Crockford"). Gate controls: `main`'s test under `main`'s gate passes (the blind spot); under the new gate it is refused, naming the five deleted names at every call site; a hand-encoded method field naming a method Kotlin lacks, a typed probe bound to a handled method, a probe bound in a form the gate cannot read, a t60 that sends a literal instead of the probe, and no probe at all are each refused. + **Open** -- frontend · dsm/EventBridge.ts: `dsm-biometric-result`, `bluetooth-permissions` and `ble-dev-automation` are still dispatched as window events with no listener in the frontend; whether device automation or the biometric flow reads them from outside the bundle is a device question, so they stay until a device run says. +- Kotlin · androidTest `AndroidLayerProofTest.t40_fullFrame_identityCheckAndBalanceFetch` (`@RealHardware`): `ensureGenesis` installs the loopback test config, so `faucet.claim` is refused by the storage-set catalog on any device and the test cannot pass. It needs the suite to install a config that names the network's pinned set; no client config for the live fleet is cut. +- frontend · dsm/EventBridge.ts: `dsm-biometric-result` and `ble-dev-automation` are still dispatched as window events with no listener in the frontend; whether device automation or the biometric flow reads them from outside the bundle is a device question, so they stay until a device run says. `bluetooth-permissions` went with its producer, the `BLUETOOTH_PERMISSIONS` host event. -- frontend · `offlineSend` primes BLE advertising and scanning itself and sleeps 1.5 s before `wallet.sendOffline`, and ContactsContext's `ensureBleAdvertisingIfContacts` starts advertising on contact events: transport orchestration above Rust, with failures swallowed. The contacts mapper also falls back to BLE addresses the native side resolved this session (`dsm/resolution.ts`). Changing either needs a device run. +- frontend · components/screens/ContactsTabScreen.tsx: re-reads the contact list every 5 s on a timer, beside the events that announce a change. +- Kotlin · UnifiedContactBridge.kt `removeContact`, `hasContactForDeviceId`, with their `Unified` functions, externals and JNI exports: nothing calls them. A contacts pass. +- `dsm_sdk` · bluetooth/bilateral_ble_handler.rs `handle_prepare_request`: a prepare that is not for this relationship is not meaningless bytes to the receiver, though its parent can never match. The receiver never checks that the prepare is for it: it logs the target and carries on. When the sender is also its contact, the tip mismatch stores the sender's claimed tip as a live-peer claim, and that claim blocks the receiving appliance's sends to the sender, online as well as offline (`wallet.send` checks the same readiness); nothing outside recovery clears it. The receiver also answers with a signed rejection, which the sender drops, and emits a rejected event to its own app. A throwaway probe reproduced it: send-ready before, then blocked with "Live peer reported a different relationship tip". The target field is not under the sender's signature; the operation inside the signed commitment names the recipient. The Kotlin fallback below is one way such a prepare arrives. +- Kotlin · bridge/ble/BleCoordinator.kt `resolveSession`: when the address a transfer names is not a live session and no identity matches it, the transfer goes to the one ready peer, whichever appliance that is. A prepare for one contact can reach another appliance; the fallback picks a destination the SDK did not choose. A transport pass. +- Kotlin · AndroidManifest.xml `PicoSelfTestActivity`: a bench self-test its own comment calls debug bring-up only, exported and launched on USB attach in the production manifest. The hardware track's. +- `proto` · `BilateralReconciliationRequest`, `BilateralReconciliationResponse`, `BleFrameType` 10–11, with their arms in `dsm_sdk` bluetooth/frame_classify.rs and wire/mod.rs: reconciliation was deleted (a fork is a Tripwire violation, not reconcilable) and its frames outlived it; the request's `include_peer_status` and `ble_address` name the peer status read deleted above. A backend wire pass. - frontend · SofiScreen: orders a vault's token pair bytewise before sending it (§28) — a protocol rule applied above Rust. The SoFi track's screen. - frontend · dsm/transactions.ts `schedulePostAcceptRefreshes`: after Accept, four re-reads of the wallet on a frame cadence (0/0.5/1/2 s) beside Rust's TRANSFER_COMPLETE announcement, kept because whether the announcement alone reaches the screen on a device is undecided; a device run decides, and the cadence goes if it does. - 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. - frontend · `public/index.html` `sendMessageBin`: rejects a request after 30 minutes on a wall-clock timer (a bound sized for the M=3 K=21 enrollment); `installPortHandler` tells a response from an async event by whether the first eight bytes match a pending id. Both are transport-side, fail-closed and unchanged by the sweep; they are where the port protocol still guesses. -- 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: the reads, the facts and the ladder (`fix/sofi-verdict-core-resolver`, 2026-09-26)