From 69ae577c95c1e7a122ea64a98199b2946339ebb3 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Wed, 26 Aug 2026 13:23:18 +0200 Subject: [PATCH 1/9] test: keep the backend host across app relaunches processArguments in the session capabilities apply only to the first launch. reinstallApp and the other relaunch helpers call driver.activateApp, which starts the app with no environment, so E2E_LOCAL_HOST is lost and Env.swift falls back to the Info.plist value fixed at build time. Against a stack on another machine that means the app looks for Electrum on the simulator itself and never produces a balance, so completeOnboarding times out waiting for TotalBalance-primary. Every spec reinstalls in a before hook, so it affects all of them. Relaunch through `mobile: launchApp`, which does take an environment. Guarded on iOS and on the variable being set, so nothing changes for Android or for runs against a local stack. Co-Authored-By: Claude Opus 5 --- test/helpers/setup.ts | 20 +++++++++++++++++--- test/specs/migration.e2e.ts | 9 +++++---- test/specs/receive-ln-payments.e2e.ts | 3 ++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 77b53c04..5ba8e5c7 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -42,11 +42,25 @@ export function grantIOSCameraPermission(appIdParam?: string) { } } +export async function activateAppWithEnv(appId: string) { + // processArguments in the session capabilities only apply to the first launch, + // so a relaunch would lose E2E_LOCAL_HOST and the app would fall back to the + // Info.plist value baked in at build time. + if (driver.isIOS && process.env.E2E_LOCAL_HOST) { + await driver.execute('mobile: launchApp', { + bundleId: appId, + environment: { E2E_LOCAL_HOST: process.env.E2E_LOCAL_HOST }, + }); + return; + } + await driver.activateApp(appId); +} + export async function launchFreshApp() { const appId = getAppId(); await driver.terminateApp(appId); - await driver.activateApp(appId); + await activateAppWithEnv(appId); await sleep(3000); } @@ -62,7 +76,7 @@ export async function reinstallApp() { resetBootedIOSKeychain(); await driver.installApp(appPath); grantIOSCameraPermission(appId); - await driver.activateApp(appId); + await activateAppWithEnv(appId); } export function getRnAppPath(): string { @@ -92,7 +106,7 @@ export async function reinstallAppFromPath(appPath: string, appId: string = getA resetBootedIOSKeychain(); await driver.installApp(appPath); grantIOSCameraPermission(appId); - await driver.activateApp(appId); + await activateAppWithEnv(appId); } /** diff --git a/test/specs/migration.e2e.ts b/test/specs/migration.e2e.ts index 6dc165fb..b41e7e53 100644 --- a/test/specs/migration.e2e.ts +++ b/test/specs/migration.e2e.ts @@ -29,6 +29,7 @@ import { grantIOSCameraPermission, reinstallAppFromPath, resetBootedIOSKeychain, + activateAppWithEnv, } from '../helpers/setup'; import { getAppId } from '../helpers/constants'; import initElectrum, { ElectrumClient } from '../helpers/electrum'; @@ -172,7 +173,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { console.info(`→ Installing native app from: ${getNativeAppPath()}`); await driver.installApp(getNativeAppPath()); grantIOSCameraPermission(); - await driver.activateApp(getAppId()); + await activateAppWithEnv(getAppId()); // Restore wallet with mnemonic (uses custom flow to handle backup sheet) await restoreWallet(mnemonic!, { @@ -197,7 +198,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); await driver.installApp(getNativeAppPath()); grantIOSCameraPermission(); - await driver.activateApp(getAppId()); + await activateAppWithEnv(getAppId()); // Handle migration flow await handleMigrationFlow({ withSweep: false }); @@ -217,7 +218,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); await driver.installApp(getNativeAppPath()); grantIOSCameraPermission(); - await driver.activateApp(getAppId()); + await activateAppWithEnv(getAppId()); // Handle migration flow await handleMigrationFlow({ withSweep: false }); @@ -239,7 +240,7 @@ describe('@migration - Migration from legacy RN app to native app', () => { console.info(`→ Installing native app on top of RN: ${getNativeAppPath()}`); await driver.installApp(getNativeAppPath()); grantIOSCameraPermission(); - await driver.activateApp(getAppId()); + await activateAppWithEnv(getAppId()); // Handle migration flow await handleMigrationFlow({ withSweep: false }); diff --git a/test/specs/receive-ln-payments.e2e.ts b/test/specs/receive-ln-payments.e2e.ts index cf5aa5d2..99316347 100644 --- a/test/specs/receive-ln-payments.e2e.ts +++ b/test/specs/receive-ln-payments.e2e.ts @@ -22,6 +22,7 @@ import { } from '../helpers/actions'; import { payInvoice } from '../helpers/regtest'; import { getAppId } from '../helpers/constants'; +import { activateAppWithEnv } from '../helpers/setup'; const PAYMENT_COUNT = Number(process.env.PAYMENT_COUNT || '21'); const PAYMENT_AMOUNT = Number(process.env.PAYMENT_AMOUNT || '10'); @@ -39,7 +40,7 @@ function extractLightningInvoice(uri: string): string { describe('Receive LN payments (utility)', () => { before(async () => { const appId = getAppId(); - await driver.activateApp(appId); + await activateAppWithEnv(appId); await sleep(3000); }); From ef4b4df2f20f152764e8c55acb39ff1fad8e0ee2 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Wed, 26 Aug 2026 13:56:56 +0200 Subject: [PATCH 2/9] test: make WDA timeouts and appium logging configurable Session creation intermittently times out waiting for WebDriverAgent, and logLevel warn hides whether it is building, launching or failing to connect. Route the appium server log to artifacts and let the level and the WDA timeouts be raised per run. Defaults are unchanged. Co-Authored-By: Claude Opus 5 --- wdio.conf.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/wdio.conf.ts b/wdio.conf.ts index dc12f667..8bcdc16a 100644 --- a/wdio.conf.ts +++ b/wdio.conf.ts @@ -18,6 +18,11 @@ const appiumNewCommandTimeout = Number.parseInt( process.env.APPIUM_NEW_COMMAND_TIMEOUT ?? '300', 10 ); +const wdaLaunchTimeout = Number.parseInt(process.env.WDA_LAUNCH_TIMEOUT ?? '300000', 10); +const connectionRetryTimeout = Number.parseInt( + process.env.WDIO_CONNECTION_RETRY_TIMEOUT ?? '360000', + 10 +); export const config: WebdriverIO.Config = { // @@ -108,8 +113,8 @@ export const config: WebdriverIO.Config = { // 🩹 Stability improvements 'appium:newCommandTimeout': 300, - 'appium:wdaLaunchTimeout': 300000, - 'appium:wdaConnectionTimeout': 300000, + 'appium:wdaLaunchTimeout': wdaLaunchTimeout, + 'appium:wdaConnectionTimeout': wdaLaunchTimeout, 'appium:wdaStartupRetries': 3, 'appium:wdaStartupRetryInterval': 5000, }, @@ -122,7 +127,7 @@ export const config: WebdriverIO.Config = { // Define all options that are relevant for the WebdriverIO instance here // // Level of logging verbosity: trace | debug | info | warn | error | silent - logLevel: 'warn', + logLevel: (process.env.WDIO_LOG_LEVEL as WebdriverIO.Config['logLevel']) ?? 'warn', // // Set specific log levels per logger // loggers: @@ -153,8 +158,8 @@ export const config: WebdriverIO.Config = { // // Default timeout in milliseconds for request // if browser driver or grid doesn't send response - // Must be >= wdaLaunchTimeout (300000) to allow WDA time to start - connectionRetryTimeout: 360000, + // Must be >= wdaLaunchTimeout to allow WDA time to start + connectionRetryTimeout, // // Default request retries count connectionRetryCount: 3, @@ -163,7 +168,11 @@ export const config: WebdriverIO.Config = { // Services take over a specific job you don't want to take care of. They enhance // your test setup with almost no effort. Unlike plugins, they don't add new // commands. Instead, they hook themselves up into the test process. - services: ['appium'], + services: [ + // Appium's own log is the only place that says whether WDA is building, + // launching or failing to connect. + ['appium', { logPath: process.env.APPIUM_LOG_PATH ?? './artifacts' }], + ], // Framework you want to run your specs with. // The following are supported: Mocha, Jasmine, and Cucumber From dc8f50a43ec612ec76d42782441c7a29b2f4ef93 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Wed, 26 Aug 2026 15:06:56 +0200 Subject: [PATCH 3/9] test: cover the reachable address in LND TLS cert LND issues its own cert on first start with SANs for 127.0.0.1, ::1 and its container address. Both gRPC and REST verify the hostname, so a suite running on another machine is rejected: ERR_TLS_CERT_ALTNAME_INVALID: IP 100.116.153.66 is not in the cert list: 127.0.0.1, ::1, 172.18.0.4 tlsextraip adds the address LND is actually reached on, reusing the variable externalip already takes. Defaults to 127.0.0.1, which is already covered, so a local stack is unaffected. Co-Authored-By: Claude Opus 5 --- docker/docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index dc573267..d2ea92be 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -106,6 +106,10 @@ services: - '--noseedbackup' - '--alias=lnd' - '--externalip=${LND_EXTERNAL_IP:-127.0.0.1}' + # LND issues its own cert on first start, covering only 127.0.0.1, ::1 and + # its container address. gRPC and REST verify the hostname, so reaching it + # from another machine needs that address in the SAN list. + - '--tlsextraip=${LND_EXTERNAL_IP:-127.0.0.1}' - '--bitcoin.active' - '--bitcoin.regtest' - '--bitcoin.node=bitcoind' From df9de7ceaedc01c2a59beea5a320a4ea10190847 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Fri, 28 Aug 2026 18:15:45 +0200 Subject: [PATCH 4/9] test: reach LND REST at its configured address in the lnurl spec The spec builds its own LNURL server pointed at LND over REST, but pinned the address to 127.0.0.1:8080 while taking the macaroon and cert from lndConfig. Against a stack on another machine there is nothing on loopback, so the lnurl-channel flow never completes and ConnectButton never appears. Use lndConfig for the address too, which #206 already made configurable. Co-Authored-By: Claude Opus 5 --- test/specs/lnurl.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/specs/lnurl.e2e.ts b/test/specs/lnurl.e2e.ts index 5bfa85e6..c67b6208 100644 --- a/test/specs/lnurl.e2e.ts +++ b/test/specs/lnurl.e2e.ts @@ -92,7 +92,7 @@ describe('@lnurl - LNURL', () => { lightning: { backend: 'lnd', config: { - hostname: '127.0.0.1:8080', + hostname: `${lndConfig.restHost}:${lndConfig.restPort}`, macaroon: lndConfig.macaroonPath, cert: lndConfig.tls, }, From 9948c6c62609be1ad8309a599cce75b1147cc5b7 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Fri, 28 Aug 2026 23:17:56 +0200 Subject: [PATCH 5/9] test: stabilize capped amount feedback checks --- test/helpers/actions.ts | 60 ++++++++++++++++++++++++++++++++++++++++- test/specs/lnurl.e2e.ts | 15 +++-------- test/specs/send.e2e.ts | 20 +++----------- 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index f40e6121..143f16f5 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -883,7 +883,10 @@ export async function waitForTextToDisappear(texts: string[], timeout: number) { async function assertAddressTypeSwitchFeedback() { // await waitForToast('AddressTypeApplyingToast', { dismiss: false }); - await waitForToast('AddressTypeSettingsUpdatedToast'); + await waitForToast('AddressTypeSettingsUpdatedToast', { + dismiss: driver.isAndroid, + timeout: 120_000, + }); } export async function switchPrimaryAddressType(nextType: addressTypePreference) { @@ -1265,6 +1268,61 @@ export async function waitForToast( } } +async function waitForTransientToastAfterAction( + toastId: ToastId, + action: () => Promise +) { + if (driver.isAndroid) { + await action(); + await waitForToast(toastId); + return; + } + + // These feedback toasts live for 1.5 seconds. XCUITest's default all-match lookup can + // find one, then lose it while rebinding the accessibility snapshot. Scope the faster + // single-match lookup to this top-level element so normal nested lookups stay unchanged. + await driver.updateSettings({ useFirstMatch: true }); + try { + await browser.waitUntil( + async () => { + await action(); + try { + const toast = await elementById(toastId); + return Boolean(toast.elementId); + } catch { + return false; + } + }, + { + timeout: 30_000, + interval: 250, + timeoutMsg: `Timed out waiting for transient toast: ${toastId}`, + } + ); + } finally { + await driver.updateSettings({ useFirstMatch: false }); + } +} + +export async function exceedAmountInputCap(maxAmountSats: number) { + await enterAmount(maxAmountSats); + await verifyAmountToSend(maxAmountSats); + await waitForTransientToastAfterAction('SendAmountExceededToast', async () => { + await tap('N1'); + }); + await verifyAmountToSend(maxAmountSats); +} + +export async function exceedAvailableAmountInputCap() { + await tap('AvailableAmount'); + const availableAmountSats = await getAmountUnder('AvailableAmount'); + await verifyAmountToSend(availableAmountSats); + await waitForTransientToastAfterAction('SendAmountExceededToast', async () => { + await tap('N1'); + }); + await verifyAmountToSend(availableAmountSats); +} + /** Acknowledges the received payment notification by tapping the button. */ export async function acknowledgeReceivedPayment({ timeout = 30_000 }: { timeout?: number } = {}) { diff --git a/test/specs/lnurl.e2e.ts b/test/specs/lnurl.e2e.ts index c67b6208..5c07b310 100644 --- a/test/specs/lnurl.e2e.ts +++ b/test/specs/lnurl.e2e.ts @@ -25,6 +25,7 @@ import { acknowledgeReceivedPayment, acknowledgeExternalSuccess, enterAmount, + exceedAmountInputCap, } from '../helpers/actions'; import { reinstallApp } from '../helpers/setup'; import { ciIt } from '../helpers/suite'; @@ -175,18 +176,10 @@ describe('@lnurl - LNURL', () => { await enterAddressViaScanPrompt(payRequest1.encoded, { acceptCameraPermission: false }); await expectTextWithin('SendNumberField', '0'); - // Check that 149 sats is below minimum and 201 sats is above maximum (both rejected) - try { - await enterAmount(201); - await waitForToast('SendAmountExceededToast', { dismiss: driver.isAndroid }); - } catch { - console.warn('SendAmountExceededToast not triggered, trying again...'); - // tap on 1 fast to trigger the toast - await elementById('N1').click(); - await waitForToast('SendAmountExceededToast', { dismiss: driver.isAndroid }); - } + // Check that input above the 200 sat maximum is capped and 149 sats is rejected as below minimum + await exceedAmountInputCap(200); - await multiTap('NRemove', 3); // remove "201" + await multiTap('NRemove', 3); // remove "200" await enterAmount(149); await expectTextWithin('SendNumberField', '149'); await tap('ContinueAmount'); diff --git a/test/specs/send.e2e.ts b/test/specs/send.e2e.ts index 7be89431..cb329330 100644 --- a/test/specs/send.e2e.ts +++ b/test/specs/send.e2e.ts @@ -25,7 +25,7 @@ import { editRecipientAddress, typeRecipientInput, tap, - enterAmount, + exceedAvailableAmountInputCap, verifyAmountToSend, } from '../helpers/actions'; import { lndConfig } from '../helpers/constants'; @@ -145,14 +145,7 @@ describe('@send - Send', () => { // type amount over balance and verify you cannot continue await tap('AddressContinue'); - await enterAmount(amount + 1); - try { - await waitForToast('SendAmountExceededToast'); - } catch { - console.warn('SendAmountExceededToast not triggered, trying again...'); - await elementById('N1').click(); - await waitForToast('SendAmountExceededToast'); - } + await exceedAvailableAmountInputCap(); await tap('NavigationBack'); // check validation for unified invoice when balance is enough (10_000 sats) @@ -265,14 +258,7 @@ describe('@send - Send', () => { const { paymentRequest: invoice0 } = await lnd.addInvoice({}); console.info({ invoice0 }); await enterAddress(invoice0); - await enterAmount(10_000 + 1); - try { - await waitForToast('SendAmountExceededToast'); - } catch { - console.warn('SendAmountExceededToast not triggered, trying again...'); - await elementById('N1').click(); - await waitForToast('SendAmountExceededToast'); - } + await exceedAvailableAmountInputCap(); await swipeFullScreen('down'); // send to onchain address From 48ba50b62dd9226f76fafed97b6e2234cc61c9c4 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Sun, 30 Aug 2026 10:58:19 +0200 Subject: [PATCH 6/9] test: read the available amount from its MoneyText element getAmountUnder scans for static text descendants, but AvailableAmount exposes its value through a nested MoneyText, so the lookup found nothing and @send_1 and @send_2 failed on every attempt. Read it the way the onchain spec and getTotalBalance already do. Co-Authored-By: Claude Opus 5 --- test/helpers/actions.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index 143f16f5..af170ba4 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -1314,8 +1314,11 @@ export async function exceedAmountInputCap(maxAmountSats: number) { } export async function exceedAvailableAmountInputCap() { + // AvailableAmount exposes its value through a nested MoneyText rather than a + // plain static text, and tapping it raises a toast over the element. + const availableText = await (await elementByIdWithin('AvailableAmount', 'MoneyText')).getText(); + const availableAmountSats = Number(availableText.replace(/[^\d]/g, '')); await tap('AvailableAmount'); - const availableAmountSats = await getAmountUnder('AvailableAmount'); await verifyAmountToSend(availableAmountSats); await waitForTransientToastAfterAction('SendAmountExceededToast', async () => { await tap('N1'); From 78dce12d1ef90ec277e69a0048131654b2509dd0 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Sun, 30 Aug 2026 10:58:19 +0200 Subject: [PATCH 7/9] test: wait out the balance unit toast instead of dragging it The toast expires before the drag runs, so dismissing it failed the element lookup and flaked @settings_01. Co-Authored-By: Claude Opus 5 --- test/specs/settings.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/specs/settings.e2e.ts b/test/specs/settings.e2e.ts index 28fc6c1c..14d39966 100644 --- a/test/specs/settings.e2e.ts +++ b/test/specs/settings.e2e.ts @@ -45,7 +45,7 @@ describe('@settings - Settings', () => { } await expect(fiatSymbol).toHaveText('$'); if (driver.isIOS) { - await waitForToast('BalanceUnitSwitchedToast'); + await waitForToast('BalanceUnitSwitchedToast', { waitToDisappear: true }); } // - change settings (currency to EUR) // From f0c3e97a84fb2be0dbe9417b0ea0deff9d932168 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Sun, 30 Aug 2026 12:04:07 +0200 Subject: [PATCH 8/9] test: let the trezor emulator run on another machine The controller script only needs python3 with websockets and reads its endpoint from TREZOR_CONTROLLER_WS, so docker exec was only supplying an interpreter. Under TREZOR_REMOTE=1 it runs locally against a forwarded controller socket and the container lifecycle is left to whoever started it, which lets the suite drive an emulator on a host that has Docker. Default behaviour is unchanged. The compose service also gains a host-gateway alias and an overridable MACOS flag so it can start on Linux. Co-Authored-By: Claude Opus 5 --- docker/docker-compose.yml | 6 ++- scripts/trezor-emulator | 86 +++++++++++++++++++++++++++++---------- 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d2ea92be..eed4c849 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -218,8 +218,12 @@ services: - '6080:6080' # noVNC web viewer - '9003:9003' # MCP server SSE transport environment: - - MACOS=1 + - MACOS=${TREZOR_MACOS:-1} - REGTEST_RPC_URL=http://host.docker.internal:43782 + # host.docker.internal is implicit on Docker Desktop but not on Linux, where + # the emulator otherwise cannot reach the published bitcoind port. + extra_hosts: + - 'host.docker.internal:host-gateway' volumes: - './.trezor-user-env/trezor-suite:/trezor-user-env/trezor-suite' - './.trezor-user-env/logs/screens:/trezor-user-env/logs/screens' diff --git a/scripts/trezor-emulator b/scripts/trezor-emulator index 8f5b1b82..28fde6b4 100755 --- a/scripts/trezor-emulator +++ b/scripts/trezor-emulator @@ -46,6 +46,32 @@ compose() { docker compose -f "$COMPOSE_FILE" "$@" } +# The container may live on another machine, with its controller websocket and +# bridge reached over a port forward. The controller script only needs python3 +# with `websockets`, so it runs here instead of via `docker exec`. +remote_mode() { + [[ "${TREZOR_REMOTE:-0}" == "1" ]] +} + +# Callers fill CONTROLLER_ENVS with KEY=VALUE entries first. A global rather +# than a nameref, which bash 3.2 on macOS does not have. +CONTROLLER_ENVS=() + +run_controller_script() { + if remote_mode; then + env "${CONTROLLER_ENVS[@]}" "${TREZOR_LOCAL_PYTHON:-python3}" "$CONTROLLER_SCRIPT" "$@" + return + fi + + local docker_envs=() kv + for kv in "${CONTROLLER_ENVS[@]}"; do + docker_envs+=(-e "$kv") + done + docker exec -i "${docker_envs[@]}" \ + "$TREZOR_CONTAINER" "${TREZOR_CONTAINER_PYTHON:-/trezor-user-env/.venv/bin/python3}" - "$@" \ + < "$CONTROLLER_SCRIPT" +} + compose_with_profile() { if [[ -n "${TREZOR_PROFILE:-}" ]]; then compose --profile "$TREZOR_PROFILE" "$@" @@ -57,6 +83,8 @@ compose_with_profile() { resolve_container() { local compose_id + remote_mode && return 0 + if [[ -n "${TREZOR_CONTAINER:-}" ]] && docker inspect "$TREZOR_CONTAINER" >/dev/null 2>&1; then return 0 fi @@ -75,6 +103,7 @@ container_exists() { } container_running() { + remote_mode && return 0 resolve_container || return 1 [[ "$(docker inspect -f '{{.State.Running}}' "$TREZOR_CONTAINER" 2>/dev/null)" == "true" ]] } @@ -106,11 +135,15 @@ prepare_dirs() { } random_mnemonic() { - docker exec -i "$TREZOR_CONTAINER" /trezor-user-env/.venv/bin/python3 - <<'PY' -from mnemonic import Mnemonic + local py="${TREZOR_CONTAINER_PYTHON:-/trezor-user-env/.venv/bin/python3}" + local script='from mnemonic import Mnemonic +print(Mnemonic("english").generate(strength=128))' -print(Mnemonic("english").generate(strength=128)) -PY + if remote_mode; then + "${TREZOR_LOCAL_PYTHON:-python3}" -c "$script" + else + docker exec -i "$TREZOR_CONTAINER" "$py" -c "$script" + fi } resolve_mnemonic() { @@ -141,28 +174,32 @@ controller() { echo "Using Trezor mnemonic: $mnemonic" >&2 fi - docker exec -i \ - -e TREZOR_CONTROLLER_WS="${TREZOR_CONTROLLER_WS:-ws://127.0.0.1:9001}" \ - -e TREZOR_MODEL="${TREZOR_MODEL:-T2T1}" \ - -e TREZOR_FIRMWARE="${TREZOR_FIRMWARE:-2-main}" \ - -e TREZOR_BRIDGE_VERSION="${TREZOR_BRIDGE_VERSION:-node-bridge}" \ - -e TREZOR_MNEMONIC="$mnemonic" \ - -e TREZOR_PIN="${TREZOR_PIN:-}" \ - -e TREZOR_PASSPHRASE_PROTECTION="${TREZOR_PASSPHRASE_PROTECTION:-false}" \ - -e TREZOR_LABEL="${TREZOR_LABEL:-Bitkit Test Trezor}" \ - -e TREZOR_NEEDS_BACKUP="${TREZOR_NEEDS_BACKUP:-false}" \ - -e TREZOR_WIPE="${TREZOR_WIPE:-true}" \ - "$TREZOR_CONTAINER" "$python_bin" - "$@" < "$CONTROLLER_SCRIPT" + local envs=( + TREZOR_CONTROLLER_WS="${TREZOR_CONTROLLER_WS:-ws://127.0.0.1:9001}" + TREZOR_MODEL="${TREZOR_MODEL:-T2T1}" + TREZOR_FIRMWARE="${TREZOR_FIRMWARE:-2-main}" + TREZOR_BRIDGE_VERSION="${TREZOR_BRIDGE_VERSION:-node-bridge}" + TREZOR_MNEMONIC="$mnemonic" + TREZOR_PIN="${TREZOR_PIN:-}" + TREZOR_PASSPHRASE_PROTECTION="${TREZOR_PASSPHRASE_PROTECTION:-false}" + TREZOR_LABEL="${TREZOR_LABEL:-Bitkit Test Trezor}" + TREZOR_NEEDS_BACKUP="${TREZOR_NEEDS_BACKUP:-false}" + TREZOR_WIPE="${TREZOR_WIPE:-true}" + ) + CONTROLLER_ENVS=("${envs[@]}") + TREZOR_CONTAINER_PYTHON="$python_bin" run_controller_script "$@" } get_address() { local address_path="${TREZOR_ADDRESS_PATH:-m/84h/1h/0h/0/0}" - docker exec -i \ - -e TREZOR_CONTROLLER_WS="${TREZOR_CONTROLLER_WS:-ws://127.0.0.1:9001}" \ - -e TREZOR_ADDRESS_COIN="${TREZOR_ADDRESS_COIN:-Regtest}" \ - -e TREZOR_ADDRESS_PATH="$address_path" \ - "$TREZOR_CONTAINER" "${TREZOR_CONTAINER_PYTHON:-/trezor-user-env/.venv/bin/python3}" - get-address < "$CONTROLLER_SCRIPT" + local envs=( + TREZOR_CONTROLLER_WS="${TREZOR_CONTROLLER_WS:-ws://127.0.0.1:9001}" + TREZOR_ADDRESS_COIN="${TREZOR_ADDRESS_COIN:-Regtest}" + TREZOR_ADDRESS_PATH="$address_path" + ) + CONTROLLER_ENVS=("${envs[@]}") + run_controller_script get-address } print_address() { @@ -211,6 +248,11 @@ install_apple_silicon_sdl_packages() { } start_env() { + if remote_mode; then + wait_for_controller + return + fi + prepare_dirs # Force recreate so a stopped container with a deleted compose network @@ -371,6 +413,8 @@ stop() { controller stop || true fi + remote_mode && return 0 + compose_with_profile stop "$TREZOR_SERVICE" } From bd8b031fe9a3ce73549e71492bd0152de1473437 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Sun, 30 Aug 2026 22:29:53 +0200 Subject: [PATCH 9/9] fix: raise trezorlib's Timeout when the bridge is slow protocol_v1.probe drains stale responses with a 0.1s read and stops when it catches Timeout, but call_bridge lets the requests exception through. On loopback the bridge always answers inside that window; reached over a port forward it does not, and the probe dies instead of breaking. Co-Authored-By: Claude Opus 5 --- scripts/trezor-controller.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/trezor-controller.py b/scripts/trezor-controller.py index fca4e45e..ff5701e4 100755 --- a/scripts/trezor-controller.py +++ b/scripts/trezor-controller.py @@ -95,12 +95,36 @@ async def raw(payload: str) -> None: await send(parsed) +def _translate_bridge_timeouts() -> None: + """Raise trezorlib's Timeout for a slow bridge, not the requests one. + + protocol_v1.probe drains stale responses with a 0.1s read and relies on + catching Timeout to stop. call_bridge lets the requests exception through, + which only shows up once the bridge is reached over a link slower than that + read timeout. + """ + import requests + from trezorlib.transport import Timeout, bridge + + original = bridge.call_bridge + + def call_bridge(*args, **kwargs): + try: + return original(*args, **kwargs) + except requests.exceptions.Timeout as exc: + raise Timeout(str(exc)) from exc + + bridge.call_bridge = call_bridge + + def get_address() -> None: from trezorlib import btc, messages from trezorlib.client import get_default_client from trezorlib.tools import parse_path from trezorlib.transport.bridge import BridgeTransport + _translate_bridge_timeouts() + transport = None for _ in range(30): transport = next(iter(BridgeTransport.enumerate()), None)