From f440185a8d6fae79e80d3f36e56e39c9055fc390 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:47:27 -0400 Subject: [PATCH 1/3] test(android): instrumented suite compiles, runs on a managed device in CI, and caught a framed error envelope read as success The seven androidTest files had not compiled for a long time and no workflow ran them. Running them on a Gradle managed device found one production bug. Production: isErrorEnvelope decoded the bytes as they cross JNI, but every envelope the bridge hands Kotlin carries the 0x03 frame byte, which the canonical decoder refuses; every rejection therefore read as "not an error" (vector case_0001_proof_cap_over read ACCEPT instead of PROOF_TOO_LARGE). dsm_sdk::envelope::transport::error_code_of_transport_bytes strips the frame byte when present; the JNI export delegates to it. Host test an_error_envelope_is_detected_framed_and_bare; mutation control (no strip) red, restored. Tests: SoFi harnesses fund AMM vaults through funding_legs, read the u64 reserves and assert the hop's parent_binding, and carry @RealHardware (two phones + live fleet). AndroidLayerProofTest calls initSdk (router install), installs a test-only env config from the instrumentation assets, retargets calls to methods the bridge dispatches (getDeviceIdBin with a 32-byte assertion, getGenesisHashBin), deletes tests for removed capabilities (identity presence, Bluetooth status, device-id alias), and marks the faucet test @RealHardware. BleEventRelayPersistenceTest marks the bridge ready before flushing, adds a not-ready test and a reset hook. CI: android-instrumented-tests builds every packaged ABI (make android-libs; refreshDsmJniLibs requires all three) and runs pixel6Api34DebugAndroidTest excluding @RealHardware. Locally, with the app's web assets moved aside as in CI: 45 of 45 pass. Not changed: the committed dsm_env_config.toml fails the SDK's strict loader (nodes lack register_incarnation); it is an owner-protected file. --- .github/workflows/ci.yml | 80 +++++++++++++++ dsm_client/android/app/build.gradle.kts | 11 +++ .../assets/dsm_env_config.instrumented.toml | 19 ++++ .../java/com/dsm/wallet/RealHardware.kt | 13 +++ .../wallet/bridge/AndroidLayerProofTest.kt | 99 ++++++------------- .../bridge/BleEventRelayPersistenceTest.kt | 30 +++++- .../wallet/sofi/SoFiCrossDeviceOwnerTest.kt | 14 +-- .../wallet/sofi/SoFiCrossDeviceTraderTest.kt | 6 +- .../com/dsm/wallet/sofi/SoFiTestHelpers.kt | 26 +++-- .../dsm/wallet/sofi/SoFiTradeRealHwTest.kt | 14 +-- .../com/dsm/wallet/bridge/BleEventRelay.kt | 8 ++ .../dsm_sdk/src/envelope/transport.rs | 74 ++++++++++++++ .../src/jni/unified_protobuf_bridge.rs | 11 +-- 13 files changed, 306 insertions(+), 99 deletions(-) create mode 100644 dsm_client/android/app/src/androidTest/assets/dsm_env_config.instrumented.toml create mode 100644 dsm_client/android/app/src/androidTest/java/com/dsm/wallet/RealHardware.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e816706f3..12620e305 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -495,6 +495,86 @@ jobs: - name: Run app unit tests run: ./gradlew :app:testDebugUnitTest --no-daemon --stacktrace + # -------------------------------------------------------------------------- + # Android: instrumented tests on a Gradle managed device (emulator, KVM). + # Runs the device-local suite (bridge layer proof, vector rejects, BLE relay + # persistence). Classes marked @RealHardware need two paired phones and the + # live fleet; they compile here and are excluded by runner argument. The app + # loads libdsm_sdk.so, so the SDK library is built first for every packaged ABI. + # -------------------------------------------------------------------------- + android-instrumented-tests: + name: Android Instrumented Tests (managed device) + needs: [select] + if: needs.select.outputs.jni_android == 'true' + runs-on: ubuntu-latest + # Three cold NDK builds (all packaged ABIs) plus the managed-device boot. + timeout-minutes: 90 + steps: + - uses: actions/checkout@v7 + + - name: Enable KVM for the managed device + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - uses: dtolnay/rust-toolchain@1.98.0 + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: dsm_client/deterministic_state_machine + key: android-all-abis + + - name: Install cargo-ndk + run: cargo install cargo-ndk --locked + + - name: Build the SDK library for every packaged ABI + # refreshDsmJniLibs (app/build.gradle.kts) refuses to package unless all + # three abiFilters ABIs have a library, so building only the emulator's + # x86_64 would fail before any test ran. This is the repo's own target. + env: + ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_LATEST_HOME }} + run: make android-libs + + - name: Cache Gradle caches + uses: actions/cache@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('dsm_client/android/**/*.gradle*', 'dsm_client/android/**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Accept SDK licenses (system image download) + run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true + + - name: Run the instrumented suite on the managed device + working-directory: dsm_client/android + run: | + ./gradlew :app:pixel6Api34DebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.notAnnotation=com.dsm.wallet.RealHardware \ + --no-daemon --stacktrace + + - name: Upload instrumented test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-instrumented-reports + path: | + dsm_client/android/app/build/reports/androidTests/managedDevice/ + dsm_client/android/app/build/outputs/androidTest-results/managedDevice/ + if-no-files-found: ignore + # -------------------------------------------------------------------------- # Coverage: Rust + frontend reports, Codecov upload, aggregate summary # -------------------------------------------------------------------------- diff --git a/dsm_client/android/app/build.gradle.kts b/dsm_client/android/app/build.gradle.kts index 9031ca584..e1ccceeea 100644 --- a/dsm_client/android/app/build.gradle.kts +++ b/dsm_client/android/app/build.gradle.kts @@ -176,6 +176,17 @@ android { } testOptions { + // Gradle managed device for the instrumented suite in CI (device-local + // tests only; `@RealHardware` classes are excluded by runner argument). + managedDevices { + localDevices { + create("pixel6Api34") { + device = "Pixel 6" + apiLevel = 34 + systemImageSource = "aosp-atd" + } + } + } unitTests { isIncludeAndroidResources = true // Many Android platform APIs in unit tests don't have real implementations; returning defaults makes tests less flaky diff --git a/dsm_client/android/app/src/androidTest/assets/dsm_env_config.instrumented.toml b/dsm_client/android/app/src/androidTest/assets/dsm_env_config.instrumented.toml new file mode 100644 index 000000000..0e468e255 --- /dev/null +++ b/dsm_client/android/app/src/androidTest/assets/dsm_env_config.instrumented.toml @@ -0,0 +1,19 @@ +# Test-only environment config for the instrumented suite. +# +# The app's bundled dsm_env_config.toml is a deployment file maintained +# outside this suite; the bridge tests must not depend on it. These tests +# replicate MainActivity's startup and exercise the local bridge (codec, +# routing, identity, balances) without reaching a storage node, so the node +# entries only have to satisfy the loader's schema: at least one node, each +# with name, endpoint and register_incarnation. Loopback endpoints need +# allow_localhost, which the Android release loader otherwise refuses. +protocol = "http" +lan_ip = "127.0.0.1" +allow_localhost = true +storage_node_mode = "remote" +ports = [8080] + +[[nodes]] +name = "instrumented-node-1" +endpoint = "http://127.0.0.1:8080" +register_incarnation = "INSTRUMENTED-TEST-NODE-1" diff --git a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/RealHardware.kt b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/RealHardware.kt new file mode 100644 index 000000000..35ebd8282 --- /dev/null +++ b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/RealHardware.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.dsm.wallet + +/** + * Marks an instrumented test that needs real hardware: two paired phones and + * the live storage fleet (the SoFi cross-device and real-hardware trade + * harnesses). CI runs the instrumented suite on a Gradle managed device with + * `notAnnotation=com.dsm.wallet.RealHardware`, so these compile in CI and run + * only from a hands-on four-phone session. + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class RealHardware diff --git a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/AndroidLayerProofTest.kt b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/AndroidLayerProofTest.kt index 234b348ec..daa3cf3fe 100644 --- a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/AndroidLayerProofTest.kt +++ b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/AndroidLayerProofTest.kt @@ -4,6 +4,8 @@ package com.dsm.wallet.bridge import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.dsm.wallet.RealHardware import com.dsm.wallet.ui.MainActivity import com.google.protobuf.ByteString import java.io.File @@ -248,7 +250,7 @@ class AndroidLayerProofTest { ensureGenesis() val messageId = 0x0102030405060708L - val requestBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0)) + val requestBytes = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0)) val framedReq = prependMessageId(messageId, requestBytes) val framedResp = MainActivity.processBridgeRequestForTest(ctx, framedReq) @@ -263,7 +265,7 @@ class AndroidLayerProofTest { val id1 = 1L val id2 = 2L - val requestBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0)) + val requestBytes = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0)) val resp1 = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(id1, requestBytes)) val resp2 = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(id2, requestBytes)) @@ -277,7 +279,7 @@ class AndroidLayerProofTest { ensureGenesis() val messageId = Long.MAX_VALUE - val requestBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0)) + val requestBytes = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0)) val framedResp = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(messageId, requestBytes)) assertEquals("Max message ID must survive", messageId, readMessageId(framedResp)) @@ -290,30 +292,6 @@ class AndroidLayerProofTest { // returns a valid BridgeRpcResponse, and the data is correct. // ========================================================================= - @Test - fun t20_method_hasIdentityDirect_beforeGenesis() { - // Fresh context may or may not have identity — just verify no crash - val requestBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0)) - val framedResp = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(1L, requestBytes)) - - assertTrue("Must get response", framedResp.size > 8) - val respBody = framedResp.copyOfRange(8, framedResp.size) - val (isSuccess, data) = BridgeEnvelopeCodec.parseEnvelopeResponse(respBody) - assertTrue("hasIdentityDirect must return success (even if false)", isSuccess) - assertEquals("Must return 1-byte boolean", 1, data.size) - assertTrue("Value must be 0 or 1", data[0] == 0.toByte() || data[0] == 1.toByte()) - } - - @Test - fun t21_method_hasIdentityDirect_afterGenesis() { - ensureGenesis() - - val resp = callBridgeMethod("hasIdentityDirect", ByteArray(0)) - assertTrue("Must be success", resp.first) - assertEquals("Must return 1 byte", 1, resp.second.size) - assertEquals("Identity must exist after genesis", 1.toByte(), resp.second[0]) - } - @Test fun t22_method_getDeviceIdBin() { ensureGenesis() @@ -325,10 +303,10 @@ class AndroidLayerProofTest { } @Test - fun t23_method_getPersistedGenesisHash() { + fun t23_method_getGenesisHashBin() { ensureGenesis() - val resp = callBridgeMethod("getPersistedGenesisHash", ByteArray(0)) + val resp = callBridgeMethod("getGenesisHashBin", ByteArray(0)) assertTrue("Must be success", resp.first) assertEquals("Genesis hash must be 32 bytes", 32, resp.second.size) assertFalse("Genesis hash must not be all zeros", resp.second.all { it == 0.toByte() }) @@ -487,14 +465,6 @@ class AndroidLayerProofTest { // Key may be 32 or 33 bytes depending on key type, or empty if not available } - @Test - fun t33_method_getBluetoothStatus() { - // No BLE needed — just proves the method doesn't crash - val resp = callBridgeMethod("getBluetoothStatus", ByteArray(0)) - assertTrue("Must be success", resp.first) - assertEquals("Must return 1-byte boolean", 1, resp.second.size) - } - @Test fun t34_method_getPersistedGenesisEnvelope() { ensureGenesis() @@ -511,13 +481,15 @@ 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. + @RealHardware @Test fun t40_fullFrame_identityCheckAndBalanceFetch() { ensureGenesis() claimFaucet() // Step 1: Identity check (same bytes JS would send) - val identityReq = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0)) + val identityReq = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0)) val identityFramed = prependMessageId(1001L, identityReq) val identityResp = MainActivity.processBridgeRequestForTest(ctx, identityFramed) @@ -526,7 +498,7 @@ class AndroidLayerProofTest { identityResp.copyOfRange(8, identityResp.size) ) assertTrue("Identity must succeed", idOk) - assertEquals("Identity = true", 1.toByte(), idData[0]) + assertEquals("An identity has a 32-byte device id", 32, idData.size) // Step 2: Fetch balances (same bytes JS would send) val balReq = encodeBridgeRpcRequest("getAllBalancesStrict", ByteArray(0)) @@ -563,23 +535,6 @@ class AndroidLayerProofTest { assertTrue("ERA balance must be positive after faucet", eraBalance > 0L) } - @Test - fun t41_fullFrame_deviceId_matchesBetweenMethods() { - ensureGenesis() - - // Get device ID via getDeviceIdBin - val resp1 = callBridgeMethod("getDeviceIdBin", ByteArray(0)) - val deviceId1 = resp1.second - - // Get device ID via getPersistedDeviceId (alias) - val resp2 = callBridgeMethod("getPersistedDeviceId", ByteArray(0)) - val deviceId2 = resp2.second - - assertEquals("Both must be 32 bytes", 32, deviceId1.size) - assertEquals("Both must be 32 bytes", 32, deviceId2.size) - assertTrue("Device IDs from both methods must match", deviceId1.contentEquals(deviceId2)) - } - @Test fun t42_fullFrame_headersContainDeviceId() { ensureGenesis() @@ -617,8 +572,8 @@ class AndroidLayerProofTest { Thread { try { barrier.await() // All threads start simultaneously - val resp = callBridgeMethod("hasIdentityDirect", ByteArray(0)) - if (resp.first && resp.second.size == 1 && resp.second[0] == 1.toByte()) { + val resp = callBridgeMethod("getDeviceIdBin", ByteArray(0)) + if (resp.first && resp.second.size == 32 && resp.second.any { it != 0.toByte() }) { successes.incrementAndGet() } else { errors.incrementAndGet() @@ -675,11 +630,11 @@ class AndroidLayerProofTest { ensureGenesis() val methods = listOf( - "hasIdentityDirect" to ByteArray(0), "getDeviceIdBin" to ByteArray(0), - "getPersistedGenesisHash" to ByteArray(0), - "getBluetoothStatus" to ByteArray(0), + "getGenesisHashBin" to ByteArray(0), + "getSigningPublicKeyBin" to ByteArray(0), "getTransportHeadersV3Bin" to ByteArray(0), + "getAllBalancesStrict" to ByteArray(0), ) val threadCount = methods.size * 2 @@ -720,7 +675,7 @@ class AndroidLayerProofTest { try { barrier.await() val msgId = (1000L + i) - val reqBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0)) + val reqBytes = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0)) val framedReq = prependMessageId(msgId, reqBytes) val framedResp = MainActivity.processBridgeRequestForTest(ctx, framedReq) @@ -822,9 +777,9 @@ class AndroidLayerProofTest { MainActivity.processBridgeRequestForTest(ctx, prependMessageId(1L, garbage)) // Then: send valid request — bridge must still work - val resp = callBridgeMethod("hasIdentityDirect", ByteArray(0)) + val resp = callBridgeMethod("getDeviceIdBin", ByteArray(0)) assertTrue("Bridge must work after error", resp.first) - assertEquals("Identity must still exist", 1.toByte(), resp.second[0]) + assertEquals("Identity must still exist", 32, resp.second.size) } @Test @@ -835,8 +790,8 @@ class AndroidLayerProofTest { var successCount = 0 for (i in 0 until 100) { try { - val resp = callBridgeMethod("hasIdentityDirect", ByteArray(0)) - if (resp.first) successCount++ + val resp = callBridgeMethod("getDeviceIdBin", ByteArray(0)) + if (resp.first && resp.second.size == 32) successCount++ } catch (_: Throwable) { // count as failure } @@ -855,13 +810,21 @@ class AndroidLayerProofTest { // Replicate the SDK init that MainActivity does at startup: // 1. Set storage base dir (required before AppState can persist) Unified.initStorageBaseDir(ctx.filesDir.absolutePath.toByteArray(Charsets.UTF_8)) - // 2. Copy dsm_env_config.toml from APK assets to app files dir + // 2. Install a TEST-ONLY env config (androidTest/assets) into the app + // files dir. The app's bundled dsm_env_config.toml is a deployment + // file this suite must not depend on; it is read here from the + // instrumentation APK's assets, not the app's. val cfgFile = File(ctx.filesDir, "dsm_env_config.toml") - ctx.assets.open("dsm_env_config.toml").use { input -> + InstrumentationRegistry.getInstrumentation().context.assets + .open("dsm_env_config.instrumented.toml").use { input -> FileOutputStream(cfgFile, false).use { out -> input.copyTo(out) } } // 3. Tell Rust where the config is (sets ENV_CONFIG_PATH + DSM_ALLOW_LOCALHOST) Unified.initDsmSdk(cfgFile.absolutePath) + // 4. Initialize the SDK the way MainActivity.initDsmAndSignalReady does: + // this is the step that installs the app router behind the ingress; + // without it every routed method answers "app router not installed". + assertTrue("initSdk must install the app router", Unified.initSdk(ctx.filesDir.absolutePath)) // Canonical mnemonic-rooted Genesis v2: generate a mnemonic, then create the wallet from // it. No storage nodes, no silicon — the BIP39 mnemonic is the sole root. diff --git a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/BleEventRelayPersistenceTest.kt b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/BleEventRelayPersistenceTest.kt index b07a09c02..a185729e3 100644 --- a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/BleEventRelayPersistenceTest.kt +++ b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/bridge/BleEventRelayPersistenceTest.kt @@ -24,7 +24,8 @@ class BleEventRelayPersistenceTest { @Before fun setUp() { ctx = ApplicationProvider.getApplicationContext() - // Clear any prior test data + // Clear any prior test data and any bridge-ready state a prior test set + BleEventRelay.testResetBridgeReady() BleEventRelay.clearAll(ctx) } @@ -55,13 +56,32 @@ class BleEventRelayPersistenceTest { } assertEquals(3, BleEventRelay.getPendingCount(ctx)) - // When: flush + // When: the bridge is ready and we flush. There is no WebView in an + // instrumented process, so delivery throws and the relay DROPS the + // replayed event (persistIfUnavailable = false) instead of re-inserting + // it — which is exactly what lets the row count reach zero. + BleEventRelay.markBridgeReady(ctx) BleEventRelay.flushPersisted(ctx) // Then: all events flushed and pruned assertEquals(0, BleEventRelay.getPendingCount(ctx)) } + @Test + fun flushLeavesEventsWhenBridgeNotReady() { + // Given: 2 persisted events and a bridge that is NOT ready + for (i in 1..2) { + BleEventRelay.testPersistDirect(ctx, "event$i".toByteArray(Charsets.ISO_8859_1)) + } + assertEquals(2, BleEventRelay.getPendingCount(ctx)) + + // When: flush before the bridge is ready + BleEventRelay.flushPersisted(ctx) + + // Then: nothing is dropped — the events wait for the bridge + assertEquals(2, BleEventRelay.getPendingCount(ctx)) + } + @Test fun enforcesCap() { // Given: attempt to persist 210 events @@ -84,8 +104,10 @@ class BleEventRelayPersistenceTest { } assertEquals(2, BleEventRelay.getPendingCount(ctx)) - // When: flush (normally succeeds; testing rollback would require mocking DB failure) - // For now, verify flush completes without exception + // When: flush with the bridge ready (testing a mid-transaction DB + // failure would need a fault-injecting database; here the flush must + // complete and commit as one transaction) + BleEventRelay.markBridgeReady(ctx) BleEventRelay.flushPersisted(ctx) // Then: events cleared diff --git a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiCrossDeviceOwnerTest.kt b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiCrossDeviceOwnerTest.kt index 277a67c55..bef11736b 100644 --- a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiCrossDeviceOwnerTest.kt +++ b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiCrossDeviceOwnerTest.kt @@ -7,6 +7,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry +import com.dsm.wallet.RealHardware import com.dsm.wallet.ui.MainActivity import dsm.types.proto.AmmVaultSummaryV1 import org.junit.Assert.assertEquals @@ -59,6 +60,7 @@ import java.security.SecureRandom * Watch logs: * adb logcat -s SOFI_TRADE SOFI_XDEV */ +@RealHardware @RunWith(AndroidJUnit4::class) @LargeTest @FixMethodOrder(MethodSorters.NAME_ASCENDING) @@ -177,8 +179,8 @@ class SoFiCrossDeviceOwnerTest { val vid = summary.vaultId.toByteArray() val matchesOurs = vid.contentEquals(vault1Id) || vid.contentEquals(vault2Id) if (!matchesOurs) return@firstOrNull false - val ra = u128beToLong(summary.reserveAU128.toByteArray()) - val rb = u128beToLong(summary.reserveBU128.toByteArray()) + val ra = summary.reserveA + val rb = summary.reserveB ra != INITIAL_RESERVE_A || rb != INITIAL_RESERVE_B } if (settled != null) { @@ -202,8 +204,8 @@ class SoFiCrossDeviceOwnerTest { vid.contentEquals(vault1Id) || vid.contentEquals(vault2Id) } .map { s -> - val ra = u128beToLong(s.reserveAU128.toByteArray()) - val rb = u128beToLong(s.reserveBU128.toByteArray()) + val ra = s.reserveA + val rb = s.reserveB "${b32(s.vaultId.toByteArray())}: ra=$ra rb=$rb" } Log.e(TAG, "owner poll: trader settlement never reached us. snapshot=$finalSnapshot") @@ -216,8 +218,8 @@ class SoFiCrossDeviceOwnerTest { // Trader spent ERA (lex-higher = reserveB) and // received output_token (lex-lower = reserveA), so // reserveB grew and reserveA shrunk. ── - val raAfter = u128beToLong(updated.reserveAU128.toByteArray()) - val rbAfter = u128beToLong(updated.reserveBU128.toByteArray()) + val raAfter = updated.reserveA + val rbAfter = updated.reserveB assertTrue( "reserveB (ERA) must grow after trader settle ($rbAfter <= $INITIAL_RESERVE_B)", rbAfter > INITIAL_RESERVE_B, diff --git a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiCrossDeviceTraderTest.kt b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiCrossDeviceTraderTest.kt index 576cc2e33..fa1fefdb6 100644 --- a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiCrossDeviceTraderTest.kt +++ b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiCrossDeviceTraderTest.kt @@ -7,6 +7,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry +import com.dsm.wallet.RealHardware import com.dsm.wallet.bridge.BridgeEncoding import com.dsm.wallet.ui.MainActivity import dsm.types.proto.RouteCommitV1 @@ -63,6 +64,7 @@ import org.junit.runners.MethodSorters * Watch logs: * adb logcat -s SOFI_TRADE SOFI_XDEV */ +@RealHardware @RunWith(AndroidJUnit4::class) @LargeTest @FixMethodOrder(MethodSorters.NAME_ASCENDING) @@ -211,8 +213,8 @@ class SoFiCrossDeviceTraderTest { val expectedOut = u128beToLong(rc.expectedFinalOutputAmountU128.toByteArray()) assertTrue("expected output must be > 0 (got $expectedOut)", expectedOut > 0L) assertTrue( - "hop must carry a stamped anchor-state binding (reserves digest)", - rc.hopsList[0].vaultStateReservesDigest.size() == 32, + "hop must carry a stamped parent binding (the parent state's c_n)", + rc.hopsList[0].parentBinding.size() == 32, ) Log.i(TAG, "trader quote: exact expected=$expectedOut (single route, anchor-bound)") diff --git a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiTestHelpers.kt b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiTestHelpers.kt index 259383620..c1ededd12 100644 --- a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiTestHelpers.kt +++ b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiTestHelpers.kt @@ -13,6 +13,7 @@ import dsm.types.proto.ArgPack import dsm.types.proto.BalanceGetResponse import dsm.types.proto.Codec import dsm.types.proto.DlvInstantiateV1 +import dsm.types.proto.DlvFundingLegV1 import dsm.types.proto.DlvSpecV1 import dsm.types.proto.DlvUnlockRoutedV1 import dsm.types.proto.Envelope @@ -324,8 +325,9 @@ internal class SoFiTestContext( val amm = AmmConstantProduct.newBuilder() .setTokenA(ByteString.copyFrom(lexLower)) .setTokenB(ByteString.copyFrom(lexHigher)) - .setReserveAU128(ByteString.copyFrom(u128be(INITIAL_RESERVE_A))) - .setReserveBU128(ByteString.copyFrom(u128be(INITIAL_RESERVE_B))) + // The predicate carries no reserves: the vault's liquidity is + // encumbered from the creator's balance through funding legs + // (DlvInstantiateV1.funding_legs) and proved from reserve leaves. .setFeeBps(feeBps) .build() val fm = FulfillmentMechanism.newBuilder() @@ -358,8 +360,20 @@ internal class SoFiTestContext( // Empty pk + signature → Rust accept-or-stamp uses the // wallet's pk + signs Track C.4 style. .setCreatorPublicKey(ByteString.EMPTY) - .setTokenId(ByteString.EMPTY) - .setLockedAmountU128(ByteString.copyFrom(ByteArray(16))) + // An AMM vault carries exactly two funding legs, the spec's pair + // in lex order, both non-zero (proto DlvInstantiateV1.funding_legs). + .addFundingLegs( + DlvFundingLegV1.newBuilder() + .setPolicyCommit(ByteString.copyFrom(lexLower)) + .setAmount(INITIAL_RESERVE_A) + .build(), + ) + .addFundingLegs( + DlvFundingLegV1.newBuilder() + .setPolicyCommit(ByteString.copyFrom(lexHigher)) + .setAmount(INITIAL_RESERVE_B) + .build(), + ) .setSignature(ByteString.EMPTY) .build() @@ -386,8 +400,8 @@ internal class SoFiTestContext( .setVaultId(ByteString.copyFrom(vaultId)) .setTokenA(ByteString.copyFrom(lexLower)) .setTokenB(ByteString.copyFrom(lexHigher)) - .setReserveAU128(ByteString.copyFrom(u128be(INITIAL_RESERVE_A))) - .setReserveBU128(ByteString.copyFrom(u128be(INITIAL_RESERVE_B))) + // Reserves are not accepted from the caller: the handler reads + // them from the owner's encumbered reserve leaves. .setFeeBps(feeBps) .setUnlockSpecDigest(ByteString.copyFrom(unlockSpecDigest)) .setUnlockSpecKey("defi/spec/sofi-test/$label") diff --git a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiTradeRealHwTest.kt b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiTradeRealHwTest.kt index b6b963c4b..6ec319d08 100644 --- a/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiTradeRealHwTest.kt +++ b/dsm_client/android/app/src/androidTest/java/com/dsm/wallet/sofi/SoFiTradeRealHwTest.kt @@ -7,6 +7,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry +import com.dsm.wallet.RealHardware import com.dsm.wallet.ui.MainActivity import dsm.types.proto.AmmVaultSummaryV1 import dsm.types.proto.RouteCommitV1 @@ -82,6 +83,7 @@ import java.security.SecureRandom * - The post-trade reserve update (chunks #7 republish-on-settled) * completes within the bounded poll window after `dlv.unlockRouted`. */ +@RealHardware @RunWith(AndroidJUnit4::class) @LargeTest @FixMethodOrder(MethodSorters.NAME_ASCENDING) @@ -182,8 +184,8 @@ class SoFiTradeRealHwTest { val expectedOut = u128beToLong(rc.expectedFinalOutputAmountU128.toByteArray()) assertTrue("expected output must be > 0 (got $expectedOut)", expectedOut > 0L) assertTrue( - "hop must carry a stamped anchor-state binding (reserves digest)", - rc.hopsList[0].vaultStateReservesDigest.size() == 32, + "hop must carry a stamped parent binding (the parent state's c_n)", + rc.hopsList[0].parentBinding.size() == 32, ) Log.i(TAG, "quote: exact expected=$expectedOut (single route, anchor-bound)") @@ -212,8 +214,8 @@ class SoFiTradeRealHwTest { val owned = sofi.listOwnedAmmVaults() primaryAfter = owned.firstOrNull { it.vaultId.toByteArray().contentEquals(primaryVaultId) } if (primaryAfter != null) { - val ra = u128beToLong(primaryAfter.reserveAU128.toByteArray()) - val rb = u128beToLong(primaryAfter.reserveBU128.toByteArray()) + val ra = primaryAfter.reserveA + val rb = primaryAfter.reserveB if (ra != INITIAL_RESERVE_A || rb != INITIAL_RESERVE_B) { reservesMoved = true break @@ -237,8 +239,8 @@ class SoFiTradeRealHwTest { // Trader spends ERA (tokenB) in, gets DEMO_BBB (tokenA) out. // So reserveB INCREASES (trader put ERA into the reserve) and // reserveA DECREASES (reserve paid out DEMO_BBB). - val raAfter = u128beToLong(updated.reserveAU128.toByteArray()) - val rbAfter = u128beToLong(updated.reserveBU128.toByteArray()) + val raAfter = updated.reserveA + val rbAfter = updated.reserveB assertTrue( "reserveB (ERA) must grow ($rbAfter <= $INITIAL_RESERVE_B)", rbAfter > INITIAL_RESERVE_B, diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BleEventRelay.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BleEventRelay.kt index 3c5c10711..7ae86c523 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BleEventRelay.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BleEventRelay.kt @@ -219,6 +219,14 @@ object BleEventRelay { } } + /** Test-only: forget a previous `markBridgeReady`, so a test can prove the + * not-ready path after another test proved the ready path. */ + @androidx.annotation.VisibleForTesting + @JvmStatic + fun testResetBridgeReady() { + bridgeReady = false + } + @androidx.annotation.VisibleForTesting @JvmStatic fun testPersistDirect(ctx: Context, envelopeBytes: ByteArray) { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/envelope/transport.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/envelope/transport.rs index 0bf6703ca..a314ff97a 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/envelope/transport.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/envelope/transport.rs @@ -30,11 +30,85 @@ pub fn from_canonical_bytes(bytes: &[u8]) -> Result { Ok(env) } +/// The error code carried by a transport envelope, or `None` for anything that +/// is not an Error envelope. +/// +/// Envelopes cross the JNI boundary FRAMED: a leading `0x03` byte precedes the +/// canonical v3 bytes (`processEnvelopeV3` returns the ingress response framed, +/// and the bridge's error builders frame theirs the same way). The canonical +/// decoder refuses that byte, so a detector that decoded framed bytes as they +/// arrive reported every response as "not an error" — the on-device vector +/// suite found exactly that: a rejected proof-cap case read as ACCEPT. The +/// frame byte is stripped when present, as the request path strips it, so +/// framed and bare envelopes both decode. +pub fn error_code_of_transport_bytes(bytes: &[u8]) -> Option { + let bytes = if bytes.first() == Some(&0x03) { + &bytes[1..] + } else { + bytes + }; + match from_canonical_bytes(bytes) { + Ok(env) => match env.payload { + Some(crate::generated::envelope::Payload::Error(e)) => Some(e.code), + _ => None, + }, + Err(_) => None, + } +} + #[cfg(test)] mod tests { use super::*; use crate::generated::Headers; + /// A framed error envelope and its bare form both report their code; an + /// empty buffer and a lone frame byte never do. + #[test] + fn an_error_envelope_is_detected_framed_and_bare() { + use crate::generated as pb; + let error_env = Envelope { + version: 3, + headers: Some(pb::Headers { + device_id: vec![1; 32], + chain_tip: vec![2; 32], + genesis_hash: vec![3; 32], + seq: 0, + }), + message_id: vec![7; 16], + payload: Some(pb::envelope::Payload::Error(pb::Error { + code: 470, + message: "proof too large".to_string(), + context: Vec::new(), + source_tag: 0, + is_recoverable: false, + debug_b32: String::new(), + })), + }; + let bare = to_canonical_bytes(&error_env); + let mut framed = vec![0x03]; + framed.extend_from_slice(&bare); + assert_eq!( + error_code_of_transport_bytes(&framed), + Some(470), + "framed error" + ); + assert_eq!( + error_code_of_transport_bytes(&bare), + Some(470), + "bare error" + ); + assert_eq!( + error_code_of_transport_bytes(&[]), + None, + "empty is not an error" + ); + assert_eq!( + error_code_of_transport_bytes(&[0x03]), + None, + "a frame byte alone" + ); + } + #[test] fn sdk_envelope_roundtrip_preserves_fields() { let env = Envelope { 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 a0db03632..15506132b 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 @@ -1202,14 +1202,11 @@ pub extern "system" fn Java_com_dsm_native_DsmNative_getTransportHeadersV3Pack( ) } +/// The error code of a framed or bare Error envelope, or `None` — see +/// `crate::envelope::transport::error_code_of_transport_bytes` for why the +/// frame byte must be stripped here. fn is_error_envelope_bytes(bytes: &[u8]) -> Option { - match crate::envelope::from_canonical_bytes(bytes) { - Ok(env) => match env.payload { - Some(pb::envelope::Payload::Error(e)) => Some(e.code), - _ => None, - }, - Err(_) => None, - } + crate::envelope::transport::error_code_of_transport_bytes(bytes) } /// JNI helper: return error code (>0) if envelope is an Error envelope, otherwise 0. From 55e67b9cec5db472b26eb08c1ecc703fbbd636cd Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:13:23 -0400 Subject: [PATCH 2/3] ci(android): install the NDK version Gradle pins and resolve its path in the shell The managed-device job set ANDROID_NDK_HOME from ${{ env.ANDROID_NDK_LATEST_HOME }}. An env-context expression reads only workflow-defined variables, not the runner image's, so it evaluated to an empty string and cargo-ndk refused to detect an NDK. The job now accepts SDK licenses first, installs the ndkVersion that app/build.gradle.kts pins (the same NDK its CMake build uses), and exports that path through GITHUB_ENV before make android-libs. --- .github/workflows/ci.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12620e305..67117dbff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -537,12 +537,26 @@ jobs: - name: Install cargo-ndk run: cargo install cargo-ndk --locked + - name: Accept SDK licenses (NDK and system image downloads) + run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true + + - name: Install the NDK version Gradle pins + # app/build.gradle.kts pins ndkVersion, and its CMake build uses that NDK, + # so the Rust libraries are built with the same one. The path is resolved + # in the shell: a `${{ env.* }}` expression reads only workflow-defined + # variables, never the runner image's, and evaluated to an empty + # ANDROID_NDK_HOME that cargo-ndk refused. + run: | + NDK_VERSION="$(sed -n 's/.*ndkVersion = "\([^"]*\)".*/\1/p' dsm_client/android/app/build.gradle.kts | head -1)" + test -n "$NDK_VERSION" + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null + test -d "$ANDROID_HOME/ndk/$NDK_VERSION/toolchains/llvm/prebuilt" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV" + - name: Build the SDK library for every packaged ABI # refreshDsmJniLibs (app/build.gradle.kts) refuses to package unless all # three abiFilters ABIs have a library, so building only the emulator's # x86_64 would fail before any test ran. This is the repo's own target. - env: - ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_LATEST_HOME }} run: make android-libs - name: Cache Gradle caches @@ -555,9 +569,6 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: Accept SDK licenses (system image download) - run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true - - name: Run the instrumented suite on the managed device working-directory: dsm_client/android run: | From 5fa7c15461726835e115788a790b338629774145 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:29:09 -0400 Subject: [PATCH 3/3] ci(android): install protoc for the managed-device job's SDK build With the NDK resolved, make android-libs reached the dsm build script, which compiles the protocol with prost and failed with "Could not find protoc". The job now installs protobuf-compiler the same way every other Rust job does. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67117dbff..48379174c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -534,6 +534,13 @@ jobs: workspaces: dsm_client/deterministic_state_machine key: android-all-abis + - name: Install CI system deps + # The dsm build script compiles the protocol with prost, which needs + # protoc; every other Rust job installs it the same way. + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + - name: Install cargo-ndk run: cargo install cargo-ndk --locked