diff --git a/pyproject.toml b/pyproject.toml index 4f631c50..539b0b4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dev = [ [project.optional-dependencies] ledger = ["ledgereth==0.10.0"] -trezor = ["trezor==0.13.10"] +trezor = ["trezor==0.20.1"] [project.scripts] safe-cli = "safe_cli.main:main" diff --git a/src/safe_cli/operators/hw_wallets/trezor_exceptions.py b/src/safe_cli/operators/hw_wallets/trezor_exceptions.py index 4a5abe21..bade4246 100644 --- a/src/safe_cli/operators/hw_wallets/trezor_exceptions.py +++ b/src/safe_cli/operators/hw_wallets/trezor_exceptions.py @@ -2,6 +2,7 @@ from trezorlib.exceptions import ( Cancelled, + DeviceLockedError, OutdatedFirmwareError, PinException, TrezorFailure, @@ -27,6 +28,8 @@ def wrapper(*args, **kwargs): raise HardwareWalletException("Wrong PIN") from None except Cancelled: raise HardwareWalletException("Trezor operation was cancelled") from None + except DeviceLockedError: + raise HardwareWalletException("Trezor device is locked") from None except TransportException: raise HardwareWalletException("Trezor device is not connected") from None except InvalidDerivationPath as e: diff --git a/src/safe_cli/operators/hw_wallets/trezor_wallet.py b/src/safe_cli/operators/hw_wallets/trezor_wallet.py index bba100bc..c513c18a 100644 --- a/src/safe_cli/operators/hw_wallets/trezor_wallet.py +++ b/src/safe_cli/operators/hw_wallets/trezor_wallet.py @@ -5,7 +5,13 @@ from hexbytes import HexBytes from safe_eth.safe.signatures import signature_split, signature_to_bytes from trezorlib import tools -from trezorlib.client import TrezorClient, get_default_client +from trezorlib.cli import get_code_entry_code, get_passphrase +from trezorlib.cli.ui import ClickUI +from trezorlib.client import ( + Session, + get_default_client, + get_default_session, +) from trezorlib.ethereum import ( get_address, sign_message, @@ -13,7 +19,6 @@ sign_tx_eip1559, sign_typed_data_hash, ) -from trezorlib.ui import ClickUI from web3.types import TxParams from .hw_wallet import HwWallet @@ -22,20 +27,25 @@ @cache @raise_trezor_exception_as_hw_wallet_exception -def get_trezor_client() -> TrezorClient: +def get_trezor_session() -> Session: """ - Return default trezor configuration that store passphrase on host. - This method is cached to share the same configuration between trezor calls while the class is not instantiated. + Return a default Trezor session, entering the passphrase on the host unless the device requires on-device entry. + This method is cached to share the same session between trezor calls while the class is not instantiated. :return: """ - ui = ClickUI(passphrase_on_host=True, always_prompt=True) - client = get_default_client(ui=ui) - return client + ui = ClickUI(always_prompt=True) + client = get_default_client( + "safe-cli", + button_callback=ui.button_request, + pin_callback=ui.get_pin, + code_entry_callback=get_code_entry_code, + ) + return get_default_session(client, passphrase_callback=get_passphrase) class TrezorWallet(HwWallet): def __init__(self, derivation_path: str): - self.client: TrezorClient = get_trezor_client() + self.session: Session = get_trezor_session() self.address_n = tools.parse_path(derivation_path) super().__init__(derivation_path) @@ -44,7 +54,7 @@ def get_address(self) -> ChecksumAddress: """ :return: public address for derivation_path """ - return get_address(client=self.client, n=self.address_n) + return get_address(self.session, n=self.address_n) @raise_trezor_exception_as_hw_wallet_exception def sign_typed_hash(self, domain_hash: bytes, message_hash: bytes) -> bytes: @@ -55,7 +65,7 @@ def sign_typed_hash(self, domain_hash: bytes, message_hash: bytes) -> bytes: :return: signature bytes """ signed = sign_typed_data_hash( - self.client, + self.session, n=self.address_n, domain_hash=domain_hash, message_hash=message_hash, @@ -75,7 +85,7 @@ def get_signed_raw_transaction( if tx_parameters.get("maxPriorityFeePerGas"): # EIP1559 v, r, s = sign_tx_eip1559( - self.client, + self.session, n=self.address_n, nonce=tx_parameters["nonce"], gas_limit=tx_parameters["gas"], @@ -109,7 +119,7 @@ def get_signed_raw_transaction( else: # Legacy transaction v, r, s = sign_tx( - self.client, + self.session, n=self.address_n, nonce=tx_parameters["nonce"], gas_price=tx_parameters["gasPrice"], @@ -144,7 +154,7 @@ def sign_message(self, message: bytes) -> bytes: :param message: :return: bytes signature """ - signed = sign_message(self.client, self.address_n, message) + signed = sign_message(self.session, self.address_n, message) # V field must be greater than 30 for signed messages. https://github.com/safe-global/safe-smart-account/blob/main/contracts/Safe.sol#L309 v, r, s = signature_split(signed.signature) return signature_to_bytes(v + 4, r, s) diff --git a/tests/test_trezor_wallet.py b/tests/test_trezor_wallet.py index e0e5caca..3f92d093 100644 --- a/tests/test_trezor_wallet.py +++ b/tests/test_trezor_wallet.py @@ -9,11 +9,9 @@ from safe_eth.safe import SafeTx from safe_eth.safe.signatures import signature_split, signature_to_bytes from safe_eth.safe.tests.safe_test_case import SafeTestCaseMixin -from trezorlib.client import TrezorClient from trezorlib.exceptions import Cancelled, OutdatedFirmwareError, PinException from trezorlib.messages import EthereumTypedDataSignature from trezorlib.transport import TransportException -from trezorlib.ui import ClickUI from safe_cli.operators.exceptions import HardwareWalletException from safe_cli.operators.hw_wallets.trezor_wallet import TrezorWallet @@ -21,7 +19,7 @@ class TestTrezorManager(SafeTestCaseMixin, unittest.TestCase): @mock.patch( - "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_client", + "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_session", return_value=None, ) @mock.patch( @@ -29,10 +27,10 @@ class TestTrezorManager(SafeTestCaseMixin, unittest.TestCase): return_value=None, ) def test_setup_trezor_wallet( - self, mock_trezor_client: MagicMock, mock_get_address: MagicMock + self, mock_trezor_session: MagicMock, mock_get_address: MagicMock ): trezor_wallet = TrezorWallet("44'/60'/0'/0") - self.assertIsNone(trezor_wallet.client) + self.assertIsNone(trezor_wallet.session) @mock.patch( "safe_cli.operators.hw_wallets.trezor_wallet.sign_typed_data_hash", @@ -43,21 +41,16 @@ def test_setup_trezor_wallet( autospec=True, ) @mock.patch( - "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_client", + "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_session", autospec=True, ) def test_hw_device_exception( self, - mock_trezor_client: MagicMock, + mock_trezor_session: MagicMock, mock_trezor_get_address: MagicMock, mock_trezor_sign: MagicMock, ): derivation_path = "44'/60'/0'/0" - transport_mock = MagicMock(auto_spec=True) - mock_trezor_client.return_value = TrezorClient( - transport_mock, ui=ClickUI(), _init_device=False - ) - mock_trezor_client.return_value.is_outdated = MagicMock(return_value=False) random_domain_bytes = os.urandom(32) random_message_bytes = os.urandom(32) @@ -104,19 +97,14 @@ def test_hw_device_exception( autospec=True, ) @mock.patch( - "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_client", + "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_session", autospec=True, ) def test_sign_typed_hash( - self, mock_trezor_client: MagicMock, mock_get_address: MagicMock + self, mock_trezor_session: MagicMock, mock_get_address: MagicMock ): owner = Account.create() to = Account.create() - transport_mock = MagicMock(auto_spec=True) - mock_trezor_client.return_value = TrezorClient( - transport_mock, ui=ClickUI(), _init_device=False - ) - mock_trezor_client.return_value.is_outdated = MagicMock(return_value=False) mock_get_address.return_value = owner.address trezor_wallet = TrezorWallet("44'/60'/0'/0") @@ -145,9 +133,7 @@ def test_sign_typed_hash( trezor_return_signature = EthereumTypedDataSignature( signature=expected_signature, address=trezor_wallet.address ) - mock_trezor_client.return_value.call = MagicMock( - return_value=trezor_return_signature - ) + mock_trezor_session.return_value.call.return_value = trezor_return_signature signature = trezor_wallet.sign_typed_hash(encode_hash[1], encode_hash[2]) self.assertEqual(expected_signature, signature) @@ -164,23 +150,18 @@ def test_sign_typed_hash( autospec=True, ) @mock.patch( - "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_client", + "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_session", autospec=True, ) def test_get_signed_raw_transaction( self, - mock_trezor_client: MagicMock, + mock_trezor_session: MagicMock, mock_get_address: MagicMock, mock_sign_tx_eip1559: MagicMock, mock_sign_tx: MagicMock, ): owner = Account.create() to = Account.create() - transport_mock = MagicMock(auto_spec=True) - mock_trezor_client.return_value = TrezorClient( - transport_mock, ui=ClickUI(), _init_device=False - ) - mock_trezor_client.return_value.is_outdated = MagicMock(return_value=False) mock_get_address.return_value = owner.address trezor_wallet = TrezorWallet("44'/60'/0'/0") @@ -261,21 +242,16 @@ def test_get_signed_raw_transaction( autospec=True, ) @mock.patch( - "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_client", + "safe_cli.operators.hw_wallets.trezor_wallet.get_trezor_session", autospec=True, ) def test_get_sign_message( self, - mock_trezor_client: MagicMock, + mock_trezor_session: MagicMock, mock_get_address: MagicMock, mock_sign_message: MagicMock, ): owner = Account.create() - transport_mock = MagicMock(auto_spec=True) - mock_trezor_client.return_value = TrezorClient( - transport_mock, ui=ClickUI(), _init_device=False - ) - mock_trezor_client.return_value.is_outdated = MagicMock(return_value=False) mock_get_address.return_value = owner.address trezor_wallet = TrezorWallet("44'/60'/0'/0") expected_signature = HexBytes( diff --git a/uv.lock b/uv.lock index f91a367f..a89a3b8c 100644 --- a/uv.lock +++ b/uv.lock @@ -1068,18 +1068,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "ecdsa" -version = "0.19.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, -] - [[package]] name = "ecpy" version = "1.2.5" @@ -1536,7 +1524,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.15'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2131,6 +2119,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "noiseprotocol" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/17/fcf8a90dcf36fe00b475e395f34d92f42c41379c77b25a16066f63002f95/noiseprotocol-0.3.1.tar.gz", hash = "sha256:b092a871b60f6a8f07f17950dc9f7098c8fe7d715b049bd4c24ee3752b90d645", size = 16890, upload-time = "2020-11-25T19:06:48.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/e1/76e4694201d67b93a6f1644b2588b4a3d965419fe189416e3496cf415db5/noiseprotocol-0.3.1-py3-none-any.whl", hash = "sha256:2e1a603a38439636cf0ffd8b3e8b12cee27d368a28b41be7dbe568b2abb23111", size = 20546, upload-time = "2020-03-03T18:51:28.095Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -3196,7 +3196,7 @@ requires-dist = [ { name = "requests", specifier = ">=2" }, { name = "safe-eth-py", specifier = ">=7.20.0" }, { name = "tabulate", specifier = ">=0.8" }, - { name = "trezor", marker = "extra == 'trezor'", specifier = "==0.13.10" }, + { name = "trezor", marker = "extra == 'trezor'", specifier = "==0.20.1" }, { name = "typer", specifier = ">=0.14.0" }, ] provides-extras = ["ledger", "trezor"] @@ -3275,15 +3275,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "slip10" version = "1.1.0" @@ -3375,24 +3366,26 @@ wheels = [ [[package]] name = "trezor" -version = "0.13.10" +version = "0.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "construct" }, { name = "construct-classes" }, { name = "cryptography" }, - { name = "ecdsa" }, + { name = "keyring" }, { name = "libusb1" }, { name = "mnemonic" }, + { name = "noiseprotocol" }, + { name = "platformdirs" }, { name = "requests" }, { name = "shamir-mnemonic" }, { name = "slip10" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/11/bd2ff7f6ff07cdd739b27de64398e9772ceaef59e0ee1341f4bb4a571794/trezor-0.13.10.tar.gz", hash = "sha256:7a0b6ae4628dd0c31a5ceb51258918d9bbdd3ad851388837225826b228ee504f", size = 261816, upload-time = "2025-02-12T13:28:09.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/15/97496cb13337f516fd2a263e49aa4fa71ff11cb1aef2915a0a12694c5e03/trezor-0.20.1.tar.gz", hash = "sha256:06f21ef1b0ad20f8bc220f229f2ff3abfedc15e90ca3bbdafcd967a6031e2cb3", size = 383097, upload-time = "2026-05-11T13:14:08.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/45/d9865956a9d94e5d7113215be5856fd453465fa14b41e6888bbe840ae389/trezor-0.13.10-py3-none-any.whl", hash = "sha256:7c85dc2c47998765c84d309fc753d2b116c943d447289157895488899c95706d", size = 238725, upload-time = "2025-02-12T13:28:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/13/d1/64775ccfd5375a8cc484aed707386210f37e3a624185d1d265ca764427af/trezor-0.20.1-py3-none-any.whl", hash = "sha256:6de50703102f90dc5399d40dd7c8134d13b6c54a617d41178b081baf2aeb2b91", size = 304646, upload-time = "2026-05-11T13:14:06.706Z" }, ] [[package]]