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

safe-global / safe-cli / 7415363825

04 Jan 2024 10:08PM UTC coverage: 93.848% (+0.2%) from 93.638%
7415363825

Pull #337

github

web-flow
Merge 3f1215c06 into 687a58bd7
Pull Request #337: Support execute transaction from Trezor

2044 of 2178 relevant lines covered (93.85%)

3.71 hits per line

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

95.64
/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 balance > 0
4✔
309
        ):
310
            self.hw_wallet_manager.set_sender(hw_wallet_type, derivation_path)
4✔
311
            print_formatted_text(HTML(f"HwDevice {address} added as sender"))
4✔
312
        else:
313
            print_formatted_text(HTML(f"HwDevice {address} wasn't added as sender"))
4✔
314

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

575
        :return:
576
        """
577

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

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

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

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

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

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

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

631
        :return:
632
        """
633

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

919
        :return:
920
        """
921

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

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

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

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

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

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

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

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

1015
        return safe_tx
4✔
1016

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1113
        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