diff --git a/README.md b/README.md index 88f55652..e0cc8d37 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Operations on `tx-service` mode, requires a Safe Transaction Service working on - `history`: History of multisig transactions (including pending). - `execute-tx `: Execute a pending tx with enough signatures. - `sign-tx `: Sign a tx with the loaded owners for the provided `SafeTxHash`. +- `sign_message [--eip191_message ] [--eip712_path ]`: sign the provided string message provided by standard input or the `EIP712` provided by file. - `batch-txs [ ... ]`: Batch transactions into one Multisig Transaction using the provided `safe-nonce`. **Any safe-tx can be used**: transactions from other Safes, transactions already executed, transactions pending for execution... Only limitation is that diff --git a/safe_cli/operators/safe_operator.py b/safe_cli/operators/safe_operator.py index 2c470361..b6d8260c 100644 --- a/safe_cli/operators/safe_operator.py +++ b/safe_cli/operators/safe_operator.py @@ -1,7 +1,8 @@ import dataclasses +import json import os from functools import cached_property, wraps -from typing import List, Optional, Sequence, Set +from typing import List, Optional, Sequence, Set, Tuple from ens import ENS from eth_account import Account @@ -27,7 +28,9 @@ get_erc20_contract, get_erc721_contract, get_safe_V1_1_1_contract, + get_sign_message_lib_contract, ) +from gnosis.eth.eip712 import eip712_encode from gnosis.eth.utils import get_empty_tx_params from gnosis.safe import InvalidInternalTx, Safe, SafeOperation, SafeTx from gnosis.safe.api import TransactionServiceApi @@ -60,12 +63,14 @@ ) from safe_cli.safe_addresses import ( get_default_fallback_handler_address, + get_last_sign_message_lib_address, get_safe_contract_address, get_safe_l2_contract_address, ) from safe_cli.utils import choose_option_from_list, get_erc_20_list, yes_or_no_question from ..contracts import safe_to_l2_migration +from .hw_wallets.hw_wallet import HwWallet from .hw_wallets.hw_wallet_manager import HwWalletType, get_hw_wallet_manager @@ -430,6 +435,43 @@ def approve_hash(self, hash_to_approve: HexBytes, sender: str) -> bool: ) return False + def sign_message( + self, + eip191_message: Optional[str] = None, + eip712_message_path: Optional[str] = None, + ) -> bool: + if eip712_message_path: + try: + message = json.load(open(eip712_message_path, "r")) + message_bytes = b"".join(eip712_encode(message)) + except ValueError: + raise ValueError + else: + message = eip191_message + message_bytes = eip191_message.encode("UTF-8") + + safe_message_hash = self.safe.get_message_hash(message_bytes) + + sign_message_lib_address = get_last_sign_message_lib_address( + self.ethereum_client + ) + contract = get_sign_message_lib_contract(self.ethereum_client.w3, self.address) + sign_message_data = HexBytes( + contract.functions.signMessage(message_bytes).build_transaction( + get_empty_tx_params(), + )["data"] + ) + print_formatted_text(HTML(f"Signing message: \n {message}")) + if self.prepare_and_execute_safe_transaction( + sign_message_lib_address, + 0, + sign_message_data, + operation=SafeOperation.DELEGATE_CALL, + ): + print_formatted_text( + HTML(f"Message was signed correctly: {safe_message_hash.hex()}") + ) + def add_owner(self, new_owner: str, threshold: Optional[int] = None) -> bool: threshold = threshold if threshold is not None else self.safe_cli_info.threshold if new_owner in self.safe_cli_info.owners: @@ -978,25 +1020,28 @@ def batch_safe_txs( else: return safe_tx - # TODO Set sender so we can save gas in that signature - def sign_transaction(self, safe_tx: SafeTx) -> SafeTx: + def get_signers(self) -> Tuple[List[LocalAccount], List[HwWallet]]: + """ + + :return: Tuple with eoa signers and hw_wallet signers + """ permitted_signers = self.get_permitted_signers() threshold = self.safe_cli_info.threshold - selected_accounts: List[ + eoa_signers: List[ Account ] = [] # Some accounts that are not an owner can be loaded for account in self.accounts: if account.address in permitted_signers: - selected_accounts.append(account) + eoa_signers.append(account) threshold -= 1 if threshold == 0: break # If still pending required signatures continue with ledger owners - selected_ledger_accounts = [] + hw_wallet_signers = [] if threshold > 0 and self.hw_wallet_manager.wallets: - for ledger_account in self.hw_wallet_manager.wallets: - if ledger_account.address in permitted_signers: - selected_ledger_accounts.append(ledger_account) + for hw_wallet in self.hw_wallet_manager.wallets: + if hw_wallet.address in permitted_signers: + hw_wallet_signers.append(hw_wallet) threshold -= 1 if threshold == 0: break @@ -1004,14 +1049,17 @@ def sign_transaction(self, safe_tx: SafeTx) -> SafeTx: if self.require_all_signatures and threshold > 0: raise NotEnoughSignatures(threshold) - for selected_account in selected_accounts: + return (eoa_signers, hw_wallet_signers) + + # TODO Set sender so we can save gas in that signature + def sign_transaction(self, safe_tx: SafeTx) -> SafeTx: + eoa_signers, hw_wallets_signers = self.get_signers() + for selected_account in eoa_signers: safe_tx.sign(selected_account.key) # Sign with ledger - if len(selected_ledger_accounts) > 0: - safe_tx = self.hw_wallet_manager.sign_eip712( - safe_tx, selected_ledger_accounts - ) + if len(hw_wallets_signers) > 0: + safe_tx = self.hw_wallet_manager.sign_eip712(safe_tx, hw_wallets_signers) return safe_tx diff --git a/safe_cli/operators/safe_tx_service_operator.py b/safe_cli/operators/safe_tx_service_operator.py index 1fad9454..d30b071c 100644 --- a/safe_cli/operators/safe_tx_service_operator.py +++ b/safe_cli/operators/safe_tx_service_operator.py @@ -1,15 +1,19 @@ +import json from typing import Any, Dict, Optional, Sequence, Set from colorama import Fore, Style +from eth_account.messages import defunct_hash_message from eth_typing import ChecksumAddress from hexbytes import HexBytes from prompt_toolkit import HTML, print_formatted_text from tabulate import tabulate from gnosis.eth.contracts import get_erc20_contract +from gnosis.eth.eip712 import eip712_encode_hash from gnosis.safe import SafeOperation, SafeTx from gnosis.safe.api import SafeAPIException from gnosis.safe.multi_send import MultiSend, MultiSendOperation, MultiSendTx +from gnosis.safe.signatures import signature_to_bytes from safe_cli.utils import yes_or_no_question @@ -32,6 +36,58 @@ def __init__(self, address: str, node_url: str): def approve_hash(self, hash_to_approve: HexBytes, sender: str) -> bool: raise NotImplementedError("Not supported when using tx service") + def sign_message( + self, + eip191_message: Optional[str] = None, + eip712_message_path: Optional[str] = None, + ) -> bool: + if eip712_message_path: + try: + message = json.load(open(eip712_message_path, "r")) + message_hash = eip712_encode_hash(message) + except ValueError: + raise ValueError + else: + message = eip191_message + message_hash = defunct_hash_message(text=message) + + safe_message_hash = self.safe.get_message_hash(message_hash) + eoa_signers, hw_wallet_signers = self.get_signers() + signers = [] + signatures = b"" + for eoa_signer in eoa_signers: + signature_dict = eoa_signer.signHash(safe_message_hash) + signature = signature_to_bytes( + signature_dict["v"], signature_dict["r"], signature_dict["s"] + ) + signers.append(eoa_signer.address) + signer_pos = sorted(signers, key=lambda x: int(x, 16)).index( + eoa_signer.address + ) + signatures = ( + signatures[: 65 * signer_pos] + + signature + + signatures[65 * signer_pos :] + ) + + if len(hw_wallet_signers) > 0: + raise NotImplementedError("SignHash by hardware wallet is not implemented") + + if self.safe_tx_service.post_message(self.address, message, signatures): + print_formatted_text( + HTML( + "Message was correctly created on Safe Transaction Service" + ) + ) + return True + else: + print_formatted_text( + HTML( + "Something went wrong creating message on Safe Transaction Service" + ) + ) + return False + def get_delegates(self): delegates = self.safe_tx_service.get_delegates(self.address) headers = ["delegate", "delegator", "label"] diff --git a/safe_cli/prompt_parser.py b/safe_cli/prompt_parser.py index 2ecdd43d..faae1991 100644 --- a/safe_cli/prompt_parser.py +++ b/safe_cli/prompt_parser.py @@ -189,6 +189,10 @@ def unload_cli_owners(args): def approve_hash(args): safe_operator.approve_hash(args.hash_to_approve, args.sender) + @safe_exception + def sign_message(args): + safe_operator.sign_message(args.eip191_message, args.eip712_path) + @safe_exception def add_owner(args): safe_operator.add_owner(args.address, threshold=args.threshold) @@ -368,6 +372,13 @@ def remove_delegate(args): parser_approve_hash.add_argument("sender", type=check_ethereum_address) parser_approve_hash.set_defaults(func=approve_hash) + # Sign message + parser_sign_message = subparsers.add_parser("sign_message") + group = parser_sign_message.add_mutually_exclusive_group(required=True) + group.add_argument("--eip191_message", type=str) + group.add_argument("--eip712_path", type=str) + parser_sign_message.set_defaults(func=sign_message) + # Add owner parser_add_owner = subparsers.add_parser("add_owner") parser_add_owner.add_argument("address", type=check_ethereum_address) diff --git a/safe_cli/safe_addresses.py b/safe_cli/safe_addresses.py index 249e0ebd..64267f5b 100644 --- a/safe_cli/safe_addresses.py +++ b/safe_cli/safe_addresses.py @@ -99,3 +99,17 @@ def get_last_multisend_call_only_address( "0xf220D3b4DFb23C4ade8C88E526C1353AbAcbC38F", # v1.3.0 zkSync ], ) + + +def get_last_sign_message_lib_address( + ethereum_client: EthereumClient, +) -> ChecksumAddress: + return _get_valid_contract( + ethereum_client, + [ + "0xd53cd0aB83D845Ac265BE939c57F53AD838012c9", # v1.4.1 + "0xA65387F16B013cf2Af4605Ad8aA5ec25a2cbA3a2", # v1.3.0 + "0x98FFBBF51bb33A056B08ddf711f289936AafF717", # v1.3.0 + "0x357147caf9C0cCa67DfA0CF5369318d8193c8407", # v1.3.0 zkSync + ], + ) diff --git a/safe_cli/safe_completer_constants.py b/safe_cli/safe_completer_constants.py index da4f3003..455206a4 100644 --- a/safe_cli/safe_completer_constants.py +++ b/safe_cli/safe_completer_constants.py @@ -35,6 +35,7 @@ "send_erc721": "
[--safe-nonce ]", "send_ether": "
[--safe-nonce ]", "show_cli_owners": "(read-only)", + "sign_message": "[--eip191_message ] [--eip712_path ]", "sign-tx": "", "unload_cli_owners": "
[
...]", "update": "", @@ -81,6 +82,9 @@ "sign-tx": HTML( "sign-tx will sign the provided safeTxHash using the owners loaded on the CLI" ), + "sign_message": HTML( + "sign_message sign the provided string message provided by standard input or the EIP712 provided by file" + ), "info": HTML( "info will return all the information available for a Safe, with Gnosis Tx Service and " "Etherscan links if the network is supported" diff --git a/safe_cli/safe_lexer.py b/safe_cli/safe_lexer.py index bdd743cf..1020c81b 100644 --- a/safe_cli/safe_lexer.py +++ b/safe_cli/safe_lexer.py @@ -20,6 +20,7 @@ class SafeLexer(BashLexer): "load_cli_owners", "unload_cli_owners", "approve_hash", + "sign_message", "add_owner", "change_threshold", "change_fallback_handler", diff --git a/tests/mocks/mock_eip712.json b/tests/mocks/mock_eip712.json new file mode 100644 index 00000000..5c4e2b2d --- /dev/null +++ b/tests/mocks/mock_eip712.json @@ -0,0 +1,101 @@ +{ + "types":{ + "Nested":[ + { + "name":"nestedString", + "type":"string" + }, + { + "name":"nestedAddress", + "type":"address" + }, + { + "name":"nestedUint256", + "type":"uint256" + }, + { + "name":"nestedUint32", + "type":"uint32" + }, + { + "name":"nestedBytes32", + "type":"bytes32" + }, + { + "name":"nestedBoolean", + "type":"bool" + } + ], + "Example":[ + { + "name":"testString", + "type":"string" + }, + { + "name":"testAddress", + "type":"address" + }, + { + "name":"testUint256", + "type":"uint256" + }, + { + "name":"testUint32", + "type":"uint32" + }, + { + "name":"testBytes32", + "type":"bytes32" + }, + { + "name":"testBoolean", + "type":"bool" + }, + { + "name":"testNested", + "type":"Nested" + } + ], + "EIP712Domain":[ + { + "name":"name", + "type":"string" + }, + { + "name":"version", + "type":"string" + }, + { + "name":"chainId", + "type":"uint256" + }, + { + "name":"verifyingContract", + "type":"address" + } + ] + }, + "domain":{ + "name":"Signed by safe-cli", + "version":"0.9.0_working_progress", + "chainId":"5", + "verifyingContract":"0xaecdfd3a19f777f0c03e6bf99aafb59937d6467b" + }, + "primaryType":"Example", + "message":{ + "testString":"Hello Deeeeeemo", + "testAddress":"0xaecdfd3a19f777f0c03e6bf99aafb59937d6467b", + "testUint256":"115792089237316195423570985008687907853269984665640564039457584007908834671663", + "testUint32":"123", + "testBytes32":"0x00000000000000000000000000000000000000000000000000000000deadbeef", + "testBoolean":true, + "testNested":{ + "nestedString":"Hello Deeeeeemo", + "nestedAddress":"0x0000000000000000000000000000000000000002", + "nestedUint256":"0", + "nestedUint32":"1", + "nestedBytes32":"0x000000000000000000000000000000000000000000000000000000000000da7a", + "nestedBoolean":false + } + } +} \ No newline at end of file diff --git a/tests/test_safe_operator.py b/tests/test_safe_operator.py index 991b35a0..56bb73ad 100644 --- a/tests/test_safe_operator.py +++ b/tests/test_safe_operator.py @@ -1,3 +1,4 @@ +import json import unittest from functools import lru_cache from unittest import mock @@ -11,6 +12,7 @@ from web3.types import Wei from gnosis.eth import EthereumClient +from gnosis.eth.eip712 import eip712_encode from gnosis.safe import Safe from gnosis.safe.multi_send import MultiSend @@ -172,6 +174,27 @@ def test_approve_hash(self): with self.assertRaises(HashAlreadyApproved): safe_operator.approve_hash(safe_tx_hash, self.ethereum_test_account.address) + @mock.patch("safe_cli.safe_addresses._get_valid_contract") + def test_sign_message(self, mock_sign_message_lib_address): + safe_address = self.deploy_test_safe( + owners=[self.ethereum_test_account.address] + ).address + safe_operator = SafeOperator(safe_address, self.ethereum_node_url) + mock_sign_message_lib_address.return_value = self.deploy_sign_message_lib() + message = "Safe2024" + safe_operator.accounts.add(self.ethereum_test_account) + safe_operator.default_sender = self.ethereum_test_account + message_hash = safe_operator.safe.get_message_hash(bytes(message, "utf-8")) + safe_operator.sign_message(eip191_message=message) + self.assertTrue(safe_operator.safe.retrieve_is_message_signed(message_hash)) + eip712_path = "tests/mocks/mock_eip712.json" + message = json.load(open(eip712_path, "r")) + message_hash = safe_operator.safe.get_message_hash( + b"".join(eip712_encode(message)) + ) + safe_operator.sign_message(eip712_message_path=eip712_path) + self.assertTrue(safe_operator.safe.retrieve_is_message_signed(message_hash)) + def test_add_owner(self): safe_address = self.deploy_test_safe( owners=[self.ethereum_test_account.address]