def validate(self, data): super().validate(data) if not SafeContract.objects.filter(address=data['safe']).exists(): raise ValidationError(f"Safe={data['safe']} does not exist or it's still not indexed") ethereum_client = EthereumClientProvider() safe = Safe(data['safe'], ethereum_client) # 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') signature = data['signature'] delegate = data['delegate'] # Delegate address to be added # Tries to find a valid delegator using multiple strategies for operation_hash in (DelegateSignatureHelper.calculate_hash(delegate), DelegateSignatureHelper.calculate_hash(delegate, eth_sign=True), DelegateSignatureHelper.calculate_hash(delegate, previous_topt=True), DelegateSignatureHelper.calculate_hash(delegate, eth_sign=True, previous_topt=True)): delegator = self.check_signature(signature, operation_hash, safe_owners) if delegator: break if not delegator: raise ValidationError('Signing owner is not an owner of the Safe') data['delegator'] = delegator return data
def validate_signature(self, signature: bytes): safe_tx_hash = self.context['safe_tx_hash'] try: multisig_transaction = MultisigTransaction.objects.select_related( 'ethereum_tx' ).get(safe_tx_hash=safe_tx_hash) except MultisigTransaction.DoesNotExist: raise NotFound(f'Multisig transaction with safe-tx-hash={safe_tx_hash} was not found') safe_address = multisig_transaction.safe if multisig_transaction.executed: raise ValidationError(f'Transaction with safe-tx-hash={safe_tx_hash} was already executed') ethereum_client = EthereumClientProvider() safe = Safe(safe_address, ethereum_client) try: safe_owners = safe.retrieve_owners(block_identifier='pending') except BadFunctionCallOutput: # Error using pending block identifier safe_owners = safe.retrieve_owners(block_identifier='latest') parsed_signatures = SafeSignature.parse_signature(signature, safe_tx_hash) signature_owners = [] for safe_signature in parsed_signatures: owner = safe_signature.owner if owner not in safe_owners: raise ValidationError(f'Signer={owner} is not an owner. Current owners={safe_owners}') 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 signature_owners: raise ValidationError(f'Signature for owner={owner} is duplicated') signature_owners.append(owner) return signature
def get_safe_owners( self, ethereum_client: EthereumClient, safe_address: ChecksumAddress) -> List[ChecksumAddress]: safe = Safe(safe_address, ethereum_client) try: return safe.retrieve_owners(block_identifier="pending") except BadFunctionCallOutput: # Error using pending block identifier return safe.retrieve_owners(block_identifier="latest")
def test_safe_cli_happy_path(self): accounts = [self.ethereum_test_account, Account.create()] account_addresses = [account.address for account in accounts] safe_address = self.deploy_test_safe( owners=account_addresses, threshold=2, initial_funding_wei=self.w3.toWei(1, "ether"), ).safe_address safe = Safe(safe_address, self.ethereum_client) safe_operator = SafeOperator(safe_address, self.ethereum_node_url) prompt_parser = PromptParser(safe_operator) random_address = Account.create().address self.assertEqual(safe_operator.accounts, set()) prompt_parser.process_command( f"load_cli_owners {self.ethereum_test_account.key.hex()}") self.assertEqual(safe_operator.default_sender, self.ethereum_test_account) self.assertEqual(safe_operator.accounts, {self.ethereum_test_account}) prompt_parser.process_command( f"send_ether {random_address} 1") # No enough signatures self.assertEqual(self.ethereum_client.get_balance(random_address), 0) value = 123 prompt_parser.process_command( f"load_cli_owners {accounts[1].key.hex()}") prompt_parser.process_command(f"send_ether {random_address} {value}") self.assertEqual(self.ethereum_client.get_balance(random_address), value) # Change threshold self.assertEqual(safe_operator.safe_cli_info.threshold, 2) self.assertEqual(safe.retrieve_threshold(), 2) prompt_parser.process_command("change_threshold 1") self.assertEqual(safe_operator.safe_cli_info.threshold, 1) self.assertEqual(safe.retrieve_threshold(), 1) # Approve Hash safe_tx_hash = Web3.keccak(text="hola") self.assertFalse( safe_operator.safe.retrieve_is_hash_approved( accounts[0].address, safe_tx_hash)) prompt_parser.process_command( f"approve_hash {safe_tx_hash.hex()} {accounts[0].address}") self.assertTrue( safe_operator.safe.retrieve_is_hash_approved( accounts[0].address, safe_tx_hash)) # Remove owner self.assertEqual(len(safe_operator.safe_cli_info.owners), 2) self.assertEqual(len(safe.retrieve_owners()), 2) prompt_parser.process_command(f"remove_owner {accounts[1].address}") self.assertEqual(safe_operator.safe_cli_info.owners, [self.ethereum_test_account.address]) self.assertEqual(safe.retrieve_owners(), [self.ethereum_test_account.address])
def get_safe_owners(safe_address: str) -> Sequence[str]: ethereum_client = EthereumClientProvider() safe = Safe(safe_address, ethereum_client) try: return safe.retrieve_owners(block_identifier="pending") except BadFunctionCallOutput: # Error using pending block identifier try: return safe.retrieve_owners(block_identifier="latest") except BadFunctionCallOutput: return []
def validate(self, data): super().validate(data) if not SafeContract.objects.filter(address=data['safe']).exists(): raise ValidationError( f"Safe={data['safe']} does not exist or it's still not indexed" ) ethereum_client = EthereumClientProvider() safe = Safe(data['safe'], ethereum_client) # 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') signature = data['signature'] delegate = data['delegate'] operation_hash = DelegateSignatureHelper.calculate_hash(delegate) safe_signatures = SafeSignature.parse_signature( signature, operation_hash) if not safe_signatures: raise ValidationError('Cannot a valid signature') elif len(safe_signatures) > 1: raise ValidationError( 'More than one signatures detected, just one is expected') safe_signature = safe_signatures[0] delegator = safe_signature.owner if delegator not in safe_owners: if safe_signature.signature_type == SafeSignatureType.EOA: # Maybe it's an `eth_sign` signature without Gnosis Safe `v + 4`, let's try safe_signatures = SafeSignature.parse_signature( signature, DelegateSignatureHelper.calculate_hash(delegate, eth_sign=True)) safe_signature = safe_signatures[0] delegator = safe_signature.owner if delegator not in safe_owners: raise ValidationError( 'Signing owner is not an owner of the Safe') if not safe_signature.is_valid(): raise ValidationError( f'Signature of type={safe_signature.signature_type.name} for delegator={delegator} ' f'is not valid') data['delegator'] = delegator return data
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 test_deploy_proxy_contract_with_nonce(self): salt_nonce = generate_salt_nonce() owners = [Account.create().address for _ in range(2)] threshold = 2 payment_token = None safe_create2_tx = Safe.build_safe_create2_tx( self.ethereum_client, self.safe_contract_address, self.proxy_factory_contract_address, salt_nonce, owners, threshold, self.gas_price, payment_token) # Send ether for safe deploying costs self.send_tx( { 'to': safe_create2_tx.safe_address, 'value': safe_create2_tx.payment }, self.ethereum_test_account) proxy_factory = ProxyFactory(self.proxy_factory_contract_address, self.ethereum_client) ethereum_tx_sent = proxy_factory.deploy_proxy_contract_with_nonce( self.ethereum_test_account, safe_create2_tx.master_copy_address, safe_create2_tx.safe_setup_data, salt_nonce, safe_create2_tx.gas, gas_price=self.gas_price) receipt = self.ethereum_client.get_transaction_receipt( ethereum_tx_sent.tx_hash, timeout=20) self.assertEqual(receipt.status, 1) safe = Safe(ethereum_tx_sent.contract_address, self.ethereum_client) self.assertEqual(ethereum_tx_sent.contract_address, safe_create2_tx.safe_address) self.assertEqual(set(safe.retrieve_owners()), set(owners)) self.assertEqual(safe.retrieve_master_copy_address(), safe_create2_tx.master_copy_address)
def test_remove_owner(self): safe_address = self.deploy_test_safe( owners=[self.ethereum_test_account.address]).safe_address safe_operator = SafeOperator(safe_address, self.ethereum_node_url) random_address = Account.create().address with self.assertRaises(NonExistingOwnerException): safe_operator.remove_owner(random_address) safe_operator.load_cli_owners([self.ethereum_test_account.key.hex()]) new_owner = Account.create().address safe = Safe(safe_address, self.ethereum_client) self.assertTrue(safe_operator.add_owner(new_owner)) self.assertIn(new_owner, safe.retrieve_owners()) self.assertTrue(safe_operator.remove_owner(new_owner)) self.assertNotIn(new_owner, safe_operator.accounts) self.assertNotIn(new_owner, safe.retrieve_owners())
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_safe_creation(self): salt_nonce = generate_salt_nonce() owners = [Account.create().address for _ in range(2)] data = { 'saltNonce': salt_nonce, 'owners': owners, 'threshold': len(owners) } response = self.client.post(reverse('v3:safe-creation'), data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) response_json = response.json() safe_address = response_json['safe'] self.assertTrue(check_checksum(safe_address)) self.assertTrue(check_checksum(response_json['paymentReceiver'])) self.assertEqual(response_json['paymentToken'], NULL_ADDRESS) self.assertEqual(int(response_json['payment']), int(response_json['gasEstimated']) * int(response_json['gasPriceEstimated'])) self.assertGreater(int(response_json['gasEstimated']), 0) self.assertGreater(int(response_json['gasPriceEstimated']), 0) self.assertGreater(len(response_json['setupData']), 2) self.assertEqual(response_json['masterCopy'], settings.SAFE_CONTRACT_ADDRESS) self.assertTrue(SafeContract.objects.filter(address=safe_address)) self.assertTrue(SafeCreation2.objects.filter(owners__contains=[owners[0]])) safe_creation = SafeCreation2.objects.get(safe=safe_address) self.assertEqual(safe_creation.payment_token, None) # Payment includes deployment gas + gas to send eth to the deployer self.assertEqual(safe_creation.payment, safe_creation.wei_estimated_deploy_cost()) # Deploy the Safe to check it self.send_ether(safe_address, int(response_json['payment'])) safe_creation2 = SafeCreationServiceProvider().deploy_create2_safe_tx(safe_address) self.ethereum_client.get_transaction_receipt(safe_creation2.tx_hash, timeout=20) safe = Safe(safe_address, self.ethereum_client) self.assertEqual(safe.retrieve_master_copy_address(), response_json['masterCopy']) self.assertEqual(safe.retrieve_owners(), owners) # Test exception when same Safe is created response = self.client.post(reverse('v3:safe-creation'), data, format='json') self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) self.assertIn('SafeAlreadyExistsException', response.json()['exception']) data = { 'salt_nonce': -1, 'owners': owners, 'threshold': 2 } response = self.client.post(reverse('v3:safe-creation'), data, format='json') self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
def test_safe_cli_happy_path(self): accounts = [self.ethereum_test_account, Account.create()] account_addresses = [account.address for account in accounts] safe_address = self.deploy_test_safe(owners=account_addresses, threshold=2, initial_funding_wei=self.w3.toWei(1, 'ether')).safe_address safe = Safe(safe_address, self.ethereum_client) safe_operator = SafeOperator(safe_address, self.ethereum_node_url) prompt_parser = PromptParser(safe_operator) random_address = Account.create().address self.assertEqual(safe_operator.accounts, set()) prompt_parser.process_command(f'load_cli_owners {self.ethereum_test_account.key.hex()}') self.assertEqual(safe_operator.default_sender, self.ethereum_test_account) self.assertEqual(safe_operator.accounts, {self.ethereum_test_account}) prompt_parser.process_command(f'send_ether {random_address} 1') # No enough signatures self.assertEqual(self.ethereum_client.get_balance(random_address), 0) value = 123 prompt_parser.process_command(f'load_cli_owners {accounts[1].key.hex()}') prompt_parser.process_command(f'send_ether {random_address} {value}') self.assertEqual(self.ethereum_client.get_balance(random_address), value) # Change threshold self.assertEqual(safe_operator.safe_cli_info.threshold, 2) self.assertEqual(safe.retrieve_threshold(), 2) prompt_parser.process_command('change_threshold 1') self.assertEqual(safe_operator.safe_cli_info.threshold, 1) self.assertEqual(safe.retrieve_threshold(), 1) # Remove owner self.assertEqual(len(safe_operator.safe_cli_info.owners), 2) self.assertEqual(len(safe.retrieve_owners()), 2) prompt_parser.process_command(f'remove_owner {accounts[1].address}') self.assertEqual(safe_operator.safe_cli_info.owners, [self.ethereum_test_account.address]) self.assertEqual(safe.retrieve_owners(), [self.ethereum_test_account.address])
def test_add_owner(self): safe_address = self.deploy_test_safe( owners=[self.ethereum_test_account.address]).safe_address safe_operator = SafeOperator(safe_address, self.ethereum_node_url) with self.assertRaises(ExistingOwnerException): safe_operator.add_owner(self.ethereum_test_account.address) new_owner = Account.create().address with self.assertRaises(SenderRequiredException): safe_operator.add_owner(new_owner) safe_operator.default_sender = self.ethereum_test_account with self.assertRaises(NotEnoughSignatures): safe_operator.add_owner(new_owner) safe_operator.accounts.add(self.ethereum_test_account) safe = Safe(safe_address, self.ethereum_client) self.assertTrue(safe_operator.add_owner(new_owner)) self.assertIn(self.ethereum_test_account, safe_operator.accounts) self.assertIn(new_owner, safe.retrieve_owners())
def test_deploy_proxy_contract(self): s = 15 owners = [Account.create().address for _ in range(2)] threshold = 2 payment_token = None safe_creation_tx = Safe.build_safe_creation_tx( self.ethereum_client, self.safe_contract_V0_0_1_address, s, owners, threshold, self.gas_price, payment_token, payment_receiver=self.ethereum_test_account.address, ) # Send ether for safe deploying costs self.send_tx( {"to": safe_creation_tx.safe_address, "value": safe_creation_tx.payment}, self.ethereum_test_account, ) proxy_factory = ProxyFactory( self.proxy_factory_contract_address, self.ethereum_client ) ethereum_tx_sent = proxy_factory.deploy_proxy_contract( self.ethereum_test_account, safe_creation_tx.master_copy, safe_creation_tx.safe_setup_data, safe_creation_tx.gas, gas_price=self.gas_price, ) receipt = self.ethereum_client.get_transaction_receipt( ethereum_tx_sent.tx_hash, timeout=20 ) self.assertEqual(receipt.status, 1) safe = Safe(ethereum_tx_sent.contract_address, self.ethereum_client) self.assertEqual( safe.retrieve_master_copy_address(), safe_creation_tx.master_copy ) self.assertEqual(set(safe.retrieve_owners()), set(owners))
def test_setup_operator(self): for number_owners in range(1, 4): safe_operator = self.setup_operator(number_owners=number_owners) self.assertEqual(len(safe_operator.accounts), number_owners) safe = Safe(safe_operator.address, self.ethereum_client) self.assertEqual(len(safe.retrieve_owners()), number_owners)
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 validate(self, data): super().validate(data) ethereum_client = EthereumClientProvider() safe = Safe(data['safe'], ethereum_client) 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']) 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') 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 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( f'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
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_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