From c4fca7ac71f8afefad42c9a98308dc2e249d52bf Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Tue, 3 Feb 2026 21:28:10 +0100 Subject: [PATCH 1/2] Upgrade `trezorlib` to 0.20 Trezor plugin is updated for the upcoming `trezorlib` 0.20 release. Tested the following scenarios with Trezor 1 (with FW 1.14.0), T, Safe 3 and Safe 5 (with FW 2.10.0): - create a new wallet: - generate new seed - recover from backup - verify an address - send & RBF flows - set a PIN & a passphrase - open an existing wallet file - locking the device on exit - flow cancellation Safe 7 support will be added in a subsequent PR. --- contrib/requirements/requirements-hw.txt | 2 +- electrum/plugins/trezor/clientbase.py | 83 +++++++++++++++++------- electrum/plugins/trezor/trezor.py | 8 ++- 3 files changed, 65 insertions(+), 28 deletions(-) diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt index 2a6f7d9806b1..9df3a59f3683 100644 --- a/contrib/requirements/requirements-hw.txt +++ b/contrib/requirements/requirements-hw.txt @@ -1,7 +1,7 @@ hidapi # device plugin: trezor -trezor[hidapi]>=0.13.0,<0.14 +trezor[hidapi]>=0.20.0,<0.21 # device plugin: safe_t safet>=0.1.5 diff --git a/electrum/plugins/trezor/clientbase.py b/electrum/plugins/trezor/clientbase.py index 1863afc4d30e..389b134a191b 100644 --- a/electrum/plugins/trezor/clientbase.py +++ b/electrum/plugins/trezor/clientbase.py @@ -11,9 +11,9 @@ from electrum.plugin import runs_in_hwd_thread from electrum.hw_wallet.plugin import OutdatedHwFirmwareException, HardwareClientBase -from trezorlib.client import TrezorClient, PASSPHRASE_ON_DEVICE +from trezorlib.client import TrezorClient, PassphraseSetting, get_default_client from trezorlib.exceptions import TrezorFailure, Cancelled, OutdatedFirmwareError -from trezorlib.messages import WordRequestType, FailureType, ButtonRequestType +from trezorlib.messages import WordRequestType, FailureType, ButtonRequestType, Capability import trezorlib.btc import trezorlib.device @@ -53,7 +53,14 @@ def __init__(self, transport, handler, plugin): HardwareClientBase.__init__(self, plugin=plugin) if plugin.is_outdated_fw_ignored(): TrezorClient.is_outdated = lambda *args, **kwargs: False - self.client = TrezorClient(transport, ui=self) + + self.client = get_default_client( + app_name="Electrum", + path_or_transport=transport, + button_callback=self.button_request, + pin_callback=self.get_pin, + ) + self._session = None self.device = plugin.device self.handler = handler Logger.__init__(self) @@ -65,6 +72,30 @@ def __init__(self, transport, handler, plugin): self.used() + @property + def session(self): + if self._session is None: + assert self.handler is not None + + # If needed, unlock the device (triggering PIN entry dialog for legacy model). + with self.client.get_session(passphrase=PassphraseSetting.STANDARD_WALLET) as session: + session.ensure_unlocked() + + passphrase = PassphraseSetting.STANDARD_WALLET # (empty passphrase) + if self.client.features.passphrase_protection: + passphrase = self.get_passphrase(Capability.PassphraseEntry in self.client.features.capabilities) + + # Then, derive a session for this wallet (possibly with a passphrase) + if passphrase == PassphraseSetting.STANDARD_WALLET: + self._session = session # reuse the session above to avoid re-derivation + self.logger.info("Opened standard %s", self._session) + else: + self._session = self.client.get_session(passphrase) + self.logger.info("Re-opened passphrase %s", self._session) + + return self._session + + def run_flow(self, message=None, creating_wallet=False): if self.in_flow: raise RuntimeError("Overlapping call to run_flow") @@ -123,8 +154,9 @@ def has_usable_connection_with_device(self): return True try: - self.client.init_device() + self.client.ping(message="") except BaseException: + self.logger.exception("Ping failed") return False return True @@ -148,7 +180,7 @@ def i4b(self, x): def get_xpub(self, bip32_path, xtype, creating=False): address_n = parse_path(bip32_path) with self.run_flow(creating_wallet=creating): - node = trezorlib.btc.get_public_node(self.client, address_n).node + node = trezorlib.btc.get_public_node(self.session, address_n).node return BIP32Node(xtype=xtype, eckey=ecc.ECPubkey(node.public_key), chaincode=node.chain_code, @@ -164,17 +196,17 @@ def toggle_passphrase(self): msg = _("Confirm on your {} device to enable passphrases") enabled = not self.features.passphrase_protection with self.run_flow(msg): - trezorlib.device.apply_settings(self.client, use_passphrase=enabled) + trezorlib.device.apply_settings(self.session, use_passphrase=enabled) @runs_in_hwd_thread def change_label(self, label): with self.run_flow(_("Confirm the new label on your {} device")): - trezorlib.device.apply_settings(self.client, label=label) + trezorlib.device.apply_settings(self.session, label=label) @runs_in_hwd_thread def change_homescreen(self, homescreen): with self.run_flow(_("Confirm on your {} device to change your home screen")): - trezorlib.device.apply_settings(self.client, homescreen=homescreen) + trezorlib.device.apply_settings(self.session, homescreen=homescreen) @runs_in_hwd_thread def set_pin(self, remove): @@ -185,7 +217,7 @@ def set_pin(self, remove): else: msg = _("Confirm on your {} device to set a PIN") with self.run_flow(msg): - trezorlib.device.change_pin(self.client, remove) + trezorlib.device.change_pin(self.session, remove) @runs_in_hwd_thread def clear_session(self): @@ -194,7 +226,7 @@ def clear_session(self): self.logger.info(f"clear session: {self}") self.prevent_timeouts() try: - self.client.clear_session() + self.close() except BaseException as e: # If the device was removed it has the same effect... self.logger.info(f"clear_session: ignoring error {e}") @@ -202,8 +234,11 @@ def clear_session(self): @runs_in_hwd_thread def close(self): '''Called when Our wallet was closed or the device removed.''' - self.logger.info("closing client") - self.clear_session() + self.logger.info("locking: %s", self.client) + self.client.lock() + self.logger.info("closing: %s", self._session) + if self._session is not None: + self._session.close() @runs_in_hwd_thread def is_uptodate(self): @@ -233,7 +268,7 @@ def show_address(self, address_str, script_type, multisig=None): address_n = parse_path(address_str) with self.run_flow(): return trezorlib.btc.get_address( - self.client, + self.session, coin_name, address_n, show_display=True, @@ -246,7 +281,7 @@ def sign_message(self, address_str, message, *, script_type): address_n = parse_path(address_str) with self.run_flow(): return trezorlib.btc.sign_message( - self.client, + self.session, coin_name, address_n, message, @@ -256,9 +291,9 @@ def sign_message(self, address_str, message, *, script_type): @runs_in_hwd_thread def recover_device(self, recovery_type, *args, **kwargs): input_callback = self.mnemonic_callback(recovery_type) - with self.run_flow(): + with self.run_flow(), self.client.get_session(None) as seedless_session: return trezorlib.device.recover( - self.client, + seedless_session, *args, input_callback=input_callback, type=recovery_type, @@ -269,27 +304,27 @@ def recover_device(self, recovery_type, *args, **kwargs): @runs_in_hwd_thread def sign_tx(self, *args, **kwargs): with self.run_flow(): - return trezorlib.btc.sign_tx(self.client, *args, **kwargs) + return trezorlib.btc.sign_tx(self.session, *args, **kwargs) @runs_in_hwd_thread def get_ownership_id(self, *args, **kwargs): with self.run_flow(): - return trezorlib.btc.get_ownership_id(self.client, *args, **kwargs) + return trezorlib.btc.get_ownership_id(self.session, *args, **kwargs) @runs_in_hwd_thread def get_ownership_proof(self, *args, **kwargs): with self.run_flow(): - return trezorlib.btc.get_ownership_proof(self.client, *args, **kwargs) + return trezorlib.btc.get_ownership_proof(self.session, *args, **kwargs) @runs_in_hwd_thread def reset_device(self, *args, **kwargs): - with self.run_flow(): - return trezorlib.device.reset(self.client, *args, **kwargs) + with self.run_flow(), self.client.get_session(None) as seedless_session: + return trezorlib.device.reset(seedless_session, *args, **kwargs) @runs_in_hwd_thread def wipe_device(self, *args, **kwargs): - with self.run_flow(): - return trezorlib.device.wipe(self.client, *args, **kwargs) + with self.run_flow(), self.client.get_session(None) as seedless_session: + return trezorlib.device.wipe(seedless_session, *args, **kwargs) # ========= UI methods ========== @@ -335,7 +370,7 @@ def get_passphrase(self, available_on_device): self.handler.passphrase_on_device = available_on_device passphrase = self.handler.get_passphrase(msg, self.creating_wallet) - if passphrase is PASSPHRASE_ON_DEVICE: + if passphrase is PassphraseSetting.ON_DEVICE: return passphrase if passphrase is None: raise Cancelled diff --git a/electrum/plugins/trezor/trezor.py b/electrum/plugins/trezor/trezor.py index 9487707443fb..16c3e794eb76 100644 --- a/electrum/plugins/trezor/trezor.py +++ b/electrum/plugins/trezor/trezor.py @@ -33,10 +33,12 @@ InputScriptType, OutputScriptType, MultisigRedeemScriptType, TxInputType, TxOutputType, TxOutputBinType, TransactionType, AmountUnit) - from trezorlib.client import PASSPHRASE_ON_DEVICE + from trezorlib.client import PassphraseSetting import trezorlib.log #trezorlib.log.enable_debug_output() + PASSPHRASE_ON_DEVICE = PassphraseSetting.ON_DEVICE + TREZORLIB = True except Exception as e: if not (isinstance(e, ModuleNotFoundError) and e.name == 'trezorlib'): @@ -153,8 +155,8 @@ class TrezorPlugin(HW_PluginBase): libraries_URL = 'https://pypi.org/project/trezor/' minimum_firmware = (1, 5, 2) keystore_class = TrezorKeyStore - minimum_library = (0, 13, 0) - maximum_library = (0, 14) + minimum_library = (0, 20, 0) + maximum_library = (0, 21) SUPPORTED_XTYPES = ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh') DEVICE_IDS = (TREZOR_PRODUCT_KEY,) From 797f5c8ba85db8b5cb86133b016cb60910cff90f Mon Sep 17 00:00:00 2001 From: matejcik Date: Fri, 6 Mar 2026 16:03:35 +0100 Subject: [PATCH 2/2] build: support blacklisting dependencies for deterministic builds The method works as follows: 1. for every blacklisted dependency, as listed in ghost.txt, create and install an empty ghost package which will satisfy the dependency resolver 2. before hash resolution step, remove those ghosts to make hashin happy This required converting find_restricted_dependencies to use locally installed package metadata instead of looking it up online on pypi. But that seems to be a good idea anyway. --- .../find_restricted_dependencies.py | 40 ++++++++----------- contrib/freeze_packages.sh | 16 ++++++-- contrib/install_ghost.py | 32 +++++++++++++++ contrib/requirements/ghost.txt | 5 +++ 4 files changed, 67 insertions(+), 26 deletions(-) create mode 100644 contrib/install_ghost.py create mode 100644 contrib/requirements/ghost.txt diff --git a/contrib/deterministic-build/find_restricted_dependencies.py b/contrib/deterministic-build/find_restricted_dependencies.py index 5cc2c45f3663..55bda1a426db 100755 --- a/contrib/deterministic-build/find_restricted_dependencies.py +++ b/contrib/deterministic-build/find_restricted_dependencies.py @@ -1,10 +1,6 @@ #!/usr/bin/env python3 import sys - -try: - import requests -except ImportError as e: - sys.exit(f"Error: {str(e)}. Try 'python3 -m pip install '") +from importlib.metadata import requires, PackageNotFoundError def is_dependency_edge_blacklisted(*, parent_pkg: str, dep: str) -> bool: """Sometimes a package declares a hard dependency @@ -38,26 +34,24 @@ def main(): if not p: continue assert "==" in p, "This script expects a list of packages with pinned version, e.g. package==1.2.3, not {}".format(p) - p, v = p.rsplit("==", 1) - try: - data = requests.get("https://pypi.org/pypi/{}/{}/json".format(p, v)).json()["info"] - except ValueError: - raise Exception("Package could not be found: {}=={}".format(p, v)) + pkg_name, _ = p.rsplit("==", 1) try: - for r in data["requires_dist"]: # type: str - if ";" not in r: - continue - # example value for "r" at this point: "pefile (>=2017.8.1) ; sys_platform == \"win32\"" - dep, restricted = r.split(";", 1) - dep = dep.strip() - restricted = restricted.strip() - dep_basename = dep.split(" ")[0] - if check_restriction(dep=dep, restricted=restricted, parent_pkg=p): - print(dep_basename, sep=" ") - print("Installing {} from {} although it is only needed for {}".format(dep, p, restricted), file=sys.stderr) - except TypeError: - # Has no dependencies at all + reqs = requires(pkg_name) + except PackageNotFoundError: + raise Exception("Package not found in this environment: {}. Install it first.".format(p)) + if reqs is None: continue + for r in reqs: + if ";" not in r: + continue + # example value for "r": "pefile (>=2017.8.1) ; sys_platform == \"win32\"" + dep, restricted = r.split(";", 1) + dep = dep.strip() + restricted = restricted.strip() + dep_basename = dep.split(" ")[0] + if check_restriction(dep=dep, restricted=restricted, parent_pkg=pkg_name): + print(dep_basename, sep=" ") + print("Installing {} from {} although it is only needed for {}".format(dep, pkg_name, restricted), file=sys.stderr) if __name__ == "__main__": main() diff --git a/contrib/freeze_packages.sh b/contrib/freeze_packages.sh index 1a5dcc6767cc..4849795ecfe2 100755 --- a/contrib/freeze_packages.sh +++ b/contrib/freeze_packages.sh @@ -24,7 +24,7 @@ for suffix in '' '-hw' '-binaries' '-binaries-mac' '-build-wine' '-build-mac' '- reqfile="requirements${suffix}.txt" rm -rf "$venv_dir" - virtualenv -p "${SYSTEM_PYTHON}" "$venv_dir" + "${SYSTEM_PYTHON}" -m venv "$venv_dir" source "$venv_dir/bin/activate" @@ -36,16 +36,20 @@ for suffix in '' '-hw' '-binaries' '-binaries-mac' '-build-wine' '-build-mac' '- # that we should explicitly install them now, so that we pin latest versions if possible. python -m pip install --upgrade pip setuptools wheel + # install ghost packages to satisfy dependency resolvers + for package in $(cat "$contrib/requirements/ghost.txt"); do + python $contrib/install_ghost.py "$package" + done + python -m pip install -r "$contrib/requirements/${reqfile}" --upgrade echo "OK." requirements=$(pip freeze --all) - restricted=$(echo $requirements | ${SYSTEM_PYTHON} "$contrib/deterministic-build/find_restricted_dependencies.py") + restricted=$(echo $requirements | python "$contrib/deterministic-build/find_restricted_dependencies.py") if [ ! -z "$restricted" ]; then python -m pip install $restricted - requirements=$(pip freeze --all) fi echo "Generating package hashes... (${reqfile})" @@ -67,6 +71,12 @@ for suffix in '' '-hw' '-binaries' '-binaries-mac' '-build-wine' '-build-mac' '- HASHIN_FLAGS="--python-version source" fi + # remove ghost packages before hashin step, so that they aren't hashed + for package in $(cat "$contrib/requirements/ghost.txt"); do + python -m pip uninstall -y $package + done + requirements=$(pip freeze --all) + echo -e "\r Hashing requirements for $reqfile..." ${SYSTEM_PYTHON} -m hashin $HASHIN_FLAGS -r "$contrib/deterministic-build/${reqfile}" $requirements diff --git a/contrib/install_ghost.py b/contrib/install_ghost.py new file mode 100644 index 000000000000..f6c978cfa10d --- /dev/null +++ b/contrib/install_ghost.py @@ -0,0 +1,32 @@ +import sys, tempfile, subprocess +from pathlib import Path +from importlib.metadata import distribution + + +PYPROJECT_TOML = """ +[project] +name = "{name}" +version = "{version}" +description = "Ghost package to satisfy dependencies" +""" + + +def install_ghost(name: str, version: str) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + pyproject_toml = PYPROJECT_TOML.format(name=name, version=version) + (Path(tmpdir) / "pyproject.toml").write_text(pyproject_toml) + subprocess.check_call([sys.executable, "-m", "pip", "install", tmpdir]) + + dist = distribution(name) + for file in dist.files: + path = file.locate() + if path.name == "direct_url.json": + path.unlink() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python install_ghost.py ==") + sys.exit(1) + name, version = sys.argv[1].split("==") + install_ghost(name, version) diff --git a/contrib/requirements/ghost.txt b/contrib/requirements/ghost.txt new file mode 100644 index 000000000000..f506ac04724d --- /dev/null +++ b/contrib/requirements/ghost.txt @@ -0,0 +1,5 @@ +click==8.3.1 +construct==2.10.70 +construct-classes==0.2.2 +platformdirs==4.9.4 +keyring==25.7.0