Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,25 @@ 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) {
BitkitCoreNetwork.BITCOIN -> ElectrumServers.MAINNET.ESPLORA
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
}
}

Expand Down
15 changes: 13 additions & 2 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,10 @@ class HwWalletRepo @Inject constructor(
signedTx: HwFundingSignedTx,
): Result<HwFundingBroadcastResult> = 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,
Expand Down Expand Up @@ -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
Expand Down
19 changes: 12 additions & 7 deletions app/src/main/java/to/bitkit/repositories/TrezorRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ class TrezorRepo @Inject constructor(
awaitSetup()
trezorService.getTransactionHistory(
extendedKey = extendedKey,
electrumUrl = currentElectrumUrl(),
electrumUrl = currentElectrumUrl(network),
network = network,
scriptType = scriptType,
)
Expand All @@ -466,7 +466,7 @@ class TrezorRepo @Inject constructor(
awaitSetup()
trezorService.getAccountInfo(
extendedKey = extendedKey,
electrumUrl = currentElectrumUrl(),
electrumUrl = currentElectrumUrl(network),
network = network,
scriptType = scriptType,
)
Expand All @@ -484,7 +484,7 @@ class TrezorRepo @Inject constructor(
awaitSetup()
trezorService.getAddressInfo(
address = address,
electrumUrl = currentElectrumUrl(),
electrumUrl = currentElectrumUrl(network),
network = network,
)
}.onFailure { e ->
Expand All @@ -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,
Expand Down Expand Up @@ -542,12 +542,13 @@ class TrezorRepo @Inject constructor(

suspend fun broadcastRawTx(
serializedTx: String,
network: BitkitCoreNetwork,
): Result<String> = withContext(ioDispatcher) {
runSuspendCatching {
awaitSetup()
trezorService.broadcastRawTx(
serializedTx = serializedTx,
electrumUrl = currentElectrumUrl(),
electrumUrl = currentElectrumUrl(network),
)
}.onFailure {
Logger.error("Trezor broadcastRawTx failed", it, context = TAG)
Expand Down Expand Up @@ -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)) }
Expand Down Expand Up @@ -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,
)
Comment on lines +1244 to +1248

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fallback still selects wrong network

When a dev-regtest build selects Bitcoin or testnet in Trezor Dev Settings, trezorElectrumUrlOrDefault rejects the regtest override but returns the app-wide regtest Electrum setting unchanged. Account lookups, composition, and broadcast therefore use an endpoint for the wrong chain, producing unrelated state or failed operations.

Knowledge Base Used: Repositories overview (non-core domains)


private suspend fun ensureConnected() {
if (trezorService.isConnected()) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Broadcast loses signed transaction network

When a user changes the network selector after composing and signing a transaction, broadcastSignedTx passes the current selectedNetwork instead of the network used to create the signed transaction. The transaction is then submitted to another network's Electrum server and broadcast is rejected.

Knowledge Base Used: UI Screens: Navigation, Screens, and Components

.onSuccess { txid ->
TrezorDebugLog.log("BROADCAST", "SUCCESS txid=$txid")
_uiState.update {
Expand Down
12 changes: 6 additions & 6 deletions app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<String>>()
whenever(trezorRepo.broadcastRawTx(any()))
whenever(trezorRepo.broadcastRawTx(any(), any()))
.doSuspendableAnswer { broadcastResult.await() }

sut.broadcastSignedTx()
Expand All @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions docs/trezor-emulator.md
Original file line number Diff line number Diff line change
@@ -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 <address>
```

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 '<id>' on '<url>'` 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
17 changes: 11 additions & 6 deletions journeys/hardware-wallet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<action>` 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 `<action>` steps
evaluated sequentially against the running app; any failed step fails the journey.

## What the Bridge emulator does and does not simulate

Expand Down Expand Up @@ -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
Expand Down
Loading