diff --git a/.env.example b/.env.example index 78d23fa491..52b9df57b0 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,5 @@ # TREZOR_BRIDGE=true # TREZOR_BRIDGE_URL=http://10.0.2.2:21325 +# Point Trezor and hardware wallet flows at the local regtest Electrum from bitkit-docker. +# TREZOR_ELECTRUM_URL=tcp://10.0.2.2:60001 diff --git a/README.md b/README.md index d89a536e3b..f358751073 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,10 @@ When testing the Trezor Bridge emulator from bitkit-docker through Android Studi ```properties TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://10.0.2.2:21325 +TREZOR_ELECTRUM_URL=tcp://10.0.2.2:60001 ``` -CLI builds can still pass the same values as environment variables. +CLI builds can still pass the same values as environment variables. See [docs/trezor-emulator.md](docs/trezor-emulator.md) for the full emulator setup. ### Lint diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1866008f4a..99757265e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,7 @@ val geoEnv = envFlag("GEO", default = true) val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") val trezorBridgeUrlEnv = localProp("TREZOR_BRIDGE_URL").orElse("http://10.0.2.2:21325") +val trezorElectrumUrlEnv = localProp("TREZOR_ELECTRUM_URL").orElse("") val networkPerFlavor = mapOf("dev" to "REGTEST", "mainnet" to "BITCOIN", "tnet" to "TESTNET") val requestedNdkVersion = providers.environmentVariable("NDK_VERSION").orNull?.takeIf { it.isNotBlank() } val androidTestAnnotationPackage = "to.bitkit.test.annotations" @@ -325,6 +326,7 @@ androidComponents { buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) + buildConfigFields.put("TREZOR_ELECTRUM_URL", trezorElectrumUrlEnv.stringField()) buildConfigFields.put("GEO", geoEnv.booleanField()) buildConfigFields.put("FEATURE_PAYKIT_UI_DISABLED", paykitUiDisabledEnv.booleanField()) buildConfigFields.put("LOCALES", provider { bcp47Locales.joinToString(",") }.stringField()) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index e857ef58fe..d414437c18 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -184,6 +184,17 @@ internal object Env { else -> "https://bitkit.stag0.blocktank.to/backups-ldk" } + /** + * Debug/E2E-only Electrum override for Trezor and hardware wallet flows, set via `TREZOR_ELECTRUM_URL`. + * Points them at the local regtest Electrum from bitkit-docker while the rest of the app keeps its + * configured server. + */ + val trezorElectrumUrl: String? + get() = BuildConfig.TREZOR_ELECTRUM_URL.takeIf { it.isNotBlank() && (isDebug || isE2eTest) } + + fun trezorElectrumUrlOrDefault(configured: String, network: BitkitCoreNetwork): String = + trezorElectrumUrl?.takeIf { network == BitkitCoreNetwork.REGTEST } ?: configured + fun electrumUrlForNetwork(network: BitkitCoreNetwork): String { val isE2eLocal = isE2eTest && e2eBackend == "local" return when (network) { @@ -191,7 +202,7 @@ internal object Env { BitkitCoreNetwork.TESTNET, BitkitCoreNetwork.TESTNET4, BitkitCoreNetwork.SIGNET -> ElectrumServers.TESTNET BitkitCoreNetwork.REGTEST -> - if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG + trezorElectrumUrl ?: if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG } } diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 4e368f709e..b8585ed4c0 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -403,7 +403,10 @@ class HwWalletRepo @Inject constructor( signedTx: HwFundingSignedTx, ): Result = withContext(ioDispatcher) { runSuspendCatching { - val txId = trezorRepo.broadcastRawTx(serializedTx = signedTx.serializedTx).getOrThrow() + val txId = trezorRepo.broadcastRawTx( + serializedTx = signedTx.serializedTx, + network = Env.network.toCoreNetwork(), + ).getOrThrow() HwFundingBroadcastResult( txId = txId, miningFeeSats = signedTx.miningFeeSats, @@ -636,7 +639,15 @@ class HwWalletRepo @Inject constructor( val desiredWatchers = combine( hwWalletStore.data, settingsStore.data - .map { WatcherSettings(it.addressTypesToMonitor.toSet(), it.electrumServer) } + .map { + WatcherSettings( + monitoredTypes = it.addressTypesToMonitor.toSet(), + electrumUrl = Env.trezorElectrumUrlOrDefault( + configured = it.electrumServer, + network = Env.network.toCoreNetwork(), + ), + ) + } .distinctUntilChanged(), ) { data, settings -> data.knownDevices to settings diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 2c66ba607a..bc3a06e711 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -447,7 +447,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getTransactionHistory( extendedKey = extendedKey, - electrumUrl = currentElectrumUrl(), + electrumUrl = currentElectrumUrl(network), network = network, scriptType = scriptType, ) @@ -466,7 +466,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getAccountInfo( extendedKey = extendedKey, - electrumUrl = currentElectrumUrl(), + electrumUrl = currentElectrumUrl(network), network = network, scriptType = scriptType, ) @@ -484,7 +484,7 @@ class TrezorRepo @Inject constructor( awaitSetup() trezorService.getAddressInfo( address = address, - electrumUrl = currentElectrumUrl(), + electrumUrl = currentElectrumUrl(network), network = network, ) }.onFailure { e -> @@ -509,7 +509,7 @@ class TrezorRepo @Inject constructor( val params = ComposeParams( wallet = WalletParams( extendedKey = extendedKey, - electrumUrl = currentElectrumUrl(), + electrumUrl = currentElectrumUrl(network), fingerprint = fingerprint, network = network, accountType = accountType, @@ -542,12 +542,13 @@ class TrezorRepo @Inject constructor( suspend fun broadcastRawTx( serializedTx: String, + network: BitkitCoreNetwork, ): Result = withContext(ioDispatcher) { runSuspendCatching { awaitSetup() trezorService.broadcastRawTx( serializedTx = serializedTx, - electrumUrl = currentElectrumUrl(), + electrumUrl = currentElectrumUrl(network), ) }.onFailure { Logger.error("Trezor broadcastRawTx failed", it, context = TAG) @@ -975,7 +976,7 @@ class TrezorRepo @Inject constructor( gapLimit = gapLimit, ) trezorService.startWatcher(params, eventBridge) - TrezorDebugLog.log(WATCHER_TAG, "Started watcher '$watcherId'") + TrezorDebugLog.log(WATCHER_TAG, "Started watcher '$watcherId' on '$electrumUrl'") }.onFailure { Logger.error("Start watcher failed", it, context = TAG) _state.update { s -> s.copy(error = trezorErrorMessage(it)) } @@ -1240,7 +1241,11 @@ class TrezorRepo @Inject constructor( private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = Env.electrumUrlForNetwork(network) - private suspend fun currentElectrumUrl(): String = settingsStore.data.first().electrumServer + private suspend fun currentElectrumUrl(network: BitkitCoreNetwork): String = + Env.trezorElectrumUrlOrDefault( + configured = settingsStore.data.first().electrumServer, + network = network, + ) private suspend fun ensureConnected() { if (trezorService.isConnected()) return diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index e79abfdef6..67d6d6cf81 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -447,7 +447,7 @@ class TrezorViewModel @Inject constructor( val signedStep = state.sendStep as? SendStep.Signed ?: return@launch val rawTx = signedStep.signedTx.serializedTx _uiState.update { it.copy(send = it.send.copy(isBroadcasting = true)) } - trezorRepo.broadcastRawTx(serializedTx = rawTx) + trezorRepo.broadcastRawTx(serializedTx = rawTx, network = state.selectedNetwork) .onSuccess { txid -> TrezorDebugLog.log("BROADCAST", "SUCCESS txid=$txid") _uiState.update { diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 5eb92f8b6e..1b2649ad41 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -1555,7 +1555,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isFailure) verify(trezorRepo, never()).signTxFromPsbt(any(), anyOrNull()) - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), any()) verify(trezorRepo, never()).disconnectStaleSession(any()) } @@ -1585,7 +1585,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(1_250uL, result.getOrThrow().miningFeeSats) assertEquals(3uL, result.getOrThrow().feeRate) assertEquals(26_250uL, result.getOrThrow().totalSpent) - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), any()) } @Test @@ -1596,7 +1596,7 @@ class HwWalletRepoTest : BaseUnitTest() { feeRate = 3uL, totalSpent = 26_250uL, ) - whenever(trezorRepo.broadcastRawTx("rawtx")).thenReturn(Result.success("broadcast-txid")) + whenever(trezorRepo.broadcastRawTx(eq("rawtx"), any())).thenReturn(Result.success("broadcast-txid")) val sut = createRepo() val result = sut.broadcastFunding(signedTx) @@ -1617,7 +1617,7 @@ class HwWalletRepoTest : BaseUnitTest() { feeRate = 3uL, totalSpent = 26_250uL, ) - whenever(trezorRepo.broadcastRawTx("rawtx")) + whenever(trezorRepo.broadcastRawTx(eq("rawtx"), any())) .thenReturn(Result.success("core-derived-txid")) val sut = createRepo() @@ -1645,7 +1645,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isFailure) verify(trezorRepo).disconnectStaleSession("dev1") - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), any()) } @Test @@ -1666,7 +1666,7 @@ class HwWalletRepoTest : BaseUnitTest() { assertEquals(true, result.isFailure) verify(trezorRepo, never()).disconnectStaleSession(any()) - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), any()) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index fa1bd073a7..5e99017234 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -262,14 +262,14 @@ class TrezorViewModelTest : BaseUnitTest() { sut.broadcastSignedTx() advanceUntilIdle() - verify(trezorRepo, never()).broadcastRawTx(any()) + verify(trezorRepo, never()).broadcastRawTx(any(), any()) } @Test fun `broadcastSignedTx should not restore signed step after reset`() = test { loadSignedTx() val broadcastResult = CompletableDeferred>() - whenever(trezorRepo.broadcastRawTx(any())) + whenever(trezorRepo.broadcastRawTx(any(), any())) .doSuspendableAnswer { broadcastResult.await() } sut.broadcastSignedTx() @@ -293,7 +293,7 @@ class TrezorViewModelTest : BaseUnitTest() { val broadcastResults = ArrayDeque( listOf(firstBroadcastResult, secondBroadcastResult) ) - whenever(trezorRepo.broadcastRawTx(any())) + whenever(trezorRepo.broadcastRawTx(any(), any())) .doSuspendableAnswer { broadcastResults.removeFirst().await() } sut.broadcastSignedTx() diff --git a/docs/trezor-emulator.md b/docs/trezor-emulator.md new file mode 100644 index 0000000000..5412140f63 --- /dev/null +++ b/docs/trezor-emulator.md @@ -0,0 +1,69 @@ +# Trezor Emulator + +The deterministic Trezor User Env from [`synonymdev/bitkit-docker`](https://github.com/synonymdev/bitkit-docker) stands in for a physical Trezor during development. It exposes an emulated T2T1 through Trezor Bridge over HTTP, so the app talks to it with the same wallet protocol it uses over USB. Everything except the USB stack, BLE and the one-time pair code is covered; see `journeys/hardware-wallet/README.md` for the full breakdown of what the Bridge transport does and does not simulate. + +## Start the Emulator + +Run the stack and the helper from a sibling `bitkit-docker` checkout: + +```sh +cd ../bitkit-docker +docker compose up -d +./scripts/trezor-emulator start +``` + +The helper wipes and sets up a deterministic device: model `T2T1`, firmware `2-main`, seed `all all all all all all all all all all all all`, no PIN, label `Bitkit Test Trezor`. Bridge listens on `21325` and the User Env dashboard on `9002`. Linux hosts need the host-network service: `docker compose --profile trezor-linux up -d trezor-user-env-linux`. + +Other subcommands: `status` (bridge/emulator state plus a Bridge enumerate), `adb` (reverse the Bridge port for a physical phone), `logs`, `stop`. Helper internals, environment overrides and troubleshooting live in `bitkit-docker/docs/trezor-emulator.md`. + +## Build Flags + +| Flag | Default | Effect | +| - | - | - | +| `TREZOR_BRIDGE` | `false` | Enables the Bridge transport in `TrezorBridgeTransport`. Without it, Bridge devices are never enumerated. | +| `TREZOR_BRIDGE_URL` | `http://10.0.2.2:21325` | Bridge endpoint. Use `http://127.0.0.1:21325` on a physical phone after `trezor-emulator adb`. | +| `TREZOR_ELECTRUM_URL` | unset | Electrum server for Trezor and hardware wallet flows only. The rest of the app keeps the server configured under Settings → Advanced → Electrum Server. | + +Each resolves in the same order: environment variable, then `-P` Gradle property, then `local.properties`. Android Studio builds read `local.properties`; CLI and `just` builds read `.env` or inline environment variables. See `.env.example` for the commented template. + +`TREZOR_ELECTRUM_URL` applies only to debug and E2E builds, and only on regtest. It is resolved by `Env.trezorElectrumUrl` and applied in `Env.electrumUrlForNetwork` (the Dev Settings watcher), `TrezorRepo.currentElectrumUrl` (account info, compose, broadcast) and `HwWalletRepo` (the production watchers). + +## Install + +Android emulator: + +```sh +TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://10.0.2.2:21325 \ + TREZOR_ELECTRUM_URL=tcp://10.0.2.2:60001 ./gradlew installDevDebug +``` + +Physical phone, after reversing both the Bridge and Electrum ports: + +```sh +../bitkit-docker/scripts/trezor-emulator adb +adb reverse tcp:60001 tcp:60001 +TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://127.0.0.1:21325 \ + TREZOR_ELECTRUM_URL=tcp://127.0.0.1:60001 ./gradlew installDevDebug +``` + +## Funding the Emulator Wallet + +Copy an address from Dev Settings → Trezor → Get Address, then send and mine from `bitkit-docker`: + +```sh +cd ../bitkit-docker +./bitcoin-cli send 0.1
+``` + +The balance reaches the hardware wallet tile only when `TREZOR_ELECTRUM_URL` points at the local regtest node. Without it, watchers run against the staging regtest Electrum, which does not know about locally mined coins. `Started watcher '' on ''` in the app log confirms which server is in use. + +## Smoke Checklist + +Open Settings → Advanced → Dev Settings → Trezor, then verify: + +- Scan lists the Bridge emulator device +- Connect succeeds and device features are shown +- Get address and get public key succeed +- Sign and verify message succeed +- Send or compose reaches the expected funded or no-funds state +- Disconnect, reconnect and forget-device cleanup behave correctly diff --git a/journeys/hardware-wallet/README.md b/journeys/hardware-wallet/README.md index 248acf8062..617102ff77 100644 --- a/journeys/hardware-wallet/README.md +++ b/journeys/hardware-wallet/README.md @@ -2,9 +2,9 @@ AI-driven UI test journeys for the home-screen hardware wallet features, designed to run against the deterministic Trezor emulator from `synonymdev/bitkit-docker` — no physical -Trezor required. Journeys follow the `android` CLI journey XML format: natural-language -`` steps evaluated sequentially against the running app; any failed step fails -the journey. +Trezor required; see `docs/trezor-emulator.md` for the emulator and build-flag setup. +Journeys follow the `android` CLI journey XML format: natural-language `` steps +evaluated sequentially against the running app; any failed step fails the journey. ## What the Bridge emulator does and does not simulate @@ -48,12 +48,17 @@ instead of UI interactions. ```sh TREZOR_PASSPHRASE_PROTECTION=true ../bitkit-docker/scripts/trezor-emulator start ``` -3. For a physical phone, reverse the Bridge port and install with Bridge enabled: +3. For a physical phone, reverse the Bridge and Electrum ports and install with Bridge enabled: ```sh ../bitkit-docker/scripts/trezor-emulator adb - TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://127.0.0.1:21325 ./gradlew installDevDebug + adb reverse tcp:60001 tcp:60001 + TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://127.0.0.1:21325 \ + TREZOR_ELECTRUM_URL=tcp://127.0.0.1:60001 ./gradlew installDevDebug ``` - For an Android emulator use `TREZOR_BRIDGE_URL=http://10.0.2.2:21325`. + For an Android emulator use `http://10.0.2.2:21325` and `tcp://10.0.2.2:60001` instead. + `TREZOR_ELECTRUM_URL` keeps the hardware wallet watchers on the local regtest node so + coins mined from `bitkit-docker` show up; see `docs/trezor-emulator.md` for the full + flag reference. 4. A wallet must exist in the app (onboarding completed) on regtest (dev flavor). ## Journeys