From 9305b3705862e535fa6009ac68f27184bac51a82 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 21 May 2026 19:03:58 +0200 Subject: [PATCH] feat: add trezor emulator --- .gitignore | 2 + AGENTS.md | 7 + CHANGELOG.md | 1 + README.md | 56 +++++++ docker-compose.yml | 33 ++++ docs/trezor-emulator.md | 108 +++++++++++++ scripts/trezor-controller.py | 117 ++++++++++++++ scripts/trezor-emulator | 299 +++++++++++++++++++++++++++++++++++ 8 files changed, 623 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/trezor-emulator.md create mode 100755 scripts/trezor-controller.py create mode 100755 scripts/trezor-emulator diff --git a/.gitignore b/.gitignore index bdd956a..ef3db00 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ target/ .DS_Store *.local.* .vscode/ +.trezor-user-env/ +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..587e994 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Agent Notes + +## Bitkit App Testing Docs + +- Treat `README.md` as the entry point for testing Bitkit app PRs and merged features. +- When adding or changing test workflows, keep the actionable setup and check steps in `README.md`, not only in supporting docs. +- Supporting docs such as `docs/trezor-emulator.md` may contain deeper internals and troubleshooting, but README must remain enough to run the test flow. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2313178..700aef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preserve LNURL-pay invoice millisatoshi precision by creating invoices with LND `value_msat` instead of truncating callback amounts to sats ### Added +- Trezor User Env Docker service and `scripts/trezor-emulator` helper for quickly smoke-testing Bitkit app Trezor PRs - Support `amount_msat` query param in `/generate/bolt11` endpoint for sub-sat precision invoices - `bolt11` command in `bitcoin-cli` for creating regular Lightning invoices (supports `--msat` and `-m` memo) - LND hold invoice commands in `bitcoin-cli`: `holdinvoice`, `settleinvoice`, `cancelinvoice` diff --git a/README.md b/README.md index 1dd3dc8..6697b7c 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,62 @@ docker compose logs -f bitcoind ### Bitkit Testing +#### Trezor Hardware PRs + +Use this section as the entry point when checking Bitkit app PRs or merged features that need the official Trezor emulator. Start by preparing the deterministic Trezor User Env: + +```bash +./scripts/trezor-emulator start +``` + +The macOS Trezor User Env service is included in the default `docker compose up -d` stack. The helper starts or reuses that service, then resets Bridge and the emulator into the deterministic review state. Linux users can start the host-network service with `docker compose --profile trezor-linux up -d trezor-user-env-linux`. + +The helper starts the official Trezor User Env without its regtest stack, launches Bridge, wipes a deterministic T2T1 emulator, and sets it up with the `all all ...` seed and `Bitkit Test Trezor` label. It uses `scripts/trezor-controller.py` inside the container to talk to the User Env websocket controller. + +##### Bitkit Android + +For a physical phone, reverse the Bridge port and install the dev build with Bridge enabled: + +```bash +./scripts/trezor-emulator adb +TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://127.0.0.1:21325 ./gradlew installDevDebug +``` + +For an Android emulator, install with the emulator host Bridge URL: + +```bash +TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://10.0.2.2:21325 ./gradlew installDevDebug +``` + +Open the dashboard at `Settings -> Advanced -> Dev Settings -> Trezor`, then check: + +- Scan shows the Bridge emulator device +- Connect succeeds and device features are shown +- Get address succeeds +- Get public key succeeds +- Sign and verify message succeed +- Send or compose reaches the expected funded or no-funds state +- Disconnect, reconnect, and forget-device cleanup behave correctly + +##### Bitkit iOS + +Run the relevant Trezor branch from Xcode. The User Env dashboard and Bridge are available on the host at: + +- User Env dashboard: `http://localhost:9002` +- Trezor Bridge: `http://localhost:21325` + +Open the dashboard at `Settings -> Advanced -> Trezor Hardware Wallet`, then check: + +- Scan shows the Bridge emulator device +- Connect succeeds and device features are shown +- Get address succeeds +- Get public key succeeds +- Sign and verify message succeed +- Send or compose reaches the expected funded or no-funds state +- Disconnect, reconnect, and forget-device cleanup behave correctly + +See [docs/trezor-emulator.md](docs/trezor-emulator.md) for helper internals, environment overrides, and troubleshooting commands. + #### Bech32 LNURL Pay - in `Env.{kt,swift}`, use for REGTEST electrum server: `"tcp://localhost:60001"` diff --git a/docker-compose.yml b/docker-compose.yml index 4dc71db..86db55e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -239,6 +239,39 @@ services: ports: - "5050:5050" + trezor-user-env-mac: + image: ghcr.io/trezor/trezor-user-env + 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: ghcr.io/trezor/trezor-user-env + 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: postgres_data: diff --git a/docs/trezor-emulator.md b/docs/trezor-emulator.md new file mode 100644 index 0000000..c18ce10 --- /dev/null +++ b/docs/trezor-emulator.md @@ -0,0 +1,108 @@ +# Trezor Emulator Checks + +Bitkit app PRs that need Trezor hardware behavior can use the official Trezor User Env through this repo. The helper starts the User Env without its extra regtest stack, starts Trezor Bridge, wipes a deterministic T2T1 emulator, and configures it with a stable seed and label. + +## Start the Emulator + +```bash +./scripts/trezor-emulator start +``` + +On macOS, the Trezor User Env service is part of the default compose stack, so `docker compose up -d` starts it with the rest of the Bitkit services. The helper is still useful because it resets Bridge and the emulator into the deterministic review state. + +Linux needs host networking like upstream User Env, so its service stays behind the `trezor-linux` profile: + +```bash +docker compose --profile trezor-linux up -d trezor-user-env-linux +``` + +If an upstream `trezor-user-env.mac` or `trezor-user-env.unix` container is already running, the helper reuses it instead of creating a duplicate container with the same fixed name. + +The default emulator configuration is: + +- model: `T2T1` +- firmware: `2-main` +- bridge: `node-bridge` +- mnemonic: `all all all all all all all all all all all all` +- pin: empty +- passphrase protection: off +- label: `Bitkit Test Trezor` + +You can override these with environment variables, for example: + +```bash +TREZOR_MODEL=T3T1 TREZOR_FIRMWARE=3-main ./scripts/trezor-emulator start +``` + +## App Setup + +Use the same emulator stack for Bitkit Android and Bitkit iOS work. The commands below are app-specific launch notes; the emulator and Bridge setup stays the same. + +### Bitkit Android + +For a physical phone: + +```bash +./scripts/trezor-emulator adb +TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://127.0.0.1:21325 ./gradlew installDevDebug +``` + +For an Android emulator: + +```bash +TREZOR_BRIDGE=true TREZOR_BRIDGE_URL=http://10.0.2.2:21325 ./gradlew installDevDebug +``` + +The Trezor dashboard is under `Settings -> Advanced -> Dev Settings -> Trezor`. + +### Bitkit iOS + +Run Bitkit from Xcode on the relevant Trezor branch, then open `Settings -> Advanced -> Trezor Hardware Wallet`. + +The User Env dashboard and Bridge remain available at the same localhost endpoints: + +- User Env dashboard: +- Trezor Bridge: + +## Smoke Checklist + +Use this checklist when reviewing any Bitkit app PR that needs the Trezor emulator: + +- Scan shows the Bridge emulator device. +- Connect succeeds and device features are shown. +- Get address succeeds. +- Get public key succeeds. +- Sign and verify message succeed. +- Send or compose reaches the expected funded or no-funds state. +- Disconnect, reconnect, and forget-device cleanup behave correctly. + +## Helpful Commands + +```bash +./scripts/trezor-emulator status +./scripts/trezor-emulator logs +./scripts/trezor-emulator stop +``` + +Open the User Env dashboard at . Trezor Bridge listens at . + +## How It Works + +`scripts/trezor-emulator` is the entrypoint. It starts or reuses the User Env container, then runs `scripts/trezor-controller.py` inside that container with `/trezor-user-env/.venv/bin/python3`. + +The Python script talks to the User Env websocket controller at `ws://127.0.0.1:9001` and sends the setup commands: + +- `bridge-start` +- `emulator-start` +- `emulator-setup` +- `background-check` + +Running the Python script inside the container keeps the host machine free of extra Python package requirements. The container already has the `websockets` dependency that the controller client needs. + +Use `send-json` for one-off controller commands: + +```bash +./scripts/trezor-emulator send-json '{"type":"emulator-get-features"}' +``` + +On Apple Silicon, the helper installs `libsdl3-0` and `libsdl3-image0` inside the User Env container when they are missing. diff --git a/scripts/trezor-controller.py b/scripts/trezor-controller.py new file mode 100755 index 0000000..942aea0 --- /dev/null +++ b/scripts/trezor-controller.py @@ -0,0 +1,117 @@ +#!/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 +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) + + +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]) + 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..d5bf5f6 --- /dev/null +++ b/scripts/trezor-emulator @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="$ROOT_DIR/docker-compose.yml" +CONTROLLER_SCRIPT="$ROOT_DIR/scripts/trezor-controller.py" + +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 deterministic T2T1 emulator + status Show User Env bridge/emulator status and Bridge enumerate output + 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_LABEL="Bitkit Test Trezor" + TREZOR_WIPE=false +EOF +} + +detect_service() { + case "$(uname -s)" in + Darwin) + TREZOR_PROFILE="${TREZOR_PROFILE:-}" + TREZOR_SERVICE="${TREZOR_SERVICE:-trezor-user-env-mac}" + TREZOR_UPSTREAM_CONTAINER="${TREZOR_UPSTREAM_CONTAINER:-trezor-user-env.mac}" + ;; + Linux) + TREZOR_PROFILE="${TREZOR_PROFILE:-trezor-linux}" + TREZOR_SERVICE="${TREZOR_SERVICE:-trezor-user-env-linux}" + TREZOR_UPSTREAM_CONTAINER="${TREZOR_UPSTREAM_CONTAINER:-trezor-user-env.unix}" + ;; + *) + 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 + + if [[ -n "${TREZOR_UPSTREAM_CONTAINER:-}" ]] && docker inspect "$TREZOR_UPSTREAM_CONTAINER" >/dev/null 2>&1; then + TREZOR_CONTAINER="$TREZOR_UPSTREAM_CONTAINER" + 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 \ + "$ROOT_DIR/.trezor-user-env/trezor-suite" \ + "$ROOT_DIR/.trezor-user-env/logs/screens" \ + "$ROOT_DIR/.trezor-user-env/logs/mcp-screenshots" \ + "$ROOT_DIR/.trezor-user-env/firmware/user_downloaded" +} + +controller() { + local python_bin="${TREZOR_CONTAINER_PYTHON:-/trezor-user-env/.venv/bin/python3}" + + resolve_container || { + echo "Trezor User Env container is not running yet." >&2 + return 1 + } + + 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="${TREZOR_MNEMONIC:-all all all all all all all all all all all all}" \ + -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" +} + +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 + + if container_exists; then + if [[ "$(container_image)" != "ghcr.io/trezor/trezor-user-env" ]]; then + echo "Container $TREZOR_CONTAINER already exists but is not the Trezor User Env image." >&2 + exit 1 + fi + + if container_running; then + echo "Reusing running $TREZOR_CONTAINER container from compose project: $(container_project)" + else + echo "Starting existing $TREZOR_CONTAINER container." + docker start "$TREZOR_CONTAINER" >/dev/null + fi + else + compose_with_profile up -d "$TREZOR_SERVICE" + TREZOR_CONTAINER="$(compose_with_profile ps -q "$TREZOR_SERVICE" | head -n 1)" + fi + + wait_for_controller + install_apple_silicon_sdl_packages +} + +bridge_enumerate() { + curl --fail --silent --show-error -X POST "$BRIDGE_URL/enumerate" +} + +print_ready_notes() { + cat < Advanced -> Trezor Hardware Wallet. +EOF +} + +start() { + start_env + controller setup + echo + echo "Bridge enumerate:" + bridge_enumerate + echo + print_ready_notes +} + +status() { + wait_for_controller + controller status + echo + echo "Bridge enumerate:" + bridge_enumerate + echo +} + +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) start ;; + status) status ;; + adb) adb_reverse ;; + stop) stop ;; + logs) logs ;; + send-json) send_json "$@" ;; + help|--help|-h) usage ;; + *) + usage >&2 + exit 1 + ;; + esac +} + +main "$@"