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

safe-global / safe-cli / 9662287756

25 Jun 2024 12:19PM UTC coverage: 87.771% (+0.5%) from 87.279%
9662287756

Pull #419

github

web-flow
Merge d8319c98c into d9ac0c119
Pull Request #419: Support scripting

748 of 858 branches covered (87.18%)

Branch coverage included in aggregate %.

274 of 294 new or added lines in 7 files covered. (93.2%)

6 existing lines in 2 files now uncovered.

2532 of 2879 relevant lines covered (87.95%)

3.52 hits per line

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

81.1
/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
    SafeOperatorException,
57
    SafeVersionNotSupportedException,
58
    SameFallbackHandlerException,
59
    SameGuardException,
60
    SameMasterCopyException,
61
    SenderRequiredException,
62
    ThresholdLimitException,
63
    UpdateAddressesNotValid,
64
)
65
from safe_cli.safe_addresses import (
4✔
66
    get_default_fallback_handler_address,
67
    get_last_sign_message_lib_address,
68
    get_safe_contract_address,
69
    get_safe_l2_contract_address,
70
)
71
from safe_cli.utils import (
4✔
72
    choose_option_from_list,
73
    get_erc_20_list,
74
    get_input,
75
    yes_or_no_question,
76
)
77

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

82

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

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

103

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

120
    return decorated
4✔
121

122

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

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

135
    return decorated
4✔
136

137

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

477
        safe_message_hash = self.safe.get_message_hash(message_bytes)
4✔
478

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

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

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

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

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

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

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

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

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

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

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

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

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

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

647
        :return:
648
        """
649

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

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

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

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

684
        multisend_data = multisend.build_tx_data(multisend_txs)
4✔
685

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

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

706
        :return:
707
        """
708

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

714
        safe_version = self.safe.retrieve_version()
4✔
715
        chain_id = self.ethereum_client.get_chain_id()
4✔
716

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

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

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

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

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

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

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

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

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

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

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

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

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

897
    def get_threshold(self):
4✔
898
        print_formatted_text(self.safe.retrieve_threshold())
×
899

900
    def get_nonce(self):
4✔
901
        print_formatted_text(self.safe.retrieve_nonce())
4✔
902

903
    def get_owners(self):
4✔
904
        print_formatted_text(self.safe.retrieve_owners())
×
905

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

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

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

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

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

999
        :return:
1000
        """
1001

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

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

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

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

1064
    def get_signers(self) -> Tuple[List[LocalAccount], List[HwWallet]]:
4✔
1065
        """
1066

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

1090
        if self.require_all_signatures and threshold > 0:
4✔
1091
            raise NotEnoughSignatures(threshold)
4✔
1092

1093
        return (eoa_signers, hw_wallet_signers)
4✔
1094

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

1101
        # Sign with ledger
1102
        if len(hw_wallets_signers):
4✔
1103
            safe_tx = self.hw_wallet_manager.sign_safe_tx(safe_tx, hw_wallets_signers)
4✔
1104

1105
        return safe_tx
4✔
1106

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

1115
    def get_delegates(self):
4✔
1116
        return self._require_tx_service_mode()
×
1117

1118
    def add_delegate(self, delegate_address: str, label: str, signer_address: str):
4✔
1119
        return self._require_tx_service_mode()
×
1120

1121
    def remove_delegate(self, delegate_address: str, signer_address: str):
4✔
1122
        return self._require_tx_service_mode()
×
1123

1124
    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
4✔
1125
        return self._require_tx_service_mode()
×
1126

1127
    def get_balances(self):
4✔
1128
        return self._require_tx_service_mode()
×
1129

1130
    def get_transaction_history(self):
4✔
1131
        return self._require_tx_service_mode()
×
1132

1133
    def batch_txs(self, safe_nonce: int, safe_tx_hashes: Sequence[bytes]) -> bool:
4✔
1134
        return self._require_tx_service_mode()
×
1135

1136
    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
4✔
1137
        return self._require_tx_service_mode()
×
1138

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

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

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

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

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

1196
    def remove_proposed_transaction(self, safe_tx_hash: bytes):
4✔
1197
        return self._require_tx_service_mode()
×
1198

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

1206
        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