From 8c36976d256c343a4c7feb5d9288629461c4200e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 3 Oct 2026 04:07:56 +0200 Subject: [PATCH 1/4] feat: add an isolated deterministic trezor emulator fixture --- CHANGELOG.md | 1 + README.md | 33 ++++++++++++ docker-compose.yml | 36 +++++++++++++ docs/trezor-emulator.md | 37 +++++++++++++ scripts/trezor-fixture.Dockerfile | 9 ++++ scripts/trezor-fixture.py | 89 +++++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+) create mode 100644 scripts/trezor-fixture.Dockerfile create mode 100644 scripts/trezor-fixture.py diff --git a/CHANGELOG.md b/CHANGELOG.md index caa94e6..eaeceb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,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 +- Add an optional deterministic Trezor emulator and Bridge fixture for isolated regtest wallet projects. - Add an opt-in LNURL-pay regtest fixture with switchable invoice callback errors for payment retry journeys - Add Pubky marketplace test fixture behind the `marketplace` compose profile: Pubky testnet, Paykit Server `722ef268` built from pinned source, and the `pubky-marketplace` driver CLI (`up`, `seed`, `purchase`, `mine`, `verify`, `seller-auth`) for the Bitkit marketplace wallet journey with a headless or Bitkit-approved seller; see `docs/pubky-marketplace.md` - Add opt-in rc56 Payment Request fixture services for linked-peer and deadline-history wallet journeys diff --git a/README.md b/README.md index 790b206..453f1c2 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,39 @@ with the app's identity or an app build that supports importing fixture state. #### Trezor Hardware PRs +For isolated Linux or Docker-backed simulator projects, use the optional +`trezor-emulator` profile. It builds a named image from the pinned official User +Env, starts Bridge, and initializes the deterministic T2T1 device automatically: + +```bash +docker compose --profile trezor-emulator build trezor-emulator +docker compose --profile trezor-emulator up -d --wait trezor-emulator +curl -fsS -X POST http://127.0.0.1:21325/enumerate +``` + +Point the simulator's Bridge URL at `http://127.0.0.1:21325`. The dashboard is at +`http://127.0.0.1:9005`, the controller at `ws://127.0.0.1:9004`, and noVNC at +`http://127.0.0.1:6080`. For projects that remap ports, use their published Bridge +port (or the simulator seat's forwarded address). The device uses the `all all +...` seed, no PIN or passphrase, and the label `Bitkit Test Trezor`. Each project's +volumes are separate, and each startup wipes and initializes its own emulator. +The image's digest fixes both the bundled firmware and Bridge; startup downloads +nothing. Health requires setup to finish and both Bridge and the emulator to run. + +Run the existing `bitcoind` and `electrs` services for funding and broadcasting +regtest transactions. The emulator signs `Regtest` transactions without needing +its own chain backend. To control button confirmations through the debug link: + +```bash +docker compose --profile trezor-emulator exec -T trezor-emulator \ + /trezor-user-env/.venv/bin/python3 /opt/bitkit-trezor/trezor-controller.py \ + send-json '{"type":"emulator-press-yes"}' +``` + +See [docs/trezor-emulator.md](docs/trezor-emulator.md) for fixture diagnostics and +the separate manual User Env workflow below. Stop only this profile's service +with `docker compose --profile trezor-emulator stop trezor-emulator`. + 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 diff --git a/docker-compose.yml b/docker-compose.yml index 5ba2678..6c8841f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -315,6 +315,40 @@ services: ports: - "${HOMEGATE_PORT:-6288}:6288" + # Disposable, automatically initialized emulator for isolated test projects. + # Its firmware and Bridge come from the same digest as the manual User Env. + trezor-emulator: + profiles: [trezor-emulator] + image: bitkit-docker/trezor-emulator:fixture-v1 + build: + context: ./scripts + dockerfile: trezor-fixture.Dockerfile + restart: "no" + environment: + MACOS: "1" # use published ports rather than physical USB / host networking + TREZOR_MODEL: T2T1 + TREZOR_FIRMWARE: 2-main # frozen in the pinned base image + TREZOR_BRIDGE_VERSION: node-bridge + TREZOR_MNEMONIC: all all all all all all all all all all all all + TREZOR_LABEL: Bitkit Test Trezor + TREZOR_WIPE: "true" + ports: + - "127.0.0.1:21325:21325" # Bridge legacy API used by Bitkit + - "127.0.0.1:21328:21328" # Bridge current API + - "127.0.0.1:9004:9001" # controller; avoids marketplace port 3001 seat blocks + - "127.0.0.1:9005:9002" # dashboard + - "127.0.0.1:6080:6080" # emulator screen / noVNC + volumes: + - trezor_emulator_state:/var/tmp + - trezor_emulator_logs:/trezor-user-env/logs + healthcheck: + test: ["CMD", "/trezor-user-env/.venv/bin/python3", "/opt/bitkit-trezor/trezor-fixture.py", "--health"] + interval: 5s + timeout: 20s + retries: 6 + start_period: 120s + stop_grace_period: 20s + trezor-user-env-mac: image: *trezor_user_env_image ports: @@ -500,6 +534,8 @@ services: BITCOIN_RPC_URL: http://bitcoind:43782 volumes: + trezor_emulator_state: + trezor_emulator_logs: bitcoin_home: postgres_data: lnurl_auth_data: diff --git a/docs/trezor-emulator.md b/docs/trezor-emulator.md index 3160a61..4ce5688 100644 --- a/docs/trezor-emulator.md +++ b/docs/trezor-emulator.md @@ -1,5 +1,42 @@ # Trezor Emulator Checks +## Isolated automatic fixture + +The `trezor-emulator` Compose profile provides a Linux-compatible service with +published ports, a reusable named image, project-scoped state and logs, and a +healthcheck. It uses the same pinned official User Env image as the manual +workflow. SDL3 dependencies are installed at build time. Startup launches the +controller and initializes Bridge plus the deterministic T2T1 device, with bounded +startup waits and process cleanup on exit. Firmware `2-main` and `node-bridge` +refer to binaries already frozen in the base image's digest. + +```bash +docker compose --profile trezor-emulator up -d --build --wait trezor-emulator +docker compose --profile trezor-emulator ps trezor-emulator +docker compose --profile trezor-emulator logs --tail 80 trezor-emulator +curl -fsS -X POST http://127.0.0.1:21325/enumerate +``` + +Ports: Bridge legacy `21325`, Bridge current `21328`, controller `9004`, dashboard +`9005`, and noVNC `6080`. The controller and dashboard are published on different +ports from the manual User Env to avoid collisions with the regtest services when +several projects use blocks of 1000 ports. Docker-backed simulator seats remap +these base ports and forward them to their seat's loopback address. Declare this +profile as an on-demand offer; run no global shared device instance. + +Every start wipes only its own emulator and loads the public `all all ...` test +seed, no PIN, no passphrase and the `Bitkit Test Trezor` label. Its two named +volumes belong to the Compose project. Bridge enumeration must contain a device, +and controller status must report both emulator and Bridge running, before the +container is healthy. Confirmations remain controlled by the dashboard or +controller so the runner can observe and approve signing prompts. + +The fixture does not run its own Bitcoin stack. Use the existing regtest node and +Electrum server to fund the derived address and broadcast signed transactions. +Avoid starting the manual User Env alongside this profile on the same base ports. + +## Manual User Env + 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 diff --git a/scripts/trezor-fixture.Dockerfile b/scripts/trezor-fixture.Dockerfile new file mode 100644 index 0000000..7e2deaf --- /dev/null +++ b/scripts/trezor-fixture.Dockerfile @@ -0,0 +1,9 @@ +FROM ghcr.io/trezor/trezor-user-env@sha256:15871ebb234bf7c0197cfcd31ddc2edbe90173cd6386fc0283fafe05bf888c06 + +# Older ARM User Env images omit the runtime required by their bundled emulator. +RUN apt-get update && apt-get install -y --no-install-recommends libsdl3-0 libsdl3-image0 \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY trezor-controller.py trezor-fixture.py /opt/bitkit-trezor/ +WORKDIR /trezor-user-env +ENTRYPOINT ["/trezor-user-env/.venv/bin/python3", "/opt/bitkit-trezor/trezor-fixture.py"] diff --git a/scripts/trezor-fixture.py b/scripts/trezor-fixture.py new file mode 100644 index 0000000..ba46751 --- /dev/null +++ b/scripts/trezor-fixture.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Start the pinned User Env and initialize a disposable deterministic device.""" + +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time +import urllib.request + + +READY = Path("/tmp/bitkit-trezor-ready") +CONTROLLER = Path(__file__).with_name("trezor-controller.py") + + +def healthy(): + if not READY.is_file(): + raise RuntimeError("deterministic setup has not finished") + request = urllib.request.Request("http://127.0.0.1:21325/enumerate", data=b"") + with urllib.request.urlopen(request, timeout=5) as response: + devices = json.load(response) + if not devices or not any(device.get("path") for device in devices): + raise RuntimeError("Bridge has no emulator") + status = subprocess.run( + [sys.executable, str(CONTROLLER), "status"], + check=True, capture_output=True, text=True, timeout=10, + ) + status = json.loads(status.stdout) + for component in ("bridge_status", "emulator_status"): + if not status[component]["is_running"]: + raise RuntimeError(f"{component} is stopped") + + +def main(): + if sys.argv[1:] == ["--health"]: + healthy() + return + + READY.unlink(missing_ok=True) + # The image already carries the firmware and Bridge; no runtime download. + server = subprocess.Popen( + [sys.executable, "src/main.py"], start_new_session=True, + ) + + def stop(signum=None, frame=None): + if server.poll() is None: + os.killpg(server.pid, signal.SIGTERM) + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(server.pid, signal.SIGKILL) + server.wait(timeout=5) + + def terminate(signum, frame): + stop() + raise SystemExit(0) + + signal.signal(signal.SIGTERM, terminate) + signal.signal(signal.SIGINT, terminate) + try: + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + if server.poll() is not None: + raise RuntimeError(f"User Env exited with {server.returncode}") + probe = subprocess.run( + [sys.executable, str(CONTROLLER), "ping"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, + ) + if probe.returncode == 0: + break + time.sleep(1) + else: + raise RuntimeError("User Env controller was not ready within 120 seconds") + subprocess.run( + [sys.executable, str(CONTROLLER), "setup"], check=True, timeout=120, + ) + READY.touch() + healthy() + print("Deterministic Trezor emulator and Bridge are ready", flush=True) + raise SystemExit(server.wait()) + finally: + READY.unlink(missing_ok=True) + stop() + + +if __name__ == "__main__": + main() From 1a353daf33897d4bdc34d1036388bcc5f2ebe02c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 3 Oct 2026 04:09:21 +0200 Subject: [PATCH 2/4] fix: start the trezor fixture without runtime downloads --- docs/trezor-emulator.md | 2 +- scripts/trezor-fixture.Dockerfile | 4 ---- scripts/trezor-fixture.py | 22 +++++++++++++++++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/trezor-emulator.md b/docs/trezor-emulator.md index 4ce5688..50c3b0a 100644 --- a/docs/trezor-emulator.md +++ b/docs/trezor-emulator.md @@ -5,7 +5,7 @@ The `trezor-emulator` Compose profile provides a Linux-compatible service with published ports, a reusable named image, project-scoped state and logs, and a healthcheck. It uses the same pinned official User Env image as the manual -workflow. SDL3 dependencies are installed at build time. Startup launches the +workflow, including its bundled SDL3 runtime. Startup launches the controller and initializes Bridge plus the deterministic T2T1 device, with bounded startup waits and process cleanup on exit. Firmware `2-main` and `node-bridge` refer to binaries already frozen in the base image's digest. diff --git a/scripts/trezor-fixture.Dockerfile b/scripts/trezor-fixture.Dockerfile index 7e2deaf..15783a2 100644 --- a/scripts/trezor-fixture.Dockerfile +++ b/scripts/trezor-fixture.Dockerfile @@ -1,9 +1,5 @@ FROM ghcr.io/trezor/trezor-user-env@sha256:15871ebb234bf7c0197cfcd31ddc2edbe90173cd6386fc0283fafe05bf888c06 -# Older ARM User Env images omit the runtime required by their bundled emulator. -RUN apt-get update && apt-get install -y --no-install-recommends libsdl3-0 libsdl3-image0 \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - COPY trezor-controller.py trezor-fixture.py /opt/bitkit-trezor/ WORKDIR /trezor-user-env ENTRYPOINT ["/trezor-user-env/.venv/bin/python3", "/opt/bitkit-trezor/trezor-fixture.py"] diff --git a/scripts/trezor-fixture.py b/scripts/trezor-fixture.py index ba46751..f592f84 100644 --- a/scripts/trezor-fixture.py +++ b/scripts/trezor-fixture.py @@ -2,6 +2,8 @@ """Start the pinned User Env and initialize a disposable deterministic device.""" import json +import argparse +import atexit import os from pathlib import Path import signal @@ -34,6 +36,24 @@ def healthy(): def main(): + if sys.argv[1:] == ["--server"]: + # Start only the User Env components this fixture uses. Upstream main.py + # also launches an optional MCP server that downloads a second venv. + sys.path.insert(0, str(Path.cwd() / "src")) + import binaries + import bridge + import controller + import dashboard + import emulator + + atexit.register(bridge.stop) + atexit.register(emulator.stop) + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + binaries.explore(argparse.Namespace(verbosity=0)) + dashboard.start() + controller.start() + return + if sys.argv[1:] == ["--health"]: healthy() return @@ -41,7 +61,7 @@ def main(): READY.unlink(missing_ok=True) # The image already carries the firmware and Bridge; no runtime download. server = subprocess.Popen( - [sys.executable, "src/main.py"], start_new_session=True, + [sys.executable, __file__, "--server"], start_new_session=True, ) def stop(signum=None, frame=None): From 1f1b38b6283bc250de5c2e0613334563a06cde62 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 3 Oct 2026 04:11:46 +0200 Subject: [PATCH 3/4] test: verify deterministic trezor identity and regtest signing --- README.md | 8 +++ docs/trezor-emulator.md | 9 +++ scripts/trezor-fixture-check.py | 105 ++++++++++++++++++++++++++++++ scripts/trezor-fixture.Dockerfile | 2 +- 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 scripts/trezor-fixture-check.py diff --git a/README.md b/README.md index 453f1c2..6fe8b86 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,8 @@ Env, starts Bridge, and initializes the deterministic T2T1 device automatically: docker compose --profile trezor-emulator build trezor-emulator docker compose --profile trezor-emulator up -d --wait trezor-emulator curl -fsS -X POST http://127.0.0.1:21325/enumerate +docker compose --profile trezor-emulator exec -T trezor-emulator \ + /trezor-user-env/.venv/bin/python3 /opt/bitkit-trezor/trezor-fixture-check.py ``` Point the simulator's Bridge URL at `http://127.0.0.1:21325`. The dashboard is at @@ -332,6 +334,12 @@ volumes are separate, and each startup wipes and initializes its own emulator. The image's digest fixes both the bundled firmware and Bridge; startup downloads nothing. Health requires setup to finish and both Bridge and the emulator to run. +The diagnostic checks the deterministic public key, a `bcrt1` address, and +Bridge signing of a synthetic transaction with an independently verified +signature. It uses the debug link to approve only that diagnostic's prompts; +run it while no wallet uses the device. It needs no app or live chain and +does not broadcast a transaction. + Run the existing `bitcoind` and `electrs` services for funding and broadcasting regtest transactions. The emulator signs `Regtest` transactions without needing its own chain backend. To control button confirmations through the debug link: diff --git a/docs/trezor-emulator.md b/docs/trezor-emulator.md index 50c3b0a..2063aa2 100644 --- a/docs/trezor-emulator.md +++ b/docs/trezor-emulator.md @@ -15,6 +15,8 @@ docker compose --profile trezor-emulator up -d --build --wait trezor-emulator docker compose --profile trezor-emulator ps trezor-emulator docker compose --profile trezor-emulator logs --tail 80 trezor-emulator curl -fsS -X POST http://127.0.0.1:21325/enumerate +docker compose --profile trezor-emulator exec -T trezor-emulator \ + /trezor-user-env/.venv/bin/python3 /opt/bitkit-trezor/trezor-fixture-check.py ``` Ports: Bridge legacy `21325`, Bridge current `21328`, controller `9004`, dashboard @@ -31,6 +33,13 @@ and controller status must report both emulator and Bridge running, before the container is healthy. Confirmations remain controlled by the dashboard or controller so the runner can observe and approve signing prompts. +`trezor-fixture-check.py` is a bounded, offline fixture diagnostic. It talks to +the device through Bridge, derives the expected public key independently from +the public test seed, obtains regtest addresses, signs a synthetic SegWit +transaction and verifies its signature independently. The debug link approves +its signing prompts and restores the normal interaction mode afterwards. Run +it before connecting an app; it neither runs an app journey nor broadcasts. + The fixture does not run its own Bitcoin stack. Use the existing regtest node and Electrum server to fund the derived address and broadcast signed transactions. Avoid starting the manual User Env alongside this profile on the same base ports. diff --git a/scripts/trezor-fixture-check.py b/scripts/trezor-fixture-check.py new file mode 100644 index 0000000..df03f4d --- /dev/null +++ b/scripts/trezor-fixture-check.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Check the fixture through Bridge without an app or a live chain. + +Uses the public test seed and a synthetic previous transaction. Debug-link +approval is confined to this check; normal signing still needs a runner's input. +""" + +import hashlib +import hmac +import json +import signal +import struct + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, utils +from trezorlib import btc, messages +from trezorlib.debuglink import TrezorTestContext +from trezorlib.tools import parse_path +from trezorlib.transport.bridge import BridgeTransport +from trezorlib.transport.udp import UdpTransport + + +def sha256d(data): + return hashlib.sha256(hashlib.sha256(data).digest()).digest() + + +def hash160(data): + return hashlib.new("ripemd160", hashlib.sha256(data).digest()).digest() + + +def compressed_public_key(secret): + key = ec.derive_private_key(secret, ec.SECP256K1()).public_key().public_numbers() + return bytes([2 + key.y % 2]) + key.x.to_bytes(32, "big") + + +def expected_public_key(path): + seed = hashlib.pbkdf2_hmac("sha512", b"all all all all all all all all all all all all", b"mnemonic", 2048) + material = hmac.digest(b"Bitcoin seed", seed, "sha512") + secret, chain = int.from_bytes(material[:32], "big"), material[32:] + order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + for index in path: + parent = b"\0" + secret.to_bytes(32, "big") if index & 0x80000000 else compressed_public_key(secret) + material = hmac.digest(chain, parent + struct.pack(">I", index), "sha512") + secret = (secret + int.from_bytes(material[:32], "big")) % order + chain = material[32:] + return compressed_public_key(secret) + + +def timeout(signum, frame): + raise TimeoutError("fixture check exceeded 90 seconds") + + +def main(): + signal.signal(signal.SIGALRM, timeout) + signal.alarm(90) + transports = BridgeTransport.enumerate() + if len(transports) != 1: + raise RuntimeError(f"expected one emulator, got {len(transports)}") + path = parse_path("m/84h/1h/0h/0/0") + destination_path = parse_path("m/84h/1h/0h/0/1") + with TrezorTestContext(transports[0], debug_transport=UdpTransport("127.0.0.1:21325")) as context: + with context.get_session() as session: + features = session.features + assert features.initialized and features.label == "Bitkit Test Trezor" + assert not features.pin_protection and not features.passphrase_protection + public_key = btc.get_public_node(session, path, coin_name="Regtest").node.public_key + assert public_key == expected_public_key(path), "device seed differs from the deterministic fixture" + address = btc.get_address(session, "Regtest", path, script_type=messages.InputScriptType.SPENDWITNESS) + destination = btc.get_address(session, "Regtest", destination_path, script_type=messages.InputScriptType.SPENDWITNESS) + assert address.startswith("bcrt1") and destination.startswith("bcrt1") + witness_script = b"\0\x14" + hash160(public_key) + previous = messages.TransactionType( + version=1, lock_time=0, + inputs=[messages.TxInputType(prev_hash=b"\0" * 32, prev_index=0xFFFFFFFF, script_sig=b"fixture", sequence=0xFFFFFFFF)], + bin_outputs=[messages.TxOutputBinType(amount=1_000_000, script_pubkey=witness_script)], + ) + raw_previous = ( + struct.pack(" 150 + # Independently verify the returned signature using the BIP143 digest. + outpoint = previous_hash[::-1] + b"\0" * 4 + output = struct.pack(" Date: Sat, 3 Oct 2026 04:13:15 +0200 Subject: [PATCH 4/4] fix: release the bridge session after fixture checks --- scripts/trezor-fixture-check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/trezor-fixture-check.py b/scripts/trezor-fixture-check.py index df03f4d..1f39a89 100644 --- a/scripts/trezor-fixture-check.py +++ b/scripts/trezor-fixture-check.py @@ -58,7 +58,7 @@ def main(): raise RuntimeError(f"expected one emulator, got {len(transports)}") path = parse_path("m/84h/1h/0h/0/0") destination_path = parse_path("m/84h/1h/0h/0/1") - with TrezorTestContext(transports[0], debug_transport=UdpTransport("127.0.0.1:21325")) as context: + with transports[0], TrezorTestContext(transports[0], debug_transport=UdpTransport("127.0.0.1:21325")) as context: with context.get_session() as session: features = session.features assert features.initialized and features.label == "Bitkit Test Trezor"