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

safe-global / safe-cli / 19670041703

25 Nov 2025 12:47PM UTC coverage: 88.407% (-0.2%) from 88.573%
19670041703

push

github

Uxio0
Fix CLI defaults and Safe interactions

- Detect default/attended mode from provided argv instead of sys.argv
- Use the sign-message-lib address for message signing
- Return values from threshold/nonce/owners getters and fix prompt output
- Fix tx-service batching when Multisend is not deployed
- Improve seed-derived owner loading feedback and fix approve-hash owner lookup
- Handle missing signer in approve_hash

224 of 266 branches covered (84.21%)

Branch coverage included in aggregate %.

23 of 39 new or added lines in 3 files covered. (58.97%)

2 existing lines in 1 file now uncovered.

2895 of 3262 relevant lines covered (88.75%)

3.55 hits per line

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

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

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

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

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

82

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

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

103

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

120
    return decorated
4✔
121

122

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

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

135
    return decorated
4✔
136

137

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

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

169
        try:
4✔
170
            self.safe_tx_service = TransactionServiceApi.from_ethereum_client(
4✔
171
                self.ethereum_client
172
            )
173
            if not self.safe_tx_service.api_key:
4✔
174
                print_formatted_text(
4✔
175
                    HTML(
176
                        "<ansired>To use tx-service mode, you must set the following environment variable with your API key. You can obtain your key from </ansired>"
177
                        "<ansiblue>https://developer.safe.global/</ansiblue><ansired>.</ansired>"
178
                    )
179
                )
180

181
        except EthereumNetworkNotSupported:
4✔
182
            self.safe_tx_service = None
4✔
183

184
        self.safe = Safe(address, self.ethereum_client)
4✔
185
        self.safe_contract = self.safe.contract
4✔
186
        self.safe_contract_1_1_0 = get_safe_V1_1_1_contract(
4✔
187
            self.ethereum_client.w3, address=self.address
188
        )
189
        self.accounts: Set[LocalAccount] = set()
4✔
190
        self.default_sender: Optional[LocalAccount] = None
4✔
191
        self.executed_transactions: List[str] = []
4✔
192
        self._safe_cli_info: Optional[SafeCliInfo] = None  # Cache for SafeCliInfo
4✔
193
        self.require_all_signatures = (
4✔
194
            True  # Require all signatures to be present to send a tx
195
        )
196
        self.hw_wallet_manager = get_hw_wallet_manager()
4✔
197
        self.interactive = interactive  # Disable prompt dialogs
4✔
198

199
    @cached_property
4✔
200
    def last_default_fallback_handler_address(self) -> ChecksumAddress:
4✔
201
        """
202
        :return: Address for last version of default fallback handler contract
203
        """
204
        return get_default_fallback_handler_address(self.ethereum_client)
×
205

206
    @cached_property
4✔
207
    def last_safe_contract_address(self) -> ChecksumAddress:
4✔
208
        """
209
        :return: Last version of the Safe Contract. Use events version for every network but mainnet
210
        """
211
        if self.network == EthereumNetwork.MAINNET:
4✔
212
            return get_safe_contract_address(self.ethereum_client)
×
213
        else:
214
            return get_safe_l2_contract_address(self.ethereum_client)
4✔
215

216
    @cached_property
4✔
217
    def ens_domain(self) -> Optional[str]:
4✔
218
        # FIXME After web3.py fixes the middleware copy
219
        if self.network == EthereumNetwork.MAINNET:
4✔
220
            return self.ens.name(self.address)
×
221

222
    @property
4✔
223
    def safe_cli_info(self) -> SafeCliInfo:
4✔
224
        if not self._safe_cli_info:
4✔
225
            self._safe_cli_info = self.refresh_safe_cli_info()
4✔
226
        return self._safe_cli_info
4✔
227

228
    def refresh_safe_cli_info(self) -> SafeCliInfo:
4✔
229
        self._safe_cli_info = self.get_safe_cli_info()
4✔
230
        return self._safe_cli_info
4✔
231

232
    def is_version_updated(self) -> bool:
4✔
233
        """
234
        :return: True if Safe Master Copy is updated, False otherwise
235
        """
236

237
        last_safe_contract_address = self.last_safe_contract_address
4✔
238
        if self.safe_cli_info.master_copy == last_safe_contract_address:
4✔
239
            return True
4✔
240
        else:  # Check versions, maybe safe-cli addresses were not updated
241
            try:
4✔
242
                safe_contract_version = Safe(
4✔
243
                    last_safe_contract_address, self.ethereum_client
244
                ).retrieve_version()
245
            except (
×
246
                BadFunctionCallOutput
247
            ):  # Safe master copy is not deployed or errored, maybe custom network
248
                return True  # We cannot say you are not updated ¯\_(ツ)_/¯
×
249

250
            return semantic_version.parse(
4✔
251
                self.safe_cli_info.version
252
            ) >= semantic_version.parse(safe_contract_version)
253

254
    def load_cli_owners_from_words(self, words: List[str]):
4✔
255
        if len(words) == 1:  # Reading seed from Environment Variable
×
256
            words = os.environ.get(words[0], default="").strip().split(" ")
×
257
        parsed_words = " ".join(words)
×
258
        try:
×
NEW
259
            accounts = []
×
NEW
260
            for index in range(100):  # Try first 100 accounts of seed phrase
×
261
                account = get_account_from_words(parsed_words, index=index)
×
262
                if account.address in self.safe_cli_info.owners:
×
263
                    self.load_cli_owners([to_0x_hex_str(account.key)])
×
NEW
264
                    accounts.append(account)
×
NEW
265
            if not accounts:
×
UNCOV
266
                print_formatted_text(
×
267
                    HTML(
268
                        "<ansired>Cannot generate any valid owner for this Safe</ansired>"
269
                    )
270
                )
271
        except ValidationError:
×
272
            print_formatted_text(
×
273
                HTML("<ansired>Cannot load owners from words</ansired>")
274
            )
275

276
    def load_cli_owners(self, keys: List[str]):
4✔
277
        for key in keys:
4✔
278
            try:
4✔
279
                account = Account.from_key(
4✔
280
                    os.environ.get(key, default=key)
281
                )  # Try to get key from `environ`
282
                self.accounts.add(account)
4✔
283
                balance = self.ethereum_client.get_balance(account.address)
4✔
284
                print_formatted_text(
4✔
285
                    HTML(
286
                        f"Loaded account <b>{account.address}</b> "
287
                        f'with balance={Web3.from_wei(balance, "ether")} ether'
288
                    )
289
                )
290
                if (
4✔
291
                    not self.default_sender
292
                    and not self.hw_wallet_manager.sender
293
                    and balance > 0
294
                ):
295
                    print_formatted_text(
4✔
296
                        HTML(
297
                            f"Set account <b>{account.address}</b> as default sender of txs"
298
                        )
299
                    )
300
                    self.default_sender = account
4✔
301
            except ValueError:
4✔
302
                if not self.interactive:
4✔
303
                    raise SafeOperatorException(f"Cannot load key={key}")
×
304
                print_formatted_text(HTML(f"<ansired>Cannot load key={key}</ansired>"))
4✔
305

306
    def load_hw_wallet(
4✔
307
        self,
308
        hw_wallet_type: HwWalletType,
309
        derivation_path: str,
310
        template_derivation_path: str,
311
    ):
312
        if not self.hw_wallet_manager.is_supported_hw_wallet(hw_wallet_type):
4✔
313
            return None
×
314
        if derivation_path is None:
4✔
315
            ledger_accounts = self.hw_wallet_manager.get_accounts(
4✔
316
                hw_wallet_type, template_derivation_path
317
            )
318
            if len(ledger_accounts) == 0:
4✔
319
                return None
4✔
320

321
            option = choose_option_from_list(
4✔
322
                "Select the owner address", ledger_accounts
323
            )
324
            if option is None:
4✔
325
                return None
×
326
            _, derivation_path = ledger_accounts[option]
4✔
327
        address = self.hw_wallet_manager.add_account(hw_wallet_type, derivation_path)
4✔
328
        balance = self.ethereum_client.get_balance(address)
4✔
329

330
        print_formatted_text(
4✔
331
            HTML(
332
                f"Loaded account <b>{address}</b> "
333
                f'with balance={Web3.from_wei(balance, "ether")} ether.'
334
            )
335
        )
336

337
        if (
4✔
338
            not self.default_sender
339
            and not self.hw_wallet_manager.sender
340
            and balance > 0
341
        ):
342
            self.hw_wallet_manager.set_sender(hw_wallet_type, derivation_path)
4✔
343
            print_formatted_text(HTML(f"HwDevice {address} added as sender"))
4✔
344
        else:
345
            print_formatted_text(HTML(f"HwDevice {address} wasn't added as sender"))
4✔
346

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

357
    def load_trezor_cli_owners(
4✔
358
        self, derivation_path: str = None, legacy_account: bool = False
359
    ):
360
        if legacy_account:
×
361
            self.load_hw_wallet(HwWalletType.TREZOR, derivation_path, "44'/60'/0'/{i}")
×
362
        else:
363
            self.load_hw_wallet(
×
364
                HwWalletType.TREZOR, derivation_path, "44'/60'/0'/0/{i}"
365
            )
366

367
    def unload_cli_owners(self, owners: List[str]):
4✔
368
        accounts_to_remove: Set[Account] = set()
4✔
369
        for owner in owners:
4✔
370
            for account in self.accounts:
4✔
371
                if account.address == owner:
4✔
372
                    if self.default_sender and self.default_sender.address == owner:
4✔
373
                        self.default_sender = None
4✔
374
                    accounts_to_remove.add(account)
4✔
375
                    break
4✔
376
        self.accounts = self.accounts.difference(accounts_to_remove)
4✔
377
        # Check if there are ledger owners
378
        if self.hw_wallet_manager.wallets and len(accounts_to_remove) < len(owners):
4✔
379
            accounts_to_remove = (
4✔
380
                accounts_to_remove | self.hw_wallet_manager.delete_accounts(owners)
381
            )
382

383
        if accounts_to_remove:
4✔
384
            print_formatted_text(
4✔
385
                HTML("<ansigreen>Accounts have been deleted</ansigreen>")
386
            )
387
        else:
388
            print_formatted_text(HTML("<ansired>No account was deleted</ansired>"))
×
389

390
    def show_cli_owners(self):
4✔
391
        accounts = self.accounts | self.hw_wallet_manager.wallets
×
392
        if not accounts:
×
393
            print_formatted_text(HTML("<ansired>No accounts loaded</ansired>"))
×
394
        else:
395
            for account in accounts:
×
396
                print_formatted_text(
×
397
                    HTML(
398
                        f"<ansigreen><b>Account</b> {account.address} loaded</ansigreen>"
399
                    )
400
                )
401
            if self.default_sender:
×
402
                print_formatted_text(
×
403
                    HTML(
404
                        f"<ansigreen><b>Default sender:</b> {self.default_sender.address}"
405
                        f"</ansigreen>"
406
                    )
407
                )
408
            elif self.hw_wallet_manager.sender:
×
409
                print_formatted_text(
×
410
                    HTML(
411
                        f"<ansigreen><b>HwDevice sender:</b> {self.hw_wallet_manager.sender}"
412
                        f"</ansigreen>"
413
                    )
414
                )
415
            else:
416
                print_formatted_text(
×
417
                    HTML("<ansigreen>Not default sender set </ansigreen>")
418
                )
419

420
    def approve_hash(self, hash_to_approve: HexBytes, sender: str) -> bool:
4✔
421
        sender_accounts = [
4✔
422
            account for account in self.accounts if account.address == sender
423
        ]
424
        if not sender_accounts:
4✔
425
            raise AccountNotLoadedException(sender)
4✔
426

427
        sender_account = sender_accounts[0]
4✔
428
        sender_account_address = sender_account.address
4✔
429
        if sender_account_address not in self.safe_cli_info.owners:
4✔
430
            raise NonExistingOwnerException(sender)
4✔
431
        elif self.safe.retrieve_is_hash_approved(
4✔
432
            sender_account_address, hash_to_approve
433
        ):
434
            raise HashAlreadyApproved(hash_to_approve, sender)
4✔
435
        transaction_to_send = self.safe_contract.functions.approveHash(
4✔
436
            hash_to_approve
437
        ).build_transaction(
438
            {
439
                "from": sender_account_address,
440
                "nonce": self.ethereum_client.get_nonce_for_account(
441
                    sender_account_address
442
                ),
443
            }
444
        )
445
        if self.ethereum_client.is_eip1559_supported():
4✔
446
            transaction_to_send = self.ethereum_client.set_eip1559_fees(
4✔
447
                transaction_to_send
448
            )
449
        call_result = self.ethereum_client.w3.eth.call(transaction_to_send)
4✔
450
        if call_result:  # There's revert message
4✔
NEW
451
            return False
×
452
        else:
453
            signed_transaction = sender_account.sign_transaction(transaction_to_send)
4✔
454
            tx_hash = self.ethereum_client.send_raw_transaction(
4✔
455
                signed_transaction["raw_transaction"]
456
            )
457
            print_formatted_text(
4✔
458
                HTML(
459
                    f"<ansigreen>Sent tx with tx-hash {to_0x_hex_str(tx_hash)} from owner "
460
                    f"{sender_account_address}, waiting for receipt</ansigreen>"
461
                )
462
            )
463
            if self.ethereum_client.get_transaction_receipt(tx_hash, timeout=120):
4✔
464
                return True
4✔
465
            else:
UNCOV
466
                print_formatted_text(
×
467
                    HTML(
468
                        f"<ansired>Tx with tx-hash {to_0x_hex_str(tx_hash)} still not mined</ansired>"
469
                    )
470
                )
NEW
471
                return False
×
472

473
    def sign_message(
4✔
474
        self,
475
        eip712_message_path: Optional[str] = None,
476
    ) -> bool:
477
        if eip712_message_path:
4✔
478
            try:
4✔
479
                message = json.load(open(eip712_message_path, "r"))
4✔
480
                message_bytes = b"".join(eip712_encode(message))
4✔
481
            except ValueError:
×
482
                raise ValueError
×
483
        else:
484
            print_formatted_text("EIP191 message to sign:")
4✔
485
            message = get_input()
4✔
486
            message_bytes = message.encode("UTF-8")
4✔
487

488
        safe_message_hash = self.safe.get_message_hash(message_bytes)
4✔
489

490
        sign_message_lib_address = get_last_sign_message_lib_address(
4✔
491
            self.ethereum_client
492
        )
493
        contract = get_sign_message_lib_contract(
4✔
494
            self.ethereum_client.w3, sign_message_lib_address
495
        )
496
        sign_message_data = HexBytes(
4✔
497
            contract.functions.signMessage(message_bytes).build_transaction(
498
                get_empty_tx_params(),
499
            )["data"]
500
        )
501
        print_formatted_text(HTML(f"Signing message: \n {message}"))
4✔
502
        if self.prepare_and_execute_safe_transaction(
4✔
503
            sign_message_lib_address,
504
            0,
505
            sign_message_data,
506
            operation=SafeOperationEnum.DELEGATE_CALL,
507
        ):
508
            print_formatted_text(
4✔
509
                HTML(
510
                    f"Message was signed correctly: {to_0x_hex_str(safe_message_hash)}"
511
                )
512
            )
513

514
    def confirm_message(self, safe_message_hash: bytes, sender: ChecksumAddress):
4✔
515
        return self._require_tx_service_mode()
×
516

517
    def add_owner(self, new_owner: str, threshold: Optional[int] = None) -> bool:
4✔
518
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
519
        if new_owner in self.safe_cli_info.owners:
4✔
520
            raise ExistingOwnerException(new_owner)
4✔
521
        else:
522
            # TODO Allow to set threshold
523
            transaction = self.safe_contract.functions.addOwnerWithThreshold(
4✔
524
                new_owner, threshold
525
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
526
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
527
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
528
                self.safe_cli_info.threshold = self.safe.retrieve_threshold()
4✔
529
                return True
4✔
530
            return False
×
531

532
    def remove_owner(self, owner_to_remove: str, threshold: Optional[int] = None):
4✔
533
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
534
        if owner_to_remove not in self.safe_cli_info.owners:
4✔
535
            raise NonExistingOwnerException(owner_to_remove)
4✔
536
        elif len(self.safe_cli_info.owners) == threshold:
4✔
537
            raise ThresholdLimitException()
×
538
        else:
539
            index_owner = self.safe_cli_info.owners.index(owner_to_remove)
4✔
540
            prev_owner = (
4✔
541
                self.safe_cli_info.owners[index_owner - 1]
542
                if index_owner
543
                else SENTINEL_ADDRESS
544
            )
545
            transaction = self.safe_contract.functions.removeOwner(
4✔
546
                prev_owner, owner_to_remove, threshold
547
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
548
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
549
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
550
                self.safe_cli_info.threshold = self.safe.retrieve_threshold()
4✔
551
                return True
4✔
552
            return False
×
553

554
    def send_custom(
4✔
555
        self,
556
        to: str,
557
        value: int,
558
        data: bytes,
559
        safe_nonce: Optional[int] = None,
560
        delegate_call: bool = False,
561
    ) -> bool:
562
        if value > 0:
4✔
563
            safe_balance = self.ethereum_client.get_balance(self.address)
4✔
564
            if safe_balance < value:
4✔
565
                raise NotEnoughEtherToSend(safe_balance)
4✔
566
        operation = (
4✔
567
            SafeOperationEnum.DELEGATE_CALL if delegate_call else SafeOperationEnum.CALL
568
        )
569
        return self.prepare_and_execute_safe_transaction(
4✔
570
            to, value, data, operation, safe_nonce=safe_nonce
571
        )
572

573
    def send_ether(self, to: str, value: int, **kwargs) -> bool:
4✔
574
        return self.send_custom(to, value, b"", **kwargs)
4✔
575

576
    def send_erc20(self, to: str, token_address: str, amount: int, **kwargs) -> bool:
4✔
577
        transaction = (
4✔
578
            get_erc20_contract(self.ethereum_client.w3, token_address)
579
            .functions.transfer(to, amount)
580
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
581
        )
582
        return self.send_custom(
4✔
583
            token_address, 0, HexBytes(transaction["data"]), **kwargs
584
        )
585

586
    def send_erc721(self, to: str, token_address: str, token_id: int, **kwargs) -> bool:
4✔
587
        transaction = (
4✔
588
            get_erc721_contract(self.ethereum_client.w3, token_address)
589
            .functions.transferFrom(self.address, to, token_id)
590
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
591
        )
592
        return self.send_custom(token_address, 0, transaction["data"], **kwargs)
4✔
593

594
    def change_fallback_handler(self, new_fallback_handler: str) -> bool:
4✔
595
        if new_fallback_handler == self.safe_cli_info.fallback_handler:
4✔
596
            raise SameFallbackHandlerException(new_fallback_handler)
4✔
597
        elif semantic_version.parse(
4✔
598
            self.safe_cli_info.version
599
        ) < semantic_version.parse("1.1.0"):
600
            raise FallbackHandlerNotSupportedException()
4✔
601
        elif (
4✔
602
            new_fallback_handler != NULL_ADDRESS
603
            and not self.ethereum_client.is_contract(new_fallback_handler)
604
        ):
605
            raise InvalidFallbackHandlerException(
4✔
606
                f"{new_fallback_handler} address is not a contract"
607
            )
608
        else:
609
            transaction = self.safe_contract.functions.setFallbackHandler(
4✔
610
                new_fallback_handler
611
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
612
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
613
                self.safe_cli_info.fallback_handler = new_fallback_handler
4✔
614
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
615
                return True
4✔
616

617
    def change_guard(self, guard: str) -> bool:
4✔
618
        if guard == self.safe_cli_info.guard:
4✔
619
            raise SameGuardException(guard)
4✔
620
        elif semantic_version.parse(
4✔
621
            self.safe_cli_info.version
622
        ) < semantic_version.parse("1.3.0"):
623
            raise GuardNotSupportedException()
4✔
624
        elif guard != NULL_ADDRESS and not self.ethereum_client.is_contract(guard):
4✔
625
            raise InvalidGuardException(f"{guard} address is not a contract")
4✔
626
        else:
627
            transaction = self.safe_contract.functions.setGuard(
4✔
628
                guard
629
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
630
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
631
                self.safe_cli_info.guard = guard
4✔
632
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
633
                return True
4✔
634

635
    def change_master_copy(self, new_master_copy: str) -> bool:
4✔
636
        if new_master_copy == self.safe_cli_info.master_copy:
4✔
637
            raise SameMasterCopyException(new_master_copy)
4✔
638
        else:
639
            safe_version = self.safe.retrieve_version()
4✔
640
            if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
641
                raise SafeVersionNotSupportedException(
4✔
642
                    f"{safe_version} cannot be updated (yet)"
643
                )
644

645
            try:
4✔
646
                Safe(new_master_copy, self.ethereum_client).retrieve_version()
4✔
647
            except BadFunctionCallOutput:
4✔
648
                raise InvalidMasterCopyException(new_master_copy)
4✔
649

650
            transaction = self.safe_contract_1_1_0.functions.changeMasterCopy(
4✔
651
                new_master_copy
652
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
653
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
654
                self.safe_cli_info.master_copy = new_master_copy
4✔
655
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
656
                return True
4✔
657

658
    def update_version(self) -> Optional[bool]:
4✔
659
        """
660
        Update Safe Master Copy and Fallback handler to the last version
661

662
        :return:
663
        """
664

665
        safe_version = self.safe.retrieve_version()
4✔
666
        if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
667
            raise SafeVersionNotSupportedException(
4✔
668
                f"{safe_version} cannot be updated (yet)"
669
            )
670

671
        if self.is_version_updated():
4✔
672
            raise SafeAlreadyUpdatedException(f"{safe_version} already updated")
×
673

674
        addresses = (
4✔
675
            self.last_safe_contract_address,
676
            self.last_default_fallback_handler_address,
677
        )
678
        if not all(
4✔
679
            self.ethereum_client.is_contract(contract) for contract in addresses
680
        ):
681
            raise UpdateAddressesNotValid(
×
682
                "Not valid addresses to update Safe", *addresses
683
            )
684

685
        multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
686
        tx_params = {"from": self.address, "gas": 0, "gasPrice": 0}
4✔
687
        multisend_txs = [
4✔
688
            MultiSendTx(MultiSendOperation.CALL, self.address, 0, data)
689
            for data in (
690
                self.safe_contract_1_1_0.functions.changeMasterCopy(
691
                    self.last_safe_contract_address
692
                ).build_transaction(tx_params)["data"],
693
                self.safe_contract_1_1_0.functions.setFallbackHandler(
694
                    self.last_default_fallback_handler_address
695
                ).build_transaction(tx_params)["data"],
696
            )
697
        ]
698

699
        multisend_data = multisend.build_tx_data(multisend_txs)
4✔
700

701
        if self.prepare_and_execute_safe_transaction(
4✔
702
            multisend.address,
703
            0,
704
            multisend_data,
705
            operation=SafeOperationEnum.DELEGATE_CALL,
706
        ):
707
            self.safe_cli_info.master_copy = self.last_safe_contract_address
4✔
708
            self.safe_cli_info.fallback_handler = (
4✔
709
                self.last_default_fallback_handler_address
710
            )
711
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
712

713
    def update_version_to_l2(
4✔
714
        self, migration_contract_address: ChecksumAddress
715
    ) -> Optional[bool]:
716
        """
717
        Update not L2 Safe to L2, so official UI supports it. Useful when replaying Safes deployed in
718
        non L2 networks (like mainnet) in L2 networks.
719
        Only v1.1.1, v1.3.0 and v1.4.1 versions are supported. Also, Safe nonce must be 0.
720

721
        :return:
722
        """
723

724
        if not self.ethereum_client.is_contract(migration_contract_address):
4✔
725
            raise InvalidMigrationContractException(
×
726
                f"Non L2 to L2 migration contract {migration_contract_address} is not deployed"
727
            )
728

729
        safe_version = self.safe.retrieve_version()
4✔
730
        chain_id = self.ethereum_client.get_chain_id()
4✔
731

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

735
        l2_migration_contract = self.ethereum_client.w3.eth.contract(
4✔
736
            NULL_ADDRESS, abi=safe_to_l2_migration["abi"]
737
        )
738
        if safe_version == "1.1.1":
4✔
739
            safe_l2_singleton = safe_deployments["1.3.0"]["GnosisSafeL2"][str(chain_id)]
4✔
740
            fallback_handler = safe_deployments["1.3.0"][
4✔
741
                "CompatibilityFallbackHandler"
742
            ][str(chain_id)]
743
            # Assuming first element of the array is the `canonical` address
744
            data = HexBytes(
4✔
745
                l2_migration_contract.functions.migrateFromV111(
746
                    safe_l2_singleton[0], fallback_handler[0]
747
                ).build_transaction(get_empty_tx_params())["data"]
748
            )
749
        elif safe_version in ("1.3.0", "1.4.1"):
4✔
750
            safe_l2_singleton = safe_deployments[safe_version]["GnosisSafeL2"][
4✔
751
                str(chain_id)
752
            ]
753
            fallback_handler = self.safe_cli_info.fallback_handler
4✔
754
            data = HexBytes(
4✔
755
                l2_migration_contract.functions.migrateToL2(
756
                    safe_l2_singleton[0]
757
                ).build_transaction(get_empty_tx_params())["data"]
758
            )
759
        else:
760
            raise InvalidMasterCopyException(
×
761
                "Current version is not supported to migrate to L2"
762
            )
763

764
        if self.prepare_and_execute_safe_transaction(
4✔
765
            migration_contract_address,
766
            0,
767
            data,
768
            operation=SafeOperationEnum.DELEGATE_CALL,
769
        ):
770
            self.safe_cli_info.master_copy = safe_l2_singleton
4✔
771
            self.safe_cli_info.fallback_handler = fallback_handler
4✔
772
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
773

774
    def change_threshold(self, threshold: int):
4✔
775
        if threshold == self.safe_cli_info.threshold:
4✔
776
            print_formatted_text(
×
777
                HTML(f"<ansired>Threshold is already {threshold}</ansired>")
778
            )
779
        elif threshold > len(self.safe_cli_info.owners):
4✔
780
            print_formatted_text(
×
781
                HTML(
782
                    f"<ansired>Threshold={threshold} bigger than number "
783
                    f"of owners={len(self.safe_cli_info.owners)}</ansired>"
784
                )
785
            )
786
        else:
787
            transaction = self.safe_contract.functions.changeThreshold(
4✔
788
                threshold
789
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
790

791
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
792
                self.safe_cli_info.threshold = self.safe.retrieve_threshold()
4✔
793

794
    def enable_module(self, module_address: str):
4✔
795
        if module_address in self.safe_cli_info.modules:
×
796
            print_formatted_text(
×
797
                HTML(f"<ansired>Module {module_address} is already enabled</ansired>")
798
            )
799
        else:
800
            transaction = self.safe_contract.functions.enableModule(
×
801
                module_address
802
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
803
            if self.execute_safe_internal_transaction(transaction["data"]):
×
804
                self.safe_cli_info.modules = self.safe.retrieve_modules()
×
805

806
    def disable_module(self, module_address: str):
4✔
807
        if module_address not in self.safe_cli_info.modules:
×
808
            print_formatted_text(
×
809
                HTML(f"<ansired>Module {module_address} is not enabled</ansired>")
810
            )
811
        else:
812
            pos = self.safe_cli_info.modules.index(module_address)
×
813
            if pos == 0:
×
814
                previous_address = SENTINEL_ADDRESS
×
815
            else:
816
                previous_address = self.safe_cli_info.modules[pos - 1]
×
817
            transaction = self.safe_contract.functions.disableModule(
×
818
                previous_address, module_address
819
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
820
            if self.execute_safe_internal_transaction(transaction["data"]):
×
821
                self.safe_cli_info.modules = self.safe.retrieve_modules()
×
822

823
    def print_info(self):
4✔
824
        for key, value in dataclasses.asdict(self.safe_cli_info).items():
4✔
825
            print_formatted_text(
4✔
826
                HTML(
827
                    f"<b><ansigreen>{key.capitalize()}</ansigreen></b>="
828
                    f"<ansiblue>{value}</ansiblue>"
829
                )
830
            )
831
        if self.ens_domain:
4✔
832
            print_formatted_text(
×
833
                HTML(
834
                    f"<b><ansigreen>Ens domain</ansigreen></b>="
835
                    f"<ansiblue>{self.ens_domain}</ansiblue>"
836
                )
837
            )
838
        if self.safe_tx_service:
4✔
839
            url = f"{self.safe_tx_service.base_url}/api/v1/safes/{self.address}/transactions/"
×
840
            print_formatted_text(
×
841
                HTML(
842
                    f"<b><ansigreen>Safe Tx Service</ansigreen></b>="
843
                    f"<ansiblue>{url}</ansiblue>"
844
                )
845
            )
846

847
        if self.etherscan:
4✔
848
            url = f"{self.etherscan.base_url}/address/{self.address}"
×
849
            print_formatted_text(
×
850
                HTML(
851
                    f"<b><ansigreen>Etherscan</ansigreen></b>="
852
                    f"<ansiblue>{url}</ansiblue>"
853
                )
854
            )
855

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

871
        if not self.hw_wallet_manager.is_supported_hw_wallet(HwWalletType.TREZOR):
4✔
872
            print_formatted_text(
×
873
                HTML(
874
                    "<b><ansigreen>Trezor</ansigreen></b>="
875
                    '<ansired>Disabled </ansired> <b>Optional trezor library is not installed, run pip install "safe-cli[trezor]" </b>'
876
                )
877
            )
878
        else:
879
            print_formatted_text(
4✔
880
                HTML(
881
                    "<b><ansigreen>Trezor</ansigreen></b>="
882
                    "<ansiblue>supported</ansiblue>"
883
                )
884
            )
885

886
        if not self.is_version_updated():
4✔
887
            print_formatted_text(
×
888
                HTML(
889
                    "<ansired>Safe is not updated! You can use <b>update</b> command to update "
890
                    "the Safe to a newest version</ansired>"
891
                )
892
            )
893

894
    def get_safe_cli_info(self) -> SafeCliInfo:
4✔
895
        safe = self.safe
4✔
896
        balance_ether = Web3.from_wei(
4✔
897
            self.ethereum_client.get_balance(self.address), "ether"
898
        )
899
        safe_info = safe.retrieve_all_info()
4✔
900
        return SafeCliInfo(
4✔
901
            self.address,
902
            safe_info.nonce,
903
            safe_info.threshold,
904
            safe_info.owners,
905
            safe_info.master_copy,
906
            safe_info.modules,
907
            safe_info.fallback_handler,
908
            safe_info.guard,
909
            balance_ether,
910
            safe_info.version,
911
        )
912

913
    def get_threshold(self) -> int:
4✔
NEW
914
        threshold = self.safe.retrieve_threshold()
×
NEW
915
        print_formatted_text(threshold)
×
NEW
916
        return threshold
×
917

918
    def get_nonce(self) -> int:
4✔
919
        nonce = self.safe.retrieve_nonce()
4✔
920
        print_formatted_text(nonce)
4✔
921
        return nonce
4✔
922

923
    def get_owners(self) -> List[ChecksumAddress]:
4✔
NEW
924
        owners = self.safe.retrieve_owners()
×
NEW
925
        print_formatted_text(owners)
×
NEW
926
        return owners
×
927

928
    def execute_safe_internal_transaction(self, data: bytes) -> bool:
4✔
929
        return self.prepare_and_execute_safe_transaction(self.address, 0, data)
4✔
930

931
    def prepare_safe_transaction(
4✔
932
        self,
933
        to: str,
934
        value: int,
935
        data: bytes,
936
        operation: SafeOperationEnum = SafeOperationEnum.CALL,
937
        safe_nonce: Optional[int] = None,
938
    ) -> SafeTx:
939
        safe_tx = self.safe.build_multisig_tx(
4✔
940
            to, value, data, operation=operation.value, safe_nonce=safe_nonce
941
        )
942
        self.sign_transaction(safe_tx)  # Raises exception if it cannot be signed
4✔
943
        return safe_tx
4✔
944

945
    def prepare_and_execute_safe_transaction(
4✔
946
        self,
947
        to: str,
948
        value: int,
949
        data: bytes,
950
        operation: SafeOperationEnum = SafeOperationEnum.CALL,
951
        safe_nonce: Optional[int] = None,
952
    ) -> bool:
953
        safe_tx = self.prepare_safe_transaction(
4✔
954
            to, value, data, operation, safe_nonce=safe_nonce
955
        )
956
        return self.execute_safe_transaction(safe_tx)
4✔
957

958
    @require_default_sender  # Throws Exception if default sender not found
4✔
959
    def execute_safe_transaction(self, safe_tx: SafeTx):
4✔
960
        try:
4✔
961
            if self.default_sender:
4✔
962
                call_result = safe_tx.call(self.default_sender.address)
4✔
963
            else:
964
                call_result = safe_tx.call(self.hw_wallet_manager.sender.address)
×
965
            print_formatted_text(HTML(f"Result: <ansigreen>{call_result}</ansigreen>"))
4✔
966
            if not self.interactive or yes_or_no_question(
4✔
967
                "Do you want to execute tx " + str(safe_tx)
968
            ):
969
                if self.default_sender:
4✔
970
                    tx_hash, tx = safe_tx.execute(
4✔
971
                        self.default_sender.key, eip1559_speed=TxSpeed.NORMAL
972
                    )
973
                else:
974
                    tx_hash, tx = self.hw_wallet_manager.execute_safe_tx(
×
975
                        safe_tx, eip1559_speed=TxSpeed.NORMAL
976
                    )
977
                self.executed_transactions.append(to_0x_hex_str(tx_hash))
4✔
978
                print_formatted_text(
4✔
979
                    HTML(
980
                        f"<ansigreen>Sent tx with tx-hash {to_0x_hex_str(tx_hash)} "
981
                        f"and safe-nonce {safe_tx.safe_nonce}, waiting for receipt</ansigreen>"
982
                    )
983
                )
984
                tx_receipt = self.ethereum_client.get_transaction_receipt(
4✔
985
                    tx_hash, timeout=120
986
                )
987
                if tx_receipt:
4✔
988
                    fees = self.ethereum_client.w3.from_wei(
4✔
989
                        tx_receipt["gasUsed"]
990
                        * tx_receipt.get("effectiveGasPrice", tx.get("gasPrice", 0)),
991
                        "ether",
992
                    )
993
                    print_formatted_text(
4✔
994
                        HTML(
995
                            f"<ansigreen>Tx was executed on block-number={tx_receipt['blockNumber']}, fees "
996
                            f"deducted={fees}</ansigreen>"
997
                        )
998
                    )
999
                    self.safe_cli_info.nonce += 1
4✔
1000
                    return True
4✔
1001
                else:
1002
                    print_formatted_text(
×
1003
                        HTML(
1004
                            f"<ansired>Tx with tx-hash {to_0x_hex_str(tx_hash)} still not mined</ansired>"
1005
                        )
1006
                    )
1007
        except InvalidInternalTx as invalid_internal_tx:
4✔
1008
            print_formatted_text(
×
1009
                HTML(f"Result: <ansired>InvalidTx - {invalid_internal_tx}</ansired>")
1010
            )
1011
        return False
×
1012

1013
    # Batch_transactions multisend
1014
    def batch_safe_txs(
4✔
1015
        self, safe_nonce: int, safe_txs: Sequence[SafeTx]
1016
    ) -> Optional[SafeTx]:
1017
        """
1018
        Submit signatures to the tx service. It's recommended to be on Safe v1.3.0 to prevent issues
1019
        with `safeTxGas` and gas estimation.
1020

1021
        :return:
1022
        """
1023

1024
        try:
4✔
1025
            multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
1026
        except ValueError:
4✔
1027
            if not self.interactive:
4✔
1028
                raise SafeOperatorException(
4✔
1029
                    "Multisend contract is not deployed on this network and it's required for batching txs"
1030
                )
1031
            multisend = None
×
1032
            print_formatted_text(
×
1033
                HTML(
1034
                    "<ansired>Multisend contract is not deployed on this network and it's required for "
1035
                    "batching txs</ansired>"
1036
                )
1037
            )
1038

1039
        multisend_txs = []
4✔
1040
        for safe_tx in safe_txs:
4✔
1041
            # Check if call is already a Multisend call
1042
            inner_txs = MultiSend.from_transaction_data(safe_tx.data)
4✔
1043
            if inner_txs:
4✔
1044
                multisend_txs.extend(inner_txs)
×
1045
            else:
1046
                multisend_txs.append(
4✔
1047
                    MultiSendTx(
1048
                        MultiSendOperation.CALL, safe_tx.to, safe_tx.value, safe_tx.data
1049
                    )
1050
                )
1051

1052
        if len(multisend_txs) == 1:
4✔
1053
            safe_tx.safe_tx_gas = 0
×
1054
            safe_tx.base_gas = 0
×
1055
            safe_tx.gas_price = 0
×
1056
            safe_tx.signatures = b""
×
1057
            safe_tx.safe_nonce = safe_nonce  # Resend single transaction
×
1058
        elif multisend:
4✔
1059
            safe_tx = SafeTx(
4✔
1060
                self.ethereum_client,
1061
                self.address,
1062
                multisend.address,
1063
                0,
1064
                multisend.build_tx_data(multisend_txs),
1065
                SafeOperationEnum.DELEGATE_CALL.value,
1066
                0,
1067
                0,
1068
                0,
1069
                None,
1070
                None,
1071
                safe_nonce=safe_nonce,
1072
            )
1073
        else:
1074
            # Multisend not defined
1075
            return None
×
1076

1077
        safe_tx = self.sign_transaction(safe_tx)
4✔
1078
        if not safe_tx.signatures:
4✔
1079
            print_formatted_text(
×
1080
                HTML("<ansired>At least one owner must be loaded</ansired>")
1081
            )
1082
            return None
×
1083
        else:
1084
            return safe_tx
4✔
1085

1086
    def get_signers(self) -> Tuple[List[LocalAccount], List[HwWallet]]:
4✔
1087
        """
1088
        Get the signers necessary to sign a transaction, raise an exception if was not uploaded enough signers.
1089

1090
        :return: Tuple with eoa signers and hw_wallet signers
1091
        """
1092
        permitted_signers = self.get_permitted_signers()
4✔
1093
        threshold = self.safe_cli_info.threshold
4✔
1094
        eoa_signers: List[Account] = (
4✔
1095
            []
1096
        )  # Some accounts that are not an owner can be loaded
1097
        for account in self.accounts:
4✔
1098
            if account.address in permitted_signers:
4✔
1099
                eoa_signers.append(account)
4✔
1100
                threshold -= 1
4✔
1101
                if threshold == 0:
4✔
1102
                    break
4✔
1103
        # If still pending required signatures continue with ledger owners
1104
        hw_wallet_signers = []
4✔
1105
        if threshold > 0 and self.hw_wallet_manager.wallets:
4✔
1106
            for hw_wallet in self.hw_wallet_manager.wallets:
4✔
1107
                if hw_wallet.address in permitted_signers:
4✔
1108
                    hw_wallet_signers.append(hw_wallet)
4✔
1109
                    threshold -= 1
4✔
1110
                    if threshold == 0:
4✔
1111
                        break
4✔
1112

1113
        if self.require_all_signatures and threshold > 0:
4✔
1114
            raise NotEnoughSignatures(threshold)
4✔
1115

1116
        return (eoa_signers, hw_wallet_signers)
4✔
1117

1118
    # TODO Set sender so we can save gas in that signature
1119
    def sign_transaction(self, safe_tx: SafeTx) -> SafeTx:
4✔
1120
        eoa_signers, hw_wallets_signers = self.get_signers()
4✔
1121
        for selected_account in eoa_signers:
4✔
1122
            safe_tx.sign(selected_account.key)
4✔
1123

1124
        # Sign with ledger
1125
        if len(hw_wallets_signers):
4✔
1126
            safe_tx = self.hw_wallet_manager.sign_safe_tx(safe_tx, hw_wallets_signers)
4✔
1127

1128
        return safe_tx
4✔
1129

1130
    @require_tx_service
4✔
1131
    def _require_tx_service_mode(self):
4✔
1132
        print_formatted_text(
×
1133
            HTML(
1134
                "<ansired>First enter tx-service mode using <b>tx-service</b> command</ansired>"
1135
            )
1136
        )
1137

1138
    def get_delegates(self):
4✔
1139
        return self._require_tx_service_mode()
×
1140

1141
    def add_delegate(self, delegate_address: str, label: str, signer_address: str):
4✔
1142
        return self._require_tx_service_mode()
×
1143

1144
    def remove_delegate(self, delegate_address: str, signer_address: str):
4✔
1145
        return self._require_tx_service_mode()
×
1146

1147
    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
4✔
1148
        return self._require_tx_service_mode()
×
1149

1150
    def get_balances(self):
4✔
1151
        return self._require_tx_service_mode()
×
1152

1153
    def get_transaction_history(self):
4✔
1154
        return self._require_tx_service_mode()
×
1155

1156
    def batch_txs(self, safe_nonce: int, safe_tx_hashes: Sequence[bytes]) -> bool:
4✔
1157
        return self._require_tx_service_mode()
×
1158

1159
    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
4✔
1160
        return self._require_tx_service_mode()
×
1161

1162
    def get_permitted_signers(self) -> Set[ChecksumAddress]:
4✔
1163
        """
1164
        :return: Accounts that can sign a transaction
1165
        """
1166
        return set(self.safe_cli_info.owners)
4✔
1167

1168
    def drain(self, to: str):
4✔
1169
        # Getting all events related with ERC20 transfers
1170
        last = self.ethereum_client.get_block("latest")["number"]
4✔
1171
        token_addresses = get_erc_20_list(self.ethereum_client, self.address, 1, last)
4✔
1172
        safe_txs = []
4✔
1173
        for token_address in token_addresses:
4✔
1174
            balance = self.ethereum_client.erc20.get_balance(
4✔
1175
                self.address, token_address
1176
            )
1177
            if balance > 0:
4✔
1178
                transaction = (
4✔
1179
                    get_erc20_contract(self.ethereum_client.w3, token_address)
1180
                    .functions.transfer(to, balance)
1181
                    .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
1182
                )
1183

1184
                safe_tx = self.prepare_safe_transaction(
4✔
1185
                    token_address,
1186
                    0,
1187
                    HexBytes(transaction["data"]),
1188
                    SafeOperationEnum.CALL,
1189
                    safe_nonce=None,
1190
                )
1191
                safe_txs.append(safe_tx)
4✔
1192

1193
        # Getting ethereum balance
1194
        balance_eth = self.ethereum_client.get_balance(self.address)
4✔
1195
        if balance_eth:
4✔
1196
            safe_tx = self.prepare_safe_transaction(
4✔
1197
                to,
1198
                balance_eth,
1199
                b"",
1200
                SafeOperationEnum.CALL,
1201
                safe_nonce=None,
1202
            )
1203
            safe_txs.append(safe_tx)
4✔
1204

1205
        if safe_txs:
4✔
1206
            multisend_tx = self.batch_safe_txs(self.get_nonce(), safe_txs)
4✔
1207
            if multisend_tx is not None:
4✔
1208
                if self.execute_safe_transaction(multisend_tx):
4✔
1209
                    print_formatted_text(
4✔
1210
                        HTML(
1211
                            "<ansigreen>Transaction to drain account correctly executed</ansigreen>"
1212
                        )
1213
                    )
1214
        else:
1215
            print_formatted_text(
×
1216
                HTML("<ansigreen>Safe account is currently empty</ansigreen>")
1217
            )
1218

1219
    def remove_proposed_transaction(self, safe_tx_hash: bytes):
4✔
1220
        return self._require_tx_service_mode()
×
1221

1222
    def process_command(self, first_command: str, rest_command: List[str]) -> bool:
4✔
1223
        if first_command == "help":
×
1224
            print_formatted_text("I still cannot help you")
×
1225
        elif first_command == "refresh":
×
1226
            print_formatted_text("Reloading Safe information")
×
1227
            self.refresh_safe_cli_info()
×
1228

1229
        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