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

safe-global / safe-cli / 7005690162

27 Nov 2023 01:27PM UTC coverage: 93.053%. Remained the same
7005690162

Pull #314

github

web-flow
Merge 707197e69 into b9fba9023
Pull Request #314: Bump safe-eth-py from 6.0.0b5 to 6.0.0b8

1768 of 1900 relevant lines covered (93.05%)

3.68 hits per line

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

95.15
/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_question, get_erc_20_list, yes_or_no_question
4✔
67

68
from ..contracts import safe_to_l2_migration
4✔
69

70

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

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

91

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

108
    return decorated
4✔
109

110

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

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

123
    return decorated
4✔
124

125

126
def load_ledger_manager():
4✔
127
    """
128
    Load ledgerManager if dependencies are installed
129
    :return: LedgerManager or None
130
    """
131
    try:
4✔
132
        from safe_cli.operators.hw_accounts.ledger_manager import LedgerManager
4✔
133

134
        return LedgerManager()
4✔
135
    except (ModuleNotFoundError, IOError):
×
136
        return None
×
137

138

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

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

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

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

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

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

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

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

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

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

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

237
            return semantic_version.parse(
4✔
238
                self.safe_cli_info.version
4✔
239
            ) >= semantic_version.parse(safe_contract_version)
4✔
240

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

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

285
    def load_ledger_cli_owners(
4✔
286
        self, derivation_path: str = None, legacy_account: bool = False
4✔
287
    ):
288
        if not self.ledger_manager:
4✔
289
            return None
290
        if derivation_path is None:
4✔
291
            ledger_accounts = self.ledger_manager.get_accounts(
4✔
292
                legacy_account=legacy_account
4✔
293
            )
294
            if len(ledger_accounts) == 0:
4✔
295
                return None
4✔
296

297
            for option, ledger_account in enumerate(ledger_accounts):
4✔
298
                address, _ = ledger_account
4✔
299
                print_formatted_text(HTML(f"{option} - <b>{address}</b> "))
4✔
300

301
            option = choose_option_question(
4✔
302
                "Select the owner address", len(ledger_accounts)
4✔
303
            )
304
            if option is None:
4✔
305
                return None
306
            _, derivation_path = ledger_accounts[option]
4✔
307

308
        address = self.ledger_manager.add_account(derivation_path)
4✔
309
        balance = self.ethereum_client.get_balance(address)
4✔
310
        print_formatted_text(
4✔
311
            HTML(
4✔
312
                f"Loaded account <b>{address}</b> "
4✔
313
                f'with balance={Web3.from_wei(balance, "ether")} ether.\n'
3✔
314
                f"Ledger account cannot be defined as sender"
315
            )
316
        )
317

318
    def unload_cli_owners(self, owners: List[str]):
4✔
319
        accounts_to_remove: Set[Account] = set()
4✔
320
        for owner in owners:
4✔
321
            for account in self.accounts:
4✔
322
                if account.address == owner:
4✔
323
                    if self.default_sender and self.default_sender.address == owner:
4✔
324
                        self.default_sender = None
4✔
325
                    accounts_to_remove.add(account)
4✔
326
                    break
4✔
327
        self.accounts = self.accounts.difference(accounts_to_remove)
4✔
328
        # Check if there are ledger owners
329
        if self.ledger_manager and len(accounts_to_remove) < len(owners):
4✔
330
            accounts_to_remove = (
4✔
331
                accounts_to_remove | self.ledger_manager.delete_accounts(owners)
4✔
332
            )
333

334
        if accounts_to_remove:
4✔
335
            print_formatted_text(
4✔
336
                HTML("<ansigreen>Accounts have been deleted</ansigreen>")
4✔
337
            )
338
        else:
339
            print_formatted_text(HTML("<ansired>No account was deleted</ansired>"))
340

341
    def show_cli_owners(self):
4✔
342
        accounts = (
343
            self.accounts | self.ledger_manager.accounts
344
            if self.ledger_manager
345
            else self.accounts
346
        )
347
        if not accounts:
348
            print_formatted_text(HTML("<ansired>No accounts loaded</ansired>"))
349
        else:
350
            for account in accounts:
351
                print_formatted_text(
352
                    HTML(
353
                        f"<ansigreen><b>Account</b> {account.address} loaded</ansigreen>"
354
                    )
355
                )
356
            if self.default_sender:
357
                print_formatted_text(
358
                    HTML(
359
                        f"<ansigreen><b>Default sender:</b> {self.default_sender.address}"
360
                        f"</ansigreen>"
361
                    )
362
                )
363
            else:
364
                print_formatted_text(
365
                    HTML("<ansigreen>Not default sender set </ansigreen>")
366
                )
367

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

422
    def add_owner(self, new_owner: str, threshold: Optional[int] = None) -> bool:
4✔
423
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
424
        if new_owner in self.safe_cli_info.owners:
4✔
425
            raise ExistingOwnerException(new_owner)
4✔
426
        else:
427
            # TODO Allow to set threshold
428
            transaction = self.safe_contract.functions.addOwnerWithThreshold(
4✔
429
                new_owner, threshold
4✔
430
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
431
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
432
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
433
                self.safe_cli_info.threshold = threshold
4✔
434
                return True
4✔
435
            return False
436

437
    def remove_owner(self, owner_to_remove: str, threshold: Optional[int] = None):
4✔
438
        threshold = threshold if threshold is not None else self.safe_cli_info.threshold
4✔
439
        if owner_to_remove not in self.safe_cli_info.owners:
4✔
440
            raise NonExistingOwnerException(owner_to_remove)
4✔
441
        elif len(self.safe_cli_info.owners) == threshold:
4✔
442
            raise ThresholdLimitException()
443
        else:
444
            index_owner = self.safe_cli_info.owners.index(owner_to_remove)
4✔
445
            prev_owner = (
4✔
446
                self.safe_cli_info.owners[index_owner - 1]
4✔
447
                if index_owner
4✔
448
                else SENTINEL_ADDRESS
4✔
449
            )
450
            transaction = self.safe_contract.functions.removeOwner(
4✔
451
                prev_owner, owner_to_remove, threshold
4✔
452
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
453
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
454
                self.safe_cli_info.owners = self.safe.retrieve_owners()
4✔
455
                self.safe_cli_info.threshold = threshold
4✔
456
                return True
4✔
457
            return False
458

459
    def send_custom(
4✔
460
        self,
461
        to: str,
4✔
462
        value: int,
4✔
463
        data: bytes,
4✔
464
        safe_nonce: Optional[int] = None,
4✔
465
        delegate_call: bool = False,
4✔
466
    ) -> bool:
4✔
467
        if value > 0:
4✔
468
            safe_balance = self.ethereum_client.get_balance(self.address)
4✔
469
            if safe_balance < value:
4✔
470
                raise NotEnoughEtherToSend(safe_balance)
4✔
471
        operation = SafeOperation.DELEGATE_CALL if delegate_call else SafeOperation.CALL
4✔
472
        return self.prepare_and_execute_safe_transaction(
4✔
473
            to, value, data, operation, safe_nonce=safe_nonce
4✔
474
        )
475

476
    def send_ether(self, to: str, value: int, **kwargs) -> bool:
4✔
477
        return self.send_custom(to, value, b"", **kwargs)
4✔
478

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

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

497
    def change_fallback_handler(self, new_fallback_handler: str) -> bool:
4✔
498
        if new_fallback_handler == self.safe_cli_info.fallback_handler:
4✔
499
            raise SameFallbackHandlerException(new_fallback_handler)
4✔
500
        elif semantic_version.parse(
4✔
501
            self.safe_cli_info.version
4✔
502
        ) < semantic_version.parse("1.1.0"):
4✔
503
            raise FallbackHandlerNotSupportedException()
4✔
504
        elif (
2✔
505
            new_fallback_handler != NULL_ADDRESS
4✔
506
            and not self.ethereum_client.is_contract(new_fallback_handler)
4✔
507
        ):
508
            raise InvalidFallbackHandlerException(
4✔
509
                f"{new_fallback_handler} address is not a contract"
4✔
510
            )
511
        else:
512
            transaction = self.safe_contract.functions.setFallbackHandler(
4✔
513
                new_fallback_handler
4✔
514
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
515
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
516
                self.safe_cli_info.fallback_handler = new_fallback_handler
4✔
517
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
518
                return True
4✔
519

520
    def change_guard(self, guard: str) -> bool:
4✔
521
        if guard == self.safe_cli_info.guard:
4✔
522
            raise SameGuardException(guard)
4✔
523
        elif semantic_version.parse(
4✔
524
            self.safe_cli_info.version
4✔
525
        ) < semantic_version.parse("1.3.0"):
4✔
526
            raise GuardNotSupportedException()
4✔
527
        elif guard != NULL_ADDRESS and not self.ethereum_client.is_contract(guard):
4✔
528
            raise InvalidGuardException(f"{guard} address is not a contract")
4✔
529
        else:
530
            transaction = self.safe_contract.functions.setGuard(
4✔
531
                guard
4✔
532
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
533
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
534
                self.safe_cli_info.guard = guard
4✔
535
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
536
                return True
4✔
537

538
    def change_master_copy(self, new_master_copy: str) -> bool:
4✔
539
        if new_master_copy == self.safe_cli_info.master_copy:
4✔
540
            raise SameMasterCopyException(new_master_copy)
4✔
541
        else:
542
            safe_version = self.safe.retrieve_version()
4✔
543
            if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
544
                raise SafeVersionNotSupportedException(
4✔
545
                    f"{safe_version} cannot be updated (yet)"
4✔
546
                )
547

548
            try:
4✔
549
                Safe(new_master_copy, self.ethereum_client).retrieve_version()
4✔
550
            except BadFunctionCallOutput:
4✔
551
                raise InvalidMasterCopyException(new_master_copy)
4✔
552

553
            transaction = self.safe_contract_1_1_0.functions.changeMasterCopy(
4✔
554
                new_master_copy
4✔
555
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
556
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
557
                self.safe_cli_info.master_copy = new_master_copy
4✔
558
                self.safe_cli_info.version = self.safe.retrieve_version()
4✔
559
                return True
4✔
560

561
    def update_version(self) -> Optional[bool]:
4✔
562
        """
563
        Update Safe Master Copy and Fallback handler to the last version
564

565
        :return:
566
        """
567

568
        safe_version = self.safe.retrieve_version()
4✔
569
        if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
570
            raise SafeVersionNotSupportedException(
4✔
571
                f"{safe_version} cannot be updated (yet)"
4✔
572
            )
573

574
        if self.is_version_updated():
4✔
575
            raise SafeAlreadyUpdatedException(f"{safe_version} already updated")
576

577
        addresses = (
4✔
578
            self.last_safe_contract_address,
4✔
579
            self.last_default_fallback_handler_address,
4✔
580
        )
581
        if not all(
4✔
582
            self.ethereum_client.is_contract(contract) for contract in addresses
4✔
583
        ):
584
            raise UpdateAddressesNotValid(
585
                "Not valid addresses to update Safe", *addresses
586
            )
587

588
        multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
589
        tx_params = {"from": self.address, "gas": 0, "gasPrice": 0}
4✔
590
        multisend_txs = [
4✔
591
            MultiSendTx(MultiSendOperation.CALL, self.address, 0, data)
4✔
592
            for data in (
4✔
593
                self.safe_contract_1_1_0.functions.changeMasterCopy(
4✔
594
                    self.last_safe_contract_address
4✔
595
                ).build_transaction(tx_params)["data"],
4✔
596
                self.safe_contract_1_1_0.functions.setFallbackHandler(
4✔
597
                    self.last_default_fallback_handler_address
4✔
598
                ).build_transaction(tx_params)["data"],
4✔
599
            )
600
        ]
601

602
        multisend_data = multisend.build_tx_data(multisend_txs)
4✔
603

604
        if self.prepare_and_execute_safe_transaction(
4✔
605
            multisend.address, 0, multisend_data, operation=SafeOperation.DELEGATE_CALL
4✔
606
        ):
607
            self.safe_cli_info.master_copy = self.last_safe_contract_address
4✔
608
            self.safe_cli_info.fallback_handler = (
4✔
609
                self.last_default_fallback_handler_address
4✔
610
            )
611
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
612

613
    def update_version_to_l2(
4✔
614
        self, migration_contract_address: ChecksumAddress
4✔
615
    ) -> Optional[bool]:
4✔
616
        """
617
        Update not L2 Safe to L2, so official UI supports it. Useful when replaying Safes deployed in
618
        non L2 networks (like mainnet) in L2 networks.
619
        Only v1.1.1, v1.3.0 and v1.4.1 versions are supported. Also, Safe nonce must be 0.
620

621
        :return:
622
        """
623

624
        if not self.ethereum_client.is_contract(migration_contract_address):
4✔
625
            raise InvalidMigrationContractException(
626
                f"Non L2 to L2 migration contract {migration_contract_address} is not deployed"
627
            )
628

629
        safe_version = self.safe.retrieve_version()
4✔
630
        chain_id = self.ethereum_client.get_chain_id()
4✔
631

632
        if self.safe.retrieve_nonce() > 0:
4✔
633
            raise InvalidNonceException("Nonce must be 0 for non L2 to L2 migration")
634

635
        l2_migration_contract = self.ethereum_client.w3.eth.contract(
4✔
636
            NULL_ADDRESS, abi=safe_to_l2_migration["abi"]
4✔
637
        )
638
        if safe_version == "1.1.1":
4✔
639
            safe_l2_singleton = safe_deployments["1.3.0"]["GnosisSafeL2"][str(chain_id)]
4✔
640
            fallback_handler = safe_deployments["1.3.0"][
4✔
641
                "CompatibilityFallbackHandler"
4✔
642
            ][str(chain_id)]
4✔
643
            data = HexBytes(
4✔
644
                l2_migration_contract.functions.migrateFromV111(
4✔
645
                    safe_l2_singleton, fallback_handler
4✔
646
                ).build_transaction(get_empty_tx_params())["data"]
4✔
647
            )
648
        elif safe_version in ("1.3.0", "1.4.1"):
4✔
649
            safe_l2_singleton = safe_deployments[safe_version]["GnosisSafeL2"][
4✔
650
                str(chain_id)
4✔
651
            ]
652
            fallback_handler = self.safe_cli_info.fallback_handler
4✔
653
            data = HexBytes(
4✔
654
                l2_migration_contract.functions.migrateToL2(
4✔
655
                    safe_l2_singleton
4✔
656
                ).build_transaction(get_empty_tx_params())["data"]
4✔
657
            )
658
        else:
659
            raise InvalidMasterCopyException(
660
                "Current version is not supported to migrate to L2"
661
            )
662

663
        if self.prepare_and_execute_safe_transaction(
4✔
664
            migration_contract_address, 0, data, operation=SafeOperation.DELEGATE_CALL
4✔
665
        ):
666
            self.safe_cli_info.master_copy = safe_l2_singleton
4✔
667
            self.safe_cli_info.fallback_handler = fallback_handler
4✔
668
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
669

670
    def change_threshold(self, threshold: int):
4✔
671
        if threshold == self.safe_cli_info.threshold:
4✔
672
            print_formatted_text(
673
                HTML(f"<ansired>Threshold is already {threshold}</ansired>")
674
            )
675
        elif threshold > len(self.safe_cli_info.owners):
4✔
676
            print_formatted_text(
677
                HTML(
678
                    f"<ansired>Threshold={threshold} bigger than number "
679
                    f"of owners={len(self.safe_cli_info.owners)}</ansired>"
680
                )
681
            )
682
        else:
683
            transaction = self.safe_contract.functions.changeThreshold(
4✔
684
                threshold
4✔
685
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
4✔
686

687
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
688
                self.safe_cli_info.threshold = threshold
4✔
689

690
    def enable_module(self, module_address: str):
4✔
691
        if module_address in self.safe_cli_info.modules:
692
            print_formatted_text(
693
                HTML(f"<ansired>Module {module_address} is already enabled</ansired>")
694
            )
695
        else:
696
            transaction = self.safe_contract.functions.enableModule(
697
                module_address
698
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
699
            if self.execute_safe_internal_transaction(transaction["data"]):
700
                self.safe_cli_info.modules = self.safe.retrieve_modules()
701

702
    def disable_module(self, module_address: str):
4✔
703
        if module_address not in self.safe_cli_info.modules:
704
            print_formatted_text(
705
                HTML(f"<ansired>Module {module_address} is not enabled</ansired>")
706
            )
707
        else:
708
            pos = self.safe_cli_info.modules.index(module_address)
709
            if pos == 0:
710
                previous_address = SENTINEL_ADDRESS
711
            else:
712
                previous_address = self.safe_cli_info.modules[pos - 1]
713
            transaction = self.safe_contract.functions.disableModule(
714
                previous_address, module_address
715
            ).build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
716
            if self.execute_safe_internal_transaction(transaction["data"]):
717
                self.safe_cli_info.modules = self.safe.retrieve_modules()
718

719
    def print_info(self):
4✔
720
        for key, value in dataclasses.asdict(self.safe_cli_info).items():
4✔
721
            print_formatted_text(
4✔
722
                HTML(
4✔
723
                    f"<b><ansigreen>{key.capitalize()}</ansigreen></b>="
4✔
724
                    f"<ansiblue>{value}</ansiblue>"
3✔
725
                )
726
            )
727
        if self.ens_domain:
4✔
728
            print_formatted_text(
729
                HTML(
730
                    f"<b><ansigreen>Ens domain</ansigreen></b>="
731
                    f"<ansiblue>{self.ens_domain}</ansiblue>"
732
                )
733
            )
734
        if self.safe_tx_service:
4✔
735
            url = f"{self.safe_tx_service.base_url}/api/v1/safes/{self.address}/transactions/"
736
            print_formatted_text(
737
                HTML(
738
                    f"<b><ansigreen>Safe Tx Service</ansigreen></b>="
739
                    f"<ansiblue>{url}</ansiblue>"
740
                )
741
            )
742

743
        if self.etherscan:
4✔
744
            url = f"{self.etherscan.base_url}/address/{self.address}"
745
            print_formatted_text(
746
                HTML(
747
                    f"<b><ansigreen>Etherscan</ansigreen></b>="
748
                    f"<ansiblue>{url}</ansiblue>"
749
                )
750
            )
751

752
        if not self.ledger_manager:
4✔
753
            print_formatted_text(
754
                HTML(
755
                    "<b><ansigreen>Ledger</ansigreen></b>="
756
                    "<ansired>Disabled </ansired> <b>Optional ledger library is not installed, run pip install safe-cli[ledger] </b>"
757
                )
758
            )
759
        elif self.ledger_manager.connected:
4✔
760
            print_formatted_text(
761
                HTML(
762
                    "<b><ansigreen>Ledger</ansigreen></b>="
763
                    "<ansiblue>Connected</ansiblue>"
764
                )
765
            )
766
        else:
767
            print_formatted_text(
4✔
768
                HTML(
4✔
769
                    "<b><ansigreen>Ledger</ansigreen></b>="
4✔
770
                    "<ansiblue>disconnected</ansiblue>"
771
                )
772
            )
773

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

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

801
    def get_threshold(self):
4✔
802
        print_formatted_text(self.safe.retrieve_threshold())
803

804
    def get_nonce(self):
4✔
805
        print_formatted_text(self.safe.retrieve_nonce())
4✔
806

807
    def get_owners(self):
4✔
808
        print_formatted_text(self.safe.retrieve_owners())
809

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

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

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

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

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

893
        :return:
894
        """
895

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

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

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

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

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

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

980
        for selected_account in selected_accounts:
4✔
981
            safe_tx.sign(selected_account.key)
4✔
982

983
        # Sign with ledger
984
        if len(selected_ledger_accounts) > 0:
4✔
985
            safe_tx = self.ledger_manager.sign_eip712(safe_tx, selected_ledger_accounts)
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