def estimate_tx(self, safe_address: str, to: str, value: int, data: bytes, operation: int, gas_token: Optional[str]) -> TransactionEstimationWithNonce: """ :return: TransactionEstimation with costs using the provided gas token and last used nonce of the Safe :raises: InvalidGasToken: If Gas Token is not valid """ if not self._is_valid_gas_token(gas_token): raise InvalidGasToken(gas_token) last_used_nonce = self.get_last_used_nonce(safe_address) safe = Safe(safe_address, self.ethereum_client) safe_tx_gas = safe.estimate_tx_gas(to, value, data, operation) safe_tx_base_gas = safe.estimate_tx_base_gas(to, value, data, operation, gas_token, safe_tx_gas) # For Safe contracts v1.0.0 operational gas is not used (`base_gas` has all the related costs already) safe_version = safe.retrieve_version() if Version(safe_version) >= Version('1.0.0'): safe_tx_operational_gas = 0 else: safe_tx_operational_gas = safe.estimate_tx_operational_gas(len(data) if data else 0) # Can throw RelayServiceException gas_price = self._estimate_tx_gas_price(self._get_configured_gas_price(), gas_token) return TransactionEstimationWithNonce(safe_tx_gas, safe_tx_base_gas, safe_tx_base_gas, safe_tx_operational_gas, gas_price, gas_token or NULL_ADDRESS, last_used_nonce, self.tx_sender_account.address)
def estimate_tx_for_all_tokens(self, safe_address: str, to: str, value: int, data: bytes, operation: int) -> TransactionEstimationWithNonceAndGasTokens: """ :return: TransactionEstimation with costs using ether and every gas token supported by the service, with the last used nonce of the Safe :raises: InvalidGasToken: If Gas Token is not valid """ safe = Safe(safe_address, self.ethereum_client) last_used_nonce = self.get_last_used_nonce(safe_address) safe_tx_gas = safe.estimate_tx_gas(to, value, data, operation) safe_version = safe.retrieve_version() if Version(safe_version) >= Version('1.0.0'): safe_tx_operational_gas = 0 else: safe_tx_operational_gas = safe.estimate_tx_operational_gas(len(data) if data else 0) # Calculate `base_gas` for ether and calculate for tokens using the ether token price ether_safe_tx_base_gas = safe.estimate_tx_base_gas(to, value, data, operation, NULL_ADDRESS, safe_tx_gas) base_gas_price = self._get_configured_gas_price() gas_price = self._estimate_tx_gas_price(base_gas_price, NULL_ADDRESS) gas_token_estimations = [TransactionGasTokenEstimation(ether_safe_tx_base_gas, gas_price, NULL_ADDRESS)] token_gas_difference = 50000 # 50K gas more expensive than ether for token in Token.objects.gas_tokens(): try: gas_price = self._estimate_tx_gas_price(base_gas_price, token.address) gas_token_estimations.append( TransactionGasTokenEstimation(ether_safe_tx_base_gas + token_gas_difference, gas_price, token.address) ) except CannotGetTokenPriceFromApi: logger.error('Cannot get price for token=%s', token.address) return TransactionEstimationWithNonceAndGasTokens(last_used_nonce, safe_tx_gas, safe_tx_operational_gas, gas_token_estimations)
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 estimate_tx_for_all_tokens(self, safe_address: str, to: str, value: int, data: str, operation: int) -> TransactionEstimationWithNonceAndGasTokens: last_used_nonce = SafeMultisigTx.objects.get_last_nonce_for_safe(safe_address) safe = Safe(safe_address, self.ethereum_client) safe_tx_gas = safe.estimate_tx_gas(to, value, data, operation) safe_version = safe.retrieve_version() if Version(safe_version) >= Version('1.0.0'): safe_tx_operational_gas = 0 else: safe_tx_operational_gas = safe.estimate_tx_operational_gas(len(data) if data else 0) # Calculate `base_gas` for ether and calculate for tokens using the ether token price ether_safe_tx_base_gas = safe.estimate_tx_base_gas(to, value, data, operation, NULL_ADDRESS, safe_tx_gas) gas_price = self._estimate_tx_gas_price(NULL_ADDRESS) gas_token_estimations = [TransactionGasTokenEstimation(ether_safe_tx_base_gas, gas_price, NULL_ADDRESS)] token_gas_difference = 50000 # 50K gas more expensive than ether for token in Token.objects.gas_tokens(): try: gas_price = self._estimate_tx_gas_price(token.address) gas_token_estimations.append( TransactionGasTokenEstimation(ether_safe_tx_base_gas + token_gas_difference, gas_price, token.address) ) except CannotGetTokenPriceFromApi: logger.error('Cannot get price for token=%s', token.address) return TransactionEstimationWithNonceAndGasTokens(last_used_nonce, safe_tx_gas, safe_tx_operational_gas, gas_token_estimations)
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)
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')
def validate(self, data): super().validate(data) ethereum_client = EthereumClientProvider() safe = Safe(data['safe'], ethereum_client) try: safe_version = safe.retrieve_version() except BadFunctionCallOutput as e: raise ValidationError( f'Could not get Safe version from blockchain, check contract exists on network ' f'{ethereum_client.get_network().name}') from e except IOError: raise ValidationError( 'Problem connecting to the ethereum node, please try again later' ) safe_tx = safe.build_multisig_tx(data['to'], data['value'], data['data'], data['operation'], data['safe_tx_gas'], data['base_gas'], data['gas_price'], data['gas_token'], data['refund_receiver'], safe_nonce=data['nonce'], safe_version=safe_version) contract_transaction_hash = safe_tx.safe_tx_hash # Check safe tx hash matches if contract_transaction_hash != data['contract_transaction_hash']: raise ValidationError( f'Contract-transaction-hash={contract_transaction_hash.hex()} ' f'does not match provided contract-tx-hash={data["contract_transaction_hash"].hex()}' ) # Check there's not duplicated tx with same `nonce` or same `safeTxHash` for the same Safe. # We allow duplicated if existing tx is not executed multisig_transactions = MultisigTransaction.objects.filter( safe=safe.address, nonce=data['nonce']).executed() if multisig_transactions: for multisig_transaction in multisig_transactions: if multisig_transaction.safe_tx_hash == contract_transaction_hash.hex( ): raise ValidationError( f'Tx with safe-tx-hash={contract_transaction_hash.hex()} ' f'for safe={safe.address} was already executed in ' f'tx-hash={multisig_transaction.ethereum_tx_id}') raise ValidationError( f'Tx with nonce={safe_tx.safe_nonce} for safe={safe.address} ' f'already executed in tx-hash={multisig_transactions[0].ethereum_tx_id}' ) # Check owners and pending owners try: safe_owners = safe.retrieve_owners(block_identifier='pending') except BadFunctionCallOutput: # Error using pending block identifier safe_owners = safe.retrieve_owners(block_identifier='latest') except IOError: raise ValidationError( 'Problem connecting to the ethereum node, please try again later' ) data['safe_owners'] = safe_owners delegates = SafeContractDelegate.objects.get_delegates_for_safe( safe.address) allowed_senders = safe_owners + delegates if not data['sender'] in allowed_senders: raise ValidationError( f'Sender={data["sender"]} is not an owner or delegate. ' f'Current owners={safe_owners}. Delegates={delegates}') signature_owners = [] # TODO Make signature mandatory signature = data.get('signature', b'') parsed_signatures = SafeSignature.parse_signature( signature, contract_transaction_hash) data['parsed_signatures'] = parsed_signatures # If there's at least one signature, transaction is trusted (until signatures are mandatory) data['trusted'] = bool(parsed_signatures) for safe_signature in parsed_signatures: owner = safe_signature.owner if not safe_signature.is_valid(ethereum_client, safe.address): raise ValidationError( f'Signature={safe_signature.signature.hex()} for owner={owner} is not valid' ) if owner in delegates and len(parsed_signatures) > 1: raise ValidationError( 'Just one signature is expected if using delegates') if owner not in allowed_senders: raise ValidationError( f'Signer={owner} is not an owner or delegate. ' f'Current owners={safe_owners}. Delegates={delegates}') if owner in signature_owners: raise ValidationError( f'Signature for owner={owner} is duplicated') signature_owners.append(owner) # TODO Make signature mandatory. len(signature_owners) must be >= 1 if signature_owners and data['sender'] not in signature_owners: raise ValidationError( f'Signature does not match sender={data["sender"]}. ' f'Calculated owners={signature_owners}') return data
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
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
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
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
def validate(self, data): super().validate(data) ethereum_client = EthereumClientProvider() safe = Safe(data["safe"], ethereum_client) try: safe_version = safe.retrieve_version() except BadFunctionCallOutput as e: raise ValidationError( f"Could not get Safe version from blockchain, check contract exists on network " f"{ethereum_client.get_network().name}") from e except IOError: raise ValidationError( "Problem connecting to the ethereum node, please try again later" ) safe_tx = safe.build_multisig_tx( data["to"], data["value"], data["data"], data["operation"], data["safe_tx_gas"], data["base_gas"], data["gas_price"], data["gas_token"], data["refund_receiver"], safe_nonce=data["nonce"], safe_version=safe_version, ) contract_transaction_hash = safe_tx.safe_tx_hash # Check safe tx hash matches if contract_transaction_hash != data["contract_transaction_hash"]: raise ValidationError( f"Contract-transaction-hash={contract_transaction_hash.hex()} " f'does not match provided contract-tx-hash={data["contract_transaction_hash"].hex()}' ) # Check there's not duplicated tx with same `nonce` or same `safeTxHash` for the same Safe. # We allow duplicated if existing tx is not executed multisig_transactions = MultisigTransaction.objects.filter( safe=safe.address, nonce=data["nonce"]).executed() if multisig_transactions: for multisig_transaction in multisig_transactions: if multisig_transaction.safe_tx_hash == contract_transaction_hash.hex( ): raise ValidationError( f"Tx with safe-tx-hash={contract_transaction_hash.hex()} " f"for safe={safe.address} was already executed in " f"tx-hash={multisig_transaction.ethereum_tx_id}") raise ValidationError( f"Tx with nonce={safe_tx.safe_nonce} for safe={safe.address} " f"already executed in tx-hash={multisig_transactions[0].ethereum_tx_id}" ) # Check owners and pending owners try: safe_owners = safe.retrieve_owners(block_identifier="pending") except BadFunctionCallOutput: # Error using pending block identifier safe_owners = safe.retrieve_owners(block_identifier="latest") except IOError: raise ValidationError( "Problem connecting to the ethereum node, please try again later" ) data["safe_owners"] = safe_owners delegates = SafeContractDelegate.objects.get_delegates_for_safe_and_owners( safe.address, safe_owners) allowed_senders = set(safe_owners) | delegates if not data["sender"] in allowed_senders: raise ValidationError( f'Sender={data["sender"]} is not an owner or delegate. ' f"Current owners={safe_owners}. Delegates={delegates}") signature_owners = [] # TODO Make signature mandatory signature = data.get("signature", b"") parsed_signatures = SafeSignature.parse_signature( signature, contract_transaction_hash) data["parsed_signatures"] = parsed_signatures # If there's at least one signature, transaction is trusted (until signatures are mandatory) data["trusted"] = bool(parsed_signatures) for safe_signature in parsed_signatures: owner = safe_signature.owner if not safe_signature.is_valid(ethereum_client, safe.address): raise ValidationError( f"Signature={safe_signature.signature.hex()} for owner={owner} is not valid" ) if owner in delegates and len(parsed_signatures) > 1: raise ValidationError( "Just one signature is expected if using delegates") if owner not in allowed_senders: raise ValidationError( f"Signer={owner} is not an owner or delegate. " f"Current owners={safe_owners}. Delegates={delegates}") if owner in signature_owners: raise ValidationError( f"Signature for owner={owner} is duplicated") signature_owners.append(owner) # TODO Make signature mandatory. len(signature_owners) must be >= 1 if signature_owners and data["sender"] not in signature_owners: raise ValidationError( f'Signature does not match sender={data["sender"]}. ' f"Calculated owners={signature_owners}") return data