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

safe-global / safe-cli / 12011247560

25 Nov 2024 01:38PM CUT coverage: 88.612%. Remained the same
12011247560

Pull #469

github

web-flow
Merge d6e230fe3 into 0659e6cdb
Pull Request #469: Bump typer from 0.13.0 to 0.13.1

221 of 262 branches covered (84.35%)

Branch coverage included in aggregate %.

2868 of 3224 relevant lines covered (88.96%)

2.67 hits per line

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

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

7
from ens import ENS
3✔
8
from eth_account import Account
3✔
9
from eth_account.signers.local import LocalAccount
3✔
10
from eth_typing import ChecksumAddress
3✔
11
from eth_utils import ValidationError
3✔
12
from hexbytes import HexBytes
3✔
13
from packaging import version as semantic_version
3✔
14
from prompt_toolkit import HTML, print_formatted_text
3✔
15
from safe_eth.eth import (
3✔
16
    EthereumClient,
17
    EthereumNetwork,
18
    EthereumNetworkNotSupported,
19
    TxSpeed,
20
)
21
from safe_eth.eth.clients import EtherscanClient, EtherscanClientConfigurationProblem
3✔
22
from safe_eth.eth.constants import NULL_ADDRESS, SENTINEL_ADDRESS
3✔
23
from safe_eth.eth.contracts import (
3✔
24
    get_erc20_contract,
25
    get_erc721_contract,
26
    get_safe_V1_1_1_contract,
27
    get_sign_message_lib_contract,
28
)
29
from safe_eth.eth.eip712 import eip712_encode
3✔
30
from safe_eth.eth.utils import get_empty_tx_params
3✔
31
from safe_eth.safe import InvalidInternalTx, Safe, SafeOperationEnum, SafeTx
3✔
32
from safe_eth.safe.api import TransactionServiceApi
3✔
33
from safe_eth.safe.multi_send import MultiSend, MultiSendOperation, MultiSendTx
3✔
34
from safe_eth.safe.safe_deployments import safe_deployments
3✔
35
from web3 import Web3
3✔
36
from web3.contract import Contract
3✔
37
from web3.exceptions import BadFunctionCallOutput
3✔
38

39
from safe_cli.ethereum_hd_wallet import get_account_from_words
3✔
40
from safe_cli.operators.exceptions import (
3✔
41
    AccountNotLoadedException,
42
    ExistingOwnerException,
43
    FallbackHandlerNotSupportedException,
44
    GuardNotSupportedException,
45
    HashAlreadyApproved,
46
    InvalidFallbackHandlerException,
47
    InvalidGuardException,
48
    InvalidMasterCopyException,
49
    InvalidMigrationContractException,
50
    InvalidNonceException,
51
    NonExistingOwnerException,
52
    NotEnoughEtherToSend,
53
    NotEnoughSignatures,
54
    SafeAlreadyUpdatedException,
55
    SafeOperatorException,
56
    SafeVersionNotSupportedException,
57
    SameFallbackHandlerException,
58
    SameGuardException,
59
    SameMasterCopyException,
60
    SenderRequiredException,
61
    ThresholdLimitException,
62
    UpdateAddressesNotValid,
63
)
64
from safe_cli.safe_addresses import (
3✔
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 (
3✔
71
    choose_option_from_list,
72
    get_erc_20_list,
73
    get_input,
74
    yes_or_no_question,
75
)
76

77
from ..contracts import safe_to_l2_migration
3✔
78
from .hw_wallets.hw_wallet import HwWallet
3✔
79
from .hw_wallets.hw_wallet_manager import HwWalletType, get_hw_wallet_manager
3✔
80

81

82
@dataclasses.dataclass
3✔
83
class SafeCliInfo:
3✔
84
    address: str
3✔
85
    nonce: int
3✔
86
    threshold: int
3✔
87
    owners: List[str]
3✔
88
    master_copy: str
3✔
89
    modules: List[str]
3✔
90
    fallback_handler: str
3✔
91
    guard: str
3✔
92
    balance_ether: int
3✔
93
    version: str
3✔
94

95
    def __str__(self):
3✔
96
        return (
×
97
            f"safe-version={self.version} nonce={self.nonce} threshold={self.threshold} owners={self.owners} "
98
            f"master-copy={self.master_copy} fallback-hander={self.fallback_handler} "
99
            f"modules={self.modules} balance-ether={self.balance_ether:.4f}"
100
        )
101

102

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

119
    return decorated
3✔
120

121

122
def require_default_sender(f):
3✔
123
    """
124
    Throws SenderRequiredException if not default sender configured
125
    """
126

127
    @wraps(f)
3✔
128
    def decorated(self, *args, **kwargs):
3✔
129
        if not self.default_sender and not self.hw_wallet_manager.sender:
3✔
130
            raise SenderRequiredException()
3✔
131
        else:
132
            return f(self, *args, **kwargs)
3✔
133

134
    return decorated
3✔
135

136

137
class SafeOperator:
3✔
138
    address: ChecksumAddress
3✔
139
    node_url: str
3✔
140
    ethereum_client: EthereumClient
3✔
141
    ens: ENS
3✔
142
    network: EthereumNetwork
3✔
143
    etherscan: Optional[EtherscanClient]
3✔
144
    safe_tx_service: Optional[TransactionServiceApi]
3✔
145
    safe: Safe
3✔
146
    safe_contract: Contract
3✔
147
    safe_contract_1_1_0: Contract
3✔
148
    accounts: Set[LocalAccount] = set()
3✔
149
    default_sender: Optional[LocalAccount]
3✔
150
    executed_transactions: List[str]
3✔
151
    _safe_cli_info: Optional[SafeCliInfo]
3✔
152
    require_all_signatures: bool
3✔
153
    interactive: bool
3✔
154

155
    def __init__(
3✔
156
        self, address: ChecksumAddress, node_url: str, interactive: bool = True
157
    ):
158
        self.address = address
3✔
159
        self.node_url = node_url
3✔
160
        self.ethereum_client = EthereumClient(self.node_url)
3✔
161
        self.ens = ENS.from_web3(self.ethereum_client.w3)
3✔
162
        self.network: EthereumNetwork = self.ethereum_client.get_network()
3✔
163
        try:
3✔
164
            self.etherscan = EtherscanClient(self.network)
3✔
165
        except EtherscanClientConfigurationProblem:
3✔
166
            self.etherscan = None
3✔
167

168
        try:
3✔
169
            self.safe_tx_service = TransactionServiceApi.from_ethereum_client(
3✔
170
                self.ethereum_client
171
            )
172
        except EthereumNetworkNotSupported:
3✔
173
            self.safe_tx_service = None
3✔
174

175
        self.safe = Safe(address, self.ethereum_client)
3✔
176
        self.safe_contract = self.safe.contract
3✔
177
        self.safe_contract_1_1_0 = get_safe_V1_1_1_contract(
3✔
178
            self.ethereum_client.w3, address=self.address
179
        )
180
        self.accounts: Set[LocalAccount] = set()
3✔
181
        self.default_sender: Optional[LocalAccount] = None
3✔
182
        self.executed_transactions: List[str] = []
3✔
183
        self._safe_cli_info: Optional[SafeCliInfo] = None  # Cache for SafeCliInfo
3✔
184
        self.require_all_signatures = (
3✔
185
            True  # Require all signatures to be present to send a tx
186
        )
187
        self.hw_wallet_manager = get_hw_wallet_manager()
3✔
188
        self.interactive = interactive  # Disable prompt dialogs
3✔
189

190
    @cached_property
3✔
191
    def last_default_fallback_handler_address(self) -> ChecksumAddress:
3✔
192
        """
193
        :return: Address for last version of default fallback handler contract
194
        """
195
        return get_default_fallback_handler_address(self.ethereum_client)
×
196

197
    @cached_property
3✔
198
    def last_safe_contract_address(self) -> ChecksumAddress:
3✔
199
        """
200
        :return: Last version of the Safe Contract. Use events version for every network but mainnet
201
        """
202
        if self.network == EthereumNetwork.MAINNET:
3✔
203
            return get_safe_contract_address(self.ethereum_client)
×
204
        else:
205
            return get_safe_l2_contract_address(self.ethereum_client)
3✔
206

207
    @cached_property
3✔
208
    def ens_domain(self) -> Optional[str]:
3✔
209
        # FIXME After web3.py fixes the middleware copy
210
        if self.network == EthereumNetwork.MAINNET:
3✔
211
            return self.ens.name(self.address)
×
212

213
    @property
3✔
214
    def safe_cli_info(self) -> SafeCliInfo:
3✔
215
        if not self._safe_cli_info:
3✔
216
            self._safe_cli_info = self.refresh_safe_cli_info()
3✔
217
        return self._safe_cli_info
3✔
218

219
    def refresh_safe_cli_info(self) -> SafeCliInfo:
3✔
220
        self._safe_cli_info = self.get_safe_cli_info()
3✔
221
        return self._safe_cli_info
3✔
222

223
    def is_version_updated(self) -> bool:
3✔
224
        """
225
        :return: True if Safe Master Copy is updated, False otherwise
226
        """
227

228
        last_safe_contract_address = self.last_safe_contract_address
3✔
229
        if self.safe_cli_info.master_copy == last_safe_contract_address:
3✔
230
            return True
3✔
231
        else:  # Check versions, maybe safe-cli addresses were not updated
232
            try:
3✔
233
                safe_contract_version = Safe(
3✔
234
                    last_safe_contract_address, self.ethereum_client
235
                ).retrieve_version()
236
            except (
×
237
                BadFunctionCallOutput
238
            ):  # Safe master copy is not deployed or errored, maybe custom network
239
                return True  # We cannot say you are not updated ¯\_(ツ)_/¯
×
240

241
            return semantic_version.parse(
3✔
242
                self.safe_cli_info.version
243
            ) >= semantic_version.parse(safe_contract_version)
244

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

265
    def load_cli_owners(self, keys: List[str]):
3✔
266
        for key in keys:
3✔
267
            try:
3✔
268
                account = Account.from_key(
3✔
269
                    os.environ.get(key, default=key)
270
                )  # Try to get key from `environ`
271
                self.accounts.add(account)
3✔
272
                balance = self.ethereum_client.get_balance(account.address)
3✔
273
                print_formatted_text(
3✔
274
                    HTML(
275
                        f"Loaded account <b>{account.address}</b> "
276
                        f'with balance={Web3.from_wei(balance, "ether")} ether'
277
                    )
278
                )
279
                if (
3✔
280
                    not self.default_sender
281
                    and not self.hw_wallet_manager.sender
282
                    and balance > 0
283
                ):
284
                    print_formatted_text(
3✔
285
                        HTML(
286
                            f"Set account <b>{account.address}</b> as default sender of txs"
287
                        )
288
                    )
289
                    self.default_sender = account
3✔
290
            except ValueError:
3✔
291
                if not self.interactive:
3✔
292
                    raise SafeOperatorException(f"Cannot load key={key}")
×
293
                print_formatted_text(HTML(f"<ansired>Cannot load key={key}</ansired>"))
3✔
294

295
    def load_hw_wallet(
3✔
296
        self,
297
        hw_wallet_type: HwWalletType,
298
        derivation_path: str,
299
        template_derivation_path: str,
300
    ):
301
        if not self.hw_wallet_manager.is_supported_hw_wallet(hw_wallet_type):
3✔
302
            return None
×
303
        if derivation_path is None:
3✔
304
            ledger_accounts = self.hw_wallet_manager.get_accounts(
3✔
305
                hw_wallet_type, template_derivation_path
306
            )
307
            if len(ledger_accounts) == 0:
3✔
308
                return None
3✔
309

310
            option = choose_option_from_list(
3✔
311
                "Select the owner address", ledger_accounts
312
            )
313
            if option is None:
3✔
314
                return None
×
315
            _, derivation_path = ledger_accounts[option]
3✔
316
        address = self.hw_wallet_manager.add_account(hw_wallet_type, derivation_path)
3✔
317
        balance = self.ethereum_client.get_balance(address)
3✔
318

319
        print_formatted_text(
3✔
320
            HTML(
321
                f"Loaded account <b>{address}</b> "
322
                f'with balance={Web3.from_wei(balance, "ether")} ether.'
323
            )
324
        )
325

326
        if (
3✔
327
            not self.default_sender
328
            and not self.hw_wallet_manager.sender
329
            and balance > 0
330
        ):
331
            self.hw_wallet_manager.set_sender(hw_wallet_type, derivation_path)
3✔
332
            print_formatted_text(HTML(f"HwDevice {address} added as sender"))
3✔
333
        else:
334
            print_formatted_text(HTML(f"HwDevice {address} wasn't added as sender"))
3✔
335

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

346
    def load_trezor_cli_owners(
3✔
347
        self, derivation_path: str = None, legacy_account: bool = False
348
    ):
349
        if legacy_account:
×
350
            self.load_hw_wallet(HwWalletType.TREZOR, derivation_path, "44'/60'/0'/{i}")
×
351
        else:
352
            self.load_hw_wallet(
×
353
                HwWalletType.TREZOR, derivation_path, "44'/60'/0'/0/{i}"
354
            )
355

356
    def unload_cli_owners(self, owners: List[str]):
3✔
357
        accounts_to_remove: Set[Account] = set()
3✔
358
        for owner in owners:
3✔
359
            for account in self.accounts:
3✔
360
                if account.address == owner:
3✔
361
                    if self.default_sender and self.default_sender.address == owner:
3✔
362
                        self.default_sender = None
3✔
363
                    accounts_to_remove.add(account)
3✔
364
                    break
3✔
365
        self.accounts = self.accounts.difference(accounts_to_remove)
3✔
366
        # Check if there are ledger owners
367
        if self.hw_wallet_manager.wallets and len(accounts_to_remove) < len(owners):
3✔
368
            accounts_to_remove = (
3✔
369
                accounts_to_remove | self.hw_wallet_manager.delete_accounts(owners)
370
            )
371

372
        if accounts_to_remove:
3✔
373
            print_formatted_text(
3✔
374
                HTML("<ansigreen>Accounts have been deleted</ansigreen>")
375
            )
376
        else:
377
            print_formatted_text(HTML("<ansired>No account was deleted</ansired>"))
×
378

379
    def show_cli_owners(self):
3✔
380
        accounts = self.accounts | self.hw_wallet_manager.wallets
×
381
        if not accounts:
×
382
            print_formatted_text(HTML("<ansired>No accounts loaded</ansired>"))
×
383
        else:
384
            for account in accounts:
×
385
                print_formatted_text(
×
386
                    HTML(
387
                        f"<ansigreen><b>Account</b> {account.address} loaded</ansigreen>"
388
                    )
389
                )
390
            if self.default_sender:
×
391
                print_formatted_text(
×
392
                    HTML(
393
                        f"<ansigreen><b>Default sender:</b> {self.default_sender.address}"
394
                        f"</ansigreen>"
395
                    )
396
                )
397
            elif self.hw_wallet_manager.sender:
×
398
                print_formatted_text(
×
399
                    HTML(
400
                        f"<ansigreen><b>HwDevice sender:</b> {self.hw_wallet_manager.sender}"
401
                        f"</ansigreen>"
402
                    )
403
                )
404
            else:
405
                print_formatted_text(
×
406
                    HTML("<ansigreen>Not default sender set </ansigreen>")
407
                )
408

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

463
    def sign_message(
3✔
464
        self,
465
        eip712_message_path: Optional[str] = None,
466
    ) -> bool:
467
        if eip712_message_path:
3✔
468
            try:
3✔
469
                message = json.load(open(eip712_message_path, "r"))
3✔
470
                message_bytes = b"".join(eip712_encode(message))
3✔
471
            except ValueError:
×
472
                raise ValueError
×
473
        else:
474
            print_formatted_text("EIP191 message to sign:")
3✔
475
            message = get_input()
3✔
476
            message_bytes = message.encode("UTF-8")
3✔
477

478
        safe_message_hash = self.safe.get_message_hash(message_bytes)
3✔
479

480
        sign_message_lib_address = get_last_sign_message_lib_address(
3✔
481
            self.ethereum_client
482
        )
483
        contract = get_sign_message_lib_contract(self.ethereum_client.w3, self.address)
3✔
484
        sign_message_data = HexBytes(
3✔
485
            contract.functions.signMessage(message_bytes).build_transaction(
486
                get_empty_tx_params(),
487
            )["data"]
488
        )
489
        print_formatted_text(HTML(f"Signing message: \n {message}"))
3✔
490
        if self.prepare_and_execute_safe_transaction(
3✔
491
            sign_message_lib_address,
492
            0,
493
            sign_message_data,
494
            operation=SafeOperationEnum.DELEGATE_CALL,
495
        ):
496
            print_formatted_text(
3✔
497
                HTML(f"Message was signed correctly: {safe_message_hash.hex()}")
498
            )
499

500
    def confirm_message(self, safe_message_hash: bytes, sender: ChecksumAddress):
3✔
501
        return self._require_tx_service_mode()
×
502

503
    def add_owner(self, new_owner: str, threshold: Optional[int] = None) -> bool:
3✔
504
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
3✔
505
        if new_owner in self.safe_cli_info.owners:
3✔
506
            raise ExistingOwnerException(new_owner)
3✔
507
        else:
508
            # TODO Allow to set threshold
509
            transaction = self.safe_contract.functions.addOwnerWithThreshold(
3✔
510
                new_owner, threshold
511
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
512
            if self.execute_safe_internal_transaction(transaction["data"]):
3✔
513
                self.safe_cli_info.owners = self.safe.retrieve_owners()
3✔
514
                self.safe_cli_info.threshold = self.safe.retrieve_threshold()
3✔
515
                return True
3✔
516
            return False
×
517

518
    def remove_owner(self, owner_to_remove: str, threshold: Optional[int] = None):
3✔
519
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
3✔
520
        if owner_to_remove not in self.safe_cli_info.owners:
3✔
521
            raise NonExistingOwnerException(owner_to_remove)
3✔
522
        elif len(self.safe_cli_info.owners) == threshold:
3✔
523
            raise ThresholdLimitException()
×
524
        else:
525
            index_owner = self.safe_cli_info.owners.index(owner_to_remove)
3✔
526
            prev_owner = (
3✔
527
                self.safe_cli_info.owners[index_owner - 1]
528
                if index_owner
529
                else SENTINEL_ADDRESS
530
            )
531
            transaction = self.safe_contract.functions.removeOwner(
3✔
532
                prev_owner, owner_to_remove, threshold
533
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
534
            if self.execute_safe_internal_transaction(transaction["data"]):
3✔
535
                self.safe_cli_info.owners = self.safe.retrieve_owners()
3✔
536
                self.safe_cli_info.threshold = self.safe.retrieve_threshold()
3✔
537
                return True
3✔
538
            return False
×
539

540
    def send_custom(
3✔
541
        self,
542
        to: str,
543
        value: int,
544
        data: bytes,
545
        safe_nonce: Optional[int] = None,
546
        delegate_call: bool = False,
547
    ) -> bool:
548
        if value > 0:
3✔
549
            safe_balance = self.ethereum_client.get_balance(self.address)
3✔
550
            if safe_balance < value:
3✔
551
                raise NotEnoughEtherToSend(safe_balance)
3✔
552
        operation = (
3✔
553
            SafeOperationEnum.DELEGATE_CALL if delegate_call else SafeOperationEnum.CALL
554
        )
555
        return self.prepare_and_execute_safe_transaction(
3✔
556
            to, value, data, operation, safe_nonce=safe_nonce
557
        )
558

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

562
    def send_erc20(self, to: str, token_address: str, amount: int, **kwargs) -> bool:
3✔
563
        transaction = (
3✔
564
            get_erc20_contract(self.ethereum_client.w3, token_address)
565
            .functions.transfer(to, amount)
566
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
567
        )
568
        return self.send_custom(
3✔
569
            token_address, 0, HexBytes(transaction["data"]), **kwargs
570
        )
571

572
    def send_erc721(self, to: str, token_address: str, token_id: int, **kwargs) -> bool:
3✔
573
        transaction = (
3✔
574
            get_erc721_contract(self.ethereum_client.w3, token_address)
575
            .functions.transferFrom(self.address, to, token_id)
576
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
577
        )
578
        return self.send_custom(token_address, 0, transaction["data"], **kwargs)
3✔
579

580
    def change_fallback_handler(self, new_fallback_handler: str) -> bool:
3✔
581
        if new_fallback_handler == self.safe_cli_info.fallback_handler:
3✔
582
            raise SameFallbackHandlerException(new_fallback_handler)
3✔
583
        elif semantic_version.parse(
3✔
584
            self.safe_cli_info.version
585
        ) < semantic_version.parse("1.1.0"):
586
            raise FallbackHandlerNotSupportedException()
3✔
587
        elif (
3✔
588
            new_fallback_handler != NULL_ADDRESS
589
            and not self.ethereum_client.is_contract(new_fallback_handler)
590
        ):
591
            raise InvalidFallbackHandlerException(
3✔
592
                f"{new_fallback_handler} address is not a contract"
593
            )
594
        else:
595
            transaction = self.safe_contract.functions.setFallbackHandler(
3✔
596
                new_fallback_handler
597
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
598
            if self.execute_safe_internal_transaction(transaction["data"]):
3✔
599
                self.safe_cli_info.fallback_handler = new_fallback_handler
3✔
600
                self.safe_cli_info.version = self.safe.retrieve_version()
3✔
601
                return True
3✔
602

603
    def change_guard(self, guard: str) -> bool:
3✔
604
        if guard == self.safe_cli_info.guard:
3✔
605
            raise SameGuardException(guard)
3✔
606
        elif semantic_version.parse(
3✔
607
            self.safe_cli_info.version
608
        ) < semantic_version.parse("1.3.0"):
609
            raise GuardNotSupportedException()
3✔
610
        elif guard != NULL_ADDRESS and not self.ethereum_client.is_contract(guard):
3✔
611
            raise InvalidGuardException(f"{guard} address is not a contract")
3✔
612
        else:
613
            transaction = self.safe_contract.functions.setGuard(
3✔
614
                guard
615
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
616
            if self.execute_safe_internal_transaction(transaction["data"]):
3✔
617
                self.safe_cli_info.guard = guard
3✔
618
                self.safe_cli_info.version = self.safe.retrieve_version()
3✔
619
                return True
3✔
620

621
    def change_master_copy(self, new_master_copy: str) -> bool:
3✔
622
        if new_master_copy == self.safe_cli_info.master_copy:
3✔
623
            raise SameMasterCopyException(new_master_copy)
3✔
624
        else:
625
            safe_version = self.safe.retrieve_version()
3✔
626
            if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
3✔
627
                raise SafeVersionNotSupportedException(
3✔
628
                    f"{safe_version} cannot be updated (yet)"
629
                )
630

631
            try:
3✔
632
                Safe(new_master_copy, self.ethereum_client).retrieve_version()
3✔
633
            except BadFunctionCallOutput:
3✔
634
                raise InvalidMasterCopyException(new_master_copy)
3✔
635

636
            transaction = self.safe_contract_1_1_0.functions.changeMasterCopy(
3✔
637
                new_master_copy
638
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
639
            if self.execute_safe_internal_transaction(transaction["data"]):
3✔
640
                self.safe_cli_info.master_copy = new_master_copy
3✔
641
                self.safe_cli_info.version = self.safe.retrieve_version()
3✔
642
                return True
3✔
643

644
    def update_version(self) -> Optional[bool]:
3✔
645
        """
646
        Update Safe Master Copy and Fallback handler to the last version
647

648
        :return:
649
        """
650

651
        safe_version = self.safe.retrieve_version()
3✔
652
        if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
3✔
653
            raise SafeVersionNotSupportedException(
3✔
654
                f"{safe_version} cannot be updated (yet)"
655
            )
656

657
        if self.is_version_updated():
3✔
658
            raise SafeAlreadyUpdatedException(f"{safe_version} already updated")
×
659

660
        addresses = (
3✔
661
            self.last_safe_contract_address,
662
            self.last_default_fallback_handler_address,
663
        )
664
        if not all(
3✔
665
            self.ethereum_client.is_contract(contract) for contract in addresses
666
        ):
667
            raise UpdateAddressesNotValid(
×
668
                "Not valid addresses to update Safe", *addresses
669
            )
670

671
        multisend = MultiSend(ethereum_client=self.ethereum_client)
3✔
672
        tx_params = {"from": self.address, "gas": 0, "gasPrice": 0}
3✔
673
        multisend_txs = [
3✔
674
            MultiSendTx(MultiSendOperation.CALL, self.address, 0, data)
675
            for data in (
676
                self.safe_contract_1_1_0.functions.changeMasterCopy(
677
                    self.last_safe_contract_address
678
                ).build_transaction(tx_params)["data"],
679
                self.safe_contract_1_1_0.functions.setFallbackHandler(
680
                    self.last_default_fallback_handler_address
681
                ).build_transaction(tx_params)["data"],
682
            )
683
        ]
684

685
        multisend_data = multisend.build_tx_data(multisend_txs)
3✔
686

687
        if self.prepare_and_execute_safe_transaction(
3✔
688
            multisend.address,
689
            0,
690
            multisend_data,
691
            operation=SafeOperationEnum.DELEGATE_CALL,
692
        ):
693
            self.safe_cli_info.master_copy = self.last_safe_contract_address
3✔
694
            self.safe_cli_info.fallback_handler = (
3✔
695
                self.last_default_fallback_handler_address
696
            )
697
            self.safe_cli_info.version = self.safe.retrieve_version()
3✔
698

699
    def update_version_to_l2(
3✔
700
        self, migration_contract_address: ChecksumAddress
701
    ) -> Optional[bool]:
702
        """
703
        Update not L2 Safe to L2, so official UI supports it. Useful when replaying Safes deployed in
704
        non L2 networks (like mainnet) in L2 networks.
705
        Only v1.1.1, v1.3.0 and v1.4.1 versions are supported. Also, Safe nonce must be 0.
706

707
        :return:
708
        """
709

710
        if not self.ethereum_client.is_contract(migration_contract_address):
3✔
711
            raise InvalidMigrationContractException(
×
712
                f"Non L2 to L2 migration contract {migration_contract_address} is not deployed"
713
            )
714

715
        safe_version = self.safe.retrieve_version()
3✔
716
        chain_id = self.ethereum_client.get_chain_id()
3✔
717

718
        if self.safe.retrieve_nonce() > 0:
3✔
719
            raise InvalidNonceException("Nonce must be 0 for non L2 to L2 migration")
×
720

721
        l2_migration_contract = self.ethereum_client.w3.eth.contract(
3✔
722
            NULL_ADDRESS, abi=safe_to_l2_migration["abi"]
723
        )
724
        if safe_version == "1.1.1":
3✔
725
            safe_l2_singleton = safe_deployments["1.3.0"]["GnosisSafeL2"][str(chain_id)]
3✔
726
            fallback_handler = safe_deployments["1.3.0"][
3✔
727
                "CompatibilityFallbackHandler"
728
            ][str(chain_id)]
729
            data = HexBytes(
3✔
730
                l2_migration_contract.functions.migrateFromV111(
731
                    safe_l2_singleton, fallback_handler
732
                ).build_transaction(get_empty_tx_params())["data"]
733
            )
734
        elif safe_version in ("1.3.0", "1.4.1"):
3✔
735
            safe_l2_singleton = safe_deployments[safe_version]["GnosisSafeL2"][
3✔
736
                str(chain_id)
737
            ]
738
            fallback_handler = self.safe_cli_info.fallback_handler
3✔
739
            data = HexBytes(
3✔
740
                l2_migration_contract.functions.migrateToL2(
741
                    safe_l2_singleton
742
                ).build_transaction(get_empty_tx_params())["data"]
743
            )
744
        else:
745
            raise InvalidMasterCopyException(
×
746
                "Current version is not supported to migrate to L2"
747
            )
748

749
        if self.prepare_and_execute_safe_transaction(
3✔
750
            migration_contract_address,
751
            0,
752
            data,
753
            operation=SafeOperationEnum.DELEGATE_CALL,
754
        ):
755
            self.safe_cli_info.master_copy = safe_l2_singleton
3✔
756
            self.safe_cli_info.fallback_handler = fallback_handler
3✔
757
            self.safe_cli_info.version = self.safe.retrieve_version()
3✔
758

759
    def change_threshold(self, threshold: int):
3✔
760
        if threshold == self.safe_cli_info.threshold:
3✔
761
            print_formatted_text(
×
762
                HTML(f"<ansired>Threshold is already {threshold}</ansired>")
763
            )
764
        elif threshold > len(self.safe_cli_info.owners):
3✔
765
            print_formatted_text(
×
766
                HTML(
767
                    f"<ansired>Threshold={threshold} bigger than number "
768
                    f"of owners={len(self.safe_cli_info.owners)}</ansired>"
769
                )
770
            )
771
        else:
772
            transaction = self.safe_contract.functions.changeThreshold(
3✔
773
                threshold
774
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
775

776
            if self.execute_safe_internal_transaction(transaction["data"]):
3✔
777
                self.safe_cli_info.threshold = self.safe.retrieve_threshold()
3✔
778

779
    def enable_module(self, module_address: str):
3✔
780
        if module_address in self.safe_cli_info.modules:
×
781
            print_formatted_text(
×
782
                HTML(f"<ansired>Module {module_address} is already enabled</ansired>")
783
            )
784
        else:
785
            transaction = self.safe_contract.functions.enableModule(
×
786
                module_address
787
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
788
            if self.execute_safe_internal_transaction(transaction["data"]):
×
789
                self.safe_cli_info.modules = self.safe.retrieve_modules()
×
790

791
    def disable_module(self, module_address: str):
3✔
792
        if module_address not in self.safe_cli_info.modules:
×
793
            print_formatted_text(
×
794
                HTML(f"<ansired>Module {module_address} is not enabled</ansired>")
795
            )
796
        else:
797
            pos = self.safe_cli_info.modules.index(module_address)
×
798
            if pos == 0:
×
799
                previous_address = SENTINEL_ADDRESS
×
800
            else:
801
                previous_address = self.safe_cli_info.modules[pos - 1]
×
802
            transaction = self.safe_contract.functions.disableModule(
×
803
                previous_address, module_address
804
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
805
            if self.execute_safe_internal_transaction(transaction["data"]):
×
806
                self.safe_cli_info.modules = self.safe.retrieve_modules()
×
807

808
    def print_info(self):
3✔
809
        for key, value in dataclasses.asdict(self.safe_cli_info).items():
3✔
810
            print_formatted_text(
3✔
811
                HTML(
812
                    f"<b><ansigreen>{key.capitalize()}</ansigreen></b>="
813
                    f"<ansiblue>{value}</ansiblue>"
814
                )
815
            )
816
        if self.ens_domain:
3✔
817
            print_formatted_text(
×
818
                HTML(
819
                    f"<b><ansigreen>Ens domain</ansigreen></b>="
820
                    f"<ansiblue>{self.ens_domain}</ansiblue>"
821
                )
822
            )
823
        if self.safe_tx_service:
3✔
824
            url = f"{self.safe_tx_service.base_url}/api/v1/safes/{self.address}/transactions/"
×
825
            print_formatted_text(
×
826
                HTML(
827
                    f"<b><ansigreen>Safe Tx Service</ansigreen></b>="
828
                    f"<ansiblue>{url}</ansiblue>"
829
                )
830
            )
831

832
        if self.etherscan:
3✔
833
            url = f"{self.etherscan.base_url}/address/{self.address}"
×
834
            print_formatted_text(
×
835
                HTML(
836
                    f"<b><ansigreen>Etherscan</ansigreen></b>="
837
                    f"<ansiblue>{url}</ansiblue>"
838
                )
839
            )
840

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

856
        if not self.hw_wallet_manager.is_supported_hw_wallet(HwWalletType.TREZOR):
3✔
857
            print_formatted_text(
×
858
                HTML(
859
                    "<b><ansigreen>Trezor</ansigreen></b>="
860
                    "<ansired>Disabled </ansired> <b>Optional trezor library is not installed, run pip install safe-cli[trezor] </b>"
861
                )
862
            )
863
        else:
864
            print_formatted_text(
3✔
865
                HTML(
866
                    "<b><ansigreen>Trezor</ansigreen></b>="
867
                    "<ansiblue>supported</ansiblue>"
868
                )
869
            )
870

871
        if not self.is_version_updated():
3✔
872
            print_formatted_text(
×
873
                HTML(
874
                    "<ansired>Safe is not updated! You can use <b>update</b> command to update "
875
                    "the Safe to a newest version</ansired>"
876
                )
877
            )
878

879
    def get_safe_cli_info(self) -> SafeCliInfo:
3✔
880
        safe = self.safe
3✔
881
        balance_ether = Web3.from_wei(
3✔
882
            self.ethereum_client.get_balance(self.address), "ether"
883
        )
884
        safe_info = safe.retrieve_all_info()
3✔
885
        return SafeCliInfo(
3✔
886
            self.address,
887
            safe_info.nonce,
888
            safe_info.threshold,
889
            safe_info.owners,
890
            safe_info.master_copy,
891
            safe_info.modules,
892
            safe_info.fallback_handler,
893
            safe_info.guard,
894
            balance_ether,
895
            safe_info.version,
896
        )
897

898
    def get_threshold(self):
3✔
899
        print_formatted_text(self.safe.retrieve_threshold())
×
900

901
    def get_nonce(self):
3✔
902
        print_formatted_text(self.safe.retrieve_nonce())
3✔
903

904
    def get_owners(self):
3✔
905
        print_formatted_text(self.safe.retrieve_owners())
×
906

907
    def execute_safe_internal_transaction(self, data: bytes) -> bool:
3✔
908
        return self.prepare_and_execute_safe_transaction(self.address, 0, data)
3✔
909

910
    def prepare_safe_transaction(
3✔
911
        self,
912
        to: str,
913
        value: int,
914
        data: bytes,
915
        operation: SafeOperationEnum = SafeOperationEnum.CALL,
916
        safe_nonce: Optional[int] = None,
917
    ) -> SafeTx:
918
        safe_tx = self.safe.build_multisig_tx(
3✔
919
            to, value, data, operation=operation.value, safe_nonce=safe_nonce
920
        )
921
        self.sign_transaction(safe_tx)  # Raises exception if it cannot be signed
3✔
922
        return safe_tx
3✔
923

924
    def prepare_and_execute_safe_transaction(
3✔
925
        self,
926
        to: str,
927
        value: int,
928
        data: bytes,
929
        operation: SafeOperationEnum = SafeOperationEnum.CALL,
930
        safe_nonce: Optional[int] = None,
931
    ) -> bool:
932
        safe_tx = self.prepare_safe_transaction(
3✔
933
            to, value, data, operation, safe_nonce=safe_nonce
934
        )
935
        return self.execute_safe_transaction(safe_tx)
3✔
936

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

992
    # Batch_transactions multisend
993
    def batch_safe_txs(
3✔
994
        self, safe_nonce: int, safe_txs: Sequence[SafeTx]
995
    ) -> Optional[SafeTx]:
996
        """
997
        Submit signatures to the tx service. It's recommended to be on Safe v1.3.0 to prevent issues
998
        with `safeTxGas` and gas estimation.
999

1000
        :return:
1001
        """
1002

1003
        try:
3✔
1004
            multisend = MultiSend(ethereum_client=self.ethereum_client)
3✔
1005
        except ValueError:
3✔
1006
            if not self.interactive:
3✔
1007
                raise SafeOperatorException(
3✔
1008
                    "Multisend contract is not deployed on this network and it's required for batching txs"
1009
                )
1010
            multisend = None
×
1011
            print_formatted_text(
×
1012
                HTML(
1013
                    "<ansired>Multisend contract is not deployed on this network and it's required for "
1014
                    "batching txs</ansired>"
1015
                )
1016
            )
1017

1018
        multisend_txs = []
3✔
1019
        for safe_tx in safe_txs:
3✔
1020
            # Check if call is already a Multisend call
1021
            inner_txs = MultiSend.from_transaction_data(safe_tx.data)
3✔
1022
            if inner_txs:
3✔
1023
                multisend_txs.extend(inner_txs)
×
1024
            else:
1025
                multisend_txs.append(
3✔
1026
                    MultiSendTx(
1027
                        MultiSendOperation.CALL, safe_tx.to, safe_tx.value, safe_tx.data
1028
                    )
1029
                )
1030

1031
        if len(multisend_txs) == 1:
3✔
1032
            safe_tx.safe_tx_gas = 0
×
1033
            safe_tx.base_gas = 0
×
1034
            safe_tx.gas_price = 0
×
1035
            safe_tx.signatures = b""
×
1036
            safe_tx.safe_nonce = safe_nonce  # Resend single transaction
×
1037
        elif multisend:
3✔
1038
            safe_tx = SafeTx(
3✔
1039
                self.ethereum_client,
1040
                self.address,
1041
                multisend.address,
1042
                0,
1043
                multisend.build_tx_data(multisend_txs),
1044
                SafeOperationEnum.DELEGATE_CALL.value,
1045
                0,
1046
                0,
1047
                0,
1048
                None,
1049
                None,
1050
                safe_nonce=safe_nonce,
1051
            )
1052
        else:
1053
            # Multisend not defined
1054
            return None
×
1055

1056
        safe_tx = self.sign_transaction(safe_tx)
3✔
1057
        if not safe_tx.signatures:
3✔
1058
            print_formatted_text(
×
1059
                HTML("<ansired>At least one owner must be loaded</ansired>")
1060
            )
1061
            return None
×
1062
        else:
1063
            return safe_tx
3✔
1064

1065
    def get_signers(self) -> Tuple[List[LocalAccount], List[HwWallet]]:
3✔
1066
        """
1067
        Get the signers necessary to sign a transaction, raise an exception if was not uploaded enough signers.
1068

1069
        :return: Tuple with eoa signers and hw_wallet signers
1070
        """
1071
        permitted_signers = self.get_permitted_signers()
3✔
1072
        threshold = self.safe_cli_info.threshold
3✔
1073
        eoa_signers: List[Account] = (
3✔
1074
            []
1075
        )  # Some accounts that are not an owner can be loaded
1076
        for account in self.accounts:
3✔
1077
            if account.address in permitted_signers:
3✔
1078
                eoa_signers.append(account)
3✔
1079
                threshold -= 1
3✔
1080
                if threshold == 0:
3✔
1081
                    break
3✔
1082
        # If still pending required signatures continue with ledger owners
1083
        hw_wallet_signers = []
3✔
1084
        if threshold > 0 and self.hw_wallet_manager.wallets:
3✔
1085
            for hw_wallet in self.hw_wallet_manager.wallets:
3✔
1086
                if hw_wallet.address in permitted_signers:
3✔
1087
                    hw_wallet_signers.append(hw_wallet)
3✔
1088
                    threshold -= 1
3✔
1089
                    if threshold == 0:
3✔
1090
                        break
3✔
1091

1092
        if self.require_all_signatures and threshold > 0:
3✔
1093
            raise NotEnoughSignatures(threshold)
3✔
1094

1095
        return (eoa_signers, hw_wallet_signers)
3✔
1096

1097
    # TODO Set sender so we can save gas in that signature
1098
    def sign_transaction(self, safe_tx: SafeTx) -> SafeTx:
3✔
1099
        eoa_signers, hw_wallets_signers = self.get_signers()
3✔
1100
        for selected_account in eoa_signers:
3✔
1101
            safe_tx.sign(selected_account.key)
3✔
1102

1103
        # Sign with ledger
1104
        if len(hw_wallets_signers):
3✔
1105
            safe_tx = self.hw_wallet_manager.sign_safe_tx(safe_tx, hw_wallets_signers)
3✔
1106

1107
        return safe_tx
3✔
1108

1109
    @require_tx_service
3✔
1110
    def _require_tx_service_mode(self):
3✔
1111
        print_formatted_text(
×
1112
            HTML(
1113
                "<ansired>First enter tx-service mode using <b>tx-service</b> command</ansired>"
1114
            )
1115
        )
1116

1117
    def get_delegates(self):
3✔
1118
        return self._require_tx_service_mode()
×
1119

1120
    def add_delegate(self, delegate_address: str, label: str, signer_address: str):
3✔
1121
        return self._require_tx_service_mode()
×
1122

1123
    def remove_delegate(self, delegate_address: str, signer_address: str):
3✔
1124
        return self._require_tx_service_mode()
×
1125

1126
    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
3✔
1127
        return self._require_tx_service_mode()
×
1128

1129
    def get_balances(self):
3✔
1130
        return self._require_tx_service_mode()
×
1131

1132
    def get_transaction_history(self):
3✔
1133
        return self._require_tx_service_mode()
×
1134

1135
    def batch_txs(self, safe_nonce: int, safe_tx_hashes: Sequence[bytes]) -> bool:
3✔
1136
        return self._require_tx_service_mode()
×
1137

1138
    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
3✔
1139
        return self._require_tx_service_mode()
×
1140

1141
    def get_permitted_signers(self) -> Set[ChecksumAddress]:
3✔
1142
        """
1143
        :return: Accounts that can sign a transaction
1144
        """
1145
        return set(self.safe_cli_info.owners)
3✔
1146

1147
    def drain(self, to: str):
3✔
1148
        # Getting all events related with ERC20 transfers
1149
        last = self.ethereum_client.get_block("latest")["number"]
3✔
1150
        token_addresses = get_erc_20_list(self.ethereum_client, self.address, 1, last)
3✔
1151
        safe_txs = []
3✔
1152
        for token_address in token_addresses:
3✔
1153
            balance = self.ethereum_client.erc20.get_balance(
3✔
1154
                self.address, token_address
1155
            )
1156
            if balance > 0:
3✔
1157
                transaction = (
3✔
1158
                    get_erc20_contract(self.ethereum_client.w3, token_address)
1159
                    .functions.transfer(to, balance)
1160
                    .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
1161
                )
1162

1163
                safe_tx = self.prepare_safe_transaction(
3✔
1164
                    token_address,
1165
                    0,
1166
                    HexBytes(transaction["data"]),
1167
                    SafeOperationEnum.CALL,
1168
                    safe_nonce=None,
1169
                )
1170
                safe_txs.append(safe_tx)
3✔
1171

1172
        # Getting ethereum balance
1173
        balance_eth = self.ethereum_client.get_balance(self.address)
3✔
1174
        if balance_eth:
3✔
1175
            safe_tx = self.prepare_safe_transaction(
3✔
1176
                to,
1177
                balance_eth,
1178
                b"",
1179
                SafeOperationEnum.CALL,
1180
                safe_nonce=None,
1181
            )
1182
            safe_txs.append(safe_tx)
3✔
1183

1184
        if safe_txs:
3✔
1185
            multisend_tx = self.batch_safe_txs(self.get_nonce(), safe_txs)
3✔
1186
            if multisend_tx is not None:
3✔
1187
                if self.execute_safe_transaction(multisend_tx):
3✔
1188
                    print_formatted_text(
3✔
1189
                        HTML(
1190
                            "<ansigreen>Transaction to drain account correctly executed</ansigreen>"
1191
                        )
1192
                    )
1193
        else:
1194
            print_formatted_text(
×
1195
                HTML("<ansigreen>Safe account is currently empty</ansigreen>")
1196
            )
1197

1198
    def remove_proposed_transaction(self, safe_tx_hash: bytes):
3✔
1199
        return self._require_tx_service_mode()
×
1200

1201
    def process_command(self, first_command: str, rest_command: List[str]) -> bool:
3✔
1202
        if first_command == "help":
×
1203
            print_formatted_text("I still cannot help you")
×
1204
        elif first_command == "refresh":
×
1205
            print_formatted_text("Reloading Safe information")
×
1206
            self.refresh_safe_cli_info()
×
1207

1208
        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