diff --git a/.gitignore b/.gitignore index beeb1b2..3f273c2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules docker/lnd docker/lnurl-server-data docker/lnurl-server/data +docker/.trezor-user-env artifacts WARP.md .ai diff --git a/README.md b/README.md index 6b3b0e5..2b33526 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,137 @@ BACKEND=local ./scripts/build-ios-sim.sh BACKEND=regtest ./scripts/build-ios-sim.sh ``` +Optional Trezor Bridge support is disabled by default and can be enabled per build: + +```bash +# Android emulator, local backend +TREZOR_BRIDGE=true ./scripts/build-android-apk.sh + +# Android physical device, local backend +TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://127.0.0.1:21325 ./scripts/build-android-apk.sh + +# Android emulator, staging regtest backend +BACKEND=regtest TREZOR_BRIDGE=true ./scripts/build-android-apk.sh + +# iOS simulator, local backend +TREZOR_BRIDGE=true TREZOR_ELECTRUM_URL=tcp://127.0.0.1:60001 ./scripts/build-ios-sim.sh + +# iOS simulator, staging regtest backend +BACKEND=regtest TREZOR_BRIDGE=true ./scripts/build-ios-sim.sh +``` + --- +### ๐Ÿ” Manual Trezor Emulator Setup + +The local docker setup includes an opt-in Trezor User Env fixture for manual hardware-wallet checks. It starts the official Trezor emulator and Bridge, but it is not part of the default `docker compose up -d` stack. + +Default emulator state: + +- Model: `T2T1` +- Firmware: `2-main` +- Bridge: `node-bridge` +- Mnemonic: random 12-word BIP39 phrase generated on each `start` +- PIN: empty +- Passphrase protection: off +- Label: `Bitkit Test Trezor` + +Start the emulator: + +```bash +./scripts/trezor-emulator start +./scripts/trezor-emulator status +``` + +`start` prints the generated mnemonic and the first native regtest receive address (`m/84h/1h/0h/0/0`) so it can be funded during manual checks. +`start` refuses to wipe/reseed an already-running emulator. Use `stop` before starting a new one. + +For CI or scripts, use JSON output: + +```bash +./scripts/trezor-emulator start --json > artifacts/trezor-emulator.json +./scripts/trezor-emulator status --json +``` + +`status --json` returns the current receive address metadata, but not the mnemonic. + +Use the deterministic seed when you want to reuse known history/funds: + +```bash +TREZOR_RANDOM_MNEMONIC=false ./scripts/trezor-emulator start +``` + +Or provide an explicit seed: + +```bash +TREZOR_MNEMONIC="all all all all all all all all all all all all" ./scripts/trezor-emulator start +``` + +Override the printed address coin/path when needed: + +```bash +TREZOR_ADDRESS_COIN=Testnet TREZOR_ADDRESS_PATH="m/84h/1h/0h/0/0" ./scripts/trezor-emulator start +``` + +Useful URLs: + +- User Env dashboard: `http://localhost:9002` +- Trezor Bridge: `http://localhost:21325` + +The Trezor User Env image is pinned in `docker/docker-compose.yml` so the emulator Bridge keeps the raw message format expected by current Bitkit builds. + +#### Android Emulator + +```bash +# Local backend +cd docker +docker compose up -d +cd .. +./scripts/trezor-emulator start +TREZOR_BRIDGE=true ./scripts/build-android-apk.sh +npm run e2e:android + +# Staging regtest backend +./scripts/trezor-emulator start +BACKEND=regtest TREZOR_BRIDGE=true ./scripts/build-android-apk.sh +BACKEND=regtest npm run e2e:android +``` + +For manual checks, open Bitkit and use the app's developer Trezor screen to scan and connect to `Bitkit Test Trezor`. + +#### Android Physical Device + +```bash +./scripts/trezor-emulator start +./scripts/trezor-emulator adb +TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://127.0.0.1:21325 ./scripts/build-android-apk.sh +``` + +#### iOS Simulator + +```bash +# Local backend +cd docker +docker compose up -d +cd .. +./scripts/trezor-emulator start +TREZOR_BRIDGE=true TREZOR_ELECTRUM_URL=tcp://127.0.0.1:60001 ./scripts/build-ios-sim.sh +npm run e2e:ios + +# Staging regtest backend +./scripts/trezor-emulator start +BACKEND=regtest TREZOR_BRIDGE=true ./scripts/build-ios-sim.sh +BACKEND=regtest npm run e2e:ios +``` + +Stop the emulator when finished: + +```bash +./scripts/trezor-emulator stop +``` + +Backend and Trezor are independent. `BACKEND=local` uses local Bitcoin/Electrum, while `BACKEND=regtest` uses remote staging regtest services. The Trezor emulator always provides only the device and Bridge. Fund or mine against the same backend the app was built for. + ### ๐Ÿงช Running tests **Important:** The `BACKEND` environment variable controls which infrastructure the tests use for blockchain operations (deposits, mining blocks): diff --git a/ci_run_android.sh b/ci_run_android.sh index 1e4fc18..a44d7fc 100755 --- a/ci_run_android.sh +++ b/ci_run_android.sh @@ -56,6 +56,8 @@ if [[ "${BACKEND:-local}" != "mainnet" ]]; then adb reverse tcp:30001 tcp:30001 # homegate port adb reverse tcp:6288 tcp:6288 + # trezor bridge port + adb reverse tcp:21325 tcp:21325 fi # show touches adb shell settings put system show_touches 1 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8c07ddd..e8796ee 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,3 +1,5 @@ +x-trezor-user-env-image: &trezor_user_env_image ghcr.io/trezor/trezor-user-env@sha256:15871ebb234bf7c0197cfcd31ddc2edbe90173cd6386fc0283fafe05bf888c06 + services: bitcoind: container_name: bitcoin @@ -199,6 +201,41 @@ services: ports: - '6288:8080' + trezor-user-env-mac: + image: *trezor_user_env_image + profiles: + - trezor + ports: + - '9001:9001' # User Env controller websocket + - '9002:9002' # User Env dashboard + - '21325:21325' # Trezor Bridge legacy port used by Bitkit + - '21328:21328' # Trezor Bridge current port + - '15900:5900' # VNC port, offset to avoid local VNC conflicts + - '6080:6080' # noVNC web viewer + - '9003:9003' # MCP server SSE transport + environment: + - MACOS=1 + - REGTEST_RPC_URL=http://host.docker.internal:43782 + volumes: + - './.trezor-user-env/trezor-suite:/trezor-user-env/trezor-suite' + - './.trezor-user-env/logs/screens:/trezor-user-env/logs/screens' + - './.trezor-user-env/logs/mcp-screenshots:/trezor-user-env/logs/mcp-screenshots' + - './.trezor-user-env/firmware/user_downloaded:/trezor-user-env/src/binaries/firmware/bin/user_downloaded' + + trezor-user-env-linux: + container_name: trezor-user-env.unix + image: *trezor_user_env_image + profiles: + - trezor-linux + network_mode: 'host' + environment: + - PHYSICAL_TREZOR=${PHYSICAL_TREZOR:-} + volumes: + - './.trezor-user-env/trezor-suite:/trezor-user-env/trezor-suite' + - './.trezor-user-env/logs/screens:/trezor-user-env/logs/screens' + - './.trezor-user-env/logs/mcp-screenshots:/trezor-user-env/logs/mcp-screenshots' + - './.trezor-user-env/firmware/user_downloaded:/trezor-user-env/src/binaries/firmware/bin/user_downloaded' + volumes: bitcoin_home: diff --git a/scripts/adb-reverse.sh b/scripts/adb-reverse.sh index 0019881..7c4140b 100755 --- a/scripts/adb-reverse.sh +++ b/scripts/adb-reverse.sh @@ -7,6 +7,8 @@ for d in $(adb devices | awk 'NR>1 && $2=="device" {print $1}'); do adb -s "$d" reverse tcp:9735 tcp:9735 adb -s "$d" reverse tcp:30001 tcp:30001 adb -s "$d" reverse tcp:6288 tcp:6288 + adb -s "$d" reverse tcp:21325 tcp:21325 + done echo "Done." \ No newline at end of file diff --git a/scripts/build-android-apk.sh b/scripts/build-android-apk.sh index 7d6dfa1..a41727f 100755 --- a/scripts/build-android-apk.sh +++ b/scripts/build-android-apk.sh @@ -16,12 +16,16 @@ # ./scripts/build-android-apk.sh # BACKEND=regtest ./scripts/build-android-apk.sh # BACKEND=mainnet ./scripts/build-android-apk.sh +# TREZOR_BRIDGE=true ./scripts/build-android-apk.sh +# TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://127.0.0.1:21325 ./scripts/build-android-apk.sh set -euo pipefail E2E_ROOT="$(cd "$(dirname "$0")/.." && pwd)" ANDROID_ROOT="$(cd "$E2E_ROOT/../bitkit-android" && pwd)" BACKEND="${BACKEND:-local}" +TREZOR_BRIDGE="${TREZOR_BRIDGE:-false}" +TREZOR_BRIDGE_URL="${TREZOR_BRIDGE_URL:-http://10.0.2.2:21325}" E2E_BACKEND="local" GRADLE_TASK="assembleDevDebug" APK_FLAVOR_DIR="dev/debug" @@ -40,10 +44,14 @@ else echo "ERROR: Unsupported BACKEND value: $BACKEND" >&2 exit 1 fi -echo "Building Android APK (BACKEND=$BACKEND, E2E_BACKEND=$E2E_BACKEND, GRADLE_TASK=$GRADLE_TASK)..." +echo "Building Android APK (BACKEND=$BACKEND, E2E_BACKEND=$E2E_BACKEND, TREZOR_BRIDGE=$TREZOR_BRIDGE, TREZOR_BRIDGE_URL=$TREZOR_BRIDGE_URL, GRADLE_TASK=$GRADLE_TASK)..." pushd "$ANDROID_ROOT" >/dev/null -E2E=true E2E_BACKEND="$E2E_BACKEND" ./gradlew "$GRADLE_TASK" --no-daemon --stacktrace +E2E=true \ + E2E_BACKEND="$E2E_BACKEND" \ + TREZOR_BRIDGE="$TREZOR_BRIDGE" \ + TREZOR_BRIDGE_URL="$TREZOR_BRIDGE_URL" \ + ./gradlew "$GRADLE_TASK" --no-daemon --stacktrace popd >/dev/null # Find the universal APK diff --git a/scripts/build-ios-sim.sh b/scripts/build-ios-sim.sh index 3dadbbc..8a68e0c 100755 --- a/scripts/build-ios-sim.sh +++ b/scripts/build-ios-sim.sh @@ -14,11 +14,16 @@ # Usage: # ./scripts/build-ios-sim.sh # BACKEND=regtest ./scripts/build-ios-sim.sh +# TREZOR_BRIDGE=true ./scripts/build-ios-sim.sh +# TREZOR_BRIDGE=true BACKEND=regtest ./scripts/build-ios-sim.sh set -euo pipefail E2E_ROOT="$(cd "$(dirname "$0")/.." && pwd)" IOS_ROOT="$(cd "$E2E_ROOT/../bitkit-ios" && pwd)" BACKEND="${BACKEND:-local}" +TREZOR_BRIDGE="${TREZOR_BRIDGE:-false}" +TREZOR_BRIDGE_URL="${TREZOR_BRIDGE_URL:-http://127.0.0.1:21325}" +TREZOR_ELECTRUM_URL="${TREZOR_ELECTRUM_URL:-}" E2E_BACKEND="local" E2E_NETWORK="regtest" XCODE_EXTRA_ARGS=() @@ -35,9 +40,17 @@ fi XCODE_EXTRA_ARGS+=( "E2E_BACKEND=$E2E_BACKEND" "E2E_NETWORK=$E2E_NETWORK" + "TREZOR_BRIDGE=$TREZOR_BRIDGE" + "TREZOR_BRIDGE_URL=$TREZOR_BRIDGE_URL" "SWIFT_ACTIVE_COMPILATION_CONDITIONS=\$(inherited) E2E_BUILD" ) +if [[ -n "$TREZOR_ELECTRUM_URL" ]]; then + XCODE_EXTRA_ARGS+=("TREZOR_ELECTRUM_URL=$TREZOR_ELECTRUM_URL") +fi + +echo "Building iOS simulator app (BACKEND=$BACKEND, E2E_BACKEND=$E2E_BACKEND, TREZOR_BRIDGE=$TREZOR_BRIDGE, TREZOR_BRIDGE_URL=$TREZOR_BRIDGE_URL)..." + xcodebuild \ -project "$IOS_ROOT/Bitkit.xcodeproj" \ -scheme Bitkit \ diff --git a/scripts/trezor-controller.py b/scripts/trezor-controller.py new file mode 100755 index 0000000..fca4e45 --- /dev/null +++ b/scripts/trezor-controller.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Small client for the Trezor User Env websocket controller. + +The shell helper runs this file inside the User Env container so the host does +not need a Python websocket package installed. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import time +from typing import Any + +import websockets + + +CONTROLLER_WS = os.environ.get("TREZOR_CONTROLLER_WS", "ws://127.0.0.1:9001") +DEFAULT_MNEMONIC = "all all all all all all all all all all all all" + + +def next_id() -> int: + next_id.value += 1 + return next_id.value + + +next_id.value = 0 + + +async def send(payload: dict[str, Any], *, allow_failure: bool = False) -> dict[str, Any]: + async with websockets.connect(CONTROLLER_WS) as websocket: + await websocket.recv() + await websocket.send(json.dumps(payload)) + raw_response = await websocket.recv() + + response = json.loads(raw_response) + print(json.dumps(response, indent=2, sort_keys=True)) + + if not allow_failure and not response.get("success", False): + raise RuntimeError(response.get("error", response)) + + return response + + +async def setup() -> None: + await send( + { + "type": "bridge-start", + "version": os.environ.get("TREZOR_BRIDGE_VERSION", "node-bridge"), + "id": next_id(), + } + ) + await send( + { + "type": "emulator-start", + "model": os.environ.get("TREZOR_MODEL", "T2T1"), + "version": os.environ.get("TREZOR_FIRMWARE", "2-main"), + "wipe": os.environ.get("TREZOR_WIPE", "true").lower() != "false", + "id": next_id(), + } + ) + await send( + { + "type": "emulator-setup", + "mnemonic": os.environ.get("TREZOR_MNEMONIC", DEFAULT_MNEMONIC), + "pin": os.environ.get("TREZOR_PIN", ""), + "passphrase_protection": os.environ.get( + "TREZOR_PASSPHRASE_PROTECTION", "false" + ).lower() + == "true", + "label": os.environ.get("TREZOR_LABEL", "Bitkit Test Trezor"), + "needs_backup": os.environ.get("TREZOR_NEEDS_BACKUP", "false").lower() + == "true", + "id": next_id(), + } + ) + await status() + + +async def status() -> None: + await send({"type": "background-check", "id": next_id()}) + + +async def stop() -> None: + await send({"type": "emulator-stop", "id": next_id()}, allow_failure=True) + await send({"type": "bridge-stop", "id": next_id()}, allow_failure=True) + await status() + + +async def raw(payload: str) -> None: + parsed = json.loads(payload) + parsed.setdefault("id", next_id()) + await send(parsed) + + +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 + + transport = None + for _ in range(30): + transport = next(iter(BridgeTransport.enumerate()), None) + if transport is not None: + break + time.sleep(1) + + if transport is None: + raise SystemExit("No Trezor Bridge device found.") + + coin = os.environ.get("TREZOR_ADDRESS_COIN", "Regtest") + path = os.environ.get("TREZOR_ADDRESS_PATH", "m/84h/1h/0h/0/0").replace("h", "'") + + client = get_default_client("bitkit-e2e", transport) + with client: + session = client.get_session() + address = btc.get_address( + session, + coin, + parse_path(path), + show_display=False, + script_type=messages.InputScriptType.SPENDWITNESS, + ) + + print(address) + + +async def main() -> None: + command = sys.argv[1] if len(sys.argv) > 1 else "setup" + + if command == "ping": + await send({"type": "ping", "id": next_id()}) + elif command == "setup": + await setup() + elif command == "status": + await status() + elif command == "stop": + await stop() + elif command == "send-json": + if len(sys.argv) != 3: + raise SystemExit("send-json expects one JSON payload argument") + await raw(sys.argv[2]) + elif command == "get-address": + get_address() + else: + raise SystemExit(f"Unknown controller command: {command}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/trezor-emulator b/scripts/trezor-emulator new file mode 100755 index 0000000..e205d3d --- /dev/null +++ b/scripts/trezor-emulator @@ -0,0 +1,455 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="$ROOT_DIR/docker/docker-compose.yml" +CONTROLLER_SCRIPT="$ROOT_DIR/scripts/trezor-controller.py" +TREZOR_DATA_DIR="$ROOT_DIR/docker/.trezor-user-env" + +DASHBOARD_URL="${TREZOR_DASHBOARD_URL:-http://localhost:9002}" +BRIDGE_URL="${TREZOR_BRIDGE_URL:-http://localhost:21325}" +BRIDGE_PORT="${TREZOR_BRIDGE_PORT:-21325}" + +usage() { + cat <<'EOF' +Usage: ./scripts/trezor-emulator + +Commands: + start Start Trezor User Env, Bridge, and a T2T1 emulator + start --json + Start and print a machine-readable summary to stdout + status Show User Env bridge/emulator status and Bridge enumerate output + status --json + Print the current receive address metadata + adb Reverse the Bridge port for a physical Android device + stop Stop the emulator and Bridge; stop repo-managed User Env when owned here + logs Tail the User Env container logs + send-json Send one raw JSON command to the User Env controller + help Show this help + +Useful environment overrides: + TREZOR_MODEL=T2T1 + TREZOR_FIRMWARE=2-main + TREZOR_BRIDGE_VERSION=node-bridge + TREZOR_RANDOM_MNEMONIC=true + TREZOR_MNEMONIC="all all all all all all all all all all all all" + TREZOR_ADDRESS_COIN=Regtest + TREZOR_ADDRESS_PATH="m/84'/1'/0'/0/0" + TREZOR_LABEL="Bitkit Test Trezor" + TREZOR_WIPE=false +EOF +} + +detect_service() { + case "$(uname -s)" in + Darwin) + TREZOR_PROFILE="${TREZOR_PROFILE:-trezor}" + TREZOR_SERVICE="${TREZOR_SERVICE:-trezor-user-env-mac}" + ;; + Linux) + TREZOR_PROFILE="${TREZOR_PROFILE:-trezor-linux}" + TREZOR_SERVICE="${TREZOR_SERVICE:-trezor-user-env-linux}" + ;; + *) + echo "Unsupported OS for the bundled Trezor User Env helper: $(uname -s)" >&2 + exit 1 + ;; + esac +} + +compose() { + docker compose -f "$COMPOSE_FILE" "$@" +} + +compose_with_profile() { + if [[ -n "${TREZOR_PROFILE:-}" ]]; then + compose --profile "$TREZOR_PROFILE" "$@" + else + compose "$@" + fi +} + +resolve_container() { + local compose_id + + if [[ -n "${TREZOR_CONTAINER:-}" ]] && docker inspect "$TREZOR_CONTAINER" >/dev/null 2>&1; then + return 0 + fi + + compose_id="$(compose_with_profile ps -q "$TREZOR_SERVICE" 2>/dev/null | head -n 1 || true)" + if [[ -n "$compose_id" ]]; then + TREZOR_CONTAINER="$compose_id" + return 0 + fi + + return 1 +} + +container_exists() { + resolve_container +} + +container_running() { + resolve_container || return 1 + [[ "$(docker inspect -f '{{.State.Running}}' "$TREZOR_CONTAINER" 2>/dev/null)" == "true" ]] +} + +container_image() { + resolve_container || return 0 + docker inspect -f '{{.Config.Image}}' "$TREZOR_CONTAINER" 2>/dev/null || true +} + +container_project() { + resolve_container || return 0 + docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}' "$TREZOR_CONTAINER" 2>/dev/null || true +} + +container_logs() { + if container_exists; then + docker logs "$@" "$TREZOR_CONTAINER" + else + compose_with_profile logs "$@" "$TREZOR_SERVICE" + fi +} + +prepare_dirs() { + mkdir -p \ + "$TREZOR_DATA_DIR/trezor-suite" \ + "$TREZOR_DATA_DIR/logs/screens" \ + "$TREZOR_DATA_DIR/logs/mcp-screenshots" \ + "$TREZOR_DATA_DIR/firmware/user_downloaded" +} + +random_mnemonic() { + docker exec -i "$TREZOR_CONTAINER" /trezor-user-env/.venv/bin/python3 - <<'PY' +from mnemonic import Mnemonic + +print(Mnemonic("english").generate(strength=128)) +PY +} + +resolve_mnemonic() { + if [[ -n "${TREZOR_MNEMONIC:-}" ]]; then + echo "$TREZOR_MNEMONIC" + return + fi + + if [[ "${TREZOR_RANDOM_MNEMONIC:-true}" == "true" ]]; then + random_mnemonic + return + fi + + echo "all all all all all all all all all all all all" +} + +controller() { + local python_bin="${TREZOR_CONTAINER_PYTHON:-/trezor-user-env/.venv/bin/python3}" + local mnemonic="all all all all all all all all all all all all" + + resolve_container || { + echo "Trezor User Env container is not running yet." >&2 + return 1 + } + + if [[ "${1:-}" == "setup" ]]; then + mnemonic="${TREZOR_MNEMONIC:-$(resolve_mnemonic)}" + 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" +} + +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" +} + +print_address() { + local address_path="${TREZOR_ADDRESS_PATH:-m/84h/1h/0h/0/0}" + local display_path="${address_path//h/\'}" + local address + + address="$(get_address)" + echo "Trezor address (${TREZOR_ADDRESS_COIN:-Regtest}, $display_path): $address" +} + +wait_for_controller() { + local attempt + + if ! container_running; then + echo "Trezor User Env container is not running. Use ./scripts/trezor-emulator start." >&2 + return 1 + fi + + for attempt in $(seq 1 60); do + if controller ping >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + echo "Trezor User Env controller did not become ready." >&2 + container_logs --tail=80 >&2 || true + exit 1 +} + +install_apple_silicon_sdl_packages() { + if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then + return 0 + fi + + docker exec "$TREZOR_CONTAINER" sh -lc ' + if dpkg -s libsdl3-0 libsdl3-image0 >/dev/null 2>&1; then + exit 0 + fi + + echo "Installing SDL3 runtime packages required by the Trezor emulator..." + apt-get update + apt-get install -y libsdl3-0 libsdl3-image0 + ' +} + +start_env() { + prepare_dirs + + compose_with_profile up -d "$TREZOR_SERVICE" + TREZOR_CONTAINER="$(compose_with_profile ps -q "$TREZOR_SERVICE" | head -n 1)" + + wait_for_controller + install_apple_silicon_sdl_packages +} + +bridge_enumerate() { + curl --fail --silent --show-error -X POST "$BRIDGE_URL/enumerate" +} + +emulator_running() { + local status + + status="$(controller status 2>/dev/null || true)" + STATUS="$status" python3 - <<'PY' +import json +import os +import sys + +try: + status = json.loads(os.environ.get("STATUS", "")) +except json.JSONDecodeError: + print("false") + raise SystemExit(0) + +print("true" if status.get("emulator_status", {}).get("is_running") else "false") +PY +} + +print_ready_notes() { + cat <&2 + if [[ "$(emulator_running)" == "true" ]]; then + echo "Trezor emulator is already running. Use './scripts/trezor-emulator status' or './scripts/trezor-emulator stop' before starting a new one." >&2 + exit 1 + fi + mnemonic="$(resolve_mnemonic)" + TREZOR_MNEMONIC="$mnemonic" controller setup >&2 + address="$(get_address)" + print_json_summary "$mnemonic" "$address" + return + fi + + start_env + if [[ "$(emulator_running)" == "true" ]]; then + echo "Trezor emulator is already running." + echo + echo "Use './scripts/trezor-emulator status' to inspect it." + echo "Use './scripts/trezor-emulator stop' before starting a new one." + exit 1 + fi + mnemonic="$(resolve_mnemonic)" + TREZOR_MNEMONIC="$mnemonic" controller setup + address="$(get_address)" + local address_path="${TREZOR_ADDRESS_PATH:-m/84h/1h/0h/0/0}" + local display_path="${address_path//h/\'}" + + echo "Trezor address (${TREZOR_ADDRESS_COIN:-Regtest}, $display_path): $address" + echo + echo "Bridge enumerate:" + bridge_enumerate + echo + print_ready_notes +} + +status() { + wait_for_controller + controller status + echo + echo "Bridge enumerate:" + bridge_enumerate + echo +} + +status_json() { + local address + + wait_for_controller + + address="$(get_address)" + print_json_summary "" "$address" +} + +adb_reverse() { + if ! command -v adb >/dev/null 2>&1; then + echo "adb is not available on PATH." >&2 + exit 1 + fi + + adb reverse "tcp:$BRIDGE_PORT" "tcp:$BRIDGE_PORT" +} + +stop() { + if resolve_container && container_running; then + controller stop || true + fi + + compose_with_profile stop "$TREZOR_SERVICE" +} + +logs() { + container_logs -f +} + +send_json() { + if [[ $# -ne 1 ]]; then + echo "send-json expects one JSON payload argument." >&2 + exit 1 + fi + + wait_for_controller + controller send-json "$1" +} + +main() { + detect_service + + local command="${1:-help}" + shift || true + + case "$command" in + start) + local output_json="false" + + while [[ $# -gt 0 ]]; do + case "$1" in + --json) + output_json="true" + ;; + *) + usage >&2 + exit 1 + ;; + esac + shift + done + + start "$output_json" + ;; + status) + case "${1:-}" in + --json) + shift + if [[ $# -ne 0 ]]; then + usage >&2 + exit 1 + fi + status_json + ;; + "") + status + ;; + *) + usage >&2 + exit 1 + ;; + esac + ;; + adb) adb_reverse ;; + stop) stop ;; + logs) logs ;; + send-json) send_json "$@" ;; + help|--help|-h) usage ;; + *) + usage >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/test/helpers/actions.ts b/test/helpers/actions.ts index 7cc6f24..ff488be 100644 --- a/test/helpers/actions.ts +++ b/test/helpers/actions.ts @@ -327,7 +327,7 @@ function checkBalanceCondition( } } -async function expectBalanceWithWait( +export async function expectBalanceWithWait( getter: () => Promise, name: string, expected: number, @@ -1151,10 +1151,12 @@ export async function receiveOnchainFunds({ sats = 100_000, blocksToMine = 1, expectHighBalanceWarning = false, + verifyBalances = true, }: { sats?: number; blocksToMine?: number; expectHighBalanceWarning?: boolean; + verifyBalances?: boolean; } = {}) { // receive some first const address = await getReceiveAddress(); @@ -1168,9 +1170,11 @@ export async function receiveOnchainFunds({ await acknowledgeReceivedPaymentIfPresent(); } - await expectTotalBalance(sats); - await expectSavingsBalance(sats); - await expectSpendingBalance(0); + if (verifyBalances) { + await expectTotalBalance(sats); + await expectSavingsBalance(sats); + await expectSpendingBalance(0); + } await dismissBackupTimedSheet({ triggerTimedSheet: true }); if (expectHighBalanceWarning) { diff --git a/test/helpers/hardware-wallet.ts b/test/helpers/hardware-wallet.ts new file mode 100644 index 0000000..485a7b7 --- /dev/null +++ b/test/helpers/hardware-wallet.ts @@ -0,0 +1,178 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + acknowledgeReceivedPaymentIfPresent, + doNavigationClose, + elementById, + expectBalanceWithWait, + expectTextWithin, + expectText, + formatSats, + getAmountUnder, + sleep, + tap, + typeText, + handleAndroidAlert, +} from './actions'; +import { openHomeWidgets, openSettings } from './navigation'; +import { deposit, mineBlocks } from './regtest'; + +const E2E_ROOT = path.resolve(__dirname, '..', '..'); +const ARTIFACTS_DIR = path.join(E2E_ROOT, 'artifacts'); +const TREZOR_FIXTURE_PATH = path.join(ARTIFACTS_DIR, 'trezor-emulator.json'); + +export type TrezorEmulatorFixture = { + dashboardUrl: string; + bridgeUrl: string; + mnemonic: string | null; + address: { + coin: string; + path: string; + value: string; + }; +}; + +function runTrezorEmulatorJson(args: string[]): TrezorEmulatorFixture { + const output = execFileSync('./scripts/trezor-emulator', args, { + cwd: E2E_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); + return JSON.parse(output) as TrezorEmulatorFixture; +} + +function runTrezorEmulator(args: string[]) { + execFileSync('./scripts/trezor-emulator', args, { + cwd: E2E_ROOT, + stdio: 'inherit', + }); +} + +function writeFixture(fixture: TrezorEmulatorFixture): TrezorEmulatorFixture { + fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); + fs.writeFileSync(TREZOR_FIXTURE_PATH, `${JSON.stringify(fixture, null, 2)}\n`); + return fixture; +} + +export function ensureTrezorEmulator({ fresh = false }: { fresh?: boolean } = {}): TrezorEmulatorFixture { + if (fresh) { + runTrezorEmulator(['stop']); + return writeFixture(runTrezorEmulatorJson(['start', '--json'])); + } + + try { + return writeFixture(runTrezorEmulatorJson(['status', '--json'])); + } catch { + return writeFixture(runTrezorEmulatorJson(['start', '--json'])); + } +} + +export async function openHardwareWalletSettings() { + await openSettings('general'); + await tap('HardwareWalletsSettings'); + await elementById('HardwareWalletsScreen').waitForDisplayed(); +} + +export async function startHardwareWalletFlowFromSuggestion() { + await openHomeWidgets(); + await elementById('Suggestion-hardware').waitForDisplayed({ timeout: 30_000 }); + await tap('Suggestion-hardware'); +} + +export async function connectHardwareWalletFromSettings(label: string) { + await openHardwareWalletSettings(); + await tap('AddHardwareWallet'); + await completeHardwareWalletFlow(label); + await elementById('HardwareWalletsScreen').waitForDisplayed(); +} + +export async function completeHardwareWalletFlow(label: string) { + await elementById('HardwareWalletIntroScreen').waitForDisplayed(); + await sleep(1000); + await tap('HardwareWalletIntroContinue'); + await handleAndroidAlert(); + + await elementById('HardwareWalletFoundScreen').waitForDisplayed(); + await sleep(1000); + await tap('HardwareWalletFoundConnect'); + await elementById('HardwareWalletPairedScreen').waitForDisplayed(); + await typeText('HardwareWalletLabelInput', label); + await tap('HardwareWalletPairedFinish'); + await sleep(1000); +} + +export async function expectHardwareWalletInSettings( + label: string, + { visible }: { visible: boolean } +) { + await expectText(label, { visible, strategy: 'contains' }); +} + +export async function expectHardwareSuggestion({ visible }: { visible: boolean }) { + if (visible) { + await openHomeWidgets(); + } + await elementById('Suggestion-hardware').waitForDisplayed({ + reverse: !visible, + timeout: 30_000, + }); +} + +export async function expectHardwareWalletOnHome(label: string, { visible }: { visible: boolean }) { + await doNavigationClose(); + await elementById('ActivityHardware').waitForDisplayed({ + reverse: !visible, + timeout: 30_000, + }); + if (visible) { + await expectText(label, { strategy: 'contains' }); + } +} + +export async function expectHardwareWalletBalance(expected: number): Promise { + return expectBalanceWithWait( + () => getAmountUnder('ActivityHardware'), + 'hardware wallet', + expected, + ); +} + +export async function fundHardwareWalletAndAcknowledge( + fixture: TrezorEmulatorFixture, + { sats = 15_000, blocksToMine = 1 }: { sats?: number; blocksToMine?: number } = {} +) { + await deposit(fixture.address.value, sats); + if (blocksToMine > 0) { + await mineBlocks(blocksToMine); + } + await acknowledgeReceivedPaymentIfPresent(); +} + +export async function expectHardwareWalletReceivedActivity(sats: number) { + await doNavigationClose(); + await elementById('ActivityHardware').waitForDisplayed(); + await expectTextWithin('ActivityHardware', formatSats(sats)); + await tap('ActivityHardware'); + await elementById('HardwareWalletScreen').waitForDisplayed(); + await elementById('Activity-1').waitForDisplayed(); + await expectTextWithin('Activity-1', 'Received'); + await expectTextWithin('Activity-1', formatSats(sats)); +} + +export async function removeHardwareWalletFromSettings(label: string) { + await openHardwareWalletSettings(); + await expectHardwareWalletInSettings(label, { visible: true }); + await tapFirstHardwareWalletDelete(); + await tap('DialogConfirm'); + await sleep(500); + await expectHardwareWalletInSettings(label, { visible: false }); +} + +async function tapFirstHardwareWalletDelete() { + const deleteButton = await $('android=new UiSelector().resourceIdMatches(".*HardwareWalletRowDelete_.*")'); + await deleteButton.waitForDisplayed({ timeout: 30_000 }); + await deleteButton.click(); +} + diff --git a/test/helpers/navigation.ts b/test/helpers/navigation.ts index 585a61d..fab9710 100644 --- a/test/helpers/navigation.ts +++ b/test/helpers/navigation.ts @@ -53,6 +53,18 @@ export async function openProfile() { await sleep(500); } +/** + * Opens the Home widgets page from the drawer. + * On first use, the app shows the widgets intro screen; choose the + * View & Organize action to land on the widgets page. + */ +export async function openHomeWidgets() { + await tap('HeaderMenu'); + await tap('DrawerWidgets'); + await tapWidgetsIntroViewOrganizeIfShown(); + await elementById('SuggestionsWidget').waitForDisplayed({ timeout: 30_000 }); +} + /** * Closes the drawer and navigates back to the Wallet home screen. */ @@ -75,3 +87,15 @@ export async function doTriggerTimedSheet() { await sleep(500); await doNavigationClose(); } + +async function tapWidgetsIntroViewOrganizeIfShown() { + const viewOrganize = elementById('WidgetsOnboardingViewOrganize'); + try { + await viewOrganize.waitForDisplayed({ timeout: 3_000 }); + await sleep(500); + await viewOrganize.click(); + await sleep(500); + } catch { + // Widgets intro is shown only once. + } +} diff --git a/test/specs/hardware-wallet.e2e.ts b/test/specs/hardware-wallet.e2e.ts new file mode 100644 index 0000000..baad7bc --- /dev/null +++ b/test/specs/hardware-wallet.e2e.ts @@ -0,0 +1,77 @@ +import { completeOnboarding, doNavigationClose, expectSavingsBalance, expectSpendingBalance, expectTotalBalance, receiveOnchainFunds } from '../helpers/actions'; +import initElectrum from '../helpers/electrum'; +import { + completeHardwareWalletFlow, + connectHardwareWalletFromSettings, + ensureTrezorEmulator, + expectHardwareSuggestion, + expectHardwareWalletBalance, + expectHardwareWalletInSettings, + expectHardwareWalletOnHome, + expectHardwareWalletReceivedActivity, + fundHardwareWalletAndAcknowledge, + openHardwareWalletSettings, + removeHardwareWalletFromSettings, + startHardwareWalletFlowFromSuggestion, + type TrezorEmulatorFixture, +} from '../helpers/hardware-wallet'; +import { ensureLocalFunds } from '../helpers/regtest'; +import { reinstallApp } from '../helpers/setup'; +import { ciIt } from '../helpers/suite'; + +describe('@hardware_wallet - Hardware Wallet', () => { + const walletLabel = 'E2E Trezor'; + let trezorFixture: TrezorEmulatorFixture; + let electrum: Awaited> | undefined; + + before(async function () { + if (!driver.isAndroid) { + this.skip(); + } + await ensureLocalFunds(); + electrum = await initElectrum(); + }); + + beforeEach(async () => { + trezorFixture = ensureTrezorEmulator({ fresh: true }); + await reinstallApp(); + await completeOnboarding(); + await electrum?.waitForSync(); + }); + + after(async () => { + await electrum?.stop(); + }); + + ciIt('@hardware_wallet_1 - Can connect, show, and remove a Trezor emulator wallet', async () => { + await expectHardwareSuggestion({ visible: true }); + await startHardwareWalletFlowFromSuggestion(); + await completeHardwareWalletFlow(walletLabel); + await expectHardwareSuggestion({ visible: false }); + await openHardwareWalletSettings(); + await expectHardwareWalletInSettings(walletLabel, { visible: true }); + await expectHardwareWalletOnHome(walletLabel, { visible: true }); + await removeHardwareWalletFromSettings(walletLabel); + await expectHardwareWalletInSettings(walletLabel, { visible: false }); + await expectHardwareWalletOnHome(walletLabel, { visible: false }); + }); + + ciIt('@hardware_wallet_2 - Can receive onchain funds to hardware wallet', async () => { + const sats = 15_000; + + await connectHardwareWalletFromSettings(walletLabel); + await fundHardwareWalletAndAcknowledge(trezorFixture, { sats }); + await expectHardwareWalletReceivedActivity(sats); + await doNavigationClose(); + await expectTotalBalance(sats); + await expectHardwareWalletBalance(sats); + await expectSavingsBalance(0); + await expectSpendingBalance(0); + + await receiveOnchainFunds({ sats, verifyBalances: false }); + await expectTotalBalance(2 * sats); + await expectHardwareWalletBalance(sats); + await expectSavingsBalance(sats); + await expectSpendingBalance(0); + }); +});