Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,47 @@ 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
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
`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.

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:

```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
Expand Down
36 changes: 36 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions docs/trezor-emulator.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,51 @@
# 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, 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.

```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
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
`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.

`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.

## 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
Expand Down
105 changes: 105 additions & 0 deletions scripts/trezor-fixture-check.py
Original file line number Diff line number Diff line change
@@ -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 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"
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("<I", 1) + b"\x01" + b"\0" * 32 + b"\xff" * 4 + b"\x07fixture" + b"\xff" * 4
+ b"\x01" + struct.pack("<Q", 1_000_000) + b"\x16" + witness_script + b"\0" * 4
)
previous_hash = sha256d(raw_previous)[::-1]
signatures, serialized = btc.sign_tx(
session, "Regtest",
[messages.TxInputType(address_n=path, prev_hash=previous_hash, prev_index=0, amount=1_000_000, script_type=messages.InputScriptType.SPENDWITNESS, sequence=0xFFFFFFFF)],
[messages.TxOutputType(address=destination, amount=999_000, script_type=messages.OutputScriptType.PAYTOADDRESS)],
prev_txes={previous_hash: previous}, version=2, lock_time=0,
)
assert len(signatures) == 1 and signatures[0] and len(serialized) > 150
# Independently verify the returned signature using the BIP143 digest.
outpoint = previous_hash[::-1] + b"\0" * 4
output = struct.pack("<Q", 999_000) + b"\x16\0\x14" + hash160(expected_public_key(destination_path))
script_code = b"\x19\x76\xa9\x14" + hash160(public_key) + b"\x88\xac"
digest = sha256d(
struct.pack("<I", 2) + sha256d(outpoint) + sha256d(b"\xff" * 4) + outpoint
+ script_code + struct.pack("<Q", 1_000_000) + b"\xff" * 4 + sha256d(output)
+ struct.pack("<II", 0, 1)
)
verifier = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), public_key)
verifier.verify(signatures[0], digest, ec.ECDSA(utils.Prehashed(hashes.SHA256())))
print(json.dumps({"initialized": True, "label": features.label, "deterministic_public_key": True, "regtest_address": address, "bridge_signing": "verified", "signed_bytes": len(serialized)}, sort_keys=True))
signal.alarm(0)


if __name__ == "__main__":
main()
5 changes: 5 additions & 0 deletions scripts/trezor-fixture.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM ghcr.io/trezor/trezor-user-env@sha256:15871ebb234bf7c0197cfcd31ddc2edbe90173cd6386fc0283fafe05bf888c06

COPY trezor-controller.py trezor-fixture.py trezor-fixture-check.py /opt/bitkit-trezor/
WORKDIR /trezor-user-env
ENTRYPOINT ["/trezor-user-env/.venv/bin/python3", "/opt/bitkit-trezor/trezor-fixture.py"]
109 changes: 109 additions & 0 deletions scripts/trezor-fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""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
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:] == ["--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

READY.unlink(missing_ok=True)
# The image already carries the firmware and Bridge; no runtime download.
server = subprocess.Popen(
[sys.executable, __file__, "--server"], 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()