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

safe-global / safe-cli / 9698327897

27 Jun 2024 02:20PM CUT coverage: 88.505%. Remained the same
9698327897

Pull #428

github

web-flow
Merge 50dd01ae7 into e829cd6e3
Pull Request #428: Add typer dependence

819 of 938 branches covered (87.31%)

Branch coverage included in aggregate %.

2815 of 3168 relevant lines covered (88.86%)

3.55 hits per line

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

68.2
/src/safe_cli/operators/safe_tx_service_operator.py
1
import json
4✔
2
from itertools import chain
4✔
3
from typing import Any, Dict, List, Optional, Sequence, Set, Union
4✔
4

5
from colorama import Fore, Style
4✔
6
from eth_account.messages import defunct_hash_message
4✔
7
from eth_account.signers.local import LocalAccount
4✔
8
from eth_typing import ChecksumAddress
4✔
9
from hexbytes import HexBytes
4✔
10
from prompt_toolkit import HTML, print_formatted_text
4✔
11
from tabulate import tabulate
4✔
12

13
from gnosis.eth.contracts import get_erc20_contract
4✔
14
from gnosis.eth.eip712 import eip712_encode_hash
4✔
15
from gnosis.safe import SafeOperationEnum, SafeTx
4✔
16
from gnosis.safe.api import SafeAPIException
4✔
17
from gnosis.safe.api.transaction_service_api.transaction_service_messages import (
4✔
18
    get_remove_transaction_message,
19
)
20
from gnosis.safe.multi_send import MultiSend, MultiSendOperation, MultiSendTx
4✔
21
from gnosis.safe.safe_signature import SafeSignature, SafeSignatureEOA
4✔
22
from gnosis.safe.signatures import signature_to_bytes
4✔
23

24
from ..utils import get_input, yes_or_no_question
4✔
25
from . import SafeServiceNotAvailable
4✔
26
from .exceptions import AccountNotLoadedException, NonExistingOwnerException
4✔
27
from .hw_wallets.hw_wallet import HwWallet
4✔
28
from .safe_operator import SafeOperator
4✔
29

30

31
class SafeTxServiceOperator(SafeOperator):
4✔
32
    def __init__(self, address: str, node_url: str):
4✔
33
        super().__init__(address, node_url)
4✔
34
        if not self.safe_tx_service:
4✔
35
            raise SafeServiceNotAvailable(
4✔
36
                f"Cannot configure tx service for network {self.network.name}"
37
            )
38
        self.require_all_signatures = (
4✔
39
            False  # It doesn't require all signatures to be present to send a tx
40
        )
41

42
    def approve_hash(self, hash_to_approve: HexBytes, sender: str) -> bool:
4✔
43
        raise NotImplementedError("Not supported when using tx service")
44

45
    def sign_message(
4✔
46
        self,
47
        eip712_message_path: Optional[str] = None,
48
    ) -> bool:
49
        if eip712_message_path:
×
50
            try:
×
51
                message = json.load(open(eip712_message_path, "r"))
×
52
                message_hash = eip712_encode_hash(message)
×
53
            except ValueError:
×
54
                raise ValueError
×
55
        else:
56
            print_formatted_text("EIP191 message to sign:")
×
57
            message = get_input()
×
58
            message_hash = defunct_hash_message(text=message)
×
59

60
        safe_message_hash = self.safe.get_message_hash(message_hash)
×
61
        eoa_signers, hw_wallet_signers = self.get_signers()
×
62
        safe_signatures: List[SafeSignature] = []
×
63
        for eoa_signer in eoa_signers:
×
64
            signature_dict = eoa_signer.signHash(safe_message_hash)
×
65
            signature = signature_to_bytes(
×
66
                signature_dict["v"], signature_dict["r"], signature_dict["s"]
67
            )
68
            safe_signatures.append(SafeSignatureEOA(signature, safe_message_hash))
×
69

70
        signatures = SafeSignature.export_signatures(safe_signatures)
×
71

72
        if hw_wallet_signers:
×
73
            print_formatted_text(
×
74
                HTML(
75
                    "<ansired>Signing messages is not currently supported by hardware wallets</ansired>"
76
                )
77
            )
78
            return False
×
79

80
        if self.safe_tx_service.post_message(self.address, message, signatures):
×
81
            print_formatted_text(
×
82
                HTML(
83
                    f"<ansigreen>Message  with safe-message-hash {safe_message_hash.hex()} was correctly created on Safe Transaction Service</ansigreen>"
84
                )
85
            )
86
            return True
×
87
        else:
88
            print_formatted_text(
×
89
                HTML(
90
                    "<ansired>Something went wrong creating message on Safe Transaction Service</ansired>"
91
                )
92
            )
93
            return False
×
94

95
    def confirm_message(self, safe_message_hash: bytes, sender: ChecksumAddress):
4✔
96
        # GET message
97
        try:
4✔
98
            safe_message = self.safe_tx_service.get_message(safe_message_hash)
4✔
99
        except SafeAPIException:
×
100
            print_formatted_text(
×
101
                HTML(
102
                    f"<ansired>Message with hash {safe_message_hash.hex()} does not exist</ansired>"
103
                )
104
            )
105
        if not yes_or_no_question(
4✔
106
            f"Message: {safe_message['message']} \n Do you want to sign the following message?:"
107
        ):
108
            return False
×
109

110
        signer = self.search_account(sender)
4✔
111
        if not signer:
4✔
112
            print_formatted_text(
×
113
                HTML(f"<ansired>Owner with address {sender} was not loaded</ansired>")
114
            )
115

116
        if isinstance(signer, LocalAccount):
4✔
117
            signature = signer.signHash(safe_message_hash).signature
4✔
118
        else:
119
            print_formatted_text(
×
120
                HTML(
121
                    "<ansired>Signing messages is not currently supported by hardware wallets</ansired>"
122
                )
123
            )
124
            return False
×
125

126
        try:
4✔
127
            self.safe_tx_service.post_message_signature(safe_message_hash, signature)
4✔
128
        except SafeAPIException as e:
4✔
129
            print_formatted_text(
4✔
130
                HTML(f"<ansired>Message wasn't confirmed due an error: {e}</ansired>")
131
            )
132
            return False
4✔
133
        print_formatted_text(
4✔
134
            HTML(
135
                f"<ansigreen>Message with safe-message-hash {safe_message_hash.hex()} was correctly confirmed on Safe Transaction Service</ansigreen>"
136
            )
137
        )
138
        return True
4✔
139

140
    def get_delegates(self):
4✔
141
        delegates = self.safe_tx_service.get_delegates(self.address)
4✔
142
        headers = ["delegate", "delegator", "label"]
4✔
143
        rows = []
4✔
144
        for delegate in delegates:
4✔
145
            row = [delegate["delegate"], delegate["delegator"], delegate["label"]]
4✔
146
            rows.append(row)
4✔
147
        print(tabulate(rows, headers=headers))
4✔
148
        return rows
4✔
149

150
    def add_delegate(self, delegate_address: str, label: str, signer_address: str):
4✔
151
        signer_account = [
4✔
152
            account for account in self.accounts if account.address == signer_address
153
        ]
154
        if not signer_account:
4✔
155
            raise AccountNotLoadedException(signer_address)
×
156
        elif signer_address not in self.safe_cli_info.owners:
4✔
157
            raise NonExistingOwnerException(signer_address)
×
158
        else:
159
            signer_account = signer_account[0]
4✔
160
            try:
4✔
161
                self.safe_tx_service.add_delegate(
4✔
162
                    self.address, delegate_address, label, signer_account
163
                )
164
                return True
4✔
165
            except SafeAPIException:
×
166
                return False
×
167

168
    def remove_delegate(self, delegate_address: str, signer_address: str):
4✔
169
        signer_account = [
4✔
170
            account for account in self.accounts if account.address == signer_address
171
        ]
172
        if not signer_account:
4✔
173
            raise AccountNotLoadedException(signer_address)
×
174
        elif signer_address not in self.safe_cli_info.owners:
4✔
175
            raise NonExistingOwnerException(signer_address)
×
176
        else:
177
            signer_account = signer_account[0]
4✔
178
            try:
4✔
179
                self.safe_tx_service.remove_delegate(
4✔
180
                    self.address, delegate_address, signer_account
181
                )
182
                return True
4✔
183
            except SafeAPIException:
×
184
                return False
×
185

186
    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
4✔
187
        """
188
        Submit signatures to the tx service
189

190
        :return:
191
        """
192

193
        safe_tx, tx_hash = self.safe_tx_service.get_safe_transaction(safe_tx_hash)
4✔
194
        safe_tx.signatures = b""  # Don't post again existing signatures
4✔
195
        if tx_hash:
4✔
196
            print_formatted_text(
4✔
197
                HTML(
198
                    f"<ansired>Tx with safe-tx-hash {safe_tx_hash.hex()} "
199
                    f"has already been executed on {tx_hash.hex()}</ansired>"
200
                )
201
            )
202
        else:
203
            safe_tx = self.sign_transaction(safe_tx)
4✔
204
            if safe_tx.signers:
4✔
205
                self.safe_tx_service.post_signatures(safe_tx_hash, safe_tx.signatures)
4✔
206
                print_formatted_text(
4✔
207
                    HTML(
208
                        f"<ansigreen>{len(safe_tx.signers)} signatures were submitted to the tx service</ansigreen>"
209
                    )
210
                )
211
                return True
4✔
212
            else:
213
                print_formatted_text(
4✔
214
                    HTML(
215
                        "<ansired>Cannot generate signatures as there were no suitable signers</ansired>"
216
                    )
217
                )
218
        return False
4✔
219

220
    def batch_txs(self, safe_nonce: int, safe_tx_hashes: Sequence[bytes]) -> bool:
4✔
221
        """
222
        Submit signatures to the tx service. It's recommended to be on Safe v1.3.0 to prevent issues
223
        with `safeTxGas` and gas estimation.
224

225
        :return:
226
        """
227

228
        try:
4✔
229
            multisend = MultiSend(ethereum_client=self.ethereum_client)
4✔
230
        except ValueError:
×
231
            print_formatted_text(
×
232
                HTML(
233
                    "<ansired>Multisend contract is not deployed on this network and it's required for "
234
                    "batching txs</ansired>"
235
                )
236
            )
237

238
        multisend_txs = []
4✔
239
        for safe_tx_hash in safe_tx_hashes:
4✔
240
            safe_tx, _ = self.safe_tx_service.get_safe_transaction(safe_tx_hash)
4✔
241
            # Check if call is already a Multisend call
242
            inner_txs = MultiSend.from_transaction_data(safe_tx.data)
4✔
243
            if inner_txs:
4✔
244
                multisend_txs.extend(inner_txs)
×
245
            else:
246
                multisend_txs.append(
4✔
247
                    MultiSendTx(
248
                        MultiSendOperation.CALL, safe_tx.to, safe_tx.value, safe_tx.data
249
                    )
250
                )
251

252
        if len(multisend_txs) > 1:
4✔
253
            safe_tx = SafeTx(
×
254
                self.ethereum_client,
255
                self.address,
256
                multisend.address,
257
                0,
258
                multisend.build_tx_data(multisend_txs),
259
                SafeOperationEnum.DELEGATE_CALL.value,
260
                0,
261
                0,
262
                0,
263
                None,
264
                None,
265
                safe_nonce=safe_nonce,
266
            )
267
        else:
268
            safe_tx.safe_tx_gas = 0
4✔
269
            safe_tx.base_gas = 0
4✔
270
            safe_tx.gas_price = 0
4✔
271
            safe_tx.signatures = b""
4✔
272
            safe_tx.safe_nonce = safe_nonce  # Resend single transaction
4✔
273
        safe_tx = self.sign_transaction(safe_tx)
4✔
274
        if not safe_tx.signatures:
4✔
275
            print_formatted_text(
×
276
                HTML("<ansired>At least one owner must be loaded</ansired>")
277
            )
278
            return False
×
279
        else:
280
            return self.post_transaction_to_tx_service(safe_tx)
4✔
281

282
    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
4✔
283
        """
284
        Submit transaction on the tx-service to blockchain
285

286
        :return:
287
        """
288
        safe_tx, tx_hash = self.safe_tx_service.get_safe_transaction(safe_tx_hash)
×
289
        if tx_hash:
×
290
            print_formatted_text(
×
291
                HTML(
292
                    f"<ansired>Tx with safe-tx-hash {safe_tx_hash.hex()} "
293
                    f"has already been executed on {tx_hash.hex()}</ansired>"
294
                )
295
            )
296
        elif len(safe_tx.signers) < self.safe_cli_info.threshold:
×
297
            print_formatted_text(
×
298
                HTML(
299
                    f"<ansired>Number of signatures {len(safe_tx.signers)} "
300
                    f"must reach the threshold {self.safe_cli_info.threshold}</ansired>"
301
                )
302
            )
303
        else:
304
            if executed := self.execute_safe_transaction(safe_tx):
×
305
                self.refresh_safe_cli_info()
×
306
            return executed
×
307

308
    def get_balances(self):
4✔
309
        balances = self.safe_tx_service.get_balances(self.address)
4✔
310
        headers = ["name", "balance", "symbol", "decimals", "tokenAddress"]
4✔
311
        rows = []
4✔
312
        for balance in balances:
4✔
313
            if balance["tokenAddress"]:  # Token
4✔
314
                row = [
4✔
315
                    balance["token"]["name"],
316
                    f"{int(balance['balance']) / 10 ** int(balance['token']['decimals']):.5f}",
317
                    balance["token"]["symbol"],
318
                    balance["token"]["decimals"],
319
                    balance["tokenAddress"],
320
                ]
321
            else:  # Ether
322
                row = [
4✔
323
                    "ETHER",
324
                    f"{int(balance['balance']) / 10 ** 18:.5f}",
325
                    "Ξ",
326
                    18,
327
                    "",
328
                ]
329
            rows.append(row)
4✔
330
        print(tabulate(rows, headers=headers))
4✔
331
        return rows
4✔
332

333
    def get_transaction_history(self):
4✔
334
        transactions = self.safe_tx_service.get_transactions(self.address)
4✔
335
        headers = ["nonce", "to", "value", "transactionHash", "safeTxHash"]
4✔
336
        rows = []
4✔
337
        last_executed_tx = False
4✔
338
        for transaction in transactions:
4✔
339
            row = [transaction[header] for header in headers]
4✔
340
            data_decoded: Dict[str, Any] = transaction.get("dataDecoded")
4✔
341
            if data_decoded:
4✔
342
                row.append(self.safe_tx_service.data_decoded_to_text(data_decoded))
4✔
343
            if transaction["transactionHash"]:
4✔
344
                if not transaction["isSuccessful"]:
4✔
345
                    # Transaction failed
346
                    row[0] = Fore.RED + str(row[0])
×
347
                else:
348
                    row[0] = Fore.GREEN + str(
4✔
349
                        row[0]
350
                    )  # For executed transactions we use green
351
                    if not last_executed_tx:
4✔
352
                        row[0] = Style.BRIGHT + row[0]
4✔
353
                        last_executed_tx = True
4✔
354
            else:
355
                row[0] = Fore.YELLOW + str(
×
356
                    row[0]
357
                )  # For non executed transactions we use yellow
358

359
            row[0] = Style.RESET_ALL + row[0]  # Reset all just in case
4✔
360
            rows.append(row)
4✔
361

362
        headers.append("dataDecoded")
4✔
363
        headers[0] = Style.BRIGHT + headers[0]
4✔
364
        print(tabulate(rows, headers=headers))
4✔
365
        return rows
4✔
366

367
    def prepare_and_execute_safe_transaction(
4✔
368
        self,
369
        to: str,
370
        value: int,
371
        data: bytes,
372
        operation: SafeOperationEnum = SafeOperationEnum.CALL,
373
        safe_nonce: Optional[int] = None,
374
    ) -> bool:
375
        safe_tx = self.prepare_safe_transaction(
×
376
            to, value, data, operation, safe_nonce=safe_nonce
377
        )
378
        return self.post_transaction_to_tx_service(safe_tx)
×
379

380
    def post_transaction_to_tx_service(self, safe_tx: SafeTx) -> bool:
4✔
381
        if not yes_or_no_question(
4✔
382
            f"Do you want to send the tx with safe-tx-hash={safe_tx.safe_tx_hash.hex()} to Safe Transaction Service (it will not be executed) "
383
            + str(safe_tx)
384
        ):
385
            return False
×
386

387
        self.safe_tx_service.post_transaction(safe_tx)
4✔
388
        print_formatted_text(
4✔
389
            HTML(
390
                f"<ansigreen>Tx with safe-tx-hash={safe_tx.safe_tx_hash.hex()} was sent to Safe Transaction service</ansigreen>"
391
            )
392
        )
393
        return True
4✔
394

395
    def get_permitted_signers(self) -> Set[ChecksumAddress]:
4✔
396
        """
397
        :return: Owners and delegates, as they also can sign a transaction for the tx service
398
        """
399
        owners = super().get_permitted_signers()
4✔
400
        owners.update(
4✔
401
            [
402
                row["delegate"]
403
                for row in self.safe_tx_service.get_delegates(self.address)
404
            ]
405
        )
406
        return owners
4✔
407

408
    # Function that sends all assets to an account (to)
409
    def drain(self, to: ChecksumAddress):
4✔
410
        balances = self.safe_tx_service.get_balances(self.address)
×
411
        safe_txs = []
×
412
        safe_tx = None
×
413
        for balance in balances:
×
414
            amount = int(balance["balance"])
×
415
            if balance["tokenAddress"] is None:  # Then is ether
×
416
                if amount != 0:
×
417
                    safe_tx = self.prepare_safe_transaction(
×
418
                        to,
419
                        amount,
420
                        b"",
421
                        SafeOperationEnum.CALL,
422
                        safe_nonce=None,
423
                    )
424
            else:
425
                transaction = (
×
426
                    get_erc20_contract(self.ethereum_client.w3, balance["tokenAddress"])
427
                    .functions.transfer(to, amount)
428
                    .build_transaction({"from": self.address, "gas": 0, "gasPrice": 0})
429
                )
430
                safe_tx = self.prepare_safe_transaction(
×
431
                    balance["tokenAddress"],
432
                    0,
433
                    HexBytes(transaction["data"]),
434
                    SafeOperationEnum.CALL,
435
                    safe_nonce=None,
436
                )
437
            if safe_tx:
×
438
                safe_txs.append(safe_tx)
×
439
        if len(safe_txs) > 0:
×
440
            multisend_tx = self.batch_safe_txs(safe_tx.safe_nonce, safe_txs)
×
441
            if multisend_tx is not None:
×
442
                self.post_transaction_to_tx_service(multisend_tx)
×
443
                print_formatted_text(
×
444
                    HTML(
445
                        "<ansigreen>Transaction to drain account correctly created</ansigreen>"
446
                    )
447
                )
448
        else:
449
            print_formatted_text(
×
450
                HTML("<ansigreen>Safe account is currently empty</ansigreen>")
451
            )
452

453
    def search_account(
4✔
454
        self, address: ChecksumAddress
455
    ) -> Optional[Union[LocalAccount, HwWallet]]:
456
        """
457
        Search the provided address between loaded owners
458

459
        :param address:
460
        :return: LocalAccount or HwWallet of the provided address
461
        """
462
        for account in chain(self.accounts, self.hw_wallet_manager.wallets):
4✔
463
            if account.address == address:
4✔
464
                return account
4✔
465

466
    def remove_proposed_transaction(self, safe_tx_hash: bytes):
4✔
467
        eip712_message = get_remove_transaction_message(
4✔
468
            self.address, safe_tx_hash, self.ethereum_client.get_chain_id()
469
        )
470
        message_hash = eip712_encode_hash(eip712_message)
4✔
471
        try:
4✔
472
            safe_tx, _ = self.safe_tx_service.get_safe_transaction(safe_tx_hash)
4✔
473
            signer = self.search_account(safe_tx.proposer)
4✔
474
            if not signer:
4✔
475
                print_formatted_text(
4✔
476
                    HTML(
477
                        f"<ansired>The proposer with address: {safe_tx.proposer} was not loaded</ansired>"
478
                    )
479
                )
480
                return False
4✔
481

482
            if isinstance(signer, LocalAccount):
4✔
483
                signature = signer.signHash(message_hash).signature
4✔
484
            else:
485
                signature = self.hw_wallet_manager.sign_eip712(
×
486
                    eip712_message, [signer]
487
                )[0].signature
488

489
            if len(safe_tx.signers) >= self.safe.retrieve_threshold():
4✔
490
                print_formatted_text(
×
491
                    HTML(
492
                        "<ansired>The transaction has all the required signatures to be executed!!!\n"
493
                        "This means that the transaction can be executed by a 3rd party monitoring your Safe even after removal!\n"
494
                        f"Make sure you execute a transaction with nonce {safe_tx.safe_nonce} to void the current transaction"
495
                        "</ansired>"
496
                    )
497
                )
498

499
            if not yes_or_no_question(
4✔
500
                f"Do you want to remove the tx with safe-tx-hash={safe_tx.safe_tx_hash.hex()}"
501
            ):
502
                return False
×
503

504
            self.safe_tx_service.delete_transaction(safe_tx_hash.hex(), signature.hex())
4✔
505
            print_formatted_text(
4✔
506
                HTML(
507
                    f"<ansigreen>Transaction {safe_tx_hash.hex()} was removed correctly</ansigreen>"
508
                )
509
            )
510
            return True
4✔
511
        except SafeAPIException as e:
×
512
            print_formatted_text(
×
513
                HTML(f"<ansired>Transaction wasn't removed due an error: {e}</ansired>")
514
            )
515
            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

© 2025 Coveralls, Inc