def resend(self, gas_price: int, multisig_tx: SafeMultisigTx) -> Optional[EthereumTx]: """ Resend transaction with `gas_price` if it's higher or equal than transaction gas price. Setting equal `gas_price` is allowed as sometimes a transaction can be out of the mempool but `gas_price` does not need to be increased when resending :param gas_price: New gas price for the transaction. Must be >= old gas price :param multisig_tx: Multisig Tx not mined to be sent again :return: If a new transaction is sent is returned, `None` if not """ if multisig_tx.ethereum_tx.gas_price > gas_price: logger.info( '%s tx gas price is %d > %d. Nothing to do here', multisig_tx.ethereum_tx_id, multisig_tx.ethereum_tx.gas_price, gas_price ) return None assert multisig_tx.ethereum_tx.block_id is None, 'Block is present!' safe = Safe(multisig_tx.safe_id, self.ethereum_client) try: if safe.retrieve_nonce() > multisig_tx.nonce: multisig_tx.delete() # Transaction is not valid anymore return None except (ValueError, BadFunctionCallOutput): logger.error('Something is wrong with Safe %s, cannot retrieve nonce', multisig_tx.safe_id, exc_info=True) return None logger.info( '%s tx gas price was %d. Resending with new gas price %d', multisig_tx.ethereum_tx_id, multisig_tx.ethereum_tx.gas_price, gas_price ) safe_tx = multisig_tx.get_safe_tx(self.ethereum_client) tx_gas = safe_tx.recommended_gas() try: tx_hash, tx = safe_tx.execute(self.tx_sender_account.key, tx_gas=tx_gas, tx_gas_price=gas_price, tx_nonce=multisig_tx.ethereum_tx.nonce) except ValueError: # ValueError({'code': -32010, 'message': 'Transaction nonce is too low. Try incrementing the nonce.'}) try: # Check that transaction is still valid safe_tx.call(tx_sender_address=self.tx_sender_account.address, tx_gas=tx_gas) except InvalidMultisigTx: # Maybe there's a transaction with a lower nonce that must be mined before # It doesn't matter, as soon as a transaction with a newer nonce is added it will be deleted return None # Send transaction again with a new 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(self.tx_sender_account.key, tx_gas=tx_gas, tx_gas_price=gas_price, tx_nonce=tx_nonce) multisig_tx.ethereum_tx = EthereumTx.objects.create_from_tx_dict(tx, tx_hash) multisig_tx.full_clean(validate_unique=False) multisig_tx.save(update_fields=['ethereum_tx']) return multisig_tx.ethereum_tx
def get_last_used_nonce(self, safe_address: str) -> Optional[int]: safe = Safe(safe_address, self.ethereum_client) last_used_nonce = SafeMultisigTx.objects.get_last_nonce_for_safe(safe_address) last_used_nonce = last_used_nonce if last_used_nonce is not None else -1 try: blockchain_nonce = safe.retrieve_nonce() last_used_nonce = max(last_used_nonce, blockchain_nonce - 1) if last_used_nonce < 0: # There's no last_used_nonce last_used_nonce = None return last_used_nonce except BadFunctionCallOutput: # If Safe does not exist raise SafeDoesNotExist(f'Safe={safe_address} does not exist')
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 test_create_multisig_tx(self): w3 = self.w3 # The balance we will send to the safe safe_balance = w3.toWei(0.02, 'ether') # Create Safe funder_account = self.ethereum_test_account funder = funder_account.address accounts = [self.create_account(), self.create_account()] # Signatures must be sorted! accounts.sort(key=lambda account: account.address.lower()) owners = [x.address for x in accounts] threshold = len(accounts) safe_creation = self.deploy_test_safe(owners=owners, threshold=threshold) my_safe_address = safe_creation.safe_address my_safe_contract = get_safe_contract(w3, my_safe_address) SafeContractFactory(address=my_safe_address) to = funder value = safe_balance // 4 data = HexBytes('') operation = 0 safe_tx_gas = 100000 data_gas = 300000 gas_price = self.transaction_service._get_minimum_gas_price() gas_token = NULL_ADDRESS refund_receiver = NULL_ADDRESS safe = Safe(my_safe_address, self.ethereum_client) nonce = safe.retrieve_nonce() safe_tx = safe.build_multisig_tx(to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, safe_nonce=nonce).safe_tx_hash # Just to make sure we are not miscalculating tx_hash contract_multisig_tx_hash = my_safe_contract.functions.getTransactionHash( to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce).call() self.assertEqual(safe_tx, contract_multisig_tx_hash) signatures = [account.signHash(safe_tx) for account in accounts] # Check owners are the same contract_owners = my_safe_contract.functions.getOwners().call() self.assertEqual(set(contract_owners), set(owners)) invalid_proxy = self.deploy_example_erc20(1, Account.create().address) with self.assertRaises(InvalidProxyContract): SafeContractFactory(address=invalid_proxy.address) self.transaction_service.create_multisig_tx( invalid_proxy.address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, signatures, ) # Use invalid master copy random_master_copy = Account.create().address proxy_create_tx = get_paying_proxy_contract(self.w3).constructor( random_master_copy, b'', NULL_ADDRESS, NULL_ADDRESS, 0).buildTransaction({'from': self.ethereum_test_account.address}) tx_hash = self.ethereum_client.send_unsigned_transaction( proxy_create_tx, private_key=self.ethereum_test_account.key) tx_receipt = self.ethereum_client.get_transaction_receipt(tx_hash, timeout=60) proxy_address = tx_receipt.contractAddress with self.assertRaises(InvalidMasterCopyAddress): SafeContractFactory(address=proxy_address) self.transaction_service.create_multisig_tx( proxy_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, signatures, ) with self.assertRaises(NotEnoughFundsForMultisigTx): self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, signatures, ) # Send something to the safe self.send_tx({ 'to': my_safe_address, 'value': safe_balance }, funder_account) bad_refund_receiver = get_eth_address_with_key()[0] with self.assertRaises(InvalidRefundReceiver): self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, bad_refund_receiver, nonce, signatures, ) invalid_gas_price = 0 with self.assertRaises(RefundMustBeEnabled): self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, invalid_gas_price, gas_token, refund_receiver, nonce, signatures, ) with self.assertRaises(GasPriceTooLow): self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, self.transaction_service._estimate_tx_gas_price( self.transaction_service._get_minimum_gas_price(), gas_token) - 1, gas_token, refund_receiver, nonce, signatures) with self.assertRaises(InvalidGasToken): invalid_gas_token = Account.create().address self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, invalid_gas_token, refund_receiver, nonce, reversed(signatures)) with self.assertRaises(SignaturesNotSorted): self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, reversed(signatures)) with self.assertRaises(SignerIsBanned): for account in accounts: BannedSignerFactory(address=account.address) self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, signatures) BannedSigner.objects.all().delete() self.assertEqual(BannedSigner.objects.count(), 0) sender = self.transaction_service.tx_sender_account.address sender_balance = w3.eth.getBalance(sender) safe_balance = w3.eth.getBalance(my_safe_address) safe_multisig_tx = self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, signatures, ) with self.assertRaises(SafeMultisigTxExists): self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, signatures, ) tx_receipt = w3.eth.waitForTransactionReceipt( safe_multisig_tx.ethereum_tx.tx_hash) self.assertTrue(tx_receipt['status']) self.assertEqual(w3.toChecksumAddress(tx_receipt['from']), sender) self.assertEqual(w3.toChecksumAddress(tx_receipt['to']), my_safe_address) self.assertGreater(safe_multisig_tx.ethereum_tx.gas_price, gas_price) # We used minimum gas price sender_new_balance = w3.eth.getBalance(sender) gas_used = tx_receipt['gasUsed'] tx_fees = gas_used * safe_multisig_tx.ethereum_tx.gas_price estimated_refund = ( safe_multisig_tx.data_gas + safe_multisig_tx.safe_tx_gas) * safe_multisig_tx.gas_price real_refund = safe_balance - w3.eth.getBalance(my_safe_address) - value # Real refund can be less if not all the `safe_tx_gas` is used self.assertGreaterEqual(estimated_refund, real_refund) self.assertEqual(sender_new_balance, sender_balance - tx_fees + real_refund) self.assertEqual(safe.retrieve_nonce(), 1) # Send again the tx and check that works nonce += 1 value = 0 safe_tx = safe.build_multisig_tx(to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, safe_nonce=nonce) # Use invalid signatures with self.assertRaises(InvalidOwners): signatures = [ Account.create().signHash(safe_tx.safe_tx_hash) for _ in range(len(accounts)) ] self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, signatures, ) signatures = [ account.signHash(safe_tx.safe_tx_hash) for account in accounts ] safe_multisig_tx = self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, signatures, ) tx_receipt = w3.eth.waitForTransactionReceipt( safe_multisig_tx.ethereum_tx.tx_hash) self.assertTrue(tx_receipt['status'])
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
def resend( self, gas_price: int, multisig_tx: SafeMultisigTx ) -> Optional[EthereumTx]: """ Resend transaction with `gas_price` if it's higher or equal than transaction gas price. Setting equal `gas_price` is allowed as sometimes a transaction can be out of the mempool but `gas_price` does not need to be increased when resending :param gas_price: New gas price for the transaction. Must be >= old gas price :param multisig_tx: Multisig Tx not mined to be sent again :return: If a new transaction is sent is returned, `None` if not """ assert multisig_tx.ethereum_tx.block_id is None, "Block is present!" transaction_receipt = self.ethereum_client.get_transaction_receipt( multisig_tx.ethereum_tx_id ) if transaction_receipt and transaction_receipt["blockNumber"]: logger.info( "%s tx was already mined on block %d", multisig_tx.ethereum_tx_id, transaction_receipt["blockNumber"], ) return None # Check that transaction is still valid safe_tx = multisig_tx.get_safe_tx(self.ethereum_client) tx_gas = safe_tx.recommended_gas() try: safe_tx.call( tx_sender_address=self.tx_sender_account.address, tx_gas=tx_gas ) except InvalidMultisigTx: # Maybe there's a transaction with a lower nonce that must be mined before # It doesn't matter, as soon as a transaction with a newer nonce is added it will be deleted return None if multisig_tx.ethereum_tx.gas_price >= gas_price: logger.info( "%s tx gas price is %d >= current gas price %d. Tx should be mined soon", multisig_tx.ethereum_tx_id, multisig_tx.ethereum_tx.gas_price, gas_price, ) # Maybe tx was deleted from mempool, resend it tx_params = multisig_tx.ethereum_tx.as_tx_dict() raw_transaction = self.tx_sender_account.sign_transaction(tx_params)[ "rawTransaction" ] try: self.ethereum_client.send_raw_transaction(raw_transaction) except ValueError: logger.warning("Error resending transaction", exc_info=True) return None safe = Safe(multisig_tx.safe_id, self.ethereum_client) try: safe_nonce = safe.retrieve_nonce() if safe_nonce > multisig_tx.nonce: logger.info( "%s tx safe nonce is %d and current safe nonce is %d. Transaction is not valid anymore. Deleting", multisig_tx.ethereum_tx_id, multisig_tx.nonce, safe_nonce, ) multisig_tx.delete() # Transaction is not valid anymore return None except (ValueError, BadFunctionCallOutput): logger.error( "Something is wrong with Safe %s, cannot retrieve nonce", multisig_tx.safe_id, exc_info=True, ) return None logger.info( "%s tx gas price was %d. Resending with new gas price %d", multisig_tx.ethereum_tx_id, multisig_tx.ethereum_tx.gas_price, gas_price, ) try: tx_hash, tx = safe_tx.execute( self.tx_sender_account.key, tx_gas=tx_gas, tx_gas_price=gas_price, tx_nonce=multisig_tx.ethereum_tx.nonce, eip1559_speed=TxSpeed.NORMAL, ) logger.info( "Tx with old tx-hash %s was resent with a new tx-hash %s", multisig_tx.ethereum_tx_id, tx_hash.hex(), ) except InvalidNonce: # Send transaction again with a new 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( self.tx_sender_account.key, tx_gas=tx_gas, tx_gas_price=gas_price, tx_nonce=tx_nonce, eip1559_speed=TxSpeed.NORMAL, ) logger.error( "Nonce problem, sending transaction for Safe %s with a new nonce %d and tx-hash %s", multisig_tx.safe_id, tx_nonce, tx_hash.hex(), exc_info=True, ) except ValueError: logger.error("Problem resending transaction", exc_info=True) return None multisig_tx.ethereum_tx = EthereumTx.objects.create_from_tx_dict(tx, tx_hash) multisig_tx.full_clean(validate_unique=False) multisig_tx.save(update_fields=["ethereum_tx"]) return multisig_tx.ethereum_tx
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 test_resend_txs(self): # Nothing happens call_command(resend_txs.Command()) w3 = self.w3 # The balance we will send to the safe safe_balance = w3.toWei(0.02, 'ether') # Create Safe accounts = [self.create_account(), self.create_account()] # Signatures must be sorted! accounts.sort(key=lambda account: account.address.lower()) safe_creation = self.deploy_test_safe( owners=[x.address for x in accounts], threshold=len(accounts), initial_funding_wei=safe_balance) my_safe_address = safe_creation.safe_address to = Account().create().address value = safe_balance // 4 data = HexBytes('') operation = 0 safe_tx_gas = 100000 data_gas = 300000 gas_price = self.transaction_service._get_minimum_gas_price() gas_token = NULL_ADDRESS refund_receiver = NULL_ADDRESS safe = Safe(my_safe_address, self.ethereum_client) nonce = safe.retrieve_nonce() safe_multisig_tx_hash = safe.build_multisig_tx( to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, safe_nonce=nonce).safe_tx_hash signatures = [ account.signHash(safe_multisig_tx_hash) for account in accounts ] sender = self.transaction_service.tx_sender_account.address # Ganache snapshot snapshot_id = w3.testing.snapshot() safe_multisig_tx = self.transaction_service.create_multisig_tx( my_safe_address, to, value, data, operation, safe_tx_gas, data_gas, gas_price, gas_token, refund_receiver, nonce, signatures, ) tx_receipt = w3.eth.waitForTransactionReceipt( safe_multisig_tx.ethereum_tx.tx_hash) self.assertTrue(tx_receipt['status']) self.assertEqual(w3.toChecksumAddress(tx_receipt['from']), sender) self.assertEqual(w3.toChecksumAddress(tx_receipt['to']), my_safe_address) self.assertEqual(w3.eth.getBalance(to), value) w3.testing.revert(snapshot_id) # Revert to snapshot in ganache self.assertEqual(w3.eth.getBalance(to), 0) old_multisig_tx: SafeMultisigTx = SafeMultisigTx.objects.all().first() old_multisig_tx.created = timezone.now() - timedelta(days=1) old_multisig_tx.save() new_gas_price = old_multisig_tx.ethereum_tx.gas_price + 1 call_command(resend_txs.Command(), gas_price=new_gas_price) multisig_tx: SafeMultisigTx = SafeMultisigTx.objects.all().first() self.assertNotEqual(multisig_tx.ethereum_tx_id, old_multisig_tx.ethereum_tx_id) self.assertEqual(multisig_tx.ethereum_tx.gas_price, new_gas_price) self.assertEqual(w3.eth.getBalance(to), value) # Tx is executed again self.assertEqual(multisig_tx.get_safe_tx().__dict__, old_multisig_tx.get_safe_tx().__dict__)