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

safe-global / safe-cli / 7209362052

14 Dec 2023 01:11PM UTC coverage: 93.463%. Remained the same
7209362052

push

github

moisses89
Fix pin matrix does not display

0 of 1 new or added line in 1 file covered. (0.0%)

1916 of 2050 relevant lines covered (93.46%)

3.7 hits per line

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

95.55
/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:
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 not self.default_sender and balance > 0:
4✔
264
                    print_formatted_text(
4✔
265
                        HTML(
4✔
266
                            f"Set account <b>{account.address}</b> as default sender of txs"
4✔
267
                        )
268
                    )
269
                    self.default_sender = account
4✔
270
            except ValueError:
4✔
271
                print_formatted_text(HTML(f"<ansired>Cannot load key={key}</ansired>"))
4✔
272

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

285
            option = choose_option_from_list(
4✔
286
                "Select the owner address", ledger_accounts
4✔
287
            )
288
            if option is None:
4✔
289
                return None
290
            _, derivation_path = ledger_accounts[option]
4✔
291

292
        address = self.hw_wallet_manager.add_account(hw_wallet_type, derivation_path)
4✔
293
        balance = self.ethereum_client.get_balance(address)
4✔
294
        print_formatted_text(
4✔
295
            HTML(
4✔
296
                f"Loaded account <b>{address}</b> "
4✔
297
                f'with balance={Web3.from_wei(balance, "ether")} ether.\n'
3✔
298
                f"Ledger account cannot be defined as sender"
299
            )
300
        )
301

302
    def load_ledger_cli_owners(
4✔
303
        self, derivation_path: str = None, legacy_account: bool = False
4✔
304
    ):
305
        self.load_hw_wallet(HwWalletType.LEDGER, derivation_path, legacy_account)
4✔
306

307
    def load_trezor_cli_owners(
4✔
308
        self, derivation_path: str = None, legacy_account: bool = False
4✔
309
    ):
310
        self.load_hw_wallet(HwWalletType.TREZOR, derivation_path, legacy_account)
311

312
    def unload_cli_owners(self, owners: List[str]):
4✔
313
        accounts_to_remove: Set[Account] = set()
4✔
314
        for owner in owners:
4✔
315
            for account in self.accounts:
4✔
316
                if account.address == owner:
4✔
317
                    if self.default_sender and self.default_sender.address == owner:
4✔
318
                        self.default_sender = None
4✔
319
                    accounts_to_remove.add(account)
4✔
320
                    break
4✔
321
        self.accounts = self.accounts.difference(accounts_to_remove)
4✔
322
        # Check if there are ledger owners
323
        if self.hw_wallet_manager.wallets and len(accounts_to_remove) < len(owners):
4✔
324
            accounts_to_remove = (
4✔
325
                accounts_to_remove | self.hw_wallet_manager.delete_accounts(owners)
4✔
326
            )
327

328
        if accounts_to_remove:
4✔
329
            print_formatted_text(
4✔
330
                HTML("<ansigreen>Accounts have been deleted</ansigreen>")
4✔
331
            )
332
        else:
333
            print_formatted_text(HTML("<ansired>No account was deleted</ansired>"))
334

335
    def show_cli_owners(self):
4✔
336
        accounts = self.accounts | self.hw_wallet_manager.wallets
337
        if not accounts:
338
            print_formatted_text(HTML("<ansired>No accounts loaded</ansired>"))
339
        else:
340
            for account in accounts:
341
                print_formatted_text(
342
                    HTML(
343
                        f"<ansigreen><b>Account</b> {account.address} loaded</ansigreen>"
344
                    )
345
                )
346
            if self.default_sender:
347
                print_formatted_text(
348
                    HTML(
349
                        f"<ansigreen><b>Default sender:</b> {self.default_sender.address}"
350
                        f"</ansigreen>"
351
                    )
352
                )
353
            else:
354
                print_formatted_text(
355
                    HTML("<ansigreen>Not default sender set </ansigreen>")
356
                )
357

358
    def approve_hash(self, hash_to_approve: HexBytes, sender: str) -> bool:
4✔
359
        sender_account = [
4✔
360
            account for account in self.accounts if account.address == sender
4✔
361
        ]
362
        if not sender_account:
4✔
363
            raise AccountNotLoadedException(sender)
4✔
364
        elif sender not in self.safe_cli_info.owners:
4✔
365
            raise NonExistingOwnerException(sender)
4✔
366
        elif self.safe.retrieve_is_hash_approved(
4✔
367
            self.default_sender.address, hash_to_approve
4✔
368
        ):
369
            raise HashAlreadyApproved(hash_to_approve, self.default_sender.address)
4✔
370
        else:
371
            sender_account = sender_account[0]
4✔
372
            transaction_to_send = self.safe_contract.functions.approveHash(
4✔
373
                hash_to_approve
4✔
374
            ).build_transaction(
2✔
375
                {
4✔
376
                    "from": sender_account.address,
4✔
377
                    "nonce": self.ethereum_client.get_nonce_for_account(
4✔
378
                        sender_account.address
4✔
379
                    ),
380
                }
381
            )
382
            if self.ethereum_client.is_eip1559_supported():
4✔
383
                transaction_to_send = self.ethereum_client.set_eip1559_fees(
4✔
384
                    transaction_to_send
4✔
385
                )
386
            call_result = self.ethereum_client.w3.eth.call(transaction_to_send)
4✔
387
            if call_result:  # There's revert message
4✔
388
                return False
389
            else:
390
                signed_transaction = sender_account.sign_transaction(
4✔
391
                    transaction_to_send
4✔
392
                )
393
                tx_hash = self.ethereum_client.send_raw_transaction(
4✔
394
                    signed_transaction["rawTransaction"]
4✔
395
                )
396
                print_formatted_text(
4✔
397
                    HTML(
4✔
398
                        f"<ansigreen>Sent tx with tx-hash {tx_hash.hex()} from owner "
4✔
399
                        f"{self.default_sender.address}, waiting for receipt</ansigreen>"
3✔
400
                    )
401
                )
402
                if self.ethereum_client.get_transaction_receipt(tx_hash, timeout=120):
4✔
403
                    return True
4✔
404
                else:
405
                    print_formatted_text(
406
                        HTML(
407
                            f"<ansired>Tx with tx-hash {tx_hash.hex()} still not mined</ansired>"
408
                        )
409
                    )
410
                    return False
411

412
    def add_owner(self, new_owner: str, threshold: Optional[int] = None) -> bool:
4✔
413
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
414
        if new_owner in self.safe_cli_info.owners:
4✔
415
            raise ExistingOwnerException(new_owner)
4✔
416
        else:
417
            # TODO Allow to set threshold
418
            transaction = self.safe_contract.functions.addOwnerWithThreshold(
4✔
419
                new_owner, threshold
4✔
420
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
421
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
422
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
423
                self.safe_cli_info.threshold = threshold
4✔
424
                return True
4✔
425
            return False
426

427
    def remove_owner(self, owner_to_remove: str, threshold: Optional[int] = None):
4✔
428
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
429
        if owner_to_remove not in self.safe_cli_info.owners:
4✔
430
            raise NonExistingOwnerException(owner_to_remove)
4✔
431
        elif len(self.safe_cli_info.owners) == threshold:
4✔
432
            raise ThresholdLimitException()
433
        else:
434
            index_owner = self.safe_cli_info.owners.index(owner_to_remove)
4✔
435
            prev_owner = (
4✔
436
                self.safe_cli_info.owners[index_owner - 1]
4✔
437
                if index_owner
4✔
438
                else SENTINEL_ADDRESS
4✔
439
            )
440
            transaction = self.safe_contract.functions.removeOwner(
4✔
441
                prev_owner, owner_to_remove, threshold
4✔
442
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
443
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
444
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
445
                self.safe_cli_info.threshold = threshold
4✔
446
                return True
4✔
447
            return False
448

449
    def send_custom(
4✔
450
        self,
451
        to: str,
4✔
452
        value: int,
4✔
453
        data: bytes,
4✔
454
        safe_nonce: Optional[int] = None,
4✔
455
        delegate_call: bool = False,
4✔
456
    ) -> bool:
4✔
457
        if value > 0:
4✔
458
            safe_balance = self.ethereum_client.get_balance(self.address)
4✔
459
            if safe_balance < value:
4✔
460
                raise NotEnoughEtherToSend(safe_balance)
4✔
461
        operation = SafeOperation.DELEGATE_CALL if delegate_call else SafeOperation.CALL
4✔
462
        return self.prepare_and_execute_safe_transaction(
4✔
463
            to, value, data, operation, safe_nonce=safe_nonce
4✔
464
        )
465

466
    def send_ether(self, to: str, value: int, **kwargs) -> bool:
4✔
467
        return self.send_custom(to, value, b"", **kwargs)
4✔
468

469
    def send_erc20(self, to: str, token_address: str, amount: int, **kwargs) -> bool:
4✔
470
        transaction = (
471
            get_erc20_contract(self.ethereum_client.w3, token_address)
472
            .functions.transfer(to, amount)
473
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
474
        )
475
        return self.send_custom(
476
            token_address, 0, HexBytes(transaction["data"]), **kwargs
477
        )
478

479
    def send_erc721(self, to: str, token_address: str, token_id: int, **kwargs) -> bool:
4✔
480
        transaction = (
481
            get_erc721_contract(self.ethereum_client.w3, token_address)
482
            .functions.transferFrom(self.address, to, token_id)
483
            .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
484
        )
485
        return self.send_custom(token_address, 0, transaction["data"], **kwargs)
486

487
    def change_fallback_handler(self, new_fallback_handler: str) -> bool:
4✔
488
        if new_fallback_handler == self.safe_cli_info.fallback_handler:
4✔
489
            raise SameFallbackHandlerException(new_fallback_handler)
4✔
490
        elif semantic_version.parse(
4✔
491
            self.safe_cli_info.version
4✔
492
        ) < semantic_version.parse("1.1.0"):
4✔
493
            raise FallbackHandlerNotSupportedException()
4✔
494
        elif (
2✔
495
            new_fallback_handler != NULL_ADDRESS
4✔
496
            and not self.ethereum_client.is_contract(new_fallback_handler)
4✔
497
        ):
498
            raise InvalidFallbackHandlerException(
4✔
499
                f"{new_fallback_handler} address is not a contract"
4✔
500
            )
501
        else:
502
            transaction = self.safe_contract.functions.setFallbackHandler(
4✔
503
                new_fallback_handler
4✔
504
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
505
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
506
                self.safe_cli_info.fallback_handler = new_fallback_handler
4✔
507
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
508
                return True
4✔
509

510
    def change_guard(self, guard: str) -> bool:
4✔
511
        if guard == self.safe_cli_info.guard:
4✔
512
            raise SameGuardException(guard)
4✔
513
        elif semantic_version.parse(
4✔
514
            self.safe_cli_info.version
4✔
515
        ) < semantic_version.parse("1.3.0"):
4✔
516
            raise GuardNotSupportedException()
4✔
517
        elif guard != NULL_ADDRESS and not self.ethereum_client.is_contract(guard):
4✔
518
            raise InvalidGuardException(f"{guard} address is not a contract")
4✔
519
        else:
520
            transaction = self.safe_contract.functions.setGuard(
4✔
521
                guard
4✔
522
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
523
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
524
                self.safe_cli_info.guard = guard
4✔
525
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
526
                return True
4✔
527

528
    def change_master_copy(self, new_master_copy: str) -> bool:
4✔
529
        if new_master_copy == self.safe_cli_info.master_copy:
4✔
530
            raise SameMasterCopyException(new_master_copy)
4✔
531
        else:
532
            safe_version = self.safe.retrieve_version()
4✔
533
            if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
534
                raise SafeVersionNotSupportedException(
4✔
535
                    f"{safe_version} cannot be updated (yet)"
4✔
536
                )
537

538
            try:
4✔
539
                Safe(new_master_copy, self.ethereum_client).retrieve_version()
4✔
540
            except BadFunctionCallOutput:
4✔
541
                raise InvalidMasterCopyException(new_master_copy)
4✔
542

543
            transaction = self.safe_contract_1_1_0.functions.changeMasterCopy(
4✔
544
                new_master_copy
4✔
545
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
546
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
547
                self.safe_cli_info.master_copy = new_master_copy
4✔
548
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
549
                return True
4✔
550

551
    def update_version(self) -> Optional[bool]:
4✔
552
        """
553
        Update Safe Master Copy and Fallback handler to the last version
554

555
        :return:
556
        """
557

558
        safe_version = self.safe.retrieve_version()
4✔
559
        if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
560
            raise SafeVersionNotSupportedException(
4✔
561
                f"{safe_version} cannot be updated (yet)"
4✔
562
            )
563

564
        if self.is_version_updated():
4✔
565
            raise SafeAlreadyUpdatedException(f"{safe_version} already updated")
566

567
        addresses = (
4✔
568
            self.last_safe_contract_address,
4✔
569
            self.last_default_fallback_handler_address,
4✔
570
        )
571
        if not all(
4✔
572
            self.ethereum_client.is_contract(contract) for contract in addresses
4✔
573
        ):
574
            raise UpdateAddressesNotValid(
575
                "Not valid addresses to update Safe", *addresses
576
            )
577

578
        multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
579
        tx_params = {"from": self.address, "gas": 0, "gasPrice": 0}
4✔
580
        multisend_txs = [
4✔
581
            MultiSendTx(MultiSendOperation.CALL, self.address, 0, data)
4✔
582
            for data in (
4✔
583
                self.safe_contract_1_1_0.functions.changeMasterCopy(
4✔
584
                    self.last_safe_contract_address
4✔
585
                ).build_transaction(tx_params)["data"],
4✔
586
                self.safe_contract_1_1_0.functions.setFallbackHandler(
4✔
587
                    self.last_default_fallback_handler_address
4✔
588
                ).build_transaction(tx_params)["data"],
4✔
589
            )
590
        ]
591

592
        multisend_data = multisend.build_tx_data(multisend_txs)
4✔
593

594
        if self.prepare_and_execute_safe_transaction(
4✔
595
            multisend.address, 0, multisend_data, operation=SafeOperation.DELEGATE_CALL
4✔
596
        ):
597
            self.safe_cli_info.master_copy = self.last_safe_contract_address
4✔
598
            self.safe_cli_info.fallback_handler = (
4✔
599
                self.last_default_fallback_handler_address
4✔
600
            )
601
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
602

603
    def update_version_to_l2(
4✔
604
        self, migration_contract_address: ChecksumAddress
4✔
605
    ) -> Optional[bool]:
4✔
606
        """
607
        Update not L2 Safe to L2, so official UI supports it. Useful when replaying Safes deployed in
608
        non L2 networks (like mainnet) in L2 networks.
609
        Only v1.1.1, v1.3.0 and v1.4.1 versions are supported. Also, Safe nonce must be 0.
610

611
        :return:
612
        """
613

614
        if not self.ethereum_client.is_contract(migration_contract_address):
4✔
615
            raise InvalidMigrationContractException(
616
                f"Non L2 to L2 migration contract {migration_contract_address} is not deployed"
617
            )
618

619
        safe_version = self.safe.retrieve_version()
4✔
620
        chain_id = self.ethereum_client.get_chain_id()
4✔
621

622
        if self.safe.retrieve_nonce() > 0:
4✔
623
            raise InvalidNonceException("Nonce must be 0 for non L2 to L2 migration")
624

625
        l2_migration_contract = self.ethereum_client.w3.eth.contract(
4✔
626
            NULL_ADDRESS, abi=safe_to_l2_migration["abi"]
4✔
627
        )
628
        if safe_version == "1.1.1":
4✔
629
            safe_l2_singleton = safe_deployments["1.3.0"]["GnosisSafeL2"][str(chain_id)]
4✔
630
            fallback_handler = safe_deployments["1.3.0"][
4✔
631
                "CompatibilityFallbackHandler"
4✔
632
            ][str(chain_id)]
4✔
633
            data = HexBytes(
4✔
634
                l2_migration_contract.functions.migrateFromV111(
4✔
635
                    safe_l2_singleton, fallback_handler
4✔
636
                ).build_transaction(get_empty_tx_params())["data"]
4✔
637
            )
638
        elif safe_version in ("1.3.0", "1.4.1"):
4✔
639
            safe_l2_singleton = safe_deployments[safe_version]["GnosisSafeL2"][
4✔
640
                str(chain_id)
4✔
641
            ]
642
            fallback_handler = self.safe_cli_info.fallback_handler
4✔
643
            data = HexBytes(
4✔
644
                l2_migration_contract.functions.migrateToL2(
4✔
645
                    safe_l2_singleton
4✔
646
                ).build_transaction(get_empty_tx_params())["data"]
4✔
647
            )
648
        else:
649
            raise InvalidMasterCopyException(
650
                "Current version is not supported to migrate to L2"
651
            )
652

653
        if self.prepare_and_execute_safe_transaction(
4✔
654
            migration_contract_address, 0, data, operation=SafeOperation.DELEGATE_CALL
4✔
655
        ):
656
            self.safe_cli_info.master_copy = safe_l2_singleton
4✔
657
            self.safe_cli_info.fallback_handler = fallback_handler
4✔
658
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
659

660
    def change_threshold(self, threshold: int):
4✔
661
        if threshold == self.safe_cli_info.threshold:
4✔
662
            print_formatted_text(
663
                HTML(f"<ansired>Threshold is already {threshold}</ansired>")
664
            )
665
        elif threshold > len(self.safe_cli_info.owners):
4✔
666
            print_formatted_text(
667
                HTML(
668
                    f"<ansired>Threshold={threshold} bigger than number "
669
                    f"of owners={len(self.safe_cli_info.owners)}</ansired>"
670
                )
671
            )
672
        else:
673
            transaction = self.safe_contract.functions.changeThreshold(
4✔
674
                threshold
4✔
675
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
676

677
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
678
                self.safe_cli_info.threshold = threshold
4✔
679

680
    def enable_module(self, module_address: str):
4✔
681
        if module_address in self.safe_cli_info.modules:
682
            print_formatted_text(
683
                HTML(f"<ansired>Module {module_address} is already enabled</ansired>")
684
            )
685
        else:
686
            transaction = self.safe_contract.functions.enableModule(
687
                module_address
688
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
689
            if self.execute_safe_internal_transaction(transaction["data"]):
690
                self.safe_cli_info.modules = self.safe.retrieve_modules()
691

692
    def disable_module(self, module_address: str):
4✔
693
        if module_address not in self.safe_cli_info.modules:
694
            print_formatted_text(
695
                HTML(f"<ansired>Module {module_address} is not enabled</ansired>")
696
            )
697
        else:
698
            pos = self.safe_cli_info.modules.index(module_address)
699
            if pos == 0:
700
                previous_address = SENTINEL_ADDRESS
701
            else:
702
                previous_address = self.safe_cli_info.modules[pos - 1]
703
            transaction = self.safe_contract.functions.disableModule(
704
                previous_address, module_address
705
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
706
            if self.execute_safe_internal_transaction(transaction["data"]):
707
                self.safe_cli_info.modules = self.safe.retrieve_modules()
708

709
    def print_info(self):
4✔
710
        for key, value in dataclasses.asdict(self.safe_cli_info).items():
4✔
711
            print_formatted_text(
4✔
712
                HTML(
4✔
713
                    f"<b><ansigreen>{key.capitalize()}</ansigreen></b>="
4✔
714
                    f"<ansiblue>{value}</ansiblue>"
3✔
715
                )
716
            )
717
        if self.ens_domain:
4✔
718
            print_formatted_text(
719
                HTML(
720
                    f"<b><ansigreen>Ens domain</ansigreen></b>="
721
                    f"<ansiblue>{self.ens_domain}</ansiblue>"
722
                )
723
            )
724
        if self.safe_tx_service:
4✔
725
            url = f"{self.safe_tx_service.base_url}/api/v1/safes/{self.address}/transactions/"
4✔
726
            print_formatted_text(
4✔
727
                HTML(
4✔
728
                    f"<b><ansigreen>Safe Tx Service</ansigreen></b>="
4✔
729
                    f"<ansiblue>{url}</ansiblue>"
3✔
730
                )
731
            )
732

733
        if self.etherscan:
4✔
734
            url = f"{self.etherscan.base_url}/address/{self.address}"
4✔
735
            print_formatted_text(
4✔
736
                HTML(
4✔
737
                    f"<b><ansigreen>Etherscan</ansigreen></b>="
4✔
738
                    f"<ansiblue>{url}</ansiblue>"
3✔
739
                )
740
            )
741

742
        if not self.hw_wallet_manager.is_supported_hw_wallet(HwWalletType.LEDGER):
4✔
743
            print_formatted_text(
744
                HTML(
745
                    "<b><ansigreen>Ledger</ansigreen></b>="
746
                    "<ansired>Disabled </ansired> <b>Optional ledger library is not installed, run pip install safe-cli[ledger] </b>"
747
                )
748
            )
749
        else:
750
            print_formatted_text(
4✔
751
                HTML(
4✔
752
                    "<b><ansigreen>Ledger</ansigreen></b>="
4✔
753
                    "<ansiblue>supported</ansiblue>"
754
                )
755
            )
756

757
        if not self.hw_wallet_manager.is_supported_hw_wallet(HwWalletType.TREZOR):
4✔
758
            print_formatted_text(
759
                HTML(
760
                    "<b><ansigreen>Trezor</ansigreen></b>="
761
                    "<ansired>Disabled </ansired> <b>Optional trezor library is not installed, run pip install safe-cli[trezor] </b>"
762
                )
763
            )
764
        else:
765
            print_formatted_text(
4✔
766
                HTML(
4✔
767
                    "<b><ansigreen>Trezor</ansigreen></b>="
4✔
768
                    "<ansiblue>supported</ansiblue>"
769
                )
770
            )
771

772
        if not self.is_version_updated():
4✔
773
            print_formatted_text(
774
                HTML(
775
                    "<ansired>Safe is not updated! You can use <b>update</b> command to update "
776
                    "the Safe to a newest version</ansired>"
777
                )
778
            )
779

780
    def get_safe_cli_info(self) -> SafeCliInfo:
4✔
781
        safe = self.safe
4✔
782
        balance_ether = Web3.from_wei(
4✔
783
            self.ethereum_client.get_balance(self.address), "ether"
4✔
784
        )
785
        safe_info = safe.retrieve_all_info()
4✔
786
        return SafeCliInfo(
4✔
787
            self.address,
4✔
788
            safe_info.nonce,
4✔
789
            safe_info.threshold,
4✔
790
            safe_info.owners,
4✔
791
            safe_info.master_copy,
4✔
792
            safe_info.modules,
4✔
793
            safe_info.fallback_handler,
4✔
794
            safe_info.guard,
4✔
795
            balance_ether,
4✔
796
            safe_info.version,
4✔
797
        )
798

799
    def get_threshold(self):
4✔
800
        print_formatted_text(self.safe.retrieve_threshold())
801

802
    def get_nonce(self):
4✔
803
        print_formatted_text(self.safe.retrieve_nonce())
4✔
804

805
    def get_owners(self):
4✔
806
        print_formatted_text(self.safe.retrieve_owners())
807

808
    def execute_safe_internal_transaction(self, data: bytes) -> bool:
4✔
809
        return self.prepare_and_execute_safe_transaction(self.address, 0, data)
4✔
810

811
    def prepare_safe_transaction(
4✔
812
        self,
813
        to: str,
4✔
814
        value: int,
4✔
815
        data: bytes,
4✔
816
        operation: SafeOperation = SafeOperation.CALL,
4✔
817
        safe_nonce: Optional[int] = None,
4✔
818
    ) -> SafeTx:
4✔
819
        safe_tx = self.safe.build_multisig_tx(
4✔
820
            to, value, data, operation=operation.value, safe_nonce=safe_nonce
4✔
821
        )
822
        self.sign_transaction(safe_tx)  # Raises exception if it cannot be signed
4✔
823
        return safe_tx
4✔
824

825
    def prepare_and_execute_safe_transaction(
4✔
826
        self,
827
        to: str,
4✔
828
        value: int,
4✔
829
        data: bytes,
4✔
830
        operation: SafeOperation = SafeOperation.CALL,
4✔
831
        safe_nonce: Optional[int] = None,
4✔
832
    ) -> bool:
4✔
833
        safe_tx = self.prepare_safe_transaction(
4✔
834
            to, value, data, operation, safe_nonce=safe_nonce
4✔
835
        )
836
        return self.execute_safe_transaction(safe_tx)
4✔
837

838
    @require_default_sender  # Throws Exception if default sender not found
4✔
839
    def execute_safe_transaction(self, safe_tx: SafeTx):
4✔
840
        try:
4✔
841
            call_result = safe_tx.call(self.default_sender.address)
4✔
842
            print_formatted_text(HTML(f"Result: <ansigreen>{call_result}</ansigreen>"))
4✔
843
            if yes_or_no_question("Do you want to execute tx " + str(safe_tx)):
4✔
844
                tx_hash, tx = safe_tx.execute(
4✔
845
                    self.default_sender.key, eip1559_speed=TxSpeed.NORMAL
4✔
846
                )
847
                self.executed_transactions.append(tx_hash.hex())
4✔
848
                print_formatted_text(
4✔
849
                    HTML(
4✔
850
                        f"<ansigreen>Sent tx with tx-hash {tx_hash.hex()} "
4✔
851
                        f"and safe-nonce {safe_tx.safe_nonce}, waiting for receipt</ansigreen>"
3✔
852
                    )
853
                )
854
                tx_receipt = self.ethereum_client.get_transaction_receipt(
4✔
855
                    tx_hash, timeout=120
4✔
856
                )
857
                if tx_receipt:
4✔
858
                    fees = self.ethereum_client.w3.from_wei(
4✔
859
                        tx_receipt["gasUsed"]
4✔
860
                        * tx_receipt.get("effectiveGasPrice", tx.get("gasPrice", 0)),
4✔
861
                        "ether",
4✔
862
                    )
863
                    print_formatted_text(
4✔
864
                        HTML(
4✔
865
                            f"<ansigreen>Tx was executed on block-number={tx_receipt['blockNumber']}, fees "
4✔
866
                            f"deducted={fees}</ansigreen>"
3✔
867
                        )
868
                    )
869
                    self.safe_cli_info.nonce += 1
4✔
870
                    return True
4✔
871
                else:
872
                    print_formatted_text(
873
                        HTML(
874
                            f"<ansired>Tx with tx-hash {tx_hash.hex()} still not mined</ansired>"
875
                        )
876
                    )
877
        except InvalidInternalTx as invalid_internal_tx:
878
            print_formatted_text(
879
                HTML(f"Result: <ansired>InvalidTx - {invalid_internal_tx}</ansired>")
880
            )
881
        return False
882

883
    # Batch_transactions multisend
884
    def batch_safe_txs(
4✔
885
        self, safe_nonce: int, safe_txs: Sequence[SafeTx]
4✔
886
    ) -> Optional[SafeTx]:
4✔
887
        """
888
        Submit signatures to the tx service. It's recommended to be on Safe v1.3.0 to prevent issues
889
        with `safeTxGas` and gas estimation.
890

891
        :return:
892
        """
893

894
        try:
4✔
895
            multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
896
        except ValueError:
897
            multisend = None
898
            print_formatted_text(
899
                HTML(
900
                    "<ansired>Multisend contract is not deployed on this network and it's required for "
901
                    "batching txs</ansired>"
902
                )
903
            )
904

905
        multisend_txs = []
4✔
906
        for safe_tx in safe_txs:
4✔
907
            # Check if call is already a Multisend call
908
            inner_txs = MultiSend.from_transaction_data(safe_tx.data)
4✔
909
            if inner_txs:
4✔
910
                multisend_txs.extend(inner_txs)
911
            else:
912
                multisend_txs.append(
4✔
913
                    MultiSendTx(
4✔
914
                        MultiSendOperation.CALL, safe_tx.to, safe_tx.value, safe_tx.data
4✔
915
                    )
916
                )
917

918
        if len(multisend_txs) == 1:
4✔
919
            safe_tx.safe_tx_gas = 0
920
            safe_tx.base_gas = 0
921
            safe_tx.gas_price = 0
922
            safe_tx.signatures = b""
923
            safe_tx.safe_nonce = safe_nonce  # Resend single transaction
924
        elif multisend:
4✔
925
            safe_tx = SafeTx(
4✔
926
                self.ethereum_client,
4✔
927
                self.address,
4✔
928
                multisend.address,
4✔
929
                0,
4✔
930
                multisend.build_tx_data(multisend_txs),
4✔
931
                SafeOperation.DELEGATE_CALL.value,
4✔
932
                0,
4✔
933
                0,
4✔
934
                0,
4✔
935
                None,
4✔
936
                None,
4✔
937
                safe_nonce=safe_nonce,
4✔
938
            )
939
        else:
940
            # Multisend not defined
941
            return None
942

943
        safe_tx = self.sign_transaction(safe_tx)
4✔
944
        if not safe_tx.signatures:
4✔
945
            print_formatted_text(
946
                HTML("<ansired>At least one owner must be loaded</ansired>")
947
            )
948
            return None
949
        else:
950
            return safe_tx
4✔
951

952
    # TODO Set sender so we can save gas in that signature
953
    def sign_transaction(self, safe_tx: SafeTx) -> SafeTx:
4✔
954
        permitted_signers = self.get_permitted_signers()
4✔
955
        threshold = self.safe_cli_info.threshold
4✔
956
        selected_accounts: List[
4✔
957
            Account
958
        ] = []  # Some accounts that are not an owner can be loaded
4✔
959
        for account in self.accounts:
4✔
960
            if account.address in permitted_signers:
4✔
961
                selected_accounts.append(account)
4✔
962
                threshold -= 1
4✔
963
                if threshold == 0:
4✔
964
                    break
4✔
965
        # If still pending required signatures continue with ledger owners
966
        selected_ledger_accounts = []
4✔
967
        if threshold > 0 and self.hw_wallet_manager.wallets:
4✔
968
            for ledger_account in self.hw_wallet_manager.wallets:
969
                if ledger_account.address in permitted_signers:
970
                    selected_ledger_accounts.append(ledger_account)
971
                    threshold -= 1
972
                    if threshold == 0:
973
                        break
974

975
        if self.require_all_signatures and threshold > 0:
4✔
976
            raise NotEnoughSignatures(threshold)
4✔
977

978
        for selected_account in selected_accounts:
4✔
979
            safe_tx.sign(selected_account.key)
4✔
980

981
        # Sign with ledger
982
        if len(selected_ledger_accounts) > 0:
4✔
983
            safe_tx = self.hw_wallet_manager.sign_eip712(
984
                safe_tx, selected_ledger_accounts
985
            )
986

987
        return safe_tx
4✔
988

989
    @require_tx_service
4✔
990
    def _require_tx_service_mode(self):
4✔
991
        print_formatted_text(
992
            HTML(
993
                "<ansired>First enter tx-service mode using <b>tx-service</b> command</ansired>"
994
            )
995
        )
996

997
    def get_delegates(self):
4✔
998
        return self._require_tx_service_mode()
999

1000
    def add_delegate(self, delegate_address: str, label: str, signer_address: str):
4✔
1001
        return self._require_tx_service_mode()
1002

1003
    def remove_delegate(self, delegate_address: str, signer_address: str):
4✔
1004
        return self._require_tx_service_mode()
1005

1006
    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
4✔
1007
        return self._require_tx_service_mode()
1008

1009
    def get_balances(self):
4✔
1010
        return self._require_tx_service_mode()
1011

1012
    def get_transaction_history(self):
4✔
1013
        return self._require_tx_service_mode()
1014

1015
    def batch_txs(self, safe_nonce: int, safe_tx_hashes: Sequence[bytes]) -> bool:
4✔
1016
        return self._require_tx_service_mode()
1017

1018
    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
4✔
1019
        return self._require_tx_service_mode()
1020

1021
    def get_permitted_signers(self) -> Set[ChecksumAddress]:
4✔
1022
        """
1023
        :return: Accounts that can sign a transaction
1024
        """
1025
        return set(self.safe_cli_info.owners)
4✔
1026

1027
    def drain(self, to: str):
4✔
1028
        # Getting all events related with ERC20 transfers
1029
        last = self.ethereum_client.get_block("latest")["number"]
4✔
1030
        token_addresses = get_erc_20_list(self.ethereum_client, self.address, 1, last)
4✔
1031
        safe_txs = []
4✔
1032
        for token_address in token_addresses:
4✔
1033
            balance = self.ethereum_client.erc20.get_balance(
4✔
1034
                self.address, token_address
4✔
1035
            )
1036
            if balance > 0:
4✔
1037
                transaction = (
4✔
1038
                    get_erc20_contract(self.ethereum_client.w3, token_address)
4✔
1039
                    .functions.transfer(to, balance)
4✔
1040
                    .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
1041
                )
1042

1043
                safe_tx = self.prepare_safe_transaction(
4✔
1044
                    token_address,
4✔
1045
                    0,
4✔
1046
                    HexBytes(transaction["data"]),
4✔
1047
                    SafeOperation.CALL,
4✔
1048
                    safe_nonce=None,
4✔
1049
                )
1050
                safe_txs.append(safe_tx)
4✔
1051

1052
        # Getting ethereum balance
1053
        balance_eth = self.ethereum_client.get_balance(self.address)
4✔
1054
        if balance_eth:
4✔
1055
            safe_tx = self.prepare_safe_transaction(
4✔
1056
                to,
4✔
1057
                balance_eth,
4✔
1058
                b"",
4✔
1059
                SafeOperation.CALL,
4✔
1060
                safe_nonce=None,
4✔
1061
            )
1062
            safe_txs.append(safe_tx)
4✔
1063

1064
        if safe_txs:
4✔
1065
            multisend_tx = self.batch_safe_txs(self.get_nonce(), safe_txs)
4✔
1066
            if multisend_tx is not None:
4✔
1067
                if self.execute_safe_transaction(multisend_tx):
4✔
1068
                    print_formatted_text(
4✔
1069
                        HTML(
4✔
1070
                            "<ansigreen>Transaction to drain account correctly executed</ansigreen>"
4✔
1071
                        )
1072
                    )
1073
        else:
1074
            print_formatted_text(
1075
                HTML("<ansigreen>Safe account is currently empty</ansigreen>")
1076
            )
1077

1078
    def process_command(self, first_command: str, rest_command: List[str]) -> bool:
4✔
1079
        if first_command == "help":
1080
            print_formatted_text("I still cannot help you")
1081
        elif first_command == "refresh":
1082
            print_formatted_text("Reloading Safe information")
1083
            self.refresh_safe_cli_info()
1084

1085
        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