Skip to content
Open
10 changes: 9 additions & 1 deletion docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -214,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'
Expand Down
24 changes: 24 additions & 0 deletions scripts/trezor-controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 65 additions & 21 deletions scripts/trezor-emulator
Original file line number Diff line number Diff line change
Expand Up @@ -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" "$@"
Expand All @@ -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
Expand All @@ -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" ]]
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -371,6 +413,8 @@ stop() {
controller stop || true
fi

remote_mode && return 0

compose_with_profile stop "$TREZOR_SERVICE"
}

Expand Down
63 changes: 62 additions & 1 deletion test/helpers/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1265,6 +1268,64 @@ export async function waitForToast(
}
}

async function waitForTransientToastAfterAction(
toastId: ToastId,
action: () => Promise<void>
) {
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() {
// 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');
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 } = {}) {
Expand Down
20 changes: 17 additions & 3 deletions test/helpers/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}

/**
Expand Down
17 changes: 5 additions & 12 deletions test/specs/lnurl.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
acknowledgeReceivedPayment,
acknowledgeExternalSuccess,
enterAmount,
exceedAmountInputCap,
} from '../helpers/actions';
import { reinstallApp } from '../helpers/setup';
import { ciIt } from '../helpers/suite';
Expand Down Expand Up @@ -92,7 +93,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,
},
Expand Down Expand Up @@ -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');
Expand Down
Loading