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

safe-global / safe-cli / 9667498448

25 Jun 2024 06:10PM UTC coverage: 87.46% (+0.2%) from 87.279%
9667498448

Pull #419

github

web-flow
Merge f9b5250e7 into a9427b222
Pull Request #419: Adding support for executing transactions in scripting mode

750 of 869 branches covered (86.31%)

Branch coverage included in aggregate %.

288 of 316 new or added lines in 7 files covered. (91.14%)

6 existing lines in 2 files now uncovered.

2549 of 2903 relevant lines covered (87.81%)

3.51 hits per line

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

75.0
/src/safe_cli/main.py
1
#!/bin/env python3
2
import os
4✔
3
import sys
4✔
4
from typing import Annotated, List
4✔
5

6
import typer
4✔
7
from art import text2art
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 typer.main import get_command, get_command_name
4✔
12
from web3 import Web3
4✔
13

14
from . import VERSION
4✔
15
from .argparse_validators import check_hex_str
4✔
16
from .operators import SafeOperator
4✔
17
from .safe_cli import SafeCli
4✔
18
from .typer_validators import (
4✔
19
    ChecksumAddressParser,
20
    HexBytesParser,
21
    check_ethereum_address,
22
    check_private_keys,
23
)
24
from .utils import get_safe_from_owner
4✔
25

26
app = typer.Typer(name="Safe CLI")
4✔
27

28

29
def _build_safe_operator_and_load_keys(
4✔
30
    safe_address: ChecksumAddress,
31
    node_url: str,
32
    private_keys: List[str],
33
    interactive: bool,
34
) -> SafeOperator:
35
    safe_operator = SafeOperator(safe_address, node_url, interactive=interactive)
4✔
36
    safe_operator.load_cli_owners(private_keys)
4✔
37
    return safe_operator
4✔
38

39

40
def _check_interactive_mode(interactive_mode: bool) -> bool:
4✔
41
    print(interactive_mode, os.getenv("SAFE_CLI_INTERACTIVE"))
4✔
42
    if not interactive_mode:
4✔
43
        return False
4✔
44

45
    # --no-interactive arg > env var.
46
    env_var = os.getenv("SAFE_CLI_INTERACTIVE")
4✔
47
    if env_var:
4✔
NEW
48
        return env_var.lower() in ("true", "1", "yes")
×
49

50
    return True
4✔
51

52

53
@app.command()
4✔
54
def send_ether(
4✔
55
    safe_address: Annotated[
56
        ChecksumAddress,
57
        typer.Argument(
58
            help="The address of the Safe.",
59
            callback=check_ethereum_address,
60
            click_type=ChecksumAddressParser(),
61
            show_default=False,
62
        ),
63
    ],
64
    node_url: Annotated[
65
        str, typer.Argument(help="Ethereum node url.", show_default=False)
66
    ],
67
    to: Annotated[
68
        ChecksumAddress,
69
        typer.Argument(
70
            help="The address of destination.",
71
            callback=check_ethereum_address,
72
            click_type=ChecksumAddressParser(),
73
            show_default=False,
74
        ),
75
    ],
76
    value: Annotated[
77
        int, typer.Argument(help="Amount of ether in wei to send.", show_default=False)
78
    ],
79
    private_key: Annotated[
80
        List[str],
81
        typer.Option(
82
            help="List of private keys of signers.",
83
            rich_help_panel="Optional Arguments",
84
            show_default=False,
85
            callback=check_private_keys,
86
        ),
87
    ] = None,
88
    safe_nonce: Annotated[
89
        int,
90
        typer.Option(
91
            help="Force nonce for tx_sender",
92
            rich_help_panel="Optional Arguments",
93
            show_default=False,
94
        ),
95
    ] = None,
96
    interactive: Annotated[
97
        bool,
98
        typer.Option(
99
            help="Request iteration from the user. Use --non-interactive for unattended execution.",
100
            rich_help_panel="Optional Arguments",
101
            callback=_check_interactive_mode,
102
        ),
103
    ] = True,
104
):
105
    safe_operator = _build_safe_operator_and_load_keys(
4✔
106
        safe_address, node_url, private_key, interactive
107
    )
108
    safe_operator.send_ether(to, value, safe_nonce=safe_nonce)
4✔
109

110

111
@app.command()
4✔
112
def send_erc20(
4✔
113
    safe_address: Annotated[
114
        ChecksumAddress,
115
        typer.Argument(
116
            help="The address of the Safe.",
117
            callback=check_ethereum_address,
118
            click_type=ChecksumAddressParser(),
119
            show_default=False,
120
        ),
121
    ],
122
    node_url: Annotated[
123
        str, typer.Argument(help="Ethereum node url.", show_default=False)
124
    ],
125
    to: Annotated[
126
        ChecksumAddress,
127
        typer.Argument(
128
            help="The address of destination.",
129
            callback=check_ethereum_address,
130
            click_type=ChecksumAddressParser(),
131
            show_default=False,
132
        ),
133
    ],
134
    token_address: Annotated[
135
        ChecksumAddress,
136
        typer.Argument(
137
            help="Erc20 token address.",
138
            callback=check_ethereum_address,
139
            click_type=ChecksumAddressParser(),
140
            show_default=False,
141
        ),
142
    ],
143
    amount: Annotated[
144
        int,
145
        typer.Argument(
146
            help="Amount of erc20 tokens in wei to send.", show_default=False
147
        ),
148
    ],
149
    private_key: Annotated[
150
        List[str],
151
        typer.Option(
152
            help="List of private keys of signers.",
153
            rich_help_panel="Optional Arguments",
154
            show_default=False,
155
            callback=check_private_keys,
156
        ),
157
    ] = None,
158
    safe_nonce: Annotated[
159
        int,
160
        typer.Option(
161
            help="Force nonce for tx_sender",
162
            rich_help_panel="Optional Arguments",
163
            show_default=False,
164
        ),
165
    ] = None,
166
    interactive: Annotated[
167
        bool,
168
        typer.Option(
169
            help="Request iteration from the user. Use --non-interactive for unattended execution.",
170
            rich_help_panel="Optional Arguments",
171
            callback=_check_interactive_mode,
172
        ),
173
    ] = True,
174
):
175
    safe_operator = _build_safe_operator_and_load_keys(
4✔
176
        safe_address, node_url, private_key, interactive
177
    )
178
    safe_operator.send_erc20(to, token_address, amount, safe_nonce=safe_nonce)
4✔
179

180

181
@app.command()
4✔
182
def send_erc721(
4✔
183
    safe_address: Annotated[
184
        ChecksumAddress,
185
        typer.Argument(
186
            help="The address of the Safe.",
187
            callback=check_ethereum_address,
188
            click_type=ChecksumAddressParser(),
189
            show_default=False,
190
        ),
191
    ],
192
    node_url: Annotated[
193
        str, typer.Argument(help="Ethereum node url.", show_default=False)
194
    ],
195
    to: Annotated[
196
        ChecksumAddress,
197
        typer.Argument(
198
            help="The address of destination.",
199
            callback=check_ethereum_address,
200
            click_type=ChecksumAddressParser(),
201
            show_default=False,
202
        ),
203
    ],
204
    token_address: Annotated[
205
        ChecksumAddress,
206
        typer.Argument(
207
            help="Erc721 token address.",
208
            callback=check_ethereum_address,
209
            click_type=ChecksumAddressParser(),
210
            show_default=False,
211
        ),
212
    ],
213
    token_id: Annotated[
214
        int, typer.Argument(help="Erc721 token id.", show_default=False)
215
    ],
216
    private_key: Annotated[
217
        List[str],
218
        typer.Option(
219
            help="List of private keys of signers.",
220
            rich_help_panel="Optional Arguments",
221
            show_default=False,
222
            callback=check_private_keys,
223
        ),
224
    ] = None,
225
    safe_nonce: Annotated[
226
        int,
227
        typer.Option(
228
            help="Force nonce for tx_sender",
229
            rich_help_panel="Optional Arguments",
230
            show_default=False,
231
        ),
232
    ] = None,
233
    interactive: Annotated[
234
        bool,
235
        typer.Option(
236
            help="Request iteration from the user. Use --non-interactive for unattended execution.",
237
            rich_help_panel="Optional Arguments",
238
            callback=_check_interactive_mode,
239
        ),
240
    ] = True,
241
):
242
    safe_operator = _build_safe_operator_and_load_keys(
4✔
243
        safe_address, node_url, private_key, interactive
244
    )
245
    safe_operator.send_erc721(to, token_address, token_id, safe_nonce=safe_nonce)
4✔
246

247

248
@app.command()
4✔
249
def send_custom(
4✔
250
    safe_address: Annotated[
251
        ChecksumAddress,
252
        typer.Argument(
253
            help="The address of the Safe.",
254
            callback=check_ethereum_address,
255
            click_type=ChecksumAddressParser(),
256
            show_default=False,
257
        ),
258
    ],
259
    node_url: Annotated[
260
        str, typer.Argument(help="Ethereum node url.", show_default=False)
261
    ],
262
    to: Annotated[
263
        ChecksumAddress,
264
        typer.Argument(
265
            help="The address of destination.",
266
            callback=check_ethereum_address,
267
            click_type=ChecksumAddressParser(),
268
            show_default=False,
269
        ),
270
    ],
271
    value: Annotated[int, typer.Argument(help="Value to send.", show_default=False)],
272
    data: Annotated[
273
        HexBytes,
274
        typer.Argument(
275
            help="HexBytes data to send.",
276
            callback=check_hex_str,
277
            click_type=HexBytesParser(),
278
            show_default=False,
279
        ),
280
    ],
281
    private_key: Annotated[
282
        List[str],
283
        typer.Option(
284
            help="List of private keys of signers.",
285
            rich_help_panel="Optional Arguments",
286
            show_default=False,
287
            callback=check_private_keys,
288
        ),
289
    ] = None,
290
    safe_nonce: Annotated[
291
        int,
292
        typer.Option(
293
            help="Force nonce for tx_sender",
294
            rich_help_panel="Optional Arguments",
295
            show_default=False,
296
        ),
297
    ] = None,
298
    delegate: Annotated[
299
        bool,
300
        typer.Option(
301
            help="Use DELEGATE_CALL. By default use CALL",
302
            rich_help_panel="Optional Arguments",
303
        ),
304
    ] = False,
305
    interactive: Annotated[
306
        bool,
307
        typer.Option(
308
            help="Request iteration from the user. Use --non-interactive for unattended execution.",
309
            rich_help_panel="Optional Arguments",
310
            callback=_check_interactive_mode,
311
        ),
312
    ] = True,
313
):
314
    safe_operator = _build_safe_operator_and_load_keys(
4✔
315
        safe_address, node_url, private_key, interactive
316
    )
317
    safe_operator.send_custom(
4✔
318
        to, value, data, safe_nonce=safe_nonce, delegate_call=delegate
319
    )
320

321

322
@app.command()
4✔
323
def version():
4✔
324
    print(f"Safe Cli v{VERSION}")
4✔
325

326

327
@app.command(
4✔
328
    hidden=True,
329
    name="attended-mode",
330
    help="""
331
        safe-cli [--history] [--get-safes-from-owner] address node_url\n
332
        Examples:\n
333
            safe-cli 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n
334
            safe-cli --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n\n\n\n
335
            safe-cli --history 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n
336
            safe-cli --history --get-safes-from-owner 0x0000000000000000000000000000000000000000 https://sepolia.drpc.org\n\n\n\n
337
            safe-cli send-ether 0xsafeaddress https://sepolia.drpc.org 0xtoaddress wei-amount --private-key key1 --private-key key1 --private-key keyN [--no-interactive]\n
338
            safe-cli send-erc721 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres id --private-key key1 --private-key key2 --private-key keyN [--no-interactive]\n
339
            safe-cli send-erc20 0xsafeaddress https://sepolia.drpc.org 0xtoaddress 0xtokenaddres wei-amount --private-key key1 --private-key key2 --private-key keyN [--no-interactive]\n
340
            safe-cli send-custom 0xsafeaddress https://sepolia.drpc.org 0xtoaddress value 0xtxdata --private-key key1 --private-key key2 --private-key keyN [--no-interactive]\n\n\n\n
341
    """,
342
    epilog="Commands available in unattended mode:\n\n\n\n"
343
    + "\n\n".join(
344
        [
345
            f"  {get_command_name(command)}"
346
            for command in get_command(app).commands.keys()
347
        ]
348
    )
349
    + "\n\n\n\nUse the --help option of each command to see the usage options.",
350
)
351
def default_attended_mode(
4✔
352
    address: Annotated[
353
        ChecksumAddress,
354
        typer.Argument(
355
            help="The address of the Safe, or an owner address if --get-safes-from-owner is specified.",
356
            callback=check_ethereum_address,
357
            click_type=ChecksumAddressParser(),
358
            show_default=False,
359
        ),
360
    ],
361
    node_url: Annotated[
362
        str, typer.Argument(help="Ethereum node url.", show_default=False)
363
    ],
364
    history: Annotated[
365
        bool,
366
        typer.Option(
367
            help="Enable history. By default it's disabled due to security reasons",
368
            rich_help_panel="Optional Arguments",
369
        ),
370
    ] = False,
371
    get_safes_from_owner: Annotated[
372
        bool,
373
        typer.Option(
374
            help="Indicates that address is an owner (Safe Transaction Service is required for this feature)",
375
            rich_help_panel="Optional Arguments",
376
        ),
377
    ] = False,
378
) -> None:
379
    print_formatted_text(text2art("Safe CLI"))  # Print fancy text
4✔
380
    print_formatted_text(HTML(f"<b>Version: {VERSION}</b>"))
4✔
381

382
    if get_safes_from_owner:
4✔
383
        safe_address_listed = get_safe_from_owner(address, node_url)
4✔
NEW
384
        safe_cli = SafeCli(safe_address_listed, node_url, history)
×
385
    else:
386
        safe_cli = SafeCli(address, node_url, history)
4✔
387
    safe_cli.print_startup_info()
4✔
388
    safe_cli.loop()
4✔
389

390

391
def _is_safe_cli_default_command(arguments: List[str]) -> bool:
4✔
392
    # safe-cli
NEW
393
    if len(sys.argv) == 1:
×
NEW
394
        return True
×
395

NEW
396
    if sys.argv[1] == "--help":
×
NEW
397
        return True
×
398

399
    # Only added if is not a valid command, and it is an address. safe-cli 0xaddress http://url
NEW
400
    if sys.argv[1] not in [
×
401
        get_command_name(key) for key in get_command(app).commands.keys()
402
    ] and Web3.is_checksum_address(sys.argv[1]):
NEW
403
        return True
×
404

NEW
405
    return False
×
406

407

408
def main():
4✔
409
    # By default, the attended mode is initialised. Otherwise, the required command must be specified.
NEW
410
    if _is_safe_cli_default_command(sys.argv):
×
NEW
411
        sys.argv.insert(1, "attended-mode")
×
NEW
412
    app()
×
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