• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

safe-global / safe-cli / 9464772673

11 Jun 2024 11:32AM UTC coverage: 87.16% (+6.5%) from 80.617%
9464772673

push

github

web-flow
Update project to use hatch (#404)

* Update project to use hatch

- Update pre-commit, as black previous version was having issues
- Use recommended structure: https://docs.pytest.org/en/stable/explanation/goodpractices.html
- Update CI
- Add run_tests.sh script

* Fix coverage

* Fix version

* Fix module export

* Fix linting

---------

Co-authored-by: Uxio Fuentefria <6909403+Uxio0@users.noreply.github.com>

722 of 835 branches covered (86.47%)

Branch coverage included in aggregate %.

3 of 3 new or added lines in 2 files covered. (100.0%)

2326 of 2662 relevant lines covered (87.38%)

3.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.28
/src/safe_cli/operators/safe_operator.py
1
import dataclasses
4✔
2
import json
4✔
3
import os
4✔
4
from functools import cached_property, wraps
4✔
5
from typing import List, Optional, Sequence, Set, Tuple
4✔
6

7
from ens import ENS
4✔
8
from eth_account import Account
4✔
9
from eth_account.signers.local import LocalAccount
4✔
10
from eth_typing import ChecksumAddress
4✔
11
from eth_utils import ValidationError
4✔
12
from hexbytes import HexBytes
4✔
13
from packaging import version as semantic_version
4✔
14
from prompt_toolkit import HTML, print_formatted_text
4✔
15
from web3 import Web3
4✔
16
from web3.contract import Contract
4✔
17
from web3.exceptions import BadFunctionCallOutput
4✔
18

19
from gnosis.eth import (
4✔
20
    EthereumClient,
21
    EthereumNetwork,
22
    EthereumNetworkNotSupported,
23
    TxSpeed,
24
)
25
from gnosis.eth.clients import EtherscanClient, EtherscanClientConfigurationProblem
4✔
26
from gnosis.eth.constants import NULL_ADDRESS, SENTINEL_ADDRESS
4✔
27
from gnosis.eth.contracts import (
4✔
28
    get_erc20_contract,
29
    get_erc721_contract,
30
    get_safe_V1_1_1_contract,
31
    get_sign_message_lib_contract,
32
)
33
from gnosis.eth.eip712 import eip712_encode
4✔
34
from gnosis.eth.utils import get_empty_tx_params
4✔
35
from gnosis.safe import InvalidInternalTx, Safe, SafeOperationEnum, SafeTx
4✔
36
from gnosis.safe.api import TransactionServiceApi
4✔
37
from gnosis.safe.multi_send import MultiSend, MultiSendOperation, MultiSendTx
4✔
38
from gnosis.safe.safe_deployments import safe_deployments
4✔
39

40
from safe_cli.ethereum_hd_wallet import get_account_from_words
4✔
41
from safe_cli.operators.exceptions import (
4✔
42
    AccountNotLoadedException,
43
    ExistingOwnerException,
44
    FallbackHandlerNotSupportedException,
45
    GuardNotSupportedException,
46
    HashAlreadyApproved,
47
    InvalidFallbackHandlerException,
48
    InvalidGuardException,
49
    InvalidMasterCopyException,
50
    InvalidMigrationContractException,
51
    InvalidNonceException,
52
    NonExistingOwnerException,
53
    NotEnoughEtherToSend,
54
    NotEnoughSignatures,
55
    SafeAlreadyUpdatedException,
56
    SafeVersionNotSupportedException,
57
    SameFallbackHandlerException,
58
    SameGuardException,
59
    SameMasterCopyException,
60
    SenderRequiredException,
61
    ThresholdLimitException,
62
    UpdateAddressesNotValid,
63
)
64
from safe_cli.safe_addresses import (
4✔
65
    get_default_fallback_handler_address,
66
    get_last_sign_message_lib_address,
67
    get_safe_contract_address,
68
    get_safe_l2_contract_address,
69
)
70
from safe_cli.utils import choose_option_from_list, get_erc_20_list, yes_or_no_question
4✔
71

72
from ..contracts import safe_to_l2_migration
4✔
73
from .hw_wallets.hw_wallet import HwWallet
4✔
74
from .hw_wallets.hw_wallet_manager import HwWalletType, get_hw_wallet_manager
4✔
75

76

77
@dataclasses.dataclass
4✔
78
class SafeCliInfo:
4✔
79
    address: str
4✔
80
    nonce: int
4✔
81
    threshold: int
4✔
82
    owners: List[str]
4✔
83
    master_copy: str
4✔
84
    modules: List[str]
4✔
85
    fallback_handler: str
4✔
86
    guard: str
4✔
87
    balance_ether: int
4✔
88
    version: str
4✔
89

90
    def __str__(self):
4✔
91
        return (
4✔
92
            f"safe-version={self.version} nonce={self.nonce} threshold={self.threshold} owners={self.owners} "
93
            f"master-copy={self.master_copy} fallback-hander={self.fallback_handler} "
94
            f"modules={self.modules} balance-ether={self.balance_ether:.4f}"
95
        )
96

97

98
def require_tx_service(f):
4✔
99
    @wraps(f)
4✔
100
    def decorated(self, *args, **kwargs):
4✔
101
        if not self.safe_tx_service:
×
102
            print_formatted_text(
×
103
                HTML(
104
                    f"<ansired>No tx service available for "
105
                    f"network={self.network.name}</ansired>"
106
                )
107
            )
108
            if self.etherscan:
×
109
                url = f"{self.etherscan.base_url}/address/{self.address}"
×
110
                print_formatted_text(HTML(f"<b>Try Etherscan instead</b> {url}"))
×
111
        else:
112
            return f(self, *args, **kwargs)
×
113

114
    return decorated
4✔
115

116

117
def require_default_sender(f):
4✔
118
    """
119
    Throws SenderRequiredException if not default sender configured
120
    """
121

122
    @wraps(f)
4✔
123
    def decorated(self, *args, **kwargs):
4✔
124
        if not self.default_sender and not self.hw_wallet_manager.sender:
4✔
125
            raise SenderRequiredException()
4✔
126
        else:
127
            return f(self, *args, **kwargs)
4✔
128

129
    return decorated
4✔
130

131

132
class SafeOperator:
4✔
133
    address: ChecksumAddress
4✔
134
    node_url: str
4✔
135
    ethereum_client: EthereumClient
4✔
136
    ens: ENS
4✔
137
    network: EthereumNetwork
4✔
138
    etherscan: Optional[EtherscanClient]
4✔
139
    safe_tx_service: Optional[TransactionServiceApi]
4✔
140
    safe: Safe
4✔
141
    safe_contract: Contract
4✔
142
    safe_contract_1_1_0: Contract
4✔
143
    accounts: Set[LocalAccount] = set()
4✔
144
    default_sender: Optional[LocalAccount]
4✔
145
    executed_transactions: List[str]
4✔
146
    _safe_cli_info: Optional[SafeCliInfo]
4✔
147
    require_all_signatures: bool
4✔
148

149
    def __init__(self, address: ChecksumAddress, node_url: str):
4✔
150
        self.address = address
4✔
151
        self.node_url = node_url
4✔
152
        self.ethereum_client = EthereumClient(self.node_url)
4✔
153
        self.ens = ENS.from_web3(self.ethereum_client.w3)
4✔
154
        self.network: EthereumNetwork = self.ethereum_client.get_network()
4✔
155
        try:
4✔
156
            self.etherscan = EtherscanClient(self.network)
4✔
157
        except EtherscanClientConfigurationProblem:
4✔
158
            self.etherscan = None
4✔
159

160
        try:
4✔
161
            self.safe_tx_service = TransactionServiceApi.from_ethereum_client(
4✔
162
                self.ethereum_client
163
            )
164
        except EthereumNetworkNotSupported:
4✔
165
            self.safe_tx_service = None
4✔
166

167
        self.safe = Safe(address, self.ethereum_client)
4✔
168
        self.safe_contract = self.safe.contract
4✔
169
        self.safe_contract_1_1_0 = get_safe_V1_1_1_contract(
4✔
170
            self.ethereum_client.w3, address=self.address
171
        )
172
        self.accounts: Set[LocalAccount] = set()
4✔
173
        self.default_sender: Optional[LocalAccount] = None
4✔
174
        self.executed_transactions: List[str] = []
4✔
175
        self._safe_cli_info: Optional[SafeCliInfo] = None  # Cache for SafeCliInfo
4✔
176
        self.require_all_signatures = (
4✔
177
            True  # Require all signatures to be present to send a tx
178
        )
179
        self.hw_wallet_manager = get_hw_wallet_manager()
4✔
180

181
    @cached_property
4✔
182
    def last_default_fallback_handler_address(self) -> ChecksumAddress:
4✔
183
        """
184
        :return: Address for last version of default fallback handler contract
185
        """
186
        return get_default_fallback_handler_address(self.ethereum_client)
×
187

188
    @cached_property
4✔
189
    def last_safe_contract_address(self) -> ChecksumAddress:
4✔
190
        """
191
        :return: Last version of the Safe Contract. Use events version for every network but mainnet
192
        """
193
        if self.network == EthereumNetwork.MAINNET:
×
194
            return get_safe_contract_address(self.ethereum_client)
×
195
        else:
196
            return get_safe_l2_contract_address(self.ethereum_client)
×
197

198
    @cached_property
4✔
199
    def ens_domain(self) -> Optional[str]:
4✔
200
        # FIXME After web3.py fixes the middleware copy
201
        if self.network == EthereumNetwork.MAINNET:
4✔
202
            return self.ens.name(self.address)
×
203

204
    @property
4✔
205
    def safe_cli_info(self) -> SafeCliInfo:
4✔
206
        if not self._safe_cli_info:
4✔
207
            self._safe_cli_info = self.refresh_safe_cli_info()
4✔
208
        return self._safe_cli_info
4✔
209

210
    def refresh_safe_cli_info(self) -> SafeCliInfo:
4✔
211
        self._safe_cli_info = self.get_safe_cli_info()
4✔
212
        return self._safe_cli_info
4✔
213

214
    def is_version_updated(self) -> bool:
4✔
215
        """
216
        :return: True if Safe Master Copy is updated, False otherwise
217
        """
218

219
        last_safe_contract_address = self.last_safe_contract_address
4✔
220
        if self.safe_cli_info.master_copy == last_safe_contract_address:
4✔
221
            return True
×
222
        else:  # Check versions, maybe safe-cli addresses were not updated
223
            try:
4✔
224
                safe_contract_version = Safe(
4✔
225
                    last_safe_contract_address, self.ethereum_client
226
                ).retrieve_version()
227
            except (
×
228
                BadFunctionCallOutput
229
            ):  # Safe master copy is not deployed or errored, maybe custom network
230
                return True  # We cannot say you are not updated ¯\_(ツ)_/¯
×
231

232
            return semantic_version.parse(
4✔
233
                self.safe_cli_info.version
234
            ) >= semantic_version.parse(safe_contract_version)
235

236
    def load_cli_owners_from_words(self, words: List[str]):
4✔
237
        if len(words) == 1:  # Reading seed from Environment Variable
×
238
            words = os.environ.get(words[0], default="").strip().split(" ")
×
239
        parsed_words = " ".join(words)
×
240
        try:
×
241
            for index in range(100):  # Try first accounts of seed phrase
×
242
                account = get_account_from_words(parsed_words, index=index)
×
243
                if account.address in self.safe_cli_info.owners:
×
244
                    self.load_cli_owners([account.key.hex()])
×
245
            if not index:
×
246
                print_formatted_text(
×
247
                    HTML(
248
                        "<ansired>Cannot generate any valid owner for this Safe</ansired>"
249
                    )
250
                )
251
        except ValidationError:
×
252
            print_formatted_text(
×
253
                HTML("<ansired>Cannot load owners from words</ansired>")
254
            )
255

256
    def load_cli_owners(self, keys: List[str]):
4✔
257
        for key in keys:
4✔
258
            try:
4✔
259
                account = Account.from_key(
4✔
260
                    os.environ.get(key, default=key)
261
                )  # Try to get key from `environ`
262
                self.accounts.add(account)
4✔
263
                balance = self.ethereum_client.get_balance(account.address)
4✔
264
                print_formatted_text(
4✔
265
                    HTML(
266
                        f"Loaded account <b>{account.address}</b> "
267
                        f'with balance={Web3.from_wei(balance, "ether")} ether'
268
                    )
269
                )
270
                if (
4✔
271
                    not self.default_sender
272
                    and not self.hw_wallet_manager.sender
273
                    and balance > 0
274
                ):
275
                    print_formatted_text(
4✔
276
                        HTML(
277
                            f"Set account <b>{account.address}</b> as default sender of txs"
278
                        )
279
                    )
280
                    self.default_sender = account
4✔
281
            except ValueError:
4✔
282
                print_formatted_text(HTML(f"<ansired>Cannot load key={key}</ansired>"))
4✔
283

284
    def load_hw_wallet(
4✔
285
        self,
286
        hw_wallet_type: HwWalletType,
287
        derivation_path: str,
288
        template_derivation_path: str,
289
    ):
290
        if not self.hw_wallet_manager.is_supported_hw_wallet(hw_wallet_type):
4✔
291
            return None
×
292
        if derivation_path is None:
4✔
293
            ledger_accounts = self.hw_wallet_manager.get_accounts(
4✔
294
                hw_wallet_type, template_derivation_path
295
            )
296
            if len(ledger_accounts) == 0:
4✔
297
                return None
4✔
298

299
            option = choose_option_from_list(
4✔
300
                "Select the owner address", ledger_accounts
301
            )
302
            if option is None:
4✔
303
                return None
×
304
            _, derivation_path = ledger_accounts[option]
4✔
305
        address = self.hw_wallet_manager.add_account(hw_wallet_type, derivation_path)
4✔
306
        balance = self.ethereum_client.get_balance(address)
4✔
307

308
        print_formatted_text(
4✔
309
            HTML(
310
                f"Loaded account <b>{address}</b> "
311
                f'with balance={Web3.from_wei(balance, "ether")} ether.'
312
            )
313
        )
314

315
        if (
4✔
316
            not self.default_sender
317
            and not self.hw_wallet_manager.sender
318
            and balance > 0
319
        ):
320
            self.hw_wallet_manager.set_sender(hw_wallet_type, derivation_path)
4✔
321
            print_formatted_text(HTML(f"HwDevice {address} added as sender"))
4✔
322
        else:
323
            print_formatted_text(HTML(f"HwDevice {address} wasn't added as sender"))
4✔
324

325
    def load_ledger_cli_owners(
4✔
326
        self, derivation_path: str = None, legacy_account: bool = False
327
    ):
328
        if legacy_account:
4✔
329
            self.load_hw_wallet(HwWalletType.LEDGER, derivation_path, "44'/60'/0'/{i}")
×
330
        else:
331
            self.load_hw_wallet(
4✔
332
                HwWalletType.LEDGER, derivation_path, "44'/60'/{i}'/0/0"
333
            )
334

335
    def load_trezor_cli_owners(
4✔
336
        self, derivation_path: str = None, legacy_account: bool = False
337
    ):
338
        if legacy_account:
×
339
            self.load_hw_wallet(HwWalletType.TREZOR, derivation_path, "44'/60'/0'/{i}")
×
340
        else:
341
            self.load_hw_wallet(
×
342
                HwWalletType.TREZOR, derivation_path, "44'/60'/0'/0/{i}"
343
            )
344

345
    def unload_cli_owners(self, owners: List[str]):
4✔
346
        accounts_to_remove: Set[Account] = set()
4✔
347
        for owner in owners:
4✔
348
            for account in self.accounts:
4✔
349
                if account.address == owner:
4✔
350
                    if self.default_sender and self.default_sender.address == owner:
4✔
351
                        self.default_sender = None
4✔
352
                    accounts_to_remove.add(account)
4✔
353
                    break
4✔
354
        self.accounts = self.accounts.difference(accounts_to_remove)
4✔
355
        # Check if there are ledger owners
356
        if self.hw_wallet_manager.wallets and len(accounts_to_remove) < len(owners):
4✔
357
            accounts_to_remove = (
4✔
358
                accounts_to_remove | self.hw_wallet_manager.delete_accounts(owners)
359
            )
360

361
        if accounts_to_remove:
4✔
362
            print_formatted_text(
4✔
363
                HTML("<ansigreen>Accounts have been deleted</ansigreen>")
364
            )
365
        else:
366
            print_formatted_text(HTML("<ansired>No account was deleted</ansired>"))
×
367

368
    def show_cli_owners(self):
4✔
369
        accounts = self.accounts | self.hw_wallet_manager.wallets
×
370
        if not accounts:
×
371
            print_formatted_text(HTML("<ansired>No accounts loaded</ansired>"))
×
372
        else:
373
            for account in accounts:
×
374
                print_formatted_text(
×
375
                    HTML(
376
                        f"<ansigreen><b>Account</b> {account.address} loaded</ansigreen>"
377
                    )
378
                )
379
            if self.default_sender:
×
380
                print_formatted_text(
×
381
                    HTML(
382
                        f"<ansigreen><b>Default sender:</b> {self.default_sender.address}"
383
                        f"</ansigreen>"
384
                    )
385
                )
386
            elif self.hw_wallet_manager.sender:
×
387
                print_formatted_text(
×
388
                    HTML(
389
                        f"<ansigreen><b>HwDevice sender:</b> {self.hw_wallet_manager.sender}"
390
                        f"</ansigreen>"
391
                    )
392
                )
393
            else:
394
                print_formatted_text(
×
395
                    HTML("<ansigreen>Not default sender set </ansigreen>")
396
                )
397

398
    def approve_hash(self, hash_to_approve: HexBytes, sender: str) -> bool:
4✔
399
        sender_account = [
4✔
400
            account for account in self.accounts if account.address == sender
401
        ]
402
        if not sender_account:
4✔
403
            raise AccountNotLoadedException(sender)
4✔
404
        elif sender not in self.safe_cli_info.owners:
4✔
405
            raise NonExistingOwnerException(sender)
4✔
406
        elif self.safe.retrieve_is_hash_approved(
4✔
407
            self.default_sender.address, hash_to_approve
408
        ):
409
            raise HashAlreadyApproved(hash_to_approve, self.default_sender.address)
4✔
410
        else:
411
            sender_account = sender_account[0]
4✔
412
            transaction_to_send = self.safe_contract.functions.approveHash(
4✔
413
                hash_to_approve
414
            ).build_transaction(
415
                {
416
                    "from": sender_account.address,
417
                    "nonce": self.ethereum_client.get_nonce_for_account(
418
                        sender_account.address
419
                    ),
420
                }
421
            )
422
            if self.ethereum_client.is_eip1559_supported():
4✔
423
                transaction_to_send = self.ethereum_client.set_eip1559_fees(
4✔
424
                    transaction_to_send
425
                )
426
            call_result = self.ethereum_client.w3.eth.call(transaction_to_send)
4✔
427
            if call_result:  # There's revert message
4✔
428
                return False
×
429
            else:
430
                signed_transaction = sender_account.sign_transaction(
4✔
431
                    transaction_to_send
432
                )
433
                tx_hash = self.ethereum_client.send_raw_transaction(
4✔
434
                    signed_transaction["rawTransaction"]
435
                )
436
                print_formatted_text(
4✔
437
                    HTML(
438
                        f"<ansigreen>Sent tx with tx-hash {tx_hash.hex()} from owner "
439
                        f"{self.default_sender.address}, waiting for receipt</ansigreen>"
440
                    )
441
                )
442
                if self.ethereum_client.get_transaction_receipt(tx_hash, timeout=120):
4✔
443
                    return True
4✔
444
                else:
445
                    print_formatted_text(
×
446
                        HTML(
447
                            f"<ansired>Tx with tx-hash {tx_hash.hex()} still not mined</ansired>"
448
                        )
449
                    )
450
                    return False
×
451

452
    def sign_message(
4✔
453
        self,
454
        eip191_message: Optional[str] = None,
455
        eip712_message_path: Optional[str] = None,
456
    ) -> bool:
457
        if eip712_message_path:
4✔
458
            try:
4✔
459
                message = json.load(open(eip712_message_path, "r"))
4✔
460
                message_bytes = b"".join(eip712_encode(message))
4✔
461
            except ValueError:
×
462
                raise ValueError
×
463
        else:
464
            message = eip191_message
4✔
465
            message_bytes = eip191_message.encode("UTF-8")
4✔
466

467
        safe_message_hash = self.safe.get_message_hash(message_bytes)
4✔
468

469
        sign_message_lib_address = get_last_sign_message_lib_address(
4✔
470
            self.ethereum_client
471
        )
472
        contract = get_sign_message_lib_contract(self.ethereum_client.w3, self.address)
4✔
473
        sign_message_data = HexBytes(
4✔
474
            contract.functions.signMessage(message_bytes).build_transaction(
475
                get_empty_tx_params(),
476
            )["data"]
477
        )
478
        print_formatted_text(HTML(f"Signing message: \n {message}"))
4✔
479
        if self.prepare_and_execute_safe_transaction(
4✔
480
            sign_message_lib_address,
481
            0,
482
            sign_message_data,
483
            operation=SafeOperationEnum.DELEGATE_CALL,
484
        ):
485
            print_formatted_text(
4✔
486
                HTML(f"Message was signed correctly: {safe_message_hash.hex()}")
487
            )
488

489
    def add_owner(self, new_owner: str, threshold: Optional[int] = None) -> bool:
4✔
490
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
491
        if new_owner in self.safe_cli_info.owners:
4✔
492
            raise ExistingOwnerException(new_owner)
4✔
493
        else:
494
            # TODO Allow to set threshold
495
            transaction = self.safe_contract.functions.addOwnerWithThreshold(
4✔
496
                new_owner, threshold
497
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
498
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
499
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
500
                self.safe_cli_info.threshold = threshold
4✔
501
                return True
4✔
502
            return False
×
503

504
    def remove_owner(self, owner_to_remove: str, threshold: Optional[int] = None):
4✔
505
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
506
        if owner_to_remove not in self.safe_cli_info.owners:
4✔
507
            raise NonExistingOwnerException(owner_to_remove)
4✔
508
        elif len(self.safe_cli_info.owners) == threshold:
4✔
509
            raise ThresholdLimitException()
×
510
        else:
511
            index_owner = self.safe_cli_info.owners.index(owner_to_remove)
4✔
512
            prev_owner = (
4✔
513
                self.safe_cli_info.owners[index_owner - 1]
514
                if index_owner
515
                else SENTINEL_ADDRESS
516
            )
517
            transaction = self.safe_contract.functions.removeOwner(
4✔
518
                prev_owner, owner_to_remove, threshold
519
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
520
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
521
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
522
                self.safe_cli_info.threshold = threshold
4✔
523
                return True
4✔
524
            return False
×
525

526
    def send_custom(
4✔
527
        self,
528
        to: str,
529
        value: int,
530
        data: bytes,
531
        safe_nonce: Optional[int] = None,
532
        delegate_call: bool = False,
533
    ) -> bool:
534
        if value > 0:
4✔
535
            safe_balance = self.ethereum_client.get_balance(self.address)
4✔
536
            if safe_balance < value:
4✔
537
                raise NotEnoughEtherToSend(safe_balance)
4✔
538
        operation = (
4✔
539
            SafeOperationEnum.DELEGATE_CALL if delegate_call else SafeOperationEnum.CALL
540
        )
541
        return self.prepare_and_execute_safe_transaction(
4✔
542
            to, value, data, operation, safe_nonce=safe_nonce
543
        )
544

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

548
    def send_erc20(self, to: str, token_address: str, amount: int, **kwargs) -> bool:
4✔
549
        transaction = (
×
550
            get_erc20_contract(self.ethereum_client.w3, token_address)
551
            .functions.transfer(to, amount)
552
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
553
        )
554
        return self.send_custom(
×
555
            token_address, 0, HexBytes(transaction["data"]), **kwargs
556
        )
557

558
    def send_erc721(self, to: str, token_address: str, token_id: int, **kwargs) -> bool:
4✔
559
        transaction = (
×
560
            get_erc721_contract(self.ethereum_client.w3, token_address)
561
            .functions.transferFrom(self.address, to, token_id)
562
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
563
        )
564
        return self.send_custom(token_address, 0, transaction["data"], **kwargs)
×
565

566
    def change_fallback_handler(self, new_fallback_handler: str) -> bool:
4✔
567
        if new_fallback_handler == self.safe_cli_info.fallback_handler:
4✔
568
            raise SameFallbackHandlerException(new_fallback_handler)
4✔
569
        elif semantic_version.parse(
4✔
570
            self.safe_cli_info.version
571
        ) < semantic_version.parse("1.1.0"):
572
            raise FallbackHandlerNotSupportedException()
4✔
573
        elif (
4✔
574
            new_fallback_handler != NULL_ADDRESS
575
            and not self.ethereum_client.is_contract(new_fallback_handler)
576
        ):
577
            raise InvalidFallbackHandlerException(
4✔
578
                f"{new_fallback_handler} address is not a contract"
579
            )
580
        else:
581
            transaction = self.safe_contract.functions.setFallbackHandler(
4✔
582
                new_fallback_handler
583
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
584
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
585
                self.safe_cli_info.fallback_handler = new_fallback_handler
4✔
586
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
587
                return True
4✔
588

589
    def change_guard(self, guard: str) -> bool:
4✔
590
        if guard == self.safe_cli_info.guard:
4✔
591
            raise SameGuardException(guard)
4✔
592
        elif semantic_version.parse(
4✔
593
            self.safe_cli_info.version
594
        ) < semantic_version.parse("1.3.0"):
595
            raise GuardNotSupportedException()
4✔
596
        elif guard != NULL_ADDRESS and not self.ethereum_client.is_contract(guard):
4✔
597
            raise InvalidGuardException(f"{guard} address is not a contract")
4✔
598
        else:
599
            transaction = self.safe_contract.functions.setGuard(
4✔
600
                guard
601
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
602
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
603
                self.safe_cli_info.guard = guard
4✔
604
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
605
                return True
4✔
606

607
    def change_master_copy(self, new_master_copy: str) -> bool:
4✔
608
        if new_master_copy == self.safe_cli_info.master_copy:
4✔
609
            raise SameMasterCopyException(new_master_copy)
4✔
610
        else:
611
            safe_version = self.safe.retrieve_version()
4✔
612
            if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
613
                raise SafeVersionNotSupportedException(
4✔
614
                    f"{safe_version} cannot be updated (yet)"
615
                )
616

617
            try:
4✔
618
                Safe(new_master_copy, self.ethereum_client).retrieve_version()
4✔
619
            except BadFunctionCallOutput:
4✔
620
                raise InvalidMasterCopyException(new_master_copy)
4✔
621

622
            transaction = self.safe_contract_1_1_0.functions.changeMasterCopy(
4✔
623
                new_master_copy
624
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
625
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
626
                self.safe_cli_info.master_copy = new_master_copy
4✔
627
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
628
                return True
4✔
629

630
    def update_version(self) -> Optional[bool]:
4✔
631
        """
632
        Update Safe Master Copy and Fallback handler to the last version
633

634
        :return:
635
        """
636

637
        safe_version = self.safe.retrieve_version()
4✔
638
        if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
639
            raise SafeVersionNotSupportedException(
4✔
640
                f"{safe_version} cannot be updated (yet)"
641
            )
642

643
        if self.is_version_updated():
4✔
644
            raise SafeAlreadyUpdatedException(f"{safe_version} already updated")
×
645

646
        addresses = (
4✔
647
            self.last_safe_contract_address,
648
            self.last_default_fallback_handler_address,
649
        )
650
        if not all(
4✔
651
            self.ethereum_client.is_contract(contract) for contract in addresses
652
        ):
653
            raise UpdateAddressesNotValid(
×
654
                "Not valid addresses to update Safe", *addresses
655
            )
656

657
        multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
658
        tx_params = {"from": self.address, "gas": 0, "gasPrice": 0}
4✔
659
        multisend_txs = [
4✔
660
            MultiSendTx(MultiSendOperation.CALL, self.address, 0, data)
661
            for data in (
662
                self.safe_contract_1_1_0.functions.changeMasterCopy(
663
                    self.last_safe_contract_address
664
                ).build_transaction(tx_params)["data"],
665
                self.safe_contract_1_1_0.functions.setFallbackHandler(
666
                    self.last_default_fallback_handler_address
667
                ).build_transaction(tx_params)["data"],
668
            )
669
        ]
670

671
        multisend_data = multisend.build_tx_data(multisend_txs)
4✔
672

673
        if self.prepare_and_execute_safe_transaction(
4✔
674
            multisend.address,
675
            0,
676
            multisend_data,
677
            operation=SafeOperationEnum.DELEGATE_CALL,
678
        ):
679
            self.safe_cli_info.master_copy = self.last_safe_contract_address
4✔
680
            self.safe_cli_info.fallback_handler = (
4✔
681
                self.last_default_fallback_handler_address
682
            )
683
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
684

685
    def update_version_to_l2(
4✔
686
        self, migration_contract_address: ChecksumAddress
687
    ) -> Optional[bool]:
688
        """
689
        Update not L2 Safe to L2, so official UI supports it. Useful when replaying Safes deployed in
690
        non L2 networks (like mainnet) in L2 networks.
691
        Only v1.1.1, v1.3.0 and v1.4.1 versions are supported. Also, Safe nonce must be 0.
692

693
        :return:
694
        """
695

696
        if not self.ethereum_client.is_contract(migration_contract_address):
4✔
697
            raise InvalidMigrationContractException(
×
698
                f"Non L2 to L2 migration contract {migration_contract_address} is not deployed"
699
            )
700

701
        safe_version = self.safe.retrieve_version()
4✔
702
        chain_id = self.ethereum_client.get_chain_id()
4✔
703

704
        if self.safe.retrieve_nonce() > 0:
4✔
705
            raise InvalidNonceException("Nonce must be 0 for non L2 to L2 migration")
×
706

707
        l2_migration_contract = self.ethereum_client.w3.eth.contract(
4✔
708
            NULL_ADDRESS, abi=safe_to_l2_migration["abi"]
709
        )
710
        if safe_version == "1.1.1":
4✔
711
            safe_l2_singleton = safe_deployments["1.3.0"]["GnosisSafeL2"][str(chain_id)]
4✔
712
            fallback_handler = safe_deployments["1.3.0"][
4✔
713
                "CompatibilityFallbackHandler"
714
            ][str(chain_id)]
715
            data = HexBytes(
4✔
716
                l2_migration_contract.functions.migrateFromV111(
717
                    safe_l2_singleton, fallback_handler
718
                ).build_transaction(get_empty_tx_params())["data"]
719
            )
720
        elif safe_version in ("1.3.0", "1.4.1"):
4✔
721
            safe_l2_singleton = safe_deployments[safe_version]["GnosisSafeL2"][
4✔
722
                str(chain_id)
723
            ]
724
            fallback_handler = self.safe_cli_info.fallback_handler
4✔
725
            data = HexBytes(
4✔
726
                l2_migration_contract.functions.migrateToL2(
727
                    safe_l2_singleton
728
                ).build_transaction(get_empty_tx_params())["data"]
729
            )
730
        else:
731
            raise InvalidMasterCopyException(
×
732
                "Current version is not supported to migrate to L2"
733
            )
734

735
        if self.prepare_and_execute_safe_transaction(
4✔
736
            migration_contract_address,
737
            0,
738
            data,
739
            operation=SafeOperationEnum.DELEGATE_CALL,
740
        ):
741
            self.safe_cli_info.master_copy = safe_l2_singleton
4✔
742
            self.safe_cli_info.fallback_handler = fallback_handler
4✔
743
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
744

745
    def change_threshold(self, threshold: int):
4✔
746
        if threshold == self.safe_cli_info.threshold:
4✔
747
            print_formatted_text(
×
748
                HTML(f"<ansired>Threshold is already {threshold}</ansired>")
749
            )
750
        elif threshold > len(self.safe_cli_info.owners):
4✔
751
            print_formatted_text(
×
752
                HTML(
753
                    f"<ansired>Threshold={threshold} bigger than number "
754
                    f"of owners={len(self.safe_cli_info.owners)}</ansired>"
755
                )
756
            )
757
        else:
758
            transaction = self.safe_contract.functions.changeThreshold(
4✔
759
                threshold
760
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
761

762
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
763
                self.safe_cli_info.threshold = threshold
4✔
764

765
    def enable_module(self, module_address: str):
4✔
766
        if module_address in self.safe_cli_info.modules:
×
767
            print_formatted_text(
×
768
                HTML(f"<ansired>Module {module_address} is already enabled</ansired>")
769
            )
770
        else:
771
            transaction = self.safe_contract.functions.enableModule(
×
772
                module_address
773
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
774
            if self.execute_safe_internal_transaction(transaction["data"]):
×
775
                self.safe_cli_info.modules = self.safe.retrieve_modules()
×
776

777
    def disable_module(self, module_address: str):
4✔
778
        if module_address not in self.safe_cli_info.modules:
×
779
            print_formatted_text(
×
780
                HTML(f"<ansired>Module {module_address} is not enabled</ansired>")
781
            )
782
        else:
783
            pos = self.safe_cli_info.modules.index(module_address)
×
784
            if pos == 0:
×
785
                previous_address = SENTINEL_ADDRESS
×
786
            else:
787
                previous_address = self.safe_cli_info.modules[pos - 1]
×
788
            transaction = self.safe_contract.functions.disableModule(
×
789
                previous_address, module_address
790
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
791
            if self.execute_safe_internal_transaction(transaction["data"]):
×
792
                self.safe_cli_info.modules = self.safe.retrieve_modules()
×
793

794
    def print_info(self):
4✔
795
        for key, value in dataclasses.asdict(self.safe_cli_info).items():
4✔
796
            print_formatted_text(
4✔
797
                HTML(
798
                    f"<b><ansigreen>{key.capitalize()}</ansigreen></b>="
799
                    f"<ansiblue>{value}</ansiblue>"
800
                )
801
            )
802
        if self.ens_domain:
4✔
803
            print_formatted_text(
×
804
                HTML(
805
                    f"<b><ansigreen>Ens domain</ansigreen></b>="
806
                    f"<ansiblue>{self.ens_domain}</ansiblue>"
807
                )
808
            )
809
        if self.safe_tx_service:
4✔
810
            url = f"{self.safe_tx_service.base_url}/api/v1/safes/{self.address}/transactions/"
4✔
811
            print_formatted_text(
4✔
812
                HTML(
813
                    f"<b><ansigreen>Safe Tx Service</ansigreen></b>="
814
                    f"<ansiblue>{url}</ansiblue>"
815
                )
816
            )
817

818
        if self.etherscan:
4✔
819
            url = f"{self.etherscan.base_url}/address/{self.address}"
4✔
820
            print_formatted_text(
4✔
821
                HTML(
822
                    f"<b><ansigreen>Etherscan</ansigreen></b>="
823
                    f"<ansiblue>{url}</ansiblue>"
824
                )
825
            )
826

827
        if not self.hw_wallet_manager.is_supported_hw_wallet(HwWalletType.LEDGER):
4✔
828
            print_formatted_text(
×
829
                HTML(
830
                    "<b><ansigreen>Ledger</ansigreen></b>="
831
                    "<ansired>Disabled </ansired> <b>Optional ledger library is not installed, run pip install safe-cli[ledger] </b>"
832
                )
833
            )
834
        else:
835
            print_formatted_text(
4✔
836
                HTML(
837
                    "<b><ansigreen>Ledger</ansigreen></b>="
838
                    "<ansiblue>supported</ansiblue>"
839
                )
840
            )
841

842
        if not self.hw_wallet_manager.is_supported_hw_wallet(HwWalletType.TREZOR):
4✔
843
            print_formatted_text(
×
844
                HTML(
845
                    "<b><ansigreen>Trezor</ansigreen></b>="
846
                    "<ansired>Disabled </ansired> <b>Optional trezor library is not installed, run pip install safe-cli[trezor] </b>"
847
                )
848
            )
849
        else:
850
            print_formatted_text(
4✔
851
                HTML(
852
                    "<b><ansigreen>Trezor</ansigreen></b>="
853
                    "<ansiblue>supported</ansiblue>"
854
                )
855
            )
856

857
        if not self.is_version_updated():
4✔
858
            print_formatted_text(
×
859
                HTML(
860
                    "<ansired>Safe is not updated! You can use <b>update</b> command to update "
861
                    "the Safe to a newest version</ansired>"
862
                )
863
            )
864

865
    def get_safe_cli_info(self) -> SafeCliInfo:
4✔
866
        safe = self.safe
4✔
867
        balance_ether = Web3.from_wei(
4✔
868
            self.ethereum_client.get_balance(self.address), "ether"
869
        )
870
        safe_info = safe.retrieve_all_info()
4✔
871
        return SafeCliInfo(
4✔
872
            self.address,
873
            safe_info.nonce,
874
            safe_info.threshold,
875
            safe_info.owners,
876
            safe_info.master_copy,
877
            safe_info.modules,
878
            safe_info.fallback_handler,
879
            safe_info.guard,
880
            balance_ether,
881
            safe_info.version,
882
        )
883

884
    def get_threshold(self):
4✔
885
        print_formatted_text(self.safe.retrieve_threshold())
×
886

887
    def get_nonce(self):
4✔
888
        print_formatted_text(self.safe.retrieve_nonce())
4✔
889

890
    def get_owners(self):
4✔
891
        print_formatted_text(self.safe.retrieve_owners())
×
892

893
    def execute_safe_internal_transaction(self, data: bytes) -> bool:
4✔
894
        return self.prepare_and_execute_safe_transaction(self.address, 0, data)
4✔
895

896
    def prepare_safe_transaction(
4✔
897
        self,
898
        to: str,
899
        value: int,
900
        data: bytes,
901
        operation: SafeOperationEnum = SafeOperationEnum.CALL,
902
        safe_nonce: Optional[int] = None,
903
    ) -> SafeTx:
904
        safe_tx = self.safe.build_multisig_tx(
4✔
905
            to, value, data, operation=operation.value, safe_nonce=safe_nonce
906
        )
907
        self.sign_transaction(safe_tx)  # Raises exception if it cannot be signed
4✔
908
        return safe_tx
4✔
909

910
    def prepare_and_execute_safe_transaction(
4✔
911
        self,
912
        to: str,
913
        value: int,
914
        data: bytes,
915
        operation: SafeOperationEnum = SafeOperationEnum.CALL,
916
        safe_nonce: Optional[int] = None,
917
    ) -> bool:
918
        safe_tx = self.prepare_safe_transaction(
4✔
919
            to, value, data, operation, safe_nonce=safe_nonce
920
        )
921
        return self.execute_safe_transaction(safe_tx)
4✔
922

923
    @require_default_sender  # Throws Exception if default sender not found
4✔
924
    def execute_safe_transaction(self, safe_tx: SafeTx):
4✔
925
        try:
4✔
926
            if self.default_sender:
4✔
927
                call_result = safe_tx.call(self.default_sender.address)
4✔
928
            else:
929
                call_result = safe_tx.call(self.hw_wallet_manager.sender.address)
×
930
            print_formatted_text(HTML(f"Result: <ansigreen>{call_result}</ansigreen>"))
4✔
931
            if yes_or_no_question("Do you want to execute tx " + str(safe_tx)):
4✔
932
                if self.default_sender:
4✔
933
                    tx_hash, tx = safe_tx.execute(
4✔
934
                        self.default_sender.key, eip1559_speed=TxSpeed.NORMAL
935
                    )
936
                else:
937
                    tx_hash, tx = self.hw_wallet_manager.execute_safe_tx(
×
938
                        safe_tx, eip1559_speed=TxSpeed.NORMAL
939
                    )
940
                self.executed_transactions.append(tx_hash.hex())
4✔
941
                print_formatted_text(
4✔
942
                    HTML(
943
                        f"<ansigreen>Sent tx with tx-hash {tx_hash.hex()} "
944
                        f"and safe-nonce {safe_tx.safe_nonce}, waiting for receipt</ansigreen>"
945
                    )
946
                )
947
                tx_receipt = self.ethereum_client.get_transaction_receipt(
4✔
948
                    tx_hash, timeout=120
949
                )
950
                if tx_receipt:
4✔
951
                    fees = self.ethereum_client.w3.from_wei(
4✔
952
                        tx_receipt["gasUsed"]
953
                        * tx_receipt.get("effectiveGasPrice", tx.get("gasPrice", 0)),
954
                        "ether",
955
                    )
956
                    print_formatted_text(
4✔
957
                        HTML(
958
                            f"<ansigreen>Tx was executed on block-number={tx_receipt['blockNumber']}, fees "
959
                            f"deducted={fees}</ansigreen>"
960
                        )
961
                    )
962
                    self.safe_cli_info.nonce += 1
4✔
963
                    return True
4✔
964
                else:
965
                    print_formatted_text(
×
966
                        HTML(
967
                            f"<ansired>Tx with tx-hash {tx_hash.hex()} still not mined</ansired>"
968
                        )
969
                    )
970
        except InvalidInternalTx as invalid_internal_tx:
×
971
            print_formatted_text(
×
972
                HTML(f"Result: <ansired>InvalidTx - {invalid_internal_tx}</ansired>")
973
            )
974
        return False
×
975

976
    # Batch_transactions multisend
977
    def batch_safe_txs(
4✔
978
        self, safe_nonce: int, safe_txs: Sequence[SafeTx]
979
    ) -> Optional[SafeTx]:
980
        """
981
        Submit signatures to the tx service. It's recommended to be on Safe v1.3.0 to prevent issues
982
        with `safeTxGas` and gas estimation.
983

984
        :return:
985
        """
986

987
        try:
4✔
988
            multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
989
        except ValueError:
×
990
            multisend = None
×
991
            print_formatted_text(
×
992
                HTML(
993
                    "<ansired>Multisend contract is not deployed on this network and it's required for "
994
                    "batching txs</ansired>"
995
                )
996
            )
997

998
        multisend_txs = []
4✔
999
        for safe_tx in safe_txs:
4✔
1000
            # Check if call is already a Multisend call
1001
            inner_txs = MultiSend.from_transaction_data(safe_tx.data)
4✔
1002
            if inner_txs:
4✔
1003
                multisend_txs.extend(inner_txs)
×
1004
            else:
1005
                multisend_txs.append(
4✔
1006
                    MultiSendTx(
1007
                        MultiSendOperation.CALL, safe_tx.to, safe_tx.value, safe_tx.data
1008
                    )
1009
                )
1010

1011
        if len(multisend_txs) == 1:
4✔
1012
            safe_tx.safe_tx_gas = 0
×
1013
            safe_tx.base_gas = 0
×
1014
            safe_tx.gas_price = 0
×
1015
            safe_tx.signatures = b""
×
1016
            safe_tx.safe_nonce = safe_nonce  # Resend single transaction
×
1017
        elif multisend:
4✔
1018
            safe_tx = SafeTx(
4✔
1019
                self.ethereum_client,
1020
                self.address,
1021
                multisend.address,
1022
                0,
1023
                multisend.build_tx_data(multisend_txs),
1024
                SafeOperationEnum.DELEGATE_CALL.value,
1025
                0,
1026
                0,
1027
                0,
1028
                None,
1029
                None,
1030
                safe_nonce=safe_nonce,
1031
            )
1032
        else:
1033
            # Multisend not defined
1034
            return None
×
1035

1036
        safe_tx = self.sign_transaction(safe_tx)
4✔
1037
        if not safe_tx.signatures:
4✔
1038
            print_formatted_text(
×
1039
                HTML("<ansired>At least one owner must be loaded</ansired>")
1040
            )
1041
            return None
×
1042
        else:
1043
            return safe_tx
4✔
1044

1045
    def get_signers(self) -> Tuple[List[LocalAccount], List[HwWallet]]:
4✔
1046
        """
1047

1048
        :return: Tuple with eoa signers and hw_wallet signers
1049
        """
1050
        permitted_signers = self.get_permitted_signers()
4✔
1051
        threshold = self.safe_cli_info.threshold
4✔
1052
        eoa_signers: List[Account] = (
4✔
1053
            []
1054
        )  # Some accounts that are not an owner can be loaded
1055
        for account in self.accounts:
4✔
1056
            if account.address in permitted_signers:
4✔
1057
                eoa_signers.append(account)
4✔
1058
                threshold -= 1
4✔
1059
                if threshold == 0:
4✔
1060
                    break
4✔
1061
        # If still pending required signatures continue with ledger owners
1062
        hw_wallet_signers = []
4✔
1063
        if threshold > 0 and self.hw_wallet_manager.wallets:
4✔
1064
            for hw_wallet in self.hw_wallet_manager.wallets:
×
1065
                if hw_wallet.address in permitted_signers:
×
1066
                    hw_wallet_signers.append(hw_wallet)
×
1067
                    threshold -= 1
×
1068
                    if threshold == 0:
×
1069
                        break
×
1070

1071
        if self.require_all_signatures and threshold > 0:
4✔
1072
            raise NotEnoughSignatures(threshold)
4✔
1073

1074
        return (eoa_signers, hw_wallet_signers)
4✔
1075

1076
    # TODO Set sender so we can save gas in that signature
1077
    def sign_transaction(self, safe_tx: SafeTx) -> SafeTx:
4✔
1078
        eoa_signers, hw_wallets_signers = self.get_signers()
4✔
1079
        for selected_account in eoa_signers:
4✔
1080
            safe_tx.sign(selected_account.key)
4✔
1081

1082
        # Sign with ledger
1083
        if len(hw_wallets_signers):
4✔
1084
            safe_tx = self.hw_wallet_manager.sign_safe_tx(safe_tx, hw_wallets_signers)
×
1085

1086
        return safe_tx
4✔
1087

1088
    @require_tx_service
4✔
1089
    def _require_tx_service_mode(self):
4✔
1090
        print_formatted_text(
×
1091
            HTML(
1092
                "<ansired>First enter tx-service mode using <b>tx-service</b> command</ansired>"
1093
            )
1094
        )
1095

1096
    def get_delegates(self):
4✔
1097
        return self._require_tx_service_mode()
×
1098

1099
    def add_delegate(self, delegate_address: str, label: str, signer_address: str):
4✔
1100
        return self._require_tx_service_mode()
×
1101

1102
    def remove_delegate(self, delegate_address: str, signer_address: str):
4✔
1103
        return self._require_tx_service_mode()
×
1104

1105
    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
4✔
1106
        return self._require_tx_service_mode()
×
1107

1108
    def get_balances(self):
4✔
1109
        return self._require_tx_service_mode()
×
1110

1111
    def get_transaction_history(self):
4✔
1112
        return self._require_tx_service_mode()
×
1113

1114
    def batch_txs(self, safe_nonce: int, safe_tx_hashes: Sequence[bytes]) -> bool:
4✔
1115
        return self._require_tx_service_mode()
×
1116

1117
    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
4✔
1118
        return self._require_tx_service_mode()
×
1119

1120
    def get_permitted_signers(self) -> Set[ChecksumAddress]:
4✔
1121
        """
1122
        :return: Accounts that can sign a transaction
1123
        """
1124
        return set(self.safe_cli_info.owners)
4✔
1125

1126
    def drain(self, to: str):
4✔
1127
        # Getting all events related with ERC20 transfers
1128
        last = self.ethereum_client.get_block("latest")["number"]
4✔
1129
        token_addresses = get_erc_20_list(self.ethereum_client, self.address, 1, last)
4✔
1130
        safe_txs = []
4✔
1131
        for token_address in token_addresses:
4✔
1132
            balance = self.ethereum_client.erc20.get_balance(
4✔
1133
                self.address, token_address
1134
            )
1135
            if balance > 0:
4✔
1136
                transaction = (
4✔
1137
                    get_erc20_contract(self.ethereum_client.w3, token_address)
1138
                    .functions.transfer(to, balance)
1139
                    .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
1140
                )
1141

1142
                safe_tx = self.prepare_safe_transaction(
4✔
1143
                    token_address,
1144
                    0,
1145
                    HexBytes(transaction["data"]),
1146
                    SafeOperationEnum.CALL,
1147
                    safe_nonce=None,
1148
                )
1149
                safe_txs.append(safe_tx)
4✔
1150

1151
        # Getting ethereum balance
1152
        balance_eth = self.ethereum_client.get_balance(self.address)
4✔
1153
        if balance_eth:
4✔
1154
            safe_tx = self.prepare_safe_transaction(
4✔
1155
                to,
1156
                balance_eth,
1157
                b"",
1158
                SafeOperationEnum.CALL,
1159
                safe_nonce=None,
1160
            )
1161
            safe_txs.append(safe_tx)
4✔
1162

1163
        if safe_txs:
4✔
1164
            multisend_tx = self.batch_safe_txs(self.get_nonce(), safe_txs)
4✔
1165
            if multisend_tx is not None:
4✔
1166
                if self.execute_safe_transaction(multisend_tx):
4✔
1167
                    print_formatted_text(
4✔
1168
                        HTML(
1169
                            "<ansigreen>Transaction to drain account correctly executed</ansigreen>"
1170
                        )
1171
                    )
1172
        else:
1173
            print_formatted_text(
×
1174
                HTML("<ansigreen>Safe account is currently empty</ansigreen>")
1175
            )
1176

1177
    def remove_proposed_transaction(self, safe_tx_hash: bytes):
4✔
1178
        return self._require_tx_service_mode()
×
1179

1180
    def process_command(self, first_command: str, rest_command: List[str]) -> bool:
4✔
1181
        if first_command == "help":
×
1182
            print_formatted_text("I still cannot help you")
×
1183
        elif first_command == "refresh":
×
1184
            print_formatted_text("Reloading Safe information")
×
1185
            self.refresh_safe_cli_info()
×
1186

1187
        return False
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc