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

safe-global / safe-cli / 7087242091

04 Dec 2023 01:23PM UTC coverage: 93.371%. Remained the same
7087242091

Pull #320

github

web-flow
Merge 489f75539 into 443cd5f78
Pull Request #320: Bump web3 from 6.11.3 to 6.11.4

1803 of 1931 relevant lines covered (93.37%)

3.69 hits per line

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

95.21
/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

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
            option = choose_option_from_list(
4✔
298
                "Select the owner address", ledger_accounts
4✔
299
            )
300
            if option is None:
4✔
301
                return None
302
            _, derivation_path = ledger_accounts[option]
4✔
303

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

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

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

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

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

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

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

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

472
    def send_ether(self, to: str, value: int, **kwargs) -> bool:
4✔
473
        return self.send_custom(to, value, b"", **kwargs)
4✔
474

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

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

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

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

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

544
            try:
4✔
545
                Safe(new_master_copy, self.ethereum_client).retrieve_version()
4✔
546
            except BadFunctionCallOutput:
4✔
547
                raise InvalidMasterCopyException(new_master_copy)
4✔
548

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

557
    def update_version(self) -> Optional[bool]:
4✔
558
        """
559
        Update Safe Master Copy and Fallback handler to the last version
560

561
        :return:
562
        """
563

564
        safe_version = self.safe.retrieve_version()
4✔
565
        if semantic_version.parse(safe_version) >= semantic_version.parse("1.3.0"):
4✔
566
            raise SafeVersionNotSupportedException(
4✔
567
                f"{safe_version} cannot be updated (yet)"
4✔
568
            )
569

570
        if self.is_version_updated():
4✔
571
            raise SafeAlreadyUpdatedException(f"{safe_version} already updated")
572

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

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

598
        multisend_data = multisend.build_tx_data(multisend_txs)
4✔
599

600
        if self.prepare_and_execute_safe_transaction(
4✔
601
            multisend.address, 0, multisend_data, operation=SafeOperation.DELEGATE_CALL
4✔
602
        ):
603
            self.safe_cli_info.master_copy = self.last_safe_contract_address
4✔
604
            self.safe_cli_info.fallback_handler = (
4✔
605
                self.last_default_fallback_handler_address
4✔
606
            )
607
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
608

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

617
        :return:
618
        """
619

620
        if not self.ethereum_client.is_contract(migration_contract_address):
4✔
621
            raise InvalidMigrationContractException(
622
                f"Non L2 to L2 migration contract {migration_contract_address} is not deployed"
623
            )
624

625
        safe_version = self.safe.retrieve_version()
4✔
626
        chain_id = self.ethereum_client.get_chain_id()
4✔
627

628
        if self.safe.retrieve_nonce() > 0:
4✔
629
            raise InvalidNonceException("Nonce must be 0 for non L2 to L2 migration")
630

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

659
        if self.prepare_and_execute_safe_transaction(
4✔
660
            migration_contract_address, 0, data, operation=SafeOperation.DELEGATE_CALL
4✔
661
        ):
662
            self.safe_cli_info.master_copy = safe_l2_singleton
4✔
663
            self.safe_cli_info.fallback_handler = fallback_handler
4✔
664
            self.safe_cli_info.version = self.safe.retrieve_version()
4✔
665

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

683
            if self.execute_safe_internal_transaction(transaction["data"]):
4✔
684
                self.safe_cli_info.threshold = threshold
4✔
685

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

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

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

739
        if self.etherscan:
4✔
740
            url = f"{self.etherscan.base_url}/address/{self.address}"
4✔
741
            print_formatted_text(
4✔
742
                HTML(
4✔
743
                    f"<b><ansigreen>Etherscan</ansigreen></b>="
4✔
744
                    f"<ansiblue>{url}</ansiblue>"
3✔
745
                )
746
            )
747

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

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

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

797
    def get_threshold(self):
4✔
798
        print_formatted_text(self.safe.retrieve_threshold())
799

800
    def get_nonce(self):
4✔
801
        print_formatted_text(self.safe.retrieve_nonce())
4✔
802

803
    def get_owners(self):
4✔
804
        print_formatted_text(self.safe.retrieve_owners())
805

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

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

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

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

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

889
        :return:
890
        """
891

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

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

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

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

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

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

976
        for selected_account in selected_accounts:
4✔
977
            safe_tx.sign(selected_account.key)
4✔
978

979
        # Sign with ledger
980
        if len(selected_ledger_accounts) > 0:
4✔
981
            safe_tx = self.ledger_manager.sign_eip712(safe_tx, selected_ledger_accounts)
982

983
        return safe_tx
4✔
984

985
    @require_tx_service
4✔
986
    def _require_tx_service_mode(self):
4✔
987
        print_formatted_text(
988
            HTML(
989
                "<ansired>First enter tx-service mode using <b>tx-service</b> command</ansired>"
990
            )
991
        )
992

993
    def get_delegates(self):
4✔
994
        return self._require_tx_service_mode()
995

996
    def add_delegate(self, delegate_address: str, label: str, signer_address: str):
4✔
997
        return self._require_tx_service_mode()
998

999
    def remove_delegate(self, delegate_address: str, signer_address: str):
4✔
1000
        return self._require_tx_service_mode()
1001

1002
    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
4✔
1003
        return self._require_tx_service_mode()
1004

1005
    def get_balances(self):
4✔
1006
        return self._require_tx_service_mode()
1007

1008
    def get_transaction_history(self):
4✔
1009
        return self._require_tx_service_mode()
1010

1011
    def batch_txs(self, safe_nonce: int, safe_tx_hashes: Sequence[bytes]) -> bool:
4✔
1012
        return self._require_tx_service_mode()
1013

1014
    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
4✔
1015
        return self._require_tx_service_mode()
1016

1017
    def get_permitted_signers(self) -> Set[ChecksumAddress]:
4✔
1018
        """
1019
        :return: Accounts that can sign a transaction
1020
        """
1021
        return set(self.safe_cli_info.owners)
4✔
1022

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

1039
                safe_tx = self.prepare_safe_transaction(
4✔
1040
                    token_address,
4✔
1041
                    0,
4✔
1042
                    HexBytes(transaction["data"]),
4✔
1043
                    SafeOperation.CALL,
4✔
1044
                    safe_nonce=None,
4✔
1045
                )
1046
                safe_txs.append(safe_tx)
4✔
1047

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

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

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

1081
        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