Skip to content

Commit cf06b2d

Browse files
authored
feat(cli,sdk): add support for "queue scripts" (#95)
* refactor(MultiSend): encode data directly instead of using `txn` arg * refactor(MultiSend): remove unreachable checks * feat(SDK): add `.cli` module w/ `propose_batch` decorator * feat(CLI): add `pending ensure` command to process "queue scripts" * docs(SDK,Userguide): add docs & userguide for `propose_from_simulation` * fix(CLI): ensure full range of set * fix(SDK): add additional check of nonce script name being numeric * refactor(CLI): add prompt to `--submitter` option for queue script/`ensure` * refactor(CLI): raise UsageError for errors in `pending ensure` cmd * docs(SDK): add better docs for how to use args in queue scripts
1 parent 4c49f44 commit cf06b2d

File tree

7 files changed

+359
-12
lines changed

7 files changed

+359
-12
lines changed

ape_safe/_cli/pending.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import runpy
12
from collections.abc import Sequence
23
from typing import TYPE_CHECKING, Optional, Union, cast
34

45
import click
56
import rich
6-
from ape.cli import ConnectedProviderCommand
7+
from ape.cli import ConnectedProviderCommand, account_option
78
from ape.exceptions import SignatureError
89
from eth_typing import ChecksumAddress, Hash32
910
from eth_utils import humanize_hash, to_hex
@@ -404,3 +405,80 @@ def _filter_tx_from_ids(
404405
return [x for x in txn_ids if x != txn.safe_tx_hash]
405406

406407
return txn_ids
408+
409+
410+
@pending.command(cls=ConnectedProviderCommand)
411+
@safe_cli_ctx()
412+
@account_option("--submitter", prompt="Select an account to submit or propose transaction(s)")
413+
@safe_option
414+
def ensure(cli_ctx, ecosystem, network, submitter, safe):
415+
"""
416+
Ensure Safe API queue matches `scripts/`
417+
418+
This command uses scripts from `scripts/<ecosystem-name>_<network-name>_nonce*.py`
419+
(or `scripts/nonce*.py` if no network-specified scripts detected) and "replays" them from the
420+
Safe's current on-chain nonce, up to the last script available in the folder. When executed
421+
using a fork network, it will only perform a simulated validation of the script. When executed
422+
using a live network, it will propose the transaction ONLY IF the transaction at that nonce
423+
height does not match the expected calldata.
424+
425+
To enable this, scripts under `scripts/` must be properly named, and all use
426+
`ape_safe.cli.propose_batch`.
427+
"""
428+
429+
if scripts_to_ensure := list(
430+
cli_ctx.local_project.scripts_folder.glob(
431+
f"{ecosystem.name}_{network.name.rstrip('-fork')}_nonce*.py"
432+
)
433+
):
434+
scripts_found_str = "\n".join(
435+
str(fs.relative_to(cli_ctx.local_project.scripts_folder)) for fs in scripts_to_ensure
436+
)
437+
cli_ctx.logger.debug(f"Network-specific scripts found:\n{scripts_found_str}")
438+
439+
elif scripts_to_ensure := list(cli_ctx.local_project.scripts_folder.glob("nonce*.py")):
440+
scripts_found_str = "\n".join(
441+
str(fs.relative_to(cli_ctx.local_project.scripts_folder)) for fs in scripts_to_ensure
442+
)
443+
cli_ctx.logger.debug(f"Scripts found:\n{scripts_found_str}")
444+
445+
else:
446+
raise click.UsageError("No queue scripts detected under `scripts/`.")
447+
448+
starting_nonce = safe.next_nonce
449+
if not (
450+
pending_scripts := {
451+
nonce: script_path
452+
for script_path in scripts_to_ensure
453+
if "nonce" in script_path.stem
454+
and (nonce := int(script_path.stem.split("nonce")[-1])) >= starting_nonce
455+
}
456+
):
457+
raise click.UsageError(
458+
"No queue scripts detected under `scripts/` above current Safe nonce."
459+
)
460+
461+
elif min(pending_scripts) != starting_nonce:
462+
raise click.UsageError(
463+
f"Next nonce for {safe.address} is {starting_nonce}, not {min(pending_scripts)}."
464+
)
465+
466+
elif missing_nonces := sorted(
467+
set(range(starting_nonce, max(pending_scripts) + 1)) - set(pending_scripts)
468+
):
469+
display_str = ", ".join(map(str, missing_nonces))
470+
raise click.UsageError(f"Missing queue scripts for nonce(s): {display_str}")
471+
472+
for nonce in sorted(pending_scripts):
473+
script_path = pending_scripts[nonce].relative_to(cli_ctx.local_project.path)
474+
if not (cmd := runpy.run_path(str(script_path), run_name=script_path.stem).get("cli")):
475+
raise click.UsageError(f"No command `cli` detected in {script_path}.")
476+
477+
cli_ctx.logger.info(
478+
f"Running queue script for nonce {nonce} ('{script_path}'):\n\n {cmd.help}\n"
479+
)
480+
# NOTE: This matches signature from `ape_safe.cli:propose_batch`
481+
cmd.callback.__wrapped__.func(cli_ctx, network, submitter, safe)
482+
483+
if not network.is_fork:
484+
cli_ctx.logger.success(f"Queue for {safe.address} up-to-date!")

ape_safe/cli.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import inspect
2+
from typing import TYPE_CHECKING, Callable
3+
4+
import click
5+
from ape.api.accounts import ImpersonatedAccount
6+
from ape.cli import (
7+
ApeCliContextObject,
8+
ConnectedProviderCommand,
9+
account_option,
10+
ape_cli_context,
11+
network_option,
12+
)
13+
14+
from ape_safe._cli.click_ext import safe_argument
15+
16+
if TYPE_CHECKING:
17+
from ape.api import AccountAPI, NetworkAPI
18+
19+
from ape_safe.accounts import SafeAccount
20+
21+
22+
def propose_from_simulation():
23+
"""
24+
Create and propose a new SafeTx from transaction receipts inside a fork.
25+
26+
Usage::
27+
28+
@propose_from_simulation()
29+
# NOTE: Name of decorated function *must* be called `cli`
30+
# NOTE: Decorated function may have `safe` or `submitter` args in it
31+
def cli():
32+
# This entire function is executed within a fork/isolated context
33+
# Use normal Ape features
34+
my_contract = Contract("<address>")
35+
36+
# Any transaction is performed as if `sender=safe.address`
37+
my_contract.mutableMethod(...)
38+
39+
# You can make assertions that will cause your simulation to fail if tripped
40+
assert my_contract.viewMethod() == ...
41+
42+
# You can also add conditional calls
43+
if my_contract.viewMethod() < some_number:
44+
my_contract.mutableMethod()
45+
46+
# Once you are done with your transactions, the simulation will complete after exiting
47+
48+
49+
# Once the simulation is complete, the decorator will collect all receipts it finds
50+
# from the `safe` and collect them into a single SafeTx to propose to a public network
51+
"""
52+
53+
def decorator(
54+
cmd: (
55+
Callable[[], None]
56+
| Callable[["SafeAccount"], None]
57+
| Callable[["SafeAccount", "AccountAPI"], None]
58+
),
59+
) -> click.Command:
60+
args: dict = {}
61+
parameters = inspect.signature(cmd).parameters
62+
if "safe" in parameters:
63+
args["safe"] = None
64+
65+
if "submitter" in parameters:
66+
args["submitter"] = None
67+
68+
if (script_name := cmd.__module__.split(".")[-1]).startswith("nonce"):
69+
if not script_name[5:].isnumeric():
70+
raise click.UsageError(
71+
f"Script 'scripts/{script_name}.py' must follow 'nonce<N>.py', "
72+
"where <N> is convertible to an integer value"
73+
)
74+
75+
script_nonce = int(script_name[5:])
76+
77+
else:
78+
script_nonce = None # Auto-determine nonce later
79+
80+
@click.command(cls=ConnectedProviderCommand, name=cmd.__module__)
81+
@ape_cli_context()
82+
@network_option()
83+
@account_option(
84+
"--submitter",
85+
prompt="Select an account to submit or propose transaction(s)",
86+
)
87+
@safe_argument
88+
def new_cmd(
89+
cli_ctx: ApeCliContextObject,
90+
network: "NetworkAPI",
91+
submitter: "AccountAPI",
92+
safe: "SafeAccount",
93+
):
94+
if "safe" in args:
95+
args["safe"] = safe
96+
if "submitter" in args:
97+
args["submitter"] = submitter
98+
99+
# TODO: Use name of script to determine nonce? If starts with `nonce<XXX>.py`
100+
batch = safe.create_batch()
101+
total_gas_used = 0
102+
103+
with (
104+
cli_ctx.chain_manager.isolate()
105+
if network.is_fork
106+
else cli_ctx.network_manager.fork()
107+
):
108+
with cli_ctx.account_manager.use_sender(
109+
# NOTE: Use impersonated account to skip processing w/ `SafeAccount`
110+
ImpersonatedAccount(raw_address=safe.address)
111+
):
112+
# NOTE: Make sure the safe has money for gas
113+
if not safe.balance:
114+
safe.balance += int(1e20)
115+
116+
cmd(*args.values()) # NOTE: 1 or more receipts should be mined from calling cmd
117+
118+
cli_ctx.logger.success(
119+
"Simulation complete, collecting receipts into batch to propose"
120+
)
121+
122+
for txn in safe.history.sessional:
123+
if txn.sender != safe.address:
124+
raise RuntimeError("Don't execute other transactions!")
125+
126+
elif txn.failed:
127+
raise RuntimeError("Transaction failed!")
128+
129+
total_gas_used += txn.gas_used
130+
batch.add_from_receipt(txn)
131+
132+
# NOTE: After here, we are exiting the isolation context (either fork or rollback)
133+
134+
cli_ctx.logger.info(
135+
f"Collected {len(batch.calls)} receipts into batch (gas used: {total_gas_used})"
136+
)
137+
138+
if len(batch.calls) > 1:
139+
safe_tx = batch.as_safe_tx(nonce=script_nonce)
140+
141+
else: # When only one transaction receipt exits, just directly call that
142+
cli_ctx.logger.info("Only 1 call found, calling directly instead of MultiSend")
143+
txn = batch.calls[0]
144+
safe_tx = safe.create_safe_tx(
145+
to=txn["target"], value=txn["value"], data=txn["callData"]
146+
)
147+
148+
if network.is_fork: # Testing, execute as a simulation (don't set nonce)
149+
cli_ctx.logger.info("Using fork network, dry-running SafeTx")
150+
safe.create_execute_transaction(safe_tx, {}, impersonate=True, submitter=submitter)
151+
152+
elif not (confirmations := safe.get_api_confirmations(safe_tx)):
153+
# Real mainnet, propose if not already in queue
154+
cli_ctx.logger.info("Using public network, proposing SafeTx to Safe API")
155+
safe.propose_safe_tx(safe_tx, submitter=submitter)
156+
157+
safe_tx_id = safe_tx._message_hash_
158+
cli_ctx.logger.success("'{safe_tx_id.to_0x_hex()}' proposed to queue")
159+
160+
else:
161+
safe_tx_id = safe_tx._message_hash_
162+
cli_ctx.logger.info(
163+
f"SafeTxID '{safe_tx_id.to_0x_hex()}' already in queue "
164+
f"({len(confirmations)}/{safe.confirmations_required})"
165+
)
166+
167+
new_cmd.help = cmd.__doc__
168+
return new_cmd
169+
170+
return decorator

ape_safe/multisend.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,9 @@ def as_safe_tx(self, safe: Optional[SafeAccount] = None, **safe_tx_kwargs) -> "S
209209
if not (safe or (safe := self.safe)):
210210
raise ValueError("Must provide `safe=` to call this function")
211211

212-
elif not isinstance(safe, SafeAccount):
213-
raise ValueError("`safe=` must be a SafeAccount to use Multisend")
214-
215212
return safe.create_safe_tx(
216-
self.handler.as_transaction(b"".join(self.encoded_calls)),
213+
to=self.contract.address,
214+
data=self.handler.encode_input(b"".join(self.encoded_calls)),
217215
operation=OperationType.DELEGATECALL,
218216
**safe_tx_kwargs,
219217
)
@@ -226,9 +224,6 @@ def propose(
226224
if not (safe or (safe := self.safe)):
227225
raise ValueError("Must provide `safe=` to call this function")
228226

229-
elif not isinstance(safe, SafeAccount):
230-
raise ValueError("`safe=` must be a SafeAccount to use Multisend")
231-
232227
submitter = safe_tx_kwargs.pop("submitter", None)
233228
sigs_by_signer = safe_tx_kwargs.pop("sigs_by_signer", None)
234229
safe_tx = self.as_safe_tx(safe, **safe_tx_kwargs)
@@ -262,9 +257,6 @@ def as_transaction(
262257
if not (safe or (safe := self.safe)):
263258
raise ValueError("Must provide `safe=` to call this function")
264259

265-
elif not isinstance(safe, SafeAccount):
266-
raise ValueError("`safe=` must be a SafeAccount to use Multisend")
267-
268260
safe_tx_kwargs = {}
269261
for field in safe.safe_tx_def.__annotations__:
270262
if field in txn_kwargs:

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- safe_management
66
- multisend
77
- modules
8+
- production
89
:commands:
910
- mgmt
1011
- pending

docs/methoddocs/cli.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
```{eval-rst}
2+
.. automodule:: ape_safe.cli
3+
:members:
4+
```

0 commit comments

Comments
 (0)