Example #1
0
    def test_safe_cli_happy_path(self):
        accounts = [self.ethereum_test_account, Account.create()]
        account_addresses = [account.address for account in accounts]
        safe_address = self.deploy_test_safe(
            owners=account_addresses,
            threshold=2,
            initial_funding_wei=self.w3.toWei(1, "ether"),
        ).safe_address
        safe = Safe(safe_address, self.ethereum_client)
        safe_operator = SafeOperator(safe_address, self.ethereum_node_url)
        prompt_parser = PromptParser(safe_operator)
        random_address = Account.create().address

        self.assertEqual(safe_operator.accounts, set())
        prompt_parser.process_command(
            f"load_cli_owners {self.ethereum_test_account.key.hex()}")
        self.assertEqual(safe_operator.default_sender,
                         self.ethereum_test_account)
        self.assertEqual(safe_operator.accounts, {self.ethereum_test_account})

        prompt_parser.process_command(
            f"send_ether {random_address} 1")  # No enough signatures
        self.assertEqual(self.ethereum_client.get_balance(random_address), 0)

        value = 123
        prompt_parser.process_command(
            f"load_cli_owners {accounts[1].key.hex()}")
        prompt_parser.process_command(f"send_ether {random_address} {value}")
        self.assertEqual(self.ethereum_client.get_balance(random_address),
                         value)

        # Change threshold
        self.assertEqual(safe_operator.safe_cli_info.threshold, 2)
        self.assertEqual(safe.retrieve_threshold(), 2)
        prompt_parser.process_command("change_threshold 1")
        self.assertEqual(safe_operator.safe_cli_info.threshold, 1)
        self.assertEqual(safe.retrieve_threshold(), 1)

        # Approve Hash
        safe_tx_hash = Web3.keccak(text="hola")
        self.assertFalse(
            safe_operator.safe.retrieve_is_hash_approved(
                accounts[0].address, safe_tx_hash))
        prompt_parser.process_command(
            f"approve_hash {safe_tx_hash.hex()} {accounts[0].address}")
        self.assertTrue(
            safe_operator.safe.retrieve_is_hash_approved(
                accounts[0].address, safe_tx_hash))

        # Remove owner
        self.assertEqual(len(safe_operator.safe_cli_info.owners), 2)
        self.assertEqual(len(safe.retrieve_owners()), 2)
        prompt_parser.process_command(f"remove_owner {accounts[1].address}")
        self.assertEqual(safe_operator.safe_cli_info.owners,
                         [self.ethereum_test_account.address])
        self.assertEqual(safe.retrieve_owners(),
                         [self.ethereum_test_account.address])
Example #2
0
    def deploy_test_safe_v1_0_0(
        self,
        number_owners: int = 3,
        threshold: Optional[int] = None,
        owners: Optional[List[ChecksumAddress]] = None,
        initial_funding_wei: int = 0,
    ) -> Safe:
        owners = (owners if owners else
                  [Account.create().address for _ in range(number_owners)])
        if not threshold:
            threshold = len(owners) - 1 if len(owners) > 1 else 1
        empty_parameters = {"gas": 1, "gasPrice": 1}
        to = NULL_ADDRESS
        data = b""
        payment_token = NULL_ADDRESS
        payment = 0
        payment_receiver = NULL_ADDRESS
        initializer = HexBytes(
            self.safe_contract_V1_0_0.functions.setup(
                owners, threshold, to, data, payment_token, payment,
                payment_receiver).buildTransaction(empty_parameters)["data"])
        ethereum_tx_sent = self.proxy_factory.deploy_proxy_contract(
            self.ethereum_test_account,
            self.safe_contract_V1_0_0_address,
            initializer=initializer,
        )
        safe = Safe(ethereum_tx_sent.contract_address, self.ethereum_client)
        if initial_funding_wei:
            self.send_ether(safe.address, initial_funding_wei)

        self.assertEqual(safe.retrieve_version(), "1.0.0")
        self.assertEqual(safe.retrieve_threshold(), threshold)
        self.assertCountEqual(safe.retrieve_owners(), owners)

        return safe
 def retrieve_safe_info(self, address: str) -> SafeInfo:
     safe = Safe(address, self.ethereum_client)
     if not self.ethereum_client.is_contract(address):
         raise SafeNotDeployed('Safe with address=%s not deployed' %
                               address)
     nonce = safe.retrieve_nonce()
     threshold = safe.retrieve_threshold()
     owners = safe.retrieve_owners()
     master_copy = safe.retrieve_master_copy_address()
     version = safe.retrieve_version()
     return SafeInfo(address, nonce, threshold, owners, master_copy,
                     version)
Example #4
0
    def test_safe_cli_happy_path(self):
        accounts = [self.ethereum_test_account, Account.create()]
        account_addresses = [account.address for account in accounts]
        safe_address = self.deploy_test_safe(owners=account_addresses, threshold=2,
                                             initial_funding_wei=self.w3.toWei(1, 'ether')).safe_address
        safe = Safe(safe_address, self.ethereum_client)
        safe_operator = SafeOperator(safe_address, self.ethereum_node_url)
        prompt_parser = PromptParser(safe_operator)
        random_address = Account.create().address

        self.assertEqual(safe_operator.accounts, set())
        prompt_parser.process_command(f'load_cli_owners {self.ethereum_test_account.key.hex()}')
        self.assertEqual(safe_operator.default_sender, self.ethereum_test_account)
        self.assertEqual(safe_operator.accounts, {self.ethereum_test_account})

        prompt_parser.process_command(f'send_ether {random_address} 1')  # No enough signatures
        self.assertEqual(self.ethereum_client.get_balance(random_address), 0)

        value = 123
        prompt_parser.process_command(f'load_cli_owners {accounts[1].key.hex()}')
        prompt_parser.process_command(f'send_ether {random_address} {value}')
        self.assertEqual(self.ethereum_client.get_balance(random_address), value)

        # Change threshold
        self.assertEqual(safe_operator.safe_cli_info.threshold, 2)
        self.assertEqual(safe.retrieve_threshold(), 2)
        prompt_parser.process_command('change_threshold 1')
        self.assertEqual(safe_operator.safe_cli_info.threshold, 1)
        self.assertEqual(safe.retrieve_threshold(), 1)

        # Remove owner
        self.assertEqual(len(safe_operator.safe_cli_info.owners), 2)
        self.assertEqual(len(safe.retrieve_owners()), 2)
        prompt_parser.process_command(f'remove_owner {accounts[1].address}')
        self.assertEqual(safe_operator.safe_cli_info.owners, [self.ethereum_test_account.address])
        self.assertEqual(safe.retrieve_owners(), [self.ethereum_test_account.address])
    def _send_multisig_tx(self,
                          safe_address: str,
                          to: str,
                          value: int,
                          data: bytes,
                          operation: int,
                          safe_tx_gas: int,
                          base_gas: int,
                          gas_price: int,
                          gas_token: str,
                          refund_receiver: str,
                          safe_nonce: int,
                          signatures: bytes,
                          block_identifier='latest') -> Tuple[bytes, bytes, Dict[str, Any]]:
        """
        This function calls the `send_multisig_tx` of the Safe, but has some limitations to prevent abusing
        the relay
        :return: Tuple(tx_hash, safe_tx_hash, tx)
        :raises: InvalidMultisigTx: If user tx cannot go through the Safe
        """

        safe = Safe(safe_address, self.ethereum_client)
        data = data or b''
        gas_token = gas_token or NULL_ADDRESS
        refund_receiver = refund_receiver or NULL_ADDRESS
        to = to or NULL_ADDRESS

        # Make sure refund receiver is set to 0x0 so that the contract refunds the gas costs to tx.origin
        if not self._check_refund_receiver(refund_receiver):
            raise InvalidRefundReceiver(refund_receiver)

        self._check_safe_gas_price(gas_token, gas_price)

        # Make sure proxy contract is ours
        if not self.proxy_factory.check_proxy_code(safe_address):
            raise InvalidProxyContract(safe_address)

        # Make sure master copy is valid
        safe_master_copy_address = safe.retrieve_master_copy_address()
        if safe_master_copy_address not in self.safe_valid_contract_addresses:
            raise InvalidMasterCopyAddress(safe_master_copy_address)

        # Check enough funds to pay for the gas
        if not safe.check_funds_for_tx_gas(safe_tx_gas, base_gas, gas_price, gas_token):
            raise NotEnoughFundsForMultisigTx

        threshold = safe.retrieve_threshold()
        number_signatures = len(signatures) // 65  # One signature = 65 bytes
        if number_signatures < threshold:
            raise SignaturesNotFound('Need at least %d signatures' % threshold)

        safe_tx_gas_estimation = safe.estimate_tx_gas(to, value, data, operation)
        safe_base_gas_estimation = safe.estimate_tx_base_gas(to, value, data, operation, gas_token,
                                                             safe_tx_gas_estimation)
        if safe_tx_gas < safe_tx_gas_estimation or base_gas < safe_base_gas_estimation:
            raise InvalidGasEstimation("Gas should be at least equal to safe-tx-gas=%d and base-gas=%d. Current is "
                                       "safe-tx-gas=%d and base-gas=%d" %
                                       (safe_tx_gas_estimation, safe_base_gas_estimation, safe_tx_gas, base_gas))

        # We use fast tx gas price, if not txs could be stuck
        tx_gas_price = self._get_configured_gas_price()
        tx_sender_private_key = self.tx_sender_account.key
        tx_sender_address = Account.from_key(tx_sender_private_key).address

        safe_tx = safe.build_multisig_tx(
            to,
            value,
            data,
            operation,
            safe_tx_gas,
            base_gas,
            gas_price,
            gas_token,
            refund_receiver,
            signatures,
            safe_nonce=safe_nonce,
            safe_version=safe.retrieve_version()
        )

        owners = safe.retrieve_owners()
        signers = safe_tx.signers
        if set(signers) - set(owners):  # All the signers must be owners
            raise InvalidOwners('Signers=%s are not valid owners of the safe. Owners=%s', safe_tx.signers, owners)

        if signers != safe_tx.sorted_signers:
            raise SignaturesNotSorted('Safe-tx-hash=%s - Signatures are not sorted by owner: %s' %
                                      (safe_tx.safe_tx_hash.hex(), safe_tx.signers))

        if banned_signers := BannedSigner.objects.filter(address__in=signers):
            raise SignerIsBanned(f'Signers {list(banned_signers)} are banned')
Example #6
0
class SafeOperator:
    def __init__(self, address: str, node_url: str):
        self.address = address
        self.node_url = node_url
        self.ethereum_client = EthereumClient(self.node_url)
        self.ens = ENS.fromWeb3(self.ethereum_client.w3)
        self.network: EthereumNetwork = self.ethereum_client.get_network()
        self.etherscan = Etherscan.from_network_number(self.network.value)
        self.safe_tx_service = TransactionService.from_network_number(
            self.network.value)
        self.safe_relay_service = RelayService.from_network_number(
            self.network.value)
        self.safe = Safe(address, self.ethereum_client)
        self.safe_contract = self.safe.get_contract()
        self.accounts: Set[LocalAccount] = set()
        self.default_sender: Optional[LocalAccount] = None
        self.executed_transactions: List[str] = []
        self._safe_cli_info: Optional[
            SafeCliInfo] = None  # Cache for SafeCliInfo

    @cached_property
    def ens_domain(self) -> Optional[str]:
        # FIXME After web3.py fixes the middleware copy
        if self.network == EthereumNetwork.MAINNET:
            return self.ens.name(self.address)

    @property
    def safe_cli_info(self) -> SafeCliInfo:
        if not self._safe_cli_info:
            self._safe_cli_info = self.refresh_safe_cli_info()
        return self._safe_cli_info

    def _require_default_sender(self) -> NoReturn:
        """
        Throws SenderRequiredException if not default sender configured
        """
        if not self.default_sender:
            raise SenderRequiredException()

    def is_version_updated(self) -> bool:
        """
        :return: True if Safe Master Copy is updated, False otherwise
        """

        if self._safe_cli_info.master_copy == LAST_SAFE_CONTRACT:
            return True
        else:  # Check versions, maybe safe-cli addresses were not updated
            safe_contract = get_safe_contract(self.ethereum_client.w3,
                                              LAST_SAFE_CONTRACT)
            try:
                safe_contract_version = safe_contract.functions.VERSION().call(
                )
            except BadFunctionCallOutput:  # Safe master copy is not deployed or errored, maybe custom network
                return True  # We cannot say you are not updated ¯\_(ツ)_/¯
            return semantic_version.parse(
                self.safe_cli_info.version) >= semantic_version.parse(
                    safe_contract_version)

    def refresh_safe_cli_info(self) -> SafeCliInfo:
        self._safe_cli_info = self.get_safe_cli_info()
        return self._safe_cli_info

    def get_balances(self):
        if not self.safe_tx_service:  # TODO Maybe use Etherscan
            print_formatted_text(
                HTML(f'<ansired>No tx service available for '
                     f'network={self.network.name}</ansired>'))
        else:
            balances = self.safe_tx_service.get_balances(self.address)
            headers = ['name', 'balance', 'symbol', 'decimals', 'tokenAddress']
            rows = []
            for balance in balances:
                if balance['tokenAddress']:  # Token
                    row = [
                        balance['token']['name'],
                        f"{int(balance['balance']) / 10**int(balance['token']['decimals']):.5f}",
                        balance['token']['symbol'],
                        balance['token']['decimals'], balance['tokenAddress']
                    ]
                else:  # Ether
                    row = [
                        'ETHER', f"{int(balance['balance']) / 10 ** 18:.5f}",
                        'Ξ', 18, ''
                    ]
                rows.append(row)
            print(tabulate(rows, headers=headers))

    def get_transaction_history(self):
        if not self.safe_tx_service:
            print_formatted_text(
                HTML(f'<ansired>No tx service available for '
                     f'network={self.network.name}</ansired>'))
            if self.etherscan.base_url:
                url = f'{self.etherscan.base_url}/address/{self.address}'
                print_formatted_text(
                    HTML(f'<b>Try Etherscan instead</b> {url}'))
        else:
            transactions = self.safe_tx_service.get_transactions(self.address)
            headers = ['nonce', 'to', 'value', 'transactionHash', 'safeTxHash']
            rows = []
            last_executed_tx = False
            for transaction in transactions:
                row = [transaction[header] for header in headers]
                data_decoded: Dict[str, Any] = transaction.get('dataDecoded')
                if data_decoded:
                    row.append(
                        self.safe_tx_service.data_decoded_to_text(
                            data_decoded))
                if transaction['transactionHash'] and transaction[
                        'isSuccessful']:
                    row[0] = Fore.GREEN + str(
                        row[0])  # For executed transactions we use green
                    if not last_executed_tx:
                        row[0] = Style.BRIGHT + row[0]
                        last_executed_tx = True
                elif transaction['transactionHash']:
                    row[0] = Fore.RED + str(row[0])  # For transactions failed
                else:
                    row[0] = Fore.YELLOW + str(
                        row[0])  # For non executed transactions we use yellow

                row[0] = Style.RESET_ALL + row[0]  # Reset all just in case
                rows.append(row)

            headers.append('dataDecoded')
            headers[0] = Style.BRIGHT + headers[0]
            print(tabulate(rows, headers=headers))

    def load_cli_owners_from_words(self, words: List[str]):
        if len(words) == 1:  # Reading seed from Environment Variable
            words = os.environ.get(words[0], default="").strip().split(" ")
        parsed_words = ' '.join(words)
        try:
            for index in range(100):  # Try first accounts of seed phrase
                account = get_account_from_words(parsed_words, index=index)
                if account.address in self.safe_cli_info.owners:
                    self.load_cli_owners([account.key.hex()])
            if not index:
                print_formatted_text(
                    HTML(
                        f'<ansired>Cannot generate any valid owner for this Safe</ansired>'
                    ))
        except ValidationError:
            print_formatted_text(
                HTML(f'<ansired>Cannot load owners from words</ansired>'))

    def load_cli_owners(self, keys: List[str]):
        for key in keys:
            try:
                account = Account.from_key(os.environ.get(
                    key, default=key))  # Try to get key from `environ`
                self.accounts.add(account)
                balance = self.ethereum_client.get_balance(account.address)
                print_formatted_text(
                    HTML(f'Loaded account <b>{account.address}</b> '
                         f'with balance={Web3.fromWei(balance, "ether")} ether'
                         ))
                if not self.default_sender and balance > 0:
                    print_formatted_text(
                        HTML(
                            f'Set account <b>{account.address}</b> as default sender of txs'
                        ))
                    self.default_sender = account
            except ValueError:
                print_formatted_text(
                    HTML(f'<ansired>Cannot load key=f{key}</ansired>'))

    def unload_cli_owners(self, owners: List[str]):
        accounts_to_remove: Set[Account] = set()
        for owner in owners:
            for account in self.accounts:
                if account.address == owner:
                    if self.default_sender and self.default_sender.address == owner:
                        self.default_sender = None
                    accounts_to_remove.add(account)
                    break
        self.accounts = self.accounts.difference(accounts_to_remove)
        if accounts_to_remove:
            print_formatted_text(
                HTML(f'<ansigreen>Accounts have been deleted</ansigreen>'))
        else:
            print_formatted_text(
                HTML(f'<ansired>No account was deleted</ansired>'))

    def show_cli_owners(self):
        if not self.accounts:
            print_formatted_text(
                HTML(f'<ansired>No accounts loaded</ansired>'))
        else:
            for account in self.accounts:
                print_formatted_text(
                    HTML(
                        f'<ansigreen><b>Account</b> {account.address} loaded</ansigreen>'
                    ))
            if self.default_sender:
                print_formatted_text(
                    HTML(
                        f'<ansigreen><b>Default sender:</b> {self.default_sender.address}'
                        f'</ansigreen>'))
            else:
                print_formatted_text(
                    HTML(f'<ansigreen>Not default sender set </ansigreen>'))

    def add_owner(self, new_owner: str) -> bool:
        if new_owner in self.safe_cli_info.owners:
            raise ExistingOwnerException(new_owner)
        else:
            # TODO Allow to set threshold
            threshold = self.safe_cli_info.threshold
            transaction = self.safe_contract.functions.addOwnerWithThreshold(
                new_owner, threshold).buildTransaction({
                    'from': self.address,
                    'gas': 0,
                    'gasPrice': 0
                })
            if self.execute_safe_internal_transaction(transaction['data']):
                self.safe_cli_info.owners = self.safe.retrieve_owners()
                self.safe_cli_info.threshold = threshold
                return True
            return False

    def remove_owner(self, owner_to_remove: str):
        if owner_to_remove not in self.safe_cli_info.owners:
            raise NonExistingOwnerException(owner_to_remove)
        elif len(self.safe_cli_info.owners) == self.safe_cli_info.threshold:
            raise ThresholdLimitException()
        else:
            index_owner = self.safe_cli_info.owners.index(owner_to_remove)
            prev_owner = self.safe_cli_info.owners[
                index_owner - 1] if index_owner else SENTINEL_ADDRESS
            threshold = self.safe_cli_info.threshold
            transaction = self.safe_contract.functions.removeOwner(
                prev_owner, owner_to_remove, threshold).buildTransaction({
                    'from':
                    self.address,
                    'gas':
                    0,
                    'gasPrice':
                    0
                })
            if self.execute_safe_internal_transaction(transaction['data']):
                self.safe_cli_info.owners = self.safe.retrieve_owners()
                self.safe_cli_info.threshold = threshold
                return True
            return False

    def send_custom(
        self,
        to: str,
        value: int,
        data: bytes,
        safe_nonce: Optional[int] = None,
        delegate_call: bool = False,
        destination: TransactionDestination = TransactionDestination.BLOCKCHAIN
    ) -> bool:
        if value > 0:
            safe_balance = self.ethereum_client.get_balance(self.address)
            if safe_balance < value:
                raise NotEnoughEtherToSend(safe_balance)
        operation = SafeOperation.DELEGATE_CALL if delegate_call else SafeOperation.CALL
        if destination == TransactionDestination.BLOCKCHAIN:
            return self.execute_safe_transaction(to,
                                                 value,
                                                 data,
                                                 operation,
                                                 safe_nonce=safe_nonce)
        elif destination == TransactionDestination.TRANSACTION_SERVICE:
            return self.post_transaction_to_tx_service(to,
                                                       value,
                                                       data,
                                                       operation,
                                                       safe_nonce=safe_nonce)
        elif destination == TransactionDestination.RELAY_SERVICE:
            return self.post_transaction_to_relay_service(
                to, value, data, operation)

    def send_ether(self, to: str, value: int, **kwargs) -> bool:
        return self.send_custom(to, value, b'', **kwargs)

    def send_erc20(self, to: str, token_address: str, amount: int,
                   **kwargs) -> bool:
        transaction = get_erc20_contract(self.ethereum_client.w3,
                                         token_address).functions.transfer(
                                             to, amount).buildTransaction({
                                                 'from':
                                                 self.address,
                                                 'gas':
                                                 0,
                                                 'gasPrice':
                                                 0
                                             })
        return self.send_custom(token_address, 0,
                                HexBytes(transaction['data']), **kwargs)

    def send_erc721(self, to: str, token_address: str, token_id: int,
                    **kwargs) -> bool:
        transaction = get_erc721_contract(
            self.ethereum_client.w3,
            token_address).functions.transferFrom(self.address, to,
                                                  token_id).buildTransaction({
                                                      'from':
                                                      self.address,
                                                      'gas':
                                                      0,
                                                      'gasPrice':
                                                      0
                                                  })
        return self.send_custom(token_address, 0, transaction['data'],
                                **kwargs)

    def change_fallback_handler(self, new_fallback_handler: str) -> bool:
        if new_fallback_handler == self.safe_cli_info.fallback_handler:
            raise SameFallbackHandlerException(new_fallback_handler)
        elif semantic_version.parse(
                self.safe_cli_info.version) < semantic_version.parse('1.1.0'):
            raise FallbackHandlerNotSupportedException()
        else:
            # TODO Check that fallback handler is valid
            transaction = self.safe_contract.functions.setFallbackHandler(
                new_fallback_handler).buildTransaction({
                    'from': self.address,
                    'gas': 0,
                    'gasPrice': 0
                })
            if self.execute_safe_internal_transaction(transaction['data']):
                self.safe_cli_info.fallback_handler = new_fallback_handler
                self.safe_cli_info.version = self.safe.retrieve_version()
                return True

    def change_master_copy(self, new_master_copy: str) -> bool:
        # TODO Check that master copy is valid
        if new_master_copy == self.safe_cli_info.master_copy:
            raise SameMasterCopyException(new_master_copy)
        else:
            try:
                Safe(new_master_copy, self.ethereum_client).retrieve_version()
            except BadFunctionCallOutput:
                raise InvalidMasterCopyException(new_master_copy)

            transaction = self.safe_contract.functions.changeMasterCopy(
                new_master_copy).buildTransaction({
                    'from': self.address,
                    'gas': 0,
                    'gasPrice': 0
                })
            if self.execute_safe_internal_transaction(transaction['data']):
                self.safe_cli_info.master_copy = new_master_copy
                self.safe_cli_info.version = self.safe.retrieve_version()
                return True

    def update_version(self) -> Optional[bool]:
        """
        Update Safe Master Copy and Fallback handler to the last version
        :return:
        """
        if self.is_version_updated():
            raise SafeAlreadyUpdatedException()

        multisend = MultiSend(LAST_MULTISEND_CONTRACT, self.ethereum_client)
        tx_params = {'from': self.address, 'gas': 0, 'gasPrice': 0}
        multisend_txs = [
            MultiSendTx(MultiSendOperation.CALL, self.address, 0, data)
            for data in (self.safe_contract.functions.changeMasterCopy(
                LAST_SAFE_CONTRACT).buildTransaction(tx_params)['data'],
                         self.safe_contract.functions.setFallbackHandler(
                             LAST_DEFAULT_CALLBACK_HANDLER).buildTransaction(
                                 tx_params)['data'])
        ]

        multisend_data = multisend.build_tx_data(multisend_txs)

        if self.execute_safe_transaction(
                multisend.address,
                0,
                multisend_data,
                operation=SafeOperation.DELEGATE_CALL):
            self.safe_cli_info.master_copy = LAST_SAFE_CONTRACT
            self.safe_cli_info.fallback_handler = LAST_DEFAULT_CALLBACK_HANDLER
            self.safe_cli_info.version = self.safe.retrieve_version()

    def change_threshold(self, threshold: int):
        if threshold == self.safe_cli_info.threshold:
            print_formatted_text(
                HTML(f'<ansired>Threshold is already {threshold}</ansired>'))
        elif threshold > len(self.safe_cli_info.owners):
            print_formatted_text(
                HTML(f'<ansired>Threshold={threshold} bigger than number '
                     f'of owners={len(self.safe_cli_info.owners)}</ansired>'))
        else:
            transaction = self.safe_contract.functions.changeThreshold(
                threshold).buildTransaction({
                    'from': self.address,
                    'gas': 0,
                    'gasPrice': 0
                })

            if self.execute_safe_internal_transaction(transaction['data']):
                self.safe_cli_info.threshold = threshold

    def enable_module(self, module_address: str):
        if module_address in self.safe_cli_info.modules:
            print_formatted_text(
                HTML(
                    f'<ansired>Module {module_address} is already enabled</ansired>'
                ))
        else:
            transaction = self.safe_contract.functions.enableModule(
                module_address).buildTransaction({
                    'from': self.address,
                    'gas': 0,
                    'gasPrice': 0
                })
            if self.execute_safe_internal_transaction(transaction['data']):
                self.safe_cli_info.modules = self.safe.retrieve_modules()

    def disable_module(self, module_address: str):
        if module_address not in self.safe_cli_info.modules:
            print_formatted_text(
                HTML(
                    f'<ansired>Module {module_address} is not enabled</ansired>'
                ))
        else:
            pos = self.safe_cli_info.modules.index(module_address)
            if pos == 0:
                previous_address = SENTINEL_ADDRESS
            else:
                previous_address = self.safe_cli_info.modules[pos - 1]
            transaction = self.safe_contract.functions.disableModule(
                previous_address, module_address).buildTransaction({
                    'from':
                    self.address,
                    'gas':
                    0,
                    'gasPrice':
                    0
                })
            if self.execute_safe_internal_transaction(transaction['data']):
                self.safe_cli_info.modules = self.safe.retrieve_modules()

    def print_info(self):
        for key, value in dataclasses.asdict(self.safe_cli_info).items():
            print_formatted_text(
                HTML(f'<b><ansigreen>{key.capitalize()}</ansigreen></b>='
                     f'<ansiblue>{value}</ansiblue>'))
        if self.ens_domain:
            print_formatted_text(
                HTML(f'<b><ansigreen>Ens domain</ansigreen></b>='
                     f'<ansiblue>{self.ens_domain}</ansiblue>'))
        if self.safe_tx_service:
            url = f'{self.safe_tx_service.base_url}/api/v1/safes/{self.address}/transactions/'
            print_formatted_text(
                HTML(f'<b><ansigreen>Safe Tx Service</ansigreen></b>='
                     f'<ansiblue>{url}</ansiblue>'))

        if self.safe_relay_service:
            url = f'{self.safe_relay_service.base_url}/api/v1/safes/{self.address}/transactions/'
            print_formatted_text(
                HTML(f'<b><ansigreen>Safe Relay Service</ansigreen></b>='
                     f'<ansiblue>{url}</ansiblue>'))

        if self.etherscan.base_url:
            url = f'{self.etherscan.base_url}/address/{self.address}'
            print_formatted_text(
                HTML(f'<b><ansigreen>Etherscan</ansigreen></b>='
                     f'<ansiblue>{url}</ansiblue>'))

        if not self.is_version_updated():
            print_formatted_text(
                HTML(
                    f'<ansired>Safe is not updated! You can use <b>update</b> command to update '
                    f'the Safe to a newest version</ansired>'))

    def get_safe_cli_info(self) -> SafeCliInfo:
        safe = self.safe
        balance_ether = Web3.fromWei(
            self.ethereum_client.get_balance(self.address), 'ether')
        safe_info = safe.retrieve_all_info()
        return SafeCliInfo(self.address, safe_info.nonce, safe_info.threshold,
                           safe_info.owners, safe_info.master_copy,
                           safe_info.modules, safe_info.fallback_handler,
                           balance_ether, safe_info.version)

    def get_threshold(self):
        print_formatted_text(self.safe.retrieve_threshold())

    def get_nonce(self):
        print_formatted_text(self.safe.retrieve_nonce())

    def get_owners(self):
        print_formatted_text(self.safe.retrieve_owners())

    def execute_safe_internal_transaction(self, data: bytes) -> bool:
        return self.execute_safe_transaction(self.address, 0, data)

    def execute_safe_transaction(self,
                                 to: str,
                                 value: int,
                                 data: bytes,
                                 operation: SafeOperation = SafeOperation.CALL,
                                 safe_nonce: Optional[int] = None) -> bool:
        self._require_default_sender(
        )  # Throws Exception if default sender not found
        # TODO Test tx is successful
        safe_tx = self.safe.build_multisig_tx(to,
                                              value,
                                              data,
                                              operation=operation.value,
                                              safe_nonce=safe_nonce)
        self.sign_transaction(
            safe_tx)  # Raises exception if it cannot be signed

        try:
            call_result = safe_tx.call(self.default_sender.address)
            print_formatted_text(
                HTML(f'Result: <ansigreen>{call_result}</ansigreen>'))
            tx_hash, _ = safe_tx.execute(self.default_sender.key)
            self.executed_transactions.append(tx_hash.hex())
            print_formatted_text(
                HTML(
                    f'<ansigreen>Executed tx with tx-hash={tx_hash.hex()} '
                    f'and safe-nonce={safe_tx.safe_nonce}, waiting for receipt</ansigreen>'
                ))
            if self.ethereum_client.get_transaction_receipt(tx_hash,
                                                            timeout=120):
                self.safe_cli_info.nonce += 1
                return True
            else:
                print_formatted_text(
                    HTML(
                        f'<ansired>Tx with tx-hash={tx_hash.hex()} still not mined</ansired>'
                    ))
            return False
        except InvalidInternalTx as invalid_internal_tx:
            print_formatted_text(
                HTML(
                    f'Result: <ansired>InvalidTx - {invalid_internal_tx}</ansired>'
                ))
            return False

    def post_transaction_to_tx_service(
            self,
            to: str,
            value: int,
            data: bytes,
            operation: SafeOperation = SafeOperation.CALL,
            safe_nonce: Optional[int] = None):
        if not self.safe_tx_service:
            raise ServiceNotAvailable(self.network.name)

        safe_tx = self.safe.build_multisig_tx(to,
                                              value,
                                              data,
                                              operation=operation.value,
                                              safe_nonce=safe_nonce)
        for account in self.accounts:
            safe_tx.sign(
                account.key)  # Raises exception if it cannot be signed
        self.safe_tx_service.post_transaction(self.address, safe_tx)

    def post_transaction_to_relay_service(
            self,
            to: str,
            value: int,
            data: bytes,
            operation: SafeOperation = SafeOperation.CALL,
            gas_token: Optional[str] = None):
        if not self.safe_relay_service:
            raise ServiceNotAvailable(self.network.name)

        safe_tx = self.safe.build_multisig_tx(to,
                                              value,
                                              data,
                                              operation=operation.value,
                                              gas_token=gas_token)
        estimation = self.safe_relay_service.get_estimation(
            self.address, safe_tx)
        safe_tx.base_gas = estimation['baseGas']
        safe_tx.safe_tx_gas = estimation['safeTxGas']
        safe_tx.gas_price = estimation['gasPrice']
        safe_tx.safe_nonce = estimation['lastUsedNonce'] + 1
        safe_tx.refund_receiver = estimation['refundReceiver']
        self.sign_transaction(safe_tx)
        transaction_data = self.safe_relay_service.send_transaction(
            self.address, safe_tx)
        tx_hash = transaction_data['txHash']
        print_formatted_text(
            HTML(f'<ansigreen>Gnosis Safe Relay has queued transaction with '
                 f'transaction-hash <b>{tx_hash}</b></ansigreen>'))

    # TODO Set sender so we can save gas in that signature
    def sign_transaction(self, safe_tx: SafeTx) -> NoReturn:
        owners = self.safe_cli_info.owners
        threshold = self.safe_cli_info.threshold
        selected_accounts: List[Account] = [
        ]  # Some accounts that are not an owner can be loaded
        for account in self.accounts:
            if account.address in owners:
                selected_accounts.append(account)
                threshold -= 1
                if threshold == 0:
                    break

        if threshold > 0:
            raise NotEnoughSignatures(threshold)

        for selected_account in selected_accounts:
            safe_tx.sign(selected_account.key)
        """
        selected_accounts.sort(key=lambda a: a.address.lower())
        signatures: bytes = b''
        for selected_account in selected_accounts:
            signatures += selected_account.signHash(safe_tx_hash)
        return signatures
        """

    def process_command(self, first_command: str,
                        rest_command: List[str]) -> bool:
        if first_command == 'help':
            print_formatted_text('I still cannot help you')
        elif first_command == 'history':
            self.get_transaction_history()
        elif first_command == 'refresh':
            print_formatted_text('Reloading Safe information')
            self.refresh_safe_cli_info()

        return False
Example #7
0
class SafeOperator:
    def __init__(self, address: str, node_url: str):
        self.address = address
        self.node_url = node_url
        self.ethereum_client = EthereumClient(self.node_url)
        self.ens = ENS.fromWeb3(self.ethereum_client.w3)
        self.network: EthereumNetwork = self.ethereum_client.get_network()
        self.etherscan = EtherscanApi.from_ethereum_client(
            self.ethereum_client)
        self.safe_relay_service = RelayServiceApi.from_ethereum_client(
            self.ethereum_client)
        self.safe_tx_service = TransactionServiceApi.from_ethereum_client(
            self.ethereum_client)
        self.safe = Safe(address, self.ethereum_client)
        self.safe_contract = self.safe.get_contract()
        self.safe_contract_1_1_0 = get_safe_V1_1_1_contract(
            self.ethereum_client.w3, address=self.address)
        self.accounts: Set[LocalAccount] = set()
        self.default_sender: Optional[LocalAccount] = None
        self.executed_transactions: List[str] = []
        self._safe_cli_info: Optional[
            SafeCliInfo] = None  # Cache for SafeCliInfo
        self.require_all_signatures = (
            True  # Require all signatures to be present to send a tx
        )

    @cached_property
    def ens_domain(self) -> Optional[str]:
        # FIXME After web3.py fixes the middleware copy
        if self.network == EthereumNetwork.MAINNET:
            return self.ens.name(self.address)

    @property
    def safe_cli_info(self) -> SafeCliInfo:
        if not self._safe_cli_info:
            self._safe_cli_info = self.refresh_safe_cli_info()
        return self._safe_cli_info

    def is_version_updated(self) -> bool:
        """
        :return: True if Safe Master Copy is updated, False otherwise
        """

        if self._safe_cli_info.master_copy == LAST_SAFE_CONTRACT:
            return True
        else:  # Check versions, maybe safe-cli addresses were not updated
            safe_contract = get_safe_contract(self.ethereum_client.w3,
                                              LAST_SAFE_CONTRACT)
            try:
                safe_contract_version = safe_contract.functions.VERSION().call(
                )
            except BadFunctionCallOutput:  # Safe master copy is not deployed or errored, maybe custom network
                return True  # We cannot say you are not updated ¯\_(ツ)_/¯
            return semantic_version.parse(
                self.safe_cli_info.version) >= semantic_version.parse(
                    safe_contract_version)

    def refresh_safe_cli_info(self) -> SafeCliInfo:
        self._safe_cli_info = self.get_safe_cli_info()
        return self._safe_cli_info

    def load_cli_owners_from_words(self, words: List[str]):
        if len(words) == 1:  # Reading seed from Environment Variable
            words = os.environ.get(words[0], default="").strip().split(" ")
        parsed_words = " ".join(words)
        try:
            for index in range(100):  # Try first accounts of seed phrase
                account = get_account_from_words(parsed_words, index=index)
                if account.address in self.safe_cli_info.owners:
                    self.load_cli_owners([account.key.hex()])
            if not index:
                print_formatted_text(
                    HTML(
                        "<ansired>Cannot generate any valid owner for this Safe</ansired>"
                    ))
        except ValidationError:
            print_formatted_text(
                HTML("<ansired>Cannot load owners from words</ansired>"))

    def load_cli_owners(self, keys: List[str]):
        for key in keys:
            try:
                account = Account.from_key(os.environ.get(
                    key, default=key))  # Try to get key from `environ`
                self.accounts.add(account)
                balance = self.ethereum_client.get_balance(account.address)
                print_formatted_text(
                    HTML(f"Loaded account <b>{account.address}</b> "
                         f'with balance={Web3.fromWei(balance, "ether")} ether'
                         ))
                if not self.default_sender and balance > 0:
                    print_formatted_text(
                        HTML(
                            f"Set account <b>{account.address}</b> as default sender of txs"
                        ))
                    self.default_sender = account
            except ValueError:
                print_formatted_text(
                    HTML(f"<ansired>Cannot load key={key}</ansired>"))

    def unload_cli_owners(self, owners: List[str]):
        accounts_to_remove: Set[Account] = set()
        for owner in owners:
            for account in self.accounts:
                if account.address == owner:
                    if self.default_sender and self.default_sender.address == owner:
                        self.default_sender = None
                    accounts_to_remove.add(account)
                    break
        self.accounts = self.accounts.difference(accounts_to_remove)
        if accounts_to_remove:
            print_formatted_text(
                HTML("<ansigreen>Accounts have been deleted</ansigreen>"))
        else:
            print_formatted_text(
                HTML("<ansired>No account was deleted</ansired>"))

    def show_cli_owners(self):
        if not self.accounts:
            print_formatted_text(HTML("<ansired>No accounts loaded</ansired>"))
        else:
            for account in self.accounts:
                print_formatted_text(
                    HTML(
                        f"<ansigreen><b>Account</b> {account.address} loaded</ansigreen>"
                    ))
            if self.default_sender:
                print_formatted_text(
                    HTML(
                        f"<ansigreen><b>Default sender:</b> {self.default_sender.address}"
                        f"</ansigreen>"))
            else:
                print_formatted_text(
                    HTML("<ansigreen>Not default sender set </ansigreen>"))

    def approve_hash(self, hash_to_approve: HexBytes, sender: str) -> bool:
        sender_account = [
            account for account in self.accounts if account.address == sender
        ]
        if not sender_account:
            raise AccountNotLoadedException(sender)
        elif sender not in self.safe_cli_info.owners:
            raise NonExistingOwnerException(sender)
        elif self.safe.retrieve_is_hash_approved(self.default_sender.address,
                                                 hash_to_approve):
            raise HashAlreadyApproved(hash_to_approve,
                                      self.default_sender.address)
        else:
            sender_account = sender_account[0]
            transaction_to_send = self.safe_contract.functions.approveHash(
                hash_to_approve).buildTransaction({
                    "from":
                    sender_account.address,
                    "nonce":
                    self.ethereum_client.get_nonce_for_account(
                        sender_account.address),
                })
            if self.ethereum_client.is_eip1559_supported():
                transaction_to_send = self.ethereum_client.set_eip1559_fees(
                    transaction_to_send)
            call_result = self.ethereum_client.w3.eth.call(transaction_to_send)
            if call_result:  # There's revert message
                return False
            else:
                signed_transaction = sender_account.sign_transaction(
                    transaction_to_send)
                tx_hash = self.ethereum_client.send_raw_transaction(
                    signed_transaction["rawTransaction"])
                print_formatted_text(
                    HTML(
                        f"<ansigreen>Sent tx with tx-hash {tx_hash.hex()} from owner "
                        f"{self.default_sender.address}, waiting for receipt</ansigreen>"
                    ))
                if self.ethereum_client.get_transaction_receipt(tx_hash,
                                                                timeout=120):
                    return True
                else:
                    print_formatted_text(
                        HTML(
                            f"<ansired>Tx with tx-hash {tx_hash.hex()} still not mined</ansired>"
                        ))
                    return False

    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:
            raise ExistingOwnerException(new_owner)
        else:
            # TODO Allow to set threshold
            transaction = self.safe_contract.functions.addOwnerWithThreshold(
                new_owner, threshold).buildTransaction({
                    "from": self.address,
                    "gas": 0,
                    "gasPrice": 0
                })
            if self.execute_safe_internal_transaction(transaction["data"]):
                self.safe_cli_info.owners = self.safe.retrieve_owners()
                self.safe_cli_info.threshold = threshold
                return True
            return False

    def remove_owner(self,
                     owner_to_remove: str,
                     threshold: Optional[int] = None):
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
        if owner_to_remove not in self.safe_cli_info.owners:
            raise NonExistingOwnerException(owner_to_remove)
        elif len(self.safe_cli_info.owners) == threshold:
            raise ThresholdLimitException()
        else:
            index_owner = self.safe_cli_info.owners.index(owner_to_remove)
            prev_owner = (self.safe_cli_info.owners[index_owner - 1]
                          if index_owner else SENTINEL_ADDRESS)
            transaction = self.safe_contract.functions.removeOwner(
                prev_owner, owner_to_remove, threshold).buildTransaction({
                    "from":
                    self.address,
                    "gas":
                    0,
                    "gasPrice":
                    0
                })
            if self.execute_safe_internal_transaction(transaction["data"]):
                self.safe_cli_info.owners = self.safe.retrieve_owners()
                self.safe_cli_info.threshold = threshold
                return True
            return False

    def send_custom(
        self,
        to: str,
        value: int,
        data: bytes,
        safe_nonce: Optional[int] = None,
        delegate_call: bool = False,
    ) -> bool:
        if value > 0:
            safe_balance = self.ethereum_client.get_balance(self.address)
            if safe_balance < value:
                raise NotEnoughEtherToSend(safe_balance)
        operation = SafeOperation.DELEGATE_CALL if delegate_call else SafeOperation.CALL
        return self.prepare_and_execute_safe_transaction(to,
                                                         value,
                                                         data,
                                                         operation,
                                                         safe_nonce=safe_nonce)

    def send_ether(self, to: str, value: int, **kwargs) -> bool:
        return self.send_custom(to, value, b"", **kwargs)

    def send_erc20(self, to: str, token_address: str, amount: int,
                   **kwargs) -> bool:
        transaction = (get_erc20_contract(self.ethereum_client.w3,
                                          token_address).functions.transfer(
                                              to, amount).buildTransaction({
                                                  "from":
                                                  self.address,
                                                  "gas":
                                                  0,
                                                  "gasPrice":
                                                  0
                                              }))
        return self.send_custom(token_address, 0,
                                HexBytes(transaction["data"]), **kwargs)

    def send_erc721(self, to: str, token_address: str, token_id: int,
                    **kwargs) -> bool:
        transaction = (get_erc721_contract(
            self.ethereum_client.w3,
            token_address).functions.transferFrom(self.address, to,
                                                  token_id).buildTransaction({
                                                      "from":
                                                      self.address,
                                                      "gas":
                                                      0,
                                                      "gasPrice":
                                                      0
                                                  }))
        return self.send_custom(token_address, 0, transaction["data"],
                                **kwargs)

    def change_fallback_handler(self, new_fallback_handler: str) -> bool:
        if new_fallback_handler == self.safe_cli_info.fallback_handler:
            raise SameFallbackHandlerException(new_fallback_handler)
        elif semantic_version.parse(
                self.safe_cli_info.version) < semantic_version.parse("1.1.0"):
            raise FallbackHandlerNotSupportedException()
        elif (new_fallback_handler != NULL_ADDRESS
              and not self.ethereum_client.is_contract(new_fallback_handler)):
            raise InvalidFallbackHandlerException(
                f"{new_fallback_handler} address is not a contract")
        else:
            transaction = self.safe_contract.functions.setFallbackHandler(
                new_fallback_handler).buildTransaction({
                    "from": self.address,
                    "gas": 0,
                    "gasPrice": 0
                })
            if self.execute_safe_internal_transaction(transaction["data"]):
                self.safe_cli_info.fallback_handler = new_fallback_handler
                self.safe_cli_info.version = self.safe.retrieve_version()
                return True

    def change_guard(self, guard: str) -> bool:
        if guard == self.safe_cli_info.guard:
            raise SameGuardException(guard)
        elif semantic_version.parse(
                self.safe_cli_info.version) < semantic_version.parse("1.3.0"):
            raise GuardNotSupportedException()
        elif guard != NULL_ADDRESS and not self.ethereum_client.is_contract(
                guard):
            raise InvalidGuardException(f"{guard} address is not a contract")
        else:
            transaction = self.safe_contract.functions.setGuard(
                guard).buildTransaction({
                    "from": self.address,
                    "gas": 0,
                    "gasPrice": 0
                })
            if self.execute_safe_internal_transaction(transaction["data"]):
                self.safe_cli_info.guard = guard
                self.safe_cli_info.version = self.safe.retrieve_version()
                return True

    def change_master_copy(self, new_master_copy: str) -> bool:
        # TODO Check that master copy is valid
        if new_master_copy == self.safe_cli_info.master_copy:
            raise SameMasterCopyException(new_master_copy)
        else:
            try:
                Safe(new_master_copy, self.ethereum_client).retrieve_version()
            except BadFunctionCallOutput:
                raise InvalidMasterCopyException(new_master_copy)

            transaction = self.safe_contract_1_1_0.functions.changeMasterCopy(
                new_master_copy).buildTransaction({
                    "from": self.address,
                    "gas": 0,
                    "gasPrice": 0
                })
            if self.execute_safe_internal_transaction(transaction["data"]):
                self.safe_cli_info.master_copy = new_master_copy
                self.safe_cli_info.version = self.safe.retrieve_version()
                return True

    def update_version(self) -> Optional[bool]:
        """
        Update Safe Master Copy and Fallback handler to the last version
        :return:
        """
        if self.is_version_updated():
            raise SafeAlreadyUpdatedException()

        addresses = (LAST_SAFE_CONTRACT, LAST_DEFAULT_CALLBACK_HANDLER)
        if not all(
                self.ethereum_client.is_contract(contract)
                for contract in addresses):
            raise UpdateAddressesNotValid("Not valid addresses to update Safe",
                                          *addresses)

        multisend = MultiSend(LAST_MULTISEND_CONTRACT, self.ethereum_client)
        tx_params = {"from": self.address, "gas": 0, "gasPrice": 0}
        multisend_txs = [
            MultiSendTx(MultiSendOperation.CALL, self.address, 0, data)
            for data in (
                self.safe_contract_1_1_0.functions.changeMasterCopy(
                    LAST_SAFE_CONTRACT).buildTransaction(tx_params)["data"],
                self.safe_contract_1_1_0.functions.setFallbackHandler(
                    LAST_DEFAULT_CALLBACK_HANDLER).buildTransaction(tx_params)
                ["data"],
            )
        ]

        multisend_data = multisend.build_tx_data(multisend_txs)

        if self.prepare_and_execute_safe_transaction(
                multisend.address,
                0,
                multisend_data,
                operation=SafeOperation.DELEGATE_CALL):
            self.safe_cli_info.master_copy = LAST_SAFE_CONTRACT
            self.safe_cli_info.fallback_handler = LAST_DEFAULT_CALLBACK_HANDLER
            self.safe_cli_info.version = self.safe.retrieve_version()

    def change_threshold(self, threshold: int):
        if threshold == self.safe_cli_info.threshold:
            print_formatted_text(
                HTML(f"<ansired>Threshold is already {threshold}</ansired>"))
        elif threshold > len(self.safe_cli_info.owners):
            print_formatted_text(
                HTML(f"<ansired>Threshold={threshold} bigger than number "
                     f"of owners={len(self.safe_cli_info.owners)}</ansired>"))
        else:
            transaction = self.safe_contract.functions.changeThreshold(
                threshold).buildTransaction({
                    "from": self.address,
                    "gas": 0,
                    "gasPrice": 0
                })

            if self.execute_safe_internal_transaction(transaction["data"]):
                self.safe_cli_info.threshold = threshold

    def enable_module(self, module_address: str):
        if module_address in self.safe_cli_info.modules:
            print_formatted_text(
                HTML(
                    f"<ansired>Module {module_address} is already enabled</ansired>"
                ))
        else:
            transaction = self.safe_contract.functions.enableModule(
                module_address).buildTransaction({
                    "from": self.address,
                    "gas": 0,
                    "gasPrice": 0
                })
            if self.execute_safe_internal_transaction(transaction["data"]):
                self.safe_cli_info.modules = self.safe.retrieve_modules()

    def disable_module(self, module_address: str):
        if module_address not in self.safe_cli_info.modules:
            print_formatted_text(
                HTML(
                    f"<ansired>Module {module_address} is not enabled</ansired>"
                ))
        else:
            pos = self.safe_cli_info.modules.index(module_address)
            if pos == 0:
                previous_address = SENTINEL_ADDRESS
            else:
                previous_address = self.safe_cli_info.modules[pos - 1]
            transaction = self.safe_contract.functions.disableModule(
                previous_address, module_address).buildTransaction({
                    "from":
                    self.address,
                    "gas":
                    0,
                    "gasPrice":
                    0
                })
            if self.execute_safe_internal_transaction(transaction["data"]):
                self.safe_cli_info.modules = self.safe.retrieve_modules()

    def print_info(self):
        for key, value in dataclasses.asdict(self.safe_cli_info).items():
            print_formatted_text(
                HTML(f"<b><ansigreen>{key.capitalize()}</ansigreen></b>="
                     f"<ansiblue>{value}</ansiblue>"))
        if self.ens_domain:
            print_formatted_text(
                HTML(f"<b><ansigreen>Ens domain</ansigreen></b>="
                     f"<ansiblue>{self.ens_domain}</ansiblue>"))
        if self.safe_tx_service:
            url = f"{self.safe_tx_service.base_url}/api/v1/safes/{self.address}/transactions/"
            print_formatted_text(
                HTML(f"<b><ansigreen>Safe Tx Service</ansigreen></b>="
                     f"<ansiblue>{url}</ansiblue>"))

        if self.safe_relay_service:
            url = f"{self.safe_relay_service.base_url}/api/v1/safes/{self.address}/transactions/"
            print_formatted_text(
                HTML(f"<b><ansigreen>Safe Relay Service</ansigreen></b>="
                     f"<ansiblue>{url}</ansiblue>"))

        if self.etherscan:
            url = f"{self.etherscan.base_url}/address/{self.address}"
            print_formatted_text(
                HTML(f"<b><ansigreen>Etherscan</ansigreen></b>="
                     f"<ansiblue>{url}</ansiblue>"))

        if not self.is_version_updated():
            print_formatted_text(
                HTML(
                    "<ansired>Safe is not updated! You can use <b>update</b> command to update "
                    "the Safe to a newest version</ansired>"))

    def get_safe_cli_info(self) -> SafeCliInfo:
        safe = self.safe
        balance_ether = Web3.fromWei(
            self.ethereum_client.get_balance(self.address), "ether")
        safe_info = safe.retrieve_all_info()
        return SafeCliInfo(
            self.address,
            safe_info.nonce,
            safe_info.threshold,
            safe_info.owners,
            safe_info.master_copy,
            safe_info.modules,
            safe_info.fallback_handler,
            safe_info.guard,
            balance_ether,
            safe_info.version,
        )

    def get_threshold(self):
        print_formatted_text(self.safe.retrieve_threshold())

    def get_nonce(self):
        print_formatted_text(self.safe.retrieve_nonce())

    def get_owners(self):
        print_formatted_text(self.safe.retrieve_owners())

    def execute_safe_internal_transaction(self, data: bytes) -> bool:
        return self.prepare_and_execute_safe_transaction(self.address, 0, data)

    def prepare_safe_transaction(
        self,
        to: str,
        value: int,
        data: bytes,
        operation: SafeOperation = SafeOperation.CALL,
        safe_nonce: Optional[int] = None,
    ) -> SafeTx:
        safe_tx = self.safe.build_multisig_tx(to,
                                              value,
                                              data,
                                              operation=operation.value,
                                              safe_nonce=safe_nonce)
        self.sign_transaction(
            safe_tx)  # Raises exception if it cannot be signed
        return safe_tx

    def prepare_and_execute_safe_transaction(
        self,
        to: str,
        value: int,
        data: bytes,
        operation: SafeOperation = SafeOperation.CALL,
        safe_nonce: Optional[int] = None,
    ) -> bool:
        safe_tx = self.prepare_safe_transaction(to,
                                                value,
                                                data,
                                                operation,
                                                safe_nonce=safe_nonce)
        return self.execute_safe_transaction(safe_tx)

    @require_default_sender  # Throws Exception if default sender not found
    def execute_safe_transaction(self, safe_tx: SafeTx):
        try:
            call_result = safe_tx.call(self.default_sender.address)
            print_formatted_text(
                HTML(f"Result: <ansigreen>{call_result}</ansigreen>"))
            if yes_or_no_question("Do you want to execute tx " + str(safe_tx)):
                tx_hash, tx = safe_tx.execute(self.default_sender.key,
                                              eip1559_speed=TxSpeed.NORMAL)
                self.executed_transactions.append(tx_hash.hex())
                print_formatted_text(
                    HTML(
                        f"<ansigreen>Sent tx with tx-hash {tx_hash.hex()} "
                        f"and safe-nonce {safe_tx.safe_nonce}, waiting for receipt</ansigreen>"
                    ))
                tx_receipt = self.ethereum_client.get_transaction_receipt(
                    tx_hash, timeout=120)
                if tx_receipt:
                    fees = self.ethereum_client.w3.fromWei(
                        tx_receipt["gasUsed"] * tx_receipt.get(
                            "effectiveGasPrice", tx.get("gasPrice", 0)),
                        "ether",
                    )
                    print_formatted_text(
                        HTML(
                            f"<ansigreen>Tx was executed on block-number={tx_receipt['blockNumber']}, fees "
                            f"deducted={fees}</ansigreen>"))
                    self.safe_cli_info.nonce += 1
                    return True
                else:
                    print_formatted_text(
                        HTML(
                            f"<ansired>Tx with tx-hash {tx_hash.hex()} still not mined</ansired>"
                        ))
        except InvalidInternalTx as invalid_internal_tx:
            print_formatted_text(
                HTML(
                    f"Result: <ansired>InvalidTx - {invalid_internal_tx}</ansired>"
                ))
        return False

    # TODO Set sender so we can save gas in that signature
    def sign_transaction(self, safe_tx: SafeTx) -> SafeTx:
        permitted_signers = self.get_permitted_signers()
        threshold = self.safe_cli_info.threshold
        selected_accounts: 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)
                threshold -= 1
                if threshold == 0:
                    break

        if self.require_all_signatures and threshold > 0:
            raise NotEnoughSignatures(threshold)

        for selected_account in selected_accounts:
            safe_tx.sign(selected_account.key)

        return safe_tx

    @require_tx_service
    def _require_tx_service_mode(self):
        print_formatted_text(
            HTML(
                "<ansired>First enter tx-service mode using <b>tx-service</b> command</ansired>"
            ))

    def get_delegates(self):
        return self._require_tx_service_mode()

    def add_delegate(self, delegate_address: str, label: str,
                     signer_address: str):
        return self._require_tx_service_mode()

    def remove_delegate(self, delegate_address: str, signer_address: str):
        return self._require_tx_service_mode()

    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
        return self._require_tx_service_mode()

    def get_balances(self):
        return self._require_tx_service_mode()

    def get_transaction_history(self):
        return self._require_tx_service_mode()

    def batch_txs(self, safe_nonce: int,
                  safe_tx_hashes: Sequence[bytes]) -> bool:
        return self._require_tx_service_mode()

    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
        return self._require_tx_service_mode()

    def get_permitted_signers(self) -> Set[str]:
        return set(self.safe_cli_info.owners)

    def process_command(self, first_command: str,
                        rest_command: List[str]) -> bool:
        if first_command == "help":
            print_formatted_text("I still cannot help you")
        elif first_command == "refresh":
            print_formatted_text("Reloading Safe information")
            self.refresh_safe_cli_info()

        return False
Example #8
0
    def _send_multisig_tx(self,
                          safe_address: str,
                          to: str,
                          value: int,
                          data: bytes,
                          operation: int,
                          safe_tx_gas: int,
                          base_gas: int,
                          gas_price: int,
                          gas_token: str,
                          refund_receiver: str,
                          safe_nonce: int,
                          signatures: bytes,
                          tx_gas=None,
                          block_identifier='latest') -> Tuple[bytes, bytes, Dict[str, Any]]:
        """
        This function calls the `send_multisig_tx` of the Safe, but has some limitations to prevent abusing
        the relay
        :return: Tuple(tx_hash, safe_tx_hash, tx)
        :raises: InvalidMultisigTx: If user tx cannot go through the Safe
        """

        safe = Safe(safe_address, self.ethereum_client)
        data = data or b''
        gas_token = gas_token or NULL_ADDRESS
        refund_receiver = refund_receiver or NULL_ADDRESS
        to = to or NULL_ADDRESS

        # Make sure refund receiver is set to 0x0 so that the contract refunds the gas costs to tx.origin
        if not self._check_refund_receiver(refund_receiver):
            raise InvalidRefundReceiver(refund_receiver)

        self._check_safe_gas_price(gas_token, gas_price)

        # Make sure proxy contract is ours
        if not self.proxy_factory.check_proxy_code(safe_address):
            raise InvalidProxyContract(safe_address)

        # Make sure master copy is valid
        safe_master_copy_address = safe.retrieve_master_copy_address()
        if safe_master_copy_address not in self.safe_valid_contract_addresses:
            raise InvalidMasterCopyAddress(safe_master_copy_address)

        # Check enough funds to pay for the gas
        if not safe.check_funds_for_tx_gas(safe_tx_gas, base_gas, gas_price, gas_token):
            raise NotEnoughFundsForMultisigTx

        threshold = safe.retrieve_threshold()
        number_signatures = len(signatures) // 65  # One signature = 65 bytes
        if number_signatures < threshold:
            raise SignaturesNotFound('Need at least %d signatures' % threshold)

        safe_tx_gas_estimation = safe.estimate_tx_gas(to, value, data, operation)
        safe_base_gas_estimation = safe.estimate_tx_base_gas(to, value, data, operation, gas_token,
                                                             safe_tx_gas_estimation)
        if safe_tx_gas < safe_tx_gas_estimation or base_gas < safe_base_gas_estimation:
            raise InvalidGasEstimation("Gas should be at least equal to safe-tx-gas=%d and data-gas=%d. Current is "
                                       "safe-tx-gas=%d and data-gas=%d" %
                                       (safe_tx_gas_estimation, safe_base_gas_estimation, safe_tx_gas, base_gas))

        # Use user provided gasPrice for TX if more than our stardard gas price
        standard_gas = self._get_configured_gas_price()
        if gas_price > standard_gas :
            tx_gas_price = gas_price
        else:
            tx_gas_price = standard_gas

        tx_sender_private_key = self.tx_sender_account.privateKey
        tx_sender_address = Account.privateKeyToAccount(tx_sender_private_key).address

        safe_tx = safe.build_multisig_tx(
            to,
            value,
            data,
            operation,
            safe_tx_gas,
            base_gas,
            gas_price,
            gas_token,
            refund_receiver,
            signatures,
            safe_nonce=safe_nonce,
            safe_version=safe.retrieve_version()
        )

        if safe_tx.signers != safe_tx.sorted_signers:
            raise SignaturesNotSorted('Safe-tx-hash=%s - Signatures are not sorted by owner: %s' %
                                      (safe_tx.safe_tx_hash, safe_tx.signers))

        safe_tx.call(tx_sender_address=tx_sender_address, block_identifier=block_identifier)

        logger.info('_send_multisig_tx about to execute tx for safe=%s and nonce=%s', safe_address, safe_nonce)
        with EthereumNonceLock(self.redis, self.ethereum_client, self.tx_sender_account.address,
                               timeout=60 * 2) as tx_nonce:
            logger.info('_send_multisig_tx executing tx for safe=%s and nonce=%s', safe_address, safe_nonce)
            tx_hash, tx = safe_tx.execute(tx_sender_private_key, tx_gas=tx_gas, tx_gas_price=tx_gas_price,
                                          tx_nonce=tx_nonce, block_identifier=block_identifier)
            return tx_hash, safe_tx.tx_hash, tx
Example #9
0
    def _send_multisig_tx(self,
                          safe_address: str,
                          to: str,
                          value: int,
                          data: bytes,
                          operation: int,
                          safe_tx_gas: int,
                          base_gas: int,
                          gas_price: int,
                          gas_token: str,
                          refund_receiver: str,
                          safe_nonce: int,
                          signatures: bytes,
                          tx_gas: Optional[int] = None,
                          block_identifier='latest') -> Tuple[bytes, bytes, Dict[str, Any]]:
        """
        This function calls the `send_multisig_tx` of the Safe, but has some limitations to prevent abusing
        the relay
        :return: Tuple(tx_hash, safe_tx_hash, tx)
        :raises: InvalidMultisigTx: If user tx cannot go through the Safe
        """

        safe = Safe(safe_address, self.ethereum_client)
        data = data or b''
        gas_token = gas_token or NULL_ADDRESS
        refund_receiver = refund_receiver or NULL_ADDRESS
        to = to or NULL_ADDRESS

        # Make sure refund receiver is set to 0x0 so that the contract refunds the gas costs to tx.origin
        if not self._check_refund_receiver(refund_receiver):
            raise InvalidRefundReceiver(refund_receiver)

        self._check_safe_gas_price(gas_token, gas_price)

        # Make sure proxy contract is ours
        if not self.proxy_factory.check_proxy_code(safe_address):
            raise InvalidProxyContract(safe_address)

        # Make sure master copy is valid
        safe_master_copy_address = safe.retrieve_master_copy_address()
        if safe_master_copy_address not in self.safe_valid_contract_addresses:
            raise InvalidMasterCopyAddress(safe_master_copy_address)

        # Check enough funds to pay for the gas
        if not safe.check_funds_for_tx_gas(safe_tx_gas, base_gas, gas_price, gas_token):
            raise NotEnoughFundsForMultisigTx

        threshold = safe.retrieve_threshold()
        number_signatures = len(signatures) // 65  # One signature = 65 bytes
        if number_signatures < threshold:
            raise SignaturesNotFound('Need at least %d signatures' % threshold)

        safe_tx_gas_estimation = safe.estimate_tx_gas(to, value, data, operation)
        safe_base_gas_estimation = safe.estimate_tx_base_gas(to, value, data, operation, gas_token,
                                                             safe_tx_gas_estimation)
        if safe_tx_gas < safe_tx_gas_estimation or base_gas < safe_base_gas_estimation:
            raise InvalidGasEstimation("Gas should be at least equal to safe-tx-gas=%d and base-gas=%d. Current is "
                                       "safe-tx-gas=%d and base-gas=%d" %
                                       (safe_tx_gas_estimation, safe_base_gas_estimation, safe_tx_gas, base_gas))

        # We use fast tx gas price, if not txs could be stuck
        tx_gas_price = self._get_configured_gas_price()
        tx_sender_private_key = self.tx_sender_account.key
        tx_sender_address = Account.from_key(tx_sender_private_key).address

        safe_tx = safe.build_multisig_tx(
            to,
            value,
            data,
            operation,
            safe_tx_gas,
            base_gas,
            gas_price,
            gas_token,
            refund_receiver,
            signatures,
            safe_nonce=safe_nonce,
            safe_version=safe.retrieve_version()
        )

        if safe_tx.signers != safe_tx.sorted_signers:
            raise SignaturesNotSorted('Safe-tx-hash=%s - Signatures are not sorted by owner: %s' %
                                      (safe_tx.safe_tx_hash.hex(), safe_tx.signers))

        logger.info('Safe=%s safe-nonce=%d Check `call()` before sending transaction', safe_address, safe_nonce)
        # Set `gasLimit` for `call()`. It will use the same that it will be used later for execution
        tx_gas = safe_tx.base_gas + safe_tx.safe_tx_gas + 25000
        safe_tx.call(tx_gas=tx_gas, tx_sender_address=tx_sender_address, block_identifier=block_identifier)
        logger.info('Safe=%s safe-nonce=%d `call()` was successful', safe_address, safe_nonce)

        with EthereumNonceLock(self.redis, self.ethereum_client, self.tx_sender_account.address,
                               lock_timeout=60 * 2) as tx_nonce:
            tx_hash, tx = safe_tx.execute(tx_sender_private_key, tx_gas=tx_gas, tx_gas_price=tx_gas_price,
                                          tx_nonce=tx_nonce, block_identifier=block_identifier)
            logger.info('Safe=%s, Sent transaction with nonce=%d tx-hash=%s for safe-tx-hash=%s safe-nonce=%d',
                        safe_address, tx_nonce, tx_hash.hex(), safe_tx.safe_tx_hash.hex(), safe_tx.safe_nonce)
            return tx_hash, safe_tx.safe_tx_hash, tx