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

safe-global / safe-cli / 12011247560

25 Nov 2024 01:38PM CUT coverage: 88.612%. Remained the same
12011247560

Pull #469

github

web-flow
Merge d6e230fe3 into 0659e6cdb
Pull Request #469: Bump typer from 0.13.0 to 0.13.1

221 of 262 branches covered (84.35%)

Branch coverage included in aggregate %.

2868 of 3224 relevant lines covered (88.96%)

2.67 hits per line

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

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

5
from colorama import Fore, Style
3✔
6
from eth_account.messages import defunct_hash_message
3✔
7
from eth_account.signers.local import LocalAccount
3✔
8
from eth_typing import ChecksumAddress
3✔
9
from hexbytes import HexBytes
3✔
10
from prompt_toolkit import HTML, print_formatted_text
3✔
11
from safe_eth.eth.contracts import get_erc20_contract
3✔
12
from safe_eth.eth.eip712 import eip712_encode_hash
3✔
13
from safe_eth.safe import SafeOperationEnum, SafeTx
3✔
14
from safe_eth.safe.api import SafeAPIException
3✔
15
from safe_eth.safe.api.transaction_service_api.transaction_service_messages import (
3✔
16
    get_remove_transaction_message,
17
)
18
from safe_eth.safe.multi_send import MultiSend, MultiSendOperation, MultiSendTx
3✔
19
from safe_eth.safe.safe_signature import SafeSignature
3✔
20
from safe_eth.safe.signatures import signature_to_bytes
3✔
21
from tabulate import tabulate
3✔
22

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

29

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

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

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

59
        safe_message_hash = self.safe.get_message_hash(message_hash)
×
60
        eoa_signers, hw_wallet_signers = self.get_signers()
×
61
        # Safe transaction service just accept one signer to create a message
62
        signature = b""
×
63
        if eoa_signers:
×
64
            signature_dict = eoa_signers[0].signHash(safe_message_hash)
×
65
            signature = signature_to_bytes(
×
66
                signature_dict["v"], signature_dict["r"], signature_dict["s"]
67
            )
68

69
        elif hw_wallet_signers:
×
70
            signature = SafeSignature.export_signatures(
×
71
                self.hw_wallet_manager.sign_message(
72
                    safe_message_hash, [hw_wallet_signers[0]]
73
                )
74
            )
75
        else:
76
            print_formatted_text(
×
77
                HTML("<ansired>At least one owner must be loaded</ansired>")
78
            )
79

80
        if self.safe_tx_service.post_message(self.address, message, signature):
×
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):
3✔
96
        # GET message
97
        try:
3✔
98
            safe_message = self.safe_tx_service.get_message(safe_message_hash)
3✔
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(
3✔
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)
3✔
111
        if not signer:
3✔
112
            print_formatted_text(
×
113
                HTML(f"<ansired>Owner with address {sender} was not loaded</ansired>")
114
            )
115

116
        if isinstance(signer, LocalAccount):
3✔
117
            signature = signer.signHash(safe_message_hash).signature
3✔
118
        else:
119
            signature = SafeSignature.export_signatures(
×
120
                self.hw_wallet_manager.sign_message(safe_message_hash, [signer])
121
            )
122

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

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

147
    def add_delegate(self, delegate_address: str, label: str, signer_address: str):
3✔
148
        signer_account = [
3✔
149
            account for account in self.accounts if account.address == signer_address
150
        ]
151
        if not signer_account:
3✔
152
            raise AccountNotLoadedException(signer_address)
×
153
        elif signer_address not in self.safe_cli_info.owners:
3✔
154
            raise NonExistingOwnerException(signer_address)
×
155
        else:
156
            signer_account = signer_account[0]
3✔
157
            try:
3✔
158
                hash_to_sign = self.safe_tx_service.create_delegate_message_hash(
3✔
159
                    delegate_address
160
                )
161
                signature = signer_account.signHash(hash_to_sign)
3✔
162
                self.safe_tx_service.add_delegate(
3✔
163
                    delegate_address,
164
                    signer_account.address,
165
                    label,
166
                    signature.signature,
167
                    safe_address=self.address,
168
                )
169
                return True
3✔
170
            except SafeAPIException:
×
171
                return False
×
172

173
    def remove_delegate(self, delegate_address: str, signer_address: str):
3✔
174
        signer_account = [
3✔
175
            account for account in self.accounts if account.address == signer_address
176
        ]
177
        if not signer_account:
3✔
178
            raise AccountNotLoadedException(signer_address)
×
179
        elif signer_address not in self.safe_cli_info.owners:
3✔
180
            raise NonExistingOwnerException(signer_address)
×
181
        else:
182
            signer_account = signer_account[0]
3✔
183
            try:
3✔
184
                hash_to_sign = self.safe_tx_service.create_delegate_message_hash(
3✔
185
                    delegate_address
186
                )
187
                signature = signer_account.signHash(hash_to_sign)
3✔
188
                self.safe_tx_service.remove_delegate(
3✔
189
                    delegate_address,
190
                    signer_account.address,
191
                    signature.signature,
192
                    safe_address=self.address,
193
                )
194
                return True
3✔
195
            except SafeAPIException:
×
196
                return False
×
197

198
    def submit_signatures(self, safe_tx_hash: bytes) -> bool:
3✔
199
        """
200
        Submit signatures to the tx service
201

202
        :return:
203
        """
204

205
        safe_tx, tx_hash = self.safe_tx_service.get_safe_transaction(safe_tx_hash)
3✔
206
        safe_tx.signatures = b""  # Don't post again existing signatures
3✔
207
        if tx_hash:
3✔
208
            print_formatted_text(
3✔
209
                HTML(
210
                    f"<ansired>Tx with safe-tx-hash {safe_tx_hash.hex()} "
211
                    f"has already been executed on {tx_hash.hex()}</ansired>"
212
                )
213
            )
214
        else:
215
            safe_tx = self.sign_transaction(safe_tx)
3✔
216
            if safe_tx.signers:
3✔
217
                self.safe_tx_service.post_signatures(safe_tx_hash, safe_tx.signatures)
3✔
218
                print_formatted_text(
3✔
219
                    HTML(
220
                        f"<ansigreen>{len(safe_tx.signers)} signatures were submitted to the tx service</ansigreen>"
221
                    )
222
                )
223
                return True
3✔
224
            else:
225
                print_formatted_text(
3✔
226
                    HTML(
227
                        "<ansired>Cannot generate signatures as there were no suitable signers</ansired>"
228
                    )
229
                )
230
        return False
3✔
231

232
    def batch_txs(self, safe_nonce: int, safe_tx_hashes: Sequence[bytes]) -> bool:
3✔
233
        """
234
        Submit signatures to the tx service. It's recommended to be on Safe v1.3.0 to prevent issues
235
        with `safeTxGas` and gas estimation.
236

237
        :return:
238
        """
239

240
        try:
3✔
241
            multisend = MultiSend(ethereum_client=self.ethereum_client)
3✔
242
        except ValueError:
×
243
            print_formatted_text(
×
244
                HTML(
245
                    "<ansired>Multisend contract is not deployed on this network and it's required for "
246
                    "batching txs</ansired>"
247
                )
248
            )
249

250
        multisend_txs = []
3✔
251
        for safe_tx_hash in safe_tx_hashes:
3✔
252
            safe_tx, _ = self.safe_tx_service.get_safe_transaction(safe_tx_hash)
3✔
253
            # Check if call is already a Multisend call
254
            inner_txs = MultiSend.from_transaction_data(safe_tx.data)
3✔
255
            if inner_txs:
3✔
256
                multisend_txs.extend(inner_txs)
×
257
            else:
258
                multisend_txs.append(
3✔
259
                    MultiSendTx(
260
                        MultiSendOperation.CALL, safe_tx.to, safe_tx.value, safe_tx.data
261
                    )
262
                )
263

264
        if len(multisend_txs) > 1:
3✔
265
            safe_tx = SafeTx(
×
266
                self.ethereum_client,
267
                self.address,
268
                multisend.address,
269
                0,
270
                multisend.build_tx_data(multisend_txs),
271
                SafeOperationEnum.DELEGATE_CALL.value,
272
                0,
273
                0,
274
                0,
275
                None,
276
                None,
277
                safe_nonce=safe_nonce,
278
            )
279
        else:
280
            safe_tx.safe_tx_gas = 0
3✔
281
            safe_tx.base_gas = 0
3✔
282
            safe_tx.gas_price = 0
3✔
283
            safe_tx.signatures = b""
3✔
284
            safe_tx.safe_nonce = safe_nonce  # Resend single transaction
3✔
285
        safe_tx = self.sign_transaction(safe_tx)
3✔
286
        if not safe_tx.signatures:
3✔
287
            print_formatted_text(
×
288
                HTML("<ansired>At least one owner must be loaded</ansired>")
289
            )
290
            return False
×
291
        else:
292
            return self.post_transaction_to_tx_service(safe_tx)
3✔
293

294
    def execute_tx(self, safe_tx_hash: Sequence[bytes]) -> bool:
3✔
295
        """
296
        Submit transaction on the tx-service to blockchain
297

298
        :return:
299
        """
300
        safe_tx, tx_hash = self.safe_tx_service.get_safe_transaction(safe_tx_hash)
×
301
        if tx_hash:
×
302
            print_formatted_text(
×
303
                HTML(
304
                    f"<ansired>Tx with safe-tx-hash {safe_tx_hash.hex()} "
305
                    f"has already been executed on {tx_hash.hex()}</ansired>"
306
                )
307
            )
308
        elif len(safe_tx.signers) < self.safe_cli_info.threshold:
×
309
            print_formatted_text(
×
310
                HTML(
311
                    f"<ansired>Number of signatures {len(safe_tx.signers)} "
312
                    f"must reach the threshold {self.safe_cli_info.threshold}</ansired>"
313
                )
314
            )
315
        else:
316
            if executed := self.execute_safe_transaction(safe_tx):
×
317
                self.refresh_safe_cli_info()
×
318
            return executed
×
319

320
    def get_balances(self):
3✔
321
        balances = self.safe_tx_service.get_balances(self.address)
3✔
322
        headers = ["name", "balance", "symbol", "decimals", "tokenAddress"]
3✔
323
        rows = []
3✔
324
        for balance in balances:
3✔
325
            if balance["tokenAddress"]:  # Token
3✔
326
                row = [
3✔
327
                    balance["token"]["name"],
328
                    f"{int(balance['balance']) / 10 ** int(balance['token']['decimals']):.5f}",
329
                    balance["token"]["symbol"],
330
                    balance["token"]["decimals"],
331
                    balance["tokenAddress"],
332
                ]
333
            else:  # Ether
334
                row = [
3✔
335
                    "ETHER",
336
                    f"{int(balance['balance']) / 10 ** 18:.5f}",
337
                    "Ξ",
338
                    18,
339
                    "",
340
                ]
341
            rows.append(row)
3✔
342
        print(tabulate(rows, headers=headers))
3✔
343
        return rows
3✔
344

345
    def get_transaction_history(self):
3✔
346
        transactions = self.safe_tx_service.get_transactions(self.address)
3✔
347
        headers = ["nonce", "to", "value", "transactionHash", "safeTxHash"]
3✔
348
        rows = []
3✔
349
        last_executed_tx = False
3✔
350
        for transaction in transactions:
3✔
351
            row = [transaction[header] for header in headers]
3✔
352
            data_decoded: Dict[str, Any] = transaction.get("dataDecoded")
3✔
353
            if data_decoded:
3✔
354
                row.append(self.safe_tx_service.data_decoded_to_text(data_decoded))
3✔
355
            if transaction["transactionHash"]:
3✔
356
                if not transaction["isSuccessful"]:
3✔
357
                    # Transaction failed
358
                    row[0] = Fore.RED + str(row[0])
×
359
                else:
360
                    row[0] = Fore.GREEN + str(
3✔
361
                        row[0]
362
                    )  # For executed transactions we use green
363
                    if not last_executed_tx:
3✔
364
                        row[0] = Style.BRIGHT + row[0]
3✔
365
                        last_executed_tx = True
3✔
366
            else:
367
                row[0] = Fore.YELLOW + str(
×
368
                    row[0]
369
                )  # For non executed transactions we use yellow
370

371
            row[0] = Style.RESET_ALL + row[0]  # Reset all just in case
3✔
372
            rows.append(row)
3✔
373

374
        headers.append("dataDecoded")
3✔
375
        headers[0] = Style.BRIGHT + headers[0]
3✔
376
        print(tabulate(rows, headers=headers))
3✔
377
        return rows
3✔
378

379
    def prepare_and_execute_safe_transaction(
3✔
380
        self,
381
        to: str,
382
        value: int,
383
        data: bytes,
384
        operation: SafeOperationEnum = SafeOperationEnum.CALL,
385
        safe_nonce: Optional[int] = None,
386
    ) -> bool:
387
        safe_tx = self.prepare_safe_transaction(
×
388
            to, value, data, operation, safe_nonce=safe_nonce
389
        )
390
        return self.post_transaction_to_tx_service(safe_tx)
×
391

392
    def post_transaction_to_tx_service(self, safe_tx: SafeTx) -> bool:
3✔
393
        if not yes_or_no_question(
3✔
394
            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) "
395
            + str(safe_tx)
396
        ):
397
            return False
×
398

399
        self.safe_tx_service.post_transaction(safe_tx)
3✔
400
        print_formatted_text(
3✔
401
            HTML(
402
                f"<ansigreen>Tx with safe-tx-hash={safe_tx.safe_tx_hash.hex()} was sent to Safe Transaction service</ansigreen>"
403
            )
404
        )
405
        return True
3✔
406

407
    def get_permitted_signers(self) -> Set[ChecksumAddress]:
3✔
408
        """
409
        :return: Owners and delegates, as they also can sign a transaction for the tx service
410
        """
411
        owners = super().get_permitted_signers()
3✔
412
        owners.update(
3✔
413
            [
414
                row["delegate"]
415
                for row in self.safe_tx_service.get_delegates(self.address)
416
            ]
417
        )
418
        return owners
3✔
419

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

465
    def search_account(
3✔
466
        self, address: ChecksumAddress
467
    ) -> Optional[Union[LocalAccount, HwWallet]]:
468
        """
469
        Search the provided address between loaded owners
470

471
        :param address:
472
        :return: LocalAccount or HwWallet of the provided address
473
        """
474
        for account in chain(self.accounts, self.hw_wallet_manager.wallets):
3✔
475
            if account.address == address:
3✔
476
                return account
3✔
477

478
    def remove_proposed_transaction(self, safe_tx_hash: bytes):
3✔
479
        eip712_message = get_remove_transaction_message(
3✔
480
            self.address, safe_tx_hash, self.ethereum_client.get_chain_id()
481
        )
482
        message_hash = eip712_encode_hash(eip712_message)
3✔
483
        try:
3✔
484
            safe_tx, _ = self.safe_tx_service.get_safe_transaction(safe_tx_hash)
3✔
485
            signer = self.search_account(safe_tx.proposer)
3✔
486
            if not signer:
3✔
487
                print_formatted_text(
3✔
488
                    HTML(
489
                        f"<ansired>The proposer with address: {safe_tx.proposer} was not loaded</ansired>"
490
                    )
491
                )
492
                return False
3✔
493

494
            if isinstance(signer, LocalAccount):
3✔
495
                signature = signer.signHash(message_hash).signature
3✔
496
            else:
497
                signature = self.hw_wallet_manager.sign_eip712(
×
498
                    eip712_message, [signer]
499
                )[0].signature
500

501
            if len(safe_tx.signers) >= self.safe.retrieve_threshold():
3✔
502
                print_formatted_text(
×
503
                    HTML(
504
                        "<ansired>The transaction has all the required signatures to be executed!!!\n"
505
                        "This means that the transaction can be executed by a 3rd party monitoring your Safe even after removal!\n"
506
                        f"Make sure you execute a transaction with nonce {safe_tx.safe_nonce} to void the current transaction"
507
                        "</ansired>"
508
                    )
509
                )
510

511
            if not yes_or_no_question(
3✔
512
                f"Do you want to remove the tx with safe-tx-hash={safe_tx.safe_tx_hash.hex()}"
513
            ):
514
                return False
×
515

516
            self.safe_tx_service.delete_transaction(safe_tx_hash.hex(), signature.hex())
3✔
517
            print_formatted_text(
3✔
518
                HTML(
519
                    f"<ansigreen>Transaction {safe_tx_hash.hex()} was removed correctly</ansigreen>"
520
                )
521
            )
522
            return True
3✔
523
        except SafeAPIException as e:
×
524
            print_formatted_text(
×
525
                HTML(f"<ansired>Transaction wasn't removed due an error: {e}</ansired>")
526
            )
527
            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