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

safe-global / safe-cli / 7504397399

12 Jan 2024 03:50PM UTC coverage: 93.638%. Remained the same
7504397399

Pull #336

github

web-flow
Merge c83933aeb into 7a5c68aa2
Pull Request #336: Bump coverage from 7.3.4 to 7.4.0

1987 of 2122 relevant lines covered (93.64%)

3.7 hits per line

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

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

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

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

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

68
from ..contracts import safe_to_l2_migration
4✔
69
from .hw_wallets.hw_wallet_manager import HwWalletType, get_hw_wallet_manager
4✔
70

71

72
@dataclasses.dataclass
4✔
73
class SafeCliInfo:
4✔
74
    address: str
4✔
75
    nonce: int
4✔
76
    threshold: int
4✔
77
    owners: List[str]
4✔
78
    master_copy: str
4✔
79
    modules: List[str]
4✔
80
    fallback_handler: str
4✔
81
    guard: str
4✔
82
    balance_ether: int
4✔
83
    version: str
4✔
84

85
    def __str__(self):
4✔
86
        return (
4✔
87
            f"safe-version={self.version} nonce={self.nonce} threshold={self.threshold} owners={self.owners} "
4✔
88
            f"master-copy={self.master_copy} fallback-hander={self.fallback_handler} "
3✔
89
            f"modules={self.modules} balance-ether={self.balance_ether:.4f}"
3✔
90
        )
91

92

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

109
    return decorated
4✔
110

111

112
def require_default_sender(f):
4✔
113
    """
114
    Throws SenderRequiredException if not default sender configured
115
    """
116

117
    @wraps(f)
4✔
118
    def decorated(self, *args, **kwargs):
4✔
119
        if not self.default_sender and not self.hw_wallet_manager.sender:
4✔
120
            raise SenderRequiredException()
4✔
121
        else:
×
122
            return f(self, *args, **kwargs)
4✔
123

124
    return decorated
4✔
125

126

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

144
    def __init__(self, address: ChecksumAddress, node_url: str):
4✔
145
        self.address = address
4✔
146
        self.node_url = node_url
4✔
147
        self.ethereum_client = EthereumClient(self.node_url)
4✔
148
        self.ens = ENS.from_web3(self.ethereum_client.w3)
4✔
149
        self.network: EthereumNetwork = self.ethereum_client.get_network()
4✔
150
        try:
4✔
151
            self.etherscan = EtherscanClient(self.network)
4✔
152
        except EtherscanClientConfigurationProblem:
4✔
153
            self.etherscan = None
4✔
154

155
        try:
4✔
156
            self.safe_tx_service = TransactionServiceApi.from_ethereum_client(
4✔
157
                self.ethereum_client
4✔
158
            )
159
        except EthereumNetworkNotSupported:
4✔
160
            self.safe_tx_service = None
4✔
161

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

176
    @cached_property
4✔
177
    def last_default_fallback_handler_address(self) -> ChecksumAddress:
4✔
178
        """
179
        :return: Address for last version of default fallback handler contract
180
        """
181
        return get_default_fallback_handler_address(self.ethereum_client)
×
182

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

193
    @cached_property
4✔
194
    def ens_domain(self) -> Optional[str]:
4✔
195
        # FIXME After web3.py fixes the middleware copy
196
        if self.network == EthereumNetwork.MAINNET:
4✔
197
            return self.ens.name(self.address)
×
198

199
    @property
4✔
200
    def safe_cli_info(self) -> SafeCliInfo:
4✔
201
        if not self._safe_cli_info:
4✔
202
            self._safe_cli_info = self.refresh_safe_cli_info()
4✔
203
        return self._safe_cli_info
4✔
204

205
    def refresh_safe_cli_info(self) -> SafeCliInfo:
4✔
206
        self._safe_cli_info = self.get_safe_cli_info()
4✔
207
        return self._safe_cli_info
4✔
208

209
    def is_version_updated(self) -> bool:
4✔
210
        """
211
        :return: True if Safe Master Copy is updated, False otherwise
212
        """
213

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

225
            return semantic_version.parse(
4✔
226
                self.safe_cli_info.version
4✔
227
            ) >= semantic_version.parse(safe_contract_version)
4✔
228

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

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

277
    def load_hw_wallet(
4✔
278
        self, hw_wallet_type: HwWalletType, derivation_path: str, legacy_account: bool
4✔
279
    ):
280
        if not self.hw_wallet_manager.is_supported_hw_wallet(hw_wallet_type):
4✔
281
            return None
282
        if derivation_path is None:
4✔
283
            ledger_accounts = self.hw_wallet_manager.get_accounts(
4✔
284
                hw_wallet_type, legacy_account=legacy_account
4✔
285
            )
286
            if len(ledger_accounts) == 0:
4✔
287
                return None
4✔
288

289
            option = choose_option_from_list(
4✔
290
                "Select the owner address", ledger_accounts
4✔
291
            )
292
            if option is None:
4✔
293
                return None
294
            _, derivation_path = ledger_accounts[option]
4✔
295
        address = self.hw_wallet_manager.add_account(hw_wallet_type, derivation_path)
4✔
296
        balance = self.ethereum_client.get_balance(address)
4✔
297

298
        print_formatted_text(
4✔
299
            HTML(
4✔
300
                f"Loaded account <b>{address}</b> "
4✔
301
                f'with balance={Web3.from_wei(balance, "ether")} ether.'
3✔
302
            )
303
        )
304

305
        if (
4✔
306
            not self.default_sender
4✔
307
            and not self.hw_wallet_manager.sender
4✔
308
            and hw_wallet_type == HwWalletType.LEDGER
4✔
309
            and balance > 0
4✔
310
        ):
311
            self.hw_wallet_manager.set_sender(hw_wallet_type, derivation_path)
4✔
312
            print_formatted_text(HTML(f"HwDevice {address} added as sender"))
4✔
313
        else:
314
            print_formatted_text(HTML(f"HwDevice {address} wasn't added as sender"))
4✔
315

316
    def load_ledger_cli_owners(
4✔
317
        self, derivation_path: str = None, legacy_account: bool = False
4✔
318
    ):
319
        self.load_hw_wallet(HwWalletType.LEDGER, derivation_path, legacy_account)
4✔
320

321
    def load_trezor_cli_owners(
4✔
322
        self, derivation_path: str = None, legacy_account: bool = False
4✔
323
    ):
324
        self.load_hw_wallet(HwWalletType.TREZOR, derivation_path, legacy_account)
325

326
    def unload_cli_owners(self, owners: List[str]):
4✔
327
        accounts_to_remove: Set[Account] = set()
4✔
328
        for owner in owners:
4✔
329
            for account in self.accounts:
4✔
330
                if account.address == owner:
4✔
331
                    if self.default_sender and self.default_sender.address == owner:
4✔
332
                        self.default_sender = None
4✔
333
                    accounts_to_remove.add(account)
4✔
334
                    break
4✔
335
        self.accounts = self.accounts.difference(accounts_to_remove)
4✔
336
        # Check if there are ledger owners
337
        if self.hw_wallet_manager.wallets and len(accounts_to_remove) < len(owners):
4✔
338
            accounts_to_remove = (
4✔
339
                accounts_to_remove | self.hw_wallet_manager.delete_accounts(owners)
4✔
340
            )
341

342
        if accounts_to_remove:
4✔
343
            print_formatted_text(
4✔
344
                HTML("<ansigreen>Accounts have been deleted</ansigreen>")
4✔
345
            )
346
        else:
347
            print_formatted_text(HTML("<ansired>No account was deleted</ansired>"))
348

349
    def show_cli_owners(self):
4✔
350
        accounts = self.accounts | self.hw_wallet_manager.wallets
351
        if not accounts:
352
            print_formatted_text(HTML("<ansired>No accounts loaded</ansired>"))
353
        else:
354
            for account in accounts:
355
                print_formatted_text(
356
                    HTML(
357
                        f"<ansigreen><b>Account</b> {account.address} loaded</ansigreen>"
358
                    )
359
                )
360
            if self.default_sender:
361
                print_formatted_text(
362
                    HTML(
363
                        f"<ansigreen><b>Default sender:</b> {self.default_sender.address}"
364
                        f"</ansigreen>"
365
                    )
366
                )
367
            elif self.hw_wallet_manager.sender:
368
                print_formatted_text(
369
                    HTML(
370
                        f"<ansigreen><b>HwDevice sender:</b> {self.hw_wallet_manager.sender}"
371
                        f"</ansigreen>"
372
                    )
373
                )
374
            else:
375
                print_formatted_text(
376
                    HTML("<ansigreen>Not default sender set </ansigreen>")
377
                )
378

379
    def approve_hash(self, hash_to_approve: HexBytes, sender: str) -> bool:
4✔
380
        sender_account = [
4✔
381
            account for account in self.accounts if account.address == sender
4✔
382
        ]
383
        if not sender_account:
4✔
384
            raise AccountNotLoadedException(sender)
4✔
385
        elif sender not in self.safe_cli_info.owners:
4✔
386
            raise NonExistingOwnerException(sender)
4✔
387
        elif self.safe.retrieve_is_hash_approved(
4✔
388
            self.default_sender.address, hash_to_approve
4✔
389
        ):
390
            raise HashAlreadyApproved(hash_to_approve, self.default_sender.address)
4✔
391
        else:
392
            sender_account = sender_account[0]
4✔
393
            transaction_to_send = self.safe_contract.functions.approveHash(
4✔
394
                hash_to_approve
4✔
395
            ).build_transaction(
2✔
396
                {
4✔
397
                    "from": sender_account.address,
4✔
398
                    "nonce": self.ethereum_client.get_nonce_for_account(
4✔
399
                        sender_account.address
4✔
400
                    ),
401
                }
402
            )
403
            if self.ethereum_client.is_eip1559_supported():
4✔
404
                transaction_to_send = self.ethereum_client.set_eip1559_fees(
4✔
405
                    transaction_to_send
4✔
406
                )
407
            call_result = self.ethereum_client.w3.eth.call(transaction_to_send)
4✔
408
            if call_result:  # There's revert message
4✔
409
                return False
410
            else:
411
                signed_transaction = sender_account.sign_transaction(
4✔
412
                    transaction_to_send
4✔
413
                )
414
                tx_hash = self.ethereum_client.send_raw_transaction(
4✔
415
                    signed_transaction["rawTransaction"]
4✔
416
                )
417
                print_formatted_text(
4✔
418
                    HTML(
4✔
419
                        f"<ansigreen>Sent tx with tx-hash {tx_hash.hex()} from owner "
4✔
420
                        f"{self.default_sender.address}, waiting for receipt</ansigreen>"
3✔
421
                    )
422
                )
423
                if self.ethereum_client.get_transaction_receipt(tx_hash, timeout=120):
4✔
424
                    return True
4✔
425
                else:
426
                    print_formatted_text(
427
                        HTML(
428
                            f"<ansired>Tx with tx-hash {tx_hash.hex()} still not mined</ansired>"
429
                        )
430
                    )
431
                    return False
432

433
    def add_owner(self, new_owner: str, threshold: Optional[int] = None) -> bool:
4✔
434
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
435
        if new_owner in self.safe_cli_info.owners:
4✔
436
            raise ExistingOwnerException(new_owner)
4✔
437
        else:
438
            # TODO Allow to set threshold
439
            transaction = self.safe_contract.functions.addOwnerWithThreshold(
4✔
440
                new_owner, threshold
4✔
441
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
442
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
443
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
444
                self.safe_cli_info.threshold = threshold
4✔
445
                return True
4✔
446
            return False
447

448
    def remove_owner(self, owner_to_remove: str, threshold: Optional[int] = None):
4✔
449
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
450
        if owner_to_remove not in self.safe_cli_info.owners:
4✔
451
            raise NonExistingOwnerException(owner_to_remove)
4✔
452
        elif len(self.safe_cli_info.owners) == threshold:
4✔
453
            raise ThresholdLimitException()
454
        else:
455
            index_owner = self.safe_cli_info.owners.index(owner_to_remove)
4✔
456
            prev_owner = (
4✔
457
                self.safe_cli_info.owners[index_owner - 1]
4✔
458
                if index_owner
4✔
459
                else SENTINEL_ADDRESS
4✔
460
            )
461
            transaction = self.safe_contract.functions.removeOwner(
4✔
462
                prev_owner, owner_to_remove, threshold
4✔
463
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
464
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
465
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
466
                self.safe_cli_info.threshold = threshold
4✔
467
                return True
4✔
468
            return False
469

470
    def send_custom(
4✔
471
        self,
472
        to: str,
4✔
473
        value: int,
4✔
474
        data: bytes,
4✔
475
        safe_nonce: Optional[int] = None,
4✔
476
        delegate_call: bool = False,
4✔
477
    ) -> bool:
4✔
478
        if value > 0:
4✔
479
            safe_balance = self.ethereum_client.get_balance(self.address)
4✔
480
            if safe_balance < value:
4✔
481
                raise NotEnoughEtherToSend(safe_balance)
4✔
482
        operation = SafeOperation.DELEGATE_CALL if delegate_call else SafeOperation.CALL
4✔
483
        return self.prepare_and_execute_safe_transaction(
4✔
484
            to, value, data, operation, safe_nonce=safe_nonce
4✔
485
        )
486

487
    def send_ether(self, to: str, value: int, **kwargs) -> bool:
4✔
488
        return self.send_custom(to, value, b"", **kwargs)
4✔
489

490
    def send_erc20(self, to: str, token_address: str, amount: int, **kwargs) -> bool:
4✔
491
        transaction = (
492
            get_erc20_contract(self.ethereum_client.w3, token_address)
493
            .functions.transfer(to, amount)
494
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
495
        )
496
        return self.send_custom(
497
            token_address, 0, HexBytes(transaction["data"]), **kwargs
498
        )
499

500
    def send_erc721(self, to: str, token_address: str, token_id: int, **kwargs) -> bool:
4✔
501
        transaction = (
502
            get_erc721_contract(self.ethereum_client.w3, token_address)
503
            .functions.transferFrom(self.address, to, token_id)
504
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
505
        )
506
        return self.send_custom(token_address, 0, transaction["data"], **kwargs)
507

508
    def change_fallback_handler(self, new_fallback_handler: str) -> bool:
4✔
509
        if new_fallback_handler == self.safe_cli_info.fallback_handler:
4✔
510
            raise SameFallbackHandlerException(new_fallback_handler)
4✔
511
        elif semantic_version.parse(
4✔
512
            self.safe_cli_info.version
4✔
513
        ) < semantic_version.parse("1.1.0"):
4✔
514
            raise FallbackHandlerNotSupportedException()
4✔
515
        elif (
2✔
516
            new_fallback_handler != NULL_ADDRESS
4✔
517
            and not self.ethereum_client.is_contract(new_fallback_handler)
4✔
518
        ):
519
            raise InvalidFallbackHandlerException(
4✔
520
                f"{new_fallback_handler} address is not a contract"
4✔
521
            )
522
        else:
523
            transaction = self.safe_contract.functions.setFallbackHandler(
4✔
524
                new_fallback_handler
4✔
525
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
526
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
527
                self.safe_cli_info.fallback_handler = new_fallback_handler
4✔
528
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
529
                return True
4✔
530

531
    def change_guard(self, guard: str) -> bool:
4✔
532
        if guard == self.safe_cli_info.guard:
4✔
533
            raise SameGuardException(guard)
4✔
534
        elif semantic_version.parse(
4✔
535
            self.safe_cli_info.version
4✔
536
        ) < semantic_version.parse("1.3.0"):
4✔
537
            raise GuardNotSupportedException()
4✔
538
        elif guard != NULL_ADDRESS and not self.ethereum_client.is_contract(guard):
4✔
539
            raise InvalidGuardException(f"{guard} address is not a contract")
4✔
540
        else:
541
            transaction = self.safe_contract.functions.setGuard(
4✔
542
                guard
4✔
543
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
544
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
545
                self.safe_cli_info.guard = guard
4✔
546
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
547
                return True
4✔
548

549
    def change_master_copy(self, new_master_copy: str) -> bool:
4✔
550
        if new_master_copy == self.safe_cli_info.master_copy:
4✔
551
            raise SameMasterCopyException(new_master_copy)
4✔
552
        else:
553
            safe_version = self.safe.retrieve_version()
4✔
554
            if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
555
                raise SafeVersionNotSupportedException(
4✔
556
                    f"{safe_version} cannot be updated (yet)"
4✔
557
                )
558

559
            try:
4✔
560
                Safe(new_master_copy, self.ethereum_client).retrieve_version()
4✔
561
            except BadFunctionCallOutput:
4✔
562
                raise InvalidMasterCopyException(new_master_copy)
4✔
563

564
            transaction = self.safe_contract_1_1_0.functions.changeMasterCopy(
4✔
565
                new_master_copy
4✔
566
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
567
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
568
                self.safe_cli_info.master_copy = new_master_copy
4✔
569
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
570
                return True
4✔
571

572
    def update_version(self) -> Optional[bool]:
4✔
573
        """
574
        Update Safe Master Copy and Fallback handler to the last version
575

576
        :return:
577
        """
578

579
        safe_version = self.safe.retrieve_version()
4✔
580
        if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
581
            raise SafeVersionNotSupportedException(
4✔
582
                f"{safe_version} cannot be updated (yet)"
4✔
583
            )
584

585
        if self.is_version_updated():
4✔
586
            raise SafeAlreadyUpdatedException(f"{safe_version} already updated")
587

588
        addresses = (
4✔
589
            self.last_safe_contract_address,
4✔
590
            self.last_default_fallback_handler_address,
4✔
591
        )
592
        if not all(
4✔
593
            self.ethereum_client.is_contract(contract) for contract in addresses
4✔
594
        ):
595
            raise UpdateAddressesNotValid(
596
                "Not valid addresses to update Safe", *addresses
597
            )
598

599
        multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
600
        tx_params = {"from": self.address, "gas": 0, "gasPrice": 0}
4✔
601
        multisend_txs = [
4✔
602
            MultiSendTx(MultiSendOperation.CALL, self.address, 0, data)
4✔
603
            for data in (
4✔
604
                self.safe_contract_1_1_0.functions.changeMasterCopy(
4✔
605
                    self.last_safe_contract_address
4✔
606
                ).build_transaction(tx_params)["data"],
4✔
607
                self.safe_contract_1_1_0.functions.setFallbackHandler(
4✔
608
                    self.last_default_fallback_handler_address
4✔
609
                ).build_transaction(tx_params)["data"],
4✔
610
            )
611
        ]
612

613
        multisend_data = multisend.build_tx_data(multisend_txs)
4✔
614

615
        if self.prepare_and_execute_safe_transaction(
4✔
616
            multisend.address, 0, multisend_data, operation=SafeOperation.DELEGATE_CALL
4✔
617
        ):
618
            self.safe_cli_info.master_copy = self.last_safe_contract_address
4✔
619
            self.safe_cli_info.fallback_handler = (
4✔
620
                self.last_default_fallback_handler_address
4✔
621
            )
622
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
623

624
    def update_version_to_l2(
4✔
625
        self, migration_contract_address: ChecksumAddress
4✔
626
    ) -> Optional[bool]:
4✔
627
        """
628
        Update not L2 Safe to L2, so official UI supports it. Useful when replaying Safes deployed in
629
        non L2 networks (like mainnet) in L2 networks.
630
        Only v1.1.1, v1.3.0 and v1.4.1 versions are supported. Also, Safe nonce must be 0.
631

632
        :return:
633
        """
634

635
        if not self.ethereum_client.is_contract(migration_contract_address):
4✔
636
            raise InvalidMigrationContractException(
637
                f"Non L2 to L2 migration contract {migration_contract_address} is not deployed"
638
            )
639

640
        safe_version = self.safe.retrieve_version()
4✔
641
        chain_id = self.ethereum_client.get_chain_id()
4✔
642

643
        if self.safe.retrieve_nonce() > 0:
4✔
644
            raise InvalidNonceException("Nonce must be 0 for non L2 to L2 migration")
645

646
        l2_migration_contract = self.ethereum_client.w3.eth.contract(
4✔
647
            NULL_ADDRESS, abi=safe_to_l2_migration["abi"]
4✔
648
        )
649
        if safe_version == "1.1.1":
4✔
650
            safe_l2_singleton = safe_deployments["1.3.0"]["GnosisSafeL2"][str(chain_id)]
4✔
651
            fallback_handler = safe_deployments["1.3.0"][
4✔
652
                "CompatibilityFallbackHandler"
4✔
653
            ][str(chain_id)]
4✔
654
            data = HexBytes(
4✔
655
                l2_migration_contract.functions.migrateFromV111(
4✔
656
                    safe_l2_singleton, fallback_handler
4✔
657
                ).build_transaction(get_empty_tx_params())["data"]
4✔
658
            )
659
        elif safe_version in ("1.3.0", "1.4.1"):
4✔
660
            safe_l2_singleton = safe_deployments[safe_version]["GnosisSafeL2"][
4✔
661
                str(chain_id)
4✔
662
            ]
663
            fallback_handler = self.safe_cli_info.fallback_handler
4✔
664
            data = HexBytes(
4✔
665
                l2_migration_contract.functions.migrateToL2(
4✔
666
                    safe_l2_singleton
4✔
667
                ).build_transaction(get_empty_tx_params())["data"]
4✔
668
            )
669
        else:
670
            raise InvalidMasterCopyException(
671
                "Current version is not supported to migrate to L2"
672
            )
673

674
        if self.prepare_and_execute_safe_transaction(
4✔
675
            migration_contract_address, 0, data, operation=SafeOperation.DELEGATE_CALL
4✔
676
        ):
677
            self.safe_cli_info.master_copy = safe_l2_singleton
4✔
678
            self.safe_cli_info.fallback_handler = fallback_handler
4✔
679
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
680

681
    def change_threshold(self, threshold: int):
4✔
682
        if threshold == self.safe_cli_info.threshold:
4✔
683
            print_formatted_text(
684
                HTML(f"<ansired>Threshold is already {threshold}</ansired>")
685
            )
686
        elif threshold > len(self.safe_cli_info.owners):
4✔
687
            print_formatted_text(
688
                HTML(
689
                    f"<ansired>Threshold={threshold} bigger than number "
690
                    f"of owners={len(self.safe_cli_info.owners)}</ansired>"
691
                )
692
            )
693
        else:
694
            transaction = self.safe_contract.functions.changeThreshold(
4✔
695
                threshold
4✔
696
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
697

698
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
699
                self.safe_cli_info.threshold = threshold
4✔
700

701
    def enable_module(self, module_address: str):
4✔
702
        if module_address in self.safe_cli_info.modules:
703
            print_formatted_text(
704
                HTML(f"<ansired>Module {module_address} is already enabled</ansired>")
705
            )
706
        else:
707
            transaction = self.safe_contract.functions.enableModule(
708
                module_address
709
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
710
            if self.execute_safe_internal_transaction(transaction["data"]):
711
                self.safe_cli_info.modules = self.safe.retrieve_modules()
712

713
    def disable_module(self, module_address: str):
4✔
714
        if module_address not in self.safe_cli_info.modules:
715
            print_formatted_text(
716
                HTML(f"<ansired>Module {module_address} is not enabled</ansired>")
717
            )
718
        else:
719
            pos = self.safe_cli_info.modules.index(module_address)
720
            if pos == 0:
721
                previous_address = SENTINEL_ADDRESS
722
            else:
723
                previous_address = self.safe_cli_info.modules[pos - 1]
724
            transaction = self.safe_contract.functions.disableModule(
725
                previous_address, module_address
726
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
727
            if self.execute_safe_internal_transaction(transaction["data"]):
728
                self.safe_cli_info.modules = self.safe.retrieve_modules()
729

730
    def print_info(self):
4✔
731
        for key, value in dataclasses.asdict(self.safe_cli_info).items():
4✔
732
            print_formatted_text(
4✔
733
                HTML(
4✔
734
                    f"<b><ansigreen>{key.capitalize()}</ansigreen></b>="
4✔
735
                    f"<ansiblue>{value}</ansiblue>"
3✔
736
                )
737
            )
738
        if self.ens_domain:
4✔
739
            print_formatted_text(
740
                HTML(
741
                    f"<b><ansigreen>Ens domain</ansigreen></b>="
742
                    f"<ansiblue>{self.ens_domain}</ansiblue>"
743
                )
744
            )
745
        if self.safe_tx_service:
4✔
746
            url = f"{self.safe_tx_service.base_url}/api/v1/safes/{self.address}/transactions/"
4✔
747
            print_formatted_text(
4✔
748
                HTML(
4✔
749
                    f"<b><ansigreen>Safe Tx Service</ansigreen></b>="
4✔
750
                    f"<ansiblue>{url}</ansiblue>"
3✔
751
                )
752
            )
753

754
        if self.etherscan:
4✔
755
            url = f"{self.etherscan.base_url}/address/{self.address}"
4✔
756
            print_formatted_text(
4✔
757
                HTML(
4✔
758
                    f"<b><ansigreen>Etherscan</ansigreen></b>="
4✔
759
                    f"<ansiblue>{url}</ansiblue>"
3✔
760
                )
761
            )
762

763
        if not self.hw_wallet_manager.is_supported_hw_wallet(HwWalletType.LEDGER):
4✔
764
            print_formatted_text(
765
                HTML(
766
                    "<b><ansigreen>Ledger</ansigreen></b>="
767
                    "<ansired>Disabled </ansired> <b>Optional ledger library is not installed, run pip install safe-cli[ledger] </b>"
768
                )
769
            )
770
        else:
771
            print_formatted_text(
4✔
772
                HTML(
4✔
773
                    "<b><ansigreen>Ledger</ansigreen></b>="
4✔
774
                    "<ansiblue>supported</ansiblue>"
775
                )
776
            )
777

778
        if not self.hw_wallet_manager.is_supported_hw_wallet(HwWalletType.TREZOR):
4✔
779
            print_formatted_text(
780
                HTML(
781
                    "<b><ansigreen>Trezor</ansigreen></b>="
782
                    "<ansired>Disabled </ansired> <b>Optional trezor library is not installed, run pip install safe-cli[trezor] </b>"
783
                )
784
            )
785
        else:
786
            print_formatted_text(
4✔
787
                HTML(
4✔
788
                    "<b><ansigreen>Trezor</ansigreen></b>="
4✔
789
                    "<ansiblue>supported</ansiblue>"
790
                )
791
            )
792

793
        if not self.is_version_updated():
4✔
794
            print_formatted_text(
795
                HTML(
796
                    "<ansired>Safe is not updated! You can use <b>update</b> command to update "
797
                    "the Safe to a newest version</ansired>"
798
                )
799
            )
800

801
    def get_safe_cli_info(self) -> SafeCliInfo:
4✔
802
        safe = self.safe
4✔
803
        balance_ether = Web3.from_wei(
4✔
804
            self.ethereum_client.get_balance(self.address), "ether"
4✔
805
        )
806
        safe_info = safe.retrieve_all_info()
4✔
807
        return SafeCliInfo(
4✔
808
            self.address,
4✔
809
            safe_info.nonce,
4✔
810
            safe_info.threshold,
4✔
811
            safe_info.owners,
4✔
812
            safe_info.master_copy,
4✔
813
            safe_info.modules,
4✔
814
            safe_info.fallback_handler,
4✔
815
            safe_info.guard,
4✔
816
            balance_ether,
4✔
817
            safe_info.version,
4✔
818
        )
819

820
    def get_threshold(self):
4✔
821
        print_formatted_text(self.safe.retrieve_threshold())
822

823
    def get_nonce(self):
4✔
824
        print_formatted_text(self.safe.retrieve_nonce())
4✔
825

826
    def get_owners(self):
4✔
827
        print_formatted_text(self.safe.retrieve_owners())
828

829
    def execute_safe_internal_transaction(self, data: bytes) -> bool:
4✔
830
        return self.prepare_and_execute_safe_transaction(self.address, 0, data)
4✔
831

832
    def prepare_safe_transaction(
4✔
833
        self,
834
        to: str,
4✔
835
        value: int,
4✔
836
        data: bytes,
4✔
837
        operation: SafeOperation = SafeOperation.CALL,
4✔
838
        safe_nonce: Optional[int] = None,
4✔
839
    ) -> SafeTx:
4✔
840
        safe_tx = self.safe.build_multisig_tx(
4✔
841
            to, value, data, operation=operation.value, safe_nonce=safe_nonce
4✔
842
        )
843
        self.sign_transaction(safe_tx)  # Raises exception if it cannot be signed
4✔
844
        return safe_tx
4✔
845

846
    def prepare_and_execute_safe_transaction(
4✔
847
        self,
848
        to: str,
4✔
849
        value: int,
4✔
850
        data: bytes,
4✔
851
        operation: SafeOperation = SafeOperation.CALL,
4✔
852
        safe_nonce: Optional[int] = None,
4✔
853
    ) -> bool:
4✔
854
        safe_tx = self.prepare_safe_transaction(
4✔
855
            to, value, data, operation, safe_nonce=safe_nonce
4✔
856
        )
857
        return self.execute_safe_transaction(safe_tx)
4✔
858

859
    @require_default_sender  # Throws Exception if default sender not found
4✔
860
    def execute_safe_transaction(self, safe_tx: SafeTx):
4✔
861
        try:
4✔
862
            if self.default_sender:
4✔
863
                call_result = safe_tx.call(self.default_sender.address)
4✔
864
            else:
865
                call_result = safe_tx.call(self.hw_wallet_manager.sender.address)
866
            print_formatted_text(HTML(f"Result: <ansigreen>{call_result}</ansigreen>"))
4✔
867
            if yes_or_no_question("Do you want to execute tx " + str(safe_tx)):
4✔
868
                if self.default_sender:
4✔
869
                    tx_hash, tx = safe_tx.execute(
4✔
870
                        self.default_sender.key, eip1559_speed=TxSpeed.NORMAL
4✔
871
                    )
872
                else:
873
                    tx_hash, tx = self.hw_wallet_manager.execute_safe_tx(
874
                        safe_tx, eip1559_speed=TxSpeed.NORMAL
875
                    )
876
                self.executed_transactions.append(tx_hash.hex())
4✔
877
                print_formatted_text(
4✔
878
                    HTML(
4✔
879
                        f"<ansigreen>Sent tx with tx-hash {tx_hash.hex()} "
4✔
880
                        f"and safe-nonce {safe_tx.safe_nonce}, waiting for receipt</ansigreen>"
3✔
881
                    )
882
                )
883
                tx_receipt = self.ethereum_client.get_transaction_receipt(
4✔
884
                    tx_hash, timeout=120
4✔
885
                )
886
                if tx_receipt:
4✔
887
                    fees = self.ethereum_client.w3.from_wei(
4✔
888
                        tx_receipt["gasUsed"]
4✔
889
                        * tx_receipt.get("effectiveGasPrice", tx.get("gasPrice", 0)),
4✔
890
                        "ether",
4✔
891
                    )
892
                    print_formatted_text(
4✔
893
                        HTML(
4✔
894
                            f"<ansigreen>Tx was executed on block-number={tx_receipt['blockNumber']}, fees "
4✔
895
                            f"deducted={fees}</ansigreen>"
3✔
896
                        )
897
                    )
898
                    self.safe_cli_info.nonce += 1
4✔
899
                    return True
4✔
900
                else:
901
                    print_formatted_text(
902
                        HTML(
903
                            f"<ansired>Tx with tx-hash {tx_hash.hex()} still not mined</ansired>"
904
                        )
905
                    )
906
        except InvalidInternalTx as invalid_internal_tx:
907
            print_formatted_text(
908
                HTML(f"Result: <ansired>InvalidTx - {invalid_internal_tx}</ansired>")
909
            )
910
        return False
911

912
    # Batch_transactions multisend
913
    def batch_safe_txs(
4✔
914
        self, safe_nonce: int, safe_txs: Sequence[SafeTx]
4✔
915
    ) -> Optional[SafeTx]:
4✔
916
        """
917
        Submit signatures to the tx service. It's recommended to be on Safe v1.3.0 to prevent issues
918
        with `safeTxGas` and gas estimation.
919

920
        :return:
921
        """
922

923
        try:
4✔
924
            multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
925
        except ValueError:
926
            multisend = None
927
            print_formatted_text(
928
                HTML(
929
                    "<ansired>Multisend contract is not deployed on this network and it's required for "
930
                    "batching txs</ansired>"
931
                )
932
            )
933

934
        multisend_txs = []
4✔
935
        for safe_tx in safe_txs:
4✔
936
            # Check if call is already a Multisend call
937
            inner_txs = MultiSend.from_transaction_data(safe_tx.data)
4✔
938
            if inner_txs:
4✔
939
                multisend_txs.extend(inner_txs)
940
            else:
941
                multisend_txs.append(
4✔
942
                    MultiSendTx(
4✔
943
                        MultiSendOperation.CALL, safe_tx.to, safe_tx.value, safe_tx.data
4✔
944
                    )
945
                )
946

947
        if len(multisend_txs) == 1:
4✔
948
            safe_tx.safe_tx_gas = 0
949
            safe_tx.base_gas = 0
950
            safe_tx.gas_price = 0
951
            safe_tx.signatures = b""
952
            safe_tx.safe_nonce = safe_nonce  # Resend single transaction
953
        elif multisend:
4✔
954
            safe_tx = SafeTx(
4✔
955
                self.ethereum_client,
4✔
956
                self.address,
4✔
957
                multisend.address,
4✔
958
                0,
4✔
959
                multisend.build_tx_data(multisend_txs),
4✔
960
                SafeOperation.DELEGATE_CALL.value,
4✔
961
                0,
4✔
962
                0,
4✔
963
                0,
4✔
964
                None,
4✔
965
                None,
4✔
966
                safe_nonce=safe_nonce,
4✔
967
            )
968
        else:
969
            # Multisend not defined
970
            return None
971

972
        safe_tx = self.sign_transaction(safe_tx)
4✔
973
        if not safe_tx.signatures:
4✔
974
            print_formatted_text(
975
                HTML("<ansired>At least one owner must be loaded</ansired>")
976
            )
977
            return None
978
        else:
979
            return safe_tx
4✔
980

981
    # TODO Set sender so we can save gas in that signature
982
    def sign_transaction(self, safe_tx: SafeTx) -> SafeTx:
4✔
983
        permitted_signers = self.get_permitted_signers()
4✔
984
        threshold = self.safe_cli_info.threshold
4✔
985
        selected_accounts: List[
4✔
986
            Account
987
        ] = []  # Some accounts that are not an owner can be loaded
4✔
988
        for account in self.accounts:
4✔
989
            if account.address in permitted_signers:
4✔
990
                selected_accounts.append(account)
4✔
991
                threshold -= 1
4✔
992
                if threshold == 0:
4✔
993
                    break
4✔
994
        # If still pending required signatures continue with ledger owners
995
        selected_ledger_accounts = []
4✔
996
        if threshold > 0 and self.hw_wallet_manager.wallets:
4✔
997
            for ledger_account in self.hw_wallet_manager.wallets:
998
                if ledger_account.address in permitted_signers:
999
                    selected_ledger_accounts.append(ledger_account)
1000
                    threshold -= 1
1001
                    if threshold == 0:
1002
                        break
1003

1004
        if self.require_all_signatures and threshold > 0:
4✔
1005
            raise NotEnoughSignatures(threshold)
4✔
1006

1007
        for selected_account in selected_accounts:
4✔
1008
            safe_tx.sign(selected_account.key)
4✔
1009

1010
        # Sign with ledger
1011
        if len(selected_ledger_accounts) > 0:
4✔
1012
            safe_tx = self.hw_wallet_manager.sign_eip712(
1013
                safe_tx, selected_ledger_accounts
1014
            )
1015

1016
        return safe_tx
4✔
1017

1018
    @require_tx_service
4✔
1019
    def _require_tx_service_mode(self):
4✔
1020
        print_formatted_text(
1021
            HTML(
1022
                "<ansired>First enter tx-service mode using <b>tx-service</b> command</ansired>"
1023
            )
1024
        )
1025

1026
    def get_delegates(self):
4✔
1027
        return self._require_tx_service_mode()
1028

1029
    def add_delegate(self, delegate_address: str, label: str, signer_address: str):
4✔
1030
        return self._require_tx_service_mode()
1031

1032
    def remove_delegate(self, delegate_address: str, signer_address: str):
4✔
1033
        return self._require_tx_service_mode()
1034

1035
    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
4✔
1036
        return self._require_tx_service_mode()
1037

1038
    def get_balances(self):
4✔
1039
        return self._require_tx_service_mode()
1040

1041
    def get_transaction_history(self):
4✔
1042
        return self._require_tx_service_mode()
1043

1044
    def batch_txs(self, safe_nonce: int, safe_tx_hashes: Sequence[bytes]) -> bool:
4✔
1045
        return self._require_tx_service_mode()
1046

1047
    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
4✔
1048
        return self._require_tx_service_mode()
1049

1050
    def get_permitted_signers(self) -> Set[ChecksumAddress]:
4✔
1051
        """
1052
        :return: Accounts that can sign a transaction
1053
        """
1054
        return set(self.safe_cli_info.owners)
4✔
1055

1056
    def drain(self, to: str):
4✔
1057
        # Getting all events related with ERC20 transfers
1058
        last = self.ethereum_client.get_block("latest")["number"]
4✔
1059
        token_addresses = get_erc_20_list(self.ethereum_client, self.address, 1, last)
4✔
1060
        safe_txs = []
4✔
1061
        for token_address in token_addresses:
4✔
1062
            balance = self.ethereum_client.erc20.get_balance(
4✔
1063
                self.address, token_address
4✔
1064
            )
1065
            if balance > 0:
4✔
1066
                transaction = (
4✔
1067
                    get_erc20_contract(self.ethereum_client.w3, token_address)
4✔
1068
                    .functions.transfer(to, balance)
4✔
1069
                    .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
1070
                )
1071

1072
                safe_tx = self.prepare_safe_transaction(
4✔
1073
                    token_address,
4✔
1074
                    0,
4✔
1075
                    HexBytes(transaction["data"]),
4✔
1076
                    SafeOperation.CALL,
4✔
1077
                    safe_nonce=None,
4✔
1078
                )
1079
                safe_txs.append(safe_tx)
4✔
1080

1081
        # Getting ethereum balance
1082
        balance_eth = self.ethereum_client.get_balance(self.address)
4✔
1083
        if balance_eth:
4✔
1084
            safe_tx = self.prepare_safe_transaction(
4✔
1085
                to,
4✔
1086
                balance_eth,
4✔
1087
                b"",
4✔
1088
                SafeOperation.CALL,
4✔
1089
                safe_nonce=None,
4✔
1090
            )
1091
            safe_txs.append(safe_tx)
4✔
1092

1093
        if safe_txs:
4✔
1094
            multisend_tx = self.batch_safe_txs(self.get_nonce(), safe_txs)
4✔
1095
            if multisend_tx is not None:
4✔
1096
                if self.execute_safe_transaction(multisend_tx):
4✔
1097
                    print_formatted_text(
4✔
1098
                        HTML(
4✔
1099
                            "<ansigreen>Transaction to drain account correctly executed</ansigreen>"
4✔
1100
                        )
1101
                    )
1102
        else:
1103
            print_formatted_text(
1104
                HTML("<ansigreen>Safe account is currently empty</ansigreen>")
1105
            )
1106

1107
    def process_command(self, first_command: str, rest_command: List[str]) -> bool:
4✔
1108
        if first_command == "help":
1109
            print_formatted_text("I still cannot help you")
1110
        elif first_command == "refresh":
1111
            print_formatted_text("Reloading Safe information")
1112
            self.refresh_safe_cli_info()
1113

1114
        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

© 2026 Coveralls, Inc