Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ledger #195

Merged
merged 19 commits into from
Oct 30, 2023
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,20 @@ the information about the Safe using:
```
> refresh
```
## Ledger module
Ledger module is an optional feature of safe-cli to sign transactions with the help of [ledgereth](https://github.com/mikeshultz/ledger-eth-lib) library based on [ledgerblue](https://github.com/LedgerHQ/blue-loader-python).

To enable, safe-cli must be installed as follows:
```
pip install safe-cli[ledger]
```
When running on Linux, make sure the following rules have been added to `/etc/udev/rules.d/`:
```commandline
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0000", MODE="0660", TAG+="uaccess", TAG+="udev-acl" OWNER="<UNIX username>"
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0001", MODE="0660", TAG+="uaccess", TAG+="udev-acl" OWNER="<UNIX username>"
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0004", MODE="0660", TAG+="uaccess", TAG+="udev-acl" OWNER="<UNIX username>"
```
**NOTE**: before signing anything ensure that the data showing on your ledger is the same as the safe-cli data.
## Creating a new Safe
Use `safe-creator <node_url> <private_key> --owners <checksummed_address_1> <checksummed_address_2> --threshold <uint> --salt-nonce <uint256>`.

Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
art==6.1
colorama==0.4.6
ledgereth==0.9.0
moisses89 marked this conversation as resolved.
Show resolved Hide resolved
packaging>=23.1
prompt_toolkit==3.0.39
pygments==2.16.1
requests==2.31.0
safe-eth-py==6.0.0b2
safe-eth-py==6.0.0b5
tabulate==0.9.0
web3==6.10.0
3 changes: 2 additions & 1 deletion safe_cli/operators/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# flake8: noqa F401
from .enums import SafeOperatorMode
from .safe_operator import SafeOperator, SafeServiceNotAvailable
from .exceptions import SafeServiceNotAvailable
from .safe_operator import SafeOperator
from .safe_tx_service_operator import SafeTxServiceOperator
86 changes: 86 additions & 0 deletions safe_cli/operators/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
class SafeOperatorException(Exception):
pass


class ExistingOwnerException(SafeOperatorException):
pass


class NonExistingOwnerException(SafeOperatorException):
pass


class HashAlreadyApproved(SafeOperatorException):
pass


class ThresholdLimitException(SafeOperatorException):
pass


class SameFallbackHandlerException(SafeOperatorException):
pass


class InvalidFallbackHandlerException(SafeOperatorException):
pass


class FallbackHandlerNotSupportedException(SafeOperatorException):
pass


class SameGuardException(SafeOperatorException):
pass


class InvalidGuardException(SafeOperatorException):
pass


class GuardNotSupportedException(SafeOperatorException):
pass


class SameMasterCopyException(SafeOperatorException):
pass


class SafeAlreadyUpdatedException(SafeOperatorException):
pass


class UpdateAddressesNotValid(SafeOperatorException):
pass


class SenderRequiredException(SafeOperatorException):
pass


class AccountNotLoadedException(SafeOperatorException):
pass


class NotEnoughSignatures(SafeOperatorException):
pass


class InvalidMasterCopyException(SafeOperatorException):
pass


class NotEnoughEtherToSend(SafeOperatorException):
pass


class NotEnoughTokenToSend(SafeOperatorException):
pass


class SafeServiceNotAvailable(SafeOperatorException):
pass


class HardwareWalletException(SafeOperatorException):
pass
Empty file.
31 changes: 31 additions & 0 deletions safe_cli/operators/hw_accounts/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import functools

from ledgereth.exceptions import (
LedgerAppNotOpened,
LedgerCancel,
LedgerLocked,
LedgerNotFound,
)

from safe_cli.operators.exceptions import HardwareWalletException
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, using a exception file we prevent a posible circular dependency



def raise_as_hw_account_exception(function):
@functools.wraps(function)
def wrapper(*args, **kwargs):
try:
return function(*args, **kwargs)
except LedgerNotFound as e:
raise HardwareWalletException(e.message)
except LedgerLocked as e:
raise HardwareWalletException(e.message)
except LedgerAppNotOpened as e:
raise HardwareWalletException(e.message)
except LedgerCancel as e:
raise HardwareWalletException(e.message)
except BaseException as e:
if "Error while writing" in e.args:
raise HardwareWalletException("Ledger error writting, restart safe-cli")
raise e

return wrapper
129 changes: 129 additions & 0 deletions safe_cli/operators/hw_accounts/ledger_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from typing import List, Optional, Set, Tuple

from eth_typing import ChecksumAddress
from ledgereth import sign_typed_data_draft
from ledgereth.accounts import LedgerAccount, get_account_by_path
from ledgereth.comms import init_dongle
from ledgereth.exceptions import LedgerNotFound
from prompt_toolkit import HTML, print_formatted_text

from gnosis.eth.eip712 import eip712_encode
from gnosis.safe import SafeTx
from gnosis.safe.signatures import signature_to_bytes

from safe_cli.operators.hw_accounts.exceptions import raise_as_hw_account_exception


class LedgerManager:
def __init__(self):
self.dongle = None
self.accounts: Set[LedgerAccount] = set()
self.connect()

def connect(self) -> bool:
"""
Connect with ledger
:return: True if connection was successful or False in other case
"""
try:
self.dongle = init_dongle(self.dongle)
return True
except LedgerNotFound:
return False

@property
@raise_as_hw_account_exception
def connected(self) -> bool:
"""
:return: True if ledger is connected or False in other case
"""
return self.connect()

@raise_as_hw_account_exception
def get_accounts(
self, legacy_account: Optional[bool] = False, number_accounts: Optional[int] = 5
) -> List[Tuple[ChecksumAddress, str]]:
"""
Request to ledger device the first n accounts

:param legacy_account:
:param number_accounts: number of accounts requested to ledger
:return: a list of tuples with address and derivation path
"""
accounts = []
for i in range(number_accounts):
if legacy_account:
path_string = f"44'/60'/0'/{i}"
else:
path_string = f"44'/60'/{i}'/0/0"

account = get_account_by_path(path_string, self.dongle)
accounts.append((account.address, account.path))
return accounts

@raise_as_hw_account_exception
def add_account(self, derivation_path: str):
"""
Add an account to ledger manager set

:param derivation_path:
:return:
"""
account = get_account_by_path(derivation_path, self.dongle)
self.accounts.add(LedgerAccount(account.path, account.address))

def delete_accounts(self, addresses: List[ChecksumAddress]) -> Set:
"""
Remove ledger accounts from address

:param accounts:
:return: list with the delete accounts
"""
accounts_to_remove = set()
for address in addresses:
for account in self.accounts:
if account.address == address:
accounts_to_remove.add(account)
self.accounts = self.accounts.difference(accounts_to_remove)
return accounts_to_remove

@raise_as_hw_account_exception
def sign_eip712(self, safe_tx: SafeTx, accounts: List[LedgerAccount]) -> SafeTx:
"""
Call ledger ethereum app method to sign eip712 hashes with a ledger account

:param domain_hash:
:param message_hash:
:param account: ledger account
:return: bytes of signature
"""
encode_hash = eip712_encode(safe_tx.eip712_structured_data)
domain_hash = encode_hash[1]
message_hash = encode_hash[2]
for account in accounts:
print_formatted_text(
HTML(
"<ansired>Make sure in your ledger before signing that domain_hash and message_hash are both correct</ansired>"
)
)
print_formatted_text(HTML(f"Domain_hash: <b>{domain_hash.hex()}</b>"))
print_formatted_text(HTML(f"Message_hash: <b>{message_hash.hex()}</b>"))
signed = sign_typed_data_draft(
domain_hash, message_hash, account.path, self.dongle
)

signature = signature_to_bytes(signed.v, signed.r, signed.s)
# TODO should be refactored on safe_eth_py function insert_signature_sorted
# Insert signature sorted
if account.address not in safe_tx.signers:
new_owners = safe_tx.signers + [account.address]
new_owner_pos = sorted(new_owners, key=lambda x: int(x, 16)).index(
account.address
)
safe_tx.signatures = (
safe_tx.signatures[: 65 * new_owner_pos]
+ signature
+ safe_tx.signatures[65 * new_owner_pos :]
)

return safe_tx
Loading